Agenda, Agendador, Configurações

This commit is contained in:
Leonardo
2026-03-12 08:58:36 -03:00
parent f733db8436
commit f4b185ae17
197 changed files with 33405 additions and 6507 deletions

View File

@@ -0,0 +1,17 @@
{
"permissions": {
"allow": [
"Bash(node:*)",
"Bash(powershell:*)",
"Bash(grep:*)",
"Bash(cd \"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai\" && sed -i \\\\\n 's/console\\\\.time\\(tlabel\\)/const _perfEnd = logPerf\\('\\\\''router.guard'\\\\'', tlabel\\)/g' \\\\\n src/router/guards.js && echo \"console.time substituído\")",
"Bash(cd \"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai\" && sed -i \\\\\n 's/console\\\\.timeEnd\\(tlabel\\)/_perfEnd\\(\\)/g' \\\\\n src/router/guards.js && echo \"console.timeEnd substituído\")",
"Bash(cd \"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai\" && npm install --save-dev vitest @vitest/ui 2>&1 | tail -5)",
"Bash(find \"D:\\\\leonohama\\\\AgenciaPsi.com.br\\\\Sistema\\\\agenciapsi-primesakai/DBS\" -name \"*.sql\" -type f 2>/dev/null | head -10)",
"Bash(find \"D:\\\\leonohama\\\\AgenciaPsi.com.br\\\\Sistema\\\\agenciapsi-primesakai/DBS\" -name \"*.sql\" -type f 2>/dev/null | xargs grep -l \"agenda_eventos\" | head -3)",
"Bash(find \"D:\\\\leonohama\\\\AgenciaPsi.com.br\\\\Sistema\\\\agenciapsi-primesakai/DBS/2026-03-11\" -name \"*.sql\" -type f 2>/dev/null | head -3)",
"Bash(find \"D:\\\\leonohama\\\\AgenciaPsi.com.br\\\\Sistema\\\\agenciapsi-primesakai\" -type f -name \"*.sql\" 2>/dev/null | xargs grep -l \"agenda_eventos\" 2>/dev/null | head -5)",
"Bash(find /d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/src -name \"*[Pp]ricing*\" -o -name \"*[Pp]reco*\" -o -name \"*[Vv]alor*\" 2>/dev/null | head -20)"
]
}
}

274
AUDITORIA.md Normal file
View File

@@ -0,0 +1,274 @@
# Auditoria Técnica — AgenciaPsi MVP
**Data:** 2026-03-11
**Stack:** Vue 3 · PrimeVue · Supabase · PostgreSQL · FullCalendar
**Modelo:** claude-sonnet-4-6
---
## 1. Visão Geral Arquitetural
**Pontos fortes:**
- Estrutura feature-based bem definida (`features/agenda`, `features/patients`)
- Separação correta: repository → composable → page
- Multi-tenancy é first-class: `tenant_id` em todos os queries críticos
- Sistema de guards robusto: RBAC + entitlements + tenantFeatures + session race-condition handling
- `useRecurrence` é bem arquitetado: virtual occurrences no frontend + exceções no banco (sem N linhas futuras)
---
## 2. Bugs Críticos
### ✅ [RESOLVIDO] `useRecurrence.js` — variável `occurrenceCount` não declarada
**Bug original:** branches `custom_weekdays`, `monthly` e `yearly` usavam `occurrenceCount` sem declará-la → `ReferenceError` em runtime. Nenhum dos três contava ocorrências anteriores ao range, então `max_occurrences` nunca funcionava corretamente.
**Correção (Sessão 2):** Cada branch ganhou `let occurrenceCount = 0` + fase de pré-contagem de `ruleStart` até `effStart`.
**Resolvido em:** Sessão 2 — 2026-03-11
---
### ✅ [RESOLVIDO] Exceção de remarcação fora do range não aparece
**Bug original:** `loadExceptions` só buscava `original_date` no range. Se `original_date` estivesse fora mas `new_date` caísse dentro, a sessão remarcada não aparecia.
**Correção (Sessão 3):**
- `loadExceptions`: duas queries paralelas — `q1` (original_date no range) + `q2` (reschedule com new_date no range). Mescladas e deduplicadas por `id`.
- `expandRules` post-pass: itera exceções não consumidas (`handledExIds`), injeta inbound reschedules com `buildOccurrence(rule, newDate, ex.original_date, ex)`.
**Resolvido em:** Sessão 3 — 2026-03-11
---
## 3. Segurança
### ✅ [RESOLVIDO] SQL dumps no repositório
**Arquivos:** `schema.sql`, `backup.sql`, `data_dump.sql`, `full_dump.sql`
Verificado via `git log --all --full-history` — arquivos **nunca foram commitados**. Movidos para pasta externa ao repositório. Nenhuma purga de histórico necessária.
**Resolvido em:** Sessão 3 — 2026-03-11
---
### ✅ [RESOLVIDO] `useAgendaEvents` — sem `tenant_id` em nenhuma operação
**Correção (Sessão 2):** `tenant_id` injetado em `create()`, `loadMyRange()`, `update()`, `remove()`, `removeSeriesFrom()`, `removeAllSeries()`. Helpers `assertTenantId()` e `getUid()` adicionados.
**Resolvido em:** Sessão 2 — 2026-03-11
---
### ✅ [RESOLVIDO] `loadRules` em `useRecurrence` sem filtro `tenant_id`
**Correção (Sessão 2):** `loadRules` e `loadAndExpand` aceitam `tenantId` opcional e aplicam `.eq('tenant_id', tenantId)`. Call site em `AgendaTerapeutaPage._reloadRange` passa `tenantStore.activeTenantId`.
**Resolvido em:** Sessão 2 — 2026-03-11
---
### ✅ [RESOLVIDO] `console.log` expõe dados de pacientes no browser
**Correção (Sessão 2):** Todos os `console.*` substituídos pelo `supportLogger`. Logs só aparecem quando modo suporte está ativo (token válido no banco).
**Resolvido em:** Sessão 2 — 2026-03-11
**Arquivos criados:** `src/support/supportLogger.js`, `src/support/supportDebugStore.js`
---
### 🟡 [ABERTO] `window.__guardsBound` / `window.__supabaseAuthListenerBound`
Usar `window.*` para controle de listeners é frágil em hot-reload.
**Solução:** Gerenciar via módulo singleton ou `app.config.globalProperties`.
---
### ✅ [RESOLVIDO] `globalRole` do `profiles` sem cache no guard
Adicionados `globalRoleCacheUid` + `globalRoleCache` no `guards.js`.
Cache invalida em: uid change, SIGNED_OUT, SIGNED_IN com user diferente.
Query ao banco ocorre apenas na primeira navegação por sessão.
**Resolvido em:** Sessão 4 — 2026-03-11
---
## 4. Duplicações e Inconsistências
### ✅ [RESOLVIDO] Dois composables para a mesma entidade
`src/composables/useAgendaEvents.js` era código morto (sem imports). Deletado.
O autoritativo `src/features/agenda/composables/useAgendaEvents.js` permanece.
**Resolvido em:** Sessão 3 — 2026-03-11
---
### ✅ [RESOLVIDO] Dois mappers para agenda
`src/features/agenda/domain/agenda.mappers.js` estava vazio e sem imports. Deletado.
`src/features/agenda/domain/agenda.types.js` também sem imports. Deletado. Diretório `domain/` removido.
O autoritativo `src/features/agenda/services/agendaMappers.js` permanece.
**Resolvido em:** Sessão 4 — 2026-03-11
---
### ✅ [RESOLVIDO] N+1 Query — migração `paciente_id` → `patient_id`
**Resolvido em:** Sessão 3 — 2026-03-11
**Migration executada:** `migrations/unify_patient_id.sql`
- UPDATE copiou `paciente_id``patient_id` onde null (resultado: 0 órfãos — todos já tinham `patient_id`)
- `ALTER TABLE agenda_eventos DROP COLUMN paciente_id` executado com sucesso
**Código atualizado:**
- `useAgendaEvents.js`: `paciente_id` removido do `BASE_SELECT`; `create()`/`update()` stripam `paciente_id` do payload
- `agendaRepository.js`: workaround N+1 de orphan ids removido
- `agendaMappers.js`: `paciente_id` agora é alias de `patient_id` (UI only)
- `AgendaTerapeutaPage.vue` + `AgendaClinicaPage.vue`: `pickDbFields` usa `patient_id`
- `AgendamentosRecebidosPage.vue`: `dbFields` removeu `paciente_id`
- `PatientsListPage.vue` + `AgendaEventDialog.vue`: `.or()``.eq('patient_id', id)`
---
## 5. Limpeza Necessária
### ✅ [RESOLVIDO] Template Sakai removido — bundle de produção
**Resolvido em:** Sessão 3 — 2026-03-11
**Removidos:** `src/views/uikit/` (15 arquivos), `src/views/utilities/Blocks.vue`, `src/components/BlockViewer.vue`, `src/components/FloatingConfigurator.vue`, `src/views/pages/Documentation.vue`, `src/assets/demo/`, `src/navigation/menus/sakai.demo.menu.js`, `src/router/routes.demo.js`, `src/assets/styles.scss` (@use demo removido)
**Referências limpas:** `package.json` renomeado para `agenciapsi`, demoRoutes e sakaiDemoMenu removidos dos index files, `FloatingConfigurator` removido de Login, NotFound, Access, Error, ResetPasswordPage.
---
### 🟡 [PARCIAL] Arquivos obsoletos no projeto
**Deletados (Sessão 4):**
- `src/layout/ConfiguracoesPage-old.vue`
- `src/features/agenda/domain/` (diretório inteiro — 2 arquivos não usados)
**Ainda presentes:**
- `src/layout/ConfiguracoesPage - Copia.vue` — verificar se está no git (staged como D)
- `src/views/pages/public/Landingpage-v1 - bkp.vue`
- `comandos.txt` (na raiz)
---
### ✅ [RESOLVIDO] Logs excessivos em produção
`console.time/timeLog/timeEnd/warn/error` em `guards.js` substituídos por `logGuard()`, `logError()`, `logPerf()`.
**Resolvido em:** Sessão 2 — 2026-03-11
---
## 6. Status das Features do MVP
| Feature | Status | Observação |
|---|---|---|
| Agenda de sessões | ✅ Implementado | FullCalendar + composables |
| Cadastro de pacientes | ✅ Implementado | CRUD completo |
| Recorrência de sessões | ✅ Corrigido | Bugs de occurrenceCount e cross-range resolvidos |
| Sessões presenciais/online | ✅ Implementado | campo `modalidade` |
| Controle de faltas | ✅ Implementado | `exception_type = 'patient_missed'` |
| Remarcação | ✅ Corrigido | Bug cross-range resolvido (Sessão 3) |
| Bloqueio de agenda | ✅ Implementado | `BloqueioDialog.vue` |
| Agendamento online | ✅ Implementado | `AgendadorPublicoPage.vue` |
| Prontuário | ✅ Integrado | Seção "Sessões" adicionada ao `PatientProntuario.vue` |
| Notificações/lembretes | ❌ Não implementado | Sem trigger/edge function |
| Financeiro/faturamento | ⚠️ Parcial | Páginas de plano mas sem sessão→pagamento |
| Relatórios | ✅ Implementado | `RelatoriosPage.vue` — terapeuta — sessões, faltas, taxa, gráfico |
---
## 7. Backlog Técnico
- [ ] Cache de `globalRole` no guard (reduzir queries por navegação)
- [ ] Implementar notificações: WhatsApp/Email via Supabase Edge Functions
- [ ] Integração prontuário ↔ sessões
- [ ] Integração sessão ↔ pagamento (financeiro)
- [ ] Relatórios básicos: sessões realizadas, faltas, receita
- [ ] Migrar domínio de recorrência para TypeScript
- [ ] Consolidar dois mappers de agenda (`agendaMappers.js` vs `domain/agenda.mappers.js`)
- [ ] Remover arquivos obsoletos (ConfiguracoesPage-old, Landingpage-v1 bkp, etc.)
---
## 8. Prioridades de Ação
### ✅ Fazer AGORA — todos concluídos
1. `[x]` ~~Remover dumps SQL~~ → nunca commitados, movidos para fora do repo
2. `[x]` ~~Corrigir bug `occurrenceCount`~~ → pré-contagem em todos os branches
3. `[x]` ~~Adicionar `tenant_id` ao `useAgendaEvents` e `loadRules`~~ → injetado em todas as operações
4. `[x]` ~~Remover `console.log` com dados de pacientes~~`supportLogger`
### ✅ Fazer em seguida — todos concluídos
5. `[x]` ~~Corrigir bug remarcação cross-range~~ → 2 queries + post-pass em `expandRules`
6. `[x]` ~~Consolidar dois `useAgendaEvents`~~ → legado deletado
7. `[x]` ~~Unificar `paciente_id` + `patient_id`~~ → migration executada + código limpo
8. `[x]` ~~Remover Sakai de demo~~ → removido + menu SaaS limpo
### Backlog
9. `[x]` Cache de `globalRole` no guard — `globalRoleCacheUid/globalRoleCache` em guards.js
10. `[ ]` Notificações (WhatsApp/Email via Edge Functions) ← próximo
11. `[x]` Integração prontuário ↔ sessões — seção "Sessões" em `PatientProntuario.vue`
12. `[x]` Relatórios básicos — `RelatoriosPage.vue` em /therapist/relatorios
13. `[x]` Consolidar mappers de agenda — `domain/` deletado, `agendaMappers.js` é único
---
## 9. Sistema de Suporte Técnico SaaS
Sistema seguro para admins SaaS acessarem a agenda de terapeutas em modo debug.
| Arquivo | Responsabilidade |
|---|---|
| `migrations/support_sessions.sql` | Tabela, índices, RLS, RPCs (token gerado via `gen_random_uuid()` duplo — sem pgcrypto) |
| `src/support/supportLogger.js` | Logger centralizado — silencioso fora do modo suporte |
| `src/support/supportDebugStore.js` | Store Pinia — valida token via RPC `validate_support_session` |
| `src/support/supportSessionService.js` | CRUD de sessões de suporte (criar/listar/revogar) |
| `src/support/components/SupportDebugBanner.vue` | Banner fixo na agenda com painel de logs filtráveis |
| `src/views/pages/saas/SaasSupportPage.vue` | Painel SaaS para gerenciar sessões de suporte |
**RPCs no banco:**
- `create_support_session(p_tenant_id, p_ttl_minutes)``{ token, expires_at, session_id }`
- `validate_support_session(p_token)``{ valid, tenant_id }`
- `revoke_support_session(p_token)``boolean`
---
## 10. Histórico de Sessões
### Sessão 1 — 2026-03-11
- Auditoria técnica completa gerada
- Nenhum item resolvido
### Sessão 2 — 2026-03-11
- Sistema de suporte técnico SaaS implementado (migration + 5 arquivos criados)
- Bug `occurrenceCount` corrigido (itens 2 e 4)
- `tenant_id` adicionado ao `useAgendaEvents` e `loadRules` (item 3)
- `console.*` substituídos por `supportLogger`
### Sessão 4 — 2026-03-11
- Cache `globalRole` adicionado ao guard (item 9) — sem query ao banco por navegação
- Integração prontuário ↔ sessões (item 11) — painel "Sessões" em `PatientProntuario.vue`
- `RelatoriosPage.vue` criada em `/therapist/relatorios` (item 12) — cards, gráfico Chart.js, tabela DataTable
- Consolidação mappers (item 13) — `domain/agenda.mappers.js` vazio deletado + `agenda.types.js` + dir `domain/`
- `ConfiguracoesPage-old.vue` deletado (limpeza)
### Sessão 3 — 2026-03-11
- Bug remarcação cross-range resolvido (item 5)
- `logPerf is not defined` em guards.js corrigido
- `pgcrypto` → substituído por `gen_random_uuid()` duplo no support_sessions
- Sakai demo removido completamente (item 8) + `styles.scss` corrigido
- `useAgendaEvents` legado deletado (item 6)
- `paciente_id` unificado em `patient_id` — migration executada (item 7)
- SQL dumps confirmados como nunca commitados (item 1 encerrado)
---
*Para retomar: devolva este arquivo ao início da conversa e indique qual item quer atacar.*

View File

@@ -0,0 +1,414 @@
-- ============================================================
-- SUPERVISOR — Fase 1
-- Aplicar no Supabase SQL Editor (em ordem)
-- ============================================================
-- ────────────────────────────────────────────────────────────
-- 1. tenants.kind → adiciona 'supervisor'
-- ────────────────────────────────────────────────────────────
ALTER TABLE public.tenants
DROP CONSTRAINT IF EXISTS tenants_kind_check;
ALTER TABLE public.tenants
ADD CONSTRAINT tenants_kind_check
CHECK (kind = ANY (ARRAY[
'therapist',
'clinic_coworking',
'clinic_reception',
'clinic_full',
'clinic',
'saas',
'supervisor' -- ← novo
]));
-- ────────────────────────────────────────────────────────────
-- 2. plans.target → adiciona 'supervisor'
-- ────────────────────────────────────────────────────────────
ALTER TABLE public.plans
DROP CONSTRAINT IF EXISTS plans_target_check;
ALTER TABLE public.plans
ADD CONSTRAINT plans_target_check
CHECK (target = ANY (ARRAY[
'patient',
'therapist',
'clinic',
'supervisor' -- ← novo
]));
-- ────────────────────────────────────────────────────────────
-- 3. plans.max_supervisees — limite de supervisionados
-- ────────────────────────────────────────────────────────────
ALTER TABLE public.plans
ADD COLUMN IF NOT EXISTS max_supervisees integer DEFAULT NULL;
COMMENT ON COLUMN public.plans.max_supervisees IS
'Limite de terapeutas que podem ser supervisionados. Apenas para planos target=supervisor. NULL = sem limite.';
-- ────────────────────────────────────────────────────────────
-- 4. Planos supervisor_free e supervisor_pro
-- ────────────────────────────────────────────────────────────
INSERT INTO public.plans (key, name, description, target, is_active, max_supervisees)
VALUES
(
'supervisor_free',
'Supervisor Free',
'Plano gratuito de supervisão. Até 3 terapeutas supervisionados.',
'supervisor',
true,
3
),
(
'supervisor_pro',
'Supervisor PRO',
'Plano profissional de supervisão. Até 20 terapeutas supervisionados.',
'supervisor',
true,
20
)
ON CONFLICT (key) DO UPDATE
SET
name = EXCLUDED.name,
description = EXCLUDED.description,
target = EXCLUDED.target,
is_active = EXCLUDED.is_active,
max_supervisees = EXCLUDED.max_supervisees;
-- ────────────────────────────────────────────────────────────
-- 5. Features de supervisor
-- ────────────────────────────────────────────────────────────
INSERT INTO public.features (key, name, descricao)
VALUES
(
'supervisor.access',
'Acesso à Supervisão',
'Acesso básico ao espaço de supervisão (sala, lista de supervisionados).'
),
(
'supervisor.invite',
'Convidar Supervisionados',
'Permite convidar terapeutas para participar da sala de supervisão.'
),
(
'supervisor.sessions',
'Sessões de Supervisão',
'Agendamento e registro de sessões de supervisão.'
),
(
'supervisor.reports',
'Relatórios de Supervisão',
'Relatórios avançados de progresso e evolução dos supervisionados.'
)
ON CONFLICT (key) DO UPDATE
SET
name = EXCLUDED.name,
descricao = EXCLUDED.descricao;
-- ────────────────────────────────────────────────────────────
-- 6. plan_features — vincula features aos planos supervisor
-- ────────────────────────────────────────────────────────────
DO $$
DECLARE
v_free_id uuid;
v_pro_id uuid;
v_f_access uuid;
v_f_invite uuid;
v_f_sessions uuid;
v_f_reports uuid;
BEGIN
SELECT id INTO v_free_id FROM public.plans WHERE key = 'supervisor_free';
SELECT id INTO v_pro_id FROM public.plans WHERE key = 'supervisor_pro';
SELECT id INTO v_f_access FROM public.features WHERE key = 'supervisor.access';
SELECT id INTO v_f_invite FROM public.features WHERE key = 'supervisor.invite';
SELECT id INTO v_f_sessions FROM public.features WHERE key = 'supervisor.sessions';
SELECT id INTO v_f_reports FROM public.features WHERE key = 'supervisor.reports';
-- supervisor_free: access + invite (limitado por max_supervisees=3)
INSERT INTO public.plan_features (plan_id, feature_id)
VALUES
(v_free_id, v_f_access),
(v_free_id, v_f_invite)
ON CONFLICT DO NOTHING;
-- supervisor_pro: tudo
INSERT INTO public.plan_features (plan_id, feature_id)
VALUES
(v_pro_id, v_f_access),
(v_pro_id, v_f_invite),
(v_pro_id, v_f_sessions),
(v_pro_id, v_f_reports)
ON CONFLICT DO NOTHING;
END;
$$;
-- ────────────────────────────────────────────────────────────
-- 7. activate_subscription_from_intent — suporte a supervisor
-- Supervisor = pessoal (user_id), sem tenant_id (igual therapist)
-- ────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.activate_subscription_from_intent(p_intent_id uuid)
RETURNS public.subscriptions
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
v_intent record;
v_sub public.subscriptions;
v_days int;
v_user_id uuid;
v_plan_id uuid;
v_target text;
begin
-- lê pela VIEW unificada
select * into v_intent
from public.subscription_intents
where id = p_intent_id;
if not found then
raise exception 'Intent não encontrado: %', p_intent_id;
end if;
if v_intent.status <> 'paid' then
raise exception 'Intent precisa estar paid para ativar assinatura';
end if;
-- resolve target e plan_id via plans.key
select p.id, p.target
into v_plan_id, v_target
from public.plans p
where p.key = v_intent.plan_key
limit 1;
if v_plan_id is null then
raise exception 'Plano não encontrado em plans.key = %', v_intent.plan_key;
end if;
v_target := lower(coalesce(v_target, ''));
-- ✅ supervisor adicionado
if v_target not in ('clinic', 'therapist', 'supervisor') then
raise exception 'Target inválido em plans.target: %', v_target;
end if;
-- regra por target
if v_target = 'clinic' then
if v_intent.tenant_id is null then
raise exception 'Intent sem tenant_id';
end if;
else
-- therapist ou supervisor: vinculado ao user
v_user_id := v_intent.user_id;
if v_user_id is null then
v_user_id := v_intent.created_by_user_id;
end if;
end if;
if v_target in ('therapist', 'supervisor') and v_user_id is null then
raise exception 'Não foi possível determinar user_id para assinatura %.', v_target;
end if;
-- cancela assinatura ativa anterior
if v_target = 'clinic' then
update public.subscriptions
set status = 'cancelled',
cancelled_at = now()
where tenant_id = v_intent.tenant_id
and plan_id = v_plan_id
and status = 'active';
else
-- therapist ou supervisor
update public.subscriptions
set status = 'cancelled',
cancelled_at = now()
where user_id = v_user_id
and plan_id = v_plan_id
and status = 'active'
and tenant_id is null;
end if;
-- duração do plano (30 dias para mensal)
v_days := case
when lower(coalesce(v_intent.interval, 'month')) = 'year' then 365
else 30
end;
-- cria nova assinatura
insert into public.subscriptions (
user_id,
plan_id,
status,
started_at,
expires_at,
cancelled_at,
activated_at,
tenant_id,
plan_key,
interval,
source,
created_at,
updated_at
)
values (
case when v_target = 'clinic' then null else v_user_id end,
v_plan_id,
'active',
now(),
now() + make_interval(days => v_days),
null,
now(),
case when v_target = 'clinic' then v_intent.tenant_id else null end,
v_intent.plan_key,
v_intent.interval,
'manual',
now(),
now()
)
returning * into v_sub;
-- grava vínculo intent → subscription
if v_target = 'clinic' then
update public.subscription_intents_tenant
set subscription_id = v_sub.id
where id = p_intent_id;
else
update public.subscription_intents_personal
set subscription_id = v_sub.id
where id = p_intent_id;
end if;
return v_sub;
end;
$$;
-- ────────────────────────────────────────────────────────────
-- 8. subscriptions_validate_scope — suporte a supervisor
-- ────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.subscriptions_validate_scope()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_target text;
BEGIN
SELECT lower(p.target) INTO v_target
FROM public.plans p
WHERE p.id = NEW.plan_id;
IF v_target IS NULL THEN
RAISE EXCEPTION 'Plano inválido (target nulo).';
END IF;
IF v_target = 'clinic' THEN
IF NEW.tenant_id IS NULL THEN
RAISE EXCEPTION 'Assinatura clinic exige tenant_id.';
END IF;
IF NEW.user_id IS NOT NULL THEN
RAISE EXCEPTION 'Assinatura clinic não pode ter user_id (XOR).';
END IF;
ELSIF v_target IN ('therapist', 'supervisor') THEN
-- supervisor é pessoal como therapist
IF NEW.tenant_id IS NOT NULL THEN
RAISE EXCEPTION 'Assinatura % não deve ter tenant_id.', v_target;
END IF;
IF NEW.user_id IS NULL THEN
RAISE EXCEPTION 'Assinatura % exige user_id.', v_target;
END IF;
ELSIF v_target = 'patient' THEN
IF NEW.tenant_id IS NOT NULL THEN
RAISE EXCEPTION 'Assinatura patient não deve ter tenant_id.';
END IF;
IF NEW.user_id IS NULL THEN
RAISE EXCEPTION 'Assinatura patient exige user_id.';
END IF;
ELSE
RAISE EXCEPTION 'Target de plano inválido: %', v_target;
END IF;
RETURN NEW;
END;
$$;
-- ────────────────────────────────────────────────────────────
-- 9. subscription_intents_view_insert — suporte a supervisor
-- supervisor é roteado como therapist (tabela personal)
-- ────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.subscription_intents_view_insert()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
v_target text;
v_plan_id uuid;
begin
select p.id, p.target into v_plan_id, v_target
from public.plans p
where p.key = new.plan_key;
if v_plan_id is null then
raise exception 'Plano inválido: plan_key=%', new.plan_key;
end if;
if lower(v_target) = 'clinic' then
if new.tenant_id is null then
raise exception 'Intenção clinic exige tenant_id.';
end if;
insert into public.subscription_intents_tenant (
id, tenant_id, created_by_user_id, email,
plan_id, plan_key, interval, amount_cents, currency,
status, source, notes, created_at, paid_at
) values (
coalesce(new.id, gen_random_uuid()),
new.tenant_id, new.created_by_user_id, new.email,
v_plan_id, new.plan_key, coalesce(new.interval,'month'),
new.amount_cents, coalesce(new.currency,'BRL'),
coalesce(new.status,'pending'), coalesce(new.source,'manual'),
new.notes, coalesce(new.created_at, now()), new.paid_at
);
new.plan_target := 'clinic';
return new;
end if;
-- therapist ou supervisor → tabela personal
if lower(v_target) in ('therapist', 'supervisor') then
insert into public.subscription_intents_personal (
id, user_id, created_by_user_id, email,
plan_id, plan_key, interval, amount_cents, currency,
status, source, notes, created_at, paid_at
) values (
coalesce(new.id, gen_random_uuid()),
new.user_id, new.created_by_user_id, new.email,
v_plan_id, new.plan_key, coalesce(new.interval,'month'),
new.amount_cents, coalesce(new.currency,'BRL'),
coalesce(new.status,'pending'), coalesce(new.source,'manual'),
new.notes, coalesce(new.created_at, now()), new.paid_at
);
new.plan_target := lower(v_target); -- 'therapist' ou 'supervisor'
return new;
end if;
raise exception 'Target de plano não suportado: %', v_target;
end;
$$;
-- ────────────────────────────────────────────────────────────
-- FIM — verificação rápida
-- ────────────────────────────────────────────────────────────
SELECT key, name, target, max_supervisees
FROM public.plans
WHERE target = 'supervisor'
ORDER BY key;

View File

@@ -0,0 +1,220 @@
-- =============================================================================
-- FIX: Atribuir plano free a usuários/tenants sem assinatura ativa
-- =============================================================================
-- Execute no SQL Editor do Supabase (service_role)
-- Idempotente: só insere onde não existe assinatura ativa.
--
-- Regras:
-- • tenant kind = 'therapist' → therapist_free (por user_id do admin)
-- • tenant kind IN (clinic_*) → clinic_free (por tenant_id)
-- • profiles.account_type = 'patient' / portal_user → patient_free (por user_id)
-- =============================================================================
BEGIN;
-- ──────────────────────────────────────────────────────────────────────────────
-- DIAGNÓSTICO — mostra o estado atual antes de corrigir
-- ──────────────────────────────────────────────────────────────────────────────
DO $$
DECLARE
r RECORD;
BEGIN
RAISE NOTICE '=== DIAGNÓSTICO DE ASSINATURAS ===';
RAISE NOTICE '';
-- Terapeutas sem plano
RAISE NOTICE '--- Terapeutas SEM assinatura ativa ---';
FOR r IN
SELECT
tm.user_id,
p.full_name,
t.id AS tenant_id,
t.name AS tenant_name
FROM public.tenant_members tm
JOIN public.tenants t ON t.id = tm.tenant_id
JOIN public.profiles p ON p.id = tm.user_id
WHERE t.kind = 'therapist'
AND tm.role = 'tenant_admin'
AND tm.status = 'active'
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.user_id = tm.user_id
AND s.status = 'active'
)
LOOP
RAISE NOTICE ' FALTANDO: % (%) — tenant %', r.full_name, r.user_id, r.tenant_id;
END LOOP;
-- Clínicas sem plano
RAISE NOTICE '';
RAISE NOTICE '--- Clínicas SEM assinatura ativa ---';
FOR r IN
SELECT t.id, t.name, t.kind
FROM public.tenants t
WHERE t.kind IN ('clinic_coworking', 'clinic_reception', 'clinic_full', 'clinic')
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.tenant_id = t.id
AND s.status = 'active'
)
LOOP
RAISE NOTICE ' FALTANDO: % (%) — kind %', r.name, r.id, r.kind;
END LOOP;
-- Pacientes sem plano
RAISE NOTICE '';
RAISE NOTICE '--- Pacientes SEM assinatura ativa ---';
FOR r IN
SELECT p.id, p.full_name
FROM public.profiles p
WHERE p.account_type = 'patient'
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.user_id = p.id
AND s.status = 'active'
)
LOOP
RAISE NOTICE ' FALTANDO: % (%)', r.full_name, r.id;
END LOOP;
RAISE NOTICE '';
RAISE NOTICE '=== FIM DO DIAGNÓSTICO — aplicando correções... ===';
END;
$$;
-- ──────────────────────────────────────────────────────────────────────────────
-- CORREÇÃO 1: Terapeutas sem assinatura → therapist_free
-- Escopo: user_id do tenant_admin do tenant kind='therapist'
-- ──────────────────────────────────────────────────────────────────────────────
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
tm.user_id,
p.id,
p.key,
'active',
'month',
now(),
now() + interval '30 days',
'fix_seed',
now(),
now()
FROM public.tenant_members tm
JOIN public.tenants t ON t.id = tm.tenant_id
JOIN public.plans p ON p.key = 'therapist_free'
WHERE t.kind = 'therapist'
AND tm.role = 'tenant_admin'
AND tm.status = 'active'
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.user_id = tm.user_id
AND s.status = 'active'
);
-- ──────────────────────────────────────────────────────────────────────────────
-- CORREÇÃO 2: Clínicas sem assinatura → clinic_free
-- Escopo: tenant_id
-- ──────────────────────────────────────────────────────────────────────────────
INSERT INTO public.subscriptions (
tenant_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
t.id,
p.id,
p.key,
'active',
'month',
now(),
now() + interval '30 days',
'fix_seed',
now(),
now()
FROM public.tenants t
JOIN public.plans p ON p.key = 'clinic_free'
WHERE t.kind IN ('clinic_coworking', 'clinic_reception', 'clinic_full', 'clinic')
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.tenant_id = t.id
AND s.status = 'active'
);
-- ──────────────────────────────────────────────────────────────────────────────
-- CORREÇÃO 3: Pacientes sem assinatura → patient_free
-- Escopo: user_id
-- ──────────────────────────────────────────────────────────────────────────────
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
pr.id,
p.id,
p.key,
'active',
'month',
now(),
now() + interval '30 days',
'fix_seed',
now(),
now()
FROM public.profiles pr
JOIN public.plans p ON p.key = 'patient_free'
WHERE pr.account_type = 'patient'
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.user_id = pr.id
AND s.status = 'active'
);
-- ──────────────────────────────────────────────────────────────────────────────
-- CONFIRMAÇÃO — mostra o que foi inserido (source = 'fix_seed')
-- ──────────────────────────────────────────────────────────────────────────────
DO $$
DECLARE
r RECORD;
total INT := 0;
BEGIN
RAISE NOTICE '';
RAISE NOTICE '=== ASSINATURAS CRIADAS NESTA EXECUÇÃO ===';
FOR r IN
SELECT
s.plan_key,
COALESCE(pr.full_name, t.name) AS nome,
COALESCE(s.user_id::text, s.tenant_id::text) AS owner_id
FROM public.subscriptions s
LEFT JOIN public.profiles pr ON pr.id = s.user_id
LEFT JOIN public.tenants t ON t.id = s.tenant_id
WHERE s.source = 'fix_seed'
AND s.started_at >= now() - interval '5 seconds'
ORDER BY s.plan_key, nome
LOOP
RAISE NOTICE ' ✅ % → % (%)', r.plan_key, r.nome, r.owner_id;
total := total + 1;
END LOOP;
IF total = 0 THEN
RAISE NOTICE ' (nenhuma nova assinatura criada — todos já tinham plano ativo)';
ELSE
RAISE NOTICE '';
RAISE NOTICE ' Total: % assinatura(s) criada(s).', total;
END IF;
END;
$$;
COMMIT;

