Sessoes 6cont-10: hardening em 6 areas + scan completo do SaaS
Continuacao de 7c20b51. Esta etapa fechou TODA revisao senior do SaaS
(15 areas auditadas) + refator parcial de pacientes.
Ver commit.md para descricao completa por sessao.
# Estado final do projeto
- A# auditoria abertos: 1 (A#31 Deploy real)
- V# verificacoes abertos: 14 (todos medios/baixos adiados com plano)
- Criticos: 0
- Altos: 0
- Vitest: 208/208 (era 192, +16 nos novos composables)
- SQL integration: 33/33
- E2E (Playwright): 5/5
- Areas auditadas: 15
# Highlights
- Documentos 100% fechado (V#50/51/52: portal-paciente policy + content_sha256 + 4 cron jobs retention)
- Tenants V#1 P0: tenant_invites com RLS off + 0 policies (mesmo padrao A#30)
- Calendario 100% fechado: feriados WITH CHECK
- Addons V#1 P0 (dinheiro): addon_transactions WITH CHECK saas_admin
- Central SaaS V#1: faq write so saas_admin (era tenant_admin)
- Servicos/Prontuarios 100% fechado: services/medicos/insurance_plans + cascades
- Pacientes V#9: 2 composables novos (useCep, usePatientSupportContacts) + repo estendido + script extraido (template intocado, fica para quando houver E2E)
# 8 migrations novas neste commit
- 20260419000011_documents_portal_patient_policy.sql
- 20260419000012_documents_content_hash.sql
- 20260419000013_cron_retention_jobs.sql
- 20260419000014_financial_security_hardening.sql
- 20260419000015_communication_security_hardening.sql
- 20260419000016_tenants_calendario_hardening.sql
- 20260419000017_addons_central_saas_hardening.sql
- 20260419000018_servicos_prontuarios_hardening.sql
Total acumulado: 18 migrations (Sessoes 1-10).
# A#31 reformulado pra proxima sessao
"Deploy real" muda escopo: como nao ha cloud Supabase nem secrets reais
ainda (MVP), proxima sessao vira "Preparacao completa pra deploy" (DEPLOY.md,
validar migrations num container limpo, audit edge functions, listar env vars,
script db.cjs deploy-check).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,237 +1,176 @@
|
||||
# Sessões 1-6 acumuladas — hardening, defesa em profundidade, +192 testes
|
||||
# Sessões 6 (continuação) → 10 — hardening em 6 áreas + scan completo do SaaS
|
||||
|
||||
Repositório estava sem commit há ~5 sessões de trabalho intenso. Este commit
|
||||
consolida tudo que foi feito desde o último marco (`d088a89`).
|
||||
Continuação do commit `7c20b51` (Sessões 1-6 iniciais). Esta etapa fechou
|
||||
**toda revisão sênior do SaaS** + refator parcial de pacientes.
|
||||
|
||||
**A# auditoria:** 30 itens registrados, **0 abertos**.
|
||||
**V# verificações:** 52 registradas (10 novas em Documentos), **5 abertas** (todas adiadas com plano).
|
||||
**T# testes:** 10/10 escritas. **192 vitest + 33 SQL + 5 E2E** passando.
|
||||
**Estado final do projeto:**
|
||||
- A# auditoria abertos: **1** (A#31 Deploy real)
|
||||
- V# verificações abertos: 14 (todos médios/baixos adiados com plano completo no DB)
|
||||
- 🔴 Críticos: **0** ✅
|
||||
- 🟠 Altos: **0** ✅
|
||||
- Vitest: **208/208** (era 192)
|
||||
- SQL integration: **33/33**
|
||||
- E2E (Playwright): **5/5**
|
||||
- Áreas auditadas: **15** (todas as principais do SaaS)
|
||||
|
||||
---
|
||||
|
||||
## Sessão 1 — auth/router/session
|
||||
## Sessão 6 (continuação) — Documentos pendentes + Pacientes V#3
|
||||
|
||||
- A#7 resolvido (`window.__guardsBound`)
|
||||
- 10 verificações registradas (V#1–V#10), 5 corrigidas:
|
||||
- `session.js` logger inconsistente
|
||||
- `tenantStore.ensureLoaded` polling
|
||||
- `normalizeRole` duplicado → extraído pra `src/utils/roleNormalizer.js`
|
||||
- `console.error` em `router/index.js`
|
||||
- `.single()` em `fetchRole`
|
||||
### Documentos: 100% fechado (V#50, V#51, V#52)
|
||||
- **V#50** — Policy `documents: portal patient read` adicional. Paciente lê documento via portal quando `compartilhado_portal=true` AND patient pertence a auth.uid AND não expirou.
|
||||
- **V#51** — `documents.content_sha256` (nullable, índice parcial). `Documents.service.uploadDocument` calcula SHA-256 hex client-side via `crypto.subtle.digest`. Helper novo `verifyDocumentIntegrity(docId)` baixa arquivo e re-hash.
|
||||
- **V#52** — Migration `...13` cron retention via pg_cron: 4 jobs (document_access_logs 1 ano, math_challenges 1h, public_submission_attempts 90 dias, submission_rate_limits 30 dias).
|
||||
|
||||
### Pacientes V#3 (parcial — fundação)
|
||||
- `src/features/patients/services/patientsRepository.js` — list/get/create/update/softDelete + groups + tags + getSessionCounts.
|
||||
- `src/features/patients/composables/usePatients.js` — wrapper reativo (rows/loading/error).
|
||||
- PatientsListPage.hydrateAssociationsSupabase migrada — substitui 4 queries diretas por chamadas ao repo (paralelismo preservado).
|
||||
- V#9 (PatientsCadastroPage 1991 linhas) → adiado pra Sessão 10.
|
||||
|
||||
---
|
||||
|
||||
## Sessão 2 — agenda
|
||||
## Sessão 7 — Tenants + Calendário
|
||||
|
||||
- 11 verificações (V#11–V#21), 10 corrigidas:
|
||||
- `useRecurrence` CRUD ganhou filtro `tenant_id` (alto)
|
||||
- `agenda.service.js` vazio deletado
|
||||
- `agendaRepository` ↔ `useAgendaEvents` consolidados (composable virou wrapper fino, 181→67 linhas)
|
||||
- `AGENDA_EVENT_SELECT` centralizado em `agendaSelects.js`
|
||||
- `_tenantGuards.js` compartilhado
|
||||
- V#21 status `remarcar` → `remarcado` padronizado em 14 edições
|
||||
### Tenants (8 V#)
|
||||
- 🔴 **V#1 P0** — `tenant_invites` com RLS off + 0 policies (mesmo padrão A#30 Sessão 5). Tabela tinha 0 rows. Migration: ENABLE RLS + 4 policies (SELECT tenant_admin/saas; INSERT WITH CHECK invited_by=auth.uid; UPDATE só revogação; DELETE tenant_admin/saas). Aceitar invite continua via RPC `tenant_accept_invite` SECURITY DEFINER.
|
||||
- 🟠 **V#2** profiles INSERT WITH CHECK (id = auth.uid)
|
||||
- 🟠 **V#3** support_sessions INSERT WITH CHECK (admin_id = auth.uid + saas_admin guard)
|
||||
- 🟡 **V#4 (signup público)** verificado: RPC `ensure_personal_tenant` SECURITY DEFINER já existia (Signup.vue:232) → **ok**
|
||||
- 🟡 **V#5 (accept_invite)** verificado: RPCs `tenant_accept_invite` + `tenant_invite_member_by_email` já existiam → **ok**
|
||||
- 🟡 **V#6** user_settings INSERT WITH CHECK
|
||||
- 🟢 V#7/V#8 baixos — adiados
|
||||
|
||||
### Calendário (2 V#) — 100% fechado
|
||||
- 🔴 **V#1** feriados_insert + feriados_saas_insert ganharam WITH CHECK. Spam de feriado global bloqueado.
|
||||
- 🟢 **V#2** feriados_delete agora permite tenant_admin (não só owner).
|
||||
|
||||
---
|
||||
|
||||
## Sessão 3 — pacientes
|
||||
## Sessão 8 — Addons + Central SaaS
|
||||
|
||||
- 10 verificações (V#22–V#31), 6 corrigidas + 4 documentadas
|
||||
- 5 arquivos obsoletos deletados (PatientsCadastroPage Bkp, preview, prontuário design 1/2/3)
|
||||
- `tenant_id` em todas queries de patients (alto)
|
||||
- 9 `console.*` migrados pra logger
|
||||
- `hydrateAssociations` paralelizada (5 round-trips → 2)
|
||||
- `.maybeSingle()` onde precisava
|
||||
### Addons (4 V#)
|
||||
- 🔴 **V#1 P0 (dinheiro)** — `addon_transactions_admin_insert` ganhou WITH CHECK (EXISTS saas_admins). Edge functions com service_role bypassam RLS, pipeline preservado. **Authenticated comum não cria mais transação fake.**
|
||||
- 🟠 **V#2** — 3 CHECK constraints em `addon_credits`: balance >= 0, total_consumed >= 0, total_purchased >= 0. Saldo negativo silencioso eliminado.
|
||||
- 🟡 V#3 (UI extrato) — adiado.
|
||||
- 🟡 V#4 — verificado: `addon_products` não tem `tenant_id` (catálogo global por design) → **ok**.
|
||||
|
||||
### Central SaaS (3 V#)
|
||||
- 🟠 **V#1** — `faq_admin_write` substituído por `faq_saas_admin_write` em `saas_faq` E `saas_faq_itens` — só saas_admin escreve. Tenant_admin lê via `faq_auth_read` (permanece).
|
||||
- 🟢 V#2/V#3 médios/baixos — adiados.
|
||||
|
||||
---
|
||||
|
||||
## Sessão 4 — security review (página pública de cadastro)
|
||||
## Sessão 9 — Serviços/Prontuários (100% fechado)
|
||||
|
||||
- V#31 virou security review completa: **15 vulnerabilidades (A#15–A#29), 14 corrigidas**
|
||||
- **Críticos:**
|
||||
- **A#15** bucket `avatars` público → 5MB + mime whitelist + policies restritas
|
||||
- **A#16** RPC v2 ignorava `active/expires/max_uses` → validação completa + incrementa `uses`
|
||||
- **A#17** `notas_internas` exposto ao paciente → removido do form e RPC
|
||||
- **A#18** `Math.random()` pra token → RPCs server-side via `gen_random_uuid()`
|
||||
- **A#19** intake sem `tenant_id` → RPC resolve via `patient_invites` ou `tenant_members`
|
||||
- **Médios:** log `patient_invite_attempts` (A#24), política LGPD (A#25), botão mock só em DEV (A#26), length caps server-side (A#27)
|
||||
- **Baixos:** duplicado `PatientsExternalLinkPage` deletado, `Landingpage-v1 - bkp.vue` deletado
|
||||
5/5 V# corrigidos:
|
||||
- 🔴 **V#1** services + insurance_plans → 4 policies separadas (SELECT tenant_member; INSERT/UPDATE/DELETE owner+saas).
|
||||
- 🔴 **V#2** medicos → 4 policies separadas (catálogo de médicos referenciadores compartilhado entre profissionais do tenant).
|
||||
- 🟠 **V#3** commitment_services — cascade reescrito via JOIN com services (USING permite tenant_member; WITH CHECK só owner).
|
||||
- 🟠 **V#4** insurance_plan_services — cascade reescrito via JOIN com insurance_plans.
|
||||
- 🟡 **V#5** commitment_time_logs/determined_commitments/determined_commitment_fields ganharam WITH CHECK em INSERT.
|
||||
|
||||
---
|
||||
|
||||
## Sessão 5 — SaaS (planos, preços, recursos)
|
||||
## Sessão 10 — Pacientes V#9 (script extraído)
|
||||
|
||||
- 10 verificações (V#33–V#42)
|
||||
- **🔴 P0:** A#30 — 7 tabelas SaaS com RLS OFF + `GRANT ALL` pra anon. Migration `...05_saas_rls_emergency_fix` aplicou REVOKE + ENABLE RLS + 9 policies corretas
|
||||
- 109/109 testes passando
|
||||
PatientsCadastroPage.vue: 1991 → 1951 linhas (qualitativo > quantitativo).
|
||||
|
||||
### 2 composables novos
|
||||
- **`useCep.js`** — busca ViaCEP reutilizável. 6 testes (sem rede, mock fetch).
|
||||
- **`usePatientSupportContacts.js`** — CRUD de contatos de suporte encapsulado (load/save/add/remove/iniciaisFor). 10 testes com builder thenable.
|
||||
|
||||
### patientsRepository estendido
|
||||
- `getPatientRelations(patientId)` — retorna {groupIds, tagIds}
|
||||
- `replacePatientGroup(patientId, groupId, {tenantId})`
|
||||
- `replacePatientTags(patientId, tagIds, {tenantId, ownerId})`
|
||||
|
||||
### PatientsCadastroPage refatorado
|
||||
- 8 funções de query → delegação 1-linha ao patientsRepository
|
||||
- onCepBlur → usa composable useCep
|
||||
- Contatos de suporte → composable
|
||||
- Template **não** foi tocado (zero risco de regressão visual)
|
||||
- Quebra de template em sub-componentes Vue → adiado pra quando houver E2E cobrindo a página
|
||||
|
||||
---
|
||||
|
||||
## Sessão 6 (HOJE, 2026-04-19) — bloco principal
|
||||
|
||||
### V#34 + V#41 — Opção B2 (plano + override + exceção comercial)
|
||||
|
||||
Resolve `tenantFeaturesStore.isEnabled` que retornava `true` por default
|
||||
(qualquer feature aparecia ativa pra qualquer tenant) E a dupla-fonte com
|
||||
`entitlementsStore`.
|
||||
|
||||
**Backend** (migration `...01`):
|
||||
- Trigger `tenant_features_guard_with_plan` ganhou bypass via session flag
|
||||
- RPC `set_tenant_feature_exception` SECURITY DEFINER com regras assimétricas:
|
||||
- `enabled=false` → tenant_admin OU saas_admin (preferência)
|
||||
- `enabled=true` AND plano permite → tenant_admin OU saas_admin
|
||||
- `enabled=true` AND plano NÃO permite → **só saas_admin + reason obrigatório**
|
||||
- Policy `tenant_features_write_saas_only`
|
||||
|
||||
**Frontend:**
|
||||
- `tenantFeaturesStore.isEnabled` reescrito (B2): override negativo desliga, override positivo liga (exceção), sem override segue plano
|
||||
- `setForTenant` chama RPC com `reason`
|
||||
- Tela nova `/saas/tenant-features` com dialog de motivo obrigatório
|
||||
- JSDoc separação semântica: `entitlementsStore.has` = "plano permite?" vs `tenantFeaturesStore.isEnabled` = "ativo agora?"
|
||||
- 17 testes em `tenantFeaturesStore.spec.js`
|
||||
|
||||
### Pendentes Sessão 5 fechados
|
||||
|
||||
- **V#35** — 17→11 policies (consolidadas plans/features/plan_features/subscriptions) + `COMMENT ON POLICY`
|
||||
- **V#36** — RPC `delete_plan_safe` bloqueia DELETE com subscriptions ativas
|
||||
- **V#40** — `features.is_active` (soft delete) + UI com filtro/Reativar
|
||||
- **V#42** — `entitlementsStore.loadFor*` no catch não marca como carregado + `logError`
|
||||
|
||||
### Testes T#5/T#7/T#8
|
||||
|
||||
- **T#5** `tenantStore.spec.js` — 15 testes (singleflight, regressão V#5, erros, setActiveTenant, reset, getters)
|
||||
- **T#7** `validators.spec.js` — 38 testes (sanitização do intake)
|
||||
- **T#8** `database-novo/tests/run.cjs` — runner Node + docker exec, 33 cenários SQL
|
||||
|
||||
### A#20 (CAPTCHA) — rev2 self-hosted
|
||||
|
||||
**Decisão:** descartado Cloudflare Turnstile / hCaptcha em favor de defesa em
|
||||
camadas self-hosted. Razões: zero LGPD, zero provider, zero fricção pro paciente
|
||||
legítimo (UX importa em paciente vulnerável buscando atendimento).
|
||||
|
||||
**5 camadas:**
|
||||
1. **Honeypot** — campo invisível
|
||||
2. **Validação** básica
|
||||
3. **Rate limit por IP** — `check_rate_limit` RPC
|
||||
4. **Math captcha condicional** — só ativa após N falhas (default 3)
|
||||
5. **Modo paranoid** global toggle
|
||||
|
||||
**Implementação:**
|
||||
- Migrations `...06` (4 tabelas) + `...07` (RPCs)
|
||||
- Edge function `submit-patient-intake` reescrita (dual endpoint)
|
||||
- Componente `MathCaptchaChallenge.vue` lazy
|
||||
- Tela `/saas/security` com card explicativo (6 seções), KPIs 24h, toggles, sliders, dashboard de IPs
|
||||
|
||||
### SaaS Twilio Config (UI editável)
|
||||
|
||||
- Migration `...08` (singleton + RPCs `get_twilio_config`/`update_twilio_config`)
|
||||
- **AUTH_TOKEN permanece em env var** (único secret); SID/webhook/rate/margem migram pra DB
|
||||
- Edge function lê do banco com fallback pra env (back-compat)
|
||||
- Tela `/saas/twilio-config` com card + status do AUTH_TOKEN
|
||||
- Bug fix: `friendlyErrorMessage()` traduz "Edge Function returned a non-2xx status code"
|
||||
|
||||
### Revisão sênior em Documentos/prontuários
|
||||
|
||||
10 V# novas registradas, 7 corrigidas, 3 adiadas.
|
||||
|
||||
**Críticos:**
|
||||
- 🔴 **V#43/V#44** vazamento entre clínicas via storage policies — corrigido com tenant scoping no path `(storage.foldername(name))[1]::uuid IN tenant_members`
|
||||
- 🔴 **V#45** documents policy pobre (só `owner_id = auth.uid()`) — separada em SELECT/INSERT/UPDATE/DELETE com tenant scoping
|
||||
|
||||
**Altos:**
|
||||
- 🟠 **V#46** share_links sem incremento de usos — RPC `validate_share_token` atomicamente valida + incrementa + loga
|
||||
- 🟠 **V#47** signatures policy ALL — separada (UPDATE só pra signatário)
|
||||
|
||||
**Médios:**
|
||||
- 🟡 **V#48** access_logs WITH CHECK
|
||||
- 🟡 **V#49** templates WITH CHECK
|
||||
|
||||
### B-block (V# avulsos)
|
||||
|
||||
- **V#2** Listener `onAuthStateChange` consolidado (session.js virou autoridade + API `onSessionEvent`)
|
||||
- **V#6** `globalRoleCache` TTL 5min
|
||||
- **V#10** Bloqueio SaaS via `meta.area`/`meta.saasAdmin` em vez de `path.startsWith`
|
||||
- **V#8** RPC `get_patient_session_counts` substitui `.limit(1000)` arbitrário
|
||||
- **V#9 router** short-circuit `lastEnsureKey` em ensureMenuBuilt
|
||||
- **V#17** 25 `console.*` eliminados em src/views/pages/saas/
|
||||
- **V#18** TTL real em tenantFeaturesStore
|
||||
|
||||
### T#9 + T#10
|
||||
|
||||
- **T#9** `useAgendaEvents.spec.js` — 13 testes do wrapper
|
||||
- **T#10** Playwright + Chromium instalados; 5 specs E2E em `e2e/patient-intake.spec.js`
|
||||
- **Bug fix achado pelo E2E**: `CadastroPacienteExterno.enviar` não extraía body do erro 403 — corrigido
|
||||
|
||||
---
|
||||
|
||||
## 📦 Migrations consolidadas (todas as sessões)
|
||||
## 📦 Migrations consolidadas neste commit (8)
|
||||
|
||||
```
|
||||
20260417000001_dev_tables (Sessão pré-1: tabelas dev)
|
||||
20260417000002_dev_tables_ordem
|
||||
20260418000001_dev_verificacoes (Sessão 1)
|
||||
20260418000002_patient_intake_security_hardening (Sessão 4)
|
||||
20260418000003_patient_invite_attempts_log (Sessão 4)
|
||||
20260418000004_dev_tests (Sessão 1)
|
||||
20260418000005_saas_rls_emergency_fix (Sessão 5 — P0)
|
||||
20260419000001_tenant_features_b2_governance (Sessão 6 — V#34/V#41)
|
||||
20260419000002_features_is_active (Sessão 6 — V#40)
|
||||
20260419000003_delete_plan_safe (Sessão 6 — V#36)
|
||||
20260419000004_consolidate_policies (Sessão 6 — V#35)
|
||||
20260419000005_restrict_intake_rpc (Sessão 6 — A#20)
|
||||
20260419000006_layered_bot_defense (Sessão 6 — A#20 rev2)
|
||||
20260419000007_bot_defense_rpcs (Sessão 6 — A#20 rev2)
|
||||
20260419000008_saas_twilio_config (Sessão 6)
|
||||
20260419000009_patient_session_counts_rpc (Sessão 6 — V#8)
|
||||
20260419000010_documents_security_hardening (Sessão 6 — V#43-V#49)
|
||||
20260419000011_documents_portal_patient_policy.sql (V#50)
|
||||
20260419000012_documents_content_hash.sql (V#51)
|
||||
20260419000013_cron_retention_jobs.sql (V#52 + math_challenges + submissions + rate_limits)
|
||||
20260419000014_financial_security_hardening.sql (5 V# financeiro — fechados na Sessão 6)
|
||||
20260419000015_communication_security_hardening.sql (5 V# comunicação — fechados na Sessão 6)
|
||||
20260419000016_tenants_calendario_hardening.sql (Tenants V#1-V#3,V#6 + Calendário V#1-V#2)
|
||||
20260419000017_addons_central_saas_hardening.sql (Addons V#1-V#2 + Central SaaS V#1)
|
||||
20260419000018_servicos_prontuarios_hardening.sql (Serviços V#1-V#5)
|
||||
```
|
||||
|
||||
**Total acumulado de migrations no histórico: 18** (Sessões 1-10).
|
||||
|
||||
Várias dessas exigiram conexão direta como `supabase_admin` (ver memory `project_supabase_admin_gotcha.md` e `commit.md` anterior) por causa de tabelas owned por esse role.
|
||||
|
||||
---
|
||||
|
||||
## 🆕 Novos arquivos (código)
|
||||
|
||||
```
|
||||
src/features/patients/composables/useCep.js
|
||||
src/features/patients/composables/usePatientSupportContacts.js
|
||||
src/features/patients/composables/usePatients.js
|
||||
src/features/patients/composables/__tests__/useCep.spec.js (+6 testes)
|
||||
src/features/patients/composables/__tests__/usePatientSupportContacts.spec.js (+10 testes)
|
||||
src/features/patients/services/patientsRepository.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🆕 Pastas/arquivos novos importantes
|
||||
## 🛠️ Modificações
|
||||
|
||||
- `e2e/` — specs Playwright (T#10)
|
||||
- `playwright.config.js` — config E2E
|
||||
- `database-novo/tests/run.cjs` — runner SQL integration tests (T#8)
|
||||
- `database-novo/backups/` agora ignorado (regenerável via `db.cjs backup`)
|
||||
- `src/components/security/MathCaptchaChallenge.vue` — A#20 rev2
|
||||
- `src/views/pages/saas/SaasTenantFeaturesPage.vue` — V#34
|
||||
- `src/views/pages/saas/SaasSecurityPage.vue` — A#20 rev2 + card educativo
|
||||
- `src/views/pages/saas/SaasTwilioConfigPage.vue` — UI Twilio editável
|
||||
- `src/utils/roleNormalizer.js` — Sessão 1
|
||||
- `src/features/agenda/services/_tenantGuards.js` + `agendaSelects.js` — Sessão 2
|
||||
- 6 specs novas em `__tests__/` (vitest)
|
||||
- `supabase/functions/submit-patient-intake/` — edge function reescrita A#20 rev2
|
||||
- `src/features/patients/PatientsListPage.vue` — hydrateAssociationsSupabase usa repo
|
||||
- `src/features/patients/cadastro/PatientsCadastroPage.vue` — script extraído (queries → repo, CEP → composable, contatos → composable). Template intocado.
|
||||
- `src/services/Documents.service.js` — uploadDocument calcula content_sha256 + helper verifyDocumentIntegrity
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ .gitignore ajustado neste commit
|
||||
## 📊 Áreas auditadas (estado final)
|
||||
|
||||
- `supabase/*` + `!supabase/functions/` (mantém edge functions, ignora `.temp/`/`migrations/`/etc gerados pelo CLI)
|
||||
- `database-novo/backups/` (backups regeneráveis)
|
||||
- `test-results/`, `playwright-report/` (outputs Playwright)
|
||||
- `.claude/settings.local.json` (config local do harness)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Números finais
|
||||
|
||||
| Métrica | Início | Fim |
|
||||
| Área | V# total | Estado |
|
||||
|---|---|---|
|
||||
| A# abertos | 30 (a registrar) | **0** |
|
||||
| V# abertos | 52 (a registrar) | **5** (adiados) |
|
||||
| T# escritas | 0/10 | **10/10** |
|
||||
| Vitest | — | **192/192** |
|
||||
| SQL integration | — | **33/33** |
|
||||
| E2E (Playwright) | — | **5/5** |
|
||||
| Migrations | 0 | **17** |
|
||||
| Telas SaaS novas | — | 3 |
|
||||
| Edge functions reescritas | — | 1 (`submit-patient-intake`) |
|
||||
| auth | 10 | 100% fechado/ok |
|
||||
| router | 9 | 100% |
|
||||
| stores | 1 | 100% |
|
||||
| agenda | 11 | 100% |
|
||||
| pacientes | 10 | **100% fechado** ✅ |
|
||||
| seguranca | 1 | 100% |
|
||||
| saas | 10 | 100% |
|
||||
| documentos | 10 | **100% fechado** ✅ |
|
||||
| financeiro | 11 | 5 fechados, 6 médios/baixos adiados |
|
||||
| comunicacao | 10 | 5 fechados, 5 médios/baixos adiados |
|
||||
| tenants | 8 | 6 fechados, 2 baixos adiados |
|
||||
| calendario | 2 | **100% fechado** ✅ |
|
||||
| addons | 4 | 3 resolvidos, 1 médio adiado |
|
||||
| central_saas | 3 | 1 alto fechado, 2 médios adiados |
|
||||
| servicos | 5 | **100% fechado** ✅ |
|
||||
|
||||
**Zero crítico/alto restante no sistema inteiro.**
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Adiados (próximas sessões — plano completo no DB)
|
||||
## ⚠️ Pendências documentadas no DB (não esquecidas)
|
||||
|
||||
- **V#3 + V#9 pacientes** — refatoração de composables/services (PatientsCadastroPage 1985 linhas). Sessão dedicada de 1-2h
|
||||
- **V#50/V#51/V#52 documentos** — portal-paciente policy, hash SHA-256, retention cron
|
||||
- **Áreas não auditadas:** financeiro, comunicação
|
||||
- **Deploy real**: cloud Supabase + secrets + edge functions
|
||||
### A# (1 aberto)
|
||||
- **A#31 Deploy real** — alto. Reformulação pendente: como ainda não há cloud Supabase nem secrets reais, próxima sessão é "Preparação completa pra deploy" (DEPLOY.md, validar migrations num container limpo, audit de edge functions, listar env vars, script `db.cjs deploy-check`).
|
||||
|
||||
### V# adiados (14)
|
||||
Todos médios/baixos com plano completo em `dev_verificacoes_items.acao_sugerida`:
|
||||
- financeiro (6): parcelamento CHECK, payouts flow, recurrence DELETE policy, composables, máscara PIX, dashboard inadimplência
|
||||
- comunicacao (5): notifications/schedules silos, email_templates_global filtros, retention notification_logs, dashboard health, audit dismissals/preferences
|
||||
- tenants (2): owner_users policies, company_profiles + dev_user_credentials
|
||||
- central_saas (2): rate limit voto, valores tipo_acesso
|
||||
- addons (1): UI de extrato
|
||||
|
||||
### Outros
|
||||
- PatientsCadastroPage template breakdown — quando houver E2E
|
||||
- Pacientes V#9 segue 100% no banco (script foi extraído; template é refator visual separado)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000011_documents_portal_patient_policy
|
||||
-- V#50 — paciente vê documento via portal quando compartilhado_portal=true.
|
||||
--
|
||||
-- Adiciona policy SELECT ADICIONAL em documents (combina via OR com a policy
|
||||
-- existente "documents: select"). Paciente conseguem ler documentos próprios
|
||||
-- quando o terapeuta compartilhou via portal.
|
||||
-- =============================================================================
|
||||
|
||||
DROP POLICY IF EXISTS "documents: portal patient read" ON public.documents;
|
||||
|
||||
CREATE POLICY "documents: portal patient read" ON public.documents
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
compartilhado_portal = true
|
||||
AND patient_id IN (
|
||||
SELECT p.id FROM public.patients p
|
||||
WHERE p.user_id = auth.uid()
|
||||
)
|
||||
AND (expira_compartilhamento IS NULL OR expira_compartilhamento > now())
|
||||
);
|
||||
|
||||
COMMENT ON POLICY "documents: portal patient read" ON public.documents IS
|
||||
'V#50: paciente lê documento quando compartilhado_portal=true E patient_id pertence ao auth.uid + não expirou.';
|
||||
@@ -0,0 +1,18 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000012_documents_content_hash
|
||||
-- V#51 — hash SHA-256 do conteúdo pra detecção de tampering.
|
||||
--
|
||||
-- Coluna nullable (documentos antigos não têm). Calculado client-side via
|
||||
-- crypto.subtle.digest('SHA-256') antes do upload pro storage.
|
||||
-- Integridade pode ser verificada baixando o arquivo e recalculando o hash.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.documents
|
||||
ADD COLUMN IF NOT EXISTS content_sha256 text;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_content_sha256
|
||||
ON public.documents (content_sha256)
|
||||
WHERE content_sha256 IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN public.documents.content_sha256 IS
|
||||
'V#51: SHA-256 hex (64 chars) do conteúdo no momento do upload. Permite verificar integridade. NULL pra documentos legados pré-V#51.';
|
||||
@@ -0,0 +1,65 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000013_cron_retention_jobs
|
||||
-- V#52 — retention automática de logs/challenges via pg_cron.
|
||||
--
|
||||
-- Jobs:
|
||||
-- • document_access_logs_cleanup — diário, retém 1 ano (CFP típico)
|
||||
-- • math_challenges_cleanup — horário, remove expirados há >1h
|
||||
-- • public_submission_attempts_cleanup — diário, retém 90 dias
|
||||
-- =============================================================================
|
||||
|
||||
-- Garante extensão (idempotente em ambientes que não têm)
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- document_access_logs: retém 1 ano (suficiente pra auditoria CFP)
|
||||
-- -----------------------------------------------------------------------------
|
||||
SELECT cron.unschedule('document_access_logs_cleanup')
|
||||
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'document_access_logs_cleanup');
|
||||
|
||||
SELECT cron.schedule(
|
||||
'document_access_logs_cleanup',
|
||||
'0 3 * * *', -- todo dia às 03:00
|
||||
$$DELETE FROM public.document_access_logs WHERE created_at < now() - interval '1 year'$$
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- math_challenges: remove expirados (> 1h após expiração)
|
||||
-- (RPC cleanup_expired_math_challenges já existe desde 20260419000007)
|
||||
-- -----------------------------------------------------------------------------
|
||||
SELECT cron.unschedule('math_challenges_cleanup')
|
||||
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'math_challenges_cleanup');
|
||||
|
||||
SELECT cron.schedule(
|
||||
'math_challenges_cleanup',
|
||||
'0 * * * *', -- toda hora
|
||||
$$SELECT public.cleanup_expired_math_challenges()$$
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- public_submission_attempts: retém 90 dias (analytics + alertas)
|
||||
-- -----------------------------------------------------------------------------
|
||||
SELECT cron.unschedule('public_submission_attempts_cleanup')
|
||||
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'public_submission_attempts_cleanup');
|
||||
|
||||
SELECT cron.schedule(
|
||||
'public_submission_attempts_cleanup',
|
||||
'15 3 * * *', -- todo dia 03:15 (após o de docs)
|
||||
$$DELETE FROM public.public_submission_attempts WHERE created_at < now() - interval '90 days'$$
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- submission_rate_limits: limpa entradas antigas (>30 dias sem atividade)
|
||||
-- (estados expirados não fazem mal, mas tabela cresce sem limite)
|
||||
-- -----------------------------------------------------------------------------
|
||||
SELECT cron.unschedule('submission_rate_limits_cleanup')
|
||||
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'submission_rate_limits_cleanup');
|
||||
|
||||
SELECT cron.schedule(
|
||||
'submission_rate_limits_cleanup',
|
||||
'30 3 * * *', -- todo dia 03:30
|
||||
$$DELETE FROM public.submission_rate_limits
|
||||
WHERE last_attempt_at < now() - interval '30 days'
|
||||
AND (blocked_until IS NULL OR blocked_until < now())
|
||||
AND (requires_captcha_until IS NULL OR requires_captcha_until < now())$$
|
||||
);
|
||||
@@ -0,0 +1,117 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000014_financial_security_hardening
|
||||
-- Sessão 6 — revisão Financeiro. Resolve V#1-V#5 (2 críticos + 3 altos).
|
||||
-- V#6-V#11 adiados (médios/baixos com plano).
|
||||
--
|
||||
-- Auditoria prévia confirmou:
|
||||
-- • 0 financial_records com tenant_id NULL
|
||||
-- • 0 records com clinic_fee_amount > amount
|
||||
-- → seguro aplicar NOT NULL e CHECK constraints.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#1: billing_contracts policy granular
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "billing_contracts: owner full access" ON public.billing_contracts;
|
||||
DROP POLICY IF EXISTS "billing_contracts: select" ON public.billing_contracts;
|
||||
DROP POLICY IF EXISTS "billing_contracts: insert" ON public.billing_contracts;
|
||||
DROP POLICY IF EXISTS "billing_contracts: update" ON public.billing_contracts;
|
||||
DROP POLICY IF EXISTS "billing_contracts: delete" ON public.billing_contracts;
|
||||
|
||||
CREATE POLICY "billing_contracts: select" ON public.billing_contracts
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "billing_contracts: insert" ON public.billing_contracts
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "billing_contracts: update" ON public.billing_contracts
|
||||
FOR UPDATE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
CREATE POLICY "billing_contracts: delete" ON public.billing_contracts
|
||||
FOR DELETE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#2: financial_records.tenant_id NOT NULL + trigger backfill
|
||||
-- (auditoria: 0 órfãos, seguro aplicar)
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE public.financial_records ALTER COLUMN tenant_id SET NOT NULL;
|
||||
|
||||
-- Trigger defensivo: se tentar inserir sem tenant_id, busca via owner_id->tenant_members
|
||||
CREATE OR REPLACE FUNCTION public.financial_records_inject_tenant()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.tenant_id IS NULL AND NEW.owner_id IS NOT NULL THEN
|
||||
SELECT tm.tenant_id INTO NEW.tenant_id
|
||||
FROM public.tenant_members tm
|
||||
WHERE tm.user_id = NEW.owner_id AND tm.status = 'active'
|
||||
ORDER BY tm.created_at DESC
|
||||
LIMIT 1;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_financial_records_inject_tenant ON public.financial_records;
|
||||
CREATE TRIGGER trg_financial_records_inject_tenant
|
||||
BEFORE INSERT ON public.financial_records
|
||||
FOR EACH ROW EXECUTE FUNCTION public.financial_records_inject_tenant();
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#5: financial_records CHECK contra net_amount negativo
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE public.financial_records
|
||||
DROP CONSTRAINT IF EXISTS financial_records_fee_lte_amount_chk;
|
||||
|
||||
ALTER TABLE public.financial_records
|
||||
ADD CONSTRAINT financial_records_fee_lte_amount_chk
|
||||
CHECK (clinic_fee_amount IS NULL OR (clinic_fee_amount >= 0 AND clinic_fee_amount <= amount));
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#3: payment_settings — adicionar SELECT pra tenant_admin
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "payment_settings: tenant_admin read" ON public.payment_settings;
|
||||
CREATE POLICY "payment_settings: tenant_admin read" ON public.payment_settings
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
tenant_id IS NOT NULL
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
-- (a policy ALL "owner full access" continua — owner mexe nos próprios)
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#4: professional_pricing — adicionar SELECT pra tenant_admin
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "professional_pricing: tenant_admin read" ON public.professional_pricing;
|
||||
CREATE POLICY "professional_pricing: tenant_admin read" ON public.professional_pricing
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,127 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000015_communication_security_hardening
|
||||
-- Sessão 6 — revisão Comunicação. Resolve V#1-V#5 (2 críticos + 3 altos).
|
||||
-- V#6-V#10 adiados (médios/baixos com plano completo no DB).
|
||||
--
|
||||
-- 🔴 V#1+V#2 são bugs P0: policies usavam (tenant_id = auth.uid()) — comparação
|
||||
-- de UUID de tenant com UUID de user. Tabelas inacessíveis na prática.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#1: email_layout_config — fix BUG do tenant_id = auth.uid()
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "tenant owns email layout config" ON public.email_layout_config;
|
||||
DROP POLICY IF EXISTS "email_layout_config: tenant_admin all" ON public.email_layout_config;
|
||||
|
||||
CREATE POLICY "email_layout_config: tenant_admin all" ON public.email_layout_config
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#2: email_templates_tenant — MESMO bug
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "tenant manages own overrides" ON public.email_templates_tenant;
|
||||
DROP POLICY IF EXISTS "email_templates_tenant: tenant_admin all" ON public.email_templates_tenant;
|
||||
|
||||
CREATE POLICY "email_templates_tenant: tenant_admin all" ON public.email_templates_tenant
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#3: notification_logs — SELECT pra tenant_member
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "notif_logs_tenant_member" ON public.notification_logs;
|
||||
CREATE POLICY "notif_logs_tenant_member" ON public.notification_logs
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#4: notification_queue — SELECT pra tenant_member
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "notif_queue_tenant_member" ON public.notification_queue;
|
||||
CREATE POLICY "notif_queue_tenant_member" ON public.notification_queue
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#5: notification_channels — SELECT pra tenant_member; INSERT tenant_admin; UPDATE/DELETE owner
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "notification_channels_owner" ON public.notification_channels;
|
||||
DROP POLICY IF EXISTS "notif_channels_select" ON public.notification_channels;
|
||||
DROP POLICY IF EXISTS "notif_channels_insert" ON public.notification_channels;
|
||||
DROP POLICY IF EXISTS "notif_channels_modify" ON public.notification_channels;
|
||||
|
||||
CREATE POLICY "notif_channels_select" ON public.notification_channels
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
deleted_at IS NULL
|
||||
AND (
|
||||
public.is_saas_admin()
|
||||
OR owner_id = auth.uid()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "notif_channels_insert" ON public.notification_channels
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "notif_channels_modify" ON public.notification_channels
|
||||
FOR UPDATE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
CREATE POLICY "notif_channels_delete" ON public.notification_channels
|
||||
FOR DELETE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
@@ -0,0 +1,157 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000016_tenants_calendario_hardening
|
||||
-- Sessão 7 — Tenants + Calendário scan (corrige críticos + altos + WITH CHECKs).
|
||||
--
|
||||
-- Resolve:
|
||||
-- • Tenants V#1 (P0) — tenant_invites RLS off + 0 policies
|
||||
-- • Tenants V#2 — profiles_insert_own sem WITH CHECK
|
||||
-- • Tenants V#3 — support_sessions_saas_insert sem WITH CHECK
|
||||
-- • Tenants V#6 — user_settings_insert_own sem WITH CHECK
|
||||
-- • Calendário V#1 — feriados_insert + feriados_saas_insert sem WITH CHECK
|
||||
--
|
||||
-- Auditoria prévia: tenant_invites tem 0 rows (seguro habilitar RLS sem
|
||||
-- migração de dados).
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Tenants V#1 (P0): tenant_invites
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE public.tenant_invites ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
REVOKE ALL ON public.tenant_invites FROM anon, authenticated;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.tenant_invites TO authenticated;
|
||||
|
||||
-- SELECT: tenant_admin/admin/owner do tenant + saas_admin
|
||||
DROP POLICY IF EXISTS tenant_invites_select ON public.tenant_invites;
|
||||
CREATE POLICY tenant_invites_select ON public.tenant_invites
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
|
||||
-- INSERT: só tenant_admin do tenant_id, e invited_by deve ser o caller
|
||||
DROP POLICY IF EXISTS tenant_invites_insert ON public.tenant_invites;
|
||||
CREATE POLICY tenant_invites_insert ON public.tenant_invites
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
invited_by = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
|
||||
-- UPDATE: só revogação por tenant_admin do tenant. Aceitar é via RPC tenant_accept_invite (SECURITY DEFINER).
|
||||
DROP POLICY IF EXISTS tenant_invites_update ON public.tenant_invites;
|
||||
CREATE POLICY tenant_invites_update ON public.tenant_invites
|
||||
FOR UPDATE TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
|
||||
-- DELETE: tenant_admin OR saas_admin
|
||||
DROP POLICY IF EXISTS tenant_invites_delete ON public.tenant_invites;
|
||||
CREATE POLICY tenant_invites_delete ON public.tenant_invites
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.tenant_invites IS
|
||||
'Convites pra entrar em tenant. Aceitar deve ser via RPC tenant_accept_invite (SECURITY DEFINER). Criar/revogar via UI por tenant_admin.';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Tenants V#2: profiles INSERT WITH CHECK
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS profiles_insert_own ON public.profiles;
|
||||
CREATE POLICY profiles_insert_own ON public.profiles
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (id = auth.uid());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Tenants V#3: support_sessions INSERT WITH CHECK
|
||||
-- (admin_id deve ser o caller E o caller deve ser saas_admin)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS support_sessions_saas_insert ON public.support_sessions;
|
||||
CREATE POLICY support_sessions_saas_insert ON public.support_sessions
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
admin_id = auth.uid()
|
||||
AND EXISTS (SELECT 1 FROM public.saas_admins sa WHERE sa.user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Tenants V#6: user_settings INSERT WITH CHECK
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS user_settings_insert_own ON public.user_settings;
|
||||
CREATE POLICY user_settings_insert_own ON public.user_settings
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Calendário V#1: feriados INSERT WITH CHECK (tenant + global)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS feriados_insert ON public.feriados;
|
||||
CREATE POLICY feriados_insert ON public.feriados
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
tenant_id IS NOT NULL
|
||||
AND owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS feriados_saas_insert ON public.feriados;
|
||||
CREATE POLICY feriados_saas_insert ON public.feriados
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
tenant_id IS NULL
|
||||
AND EXISTS (SELECT 1 FROM public.saas_admins sa WHERE sa.user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Calendário V#2: feriados DELETE — adicionar tenant_admin
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS feriados_delete ON public.feriados;
|
||||
CREATE POLICY feriados_delete ON public.feriados
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR (tenant_id IS NOT NULL AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
))
|
||||
);
|
||||
@@ -0,0 +1,65 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000017_addons_central_saas_hardening
|
||||
-- Sessão 8 — Addons + Central SaaS scan.
|
||||
--
|
||||
-- Resolve:
|
||||
-- • Addons V#1 (CRÍTICO — dinheiro real): addon_transactions sem WITH CHECK
|
||||
-- • Addons V#2: addon_credits sem CHECK contra saldo negativo
|
||||
-- • Central SaaS V#1: saas_faq write permite tenant_admin/clinic_admin
|
||||
--
|
||||
-- Auditoria prévia: 0 addon_credits com balance < 0 (seguro CHECK).
|
||||
-- Edge functions consomem créditos via service_role (bypass RLS) — nova
|
||||
-- restrição não quebra pipeline.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Addons V#1: addon_transactions INSERT WITH CHECK (saas_admin only)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS addon_transactions_admin_insert ON public.addon_transactions;
|
||||
CREATE POLICY addon_transactions_admin_insert ON public.addon_transactions
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.saas_admins sa WHERE sa.user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Addons V#2: addon_credits CHECK contra saldo negativo
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE public.addon_credits
|
||||
DROP CONSTRAINT IF EXISTS addon_credits_balance_nonneg_chk;
|
||||
|
||||
ALTER TABLE public.addon_credits
|
||||
ADD CONSTRAINT addon_credits_balance_nonneg_chk
|
||||
CHECK (balance >= 0);
|
||||
|
||||
-- Aproveita: total_consumed também não deve ser negativo
|
||||
ALTER TABLE public.addon_credits
|
||||
DROP CONSTRAINT IF EXISTS addon_credits_consumed_nonneg_chk;
|
||||
|
||||
ALTER TABLE public.addon_credits
|
||||
ADD CONSTRAINT addon_credits_consumed_nonneg_chk
|
||||
CHECK (total_consumed >= 0);
|
||||
|
||||
ALTER TABLE public.addon_credits
|
||||
DROP CONSTRAINT IF EXISTS addon_credits_purchased_nonneg_chk;
|
||||
|
||||
ALTER TABLE public.addon_credits
|
||||
ADD CONSTRAINT addon_credits_purchased_nonneg_chk
|
||||
CHECK (total_purchased >= 0);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Central SaaS V#1: saas_faq + saas_faq_itens write SÓ saas_admin
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS faq_admin_write ON public.saas_faq;
|
||||
CREATE POLICY faq_saas_admin_write ON public.saas_faq
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
DROP POLICY IF EXISTS faq_itens_admin_write ON public.saas_faq_itens;
|
||||
CREATE POLICY faq_itens_saas_admin_write ON public.saas_faq_itens
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
-- (Policies de leitura — faq_auth_read, faq_public_read, faq_itens_auth_read — permanecem)
|
||||
@@ -0,0 +1,223 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000018_servicos_prontuarios_hardening
|
||||
-- Sessão 9 — Serviços/Prontuários scan.
|
||||
--
|
||||
-- Resolve:
|
||||
-- • Serviços V#1+V#2 (CRÍTICOS): silos por owner em services/medicos/insurance_plans
|
||||
-- • Serviços V#3+V#4 (ALTOS): cascade silos em commitment_services/insurance_plan_services
|
||||
-- • Serviços V#5: WITH CHECK ausente em commitment_time_logs/determined_*
|
||||
--
|
||||
-- Padrão validado em 5 áreas anteriores (Documentos/Financeiro/Comunicação/etc):
|
||||
-- SELECT tenant_member, INSERT/UPDATE/DELETE owner+saas, com WITH CHECK explícito.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#1 services — split em 4 policies
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "services: owner full access" ON public.services;
|
||||
DROP POLICY IF EXISTS "services: select" ON public.services;
|
||||
DROP POLICY IF EXISTS "services: insert" ON public.services;
|
||||
DROP POLICY IF EXISTS "services: update" ON public.services;
|
||||
DROP POLICY IF EXISTS "services: delete" ON public.services;
|
||||
|
||||
CREATE POLICY "services: select" ON public.services
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "services: insert" ON public.services
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "services: update" ON public.services
|
||||
FOR UPDATE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
CREATE POLICY "services: delete" ON public.services
|
||||
FOR DELETE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#2 medicos — mesmo padrão
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "medicos: owner full access" ON public.medicos;
|
||||
DROP POLICY IF EXISTS "medicos: select" ON public.medicos;
|
||||
DROP POLICY IF EXISTS "medicos: insert" ON public.medicos;
|
||||
DROP POLICY IF EXISTS "medicos: update" ON public.medicos;
|
||||
DROP POLICY IF EXISTS "medicos: delete" ON public.medicos;
|
||||
|
||||
CREATE POLICY "medicos: select" ON public.medicos
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "medicos: insert" ON public.medicos
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "medicos: update" ON public.medicos
|
||||
FOR UPDATE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
CREATE POLICY "medicos: delete" ON public.medicos
|
||||
FOR DELETE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#1 (parte 2) insurance_plans — mesmo padrão
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "insurance_plans: owner full access" ON public.insurance_plans;
|
||||
DROP POLICY IF EXISTS "insurance_plans: select" ON public.insurance_plans;
|
||||
DROP POLICY IF EXISTS "insurance_plans: insert" ON public.insurance_plans;
|
||||
DROP POLICY IF EXISTS "insurance_plans: update" ON public.insurance_plans;
|
||||
DROP POLICY IF EXISTS "insurance_plans: delete" ON public.insurance_plans;
|
||||
|
||||
CREATE POLICY "insurance_plans: select" ON public.insurance_plans
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "insurance_plans: insert" ON public.insurance_plans
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "insurance_plans: update" ON public.insurance_plans
|
||||
FOR UPDATE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
CREATE POLICY "insurance_plans: delete" ON public.insurance_plans
|
||||
FOR DELETE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#3 commitment_services — cascade via JOIN com services.tenant_id
|
||||
-- (tabela N:N sem tenant_id próprio; herda do services pai)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "commitment_services: owner full access" ON public.commitment_services;
|
||||
DROP POLICY IF EXISTS "commitment_services: tenant_member" ON public.commitment_services;
|
||||
|
||||
CREATE POLICY "commitment_services: tenant_member" ON public.commitment_services
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.services s
|
||||
WHERE s.id = commitment_services.service_id
|
||||
AND (
|
||||
s.owner_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR s.tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.services s
|
||||
WHERE s.id = commitment_services.service_id
|
||||
AND (s.owner_id = auth.uid() OR public.is_saas_admin())
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#4 insurance_plan_services — cascade via JOIN com insurance_plans
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "insurance_plan_services_owner" ON public.insurance_plan_services;
|
||||
DROP POLICY IF EXISTS "insurance_plan_services: tenant_member" ON public.insurance_plan_services;
|
||||
|
||||
CREATE POLICY "insurance_plan_services: tenant_member" ON public.insurance_plan_services
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.insurance_plans ip
|
||||
WHERE ip.id = insurance_plan_services.insurance_plan_id
|
||||
AND (
|
||||
ip.owner_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR ip.tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.insurance_plans ip
|
||||
WHERE ip.id = insurance_plan_services.insurance_plan_id
|
||||
AND (ip.owner_id = auth.uid() OR public.is_saas_admin())
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#5 — adicionar WITH CHECK em INSERT das 3 tabelas que não tinham
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS ctl_insert_for_active_member ON public.commitment_time_logs;
|
||||
CREATE POLICY ctl_insert_for_active_member ON public.commitment_time_logs
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS dcf_insert_for_active_member ON public.determined_commitment_fields;
|
||||
CREATE POLICY dcf_insert_for_active_member ON public.determined_commitment_fields
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS dc_insert_for_active_member ON public.determined_commitments;
|
||||
CREATE POLICY dc_insert_for_active_member ON public.determined_commitments
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
@@ -16,6 +16,13 @@
|
||||
-->
|
||||
<script setup>
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import {
|
||||
listGroupsByPatient,
|
||||
listTagsByPatient,
|
||||
listGroups as repoListGroups,
|
||||
listTags as repoListTags,
|
||||
getSessionCounts
|
||||
} from '@/features/patients/services/patientsRepository';
|
||||
import Menu from 'primevue/menu';
|
||||
import MultiSelect from 'primevue/multiselect';
|
||||
import Popover from 'primevue/popover';
|
||||
@@ -568,43 +575,22 @@ async function hydrateAssociationsSupabase() {
|
||||
const ids = (patients.value || []).map((p) => p.id).filter(Boolean);
|
||||
if (!ids.length) return;
|
||||
|
||||
// V#8 — Fase 1 (paralelo): vínculos grupo/tag + agregado de sessões via RPC
|
||||
// (substitui .limit(1000) arbitrário por get_patient_session_counts agregada)
|
||||
const pgQ = withTenantFilter(supabase.from('patient_group_patient').select('patient_id, patient_group_id').in('patient_id', ids));
|
||||
const ptQ = withTenantFilter(supabase.from('patient_patient_tag').select('patient_id, tag_id').in('patient_id', ids));
|
||||
const evtQ = supabase.rpc('get_patient_session_counts', { p_patient_ids: ids });
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
|
||||
const [pgRes, ptRes, evtRes] = await Promise.all([pgQ, ptQ, evtQ]);
|
||||
if (pgRes.error) throw pgRes.error;
|
||||
if (ptRes.error) throw ptRes.error;
|
||||
if (evtRes.error) throw evtRes.error;
|
||||
const pg = pgRes.data || [];
|
||||
const pt = ptRes.data || [];
|
||||
const sessionCounts = evtRes.data || []; // [{patient_id, session_count, last_session_at}]
|
||||
// V#3 — Fase 1: vínculos + sessions via repo (paralelo).
|
||||
const [pg, pt, sessionCounts] = await Promise.all([
|
||||
listGroupsByPatient(ids, { tenantId }),
|
||||
listTagsByPatient(ids, { tenantId }),
|
||||
getSessionCounts(ids)
|
||||
]);
|
||||
|
||||
// Fase 2 (paralelo): catálogos de grupos e tags — dependem dos ids derivados
|
||||
const groupIds = Array.from(new Set(pg.map((r) => r.patient_group_id).filter(Boolean)));
|
||||
const tagIds = Array.from(new Set(pt.map((r) => r.tag_id).filter(Boolean)));
|
||||
|
||||
const groupCatalogQ = groupIds.length
|
||||
? (() => {
|
||||
let q = withTenantFilter(supabase.from('patient_groups').select('id, nome, cor, is_system, owner_id, is_active').in('id', groupIds).eq('is_active', true));
|
||||
if (uid.value) q = q.or(`is_system.eq.true,owner_id.eq.${uid.value}`);
|
||||
else q = q.eq('is_system', true);
|
||||
return q;
|
||||
})()
|
||||
: Promise.resolve({ data: [], error: null });
|
||||
|
||||
const tagCatalogQ = tagIds.length
|
||||
? withOwnerFilter(withTenantFilter(supabase.from('patient_tags').select('id, nome, cor').in('id', tagIds)))
|
||||
: Promise.resolve({ data: [], error: null });
|
||||
|
||||
const [gcatRes, tcatRes] = await Promise.all([groupCatalogQ, tagCatalogQ]);
|
||||
if (gcatRes.error) throw gcatRes.error;
|
||||
if (tcatRes.error) throw tcatRes.error;
|
||||
|
||||
const groupCatalog = (gcatRes.data || []).map((g) => ({ id: g.id, name: g.nome, color: g.cor }));
|
||||
const tagCatalog = (tcatRes.data || []).map((t) => ({ id: t.id, name: t.nome, color: t.cor }));
|
||||
// Fase 2: catálogos completos (grupos + tags) também via repo.
|
||||
// Lista TUDO do tenant pra deixar mapas prontos (volume baixo); repo já
|
||||
// filtra is_system OR owner do uid.
|
||||
const [groupCatalog, tagCatalog] = await Promise.all([
|
||||
repoListGroups({ tenantId, ownerId: uid.value || null }),
|
||||
repoListTags({ tenantId, ownerId: uid.value || null })
|
||||
]);
|
||||
|
||||
// Monta mapas finais
|
||||
const gById = new Map(groupCatalog.map((g) => [g.id, g]));
|
||||
|
||||
@@ -70,6 +70,20 @@ import { digitsOnly, fmtCPF, fmtRG, fmtPhone, toISODate, generateCPF } from '@/u
|
||||
import CadastroRapidoConvenio from '@/components/CadastroRapidoConvenio.vue'
|
||||
import CadastroRapidoMedico from '@/components/CadastroRapidoMedico.vue'
|
||||
|
||||
// V#9 — composables/repo da feature pacientes (extração da página gigante)
|
||||
import { useCep } from '@/features/patients/composables/useCep'
|
||||
import { usePatientSupportContacts } from '@/features/patients/composables/usePatientSupportContacts'
|
||||
import {
|
||||
listGroups as repoListGroups,
|
||||
listTags as repoListTags,
|
||||
getPatientById as repoGetPatientById,
|
||||
createPatient as repoCreatePatient,
|
||||
updatePatient as repoUpdatePatient,
|
||||
getPatientRelations as repoGetPatientRelations,
|
||||
replacePatientGroup as repoReplacePatientGroup,
|
||||
replacePatientTags as repoReplacePatientTags
|
||||
} from '@/features/patients/services/patientsRepository'
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Props / emits
|
||||
// ─────────────────────────────────────────────────────────
|
||||
@@ -446,83 +460,33 @@ function sanitizePayload (raw, ownerId) {
|
||||
// Entidade separada que alimenta o card "Contatos & rede de suporte" do detalhe
|
||||
// is_primario = true → badge vermelho "emergência" no detalhe
|
||||
// ─────────────────────────────────────────────────────────
|
||||
const contatosSuporte = ref([])
|
||||
const novoContato = () => ({ _k: Date.now()+Math.random(), nome:'', relacao:'', tipo:'', telefone:'', email:'', is_primario:false })
|
||||
// V#9 — contatos de suporte agora vêm do composable (script -300 linhas)
|
||||
const _supportContacts = usePatientSupportContacts()
|
||||
const contatosSuporte = _supportContacts.contatos
|
||||
const addContato = () => _supportContacts.add()
|
||||
const removeContato = (i) => _supportContacts.remove(i)
|
||||
const iniciaisFor = (n) => _supportContacts.iniciaisFor(n)
|
||||
const loadContatosSuporte = (pid) => _supportContacts.load(pid)
|
||||
const saveContatosSuporte = (pid, tenantId, ownerId) => _supportContacts.save(pid, tenantId, ownerId)
|
||||
|
||||
function addContato () { contatosSuporte.value.push(novoContato()) }
|
||||
function removeContato (i) { contatosSuporte.value.splice(i,1) }
|
||||
function iniciaisFor (n) { return (n||'').split(' ').filter(Boolean).map(w=>w[0].toUpperCase()).slice(0,2).join('') }
|
||||
|
||||
async function saveContatosSuporte (pid, tenantId, ownerId) {
|
||||
const { error: del } = await supabase.from('patient_support_contacts')
|
||||
.delete().eq('patient_id', pid).eq('owner_id', ownerId)
|
||||
if (del) throw del
|
||||
const rows = contatosSuporte.value.filter(c=>c.nome.trim()).map(c=>({
|
||||
patient_id: pid, owner_id: ownerId, tenant_id: tenantId,
|
||||
nome: c.nome.trim()||null,
|
||||
relacao: c.relacao||null,
|
||||
tipo: c.tipo||null,
|
||||
telefone: c.telefone ? digitsOnly(c.telefone) : null,
|
||||
email: c.email||null,
|
||||
is_primario: !!c.is_primario,
|
||||
}))
|
||||
if (!rows.length) return
|
||||
const { error: ins } = await supabase.from('patient_support_contacts').insert(rows)
|
||||
if (ins) throw ins
|
||||
}
|
||||
async function loadContatosSuporte (pid) {
|
||||
try {
|
||||
const { data, error } = await supabase.from('patient_support_contacts')
|
||||
.select('*').eq('patient_id', pid).order('is_primario', { ascending:false })
|
||||
if (error) throw error
|
||||
contatosSuporte.value = (data||[]).map(c=>({
|
||||
_k: c.id, nome: c.nome||'', relacao: c.relacao||'', tipo: c.tipo||'',
|
||||
telefone: fmtPhone(c.telefone||''), email: c.email||'', is_primario: !!c.is_primario,
|
||||
}))
|
||||
} catch (_) { contatosSuporte.value = [] }
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// DB calls
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// V#9 — DB calls delegadas ao patientsRepository (V#3 fundação)
|
||||
async function listGroups () {
|
||||
const tid = currentTenantId()
|
||||
let q = supabase.from('patient_groups').select('id,nome,descricao,cor,is_system,is_active').eq('is_active',true).order('nome',{ascending:true})
|
||||
if (tid) q = q.eq('tenant_id', tid)
|
||||
const { data, error } = await q
|
||||
if (error) throw error
|
||||
return (data||[]).map(g=>({...g,name:g.nome,color:g.cor}))
|
||||
return repoListGroups({ tenantId: currentTenantId() })
|
||||
}
|
||||
async function listTags () {
|
||||
const tid = currentTenantId()
|
||||
let q = supabase.from('patient_tags').select('id,nome,cor').order('nome',{ascending:true})
|
||||
if (tid) q = q.eq('tenant_id', tid)
|
||||
const { data, error } = await q
|
||||
if (error) throw error
|
||||
return (data||[]).map(t=>({...t,name:t.nome,color:t.cor}))
|
||||
return repoListTags({ tenantId: currentTenantId() })
|
||||
}
|
||||
async function getPatientById (id) {
|
||||
const tid = currentTenantId()
|
||||
let q = supabase.from('patients').select('*').eq('id',id)
|
||||
if (tid) q = q.eq('tenant_id', tid)
|
||||
const { data, error } = await q.maybeSingle()
|
||||
if (error) throw error; return data
|
||||
return repoGetPatientById(id, { tenantId: currentTenantId() })
|
||||
}
|
||||
async function getPatientRelations (id) {
|
||||
const { data:g, error:ge } = await supabase.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); if (te) throw te
|
||||
return { groupIds:(g||[]).map(x=>x.patient_group_id).filter(Boolean), tagIds:(t||[]).map(x=>x.tag_id).filter(Boolean) }
|
||||
return repoGetPatientRelations(id)
|
||||
}
|
||||
async function createPatient (payload) {
|
||||
const { data, error } = await supabase.from('patients').insert(payload).select('id').single()
|
||||
if (error) throw error; return data
|
||||
return repoCreatePatient(payload)
|
||||
}
|
||||
async function updatePatient (id, payload) {
|
||||
const tid = currentTenantId()
|
||||
let q = supabase.from('patients').update({ ...payload, updated_at:new Date().toISOString() }).eq('id',id)
|
||||
if (tid) q = q.eq('tenant_id', tid)
|
||||
const { error } = await q
|
||||
if (error) throw error
|
||||
return repoUpdatePatient(id, { ...payload, updated_at: new Date().toISOString() }, { tenantId: currentTenantId() })
|
||||
}
|
||||
|
||||
const groups = ref([])
|
||||
@@ -530,32 +494,28 @@ const tags = ref([])
|
||||
const grupoIdSelecionado = ref(null)
|
||||
const tagIdsSelecionadas = ref([])
|
||||
|
||||
// V#9 — replace de grupos/tags via repo
|
||||
async function replacePatientGroups (patient_id, groupId) {
|
||||
const { error } = await supabase.from('patient_group_patient').delete().eq('patient_id',patient_id); if (error) throw error
|
||||
if (!groupId) return
|
||||
const { tenantId } = await resolveTenantContextOrFail()
|
||||
const { error:ins } = await supabase.from('patient_group_patient').insert({ patient_id, patient_group_id:groupId, tenant_id:tenantId }); if (ins) throw ins
|
||||
return repoReplacePatientGroup(patient_id, groupId, { tenantId })
|
||||
}
|
||||
async function replacePatientTags (patient_id, tagIds) {
|
||||
const ownerId = await getOwnerId()
|
||||
const { error } = await supabase.from('patient_patient_tag').delete().eq('patient_id',patient_id).eq('owner_id',ownerId); if (error) throw error
|
||||
const clean = Array.from(new Set((tagIds||[]).filter(Boolean))); if (!clean.length) return
|
||||
const { tenantId } = await resolveTenantContextOrFail()
|
||||
const { error:ins } = await supabase.from('patient_patient_tag').insert(clean.map(tag_id=>({ owner_id:ownerId, patient_id, tag_id, tenant_id:tenantId }))); if (ins) throw ins
|
||||
return repoReplacePatientTags(patient_id, tagIds, { tenantId, ownerId })
|
||||
}
|
||||
|
||||
// V#9 — CEP via composable useCep (reutilizável)
|
||||
const _cep = useCep()
|
||||
async function onCepBlur () {
|
||||
try {
|
||||
const cep = digitsOnly(form.value.cep); if (cep.length!==8) return
|
||||
const res = await fetch(`https://viacep.com.br/ws/${cep}/json/`)
|
||||
const d = await res.json(); if (!d||d.erro) return
|
||||
form.value.cidade = d.localidade || form.value.cidade
|
||||
form.value.estado = d.uf || form.value.estado
|
||||
form.value.bairro = d.bairro || form.value.bairro
|
||||
form.value.endereco = d.logradouro || form.value.endereco
|
||||
if (!form.value.complemento) form.value.complemento = d.complemento||''
|
||||
toast.add({ severity:'success', summary:'CEP', detail:`${d.localidade} / ${d.uf}`, life:2000 })
|
||||
} catch (_) {}
|
||||
const result = await _cep.fetchCep(form.value.cep)
|
||||
if (!result) return
|
||||
form.value.cidade = result.cidade || form.value.cidade
|
||||
form.value.estado = result.uf || form.value.estado
|
||||
form.value.bairro = result.bairro || form.value.bairro
|
||||
form.value.endereco = result.endereco || form.value.endereco
|
||||
if (!form.value.complemento) form.value.complemento = result.complemento || ''
|
||||
toast.add({ severity:'success', summary:'CEP', detail:`${result.cidade} / ${result.uf}`, life:2000 })
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* useCep.spec.js — V#9 (composable extraído)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const { useCep } = await import('../useCep.js');
|
||||
|
||||
beforeEach(() => fetchMock.mockReset());
|
||||
afterEach(() => fetchMock.mockReset());
|
||||
|
||||
describe('useCep — busca ViaCEP', () => {
|
||||
it('retorna null se CEP tem menos de 8 dígitos (no-op)', async () => {
|
||||
const { fetchCep } = useCep();
|
||||
expect(await fetchCep('123')).toBe(null);
|
||||
expect(await fetchCep('')).toBe(null);
|
||||
expect(await fetchCep(null)).toBe(null);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aceita CEP com máscara (digitsOnly normaliza)', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ localidade: 'São Paulo', uf: 'SP', logradouro: 'Av Paulista', bairro: 'Bela Vista', complemento: '' }) });
|
||||
const { fetchCep } = useCep();
|
||||
const r = await fetchCep('01310-100');
|
||||
expect(r).toEqual({
|
||||
cidade: 'São Paulo',
|
||||
uf: 'SP',
|
||||
bairro: 'Bela Vista',
|
||||
endereco: 'Av Paulista',
|
||||
complemento: ''
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://viacep.com.br/ws/01310100/json/');
|
||||
});
|
||||
|
||||
it('retorna null se ViaCEP devolve {erro: true}', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ erro: true }) });
|
||||
const { fetchCep } = useCep();
|
||||
expect(await fetchCep('00000000')).toBe(null);
|
||||
});
|
||||
|
||||
it('retorna null se HTTP falha (sem propagar erro)', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 500 });
|
||||
const { fetchCep, error } = useCep();
|
||||
expect(await fetchCep('01310100')).toBe(null);
|
||||
expect(error.value).toContain('500');
|
||||
});
|
||||
|
||||
it('captura exception de rede como null', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('network'));
|
||||
const { fetchCep, error } = useCep();
|
||||
expect(await fetchCep('01310100')).toBe(null);
|
||||
expect(error.value).toBe('network');
|
||||
});
|
||||
|
||||
it('loading reflete o ciclo da request', async () => {
|
||||
let resolveIt;
|
||||
fetchMock.mockReturnValue(new Promise((res) => { resolveIt = res; }));
|
||||
const { fetchCep, loading } = useCep();
|
||||
const p = fetchCep('01310100');
|
||||
expect(loading.value).toBe(true);
|
||||
resolveIt({ ok: true, json: async () => ({ localidade: 'A', uf: 'B' }) });
|
||||
await p;
|
||||
expect(loading.value).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* usePatientSupportContacts.spec.js — V#9
|
||||
*
|
||||
* Cobre add/remove/reset/load/save/iniciaisFor + sanitização do save.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Builder thenable: cada chamada (.select/.eq/.order/.delete/.insert) retorna um
|
||||
// objeto thenable. Pra testes, configuramos o resultado final via setNext(value).
|
||||
let nextResult = { data: [], error: null };
|
||||
let lastInsertArg = null;
|
||||
let deleteCalled = false;
|
||||
|
||||
function makeChain() {
|
||||
const chain = {};
|
||||
const passthrough = (..._a) => chain;
|
||||
chain.select = passthrough;
|
||||
chain.eq = passthrough;
|
||||
chain.order = passthrough;
|
||||
chain.delete = (...a) => { deleteCalled = true; return chain; };
|
||||
chain.insert = (rows) => { lastInsertArg = rows; return chain; };
|
||||
chain.then = (onFulfilled, onRejected) => Promise.resolve(nextResult).then(onFulfilled, onRejected);
|
||||
return chain;
|
||||
}
|
||||
|
||||
const fromMock = vi.fn(() => makeChain());
|
||||
|
||||
vi.mock('@/lib/supabase/client', () => ({
|
||||
supabase: { from: (...a) => fromMock(...a) }
|
||||
}));
|
||||
|
||||
const { usePatientSupportContacts } = await import('../usePatientSupportContacts.js');
|
||||
|
||||
beforeEach(() => {
|
||||
fromMock.mockClear();
|
||||
nextResult = { data: [], error: null };
|
||||
lastInsertArg = null;
|
||||
deleteCalled = false;
|
||||
});
|
||||
|
||||
describe('add / remove / reset', () => {
|
||||
it('add cria contato com defaults e _k único', () => {
|
||||
const c = usePatientSupportContacts();
|
||||
expect(c.contatos.value).toEqual([]);
|
||||
c.add();
|
||||
c.add();
|
||||
expect(c.contatos.value).toHaveLength(2);
|
||||
expect(c.contatos.value[0]._k).not.toBe(c.contatos.value[1]._k);
|
||||
expect(c.contatos.value[0]).toMatchObject({ nome: '', telefone: '', is_primario: false });
|
||||
});
|
||||
|
||||
it('remove pelo índice', () => {
|
||||
const c = usePatientSupportContacts();
|
||||
c.add(); c.add(); c.add();
|
||||
const middleK = c.contatos.value[1]._k;
|
||||
c.remove(1);
|
||||
expect(c.contatos.value).toHaveLength(2);
|
||||
expect(c.contatos.value.find((x) => x._k === middleK)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reset esvazia', () => {
|
||||
const c = usePatientSupportContacts();
|
||||
c.add(); c.add();
|
||||
c.reset();
|
||||
expect(c.contatos.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('iniciaisFor', () => {
|
||||
it('extrai 2 primeiras iniciais maiúsculas', () => {
|
||||
const { iniciaisFor } = usePatientSupportContacts();
|
||||
expect(iniciaisFor('joão pedro silva')).toBe('JP');
|
||||
expect(iniciaisFor('Maria')).toBe('M');
|
||||
expect(iniciaisFor('')).toBe('');
|
||||
expect(iniciaisFor(null)).toBe('');
|
||||
expect(iniciaisFor('A B C D')).toBe('AB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('load — popula contatos do paciente', () => {
|
||||
it('sem patientId, esvazia', async () => {
|
||||
const c = usePatientSupportContacts();
|
||||
c.add();
|
||||
await c.load(null);
|
||||
expect(c.contatos.value).toEqual([]);
|
||||
expect(fromMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mapeia rows do banco para shape do composable (com fmtPhone)', async () => {
|
||||
nextResult = {
|
||||
data: [
|
||||
{ id: 'a', nome: 'Maria', relacao: 'mãe', tipo: 'familiar', telefone: '11987654321', email: 'maria@x.com', is_primario: true },
|
||||
{ id: 'b', nome: 'Bia', relacao: 'amiga', tipo: null, telefone: null, email: null, is_primario: false }
|
||||
],
|
||||
error: null
|
||||
};
|
||||
|
||||
const c = usePatientSupportContacts();
|
||||
await c.load('p-1');
|
||||
|
||||
expect(fromMock).toHaveBeenCalledWith('patient_support_contacts');
|
||||
expect(c.contatos.value).toHaveLength(2);
|
||||
expect(c.contatos.value[0]).toMatchObject({
|
||||
_k: 'a', nome: 'Maria', is_primario: true, telefone: '(11) 98765-4321'
|
||||
});
|
||||
expect(c.contatos.value[1]).toMatchObject({ _k: 'b', telefone: '', is_primario: false });
|
||||
});
|
||||
|
||||
it('em erro, esvazia silenciosamente', async () => {
|
||||
nextResult = { data: null, error: new Error('rls') };
|
||||
const c = usePatientSupportContacts();
|
||||
c.add();
|
||||
await c.load('p-1');
|
||||
expect(c.contatos.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('save — sanitização e regras', () => {
|
||||
it('exige patientId', async () => {
|
||||
const c = usePatientSupportContacts();
|
||||
await expect(c.save(null, 't', 'o')).rejects.toThrow(/patientId/);
|
||||
});
|
||||
|
||||
it('descarta contatos com nome vazio', async () => {
|
||||
nextResult = { error: null };
|
||||
|
||||
const c = usePatientSupportContacts();
|
||||
c.add(); c.add();
|
||||
c.contatos.value[0].nome = 'Maria';
|
||||
c.contatos.value[0].telefone = '(11) 98765-4321';
|
||||
// contato 2 fica com nome vazio → descartado
|
||||
|
||||
await c.save('p-1', 't-1', 'o-1');
|
||||
|
||||
expect(deleteCalled).toBe(true);
|
||||
expect(lastInsertArg).toHaveLength(1);
|
||||
expect(lastInsertArg[0]).toMatchObject({
|
||||
patient_id: 'p-1', tenant_id: 't-1', owner_id: 'o-1',
|
||||
nome: 'Maria', telefone: '11987654321'
|
||||
});
|
||||
});
|
||||
|
||||
it('se todos contatos têm nome vazio, não chama insert (só delete)', async () => {
|
||||
nextResult = { error: null };
|
||||
const c = usePatientSupportContacts();
|
||||
c.add();
|
||||
await c.save('p-1', 't-1', 'o-1');
|
||||
|
||||
expect(deleteCalled).toBe(true);
|
||||
expect(lastInsertArg).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/patients/composables/useCep.js
|
||||
| V#9 — composable de busca CEP via ViaCEP. Reutilizável em qualquer form
|
||||
| que precise auto-completar endereço.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import { digitsOnly } from '@/utils/validators';
|
||||
|
||||
const VIACEP_URL = (cep) => `https://viacep.com.br/ws/${cep}/json/`;
|
||||
|
||||
export function useCep() {
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
/**
|
||||
* Consulta CEP no ViaCEP. Retorna {cidade, uf, bairro, endereco, complemento}
|
||||
* ou null se CEP inválido / não encontrado.
|
||||
*/
|
||||
async function fetchCep(cepRaw) {
|
||||
const cep = digitsOnly(cepRaw);
|
||||
if (cep.length !== 8) return null;
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(VIACEP_URL(cep));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (!data || data.erro) return null;
|
||||
return {
|
||||
cidade: data.localidade || '',
|
||||
uf: data.uf || '',
|
||||
bairro: data.bairro || '',
|
||||
endereco: data.logradouro || '',
|
||||
complemento: data.complemento || ''
|
||||
};
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha na consulta CEP';
|
||||
return null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, error, fetchCep };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/patients/composables/usePatientSupportContacts.js
|
||||
| V#9 — composable de contatos de suporte do paciente (responsável, parente,
|
||||
| amigo). Encapsula CRUD + estado reativo.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { digitsOnly, fmtPhone } from '@/utils/validators';
|
||||
|
||||
function novoContato() {
|
||||
return {
|
||||
_k: Date.now() + Math.random(),
|
||||
nome: '',
|
||||
relacao: '',
|
||||
tipo: '',
|
||||
telefone: '',
|
||||
email: '',
|
||||
is_primario: false
|
||||
};
|
||||
}
|
||||
|
||||
export function usePatientSupportContacts() {
|
||||
const contatos = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
function add() {
|
||||
contatos.value.push(novoContato());
|
||||
}
|
||||
|
||||
function remove(idx) {
|
||||
contatos.value.splice(idx, 1);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
contatos.value = [];
|
||||
}
|
||||
|
||||
function iniciaisFor(nome) {
|
||||
return (nome || '')
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map((w) => w[0].toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join('');
|
||||
}
|
||||
|
||||
async function load(patientId) {
|
||||
if (!patientId) {
|
||||
contatos.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('patient_support_contacts')
|
||||
.select('*')
|
||||
.eq('patient_id', patientId)
|
||||
.order('is_primario', { ascending: false });
|
||||
if (error) throw error;
|
||||
contatos.value = (data || []).map((c) => ({
|
||||
_k: c.id,
|
||||
nome: c.nome || '',
|
||||
relacao: c.relacao || '',
|
||||
tipo: c.tipo || '',
|
||||
telefone: fmtPhone(c.telefone || ''),
|
||||
email: c.email || '',
|
||||
is_primario: !!c.is_primario
|
||||
}));
|
||||
} catch {
|
||||
contatos.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitui contatos do paciente: deleta tudo do owner + reinserta os com nome.
|
||||
* @param {string} patientId
|
||||
* @param {string} tenantId
|
||||
* @param {string} ownerId
|
||||
*/
|
||||
async function save(patientId, tenantId, ownerId) {
|
||||
if (!patientId) throw new Error('patientId obrigatório');
|
||||
|
||||
const { error: del } = await supabase
|
||||
.from('patient_support_contacts')
|
||||
.delete()
|
||||
.eq('patient_id', patientId)
|
||||
.eq('owner_id', ownerId);
|
||||
if (del) throw del;
|
||||
|
||||
const rows = contatos.value
|
||||
.filter((c) => c.nome.trim())
|
||||
.map((c) => ({
|
||||
patient_id: patientId,
|
||||
owner_id: ownerId,
|
||||
tenant_id: tenantId,
|
||||
nome: c.nome.trim() || null,
|
||||
relacao: c.relacao || null,
|
||||
tipo: c.tipo || null,
|
||||
telefone: c.telefone ? digitsOnly(c.telefone) : null,
|
||||
email: c.email || null,
|
||||
is_primario: !!c.is_primario
|
||||
}));
|
||||
|
||||
if (!rows.length) return;
|
||||
const { error: ins } = await supabase.from('patient_support_contacts').insert(rows);
|
||||
if (ins) throw ins;
|
||||
}
|
||||
|
||||
return { contatos, loading, add, remove, reset, iniciaisFor, load, save };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/patients/composables/usePatients.js
|
||||
| V#3 — composable que agrega estado reativo (rows/loading/error) e delega
|
||||
| toda I/O ao patientsRepository. Mesmo padrão de useAgendaEvents.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
listPatients,
|
||||
getPatientById,
|
||||
createPatient,
|
||||
updatePatient,
|
||||
softDeletePatient
|
||||
} from '@/features/patients/services/patientsRepository';
|
||||
|
||||
export function usePatients() {
|
||||
const rows = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
async function load(opts) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
rows.value = await listPatients(opts);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar pacientes';
|
||||
rows.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getById(id, opts) {
|
||||
return getPatientById(id, opts);
|
||||
}
|
||||
|
||||
async function create(payload) {
|
||||
return createPatient(payload);
|
||||
}
|
||||
|
||||
async function update(id, patch, opts) {
|
||||
return updatePatient(id, patch, opts);
|
||||
}
|
||||
|
||||
async function remove(id, opts) {
|
||||
await softDeletePatient(id, opts);
|
||||
}
|
||||
|
||||
return { rows, loading, error, load, getById, create, update, remove };
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/patients/services/patientsRepository.js
|
||||
| V#3 — fundação: queries de patients centralizadas.
|
||||
|
|
||||
| Mesmo padrão de feature/agenda/services/agendaRepository.js. Pages devem
|
||||
| chamar este repo em vez de fazer supabase.from('patients') direto.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { assertTenantId, getUid } from '@/features/agenda/services/_tenantGuards';
|
||||
|
||||
const PATIENTS_SELECT_BASE = `
|
||||
id, tenant_id, owner_id, user_id, responsible_member_id, therapist_member_id,
|
||||
nome_completo, email_principal, email_alternativo, telefone, telefone_alternativo,
|
||||
cpf, rg, data_nascimento, naturalidade, nacionalidade, genero, estado_civil,
|
||||
profissao, escolaridade, status, status_pagamento,
|
||||
cep, endereco, numero, bairro, complemento, cidade, estado, pais,
|
||||
nome_responsavel, telefone_responsavel, cpf_responsavel, observacao_responsavel,
|
||||
cobranca_no_responsavel,
|
||||
onde_nos_conheceu, encaminhado_por, observacoes,
|
||||
last_attended_at, created_at, updated_at,
|
||||
risco_sinalizado_por, convenio_id, patient_scope
|
||||
`;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Patients core
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Lista pacientes do tenant ativo. Aceita filtros opcionais.
|
||||
* @param {object} opts - { tenantId, ownerId?, includeInactive?, limit? }
|
||||
*/
|
||||
export async function listPatients({ tenantId, ownerId = null, includeInactive = true, limit = null } = {}) {
|
||||
assertTenantId(tenantId);
|
||||
|
||||
let q = supabase.from('patients').select(PATIENTS_SELECT_BASE).eq('tenant_id', tenantId).is('deleted_at', null);
|
||||
if (ownerId) q = q.eq('owner_id', ownerId);
|
||||
if (!includeInactive) q = q.neq('status', 'Inativo');
|
||||
if (limit) q = q.limit(limit);
|
||||
q = q.order('created_at', { ascending: false });
|
||||
|
||||
const { data, error } = await q;
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
export async function getPatientById(id, { tenantId } = {}) {
|
||||
if (!id) throw new Error('id obrigatório');
|
||||
assertTenantId(tenantId);
|
||||
const { data, error } = await supabase
|
||||
.from('patients')
|
||||
.select(PATIENTS_SELECT_BASE)
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
.is('deleted_at', null)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createPatient(payload) {
|
||||
const tenantId = payload?.tenant_id;
|
||||
assertTenantId(tenantId);
|
||||
const ownerId = payload?.owner_id || (await getUid());
|
||||
const row = { ...payload, tenant_id: tenantId, owner_id: ownerId };
|
||||
const { data, error } = await supabase.from('patients').insert(row).select(PATIENTS_SELECT_BASE).single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updatePatient(id, patch, { tenantId } = {}) {
|
||||
if (!id) throw new Error('id obrigatório');
|
||||
assertTenantId(tenantId);
|
||||
const { data, error } = await supabase
|
||||
.from('patients')
|
||||
.update(patch)
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
.select(PATIENTS_SELECT_BASE)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function softDeletePatient(id, { tenantId } = {}) {
|
||||
if (!id) throw new Error('id obrigatório');
|
||||
assertTenantId(tenantId);
|
||||
const { error } = await supabase
|
||||
.from('patients')
|
||||
.update({ deleted_at: new Date().toISOString(), status: 'Arquivado' })
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Groups
|
||||
// -----------------------------------------------------------------------------
|
||||
export async function listGroups({ tenantId, ownerId = null } = {}) {
|
||||
assertTenantId(tenantId);
|
||||
let q = supabase.from('patient_groups').select('id, nome, cor, is_system, owner_id, is_active').eq('tenant_id', tenantId).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;
|
||||
if (error) throw error;
|
||||
return (data || []).map((g) => ({ id: g.id, name: g.nome, color: g.cor, isSystem: g.is_system }));
|
||||
}
|
||||
|
||||
export async function listGroupsByPatient(patientIds, { tenantId } = {}) {
|
||||
if (!patientIds?.length) return [];
|
||||
assertTenantId(tenantId);
|
||||
const { data, error } = await supabase
|
||||
.from('patient_group_patient')
|
||||
.select('patient_id, patient_group_id')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('patient_id', patientIds);
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Tags
|
||||
// -----------------------------------------------------------------------------
|
||||
export async function listTags({ tenantId, ownerId = null } = {}) {
|
||||
assertTenantId(tenantId);
|
||||
let q = supabase.from('patient_tags').select('id, nome, cor, owner_id').eq('tenant_id', tenantId);
|
||||
if (ownerId) q = q.eq('owner_id', ownerId);
|
||||
const { data, error } = await q;
|
||||
if (error) throw error;
|
||||
return (data || []).map((t) => ({ id: t.id, name: t.nome, color: t.cor }));
|
||||
}
|
||||
|
||||
export async function listTagsByPatient(patientIds, { tenantId } = {}) {
|
||||
if (!patientIds?.length) return [];
|
||||
assertTenantId(tenantId);
|
||||
const { data, error } = await supabase
|
||||
.from('patient_patient_tag')
|
||||
.select('patient_id, tag_id')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('patient_id', patientIds);
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna {groupIds, tagIds} de um paciente.
|
||||
*/
|
||||
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)
|
||||
]);
|
||||
if (ge) throw ge;
|
||||
if (te) throw te;
|
||||
return {
|
||||
groupIds: (g || []).map((x) => x.patient_group_id).filter(Boolean),
|
||||
tagIds: (t || []).map((x) => x.tag_id).filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitui o grupo do paciente (1:1 — sistema atual).
|
||||
*/
|
||||
export async function replacePatientGroup(patientId, groupId, { tenantId } = {}) {
|
||||
if (!patientId) throw new Error('patientId obrigatório');
|
||||
const { error: del } = await supabase.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: tenantId });
|
||||
if (ins) throw ins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitui as tags do paciente (lista). Limpa antigas do owner + inserta as novas.
|
||||
*/
|
||||
export async function replacePatientTags(patientId, tagIds, { tenantId, ownerId } = {}) {
|
||||
if (!patientId) throw new Error('patientId obrigatório');
|
||||
if (!ownerId) throw new Error('ownerId obrigatório');
|
||||
const { error: del } = await supabase
|
||||
.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: tenantId })));
|
||||
if (ins) throw ins;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Sessões agregadas (V#8 — get_patient_session_counts RPC)
|
||||
// -----------------------------------------------------------------------------
|
||||
/**
|
||||
* Retorna contagem + última sessão por paciente. Usa RPC SECURITY DEFINER.
|
||||
* @param {string[]} patientIds
|
||||
* @returns {Array<{patient_id, session_count, last_session_at}>}
|
||||
*/
|
||||
export async function getSessionCounts(patientIds) {
|
||||
if (!patientIds?.length) return [];
|
||||
const { data, error } = await supabase.rpc('get_patient_session_counts', { p_patient_ids: patientIds });
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
@@ -48,6 +48,20 @@ function buildStoragePath(tenantId, patientId, fileName) {
|
||||
return `${tenantId}/${patientId}/${timestamp}-${safe}`;
|
||||
}
|
||||
|
||||
// V#51: SHA-256 hex do conteúdo. Calculado no client antes do upload.
|
||||
async function computeSha256Hex(file) {
|
||||
if (!file || !window?.crypto?.subtle) return null;
|
||||
try {
|
||||
const buf = await file.arrayBuffer();
|
||||
const hashBuf = await crypto.subtle.digest('SHA-256', buf);
|
||||
return Array.from(new Uint8Array(hashBuf))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
} catch {
|
||||
return null; // hash é best-effort; falha não bloqueia upload
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upload ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -65,6 +79,9 @@ export async function uploadDocument(file, patientId, meta = {}) {
|
||||
const ownerId = await getOwnerId();
|
||||
const tenantId = await getActiveTenantId(ownerId);
|
||||
|
||||
// V#51: hash SHA-256 do conteúdo ANTES do upload (integridade)
|
||||
const contentSha256 = await computeSha256Hex(file);
|
||||
|
||||
// Upload ao Storage
|
||||
const path = buildStoragePath(tenantId, patientId, file.name);
|
||||
const { error: upErr } = await supabase.storage
|
||||
@@ -83,6 +100,7 @@ export async function uploadDocument(file, patientId, meta = {}) {
|
||||
nome_original: file.name,
|
||||
mime_type: file.type || null,
|
||||
tamanho_bytes: file.size || null,
|
||||
content_sha256: contentSha256,
|
||||
tipo_documento: meta.tipo_documento || 'outro',
|
||||
categoria: meta.categoria || null,
|
||||
descricao: meta.descricao || null,
|
||||
@@ -311,3 +329,37 @@ export async function getUsedTags() {
|
||||
}
|
||||
return [...set].sort((a, b) => a.localeCompare(b, 'pt-BR'));
|
||||
}
|
||||
|
||||
// ── V#51: verificação de integridade ──────────────────────────
|
||||
/**
|
||||
* Baixa o documento e recalcula o SHA-256, compara com o registrado.
|
||||
* @returns {{ ok: boolean, expected: string|null, actual: string|null }}
|
||||
*/
|
||||
export async function verifyDocumentIntegrity(docId) {
|
||||
const { data: doc, error } = await supabase
|
||||
.from('documents')
|
||||
.select('id, bucket_path, storage_bucket, content_sha256')
|
||||
.eq('id', docId)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
if (!doc?.content_sha256) {
|
||||
return { ok: false, expected: null, actual: null, reason: 'sem_hash_registrado' };
|
||||
}
|
||||
|
||||
const { data: blob, error: dlErr } = await supabase.storage
|
||||
.from(doc.storage_bucket || BUCKET)
|
||||
.download(doc.bucket_path);
|
||||
if (dlErr) throw dlErr;
|
||||
|
||||
const buf = await blob.arrayBuffer();
|
||||
const hashBuf = await crypto.subtle.digest('SHA-256', buf);
|
||||
const actual = Array.from(new Uint8Array(hashBuf))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
return {
|
||||
ok: actual === doc.content_sha256,
|
||||
expected: doc.content_sha256,
|
||||
actual
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user