@fayz-ai/db 0.16.0 → 0.17.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;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "fayz": {
4
4
  "status": "beta"
5
5
  },
6
- "version": "0.16.0",
6
+ "version": "0.17.0",
7
7
  "description": "Fayz SDK database layer — Drizzle schema primitives, spine references, and migration helpers shared across plugins.",
8
8
  "type": "module",
9
9
  "sideEffects": false,