View File

@@ -0,0 +1,50 @@
-- Fix: subscriptions_validate_scope — adiciona suporte a target='patient'
CREATE OR REPLACE FUNCTION public.subscriptions_validate_scope()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_target text;
BEGIN
SELECT lower(p.target) INTO v_target
FROM public.plans p
WHERE p.id = NEW.plan_id;
IF v_target IS NULL THEN
RAISE EXCEPTION 'Plano inválido (target nulo).';
END IF;
IF v_target = 'clinic' THEN
IF NEW.tenant_id IS NULL THEN
RAISE EXCEPTION 'Assinatura clinic exige tenant_id.';
END IF;
IF NEW.user_id IS NOT NULL THEN
RAISE EXCEPTION 'Assinatura clinic não pode ter user_id (XOR).';
END IF;
ELSIF v_target = 'therapist' THEN
IF NEW.tenant_id IS NOT NULL THEN
RAISE EXCEPTION 'Assinatura therapist não deve ter tenant_id.';
END IF;
IF NEW.user_id IS NULL THEN
RAISE EXCEPTION 'Assinatura therapist exige user_id.';
END IF;
ELSIF v_target = 'patient' THEN
IF NEW.tenant_id IS NOT NULL THEN
RAISE EXCEPTION 'Assinatura patient não deve ter tenant_id.';
END IF;
IF NEW.user_id IS NULL THEN
RAISE EXCEPTION 'Assinatura patient exige user_id.';
END IF;
ELSE
RAISE EXCEPTION 'Target de plano inválido: %', v_target;
END IF;
RETURN NEW;
END;
$$;
ALTER FUNCTION public.subscriptions_validate_scope() OWNER TO supabase_admin;

View File

@@ -0,0 +1,296 @@
-- =============================================================================
-- SEED 001 — Usuários fictícios para teste
-- =============================================================================
-- Execute APÓS migration_001.sql
--
-- Usuários criados:
-- paciente@agenciapsi.com.br senha: Teste@123 → patient
-- terapeuta@agenciapsi.com.br senha: Teste@123 → therapist
-- clinica1@agenciapsi.com.br senha: Teste@123 → clinic_coworking
-- clinica2@agenciapsi.com.br senha: Teste@123 → clinic_reception
-- clinica3@agenciapsi.com.br senha: Teste@123 → clinic_full
-- saas@agenciapsi.com.br senha: Teste@123 → saas_admin
-- =============================================================================
-- ============================================================
-- Limpeza de seeds anteriores
-- ============================================================
ALTER TABLE public.patient_groups DISABLE TRIGGER ALL;
DELETE FROM public.tenant_members
WHERE user_id IN (
SELECT id FROM auth.users
WHERE email IN (
'paciente@agenciapsi.com.br',
'terapeuta@agenciapsi.com.br',
'clinica1@agenciapsi.com.br',
'clinica2@agenciapsi.com.br',
'clinica3@agenciapsi.com.br',
'saas@agenciapsi.com.br'
)
);
DELETE FROM public.tenants WHERE id IN (
'bbbbbbbb-0002-0002-0002-000000000002',
'bbbbbbbb-0003-0003-0003-000000000003',
'bbbbbbbb-0004-0004-0004-000000000004',
'bbbbbbbb-0005-0005-0005-000000000005'
);
DELETE FROM auth.users WHERE email IN (
'paciente@agenciapsi.com.br',
'terapeuta@agenciapsi.com.br',
'clinica1@agenciapsi.com.br',
'clinica2@agenciapsi.com.br',
'clinica3@agenciapsi.com.br',
'saas@agenciapsi.com.br'
);
ALTER TABLE public.patient_groups ENABLE TRIGGER ALL;
-- ============================================================
-- 1. Usuários no auth.users
-- ============================================================
INSERT INTO auth.users (
id, email, encrypted_password, email_confirmed_at,
created_at, updated_at, raw_user_meta_data, role, aud
)
VALUES
(
'aaaaaaaa-0001-0001-0001-000000000001',
'paciente@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Ana Paciente"}'::jsonb,
'authenticated', 'authenticated'
),
(
'aaaaaaaa-0002-0002-0002-000000000002',
'terapeuta@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Bruno Terapeuta"}'::jsonb,
'authenticated', 'authenticated'
),
(
'aaaaaaaa-0003-0003-0003-000000000003',
'clinica1@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Clinica Espaco Psi"}'::jsonb,
'authenticated', 'authenticated'
),
(
'aaaaaaaa-0004-0004-0004-000000000004',
'clinica2@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Clinica Mente Sa"}'::jsonb,
'authenticated', 'authenticated'
),
(
'aaaaaaaa-0005-0005-0005-000000000005',
'clinica3@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Clinica Bem Estar"}'::jsonb,
'authenticated', 'authenticated'
),
(
'aaaaaaaa-0006-0006-0006-000000000006',
'saas@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Admin Plataforma"}'::jsonb,
'authenticated', 'authenticated'
);
-- ============================================================
-- 2. Profiles
-- ============================================================
INSERT INTO public.profiles (id, role, account_type, full_name)
VALUES
('aaaaaaaa-0001-0001-0001-000000000001', 'portal_user', 'patient', 'Ana Paciente'),
('aaaaaaaa-0002-0002-0002-000000000002', 'portal_user', 'therapist', 'Bruno Terapeuta'),
('aaaaaaaa-0003-0003-0003-000000000003', 'portal_user', 'clinic', 'Clinica Espaco Psi'),
('aaaaaaaa-0004-0004-0004-000000000004', 'portal_user', 'clinic', 'Clinica Mente Sa'),
('aaaaaaaa-0005-0005-0005-000000000005', 'portal_user', 'clinic', 'Clinica Bem Estar'),
('aaaaaaaa-0006-0006-0006-000000000006', 'saas_admin', 'free', 'Admin Plataforma')
ON CONFLICT (id) DO UPDATE SET
role = EXCLUDED.role,
account_type = EXCLUDED.account_type,
full_name = EXCLUDED.full_name;
-- ============================================================
-- 3. SaaS Admin
-- ============================================================
INSERT INTO public.saas_admins (user_id, created_at)
VALUES ('aaaaaaaa-0006-0006-0006-000000000006', now())
ON CONFLICT (user_id) DO NOTHING;
-- ============================================================
-- 4. Tenant do terapeuta
-- ============================================================
INSERT INTO public.tenants (id, name, kind, created_at)
VALUES ('bbbbbbbb-0002-0002-0002-000000000002', 'Bruno Terapeuta', 'therapist', now())
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES ('bbbbbbbb-0002-0002-0002-000000000002', 'aaaaaaaa-0002-0002-0002-000000000002', 'tenant_admin', 'active', now())
ON CONFLICT (tenant_id, user_id) DO NOTHING;
DO $$ BEGIN
PERFORM public.seed_determined_commitments('bbbbbbbb-0002-0002-0002-000000000002');
END; $$;
-- ============================================================
-- 5. Tenant Clinica 1 — Coworking
-- ============================================================
INSERT INTO public.tenants (id, name, kind, created_at)
VALUES ('bbbbbbbb-0003-0003-0003-000000000003', 'Clinica Espaco Psi', 'clinic_coworking', now())
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES ('bbbbbbbb-0003-0003-0003-000000000003', 'aaaaaaaa-0003-0003-0003-000000000003', 'tenant_admin', 'active', now())
ON CONFLICT (tenant_id, user_id) DO NOTHING;
DO $$ BEGIN
PERFORM public.seed_determined_commitments('bbbbbbbb-0003-0003-0003-000000000003');
END; $$;
-- ============================================================
-- 6. Tenant Clinica 2 — Recepcao
-- ============================================================
INSERT INTO public.tenants (id, name, kind, created_at)
VALUES ('bbbbbbbb-0004-0004-0004-000000000004', 'Clinica Mente Sa', 'clinic_reception', now())
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES ('bbbbbbbb-0004-0004-0004-000000000004', 'aaaaaaaa-0004-0004-0004-000000000004', 'tenant_admin', 'active', now())
ON CONFLICT (tenant_id, user_id) DO NOTHING;
DO $$ BEGIN
PERFORM public.seed_determined_commitments('bbbbbbbb-0004-0004-0004-000000000004');
END; $$;
-- ============================================================
-- 7. Tenant Clinica 3 — Full
-- ============================================================
INSERT INTO public.tenants (id, name, kind, created_at)
VALUES ('bbbbbbbb-0005-0005-0005-000000000005', 'Clinica Bem Estar', 'clinic_full', now())
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES ('bbbbbbbb-0005-0005-0005-000000000005', 'aaaaaaaa-0005-0005-0005-000000000005', 'tenant_admin', 'active', now())
ON CONFLICT (tenant_id, user_id) DO NOTHING;
DO $$ BEGIN
PERFORM public.seed_determined_commitments('bbbbbbbb-0005-0005-0005-000000000005');
END; $$;
-- ============================================================
-- 8. Subscriptions ativas
-- ============================================================
-- Paciente → patient_free
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end, source, started_at, activated_at
)
SELECT
'aaaaaaaa-0001-0001-0001-000000000001',
p.id, p.key, 'active', 'month',
now(), now() + interval '30 days', 'seed', now(), now()
FROM public.plans p WHERE p.key = 'patient_free';
-- Terapeuta → therapist_free
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end, source, started_at, activated_at
)
SELECT
'aaaaaaaa-0002-0002-0002-000000000002',
p.id, p.key, 'active', 'month',
now(), now() + interval '30 days', 'seed', now(), now()
FROM public.plans p WHERE p.key = 'therapist_free';
-- Clinica 1 → clinic_free
INSERT INTO public.subscriptions (
tenant_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end, source, started_at, activated_at
)
SELECT
'bbbbbbbb-0003-0003-0003-000000000003',
p.id, p.key, 'active', 'month',
now(), now() + interval '30 days', 'seed', now(), now()
FROM public.plans p WHERE p.key = 'clinic_free';
-- Clinica 2 → clinic_free
INSERT INTO public.subscriptions (
tenant_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end, source, started_at, activated_at
)
SELECT
'bbbbbbbb-0004-0004-0004-000000000004',
p.id, p.key, 'active', 'month',
now(), now() + interval '30 days', 'seed', now(), now()
FROM public.plans p WHERE p.key = 'clinic_free';
-- Clinica 3 → clinic_free
INSERT INTO public.subscriptions (
tenant_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end, source, started_at, activated_at
)
SELECT
'bbbbbbbb-0005-0005-0005-000000000005',
p.id, p.key, 'active', 'month',
now(), now() + interval '30 days', 'seed', now(), now()
FROM public.plans p WHERE p.key = 'clinic_free';
-- ============================================================
-- 9. Vincula terapeuta à Clinica 3 (exemplo de associacao)
-- ============================================================
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES (
'bbbbbbbb-0005-0005-0005-000000000005',
'aaaaaaaa-0002-0002-0002-000000000002',
'therapist', 'active', now()
)
ON CONFLICT (tenant_id, user_id) DO NOTHING;
-- ============================================================
-- Confirmacao
-- ============================================================
DO $$
BEGIN
RAISE NOTICE '✅ Seed aplicado com sucesso.';
RAISE NOTICE ' paciente@agenciapsi.com.br → patient';
RAISE NOTICE ' terapeuta@agenciapsi.com.br → therapist';
RAISE NOTICE ' clinica1@agenciapsi.com.br → clinic_coworking';
RAISE NOTICE ' clinica2@agenciapsi.com.br → clinic_reception';
RAISE NOTICE ' clinica3@agenciapsi.com.br → clinic_full';
RAISE NOTICE ' saas@agenciapsi.com.br → saas_admin';
RAISE NOTICE ' Senha: Teste@123';
END;
$$;

View File

@@ -0,0 +1,13 @@
-- =============================================================================
-- Migration 002 — Adiciona layout_variant em user_settings
-- =============================================================================
-- Execute no SQL Editor do Supabase (ou via Docker psql).
-- Tolerante: usa IF NOT EXISTS / DEFAULT para não quebrar dados existentes.
-- =============================================================================
ALTER TABLE public.user_settings
ADD COLUMN IF NOT EXISTS layout_variant TEXT NOT NULL DEFAULT 'classic';
-- =============================================================================
RAISE NOTICE '✅ Coluna layout_variant adicionada a user_settings.';
-- =============================================================================

View File

@@ -0,0 +1,334 @@
-- =============================================================================
-- SEED — Usuários fictícios para teste
-- =============================================================================
-- IMPORTANTE: Execute APÓS a migration_001.sql
-- IMPORTANTE: Requer extensão pgcrypto (já ativa no Supabase)
--
-- Cria os seguintes usuários de teste:
--
-- paciente@agenciapsi.com.br senha: Teste@123 → paciente
-- terapeuta@agenciapsi.com.br senha: Teste@123 → terapeuta solo
-- clinica1@agenciapsi.com.br senha: Teste@123 → clínica coworking
-- clinica2@agenciapsi.com.br senha: Teste@123 → clínica com secretaria
-- clinica3@agenciapsi.com.br senha: Teste@123 → clínica full
-- saas@agenciapsi.com.br senha: Teste@123 → admin da plataforma
--
-- =============================================================================
BEGIN;
-- ============================================================
-- Helper: cria usuário no auth.users + profile
-- (Supabase não expõe auth.users diretamente, mas em SQL Editor
-- com acesso de service_role podemos inserir diretamente)
-- ============================================================
-- Limpa seeds anteriores se existirem
DELETE FROM auth.users
WHERE email IN (
'paciente@agenciapsi.com.br',
'terapeuta@agenciapsi.com.br',
'clinica1@agenciapsi.com.br',
'clinica2@agenciapsi.com.br',
'clinica3@agenciapsi.com.br',
'saas@agenciapsi.com.br'
);
-- ============================================================
-- 1. Cria usuários no auth.users
-- ============================================================
INSERT INTO auth.users (
instance_id,
id,
email,
encrypted_password,
email_confirmed_at,
confirmed_at,
created_at,
updated_at,
raw_user_meta_data,
raw_app_meta_data,
role,
aud,
is_sso_user,
is_anonymous,
confirmation_token,
recovery_token,
email_change_token_new,
email_change_token_current,
email_change
)
VALUES
-- Paciente
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0001-0001-0001-000000000001',
'paciente@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(), now(),
'{"name": "Ana Paciente"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
),
-- Terapeuta
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0002-0002-0002-000000000002',
'terapeuta@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(), now(),
'{"name": "Bruno Terapeuta"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
),
-- Clínica 1 — Coworking
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0003-0003-0003-000000000003',
'clinica1@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(), now(),
'{"name": "Clínica Espaço Psi"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
),
-- Clínica 2 — Recepção
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0004-0004-0004-000000000004',
'clinica2@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(), now(),
'{"name": "Clínica Mente Sã"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
),
-- Clínica 3 — Full
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0005-0005-0005-000000000005',
'clinica3@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(), now(),
'{"name": "Clínica Bem Estar"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
),
-- SaaS Admin
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0006-0006-0006-000000000006',
'saas@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(), now(),
'{"name": "Admin Plataforma"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
);
-- auth.identities (obrigatório para GoTrue reconhecer login email/senha)
INSERT INTO auth.identities (id, user_id, provider_id, provider, identity_data, created_at, updated_at, last_sign_in_at)
VALUES
(gen_random_uuid(), 'aaaaaaaa-0001-0001-0001-000000000001', 'paciente@agenciapsi.com.br', 'email', '{"sub": "aaaaaaaa-0001-0001-0001-000000000001", "email": "paciente@agenciapsi.com.br", "email_verified": true}'::jsonb, now(), now(), now()),
(gen_random_uuid(), 'aaaaaaaa-0002-0002-0002-000000000002', 'terapeuta@agenciapsi.com.br', 'email', '{"sub": "aaaaaaaa-0002-0002-0002-000000000002", "email": "terapeuta@agenciapsi.com.br", "email_verified": true}'::jsonb, now(), now(), now()),
(gen_random_uuid(), 'aaaaaaaa-0003-0003-0003-000000000003', 'clinica1@agenciapsi.com.br', 'email', '{"sub": "aaaaaaaa-0003-0003-0003-000000000003", "email": "clinica1@agenciapsi.com.br", "email_verified": true}'::jsonb, now(), now(), now()),
(gen_random_uuid(), 'aaaaaaaa-0004-0004-0004-000000000004', 'clinica2@agenciapsi.com.br', 'email', '{"sub": "aaaaaaaa-0004-0004-0004-000000000004", "email": "clinica2@agenciapsi.com.br", "email_verified": true}'::jsonb, now(), now(), now()),
(gen_random_uuid(), 'aaaaaaaa-0005-0005-0005-000000000005', 'clinica3@agenciapsi.com.br', 'email', '{"sub": "aaaaaaaa-0005-0005-0005-000000000005", "email": "clinica3@agenciapsi.com.br", "email_verified": true}'::jsonb, now(), now(), now()),
(gen_random_uuid(), 'aaaaaaaa-0006-0006-0006-000000000006', 'saas@agenciapsi.com.br', 'email', '{"sub": "aaaaaaaa-0006-0006-0006-000000000006", "email": "saas@agenciapsi.com.br", "email_verified": true}'::jsonb, now(), now(), now())
ON CONFLICT (provider, provider_id) DO NOTHING;
-- ============================================================
-- 2. Profiles (o trigger handle_new_user não dispara em inserts
-- diretos no auth.users via SQL, então criamos manualmente)
-- ============================================================
INSERT INTO public.profiles (id, role, account_type, full_name)
VALUES
('aaaaaaaa-0001-0001-0001-000000000001', 'portal_user', 'patient', 'Ana Paciente'),
('aaaaaaaa-0002-0002-0002-000000000002', 'tenant_member', 'therapist', 'Bruno Terapeuta'),
('aaaaaaaa-0003-0003-0003-000000000003', 'tenant_member', 'clinic', 'Clínica Espaço Psi'),
('aaaaaaaa-0004-0004-0004-000000000004', 'tenant_member', 'clinic', 'Clínica Mente Sã'),
('aaaaaaaa-0005-0005-0005-000000000005', 'tenant_member', 'clinic', 'Clínica Bem Estar'),
('aaaaaaaa-0006-0006-0006-000000000006', 'saas_admin', 'free', 'Admin Plataforma')
ON CONFLICT (id) DO UPDATE SET
role = EXCLUDED.role,
account_type = EXCLUDED.account_type,
full_name = EXCLUDED.full_name;
-- ============================================================
-- 3. SaaS Admin na tabela saas_admins
-- ============================================================
INSERT INTO public.saas_admins (user_id, created_at)
VALUES ('aaaaaaaa-0006-0006-0006-000000000006', now())
ON CONFLICT (user_id) DO NOTHING;
-- ============================================================
-- 4. Tenant do terapeuta
-- ============================================================
INSERT INTO public.tenants (id, name, kind, created_at)
VALUES ('bbbbbbbb-0002-0002-0002-000000000002', 'Bruno Terapeuta', 'therapist', now())
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES ('bbbbbbbb-0002-0002-0002-000000000002', 'aaaaaaaa-0002-0002-0002-000000000002', 'tenant_admin', 'active', now())
ON CONFLICT (tenant_id, user_id) DO NOTHING;
DO $$ BEGIN PERFORM public.seed_determined_commitments('bbbbbbbb-0002-0002-0002-000000000002'); END $$;
-- ============================================================
-- 5. Tenant da Clínica 1 — Coworking
-- ============================================================
INSERT INTO public.tenants (id, name, kind, created_at)
VALUES ('bbbbbbbb-0003-0003-0003-000000000003', 'Clínica Espaço Psi', 'clinic_coworking', now())
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES ('bbbbbbbb-0003-0003-0003-000000000003', 'aaaaaaaa-0003-0003-0003-000000000003', 'tenant_admin', 'active', now())
ON CONFLICT (tenant_id, user_id) DO NOTHING;
DO $$ BEGIN PERFORM public.seed_determined_commitments('bbbbbbbb-0003-0003-0003-000000000003'); END $$;
-- ============================================================
-- 6. Tenant da Clínica 2 — Recepção
-- ============================================================
INSERT INTO public.tenants (id, name, kind, created_at)
VALUES ('bbbbbbbb-0004-0004-0004-000000000004', 'Clínica Mente Sã', 'clinic_reception', now())
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES ('bbbbbbbb-0004-0004-0004-000000000004', 'aaaaaaaa-0004-0004-0004-000000000004', 'tenant_admin', 'active', now())
ON CONFLICT (tenant_id, user_id) DO NOTHING;
DO $$ BEGIN PERFORM public.seed_determined_commitments('bbbbbbbb-0004-0004-0004-000000000004'); END $$;
-- ============================================================
-- 7. Tenant da Clínica 3 — Full
-- ============================================================
INSERT INTO public.tenants (id, name, kind, created_at)
VALUES ('bbbbbbbb-0005-0005-0005-000000000005', 'Clínica Bem Estar', 'clinic_full', now())
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES ('bbbbbbbb-0005-0005-0005-000000000005', 'aaaaaaaa-0005-0005-0005-000000000005', 'tenant_admin', 'active', now())
ON CONFLICT (tenant_id, user_id) DO NOTHING;
DO $$ BEGIN PERFORM public.seed_determined_commitments('bbbbbbbb-0005-0005-0005-000000000005'); END $$;
-- ============================================================
-- 8. Subscriptions ativas para cada conta
-- ============================================================
-- Terapeuta → plano therapist_free
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'aaaaaaaa-0002-0002-0002-000000000002',
p.id, p.key, 'active', 'month',
now(), now() + interval '30 days',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'therapist_free';
-- Clínica 1 → plano clinic_free
INSERT INTO public.subscriptions (
tenant_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'bbbbbbbb-0003-0003-0003-000000000003',
p.id, p.key, 'active', 'month',
now(), now() + interval '30 days',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'clinic_free';
-- Clínica 2 → plano clinic_free
INSERT INTO public.subscriptions (
tenant_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'bbbbbbbb-0004-0004-0004-000000000004',
p.id, p.key, 'active', 'month',
now(), now() + interval '30 days',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'clinic_free';
-- Clínica 3 → plano clinic_free
INSERT INTO public.subscriptions (
tenant_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'bbbbbbbb-0005-0005-0005-000000000005',
p.id, p.key, 'active', 'month',
now(), now() + interval '30 days',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'clinic_free';
-- Paciente → plano patient_free
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'aaaaaaaa-0001-0001-0001-000000000001',
p.id, p.key, 'active', 'month',
now(), now() + interval '30 days',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'patient_free';
-- ============================================================
-- 9. Vincula terapeuta à Clínica 3 (full) como exemplo
-- ============================================================
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES (
'bbbbbbbb-0005-0005-0005-000000000005',
'aaaaaaaa-0002-0002-0002-000000000002',
'therapist',
'active',
now()
)
ON CONFLICT (tenant_id, user_id) DO NOTHING;
-- ============================================================
-- 10. Confirma
-- ============================================================
DO $$
BEGIN
RAISE NOTICE '✅ Seed aplicado com sucesso.';
RAISE NOTICE '';
RAISE NOTICE ' Usuários criados:';
RAISE NOTICE ' paciente@agenciapsi.com.br → patient';
RAISE NOTICE ' terapeuta@agenciapsi.com.br → therapist (tenant próprio + vinculado à Clínica 3)';
RAISE NOTICE ' clinica1@agenciapsi.com.br → clinic_coworking';
RAISE NOTICE ' clinica2@agenciapsi.com.br → clinic_reception';
RAISE NOTICE ' clinica3@agenciapsi.com.br → clinic_full';
RAISE NOTICE ' saas@agenciapsi.com.br → saas_admin';
RAISE NOTICE ' Senha de todos: Teste@123';
END;
$$;
COMMIT;

View File

@@ -0,0 +1,199 @@
-- =============================================================================
-- SEED 002 — Supervisor e Editor
-- =============================================================================
-- Execute APÓS seed_001.sql
-- Requer: pgcrypto (já ativo no Supabase)
--
-- Cria os seguintes usuários de teste:
--
-- supervisor@agenciapsi.com.br senha: Teste@123 → supervisor da Clínica 3
-- editor@agenciapsi.com.br senha: Teste@123 → editor de conteúdo (plataforma)
--
-- UUIDs reservados:
-- Supervisor → aaaaaaaa-0007-0007-0007-000000000007
-- Editor → aaaaaaaa-0008-0008-0008-000000000008
--
-- =============================================================================
BEGIN;
-- ============================================================
-- 0. Migration: adiciona platform_roles em profiles (se não existir)
-- ============================================================
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS platform_roles text[] NOT NULL DEFAULT '{}';
COMMENT ON COLUMN public.profiles.platform_roles IS
'Papéis globais de plataforma, independentes de tenant. Ex: editor de microlearning. Atribuído pelo saas_admin.';
-- ============================================================
-- 1. Remove seeds anteriores (idempotente)
-- ============================================================
DELETE FROM auth.users
WHERE email IN (
'supervisor@agenciapsi.com.br',
'editor@agenciapsi.com.br'
);
-- ============================================================
-- 2. Cria usuários no auth.users
-- ============================================================
INSERT INTO auth.users (
instance_id,
id,
email,
encrypted_password,
email_confirmed_at,
created_at,
updated_at,
raw_user_meta_data,
raw_app_meta_data,
role,
aud,
is_sso_user,
is_anonymous,
confirmation_token,
recovery_token,
email_change_token_new,
email_change_token_current,
email_change
)
VALUES
-- Supervisor
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0007-0007-0007-000000000007',
'supervisor@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Carlos Supervisor"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
),
-- Editor de Conteúdo
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0008-0008-0008-000000000008',
'editor@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Diana Editora"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
);
-- ============================================================
-- 3. auth.identities (obrigatório para GoTrue reconhecer login)
-- ============================================================
INSERT INTO auth.identities (id, user_id, provider_id, provider, identity_data, created_at, updated_at, last_sign_in_at)
VALUES
(
gen_random_uuid(),
'aaaaaaaa-0007-0007-0007-000000000007',
'supervisor@agenciapsi.com.br',
'email',
'{"sub": "aaaaaaaa-0007-0007-0007-000000000007", "email": "supervisor@agenciapsi.com.br", "email_verified": true}'::jsonb,
now(), now(), now()
),
(
gen_random_uuid(),
'aaaaaaaa-0008-0008-0008-000000000008',
'editor@agenciapsi.com.br',
'email',
'{"sub": "aaaaaaaa-0008-0008-0008-000000000008", "email": "editor@agenciapsi.com.br", "email_verified": true}'::jsonb,
now(), now(), now()
)
ON CONFLICT (provider, provider_id) DO NOTHING;
-- ============================================================
-- 4. Profiles
-- Supervisor → tenant_member (papel no tenant via tenant_members.role)
-- Editor → tenant_member + platform_roles = '{editor}'
-- ============================================================
INSERT INTO public.profiles (id, role, account_type, full_name, platform_roles)
VALUES
(
'aaaaaaaa-0007-0007-0007-000000000007',
'tenant_member',
'therapist',
'Carlos Supervisor',
'{}'
),
(
'aaaaaaaa-0008-0008-0008-000000000008',
'tenant_member',
'therapist',
'Diana Editora',
'{editor}' -- permissão de plataforma: acesso à área do editor
)
ON CONFLICT (id) DO UPDATE SET
role = EXCLUDED.role,
account_type = EXCLUDED.account_type,
full_name = EXCLUDED.full_name,
platform_roles = EXCLUDED.platform_roles;
-- ============================================================
-- 5. Vincula Supervisor à Clínica 3 (Full) com role 'supervisor'
-- ============================================================
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES (
'bbbbbbbb-0005-0005-0005-000000000005', -- Clínica Bem Estar (Full)
'aaaaaaaa-0007-0007-0007-000000000007', -- Carlos Supervisor
'supervisor',
'active',
now()
)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET
role = EXCLUDED.role,
status = EXCLUDED.status;
-- ============================================================
-- 6. Vincula Editor à Clínica 3 como terapeuta
-- (contexto de tenant para o editor poder usar /therapist também,
-- se necessário. O papel de editor vem de platform_roles.)
-- ============================================================
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES (
'bbbbbbbb-0005-0005-0005-000000000005', -- Clínica Bem Estar (Full)
'aaaaaaaa-0008-0008-0008-000000000008', -- Diana Editora
'therapist',
'active',
now()
)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET
role = EXCLUDED.role,
status = EXCLUDED.status;
-- ============================================================
-- 7. Confirma
-- ============================================================
DO $$
BEGIN
RAISE NOTICE '✅ Seed 002 aplicado com sucesso.';
RAISE NOTICE '';
RAISE NOTICE ' Migration aplicada:';
RAISE NOTICE ' → profiles.platform_roles text[] adicionada (se não existia)';
RAISE NOTICE '';
RAISE NOTICE ' Usuários criados:';
RAISE NOTICE ' supervisor@agenciapsi.com.br → supervisor da Clínica Bem Estar (Full)';
RAISE NOTICE ' editor@agenciapsi.com.br → editor de conteúdo (platform_roles = {editor})';
RAISE NOTICE ' Senha de todos: Teste@123';
END;
$$;
COMMIT;

View File

@@ -0,0 +1,283 @@
-- =============================================================================
-- SEED 003 — Terapeuta 2, Terapeuta 3 e Secretária
-- =============================================================================
-- Execute APÓS seed_001.sql (e seed_002.sql se quiser todos os seeds)
-- Requer: pgcrypto (já ativo no Supabase)
--
-- Cria os seguintes usuários de teste:
--
-- therapist2@agenciapsi.com.br senha: Teste@123 → terapeuta 2 (tenant próprio + Clínica 3)
-- therapist3@agenciapsi.com.br senha: Teste@123 → terapeuta 3 (tenant próprio + Clínica 3)
-- secretary@agenciapsi.com.br senha: Teste@123 → clinic_admin na Clínica 2 (Mente Sã)
--
-- UUIDs reservados:
-- Terapeuta 2 → aaaaaaaa-0009-0009-0009-000000000009
-- Terapeuta 3 → aaaaaaaa-0010-0010-0010-000000000010
-- Secretária → aaaaaaaa-0011-0011-0011-000000000011
-- Tenant Terapeuta 2 → bbbbbbbb-0009-0009-0009-000000000009
-- Tenant Terapeuta 3 → bbbbbbbb-0010-0010-0010-000000000010
-- =============================================================================
BEGIN;
-- ============================================================
-- 1. Remove seeds anteriores (idempotente)
-- ============================================================
DELETE FROM auth.users
WHERE email IN (
'therapist2@agenciapsi.com.br',
'therapist3@agenciapsi.com.br',
'secretary@agenciapsi.com.br'
);
-- ============================================================
-- 2. Cria usuários no auth.users
-- ⚠️ confirmed_at é coluna gerada — NÃO incluir na lista
-- ============================================================
INSERT INTO auth.users (
instance_id,
id,
email,
encrypted_password,
email_confirmed_at,
created_at,
updated_at,
raw_user_meta_data,
raw_app_meta_data,
role,
aud,
is_sso_user,
is_anonymous,
confirmation_token,
recovery_token,
email_change_token_new,
email_change_token_current,
email_change
)
VALUES
-- Terapeuta 2
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0009-0009-0009-000000000009',
'therapist2@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Eva Terapeuta"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
),
-- Terapeuta 3
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0010-0010-0010-000000000010',
'therapist3@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Felipe Terapeuta"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
),
-- Secretária
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0011-0011-0011-000000000011',
'secretary@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Gabriela Secretária"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
);
-- ============================================================
-- 3. auth.identities (obrigatório para GoTrue reconhecer login)
-- ============================================================
INSERT INTO auth.identities (id, user_id, provider_id, provider, identity_data, created_at, updated_at, last_sign_in_at)
VALUES
(
gen_random_uuid(),
'aaaaaaaa-0009-0009-0009-000000000009',
'therapist2@agenciapsi.com.br',
'email',
'{"sub": "aaaaaaaa-0009-0009-0009-000000000009", "email": "therapist2@agenciapsi.com.br", "email_verified": true}'::jsonb,
now(), now(), now()
),
(
gen_random_uuid(),
'aaaaaaaa-0010-0010-0010-000000000010',
'therapist3@agenciapsi.com.br',
'email',
'{"sub": "aaaaaaaa-0010-0010-0010-000000000010", "email": "therapist3@agenciapsi.com.br", "email_verified": true}'::jsonb,
now(), now(), now()
),
(
gen_random_uuid(),
'aaaaaaaa-0011-0011-0011-000000000011',
'secretary@agenciapsi.com.br',
'email',
'{"sub": "aaaaaaaa-0011-0011-0011-000000000011", "email": "secretary@agenciapsi.com.br", "email_verified": true}'::jsonb,
now(), now(), now()
)
ON CONFLICT (provider, provider_id) DO NOTHING;
-- ============================================================
-- 4. Profiles
-- ============================================================
INSERT INTO public.profiles (id, role, account_type, full_name)
VALUES
(
'aaaaaaaa-0009-0009-0009-000000000009',
'tenant_member',
'therapist',
'Eva Terapeuta'
),
(
'aaaaaaaa-0010-0010-0010-000000000010',
'tenant_member',
'therapist',
'Felipe Terapeuta'
),
(
'aaaaaaaa-0011-0011-0011-000000000011',
'tenant_member',
'therapist',
'Gabriela Secretária'
)
ON CONFLICT (id) DO UPDATE SET
role = EXCLUDED.role,
account_type = EXCLUDED.account_type,
full_name = EXCLUDED.full_name;
-- ============================================================
-- 5. Tenants pessoais dos Terapeutas 2 e 3
-- ============================================================
INSERT INTO public.tenants (id, name, kind, created_at)
VALUES
('bbbbbbbb-0009-0009-0009-000000000009', 'Eva Terapeuta', 'therapist', now()),
('bbbbbbbb-0010-0010-0010-000000000010', 'Felipe Terapeuta', 'therapist', now())
ON CONFLICT (id) DO NOTHING;
-- Terapeuta 2 → tenant_admin do próprio tenant
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES (
'bbbbbbbb-0009-0009-0009-000000000009',
'aaaaaaaa-0009-0009-0009-000000000009',
'tenant_admin', 'active', now()
)
ON CONFLICT (tenant_id, user_id) DO NOTHING;
-- Terapeuta 3 → tenant_admin do próprio tenant
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES (
'bbbbbbbb-0010-0010-0010-000000000010',
'aaaaaaaa-0010-0010-0010-000000000010',
'tenant_admin', 'active', now()
)
ON CONFLICT (tenant_id, user_id) DO NOTHING;
-- ============================================================
-- 6. Vincula Terapeutas 2 e 3 à Clínica 3 — Full
-- (mesmo padrão de terapeuta@agenciapsi.com.br no seed_001)
-- ============================================================
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES
(
'bbbbbbbb-0005-0005-0005-000000000005', -- Clínica Bem Estar (Full)
'aaaaaaaa-0009-0009-0009-000000000009', -- Eva Terapeuta
'therapist', 'active', now()
),
(
'bbbbbbbb-0005-0005-0005-000000000005', -- Clínica Bem Estar (Full)
'aaaaaaaa-0010-0010-0010-000000000010', -- Felipe Terapeuta
'therapist', 'active', now()
)
ON CONFLICT (tenant_id, user_id) DO NOTHING;
-- ============================================================
-- 7. Vincula Secretária à Clínica 2 (Recepção) como clinic_admin
-- A secretária gerencia a recepção/agenda da clínica.
-- Acessa a área /admin com o mesmo contexto de clinic_admin.
-- ============================================================
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES (
'bbbbbbbb-0004-0004-0004-000000000004', -- Clínica Mente Sã (Recepção)
'aaaaaaaa-0011-0011-0011-000000000011', -- Gabriela Secretária
'clinic_admin', 'active', now()
)
ON CONFLICT (tenant_id, user_id) DO NOTHING;
-- ============================================================
-- 8. Subscriptions
-- Terapeutas 2 e 3 → therapist_free (escopo: user_id)
-- Secretária → sem assinatura própria (usa o plano da Clínica 2)
-- ============================================================
-- Terapeuta 2 → therapist_free
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'aaaaaaaa-0009-0009-0009-000000000009',
p.id, p.key, 'active', 'month',
now(), now() + interval '30 days',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'therapist_free'
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.user_id = 'aaaaaaaa-0009-0009-0009-000000000009' AND s.status = 'active'
);
-- Terapeuta 3 → therapist_free
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'aaaaaaaa-0010-0010-0010-000000000010',
p.id, p.key, 'active', 'month',
now(), now() + interval '30 days',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'therapist_free'
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.user_id = 'aaaaaaaa-0010-0010-0010-000000000010' AND s.status = 'active'
);
-- Nota: a Secretária não tem assinatura própria.
-- O acesso vem do plano da Clínica 2 (tenant_id = bbbbbbbb-0004-0004-0004-000000000004).
-- ============================================================
-- 9. Confirma
-- ============================================================
DO $$
BEGIN
RAISE NOTICE '✅ Seed 003 aplicado com sucesso.';
RAISE NOTICE '';
RAISE NOTICE ' Usuários criados:';
RAISE NOTICE ' therapist2@agenciapsi.com.br → tenant próprio (bbbbbbbb-0009) + Clínica 3 como therapist';
RAISE NOTICE ' therapist3@agenciapsi.com.br → tenant próprio (bbbbbbbb-0010) + Clínica 3 como therapist';
RAISE NOTICE ' secretary@agenciapsi.com.br → clinic_admin na Clínica 2 Mente Sã (bbbbbbbb-0004)';
RAISE NOTICE ' Senha de todos: Teste@123';
END;
$$;
COMMIT;

