diff --git a/database-novo/migrations/20260612000007_f3_my_tenants_slug.sql b/database-novo/migrations/20260612000007_f3_my_tenants_slug.sql new file mode 100644 index 0000000..58bfc2d --- /dev/null +++ b/database-novo/migrations/20260612000007_f3_my_tenants_slug.sql @@ -0,0 +1,33 @@ +-- ============================================================================= +-- F3 — my_tenants() passa a devolver slug (e nome) do tenant +-- +-- O frontend resolve o schema físico do tenant ativo no cliente: +-- tenantStore guarda memberships de my_tenants(); slug -> 'tenant_'. +-- Campo extra é inofensivo pro frontend atual (main) que ignora colunas novas. +-- (mudança de RETURNS TABLE exige DROP + CREATE) +-- ============================================================================= + +BEGIN; + +DROP FUNCTION IF EXISTS public.my_tenants(); + +CREATE FUNCTION public.my_tenants() + RETURNS TABLE(tenant_id uuid, role text, status text, kind text, slug text, tenant_name text) + LANGUAGE sql + STABLE +AS $function$ + select + tm.tenant_id, + tm.role, + tm.status, + t.kind, + t.slug, + t.name + from public.tenant_members tm + join public.tenants t on t.id = tm.tenant_id + where tm.user_id = auth.uid(); +$function$; + +GRANT EXECUTE ON FUNCTION public.my_tenants() TO authenticated; + +COMMIT; diff --git a/scripts/codemod-tenant-db.py b/scripts/codemod-tenant-db.py new file mode 100644 index 0000000..4993d7c --- /dev/null +++ b/scripts/codemod-tenant-db.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- +""" +F3 schema-per-tenant: codemod do frontend. + +1. supabase.from('') -> tenantDb().from('...') (84 tabelas + 6 views) +2. injeta import { tenantDb } from '@/lib/supabase/tenantClient' +3. remove .eq('tenant_id', ) APENAS dentro de cadeias tenantDb().from(...) +4. relatorio de sobras pra passada manual: + - tenant_id em payloads dentro de cadeias tenantDb (insert/upsert/update) + - onConflict com tenant_id em cadeias tenantDb + - supabase.from() pra auditoria + +Uso: python scripts/codemod-tenant-db.py [--apply] (default: dry-run) +""" +import io, os, re, sys + +ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'src') +APPLY = '--apply' in sys.argv + +TENANT_RELS = [ + # 84 tabelas (docs/F0_categorizacao.md §1.1 + §1.2) + 'agenda_bloqueios','agenda_configuracoes','agenda_eventos','agenda_online_slots', + 'agenda_regras_semanais','agenda_slots_bloqueados_semanais','agenda_slots_regras', + 'agendador_configuracoes','agendador_solicitacoes','asaas_customers','asaas_payments', + 'billing_contracts','clinical_note_templates','clinical_note_versions','clinical_notes', + 'commitment_services','commitment_time_logs','company_profiles','contact_email_types', + 'contact_emails','contact_phones','contact_types','conversation_assignments', + 'conversation_autoreply_log','conversation_autoreply_settings','conversation_bot_sessions', + 'conversation_bots','conversation_messages','conversation_notes','conversation_optout_keywords', + 'conversation_optouts','conversation_sla_breaches','conversation_sla_rules','conversation_tags', + 'conversation_thread_tags','determined_commitment_fields','determined_commitments', + 'document_access_logs','document_generated','document_share_links','document_signatures', + 'document_templates','documents','email_layout_config','email_templates_tenant','feriados', + 'financial_categories','financial_exceptions','financial_records','insurance_plan_services', + 'insurance_plans','medicos','notification_channels','notification_logs','notification_preferences', + 'notification_queue','notification_schedules','notification_templates','notifications', + 'patient_contacts','patient_discounts','patient_group_patient','patient_groups', + 'patient_intake_requests','patient_invite_attempts','patient_invites','patient_patient_tag', + 'patient_status_history','patient_support_contacts','patient_tags','patient_timeline','patients', + 'payment_settings','professional_pricing','recurrence_exceptions','recurrence_rule_services', + 'recurrence_rules','services','session_reminder_logs','session_reminder_settings', + 'therapist_payout_records','therapist_payouts','twilio_subaccount_usage','whatsapp_connection_incidents', + # 6 views clonadas por schema + 'conversation_threads','audit_log_unified','v_cashflow_projection','v_commitment_totals', + 'v_patient_groups_with_counts','v_tag_patient_counts', +] +SKIP_FILES = {'tenantClient.js', 'useTenantDb.js', 'client.js'} + +names = '|'.join(sorted(TENANT_RELS, key=len, reverse=True)) +FROM_RE = re.compile(r"supabase\s*\.\s*from\(\s*(['\"])(" + names + r")\1\s*\)") +IMPORT_LINE = "import { tenantDb } from '@/lib/supabase/tenantClient';" + +def skip_string(s, p): + q = s[p]; p += 1 + while p < len(s): + if s[p] == '\\': p += 2; continue + if s[p] == q: return p + 1 + p += 1 + return p + +def balanced_end(s, open_paren): + """indice logo apos o ')' que fecha o '(' em open_paren""" + depth = 0; p = open_paren + while p < len(s): + c = s[p] + if c in '\'"`': + p = skip_string(s, p); continue + if c == '(': depth += 1 + elif c == ')': + depth -= 1 + if depth == 0: return p + 1 + p += 1 + return p + +def chain_end(s, start): + """fim da cadeia de metodos iniciada logo apos from(...)""" + i = start + while True: + j = i + while j < len(s) and s[j] in ' \t\r\n': j += 1 + if j < len(s) and s[j] == '.': + m = re.match(r'[A-Za-z_$][\w$]*', s[j+1:]) + if not m: return i + k = j + 1 + m.end() + while k < len(s) and s[k] in ' \t\r\n': k += 1 + if k < len(s) and s[k] == '(': + i = balanced_end(s, k) + elif k < len(s) and s[k] == ';': + return k + else: + # acesso a propriedade sem chamada (ex.: .then? sempre tem parens) — para + return i + else: + return i + +EQ_RE = re.compile(r"\.\s*eq\(\s*(['\"])tenant_id\1\s*,") + +report = {'files': 0, 'from': 0, 'eq': 0, 'payload': [], 'onconflict': [], 'dynamic_from': []} + +for dirpath, dirnames, filenames in os.walk(ROOT): + dirnames[:] = [d for d in dirnames if d not in ('node_modules', '__tests__')] + for fn in filenames: + if not fn.endswith(('.js', '.vue', '.ts')) or fn in SKIP_FILES: + continue + path = os.path.join(dirpath, fn) + text = io.open(path, encoding='utf-8').read() + orig = text + + # 1. from() replacement + text, n_from = FROM_RE.subn(lambda m: "tenantDb().from(%s%s%s)" % (m.group(1), m.group(2), m.group(1)), text) + report['from'] += n_from + + # 3. eq removal dentro de cadeias tenantDb + n_eq = 0 + while True: + removed = False + for m in re.finditer(r"tenantDb\(\)\s*\.\s*from\(", text): + fstart = m.end() - 1 + fend = balanced_end(text, fstart) + cend = chain_end(text, fend) + span = text[fend:cend] + em = EQ_RE.search(span) + if em: + eq_open = fend + span.index('(', em.start() + 1) + # acha o '(' do .eq + eq_paren = fend + em.end() - len(em.group(0)) + span[em.start():].index('(') + eq_paren = fend + em.start() + text[fend + em.start():].index('(') + eq_close = balanced_end(text, eq_paren) + eq_dot = fend + em.start() + text = text[:eq_dot] + text[eq_close:] + n_eq += 1 + removed = True + break + if not removed: + break + report['eq'] += n_eq + + # 2. import injection + if 'tenantDb(' in text and "from '@/lib/supabase/tenantClient'" not in text: + anchor = re.search(r"^import .*from '@/lib/supabase/client';?\s*$", text, re.M) + if anchor: + text = text[:anchor.end()] + '\n' + IMPORT_LINE + text[anchor.end():] + else: + first_import = re.search(r"^import .*$", text, re.M) + if first_import: + text = text[:first_import.end()] + '\n' + IMPORT_LINE + text[first_import.end():] + else: + report['payload'].append((path, 0, 'SEM PONTO DE IMPORT — inserir manualmente')) + + # 4. relatorios de sobras dentro de cadeias tenantDb + for m in re.finditer(r"tenantDb\(\)\s*\.\s*from\(", text): + fstart = m.end() - 1 + fend = balanced_end(text, fstart) + cend = chain_end(text, fend) + span = text[fend:cend] + line = text[:m.start()].count('\n') + 1 + if re.search(r"\btenant_id\b", span): + if 'onConflict' in span and 'tenant_id' in span: + report['onconflict'].append((path, line)) + else: + report['payload'].append((path, line, 'tenant_id na cadeia')) + + # from() dinamico com supabase (auditoria) + for m in re.finditer(r"supabase\s*\.\s*from\(\s*[^'\")]", text): + line = text[:m.start()].count('\n') + 1 + report['dynamic_from'].append((path, line)) + + if text != orig: + report['files'] += 1 + if APPLY: + io.open(path, 'w', encoding='utf-8', newline='').write(text) + +mode = 'APPLY' if APPLY else 'DRY-RUN' +print('[%s] arquivos alterados: %d | from substituidos: %d | eq removidos: %d' % + (mode, report['files'], report['from'], report['eq'])) +print('\n-- tenant_id sobrando em cadeias tenantDb (payloads, passada manual): %d' % len(report['payload'])) +for p, l, why in report['payload'][:80]: + print(' %s:%s (%s)' % (os.path.relpath(p, ROOT), l, why)) +print('\n-- onConflict com tenant_id em cadeias tenantDb: %d' % len(report['onconflict'])) +for p, l in report['onconflict']: + print(' %s:%s' % (os.path.relpath(p, ROOT), l)) +print('\n-- supabase.from(nao-literal) pra auditoria: %d' % len(report['dynamic_from'])) +for p, l in report['dynamic_from'][:40]: + print(' %s:%s' % (os.path.relpath(p, ROOT), l)) diff --git a/src/App.vue b/src/App.vue index fb36cf5..0b1eb5a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -18,6 +18,7 @@ import { onMounted, watch } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { supabase } from '@/lib/supabase/client'; +import { tenantDb } from '@/lib/supabase/tenantClient'; import { useTenantStore } from '@/stores/tenantStore'; import { fetchDocsForPath } from '@/composables/useAjuda'; @@ -51,8 +52,7 @@ async function checkSetupWizard() { // Se já confirmamos que este uid passou o setup, não verifica de novo if (_setupClearedUid === uid && _setupClearedIsClinic === isClinic) return; - const { data } = await supabase - .from('agenda_configuracoes') + const { data } = await tenantDb().from('agenda_configuracoes') .select('setup_concluido, setup_clinica_concluido, atendimento_mode') .eq('owner_id', uid) .maybeSingle(); diff --git a/src/components/agenda/AgendaEventoFinanceiroPanel.vue b/src/components/agenda/AgendaEventoFinanceiroPanel.vue index 9066465..fc9cc59 100644 --- a/src/components/agenda/AgendaEventoFinanceiroPanel.vue +++ b/src/components/agenda/AgendaEventoFinanceiroPanel.vue @@ -35,6 +35,7 @@ import { useToast } from 'primevue/usetoast'; import { useConfirm } from 'primevue/useconfirm'; import { supabase } from '@/lib/supabase/client'; +import { tenantDb } from '@/lib/supabase/tenantClient'; import { useAgendaFinanceiro } from '@/composables/useAgendaFinanceiro'; import { emitirReciboParaSessao } from '@/services/DocumentGenerate.service'; @@ -126,8 +127,7 @@ async function fetchRecord() { // após cancelar (caso comum: cancelou sem querer ou quer recobrar). // Sem esse filtro, o scenario ficava em 'com-cobranca' mostrando // o cancelado, e o botão "Gerar cobrança" sumia. - const { data, error } = await supabase - .from('financial_records') + const { data, error } = await tenantDb().from('financial_records') .select('id, amount, discount_amount, final_amount, status, due_date, paid_at, payment_method') .eq('agenda_evento_id', props.evento.id) .neq('status', 'cancelled') @@ -213,7 +213,7 @@ function requestCancel() { acceptSeverity: 'danger', accept: async () => { try { - const { error } = await supabase.from('financial_records').update({ status: 'cancelled', updated_at: new Date().toISOString() }).eq('id', record.value.id); + const { error } = await tenantDb().from('financial_records').update({ status: 'cancelled', updated_at: new Date().toISOString() }).eq('id', record.value.id); if (error) throw error; diff --git a/src/components/agenda/AgendaOnlineGradeCard.vue b/src/components/agenda/AgendaOnlineGradeCard.vue index f33be0e..a3fa60d 100644 --- a/src/components/agenda/AgendaOnlineGradeCard.vue +++ b/src/components/agenda/AgendaOnlineGradeCard.vue @@ -27,6 +27,7 @@ import { gerarSlotsDoDia } from '@/utils/slotsGenerator'; import { supabase } from '@/lib/supabase/client'; +import { tenantDb } from '@/lib/supabase/tenantClient'; const toast = useToast(); const props = defineProps({ @@ -51,7 +52,7 @@ const regrasSemanais = ref([]); // agenda_regras_semanais const bloqueadosByDia = ref({}); // {dia: Set('09:00'...)} async function loadRegrasSemanais() { - const { data, error } = await supabase.from('agenda_regras_semanais').select('*').eq('owner_id', props.ownerId).order('dia_semana', { ascending: true }).order('hora_inicio', { ascending: true }); + const { data, error } = await tenantDb().from('agenda_regras_semanais').select('*').eq('owner_id', props.ownerId).order('dia_semana', { ascending: true }).order('hora_inicio', { ascending: true }); if (error) throw error; regrasSemanais.value = data || []; diff --git a/src/components/conversations/ConversationDrawer.vue b/src/components/conversations/ConversationDrawer.vue index 3dc6f90..cebfc0e 100644 --- a/src/components/conversations/ConversationDrawer.vue +++ b/src/components/conversations/ConversationDrawer.vue @@ -17,6 +17,7 @@ import { useConversationNotes } from '@/composables/useConversationNotes'; import { useConversationTags } from '@/composables/useConversationTags'; import { useConversationAssignment } from '@/composables/useConversationAssignment'; import { supabase } from '@/lib/supabase/client'; +import { tenantDb } from '@/lib/supabase/tenantClient'; import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'; const toast = useToast(); @@ -69,10 +70,8 @@ async function loadPatients() { linkPatientLoading.value = true; try { // Carrega todos os pacientes do tenant (até 500) — filter é client-side - const { data, error } = await supabase - .from('patients') + const { data, error } = await tenantDb().from('patients') .select('id, nome_completo, telefone, email_principal, status') - .eq('tenant_id', tenantId) .order('nome_completo', { ascending: true }) .limit(500); if (error) throw error; @@ -99,8 +98,7 @@ async function confirmLinkPatient() { const tenantId = store.thread.tenant_id; // 1) Vincula conversation_messages - const { error } = await supabase - .from('conversation_messages') + const { error } = await tenantDb().from('conversation_messages') .update({ patient_id: patient.id }) .or(`from_number.eq.${phone},to_number.eq.${phone}`) .is('patient_id', null); @@ -137,8 +135,7 @@ async function upsertWhatsappForExisting(tenantId, patientId, threadPhone) { const phoneDigits = String(threadPhone).replace(/\D/g, ''); // Busca se já tem esse número cadastrado - const { data: existing } = await supabase - .from('contact_phones') + const { data: existing } = await tenantDb().from('contact_phones') .select('id, contact_type_id, whatsapp_linked_at') .eq('entity_type', 'patient') .eq('entity_id', patientId) @@ -149,8 +146,7 @@ async function upsertWhatsappForExisting(tenantId, patientId, threadPhone) { if (existing) { // Atualiza vinculado_at se ainda não tinha if (!existing.whatsapp_linked_at) { - await supabase - .from('contact_phones') + await tenantDb().from('contact_phones') .update({ whatsapp_linked_at: new Date().toISOString() }) .eq('id', existing.id); } @@ -158,17 +154,15 @@ async function upsertWhatsappForExisting(tenantId, patientId, threadPhone) { } // Não tem — cria novo com type='whatsapp' - const { data: types } = await supabase - .from('contact_types') + const { data: types } = await tenantDb().from('contact_types') .select('id, slug') - .is('tenant_id', null) + .eq('is_system', true) .eq('slug', 'whatsapp') .maybeSingle(); const whatsappTypeId = types?.id; if (!whatsappTypeId) return; - await supabase.from('contact_phones').insert({ - tenant_id: tenantId, + await tenantDb().from('contact_phones').insert({ entity_type: 'patient', entity_id: patientId, contact_type_id: whatsappTypeId, @@ -201,8 +195,7 @@ async function onPatientCreated(row) { } try { // 1) Vincula TODAS as mensagens do thread (anon) a esse patient_id - const { error: msgErr } = await supabase - .from('conversation_messages') + const { error: msgErr } = await tenantDb().from('conversation_messages') .update({ patient_id: newPatientId }) .or(`from_number.eq.${phone},to_number.eq.${phone}`) .is('patient_id', null); @@ -238,10 +231,9 @@ async function insertWhatsappContactPhone(tenantId, patientId, threadPhone, form if (!tenantId || !patientId || !threadPhone) return; try { // Busca tipos system - const { data: types } = await supabase - .from('contact_types') + const { data: types } = await tenantDb().from('contact_types') .select('id, slug') - .is('tenant_id', null); + .eq('is_system', true); const celularType = types?.find((t) => t.slug === 'celular'); const whatsappType = types?.find((t) => t.slug === 'whatsapp'); @@ -257,7 +249,6 @@ async function insertWhatsappContactPhone(tenantId, patientId, threadPhone, form // Celular primary (from form — o que o user digitou no cadastro rápido) if (celularType && formDigits && formDigits.length >= 8) { rows.push({ - tenant_id: tenantId, entity_type: 'patient', entity_id: patientId, contact_type_id: celularType.id, @@ -270,7 +261,6 @@ async function insertWhatsappContactPhone(tenantId, patientId, threadPhone, form // WhatsApp linked (from thread) — só se diferente do celular if (whatsappType && phoneDigits && formNoDdi !== threadNoDdi) { rows.push({ - tenant_id: tenantId, entity_type: 'patient', entity_id: patientId, contact_type_id: whatsappType.id, @@ -285,7 +275,7 @@ async function insertWhatsappContactPhone(tenantId, patientId, threadPhone, form } if (rows.length > 0) { - await supabase.from('contact_phones').insert(rows); + await tenantDb().from('contact_phones').insert(rows); } } catch (e) { console.warn('[ConversationDrawer] insert whatsapp contact_phones:', e?.message); diff --git a/src/components/conversations/GlobalInboundNotifier.vue b/src/components/conversations/GlobalInboundNotifier.vue index f6241df..8590c03 100644 --- a/src/components/conversations/GlobalInboundNotifier.vue +++ b/src/components/conversations/GlobalInboundNotifier.vue @@ -13,6 +13,7 @@