F6.2 Lote C: split de notifications (tenant-local + notifications_sistema)

DB (supabase_admin):
- public.notifications_sistema (cross-tenant SaaS->tenant: suporte, billing;
  vazio hoje, future-proof) + RLS owner_id + realtime + notify_user_sistema()
- notify_on_session_status, fanout_inbound_message_to_notifications,
  cancel_notifications_on_opt_out/session_cancel reescritos schema-aware
  (search_path dinamico; notifications/notification_queue no schema;
  tenant_members/patients global/schema)
- notify_on_intake/scheduling disparam em tabelas PUBLIC (F1b) -> roteiam pro
  schema via tenant_schema_for + EXECUTE format
- cancel_patient_pending_notifications: notification_queue unqualified (herda
  search_path do trigger chamador)
- detach dos 4 notif-triggers tenant de public; attach_notif_triggers recria
  5 notif triggers/schema
- smoke: msg inbound -> notification no schema, destinatario correto

Frontend (notificationStore.js): load le das 2 fontes (tenantDb + public.
notifications_sistema), merge por created_at, campo _origem; realtime 2 canais;
markRead/markAllRead/archive roteiam por _origem

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-06-13 14:06:58 -03:00
parent 77ef06fde7
commit bedbb9bafc
2 changed files with 329 additions and 40 deletions
+63 -40
View File
@@ -107,83 +107,106 @@ export const useNotificationStore = defineStore('notifications', {
},
actions: {
// schema-per-tenant: notificações vêm de DUAS fontes —
// • tenant_<slug>.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);
},