View File

@@ -0,0 +1,36 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- agendador_check_email
-- Verifica se um e-mail já possui solicitação anterior para este agendador
-- SECURITY DEFINER → anon pode chamar sem burlar RLS diretamente
-- Execute no Supabase SQL Editor
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.agendador_check_email(
p_slug text,
p_email text
)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_owner_id uuid;
BEGIN
SELECT c.owner_id INTO v_owner_id
FROM public.agendador_configuracoes c
WHERE c.link_slug = p_slug AND c.ativo = true
LIMIT 1;
IF v_owner_id IS NULL THEN RETURN false; END IF;
RETURN EXISTS (
SELECT 1 FROM public.agendador_solicitacoes s
WHERE s.owner_id = v_owner_id
AND lower(s.paciente_email) = lower(trim(p_email))
LIMIT 1
);
END;
$$;
GRANT EXECUTE ON FUNCTION public.agendador_check_email(text, text) TO anon, authenticated;

View File

@@ -0,0 +1,62 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- Feature keys do Agendador Online
-- Execute no Supabase SQL Editor
-- ═══════════════════════════════════════════════════════════════════════════
-- ── 1. Inserir as features ──────────────────────────────────────────────────
INSERT INTO public.features (key, name, descricao)
VALUES
(
'agendador.online',
'Agendador Online',
'Permite que pacientes solicitem agendamentos via link público. Inclui aprovação manual ou automática, controle de horários e notificações.'
),
(
'agendador.link_personalizado',
'Link Personalizado do Agendador',
'Permite que o profissional escolha um slug de URL próprio para o agendador (ex: /agendar/dra-ana-silva) em vez de um link gerado automaticamente.'
)
ON CONFLICT (key) DO UPDATE
SET name = EXCLUDED.name,
descricao = EXCLUDED.descricao;
-- ── 2. Vincular aos planos ──────────────────────────────────────────────────
-- ATENÇÃO: ajuste os filtros de plan key/name conforme seus planos reais.
-- Exemplo: agendador.online disponível para planos PRO e acima.
-- agendador.link_personalizado apenas para planos Elite/Superior.
-- agendador.online → todos os planos com target 'therapist' ou 'clinic'
-- (Adapte o WHERE conforme necessário)
INSERT INTO public.plan_features (plan_id, feature_id, enabled)
SELECT
p.id,
f.id,
true
FROM public.plans p
CROSS JOIN public.features f
WHERE f.key = 'agendador.online'
AND p.is_active = true
-- Comente a linha abaixo para liberar para TODOS os planos:
-- AND p.key IN ('pro', 'elite', 'clinic_pro', 'clinic_elite')
ON CONFLICT DO NOTHING;
-- agendador.link_personalizado → apenas planos superiores
-- Deixe comentado e adicione manualmente quando definir os planos:
-- INSERT INTO public.plan_features (plan_id, feature_id, enabled)
-- SELECT p.id, f.id, true
-- FROM public.plans p
-- CROSS JOIN public.features f
-- WHERE f.key = 'agendador.link_personalizado'
-- AND p.key IN ('elite', 'clinic_elite', 'pro_plus')
-- ON CONFLICT DO NOTHING;
-- ── 3. Verificação ─────────────────────────────────────────────────────────
SELECT
f.key,
f.name,
COUNT(pf.plan_id) AS planos_vinculados
FROM public.features f
LEFT JOIN public.plan_features pf ON pf.feature_id = f.id AND pf.enabled = true
WHERE f.key IN ('agendador.online', 'agendador.link_personalizado')
GROUP BY f.key, f.name
ORDER BY f.key;

View File

@@ -0,0 +1,221 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- FIX: agendador_slots_disponiveis + agendador_dias_disponiveis
-- Usa agenda_online_slots como fonte de slots
-- Cruzamento com: agenda_eventos, recurrence_rules/exceptions, agendador_solicitacoes
-- Execute no Supabase SQL Editor
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.agendador_slots_disponiveis(
p_slug text,
p_data date
)
RETURNS TABLE (hora time, disponivel boolean)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_owner_id uuid;
v_duracao int;
v_antecedencia int;
v_agora timestamptz;
v_db_dow int;
v_slot time;
v_slot_fim time;
v_slot_ts timestamptz;
v_ocupado boolean;
-- loop de recorrências
v_rule RECORD;
v_rule_start_dow int;
v_first_occ date;
v_day_diff int;
v_ex_type text;
BEGIN
SELECT c.owner_id, c.duracao_sessao_min, c.antecedencia_minima_horas
INTO v_owner_id, v_duracao, v_antecedencia
FROM public.agendador_configuracoes c
WHERE c.link_slug = p_slug AND c.ativo = true
LIMIT 1;
IF v_owner_id IS NULL THEN RETURN; END IF;
v_agora := now();
v_db_dow := extract(dow from p_data::timestamp)::int;
FOR v_slot IN
SELECT s.time
FROM public.agenda_online_slots s
WHERE s.owner_id = v_owner_id
AND s.weekday = v_db_dow
AND s.enabled = true
ORDER BY s.time
LOOP
v_slot_fim := v_slot + (v_duracao || ' minutes')::interval;
v_ocupado := false;
-- ── Antecedência mínima ──────────────────────────────────────────────────
v_slot_ts := (p_data::text || ' ' || v_slot::text)::timestamp
AT TIME ZONE 'America/Sao_Paulo';
IF v_slot_ts < v_agora + (v_antecedencia || ' hours')::interval THEN
v_ocupado := true;
END IF;
-- ── Eventos avulsos internos (agenda_eventos) ────────────────────────────
IF NOT v_ocupado THEN
SELECT EXISTS (
SELECT 1 FROM public.agenda_eventos e
WHERE e.owner_id = v_owner_id
AND e.status::text NOT IN ('cancelado', 'faltou')
AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::date = p_data
AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::time < v_slot_fim
AND (e.fim_em AT TIME ZONE 'America/Sao_Paulo')::time > v_slot
) INTO v_ocupado;
END IF;
-- ── Recorrências ativas (recurrence_rules) ───────────────────────────────
-- Loop explícito para evitar erros de tipo no cálculo do ciclo semanal
IF NOT v_ocupado THEN
FOR v_rule IN
SELECT
r.id,
r.start_date::date AS start_date,
r.end_date::date AS end_date,
r.start_time::time AS start_time,
r.end_time::time AS end_time,
COALESCE(r.interval, 1)::int AS interval
FROM public.recurrence_rules r
WHERE r.owner_id = v_owner_id
AND r.status = 'ativo'
AND p_data >= r.start_date::date
AND (r.end_date IS NULL OR p_data <= r.end_date::date)
AND v_db_dow = ANY(r.weekdays)
AND r.start_time::time < v_slot_fim
AND r.end_time::time > v_slot
LOOP
-- Calcula a primeira ocorrência do dia-da-semana a partir do start_date
v_rule_start_dow := extract(dow from v_rule.start_date)::int;
v_first_occ := v_rule.start_date
+ (((v_db_dow - v_rule_start_dow + 7) % 7))::int;
v_day_diff := (p_data - v_first_occ)::int;
-- Ocorrência válida: diff >= 0 e divisível pelo ciclo semanal
IF v_day_diff >= 0 AND v_day_diff % (7 * v_rule.interval) = 0 THEN
-- Verifica se há exceção para esta data
v_ex_type := NULL;
SELECT ex.type INTO v_ex_type
FROM public.recurrence_exceptions ex
WHERE ex.recurrence_id = v_rule.id
AND ex.original_date = p_data
LIMIT 1;
-- Sem exceção, ou exceção que não cancela → bloqueia o slot
IF v_ex_type IS NULL OR v_ex_type NOT IN (
'cancel_session', 'patient_missed',
'therapist_canceled', 'holiday_block',
'reschedule_session'
) THEN
v_ocupado := true;
EXIT; -- já basta uma regra que conflite
END IF;
END IF;
END LOOP;
END IF;
-- ── Recorrências remarcadas para este dia (reschedule → new_date = p_data) ─
IF NOT v_ocupado THEN
SELECT EXISTS (
SELECT 1
FROM public.recurrence_exceptions ex
JOIN public.recurrence_rules r ON r.id = ex.recurrence_id
WHERE r.owner_id = v_owner_id
AND r.status = 'ativo'
AND ex.type = 'reschedule_session'
AND ex.new_date = p_data
AND COALESCE(ex.new_start_time, r.start_time)::time < v_slot_fim
AND COALESCE(ex.new_end_time, r.end_time)::time > v_slot
) INTO v_ocupado;
END IF;
-- ── Solicitações públicas pendentes ──────────────────────────────────────
IF NOT v_ocupado THEN
SELECT EXISTS (
SELECT 1 FROM public.agendador_solicitacoes sol
WHERE sol.owner_id = v_owner_id
AND sol.status = 'pendente'
AND sol.data_solicitada = p_data
AND sol.hora_solicitada = v_slot
AND (sol.reservado_ate IS NULL OR sol.reservado_ate > v_agora)
) INTO v_ocupado;
END IF;
hora := v_slot;
disponivel := NOT v_ocupado;
RETURN NEXT;
END LOOP;
END;
$$;
GRANT EXECUTE ON FUNCTION public.agendador_slots_disponiveis(text, date) TO anon, authenticated;
-- ── agendador_dias_disponiveis ───────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.agendador_dias_disponiveis(
p_slug text,
p_ano int,
p_mes int
)
RETURNS TABLE (data date, tem_slots boolean)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_owner_id uuid;
v_antecedencia int;
v_agora timestamptz;
v_data date;
v_data_inicio date;
v_data_fim date;
v_db_dow int;
v_tem_slot boolean;
BEGIN
SELECT c.owner_id, c.antecedencia_minima_horas
INTO v_owner_id, v_antecedencia
FROM public.agendador_configuracoes c
WHERE c.link_slug = p_slug AND c.ativo = true
LIMIT 1;
IF v_owner_id IS NULL THEN RETURN; END IF;
v_agora := now();
v_data_inicio := make_date(p_ano, p_mes, 1);
v_data_fim := (v_data_inicio + interval '1 month' - interval '1 day')::date;
v_data := v_data_inicio;
WHILE v_data <= v_data_fim LOOP
v_db_dow := extract(dow from v_data::timestamp)::int;
SELECT EXISTS (
SELECT 1 FROM public.agenda_online_slots s
WHERE s.owner_id = v_owner_id
AND s.weekday = v_db_dow
AND s.enabled = true
AND (v_data::text || ' ' || s.time::text)::timestamp
AT TIME ZONE 'America/Sao_Paulo'
>= v_agora + (v_antecedencia || ' hours')::interval
) INTO v_tem_slot;
IF v_tem_slot THEN
data := v_data;
tem_slots := true;
RETURN NEXT;
END IF;
v_data := v_data + 1;
END LOOP;
END;
$$;
GRANT EXECUTE ON FUNCTION public.agendador_dias_disponiveis(text, int, int) TO anon, authenticated;

View File

@@ -0,0 +1,170 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- Agendador Online — tabelas de configuração e solicitações
-- ═══════════════════════════════════════════════════════════════════════════
-- ── 1. agendador_configuracoes ──────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "public"."agendador_configuracoes" (
"owner_id" "uuid" NOT NULL,
"tenant_id" "uuid",
-- PRO / Ativação
"ativo" boolean DEFAULT false NOT NULL,
"link_slug" "text",
-- Identidade Visual
"imagem_fundo_url" "text",
"imagem_header_url" "text",
"logomarca_url" "text",
"cor_primaria" "text" DEFAULT '#4b6bff',
-- Perfil Público
"nome_exibicao" "text",
"endereco" "text",
"botao_como_chegar_ativo" boolean DEFAULT true NOT NULL,
"maps_url" "text",
-- Fluxo de Agendamento
"modo_aprovacao" "text" DEFAULT 'aprovacao' NOT NULL,
"modalidade" "text" DEFAULT 'presencial' NOT NULL,
"tipos_habilitados" "jsonb" DEFAULT '["primeira","retorno"]'::jsonb NOT NULL,
"duracao_sessao_min" integer DEFAULT 50 NOT NULL,
"antecedencia_minima_horas" integer DEFAULT 24 NOT NULL,
"prazo_resposta_horas" integer DEFAULT 2 NOT NULL,
"reserva_horas" integer DEFAULT 2 NOT NULL,
-- Pagamento
"pagamento_obrigatorio" boolean DEFAULT false NOT NULL,
"pix_chave" "text",
"pix_countdown_minutos" integer DEFAULT 20 NOT NULL,
-- Triagem & Conformidade
"triagem_motivo" boolean DEFAULT true NOT NULL,
"triagem_como_conheceu" boolean DEFAULT false NOT NULL,
"verificacao_email" boolean DEFAULT false NOT NULL,
"exigir_aceite_lgpd" boolean DEFAULT true NOT NULL,
-- Textos
"mensagem_boas_vindas" "text",
"texto_como_se_preparar" "text",
"texto_termos_lgpd" "text",
-- Timestamps
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "agendador_configuracoes_pkey" PRIMARY KEY ("owner_id"),
CONSTRAINT "agendador_configuracoes_owner_fk"
FOREIGN KEY ("owner_id") REFERENCES "auth"."users"("id") ON DELETE CASCADE,
CONSTRAINT "agendador_configuracoes_tenant_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE CASCADE,
CONSTRAINT "agendador_configuracoes_modo_check"
CHECK ("modo_aprovacao" = ANY (ARRAY['automatico','aprovacao'])),
CONSTRAINT "agendador_configuracoes_modalidade_check"
CHECK ("modalidade" = ANY (ARRAY['presencial','online','ambos'])),
CONSTRAINT "agendador_configuracoes_duracao_check"
CHECK ("duracao_sessao_min" >= 10 AND "duracao_sessao_min" <= 240),
CONSTRAINT "agendador_configuracoes_antecedencia_check"
CHECK ("antecedencia_minima_horas" >= 0 AND "antecedencia_minima_horas" <= 720),
CONSTRAINT "agendador_configuracoes_reserva_check"
CHECK ("reserva_horas" >= 1 AND "reserva_horas" <= 48),
CONSTRAINT "agendador_configuracoes_pix_countdown_check"
CHECK ("pix_countdown_minutos" >= 5 AND "pix_countdown_minutos" <= 120),
CONSTRAINT "agendador_configuracoes_prazo_check"
CHECK ("prazo_resposta_horas" >= 1 AND "prazo_resposta_horas" <= 72)
);
ALTER TABLE "public"."agendador_configuracoes" ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "agendador_cfg_select" ON "public"."agendador_configuracoes";
CREATE POLICY "agendador_cfg_select" ON "public"."agendador_configuracoes"
FOR SELECT USING (auth.uid() = owner_id);
DROP POLICY IF EXISTS "agendador_cfg_write" ON "public"."agendador_configuracoes";
CREATE POLICY "agendador_cfg_write" ON "public"."agendador_configuracoes"
USING (auth.uid() = owner_id)
WITH CHECK (auth.uid() = owner_id);
CREATE INDEX IF NOT EXISTS "agendador_cfg_tenant_idx"
ON "public"."agendador_configuracoes" ("tenant_id");
-- ── 2. agendador_solicitacoes ───────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "public"."agendador_solicitacoes" (
"id" "uuid" DEFAULT gen_random_uuid() NOT NULL,
"owner_id" "uuid" NOT NULL,
"tenant_id" "uuid",
-- Dados do paciente
"paciente_nome" "text" NOT NULL,
"paciente_sobrenome" "text",
"paciente_email" "text" NOT NULL,
"paciente_celular" "text",
"paciente_cpf" "text",
-- Agendamento solicitado
"tipo" "text" NOT NULL,
"modalidade" "text" NOT NULL,
"data_solicitada" date NOT NULL,
"hora_solicitada" time NOT NULL,
-- Reserva temporária
"reservado_ate" timestamp with time zone,
-- Triagem
"motivo" "text",
"como_conheceu" "text",
-- Pagamento
"pix_status" "text" DEFAULT 'pendente',
"pix_pago_em" timestamp with time zone,
-- Status geral
"status" "text" DEFAULT 'pendente' NOT NULL,
"recusado_motivo" "text",
-- Autorização
"autorizado_em" timestamp with time zone,
"autorizado_por" "uuid",
-- Vínculos internos
"user_id" "uuid",
"patient_id" "uuid",
"evento_id" "uuid",
-- Timestamps
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "agendador_solicitacoes_pkey" PRIMARY KEY ("id"),
CONSTRAINT "agendador_sol_owner_fk"
FOREIGN KEY ("owner_id") REFERENCES "auth"."users"("id") ON DELETE CASCADE,
CONSTRAINT "agendador_sol_tenant_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE CASCADE,
CONSTRAINT "agendador_sol_status_check"
CHECK ("status" = ANY (ARRAY['pendente','autorizado','recusado','expirado'])),
CONSTRAINT "agendador_sol_tipo_check"
CHECK ("tipo" = ANY (ARRAY['primeira','retorno','reagendar'])),
CONSTRAINT "agendador_sol_modalidade_check"
CHECK ("modalidade" = ANY (ARRAY['presencial','online'])),
CONSTRAINT "agendador_sol_pix_check"
CHECK ("pix_status" IS NULL OR "pix_status" = ANY (ARRAY['pendente','pago','expirado']))
);
ALTER TABLE "public"."agendador_solicitacoes" ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "agendador_sol_owner_select" ON "public"."agendador_solicitacoes";
CREATE POLICY "agendador_sol_owner_select" ON "public"."agendador_solicitacoes"
FOR SELECT USING (auth.uid() = owner_id);
DROP POLICY IF EXISTS "agendador_sol_owner_write" ON "public"."agendador_solicitacoes";
CREATE POLICY "agendador_sol_owner_write" ON "public"."agendador_solicitacoes"
USING (auth.uid() = owner_id)
WITH CHECK (auth.uid() = owner_id);
CREATE INDEX IF NOT EXISTS "agendador_sol_owner_idx"
ON "public"."agendador_solicitacoes" ("owner_id", "status");
CREATE INDEX IF NOT EXISTS "agendador_sol_tenant_idx"
ON "public"."agendador_solicitacoes" ("tenant_id");
CREATE INDEX IF NOT EXISTS "agendador_sol_data_idx"
ON "public"."agendador_solicitacoes" ("data_solicitada", "hora_solicitada");

View File

@@ -0,0 +1,219 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- Agendador Online — acesso público (anon) + função de slots disponíveis
-- ═══════════════════════════════════════════════════════════════════════════
-- ── 1. Geração automática de slug ──────────────────────────────────────────
-- Cria slug único de 8 chars quando o profissional ativa sem link_personalizado
CREATE OR REPLACE FUNCTION public.agendador_gerar_slug()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
v_slug text;
v_exists boolean;
BEGIN
-- só gera se ativou e não tem slug ainda
IF NEW.ativo = true AND (NEW.link_slug IS NULL OR NEW.link_slug = '') THEN
LOOP
v_slug := lower(substring(replace(gen_random_uuid()::text, '-', ''), 1, 8));
SELECT EXISTS (
SELECT 1 FROM public.agendador_configuracoes
WHERE link_slug = v_slug AND owner_id <> NEW.owner_id
) INTO v_exists;
EXIT WHEN NOT v_exists;
END LOOP;
NEW.link_slug := v_slug;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS agendador_slug_trigger ON public.agendador_configuracoes;
CREATE TRIGGER agendador_slug_trigger
BEFORE INSERT OR UPDATE ON public.agendador_configuracoes
FOR EACH ROW EXECUTE FUNCTION public.agendador_gerar_slug();
-- ── 2. Políticas públicas (anon) ────────────────────────────────────────────
-- Leitura pública da config pelo slug (só ativo)
DROP POLICY IF EXISTS "agendador_cfg_public_read" ON public.agendador_configuracoes;
CREATE POLICY "agendador_cfg_public_read" ON public.agendador_configuracoes
FOR SELECT TO anon
USING (ativo = true AND link_slug IS NOT NULL);
-- Inserção pública de solicitações (qualquer pessoa pode solicitar)
DROP POLICY IF EXISTS "agendador_sol_public_insert" ON public.agendador_solicitacoes;
CREATE POLICY "agendador_sol_public_insert" ON public.agendador_solicitacoes
FOR INSERT TO anon
WITH CHECK (true);
-- Leitura da própria solicitação (pelo paciente logado)
DROP POLICY IF EXISTS "agendador_sol_patient_read" ON public.agendador_solicitacoes;
CREATE POLICY "agendador_sol_patient_read" ON public.agendador_solicitacoes
FOR SELECT TO authenticated
USING (auth.uid() = user_id OR auth.uid() = owner_id);
-- ── 3. Função: retorna slots disponíveis para uma data ──────────────────────
-- Roda como SECURITY DEFINER (acessa agenda_regras e agenda_eventos sem RLS)
CREATE OR REPLACE FUNCTION public.agendador_slots_disponiveis(
p_slug text,
p_data date
)
RETURNS TABLE (hora time, disponivel boolean)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_owner_id uuid;
v_duracao int;
v_reserva int;
v_antecedencia int;
v_dia_semana int; -- 0=dom..6=sab (JS) → convertemos
v_db_dow int; -- 0=dom..6=sab no Postgres (extract dow)
v_inicio time;
v_fim time;
v_slot time;
v_slot_fim time;
v_agora timestamptz;
BEGIN
-- carrega config do agendador
SELECT
c.owner_id,
c.duracao_sessao_min,
c.reserva_horas,
c.antecedencia_minima_horas
INTO v_owner_id, v_duracao, v_reserva, v_antecedencia
FROM public.agendador_configuracoes c
WHERE c.link_slug = p_slug AND c.ativo = true
LIMIT 1;
IF v_owner_id IS NULL THEN
RETURN;
END IF;
v_agora := now();
v_db_dow := extract(dow from p_data::timestamp)::int; -- 0=dom..6=sab
-- regra semanal para o dia da semana
SELECT hora_inicio, hora_fim
INTO v_inicio, v_fim
FROM public.agenda_regras_semanais
WHERE owner_id = v_owner_id
AND dia_semana = v_db_dow
AND ativo = true
LIMIT 1;
IF v_inicio IS NULL THEN
RETURN; -- profissional não atende nesse dia
END IF;
-- itera slots de v_duracao em v_duracao dentro da jornada
v_slot := v_inicio;
WHILE v_slot + (v_duracao || ' minutes')::interval <= v_fim LOOP
v_slot_fim := v_slot + (v_duracao || ' minutes')::interval;
-- bloco temporário para verificar conflitos
DECLARE
v_ocupado boolean := false;
v_slot_ts timestamptz;
BEGIN
-- antecedência mínima (compara em horário de Brasília)
v_slot_ts := (p_data::text || ' ' || v_slot::text)::timestamp AT TIME ZONE 'America/Sao_Paulo';
IF v_slot_ts < v_agora + (v_antecedencia || ' hours')::interval THEN
v_ocupado := true;
END IF;
-- conflito com eventos existentes na agenda
IF NOT v_ocupado THEN
SELECT EXISTS (
SELECT 1 FROM public.agenda_eventos
WHERE owner_id = v_owner_id
AND status::text NOT IN ('cancelado', 'faltou')
AND inicio_em AT TIME ZONE 'America/Sao_Paulo' >= p_data::timestamp
AND inicio_em AT TIME ZONE 'America/Sao_Paulo' < p_data::timestamp + interval '1 day'
AND (inicio_em AT TIME ZONE 'America/Sao_Paulo')::time < v_slot_fim
AND (fim_em AT TIME ZONE 'America/Sao_Paulo')::time > v_slot
) INTO v_ocupado;
END IF;
-- conflito com solicitações pendentes (reservadas)
IF NOT v_ocupado THEN
SELECT EXISTS (
SELECT 1 FROM public.agendador_solicitacoes
WHERE owner_id = v_owner_id
AND status = 'pendente'
AND data_solicitada = p_data
AND hora_solicitada = v_slot
AND (reservado_ate IS NULL OR reservado_ate > v_agora)
) INTO v_ocupado;
END IF;
hora := v_slot;
disponivel := NOT v_ocupado;
RETURN NEXT;
END;
v_slot := v_slot + (v_duracao || ' minutes')::interval;
END LOOP;
END;
$$;
GRANT EXECUTE ON FUNCTION public.agendador_slots_disponiveis(text, date) TO anon, authenticated;
-- ── 4. Função: retorna dias com disponibilidade no mês ─────────────────────
CREATE OR REPLACE FUNCTION public.agendador_dias_disponiveis(
p_slug text,
p_ano int,
p_mes int -- 1-12
)
RETURNS TABLE (data date, tem_slots boolean)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_owner_id uuid;
v_antecedencia int;
v_data date;
v_data_inicio date;
v_data_fim date;
v_agora timestamptz;
v_db_dow int;
v_tem_regra boolean;
BEGIN
SELECT c.owner_id, c.antecedencia_minima_horas
INTO v_owner_id, v_antecedencia
FROM public.agendador_configuracoes c
WHERE c.link_slug = p_slug AND c.ativo = true
LIMIT 1;
IF v_owner_id IS NULL THEN RETURN; END IF;
v_agora := now();
v_data_inicio := make_date(p_ano, p_mes, 1);
v_data_fim := (v_data_inicio + interval '1 month' - interval '1 day')::date;
v_data := v_data_inicio;
WHILE v_data <= v_data_fim LOOP
-- não oferece dias no passado ou dentro da antecedência mínima
IF v_data::timestamptz + '23:59:59'::interval > v_agora + (v_antecedencia || ' hours')::interval THEN
v_db_dow := extract(dow from v_data::timestamp)::int;
SELECT EXISTS (
SELECT 1 FROM public.agenda_regras_semanais
WHERE owner_id = v_owner_id AND dia_semana = v_db_dow AND ativo = true
) INTO v_tem_regra;
IF v_tem_regra THEN
data := v_data;
tem_slots := true;
RETURN NEXT;
END IF;
END IF;
v_data := v_data + 1;
END LOOP;
END;
$$;
GRANT EXECUTE ON FUNCTION public.agendador_dias_disponiveis(text, int, int) TO anon, authenticated;

