diff --git a/database-novo/manual/f6_2c_notifications_split.supabase_admin.sql b/database-novo/manual/f6_2c_notifications_split.supabase_admin.sql new file mode 100644 index 0000000..4a59306 --- /dev/null +++ b/database-novo/manual/f6_2c_notifications_split.supabase_admin.sql @@ -0,0 +1,266 @@ +-- ============================================================================= +-- F6.2 Lote C — split de notifications (tenant-local vs SaaS cross-tenant) +-- +-- ⚠️ APLICAR COMO supabase_admin (CREATE OR REPLACE de funções owned por +-- postgres E supabase_admin; superuser preserva o owner): +-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \ +-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \ +-- < database-novo/manual/f6_2c_notifications_split.supabase_admin.sql +-- +-- Neste projeto, TODAS as notificações atuais (inbound_message, session_status, +-- system_alert, new_patient) são tenant-LOCAIS (avisos cross-tenant do SaaS +-- vivem em global_notices). Então: +-- * notifications continua tenant-local → já vive no schema do tenant (F6.1) +-- * public.notifications_sistema é criado como o canal SaaS→tenant / dev +-- cross-tenant (vazio hoje; pronto pro futuro: suporte, billing, etc.) +-- Triggers de notif reescritos schema-aware; os que disparam em tabelas PUBLIC +-- (notify_on_intake, notify_on_scheduling) roteiam pro schema via EXECUTE format. +-- ============================================================================= + +BEGIN; + +-- ─────────────────────────────────────────────────────────────────────────── +-- 1) notifications_sistema (GLOBAL, cross-tenant) +-- ─────────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS public.notifications_sistema ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id uuid NOT NULL, -- destinatário (user do tenant OU dev) + tenant_id uuid REFERENCES public.tenants(id) ON DELETE CASCADE, -- contexto (nullable: alerta global) + type text NOT NULL, + ref_id uuid, + ref_table text, + payload jsonb, + read_at timestamptz, + archived boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS notif_sistema_owner_idx ON public.notifications_sistema (owner_id, created_at DESC) WHERE archived = false; + +ALTER TABLE public.notifications_sistema ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS notif_sistema_owner ON public.notifications_sistema; +CREATE POLICY notif_sistema_owner ON public.notifications_sistema + FOR ALL TO authenticated USING (owner_id = auth.uid()) WITH CHECK (owner_id = auth.uid()); + +-- realtime +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_publication_tables WHERE pubname='supabase_realtime' AND schemaname='public' AND tablename='notifications_sistema') THEN + ALTER PUBLICATION supabase_realtime ADD TABLE public.notifications_sistema; + END IF; +END $$; + +-- helper pro futuro: emite notificação cross-tenant (dev/SaaS -> destinatário) +CREATE OR REPLACE FUNCTION public.notify_user_sistema( + p_owner_id uuid, p_type text, p_payload jsonb, + p_tenant_id uuid DEFAULT NULL, p_ref_id uuid DEFAULT NULL, p_ref_table text DEFAULT NULL) +RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +DECLARE v_id uuid; +BEGIN + INSERT INTO public.notifications_sistema (owner_id, tenant_id, type, ref_id, ref_table, payload) + VALUES (p_owner_id, p_tenant_id, p_type, p_ref_id, p_ref_table, p_payload) + RETURNING id INTO v_id; + RETURN v_id; +END $$; + +-- ─────────────────────────────────────────────────────────────────────────── +-- 2) Rewrites dos triggers de notif (tenant-local) — schema-aware +-- ─────────────────────────────────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION public.notify_on_session_status() +RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +DECLARE v_nome text; +BEGIN + IF NEW.status IN ('faltou','cancelado') AND OLD.status IS DISTINCT FROM NEW.status THEN + PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true); + SELECT nome_completo INTO v_nome FROM patients WHERE id = NEW.patient_id LIMIT 1; + INSERT INTO notifications (owner_id, type, ref_id, ref_table, payload) + VALUES (NEW.owner_id, 'session_status', NEW.id, 'agenda_eventos', + jsonb_build_object( + 'title', CASE WHEN NEW.status='faltou' THEN 'Paciente faltou' ELSE 'Sessão cancelada' END, + 'detail', COALESCE(v_nome,'Paciente') || ' — ' || to_char(NEW.inicio_em,'DD/MM HH24:MI'), + 'deeplink', '/therapist/agenda', + 'avatar_initials', upper(left(COALESCE(v_nome,'?'),2)))); + END IF; + RETURN NEW; +END $$; + +CREATE OR REPLACE FUNCTION public.fanout_inbound_message_to_notifications() +RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +DECLARE + v_target_user uuid; v_title text; v_detail text; v_initials text; v_deeplink text; + v_patient_name text; v_payload jsonb; v_tenant uuid; +BEGIN + IF NEW.direction <> 'inbound' THEN RETURN NEW; END IF; + v_tenant := public.tenant_id_for_schema(TG_TABLE_SCHEMA); + PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true); + + IF NEW.patient_id IS NOT NULL THEN + SELECT nome_completo INTO v_patient_name FROM patients WHERE id = NEW.patient_id; + END IF; + v_title := COALESCE(v_patient_name, NEW.from_number, 'Desconhecido'); + v_detail := COALESCE(left(NEW.body, 100), '[mensagem sem texto]'); + IF v_patient_name IS NOT NULL THEN + v_initials := upper(left(v_patient_name,1)) || COALESCE(upper(left(split_part(v_patient_name,' ',2),1)),''); + ELSE v_initials := '?'; END IF; + v_deeplink := '/admin/conversas'; + v_payload := jsonb_build_object('title',v_title,'detail',v_detail,'avatar_initials',v_initials, + 'deeplink',v_deeplink,'channel',NEW.channel,'conversation_message_id',NEW.id, + 'patient_id',NEW.patient_id,'from_number',NEW.from_number); + + -- destinatário: responsável do paciente (tenant_members é GLOBAL) + IF NEW.patient_id IS NOT NULL THEN + SELECT tm.user_id INTO v_target_user + FROM patients p JOIN public.tenant_members tm ON tm.id = p.responsible_member_id + WHERE p.id = NEW.patient_id AND tm.status = 'active' LIMIT 1; + IF v_target_user IS NOT NULL THEN + INSERT INTO notifications (owner_id, type, ref_id, ref_table, payload) + VALUES (v_target_user, 'inbound_message', NULL, 'conversation_messages', v_payload); + RETURN NEW; + END IF; + END IF; + -- fallback: fan-out pros admins/therapists ativos do tenant (global) + INSERT INTO notifications (owner_id, type, ref_id, ref_table, payload) + SELECT tm.user_id, 'inbound_message', NULL, 'conversation_messages', v_payload + FROM public.tenant_members tm + WHERE tm.tenant_id = v_tenant AND tm.status = 'active' + AND tm.role IN ('clinic_admin','tenant_admin','therapist'); + RETURN NEW; +END $$; + +-- helper de cancelamento: notification_queue é tenant; herda search_path do trigger chamador +CREATE OR REPLACE FUNCTION public.cancel_patient_pending_notifications(p_patient_id uuid, p_channel text DEFAULT NULL, p_evento_id uuid DEFAULT NULL) +RETURNS integer LANGUAGE plpgsql SECURITY DEFINER +AS $$ +DECLARE v_canceled integer; +BEGIN + UPDATE notification_queue SET status='cancelado', updated_at=now() + WHERE patient_id = p_patient_id AND status IN ('pendente','processando') + AND (p_channel IS NULL OR channel = p_channel) + AND (p_evento_id IS NULL OR agenda_evento_id = p_evento_id); + GET DIAGNOSTICS v_canceled = ROW_COUNT; + RETURN v_canceled; +END $$; + +CREATE OR REPLACE FUNCTION public.cancel_notifications_on_opt_out() +RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +BEGIN + PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true); + IF OLD.whatsapp_opt_in = true AND NEW.whatsapp_opt_in = false THEN + PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, 'whatsapp'); + END IF; + IF OLD.email_opt_in = true AND NEW.email_opt_in = false THEN + PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, 'email'); + END IF; + IF OLD.sms_opt_in = true AND NEW.sms_opt_in = false THEN + PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, 'sms'); + END IF; + RETURN NEW; +END $$; + +CREATE OR REPLACE FUNCTION public.cancel_notifications_on_session_cancel() +RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +BEGIN + IF NEW.status = 'cancelado' AND OLD.status <> 'cancelado' THEN + PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true); + PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, NULL, NEW.id); + END IF; + RETURN NEW; +END $$; + +-- ─────────────────────────────────────────────────────────────────────────── +-- 3) Triggers que disparam em tabelas PUBLIC (intake/scheduling, F1b) — +-- roteiam a notificação pro schema do tenant via EXECUTE format +-- ─────────────────────────────────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION public.notify_on_intake() +RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +DECLARE v_schema text; +BEGIN + IF NEW.status = 'new' THEN + v_schema := public.tenant_schema_for(NEW.tenant_id); + IF v_schema IS NULL THEN RETURN NEW; END IF; + EXECUTE format('INSERT INTO %I.notifications (owner_id, type, ref_id, ref_table, payload) VALUES ($1,$2,$3,$4,$5)', v_schema) + USING NEW.owner_id, 'new_patient', NEW.id, 'patient_intake_requests', + jsonb_build_object('title','Novo cadastro externo','detail',COALESCE(NEW.nome_completo,'Paciente'), + 'deeplink','/therapist/patients/cadastro/recebidos','avatar_initials',upper(left(COALESCE(NEW.nome_completo,'?'),2))); + END IF; + RETURN NEW; +END $$; + +CREATE OR REPLACE FUNCTION public.notify_on_scheduling() +RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +DECLARE v_schema text; +BEGIN + IF NEW.status = 'pendente' THEN + v_schema := public.tenant_schema_for(NEW.tenant_id); + IF v_schema IS NULL THEN RETURN NEW; END IF; + EXECUTE format('INSERT INTO %I.notifications (owner_id, type, ref_id, ref_table, payload) VALUES ($1,$2,$3,$4,$5)', v_schema) + USING NEW.owner_id, 'new_scheduling', NEW.id, 'agendador_solicitacoes', + jsonb_build_object('title','Nova solicitação de agendamento', + 'detail', COALESCE(NEW.paciente_nome,'Paciente') || ' ' || COALESCE(NEW.paciente_sobrenome,'') || ' — ' || COALESCE(NEW.tipo,''), + 'deeplink','/therapist/agendamentos-recebidos', + 'avatar_initials', upper(left(COALESCE(NEW.paciente_nome,'?'),1) || left(COALESCE(NEW.paciente_sobrenome,''),1))); + END IF; + RETURN NEW; +END $$; + +-- ─────────────────────────────────────────────────────────────────────────── +-- 4) Detach dos notif-triggers tenant de public + attach nos schemas (estende +-- attach_schema_aware_triggers com os 5 triggers de notif tenant) +-- ─────────────────────────────────────────────────────────────────────────── +DO $$ +DECLARE + aware text[] := ARRAY['notify_on_session_status','fanout_inbound_message_to_notifications', + 'cancel_notifications_on_opt_out','cancel_notifications_on_session_cancel']; + r record; +BEGIN + FOR r IN + SELECT c.relname AS tab, t.tgname FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid + JOIN pg_namespace n ON n.oid=c.relnamespace JOIN pg_proc p ON p.oid=t.tgfoid + WHERE n.nspname='public' AND NOT t.tgisinternal AND p.proname = ANY(aware) + AND c.relname IN (SELECT table_name FROM information_schema.tables WHERE table_schema='_tenant_template' AND table_type='BASE TABLE') + LOOP + EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', r.tgname, r.tab); + END LOOP; +END $$; + +CREATE OR REPLACE FUNCTION public.attach_notif_triggers(p_schema text) +RETURNS int LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +DECLARE + specs jsonb := jsonb_build_array( + jsonb_build_object('tab','agenda_eventos','name','trg_notify_on_session_status','spec','AFTER UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.notify_on_session_status()'), + jsonb_build_object('tab','agenda_eventos','name','trg_cancel_notifs_on_session_cancel','spec','AFTER UPDATE ON __T__ FOR EACH ROW WHEN (new.status IS DISTINCT FROM old.status) EXECUTE FUNCTION public.cancel_notifications_on_session_cancel()'), + jsonb_build_object('tab','agenda_eventos','name','trg_agenda_status_notify','spec','AFTER UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_notify_agenda_status_change()'), + jsonb_build_object('tab','conversation_messages','name','trg_fanout_inbound_to_notifications','spec','AFTER INSERT ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fanout_inbound_message_to_notifications()'), + jsonb_build_object('tab','notification_preferences','name','trg_cancel_notifs_on_opt_out','spec','AFTER UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.cancel_notifications_on_opt_out()') + ); + el jsonb; v_count int := 0; v_target text; +BEGIN + IF p_schema NOT LIKE 'tenant\_%' THEN RAISE EXCEPTION 'schema inválido %', p_schema; END IF; + FOR el IN SELECT * FROM jsonb_array_elements(specs) LOOP + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=p_schema AND table_name=(el->>'tab')) THEN CONTINUE; END IF; + v_target := format('%I.%I', p_schema, el->>'tab'); + EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', el->>'name', v_target); + EXECUTE 'CREATE TRIGGER ' || quote_ident(el->>'name') || ' ' || replace(el->>'spec','__T__',v_target); + v_count := v_count + 1; + END LOOP; + RETURN v_count; +END $$; + +DO $$ +DECLARE r record; v int; +BEGIN + FOR r IN SELECT schema_name FROM public.tenant_schemas ORDER BY schema_name LOOP + v := public.attach_notif_triggers(r.schema_name); + RAISE NOTICE 'F6.2C %: % notif triggers', r.schema_name, v; + END LOOP; +END $$; + +COMMIT; diff --git a/src/stores/notificationStore.js b/src/stores/notificationStore.js index 8cb46c8..aadfdd0 100644 --- a/src/stores/notificationStore.js +++ b/src/stores/notificationStore.js @@ -107,83 +107,106 @@ export const useNotificationStore = defineStore('notifications', { }, actions: { + // schema-per-tenant: notificações vêm de DUAS fontes — + // • tenant_.notifications (locais: agenda, conversas, pacientes) + // • public.notifications_sistema (cross-tenant: avisos do SaaS, suporte) + // Cada item carrega _origem ('tenant' | 'sistema') p/ rotear markRead/archive. async load(ownerId) { - const { data, error } = await tenantDb().from('notifications').select('*').eq('owner_id', ownerId).eq('archived', false).order('created_at', { ascending: false }).limit(50); + const [localRes, sistemaRes] = await Promise.all([ + tenantDb().from('notifications').select('*').eq('owner_id', ownerId).eq('archived', false).order('created_at', { ascending: false }).limit(50), + supabase.from('notifications_sistema').select('*').eq('owner_id', ownerId).eq('archived', false).order('created_at', { ascending: false }).limit(50) + ]); - if (error) { - console.error('[notificationStore] load error:', error.message); - return; - } + if (localRes.error) console.error('[notificationStore] load tenant error:', localRes.error.message); + if (sistemaRes.error) console.error('[notificationStore] load sistema error:', sistemaRes.error.message); - this.items = data || []; + const local = (localRes.data || []).map((n) => ({ ...n, _origem: 'tenant' })); + const sistema = (sistemaRes.data || []).map((n) => ({ ...n, _origem: 'sistema' })); + + this.items = [...local, ...sistema] + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) + .slice(0, 50); }, subscribeRealtime(ownerId, onInsert = null) { if (this._channel) return; const tenantSchema = useTenantStore().activeTenantSchema; - if (!tenantSchema) return; - const channel = supabase - .channel(`notifications:${ownerId}`) - .on( - 'postgres_changes', - { - event: 'INSERT', - schema: tenantSchema, - table: 'notifications', - filter: `owner_id=eq.${ownerId}` - }, - (payload) => { - this.items.unshift(payload.new); - if (typeof onInsert === 'function') { - try { onInsert(payload.new); } catch { /* ignore */ } - } - } - ) - .subscribe(); + const onIns = (origem) => (payload) => { + const item = { ...payload.new, _origem: origem }; + this.items.unshift(item); + this.items.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + if (typeof onInsert === 'function') { + try { onInsert(item); } catch { /* ignore */ } + } + }; + const channel = supabase.channel(`notifications:${ownerId}`); + if (tenantSchema) { + channel.on('postgres_changes', + { event: 'INSERT', schema: tenantSchema, table: 'notifications', filter: `owner_id=eq.${ownerId}` }, + onIns('tenant')); + } + // canal cross-tenant (avisos do SaaS) — sempre escuta public + channel.on('postgres_changes', + { event: 'INSERT', schema: 'public', table: 'notifications_sistema', filter: `owner_id=eq.${ownerId}` }, + onIns('sistema')); + + channel.subscribe(); this._channel = channel; }, + // roteia pra fonte certa conforme _origem do item + _sourceFor(item) { + return item?._origem === 'sistema' + ? supabase.from('notifications_sistema') + : tenantDb().from('notifications'); + }, + async markRead(id) { + const item = this.items.find((n) => n.id === id); + if (!item) return; const now = new Date().toISOString(); - const { error } = await tenantDb().from('notifications').update({ read_at: now }).eq('id', id); + const { error } = await this._sourceFor(item).update({ read_at: now }).eq('id', id); if (error) { console.error('[notificationStore] markRead error:', error.message); return; } - - const item = this.items.find((n) => n.id === id); - if (item) item.read_at = now; + item.read_at = now; }, async markAllRead() { - const unreadIds = this.items.filter((n) => !n.read_at && !n.archived).map((n) => n.id); - - if (!unreadIds.length) return; - + const unread = this.items.filter((n) => !n.read_at && !n.archived); + if (!unread.length) return; const now = new Date().toISOString(); - const { error } = await tenantDb().from('notifications').update({ read_at: now }).in('id', unreadIds); - if (error) { - console.error('[notificationStore] markAllRead error:', error.message); + const tenantIds = unread.filter((n) => n._origem !== 'sistema').map((n) => n.id); + const sistemaIds = unread.filter((n) => n._origem === 'sistema').map((n) => n.id); + + const ops = []; + if (tenantIds.length) ops.push(tenantDb().from('notifications').update({ read_at: now }).in('id', tenantIds)); + if (sistemaIds.length) ops.push(supabase.from('notifications_sistema').update({ read_at: now }).in('id', sistemaIds)); + const results = await Promise.all(ops); + const err = results.find((r) => r.error); + if (err) { + console.error('[notificationStore] markAllRead error:', err.error.message); return; } - this.items.forEach((n) => { - if (unreadIds.includes(n.id)) n.read_at = now; + if (!n.read_at && !n.archived) n.read_at = now; }); }, async archive(id) { - const { error } = await tenantDb().from('notifications').update({ archived: true }).eq('id', id); + const item = this.items.find((n) => n.id === id); + if (!item) return; + const { error } = await this._sourceFor(item).update({ archived: true }).eq('id', id); if (error) { console.error('[notificationStore] archive error:', error.message); return; } - this.items = this.items.filter((n) => n.id !== id); },