Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras

This commit is contained in:
Leonardo
2026-03-24 21:26:58 -03:00
parent a89d1f5560
commit 53a4980396
453 changed files with 121427 additions and 174407 deletions

View File

@@ -20,7 +20,30 @@
"Bash(find:*)",
"Bash(ls:*)",
"Bash(npx vite:*)",
"Bash(powershell -Command \"$content = [System.IO.File]::ReadAllText\\(''src/views/pages/clinic/clinic/ClinicFeaturesPage.vue'', [System.Text.Encoding]::UTF8\\); $content = $content -replace [char]0x201C, ''\"\"'' -replace [char]0x201D, ''\"\"''; [System.IO.File]::WriteAllText\\(''src/views/pages/clinic/clinic/ClinicFeaturesPage.vue'', $content, [System.Text.Encoding]::UTF8\\)\")"
"Bash(powershell -Command \"$content = [System.IO.File]::ReadAllText\\(''src/views/pages/clinic/clinic/ClinicFeaturesPage.vue'', [System.Text.Encoding]::UTF8\\); $content = $content -replace [char]0x201C, ''\"\"'' -replace [char]0x201D, ''\"\"''; [System.IO.File]::WriteAllText\\(''src/views/pages/clinic/clinic/ClinicFeaturesPage.vue'', $content, [System.Text.Encoding]::UTF8\\)\")",
"Bash(xargs cat:*)",
"Bash(xxd \"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/DBS/2026-03-21/schema.sql\")",
"Bash(iconv -f UTF-16LE -t UTF-8 \"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/DBS/2026-03-21/schema.sql\")",
"Bash(mkdir -p docs/billing docs/planos docs/subscription-health docs/estrategia docs/specs)",
"Bash(mkdir -p database/backups database/migrations database/seeds database/fixes database/snippets)",
"Bash(cd:*)",
"Bash(mv disparando-whatsapp-local.md docs/whatsapp.md)",
"Bash(mv comandos.txt docs/)",
"Bash(mv dados-padrões-da-agenda.txt docs/)",
"Bash(mv USER_ARCHETYPES.html docs/)",
"Bash(mv Novo-DB/migration_*.sql database/migrations/)",
"Bash(mv Novo-DB/seed_*.sql database/seeds/)",
"Bash(mv Novo-DB/fix_*.sql database/fixes/)",
"Bash(npm install:*)",
"Bash(cp:*)",
"Bash(npm uninstall:*)",
"Bash(rm:*)",
"Bash(docker ps:*)",
"Bash(docker exec:*)",
"Bash(\"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/database-novo/backups/2026-03-23/schema.sql\")",
"Bash(\"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/database-novo/backups/2026-03-23/data.sql\")",
"Bash(\"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/database-novo/backups/2026-03-23/full_dump.sql\")",
"Bash(wc:*)"
]
}
}

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ api-generator/typedoc.json
**/.DS_Store
Dev-documentacao/
supabase/
evolution-api/

15
.hintrc Normal file
View File

@@ -0,0 +1,15 @@
{
"extends": [
"development"
],
"hints": {
"compat-api/css": [
"default",
{
"ignore": [
"background-color: color-mix(in srgb, var(--primary-color) 50%, transparent)"
]
}
]
}
}

View File

@@ -1,414 +0,0 @@
-- ============================================================
-- 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

@@ -1,296 +0,0 @@
-- =============================================================================
-- 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

@@ -1,13 +0,0 @@
-- =============================================================================
-- 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

@@ -1,334 +0,0 @@
-- =============================================================================
-- 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

@@ -1,283 +0,0 @@
-- =============================================================================
-- 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

@@ -1,36 +0,0 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- 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

@@ -1,62 +0,0 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- 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

@@ -1,221 +0,0 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- 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

@@ -1,170 +0,0 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- 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

@@ -1,219 +0,0 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- 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

@@ -1,19 +0,0 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- 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

@@ -1,56 +0,0 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- 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

@@ -1,6 +0,0 @@
-- 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

@@ -1,110 +0,0 @@
-- =========================================================
-- 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

@@ -1,212 +0,0 @@
-- =========================================================
-- 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

@@ -1,266 +0,0 @@
-- =========================================================
-- 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

@@ -1,105 +0,0 @@
-- =========================================================
-- 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

@@ -1,174 +0,0 @@
/*
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

@@ -1,147 +0,0 @@
-- =========================================================
-- 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

@@ -1,134 +0,0 @@
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

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

View File

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

View File

@@ -1,45 +0,0 @@
-- 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

@@ -1,20 +0,0 @@
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

@@ -1,5 +0,0 @@
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

@@ -1,12 +0,0 @@
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

@@ -1,23 +0,0 @@
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

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

View File

@@ -1,6 +0,0 @@
-- 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

@@ -1,12 +0,0 @@
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

@@ -1,46 +0,0 @@
-- ============================================================
-- 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

@@ -1,10 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +0,0 @@
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

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

View File

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

View File

@@ -1,5 +0,0 @@
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

@@ -1,12 +0,0 @@
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

@@ -1,9 +0,0 @@
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

@@ -1,5 +0,0 @@
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

@@ -1,9 +0,0 @@
-- 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

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

View File

@@ -1,5 +0,0 @@
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

@@ -1,18 +0,0 @@
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

@@ -1,4 +0,0 @@
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

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

View File

@@ -1,6 +0,0 @@
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

@@ -1,15 +0,0 @@
-- 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

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

View File

@@ -1,5 +0,0 @@
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

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

View File

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

View File

@@ -1,17 +0,0 @@
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

@@ -1,13 +0,0 @@
-- 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

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

View File

@@ -1,8 +0,0 @@
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

@@ -1,7 +0,0 @@
-- 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

@@ -1,8 +0,0 @@
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

@@ -1,266 +0,0 @@
-- =========================================================
-- 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

@@ -1,42 +0,0 @@
-- 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

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

View File

@@ -1,14 +0,0 @@
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

@@ -1,17 +0,0 @@
-- 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

@@ -1,123 +0,0 @@
-- ============================================================
-- 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');

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,414 +0,0 @@
-- ============================================================
-- 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

@@ -1,220 +0,0 @@
-- =============================================================================
-- 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

@@ -1,50 +0,0 @@
-- 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

@@ -1,296 +0,0 @@
-- =============================================================================
-- 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

@@ -1,13 +0,0 @@
-- =============================================================================
-- 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

@@ -1,199 +0,0 @@
-- =============================================================================
-- 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,159 @@
# DialogConfirmation — Padrão de Componente
> **Stack**: Vue 3 + PrimeVue 4 + Tailwind CSS
---
## Regras gerais
| Propriedade | Valor obrigatório |
|---|---|
| `group` | sempre `"headless"` — desacopla o template do trigger |
| `ConfirmDialog` | declarado **uma única vez**, no componente pai (página) |
| Filhos | disparam via `useConfirm()` com `group: 'headless'` — sem declarar `ConfirmDialog` próprio |
| `icon` | passado em `confirm.require({ icon })` — classe PrimeIcons sem o prefixo `pi` (ex: `'pi-trash'`) |
| `color` | passado em `confirm.require({ color })` — hex; define o fundo do círculo e a cor do botão Confirmar |
| `ConfirmationService` | obrigatório em `main.js``app.use(ConfirmationService)` |
---
## Arquitetura pai / filho
```
Pai (página)
└── <ConfirmDialog group="headless" /> ← único, renderiza aqui
├── Filho A (componente qualquer) → confirm.require({ group: 'headless', ... })
└── Filho B (Dialog interno) → confirm.require({ group: 'headless', ... })
```
> O `ConfirmDialog` **não** deve ser colocado dentro de um `<Dialog>` filho — isso causaria dois popups simultâneos. Sempre no pai.
---
## Setup obrigatório — `main.js`
```js
import ConfirmationService from 'primevue/confirmationservice'
app.use(ConfirmationService) // sem isso, useConfirm() não funciona
```
---
## Template do `ConfirmDialog` (somente no pai)
```vue
<!-- Declarado uma única vez, antes do conteúdo principal -->
<ConfirmDialog group="headless">
<template #container="{ message, acceptCallback, rejectCallback }">
<div class="flex flex-col items-center p-8 bg-surface-0 dark:bg-surface-900 rounded-xl shadow-xl">
<!-- Círculo central: cor e ícone vindos de message -->
<div
class="rounded-full inline-flex justify-center items-center h-24 w-24 -mt-20"
:style="{ background: message.color || 'var(--p-primary-color)', color: '#fff' }"
>
<i :class="`pi ${message.icon || 'pi-question'} !text-4xl`"></i>
</div>
<span class="font-bold text-2xl block mb-2 mt-6">{{ message.header }}</span>
<p class="mb-0 text-center text-[var(--text-color-secondary)]">{{ message.message }}</p>
<div class="flex items-center gap-2 mt-6">
<!-- Confirmar: cor dinâmica via message.color -->
<Button
label="Confirmar"
class="rounded-full"
:style="{
background: message.color || 'var(--p-primary-color)',
borderColor: message.color || 'var(--p-primary-color)'
}"
@click="acceptCallback"
/>
<!-- Cancelar: sempre outlined, neutro -->
<Button
label="Cancelar"
variant="outlined"
class="rounded-full"
@click="rejectCallback"
/>
</div>
</div>
</template>
</ConfirmDialog>
```
---
## Uso nos componentes (pai ou filhos)
```vue
<script setup>
import { useConfirm } from 'primevue/useconfirm'
const confirm = useConfirm()
function confirmDelete(item) {
confirm.require({
group: 'headless',
header: 'Excluir item?',
message: `"${item.name}" será removido permanentemente. Essa ação não pode ser desfeita.`,
icon: 'pi-trash',
color: '#ef4444',
accept: () => onDelete(item)
})
}
</script>
```
---
## Paleta de ícones e cores por ação
| Ação | `icon` | `color` | Observação |
|---|---|---|---|
| Excluir / Remover | `pi-trash` | `#ef4444` | Vermelho — ação destrutiva |
| Salvar / Confirmar | `pi-save` | `var(--p-primary-color)` | Cor primária do tema |
| Editar / Atualizar | `pi-pencil` | `#f97316` | Laranja — mudança de estado |
| Aviso / Atenção | `pi-exclamation-triangle` | `#eab308` | Amarelo — ação reversível |
| Info / Neutro | `pi-info-circle` | `#3b82f6` | Azul — informativo |
---
## Referência completa de `confirm.require`
```js
confirm.require({
group: 'headless', // obrigatório — aponta para o ConfirmDialog correto
header: 'Título do popup', // linha em negrito
message: 'Descrição clara.', // linha secundária
icon: 'pi-trash', // sufixo PrimeIcons sem o "pi " inicial
color: '#ef4444', // hex — fundo do círculo + cor do botão Confirmar
accept: () => { /* ação confirmada */ },
reject: () => { /* opcional — ação cancelada */ }
})
```
---
## Checklist antes de usar
- [ ] `ConfirmationService` registrado no `main.js`
- [ ] `<ConfirmDialog group="headless">` declarado **apenas no pai**, antes do conteúdo
- [ ] Filhos usam `useConfirm()` com `group: 'headless'` — sem `ConfirmDialog` próprio
- [ ] `icon` passado como sufixo PrimeIcons: `'pi-trash'`, não `'pi pi-trash'`
- [ ] `color` em hex para ações com semântica de cor (delete = `#ef4444`)
- [ ] `header` curto e direto | `message` com contexto suficiente para o usuário decidir
- [ ] `accept` contém a ação real — `reject` é opcional
---
## Variações de confirmação
| Contexto | `header` | `icon` | `color` |
|---|---|---|---|
| Excluir registro | `'Excluir <entidade>?'` | `pi-trash` | `#ef4444` |
| Remover item de lista | `'Remover campo?'` | `pi-trash` | `#ef4444` |
| Salvar com impacto | `'Confirmar alterações?'` | `pi-save` | primária |
| Atualizar com risco | `'Atualizar <entidade>?'` | `pi-pencil` | `#f97316` |
| Ação irreversível genérica | `'Tem certeza?'` | `pi-exclamation-triangle` | `#eab308` |