View File

@@ -0,0 +1,19 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- FIX: adiciona status 'convertido' na constraint de agendador_solicitacoes
-- e adiciona coluna motivo_recusa (alias amigável de recusado_motivo)
-- Execute no Supabase SQL Editor
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Remove o CHECK existente e recria com os novos valores
ALTER TABLE public.agendador_solicitacoes
DROP CONSTRAINT IF EXISTS "agendador_sol_status_check";
ALTER TABLE public.agendador_solicitacoes
ADD CONSTRAINT "agendador_sol_status_check"
CHECK (status = ANY (ARRAY[
'pendente',
'autorizado',
'recusado',
'expirado',
'convertido'
]));

View File

@@ -0,0 +1,56 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- Storage bucket para imagens do Agendador Online
-- Execute no Supabase SQL Editor
-- ═══════════════════════════════════════════════════════════════════════════
-- ── 1. Criar o bucket ──────────────────────────────────────────────────────
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'agendador',
'agendador',
true, -- público (URLs diretas sem assinar)
5242880, -- 5 MB
ARRAY['image/jpeg','image/png','image/webp','image/gif']
)
ON CONFLICT (id) DO UPDATE
SET public = true,
file_size_limit = 5242880,
allowed_mime_types = ARRAY['image/jpeg','image/png','image/webp','image/gif'];
-- ── 2. Políticas ───────────────────────────────────────────────────────────
-- Leitura pública (anon e authenticated)
DROP POLICY IF EXISTS "agendador_storage_public_read" ON storage.objects;
CREATE POLICY "agendador_storage_public_read"
ON storage.objects FOR SELECT
USING (bucket_id = 'agendador');
-- Upload: apenas o dono da pasta (owner_id é o primeiro segmento do path)
DROP POLICY IF EXISTS "agendador_storage_owner_insert" ON storage.objects;
CREATE POLICY "agendador_storage_owner_insert"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'agendador'
AND (storage.foldername(name))[1] = auth.uid()::text
);
-- Update/upsert pelo dono
DROP POLICY IF EXISTS "agendador_storage_owner_update" ON storage.objects;
CREATE POLICY "agendador_storage_owner_update"
ON storage.objects FOR UPDATE
TO authenticated
USING (
bucket_id = 'agendador'
AND (storage.foldername(name))[1] = auth.uid()::text
);
-- Delete pelo dono
DROP POLICY IF EXISTS "agendador_storage_owner_delete" ON storage.objects;
CREATE POLICY "agendador_storage_owner_delete"
ON storage.objects FOR DELETE
TO authenticated
USING (
bucket_id = 'agendador'
AND (storage.foldername(name))[1] = auth.uid()::text
);

View File

@@ -0,0 +1,6 @@
-- Migration: remove session_start_offset_min from agenda_configuracoes
-- This field is replaced by hora_inicio in agenda_regras_semanais (work schedule per day)
-- The first session slot is now derived directly from hora_inicio of the work rule.
ALTER TABLE public.agenda_configuracoes
DROP COLUMN IF EXISTS session_start_offset_min;

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1,110 @@
-- =========================================================
-- Agência PSI — Profiles (v2) + Trigger + RLS
-- - 1 profile por auth.users.id
-- - role base (admin|therapist|patient)
-- - pronto para evoluir p/ multi-tenant depois
-- =========================================================
-- 0) Função padrão updated_at (se já existir, mantém)
create or replace function public.set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
-- 1) Tabela profiles
create table if not exists public.profiles (
id uuid primary key, -- = auth.users.id
email text,
full_name text,
avatar_url text,
role text not null default 'patient',
status text not null default 'active',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint profiles_role_check check (role in ('admin','therapist','patient')),
constraint profiles_status_check check (status in ('active','inactive','invited'))
);
-- FK opcional (em Supabase costuma ser ok)
do $$
begin
if not exists (
select 1
from pg_constraint
where conname = 'profiles_id_fkey'
) then
alter table public.profiles
add constraint profiles_id_fkey
foreign key (id) references auth.users(id)
on delete cascade;
end if;
end $$;
-- Índices úteis
create index if not exists profiles_role_idx on public.profiles(role);
create index if not exists profiles_status_idx on public.profiles(status);
-- 2) Trigger updated_at
drop trigger if exists t_profiles_set_updated_at on public.profiles;
create trigger t_profiles_set_updated_at
before update on public.profiles
for each row execute function public.set_updated_at();
-- 3) Trigger pós-signup: cria profile automático
-- Observação: roda como SECURITY DEFINER
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.profiles (id, email, role, status)
values (new.id, new.email, 'patient', 'active')
on conflict (id) do update
set email = excluded.email;
return new;
end;
$$;
drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
-- 4) RLS
alter table public.profiles enable row level security;
-- Leitura do próprio profile
drop policy if exists "profiles_select_own" on public.profiles;
create policy "profiles_select_own"
on public.profiles
for select
to authenticated
using (id = auth.uid());
-- Update do próprio profile (campos não-sensíveis)
drop policy if exists "profiles_update_own" on public.profiles;
create policy "profiles_update_own"
on public.profiles
for update
to authenticated
using (id = auth.uid())
with check (id = auth.uid());
-- Insert só do próprio (na prática quem insere é trigger, mas deixa coerente)
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());

View File

@@ -0,0 +1,212 @@
-- =========================================================
-- Agência PSI Quasar — Cadastro Externo de Paciente (Supabase/Postgres)
-- Objetivo:
-- - Ter um link público com TOKEN que o terapeuta envia ao paciente
-- - Paciente preenche um formulário público
-- - Salva em "intake requests" (pré-cadastro)
-- - Terapeuta revisa e converte em paciente dentro do sistema
--
-- Tabelas:
-- - patient_invites
-- - patient_intake_requests
--
-- Funções:
-- - create_patient_intake_request (RPC pública - anon)
--
-- Segurança:
-- - RLS habilitada
-- - Público (anon) não lê nada, só executa RPC
-- - Terapeuta (authenticated) lê/atualiza somente seus registros
-- =========================================================
-- 0) Tabelas
create table if not exists public.patient_invites (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null,
token text not null unique,
active boolean not null default true,
expires_at timestamptz null,
max_uses int null,
uses int not null default 0,
created_at timestamptz not null default now()
);
create index if not exists patient_invites_owner_id_idx on public.patient_invites(owner_id);
create index if not exists patient_invites_token_idx on public.patient_invites(token);
create table if not exists public.patient_intake_requests (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null,
token text not null,
name text not null,
email text null,
phone text null,
notes text null,
consent boolean not null default false,
status text not null default 'new', -- new | converted | rejected
created_at timestamptz not null default now()
);
create index if not exists patient_intake_owner_id_idx on public.patient_intake_requests(owner_id);
create index if not exists patient_intake_token_idx on public.patient_intake_requests(token);
create index if not exists patient_intake_status_idx on public.patient_intake_requests(status);
-- 1) RLS
alter table public.patient_invites enable row level security;
alter table public.patient_intake_requests enable row level security;
-- 2) Fechar acesso direto para anon (público)
revoke all on table public.patient_invites from anon;
revoke all on table public.patient_intake_requests from anon;
-- 3) Policies: terapeuta (authenticated) - somente próprios registros
-- patient_invites
drop policy if exists invites_select_own on public.patient_invites;
create policy invites_select_own
on public.patient_invites for select
to authenticated
using (owner_id = auth.uid());
drop policy if exists invites_insert_own on public.patient_invites;
create policy invites_insert_own
on public.patient_invites for insert
to authenticated
with check (owner_id = auth.uid());
drop policy if exists invites_update_own on public.patient_invites;
create policy invites_update_own
on public.patient_invites for update
to authenticated
using (owner_id = auth.uid())
with check (owner_id = auth.uid());
-- patient_intake_requests
drop policy if exists intake_select_own on public.patient_intake_requests;
create policy intake_select_own
on public.patient_intake_requests for select
to authenticated
using (owner_id = auth.uid());
drop policy if exists intake_update_own on public.patient_intake_requests;
create policy intake_update_own
on public.patient_intake_requests for update
to authenticated
using (owner_id = auth.uid())
with check (owner_id = auth.uid());
-- 4) RPC pública para criar intake (página pública)
-- Importantíssimo: security definer + search_path fixo
create or replace function public.create_patient_intake_request(
p_token text,
p_name text,
p_email text default null,
p_phone text default null,
p_notes text default null,
p_consent boolean default false
)
returns uuid
language plpgsql
security definer
set search_path = public
as $$
declare
v_owner uuid;
v_active boolean;
v_expires timestamptz;
v_max_uses int;
v_uses int;
v_id uuid;
begin
select owner_id, active, expires_at, max_uses, uses
into v_owner, v_active, v_expires, v_max_uses, v_uses
from public.patient_invites
where token = p_token
limit 1;
if v_owner is null then
raise exception 'Token inválido';
end if;
if v_active is not true then
raise exception 'Link desativado';
end if;
if v_expires is not null and now() > v_expires then
raise exception 'Link expirado';
end if;
if v_max_uses is not null and v_uses >= v_max_uses then
raise exception 'Limite de uso atingido';
end if;
if p_name is null or length(trim(p_name)) = 0 then
raise exception 'Nome é obrigatório';
end if;
insert into public.patient_intake_requests
(owner_id, token, name, email, phone, notes, consent, status)
values
(v_owner, p_token, trim(p_name),
nullif(lower(trim(p_email)), ''),
nullif(trim(p_phone), ''),
nullif(trim(p_notes), ''),
coalesce(p_consent, false),
'new')
returning id into v_id;
update public.patient_invites
set uses = uses + 1
where token = p_token;
return v_id;
end;
$$;
grant execute on function public.create_patient_intake_request(text, text, text, text, text, boolean) to anon;
grant execute on function public.create_patient_intake_request(text, text, text, text, text, boolean) to authenticated;
-- 5) (Opcional) helper para rotacionar token no painel (somente authenticated)
-- Você pode usar no front via supabase.rpc('rotate_patient_invite_token')
create or replace function public.rotate_patient_invite_token(
p_new_token text
)
returns uuid
language plpgsql
security definer
set search_path = public
as $$
declare
v_uid uuid;
v_id uuid;
begin
-- pega o usuário logado
v_uid := auth.uid();
if v_uid is null then
raise exception 'Usuário não autenticado';
end if;
-- desativa tokens antigos ativos do usuário
update public.patient_invites
set active = false
where owner_id = v_uid
and active = true;
-- cria novo token
insert into public.patient_invites (owner_id, token, active)
values (v_uid, p_new_token, true)
returning id into v_id;
return v_id;
end;
$$;
grant execute on function public.rotate_patient_invite_token(text) to authenticated;
grant select, insert, update, delete on table public.patient_invites to authenticated;
grant select, insert, update, delete on table public.patient_intake_requests to authenticated;
-- anon não precisa acessar tabelas diretamente
revoke all on table public.patient_invites from anon;
revoke all on table public.patient_intake_requests from anon;

View File

@@ -0,0 +1,266 @@
-- =========================================================
-- PATCH — Completar cadastro para bater com PatientsCadastroPage.vue
-- (rode DEPOIS do seu supabase_cadastro_pacientes.sql)
-- =========================================================
create extension if not exists pgcrypto;
-- ---------------------------------------------------------
-- 1) Completar colunas que o front usa e hoje faltam em patients
-- ---------------------------------------------------------
do $$
begin
if not exists (
select 1 from information_schema.columns
where table_schema='public' and table_name='patients' and column_name='email_alt'
) then
alter table public.patients add column email_alt text;
end if;
if not exists (
select 1 from information_schema.columns
where table_schema='public' and table_name='patients' and column_name='phones'
) then
-- array de textos (Postgres). No JS você manda ["...","..."] normalmente.
alter table public.patients add column phones text[];
end if;
if not exists (
select 1 from information_schema.columns
where table_schema='public' and table_name='patients' and column_name='gender'
) then
alter table public.patients add column gender text;
end if;
if not exists (
select 1 from information_schema.columns
where table_schema='public' and table_name='patients' and column_name='marital_status'
) then
alter table public.patients add column marital_status text;
end if;
end $$;
-- (opcional) índices úteis pra busca/filtro por nome/email
create index if not exists idx_patients_owner_name on public.patients(owner_id, name);
create index if not exists idx_patients_owner_email on public.patients(owner_id, email);
-- ---------------------------------------------------------
-- 2) patient_groups
-- ---------------------------------------------------------
create table if not exists public.patient_groups (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users(id) on delete cascade,
name text not null,
color text,
is_system boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- nome único por owner
do $$
begin
if not exists (
select 1 from pg_constraint
where conname = 'patient_groups_owner_name_uniq'
and conrelid = 'public.patient_groups'::regclass
) then
alter table public.patient_groups
add constraint patient_groups_owner_name_uniq unique(owner_id, name);
end if;
end $$;
drop trigger if exists trg_patient_groups_set_updated_at on public.patient_groups;
create trigger trg_patient_groups_set_updated_at
before update on public.patient_groups
for each row execute function public.set_updated_at();
create index if not exists idx_patient_groups_owner on public.patient_groups(owner_id);
alter table public.patient_groups enable row level security;
drop policy if exists "patient_groups_select_own" on public.patient_groups;
create policy "patient_groups_select_own"
on public.patient_groups for select
to authenticated
using (owner_id = auth.uid());
drop policy if exists "patient_groups_insert_own" on public.patient_groups;
create policy "patient_groups_insert_own"
on public.patient_groups for insert
to authenticated
with check (owner_id = auth.uid());
drop policy if exists "patient_groups_update_own" on public.patient_groups;
create policy "patient_groups_update_own"
on public.patient_groups for update
to authenticated
using (owner_id = auth.uid())
with check (owner_id = auth.uid());
drop policy if exists "patient_groups_delete_own" on public.patient_groups;
create policy "patient_groups_delete_own"
on public.patient_groups for delete
to authenticated
using (owner_id = auth.uid());
grant select, insert, update, delete on public.patient_groups to authenticated;
-- ---------------------------------------------------------
-- 3) patient_tags
-- ---------------------------------------------------------
create table if not exists public.patient_tags (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users(id) on delete cascade,
name text not null,
color text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
do $$
begin
if not exists (
select 1 from pg_constraint
where conname = 'patient_tags_owner_name_uniq'
and conrelid = 'public.patient_tags'::regclass
) then
alter table public.patient_tags
add constraint patient_tags_owner_name_uniq unique(owner_id, name);
end if;
end $$;
drop trigger if exists trg_patient_tags_set_updated_at on public.patient_tags;
create trigger trg_patient_tags_set_updated_at
before update on public.patient_tags
for each row execute function public.set_updated_at();
create index if not exists idx_patient_tags_owner on public.patient_tags(owner_id);
alter table public.patient_tags enable row level security;
drop policy if exists "patient_tags_select_own" on public.patient_tags;
create policy "patient_tags_select_own"
on public.patient_tags for select
to authenticated
using (owner_id = auth.uid());
drop policy if exists "patient_tags_insert_own" on public.patient_tags;
create policy "patient_tags_insert_own"
on public.patient_tags for insert
to authenticated
with check (owner_id = auth.uid());
drop policy if exists "patient_tags_update_own" on public.patient_tags;
create policy "patient_tags_update_own"
on public.patient_tags for update
to authenticated
using (owner_id = auth.uid())
with check (owner_id = auth.uid());
drop policy if exists "patient_tags_delete_own" on public.patient_tags;
create policy "patient_tags_delete_own"
on public.patient_tags for delete
to authenticated
using (owner_id = auth.uid());
grant select, insert, update, delete on public.patient_tags to authenticated;
-- ---------------------------------------------------------
-- 4) pivôs (patient_group_patient / patient_patient_tag)
-- ---------------------------------------------------------
create table if not exists public.patient_group_patient (
patient_id uuid not null references public.patients(id) on delete cascade,
patient_group_id uuid not null references public.patient_groups(id) on delete cascade,
created_at timestamptz not null default now(),
primary key (patient_id, patient_group_id)
);
create index if not exists idx_pgp_patient on public.patient_group_patient(patient_id);
create index if not exists idx_pgp_group on public.patient_group_patient(patient_group_id);
alter table public.patient_group_patient enable row level security;
-- a pivot “herda” tenant via join; policy usando exists pra validar owner do patient
drop policy if exists "pgp_select_own" on public.patient_group_patient;
create policy "pgp_select_own"
on public.patient_group_patient for select
to authenticated
using (
exists (
select 1 from public.patients p
where p.id = patient_group_patient.patient_id
and p.owner_id = auth.uid()
)
);
drop policy if exists "pgp_write_own" on public.patient_group_patient;
create policy "pgp_write_own"
on public.patient_group_patient for all
to authenticated
using (
exists (
select 1 from public.patients p
where p.id = patient_group_patient.patient_id
and p.owner_id = auth.uid()
)
)
with check (
exists (
select 1 from public.patients p
where p.id = patient_group_patient.patient_id
and p.owner_id = auth.uid()
)
);
grant select, insert, update, delete on public.patient_group_patient to authenticated;
-- tags pivot (ATENÇÃO: coluna é tag_id, como teu Vue usa!)
create table if not exists public.patient_patient_tag (
patient_id uuid not null references public.patients(id) on delete cascade,
tag_id uuid not null references public.patient_tags(id) on delete cascade,
created_at timestamptz not null default now(),
primary key (patient_id, tag_id)
);
create index if not exists idx_ppt_patient on public.patient_patient_tag(patient_id);
create index if not exists idx_ppt_tag on public.patient_patient_tag(tag_id);
alter table public.patient_patient_tag enable row level security;
drop policy if exists "ppt_select_own" on public.patient_patient_tag;
create policy "ppt_select_own"
on public.patient_patient_tag for select
to authenticated
using (
exists (
select 1 from public.patients p
where p.id = patient_patient_tag.patient_id
and p.owner_id = auth.uid()
)
);
drop policy if exists "ppt_write_own" on public.patient_patient_tag;
create policy "ppt_write_own"
on public.patient_patient_tag for all
to authenticated
using (
exists (
select 1 from public.patients p
where p.id = patient_patient_tag.patient_id
and p.owner_id = auth.uid()
)
)
with check (
exists (
select 1 from public.patients p
where p.id = patient_patient_tag.patient_id
and p.owner_id = auth.uid()
)
);
grant select, insert, update, delete on public.patient_patient_tag to authenticated;
-- =========================================================
-- FIM PATCH
-- =========================================================

View File

@@ -0,0 +1,105 @@
-- =========================================================
-- INTakes / Cadastros Recebidos - Supabase Local
-- =========================================================
-- 0) Extensões úteis (geralmente já existem no Supabase, mas é seguro)
create extension if not exists pgcrypto;
-- 1) Função padrão para updated_at
create or replace function public.set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
-- 2) Tabela patient_intake_requests (espelhando nuvem)
create table if not exists public.patient_intake_requests (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null,
token text,
name text,
email text,
phone text,
notes text,
consent boolean not null default false,
status text not null default 'new',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
payload jsonb
);
-- 3) Índices (performance em listagem e filtros)
create index if not exists idx_intakes_owner_created
on public.patient_intake_requests (owner_id, created_at desc);
create index if not exists idx_intakes_owner_status_created
on public.patient_intake_requests (owner_id, status, created_at desc);
create index if not exists idx_intakes_status_created
on public.patient_intake_requests (status, created_at desc);
-- 4) Trigger updated_at
drop trigger if exists trg_patient_intake_requests_updated_at on public.patient_intake_requests;
create trigger trg_patient_intake_requests_updated_at
before update on public.patient_intake_requests
for each row execute function public.set_updated_at();
-- 5) RLS
alter table public.patient_intake_requests enable row level security;
-- 6) Policies (iguais às que você mostrou na nuvem)
drop policy if exists intake_select_own on public.patient_intake_requests;
create policy intake_select_own
on public.patient_intake_requests
for select
to authenticated
using (owner_id = auth.uid());
drop policy if exists intake_update_own on public.patient_intake_requests;
create policy intake_update_own
on public.patient_intake_requests
for update
to authenticated
using (owner_id = auth.uid())
with check (owner_id = auth.uid());
drop policy if exists "delete own intake requests" on public.patient_intake_requests;
create policy "delete own intake requests"
on public.patient_intake_requests
for delete
to authenticated
using (owner_id = auth.uid());
-- =========================================================
-- OPCIONAL (RECOMENDADO): registrar conversão
-- =========================================================
-- Se você pretende marcar intake como convertido e guardar o patient_id:
alter table public.patient_intake_requests
add column if not exists converted_patient_id uuid;
create index if not exists idx_intakes_converted_patient_id
on public.patient_intake_requests (converted_patient_id);
-- Opcional: impedir delete de intakes convertidos (melhor para auditoria)
-- (Se quiser manter delete liberado como na nuvem, comente este bloco.)
drop policy if exists "delete own intake requests" on public.patient_intake_requests;
create policy "delete_own_intakes_not_converted"
on public.patient_intake_requests
for delete
to authenticated
using (owner_id = auth.uid() and status <> 'converted');
-- =========================================================
-- OPCIONAL: check de status (evita status inválido)
-- =========================================================
alter table public.patient_intake_requests
drop constraint if exists chk_intakes_status;
alter table public.patient_intake_requests
add constraint chk_intakes_status
check (status in ('new', 'converted', 'rejected'));

View File

@@ -0,0 +1,174 @@
/*
patient_groups_setup.sql
Setup completo para:
- public.patient_groups
- public.patient_group_patient (tabela ponte)
- view public.v_patient_groups_with_counts
- índice único por owner + nome (case-insensitive)
- 3 grupos padrão do sistema (Crianças, Adolescentes, Idosos) NÃO editáveis / NÃO removíveis
- triggers de proteção
Observação (importante):
- Os grupos padrão são criados com owner_id = '00000000-0000-0000-0000-000000000000' (SYSTEM_OWNER),
para ficarem "globais" e não dependerem de auth.uid() em migrations.
- Se você quiser que os grupos padrão pertençam a um owner específico (tenant),
basta trocar o SYSTEM_OWNER abaixo por esse UUID.
*/
begin;
-- ===========================
-- 0) Constante de "dono do sistema"
-- ===========================
-- Troque aqui se você quiser que os grupos padrão pertençam a um owner específico.
-- Ex.: '816b24fe-a0c3-4409-b79b-c6c0a6935d03'
do $$
begin
-- só para documentar; não cria nada
end $$;
-- ===========================
-- 1) Tabela principal: patient_groups
-- ===========================
create table if not exists public.patient_groups (
id uuid primary key default gen_random_uuid(),
name text not null,
description text,
color text,
is_active boolean not null default true,
is_system boolean not null default false,
owner_id uuid not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- (Opcional, mas recomendado) Garante que name não seja só espaços
-- e evita nomes vazios.
alter table public.patient_groups
drop constraint if exists patient_groups_name_not_blank_check;
alter table public.patient_groups
add constraint patient_groups_name_not_blank_check
check (length(btrim(name)) > 0);
-- ===========================
-- 2) Tabela ponte: patient_group_patient
-- ===========================
-- Se você já tiver essa tabela com FKs, ajuste aqui conforme seu schema.
create table if not exists public.patient_group_patient (
patient_group_id uuid not null references public.patient_groups(id) on delete cascade,
patient_id uuid not null references public.patients(id) on delete cascade,
created_at timestamptz not null default now()
);
-- Evita duplicar vínculo paciente<->grupo
create unique index if not exists patient_group_patient_unique
on public.patient_group_patient (patient_group_id, patient_id);
-- ===========================
-- 3) View com contagem
-- ===========================
create or replace view public.v_patient_groups_with_counts as
select
g.*,
coalesce(count(distinct pgp.patient_id), 0)::int as patients_count
from public.patient_groups g
left join public.patient_group_patient pgp
on pgp.patient_group_id = g.id
group by g.id;
-- ===========================
-- 4) Índice único: não permitir mesmo nome por owner (case-insensitive)
-- ===========================
-- Atenção: se já existirem duplicados, este índice pode falhar ao criar.
create unique index if not exists patient_groups_owner_name_unique
on public.patient_groups (owner_id, (lower(name)));
-- ===========================
-- 5) Triggers de proteção: system não edita / não remove
-- ===========================
create or replace function public.prevent_system_group_changes()
returns trigger
language plpgsql
as $$
begin
if old.is_system = true then
raise exception 'Grupos padrão do sistema não podem ser alterados ou excluídos.';
end if;
if tg_op = 'DELETE' then
return old;
end if;
return new;
end;
$$;
drop trigger if exists trg_prevent_system_group_changes on public.patient_groups;
create trigger trg_prevent_system_group_changes
before update or delete on public.patient_groups
for each row
execute function public.prevent_system_group_changes();
-- Impede "promover" um grupo comum para system
create or replace function public.prevent_promoting_to_system()
returns trigger
language plpgsql
as $$
begin
if new.is_system = true and old.is_system is distinct from true then
raise exception 'Não é permitido transformar um grupo comum em grupo do sistema.';
end if;
return new;
end;
$$;
drop trigger if exists trg_prevent_promoting_to_system on public.patient_groups;
create trigger trg_prevent_promoting_to_system
before update on public.patient_groups
for each row
execute function public.prevent_promoting_to_system();
-- ===========================
-- 6) Inserir 3 grupos padrão (imutáveis)
-- ===========================
-- Dono "global" do sistema (mude se quiser):
-- 00000000-0000-0000-0000-000000000000
with sys_owner as (
select '00000000-0000-0000-0000-000000000000'::uuid as owner_id
)
insert into public.patient_groups (name, description, color, is_active, is_system, owner_id)
select v.name, v.description, v.color, v.is_active, v.is_system, s.owner_id
from sys_owner s
join (values
('Crianças', 'Grupo padrão do sistema', null, true, true),
('Adolescentes', 'Grupo padrão do sistema', null, true, true),
('Idosos', 'Grupo padrão do sistema', null, true, true)
) as v(name, description, color, is_active, is_system)
on true
where not exists (
select 1
from public.patient_groups g
where g.owner_id = s.owner_id
and lower(g.name) = lower(v.name)
);
commit;
/*
Testes rápidos:
1) Ver tudo:
select * from public.v_patient_groups_with_counts order by is_system desc, name;
2) Tentar editar um system (deve falhar):
update public.patient_groups set name='X' where name='Crianças';
3) Tentar deletar um system (deve falhar):
delete from public.patient_groups where name='Crianças';
4) Tentar duplicar nome no mesmo owner (deve falhar por índice único):
insert into public.patient_groups (name, is_active, is_system, owner_id)
values ('teste22', true, false, '816b24fe-a0c3-4409-b79b-c6c0a6935d03');
*/

View File

@@ -0,0 +1,147 @@
-- =========================================================
-- pacientesIndexPage.sql
-- Views + índices para a tela PatientsListPage
-- =========================================================
-- 0) Extensões úteis
create extension if not exists pg_trgm;
-- 1) updated_at automático (se você quiser manter updated_at sempre correto)
create or replace function public.set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists trg_patients_set_updated_at on public.patients;
create trigger trg_patients_set_updated_at
before update on public.patients
for each row execute function public.set_updated_at();
-- =========================================================
-- 2) Views de contagem (usadas em KPIs e telas auxiliares)
-- =========================================================
-- 2.1) Grupos com contagem de pacientes
create or replace view public.v_patient_groups_with_counts as
select
g.id,
g.name,
g.color,
coalesce(count(pgp.patient_id), 0)::int as patients_count
from public.patient_groups g
left join public.patient_group_patient pgp
on pgp.patient_group_id = g.id
group by g.id, g.name, g.color;
-- 2.2) Tags com contagem de pacientes
create or replace view public.v_tag_patient_counts as
select
t.id,
t.name,
t.color,
coalesce(count(ppt.patient_id), 0)::int as patients_count
from public.patient_tags t
left join public.patient_patient_tag ppt
on ppt.tag_id = t.id
group by t.id, t.name, t.color;
-- =========================================================
-- 3) View principal da Index (pacientes + grupos/tags agregados)
-- =========================================================
create or replace view public.v_patients_index as
select
p.*,
-- array JSON com os grupos do paciente
coalesce(gx.groups, '[]'::jsonb) as groups,
-- array JSON com as tags do paciente
coalesce(tx.tags, '[]'::jsonb) as tags,
-- contagens para UI/KPIs
coalesce(gx.groups_count, 0)::int as groups_count,
coalesce(tx.tags_count, 0)::int as tags_count
from public.patients p
left join lateral (
select
jsonb_agg(
distinct jsonb_build_object(
'id', g.id,
'name', g.name,
'color', g.color
)
) filter (where g.id is not null) as groups,
count(distinct g.id) as groups_count
from public.patient_group_patient pgp
join public.patient_groups g
on g.id = pgp.patient_group_id
where pgp.patient_id = p.id
) gx on true
left join lateral (
select
jsonb_agg(
distinct jsonb_build_object(
'id', t.id,
'name', t.name,
'color', t.color
)
) filter (where t.id is not null) as tags,
count(distinct t.id) as tags_count
from public.patient_patient_tag ppt
join public.patient_tags t
on t.id = ppt.tag_id
where ppt.patient_id = p.id
) tx on true;
-- =========================================================
-- 4) Índices recomendados (performance real na listagem/filtros)
-- =========================================================
-- Patients
create index if not exists idx_patients_owner_id
on public.patients (owner_id);
create index if not exists idx_patients_created_at
on public.patients (created_at desc);
create index if not exists idx_patients_status
on public.patients (status);
create index if not exists idx_patients_last_attended_at
on public.patients (last_attended_at desc);
-- Busca rápida (name/email/phone)
create index if not exists idx_patients_name_trgm
on public.patients using gin (name gin_trgm_ops);
create index if not exists idx_patients_email_trgm
on public.patients using gin (email gin_trgm_ops);
create index if not exists idx_patients_phone_trgm
on public.patients using gin (phone gin_trgm_ops);
-- Pivot: grupos
create index if not exists idx_pgp_patient_id
on public.patient_group_patient (patient_id);
create index if not exists idx_pgp_group_id
on public.patient_group_patient (patient_group_id);
-- Pivot: tags
create index if not exists idx_ppt_patient_id
on public.patient_patient_tag (patient_id);
create index if not exists idx_ppt_tag_id
on public.patient_patient_tag (tag_id);

