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
+5 -7
View File
@@ -24,6 +24,7 @@ import Textarea from 'primevue/textarea';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
const router = useRouter();
const toast = useToast();
@@ -337,10 +338,9 @@ async function loadNegocio() {
tenantId.value = member.tenant_id;
// Carregar dados da empresa
const { data: co, error: coErr } = await supabase
.from('company_profiles')
const { data: co, error: coErr } = await tenantDb().from('company_profiles')
.select('*')
.eq('tenant_id', tenantId.value)
.maybeSingle();
if (coErr) throw coErr;
@@ -401,7 +401,6 @@ async function saveAll() {
}
const payload = {
tenant_id: tenantId.value,
nome_fantasia: String(form.nome_fantasia || '').trim() || null,
razao_social: String(form.razao_social || '').trim() || null,
tipo_empresa: String(form.tipo_empresa || '').trim() || null,
@@ -423,9 +422,8 @@ async function saveAll() {
updated_at: new Date().toISOString()
};
const { error } = await supabase
.from('company_profiles')
.upsert(payload, { onConflict: 'tenant_id' });
const { error } = await tenantDb().from('company_profiles')
.upsert(payload, { onConflict: 'singleton' });
if (error) throw error;
+7 -9
View File
@@ -20,6 +20,7 @@ import { useRouter } from 'vue-router';
import { useLayout } from '@/layout/composables/layout';
import Menu from 'primevue/menu';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
import { useClinicKPIs } from '@/composables/useClinicKPIs';
import FirstResponseCard from '@/components/dashboard/FirstResponseCard.vue';
@@ -419,10 +420,9 @@ async function load() {
try {
const [eventosRes, membrosRes, solRes, cadRes] = await Promise.all([
supabase
.from('agenda_eventos')
tenantDb().from('agenda_eventos')
.select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, patient_id, owner_id, recurrence_id, patients(nome_completo)')
.eq('tenant_id', tid)
.gte('inicio_em', mesInicio)
.lte('inicio_em', mesFim)
.order('inicio_em', { ascending: true }),
@@ -432,17 +432,15 @@ async function load() {
.eq('tenant_id', tid)
.in('role', ['therapist', 'tenant_admin'])
.eq('status', 'active'),
supabase
.from('agendador_solicitacoes')
tenantDb().from('agendador_solicitacoes')
.select('id, paciente_nome, paciente_sobrenome, tipo, modalidade, data_solicitada, hora_solicitada')
.eq('tenant_id', tid)
.eq('status', 'pendente')
.order('created_at', { ascending: false })
.limit(10),
supabase
.from('patient_intake_requests')
tenantDb().from('patient_intake_requests')
.select('id, nome_completo, status, created_at')
.eq('tenant_id', tid)
.eq('status', 'new')
.order('created_at', { ascending: false })
.limit(10)
@@ -19,6 +19,7 @@ import { ref, computed, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
const toast = useToast();
// ── Contexto ──────────────────────────────────────────────────
@@ -82,8 +83,7 @@ async function init() {
}
async function loadChannel() {
const { data } = await supabase
.from('notification_channels')
const { data } = await tenantDb().from('notification_channels')
.select('*')
.eq('owner_id', userId.value)
.eq('channel', 'sms')
@@ -103,8 +103,7 @@ async function loadChannel() {
async function loadLogs() {
logsLoading.value = true;
const { data } = await supabase
.from('notification_logs')
const { data } = await tenantDb().from('notification_logs')
.select('id, template_key, recipient_address, status, failure_reason, provider_message_id, sent_at, failed_at, created_at')
.eq('owner_id', userId.value)
.eq('channel', 'sms')
@@ -137,8 +136,7 @@ async function saveCredentials() {
const creds = { ...credentials.value };
if (channel.value?.id) {
const { error } = await supabase
.from('notification_channels')
const { error } = await tenantDb().from('notification_channels')
.update({
credentials: creds,
is_active: true,
@@ -147,11 +145,9 @@ async function saveCredentials() {
.eq('id', channel.value.id);
if (error) throw error;
} else {
const { data, error } = await supabase
.from('notification_channels')
const { data, error } = await tenantDb().from('notification_channels')
.insert({
owner_id: userId.value,
tenant_id: tenantId.value,
channel: 'sms',
provider: 'twilio',
display_name: 'SMS via Twilio',
@@ -194,8 +190,7 @@ async function testConnection() {
// Atualiza connection_status para connected
if (channel.value?.id) {
await supabase
.from('notification_channels')
await tenantDb().from('notification_channels')
.update({ connection_status: 'connected' })
.eq('id', channel.value.id);
channel.value.connection_status = 'connected';
@@ -206,8 +201,7 @@ async function testConnection() {
toast.add({ severity: 'warn', summary: 'Falha no teste', detail: data?.message, life: 6000 });
if (channel.value?.id) {
await supabase
.from('notification_channels')
await tenantDb().from('notification_channels')
.update({ connection_status: 'error' })
.eq('id', channel.value.id);
channel.value.connection_status = 'error';
@@ -6,6 +6,7 @@ import { useConfirm } from 'primevue/useconfirm';
import { formatDistanceToNow, format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useNotificationStore } from '@/stores/notificationStore';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import { useTenantStore } from '@/stores/tenantStore';
@@ -92,7 +93,7 @@ async function load() {
loading.value = false;
return;
}
const { data, error } = await supabase.from('notifications').select('*').eq('owner_id', ownerId.value).order('created_at', { ascending: false }).limit(500);
const { data, error } = await tenantDb().from('notifications').select('*').eq('owner_id', ownerId.value).order('created_at', { ascending: false }).limit(500);
if (error) throw error;
items.value = data || [];
} catch (e) {
@@ -105,7 +106,7 @@ async function load() {
// ─── Actions ─────────────────────────────────────────
async function markRead(id) {
const now = new Date().toISOString();
const { error } = await supabase.from('notifications').update({ read_at: now }).eq('id', id);
const { error } = await tenantDb().from('notifications').update({ read_at: now }).eq('id', id);
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 });
return;
@@ -118,7 +119,7 @@ async function markRead(id) {
}
async function markUnread(id) {
const { error } = await supabase.from('notifications').update({ read_at: null }).eq('id', id);
const { error } = await tenantDb().from('notifications').update({ read_at: null }).eq('id', id);
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 });
return;
@@ -130,7 +131,7 @@ async function markUnread(id) {
}
async function archive(id) {
const { error } = await supabase.from('notifications').update({ archived: true }).eq('id', id);
const { error } = await tenantDb().from('notifications').update({ archived: true }).eq('id', id);
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 });
return;
@@ -142,7 +143,7 @@ async function archive(id) {
}
async function unarchive(id) {
const { error } = await supabase.from('notifications').update({ archived: false }).eq('id', id);
const { error } = await tenantDb().from('notifications').update({ archived: false }).eq('id', id);
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 });
return;
@@ -168,7 +169,7 @@ function confirmRemove(id) {
}
async function remove(id) {
const { error } = await supabase.from('notifications').delete().eq('id', id);
const { error } = await tenantDb().from('notifications').delete().eq('id', id);
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 });
return;
@@ -182,7 +183,7 @@ async function markAllRead() {
const unreadIds = items.value.filter((n) => !n.read_at && !n.archived).map((n) => n.id);
if (!unreadIds.length) return;
const now = new Date().toISOString();
const { error } = await supabase.from('notifications').update({ read_at: now }).in('id', unreadIds);
const { error } = await tenantDb().from('notifications').update({ read_at: now }).in('id', unreadIds);
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 });
return;
+3 -3
View File
@@ -18,6 +18,7 @@
import { ref, computed, watch, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
import { useLayout } from '@/layout/composables/layout';
import { exportSessionsToPDF, exportSessionsToXLSX, exportSessionsToCSV } from '@/services/reportExport.service';
@@ -76,10 +77,9 @@ async function loadSessions() {
sessions.value = [];
try {
const { data, error } = await supabase
.from('agenda_eventos')
const { data, error } = await tenantDb().from('agenda_eventos')
.select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, patient_id, patients(nome_completo)')
.eq('tenant_id', tenantId)
.eq('owner_id', uid)
.gte('inicio_em', start.toISOString())
.lte('inicio_em', end.toISOString())
@@ -21,6 +21,7 @@ import { useLayout } from '@/layout/composables/layout';
import { useToast } from 'primevue/usetoast';
import Menu from 'primevue/menu';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
import { useAgendaEvents } from '@/features/agenda/composables/useAgendaEvents';
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue';
@@ -255,7 +256,6 @@ function _isUuid(v) {
function _pickDbFields(obj) {
const allowed = [
'tenant_id',
'owner_id',
'terapeuta_id',
'patient_id',
@@ -294,10 +294,6 @@ async function onAgendaDialogSave(arg) {
if (!normalized.owner_id && ownerId.value) normalized.owner_id = ownerId.value;
const tid = clinicTenantId.value;
if (!tid) throw new Error('tenant_id não encontrado.');
normalized.tenant_id = tid;
if (!normalized.visibility_scope) normalized.visibility_scope = 'public';
if (!normalized.status) normalized.status = 'agendado';
if (!normalized.tipo) normalized.tipo = 'sessao';
@@ -566,7 +562,7 @@ const solicitacoesPendentes = computed(() => solicitacoes.value.length);
async function aceitarSol(id) {
try {
await supabase.from('agendador_solicitacoes').update({ status: 'autorizado' }).eq('id', id);
await tenantDb().from('agendador_solicitacoes').update({ status: 'autorizado' }).eq('id', id);
_solicitacoesBruto.value = _solicitacoesBruto.value.filter((s) => s.id !== id);
} catch (e) {
console.error('[TherapistDashboard] aceitarSol:', e);
@@ -574,7 +570,7 @@ async function aceitarSol(id) {
}
async function recusarSol(id) {
try {
await supabase.from('agendador_solicitacoes').update({ status: 'recusado' }).eq('id', id);
await tenantDb().from('agendador_solicitacoes').update({ status: 'recusado' }).eq('id', id);
_solicitacoesBruto.value = _solicitacoesBruto.value.filter((s) => s.id !== id);
} catch (e) {
console.error('[TherapistDashboard] recusarSol:', e);
@@ -888,34 +884,29 @@ async function load() {
try {
const [eventosRes, recRes, solRes, cadRes] = await Promise.all([
(() => {
let q = supabase
.from('agenda_eventos')
let q = tenantDb().from('agenda_eventos')
.select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, patient_id, recurrence_id, determined_commitment_id, patients(nome_completo), determined_commitments(bg_color, text_color)')
.eq('owner_id', ownerId.value)
.gte('inicio_em', mesInicio)
.lte('inicio_em', mesFim)
.order('inicio_em', { ascending: true });
if (tid) q = q.eq('tenant_id', tid);
return q;
})(),
(() => {
let q = supabase
.from('recurrence_rules')
let q = tenantDb().from('recurrence_rules')
.select('id, patient_id, type, interval, weekdays, start_date, end_date, max_occurrences, start_time')
.eq('owner_id', ownerId.value)
.eq('status', 'ativo')
.order('start_date', { ascending: false });
if (tid) q = q.eq('tenant_id', tid);
return q;
})(),
supabase
.from('agendador_solicitacoes')
tenantDb().from('agendador_solicitacoes')
.select('id, paciente_nome, paciente_sobrenome, tipo, modalidade, data_solicitada, hora_solicitada')
.eq('owner_id', ownerId.value)
.eq('status', 'pendente')
.order('created_at', { ascending: false })
.limit(10),
supabase.from('patient_intake_requests').select('id, nome_completo, status, created_at').eq('owner_id', ownerId.value).eq('status', 'new').order('created_at', { ascending: false }).limit(10)
tenantDb().from('patient_intake_requests').select('id, nome_completo, status, created_at').eq('owner_id', ownerId.value).eq('status', 'new').order('created_at', { ascending: false }).limit(10)
]);
eventosDoMes.value = eventosRes.data || [];
_solicitacoesBruto.value = solRes.data || [];
@@ -923,7 +914,7 @@ async function load() {
const rawRules = recRes.data || [];
const patientIds = [...new Set(rawRules.map((r) => r.patient_id).filter(Boolean))];
if (patientIds.length) {
const { data: pts } = await supabase.from('patients').select('id, nome_completo').in('id', patientIds);
const { data: pts } = await tenantDb().from('patients').select('id, nome_completo').in('id', patientIds);
const ptMap = Object.fromEntries((pts || []).map((p) => [p.id, p.nome_completo]));
for (const r of rawRules) r._patientNome = ptMap[r.patient_id] || '—';
} else {
@@ -931,8 +922,7 @@ async function load() {
}
const rulesComLimite = rawRules.filter((r) => r.max_occurrences);
if (rulesComLimite.length) {
const { data: sessData } = await supabase
.from('agenda_eventos')
const { data: sessData } = await tenantDb().from('agenda_eventos')
.select('id, recurrence_id')
.in(
'recurrence_id',