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
@@ -21,6 +21,7 @@
*/
import { dateToISO } from '@/features/agenda/utils/timeHelpers';
import { tenantDb } from '@/lib/supabase/tenantClient';
// ── Helpers puros ─────────────────────────────────────────────────────────
@@ -155,10 +156,9 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
const excType = exceptionTypeMap[status];
if (excType && tenantId) {
try {
const { data } = await supabase
.from('financial_exceptions')
const { data } = await tenantDb().from('financial_exceptions')
.select('*')
.eq('tenant_id', tenantId)
.eq('exception_type', excType)
.or(`owner_id.eq.${ownerId},owner_id.is.null`)
.order('owner_id', { ascending: false, nullsLast: true })
@@ -177,8 +177,7 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
const contractId = row?.billing_contract_id ?? null;
if (contractId) {
try {
const { data } = await supabase
.from('billing_contracts')
const { data } = await tenantDb().from('billing_contracts')
.select('*')
.eq('id', contractId)
.maybeSingle();
@@ -189,14 +188,12 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
}
if (!ctx.billingContract && eventoId) {
try {
const { data: ev } = await supabase
.from('agenda_eventos')
const { data: ev } = await tenantDb().from('agenda_eventos')
.select('billing_contract_id')
.eq('id', eventoId)
.maybeSingle();
if (ev?.billing_contract_id) {
const { data: c } = await supabase
.from('billing_contracts')
const { data: c } = await tenantDb().from('billing_contracts')
.select('*')
.eq('id', ev.billing_contract_id)
.maybeSingle();
@@ -208,10 +205,9 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
}
if (!ctx.billingContract && patientId && tenantId) {
try {
const { data: c } = await supabase
.from('billing_contracts')
const { data: c } = await tenantDb().from('billing_contracts')
.select('*')
.eq('tenant_id', tenantId)
.eq('patient_id', patientId)
.eq('status', 'active')
.eq('type', 'package')
@@ -227,8 +223,7 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
// 3) Pending record
if (eventoId) {
try {
const { data } = await supabase
.from('financial_records')
const { data } = await tenantDb().from('financial_records')
.select('*')
.eq('agenda_evento_id', eventoId)
.in('status', ['pending', 'overdue'])
@@ -244,8 +239,7 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
// 3b) Paid record pré-existente (caso C12: antecipar pagamento).
if (eventoId) {
try {
const { data } = await supabase
.from('financial_records')
const { data } = await tenantDb().from('financial_records')
.select('id, status, amount, final_amount, paid_at, payment_method')
.eq('agenda_evento_id', eventoId)
.eq('status', 'paid')
@@ -266,16 +260,14 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
saldoConsumed: false
};
try {
const { data: evRow } = await supabase
.from('agenda_eventos')
const { data: evRow } = await tenantDb().from('agenda_eventos')
.select('status, billing_contract_id')
.eq('id', eventoId)
.maybeSingle();
if (evRow) {
ctx.reverseArtifacts.previousStatus = evRow.status;
}
const { data: recs } = await supabase
.from('financial_records')
const { data: recs } = await tenantDb().from('financial_records')
.select('id, status, amount, final_amount, description, paid_at, payment_method')
.eq('agenda_evento_id', eventoId)
.neq('status', 'cancelled')
@@ -336,8 +328,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
const today = new Date().toISOString().slice(0, 10);
const reason = `Cancelada via reversão de status (${r.previousStatus} → agendado) em ${today}`;
for (const id of pendingIds) {
const { error: cErr } = await supabase
.from('financial_records')
const { error: cErr } = await tenantDb().from('financial_records')
.update({
status: 'cancelled',
notes: `[${today}] ${reason}`,
@@ -356,8 +347,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
// 2) Devolver saldo
if (decision.reverseRestoreSaldo && r.saldoConsumed && ctx.billingContract?.id) {
try {
const { data: freshContract, error: fetchErr } = await supabase
.from('billing_contracts')
const { data: freshContract, error: fetchErr } = await tenantDb().from('billing_contracts')
.select('sessions_used, total_sessions, status')
.eq('id', ctx.billingContract.id)
.maybeSingle();
@@ -369,7 +359,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
if (currentUsed >= totalSessions) {
patch.status = 'active';
}
const { error: dErr } = await supabase.from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
const { error: dErr } = await tenantDb().from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
if (dErr) throw dErr;
} catch (e) {
console.error('[agendaBilling/reverse] erro decrementando saldo:', e?.message);
@@ -380,7 +370,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
// 3) Desamarrar billing_contract_id (só se devolveu saldo)
if (decision.reverseRestoreSaldo && r.saldoConsumed) {
try {
await supabase.from('agenda_eventos').update({ billing_contract_id: null, updated_at: new Date().toISOString() }).eq('id', eventoId);
await tenantDb().from('agenda_eventos').update({ billing_contract_id: null, updated_at: new Date().toISOString() }).eq('id', eventoId);
} catch (e) {
console.warn('[agendaBilling/reverse] erro desamarrando billing_contract_id:', e?.message);
}
@@ -393,8 +383,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
// 1) Consumir saldo
if (decision.consumeSaldo && ctx.billingContract?.id) {
tasks.push(
supabase
.from('billing_contracts')
tenantDb().from('billing_contracts')
.update({ sessions_used: (ctx.billingContract.sessions_used ?? 0) + 1 })
.eq('id', ctx.billingContract.id)
);
@@ -404,8 +393,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
const isForwardStatus = novoStatus === 'realizado' || novoStatus === 'faltou' || novoStatus === 'cancelado';
if (isForwardStatus && ctx.billingContract?.id && eventoId) {
tasks.push(
supabase
.from('agenda_eventos')
tenantDb().from('agenda_eventos')
.update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() })
.eq('id', eventoId)
);
@@ -418,7 +406,6 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
const fineDesc = novoStatus === 'faltou' ? `Multa por falta · sessão ${sessaoLabel}` : `Taxa de cancelamento tardio · sessão ${sessaoLabel}`;
const finePayload = {
owner_id: uid,
tenant_id: tenantId,
patient_id: patientId,
agenda_evento_id: eventoId,
amount: decision.fineAmount,
@@ -429,8 +416,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
type: 'receita'
};
tasks.push(
supabase
.from('financial_records')
tenantDb().from('financial_records')
.insert(finePayload)
.then(({ error }) => {
if (error) {
@@ -455,8 +441,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
const noteEntry = `[${today}] ${reasonText}`;
const noteText = ctx.pendingRecord.notes ? `${ctx.pendingRecord.notes}\n${noteEntry}` : noteEntry;
tasks.push(
supabase
.from('financial_records')
tenantDb().from('financial_records')
.update({
status: 'cancelled',
notes: noteText,
@@ -469,8 +454,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
// 3) Realizado avulsa pendente: marcar pendingRecord como pago (ou só status)
if (decision.markPaid && ctx.pendingRecord?.id) {
tasks.push(
supabase
.from('financial_records')
tenantDb().from('financial_records')
.update({
status: 'paid',
paid_at: new Date().toISOString(),
@@ -492,8 +476,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
}
}
try {
const { data: freshContract, error: fetchErr } = await supabase
.from('billing_contracts')
const { data: freshContract, error: fetchErr } = await tenantDb().from('billing_contracts')
.select('sessions_used, total_sessions, status')
.eq('id', ctx.billingContract.id)
.maybeSingle();
@@ -504,7 +487,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
if (newUsed >= (freshContract?.total_sessions ?? 0)) {
patch.status = 'completed';
}
const { error: incErr } = await supabase.from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
const { error: incErr } = await tenantDb().from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
if (incErr) throw incErr;
tx({ severity: 'success', summary: 'Sessão consumida', detail: `Saldo: ${newUsed}/${freshContract?.total_sessions ?? '?'}. Pagamento já estava registrado.`, life: 4000 });
} catch (e) {
@@ -520,7 +503,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
try {
const { error: linkErr } = await supabase.from('agenda_eventos').update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() }).eq('id', eventoId);
const { error: linkErr } = await tenantDb().from('agenda_eventos').update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() }).eq('id', eventoId);
if (linkErr) throw linkErr;
} catch (e) {
console.error('[agendaBilling] erro amarrando billing_contract_id:', e?.message);
@@ -548,7 +531,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
if (newUsed >= (ctx.billingContract.total_sessions ?? 0)) {
patchContract.status = 'completed';
}
const { error: incErr } = await supabase.from('billing_contracts').update(patchContract).eq('id', ctx.billingContract.id);
const { error: incErr } = await tenantDb().from('billing_contracts').update(patchContract).eq('id', ctx.billingContract.id);
if (incErr) throw incErr;
} catch (e) {
console.error('[agendaBilling] erro incrementando sessions_used:', e?.message);
@@ -570,8 +553,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
// Pós-processamento do record gerado pelo pacote saldo
if (decision.generatePackageCharge && eventoId) {
try {
const { data: newRec } = await supabase
.from('financial_records')
const { data: newRec } = await tenantDb().from('financial_records')
.select('id')
.eq('agenda_evento_id', eventoId)
.order('created_at', { ascending: false })
@@ -579,8 +561,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
.single();
if (newRec?.id) {
if (decision.markPaid) {
await supabase
.from('financial_records')
await tenantDb().from('financial_records')
.update({
status: 'paid',
paid_at: new Date().toISOString(),
@@ -589,8 +570,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
})
.eq('id', newRec.id);
} else if (decision.paymentMethod === 'link') {
await supabase
.from('financial_records')
await tenantDb().from('financial_records')
.update({ payment_method: 'asaas', updated_at: new Date().toISOString() })
.eq('id', newRec.id);
}
@@ -609,11 +589,9 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
export async function createPackageContract({ supabase, rule, normalized, recorrencia, tenantId, packageStyle = 'upfront', paymentMethod = 'link', markPaidNow = false }) {
const { n, packagePrice } = computeSeriePrice(recorrencia);
try {
const { data: createdContract, error: contractErr } = await supabase
.from('billing_contracts')
const { data: createdContract, error: contractErr } = await tenantDb().from('billing_contracts')
.insert({
owner_id: normalized.owner_id,
tenant_id: tenantId,
patient_id: normalized.paciente_id,
type: 'package',
total_sessions: n,
@@ -645,11 +623,9 @@ export async function createPackageContract({ supabase, rule, normalized, recorr
startDt.setHours(hh, mm, 0, 0);
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
const { data: createdEvent, error: evErr } = await supabase
.from('agenda_eventos')
const { data: createdEvent, error: evErr } = await tenantDb().from('agenda_eventos')
.insert({
owner_id: rule.owner_id,
tenant_id: tenantId,
terapeuta_id: rule.therapist_id ?? null,
recurrence_id: rule.id,
recurrence_date: firstISO,
@@ -680,8 +656,7 @@ export async function createPackageContract({ supabase, rule, normalized, recorr
if (cobErr) throw cobErr;
const paidNow = markPaidNow === true && paymentMethod !== 'link';
const { data: recRow } = await supabase
.from('financial_records')
const { data: recRow } = await tenantDb().from('financial_records')
.select('id')
.eq('agenda_evento_id', createdEvent.id)
.order('created_at', { ascending: false })
@@ -696,7 +671,7 @@ export async function createPackageContract({ supabase, rule, normalized, recorr
patch.status = 'paid';
patch.paid_at = new Date().toISOString();
}
await supabase.from('financial_records').update(patch).eq('id', recRow.id);
await tenantDb().from('financial_records').update(patch).eq('id', recRow.id);
}
const methodLabel = {
@@ -745,7 +720,6 @@ export async function materializeAndChargePerSession({ supabase, rule, normalize
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
return {
owner_id: rule.owner_id,
tenant_id: tenantId,
terapeuta_id: rule.therapist_id ?? null,
recurrence_id: rule.id,
recurrence_date: iso,
@@ -762,7 +736,7 @@ export async function materializeAndChargePerSession({ supabase, rule, normalize
};
});
const { data: createdEvents, error: evErr } = await supabase.from('agenda_eventos').insert(rows).select('id, inicio_em');
const { data: createdEvents, error: evErr } = await tenantDb().from('agenda_eventos').insert(rows).select('id, inicio_em');
if (evErr) throw evErr;
let okCount = 0;