View File

@@ -0,0 +1,134 @@
create extension if not exists pgcrypto;
-- ===============================
-- TABELA: patient_tags
-- ===============================
create table if not exists public.patient_tags (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null,
name text not null,
color text,
is_native boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz
);
create unique index if not exists patient_tags_owner_name_uq
on public.patient_tags (owner_id, lower(name));
-- ===============================
-- TABELA: patient_patient_tag (pivot)
-- ===============================
create table if not exists public.patient_patient_tag (
owner_id uuid not null,
patient_id uuid not null,
tag_id uuid not null,
created_at timestamptz not null default now(),
primary key (patient_id, tag_id)
);
create index if not exists ppt_owner_idx on public.patient_patient_tag(owner_id);
create index if not exists ppt_tag_idx on public.patient_patient_tag(tag_id);
create index if not exists ppt_patient_idx on public.patient_patient_tag(patient_id);
-- ===============================
-- FOREIGN KEYS (com checagem)
-- ===============================
do $$
begin
if not exists (
select 1 from pg_constraint
where conname = 'ppt_tag_fk'
and conrelid = 'public.patient_patient_tag'::regclass
) then
alter table public.patient_patient_tag
add constraint ppt_tag_fk
foreign key (tag_id)
references public.patient_tags(id)
on delete cascade;
end if;
end $$;
do $$
begin
if not exists (
select 1 from pg_constraint
where conname = 'ppt_patient_fk'
and conrelid = 'public.patient_patient_tag'::regclass
) then
alter table public.patient_patient_tag
add constraint ppt_patient_fk
foreign key (patient_id)
references public.patients(id)
on delete cascade;
end if;
end $$;
-- ===============================
-- VIEW: contagem por tag
-- ===============================
create or replace view public.v_tag_patient_counts as
select
t.id,
t.owner_id,
t.name,
t.color,
t.is_native,
t.created_at,
t.updated_at,
coalesce(count(ppt.patient_id), 0)::int as patient_count
from public.patient_tags t
left join public.patient_patient_tag ppt
on ppt.tag_id = t.id
and ppt.owner_id = t.owner_id
group by
t.id, t.owner_id, t.name, t.color, t.is_native, t.created_at, t.updated_at;
-- ===============================
-- RLS
-- ===============================
alter table public.patient_tags enable row level security;
alter table public.patient_patient_tag enable row level security;
drop policy if exists tags_select_own on public.patient_tags;
create policy tags_select_own
on public.patient_tags
for select
using (owner_id = auth.uid());
drop policy if exists tags_insert_own on public.patient_tags;
create policy tags_insert_own
on public.patient_tags
for insert
with check (owner_id = auth.uid());
drop policy if exists tags_update_own on public.patient_tags;
create policy tags_update_own
on public.patient_tags
for update
using (owner_id = auth.uid())
with check (owner_id = auth.uid());
drop policy if exists tags_delete_own on public.patient_tags;
create policy tags_delete_own
on public.patient_tags
for delete
using (owner_id = auth.uid());
drop policy if exists ppt_select_own on public.patient_patient_tag;
create policy ppt_select_own
on public.patient_patient_tag
for select
using (owner_id = auth.uid());
drop policy if exists ppt_insert_own on public.patient_patient_tag;
create policy ppt_insert_own
on public.patient_patient_tag
for insert
with check (owner_id = auth.uid());
drop policy if exists ppt_delete_own on public.patient_patient_tag;
create policy ppt_delete_own
on public.patient_patient_tag
for delete
using (owner_id = auth.uid());

View File

@@ -0,0 +1,2 @@
ALTER TABLE public.agenda_configuracoes DROP COLUMN IF EXISTS
session_start_offset_min;

View File

@@ -0,0 +1 @@
select pg_get_functiondef('public.NOME_DA_FUNCAO(args_aqui)'::regprocedure);

View File

@@ -0,0 +1,45 @@
-- 1) Tabela profiles
create table if not exists public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
role text not null default 'patient' check (role in ('admin','therapist','patient')),
full_name text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- 2) updated_at automático
create or replace function public.set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists trg_profiles_updated_at on public.profiles;
create trigger trg_profiles_updated_at
before update on public.profiles
for each row execute function public.set_updated_at();
-- 3) Trigger: cria profile automaticamente quando usuário nasce no auth
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.profiles (id, role)
values (new.id, 'patient')
on conflict (id) do nothing;
return new;
end;
$$;
drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();

View File

@@ -0,0 +1,20 @@
do $$
begin
if exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'patient_patient_tag'
and column_name = 'patient_tag_id'
)
and not exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'patient_patient_tag'
and column_name = 'tag_id'
) then
alter table public.patient_patient_tag
rename column patient_tag_id to tag_id;
end if;
end $$;

View File

@@ -0,0 +1,5 @@
insert into public.profiles (id, role)
select u.id, 'patient'
from auth.users u
left join public.profiles p on p.id = u.id
where p.id is null;

View File

@@ -0,0 +1,12 @@
select
id, name, email, phone,
birth_date, cpf, rg, gender,
marital_status, profession,
place_of_birth, education_level,
cep, address_street, address_number,
address_neighborhood, address_city, address_state,
phone_alt,
notes, consent, created_at
from public.patient_intake_requests
order by created_at desc
limit 1;

View File

@@ -0,0 +1,23 @@
create or replace function public.agenda_cfg_sync()
returns trigger
language plpgsql
as $$
begin
if new.agenda_view_mode = 'custom' then
new.usar_horario_admin_custom := true;
new.admin_inicio_visualizacao := new.agenda_custom_start;
new.admin_fim_visualizacao := new.agenda_custom_end;
else
new.usar_horario_admin_custom := false;
end if;
return new;
end;
$$;
drop trigger if exists trg_agenda_cfg_sync on public.agenda_configuracoes;
create trigger trg_agenda_cfg_sync
before insert or update on public.agenda_configuracoes
for each row
execute function public.agenda_cfg_sync();

View File

@@ -0,0 +1,2 @@
drop index if exists public.uq_subscriptions_tenant;
drop index if exists public.uq_subscriptions_personal_user;

View File

@@ -0,0 +1,6 @@
-- Limpa TUDO da agenda (eventos, regras, exceções)
-- Execute no Supabase Studio — não tem volta!
DELETE FROM recurrence_exceptions;
DELETE FROM recurrence_rules;
DELETE FROM agenda_eventos;

View File

@@ -0,0 +1,12 @@
select
t.tgname as trigger_name,
pg_get_triggerdef(t.oid) as trigger_def,
p.proname as function_name,
n.nspname as function_schema
from pg_trigger t
join pg_proc p on p.oid = t.tgfoid
join pg_namespace n on n.oid = p.pronamespace
join pg_class c on c.oid = t.tgrelid
where not t.tgisinternal
and c.relname = 'patient_intake_requests'
order by t.tgname;

View File

@@ -0,0 +1,46 @@
-- ============================================================
-- LIMPEZA DE DADOS DE TESTE — filtra por tenant/owner
-- Execute no Supabase Studio com cuidado -- ============================================================
DO $$ DECLARE
v_tenant_id uuid := 'bbbbbbbb-0002-0002-0002-000000000002';
v_owner_id uuid := 'aaaaaaaa-0002-0002-0002-000000000002';
n_exc int;
n_ev int;
n_rule int;
n_sol int;
BEGIN
-- 1. Exceções (filha de recurrence_rules — apagar primeiro)
DELETE FROM public.recurrence_exceptions
WHERE recurrence_id IN (
SELECT id FROM public.recurrence_rules
WHERE (v_tenant_id IS NULL OR tenant_id = v_tenant_id)
AND (v_owner_id IS NULL OR owner_id = v_owner_id)
);
GET DIAGNOSTICS n_exc = ROW_COUNT;
-- 2. Regras de recorrência
DELETE FROM public.recurrence_rules
WHERE (v_tenant_id IS NULL OR tenant_id = v_tenant_id)
AND (v_owner_id IS NULL OR owner_id = v_owner_id);
GET DIAGNOSTICS n_rule = ROW_COUNT;
-- 3. Eventos da agenda
DELETE FROM public.agenda_eventos
WHERE (v_tenant_id IS NULL OR tenant_id = v_tenant_id)
AND (v_owner_id IS NULL OR owner_id = v_owner_id);
GET DIAGNOSTICS n_ev = ROW_COUNT;
-- 4. Solicitações públicas (agendador online)
DELETE FROM public.agendador_solicitacoes
WHERE (v_tenant_id IS NULL OR tenant_id = v_tenant_id)
AND (v_owner_id IS NULL OR owner_id = v_owner_id);
GET DIAGNOSTICS n_sol = ROW_COUNT;
RAISE NOTICE '✅ Limpeza concluída:';
RAISE NOTICE ' recurrence_exceptions : %', n_exc;
RAISE NOTICE ' recurrence_rules : %', n_rule;
RAISE NOTICE ' agenda_eventos : %', n_ev;
RAISE NOTICE ' agendador_solicitacoes : %', n_sol;
END;
$$;

View File

@@ -0,0 +1,10 @@
select
id as tenant_member_id,
tenant_id,
user_id,
role,
status,
created_at
from public.tenant_members
where user_id = '824f125c-55bb-40f5-a8c4-7a33618b91c7'
order by created_at desc;

View File

@@ -0,0 +1,4 @@
select *
from agenda_eventos
order by created_at desc nulls last
limit 10;

View File

@@ -0,0 +1,6 @@
select
routine_name,
routine_type
from information_schema.routines
where routine_schema = 'public'
and routine_name ilike '%intake%';

View File

@@ -0,0 +1,4 @@
select id as owner_id, email, created_at
from auth.users
where email = 'admin@agendapsi.com.br'
limit 1;

View File

@@ -0,0 +1,8 @@
select
id,
owner_id,
status,
created_at,
converted_patient_id
from public.patient_intake_requests
where id = '54daa09a-b2cb-4a0b-91aa-e4cea1915efe';

View File

@@ -0,0 +1,6 @@
select f.key as feature_key
from public.plan_features pf
join public.features f on f.id = pf.feature_id
where pf.plan_id = 'fdc2813d-dfaa-4e2c-b71d-ef7b84dfd9e9'
and pf.enabled = true
order by f.key;

View File

@@ -0,0 +1,3 @@
select id, key, name
from public.plans
order by key;

View File

@@ -0,0 +1 @@
SELECT * FROM public.agendador_solicitacoes LIMIT 5;

View File

@@ -0,0 +1,5 @@
select table_schema, table_name, column_name
from information_schema.columns
where column_name in ('owner_id', 'tenant_id')
and table_schema = 'public'
order by table_name;

View File

@@ -0,0 +1,12 @@
alter table public.patient_groups enable row level security;
drop policy if exists patient_groups_select on public.patient_groups;
create policy patient_groups_select
on public.patient_groups
for select
to authenticated
using (
owner_id = auth.uid()
or owner_id is null
);

View File

@@ -0,0 +1,9 @@
select *
from public.owner_feature_entitlements
where owner_id = '816b24fe-a0c3-4409-b79b-c6c0a6935d03'::uuid
order by feature_key;
select public.has_feature('816b24fe-a0c3-4409-b79b-c6c0a6935d03'::uuid, 'online_scheduling.manage') as can_manage;

View File

@@ -0,0 +1,5 @@
create index if not exists idx_patient_group_patient_group_id
on public.patient_group_patient (patient_group_id);
create index if not exists idx_patient_groups_owner_system_nome
on public.patient_groups (owner_id, is_system, nome);

View File

@@ -0,0 +1,9 @@
-- 1) Marcar como SaaS master
insert into public.saas_admins (user_id)
values ('40a4b683-a0c9-4890-a201-20faf41fca06')
on conflict (user_id) do nothing;
-- 2) Garantir profile (seu session.js busca role em profiles)
insert into public.profiles (id, role)
values ('40a4b683-a0c9-4890-a201-20faf41fca06', 'saas_admin')
on conflict (id) do update set role = excluded.role;

View File

@@ -0,0 +1,5 @@
select *
from public.owner_feature_entitlements
where owner_id = '816b24fe-a0c3-4409-b79b-c6c0a6935d03'::uuid
order by feature_key;

View File

@@ -0,0 +1,5 @@
select column_name, data_type
from information_schema.columns
where table_schema = 'public'
and table_name = 'patient_patient_tag'
order by ordinal_position;

View File

@@ -0,0 +1,18 @@
create or replace function public.prevent_promoting_to_system()
returns trigger
language plpgsql
as $$
begin
if new.is_system = true and old.is_system is distinct from true then
raise exception 'Não é permitido transformar um grupo comum em grupo do sistema.';
end if;
return new;
end;
$$;
drop trigger if exists trg_prevent_promoting_to_system on public.patient_groups;
create trigger trg_prevent_promoting_to_system
before update on public.patient_groups
for each row
execute function public.prevent_promoting_to_system();

View File

@@ -0,0 +1,4 @@
select id, tenant_id, user_id, role, status, created_at
from tenant_members
where user_id = '1715ec83-9a30-4dce-b73a-2deb66dcfb13'
order by created_at desc;

View File

@@ -0,0 +1,2 @@
const { data, error } = await supabase.rpc('my_tenants')
console.log({ data, error })

View File

@@ -0,0 +1,6 @@
select id, tenant_id, user_id, role, status, created_at
from tenant_members
where tenant_id = '816b24fe-a0c3-4409-b79b-c6c0a6935d03'::uuid
order by
(case when role = 'clinic_admin' then 0 else 1 end),
created_at;

View File

@@ -0,0 +1,15 @@
-- Para o tenant A:
select id as responsible_member_id
from public.tenant_members
where tenant_id = '816b24fe-a0c3-4409-b79b-c6c0a6935d03'
and user_id = '824f125c-55bb-40f5-a8c4-7a33618b91c7'
and status = 'active'
limit 1;
-- Para o tenant B:
select id as responsible_member_id
from public.tenant_members
where tenant_id = 'e8b10543-fb36-4e75-9d37-6fece9745637'
and user_id = '824f125c-55bb-40f5-a8c4-7a33618b91c7'
and status = 'active'
limit 1;

View File

@@ -0,0 +1,4 @@
select id, name, kind, created_at
from public.tenants
order by created_at desc
limit 10;

View File

@@ -0,0 +1,5 @@
select id as member_id
from tenant_members
where tenant_id = '816b24fe-a0c3-4409-b79b-c6c0a6935d03'
and user_id = '824f125c-55bb-40f5-a8c4-7a33618b91c7'
limit 1;

View File

@@ -0,0 +1,2 @@
insert into public.users (id, created_at)
values ('e8b10543-fb36-4e75-9d37-6fece9745637', now());

View File

@@ -0,0 +1,4 @@
select *
from public.tenant_members
where tenant_id = 'UUID_AQUI'
order by created_at desc;

View File

@@ -0,0 +1,17 @@
create table if not exists public.subscriptions (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
plan_key text not null,
interval text not null check (interval in ('month','year')),
status text not null check (status in ('active','canceled','past_due','trial')) default 'active',
started_at timestamptz not null default now(),
current_period_start timestamptz not null default now(),
current_period_end timestamptz null,
canceled_at timestamptz null,
source text not null default 'manual',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists subscriptions_user_id_idx on public.subscriptions(user_id);
create index if not exists subscriptions_status_idx on public.subscriptions(status);

View File

@@ -0,0 +1,13 @@
-- Ver todas as regras no banco
SELECT
id,
owner_id,
status,
type,
weekdays,
start_date,
end_date,
start_time,
created_at
FROM recurrence_rules
ORDER BY created_at DESC;

View File

@@ -0,0 +1,3 @@
select id, name, created_at
from public.tenants
order by created_at desc;

View File

@@ -0,0 +1,8 @@
alter table public.profiles enable row level security;
drop policy if exists "profiles_select_own" on public.profiles;
create policy "profiles_select_own"
on public.profiles
for select
to authenticated
using (id = auth.uid());

View File

@@ -0,0 +1,7 @@
-- 2) Tenho membership ativa no tenant atual?
select *
from tenant_members
where tenant_id = 'e8b10543-fb36-4e75-9d37-6fece9745637'
and user_id = auth.uid()
order by created_at desc;

View File

@@ -0,0 +1,8 @@
select
n.nspname as schema,
p.proname as function_name,
pg_get_functiondef(p.oid) as definition
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and pg_get_functiondef(p.oid) ilike '%entitlements_invalidation%';

View File

@@ -0,0 +1,266 @@
-- =========================================================
-- PATCH — Completar cadastro para bater com PatientsCadastroPage.vue
-- (rode DEPOIS do seu supabase_cadastro_pacientes.sql)
-- =========================================================
create extension if not exists pgcrypto;
-- ---------------------------------------------------------
-- 1) Completar colunas que o front usa e hoje faltam em patients
-- ---------------------------------------------------------
do $$
begin
if not exists (
select 1 from information_schema.columns
where table_schema='public' and table_name='patients' and column_name='email_alt'
) then
alter table public.patients add column email_alt text;
end if;
if not exists (
select 1 from information_schema.columns
where table_schema='public' and table_name='patients' and column_name='phones'
) then
-- array de textos (Postgres). No JS você manda ["...","..."] normalmente.
alter table public.patients add column phones text[];
end if;
if not exists (
select 1 from information_schema.columns
where table_schema='public' and table_name='patients' and column_name='gender'
) then
alter table public.patients add column gender text;
end if;
if not exists (
select 1 from information_schema.columns
where table_schema='public' and table_name='patients' and column_name='marital_status'
) then
alter table public.patients add column marital_status text;
end if;
end $$;
-- (opcional) índices úteis pra busca/filtro por nome/email
create index if not exists idx_patients_owner_name on public.patients(owner_id, name);
create index if not exists idx_patients_owner_email on public.patients(owner_id, email);
-- ---------------------------------------------------------
-- 2) patient_groups
-- ---------------------------------------------------------
create table if not exists public.patient_groups (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users(id) on delete cascade,
name text not null,
color text,
is_system boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- nome único por owner
do $$
begin
if not exists (
select 1 from pg_constraint
where conname = 'patient_groups_owner_name_uniq'
and conrelid = 'public.patient_groups'::regclass
) then
alter table public.patient_groups
add constraint patient_groups_owner_name_uniq unique(owner_id, name);
end if;
end $$;
drop trigger if exists trg_patient_groups_set_updated_at on public.patient_groups;
create trigger trg_patient_groups_set_updated_at
before update on public.patient_groups
for each row execute function public.set_updated_at();
create index if not exists idx_patient_groups_owner on public.patient_groups(owner_id);
alter table public.patient_groups enable row level security;
drop policy if exists "patient_groups_select_own" on public.patient_groups;
create policy "patient_groups_select_own"
on public.patient_groups for select
to authenticated
using (owner_id = auth.uid());
drop policy if exists "patient_groups_insert_own" on public.patient_groups;
create policy "patient_groups_insert_own"
on public.patient_groups for insert
to authenticated
with check (owner_id = auth.uid());
drop policy if exists "patient_groups_update_own" on public.patient_groups;
create policy "patient_groups_update_own"
on public.patient_groups for update
to authenticated
using (owner_id = auth.uid())
with check (owner_id = auth.uid());
drop policy if exists "patient_groups_delete_own" on public.patient_groups;
create policy "patient_groups_delete_own"
on public.patient_groups for delete
to authenticated
using (owner_id = auth.uid());
grant select, insert, update, delete on public.patient_groups to authenticated;
-- ---------------------------------------------------------
-- 3) patient_tags
-- ---------------------------------------------------------
create table if not exists public.patient_tags (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users(id) on delete cascade,
name text not null,
color text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
do $$
begin
if not exists (
select 1 from pg_constraint
where conname = 'patient_tags_owner_name_uniq'
and conrelid = 'public.patient_tags'::regclass
) then
alter table public.patient_tags
add constraint patient_tags_owner_name_uniq unique(owner_id, name);
end if;
end $$;
drop trigger if exists trg_patient_tags_set_updated_at on public.patient_tags;
create trigger trg_patient_tags_set_updated_at
before update on public.patient_tags
for each row execute function public.set_updated_at();
create index if not exists idx_patient_tags_owner on public.patient_tags(owner_id);
alter table public.patient_tags enable row level security;
drop policy if exists "patient_tags_select_own" on public.patient_tags;
create policy "patient_tags_select_own"
on public.patient_tags for select
to authenticated
using (owner_id = auth.uid());
drop policy if exists "patient_tags_insert_own" on public.patient_tags;
create policy "patient_tags_insert_own"
on public.patient_tags for insert
to authenticated
with check (owner_id = auth.uid());
drop policy if exists "patient_tags_update_own" on public.patient_tags;
create policy "patient_tags_update_own"
on public.patient_tags for update
to authenticated
using (owner_id = auth.uid())
with check (owner_id = auth.uid());
drop policy if exists "patient_tags_delete_own" on public.patient_tags;
create policy "patient_tags_delete_own"
on public.patient_tags for delete
to authenticated
using (owner_id = auth.uid());
grant select, insert, update, delete on public.patient_tags to authenticated;
-- ---------------------------------------------------------
-- 4) pivôs (patient_group_patient / patient_patient_tag)
-- ---------------------------------------------------------
create table if not exists public.patient_group_patient (
patient_id uuid not null references public.patients(id) on delete cascade,
patient_group_id uuid not null references public.patient_groups(id) on delete cascade,
created_at timestamptz not null default now(),
primary key (patient_id, patient_group_id)
);
create index if not exists idx_pgp_patient on public.patient_group_patient(patient_id);
create index if not exists idx_pgp_group on public.patient_group_patient(patient_group_id);
alter table public.patient_group_patient enable row level security;
-- a pivot “herda” tenant via join; policy usando exists pra validar owner do patient
drop policy if exists "pgp_select_own" on public.patient_group_patient;
create policy "pgp_select_own"
on public.patient_group_patient for select
to authenticated
using (
exists (
select 1 from public.patients p
where p.id = patient_group_patient.patient_id
and p.owner_id = auth.uid()
)
);
drop policy if exists "pgp_write_own" on public.patient_group_patient;
create policy "pgp_write_own"
on public.patient_group_patient for all
to authenticated
using (
exists (
select 1 from public.patients p
where p.id = patient_group_patient.patient_id
and p.owner_id = auth.uid()
)
)
with check (
exists (
select 1 from public.patients p
where p.id = patient_group_patient.patient_id
and p.owner_id = auth.uid()
)
);
grant select, insert, update, delete on public.patient_group_patient to authenticated;
-- tags pivot (ATENÇÃO: coluna é tag_id, como teu Vue usa!)
create table if not exists public.patient_patient_tag (
patient_id uuid not null references public.patients(id) on delete cascade,
tag_id uuid not null references public.patient_tags(id) on delete cascade,
created_at timestamptz not null default now(),
primary key (patient_id, tag_id)
);
create index if not exists idx_ppt_patient on public.patient_patient_tag(patient_id);
create index if not exists idx_ppt_tag on public.patient_patient_tag(tag_id);
alter table public.patient_patient_tag enable row level security;
drop policy if exists "ppt_select_own" on public.patient_patient_tag;
create policy "ppt_select_own"
on public.patient_patient_tag for select
to authenticated
using (
exists (
select 1 from public.patients p
where p.id = patient_patient_tag.patient_id
and p.owner_id = auth.uid()
)
);
drop policy if exists "ppt_write_own" on public.patient_patient_tag;
create policy "ppt_write_own"
on public.patient_patient_tag for all
to authenticated
using (
exists (
select 1 from public.patients p
where p.id = patient_patient_tag.patient_id
and p.owner_id = auth.uid()
)
)
with check (
exists (
select 1 from public.patients p
where p.id = patient_patient_tag.patient_id
and p.owner_id = auth.uid()
)
);
grant select, insert, update, delete on public.patient_patient_tag to authenticated;
-- =========================================================
-- FIM PATCH
-- =========================================================

View File

@@ -0,0 +1,42 @@
-- BUCKET avatars: RLS por pasta do usuário "<uid>/..."
-- Requer que seu path seja: `${auth.uid()}/...` (no seu código já é)
drop policy if exists "avatars_select_own" on storage.objects;
create policy "avatars_select_own"
on storage.objects for select
to authenticated
using (
bucket_id = 'avatars'
and name like auth.uid()::text || '/%'
);
drop policy if exists "avatars_insert_own" on storage.objects;
create policy "avatars_insert_own"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'avatars'
and name like auth.uid()::text || '/%'
);
drop policy if exists "avatars_update_own" on storage.objects;
create policy "avatars_update_own"
on storage.objects for update
to authenticated
using (
bucket_id = 'avatars'
and name like auth.uid()::text || '/%'
)
with check (
bucket_id = 'avatars'
and name like auth.uid()::text || '/%'
);
drop policy if exists "avatars_delete_own" on storage.objects;
create policy "avatars_delete_own"
on storage.objects for delete
to authenticated
using (
bucket_id = 'avatars'
and name like auth.uid()::text || '/%'
);

View File

@@ -0,0 +1,3 @@
select *
from tenant_members
where user_id = 'SEU_USER_ID';

View File

@@ -0,0 +1,14 @@
select
tm.id as responsible_member_id,
tm.tenant_id,
tm.user_id,
tm.role,
tm.status,
tm.created_at
from public.tenant_members tm
where tm.tenant_id = (
select owner_id from public.patient_intake_requests
where id = '54daa09a-b2cb-4a0b-91aa-e4cea1915efe'
)
and tm.user_id = '824f125c-55bb-40f5-a8c4-7a33618b91c7'
limit 10;

View File

@@ -0,0 +1,17 @@
-- 1) Qual é meu uid?
select auth.uid() as my_uid;
-- 2) Tenho membership ativa no tenant atual?
select *
from tenant_members
where tenant_id = 'e8b10543-fb36-4e75-9d37-6fece9745637'
and user_id = auth.uid()
order by created_at desc;
-- 3) Se você usa status:
select *
from tenant_members
where tenant_id = 'e8b10543-fb36-4e75-9d37-6fece9745637'
and user_id = auth.uid()
and status = 'active'
order by created_at desc;

View File

@@ -0,0 +1,123 @@
-- ============================================================
-- saas_docs — Documentação dinâmica do sistema
-- Exibida nas páginas do frontend via botão "Ajuda"
-- ============================================================
-- ------------------------------------------------------------
-- 1. TABELA
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.saas_docs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
titulo text NOT NULL,
conteudo text NOT NULL DEFAULT '',
medias jsonb NOT NULL DEFAULT '[]'::jsonb,
-- formato: [{ "tipo": "imagem"|"video", "url": "..." }, ...]
tipo_acesso text NOT NULL DEFAULT 'usuario'
CHECK (tipo_acesso IN ('usuario', 'admin')),
-- 'usuario' → todos os autenticados
-- 'admin' → clinic_admin, tenant_admin, saas_admin
pagina_path text NOT NULL,
-- path da rota do frontend, ex: '/therapist/agenda'
pagina_label text,
-- label amigável (informativo, não usado no match)
docs_relacionados uuid[] NOT NULL DEFAULT '{}',
-- IDs de outros saas_docs exibidos como "Veja também"
ativo boolean NOT NULL DEFAULT true,
ordem int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- ------------------------------------------------------------
-- 2. ÍNDICE
-- ------------------------------------------------------------
-- Query principal do frontend: filtra por path + ativo
CREATE INDEX IF NOT EXISTS saas_docs_path_ativo_idx
ON public.saas_docs (pagina_path, ativo);
-- ------------------------------------------------------------
-- 3. RLS
-- ------------------------------------------------------------
ALTER TABLE public.saas_docs ENABLE ROW LEVEL SECURITY;
-- SaaS admin: acesso total (SELECT, INSERT, UPDATE, DELETE)
-- Verificado via tabela saas_admins
CREATE POLICY "saas_admin_full_access" ON public.saas_docs
FOR ALL
TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.saas_admins
WHERE saas_admins.user_id = auth.uid()
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.saas_admins
WHERE saas_admins.user_id = auth.uid()
)
);
-- Admins de clínica: leem todos os docs ativos (usuario + admin)
CREATE POLICY "clinic_admin_read_all_docs" ON public.saas_docs
FOR SELECT
TO authenticated
USING (
ativo = true
AND EXISTS (
SELECT 1 FROM public.profiles
WHERE profiles.id = auth.uid()
AND profiles.role IN ('clinic_admin', 'tenant_admin')
)
);
-- Demais usuários autenticados: leem apenas docs do tipo 'usuario'
CREATE POLICY "users_read_usuario_docs" ON public.saas_docs
FOR SELECT
TO authenticated
USING (
ativo = true
AND tipo_acesso = 'usuario'
);
-- ------------------------------------------------------------
-- 4. STORAGE — bucket saas-docs (imagens dos documentos)
-- ------------------------------------------------------------
INSERT INTO storage.buckets (id, name, public)
VALUES ('saas-docs', 'saas-docs', true)
ON CONFLICT (id) DO NOTHING;
-- SaaS admin: pode fazer upload
CREATE POLICY "saas_admin_storage_upload" ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'saas-docs'
AND EXISTS (
SELECT 1 FROM public.saas_admins
WHERE saas_admins.user_id = auth.uid()
)
);
-- SaaS admin: pode deletar
CREATE POLICY "saas_admin_storage_delete" ON storage.objects
FOR DELETE
TO authenticated
USING (
bucket_id = 'saas-docs'
AND EXISTS (
SELECT 1 FROM public.saas_admins
WHERE saas_admins.user_id = auth.uid()
)
);
-- Leitura pública (bucket é público, mas policy explícita para clareza)
CREATE POLICY "saas_docs_public_read" ON storage.objects
FOR SELECT
TO public
USING (bucket_id = 'saas-docs');

View File

