Files
agenciapsilmno/scripts/codemod-tenant-db.py
T
Leonardo a7f6bcbe66 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>
2026-06-13 04:44:59 -03:00

185 lines
8.5 KiB
Python

# -*- coding: utf-8 -*-
"""
F3 schema-per-tenant: codemod do frontend.
1. supabase.from('<tabela/view tenant>') -> tenantDb().from('...') (84 tabelas + 6 views)
2. injeta import { tenantDb } from '@/lib/supabase/tenantClient'
3. remove .eq('tenant_id', <expr>) 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(<nao-literal>) 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))