schema-per-tenant: F0 categorizacao + F1 template/helpers + F2 provisionamento
- docs/F0_categorizacao.md: varredura completa (137 tabelas -> 84 tenant + 53 global, 66 funcoes, FKs, policies, edge functions) + decisoes Q1-Q4 - F1 (migrations 01-05): tenants.slug, helpers de schema, _tenant_template (84 tabelas sem tenant_id, singletons, views __SCHEMA__/__TENANT_ID__), clone_tenant_template/drop_tenant_schema, channel_routing, tenant_schemas - F2 (migration 06): provision_account_tenant/create_clinic_tenant/ ensure_personal_tenant_for_user clonam schema na mesma transacao - db.cjs: psqlFile agora usa ON_ERROR_STOP=1 (falha de migration nao passa mais como sucesso silencioso) - blueprint original em novo-rumo.txt; wiki Obsidian atualizada Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1642,3 +1642,9 @@ PROXIMA SESSAO (retomar amanha 22/05):
|
||||
PUSH PENDENTE: 35 commits ahead of origin/main; SSL self-signed
|
||||
do Gitea exige `git -c http.sslVerify=false push origin main`
|
||||
+ credenciais (user faz manual).
|
||||
|
||||
## [2026-06-12 10:47] session | F0 schema-per-tenant: varredura e categorizacao
|
||||
Touched: Migracao Schema-per-Tenant, index
|
||||
|
||||
## [2026-06-12 11:49] session | F1 schema-per-tenant: template + helpers + clone
|
||||
Touched: Migracao Schema-per-Tenant
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Migração Schema-per-Tenant
|
||||
|
||||
**Status:** F2 concluída e smoke-testada (2026-06-12). Próximo: F3 (frontend useTenantDb).
|
||||
|
||||
## F2 — entregue (migration 20260612000006)
|
||||
Os 3 únicos pontos de criação de tenant (`provision_account_tenant`, `create_clinic_tenant`, `ensure_personal_tenant_for_user` — este último também acionado pelo trigger de signup `handle_new_user_create_personal_tenant`) agora chamam `clone_tenant_template()` na mesma transação: clone falhou → tenant não nasce. Smoke: ensure_personal criou tenant pessoal `tenant_terapeuta_pessoal` com 84 tabelas + registro, 2ª chamada idempotente, drop limpou tudo. Não há fluxo de exclusão de tenant no sistema (drop_tenant_schema fica pra uso admin/manual).
|
||||
|
||||
## F1 — entregue (migrations 20260612000001–05 em database-novo/migrations/)
|
||||
- `tenants.slug` criado + backfill dos 9 + trigger auto-gera/imutável
|
||||
- Helpers: `tenant_schema_name/for`, `tenant_id_for_schema`, `tenant_schema_checked(p_tenant_id)` (valida `is_tenant_member` — substitui current_tenant_schema do blueprint)
|
||||
- `_tenant_template`: 84 tabelas sem tenant_id, 6 singletons (`singleton boolean PK/UQ` nas configs 1-linha: company_profiles, email_layout_config, conversation_autoreply_settings/bots/sla_rules, session_reminder_settings), 4 sequences locais, 94 FKs (62 intra + 32 pra public/auth), 6 views com placeholders `__SCHEMA__`/`__TENANT_ID__` em `_views`, seeds de sistema (whitelist 8 lookups)
|
||||
- `clone_tenant_template(uuid)` → tabelas+seqs+seeds+FKs+views+RLS (policies com tenant_id EMBUTIDO: `is_tenant_member('<uuid>')` + saas_admin_full)+realtime+grants+trigger routing+registro em `tenant_schemas`
|
||||
- `drop_tenant_schema(uuid)` protegido; `public.channel_routing` (webhook inbound acha tenant do canal) sincronizada por trigger
|
||||
- Smoke: clone tenant_smoke_f1 → 84 tabelas/168 policies/roundtrip/routing sync/singleton rejeitando 2ª linha → drop limpo
|
||||
|
||||
### Gotchas aprendidos na F1
|
||||
- **`postgres` não é superuser no Supabase** → `session_replication_role` proibido; seeds usam retry-loop de FK (rounds). Vale pro F6 (migração de dados): rodar como `supabase_admin` ou retry-loop.
|
||||
- **db.cjs aplicava migration sem `ON_ERROR_STOP`** → rollback silencioso reportado como sucesso. Corrigido (psqlFile agora usa `-v ON_ERROR_STOP=1`).
|
||||
- Linhas operacionais órfãs com tenant_id NULL (intakes/convites/notifs) NÃO são seeds — whitelist explícita.
|
||||
- Clones F1/F2 ainda SEM triggers de negócio (F6) e fora do PostgREST (F5) — `_meta.triggers_pending=true`.
|
||||
|
||||
Migração de multi-tenant RLS-only (tenant_id em cada tabela) para schema físico por tenant (`tenant_<slug>`), seguindo blueprint do projeto irmão (`novo-rumo.txt` na raiz), adaptado.
|
||||
|
||||
## Artefatos
|
||||
- `docs/F0_categorizacao.md` — varredura completa: classificação das 137 tabelas, 66 funções, 6 views, FKs, edge functions, divergências.
|
||||
- `novo-rumo.txt` (raiz) — blueprint original com lições do projeto irmão.
|
||||
|
||||
## Números-chave
|
||||
- 137 tabelas public → 79 tenant-scoped + 5 em decisão (infra mensageria) + 53 globais
|
||||
- 66 funções afetadas (blueprint avisava: listas pré-feitas subestimam — era "29" lá, 66 aqui)
|
||||
- 1 única FK global→tenant problemática: `whatsapp_credits_transactions.conversation_message_id`
|
||||
- 0 policies de tabelas globais usando funções a refatorar
|
||||
- 9 tenants (3 clínicas + 6 therapists), volumetria minúscula (<400 linhas/tabela)
|
||||
|
||||
## Divergências vs blueprint (decisivas)
|
||||
1. **Sem `tenants.slug`** — precisa criar coluna ou usar uuid no nome do schema.
|
||||
2. **Multi-membership**: `profiles.tenant_id` 100% NULL; verdade vive em `tenant_members` (4 users multi-tenant). `current_tenant_schema()` do blueprint não funciona → frontend escolhe schema ([[tenantStore]] já tem `activeTenantId`), segurança via policy com tenant_id embutido por schema + RPCs recebem `p_tenant_id` validado com `is_tenant_member()`.
|
||||
3. **6/9 tenants são terapeutas individuais** — schema por signup; custo operacional do config.toml do PostgREST cresce com tenants.
|
||||
4. `email_layout_config.tenant_id` e `email_templates_tenant.tenant_id` apontam pra **auth.users** (legado) — mapear na migração de dados.
|
||||
5. View `current_tenant_id` é código morto (claim JWT nunca populado).
|
||||
|
||||
## Decisões (2026-06-12)
|
||||
- Q1: **criar `tenants.slug`** → schemas `tenant_<slug>`
|
||||
- Q2: **todo tenant ganha schema** (clínicas e therapists)
|
||||
- Q3: **mensageria tenant-scoped** (isolamento máximo, contra rec. global) → crons varrem tenants em loop; webhooks inbound precisam de índice global `channel_routing` (channel_external_id → tenant_id) pra rotear antes de gravar
|
||||
- Q4: **asaas tenant** (staging `asaas_webhook_events` global roteia)
|
||||
|
||||
Total final: **84 tabelas tenant-scoped, 53 globais.**
|
||||
|
||||
## Fases (tasks #1–#7 na sessão)
|
||||
F0 categorização ✅ · F1 template+helpers · F2 provisionamento · F3 frontend useTenantDb · F4 edge functions · F5 PostgREST config · F6 rewrite funções + migração dados + drops (lotes, backup antes de cada um)
|
||||
|
||||
Relacionados: [[Decisões de Billing da Agenda]], [[Supabase Local]], [[index]]
|
||||
@@ -30,3 +30,4 @@ _(synthesized answers to questions you've asked, filed back as pages)_
|
||||
---
|
||||
|
||||
*This index is maintained by Claude via `/wiki-brain`. Do not edit by hand unless you know what you're doing.*
|
||||
- [[Migracao Schema-per-Tenant]] — migração RLS-only → schema físico por tenant (F0 done, aguardando Q1-Q4)
|
||||
|
||||
@@ -89,7 +89,7 @@ function psqlFile(filePath) {
|
||||
const absPath = path.resolve(filePath);
|
||||
const content = fs.readFileSync(absPath, 'utf8');
|
||||
const utf8Content = "SET client_encoding TO 'UTF8';\n" + content;
|
||||
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} -q`;
|
||||
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} -q -v ON_ERROR_STOP=1`;
|
||||
return execSync(cmd, {
|
||||
input: utf8Content,
|
||||
encoding: 'utf8',
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
-- =============================================================================
|
||||
-- F1.1 — Schema-per-tenant: coluna tenants.slug
|
||||
-- Plano: novo-rumo.txt + docs/F0_categorizacao.md (decisão Q1)
|
||||
-- Slug é a base do nome do schema físico: tenant_<slug>. Unico e imutável.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE public.tenants ADD COLUMN IF NOT EXISTS slug text;
|
||||
|
||||
-- Geração de slug a partir do nome (sanitizado pra identificador Postgres)
|
||||
CREATE OR REPLACE FUNCTION public.generate_tenant_slug(p_name text)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
base text;
|
||||
cand text;
|
||||
n int := 1;
|
||||
BEGIN
|
||||
base := lower(coalesce(nullif(trim(p_name), ''), 'tenant'));
|
||||
base := translate(base,
|
||||
'áàâãäåéèêëíìîïóòôõöúùûüçñýÿ',
|
||||
'aaaaaaeeeeiiiiooooouuuucnyy');
|
||||
base := regexp_replace(base, '[^a-z0-9]+', '_', 'g');
|
||||
base := regexp_replace(base, '^_+|_+$', '', 'g');
|
||||
base := left(base, 48);
|
||||
IF base = '' OR base !~ '^[a-z]' THEN
|
||||
base := 't_' || base;
|
||||
base := left(base, 48);
|
||||
END IF;
|
||||
cand := base;
|
||||
WHILE EXISTS (SELECT 1 FROM public.tenants WHERE slug = cand) LOOP
|
||||
n := n + 1;
|
||||
cand := left(base, 44) || '_' || n;
|
||||
END LOOP;
|
||||
RETURN cand;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Backfill dos tenants existentes
|
||||
DO $$
|
||||
DECLARE r record;
|
||||
BEGIN
|
||||
FOR r IN SELECT id, name FROM public.tenants WHERE slug IS NULL ORDER BY created_at, id LOOP
|
||||
UPDATE public.tenants SET slug = public.generate_tenant_slug(r.name) WHERE id = r.id;
|
||||
RAISE NOTICE 'tenant % -> slug %', r.id, (SELECT slug FROM public.tenants WHERE id = r.id);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE public.tenants ALTER COLUMN slug SET NOT NULL;
|
||||
ALTER TABLE public.tenants ADD CONSTRAINT tenants_slug_key UNIQUE (slug);
|
||||
ALTER TABLE public.tenants ADD CONSTRAINT tenants_slug_format CHECK (slug ~ '^[a-z][a-z0-9_]{1,47}$');
|
||||
|
||||
-- Auto-gera no INSERT (provisionamento atual não conhece slug); imutável no UPDATE
|
||||
CREATE OR REPLACE FUNCTION public.trg_tenants_slug()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
IF NEW.slug IS NULL OR trim(NEW.slug) = '' THEN
|
||||
NEW.slug := public.generate_tenant_slug(NEW.name);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF NEW.slug IS DISTINCT FROM OLD.slug THEN
|
||||
RAISE EXCEPTION 'tenants.slug é imutável (tenant %, % -> %)', OLD.id, OLD.slug, NEW.slug;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_tenants_slug_ins ON public.tenants;
|
||||
CREATE TRIGGER trg_tenants_slug_ins BEFORE INSERT ON public.tenants
|
||||
FOR EACH ROW EXECUTE FUNCTION public.trg_tenants_slug();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_tenants_slug_upd ON public.tenants;
|
||||
CREATE TRIGGER trg_tenants_slug_upd BEFORE UPDATE OF slug ON public.tenants
|
||||
FOR EACH ROW EXECUTE FUNCTION public.trg_tenants_slug();
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,73 @@
|
||||
-- =============================================================================
|
||||
-- F1.2 — Schema-per-tenant: helpers de resolução de schema
|
||||
-- Adaptação ao modelo multi-membership deste projeto (docs/F0_categorizacao.md D2):
|
||||
-- profiles.tenant_id é NULL; membership vive em tenant_members (multi-tenant).
|
||||
-- Logo NÃO existe current_tenant_schema() — RPCs recebem p_tenant_id explícito
|
||||
-- e validam via tenant_schema_checked(p_tenant_id).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- slug -> nome de schema (validado). Retorna NULL se slug inválido.
|
||||
CREATE OR REPLACE FUNCTION public.tenant_schema_name(p_slug text)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
SELECT CASE
|
||||
WHEN p_slug ~ '^[a-z][a-z0-9_]{1,47}$' THEN 'tenant_' || p_slug
|
||||
ELSE NULL
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- tenant_id -> nome de schema
|
||||
CREATE OR REPLACE FUNCTION public.tenant_schema_for(p_tenant_id uuid)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
SELECT public.tenant_schema_name(t.slug) FROM public.tenants t WHERE t.id = p_tenant_id;
|
||||
$$;
|
||||
|
||||
-- nome de schema -> tenant_id (CRÍTICO pra triggers: a coluna tenant_id não
|
||||
-- existe mais nas tabelas tenant; o schema é a identidade)
|
||||
CREATE OR REPLACE FUNCTION public.tenant_id_for_schema(p_schema text)
|
||||
RETURNS uuid
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
SELECT t.id FROM public.tenants t WHERE public.tenant_schema_name(t.slug) = p_schema;
|
||||
$$;
|
||||
|
||||
-- Resolve schema de um tenant COM validação de acesso do usuário logado.
|
||||
-- Substitui o current_tenant_schema() do blueprint (que assumia 1 tenant/usuário).
|
||||
CREATE OR REPLACE FUNCTION public.tenant_schema_checked(p_tenant_id uuid)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_schema text;
|
||||
BEGIN
|
||||
IF p_tenant_id IS NULL THEN
|
||||
RAISE EXCEPTION 'tenant_schema_checked: p_tenant_id obrigatório';
|
||||
END IF;
|
||||
IF NOT public.is_tenant_member(p_tenant_id) AND NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'acesso negado ao tenant %', p_tenant_id
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||
IF v_schema IS NULL THEN
|
||||
RAISE EXCEPTION 'tenant % não encontrado ou slug inválido', p_tenant_id;
|
||||
END IF;
|
||||
RETURN v_schema;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,513 @@
|
||||
-- =============================================================================
|
||||
-- F1.3 — Schema-per-tenant: construção do schema _tenant_template
|
||||
--
|
||||
-- Clona a ESTRUTURA das 84 tabelas tenant-scoped (docs/F0_categorizacao.md §1)
|
||||
-- a partir de public, SEM a coluna tenant_id:
|
||||
-- * PK/UNIQUE compostos perdem tenant_id; PK/UNIQUE que eram SÓ (tenant_id)
|
||||
-- viram coluna `singleton boolean` (tabela de config 1-linha-por-tenant)
|
||||
-- * índices parciais WHERE tenant_id IS [NOT] NULL são fundidos/deduplicados
|
||||
-- * sequences bigserial são localizadas no template (não compartilham public)
|
||||
-- * FKs locais apontam pro template; FKs pra tabelas globais ficam em public/auth
|
||||
-- * linhas-default do sistema (tenant_id IS NULL) viram SEED do template e
|
||||
-- são copiadas pra cada tenant no clone
|
||||
-- * 6 views adaptadas ficam registradas em _tenant_template._views com
|
||||
-- placeholders __SCHEMA__ / __TENANT_ID__ (instanciadas no clone)
|
||||
--
|
||||
-- O template NUNCA é exposto no PostgREST nem recebe dados de tenant.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
DROP SCHEMA IF EXISTS _tenant_template CASCADE;
|
||||
CREATE SCHEMA _tenant_template;
|
||||
|
||||
-- Helper interno: remove tenant_id de uma definição de índice e simplifica
|
||||
-- predicados parciais que testavam tenant_id.
|
||||
CREATE FUNCTION _tenant_template._adapt_indexdef(p_def text)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
DECLARE d text := p_def;
|
||||
BEGIN
|
||||
-- coluna no início/meio/fim da lista
|
||||
d := regexp_replace(d, '\(tenant_id,\s*', '(', 'g');
|
||||
d := regexp_replace(d, ',\s*tenant_id\)', ')', 'g');
|
||||
d := regexp_replace(d, ',\s*tenant_id,', ',', 'g');
|
||||
-- predicados parciais
|
||||
d := replace(d, '(tenant_id IS NOT NULL) AND ', '');
|
||||
d := replace(d, ' AND (tenant_id IS NOT NULL)', '');
|
||||
d := replace(d, ' WHERE ((tenant_id IS NOT NULL))', '');
|
||||
d := replace(d, ' WHERE (tenant_id IS NOT NULL)', '');
|
||||
d := replace(d, '(tenant_id IS NULL) AND ', '');
|
||||
d := replace(d, ' AND (tenant_id IS NULL)', '');
|
||||
d := replace(d, ' WHERE ((tenant_id IS NULL))', '');
|
||||
d := replace(d, ' WHERE (tenant_id IS NULL)', '');
|
||||
RETURN d;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
tabs text[] := ARRAY[
|
||||
'agenda_bloqueios','agenda_configuracoes','agenda_eventos','agenda_online_slots',
|
||||
'agenda_regras_semanais','agenda_slots_bloqueados_semanais','agenda_slots_regras',
|
||||
'agendador_configuracoes','agendador_solicitacoes',
|
||||
'asaas_customers','asaas_payments',
|
||||
'billing_contracts',
|
||||
'clinical_note_templates','clinical_note_versions','clinical_notes',
|
||||
'commitment_services','commitment_time_logs',
|
||||
'company_profiles',
|
||||
'contact_email_types','contact_emails','contact_phones','contact_types',
|
||||
'conversation_assignments','conversation_autoreply_log','conversation_autoreply_settings',
|
||||
'conversation_bot_sessions','conversation_bots','conversation_messages','conversation_notes',
|
||||
'conversation_optout_keywords','conversation_optouts','conversation_sla_breaches',
|
||||
'conversation_sla_rules','conversation_tags','conversation_thread_tags',
|
||||
'determined_commitment_fields','determined_commitments',
|
||||
'document_access_logs','document_generated','document_share_links','document_signatures',
|
||||
'document_templates','documents',
|
||||
'email_layout_config','email_templates_tenant',
|
||||
'feriados',
|
||||
'financial_categories','financial_exceptions','financial_records',
|
||||
'insurance_plan_services','insurance_plans',
|
||||
'medicos',
|
||||
'notification_channels','notification_logs','notification_preferences','notification_queue',
|
||||
'notification_schedules','notification_templates','notifications',
|
||||
'patient_contacts','patient_discounts','patient_group_patient','patient_groups',
|
||||
'patient_intake_requests','patient_invite_attempts','patient_invites','patient_patient_tag',
|
||||
'patient_status_history','patient_support_contacts','patient_tags','patient_timeline','patients',
|
||||
'payment_settings','professional_pricing',
|
||||
'recurrence_exceptions','recurrence_rule_services','recurrence_rules',
|
||||
'services',
|
||||
'session_reminder_logs','session_reminder_settings',
|
||||
'therapist_payout_records','therapist_payouts',
|
||||
'twilio_subaccount_usage','whatsapp_connection_incidents'
|
||||
];
|
||||
t text;
|
||||
r record;
|
||||
r2 record;
|
||||
v_def text;
|
||||
v_sig text;
|
||||
v_seq text;
|
||||
v_cols text;
|
||||
v_remaining text;
|
||||
v_n int;
|
||||
seen_sigs text[];
|
||||
pending text[] := ARRAY[]::text[];
|
||||
failed text[];
|
||||
BEGIN
|
||||
PERFORM pg_catalog.set_config('search_path', 'pg_catalog', true);
|
||||
|
||||
IF array_length(tabs, 1) <> 84 THEN
|
||||
RAISE EXCEPTION 'lista de tabelas tenant deveria ter 84, tem %', array_length(tabs, 1);
|
||||
END IF;
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
-- PASS 1: clonar estrutura
|
||||
---------------------------------------------------------------------------
|
||||
FOREACH t IN ARRAY tabs LOOP
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = t AND table_type = 'BASE TABLE') THEN
|
||||
RAISE EXCEPTION 'tabela public.% não existe — lista F0 desatualizada', t;
|
||||
END IF;
|
||||
EXECUTE format('CREATE TABLE _tenant_template.%I (LIKE public.%I INCLUDING ALL)', t, t);
|
||||
END LOOP;
|
||||
RAISE NOTICE 'PASS 1 ok: % tabelas clonadas', array_length(tabs, 1);
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
-- PASS 2: localizar sequences (defaults nextval apontando pra public)
|
||||
---------------------------------------------------------------------------
|
||||
FOR r IN
|
||||
SELECT c.relname AS tab, a.attname AS col,
|
||||
pg_get_expr(d.adbin, d.adrelid) AS def
|
||||
FROM pg_attrdef d
|
||||
JOIN pg_class c ON c.oid = d.adrelid
|
||||
JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum
|
||||
WHERE c.relnamespace = '_tenant_template'::regnamespace
|
||||
AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval(''public.%'
|
||||
LOOP
|
||||
v_seq := r.tab || '_' || r.col || '_seq';
|
||||
EXECUTE format('CREATE SEQUENCE _tenant_template.%I', v_seq);
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I ALTER COLUMN %I SET DEFAULT nextval(%L::regclass)',
|
||||
r.tab, r.col, '_tenant_template.' || v_seq);
|
||||
EXECUTE format('ALTER SEQUENCE _tenant_template.%I OWNED BY _tenant_template.%I.%I',
|
||||
v_seq, r.tab, r.col);
|
||||
RAISE NOTICE 'PASS 2: sequence local _tenant_template.% (%.%)', v_seq, r.tab, r.col;
|
||||
END LOOP;
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
-- PASS 3: drop tenant_id + recriar constraints/índices sem a coluna
|
||||
---------------------------------------------------------------------------
|
||||
FOREACH t IN ARRAY tabs LOOP
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_attribute
|
||||
WHERE attrelid = ('_tenant_template.' || quote_ident(t))::regclass
|
||||
AND attname = 'tenant_id' AND NOT attisdropped) THEN
|
||||
CONTINUE; -- joins/children sem tenant_id (commitment_services etc.)
|
||||
END IF;
|
||||
|
||||
-- 3a. capturar PK/UNIQUE que contêm tenant_id (no template)
|
||||
CREATE TEMP TABLE IF NOT EXISTS _f1_cons (tab text, conname text, contype char, remaining text) ON COMMIT DROP;
|
||||
DELETE FROM _f1_cons WHERE tab = t;
|
||||
INSERT INTO _f1_cons
|
||||
SELECT t, con.conname, con.contype,
|
||||
(SELECT string_agg(quote_ident(a.attname), ', ' ORDER BY k.ord)
|
||||
FROM unnest(con.conkey) WITH ORDINALITY k(attnum, ord)
|
||||
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum
|
||||
WHERE a.attname <> 'tenant_id')
|
||||
FROM pg_constraint con
|
||||
WHERE con.conrelid = ('_tenant_template.' || quote_ident(t))::regclass
|
||||
AND con.contype IN ('p', 'u')
|
||||
AND EXISTS (SELECT 1 FROM unnest(con.conkey) k
|
||||
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k
|
||||
WHERE a.attname = 'tenant_id');
|
||||
|
||||
-- 3b. capturar índices "soltos" (não-constraint) que usam tenant_id
|
||||
CREATE TEMP TABLE IF NOT EXISTS _f1_idx (tab text, idxname text, def text) ON COMMIT DROP;
|
||||
DELETE FROM _f1_idx WHERE tab = t;
|
||||
INSERT INTO _f1_idx
|
||||
SELECT t, c2.relname, pg_get_indexdef(i.indexrelid)
|
||||
FROM pg_index i
|
||||
JOIN pg_class c2 ON c2.oid = i.indexrelid
|
||||
WHERE i.indrelid = ('_tenant_template.' || quote_ident(t))::regclass
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_constraint cc WHERE cc.conindid = i.indexrelid)
|
||||
AND pg_get_indexdef(i.indexrelid) ~ '\mtenant_id\M';
|
||||
|
||||
-- 3c. drop da coluna (leva junto constraints/índices que a usam)
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I DROP COLUMN tenant_id CASCADE', t);
|
||||
|
||||
-- 3d. recriar PK/UNIQUE
|
||||
FOR r IN SELECT * FROM _f1_cons WHERE tab = t LOOP
|
||||
IF r.remaining IS NULL OR r.remaining = '' THEN
|
||||
-- era PK/UNIQUE exatamente (tenant_id): tabela 1-linha-por-tenant
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I ADD COLUMN singleton boolean NOT NULL DEFAULT true', t);
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I CHECK (singleton = true)',
|
||||
t, t || '_singleton_chk');
|
||||
IF r.contype = 'p' THEN
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I PRIMARY KEY (singleton)',
|
||||
t, r.conname);
|
||||
ELSE
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I UNIQUE (singleton)',
|
||||
t, r.conname);
|
||||
END IF;
|
||||
RAISE NOTICE 'PASS 3: %.% era (tenant_id) -> singleton (%)', t, r.conname,
|
||||
CASE r.contype WHEN 'p' THEN 'PK' ELSE 'UNIQUE' END;
|
||||
ELSE
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I %s (%s)',
|
||||
t, r.conname,
|
||||
CASE r.contype WHEN 'p' THEN 'PRIMARY KEY' ELSE 'UNIQUE' END,
|
||||
r.remaining);
|
||||
RAISE NOTICE 'PASS 3: %.% recriado sem tenant_id -> (%)', t, r.conname, r.remaining;
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
-- 3e. recriar índices soltos transformados (com dedupe)
|
||||
seen_sigs := ARRAY[]::text[];
|
||||
-- assinaturas dos índices que já existem na tabela (pós-recriação de constraints)
|
||||
FOR r2 IN
|
||||
SELECT regexp_replace(pg_get_indexdef(i.indexrelid),
|
||||
'^CREATE (UNIQUE )?INDEX [^ ]+ ON [^ ]+ ', '') AS sig
|
||||
FROM pg_index i
|
||||
WHERE i.indrelid = ('_tenant_template.' || quote_ident(t))::regclass
|
||||
LOOP
|
||||
seen_sigs := seen_sigs || r2.sig;
|
||||
END LOOP;
|
||||
|
||||
FOR r IN SELECT * FROM _f1_idx WHERE tab = t LOOP
|
||||
-- índice cuja ÚNICA coluna era tenant_id: descartar
|
||||
IF r.def ~ '\(tenant_id\)( WHERE .*)?$' THEN
|
||||
RAISE NOTICE 'PASS 3: índice % descartado (era só tenant_id)', r.idxname;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
v_def := _tenant_template._adapt_indexdef(r.def);
|
||||
v_sig := regexp_replace(v_def, '^CREATE (UNIQUE )?INDEX [^ ]+ ON [^ ]+ ', '');
|
||||
IF v_sig = ANY (seen_sigs) THEN
|
||||
RAISE NOTICE 'PASS 3: índice % deduplicado', r.idxname;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
EXECUTE v_def;
|
||||
seen_sigs := seen_sigs || v_sig;
|
||||
RAISE NOTICE 'PASS 3: índice % recriado: %', r.idxname, v_sig;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
-- PASS 4: FKs (a partir das FKs reais de public)
|
||||
---------------------------------------------------------------------------
|
||||
FOR r IN
|
||||
SELECT con.conname,
|
||||
cl.relname AS tab,
|
||||
ns2.nspname AS fschema,
|
||||
cl2.relname AS ftab,
|
||||
pg_get_constraintdef(con.oid) AS def,
|
||||
EXISTS (SELECT 1 FROM unnest(con.conkey) k
|
||||
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k
|
||||
WHERE a.attname = 'tenant_id') AS uses_tenant_id
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class cl ON cl.oid = con.conrelid
|
||||
JOIN pg_class cl2 ON cl2.oid = con.confrelid
|
||||
JOIN pg_namespace ns2 ON ns2.oid = cl2.relnamespace
|
||||
WHERE con.contype = 'f'
|
||||
AND cl.relnamespace = 'public'::regnamespace
|
||||
AND cl.relname = ANY (tabs)
|
||||
ORDER BY cl.relname, con.conname
|
||||
LOOP
|
||||
IF r.uses_tenant_id THEN
|
||||
RAISE NOTICE 'PASS 4: FK %.% descartada (coluna tenant_id removida)', r.tab, r.conname;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
v_def := r.def;
|
||||
IF r.fschema = 'public' AND r.ftab = ANY (tabs) THEN
|
||||
-- alvo também é tenant-scoped -> referência intra-template
|
||||
v_def := regexp_replace(v_def,
|
||||
' REFERENCES (public\.)?' || r.ftab || '\(',
|
||||
' REFERENCES _tenant_template.' || r.ftab || '(');
|
||||
END IF;
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I %s', r.tab, r.conname, v_def);
|
||||
END LOOP;
|
||||
RAISE NOTICE 'PASS 4 ok: FKs recriadas';
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
-- PASS 5: seeds — linhas-default do sistema (tenant_id IS NULL em public)
|
||||
-- APENAS tabelas de lookup/template (whitelist): linhas operacionais órfãs
|
||||
-- com tenant_id NULL (intakes, convites, notifs) NÃO são defaults.
|
||||
-- Sem session_replication_role (postgres não é superuser no Supabase):
|
||||
-- resolve ordem de FK por tentativa-e-repetição em rounds.
|
||||
---------------------------------------------------------------------------
|
||||
FOREACH t IN ARRAY ARRAY[
|
||||
'clinical_note_templates','contact_email_types','contact_types',
|
||||
'conversation_optout_keywords','conversation_tags','document_templates',
|
||||
'notification_templates','feriados'
|
||||
] LOOP
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = t AND column_name = 'tenant_id') THEN
|
||||
CONTINUE;
|
||||
END IF;
|
||||
EXECUTE format('SELECT count(*) FROM public.%I WHERE tenant_id IS NULL', t) INTO v_n;
|
||||
IF v_n = 0 THEN CONTINUE; END IF;
|
||||
pending := pending || t;
|
||||
END LOOP;
|
||||
|
||||
WHILE coalesce(array_length(pending, 1), 0) > 0 LOOP
|
||||
failed := ARRAY[]::text[];
|
||||
FOREACH t IN ARRAY pending LOOP
|
||||
SELECT string_agg(quote_ident(c.column_name), ', ' ORDER BY c.ordinal_position)
|
||||
INTO v_cols
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_schema = '_tenant_template' AND c.table_name = t
|
||||
AND c.column_name <> 'singleton'
|
||||
AND EXISTS (SELECT 1 FROM information_schema.columns p
|
||||
WHERE p.table_schema = 'public' AND p.table_name = t
|
||||
AND p.column_name = c.column_name);
|
||||
BEGIN
|
||||
EXECUTE format('INSERT INTO _tenant_template.%I (%s) SELECT %s FROM public.%I WHERE tenant_id IS NULL',
|
||||
t, v_cols, v_cols, t);
|
||||
RAISE NOTICE 'PASS 5: linhas-default semeadas em _tenant_template.%', t;
|
||||
EXCEPTION WHEN foreign_key_violation THEN
|
||||
failed := failed || t;
|
||||
END;
|
||||
END LOOP;
|
||||
IF array_length(failed, 1) = array_length(pending, 1) THEN
|
||||
RAISE EXCEPTION 'PASS 5: dependência circular/externa nos seeds: %', failed;
|
||||
END IF;
|
||||
pending := failed;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- Metadados do template
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE _tenant_template._meta (key text PRIMARY KEY, value jsonb NOT NULL);
|
||||
INSERT INTO _tenant_template._meta VALUES
|
||||
('template_version', '1'::jsonb),
|
||||
('built_from', '"docs/F0_categorizacao.md"'::jsonb),
|
||||
('triggers_pending', 'true'::jsonb); -- triggers de negócio só na F6
|
||||
|
||||
-- Tabelas que entram na publication supabase_realtime a cada clone
|
||||
-- (espelha o estado atual da publication em public: conversation_messages, notifications)
|
||||
CREATE TABLE _tenant_template._realtime_tables (table_name text PRIMARY KEY);
|
||||
INSERT INTO _tenant_template._realtime_tables
|
||||
SELECT tablename FROM pg_publication_tables
|
||||
WHERE pubname = 'supabase_realtime' AND schemaname = 'public'
|
||||
AND tablename IN (
|
||||
'agenda_bloqueios','agenda_configuracoes','agenda_eventos','agenda_online_slots',
|
||||
'agenda_regras_semanais','agenda_slots_bloqueados_semanais','agenda_slots_regras',
|
||||
'agendador_configuracoes','agendador_solicitacoes','asaas_customers','asaas_payments',
|
||||
'billing_contracts','clinical_note_templates','clinical_note_versions','clinical_notes',
|
||||
'commitment_services','commitment_time_logs','company_profiles','contact_email_types',
|
||||
'contact_emails','contact_phones','contact_types','conversation_assignments',
|
||||
'conversation_autoreply_log','conversation_autoreply_settings','conversation_bot_sessions',
|
||||
'conversation_bots','conversation_messages','conversation_notes','conversation_optout_keywords',
|
||||
'conversation_optouts','conversation_sla_breaches','conversation_sla_rules','conversation_tags',
|
||||
'conversation_thread_tags','determined_commitment_fields','determined_commitments',
|
||||
'document_access_logs','document_generated','document_share_links','document_signatures',
|
||||
'document_templates','documents','email_layout_config','email_templates_tenant','feriados',
|
||||
'financial_categories','financial_exceptions','financial_records','insurance_plan_services',
|
||||
'insurance_plans','medicos','notification_channels','notification_logs','notification_preferences',
|
||||
'notification_queue','notification_schedules','notification_templates','notifications',
|
||||
'patient_contacts','patient_discounts','patient_group_patient','patient_groups',
|
||||
'patient_intake_requests','patient_invite_attempts','patient_invites','patient_patient_tag',
|
||||
'patient_status_history','patient_support_contacts','patient_tags','patient_timeline','patients',
|
||||
'payment_settings','professional_pricing','recurrence_exceptions','recurrence_rule_services',
|
||||
'recurrence_rules','services','session_reminder_logs','session_reminder_settings',
|
||||
'therapist_payout_records','therapist_payouts','twilio_subaccount_usage','whatsapp_connection_incidents'
|
||||
);
|
||||
|
||||
-- Views adaptadas (instanciadas pelo clone com __SCHEMA__ / __TENANT_ID__)
|
||||
CREATE TABLE _tenant_template._views (
|
||||
view_name text PRIMARY KEY,
|
||||
position int NOT NULL,
|
||||
definition text NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO _tenant_template._views VALUES
|
||||
('conversation_threads', 1, $vw$
|
||||
CREATE VIEW __SCHEMA__.conversation_threads WITH (security_invoker = true) AS
|
||||
WITH base AS (
|
||||
SELECT cm.id, cm.patient_id, cm.channel, cm.body, cm.direction,
|
||||
cm.kanban_status, cm.read_at, cm.created_at,
|
||||
CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END AS contact_number,
|
||||
COALESCE(cm.patient_id::text,
|
||||
'anon:' || COALESCE(CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END,
|
||||
'unknown')) AS thread_key
|
||||
FROM __SCHEMA__.conversation_messages cm
|
||||
), latest AS (
|
||||
SELECT DISTINCT ON (base.thread_key)
|
||||
base.thread_key, base.patient_id, base.channel, base.contact_number,
|
||||
base.body AS last_message_body, base.direction AS last_message_direction,
|
||||
base.kanban_status, base.created_at AS last_message_at
|
||||
FROM base
|
||||
ORDER BY base.thread_key, base.created_at DESC
|
||||
), counts AS (
|
||||
SELECT base.thread_key, count(*) AS message_count,
|
||||
count(*) FILTER (WHERE base.direction = 'inbound' AND base.read_at IS NULL) AS unread_count
|
||||
FROM base
|
||||
GROUP BY base.thread_key
|
||||
)
|
||||
SELECT '__TENANT_ID__'::uuid AS tenant_id,
|
||||
l.thread_key, l.patient_id, p.nome_completo AS patient_name,
|
||||
l.contact_number, l.channel, c.message_count, c.unread_count,
|
||||
l.last_message_at, l.last_message_body, l.last_message_direction,
|
||||
l.kanban_status, ca.assigned_to, ca.assigned_at
|
||||
FROM latest l
|
||||
JOIN counts c ON c.thread_key = l.thread_key
|
||||
LEFT JOIN __SCHEMA__.patients p ON p.id = l.patient_id
|
||||
LEFT JOIN __SCHEMA__.conversation_assignments ca ON ca.thread_key = l.thread_key
|
||||
$vw$),
|
||||
('audit_log_unified', 2, $vw$
|
||||
CREATE VIEW __SCHEMA__.audit_log_unified WITH (security_invoker = true) AS
|
||||
SELECT 'audit:' || al.id::text AS uid, al.tenant_id, al.user_id, al.entity_type, al.entity_id, al.action,
|
||||
CASE al.action
|
||||
WHEN 'insert' THEN 'Criou ' || al.entity_type
|
||||
WHEN 'update' THEN ('Alterou ' || al.entity_type) || COALESCE((' (' || array_to_string(al.changed_fields, ', ')) || ')', '')
|
||||
WHEN 'delete' THEN 'Excluiu ' || al.entity_type
|
||||
END AS description,
|
||||
al.created_at AS occurred_at, 'audit_logs' AS source,
|
||||
jsonb_build_object('old_values', al.old_values, 'new_values', al.new_values, 'changed_fields', al.changed_fields) AS details
|
||||
FROM public.audit_logs al
|
||||
WHERE al.tenant_id = '__TENANT_ID__'::uuid
|
||||
UNION ALL
|
||||
SELECT 'doc_access:' || dal.id::text, '__TENANT_ID__'::uuid, dal.user_id, 'document', dal.documento_id::text, dal.acao,
|
||||
CASE dal.acao
|
||||
WHEN 'visualizou' THEN 'Visualizou documento'
|
||||
WHEN 'baixou' THEN 'Baixou documento'
|
||||
WHEN 'imprimiu' THEN 'Imprimiu documento'
|
||||
WHEN 'compartilhou' THEN 'Compartilhou documento'
|
||||
WHEN 'assinou' THEN 'Assinou documento'
|
||||
ELSE dal.acao
|
||||
END,
|
||||
dal.acessado_em, 'document_access_logs',
|
||||
jsonb_build_object('ip', dal.ip::text, 'user_agent', dal.user_agent)
|
||||
FROM __SCHEMA__.document_access_logs dal
|
||||
UNION ALL
|
||||
SELECT 'psh:' || psh.id::text, '__TENANT_ID__'::uuid, psh.alterado_por, 'patient_status', psh.patient_id::text, 'status_change',
|
||||
((('Status do paciente: ' || COALESCE(psh.status_anterior, '—')) || ' → ') || psh.status_novo) || COALESCE((' (' || psh.motivo) || ')', ''),
|
||||
psh.alterado_em, 'patient_status_history',
|
||||
jsonb_build_object('status_anterior', psh.status_anterior, 'status_novo', psh.status_novo, 'motivo', psh.motivo,
|
||||
'encaminhado_para', psh.encaminhado_para, 'data_saida', psh.data_saida)
|
||||
FROM __SCHEMA__.patient_status_history psh
|
||||
UNION ALL
|
||||
SELECT 'notif:' || nl.id::text, '__TENANT_ID__'::uuid, nl.owner_id, 'notification', nl.patient_id::text, nl.status,
|
||||
((('Notificação ' || nl.channel) || ' ') || nl.status) || COALESCE(' para ' || nl.recipient_address, ''),
|
||||
nl.created_at, 'notification_logs',
|
||||
jsonb_build_object('channel', nl.channel, 'template_key', nl.template_key, 'status', nl.status,
|
||||
'provider', nl.provider, 'failure_reason', nl.failure_reason)
|
||||
FROM __SCHEMA__.notification_logs nl
|
||||
UNION ALL
|
||||
SELECT 'addon:' || at.id::text, at.tenant_id, at.admin_user_id, 'addon_transaction', at.id::text, at.type,
|
||||
CASE at.type
|
||||
WHEN 'purchase' THEN (('Compra de ' || at.amount) || ' créditos de ') || at.addon_type
|
||||
WHEN 'consumption' THEN (('Consumo de ' || abs(at.amount)) || ' crédito(s) ') || at.addon_type
|
||||
WHEN 'adjustment' THEN 'Ajuste de créditos ' || at.addon_type
|
||||
WHEN 'refund' THEN (('Reembolso de ' || abs(at.amount)) || ' créditos ') || at.addon_type
|
||||
ELSE (at.type || ' ') || at.addon_type
|
||||
END,
|
||||
at.created_at, 'addon_transactions',
|
||||
jsonb_build_object('addon_type', at.addon_type, 'amount', at.amount, 'balance_after', at.balance_after,
|
||||
'price_cents', at.price_cents, 'payment_reference', at.payment_reference)
|
||||
FROM public.addon_transactions at
|
||||
WHERE at.tenant_id = '__TENANT_ID__'::uuid
|
||||
$vw$),
|
||||
('v_cashflow_projection', 3, $vw$
|
||||
CREATE VIEW __SCHEMA__.v_cashflow_projection WITH (security_invoker = true) AS
|
||||
SELECT gs.mes,
|
||||
to_char(gs.mes, 'YYYY-MM') AS mes_label,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'receita'::financial_record_type AND (fr.status = ANY (ARRAY['pending', 'overdue']))), 0) AS receitas_projetadas,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'despesa'::financial_record_type AND (fr.status = ANY (ARRAY['pending', 'overdue']))), 0) AS despesas_projetadas,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'receita'::financial_record_type AND fr.status = 'pending'), 0) AS receitas_pendentes,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'receita'::financial_record_type AND fr.status = 'overdue'), 0) AS receitas_vencidas,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'despesa'::financial_record_type AND fr.status = 'pending'), 0) AS despesas_pendentes,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'despesa'::financial_record_type AND fr.status = 'overdue'), 0) AS despesas_vencidas,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'receita'::financial_record_type AND (fr.status = ANY (ARRAY['pending', 'overdue']))), 0)
|
||||
- COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'despesa'::financial_record_type AND (fr.status = ANY (ARRAY['pending', 'overdue']))), 0) AS saldo_projetado,
|
||||
count(fr.id) FILTER (WHERE fr.status = ANY (ARRAY['pending', 'overdue'])) AS count_registros
|
||||
FROM generate_series(date_trunc('month', CURRENT_DATE::timestamp with time zone)::date::timestamp with time zone,
|
||||
(date_trunc('month', CURRENT_DATE::timestamp with time zone) + '5 mons'::interval)::date::timestamp with time zone,
|
||||
'1 mon'::interval) gs(mes)
|
||||
LEFT JOIN __SCHEMA__.financial_records fr
|
||||
ON fr.deleted_at IS NULL
|
||||
AND (fr.status = ANY (ARRAY['pending', 'overdue']))
|
||||
AND date_trunc('month', fr.due_date::timestamp with time zone)::date = gs.mes
|
||||
GROUP BY gs.mes
|
||||
ORDER BY gs.mes
|
||||
$vw$),
|
||||
('v_commitment_totals', 4, $vw$
|
||||
CREATE VIEW __SCHEMA__.v_commitment_totals WITH (security_invoker = true) AS
|
||||
SELECT '__TENANT_ID__'::uuid AS tenant_id,
|
||||
c.id AS commitment_id,
|
||||
COALESCE(sum(l.minutes), 0)::integer AS total_minutes
|
||||
FROM __SCHEMA__.determined_commitments c
|
||||
LEFT JOIN __SCHEMA__.commitment_time_logs l ON l.commitment_id = c.id
|
||||
GROUP BY c.id
|
||||
$vw$),
|
||||
('v_patient_groups_with_counts', 5, $vw$
|
||||
CREATE VIEW __SCHEMA__.v_patient_groups_with_counts WITH (security_invoker = true) AS
|
||||
SELECT pg.id, pg.nome, pg.cor, pg.owner_id, pg.is_system, pg.is_active, pg.created_at, pg.updated_at,
|
||||
COALESCE(count(pgp.patient_id), 0)::integer AS patients_count
|
||||
FROM __SCHEMA__.patient_groups pg
|
||||
LEFT JOIN __SCHEMA__.patient_group_patient pgp ON pgp.patient_group_id = pg.id
|
||||
GROUP BY pg.id, pg.nome, pg.cor, pg.owner_id, pg.is_system, pg.is_active, pg.created_at, pg.updated_at
|
||||
$vw$),
|
||||
('v_tag_patient_counts', 6, $vw$
|
||||
CREATE VIEW __SCHEMA__.v_tag_patient_counts WITH (security_invoker = true) AS
|
||||
SELECT t.id, t.owner_id, t.nome, t.cor, t.is_padrao, t.created_at, t.updated_at,
|
||||
COALESCE(count(ppt.patient_id), 0)::integer AS pacientes_count,
|
||||
COALESCE(count(ppt.patient_id), 0)::integer AS patient_count
|
||||
FROM __SCHEMA__.patient_tags t
|
||||
LEFT JOIN __SCHEMA__.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.nome, t.cor, t.is_padrao, t.created_at, t.updated_at
|
||||
$vw$);
|
||||
|
||||
-- Valida as views instanciando no próprio template (tenant nulo)
|
||||
DO $$
|
||||
DECLARE r record;
|
||||
BEGIN
|
||||
PERFORM pg_catalog.set_config('search_path', 'public, pg_catalog', true);
|
||||
FOR r IN SELECT * FROM _tenant_template._views ORDER BY position LOOP
|
||||
EXECUTE replace(replace(r.definition, '__SCHEMA__', '_tenant_template'),
|
||||
'__TENANT_ID__', '00000000-0000-0000-0000-000000000000');
|
||||
RAISE NOTICE 'view _tenant_template.% validada', r.view_name;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,301 @@
|
||||
-- =============================================================================
|
||||
-- F1.4 — Schema-per-tenant: clone/drop + registro + roteamento de canais
|
||||
--
|
||||
-- * public.tenant_schemas — registro dos schemas provisionados (alimenta o
|
||||
-- gerador do config.toml na F5)
|
||||
-- * public.channel_routing — índice global de roteamento: webhooks inbound
|
||||
-- (Twilio/Evolution) precisam descobrir o tenant do canal ANTES de saber o
|
||||
-- schema (decisão Q3: notification_channels mora no schema do tenant).
|
||||
-- Mantido por trigger em cada tenant_<slug>.notification_channels.
|
||||
-- * clone_tenant_template(tenant_id) — instancia tenant_<slug> a partir do
|
||||
-- _tenant_template: tabelas + sequences locais + FKs + seeds + views + RLS
|
||||
-- (policies com tenant_id EMBUTIDO — modelo multi-membership) + realtime +
|
||||
-- grants + trigger de roteamento.
|
||||
-- * drop_tenant_schema(tenant_id) — protegido (assert tenant_%).
|
||||
--
|
||||
-- NOTA: clones criados na F1/F2 ainda NÃO têm triggers de negócio (F6) e não
|
||||
-- estão expostos no PostgREST (F5). _meta.triggers_pending registra isso.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Registro de schemas provisionados
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.tenant_schemas (
|
||||
tenant_id uuid PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
|
||||
schema_name text NOT NULL UNIQUE,
|
||||
template_version int NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.tenant_schemas ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_schemas_select ON public.tenant_schemas;
|
||||
CREATE POLICY tenant_schemas_select ON public.tenant_schemas
|
||||
FOR SELECT TO authenticated
|
||||
USING (public.is_tenant_member(tenant_id) OR public.is_saas_admin());
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Índice global de roteamento de canais (webhook inbound -> tenant)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.channel_routing (
|
||||
channel_id uuid PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
||||
channel text NOT NULL,
|
||||
provider text,
|
||||
sender_address text,
|
||||
twilio_subaccount_sid text,
|
||||
twilio_phone_number text,
|
||||
metadata jsonb,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS channel_routing_tenant_idx ON public.channel_routing (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS channel_routing_sender_idx ON public.channel_routing (sender_address) WHERE sender_address IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS channel_routing_twilio_phone_idx ON public.channel_routing (twilio_phone_number) WHERE twilio_phone_number IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS channel_routing_twilio_sid_idx ON public.channel_routing (twilio_subaccount_sid) WHERE twilio_subaccount_sid IS NOT NULL;
|
||||
|
||||
-- Tabela de infra: só service_role (edge functions) e saas admin enxergam
|
||||
ALTER TABLE public.channel_routing ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS channel_routing_saas_admin ON public.channel_routing;
|
||||
CREATE POLICY channel_routing_saas_admin ON public.channel_routing
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
-- Trigger anexado a cada tenant_<slug>.notification_channels pelo clone
|
||||
CREATE OR REPLACE FUNCTION public.trg_sync_channel_routing()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tenant_id uuid;
|
||||
BEGIN
|
||||
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
|
||||
IF v_tenant_id IS NULL THEN
|
||||
RAISE WARNING 'trg_sync_channel_routing: schema % sem tenant correspondente', TG_TABLE_SCHEMA;
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END IF;
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
DELETE FROM public.channel_routing WHERE channel_id = OLD.id;
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
INSERT INTO public.channel_routing AS cr
|
||||
(channel_id, tenant_id, channel, provider, sender_address,
|
||||
twilio_subaccount_sid, twilio_phone_number, metadata, is_active, updated_at)
|
||||
VALUES
|
||||
(NEW.id, v_tenant_id, NEW.channel, NEW.provider, NEW.sender_address,
|
||||
NEW.twilio_subaccount_sid, NEW.twilio_phone_number, NEW.metadata,
|
||||
COALESCE(NEW.is_active, false) AND NEW.deleted_at IS NULL, now())
|
||||
ON CONFLICT (channel_id) DO UPDATE SET
|
||||
tenant_id = EXCLUDED.tenant_id,
|
||||
channel = EXCLUDED.channel,
|
||||
provider = EXCLUDED.provider,
|
||||
sender_address = EXCLUDED.sender_address,
|
||||
twilio_subaccount_sid = EXCLUDED.twilio_subaccount_sid,
|
||||
twilio_phone_number = EXCLUDED.twilio_phone_number,
|
||||
metadata = EXCLUDED.metadata,
|
||||
is_active = EXCLUDED.is_active,
|
||||
updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- clone_tenant_template
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.clone_tenant_template(p_tenant_id uuid)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_slug text;
|
||||
v_schema text;
|
||||
v_version int;
|
||||
t text;
|
||||
r record;
|
||||
v_def text;
|
||||
v_seq text;
|
||||
v_n int;
|
||||
v_pending text[];
|
||||
v_failed text[];
|
||||
BEGIN
|
||||
SELECT slug INTO v_slug FROM public.tenants WHERE id = p_tenant_id;
|
||||
IF v_slug IS NULL THEN
|
||||
RAISE EXCEPTION 'clone_tenant_template: tenant % não existe ou sem slug', p_tenant_id;
|
||||
END IF;
|
||||
v_schema := public.tenant_schema_name(v_slug);
|
||||
IF v_schema IS NULL THEN
|
||||
RAISE EXCEPTION 'clone_tenant_template: slug % inválido', v_slug;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = v_schema) THEN
|
||||
RAISE EXCEPTION 'clone_tenant_template: schema % já existe', v_schema;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = '_tenant_template') THEN
|
||||
RAISE EXCEPTION 'clone_tenant_template: _tenant_template não existe (rode a F1.3)';
|
||||
END IF;
|
||||
SELECT (value)::int INTO v_version FROM _tenant_template._meta WHERE key = 'template_version';
|
||||
|
||||
EXECUTE format('CREATE SCHEMA %I', v_schema);
|
||||
|
||||
-- nomes qualificados nas definições geradas pelo catálogo
|
||||
PERFORM pg_catalog.set_config('search_path', 'pg_catalog', true);
|
||||
|
||||
-- 1. tabelas
|
||||
FOR r IN
|
||||
SELECT table_name AS tab FROM information_schema.tables
|
||||
WHERE table_schema = '_tenant_template' AND table_type = 'BASE TABLE'
|
||||
AND table_name NOT LIKE '\_%'
|
||||
ORDER BY table_name
|
||||
LOOP
|
||||
EXECUTE format('CREATE TABLE %I.%I (LIKE _tenant_template.%I INCLUDING ALL)',
|
||||
v_schema, r.tab, r.tab);
|
||||
END LOOP;
|
||||
|
||||
-- 2. sequences locais (defaults que apontam pro template)
|
||||
FOR r IN
|
||||
SELECT c.relname AS tab, a.attname AS col
|
||||
FROM pg_attrdef d
|
||||
JOIN pg_class c ON c.oid = d.adrelid
|
||||
JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum
|
||||
WHERE c.relnamespace = v_schema::regnamespace
|
||||
AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval(''_tenant_template.%'
|
||||
LOOP
|
||||
v_seq := r.tab || '_' || r.col || '_seq';
|
||||
EXECUTE format('CREATE SEQUENCE %I.%I', v_schema, v_seq);
|
||||
EXECUTE format('ALTER TABLE %I.%I ALTER COLUMN %I SET DEFAULT nextval(%L::regclass)',
|
||||
v_schema, r.tab, r.col, format('%I.%I', v_schema, v_seq));
|
||||
EXECUTE format('ALTER SEQUENCE %I.%I OWNED BY %I.%I.%I',
|
||||
v_schema, v_seq, v_schema, r.tab, r.col);
|
||||
END LOOP;
|
||||
|
||||
-- 3. seeds (linhas-default do sistema guardadas no template)
|
||||
-- Sem session_replication_role (postgres não é superuser no Supabase):
|
||||
-- ordem de FK resolvida por tentativa-e-repetição em rounds.
|
||||
v_pending := ARRAY[]::text[];
|
||||
FOR r IN
|
||||
SELECT table_name AS tab FROM information_schema.tables
|
||||
WHERE table_schema = '_tenant_template' AND table_type = 'BASE TABLE'
|
||||
AND table_name NOT LIKE '\_%'
|
||||
ORDER BY table_name
|
||||
LOOP
|
||||
EXECUTE format('SELECT count(*) FROM _tenant_template.%I', r.tab) INTO v_n;
|
||||
IF v_n > 0 THEN v_pending := v_pending || r.tab; END IF;
|
||||
END LOOP;
|
||||
|
||||
WHILE coalesce(array_length(v_pending, 1), 0) > 0 LOOP
|
||||
v_failed := ARRAY[]::text[];
|
||||
FOR r IN SELECT unnest(v_pending) AS tab LOOP
|
||||
BEGIN
|
||||
EXECUTE format('INSERT INTO %I.%I SELECT * FROM _tenant_template.%I',
|
||||
v_schema, r.tab, r.tab);
|
||||
EXCEPTION WHEN foreign_key_violation THEN
|
||||
v_failed := v_failed || r.tab;
|
||||
END;
|
||||
END LOOP;
|
||||
IF array_length(v_failed, 1) = array_length(v_pending, 1) THEN
|
||||
RAISE EXCEPTION 'clone_tenant_template: dependência circular nos seeds: %', v_failed;
|
||||
END IF;
|
||||
v_pending := v_failed;
|
||||
END LOOP;
|
||||
|
||||
-- 4. FKs (intra-schema e pra public/auth)
|
||||
FOR r IN
|
||||
SELECT cl.relname AS tab, con.conname, pg_get_constraintdef(con.oid) AS def
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class cl ON cl.oid = con.conrelid
|
||||
WHERE con.contype = 'f'
|
||||
AND cl.relnamespace = '_tenant_template'::regnamespace
|
||||
ORDER BY cl.relname, con.conname
|
||||
LOOP
|
||||
v_def := replace(r.def, ' REFERENCES _tenant_template.', format(' REFERENCES %I.', v_schema));
|
||||
EXECUTE format('ALTER TABLE %I.%I ADD CONSTRAINT %I %s', v_schema, r.tab, r.conname, v_def);
|
||||
END LOOP;
|
||||
|
||||
-- 5. views (placeholders __SCHEMA__ / __TENANT_ID__)
|
||||
PERFORM pg_catalog.set_config('search_path', 'public, pg_catalog', true);
|
||||
FOR r IN SELECT * FROM _tenant_template._views ORDER BY position LOOP
|
||||
EXECUTE replace(replace(r.definition, '__SCHEMA__', quote_ident(v_schema)),
|
||||
'__TENANT_ID__', p_tenant_id::text);
|
||||
END LOOP;
|
||||
|
||||
-- 6. RLS: tenant_id embutido (multi-membership: o usuário só enxerga
|
||||
-- schemas de tenants onde tenant_members o lista como ativo)
|
||||
FOR r IN
|
||||
SELECT table_name AS tab FROM information_schema.tables
|
||||
WHERE table_schema = '_tenant_template' AND table_type = 'BASE TABLE'
|
||||
AND table_name NOT LIKE '\_%'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I.%I ENABLE ROW LEVEL SECURITY', v_schema, r.tab);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY tenant_member_full ON %I.%I FOR ALL TO authenticated USING (public.is_tenant_member(%L::uuid)) WITH CHECK (public.is_tenant_member(%L::uuid))',
|
||||
v_schema, r.tab, p_tenant_id, p_tenant_id);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY saas_admin_full ON %I.%I FOR ALL TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin())',
|
||||
v_schema, r.tab);
|
||||
END LOOP;
|
||||
|
||||
-- 7. trigger de roteamento de canais
|
||||
EXECUTE format(
|
||||
'CREATE TRIGGER trg_channel_routing AFTER INSERT OR UPDATE OR DELETE ON %I.notification_channels FOR EACH ROW EXECUTE FUNCTION public.trg_sync_channel_routing()',
|
||||
v_schema);
|
||||
|
||||
-- 8. realtime
|
||||
FOR r IN SELECT table_name FROM _tenant_template._realtime_tables LOOP
|
||||
EXECUTE format('ALTER PUBLICATION supabase_realtime ADD TABLE %I.%I', v_schema, r.table_name);
|
||||
END LOOP;
|
||||
|
||||
-- 9. grants (espelha o padrão do Supabase pra schemas expostos)
|
||||
EXECUTE format('GRANT USAGE ON SCHEMA %I TO anon, authenticated, service_role', v_schema);
|
||||
EXECUTE format('GRANT ALL ON ALL TABLES IN SCHEMA %I TO anon, authenticated, service_role', v_schema);
|
||||
EXECUTE format('GRANT ALL ON ALL SEQUENCES IN SCHEMA %I TO anon, authenticated, service_role', v_schema);
|
||||
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON TABLES TO anon, authenticated, service_role', v_schema);
|
||||
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON SEQUENCES TO anon, authenticated, service_role', v_schema);
|
||||
|
||||
INSERT INTO public.tenant_schemas (tenant_id, schema_name, template_version)
|
||||
VALUES (p_tenant_id, v_schema, COALESCE(v_version, 1));
|
||||
|
||||
RETURN v_schema;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- drop_tenant_schema
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.drop_tenant_schema(p_tenant_id uuid)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_schema text;
|
||||
BEGIN
|
||||
SELECT schema_name INTO v_schema FROM public.tenant_schemas WHERE tenant_id = p_tenant_id;
|
||||
IF v_schema IS NULL THEN
|
||||
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||
END IF;
|
||||
IF v_schema IS NULL OR v_schema NOT LIKE 'tenant\_%' THEN
|
||||
RAISE EXCEPTION 'drop_tenant_schema: schema inválido pra tenant % (%)', p_tenant_id, v_schema;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = v_schema) THEN
|
||||
RAISE EXCEPTION 'drop_tenant_schema: schema % não existe', v_schema;
|
||||
END IF;
|
||||
DELETE FROM public.channel_routing WHERE tenant_id = p_tenant_id;
|
||||
DELETE FROM public.tenant_schemas WHERE tenant_id = p_tenant_id;
|
||||
EXECUTE format('DROP SCHEMA %I CASCADE', v_schema);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Clone/drop são operações de provisionamento: só service_role (edge) e postgres
|
||||
REVOKE ALL ON FUNCTION public.clone_tenant_template(uuid) FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.drop_tenant_schema(uuid) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.clone_tenant_template(uuid) TO service_role;
|
||||
GRANT EXECUTE ON FUNCTION public.drop_tenant_schema(uuid) TO service_role;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- =============================================================================
|
||||
-- F1.5 — Correção dos seeds do _tenant_template
|
||||
--
|
||||
-- O PASS 5 da F1.3 semeou TODA linha com tenant_id IS NULL de public — mas
|
||||
-- patient_intake_requests (2), patient_invites (1) e notifications (2) eram
|
||||
-- dados operacionais órfãos, não defaults do sistema. Cada tenant novo nasceria
|
||||
-- com esses registros fantasmas.
|
||||
--
|
||||
-- Whitelist canônica de seeds do template (lookups/templates do sistema):
|
||||
-- clinical_note_templates, contact_email_types, contact_types,
|
||||
-- conversation_optout_keywords, conversation_tags, document_templates,
|
||||
-- notification_templates, feriados
|
||||
--
|
||||
-- (20260612000003 foi corrigida em retrospecto pra instalações do zero;
|
||||
-- esta migration corrige bancos que já aplicaram a versão original.)
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
DELETE FROM _tenant_template.patient_intake_requests;
|
||||
DELETE FROM _tenant_template.patient_invites;
|
||||
DELETE FROM _tenant_template.notifications;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,167 @@
|
||||
-- =============================================================================
|
||||
-- F2 — Schema-per-tenant: provisionamento cria o schema físico
|
||||
--
|
||||
-- Os 3 pontos de criação de tenant passam a chamar clone_tenant_template()
|
||||
-- logo após inserir em tenants/tenant_members. Tudo na mesma transação:
|
||||
-- se o clone falhar, o tenant não nasce (atomicidade).
|
||||
--
|
||||
-- Pontos cobertos (F0 §levantamento — não há outros INSERT INTO tenants):
|
||||
-- * provision_account_tenant — wizard de cadastro (therapist/clinic_*)
|
||||
-- * create_clinic_tenant — criação avulsa de clínica
|
||||
-- * ensure_personal_tenant_for_user — tenant pessoal (kind='saas'),
|
||||
-- chamado também pelo trigger de signup (handle_new_user_create_personal_tenant)
|
||||
--
|
||||
-- Decisão Q2: TODO tenant ganha schema, inclusive therapist e pessoal.
|
||||
-- Clones nascem sem triggers de negócio (F6) e fora do PostgREST (F5).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.create_clinic_tenant(p_name text)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $function$
|
||||
declare
|
||||
v_uid uuid;
|
||||
v_tenant uuid;
|
||||
v_name text;
|
||||
begin
|
||||
v_uid := auth.uid();
|
||||
if v_uid is null then
|
||||
raise exception 'Not authenticated';
|
||||
end if;
|
||||
|
||||
v_name := nullif(trim(coalesce(p_name, '')), '');
|
||||
if v_name is null then
|
||||
v_name := 'Clínica';
|
||||
end if;
|
||||
|
||||
insert into public.tenants (name, kind, created_at)
|
||||
values (v_name, 'clinic', now())
|
||||
returning id into v_tenant;
|
||||
|
||||
insert into public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
values (v_tenant, v_uid, 'tenant_admin', 'active', now());
|
||||
|
||||
-- F2: schema físico do tenant (rollback junto se algo falhar)
|
||||
perform public.clone_tenant_template(v_tenant);
|
||||
|
||||
return v_tenant;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.ensure_personal_tenant_for_user(p_user_id uuid)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $function$
|
||||
declare
|
||||
v_uid uuid;
|
||||
v_existing uuid;
|
||||
v_tenant uuid;
|
||||
v_email text;
|
||||
v_name text;
|
||||
begin
|
||||
v_uid := p_user_id;
|
||||
if v_uid is null then
|
||||
raise exception 'Missing user id';
|
||||
end if;
|
||||
|
||||
-- só considera tenant pessoal (kind='saas')
|
||||
select tm.tenant_id
|
||||
into v_existing
|
||||
from public.tenant_members tm
|
||||
join public.tenants t on t.id = tm.tenant_id
|
||||
where tm.user_id = v_uid
|
||||
and tm.status = 'active'
|
||||
and t.kind = 'saas'
|
||||
order by tm.created_at desc
|
||||
limit 1;
|
||||
|
||||
if v_existing is not null then
|
||||
return v_existing;
|
||||
end if;
|
||||
|
||||
select email into v_email
|
||||
from auth.users
|
||||
where id = v_uid;
|
||||
|
||||
v_name := coalesce(split_part(v_email, '@', 1), 'Conta');
|
||||
|
||||
insert into public.tenants (name, kind, created_at)
|
||||
values (v_name || ' (Pessoal)', 'saas', now())
|
||||
returning id into v_tenant;
|
||||
|
||||
insert into public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
values (v_tenant, v_uid, 'tenant_admin', 'active', now());
|
||||
|
||||
-- F2: schema físico do tenant (rollback junto se algo falhar)
|
||||
perform public.clone_tenant_template(v_tenant);
|
||||
|
||||
return v_tenant;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.provision_account_tenant(p_user_id uuid, p_kind text, p_name text DEFAULT NULL::text)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_tenant_id uuid;
|
||||
v_account_type text;
|
||||
v_name text;
|
||||
BEGIN
|
||||
IF p_kind NOT IN ('therapist', 'clinic_coworking', 'clinic_reception', 'clinic_full') THEN
|
||||
RAISE EXCEPTION 'kind inválido: "%". Use: therapist, clinic_coworking, clinic_reception, clinic_full.', p_kind
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
v_account_type := CASE WHEN p_kind = 'therapist' THEN 'therapist' ELSE 'clinic' END;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.tenant_members tm
|
||||
JOIN public.tenants t ON t.id = tm.tenant_id
|
||||
WHERE tm.user_id = p_user_id
|
||||
AND tm.role = 'tenant_admin'
|
||||
AND tm.status = 'active'
|
||||
AND t.kind = p_kind
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Usuário já possui um tenant do tipo "%".', p_kind
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
v_name := COALESCE(
|
||||
NULLIF(TRIM(p_name), ''),
|
||||
(
|
||||
SELECT COALESCE(NULLIF(TRIM(pr.full_name), ''), SPLIT_PART(au.email, '@', 1))
|
||||
FROM public.profiles pr
|
||||
JOIN auth.users au ON au.id = pr.id
|
||||
WHERE pr.id = p_user_id
|
||||
),
|
||||
'Conta'
|
||||
);
|
||||
|
||||
INSERT INTO public.tenants (name, kind, created_at)
|
||||
VALUES (v_name, p_kind, now())
|
||||
RETURNING id INTO v_tenant_id;
|
||||
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES (v_tenant_id, p_user_id, 'tenant_admin', 'active', now());
|
||||
|
||||
UPDATE public.profiles
|
||||
SET account_type = v_account_type
|
||||
WHERE id = p_user_id;
|
||||
|
||||
PERFORM public.seed_determined_commitments(v_tenant_id);
|
||||
|
||||
-- F2: schema físico do tenant (rollback junto se algo falhar)
|
||||
PERFORM public.clone_tenant_template(v_tenant_id);
|
||||
|
||||
RETURN v_tenant_id;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,154 @@
|
||||
# F0 — Categorização para migração Schema-per-Tenant
|
||||
|
||||
> Gerado em 2026-06-12 a partir de varredura direta no banco local (`supabase_db_agenciapsi-primesakai`),
|
||||
> grep nas edge functions (`supabase/functions/`) e no frontend (`src/`).
|
||||
> Fonte do plano: `novo-rumo.txt` (blueprint do projeto irmão), adaptado às divergências deste projeto.
|
||||
|
||||
## Sumário executivo
|
||||
|
||||
| Item | Quantidade |
|
||||
|---|---|
|
||||
| Tabelas em `public` (BASE TABLE) | 137 |
|
||||
| **Tenant-scoped** (vão pra `tenant_<x>`) — decidido Q3 | **84** |
|
||||
| **Globais** (ficam em `public`) | **53** |
|
||||
| Funções que referenciam tabelas-tenant | **66** (não 29 — o aviso do blueprint se confirmou) |
|
||||
| Views que referenciam tabelas-tenant | 6 |
|
||||
| FKs global→tenant problemáticas | **1** (`whatsapp_credits_transactions.conversation_message_id`) |
|
||||
| Policies de tabelas globais usando funções a refatorar | **0** (risco de policy órfã é baixo) |
|
||||
| Edge functions que tocam tabelas-tenant | ~25 de 29 |
|
||||
| Tenants existentes | 9 (3 clínicas + 6 terapeutas individuais) |
|
||||
| Volumetria | Baixa (maior tabela tenant: `conversation_messages` 355 linhas) — migração de dados é barata |
|
||||
|
||||
## ⚠️ Divergências críticas vs blueprint (novo-rumo.txt)
|
||||
|
||||
Estas diferenças exigem adaptação do plano — o blueprint NÃO se aplica literalmente:
|
||||
|
||||
### D1 — Não existe `tenants.slug`
|
||||
Colunas de `tenants`: `id, name, created_at, kind, papel_timbrado, cpf_cnpj`.
|
||||
O blueprint assume `slug` para nomear schemas (`tenant_<slug>`).
|
||||
**Opções:** (a) adicionar coluna `slug` (unique, imutável, sanitizada a partir de `name`); (b) usar `tenant_` + uuid sem hífens (feio, mas sem coluna nova).
|
||||
|
||||
### D2 — Membership multi-tenant via `tenant_members` (profiles.tenant_id está 100% NULL)
|
||||
- `profiles.tenant_id` existe mas tem **0 linhas preenchidas**.
|
||||
- Membership real: `tenant_members` (15 linhas), com **4 usuários membros de mais de um tenant**.
|
||||
- Tenant ativo é resolvido no **frontend**: RPC `my_tenants()` → Pinia `tenantStore.activeTenantId` → localStorage. Sem claim no JWT. Router troca tenant por `meta.tenantScope` (clinic/personal/supervisor).
|
||||
|
||||
**Consequência:** o helper `current_tenant_schema()` do blueprint (baseado em `profiles.tenant_id`) **não funciona aqui**. Adaptação proposta:
|
||||
- Frontend escolhe o schema diretamente: `db()` = `supabase.schema(tenantSchemaName(activeTenant))` — já sabe o tenant ativo.
|
||||
- Segurança não depende da escolha do cliente: cada schema clonado ganha policies com o tenant_id **embutido**: `USING (public.is_tenant_member('<uuid-do-tenant>'))`. Usuário só lê schema de tenant onde é membro, mesmo apontando o client pra outro schema.
|
||||
- RPCs que precisam de "tenant atual" passam a receber `p_tenant_id` explícito, validado com `is_tenant_member()` antes do `set_config('search_path', ...)`. Substitui `current_tenant_schema()` por `tenant_schema_checked(p_tenant_id)`.
|
||||
- Edge functions: client envia o tenant ativo (header `X-Tenant-Id` ou body); a function valida membership via `tenant_members` antes de usar `.schema()`.
|
||||
|
||||
### D3 — 6 dos 9 tenants são terapeutas individuais (`kind='therapist'`)
|
||||
Schema-per-tenant aqui significa **um schema por terapeuta** que se cadastrar. Com 9 tenants é trivial; em escala self-serve (centenas/milhares), o array `schemas` do PostgREST e o catálogo do Postgres crescem linearmente. Funciona, mas é um custo operacional permanente (config.toml + restart a cada signup, a menos que automatize).
|
||||
**Recomendação:** modelo uniforme (todo tenant ganha schema, qualquer `kind`) — modelo misto (clínica com schema, terapeuta em public) dobraria a complexidade de todas as funções e do frontend.
|
||||
|
||||
### D4 — `tenant_id` que aponta pra `auth.users` (legado)
|
||||
`email_layout_config.tenant_id` e `email_templates_tenant.tenant_id` têm FK pra **auth.users**, não pra `tenants`. Tratar na migração de dados (mapear user→tenant via `tenant_members` ou `owner_id`).
|
||||
|
||||
### D5 — View `current_tenant_id` é código morto
|
||||
`SELECT current_setting('request.jwt.claim.tenant_id', true)` — claim nunca populado. Remover na F6.
|
||||
|
||||
---
|
||||
|
||||
## 1. Classificação das 137 tabelas
|
||||
|
||||
### 1.1 TENANT-SCOPED — movem pra `tenant_<x>` (79)
|
||||
|
||||
**Agenda (7):** agenda_bloqueios, agenda_configuracoes, agenda_eventos, agenda_online_slots, agenda_regras_semanais, agenda_slots_bloqueados_semanais, agenda_slots_regras
|
||||
**Agendador público (2):** agendador_configuracoes, agendador_solicitacoes
|
||||
**Asaas — cobrança de pacientes (2):** asaas_customers, asaas_payments *(FKs → patients/financial_records confirmam: é billing da clínica→paciente, não SaaS→tenant; ver decisão Q4)*
|
||||
**Billing clínico (1):** billing_contracts
|
||||
**Prontuário (3):** clinical_note_templates, clinical_note_versions, clinical_notes
|
||||
**Compromissos (4):** commitment_services *(sem tenant_id — join, FK confirma)*, commitment_time_logs, determined_commitment_fields, determined_commitments
|
||||
**Cadastro da clínica (2):** company_profiles, medicos
|
||||
**Contatos (4):** contact_email_types, contact_emails, contact_phones, contact_types
|
||||
**Conversas/WhatsApp — conteúdo (13):** conversation_assignments, conversation_autoreply_log, conversation_autoreply_settings, conversation_bot_sessions, conversation_bots, conversation_messages, conversation_notes, conversation_optout_keywords, conversation_optouts, conversation_sla_breaches, conversation_sla_rules, conversation_tags, conversation_thread_tags *(dados clínicos sensíveis → LGPD favorece isolamento físico)*
|
||||
**Documentos (6):** document_access_logs, document_generated, document_share_links, document_signatures, document_templates, documents *(⚠️ owned por `supabase_admin` — usar psql direto, gotcha conhecido)*
|
||||
**E-mail do tenant (2):** email_layout_config, email_templates_tenant *(⚠️ D4)*
|
||||
**Financeiro (4):** financial_categories *(sem tenant_id, 0 linhas, FK de financial_records — mover junto evita FK cross-schema)*, financial_exceptions, financial_records, feriados
|
||||
**Convênios (2):** insurance_plan_services *(sem tenant_id — join)*, insurance_plans
|
||||
**Notificações do tenant (4):** notifications *(SPLIT — ver §5)*, notification_preferences, notification_schedules, notification_templates
|
||||
**Pacientes (13):** patient_contacts, patient_discounts, patient_group_patient, patient_groups, patient_intake_requests, patient_invite_attempts, patient_invites, patient_patient_tag, patient_status_history, patient_support_contacts, patient_tags, patient_timeline, patients
|
||||
**Precificação/pagamento (3):** payment_settings, professional_pricing, services
|
||||
**Recorrência (3):** recurrence_exceptions, recurrence_rule_services *(sem tenant_id — join)*, recurrence_rules
|
||||
**Lembretes de sessão (2):** session_reminder_logs, session_reminder_settings
|
||||
**Repasse (2):** therapist_payout_records *(sem tenant_id — child, FK confirma)*, therapist_payouts
|
||||
|
||||
> 5 tabelas tenant-scoped **não têm** coluna tenant_id (joins/children): commitment_services, insurance_plan_services, recurrence_rule_services, therapist_payout_records, financial_categories. A heurística "tem tenant_id" sozinha teria errado — confirmadas via FK.
|
||||
|
||||
### 1.2 Infra de mensageria (5) — ✅ DECIDIDO (Q3): TENANT-SCOPED
|
||||
|
||||
notification_channels, notification_queue, notification_logs, twilio_subaccount_usage, whatsapp_connection_incidents
|
||||
|
||||
**Decisão do Leonardo (2026-06-12):** isolamento físico máximo — as 5 movem pro schema do tenant (LGPD; conteúdo de mensagem nunca fica em public). Consequências assumidas:
|
||||
- Os crons `process-notification-queue`, `process-email-queue`, `process-sms-queue`, `process-whatsapp-queue`, `whatsapp-heartbeat-check`, `conversation-sla-check` passam a varrer tenants em loop (`FOR t IN SELECT … FROM tenants` / loop no Deno com `.schema()` por tenant).
|
||||
- Webhooks inbound (twilio/evolution) resolvem o tenant pelo canal ANTES de gravar → precisa de um índice global de roteamento `public.channel_routing` (channel_external_id → tenant_id) mantido por trigger no schema tenant, já que não dá pra procurar o canal em N schemas.
|
||||
- `twilio_subaccount_usage.channel_id → notification_channels` fica intra-schema (OK).
|
||||
- FK global→tenant continua sendo só `whatsapp_credits_transactions.conversation_message_id` (vira coluna solta).
|
||||
|
||||
### 1.3 GLOBAIS — ficam em `public` (53)
|
||||
|
||||
**SaaS core:** tenants, tenant_members, tenant_invites, tenant_features, tenant_feature_exceptions_log, tenant_modules, profiles, profile_specialties, specialties, owner_users, saas_admins, user_settings
|
||||
**Planos/assinaturas:** plans, plan_features, plan_prices, plan_public, plan_public_bullets, subscriptions, subscription_events, subscription_intents_legacy, subscription_intents_personal, subscription_intents_tenant, features, modules, module_features, entitlements_invalidation
|
||||
**Créditos/addons (billing SaaS→tenant):** addon_credits, addon_products, addon_transactions, whatsapp_credit_packages, whatsapp_credit_purchases, whatsapp_credits_balance, whatsapp_credits_transactions
|
||||
**Plataforma:** _db_migrations, asaas_webhook_events *(staging de webhook — roteia pro tenant na F6)*, audit_logs *(auditoria cross-tenant, padrão `tenant_audit_log` do blueprint)*, email_templates_global, email_layout? não — global_notices, notice_dismissals, login_carousel_slides, math_challenges, public_submission_attempts, submission_rate_limits, support_sessions, saas_doc_votos, saas_docs, saas_faq, saas_faq_itens, saas_security_config, saas_twilio_config
|
||||
**Dev/tracking (11):** dev_auditoria_items, dev_comparison_competitor_status, dev_comparison_matrix, dev_competitor_features, dev_competitors, dev_generation_log, dev_roadmap_items, dev_roadmap_phases, dev_test_items, dev_user_credentials, dev_verificacoes_items
|
||||
|
||||
> Tabelas com tenant_id que **ficam** em public (manter `.eq('tenant_id')` no FE): tenant_features, tenant_feature_exceptions_log, tenant_invites, tenant_members, subscriptions, subscription_intents_*, addon_credits, addon_transactions, whatsapp_credit_*, audit_logs, support_sessions, profiles (+ grupo 1.2 se ficar global).
|
||||
|
||||
## 2. Funções — 66 referenciam tabelas-tenant (de 445 em public)
|
||||
|
||||
Por categoria (lista completa no fim):
|
||||
|
||||
- **Triggers em tabelas tenant (18):** agendador_gerar_slug, auto_create_financial_record_from_session, fanout_inbound_message_to_notifications, fn_agenda_regras_semanais_no_overlap, fn_clinical_note_version, fn_document_signature_timeline, fn_documents_timeline_insert, fn_sla_resolve_on_outbound, fn_whatsapp_low_balance_notify, notify_on_intake, notify_on_scheduling, notify_on_session_status, sync_busy_mirror_agenda_eventos, sync_legacy_email_fields, sync_legacy_phone_fields, trg_fn_patient_risco_timeline, trg_fn_patient_status_history, trg_fn_patient_status_timeline → padrão `TG_TABLE_SCHEMA` + `tenant_id_for_schema()`
|
||||
- **RPCs chamadas por usuário logado (~30):** cancel_recurrence_from, cancelar_eventos_serie, can_delete_patient, create_financial_record_for_session, create_therapist_payout, delete_commitment_full, delete_determined_commitment, export_patient_data, get_entity_primary_phone, get_financial_report, get_financial_summary, get_patient_session_counts, issue_patient_invite, list_financial_records, list_my_signatures, mark_as_paid, mark_payout_as_paid, rotate_patient_invite_token(+v2), safe_delete_patient, search_global, seed_default_patient_groups, seed_determined_commitments, split_recurrence_at, tenant_remove_member… → padrão `p_tenant_id` + `is_tenant_member()` + `set_config('search_path')` (adaptação D2)
|
||||
- **RPCs globais/cron (~10):** cleanup_notification_queue, convert_abandoned_intake_to_lead, populate_notification_queue, sync_overdue_financial_records, unstick_notification_queue, whatsapp_heartbeat_*, sla_open_breach, sla_mark_notified, first_response_stats, _first_response_runs → padrão loop `FOR t_row IN SELECT … FROM tenants`
|
||||
- **RPCs públicas/anon por token (~8):** create_patient_intake_request(+v2), get_patient_intake_invite_info, get_signable_document_by_token, sign_document_by_token, sign_document_by_signature_id, validate_share_token, match_patient_by_phone, agendador_dias_disponiveis, agendador_slots_disponiveis → o token/slug identifica o tenant → resolver schema a partir do registro
|
||||
- **SQL puro (8):** _first_response_runs, can_delete_patient, get_entity_primary_phone, get_financial_report, get_financial_summary, get_patient_session_counts, list_financial_records → converter pra plpgsql (limitação 3 do blueprint; exige DROP+CREATE)
|
||||
|
||||
## 3. Views, FKs e policies
|
||||
|
||||
**6 views referenciam tabelas-tenant** → recriar dentro do schema template: audit_log_unified *(parcial — mistura audit_logs global)*, conversation_threads *(9 usos FE + 1 edge)*, v_cashflow_projection, v_commitment_totals, v_patient_groups_with_counts, v_tag_patient_counts. Demais 23 views só tocam globais — intactas.
|
||||
|
||||
**FK global→tenant (a única problemática):** `whatsapp_credits_transactions.conversation_message_id → conversation_messages`. Decisão: converter pra coluna solta (uuid sem constraint) — billing não pode impedir DROP de schema de tenant.
|
||||
|
||||
**FKs tenant→public (ok fisicamente, perdem embed PostgREST):** patients→tenant_members (responsible/therapist_member_id), *→auth.users, financial_records→tenants (coluna some), etc. → helper `attachProfiles`/fake-embed no FE (limitação 1 do blueprint).
|
||||
|
||||
**Policies:** nenhuma policy de tabela global usa as 66 funções → zero recriação de policy em public. Policies das tabelas movidas morrem com elas; schemas tenant ganham policies novas no clone. Helpers RLS atuais (`is_tenant_member` 65 usos, `is_saas_admin` 177, `tenant_has_feature` 56, `is_clinic_tenant` 56) continuam válidos e são reaproveitados nas policies dos schemas tenant.
|
||||
|
||||
## 4. Edge functions — 29 no total, ~25 tocam tabelas-tenant
|
||||
|
||||
Mais afetadas (refs a tabelas do grupo tenant): process-notification-queue, process-email-queue, process-sms-queue, process-whatsapp-queue, send-session-reminders(+manual,+status), conversation-sla-check, evolution-whatsapp-inbound, twilio-whatsapp-inbound, send-whatsapp-message, submit-patient-intake, save-intake-progress, get-intake-invite-info, convert-abandoned-intakes, asaas-webhook, asaas-create-payment-record, asaas-cancel-payment, asaas-sync-payment, notification-webhook, sync-email-templates.
|
||||
Se grupo 1.2 ficar global (Q3), os processadores de fila mantêm a maior parte do código; o refactor concentra-se em: conversation_* (12 refs), session_reminder_logs (7), notifications (2), intake (6), asaas (4), agenda_eventos (3), patients (2).
|
||||
Padrão: inbound/webhook resolve tenant pelo canal/token → `.schema(tenant_schema_for(tenant_id))`; crons varrem `tenants` em loop.
|
||||
|
||||
## 5. Split de `notifications` (172 linhas)
|
||||
|
||||
Igual ao blueprint: `tenant_<x>.notifications` (locais) + `public.notifications_sistema` (cross-tenant: avisos SaaS, suporte, system_alert). Funções notify_* ganham 2 variantes; `useNotifications.js` (18 usos FE) mescla 2 fontes com `_origem`; realtime em 2 canais (canal tenant usa `schema: tenant_<x>`). Detalhar tipos cross-tenant vs locais na F6-Lote 1 (query por `type` antes do split).
|
||||
|
||||
## 6. Frontend — alvos do refactor (F3)
|
||||
|
||||
- `src/lib/supabase/client.js` — cliente único; criar `useTenantDb.js` ao lado.
|
||||
- ~100 usos `from('agenda_eventos')`, 64 `financial_records`, 45 `patients`… (tabela completa no grep de F0).
|
||||
- Remover `.eq('tenant_id', …)` APENAS nas tabelas que saem; manter nas globais (tenant_members, tenant_features, subscriptions, etc.).
|
||||
- `tenantStore` ganha `activeTenantSchema` (computed de slug); repositories trocam `supabase.from` → `db().from`.
|
||||
- Realtime: canais de tabelas tenant trocam `schema: 'public'` → `schema: tenantSchemaName`.
|
||||
- Embeds `profiles!fkey(...)` em tabelas tenant → `attachProfiles()`.
|
||||
|
||||
## 7. Volumetria (migração de dados barata)
|
||||
|
||||
Top tenant-scoped: conversation_messages 355, notifications 172, determined_commitment_fields 117, financial_records 54, determined_commitments 47, agenda_eventos 37, patients 35. Todo o resto < 40 linhas. `audit_logs` (608) fica em public. Migração completa roda em segundos; ainda assim com backup por lote (regra do blueprint).
|
||||
|
||||
## 8. Decisões — ✅ respondidas pelo Leonardo em 2026-06-12
|
||||
|
||||
| # | Decisão | Resposta |
|
||||
|---|---|---|
|
||||
| Q1 | Nome do schema | **Criar coluna `tenants.slug`** (unique, imutável, gerado de name) → `tenant_<slug>` |
|
||||
| Q2 | Quais tenants ganham schema | **Todos** (clínicas e therapists — modelo uniforme) |
|
||||
| Q3 | Infra de mensageria (5 tabelas) | **Tenant-scoped** (isolamento máximo; ver §1.2 — crons em loop + índice de roteamento de canais) |
|
||||
| Q4 | asaas_customers/asaas_payments | **Tenant** (webhook roteia via staging global `asaas_webhook_events`) |
|
||||
|
||||
## Anexo — 66 funções (nome | kind | linguagem | é trigger)
|
||||
|
||||
_first_response_runs|sql · agendador_dias_disponiveis|plpgsql · agendador_gerar_slug|plpgsql|trg · agendador_slots_disponiveis|plpgsql · auto_create_financial_record_from_session|plpgsql|trg · can_delete_patient|sql · cancel_patient_pending_notifications|plpgsql · cancel_recurrence_from|plpgsql · cancelar_eventos_serie|plpgsql · cleanup_notification_queue|plpgsql · convert_abandoned_intake_to_lead|plpgsql · create_financial_record_for_session|plpgsql · create_patient_intake_request|plpgsql · create_patient_intake_request_v2|plpgsql · create_therapist_payout|plpgsql · delete_commitment_full|plpgsql · delete_determined_commitment|plpgsql · export_patient_data|plpgsql · fanout_inbound_message_to_notifications|plpgsql|trg · first_response_stats|plpgsql · fn_agenda_regras_semanais_no_overlap|plpgsql|trg · fn_clinical_note_version|plpgsql|trg · fn_document_signature_timeline|plpgsql|trg · fn_documents_timeline_insert|plpgsql|trg · fn_sla_resolve_on_outbound|plpgsql|trg · fn_whatsapp_low_balance_notify|plpgsql|trg · get_entity_primary_phone|sql · get_financial_report|sql · get_financial_summary|sql · get_patient_intake_invite_info|plpgsql · get_patient_session_counts|sql · get_signable_document_by_token|plpgsql · issue_patient_invite|plpgsql · list_financial_records|sql · list_my_signatures|plpgsql · mark_as_paid|plpgsql · mark_payout_as_paid|plpgsql · match_patient_by_phone|plpgsql · notify_on_intake|plpgsql|trg · notify_on_scheduling|plpgsql|trg · notify_on_session_status|plpgsql|trg · populate_notification_queue|plpgsql · rotate_patient_invite_token|plpgsql · rotate_patient_invite_token_v2|plpgsql · safe_delete_patient|plpgsql · search_global|plpgsql · seed_default_patient_groups|plpgsql · seed_determined_commitments|plpgsql · sign_document_by_signature_id|plpgsql · sign_document_by_token|plpgsql · sla_mark_notified|plpgsql · sla_open_breach|plpgsql · split_recurrence_at|plpgsql · sync_busy_mirror_agenda_eventos|plpgsql|trg · sync_legacy_email_fields|plpgsql|trg · sync_legacy_phone_fields|plpgsql|trg · sync_overdue_financial_records|plpgsql · tenant_remove_member|plpgsql · trg_fn_patient_risco_timeline|plpgsql|trg · trg_fn_patient_status_history|plpgsql|trg · trg_fn_patient_status_timeline|plpgsql|trg · unstick_notification_queue|plpgsql · validate_share_token|plpgsql · whatsapp_heartbeat_mark_notified|plpgsql · whatsapp_heartbeat_open_incident|plpgsql · whatsapp_heartbeat_resolve_open_incidents|plpgsql
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
Prompt: Refactor Multi-Tenant para Schema-per-Tenant em Supabase
|
||||
Contexto e objetivo
|
||||
|
||||
Estou migrando meu sistema multi-tenant de RLS-only com tenant_id em cada tabela para schema-per-tenant (tenant_<slug>
|
||||
com clones físicos da estrutura). Quero isolamento físico das tabelas que pertencem a um tenant, mantendo em public
|
||||
apenas tabelas globais (auth.users, profiles, tenants, planos SaaS, notificações de sistema, etc.).
|
||||
|
||||
Já fiz esse refactor num projeto irmão (Vue 3 + Supabase + Postgres 17). Quero que você execute o mesmo aqui,
|
||||
considerando as lições que aprendi.
|
||||
|
||||
Antes de começar — varredura obrigatória
|
||||
|
||||
Não confie na lista que o usuário (ou um amigo programador) te entregar. Verifique tudo:
|
||||
|
||||
1. Liste TODAS as tabelas em public e classifique cada uma como "tenant-scoped" ou "global". Use a heurística: tem
|
||||
coluna tenant_id? É candidata a tenant-scoped. Mas reveja caso a caso — algumas globais (tenant_features,
|
||||
tenant_audit_log, support_messages) também têm tenant_id como FK e devem ficar em public.
|
||||
SELECT table_name,
|
||||
EXISTS(SELECT 1 FROM information_schema.columns c
|
||||
WHERE c.table_schema='public' AND c.table_name=t.table_name
|
||||
AND c.column_name='tenant_id') AS has_tenant_id
|
||||
FROM information_schema.tables t
|
||||
WHERE table_schema='public' AND table_type='BASE TABLE'
|
||||
ORDER BY table_name;
|
||||
2. Liste TODAS as funções em public que referenciam essas tabelas-tenant. Não confie em listas pré-feitas — eu recebi
|
||||
"29 funções" e eram na verdade 52. Use:
|
||||
WITH tenant_tabs AS (SELECT unnest(ARRAY[/* sua lista */]) AS tab)
|
||||
SELECT DISTINCT p.proname, p.prokind, l.lanname
|
||||
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
JOIN pg_language l ON l.oid = p.prolang
|
||||
CROSS JOIN tenant_tabs t
|
||||
WHERE n.nspname='public'
|
||||
AND pg_get_functiondef(p.oid) ~ ('\m' || t.tab || '\M')
|
||||
ORDER BY 1;
|
||||
3. Liste FKs cross-schema (de tabelas que vão ficar em public, apontando pras que vão sair). Se houver, planeje
|
||||
cuidado especial.
|
||||
4. Liste todas as edge functions e grep cada uma por .from('<tabela_tenant>').
|
||||
5. Liste as policies RLS que usam funções a refatorar — vão precisar ser dropadas/recriadas.
|
||||
|
||||
Plano de execução em fases
|
||||
|
||||
F0 — Categorização (não codar nada ainda)
|
||||
|
||||
Faça as listagens acima. Salve em documento markdown na raiz: docs/F0_categorizacao.md. Conte tabelas, funções, edge
|
||||
functions, FKs cross-schema, policies dependentes. Pause e mostre pro usuário antes de seguir.
|
||||
|
||||
F1 — Template + helpers
|
||||
|
||||
- Crie schema _tenant_template com TODAS as tabelas tenant-scoped clonadas SEM a coluna tenant_id (compostos unique
|
||||
também perdem tenant_id). Inclua índices, FKs locais, sequences, constraints.
|
||||
- Crie helpers em public:
|
||||
- tenant_schema_name(slug text) → text (IMMUTABLE) — converte slug→nome de schema sanitizado.
|
||||
- tenant_schema_for(tenant_id uuid) → text (STABLE) — busca slug e devolve schema.
|
||||
- tenant_id_for_schema(schema text) → uuid (STABLE) — inverso. CRÍTICO pra triggers que precisam descobrir o
|
||||
tenant_id (porque a coluna não existe mais nas tabelas tenant).
|
||||
- current_tenant_schema() → text (STABLE SECURITY DEFINER) — lê profiles.tenant_id do auth.uid() e devolve o schema
|
||||
dele.
|
||||
- clone_tenant_template(slug) → void (SECURITY DEFINER) — clona o template pra um schema novo.
|
||||
- drop_tenant_schema(tenant_id) → void — proteção: assert que target LIKE 'tenant_%' antes de DROP CASCADE.
|
||||
|
||||
F2 — Provisionamento
|
||||
|
||||
- Adapte sua função/edge provision_from_intent (ou equivalente) pra chamar clone_tenant_template(slug) quando criar
|
||||
tenant novo.
|
||||
- Confirme que policies padrão são criadas no schema clonado (uma policy tenant_member_full TO authenticated filtrando
|
||||
por profiles.tenant_id = '<id-do-tenant>').
|
||||
|
||||
F3 — Frontend: composable de acesso tenant
|
||||
|
||||
- Crie useTenantDb.js:
|
||||
export function useTenantDb() {
|
||||
const { perfil } = useAuth();
|
||||
const schemaName = computed(() => tenantSchemaName(perfil.value?.tenant_slug));
|
||||
const isReady = computed(() => Boolean(schemaName.value));
|
||||
function db() {
|
||||
if (!schemaName.value) throw new Error('tenant não disponível');
|
||||
return supabase.schema(schemaName.value);
|
||||
}
|
||||
return { db, schemaName, isReady };
|
||||
}
|
||||
- Faça find/replace amplo: supabase.from('<tenant_table>') → db().from('<tenant_table>') em todas as
|
||||
views/components/composables que tocam tabelas tenant.
|
||||
|
||||
F4 — Edge functions
|
||||
|
||||
Padrão pra qualquer edge function que precisa acessar tabela tenant:
|
||||
const userClient = createClient(SUPABASE_URL, ANON_KEY, {
|
||||
global: { headers: { Authorization: authHeader } }
|
||||
});
|
||||
const { data: tenantSchema } = await userClient.rpc('current_tenant_schema');
|
||||
const tenantDb = userClient.schema(tenantSchema as string);
|
||||
await tenantDb.from('oficios').update(...).eq(...);
|
||||
Tabelas globais (profiles, tenants, addon_*, support_*, etc.) seguem usando userClient.from(...) direto.
|
||||
|
||||
F5 — Expor schemas no PostgREST
|
||||
|
||||
Edite supabase/config.toml:
|
||||
[api]
|
||||
schemas = ["public", "graphql_public", "tenant_<slug1>", "tenant_<slug2>", ...]
|
||||
extra_search_path = ["public", "extensions"]
|
||||
Restart Supabase. Toda criação de tenant novo precisa atualizar este array e restartar PostgREST — automatize via
|
||||
migration que regenera config.toml, ou aceite gerenciamento manual.
|
||||
|
||||
F6 — Rewrite funções + drop tabelas em public (a fase mais perigosa)
|
||||
|
||||
Divida em lotes pequenos e teste cada um:
|
||||
|
||||
Lote 1 — split de notifications
|
||||
|
||||
Caso especial crítico. Antes do split, identifique:
|
||||
- Tipos de notif que cruzam tenants (dev recebe de todos os tenants, support_reply enviado pelo dev pro tenant,
|
||||
system_alert global).
|
||||
- Tipos que são puramente tenant-local (voucher_gerado, os_atribuida, oficio_assinado, prazos).
|
||||
|
||||
Decisão estrutural: notifications precisa virar duas tabelas:
|
||||
- tenant_<slug>.notifications — locais do tenant.
|
||||
- public.notifications_sistema — cross-tenant (SaaS pro tenant, ou pro dev).
|
||||
|
||||
Migration faz:
|
||||
1. Cria public.notifications_sistema (mesma estrutura + RLS própria + adiciona à publication realtime).
|
||||
2. Migra dados: INSERT INTO notifications_sistema SELECT ... WHERE type IN (cross_tenant_types), depois loop por
|
||||
tenant INSERT INTO tenant_X.notifications SELECT ... WHERE tenant_id = X AND type IN (local_types).
|
||||
3. Refatora todas as funções de notif (notify_user, notify_user_sistema, notify_tenant_admins, notify_all_devs,
|
||||
mark/archive_*) — duas variantes (_sistema_ em public, outras EXECUTE format pro schema tenant).
|
||||
4. DROP TABLE public.notifications.
|
||||
5. Frontend useNotifications.js: lê das duas fontes em paralelo, mescla por created_at DESC, cada item ganha campo
|
||||
_origem: 'tenant' | 'sistema'. Realtime em 2 canais. markRead/archive roteiam pra RPC correta via _origem.
|
||||
|
||||
Lote 2-4 — refator das demais funções
|
||||
|
||||
Padrão pra TRIGGER em tabela tenant:
|
||||
CREATE OR REPLACE FUNCTION public.trg_xxx() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE v_tenant_id uuid;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA); -- só se precisar
|
||||
-- ... lógica com tabelas tenant SEM prefixo `public.` ...
|
||||
END $$;
|
||||
|
||||
Padrão pra RPC chamada por user logado em um tenant:
|
||||
CREATE OR REPLACE FUNCTION public.minha_rpc(...) RETURNS ...
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE v_schema text := public.current_tenant_schema();
|
||||
BEGIN
|
||||
IF v_schema IS NULL THEN RAISE EXCEPTION 'sem tenant'; END IF;
|
||||
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||
-- ... lógica ...
|
||||
END $$;
|
||||
|
||||
Padrão pra RPC global (cron, dev, varre múltiplos tenants):
|
||||
FOR t_row IN SELECT id, slug FROM public.tenants WHERE ativo = true LOOP
|
||||
v_schema := public.tenant_schema_name(t_row.slug);
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = v_schema) THEN CONTINUE; END IF;
|
||||
EXECUTE format('UPDATE %I.tabela ...', v_schema);
|
||||
END LOOP;
|
||||
|
||||
Padrão pra função que escreve no schema de OUTRO tenant (notify_user com p_tenant_id, etc.):
|
||||
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||
IF v_schema NOT LIKE 'tenant_%' THEN RETURN; END IF;
|
||||
EXECUTE format('INSERT INTO %I.notifications (...) VALUES ($1, $2, ...)', v_schema)
|
||||
USING ...;
|
||||
|
||||
Lote 4.5 — migração de DADOS (esqueci de avisar primeiro, vai se ferrar)
|
||||
|
||||
ESSE É O ERRO MAIS COMUM: o template clona estrutura, mas você esquece dos DADOS. Depois descobre que
|
||||
tenant_sindspam.os está vazio porque você nunca migrou. Faça uma migration que:
|
||||
|
||||
SET session_replication_role = replica; -- desabilita FK checks
|
||||
DO $$
|
||||
DECLARE
|
||||
tenant_id_target uuid := '...';
|
||||
tenant_schema text := 'tenant_...';
|
||||
tabs text[] := ARRAY[/* lista */];
|
||||
t text;
|
||||
v_cols text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY tabs LOOP
|
||||
-- Lista colunas do schema tenant (sem tenant_id já)
|
||||
SELECT string_agg(quote_ident(column_name), ', ' ORDER BY ordinal_position)
|
||||
INTO v_cols
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = tenant_schema AND table_name = t;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema='public' AND table_name=t AND column_name='tenant_id') THEN
|
||||
EXECUTE format(
|
||||
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I WHERE tenant_id = %L ON CONFLICT DO NOTHING',
|
||||
tenant_schema, t, v_cols, v_cols, t, tenant_id_target);
|
||||
ELSE
|
||||
EXECUTE format(
|
||||
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I ON CONFLICT DO NOTHING',
|
||||
tenant_schema, t, v_cols, v_cols, t);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
-- Reset sequences:
|
||||
FOR r IN SELECT t.table_name, c.column_name FROM information_schema.tables t
|
||||
JOIN information_schema.columns c ON c.table_schema=t.table_schema AND c.table_name=t.table_name
|
||||
WHERE t.table_schema=tenant_schema AND c.data_type='bigint' AND c.column_default LIKE 'nextval(%' LOOP
|
||||
v_seq := pg_get_serial_sequence(format('%I.%I', tenant_schema, r.table_name), r.column_name);
|
||||
EXECUTE format('SELECT setval(%L, COALESCE((SELECT MAX(%I) FROM %I.%I), 0))',
|
||||
v_seq, r.column_name, tenant_schema, r.table_name);
|
||||
END LOOP;
|
||||
SET session_replication_role = origin;
|
||||
|
||||
Lote 5 — DROP CASCADE das tabelas em public
|
||||
|
||||
Só depois de TODAS as funções refatoradas e dados migrados:
|
||||
SET session_replication_role = replica;
|
||||
DO $$ BEGIN
|
||||
FOREACH t IN ARRAY tabs LOOP
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=t) THEN
|
||||
EXECUTE format('DROP TABLE public.%I CASCADE', t);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
SET session_replication_role = origin;
|
||||
|
||||
Limitações conhecidas e workarounds
|
||||
|
||||
1. PostgREST não suporta embed FK cross-schema
|
||||
|
||||
Você vai pagar esse pato. O PostgREST 14.x não consegue resolver embeds tipo db().from('os').select('*,
|
||||
profiles!os_solicitante_profile_id_fkey(nome)') quando os está em tenant_X e profiles em public, mesmo com FK física
|
||||
existindo. Mensagem: PGRST200: Could not find a relationship between 'os' and 'profiles' in the schema cache.
|
||||
|
||||
Solução: helper de "fake embed" no frontend. Crie useProfileEmbed.js:
|
||||
export async function attachProfiles(rows, mappings, columns = 'id, nome, email, role') {
|
||||
if (!rows?.length) return rows;
|
||||
const allIds = new Set();
|
||||
for (const m of mappings) rows.forEach(r => { if (r?.[m.idField]) allIds.add(r[m.idField]); });
|
||||
const { data } = await supabase.from('profiles').select(columns).in('id', [...allIds]);
|
||||
const map = new Map((data || []).map(p => [p.id, p]));
|
||||
return rows.map(r => {
|
||||
const out = { ...r };
|
||||
for (const m of mappings) out[m.alias] = r?.[m.idField] ? map.get(r[m.idField]) || null : null;
|
||||
return out;
|
||||
});
|
||||
}
|
||||
// Variantes: attachProfilesNested(rows, nestedKey, mappings), attachProfilesById(rows, idField, alias)
|
||||
Faz 2 queries + merge em JS. Toda tela que tinha profiles!fkey(...) precisa virar duas queries + attach.
|
||||
|
||||
2. %ROWTYPE de tabelas tenant
|
||||
|
||||
Funções que declaravam v_plano public.convenio_planos%ROWTYPE quebram quando a tabela some do public. Troque por
|
||||
RECORD em todas. Quando precisar retornar tabela (RETURNS os_problemas), troque por RETURNS jsonb e construa via
|
||||
jsonb_build_object(...).
|
||||
|
||||
3. SQL functions com SET search_path TO 'public' declarado
|
||||
|
||||
Algumas funções são LANGUAGE sql com declaração estática SET search_path TO 'public'. Não dá pra usar set_config
|
||||
dinâmico em SQL puro. Converta pra LANGUAGE plpgsql. Atenção: isso exige DROP + CREATE (CREATE OR REPLACE não muda
|
||||
linguagem) → se tiver policy dependendo da função, drope a policy primeiro.
|
||||
|
||||
4. Triggers de notif que filtram cada destinatário
|
||||
|
||||
notify_tenant_admins insere em múltiplos owners via SELECT ... FROM profiles WHERE role IN (...). Pra respeitar
|
||||
preferências individuais, adicione AND public.should_notify(p.id, p_type) no WHERE.
|
||||
|
||||
5. Realtime
|
||||
|
||||
- A tabela notifications_sistema precisa ser adicionada explicitamente à publication: ALTER PUBLICATION
|
||||
supabase_realtime ADD TABLE public.notifications_sistema.
|
||||
- Canais realtime no frontend precisam do schema correto: { event: '*', schema: 'tenant_<slug>', table:
|
||||
'notifications', filter: 'owner_id=eq.X' } — não mais schema: 'public'.
|
||||
|
||||
6. Filtros .eq('tenant_id', X) no frontend
|
||||
|
||||
Após o split, qualquer db().from('tabela_tenant').eq('tenant_id', X) quebra com column tenant_id does not exist — a
|
||||
coluna sumiu. Faça grep e remova esses filtros (o isolamento agora é pelo schema). Mantenha em tabelas que ficam em
|
||||
public (tenant_features, tenant_audit_log, profiles).
|
||||
|
||||
7. session_replication_role na migração de dados
|
||||
|
||||
INSERTs em massa com FKs entre tabelas tenant podem falhar por ordem topológica. SET session_replication_role =
|
||||
replica desabilita checks de FK durante o INSERT. Lembre de voltar pra origin ao final.
|
||||
|
||||
8. Reset de sequences
|
||||
|
||||
Tabelas tenant com id bigint generated by sequence precisam de setval pós-migração — senão próximo INSERT vai colidir
|
||||
com PKs existentes.
|
||||
|
||||
9. Policies que usam funções refatoradas
|
||||
|
||||
unidade_in_current_tenant(uuid) aparecia como USING (...) em policies de public.prestador_unidade_acessos. Antes de
|
||||
DROP+CREATE da função, dropei as 2 policies. Tabelas que vão sumir não precisam recriar policy. Se a função é usada em
|
||||
policies de tabelas que ficam, recrie a policy depois.
|
||||
|
||||
10. FKs de tabelas que ficam em public apontando pras que saem
|
||||
|
||||
Antes de DROP, rode query pra detectar. Se houver, decida: migra a tabela referenciadora pro tenant também, ou
|
||||
converte FK pra coluna solta sem constraint.
|
||||
|
||||
Frontend — refactor sistemático
|
||||
|
||||
1. Find/replace em massa: supabase.from('<lista_tabelas_tenant>') → db().from(...). Importe useTenantDb.
|
||||
2. Caça por .eq('tenant_id': remova nos from('<tenant_table>'), mantenha nos from('<public_table>').
|
||||
3. Caça por embed profiles!fkey(...) em queries de tabelas tenant: refatore com attachProfiles.
|
||||
4. Caça por subscribeRealtime com schema: 'public' pra tabelas que viraram tenant — troque pra schema:
|
||||
tenantSchemaName(slug).
|
||||
5. Composables/serviços que usam supabase.from(...) em vez de db() direto: idem.
|
||||
|
||||
Backups e segurança
|
||||
|
||||
Sempre faça backup antes de cada lote:
|
||||
docker exec supabase_db_<projeto> pg_dump -U postgres -d postgres --schema=public --no-owner --no-acl >
|
||||
backups/pre-loteN/public.sql
|
||||
docker exec supabase_db_<projeto> pg_dump -U postgres -d postgres --schema=tenant_<slug> --no-owner --no-acl >
|
||||
backups/pre-loteN/tenant_<slug>.sql
|
||||
|
||||
Pra recarregar cache do PostgREST após mudanças:
|
||||
docker exec supabase_db_<projeto> psql -U postgres -d postgres -c "NOTIFY pgrst, 'reload schema'"
|
||||
|
||||
Se mudou config.toml (schemas expostos), restart obrigatório:
|
||||
docker restart supabase_rest_<projeto>
|
||||
|
||||
Checklist final por lote
|
||||
|
||||
Antes de marcar um lote como concluído:
|
||||
- Migration aplica sem erro (psql -v ON_ERROR_STOP=1)
|
||||
- Smoke test SQL chamando as funções refatoradas via SET LOCAL request.jwt.claim.sub
|
||||
- NOTIFY pgrst, 'reload schema' rodado
|
||||
- Usuário testou as telas do FE que tocam essas funções
|
||||
- Sem erros novos no console do navegador (network 4xx/5xx, PGRST200, etc.)
|
||||
|
||||
Como interagir comigo durante o trabalho
|
||||
|
||||
- Antes de codar qualquer fase, mostre o plano resumido e pergunte se prossegue.
|
||||
- Para decisões estruturais (ex: notifications split, função X retorna jsonb ou record composto, drop CASCADE de
|
||||
policy órfã), use perguntas múltipla escolha — não decida sozinho.
|
||||
- Ao terminar um lote, sumarize o que mudou + lista de coisas pra eu testar no FE.
|
||||
- Não confie em listas pré-feitas (suas ou do usuário). Sempre re-confirme via query no banco.
|
||||
- Backup antes de cada DROP destrutivo.
|
||||
- PostgREST cache é teimoso — NOTIFY pgrst resolve tabelas/funções; restart do container pra mudanças de config.toml.
|
||||
Reference in New Issue
Block a user