@@ -0,0 +1,87 @@
Debug Agenda
Funcionando :
- Cadastrar compromisso do tipo Sessão, ok
- Editar compromisso do tipo Sessão, ok
- Trocar Valor do compromisso do tipo sessão, ok
- Ajustar horário arrastando do compromisso do tipo sessão, ok
- Cada compromisso exibe sua cor própria ao ser adicionado na agenda, ok
- Ao Atualizar as cores na Sessão de Compromissos, elas são ajustadas na agenda também, ok
- Status [Agendado, Realizado, Faltou, Cancelado e Remarcar] Todos estão funcionando, ok
- O criar compromisso do tipo sessão está trazendo o valor padrão das configuraçãoes, ok
- O editar está salvando o novo valor informado, ok
- Modalidade [Presencial, Online] está sendo salva ao editar, ok
- Ajuste de Mensagem : "Conflito com X às HH:MM" não é do samePatientConflict : Encontrei o bug. Na timeConflict (linha 1785), a exclusão do evento atual usa form.value.id — mas ocorrências virtuais têm id: null, então nunca são excluídas. A ocorrência fica conflitando consigo mesma.
Causa: ocorrências virtuais têm id: null, então if (form.value.id && ...) nunca excluía a própria ocorrência da checagem — ela conflitava consigo mesma. Agora, para ocorrências virtuais, a exclusão é feita pelo par recurrence_id + original_date.
- Adição de Recorrencia, ok
- Remoção de reorrencia, ok
- Remoção de recorrencias futuras, ok
- Remoção de todas as recorrencias, mantendo o compromisso raiz (inicial), ok
- Adicionado Confirmação de remoção e frase de efeito, ok
- "Todos os agendamentos futuros da série serão removidos permanentemente. Esta sessão será mantida como avulsa. Esta ação é irreversível."
- "A série foi encerrada. Esta sessão foi mantida como avulsa."
Bugs conhecidos corrigidos :
Feito. Resumo dos 4 fixes:
┌──────┬─────────────────────────────────────────┬───────────────────────────────────────────────────────────────┐
│ # │ Problema │ Solução │ ├──────┼─────────────────────────────────────────┼───────────────────────────────────────────────────────────────┤
│ Bug │ Recorrência em evento existente → só │ Removida condição !id — agora cria a regra e vincula o evento │ │ 1 │ fazia update │ existente │
├──────┼─────────────────────────────────────────┼───────────────────────────────────────────────────────────────┤
│ Bug │ Botão de lixeira some quando form.id é │ Substituído por botão "Encerrar série" para hasSerie, lixeira │
│ 2a │ null (virtual) │ simples para eventos avulsos │
├──────┼─────────────────────────────────────────┼───────────────────────────────────────────────────────────────┤
│ Bug │ X das pills some para sessões │ Removida restrição v-if="!isPast" — X sempre visível │
│ 2b │ passadas/canceladas │ │
├──────┼─────────────────────────────────────────┼───────────────────────────────────────────────────────────────┤
│ Bug │ Sem confirmação ao encerrar série pelo │ onEncerrarSerie() com confirm.require() e aviso de │
│ 2c │ botão novo │ irreversibilidade │
└──────┴─────────────────────────────────────────┴───────────────────────────────────────────────────────────────┘
- Dots (mini-day-dot) adicionados em todos os dias que possuem compromissos, ok
Corrigido também na AgendaClinicaPage:
┌────────────────────────┬────────────────────────────────────────────────────────────────────────────────────────┐
│ Fix │ O que foi feito │ ├────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────┤
│ Mini-day-dot │ loadMiniMonthEvents agora itera sobre todos os ownerIds da clínica chamando │ │ │ loadAndExpand para incluir ocorrências virtuais de recorrência │
├────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────┤
│ 'todos' delete │ Mantém sessão atual como avulsa antes de cancelar a regra (igual ao terapeuta, usando │
│ │ createClinic/updateClinic) │
├────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────┤
│ Recorrente em evento │ Removida condição !id, cria a regra e vincula evento via updateClinic │
│ existente │ │
├────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────┤
│ ConfirmDialog │ Adicionado <ConfirmDialog /> na página para as confirmações funcionarem corretamente │
└────────────────────────┴────────────────────────────────────────────────────────────────────────────────────────┘
Quando adiciona um compromisso na agenda e não aparece :
1. Join FK errada (!determined_commitment_id → !agenda_eventos_determined_commitment_fk) em ambos
agendaClinicRepository.js e useAgendaEvents.js — esse era o bug principal. O Supabase usava o nome do constraint FK (agenda_eventos_determined_commitment_fk), não o nome da coluna. A query estava falhando silenciosamente, mantendo
rows.value no estado anterior (vazio na primeira carga). 2. modalidade ausente do pickDbFields — adicionado para não perder a modalidade selecionada pelo usuário.
3. Toast de erro após _reloadRange — agora, se o reload falhar, um warning aparece em vez de mostrar sucesso sem
eventos.
Para colorir o background dos compromissos :
Pronto. Adicionei eventContent no buildFcOptions do AgendaClinicMosaic — mesmo código do AgendaTerapeutaPage — com
avatar (foto ou iniciais), horário, título e observações. O CSS foi adicionado em bloco não-scoped para que o HTML
injetado pelo FullCalendar seja estilizado corretamente.
As cores dos compromissos já chegavam via backgroundColor no mapper (tanto para o terapeuta quanto para a clínica), o
que estava faltando era o eventContent customizado que dá o mesmo visual.

232
TESTES.md Normal file
View File

@@ -0,0 +1,232 @@
# Guia de Testes — AgenciaPsi
## Testes Automatizados
### Pré-requisito
Vitest já instalado (`npm install` resolve). Não precisa de banco, Supabase ou variáveis de ambiente.
### Comandos
| Comando | Descrição |
|---|---|
| `npm test` | Roda todos os testes uma vez e exibe resultado |
| `npm run test:watch` | Modo watch — re-roda ao salvar arquivos |
| `npm run test:ui` | Abre UI visual no browser (`http://localhost:51204`) |
### Arquivos de teste
| Arquivo | O que cobre |
|---|---|
| `src/features/agenda/composables/__tests__/useRecurrence.spec.js` | Geração de datas por tipo de regra, max_occurrences global, exceções, remarcação cross-range |
| `src/features/agenda/services/__tests__/agendaMappers.spec.js` | Mapeamento para FullCalendar, ícones de status, cores, buildNextSessions, minutesToDuration, buildWeeklyBreakBackgroundEvents |
### Quando rodar
- Antes de commitar qualquer mudança em `useRecurrence.js` ou `agendaMappers.js`
- Ao adicionar novo tipo de frequência (mensal, quinzenal, etc.)
- Ao mexer em exceções de recorrência
- Em CI/CD antes do deploy
---
## Testes Manuais
### Preparação
1. Limpar dados de teste no banco:
```sql
TRUNCATE TABLE recurrence_exceptions CASCADE;
TRUNCATE TABLE recurrence_rules CASCADE;
TRUNCATE TABLE agenda_eventos CASCADE;
TRUNCATE TABLE agendador_solicitacoes CASCADE;
```
2. Fazer login com seu usuário real
3. Selecionar a clínica/tenant correto
---
### 1. Evento Avulso
| Passo | Esperado |
|---|---|
| Clicar em um horário livre na agenda | Dialog de criação abre |
| Preencher paciente, horário, modalidade → Salvar | Evento aparece no calendário |
| Clicar no evento → Editar horário → Salvar | Horário atualiza |
| Clicar no evento → Marcar como "Faltou" | Cor muda para vermelho, ícone ✗ |
| Clicar no evento → Marcar como "Realizado" | Cor muda para cinza, ícone ✓ |
| Clicar no evento → Cancelar sessão | Cor muda para laranja, ícone ∅ |
| Clicar no evento → Excluir | Evento some do calendário |
---
### 2. Recorrência Semanal
| Passo | Esperado |
|---|---|
| Criar evento com frequência "Semanal" | Ocorrências aparecem em todas as semanas seguintes com ícone ↻ |
| Navegar para a semana seguinte | Ocorrências continuam aparecendo |
| Navegar para além do end_date | Não aparecem ocorrências após a data final |
| Criar série com "4 sessões" (max_occurrences) | Exatamente 4 ocorrências visíveis no calendário |
---
### 3. Recorrência Quinzenal e Dias Específicos
| Passo | Esperado |
|---|---|
| Criar série "Quinzenal" | Ocorrências aparecem a cada 2 semanas |
| Criar série "Dias específicos" (ex: seg + qua) | Ambos os dias aparecem toda semana |
| Navegar para semanas futuras | Padrão se mantém |
---
### 4. Edição de Série
| Passo | Esperado |
|---|---|
| Clicar em ocorrência → Editar → "Somente este" → mudar horário | Só aquela data muda; as outras continuam iguais |
| Clicar em ocorrência → Cancelar → "Somente este" | Só aquela data some (ou aparece cancelada) |
| Clicar em ocorrência → Cancelar → "Este e os seguintes" | A partir daquela data, sem mais ocorrências |
| Clicar em ocorrência → Cancelar → "Todos" | Série inteira some |
---
### 5. Remarcação Cross-Range ⭐
Este é o caso mais importante a testar.
| Passo | Esperado |
|---|---|
| Criar série semanal (ex: toda segunda) | Ocorrências nas segundas |
| Clicar na sessão da **semana 1** → Remarcar para **terça da semana 2** | — |
| Navegar para a **semana 1** | Segunda da semana 1 aparece vazia ou como "remarcado" |
| Navegar para a **semana 2** | Terça aparece com ícone ↺ e status "remarcado" |
---
### 6. Bloqueio de Agenda
| Passo | Esperado |
|---|---|
| Criar bloqueio de horário | Aparece no calendário com visual diferente (ícone ⊘) |
| Tentar agendar no horário bloqueado | Aviso de conflito |
---
### 7. Agendamento Online (Agendador Público)
| Passo | Esperado |
|---|---|
| Acessar URL pública do agendador | Página pública abre sem login |
| Selecionar data/horário disponível → Enviar solicitação | Confirmação exibida |
| No painel do terapeuta → "Agendamentos Recebidos" | Solicitação aparece na lista |
| Clicar em "Confirmar" | Evento criado na agenda |
| Clicar em "Recusar" | Solicitação removida, sem evento na agenda |
---
### 8. Suporte Técnico SaaS
| Passo | Esperado |
|---|---|
| Logar como `saas_admin` → Menu "Suporte Técnico" | Página de suporte abre |
| Selecionar um tenant → "Criar Sessão de Suporte" | URL com token é gerada |
| Copiar URL e abrir em outra aba | Agenda do terapeuta abre com banner de debug no rodapé |
| No banner → filtrar logs por categoria | Logs filtram corretamente |
| No banner → "Desativar suporte" | Banner some |
| No painel SaaS → "Revogar" na sessão ativa | Token invalidado |
---
### 9. Multi-Tenancy (se você tem 2 clínicas cadastradas)
| Passo | Esperado |
|---|---|
| Criar evento na clínica A | Evento aparece na agenda da clínica A |
| Trocar para clínica B | Evento da clínica A **não aparece** |
| Criar evento na clínica B | Aparece apenas na clínica B |
---
## Pedindo ao Claude para Executar os Testes
### Como usar o Claude Code para rodar e corrigir testes
O Claude Code (este agente) consegue rodar os testes, ler os erros e corrigir os problemas automaticamente. Basta iniciar a conversa com o contexto certo.
### Prompt de retomada recomendado
Cole isso no início de uma nova sessão com o Claude:
---
> Estou desenvolvendo o AgenciaPsi. Temos testes automatizados com Vitest.
>
> **Arquivos de teste:**
> - `src/features/agenda/composables/__tests__/useRecurrence.spec.js` — testa `generateDates`, `expandRules`, `mergeWithStoredSessions`
> - `src/features/agenda/services/__tests__/agendaMappers.spec.js` — testa mapeamento para FullCalendar
>
> **Rodar os testes:** `npm test`
>
> Por favor, rode os testes agora e me informe o resultado. Se houver falhas, analise a causa e corrija.
---
### O que o Claude consegue fazer automaticamente
| Pedido | O Claude faz |
|---|---|
| "Rode os testes" | Executa `npm test` e exibe o resultado |
| "Tem algum teste falhando?" | Roda e diagnóstica a causa raiz |
| "Corrija os testes que falham" | Analisa erro, ajusta o código ou o teste e re-roda |
| "Adicionei a funcionalidade X, crie testes para ela" | Lê o código e escreve novos casos no spec |
| "O teste Y está errado, o comportamento correto é Z" | Atualiza a asserção e confirma que passa |
### Boas práticas ao pedir testes ao Claude
- **Forneça o `AUDITORIA.md`** no início da sessão — dá contexto sobre a arquitetura e decisões já tomadas
- **Descreva o comportamento esperado** em português, não o código — o Claude escreve o código do teste
- **Se um teste falhar e você achar que o código está certo**, diga isso explicitamente: *"o teste está errado, não o código"* — o Claude vai ajustar a asserção
- **Se um teste falhar e você achar que o código está errado**, diga: *"o comportamento esperado é X"* — o Claude vai corrigir a implementação
### Exemplo de sessão típica
```
Você: Rodei npm test e 2 testes falharam. Analise e corrija.
Claude: [roda npm test, lê os erros, corrige o código ou as asserções, re-roda até 63/63 passarem]
```
---
## Adicionando Novos Testes
### Para `useRecurrence.spec.js`
```js
import { generateDates, expandRules, mergeWithStoredSessions } from '../useRecurrence.js'
it('meu novo caso', () => {
const r = {
id: 'rule-1', type: 'weekly', weekdays: [1], interval: 1,
start_date: '2026-03-02', end_date: null, max_occurrences: null,
status: 'ativo', start_time: '09:00', end_time: '10:00',
// ... outros campos necessários
}
const dates = generateDates(r, new Date(2026, 2, 1), new Date(2026, 2, 31))
expect(dates.length).toBe(/* esperado */)
})
```
### Para `agendaMappers.spec.js`
```js
import { mapAgendaEventosToCalendarEvents } from '../agendaMappers.js'
it('meu novo caso de mapeamento', () => {
const [ev] = mapAgendaEventosToCalendarEvents([{
id: 'ev-1', titulo: 'Teste', tipo: 'sessao', status: 'agendado',
inicio_em: '2026-03-10T09:00:00', fim_em: '2026-03-10T10:00:00',
owner_id: 'owner-1',
}])
expect(ev.extendedProps./* campo */).toBe(/* esperado */)
})
```

15
comandos.txt Normal file
View File

@@ -0,0 +1,15 @@
para gerar o sql
supabase db dump --local -f full_dump.sql
para exportar todo o banco:
docker exec -i supabase_db_agenciapsi-primesakai pg_dump -U postgres postgres > backup.sql
para exportar o schema.sql:
docker exec -i supabase_db_agenciapsi-primesakai pg_dump -U postgres --schema-only postgres > schema.sql
para restaurar o banco:
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres postgres < backup.sql
para exportar sem ownership e ACLs (deixa o dump portável para outro ambiente):
docker exec -i supabase_db_agenciapsi-primesakai pg_dump -U postgres --no-owner --no-acl postgres > full_dump.sql

BIN
estrutura_src.txt Normal file

Binary file not shown.

View File

@@ -0,0 +1,29 @@
-- ============================================================
-- CLEANUP — remove dados da simulação AgenciaPsi
-- Data: 11/03/2026, 13:48:50
-- ============================================================
SET session_replication_role = replica;
BEGIN;
DELETE FROM recurrence_exceptions WHERE id IN ('036ec898-d994-4892-9f6d-d0367d941e7f', '153bbd3a-d6f8-4199-bd1b-f744ad400e50', '8e4af6a8-beba-4cae-86cf-be8bbc02f8a9', 'a568ceac-0be6-4ec6-b4c8-1d666ff27fd5', '37a01ea8-65d1-4810-b8d6-869100251f47', '50c4b11e-a4ca-4f1f-ac09-05da87db3cd9', '3721079d-4ee3-46e5-a873-a33fce0e8080', '0e99db44-019d-40b5-a67e-cd55b4a096c2');
DELETE FROM agenda_eventos WHERE id IN ('19a93b33-68be-4916-9069-48587ba5b11f', '30d76055-1a5d-409a-b716-5e27aa109b0f', 'aa3d5832-a462-4380-8d4c-390aff127952', '32fc2417-ab45-4091-be6d-8b66b9231fc7', '42bd5e8b-4bc3-4fa2-abad-5cc8c520d44d', '7f180aac-b117-4237-a725-18333b3e6791', '3122bb93-3cea-4cfc-a3a3-ce66be7ccb0d', 'bb8536db-35c5-47f4-88d6-c8bab4bfad8e', 'e10632b8-2a40-4fb6-a318-7e95db39b216', 'c8d815ea-9445-4c33-8777-9a4d0af7977e', '0041d195-5203-4e83-9b18-c60b5ececa40', '25672cbd-939d-4f64-bf3a-807cb206b0aa', '94eeb1cb-2bc3-49c9-8394-1cda9ff51638', 'a008de79-810b-4b6f-ad1f-313053955c09', 'db00a8cc-44a8-4c23-a577-0eb75e750f18', '12efaf45-57a9-4c91-8020-d3bf6fbca1be', '0a5fd25c-d8c6-4f07-b88a-49db13881e09', '64d0e09a-dd16-46cb-90dd-2973bae26327', '49b90ec6-3136-446d-a6f0-c0a3ae06f4f8', 'f981e635-d536-409c-b6c6-ff6d9276394a', 'c609091f-0414-4bb6-99a5-0db40cf18148', '7cdc08f3-8ae0-42f9-a324-4f751782ff2a', '90c2a326-d173-4ad2-b716-86db878d035e', '1bd0b8ff-e058-4fdc-905e-87f958a9b9cd', '049aac05-cfc2-4107-8e07-2178174a4c91', 'f045c147-758e-4cc8-b4fd-633488e263e2', 'b097f0ac-67fd-4c6e-8cab-6417772a24ff', 'c0256c42-c125-4d17-ac8d-e210b50ca56f', 'd43bd403-ac9a-4e3d-a00d-91b2fdaa8b84', '8515afad-90a0-4a7f-936a-9b6977759bf6', 'a8f0bdb6-9502-49e5-8c2b-a00822113847', '041a0295-4986-43ba-9ef1-49eb962a64e1', '074d85af-d24e-4de6-8c90-0073f1411a0b', 'a07e5625-6c44-4c05-9607-e60cea9ab226');
DELETE FROM recurrence_rules WHERE id IN ('f94e562a-dee3-4b6c-9a63-62f54173f7e5', '84614add-030b-4110-8542-fbb2af844648', '9e493b9e-06a5-4adc-b5df-3124bbf56869', 'f4d992a3-8ca6-4930-b39c-1f9454bc4df1', 'b5c62afc-75c2-4dd3-a35a-037d7e1d4609', '6cd1c566-eee0-4bf4-b68b-c5d9ad24714c');
DELETE FROM agendador_solicitacoes WHERE id IN ('891b1bd8-05a6-4aac-8346-8aa99d889ebf', 'e80d1e72-dbdd-48c3-be6f-117d2f5f4267', 'db89a451-74ab-4b43-ad16-91b69172fff5');
DELETE FROM agenda_regras_semanais WHERE id IN ('815e4214-e3c6-4934-9a85-458d03ccb801', '9ab48292-3170-475f-b66b-7adf50477ae5', 'b9d6ec37-b290-4cd1-b82a-7e13c2cecdff', 'a9d2c596-8e9c-4f7b-b3d0-7078080f63be', '1ad0afcd-e59d-41d4-9e07-c53ec210e23e');
-- Descomente se quiser remover também as configurações de agenda:
-- DELETE FROM agenda_configuracoes WHERE owner_id = 'aaaaaaaa-0002-0002-0002-000000000002';
DELETE FROM patients WHERE id IN ('7910bd11-fdd3-4719-8d31-7352e33b0871', 'c23586a1-7f89-437b-9b15-0894c6f4e766', '56410d42-c489-47ef-a324-6177d0d54b6c', 'd7faec09-1ac0-47c6-bb75-ac3872188c66', '7d407456-ff3b-44ff-b8a6-f555987206a1', '86fcfccb-6230-42fa-a111-987d707fd4be', 'f408f949-c5cd-46c1-aa98-446eb378c653', '82b39d67-aac3-4117-b740-cdbec1d61ffc');
COMMIT;
SET session_replication_role = DEFAULT;
-- ─── Fim do cleanup ─────────────────────────────────────────────────────────

15
logs/simulation-log.txt Normal file
View File

@@ -0,0 +1,15 @@
[INFO] 2026-03-11 16:48:50 Iniciando simulação...
[INFO] 2026-03-11 16:48:50 OWNER_ID: aaaaaaaa-0002-0002-0002-000000000002
[INFO] 2026-03-11 16:48:50 TENANT_ID: bbbbbbbb-0002-0002-0002-000000000002
[INFO] 2026-03-11 16:48:50 Período: 2026-02-09 → 2026-06-09
[INFO] 2026-03-11 16:48:50 ✔ 8 pacientes criados
[INFO] 2026-03-11 16:48:50 ✔ agenda_configuracoes inserida
[INFO] 2026-03-11 16:48:50 ✔ 5 regras semanais inseridas
[INFO] 2026-03-11 16:48:50 ✔ 2 eventos avulsos criados
[INFO] 2026-03-11 16:48:50 ✔ 6 regras de recorrência criadas
[INFO] 2026-03-11 16:48:50 ✔ Exceções: 6 faltou, 2 remarcado, 0 cancelado
[INFO] 2026-03-11 16:48:50 ✔ 32 sessões reais (passado) criadas
[INFO] 2026-03-11 16:48:50 ✔ 5 solicitações do agendador criadas
[INFO] 2026-03-11 16:48:50 ✔ Seed SQL: D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\logs\simulation-seed.sql
[INFO] 2026-03-11 16:48:50 ✔ Cleanup SQL: D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\logs\simulation-cleanup.sql
[INFO] 2026-03-11 16:48:50 ✔ Relatório: D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\logs\simulation-report.txt

View File

@@ -0,0 +1,63 @@
============================================================
RELATÓRIO DE SIMULAÇÃO — AgenciaPsi
Gerado em: 11/03/2026, 13:48:50
============================================================
OWNER_ID: aaaaaaaa-0002-0002-0002-000000000002
TENANT_ID: bbbbbbbb-0002-0002-0002-000000000002
─── Dados gerados ─────────────────────────────────────────
Pacientes: 8
Séries de recorrência: 6
Sessões reais (passado): 32
Eventos avulsos: 2
Exceções — faltou: 6
Exceções — remarcado: 2
Exceções — cancelado: 0
Solicitações agendador: 5
─── Pacientes criados ─────────────────────────────────────
[7910bd11-fdd3-4719-8d31-7352e33b0871] Isabela Carvalho — isabela.carvalho82@hotmail.com
[c23586a1-7f89-437b-9b15-0894c6f4e766] Ana Souza — ana.souza58@gmail.com
[56410d42-c489-47ef-a324-6177d0d54b6c] Helena Santos — helena.santos3@gmail.com
[d7faec09-1ac0-47c6-bb75-ac3872188c66] Daniel Carvalho — daniel.carvalho86@outlook.com
[7d407456-ff3b-44ff-b8a6-f555987206a1] Mariana Rocha — mariana.rocha79@gmail.com
[86fcfccb-6230-42fa-a111-987d707fd4be] Fernanda Silva — fernanda.silva87@hotmail.com
[f408f949-c5cd-46c1-aa98-446eb378c653] Juliana Almeida — juliana.almeida64@outlook.com
[82b39d67-aac3-4117-b740-cdbec1d61ffc] Karen Nunes — karen.nunes25@gmail.com
─── Séries de recorrência ─────────────────────────────────
[f94e562a-dee3-4b6c-9a63-62f54173f7e5]
Paciente: Isabela Carvalho
Tipo: weekly | Dias: Seg
Período: 2026-02-09 → 2026-06-09
[84614add-030b-4110-8542-fbb2af844648]
Paciente: Ana Souza
Tipo: weekly | Dias: Ter
Período: 2026-02-09 → 2026-06-09
[9e493b9e-06a5-4adc-b5df-3124bbf56869]
Paciente: Helena Santos
Tipo: weekly | Dias: Qui
Período: 2026-02-09 → 2026-06-09
[f4d992a3-8ca6-4930-b39c-1f9454bc4df1]
Paciente: Daniel Carvalho
Tipo: biweekly | Dias: Seg
Período: 2026-02-09 → 2026-06-09
[b5c62afc-75c2-4dd3-a35a-037d7e1d4609]
Paciente: Mariana Rocha
Tipo: custom_weekdays | Dias: Seg, Qua
Período: 2026-02-09 → 2026-06-09
[6cd1c566-eee0-4bf4-b68b-c5d9ad24714c]
Paciente: Fernanda Silva
Tipo: weekly | Dias: Qua
Período: 2026-02-09 → 2026-06-09
─── Como testar ───────────────────────────────────────────
1. Abra o Supabase SQL Editor
2. Cole e rode: logs/simulation-seed.sql
3. Acesse a agenda — os eventos devem aparecer
4. Acesse Pacientes — pacientes simulados aparecem na lista
5. Acesse Agendamentos Recebidos — solicitações pendentes
6. Quando terminar, rode: logs/simulation-cleanup.sql
============================================================

237
logs/simulation-seed.sql Normal file
View File

