05c6746e33
- 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>
514 lines
28 KiB
PL/PgSQL
514 lines
28 KiB
PL/PgSQL
-- =============================================================================
|
|
-- 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;
|