# -*- 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))