@@ -0,0 +1,237 @@
-- ============================================================
-- SIMULAÇÃO AgenciaPsi — gerado por npm run simulate
-- Data: 11/03/2026, 13:48:50
-- ============================================================
-- Desabilita triggers para permitir inserts de simulação
SET session_replication_role = replica;
BEGIN;
-- ─── 1. Pacientes ───────────────────────────────────────────────────────────
INSERT INTO patients (id, tenant_id, owner_id, responsible_member_id, nome_completo, email_principal, telefone, cpf, patient_scope, status)
VALUES ('7910bd11-fdd3-4719-8d31-7352e33b0871', 'bbbbbbbb-0002-0002-0002-000000000002', 'aaaaaaaa-0002-0002-0002-000000000002',
(SELECT id FROM tenant_members WHERE user_id = 'aaaaaaaa-0002-0002-0002-000000000002' AND tenant_id = 'bbbbbbbb-0002-0002-0002-000000000002' LIMIT 1),
'Isabela Carvalho', 'isabela.carvalho82@hotmail.com', '(51) 93447-7268', '36694226368', 'clinic', 'Ativo');
INSERT INTO patients (id, tenant_id, owner_id, responsible_member_id, nome_completo, email_principal, telefone, cpf, patient_scope, status)
VALUES ('c23586a1-7f89-437b-9b15-0894c6f4e766', 'bbbbbbbb-0002-0002-0002-000000000002', 'aaaaaaaa-0002-0002-0002-000000000002',
(SELECT id FROM tenant_members WHERE user_id = 'aaaaaaaa-0002-0002-0002-000000000002' AND tenant_id = 'bbbbbbbb-0002-0002-0002-000000000002' LIMIT 1),
'Ana Souza', 'ana.souza58@gmail.com', '(11) 91789-2404', '76394836115', 'clinic', 'Ativo');
INSERT INTO patients (id, tenant_id, owner_id, responsible_member_id, nome_completo, email_principal, telefone, cpf, patient_scope, status)
VALUES ('56410d42-c489-47ef-a324-6177d0d54b6c', 'bbbbbbbb-0002-0002-0002-000000000002', 'aaaaaaaa-0002-0002-0002-000000000002',
(SELECT id FROM tenant_members WHERE user_id = 'aaaaaaaa-0002-0002-0002-000000000002' AND tenant_id = 'bbbbbbbb-0002-0002-0002-000000000002' LIMIT 1),
'Helena Santos', 'helena.santos3@gmail.com', '(11) 92531-4591', '98615983257', 'clinic', 'Ativo');
INSERT INTO patients (id, tenant_id, owner_id, responsible_member_id, nome_completo, email_principal, telefone, cpf, patient_scope, status)
VALUES ('d7faec09-1ac0-47c6-bb75-ac3872188c66', 'bbbbbbbb-0002-0002-0002-000000000002', 'aaaaaaaa-0002-0002-0002-000000000002',
(SELECT id FROM tenant_members WHERE user_id = 'aaaaaaaa-0002-0002-0002-000000000002' AND tenant_id = 'bbbbbbbb-0002-0002-0002-000000000002' LIMIT 1),
'Daniel Carvalho', 'daniel.carvalho86@outlook.com', '(11) 95077-3606', '58936177264', 'clinic', 'Ativo');
INSERT INTO patients (id, tenant_id, owner_id, responsible_member_id, nome_completo, email_principal, telefone, cpf, patient_scope, status)
VALUES ('7d407456-ff3b-44ff-b8a6-f555987206a1', 'bbbbbbbb-0002-0002-0002-000000000002', 'aaaaaaaa-0002-0002-0002-000000000002',
(SELECT id FROM tenant_members WHERE user_id = 'aaaaaaaa-0002-0002-0002-000000000002' AND tenant_id = 'bbbbbbbb-0002-0002-0002-000000000002' LIMIT 1),
'Mariana Rocha', 'mariana.rocha79@gmail.com', '(21) 92159-7795', '14818998161', 'clinic', 'Ativo');
INSERT INTO patients (id, tenant_id, owner_id, responsible_member_id, nome_completo, email_principal, telefone, cpf, patient_scope, status)
VALUES ('86fcfccb-6230-42fa-a111-987d707fd4be', 'bbbbbbbb-0002-0002-0002-000000000002', 'aaaaaaaa-0002-0002-0002-000000000002',
(SELECT id FROM tenant_members WHERE user_id = 'aaaaaaaa-0002-0002-0002-000000000002' AND tenant_id = 'bbbbbbbb-0002-0002-0002-000000000002' LIMIT 1),
'Fernanda Silva', 'fernanda.silva87@hotmail.com', '(21) 97345-9576', '15668565266', 'clinic', 'Ativo');
INSERT INTO patients (id, tenant_id, owner_id, responsible_member_id, nome_completo, email_principal, telefone, cpf, patient_scope, status)
VALUES ('f408f949-c5cd-46c1-aa98-446eb378c653', 'bbbbbbbb-0002-0002-0002-000000000002', 'aaaaaaaa-0002-0002-0002-000000000002',
(SELECT id FROM tenant_members WHERE user_id = 'aaaaaaaa-0002-0002-0002-000000000002' AND tenant_id = 'bbbbbbbb-0002-0002-0002-000000000002' LIMIT 1),
'Juliana Almeida', 'juliana.almeida64@outlook.com', '(71) 95914-2804', '54146327124', 'clinic', 'Ativo');
INSERT INTO patients (id, tenant_id, owner_id, responsible_member_id, nome_completo, email_principal, telefone, cpf, patient_scope, status)
VALUES ('82b39d67-aac3-4117-b740-cdbec1d61ffc', 'bbbbbbbb-0002-0002-0002-000000000002', 'aaaaaaaa-0002-0002-0002-000000000002',
(SELECT id FROM tenant_members WHERE user_id = 'aaaaaaaa-0002-0002-0002-000000000002' AND tenant_id = 'bbbbbbbb-0002-0002-0002-000000000002' LIMIT 1),
'Karen Nunes', 'karen.nunes25@gmail.com', '(31) 90712-9951', '78969125499', 'clinic', 'Ativo');
-- ─── 2. Configurações de agenda ─────────────────────────────────────────────
INSERT INTO agenda_configuracoes (owner_id, tenant_id, session_duration_min, session_break_min, pausas_semanais, online_ativo, setup_clinica_concluido)
VALUES ('aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002',
50, 10, '[{"weekday":1,"start":"12:00","end":"13:00","label":"Almoço"},{"weekday":2,"start":"12:00","end":"13:00","label":"Almoço"},{"weekday":3,"start":"12:00","end":"13:00","label":"Almoço"},{"weekday":4,"start":"12:00","end":"13:00","label":"Almoço"},{"weekday":5,"start":"12:00","end":"13:00","label":"Almoço"}]'::jsonb, true, true)
ON CONFLICT (owner_id) DO NOTHING;
-- ─── 3. Regras semanais ─────────────────────────────────────────────────────
INSERT INTO agenda_regras_semanais (id, owner_id, tenant_id, dia_semana, hora_inicio, hora_fim, ativo)
VALUES ('815e4214-e3c6-4934-9a85-458d03ccb801', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', 1, '08:00', '18:00', true);
INSERT INTO agenda_regras_semanais (id, owner_id, tenant_id, dia_semana, hora_inicio, hora_fim, ativo)
VALUES ('9ab48292-3170-475f-b66b-7adf50477ae5', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', 2, '08:00', '18:00', true);
INSERT INTO agenda_regras_semanais (id, owner_id, tenant_id, dia_semana, hora_inicio, hora_fim, ativo)
VALUES ('b9d6ec37-b290-4cd1-b82a-7e13c2cecdff', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', 3, '08:00', '18:00', true);
INSERT INTO agenda_regras_semanais (id, owner_id, tenant_id, dia_semana, hora_inicio, hora_fim, ativo)
VALUES ('a9d2c596-8e9c-4f7b-b3d0-7078080f63be', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', 4, '08:00', '18:00', true);
INSERT INTO agenda_regras_semanais (id, owner_id, tenant_id, dia_semana, hora_inicio, hora_fim, ativo)
VALUES ('1ad0afcd-e59d-41d4-9e07-c53ec210e23e', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', 5, '08:00', '18:00', true);
-- ─── 4. Eventos avulsos (passado) ──────────────────────────────────────────
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, titulo)
VALUES ('19a93b33-68be-4916-9069-48587ba5b11f', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '7910bd11-fdd3-4719-8d31-7352e33b0871',
'sessao', 'faltou', '2026-02-23T13:00:00', '2026-02-23T13:50:00', 'online', 'Sessão avulsa');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, titulo)
VALUES ('30d76055-1a5d-409a-b716-5e27aa109b0f', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '56410d42-c489-47ef-a324-6177d0d54b6c',
'sessao', 'agendado', '2026-02-16T13:00:00', '2026-02-16T13:50:00', 'presencial', 'Sessão avulsa');
-- ─── 5. Séries de recorrência ───────────────────────────────────────────────
INSERT INTO recurrence_rules (id, owner_id, tenant_id, patient_id, type, weekdays, interval, start_date, end_date, status, start_time, end_time, modalidade)
VALUES ('f94e562a-dee3-4b6c-9a63-62f54173f7e5', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '7910bd11-fdd3-4719-8d31-7352e33b0871',
'weekly', ARRAY[1]::smallint[], 1, '2026-02-09', '2026-06-09',
'ativo', '09:00', '09:50', 'online');
INSERT INTO recurrence_rules (id, owner_id, tenant_id, patient_id, type, weekdays, interval, start_date, end_date, status, start_time, end_time, modalidade)
VALUES ('84614add-030b-4110-8542-fbb2af844648', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', 'c23586a1-7f89-437b-9b15-0894c6f4e766',
'weekly', ARRAY[2]::smallint[], 1, '2026-02-09', '2026-06-09',
'ativo', '10:00', '10:50', 'presencial');
INSERT INTO recurrence_rules (id, owner_id, tenant_id, patient_id, type, weekdays, interval, start_date, end_date, status, start_time, end_time, modalidade)
VALUES ('9e493b9e-06a5-4adc-b5df-3124bbf56869', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '56410d42-c489-47ef-a324-6177d0d54b6c',
'weekly', ARRAY[4]::smallint[], 1, '2026-02-09', '2026-06-09',
'ativo', '11:00', '11:50', 'presencial');
INSERT INTO recurrence_rules (id, owner_id, tenant_id, patient_id, type, weekdays, interval, start_date, end_date, status, start_time, end_time, modalidade)
VALUES ('f4d992a3-8ca6-4930-b39c-1f9454bc4df1', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', 'd7faec09-1ac0-47c6-bb75-ac3872188c66',
'biweekly', ARRAY[1]::smallint[], 1, '2026-02-09', '2026-06-09',
'ativo', '14:00', '14:50', 'online');
INSERT INTO recurrence_rules (id, owner_id, tenant_id, patient_id, type, weekdays, interval, start_date, end_date, status, start_time, end_time, modalidade)
VALUES ('b5c62afc-75c2-4dd3-a35a-037d7e1d4609', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '7d407456-ff3b-44ff-b8a6-f555987206a1',
'custom_weekdays', ARRAY[1,3]::smallint[], 1, '2026-02-09', '2026-06-09',
'ativo', '15:00', '15:50', 'online');
INSERT INTO recurrence_rules (id, owner_id, tenant_id, patient_id, type, weekdays, interval, start_date, end_date, status, start_time, end_time, modalidade)
VALUES ('6cd1c566-eee0-4bf4-b68b-c5d9ad24714c', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '86fcfccb-6230-42fa-a111-987d707fd4be',
'weekly', ARRAY[3]::smallint[], 1, '2026-02-09', '2026-06-09',
'ativo', '16:00', '16:50', 'online');
-- ─── 6. Exceções de recorrência ────────────────────────────────────────────
INSERT INTO recurrence_exceptions (id, recurrence_id, tenant_id, original_date, type, new_date)
VALUES ('036ec898-d994-4892-9f6d-d0367d941e7f', '9e493b9e-06a5-4adc-b5df-3124bbf56869', 'bbbbbbbb-0002-0002-0002-000000000002', '2026-02-19', 'patient_missed', NULL);
INSERT INTO recurrence_exceptions (id, recurrence_id, tenant_id, original_date, type, new_date)
VALUES ('153bbd3a-d6f8-4199-bd1b-f744ad400e50', '9e493b9e-06a5-4adc-b5df-3124bbf56869', 'bbbbbbbb-0002-0002-0002-000000000002', '2026-03-05', 'patient_missed', NULL);
INSERT INTO recurrence_exceptions (id, recurrence_id, tenant_id, original_date, type, new_date)
VALUES ('8e4af6a8-beba-4cae-86cf-be8bbc02f8a9', 'f4d992a3-8ca6-4930-b39c-1f9454bc4df1', 'bbbbbbbb-0002-0002-0002-000000000002', '2026-02-23', 'reschedule_session', '2026-02-27');
INSERT INTO recurrence_exceptions (id, recurrence_id, tenant_id, original_date, type, new_date)
VALUES ('a568ceac-0be6-4ec6-b4c8-1d666ff27fd5', 'b5c62afc-75c2-4dd3-a35a-037d7e1d4609', 'bbbbbbbb-0002-0002-0002-000000000002', '2026-02-09', 'patient_missed', NULL);
INSERT INTO recurrence_exceptions (id, recurrence_id, tenant_id, original_date, type, new_date)
VALUES ('37a01ea8-65d1-4810-b8d6-869100251f47', 'b5c62afc-75c2-4dd3-a35a-037d7e1d4609', 'bbbbbbbb-0002-0002-0002-000000000002', '2026-02-16', 'patient_missed', NULL);
INSERT INTO recurrence_exceptions (id, recurrence_id, tenant_id, original_date, type, new_date)
VALUES ('50c4b11e-a4ca-4f1f-ac09-05da87db3cd9', 'b5c62afc-75c2-4dd3-a35a-037d7e1d4609', 'bbbbbbbb-0002-0002-0002-000000000002', '2026-02-18', 'patient_missed', NULL);
INSERT INTO recurrence_exceptions (id, recurrence_id, tenant_id, original_date, type, new_date)
VALUES ('3721079d-4ee3-46e5-a873-a33fce0e8080', 'b5c62afc-75c2-4dd3-a35a-037d7e1d4609', 'bbbbbbbb-0002-0002-0002-000000000002', '2026-02-25', 'patient_missed', NULL);
INSERT INTO recurrence_exceptions (id, recurrence_id, tenant_id, original_date, type, new_date)
VALUES ('0e99db44-019d-40b5-a67e-cd55b4a096c2', '6cd1c566-eee0-4bf4-b68b-c5d9ad24714c', 'bbbbbbbb-0002-0002-0002-000000000002', '2026-02-25', 'reschedule_session', '2026-02-26');
-- ─── 7. Sessões reais (passado — realizado/faltou) ────────────────────────
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('aa3d5832-a462-4380-8d4c-390aff127952', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '7910bd11-fdd3-4719-8d31-7352e33b0871',
'sessao', 'realizado', '2026-02-09T09:00:00', '2026-02-09T09:50:00', 'online', 'f94e562a-dee3-4b6c-9a63-62f54173f7e5', '2026-02-09');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('32fc2417-ab45-4091-be6d-8b66b9231fc7', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '7910bd11-fdd3-4719-8d31-7352e33b0871',
'sessao', 'realizado', '2026-02-16T09:00:00', '2026-02-16T09:50:00', 'online', 'f94e562a-dee3-4b6c-9a63-62f54173f7e5', '2026-02-16');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('42bd5e8b-4bc3-4fa2-abad-5cc8c520d44d', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '7910bd11-fdd3-4719-8d31-7352e33b0871',
'sessao', 'realizado', '2026-02-23T09:00:00', '2026-02-23T09:50:00', 'online', 'f94e562a-dee3-4b6c-9a63-62f54173f7e5', '2026-02-23');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('7f180aac-b117-4237-a725-18333b3e6791', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '7910bd11-fdd3-4719-8d31-7352e33b0871',
'sessao', 'realizado', '2026-03-02T09:00:00', '2026-03-02T09:50:00', 'online', 'f94e562a-dee3-4b6c-9a63-62f54173f7e5', '2026-03-02');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('3122bb93-3cea-4cfc-a3a3-ce66be7ccb0d', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '7910bd11-fdd3-4719-8d31-7352e33b0871',
'sessao', 'realizado', '2026-03-09T09:00:00', '2026-03-09T09:50:00', 'online', 'f94e562a-dee3-4b6c-9a63-62f54173f7e5', '2026-03-09');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('bb8536db-35c5-47f4-88d6-c8bab4bfad8e', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', 'c23586a1-7f89-437b-9b15-0894c6f4e766',
'sessao', 'realizado', '2026-02-10T10:00:00', '2026-02-10T10:50:00', 'presencial', '84614add-030b-4110-8542-fbb2af844648', '2026-02-10');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('e10632b8-2a40-4fb6-a318-7e95db39b216', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', 'c23586a1-7f89-437b-9b15-0894c6f4e766',
'sessao', 'realizado', '2026-02-17T10:00:00', '2026-02-17T10:50:00', 'presencial', '84614add-030b-4110-8542-fbb2af844648', '2026-02-17');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('c8d815ea-9445-4c33-8777-9a4d0af7977e', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', 'c23586a1-7f89-437b-9b15-0894c6f4e766',
'sessao', 'realizado', '2026-02-24T10:00:00', '2026-02-24T10:50:00', 'presencial', '84614add-030b-4110-8542-fbb2af844648', '2026-02-24');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('0041d195-5203-4e83-9b18-c60b5ececa40', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', 'c23586a1-7f89-437b-9b15-0894c6f4e766',
'sessao', 'realizado', '2026-03-03T10:00:00', '2026-03-03T10:50:00', 'presencial', '84614add-030b-4110-8542-fbb2af844648', '2026-03-03');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('25672cbd-939d-4f64-bf3a-807cb206b0aa', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', 'c23586a1-7f89-437b-9b15-0894c6f4e766',
'sessao', 'realizado', '2026-03-10T10:00:00', '2026-03-10T10:50:00', 'presencial', '84614add-030b-4110-8542-fbb2af844648', '2026-03-10');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('94eeb1cb-2bc3-49c9-8394-1cda9ff51638', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '56410d42-c489-47ef-a324-6177d0d54b6c',
'sessao', 'realizado', '2026-02-12T11:00:00', '2026-02-12T11:50:00', 'presencial', '9e493b9e-06a5-4adc-b5df-3124bbf56869', '2026-02-12');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('a008de79-810b-4b6f-ad1f-313053955c09', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '56410d42-c489-47ef-a324-6177d0d54b6c',
'sessao', 'realizado', '2026-02-19T11:00:00', '2026-02-19T11:50:00', 'presencial', '9e493b9e-06a5-4adc-b5df-3124bbf56869', '2026-02-19');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('db00a8cc-44a8-4c23-a577-0eb75e750f18', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '56410d42-c489-47ef-a324-6177d0d54b6c',
'sessao', 'realizado', '2026-02-26T11:00:00', '2026-02-26T11:50:00', 'presencial', '9e493b9e-06a5-4adc-b5df-3124bbf56869', '2026-02-26');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('12efaf45-57a9-4c91-8020-d3bf6fbca1be', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '56410d42-c489-47ef-a324-6177d0d54b6c',
'sessao', 'realizado', '2026-03-05T11:00:00', '2026-03-05T11:50:00', 'presencial', '9e493b9e-06a5-4adc-b5df-3124bbf56869', '2026-03-05');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('0a5fd25c-d8c6-4f07-b88a-49db13881e09', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', 'd7faec09-1ac0-47c6-bb75-ac3872188c66',
'sessao', 'realizado', '2026-02-09T14:00:00', '2026-02-09T14:50:00', 'online', 'f4d992a3-8ca6-4930-b39c-1f9454bc4df1', '2026-02-09');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('64d0e09a-dd16-46cb-90dd-2973bae26327', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', 'd7faec09-1ac0-47c6-bb75-ac3872188c66',
'sessao', 'faltou', '2026-02-16T14:00:00', '2026-02-16T14:50:00', 'online', 'f4d992a3-8ca6-4930-b39c-1f9454bc4df1', '2026-02-16');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('49b90ec6-3136-446d-a6f0-c0a3ae06f4f8', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', 'd7faec09-1ac0-47c6-bb75-ac3872188c66',
'sessao', 'realizado', '2026-02-23T14:00:00', '2026-02-23T14:50:00', 'online', 'f4d992a3-8ca6-4930-b39c-1f9454bc4df1', '2026-02-23');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('f981e635-d536-409c-b6c6-ff6d9276394a', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', 'd7faec09-1ac0-47c6-bb75-ac3872188c66',
'sessao', 'realizado', '2026-03-02T14:00:00', '2026-03-02T14:50:00', 'online', 'f4d992a3-8ca6-4930-b39c-1f9454bc4df1', '2026-03-02');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('c609091f-0414-4bb6-99a5-0db40cf18148', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', 'd7faec09-1ac0-47c6-bb75-ac3872188c66',
'sessao', 'realizado', '2026-03-09T14:00:00', '2026-03-09T14:50:00', 'online', 'f4d992a3-8ca6-4930-b39c-1f9454bc4df1', '2026-03-09');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('7cdc08f3-8ae0-42f9-a324-4f751782ff2a', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '7d407456-ff3b-44ff-b8a6-f555987206a1',
'sessao', 'realizado', '2026-02-09T15:00:00', '2026-02-09T15:50:00', 'online', 'b5c62afc-75c2-4dd3-a35a-037d7e1d4609', '2026-02-09');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('90c2a326-d173-4ad2-b716-86db878d035e', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '7d407456-ff3b-44ff-b8a6-f555987206a1',
'sessao', 'realizado', '2026-02-11T15:00:00', '2026-02-11T15:50:00', 'online', 'b5c62afc-75c2-4dd3-a35a-037d7e1d4609', '2026-02-11');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('1bd0b8ff-e058-4fdc-905e-87f958a9b9cd', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '7d407456-ff3b-44ff-b8a6-f555987206a1',
'sessao', 'realizado', '2026-02-16T15:00:00', '2026-02-16T15:50:00', 'online', 'b5c62afc-75c2-4dd3-a35a-037d7e1d4609', '2026-02-16');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('049aac05-cfc2-4107-8e07-2178174a4c91', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '7d407456-ff3b-44ff-b8a6-f555987206a1',
'sessao', 'faltou', '2026-02-18T15:00:00', '2026-02-18T15:50:00', 'online', 'b5c62afc-75c2-4dd3-a35a-037d7e1d4609', '2026-02-18');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('f045c147-758e-4cc8-b4fd-633488e263e2', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '7d407456-ff3b-44ff-b8a6-f555987206a1',
'sessao', 'realizado', '2026-02-23T15:00:00', '2026-02-23T15:50:00', 'online', 'b5c62afc-75c2-4dd3-a35a-037d7e1d4609', '2026-02-23');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('b097f0ac-67fd-4c6e-8cab-6417772a24ff', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '7d407456-ff3b-44ff-b8a6-f555987206a1',
'sessao', 'realizado', '2026-02-25T15:00:00', '2026-02-25T15:50:00', 'online', 'b5c62afc-75c2-4dd3-a35a-037d7e1d4609', '2026-02-25');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('c0256c42-c125-4d17-ac8d-e210b50ca56f', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '7d407456-ff3b-44ff-b8a6-f555987206a1',
'sessao', 'realizado', '2026-03-02T15:00:00', '2026-03-02T15:50:00', 'online', 'b5c62afc-75c2-4dd3-a35a-037d7e1d4609', '2026-03-02');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('d43bd403-ac9a-4e3d-a00d-91b2fdaa8b84', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '7d407456-ff3b-44ff-b8a6-f555987206a1',
'sessao', 'realizado', '2026-03-04T15:00:00', '2026-03-04T15:50:00', 'online', 'b5c62afc-75c2-4dd3-a35a-037d7e1d4609', '2026-03-04');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('8515afad-90a0-4a7f-936a-9b6977759bf6', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '7d407456-ff3b-44ff-b8a6-f555987206a1',
'sessao', 'faltou', '2026-03-09T15:00:00', '2026-03-09T15:50:00', 'online', 'b5c62afc-75c2-4dd3-a35a-037d7e1d4609', '2026-03-09');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('a8f0bdb6-9502-49e5-8c2b-a00822113847', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '86fcfccb-6230-42fa-a111-987d707fd4be',
'sessao', 'realizado', '2026-02-11T16:00:00', '2026-02-11T16:50:00', 'online', '6cd1c566-eee0-4bf4-b68b-c5d9ad24714c', '2026-02-11');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('041a0295-4986-43ba-9ef1-49eb962a64e1', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '86fcfccb-6230-42fa-a111-987d707fd4be',
'sessao', 'faltou', '2026-02-18T16:00:00', '2026-02-18T16:50:00', 'online', '6cd1c566-eee0-4bf4-b68b-c5d9ad24714c', '2026-02-18');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('074d85af-d24e-4de6-8c90-0073f1411a0b', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '86fcfccb-6230-42fa-a111-987d707fd4be',
'sessao', 'faltou', '2026-02-25T16:00:00', '2026-02-25T16:50:00', 'online', '6cd1c566-eee0-4bf4-b68b-c5d9ad24714c', '2026-02-25');
INSERT INTO agenda_eventos (id, owner_id, tenant_id, patient_id, tipo, status, inicio_em, fim_em, modalidade, recurrence_id, recurrence_date)
VALUES ('a07e5625-6c44-4c05-9607-e60cea9ab226', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', '86fcfccb-6230-42fa-a111-987d707fd4be',
'sessao', 'realizado', '2026-03-04T16:00:00', '2026-03-04T16:50:00', 'online', '6cd1c566-eee0-4bf4-b68b-c5d9ad24714c', '2026-03-04');
-- ─── 8. Agendador Público — solicitações pendentes ─────────────────────────
INSERT INTO agendador_solicitacoes (id, owner_id, tenant_id, paciente_nome, paciente_sobrenome, paciente_email, paciente_celular, tipo, modalidade, data_solicitada, hora_solicitada, status)
VALUES ('891b1bd8-05a6-4aac-8346-8aa99d889ebf', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', 'Marcos', 'Costa', 'marcos.costa12@yahoo.com.br', '(51) 91799-2922',
'primeira', 'presencial', '2026-03-24', '09:00', 'recusado');
INSERT INTO agendador_solicitacoes (id, owner_id, tenant_id, paciente_nome, paciente_sobrenome, paciente_email, paciente_celular, tipo, modalidade, data_solicitada, hora_solicitada, status)
VALUES ('e80d1e72-dbdd-48c3-be6f-117d2f5f4267', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', 'Carla', 'Carvalho', 'carla.carvalho82@hotmail.com', '(91) 92340-8703',
'reagendar', 'presencial', '2026-03-23', '09:00', 'pendente');
INSERT INTO agendador_solicitacoes (id, owner_id, tenant_id, paciente_nome, paciente_sobrenome, paciente_email, paciente_celular, tipo, modalidade, data_solicitada, hora_solicitada, status)
VALUES ('db89a451-74ab-4b43-ad16-91b69172fff5', 'aaaaaaaa-0002-0002-0002-000000000002', 'bbbbbbbb-0002-0002-0002-000000000002', 'Sérgio', 'Martins', 'sergio.martins82@gmail.com', '(11) 93735-0761',
'reagendar', 'presencial', '2026-03-16', '08:00', 'pendente');
COMMIT;
-- Restaura comportamento normal dos triggers
SET session_replication_role = DEFAULT;
-- ─── Fim do seed ────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,8 @@
-- migrations/agenda_eventos_price.sql
-- Adiciona coluna price à agenda_eventos para registrar o valor da sessão
ALTER TABLE agenda_eventos
ADD COLUMN IF NOT EXISTS price numeric(10,2);
COMMENT ON COLUMN agenda_eventos.price IS
'Valor da sessão em BRL. Preenchido automaticamente pela tabela professional_pricing do profissional.';

View File

@@ -0,0 +1,36 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- agendador_check_email
-- Verifica se um e-mail já possui solicitação anterior para este agendador
-- SECURITY DEFINER → anon pode chamar sem burlar RLS diretamente
-- Execute no Supabase SQL Editor
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.agendador_check_email(
p_slug text,
p_email text
)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_owner_id uuid;
BEGIN
SELECT c.owner_id INTO v_owner_id
FROM public.agendador_configuracoes c
WHERE c.link_slug = p_slug AND c.ativo = true
LIMIT 1;
IF v_owner_id IS NULL THEN RETURN false; END IF;
RETURN EXISTS (
SELECT 1 FROM public.agendador_solicitacoes s
WHERE s.owner_id = v_owner_id
AND lower(s.paciente_email) = lower(trim(p_email))
LIMIT 1
);
END;
$$;
GRANT EXECUTE ON FUNCTION public.agendador_check_email(text, text) TO anon, authenticated;

View File

@@ -0,0 +1,62 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- Feature keys do Agendador Online
-- Execute no Supabase SQL Editor
-- ═══════════════════════════════════════════════════════════════════════════
-- ── 1. Inserir as features ──────────────────────────────────────────────────
INSERT INTO public.features (key, name, descricao)
VALUES
(
'agendador.online',
'Agendador Online',
'Permite que pacientes solicitem agendamentos via link público. Inclui aprovação manual ou automática, controle de horários e notificações.'
),
(
'agendador.link_personalizado',
'Link Personalizado do Agendador',
'Permite que o profissional escolha um slug de URL próprio para o agendador (ex: /agendar/dra-ana-silva) em vez de um link gerado automaticamente.'
)
ON CONFLICT (key) DO UPDATE
SET name = EXCLUDED.name,
descricao = EXCLUDED.descricao;
-- ── 2. Vincular aos planos ──────────────────────────────────────────────────
-- ATENÇÃO: ajuste os filtros de plan key/name conforme seus planos reais.
-- Exemplo: agendador.online disponível para planos PRO e acima.
-- agendador.link_personalizado apenas para planos Elite/Superior.
-- agendador.online → todos os planos com target 'therapist' ou 'clinic'
-- (Adapte o WHERE conforme necessário)
INSERT INTO public.plan_features (plan_id, feature_id, enabled)
SELECT
p.id,
f.id,
true
FROM public.plans p
CROSS JOIN public.features f
WHERE f.key = 'agendador.online'
AND p.is_active = true
-- Comente a linha abaixo para liberar para TODOS os planos:
-- AND p.key IN ('pro', 'elite', 'clinic_pro', 'clinic_elite')
ON CONFLICT DO NOTHING;
-- agendador.link_personalizado → apenas planos superiores
-- Deixe comentado e adicione manualmente quando definir os planos:
-- INSERT INTO public.plan_features (plan_id, feature_id, enabled)
-- SELECT p.id, f.id, true
-- FROM public.plans p
-- CROSS JOIN public.features f
-- WHERE f.key = 'agendador.link_personalizado'
-- AND p.key IN ('elite', 'clinic_elite', 'pro_plus')
-- ON CONFLICT DO NOTHING;
-- ── 3. Verificação ─────────────────────────────────────────────────────────
SELECT
f.key,
f.name,
COUNT(pf.plan_id) AS planos_vinculados
FROM public.features f
LEFT JOIN public.plan_features pf ON pf.feature_id = f.id AND pf.enabled = true
WHERE f.key IN ('agendador.online', 'agendador.link_personalizado')
GROUP BY f.key, f.name
ORDER BY f.key;

View File

@@ -0,0 +1,221 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- FIX: agendador_slots_disponiveis + agendador_dias_disponiveis
-- Usa agenda_online_slots como fonte de slots
-- Cruzamento com: agenda_eventos, recurrence_rules/exceptions, agendador_solicitacoes
-- Execute no Supabase SQL Editor
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.agendador_slots_disponiveis(
p_slug text,
p_data date
)
RETURNS TABLE (hora time, disponivel boolean)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_owner_id uuid;
v_duracao int;
v_antecedencia int;
v_agora timestamptz;
v_db_dow int;
v_slot time;
v_slot_fim time;
v_slot_ts timestamptz;
v_ocupado boolean;
-- loop de recorrências
v_rule RECORD;
v_rule_start_dow int;
v_first_occ date;
v_day_diff int;
v_ex_type text;
BEGIN
SELECT c.owner_id, c.duracao_sessao_min, c.antecedencia_minima_horas
INTO v_owner_id, v_duracao, v_antecedencia
FROM public.agendador_configuracoes c
WHERE c.link_slug = p_slug AND c.ativo = true
LIMIT 1;
IF v_owner_id IS NULL THEN RETURN; END IF;
v_agora := now();
v_db_dow := extract(dow from p_data::timestamp)::int;
FOR v_slot IN
SELECT s.time
FROM public.agenda_online_slots s
WHERE s.owner_id = v_owner_id
AND s.weekday = v_db_dow
AND s.enabled = true
ORDER BY s.time
LOOP
v_slot_fim := v_slot + (v_duracao || ' minutes')::interval;
v_ocupado := false;
-- ── Antecedência mínima ──────────────────────────────────────────────────
v_slot_ts := (p_data::text || ' ' || v_slot::text)::timestamp
AT TIME ZONE 'America/Sao_Paulo';
IF v_slot_ts < v_agora + (v_antecedencia || ' hours')::interval THEN
v_ocupado := true;
END IF;
-- ── Eventos avulsos internos (agenda_eventos) ────────────────────────────
IF NOT v_ocupado THEN
SELECT EXISTS (
SELECT 1 FROM public.agenda_eventos e
WHERE e.owner_id = v_owner_id
AND e.status::text NOT IN ('cancelado', 'faltou')
AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::date = p_data
AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::time < v_slot_fim
AND (e.fim_em AT TIME ZONE 'America/Sao_Paulo')::time > v_slot
) INTO v_ocupado;
END IF;
-- ── Recorrências ativas (recurrence_rules) ───────────────────────────────
-- Loop explícito para evitar erros de tipo no cálculo do ciclo semanal
IF NOT v_ocupado THEN
FOR v_rule IN
SELECT
r.id,
r.start_date::date AS start_date,
r.end_date::date AS end_date,
r.start_time::time AS start_time,
r.end_time::time AS end_time,
COALESCE(r.interval, 1)::int AS interval
FROM public.recurrence_rules r
WHERE r.owner_id = v_owner_id
AND r.status = 'ativo'
AND p_data >= r.start_date::date
AND (r.end_date IS NULL OR p_data <= r.end_date::date)
AND v_db_dow = ANY(r.weekdays)
AND r.start_time::time < v_slot_fim
AND r.end_time::time > v_slot
LOOP
-- Calcula a primeira ocorrência do dia-da-semana a partir do start_date
v_rule_start_dow := extract(dow from v_rule.start_date)::int;
v_first_occ := v_rule.start_date
+ (((v_db_dow - v_rule_start_dow + 7) % 7))::int;
v_day_diff := (p_data - v_first_occ)::int;
-- Ocorrência válida: diff >= 0 e divisível pelo ciclo semanal
IF v_day_diff >= 0 AND v_day_diff % (7 * v_rule.interval) = 0 THEN
-- Verifica se há exceção para esta data
v_ex_type := NULL;
SELECT ex.type INTO v_ex_type
FROM public.recurrence_exceptions ex
WHERE ex.recurrence_id = v_rule.id
AND ex.original_date = p_data
LIMIT 1;
-- Sem exceção, ou exceção que não cancela → bloqueia o slot
IF v_ex_type IS NULL OR v_ex_type NOT IN (
'cancel_session', 'patient_missed',
'therapist_canceled', 'holiday_block',
'reschedule_session'
) THEN
v_ocupado := true;
EXIT; -- já basta uma regra que conflite
END IF;
END IF;
END LOOP;
END IF;
-- ── Recorrências remarcadas para este dia (reschedule → new_date = p_data) ─
IF NOT v_ocupado THEN
SELECT EXISTS (
SELECT 1
FROM public.recurrence_exceptions ex
JOIN public.recurrence_rules r ON r.id = ex.recurrence_id
WHERE r.owner_id = v_owner_id
AND r.status = 'ativo'
AND ex.type = 'reschedule_session'
AND ex.new_date = p_data
AND COALESCE(ex.new_start_time, r.start_time)::time < v_slot_fim
AND COALESCE(ex.new_end_time, r.end_time)::time > v_slot
) INTO v_ocupado;
END IF;
-- ── Solicitações públicas pendentes ──────────────────────────────────────
IF NOT v_ocupado THEN
SELECT EXISTS (
SELECT 1 FROM public.agendador_solicitacoes sol
WHERE sol.owner_id = v_owner_id
AND sol.status = 'pendente'
AND sol.data_solicitada = p_data
AND sol.hora_solicitada = v_slot
AND (sol.reservado_ate IS NULL OR sol.reservado_ate > v_agora)
) INTO v_ocupado;
END IF;
hora := v_slot;
disponivel := NOT v_ocupado;
RETURN NEXT;
END LOOP;
END;
$$;
GRANT EXECUTE ON FUNCTION public.agendador_slots_disponiveis(text, date) TO anon, authenticated;
-- ── agendador_dias_disponiveis ───────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.agendador_dias_disponiveis(
p_slug text,
p_ano int,
p_mes int
)
RETURNS TABLE (data date, tem_slots boolean)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_owner_id uuid;
v_antecedencia int;
v_agora timestamptz;
v_data date;
v_data_inicio date;
v_data_fim date;
v_db_dow int;
v_tem_slot boolean;
BEGIN
SELECT c.owner_id, c.antecedencia_minima_horas
INTO v_owner_id, v_antecedencia
FROM public.agendador_configuracoes c
WHERE c.link_slug = p_slug AND c.ativo = true
LIMIT 1;
IF v_owner_id IS NULL THEN RETURN; END IF;
v_agora := now();
v_data_inicio := make_date(p_ano, p_mes, 1);
v_data_fim := (v_data_inicio + interval '1 month' - interval '1 day')::date;
v_data := v_data_inicio;
WHILE v_data <= v_data_fim LOOP
v_db_dow := extract(dow from v_data::timestamp)::int;
SELECT EXISTS (
SELECT 1 FROM public.agenda_online_slots s
WHERE s.owner_id = v_owner_id
AND s.weekday = v_db_dow
AND s.enabled = true
AND (v_data::text || ' ' || s.time::text)::timestamp
AT TIME ZONE 'America/Sao_Paulo'
>= v_agora + (v_antecedencia || ' hours')::interval
) INTO v_tem_slot;
IF v_tem_slot THEN
data := v_data;
tem_slots := true;
RETURN NEXT;
END IF;
v_data := v_data + 1;
END LOOP;
END;
$$;
GRANT EXECUTE ON FUNCTION public.agendador_dias_disponiveis(text, int, int) TO anon, authenticated;

View File

@@ -0,0 +1,170 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- Agendador Online — tabelas de configuração e solicitações
-- ═══════════════════════════════════════════════════════════════════════════
-- ── 1. agendador_configuracoes ──────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "public"."agendador_configuracoes" (
"owner_id" "uuid" NOT NULL,
"tenant_id" "uuid",
-- PRO / Ativação
"ativo" boolean DEFAULT false NOT NULL,
"link_slug" "text",
-- Identidade Visual
"imagem_fundo_url" "text",
"imagem_header_url" "text",
"logomarca_url" "text",
"cor_primaria" "text" DEFAULT '#4b6bff',
-- Perfil Público
"nome_exibicao" "text",
"endereco" "text",
"botao_como_chegar_ativo" boolean DEFAULT true NOT NULL,
"maps_url" "text",
-- Fluxo de Agendamento
"modo_aprovacao" "text" DEFAULT 'aprovacao' NOT NULL,
"modalidade" "text" DEFAULT 'presencial' NOT NULL,
"tipos_habilitados" "jsonb" DEFAULT '["primeira","retorno"]'::jsonb NOT NULL,
"duracao_sessao_min" integer DEFAULT 50 NOT NULL,
"antecedencia_minima_horas" integer DEFAULT 24 NOT NULL,
"prazo_resposta_horas" integer DEFAULT 2 NOT NULL,
"reserva_horas" integer DEFAULT 2 NOT NULL,
-- Pagamento
"pagamento_obrigatorio" boolean DEFAULT false NOT NULL,
"pix_chave" "text",
"pix_countdown_minutos" integer DEFAULT 20 NOT NULL,
-- Triagem & Conformidade
"triagem_motivo" boolean DEFAULT true NOT NULL,
"triagem_como_conheceu" boolean DEFAULT false NOT NULL,
"verificacao_email" boolean DEFAULT false NOT NULL,
"exigir_aceite_lgpd" boolean DEFAULT true NOT NULL,
-- Textos
"mensagem_boas_vindas" "text",
"texto_como_se_preparar" "text",
"texto_termos_lgpd" "text",
-- Timestamps
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "agendador_configuracoes_pkey" PRIMARY KEY ("owner_id"),
CONSTRAINT "agendador_configuracoes_owner_fk"
FOREIGN KEY ("owner_id") REFERENCES "auth"."users"("id") ON DELETE CASCADE,
CONSTRAINT "agendador_configuracoes_tenant_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE CASCADE,
CONSTRAINT "agendador_configuracoes_modo_check"
CHECK ("modo_aprovacao" = ANY (ARRAY['automatico','aprovacao'])),
CONSTRAINT "agendador_configuracoes_modalidade_check"
CHECK ("modalidade" = ANY (ARRAY['presencial','online','ambos'])),
CONSTRAINT "agendador_configuracoes_duracao_check"
CHECK ("duracao_sessao_min" >= 10 AND "duracao_sessao_min" <= 240),
CONSTRAINT "agendador_configuracoes_antecedencia_check"
CHECK ("antecedencia_minima_horas" >= 0 AND "antecedencia_minima_horas" <= 720),
CONSTRAINT "agendador_configuracoes_reserva_check"
CHECK ("reserva_horas" >= 1 AND "reserva_horas" <= 48),
CONSTRAINT "agendador_configuracoes_pix_countdown_check"
CHECK ("pix_countdown_minutos" >= 5 AND "pix_countdown_minutos" <= 120),
CONSTRAINT "agendador_configuracoes_prazo_check"
CHECK ("prazo_resposta_horas" >= 1 AND "prazo_resposta_horas" <= 72)
);
ALTER TABLE "public"."agendador_configuracoes" ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "agendador_cfg_select" ON "public"."agendador_configuracoes";
CREATE POLICY "agendador_cfg_select" ON "public"."agendador_configuracoes"
FOR SELECT USING (auth.uid() = owner_id);
DROP POLICY IF EXISTS "agendador_cfg_write" ON "public"."agendador_configuracoes";
CREATE POLICY "agendador_cfg_write" ON "public"."agendador_configuracoes"
USING (auth.uid() = owner_id)
WITH CHECK (auth.uid() = owner_id);
CREATE INDEX IF NOT EXISTS "agendador_cfg_tenant_idx"
ON "public"."agendador_configuracoes" ("tenant_id");
-- ── 2. agendador_solicitacoes ───────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "public"."agendador_solicitacoes" (
"id" "uuid" DEFAULT gen_random_uuid() NOT NULL,
"owner_id" "uuid" NOT NULL,
"tenant_id" "uuid",
-- Dados do paciente
"paciente_nome" "text" NOT NULL,
"paciente_sobrenome" "text",
"paciente_email" "text" NOT NULL,
"paciente_celular" "text",
"paciente_cpf" "text",
-- Agendamento solicitado
"tipo" "text" NOT NULL,
"modalidade" "text" NOT NULL,
"data_solicitada" date NOT NULL,
"hora_solicitada" time NOT NULL,
-- Reserva temporária
"reservado_ate" timestamp with time zone,
-- Triagem
"motivo" "text",
"como_conheceu" "text",
-- Pagamento
"pix_status" "text" DEFAULT 'pendente',
"pix_pago_em" timestamp with time zone,
-- Status geral
"status" "text" DEFAULT 'pendente' NOT NULL,
"recusado_motivo" "text",
-- Autorização
"autorizado_em" timestamp with time zone,
"autorizado_por" "uuid",
-- Vínculos internos
"user_id" "uuid",
"patient_id" "uuid",
"evento_id" "uuid",
-- Timestamps
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "agendador_solicitacoes_pkey" PRIMARY KEY ("id"),
CONSTRAINT "agendador_sol_owner_fk"
FOREIGN KEY ("owner_id") REFERENCES "auth"."users"("id") ON DELETE CASCADE,
CONSTRAINT "agendador_sol_tenant_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE CASCADE,
CONSTRAINT "agendador_sol_status_check"
CHECK ("status" = ANY (ARRAY['pendente','autorizado','recusado','expirado'])),
CONSTRAINT "agendador_sol_tipo_check"
CHECK ("tipo" = ANY (ARRAY['primeira','retorno','reagendar'])),
CONSTRAINT "agendador_sol_modalidade_check"
CHECK ("modalidade" = ANY (ARRAY['presencial','online'])),
CONSTRAINT "agendador_sol_pix_check"
CHECK ("pix_status" IS NULL OR "pix_status" = ANY (ARRAY['pendente','pago','expirado']))
);
ALTER TABLE "public"."agendador_solicitacoes" ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "agendador_sol_owner_select" ON "public"."agendador_solicitacoes";
CREATE POLICY "agendador_sol_owner_select" ON "public"."agendador_solicitacoes"
FOR SELECT USING (auth.uid() = owner_id);
DROP POLICY IF EXISTS "agendador_sol_owner_write" ON "public"."agendador_solicitacoes";
CREATE POLICY "agendador_sol_owner_write" ON "public"."agendador_solicitacoes"
USING (auth.uid() = owner_id)
WITH CHECK (auth.uid() = owner_id);
CREATE INDEX IF NOT EXISTS "agendador_sol_owner_idx"
ON "public"."agendador_solicitacoes" ("owner_id", "status");
CREATE INDEX IF NOT EXISTS "agendador_sol_tenant_idx"
ON "public"."agendador_solicitacoes" ("tenant_id");
CREATE INDEX IF NOT EXISTS "agendador_sol_data_idx"
ON "public"."agendador_solicitacoes" ("data_solicitada", "hora_solicitada");

View File

@@ -0,0 +1,20 @@
-- migrations/agendador_pagamento_modo.sql
-- Adiciona suporte a modo de pagamento no agendador online
-- Execute no Supabase SQL Editor
ALTER TABLE agendador_configuracoes
ADD COLUMN IF NOT EXISTS pagamento_modo text NOT NULL DEFAULT 'sem_pagamento',
ADD COLUMN IF NOT EXISTS pagamento_metodos_visiveis text[] NOT NULL DEFAULT '{}';
-- Migração de dados existentes:
-- quem tinha pagamento_obrigatorio = true → pix_antecipado
UPDATE agendador_configuracoes
SET pagamento_modo = 'pix_antecipado'
WHERE pagamento_obrigatorio = true
AND pagamento_modo = 'sem_pagamento';
COMMENT ON COLUMN agendador_configuracoes.pagamento_modo IS
'sem_pagamento | pagar_na_hora | pix_antecipado';
COMMENT ON COLUMN agendador_configuracoes.pagamento_metodos_visiveis IS
'Métodos exibidos ao paciente quando pagamento_modo = pagar_na_hora. Ex: {pix, deposito, dinheiro, cartao, convenio}';

View File

@@ -0,0 +1,219 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- Agendador Online — acesso público (anon) + função de slots disponíveis
-- ═══════════════════════════════════════════════════════════════════════════
-- ── 1. Geração automática de slug ──────────────────────────────────────────
-- Cria slug único de 8 chars quando o profissional ativa sem link_personalizado
CREATE OR REPLACE FUNCTION public.agendador_gerar_slug()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
v_slug text;
v_exists boolean;
BEGIN
-- só gera se ativou e não tem slug ainda
IF NEW.ativo = true AND (NEW.link_slug IS NULL OR NEW.link_slug = '') THEN
LOOP
v_slug := lower(substring(replace(gen_random_uuid()::text, '-', ''), 1, 8));
SELECT EXISTS (
SELECT 1 FROM public.agendador_configuracoes
WHERE link_slug = v_slug AND owner_id <> NEW.owner_id
) INTO v_exists;
EXIT WHEN NOT v_exists;
END LOOP;
NEW.link_slug := v_slug;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS agendador_slug_trigger ON public.agendador_configuracoes;
CREATE TRIGGER agendador_slug_trigger
BEFORE INSERT OR UPDATE ON public.agendador_configuracoes
FOR EACH ROW EXECUTE FUNCTION public.agendador_gerar_slug();
-- ── 2. Políticas públicas (anon) ────────────────────────────────────────────
-- Leitura pública da config pelo slug (só ativo)
DROP POLICY IF EXISTS "agendador_cfg_public_read" ON public.agendador_configuracoes;
CREATE POLICY "agendador_cfg_public_read" ON public.agendador_configuracoes
FOR SELECT TO anon
USING (ativo = true AND link_slug IS NOT NULL);
-- Inserção pública de solicitações (qualquer pessoa pode solicitar)
DROP POLICY IF EXISTS "agendador_sol_public_insert" ON public.agendador_solicitacoes;
CREATE POLICY "agendador_sol_public_insert" ON public.agendador_solicitacoes
FOR INSERT TO anon
WITH CHECK (true);
-- Leitura da própria solicitação (pelo paciente logado)
DROP POLICY IF EXISTS "agendador_sol_patient_read" ON public.agendador_solicitacoes;
CREATE POLICY "agendador_sol_patient_read" ON public.agendador_solicitacoes
FOR SELECT TO authenticated
USING (auth.uid() = user_id OR auth.uid() = owner_id);
-- ── 3. Função: retorna slots disponíveis para uma data ──────────────────────
-- Roda como SECURITY DEFINER (acessa agenda_regras e agenda_eventos sem RLS)
CREATE OR REPLACE FUNCTION public.agendador_slots_disponiveis(
p_slug text,
p_data date
)
RETURNS TABLE (hora time, disponivel boolean)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_owner_id uuid;
v_duracao int;
v_reserva int;
v_antecedencia int;
v_dia_semana int; -- 0=dom..6=sab (JS) → convertemos
v_db_dow int; -- 0=dom..6=sab no Postgres (extract dow)
v_inicio time;
v_fim time;
v_slot time;
v_slot_fim time;
v_agora timestamptz;
BEGIN
-- carrega config do agendador
SELECT
c.owner_id,
c.duracao_sessao_min,
c.reserva_horas,
c.antecedencia_minima_horas
INTO v_owner_id, v_duracao, v_reserva, v_antecedencia
FROM public.agendador_configuracoes c
WHERE c.link_slug = p_slug AND c.ativo = true
LIMIT 1;
IF v_owner_id IS NULL THEN
RETURN;
END IF;
v_agora := now();
v_db_dow := extract(dow from p_data::timestamp)::int; -- 0=dom..6=sab
-- regra semanal para o dia da semana
SELECT hora_inicio, hora_fim
INTO v_inicio, v_fim
FROM public.agenda_regras_semanais
WHERE owner_id = v_owner_id
AND dia_semana = v_db_dow
AND ativo = true
LIMIT 1;
IF v_inicio IS NULL THEN
RETURN; -- profissional não atende nesse dia
END IF;
-- itera slots de v_duracao em v_duracao dentro da jornada
v_slot := v_inicio;
WHILE v_slot + (v_duracao || ' minutes')::interval <= v_fim LOOP
v_slot_fim := v_slot + (v_duracao || ' minutes')::interval;
-- bloco temporário para verificar conflitos
DECLARE
v_ocupado boolean := false;
v_slot_ts timestamptz;
BEGIN
-- antecedência mínima (compara em horário de Brasília)
v_slot_ts := (p_data::text || ' ' || v_slot::text)::timestamp AT TIME ZONE 'America/Sao_Paulo';
IF v_slot_ts < v_agora + (v_antecedencia || ' hours')::interval THEN
v_ocupado := true;
END IF;
-- conflito com eventos existentes na agenda
IF NOT v_ocupado THEN
SELECT EXISTS (
SELECT 1 FROM public.agenda_eventos
WHERE owner_id = v_owner_id
AND status::text NOT IN ('cancelado', 'faltou')
AND inicio_em AT TIME ZONE 'America/Sao_Paulo' >= p_data::timestamp
AND inicio_em AT TIME ZONE 'America/Sao_Paulo' < p_data::timestamp + interval '1 day'
AND (inicio_em AT TIME ZONE 'America/Sao_Paulo')::time < v_slot_fim
AND (fim_em AT TIME ZONE 'America/Sao_Paulo')::time > v_slot
) INTO v_ocupado;
END IF;
-- conflito com solicitações pendentes (reservadas)
IF NOT v_ocupado THEN
SELECT EXISTS (
SELECT 1 FROM public.agendador_solicitacoes
WHERE owner_id = v_owner_id
AND status = 'pendente'
AND data_solicitada = p_data
AND hora_solicitada = v_slot
AND (reservado_ate IS NULL OR reservado_ate > v_agora)
) INTO v_ocupado;
END IF;
hora := v_slot;
disponivel := NOT v_ocupado;
RETURN NEXT;
END;
v_slot := v_slot + (v_duracao || ' minutes')::interval;
END LOOP;
END;
$$;
GRANT EXECUTE ON FUNCTION public.agendador_slots_disponiveis(text, date) TO anon, authenticated;
-- ── 4. Função: retorna dias com disponibilidade no mês ─────────────────────
CREATE OR REPLACE FUNCTION public.agendador_dias_disponiveis(
p_slug text,
p_ano int,
p_mes int -- 1-12
)
RETURNS TABLE (data date, tem_slots boolean)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_owner_id uuid;
v_antecedencia int;
v_data date;
v_data_inicio date;
v_data_fim date;
v_agora timestamptz;
v_db_dow int;
v_tem_regra boolean;
BEGIN
SELECT c.owner_id, c.antecedencia_minima_horas
INTO v_owner_id, v_antecedencia
FROM public.agendador_configuracoes c
WHERE c.link_slug = p_slug AND c.ativo = true
LIMIT 1;
IF v_owner_id IS NULL THEN RETURN; END IF;
v_agora := now();
v_data_inicio := make_date(p_ano, p_mes, 1);
v_data_fim := (v_data_inicio + interval '1 month' - interval '1 day')::date;
v_data := v_data_inicio;
WHILE v_data <= v_data_fim LOOP
-- não oferece dias no passado ou dentro da antecedência mínima
IF v_data::timestamptz + '23:59:59'::interval > v_agora + (v_antecedencia || ' hours')::interval THEN
v_db_dow := extract(dow from v_data::timestamp)::int;
SELECT EXISTS (
SELECT 1 FROM public.agenda_regras_semanais
WHERE owner_id = v_owner_id AND dia_semana = v_db_dow AND ativo = true
) INTO v_tem_regra;
IF v_tem_regra THEN
data := v_data;
tem_slots := true;
RETURN NEXT;
END IF;
END IF;
v_data := v_data + 1;
END LOOP;
END;
$$;
GRANT EXECUTE ON FUNCTION public.agendador_dias_disponiveis(text, int, int) TO anon, authenticated;

View File

@@ -0,0 +1,19 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- FIX: adiciona status 'convertido' na constraint de agendador_solicitacoes
-- e adiciona coluna motivo_recusa (alias amigável de recusado_motivo)
-- Execute no Supabase SQL Editor
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Remove o CHECK existente e recria com os novos valores
ALTER TABLE public.agendador_solicitacoes
DROP CONSTRAINT IF EXISTS "agendador_sol_status_check";
ALTER TABLE public.agendador_solicitacoes
ADD CONSTRAINT "agendador_sol_status_check"
CHECK (status = ANY (ARRAY[
'pendente',
'autorizado',
'recusado',
'expirado',
'convertido'
]));

View File

@@ -0,0 +1,56 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- Storage bucket para imagens do Agendador Online
-- Execute no Supabase SQL Editor
-- ═══════════════════════════════════════════════════════════════════════════
-- ── 1. Criar o bucket ──────────────────────────────────────────────────────
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'agendador',
'agendador',
true, -- público (URLs diretas sem assinar)
5242880, -- 5 MB
ARRAY['image/jpeg','image/png','image/webp','image/gif']
)
ON CONFLICT (id) DO UPDATE
SET public = true,
file_size_limit = 5242880,
allowed_mime_types = ARRAY['image/jpeg','image/png','image/webp','image/gif'];
-- ── 2. Políticas ───────────────────────────────────────────────────────────
-- Leitura pública (anon e authenticated)
DROP POLICY IF EXISTS "agendador_storage_public_read" ON storage.objects;
CREATE POLICY "agendador_storage_public_read"
ON storage.objects FOR SELECT
USING (bucket_id = 'agendador');
-- Upload: apenas o dono da pasta (owner_id é o primeiro segmento do path)
DROP POLICY IF EXISTS "agendador_storage_owner_insert" ON storage.objects;
CREATE POLICY "agendador_storage_owner_insert"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'agendador'
AND (storage.foldername(name))[1] = auth.uid()::text
);
-- Update/upsert pelo dono
DROP POLICY IF EXISTS "agendador_storage_owner_update" ON storage.objects;
CREATE POLICY "agendador_storage_owner_update"
ON storage.objects FOR UPDATE
TO authenticated
USING (
bucket_id = 'agendador'
AND (storage.foldername(name))[1] = auth.uid()::text
);
-- Delete pelo dono
DROP POLICY IF EXISTS "agendador_storage_owner_delete" ON storage.objects;
CREATE POLICY "agendador_storage_owner_delete"
ON storage.objects FOR DELETE
TO authenticated
USING (
bucket_id = 'agendador'
AND (storage.foldername(name))[1] = auth.uid()::text
);

View File

@@ -0,0 +1,71 @@
-- migrations/payment_settings.sql
-- Tabela de configurações de formas de pagamento por terapeuta/owner
-- Execute no Supabase SQL Editor
CREATE TABLE IF NOT EXISTS payment_settings (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
owner_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
-- Pix
pix_ativo boolean NOT NULL DEFAULT false,
pix_tipo text NOT NULL DEFAULT 'cpf', -- cpf | cnpj | email | celular | aleatoria
pix_chave text NOT NULL DEFAULT '',
pix_nome_titular text NOT NULL DEFAULT '',
-- Depósito / TED
deposito_ativo boolean NOT NULL DEFAULT false,
deposito_banco text NOT NULL DEFAULT '',
deposito_agencia text NOT NULL DEFAULT '',
deposito_conta text NOT NULL DEFAULT '',
deposito_tipo_conta text NOT NULL DEFAULT 'corrente', -- corrente | poupanca
deposito_titular text NOT NULL DEFAULT '',
deposito_cpf_cnpj text NOT NULL DEFAULT '',
-- Dinheiro (espécie)
dinheiro_ativo boolean NOT NULL DEFAULT false,
-- Cartão (maquininha presencial)
cartao_ativo boolean NOT NULL DEFAULT false,
cartao_instrucao text NOT NULL DEFAULT '',
-- Plano de saúde / Convênio
convenio_ativo boolean NOT NULL DEFAULT false,
convenio_lista text NOT NULL DEFAULT '', -- texto livre com convênios aceitos
-- Observações gerais exibidas ao paciente
observacoes_pagamento text NOT NULL DEFAULT '',
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT payment_settings_owner_id_key UNIQUE (owner_id)
);
-- Índice por tenant
CREATE INDEX IF NOT EXISTS payment_settings_tenant_id_idx ON payment_settings(tenant_id);
-- RLS
ALTER TABLE payment_settings ENABLE ROW LEVEL SECURITY;
-- Owner lê e escreve os próprios dados
DROP POLICY IF EXISTS "payment_settings: owner full access" ON payment_settings;
CREATE POLICY "payment_settings: owner full access"
ON payment_settings
FOR ALL
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- updated_at automático
CREATE OR REPLACE FUNCTION update_payment_settings_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_payment_settings_updated_at ON payment_settings;
CREATE TRIGGER trg_payment_settings_updated_at
BEFORE UPDATE ON payment_settings
FOR EACH ROW EXECUTE FUNCTION update_payment_settings_updated_at();

View File

@@ -0,0 +1,61 @@
-- migrations/professional_pricing.sql
-- Fase 1: Precificação — tabela de preços padrão por profissional
-- Execute no Supabase SQL Editor
-- ─────────────────────────────────────────────────────────────
-- 1. Campo price em agenda_eventos
-- ─────────────────────────────────────────────────────────────
ALTER TABLE agenda_eventos
ADD COLUMN IF NOT EXISTS price numeric(10,2);
-- ─────────────────────────────────────────────────────────────
-- 2. Tabela de preços por profissional
-- Chave: owner_id + determined_commitment_id (NULL = padrão)
-- ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS professional_pricing (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
owner_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
determined_commitment_id uuid REFERENCES determined_commitments(id) ON DELETE SET NULL,
-- NULL = preço padrão (fallback quando não há match por tipo)
price numeric(10,2) NOT NULL,
notes text,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT professional_pricing_owner_commitment_key
UNIQUE (owner_id, determined_commitment_id)
);
-- Índice por tenant (listagens do admin)
CREATE INDEX IF NOT EXISTS professional_pricing_tenant_idx
ON professional_pricing (tenant_id);
-- ─────────────────────────────────────────────────────────────
-- 3. RLS
-- ─────────────────────────────────────────────────────────────
ALTER TABLE professional_pricing ENABLE ROW LEVEL SECURITY;
-- Terapeuta lê e escreve seus próprios preços
DROP POLICY IF EXISTS "professional_pricing: owner full access" ON professional_pricing;
CREATE POLICY "professional_pricing: owner full access"
ON professional_pricing
FOR ALL
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- ─────────────────────────────────────────────────────────────
-- 4. updated_at automático
-- ─────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION update_professional_pricing_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_professional_pricing_updated_at ON professional_pricing;
CREATE TRIGGER trg_professional_pricing_updated_at
BEFORE UPDATE ON professional_pricing
FOR EACH ROW EXECUTE FUNCTION update_professional_pricing_updated_at();

View File

@@ -0,0 +1,6 @@
-- migrations/recurrence_rules_price.sql
-- Adiciona campo price em recurrence_rules para herança nas ocorrências virtuais
-- Execute no Supabase SQL Editor
ALTER TABLE recurrence_rules
ADD COLUMN IF NOT EXISTS price numeric(10,2);

View File

@@ -0,0 +1,6 @@
-- Migration: remove session_start_offset_min from agenda_configuracoes
-- This field is replaced by hora_inicio in agenda_regras_semanais (work schedule per day)
-- The first session slot is now derived directly from hora_inicio of the work rule.
ALTER TABLE public.agenda_configuracoes
DROP COLUMN IF EXISTS session_start_offset_min;

View File

@@ -0,0 +1,235 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- Support Sessions — Sessões de suporte técnico SaaS
-- ═══════════════════════════════════════════════════════════════════════════
-- Permite que admins SaaS gerem tokens de acesso temporário para debug
-- de agendas de terapeutas, sem expor debug para usuários comuns.
--
-- SEGURANÇA:
-- - RLS: só saas_admin pode criar/listar sessões
-- - Token é opaco (gen_random_uuid) — não adivinhável
-- - expires_at com TTL máximo de 60 minutos
-- - validate_support_session() retorna apenas true/false + tenant_id
-- (não expõe dados do admin)
-- ═══════════════════════════════════════════════════════════════════════════
-- ── Tabela ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "public"."support_sessions" (
"id" uuid DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"admin_id" uuid NOT NULL,
"token" text NOT NULL DEFAULT (replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '')),
"expires_at" timestamp with time zone NOT NULL
DEFAULT (now() + interval '60 minutes'),
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT "support_sessions_pkey" PRIMARY KEY ("id"),
CONSTRAINT "support_sessions_tenant_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE CASCADE,
CONSTRAINT "support_sessions_admin_fk"
FOREIGN KEY ("admin_id") REFERENCES "auth"."users"("id") ON DELETE CASCADE,
CONSTRAINT "support_sessions_token_unique" UNIQUE ("token")
);
-- ── Índices ──────────────────────────────────────────────────────────────────
CREATE INDEX IF NOT EXISTS "support_sessions_token_idx"
ON "public"."support_sessions" ("token");
CREATE INDEX IF NOT EXISTS "support_sessions_tenant_idx"
ON "public"."support_sessions" ("tenant_id");
CREATE INDEX IF NOT EXISTS "support_sessions_expires_idx"
ON "public"."support_sessions" ("expires_at");
-- ── RLS ──────────────────────────────────────────────────────────────────────
ALTER TABLE "public"."support_sessions" ENABLE ROW LEVEL SECURITY;
-- Somente saas_admin pode ver suas próprias sessões de suporte
DROP POLICY IF EXISTS "support_sessions_saas_select" ON "public"."support_sessions";
CREATE POLICY "support_sessions_saas_select"
ON "public"."support_sessions"
FOR SELECT
USING (
auth.uid() = admin_id
AND EXISTS (
SELECT 1 FROM public.profiles
WHERE id = auth.uid()
AND role = 'saas_admin'
)
);
-- Somente saas_admin pode criar sessões de suporte
DROP POLICY IF EXISTS "support_sessions_saas_insert" ON "public"."support_sessions";
CREATE POLICY "support_sessions_saas_insert"
ON "public"."support_sessions"
FOR INSERT
WITH CHECK (
auth.uid() = admin_id
AND EXISTS (
SELECT 1 FROM public.profiles
WHERE id = auth.uid()
AND role = 'saas_admin'
)
);
-- Somente saas_admin pode deletar suas próprias sessões
DROP POLICY IF EXISTS "support_sessions_saas_delete" ON "public"."support_sessions";
CREATE POLICY "support_sessions_saas_delete"
ON "public"."support_sessions"
FOR DELETE
USING (
auth.uid() = admin_id
AND EXISTS (
SELECT 1 FROM public.profiles
WHERE id = auth.uid()
AND role = 'saas_admin'
)
);
-- ── RPC: create_support_session ───────────────────────────────────────────────
-- Cria uma sessão de suporte para um tenant.
-- Apenas saas_admin pode chamar. TTL: 60 minutos (configurável via p_ttl_minutes).
-- Retorna: token, expires_at
DROP FUNCTION IF EXISTS public.create_support_session(uuid, integer);
CREATE OR REPLACE FUNCTION public.create_support_session(
p_tenant_id uuid,
p_ttl_minutes integer DEFAULT 60
)
RETURNS json
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_admin_id uuid;
v_role text;
v_token text;
v_expires timestamp with time zone;
v_session support_sessions;
BEGIN
-- Verifica autenticação
v_admin_id := auth.uid();
IF v_admin_id IS NULL THEN
RAISE EXCEPTION 'Não autenticado.' USING ERRCODE = 'P0001';
END IF;
-- Verifica role saas_admin
SELECT role INTO v_role
FROM public.profiles
WHERE id = v_admin_id;
IF v_role <> 'saas_admin' THEN
RAISE EXCEPTION 'Acesso negado. Somente saas_admin pode criar sessões de suporte.'
USING ERRCODE = 'P0002';
END IF;
-- Valida TTL (1 a 120 minutos)
IF p_ttl_minutes < 1 OR p_ttl_minutes > 120 THEN
RAISE EXCEPTION 'TTL inválido. Use entre 1 e 120 minutos.'
USING ERRCODE = 'P0003';
END IF;
-- Valida tenant
IF NOT EXISTS (SELECT 1 FROM public.tenants WHERE id = p_tenant_id) THEN
RAISE EXCEPTION 'Tenant não encontrado.'
USING ERRCODE = 'P0004';
END IF;
-- Gera token único (64 chars hex, sem pgcrypto)
v_token := replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '');
v_expires := now() + (p_ttl_minutes || ' minutes')::interval;
-- Insere sessão
INSERT INTO public.support_sessions (tenant_id, admin_id, token, expires_at)
VALUES (p_tenant_id, v_admin_id, v_token, v_expires)
RETURNING * INTO v_session;
RETURN json_build_object(
'token', v_session.token,
'expires_at', v_session.expires_at,
'session_id', v_session.id
);
END;
$$;
-- ── RPC: validate_support_session ────────────────────────────────────────────
-- Valida um token de suporte. Não requer autenticação (chamada pública).
-- Retorna: { valid: bool, tenant_id: uuid|null }
-- NUNCA retorna admin_id ou dados internos.
DROP FUNCTION IF EXISTS public.validate_support_session(text);
CREATE OR REPLACE FUNCTION public.validate_support_session(
p_token text
)
RETURNS json
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_session support_sessions;
BEGIN
IF p_token IS NULL OR length(trim(p_token)) < 32 THEN
RETURN json_build_object('valid', false, 'tenant_id', null);
END IF;
SELECT * INTO v_session
FROM public.support_sessions
WHERE token = p_token
AND expires_at > now()
LIMIT 1;
IF NOT FOUND THEN
RETURN json_build_object('valid', false, 'tenant_id', null);
END IF;
RETURN json_build_object(
'valid', true,
'tenant_id', v_session.tenant_id
);
END;
$$;
-- ── RPC: revoke_support_session ───────────────────────────────────────────────
-- Revoga um token manualmente. Apenas o admin que criou pode revogar.
DROP FUNCTION IF EXISTS public.revoke_support_session(text);
CREATE OR REPLACE FUNCTION public.revoke_support_session(
p_token text
)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_admin_id uuid;
v_role text;
BEGIN
v_admin_id := auth.uid();
IF v_admin_id IS NULL THEN
RAISE EXCEPTION 'Não autenticado.' USING ERRCODE = 'P0001';
END IF;
SELECT role INTO v_role FROM public.profiles WHERE id = v_admin_id;
IF v_role <> 'saas_admin' THEN
RAISE EXCEPTION 'Acesso negado.' USING ERRCODE = 'P0002';
END IF;
DELETE FROM public.support_sessions
WHERE token = p_token
AND admin_id = v_admin_id;
RETURN FOUND;
END;
$$;
-- ── Cleanup automático (opcional) ────────────────────────────────────────────
-- Sessões expiradas podem ser limpas periodicamente via pg_cron ou edge function.
-- DELETE FROM public.support_sessions WHERE expires_at < now();