View File

@@ -0,0 +1,183 @@
# Dialog — Padrão de Componente
> **Stack**: Vue 3 + PrimeVue 4 + Tailwind CSS
---
## Regras gerais
| Propriedade | Valor obrigatório |
|---|---|
| `modal` | sempre `true` |
| `maximizable` | sempre presente — botão nativo do PrimeVue, sem estado manual |
| `:draggable` | sempre `false` |
| `:closable` | `!saving` — desabilita o X durante operações assíncronas |
| `:dismissableMask` | `!saving` — impede fechar clicando fora durante saving |
| `pt:mask:class` | `backdrop-blur-xs` |
| Largura | `w-[50rem]` (padrão); responsivo via `:breakpoints` |
| Breakpoints | `{ '1199px': '90vw', '768px': '94vw' }` |
---
## Estrutura obrigatória
```
<Dialog>
├── #header ← dot de cor (se aplicável), título/subtítulo, btn Excluir
├── Banner ← preview visual (opcional — apenas quando há cor/identidade visual)
├── Corpo ← campos do formulário
└── #footer ← Cancelar (flat) | Salvar (primary)
```
---
## Configuração completa do `<Dialog>`
```vue
<Dialog
v-model:visible="visible"
modal
:draggable="false"
:closable="!saving"
:dismissableMask="!saving"
maximizable
class="dc-dialog w-[50rem]"
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
:pt="{
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
}"
pt:mask:class="backdrop-blur-xs"
>
```
### Detalhes do `pt`
| Chave | O que faz |
|---|---|
| `header` | `!p-3` padding uniforme; `!rounded-t-[12px]` borda top arredondada; `border-b` + `shadow` separador com profundidade; `bg-gray-100` fundo levemente cinza |
| `content` | `!p-3` padding interno do corpo |
| `footer` | `!p-0` remove padding nativo (controlado pelo wrapper interno); `!rounded-b-[12px]` borda bottom arredondada; `border-t` + `shadow` separador; `bg-gray-100` fundo levemente cinza |
| `pcCloseButton` | `!rounded-md` remove o círculo nativo; `hover:!text-red-500` feedback de danger no hover |
| `pcMaximizeButton` | `!rounded-md` remove o círculo nativo; `hover:!text-primary` feedback de cor primária no hover |
> O `!` (important) é necessário porque o PrimeVue injeta estilos inline nos botões e no root do Dialog — sem ele o Tailwind perde a disputa de especificidade.
---
## Header — slot `#header`
```
[dot-cor] [título / subtítulo] [btn-excluir] ← Close e Maximize nativos vêm após
```
- O PrimeVue injeta **Maximize** e **Close** automaticamente à direita do slot `#header`.
- O botão **Excluir** fica **sempre no header**, nunca no footer.
- Excluir desabilitado quando o registro é nativo/padrão: `:disabled="saving || isNativeRecord"`.
```vue
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<!-- Dot de cor (omitir se não houver cor associada) -->
<span
class="shrink-0 w-3.5 h-3.5 rounded-full border-2 border-white/30
shadow-[0_0_0_3px_rgba(0,0,0,0.08)] transition-colors duration-200"
:style="{ backgroundColor: previewBgColor }"
/>
<div class="min-w-0">
<div class="text-base font-semibold truncate">
{{ form.name || (mode === 'create' ? 'Novo item' : 'Editar item') }}
</div>
<div class="text-xs opacity-50">
{{ mode === 'create' ? 'Criando novo registro' : 'Editando registro' }}
</div>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<!-- Excluir visível apenas em edit, desabilitado se nativo -->
<Button
v-if="mode === 'edit' && canDelete !== undefined"
icon="pi pi-trash"
severity="danger"
text
rounded
:disabled="saving || isNativeRecord"
v-tooltip.top="'Excluir'"
@click="emitDelete"
/>
<!-- Maximize e Close nativos do PrimeVue são injetados aqui automaticamente -->
</div>
</div>
</template>
```
---
## Footer — slot `#footer`
```vue
<template #footer>
<div class="flex items-center justify-end gap-2 px-3 py-3">
<!-- Cancelar: sempre flat, hover vermelho suave -->
<Button
label="Cancelar"
severity="secondary"
text
class="rounded-full hover:!text-red-500"
:disabled="saving"
@click="close"
/>
<!-- Salvar: sempre primary -->
<Button
label="Salvar"
icon="pi pi-check"
class="rounded-full"
:loading="saving"
:disabled="!canSubmit"
@click="submit"
/>
</div>
</template>
```
> **Regra**: Cancelar = `severity="secondary" text` + `hover:!text-red-500`. Salvar = primary (sem severity, usa o padrão do tema). Padding controlado pelo `div` interno (`px-3 py-3`), não pelo `pt.footer`.
---
## Maximizar
Use a prop nativa `maximizable`. O PrimeVue injeta e gerencia o botão automaticamente — sem `ref`, sem `isMaximized`, sem `<Button>` manual.
```vue
<Dialog maximizable ...>
```
---
## Checklist antes de publicar um Dialog
- [ ] `modal`, `:draggable="false"`, `:closable="!saving"`, `:dismissableMask="!saving"` presentes
- [ ] `maximizable` na prop (botão nativo, sem estado manual)
- [ ] `class="dc-dialog w-[50rem]"` + `:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"`
- [ ] `pt` completo: header, content, footer, pcCloseButton, pcMaximizeButton
- [ ] Header com `bg-gray-100`, `border-b`, shadow e `!rounded-t-[12px]`
- [ ] Footer com `bg-gray-100`, `border-t`, shadow e `!rounded-b-[12px]`
- [ ] Botão **Excluir** no header (nunca no footer), desabilitado se nativo
- [ ] Cancelar = `text` + `hover:!text-red-500` | Salvar = primary
- [ ] Padding do footer via `px-3 py-3` no `div` interno
---
## Variações de largura
| Uso | Classe |
|---|---|
| Formulário simples | `w-[36rem]` |
| Formulário padrão | `w-[50rem]`**padrão** |
| Formulário complexo | `w-[70rem]` |
| Tela cheia | `maximizable` — usuário controla |

119
database-novo/README.md Normal file
View File

@@ -0,0 +1,119 @@
# database-novo
Banco de dados do AgenciaPsi — organizado, documentado e com CLI para gerenciamento.
## Quick Start
```bash
cd database-novo
# Instalação do zero (schema + fixes + seeds + backup)
node db.cjs setup
# Ver estado do banco
node db.cjs status
# Backup
node db.cjs backup
# Restaurar (perdi o banco!)
node db.cjs restore
```
Para o guia completo, veja **`docs/setup_guide.md`**.
## Comandos do CLI
| Comando | O que faz |
|---------|-----------|
| `node db.cjs setup` | Instala do zero (schema + fixes + seeds) |
| `node db.cjs backup` | Exporta backup com data para `backups/` |
| `node db.cjs restore [data]` | Restaura de um backup |
| `node db.cjs migrate` | Aplica migrations pendentes |
| `node db.cjs seed [grupo]` | Roda seeds (all, users, system, test_data) |
| `node db.cjs status` | Estado do banco, backups, migrations |
| `node db.cjs diff` | Compara schema atual vs último backup |
| `node db.cjs reset` | Reseta e reinstala tudo |
| `node db.cjs verify` | Verifica integridade dos dados |
## Estrutura
```
database-novo/
├── db.cjs # CLI de gerenciamento do banco
├── db.config.json # Configuração (container, seeds, fixes)
├── schema/ # Schema SQL separado por seção
│ ├── 00_full/schema.sql # Schema completo (referência)
│ ├── 01_extensions/ # Schemas + extensões PostgreSQL
│ ├── 02_types/ # Enums (auth, public, infra)
│ ├── 03_functions/ # 11 arquivos por domínio
│ ├── 04_tables/ # 10 arquivos por domínio
│ ├── 05_views/ # 24 views
│ ├── 06_indexes/ # Índices
│ ├── 07_foreign_keys/ # PKs, FKs, constraints
│ ├── 08_triggers/ # Triggers
│ ├── 09_policies/ # 217 RLS policies
│ └── 10_grants/ # Grants
├── seeds/ # Seeds de dados
│ ├── seed_001_fixed.sql # 6 usuários base + tenants
│ ├── seed_002.sql # Supervisor + Editor
│ ├── seed_003.sql # Therapist2, Therapist3, Secretary
│ ├── seed_010_plans.sql # 7 planos + 4 preços
│ ├── seed_011_features.sql # 26 features
│ ├── seed_012_plan_features.sql # 85 vínculos plano↔feature
│ ├── seed_013_subscriptions.sql # 9 subscriptions + compromissos
│ ├── seed_014_global_data.sql # 11 email + 16 notif templates + 3 slides
│ ├── seed_020_test_data.sql # Dados de teste (50 pacientes, eventos, etc.)
│ ├── seed_020_test_data_cleanup.sql # Limpeza dos dados de teste
│ └── run_all_seeds.sh # Script bash alternativo
├── migrations/ # Migrations incrementais
├── fixes/ # 7 correções aplicadas
├── backups/ # Backups com data (auto-gerenciados)
│ └── 2026-03-23/ # schema.sql + data.sql + full_dump.sql
└── docs/ # Documentação
├── setup_guide.md # Guia completo de instalação e uso
├── schema_map.md # Mapa das 84 tabelas
├── business_rules.md # Regras de negócio
└── users_test.md # 11 usuários de teste (UUIDs + vínculos)
```
## Planos
| Key | Target | Preço | Limites |
|-----|--------|-------|---------|
| `patient_free` | patient | R$0 | — |
| `therapist_free` | therapist | R$0 | 40 agendamentos/mês, 50 lembretes/mês |
| `therapist_pro` | therapist | R$49/mês · R$490/ano | Ilimitado |
| `clinic_free` | clinic | R$0 | 30 pacientes, 5 terapeutas, 40 agend/mês |
| `clinic_pro` | clinic | R$149/mês · R$1490/ano | Ilimitado |
| `supervisor_free` | supervisor | R$0 | Até 3 supervisionados |
| `supervisor_pro` | supervisor | R$0 | Até 20 supervisionados |
## Usuários de Teste
Senha de todos: `Teste@123`
| Email | Plano | Tipo |
|-------|-------|------|
| paciente@agenciapsi.com.br | patient_free | Paciente |
| terapeuta@agenciapsi.com.br | therapist_free | Terapeuta solo + Clínica 3 |
| clinica1@agenciapsi.com.br | clinic_free | Clínica coworking |
| clinica2@agenciapsi.com.br | clinic_free | Clínica recepção |
| clinica3@agenciapsi.com.br | clinic_free | Clínica full |
| saas@agenciapsi.com.br | — | Admin plataforma |
| supervisor@agenciapsi.com.br | supervisor_free | Supervisor |
| editor@agenciapsi.com.br | therapist_free | Editor |
| therapist2@agenciapsi.com.br | therapist_free | Terapeuta |
| therapist3@agenciapsi.com.br | therapist_free | Terapeuta |
| secretary@agenciapsi.com.br | — | Secretária (Clínica 2) |
## Idempotência
Todos os seeds são idempotentes (ON CONFLICT DO UPDATE ou DELETE + INSERT). Podem ser re-executados quantas vezes necessário.

