a7f6bcbe66
- 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>
185 lines
8.5 KiB
Python
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))
|