@fayz-ai/db 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 116_o_sdr_agrupa_a_empresa_que_ele_prospecta.sql — a chave que faltava no
3
+ -- recorte da 071.
4
+ --
5
+ -- ── o buraco ────────────────────────────────────────────────────────────
6
+ --
7
+ -- A 071 desenhou o SDR: leads, funil, orçamento, conversa. Não deu
8
+ -- `crm.companies`, e na época isso parecia certo — prospectar é falar com
9
+ -- gente, não manter cadastro de empresa.
10
+ --
11
+ -- Depois vieram os grupos: raiz de CNPJ (015), bandeira e federação (crm/039).
12
+ -- A proposta é automática e ERRA — é um palpite sobre nome e raiz, e cinco
13
+ -- Abrasel com cinco raízes distintas são a mesma instituição enquanto
14
+ -- `RESTAURANTE` com onze empresas não é parentesco nenhum. Quem vê o erro é
15
+ -- quem trabalha a lista, e quem trabalha a lista é o SDR.
16
+ --
17
+ -- E a tela de Grupos não é uma rota própria: mora DENTRO de Empresas, cuja
18
+ -- rota pede `crm.companies.read`. Sem esta chave o SDR não só não agrupa —
19
+ -- ele não chega à tela. Medido no cluster: 20 papéis `sdr`, nenhum com
20
+ -- qualquer permissão `crm.companies`.
21
+ --
22
+ -- ── por que um arquivo novo e não uma correção na 071 ───────────────────
23
+ --
24
+ -- A 071 já rodou, e está no ledger com o checksum do que rodou. Reescrevê-la
25
+ -- faria a linha do ledger descrever um arquivo que ninguém aplicou — é o
26
+ -- mesmo defeito que o `check-ledger-drift` chama de DRIFT e que faz um
27
+ -- `fayz db apply` parar no meio.
28
+ --
29
+ -- ── `edit` e não `manage` ───────────────────────────────────────────────
30
+ --
31
+ -- Agrupar, desagrupar e corrigir o nome do grupo: sim. Apagar empresa: não.
32
+ -- Uma empresa apagada leva junto os contatos que penduram nela, e isso é
33
+ -- decisão de quem responde pela carteira, não de quem prospecta.
34
+ -- ---------------------------------------------------------------------------
35
+
36
+ -- ── primeiro: `crm.companies` NÃO ESTÁ NO CATÁLOGO ─────────────────────────
37
+ --
38
+ -- Medido antes de escrever isto: `app.permissions` tem 15 chaves `crm.*` —
39
+ -- `leads`, `pipeline` e `quotes`, cinco ações cada — e NENHUMA de `companies`.
40
+ -- Mas a rota de Empresas do plugin-crm pede `crm.companies.read`, e as telas
41
+ -- do app concedem `crm.companies` a owner, gerente e vendas.
42
+ --
43
+ -- Ou seja: a chave é exigida pela rota e concedida pelo app, e o catálogo do
44
+ -- banco nunca soube dela. Conceder a alguém uma chave que o catálogo não tem
45
+ -- é escrever numa linha que ninguém lê — a 071 já se protege disso com o
46
+ -- `WHERE EXISTS` logo abaixo, e é por isso que este arquivo registra antes de
47
+ -- conceder.
48
+ --
49
+ -- As cinco ações, como os irmãos `crm.leads` e `crm.quotes` têm. Registrar
50
+ -- todas mantém o vocabulário parelho; quem recebe cada uma é decisão de papel,
51
+ -- não de catálogo.
52
+ INSERT INTO app.permissions (key, plugin)
53
+ SELECT 'crm.companies.' || a, 'crm'
54
+ FROM unnest(ARRAY['read', 'create', 'edit', 'delete', 'manage']) AS a
55
+ ON CONFLICT (key) DO NOTHING;
56
+
57
+ -- ── depois: o recorte do SDR ───────────────────────────────────────────────
58
+ INSERT INTO app.role_templates (template, role_key, role_name, sort_order, permission)
59
+ SELECT 'generic', 'sdr', 'SDR', 3, p
60
+ FROM unnest(ARRAY['crm.companies.read', 'crm.companies.edit']) AS p
61
+ -- Só o que o catálogo realmente tem: uma chave inventada aqui vira uma
62
+ -- permissão que nenhuma tela consulta — silenciosa, e por isso pior que um
63
+ -- erro. Mesma guarda da 071.
64
+ WHERE EXISTS (SELECT 1 FROM app.permissions perm WHERE perm.key = p)
65
+ ON CONFLICT DO NOTHING;
66
+
67
+ -- ── quem já tem o papel recebe a chave agora ───────────────────────────────
68
+ --
69
+ -- Sem isto a migration só serve a quem nascer depois dela, e os 20 SDR que já
70
+ -- existem seguem sem alcançar a tela — que é exatamente o defeito em aberto.
71
+ DO $$
72
+ DECLARE v_perms integer;
73
+ BEGIN
74
+ INSERT INTO app.role_permissions (tenant_id, role_id, permission)
75
+ SELECT r.tenant_id, r.id, t.permission
76
+ FROM app.roles r
77
+ JOIN app.role_templates t ON t.template = 'generic' AND t.role_key = 'sdr'
78
+ WHERE r.key = 'sdr'
79
+ AND t.permission LIKE 'crm.companies.%'
80
+ ON CONFLICT DO NOTHING;
81
+ GET DIAGNOSTICS v_perms = ROW_COUNT;
82
+
83
+ RAISE NOTICE '116: % permissão(ões) crm.companies concedida(s) a papéis SDR', v_perms;
84
+ END $$;
@@ -0,0 +1,41 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 117_o_instagram_e_um_canal_de_contato.sql
3
+ --
4
+ -- ── por que coluna, e não `custom_fields` ───────────────────────────────
5
+ --
6
+ -- `people` já tem `custom_fields` e `metadata`, e a tentação é pôr o Instagram
7
+ -- num deles para não mexer na espinha. Este repo já pagou por essa escolha: a
8
+ -- importação do Salão Abrasel pôs as 46 respostas do credenciamento em
9
+ -- `custom_fields` e o resultado foram 297 fichas com os dados no banco e três
10
+ -- campos na tela — porque NENHUMA tela de pessoa lê `custom_fields`. Está
11
+ -- escrito no cabeçalho do `import_crm.py`: um dado que o produto não abre não
12
+ -- está importado, está arquivado.
13
+ --
14
+ -- Coluna também é o que torna o campo buscável, exportável e ordenável sem
15
+ -- nenhum trabalho a mais — e um vendedor que preenche um handle quer achar a
16
+ -- ficha por ele depois.
17
+ --
18
+ -- ── e por que na ESPINHA, e não só no CRM ───────────────────────────────
19
+ --
20
+ -- Instagram é canal de contato, do mesmo tipo de `email` e `phone`, que já
21
+ -- moram aqui. Não é um conceito de vendas: a clínica tem o Instagram da
22
+ -- paciente, o salão tem o da cliente, o restaurante tem o do fornecedor.
23
+ -- Pôr no plugin-crm obrigaria toda vertical que quisesse o campo a instalar um
24
+ -- CRM, e a segunda que precisasse criaria a sua própria coluna com outro nome.
25
+ --
26
+ -- ── texto simples, sem CHECK ────────────────────────────────────────────
27
+ --
28
+ -- Quem digita vai escrever `@fulano`, `fulano`, `instagram.com/fulano` e
29
+ -- `https://www.instagram.com/fulano/`. Uma restrição que recusasse três dessas
30
+ -- formas transformaria um campo opcional numa briga — e o vendedor pararia de
31
+ -- preencher, que é o único modo de este campo falhar de verdade.
32
+ --
33
+ -- A normalização acontece na ESCRITA, na tela, onde há quem corrigir. O banco
34
+ -- guarda o que chegou.
35
+ -- ---------------------------------------------------------------------------
36
+
37
+ ALTER TABLE public.people
38
+ ADD COLUMN IF NOT EXISTS instagram text;
39
+
40
+ COMMENT ON COLUMN public.people.instagram IS
41
+ 'O @ da pessoa, preenchido à mão por quem fala com ela. Canal de contato, ao lado de email e phone — não é conceito de vendas (117).';
@@ -0,0 +1,46 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 118_a_view_mascarada_acompanha_a_tabela.sql
3
+ --
4
+ -- ── o que o teste pegou ─────────────────────────────────────────────────
5
+ --
6
+ -- A 117 acrescentou `people.instagram` e `v_people` — a view mascarada — ficou
7
+ -- para trás. O `N16d` reclamou na hora, e o cabeçalho dele diz exatamente por
8
+ -- que a regra existe: uma coluna que existe na tabela e falta na view lê, do
9
+ -- lado do cliente, como "não existe esse campo" — não como um erro (#222).
10
+ --
11
+ -- Foi o teste que achou, não eu. Eu tinha conferido `v_leads` e `v_crm_leads`
12
+ -- e parado ali, satisfeito por ter lembrado das views. `v_people` é uma
13
+ -- terceira, gerada por outro mecanismo, e nenhuma leitura minha do código
14
+ -- teria mostrado isso — `app.masked_views` é um registro, não um `CREATE VIEW`
15
+ -- que se encontra buscando por nome.
16
+ --
17
+ -- ── e `phone_digits` já estava atrás ────────────────────────────────────
18
+ --
19
+ -- `app.masked_views_stale()` aponta duas colunas, não uma: `instagram` (minha)
20
+ -- e `phone_digits`, que entrou antes e passou pelo mesmo buraco. Regenerar
21
+ -- resolve as duas — e esta é a diferença entre corrigir a view à mão e chamar
22
+ -- quem a gera: à mão eu consertaria só a que me incomoda.
23
+ --
24
+ -- ── por isso `masked_view_refresh`, e não um CREATE VIEW ────────────────
25
+ --
26
+ -- A view é DERIVADA: as colunas mascaradas saem de `app.masked_columns` e do
27
+ -- contexto registrado. Escrevê-la à mão aqui congelaria a saída de hoje e a
28
+ -- próxima coluna voltaria a ficar para trás — com a diferença de que, aí, o
29
+ -- gerador e o arquivo discordariam sobre quem manda.
30
+ -- ---------------------------------------------------------------------------
31
+
32
+ DO $$
33
+ DECLARE r record;
34
+ BEGIN
35
+ -- Toda view mascarada atrasada, e não só a de `people`: a regra é "nenhuma
36
+ -- fica para trás", e consertar uma de cada vez é como se acumulam as outras.
37
+ FOR r IN
38
+ SELECT m.table_schema, m.table_name, m.context, m.reference
39
+ FROM app.masked_views m
40
+ JOIN app.masked_views_stale() s ON s.table_name = m.table_name
41
+ LOOP
42
+ PERFORM app.masked_view_refresh(
43
+ format('%I.%I', r.table_schema, r.table_name)::regclass, r.context, r.reference);
44
+ RAISE NOTICE 'db/118: % regenerada', r.table_name;
45
+ END LOOP;
46
+ END $$;
@@ -0,0 +1,155 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 119_a_reuniao_tem_mais_de_uma_pessoa.sql
3
+ --
4
+ -- ── o buraco ────────────────────────────────────────────────────────────
5
+ --
6
+ -- Um compromisso tem UMA pessoa responsável (`appointments.assignee_id`) e UM
7
+ -- cliente (`party_id`). Isso descreve bem um corte de cabelo e uma consulta:
8
+ -- um profissional atende uma pessoa.
9
+ --
10
+ -- Não descreve uma reunião comercial. Ali sentam o vendedor, o dono do
11
+ -- restaurante, às vezes o sócio dele e alguém do time que entra só para a
12
+ -- parte técnica. Hoje não há onde escrever isso, e quem tenta acaba pondo
13
+ -- nome no campo de observações — que ninguém consegue filtrar depois.
14
+ --
15
+ -- ── por que não usar o que já existe ────────────────────────────────────
16
+ --
17
+ -- `appointment_items.assignee_id` aceita N profissionais. Parece servir, e não
18
+ -- serve: cada linha ali é um ITEM DE SERVIÇO, com preço e duração. Pôr uma
19
+ -- pessoa na reunião exigiria inventar um serviço para ela, e um cadastro que
20
+ -- existe só para satisfazer uma validação é pior que o campo que faltava.
21
+ --
22
+ -- Alargar `assignee_id` para uma lista seria o outro caminho, e é o caminho
23
+ -- que quebra todo mundo: toda vertical lê essa coluna esperando uma pessoa.
24
+ --
25
+ -- ── por que isto não muda nada para quem já usa a agenda ────────────────
26
+ --
27
+ -- Tabela NOVA, nenhum ALTER. `appointments` sai daqui com exatamente as
28
+ -- colunas com que entrou, e `agenda_booking_create` não é tocada — acrescentar
29
+ -- parâmetro a uma função no Postgres cria uma SEGUNDA função, e as duas
30
+ -- convivendo deixam a chamada ambígua para todo mundo que já chamava.
31
+ --
32
+ -- Salão, clínica e restaurante nunca inserem uma linha aqui. Uma tabela sem
33
+ -- linhas é invisível: o comportamento deles continua idêntico.
34
+ --
35
+ -- ── espinha, e não plugin ───────────────────────────────────────────────
36
+ --
37
+ -- "Quem está neste compromisso" é pergunta de agenda, não de CRM. A relação é
38
+ -- `appointments` → `people`, as duas da espinha. Um `plg_crm_*` aqui obrigaria
39
+ -- qualquer outra vertical a instalar o CRM para poder convidar alguém.
40
+ --
41
+ -- Idempotente e replay-safe.
42
+ -- ---------------------------------------------------------------------------
43
+
44
+ CREATE TABLE IF NOT EXISTS public.appointment_attendees (
45
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
46
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
47
+ -- `booking_id`, e não `appointment_id`: é o nome que `appointment_items` já
48
+ -- usa para apontar para a mesma tabela. Duas colunas irmãs com nomes
49
+ -- diferentes para a mesma coisa é o tipo de detalhe que faz alguém escrever
50
+ -- o JOIN errado às duas da manhã.
51
+ booking_id uuid NOT NULL REFERENCES public.appointments(id) ON DELETE CASCADE,
52
+ -- CASCADE e não SET NULL: convidado sem pessoa não é convidado, é lixo. Se a
53
+ -- pessoa sai da base, a linha sai junto.
54
+ person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE CASCADE,
55
+ -- `required` | `optional`. Texto livre com CHECK em vez de enum: a agenda do
56
+ -- Google fala a mesma língua, e o dia em que precisarmos de `organizer` a
57
+ -- mudança é um CHECK, não um tipo com dependentes.
58
+ --
59
+ -- NÃO pode se chamar `kind`: esse nome é reservado às colunas de espécie, e
60
+ -- toda coluna `kind` tem que ter um registro `*_kinds` com chave estrangeira
61
+ -- atrás dela (check-kind-registry). Isto aqui não é uma espécie.
62
+ role text NOT NULL DEFAULT 'required',
63
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
64
+ created_at timestamptz NOT NULL DEFAULT now(),
65
+ updated_at timestamptz NOT NULL DEFAULT now(),
66
+ CONSTRAINT appointment_attendees_role_check
67
+ CHECK (role IN ('required', 'optional'))
68
+ );
69
+
70
+ -- A mesma pessoa duas vezes na mesma reunião não quer dizer nada. O formulário
71
+ -- já evita, mas o formulário não é a última palavra: duas abas abertas, um
72
+ -- clique duplo e um retry de rede chegam aqui sem passar por ele.
73
+ CREATE UNIQUE INDEX IF NOT EXISTS appointment_attendees_booking_person_idx
74
+ ON public.appointment_attendees (booking_id, person_id);
75
+
76
+ -- Ler os convidados DE UMA reserva é a única pergunta que a tela faz.
77
+ CREATE INDEX IF NOT EXISTS appointment_attendees_booking_idx
78
+ ON public.appointment_attendees (tenant_id, booking_id);
79
+
80
+ -- "Em que reuniões esta pessoa está" — a pergunta do outro lado, que a tela do
81
+ -- lead vai querer quando alguém abrir o perfil.
82
+ CREATE INDEX IF NOT EXISTS appointment_attendees_person_idx
83
+ ON public.appointment_attendees (tenant_id, person_id);
84
+
85
+ COMMENT ON TABLE public.appointment_attendees IS
86
+ 'Quem mais está no compromisso, além do responsável. `assignee_id` continua sendo UM profissional — convidado é adição, não substituição (119).';
87
+ COMMENT ON COLUMN public.appointment_attendees.role IS
88
+ 'required | optional — quem precisa estar e quem é bem-vindo.';
89
+
90
+ -- ---------------------------------------------------------------------------
91
+ -- O escopo herdado do compromisso
92
+ --
93
+ -- Quem grava um convidado sabe a reunião e a pessoa; não tem por que saber o
94
+ -- tenant nem a unidade. Deixar isso a cargo do cliente é como o `tenant_id`
95
+ -- errado entra numa tabela — e com RLS ligada o erro não aparece como erro,
96
+ -- aparece como linha que sumiu.
97
+ --
98
+ -- Cópia fiel de `trg_appointment_items_inherit_scope`, a irmã ao lado.
99
+ -- ---------------------------------------------------------------------------
100
+
101
+ CREATE OR REPLACE FUNCTION public.trg_appointment_attendees_inherit_scope()
102
+ RETURNS trigger
103
+ LANGUAGE plpgsql
104
+ SECURITY DEFINER
105
+ SET search_path = ''
106
+ AS $$
107
+ BEGIN
108
+ IF NEW.tenant_id IS NULL OR NEW.unit_id IS NULL THEN
109
+ SELECT coalesce(NEW.tenant_id, a.tenant_id), coalesce(NEW.unit_id, a.unit_id)
110
+ INTO NEW.tenant_id, NEW.unit_id
111
+ FROM public.appointments a
112
+ WHERE a.id = NEW.booking_id;
113
+ END IF;
114
+ RETURN NEW;
115
+ END;
116
+ $$;
117
+
118
+ REVOKE ALL ON FUNCTION public.trg_appointment_attendees_inherit_scope() FROM public, anon, authenticated;
119
+
120
+ DROP TRIGGER IF EXISTS appointment_attendees_inherit_scope ON public.appointment_attendees;
121
+ CREATE TRIGGER appointment_attendees_inherit_scope
122
+ BEFORE INSERT ON public.appointment_attendees
123
+ FOR EACH ROW EXECUTE FUNCTION public.trg_appointment_attendees_inherit_scope();
124
+
125
+ -- ---------------------------------------------------------------------------
126
+ -- RLS, políticas, `unit_id`/`owner_id`, índices de escopo e registro
127
+ --
128
+ -- Escrever as quatro políticas à mão aqui seria escrever de novo, pior, o que
129
+ -- `scaffold_table` gera igual para as outras. E as asserções de postura do
130
+ -- pgtap são escritas contra exatamente o que ele produz.
131
+ --
132
+ -- `p_owner_scoping = false`: convidado não tem dono próprio. Quem enxerga a
133
+ -- reunião enxerga quem está nela — esconder metade da sala de quem já está na
134
+ -- sala não protege nada e quebra a tela.
135
+ --
136
+ -- As permissões são as da agenda (`agenda.read/create/edit/delete`), que já
137
+ -- existem no catálogo. Nenhuma chave nova para ninguém conceder.
138
+ -- ---------------------------------------------------------------------------
139
+
140
+ SELECT app.scaffold_table(
141
+ 'public.appointment_attendees'::regclass,
142
+ 'agenda.appointment_attendee',
143
+ 'agenda',
144
+ false,
145
+ 'enforce'
146
+ );
147
+
148
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.appointment_attendees TO authenticated;
149
+ GRANT ALL ON public.appointment_attendees TO service_role;
150
+
151
+ -- A espinha declara o que é dela, para que nenhum plugin crie uma tabela com
152
+ -- este nome mais adiante achando que o terreno estava vazio.
153
+ INSERT INTO app.base_owned_tables (table_name, note)
154
+ VALUES ('appointment_attendees', 'Quem comparece a um Compromisso.')
155
+ ON CONFLICT (table_name) DO NOTHING;
@@ -0,0 +1,76 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 120_o_servico_diz_se_ele_se_agenda.sql — nem todo serviço se marca na agenda.
3
+ -- ---------------------------------------------------------------------------
4
+ -- O formulário de agendamento do V2 oferece o catálogo inteiro. No BeautySoft
5
+ -- ele oferece TRÊS — Avaliação, Revisão e Compra Antecipada —, e a regra está
6
+ -- escrita na própria tela dele:
7
+ --
8
+ -- "Somente Avaliação, Revisão, Compra Antecipada e serviços com contrato
9
+ -- assinado + avaliação vinculada podem ser agendados."
10
+ --
11
+ -- Não é detalhe de estética. É o desenho do negócio: o tratamento é VENDIDO
12
+ -- (um pacote, um contrato), e o que se marca na agenda é o passo do processo —
13
+ -- a consulta que avalia, o retorno que revisa, a entrega do que foi comprado
14
+ -- antes. Oferecer os 122 no agendamento faz a recepção escolher entre "Ácido
15
+ -- Hialurônico" e "Avaliação" como se fossem a mesma coisa, e marcar o
16
+ -- tratamento em vez do passo é como o histórico do cliente deixa de fechar com
17
+ -- o que ele pagou.
18
+ --
19
+ -- ── A forma no V2 ──────────────────────────────────────────────────────────
20
+ -- Uma coluna, não um enum copiado do V1. `service_kind` de lá tem quatro
21
+ -- valores dos quais três respondem à MESMA pergunta ("isto se marca?"), e o
22
+ -- quarto — o serviço com contrato assinado + avaliação vinculada — depende de
23
+ -- um contrato que o V2 ainda não modela. Um enum importado inteiro carregaria
24
+ -- uma classificação que ninguém aqui sabe ler.
25
+ --
26
+ -- `is_bookable` é a pergunta, e é a MESMA palavra que `people.is_bookable` já
27
+ -- usa para separar quem atende de quem trabalha na casa. Duas tabelas, uma
28
+ -- pergunta, um nome.
29
+ --
30
+ -- ── Nasce TRUE, e isso é deliberado ────────────────────────────────────────
31
+ -- Uma conta que nunca pensou no assunto continua marcando o que sempre marcou.
32
+ -- A regra do ByDesign: pergunta não respondida cai no padrão, não em "não" —
33
+ -- e o contrário esvaziaria o agendamento de toda conta no dia do deploy.
34
+ --
35
+ -- Quem vem do V1 responde na importação (`services.service_kind`) ou na tela
36
+ -- de Serviços, no interruptor "Aparece na agenda".
37
+ --
38
+ -- Idempotente.
39
+ -- ---------------------------------------------------------------------------
40
+
41
+ -- ── Onde a coluna mora ─────────────────────────────────────────────────────
42
+ -- Em `products`. A 191 aposentou o espelho `public.services` e um serviço
43
+ -- passou a ser produto de `kind='service'` — é de lá que o SDK lê, e medido no
44
+ -- cluster do salão os dois lados batem (122 e 122).
45
+ --
46
+ -- Nos pools que ainda têm a tabela antiga viva, a coluna vai nos DOIS: a tela
47
+ -- de Serviços lê o espelho, e uma coluna que só existe de um lado seria um
48
+ -- interruptor que se desliga e volta sozinho.
49
+ ALTER TABLE public.products
50
+ ADD COLUMN IF NOT EXISTS is_bookable boolean NOT NULL DEFAULT true;
51
+
52
+ COMMENT ON COLUMN public.products.is_bookable IS
53
+ 'Este serviço pode ser MARCADO na agenda. Falso = vendido, não agendado (o '
54
+ 'tratamento que se compra em pacote; o que se marca é a avaliação, a revisão '
55
+ 'ou a entrega). Espelha o `service_kind` do V1. Só significa algo para '
56
+ 'kind=service. Padrão true.';
57
+
58
+ CREATE INDEX IF NOT EXISTS products_bookable_idx
59
+ ON public.products (tenant_id, is_bookable)
60
+ WHERE is_bookable;
61
+
62
+ DO $$
63
+ DECLARE
64
+ -- O oid guardado, e não `'public.services'::regclass` dentro do IF: o cast
65
+ -- LANÇA quando a relação não existe, e o `AND` não o protege — o planejador
66
+ -- avalia a subconsulta antes de decidir. Num banco do zero (sem o espelho
67
+ -- legado) isto derrubava a migration inteira com "relation does not exist",
68
+ -- que é exatamente o caso que o guarda existia para cobrir.
69
+ v_oid oid := to_regclass('public.services');
70
+ BEGIN
71
+ IF v_oid IS NOT NULL
72
+ AND (SELECT relkind FROM pg_class WHERE oid = v_oid) = 'r' THEN
73
+ EXECUTE 'ALTER TABLE public.services ADD COLUMN IF NOT EXISTS is_bookable boolean NOT NULL DEFAULT true';
74
+ EXECUTE 'CREATE INDEX IF NOT EXISTS services_bookable_idx ON public.services (tenant_id, is_bookable) WHERE is_bookable';
75
+ END IF;
76
+ END $$;
@@ -0,0 +1,109 @@
1
+ -- A liberação pergunta uma vez, e não cento e vinte mil.
2
+ --
3
+ -- Qualquer lista de Pessoas do Espaço Facial levava ~6,4 s e às vezes estourava
4
+ -- o `statement_timeout` — a tela voltava vazia, que é como o PostgREST entrega
5
+ -- o 57014. Medido no cluster, como `authenticated`, tenant Espaço Facial:
6
+ --
7
+ -- select count(*) from people where kind='customer' 6.206 ms 331.301 buffers
8
+ -- lista com name ilike '%test%', limit 20 6.459 ms 336.521 buffers
9
+ --
10
+ -- ── Não era a busca ─────────────────────────────────────────────────────────
11
+ --
12
+ -- O primeiro suspeito é o `ilike '%test%'`: curinga à esquerda, e a suspeita é
13
+ -- razoável. Mas o `count(*)` acima não tem busca nenhuma e custa o MESMO. O que
14
+ -- os dois têm em comum é a política.
15
+ --
16
+ -- (Sobre o ILIKE, de passagem, porque ele volta: com RLS ligada ele NUNCA vai
17
+ -- virar condição de índice. `texticlike` não é `leakproof`, e um qual que não é
18
+ -- leakproof não desce abaixo da barreira de segurança — então o índice trigrama
19
+ -- `people_name_trgm_idx`, que sozinho responde em 13 ms, fica de fora por
20
+ -- construção. Isso não é um defeito a corrigir aqui; é o preço da barreira.)
21
+ --
22
+ -- ── Era esta linha ──────────────────────────────────────────────────────────
23
+ --
24
+ -- A política `people_available_in` (migration 087) tem quatro cláusulas em OU, e
25
+ -- a terceira é:
26
+ --
27
+ -- NOT app.has_release_tuple('people.person', id)
28
+ --
29
+ -- `has_release_tuple` é barata: uma função SQL simples, inlinável, que faz um
30
+ -- EXISTS num índice. O 087 a escreveu assim de propósito, porque a versão
31
+ -- anterior era SECURITY DEFINER e o Postgres não inlina essas — cada linha
32
+ -- pagava a montagem de um executor inteiro.
33
+ --
34
+ -- O que o 087 não viu é que barata **vezes cento e vinte mil** não é barata. A
35
+ -- cláusula só é alcançada quando as duas anteriores falham, e no Espaço Facial
36
+ -- elas falham SEMPRE:
37
+ --
38
+ -- pessoas no tenant 120.808
39
+ -- com unit_id NULL 120.455 (99,7%)
40
+ -- grants de people.person 1.446 (grants_exist → verdadeiro)
41
+ --
42
+ -- Cliente de salão não mora numa filial — ela é da rede, e o V1 nem tem coluna
43
+ -- de unidade em `clients`. Então `unit_id IS NOT NULL` é falso, e a sonda ao
44
+ -- índice roda uma vez por linha: 2,8 buffers × 120 mil = os 331.301 do plano.
45
+ --
46
+ -- ── A correção ──────────────────────────────────────────────────────────────
47
+ --
48
+ -- A mesma pergunta, feita UMA vez. `NOT IN` sobre uma subconsulta NÃO
49
+ -- correlacionada é resolvida como *hashed SubPlan*: o Postgres lê as 1.446
50
+ -- linhas de grant, monta uma tabela hash e depois cada pessoa custa uma sondagem
51
+ -- de hash em vez de uma descida de índice.
52
+ --
53
+ -- É o mesmo movimento que o funil do CRM já tinha feito por outro motivo: o que
54
+ -- é correlacionado não desce para dentro da barreira, e o que não é, desce uma
55
+ -- vez só.
56
+ --
57
+ -- select count(*) ... 6.206 ms → 115 ms (54×)
58
+ -- lista com ilike, limit 20 6.459 ms → 467 ms (14×)
59
+ --
60
+ -- O resultado é o MESMO — as duas formas devolvem 119.518 linhas. A quarta
61
+ -- cláusula (`available_in_my_units`) continua depois do NOT IN e é ela que
62
+ -- deixa passar quem tem tupla de liberação para a unidade de quem lê.
63
+ --
64
+ -- `resource_id IS NOT NULL` não é decoração: `NOT IN` com um NULO na lista
65
+ -- devolve NULO para todo mundo, e NULO numa política esconde a linha. Sem essa
66
+ -- cláusula, um grant órfão apagaria a agenda inteira de todo mundo.
67
+ --
68
+ -- ── E o índice da lista ─────────────────────────────────────────────────────
69
+ --
70
+ -- Com a política corrigida, o que sobra é a varredura ordenada por nome, e ela
71
+ -- volta ao heap só para ler `is_active`, `unit_id`, `owner_id` e `id`. Pendurar
72
+ -- essas quatro no índice que já existe transforma o `count` num Index Only Scan
73
+ -- (255 ms → 115 ms). A chave não muda — é o mesmo índice, com carga.
74
+ --
75
+ -- Idempotente.
76
+
77
+ ALTER POLICY people_available_in ON public.people USING (
78
+ (NOT (SELECT app.grants_exist('people.person', ARRAY['available_in', 'unavailable_in'])))
79
+ OR (unit_id IS NOT NULL)
80
+ -- A pergunta de sempre, resolvida uma vez: "esta pessoa tem tupla de
81
+ -- liberação?". Não correlacionada de propósito — é isso que a torna um hash.
82
+ OR (people.id NOT IN (
83
+ SELECT g.resource_id
84
+ FROM app.resource_grants g
85
+ WHERE g.tenant_id = (SELECT app.current_tenant_id())
86
+ AND g.resource_type = 'people.person'
87
+ AND g.relation IN ('available_in', 'unavailable_in')
88
+ AND g.resource_id IS NOT NULL))
89
+ OR (SELECT app.available_in_my_units('people.person', people.id)));
90
+
91
+ -- Um índice novo em `resource_grants` foi tentado aqui e NÃO entrou. A ideia era
92
+ -- ler por recurso em vez de por sujeito, porque o plano gasta 921 buffers para
93
+ -- devolver 1.446 linhas. Medido: o planejador continuou escolhendo
94
+ -- `resource_grants_subject_idx` — cuja chave já começa por
95
+ -- (tenant_id, …, relation, resource_type) — e a subconsulta custa 0,95 ms de um
96
+ -- total de 130. Índice que o planejador não escolhe é peso de escrita sem
97
+ -- leitura, e fica registrado aqui em vez de no catálogo.
98
+
99
+ -- A lista de pessoas: mesma chave do índice que já existia, com as quatro
100
+ -- colunas que a política lê penduradas. O antigo sai depois do novo entrar,
101
+ -- para nenhum plano ficar sem índice no meio.
102
+ CREATE INDEX IF NOT EXISTS people_tenant_kind_name_cover_idx
103
+ ON public.people USING btree (tenant_id, kind, name)
104
+ INCLUDE (id, is_active, unit_id, owner_id);
105
+
106
+ DROP INDEX IF EXISTS public.people_tenant_kind_name_idx;
107
+
108
+ COMMENT ON INDEX public.people_tenant_kind_name_cover_idx IS
109
+ 'A lista de Pessoas por tipo, em ordem de nome. As colunas penduradas são as que people_available_in e people_authz_select leem — sem elas o count volta ao heap linha a linha (121).';
@@ -0,0 +1,80 @@
1
+ -- O e-mail da pessoa é um e-mail, não uma lista.
2
+ --
3
+ -- A ficha do profissional mostrava, no campo E-mail:
4
+ --
5
+ -- ["marataizes@espacofacial.com.br"]
6
+ --
7
+ -- e, no Telefone, `[]`. Não é defeito de tela: `people.email` e `people.phone`
8
+ -- são `text`, e o porte do V1 gravou ali o JSON do array que o V1 mantém —
9
+ -- lá uma pessoa pode ter vários e-mails, aqui a coluna guarda um.
10
+ --
11
+ -- Medido em 21/09/2026 (klvfxzreepavcpyjiwla, 123.484 pessoas, 25 contas):
12
+ --
13
+ -- e-mail em forma de array 456 (451 com um valor, 4 vazios, 1 com dois)
14
+ -- telefone em forma de array 277 (233 com um valor, 43 vazios, 1 com dois)
15
+ --
16
+ -- ── Por que reparar o dado e não formatar na tela ──────────────────────────
17
+ --
18
+ -- Um `JSON.parse` na hora de desenhar esconderia o problema nos lugares que
19
+ -- ainda não existem: a busca por e-mail não acha `["x@y"]` procurando `x@y`, o
20
+ -- disparo de mensagem manda para um endereço com colchete, e o `unique` que um
21
+ -- dia entrar nessa coluna vai tratar `x@y` e `["x@y"]` como pessoas diferentes.
22
+ -- Campo estranho na tela é sintoma; a cura é onde o dado foi escrito.
23
+ --
24
+ -- ── O que sobra não se perde ───────────────────────────────────────────────
25
+ --
26
+ -- O primeiro valor sobe para a coluna; os demais descem para
27
+ -- `metadata.contact.extra_emails` / `extra_phones`. São dois registros no
28
+ -- cluster inteiro, mas apagar o segundo e-mail de alguém para deixar a coluna
29
+ -- bonita é o tipo de perda que só aparece quando a pessoa não recebe o aviso.
30
+ --
31
+ -- Array vazio vira NULL: `[]` na tela diz que existe um telefone e não diz
32
+ -- qual; vazio diz a verdade.
33
+
34
+ DO $$
35
+ DECLARE
36
+ v_emails integer := 0;
37
+ v_phones integer := 0;
38
+ BEGIN
39
+ -- Guarda o que exceder ANTES de sobrescrever a coluna.
40
+ UPDATE public.people p
41
+ SET metadata = jsonb_set(
42
+ coalesce(p.metadata, '{}'::jsonb),
43
+ '{contact,extra_emails}',
44
+ (SELECT jsonb_agg(v) FROM jsonb_array_elements(p.email::jsonb) WITH ORDINALITY t(v, i) WHERE i > 1),
45
+ true)
46
+ WHERE p.email ~ '^\[.*\]$'
47
+ AND jsonb_typeof(p.email::jsonb) = 'array'
48
+ AND jsonb_array_length(p.email::jsonb) > 1;
49
+
50
+ UPDATE public.people p
51
+ SET metadata = jsonb_set(
52
+ coalesce(p.metadata, '{}'::jsonb),
53
+ '{contact,extra_phones}',
54
+ (SELECT jsonb_agg(v) FROM jsonb_array_elements(p.phone::jsonb) WITH ORDINALITY t(v, i) WHERE i > 1),
55
+ true)
56
+ WHERE p.phone ~ '^\[.*\]$'
57
+ AND jsonb_typeof(p.phone::jsonb) = 'array'
58
+ AND jsonb_array_length(p.phone::jsonb) > 1;
59
+
60
+ -- O primeiro valor vira o valor. `#>>'{0}'` devolve NULL no array vazio, que
61
+ -- é exatamente o que se quer guardar ali.
62
+ UPDATE public.people p
63
+ SET email = nullif(btrim(p.email::jsonb #>> '{0}'), '')
64
+ WHERE p.email ~ '^\[.*\]$'
65
+ AND jsonb_typeof(p.email::jsonb) = 'array';
66
+ GET DIAGNOSTICS v_emails = ROW_COUNT;
67
+
68
+ UPDATE public.people p
69
+ SET phone = nullif(btrim(p.phone::jsonb #>> '{0}'), '')
70
+ WHERE p.phone ~ '^\[.*\]$'
71
+ AND jsonb_typeof(p.phone::jsonb) = 'array';
72
+ GET DIAGNOSTICS v_phones = ROW_COUNT;
73
+
74
+ RAISE NOTICE 'people: % e-mail(s) e % telefone(s) saíram da forma de lista', v_emails, v_phones;
75
+ END $$;
76
+
77
+ COMMENT ON COLUMN public.people.email IS
78
+ 'UM e-mail. O V1 guarda lista; o porte tem de escolher o principal e mandar o resto para metadata.contact.extra_emails (ver migration 122).';
79
+ COMMENT ON COLUMN public.people.phone IS
80
+ 'UM telefone. Mesma regra do e-mail — o excedente vai para metadata.contact.extra_phones (ver migration 122).';