File diff suppressed because it is too large Load Diff

733
database-novo/db.cjs Normal file
View File

@@ -0,0 +1,733 @@
#!/usr/bin/env node
// =============================================================================
// AgenciaPsi — Database CLI
// =============================================================================
// Uso: node db.cjs <comando> [opcoes]
//
// Comandos:
// setup Instalação do zero (schema + seeds)
// backup Exporta backup com data atual
// restore [data] Restaura de um backup (ex: 2026-03-23)
// migrate Aplica migrations pendentes
// seed [grupo] Roda seeds (all|users|system|test_data)
// status Mostra estado atual do banco
// diff Compara schema atual vs último backup
// reset Reseta o banco e reinstala tudo
// verify Verifica integridade dos dados essenciais
// help Mostra ajuda
// =============================================================================
const { execSync, spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const ROOT = __dirname;
const CONFIG = JSON.parse(fs.readFileSync(path.join(ROOT, 'db.config.json'), 'utf8'));
const CONTAINER = CONFIG.container;
const DB = CONFIG.database;
const USER = CONFIG.user;
// ---------------------------------------------------------------------------
// Colors (sem dependências externas)
// ---------------------------------------------------------------------------
const c = {
reset: '\x1b[0m',
bold: '\x1b[1m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
gray: '\x1b[90m'
};
function log(msg) {
console.log(msg);
}
function info(msg) {
log(`${c.cyan}${c.reset} ${msg}`);
}
function ok(msg) {
log(`${c.green}${c.reset} ${msg}`);
}
function warn(msg) {
log(`${c.yellow}${c.reset} ${msg}`);
}
function err(msg) {
log(`${c.red}${c.reset} ${msg}`);
}
function title(msg) {
log(`\n${c.bold}${c.blue}═══ ${msg} ═══${c.reset}\n`);
}
function step(msg) {
log(`${c.gray}${c.reset} ${msg}`);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function dockerRunning() {
try {
const result = spawnSync('docker', ['inspect', '-f', '{{.State.Running}}', CONTAINER], {
encoding: 'utf8',
timeout: 10000,
stdio: ['pipe', 'pipe', 'pipe']
});
return result.stdout.trim() === 'true';
} catch {
return false;
}
}
function psql(sql, opts = {}) {
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} ${opts.tuples ? '-t' : ''} ${opts.quiet ? '-q' : ''} -c "${sql.replace(/"/g, '\\"')}"`;
return execSync(cmd, { encoding: 'utf8', timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, PYTHONIOENCODING: 'utf-8', LANG: 'C.UTF-8' } }).trim();
}
function psqlFile(filePath) {
const absPath = path.resolve(filePath);
const content = fs.readFileSync(absPath, 'utf8');
// Prepend SET client_encoding to ensure UTF-8 inside the session
const utf8Content = "SET client_encoding TO 'UTF8';\n" + content;
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} -q`;
return execSync(cmd, { input: utf8Content, encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, PYTHONIOENCODING: 'utf-8', LANG: 'C.UTF-8' } });
}
function pgDump(args) {
const cmd = `docker exec -e PGCLIENTENCODING=UTF8 ${CONTAINER} pg_dump -U ${USER} -d ${DB} ${args}`;
return execSync(cmd, { encoding: 'utf8', timeout: 120000, maxBuffer: 50 * 1024 * 1024 });
}
function today() {
return new Date().toISOString().slice(0, 10);
}
function fileHash(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
}
function ensureDir(dir) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
function requireDocker() {
if (!dockerRunning()) {
err(`Container "${CONTAINER}" não está rodando.`);
log(`\n Inicie o Supabase primeiro:`);
log(` ${c.cyan}npx supabase start${c.reset}\n`);
process.exit(1);
}
}
function listBackups() {
const dir = path.join(ROOT, 'backups');
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter((f) => /^\d{4}-\d{2}-\d{2}$/.test(f))
.sort()
.reverse();
}
// ---------------------------------------------------------------------------
// Migration tracking table
// ---------------------------------------------------------------------------
function ensureMigrationTable() {
psql(`
CREATE TABLE IF NOT EXISTS _db_migrations (
id SERIAL PRIMARY KEY,
filename TEXT NOT NULL UNIQUE,
hash TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'migration',
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`);
}
function getAppliedMigrations() {
ensureMigrationTable();
const result = psql('SELECT filename, hash, category, applied_at::text FROM _db_migrations ORDER BY id;', { tuples: true });
if (!result) return [];
return result
.split('\n')
.filter(Boolean)
.map((line) => {
const [filename, hash, category, applied_at] = line.split('|').map((s) => s.trim());
return { filename, hash, category, applied_at };
});
}
function recordMigration(filename, hash, category) {
psql(`INSERT INTO _db_migrations (filename, hash, category) VALUES ('${filename}', '${hash}', '${category}') ON CONFLICT (filename) DO UPDATE SET hash = EXCLUDED.hash, applied_at = now();`);
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
const commands = {};
// ---- SETUP ----
commands.setup = function () {
title('Setup — Instalação do zero');
requireDocker();
// 1. Schema
const schemaFile = path.join(ROOT, CONFIG.schema);
if (!fs.existsSync(schemaFile)) {
err(`Schema não encontrado: ${schemaFile}`);
process.exit(1);
}
info('Aplicando schema...');
psqlFile(schemaFile);
ok('Schema aplicado');
// 2. Fixes
info('Aplicando fixes...');
for (const fix of CONFIG.fixes) {
const fixPath = path.join(ROOT, 'fixes', fix);
if (fs.existsSync(fixPath)) {
step(fix);
psqlFile(fixPath);
}
}
ok(`${CONFIG.fixes.length} fixes aplicados`);
// 3. Seeds
commands.seed('all');
// 4. Migration table
ensureMigrationTable();
// 5. Record seeds as applied
const allSeeds = [...CONFIG.seeds.users, ...CONFIG.seeds.system];
for (const seed of allSeeds) {
const seedPath = path.join(ROOT, 'seeds', seed);
if (fs.existsSync(seedPath)) {
recordMigration(seed, fileHash(seedPath), 'seed');
}
}
for (const fix of CONFIG.fixes) {
const fixPath = path.join(ROOT, 'fixes', fix);
if (fs.existsSync(fixPath)) {
recordMigration(fix, fileHash(fixPath), 'fix');
}
}
ok('Setup completo!');
log('');
// 6. Auto-backup
info('Criando backup pós-setup...');
commands.backup();
// 7. Verify
commands.verify();
};
// ---- BACKUP ----
commands.backup = function () {
title('Backup');
requireDocker();
const date = today();
const dir = path.join(ROOT, 'backups', date);
ensureDir(dir);
const infraSchemas = ['storage', 'realtime', '_realtime', 'supabase_functions', 'extensions', 'graphql', 'graphql_public', 'pgsodium', 'vault', 'net', '_analytics'];
const excludeFlags = infraSchemas.map((s) => `--exclude-schema=${s}`).join(' ');
step('Exportando schema...');
const schema = pgDump('--schema-only --no-owner --no-privileges');
fs.writeFileSync(path.join(dir, 'schema.sql'), schema);
step('Exportando dados...');
const data = pgDump(`--data-only --no-owner --no-privileges ${excludeFlags}`);
fs.writeFileSync(path.join(dir, 'data.sql'), data);
step('Exportando dump completo...');
const full = pgDump('--no-owner --no-privileges');
fs.writeFileSync(path.join(dir, 'full_dump.sql'), full);
const sizes = ['schema.sql', 'data.sql', 'full_dump.sql'].map((f) => {
const stat = fs.statSync(path.join(dir, f));
return `${f}: ${(stat.size / 1024).toFixed(0)}KB`;
});
ok(`Backup salvo em backups/${date}/`);
sizes.forEach((s) => step(s));
// Cleanup old backups
cleanupBackups();
};
function cleanupBackups() {
const backups = listBackups();
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - CONFIG.backupRetentionDays);
const cutoffStr = cutoff.toISOString().slice(0, 10);
let removed = 0;
for (const b of backups) {
if (b < cutoffStr) {
const dir = path.join(ROOT, 'backups', b);
fs.rmSync(dir, { recursive: true, force: true });
removed++;
}
}
if (removed > 0) {
info(`${removed} backup(s) antigo(s) removido(s) (>${CONFIG.backupRetentionDays} dias)`);
}
}
// ---- RESTORE ----
commands.restore = function (dateArg) {
title('Restore');
requireDocker();
const backups = listBackups();
if (backups.length === 0) {
err('Nenhum backup encontrado.');
process.exit(1);
}
const date = dateArg || backups[0];
const dir = path.join(ROOT, 'backups', date);
if (!fs.existsSync(dir)) {
err(`Backup não encontrado: ${date}`);
log(`\n Backups disponíveis:`);
backups.forEach((b) => log(` ${c.cyan}${b}${c.reset}`));
process.exit(1);
}
const fullDump = path.join(dir, 'full_dump.sql');
const schemaFile = path.join(dir, 'schema.sql');
const dataFile = path.join(dir, 'data.sql');
// Safety backup before restore
info('Criando backup de segurança antes do restore...');
try {
commands.backup();
} catch {
warn('Não foi possível criar backup de segurança (banco pode estar vazio)');
}
if (fs.existsSync(fullDump)) {
info(`Restaurando de backups/${date}/full_dump.sql ...`);
// Drop and recreate public schema
step('Limpando schema public...');
psql('DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO postgres; GRANT ALL ON SCHEMA public TO public;');
step('Aplicando full dump...');
psqlFile(fullDump);
} else if (fs.existsSync(schemaFile) && fs.existsSync(dataFile)) {
info(`Restaurando de backups/${date}/ (schema + data)...`);
step('Limpando schema public...');
psql('DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO postgres; GRANT ALL ON SCHEMA public TO public;');
step('Aplicando schema...');
psqlFile(schemaFile);
step('Aplicando dados...');
psqlFile(dataFile);
} else {
err(`Backup incompleto em ${date}/`);
process.exit(1);
}
ok(`Banco restaurado de backups/${date}/`);
// Verify
commands.verify();
};
// ---- MIGRATE ----
commands.migrate = function () {
title('Migrate');
requireDocker();
ensureMigrationTable();
const migrationsDir = path.join(ROOT, 'migrations');
if (!fs.existsSync(migrationsDir)) {
info('Nenhuma pasta migrations/ encontrada.');
return;
}
const files = fs
.readdirSync(migrationsDir)
.filter((f) => f.endsWith('.sql'))
.sort();
if (files.length === 0) {
info('Nenhuma migration encontrada.');
return;
}
const applied = getAppliedMigrations().map((m) => m.filename);
const pending = files.filter((f) => !applied.includes(f));
if (pending.length === 0) {
ok('Todas as migrations já foram aplicadas.');
return;
}
// Auto-backup before migrating
info('Criando backup antes de migrar...');
commands.backup();
info(`${pending.length} migration(s) pendente(s):`);
for (const file of pending) {
step(`Aplicando ${file}...`);
const filePath = path.join(migrationsDir, file);
try {
psqlFile(filePath);
recordMigration(file, fileHash(filePath), 'migration');
ok(` ${file}`);
} catch (e) {
err(` FALHA em ${file}: ${e.message}`);
err('Migration abortada. Banco pode estar em estado parcial.');
err('Use "node db.cjs restore" para voltar ao backup.');
process.exit(1);
}
}
ok(`${pending.length} migration(s) aplicada(s)`);
};
// ---- SEED ----
commands.seed = function (group) {
const validGroups = ['all', 'users', 'system', 'test_data'];
if (!group) group = 'all';
if (!validGroups.includes(group)) {
err(`Grupo inválido: ${group}`);
log(` Grupos válidos: ${validGroups.join(', ')}`);
process.exit(1);
}
title(`Seeds — ${group}`);
requireDocker();
const groups = group === 'all' ? ['users', 'system'] : [group];
let total = 0;
for (const g of groups) {
const seeds = CONFIG.seeds[g];
if (!seeds || seeds.length === 0) continue;
info(`Grupo: ${g}`);
for (const seed of seeds) {
const seedPath = path.join(ROOT, 'seeds', seed);
if (!fs.existsSync(seedPath)) {
warn(` Arquivo não encontrado: ${seed}`);
continue;
}
step(seed);
try {
psqlFile(seedPath);
total++;
} catch (e) {
err(` FALHA em ${seed}: ${e.stderr || e.message}`);
process.exit(1);
}
}
}
ok(`${total} seed(s) aplicado(s)`);
};
// ---- STATUS ----
commands.status = function () {
title('Status');
requireDocker();
// Docker
ok(`Container: ${CONTAINER} (rodando)`);
// Backups
const backups = listBackups();
if (backups.length > 0) {
ok(`Último backup: ${backups[0]}`);
info(`Total de backups: ${backups.length}`);
} else {
warn('Nenhum backup encontrado');
}
// Migrations
try {
const applied = getAppliedMigrations();
if (applied.length > 0) {
info(`Migrations aplicadas: ${applied.length}`);
applied.slice(-5).forEach((m) => {
step(`${m.filename} ${c.gray}(${m.category}, ${m.applied_at})${c.reset}`);
});
}
// Pending
const migrationsDir = path.join(ROOT, 'migrations');
if (fs.existsSync(migrationsDir)) {
const files = fs.readdirSync(migrationsDir).filter((f) => f.endsWith('.sql'));
const pending = files.filter((f) => !applied.map((m) => m.filename).includes(f));
if (pending.length > 0) {
warn(`${pending.length} migration(s) pendente(s):`);
pending.forEach((f) => step(`${c.yellow}${f}${c.reset}`));
}
}
} catch {
info('Tabela _db_migrations não existe (rode setup primeiro)');
}
// DB counts
log('');
info('Dados no banco:');
const counts = [
['auth.users', 'SELECT count(*) FROM auth.users'],
['profiles', 'SELECT count(*) FROM profiles'],
['tenants', 'SELECT count(*) FROM tenants'],
['plans', 'SELECT count(*) FROM plans'],
['features', 'SELECT count(*) FROM features'],
['plan_features', 'SELECT count(*) FROM plan_features'],
['subscriptions', 'SELECT count(*) FROM subscriptions'],
['email_templates_global', 'SELECT count(*) FROM email_templates_global'],
['notification_templates', 'SELECT count(*) FROM notification_templates']
];
for (const [label, sql] of counts) {
try {
const count = psql(sql, { tuples: true }).trim();
const color = parseInt(count) > 0 ? c.green : c.red;
step(`${label}: ${color}${count}${c.reset}`);
} catch {
step(`${label}: ${c.gray}(tabela não existe)${c.reset}`);
}
}
};
// ---- DIFF ----
commands.diff = function () {
title('Diff — Schema');
requireDocker();
const backups = listBackups();
if (backups.length === 0) {
err('Nenhum backup para comparar. Rode "node db.cjs backup" primeiro.');
process.exit(1);
}
const lastBackup = backups[0];
const lastSchemaPath = path.join(ROOT, 'backups', lastBackup, 'schema.sql');
if (!fs.existsSync(lastSchemaPath)) {
err(`Schema não encontrado no backup ${lastBackup}`);
process.exit(1);
}
info('Exportando schema atual...');
const currentSchema = pgDump('--schema-only --no-owner --no-privileges');
const lastSchema = fs.readFileSync(lastSchemaPath, 'utf8');
// Extract table definitions for comparison
const extractTables = (sql) => {
const tables = {};
const regex = /CREATE TABLE (?:IF NOT EXISTS )?(\S+)\s*\(([\s\S]*?)\);/g;
let match;
while ((match = regex.exec(sql)) !== null) {
tables[match[1]] = match[2].trim();
}
return tables;
};
const currentTables = extractTables(currentSchema);
const lastTables = extractTables(lastSchema);
const allTables = new Set([...Object.keys(currentTables), ...Object.keys(lastTables)]);
let added = 0,
removed = 0,
changed = 0,
unchanged = 0;
for (const table of [...allTables].sort()) {
if (!lastTables[table]) {
log(` ${c.green}+ ${table}${c.reset} (nova)`);
added++;
} else if (!currentTables[table]) {
log(` ${c.red}- ${table}${c.reset} (removida)`);
removed++;
} else if (currentTables[table] !== lastTables[table]) {
log(` ${c.yellow}~ ${table}${c.reset} (alterada)`);
changed++;
} else {
unchanged++;
}
}
log('');
ok(`Comparado com backup de ${lastBackup}:`);
step(`${added} nova(s), ${changed} alterada(s), ${removed} removida(s), ${unchanged} sem mudança`);
};
// ---- RESET ----
commands.reset = function () {
title('Reset — CUIDADO');
requireDocker();
// Safety backup
info('Criando backup antes do reset...');
try {
commands.backup();
} catch {
warn('Não foi possível criar backup');
}
warn('Resetando schema public...');
psql('DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO postgres; GRANT ALL ON SCHEMA public TO public;');
ok('Schema public resetado');
// Re-run setup
commands.setup();
};
// ---- VERIFY ----
commands.verify = function () {
title('Verificação de integridade');
requireDocker();
const checks = [
{ name: 'auth.users', sql: 'SELECT count(*) FROM auth.users', min: 1 },
{ name: 'profiles', sql: 'SELECT count(*) FROM profiles', min: 1 },
{ name: 'tenants', sql: 'SELECT count(*) FROM tenants', min: 1 },
{ name: 'plans', sql: 'SELECT count(*) FROM plans', min: 7 },
{ name: 'features', sql: 'SELECT count(*) FROM features', min: 20 },
{ name: 'plan_features', sql: 'SELECT count(*) FROM plan_features', min: 50 },
{ name: 'subscriptions', sql: 'SELECT count(*) FROM subscriptions', min: 1 },
{ name: 'email_templates', sql: 'SELECT count(*) FROM email_templates_global', min: 10 }
];
let pass = 0,
fail = 0;
for (const check of checks) {
try {
const count = parseInt(psql(check.sql, { tuples: true }).trim());
if (count >= check.min) {
ok(`${check.name}: ${count} (mín: ${check.min})`);
pass++;
} else {
err(`${check.name}: ${count} (esperado ≥ ${check.min})`);
fail++;
}
} catch {
err(`${check.name}: tabela não existe`);
fail++;
}
}
// Check entitlements view
try {
const ent = psql('SELECT count(*) FROM v_tenant_entitlements;', { tuples: true }).trim();
ok(`v_tenant_entitlements: ${ent} registros`);
pass++;
} catch {
err('v_tenant_entitlements: view não existe');
fail++;
}
log('');
if (fail === 0) {
ok(`${c.bold}Todos os ${pass} checks passaram!${c.reset}`);
} else {
err(`${fail} check(s) falharam, ${pass} passaram`);
}
};
// ---- HELP ----
commands.help = function () {
log(`
${c.bold}AgenciaPsi — Database CLI${c.reset}
${c.cyan}Uso:${c.reset} node db.cjs <comando> [opções]
${c.cyan}Comandos:${c.reset}
${c.bold}setup${c.reset} Instalação do zero (schema + fixes + seeds)
Cria backup automático após concluir
${c.bold}backup${c.reset} Exporta banco para backups/YYYY-MM-DD/
Gera: schema.sql, data.sql, full_dump.sql
${c.bold}restore [data]${c.reset} Restaura de um backup
Sem data = último backup disponível
Ex: node db.cjs restore 2026-03-23
${c.bold}migrate${c.reset} Aplica migrations pendentes (pasta migrations/)
Backup automático antes de aplicar
${c.bold}seed [grupo]${c.reset} Roda seeds (all, users, system, test_data)
Ex: node db.cjs seed system
${c.bold}status${c.reset} Mostra estado do banco, backups, migrations
${c.bold}diff${c.reset} Compara schema atual vs último backup
${c.bold}reset${c.reset} Reseta o banco e reinstala tudo do zero
${c.yellow}⚠ Cria backup antes de resetar${c.reset}
${c.bold}verify${c.reset} Verifica integridade dos dados essenciais
${c.bold}help${c.reset} Mostra esta ajuda
${c.cyan}Exemplos:${c.reset}
${c.gray}# Primeira vez — instala tudo${c.reset}
node db.cjs setup
${c.gray}# Backup diário${c.reset}
node db.cjs backup
${c.gray}# Perdi o banco — restaurar${c.reset}
node db.cjs restore
${c.gray}# Nova migration${c.reset}
node db.cjs migrate
${c.gray}# Ver o que tem no banco${c.reset}
node db.cjs status
`);
};
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const [, , cmd, ...args] = process.argv;
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
commands.help();
process.exit(0);
}
if (!commands[cmd]) {
err(`Comando desconhecido: ${cmd}`);
log(` Use ${c.cyan}node db.cjs help${c.reset} para ver os comandos disponíveis.`);
process.exit(1);
}
try {
commands[cmd](...args);
} catch (e) {
err(`Erro: ${e.message}`);
if (process.env.DEBUG) console.error(e);
process.exit(1);
}

View File

@@ -0,0 +1,34 @@
{
"container": "supabase_db_agenciapsi-primesakai",
"database": "postgres",
"user": "postgres",
"backupRetentionDays": 30,
"schema": "schema/00_full/schema.sql",
"seeds": {
"users": [
"seed_001_fixed.sql",
"seed_002.sql",
"seed_003.sql"
],
"system": [
"seed_010_plans.sql",
"seed_011_features.sql",
"seed_012_plan_features.sql",
"seed_013_subscriptions.sql",
"seed_014_global_data.sql"
],
"test_data": [
"seed_020_test_data.sql"
]
},
"fixes": [
"fix_addon_credits_fk.sql",
"fix_addon_rls_saas_admin.sql",
"fix_missing_subscriptions.sql",
"fix_notification_templates_rls_admin.sql",
"fix_seed_patient_groups.sql",
"fix_subscriptions_validate_scope.sql",
"fix_template_keys_match_populate.sql",
"fix_encoding_accents.sql"
]
}

View File

@@ -0,0 +1,176 @@
# Regras de Negócio — Banco de Dados AgenciaPsi
## 1. Planos e Targets
| Target | Planos | Escopo da Subscription |
|--------|--------|----------------------|
| `patient` | patient_free | `user_id` (sem tenant_id) |
| `therapist` | therapist_free, therapist_pro | `user_id` (sem tenant_id) |
| `clinic` | clinic_free, clinic_pro | `tenant_id` (sem user_id) |
| `supervisor` | supervisor_free, supervisor_pro | `user_id` (sem tenant_id) |
**Constraint `subscriptions_owner_xor`**: Uma subscription DEVE ter `tenant_id` XOR `user_id`, nunca ambos.
**Trigger `subscriptions_validate_scope`**: Valida que o target do plano casa com o escopo:
- `clinic` → exige `tenant_id`, rejeita `user_id`
- `therapist`, `supervisor`, `patient` → exige `user_id`, rejeita `tenant_id`
## 2. Planos Core (protegidos)
Os planos `clinic_free`, `clinic_pro`, `therapist_free`, `therapist_pro` são **core**:
- **Não podem ter `key` alterada** (trigger `trg_no_change_core_plan_key`)
- **Não podem ter `target` alterado** (trigger `trg_no_change_plan_target`)
- **Não podem ser deletados** (trigger `trg_no_delete_core_plans`)
Para bypass (migração): `SET LOCAL app.plan_migration_bypass = '1'`
## 3. Entitlements (Features)
### Resolução de features para TENANTS (clínicas)
```
tenant_has_feature(tenant_id, feature_key) =
EXISTS em v_tenant_entitlements (via plano)
OR
EXISTS em tenant_features (override direto)
```
### Resolução de features para USERS (terapeutas, supervisores)
```
user_has_feature(user_id, feature_key) =
EXISTS em v_user_entitlements (via plano pessoal)
```
### Cadeia de resolução
```
subscription → plan → plan_features → features
plan_features.limits (jsonb) → limites quantitativos
```
### Views de entitlements
- `v_tenant_active_subscription` → subscription ativa do tenant
- `v_user_active_subscription` → subscription ativa do user
- `v_tenant_entitlements` → feature_key + allowed
- `v_tenant_entitlements_full` → + limits + plan_id + plan_key
- `v_user_entitlements` → feature_key + allowed (para planos pessoais)
## 4. Tipos de Tenant
| kind | Descrição | Criação |
|------|-----------|---------|
| `therapist` | Terapeuta solo | Automático ao criar conta de terapeuta |
| `clinic_coworking` | Clínica coworking | Manual |
| `clinic_reception` | Clínica com recepção | Manual |
| `clinic_full` | Clínica completa | Manual |
| `supervisor` | Supervisor | Automático |
| `saas` | Sistema (legado) | — |
| `clinic` | Legado | — |
**O `kind` é imutável após criação** (trigger `trg_tenant_kind_immutable`).
## 5. Roles e Permissões
### Profile roles
| Role | Descrição |
|------|-----------|
| `saas_admin` | Administrador da plataforma |
| `tenant_member` | Membro de um ou mais tenants |
| `portal_user` | Paciente (acesso ao portal) |
| `patient` | Paciente (legado) |
### Tenant member roles
| Role | Descrição |
|------|-----------|
| `tenant_admin` | Admin do tenant (dono) |
| `therapist` | Terapeuta membro |
| `clinic_admin` | Admin da clínica (secretária com poderes) |
| `secretary` | Secretária |
| `supervisor` | Supervisor |
| `patient` | Paciente do tenant |
### Platform roles (array em profiles)
| Role | Descrição |
|------|-----------|
| `editor` | Editor de conteúdo da plataforma |
## 6. Compromissos Determinados
A função `seed_determined_commitments(tenant_id)` cria 5 tipos nativos:
| native_key | Nome | locked | active |
|------------|------|--------|--------|
| `session` | Sessão | true | true |
| `reading` | Leitura | false | true |
| `supervision` | Supervisão | false | true |
| `class` | Aula | false | **false** |
| `analysis` | Análise Pessoal | false | true |
- `session` é **locked** (não pode ser editado/deletado)
- O `native_key = 'session'` é usado pelo agendador online para identificar o compromisso padrão
## 7. Grupos de Pacientes Padrão
A função `seed_default_patient_groups(tenant_id)` cria 3 grupos sistema:
| Nome | Cor | is_system |
|------|-----|-----------|
| Crianças | #60a5fa | true |
| Adolescentes | #a78bfa | true |
| Idosos | #34d399 | true |
Grupos sistema não podem ser editados/deletados (trigger `prevent_system_group_changes`).
## 8. Subscriptions — Status
| Status | Descrição |
|--------|-----------|
| `pending` | Aguardando ativação |
| `active` | Ativa |
| `past_due` | Pagamento atrasado |
| `suspended` | Suspensa |
| `cancelled` | Cancelada |
| `expired` | Expirada |
## 9. Templates de Email
**Globais** (`email_templates_global`): templates padrão da plataforma, gerenciados pelo saas_admin.
**Tenant** (`email_templates_tenant`): overrides por tenant. Se existir, usa o do tenant; se não, usa o global.
### Keys de template
| Domínio | Templates |
|---------|-----------|
| session | reminder, confirmation, cancellation, rescheduled |
| intake | received, approved, rejected |
| scheduler | request_accepted, request_rejected |
| system | welcome, password_reset |
Canais: `email`, `whatsapp`, `sms`
## 10. Notificações — Sistema
| Canal | Tipos |
|-------|-------|
| WhatsApp | lembrete_sessao, confirmacao_sessao, cancelamento_sessao |
| SMS | lembrete_sessao |
### Schedule keys
| Key | Descrição |
|-----|-----------|
| `lembrete_24h` | 24 horas antes |
| `lembrete_2h` | 2 horas antes |
| `lembrete_30min` | 30 minutos antes |
| `confirmacao_imediata` | Imediato após confirmar |
| `cancelamento_imediato` | Imediato após cancelar |
## 11. RLS (Row Level Security)
Todas as tabelas do schema `public` têm RLS habilitado. As policies usam:
- `auth.uid()` — ID do usuário autenticado
- `is_saas_admin()` — verifica se é admin da plataforma
- `is_tenant_member(tenant_id)` — verifica se pertence ao tenant
- `is_tenant_admin(tenant_id)` — verifica se é admin do tenant
- `current_member_role(tenant_id)` — role do membro no tenant
- `tenant_has_feature(tenant_id, feature_key)` — verifica feature
**Se as features/plan_features não existirem no banco, as policies de RLS bloqueiam o acesso.**

View File

@@ -0,0 +1,191 @@
# Schema Map — AgenciaPsi
Mapa completo do banco de dados PostgreSQL 17, extraído de `schema.sql` (2026-03-23).
**84 tabelas** no schema `public` + tabelas de infraestrutura (auth, storage, realtime).
## Domínios
### Core (11 tabelas)
| Tabela | Descrição |
|--------|-----------|
| `profiles` | Perfil do usuário (role, account_type, full_name, platform_roles) |
| `tenants` | Organizações (clínicas, terapeutas solo, supervisores) |
| `tenant_members` | Vínculo usuário↔tenant com role (tenant_admin, therapist, secretary, etc.) |
| `tenant_invites` | Convites pendentes para ingressar em um tenant |
| `tenant_features` | Overrides de features por tenant (exceções comerciais) |
| `tenant_feature_exceptions_log` | Log de alterações em tenant_features |
| `saas_admins` | Administradores da plataforma |
| `owner_users` | Mapeamento owner_id→user_id para RLS |
| `user_settings` | Configurações pessoais do usuário |
| `company_profiles` | Perfil da empresa/clínica (logo, endereço, etc.) |
| `dev_user_credentials` | Credenciais de teste (apenas dev) |
### Plans & Billing (20 tabelas)
| Tabela | Descrição |
|--------|-----------|
| `plans` | Planos disponíveis (key, target, price_cents, max_supervisees) |
| `plan_prices` | Preços por intervalo (month/year) com versionamento |
| `plan_features` | Vínculo plano↔feature com limites (limits jsonb) |
| `plan_public` | Info pública dos planos (para página de preços) |
| `plan_public_bullets` | Bullets de marketing dos planos |
| `features` | Features do sistema (key, name, descricao) |
| `entitlements_invalidation` | Cache invalidation de entitlements |
| `subscriptions` | Assinaturas ativas (user_id XOR tenant_id) |
| `subscription_events` | Histórico de eventos de assinatura |
| `subscription_intents_personal` | Intenções de assinatura pessoal |
| `subscription_intents_tenant` | Intenções de assinatura de tenant |
| `subscription_intents_legacy` | Intenções legadas |
| `billing_contracts` | Contratos de cobrança |
| `addon_credits` | Créditos de add-ons por tenant |
| `addon_products` | Produtos add-on disponíveis |
| `addon_transactions` | Transações de add-ons |
| `modules` | Módulos do sistema |
| `module_features` | Features por módulo |
| `tenant_modules` | Módulos ativos por tenant |
### Agenda (11 tabelas)
| Tabela | Descrição |
|--------|-----------|
| `agenda_bloqueios` | Bloqueios de horário |
| `agenda_configuracoes` | Configurações da agenda por tenant_member |
| `agenda_eventos` | Eventos da agenda (sessões, bloqueios) |
| `agenda_excecoes` | Exceções na agenda (horários extras, bloqueios pontuais) |
| `agenda_online_slots` | Slots de agendamento online |
| `agenda_regras_semanais` | Regras semanais de disponibilidade |
| `agenda_slots_bloqueados_semanais` | Slots bloqueados na semana |
| `agenda_slots_regras` | Regras de slots |
| `recurrence_rules` | Regras de recorrência de sessões |
| `recurrence_exceptions` | Exceções a recorrências |
| `recurrence_rule_services` | Serviços vinculados a recorrências |
### Agendador Online (2 tabelas)
| Tabela | Descrição |
|--------|-----------|
| `agendador_configuracoes` | Configurações do agendador online público |
| `agendador_solicitacoes` | Solicitações de agendamento recebidas |
### Pacientes (8 tabelas)
| Tabela | Descrição |
|--------|-----------|
| `patients` | Pacientes vinculados a um tenant |
| `patient_groups` | Grupos de pacientes (sistema + customizados) |
| `patient_group_patient` | Vínculo paciente↔grupo |
| `patient_tags` | Tags personalizadas |
| `patient_patient_tag` | Vínculo paciente↔tag |
| `patient_intake_requests` | Solicitações de cadastro (triagem) |
| `patient_invites` | Convites para portal do paciente |
| `patient_discounts` | Descontos por paciente |
### Compromissos Determinados (4 tabelas)
| Tabela | Descrição |
|--------|-----------|
| `determined_commitments` | Tipos de compromisso (sessão, leitura, supervisão, etc.) |
| `determined_commitment_fields` | Campos customizados por tipo de compromisso |
| `commitment_services` | Serviços vinculados a compromissos |
| `commitment_time_logs` | Logs de tempo por compromisso |
### Financeiro (9 tabelas)
| Tabela | Descrição |
|--------|-----------|
| `financial_records` | Lançamentos financeiros (receita/despesa) |
| `financial_categories` | Categorias de lançamento |
| `financial_exceptions` | Exceções financeiras |
| `payment_settings` | Configurações de pagamento por tenant |
| `professional_pricing` | Precificação por profissional |
| `therapist_payouts` | Repasses a terapeutas |
| `therapist_payout_records` | Registros de repasse |
| `services` | Serviços oferecidos |
| `insurance_plans` + `insurance_plan_services` | Convênios e serviços por convênio |
### Notificações (10 tabelas)
| Tabela | Descrição |
|--------|-----------|
| `notification_channels` | Canais de notificação por tenant |
| `notification_logs` | Logs de envio |
| `notification_preferences` | Preferências do paciente (opt-in/out) |
| `notification_queue` | Fila de envio |
| `notification_schedules` | Agendamentos de notificação |
| `notification_templates` | Templates WhatsApp/SMS (default + tenant) |
| `notifications` | Notificações in-app |
| `email_templates_global` | Templates de email globais (plataforma) |
| `email_templates_tenant` | Overrides de templates por tenant |
| `email_layout_config` | Configuração de layout de email |
### SaaS Admin / UI (8 tabelas)
| Tabela | Descrição |
|--------|-----------|
| `saas_docs` | Documentação da plataforma |
| `saas_doc_votos` | Votos em docs |
| `saas_faq` | Categorias de FAQ |
| `saas_faq_itens` | Itens de FAQ |
| `feriados` | Feriados nacionais/regionais |
| `global_notices` | Avisos globais da plataforma |
| `login_carousel_slides` | Slides do carrossel de login |
| `notice_dismissals` | Dismissals de avisos por usuário |
### Suporte (1 tabela)
| Tabela | Descrição |
|--------|-----------|
| `support_sessions` | Sessões de suporte técnico |
---
## Views Principais
| View | Descrição |
|------|-----------|
| `v_tenant_active_subscription` | Subscription ativa por tenant |
| `v_user_active_subscription` | Subscription ativa por user |
| `v_tenant_entitlements` | Features habilitadas por tenant (via plano) |
| `v_tenant_entitlements_full` | Entitlements + limits + plan info |
| `v_tenant_entitlements_json` | Entitlements agregados como JSON |
| `v_user_entitlements` | Features habilitadas por user (via plano) |
| `v_tenant_members_with_profiles` | Membros do tenant com dados do perfil |
| `v_tenant_staff` | Staff do tenant (membros + convites) |
| `v_tenant_people` | Todas as pessoas do tenant |
| `v_plan_active_prices` | Preços ativos dos planos |
| `v_public_pricing` | Preços públicos para página de marketing |
| `v_subscription_health` | Saúde das subscriptions |
| `v_cashflow_projection` | Projeção de fluxo de caixa |
| `v_commitment_totals` | Totais de compromissos |
| `v_patient_groups_with_counts` | Grupos com contagem de pacientes |
| `v_tag_patient_counts` | Tags com contagem de pacientes |
| `subscription_intents` | View unificada de intenções (com INSTEAD OF trigger) |
| `owner_feature_entitlements` | Entitlements por owner |
| `current_tenant_id` | Tenant ativo do usuário corrente |
---
## Funções Críticas
| Função | Tipo | Descrição |
|--------|------|-----------|
| `tenant_has_feature(uuid, text)` | Query | Verifica se tenant tem feature (plano + override) |
| `user_has_feature(uuid, text)` | Query | Verifica se user tem feature via plano pessoal |
| `has_feature(uuid, text)` | Query | Alias genérico |
| `seed_determined_commitments(uuid)` | Seed | Cria 5 tipos de compromisso nativos por tenant |
| `seed_default_patient_groups(uuid)` | Seed | Cria 3 grupos de pacientes padrão |
| `seed_default_financial_categories(uuid)` | Seed | Cria categorias financeiras padrão |
| `subscriptions_validate_scope()` | Trigger | Valida XOR (user_id vs tenant_id) por target |
| `activate_subscription_from_intent(uuid)` | RPC | Ativa subscription a partir de intent |
| `handle_new_user()` | Trigger | Cria profile + tenant pessoal ao cadastrar |
| `ensure_personal_tenant()` | RPC | Garante que o user tem um tenant pessoal |
| `populate_notification_queue()` | Cron | Popula fila de notificações |
| `agendador_slots_disponiveis(text, date)` | RPC | Retorna slots disponíveis para agendamento |
---
## Enums (public schema)
| Tipo | Valores |
|------|---------|
| `commitment_log_source` | manual, auto |
| `determined_field_type` | text, textarea, number, date, select, boolean |
| `financial_record_type` | receita, despesa |
| `recurrence_exception_type` | cancel_session, reschedule_session, patient_missed, therapist_canceled, holiday_block |
| `recurrence_type` | weekly, biweekly, monthly, yearly, custom_weekdays |
| `status_agenda_serie` | ativo, pausado, cancelado |
| `status_evento_agenda` | agendado, realizado, faltou, cancelado, remarcar |
| `status_excecao_agenda` | pendente, ativo, arquivado |
| `tipo_evento_agenda` | sessao, bloqueio |
| `tipo_excecao_agenda` | bloqueio, horario_extra |

View File

@@ -0,0 +1,297 @@
# Guia de Instalação e Uso — AgenciaPsi Database
## Pré-requisitos
1. **Docker Desktop** instalado e rodando
2. **Node.js** 18+ instalado
3. **Supabase CLI** instalado (`npm install -g supabase`)
## Instalação do Zero (banco vazio)
### 1. Iniciar o Supabase
```bash
# Na raiz do projeto (agenciapsi-primesakai/)
npx supabase start
```
Aguarde até o container `supabase_db_agenciapsi-primesakai` estar rodando.
### 2. Verificar se o container está ok
```bash
docker ps | grep supabase_db
```
Deve mostrar o container com status `Up`.
### 3. Instalar o banco completo
```bash
cd database-novo
node db.cjs setup
```
Isso faz tudo automaticamente:
- Aplica o schema completo (84 tabelas, funções, triggers, policies)
- Aplica os 7 fixes conhecidos
- Cria os 11 usuários de teste
- Cria os 7 planos + 4 preços
- Cria as 26 features + 85 vínculos plano↔feature
- Cria as 9 subscriptions + compromissos determinados
- Cria os templates de email, notificação e carousel
- Cria backup automático pós-instalação
- Verifica integridade no final
### 4. Verificar
```bash
node db.cjs status
```
Deve mostrar todos os counts verdes.
## Backup
### Criar backup manual
```bash
node db.cjs backup
```
Salva em `backups/YYYY-MM-DD/` com 3 arquivos:
- `schema.sql` — estrutura do banco
- `data.sql` — dados (sem schemas de infra)
- `full_dump.sql` — tudo junto
### Backup automático
O backup é feito automaticamente:
- Após o `setup`
- Antes de cada `migrate`
- Antes de cada `restore`
- Antes de cada `reset`
### Retenção
Backups com mais de 30 dias são removidos automaticamente. Para alterar, edite `backupRetentionDays` no `db.config.json`.
## Restaurar o Banco
### Restaurar do último backup
```bash
node db.cjs restore
```
### Restaurar de uma data específica
```bash
node db.cjs restore 2026-03-23
```
O restore:
1. Cria backup de segurança do estado atual
2. Limpa o schema public
3. Aplica o full_dump.sql do backup
4. Verifica integridade
## Migrations (alterações no banco)
### Criar uma migration
Crie um arquivo SQL na pasta `migrations/` com nome sequencial:
```
migrations/
├── 001_add_column_x.sql
├── 002_create_table_y.sql
└── 003_fix_something.sql
```
O nome deve começar com número para garantir a ordem.
### Aplicar migrations pendentes
```bash
node db.cjs migrate
```
O CLI:
1. Cria backup automático
2. Compara com a tabela `_db_migrations` no banco
3. Aplica apenas as que ainda não foram executadas
4. Registra cada migration aplicada
5. Se uma falhar, para imediatamente (use `restore` para voltar)
### Ver migrations aplicadas
```bash
node db.cjs status
```
## Seeds (dados de teste)
### Rodar todos os seeds
```bash
node db.cjs seed all # ou simplesmente: node db.cjs seed
```
### Rodar grupo específico
```bash
node db.cjs seed users # Apenas usuários (seed_001 a 003)
node db.cjs seed system # Apenas sistema (seed_010 a 014)
node db.cjs seed test_data # Dados de teste (seed_020)
```
### Ordem dos seeds
| # | Arquivo | O que faz |
|---|---------|-----------|
| 1 | `seed_001_fixed.sql` | 6 usuários base + tenants |
| 2 | `seed_002.sql` | Supervisor + Editor |
| 3 | `seed_003.sql` | Therapist2, Therapist3, Secretary |
| 4 | `seed_010_plans.sql` | 7 planos + 4 preços |
| 5 | `seed_011_features.sql` | 26 features |
| 6 | `seed_012_plan_features.sql` | 85 vínculos plano↔feature |
| 7 | `seed_013_subscriptions.sql` | 9 subscriptions + compromissos |
| 8 | `seed_014_global_data.sql` | Templates + carousel |
## Outros Comandos
### Ver status
```bash
node db.cjs status
```
Mostra: container, backups, migrations aplicadas/pendentes, counts de todas as tabelas.
### Comparar mudanças
```bash
node db.cjs diff
```
Compara o schema atual no banco com o último backup. Mostra tabelas adicionadas, removidas ou alteradas.
### Verificar integridade
```bash
node db.cjs verify
```
Checa se os dados essenciais existem (plans, features, subscriptions, etc).
### Reset completo
```bash
node db.cjs reset
```
**⚠ CUIDADO**: Apaga tudo e reinstala do zero. Cria backup antes.
## Estrutura de Pastas
```
database-novo/
├── db.js ← CLI principal
├── db.config.json ← Configuração (container, seeds, fixes)
├── schema/ ← Schema SQL separado por seção
│ ├── 00_full/ ← Schema completo (referência)
│ ├── 01_extensions/ ← Extensões PostgreSQL
│ ├── 02_types/ ← Enums e tipos
│ ├── 03_functions/ ← Funções (11 arquivos por domínio)
│ ├── 04_tables/ ← Tabelas (10 arquivos por domínio)
│ ├── 05_views/ ← 24 views
│ ├── 06_indexes/ ← Índices
│ ├── 07_foreign_keys/ ← PKs, FKs, constraints
│ ├── 08_triggers/ ← Triggers
│ ├── 09_policies/ ← 217 RLS policies
│ └── 10_grants/ ← Grants
├── seeds/ ← Seeds de dados
│ ├── seed_001_fixed.sql
│ ├── ...
│ └── run_all_seeds.sh
├── migrations/ ← Migrations (alterações incrementais)
├── fixes/ ← Correções aplicadas
├── backups/ ← Backups com data
│ ├── 2026-03-23/
│ └── ...
└── docs/ ← Documentação
├── setup_guide.md ← Este arquivo
├── schema_map.md ← Mapa de 84 tabelas
├── business_rules.md ← Regras de negócio
└── users_test.md ← Usuários de teste
```
## Credenciais de Teste
| Email | Senha | Tipo |
|-------|-------|------|
| paciente@agenciapsi.com.br | Teste@123 | Paciente |
| terapeuta@agenciapsi.com.br | Teste@123 | Terapeuta solo |
| clinica1@agenciapsi.com.br | Teste@123 | Clínica coworking |
| clinica2@agenciapsi.com.br | Teste@123 | Clínica recepção |
| clinica3@agenciapsi.com.br | Teste@123 | Clínica full |
| saas@agenciapsi.com.br | Teste@123 | Admin plataforma |
| supervisor@agenciapsi.com.br | Teste@123 | Supervisor |
| editor@agenciapsi.com.br | Teste@123 | Editor |
| therapist2@agenciapsi.com.br | Teste@123 | Terapeuta |
| therapist3@agenciapsi.com.br | Teste@123 | Terapeuta |
| secretary@agenciapsi.com.br | Teste@123 | Secretária |
## Troubleshooting
### "Container não está rodando"
```bash
# Verificar
docker ps | grep supabase
# Reiniciar
npx supabase stop
npx supabase start
```
### "Tabela não existe" após setup
O schema pode não ter sido aplicado corretamente. Rode:
```bash
node db.cjs reset
```
### "Permission denied" / RLS bloqueando
Se features/plan_features estiverem vazios, o RLS bloqueia tudo. Rode:
```bash
node db.cjs seed system
```
### Migration falhou no meio
```bash
# Voltar ao estado anterior
node db.cjs restore
# Corrigir o SQL da migration, depois tentar de novo
node db.cjs migrate
```
### Quero começar do zero
```bash
node db.cjs reset
```
Isso apaga tudo, reaplica schema, fixes, seeds, e verifica.

View File

@@ -0,0 +1,90 @@
# Usuários de Teste — AgenciaPsi
Senha de todos: `Teste@123`
## Mapa de UUIDs
### Users (auth.users.id = profiles.id)
| Email | UUID | Nome |
|-------|------|------|
| paciente@agenciapsi.com.br | `aaaaaaaa-0001-0001-0001-000000000001` | Ana Paciente |
| terapeuta@agenciapsi.com.br | `aaaaaaaa-0002-0002-0002-000000000002` | Bruno Terapeuta |
| clinica1@agenciapsi.com.br | `aaaaaaaa-0003-0003-0003-000000000003` | Clínica Espaço Psi |
| clinica2@agenciapsi.com.br | `aaaaaaaa-0004-0004-0004-000000000004` | Clínica Mente sã |
| clinica3@agenciapsi.com.br | `aaaaaaaa-0005-0005-0005-000000000005` | Clínica Bem Estar |
| saas@agenciapsi.com.br | `aaaaaaaa-0006-0006-0006-000000000006` | Admin Plataforma |
| supervisor@agenciapsi.com.br | `aaaaaaaa-0007-0007-0007-000000000007` | Carlos Supervisor |
| editor@agenciapsi.com.br | `aaaaaaaa-0008-0008-0008-000000000008` | Diana Editora |
| therapist2@agenciapsi.com.br | `aaaaaaaa-0009-0009-0009-000000000009` | Eva Terapeuta |
| therapist3@agenciapsi.com.br | `aaaaaaaa-0010-0010-0010-000000000010` | Felipe Terapeuta |
| secretary@agenciapsi.com.br | `aaaaaaaa-0011-0011-0011-000000000011` | Gabriela Secretária |
### Tenants
| Nome | UUID | Kind |
|------|------|------|
| Bruno Terapeuta | `bbbbbbbb-0002-0002-0002-000000000002` | therapist |
| Clínica Espaço Psi | `bbbbbbbb-0003-0003-0003-000000000003` | clinic_coworking |
| Clínica Mente sã | `bbbbbbbb-0004-0004-0004-000000000004` | clinic_reception |
| Clínica Bem Estar | `bbbbbbbb-0005-0005-0005-000000000005` | clinic_full |
| Eva Terapeuta | `bbbbbbbb-0009-0009-0009-000000000009` | therapist |
| Felipe Terapeuta | `bbbbbbbb-0010-0010-0010-000000000010` | therapist |
## Mapa de Vínculos
```
paciente@ → portal_user / patient_free (user_id)
Sem tenant próprio
terapeuta@ → tenant_member / therapist
Tenant: bbbbbbbb-0002 (therapist) → tenant_admin
Clínica 3: bbbbbbbb-0005 → therapist
Subscription: therapist_free (user_id)
clinica1@ → tenant_member / clinic
Tenant: bbbbbbbb-0003 (clinic_coworking) → tenant_admin
Subscription: clinic_free (tenant_id)
clinica2@ → tenant_member / clinic
Tenant: bbbbbbbb-0004 (clinic_reception) → tenant_admin
Subscription: clinic_free (tenant_id)
clinica3@ → tenant_member / clinic
Tenant: bbbbbbbb-0005 (clinic_full) → tenant_admin
Subscription: clinic_free (tenant_id)
saas@ → saas_admin
Sem tenant, sem subscription
supervisor@ → tenant_member / therapist
Clínica 3: bbbbbbbb-0005 → supervisor
Subscription: supervisor_free (user_id)
editor@ → tenant_member / therapist + platform_roles: {editor}
Clínica 3: bbbbbbbb-0005 → therapist
Subscription: therapist_free (user_id)
therapist2@ → tenant_member / therapist
Tenant: bbbbbbbb-0009 (therapist) → tenant_admin
Clínica 3: bbbbbbbb-0005 → therapist
Subscription: therapist_free (user_id)
therapist3@ → tenant_member / therapist
Tenant: bbbbbbbb-0010 (therapist) → tenant_admin
Clínica 3: bbbbbbbb-0005 → therapist
Subscription: therapist_free (user_id)
secretary@ → tenant_member / therapist (profile)
Clínica 2: bbbbbbbb-0004 → clinic_admin
Sem subscription própria (usa plano da Clínica 2)
```
## Clínica 3 — Bem Estar (Full) — Membros
| Membro | Role |
|--------|------|
| clinica3@ | tenant_admin |
| terapeuta@ | therapist |
| supervisor@ | supervisor |
| editor@ | therapist |
| therapist2@ | therapist |
| therapist3@ | therapist |

View File

@@ -0,0 +1,11 @@
-- ============================================================
-- Fix: addon_credits e addon_transactions tenant_id FK
-- Corrige FK que apontava para auth.users → agora aponta para public.tenants
-- Agência PSI — 2026-03-22
-- ============================================================
ALTER TABLE public.addon_credits DROP CONSTRAINT IF EXISTS addon_credits_tenant_id_fkey;
ALTER TABLE public.addon_credits ADD CONSTRAINT addon_credits_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id);
ALTER TABLE public.addon_transactions DROP CONSTRAINT IF EXISTS addon_transactions_tenant_id_fkey;
ALTER TABLE public.addon_transactions ADD CONSTRAINT addon_transactions_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id);

View File

@@ -0,0 +1,83 @@
-- ============================================================
-- Fix: RLS addon_credits e addon_transactions
-- 1. SaaS Admin: acesso total
-- 2. Tenant members: SELECT nos seus créditos/transações
-- Agência PSI — 2026-03-22
-- ============================================================
-- ── addon_products: admin pode tudo (CRUD) ────────────────────
DROP POLICY IF EXISTS "addon_products_admin_all" ON public.addon_products;
CREATE POLICY "addon_products_admin_all"
ON public.addon_products FOR ALL
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
)
WITH CHECK (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
);
-- ── addon_credits: admin pode ver todos ───────────────────────
DROP POLICY IF EXISTS "addon_credits_admin_select" ON public.addon_credits;
CREATE POLICY "addon_credits_admin_select"
ON public.addon_credits FOR SELECT
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
);
-- ── addon_credits: admin pode inserir/atualizar ───────────────
DROP POLICY IF EXISTS "addon_credits_admin_write" ON public.addon_credits;
CREATE POLICY "addon_credits_admin_write"
ON public.addon_credits FOR ALL
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
)
WITH CHECK (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
);
-- ── addon_transactions: admin pode ver todas ──────────────────
DROP POLICY IF EXISTS "addon_transactions_admin_select" ON public.addon_transactions;
CREATE POLICY "addon_transactions_admin_select"
ON public.addon_transactions FOR SELECT
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
);
-- ── addon_transactions: admin pode inserir ────────────────────
DROP POLICY IF EXISTS "addon_transactions_admin_insert" ON public.addon_transactions;
CREATE POLICY "addon_transactions_admin_insert"
ON public.addon_transactions FOR INSERT
TO authenticated
WITH CHECK (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
);
-- ══════════════════════════════════════════════════════════════
-- Corrige policies de tenant members (SELECT)
-- A policy original usava tenant_id = auth.uid(), mas o auth.uid()
-- é o user_id, não o tenant_id. Usa is_tenant_member() em vez disso.
-- ══════════════════════════════════════════════════════════════
-- addon_credits: membro do tenant vê os créditos do seu tenant
DROP POLICY IF EXISTS "addon_credits_select_own" ON public.addon_credits;
CREATE POLICY "addon_credits_select_own"
ON public.addon_credits FOR SELECT
TO authenticated
USING (
public.is_tenant_member(tenant_id)
OR owner_id = auth.uid()
);
-- addon_transactions: membro do tenant vê as transações do seu tenant
DROP POLICY IF EXISTS "addon_transactions_select_own" ON public.addon_transactions;
CREATE POLICY "addon_transactions_select_own"
ON public.addon_transactions FOR SELECT
TO authenticated
USING (
public.is_tenant_member(tenant_id)
OR owner_id = auth.uid()
);

View File

@@ -0,0 +1,179 @@
-- =============================================================================
-- FIX: Corrige acentuação perdida (caracteres ?? no banco)
-- =============================================================================
-- Causa: Seeds aplicados originalmente sem encoding UTF-8 correto.
-- Os ?? são bytes literais 0x3F (ASCII ?) onde deveria haver UTF-8.
-- Este fix faz UPDATE direto nos valores conhecidos.
-- =============================================================================
BEGIN;
SET client_encoding TO 'UTF8';
-- ============================================================
-- 1. PROFILES — full_name
-- ============================================================
UPDATE profiles SET full_name = 'Clínica Espaço Psi' WHERE id = 'aaaaaaaa-0003-0003-0003-000000000003' AND full_name != 'Clínica Espaço Psi';
UPDATE profiles SET full_name = 'Clínica Mente Sã' WHERE id = 'aaaaaaaa-0004-0004-0004-000000000004' AND full_name != 'Clínica Mente Sã';
UPDATE profiles SET full_name = 'Clínica Bem Estar' WHERE id = 'aaaaaaaa-0005-0005-0005-000000000005' AND full_name != 'Clínica Bem Estar';
UPDATE profiles SET full_name = 'Gabriela Secretária' WHERE id = 'aaaaaaaa-0011-0011-0011-000000000011' AND full_name != 'Gabriela Secretária';
-- ============================================================
-- 2. TENANTS — name
-- ============================================================
UPDATE tenants SET name = 'Clínica Espaço Psi' WHERE id = 'bbbbbbbb-0003-0003-0003-000000000003';
UPDATE tenants SET name = 'Clínica Mente Sã' WHERE id = 'bbbbbbbb-0004-0004-0004-000000000004';
UPDATE tenants SET name = 'Clínica Bem Estar' WHERE id = 'bbbbbbbb-0005-0005-0005-000000000005';
-- ============================================================
-- 3. DETERMINED_COMMITMENTS — name
-- ============================================================
UPDATE determined_commitments SET name = 'Sessão' WHERE native_key = 'session';
UPDATE determined_commitments SET name = 'Supervisão' WHERE native_key = 'supervision';
UPDATE determined_commitments SET name = 'Análise Pessoal' WHERE native_key = 'analysis';
-- ============================================================
-- 4. PLANS — name, description
-- ============================================================
UPDATE plans SET name = 'THERAPIST PRO', description = 'Plano profissional para terapeutas' WHERE key = 'therapist_pro' AND description LIKE '%??%';
UPDATE plans SET name = 'CLINIC PRO', description = 'Plano profissional para clínicas' WHERE key = 'clinic_pro' AND description LIKE '%??%';
UPDATE plans SET name = 'THERAPIST FREE', description = 'Plano gratuito para terapeutas' WHERE key = 'therapist_free' AND description LIKE '%??%';
UPDATE plans SET name = 'CLINIC FREE', description = 'Plano gratuito para clínicas' WHERE key = 'clinic_free' AND description LIKE '%??%';
-- ============================================================
-- 5. FEATURES — name, description
-- ============================================================
UPDATE features SET name = 'Agenda - Visualizar', description = 'Visualização da agenda' WHERE key = 'agenda.view';
UPDATE features SET name = 'Agenda - Gerenciar', description = 'Gerenciamento completo da agenda' WHERE key = 'agenda.manage';
UPDATE features SET name = 'Pacientes', description = 'Módulo de pacientes' WHERE key = 'patients';
UPDATE features SET name = 'Pacientes - Visualizar', description = 'Visualização de pacientes' WHERE key = 'patients.view';
UPDATE features SET name = 'Pacientes - Gerenciar', description = 'Gerenciamento completo de pacientes' WHERE key = 'patients.manage';
UPDATE features SET name = 'Agendamento Online', description = 'Sistema de agendamento online' WHERE key = 'online_scheduling';
UPDATE features SET name = 'Agendamento Online - Gerenciar', description = 'Gerenciamento do agendamento online' WHERE key = 'online_scheduling.manage';
UPDATE features SET name = 'Agendamento Online - Público', description = 'Página pública do agendador' WHERE key = 'online_scheduling.public';
UPDATE features SET name = 'Lembretes', description = 'Sistema de lembretes automáticos' WHERE key = 'reminders';
UPDATE features SET name = 'Relatórios Básicos', description = 'Relatórios básicos' WHERE key = 'reports_basic';
UPDATE features SET name = 'Relatórios Avançados', description = 'Relatórios avançados com exportação' WHERE key = 'reports_advanced';
UPDATE features SET name = 'Secretária', description = 'Funcionalidade de secretária' WHERE key = 'secretary';
UPDATE features SET name = 'Recepção Compartilhada', description = 'Recepção compartilhada entre terapeutas' WHERE key = 'shared_reception';
UPDATE features SET name = 'Salas', description = 'Gerenciamento de salas' WHERE key = 'rooms';
UPDATE features SET name = 'Intake Público', description = 'Formulário de intake público' WHERE key = 'intake_public';
UPDATE features SET name = 'Intakes PRO', description = 'Funcionalidades avançadas de intake' WHERE key = 'intakes_pro';
UPDATE features SET name = 'Branding Personalizado', description = 'Personalização de marca' WHERE key = 'custom_branding';
UPDATE features SET name = 'Acesso API', description = 'Acesso via API' WHERE key = 'api_access';
UPDATE features SET name = 'Log de Auditoria', description = 'Log de auditoria completo' WHERE key = 'audit_log';
UPDATE features SET name = 'Lembrete SMS', description = 'Lembretes via SMS' WHERE key = 'sms_reminder';
UPDATE features SET name = 'Calendário da Clínica', description = 'Visão consolidada do calendário' WHERE key = 'clinic_calendar';
UPDATE features SET name = 'Relatórios Avançados (Clínica)', description = 'Relatórios avançados da clínica' WHERE key = 'advanced_reports';
UPDATE features SET name = 'Supervisor - Acesso', description = 'Acesso ao módulo de supervisão' WHERE key = 'supervisor.access';
UPDATE features SET name = 'Supervisor - Convidar', description = 'Convidar supervisionados' WHERE key = 'supervisor.invite';
UPDATE features SET name = 'Supervisor - Sessões', description = 'Gerenciar sessões de supervisão' WHERE key = 'supervisor.sessions';
UPDATE features SET name = 'Supervisor - Relatórios', description = 'Relatórios de supervisão' WHERE key = 'supervisor.reports';
-- ============================================================
-- 6. EMAIL_TEMPLATES_GLOBAL — subject, body_html, body_text
-- ============================================================
UPDATE email_templates_global SET
subject = 'Lembrete: sua sessão amanhã às {{session_time}}',
body_text = 'Olá {{patient_name}}, lembrete da sua sessão amanhã às {{session_time}} com {{therapist_name}}.'
WHERE key = 'session.reminder';
UPDATE email_templates_global SET
subject = 'Sessão confirmada — {{session_date}} às {{session_time}}',
body_text = 'Sua sessão com {{therapist_name}} em {{session_date}} às {{session_time}} foi confirmada.'
WHERE key = 'session.confirmation';
UPDATE email_templates_global SET
subject = 'Sessão cancelada — {{session_date}}',
body_text = 'A sessão de {{session_date}} às {{session_time}} com {{therapist_name}} foi cancelada.'
WHERE key = 'session.cancellation';
UPDATE email_templates_global SET
subject = 'Sessão reagendada — novo horário: {{session_date}} às {{session_time}}',
body_text = 'Sua sessão foi reagendada para {{session_date}} às {{session_time}} com {{therapist_name}}.'
WHERE key = 'session.rescheduled';
UPDATE email_templates_global SET
subject = 'Recebemos seu cadastro — {{patient_name}}',
body_text = 'Olá {{patient_name}}, recebemos seu formulário de cadastro. Entraremos em contato em breve.'
WHERE key = 'intake.received';
UPDATE email_templates_global SET
subject = 'Cadastro aprovado — Bem-vindo(a)!',
body_text = 'Olá {{patient_name}}, seu cadastro foi aprovado. Você já pode acessar a plataforma.'
WHERE key = 'intake.approved';
UPDATE email_templates_global SET
subject = 'Cadastro não aprovado',
body_text = 'Olá {{patient_name}}, infelizmente seu cadastro não foi aprovado no momento.'
WHERE key = 'intake.rejected';
UPDATE email_templates_global SET
subject = 'Solicitação aceita — {{session_date}} às {{session_time}}',
body_text = 'Sua solicitação de agendamento para {{session_date}} às {{session_time}} foi aceita.'
WHERE key = 'scheduler.request_accepted';
UPDATE email_templates_global SET
subject = 'Solicitação não disponível',
body_text = 'Infelizmente o horário solicitado não está disponível. Por favor, escolha outro horário.'
WHERE key = 'scheduler.request_rejected';
UPDATE email_templates_global SET
subject = 'Bem-vindo(a) à AgenciaPsi!',
body_text = 'Olá {{user_name}}, sua conta foi criada com sucesso. Acesse a plataforma para começar.'
WHERE key = 'system.welcome';
UPDATE email_templates_global SET
subject = 'Redefinição de senha — AgenciaPsi',
body_text = 'Clique no link abaixo para redefinir sua senha: {{reset_link}}'
WHERE key = 'system.password_reset';
-- ============================================================
-- 7. LOGIN_CAROUSEL_SLIDES — title, description
-- ============================================================
UPDATE login_carousel_slides SET
title = '<strong>Gestão clínica simplificada</strong>',
body = 'Gerencie agenda, pacientes e financeiro em um só lugar. Simples, rápido e seguro.'
WHERE ordem = 1;
UPDATE login_carousel_slides SET
title = '<strong>Múltiplos profissionais, uma só plataforma</strong>',
body = 'Ideal para clínicas com vários terapeutas. Cada profissional com sua agenda e seus pacientes.'
WHERE ordem = 2;
UPDATE login_carousel_slides SET
title = '<strong>Seguro, privado e sempre disponível</strong>',
body = 'Seus dados protegidos com criptografia. Acesse de qualquer lugar, a qualquer hora.'
WHERE ordem = 3;
-- ============================================================
-- 8. PATIENT_GROUPS (default groups) — name
-- ============================================================
UPDATE patient_groups SET nome = 'Crianças' WHERE nome LIKE 'Crian%' AND is_system = true;
UPDATE patient_groups SET nome = 'Adolescentes' WHERE nome LIKE 'Adolescen%' AND is_system = true;
UPDATE patient_groups SET nome = 'Idosos' WHERE nome LIKE 'Idoso%' AND is_system = true;
-- ============================================================
-- 9. AUTH.USERS — raw_user_meta_data (name field)
-- ============================================================
UPDATE auth.users SET raw_user_meta_data = jsonb_set(raw_user_meta_data, '{name}', '"Clínica Espaço Psi"') WHERE id = 'aaaaaaaa-0003-0003-0003-000000000003';
UPDATE auth.users SET raw_user_meta_data = jsonb_set(raw_user_meta_data, '{name}', '"Clínica Mente Sã"') WHERE id = 'aaaaaaaa-0004-0004-0004-000000000004';
UPDATE auth.users SET raw_user_meta_data = jsonb_set(raw_user_meta_data, '{name}', '"Clínica Bem Estar"') WHERE id = 'aaaaaaaa-0005-0005-0005-000000000005';
UPDATE auth.users SET raw_user_meta_data = jsonb_set(raw_user_meta_data, '{name}', '"Gabriela Secretária"') WHERE id = 'aaaaaaaa-0011-0011-0011-000000000011';
COMMIT;
-- ============================================================
DO $$
DECLARE
broken_count int;
BEGIN
SELECT count(*) INTO broken_count
FROM profiles WHERE full_name LIKE '%??%';
IF broken_count = 0 THEN
RAISE NOTICE 'fix_encoding_accents: Todos os acentos corrigidos com sucesso.';
ELSE
RAISE WARNING 'fix_encoding_accents: Ainda restam % registros com ?? em profiles.full_name', broken_count;
END IF;
END $$;

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