F3 schema-per-tenant: frontend usa tenantDb() pra tabelas tenant

- useTenantDb composable + lib/supabase/tenantClient (tenantDb/tenantSchemaName)
- tenantStore: getters activeTenantSlug/activeTenantSchema; my_tenants() RPC
  passa a devolver slug+nome (migration 07)
- codemod scripts/codemod-tenant-db.py: supabase.from('<84 tabelas + 6 views
  tenant>') -> tenantDb().from(...) em 139 arquivos (777 chamadas), remove
  .eq('tenant_id') das cadeias tenant (173)
- passada manual (4 agentes): remove tenant_id de payloads insert/upsert/update,
  selects, .or/.is de defaults; onConflict ajustado pros uniques sem tenant_id
  (singletons usam 'singleton'); realtime de tabelas tenant aponta pro schema
  do tenant ativo; repos dropam tenant_id defensivamente de payloads externos
- agendaSelects: tenant_id fora do AGENDA_EVENT_SELECT (quebraria PostgREST)
- zero embeds cross-schema (todos FK embeds sao tenant->tenant ou global->global)
- build de producao passa; 67 .js checados

Pendente (fora do escopo F3, sao cross-tenant/anon -> F4/F6):
- AgendadorPublicoPage (anon, resolve tenant por link_slug)
- Saas{Feriados,NotificationTemplates,DocumentTemplates,Whatsapp}Page
  (gerenciam defaults do sistema / views cross-tenant)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-06-13 04:44:59 -03:00
parent 05c6746e33
commit a7f6bcbe66
142 changed files with 1404 additions and 1472 deletions
@@ -6,7 +6,7 @@
| V#3 — fundação: queries de patients centralizadas.
|
| Pages e composables devem chamar este repo em vez de fazer
| supabase.from('patients') direto.
| tenantDb().from('patients') direto.
|
| Inclui também reads cross-feature em escopo de paciente (agenda_eventos,
| financial_records, documents, recurrence_rules, conversation_messages,
@@ -16,6 +16,7 @@
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId, getUid } from '@/features/agenda/services/_tenantGuards';
import {
@@ -51,7 +52,7 @@ function resolveTenantId(tenantIdArg) {
export async function listPatients({ tenantId, ownerId = null, includeInactive = true, limit = null } = {}) {
const tid = resolveTenantId(tenantId);
let q = supabase.from('patients').select(PATIENTS_SELECT_BASE).eq('tenant_id', tid);
let q = tenantDb().from('patients').select(PATIENTS_SELECT_BASE);
if (ownerId) q = q.eq('owner_id', ownerId);
if (!includeInactive) q = q.neq('status', 'Inativo');
if (limit) q = q.limit(limit);
@@ -65,7 +66,7 @@ export async function listPatients({ tenantId, ownerId = null, includeInactive =
export async function getPatientById(id, { tenantId } = {}) {
if (!id) throw new Error('id obrigatório');
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('patients').select(PATIENTS_SELECT_BASE).eq('id', id).eq('tenant_id', tid).maybeSingle();
const { data, error } = await tenantDb().from('patients').select(PATIENTS_SELECT_BASE).eq('id', id).maybeSingle();
if (error) throw error;
return data;
}
@@ -77,9 +78,9 @@ export async function createPatient(payload) {
// criar pacientes "de outro terapeuta". Repository é defesa em profundidade.
const ownerId = await getUid();
// eslint-disable-next-line no-unused-vars
const { owner_id: _dropped, ...rest } = payload || {};
const row = { ...rest, tenant_id: tid, owner_id: ownerId };
const { data, error } = await supabase.from('patients').insert(row).select(PATIENTS_SELECT_BASE).single();
const { owner_id: _dropped, tenant_id: _tenantDropped, ...rest } = payload || {};
const row = { ...rest, owner_id: ownerId };
const { data, error } = await tenantDb().from('patients').insert(row).select(PATIENTS_SELECT_BASE).single();
if (error) throw error;
return data;
}
@@ -87,7 +88,7 @@ export async function createPatient(payload) {
export async function updatePatient(id, patch, { tenantId } = {}) {
if (!id) throw new Error('id obrigatório');
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('patients').update(patch).eq('id', id).eq('tenant_id', tid).select(PATIENTS_SELECT_BASE).single();
const { data, error } = await tenantDb().from('patients').update(patch).eq('id', id).select(PATIENTS_SELECT_BASE).single();
if (error) throw error;
return data;
}
@@ -95,7 +96,7 @@ export async function updatePatient(id, patch, { tenantId } = {}) {
export async function softDeletePatient(id, { tenantId } = {}) {
if (!id) throw new Error('id obrigatório');
const tid = resolveTenantId(tenantId);
const { error } = await supabase.from('patients').update({ status: 'Arquivado' }).eq('id', id).eq('tenant_id', tid);
const { error } = await tenantDb().from('patients').update({ status: 'Arquivado' }).eq('id', id);
if (error) throw error;
}
@@ -111,8 +112,8 @@ export async function softDeletePatient(id, { tenantId } = {}) {
export async function getPatientRelations(patientId) {
if (!patientId) return { groupIds: [], tagIds: [] };
const [{ data: g, error: ge }, { data: t, error: te }] = await Promise.all([
supabase.from('patient_group_patient').select('patient_group_id').eq('patient_id', patientId),
supabase.from('patient_patient_tag').select('tag_id').eq('patient_id', patientId)
tenantDb().from('patient_group_patient').select('patient_group_id').eq('patient_id', patientId),
tenantDb().from('patient_patient_tag').select('tag_id').eq('patient_id', patientId)
]);
if (ge) throw ge;
if (te) throw te;
@@ -126,7 +127,7 @@ export async function getPatientRelations(patientId) {
export async function listGroups({ tenantId, ownerId = null } = {}) {
const tid = resolveTenantId(tenantId);
let q = supabase.from('patient_groups').select(PATIENT_GROUPS_SELECT).eq('tenant_id', tid).eq('is_active', true);
let q = tenantDb().from('patient_groups').select(PATIENT_GROUPS_SELECT).eq('is_active', true);
if (ownerId) q = q.or(`is_system.eq.true,owner_id.eq.${ownerId}`);
q = q.order('nome', { ascending: true });
const { data, error } = await q;
@@ -137,7 +138,7 @@ export async function listGroups({ tenantId, ownerId = null } = {}) {
export async function listGroupsByPatient(patientIds, { tenantId } = {}) {
if (!patientIds?.length) return [];
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('patient_group_patient').select('patient_id, patient_group_id').eq('tenant_id', tid).in('patient_id', patientIds);
const { data, error } = await tenantDb().from('patient_group_patient').select('patient_id, patient_group_id').in('patient_id', patientIds);
if (error) throw error;
return data || [];
}
@@ -147,7 +148,7 @@ export async function listGroupsByPatient(patientIds, { tenantId } = {}) {
*/
export async function getGroupsByIds(ids) {
if (!ids?.length) return [];
const { data, error } = await supabase.from('patient_groups').select(PATIENT_GROUPS_SELECT_BRIEF).in('id', ids).order('nome', { ascending: true });
const { data, error } = await tenantDb().from('patient_groups').select(PATIENT_GROUPS_SELECT_BRIEF).in('id', ids).order('nome', { ascending: true });
if (error) throw error;
return (data || []).map((g) => ({ id: g.id, name: g.nome }));
}
@@ -158,10 +159,10 @@ export async function getGroupsByIds(ids) {
export async function replacePatientGroup(patientId, groupId, { tenantId } = {}) {
if (!patientId) throw new Error('patientId obrigatório');
const tid = resolveTenantId(tenantId);
const { error: del } = await supabase.from('patient_group_patient').delete().eq('patient_id', patientId).eq('tenant_id', tid);
const { error: del } = await tenantDb().from('patient_group_patient').delete().eq('patient_id', patientId);
if (del) throw del;
if (!groupId) return;
const { error: ins } = await supabase.from('patient_group_patient').insert({ patient_id: patientId, patient_group_id: groupId, tenant_id: tid });
const { error: ins } = await tenantDb().from('patient_group_patient').insert({ patient_id: patientId, patient_group_id: groupId });
if (ins) throw ins;
}
@@ -169,7 +170,7 @@ export async function replacePatientGroup(patientId, groupId, { tenantId } = {})
export async function listTags({ tenantId, ownerId = null } = {}) {
const tid = resolveTenantId(tenantId);
let q = supabase.from('patient_tags').select(PATIENT_TAGS_SELECT).eq('tenant_id', tid);
let q = tenantDb().from('patient_tags').select(PATIENT_TAGS_SELECT);
if (ownerId) q = q.eq('owner_id', ownerId);
const { data, error } = await q;
if (error) throw error;
@@ -179,7 +180,7 @@ export async function listTags({ tenantId, ownerId = null } = {}) {
export async function listTagsByPatient(patientIds, { tenantId } = {}) {
if (!patientIds?.length) return [];
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('patient_patient_tag').select('patient_id, tag_id').eq('tenant_id', tid).in('patient_id', patientIds);
const { data, error } = await tenantDb().from('patient_patient_tag').select('patient_id, tag_id').in('patient_id', patientIds);
if (error) throw error;
return data || [];
}
@@ -189,7 +190,7 @@ export async function listTagsByPatient(patientIds, { tenantId } = {}) {
*/
export async function getTagsByIds(ids) {
if (!ids?.length) return [];
const { data, error } = await supabase.from('patient_tags').select(PATIENT_TAGS_SELECT_BRIEF).in('id', ids).order('nome', { ascending: true });
const { data, error } = await tenantDb().from('patient_tags').select(PATIENT_TAGS_SELECT_BRIEF).in('id', ids).order('nome', { ascending: true });
if (error) throw error;
return (data || []).map((t) => ({ id: t.id, name: t.nome, color: t.cor }));
}
@@ -202,12 +203,12 @@ export async function replacePatientTags(patientId, tagIds, { tenantId, ownerId
if (!ownerId) throw new Error('ownerId obrigatório');
const tid = resolveTenantId(tenantId);
const { error: del } = await supabase.from('patient_patient_tag').delete().eq('patient_id', patientId).eq('owner_id', ownerId).eq('tenant_id', tid);
const { error: del } = await tenantDb().from('patient_patient_tag').delete().eq('patient_id', patientId).eq('owner_id', ownerId);
if (del) throw del;
const clean = Array.from(new Set((tagIds || []).filter(Boolean)));
if (!clean.length) return;
const { error: ins } = await supabase.from('patient_patient_tag').insert(clean.map((tag_id) => ({ owner_id: ownerId, patient_id: patientId, tag_id, tenant_id: tid })));
const { error: ins } = await tenantDb().from('patient_patient_tag').insert(clean.map((tag_id) => ({ owner_id: ownerId, patient_id: patientId, tag_id })));
if (ins) throw ins;
}
@@ -224,10 +225,9 @@ export async function replacePatientTags(patientId, tagIds, { tenantId, ownerId
export async function listSessionsByPatient(patientId, { tenantId } = {}) {
if (!patientId) return [];
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase
.from('agenda_eventos')
const { data, error } = await tenantDb().from('agenda_eventos')
.select(PATIENT_SESSIONS_SELECT)
.eq('tenant_id', tid)
.eq('patient_id', patientId)
.order('inicio_em', { ascending: false })
.limit(100);
@@ -251,7 +251,6 @@ export async function createPatientSession(patientId, payload) {
const row = {
patient_id: patientId,
owner_id: uid,
tenant_id: tid,
inicio_em: payload.inicio_em,
fim_em: payload.fim_em,
status: payload.status || 'agendado',
@@ -266,7 +265,7 @@ export async function createPatientSession(patientId, payload) {
price: payload.price ?? null
};
const { data, error } = await supabase.from('agenda_eventos').insert([row]).select().single();
const { data, error } = await tenantDb().from('agenda_eventos').insert([row]).select().single();
if (error) throw error;
return data;
}
@@ -277,7 +276,7 @@ export async function createPatientSession(patientId, payload) {
export async function updatePatientSessionStatus(sessionId, status, { tenantId } = {}) {
if (!sessionId) throw new Error('sessionId obrigatório');
const tid = resolveTenantId(tenantId);
const { error } = await supabase.from('agenda_eventos').update({ status }).eq('id', sessionId).eq('tenant_id', tid);
const { error } = await tenantDb().from('agenda_eventos').update({ status }).eq('id', sessionId);
if (error) throw error;
}
@@ -287,7 +286,7 @@ export async function updatePatientSessionStatus(sessionId, status, { tenantId }
*/
export async function findSessionByRecurrence(recurrenceId, recurrenceDate) {
if (!recurrenceId || !recurrenceDate) return null;
const { data, error } = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', recurrenceDate).maybeSingle();
const { data, error } = await tenantDb().from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', recurrenceDate).maybeSingle();
if (error) throw error;
return data || null;
}
@@ -303,10 +302,9 @@ export async function findSessionByRecurrence(recurrenceId, recurrenceDate) {
export async function listFinancialRecordsByPatient(patientId, { tenantId } = {}) {
if (!patientId) return [];
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase
.from('financial_records')
const { data, error } = await tenantDb().from('financial_records')
.select(PATIENT_FINANCIAL_RECORDS_SELECT)
.eq('tenant_id', tid)
.eq('patient_id', patientId)
.eq('type', 'receita')
.order('created_at', { ascending: false })
@@ -329,7 +327,6 @@ export async function createFinancialRecord(patientId, payload) {
const row = {
patient_id: patientId,
owner_id: uid,
tenant_id: tid,
type: 'receita',
amount: Number(payload.amount),
due_date: payload.due_date || null,
@@ -338,7 +335,7 @@ export async function createFinancialRecord(patientId, payload) {
paid_at: null
};
const { data, error } = await supabase.from('financial_records').insert([row]).select().single();
const { data, error } = await tenantDb().from('financial_records').insert([row]).select().single();
if (error) throw error;
return data;
}
@@ -349,7 +346,7 @@ export async function createFinancialRecord(patientId, payload) {
export async function markFinancialRecordPaid(recordId, { tenantId } = {}) {
if (!recordId) throw new Error('recordId obrigatório');
const tid = resolveTenantId(tenantId);
const { error } = await supabase.from('financial_records').update({ paid_at: new Date().toISOString() }).eq('id', recordId).eq('tenant_id', tid);
const { error } = await tenantDb().from('financial_records').update({ paid_at: new Date().toISOString() }).eq('id', recordId);
if (error) throw error;
}
@@ -359,7 +356,7 @@ export async function markFinancialRecordPaid(recordId, { tenantId } = {}) {
export async function markFinancialRecordUnpaid(recordId, { tenantId } = {}) {
if (!recordId) throw new Error('recordId obrigatório');
const tid = resolveTenantId(tenantId);
const { error } = await supabase.from('financial_records').update({ paid_at: null }).eq('id', recordId).eq('tenant_id', tid);
const { error } = await tenantDb().from('financial_records').update({ paid_at: null }).eq('id', recordId);
if (error) throw error;
}
@@ -370,10 +367,9 @@ export async function markFinancialRecordUnpaid(recordId, { tenantId } = {}) {
export async function listDocumentsByPatient(patientId, { tenantId } = {}) {
if (!patientId) return [];
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase
.from('documents')
const { data, error } = await tenantDb().from('documents')
.select(PATIENT_DOCUMENTS_SELECT)
.eq('tenant_id', tid)
.eq('patient_id', patientId)
.is('deleted_at', null)
.order('created_at', { ascending: false })
@@ -390,10 +386,9 @@ export async function listDocumentsByPatient(patientId, { tenantId } = {}) {
export async function listMessagesByPatient(patientId, { tenantId } = {}) {
if (!patientId) return [];
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase
.from('conversation_messages')
const { data, error } = await tenantDb().from('conversation_messages')
.select(PATIENT_MESSAGES_SELECT)
.eq('tenant_id', tid)
.eq('patient_id', patientId)
.order('created_at', { ascending: false })
.limit(200);
@@ -409,10 +404,9 @@ export async function listMessagesByPatient(patientId, { tenantId } = {}) {
export async function listRecurrencesByPatient(patientId, { tenantId } = {}) {
if (!patientId) return [];
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase
.from('recurrence_rules')
const { data, error } = await tenantDb().from('recurrence_rules')
.select(PATIENT_RECURRENCE_RULES_SELECT)
.eq('tenant_id', tid)
.eq('patient_id', patientId)
.order('start_date', { ascending: false });
if (error) throw error;
@@ -425,7 +419,7 @@ export async function listRecurrencesByPatient(patientId, { tenantId } = {}) {
export async function updateRecurrenceStatus(ruleId, status, { tenantId } = {}) {
if (!ruleId) throw new Error('ruleId obrigatório');
const tid = resolveTenantId(tenantId);
const { error } = await supabase.from('recurrence_rules').update({ status, updated_at: new Date().toISOString() }).eq('id', ruleId).eq('tenant_id', tid);
const { error } = await tenantDb().from('recurrence_rules').update({ status, updated_at: new Date().toISOString() }).eq('id', ruleId);
if (error) throw error;
}
@@ -436,7 +430,7 @@ export async function updateRecurrenceStatus(ruleId, status, { tenantId } = {})
export async function listSupportContactsByPatient(patientId, { tenantId } = {}) {
if (!patientId) return [];
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('patient_support_contacts').select(PATIENT_SUPPORT_CONTACTS_SELECT).eq('tenant_id', tid).eq('patient_id', patientId).order('is_primario', { ascending: false });
const { data, error } = await tenantDb().from('patient_support_contacts').select(PATIENT_SUPPORT_CONTACTS_SELECT).eq('patient_id', patientId).order('is_primario', { ascending: false });
if (error) throw error;
return data || [];
}
@@ -452,7 +446,7 @@ export async function replacePatientSupportContacts(patientId, contacts, { tenan
if (!ownerId) throw new Error('ownerId obrigatório');
const tid = resolveTenantId(tenantId);
const { error: del } = await supabase.from('patient_support_contacts').delete().eq('patient_id', patientId).eq('owner_id', ownerId).eq('tenant_id', tid);
const { error: del } = await tenantDb().from('patient_support_contacts').delete().eq('patient_id', patientId).eq('owner_id', ownerId);
if (del) throw del;
if (!contacts?.length) return;
@@ -460,11 +454,10 @@ export async function replacePatientSupportContacts(patientId, contacts, { tenan
const rows = contacts.map((c) => ({
...c,
patient_id: patientId,
owner_id: ownerId,
tenant_id: tid
owner_id: ownerId
}));
const { error: ins } = await supabase.from('patient_support_contacts').insert(rows);
const { error: ins } = await tenantDb().from('patient_support_contacts').insert(rows);
if (ins) throw ins;
}
@@ -490,8 +483,7 @@ export async function markIntakeConverted(intakeId, patientId, { tenantId } = {}
// tenant_id no patient_intake_requests pode ser nullable (intake público sem tenant)
// — só filtramos se passado explícito.
let q = supabase
.from('patient_intake_requests')
let q = tenantDb().from('patient_intake_requests')
.update({
status: 'converted',
converted_patient_id: patientId,
@@ -499,11 +491,6 @@ export async function markIntakeConverted(intakeId, patientId, { tenantId } = {}
})
.eq('id', intakeId);
if (tenantId) {
const tid = resolveTenantId(tenantId);
q = q.eq('tenant_id', tid);
}
const { error } = await q;
if (error) throw error;
}