Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,48 @@ 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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,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;
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,6 +16,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_RECORD_SELECT, flattenFinancialRecord } from './financialSelects';
|
||||
@@ -46,10 +47,9 @@ export async function list(filters = {}) {
|
||||
const limit = filters.limit ?? 50;
|
||||
const offset = filters.offset ?? 0;
|
||||
|
||||
let q = supabase
|
||||
.from('financial_records')
|
||||
let q = tenantDb().from('financial_records')
|
||||
.select(FINANCIAL_RECORD_SELECT, { count: 'exact' })
|
||||
.eq('tenant_id', tid)
|
||||
|
||||
.is('deleted_at', null)
|
||||
.order('due_date', { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
@@ -75,7 +75,7 @@ export async function getById(recordId, { tenantId } = {}) {
|
||||
if (!recordId) throw new Error('recordId obrigatório.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { data, error } = await supabase.from('financial_records').select(FINANCIAL_RECORD_SELECT).eq('id', recordId).eq('tenant_id', tid).maybeSingle();
|
||||
const { data, error } = await tenantDb().from('financial_records').select(FINANCIAL_RECORD_SELECT).eq('id', recordId).maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return data ? flattenFinancialRecord(data) : null;
|
||||
@@ -89,7 +89,7 @@ export async function listByEvent(eventId, { tenantId } = {}) {
|
||||
if (!eventId) return [];
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { data, error } = await supabase.from('financial_records').select(FINANCIAL_RECORD_SELECT).eq('tenant_id', tid).eq('agenda_evento_id', eventId).is('deleted_at', null);
|
||||
const { data, error } = await tenantDb().from('financial_records').select(FINANCIAL_RECORD_SELECT).eq('agenda_evento_id', eventId).is('deleted_at', null);
|
||||
|
||||
if (error) throw error;
|
||||
return (data || []).map(flattenFinancialRecord);
|
||||
@@ -140,7 +140,6 @@ export async function createManual(payload) {
|
||||
const amount = Number(payload.amount);
|
||||
|
||||
const row = {
|
||||
tenant_id: tid,
|
||||
owner_id: uid,
|
||||
patient_id: payload.patient_id ?? null,
|
||||
agenda_evento_id: null,
|
||||
@@ -155,7 +154,7 @@ export async function createManual(payload) {
|
||||
notes: payload.notes ? String(payload.notes).trim() || null : null
|
||||
};
|
||||
|
||||
const { data, error } = await supabase.from('financial_records').insert([row]).select(FINANCIAL_RECORD_SELECT).single();
|
||||
const { data, error } = await tenantDb().from('financial_records').insert([row]).select(FINANCIAL_RECORD_SELECT).single();
|
||||
|
||||
if (error) throw error;
|
||||
return flattenFinancialRecord(data);
|
||||
@@ -164,10 +163,13 @@ export async function createManual(payload) {
|
||||
/**
|
||||
* Marca record como pago via RPC (server-side timestamps + audit).
|
||||
*/
|
||||
export async function markAsPaid(recordId, paymentMethod) {
|
||||
export async function markAsPaid(recordId, paymentMethod, { tenantId } = {}) {
|
||||
if (!recordId) throw new Error('recordId obrigatório.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
// RPC retorna jsonb (objeto único) — `data` é o financial_record, não array.
|
||||
const { data, error } = await supabase.rpc('mark_as_paid', {
|
||||
p_tenant_id: tid,
|
||||
p_financial_record_id: recordId,
|
||||
p_payment_method: paymentMethod
|
||||
});
|
||||
@@ -184,8 +186,7 @@ export async function markAsUnpaid(recordId, { tenantId } = {}) {
|
||||
if (!recordId) throw new Error('recordId obrigatório.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('financial_records')
|
||||
const { error } = await tenantDb().from('financial_records')
|
||||
.update({
|
||||
status: 'pending',
|
||||
paid_at: null,
|
||||
@@ -193,13 +194,13 @@ export async function markAsUnpaid(recordId, { tenantId } = {}) {
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', recordId)
|
||||
.eq('tenant_id', tid);
|
||||
;
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancela record (soft — status='cancelled'). Defesa em profundidade: .eq('tenant_id').
|
||||
* Cancela record (soft — status='cancelled').
|
||||
*/
|
||||
export async function cancel(recordId, { tenantId, reason } = {}) {
|
||||
if (!recordId) throw new Error('recordId obrigatório.');
|
||||
@@ -208,7 +209,7 @@ export async function cancel(recordId, { tenantId, reason } = {}) {
|
||||
const patch = { status: 'cancelled', updated_at: new Date().toISOString() };
|
||||
if (reason) patch.notes = String(reason).trim() || null;
|
||||
|
||||
const { error } = await supabase.from('financial_records').update(patch).eq('id', recordId).eq('tenant_id', tid);
|
||||
const { error } = await tenantDb().from('financial_records').update(patch).eq('id', recordId);
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
@@ -223,7 +224,7 @@ export async function update(recordId, patch, { tenantId } = {}) {
|
||||
|
||||
const safePatch = { ...patch, updated_at: new Date().toISOString() };
|
||||
|
||||
const { data, error } = await supabase.from('financial_records').update(safePatch).eq('id', recordId).eq('tenant_id', tid).select(FINANCIAL_RECORD_SELECT).single();
|
||||
const { data, error } = await tenantDb().from('financial_records').update(safePatch).eq('id', recordId).select(FINANCIAL_RECORD_SELECT).single();
|
||||
|
||||
if (error) throw error;
|
||||
return flattenFinancialRecord(data);
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
| Pure functions seguindo blueprints/repository-blueprint.md.
|
||||
|
|
||||
| Schema (servicos_prontuarios.sql):
|
||||
| id, owner_id, tenant_id,
|
||||
| id, owner_id,
|
||||
| name text, notes text, default_value numeric(10,2),
|
||||
| active boolean DEFAULT true, created_at, updated_at
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { assertTenantId, getUid } from './_tenantGuards';
|
||||
import { INSURANCE_PLAN_SELECT } from './insurancePlansSelects';
|
||||
@@ -37,7 +38,7 @@ export async function listForOwner({ ownerId, tenantId, includeInactive = false
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const uid = ownerId || (await getUid());
|
||||
|
||||
let q = supabase.from('insurance_plans').select(INSURANCE_PLAN_SELECT).eq('tenant_id', tid).eq('owner_id', uid).order('name', { ascending: true });
|
||||
let q = tenantDb().from('insurance_plans').select(INSURANCE_PLAN_SELECT).eq('owner_id', uid).order('name', { ascending: true });
|
||||
|
||||
if (!includeInactive) q = q.eq('active', true);
|
||||
|
||||
@@ -47,14 +48,14 @@ export async function listForOwner({ ownerId, tenantId, includeInactive = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê convênio por id. Filtra owner_id + tenant_id por segurança.
|
||||
* Lê convênio por id. Filtra owner_id por segurança.
|
||||
*/
|
||||
export async function getById(id, { tenantId } = {}) {
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const uid = await getUid();
|
||||
|
||||
const { data, error } = await supabase.from('insurance_plans').select(INSURANCE_PLAN_SELECT).eq('id', id).eq('tenant_id', tid).eq('owner_id', uid).maybeSingle();
|
||||
const { data, error } = await tenantDb().from('insurance_plans').select(INSURANCE_PLAN_SELECT).eq('id', id).eq('owner_id', uid).maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return data || null;
|
||||
@@ -76,7 +77,7 @@ export async function findByName({ name, ownerId, tenantId } = {}) {
|
||||
const safeName = String(name).trim();
|
||||
if (!safeName) return null;
|
||||
|
||||
const { data, error } = await supabase.from('insurance_plans').select(INSURANCE_PLAN_SELECT).eq('tenant_id', tid).eq('owner_id', uid).eq('active', true).ilike('name', safeName).limit(1).maybeSingle();
|
||||
const { data, error } = await tenantDb().from('insurance_plans').select(INSURANCE_PLAN_SELECT).eq('owner_id', uid).eq('active', true).ilike('name', safeName).limit(1).maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return data || null;
|
||||
@@ -84,7 +85,7 @@ export async function findByName({ name, ownerId, tenantId } = {}) {
|
||||
|
||||
/**
|
||||
* Cria convênio. Pré-checa duplicidade por nome (case-insensitive) — se já
|
||||
* existe ativo, lança erro PT-BR. Repository injeta owner_id + tenant_id.
|
||||
* existe ativo, lança erro PT-BR. Repository injeta owner_id.
|
||||
*/
|
||||
export async function create(payload) {
|
||||
if (!payload) throw new Error('Payload vazio.');
|
||||
@@ -103,21 +104,20 @@ export async function create(payload) {
|
||||
|
||||
const insertPayload = {
|
||||
owner_id: uid,
|
||||
tenant_id: tid,
|
||||
name: name.slice(0, 120),
|
||||
notes: payload.notes ? String(payload.notes).trim().slice(0, 500) || null : null,
|
||||
default_value: payload.default_value != null && payload.default_value !== '' ? Number(payload.default_value) : null,
|
||||
active: payload.active !== false
|
||||
};
|
||||
|
||||
const { data, error } = await supabase.from('insurance_plans').insert([insertPayload]).select(INSURANCE_PLAN_SELECT).single();
|
||||
const { data, error } = await tenantDb().from('insurance_plans').insert([insertPayload]).select(INSURANCE_PLAN_SELECT).single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza convênio. Filtra por id + tenant_id.
|
||||
* Atualiza convênio. Filtra por id.
|
||||
*/
|
||||
export async function update(id, patch, { tenantId } = {}) {
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
@@ -127,7 +127,7 @@ export async function update(id, patch, { tenantId } = {}) {
|
||||
const safePatch = sanitize(patch);
|
||||
safePatch.updated_at = new Date().toISOString();
|
||||
|
||||
const { data, error } = await supabase.from('insurance_plans').update(safePatch).eq('id', id).eq('tenant_id', tid).select(INSURANCE_PLAN_SELECT).single();
|
||||
const { data, error } = await tenantDb().from('insurance_plans').update(safePatch).eq('id', id).select(INSURANCE_PLAN_SELECT).single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
@@ -140,7 +140,7 @@ export async function softDelete(id, { tenantId } = {}) {
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { error } = await supabase.from('insurance_plans').update({ active: false, updated_at: new Date().toISOString() }).eq('id', id).eq('tenant_id', tid);
|
||||
const { error } = await tenantDb().from('insurance_plans').update({ active: false, updated_at: new Date().toISOString() }).eq('id', id);
|
||||
|
||||
if (error) throw error;
|
||||
return true;
|
||||
@@ -149,7 +149,9 @@ export async function softDelete(id, { tenantId } = {}) {
|
||||
// ─── helpers internos ────────────────────────────────────────────────────────
|
||||
|
||||
function sanitize(payload) {
|
||||
const out = { ...payload };
|
||||
// Dropa tenant_id defensivamente (schema-per-tenant: coluna não existe mais)
|
||||
const { tenant_id: _drop, ...rest } = payload;
|
||||
const out = { ...rest };
|
||||
if ('name' in out && typeof out.name === 'string') {
|
||||
const t = out.name.trim();
|
||||
out.name = t === '' ? null : t.slice(0, 120);
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
| blueprints/repository-blueprint.md.
|
||||
|
|
||||
| Schema (servicos_prontuarios.sql):
|
||||
| id, owner_id, tenant_id, nome, crm, especialidade,
|
||||
| id, owner_id, nome, crm, especialidade,
|
||||
| telefone_profissional, telefone_pessoal, email, clinica,
|
||||
| cidade, estado='SP', observacoes, ativo=true, created_at, updated_at
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { assertTenantId, getUid } from './_tenantGuards';
|
||||
import { MEDICO_LIST_SELECT, MEDICO_FULL_SELECT } from './medicosSelects';
|
||||
@@ -38,7 +39,7 @@ export async function listForOwner({ ownerId, tenantId, includeInactive = false
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const uid = ownerId || (await getUid());
|
||||
|
||||
let q = supabase.from('medicos').select(MEDICO_LIST_SELECT).eq('tenant_id', tid).eq('owner_id', uid).order('nome', { ascending: true });
|
||||
let q = tenantDb().from('medicos').select(MEDICO_LIST_SELECT).eq('owner_id', uid).order('nome', { ascending: true });
|
||||
|
||||
if (!includeInactive) q = q.eq('ativo', true);
|
||||
|
||||
@@ -48,7 +49,7 @@ export async function listForOwner({ ownerId, tenantId, includeInactive = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê um médico completo (pra edit). Filtra owner_id + tenant_id por segurança.
|
||||
* Lê um médico completo (pra edit). Filtra owner_id por segurança.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {Object} [opts]
|
||||
@@ -59,14 +60,14 @@ export async function getById(id, { tenantId } = {}) {
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const uid = await getUid();
|
||||
|
||||
const { data, error } = await supabase.from('medicos').select(MEDICO_FULL_SELECT).eq('id', id).eq('tenant_id', tid).eq('owner_id', uid).maybeSingle();
|
||||
const { data, error } = await tenantDb().from('medicos').select(MEDICO_FULL_SELECT).eq('id', id).eq('owner_id', uid).maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return data || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria médico. Injeta owner_id (uid logado) + tenant_id (store).
|
||||
* Cria médico. Injeta owner_id (uid logado).
|
||||
* Payload aceita os campos canônicos da tabela; o repository sanitiza
|
||||
* trims e nullif vazio.
|
||||
*
|
||||
@@ -83,18 +84,17 @@ export async function create(payload) {
|
||||
const insertPayload = {
|
||||
...sanitize(payload),
|
||||
owner_id: uid,
|
||||
tenant_id: tid,
|
||||
ativo: payload.ativo !== false
|
||||
};
|
||||
|
||||
const { data, error } = await supabase.from('medicos').insert([insertPayload]).select(MEDICO_FULL_SELECT).single();
|
||||
const { data, error } = await tenantDb().from('medicos').insert([insertPayload]).select(MEDICO_FULL_SELECT).single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza médico. Filtra por id + tenant_id (defesa em profundidade — RLS reforça).
|
||||
* Atualiza médico. Filtra por id (defesa em profundidade — RLS reforça).
|
||||
* updated_at é atualizado server-side ou aqui se não houver trigger.
|
||||
*
|
||||
* @param {string} id
|
||||
@@ -112,7 +112,7 @@ export async function update(id, patch, { tenantId } = {}) {
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
const { data, error } = await supabase.from('medicos').update(safePatch).eq('id', id).eq('tenant_id', tid).select(MEDICO_FULL_SELECT).single();
|
||||
const { data, error } = await tenantDb().from('medicos').update(safePatch).eq('id', id).select(MEDICO_FULL_SELECT).single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
@@ -130,7 +130,7 @@ export async function softDelete(id, { tenantId } = {}) {
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { error } = await supabase.from('medicos').update({ ativo: false, updated_at: new Date().toISOString() }).eq('id', id).eq('tenant_id', tid);
|
||||
const { error } = await tenantDb().from('medicos').update({ ativo: false, updated_at: new Date().toISOString() }).eq('id', id);
|
||||
|
||||
if (error) throw error;
|
||||
return true;
|
||||
@@ -141,12 +141,14 @@ export async function softDelete(id, { tenantId } = {}) {
|
||||
/**
|
||||
* Sanitiza payload: trim em strings, nullif vazio.
|
||||
* Não sanitiza telefones (já chegam digits-only do componente)
|
||||
* nem owner_id/tenant_id/ativo (controlados pelo repository).
|
||||
* nem owner_id/ativo (controlados pelo repository).
|
||||
* Dropa tenant_id defensivamente (schema-per-tenant: coluna não existe mais).
|
||||
*/
|
||||
function sanitize(payload) {
|
||||
const stringFields = ['nome', 'crm', 'especialidade', 'telefone_profissional', 'telefone_pessoal', 'email', 'clinica', 'cidade', 'estado', 'observacoes'];
|
||||
|
||||
const out = { ...payload };
|
||||
const { tenant_id: _drop, ...rest } = payload;
|
||||
const out = { ...rest };
|
||||
for (const f of stringFields) {
|
||||
if (f in out) {
|
||||
const v = out[f];
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
-->
|
||||
<script setup>
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import {
|
||||
listGroupsByPatient,
|
||||
listTagsByPatient,
|
||||
@@ -76,13 +77,12 @@ async function abrirSessoes(pat) {
|
||||
recorrencias.value = [];
|
||||
try {
|
||||
const [evts, recs] = await Promise.all([
|
||||
supabase
|
||||
.from('agenda_eventos')
|
||||
tenantDb().from('agenda_eventos')
|
||||
.select('id, titulo, tipo, status, inicio_em, fim_em, modalidade, insurance_guide_number, insurance_value, insurance_plans(name)')
|
||||
.eq('patient_id', pat.id)
|
||||
.order('inicio_em', { ascending: false })
|
||||
.limit(100),
|
||||
supabase.from('recurrence_rules').select('id, type, interval, weekdays, start_date, end_date, start_time, duration_min, status').eq('patient_id', pat.id).order('start_date', { ascending: false })
|
||||
tenantDb().from('recurrence_rules').select('id, type, interval, weekdays, start_date, end_date, start_time, duration_min, status').eq('patient_id', pat.id).order('start_date', { ascending: false })
|
||||
]);
|
||||
sessoesLista.value = evts.data || [];
|
||||
recorrencias.value = recs.data || [];
|
||||
@@ -487,11 +487,9 @@ function withOwnerFilter(q) {
|
||||
return uid.value ? q.eq('owner_id', uid.value) : q;
|
||||
}
|
||||
|
||||
// Defesa em profundidade: filtra por tenant_id do tenantStore em todas as queries.
|
||||
// RLS cobre no backend, mas blindamos no cliente (padrão do projeto).
|
||||
// Schema-per-tenant: isolamento via schema tenant_<slug>; tabela não tem mais coluna tenant_id.
|
||||
function withTenantFilter(q) {
|
||||
const tid = tenantStore.activeTenantId;
|
||||
return tid ? q.eq('tenant_id', tid) : q;
|
||||
return q;
|
||||
}
|
||||
|
||||
// ── Filtered rows ─────────────────────────────────────────
|
||||
@@ -547,13 +545,13 @@ async function fetchAll() {
|
||||
discountMap.value = {};
|
||||
if (uid.value) {
|
||||
const now = new Date().toISOString();
|
||||
const { data: discRows } = await supabase.from('patient_discounts').select('patient_id, discount_pct, discount_flat').eq('owner_id', uid.value).eq('active', true).or(`active_to.is.null,active_to.gte.${now}`);
|
||||
const { data: discRows } = await tenantDb().from('patient_discounts').select('patient_id, discount_pct, discount_flat').eq('owner_id', uid.value).eq('active', true).or(`active_to.is.null,active_to.gte.${now}`);
|
||||
if (discRows) discountMap.value = Object.fromEntries(discRows.map((d) => [d.patient_id, d]));
|
||||
}
|
||||
|
||||
insuranceMap.value = {};
|
||||
if (uid.value) {
|
||||
const { data: insRows } = await supabase.from('agenda_eventos').select('patient_id, insurance_plan_id, insurance_plans(name)').eq('owner_id', uid.value).not('insurance_plan_id', 'is', null).order('inicio_em', { ascending: false });
|
||||
const { data: insRows } = await tenantDb().from('agenda_eventos').select('patient_id, insurance_plan_id, insurance_plans(name)').eq('owner_id', uid.value).not('insurance_plan_id', 'is', null).order('inicio_em', { ascending: false });
|
||||
if (insRows) {
|
||||
for (const row of insRows) {
|
||||
if (!insuranceMap.value[row.patient_id]) insuranceMap.value[row.patient_id] = row.insurance_plans?.name ?? null;
|
||||
@@ -574,7 +572,7 @@ async function fetchAll() {
|
||||
}
|
||||
|
||||
async function listPatients() {
|
||||
let q = supabase.from('patients').select('id, owner_id, nome_completo, email_principal, telefone, avatar_url, status, last_attended_at, created_at, updated_at').order('created_at', { ascending: false });
|
||||
let q = tenantDb().from('patients').select('id, owner_id, nome_completo, email_principal, telefone, avatar_url, status, last_attended_at, created_at, updated_at').order('created_at', { ascending: false });
|
||||
q = withTenantFilter(withOwnerFilter(q));
|
||||
const { data, error } = await q;
|
||||
if (error) throw error;
|
||||
@@ -590,7 +588,7 @@ async function listPatients() {
|
||||
}
|
||||
|
||||
async function listGroups() {
|
||||
let q = supabase.from('patient_groups').select('id, owner_id, nome, cor, is_system, is_active').eq('is_active', true).order('nome', { ascending: true });
|
||||
let q = tenantDb().from('patient_groups').select('id, owner_id, nome, cor, is_system, is_active').eq('is_active', true).order('nome', { ascending: true });
|
||||
q = withTenantFilter(q);
|
||||
if (uid.value) q = q.or(`is_system.eq.true,owner_id.eq.${uid.value}`);
|
||||
else q = q.eq('is_system', true);
|
||||
@@ -600,7 +598,7 @@ async function listGroups() {
|
||||
}
|
||||
|
||||
async function listTags() {
|
||||
let q = supabase.from('patient_tags').select('id, owner_id, nome, cor').order('nome', { ascending: true });
|
||||
let q = tenantDb().from('patient_tags').select('id, owner_id, nome, cor').order('nome', { ascending: true });
|
||||
q = withTenantFilter(q);
|
||||
if (uid.value) q = q.eq('owner_id', uid.value);
|
||||
const { data, error } = await q;
|
||||
|
||||
@@ -64,6 +64,7 @@ import { useRoleGuard } from '@/composables/useRoleGuard'
|
||||
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 { logError } from '@/support/supportLogger'
|
||||
import { digitsOnly, fmtCPF, fmtRG, fmtPhone, toISODate, generateCPF } from '@/utils/validators'
|
||||
@@ -647,7 +648,7 @@ async function onSubmit () {
|
||||
await openPanel(0); return
|
||||
}
|
||||
const payload = sanitizePayload(form.value, ownerId)
|
||||
payload.tenant_id = tenantId; payload.responsible_member_id = memberId
|
||||
payload.responsible_member_id = memberId
|
||||
if (isEdit.value) {
|
||||
await updatePatient(patientId.value, payload)
|
||||
await maybeUploadAvatar(ownerId, patientId.value)
|
||||
@@ -706,7 +707,7 @@ async function doDelete () {
|
||||
['patients', 'id'],
|
||||
]
|
||||
for (const [tbl, col] of tables) {
|
||||
const { error } = await supabase.from(tbl).delete().eq(col, pid); if (error) throw error
|
||||
const { error } = await tenantDb().from(tbl).delete().eq(col, pid); if (error) throw error
|
||||
}
|
||||
toast.add({ severity:'success', summary:'Excluído', detail:'Paciente removido.', life:2500 })
|
||||
if (props.dialogMode) { emit('created', null); return }
|
||||
@@ -766,7 +767,7 @@ async function createGroupPersist () {
|
||||
createGroupSaving.value=true
|
||||
try {
|
||||
const ownerId=await getOwnerId(); const { tenantId }=await resolveTenantContextOrFail()
|
||||
const { data, error }=await supabase.from('patient_groups').insert({ owner_id:ownerId, tenant_id:tenantId, nome:name, cor:color, is_system:false, is_active:true }).select('id').single()
|
||||
const { data, error }=await tenantDb().from('patient_groups').insert({ owner_id:ownerId, nome:name, cor:color, is_system:false, is_active:true }).select('id').single()
|
||||
if (error) throw error
|
||||
groups.value=await listGroups(); if (data?.id) grupoIdSelecionado.value=data.id
|
||||
toast.add({ severity:'success', summary:'Grupo criado.', life:2500 }); createGroupDialog.value=false
|
||||
@@ -782,7 +783,7 @@ async function createTagPersist () {
|
||||
createTagSaving.value=true
|
||||
try {
|
||||
const ownerId=await getOwnerId(); const { tenantId }=await resolveTenantContextOrFail()
|
||||
const { data, error }=await supabase.from('patient_tags').insert({ owner_id:ownerId, tenant_id:tenantId, nome:name, cor:color }).select('id').single()
|
||||
const { data, error }=await tenantDb().from('patient_tags').insert({ owner_id:ownerId, nome:name, cor:color }).select('id').single()
|
||||
if (error) throw error
|
||||
tags.value=await listTags()
|
||||
if (data?.id) { const s=new Set([...(tagIdsSelecionadas.value||[]),data.id]); tagIdsSelecionadas.value=Array.from(s) }
|
||||
|
||||
@@ -371,7 +371,6 @@ async function convertToPatient() {
|
||||
const intakeAvatar = cleanStr(item.avatar_url) || cleanStr(item.foto_url) || cleanStr(item.photo_url) || null;
|
||||
|
||||
const patientPayload = {
|
||||
tenant_id: tenantId,
|
||||
responsible_member_id: responsibleMemberId,
|
||||
owner_id: ownerId,
|
||||
nome_completo: cleanStr(fNome(item)),
|
||||
|
||||
@@ -21,6 +21,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import Menu from 'primevue/menu';
|
||||
|
||||
@@ -201,7 +202,7 @@ function applyRealCountsToGroups(groupsArr, countMap) {
|
||||
async function fetchRealGroupCountsForOwner() {
|
||||
const ownerId = (await supabase.auth.getUser())?.data?.user?.id;
|
||||
if (!ownerId) throw new Error('Sessão inválida.');
|
||||
const { data, error } = await supabase.from('patient_group_patient').select('patient_group_id, patient:patients!inner(id, owner_id)').eq('patient.owner_id', ownerId);
|
||||
const { data, error } = await tenantDb().from('patient_group_patient').select('patient_group_id, patient:patients!inner(id, owner_id)').eq('patient.owner_id', ownerId);
|
||||
if (error) throw error;
|
||||
const map = Object.create(null);
|
||||
for (const row of data || []) {
|
||||
@@ -362,7 +363,7 @@ async function openGroupPatientsModal(groupRow) {
|
||||
patientsDialog.items = [];
|
||||
patientsDialog.search = '';
|
||||
try {
|
||||
const { data, error } = await supabase.from('patient_group_patient').select('patient_id, patient:patients(id, nome_completo, email_principal, telefone, avatar_url)').eq('patient_group_id', groupRow.id);
|
||||
const { data, error } = await tenantDb().from('patient_group_patient').select('patient_id, patient:patients(id, nome_completo, email_principal, telefone, avatar_url)').eq('patient_group_id', groupRow.id);
|
||||
if (error) throw error;
|
||||
patientsDialog.items = (data || [])
|
||||
.map((r) => r.patient)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
|
||||
import { formatDistanceToNow, format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
@@ -31,8 +32,7 @@ async function load() {
|
||||
if (!props.patientId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_messages')
|
||||
const { data, error } = await tenantDb().from('conversation_messages')
|
||||
.select('id, channel, direction, from_number, to_number, body, media_url, media_mime, provider, kanban_status, received_at, created_at, responded_at, delivery_status')
|
||||
.eq('patient_id', props.patientId)
|
||||
.order('created_at', { ascending: true })
|
||||
|
||||
@@ -27,6 +27,7 @@ import Popover from 'primevue/popover';
|
||||
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 { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
|
||||
|
||||
@@ -379,8 +380,7 @@ async function updateSessionStatus(ev, novoStatus, msg) {
|
||||
if (!ev?.id || sessionBusy.value) return;
|
||||
sessionBusy.value = true;
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
const { error } = await tenantDb().from('agenda_eventos')
|
||||
.update({ status: novoStatus })
|
||||
.eq('id', ev.id);
|
||||
if (error) throw error;
|
||||
@@ -432,8 +432,7 @@ function openAddPhone() {
|
||||
// bloqueamos save por falha no check.
|
||||
async function findPhoneOwner(digits, excludeId) {
|
||||
try {
|
||||
const { data: byPat } = await supabase
|
||||
.from('patients')
|
||||
const { data: byPat } = await tenantDb().from('patients')
|
||||
.select('id, nome_completo')
|
||||
.eq('telefone', digits)
|
||||
.neq('id', excludeId)
|
||||
@@ -441,8 +440,7 @@ async function findPhoneOwner(digits, excludeId) {
|
||||
.maybeSingle();
|
||||
if (byPat?.id) return byPat;
|
||||
|
||||
const { data: byCp } = await supabase
|
||||
.from('contact_phones')
|
||||
const { data: byCp } = await tenantDb().from('contact_phones')
|
||||
.select('entity_id')
|
||||
.eq('entity_type', 'patient')
|
||||
.eq('number', digits)
|
||||
@@ -450,8 +448,7 @@ async function findPhoneOwner(digits, excludeId) {
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (byCp?.entity_id) {
|
||||
const { data: p } = await supabase
|
||||
.from('patients')
|
||||
const { data: p } = await tenantDb().from('patients')
|
||||
.select('id, nome_completo')
|
||||
.eq('id', byCp.entity_id)
|
||||
.maybeSingle();
|
||||
@@ -508,8 +505,7 @@ async function _persistPhone(id, digits, tenantId) {
|
||||
|
||||
// 1) Garante o contact_type "whatsapp" (system, slug fixo via
|
||||
// seed_014_global_data).
|
||||
const { data: ctype, error: errType } = await supabase
|
||||
.from('contact_types')
|
||||
const { data: ctype, error: errType } = await tenantDb().from('contact_types')
|
||||
.select('id')
|
||||
.eq('slug', 'whatsapp')
|
||||
.order('is_system', { ascending: false })
|
||||
@@ -519,8 +515,7 @@ async function _persistPhone(id, digits, tenantId) {
|
||||
if (!ctype?.id) throw new Error('Tipo de contato "WhatsApp" não encontrado.');
|
||||
|
||||
// 2) Insere ou atualiza em contact_phones (entity_type=patient).
|
||||
const { data: existing } = await supabase
|
||||
.from('contact_phones')
|
||||
const { data: existing } = await tenantDb().from('contact_phones')
|
||||
.select('id, is_primary')
|
||||
.eq('entity_type', 'patient')
|
||||
.eq('entity_id', id)
|
||||
@@ -529,22 +524,18 @@ async function _persistPhone(id, digits, tenantId) {
|
||||
.maybeSingle();
|
||||
|
||||
if (existing?.id) {
|
||||
const { error: errUpd } = await supabase
|
||||
.from('contact_phones')
|
||||
const { error: errUpd } = await tenantDb().from('contact_phones')
|
||||
.update({ number: digits, whatsapp_linked_at: new Date().toISOString() })
|
||||
.eq('id', existing.id);
|
||||
if (errUpd) throw errUpd;
|
||||
} else {
|
||||
const { count } = await supabase
|
||||
.from('contact_phones')
|
||||
const { count } = await tenantDb().from('contact_phones')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('entity_type', 'patient')
|
||||
.eq('entity_id', id);
|
||||
const isPrimary = (count || 0) === 0;
|
||||
const { error: errIns } = await supabase
|
||||
.from('contact_phones')
|
||||
const { error: errIns } = await tenantDb().from('contact_phones')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
entity_type: 'patient',
|
||||
entity_id: id,
|
||||
contact_type_id: ctype.id,
|
||||
@@ -820,8 +811,7 @@ async function loadSessions(patientId) {
|
||||
sessionsLoading.value = true;
|
||||
sessions.value = [];
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
const { data, error } = await tenantDb().from('agenda_eventos')
|
||||
.select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, observacoes')
|
||||
.eq('patient_id', patientId)
|
||||
.order('inicio_em', { ascending: false })
|
||||
@@ -840,8 +830,7 @@ async function loadRecentMessages(patientId) {
|
||||
messagesLoading.value = true;
|
||||
recentMessages.value = [];
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_messages')
|
||||
const { data, error } = await tenantDb().from('conversation_messages')
|
||||
.select('id, body, direction, created_at, channel, kanban_status')
|
||||
.eq('patient_id', patientId)
|
||||
.order('created_at', { ascending: false })
|
||||
@@ -858,8 +847,7 @@ async function loadDocumentsList(patientId) {
|
||||
documentsLoading.value = true;
|
||||
documentsList.value = [];
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('documents')
|
||||
const { data, error } = await tenantDb().from('documents')
|
||||
.select('id, tipo_documento, created_at, status_revisao, tamanho_bytes')
|
||||
.eq('patient_id', patientId)
|
||||
.is('deleted_at', null)
|
||||
@@ -878,8 +866,7 @@ async function loadFinancialRecent(patientId) {
|
||||
financialLoading.value = true;
|
||||
financialRecords.value = [];
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('financial_records')
|
||||
const { data, error } = await tenantDb().from('financial_records')
|
||||
.select('id, type, amount, due_date, paid_at, description, payment_method, category, created_at')
|
||||
.eq('patient_id', patientId)
|
||||
.eq('type', 'receita')
|
||||
@@ -892,15 +879,15 @@ async function loadFinancialRecent(patientId) {
|
||||
}
|
||||
|
||||
async function getPatientById(id) {
|
||||
const { data, error } = await supabase.from('patients').select('*').eq('id', id).maybeSingle();
|
||||
const { data, error } = await tenantDb().from('patients').select('*').eq('id', id).maybeSingle();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getPatientRelations(id) {
|
||||
const { data: g, error: ge } = await supabase.from('patient_group_patient').select('patient_group_id').eq('patient_id', id);
|
||||
const { data: g, error: ge } = await tenantDb().from('patient_group_patient').select('patient_group_id').eq('patient_id', id);
|
||||
if (ge) throw ge;
|
||||
const { data: t, error: te } = await supabase.from('patient_patient_tag').select('tag_id').eq('patient_id', id);
|
||||
const { data: t, error: te } = await tenantDb().from('patient_patient_tag').select('tag_id').eq('patient_id', id);
|
||||
if (te) throw te;
|
||||
return {
|
||||
groupIds: (g || []).map(x => x.patient_group_id).filter(Boolean),
|
||||
@@ -910,14 +897,14 @@ async function getPatientRelations(id) {
|
||||
|
||||
async function getGroupsByIds(ids) {
|
||||
if (!ids?.length) return [];
|
||||
const { data, error } = await supabase.from('patient_groups').select('id, nome').in('id', ids).order('nome', { ascending: true });
|
||||
const { data, error } = await tenantDb().from('patient_groups').select('id, nome').in('id', ids).order('nome', { ascending: true });
|
||||
if (error) throw error;
|
||||
return (data || []).map(g => ({ id: g.id, name: g.nome }));
|
||||
}
|
||||
|
||||
async function getTagsByIds(ids) {
|
||||
if (!ids?.length) return [];
|
||||
const { data, error } = await supabase.from('patient_tags').select('id, nome, cor').in('id', ids).order('nome', { ascending: true });
|
||||
const { data, error } = await tenantDb().from('patient_tags').select('id, nome, cor').in('id', ids).order('nome', { ascending: true });
|
||||
if (error) throw error;
|
||||
return (data || []).map(t => ({ id: t.id, name: t.nome, color: t.cor }));
|
||||
}
|
||||
@@ -989,8 +976,7 @@ async function setProximaSessaoStatus(novoStatus, msgSucesso) {
|
||||
if (!ev?.id || sessionBusy.value) return;
|
||||
sessionBusy.value = true;
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
const { error } = await tenantDb().from('agenda_eventos')
|
||||
.update({ status: novoStatus })
|
||||
.eq('id', ev.id);
|
||||
if (error) throw error;
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
| Arquivo: src/features/patients/prontuario/services/clinicalNoteTemplatesRepository.js
|
||||
|
|
||||
| Repository de clinical_note_templates. Escopo escalonado:
|
||||
| - Sistema (is_system=true, tenant_id NULL) — todos authenticated leem
|
||||
| - Tenant-wide (tenant_id, owner_id NULL) — membros do tenant
|
||||
| - Owner (tenant_id + owner_id) — só o owner
|
||||
| - Sistema (is_system=true) — todos authenticated leem
|
||||
| - Tenant-wide (owner_id NULL) — membros do tenant (schema do tenant)
|
||||
| - Owner (owner_id) — só o owner
|
||||
|
|
||||
| RLS bloqueia INSERT/UPDATE/DELETE de templates is_system — só via seed.
|
||||
| Templates do tenant podem ser criados/editados pelo tenant_admin.
|
||||
@@ -16,6 +16,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { assertTenantId, getUid } from './_tenantGuards';
|
||||
import { CLINICAL_NOTE_TEMPLATE_SELECT } from './clinicalNotesSelects';
|
||||
@@ -41,7 +42,7 @@ function resolveTenantId(tenantIdArg) {
|
||||
export async function listAvailable({ noteType, tenantId, includeInactive = false } = {}) {
|
||||
resolveTenantId(tenantId); // garante tenant ativo (RLS depende)
|
||||
|
||||
let q = supabase.from('clinical_note_templates').select(CLINICAL_NOTE_TEMPLATE_SELECT).order('is_system', { ascending: false }).order('name', { ascending: true });
|
||||
let q = tenantDb().from('clinical_note_templates').select(CLINICAL_NOTE_TEMPLATE_SELECT).order('is_system', { ascending: false }).order('name', { ascending: true });
|
||||
|
||||
if (!includeInactive) q = q.eq('active', true);
|
||||
if (noteType) q = q.eq('note_type', noteType);
|
||||
@@ -57,7 +58,7 @@ export async function listAvailable({ noteType, tenantId, includeInactive = fals
|
||||
export async function getById(templateId) {
|
||||
if (!templateId) throw new Error('ID inválido.');
|
||||
|
||||
const { data, error } = await supabase.from('clinical_note_templates').select(CLINICAL_NOTE_TEMPLATE_SELECT).eq('id', templateId).maybeSingle();
|
||||
const { data, error } = await tenantDb().from('clinical_note_templates').select(CLINICAL_NOTE_TEMPLATE_SELECT).eq('id', templateId).maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return data || null;
|
||||
@@ -70,7 +71,7 @@ export async function getById(templateId) {
|
||||
export async function getByKey(key, { noteType } = {}) {
|
||||
if (!key) throw new Error('Key inválida.');
|
||||
|
||||
let q = supabase.from('clinical_note_templates').select(CLINICAL_NOTE_TEMPLATE_SELECT).eq('key', key).eq('active', true);
|
||||
let q = tenantDb().from('clinical_note_templates').select(CLINICAL_NOTE_TEMPLATE_SELECT).eq('key', key).eq('active', true);
|
||||
if (noteType) q = q.eq('note_type', noteType);
|
||||
|
||||
const { data, error } = await q.order('is_system', { ascending: false }).limit(1).maybeSingle();
|
||||
@@ -94,7 +95,6 @@ export async function create(payload) {
|
||||
const tid = resolveTenantId();
|
||||
|
||||
const row = {
|
||||
tenant_id: tid,
|
||||
owner_id: payload.ownerScoped ? uid : null,
|
||||
key: String(payload.key).trim(),
|
||||
name: String(payload.name).trim(),
|
||||
@@ -106,7 +106,7 @@ export async function create(payload) {
|
||||
active: payload.active !== false
|
||||
};
|
||||
|
||||
const { data, error } = await supabase.from('clinical_note_templates').insert([row]).select(CLINICAL_NOTE_TEMPLATE_SELECT).single();
|
||||
const { data, error } = await tenantDb().from('clinical_note_templates').insert([row]).select(CLINICAL_NOTE_TEMPLATE_SELECT).single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
@@ -120,8 +120,9 @@ export async function update(templateId, patch) {
|
||||
|
||||
const safePatch = { ...patch, updated_at: new Date().toISOString() };
|
||||
if ('is_system' in safePatch) delete safePatch.is_system; // RLS bloqueia mas defesa em profundidade
|
||||
if ('tenant_id' in safePatch) delete safePatch.tenant_id; // schema-per-tenant: coluna não existe mais
|
||||
|
||||
const { data, error } = await supabase.from('clinical_note_templates').update(safePatch).eq('id', templateId).select(CLINICAL_NOTE_TEMPLATE_SELECT).single();
|
||||
const { data, error } = await tenantDb().from('clinical_note_templates').update(safePatch).eq('id', templateId).select(CLINICAL_NOTE_TEMPLATE_SELECT).single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
@@ -133,7 +134,7 @@ export async function update(templateId, patch) {
|
||||
export async function softDelete(templateId) {
|
||||
if (!templateId) throw new Error('ID inválido.');
|
||||
|
||||
const { error } = await supabase.from('clinical_note_templates').update({ active: false, updated_at: new Date().toISOString() }).eq('id', templateId);
|
||||
const { error } = await tenantDb().from('clinical_note_templates').update({ active: false, updated_at: new Date().toISOString() }).eq('id', templateId);
|
||||
|
||||
if (error) throw error;
|
||||
return true;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { assertTenantId, getUid } from './_tenantGuards';
|
||||
import {
|
||||
@@ -55,10 +56,9 @@ export async function listForPatient(patientId, { tenantId, noteType = null, inc
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const select = brief ? CLINICAL_NOTE_SELECT_BRIEF : CLINICAL_NOTE_SELECT;
|
||||
|
||||
let q = supabase
|
||||
.from('clinical_notes')
|
||||
let q = tenantDb().from('clinical_notes')
|
||||
.select(select)
|
||||
.eq('tenant_id', tid)
|
||||
|
||||
.eq('patient_id', patientId)
|
||||
.order('pinned', { ascending: false })
|
||||
.order('created_at', { ascending: false });
|
||||
@@ -80,7 +80,7 @@ export async function listForSession(sessionEventId, { tenantId, brief = false }
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const select = brief ? CLINICAL_NOTE_SELECT_BRIEF : CLINICAL_NOTE_SELECT;
|
||||
|
||||
const { data, error } = await supabase.from('clinical_notes').select(select).eq('tenant_id', tid).eq('session_event_id', sessionEventId).is('deleted_at', null).order('created_at', { ascending: false });
|
||||
const { data, error } = await tenantDb().from('clinical_notes').select(select).eq('session_event_id', sessionEventId).is('deleted_at', null).order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return (data || []).map(flattenNoteRow);
|
||||
@@ -93,7 +93,7 @@ export async function getById(noteId, { tenantId } = {}) {
|
||||
if (!noteId) throw new Error('ID inválido.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { data, error } = await supabase.from('clinical_notes').select(CLINICAL_NOTE_SELECT).eq('id', noteId).eq('tenant_id', tid).maybeSingle();
|
||||
const { data, error } = await tenantDb().from('clinical_notes').select(CLINICAL_NOTE_SELECT).eq('id', noteId).maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return data ? flattenNoteRow(data) : null;
|
||||
@@ -140,7 +140,7 @@ export async function create(payload) {
|
||||
created_by: uid
|
||||
};
|
||||
|
||||
const { data, error } = await supabase.from('clinical_notes').insert([row]).select(CLINICAL_NOTE_SELECT).single();
|
||||
const { data, error } = await tenantDb().from('clinical_notes').insert([row]).select(CLINICAL_NOTE_SELECT).single();
|
||||
|
||||
if (error) throw error;
|
||||
return flattenNoteRow(data);
|
||||
@@ -164,7 +164,7 @@ export async function update(noteId, patch, { tenantId } = {}) {
|
||||
|
||||
const safePatch = { ...sanitize(patch), updated_by: uid };
|
||||
|
||||
const { data, error } = await supabase.from('clinical_notes').update(safePatch).eq('id', noteId).eq('tenant_id', tid).select(CLINICAL_NOTE_SELECT).single();
|
||||
const { data, error } = await tenantDb().from('clinical_notes').update(safePatch).eq('id', noteId).select(CLINICAL_NOTE_SELECT).single();
|
||||
|
||||
if (error) throw error;
|
||||
return flattenNoteRow(data);
|
||||
@@ -179,11 +179,10 @@ export async function softDelete(noteId, { tenantId } = {}) {
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const uid = await getUid();
|
||||
|
||||
const { error } = await supabase
|
||||
.from('clinical_notes')
|
||||
const { error } = await tenantDb().from('clinical_notes')
|
||||
.update({ deleted_at: new Date().toISOString(), deleted_by: uid, updated_by: uid })
|
||||
.eq('id', noteId)
|
||||
.eq('tenant_id', tid);
|
||||
;
|
||||
|
||||
if (error) throw error;
|
||||
return true;
|
||||
@@ -197,7 +196,7 @@ export async function restore(noteId, { tenantId } = {}) {
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const uid = await getUid();
|
||||
|
||||
const { error } = await supabase.from('clinical_notes').update({ deleted_at: null, deleted_by: null, updated_by: uid }).eq('id', noteId).eq('tenant_id', tid);
|
||||
const { error } = await tenantDb().from('clinical_notes').update({ deleted_at: null, deleted_by: null, updated_by: uid }).eq('id', noteId);
|
||||
|
||||
if (error) throw error;
|
||||
return true;
|
||||
@@ -216,7 +215,7 @@ export async function setPinned(noteId, pinned, { tenantId } = {}) {
|
||||
export async function listVersions(noteId) {
|
||||
if (!noteId) return [];
|
||||
|
||||
const { data, error } = await supabase.from('clinical_note_versions').select(CLINICAL_NOTE_VERSION_SELECT).eq('note_id', noteId).order('version_number', { ascending: false });
|
||||
const { data, error } = await tenantDb().from('clinical_note_versions').select(CLINICAL_NOTE_VERSION_SELECT).eq('note_id', noteId).order('version_number', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
@@ -229,7 +228,7 @@ export async function getVersion(noteId, versionNumber) {
|
||||
if (!noteId) throw new Error('noteId obrigatório.');
|
||||
if (!versionNumber) throw new Error('versionNumber obrigatório.');
|
||||
|
||||
const { data, error } = await supabase.from('clinical_note_versions').select(CLINICAL_NOTE_VERSION_SELECT).eq('note_id', noteId).eq('version_number', versionNumber).maybeSingle();
|
||||
const { data, error } = await tenantDb().from('clinical_note_versions').select(CLINICAL_NOTE_VERSION_SELECT).eq('note_id', noteId).eq('version_number', versionNumber).maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return data || null;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
| V#3 — fundação: queries de patients centralizadas.
|
||||
|
|
||||
| Pages e composables devem chamar este repo em vez de fazer
|
||||
| supabase.from('patients') direto.
|
||||
| tenantDb().from('patients') direto.
|
||||
|
|
||||
| Inclui também reads cross-feature em escopo de paciente (agenda_eventos,
|
||||
| financial_records, documents, recurrence_rules, conversation_messages,
|
||||
@@ -16,6 +16,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { assertTenantId, getUid } from '@/features/agenda/services/_tenantGuards';
|
||||
import {
|
||||
@@ -51,7 +52,7 @@ function resolveTenantId(tenantIdArg) {
|
||||
export async function listPatients({ tenantId, ownerId = null, includeInactive = true, limit = null } = {}) {
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
let q = supabase.from('patients').select(PATIENTS_SELECT_BASE).eq('tenant_id', tid);
|
||||
let q = tenantDb().from('patients').select(PATIENTS_SELECT_BASE);
|
||||
if (ownerId) q = q.eq('owner_id', ownerId);
|
||||
if (!includeInactive) q = q.neq('status', 'Inativo');
|
||||
if (limit) q = q.limit(limit);
|
||||
@@ -65,7 +66,7 @@ export async function listPatients({ tenantId, ownerId = null, includeInactive =
|
||||
export async function getPatientById(id, { tenantId } = {}) {
|
||||
if (!id) throw new Error('id obrigatório');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const { data, error } = await supabase.from('patients').select(PATIENTS_SELECT_BASE).eq('id', id).eq('tenant_id', tid).maybeSingle();
|
||||
const { data, error } = await tenantDb().from('patients').select(PATIENTS_SELECT_BASE).eq('id', id).maybeSingle();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
@@ -77,9 +78,9 @@ export async function createPatient(payload) {
|
||||
// criar pacientes "de outro terapeuta". Repository é defesa em profundidade.
|
||||
const ownerId = await getUid();
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { owner_id: _dropped, ...rest } = payload || {};
|
||||
const row = { ...rest, tenant_id: tid, owner_id: ownerId };
|
||||
const { data, error } = await supabase.from('patients').insert(row).select(PATIENTS_SELECT_BASE).single();
|
||||
const { owner_id: _dropped, tenant_id: _tenantDropped, ...rest } = payload || {};
|
||||
const row = { ...rest, owner_id: ownerId };
|
||||
const { data, error } = await tenantDb().from('patients').insert(row).select(PATIENTS_SELECT_BASE).single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
@@ -87,7 +88,7 @@ export async function createPatient(payload) {
|
||||
export async function updatePatient(id, patch, { tenantId } = {}) {
|
||||
if (!id) throw new Error('id obrigatório');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const { data, error } = await supabase.from('patients').update(patch).eq('id', id).eq('tenant_id', tid).select(PATIENTS_SELECT_BASE).single();
|
||||
const { data, error } = await tenantDb().from('patients').update(patch).eq('id', id).select(PATIENTS_SELECT_BASE).single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
@@ -95,7 +96,7 @@ export async function updatePatient(id, patch, { tenantId } = {}) {
|
||||
export async function softDeletePatient(id, { tenantId } = {}) {
|
||||
if (!id) throw new Error('id obrigatório');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const { error } = await supabase.from('patients').update({ status: 'Arquivado' }).eq('id', id).eq('tenant_id', tid);
|
||||
const { error } = await tenantDb().from('patients').update({ status: 'Arquivado' }).eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
@@ -111,8 +112,8 @@ export async function softDeletePatient(id, { tenantId } = {}) {
|
||||
export async function getPatientRelations(patientId) {
|
||||
if (!patientId) return { groupIds: [], tagIds: [] };
|
||||
const [{ data: g, error: ge }, { data: t, error: te }] = await Promise.all([
|
||||
supabase.from('patient_group_patient').select('patient_group_id').eq('patient_id', patientId),
|
||||
supabase.from('patient_patient_tag').select('tag_id').eq('patient_id', patientId)
|
||||
tenantDb().from('patient_group_patient').select('patient_group_id').eq('patient_id', patientId),
|
||||
tenantDb().from('patient_patient_tag').select('tag_id').eq('patient_id', patientId)
|
||||
]);
|
||||
if (ge) throw ge;
|
||||
if (te) throw te;
|
||||
@@ -126,7 +127,7 @@ export async function getPatientRelations(patientId) {
|
||||
|
||||
export async function listGroups({ tenantId, ownerId = null } = {}) {
|
||||
const tid = resolveTenantId(tenantId);
|
||||
let q = supabase.from('patient_groups').select(PATIENT_GROUPS_SELECT).eq('tenant_id', tid).eq('is_active', true);
|
||||
let q = tenantDb().from('patient_groups').select(PATIENT_GROUPS_SELECT).eq('is_active', true);
|
||||
if (ownerId) q = q.or(`is_system.eq.true,owner_id.eq.${ownerId}`);
|
||||
q = q.order('nome', { ascending: true });
|
||||
const { data, error } = await q;
|
||||
@@ -137,7 +138,7 @@ export async function listGroups({ tenantId, ownerId = null } = {}) {
|
||||
export async function listGroupsByPatient(patientIds, { tenantId } = {}) {
|
||||
if (!patientIds?.length) return [];
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const { data, error } = await supabase.from('patient_group_patient').select('patient_id, patient_group_id').eq('tenant_id', tid).in('patient_id', patientIds);
|
||||
const { data, error } = await tenantDb().from('patient_group_patient').select('patient_id, patient_group_id').in('patient_id', patientIds);
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
@@ -147,7 +148,7 @@ export async function listGroupsByPatient(patientIds, { tenantId } = {}) {
|
||||
*/
|
||||
export async function getGroupsByIds(ids) {
|
||||
if (!ids?.length) return [];
|
||||
const { data, error } = await supabase.from('patient_groups').select(PATIENT_GROUPS_SELECT_BRIEF).in('id', ids).order('nome', { ascending: true });
|
||||
const { data, error } = await tenantDb().from('patient_groups').select(PATIENT_GROUPS_SELECT_BRIEF).in('id', ids).order('nome', { ascending: true });
|
||||
if (error) throw error;
|
||||
return (data || []).map((g) => ({ id: g.id, name: g.nome }));
|
||||
}
|
||||
@@ -158,10 +159,10 @@ export async function getGroupsByIds(ids) {
|
||||
export async function replacePatientGroup(patientId, groupId, { tenantId } = {}) {
|
||||
if (!patientId) throw new Error('patientId obrigatório');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const { error: del } = await supabase.from('patient_group_patient').delete().eq('patient_id', patientId).eq('tenant_id', tid);
|
||||
const { error: del } = await tenantDb().from('patient_group_patient').delete().eq('patient_id', patientId);
|
||||
if (del) throw del;
|
||||
if (!groupId) return;
|
||||
const { error: ins } = await supabase.from('patient_group_patient').insert({ patient_id: patientId, patient_group_id: groupId, tenant_id: tid });
|
||||
const { error: ins } = await tenantDb().from('patient_group_patient').insert({ patient_id: patientId, patient_group_id: groupId });
|
||||
if (ins) throw ins;
|
||||
}
|
||||
|
||||
@@ -169,7 +170,7 @@ export async function replacePatientGroup(patientId, groupId, { tenantId } = {})
|
||||
|
||||
export async function listTags({ tenantId, ownerId = null } = {}) {
|
||||
const tid = resolveTenantId(tenantId);
|
||||
let q = supabase.from('patient_tags').select(PATIENT_TAGS_SELECT).eq('tenant_id', tid);
|
||||
let q = tenantDb().from('patient_tags').select(PATIENT_TAGS_SELECT);
|
||||
if (ownerId) q = q.eq('owner_id', ownerId);
|
||||
const { data, error } = await q;
|
||||
if (error) throw error;
|
||||
@@ -179,7 +180,7 @@ export async function listTags({ tenantId, ownerId = null } = {}) {
|
||||
export async function listTagsByPatient(patientIds, { tenantId } = {}) {
|
||||
if (!patientIds?.length) return [];
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const { data, error } = await supabase.from('patient_patient_tag').select('patient_id, tag_id').eq('tenant_id', tid).in('patient_id', patientIds);
|
||||
const { data, error } = await tenantDb().from('patient_patient_tag').select('patient_id, tag_id').in('patient_id', patientIds);
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
@@ -189,7 +190,7 @@ export async function listTagsByPatient(patientIds, { tenantId } = {}) {
|
||||
*/
|
||||
export async function getTagsByIds(ids) {
|
||||
if (!ids?.length) return [];
|
||||
const { data, error } = await supabase.from('patient_tags').select(PATIENT_TAGS_SELECT_BRIEF).in('id', ids).order('nome', { ascending: true });
|
||||
const { data, error } = await tenantDb().from('patient_tags').select(PATIENT_TAGS_SELECT_BRIEF).in('id', ids).order('nome', { ascending: true });
|
||||
if (error) throw error;
|
||||
return (data || []).map((t) => ({ id: t.id, name: t.nome, color: t.cor }));
|
||||
}
|
||||
@@ -202,12 +203,12 @@ export async function replacePatientTags(patientId, tagIds, { tenantId, ownerId
|
||||
if (!ownerId) throw new Error('ownerId obrigatório');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { error: del } = await supabase.from('patient_patient_tag').delete().eq('patient_id', patientId).eq('owner_id', ownerId).eq('tenant_id', tid);
|
||||
const { error: del } = await tenantDb().from('patient_patient_tag').delete().eq('patient_id', patientId).eq('owner_id', ownerId);
|
||||
if (del) throw del;
|
||||
|
||||
const clean = Array.from(new Set((tagIds || []).filter(Boolean)));
|
||||
if (!clean.length) return;
|
||||
const { error: ins } = await supabase.from('patient_patient_tag').insert(clean.map((tag_id) => ({ owner_id: ownerId, patient_id: patientId, tag_id, tenant_id: tid })));
|
||||
const { error: ins } = await tenantDb().from('patient_patient_tag').insert(clean.map((tag_id) => ({ owner_id: ownerId, patient_id: patientId, tag_id })));
|
||||
if (ins) throw ins;
|
||||
}
|
||||
|
||||
@@ -224,10 +225,9 @@ export async function replacePatientTags(patientId, tagIds, { tenantId, ownerId
|
||||
export async function listSessionsByPatient(patientId, { tenantId } = {}) {
|
||||
if (!patientId) return [];
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
const { data, error } = await tenantDb().from('agenda_eventos')
|
||||
.select(PATIENT_SESSIONS_SELECT)
|
||||
.eq('tenant_id', tid)
|
||||
|
||||
.eq('patient_id', patientId)
|
||||
.order('inicio_em', { ascending: false })
|
||||
.limit(100);
|
||||
@@ -251,7 +251,6 @@ export async function createPatientSession(patientId, payload) {
|
||||
const row = {
|
||||
patient_id: patientId,
|
||||
owner_id: uid,
|
||||
tenant_id: tid,
|
||||
inicio_em: payload.inicio_em,
|
||||
fim_em: payload.fim_em,
|
||||
status: payload.status || 'agendado',
|
||||
@@ -266,7 +265,7 @@ export async function createPatientSession(patientId, payload) {
|
||||
price: payload.price ?? null
|
||||
};
|
||||
|
||||
const { data, error } = await supabase.from('agenda_eventos').insert([row]).select().single();
|
||||
const { data, error } = await tenantDb().from('agenda_eventos').insert([row]).select().single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
@@ -277,7 +276,7 @@ export async function createPatientSession(patientId, payload) {
|
||||
export async function updatePatientSessionStatus(sessionId, status, { tenantId } = {}) {
|
||||
if (!sessionId) throw new Error('sessionId obrigatório');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const { error } = await supabase.from('agenda_eventos').update({ status }).eq('id', sessionId).eq('tenant_id', tid);
|
||||
const { error } = await tenantDb().from('agenda_eventos').update({ status }).eq('id', sessionId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
@@ -287,7 +286,7 @@ export async function updatePatientSessionStatus(sessionId, status, { tenantId }
|
||||
*/
|
||||
export async function findSessionByRecurrence(recurrenceId, recurrenceDate) {
|
||||
if (!recurrenceId || !recurrenceDate) return null;
|
||||
const { data, error } = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', recurrenceDate).maybeSingle();
|
||||
const { data, error } = await tenantDb().from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', recurrenceDate).maybeSingle();
|
||||
if (error) throw error;
|
||||
return data || null;
|
||||
}
|
||||
@@ -303,10 +302,9 @@ export async function findSessionByRecurrence(recurrenceId, recurrenceDate) {
|
||||
export async function listFinancialRecordsByPatient(patientId, { tenantId } = {}) {
|
||||
if (!patientId) return [];
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const { data, error } = await supabase
|
||||
.from('financial_records')
|
||||
const { data, error } = await tenantDb().from('financial_records')
|
||||
.select(PATIENT_FINANCIAL_RECORDS_SELECT)
|
||||
.eq('tenant_id', tid)
|
||||
|
||||
.eq('patient_id', patientId)
|
||||
.eq('type', 'receita')
|
||||
.order('created_at', { ascending: false })
|
||||
@@ -329,7 +327,6 @@ export async function createFinancialRecord(patientId, payload) {
|
||||
const row = {
|
||||
patient_id: patientId,
|
||||
owner_id: uid,
|
||||
tenant_id: tid,
|
||||
type: 'receita',
|
||||
amount: Number(payload.amount),
|
||||
due_date: payload.due_date || null,
|
||||
@@ -338,7 +335,7 @@ export async function createFinancialRecord(patientId, payload) {
|
||||
paid_at: null
|
||||
};
|
||||
|
||||
const { data, error } = await supabase.from('financial_records').insert([row]).select().single();
|
||||
const { data, error } = await tenantDb().from('financial_records').insert([row]).select().single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
@@ -349,7 +346,7 @@ export async function createFinancialRecord(patientId, payload) {
|
||||
export async function markFinancialRecordPaid(recordId, { tenantId } = {}) {
|
||||
if (!recordId) throw new Error('recordId obrigatório');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const { error } = await supabase.from('financial_records').update({ paid_at: new Date().toISOString() }).eq('id', recordId).eq('tenant_id', tid);
|
||||
const { error } = await tenantDb().from('financial_records').update({ paid_at: new Date().toISOString() }).eq('id', recordId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
@@ -359,7 +356,7 @@ export async function markFinancialRecordPaid(recordId, { tenantId } = {}) {
|
||||
export async function markFinancialRecordUnpaid(recordId, { tenantId } = {}) {
|
||||
if (!recordId) throw new Error('recordId obrigatório');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const { error } = await supabase.from('financial_records').update({ paid_at: null }).eq('id', recordId).eq('tenant_id', tid);
|
||||
const { error } = await tenantDb().from('financial_records').update({ paid_at: null }).eq('id', recordId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
@@ -370,10 +367,9 @@ export async function markFinancialRecordUnpaid(recordId, { tenantId } = {}) {
|
||||
export async function listDocumentsByPatient(patientId, { tenantId } = {}) {
|
||||
if (!patientId) return [];
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const { data, error } = await supabase
|
||||
.from('documents')
|
||||
const { data, error } = await tenantDb().from('documents')
|
||||
.select(PATIENT_DOCUMENTS_SELECT)
|
||||
.eq('tenant_id', tid)
|
||||
|
||||
.eq('patient_id', patientId)
|
||||
.is('deleted_at', null)
|
||||
.order('created_at', { ascending: false })
|
||||
@@ -390,10 +386,9 @@ export async function listDocumentsByPatient(patientId, { tenantId } = {}) {
|
||||
export async function listMessagesByPatient(patientId, { tenantId } = {}) {
|
||||
if (!patientId) return [];
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_messages')
|
||||
const { data, error } = await tenantDb().from('conversation_messages')
|
||||
.select(PATIENT_MESSAGES_SELECT)
|
||||
.eq('tenant_id', tid)
|
||||
|
||||
.eq('patient_id', patientId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(200);
|
||||
@@ -409,10 +404,9 @@ export async function listMessagesByPatient(patientId, { tenantId } = {}) {
|
||||
export async function listRecurrencesByPatient(patientId, { tenantId } = {}) {
|
||||
if (!patientId) return [];
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const { data, error } = await supabase
|
||||
.from('recurrence_rules')
|
||||
const { data, error } = await tenantDb().from('recurrence_rules')
|
||||
.select(PATIENT_RECURRENCE_RULES_SELECT)
|
||||
.eq('tenant_id', tid)
|
||||
|
||||
.eq('patient_id', patientId)
|
||||
.order('start_date', { ascending: false });
|
||||
if (error) throw error;
|
||||
@@ -425,7 +419,7 @@ export async function listRecurrencesByPatient(patientId, { tenantId } = {}) {
|
||||
export async function updateRecurrenceStatus(ruleId, status, { tenantId } = {}) {
|
||||
if (!ruleId) throw new Error('ruleId obrigatório');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const { error } = await supabase.from('recurrence_rules').update({ status, updated_at: new Date().toISOString() }).eq('id', ruleId).eq('tenant_id', tid);
|
||||
const { error } = await tenantDb().from('recurrence_rules').update({ status, updated_at: new Date().toISOString() }).eq('id', ruleId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
@@ -436,7 +430,7 @@ export async function updateRecurrenceStatus(ruleId, status, { tenantId } = {})
|
||||
export async function listSupportContactsByPatient(patientId, { tenantId } = {}) {
|
||||
if (!patientId) return [];
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const { data, error } = await supabase.from('patient_support_contacts').select(PATIENT_SUPPORT_CONTACTS_SELECT).eq('tenant_id', tid).eq('patient_id', patientId).order('is_primario', { ascending: false });
|
||||
const { data, error } = await tenantDb().from('patient_support_contacts').select(PATIENT_SUPPORT_CONTACTS_SELECT).eq('patient_id', patientId).order('is_primario', { ascending: false });
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
@@ -452,7 +446,7 @@ export async function replacePatientSupportContacts(patientId, contacts, { tenan
|
||||
if (!ownerId) throw new Error('ownerId obrigatório');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { error: del } = await supabase.from('patient_support_contacts').delete().eq('patient_id', patientId).eq('owner_id', ownerId).eq('tenant_id', tid);
|
||||
const { error: del } = await tenantDb().from('patient_support_contacts').delete().eq('patient_id', patientId).eq('owner_id', ownerId);
|
||||
if (del) throw del;
|
||||
|
||||
if (!contacts?.length) return;
|
||||
@@ -460,11 +454,10 @@ export async function replacePatientSupportContacts(patientId, contacts, { tenan
|
||||
const rows = contacts.map((c) => ({
|
||||
...c,
|
||||
patient_id: patientId,
|
||||
owner_id: ownerId,
|
||||
tenant_id: tid
|
||||
owner_id: ownerId
|
||||
}));
|
||||
|
||||
const { error: ins } = await supabase.from('patient_support_contacts').insert(rows);
|
||||
const { error: ins } = await tenantDb().from('patient_support_contacts').insert(rows);
|
||||
if (ins) throw ins;
|
||||
}
|
||||
|
||||
@@ -517,7 +510,8 @@ export async function markIntakeConverted(intakeId, patientId, { tenantId } = {}
|
||||
*/
|
||||
export async function getSessionCounts(patientIds) {
|
||||
if (!patientIds?.length) return [];
|
||||
const { data, error } = await supabase.rpc('get_patient_session_counts', { p_patient_ids: patientIds });
|
||||
const tid = resolveTenantId();
|
||||
const { data, error } = await supabase.rpc('get_patient_session_counts', { p_tenant_id: tid, p_patient_ids: patientIds });
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useRouter } from 'vue-router';
|
||||
|
||||
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { logError } from '@/support/supportLogger';
|
||||
|
||||
const router = useRouter();
|
||||
@@ -204,7 +205,7 @@ async function buscarEtiquetas() {
|
||||
const ownerId = await getOwnerId();
|
||||
|
||||
// 1) tenta view com contagem
|
||||
const v = await supabase.from('v_tag_patient_counts').select('*').eq('owner_id', ownerId).order('nome', { ascending: true });
|
||||
const v = await tenantDb().from('v_tag_patient_counts').select('*').eq('owner_id', ownerId).order('nome', { ascending: true });
|
||||
|
||||
if (!v.error) {
|
||||
etiquetas.value = (v.data || []).map(normalizarEtiquetaRow);
|
||||
@@ -212,10 +213,10 @@ async function buscarEtiquetas() {
|
||||
}
|
||||
|
||||
// 2) fallback tabela direta
|
||||
const t = await supabase.from('patient_tags').select('id, owner_id, nome, cor, is_padrao, name, color, is_native, created_at, updated_at').eq('owner_id', ownerId).order('nome', { ascending: true });
|
||||
const t = await tenantDb().from('patient_tags').select('id, owner_id, nome, cor, is_padrao, name, color, is_native, created_at, updated_at').eq('owner_id', ownerId).order('nome', { ascending: true });
|
||||
|
||||
if (t.error && /column .*nome/i.test(String(t.error.message || ''))) {
|
||||
const t2 = await supabase.from('patient_tags').select('id, owner_id, name, color, is_native, created_at, updated_at').eq('owner_id', ownerId).order('name', { ascending: true });
|
||||
const t2 = await tenantDb().from('patient_tags').select('id, owner_id, name, color, is_native, created_at, updated_at').eq('owner_id', ownerId).order('name', { ascending: true });
|
||||
if (t2.error) throw t2.error;
|
||||
etiquetas.value = (t2.data || []).map((r) => normalizarEtiquetaRow({ ...r, patient_count: 0 }));
|
||||
return;
|
||||
@@ -262,14 +263,14 @@ async function salvarDlg() {
|
||||
|
||||
if (dlg.mode === 'create') {
|
||||
const tenantId = await getActiveTenantId(ownerId);
|
||||
const res = await supabase.from('patient_tags').insert({ owner_id: ownerId, tenant_id: tenantId, nome, cor });
|
||||
const res = await tenantDb().from('patient_tags').insert({ owner_id: ownerId, nome, cor });
|
||||
if (res.error) throw res.error;
|
||||
toast.add({ severity: 'success', summary: 'Tag criada', detail: nome, life: 2500 });
|
||||
} else {
|
||||
let res = await supabase.from('patient_tags').update({ nome, cor, updated_at: new Date().toISOString() }).eq('id', dlg.id).eq('owner_id', ownerId);
|
||||
let res = await tenantDb().from('patient_tags').update({ nome, cor, updated_at: new Date().toISOString() }).eq('id', dlg.id).eq('owner_id', ownerId);
|
||||
// fallback legado
|
||||
if (res.error && /column .*nome/i.test(String(res.error.message || ''))) {
|
||||
res = await supabase.from('patient_tags').update({ name: nome, color: cor, updated_at: new Date().toISOString() }).eq('id', dlg.id).eq('owner_id', ownerId);
|
||||
res = await tenantDb().from('patient_tags').update({ name: nome, color: cor, updated_at: new Date().toISOString() }).eq('id', dlg.id).eq('owner_id', ownerId);
|
||||
}
|
||||
if (res.error) throw res.error;
|
||||
toast.add({ severity: 'success', summary: 'Tag atualizada', detail: nome, life: 2500 });
|
||||
@@ -326,9 +327,9 @@ async function excluirTags(rows) {
|
||||
toast.add({ severity: 'warn', summary: 'Nada para excluir', detail: 'Tags padrão não podem ser removidas.', life: 4000 });
|
||||
return;
|
||||
}
|
||||
const pivotDel = await supabase.from('patient_patient_tag').delete().eq('owner_id', ownerId).in('tag_id', ids);
|
||||
const pivotDel = await tenantDb().from('patient_patient_tag').delete().eq('owner_id', ownerId).in('tag_id', ids);
|
||||
if (pivotDel.error) throw pivotDel.error;
|
||||
const tagDel = await supabase.from('patient_tags').delete().eq('owner_id', ownerId).in('id', ids);
|
||||
const tagDel = await tenantDb().from('patient_tags').delete().eq('owner_id', ownerId).in('id', ids);
|
||||
if (tagDel.error) throw tagDel.error;
|
||||
etiquetasSelecionadas.value = [];
|
||||
toast.add({ severity: 'success', summary: 'Excluído', detail: `${ids.length} tag(s) removida(s).`, life: 3000 });
|
||||
@@ -359,7 +360,7 @@ async function carregarPacientesDaTag(tag) {
|
||||
modalPacientes.error = '';
|
||||
try {
|
||||
const ownerId = await getOwnerId();
|
||||
const { data, error } = await supabase.from('patient_patient_tag').select('patient_id, patients:patients(id, nome_completo, email_principal, telefone, avatar_url)').eq('owner_id', ownerId).eq('tag_id', tag.id);
|
||||
const { data, error } = await tenantDb().from('patient_patient_tag').select('patient_id, patients:patients(id, nome_completo, email_principal, telefone, avatar_url)').eq('owner_id', ownerId).eq('tag_id', tag.id);
|
||||
if (error) throw error;
|
||||
modalPacientes.items = (data || [])
|
||||
.map((r) => r.patients)
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useInsurancePlans } from '@/features/agenda/composables/useInsurancePla
|
||||
import { useServices } from '@/features/agenda/composables/useServices';
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { applyThemeEngine } from '@/theme/theme.options';
|
||||
import InputMask from 'primevue/inputmask';
|
||||
@@ -412,7 +413,7 @@ onMounted(async () => {
|
||||
if (!uid.value) return;
|
||||
userEmail.value = user.email || '';
|
||||
|
||||
const { data: cfg } = await supabase.from('agenda_configuracoes').select('setup_concluido, setup_clinica_concluido, atendimento_mode').eq('owner_id', uid.value).maybeSingle();
|
||||
const { data: cfg } = await tenantDb().from('agenda_configuracoes').select('setup_concluido, setup_clinica_concluido, atendimento_mode').eq('owner_id', uid.value).maybeSingle();
|
||||
if (cfg && (cfg.setup_concluido || cfg.setup_clinica_concluido || !!cfg.atendimento_mode)) {
|
||||
router.replace('/pages/notfound');
|
||||
return;
|
||||
@@ -439,7 +440,7 @@ async function loadNegocio() {
|
||||
if (!tenantId.value) return;
|
||||
|
||||
// Fonte única: company_profiles
|
||||
const { data } = await supabase.from('company_profiles').select('nome_fantasia,tipo_empresa,logo_url,cep,logradouro,numero,complemento,bairro,cidade,estado,telefone,email,site,redes_sociais').eq('tenant_id', tenantId.value).maybeSingle();
|
||||
const { data } = await tenantDb().from('company_profiles').select('nome_fantasia,tipo_empresa,logo_url,cep,logradouro,numero,complemento,bairro,cidade,estado,telefone,email,site,redes_sociais').maybeSingle();
|
||||
|
||||
if (!data) return;
|
||||
|
||||
@@ -477,7 +478,7 @@ async function loadNegocio() {
|
||||
}
|
||||
async function loadAtendimento() {
|
||||
if (!uid.value) return;
|
||||
const { data } = await supabase.from('agenda_configuracoes').select('atendimento_mode').eq('owner_id', uid.value).maybeSingle();
|
||||
const { data } = await tenantDb().from('agenda_configuracoes').select('atendimento_mode').eq('owner_id', uid.value).maybeSingle();
|
||||
if (data?.atendimento_mode) {
|
||||
atendimento.value.mode = data.atendimento_mode;
|
||||
markSaved('atendimento');
|
||||
@@ -570,9 +571,8 @@ async function saveNegocio(silent = false) {
|
||||
}
|
||||
|
||||
// Fonte única: company_profiles (upsert garante insert ou update)
|
||||
const { error } = await supabase.from('company_profiles').upsert(
|
||||
const { error } = await tenantDb().from('company_profiles').upsert(
|
||||
{
|
||||
tenant_id: tenantId.value,
|
||||
nome_fantasia: negocio.value.name.trim() || null,
|
||||
tipo_empresa: negocio.value.type || null,
|
||||
logo_url: logoUrl || null,
|
||||
@@ -588,7 +588,7 @@ async function saveNegocio(silent = false) {
|
||||
site: negocio.value.siteUrl?.trim() || null,
|
||||
redes_sociais: redes
|
||||
},
|
||||
{ onConflict: 'tenant_id' }
|
||||
{ onConflict: 'singleton' }
|
||||
);
|
||||
|
||||
if (error) throw error;
|
||||
@@ -614,10 +614,9 @@ async function saveAtendimento(silent = false) {
|
||||
await saveService({ name: 'Atendimento padrão', price: 0, duration_min: 50, owner_id: uid.value, tenant_id: tenantId.value });
|
||||
await loadServices(uid.value);
|
||||
}
|
||||
const { error } = await supabase.from('agenda_configuracoes').upsert(
|
||||
const { error } = await tenantDb().from('agenda_configuracoes').upsert(
|
||||
{
|
||||
owner_id: uid.value,
|
||||
tenant_id: tenantId.value,
|
||||
atendimento_mode: atendimento.value.mode,
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
@@ -691,7 +690,7 @@ async function onFinish() {
|
||||
finishing.value = true;
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const { error: finErr } = await supabase.from('agenda_configuracoes').upsert({ owner_id: uid.value, tenant_id: tenantId.value, setup_concluido: true, setup_concluido_em: now }, { onConflict: 'owner_id' });
|
||||
const { error: finErr } = await tenantDb().from('agenda_configuracoes').upsert({ owner_id: uid.value, setup_concluido: true, setup_concluido_em: now }, { onConflict: 'owner_id' });
|
||||
if (finErr) throw finErr;
|
||||
done.value = true;
|
||||
} catch (e) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useInsurancePlans } from '@/features/agenda/composables/useInsurancePla
|
||||
import { useServices } from '@/features/agenda/composables/useServices';
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { tenantDb } from '@/lib/supabase/tenantClient';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { applyThemeEngine } from '@/theme/theme.options';
|
||||
import InputMask from 'primevue/inputmask';
|
||||
@@ -416,7 +417,7 @@ onMounted(async () => {
|
||||
if (!uid.value) return;
|
||||
userEmail.value = user.email || '';
|
||||
|
||||
const { data: cfg } = await supabase.from('agenda_configuracoes').select('setup_concluido, setup_clinica_concluido, atendimento_mode').eq('owner_id', uid.value).maybeSingle();
|
||||
const { data: cfg } = await tenantDb().from('agenda_configuracoes').select('setup_concluido, setup_clinica_concluido, atendimento_mode').eq('owner_id', uid.value).maybeSingle();
|
||||
if (cfg && (cfg.setup_concluido || cfg.setup_clinica_concluido || !!cfg.atendimento_mode)) {
|
||||
router.replace('/pages/notfound');
|
||||
return;
|
||||
@@ -443,7 +444,7 @@ async function loadNegocio() {
|
||||
if (!tenantId.value) return;
|
||||
|
||||
// Fonte única: company_profiles
|
||||
const { data } = await supabase.from('company_profiles').select('nome_fantasia,tipo_empresa,logo_url,cep,logradouro,numero,complemento,bairro,cidade,estado,telefone,email,site,redes_sociais').eq('tenant_id', tenantId.value).maybeSingle();
|
||||
const { data } = await tenantDb().from('company_profiles').select('nome_fantasia,tipo_empresa,logo_url,cep,logradouro,numero,complemento,bairro,cidade,estado,telefone,email,site,redes_sociais').maybeSingle();
|
||||
|
||||
if (!data) return;
|
||||
|
||||
@@ -481,7 +482,7 @@ async function loadNegocio() {
|
||||
}
|
||||
async function loadAtendimento() {
|
||||
if (!uid.value) return;
|
||||
const { data } = await supabase.from('agenda_configuracoes').select('atendimento_mode').eq('owner_id', uid.value).maybeSingle();
|
||||
const { data } = await tenantDb().from('agenda_configuracoes').select('atendimento_mode').eq('owner_id', uid.value).maybeSingle();
|
||||
if (data?.atendimento_mode) {
|
||||
atendimento.value.mode = data.atendimento_mode;
|
||||
markSaved('atendimento');
|
||||
@@ -574,7 +575,6 @@ async function saveNegocio(silent = false) {
|
||||
}
|
||||
|
||||
const payload = {
|
||||
tenant_id: tenantId.value,
|
||||
nome_fantasia: negocio.value.name.trim() || null,
|
||||
tipo_empresa: negocio.value.type || null,
|
||||
logo_url: logoUrl || null,
|
||||
@@ -592,15 +592,15 @@ async function saveNegocio(silent = false) {
|
||||
};
|
||||
|
||||
// Verificar se já existe registro para este tenant
|
||||
const { data: existing } = await supabase.from('company_profiles').select('id').eq('tenant_id', tenantId.value).maybeSingle();
|
||||
const { data: existing } = await tenantDb().from('company_profiles').select('id').maybeSingle();
|
||||
|
||||
let error;
|
||||
if (existing?.id) {
|
||||
// Já existe — usar UPDATE direto pelo id
|
||||
({ error } = await supabase.from('company_profiles').update(payload).eq('id', existing.id));
|
||||
({ error } = await tenantDb().from('company_profiles').update(payload).eq('id', existing.id));
|
||||
} else {
|
||||
// Não existe — INSERT
|
||||
({ error } = await supabase.from('company_profiles').insert(payload));
|
||||
({ error } = await tenantDb().from('company_profiles').insert(payload));
|
||||
}
|
||||
|
||||
if (error) throw error;
|
||||
@@ -626,10 +626,9 @@ async function saveAtendimento(silent = false) {
|
||||
await saveService({ name: 'Atendimento padrão', price: 0, duration_min: 50, owner_id: uid.value, tenant_id: tenantId.value });
|
||||
await loadServices(uid.value);
|
||||
}
|
||||
const { error } = await supabase.from('agenda_configuracoes').upsert(
|
||||
const { error } = await tenantDb().from('agenda_configuracoes').upsert(
|
||||
{
|
||||
owner_id: uid.value,
|
||||
tenant_id: tenantId.value,
|
||||
atendimento_mode: atendimento.value.mode,
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
@@ -703,7 +702,7 @@ async function onFinish() {
|
||||
finishing.value = true;
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const { error: finErr } = await supabase.from('agenda_configuracoes').upsert({ owner_id: uid.value, tenant_id: tenantId.value, setup_concluido: true, setup_concluido_em: now }, { onConflict: 'owner_id' });
|
||||
const { error: finErr } = await tenantDb().from('agenda_configuracoes').upsert({ owner_id: uid.value, setup_concluido: true, setup_concluido_em: now }, { onConflict: 'owner_id' });
|
||||
if (finErr) throw finErr;
|
||||
// Fecha o dialog ANTES de mostrar a tela de parabéns
|
||||
dialogVisible.value = false;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user