View File

@@ -0,0 +1,34 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- Unifica paciente_id → patient_id em agenda_eventos
-- ═══════════════════════════════════════════════════════════════════════════
-- Contexto:
-- Campo legado `paciente_id` (texto, sem FK) coexiste com `patient_id`
-- (uuid, com FK → patients.id). Eventos antigos têm `paciente_id` preenchido
-- mas `patient_id = null`. Esta migration corrige isso e remove a coluna legada.
--
-- SEGURANÇA:
-- Execute em transação. Verifique os counts antes do COMMIT.
-- ═══════════════════════════════════════════════════════════════════════════
BEGIN;
-- 1. Copia paciente_id → patient_id onde patient_id ainda é null
-- paciente_id já é uuid no banco — sem necessidade de cast ou validação de regex
UPDATE public.agenda_eventos
SET patient_id = paciente_id
WHERE patient_id IS NULL
AND paciente_id IS NOT NULL;
-- 2. Verificação: deve retornar 0
SELECT COUNT(*) AS "orfaos_restantes"
FROM public.agenda_eventos
WHERE patient_id IS NULL AND paciente_id IS NOT NULL;
-- 3. Remove a coluna legada
ALTER TABLE public.agenda_eventos DROP COLUMN IF EXISTS paciente_id;
-- 4. Remove FK e coluna legada de terapeuta_id se existir equivalente
-- (opcional — remova o comentário se quiser limpar terapeuta_id também)
-- ALTER TABLE public.agenda_eventos DROP COLUMN IF EXISTS terapeuta_id;
COMMIT;

Some files were not shown because too many files have changed in this diff Show More