Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ce3612135 | |||
| eb9dcc714f | |||
| 6383a550a6 | |||
| c3dac5eeec | |||
| 9acce9c19d | |||
| 91b89b7b5d | |||
| 1082123967 | |||
| 2f72886d4b | |||
| 403b7234a9 | |||
| 52c34cf63a | |||
| f6470718b7 | |||
| 3730b71150 | |||
| d50073da1a | |||
| 03790ecb9e | |||
| cb153165c3 | |||
| c189906c58 | |||
| 5a87c29dd0 | |||
| a2f3b9fae4 | |||
| 1594dc9426 | |||
| 31c4f08451 | |||
| 12d5c3b6dc | |||
| a979bdf1de | |||
| a73b82fa86 | |||
| 98fe183bac | |||
| 8b620f9b04 | |||
| 96f4543138 | |||
| dc2363b4e1 | |||
| 4493e78349 | |||
| cdb9ce10ee | |||
| af2395c723 | |||
| dc7826d0b5 | |||
| 218d342181 | |||
| ee82985dc3 | |||
| 423aa5ac2a | |||
| 1243a12ced | |||
| f079192698 | |||
| 02acc88da5 | |||
| d3620f99ea | |||
| d240c6678f | |||
| 120b1e44d8 | |||
| b6bd6fdd89 | |||
| bedbb9bafc | |||
| 77ef06fde7 | |||
| 5741e10e28 | |||
| d58b939e1c | |||
| c3220f159c | |||
| 003f2eb2a6 | |||
| a85716b0ea | |||
| 6b542cd03a | |||
| 07437c9ff4 | |||
| f17e9ee786 | |||
| 9b21642e15 | |||
| ba8348d4a6 | |||
| a7f6bcbe66 |
@@ -1648,3 +1648,75 @@ Touched: Migracao Schema-per-Tenant, index
|
||||
|
||||
## [2026-06-12 11:49] session | F1 schema-per-tenant: template + helpers + clone
|
||||
Touched: Migracao Schema-per-Tenant
|
||||
|
||||
## [2026-06-13 04:52] session | F3 schema-per-tenant: frontend tenantDb
|
||||
Touched: Migracao Schema-per-Tenant
|
||||
|
||||
## [2026-06-13 09:10] session | F4 edge functions + F1b anon-tables-public
|
||||
Touched: Migracao Schema-per-Tenant
|
||||
|
||||
## [2026-06-13 09:26] session | F5 PostgREST expoe schemas tenant (E2E HTTP)
|
||||
Touched: Migracao Schema-per-Tenant
|
||||
|
||||
## [2026-06-13 12:53] session | F6.0+F6.1 clones + migracao dados; plano F6.2
|
||||
Touched: Migracao Schema-per-Tenant
|
||||
|
||||
## [2026-06-13 13:34] session | F6.2 Lote A+B triggers (agnosticos + schema-aware)
|
||||
Touched: Migracao Schema-per-Tenant
|
||||
|
||||
## [2026-06-13 14:10] session | F6.2 Lote C split notifications
|
||||
Touched: Migracao Schema-per-Tenant
|
||||
|
||||
## [2026-06-13 14:37] session | F6.2 Lote D scoped (15 RPCs); checkpoint
|
||||
Touched: Migracao Schema-per-Tenant
|
||||
|
||||
## [2026-06-13 15:13] session | F6.2 Lote D RPCs user-facing
|
||||
Touched: Migracao Schema-per-Tenant
|
||||
|
||||
## [2026-06-13 15:26] session | F6.2 Lote E cron/global RPCs
|
||||
Touched: Migracao Schema-per-Tenant
|
||||
|
||||
## [2026-06-13 15:51] session | F6.2 Lote F anon/token RPCs
|
||||
Touched: Migracao Schema-per-Tenant
|
||||
|
||||
## [2026-06-13 16:01] session | F6.2 Lote G + F6.2 COMPLETA (66 funcoes)
|
||||
Touched: Migracao Schema-per-Tenant
|
||||
|
||||
## [2026-06-13 16:26] session | F6 wiring no clone (tenants novos completos)
|
||||
Touched: Migracao Schema-per-Tenant
|
||||
|
||||
## [2026-06-13 16:45] session | F6.3 preparada (nao-aplicada) + itens SaaS-admin
|
||||
Touched: Migracao Schema-per-Tenant
|
||||
|
||||
## [2026-06-13 17:02] session | F6.4 superficie SaaS-admin resolvida (F6.3 desbloqueada)
|
||||
Touched: Migracao Schema-per-Tenant
|
||||
|
||||
## [2026-06-13 17:14] session | schema-per-tenant F0-F6.4 + wiring + rollback (F6.3 nao aplicada)
|
||||
Touched: Migracao Schema-per-Tenant
|
||||
|
||||
## [2026-06-13 18:30] session | Freemium/PLG F0 descoberta + 4 decisões
|
||||
Touched: Freemium PLG
|
||||
|
||||
## [2026-06-13 19:45] session | Freemium F1 done (enforcement pacientes + Upgrade PRO)
|
||||
Touched: Freemium PLG
|
||||
|
||||
## [2026-06-13 21:40] session | Freemium F2 nucleo (RPCs + signup + onboarding + audit fix)
|
||||
Touched: Freemium PLG
|
||||
|
||||
## [2026-06-13 22:30] session | Freemium F3 done (4 extras: blacklist, usuarios+notify, root_redirect, esqueci-email)
|
||||
Touched: Freemium PLG
|
||||
|
||||
## [2026-06-13 23:10] session | Freemium F2 polish done (welcome email + vitrine free); F1/F2/F3 completos
|
||||
Touched: Freemium PLG
|
||||
|
||||
## [2026-06-13 23:30] session | Freemium F4 runbook de deploy (docs/DEPLOY_FREEMIUM_F4.md)
|
||||
Touched: Freemium PLG
|
||||
|
||||
## [2026-06-13 23:55] session | Runbook deploy schema-per-tenant (docs/DEPLOY_SCHEMA_PER_TENANT.md)
|
||||
Touched: Migracao Schema-per-Tenant, Freemium PLG
|
||||
|
||||
## [2026-06-14 00:10] session | Handoff completo (estado + riscos + testes) + 2 runbooks de deploy
|
||||
Touched: Freemium PLG, Migracao Schema-per-Tenant
|
||||
|
||||
## [2026-06-14 00:25] session | Stack reiniciado (confirmacao e-mail ON) + gotcha pgrst.db_schemas pos-restart
|
||||
Touched: Freemium PLG, Migracao Schema-per-Tenant
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Freemium / PLG
|
||||
|
||||
Épico iniciado em 2026-06-13, branch `feat/freemium-plg` (sobre [[Migracao Schema-per-Tenant]]). Objetivo: qualquer visitante cria conta gratuita sozinho, confirma e-mail, e o ambiente do tenant é provisionado automaticamente. Plano gratuito limitado + botão "Upgrade PRO". Blueprint-diretor: `novo-rumo.txt` (raiz), vindo do sistema-irmão (sindicato) e adaptado a clínica.
|
||||
|
||||
## Descoberta (Fase 0) — o que já existia
|
||||
|
||||
O sistema já estava ~70-85% pronto:
|
||||
- **Planos free existem**: `clinic_free`, `therapist_free` (+ supervisor/patient) com `plan_features.limits` semeado (`clinic_free` → `clinic_calendar {max_patients:30, max_therapists:5}`, `online_scheduling {sessions_per_month:40}`, `reminders {reminders_per_month:50}`, `documents.upload {max_storage_mb:500}`; 14 features premium OFF).
|
||||
- **Feature gating completo**: `entitlementsStore.js` (views `v_tenant_entitlements`/`v_user_entitlements`), `FeatureGate.vue`, guard `meta.feature` → `/upgrade` (`guards.js:814`), badge PRO no menu.
|
||||
- **Provisionamento schema-per-tenant**: `ensure_personal_tenant`/`provision_account_tenant` → `clone_tenant_template`. Setup Wizard.
|
||||
- **Signup self-service**: `/lp` (pricing dinâmico de `v_public_pricing`) → `/auth/signup` (`Signup.vue:219` `signUp` inline, cria intent só no pago).
|
||||
- RPCs `activate_subscription_from_intent`, `change_subscription_plan`. `tenants.slug` 100% populado.
|
||||
|
||||
**Gap confirmado:** limites semeados mas **ninguém lê/enforça**. Sem confirmação de e-mail (`enable_confirmations=false`), sem /onboarding, signup só coleta email+senha, sem welcome email, sem os extras.
|
||||
|
||||
## Decisões (Fase 0.5)
|
||||
|
||||
1. **Modelo do blueprint** — confirmação de e-mail ON; signup grava escolha em `raw_user_meta_data` + signOut-local + tela "confirme e-mail"; provisionamento+intent viram RPCs idempotentes no 1º login (`auto_provision_free_tenant(p_slug_override)`, `processar_pos_signup`); guard manda logado-sem-tenant → `/onboarding`. Reescreve o signup inline.
|
||||
2. **Pacientes** = recurso limitado. Trigger BEFORE INSERT em `patients` lê limits em runtime, resolve tenant por `TG_TABLE_SCHEMA`, conta linhas vivas, `RAISE 'PLAN_LIMIT_REACHED|patients|<n>'`. clinic_free=30, therapist_free=20. No template + backfill 9 schemas.
|
||||
3. **Slug escolhido** no signup (sugestão sanitizada + `slug_disponivel(p_slug)→{ok,motivo}`), imutável, trava 3 camadas.
|
||||
4. **Todos os 4 extras**: /saas/usuarios + `notify_all_devs`; esqueci-email (magic link por slug, dica mascarada); blacklist (email|slug); root_redirect.
|
||||
|
||||
## Pegadinhas (do blueprint, ⚠️ caras no irmão)
|
||||
|
||||
- **#1** Signup sem sessão (confirmação ON) → tudo com `auth.uid()` quebra em silêncio. Gravar escolha em metadata, processar pós-confirmação.
|
||||
- **#2** signOut `scope:'local'` se não veio sessão — senão vaza sessão anterior e joga no painel errado.
|
||||
- **#3** Logado-sem-tenant nunca cai em painel quebrado → `/onboarding` resolve estados (provisionando, slug-colidiu, pago-aguardando, sem-acesso, erro).
|
||||
- **#4** Sino de notificação singleton precisa re-buscar ao trocar de user (logout+login).
|
||||
|
||||
## Divergência de infra
|
||||
|
||||
Blueprint pede welcome email via **Resend**; aqui é **SMTP/Mailpit** (`process-email-queue`). Reusar o pipeline SMTP existente (best-effort), não Resend.
|
||||
|
||||
## Fases
|
||||
|
||||
- **F1** ✅ DONE (2026-06-13) — therapist_free ganhou max_patients=20; trigger `enforce_patient_plan_limit` em patients (lê `plan_features.limits` em runtime, resolve plano via `tenant_active_plan_id`, conta vivos, RAISE `PLAN_LIMIT_REACHED|patients|n`); helpers globais + wiring + backfill 9 schemas. Front: `utils/planLimit.js` (toast com CTA via grupo system-alerts) nos 3 pontos de criação de paciente + botão **Upgrade PRO** no AppTopbar quando plano é free. Migrations: `20260613000005_*` + `manual/freemium_f1_plan_limits.supabase_admin.sql`. Testado em ROLLBACK (clinic_free bloqueia em 30, therapist_free em 20, PRO ilimitado).
|
||||
- **F2** 🟡 NÚCLEO DONE (2026-06-13) — `enable_confirmations=true` (config.toml, gitignored, ativa no restart do stack); RPCs `slug_disponivel`/`auto_provision_free_tenant`/`processar_pos_signup` (manual/freemium_f2_provisioning.supabase_admin.sql, testados em ROLLBACK clínica+terapeuta); **fix de regressão** `log_audit_change` (migration 20260613000006) que quebrava INSERT em tenant_members; Signup.vue reescrito (kind+nome+slug ao vivo+metadata, signOut-local + tela confirme-email); OnboardingPage.vue (provision+estados slug-colidiu/erro); guard → /onboarding; rota registrada. Build OK. **Restam (polish):** welcome email best-effort (infra SMTP schema-per-tenant) + apresentação do free na vitrine (public_name/preço "Grátis"/bullets — os planos já são is_visible=true mas sem nome/preço).
|
||||
- **F3** ✅ DONE (2026-06-13) — 4 extras. DB/edge: `blacklist` (tabela + trigger BEFORE INSERT em auth.users + integra slug_disponivel motivo 'bloqueado'); `saas_list_account_owners()` (donos por tenant, dev-only) + `notify_all_devs` + trigger em subscriptions; `saas_app_config`/`get_root_redirect()`; edge `recover-access` (esqueci-email por slug → magic link, dica mascarada). Front: SaasUsuariosPage (/saas/usuarios, selo Novo 24h) + SaasAppConfigPage (/saas/app-config, blacklist CRUD + toggle root_redirect); esqueci-email dialog no Login; root_redirect no guard ("/" não-logado→/lp|/login, cache TTL); pegadinha #4 (notificationStore.reset no logout). Arquivos: manual/freemium_f3a/b/c + functions/recover-access. Build OK, DB testado em ROLLBACK. ⚠️ edge recover-access precisa deploy (F4).
|
||||
- **F2 polish** ✅ DONE (2026-06-13) — welcome email: edge `send-welcome-email` (dono do tenant, destinatário do JWT, SMTP global/sistema com defaults Mailpit; best-effort fire-and-forget no OnboardingPage só no provision novo). Vitrine: seed `plan_public`+bullets dos free (migration 20260613000007); Landingpage mostra "Grátis para sempre" via `isFreePlan`. ⚠️ send-welcome-email precisa deploy + envs SMTP no hosted (F4). Com isso **F2 está 100%**.
|
||||
- **F4** — Deploy (hosted, dirigido pelo Leonardo). **Runbook completo em `docs/DEPLOY_FREEMIUM_F4.md`** (commit 2f72886): pré-req #0 = schema-per-tenant no hosted antes; migrations 05/06/07 + 5 manual/freemium_f* + Auth dashboard + deploy das 2 edges + secrets SMTP + rebuild + smoke 8 passos + kill-switches.
|
||||
|
||||
Método: commits por assunto; cada migration testada em transação com ROLLBACK antes de aplicar; build a cada bloco front.
|
||||
@@ -1,6 +1,98 @@
|
||||
# Migração Schema-per-Tenant
|
||||
|
||||
**Status:** F2 concluída e smoke-testada (2026-06-12). Próximo: F3 (frontend useTenantDb).
|
||||
**Status:** F6.0 + F6.1 concluídas e verificadas (2026-06-13). Dados dos 9 tenants migrados pros schemas, AINDA espelhados em public (nada dropado), backup em `database-novo/backups/pre-F6/`. Próximo: **F6.2 (rewrite das funções — push dedicado)**, depois checkpoint de teste do app, depois F6.3 (DROP, só com OK do Leonardo). F0-F2 em `main`; F3+/F1b/F5/F6.0-1 no branch.
|
||||
|
||||
## F6.0 + F6.1 — entregue (commit 003f2eb)
|
||||
- F6.0 (migration 20260613000003): clone dos 9 tenants reais (schemas vazios, expostos no PostgREST via trigger F5).
|
||||
- F6.1 (manual `database-novo/manual/f6_1_migrate_data.supabase_admin.sql`, rodar como supabase_admin): copia dados public→schemas com `session_replication_role=replica`. Tabelas com tenant_id por filtro; 3 filhas sem tenant_id (commitment_services, insurance_plan_services, recurrence_rule_services) por JOIN no pai; exclui colunas GENERATED (`net_amount`, `margin_brl`); reset de 4 sequences; ON CONFLICT DO NOTHING.
|
||||
- **Verificado**: contagens public vs schemas batem (35 patients, 37 eventos, 355 mensagens, 54 financeiro, 13 commitment_services…). notification_templates "146" = 144 seeds (16×9) + 2 tenant — esperado.
|
||||
- Gotcha: colunas GENERATED não aceitam INSERT → excluir via `is_generated='NEVER'`. DO block é atômico → erro no meio dá rollback total (re-rodar é seguro com ON CONFLICT).
|
||||
|
||||
## F6.2 — PLANO (rewrite de 66 funções + split notifications) — próximo push
|
||||
Decisões já tomadas: parar antes do DROP pra testar app; split notifications JUNTO na F6.
|
||||
|
||||
Inventário de triggers (81 attachments nas tabelas tenant em public):
|
||||
- **Schema-agnósticos** (só NEW/OLD, sem refs a tabela): família `set_updated_at`/`set_*_updated_at`, `fn_clinical_notes_updated_at`, `prevent_promoting_to_system`, `prevent_system_group_changes`, `patients_validate_member_consistency`, `fn_agenda_regras_semanais_no_overlap` → anexar nos schemas como estão.
|
||||
- **Schema-aware** (~10, escrevem em OUTRA tabela tenant / audit / notifications) → reescrever com `set_config('search_path', TG_TABLE_SCHEMA||',public,pg_temp', true)` e `tenant_id_for_schema(TG_TABLE_SCHEMA)` onde precisam do tenant_id (audit_logs/notifications_sistema são globais): `auto_create_financial_record_from_session`, `sync_busy_mirror_agenda_eventos`, `notify_on_session_status`, `fanout_inbound_message_to_notifications`, `log_audit_change`, `fn_sla_resolve_on_outbound`, `fn_clinical_note_version`, `fn_document_signature_timeline`, `fn_documents_timeline_insert`, `trg_fn_patient_status_history/timeline/risco`, `sync_legacy_email/phone_fields`, `agenda_cfg_sync`, `cancel_notifications_*`, `fn_notify_agenda_status_change`, `trg_fn_financial_records_auto_overdue`.
|
||||
- **`financial_records_inject_tenant` → OBSOLETO** no schema (não há coluna tenant_id) — NÃO anexar.
|
||||
|
||||
Sub-lotes propostos (cada um com smoke test + commit):
|
||||
- **A** ✅ DONE (commit d58b939, migration 20260613000004): `attach_agnostic_triggers(schema)` recria os triggers agnósticos (8 setters updated_at + 2 prevent_*) nos 9 schemas (54 triggers/schema). Smoke: set_updated_at dispara. Wiring no clone fica pro fim da F6.2.
|
||||
- **B** ✅ DONE (commit 5741e10, manual/f6_2b_schema_aware_triggers.supabase_admin.sql — roda como supabase_admin pois trigger fns são owned por supabase_admin). 14 funcs reescritas (set_config search_path dinâmico + tenant_id_for_schema p/ audit_logs global); sync_busy_mirror cross-tenant via tenant_schema_for+EXECUTE format; financial_records_inject_tenant obsoleto (não anexado). Detach dos 14 de public + attach 22 triggers/schema (defs reais, tenant_id removido de WHEN/UPDATE OF). Smoke: sessão→realizado cria financial_record no schema + audit roteia tenant_id certo + timeline OK.
|
||||
- **Gotchas Lote B**: (1) trigger functions owned por supabase_admin → CREATE OR REPLACE só como supabase_admin (vira manual, não db.cjs). (2) Triggers reais tinham `WHEN (new.tenant_id=new.owner_id)` e `UPDATE OF tenant_id,...` → quebram no schema; remover tenant_id dos WHEN/colunas ao re-anexar. (3) Estratégia hybrid: detach de public pra função reescrita não rodar errada lá.
|
||||
- **C** ✅ DONE (commit bedbb9b, manual/f6_2c_notifications_split.supabase_admin.sql). DESCOBERTA: neste projeto TODAS as notifs atuais (inbound_message, session_status, system_alert, new_patient) são tenant-LOCAIS — avisos cross-tenant do SaaS vivem em `global_notices`, não em notifications. Então: notifications fica tenant-local (já nos schemas); `public.notifications_sistema` criado como canal SaaS→tenant FUTURO (vazio hoje) + RLS + realtime + notify_user_sistema(). 4 notif-triggers tenant reescritos schema-aware + detach public + attach (5/schema); notify_on_intake/scheduling disparam em tabelas PUBLIC (F1b) → roteiam pro schema via tenant_schema_for+EXECUTE format; cancel_patient_pending herda search_path do chamador. Smoke: msg inbound → notif no schema, destinatário certo. Frontend notificationStore.js: load 2 fontes + merge por created_at + `_origem`; realtime 2 canais; markRead/archive roteiam por _origem. conversation_messages.id é bigint (gotcha no teste).
|
||||
- **D** ✅ DONE (commit d240c66, manual/f6_2d_user_rpcs.supabase_admin.sql + 18 sites FE; build passa). 14 RPCs (list_my_signatures→F). Helper `_tenant_route(p_tenant_id)` valida + RETORNA schema (não seta — set_config em helper com SET search_path próprio é revertido na saída! cada RPC faz o set_config). Grupo3 RETURNS<tabela>→jsonb (mark_as_paid, create_financial_record_for_session, mark_payout, create_therapist_payout). FE: p_tenant_id de activeTenantId; SETOF→jsonb transparente (nenhum consumidor indexava array). Smoke: mark_as_paid + search_global OK.
|
||||
- **Gotchas Lote D**: (1) set_config em função-helper com `SET search_path` próprio é REVERTIDO ao retornar → helper retorna schema, RPC faz o set_config. (2) %ROWTYPE/RETURNS<tabela_tenant> quebram → RECORD/jsonb. (3) search_global é MISTO (patients/agenda no schema, patient_intake_requests em public/F1b). (4) seed_* chamados por provision ANTES do clone → no-op se schema não existe (fix de ordem no wiring). (5) can_delete_patient SQL sem SET search_path herda do chamador.
|
||||
- Categorias originais (ref):
|
||||
- **CREATE OR REPLACE, já têm p_tenant_id, RETURNS jsonb/void** (sem ripple FE): `delete_commitment_full`, `delete_determined_commitment`, `seed_default_patient_groups`, `seed_determined_commitments` (⚠️ provision_account_tenant chama seed ANTES de clone — inverter ordem no wiring do clone, senão seed escreve em public). 0 chamadas FE.
|
||||
- **DROP+CREATE (novo p_tenant_id 1º param) + FE passa p_tenant_id, RETURNS scalar/jsonb**: `cancel_recurrence_from`(void,1 FE), `cancelar_eventos_serie`(int,0), `split_recurrence_at`(uuid,1), `safe_delete_patient`(jsonb,1), `export_patient_data`(jsonb,1 — toca ~10 tabelas tenant), `search_global`(jsonb STABLE,2), `list_my_signatures`(jsonb,1).
|
||||
- **RETURNS `<tabela_tenant>`/%ROWTYPE → jsonb (ripple FE: consumidores esperam row)**: `mark_as_paid`(SETOF financial_records,3 FE), `create_financial_record_for_session`(SETOF financial_records,6 FE — já tem p_tenant_id), `mark_payout_as_paid`(therapist_payouts,0), `create_therapist_payout`(therapist_payouts,0 — agregação financeira, testar com cuidado).
|
||||
- Owned mix postgres/supabase_admin → rodar migration como supabase_admin.
|
||||
- **E** ✅ DONE (commit 02acc88, manual/f6_2e_cron_rpcs.supabase_admin.sql + 2 edge). E2 (cleanup/unstick/sync_overdue/populate_notification_queue) varrem todos os schemas via loop `FROM tenant_schemas`. E1 (sla_*, whatsapp_heartbeat_*, convert_abandoned_intake_to_lead) per-tenant via service_role: helper `_tenant_schema_unchecked` (SEM is_tenant_member, pq service_role não é membro) + REVOKE de anon/authenticated. first_response_stats/_runs user-facing via _tenant_route. Edge whatsapp-heartbeat/sla ajustadas (admin.rpc + p_tenant_id). Smoke OK.
|
||||
- **Gotchas Lote E**: (1) service_role NÃO é tenant_member → RPCs de cron precisam de helper sem auth-check + REVOKE de authenticated (senão um user chamaria com tenant arbitrário). (2) conversation_messages NÃO tem coluna thread_key (é computada na view) → analytics computa inline. (3) DROP+CREATE de nova assinatura: dropar AMBAS (velha+nova) p/ idempotência.
|
||||
- **F** ✅ DONE (commit 1243a12, manual/f6_2f_anon_token_rpcs.supabase_admin.sql + 2 FE; build passa). Documentos anon resolvem tenant de `document_share_links.tenant_id` (public/F1b); agendador de `agendador_configuracoes.tenant_id`. document/document_signatures/access_logs/agenda no schema; share_links/agendador_* ficam public. %ROWTYPE→RECORD, RETURNS document_signatures→jsonb. sign_document_by_signature_id (paciente logado, NÃO é member): unchecked + auth por LINHA (signatario_id/email/doc do paciente). match_patient_by_phone: unchecked + REVOKE authenticated (só service). list_my_signatures: fan-out cross-schema. RPCs public-only (intake/invite/agendador_gerar_slug) SEM mudança. FE: signByPortal(tenantId,...).
|
||||
- **Gotcha Lote F**: paciente assinante NÃO é tenant_member → autorizar por LINHA (dono da assinatura), não por membership. Anon resolve tenant SEMPRE da tabela public que tem o token+tenant_id.
|
||||
- **G** ✅ DONE (commit ee82985, manual/f6_2g_sql_to_plpgsql.supabase_admin.sql + 3 FE; build passa). 5 funções SQL→plpgsql + p_tenant_id + _tenant_route (get_financial_summary/report, list_financial_records SETOF→jsonb, get_patient_session_counts sem filtro tenant_id). get_entity_primary_phone (interno) herda search_path. can_delete_patient/_first_response_runs já feitas em D/E. FE: p_tenant_id nas 3 RPCs financeiras.
|
||||
|
||||
## ✅✅ F6.2 COMPLETA (2026-06-13) — 66 funções migradas
|
||||
Triggers (A agnósticos + B schema-aware + C notif) + RPCs (D usuário + E cron + F anon/token + G SQL→plpgsql). Tudo smoke-testado, build passa. Próximo: **wiring no clone** + **F6.3 DROP** (com OK do Leonardo).
|
||||
- ✅ **wiring DONE** (commit dc7826d, manual/f6_2h_clone_wiring.supabase_admin.sql): trigger AFTER INSERT em tenant_schemas (trg_attach_business_triggers) dispara os 3 attach pro schema novo → tenant novo nasce com 84 triggers. attach_agnostic agora SELF-CONTAINED (dirigido por colunas, não lê public — sobrevive ao DROP). provision_account_tenant: clone ANTES do seed. Smoke OK.
|
||||
- **F6.3 DROP** 📋 PREPARADA não-aplicada (commit cdb9ce1, manual/f6_3_drop_public_tenant_tables.supabase_admin.sql): pré-flight assert + 2 FKs viram coluna solta (document_share_links.documento_id, whatsapp_credits_transactions.conversation_message_id) + dropa 9 views public + DROP CASCADE das 78 + limpa financial_records_inject_tenant. **BLOQUEADA** pelos itens em aberto abaixo.
|
||||
|
||||
## ✅ SUPERFÍCIE SaaS-ADMIN RESOLVIDA (F6.4, commit dc2363b)
|
||||
RPCs `saas_admin` (manual/f6_4_saas_admin_rpcs.supabase_admin.sql): defaults editados no `_tenant_template` + fan-out pros schemas (saas_list/add/remove_default_feriado; saas_*_default_notif_template; saas_count_notif_template_overrides). Cross-tenant: `saas_list_all_whatsapp_channels` (fan-out, substitui v_twilio_whatsapp_overview). FE: SaasFeriadosPage/SaasNotificationTemplatesPage → RPCs; SaasWhatsappPage → `supabase.schema(tenant_<slug>)` (RLS permite saas_admin) p/ tenant selecionado + RPC p/ overview; getAllChannels → RPC. **Varredura confirma ZERO supabase.from('<tabela_tenant>') público no FE.** F6.3 DESBLOQUEADA (falta só Leonardo testar app + backup). TODOs deixados: stat-cards de feriados (cidade/estado) e incidents-7d viraram 0 (UI degrada sem crash).
|
||||
|
||||
## (histórico) ITENS EM ABERTO antes do F6.3 DROP — RESOLVIDOS acima
|
||||
Superfície **SaaS-admin / cross-tenant** que ainda lê `public.<tabela_tenant>` e quebraria no DROP:
|
||||
1. **SaasWhatsappPage.vue + v_twilio_whatsapp_overview + twilioWhatsappService.getAllChannels()** — admin cross-tenant de canais WhatsApp (notification_channels/whatsapp_connection_incidents). Reescrever fan-out por schema OU usar `public.channel_routing`.
|
||||
2. **SaasNotificationTemplatesPage.vue** — gerencia templates DEFAULT do sistema (tenant_id NULL). Apontar pra `_tenant_template.notification_templates` (os defaults vivem lá agora).
|
||||
3. **SaasFeriadosPage.vue** — gerencia feriados nacionais default. Idem `_tenant_template.feriados`.
|
||||
4. **notification-webhook** (Meta) — conferir fan-out/channel_routing.
|
||||
Decisão de arquitetura: as páginas que editam DEFAULTS do sistema devem editar `_tenant_template` (propaga a tenants novos); as views cross-tenant admin devem fan-out por schema ou usar channel_routing. Resolver, testar, então aplicar F6.3.
|
||||
|
||||
## 🟢 APP TESTÁVEL AGORA (pós-wiring, pré-DROP)
|
||||
Dados nos schemas (F6.1) + 66 funções/triggers/RPCs roteiam (F6.2) + PostgREST expõe (F5) + frontend usa tenantDb (F3) + edge roteia (F4). Os dados ainda estão ESPELHADOS em public (nada dropado). Leonardo deve abrir o app no branch `feat/schema-per-tenant` e testar fluxos reais (agenda, financeiro, pacientes, documentos, notificações). Só após validação → F6.3 DROP.
|
||||
|
||||
## F5 — entregue (commit 6b542cd) — PRIMEIRO teste real via HTTP do PostgREST
|
||||
- `postgres` NÃO é superuser neste stack → não consegue `ALTER ROLE authenticator`. Quem consegue: `supabase_admin` (superuser, conecta com senha `postgres` via `psql -U supabase_admin -h 127.0.0.1`).
|
||||
- `database-novo/manual/f5_pgrst_refresh_schemas.supabase_admin.sql` (aplicar como supabase_admin, fora do db.cjs): `public.refresh_pgrst_schemas()` (SECDEF owned supabase_admin) deriva a lista de `tenant_schemas`, seta `pgrst.db_schemas` in-database na role authenticator, `NOTIFY pgrst reload config/schema`. **Expõe/retira schema SEM restart**; a GUC persiste em pg_db_role_setting (sobrevive a stop/start) e SUPERSEDE o config.toml em runtime.
|
||||
- migration `20260613000002`: trigger em `tenant_schemas` (AFTER INSERT/DELETE/UPDATE, statement-level) dispara o refresh → clone_tenant_template e drop_tenant_schema NÃO precisaram ser tocados.
|
||||
- config.toml (gitignored): baseline `public, graphql_public` + comentário; in-db config supersede.
|
||||
- **E2E via curl**: clone → `pgrst.db_schemas` inclui tenant_x → `GET /rest/v1/patients` com `Accept-Profile: tenant_x` retorna **200** (vs **406** pra schema inexistente); drop → volta 406. Tudo sem restart de container. Primeira validação real do stack F1-F5 pelo caminho HTTP do PostgREST.
|
||||
|
||||
### Gotcha F5
|
||||
- PostgREST in-database config (db-config ligada por padrão, sem `PGRST_DB_CONFIG=false`): `ALTER ROLE authenticator SET pgrst.db_schemas` + `NOTIFY pgrst, 'reload config'` é a via pra schemas dinâmicos sem restart. `reload schema` sozinho NÃO adiciona schema novo à lista exposta — só recarrega o cache dos já expostos.
|
||||
|
||||
## F4 — entregue (branch, commit 9b21642)
|
||||
- `_shared/tenant.ts`: helper das edge functions — `adminClient()` (service_role/public), `tenantDbForId(admin, tenantId)`, `schemaForTenant`, `listTenantSchemas` (crons varrem todos), `resolveTenantByChannel` (webhook→tenant via channel_routing), `tenantSchemaName`
|
||||
- `_shared/whatsapp-hooks.ts` refatorado: hooks de tabela tenant recebem `tdb`; RPCs de crédito (deduct/add_whatsapp_credits) + tenant_members continuam em `supa`+p_tenant_id
|
||||
- 23 edge functions migradas. Categorias:
|
||||
- **inbound** (twilio/evolution): tenant_id da URL → tdb
|
||||
- **crons de fila** (process-notification/email/sms/whatsapp-queue): varrem `listTenantSchemas` e drenam a fila de CADA schema — consequência direta da Q3 (filas viraram per-tenant). Modo single-tenant se `body.tenant_id` vier.
|
||||
- **crons reminders/checks** (send-session-reminders, conversation-sla-check, whatsapp-heartbeat-check, convert-abandoned-intakes, sync-email-templates): loop por tenant
|
||||
- **routing por tenant_id** (send-whatsapp-message, send-session-reminder-manual, twilio-provision, de/reactivate-channel, twilio-webhook): tenantDbForId; channel-actions sem tenant_id varrem schemas por channel_id (O(n) tenants)
|
||||
- **asaas-***: tenant_id do body → tdb; asaas-webhook fica global
|
||||
- **notification-webhook** (Meta Cloud API): resolve via channel_routing por phone_number_id, fan-out por message_id quando não casa
|
||||
- caller `useAgendaEventLifecycle.js` passa tenant_id pro send-session-reminder-manual (evento vive no schema)
|
||||
- Sem deno local → validado por grep (zero tenant_id em cadeias tdb, clients todos declarados, imports batem). Type-check real só no deploy.
|
||||
|
||||
### ⚠️ DECISÃO PENDENTE — roteamento anon-por-token (bloqueia F5/F6)
|
||||
Fluxos anônimos identificam o tenant por TOKEN/SLUG, não por login, então não sabem o schema: `save-intake-progress` (lê patient_intake_requests por token), intake RPCs (get-intake-invite-info, submit-patient-intake), `AgendadorPublicoPage`+RPCs do agendador (link_slug), document share links (validate_share_token, sign_document_by_token). Opções:
|
||||
- **A** Índice global `public_access_tokens(token_hash→tenant_id)` + triggers de sync (O(1), +1 tabela global + triggers)
|
||||
- **B** RPCs SECURITY DEFINER que varrem schemas pelo token (sem tabela nova, O(n) por request)
|
||||
- **C** Manter as tabelas anon-facing (patient_intake_requests, patient_invites, document_share_links, agendador_configuracoes/solicitacoes) em PUBLIC com RLS por token — sidesteppa o problema; custo: essas não ganham isolamento físico (mas são as menos sensíveis, feitas pra acesso anon)
|
||||
|
||||
## F3 — entregue (branch feat/schema-per-tenant, migration 07)
|
||||
- `src/lib/supabase/tenantClient.js` (`tenantDb()`, `tenantSchemaName()`) + `src/composables/useTenantDb.js`
|
||||
- `tenantStore`: getters `activeTenantSlug`/`activeTenantSchema`; `my_tenants()` RPC agora devolve slug+name (migration 20260612000007)
|
||||
- codemod `scripts/codemod-tenant-db.py`: `supabase.from('<84 tabelas + 6 views tenant>')` → `tenantDb().from(...)` em 139 arquivos (777 chamadas), removeu 173 `.eq('tenant_id')` de cadeias tenant
|
||||
- 4 agentes (2 ondas) fizeram a passada manual: tenant_id fora de payloads/selects/.or/.is; onConflict ajustado (singletons → `'singleton'`); realtime de tabelas tenant aponta pro `activeTenantSchema`; repos dropam tenant_id defensivamente de payloads de callers externos
|
||||
- **descoberta importante: ZERO embeds cross-schema** — todos os FK embeds são tenant→tenant (mesmo schema, ex. `agenda_eventos`→`patients`,`insurance_plans`) ou global→global (`profile_specialties`→`profiles`). O `attachProfiles`/fake-embed do blueprint NÃO é necessário aqui.
|
||||
- gotcha: `AGENDA_EVENT_SELECT` (constante de select) tinha tenant_id — selecionar coluna inexistente quebra PostgREST; varrer constantes `*_SELECT`, não só `.from()`
|
||||
|
||||
### Pendências F3 (fora do escopo, cross-tenant/anon → tratar em F4/F6)
|
||||
- `AgendadorPublicoPage.vue` — scheduler público anon, resolve tenant por `link_slug` (precisa RPC/edge de resolução slug→schema, igual channel_routing)
|
||||
- `Saas{Feriados,NotificationTemplates,DocumentTemplates,Whatsapp}Page.vue` — gerenciam defaults do sistema (tenant_id NULL) ou views cross-tenant; após F6 devem mirar `_tenant_template` ou `channel_routing`. Continuam apontando pra public (funcional até o drop da F6).
|
||||
|
||||
## F2 — entregue (migration 20260612000006)
|
||||
Os 3 únicos pontos de criação de tenant (`provision_account_tenant`, `create_clinic_tenant`, `ensure_personal_tenant_for_user` — este último também acionado pelo trigger de signup `handle_new_user_create_personal_tenant`) agora chamam `clone_tenant_template()` na mesma transação: clone falhou → tenant não nasce. Smoke: ensure_personal criou tenant pessoal `tenant_terapeuta_pessoal` com 84 tabelas + registro, 2ª chamada idempotente, drop limpou tudo. Não há fluxo de exclusão de tenant no sistema (drop_tenant_schema fica pra uso admin/manual).
|
||||
|
||||
@@ -31,3 +31,4 @@ _(synthesized answers to questions you've asked, filed back as pages)_
|
||||
|
||||
*This index is maintained by Claude via `/wiki-brain`. Do not edit by hand unless you know what you're doing.*
|
||||
- [[Migracao Schema-per-Tenant]] — migração RLS-only → schema físico por tenant (F0 done, aguardando Q1-Q4)
|
||||
- [[Freemium PLG]] — signup self-service + Upgrade PRO; plano gratuito limitado (pacientes); confirmação de e-mail + onboarding; branch feat/freemium-plg
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
-- =============================================================================
|
||||
-- F5 (parte supabase_admin) — refresh dinâmico dos schemas expostos no PostgREST
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin (postgres NÃO é superuser neste stack e não
|
||||
-- consegue ALTER ROLE authenticator). Mesmo padrão do gotcha de `documents`.
|
||||
--
|
||||
-- docker exec -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
|
||||
-- psql -U supabase_admin -h 127.0.0.1 -d postgres \
|
||||
-- -f /dev/stdin < database-novo/manual/f5_pgrst_refresh_schemas.supabase_admin.sql
|
||||
--
|
||||
-- A config in-database do PostgREST (db-config, ligada por padrão) lê
|
||||
-- pgrst.db_schemas da role `authenticator`. Setar essa GUC + NOTIFY reload
|
||||
-- expõe/retira schemas tenant SEM restart do container. A GUC persiste em
|
||||
-- pg_db_role_setting (sobrevive a supabase stop/start).
|
||||
--
|
||||
-- A lista é derivada SEMPRE de public.tenant_schemas (fonte da verdade dos
|
||||
-- schemas provisionados). Disparada pelo trigger em tenant_schemas (migration
|
||||
-- 20260613000002) a cada clone/drop de tenant.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.refresh_pgrst_schemas()
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER -- roda como o OWNER (supabase_admin/superuser)
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_list text;
|
||||
BEGIN
|
||||
SELECT string_agg(s, ', ' ORDER BY ord, s)
|
||||
INTO v_list
|
||||
FROM (
|
||||
SELECT 'public'::text AS s, 0 AS ord
|
||||
UNION ALL SELECT 'graphql_public', 1
|
||||
UNION ALL SELECT schema_name, 2 FROM public.tenant_schemas
|
||||
) x;
|
||||
|
||||
-- baseline defensivo se a tabela ainda não existir / vazia
|
||||
IF v_list IS NULL OR v_list = '' THEN
|
||||
v_list := 'public, graphql_public';
|
||||
END IF;
|
||||
|
||||
EXECUTE format('ALTER ROLE authenticator SET pgrst.db_schemas = %L', v_list);
|
||||
NOTIFY pgrst, 'reload config'; -- re-lê db_schemas
|
||||
NOTIFY pgrst, 'reload schema'; -- reconstrói o cache de schema
|
||||
RETURN v_list;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Garante owner superuser (caso a função já existisse owned por postgres)
|
||||
ALTER FUNCTION public.refresh_pgrst_schemas() OWNER TO supabase_admin;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.refresh_pgrst_schemas() FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.refresh_pgrst_schemas() TO postgres, service_role;
|
||||
|
||||
-- Seta o baseline imediatamente
|
||||
SELECT public.refresh_pgrst_schemas();
|
||||
@@ -0,0 +1,140 @@
|
||||
-- =============================================================================
|
||||
-- F6.1 — Migração de DADOS public -> schemas tenant (cutover)
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin (precisa SET session_replication_role=replica
|
||||
-- pra desabilitar checagem de FK durante o bulk insert — postgres não pode).
|
||||
--
|
||||
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
|
||||
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
|
||||
-- < database-novo/manual/f6_1_migrate_data.supabase_admin.sql
|
||||
--
|
||||
-- COPIA (não move) os dados de cada tenant pras suas tabelas no schema. Os
|
||||
-- dados continuam em public até o DROP da F6.3. Idempotente via ON CONFLICT
|
||||
-- DO NOTHING (rodar de novo não duplica).
|
||||
--
|
||||
-- * tabelas com tenant_id: INSERT ... SELECT WHERE tenant_id = <id>, sem a
|
||||
-- coluna tenant_id (não existe no schema)
|
||||
-- * 3 filhas sem tenant_id (commitment_services, insurance_plan_services,
|
||||
-- recurrence_rule_services): particionadas via JOIN no pai
|
||||
-- * financial_categories / therapist_payout_records: 0 linhas, ignoradas
|
||||
-- * as 6 tabelas anon-facing (F1b) NÃO existem no schema → naturalmente fora
|
||||
-- * reset de sequences (4 tabelas bigserial) ao final
|
||||
-- =============================================================================
|
||||
|
||||
SET session_replication_role = replica;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
t_row record;
|
||||
tab record;
|
||||
v_cols text;
|
||||
v_n bigint;
|
||||
-- filhas sem tenant_id: tabela -> (pai, fk_local, pk_pai)
|
||||
child_joins jsonb := jsonb_build_object(
|
||||
'commitment_services', jsonb_build_object('parent','agenda_eventos','fk','commitment_id'),
|
||||
'insurance_plan_services', jsonb_build_object('parent','insurance_plans','fk','insurance_plan_id'),
|
||||
'recurrence_rule_services', jsonb_build_object('parent','recurrence_rules','fk','rule_id')
|
||||
);
|
||||
cj jsonb;
|
||||
BEGIN
|
||||
FOR t_row IN
|
||||
SELECT t.id AS tenant_id, ts.schema_name
|
||||
FROM public.tenants t
|
||||
JOIN public.tenant_schemas ts ON ts.tenant_id = t.id
|
||||
ORDER BY t.created_at, t.id
|
||||
LOOP
|
||||
FOR tab IN
|
||||
SELECT c.relname AS table_name
|
||||
FROM pg_class c
|
||||
WHERE c.relnamespace = t_row.schema_name::regnamespace
|
||||
AND c.relkind = 'r'
|
||||
AND c.relname NOT LIKE '\_%'
|
||||
ORDER BY c.relname
|
||||
LOOP
|
||||
-- pula se a tabela não existe em public (defensivo)
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema='public' AND table_name=tab.table_name) THEN
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
-- colunas presentes em AMBOS (schema e public): exclui tenant_id
|
||||
-- (some no schema), singleton (só no schema, fica no default) e
|
||||
-- colunas GENERATED (net_amount, margin_brl — não aceitam INSERT)
|
||||
SELECT string_agg(quote_ident(sc.column_name), ', ' ORDER BY sc.ordinal_position)
|
||||
INTO v_cols
|
||||
FROM information_schema.columns sc
|
||||
WHERE sc.table_schema = t_row.schema_name AND sc.table_name = tab.table_name
|
||||
AND sc.is_generated = 'NEVER'
|
||||
AND EXISTS (SELECT 1 FROM information_schema.columns pc
|
||||
WHERE pc.table_schema='public' AND pc.table_name=tab.table_name
|
||||
AND pc.column_name = sc.column_name
|
||||
AND pc.is_generated = 'NEVER');
|
||||
|
||||
IF v_cols IS NULL THEN CONTINUE; END IF;
|
||||
|
||||
cj := child_joins -> tab.table_name;
|
||||
|
||||
IF cj IS NOT NULL THEN
|
||||
-- filha sem tenant_id: particiona via JOIN no pai
|
||||
EXECUTE format(
|
||||
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I ch '
|
||||
|| 'JOIN public.%I p ON p.id = ch.%I WHERE p.tenant_id = %L '
|
||||
|| 'ON CONFLICT DO NOTHING',
|
||||
t_row.schema_name, tab.table_name, v_cols,
|
||||
(SELECT string_agg('ch.'||quote_ident(x), ', ' ORDER BY ord)
|
||||
FROM (SELECT trim(both ' ' from unnest(string_to_array(v_cols, ','))) AS x,
|
||||
generate_subscripts(string_to_array(v_cols, ','),1) AS ord) y),
|
||||
tab.table_name,
|
||||
(cj->>'parent'), (cj->>'fk'),
|
||||
t_row.tenant_id
|
||||
);
|
||||
ELSIF EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema='public' AND table_name=tab.table_name AND column_name='tenant_id') THEN
|
||||
-- tabela com tenant_id: filtro direto
|
||||
EXECUTE format(
|
||||
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I WHERE tenant_id = %L ON CONFLICT DO NOTHING',
|
||||
t_row.schema_name, tab.table_name, v_cols, v_cols, tab.table_name, t_row.tenant_id
|
||||
);
|
||||
ELSE
|
||||
-- sem tenant_id e não é filha mapeada (financial_categories etc.):
|
||||
-- só migra se tiver 0 dependência de tenant — pula (vazias hoje)
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
GET DIAGNOSTICS v_n = ROW_COUNT;
|
||||
IF v_n > 0 THEN
|
||||
RAISE NOTICE 'F6.1 %.%: % linhas', t_row.schema_name, tab.table_name, v_n;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Reset de sequences (tabelas bigserial) em cada schema
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$
|
||||
DECLARE
|
||||
t_row record;
|
||||
r record;
|
||||
v_seq text;
|
||||
BEGIN
|
||||
FOR t_row IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
FOR r IN
|
||||
SELECT c.relname AS table_name, a.attname AS column_name
|
||||
FROM pg_attrdef d
|
||||
JOIN pg_class c ON c.oid = d.adrelid
|
||||
JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum
|
||||
WHERE c.relnamespace = t_row.schema_name::regnamespace
|
||||
AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval(%'
|
||||
LOOP
|
||||
v_seq := pg_get_serial_sequence(format('%I.%I', t_row.schema_name, r.table_name), r.column_name);
|
||||
IF v_seq IS NOT NULL THEN
|
||||
EXECUTE format('SELECT setval(%L, COALESCE((SELECT MAX(%I) FROM %I.%I), 0) + 1, false)',
|
||||
v_seq, r.column_name, t_row.schema_name, r.table_name);
|
||||
RAISE NOTICE 'F6.1 seq %.% -> %', t_row.schema_name, r.table_name, v_seq;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
SET session_replication_role = origin;
|
||||
@@ -0,0 +1,434 @@
|
||||
-- =============================================================================
|
||||
-- F6.2 Lote B — triggers schema-aware
|
||||
-- ⚠️ APLICAR COMO supabase_admin (trigger functions sao owned por supabase_admin):
|
||||
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \n-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \n-- < database-novo/manual/f6_2b_schema_aware_triggers.supabase_admin.sql
|
||||
--
|
||||
-- Estratégia hybrid: as funções são reescritas IN PLACE pra operar no schema do
|
||||
-- TG_TABLE_SCHEMA (search_path dinâmico + tenant_id_for_schema). Como ficariam
|
||||
-- erradas nas tabelas de public (TG_TABLE_SCHEMA='public'), DESANEXAMOS dos
|
||||
-- tenant-tables de public e ANEXAMOS só nos schemas. Writes de public via RPCs
|
||||
-- ainda-não-migrados (Lote D) perdem esses side-effects no curto hybrid —
|
||||
-- aceitável (public vai ser dropado na F6.3 e o app lê dos schemas).
|
||||
--
|
||||
-- Exclui os que escrevem em notifications (Lote C, com o split):
|
||||
-- notify_on_session_status, fanout_inbound_message_to_notifications,
|
||||
-- cancel_notifications_on_opt_out/on_session_cancel, fn_notify_agenda_status_change
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 1) Rewrites — tabelas tenant via search_path (unqualified); globais com public.
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- audit_logs é GLOBAL → tenant_id vem do schema
|
||||
CREATE OR REPLACE FUNCTION public.log_audit_change()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tenant_id uuid; v_entity_id text; v_old jsonb; v_new jsonb; v_changed text[];
|
||||
v_heavy text[] := ARRAY['content','content_html','content_json','raw_data','signature_data','pdf_blob','binary','body_html','body_text'];
|
||||
v_noise text[] := ARRAY['updated_at','last_seen_at','last_activity_at'];
|
||||
BEGIN
|
||||
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
v_entity_id := OLD.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := NULL;
|
||||
ELSIF TG_OP = 'INSERT' THEN
|
||||
v_entity_id := NEW.id::text; v_old := NULL; v_new := to_jsonb(NEW) - v_heavy;
|
||||
ELSE
|
||||
v_entity_id := NEW.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := to_jsonb(NEW) - v_heavy;
|
||||
SELECT array_agg(key ORDER BY key) INTO v_changed
|
||||
FROM jsonb_each(to_jsonb(NEW)) AS kv(key, value)
|
||||
WHERE (to_jsonb(OLD))->kv.key IS DISTINCT FROM kv.value;
|
||||
IF v_changed IS NULL THEN RETURN NEW; END IF;
|
||||
IF v_changed <@ v_noise THEN RETURN NEW; END IF;
|
||||
END IF;
|
||||
INSERT INTO public.audit_logs (tenant_id, user_id, entity_type, entity_id, action, old_values, new_values, changed_fields)
|
||||
VALUES (v_tenant_id, auth.uid(), TG_TABLE_NAME, v_entity_id, lower(TG_OP), v_old, v_new, v_changed);
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.trg_fn_patient_status_history()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN
|
||||
INSERT INTO patient_status_history (patient_id, status_anterior, status_novo, motivo, encaminhado_para, data_saida, alterado_por, alterado_em)
|
||||
VALUES (NEW.id, CASE WHEN TG_OP='INSERT' THEN NULL ELSE OLD.status END, NEW.status, NEW.motivo_saida, NEW.encaminhado_para, NEW.data_saida, auth.uid(), now());
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.trg_fn_patient_status_timeline()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN
|
||||
INSERT INTO patient_timeline (patient_id, evento_tipo, titulo, descricao, icone_cor, gerado_por, ocorrido_em)
|
||||
VALUES (NEW.id, 'status_alterado', 'Status alterado para ' || NEW.status,
|
||||
CASE WHEN TG_OP='INSERT' THEN 'Paciente cadastrado'
|
||||
ELSE 'De ' || OLD.status || ' → ' || NEW.status || CASE WHEN NEW.motivo_saida IS NOT NULL THEN ' · ' || NEW.motivo_saida ELSE '' END END,
|
||||
CASE NEW.status WHEN 'Ativo' THEN 'green' WHEN 'Alta' THEN 'blue' WHEN 'Inativo' THEN 'gray' WHEN 'Encaminhado' THEN 'amber' WHEN 'Arquivado' THEN 'gray' ELSE 'gray' END,
|
||||
auth.uid(), now());
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.trg_fn_patient_risco_timeline()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
IF OLD.risco_elevado IS DISTINCT FROM NEW.risco_elevado THEN
|
||||
INSERT INTO patient_timeline (patient_id, evento_tipo, titulo, descricao, icone_cor, gerado_por, ocorrido_em)
|
||||
VALUES (NEW.id, CASE WHEN NEW.risco_elevado THEN 'risco_sinalizado' ELSE 'risco_removido' END,
|
||||
CASE WHEN NEW.risco_elevado THEN 'Risco elevado sinalizado' ELSE 'Sinalização de risco removida' END,
|
||||
NEW.risco_nota, CASE WHEN NEW.risco_elevado THEN 'red' ELSE 'green' END, auth.uid(), now());
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.auto_create_financial_record_from_session()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_price numeric(10,2); v_services_total numeric(10,2); v_already_billed boolean;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
IF NEW.status::text <> 'realizado' THEN RETURN NEW; END IF;
|
||||
IF OLD.status IS NOT DISTINCT FROM NEW.status THEN RETURN NEW; END IF;
|
||||
IF NEW.tipo::text <> 'sessao' THEN RETURN NEW; END IF;
|
||||
IF NEW.patient_id IS NULL THEN RETURN NEW; END IF;
|
||||
IF NEW.billing_contract_id IS NOT NULL THEN RETURN NEW; END IF;
|
||||
|
||||
SELECT billed INTO v_already_billed FROM agenda_eventos WHERE id = NEW.id;
|
||||
IF v_already_billed = TRUE THEN
|
||||
IF EXISTS (SELECT 1 FROM financial_records WHERE agenda_evento_id = NEW.id AND deleted_at IS NULL) THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_price := NULL;
|
||||
IF NEW.recurrence_id IS NOT NULL THEN
|
||||
SELECT COALESCE(SUM(rrs.final_price), 0) INTO v_services_total
|
||||
FROM recurrence_rule_services rrs WHERE rrs.rule_id = NEW.recurrence_id;
|
||||
IF v_services_total > 0 THEN v_price := v_services_total; END IF;
|
||||
IF v_price IS NULL OR v_price = 0 THEN
|
||||
SELECT price INTO v_price FROM recurrence_rules WHERE id = NEW.recurrence_id;
|
||||
END IF;
|
||||
END IF;
|
||||
IF v_price IS NULL OR v_price = 0 THEN v_price := NEW.price; END IF;
|
||||
IF v_price IS NULL OR v_price <= 0 THEN RETURN NEW; END IF;
|
||||
|
||||
INSERT INTO financial_records (owner_id, patient_id, agenda_evento_id, type, amount, discount_amount, final_amount, clinic_fee_pct, clinic_fee_amount, status, due_date)
|
||||
VALUES (NEW.owner_id, NEW.patient_id, NEW.id, 'receita', v_price, 0, v_price, 0, 0, 'pending', (NEW.inicio_em::date + 7));
|
||||
|
||||
UPDATE agenda_eventos SET billed = TRUE WHERE id = NEW.id;
|
||||
RETURN NEW;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE WARNING '[auto_create_financial_record_from_session] evento=% erro=%', NEW.id, SQLERRM;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fn_sla_resolve_on_outbound()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_thread_key text;
|
||||
BEGIN
|
||||
IF NEW.direction <> 'outbound' THEN RETURN NEW; END IF;
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
v_thread_key := COALESCE(NEW.patient_id::text, 'anon:' || COALESCE(NEW.to_number, 'unknown'));
|
||||
UPDATE conversation_sla_breaches SET resolved_at = now(), resolved_by_message_id = NEW.id
|
||||
WHERE thread_key = v_thread_key AND resolved_at IS NULL;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fn_clinical_note_version()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE next_version integer; reason text;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
SELECT COALESCE(MAX(version_number), 0) + 1 INTO next_version FROM clinical_note_versions WHERE note_id = NEW.id;
|
||||
IF TG_OP = 'INSERT' THEN reason := 'criacao';
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN reason := 'soft_delete';
|
||||
ELSIF NEW.deleted_at IS NULL AND OLD.deleted_at IS NOT NULL THEN reason := 'restore';
|
||||
ELSE reason := 'edicao'; END IF;
|
||||
ELSE reason := 'desconhecido'; END IF;
|
||||
INSERT INTO clinical_note_versions (note_id, version_number, title, content_text, content_structured, change_reason, created_at, created_by)
|
||||
VALUES (NEW.id, next_version, NEW.title, NEW.content_text, NEW.content_structured, reason, now(), COALESCE(NEW.updated_by, NEW.created_by));
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fn_document_signature_timeline()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_patient_id uuid; v_doc_nome text;
|
||||
BEGIN
|
||||
IF NEW.status = 'assinado' AND (OLD.status IS NULL OR OLD.status <> 'assinado') THEN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
SELECT d.patient_id, d.nome_original INTO v_patient_id, v_doc_nome FROM documents d WHERE d.id = NEW.documento_id;
|
||||
IF v_patient_id IS NOT NULL THEN
|
||||
INSERT INTO patient_timeline (patient_id, evento_tipo, titulo, descricao, icone_cor, link_ref_tipo, link_ref_id, gerado_por, ocorrido_em)
|
||||
VALUES (v_patient_id, 'documento_assinado', 'Documento assinado: ' || COALESCE(v_doc_nome, 'documento'),
|
||||
'Assinado por ' || COALESCE(NEW.signatario_nome, NEW.signatario_tipo), 'green', 'documento', NEW.documento_id, NEW.signatario_id, NEW.assinado_em);
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fn_documents_timeline_insert()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
INSERT INTO patient_timeline (patient_id, evento_tipo, titulo, descricao, icone_cor, link_ref_tipo, link_ref_id, gerado_por, ocorrido_em)
|
||||
VALUES (NEW.patient_id, 'documento_adicionado', 'Documento adicionado: ' || COALESCE(NEW.nome_original, 'arquivo'),
|
||||
'Tipo: ' || COALESCE(NEW.tipo_documento, 'outro'), 'blue', 'documento', NEW.id, NEW.uploaded_by, NEW.uploaded_at);
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.sync_legacy_email_fields()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_entity_type text; v_entity_id uuid; v_primary text; v_secondary text;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
IF TG_OP = 'DELETE' THEN v_entity_type := OLD.entity_type; v_entity_id := OLD.entity_id;
|
||||
ELSE v_entity_type := NEW.entity_type; v_entity_id := NEW.entity_id; END IF;
|
||||
SELECT email INTO v_primary FROM contact_emails WHERE entity_type = v_entity_type AND entity_id = v_entity_id ORDER BY is_primary DESC, position ASC, created_at ASC LIMIT 1;
|
||||
SELECT email INTO v_secondary FROM contact_emails WHERE entity_type = v_entity_type AND entity_id = v_entity_id AND is_primary = false ORDER BY position ASC, created_at ASC LIMIT 1;
|
||||
IF v_entity_type = 'patient' THEN
|
||||
UPDATE patients SET email_principal = v_primary, email_alternativo = v_secondary WHERE id = v_entity_id;
|
||||
ELSIF v_entity_type = 'medico' THEN
|
||||
UPDATE medicos SET email = v_primary WHERE id = v_entity_id;
|
||||
END IF;
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.sync_legacy_phone_fields()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_entity_type text; v_entity_id uuid; v_primary text; v_secondary text;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
IF TG_OP = 'DELETE' THEN v_entity_type := OLD.entity_type; v_entity_id := OLD.entity_id;
|
||||
ELSE v_entity_type := NEW.entity_type; v_entity_id := NEW.entity_id; END IF;
|
||||
SELECT number INTO v_primary FROM contact_phones WHERE entity_type = v_entity_type AND entity_id = v_entity_id ORDER BY is_primary DESC, position ASC, created_at ASC LIMIT 1;
|
||||
SELECT number INTO v_secondary FROM contact_phones WHERE entity_type = v_entity_type AND entity_id = v_entity_id AND is_primary = false ORDER BY position ASC, created_at ASC LIMIT 1;
|
||||
IF v_entity_type = 'patient' THEN
|
||||
UPDATE patients SET telefone = v_primary, telefone_alternativo = v_secondary WHERE id = v_entity_id;
|
||||
ELSIF v_entity_type = 'medico' THEN
|
||||
UPDATE medicos SET telefone_profissional = v_primary, telefone_pessoal = v_secondary WHERE id = v_entity_id;
|
||||
END IF;
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fn_agenda_regras_semanais_no_overlap()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_count int;
|
||||
BEGIN
|
||||
IF new.ativo IS false THEN RETURN new; END IF;
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
SELECT count(*) INTO v_count FROM agenda_regras_semanais r
|
||||
WHERE r.owner_id = new.owner_id AND r.dia_semana = new.dia_semana AND r.ativo IS true
|
||||
AND (TG_OP = 'INSERT' OR r.id <> new.id)
|
||||
AND (new.hora_inicio < r.hora_fim AND new.hora_fim > r.hora_inicio);
|
||||
IF v_count > 0 THEN RAISE EXCEPTION 'Janela sobreposta: já existe uma regra ativa nesse intervalo.'; END IF;
|
||||
RETURN new;
|
||||
END $$;
|
||||
|
||||
-- valida member consistency: tenant_id vem do schema; tenant_members é GLOBAL
|
||||
CREATE OR REPLACE FUNCTION public.patients_validate_member_consistency()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_tid uuid; v_tenant_responsible uuid; v_tenant_therapist uuid;
|
||||
BEGIN
|
||||
v_tid := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
|
||||
SELECT tenant_id INTO v_tenant_responsible FROM public.tenant_members WHERE id = NEW.responsible_member_id;
|
||||
IF v_tenant_responsible IS NULL THEN RAISE EXCEPTION 'Responsible member not found'; END IF;
|
||||
IF v_tid IS NULL THEN RAISE EXCEPTION 'tenant não resolvido para schema %', TG_TABLE_SCHEMA; END IF;
|
||||
IF v_tenant_responsible <> v_tid THEN RAISE EXCEPTION 'Responsible member must belong to the same tenant'; END IF;
|
||||
IF NEW.patient_scope = 'therapist' THEN
|
||||
IF NEW.therapist_member_id IS NULL THEN RAISE EXCEPTION 'therapist_member_id is required when patient_scope=therapist'; END IF;
|
||||
SELECT tenant_id INTO v_tenant_therapist FROM public.tenant_members WHERE id = NEW.therapist_member_id;
|
||||
IF v_tenant_therapist IS NULL THEN RAISE EXCEPTION 'Therapist member not found'; END IF;
|
||||
IF v_tenant_therapist <> v_tid THEN RAISE EXCEPTION 'Therapist member must belong to the same tenant'; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 2) sync_busy_mirror — CROSS-TENANT: evento pessoal espelha "Ocupado" nas
|
||||
-- clínicas onde o owner é therapist. Escreve no schema de OUTROS tenants.
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.sync_busy_mirror_agenda_eventos()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_source_tenant uuid;
|
||||
is_personal boolean;
|
||||
should_mirror boolean;
|
||||
v_owner uuid;
|
||||
v_src_id uuid;
|
||||
clinic record;
|
||||
v_cschema text;
|
||||
BEGIN
|
||||
-- anti-recursão: espelho não espelha
|
||||
IF TG_OP <> 'DELETE' THEN
|
||||
IF NEW.mirror_of_event_id IS NOT NULL THEN RETURN NEW; END IF;
|
||||
v_owner := NEW.owner_id; v_src_id := NEW.id;
|
||||
ELSE
|
||||
IF OLD.mirror_of_event_id IS NOT NULL THEN RETURN OLD; END IF;
|
||||
v_owner := OLD.owner_id; v_src_id := OLD.id;
|
||||
END IF;
|
||||
|
||||
v_source_tenant := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
|
||||
is_personal := (v_source_tenant = v_owner); -- convenção: tenant pessoal tem id = owner
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
should_mirror := (OLD.visibility_scope IN ('busy_only','private'));
|
||||
ELSE
|
||||
should_mirror := (NEW.visibility_scope IN ('busy_only','private'));
|
||||
END IF;
|
||||
|
||||
IF NOT is_personal THEN
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- DELETE ou não-deve-espelhar: remove espelhos em todas as clínicas do owner
|
||||
IF TG_OP = 'DELETE' OR NOT should_mirror THEN
|
||||
FOR clinic IN
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = v_owner AND tm.role = 'therapist' AND tm.status = 'active' AND tm.tenant_id <> v_owner
|
||||
LOOP
|
||||
v_cschema := public.tenant_schema_for(clinic.tenant_id);
|
||||
IF v_cschema IS NULL THEN CONTINUE; END IF;
|
||||
EXECUTE format('DELETE FROM %I.agenda_eventos WHERE mirror_of_event_id = %L AND mirror_source = %L',
|
||||
v_cschema, v_src_id, 'personal_busy_mirror');
|
||||
END LOOP;
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- INSERT/UPDATE com espelho: upsert "Ocupado" em cada clínica do owner
|
||||
FOR clinic IN
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = v_owner AND tm.role = 'therapist' AND tm.status = 'active' AND tm.tenant_id <> v_owner
|
||||
LOOP
|
||||
v_cschema := public.tenant_schema_for(clinic.tenant_id);
|
||||
IF v_cschema IS NULL THEN CONTINUE; END IF;
|
||||
EXECUTE format(
|
||||
'INSERT INTO %I.agenda_eventos (owner_id, terapeuta_id, patient_id, tipo, status, titulo, observacoes, inicio_em, fim_em, mirror_of_event_id, mirror_source, visibility_scope, created_at, updated_at) '
|
||||
|| 'VALUES ($1,$1,NULL,$2::public.tipo_evento_agenda,$3::public.status_evento_agenda,$4,NULL,$5,$6,$7,$8,$9,now(),now()) '
|
||||
|| 'ON CONFLICT (mirror_of_event_id) WHERE mirror_of_event_id IS NOT NULL '
|
||||
|| 'DO UPDATE SET owner_id=EXCLUDED.owner_id, terapeuta_id=EXCLUDED.terapeuta_id, tipo=EXCLUDED.tipo, status=EXCLUDED.status, titulo=EXCLUDED.titulo, observacoes=EXCLUDED.observacoes, inicio_em=EXCLUDED.inicio_em, fim_em=EXCLUDED.fim_em, updated_at=now()',
|
||||
v_cschema)
|
||||
USING v_owner, 'bloqueio', 'agendado', 'Ocupado', NEW.inicio_em, NEW.fim_em, v_src_id, 'personal_busy_mirror', 'public';
|
||||
END LOOP;
|
||||
|
||||
-- remove espelhos de clínicas onde o vínculo therapist active sumiu
|
||||
FOR clinic IN
|
||||
SELECT ts.tenant_id, ts.schema_name FROM public.tenant_schemas ts
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM public.tenant_members tm
|
||||
WHERE tm.user_id = v_owner AND tm.role='therapist' AND tm.status='active' AND tm.tenant_id = ts.tenant_id
|
||||
)
|
||||
LOOP
|
||||
EXECUTE format('DELETE FROM %I.agenda_eventos WHERE mirror_of_event_id = %L AND mirror_source = %L',
|
||||
clinic.schema_name, v_src_id, 'personal_busy_mirror');
|
||||
END LOOP;
|
||||
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 3) financial_records_inject_tenant — OBSOLETO no schema (sem coluna tenant_id).
|
||||
-- Mantém em public (legacy) mas NÃO anexa nos schemas.
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 4) Detach dos tenant-tables de public + attach nos schemas
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Detacha das tabelas tenant em public os triggers schema-aware (ficariam errados lá)
|
||||
DO $$
|
||||
DECLARE
|
||||
aware text[] := ARRAY[
|
||||
'log_audit_change','trg_fn_patient_status_history','trg_fn_patient_status_timeline',
|
||||
'trg_fn_patient_risco_timeline','auto_create_financial_record_from_session',
|
||||
'fn_sla_resolve_on_outbound','fn_clinical_note_version','fn_document_signature_timeline',
|
||||
'fn_documents_timeline_insert','sync_legacy_email_fields','sync_legacy_phone_fields',
|
||||
'fn_agenda_regras_semanais_no_overlap','patients_validate_member_consistency',
|
||||
'sync_busy_mirror_agenda_eventos'
|
||||
];
|
||||
r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT c.relname AS tab, t.tgname
|
||||
FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid JOIN pg_namespace n ON n.oid=c.relnamespace
|
||||
JOIN pg_proc p ON p.oid=t.tgfoid
|
||||
WHERE n.nspname='public' AND NOT t.tgisinternal AND p.proname = ANY(aware)
|
||||
AND c.relname IN (SELECT table_name FROM information_schema.tables WHERE table_schema='_tenant_template' AND table_type='BASE TABLE')
|
||||
LOOP
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', r.tgname, r.tab);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Attach nos schemas. Specs derivadas dos triggerdefs REAIS de public, com
|
||||
-- tenant_id removido de WHEN/UPDATE OF (não existe no schema). __T__ = schema.tabela.
|
||||
CREATE OR REPLACE FUNCTION public.attach_schema_aware_triggers(p_schema text)
|
||||
RETURNS int LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
specs jsonb := jsonb_build_array(
|
||||
jsonb_build_object('tab','patients','name','trg_patient_status_history','spec','AFTER INSERT OR UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_status_history()'),
|
||||
jsonb_build_object('tab','patients','name','trg_patient_status_timeline','spec','AFTER INSERT OR UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_status_timeline()'),
|
||||
jsonb_build_object('tab','patients','name','trg_patient_risco_timeline','spec','AFTER UPDATE OF risco_elevado ON __T__ FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_risco_timeline()'),
|
||||
jsonb_build_object('tab','patients','name','trg_audit_patients','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.log_audit_change()'),
|
||||
jsonb_build_object('tab','patients','name','trg_patients_validate_members','spec','BEFORE INSERT OR UPDATE OF responsible_member_id, patient_scope, therapist_member_id ON __T__ FOR EACH ROW EXECUTE FUNCTION public.patients_validate_member_consistency()'),
|
||||
jsonb_build_object('tab','agenda_eventos','name','trg_audit_agenda_eventos','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.log_audit_change()'),
|
||||
jsonb_build_object('tab','agenda_eventos','name','trg_auto_financial_from_session','spec','AFTER UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.auto_create_financial_record_from_session()'),
|
||||
jsonb_build_object('tab','agenda_eventos','name','trg_agenda_eventos_busy_mirror_ins','spec','AFTER INSERT ON __T__ FOR EACH ROW WHEN (new.mirror_of_event_id IS NULL AND new.visibility_scope = ANY (ARRAY[''busy_only''::text, ''private''::text])) EXECUTE FUNCTION public.sync_busy_mirror_agenda_eventos()'),
|
||||
jsonb_build_object('tab','agenda_eventos','name','trg_agenda_eventos_busy_mirror_upd','spec','AFTER UPDATE ON __T__ FOR EACH ROW WHEN (new.mirror_of_event_id IS NULL AND (new.visibility_scope IS DISTINCT FROM old.visibility_scope OR new.inicio_em IS DISTINCT FROM old.inicio_em OR new.fim_em IS DISTINCT FROM old.fim_em OR new.owner_id IS DISTINCT FROM old.owner_id)) EXECUTE FUNCTION public.sync_busy_mirror_agenda_eventos()'),
|
||||
jsonb_build_object('tab','agenda_eventos','name','trg_agenda_eventos_busy_mirror_del','spec','AFTER DELETE ON __T__ FOR EACH ROW WHEN (old.mirror_of_event_id IS NULL) EXECUTE FUNCTION public.sync_busy_mirror_agenda_eventos()'),
|
||||
jsonb_build_object('tab','financial_records','name','trg_audit_financial_records','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.log_audit_change()'),
|
||||
jsonb_build_object('tab','financial_records','name','trg_financial_records_auto_overdue','spec','BEFORE UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.trg_fn_financial_records_auto_overdue()'),
|
||||
jsonb_build_object('tab','documents','name','trg_audit_documents','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.log_audit_change()'),
|
||||
jsonb_build_object('tab','documents','name','trg_documents_timeline_insert','spec','AFTER INSERT ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_documents_timeline_insert()'),
|
||||
jsonb_build_object('tab','document_signatures','name','trg_ds_timeline','spec','AFTER UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_document_signature_timeline()'),
|
||||
jsonb_build_object('tab','clinical_notes','name','trg_clinical_notes_version_insert','spec','AFTER INSERT ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_clinical_note_version()'),
|
||||
jsonb_build_object('tab','clinical_notes','name','trg_clinical_notes_version_update','spec','AFTER UPDATE OF content_text, content_structured, title, deleted_at ON __T__ FOR EACH ROW WHEN (old.content_text IS DISTINCT FROM new.content_text OR old.content_structured IS DISTINCT FROM new.content_structured OR old.title IS DISTINCT FROM new.title OR old.deleted_at IS DISTINCT FROM new.deleted_at) EXECUTE FUNCTION public.fn_clinical_note_version()'),
|
||||
jsonb_build_object('tab','conversation_messages','name','trg_sla_resolve_on_outbound','spec','AFTER INSERT ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_sla_resolve_on_outbound()'),
|
||||
jsonb_build_object('tab','contact_emails','name','trg_contact_emails_sync_legacy','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.sync_legacy_email_fields()'),
|
||||
jsonb_build_object('tab','contact_phones','name','trg_contact_phones_sync_legacy','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.sync_legacy_phone_fields()'),
|
||||
jsonb_build_object('tab','agenda_regras_semanais','name','trg_agenda_regras_semanais_no_overlap','spec','BEFORE INSERT OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_agenda_regras_semanais_no_overlap()'),
|
||||
jsonb_build_object('tab','agenda_configuracoes','name','trg_agenda_cfg_sync','spec','BEFORE INSERT OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.agenda_cfg_sync()')
|
||||
);
|
||||
el jsonb; v_count int := 0; v_target text;
|
||||
BEGIN
|
||||
IF p_schema NOT LIKE 'tenant\_%' THEN RAISE EXCEPTION 'schema inválido %', p_schema; END IF;
|
||||
FOR el IN SELECT * FROM jsonb_array_elements(specs) LOOP
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=p_schema AND table_name=(el->>'tab')) THEN CONTINUE; END IF;
|
||||
v_target := format('%I.%I', p_schema, el->>'tab');
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', el->>'name', v_target);
|
||||
EXECUTE 'CREATE TRIGGER ' || quote_ident(el->>'name') || ' ' || replace(el->>'spec', '__T__', v_target);
|
||||
v_count := v_count + 1;
|
||||
END LOOP;
|
||||
RETURN v_count;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
DECLARE r record; v int;
|
||||
BEGIN
|
||||
FOR r IN SELECT schema_name FROM public.tenant_schemas ORDER BY schema_name LOOP
|
||||
v := public.attach_schema_aware_triggers(r.schema_name);
|
||||
RAISE NOTICE 'F6.2B %: % triggers schema-aware', r.schema_name, v;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,266 @@
|
||||
-- =============================================================================
|
||||
-- F6.2 Lote C — split de notifications (tenant-local vs SaaS cross-tenant)
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin (CREATE OR REPLACE de funções owned por
|
||||
-- postgres E supabase_admin; superuser preserva o owner):
|
||||
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
|
||||
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
|
||||
-- < database-novo/manual/f6_2c_notifications_split.supabase_admin.sql
|
||||
--
|
||||
-- Neste projeto, TODAS as notificações atuais (inbound_message, session_status,
|
||||
-- system_alert, new_patient) são tenant-LOCAIS (avisos cross-tenant do SaaS
|
||||
-- vivem em global_notices). Então:
|
||||
-- * notifications continua tenant-local → já vive no schema do tenant (F6.1)
|
||||
-- * public.notifications_sistema é criado como o canal SaaS→tenant / dev
|
||||
-- cross-tenant (vazio hoje; pronto pro futuro: suporte, billing, etc.)
|
||||
-- Triggers de notif reescritos schema-aware; os que disparam em tabelas PUBLIC
|
||||
-- (notify_on_intake, notify_on_scheduling) roteiam pro schema via EXECUTE format.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 1) notifications_sistema (GLOBAL, cross-tenant)
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.notifications_sistema (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id uuid NOT NULL, -- destinatário (user do tenant OU dev)
|
||||
tenant_id uuid REFERENCES public.tenants(id) ON DELETE CASCADE, -- contexto (nullable: alerta global)
|
||||
type text NOT NULL,
|
||||
ref_id uuid,
|
||||
ref_table text,
|
||||
payload jsonb,
|
||||
read_at timestamptz,
|
||||
archived boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS notif_sistema_owner_idx ON public.notifications_sistema (owner_id, created_at DESC) WHERE archived = false;
|
||||
|
||||
ALTER TABLE public.notifications_sistema ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS notif_sistema_owner ON public.notifications_sistema;
|
||||
CREATE POLICY notif_sistema_owner ON public.notifications_sistema
|
||||
FOR ALL TO authenticated USING (owner_id = auth.uid()) WITH CHECK (owner_id = auth.uid());
|
||||
|
||||
-- realtime
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_publication_tables WHERE pubname='supabase_realtime' AND schemaname='public' AND tablename='notifications_sistema') THEN
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.notifications_sistema;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- helper pro futuro: emite notificação cross-tenant (dev/SaaS -> destinatário)
|
||||
CREATE OR REPLACE FUNCTION public.notify_user_sistema(
|
||||
p_owner_id uuid, p_type text, p_payload jsonb,
|
||||
p_tenant_id uuid DEFAULT NULL, p_ref_id uuid DEFAULT NULL, p_ref_table text DEFAULT NULL)
|
||||
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_id uuid;
|
||||
BEGIN
|
||||
INSERT INTO public.notifications_sistema (owner_id, tenant_id, type, ref_id, ref_table, payload)
|
||||
VALUES (p_owner_id, p_tenant_id, p_type, p_ref_id, p_ref_table, p_payload)
|
||||
RETURNING id INTO v_id;
|
||||
RETURN v_id;
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 2) Rewrites dos triggers de notif (tenant-local) — schema-aware
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.notify_on_session_status()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_nome text;
|
||||
BEGIN
|
||||
IF NEW.status IN ('faltou','cancelado') AND OLD.status IS DISTINCT FROM NEW.status THEN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
SELECT nome_completo INTO v_nome FROM patients WHERE id = NEW.patient_id LIMIT 1;
|
||||
INSERT INTO notifications (owner_id, type, ref_id, ref_table, payload)
|
||||
VALUES (NEW.owner_id, 'session_status', NEW.id, 'agenda_eventos',
|
||||
jsonb_build_object(
|
||||
'title', CASE WHEN NEW.status='faltou' THEN 'Paciente faltou' ELSE 'Sessão cancelada' END,
|
||||
'detail', COALESCE(v_nome,'Paciente') || ' — ' || to_char(NEW.inicio_em,'DD/MM HH24:MI'),
|
||||
'deeplink', '/therapist/agenda',
|
||||
'avatar_initials', upper(left(COALESCE(v_nome,'?'),2))));
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fanout_inbound_message_to_notifications()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_target_user uuid; v_title text; v_detail text; v_initials text; v_deeplink text;
|
||||
v_patient_name text; v_payload jsonb; v_tenant uuid;
|
||||
BEGIN
|
||||
IF NEW.direction <> 'inbound' THEN RETURN NEW; END IF;
|
||||
v_tenant := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
|
||||
IF NEW.patient_id IS NOT NULL THEN
|
||||
SELECT nome_completo INTO v_patient_name FROM patients WHERE id = NEW.patient_id;
|
||||
END IF;
|
||||
v_title := COALESCE(v_patient_name, NEW.from_number, 'Desconhecido');
|
||||
v_detail := COALESCE(left(NEW.body, 100), '[mensagem sem texto]');
|
||||
IF v_patient_name IS NOT NULL THEN
|
||||
v_initials := upper(left(v_patient_name,1)) || COALESCE(upper(left(split_part(v_patient_name,' ',2),1)),'');
|
||||
ELSE v_initials := '?'; END IF;
|
||||
v_deeplink := '/admin/conversas';
|
||||
v_payload := jsonb_build_object('title',v_title,'detail',v_detail,'avatar_initials',v_initials,
|
||||
'deeplink',v_deeplink,'channel',NEW.channel,'conversation_message_id',NEW.id,
|
||||
'patient_id',NEW.patient_id,'from_number',NEW.from_number);
|
||||
|
||||
-- destinatário: responsável do paciente (tenant_members é GLOBAL)
|
||||
IF NEW.patient_id IS NOT NULL THEN
|
||||
SELECT tm.user_id INTO v_target_user
|
||||
FROM patients p JOIN public.tenant_members tm ON tm.id = p.responsible_member_id
|
||||
WHERE p.id = NEW.patient_id AND tm.status = 'active' LIMIT 1;
|
||||
IF v_target_user IS NOT NULL THEN
|
||||
INSERT INTO notifications (owner_id, type, ref_id, ref_table, payload)
|
||||
VALUES (v_target_user, 'inbound_message', NULL, 'conversation_messages', v_payload);
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
END IF;
|
||||
-- fallback: fan-out pros admins/therapists ativos do tenant (global)
|
||||
INSERT INTO notifications (owner_id, type, ref_id, ref_table, payload)
|
||||
SELECT tm.user_id, 'inbound_message', NULL, 'conversation_messages', v_payload
|
||||
FROM public.tenant_members tm
|
||||
WHERE tm.tenant_id = v_tenant AND tm.status = 'active'
|
||||
AND tm.role IN ('clinic_admin','tenant_admin','therapist');
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
-- helper de cancelamento: notification_queue é tenant; herda search_path do trigger chamador
|
||||
CREATE OR REPLACE FUNCTION public.cancel_patient_pending_notifications(p_patient_id uuid, p_channel text DEFAULT NULL, p_evento_id uuid DEFAULT NULL)
|
||||
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE v_canceled integer;
|
||||
BEGIN
|
||||
UPDATE notification_queue SET status='cancelado', updated_at=now()
|
||||
WHERE patient_id = p_patient_id AND status IN ('pendente','processando')
|
||||
AND (p_channel IS NULL OR channel = p_channel)
|
||||
AND (p_evento_id IS NULL OR agenda_evento_id = p_evento_id);
|
||||
GET DIAGNOSTICS v_canceled = ROW_COUNT;
|
||||
RETURN v_canceled;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.cancel_notifications_on_opt_out()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
IF OLD.whatsapp_opt_in = true AND NEW.whatsapp_opt_in = false THEN
|
||||
PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, 'whatsapp');
|
||||
END IF;
|
||||
IF OLD.email_opt_in = true AND NEW.email_opt_in = false THEN
|
||||
PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, 'email');
|
||||
END IF;
|
||||
IF OLD.sms_opt_in = true AND NEW.sms_opt_in = false THEN
|
||||
PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, 'sms');
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.cancel_notifications_on_session_cancel()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.status = 'cancelado' AND OLD.status <> 'cancelado' THEN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, NULL, NEW.id);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 3) Triggers que disparam em tabelas PUBLIC (intake/scheduling, F1b) —
|
||||
-- roteiam a notificação pro schema do tenant via EXECUTE format
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.notify_on_intake()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_schema text;
|
||||
BEGIN
|
||||
IF NEW.status = 'new' THEN
|
||||
v_schema := public.tenant_schema_for(NEW.tenant_id);
|
||||
IF v_schema IS NULL THEN RETURN NEW; END IF;
|
||||
EXECUTE format('INSERT INTO %I.notifications (owner_id, type, ref_id, ref_table, payload) VALUES ($1,$2,$3,$4,$5)', v_schema)
|
||||
USING NEW.owner_id, 'new_patient', NEW.id, 'patient_intake_requests',
|
||||
jsonb_build_object('title','Novo cadastro externo','detail',COALESCE(NEW.nome_completo,'Paciente'),
|
||||
'deeplink','/therapist/patients/cadastro/recebidos','avatar_initials',upper(left(COALESCE(NEW.nome_completo,'?'),2)));
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.notify_on_scheduling()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_schema text;
|
||||
BEGIN
|
||||
IF NEW.status = 'pendente' THEN
|
||||
v_schema := public.tenant_schema_for(NEW.tenant_id);
|
||||
IF v_schema IS NULL THEN RETURN NEW; END IF;
|
||||
EXECUTE format('INSERT INTO %I.notifications (owner_id, type, ref_id, ref_table, payload) VALUES ($1,$2,$3,$4,$5)', v_schema)
|
||||
USING NEW.owner_id, 'new_scheduling', NEW.id, 'agendador_solicitacoes',
|
||||
jsonb_build_object('title','Nova solicitação de agendamento',
|
||||
'detail', COALESCE(NEW.paciente_nome,'Paciente') || ' ' || COALESCE(NEW.paciente_sobrenome,'') || ' — ' || COALESCE(NEW.tipo,''),
|
||||
'deeplink','/therapist/agendamentos-recebidos',
|
||||
'avatar_initials', upper(left(COALESCE(NEW.paciente_nome,'?'),1) || left(COALESCE(NEW.paciente_sobrenome,''),1)));
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 4) Detach dos notif-triggers tenant de public + attach nos schemas (estende
|
||||
-- attach_schema_aware_triggers com os 5 triggers de notif tenant)
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
DO $$
|
||||
DECLARE
|
||||
aware text[] := ARRAY['notify_on_session_status','fanout_inbound_message_to_notifications',
|
||||
'cancel_notifications_on_opt_out','cancel_notifications_on_session_cancel'];
|
||||
r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT c.relname AS tab, t.tgname FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid
|
||||
JOIN pg_namespace n ON n.oid=c.relnamespace JOIN pg_proc p ON p.oid=t.tgfoid
|
||||
WHERE n.nspname='public' AND NOT t.tgisinternal AND p.proname = ANY(aware)
|
||||
AND c.relname IN (SELECT table_name FROM information_schema.tables WHERE table_schema='_tenant_template' AND table_type='BASE TABLE')
|
||||
LOOP
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', r.tgname, r.tab);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.attach_notif_triggers(p_schema text)
|
||||
RETURNS int LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
specs jsonb := jsonb_build_array(
|
||||
jsonb_build_object('tab','agenda_eventos','name','trg_notify_on_session_status','spec','AFTER UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.notify_on_session_status()'),
|
||||
jsonb_build_object('tab','agenda_eventos','name','trg_cancel_notifs_on_session_cancel','spec','AFTER UPDATE ON __T__ FOR EACH ROW WHEN (new.status IS DISTINCT FROM old.status) EXECUTE FUNCTION public.cancel_notifications_on_session_cancel()'),
|
||||
jsonb_build_object('tab','agenda_eventos','name','trg_agenda_status_notify','spec','AFTER UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_notify_agenda_status_change()'),
|
||||
jsonb_build_object('tab','conversation_messages','name','trg_fanout_inbound_to_notifications','spec','AFTER INSERT ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fanout_inbound_message_to_notifications()'),
|
||||
jsonb_build_object('tab','notification_preferences','name','trg_cancel_notifs_on_opt_out','spec','AFTER UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.cancel_notifications_on_opt_out()')
|
||||
);
|
||||
el jsonb; v_count int := 0; v_target text;
|
||||
BEGIN
|
||||
IF p_schema NOT LIKE 'tenant\_%' THEN RAISE EXCEPTION 'schema inválido %', p_schema; END IF;
|
||||
FOR el IN SELECT * FROM jsonb_array_elements(specs) LOOP
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=p_schema AND table_name=(el->>'tab')) THEN CONTINUE; END IF;
|
||||
v_target := format('%I.%I', p_schema, el->>'tab');
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', el->>'name', v_target);
|
||||
EXECUTE 'CREATE TRIGGER ' || quote_ident(el->>'name') || ' ' || replace(el->>'spec','__T__',v_target);
|
||||
v_count := v_count + 1;
|
||||
END LOOP;
|
||||
RETURN v_count;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
DECLARE r record; v int;
|
||||
BEGIN
|
||||
FOR r IN SELECT schema_name FROM public.tenant_schemas ORDER BY schema_name LOOP
|
||||
v := public.attach_notif_triggers(r.schema_name);
|
||||
RAISE NOTICE 'F6.2C %: % notif triggers', r.schema_name, v;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,412 @@
|
||||
-- =============================================================================
|
||||
-- F6.2 Lote D — RPCs user-facing roteadas pro schema do tenant
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin (mix de funções owned postgres/supabase_admin).
|
||||
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
|
||||
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
|
||||
-- < database-novo/manual/f6_2d_user_rpcs.supabase_admin.sql
|
||||
--
|
||||
-- Padrão: valida is_tenant_member(p_tenant_id) + set_config search_path pro
|
||||
-- schema do tenant; remove `WHERE tenant_id=` e tenant_id de inserts; unqualify
|
||||
-- tabelas tenant; %ROWTYPE→RECORD; RETURNS <tabela_tenant>→jsonb.
|
||||
-- Tabelas que FICAM em public (audit_logs global; patient_intake_requests,
|
||||
-- document_share_links F1b) seguem com `public.` + filtro tenant_id.
|
||||
--
|
||||
-- list_my_signatures é CROSS-TENANT (assinante em vários tenants) → Lote F.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- helper: valida acesso e RETORNA o schema do tenant. NÃO seta search_path
|
||||
-- (set_config feito dentro de helper com SET search_path próprio seria revertido
|
||||
-- na saída do helper). Cada RPC faz: PERFORM set_config('search_path',
|
||||
-- public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
CREATE OR REPLACE FUNCTION public._tenant_route(p_tenant_id uuid)
|
||||
RETURNS text LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_schema text;
|
||||
BEGIN
|
||||
IF p_tenant_id IS NULL THEN RAISE EXCEPTION 'p_tenant_id obrigatório'; END IF;
|
||||
IF NOT public.is_tenant_member(p_tenant_id) AND NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'Sem permissão no tenant %', p_tenant_id USING ERRCODE='42501';
|
||||
END IF;
|
||||
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||
IF v_schema IS NULL THEN RAISE EXCEPTION 'schema não encontrado p/ tenant %', p_tenant_id; END IF;
|
||||
RETURN v_schema;
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- GRUPO 1 — já têm p_tenant_id, RETURNS jsonb/void (CREATE OR REPLACE)
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.delete_commitment_full(p_tenant_id uuid, p_commitment_id uuid)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_is_native boolean; v_fields int:=0; v_logs int:=0; v_parent int:=0;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT dc.is_native INTO v_is_native FROM determined_commitments dc WHERE dc.id = p_commitment_id;
|
||||
IF v_is_native IS NULL THEN RAISE EXCEPTION 'Commitment not found'; END IF;
|
||||
IF v_is_native = true THEN RAISE EXCEPTION 'Cannot delete native commitment'; END IF;
|
||||
DELETE FROM determined_commitment_fields WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_fields = ROW_COUNT;
|
||||
DELETE FROM commitment_time_logs WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_logs = ROW_COUNT;
|
||||
DELETE FROM determined_commitments WHERE id = p_commitment_id; GET DIAGNOSTICS v_parent = ROW_COUNT;
|
||||
IF v_parent <> 1 THEN RAISE EXCEPTION 'Parent not deleted'; END IF;
|
||||
RETURN jsonb_build_object('ok',true,'deleted',jsonb_build_object('fields',v_fields,'logs',v_logs,'commitment',v_parent));
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.delete_determined_commitment(p_tenant_id uuid, p_commitment_id uuid)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_is_native boolean; v_fields int:=0; v_logs int:=0; v_parent int:=0;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT dc.is_native INTO v_is_native FROM determined_commitments dc WHERE dc.id = p_commitment_id;
|
||||
IF v_is_native IS NULL THEN RAISE EXCEPTION 'Commitment not found for tenant'; END IF;
|
||||
IF v_is_native = true THEN RAISE EXCEPTION 'Cannot delete native commitment'; END IF;
|
||||
DELETE FROM determined_commitment_fields WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_fields = ROW_COUNT;
|
||||
DELETE FROM commitment_time_logs WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_logs = ROW_COUNT;
|
||||
DELETE FROM determined_commitments WHERE id = p_commitment_id; GET DIAGNOSTICS v_parent = ROW_COUNT;
|
||||
IF v_parent <> 1 THEN RAISE EXCEPTION 'Delete did not remove the commitment'; END IF;
|
||||
RETURN jsonb_build_object('ok',true,'tenant_id',p_tenant_id,'commitment_id',p_commitment_id,
|
||||
'deleted',jsonb_build_object('fields',v_fields,'logs',v_logs,'commitment',v_parent));
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.seed_default_patient_groups(p_tenant_id uuid)
|
||||
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_owner_id uuid; v_schema text;
|
||||
BEGIN
|
||||
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||
IF v_schema IS NULL THEN RETURN; END IF; -- schema ainda não existe (chamado antes do clone): no-op
|
||||
SELECT user_id INTO v_owner_id FROM public.tenant_members
|
||||
WHERE tenant_id = p_tenant_id AND role='tenant_admin' AND status='active' LIMIT 1;
|
||||
IF v_owner_id IS NULL THEN RETURN; END IF;
|
||||
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||
INSERT INTO patient_groups (owner_id, nome, cor, is_system)
|
||||
VALUES (v_owner_id,'Crianças','#60a5fa',true),
|
||||
(v_owner_id,'Adolescentes','#a78bfa',true),
|
||||
(v_owner_id,'Idosos','#34d399',true)
|
||||
ON CONFLICT (owner_id, nome) DO NOTHING;
|
||||
END $$;
|
||||
|
||||
-- seed_determined_commitments: idêntico em estrutura, sem tenant_id nos inserts.
|
||||
-- Recriado integralmente.
|
||||
CREATE OR REPLACE FUNCTION public.seed_determined_commitments(p_tenant_id uuid)
|
||||
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_id uuid; v_schema text;
|
||||
BEGIN
|
||||
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||
IF v_schema IS NULL THEN RETURN; END IF;
|
||||
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='session') THEN
|
||||
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
|
||||
VALUES (true,'session',true,true,'Sessão','Sessão com paciente');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='reading') THEN
|
||||
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
|
||||
VALUES (true,'reading',false,true,'Leitura','Praticar leitura');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='supervision') THEN
|
||||
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
|
||||
VALUES (true,'supervision',false,true,'Supervisão','Supervisão');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='class') THEN
|
||||
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
|
||||
VALUES (true,'class',false,false,'Aula','Dar aula');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='analysis') THEN
|
||||
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
|
||||
VALUES (true,'analysis',false,true,'Análise Pessoal','Minha análise pessoal');
|
||||
END IF;
|
||||
|
||||
SELECT id INTO v_id FROM determined_commitments WHERE is_native=true AND native_key='session' LIMIT 1;
|
||||
IF v_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='notes') THEN
|
||||
INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order)
|
||||
VALUES (v_id,'notes','Observação','textarea',false,30);
|
||||
END IF;
|
||||
SELECT id INTO v_id FROM determined_commitments WHERE is_native=true AND native_key='reading' LIMIT 1;
|
||||
IF v_id IS NOT NULL THEN
|
||||
IF NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='book') THEN
|
||||
INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order) VALUES (v_id,'book','Livro','text',false,10);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='author') THEN
|
||||
INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order) VALUES (v_id,'author','Autor','text',false,20);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='notes') THEN
|
||||
INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order) VALUES (v_id,'notes','Observação','textarea',false,30);
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- GRUPO 2 — novo p_tenant_id (1º param), RETURNS scalar/jsonb (DROP+CREATE)
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
DROP FUNCTION IF EXISTS public.cancel_recurrence_from(uuid, date);
|
||||
CREATE FUNCTION public.cancel_recurrence_from(p_tenant_id uuid, p_recurrence_id uuid, p_from_date date)
|
||||
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
UPDATE recurrence_rules
|
||||
SET end_date = p_from_date - INTERVAL '1 day', open_ended = false,
|
||||
status = CASE WHEN p_from_date <= start_date THEN 'cancelado' ELSE status END,
|
||||
updated_at = now()
|
||||
WHERE id = p_recurrence_id;
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.cancelar_eventos_serie(uuid, timestamptz);
|
||||
CREATE FUNCTION public.cancelar_eventos_serie(p_tenant_id uuid, p_serie_id uuid, p_a_partir_de timestamptz DEFAULT now())
|
||||
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_count integer;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
UPDATE agenda_eventos SET status='cancelado', updated_at=now()
|
||||
WHERE serie_id = p_serie_id AND inicio_em >= p_a_partir_de AND status NOT IN ('realizado','cancelado');
|
||||
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||
RETURN v_count;
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.split_recurrence_at(uuid, date);
|
||||
CREATE FUNCTION public.split_recurrence_at(p_tenant_id uuid, p_recurrence_id uuid, p_from_date date)
|
||||
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_old RECORD; v_new_id uuid;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT * INTO v_old FROM recurrence_rules WHERE id = p_recurrence_id;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'recurrence_rule % não encontrada', p_recurrence_id; END IF;
|
||||
UPDATE recurrence_rules SET end_date = p_from_date - INTERVAL '1 day', open_ended=false, updated_at=now()
|
||||
WHERE id = p_recurrence_id;
|
||||
INSERT INTO recurrence_rules (owner_id, therapist_id, patient_id, determined_commitment_id, type, interval, weekdays,
|
||||
start_time, end_time, timezone, duration_min, start_date, end_date, max_occurrences, open_ended,
|
||||
modalidade, titulo_custom, observacoes, extra_fields, status)
|
||||
SELECT owner_id, therapist_id, patient_id, determined_commitment_id, type, interval, weekdays,
|
||||
start_time, end_time, timezone, duration_min, p_from_date, v_old.end_date, v_old.max_occurrences, v_old.open_ended,
|
||||
modalidade, titulo_custom, observacoes, extra_fields, status
|
||||
FROM recurrence_rules WHERE id = p_recurrence_id
|
||||
RETURNING id INTO v_new_id;
|
||||
RETURN v_new_id;
|
||||
END $$;
|
||||
|
||||
-- can_delete_patient: SQL sem SET search_path → herda o do chamador (schema).
|
||||
-- Unqualified pra resolver no schema do tenant ativo.
|
||||
CREATE OR REPLACE FUNCTION public.can_delete_patient(p_patient_id uuid)
|
||||
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER
|
||||
AS $$
|
||||
SELECT NOT EXISTS (
|
||||
SELECT 1 FROM agenda_eventos WHERE patient_id = p_patient_id
|
||||
UNION ALL
|
||||
SELECT 1 FROM recurrence_rules WHERE patient_id = p_patient_id
|
||||
UNION ALL
|
||||
SELECT 1 FROM billing_contracts WHERE patient_id = p_patient_id
|
||||
);
|
||||
$$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.safe_delete_patient(uuid);
|
||||
CREATE FUNCTION public.safe_delete_patient(p_tenant_id uuid, p_patient_id uuid)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
IF NOT public.can_delete_patient(p_patient_id) THEN
|
||||
RETURN jsonb_build_object('ok',false,'error','has_history',
|
||||
'message','Este paciente possui histórico clínico ou financeiro e não pode ser removido. Você pode desativar ou arquivar o paciente.');
|
||||
END IF;
|
||||
-- ownership: owner_id direto ou responsible_member do caller (tenant_members é GLOBAL)
|
||||
IF NOT EXISTS (SELECT 1 FROM patients
|
||||
WHERE id = p_patient_id AND (owner_id = auth.uid()
|
||||
OR responsible_member_id IN (SELECT id FROM public.tenant_members WHERE user_id = auth.uid()))) THEN
|
||||
RETURN jsonb_build_object('ok',false,'error','forbidden','message','Sem permissão para excluir este paciente.');
|
||||
END IF;
|
||||
DELETE FROM patients WHERE id = p_patient_id;
|
||||
RETURN jsonb_build_object('ok',true);
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.export_patient_data(uuid);
|
||||
CREATE FUNCTION public.export_patient_data(p_tenant_id uuid, p_patient_id uuid)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_patient RECORD; v_caller uuid; v_result jsonb;
|
||||
BEGIN
|
||||
v_caller := auth.uid();
|
||||
IF v_caller IS NULL THEN RAISE EXCEPTION 'Autenticacao obrigatoria' USING ERRCODE='28000'; END IF;
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT * INTO v_patient FROM patients WHERE id = p_patient_id;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'Paciente nao encontrado' USING ERRCODE='P0002'; END IF;
|
||||
v_result := jsonb_build_object(
|
||||
'export_metadata', jsonb_build_object('generated_at', now(), 'generated_by', v_caller,
|
||||
'tenant_id', p_tenant_id, 'patient_id', p_patient_id,
|
||||
'lgpd_basis','Art. 18, II - portabilidade dos dados do titular',
|
||||
'controller','AgenciaPSI - Clinica responsavel','format_version','1.0'),
|
||||
'paciente', to_jsonb(v_patient),
|
||||
'contatos', COALESCE((SELECT jsonb_agg(to_jsonb(pc) ORDER BY pc.created_at) FROM patient_contacts pc WHERE pc.patient_id = p_patient_id),'[]'::jsonb),
|
||||
'contatos_apoio', COALESCE((SELECT jsonb_agg(to_jsonb(psc) ORDER BY psc.created_at) FROM patient_support_contacts psc WHERE psc.patient_id = p_patient_id),'[]'::jsonb),
|
||||
'historico_status', COALESCE((SELECT jsonb_agg(to_jsonb(psh) ORDER BY psh.alterado_em) FROM patient_status_history psh WHERE psh.patient_id = p_patient_id),'[]'::jsonb),
|
||||
'timeline', COALESCE((SELECT jsonb_agg(to_jsonb(pt) ORDER BY pt.ocorrido_em) FROM patient_timeline pt WHERE pt.patient_id = p_patient_id),'[]'::jsonb),
|
||||
'descontos', COALESCE((SELECT jsonb_agg(to_jsonb(pd) ORDER BY pd.created_at) FROM patient_discounts pd WHERE pd.patient_id = p_patient_id),'[]'::jsonb),
|
||||
'eventos_agenda', COALESCE((SELECT jsonb_agg(jsonb_build_object('id',ae.id,'tipo',ae.tipo,'status',ae.status,'inicio_em',ae.inicio_em,'fim_em',ae.fim_em) ORDER BY ae.inicio_em) FROM agenda_eventos ae WHERE ae.patient_id = p_patient_id),'[]'::jsonb),
|
||||
'documentos', COALESCE((SELECT jsonb_agg(jsonb_build_object('id',d.id,'nome',d.nome_original,'tipo',d.tipo_documento,'criado_em',d.created_at) ORDER BY d.created_at) FROM documents d WHERE d.patient_id = p_patient_id AND d.deleted_at IS NULL),'[]'::jsonb),
|
||||
'financeiro', COALESCE((SELECT jsonb_agg(jsonb_build_object('id',fr.id,'tipo',fr.type,'valor',fr.final_amount,'status',fr.status,'vencimento',fr.due_date) ORDER BY fr.created_at) FROM financial_records fr WHERE fr.patient_id = p_patient_id AND fr.deleted_at IS NULL),'[]'::jsonb)
|
||||
);
|
||||
RETURN v_result;
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.search_global(text, text[], integer);
|
||||
CREATE FUNCTION public.search_global(p_tenant_id uuid, p_q text, p_scope text[] DEFAULT NULL, p_limit integer DEFAULT 8)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_q text; v_pattern text; v_limit int;
|
||||
v_patients jsonb:='[]'::jsonb; v_appointments jsonb:='[]'::jsonb; v_documents jsonb:='[]'::jsonb;
|
||||
v_services jsonb:='[]'::jsonb; v_intakes jsonb:='[]'::jsonb;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
v_q := nullif(btrim(coalesce(p_q,'')),'');
|
||||
IF v_q IS NULL OR length(v_q) < 2 THEN
|
||||
RETURN jsonb_build_object('patients','[]'::jsonb,'appointments','[]'::jsonb,'documents','[]'::jsonb,'services','[]'::jsonb,'intakes','[]'::jsonb);
|
||||
END IF;
|
||||
v_q := left(v_q,80); v_pattern := '%'||v_q||'%'; v_limit := GREATEST(1, LEAST(coalesce(p_limit,8),20));
|
||||
|
||||
IF p_scope IS NULL OR 'patients' = ANY(p_scope) THEN
|
||||
SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,'label',nome_completo,
|
||||
'sublabel',coalesce(nullif(email_principal,''),nullif(telefone,''),''),'avatar_url',avatar_url,
|
||||
'deeplink','/therapist/patients/cadastro/'||id::text,'score',round(score::numeric,3))),'[]'::jsonb) INTO v_patients
|
||||
FROM (SELECT p.id,p.nome_completo,p.email_principal,p.telefone,p.avatar_url,
|
||||
GREATEST(similarity(coalesce(p.nome_completo,''),v_q),similarity(coalesce(p.email_principal,''),v_q)*0.7,
|
||||
similarity(coalesce(p.telefone,''),v_q)*0.5,similarity(coalesce(p.cpf,''),v_q)*0.6) AS score
|
||||
FROM patients p WHERE p.nome_completo ILIKE v_pattern OR p.email_principal ILIKE v_pattern OR p.telefone ILIKE v_pattern OR p.cpf ILIKE v_pattern
|
||||
ORDER BY score DESC, p.nome_completo ASC LIMIT v_limit) ranked;
|
||||
END IF;
|
||||
IF p_scope IS NULL OR 'appointments' = ANY(p_scope) THEN
|
||||
SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,'label',label,
|
||||
'sublabel',trim(both ' · ' from coalesce(patient_name,')')||' · '||to_char(inicio_em,'DD/MM/YYYY HH24:MI')),
|
||||
'deeplink','/therapist/agenda?event='||id::text,'score',round(score::numeric,3))),'[]'::jsonb) INTO v_appointments
|
||||
FROM (SELECT e.id, coalesce(nullif(e.titulo_custom,''),nullif(e.titulo,''),'Sessão') AS label, e.inicio_em, pat.nome_completo AS patient_name,
|
||||
GREATEST(similarity(coalesce(e.titulo,''),v_q),similarity(coalesce(e.titulo_custom,''),v_q),similarity(coalesce(pat.nome_completo,''),v_q)*0.9) AS score
|
||||
FROM agenda_eventos e LEFT JOIN patients pat ON pat.id = e.patient_id
|
||||
WHERE e.titulo ILIKE v_pattern OR e.titulo_custom ILIKE v_pattern OR pat.nome_completo ILIKE v_pattern
|
||||
ORDER BY score DESC, e.inicio_em DESC LIMIT v_limit) ranked;
|
||||
END IF;
|
||||
IF p_scope IS NULL OR 'documents' = ANY(p_scope) THEN
|
||||
SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,'label',nome_original,
|
||||
'sublabel',trim(both ' · ' from coalesce(patient_name,'')||' · '||coalesce(tipo_documento,'')),
|
||||
'deeplink','/therapist/patients/'||patient_id::text||'/documents','score',round(score::numeric,3))),'[]'::jsonb) INTO v_documents
|
||||
FROM (SELECT d.id,d.patient_id,d.nome_original,d.tipo_documento,pat.nome_completo AS patient_name,
|
||||
GREATEST(similarity(coalesce(d.nome_original,''),v_q),similarity(coalesce(d.descricao,''),v_q)*0.7) AS score
|
||||
FROM documents d LEFT JOIN patients pat ON pat.id = d.patient_id
|
||||
WHERE d.nome_original ILIKE v_pattern OR d.descricao ILIKE v_pattern
|
||||
ORDER BY score DESC, d.nome_original ASC LIMIT v_limit) ranked;
|
||||
END IF;
|
||||
IF p_scope IS NULL OR 'services' = ANY(p_scope) THEN
|
||||
SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,'label',name,
|
||||
'sublabel',trim(both ' · ' from 'R$ '||to_char(price,'FM999G999G990D00')||' · '||coalesce(duration_min::text||' min','')),
|
||||
'deeplink','/configuracoes/precificacao','score',round(score::numeric,3))),'[]'::jsonb) INTO v_services
|
||||
FROM (SELECT s.id,s.name,s.price,s.duration_min,
|
||||
GREATEST(similarity(coalesce(s.name,''),v_q),similarity(coalesce(s.description,''),v_q)*0.7) AS score
|
||||
FROM services s WHERE s.active IS TRUE AND (s.name ILIKE v_pattern OR s.description ILIKE v_pattern)
|
||||
ORDER BY score DESC, s.name ASC LIMIT v_limit) ranked;
|
||||
END IF;
|
||||
-- intakes: patient_intake_requests FICA em public (F1b) → qualifica + filtra tenant_id
|
||||
IF p_scope IS NULL OR 'intakes' = ANY(p_scope) THEN
|
||||
SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,
|
||||
'label',coalesce(nullif(trim(nome_completo),''),'(sem nome)'),
|
||||
'sublabel',trim(both ' · ' from coalesce(nullif(email_principal,''),nullif(telefone,''),'')||' · '||'recebido '||to_char(created_at,'DD/MM/YYYY')),
|
||||
'deeplink','/therapist/patients/cadastro/recebidos?id='||id::text,'score',round(score::numeric,3))),'[]'::jsonb) INTO v_intakes
|
||||
FROM (SELECT r.id,r.nome_completo,r.email_principal,r.telefone,r.created_at,
|
||||
GREATEST(similarity(coalesce(r.nome_completo,''),v_q),similarity(coalesce(r.email_principal,''),v_q)*0.7,similarity(coalesce(r.telefone,''),v_q)*0.5) AS score
|
||||
FROM public.patient_intake_requests r
|
||||
WHERE r.tenant_id = p_tenant_id AND r.status='new'
|
||||
AND (r.nome_completo ILIKE v_pattern OR r.email_principal ILIKE v_pattern OR r.telefone ILIKE v_pattern)
|
||||
ORDER BY score DESC, r.created_at DESC LIMIT v_limit) ranked;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('patients',v_patients,'appointments',v_appointments,'documents',v_documents,'services',v_services,'intakes',v_intakes);
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- GRUPO 3 — RETURNS <tabela_tenant> → jsonb (ripple no FE)
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
DROP FUNCTION IF EXISTS public.mark_as_paid(uuid, text);
|
||||
CREATE FUNCTION public.mark_as_paid(p_tenant_id uuid, p_financial_record_id uuid, p_payment_method text)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_record RECORD;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT * INTO v_record FROM financial_records WHERE id = p_financial_record_id AND owner_id = auth.uid() AND deleted_at IS NULL;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'Registro financeiro não encontrado ou sem permissão.'; END IF;
|
||||
IF v_record.status NOT IN ('pending','overdue') THEN RAISE EXCEPTION 'Apenas cobranças pendentes ou vencidas podem ser marcadas como pagas.'; END IF;
|
||||
UPDATE financial_records SET status='paid', paid_at=now(), payment_method=p_payment_method, updated_at=now()
|
||||
WHERE id = p_financial_record_id RETURNING * INTO v_record;
|
||||
RETURN to_jsonb(v_record);
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.create_financial_record_for_session(uuid, uuid, uuid, uuid, numeric, date);
|
||||
CREATE FUNCTION public.create_financial_record_for_session(p_tenant_id uuid, p_owner_id uuid, p_patient_id uuid, p_agenda_evento_id uuid, p_amount numeric, p_due_date date)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_existing RECORD; v_new RECORD;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT * INTO v_existing FROM financial_records WHERE agenda_evento_id = p_agenda_evento_id AND deleted_at IS NULL AND status != 'cancelled' LIMIT 1;
|
||||
IF FOUND THEN RETURN to_jsonb(v_existing); END IF;
|
||||
INSERT INTO financial_records (owner_id, patient_id, agenda_evento_id, amount, discount_amount, final_amount, status, due_date)
|
||||
VALUES (p_owner_id, p_patient_id, p_agenda_evento_id, p_amount, 0, p_amount, 'pending', p_due_date)
|
||||
RETURNING * INTO v_new;
|
||||
UPDATE agenda_eventos SET billed = TRUE WHERE id = p_agenda_evento_id;
|
||||
RETURN to_jsonb(v_new);
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.mark_payout_as_paid(uuid);
|
||||
CREATE FUNCTION public.mark_payout_as_paid(p_tenant_id uuid, p_payout_id uuid)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_payout RECORD;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT * INTO v_payout FROM therapist_payouts WHERE id = p_payout_id;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'Repasse não encontrado: %', p_payout_id; END IF;
|
||||
IF NOT public.is_tenant_admin(p_tenant_id) THEN RAISE EXCEPTION 'Apenas o administrador da clínica pode marcar repasses como pagos.'; END IF;
|
||||
IF v_payout.status <> 'pending' THEN RAISE EXCEPTION 'Repasse já está com status ''%''. Apenas repasses pendentes podem ser pagos.', v_payout.status; END IF;
|
||||
UPDATE therapist_payouts SET status='paid', paid_at=now(), updated_at=now() WHERE id = p_payout_id RETURNING * INTO v_payout;
|
||||
RETURN to_jsonb(v_payout);
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.create_therapist_payout(uuid, uuid, date, date);
|
||||
CREATE FUNCTION public.create_therapist_payout(p_tenant_id uuid, p_therapist_id uuid, p_period_start date, p_period_end date)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_payout RECORD; v_total int; v_gross numeric(10,2); v_clinic_fee numeric(10,2); v_net numeric(10,2);
|
||||
BEGIN
|
||||
IF auth.uid() <> p_therapist_id AND NOT public.is_tenant_admin(p_tenant_id) THEN
|
||||
RAISE EXCEPTION 'Sem permissão para criar repasse para este terapeuta.';
|
||||
END IF;
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
IF EXISTS (SELECT 1 FROM therapist_payouts WHERE owner_id=p_therapist_id AND period_start=p_period_start AND period_end=p_period_end AND status<>'cancelled') THEN
|
||||
RAISE EXCEPTION 'Já existe um repasse ativo para o período % a % deste terapeuta.', p_period_start, p_period_end;
|
||||
END IF;
|
||||
SELECT COUNT(*), COALESCE(SUM(amount),0), COALESCE(SUM(clinic_fee_amount),0), COALESCE(SUM(net_amount),0)
|
||||
INTO v_total, v_gross, v_clinic_fee, v_net
|
||||
FROM financial_records fr
|
||||
WHERE fr.owner_id=p_therapist_id AND fr.type='receita' AND fr.status='paid' AND fr.deleted_at IS NULL
|
||||
AND fr.paid_at::date BETWEEN p_period_start AND p_period_end
|
||||
AND NOT EXISTS (SELECT 1 FROM therapist_payout_records tpr WHERE tpr.financial_record_id = fr.id);
|
||||
IF v_total = 0 THEN RAISE EXCEPTION 'Nenhum registro financeiro elegível encontrado para o período % a %.', p_period_start, p_period_end; END IF;
|
||||
INSERT INTO therapist_payouts (owner_id, period_start, period_end, total_sessions, gross_amount, clinic_fee_total, net_amount, status)
|
||||
VALUES (p_therapist_id, p_period_start, p_period_end, v_total, v_gross, v_clinic_fee, v_net, 'pending')
|
||||
RETURNING * INTO v_payout;
|
||||
INSERT INTO therapist_payout_records (payout_id, financial_record_id)
|
||||
SELECT v_payout.id, fr.id FROM financial_records fr
|
||||
WHERE fr.owner_id=p_therapist_id AND fr.type='receita' AND fr.status='paid' AND fr.deleted_at IS NULL
|
||||
AND fr.paid_at::date BETWEEN p_period_start AND p_period_end
|
||||
AND NOT EXISTS (SELECT 1 FROM therapist_payout_records tpr WHERE tpr.financial_record_id = fr.id);
|
||||
RETURN to_jsonb(v_payout);
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,308 @@
|
||||
-- =============================================================================
|
||||
-- F6.2 Lote E — RPCs de cron/global roteadas/loopadas por tenant
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin.
|
||||
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
|
||||
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
|
||||
-- < database-novo/manual/f6_2e_cron_rpcs.supabase_admin.sql
|
||||
--
|
||||
-- E1: chamadas per-tenant pelas edge crons → p_tenant_id + set_config search_path
|
||||
-- (helper public._tenant_route do Lote D). Edge ajustada (admin.rpc + p_tenant_id).
|
||||
-- E2: crons sem-arg que varrem TODOS os tenants → loop FROM tenant_schemas.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- helper SEM checagem de auth: resolve schema pra RPCs de SERVIÇO (chamadas por
|
||||
-- service_role/edge, que não é tenant_member). Protegido por REVOKE das RPCs de
|
||||
-- anon/authenticated (só service_role/postgres chamam).
|
||||
CREATE OR REPLACE FUNCTION public._tenant_schema_unchecked(p_tenant_id uuid)
|
||||
RETURNS text LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_schema text;
|
||||
BEGIN
|
||||
IF p_tenant_id IS NULL THEN RAISE EXCEPTION 'p_tenant_id obrigatório'; END IF;
|
||||
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||
IF v_schema IS NULL THEN RAISE EXCEPTION 'schema não encontrado p/ tenant %', p_tenant_id; END IF;
|
||||
RETURN v_schema;
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- E2 — crons globais: varrem todos os schemas
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.cleanup_notification_queue()
|
||||
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record; v_n int; v_total int := 0;
|
||||
BEGIN
|
||||
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
EXECUTE format('DELETE FROM %I.notification_queue WHERE status IN (''enviado'',''cancelado'',''ignorado'') AND created_at < now() - interval ''90 days''', t.schema_name);
|
||||
GET DIAGNOSTICS v_n = ROW_COUNT; v_total := v_total + v_n;
|
||||
END LOOP;
|
||||
RETURN v_total;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.unstick_notification_queue()
|
||||
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record; v_n int; v_total int := 0;
|
||||
BEGIN
|
||||
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
EXECUTE format('UPDATE %I.notification_queue SET status=''pendente'', attempts=attempts+1, last_error=''Timeout: preso em processando por >10min'', next_retry_at=now()+interval ''2 minutes'' WHERE status=''processando'' AND updated_at < now() - interval ''10 minutes''', t.schema_name);
|
||||
GET DIAGNOSTICS v_n = ROW_COUNT; v_total := v_total + v_n;
|
||||
END LOOP;
|
||||
RETURN v_total;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.sync_overdue_financial_records()
|
||||
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record; v_n int; v_total int := 0;
|
||||
BEGIN
|
||||
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
EXECUTE format('UPDATE %I.financial_records SET status=''overdue'', updated_at=now() WHERE status=''pending'' AND due_date IS NOT NULL AND due_date < CURRENT_DATE AND deleted_at IS NULL', t.schema_name);
|
||||
GET DIAGNOSTICS v_n = ROW_COUNT; v_total := v_total + v_n;
|
||||
END LOOP;
|
||||
RETURN v_total;
|
||||
END $$;
|
||||
|
||||
-- populate: complexo (multi-tabela). set_config search_path por tenant; profiles
|
||||
-- é GLOBAL → qualificado. Remove tenant_id do INSERT e do SELECT.
|
||||
CREATE OR REPLACE FUNCTION public.populate_notification_queue()
|
||||
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record;
|
||||
BEGIN
|
||||
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
PERFORM set_config('search_path', t.schema_name || ',public,pg_temp', true);
|
||||
INSERT INTO notification_queue (
|
||||
owner_id, agenda_evento_id, patient_id, channel, template_key, schedule_key,
|
||||
resolved_vars, recipient_address, scheduled_at, idempotency_key)
|
||||
SELECT
|
||||
ae.owner_id, ae.id, ae.patient_id, ch.channel,
|
||||
'session.' || REPLACE(ns.event_type, '_sessao', '') || '.' || ch.channel,
|
||||
ns.schedule_key,
|
||||
jsonb_build_object('nome_paciente', COALESCE(p.nome_completo,'Paciente'),
|
||||
'data_sessao', TO_CHAR(ae.inicio_em AT TIME ZONE 'America/Sao_Paulo','DD/MM/YYYY'),
|
||||
'hora_sessao', TO_CHAR(ae.inicio_em AT TIME ZONE 'America/Sao_Paulo','HH24:MI'),
|
||||
'nome_terapeuta', COALESCE(prof.full_name,'Terapeuta'),
|
||||
'modalidade', COALESCE(ae.modalidade,'Presencial'),
|
||||
'titulo', COALESCE(ae.titulo,'Sessão')),
|
||||
CASE ch.channel WHEN 'whatsapp' THEN COALESCE(p.telefone,'') WHEN 'sms' THEN COALESCE(p.telefone,'') WHEN 'email' THEN COALESCE(p.email_principal,'') END,
|
||||
CASE
|
||||
WHEN (ae.inicio_em - (ns.offset_minutes||' minutes')::interval)::time < ns.allowed_time_start
|
||||
THEN DATE_TRUNC('day', ae.inicio_em - (ns.offset_minutes||' minutes')::interval) + ns.allowed_time_start
|
||||
WHEN (ae.inicio_em - (ns.offset_minutes||' minutes')::interval)::time > ns.allowed_time_end
|
||||
THEN DATE_TRUNC('day', ae.inicio_em - (ns.offset_minutes||' minutes')::interval) + ns.allowed_time_start
|
||||
ELSE ae.inicio_em - (ns.offset_minutes||' minutes')::interval END,
|
||||
ae.id::text||':'||ns.schedule_key||':'||ch.channel||':'||ae.inicio_em::date::text
|
||||
FROM agenda_eventos ae
|
||||
JOIN patients p ON p.id = ae.patient_id
|
||||
LEFT JOIN public.profiles prof ON prof.id = ae.owner_id -- GLOBAL
|
||||
JOIN notification_schedules ns ON ns.owner_id = ae.owner_id AND ns.is_active=true AND ns.deleted_at IS NULL AND ns.trigger_type='before_event' AND ns.event_type='lembrete_sessao'
|
||||
JOIN notification_channels nc ON nc.owner_id = ae.owner_id AND nc.is_active=true AND nc.deleted_at IS NULL
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT 'whatsapp' AS channel WHERE ns.whatsapp_enabled AND nc.channel='whatsapp'
|
||||
UNION ALL SELECT 'email' WHERE ns.email_enabled AND nc.channel='email'
|
||||
UNION ALL SELECT 'sms' WHERE ns.sms_enabled AND nc.channel='sms') ch
|
||||
LEFT JOIN notification_preferences np ON np.patient_id = ae.patient_id AND np.owner_id = ae.owner_id AND np.deleted_at IS NULL
|
||||
WHERE ae.tipo = 'sessao' AND ae.status NOT IN ('cancelado','realizado') AND ae.inicio_em > now()
|
||||
AND (ae.inicio_em - (ns.offset_minutes||' minutes')::interval) > now()
|
||||
ON CONFLICT (idempotency_key) DO NOTHING;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- E1 — chamadas per-tenant (p_tenant_id + route)
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.sla_open_breach(p_tenant_id uuid, p_thread_key text, p_assigned_to uuid, p_last_inbound_at timestamptz, p_threshold_minutes integer)
|
||||
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_existing_id uuid; v_new_id uuid;
|
||||
BEGIN
|
||||
IF p_tenant_id IS NULL OR p_thread_key IS NULL THEN RAISE EXCEPTION 'tenant_and_thread_required'; END IF;
|
||||
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT id INTO v_existing_id FROM conversation_sla_breaches WHERE thread_key = p_thread_key AND resolved_at IS NULL;
|
||||
IF FOUND THEN
|
||||
UPDATE conversation_sla_breaches SET assigned_to = COALESCE(p_assigned_to, assigned_to), last_inbound_at = COALESCE(p_last_inbound_at, last_inbound_at) WHERE id = v_existing_id;
|
||||
RETURN v_existing_id;
|
||||
END IF;
|
||||
INSERT INTO conversation_sla_breaches (thread_key, assigned_to, last_inbound_at, threshold_minutes_at_breach)
|
||||
VALUES (p_thread_key, p_assigned_to, p_last_inbound_at, p_threshold_minutes) RETURNING id INTO v_new_id;
|
||||
RETURN v_new_id;
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.sla_mark_notified(uuid);
|
||||
DROP FUNCTION IF EXISTS public.sla_mark_notified(uuid,uuid);
|
||||
CREATE FUNCTION public.sla_mark_notified(p_tenant_id uuid, p_breach_id uuid)
|
||||
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
|
||||
UPDATE conversation_sla_breaches SET notified_at = now(), notification_count = notification_count + 1 WHERE id = p_breach_id;
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_open_incident(uuid, text, text, jsonb);
|
||||
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_open_incident(uuid, uuid, text, text, jsonb);
|
||||
CREATE FUNCTION public.whatsapp_heartbeat_open_incident(p_tenant_id uuid, p_channel_id uuid, p_kind text, p_last_state text DEFAULT NULL, p_details jsonb DEFAULT NULL)
|
||||
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_provider text; v_existing_id uuid; v_new_id uuid;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT provider INTO v_provider FROM notification_channels WHERE id = p_channel_id AND deleted_at IS NULL;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'channel_not_found'; END IF;
|
||||
IF p_kind NOT IN ('disconnected','error','qr_pending','connecting','unknown') THEN RAISE EXCEPTION 'invalid_kind: %', p_kind; END IF;
|
||||
SELECT id INTO v_existing_id FROM whatsapp_connection_incidents WHERE channel_id = p_channel_id AND resolved_at IS NULL;
|
||||
IF FOUND THEN
|
||||
UPDATE whatsapp_connection_incidents SET last_state = COALESCE(p_last_state, last_state), details = COALESCE(p_details, details), kind = p_kind WHERE id = v_existing_id;
|
||||
RETURN v_existing_id;
|
||||
END IF;
|
||||
INSERT INTO whatsapp_connection_incidents (channel_id, provider, kind, last_state, details)
|
||||
VALUES (p_channel_id, v_provider, p_kind, p_last_state, p_details) RETURNING id INTO v_new_id;
|
||||
RETURN v_new_id;
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_mark_notified(uuid);
|
||||
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_mark_notified(uuid,uuid);
|
||||
CREATE FUNCTION public.whatsapp_heartbeat_mark_notified(p_tenant_id uuid, p_incident_id uuid)
|
||||
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
|
||||
UPDATE whatsapp_connection_incidents SET notified_at = now(), notification_count = notification_count + 1 WHERE id = p_incident_id;
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_resolve_open_incidents(uuid);
|
||||
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_resolve_open_incidents(uuid,uuid);
|
||||
CREATE FUNCTION public.whatsapp_heartbeat_resolve_open_incidents(p_tenant_id uuid, p_channel_id uuid)
|
||||
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_count int := 0;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
|
||||
UPDATE whatsapp_connection_incidents SET resolved_at = now(), duration_seconds = EXTRACT(EPOCH FROM (now() - started_at))::int
|
||||
WHERE channel_id = p_channel_id AND resolved_at IS NULL;
|
||||
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||
RETURN v_count;
|
||||
END $$;
|
||||
|
||||
-- convert_abandoned_intake_to_lead: resolve o tenant INTERNAMENTE (intake.owner_id
|
||||
-- -> tenant_members). patient_intake_requests FICA em public (F1b). Writes de
|
||||
-- conversation_messages/notes vão pro schema do tenant resolvido.
|
||||
CREATE OR REPLACE FUNCTION public.convert_abandoned_intake_to_lead(p_intake_id uuid)
|
||||
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_intake RECORD; v_tenant_id uuid; v_schema text; v_thread_key text; v_phone text;
|
||||
v_note_body text; v_admin_id uuid;
|
||||
BEGIN
|
||||
SELECT * INTO v_intake FROM public.patient_intake_requests WHERE id = p_intake_id;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'intake_not_found'; END IF;
|
||||
IF v_intake.status = 'abandoned_lead' THEN RETURN v_intake.lead_thread_key::uuid; END IF;
|
||||
SELECT tenant_id INTO v_tenant_id FROM public.tenant_members WHERE user_id = v_intake.owner_id
|
||||
ORDER BY CASE role WHEN 'tenant_admin' THEN 1 WHEN 'clinic_admin' THEN 2 ELSE 3 END LIMIT 1;
|
||||
IF v_tenant_id IS NULL THEN RAISE EXCEPTION 'tenant_not_resolved'; END IF;
|
||||
v_schema := public.tenant_schema_for(v_tenant_id);
|
||||
IF v_schema IS NULL THEN RAISE EXCEPTION 'schema_not_found'; END IF;
|
||||
|
||||
v_phone := regexp_replace(COALESCE(v_intake.telefone,''),'\D','','g');
|
||||
IF length(v_phone) BETWEEN 10 AND 11 THEN v_phone := '55'||v_phone; END IF;
|
||||
IF v_phone = '' THEN v_phone := 'unknown'; END IF;
|
||||
v_thread_key := 'anon:'||v_phone;
|
||||
v_note_body := format('📋 Lead abandonado (cadastro externo):%s%sNome: %s%sTelefone: %s%sE-mail: %s%sMotivo/Observacoes: %s%s%sIniciou em: %s · Ultima atualizacao: %s',
|
||||
E'\n',E'\n', COALESCE(v_intake.nome_completo,'—'), E'\n', COALESCE(v_intake.telefone,'—'), E'\n',
|
||||
COALESCE(v_intake.email_principal,'—'), E'\n', COALESCE(v_intake.onde_nos_conheceu,'—'), E'\n',E'\n',
|
||||
to_char(v_intake.created_at AT TIME ZONE 'America/Sao_Paulo','DD/MM HH24:MI'),
|
||||
to_char(COALESCE(v_intake.last_progress_at, v_intake.updated_at) AT TIME ZONE 'America/Sao_Paulo','DD/MM HH24:MI'));
|
||||
|
||||
SELECT user_id INTO v_admin_id FROM public.tenant_members
|
||||
WHERE tenant_id = v_tenant_id AND role IN ('tenant_admin','clinic_admin') AND status='active' LIMIT 1;
|
||||
IF v_admin_id IS NULL THEN v_admin_id := v_intake.owner_id; END IF;
|
||||
|
||||
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||
INSERT INTO conversation_messages (channel, direction, from_number, to_number, body, provider, provider_raw, kanban_status)
|
||||
VALUES ('whatsapp','inbound', CASE WHEN v_phone='unknown' THEN NULL ELSE v_phone END, NULL,
|
||||
format('🧾 Cadastro externo iniciado e não finalizado. %s entrou em contato via link público mas abandonou o formulário — ver nota interna.', COALESCE(v_intake.nome_completo,'Visitante')),
|
||||
'system', jsonb_build_object('lead_from_abandoned_intake', true, 'intake_id', v_intake.id), 'awaiting_us');
|
||||
INSERT INTO conversation_notes (thread_key, contact_number, body, created_by)
|
||||
VALUES (v_thread_key, CASE WHEN v_phone='unknown' THEN NULL ELSE v_phone END, v_note_body, v_admin_id);
|
||||
|
||||
UPDATE public.patient_intake_requests SET status='abandoned_lead', lead_thread_key=v_thread_key, updated_at=now() WHERE id = p_intake_id;
|
||||
RETURN p_intake_id;
|
||||
END $$;
|
||||
|
||||
-- first_response analytics: routam pelo p_tenant_id (cada função seta o seu próprio
|
||||
-- search_path — _first_response_runs tem SET search_path próprio que resetaria).
|
||||
CREATE OR REPLACE FUNCTION public._first_response_runs(p_tenant_id uuid, p_from timestamptz, p_to timestamptz)
|
||||
RETURNS TABLE(thread_key text, inbound_started_at timestamptz, responded_at timestamptz, response_seconds integer, responder_id uuid)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
RETURN QUERY
|
||||
WITH base AS (
|
||||
SELECT COALESCE(m.patient_id::text, 'anon:' || COALESCE(CASE WHEN m.direction='inbound' THEN m.from_number ELSE m.to_number END, 'unknown')) AS thread_key,
|
||||
m.direction, m.created_at
|
||||
FROM conversation_messages m
|
||||
WHERE m.created_at >= p_from AND m.created_at < p_to
|
||||
),
|
||||
inbound AS (
|
||||
SELECT b.thread_key AS tk, min(b.created_at) AS inbound_started_at
|
||||
FROM base b WHERE b.direction='inbound' GROUP BY b.thread_key
|
||||
)
|
||||
SELECT i.tk, i.inbound_started_at,
|
||||
(SELECT min(b2.created_at) FROM base b2 WHERE b2.thread_key = i.tk AND b2.direction='outbound' AND b2.created_at >= i.inbound_started_at) AS responded_at,
|
||||
EXTRACT(EPOCH FROM ((SELECT min(b2.created_at) FROM base b2 WHERE b2.thread_key = i.tk AND b2.direction='outbound' AND b2.created_at >= i.inbound_started_at) - i.inbound_started_at))::int AS response_seconds,
|
||||
a.assigned_to AS responder_id
|
||||
FROM inbound i
|
||||
LEFT JOIN conversation_assignments a ON a.thread_key = i.tk;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.first_response_stats(p_tenant_id uuid, p_from timestamptz DEFAULT (now() - interval '30 days'), p_to timestamptz DEFAULT now(), p_therapist_id uuid DEFAULT NULL)
|
||||
RETURNS TABLE(runs_count integer, avg_seconds integer, median_seconds integer, min_seconds integer, max_seconds integer, sla_threshold_seconds integer, sla_compliant_count integer, sla_compliance_rate numeric)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_threshold_min integer;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT threshold_minutes INTO v_threshold_min FROM conversation_sla_rules LIMIT 1;
|
||||
v_threshold_min := COALESCE(v_threshold_min, 30);
|
||||
RETURN QUERY
|
||||
WITH runs AS (
|
||||
SELECT r.response_seconds FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
|
||||
WHERE r.responded_at IS NOT NULL AND (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
|
||||
)
|
||||
SELECT count(*)::int,
|
||||
COALESCE(avg(response_seconds),0)::int,
|
||||
COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY response_seconds),0)::int,
|
||||
COALESCE(min(response_seconds),0)::int,
|
||||
COALESCE(max(response_seconds),0)::int,
|
||||
(v_threshold_min*60)::int,
|
||||
count(*) FILTER (WHERE response_seconds <= v_threshold_min*60)::int,
|
||||
CASE WHEN count(*)=0 THEN 0 ELSE round(100.0*count(*) FILTER (WHERE response_seconds <= v_threshold_min*60)/count(*),1) END
|
||||
FROM runs;
|
||||
END $$;
|
||||
|
||||
-- RPCs de serviço (cron/edge): só service_role/postgres. Sem checagem de membership.
|
||||
DO $g$
|
||||
DECLARE fn text;
|
||||
BEGIN
|
||||
FOREACH fn IN ARRAY ARRAY[
|
||||
'sla_open_breach(uuid,text,uuid,timestamptz,integer)',
|
||||
'sla_mark_notified(uuid,uuid)',
|
||||
'whatsapp_heartbeat_open_incident(uuid,uuid,text,text,jsonb)',
|
||||
'whatsapp_heartbeat_mark_notified(uuid,uuid)',
|
||||
'whatsapp_heartbeat_resolve_open_incidents(uuid,uuid)',
|
||||
'convert_abandoned_intake_to_lead(uuid)',
|
||||
'cleanup_notification_queue()','unstick_notification_queue()',
|
||||
'sync_overdue_financial_records()','populate_notification_queue()'
|
||||
] LOOP
|
||||
EXECUTE format('REVOKE ALL ON FUNCTION public.%s FROM PUBLIC, anon, authenticated', fn);
|
||||
EXECUTE format('GRANT EXECUTE ON FUNCTION public.%s TO service_role', fn);
|
||||
END LOOP;
|
||||
END $g$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,247 @@
|
||||
-- =============================================================================
|
||||
-- F6.2 Lote F — RPCs anon/token: resolvem tenant por token/slug e roteiam
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin.
|
||||
--
|
||||
-- Visitante anon não está logado → cada RPC resolve o tenant a partir do
|
||||
-- token/slug do registro que VIVE em public (F1b: document_share_links,
|
||||
-- agendador_configuracoes — ambos têm tenant_id), depois set_config search_path
|
||||
-- pro schema só pras tabelas tenant (documents, document_signatures,
|
||||
-- document_access_logs, patients, agenda_*, recurrence_*).
|
||||
-- Tabelas que ficam em public seguem qualificadas (document_share_links,
|
||||
-- agendador_configuracoes/solicitacoes).
|
||||
-- %ROWTYPE de tabelas tenant → RECORD; RETURNS document_signatures → jsonb.
|
||||
-- list_my_signatures é cross-tenant (assinante em vários tenants) → fan-out.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── Documentos: tenant via document_share_links.tenant_id (public) ──────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.validate_share_token(p_token text)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE sl public.document_share_links%ROWTYPE; v_doc RECORD; v_token text; v_schema text;
|
||||
BEGIN
|
||||
v_token := nullif(btrim(coalesce(p_token,'')),'');
|
||||
IF v_token IS NULL THEN RAISE EXCEPTION 'token obrigatório' USING ERRCODE='22023'; END IF;
|
||||
SELECT * INTO sl FROM public.document_share_links WHERE token = v_token LIMIT 1;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'Token inválido' USING ERRCODE='28000'; END IF;
|
||||
IF sl.ativo IS NOT TRUE THEN RAISE EXCEPTION 'Link desativado' USING ERRCODE='28000'; END IF;
|
||||
IF sl.expira_em IS NOT NULL AND sl.expira_em < now() THEN RAISE EXCEPTION 'Link expirado' USING ERRCODE='28000'; END IF;
|
||||
IF sl.usos_max IS NOT NULL AND sl.usos >= sl.usos_max THEN RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE='28000'; END IF;
|
||||
v_schema := public.tenant_schema_for(sl.tenant_id);
|
||||
IF v_schema IS NULL THEN RAISE EXCEPTION 'tenant inválido' USING ERRCODE='28000'; END IF;
|
||||
|
||||
UPDATE public.document_share_links SET usos = usos + 1 WHERE id = sl.id;
|
||||
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||
BEGIN
|
||||
INSERT INTO document_access_logs (documento_id, action, share_link_id)
|
||||
VALUES (sl.documento_id, 'shared_link_access', sl.id);
|
||||
EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
SELECT * INTO v_doc FROM documents WHERE id = sl.documento_id;
|
||||
RETURN jsonb_build_object('document_id', sl.documento_id, 'bucket', v_doc.storage_bucket,
|
||||
'bucket_path', v_doc.bucket_path, 'nome_original', v_doc.nome_original,
|
||||
'mime_type', v_doc.mime_type, 'tamanho_bytes', v_doc.tamanho_bytes);
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_signable_document_by_token(p_token text)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_link public.document_share_links%ROWTYPE; v_doc RECORD; v_sigs jsonb; v_schema text;
|
||||
BEGIN
|
||||
IF p_token IS NULL OR length(p_token) < 32 THEN RAISE EXCEPTION 'Token inválido' USING ERRCODE='22023'; END IF;
|
||||
SELECT * INTO v_link FROM public.document_share_links WHERE token = p_token AND ativo=true AND expira_em > now() AND usos < usos_max LIMIT 1;
|
||||
IF v_link.id IS NULL THEN RETURN jsonb_build_object('valid', false, 'error', 'expired_or_invalid'); END IF;
|
||||
v_schema := public.tenant_schema_for(v_link.tenant_id);
|
||||
IF v_schema IS NULL THEN RETURN jsonb_build_object('valid', false, 'error', 'tenant_invalid'); END IF;
|
||||
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||
SELECT * INTO v_doc FROM documents WHERE id = v_link.documento_id AND deleted_at IS NULL LIMIT 1;
|
||||
IF v_doc.id IS NULL THEN RETURN jsonb_build_object('valid', false, 'error', 'document_not_found'); END IF;
|
||||
SELECT jsonb_agg(jsonb_build_object('id',s.id,'signatario_tipo',s.signatario_tipo,'signatario_nome',s.signatario_nome,
|
||||
'signatario_email',s.signatario_email,'ordem',s.ordem,'status',s.status,'assinado_em',s.assinado_em) ORDER BY s.ordem) INTO v_sigs
|
||||
FROM document_signatures s WHERE s.documento_id = v_doc.id;
|
||||
RETURN jsonb_build_object('valid', true,
|
||||
'document', jsonb_build_object('id',v_doc.id,'nome_original',v_doc.nome_original,'mime_type',v_doc.mime_type,
|
||||
'tamanho_bytes',v_doc.tamanho_bytes,'bucket_path',v_doc.bucket_path,'storage_bucket',v_doc.storage_bucket,'tipo_documento',v_doc.tipo_documento),
|
||||
'signatures', COALESCE(v_sigs,'[]'::jsonb), 'expira_em', v_link.expira_em, 'usos_restantes', v_link.usos_max - v_link.usos);
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.sign_document_by_token(text, uuid, text);
|
||||
CREATE FUNCTION public.sign_document_by_token(p_token text, p_signature_id uuid DEFAULT NULL, p_hash_documento text DEFAULT NULL)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_link public.document_share_links%ROWTYPE; v_sig RECORD; v_ip inet; v_ua text; v_schema text;
|
||||
BEGIN
|
||||
IF p_token IS NULL OR length(p_token) < 32 THEN RAISE EXCEPTION 'Token inválido' USING ERRCODE='22023'; END IF;
|
||||
SELECT * INTO v_link FROM public.document_share_links WHERE token = p_token AND ativo=true AND expira_em > now() AND usos < usos_max LIMIT 1;
|
||||
IF v_link.id IS NULL THEN RAISE EXCEPTION 'Link expirado, inválido ou esgotado' USING ERRCODE='P0002'; END IF;
|
||||
v_schema := public.tenant_schema_for(v_link.tenant_id);
|
||||
IF v_schema IS NULL THEN RAISE EXCEPTION 'tenant inválido' USING ERRCODE='P0002'; END IF;
|
||||
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||
IF p_signature_id IS NOT NULL THEN
|
||||
SELECT * INTO v_sig FROM document_signatures WHERE id = p_signature_id AND documento_id = v_link.documento_id AND status IN ('pendente','enviado') LIMIT 1;
|
||||
ELSE
|
||||
SELECT * INTO v_sig FROM document_signatures WHERE documento_id = v_link.documento_id AND status IN ('pendente','enviado') ORDER BY ordem ASC, criado_em ASC LIMIT 1;
|
||||
END IF;
|
||||
IF v_sig.id IS NULL THEN RAISE EXCEPTION 'Nenhuma assinatura pendente para este documento' USING ERRCODE='P0002'; END IF;
|
||||
v_ip := inet_client_addr();
|
||||
BEGIN v_ua := current_setting('request.headers', true)::json ->> 'user-agent'; EXCEPTION WHEN OTHERS THEN v_ua := NULL; END;
|
||||
UPDATE document_signatures SET status='assinado', ip=v_ip, user_agent=v_ua, assinado_em=now(),
|
||||
hash_documento=COALESCE(p_hash_documento, hash_documento), atualizado_em=now()
|
||||
WHERE id = v_sig.id RETURNING * INTO v_sig;
|
||||
UPDATE public.document_share_links SET usos = usos + 1 WHERE id = v_link.id;
|
||||
RETURN to_jsonb(v_sig);
|
||||
END $$;
|
||||
|
||||
-- sign_document_by_signature_id: assinante LOGADO (paciente OU therapist) via
|
||||
-- portal. Paciente NÃO é tenant_member → routing UNCHECKED (p_tenant_id vem do
|
||||
-- FE, da própria assinatura listada). Autorização é por LINHA: só assina se for
|
||||
-- o signatário (signatario_id = uid OU email do uid).
|
||||
DROP FUNCTION IF EXISTS public.sign_document_by_signature_id(uuid, text);
|
||||
DROP FUNCTION IF EXISTS public.sign_document_by_signature_id(uuid, uuid, text);
|
||||
CREATE FUNCTION public.sign_document_by_signature_id(p_tenant_id uuid, p_signature_id uuid, p_hash_documento text DEFAULT NULL)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_row RECORD; v_ip inet; v_ua text; v_uid uuid; v_email text;
|
||||
BEGIN
|
||||
IF p_signature_id IS NULL THEN RAISE EXCEPTION 'p_signature_id obrigatório' USING ERRCODE='22023'; END IF;
|
||||
v_uid := auth.uid();
|
||||
IF v_uid IS NULL THEN RAISE EXCEPTION 'Sessão inválida' USING ERRCODE='28000'; END IF;
|
||||
SELECT email INTO v_email FROM auth.users WHERE id = v_uid;
|
||||
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
|
||||
v_ip := inet_client_addr();
|
||||
BEGIN v_ua := current_setting('request.headers', true)::json ->> 'user-agent'; EXCEPTION WHEN OTHERS THEN v_ua := NULL; END;
|
||||
UPDATE document_signatures SET status='assinado', ip=v_ip, user_agent=v_ua, assinado_em=now(),
|
||||
hash_documento=COALESCE(p_hash_documento, hash_documento), atualizado_em=now()
|
||||
WHERE id = p_signature_id AND status IN ('pendente','enviado')
|
||||
AND (signatario_id = v_uid OR signatario_email = v_email
|
||||
OR documento_id IN (SELECT d.id FROM documents d JOIN patients p ON p.id = d.patient_id WHERE p.user_id = v_uid))
|
||||
RETURNING * INTO v_row;
|
||||
IF v_row.id IS NULL THEN RAISE EXCEPTION 'Assinatura não encontrada, já processada, ou sem permissão' USING ERRCODE='P0002'; END IF;
|
||||
RETURN to_jsonb(v_row);
|
||||
END $$;
|
||||
|
||||
-- list_my_signatures: cross-tenant. Fan-out por schema (tenant_id injetado do loop).
|
||||
-- document_share_links é GLOBAL (public). Ordenação global é aproximada (por schema).
|
||||
CREATE OR REPLACE FUNCTION public.list_my_signatures(p_status text[] DEFAULT NULL)
|
||||
RETURNS TABLE(signature_id uuid, documento_id uuid, tenant_id uuid, signatario_tipo text, status text, ordem smallint, assinado_em timestamptz, criado_em timestamptz, nome_original text, tipo_documento text, mime_type text, share_token text, share_expira_em timestamptz)
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_uid uuid; t record;
|
||||
BEGIN
|
||||
v_uid := auth.uid();
|
||||
IF v_uid IS NULL THEN RAISE EXCEPTION 'Sessão inválida' USING ERRCODE='28000'; END IF;
|
||||
FOR t IN SELECT ts.tenant_id, ts.schema_name FROM public.tenant_schemas ts LOOP
|
||||
RETURN QUERY EXECUTE format(
|
||||
'SELECT s.id, s.documento_id, $2::uuid, s.signatario_tipo, s.status, s.ordem, s.assinado_em, s.criado_em, '
|
||||
|| 'd.nome_original, d.tipo_documento, d.mime_type, sl.token, sl.expira_em '
|
||||
|| 'FROM %1$I.document_signatures s '
|
||||
|| 'JOIN %1$I.documents d ON d.id = s.documento_id AND d.deleted_at IS NULL '
|
||||
|| 'LEFT JOIN LATERAL (SELECT token, expira_em FROM public.document_share_links WHERE documento_id = d.id AND ativo=true AND expira_em > now() AND usos < usos_max ORDER BY criado_em DESC LIMIT 1) sl ON true '
|
||||
|| 'WHERE (s.signatario_id = $1 OR s.signatario_email = (SELECT email FROM auth.users WHERE id=$1) '
|
||||
|| 'OR d.patient_id IN (SELECT p.id FROM %1$I.patients p WHERE p.user_id = $1)) '
|
||||
|| 'AND ($3::text[] IS NULL OR s.status = ANY($3))',
|
||||
t.schema_name)
|
||||
USING v_uid, t.tenant_id, p_status;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ── match_patient_by_phone: service (edge), p_tenant_id → unchecked ──────────
|
||||
CREATE OR REPLACE FUNCTION public.match_patient_by_phone(p_tenant_id uuid, p_phone text)
|
||||
RETURNS uuid LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_normalized text; v_patient_id uuid;
|
||||
BEGIN
|
||||
v_normalized := public.normalize_phone_br(p_phone);
|
||||
IF v_normalized IS NULL OR length(v_normalized) < 10 THEN RETURN NULL; END IF;
|
||||
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT id INTO v_patient_id FROM patients WHERE public.normalize_phone_br(telefone) = v_normalized LIMIT 1;
|
||||
IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF;
|
||||
SELECT id INTO v_patient_id FROM patients WHERE public.normalize_phone_br(telefone_alternativo) = v_normalized LIMIT 1;
|
||||
IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF;
|
||||
SELECT id INTO v_patient_id FROM patients WHERE public.normalize_phone_br(telefone_responsavel) = v_normalized LIMIT 1;
|
||||
RETURN v_patient_id;
|
||||
END $$;
|
||||
|
||||
-- ── Agendador público: tenant via agendador_configuracoes.tenant_id (public) ─
|
||||
CREATE OR REPLACE FUNCTION public.agendador_dias_disponiveis(p_slug text, p_ano integer, p_mes integer)
|
||||
RETURNS TABLE(data date, tem_slots boolean) LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_owner_id uuid; v_tenant_id uuid; v_schema text; v_antecedencia int; v_agora timestamptz;
|
||||
v_data date; v_data_inicio date; v_data_fim date; v_db_dow int; v_tem_slot boolean; v_bloqueado boolean;
|
||||
BEGIN
|
||||
SELECT c.owner_id, c.tenant_id, c.antecedencia_minima_horas INTO v_owner_id, v_tenant_id, v_antecedencia
|
||||
FROM public.agendador_configuracoes c WHERE c.link_slug = p_slug AND c.ativo = true LIMIT 1;
|
||||
IF v_owner_id IS NULL THEN RETURN; END IF;
|
||||
v_schema := public.tenant_schema_for(v_tenant_id);
|
||||
IF v_schema IS NULL THEN RETURN; END IF;
|
||||
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||
v_agora := now(); v_data_inicio := make_date(p_ano, p_mes, 1);
|
||||
v_data_fim := (v_data_inicio + interval '1 month' - interval '1 day')::date; v_data := v_data_inicio;
|
||||
WHILE v_data <= v_data_fim LOOP
|
||||
v_db_dow := extract(dow from v_data::timestamp)::int;
|
||||
SELECT EXISTS (SELECT 1 FROM agenda_bloqueios b WHERE b.owner_id=v_owner_id AND b.data_inicio<=v_data AND COALESCE(b.data_fim,v_data)>=v_data AND b.hora_inicio IS NULL AND ((NOT b.recorrente) OR (b.recorrente AND b.dia_semana=v_db_dow))) INTO v_bloqueado;
|
||||
IF v_bloqueado THEN v_data := v_data + 1; CONTINUE; END IF;
|
||||
SELECT EXISTS (SELECT 1 FROM agenda_online_slots s WHERE s.owner_id=v_owner_id AND s.weekday=v_db_dow AND s.enabled=true
|
||||
AND (v_data::text||' '||s.time::text)::timestamp AT TIME ZONE 'America/Sao_Paulo' >= v_agora + (v_antecedencia||' hours')::interval) INTO v_tem_slot;
|
||||
IF v_tem_slot THEN data := v_data; tem_slots := true; RETURN NEXT; END IF;
|
||||
v_data := v_data + 1;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.agendador_slots_disponiveis(p_slug text, p_data date)
|
||||
RETURNS TABLE(hora time without time zone, disponivel boolean) LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_owner_id uuid; v_tenant_id uuid; v_schema text; v_duracao int; v_antecedencia int; v_agora timestamptz;
|
||||
v_db_dow int; v_slot time; v_slot_fim time; v_slot_ts timestamptz; v_ocupado boolean;
|
||||
v_rule RECORD; v_rule_start_dow int; v_first_occ date; v_day_diff int; v_ex_type text;
|
||||
BEGIN
|
||||
SELECT c.owner_id, c.tenant_id, c.duracao_sessao_min, c.antecedencia_minima_horas
|
||||
INTO v_owner_id, v_tenant_id, v_duracao, v_antecedencia
|
||||
FROM public.agendador_configuracoes c WHERE c.link_slug = p_slug AND c.ativo = true LIMIT 1;
|
||||
IF v_owner_id IS NULL THEN RETURN; END IF;
|
||||
v_schema := public.tenant_schema_for(v_tenant_id);
|
||||
IF v_schema IS NULL THEN RETURN; END IF;
|
||||
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||
v_agora := now(); v_db_dow := extract(dow from p_data::timestamp)::int;
|
||||
IF EXISTS (SELECT 1 FROM agenda_bloqueios b WHERE b.owner_id=v_owner_id AND b.data_inicio<=p_data AND COALESCE(b.data_fim,p_data)>=p_data AND b.hora_inicio IS NULL AND ((NOT b.recorrente) OR (b.recorrente AND b.dia_semana=v_db_dow))) THEN RETURN; END IF;
|
||||
FOR v_slot IN SELECT s.time FROM agenda_online_slots s WHERE s.owner_id=v_owner_id AND s.weekday=v_db_dow AND s.enabled=true ORDER BY s.time LOOP
|
||||
v_slot_fim := v_slot + (v_duracao||' minutes')::interval; v_ocupado := false;
|
||||
v_slot_ts := (p_data::text||' '||v_slot::text)::timestamp AT TIME ZONE 'America/Sao_Paulo';
|
||||
IF v_slot_ts < v_agora + (v_antecedencia||' hours')::interval THEN v_ocupado := true; END IF;
|
||||
IF NOT v_ocupado THEN
|
||||
SELECT EXISTS (SELECT 1 FROM agenda_bloqueios b WHERE b.owner_id=v_owner_id AND b.data_inicio<=p_data AND COALESCE(b.data_fim,p_data)>=p_data AND b.hora_inicio IS NOT NULL AND b.hora_inicio<v_slot_fim AND b.hora_fim>v_slot AND ((NOT b.recorrente) OR (b.recorrente AND b.dia_semana=v_db_dow))) INTO v_ocupado;
|
||||
END IF;
|
||||
IF NOT v_ocupado THEN
|
||||
SELECT EXISTS (SELECT 1 FROM agenda_eventos e WHERE e.owner_id=v_owner_id AND e.status::text NOT IN ('cancelado','faltou') AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::date=p_data AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::time<v_slot_fim AND (e.fim_em AT TIME ZONE 'America/Sao_Paulo')::time>v_slot) INTO v_ocupado;
|
||||
END IF;
|
||||
IF NOT v_ocupado THEN
|
||||
FOR v_rule IN SELECT r.id, r.start_date::date AS start_date, r.end_date::date AS end_date, r.start_time::time AS start_time, r.end_time::time AS end_time, COALESCE(r.interval,1)::int AS interval
|
||||
FROM recurrence_rules r WHERE r.owner_id=v_owner_id AND r.status='ativo' AND p_data>=r.start_date::date AND (r.end_date IS NULL OR p_data<=r.end_date::date) AND v_db_dow=ANY(r.weekdays) AND r.start_time::time<v_slot_fim AND r.end_time::time>v_slot LOOP
|
||||
v_rule_start_dow := extract(dow from v_rule.start_date)::int;
|
||||
v_first_occ := v_rule.start_date + (((v_db_dow - v_rule_start_dow + 7) % 7))::int;
|
||||
v_day_diff := (p_data - v_first_occ)::int;
|
||||
IF v_day_diff >= 0 AND v_day_diff % (7 * v_rule.interval) = 0 THEN
|
||||
v_ex_type := NULL;
|
||||
SELECT ex.type INTO v_ex_type FROM recurrence_exceptions ex WHERE ex.recurrence_id=v_rule.id AND ex.original_date=p_data LIMIT 1;
|
||||
IF v_ex_type IS NULL OR v_ex_type NOT IN ('cancel_session','patient_missed','therapist_canceled','holiday_block','reschedule_session') THEN v_ocupado := true; EXIT; END IF;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END IF;
|
||||
IF NOT v_ocupado THEN
|
||||
SELECT EXISTS (SELECT 1 FROM recurrence_exceptions ex JOIN recurrence_rules r ON r.id=ex.recurrence_id WHERE r.owner_id=v_owner_id AND r.status='ativo' AND ex.type='reschedule_session' AND ex.new_date=p_data AND COALESCE(ex.new_start_time,r.start_time)::time<v_slot_fim AND COALESCE(ex.new_end_time,r.end_time)::time>v_slot) INTO v_ocupado;
|
||||
END IF;
|
||||
IF NOT v_ocupado THEN
|
||||
-- agendador_solicitacoes FICA em public (F1b)
|
||||
SELECT EXISTS (SELECT 1 FROM public.agendador_solicitacoes sol WHERE sol.owner_id=v_owner_id AND sol.status='pendente' AND sol.data_solicitada=p_data AND sol.hora_solicitada=v_slot AND (sol.reservado_ate IS NULL OR sol.reservado_ate>v_agora)) INTO v_ocupado;
|
||||
END IF;
|
||||
hora := v_slot; disponivel := NOT v_ocupado; RETURN NEXT;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- match_patient_by_phone só pra service_role (edge)
|
||||
REVOKE ALL ON FUNCTION public.match_patient_by_phone(uuid, text) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.match_patient_by_phone(uuid, text) TO service_role;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,126 @@
|
||||
-- =============================================================================
|
||||
-- F6.2 Lote G — funções SQL puras → plpgsql + roteamento por tenant
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin.
|
||||
--
|
||||
-- SQL puro não permite set_config dinâmico do search_path (limitação 3 do
|
||||
-- blueprint) → converter pra plpgsql. Adicionam p_tenant_id + _tenant_route.
|
||||
-- RETURNS SETOF <tabela_tenant> → jsonb. get_entity_primary_phone (interno,
|
||||
-- 0 callers) herda search_path do chamador (sem SET, unqualified).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.get_financial_summary(uuid, integer, integer);
|
||||
DROP FUNCTION IF EXISTS public.get_financial_summary(uuid, uuid, integer, integer);
|
||||
CREATE FUNCTION public.get_financial_summary(p_tenant_id uuid, p_owner_id uuid, p_year integer, p_month integer)
|
||||
RETURNS TABLE(total_receitas numeric, total_despesas numeric, total_pendente numeric, saldo_liquido numeric, total_repasse numeric, count_receitas bigint, count_despesas bigint)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
COALESCE(SUM(amount) FILTER (WHERE type='receita' AND status='paid'), 0),
|
||||
COALESCE(SUM(amount) FILTER (WHERE type='despesa' AND status='paid'), 0),
|
||||
COALESCE(SUM(amount) FILTER (WHERE status IN ('pending','overdue')), 0),
|
||||
COALESCE(SUM(amount) FILTER (WHERE type='receita' AND status='paid'), 0) - COALESCE(SUM(amount) FILTER (WHERE type='despesa' AND status='paid'), 0),
|
||||
COALESCE(SUM(clinic_fee_amount) FILTER (WHERE type='receita' AND status='paid'), 0),
|
||||
COUNT(*) FILTER (WHERE type='receita' AND deleted_at IS NULL),
|
||||
COUNT(*) FILTER (WHERE type='despesa' AND deleted_at IS NULL)
|
||||
FROM financial_records
|
||||
WHERE owner_id = p_owner_id AND deleted_at IS NULL
|
||||
AND EXTRACT(YEAR FROM COALESCE(paid_at::date, due_date, created_at::date)) = p_year
|
||||
AND EXTRACT(MONTH FROM COALESCE(paid_at::date, due_date, created_at::date)) = p_month;
|
||||
END $$;
|
||||
|
||||
-- list_financial_records: RETURNS SETOF financial_records → jsonb (array)
|
||||
DROP FUNCTION IF EXISTS public.list_financial_records(uuid, integer, integer, text, text, uuid, integer, integer);
|
||||
DROP FUNCTION IF EXISTS public.list_financial_records(uuid, uuid, integer, integer, text, text, uuid, integer, integer);
|
||||
CREATE FUNCTION public.list_financial_records(p_tenant_id uuid, p_owner_id uuid, p_year integer DEFAULT NULL, p_month integer DEFAULT NULL, p_type text DEFAULT NULL, p_status text DEFAULT NULL, p_patient_id uuid DEFAULT NULL, p_limit integer DEFAULT 50, p_offset integer DEFAULT 0)
|
||||
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_result jsonb;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT COALESCE(jsonb_agg(row_json), '[]'::jsonb) INTO v_result FROM (
|
||||
SELECT to_jsonb(fr) AS row_json
|
||||
FROM financial_records fr
|
||||
WHERE fr.owner_id = p_owner_id AND fr.deleted_at IS NULL
|
||||
AND (p_type IS NULL OR fr.type::text = p_type)
|
||||
AND (p_status IS NULL OR fr.status = p_status)
|
||||
AND (p_patient_id IS NULL OR fr.patient_id = p_patient_id)
|
||||
AND (p_year IS NULL OR EXTRACT(YEAR FROM COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date)) = p_year)
|
||||
AND (p_month IS NULL OR EXTRACT(MONTH FROM COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date)) = p_month)
|
||||
ORDER BY COALESCE(fr.paid_at, fr.due_date::timestamptz, fr.created_at) DESC
|
||||
LIMIT p_limit OFFSET p_offset
|
||||
) sub;
|
||||
RETURN v_result;
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.get_patient_session_counts(uuid[]);
|
||||
DROP FUNCTION IF EXISTS public.get_patient_session_counts(uuid, uuid[]);
|
||||
CREATE FUNCTION public.get_patient_session_counts(p_tenant_id uuid, p_patient_ids uuid[])
|
||||
RETURNS TABLE(patient_id uuid, session_count integer, last_session_at timestamptz)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
RETURN QUERY
|
||||
SELECT ae.patient_id, COUNT(*)::int, MAX(ae.inicio_em)
|
||||
FROM agenda_eventos ae
|
||||
WHERE ae.patient_id = ANY(p_patient_ids)
|
||||
GROUP BY ae.patient_id;
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.get_financial_report(uuid, date, date, text);
|
||||
DROP FUNCTION IF EXISTS public.get_financial_report(uuid, uuid, date, date, text);
|
||||
CREATE FUNCTION public.get_financial_report(p_tenant_id uuid, p_owner_id uuid, p_start_date date, p_end_date date, p_group_by text DEFAULT 'month')
|
||||
RETURNS TABLE(group_key text, group_label text, total_receitas numeric, total_despesas numeric, saldo numeric, total_pendente numeric, total_overdue numeric, count_records bigint)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
RETURN QUERY
|
||||
WITH base AS (
|
||||
SELECT fr.type, fr.amount, fr.final_amount, fr.status, fr.deleted_at,
|
||||
CASE p_group_by
|
||||
WHEN 'month' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'YYYY-MM')
|
||||
WHEN 'week' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'IYYY-"W"IW')
|
||||
WHEN 'category' THEN COALESCE(fr.category_id::text, fr.category, 'sem_categoria')
|
||||
WHEN 'patient' THEN COALESCE(fr.patient_id::text, 'sem_paciente')
|
||||
ELSE NULL END AS gkey,
|
||||
CASE p_group_by
|
||||
WHEN 'month' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'YYYY-MM')
|
||||
WHEN 'week' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'IYYY-"W"IW')
|
||||
WHEN 'category' THEN COALESCE(fc.name, fr.category, 'Sem categoria')
|
||||
WHEN 'patient' THEN COALESCE(p.nome_completo, fr.patient_id::text, 'Sem paciente')
|
||||
ELSE NULL END AS glabel
|
||||
FROM financial_records fr
|
||||
LEFT JOIN financial_categories fc ON fc.id = fr.category_id
|
||||
LEFT JOIN patients p ON p.id = fr.patient_id
|
||||
WHERE fr.owner_id = p_owner_id AND fr.deleted_at IS NULL
|
||||
AND COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date) BETWEEN p_start_date AND p_end_date
|
||||
)
|
||||
SELECT gkey, glabel,
|
||||
COALESCE(SUM(final_amount) FILTER (WHERE type='receita' AND status='paid'),0),
|
||||
COALESCE(SUM(final_amount) FILTER (WHERE type='despesa' AND status='paid'),0),
|
||||
COALESCE(SUM(final_amount) FILTER (WHERE type='receita' AND status='paid'),0) - COALESCE(SUM(final_amount) FILTER (WHERE type='despesa' AND status='paid'),0),
|
||||
COALESCE(SUM(final_amount) FILTER (WHERE status='pending'),0),
|
||||
COALESCE(SUM(final_amount) FILTER (WHERE status='overdue'),0),
|
||||
COUNT(*)
|
||||
FROM base WHERE gkey IS NOT NULL GROUP BY gkey, glabel ORDER BY gkey ASC;
|
||||
END $$;
|
||||
|
||||
-- get_entity_primary_phone: interno (0 callers). Sem SET search_path → herda do
|
||||
-- chamador (que roteia pro schema). Unqualified. Mantém assinatura.
|
||||
CREATE OR REPLACE FUNCTION public.get_entity_primary_phone(p_entity_type text, p_entity_id uuid)
|
||||
RETURNS text LANGUAGE sql STABLE SECURITY DEFINER
|
||||
AS $$
|
||||
SELECT number FROM contact_phones
|
||||
WHERE entity_type = p_entity_type AND entity_id = p_entity_id
|
||||
ORDER BY is_primary DESC, position ASC, created_at ASC
|
||||
LIMIT 1;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,106 @@
|
||||
-- =============================================================================
|
||||
-- F6.2 (wiring) — tenants NOVOS nascem com todos os triggers de negócio
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin.
|
||||
--
|
||||
-- Até aqui os 9 schemas existentes ganharam os triggers via backfills (Lotes
|
||||
-- A/B/C). Um tenant NOVO (clone_tenant_template) só ganhava channel-routing +
|
||||
-- RLS. Este wiring:
|
||||
-- 1. attach_agnostic_triggers → SELF-CONTAINED (dirigido por colunas, não lê
|
||||
-- public; sobrevive ao DROP da F6.3).
|
||||
-- 2. trigger AFTER INSERT em tenant_schemas dispara os 3 attach (agnostic +
|
||||
-- schema_aware + notif) pro schema novo — clone_tenant_template não precisa
|
||||
-- ser tocado (ele insere em tenant_schemas).
|
||||
-- 3. provision_account_tenant: clone ANTES do seed (seed é no-op se o schema
|
||||
-- não existe; precisa do schema criado primeiro).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1) attach_agnostic_triggers self-contained ---------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.attach_agnostic_triggers(p_schema text)
|
||||
RETURNS int LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE r record; v_count int := 0;
|
||||
BEGIN
|
||||
IF p_schema NOT LIKE 'tenant\_%' THEN RAISE EXCEPTION 'schema inválido %', p_schema; END IF;
|
||||
-- set_updated_at em toda tabela do schema que tem coluna updated_at
|
||||
FOR r IN
|
||||
SELECT c.relname AS tab
|
||||
FROM pg_class c JOIN pg_attribute a ON a.attrelid = c.oid
|
||||
WHERE c.relnamespace = p_schema::regnamespace AND c.relkind = 'r'
|
||||
AND a.attname = 'updated_at' AND NOT a.attisdropped AND c.relname NOT LIKE '\_%'
|
||||
LOOP
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS set_updated_at ON %I.%I', p_schema, r.tab);
|
||||
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %I.%I FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()', p_schema, r.tab);
|
||||
v_count := v_count + 1;
|
||||
END LOOP;
|
||||
-- prevent_* em patient_groups
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = p_schema AND table_name = 'patient_groups') THEN
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS prevent_promoting_to_system ON %I.patient_groups', p_schema);
|
||||
EXECUTE format('CREATE TRIGGER prevent_promoting_to_system BEFORE UPDATE ON %I.patient_groups FOR EACH ROW EXECUTE FUNCTION public.prevent_promoting_to_system()', p_schema);
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS prevent_system_group_changes ON %I.patient_groups', p_schema);
|
||||
EXECUTE format('CREATE TRIGGER prevent_system_group_changes BEFORE UPDATE OR DELETE ON %I.patient_groups FOR EACH ROW EXECUTE FUNCTION public.prevent_system_group_changes()', p_schema);
|
||||
v_count := v_count + 2;
|
||||
END IF;
|
||||
RETURN v_count;
|
||||
END $$;
|
||||
|
||||
-- 2) trigger de wiring em tenant_schemas -------------------------------------
|
||||
GRANT EXECUTE ON FUNCTION public.attach_agnostic_triggers(text) TO postgres, service_role;
|
||||
GRANT EXECUTE ON FUNCTION public.attach_schema_aware_triggers(text) TO postgres, service_role;
|
||||
GRANT EXECUTE ON FUNCTION public.attach_notif_triggers(text) TO postgres, service_role;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.trg_attach_business_triggers()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM public.attach_agnostic_triggers(NEW.schema_name);
|
||||
PERFORM public.attach_schema_aware_triggers(NEW.schema_name);
|
||||
PERFORM public.attach_notif_triggers(NEW.schema_name);
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
ALTER FUNCTION public.trg_attach_business_triggers() OWNER TO supabase_admin;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_tenant_schemas_attach ON public.tenant_schemas;
|
||||
CREATE TRIGGER trg_tenant_schemas_attach
|
||||
AFTER INSERT ON public.tenant_schemas
|
||||
FOR EACH ROW EXECUTE FUNCTION public.trg_attach_business_triggers();
|
||||
|
||||
-- 3) provision_account_tenant: clone ANTES do seed ---------------------------
|
||||
CREATE OR REPLACE FUNCTION public.provision_account_tenant(p_user_id uuid, p_kind text, p_name text DEFAULT NULL::text)
|
||||
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tenant_id uuid;
|
||||
v_account_type text;
|
||||
v_name text;
|
||||
BEGIN
|
||||
IF p_kind NOT IN ('therapist', 'clinic_coworking', 'clinic_reception', 'clinic_full') THEN
|
||||
RAISE EXCEPTION 'kind inválido: "%". Use: therapist, clinic_coworking, clinic_reception, clinic_full.', p_kind USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
v_account_type := CASE WHEN p_kind = 'therapist' THEN 'therapist' ELSE 'clinic' END;
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM public.tenant_members tm JOIN public.tenants t ON t.id = tm.tenant_id
|
||||
WHERE tm.user_id = p_user_id AND tm.role = 'tenant_admin' AND tm.status = 'active' AND t.kind = p_kind
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Usuário já possui um tenant do tipo "%".', p_kind USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
v_name := COALESCE(NULLIF(TRIM(p_name), ''),
|
||||
(SELECT COALESCE(NULLIF(TRIM(pr.full_name), ''), SPLIT_PART(au.email, '@', 1))
|
||||
FROM public.profiles pr JOIN auth.users au ON au.id = pr.id WHERE pr.id = p_user_id),
|
||||
'Conta');
|
||||
|
||||
INSERT INTO public.tenants (name, kind, created_at) VALUES (v_name, p_kind, now()) RETURNING id INTO v_tenant_id;
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES (v_tenant_id, p_user_id, 'tenant_admin', 'active', now());
|
||||
UPDATE public.profiles SET account_type = v_account_type WHERE id = p_user_id;
|
||||
|
||||
-- F6 wiring: clone PRIMEIRO (cria o schema), seed DEPOIS (escreve no schema)
|
||||
PERFORM public.clone_tenant_template(v_tenant_id);
|
||||
PERFORM public.seed_determined_commitments(v_tenant_id);
|
||||
|
||||
RETURN v_tenant_id;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,99 @@
|
||||
# F6.3 — Rollback da migração schema-per-tenant
|
||||
|
||||
> Como voltar atrás. Lê isto ANTES de aplicar a F6.3 (o DROP). A regra de ouro:
|
||||
> **enquanto a F6.3 NÃO foi aplicada, o rollback é trivial e sem perda.**
|
||||
|
||||
## Princípio: a branch é a rede de segurança
|
||||
|
||||
A migração inteira (F3→F6.4) vive na branch `feat/schema-per-tenant`. A `main`
|
||||
**nunca mudou** o modelo antigo — só recebeu F0/F1/F2, que são **aditivas**
|
||||
(criam `tenants.slug`, o schema `_tenant_template`, helpers e o gatilho de
|
||||
provisionamento; não removem nem alteram nada que o app antigo usa). Ou seja:
|
||||
**`git checkout main` te devolve o app funcionando no modelo public**, desde que
|
||||
o banco também volte (ver abaixo).
|
||||
|
||||
O único passo IRREVERSÍVEL por si só é o **DROP** (F6.3). Tudo antes dele é
|
||||
reversível porque os dados continuam **espelhados em public** (a F6.1 COPIA, não
|
||||
move). Por isso o checkpoint é parar ANTES do DROP.
|
||||
|
||||
---
|
||||
|
||||
## Cenário 1 — ANTES de aplicar a F6.3 (estado atual) — rollback trivial
|
||||
|
||||
Nada destrutivo foi feito. Public tem todas as tabelas e dados originais. Para
|
||||
abandonar a migração:
|
||||
|
||||
```bash
|
||||
# 1. código volta pro modelo antigo
|
||||
git checkout main
|
||||
|
||||
# 2. banco: derruba os schemas tenant + artefatos da migração
|
||||
docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
|
||||
psql -U supabase_admin -h 127.0.0.1 -d postgres <<'SQL'
|
||||
DO $$ DECLARE r record; BEGIN
|
||||
FOR r IN SELECT nspname FROM pg_namespace WHERE nspname LIKE 'tenant\_%' OR nspname='_tenant_template' LOOP
|
||||
EXECUTE format('DROP SCHEMA %I CASCADE', r.nspname);
|
||||
END LOOP;
|
||||
END $$;
|
||||
-- limpa o registro + config do PostgREST
|
||||
DELETE FROM public.tenant_schemas;
|
||||
ALTER ROLE authenticator SET pgrst.db_schemas = 'public, graphql_public';
|
||||
NOTIFY pgrst, 'reload config';
|
||||
SQL
|
||||
```
|
||||
|
||||
⚠️ As FUNÇÕES em public foram reescritas (F6.2) na branch, mas essas mudanças
|
||||
**não foram aplicadas via migration em `main`** — elas vieram dos arquivos
|
||||
`manual/f6_2*.sql` aplicados como supabase_admin no banco LOCAL. Se você quer o
|
||||
banco local 100% igual ao `main`, restaure as funções originais do backup:
|
||||
|
||||
```bash
|
||||
# restaura o estado public pré-migração (funções, triggers, tudo)
|
||||
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres \
|
||||
< database-novo/backups/pre-F6/public.sql # ou o backup mais antigo (pré-F1)
|
||||
```
|
||||
|
||||
Como na prática o banco é LOCAL e descartável, o caminho mais limpo do Cenário 1
|
||||
é: **`git checkout main` + `node db.cjs reset` (reinstala schema+seeds do zero)**.
|
||||
|
||||
---
|
||||
|
||||
## Cenário 2 — DEPOIS de aplicar a F6.3 (DROP já feito) — recuperável, com cuidado
|
||||
|
||||
O DROP removeu as 78 tabelas tenant de public. Os dados VIVOS estão nos schemas
|
||||
`tenant_<slug>`. Há duas situações:
|
||||
|
||||
### 2a) Rollback rápido (logo após o DROP, app quase não rodou nos schemas)
|
||||
Os dados em public estavam atualizados até o **backup pré-F6.3**. Se quase nada
|
||||
foi escrito nos schemas depois do DROP, restaure public do backup e volte o código:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres \
|
||||
< database-novo/backups/pre-F6.3/public.sql # backup TIRADO antes do DROP
|
||||
# + derrubar schemas tenant (bloco SQL do Cenário 1)
|
||||
```
|
||||
PERDE: qualquer escrita feita NOS SCHEMAS entre o DROP e o rollback.
|
||||
|
||||
### 2b) Rollback com dados atualizados (app rodou e acumulou dados nos schemas)
|
||||
Aí os schemas têm a verdade mais nova. Precisa de uma **migração reversa**
|
||||
(schema → public, o inverso da F6.1), re-adicionando `tenant_id`. Roteiro:
|
||||
1. Restaure a ESTRUTURA das 78 tabelas em public (do backup pré-F6.3, sem os
|
||||
dados, ou recriando via o schema dump).
|
||||
2. Para cada tenant, `INSERT INTO public.<tab> (cols + tenant_id) SELECT cols,
|
||||
'<tenant_id>' FROM tenant_<slug>.<tab>` — o inverso exato do
|
||||
`manual/f6_1_migrate_data.supabase_admin.sql` (trocar origem/destino e
|
||||
RE-ADICIONAR a coluna tenant_id com o id do tenant do schema).
|
||||
3. Resetar sequences, recriar as 9 views + 2 FKs, voltar o código (`git checkout main`).
|
||||
|
||||
Esse caminho é trabalhoso — por isso a recomendação forte: **só aplique a F6.3
|
||||
depois de validar o app**, e mantenha o backup pré-F6.3. O DROP é a única coisa
|
||||
que transforma "trivial" em "trabalhoso".
|
||||
|
||||
---
|
||||
|
||||
## Checklist antes de aplicar a F6.3 (resumo)
|
||||
- [ ] App testado no browser (fluxos autenticados sem erro PGRST/4xx).
|
||||
- [ ] Backup FRESCO: `pg_dump --schema=public > backups/pre-F6.3/public.sql`.
|
||||
- [ ] Branch commitada (rollback de código = `git checkout main`).
|
||||
- [ ] Ciente: pós-DROP, public some; a verdade passa a ser os schemas.
|
||||
@@ -0,0 +1,107 @@
|
||||
-- =============================================================================
|
||||
-- F6.3 — DROP das tabelas tenant em public (PONTO DE NÃO-RETORNO)
|
||||
--
|
||||
-- 🛑 NÃO APLICAR AINDA. Este arquivo está PREPARADO para revisão. Aplicar só
|
||||
-- depois de:
|
||||
-- (a) Leonardo testar o app no branch e validar os fluxos;
|
||||
-- (b) os ITENS EM ABERTO abaixo resolvidos;
|
||||
-- (c) backup fresco confirmado.
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin (DROP CASCADE; tabelas documents/document_* são
|
||||
-- owned por supabase_admin).
|
||||
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
|
||||
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
|
||||
-- < database-novo/manual/f6_3_drop_public_tenant_tables.supabase_admin.sql
|
||||
--
|
||||
-- BACKUP ANTES (obrigatório):
|
||||
-- docker exec supabase_db_agenciapsi-primesakai pg_dump -U postgres -d postgres \
|
||||
-- --schema=public --no-owner --no-acl > database-novo/backups/pre-F6.3/public.sql
|
||||
--
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- ✅ ITENS EM ABERTO — RESOLVIDOS (F6.4, commit dc2363b):
|
||||
-- 1. v_twilio_whatsapp_overview / getAllChannels → RPC saas_list_all_whatsapp_
|
||||
-- channels (fan-out). ✓
|
||||
-- 2. SaasFeriadosPage / SaasNotificationTemplatesPage / SaasWhatsappPage →
|
||||
-- RPCs saas_*_default + supabase.schema(tenant_<slug>). ✓
|
||||
-- 3. notification-webhook (Meta) → confirmado: usa tdb/schema (F4). ✓
|
||||
-- 4. AgendadorPublicoPage → RPCs roteadas. ✓
|
||||
-- Varredura FE confirma ZERO supabase.from('<tabela_tenant>') público restante.
|
||||
-- PRÉ-REQUISITO FINAL: Leonardo testar o app + backup fresco.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- =============================================================================
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
BEGIN;
|
||||
|
||||
-- ── 0) PRÉ-FLIGHT: aborta se algo essencial não bate ────────────────────────
|
||||
DO $$
|
||||
DECLARE v_tenants int; v_schemas int; v_drop int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO v_tenants FROM public.tenants;
|
||||
SELECT count(*) INTO v_schemas FROM public.tenant_schemas;
|
||||
IF v_tenants <> v_schemas THEN
|
||||
RAISE EXCEPTION 'F6.3 ABORT: % tenants mas % schemas provisionados — nem todos migrados', v_tenants, v_schemas;
|
||||
END IF;
|
||||
SELECT count(*) INTO v_drop FROM information_schema.tables
|
||||
WHERE table_schema='_tenant_template' AND table_type='BASE TABLE' AND table_name NOT LIKE '\_%';
|
||||
IF v_drop <> 78 THEN
|
||||
RAISE EXCEPTION 'F6.3 ABORT: _tenant_template tem % tabelas, esperava 78', v_drop;
|
||||
END IF;
|
||||
-- guarda: nenhuma das 6 anon-facing pode estar na lista de drop
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='_tenant_template'
|
||||
AND table_name IN ('patient_intake_requests','patient_invites','patient_invite_attempts',
|
||||
'document_share_links','agendador_configuracoes','agendador_solicitacoes')) THEN
|
||||
RAISE EXCEPTION 'F6.3 ABORT: tabela anon-facing presente no template (não deveria sair de public)';
|
||||
END IF;
|
||||
RAISE NOTICE 'F6.3 pré-flight OK: % tenants = % schemas, 78 tabelas a dropar', v_tenants, v_schemas;
|
||||
END $$;
|
||||
|
||||
-- ── 1) FKs de tabelas que FICAM → tabelas que SAEM: viram coluna solta ──────
|
||||
-- (validação fica no app/RPC via token; alvo vai pro schema do tenant)
|
||||
ALTER TABLE public.document_share_links DROP CONSTRAINT IF EXISTS document_share_links_documento_id_fkey;
|
||||
ALTER TABLE public.whatsapp_credits_transactions DROP CONSTRAINT IF EXISTS whatsapp_credits_transactions_conversation_message_id_fkey;
|
||||
|
||||
-- ── 2) Views public que referenciam tabelas tenant ──────────────────────────
|
||||
-- As 6 do template são recriadas POR SCHEMA (F1). v_patient_engajamento e
|
||||
-- v_patients_risco são dead code (0 uso no FE). v_twilio_whatsapp_overview:
|
||||
-- ⚠️ ver ITEM EM ABERTO #1 — só dropar após reescrever getAllChannels.
|
||||
DROP VIEW IF EXISTS public.audit_log_unified CASCADE;
|
||||
DROP VIEW IF EXISTS public.conversation_threads CASCADE;
|
||||
DROP VIEW IF EXISTS public.v_cashflow_projection CASCADE;
|
||||
DROP VIEW IF EXISTS public.v_commitment_totals CASCADE;
|
||||
DROP VIEW IF EXISTS public.v_patient_groups_with_counts CASCADE;
|
||||
DROP VIEW IF EXISTS public.v_tag_patient_counts CASCADE;
|
||||
DROP VIEW IF EXISTS public.v_patient_engajamento CASCADE; -- dead code
|
||||
DROP VIEW IF EXISTS public.v_patients_risco CASCADE; -- dead code
|
||||
DROP VIEW IF EXISTS public.v_twilio_whatsapp_overview CASCADE; -- ⚠️ item #1
|
||||
|
||||
-- ── 3) DROP das 78 tabelas tenant em public (derivado de _tenant_template) ──
|
||||
-- CASCADE leva junto triggers das tabelas + FKs intra-tenant. Dados já estão
|
||||
-- nos schemas (F6.1) — estas são as cópias velhas.
|
||||
DO $$
|
||||
DECLARE r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema='_tenant_template' AND table_type='BASE TABLE' AND table_name NOT LIKE '\_%'
|
||||
ORDER BY table_name
|
||||
LOOP
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=r.table_name) THEN
|
||||
EXECUTE format('DROP TABLE public.%I CASCADE', r.table_name);
|
||||
RAISE NOTICE 'F6.3 dropou public.%', r.table_name;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ── 4) Limpeza de funções obsoletas pós-drop ────────────────────────────────
|
||||
-- financial_records_inject_tenant só fazia sentido em public.financial_records
|
||||
-- (já dropada); não está anexado em nenhum schema.
|
||||
DROP FUNCTION IF EXISTS public.financial_records_inject_tenant() CASCADE;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ── 5) PÓS-DROP: verificações manuais (rodar separado) ───────────────────────
|
||||
-- SELECT 'tabelas tenant restantes em public: ' || count(*) FROM information_schema.tables
|
||||
-- WHERE table_schema='public' AND table_name IN (SELECT table_name FROM _tenant_template.information... );
|
||||
-- NOTIFY pgrst, 'reload schema';
|
||||
-- Conferir app: nenhuma query 4xx/PGRST200 no console.
|
||||
@@ -0,0 +1,181 @@
|
||||
-- =============================================================================
|
||||
-- F6.4 — RPCs SaaS-admin: defaults do sistema (template + fan-out) e views
|
||||
-- cross-tenant (fan-out). Destrava o F6.3 (páginas SaaS deixam de ler
|
||||
-- public.<tabela_tenant>).
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin.
|
||||
--
|
||||
-- Defaults (feriados nacionais, notification_templates is_default): editados
|
||||
-- pelo SaaS no _tenant_template (fonte da verdade, propaga p/ tenants NOVOS no
|
||||
-- clone) E fan-out pros schemas EXISTENTES. Só toca cópias-default (owner_id
|
||||
-- NULL / is_default), preserva overrides do tenant (owner_id próprio).
|
||||
-- Todas gated por is_saas_admin().
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public._assert_saas_admin()
|
||||
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$ BEGIN
|
||||
IF NOT public.is_saas_admin() THEN RAISE EXCEPTION 'Apenas SaaS admin' USING ERRCODE='42501'; END IF;
|
||||
END $$;
|
||||
|
||||
-- ── FERIADOS (defaults nacionais) ───────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.saas_list_default_feriados(p_ano integer)
|
||||
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v jsonb;
|
||||
BEGIN
|
||||
PERFORM public._assert_saas_admin();
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object('id',id,'data',data,'nome',nome,'tipo',tipo,'bloqueia_sessoes',bloqueia_sessoes) ORDER BY data),'[]'::jsonb)
|
||||
INTO v FROM _tenant_template.feriados WHERE EXTRACT(YEAR FROM data) = p_ano;
|
||||
RETURN v;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.saas_add_default_feriado(p_data date, p_nome text, p_tipo text DEFAULT 'municipal')
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record; v_owner uuid := auth.uid();
|
||||
BEGIN
|
||||
PERFORM public._assert_saas_admin();
|
||||
INSERT INTO _tenant_template.feriados (owner_id, tipo, nome, data, bloqueia_sessoes)
|
||||
VALUES (v_owner, p_tipo, p_nome, p_data, false) ON CONFLICT (data, nome) DO NOTHING;
|
||||
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
EXECUTE format('INSERT INTO %I.feriados (owner_id, tipo, nome, data, bloqueia_sessoes) VALUES ($1,$2,$3,$4,false) ON CONFLICT (data, nome) DO NOTHING', t.schema_name)
|
||||
USING v_owner, p_tipo, p_nome, p_data;
|
||||
END LOOP;
|
||||
RETURN jsonb_build_object('data', p_data, 'nome', p_nome, 'tipo', p_tipo);
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.saas_remove_default_feriado(p_data date, p_nome text)
|
||||
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record; v_n int := 0;
|
||||
BEGIN
|
||||
PERFORM public._assert_saas_admin();
|
||||
DELETE FROM _tenant_template.feriados WHERE data = p_data AND nome = p_nome;
|
||||
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
EXECUTE format('DELETE FROM %I.feriados WHERE data = $1 AND nome = $2', t.schema_name) USING p_data, p_nome;
|
||||
v_n := v_n + 1;
|
||||
END LOOP;
|
||||
RETURN v_n;
|
||||
END $$;
|
||||
|
||||
-- ── NOTIFICATION_TEMPLATES (defaults) ───────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.saas_list_default_notif_templates()
|
||||
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v jsonb;
|
||||
BEGIN
|
||||
PERFORM public._assert_saas_admin();
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(nt) ORDER BY nt.domain, nt.event_type),'[]'::jsonb)
|
||||
INTO v FROM _tenant_template.notification_templates nt WHERE nt.is_default = true AND nt.deleted_at IS NULL;
|
||||
RETURN v;
|
||||
END $$;
|
||||
|
||||
-- upsert por key (defaults têm owner_id NULL). Cria/atualiza no template + schemas.
|
||||
CREATE OR REPLACE FUNCTION public.saas_upsert_default_notif_template(p_payload jsonb)
|
||||
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record; v_key text := p_payload->>'key'; v_exists boolean;
|
||||
v_domain text := p_payload->>'domain'; v_channel text := p_payload->>'channel';
|
||||
v_event text := p_payload->>'event_type'; v_body text := p_payload->>'body_text';
|
||||
v_vars jsonb := COALESCE(p_payload->'variables','[]'::jsonb);
|
||||
v_active boolean := COALESCE((p_payload->>'is_active')::boolean, true);
|
||||
BEGIN
|
||||
PERFORM public._assert_saas_admin();
|
||||
IF v_key IS NULL THEN RAISE EXCEPTION 'key obrigatório'; END IF;
|
||||
-- template
|
||||
SELECT EXISTS(SELECT 1 FROM _tenant_template.notification_templates WHERE key=v_key AND owner_id IS NULL AND is_default=true) INTO v_exists;
|
||||
IF v_exists THEN
|
||||
UPDATE _tenant_template.notification_templates SET body_text=v_body, domain=v_domain, channel=v_channel,
|
||||
event_type=v_event, variables=v_vars, is_active=v_active WHERE key=v_key AND owner_id IS NULL AND is_default=true;
|
||||
ELSE
|
||||
INSERT INTO _tenant_template.notification_templates (owner_id, key, domain, channel, event_type, body_text, variables, is_default, is_active)
|
||||
VALUES (NULL, v_key, v_domain, v_channel, v_event, v_body, v_vars, true, v_active);
|
||||
END IF;
|
||||
-- fan-out schemas (só a cópia-default; preserva overrides do tenant owner_id<>NULL)
|
||||
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
EXECUTE format(
|
||||
'INSERT INTO %I.notification_templates (owner_id, key, domain, channel, event_type, body_text, variables, is_default, is_active) '
|
||||
|| 'VALUES (NULL,$1,$2,$3,$4,$5,$6,true,$7) '
|
||||
|| 'ON CONFLICT (owner_id, key, deleted_at) DO UPDATE SET body_text=EXCLUDED.body_text, domain=EXCLUDED.domain, '
|
||||
|| 'channel=EXCLUDED.channel, event_type=EXCLUDED.event_type, variables=EXCLUDED.variables, is_active=EXCLUDED.is_active',
|
||||
t.schema_name) USING v_key, v_domain, v_channel, v_event, v_body, v_vars, v_active;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.saas_set_default_notif_template_active(p_key text, p_active boolean)
|
||||
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record;
|
||||
BEGIN
|
||||
PERFORM public._assert_saas_admin();
|
||||
UPDATE _tenant_template.notification_templates SET is_active=p_active WHERE key=p_key AND owner_id IS NULL AND is_default=true;
|
||||
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
EXECUTE format('UPDATE %I.notification_templates SET is_active=$1 WHERE key=$2 AND owner_id IS NULL AND is_default=true', t.schema_name) USING p_active, p_key;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.saas_delete_default_notif_template(p_key text)
|
||||
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record;
|
||||
BEGIN
|
||||
PERFORM public._assert_saas_admin();
|
||||
UPDATE _tenant_template.notification_templates SET deleted_at=now() WHERE key=p_key AND owner_id IS NULL AND is_default=true;
|
||||
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
EXECUTE format('UPDATE %I.notification_templates SET deleted_at=now() WHERE key=$1 AND owner_id IS NULL AND is_default=true', t.schema_name) USING p_key;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- quantos tenants têm override (tenant_id<>NULL no modelo antigo = owner_id<>NULL aqui) por key
|
||||
CREATE OR REPLACE FUNCTION public.saas_count_notif_template_overrides(p_key text)
|
||||
RETURNS integer LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record; v_n int := 0; v_has int;
|
||||
BEGIN
|
||||
PERFORM public._assert_saas_admin();
|
||||
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
EXECUTE format('SELECT count(*) FROM %I.notification_templates WHERE key=$1 AND owner_id IS NOT NULL AND is_active=true AND deleted_at IS NULL', t.schema_name) INTO v_has USING p_key;
|
||||
v_n := v_n + v_has;
|
||||
END LOOP;
|
||||
RETURN v_n;
|
||||
END $$;
|
||||
|
||||
-- ── WHATSAPP admin (cross-tenant) — substitui v_twilio_whatsapp_overview ─────
|
||||
CREATE OR REPLACE FUNCTION public.saas_list_all_whatsapp_channels()
|
||||
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record; v_rows jsonb := '[]'::jsonb; v_part jsonb;
|
||||
BEGIN
|
||||
PERFORM public._assert_saas_admin();
|
||||
FOR t IN SELECT ts.tenant_id, ts.schema_name, tn.name AS tenant_name
|
||||
FROM public.tenant_schemas ts JOIN public.tenants tn ON tn.id = ts.tenant_id LOOP
|
||||
EXECUTE format(
|
||||
'SELECT COALESCE(jsonb_agg(jsonb_build_object('
|
||||
|| '''id'',nc.id, ''tenant_id'',$1::uuid, ''tenant_name'',$2::text, ''owner_id'',nc.owner_id,'
|
||||
|| '''provider'',nc.provider, ''is_active'',nc.is_active, ''connection_status'',nc.connection_status,'
|
||||
|| '''sender_address'',nc.sender_address, ''twilio_phone_number'',nc.twilio_phone_number,'
|
||||
|| '''last_health_check'',nc.last_health_check,'
|
||||
|| '''open_incident'',(SELECT i.kind FROM %1$I.whatsapp_connection_incidents i WHERE i.channel_id=nc.id AND i.resolved_at IS NULL LIMIT 1)'
|
||||
|| ')),''[]''::jsonb) FROM %1$I.notification_channels nc WHERE nc.channel=''whatsapp'' AND nc.deleted_at IS NULL',
|
||||
t.schema_name) INTO v_part USING t.tenant_id, t.tenant_name;
|
||||
v_rows := v_rows || v_part;
|
||||
END LOOP;
|
||||
RETURN v_rows;
|
||||
END $$;
|
||||
|
||||
-- grants: gated por is_saas_admin internamente, mas exposto a authenticated
|
||||
DO $g$ DECLARE fn text; BEGIN
|
||||
FOREACH fn IN ARRAY ARRAY[
|
||||
'saas_list_default_feriados(integer)','saas_add_default_feriado(date,text,text)','saas_remove_default_feriado(date,text)',
|
||||
'saas_list_default_notif_templates()','saas_upsert_default_notif_template(jsonb)',
|
||||
'saas_set_default_notif_template_active(text,boolean)','saas_delete_default_notif_template(text)',
|
||||
'saas_count_notif_template_overrides(text)','saas_list_all_whatsapp_channels()'
|
||||
] LOOP
|
||||
EXECUTE format('GRANT EXECUTE ON FUNCTION public.%s TO authenticated', fn);
|
||||
END LOOP;
|
||||
END $g$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,147 @@
|
||||
-- =============================================================================
|
||||
-- Freemium F1 — Enforcement de limite de plano (pacientes), schema-per-tenant
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin (anexa triggers em tabelas tenant + a função de
|
||||
-- wiring trg_attach_business_triggers é owned por supabase_admin).
|
||||
--
|
||||
-- Trigger genérico BEFORE INSERT em <schema>.patients que:
|
||||
-- 1. resolve o tenant pelo NOME DO SCHEMA (TG_TABLE_SCHEMA → tenant_schemas);
|
||||
-- 2. resolve o plano ATIVO do tenant em runtime (clínica via tenant_id;
|
||||
-- pessoal/terapeuta via owner user_id — as 6 subs pessoais têm tenant_id NULL);
|
||||
-- 3. lê o limite max_patients de plan_features.limits EM RUNTIME (mudar o número
|
||||
-- no painel passa a valer sem deploy);
|
||||
-- 4. conta pacientes vivos (status <> 'Arquivado') e dá RAISE parseável
|
||||
-- 'PLAN_LIMIT_REACHED|patients|<limite>' quando já atingiu.
|
||||
--
|
||||
-- Sem plano ativo OU sem limite definido (planos PRO) ⇒ não bloqueia.
|
||||
-- Idempotente (CREATE OR REPLACE + DROP TRIGGER IF EXISTS). Tudo em public
|
||||
-- (subscriptions/plan_features/tenant_schemas são globais) ⇒ sobrevive ao DROP F6.3.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1) Resolve o plano ativo de um tenant (clínica OU pessoal) ------------------
|
||||
CREATE OR REPLACE FUNCTION public.tenant_active_plan_id(p_tenant_id uuid)
|
||||
RETURNS uuid
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
SELECT COALESCE(
|
||||
-- clínica: subscription chaveada por tenant_id
|
||||
(SELECT vas.plan_id
|
||||
FROM public.v_tenant_active_subscription vas
|
||||
WHERE vas.tenant_id = p_tenant_id),
|
||||
-- pessoal: subscription chaveada pelo owner (user_id), tenant_id NULL
|
||||
(SELECT s.plan_id
|
||||
FROM public.subscriptions s
|
||||
JOIN public.tenant_members tm
|
||||
ON tm.user_id = s.user_id
|
||||
AND tm.tenant_id = p_tenant_id
|
||||
AND tm.status = 'active'
|
||||
WHERE s.status = 'active'
|
||||
AND s.tenant_id IS NULL
|
||||
AND (s.current_period_end IS NULL OR s.current_period_end > now())
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT 1)
|
||||
);
|
||||
$$;
|
||||
ALTER FUNCTION public.tenant_active_plan_id(uuid) OWNER TO supabase_admin;
|
||||
|
||||
-- 2) Lê um limite numérico do plano (busca a chave em qualquer feature) -------
|
||||
-- Ex.: clinic_free guarda max_patients sob clinic_calendar; therapist_free
|
||||
-- sob patients.manage. Retorna o MIN (mais restritivo) se houver mais de um.
|
||||
CREATE OR REPLACE FUNCTION public.plan_feature_limit(p_plan_id uuid, p_limit_key text)
|
||||
RETURNS int
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
SELECT min((pf.limits->>p_limit_key)::int)
|
||||
FROM public.plan_features pf
|
||||
WHERE pf.plan_id = p_plan_id
|
||||
AND pf.enabled
|
||||
AND pf.limits ? p_limit_key
|
||||
AND (pf.limits->>p_limit_key) ~ '^[0-9]+$';
|
||||
$$;
|
||||
ALTER FUNCTION public.plan_feature_limit(uuid, text) OWNER TO supabase_admin;
|
||||
|
||||
-- 3) Trigger function de enforcement -----------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.enforce_patient_plan_limit()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tenant uuid;
|
||||
v_plan uuid;
|
||||
v_limit int;
|
||||
v_count int;
|
||||
BEGIN
|
||||
SELECT tenant_id INTO v_tenant
|
||||
FROM public.tenant_schemas
|
||||
WHERE schema_name = TG_TABLE_SCHEMA;
|
||||
IF v_tenant IS NULL THEN RETURN NEW; END IF; -- schema não-tenant: ignora
|
||||
|
||||
v_plan := public.tenant_active_plan_id(v_tenant);
|
||||
IF v_plan IS NULL THEN RETURN NEW; END IF; -- sem plano ativo: não bloqueia
|
||||
|
||||
v_limit := public.plan_feature_limit(v_plan, 'max_patients');
|
||||
IF v_limit IS NULL THEN RETURN NEW; END IF; -- plano sem limite (PRO): ilimitado
|
||||
|
||||
EXECUTE format(
|
||||
'SELECT count(*) FROM %I.patients WHERE status IS DISTINCT FROM %L',
|
||||
TG_TABLE_SCHEMA, 'Arquivado'
|
||||
) INTO v_count;
|
||||
|
||||
IF v_count >= v_limit THEN
|
||||
RAISE EXCEPTION 'PLAN_LIMIT_REACHED|patients|%', v_limit USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
ALTER FUNCTION public.enforce_patient_plan_limit() OWNER TO supabase_admin;
|
||||
|
||||
-- 4) Attach helper (pendura o trigger no patients de um schema) ---------------
|
||||
CREATE OR REPLACE FUNCTION public.attach_plan_limit_triggers(p_schema text)
|
||||
RETURNS int
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF p_schema NOT LIKE 'tenant\_%' THEN
|
||||
RAISE EXCEPTION 'schema inválido %', p_schema;
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = p_schema AND table_name = 'patients'
|
||||
) THEN
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS enforce_patient_plan_limit ON %I.patients', p_schema);
|
||||
EXECUTE format(
|
||||
'CREATE TRIGGER enforce_patient_plan_limit BEFORE INSERT ON %I.patients '
|
||||
'FOR EACH ROW EXECUTE FUNCTION public.enforce_patient_plan_limit()', p_schema);
|
||||
RETURN 1;
|
||||
END IF;
|
||||
RETURN 0;
|
||||
END $$;
|
||||
ALTER FUNCTION public.attach_plan_limit_triggers(text) OWNER TO supabase_admin;
|
||||
GRANT EXECUTE ON FUNCTION public.attach_plan_limit_triggers(text) TO postgres, service_role;
|
||||
|
||||
-- 5) Wiring: tenants NOVOS ganham o trigger de limite no clone ----------------
|
||||
CREATE OR REPLACE FUNCTION public.trg_attach_business_triggers()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM public.attach_agnostic_triggers(NEW.schema_name);
|
||||
PERFORM public.attach_schema_aware_triggers(NEW.schema_name);
|
||||
PERFORM public.attach_notif_triggers(NEW.schema_name);
|
||||
PERFORM public.attach_plan_limit_triggers(NEW.schema_name);
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
ALTER FUNCTION public.trg_attach_business_triggers() OWNER TO supabase_admin;
|
||||
|
||||
-- 6) Backfill: os 9 schemas já existentes ganham o trigger agora -------------
|
||||
DO $$
|
||||
DECLARE r record; n int := 0;
|
||||
BEGIN
|
||||
FOR r IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
n := n + public.attach_plan_limit_triggers(r.schema_name);
|
||||
END LOOP;
|
||||
RAISE NOTICE 'enforce_patient_plan_limit anexado em % schemas', n;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,238 @@
|
||||
-- =============================================================================
|
||||
-- Freemium F2 — RPCs idempotentes do self-service (schema-per-tenant)
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin (auto_provision insere em tenants/members/
|
||||
-- subscriptions/profiles + roda clone_tenant_template).
|
||||
--
|
||||
-- Com confirmação de e-mail LIGADA, o signup NÃO tem sessão — então nada que
|
||||
-- dependa de auth.uid() roda no signup. A escolha do usuário (nome, slug, plano,
|
||||
-- intervalo, kind) é gravada no raw_user_meta_data do signUp e PROCESSADA aqui,
|
||||
-- no 1º login pós-confirmação:
|
||||
-- • slug_disponivel(p_slug) → {ok, motivo} (chamável por anon no signup)
|
||||
-- • auto_provision_free_tenant(...) → cria tenant + clone + master + sub free
|
||||
-- • processar_pos_signup() → cria a intenção SÓ pro caminho pago
|
||||
--
|
||||
-- Todas idempotentes. Não há tabela de aceite legal neste sistema (pulado).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1) slug_disponivel ---------------------------------------------------------
|
||||
-- Valida formato (mesma regra do generate_tenant_slug), reservados e uso.
|
||||
-- Chamável por ANON (signup acontece antes do login). Não vaza dados de
|
||||
-- tenant além do fato "slug em uso".
|
||||
CREATE OR REPLACE FUNCTION public.slug_disponivel(p_slug text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v text := lower(trim(coalesce(p_slug, '')));
|
||||
v_reservados text[] := ARRAY['public','tenant','admin','www','api','app','auth','supabase','postgres','saas','suporte','support'];
|
||||
BEGIN
|
||||
IF length(v) < 3 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'motivo', 'curto');
|
||||
END IF;
|
||||
IF length(v) > 48 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'motivo', 'longo');
|
||||
END IF;
|
||||
-- começa com letra, só [a-z0-9_]
|
||||
IF v !~ '^[a-z][a-z0-9_]*$' THEN
|
||||
RETURN jsonb_build_object('ok', false, 'motivo', 'invalido');
|
||||
END IF;
|
||||
IF v = ANY(v_reservados) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'motivo', 'reservado');
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM public.tenants WHERE slug = v) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'motivo', 'em_uso');
|
||||
END IF;
|
||||
-- (F3) blacklist de slug integra aqui via motivo 'bloqueado'
|
||||
RETURN jsonb_build_object('ok', true, 'motivo', 'disponivel');
|
||||
END $$;
|
||||
ALTER FUNCTION public.slug_disponivel(text) OWNER TO supabase_admin;
|
||||
REVOKE ALL ON FUNCTION public.slug_disponivel(text) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.slug_disponivel(text) TO anon, authenticated, service_role;
|
||||
|
||||
-- 2) auto_provision_free_tenant ----------------------------------------------
|
||||
-- Idempotente: se o usuário já tem tenant ativo, retorna esse. Senão lê o
|
||||
-- raw_user_meta_data, cria o tenant (slug escolhido OU auto), vira master,
|
||||
-- clona o schema e cria a subscription gratuita ativa (XOR conforme target).
|
||||
-- p_slug_override permite a tela /onboarding reescolher o slug se colidiu.
|
||||
CREATE OR REPLACE FUNCTION public.auto_provision_free_tenant(p_slug_override text DEFAULT NULL)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_uid uuid := auth.uid();
|
||||
v_meta jsonb;
|
||||
v_email text;
|
||||
v_kind text;
|
||||
v_acct text;
|
||||
v_name text;
|
||||
v_slug text;
|
||||
v_display text;
|
||||
v_tenant_id uuid;
|
||||
v_plan_key text;
|
||||
v_plan_id uuid;
|
||||
v_target text;
|
||||
v_existing uuid;
|
||||
BEGIN
|
||||
IF v_uid IS NULL THEN
|
||||
RAISE EXCEPTION 'sem sessão' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
-- idempotência: já tem tenant ativo?
|
||||
SELECT tm.tenant_id INTO v_existing
|
||||
FROM public.tenant_members tm
|
||||
WHERE tm.user_id = v_uid AND tm.status = 'active'
|
||||
ORDER BY tm.created_at ASC
|
||||
LIMIT 1;
|
||||
IF v_existing IS NOT NULL THEN
|
||||
RETURN jsonb_build_object('status', 'exists', 'tenant_id', v_existing,
|
||||
'slug', (SELECT slug FROM public.tenants WHERE id = v_existing));
|
||||
END IF;
|
||||
|
||||
SELECT au.raw_user_meta_data, au.email INTO v_meta, v_email
|
||||
FROM auth.users au WHERE au.id = v_uid;
|
||||
v_meta := COALESCE(v_meta, '{}'::jsonb);
|
||||
|
||||
-- kind: do metadata, default therapist (maioria). Valida contra os aceitos.
|
||||
v_kind := lower(coalesce(nullif(trim(v_meta->>'account_kind'), ''), 'therapist'));
|
||||
IF v_kind NOT IN ('therapist','clinic_coworking','clinic_reception','clinic_full') THEN
|
||||
v_kind := 'therapist';
|
||||
END IF;
|
||||
v_acct := CASE WHEN v_kind = 'therapist' THEN 'therapist' ELSE 'clinic' END;
|
||||
|
||||
v_display := nullif(trim(v_meta->>'display_name'), '');
|
||||
v_name := coalesce(
|
||||
nullif(trim(v_meta->>'tenant_name'), ''),
|
||||
v_display,
|
||||
split_part(coalesce(v_email, 'conta'), '@', 1),
|
||||
'Conta');
|
||||
|
||||
-- slug: override > metadata > NULL (trigger auto-gera). Valida disponibilidade.
|
||||
v_slug := lower(trim(coalesce(p_slug_override, v_meta->>'tenant_slug', '')));
|
||||
IF v_slug = '' THEN
|
||||
v_slug := NULL;
|
||||
ELSE
|
||||
IF NOT (public.slug_disponivel(v_slug)->>'ok')::boolean THEN
|
||||
RAISE EXCEPTION 'SLUG_TAKEN|%', v_slug USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- cria tenant (trg_tenants_slug respeita slug fornecido; gera se NULL)
|
||||
INSERT INTO public.tenants (name, kind, slug, created_at)
|
||||
VALUES (v_name, v_kind, v_slug, now())
|
||||
RETURNING id, slug INTO v_tenant_id, v_slug;
|
||||
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES (v_tenant_id, v_uid, 'tenant_admin', 'active', now());
|
||||
|
||||
UPDATE public.profiles
|
||||
SET account_type = v_acct,
|
||||
full_name = COALESCE(full_name, v_display)
|
||||
WHERE id = v_uid;
|
||||
|
||||
-- provisiona o schema físico + seed
|
||||
PERFORM public.clone_tenant_template(v_tenant_id);
|
||||
PERFORM public.seed_determined_commitments(v_tenant_id);
|
||||
|
||||
-- subscription gratuita ativa (XOR: clinic→tenant_id; therapist→user_id)
|
||||
v_plan_key := CASE WHEN v_acct = 'therapist' THEN 'therapist_free' ELSE 'clinic_free' END;
|
||||
SELECT id, lower(target) INTO v_plan_id, v_target FROM public.plans WHERE key = v_plan_key;
|
||||
|
||||
INSERT INTO public.subscriptions (plan_id, plan_key, status, interval, source,
|
||||
tenant_id, user_id, started_at, activated_at, current_period_start)
|
||||
VALUES (v_plan_id, v_plan_key, 'active', 'month', 'auto_free',
|
||||
CASE WHEN v_target = 'clinic' THEN v_tenant_id ELSE NULL END,
|
||||
CASE WHEN v_target = 'clinic' THEN NULL ELSE v_uid END,
|
||||
now(), now(), now());
|
||||
|
||||
RETURN jsonb_build_object('status', 'provisioned', 'tenant_id', v_tenant_id,
|
||||
'slug', v_slug, 'kind', v_kind, 'plan_key', v_plan_key);
|
||||
END $$;
|
||||
ALTER FUNCTION public.auto_provision_free_tenant(text) OWNER TO supabase_admin;
|
||||
REVOKE ALL ON FUNCTION public.auto_provision_free_tenant(text) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.auto_provision_free_tenant(text) TO authenticated, service_role;
|
||||
|
||||
-- 3) processar_pos_signup ----------------------------------------------------
|
||||
-- Caminho PAGO: se o usuário escolheu um plano PRO no signup (metadata),
|
||||
-- registra a intenção (idempotente — uma por usuário+plano 'new'). O caminho
|
||||
-- gratuito não gera intenção. Sem tabela de aceite legal (pulado).
|
||||
CREATE OR REPLACE FUNCTION public.processar_pos_signup()
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_uid uuid := auth.uid();
|
||||
v_meta jsonb;
|
||||
v_email text;
|
||||
v_plan_key text;
|
||||
v_interval text;
|
||||
v_plan record;
|
||||
v_tenant uuid;
|
||||
v_amount int;
|
||||
BEGIN
|
||||
IF v_uid IS NULL THEN
|
||||
RAISE EXCEPTION 'sem sessão' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
SELECT au.raw_user_meta_data, au.email INTO v_meta, v_email
|
||||
FROM auth.users au WHERE au.id = v_uid;
|
||||
v_meta := COALESCE(v_meta, '{}'::jsonb);
|
||||
|
||||
v_plan_key := nullif(trim(v_meta->>'plan_key'), '');
|
||||
v_interval := lower(coalesce(nullif(trim(v_meta->>'billing_interval'), ''), 'month'));
|
||||
IF v_interval NOT IN ('month','year') THEN v_interval := 'month'; END IF;
|
||||
|
||||
-- sem plano escolhido OU plano gratuito → nada a fazer
|
||||
IF v_plan_key IS NULL OR v_plan_key LIKE '%\_free' THEN
|
||||
RETURN jsonb_build_object('status', 'no_intent');
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_plan FROM public.plans WHERE key = v_plan_key AND is_active;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('status', 'plan_not_found', 'plan_key', v_plan_key);
|
||||
END IF;
|
||||
|
||||
-- idempotência: já existe intent 'new' desse usuário+plano?
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM public.subscription_intents
|
||||
WHERE created_by_user_id = v_uid AND plan_key = v_plan_key AND status = 'new'
|
||||
) THEN
|
||||
RETURN jsonb_build_object('status', 'intent_exists', 'plan_key', v_plan_key);
|
||||
END IF;
|
||||
|
||||
SELECT tm.tenant_id INTO v_tenant
|
||||
FROM public.tenant_members tm WHERE tm.user_id = v_uid AND tm.status = 'active'
|
||||
ORDER BY tm.created_at ASC LIMIT 1;
|
||||
|
||||
v_amount := CASE WHEN v_interval = 'year'
|
||||
THEN COALESCE(v_plan.price_cents, 0) * 12
|
||||
ELSE COALESCE(v_plan.price_cents, 0) END;
|
||||
|
||||
-- escreve direto na tabela real (a view subscription_intents tem INSTEAD OF
|
||||
-- trigger que não propaga user_id pra _tenant; o serviço do front também
|
||||
-- escreve nas tabelas reais por target).
|
||||
IF lower(v_plan.target) = 'clinic' THEN
|
||||
INSERT INTO public.subscription_intents_tenant
|
||||
(tenant_id, user_id, created_by_user_id, email, plan_id, plan_key,
|
||||
interval, amount_cents, currency, status, source)
|
||||
VALUES
|
||||
(v_tenant, v_uid, v_uid, v_email, v_plan.id, v_plan_key,
|
||||
v_interval, v_amount, 'BRL', 'new', 'signup');
|
||||
ELSE
|
||||
INSERT INTO public.subscription_intents_personal
|
||||
(user_id, created_by_user_id, email, plan_id, plan_key,
|
||||
interval, amount_cents, currency, status, source)
|
||||
VALUES
|
||||
(v_uid, v_uid, v_email, v_plan.id, v_plan_key,
|
||||
v_interval, v_amount, 'BRL', 'new', 'signup');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('status', 'intent_created', 'plan_key', v_plan_key, 'interval', v_interval);
|
||||
END $$;
|
||||
ALTER FUNCTION public.processar_pos_signup() OWNER TO supabase_admin;
|
||||
REVOKE ALL ON FUNCTION public.processar_pos_signup() FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.processar_pos_signup() TO authenticated, service_role;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,104 @@
|
||||
-- =============================================================================
|
||||
-- Freemium F3a — Blacklist de e-mails e slugs
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin (cria trigger em auth.users + altera
|
||||
-- slug_disponivel, que é owned por supabase_admin).
|
||||
--
|
||||
-- Tabela blacklist (kind email|slug). E-mail bloqueia o cadastro DE VERDADE via
|
||||
-- trigger BEFORE INSERT em auth.users (não só no front); suporta domínio inteiro
|
||||
-- com entrada '@dominio.com'. Slug integra no slug_disponivel (motivo 'bloqueado').
|
||||
-- Gerida por saas_admin (dev) em Configurações.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.blacklist (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kind text NOT NULL CHECK (kind IN ('email','slug')),
|
||||
value text NOT NULL,
|
||||
note text,
|
||||
created_by uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (kind, value)
|
||||
);
|
||||
|
||||
-- normaliza value (lower+trim) sempre
|
||||
CREATE OR REPLACE FUNCTION public.blacklist_normalize()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.value := lower(trim(NEW.value));
|
||||
IF NEW.value = '' THEN RAISE EXCEPTION 'valor vazio'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
DROP TRIGGER IF EXISTS trg_blacklist_normalize ON public.blacklist;
|
||||
CREATE TRIGGER trg_blacklist_normalize BEFORE INSERT OR UPDATE ON public.blacklist
|
||||
FOR EACH ROW EXECUTE FUNCTION public.blacklist_normalize();
|
||||
|
||||
-- RLS: só saas_admin gere
|
||||
ALTER TABLE public.blacklist ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS blacklist_saas_admin ON public.blacklist;
|
||||
CREATE POLICY blacklist_saas_admin ON public.blacklist
|
||||
FOR ALL USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
|
||||
|
||||
-- helpers ----------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.is_email_blacklisted(p_email text)
|
||||
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.blacklist
|
||||
WHERE kind = 'email'
|
||||
AND value IN (
|
||||
lower(trim(p_email)),
|
||||
'@' || split_part(lower(trim(p_email)), '@', 2)
|
||||
)
|
||||
);
|
||||
$$;
|
||||
ALTER FUNCTION public.is_email_blacklisted(text) OWNER TO supabase_admin;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.is_slug_blacklisted(p_slug text)
|
||||
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
SELECT EXISTS (SELECT 1 FROM public.blacklist WHERE kind = 'slug' AND value = lower(trim(p_slug)));
|
||||
$$;
|
||||
ALTER FUNCTION public.is_slug_blacklisted(text) OWNER TO supabase_admin;
|
||||
|
||||
-- trigger de bloqueio real no cadastro -----------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.enforce_email_blacklist()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.email IS NOT NULL AND public.is_email_blacklisted(NEW.email) THEN
|
||||
RAISE EXCEPTION 'EMAIL_BLOCKED' USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
ALTER FUNCTION public.enforce_email_blacklist() OWNER TO supabase_admin;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_enforce_email_blacklist ON auth.users;
|
||||
CREATE TRIGGER trg_enforce_email_blacklist BEFORE INSERT ON auth.users
|
||||
FOR EACH ROW EXECUTE FUNCTION public.enforce_email_blacklist();
|
||||
|
||||
-- integra no slug_disponivel (motivo 'bloqueado') ------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.slug_disponivel(p_slug text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v text := lower(trim(coalesce(p_slug, '')));
|
||||
v_reservados text[] := ARRAY['public','tenant','admin','www','api','app','auth','supabase','postgres','saas','suporte','support'];
|
||||
BEGIN
|
||||
IF length(v) < 3 THEN RETURN jsonb_build_object('ok', false, 'motivo', 'curto'); END IF;
|
||||
IF length(v) > 48 THEN RETURN jsonb_build_object('ok', false, 'motivo', 'longo'); END IF;
|
||||
IF v !~ '^[a-z][a-z0-9_]*$' THEN RETURN jsonb_build_object('ok', false, 'motivo', 'invalido'); END IF;
|
||||
IF v = ANY(v_reservados) THEN RETURN jsonb_build_object('ok', false, 'motivo', 'reservado'); END IF;
|
||||
IF public.is_slug_blacklisted(v) THEN RETURN jsonb_build_object('ok', false, 'motivo', 'bloqueado'); END IF;
|
||||
IF EXISTS (SELECT 1 FROM public.tenants WHERE slug = v) THEN RETURN jsonb_build_object('ok', false, 'motivo', 'em_uso'); END IF;
|
||||
RETURN jsonb_build_object('ok', true, 'motivo', 'disponivel');
|
||||
END $$;
|
||||
ALTER FUNCTION public.slug_disponivel(text) OWNER TO supabase_admin;
|
||||
REVOKE ALL ON FUNCTION public.slug_disponivel(text) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.slug_disponivel(text) TO anon, authenticated, service_role;
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.blacklist TO authenticated;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,116 @@
|
||||
-- =============================================================================
|
||||
-- Freemium F3b — /saas/usuarios (donos por tenant) + notificação aos devs
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin (lê auth.users.email + cria trigger em
|
||||
-- public.subscriptions; notify_user_sistema é chamada por SECURITY DEFINER).
|
||||
--
|
||||
-- • saas_list_account_owners(): 1 linha por tenant com o DONO (master),
|
||||
-- nome/slug/e-mail/plano + selo "novo" (24h). Dev-only (is_saas_admin).
|
||||
-- • notify_all_devs(): insere em notifications_sistema p/ cada saas_admin.
|
||||
-- • trigger em subscriptions: avisa os devs quando nasce/muda uma assinatura,
|
||||
-- com deeplink pra /saas/usuarios.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1) Donos por tenant (dev-only) ---------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.saas_list_account_owners()
|
||||
RETURNS TABLE (
|
||||
tenant_id uuid,
|
||||
slug text,
|
||||
tenant_name text,
|
||||
kind text,
|
||||
owner_id uuid,
|
||||
owner_name text,
|
||||
owner_email text,
|
||||
plan_key text,
|
||||
created_at timestamptz,
|
||||
is_new boolean
|
||||
)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'forbidden' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT t.id, t.slug::text, t.name::text, t.kind::text,
|
||||
owner.user_id, pr.full_name::text, au.email::text,
|
||||
COALESCE(vas.plan_key, ps.plan_key)::text,
|
||||
t.created_at,
|
||||
(t.created_at > now() - interval '24 hours')
|
||||
FROM public.tenants t
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT tm.user_id
|
||||
FROM public.tenant_members tm
|
||||
WHERE tm.tenant_id = t.id AND tm.role = 'tenant_admin' AND tm.status = 'active'
|
||||
ORDER BY tm.created_at ASC
|
||||
LIMIT 1
|
||||
) owner ON true
|
||||
LEFT JOIN public.profiles pr ON pr.id = owner.user_id
|
||||
LEFT JOIN auth.users au ON au.id = owner.user_id
|
||||
LEFT JOIN public.v_tenant_active_subscription vas ON vas.tenant_id = t.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT s.plan_key FROM public.subscriptions s
|
||||
WHERE s.user_id = owner.user_id AND s.status = 'active' AND s.tenant_id IS NULL
|
||||
ORDER BY s.created_at DESC LIMIT 1
|
||||
) ps ON true
|
||||
ORDER BY t.created_at DESC;
|
||||
END $$;
|
||||
ALTER FUNCTION public.saas_list_account_owners() OWNER TO supabase_admin;
|
||||
REVOKE ALL ON FUNCTION public.saas_list_account_owners() FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.saas_list_account_owners() TO authenticated, service_role;
|
||||
|
||||
-- 2) notify_all_devs ----------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.notify_all_devs(
|
||||
p_type text, p_payload jsonb, p_ref_id uuid DEFAULT NULL, p_ref_table text DEFAULT NULL
|
||||
)
|
||||
RETURNS int LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE r record; n int := 0;
|
||||
BEGIN
|
||||
FOR r IN SELECT user_id FROM public.saas_admins LOOP
|
||||
PERFORM public.notify_user_sistema(r.user_id, p_type, p_payload, NULL, p_ref_id, p_ref_table);
|
||||
n := n + 1;
|
||||
END LOOP;
|
||||
RETURN n;
|
||||
END $$;
|
||||
ALTER FUNCTION public.notify_all_devs(text, jsonb, uuid, text) OWNER TO supabase_admin;
|
||||
|
||||
-- 3) trigger em subscriptions -------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.trg_notify_devs_subscription()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_slug text; v_title text;
|
||||
BEGIN
|
||||
-- só em INSERT ou quando o status muda
|
||||
IF TG_OP = 'UPDATE' AND NEW.status IS NOT DISTINCT FROM OLD.status THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT t.slug INTO v_slug FROM public.tenants t WHERE t.id = NEW.tenant_id;
|
||||
|
||||
v_title := CASE WHEN TG_OP = 'INSERT' THEN 'Nova assinatura' ELSE 'Assinatura atualizada' END;
|
||||
|
||||
PERFORM public.notify_all_devs(
|
||||
'subscription_' || lower(TG_OP),
|
||||
jsonb_build_object(
|
||||
'title', v_title,
|
||||
'detail', NEW.plan_key || ' · ' || NEW.status || COALESCE(' · ' || v_slug, ''),
|
||||
'deeplink', '/saas/usuarios',
|
||||
'plan_key', NEW.plan_key,
|
||||
'status', NEW.status
|
||||
),
|
||||
NEW.id, 'subscriptions'
|
||||
);
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
ALTER FUNCTION public.trg_notify_devs_subscription() OWNER TO supabase_admin;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_subscriptions_notify_devs ON public.subscriptions;
|
||||
CREATE TRIGGER trg_subscriptions_notify_devs
|
||||
AFTER INSERT OR UPDATE OF status ON public.subscriptions
|
||||
FOR EACH ROW EXECUTE FUNCTION public.trg_notify_devs_subscription();
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- =============================================================================
|
||||
-- Freemium F3c — root_redirect (pra onde o visitante não-logado vai na raiz "/")
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin (RLS por is_saas_admin).
|
||||
--
|
||||
-- Config singleton saas_app_config + RPC pública get_root_redirect() (anon lê o
|
||||
-- alvo: 'landing' | 'login'). O guard do front usa pra rotear "/". Só saas_admin
|
||||
-- altera (via UPDATE direto, gated por RLS).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.saas_app_config (
|
||||
id boolean PRIMARY KEY DEFAULT true, -- singleton: sempre id=true
|
||||
root_redirect text NOT NULL DEFAULT 'landing' CHECK (root_redirect IN ('landing','login')),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by uuid,
|
||||
CONSTRAINT saas_app_config_singleton CHECK (id)
|
||||
);
|
||||
|
||||
INSERT INTO public.saas_app_config (id) VALUES (true) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
ALTER TABLE public.saas_app_config ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS saas_app_config_read ON public.saas_app_config;
|
||||
CREATE POLICY saas_app_config_read ON public.saas_app_config FOR SELECT USING (true);
|
||||
DROP POLICY IF EXISTS saas_app_config_write ON public.saas_app_config;
|
||||
CREATE POLICY saas_app_config_write ON public.saas_app_config
|
||||
FOR UPDATE USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
|
||||
|
||||
GRANT SELECT ON public.saas_app_config TO anon, authenticated;
|
||||
GRANT UPDATE ON public.saas_app_config TO authenticated;
|
||||
|
||||
-- RPC pública: alvo do "/" pra visitante não-logado
|
||||
CREATE OR REPLACE FUNCTION public.get_root_redirect()
|
||||
RETURNS text LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
SELECT COALESCE((SELECT root_redirect FROM public.saas_app_config WHERE id), 'landing');
|
||||
$$;
|
||||
ALTER FUNCTION public.get_root_redirect() OWNER TO supabase_admin;
|
||||
REVOKE ALL ON FUNCTION public.get_root_redirect() FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.get_root_redirect() TO anon, authenticated, service_role;
|
||||
|
||||
COMMIT;
|
||||
@@ -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_<slug>'.
|
||||
-- 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;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- =============================================================================
|
||||
-- F1b — Decisão de roteamento anon: 6 tabelas anon-facing FICAM em public
|
||||
--
|
||||
-- Fluxos anônimos identificam o tenant por TOKEN/SLUG (não por login), então
|
||||
-- não conseguem resolver o schema físico. Decisão (2026-06-13, opção C):
|
||||
-- manter essas tabelas em public com tenant_id + RLS por token, como hoje.
|
||||
--
|
||||
-- patient_intake_requests — intake de paciente por convite (token)
|
||||
-- patient_invites — tokens de convite
|
||||
-- patient_invite_attempts — rate-limit anon dos convites
|
||||
-- document_share_links — assinatura pública de documento (token)
|
||||
-- agendador_configuracoes — agendador público (link_slug)
|
||||
-- agendador_solicitacoes — solicitação criada por visitante anon
|
||||
--
|
||||
-- Logo, REMOVE essas 6 do _tenant_template (não viram schema do tenant).
|
||||
-- O clone_tenant_template itera as tabelas do template dinamicamente, então
|
||||
-- novos clones nascem sem elas automaticamente. Classificação final:
|
||||
-- 78 tenant-scoped + 59 globais (era 84 + 53).
|
||||
--
|
||||
-- Nota F6: public.document_share_links.documento_id tem FK -> documents, que
|
||||
-- VAI pro schema do tenant. No drop de public.documents (F6), essa FK precisa
|
||||
-- virar coluna solta (uuid sem constraint) — o RPC valida via token. Idem
|
||||
-- qualquer FK public->tenant dessas 6 (registrar no lote de FKs da F6).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
anon_tabs text[] := ARRAY[
|
||||
'patient_intake_requests','patient_invites','patient_invite_attempts',
|
||||
'document_share_links','agendador_configuracoes','agendador_solicitacoes'
|
||||
];
|
||||
t text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY anon_tabs LOOP
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = '_tenant_template' AND table_name = t) THEN
|
||||
EXECUTE format('DROP TABLE _tenant_template.%I CASCADE', t);
|
||||
RAISE NOTICE 'F1b: _tenant_template.% removida (fica em public)', t;
|
||||
END IF;
|
||||
-- defensivo: tira do registro de realtime do template, se estiver lá
|
||||
DELETE FROM _tenant_template._realtime_tables WHERE table_name = t;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
UPDATE _tenant_template._meta SET value = '2'::jsonb WHERE key = 'template_version';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,45 @@
|
||||
-- =============================================================================
|
||||
-- F5 — Trigger que re-expõe schemas tenant no PostgREST a cada clone/drop
|
||||
--
|
||||
-- public.refresh_pgrst_schemas() (criada em manual/f5_pgrst_refresh_schemas.
|
||||
-- supabase_admin.sql, owned por supabase_admin) seta pgrst.db_schemas + NOTIFY.
|
||||
-- Este trigger em tenant_schemas a dispara automaticamente — clone_tenant_template
|
||||
-- e drop_tenant_schema NÃO precisam ser tocados (eles inserem/removem em
|
||||
-- tenant_schemas, o que aciona o refresh no COMMIT).
|
||||
--
|
||||
-- PRÉ-REQUISITO: aplicar f5_pgrst_refresh_schemas.supabase_admin.sql ANTES desta
|
||||
-- migration (a função precisa existir e estar owned por supabase_admin).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE n.nspname = 'public' AND p.proname = 'refresh_pgrst_schemas'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'F5: public.refresh_pgrst_schemas() não existe — aplique manual/f5_pgrst_refresh_schemas.supabase_admin.sql primeiro';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Trigger function (owned por postgres) só delega pro refresh (SECDEF supabase_admin)
|
||||
CREATE OR REPLACE FUNCTION public.trg_refresh_pgrst_schemas()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM public.refresh_pgrst_schemas();
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_tenant_schemas_pgrst_refresh ON public.tenant_schemas;
|
||||
CREATE TRIGGER trg_tenant_schemas_pgrst_refresh
|
||||
AFTER INSERT OR DELETE OR UPDATE OF schema_name ON public.tenant_schemas
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION public.trg_refresh_pgrst_schemas();
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- =============================================================================
|
||||
-- F6.0 — Clona os schemas dos tenants JÁ EXISTENTES (cutover)
|
||||
--
|
||||
-- Até aqui só tenants criados PÓS-F2 ganhavam schema. Os 9 tenants que já
|
||||
-- existiam precisam dos seus schemas (ainda vazios — dados migram na F6.1).
|
||||
-- Idempotente: só clona quem não está em tenant_schemas. Cada clone dispara
|
||||
-- o trigger da F5 (expõe no PostgREST).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
r record;
|
||||
v_schema text;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT t.id, t.slug
|
||||
FROM public.tenants t
|
||||
LEFT JOIN public.tenant_schemas ts ON ts.tenant_id = t.id
|
||||
WHERE ts.tenant_id IS NULL
|
||||
ORDER BY t.created_at, t.id
|
||||
LOOP
|
||||
v_schema := public.clone_tenant_template(r.id);
|
||||
RAISE NOTICE 'F6.0: tenant % (%) -> %', r.id, r.slug, v_schema;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,76 @@
|
||||
-- =============================================================================
|
||||
-- F6.2 Lote A — anexa triggers schema-agnósticos aos schemas tenant
|
||||
--
|
||||
-- O clone (LIKE INCLUDING ALL) NÃO copia triggers. As tabelas tenant nos
|
||||
-- schemas precisam dos triggers de negócio. Lote A: os PROVADAMENTE
|
||||
-- schema-agnósticos (só mexem em NEW/OLD, não referenciam outras tabelas) —
|
||||
-- seguros pra anexar sem reescrever a função:
|
||||
-- família updated_at (8) + prevent_promoting_to_system +
|
||||
-- prevent_system_group_changes
|
||||
-- Os schema-aware (financeiro/audit/notif/timeline/sync) vêm no Lote B.
|
||||
--
|
||||
-- attach_agnostic_triggers(schema) recria, no schema dado, os triggers de
|
||||
-- public cuja função está na whitelist agnóstica. A função do trigger continua
|
||||
-- sendo a de public (agnóstica → funciona em qualquer schema). Backfill dos 9;
|
||||
-- o wiring no clone_tenant_template acontece no fim da F6.2 (com todos prontos).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.attach_agnostic_triggers(p_schema text)
|
||||
RETURNS int
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
agnostic text[] := ARRAY[
|
||||
'set_updated_at','fn_clinical_notes_updated_at','set_insurance_plans_updated_at',
|
||||
'set_medicos_updated_at','set_services_updated_at','set_updated_at_recurrence',
|
||||
'update_payment_settings_updated_at','update_professional_pricing_updated_at',
|
||||
'prevent_promoting_to_system','prevent_system_group_changes'
|
||||
];
|
||||
r record;
|
||||
v_def text;
|
||||
v_count int := 0;
|
||||
BEGIN
|
||||
IF p_schema NOT LIKE 'tenant\_%' THEN
|
||||
RAISE EXCEPTION 'attach_agnostic_triggers: schema inválido %', p_schema;
|
||||
END IF;
|
||||
|
||||
FOR r IN
|
||||
SELECT c.relname AS tab, t.tgname, pg_get_triggerdef(t.oid) AS def
|
||||
FROM pg_trigger t
|
||||
JOIN pg_class c ON c.oid = t.tgrelid
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
JOIN pg_proc p ON p.oid = t.tgfoid
|
||||
WHERE n.nspname = 'public' AND NOT t.tgisinternal
|
||||
AND p.proname = ANY(agnostic)
|
||||
AND EXISTS (SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = p_schema AND table_name = c.relname)
|
||||
LOOP
|
||||
-- redireciona o ON public.<tab> pro schema do tenant (a função fica em public)
|
||||
v_def := replace(r.def, 'ON public.' || r.tab || ' ', 'ON ' || p_schema || '.' || r.tab || ' ');
|
||||
IF v_def = r.def THEN
|
||||
RAISE EXCEPTION 'attach_agnostic_triggers: não consegui redirecionar % (%.%)', r.tgname, p_schema, r.tab;
|
||||
END IF;
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', r.tgname, p_schema, r.tab);
|
||||
EXECUTE v_def;
|
||||
v_count := v_count + 1;
|
||||
END LOOP;
|
||||
|
||||
RETURN v_count;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Backfill dos 9 schemas existentes
|
||||
DO $$
|
||||
DECLARE r record; v int;
|
||||
BEGIN
|
||||
FOR r IN SELECT schema_name FROM public.tenant_schemas ORDER BY schema_name LOOP
|
||||
v := public.attach_agnostic_triggers(r.schema_name);
|
||||
RAISE NOTICE 'F6.2A %: % triggers agnósticos', r.schema_name, v;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- =============================================================================
|
||||
-- Freemium F1 — limite de pacientes do plano therapist_free
|
||||
--
|
||||
-- clinic_free já traz max_patients=30 (em plan_features.limits da feature
|
||||
-- clinic_calendar, semeado). O therapist_free não tinha limite de pacientes.
|
||||
-- Pendura max_patients=20 na feature 'patients.manage' (a que o therapist_free
|
||||
-- já possui, enabled).
|
||||
--
|
||||
-- REGRA DE OURO: referenciar plano/feature POR KEY via subquery, nunca por uuid
|
||||
-- hardcoded (uuids divergem entre ambientes). Idempotente (merge no jsonb).
|
||||
-- O enforcement em runtime (trigger) está em manual/freemium_f1_plan_limits.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE public.plan_features pf
|
||||
SET limits = COALESCE(pf.limits, '{}'::jsonb) || jsonb_build_object('max_patients', 20)
|
||||
WHERE pf.plan_id = (SELECT id FROM public.plans WHERE key = 'therapist_free')
|
||||
AND pf.feature_id = (SELECT id FROM public.features WHERE key = 'patients.manage');
|
||||
|
||||
-- Sanidade: garante que o limite ficou gravado (1 linha afetada esperada).
|
||||
DO $$
|
||||
DECLARE v int;
|
||||
BEGIN
|
||||
SELECT (pf.limits->>'max_patients')::int INTO v
|
||||
FROM public.plan_features pf
|
||||
WHERE pf.plan_id = (SELECT id FROM public.plans WHERE key = 'therapist_free')
|
||||
AND pf.feature_id = (SELECT id FROM public.features WHERE key = 'patients.manage');
|
||||
IF v IS DISTINCT FROM 20 THEN
|
||||
RAISE EXCEPTION 'therapist_free max_patients esperado 20, obtido %', v;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,53 @@
|
||||
-- =============================================================================
|
||||
-- Fix (regressão schema-per-tenant): log_audit_change quebra INSERT em tabelas
|
||||
-- GLOBAIS auditadas.
|
||||
--
|
||||
-- log_audit_change deriva o tenant via tenant_id_for_schema(TG_TABLE_SCHEMA).
|
||||
-- Para tabelas em tenant_<slug> isso resolve certo. Mas o trigger também está
|
||||
-- em public.tenant_members (tabela global) — e tenant_id_for_schema('public')
|
||||
-- retorna NULL, violando audit_logs.tenant_id (NOT NULL). Resultado: QUALQUER
|
||||
-- INSERT em tenant_members falhava (provisionamento, aceite de convite).
|
||||
--
|
||||
-- Fix: quando o schema não resolve um tenant (tabela global), usa o tenant_id
|
||||
-- da própria linha (tenant_members.tenant_id). Se ainda assim for NULL, não
|
||||
-- audita — mas NUNCA quebra a operação de negócio.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.log_audit_change()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_tenant_id uuid; v_entity_id text; v_old jsonb; v_new jsonb; v_changed text[];
|
||||
v_heavy text[] := ARRAY['content','content_html','content_json','raw_data','signature_data','pdf_blob','binary','body_html','body_text'];
|
||||
v_noise text[] := ARRAY['updated_at','last_seen_at','last_activity_at'];
|
||||
BEGIN
|
||||
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
|
||||
|
||||
-- tabela global (public.*): cai no tenant_id da própria linha, se existir
|
||||
IF v_tenant_id IS NULL THEN
|
||||
v_tenant_id := NULLIF(to_jsonb(COALESCE(NEW, OLD)) ->> 'tenant_id', '')::uuid;
|
||||
END IF;
|
||||
|
||||
-- sem tenant resolvível → não audita, mas não quebra a operação
|
||||
IF v_tenant_id IS NULL THEN
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
v_entity_id := OLD.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := NULL;
|
||||
ELSIF TG_OP = 'INSERT' THEN
|
||||
v_entity_id := NEW.id::text; v_old := NULL; v_new := to_jsonb(NEW) - v_heavy;
|
||||
ELSE
|
||||
v_entity_id := NEW.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := to_jsonb(NEW) - v_heavy;
|
||||
SELECT array_agg(key ORDER BY key) INTO v_changed
|
||||
FROM jsonb_each(to_jsonb(NEW)) AS kv(key, value)
|
||||
WHERE (to_jsonb(OLD))->kv.key IS DISTINCT FROM kv.value;
|
||||
IF v_changed IS NULL THEN RETURN NEW; END IF;
|
||||
IF v_changed <@ v_noise THEN RETURN NEW; END IF;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.audit_logs (tenant_id, user_id, entity_type, entity_id, action, old_values, new_values, changed_fields)
|
||||
VALUES (v_tenant_id, auth.uid(), TG_TABLE_NAME, v_entity_id, lower(TG_OP), v_old, v_new, v_changed);
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END $function$;
|
||||
@@ -0,0 +1,66 @@
|
||||
-- =============================================================================
|
||||
-- Freemium F2 (polish) — apresentação do plano gratuito na vitrine pública
|
||||
--
|
||||
-- Os planos free já eram is_visible em v_public_pricing, mas sem plan_public
|
||||
-- (nome/descrição/bullets) e sem preço — renderizavam sem nome/valor. Este seed
|
||||
-- dá um cartão "Grátis" decente. Referência por KEY (subquery), idempotente.
|
||||
-- O preço "Grátis" é tratado no front (Landingpage isFreePlan).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO public.plan_public (plan_id, public_name, public_description, badge, is_featured, is_visible, sort_order)
|
||||
SELECT id, 'Grátis',
|
||||
'Comece sem custo: o essencial pra organizar sua agenda, pacientes e prontuário.',
|
||||
'Grátis', false, true, 0
|
||||
FROM public.plans WHERE key = 'clinic_free'
|
||||
ON CONFLICT (plan_id) DO UPDATE
|
||||
SET public_name = EXCLUDED.public_name,
|
||||
public_description = EXCLUDED.public_description,
|
||||
badge = EXCLUDED.badge,
|
||||
is_visible = true,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
updated_at = now();
|
||||
|
||||
INSERT INTO public.plan_public (plan_id, public_name, public_description, badge, is_featured, is_visible, sort_order)
|
||||
SELECT id, 'Grátis',
|
||||
'Pra terapeutas individuais: agenda, pacientes e prontuário sem custo.',
|
||||
'Grátis', false, true, 0
|
||||
FROM public.plans WHERE key = 'therapist_free'
|
||||
ON CONFLICT (plan_id) DO UPDATE
|
||||
SET public_name = EXCLUDED.public_name,
|
||||
public_description = EXCLUDED.public_description,
|
||||
badge = EXCLUDED.badge,
|
||||
is_visible = true,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
updated_at = now();
|
||||
|
||||
-- bullets (idempotente: limpa os dos free e re-insere)
|
||||
DELETE FROM public.plan_public_bullets
|
||||
WHERE plan_id IN (SELECT id FROM public.plans WHERE key IN ('clinic_free','therapist_free'));
|
||||
|
||||
INSERT INTO public.plan_public_bullets (plan_id, text, highlight, sort_order)
|
||||
SELECT p.id, b.text, b.highlight, b.sort_order
|
||||
FROM public.plans p
|
||||
CROSS JOIN LATERAL (
|
||||
VALUES
|
||||
('Agenda completa e prontuário', true, 1),
|
||||
('Até 30 pacientes ativos', false, 2),
|
||||
('Documentos e lembretes básicos', false, 3),
|
||||
('Agendamento online', false, 4)
|
||||
) AS b(text, highlight, sort_order)
|
||||
WHERE p.key = 'clinic_free';
|
||||
|
||||
INSERT INTO public.plan_public_bullets (plan_id, text, highlight, sort_order)
|
||||
SELECT p.id, b.text, b.highlight, b.sort_order
|
||||
FROM public.plans p
|
||||
CROSS JOIN LATERAL (
|
||||
VALUES
|
||||
('Agenda completa e prontuário', true, 1),
|
||||
('Até 20 pacientes ativos', false, 2),
|
||||
('Documentos e lembretes básicos', false, 3),
|
||||
('Agendamento online', false, 4)
|
||||
) AS b(text, highlight, sort_order)
|
||||
WHERE p.key = 'therapist_free';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,176 @@
|
||||
# Deploy F4 — Freemium / PLG (hosted)
|
||||
|
||||
> Runbook de produção do épico freemium/PLG (branch `feat/freemium-plg`).
|
||||
> Gerado em 2026-06-13. Faça **um passo de cada vez** e valide antes de seguir.
|
||||
|
||||
---
|
||||
|
||||
## ⛔ PRÉ-REQUISITO #0 (bloqueante) — schema-per-tenant no hosted
|
||||
|
||||
O freemium foi construído **em cima** da migração schema-per-tenant. As RPCs
|
||||
(`auto_provision_free_tenant`, `slug_disponivel`, enforcement de limite, etc.)
|
||||
dependem de infra que **só existe se a schema-per-tenant já estiver no hosted**:
|
||||
|
||||
- `tenant_schemas`, `_tenant_template`, `clone_tenant_template`, `seed_*`
|
||||
- helpers `tenant_id_for_schema`, `tenant_schema_name`, `is_saas_admin`, `is_tenant_member`
|
||||
- exposição dinâmica de schemas no PostgREST (`pgrst.db_schemas`)
|
||||
- `v_tenant_active_subscription`, `notifications_sistema`, `tenant_members.slug`
|
||||
|
||||
**Se o hosted ainda está no modelo RLS (branch `main`), NÃO aplique o freemium —
|
||||
ele vai quebrar.** Ordem obrigatória:
|
||||
|
||||
1. Deployar e validar a **schema-per-tenant** no hosted (migrations `20260612*` +
|
||||
`20260613000001..000004` + os `manual/f5*..f6_2h*` + `f6_4`), **sem** o F6.3
|
||||
DROP num primeiro momento (dados espelhados em public — ver `database-novo/manual/f6_3_ROLLBACK.md`).
|
||||
2. Só então seguir este runbook do freemium.
|
||||
|
||||
> Enquanto a schema-per-tenant não estiver no hosted + testada no browser
|
||||
> (task #7 / DROP F6.3 pendente), este deploy fica **em espera**.
|
||||
|
||||
---
|
||||
|
||||
## Inventário do que vai pro hosted (freemium)
|
||||
|
||||
### Migrations (rodam como `postgres` via `supabase db push` ou SQL Editor)
|
||||
| Ordem | Arquivo | O quê |
|
||||
|---|---|---|
|
||||
| 1 | `database-novo/migrations/20260613000005_freemium_f1_therapist_free_patient_limit.sql` | `max_patients=20` no therapist_free |
|
||||
| 2 | `database-novo/migrations/20260613000006_fix_audit_global_tables.sql` | **fix regressão** audit em tenant_members (aplicar SEMPRE) |
|
||||
| 3 | `database-novo/migrations/20260613000007_freemium_f2_vitrine_free.sql` | cartão "Grátis" na vitrine (plan_public + bullets) |
|
||||
|
||||
### Manual `supabase_admin` (rodam com role elevada — ver nota de permissões)
|
||||
Aplicar **nesta ordem** (idempotentes; `BEGIN/COMMIT` internos):
|
||||
1. `database-novo/manual/freemium_f1_plan_limits.supabase_admin.sql`
|
||||
2. `database-novo/manual/freemium_f2_provisioning.supabase_admin.sql`
|
||||
3. `database-novo/manual/freemium_f3a_blacklist.supabase_admin.sql`
|
||||
4. `database-novo/manual/freemium_f3b_saas_owners_notify.supabase_admin.sql`
|
||||
5. `database-novo/manual/freemium_f3c_app_config.supabase_admin.sql`
|
||||
|
||||
### Edge functions
|
||||
- `supabase/functions/recover-access` (esqueci-email por slug → magic link)
|
||||
- `supabase/functions/send-welcome-email` (boas-vindas ao dono — best-effort)
|
||||
|
||||
### Config
|
||||
- Auth → **Confirm email = ON** + Site/Redirect URLs
|
||||
- Secrets de SMTP do `send-welcome-email` (+ `APP_URL`)
|
||||
|
||||
---
|
||||
|
||||
## Passo a passo
|
||||
|
||||
### 1) Banco — migrations
|
||||
Com a CLI apontando pro projeto hosted (`supabase link` já feito):
|
||||
|
||||
```bash
|
||||
supabase db push # aplica as migrations pendentes (inclui as 3 do freemium)
|
||||
```
|
||||
Ou, se preferir manual, cole cada arquivo `migrations/2026061300000[567]_*.sql` no
|
||||
**SQL Editor** do dashboard (roda como `postgres`), na ordem da tabela acima.
|
||||
|
||||
> ⚠️ A `20260613000006_fix_audit_global_tables.sql` é **obrigatória** — sem ela,
|
||||
> qualquer novo `tenant_members` (provisionamento, convite) falha no hosted também.
|
||||
|
||||
### 2) Banco — manual `supabase_admin`
|
||||
|
||||
**Nota de permissões (hosted):** no Supabase hosted, o `postgres` da connection
|
||||
string tem mais privilégio que o local, mas o schema `auth` é de `supabase_admin`.
|
||||
A blacklist (`freemium_f3a`) cria **trigger em `auth.users`** e vários objetos são
|
||||
`ALTER FUNCTION ... OWNER TO supabase_admin`. Caminhos:
|
||||
- **SQL Editor do dashboard** roda como `postgres` (costuma conseguir criar trigger
|
||||
em `auth.users` no hosted) — tente por aí primeiro.
|
||||
- Se algum `OWNER TO supabase_admin` ou o trigger em `auth.users` falhar por permissão,
|
||||
rode via a connection string de **serviço** (Settings → Database → Connection string),
|
||||
ou abra ticket de acesso. Os `OWNER TO supabase_admin` podem ser trocados por
|
||||
`OWNER TO postgres` no hosted se necessário (sem perda funcional).
|
||||
|
||||
Aplicar os 5 arquivos `manual/freemium_f*.supabase_admin.sql` **na ordem**, colando
|
||||
no SQL Editor (cada um é uma transação). Verifique a saída sem erro a cada um.
|
||||
|
||||
**Smoke SQL pós-aplicação** (no SQL Editor):
|
||||
```sql
|
||||
select public.slug_disponivel('teste_slug_livre'); -- {ok:true}
|
||||
select public.get_root_redirect(); -- 'landing'
|
||||
-- como saas_admin (logado no dashboard você é postgres; teste a RPC existe):
|
||||
select proname from pg_proc where proname in
|
||||
('auto_provision_free_tenant','processar_pos_signup','slug_disponivel',
|
||||
'saas_list_account_owners','notify_all_devs','is_email_blacklisted','get_root_redirect');
|
||||
-- trigger de limite presente nos schemas:
|
||||
select count(*) from pg_trigger where tgname='enforce_patient_plan_limit';
|
||||
```
|
||||
|
||||
### 3) Auth — dashboard
|
||||
Authentication → **Providers / Email**:
|
||||
- **Confirm email = ON** (equivale ao `enable_confirmations=true` do config.toml local).
|
||||
- **Site URL** = origem do app em produção (ex.: `https://app.seudominio.com`).
|
||||
- **Redirect URLs** — adicionar (magic link + confirmação caem aqui):
|
||||
- `https://app.seudominio.com/onboarding`
|
||||
- `https://app.seudominio.com/auth/login`
|
||||
- `https://app.seudominio.com/**` (se preferir curinga)
|
||||
- SMTP do GoTrue (o que manda confirmação + magic link): garantir que está
|
||||
configurado com um provedor real (não Mailpit) em Authentication → Emails → SMTP.
|
||||
|
||||
### 4) Edge functions — deploy + secrets
|
||||
```bash
|
||||
supabase functions deploy recover-access
|
||||
supabase functions deploy send-welcome-email
|
||||
```
|
||||
`recover-access` usa só envs já injetadas (SUPABASE_URL / SERVICE_ROLE_KEY / ANON_KEY).
|
||||
|
||||
`send-welcome-email` usa um **SMTP de sistema** (defaults = Mailpit local). Em produção,
|
||||
configure os secrets pra um provedor real (pode ser o mesmo do GoTrue):
|
||||
```bash
|
||||
supabase secrets set \
|
||||
SMTP_HOST="smtp.seuprovedor.com" \
|
||||
SMTP_PORT="587" \
|
||||
SMTP_USER="..." \
|
||||
SMTP_PASS="..." \
|
||||
SMTP_FROM="no-reply@seudominio.com" \
|
||||
SMTP_FROM_NAME="Agência PSI" \
|
||||
APP_URL="https://app.seudominio.com"
|
||||
```
|
||||
> É best-effort: se faltar SMTP, o welcome só não envia — o onboarding/login segue.
|
||||
|
||||
### 5) Frontend — rebuild + deploy
|
||||
Build apontando pras envs do hosted (Supabase URL + anon key de produção):
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
Publique o `dist/` no hosting de sempre. (A confirmação de e-mail é resolvida
|
||||
server-side; o front já trata o caso "sem sessão" → tela "confirme seu e-mail".)
|
||||
|
||||
### 6) Smoke test no hosted (fluxo completo)
|
||||
1. `/lp` → o cartão **Grátis** aparece na vitrine.
|
||||
2. **Criar conta grátis** → escolher slug (disponibilidade ao vivo) → enviar.
|
||||
3. Cai na tela **"confirme seu e-mail"** (não loga ainda).
|
||||
4. Abre o e-mail (provedor real) → clica no link → entra → `/onboarding` provisiona →
|
||||
painel do tenant. **Welcome email** chega (se SMTP configurado).
|
||||
5. Cadastrar pacientes até passar do limite → **toast PLAN_LIMIT_REACHED** + Upgrade PRO.
|
||||
6. Logar como **dev (saas_admin)** → `/saas/usuarios` lista o novo cliente com selo
|
||||
"Novo"; o **sino** recebeu "Nova assinatura".
|
||||
7. `/auth/login` → **"Esqueci meu e-mail"** com o slug → recebe magic link, dica mascarada.
|
||||
8. `/saas/app-config` → adicionar um e-mail na **blacklist** → tentar cadastrar com
|
||||
ele → bloqueado. Trocar **root_redirect** e conferir o destino de `/`.
|
||||
|
||||
---
|
||||
|
||||
## Rollback / kill-switch (se algo der errado)
|
||||
- **Confirmação de e-mail**: desligar "Confirm email" no dashboard volta ao signup
|
||||
sem confirmação (mas o signup novo já espera confirmação — prefira corrigir a frente).
|
||||
- **Enforcement de limite**: `DROP TRIGGER enforce_patient_plan_limit ON <schema>.patients`
|
||||
(ou ajustar `plan_features.limits` pra um número alto — vale em runtime, sem deploy).
|
||||
- **Blacklist**: `DROP TRIGGER trg_enforce_email_blacklist ON auth.users;`
|
||||
- **notify devs**: `DROP TRIGGER trg_subscriptions_notify_devs ON public.subscriptions;`
|
||||
- **root_redirect**: `UPDATE public.saas_app_config SET root_redirect='login';` (ou 'landing').
|
||||
- Tudo é **aditivo** — nenhuma tabela/coluna existente foi removida pelo freemium.
|
||||
|
||||
---
|
||||
|
||||
## Checklist rápido
|
||||
- [ ] schema-per-tenant já está no hosted e validada (PRÉ-REQUISITO #0)
|
||||
- [ ] migrations 05/06/07 aplicadas (`supabase db push`)
|
||||
- [ ] 5 manual/freemium_f*.supabase_admin.sql aplicados na ordem
|
||||
- [ ] Confirm email = ON + Site/Redirect URLs + SMTP do GoTrue
|
||||
- [ ] `recover-access` e `send-welcome-email` deployadas
|
||||
- [ ] secrets SMTP do `send-welcome-email` + `APP_URL`
|
||||
- [ ] frontend rebuildado e publicado
|
||||
- [ ] smoke test (8 passos) ✅
|
||||
@@ -0,0 +1,212 @@
|
||||
# Deploy — Migração Schema-per-Tenant (hosted)
|
||||
|
||||
> Runbook de produção da migração RLS-only → schema físico por tenant
|
||||
> (branch `feat/schema-per-tenant`). **Pré-requisito do freemium** (ver
|
||||
> `docs/DEPLOY_FREEMIUM_F4.md`). Gerado em 2026-06-13.
|
||||
>
|
||||
> ⚠️ Esta é a migração **mais delicada do projeto**: envolve migração de DADOS,
|
||||
> exposição dinâmica de schemas no PostgREST e um DROP **irreversível** no fim.
|
||||
> Faça em **janela de manutenção**, com backup fresco, um passo de cada vez.
|
||||
|
||||
---
|
||||
|
||||
## Estratégia de cutover (por que é seguro)
|
||||
|
||||
O desenho **COPIA** os dados (não move) de `public` pros schemas `tenant_<slug>` e
|
||||
só remove o espelho de `public` no **último** passo (F6.3 DROP). Durante a transição,
|
||||
os dados existem nos **dois lugares** → o código antigo (lê `public`) e o novo
|
||||
(lê `tenant_<slug>`) funcionam simultaneamente. Isso permite:
|
||||
|
||||
```
|
||||
estrutura aditiva → migra dados (copia) → sobe código novo → valida → (só então) DROP
|
||||
```
|
||||
|
||||
Se algo der errado **antes do DROP**, é só voltar o frontend/edge pra versão antiga
|
||||
(que lê `public`, intacto). O DROP é o único ponto de não-retorno.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Risco hosted #1 — exposição dinâmica de schemas no PostgREST
|
||||
|
||||
Local: `refresh_pgrst_schemas()` faz `ALTER ROLE authenticator SET pgrst.db_schemas=...`
|
||||
+ `NOTIFY pgrst, 'reload config'` (config in-database, persiste em `pg_db_role_setting`).
|
||||
Um trigger em `public.tenant_schemas` re-roda isso a cada clone/drop.
|
||||
|
||||
No **Supabase hosted** isso precisa ser confirmado:
|
||||
- O hosted suporta a config in-DB do PostgREST, MAS a permissão de `ALTER ROLE
|
||||
authenticator` pode estar restrita à role de serviço. **Teste cedo** (Fase C):
|
||||
rode `select public.refresh_pgrst_schemas();` e cheque se os schemas tenant
|
||||
passam a responder via REST.
|
||||
- Fallback se o `ALTER ROLE` falhar no hosted: adicionar os schemas em
|
||||
**Dashboard → Project Settings → API → Exposed schemas** (lista). Problema: é
|
||||
**estática** — cada signup novo cria um schema que precisaria entrar na lista.
|
||||
Mitigação: manter o trigger in-DB (se funcionar) OU automatizar via Management API.
|
||||
**Decidir isso ANTES de abrir pra signup self-service.**
|
||||
|
||||
> Sem exposição dos schemas tenant, o app novo recebe 404/empty nas tabelas tenant.
|
||||
|
||||
---
|
||||
|
||||
## Inventário (branch `feat/schema-per-tenant`)
|
||||
|
||||
### Migrations (aditivas — rodam como `postgres` / `supabase db push`)
|
||||
Ordem natural por timestamp:
|
||||
```
|
||||
20260612000001_f1_tenants_slug.sql # tenants.slug + generate_tenant_slug + trigger
|
||||
20260612000002_f1_tenant_schema_helpers.sql # tenant_schema_name, tenant_id_for_schema, ...
|
||||
20260612000003_f1_tenant_template.sql # _tenant_template (78 tabelas, views, seeds)
|
||||
20260612000004_f1_clone_drop_functions.sql # clone_tenant_template, drop_tenant_schema, tenant_schemas, channel_routing
|
||||
20260612000005_f1_template_seed_whitelist.sql # limpa seeds órfãos
|
||||
20260612000006_f2_provision_clone.sql # provision_* chamam clone
|
||||
20260612000007_f3_my_tenants_slug.sql # my_tenants() retorna slug
|
||||
20260613000001_f1b_keep_anon_tables_public.sql# 6 tabelas anon ficam em public
|
||||
20260613000002_f5_pgrst_schemas_trigger.sql # trigger pgrst refresh em tenant_schemas
|
||||
20260613000003_f6_0_clone_existing_tenants.sql# clona os tenants já existentes
|
||||
20260613000004_f6_2a_attach_agnostic_triggers.sql # Lote A (triggers agnósticos)
|
||||
```
|
||||
> As 3 migrations `*_freemium_*` / `*_fix_audit_*` (000005/06/07) são do **freemium** —
|
||||
> aplicar só no deploy do freemium (depois). A `fix_audit` pode (e deve) vir já aqui se
|
||||
> for testar provisionamento, mas é inócua antes.
|
||||
|
||||
### Manual `supabase_admin` (privilegiadas — ordem obrigatória)
|
||||
```
|
||||
f5_pgrst_refresh_schemas.supabase_admin.sql # refresh_pgrst_schemas (ALTER ROLE authenticator)
|
||||
f6_2b_schema_aware_triggers.supabase_admin.sql# Lote B (14 trigger funcs schema-aware)
|
||||
f6_2c_notifications_split.supabase_admin.sql # Lote C (notifications_sistema + triggers)
|
||||
f6_2d_user_rpcs.supabase_admin.sql # Lote D (14 user RPCs + _tenant_route)
|
||||
f6_2e_cron_rpcs.supabase_admin.sql # Lote E (cron RPCs + _tenant_schema_unchecked)
|
||||
f6_2f_anon_token_rpcs.supabase_admin.sql # Lote F (anon/token RPCs)
|
||||
f6_2g_sql_to_plpgsql.supabase_admin.sql # Lote G (5 SQL→plpgsql)
|
||||
f6_2h_clone_wiring.supabase_admin.sql # wiring: tenants novos nascem com triggers
|
||||
f6_4_saas_admin_rpcs.supabase_admin.sql # SaaS-admin RPCs (feriados/notif/whatsapp)
|
||||
# DADOS:
|
||||
f6_1_migrate_data.supabase_admin.sql # cutover: COPIA dados public→schemas
|
||||
# DROP (último, gated):
|
||||
f6_3_drop_public_tenant_tables.supabase_admin.sql # 🛑 ponto de não-retorno
|
||||
```
|
||||
Rollback do DROP documentado em `database-novo/manual/f6_3_ROLLBACK.md`.
|
||||
|
||||
### Frontend / Edge (vão no rebuild + deploy)
|
||||
- `src/lib/supabase/tenantClient.js`, `src/composables/useTenantDb.js`, `tenantStore` (slug/schema getters), `notificationStore` (dual-source), e os `supabase.from(...)` → `tenantDb().from(...)` espalhados.
|
||||
- `supabase/functions/_shared/tenant.ts` + os webhooks/crons que passaram a rotear por schema.
|
||||
|
||||
### Config
|
||||
- `supabase/config.toml [api] schemas` permanece `["public","graphql_public"]` — os
|
||||
tenant são expostos **dinamicamente** (não na lista). Confirmar no hosted (Risco #1).
|
||||
|
||||
---
|
||||
|
||||
## Passo a passo
|
||||
|
||||
### Fase 0 — Pré-flight
|
||||
- [ ] **Backup completo** do hosted (dashboard → Database → Backups, ou `pg_dump`).
|
||||
- [ ] Confirmar que o hosted está no baseline (branch `main`/RLS) e estável.
|
||||
- [ ] Janela de manutenção combinada (a Fase D é cutover de dados).
|
||||
- [ ] Ter a connection string de **serviço** em mãos (algumas etapas exigem role elevada).
|
||||
|
||||
### Fase A — Estrutura aditiva (migrations)
|
||||
Aplicar as 11 migrations `20260612*`/`20260613000001..000004` (e a `fix_audit` 000006).
|
||||
Via `supabase db push` (com a branch linkada) ou colando no **SQL Editor** na ordem.
|
||||
São **aditivas** — criam slug, helpers, `_tenant_template`, funções de clone, registry
|
||||
`tenant_schemas`, e **clonam os tenants existentes** (000003 = f6_0). Não tocam dados.
|
||||
|
||||
**Verificar:**
|
||||
```sql
|
||||
select count(*) from public.tenant_schemas; -- = nº de tenants
|
||||
select tenant_schema_name((select id from tenants limit 1)); -- 'tenant_<slug>'
|
||||
select count(*) from information_schema.schemata where schema_name like 'tenant_%';
|
||||
```
|
||||
|
||||
### Fase B — Funções/triggers privilegiados (manual)
|
||||
Aplicar, **na ordem**, via connection string de serviço (ou SQL Editor se permitir):
|
||||
`f6_2b → f6_2c → f6_2d → f6_2e → f6_2f → f6_2g → f6_2h → f6_4`.
|
||||
(São CREATE OR REPLACE / idempotentes.)
|
||||
|
||||
> Vários fazem `ALTER FUNCTION ... OWNER TO supabase_admin`. Se a role disponível no
|
||||
> hosted não permitir, troque pra `OWNER TO postgres` (sem perda funcional) — mesma
|
||||
> nota do runbook do freemium.
|
||||
|
||||
### Fase C — PostgREST dinâmico (CRÍTICO — testar cedo)
|
||||
Aplicar `f5_pgrst_refresh_schemas.supabase_admin.sql` e disparar:
|
||||
```sql
|
||||
select public.refresh_pgrst_schemas(); -- seta pgrst.db_schemas + NOTIFY reload
|
||||
```
|
||||
**Teste real:** via REST (anon/auth key do hosted), bater numa tabela de um schema tenant
|
||||
(ex.: `GET /rest/v1/patients` com header `Accept-Profile: tenant_<slug>`). Deve responder
|
||||
(200/empty), não 404 "schema not exposed".
|
||||
- ✅ funcionou → seguir.
|
||||
- ❌ falhou (`ALTER ROLE authenticator` negado) → aplicar o **fallback** do Risco #1
|
||||
(Exposed schemas no dashboard) antes de prosseguir, e planejar a automação por signup.
|
||||
|
||||
### Fase D — Migração de DADOS (cutover, janela de manutenção)
|
||||
Aplicar `f6_1_migrate_data.supabase_admin.sql` (precisa `session_replication_role=replica`
|
||||
→ role de serviço). **COPIA** os dados public→schemas (idempotente, ON CONFLICT DO NOTHING).
|
||||
|
||||
**Verificar paridade** (por tabela/tenant — exemplo com `patients`):
|
||||
```sql
|
||||
-- public (origem) vs schema (destino) devem bater por tenant
|
||||
select t.slug,
|
||||
(select count(*) from public.patients p where p.tenant_id=t.id) as em_public,
|
||||
-- ajuste o schema dinamicamente / rode por tenant:
|
||||
null as em_schema
|
||||
from public.tenants t order by t.slug;
|
||||
-- e por schema: select count(*) from tenant_<slug>.patients;
|
||||
```
|
||||
Repetir o spot-check nas tabelas de maior volume (conversation_messages, financial_records, agenda_eventos).
|
||||
|
||||
### Fase E — Frontend + Edge (sobe o código novo)
|
||||
- Deploy das **edge functions** alteradas (`supabase functions deploy <nome>` pras que
|
||||
mudaram: webhooks twilio/evolution inbound, crons de fila, `_shared/tenant.ts` é embutido).
|
||||
- **Rebuild + publish do frontend** da branch (agora `tenantDb().from(...)` lê os schemas).
|
||||
- A partir daqui o app **lê/escreve nos schemas tenant**. Como os dados foram copiados na
|
||||
Fase D e `public` ainda existe, nada quebra mesmo se algum ponto antigo escapar.
|
||||
|
||||
### Fase F — Smoke test (app no modelo novo)
|
||||
- [ ] Login em 2-3 tenants distintos → agenda, pacientes, financeiro, conversas carregam.
|
||||
- [ ] Criar/editar registros → conferir que gravam em `tenant_<slug>` (não em `public`).
|
||||
- [ ] Notificações (sino) — dual-source (tenant + `notifications_sistema`).
|
||||
- [ ] Webhook inbound (twilio/evolution) grava no schema certo (roteamento por canal).
|
||||
- [ ] Crons (fila de notificação/email) varrem os tenants.
|
||||
- [ ] Provisionar um tenant NOVO de teste → nasce com schema + triggers (wiring f6_2h).
|
||||
- [ ] **Deixar rodando alguns dias** com os dados ainda espelhados em public (rede de segurança).
|
||||
|
||||
### Fase G — F6.3 DROP (🛑 PONTO DE NÃO-RETORNO)
|
||||
**Só depois** de F validada por dias + sem incidentes. Sequência:
|
||||
1. **Backup fresco obrigatório** (o header do f6_3 traz o `pg_dump --schema=public`).
|
||||
2. Reler `database-novo/manual/f6_3_ROLLBACK.md`.
|
||||
3. Aplicar `f6_3_drop_public_tenant_tables.supabase_admin.sql` (role de serviço):
|
||||
pré-flight asserts → 2 FK→coluna solta → drop 9 views → DROP CASCADE 78 tabelas public.
|
||||
4. Smoke test final. A partir daqui `public` não tem mais as tabelas tenant — só schemas.
|
||||
|
||||
---
|
||||
|
||||
## Rollback por fase
|
||||
- **Fases A–C** (estrutura/funções/pgrst): aditivas. Reverter = dropar os schemas/funções
|
||||
novos; `public` intacto, app antigo segue. Sem perda.
|
||||
- **Fase D** (dados): só copiou; reverter = ignorar/limpar schemas. `public` é a verdade.
|
||||
- **Fase E** (código): **rollback = redeploy do frontend/edge da versão antiga** (lê public).
|
||||
Esse é o botão de pânico até o DROP.
|
||||
- **Fase G** (DROP): irreversível sem restore. Rollback = restaurar do backup (ver
|
||||
`f6_3_ROLLBACK.md`). Por isso só após dias de validação.
|
||||
|
||||
---
|
||||
|
||||
## Ordem geral dos dois épicos
|
||||
```
|
||||
schema-per-tenant Fases A–F → (rodar dias) → schema-per-tenant Fase G (DROP)
|
||||
└─ freemium (DEPLOY_FREEMIUM_F4.md) pode entrar
|
||||
logo após as Fases A–F (não depende do DROP)
|
||||
```
|
||||
> O freemium **não** depende do DROP (F6.3) — depende da infra (Fases A–F). Dá pra subir
|
||||
> o freemium assim que o schema-per-tenant estiver validado no hosted, mantendo o espelho
|
||||
> em public como rede de segurança, e fazer o DROP com calma depois.
|
||||
|
||||
## Checklist
|
||||
- [ ] Fase 0: backup + janela + baseline confirmado
|
||||
- [ ] Fase A: 11 migrations aplicadas + verificação
|
||||
- [ ] Fase B: 9 manual (b→4) na ordem
|
||||
- [ ] Fase C: pgrst dinâmico testado via REST (ou fallback decidido)
|
||||
- [ ] Fase D: f6_1 + paridade de contagens conferida
|
||||
- [ ] Fase E: edges + frontend novos publicados
|
||||
- [ ] Fase F: smoke test + dias de soak
|
||||
- [ ] Fase G: backup fresco → DROP → smoke final
|
||||
@@ -9,8 +9,10 @@
|
||||
| Item | Quantidade |
|
||||
|---|---|
|
||||
| Tabelas em `public` (BASE TABLE) | 137 |
|
||||
| **Tenant-scoped** (vão pra `tenant_<x>`) — decidido Q3 | **84** |
|
||||
| **Globais** (ficam em `public`) | **53** |
|
||||
| **Tenant-scoped** (vão pra `tenant_<x>`) — Q3 + Q5 | **78** |
|
||||
| **Globais** (ficam em `public`) | **59** |
|
||||
|
||||
> **Atualização 2026-06-13 (Q5 — roteamento anon):** 6 tabelas anon-facing voltaram pra `public` (decisão opção C): `patient_intake_requests`, `patient_invites`, `patient_invite_attempts`, `document_share_links`, `agendador_configuracoes`, `agendador_solicitacoes`. Fluxos anônimos identificam o tenant por token/slug e não resolvem o schema físico — mantê-las em public com RLS por token evita um índice global de tokens. Removidas do `_tenant_template` na migration `20260613000001_f1b` (template v2, 78 tabelas). FK a observar na F6: `public.document_share_links.documento_id → documents` (tenant) vira coluna solta no drop.
|
||||
| Funções que referenciam tabelas-tenant | **66** (não 29 — o aviso do blueprint se confirmou) |
|
||||
| Views que referenciam tabelas-tenant | 6 |
|
||||
| FKs global→tenant problemáticas | **1** (`whatsapp_credits_transactions.conversation_message_id`) |
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# Handoff — Onde paramos, Riscos e Passo a passo de teste
|
||||
|
||||
> Estado consolidado dos dois épicos grandes (schema-per-tenant + freemium/PLG).
|
||||
> Última atualização: 2026-06-13. Branch de trabalho: **`feat/schema-per-tenant`** (base)
|
||||
> e **`feat/freemium-plg`** (empilhada — contém tudo). `main` segue no modelo RLS antigo.
|
||||
|
||||
---
|
||||
|
||||
## 1. Onde paramos (estado atual)
|
||||
|
||||
### Branches
|
||||
- `main` — modelo RLS-only (produção atual). Recebeu só F0/F1/F2 aditivos da schema-per-tenant.
|
||||
- `feat/schema-per-tenant` — migração completa F0→F6.2 + wiring + F6.4. **F6.3 DROP NÃO aplicado.**
|
||||
- `feat/freemium-plg` — **ramificada da schema-per-tenant**, contém TODO o freemium (F1/F2/F3) +
|
||||
os dois runbooks de deploy + este handoff. **É a branch a deployar** (tem os dois épicos).
|
||||
|
||||
### Banco LOCAL (Docker `supabase_db_agenciapsi-primesakai`)
|
||||
Está no estado **schema-per-tenant + freemium aplicado**:
|
||||
- Schemas `tenant_<slug>` existem (9 tenants clonados) + dados COPIADOS (espelho ainda em `public`).
|
||||
- Todas as migrations + todos os `manual/*.supabase_admin.sql` aplicados, **EXCETO o F6.3 DROP**.
|
||||
- `enable_confirmations` está `true` no `config.toml` mas **só ativa após reiniciar o stack**.
|
||||
|
||||
### Schema-per-tenant — ✅ feito / ⏳ pendente
|
||||
- ✅ Estrutura, helpers, template, clone/drop, provisionamento, 66 funções migradas, dados dos 9 tenants copiados+verificados, PostgREST dinâmico (local), frontend/edge roteando por schema.
|
||||
- ⏳ **F6.3 DROP** (remove o espelho em `public`) — preparado, NÃO aplicado. Aguarda teste no browser + OK + backup fresco. (task #7)
|
||||
- 📄 Deploy: `docs/DEPLOY_SCHEMA_PER_TENANT.md`.
|
||||
|
||||
### Freemium/PLG — ✅ feito / ⏳ pendente
|
||||
- ✅ **F1** limite de pacientes (trigger runtime + toast + Upgrade PRO).
|
||||
- ✅ **F2** self-service (confirmação de e-mail, RPCs idempotentes, signup reescrito, /onboarding,
|
||||
welcome email, vitrine "Grátis") + **fix de regressão** do audit em `tenant_members`.
|
||||
- ✅ **F3** 4 extras (blacklist, /saas/usuarios + notify devs, esqueci-email, root_redirect).
|
||||
- ⏳ **F4 deploy** (hosted) — runbook em `docs/DEPLOY_FREEMIUM_F4.md`. Não deployado.
|
||||
- ⏳ **Teste local ponta-a-ponta** — exige reiniciar o stack (seção 3).
|
||||
|
||||
### Tudo commitado e pushado em `feat/freemium-plg`. Nada pendente no working tree
|
||||
(só `.env`/dashboard/`.claude` locais, intencionalmente fora).
|
||||
|
||||
---
|
||||
|
||||
## 2. Riscos (todos)
|
||||
|
||||
### 🔴 Críticos
|
||||
1. **PostgREST dinâmico no hosted** — a exposição de schemas tenant usa `ALTER ROLE
|
||||
authenticator SET pgrst.db_schemas`. Pode ser restrito no hosted. Se falhar, o app novo
|
||||
recebe 404 nas tabelas tenant. **Testar cedo** (Fase C do runbook); fallback = Exposed
|
||||
schemas no dashboard (estático → problema com signup self-service). **Decidir antes de
|
||||
abrir signup.**
|
||||
2. **F6.3 DROP é irreversível** — remove as tabelas tenant de `public`. Só após dias de soak
|
||||
no modelo novo + backup fresco. Rollback = restore (`f6_3_ROLLBACK.md`).
|
||||
3. **Confirmação de e-mail + SMTP do GoTrue (hosted)** — com `Confirm email = ON`, se o SMTP
|
||||
do GoTrue não estiver configurado com provedor real, **ninguém consegue logar** (o link de
|
||||
confirmação não chega). Configurar SMTP no dashboard ANTES de ligar a confirmação.
|
||||
|
||||
### 🟠 Importantes
|
||||
4. **Manual files fora do fluxo do `db.cjs`** — os `manual/*.supabase_admin.sql` NÃO são
|
||||
aplicados pelo `db.cjs migrate`. São aplicados à mão (psql como `supabase_admin`). Fácil
|
||||
esquecer um → função/trigger ausente. Os runbooks listam a ordem.
|
||||
5. **`postgres` não é superuser no stack local** — por isso vários objetos são `supabase_admin`.
|
||||
No hosted o `postgres` é mais privilegiado, mas o schema `auth` é de `supabase_admin`:
|
||||
o trigger da blacklist em `auth.users` e os `OWNER TO supabase_admin` podem precisar de
|
||||
SQL Editor ou troca pra `OWNER TO postgres`.
|
||||
6. **`config.toml` é gitignored** — `enable_confirmations=true` está só no arquivo local
|
||||
(não versionado). No hosted a confirmação vai pelo **dashboard** (Auth → Confirm email).
|
||||
7. **Migração de dados (cutover)** — `f6_1` COPIA; conferir **paridade de contagens** por
|
||||
tenant/tabela antes de confiar (e antes do DROP).
|
||||
8. **Edge functions novas precisam deploy** — `recover-access` e `send-welcome-email` (freemium)
|
||||
+ as edges de roteamento por schema (schema-per-tenant). Esquecer = esqueci-email/welcome/
|
||||
webhooks quebram.
|
||||
9. **Slug é IMUTÁVEL** — = nome do schema físico. Uma vez escolhido, não muda (trava em 3
|
||||
camadas). UX do signup deixa claro, mas é definitivo.
|
||||
|
||||
### 🟡 Menores / a observar
|
||||
10. **Enforcement de limite é por-linha** (BEFORE INSERT) — um bulk insert de pacientes numa
|
||||
única statement pode passar marginalmente do limite (cada linha não vê as anteriores da
|
||||
mesma statement). Na prática o cadastro é 1 a 1; ok.
|
||||
11. **notify_all_devs dispara a cada subscription** (inclui a free do auto_provision) — em
|
||||
escala, muitos avisos no sino do dev. Intencional; reavaliar se incomodar.
|
||||
12. **send-welcome-email usa SMTP de sistema** (separado do canal do tenant) — precisa secrets
|
||||
no hosted; é best-effort (falha não bloqueia login).
|
||||
13. **auto_provision idempotente** retorna o 1º tenant ativo se o user já tem algum — usuário
|
||||
multi-tenant que se cadastra de novo não ganha tenant novo (esperado).
|
||||
14. **Local vs main inconsistente** — o banco local está no modelo novo; o código da `main` é
|
||||
RLS. Se fizer `git checkout main`, o app antigo ainda funciona porque `public` tem as tabelas
|
||||
(até o DROP). Não rodar `main` esperando o modelo novo (e vice-versa).
|
||||
|
||||
---
|
||||
|
||||
## 3. Passo a passo — como testar TUDO (local)
|
||||
|
||||
> Pré: Docker do Supabase rodando (portas 643xx). Frontend via `npm run dev`.
|
||||
|
||||
### Passo 0 — Ativar a confirmação de e-mail
|
||||
A confirmação só vale após reiniciar o stack (o volume do banco **persiste** — nada se perde):
|
||||
```bash
|
||||
supabase stop && supabase start # se falhar com containers unhealthy, rode start de novo (transiente)
|
||||
```
|
||||
Conferir no Studio/Mailpit que está de pé. (Se preferir NÃO testar confirmação agora, pule —
|
||||
o front trata os dois casos; mas o fluxo "confirme e-mail" só aparece com isto ligado.)
|
||||
|
||||
> 🔴 **GOTCHA OBRIGATÓRIO pós-restart** — a GUC `pgrst.db_schemas` (exposição dos schemas
|
||||
> tenant no PostgREST) **NÃO sobrevive ao `supabase stop/start`** (o `start` reseta a role
|
||||
> `authenticator`). Sem isso o app dá **404** em todas as tabelas tenant. Rodar SEMPRE após start:
|
||||
> ```bash
|
||||
> docker exec -i supabase_db_agenciapsi-primesakai psql -U supabase_admin -h 127.0.0.1 -d postgres \
|
||||
> -c "select public.refresh_pgrst_schemas();"
|
||||
> ```
|
||||
> (Confirma exposição: `curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:64321/rest/v1/patients?limit=1" -H "Accept-Profile: tenant_<slug>"` deve dar 200.)
|
||||
|
||||
### Passo 1 — Schema-per-tenant: tenants EXISTENTES ainda funcionam
|
||||
1. `npm run dev`, logar num tenant existente (ex.: clínica Bem-Estar / um terapeuta).
|
||||
2. Abrir **Agenda, Pacientes, Financeiro, Conversas** → tudo carrega (lendo de `tenant_<slug>`).
|
||||
3. Criar/editar um registro (ex.: um bloqueio na agenda, editar um paciente) → salva sem erro.
|
||||
4. Sino de notificações abre (dual-source tenant + sistema).
|
||||
> Se algo não carregar, é sinal de roteamento de schema — anotar a tela/erro.
|
||||
|
||||
### Passo 2 — Freemium: signup self-service NOVO (o fluxo principal)
|
||||
1. Deslogar. Ir em **`/lp`** → conferir o cartão **"Grátis"** na vitrine.
|
||||
2. **Criar conta grátis** → escolher tipo (terapeuta/clínica) + seu nome + nome do negócio +
|
||||
**slug** (ver a checagem de disponibilidade ao vivo) + e-mail + senha.
|
||||
3. Submeter → cai na tela **"Confirme seu e-mail"** (NÃO loga ainda).
|
||||
4. Abrir o **Mailpit** (caixa de e-mail local) → achar o e-mail de confirmação → clicar no link.
|
||||
5. Voltar/entrar em **`/auth/login`** → logar → cai em **`/onboarding`** → "Preparando seu
|
||||
ambiente…" → provisiona → entra no painel do tenant novo.
|
||||
6. Conferir no Mailpit o **e-mail de boas-vindas** (welcome — best-effort).
|
||||
7. Conferir que o schema `tenant_<slug-escolhido>` foi criado (Studio) e que você é master.
|
||||
|
||||
### Passo 3 — Limite do plano gratuito
|
||||
1. No tenant gratuito recém-criado (ou num clinic_free existente), cadastrar pacientes.
|
||||
2. Ao passar do limite (clínica=30, terapeuta=20) → aparece o **toast "Limite do plano
|
||||
gratuito"** com botão **"Fazer upgrade"** (não o erro cru).
|
||||
3. Conferir o botão **"Upgrade PRO"** dourado no topbar (visível porque o plano é free).
|
||||
|
||||
### Passo 4 — SaaS / dev (logar como saas_admin)
|
||||
1. **`/saas/usuarios`** → o cliente novo aparece com selo **"Novo"** (verde, 24h), com slug/e-mail/plano.
|
||||
2. **Sino do dev** → recebeu **"Nova assinatura"** (do provisionamento).
|
||||
3. **`/saas/app-config`**:
|
||||
- Adicionar um **e-mail na blacklist** (ex.: `bloqueado@x.com`). Depois, deslogar e tentar
|
||||
**criar conta** com ele → bloqueado de verdade.
|
||||
- Testar **`@dominio.com`** (domínio inteiro).
|
||||
- Trocar **root_redirect** (landing↔login) e abrir **`/`** deslogado → confere o destino.
|
||||
|
||||
### Passo 5 — Esqueci meu e-mail
|
||||
1. **`/auth/login`** → **"Esqueci meu e-mail"** → digitar o **slug** do tenant criado no Passo 2.
|
||||
2. Recebe a confirmação com a **dica mascarada** (jo****@gm****.com) e um **magic link** no Mailpit.
|
||||
3. Clicar no magic link → entra. (O e-mail real nunca aparece na tela.)
|
||||
4. ⚠️ Edge functions locais: precisam estar servidas (`supabase functions serve` ou o runtime
|
||||
do stack). Se o esqueci-email/welcome não responder, é a edge não estar de pé localmente.
|
||||
|
||||
### Passo 6 — Pegadinha #4 (sino ao trocar de usuário)
|
||||
1. Logado como user A, com notificações no sino → **logout**.
|
||||
2. Logar como user B → o sino **não** mostra notificações do A (foi resetado no logout).
|
||||
|
||||
### Passo 7 (opcional, destrutivo, só quando confiante) — preparar o DROP
|
||||
NÃO aplicar agora. Quando tudo acima estiver validado por dias: seguir a **Fase G** do
|
||||
`docs/DEPLOY_SCHEMA_PER_TENANT.md` (backup fresco → `f6_3_drop_public_tenant_tables`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Atalhos / referências
|
||||
- Runbooks: `docs/DEPLOY_SCHEMA_PER_TENANT.md`, `docs/DEPLOY_FREEMIUM_F4.md`.
|
||||
- Rollback do DROP: `database-novo/manual/f6_3_ROLLBACK.md`.
|
||||
- Migrations: `database-novo/migrations/` (aplicar via `node database-novo/db.cjs migrate`).
|
||||
- Manual privilegiados: `database-novo/manual/*.supabase_admin.sql` (aplicar como `supabase_admin`).
|
||||
- Wiki: `Obsidian/Brain/wiki/Migracao Schema-per-Tenant.md` e `Obsidian/Brain/wiki/Freemium PLG.md`.
|
||||
- Portas locais: API 64321 · DB 64322 · Studio 64323 (stack shiftada +10000).
|
||||
+150
-338
@@ -1,338 +1,150 @@
|
||||
Prompt: Refactor Multi-Tenant para Schema-per-Tenant em Supabase
|
||||
Contexto e objetivo
|
||||
|
||||
Estou migrando meu sistema multi-tenant de RLS-only com tenant_id em cada tabela para schema-per-tenant (tenant_<slug>
|
||||
com clones físicos da estrutura). Quero isolamento físico das tabelas que pertencem a um tenant, mantendo em public
|
||||
apenas tabelas globais (auth.users, profiles, tenants, planos SaaS, notificações de sistema, etc.).
|
||||
|
||||
Já fiz esse refactor num projeto irmão (Vue 3 + Supabase + Postgres 17). Quero que você execute o mesmo aqui,
|
||||
considerando as lições que aprendi.
|
||||
|
||||
Antes de começar — varredura obrigatória
|
||||
|
||||
Não confie na lista que o usuário (ou um amigo programador) te entregar. Verifique tudo:
|
||||
|
||||
1. Liste TODAS as tabelas em public e classifique cada uma como "tenant-scoped" ou "global". Use a heurística: tem
|
||||
coluna tenant_id? É candidata a tenant-scoped. Mas reveja caso a caso — algumas globais (tenant_features,
|
||||
tenant_audit_log, support_messages) também têm tenant_id como FK e devem ficar em public.
|
||||
SELECT table_name,
|
||||
EXISTS(SELECT 1 FROM information_schema.columns c
|
||||
WHERE c.table_schema='public' AND c.table_name=t.table_name
|
||||
AND c.column_name='tenant_id') AS has_tenant_id
|
||||
FROM information_schema.tables t
|
||||
WHERE table_schema='public' AND table_type='BASE TABLE'
|
||||
ORDER BY table_name;
|
||||
2. Liste TODAS as funções em public que referenciam essas tabelas-tenant. Não confie em listas pré-feitas — eu recebi
|
||||
"29 funções" e eram na verdade 52. Use:
|
||||
WITH tenant_tabs AS (SELECT unnest(ARRAY[/* sua lista */]) AS tab)
|
||||
SELECT DISTINCT p.proname, p.prokind, l.lanname
|
||||
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
JOIN pg_language l ON l.oid = p.prolang
|
||||
CROSS JOIN tenant_tabs t
|
||||
WHERE n.nspname='public'
|
||||
AND pg_get_functiondef(p.oid) ~ ('\m' || t.tab || '\M')
|
||||
ORDER BY 1;
|
||||
3. Liste FKs cross-schema (de tabelas que vão ficar em public, apontando pras que vão sair). Se houver, planeje
|
||||
cuidado especial.
|
||||
4. Liste todas as edge functions e grep cada uma por .from('<tabela_tenant>').
|
||||
5. Liste as policies RLS que usam funções a refatorar — vão precisar ser dropadas/recriadas.
|
||||
|
||||
Plano de execução em fases
|
||||
|
||||
F0 — Categorização (não codar nada ainda)
|
||||
|
||||
Faça as listagens acima. Salve em documento markdown na raiz: docs/F0_categorizacao.md. Conte tabelas, funções, edge
|
||||
functions, FKs cross-schema, policies dependentes. Pause e mostre pro usuário antes de seguir.
|
||||
|
||||
F1 — Template + helpers
|
||||
|
||||
- Crie schema _tenant_template com TODAS as tabelas tenant-scoped clonadas SEM a coluna tenant_id (compostos unique
|
||||
também perdem tenant_id). Inclua índices, FKs locais, sequences, constraints.
|
||||
- Crie helpers em public:
|
||||
- tenant_schema_name(slug text) → text (IMMUTABLE) — converte slug→nome de schema sanitizado.
|
||||
- tenant_schema_for(tenant_id uuid) → text (STABLE) — busca slug e devolve schema.
|
||||
- tenant_id_for_schema(schema text) → uuid (STABLE) — inverso. CRÍTICO pra triggers que precisam descobrir o
|
||||
tenant_id (porque a coluna não existe mais nas tabelas tenant).
|
||||
- current_tenant_schema() → text (STABLE SECURITY DEFINER) — lê profiles.tenant_id do auth.uid() e devolve o schema
|
||||
dele.
|
||||
- clone_tenant_template(slug) → void (SECURITY DEFINER) — clona o template pra um schema novo.
|
||||
- drop_tenant_schema(tenant_id) → void — proteção: assert que target LIKE 'tenant_%' antes de DROP CASCADE.
|
||||
|
||||
F2 — Provisionamento
|
||||
|
||||
- Adapte sua função/edge provision_from_intent (ou equivalente) pra chamar clone_tenant_template(slug) quando criar
|
||||
tenant novo.
|
||||
- Confirme que policies padrão são criadas no schema clonado (uma policy tenant_member_full TO authenticated filtrando
|
||||
por profiles.tenant_id = '<id-do-tenant>').
|
||||
|
||||
F3 — Frontend: composable de acesso tenant
|
||||
|
||||
- Crie useTenantDb.js:
|
||||
export function useTenantDb() {
|
||||
const { perfil } = useAuth();
|
||||
const schemaName = computed(() => tenantSchemaName(perfil.value?.tenant_slug));
|
||||
const isReady = computed(() => Boolean(schemaName.value));
|
||||
function db() {
|
||||
if (!schemaName.value) throw new Error('tenant não disponível');
|
||||
return supabase.schema(schemaName.value);
|
||||
}
|
||||
return { db, schemaName, isReady };
|
||||
}
|
||||
- Faça find/replace amplo: supabase.from('<tenant_table>') → db().from('<tenant_table>') em todas as
|
||||
views/components/composables que tocam tabelas tenant.
|
||||
|
||||
F4 — Edge functions
|
||||
|
||||
Padrão pra qualquer edge function que precisa acessar tabela tenant:
|
||||
const userClient = createClient(SUPABASE_URL, ANON_KEY, {
|
||||
global: { headers: { Authorization: authHeader } }
|
||||
});
|
||||
const { data: tenantSchema } = await userClient.rpc('current_tenant_schema');
|
||||
const tenantDb = userClient.schema(tenantSchema as string);
|
||||
await tenantDb.from('oficios').update(...).eq(...);
|
||||
Tabelas globais (profiles, tenants, addon_*, support_*, etc.) seguem usando userClient.from(...) direto.
|
||||
|
||||
F5 — Expor schemas no PostgREST
|
||||
|
||||
Edite supabase/config.toml:
|
||||
[api]
|
||||
schemas = ["public", "graphql_public", "tenant_<slug1>", "tenant_<slug2>", ...]
|
||||
extra_search_path = ["public", "extensions"]
|
||||
Restart Supabase. Toda criação de tenant novo precisa atualizar este array e restartar PostgREST — automatize via
|
||||
migration que regenera config.toml, ou aceite gerenciamento manual.
|
||||
|
||||
F6 — Rewrite funções + drop tabelas em public (a fase mais perigosa)
|
||||
|
||||
Divida em lotes pequenos e teste cada um:
|
||||
|
||||
Lote 1 — split de notifications
|
||||
|
||||
Caso especial crítico. Antes do split, identifique:
|
||||
- Tipos de notif que cruzam tenants (dev recebe de todos os tenants, support_reply enviado pelo dev pro tenant,
|
||||
system_alert global).
|
||||
- Tipos que são puramente tenant-local (voucher_gerado, os_atribuida, oficio_assinado, prazos).
|
||||
|
||||
Decisão estrutural: notifications precisa virar duas tabelas:
|
||||
- tenant_<slug>.notifications — locais do tenant.
|
||||
- public.notifications_sistema — cross-tenant (SaaS pro tenant, ou pro dev).
|
||||
|
||||
Migration faz:
|
||||
1. Cria public.notifications_sistema (mesma estrutura + RLS própria + adiciona à publication realtime).
|
||||
2. Migra dados: INSERT INTO notifications_sistema SELECT ... WHERE type IN (cross_tenant_types), depois loop por
|
||||
tenant INSERT INTO tenant_X.notifications SELECT ... WHERE tenant_id = X AND type IN (local_types).
|
||||
3. Refatora todas as funções de notif (notify_user, notify_user_sistema, notify_tenant_admins, notify_all_devs,
|
||||
mark/archive_*) — duas variantes (_sistema_ em public, outras EXECUTE format pro schema tenant).
|
||||
4. DROP TABLE public.notifications.
|
||||
5. Frontend useNotifications.js: lê das duas fontes em paralelo, mescla por created_at DESC, cada item ganha campo
|
||||
_origem: 'tenant' | 'sistema'. Realtime em 2 canais. markRead/archive roteiam pra RPC correta via _origem.
|
||||
|
||||
Lote 2-4 — refator das demais funções
|
||||
|
||||
Padrão pra TRIGGER em tabela tenant:
|
||||
CREATE OR REPLACE FUNCTION public.trg_xxx() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE v_tenant_id uuid;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA); -- só se precisar
|
||||
-- ... lógica com tabelas tenant SEM prefixo `public.` ...
|
||||
END $$;
|
||||
|
||||
Padrão pra RPC chamada por user logado em um tenant:
|
||||
CREATE OR REPLACE FUNCTION public.minha_rpc(...) RETURNS ...
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE v_schema text := public.current_tenant_schema();
|
||||
BEGIN
|
||||
IF v_schema IS NULL THEN RAISE EXCEPTION 'sem tenant'; END IF;
|
||||
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||
-- ... lógica ...
|
||||
END $$;
|
||||
|
||||
Padrão pra RPC global (cron, dev, varre múltiplos tenants):
|
||||
FOR t_row IN SELECT id, slug FROM public.tenants WHERE ativo = true LOOP
|
||||
v_schema := public.tenant_schema_name(t_row.slug);
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = v_schema) THEN CONTINUE; END IF;
|
||||
EXECUTE format('UPDATE %I.tabela ...', v_schema);
|
||||
END LOOP;
|
||||
|
||||
Padrão pra função que escreve no schema de OUTRO tenant (notify_user com p_tenant_id, etc.):
|
||||
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||
IF v_schema NOT LIKE 'tenant_%' THEN RETURN; END IF;
|
||||
EXECUTE format('INSERT INTO %I.notifications (...) VALUES ($1, $2, ...)', v_schema)
|
||||
USING ...;
|
||||
|
||||
Lote 4.5 — migração de DADOS (esqueci de avisar primeiro, vai se ferrar)
|
||||
|
||||
ESSE É O ERRO MAIS COMUM: o template clona estrutura, mas você esquece dos DADOS. Depois descobre que
|
||||
tenant_sindspam.os está vazio porque você nunca migrou. Faça uma migration que:
|
||||
|
||||
SET session_replication_role = replica; -- desabilita FK checks
|
||||
DO $$
|
||||
DECLARE
|
||||
tenant_id_target uuid := '...';
|
||||
tenant_schema text := 'tenant_...';
|
||||
tabs text[] := ARRAY[/* lista */];
|
||||
t text;
|
||||
v_cols text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY tabs LOOP
|
||||
-- Lista colunas do schema tenant (sem tenant_id já)
|
||||
SELECT string_agg(quote_ident(column_name), ', ' ORDER BY ordinal_position)
|
||||
INTO v_cols
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = tenant_schema AND table_name = t;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema='public' AND table_name=t AND column_name='tenant_id') THEN
|
||||
EXECUTE format(
|
||||
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I WHERE tenant_id = %L ON CONFLICT DO NOTHING',
|
||||
tenant_schema, t, v_cols, v_cols, t, tenant_id_target);
|
||||
ELSE
|
||||
EXECUTE format(
|
||||
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I ON CONFLICT DO NOTHING',
|
||||
tenant_schema, t, v_cols, v_cols, t);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
-- Reset sequences:
|
||||
FOR r IN SELECT t.table_name, c.column_name FROM information_schema.tables t
|
||||
JOIN information_schema.columns c ON c.table_schema=t.table_schema AND c.table_name=t.table_name
|
||||
WHERE t.table_schema=tenant_schema AND c.data_type='bigint' AND c.column_default LIKE 'nextval(%' LOOP
|
||||
v_seq := pg_get_serial_sequence(format('%I.%I', tenant_schema, r.table_name), r.column_name);
|
||||
EXECUTE format('SELECT setval(%L, COALESCE((SELECT MAX(%I) FROM %I.%I), 0))',
|
||||
v_seq, r.column_name, tenant_schema, r.table_name);
|
||||
END LOOP;
|
||||
SET session_replication_role = origin;
|
||||
|
||||
Lote 5 — DROP CASCADE das tabelas em public
|
||||
|
||||
Só depois de TODAS as funções refatoradas e dados migrados:
|
||||
SET session_replication_role = replica;
|
||||
DO $$ BEGIN
|
||||
FOREACH t IN ARRAY tabs LOOP
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=t) THEN
|
||||
EXECUTE format('DROP TABLE public.%I CASCADE', t);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
SET session_replication_role = origin;
|
||||
|
||||
Limitações conhecidas e workarounds
|
||||
|
||||
1. PostgREST não suporta embed FK cross-schema
|
||||
|
||||
Você vai pagar esse pato. O PostgREST 14.x não consegue resolver embeds tipo db().from('os').select('*,
|
||||
profiles!os_solicitante_profile_id_fkey(nome)') quando os está em tenant_X e profiles em public, mesmo com FK física
|
||||
existindo. Mensagem: PGRST200: Could not find a relationship between 'os' and 'profiles' in the schema cache.
|
||||
|
||||
Solução: helper de "fake embed" no frontend. Crie useProfileEmbed.js:
|
||||
export async function attachProfiles(rows, mappings, columns = 'id, nome, email, role') {
|
||||
if (!rows?.length) return rows;
|
||||
const allIds = new Set();
|
||||
for (const m of mappings) rows.forEach(r => { if (r?.[m.idField]) allIds.add(r[m.idField]); });
|
||||
const { data } = await supabase.from('profiles').select(columns).in('id', [...allIds]);
|
||||
const map = new Map((data || []).map(p => [p.id, p]));
|
||||
return rows.map(r => {
|
||||
const out = { ...r };
|
||||
for (const m of mappings) out[m.alias] = r?.[m.idField] ? map.get(r[m.idField]) || null : null;
|
||||
return out;
|
||||
});
|
||||
}
|
||||
// Variantes: attachProfilesNested(rows, nestedKey, mappings), attachProfilesById(rows, idField, alias)
|
||||
Faz 2 queries + merge em JS. Toda tela que tinha profiles!fkey(...) precisa virar duas queries + attach.
|
||||
|
||||
2. %ROWTYPE de tabelas tenant
|
||||
|
||||
Funções que declaravam v_plano public.convenio_planos%ROWTYPE quebram quando a tabela some do public. Troque por
|
||||
RECORD em todas. Quando precisar retornar tabela (RETURNS os_problemas), troque por RETURNS jsonb e construa via
|
||||
jsonb_build_object(...).
|
||||
|
||||
3. SQL functions com SET search_path TO 'public' declarado
|
||||
|
||||
Algumas funções são LANGUAGE sql com declaração estática SET search_path TO 'public'. Não dá pra usar set_config
|
||||
dinâmico em SQL puro. Converta pra LANGUAGE plpgsql. Atenção: isso exige DROP + CREATE (CREATE OR REPLACE não muda
|
||||
linguagem) → se tiver policy dependendo da função, drope a policy primeiro.
|
||||
|
||||
4. Triggers de notif que filtram cada destinatário
|
||||
|
||||
notify_tenant_admins insere em múltiplos owners via SELECT ... FROM profiles WHERE role IN (...). Pra respeitar
|
||||
preferências individuais, adicione AND public.should_notify(p.id, p_type) no WHERE.
|
||||
|
||||
5. Realtime
|
||||
|
||||
- A tabela notifications_sistema precisa ser adicionada explicitamente à publication: ALTER PUBLICATION
|
||||
supabase_realtime ADD TABLE public.notifications_sistema.
|
||||
- Canais realtime no frontend precisam do schema correto: { event: '*', schema: 'tenant_<slug>', table:
|
||||
'notifications', filter: 'owner_id=eq.X' } — não mais schema: 'public'.
|
||||
|
||||
6. Filtros .eq('tenant_id', X) no frontend
|
||||
|
||||
Após o split, qualquer db().from('tabela_tenant').eq('tenant_id', X) quebra com column tenant_id does not exist — a
|
||||
coluna sumiu. Faça grep e remova esses filtros (o isolamento agora é pelo schema). Mantenha em tabelas que ficam em
|
||||
public (tenant_features, tenant_audit_log, profiles).
|
||||
|
||||
7. session_replication_role na migração de dados
|
||||
|
||||
INSERTs em massa com FKs entre tabelas tenant podem falhar por ordem topológica. SET session_replication_role =
|
||||
replica desabilita checks de FK durante o INSERT. Lembre de voltar pra origin ao final.
|
||||
|
||||
8. Reset de sequences
|
||||
|
||||
Tabelas tenant com id bigint generated by sequence precisam de setval pós-migração — senão próximo INSERT vai colidir
|
||||
com PKs existentes.
|
||||
|
||||
9. Policies que usam funções refatoradas
|
||||
|
||||
unidade_in_current_tenant(uuid) aparecia como USING (...) em policies de public.prestador_unidade_acessos. Antes de
|
||||
DROP+CREATE da função, dropei as 2 policies. Tabelas que vão sumir não precisam recriar policy. Se a função é usada em
|
||||
policies de tabelas que ficam, recrie a policy depois.
|
||||
|
||||
10. FKs de tabelas que ficam em public apontando pras que saem
|
||||
|
||||
Antes de DROP, rode query pra detectar. Se houver, decida: migra a tabela referenciadora pro tenant também, ou
|
||||
converte FK pra coluna solta sem constraint.
|
||||
|
||||
Frontend — refactor sistemático
|
||||
|
||||
1. Find/replace em massa: supabase.from('<lista_tabelas_tenant>') → db().from(...). Importe useTenantDb.
|
||||
2. Caça por .eq('tenant_id': remova nos from('<tenant_table>'), mantenha nos from('<public_table>').
|
||||
3. Caça por embed profiles!fkey(...) em queries de tabelas tenant: refatore com attachProfiles.
|
||||
4. Caça por subscribeRealtime com schema: 'public' pra tabelas que viraram tenant — troque pra schema:
|
||||
tenantSchemaName(slug).
|
||||
5. Composables/serviços que usam supabase.from(...) em vez de db() direto: idem.
|
||||
|
||||
Backups e segurança
|
||||
|
||||
Sempre faça backup antes de cada lote:
|
||||
docker exec supabase_db_<projeto> pg_dump -U postgres -d postgres --schema=public --no-owner --no-acl >
|
||||
backups/pre-loteN/public.sql
|
||||
docker exec supabase_db_<projeto> pg_dump -U postgres -d postgres --schema=tenant_<slug> --no-owner --no-acl >
|
||||
backups/pre-loteN/tenant_<slug>.sql
|
||||
|
||||
Pra recarregar cache do PostgREST após mudanças:
|
||||
docker exec supabase_db_<projeto> psql -U postgres -d postgres -c "NOTIFY pgrst, 'reload schema'"
|
||||
|
||||
Se mudou config.toml (schemas expostos), restart obrigatório:
|
||||
docker restart supabase_rest_<projeto>
|
||||
|
||||
Checklist final por lote
|
||||
|
||||
Antes de marcar um lote como concluído:
|
||||
- Migration aplica sem erro (psql -v ON_ERROR_STOP=1)
|
||||
- Smoke test SQL chamando as funções refatoradas via SET LOCAL request.jwt.claim.sub
|
||||
- NOTIFY pgrst, 'reload schema' rodado
|
||||
- Usuário testou as telas do FE que tocam essas funções
|
||||
- Sem erros novos no console do navegador (network 4xx/5xx, PGRST200, etc.)
|
||||
|
||||
Como interagir comigo durante o trabalho
|
||||
|
||||
- Antes de codar qualquer fase, mostre o plano resumido e pergunte se prossegue.
|
||||
- Para decisões estruturais (ex: notifications split, função X retorna jsonb ou record composto, drop CASCADE de
|
||||
policy órfã), use perguntas múltipla escolha — não decida sozinho.
|
||||
- Ao terminar um lote, sumarize o que mudou + lista de coisas pra eu testar no FE.
|
||||
- Não confie em listas pré-feitas (suas ou do usuário). Sempre re-confirme via query no banco.
|
||||
- Backup antes de cada DROP destrutivo.
|
||||
- PostgREST cache é teimoso — NOTIFY pgrst resolve tabelas/funções; restart do container pra mudanças de config.toml.
|
||||
---
|
||||
|
||||
# TAREFA: Implementar modelo freemium/PLG (plano gratuito self-service + Upgrade PRO)
|
||||
|
||||
Você vai transformar o caminho de aquisição de assinatura deste SaaS multi-tenant
|
||||
em um modelo freemium/PLG, igual ao que já fiz num sistema irmão. O objetivo:
|
||||
qualquer visitante cria uma conta gratuita sozinho, confirma o e-mail, e o ambiente
|
||||
do tenant é provisionado automaticamente — sem dev no meio. Plano gratuito limitado
|
||||
+ botão "Upgrade PRO" no topo.
|
||||
|
||||
IMPORTANTE: este sistema é PARECIDO mas NÃO idêntico ao de referência. NÃO assuma
|
||||
nomes de tabelas/funções/rotas. Antes de QUALQUER código, faça a fase de descoberta
|
||||
e me apresente o mapa + as decisões pra eu confirmar. Trabalhe em fases, commitando
|
||||
por assunto, e validando cada migration no banco local em transação com ROLLBACK
|
||||
antes de seguir. Rode o build a cada bloco de frontend.
|
||||
|
||||
## FASE 0 — DESCOBERTA (não codar ainda; me devolva um mapa com file:line)
|
||||
Mapeie e me explique como funciona hoje:
|
||||
1. Landing page / vitrine de planos e como o signup é acionado (query params? rota?).
|
||||
2. Fluxo de signup: componente, se usa supabase.auth.signUp direto ou um wrapper,
|
||||
o que cria (auth user, profile, tenant, subscription). Existe trigger
|
||||
handle_new_user em auth.users? Onde o profile nasce e com qual role default?
|
||||
3. Modelo de planos SaaS: tabelas (plans, plan_prices, plan_features, plan_limits,
|
||||
subscriptions, subscription_intents...), e o catálogo de features atual (LEIA
|
||||
DO BANCO, não de seeds antigos — o catálogo costuma divergir do seed inicial).
|
||||
4. Feature gating: como uma feature é checada (composable hasFeature? guard de
|
||||
rota com meta.feature? filtro de menu?).
|
||||
5. Enforcement de limites por plano: existe? (na maioria das vezes plan_limits
|
||||
está semeado mas NINGUÉM lê — confirme).
|
||||
6. Provisionamento de tenant: como um tenant nasce hoje (função provision_*?),
|
||||
é manual (dev) ou automático? É multi-tenant por RLS ou schema-per-tenant?
|
||||
Se schema-per-tenant: existe clone_tenant_schema/tenant_schema_name? O clone
|
||||
copia triggers do template?
|
||||
7. Fluxo de auth: onde o profile é carregado no login (carregarPerfil?), onde o
|
||||
guard decide pra onde mandar o usuário (roleHomePath), e o que acontece com um
|
||||
usuário logado SEM tenant.
|
||||
8. Infra de e-mail: como e-mails transacionais são enviados (Resend? SMTP? edge
|
||||
function?). Existe tabela de templates + algum render de {{var}}? O e-mail do
|
||||
GoTrue (confirmação) funciona? Existe pg_net?
|
||||
9. Infra de billing/pagamento (AsaaS/Stripe?): existe checkout de assinatura
|
||||
RECORRENTE em nível de plano, ou só cobrança avulsa? Onde está o webhook?
|
||||
|
||||
## FASE 0.5 — DECISÕES (me apresente como perguntas; estes são os defaults que
|
||||
## funcionaram bem, com o porquê):
|
||||
- Provisionamento: AUTO, mas só DEPOIS de confirmar o e-mail (anti-spam: cada
|
||||
signup pode clonar dezenas de tabelas).
|
||||
- Funil: manter os dois caminhos (free self-service + pago via intent/comercial).
|
||||
- Upgrade PRO: checkout self-service (reusar infra de pagamento existente) — mas
|
||||
isso é FASE 3, deferida; no início o botão abre o canal comercial.
|
||||
- Trial: o "free para sempre" substitui o trial.
|
||||
- No limite: BLOQUEIA a inserção no banco (trigger) + toast amigável com CTA.
|
||||
- Slug do sindicato: a pessoa escolhe (sugestão automática a partir do nome,
|
||||
sanitizado), com checagem de disponibilidade ao vivo, e é IMUTÁVEL (se for
|
||||
schema-per-tenant, o slug É o nome do schema → trocar órfã tudo; trave em 3
|
||||
camadas: sem UI, guard no banco rejeitando UPDATE, validação na criação).
|
||||
|
||||
## FASE 1 — Fundação do plano gratuito
|
||||
1. Migration: criar plano `gratuito` (preço 0) + plan_features (tudo ON menos o
|
||||
módulo premium, ex: ordem_de_servico) + plan_limits (ex: 50 associados).
|
||||
REGRA DE OURO: referencie features POR KEY via subquery, NUNCA por uuid
|
||||
hardcoded (uuids de features geradas em runtime divergem entre ambientes).
|
||||
Deixe o plano OCULTO na vitrine nesta fase (self-service ainda não existe).
|
||||
2. Enforcement de limite GENÉRICO: uma função trigger que resolve o tenant pelo
|
||||
contexto (no schema-per-tenant: pelo nome do schema = TG_TABLE_SCHEMA; no
|
||||
RLS: pelo tenant_id), lê o plano ativo + plan_limits EM RUNTIME (pra mudar o
|
||||
número no painel valer sem deploy), conta linhas vivas e dá RAISE com um código
|
||||
parseável tipo 'PLAN_LIMIT_REACHED|<feature>|<limite>'. Trigger BEFORE INSERT
|
||||
na tabela limitada. Se schema-per-tenant: coloque no template E faça backfill
|
||||
nos schemas já existentes. Teste: 50 passam, 51º bloqueia; tenant pago intacto.
|
||||
3. Frontend: helper que traduz o erro PLAN_LIMIT_REACHED em toast amigável com
|
||||
CTA de upgrade, usado em TODOS os pontos de insert da tabela limitada. Botão
|
||||
"Upgrade PRO" no topbar quando o plano do tenant for 'gratuito'.
|
||||
|
||||
## FASE 2 — Self-service com confirmação de e-mail
|
||||
1. LIGUE a confirmação de e-mail (enable_confirmations=true no config.toml E no
|
||||
dashboard do hosted).
|
||||
2. ⚠️ PEGADINHA CRÍTICA #1: com confirmação ligada, o signup NÃO tem sessão. Então
|
||||
TUDO que dependia de auth.uid()/JWT no signup QUEBRA em silêncio:
|
||||
- inserir subscription_intents (RLS exige jwt email = email da linha) → erro.
|
||||
- registrar aceite legal (LGPD) → não grava.
|
||||
SOLUÇÃO: NÃO faça esses efeitos no signup. Grave a escolha (plan_key, interval,
|
||||
nome/slug do sindicato, ids das versões legais aceitas) no raw_user_meta_data
|
||||
do signUp, e processe TUDO no 1º login pós-confirmação, via RPCs idempotentes:
|
||||
- auto_provision_free_tenant() (lê metadata, cria tenant, provisiona, vira
|
||||
master, cria subscription gratuita ativa) — chamada em carregarPerfil quando
|
||||
o usuário não tem tenant. Gratuito não gera intenção.
|
||||
- processar_pos_signup() (aceite legal + cria a intenção SÓ pro caminho pago).
|
||||
3. ⚠️ PEGADINHA CRÍTICA #2 (segurança): após o signUp, se NÃO veio sessão
|
||||
(confirmação pendente), ENCERRE qualquer sessão local (signOut scope:'local')
|
||||
e mostre uma tela "confirme seu e-mail". Senão, uma sessão anterior (ex: dev
|
||||
testando) vaza e o push pra /login joga o usuário pro painel da sessão antiga.
|
||||
A pessoa só pode logar APÓS clicar no link do e-mail.
|
||||
4. ⚠️ PEGADINHA CRÍTICA #3 (blindagem): um usuário logado SEM tenant nunca pode
|
||||
cair num painel quebrado. No guard, redirecione todo logado-sem-tenant (não-dev)
|
||||
pra uma tela /onboarding que resolve os estados: provisionando, slug colidiu
|
||||
(deixa escolher outro slug e finalizar — faça o auto_provision aceitar um
|
||||
p_slug_override), conta paga aguardando ativação, sem acesso, erro (retry).
|
||||
5. Signup coleta nome do sindicato + slug (sugestão + sanitização + disponibilidade
|
||||
ao vivo via RPC slug_disponivel que retorna {ok, motivo}) + "seu nome".
|
||||
Torne o plano gratuito visível na vitrine agora.
|
||||
6. E-mail de boas-vindas: edge function (Resend) que renderiza o template, disparada
|
||||
no provisionamento. Best-effort (não bloqueia o login). Destinatário derivado
|
||||
do JWT, não do body.
|
||||
|
||||
## SAAS / EXTRAS (faça os que fizerem sentido)
|
||||
- Página /saas/usuarios: 1 linha por tenant com o DONO (master) — nome, slug,
|
||||
e-mail principal — via uma RPC dev-only que cruza tenants+profiles+subscriptions
|
||||
(SECURITY DEFINER). Realce em verde + selo "Novo" pra cliente criado nas últimas
|
||||
24h (rowClass baseado em created_at). Reaproveite essa RPC pra mostrar o e-mail
|
||||
principal também nas listagens de assinaturas e tenants.
|
||||
- Notificação aos devs quando nasce/muda uma assinatura (incl. trial): trigger em
|
||||
subscriptions chamando a função notify_all_devs com deeplink. ⚠️ PEGADINHA #4:
|
||||
se o sino de notificações é um singleton com flag "initialized", garanta que ele
|
||||
RE-BUSCA ao trocar de usuário (logout+login), senão fica stale e ainda vaza
|
||||
notificações entre usuários. A notificação só aparece pós-provisionamento e no
|
||||
sino do DEV (não do novo usuário).
|
||||
- "Esqueci meu e-mail": tela onde a pessoa informa o IDENTIFICADOR do sindicato
|
||||
(slug, que ela escolheu e foi avisada ser definitivo) → o servidor acha o e-mail
|
||||
do dono → mostra só uma DICA MASCARADA (jo****@gm****.com) → envia magic link
|
||||
(signInWithOtp, que usa o mesmo pipeline de e-mail do GoTrue, sem depender de
|
||||
Resend) → a pessoa clica e entra. O e-mail real NUNCA volta pro cliente.
|
||||
- root_redirect: coluna em config + RPC pública + guard, pra escolher pra onde o
|
||||
visitante não logado vai na raiz "/" (landing ou login).
|
||||
- Lista de bloqueio (blacklist) de e-mails e slugs, gerida em Configurações:
|
||||
tabela blacklist (kind email|slug). E-mail bloqueia o cadastro DE VERDADE via
|
||||
trigger BEFORE INSERT em auth.users (não só no front); suporte a domínio inteiro
|
||||
com entrada '@dominio.com'. Slug integra no slug_disponivel (motivo 'bloqueado').
|
||||
|
||||
## MÉTODO DE TRABALHO
|
||||
- Tudo numa branch nova. Commits pequenos por assunto, mensagem clara.
|
||||
- Cada migration: aplique no banco local e TESTE em transação com ROLLBACK (crie
|
||||
auth.users fake + impersone via set_config('request.jwt.claims',...)) antes de
|
||||
seguir. RPCs idempotentes.
|
||||
- Rode o build do frontend a cada bloco pra pegar erro cedo.
|
||||
- NUMERE as migrations com cuidado pra não colidir versão (quebra o db push).
|
||||
- Me mostre o mapa da Fase 0 e as decisões da Fase 0.5 ANTES de codar.
|
||||
|
||||
## DEPLOY (no fim)
|
||||
Migrations no hosted (db push) → dashboard Auth "Confirm email" ON + Site/Redirect
|
||||
URLs corretas → deploy das edge functions + secret do provedor de e-mail → rebuild
|
||||
do frontend → smoke test do fluxo: /lp → grátis → confirma e-mail → entra
|
||||
provisionado → limite bloqueia → sino do dev → esqueci-email.
|
||||
|
||||
---
|
||||
Esse prompt é "diretor": ele força a IA a mapear o teu outro sistema primeiro (porque as tabelas/nomes vão diferir) e
|
||||
te apresentar decisões antes de codar — do jeito que fizemos aqui. As 4 pegadinhas marcadas com ⚠️ são as que mais
|
||||
custaram tempo; com elas escritas, a IA evita de cara.
|
||||
|
||||
Quer que eu gere também uma versão curta (1 parágrafo) pra um primeiro disparo, ou uma variante específica caso o
|
||||
outro sistema seja RLS puro (sem schema-per-tenant)? Aí eu ajusto os trechos de provisionamento/enforcement.
|
||||
@@ -0,0 +1,184 @@
|
||||
# -*- 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))
|
||||
+2
-2
@@ -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();
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useRoleGuard } from '@/composables/useRoleGuard';
|
||||
import { isValidEmail, isValidPhone, sanitizeDigits } from '@/utils/validators';
|
||||
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { maybeShowPlanLimitToast } from '@/utils/planLimit';
|
||||
|
||||
import InputMask from 'primevue/inputmask';
|
||||
import Message from 'primevue/message';
|
||||
@@ -269,12 +270,14 @@ async function submit(mode = 'only') {
|
||||
const msg = err?.message || err?.details || 'Não foi possível criar o paciente.';
|
||||
errorMsg.value = msg;
|
||||
|
||||
if (!maybeShowPlanLimitToast(toast, err, route.fullPath)) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro ao salvar',
|
||||
detail: msg,
|
||||
life: 4500
|
||||
});
|
||||
}
|
||||
|
||||
console.error('[ComponentCadastroRapido] insert error:', err);
|
||||
} finally {
|
||||
|
||||
@@ -35,6 +35,8 @@ import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useAgendaFinanceiro } from '@/composables/useAgendaFinanceiro';
|
||||
import { emitirReciboParaSessao } from '@/services/DocumentGenerate.service';
|
||||
|
||||
@@ -51,6 +53,7 @@ const emit = defineEmits(['cobranca-atualizada']);
|
||||
// ── external ──────────────────────────────────────────────────────────────────
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const tenantStore = useTenantStore();
|
||||
const { gerarCobrancaManual, loading: finLoading, error: finError } = useAgendaFinanceiro();
|
||||
|
||||
// ── estado local ──────────────────────────────────────────────────────────────
|
||||
@@ -126,8 +129,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')
|
||||
@@ -186,6 +188,7 @@ async function confirmPayment() {
|
||||
payDlgLoading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('mark_as_paid', {
|
||||
p_tenant_id: tenantStore.activeTenantId,
|
||||
p_financial_record_id: record.value.id,
|
||||
p_payment_method: payDlgMethod.value
|
||||
});
|
||||
@@ -213,7 +216,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;
|
||||
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
|
||||
import { logEvent, logError } from '@/support/supportLogger';
|
||||
@@ -90,7 +91,7 @@ async function showNotif(msg) {
|
||||
|
||||
let name = msg.from_number || 'Desconhecido';
|
||||
if (msg.patient_id) {
|
||||
const { data } = await supabase.from('patients').select('nome_completo').eq('id', msg.patient_id).maybeSingle();
|
||||
const { data } = await tenantDb().from('patients').select('nome_completo').eq('id', msg.patient_id).maybeSingle();
|
||||
if (data?.nome_completo) name = data.nome_completo;
|
||||
}
|
||||
|
||||
@@ -142,7 +143,8 @@ function channelIcon(ch) {
|
||||
|
||||
function subscribe() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) {
|
||||
const tenantSchema = tenantStore.activeTenantSchema;
|
||||
if (!tenantId || !tenantSchema) {
|
||||
logEvent(LOG_SRC, 'subscribe skipped — sem tenant');
|
||||
return;
|
||||
}
|
||||
@@ -154,9 +156,8 @@ function subscribe() {
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'conversation_messages',
|
||||
filter: `tenant_id=eq.${tenantId}`
|
||||
schema: tenantSchema,
|
||||
table: 'conversation_messages'
|
||||
},
|
||||
(payload) => {
|
||||
const m = payload.new;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
-->
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
@@ -121,10 +122,9 @@ async function openConversationByThreadKey(threadKey) {
|
||||
try {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
const { supabase } = await import('@/lib/supabase/client');
|
||||
const { data } = await supabase
|
||||
.from('conversation_threads')
|
||||
const { data } = await tenantDb().from('conversation_threads')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
.eq('thread_key', threadKey)
|
||||
.maybeSingle();
|
||||
if (!data) return false;
|
||||
|
||||
@@ -123,7 +123,7 @@ watch(query, (v) => {
|
||||
const mySeq = ++searchSeq;
|
||||
debounceT = setTimeout(async () => {
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('search_global', { p_q: q, p_limit: 6 });
|
||||
const { data, error } = await supabase.rpc('search_global', { p_tenant_id: tenantStore.activeTenantId, p_q: q, p_limit: 6 });
|
||||
if (mySeq !== searchSeq) return; // resposta antiga, descarta
|
||||
if (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
// ─── Cache de exceções financeiras (vive enquanto o módulo estiver carregado) ─
|
||||
@@ -84,10 +85,9 @@ export function useAgendaFinanceiro() {
|
||||
|
||||
const uid = await getUid();
|
||||
|
||||
const { data, error: err } = await supabase
|
||||
.from('financial_exceptions')
|
||||
const { data, error: err } = await tenantDb().from('financial_exceptions')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
.eq('exception_type', exceptionType)
|
||||
.or(`owner_id.eq.${uid},owner_id.is.null`)
|
||||
.order('owner_id', { ascending: false, nullsLast: true }) // owner próprio tem prioridade
|
||||
@@ -188,10 +188,10 @@ export function useAgendaFinanceiro() {
|
||||
if (!rule || rule.charge_mode === 'none') {
|
||||
// Cancelar cobrança existente, se houver
|
||||
if (evento.billed) {
|
||||
const { data: existingRec } = await supabase.from('financial_records').select('id, status').eq('agenda_evento_id', evento.id).in('status', ['pending', 'overdue']).maybeSingle();
|
||||
const { data: existingRec } = await tenantDb().from('financial_records').select('id, status').eq('agenda_evento_id', evento.id).in('status', ['pending', 'overdue']).maybeSingle();
|
||||
|
||||
if (existingRec) {
|
||||
await supabase.from('financial_records').update({ status: 'cancelled', updated_at: new Date().toISOString() }).eq('id', existingRec.id);
|
||||
await tenantDb().from('financial_records').update({ status: 'cancelled', updated_at: new Date().toISOString() }).eq('id', existingRec.id);
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
@@ -202,11 +202,10 @@ export function useAgendaFinanceiro() {
|
||||
|
||||
if (evento.billed) {
|
||||
// Atualiza o valor da cobrança existente
|
||||
const { data: existingRec } = await supabase.from('financial_records').select('id').eq('agenda_evento_id', evento.id).in('status', ['pending', 'overdue']).maybeSingle();
|
||||
const { data: existingRec } = await tenantDb().from('financial_records').select('id').eq('agenda_evento_id', evento.id).in('status', ['pending', 'overdue']).maybeSingle();
|
||||
|
||||
if (existingRec) {
|
||||
await supabase
|
||||
.from('financial_records')
|
||||
await tenantDb().from('financial_records')
|
||||
.update({
|
||||
amount: chargeAmount,
|
||||
final_amount: chargeAmount,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -102,10 +103,8 @@ export function useAuditoria() {
|
||||
try {
|
||||
const { from, to } = dateRange.value;
|
||||
|
||||
let query = supabase
|
||||
.from('audit_log_unified')
|
||||
.select('uid, tenant_id, user_id, entity_type, entity_id, action, description, occurred_at, source, details')
|
||||
.eq('tenant_id', tenantId)
|
||||
let query = tenantDb().from('audit_log_unified')
|
||||
.select('uid, user_id, entity_type, entity_id, action, description, occurred_at, source, details')
|
||||
.gte('occurred_at', from.toISOString())
|
||||
.lte('occurred_at', to.toISOString())
|
||||
.order('occurred_at', { ascending: false })
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
@@ -37,10 +38,8 @@ export function useAutoReplySettings() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const { data, error: err } = await supabase
|
||||
.from('conversation_autoreply_settings')
|
||||
const { data, error: err } = await tenantDb().from('conversation_autoreply_settings')
|
||||
.select('enabled, message, cooldown_minutes, schedule_mode, business_hours, custom_window')
|
||||
.eq('tenant_id', tenantId)
|
||||
.maybeSingle();
|
||||
if (err) throw err;
|
||||
if (data) {
|
||||
@@ -80,9 +79,8 @@ export function useAutoReplySettings() {
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const { error: err } = await supabase
|
||||
.from('conversation_autoreply_settings')
|
||||
.upsert({ tenant_id: tenantId, ...payload }, { onConflict: 'tenant_id' });
|
||||
const { error: err } = await tenantDb().from('conversation_autoreply_settings')
|
||||
.upsert({ ...payload }, { onConflict: 'singleton' });
|
||||
if (err) throw err;
|
||||
settings.value = payload;
|
||||
return { ok: true };
|
||||
@@ -98,10 +96,8 @@ export function useAutoReplySettings() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return [];
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('agenda_regras_semanais')
|
||||
const { data } = await tenantDb().from('agenda_regras_semanais')
|
||||
.select('dia_semana, hora_inicio, hora_fim, ativo')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('ativo', true)
|
||||
.order('dia_semana');
|
||||
return (data || []).map((r) => ({
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
function startOfMonth(d = new Date()) {
|
||||
@@ -82,41 +83,36 @@ export function useClinicKPIs() {
|
||||
try {
|
||||
const [finRes, pendRes, patRes, eventRes, finSeriesRes] = await Promise.all([
|
||||
// 1) financial_records PAGO no mês (para MRR)
|
||||
supabase
|
||||
.from('financial_records')
|
||||
tenantDb().from('financial_records')
|
||||
.select('final_amount, patient_id')
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
.eq('status', 'paid')
|
||||
.gte('paid_at', monthStart)
|
||||
.lte('paid_at', monthEnd),
|
||||
|
||||
// 2) financial_records pending/overdue (qualquer data)
|
||||
supabase
|
||||
.from('financial_records')
|
||||
tenantDb().from('financial_records')
|
||||
.select('status, final_amount')
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
.in('status', ['pending', 'overdue']),
|
||||
|
||||
// 3) patients por status
|
||||
supabase
|
||||
.from('patients')
|
||||
tenantDb().from('patients')
|
||||
.select('status')
|
||||
.eq('tenant_id', tenantId),
|
||||
,
|
||||
|
||||
// 4) eventos de agenda no mês (para realizado/cancelado/faltou)
|
||||
supabase
|
||||
.from('agenda_eventos')
|
||||
tenantDb().from('agenda_eventos')
|
||||
.select('status, tipo')
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
.gte('inicio_em', monthStart)
|
||||
.lte('inicio_em', monthEnd)
|
||||
.neq('tipo', 'bloqueio'),
|
||||
|
||||
// 5) financial_records pagos últimos 6 meses (série + top pacientes)
|
||||
supabase
|
||||
.from('financial_records')
|
||||
tenantDb().from('financial_records')
|
||||
.select('final_amount, paid_at, patient_id, patients!patient_id(nome_completo)')
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
.eq('status', 'paid')
|
||||
.gte('paid_at', sixMonthsAgo)
|
||||
.lte('paid_at', monthEnd)
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const EMAIL_RE = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i;
|
||||
@@ -37,9 +38,8 @@ export function useContactEmails() {
|
||||
|
||||
async function loadTypes() {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('contact_email_types')
|
||||
.select('id, tenant_id, name, slug, icon, is_system, position')
|
||||
const { data } = await tenantDb().from('contact_email_types')
|
||||
.select('id, name, slug, icon, is_system, position')
|
||||
.order('position', { ascending: true })
|
||||
.order('name', { ascending: true });
|
||||
types.value = data || [];
|
||||
@@ -56,8 +56,7 @@ export function useContactEmails() {
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('contact_emails')
|
||||
const { data, error } = await tenantDb().from('contact_emails')
|
||||
.select('id, contact_email_type_id, email, is_primary, notes, position, created_at')
|
||||
.eq('entity_type', entityType)
|
||||
.eq('entity_id', entityId)
|
||||
@@ -74,8 +73,7 @@ export function useContactEmails() {
|
||||
}
|
||||
|
||||
async function unsetOtherPrimaries(entityType, entityId, exceptId = null) {
|
||||
const q = supabase
|
||||
.from('contact_emails')
|
||||
const q = tenantDb().from('contact_emails')
|
||||
.update({ is_primary: false })
|
||||
.eq('entity_type', entityType)
|
||||
.eq('entity_id', entityId)
|
||||
@@ -122,10 +120,8 @@ export function useContactEmails() {
|
||||
}
|
||||
|
||||
const maxPos = emails.value.reduce((m, e) => Math.max(m, e.position || 0), 0);
|
||||
const { data, error } = await supabase
|
||||
.from('contact_emails')
|
||||
const { data, error } = await tenantDb().from('contact_emails')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
contact_email_type_id,
|
||||
@@ -172,7 +168,7 @@ export function useContactEmails() {
|
||||
if (sanitized.is_primary === true) {
|
||||
await unsetOtherPrimaries(entityType, entityId, id);
|
||||
}
|
||||
const { error } = await supabase.from('contact_emails').update(sanitized).eq('id', id);
|
||||
const { error } = await tenantDb().from('contact_emails').update(sanitized).eq('id', id);
|
||||
if (error) throw error;
|
||||
await loadEmails(entityType, entityId);
|
||||
return { ok: true };
|
||||
@@ -200,12 +196,12 @@ export function useContactEmails() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const wasPrimary = emails.value.find((e) => e.id === id)?.is_primary;
|
||||
const { error } = await supabase.from('contact_emails').delete().eq('id', id);
|
||||
const { error } = await tenantDb().from('contact_emails').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
if (wasPrimary) {
|
||||
const remaining = emails.value.filter((e) => e.id !== id).sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||
if (remaining.length > 0) {
|
||||
await supabase.from('contact_emails').update({ is_primary: true }).eq('id', remaining[0].id);
|
||||
await tenantDb().from('contact_emails').update({ is_primary: true }).eq('id', remaining[0].id);
|
||||
}
|
||||
}
|
||||
await loadEmails(entityType, entityId);
|
||||
@@ -227,7 +223,6 @@ export function useContactEmails() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const rows = pendingItems.map((e) => ({
|
||||
tenant_id: tenantId,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
contact_email_type_id: e.contact_email_type_id,
|
||||
@@ -236,7 +231,7 @@ export function useContactEmails() {
|
||||
notes: e.notes || null,
|
||||
position: e.position
|
||||
}));
|
||||
const { error } = await supabase.from('contact_emails').insert(rows);
|
||||
const { error } = await tenantDb().from('contact_emails').insert(rows);
|
||||
if (error) throw error;
|
||||
await loadEmails(entityType, entityId);
|
||||
return { ok: true, count: rows.length };
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
function normalizeDigits(raw) {
|
||||
@@ -36,9 +37,8 @@ export function useContactPhones() {
|
||||
|
||||
async function loadTypes() {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('contact_types')
|
||||
.select('id, tenant_id, name, slug, icon, is_mobile, is_system, position')
|
||||
const { data } = await tenantDb().from('contact_types')
|
||||
.select('id, name, slug, icon, is_mobile, is_system, position')
|
||||
.order('position', { ascending: true })
|
||||
.order('name', { ascending: true });
|
||||
types.value = data || [];
|
||||
@@ -55,8 +55,7 @@ export function useContactPhones() {
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('contact_phones')
|
||||
const { data, error } = await tenantDb().from('contact_phones')
|
||||
.select('id, contact_type_id, number, is_primary, whatsapp_linked_at, notes, position, created_at')
|
||||
.eq('entity_type', entityType)
|
||||
.eq('entity_id', entityId)
|
||||
@@ -74,8 +73,7 @@ export function useContactPhones() {
|
||||
|
||||
// Ensure só 1 primary por entidade — seta outros pra false antes de inserir/atualizar
|
||||
async function unsetOtherPrimaries(entityType, entityId, exceptId = null) {
|
||||
const q = supabase
|
||||
.from('contact_phones')
|
||||
const q = tenantDb().from('contact_phones')
|
||||
.update({ is_primary: false })
|
||||
.eq('entity_type', entityType)
|
||||
.eq('entity_id', entityId)
|
||||
@@ -127,10 +125,8 @@ export function useContactPhones() {
|
||||
}
|
||||
|
||||
const maxPos = phones.value.reduce((m, p) => Math.max(m, p.position || 0), 0);
|
||||
const { data, error } = await supabase
|
||||
.from('contact_phones')
|
||||
const { data, error } = await tenantDb().from('contact_phones')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
contact_type_id,
|
||||
@@ -177,8 +173,7 @@ export function useContactPhones() {
|
||||
await unsetOtherPrimaries(entityType, entityId, id);
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('contact_phones')
|
||||
const { error } = await tenantDb().from('contact_phones')
|
||||
.update(sanitized)
|
||||
.eq('id', id);
|
||||
if (error) throw error;
|
||||
@@ -208,15 +203,14 @@ export function useContactPhones() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const wasPrimary = phones.value.find((p) => p.id === id)?.is_primary;
|
||||
const { error } = await supabase.from('contact_phones').delete().eq('id', id);
|
||||
const { error } = await tenantDb().from('contact_phones').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
|
||||
// Se removeu o primary, promove o próximo pra primary
|
||||
if (wasPrimary) {
|
||||
const remaining = phones.value.filter((p) => p.id !== id).sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||
if (remaining.length > 0) {
|
||||
await supabase
|
||||
.from('contact_phones')
|
||||
await tenantDb().from('contact_phones')
|
||||
.update({ is_primary: true })
|
||||
.eq('id', remaining[0].id);
|
||||
}
|
||||
@@ -242,7 +236,6 @@ export function useContactPhones() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const rows = pendingItems.map((p) => ({
|
||||
tenant_id: tenantId,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
contact_type_id: p.contact_type_id,
|
||||
@@ -252,7 +245,7 @@ export function useContactPhones() {
|
||||
notes: p.notes || null,
|
||||
position: p.position
|
||||
}));
|
||||
const { error } = await supabase.from('contact_phones').insert(rows);
|
||||
const { error } = await tenantDb().from('contact_phones').insert(rows);
|
||||
if (error) throw error;
|
||||
// Recarrega do DB pra ter IDs reais — substitui os pending_* por uuids.
|
||||
await loadPhones(entityType, entityId);
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
export function useConversationAssignment() {
|
||||
@@ -55,10 +56,8 @@ export function useConversationAssignment() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const { data, error: err } = await supabase
|
||||
.from('conversation_assignments')
|
||||
.select('tenant_id, thread_key, patient_id, contact_number, assigned_to, assigned_by, assigned_at')
|
||||
.eq('tenant_id', tenantId)
|
||||
const { data, error: err } = await tenantDb().from('conversation_assignments')
|
||||
.select('thread_key, patient_id, contact_number, assigned_to, assigned_by, assigned_at')
|
||||
.eq('thread_key', threadKey)
|
||||
.maybeSingle();
|
||||
if (err) throw err;
|
||||
@@ -96,7 +95,6 @@ export function useConversationAssignment() {
|
||||
if (!userId) return { ok: false, error: 'not_authenticated' };
|
||||
|
||||
const payload = {
|
||||
tenant_id: tenantId,
|
||||
thread_key: threadKey,
|
||||
patient_id: patientId || null,
|
||||
contact_number: contactNumber || null,
|
||||
@@ -105,10 +103,9 @@ export function useConversationAssignment() {
|
||||
assigned_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
const { data, error: err } = await supabase
|
||||
.from('conversation_assignments')
|
||||
.upsert(payload, { onConflict: 'tenant_id,thread_key' })
|
||||
.select('tenant_id, thread_key, patient_id, contact_number, assigned_to, assigned_by, assigned_at')
|
||||
const { data, error: err } = await tenantDb().from('conversation_assignments')
|
||||
.upsert(payload, { onConflict: 'thread_key' })
|
||||
.select('thread_key, patient_id, contact_number, assigned_to, assigned_by, assigned_at')
|
||||
.single();
|
||||
if (err) throw err;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
function sanitizeBody(raw) {
|
||||
@@ -42,10 +43,8 @@ export function useConversationNotes() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const { data, error: err } = await supabase
|
||||
.from('conversation_notes')
|
||||
const { data, error: err } = await tenantDb().from('conversation_notes')
|
||||
.select('id, thread_key, patient_id, contact_number, body, created_by, created_at, updated_at')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('thread_key', threadKey)
|
||||
.is('deleted_at', null)
|
||||
.order('created_at', { ascending: false });
|
||||
@@ -82,10 +81,8 @@ export function useConversationNotes() {
|
||||
const userId = authData?.user?.id;
|
||||
if (!userId) return { ok: false, error: 'not_authenticated' };
|
||||
|
||||
const { data, error: err } = await supabase
|
||||
.from('conversation_notes')
|
||||
const { data, error: err } = await tenantDb().from('conversation_notes')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
thread_key: threadKey,
|
||||
patient_id: patientId,
|
||||
contact_number: contactNumber,
|
||||
@@ -122,8 +119,7 @@ export function useConversationNotes() {
|
||||
if (!id || !clean) return { ok: false, error: 'invalid_params' };
|
||||
saving.value = true;
|
||||
try {
|
||||
const { error: err } = await supabase
|
||||
.from('conversation_notes')
|
||||
const { error: err } = await tenantDb().from('conversation_notes')
|
||||
.update({ body: clean })
|
||||
.eq('id', id);
|
||||
if (err) throw err;
|
||||
@@ -144,8 +140,7 @@ export function useConversationNotes() {
|
||||
if (!id) return { ok: false, error: 'invalid_id' };
|
||||
saving.value = true;
|
||||
try {
|
||||
const { error: err } = await supabase
|
||||
.from('conversation_notes')
|
||||
const { error: err } = await tenantDb().from('conversation_notes')
|
||||
.update({ deleted_at: new Date().toISOString() })
|
||||
.eq('id', id);
|
||||
if (err) throw err;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
function normalizePhoneBR(raw) {
|
||||
@@ -38,15 +39,11 @@ export function useConversationOptouts() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [optsRes, kwsRes] = await Promise.all([
|
||||
supabase
|
||||
.from('conversation_optouts')
|
||||
tenantDb().from('conversation_optouts')
|
||||
.select('id, phone, patient_id, source, keyword_matched, original_message, notes, opted_out_at, opted_back_in_at, blocked_by')
|
||||
.eq('tenant_id', tenantId)
|
||||
.order('opted_out_at', { ascending: false }),
|
||||
supabase
|
||||
.from('conversation_optout_keywords')
|
||||
.select('id, tenant_id, keyword, enabled, is_system')
|
||||
.or(`tenant_id.is.null,tenant_id.eq.${tenantId}`)
|
||||
tenantDb().from('conversation_optout_keywords')
|
||||
.select('id, keyword, enabled, is_system')
|
||||
.order('is_system', { ascending: false })
|
||||
.order('keyword', { ascending: true })
|
||||
]);
|
||||
@@ -56,7 +53,7 @@ export function useConversationOptouts() {
|
||||
// Enriquece com nome do paciente
|
||||
const patIds = [...new Set(optouts.value.map((o) => o.patient_id).filter(Boolean))];
|
||||
if (patIds.length) {
|
||||
const { data: pats } = await supabase.from('patients').select('id, nome_completo').in('id', patIds);
|
||||
const { data: pats } = await tenantDb().from('patients').select('id, nome_completo').in('id', patIds);
|
||||
const patMap = Object.fromEntries((pats || []).map((p) => [p.id, p.nome_completo]));
|
||||
optouts.value = optouts.value.map((o) => ({ ...o, _patient_name: patMap[o.patient_id] || null }));
|
||||
}
|
||||
@@ -79,19 +76,15 @@ export function useConversationOptouts() {
|
||||
const userId = authData?.user?.id;
|
||||
|
||||
// Verifica se já existe ativo
|
||||
const { data: existing } = await supabase
|
||||
.from('conversation_optouts')
|
||||
const { data: existing } = await tenantDb().from('conversation_optouts')
|
||||
.select('id')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('phone', cleanPhone)
|
||||
.is('opted_back_in_at', null)
|
||||
.maybeSingle();
|
||||
if (existing) return { ok: false, error: 'already_opted_out' };
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_optouts')
|
||||
const { data, error } = await tenantDb().from('conversation_optouts')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
phone: cleanPhone,
|
||||
patient_id: patientId,
|
||||
source: 'manual',
|
||||
@@ -115,8 +108,7 @@ export function useConversationOptouts() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const { error } = await supabase
|
||||
.from('conversation_optouts')
|
||||
const { error } = await tenantDb().from('conversation_optouts')
|
||||
.update({ opted_back_in_at: now })
|
||||
.eq('id', id);
|
||||
if (error) throw error;
|
||||
@@ -136,10 +128,9 @@ export function useConversationOptouts() {
|
||||
if (!tenantId || !clean) return { ok: false, error: 'invalid_params' };
|
||||
saving.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_optout_keywords')
|
||||
.insert({ tenant_id: tenantId, keyword: clean, is_system: false, enabled: true })
|
||||
.select('id, tenant_id, keyword, enabled, is_system')
|
||||
const { data, error } = await tenantDb().from('conversation_optout_keywords')
|
||||
.insert({ keyword: clean, is_system: false, enabled: true })
|
||||
.select('id, keyword, enabled, is_system')
|
||||
.single();
|
||||
if (error) throw error;
|
||||
keywords.value = [...keywords.value, data];
|
||||
@@ -154,8 +145,7 @@ export function useConversationOptouts() {
|
||||
async function toggleKeyword(id, enabled) {
|
||||
saving.value = true;
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('conversation_optout_keywords')
|
||||
const { error } = await tenantDb().from('conversation_optout_keywords')
|
||||
.update({ enabled })
|
||||
.eq('id', id);
|
||||
if (error) throw error;
|
||||
@@ -172,8 +162,7 @@ export function useConversationOptouts() {
|
||||
async function deleteKeyword(id) {
|
||||
saving.value = true;
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('conversation_optout_keywords')
|
||||
const { error } = await tenantDb().from('conversation_optout_keywords')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
if (error) throw error;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
function sanitizeName(raw) {
|
||||
@@ -46,9 +47,8 @@ export function useConversationTags() {
|
||||
loading.value = true;
|
||||
try {
|
||||
// RLS filtra automaticamente: system (tenant_id IS NULL) + custom do tenant ativo
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_tags')
|
||||
.select('id, tenant_id, name, slug, color, icon, position, is_system')
|
||||
const { data, error } = await tenantDb().from('conversation_tags')
|
||||
.select('id, name, slug, color, icon, position, is_system')
|
||||
.order('position', { ascending: true })
|
||||
.order('name', { ascending: true });
|
||||
if (error) throw error;
|
||||
@@ -67,10 +67,8 @@ export function useConversationTags() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId || !Array.isArray(threadKeys) || !threadKeys.length) return new Map();
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_thread_tags')
|
||||
const { data, error } = await tenantDb().from('conversation_thread_tags')
|
||||
.select('thread_key, tag_id')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('thread_key', threadKeys);
|
||||
if (error) throw error;
|
||||
const map = new Map();
|
||||
@@ -93,10 +91,8 @@ export function useConversationTags() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_thread_tags')
|
||||
const { data, error } = await tenantDb().from('conversation_thread_tags')
|
||||
.select('tag_id')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('thread_key', threadKey);
|
||||
if (error) throw error;
|
||||
threadTagIds.value = new Set((data || []).map((r) => r.tag_id));
|
||||
@@ -116,10 +112,8 @@ export function useConversationTags() {
|
||||
|
||||
try {
|
||||
if (hasTag) {
|
||||
const { error } = await supabase
|
||||
.from('conversation_thread_tags')
|
||||
const { error } = await tenantDb().from('conversation_thread_tags')
|
||||
.delete()
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('thread_key', threadKey)
|
||||
.eq('tag_id', tagId);
|
||||
if (error) throw error;
|
||||
@@ -130,10 +124,8 @@ export function useConversationTags() {
|
||||
const { data: authData } = await supabase.auth.getUser();
|
||||
const userId = authData?.user?.id;
|
||||
if (!userId) return { ok: false, error: 'not_authenticated' };
|
||||
const { error } = await supabase
|
||||
.from('conversation_thread_tags')
|
||||
const { error } = await tenantDb().from('conversation_thread_tags')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
thread_key: threadKey,
|
||||
tag_id: tagId,
|
||||
tagged_by: userId
|
||||
@@ -162,17 +154,15 @@ export function useConversationTags() {
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_tags')
|
||||
const { data, error } = await tenantDb().from('conversation_tags')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
name: cleanName,
|
||||
slug,
|
||||
color,
|
||||
icon,
|
||||
is_system: false
|
||||
})
|
||||
.select('id, tenant_id, name, slug, color, icon, position, is_system')
|
||||
.select('id, name, slug, color, icon, position, is_system')
|
||||
.single();
|
||||
if (error) throw error;
|
||||
allTags.value = [...allTags.value, data].sort((a, b) => (a.position - b.position) || a.name.localeCompare(b.name));
|
||||
@@ -201,11 +191,10 @@ export function useConversationTags() {
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_tags')
|
||||
const { data, error } = await tenantDb().from('conversation_tags')
|
||||
.update(patch)
|
||||
.eq('id', id)
|
||||
.select('id, tenant_id, name, slug, color, icon, position, is_system')
|
||||
.select('id, name, slug, color, icon, position, is_system')
|
||||
.single();
|
||||
if (error) throw error;
|
||||
allTags.value = allTags.value
|
||||
@@ -224,7 +213,7 @@ export function useConversationTags() {
|
||||
if (!id) return { ok: false, error: 'invalid_id' };
|
||||
saving.value = true;
|
||||
try {
|
||||
const { error } = await supabase.from('conversation_tags').delete().eq('id', id);
|
||||
const { error } = await tenantDb().from('conversation_tags').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
allTags.value = allTags.value.filter((t) => t.id !== id);
|
||||
const next = new Set(threadTagIds.value);
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
import { ref, computed, onUnmounted } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
// Metadata canonica das colunas do kanban — fonte unica consumida pelo
|
||||
@@ -82,10 +83,8 @@ export function useConversations() {
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const { data, error: qErr } = await supabase
|
||||
.from('conversation_threads')
|
||||
const { data, error: qErr } = await tenantDb().from('conversation_threads')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.order('last_message_at', { ascending: false })
|
||||
.limit(500);
|
||||
if (qErr) throw qErr;
|
||||
@@ -100,7 +99,8 @@ export function useConversations() {
|
||||
|
||||
function subscribeRealtime() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return;
|
||||
const tenantSchema = tenantStore.activeTenantSchema;
|
||||
if (!tenantId || !tenantSchema) return;
|
||||
if (realtimeChannel) {
|
||||
supabase.removeChannel(realtimeChannel);
|
||||
}
|
||||
@@ -110,9 +110,8 @@ export function useConversations() {
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'conversation_messages',
|
||||
filter: `tenant_id=eq.${tenantId}`
|
||||
schema: tenantSchema,
|
||||
table: 'conversation_messages'
|
||||
},
|
||||
(payload) => {
|
||||
// refetch da lista (view agrega tudo) — debounced
|
||||
@@ -129,9 +128,8 @@ export function useConversations() {
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'conversation_messages',
|
||||
filter: `tenant_id=eq.${tenantId}`
|
||||
schema: tenantSchema,
|
||||
table: 'conversation_messages'
|
||||
},
|
||||
(payload) => {
|
||||
_scheduleLoad();
|
||||
@@ -226,10 +224,8 @@ export function useConversations() {
|
||||
}
|
||||
threadLoading.value = true;
|
||||
try {
|
||||
let q = supabase
|
||||
.from('conversation_messages')
|
||||
let q = tenantDb().from('conversation_messages')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantStore.activeTenantId)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(500);
|
||||
|
||||
@@ -253,10 +249,8 @@ export function useConversations() {
|
||||
// Marca unread do inbound como lido
|
||||
const nowIso = new Date().toISOString();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
let q = supabase
|
||||
.from('conversation_messages')
|
||||
let q = tenantDb().from('conversation_messages')
|
||||
.update({ read_at: nowIso })
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('direction', 'inbound')
|
||||
.is('read_at', null);
|
||||
if (thread.patient_id) q = q.eq('patient_id', thread.patient_id);
|
||||
@@ -271,7 +265,7 @@ export function useConversations() {
|
||||
const patch = { kanban_status: newStatus };
|
||||
if (newStatus === 'resolved') patch.resolved_at = new Date().toISOString();
|
||||
|
||||
let q = supabase.from('conversation_messages').update(patch).eq('tenant_id', tenantId);
|
||||
let q = tenantDb().from('conversation_messages').update(patch);
|
||||
if (thread.patient_id) q = q.eq('patient_id', thread.patient_id);
|
||||
else q = q.eq('from_number', thread.contact_number).is('patient_id', null);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { getFeriadosNacionais } from '@/utils/feriadosBR';
|
||||
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore';
|
||||
|
||||
@@ -59,10 +60,8 @@ export function useFeriados(opts = {}) {
|
||||
}
|
||||
|
||||
async function _doFetch(tenantId, cacheKey) {
|
||||
const { data, error } = await supabase
|
||||
.from('feriados')
|
||||
const { data, error } = await tenantDb().from('feriados')
|
||||
.select('*')
|
||||
.or(`tenant_id.eq.${tenantId},tenant_id.is.null`)
|
||||
.gte('data', `${ano.value}-01-01`)
|
||||
.lte('data', `${ano.value}-12-31`)
|
||||
.order('data');
|
||||
@@ -98,10 +97,8 @@ export function useFeriados(opts = {}) {
|
||||
// Comportamento legado (sem cache) — páginas de admin que editam.
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('feriados')
|
||||
const { data, error } = await tenantDb().from('feriados')
|
||||
.select('*')
|
||||
.or(`tenant_id.eq.${tenantId},tenant_id.is.null`)
|
||||
.gte('data', `${ano.value}-01-01`)
|
||||
.lte('data', `${ano.value}-12-31`)
|
||||
.order('data');
|
||||
@@ -114,7 +111,7 @@ export function useFeriados(opts = {}) {
|
||||
|
||||
// ── Criar feriado municipal ───────────────────────────────
|
||||
async function criar(payload) {
|
||||
const { data, error } = await supabase.from('feriados').insert(payload).select().single();
|
||||
const { data, error } = await tenantDb().from('feriados').insert(payload).select().single();
|
||||
if (error) throw error;
|
||||
municipais.value = [...municipais.value, data].sort((a, b) => a.data.localeCompare(b.data));
|
||||
if (cache) cache.invalidate('feriados');
|
||||
@@ -123,7 +120,7 @@ export function useFeriados(opts = {}) {
|
||||
|
||||
// ── Remover feriado municipal ─────────────────────────────
|
||||
async function remover(id) {
|
||||
const { error } = await supabase.from('feriados').delete().eq('id', id);
|
||||
const { error } = await tenantDb().from('feriados').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
municipais.value = municipais.value.filter((f) => f.id !== id);
|
||||
if (cache) cache.invalidate('feriados');
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
// ─── helpers internos ────────────────────────────────────────────────────────
|
||||
@@ -38,7 +39,7 @@ async function getUid() {
|
||||
// ─── select base com joins ───────────────────────────────────────────────────
|
||||
|
||||
const BASE_SELECT = `
|
||||
id, tenant_id, owner_id, patient_id, agenda_evento_id,
|
||||
id, owner_id, patient_id, agenda_evento_id,
|
||||
type, amount, discount_amount, final_amount,
|
||||
status, due_date, paid_at, payment_method, payment_link,
|
||||
description, notes, created_at, updated_at,
|
||||
@@ -117,10 +118,8 @@ export function useFinancialRecords() {
|
||||
const offset = filters.offset ?? 0;
|
||||
|
||||
try {
|
||||
let query = supabase
|
||||
.from('financial_records')
|
||||
let query = tenantDb().from('financial_records')
|
||||
.select(BASE_SELECT, { count: 'exact' })
|
||||
.eq('tenant_id', tenantId)
|
||||
.is('deleted_at', null)
|
||||
.order('due_date', { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
@@ -214,11 +213,9 @@ export function useFinancialRecords() {
|
||||
const discount = payload.discount_amount ?? 0;
|
||||
const amount = payload.amount ?? 0;
|
||||
|
||||
const { data, error: err } = await supabase
|
||||
.from('financial_records')
|
||||
const { data, error: err } = await tenantDb().from('financial_records')
|
||||
.insert([
|
||||
{
|
||||
tenant_id: tenantId,
|
||||
owner_id: ownerId,
|
||||
patient_id: payload.patient_id ?? null,
|
||||
agenda_evento_id: null,
|
||||
@@ -257,14 +254,19 @@ export function useFinancialRecords() {
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
assertTenantId(tenantId);
|
||||
|
||||
const { data, error: err } = await supabase.rpc('mark_as_paid', {
|
||||
p_tenant_id: tenantId,
|
||||
p_financial_record_id: recordId,
|
||||
p_payment_method: paymentMethod
|
||||
});
|
||||
|
||||
if (err) throw err;
|
||||
|
||||
// RPC retorna SETOF (array) — patch local direto, sem depender do retorno
|
||||
// RPC retorna jsonb (objeto único) — patch local direto, sem depender do retorno
|
||||
const idx = records.value.findIndex((r) => r.id === recordId);
|
||||
if (idx !== -1) {
|
||||
records.value[idx] = {
|
||||
@@ -291,7 +293,7 @@ export function useFinancialRecords() {
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const { error: err } = await supabase.from('financial_records').update({ status: 'cancelled', updated_at: new Date().toISOString() }).eq('id', recordId);
|
||||
const { error: err } = await tenantDb().from('financial_records').update({ status: 'cancelled', updated_at: new Date().toISOString() }).eq('id', recordId);
|
||||
|
||||
if (err) throw err;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { downloadLgpdPDF } from '@/utils/lgpdExportFormats';
|
||||
|
||||
function slugify(s) {
|
||||
@@ -53,7 +54,8 @@ export function useLgpdExport() {
|
||||
throw new Error('patientId obrigatório');
|
||||
}
|
||||
|
||||
const { data, error: rpcErr } = await supabase.rpc('export_patient_data', { p_patient_id: patientId });
|
||||
const tenantId = useTenantStore().activeTenantId;
|
||||
const { data, error: rpcErr } = await supabase.rpc('export_patient_data', { p_tenant_id: tenantId, p_patient_id: patientId });
|
||||
if (rpcErr) throw rpcErr;
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
// ─── estado compartilhado ──────────────────────────────────
|
||||
@@ -50,9 +51,8 @@ async function _refresh() {
|
||||
|
||||
// 1. Agenda hoje
|
||||
{
|
||||
let q = supabase.from('agenda_eventos').select('id', { count: 'exact', head: true }).gte('inicio_em', startDay).lt('inicio_em', endDay);
|
||||
if (isClinic && tenantId) q = q.eq('tenant_id', tenantId);
|
||||
else q = q.eq('owner_id', ownerId);
|
||||
let q = tenantDb().from('agenda_eventos').select('id', { count: 'exact', head: true }).gte('inicio_em', startDay).lt('inicio_em', endDay);
|
||||
if (!(isClinic && tenantId)) q = q.eq('owner_id', ownerId);
|
||||
const { count } = await q;
|
||||
agendaHoje.value = count || 0;
|
||||
}
|
||||
@@ -74,10 +74,8 @@ async function _refresh() {
|
||||
|
||||
// 4. Conversas não lidas (mensagens inbound sem read_at)
|
||||
if (tenantId) {
|
||||
const { count } = await supabase
|
||||
.from('conversation_messages')
|
||||
const { count } = await tenantDb().from('conversation_messages')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('direction', 'inbound')
|
||||
.is('read_at', null);
|
||||
conversasUnread.value = count || 0;
|
||||
@@ -92,7 +90,8 @@ function _subscribeRealtime() {
|
||||
try {
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || null;
|
||||
if (!tenantId) return;
|
||||
const tenantSchema = tenantStore.activeTenantSchema;
|
||||
if (!tenantId || !tenantSchema) return;
|
||||
if (_realtimeChannel) {
|
||||
supabase.removeChannel(_realtimeChannel);
|
||||
}
|
||||
@@ -100,12 +99,12 @@ function _subscribeRealtime() {
|
||||
.channel(`menu_badges_conv_${tenantId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'INSERT', schema: 'public', table: 'conversation_messages', filter: `tenant_id=eq.${tenantId}` },
|
||||
{ event: 'INSERT', schema: tenantSchema, table: 'conversation_messages' },
|
||||
() => _refresh()
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'UPDATE', schema: 'public', table: 'conversation_messages', filter: `tenant_id=eq.${tenantId}` },
|
||||
{ event: 'UPDATE', schema: tenantSchema, table: 'conversation_messages' },
|
||||
() => _refresh()
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
@@ -18,6 +18,7 @@ import { onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useNotificationStore, fireBrowserNotification } from '@/stores/notificationStore';
|
||||
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
@@ -91,10 +92,9 @@ export function useNotifications() {
|
||||
if (payload.thread_key) {
|
||||
try {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
const { data } = await supabase
|
||||
.from('conversation_threads')
|
||||
const { data } = await tenantDb().from('conversation_threads')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
.eq('thread_key', payload.thread_key)
|
||||
.maybeSingle();
|
||||
if (data) {
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
export function usePatientLifecycle() {
|
||||
async function canDelete(patientId) {
|
||||
const { data, error } = await supabase.rpc('can_delete_patient', { p_patient_id: patientId });
|
||||
@@ -24,7 +26,8 @@ export function usePatientLifecycle() {
|
||||
}
|
||||
|
||||
async function deletePatient(patientId) {
|
||||
const { data, error } = await supabase.rpc('safe_delete_patient', { p_patient_id: patientId });
|
||||
const tenantId = useTenantStore().activeTenantId;
|
||||
const { data, error } = await supabase.rpc('safe_delete_patient', { p_tenant_id: tenantId, p_patient_id: patientId });
|
||||
if (error) return { ok: false, error: 'rpc_error', message: error.message };
|
||||
return data; // { ok, error?, message? }
|
||||
}
|
||||
@@ -32,8 +35,8 @@ export function usePatientLifecycle() {
|
||||
async function checkActiveSchedule(patientId) {
|
||||
const now = new Date().toISOString();
|
||||
const [evts, recs] = await Promise.all([
|
||||
supabase.from('agenda_eventos').select('id', { count: 'exact', head: true }).eq('patient_id', patientId).eq('status', 'agendado').gt('inicio_em', now),
|
||||
supabase.from('recurrence_rules').select('id', { count: 'exact', head: true }).eq('patient_id', patientId).eq('status', 'ativo')
|
||||
tenantDb().from('agenda_eventos').select('id', { count: 'exact', head: true }).eq('patient_id', patientId).eq('status', 'agendado').gt('inicio_em', now),
|
||||
tenantDb().from('recurrence_rules').select('id', { count: 'exact', head: true }).eq('patient_id', patientId).eq('status', 'ativo')
|
||||
]);
|
||||
return {
|
||||
hasFutureSessions: (evts.count ?? 0) > 0,
|
||||
@@ -42,17 +45,17 @@ export function usePatientLifecycle() {
|
||||
}
|
||||
|
||||
async function deactivatePatient(patientId) {
|
||||
const { error } = await supabase.from('patients').update({ status: 'Inativo', updated_at: new Date().toISOString() }).eq('id', patientId);
|
||||
const { error } = await tenantDb().from('patients').update({ status: 'Inativo', updated_at: new Date().toISOString() }).eq('id', patientId);
|
||||
return error ? { ok: false, error } : { ok: true };
|
||||
}
|
||||
|
||||
async function archivePatient(patientId) {
|
||||
const { error } = await supabase.from('patients').update({ status: 'Arquivado', updated_at: new Date().toISOString() }).eq('id', patientId);
|
||||
const { error } = await tenantDb().from('patients').update({ status: 'Arquivado', updated_at: new Date().toISOString() }).eq('id', patientId);
|
||||
return error ? { ok: false, error } : { ok: true };
|
||||
}
|
||||
|
||||
async function reactivatePatient(patientId) {
|
||||
const { error } = await supabase.from('patients').update({ status: 'Ativo', updated_at: new Date().toISOString() }).eq('id', patientId);
|
||||
const { error } = await tenantDb().from('patients').update({ status: 'Ativo', updated_at: new Date().toISOString() }).eq('id', patientId);
|
||||
return error ? { ok: false, error } : { ok: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const DEFAULTS = {
|
||||
@@ -38,15 +39,11 @@ export function useSessionReminders() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [settingsRes, logsRes] = await Promise.all([
|
||||
supabase
|
||||
.from('session_reminder_settings')
|
||||
tenantDb().from('session_reminder_settings')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('session_reminder_logs')
|
||||
tenantDb().from('session_reminder_logs')
|
||||
.select('id, event_id, reminder_type, sent_at, provider, skip_reason, to_phone')
|
||||
.eq('tenant_id', tenantId)
|
||||
.order('sent_at', { ascending: false })
|
||||
.limit(30)
|
||||
]);
|
||||
@@ -88,9 +85,8 @@ export function useSessionReminders() {
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('session_reminder_settings')
|
||||
.upsert({ tenant_id: tenantId, ...payload }, { onConflict: 'tenant_id' });
|
||||
const { error } = await tenantDb().from('session_reminder_settings')
|
||||
.upsert({ ...payload }, { onConflict: 'singleton' });
|
||||
if (error) throw error;
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/composables/useTenantDb.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Composable reativo sobre tenantClient: use em componentes que precisam
|
||||
| aguardar o tenant ativo (isReady) ou reagir à troca de tenant.
|
||||
| Em services/repositories, importe tenantDb direto de
|
||||
| '@/lib/supabase/tenantClient'.
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { tenantDb, tenantSchemaName } from '@/lib/supabase/tenantClient';
|
||||
|
||||
export function useTenantDb() {
|
||||
const tenantStore = useTenantStore();
|
||||
const schemaName = computed(() => tenantSchemaName(tenantStore.activeTenantSlug));
|
||||
const isReady = computed(() => Boolean(schemaName.value));
|
||||
|
||||
function db() {
|
||||
return tenantDb();
|
||||
}
|
||||
|
||||
return { db, schemaName, isReady };
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import Message from 'primevue/message';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
|
||||
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
|
||||
import AgendaEventoFinanceiroPanel from '@/components/agenda/AgendaEventoFinanceiroPanel.vue';
|
||||
@@ -803,8 +804,7 @@ async function openSessionRecordsDialog() {
|
||||
sessionRecordsDialogOpen.value = true;
|
||||
sessionRecordsLoading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('financial_records')
|
||||
const { data, error } = await tenantDb().from('financial_records')
|
||||
.select('id, description, amount, final_amount, status, due_date, paid_at, payment_method, created_at')
|
||||
.eq('agenda_evento_id', eid)
|
||||
.is('deleted_at', null)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useFeriados } from '@/composables/useFeriados';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import DatePicker from 'primevue/datepicker';
|
||||
@@ -168,7 +169,6 @@ async function confirmar() {
|
||||
try {
|
||||
const base = {
|
||||
owner_id: props.ownerId,
|
||||
tenant_id: props.tenantId,
|
||||
tipo: 'bloqueio',
|
||||
recorrente: false
|
||||
};
|
||||
@@ -204,7 +204,7 @@ async function confirmar() {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await supabase.from('agenda_bloqueios').insert(rows);
|
||||
const { error } = await tenantDb().from('agenda_bloqueios').insert(rows);
|
||||
if (error) throw error;
|
||||
|
||||
// Marcar sessões existentes como "remarcado"
|
||||
@@ -229,7 +229,7 @@ async function marcarSessoesParaRemarcar(bloqueios) {
|
||||
// Para cada bloqueio, tenta marcar sessões existentes como 'remarcado'
|
||||
for (const b of bloqueios) {
|
||||
try {
|
||||
let query = supabase.from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', props.ownerId).eq('tipo', 'sessao').gte('inicio_em', `${b.data_inicio}T00:00:00`).lte('inicio_em', `${b.data_fim}T23:59:59`);
|
||||
let query = tenantDb().from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', props.ownerId).eq('tipo', 'sessao').gte('inicio_em', `${b.data_inicio}T00:00:00`).lte('inicio_em', `${b.data_fim}T23:59:59`);
|
||||
|
||||
if (b.hora_inicio && b.hora_fim) {
|
||||
// filtra pela hora aproximada — comparação UTC simplificada
|
||||
@@ -250,7 +250,6 @@ async function salvarFeriadoMunicipal() {
|
||||
const iso = toISO(fform.value.data);
|
||||
try {
|
||||
await criarFeriado({
|
||||
tenant_id: props.tenantId,
|
||||
owner_id: props.ownerId,
|
||||
tipo: 'municipal',
|
||||
nome: fform.value.nome.trim(),
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ref, watch } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
insurancePlanId: { type: String, default: '' },
|
||||
@@ -61,7 +62,7 @@ async function onSave() {
|
||||
value: Number(form.value.value),
|
||||
active: true
|
||||
};
|
||||
const { data, error } = await supabase.from('insurance_plan_services').insert(payload).select().single();
|
||||
const { data, error } = await tenantDb().from('insurance_plan_services').insert(payload).select().single();
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Procedimento cadastrado', life: 2200 });
|
||||
emit('created', data);
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useFeriados } from '@/composables/useFeriados';
|
||||
@@ -109,7 +110,7 @@ async function loadBloqueiosMes() {
|
||||
const end = `${ano}-${String(mesAtual).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
loadingBloqueios.value = true;
|
||||
try {
|
||||
const { data } = await supabase.from('agenda_bloqueios').select('data_inicio').eq('owner_id', _ownerId.value).in('origem', ['agenda_feriado', 'agenda_dia']).gte('data_inicio', start).lte('data_inicio', end);
|
||||
const { data } = await tenantDb().from('agenda_bloqueios').select('data_inicio').eq('owner_id', _ownerId.value).in('origem', ['agenda_feriado', 'agenda_dia']).gte('data_inicio', start).lte('data_inicio', end);
|
||||
bloqueiosDatas.value = new Set((data || []).map((r) => r.data_inicio));
|
||||
} catch {
|
||||
/* silencioso */
|
||||
@@ -152,7 +153,6 @@ async function confirmarBloqueio(feriado) {
|
||||
try {
|
||||
const row = {
|
||||
owner_id: _ownerId.value,
|
||||
tenant_id: _tenantId.value,
|
||||
tipo: 'bloqueio',
|
||||
recorrente: false,
|
||||
titulo: `Feriado: ${feriado.nome}`,
|
||||
@@ -163,11 +163,11 @@ async function confirmarBloqueio(feriado) {
|
||||
origem: 'agenda_feriado'
|
||||
};
|
||||
|
||||
const { error } = await supabase.from('agenda_bloqueios').insert([row]);
|
||||
const { error } = await tenantDb().from('agenda_bloqueios').insert([row]);
|
||||
if (error) throw error;
|
||||
|
||||
// Marcar sessões existentes no dia como 'remarcado'
|
||||
await supabase.from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', _ownerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
|
||||
await tenantDb().from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', _ownerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
|
||||
|
||||
bloqueiosDatas.value = new Set([...bloqueiosDatas.value, feriado.data]);
|
||||
toast.add({
|
||||
@@ -212,7 +212,6 @@ async function salvar() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await criar({
|
||||
tenant_id: _tenantId.value,
|
||||
owner_id: _ownerId.value,
|
||||
tipo: 'municipal',
|
||||
nome: form.value.nome.trim(),
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
| o id pra que o parent pré-selecione no select de serviços.
|
||||
|
|
||||
| Campos mínimos (obrigatórios no schema):
|
||||
| name, price, owner_id, tenant_id
|
||||
| name, price, owner_id
|
||||
| Opcionais úteis:
|
||||
| duration_min, description
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -20,6 +20,7 @@
|
||||
import { ref, watch } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -72,7 +73,7 @@ async function onSave() {
|
||||
// Nome unico por owner (case-insensitive) — espelha a validacao
|
||||
// do useServices.save() pra impedir duplicata tambem quando o
|
||||
// cadastro vem do quick-create dentro do AgendaEventDialog.
|
||||
const { data: dups, error: dupErr } = await supabase.from('services').select('id').eq('owner_id', ownerId).ilike('name', name).limit(1);
|
||||
const { data: dups, error: dupErr } = await tenantDb().from('services').select('id').eq('owner_id', ownerId).ilike('name', name).limit(1);
|
||||
if (dupErr) throw dupErr;
|
||||
if (dups && dups.length > 0) {
|
||||
toast.add({ severity: 'warn', summary: 'Nome em uso', detail: 'Já existe um serviço com este nome.', life: 3500 });
|
||||
@@ -82,14 +83,13 @@ async function onSave() {
|
||||
|
||||
const payload = {
|
||||
owner_id: ownerId,
|
||||
tenant_id: tid,
|
||||
name,
|
||||
price: Number(form.value.price),
|
||||
duration_min: form.value.duration_min ? Number(form.value.duration_min) : null,
|
||||
description: form.value.description?.trim().slice(0, 500) || null,
|
||||
active: true
|
||||
};
|
||||
const { data, error } = await supabase.from('services').insert(payload).select().single();
|
||||
const { data, error } = await tenantDb().from('services').insert(payload).select().single();
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Serviço criado', life: 2200 });
|
||||
emit('created', data);
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
Acessível via SupportDebugBanner → botão "Docs". -->
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false }
|
||||
@@ -141,7 +142,7 @@ const activeTab = ref(0);
|
||||
<!-- ── Tab 1: Tabelas ─────────────────────────────────── -->
|
||||
<TabPanel header="Tabelas">
|
||||
<div class="dd-section">
|
||||
<p class="dd-p">Todas as tabelas usam <strong>Row Level Security (RLS)</strong> habilitada.</p>
|
||||
<p class="dd-p">Todas as tabelas usam <strong>Row Level Security (RLS)</strong> habilitada. As tabelas da agenda vivem no schema do tenant (<code>tenant_<slug></code>, sem coluna <code>tenant_id</code>) e são acessadas via <code>tenantDb().from(...)</code>.</p>
|
||||
|
||||
<h3 class="dd-h3">Core</h3>
|
||||
<table class="dd-table">
|
||||
@@ -156,12 +157,12 @@ const activeTab = ref(0);
|
||||
<tr>
|
||||
<td><code>agenda_configuracoes</code></td>
|
||||
<td>Configurações da agenda por owner (terapeuta ou clínica)</td>
|
||||
<td>owner_id, tenant_id, slot_duration_minutes, start_time, end_time, days_of_week</td>
|
||||
<td>owner_id, slot_duration_minutes, start_time, end_time, days_of_week</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>agenda_eventos</code></td>
|
||||
<td>Eventos individuais (sessões, bloqueios avulsos)</td>
|
||||
<td>id, owner_id, tenant_id, patient_id, starts_at, ends_at, status, recurrence_rule_id, tipo</td>
|
||||
<td>id, owner_id, patient_id, starts_at, ends_at, status, recurrence_rule_id, tipo</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>agenda_bloqueios</code></td>
|
||||
@@ -217,7 +218,7 @@ const activeTab = ref(0);
|
||||
<tr>
|
||||
<td><code>determined_commitments</code></td>
|
||||
<td>Tipos de compromisso determinístico (ex: Avaliação, Supervisão)</td>
|
||||
<td>id, owner_id, tenant_id, name, color, duration_minutes</td>
|
||||
<td>id, owner_id, name, color, duration_minutes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>determined_commitment_fields</code></td>
|
||||
@@ -232,7 +233,7 @@ const activeTab = ref(0);
|
||||
<tr>
|
||||
<td><code>services</code></td>
|
||||
<td>Catálogo de serviços do terapeuta/clínica</td>
|
||||
<td>id, owner_id, tenant_id, name, default_price, active</td>
|
||||
<td>id, owner_id, name, default_price, active</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>professional_pricing</code></td>
|
||||
@@ -636,8 +637,7 @@ async function loadEvents (ownerId, range) {
|
||||
logAPI('useAgendaEvents', 'loadEvents start', { ownerId, range })
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
const { data, error } = await tenantDb().from('agenda_eventos')
|
||||
.select('*')
|
||||
.eq('owner_id', ownerId)
|
||||
|
||||
|
||||
@@ -476,7 +476,7 @@ describe('onSendManualReminder', () => {
|
||||
_functionsInvoke.mockResolvedValueOnce({ data: { ok: true, to: '+5516988887777' }, error: null });
|
||||
const { onSendManualReminder, toast, sendingReminder } = setup({ composer });
|
||||
await onSendManualReminder();
|
||||
expect(_functionsInvoke).toHaveBeenCalledWith('send-session-reminder-manual', { body: { event_id: 'evt-1' } });
|
||||
expect(_functionsInvoke).toHaveBeenCalledWith('send-session-reminder-manual', { body: { event_id: 'evt-1', tenant_id: 'tenant-1' } });
|
||||
expect(toast.add).toHaveBeenCalledWith(expect.objectContaining({ severity: 'success' }));
|
||||
expect(sendingReminder.value).toBe(false);
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { buildBloqueioBackgroundEvents } from '@/features/agenda/services/agendaMappers';
|
||||
|
||||
export function useAgendaBloqueios() {
|
||||
@@ -55,14 +56,12 @@ export function useAgendaBloqueios() {
|
||||
// Query: recorrentes (qualquer data) OU não-recorrentes com
|
||||
// data_inicio <= isoEnd e (data_fim ?? data_inicio) >= isoStart.
|
||||
// 2 queries simples + merge pra evitar string-building frágil.
|
||||
const baseNonRec = supabase
|
||||
.from('agenda_bloqueios')
|
||||
const baseNonRec = tenantDb().from('agenda_bloqueios')
|
||||
.select('*')
|
||||
.eq('recorrente', false)
|
||||
.lte('data_inicio', isoEnd)
|
||||
.or(`data_fim.gte.${isoStart},and(data_fim.is.null,data_inicio.gte.${isoStart})`);
|
||||
const baseRec = supabase
|
||||
.from('agenda_bloqueios')
|
||||
const baseRec = tenantDb().from('agenda_bloqueios')
|
||||
.select('*')
|
||||
.eq('recorrente', true);
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import { ref, watch } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { labelStatusSessao } from './agendaEventHelpers';
|
||||
|
||||
const EVENTO_TIPO_SESSAO = 'sessao';
|
||||
@@ -157,7 +158,7 @@ export function useAgendaEventActions({
|
||||
toast.add({ severity: 'success', summary: 'Status atualizado', detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`, life: 3000 });
|
||||
return;
|
||||
}
|
||||
const { data, error } = await supabase.from('agenda_eventos').update({ status: newVal }).eq('id', formId).select().single();
|
||||
const { data, error } = await tenantDb().from('agenda_eventos').update({ status: newVal }).eq('id', formId).select().single();
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Status atualizado', detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`, life: 3000 });
|
||||
emit('updated', data);
|
||||
@@ -213,8 +214,7 @@ export function useAgendaEventActions({
|
||||
const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString();
|
||||
const dayEnd = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).toISOString();
|
||||
|
||||
let q = supabase
|
||||
.from('agenda_eventos')
|
||||
let q = tenantDb().from('agenda_eventos')
|
||||
.select('id, inicio_em, fim_em, titulo')
|
||||
.eq('patient_id', pid)
|
||||
.gte('inicio_em', dayStart)
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
export function generateRuleDates(rule) {
|
||||
const { type, interval = 1, weekdays = [], start_date, end_date, max_occurrences } = rule || {};
|
||||
if (!start_date || !weekdays?.length) return [];
|
||||
@@ -150,14 +151,13 @@ export function useAgendaEventLifecycle({
|
||||
}
|
||||
serieLoading.value = true;
|
||||
try {
|
||||
const { data: rule, error: ruleErr } = await supabase.from('recurrence_rules').select('*').eq('id', rid).maybeSingle();
|
||||
const { data: rule, error: ruleErr } = await tenantDb().from('recurrence_rules').select('*').eq('id', rid).maybeSingle();
|
||||
if (ruleErr) throw ruleErr;
|
||||
|
||||
const { data: excData } = await supabase.from('recurrence_exceptions').select('original_date, type, reason').eq('recurrence_id', rid);
|
||||
const { data: excData } = await tenantDb().from('recurrence_exceptions').select('original_date, type, reason').eq('recurrence_id', rid);
|
||||
const exMap = new Map((excData || []).map((e) => [e.original_date, e]));
|
||||
|
||||
const { data: realData } = await supabase
|
||||
.from('agenda_eventos')
|
||||
const { data: realData } = await tenantDb().from('agenda_eventos')
|
||||
.select('id, inicio_em, fim_em, status, recurrence_date')
|
||||
.eq('recurrence_id', rid)
|
||||
.is('mirror_of_event_id', null)
|
||||
@@ -236,8 +236,7 @@ export function useAgendaEventLifecycle({
|
||||
// 1) Record direto (materializada que tem agenda_evento_id real)
|
||||
const isVirtualId = typeof evId === 'string' && evId.startsWith('rec::');
|
||||
if (evId && !isVirtualId) {
|
||||
const { data, error } = await supabase
|
||||
.from('financial_records')
|
||||
const { data, error } = await tenantDb().from('financial_records')
|
||||
.select('id, amount, final_amount, status, due_date, paid_at, payment_method')
|
||||
.eq('agenda_evento_id', evId)
|
||||
.in('status', ['pending', 'paid', 'overdue'])
|
||||
@@ -255,8 +254,7 @@ export function useAgendaEventLifecycle({
|
||||
// materializadas sem cobrança individual) herdam status do
|
||||
// contrato pra UI mostrar "Cobrança paga" coerentemente.
|
||||
if (ruleId && patientId) {
|
||||
const { data: contracts } = await supabase
|
||||
.from('billing_contracts')
|
||||
const { data: contracts } = await tenantDb().from('billing_contracts')
|
||||
.select('id, package_price, charging_style, status')
|
||||
.eq('patient_id', patientId)
|
||||
.eq('type', 'package')
|
||||
@@ -266,8 +264,7 @@ export function useAgendaEventLifecycle({
|
||||
if (upfront) {
|
||||
// Confere se há record PAGO ligado a qualquer evento do
|
||||
// mesmo recurrence_id (ou seja, contrato foi quitado).
|
||||
const { data: siblingEvents } = await supabase
|
||||
.from('agenda_eventos')
|
||||
const { data: siblingEvents } = await tenantDb().from('agenda_eventos')
|
||||
.select('id')
|
||||
.eq('recurrence_id', ruleId);
|
||||
const ids = (siblingEvents || []).map((e) => e.id);
|
||||
@@ -276,8 +273,7 @@ export function useAgendaEventLifecycle({
|
||||
// pending OU overdue). Pacote upfront tem 1 record
|
||||
// unico cobrindo toda a serie — qualquer status dele
|
||||
// trava as siblings (cobranca ja emitida, imutavel).
|
||||
const { data: anyRec } = await supabase
|
||||
.from('financial_records')
|
||||
const { data: anyRec } = await tenantDb().from('financial_records')
|
||||
.select('id, amount, final_amount, status, due_date, paid_at, payment_method')
|
||||
.in('agenda_evento_id', ids)
|
||||
.in('status', ['paid', 'pending', 'overdue'])
|
||||
@@ -315,8 +311,7 @@ export function useAgendaEventLifecycle({
|
||||
const evId = props.eventRow?.id;
|
||||
if (!evId) return;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('financial_records')
|
||||
const { data, error } = await tenantDb().from('financial_records')
|
||||
.select('id, amount, final_amount, status, due_date, paid_at, payment_method')
|
||||
.eq('agenda_evento_id', evId)
|
||||
.in('status', ['pending', 'paid', 'overdue'])
|
||||
@@ -341,8 +336,7 @@ export function useAgendaEventLifecycle({
|
||||
// Só faz sentido pra sessão de série
|
||||
if (!patientId || !ruleId) return;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('billing_contracts')
|
||||
const { data, error } = await tenantDb().from('billing_contracts')
|
||||
.select('id, type, total_sessions, sessions_used, package_price, charging_style, status, active_from')
|
||||
.eq('patient_id', patientId)
|
||||
.eq('type', 'package')
|
||||
@@ -477,7 +471,7 @@ export function useAgendaEventLifecycle({
|
||||
sendingReminder.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('send-session-reminder-manual', {
|
||||
body: { event_id: composer.form.value.id }
|
||||
body: { event_id: composer.form.value.id, tenant_id: props.tenantId }
|
||||
});
|
||||
if (error || !data?.ok) {
|
||||
const err = data?.error || error?.message || 'unknown_error';
|
||||
@@ -522,8 +516,7 @@ export function useAgendaEventLifecycle({
|
||||
if (serieValorMode) serieValorMode.value = 'multiplicar';
|
||||
|
||||
if (composer.isEdit.value && composer.form.value.paciente_id && !composer.form.value.paciente_nome) {
|
||||
supabase
|
||||
.from('patients')
|
||||
tenantDb().from('patients')
|
||||
.select('id, nome_completo')
|
||||
.eq('id', composer.form.value.paciente_id)
|
||||
.maybeSingle()
|
||||
@@ -602,8 +595,7 @@ export function useAgendaEventLifecycle({
|
||||
|
||||
const d = new Date(composer.form.value.dia);
|
||||
const isoDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
const { data } = await supabase
|
||||
.from('agendador_solicitacoes')
|
||||
const { data } = await supabase.from('agendador_solicitacoes')
|
||||
.select('id, paciente_nome, paciente_sobrenome, paciente_email')
|
||||
.eq('owner_id', props.ownerId)
|
||||
.eq('status', 'pendente')
|
||||
@@ -625,8 +617,7 @@ export function useAgendaEventLifecycle({
|
||||
const dow = new Date(dia).getDay();
|
||||
loadingOnlineSlots.value = true;
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('agenda_online_slots')
|
||||
const { data } = await tenantDb().from('agenda_online_slots')
|
||||
.select('time')
|
||||
.eq('owner_id', props.ownerId)
|
||||
.eq('weekday', dow)
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
*/
|
||||
import { ref, watch, nextTick } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { calcFinalPrice } from './agendaEventHelpers';
|
||||
|
||||
export function useAgendaEventPickerBilling({
|
||||
@@ -254,13 +255,11 @@ export function useAgendaEventPickerBilling({
|
||||
pacientesError.value = '';
|
||||
pacientesLoading.value = true;
|
||||
|
||||
let q = supabase
|
||||
.from('patients')
|
||||
.select('id,nome_completo,email_principal,telefone,status,avatar_url,tenant_id,responsible_member_id,created_at')
|
||||
let q = tenantDb().from('patients')
|
||||
.select('id,nome_completo,email_principal,telefone,status,avatar_url,responsible_member_id,created_at')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(500);
|
||||
|
||||
if (props.tenantId) q = q.eq('tenant_id', props.tenantId);
|
||||
if (props.restrictPatientsToOwner && props.patientScopeOwnerId) {
|
||||
q = q.eq('responsible_member_id', props.patientScopeOwnerId);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
// Shape interno de CommitmentItem:
|
||||
// {
|
||||
// service_id: uuid,
|
||||
@@ -56,7 +57,7 @@ export function useCommitmentServices() {
|
||||
async function loadItems(eventId) {
|
||||
if (!eventId) return [];
|
||||
|
||||
const { data, error } = await supabase.from('commitment_services').select('service_id, quantity, unit_price, discount_pct, discount_flat, final_price, services(name)').eq('commitment_id', eventId).order('created_at', { ascending: true });
|
||||
const { data, error } = await tenantDb().from('commitment_services').select('service_id, quantity, unit_price, discount_pct, discount_flat, final_price, services(name)').eq('commitment_id', eventId).order('created_at', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
return (data || []).map(_mapRow);
|
||||
@@ -73,7 +74,7 @@ export function useCommitmentServices() {
|
||||
if (!eventId) throw new Error('eventId é obrigatório para salvar commitment_services.');
|
||||
|
||||
// 1. Remove itens existentes deste evento
|
||||
const { error: deleteError } = await supabase.from('commitment_services').delete().eq('commitment_id', eventId);
|
||||
const { error: deleteError } = await tenantDb().from('commitment_services').delete().eq('commitment_id', eventId);
|
||||
|
||||
if (deleteError) throw deleteError;
|
||||
|
||||
@@ -89,14 +90,14 @@ export function useCommitmentServices() {
|
||||
final_price: item.final_price
|
||||
}));
|
||||
|
||||
const { error: insertError } = await supabase.from('commitment_services').insert(rows);
|
||||
const { error: insertError } = await tenantDb().from('commitment_services').insert(rows);
|
||||
|
||||
if (insertError) throw insertError;
|
||||
}
|
||||
|
||||
// 3. Marca a ocorrência como customizada (impede sobrescrita por edições do raiz)
|
||||
if (markCustomized) {
|
||||
const { error: updateError } = await supabase.from('agenda_eventos').update({ services_customized: true }).eq('id', eventId);
|
||||
const { error: updateError } = await tenantDb().from('agenda_eventos').update({ services_customized: true }).eq('id', eventId);
|
||||
|
||||
if (updateError) throw updateError;
|
||||
}
|
||||
@@ -107,7 +108,7 @@ export function useCommitmentServices() {
|
||||
async function loadRuleItems(ruleId) {
|
||||
if (!ruleId) return [];
|
||||
|
||||
const { data, error } = await supabase.from('recurrence_rule_services').select('service_id, quantity, unit_price, discount_pct, discount_flat, final_price, services(name)').eq('rule_id', ruleId).order('created_at', { ascending: true });
|
||||
const { data, error } = await tenantDb().from('recurrence_rule_services').select('service_id, quantity, unit_price, discount_pct, discount_flat, final_price, services(name)').eq('rule_id', ruleId).order('created_at', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
return (data || []).map(_mapRow);
|
||||
@@ -120,7 +121,7 @@ export function useCommitmentServices() {
|
||||
async function saveRuleItems(ruleId, items) {
|
||||
if (!ruleId) throw new Error('ruleId é obrigatório para salvar recurrence_rule_services.');
|
||||
|
||||
const { error: deleteError } = await supabase.from('recurrence_rule_services').delete().eq('rule_id', ruleId);
|
||||
const { error: deleteError } = await tenantDb().from('recurrence_rule_services').delete().eq('rule_id', ruleId);
|
||||
|
||||
if (deleteError) throw deleteError;
|
||||
|
||||
@@ -136,7 +137,7 @@ export function useCommitmentServices() {
|
||||
final_price: item.final_price
|
||||
}));
|
||||
|
||||
const { error: insertError } = await supabase.from('recurrence_rule_services').insert(rows);
|
||||
const { error: insertError } = await tenantDb().from('recurrence_rule_services').insert(rows);
|
||||
|
||||
if (insertError) throw insertError;
|
||||
}
|
||||
@@ -171,7 +172,7 @@ export function useCommitmentServices() {
|
||||
if (!ruleId) return;
|
||||
|
||||
// Busca IDs das ocorrências materializadas elegíveis
|
||||
let q = supabase.from('agenda_eventos').select('id').eq('recurrence_id', ruleId);
|
||||
let q = tenantDb().from('agenda_eventos').select('id').eq('recurrence_id', ruleId);
|
||||
|
||||
if (!ignoreCustomized) {
|
||||
q = q.eq('services_customized', false);
|
||||
@@ -189,8 +190,7 @@ export function useCommitmentServices() {
|
||||
// em batch evita N round-trips. Status considerados imutáveis: pending,
|
||||
// paid, overdue. cancelled é ok propagar (record foi descartado).
|
||||
const eventIds = events.map((e) => e.id);
|
||||
const { data: lockedEvents, error: frErr } = await supabase
|
||||
.from('financial_records')
|
||||
const { data: lockedEvents, error: frErr } = await tenantDb().from('financial_records')
|
||||
.select('agenda_evento_id')
|
||||
.in('agenda_evento_id', eventIds)
|
||||
.in('status', ['pending', 'paid', 'overdue']);
|
||||
@@ -202,7 +202,7 @@ export function useCommitmentServices() {
|
||||
|
||||
// Para cada evento elegível: delete + insert (padrão idempotente)
|
||||
for (const ev of eligibleEvents) {
|
||||
const { error: delErr } = await supabase.from('commitment_services').delete().eq('commitment_id', ev.id);
|
||||
const { error: delErr } = await tenantDb().from('commitment_services').delete().eq('commitment_id', ev.id);
|
||||
if (delErr) throw delErr;
|
||||
|
||||
if (items?.length) {
|
||||
@@ -215,7 +215,7 @@ export function useCommitmentServices() {
|
||||
discount_flat: item.discount_flat ?? 0,
|
||||
final_price: item.final_price
|
||||
}));
|
||||
const { error: insErr } = await supabase.from('commitment_services').insert(rows);
|
||||
const { error: insErr } = await tenantDb().from('commitment_services').insert(rows);
|
||||
if (insErr) throw insErr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
export function useDeterminedCommitments(tenantIdRef) {
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
@@ -39,10 +40,9 @@ export function useDeterminedCommitments(tenantIdRef) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
const { data, error: err } = await supabase
|
||||
.from('determined_commitments')
|
||||
.select('id,tenant_id,created_by,is_native,native_key,is_locked,active,name,description,bg_color,text_color,created_at,determined_commitment_fields(id,key,label,field_type,required,sort_order)')
|
||||
.eq('tenant_id', tenantId.value) // ✅ SOMENTE tenant corrente
|
||||
const { data, error: err } = await tenantDb().from('determined_commitments')
|
||||
.select('id,created_by,is_native,native_key,is_locked,active,name,description,bg_color,text_color,created_at,determined_commitment_fields(id,key,label,field_type,required,sort_order)')
|
||||
// ✅ SOMENTE tenant corrente
|
||||
.eq('active', true)
|
||||
.order('is_native', { ascending: false })
|
||||
.order('name', { ascending: true });
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
export function useFinancialExceptions() {
|
||||
const exceptions = ref([]);
|
||||
const loading = ref(false);
|
||||
@@ -39,7 +40,7 @@ export function useFinancialExceptions() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data, error: err } = await supabase.from('financial_exceptions').select('*').or(`owner_id.eq.${ownerId},owner_id.is.null`).order('exception_type', { ascending: true }).order('created_at', { ascending: true });
|
||||
const { data, error: err } = await tenantDb().from('financial_exceptions').select('*').or(`owner_id.eq.${ownerId},owner_id.is.null`).order('exception_type', { ascending: true }).order('created_at', { ascending: true });
|
||||
|
||||
if (err) throw err;
|
||||
exceptions.value = data || [];
|
||||
@@ -60,8 +61,7 @@ export function useFinancialExceptions() {
|
||||
error.value = '';
|
||||
try {
|
||||
if (payload.id) {
|
||||
const { error: err } = await supabase
|
||||
.from('financial_exceptions')
|
||||
const { error: err } = await tenantDb().from('financial_exceptions')
|
||||
.update({
|
||||
charge_mode: payload.charge_mode,
|
||||
charge_value: payload.charge_value ?? null,
|
||||
@@ -72,9 +72,8 @@ export function useFinancialExceptions() {
|
||||
.eq('id', payload.id);
|
||||
if (err) throw err;
|
||||
} else {
|
||||
const { error: err } = await supabase.from('financial_exceptions').insert({
|
||||
const { error: err } = await tenantDb().from('financial_exceptions').insert({
|
||||
owner_id: payload.owner_id,
|
||||
tenant_id: payload.tenant_id ?? null,
|
||||
exception_type: payload.exception_type,
|
||||
charge_mode: payload.charge_mode,
|
||||
charge_value: payload.charge_value ?? null,
|
||||
@@ -96,7 +95,7 @@ export function useFinancialExceptions() {
|
||||
async function remove(id) {
|
||||
error.value = '';
|
||||
try {
|
||||
const { error: err } = await supabase.from('financial_exceptions').delete().eq('id', id);
|
||||
const { error: err } = await tenantDb().from('financial_exceptions').delete().eq('id', id);
|
||||
if (err) throw err;
|
||||
exceptions.value = exceptions.value.filter((e) => e.id !== id);
|
||||
} catch (e) {
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
export function useInsurancePlans() {
|
||||
const plans = ref([]);
|
||||
const loading = ref(false);
|
||||
@@ -40,8 +41,7 @@ export function useInsurancePlans() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const { data, error: err } = await supabase
|
||||
.from('insurance_plans')
|
||||
const { data, error: err } = await tenantDb().from('insurance_plans')
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
@@ -66,8 +66,7 @@ export function useInsurancePlans() {
|
||||
error.value = null;
|
||||
try {
|
||||
if (payload.id) {
|
||||
const { error: err } = await supabase
|
||||
.from('insurance_plans')
|
||||
const { error: err } = await tenantDb().from('insurance_plans')
|
||||
.update({
|
||||
name: payload.name,
|
||||
notes: payload.notes || null,
|
||||
@@ -76,9 +75,8 @@ export function useInsurancePlans() {
|
||||
.eq('id', payload.id);
|
||||
if (err) throw err;
|
||||
} else {
|
||||
const { error: err } = await supabase.from('insurance_plans').insert({
|
||||
const { error: err } = await tenantDb().from('insurance_plans').insert({
|
||||
owner_id: payload.owner_id,
|
||||
tenant_id: payload.tenant_id,
|
||||
name: payload.name,
|
||||
notes: payload.notes || null
|
||||
});
|
||||
@@ -93,7 +91,7 @@ export function useInsurancePlans() {
|
||||
async function toggle(id, active) {
|
||||
error.value = null;
|
||||
try {
|
||||
const { error: err } = await supabase.from('insurance_plans').update({ active }).eq('id', id);
|
||||
const { error: err } = await tenantDb().from('insurance_plans').update({ active }).eq('id', id);
|
||||
if (err) throw err;
|
||||
const plan = plans.value.find((p) => p.id === id);
|
||||
if (plan) plan.active = active;
|
||||
@@ -106,7 +104,7 @@ export function useInsurancePlans() {
|
||||
async function remove(id) {
|
||||
error.value = null;
|
||||
try {
|
||||
const { error: err } = await supabase.from('insurance_plans').update({ active: false }).eq('id', id);
|
||||
const { error: err } = await tenantDb().from('insurance_plans').update({ active: false }).eq('id', id);
|
||||
if (err) throw err;
|
||||
const plan = plans.value.find((p) => p.id === id);
|
||||
if (plan) plan.active = false;
|
||||
@@ -120,8 +118,7 @@ export function useInsurancePlans() {
|
||||
error.value = null;
|
||||
try {
|
||||
if (payload.id) {
|
||||
const { error: err } = await supabase
|
||||
.from('insurance_plan_services')
|
||||
const { error: err } = await tenantDb().from('insurance_plan_services')
|
||||
.update({
|
||||
name: payload.name,
|
||||
value: payload.value
|
||||
@@ -129,7 +126,7 @@ export function useInsurancePlans() {
|
||||
.eq('id', payload.id);
|
||||
if (err) throw err;
|
||||
} else {
|
||||
const { error: err } = await supabase.from('insurance_plan_services').insert({
|
||||
const { error: err } = await tenantDb().from('insurance_plan_services').insert({
|
||||
insurance_plan_id: payload.insurance_plan_id,
|
||||
name: payload.name,
|
||||
value: payload.value
|
||||
@@ -145,7 +142,7 @@ export function useInsurancePlans() {
|
||||
async function togglePlanService(id, active) {
|
||||
error.value = null;
|
||||
try {
|
||||
const { error: err } = await supabase.from('insurance_plan_services').update({ active }).eq('id', id);
|
||||
const { error: err } = await tenantDb().from('insurance_plan_services').update({ active }).eq('id', id);
|
||||
if (err) throw err;
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao atualizar procedimento';
|
||||
@@ -156,7 +153,7 @@ export function useInsurancePlans() {
|
||||
async function removeDefinitivo(id) {
|
||||
error.value = null;
|
||||
try {
|
||||
const { error: err } = await supabase.from('insurance_plans').delete().eq('id', id);
|
||||
const { error: err } = await tenantDb().from('insurance_plans').delete().eq('id', id);
|
||||
if (err) throw err;
|
||||
plans.value = plans.value.filter((p) => p.id !== id);
|
||||
} catch (e) {
|
||||
@@ -168,7 +165,7 @@ export function useInsurancePlans() {
|
||||
async function removePlanService(id) {
|
||||
error.value = null;
|
||||
try {
|
||||
const { error: err } = await supabase.from('insurance_plan_services').delete().eq('id', id);
|
||||
const { error: err } = await tenantDb().from('insurance_plan_services').delete().eq('id', id);
|
||||
if (err) throw err;
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao remover procedimento';
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
export function usePatientDiscounts() {
|
||||
const discounts = ref([]);
|
||||
const loading = ref(false);
|
||||
@@ -40,7 +41,7 @@ export function usePatientDiscounts() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data, error: err } = await supabase.from('patient_discounts').select('*').eq('owner_id', ownerId).order('created_at', { ascending: false });
|
||||
const { data, error: err } = await tenantDb().from('patient_discounts').select('*').eq('owner_id', ownerId).order('created_at', { ascending: false });
|
||||
|
||||
if (err) throw err;
|
||||
discounts.value = data || [];
|
||||
@@ -53,17 +54,19 @@ export function usePatientDiscounts() {
|
||||
}
|
||||
|
||||
// ── Criar ou atualizar um desconto ───────────────────────────────────
|
||||
// payload deve conter: { owner_id, tenant_id, patient_id, discount_pct, discount_flat, ... }
|
||||
// payload deve conter: { owner_id, patient_id, discount_pct, discount_flat, ... }
|
||||
// Se payload.id estiver presente, faz UPDATE; caso contrário, INSERT.
|
||||
async function save(payload) {
|
||||
error.value = '';
|
||||
try {
|
||||
if (payload.id) {
|
||||
const { id, owner_id, tenant_id, ...fields } = payload;
|
||||
const { error: err } = await supabase.from('patient_discounts').update(fields).eq('id', id).eq('owner_id', owner_id);
|
||||
const { error: err } = await tenantDb().from('patient_discounts').update(fields).eq('id', id).eq('owner_id', owner_id);
|
||||
if (err) throw err;
|
||||
} else {
|
||||
const { error: err } = await supabase.from('patient_discounts').insert(payload);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { tenant_id: _dropTenantId, ...insertFields } = payload;
|
||||
const { error: err } = await tenantDb().from('patient_discounts').insert(insertFields);
|
||||
if (err) throw err;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -76,7 +79,7 @@ export function usePatientDiscounts() {
|
||||
async function remove(id) {
|
||||
error.value = '';
|
||||
try {
|
||||
const { error: err } = await supabase.from('patient_discounts').update({ active: false }).eq('id', id);
|
||||
const { error: err } = await tenantDb().from('patient_discounts').update({ active: false }).eq('id', id);
|
||||
if (err) throw err;
|
||||
discounts.value = discounts.value.filter((d) => d.id !== id);
|
||||
} catch (e) {
|
||||
@@ -95,8 +98,7 @@ export function usePatientDiscounts() {
|
||||
if (!ownerId || !patientId) return null;
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const { data, error: err } = await supabase
|
||||
.from('patient_discounts')
|
||||
const { data, error: err } = await tenantDb().from('patient_discounts')
|
||||
.select('*')
|
||||
.eq('owner_id', ownerId)
|
||||
.eq('patient_id', patientId)
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
export function useProfessionalPricing() {
|
||||
const rows = ref([]); // professional_pricing rows
|
||||
const loading = ref(false);
|
||||
@@ -34,7 +35,7 @@ export function useProfessionalPricing() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data, error: err } = await supabase.from('professional_pricing').select('id, determined_commitment_id, price, notes').eq('owner_id', ownerId);
|
||||
const { data, error: err } = await tenantDb().from('professional_pricing').select('id, determined_commitment_id, price, notes').eq('owner_id', ownerId);
|
||||
|
||||
if (err) throw err;
|
||||
rows.value = data || [];
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { assertTenantId } from '@/features/agenda/services/_tenantGuards';
|
||||
import { logRecurrence, logError, logPerf } from '@/support/supportLogger';
|
||||
@@ -326,7 +327,6 @@ function buildOccurrence(rule, date, originalIso, exception) {
|
||||
owner_id: rule.owner_id,
|
||||
therapist_id: rule.therapist_id,
|
||||
terapeuta_id: rule.therapist_id,
|
||||
tenant_id: rule.tenant_id,
|
||||
|
||||
// nome do paciente — injetado pelo loadAndExpand via _patient
|
||||
paciente_nome: rule._patient?.nome_completo ?? null,
|
||||
@@ -452,12 +452,7 @@ export function useRecurrence() {
|
||||
// Busca regras sem end_date (abertas) + regras com end_date >= rangeStart
|
||||
// Dois selects separados evitam problemas com .or() + .is.null no Supabase JS
|
||||
const baseQuery = () => {
|
||||
let q = supabase.from('recurrence_rules').select('*').eq('owner_id', ownerId).eq('status', 'ativo').lte('start_date', endISO).order('start_date', { ascending: true });
|
||||
// Filtra por tenant quando disponível — defesa em profundidade
|
||||
if (tenantId && tenantId !== 'null' && tenantId !== 'undefined') {
|
||||
q = q.eq('tenant_id', tenantId);
|
||||
}
|
||||
return q;
|
||||
return tenantDb().from('recurrence_rules').select('*').eq('owner_id', ownerId).eq('status', 'ativo').lte('start_date', endISO).order('start_date', { ascending: true });
|
||||
};
|
||||
|
||||
const [resOpen, resWithEnd] = await Promise.all([baseQuery().is('end_date', null), baseQuery().gte('end_date', startISO).not('end_date', 'is', null)]);
|
||||
@@ -504,11 +499,11 @@ export function useRecurrence() {
|
||||
const endISO = toISO(rangeEnd);
|
||||
|
||||
// Query 1 — comportamento original: exceções cujo original_date está no range
|
||||
const q1 = supabase.from('recurrence_exceptions').select('*').in('recurrence_id', ids).gte('original_date', startISO).lte('original_date', endISO);
|
||||
const q1 = tenantDb().from('recurrence_exceptions').select('*').in('recurrence_id', ids).gte('original_date', startISO).lte('original_date', endISO);
|
||||
|
||||
// Query 2 — bug fix: remarcações cujo new_date cai neste range
|
||||
// (original_date pode estar antes ou depois do range)
|
||||
const q2 = supabase.from('recurrence_exceptions').select('*').in('recurrence_id', ids).eq('type', 'reschedule_session').not('new_date', 'is', null).gte('new_date', startISO).lte('new_date', endISO);
|
||||
const q2 = tenantDb().from('recurrence_exceptions').select('*').in('recurrence_id', ids).eq('type', 'reschedule_session').not('new_date', 'is', null).gte('new_date', startISO).lte('new_date', endISO);
|
||||
|
||||
const [res1, res2] = await Promise.all([q1, q2]);
|
||||
|
||||
@@ -550,7 +545,7 @@ export function useRecurrence() {
|
||||
// Busca nomes dos pacientes das regras carregadas
|
||||
const patientIds = [...new Set(rules.value.map((r) => r.patient_id).filter(Boolean))];
|
||||
if (patientIds.length) {
|
||||
const { data: patients } = await supabase.from('patients').select('id, nome_completo, avatar_url').in('id', patientIds);
|
||||
const { data: patients } = await tenantDb().from('patients').select('id, nome_completo, avatar_url').in('id', patientIds);
|
||||
// injeta nome diretamente na regra para o buildOccurrence usar
|
||||
const pMap = new Map((patients || []).map((p) => [p.id, p]));
|
||||
for (const rule of rules.value) {
|
||||
@@ -579,15 +574,14 @@ export function useRecurrence() {
|
||||
|
||||
/**
|
||||
* Cria uma nova regra de recorrência.
|
||||
* tenant_id é injetado do tenantStore se não vier no payload (defesa em profundidade).
|
||||
* tenant_id é dropado defensivamente — schema-per-tenant não tem essa coluna.
|
||||
* @param {Object} rule - campos da tabela recurrence_rules
|
||||
* @returns {Object} regra criada
|
||||
*/
|
||||
async function createRule(rule) {
|
||||
const tenantId = currentTenantId();
|
||||
logRecurrence('createRule →', { patient_id: rule?.patient_id, type: rule?.type });
|
||||
const safeRule = { ...rule, tenant_id: rule?.tenant_id || tenantId };
|
||||
const { data, error: err } = await supabase.from('recurrence_rules').insert([safeRule]).select('*').single();
|
||||
const { tenant_id: _dropTenantId, ...safeRule } = rule || {};
|
||||
const { data, error: err } = await tenantDb().from('recurrence_rules').insert([safeRule]).select('*').single();
|
||||
if (err) {
|
||||
logError('useRecurrence', 'createRule ERRO', err);
|
||||
throw err;
|
||||
@@ -598,15 +592,14 @@ export function useRecurrence() {
|
||||
|
||||
/**
|
||||
* Atualiza a regra toda (editar todos).
|
||||
* Filtro adicional por tenant_id — defesa em profundidade (RLS cobre, mas reforçamos).
|
||||
* Isolamento multi-tenant garantido pelo schema do tenant (tenantDb).
|
||||
*/
|
||||
async function updateRule(id, patch) {
|
||||
const tenantId = currentTenantId();
|
||||
const { data, error: err } = await supabase
|
||||
.from('recurrence_rules')
|
||||
const { data, error: err } = await tenantDb().from('recurrence_rules')
|
||||
.update({ ...patch, updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
.select('*')
|
||||
.single();
|
||||
if (err) throw err;
|
||||
@@ -614,15 +607,14 @@ export function useRecurrence() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancela a série inteira (filtro por tenant_id — defesa em profundidade).
|
||||
* Cancela a série inteira.
|
||||
*/
|
||||
async function cancelRule(id) {
|
||||
const tenantId = currentTenantId();
|
||||
const { error: err } = await supabase
|
||||
.from('recurrence_rules')
|
||||
const { error: err } = await tenantDb().from('recurrence_rules')
|
||||
.update({ status: 'cancelado', updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId);
|
||||
;
|
||||
if (err) throw err;
|
||||
}
|
||||
|
||||
@@ -631,7 +623,9 @@ export function useRecurrence() {
|
||||
* Retorna o id da nova regra criada
|
||||
*/
|
||||
async function splitRuleAt(id, fromDateISO) {
|
||||
const tenantId = currentTenantId();
|
||||
const { data, error: err } = await supabase.rpc('split_recurrence_at', {
|
||||
p_tenant_id: tenantId,
|
||||
p_recurrence_id: id,
|
||||
p_from_date: fromDateISO
|
||||
});
|
||||
@@ -643,7 +637,9 @@ export function useRecurrence() {
|
||||
* Cancela a série a partir de uma data
|
||||
*/
|
||||
async function cancelRuleFrom(id, fromDateISO) {
|
||||
const tenantId = currentTenantId();
|
||||
const { error: err } = await supabase.rpc('cancel_recurrence_from', {
|
||||
p_tenant_id: tenantId,
|
||||
p_recurrence_id: id,
|
||||
p_from_date: fromDateISO
|
||||
});
|
||||
@@ -654,13 +650,11 @@ export function useRecurrence() {
|
||||
|
||||
/**
|
||||
* Cria ou atualiza uma exceção para uma ocorrência específica.
|
||||
* tenant_id é injetado do tenantStore se não vier no payload.
|
||||
* tenant_id é dropado defensivamente — schema-per-tenant não tem essa coluna.
|
||||
*/
|
||||
async function upsertException(ex) {
|
||||
const tenantId = currentTenantId();
|
||||
const safeEx = { ...ex, tenant_id: ex?.tenant_id || tenantId };
|
||||
const { data, error: err } = await supabase
|
||||
.from('recurrence_exceptions')
|
||||
const { tenant_id: _dropTenantId, ...safeEx } = ex || {};
|
||||
const { data, error: err } = await tenantDb().from('recurrence_exceptions')
|
||||
.upsert([safeEx], { onConflict: 'recurrence_id,original_date' })
|
||||
.select('*')
|
||||
.single();
|
||||
@@ -670,16 +664,14 @@ export function useRecurrence() {
|
||||
|
||||
/**
|
||||
* Remove uma exceção (restaura a ocorrência ao normal).
|
||||
* Filtro por tenant_id — defesa em profundidade.
|
||||
*/
|
||||
async function deleteException(recurrenceId, originalDate) {
|
||||
const tenantId = currentTenantId();
|
||||
const { error: err } = await supabase
|
||||
.from('recurrence_exceptions')
|
||||
const { error: err } = await tenantDb().from('recurrence_exceptions')
|
||||
.delete()
|
||||
.eq('recurrence_id', recurrenceId)
|
||||
.eq('original_date', originalDate)
|
||||
.eq('tenant_id', tenantId);
|
||||
;
|
||||
if (err) throw err;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
export function useServices() {
|
||||
const services = ref([]);
|
||||
const loading = ref(false);
|
||||
@@ -39,7 +40,7 @@ export function useServices() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data, error: err } = await supabase.from('services').select('id, name, description, price, duration_min, active').eq('owner_id', ownerId).order('created_at', { ascending: true });
|
||||
const { data, error: err } = await tenantDb().from('services').select('id, name, description, price, duration_min, active').eq('owner_id', ownerId).order('created_at', { ascending: true });
|
||||
|
||||
if (err) throw err;
|
||||
services.value = data || [];
|
||||
@@ -61,7 +62,7 @@ export function useServices() {
|
||||
// Nome unico por owner (case-insensitive). No update,
|
||||
// ignora o proprio id pra nao conflitar consigo mesmo
|
||||
// quando o usuario salva sem mudar o nome.
|
||||
let dupQuery = supabase.from('services').select('id').eq('owner_id', payload.owner_id).ilike('name', name).limit(1);
|
||||
let dupQuery = tenantDb().from('services').select('id').eq('owner_id', payload.owner_id).ilike('name', name).limit(1);
|
||||
if (payload.id) dupQuery = dupQuery.neq('id', payload.id);
|
||||
const { data: dups, error: dupErr } = await dupQuery;
|
||||
if (dupErr) throw dupErr;
|
||||
@@ -71,10 +72,12 @@ export function useServices() {
|
||||
|
||||
if (payload.id) {
|
||||
const { id, owner_id, tenant_id, ...fields } = payload;
|
||||
const { error: err } = await supabase.from('services').update(fields).eq('id', id).eq('owner_id', owner_id);
|
||||
const { error: err } = await tenantDb().from('services').update(fields).eq('id', id).eq('owner_id', owner_id);
|
||||
if (err) throw err;
|
||||
} else {
|
||||
const { error: err } = await supabase.from('services').insert(payload);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { tenant_id: _dropTenantId, ...insertFields } = payload;
|
||||
const { error: err } = await tenantDb().from('services').insert(insertFields);
|
||||
if (err) throw err;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -86,7 +89,7 @@ export function useServices() {
|
||||
async function toggle(id, active) {
|
||||
error.value = '';
|
||||
try {
|
||||
const { error: err } = await supabase.from('services').update({ active }).eq('id', id);
|
||||
const { error: err } = await tenantDb().from('services').update({ active }).eq('id', id);
|
||||
if (err) throw err;
|
||||
const svc = services.value.find((s) => s.id === id);
|
||||
if (svc) svc.active = active;
|
||||
@@ -99,7 +102,7 @@ export function useServices() {
|
||||
async function remove(id) {
|
||||
error.value = '';
|
||||
try {
|
||||
const { error: err } = await supabase.from('services').delete().eq('id', id);
|
||||
const { error: err } = await tenantDb().from('services').delete().eq('id', id);
|
||||
if (err) throw err;
|
||||
services.value = services.value.filter((s) => s.id !== id);
|
||||
} catch (e) {
|
||||
|
||||
@@ -47,6 +47,7 @@ import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettin
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const toast = useToast();
|
||||
@@ -677,12 +678,11 @@ async function loadMonthSearchRows() {
|
||||
const end = new Date(d.getFullYear(), d.getMonth() + 1, 1).toISOString();
|
||||
monthSearchLoading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
const { data, error } = await tenantDb().from('agenda_eventos')
|
||||
.select(
|
||||
'id, owner_id, tenant_id, tipo, status, titulo, inicio_em, fim_em, observacoes, modalidade, determined_commitment_id, insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id, patients!agenda_eventos_patient_id_fkey(nome_completo)'
|
||||
'id, owner_id, tipo, status, titulo, inicio_em, fim_em, observacoes, modalidade, determined_commitment_id, insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id, patients!agenda_eventos_patient_id_fkey(nome_completo)'
|
||||
)
|
||||
.eq('tenant_id', tid)
|
||||
|
||||
.in('owner_id', ids)
|
||||
.is('mirror_of_event_id', null)
|
||||
.gte('inicio_em', start)
|
||||
@@ -915,7 +915,7 @@ async function debugPatientsForColumn(staffUserId) {
|
||||
console.log('tenant_member_id (mapeado):', memberId);
|
||||
|
||||
try {
|
||||
const { count, error } = await supabase.from('patients').select('id', { count: 'exact', head: true }).eq('tenant_id', tid);
|
||||
const { count, error } = await tenantDb().from('patients').select('id', { count: 'exact', head: true });
|
||||
if (error) throw error;
|
||||
console.log('patients total no tenant:', count);
|
||||
} catch (e) {
|
||||
@@ -924,7 +924,7 @@ async function debugPatientsForColumn(staffUserId) {
|
||||
|
||||
if (memberId && isUuid(memberId)) {
|
||||
try {
|
||||
const { count, error } = await supabase.from('patients').select('id', { count: 'exact', head: true }).eq('tenant_id', tid).eq('responsible_member_id', memberId);
|
||||
const { count, error } = await tenantDb().from('patients').select('id', { count: 'exact', head: true }).eq('responsible_member_id', memberId);
|
||||
if (error) throw error;
|
||||
console.log('patients por responsible_member_id:', count);
|
||||
} catch (e) {
|
||||
@@ -932,10 +932,9 @@ async function debugPatientsForColumn(staffUserId) {
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('patients')
|
||||
const { data, error } = await tenantDb().from('patients')
|
||||
.select('id,nome_completo,email_principal,telefone,responsible_member_id,created_at')
|
||||
.eq('tenant_id', tid)
|
||||
|
||||
.eq('responsible_member_id', memberId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(5);
|
||||
@@ -1212,8 +1211,7 @@ async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim
|
||||
if (!is_virtual || !inicio_em) return;
|
||||
const rid = row.recurrence_id ?? row.serie_id ?? null;
|
||||
const rDate = recurrence_date || inicio_em?.slice(0, 10);
|
||||
const { data: existing } = await supabase
|
||||
.from('agenda_eventos')
|
||||
const { data: existing } = await tenantDb().from('agenda_eventos')
|
||||
.select('id')
|
||||
.eq('recurrence_id', rid)
|
||||
.eq('recurrence_date', rDate)
|
||||
@@ -1281,9 +1279,8 @@ async function _offerBillingContract(basePayload, recorrencia, tenantId) {
|
||||
rejectLabel: 'Agora não',
|
||||
accept: async () => {
|
||||
try {
|
||||
const { error } = await supabase.from('billing_contracts').insert({
|
||||
const { error } = await tenantDb().from('billing_contracts').insert({
|
||||
owner_id: basePayload.owner_id,
|
||||
tenant_id: tenantId,
|
||||
patient_id: basePayload.paciente_id,
|
||||
type: 'package',
|
||||
total_sessions: n,
|
||||
@@ -1454,7 +1451,7 @@ async function onDialogSave(arg) {
|
||||
extra_fields: basePayload.extra_fields ?? null
|
||||
});
|
||||
if (arg.onSaved) {
|
||||
const { data: existing } = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', originalDate).maybeSingle();
|
||||
const { data: existing } = await tenantDb().from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', originalDate).maybeSingle();
|
||||
if (existing?.id) {
|
||||
eventId = existing.id;
|
||||
} else {
|
||||
@@ -1550,8 +1547,7 @@ async function onDialogSave(arg) {
|
||||
});
|
||||
|
||||
// Propaga campos não-serviço para sessões já materializadas da série
|
||||
await supabase
|
||||
.from('agenda_eventos')
|
||||
await tenantDb().from('agenda_eventos')
|
||||
.update({
|
||||
modalidade: basePayload.modalidade ?? 'presencial',
|
||||
titulo_custom: basePayload.titulo_custom ?? null,
|
||||
@@ -1599,8 +1595,7 @@ async function onDialogSave(arg) {
|
||||
});
|
||||
|
||||
// Propaga TODOS os campos para TODAS as sessões materializadas (sem exceção)
|
||||
await supabase
|
||||
.from('agenda_eventos')
|
||||
await tenantDb().from('agenda_eventos')
|
||||
.update({
|
||||
modalidade: basePayload.modalidade ?? 'presencial',
|
||||
titulo_custom: basePayload.titulo_custom ?? null,
|
||||
@@ -1708,7 +1703,7 @@ async function onDialogDelete(arg) {
|
||||
|
||||
if (isVirtual) {
|
||||
const rDate = row.original_date || row.inicio_em?.slice(0, 10);
|
||||
const existing = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', rDate).maybeSingle();
|
||||
const existing = await tenantDb().from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', rDate).maybeSingle();
|
||||
|
||||
if (existing.data?.id) {
|
||||
await updateClinic(existing.data.id, { recurrence_id: null, recurrence_date: null }, { tenantId: tid });
|
||||
@@ -1926,8 +1921,7 @@ async function loadMiniMonthEvents(refDate) {
|
||||
try {
|
||||
const tid = tenantId.value;
|
||||
// 1. Eventos normais (bolinhas)
|
||||
let evQ = supabase.from('agenda_eventos').select('inicio_em').gte('inicio_em', start.toISOString()).lte('inicio_em', end.toISOString());
|
||||
if (tid) evQ = evQ.eq('tenant_id', tid);
|
||||
let evQ = tenantDb().from('agenda_eventos').select('inicio_em').gte('inicio_em', start.toISOString()).lte('inicio_em', end.toISOString());
|
||||
const { data: evData } = await evQ;
|
||||
|
||||
const evSet = new Set();
|
||||
@@ -1961,7 +1955,7 @@ async function loadMiniMonthEvents(refDate) {
|
||||
const isoStart = `${year}-${pad(month + 1)}-01`;
|
||||
const lastDay = new Date(year, month + 1, 0).getDate();
|
||||
const isoEnd = `${year}-${pad(month + 1)}-${pad(lastDay)}`;
|
||||
let blkQ = supabase.from('agenda_bloqueios').select('data_inicio').is('hora_inicio', null).gte('data_inicio', isoStart).lte('data_inicio', isoEnd);
|
||||
let blkQ = tenantDb().from('agenda_bloqueios').select('data_inicio').is('hora_inicio', null).gte('data_inicio', isoStart).lte('data_inicio', isoEnd);
|
||||
if (clinicOwnerId.value) blkQ = blkQ.eq('owner_id', clinicOwnerId.value);
|
||||
const { data: blkData } = await blkQ;
|
||||
miniBlockedDaySet.value = new Set((blkData || []).map((r) => r.data_inicio));
|
||||
@@ -2050,10 +2044,9 @@ async function bloquearFeriadoDoAlerta(feriado) {
|
||||
if (!clinicOwnerId.value || !tenantId.value) return;
|
||||
feriadosAlertaSalvando.value = feriado.data;
|
||||
try {
|
||||
const { error } = await supabase.from('agenda_bloqueios').insert([
|
||||
const { error } = await tenantDb().from('agenda_bloqueios').insert([
|
||||
{
|
||||
owner_id: clinicOwnerId.value,
|
||||
tenant_id: tenantId.value,
|
||||
tipo: 'bloqueio',
|
||||
recorrente: false,
|
||||
titulo: `Feriado: ${feriado.nome}`,
|
||||
@@ -2065,7 +2058,7 @@ async function bloquearFeriadoDoAlerta(feriado) {
|
||||
}
|
||||
]);
|
||||
if (error) throw error;
|
||||
await supabase.from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', clinicOwnerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
|
||||
await tenantDb().from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', clinicOwnerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
|
||||
|
||||
feriadosAlertaBloqueados.value = new Set([...feriadosAlertaBloqueados.value, feriado.data]);
|
||||
miniBlockedDaySet.value = new Set([...miniBlockedDaySet.value, feriado.data]);
|
||||
@@ -2088,7 +2081,7 @@ async function desbloquearFeriadoDoAlerta(feriado) {
|
||||
if (!clinicOwnerId.value) return;
|
||||
feriadosAlertaSalvando.value = `unblock_${feriado.data}`;
|
||||
try {
|
||||
const { error } = await supabase.from('agenda_bloqueios').delete().eq('owner_id', clinicOwnerId.value).eq('data_inicio', feriado.data).in('origem', ['agenda_feriado', 'agenda_dia']);
|
||||
const { error } = await tenantDb().from('agenda_bloqueios').delete().eq('owner_id', clinicOwnerId.value).eq('data_inicio', feriado.data).in('origem', ['agenda_feriado', 'agenda_dia']);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useAgendaClinicStaff } from '@/features/agenda/composables/useAgendaClinicStaff';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
@@ -78,11 +79,10 @@ async function load() {
|
||||
if (!userId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
let q = supabase.from('recurrence_rules').select('*').order('start_date', { ascending: false });
|
||||
let q = tenantDb().from('recurrence_rules').select('*').order('start_date', { ascending: false });
|
||||
|
||||
if (isClinic.value) {
|
||||
if (!tenantId.value) return;
|
||||
q = q.eq('tenant_id', tenantId.value);
|
||||
if (filterOwner.value) q = q.eq('owner_id', filterOwner.value);
|
||||
} else {
|
||||
q = q.eq('owner_id', userId.value);
|
||||
@@ -97,7 +97,7 @@ async function load() {
|
||||
const patientIds = [...new Set(rawRules.map((r) => r.patient_id).filter(Boolean))];
|
||||
const patientMap = {};
|
||||
if (patientIds.length) {
|
||||
const { data: pts } = await supabase.from('patients').select('id, nome_completo, avatar_url').in('id', patientIds);
|
||||
const { data: pts } = await tenantDb().from('patients').select('id, nome_completo, avatar_url').in('id', patientIds);
|
||||
for (const p of pts || []) patientMap[p.id] = p;
|
||||
}
|
||||
for (const r of rawRules) r._patient = patientMap[r.patient_id] || null;
|
||||
@@ -115,8 +115,8 @@ async function load() {
|
||||
|
||||
async function reloadSessions(ruleIds) {
|
||||
const [exRes, sessRes] = await Promise.all([
|
||||
supabase.from('recurrence_exceptions').select('*').in('recurrence_id', ruleIds).order('original_date'),
|
||||
supabase.from('agenda_eventos').select('id, recurrence_id, recurrence_date, status, inicio_em, fim_em').in('recurrence_id', ruleIds).order('inicio_em')
|
||||
tenantDb().from('recurrence_exceptions').select('*').in('recurrence_id', ruleIds).order('original_date'),
|
||||
tenantDb().from('agenda_eventos').select('id, recurrence_id, recurrence_date, status, inicio_em, fim_em').in('recurrence_id', ruleIds).order('inicio_em')
|
||||
]);
|
||||
const exm = {};
|
||||
for (const ex of exRes.data || []) {
|
||||
@@ -254,17 +254,16 @@ const PILL_CLASS = {
|
||||
async function onPillStatusChange(rule, s, newStatus) {
|
||||
try {
|
||||
if (s.real_id) {
|
||||
await supabase.from('agenda_eventos').update({ status: newStatus }).eq('id', s.real_id);
|
||||
await tenantDb().from('agenda_eventos').update({ status: newStatus }).eq('id', s.real_id);
|
||||
} else {
|
||||
const { data: ex } = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', rule.id).eq('recurrence_date', s.date).maybeSingle();
|
||||
const { data: ex } = await tenantDb().from('agenda_eventos').select('id').eq('recurrence_id', rule.id).eq('recurrence_date', s.date).maybeSingle();
|
||||
if (ex?.id) {
|
||||
await supabase.from('agenda_eventos').update({ status: newStatus }).eq('id', ex.id);
|
||||
await tenantDb().from('agenda_eventos').update({ status: newStatus }).eq('id', ex.id);
|
||||
} else {
|
||||
await supabase.from('agenda_eventos').insert({
|
||||
await tenantDb().from('agenda_eventos').insert({
|
||||
recurrence_id: rule.id,
|
||||
recurrence_date: s.date,
|
||||
owner_id: rule.owner_id,
|
||||
tenant_id: rule.tenant_id,
|
||||
tipo: 'sessao',
|
||||
status: newStatus,
|
||||
inicio_em: s.date + 'T' + (rule.start_time || '00:00') + ':00',
|
||||
@@ -287,7 +286,7 @@ async function onCancelRule(rule) {
|
||||
const name = rule._patient?.nome_completo || 'paciente';
|
||||
if (!confirm(`Encerrar a série de "${name}"?\n\nSessões futuras deixarão de ser geradas. Sessões passadas já registradas são mantidas.`)) return;
|
||||
try {
|
||||
await supabase.from('recurrence_rules').update({ status: 'cancelado', updated_at: new Date().toISOString() }).eq('id', rule.id);
|
||||
await tenantDb().from('recurrence_rules').update({ status: 'cancelado', updated_at: new Date().toISOString() }).eq('id', rule.id);
|
||||
toast.add({ severity: 'success', summary: 'Série encerrada', life: 2000 });
|
||||
await load();
|
||||
} catch (e) {
|
||||
@@ -297,7 +296,7 @@ async function onCancelRule(rule) {
|
||||
|
||||
async function onReactivateRule(rule) {
|
||||
try {
|
||||
await supabase.from('recurrence_rules').update({ status: 'ativo', updated_at: new Date().toISOString() }).eq('id', rule.id);
|
||||
await tenantDb().from('recurrence_rules').update({ status: 'ativo', updated_at: new Date().toISOString() }).eq('id', rule.id);
|
||||
toast.add({ severity: 'success', summary: 'Série reativada', life: 2000 });
|
||||
await load();
|
||||
} catch (e) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useRouter, useRoute } from 'vue-router';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
|
||||
@@ -606,8 +607,7 @@ async function loadMonthSearchRows() {
|
||||
try {
|
||||
// 1. Eventos reais do banco — inclui recurrence_id/recurrence_date para
|
||||
// mergeWithStoredSessions deduplicar sessões materializadas de séries.
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
const { data, error } = await tenantDb().from('agenda_eventos')
|
||||
.select(
|
||||
'id, owner_id, tipo, status, titulo, inicio_em, fim_em, observacoes, modalidade, determined_commitment_id, insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id, recurrence_id, recurrence_date, patients!agenda_eventos_patient_id_fkey(nome_completo, status)'
|
||||
)
|
||||
@@ -1031,7 +1031,7 @@ const desativadoFcRef = ref(null);
|
||||
async function loadDesativados() {
|
||||
if (!ownerId.value) return;
|
||||
try {
|
||||
const { data: pats, error: pErr } = await supabase.from('patients').select('id, nome_completo, status').eq('owner_id', ownerId.value).in('status', ['Inativo', 'Arquivado']);
|
||||
const { data: pats, error: pErr } = await tenantDb().from('patients').select('id, nome_completo, status').eq('owner_id', ownerId.value).in('status', ['Inativo', 'Arquivado']);
|
||||
|
||||
if (pErr) {
|
||||
console.warn('[loadDesativados] patients error:', pErr);
|
||||
@@ -1044,9 +1044,8 @@ async function loadDesativados() {
|
||||
}
|
||||
|
||||
const patIds = pats.map((p) => p.id);
|
||||
const sessQ = supabase.from('agenda_eventos').select('id, patient_id, inicio_em, fim_em, status, titulo, modalidade, determined_commitment_id').in('patient_id', patIds).order('inicio_em', { ascending: true });
|
||||
const sessQ = tenantDb().from('agenda_eventos').select('id, patient_id, inicio_em, fim_em, status, titulo, modalidade, determined_commitment_id').in('patient_id', patIds).order('inicio_em', { ascending: true });
|
||||
if (ownerId.value) sessQ.eq('owner_id', ownerId.value);
|
||||
if (clinicTenantId.value) sessQ.eq('tenant_id', clinicTenantId.value);
|
||||
const { data: sessions, error: sErr } = await sessQ;
|
||||
|
||||
if (sErr) {
|
||||
@@ -1278,7 +1277,7 @@ async function loadMiniMonthEvents(refDate) {
|
||||
|
||||
try {
|
||||
// 1. Eventos reais (agenda_eventos)
|
||||
const { data: evData } = await supabase.from('agenda_eventos').select('inicio_em').eq('owner_id', ownerId.value).gte('inicio_em', start.toISOString()).lte('inicio_em', end.toISOString());
|
||||
const { data: evData } = await tenantDb().from('agenda_eventos').select('inicio_em').eq('owner_id', ownerId.value).gte('inicio_em', start.toISOString()).lte('inicio_em', end.toISOString());
|
||||
|
||||
const evSet = new Set();
|
||||
for (const r of evData || []) {
|
||||
@@ -1303,8 +1302,7 @@ async function loadMiniMonthEvents(refDate) {
|
||||
const isoStart = `${year}-${pad(month + 1)}-01`;
|
||||
const lastDay = new Date(year, month + 1, 0).getDate();
|
||||
const isoEnd = `${year}-${pad(month + 1)}-${pad(lastDay)}`;
|
||||
const { data: blkData } = await supabase
|
||||
.from('agenda_bloqueios')
|
||||
const { data: blkData } = await tenantDb().from('agenda_bloqueios')
|
||||
.select('data_inicio')
|
||||
.eq('owner_id', ownerId.value || '')
|
||||
.is('hora_inicio', null)
|
||||
@@ -1398,10 +1396,9 @@ async function bloquearFeriadoDoAlerta(feriado) {
|
||||
if (!ownerId.value || !clinicTenantId.value) return;
|
||||
feriadosAlertaSalvando.value = feriado.data;
|
||||
try {
|
||||
const { error } = await supabase.from('agenda_bloqueios').insert([
|
||||
const { error } = await tenantDb().from('agenda_bloqueios').insert([
|
||||
{
|
||||
owner_id: ownerId.value,
|
||||
tenant_id: clinicTenantId.value,
|
||||
tipo: 'bloqueio',
|
||||
recorrente: false,
|
||||
titulo: `Feriado: ${feriado.nome}`,
|
||||
@@ -1413,7 +1410,7 @@ async function bloquearFeriadoDoAlerta(feriado) {
|
||||
}
|
||||
]);
|
||||
if (error) throw error;
|
||||
await supabase.from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', ownerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
|
||||
await tenantDb().from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', ownerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
|
||||
|
||||
feriadosAlertaBloqueados.value = new Set([...feriadosAlertaBloqueados.value, feriado.data]);
|
||||
miniBlockedDaySet.value = new Set([...miniBlockedDaySet.value, feriado.data]);
|
||||
@@ -1438,7 +1435,7 @@ async function desbloquearFeriadoDoAlerta(feriado) {
|
||||
if (!ownerId.value) return;
|
||||
feriadosAlertaSalvando.value = `unblock_${feriado.data}`;
|
||||
try {
|
||||
const { error } = await supabase.from('agenda_bloqueios').delete().eq('owner_id', ownerId.value).eq('data_inicio', feriado.data).in('origem', ['agenda_feriado', 'agenda_dia']);
|
||||
const { error } = await tenantDb().from('agenda_bloqueios').delete().eq('owner_id', ownerId.value).eq('data_inicio', feriado.data).in('origem', ['agenda_feriado', 'agenda_dia']);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
@@ -1736,8 +1733,7 @@ async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim
|
||||
if (!is_virtual || !inicio_em) return;
|
||||
const rid = row.recurrence_id ?? row.serie_id ?? null;
|
||||
const rDate = recurrence_date || inicio_em?.slice(0, 10);
|
||||
const { data: existing } = await supabase
|
||||
.from('agenda_eventos')
|
||||
const { data: existing } = await tenantDb().from('agenda_eventos')
|
||||
.select('id')
|
||||
.eq('recurrence_id', rid)
|
||||
.eq('recurrence_date', rDate)
|
||||
@@ -1807,9 +1803,8 @@ async function _offerBillingContract(normalized, recorrencia, tenantId) {
|
||||
rejectLabel: 'Agora não',
|
||||
accept: async () => {
|
||||
try {
|
||||
const { error } = await supabase.from('billing_contracts').insert({
|
||||
const { error } = await tenantDb().from('billing_contracts').insert({
|
||||
owner_id: normalized.owner_id,
|
||||
tenant_id: tenantId,
|
||||
patient_id: normalized.paciente_id,
|
||||
type: 'package',
|
||||
total_sessions: n,
|
||||
@@ -1933,12 +1928,11 @@ async function onDialogSave(arg) {
|
||||
if (recorrencia?.conflitos?.length && createdRule?.id) {
|
||||
const exceptions = recorrencia.conflitos.map((c) => ({
|
||||
recurrence_id: createdRule.id,
|
||||
tenant_id: clinicId,
|
||||
original_date: c.date,
|
||||
type: c.conflict.type === 'feriado' ? 'holiday_block' : c.conflict.type === 'bloqueado' ? 'cancel_session' : c.conflict.type === 'folga' ? 'cancel_session' : 'cancel_session',
|
||||
reason: c.conflict.label
|
||||
}));
|
||||
const { error: exErr } = await supabase.from('recurrence_exceptions').insert(exceptions);
|
||||
const { error: exErr } = await tenantDb().from('recurrence_exceptions').insert(exceptions);
|
||||
if (exErr) logError('AgendaTerapeutaPage', 'onDialogSave: erro ao inserir exceptions', exErr);
|
||||
}
|
||||
|
||||
@@ -1998,7 +1992,7 @@ async function onDialogSave(arg) {
|
||||
extra_fields: normalized.extra_fields ?? null
|
||||
});
|
||||
if (arg.onSaved) {
|
||||
const { data: existing } = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', originalDate).maybeSingle();
|
||||
const { data: existing } = await tenantDb().from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', originalDate).maybeSingle();
|
||||
if (existing?.id) {
|
||||
eventId = existing.id;
|
||||
} else {
|
||||
@@ -2091,8 +2085,7 @@ async function onDialogSave(arg) {
|
||||
});
|
||||
|
||||
// Propaga campos não-serviço para sessões já materializadas da série
|
||||
await supabase
|
||||
.from('agenda_eventos')
|
||||
await tenantDb().from('agenda_eventos')
|
||||
.update({
|
||||
modalidade: normalized.modalidade ?? 'presencial',
|
||||
titulo_custom: normalized.titulo_custom ?? null,
|
||||
@@ -2140,8 +2133,7 @@ async function onDialogSave(arg) {
|
||||
});
|
||||
|
||||
// Propaga TODOS os campos para TODAS as sessões materializadas (sem exceção)
|
||||
await supabase
|
||||
.from('agenda_eventos')
|
||||
await tenantDb().from('agenda_eventos')
|
||||
.update({
|
||||
modalidade: normalized.modalidade ?? 'presencial',
|
||||
titulo_custom: normalized.titulo_custom ?? null,
|
||||
@@ -2205,8 +2197,7 @@ async function onDialogSave(arg) {
|
||||
let detail = 'Já existe um compromisso nesse horário. Verifique a agenda e escolha outro horário.';
|
||||
try {
|
||||
if (normalized?.inicio_em && normalized?.fim_em && normalized?.owner_id) {
|
||||
const { data: conflicting } = await supabase
|
||||
.from('agenda_eventos')
|
||||
const { data: conflicting } = await tenantDb().from('agenda_eventos')
|
||||
.select('titulo, inicio_em, fim_em')
|
||||
.eq('owner_id', normalized.owner_id)
|
||||
.lt('inicio_em', normalized.fim_em)
|
||||
@@ -2276,7 +2267,7 @@ async function onDialogDelete(arg) {
|
||||
if (isVirtual) {
|
||||
// Ocorrência virtual: materializa como evento avulso (sem recurrence_id)
|
||||
const rDate = row.original_date || row.inicio_em?.slice(0, 10);
|
||||
const existing = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', rDate).maybeSingle();
|
||||
const existing = await tenantDb().from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', rDate).maybeSingle();
|
||||
|
||||
if (existing.data?.id) {
|
||||
await update(existing.data.id, { recurrence_id: null, recurrence_date: null });
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue';
|
||||
@@ -241,17 +242,15 @@ async function converterEmSessao(s) {
|
||||
async function encontrarOuCriarPaciente(s) {
|
||||
const email = s.paciente_email?.toLowerCase().trim();
|
||||
if (email) {
|
||||
const { data: found } = await supabase.from('patients').select('id').eq('tenant_id', tenantId.value).ilike('email_principal', email).maybeSingle();
|
||||
const { data: found } = await tenantDb().from('patients').select('id').ilike('email_principal', email).maybeSingle();
|
||||
if (found?.id) return found.id;
|
||||
}
|
||||
const { data: memberData, error: memberErr } = await supabase.from('tenant_members').select('id').eq('tenant_id', tenantId.value).eq('user_id', ownerId.value).eq('status', 'active').maybeSingle();
|
||||
if (memberErr || !memberData?.id) throw new Error('Membro ativo não encontrado para criação do paciente.');
|
||||
const scope = isClinic.value ? 'clinic' : 'therapist';
|
||||
const nomeCompleto_ = [s.paciente_nome, s.paciente_sobrenome].filter(Boolean).join(' ');
|
||||
const { data: novo, error: criErr } = await supabase
|
||||
.from('patients')
|
||||
const { data: novo, error: criErr } = await tenantDb().from('patients')
|
||||
.insert({
|
||||
tenant_id: tenantId.value,
|
||||
responsible_member_id: memberData.id,
|
||||
owner_id: ownerId.value,
|
||||
nome_completo: nomeCompleto_,
|
||||
|
||||
@@ -26,6 +26,7 @@ import Menu from 'primevue/menu';
|
||||
import DeterminedCommitmentDialog from '@/features/agenda/components/DeterminedCommitmentDialog.vue';
|
||||
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const toast = useToast();
|
||||
@@ -161,10 +162,9 @@ async function fetchAll() {
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data: cData, error: cErr } = await supabase
|
||||
.from('determined_commitments')
|
||||
.select('id, tenant_id, is_native, native_key, is_locked, active, name, description, bg_color, text_color, created_at, updated_at')
|
||||
.eq('tenant_id', tenantId)
|
||||
const { data: cData, error: cErr } = await tenantDb().from('determined_commitments')
|
||||
.select('id, is_native, native_key, is_locked, active, name, description, bg_color, text_color, created_at, updated_at')
|
||||
|
||||
.order('is_native', { ascending: false })
|
||||
.order('created_at', { ascending: false });
|
||||
if (cErr) throw cErr;
|
||||
@@ -172,10 +172,9 @@ async function fetchAll() {
|
||||
const ids = (cData || []).map((x) => x.id);
|
||||
let fieldsByCommitmentId = {};
|
||||
if (ids.length > 0) {
|
||||
const { data: fData, error: fErr } = await supabase
|
||||
.from('determined_commitment_fields')
|
||||
.select('id, tenant_id, commitment_id, key, label, field_type, required, sort_order')
|
||||
.eq('tenant_id', tenantId)
|
||||
const { data: fData, error: fErr } = await tenantDb().from('determined_commitment_fields')
|
||||
.select('id, commitment_id, key, label, field_type, required, sort_order')
|
||||
|
||||
.in('commitment_id', ids)
|
||||
.order('sort_order', { ascending: true });
|
||||
if (fErr) throw fErr;
|
||||
@@ -193,7 +192,7 @@ async function fetchAll() {
|
||||
}, {});
|
||||
}
|
||||
|
||||
const { data: lData, error: lErr } = await supabase.from('commitment_time_logs').select('commitment_id, minutes').eq('tenant_id', tenantId);
|
||||
const { data: lData, error: lErr } = await tenantDb().from('commitment_time_logs').select('commitment_id, minutes');
|
||||
if (lErr) throw lErr;
|
||||
const totals = {};
|
||||
for (const row of lData || []) {
|
||||
@@ -253,7 +252,7 @@ async function onToggleActive(c) {
|
||||
if (!tenantId) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const { error } = await supabase.from('determined_commitments').update({ active: !!c.active }).eq('tenant_id', tenantId).eq('id', c.id);
|
||||
const { error } = await tenantDb().from('determined_commitments').update({ active: !!c.active }).eq('id', c.id);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Atualizado', detail: `"${c.name}" ${c.active ? 'ativo' : 'inativo'}.`, life: 2500 });
|
||||
} catch (e) {
|
||||
@@ -271,10 +270,8 @@ async function onSave(payload) {
|
||||
try {
|
||||
await supabase.auth.getUser();
|
||||
if (dlgMode.value === 'create') {
|
||||
const { data: newC, error: cErr } = await supabase
|
||||
.from('determined_commitments')
|
||||
const { data: newC, error: cErr } = await tenantDb().from('determined_commitments')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
is_native: false,
|
||||
native_key: null,
|
||||
is_locked: false,
|
||||
@@ -284,14 +281,13 @@ async function onSave(payload) {
|
||||
bg_color: payload.bg_color || null,
|
||||
text_color: payload.text_color || null
|
||||
})
|
||||
.select('id, tenant_id, is_native, native_key, is_locked, active, name, description, bg_color, text_color, created_at, updated_at')
|
||||
.select('id, is_native, native_key, is_locked, active, name, description, bg_color, text_color, created_at, updated_at')
|
||||
.single();
|
||||
if (cErr) throw cErr;
|
||||
const fields = Array.isArray(payload.fields) ? payload.fields : [];
|
||||
if (fields.length > 0) {
|
||||
const { error: fErr } = await supabase.from('determined_commitment_fields').insert(
|
||||
const { error: fErr } = await tenantDb().from('determined_commitment_fields').insert(
|
||||
fields.map((f, idx) => ({
|
||||
tenant_id: tenantId,
|
||||
commitment_id: newC.id,
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
@@ -304,8 +300,7 @@ async function onSave(payload) {
|
||||
}
|
||||
toast.add({ severity: 'success', summary: 'Criado', detail: 'Compromisso criado.', life: 2500 });
|
||||
} else {
|
||||
const { error: upErr } = await supabase
|
||||
.from('determined_commitments')
|
||||
const { error: upErr } = await tenantDb().from('determined_commitments')
|
||||
.update({
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
@@ -313,16 +308,15 @@ async function onSave(payload) {
|
||||
bg_color: payload.bg_color || null,
|
||||
text_color: payload.text_color || null
|
||||
})
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
.eq('id', payload.id);
|
||||
if (upErr) throw upErr;
|
||||
const { error: delErr } = await supabase.from('determined_commitment_fields').delete().eq('tenant_id', tenantId).eq('commitment_id', payload.id);
|
||||
const { error: delErr } = await tenantDb().from('determined_commitment_fields').delete().eq('commitment_id', payload.id);
|
||||
if (delErr) throw delErr;
|
||||
const fields = Array.isArray(payload.fields) ? payload.fields : [];
|
||||
if (fields.length > 0) {
|
||||
const { error: insErr } = await supabase.from('determined_commitment_fields').insert(
|
||||
const { error: insErr } = await tenantDb().from('determined_commitment_fields').insert(
|
||||
fields.map((f, idx) => ({
|
||||
tenant_id: tenantId,
|
||||
commitment_id: payload.id,
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
@@ -358,11 +352,11 @@ async function onDelete(c) {
|
||||
if (!tenantId) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const { error: fErr } = await supabase.from('determined_commitment_fields').delete().eq('tenant_id', tenantId).eq('commitment_id', c.id);
|
||||
const { error: fErr } = await tenantDb().from('determined_commitment_fields').delete().eq('commitment_id', c.id);
|
||||
if (fErr) throw fErr;
|
||||
const { error: lErr } = await supabase.from('commitment_time_logs').delete().eq('tenant_id', tenantId).eq('commitment_id', c.id);
|
||||
const { error: lErr } = await tenantDb().from('commitment_time_logs').delete().eq('commitment_id', c.id);
|
||||
if (lErr) throw lErr;
|
||||
const { data: delRows, error: dErr } = await supabase.from('determined_commitments').delete().eq('tenant_id', tenantId).eq('id', c.id).eq('is_native', false).select('id');
|
||||
const { data: delRows, error: dErr } = await tenantDb().from('determined_commitments').delete().eq('id', c.id).eq('is_native', false).select('id');
|
||||
if (dErr) throw dErr;
|
||||
if (!delRows?.length) throw new Error('DELETE bloqueado por RLS.');
|
||||
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Compromisso removido.', life: 2500 });
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
*/
|
||||
|
||||
import { dateToISO } from '@/features/agenda/utils/timeHelpers';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
|
||||
// ── Helpers puros ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -155,10 +156,9 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
|
||||
const excType = exceptionTypeMap[status];
|
||||
if (excType && tenantId) {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('financial_exceptions')
|
||||
const { data } = await tenantDb().from('financial_exceptions')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
.eq('exception_type', excType)
|
||||
.or(`owner_id.eq.${ownerId},owner_id.is.null`)
|
||||
.order('owner_id', { ascending: false, nullsLast: true })
|
||||
@@ -177,8 +177,7 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
|
||||
const contractId = row?.billing_contract_id ?? null;
|
||||
if (contractId) {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('billing_contracts')
|
||||
const { data } = await tenantDb().from('billing_contracts')
|
||||
.select('*')
|
||||
.eq('id', contractId)
|
||||
.maybeSingle();
|
||||
@@ -189,14 +188,12 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
|
||||
}
|
||||
if (!ctx.billingContract && eventoId) {
|
||||
try {
|
||||
const { data: ev } = await supabase
|
||||
.from('agenda_eventos')
|
||||
const { data: ev } = await tenantDb().from('agenda_eventos')
|
||||
.select('billing_contract_id')
|
||||
.eq('id', eventoId)
|
||||
.maybeSingle();
|
||||
if (ev?.billing_contract_id) {
|
||||
const { data: c } = await supabase
|
||||
.from('billing_contracts')
|
||||
const { data: c } = await tenantDb().from('billing_contracts')
|
||||
.select('*')
|
||||
.eq('id', ev.billing_contract_id)
|
||||
.maybeSingle();
|
||||
@@ -208,10 +205,9 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
|
||||
}
|
||||
if (!ctx.billingContract && patientId && tenantId) {
|
||||
try {
|
||||
const { data: c } = await supabase
|
||||
.from('billing_contracts')
|
||||
const { data: c } = await tenantDb().from('billing_contracts')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
.eq('patient_id', patientId)
|
||||
.eq('status', 'active')
|
||||
.eq('type', 'package')
|
||||
@@ -227,8 +223,7 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
|
||||
// 3) Pending record
|
||||
if (eventoId) {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('financial_records')
|
||||
const { data } = await tenantDb().from('financial_records')
|
||||
.select('*')
|
||||
.eq('agenda_evento_id', eventoId)
|
||||
.in('status', ['pending', 'overdue'])
|
||||
@@ -244,8 +239,7 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
|
||||
// 3b) Paid record pré-existente (caso C12: antecipar pagamento).
|
||||
if (eventoId) {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('financial_records')
|
||||
const { data } = await tenantDb().from('financial_records')
|
||||
.select('id, status, amount, final_amount, paid_at, payment_method')
|
||||
.eq('agenda_evento_id', eventoId)
|
||||
.eq('status', 'paid')
|
||||
@@ -266,16 +260,14 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
|
||||
saldoConsumed: false
|
||||
};
|
||||
try {
|
||||
const { data: evRow } = await supabase
|
||||
.from('agenda_eventos')
|
||||
const { data: evRow } = await tenantDb().from('agenda_eventos')
|
||||
.select('status, billing_contract_id')
|
||||
.eq('id', eventoId)
|
||||
.maybeSingle();
|
||||
if (evRow) {
|
||||
ctx.reverseArtifacts.previousStatus = evRow.status;
|
||||
}
|
||||
const { data: recs } = await supabase
|
||||
.from('financial_records')
|
||||
const { data: recs } = await tenantDb().from('financial_records')
|
||||
.select('id, status, amount, final_amount, description, paid_at, payment_method')
|
||||
.eq('agenda_evento_id', eventoId)
|
||||
.neq('status', 'cancelled')
|
||||
@@ -336,8 +328,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const reason = `Cancelada via reversão de status (${r.previousStatus} → agendado) em ${today}`;
|
||||
for (const id of pendingIds) {
|
||||
const { error: cErr } = await supabase
|
||||
.from('financial_records')
|
||||
const { error: cErr } = await tenantDb().from('financial_records')
|
||||
.update({
|
||||
status: 'cancelled',
|
||||
notes: `[${today}] ${reason}`,
|
||||
@@ -356,8 +347,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
|
||||
// 2) Devolver saldo
|
||||
if (decision.reverseRestoreSaldo && r.saldoConsumed && ctx.billingContract?.id) {
|
||||
try {
|
||||
const { data: freshContract, error: fetchErr } = await supabase
|
||||
.from('billing_contracts')
|
||||
const { data: freshContract, error: fetchErr } = await tenantDb().from('billing_contracts')
|
||||
.select('sessions_used, total_sessions, status')
|
||||
.eq('id', ctx.billingContract.id)
|
||||
.maybeSingle();
|
||||
@@ -369,7 +359,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
|
||||
if (currentUsed >= totalSessions) {
|
||||
patch.status = 'active';
|
||||
}
|
||||
const { error: dErr } = await supabase.from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
|
||||
const { error: dErr } = await tenantDb().from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
|
||||
if (dErr) throw dErr;
|
||||
} catch (e) {
|
||||
console.error('[agendaBilling/reverse] erro decrementando saldo:', e?.message);
|
||||
@@ -380,7 +370,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
|
||||
// 3) Desamarrar billing_contract_id (só se devolveu saldo)
|
||||
if (decision.reverseRestoreSaldo && r.saldoConsumed) {
|
||||
try {
|
||||
await supabase.from('agenda_eventos').update({ billing_contract_id: null, updated_at: new Date().toISOString() }).eq('id', eventoId);
|
||||
await tenantDb().from('agenda_eventos').update({ billing_contract_id: null, updated_at: new Date().toISOString() }).eq('id', eventoId);
|
||||
} catch (e) {
|
||||
console.warn('[agendaBilling/reverse] erro desamarrando billing_contract_id:', e?.message);
|
||||
}
|
||||
@@ -393,8 +383,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
|
||||
// 1) Consumir saldo
|
||||
if (decision.consumeSaldo && ctx.billingContract?.id) {
|
||||
tasks.push(
|
||||
supabase
|
||||
.from('billing_contracts')
|
||||
tenantDb().from('billing_contracts')
|
||||
.update({ sessions_used: (ctx.billingContract.sessions_used ?? 0) + 1 })
|
||||
.eq('id', ctx.billingContract.id)
|
||||
);
|
||||
@@ -404,8 +393,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
|
||||
const isForwardStatus = novoStatus === 'realizado' || novoStatus === 'faltou' || novoStatus === 'cancelado';
|
||||
if (isForwardStatus && ctx.billingContract?.id && eventoId) {
|
||||
tasks.push(
|
||||
supabase
|
||||
.from('agenda_eventos')
|
||||
tenantDb().from('agenda_eventos')
|
||||
.update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() })
|
||||
.eq('id', eventoId)
|
||||
);
|
||||
@@ -418,7 +406,6 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
|
||||
const fineDesc = novoStatus === 'faltou' ? `Multa por falta · sessão ${sessaoLabel}` : `Taxa de cancelamento tardio · sessão ${sessaoLabel}`;
|
||||
const finePayload = {
|
||||
owner_id: uid,
|
||||
tenant_id: tenantId,
|
||||
patient_id: patientId,
|
||||
agenda_evento_id: eventoId,
|
||||
amount: decision.fineAmount,
|
||||
@@ -429,8 +416,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
|
||||
type: 'receita'
|
||||
};
|
||||
tasks.push(
|
||||
supabase
|
||||
.from('financial_records')
|
||||
tenantDb().from('financial_records')
|
||||
.insert(finePayload)
|
||||
.then(({ error }) => {
|
||||
if (error) {
|
||||
@@ -455,8 +441,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
|
||||
const noteEntry = `[${today}] ${reasonText}`;
|
||||
const noteText = ctx.pendingRecord.notes ? `${ctx.pendingRecord.notes}\n${noteEntry}` : noteEntry;
|
||||
tasks.push(
|
||||
supabase
|
||||
.from('financial_records')
|
||||
tenantDb().from('financial_records')
|
||||
.update({
|
||||
status: 'cancelled',
|
||||
notes: noteText,
|
||||
@@ -469,8 +454,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
|
||||
// 3) Realizado avulsa pendente: marcar pendingRecord como pago (ou só status)
|
||||
if (decision.markPaid && ctx.pendingRecord?.id) {
|
||||
tasks.push(
|
||||
supabase
|
||||
.from('financial_records')
|
||||
tenantDb().from('financial_records')
|
||||
.update({
|
||||
status: 'paid',
|
||||
paid_at: new Date().toISOString(),
|
||||
@@ -492,8 +476,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
|
||||
}
|
||||
}
|
||||
try {
|
||||
const { data: freshContract, error: fetchErr } = await supabase
|
||||
.from('billing_contracts')
|
||||
const { data: freshContract, error: fetchErr } = await tenantDb().from('billing_contracts')
|
||||
.select('sessions_used, total_sessions, status')
|
||||
.eq('id', ctx.billingContract.id)
|
||||
.maybeSingle();
|
||||
@@ -504,7 +487,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
|
||||
if (newUsed >= (freshContract?.total_sessions ?? 0)) {
|
||||
patch.status = 'completed';
|
||||
}
|
||||
const { error: incErr } = await supabase.from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
|
||||
const { error: incErr } = await tenantDb().from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
|
||||
if (incErr) throw incErr;
|
||||
tx({ severity: 'success', summary: 'Sessão consumida', detail: `Saldo: ${newUsed}/${freshContract?.total_sessions ?? '?'}. Pagamento já estava registrado.`, life: 4000 });
|
||||
} catch (e) {
|
||||
@@ -520,7 +503,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
|
||||
const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
|
||||
|
||||
try {
|
||||
const { error: linkErr } = await supabase.from('agenda_eventos').update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() }).eq('id', eventoId);
|
||||
const { error: linkErr } = await tenantDb().from('agenda_eventos').update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() }).eq('id', eventoId);
|
||||
if (linkErr) throw linkErr;
|
||||
} catch (e) {
|
||||
console.error('[agendaBilling] erro amarrando billing_contract_id:', e?.message);
|
||||
@@ -548,7 +531,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
|
||||
if (newUsed >= (ctx.billingContract.total_sessions ?? 0)) {
|
||||
patchContract.status = 'completed';
|
||||
}
|
||||
const { error: incErr } = await supabase.from('billing_contracts').update(patchContract).eq('id', ctx.billingContract.id);
|
||||
const { error: incErr } = await tenantDb().from('billing_contracts').update(patchContract).eq('id', ctx.billingContract.id);
|
||||
if (incErr) throw incErr;
|
||||
} catch (e) {
|
||||
console.error('[agendaBilling] erro incrementando sessions_used:', e?.message);
|
||||
@@ -570,8 +553,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
|
||||
// Pós-processamento do record gerado pelo pacote saldo
|
||||
if (decision.generatePackageCharge && eventoId) {
|
||||
try {
|
||||
const { data: newRec } = await supabase
|
||||
.from('financial_records')
|
||||
const { data: newRec } = await tenantDb().from('financial_records')
|
||||
.select('id')
|
||||
.eq('agenda_evento_id', eventoId)
|
||||
.order('created_at', { ascending: false })
|
||||
@@ -579,8 +561,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
|
||||
.single();
|
||||
if (newRec?.id) {
|
||||
if (decision.markPaid) {
|
||||
await supabase
|
||||
.from('financial_records')
|
||||
await tenantDb().from('financial_records')
|
||||
.update({
|
||||
status: 'paid',
|
||||
paid_at: new Date().toISOString(),
|
||||
@@ -589,8 +570,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
|
||||
})
|
||||
.eq('id', newRec.id);
|
||||
} else if (decision.paymentMethod === 'link') {
|
||||
await supabase
|
||||
.from('financial_records')
|
||||
await tenantDb().from('financial_records')
|
||||
.update({ payment_method: 'asaas', updated_at: new Date().toISOString() })
|
||||
.eq('id', newRec.id);
|
||||
}
|
||||
@@ -609,11 +589,9 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
|
||||
export async function createPackageContract({ supabase, rule, normalized, recorrencia, tenantId, packageStyle = 'upfront', paymentMethod = 'link', markPaidNow = false }) {
|
||||
const { n, packagePrice } = computeSeriePrice(recorrencia);
|
||||
try {
|
||||
const { data: createdContract, error: contractErr } = await supabase
|
||||
.from('billing_contracts')
|
||||
const { data: createdContract, error: contractErr } = await tenantDb().from('billing_contracts')
|
||||
.insert({
|
||||
owner_id: normalized.owner_id,
|
||||
tenant_id: tenantId,
|
||||
patient_id: normalized.paciente_id,
|
||||
type: 'package',
|
||||
total_sessions: n,
|
||||
@@ -645,11 +623,9 @@ export async function createPackageContract({ supabase, rule, normalized, recorr
|
||||
startDt.setHours(hh, mm, 0, 0);
|
||||
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
|
||||
|
||||
const { data: createdEvent, error: evErr } = await supabase
|
||||
.from('agenda_eventos')
|
||||
const { data: createdEvent, error: evErr } = await tenantDb().from('agenda_eventos')
|
||||
.insert({
|
||||
owner_id: rule.owner_id,
|
||||
tenant_id: tenantId,
|
||||
terapeuta_id: rule.therapist_id ?? null,
|
||||
recurrence_id: rule.id,
|
||||
recurrence_date: firstISO,
|
||||
@@ -680,8 +656,7 @@ export async function createPackageContract({ supabase, rule, normalized, recorr
|
||||
if (cobErr) throw cobErr;
|
||||
|
||||
const paidNow = markPaidNow === true && paymentMethod !== 'link';
|
||||
const { data: recRow } = await supabase
|
||||
.from('financial_records')
|
||||
const { data: recRow } = await tenantDb().from('financial_records')
|
||||
.select('id')
|
||||
.eq('agenda_evento_id', createdEvent.id)
|
||||
.order('created_at', { ascending: false })
|
||||
@@ -696,7 +671,7 @@ export async function createPackageContract({ supabase, rule, normalized, recorr
|
||||
patch.status = 'paid';
|
||||
patch.paid_at = new Date().toISOString();
|
||||
}
|
||||
await supabase.from('financial_records').update(patch).eq('id', recRow.id);
|
||||
await tenantDb().from('financial_records').update(patch).eq('id', recRow.id);
|
||||
}
|
||||
|
||||
const methodLabel = {
|
||||
@@ -745,7 +720,6 @@ export async function materializeAndChargePerSession({ supabase, rule, normalize
|
||||
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
|
||||
return {
|
||||
owner_id: rule.owner_id,
|
||||
tenant_id: tenantId,
|
||||
terapeuta_id: rule.therapist_id ?? null,
|
||||
recurrence_id: rule.id,
|
||||
recurrence_date: iso,
|
||||
@@ -762,7 +736,7 @@ export async function materializeAndChargePerSession({ supabase, rule, normalize
|
||||
};
|
||||
});
|
||||
|
||||
const { data: createdEvents, error: evErr } = await supabase.from('agenda_eventos').insert(rows).select('id, inicio_em');
|
||||
const { data: createdEvents, error: evErr } = await tenantDb().from('agenda_eventos').insert(rows).select('id, inicio_em');
|
||||
if (evErr) throw evErr;
|
||||
|
||||
let okCount = 0;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import {
|
||||
assertTenantId as assertValidTenantId,
|
||||
assertIsoRange as assertValidIsoRange,
|
||||
@@ -24,7 +25,7 @@ import { AGENDA_EVENT_SELECT, flattenAgendaRow } from './agendaSelects';
|
||||
|
||||
/**
|
||||
* Lista eventos para mosaico da clínica (admin/secretaria) dentro de um tenant específico.
|
||||
* IMPORTANTE: SEM tenant_id aqui vira vazamento multi-tenant.
|
||||
* Isolamento multi-tenant garantido pelo schema do tenant (tenantDb).
|
||||
*/
|
||||
export async function listClinicEvents({ tenantId, ownerIds, startISO, endISO } = {}) {
|
||||
assertValidTenantId(tenantId);
|
||||
@@ -34,10 +35,9 @@ export async function listClinicEvents({ tenantId, ownerIds, startISO, endISO }
|
||||
const safeOwnerIds = sanitizeOwnerIds(ownerIds);
|
||||
if (!safeOwnerIds.length) return [];
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
const { data, error } = await tenantDb().from('agenda_eventos')
|
||||
.select(AGENDA_EVENT_SELECT)
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
.in('owner_id', safeOwnerIds)
|
||||
.gte('inicio_em', startISO)
|
||||
.lt('inicio_em', endISO)
|
||||
@@ -78,13 +78,11 @@ export async function createClinicAgendaEvento(payload, { tenantId } = {}) {
|
||||
throw new Error('owner_id é obrigatório para criação pela clínica.');
|
||||
}
|
||||
|
||||
const insertPayload = {
|
||||
...payload,
|
||||
tenant_id: tenantId
|
||||
};
|
||||
// dropa tenant_id se vier no payload (schema-per-tenant não tem a coluna)
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { tenant_id: _dropTenantId, ...insertPayload } = payload;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
const { data, error } = await tenantDb().from('agenda_eventos')
|
||||
.insert(insertPayload)
|
||||
.select(AGENDA_EVENT_SELECT)
|
||||
.single();
|
||||
@@ -95,7 +93,7 @@ export async function createClinicAgendaEvento(payload, { tenantId } = {}) {
|
||||
|
||||
/**
|
||||
* Atualização segura para clínica:
|
||||
* - filtra por id + tenant_id (evita update cruzado)
|
||||
* - filtra por id (isolamento via schema do tenant)
|
||||
* - permite editar owner_id (caso você mova evento para outro profissional)
|
||||
*/
|
||||
export async function updateClinicAgendaEvento(id, patch, { tenantId } = {}) {
|
||||
@@ -103,11 +101,13 @@ export async function updateClinicAgendaEvento(id, patch, { tenantId } = {}) {
|
||||
if (!patch) throw new Error('Patch vazio.');
|
||||
assertValidTenantId(tenantId);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.update(patch)
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { tenant_id: _dropTenantId, ...safePatch } = patch;
|
||||
|
||||
const { data, error } = await tenantDb().from('agenda_eventos')
|
||||
.update(safePatch)
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
.select(AGENDA_EVENT_SELECT)
|
||||
.single();
|
||||
|
||||
@@ -117,13 +117,13 @@ export async function updateClinicAgendaEvento(id, patch, { tenantId } = {}) {
|
||||
|
||||
/**
|
||||
* Delete seguro para clínica:
|
||||
* - filtra por id + tenant_id
|
||||
* - filtra por id (isolamento via schema do tenant)
|
||||
*/
|
||||
export async function deleteClinicAgendaEvento(id, { tenantId } = {}) {
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
assertValidTenantId(tenantId);
|
||||
|
||||
const { error } = await supabase.from('agenda_eventos').delete().eq('id', id).eq('tenant_id', tenantId);
|
||||
const { error } = await tenantDb().from('agenda_eventos').delete().eq('id', id);
|
||||
|
||||
if (error) throw error;
|
||||
return true;
|
||||
|
||||
@@ -143,8 +143,7 @@ function _mapRow(r) {
|
||||
|
||||
// timestamps
|
||||
inicio_em: r.inicio_em,
|
||||
fim_em: r.fim_em,
|
||||
tenant_id: r.tenant_id ?? null
|
||||
fim_em: r.fim_em
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { assertTenantId as assertValidTenantId, assertIsoRange, getUid } from './_tenantGuards';
|
||||
import { AGENDA_EVENT_SELECT, flattenAgendaRow } from './agendaSelects';
|
||||
@@ -23,8 +24,7 @@ import { AGENDA_EVENT_SELECT, flattenAgendaRow } from './agendaSelects';
|
||||
|
||||
export async function getMyAgendaSettings() {
|
||||
const uid = await getUid();
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_configuracoes')
|
||||
const { data, error } = await tenantDb().from('agenda_configuracoes')
|
||||
.select('*')
|
||||
.eq('owner_id', uid)
|
||||
.order('created_at', { ascending: false })
|
||||
@@ -36,8 +36,7 @@ export async function getMyAgendaSettings() {
|
||||
|
||||
export async function getMyWorkSchedule() {
|
||||
const uid = await getUid();
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_regras_semanais')
|
||||
const { data, error } = await tenantDb().from('agenda_regras_semanais')
|
||||
.select('dia_semana, hora_inicio, hora_fim, ativo')
|
||||
.eq('owner_id', uid)
|
||||
.eq('ativo', true)
|
||||
@@ -78,10 +77,9 @@ export async function listMyAgendaEvents({ startISO, endISO, ownerId, tenantId,
|
||||
const uid = ownerId || (await getUid());
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
let q = supabase
|
||||
.from('agenda_eventos')
|
||||
let q = tenantDb().from('agenda_eventos')
|
||||
.select(AGENDA_EVENT_SELECT)
|
||||
.eq('tenant_id', tid)
|
||||
|
||||
.eq('owner_id', uid)
|
||||
.gte('inicio_em', startISO)
|
||||
.lt('inicio_em', endISO)
|
||||
@@ -96,9 +94,8 @@ export async function listMyAgendaEvents({ startISO, endISO, ownerId, tenantId,
|
||||
|
||||
/**
|
||||
* Criação segura:
|
||||
* - injeta tenant_id do tenantStore
|
||||
* - injeta owner_id do usuário logado (ignora owner_id vindo de fora)
|
||||
* - dropa paciente_id (campo legado) se vier no payload
|
||||
* - dropa paciente_id (campo legado) e tenant_id (schema-per-tenant não tem a coluna) se vierem no payload
|
||||
*/
|
||||
export async function createAgendaEvento(payload) {
|
||||
if (!payload) throw new Error('Payload vazio.');
|
||||
@@ -106,11 +103,10 @@ export async function createAgendaEvento(payload) {
|
||||
const tid = resolveTenantId();
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { paciente_id: _dropped, ...rest } = payload;
|
||||
const insertPayload = { ...rest, tenant_id: tid, owner_id: uid };
|
||||
const { paciente_id: _dropped, tenant_id: _dropTenantId, ...rest } = payload;
|
||||
const insertPayload = { ...rest, owner_id: uid };
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
const { data, error } = await tenantDb().from('agenda_eventos')
|
||||
.insert([insertPayload])
|
||||
.select(AGENDA_EVENT_SELECT)
|
||||
.single();
|
||||
@@ -120,7 +116,7 @@ export async function createAgendaEvento(payload) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualização segura: filtra por id + tenant_id (RLS reforça no banco).
|
||||
* Atualização segura: filtra por id (isolamento via schema do tenant; RLS reforça no banco).
|
||||
*/
|
||||
export async function updateAgendaEvento(id, patch, { tenantId } = {}) {
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
@@ -128,13 +124,12 @@ export async function updateAgendaEvento(id, patch, { tenantId } = {}) {
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { paciente_id: _dropped, ...safePatch } = patch;
|
||||
const { paciente_id: _dropped, tenant_id: _dropTenantId, ...safePatch } = patch;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
const { data, error } = await tenantDb().from('agenda_eventos')
|
||||
.update(safePatch)
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tid)
|
||||
|
||||
.select(AGENDA_EVENT_SELECT)
|
||||
.single();
|
||||
|
||||
@@ -143,13 +138,13 @@ export async function updateAgendaEvento(id, patch, { tenantId } = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete seguro: filtra por id + tenant_id.
|
||||
* Delete seguro: filtra por id (isolamento via schema do tenant).
|
||||
*/
|
||||
export async function deleteAgendaEvento(id, { tenantId } = {}) {
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { error } = await supabase.from('agenda_eventos').delete().eq('id', id).eq('tenant_id', tid);
|
||||
const { error } = await tenantDb().from('agenda_eventos').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
export const AGENDA_EVENT_SELECT = `
|
||||
id, owner_id, patient_id, tipo, status,
|
||||
titulo, titulo_custom, observacoes, inicio_em, fim_em,
|
||||
terapeuta_id, tenant_id, visibility_scope,
|
||||
terapeuta_id, visibility_scope,
|
||||
determined_commitment_id, link_online, extra_fields, modalidade,
|
||||
recurrence_id, recurrence_date,
|
||||
mirror_of_event_id, price,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
|
||||
const ALLOWED_FIELDS = [
|
||||
'tenant_id', 'owner_id', 'terapeuta_id', 'patient_id',
|
||||
'owner_id', 'terapeuta_id', 'patient_id',
|
||||
'tipo', 'status', 'titulo', 'observacoes', 'modalidade',
|
||||
'inicio_em', 'fim_em', 'visibility_scope',
|
||||
'mirror_of_event_id', 'mirror_source',
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { assertTenantId } from './_tenantGuards';
|
||||
import {
|
||||
@@ -41,7 +42,7 @@ function resolveTenantId(tenantIdArg) {
|
||||
export async function listThreads({ tenantId, limit = 500 } = {}) {
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { data, error } = await supabase.from('conversation_threads').select(CONVERSATION_THREAD_SELECT).eq('tenant_id', tid).order('last_message_at', { ascending: false }).limit(limit);
|
||||
const { data, error } = await tenantDb().from('conversation_threads').select(CONVERSATION_THREAD_SELECT).order('last_message_at', { ascending: false }).limit(limit);
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
@@ -54,7 +55,7 @@ export async function getThreadById(threadId, { tenantId } = {}) {
|
||||
if (!threadId) throw new Error('threadId obrigatório.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { data, error } = await supabase.from('conversation_threads').select(CONVERSATION_THREAD_SELECT).eq('id', threadId).eq('tenant_id', tid).maybeSingle();
|
||||
const { data, error } = await tenantDb().from('conversation_threads').select(CONVERSATION_THREAD_SELECT).eq('id', threadId).maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return data || null;
|
||||
@@ -67,7 +68,7 @@ export async function updateThread(threadId, patch, { tenantId } = {}) {
|
||||
if (!threadId) throw new Error('threadId obrigatório.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { data, error } = await supabase.from('conversation_threads').update({ ...patch, updated_at: new Date().toISOString() }).eq('id', threadId).eq('tenant_id', tid).select(CONVERSATION_THREAD_SELECT).single();
|
||||
const { data, error } = await tenantDb().from('conversation_threads').update({ ...patch, updated_at: new Date().toISOString() }).eq('id', threadId).select(CONVERSATION_THREAD_SELECT).single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
@@ -82,7 +83,7 @@ export async function listMessagesByThread(threadId, { tenantId, limit = 500 } =
|
||||
if (!threadId) return [];
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { data, error } = await supabase.from('conversation_messages').select(CONVERSATION_MESSAGE_SELECT).eq('tenant_id', tid).eq('thread_id', threadId).order('created_at', { ascending: true }).limit(limit);
|
||||
const { data, error } = await tenantDb().from('conversation_messages').select(CONVERSATION_MESSAGE_SELECT).eq('thread_id', threadId).order('created_at', { ascending: true }).limit(limit);
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
@@ -96,7 +97,7 @@ export async function listMessagesByPatient(patientId, { tenantId, limit = 200 }
|
||||
if (!patientId) return [];
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { data, error } = await supabase.from('conversation_messages').select(CONVERSATION_MESSAGE_SELECT_BRIEF).eq('tenant_id', tid).eq('patient_id', patientId).order('created_at', { ascending: false }).limit(limit);
|
||||
const { data, error } = await tenantDb().from('conversation_messages').select(CONVERSATION_MESSAGE_SELECT_BRIEF).eq('patient_id', patientId).order('created_at', { ascending: false }).limit(limit);
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
@@ -109,7 +110,7 @@ export async function updateMessageKanban(messageId, kanbanStatus, { tenantId }
|
||||
if (!messageId) throw new Error('messageId obrigatório.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { error } = await supabase.from('conversation_messages').update({ kanban_status: kanbanStatus, updated_at: new Date().toISOString() }).eq('id', messageId).eq('tenant_id', tid);
|
||||
const { error } = await tenantDb().from('conversation_messages').update({ kanban_status: kanbanStatus, updated_at: new Date().toISOString() }).eq('id', messageId);
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { ref, reactive, watch, computed } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import {
|
||||
createSignatureRequests,
|
||||
listSignatures,
|
||||
@@ -61,8 +62,7 @@ function removeSignatario(idx) {
|
||||
async function fetchPatientEmails(patientId) {
|
||||
if (!patientId) { patientEmails.value = []; return }
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('patients')
|
||||
const { data } = await tenantDb().from('patients')
|
||||
.select('email_principal, email_alternativo')
|
||||
.eq('id', patientId)
|
||||
.single()
|
||||
|
||||
@@ -66,12 +66,17 @@ export function useDocumentSignatures() {
|
||||
}
|
||||
}
|
||||
|
||||
async function sign(signatureId, { hashDocumento = null } = {}) {
|
||||
async function sign(signatureId, { hashDocumento = null, tenantId = null } = {}) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const updated = await signByPortal(signatureId, hashDocumento);
|
||||
const idx = signatures.value.findIndex(s => s.id === signatureId);
|
||||
// schema-per-tenant: a assinatura vive no schema do tenant. Resolve o
|
||||
// tenant_id da própria linha (list_my_signatures retorna tenant_id) ou
|
||||
// do parâmetro explícito.
|
||||
const row = signatures.value.find(s => (s.signature_id ?? s.id) === signatureId);
|
||||
const tid = tenantId || row?.tenant_id;
|
||||
const updated = await signByPortal(tid, signatureId, hashDocumento);
|
||||
const idx = signatures.value.findIndex(s => (s.signature_id ?? s.id) === signatureId);
|
||||
if (idx >= 0) signatures.value.splice(idx, 1, updated);
|
||||
return updated;
|
||||
} catch (e) {
|
||||
|
||||
@@ -20,8 +20,11 @@ import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
const router = useRouter();
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const _brl = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
function fmtBRL(v) {
|
||||
@@ -66,6 +69,7 @@ async function loadSummary(uid) {
|
||||
try {
|
||||
// Receitas e despesas pagas no mês via RPC
|
||||
const { data: rpc } = await supabase.rpc('get_financial_summary', {
|
||||
p_tenant_id: tenantStore.activeTenantId,
|
||||
p_owner_id: uid,
|
||||
p_year: year,
|
||||
p_month: month
|
||||
@@ -75,7 +79,7 @@ async function loadSummary(uid) {
|
||||
totalDespesas.value = Number(s?.total_despesas ?? 0);
|
||||
|
||||
// Pending e overdue separados (sem filtro de mês)
|
||||
const { data: pendRows } = await supabase.from('financial_records').select('status, final_amount').eq('owner_id', uid).is('deleted_at', null).in('status', ['pending', 'overdue']);
|
||||
const { data: pendRows } = await tenantDb().from('financial_records').select('status, final_amount').eq('owner_id', uid).is('deleted_at', null).in('status', ['pending', 'overdue']);
|
||||
|
||||
let pen = 0,
|
||||
ove = 0;
|
||||
@@ -116,7 +120,7 @@ async function loadChart(uid) {
|
||||
chartLoading.value = true;
|
||||
const months = getLast6Months();
|
||||
try {
|
||||
const results = await Promise.all(months.map((m) => supabase.rpc('get_financial_summary', { p_owner_id: uid, p_year: m.year, p_month: m.month })));
|
||||
const results = await Promise.all(months.map((m) => supabase.rpc('get_financial_summary', { p_tenant_id: tenantStore.activeTenantId, p_owner_id: uid, p_year: m.year, p_month: m.month })));
|
||||
const receitas = results.map((r) => Number((Array.isArray(r.data) ? r.data[0] : r.data)?.total_receitas ?? 0));
|
||||
const despesas = results.map((r) => Number((Array.isArray(r.data) ? r.data[0] : r.data)?.total_despesas ?? 0));
|
||||
|
||||
@@ -141,7 +145,7 @@ async function loadCashflow() {
|
||||
cashflowLoading.value = true;
|
||||
cashflowError.value = false;
|
||||
try {
|
||||
const { data, error } = await supabase.from('v_cashflow_projection').select('mes_label, receitas_projetadas, despesas_projetadas, saldo_projetado, count_registros').order('mes', { ascending: true });
|
||||
const { data, error } = await tenantDb().from('v_cashflow_projection').select('mes_label, receitas_projetadas, despesas_projetadas, saldo_projetado, count_registros').order('mes', { ascending: true });
|
||||
if (error) throw error;
|
||||
cashflowRows.value = data ?? [];
|
||||
} catch {
|
||||
@@ -168,6 +172,7 @@ async function loadRecent(uid) {
|
||||
recentLoading.value = true;
|
||||
try {
|
||||
const { data } = await supabase.rpc('list_financial_records', {
|
||||
p_tenant_id: tenantStore.activeTenantId,
|
||||
p_owner_id: uid,
|
||||
p_limit: 5,
|
||||
p_offset: 0
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useFinancialRecords } from '@/composables/useFinancialRecords';
|
||||
|
||||
@@ -47,7 +48,7 @@ async function loadPatients() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return;
|
||||
|
||||
const { data } = await supabase.from('patients').select('id, nome_completo, identification_color').eq('tenant_id', tenantId).order('nome_completo');
|
||||
const { data } = await tenantDb().from('patients').select('id, nome_completo, identification_color').order('nome_completo');
|
||||
patients.value = data ?? [];
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
// ─── Status mapping Asaas → financial_records.status ────────────────────────
|
||||
@@ -128,10 +129,9 @@ export async function getPaymentForRecord(financialRecordId) {
|
||||
if (!financialRecordId) return null;
|
||||
const tenantId = resolveTenantId();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('asaas_payments')
|
||||
const { data, error } = await tenantDb().from('asaas_payments')
|
||||
.select('id, asaas_payment_id, billing_type, status, value, due_date, payment_date, invoice_url, payment_url, bank_slip_url, pix_qr_code, pix_copy_paste, cancelled_at')
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
.eq('financial_record_id', financialRecordId)
|
||||
.is('cancelled_at', null)
|
||||
.order('created_at', { ascending: false })
|
||||
@@ -167,7 +167,7 @@ export async function syncPayment(asaasPaymentId) {
|
||||
*/
|
||||
export async function isGatewayEnabled() {
|
||||
const tenantId = resolveTenantId();
|
||||
const { data, error } = await supabase.from('payment_settings').select('asaas_enabled, asaas_environment').eq('tenant_id', tenantId).maybeSingle();
|
||||
const { data, error } = await tenantDb().from('payment_settings').select('asaas_enabled, asaas_environment').maybeSingle();
|
||||
if (error) return false;
|
||||
return !!data?.asaas_enabled;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { assertTenantId, getUid } from './_tenantGuards';
|
||||
import { BILLING_CONTRACT_SELECT } from './financialSelects';
|
||||
@@ -31,7 +32,7 @@ export async function listForPatient(patientId, { tenantId, includeDeleted = fal
|
||||
if (!patientId) return [];
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
let q = supabase.from('billing_contracts').select(BILLING_CONTRACT_SELECT).eq('tenant_id', tid).eq('patient_id', patientId).order('created_at', { ascending: false });
|
||||
let q = tenantDb().from('billing_contracts').select(BILLING_CONTRACT_SELECT).eq('patient_id', patientId).order('created_at', { ascending: false });
|
||||
|
||||
if (!includeDeleted) q = q.is('deleted_at', null);
|
||||
|
||||
@@ -48,7 +49,7 @@ export async function getById(contractId, { tenantId } = {}) {
|
||||
if (!contractId) throw new Error('contractId obrigatório.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { data, error } = await supabase.from('billing_contracts').select(BILLING_CONTRACT_SELECT).eq('id', contractId).eq('tenant_id', tid).maybeSingle();
|
||||
const { data, error } = await tenantDb().from('billing_contracts').select(BILLING_CONTRACT_SELECT).eq('id', contractId).maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return data || null;
|
||||
@@ -65,7 +66,6 @@ export async function create(payload) {
|
||||
const tid = resolveTenantId(payload.tenantId);
|
||||
|
||||
const row = {
|
||||
tenant_id: tid,
|
||||
owner_id: uid,
|
||||
patient_id: payload.patient_id,
|
||||
charging_style: payload.charging_style,
|
||||
@@ -77,7 +77,7 @@ export async function create(payload) {
|
||||
end_date: payload.end_date || null
|
||||
};
|
||||
|
||||
const { data, error } = await supabase.from('billing_contracts').insert([row]).select(BILLING_CONTRACT_SELECT).single();
|
||||
const { data, error } = await tenantDb().from('billing_contracts').insert([row]).select(BILLING_CONTRACT_SELECT).single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
@@ -93,7 +93,7 @@ export async function update(contractId, patch, { tenantId } = {}) {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { updated_at: _dropped, ...safePatch } = patch || {};
|
||||
|
||||
const { data, error } = await supabase.from('billing_contracts').update(safePatch).eq('id', contractId).eq('tenant_id', tid).select(BILLING_CONTRACT_SELECT).single();
|
||||
const { data, error } = await tenantDb().from('billing_contracts').update(safePatch).eq('id', contractId).select(BILLING_CONTRACT_SELECT).single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
@@ -128,7 +128,7 @@ export async function findRecordsByRecurrence(recurrenceId, { tenantId } = {}) {
|
||||
if (!recurrenceId) return [];
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { data, error } = await supabase.from('financial_records').select('id, status, agenda_evento_id, billing_contract_id').eq('tenant_id', tid).is('deleted_at', null).not('agenda_evento_id', 'is', null);
|
||||
const { data, error } = await tenantDb().from('financial_records').select('id, status, agenda_evento_id, billing_contract_id').is('deleted_at', null).not('agenda_evento_id', 'is', null);
|
||||
// NOTE: filter por recurrence_id requer join — fica como TODO no orchestrator
|
||||
// (memória project_cross_week_propagation: query records cross-week por recurrence_id).
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { assertTenantId, getUid } from './_tenantGuards';
|
||||
import { FINANCIAL_EXCEPTION_SELECT } from './financialSelects';
|
||||
@@ -35,10 +36,9 @@ export async function getRule(exceptionType, { tenantId } = {}) {
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const uid = await getUid();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('financial_exceptions')
|
||||
const { data, error } = await tenantDb().from('financial_exceptions')
|
||||
.select(FINANCIAL_EXCEPTION_SELECT)
|
||||
.eq('tenant_id', tid)
|
||||
|
||||
.eq('exception_type', exceptionType)
|
||||
.or(`owner_id.eq.${uid},owner_id.is.null`)
|
||||
.order('owner_id', { ascending: false, nullsLast: true })
|
||||
@@ -54,7 +54,7 @@ export async function getRule(exceptionType, { tenantId } = {}) {
|
||||
*/
|
||||
export async function listAll({ tenantId } = {}) {
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const { data, error } = await supabase.from('financial_exceptions').select(FINANCIAL_EXCEPTION_SELECT).eq('tenant_id', tid).order('exception_type', { ascending: true });
|
||||
const { data, error } = await tenantDb().from('financial_exceptions').select(FINANCIAL_EXCEPTION_SELECT).order('exception_type', { ascending: true });
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
@@ -72,7 +72,6 @@ export async function upsertRule(payload) {
|
||||
const tid = resolveTenantId(payload.tenantId);
|
||||
|
||||
const row = {
|
||||
tenant_id: tid,
|
||||
owner_id: payload.ownerScoped ? uid : null,
|
||||
exception_type: payload.exception_type,
|
||||
charge_mode: payload.charge_mode || 'none',
|
||||
@@ -83,7 +82,8 @@ export async function upsertRule(payload) {
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
const { data, error } = await supabase.from('financial_exceptions').upsert(row, { onConflict: 'tenant_id,owner_id,exception_type' }).select(FINANCIAL_EXCEPTION_SELECT).single();
|
||||
// TODO(schema-per-tenant): conferir unique (PK é só id; antes era tenant_id,owner_id,exception_type)
|
||||
const { data, error } = await tenantDb().from('financial_exceptions').upsert(row, { onConflict: 'owner_id,exception_type' }).select(FINANCIAL_EXCEPTION_SELECT).single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user