@fayz-ai/db 0.14.0 → 0.15.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.
- package/migrations/011_leadcontrol_joins_the_family.sql +5 -1
- package/migrations/029_o_administrador_enxerga_o_que_existe.sql +167 -0
- package/migrations/031_ticketcontrol_joins_the_family.sql +109 -0
- package/migrations/032_a_marca_e_um_conjunto_de_tokens.sql +291 -0
- package/migrations/041_a_entrega_tem_onde_guardar_a_politica.sql +53 -0
- package/migrations/042_o_token_de_marca_fecha_a_porta_anon.sql +17 -0
- package/migrations/043_fiscalcontrol_joins_the_family.sql +75 -0
- package/migrations/044_o_teste_gratis_dura_trinta_dias.sql +15 -0
- package/migrations/046_the_tick_advances_the_flows.sql +77 -0
- package/migrations/047_the_tick_sweeps_the_offers.sql +74 -0
- package/migrations/052_fullcontrol_joins_the_family.sql +213 -0
- package/migrations/053_o_erp_para_de_exigir_o_que_nao_desenha.sql +37 -0
- package/package.json +1 -1
|
@@ -52,7 +52,11 @@
|
|
|
52
52
|
-- publishes these apps elsewhere updates the row; nothing in code hardcodes a
|
|
53
53
|
-- port.
|
|
54
54
|
INSERT INTO app.apps (id, name, icon, accent_color, url, requires, serves_verticals) VALUES
|
|
55
|
-
|
|
55
|
+
-- Megafone e não alvo: o alvo diz PONTARIA — a casa mira e acerta uma pessoa.
|
|
56
|
+
-- O megafone diz ALCANCE: a casa fala e muita gente ouve. É o que este app
|
|
57
|
+
-- faz — origem, segmento, campanha e porta pública existem para a casa ser
|
|
58
|
+
-- OUVIDA, e o lead é a resposta de quem ouviu.
|
|
59
|
+
('crm', 'LeadControl', 'Megaphone', '#2563EB', 'http://localhost:5307',
|
|
56
60
|
ARRAY['crm', 'conversations', 'marketing', 'automations'],
|
|
57
61
|
ARRAY[]::text[])
|
|
58
62
|
ON CONFLICT (id) DO UPDATE
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
-- 029_o_administrador_enxerga_o_que_existe.sql
|
|
2
|
+
--
|
|
3
|
+
-- O rail do LeadControl abria com duas linhas — Painel e Análises — enquanto o
|
|
4
|
+
-- painel logo abaixo mostrava "Total de Leads: 20". Os dados estavam lá, os
|
|
5
|
+
-- plugins montados, o console limpo. Só o MENU estava vazio.
|
|
6
|
+
--
|
|
7
|
+
-- A causa não é do app. É esta assimetria, no `app.seed_role_template`:
|
|
8
|
+
--
|
|
9
|
+
-- -- o Owner recebe o CATÁLOGO, e o catálogo cresce sozinho
|
|
10
|
+
-- INSERT INTO app.role_permissions
|
|
11
|
+
-- SELECT p_tenant, v_owner, p.key FROM app.permissions p;
|
|
12
|
+
--
|
|
13
|
+
-- -- todos os outros recebem uma LISTA, escrita à mão, que não cresce
|
|
14
|
+
-- INSERT INTO app.role_permissions
|
|
15
|
+
-- SELECT p_tenant, r.id, t.permission FROM app.role_templates t ...
|
|
16
|
+
--
|
|
17
|
+
-- Um plugin publicado depois que aquela lista foi escrita registra as suas
|
|
18
|
+
-- permissões em `app.permissions` e, a partir daí, chega ao Owner de graça e
|
|
19
|
+
-- **a mais ninguém, nunca**. Não há aviso: `AdminShell` esconde a linha do rail
|
|
20
|
+
-- quando a negação é por PAPEL (negação por PLANO manteria a linha com a coroa),
|
|
21
|
+
-- então o sintoma é um menu curto, que parece escolha de produto.
|
|
22
|
+
--
|
|
23
|
+
-- Medido no cluster antes desta migration, em 10 tenants:
|
|
24
|
+
--
|
|
25
|
+
-- Administrator/Manager que enxergam crm.* 0 de 10
|
|
26
|
+
-- Administrator/Manager que enxergam conversations.* 0 de 10
|
|
27
|
+
--
|
|
28
|
+
-- E não são só esses dois. Comparando Owner com Administrator num tenant real,
|
|
29
|
+
-- faltavam ao Administrator DEZENOVE features: appointments, automations, blog,
|
|
30
|
+
-- config, conversations, courses, crm, custom_forms, dashboard, marketing,
|
|
31
|
+
-- menu, reputation, sales, scribe, shop, sites, tables e mais. Ou seja: quase
|
|
32
|
+
-- todo plugin que o SDK ganhou desde que a lista foi escrita.
|
|
33
|
+
--
|
|
34
|
+
-- Por isso a correção NÃO é acrescentar quatro linhas à lista. Acrescentar
|
|
35
|
+
-- quatro linhas conserta a queixa de hoje e reprograma a mesma queixa para o
|
|
36
|
+
-- próximo plugin. A correção é tirar o Administrador da lista e colocá-lo no
|
|
37
|
+
-- CATÁLOGO, como o Owner — menos o que é do dono.
|
|
38
|
+
--
|
|
39
|
+
-- O que continua sendo lista, de propósito: manager, staff e viewer. Esses são
|
|
40
|
+
-- recortes deliberados — um "Viewer" que enxerga tudo o que existe deixou de
|
|
41
|
+
-- ser viewer. Eles vão continuar precisando de decisão a cada plugin novo; a
|
|
42
|
+
-- diferença é que agora ALGUÉM abaixo do dono enxerga o plugin no dia em que
|
|
43
|
+
-- ele registra as permissões, e o buraco aparece na hora em vez de meses depois.
|
|
44
|
+
|
|
45
|
+
-- ── o que é do dono ────────────────────────────────────────────────────────
|
|
46
|
+
--
|
|
47
|
+
-- Precisa ser uma marca no catálogo, não uma lista no meio de uma função: quem
|
|
48
|
+
-- registra uma permissão nova é o plugin, e é ele que sabe se ela é do dono.
|
|
49
|
+
ALTER TABLE app.permissions
|
|
50
|
+
ADD COLUMN IF NOT EXISTS owner_only boolean DEFAULT false NOT NULL;
|
|
51
|
+
|
|
52
|
+
COMMENT ON COLUMN app.permissions.owner_only IS
|
|
53
|
+
'Permissão que só o Owner recebe. O Administrador recebe todo o resto do catálogo automaticamente (029).';
|
|
54
|
+
|
|
55
|
+
-- Assinatura, cartão e cancelamento são do dono. E `admin.*` é o painel da
|
|
56
|
+
-- PLATAFORMA — não é papel de tenant nenhum, nem do dono da loja.
|
|
57
|
+
--
|
|
58
|
+
-- `owner_only` governa o que se CONCEDE daqui para frente; não revoga o que já
|
|
59
|
+
-- foi concedido. Um Administrador que hoje tem `billing.read` porque a lista
|
|
60
|
+
-- antiga o deu continua tendo: tirar permissão de quem já trabalha com ela é
|
|
61
|
+
-- uma quebra, e esta migration veio consertar um menu vazio, não estreitar
|
|
62
|
+
-- ninguém.
|
|
63
|
+
UPDATE app.permissions SET owner_only = true
|
|
64
|
+
WHERE key ~ '^(billing|admin)\.' AND owner_only = false;
|
|
65
|
+
|
|
66
|
+
-- ── o administrador passa a ler o catálogo ─────────────────────────────────
|
|
67
|
+
CREATE OR REPLACE FUNCTION app.seed_role_template(p_tenant uuid, p_template text DEFAULT 'generic'::text) RETURNS uuid
|
|
68
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
69
|
+
SET search_path TO ''
|
|
70
|
+
AS $$
|
|
71
|
+
DECLARE v_owner uuid;
|
|
72
|
+
BEGIN
|
|
73
|
+
IF NOT EXISTS (SELECT 1 FROM app.role_templates WHERE template = p_template) THEN
|
|
74
|
+
RAISE EXCEPTION 'seed_role_template: unknown template %', p_template;
|
|
75
|
+
END IF;
|
|
76
|
+
|
|
77
|
+
INSERT INTO app.roles (tenant_id, key, name, is_system)
|
|
78
|
+
VALUES (p_tenant, 'owner', 'Owner', true)
|
|
79
|
+
ON CONFLICT (tenant_id, key) DO NOTHING;
|
|
80
|
+
SELECT id INTO v_owner FROM app.roles WHERE tenant_id = p_tenant AND key = 'owner';
|
|
81
|
+
|
|
82
|
+
INSERT INTO app.role_permissions (tenant_id, role_id, permission)
|
|
83
|
+
SELECT p_tenant, v_owner, p.key FROM app.permissions p
|
|
84
|
+
ON CONFLICT DO NOTHING;
|
|
85
|
+
|
|
86
|
+
INSERT INTO app.roles (tenant_id, key, name, is_system)
|
|
87
|
+
SELECT DISTINCT ON (t.role_key) p_tenant, t.role_key, t.role_name, true
|
|
88
|
+
FROM app.role_templates t WHERE t.template = p_template
|
|
89
|
+
ORDER BY t.role_key
|
|
90
|
+
ON CONFLICT (tenant_id, key) DO NOTHING;
|
|
91
|
+
|
|
92
|
+
INSERT INTO app.role_permissions (tenant_id, role_id, permission)
|
|
93
|
+
SELECT p_tenant, r.id, t.permission
|
|
94
|
+
FROM app.role_templates t
|
|
95
|
+
JOIN app.roles r ON r.tenant_id = p_tenant AND r.key = t.role_key
|
|
96
|
+
WHERE t.template = p_template
|
|
97
|
+
ON CONFLICT DO NOTHING;
|
|
98
|
+
|
|
99
|
+
-- A linha que faltava. O papel `admin` ganha o catálogo menos o que é do
|
|
100
|
+
-- dono, DEPOIS da lista — o que a lista já deu continua dado, e o que ela
|
|
101
|
+
-- esqueceu deixa de importar. Só age se o template realmente tiver um papel
|
|
102
|
+
-- `admin`: um template sem administrador não passa a ter um por isto.
|
|
103
|
+
INSERT INTO app.role_permissions (tenant_id, role_id, permission)
|
|
104
|
+
SELECT p_tenant, r.id, p.key
|
|
105
|
+
FROM app.roles r
|
|
106
|
+
CROSS JOIN app.permissions p
|
|
107
|
+
WHERE r.tenant_id = p_tenant AND r.key = 'admin' AND p.owner_only = false
|
|
108
|
+
ON CONFLICT DO NOTHING;
|
|
109
|
+
|
|
110
|
+
RETURN v_owner;
|
|
111
|
+
END $$;
|
|
112
|
+
|
|
113
|
+
COMMENT ON FUNCTION app.seed_role_template(uuid, text) IS
|
|
114
|
+
'Semeia os papéis do template. Owner e Administrador leem o CATÁLOGO (o admin sem o que é do dono); manager/staff/viewer seguem sendo recortes deliberados (029).';
|
|
115
|
+
|
|
116
|
+
-- ── as casas que já nasceram cegas ─────────────────────────────────────────
|
|
117
|
+
--
|
|
118
|
+
-- A função acima conserta quem nascer daqui para frente. Os dez tenants que já
|
|
119
|
+
-- existem precisam da mesma conta, aplicada agora.
|
|
120
|
+
DO $$
|
|
121
|
+
DECLARE v_admin integer; v_mgr integer;
|
|
122
|
+
BEGIN
|
|
123
|
+
INSERT INTO app.role_permissions (tenant_id, role_id, permission)
|
|
124
|
+
SELECT r.tenant_id, r.id, p.key
|
|
125
|
+
FROM app.roles r
|
|
126
|
+
CROSS JOIN app.permissions p
|
|
127
|
+
WHERE r.key = 'admin' AND p.owner_only = false
|
|
128
|
+
ON CONFLICT DO NOTHING;
|
|
129
|
+
GET DIAGNOSTICS v_admin = ROW_COUNT;
|
|
130
|
+
|
|
131
|
+
-- O gerente é recorte, não catálogo: ele opera a casa, não a administra.
|
|
132
|
+
-- Recebe ler/criar/editar do que o LeadControl põe no rail, e não recebe
|
|
133
|
+
-- apagar nem gerir — apagar conversa e gerir integração continuam acima
|
|
134
|
+
-- dele. `regexp_replace` pega o último segmento porque as chaves têm dois
|
|
135
|
+
-- formatos no catálogo: `sales.read` e `crm.leads.read`.
|
|
136
|
+
INSERT INTO app.role_permissions (tenant_id, role_id, permission)
|
|
137
|
+
SELECT r.tenant_id, r.id, p.key
|
|
138
|
+
FROM app.roles r
|
|
139
|
+
CROSS JOIN app.permissions p
|
|
140
|
+
WHERE r.key = 'manager'
|
|
141
|
+
AND p.owner_only = false
|
|
142
|
+
AND p.key ~ '^(crm|sales|conversations|marketing|automations)\.'
|
|
143
|
+
AND regexp_replace(p.key, '^.*\.', '') IN ('read', 'create', 'edit')
|
|
144
|
+
ON CONFLICT DO NOTHING;
|
|
145
|
+
GET DIAGNOSTICS v_mgr = ROW_COUNT;
|
|
146
|
+
|
|
147
|
+
RAISE NOTICE '029: % permissões devolvidas ao Administrador, % ao Gerente', v_admin, v_mgr;
|
|
148
|
+
END $$;
|
|
149
|
+
|
|
150
|
+
-- E o mesmo recorte entra no TEMPLATE do gerente, para o próximo tenant nascer
|
|
151
|
+
-- com ele. O administrador não precisa de linha aqui: ele passou a ler o
|
|
152
|
+
-- catálogo, e uma linha de template para ele seria a lista voltando pela porta
|
|
153
|
+
-- dos fundos.
|
|
154
|
+
INSERT INTO app.role_templates (template, role_key, role_name, sort_order, permission)
|
|
155
|
+
SELECT m.template, 'manager', m.role_name, m.sort_order, p.key
|
|
156
|
+
FROM (SELECT DISTINCT ON (template) template, role_name, sort_order
|
|
157
|
+
FROM app.role_templates WHERE role_key = 'manager'
|
|
158
|
+
ORDER BY template) m
|
|
159
|
+
CROSS JOIN app.permissions p
|
|
160
|
+
WHERE p.owner_only = false
|
|
161
|
+
AND p.key ~ '^(crm|sales|conversations|marketing|automations)\.'
|
|
162
|
+
AND regexp_replace(p.key, '^.*\.', '') IN ('read', 'create', 'edit')
|
|
163
|
+
-- NOT EXISTS em vez de ON CONFLICT: esta tabela é um catálogo sem chave
|
|
164
|
+
-- única declarada, e ali ON CONFLICT DO NOTHING não protege de nada.
|
|
165
|
+
AND NOT EXISTS (
|
|
166
|
+
SELECT 1 FROM app.role_templates x
|
|
167
|
+
WHERE x.template = m.template AND x.role_key = 'manager' AND x.permission = p.key);
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 031_ticketcontrol_joins_the_family.sql — a casa de shows ganha uma linha.
|
|
3
|
+
--
|
|
4
|
+
-- O registro tem seis produtos: quatro TRADES (cozinha, loja, estúdio, escola),
|
|
5
|
+
-- uma FUNÇÃO que transbordou de aba para app (LeadControl, 011) e o painel da
|
|
6
|
+
-- própria casa (control-admin, 028). Este é o sétimo, e é um trade — mas um
|
|
7
|
+
-- trade que o ChefControl já toca pela metade.
|
|
8
|
+
--
|
|
9
|
+
-- ── Por que não é uma aba do ChefControl ───────────────────────────────────
|
|
10
|
+
-- Um bar que faz evento já cadastra o cardápio, o estoque e o caixa no Chef. O
|
|
11
|
+
-- que ele NÃO tem lá é o evento: a noite com data, capacidade por setor, lote
|
|
12
|
+
-- que vira lote, ingresso que precisa ser recusado na porta quando já entrou.
|
|
13
|
+
-- Isso não é uma tela a mais num app de restaurante — é outro turno, outra
|
|
14
|
+
-- equipe (guichê, portaria, promoter) e outro fechamento.
|
|
15
|
+
--
|
|
16
|
+
-- E é justamente por serem o MESMO TENANT que os dois convivem: `people`,
|
|
17
|
+
-- `orders`, `items` e o financeiro são os mesmos dos dois lados do switcher. A
|
|
18
|
+
-- pessoa que comprou o ingresso é a que abre comanda no bar, na mesma linha.
|
|
19
|
+
-- Nenhum concorrente brasileiro entrega isso porque nenhum é dono dos dois
|
|
20
|
+
-- lados: a bilheteria (Sympla, Ingresse) larga na porta, e o cashless (Zig)
|
|
21
|
+
-- começa nela.
|
|
22
|
+
--
|
|
23
|
+
-- ── `serves_verticals` fica VAZIO, de propósito ────────────────────────────
|
|
24
|
+
-- A 010 fez dessa coluna o gatilho de semeadura automática: um tenant que
|
|
25
|
+
-- declara um ramo ganha todo app que serve aquele ramo. Listar 'food' e
|
|
26
|
+
-- 'restaurant' aqui seria quase verdade — muito bar faz evento — e significaria
|
|
27
|
+
-- que TODO restaurante criado a partir de amanhã cresceria um segundo produto
|
|
28
|
+
-- no switcher que ninguém pediu e ninguém configurou. Um app que chega sem ser
|
|
29
|
+
-- convidado não é uma funcionalidade, é um chamado de suporte sobre uma tela
|
|
30
|
+
-- vazia. Este é opt-in: `tenant_app_set('ticket', ...)`.
|
|
31
|
+
--
|
|
32
|
+
-- ── `requires` nomeia cinco plugins ────────────────────────────────────────
|
|
33
|
+
-- `menu` e `inventory` são o bar; `orders` é o pedido (do ingresso E da
|
|
34
|
+
-- comanda, na mesma tabela); `financial` é o caixa onde a noite fecha;
|
|
35
|
+
-- `conversations` é por onde o público pergunta a hora de abrir. Sem esses
|
|
36
|
+
-- cinco o app abre num rail com Painel e placeholders.
|
|
37
|
+
--
|
|
38
|
+
-- O que NÃO está aqui: um plugin `events`. Ele nasce em M1 (ver
|
|
39
|
+
-- fayz-app/ticket-control/docs/ROADMAP.md) e é quando esta linha ganha o sexto
|
|
40
|
+
-- nome — a migration que criar as tabelas de evento é a mesma que atualiza este
|
|
41
|
+
-- array.
|
|
42
|
+
-- ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
-- ── §1 a linha do registro ────────────────────────────────────────────────
|
|
45
|
+
--
|
|
46
|
+
-- O nome é o nome de FORA (ADR: "Control" é a família, não o app). O rail
|
|
47
|
+
-- dentro diz só "Ticket"; a lista do switcher é de fora, então carrega inteiro.
|
|
48
|
+
--
|
|
49
|
+
-- #820AD1 e não outro roxo: a ficha É o produto num lançador, e o violeta claro
|
|
50
|
+
-- (#7C3AED) já é do StudioControl. Este é 18° adiante na roda e 23 pontos mais
|
|
51
|
+
-- escuro — lado a lado eles leem como duas cores, não como dois degraus de uma.
|
|
52
|
+
-- O dividendo prático de escolher o degrau ESCURO: branco sobre ele dá 7,2:1, e
|
|
53
|
+
-- a marca pode simplesmente ser o botão (o ouro do Chef não pode).
|
|
54
|
+
--
|
|
55
|
+
-- `url` é a origem de dev enquanto o app não é publicado, e é deliberadamente
|
|
56
|
+
-- visível: a coluna É a allow-list do handoff de sessão, então trocá-la é o
|
|
57
|
+
-- passo de deploy, não um detalhe.
|
|
58
|
+
INSERT INTO app.apps (id, name, icon, accent_color, url, requires, serves_verticals, active) VALUES
|
|
59
|
+
('ticket', 'TicketControl', 'Ticket', '#820AD1', 'http://localhost:5308',
|
|
60
|
+
ARRAY['menu', 'inventory', 'orders', 'financial', 'conversations'],
|
|
61
|
+
ARRAY[]::text[], true)
|
|
62
|
+
ON CONFLICT (id) DO UPDATE
|
|
63
|
+
SET name = EXCLUDED.name, icon = EXCLUDED.icon, accent_color = EXCLUDED.accent_color,
|
|
64
|
+
requires = EXCLUDED.requires, active = true,
|
|
65
|
+
-- A URL NÃO é sobrescrita numa reaplicação: quem publicar o app aponta a
|
|
66
|
+
-- coluna para o endereço real, e uma migration que reaplica não pode
|
|
67
|
+
-- devolver a frota para localhost. (Mesma regra da 028.)
|
|
68
|
+
url = coalesce(app.apps.url, EXCLUDED.url);
|
|
69
|
+
|
|
70
|
+
-- ── §2 quem recebe agora ──────────────────────────────────────────────────
|
|
71
|
+
--
|
|
72
|
+
-- Só as contas de creators@fayalabs.com que JÁ têm cardápio ativo — ou seja, as
|
|
73
|
+
-- casas que já operam um bar nesta plataforma, que é a evidência honesta de que
|
|
74
|
+
-- "o bar que também faz evento" é a história delas.
|
|
75
|
+
--
|
|
76
|
+
-- Restrito ao dono do dogfood de propósito, e isto é o que difere da 011: a
|
|
77
|
+
-- LeadControl estava pronta quando ganhou a linha. Este app está em M0 — o rail
|
|
78
|
+
-- é real, metade das telas ainda é placeholder que diz de qual marco é. Ligá-lo
|
|
79
|
+
-- para todo restaurante do cluster seria distribuir um produto pela metade.
|
|
80
|
+
-- Quando M2 fechar (o ingresso vira dinheiro), a regra geral entra numa
|
|
81
|
+
-- migration própria, que é onde ela pode ser revista sozinha.
|
|
82
|
+
INSERT INTO app.tenant_apps (tenant_id, app_id, status, source)
|
|
83
|
+
SELECT DISTINCT m.tenant_id, 'ticket', 'active', 'manual'
|
|
84
|
+
FROM app.memberships m
|
|
85
|
+
JOIN auth.users u ON u.id = m.user_id
|
|
86
|
+
WHERE u.email = 'creators@fayalabs.com'
|
|
87
|
+
AND m.active
|
|
88
|
+
AND EXISTS (
|
|
89
|
+
SELECT 1 FROM app.tenant_plugins tp
|
|
90
|
+
WHERE tp.tenant_id = m.tenant_id AND tp.plugin_id = 'menu'
|
|
91
|
+
AND tp.facet = '' AND tp.status = 'active'
|
|
92
|
+
)
|
|
93
|
+
ON CONFLICT (tenant_id, app_id) DO NOTHING;
|
|
94
|
+
|
|
95
|
+
-- E os plugins que ele precisa, para exatamente essas contas — senão o app abre
|
|
96
|
+
-- num rail com Painel e nada mais, porque a shell monta o rail com o que
|
|
97
|
+
-- app_config() devolve em `plugins`.
|
|
98
|
+
--
|
|
99
|
+
-- O teto do plano ganha do padrão, sempre: uma conta cujo plano não inclui o
|
|
100
|
+
-- plugin não o acende, e a semeadura não falha por isso.
|
|
101
|
+
INSERT INTO app.tenant_plugins (tenant_id, plugin_id, facet, status, source)
|
|
102
|
+
SELECT DISTINCT ta.tenant_id, r.plugin_id, '', 'active', 'default'
|
|
103
|
+
FROM app.tenant_apps ta
|
|
104
|
+
JOIN app.apps a ON a.id = ta.app_id
|
|
105
|
+
CROSS JOIN LATERAL unnest(a.requires) AS r(plugin_id)
|
|
106
|
+
WHERE ta.app_id = 'ticket'
|
|
107
|
+
AND ta.status = 'active'
|
|
108
|
+
AND public.plan_permits(r.plugin_id, '', ta.tenant_id)
|
|
109
|
+
ON CONFLICT (tenant_id, plugin_id, facet) DO NOTHING;
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
-- 032_a_marca_e_um_conjunto_de_tokens.sql
|
|
2
|
+
--
|
|
3
|
+
-- A marca de um tenant tinha DUAS fontes de verdade e TRÊS grafias, e nenhuma
|
|
4
|
+
-- das três conversava com as outras.
|
|
5
|
+
--
|
|
6
|
+
-- `ConnectedBrandingSettings` GRAVA settings.branding.primaryColor (camelo)
|
|
7
|
+
-- `admin-app.tsx` LÊ settings.branding.primaryColor (camelo)
|
|
8
|
+
-- `app.branding_of()` LÊ settings.branding.primary_color (cobra)
|
|
9
|
+
--
|
|
10
|
+
-- Medido antes desta migration: **nenhum** tenant tinha a chave em camelo, e um
|
|
11
|
+
-- tinha a em cobra (posta por script). Ou seja: a cor que o lojista escolhe em
|
|
12
|
+
-- Configurações nunca chegou ao switcher nem à página pública, e a que existia
|
|
13
|
+
-- nunca chegou ao tema. A tela funcionava; o dado ia para uma gaveta que
|
|
14
|
+
-- ninguém abria.
|
|
15
|
+
--
|
|
16
|
+
-- E, por cima disso, o porte de CRM criou uma SEGUNDA marca — `plg_marketing_brand`
|
|
17
|
+
-- — com nome, tom de voz e palavras proibidas, dentro de um plugin. Duas marcas
|
|
18
|
+
-- para uma casa é pior que nenhuma: mudar a cor num lugar não muda no outro, e
|
|
19
|
+
-- ninguém sabe qual das duas o site está usando.
|
|
20
|
+
--
|
|
21
|
+
-- ── O QUE ESTA MIGRATION ESTABELECE ───────────────────────────────────────
|
|
22
|
+
--
|
|
23
|
+
-- Uma marca é UM CONJUNTO DE TOKENS com três camadas, e a terceira é a que
|
|
24
|
+
-- costuma ficar de fora:
|
|
25
|
+
--
|
|
26
|
+
-- identity quem a marca é nome, assinatura, posicionamento, logo
|
|
27
|
+
-- tokens como ela se parece cor e tipografia
|
|
28
|
+
-- voice como ela FALA tom, léxico, chamadas, palavras proibidas
|
|
29
|
+
--
|
|
30
|
+
-- Voz não é "uma funcionalidade de marketing" — é a dimensão VERBAL do mesmo
|
|
31
|
+
-- conjunto. Um sistema de design que descreve a cor do botão e não descreve o
|
|
32
|
+
-- que o produto pode dizer está descrevendo metade da marca. É por isso que ela
|
|
33
|
+
-- sai do plugin e entra aqui, ao lado da cor.
|
|
34
|
+
--
|
|
35
|
+
-- RETROCOMPATIBILIDADE É OBRIGATÓRIA, e não por educação: `app.branding_of` é
|
|
36
|
+
-- lida por `tenant_branding_public(slug, domain)`, que serve loja pública e
|
|
37
|
+
-- domínio próprio a visitante ANÔNIMO. Uma troca de forma que derrube isso tira
|
|
38
|
+
-- o site do ar de quem já vendeu hoje. Por isso o leitor passa a aceitar as
|
|
39
|
+
-- TRÊS grafias e a saída antiga continua idêntica — o documento em camadas vem
|
|
40
|
+
-- ao lado, não no lugar.
|
|
41
|
+
|
|
42
|
+
-- ── o leitor tolerante ─────────────────────────────────────────────────────
|
|
43
|
+
--
|
|
44
|
+
-- Uma chave, três lugares onde ela pode estar. A ordem é deliberada: o formato
|
|
45
|
+
-- NOVO primeiro, porque é para onde tudo caminha; depois cobra, que é o que o
|
|
46
|
+
-- resolvedor sempre leu; por último camelo, que é o que a tela sempre gravou.
|
|
47
|
+
CREATE OR REPLACE FUNCTION app.brand_token(p_branding jsonb, p_path text[], p_snake text, p_camel text)
|
|
48
|
+
RETURNS text
|
|
49
|
+
LANGUAGE sql
|
|
50
|
+
IMMUTABLE
|
|
51
|
+
SET search_path TO ''
|
|
52
|
+
AS $$
|
|
53
|
+
SELECT coalesce(
|
|
54
|
+
nullif(p_branding #>> p_path, ''),
|
|
55
|
+
nullif(p_branding ->> p_snake, ''),
|
|
56
|
+
nullif(p_branding ->> p_camel, '')
|
|
57
|
+
)
|
|
58
|
+
$$;
|
|
59
|
+
|
|
60
|
+
COMMENT ON FUNCTION app.brand_token(jsonb, text[], text, text) IS
|
|
61
|
+
'Lê um token de marca aceitando as três grafias que existiram: o caminho em camadas, cobra e camelo. A tolerância é o que permite mudar a forma sem derrubar a loja pública (032).';
|
|
62
|
+
|
|
63
|
+
-- ── a marca inteira, normalizada ───────────────────────────────────────────
|
|
64
|
+
CREATE OR REPLACE FUNCTION app.brand_of(p_tenant uuid)
|
|
65
|
+
RETURNS jsonb
|
|
66
|
+
LANGUAGE plpgsql
|
|
67
|
+
STABLE
|
|
68
|
+
SECURITY DEFINER
|
|
69
|
+
SET search_path TO ''
|
|
70
|
+
AS $$
|
|
71
|
+
DECLARE t record; b jsonb;
|
|
72
|
+
BEGIN
|
|
73
|
+
SELECT x.id, x.name, x.slug, x.logo_url, x.settings INTO t
|
|
74
|
+
FROM public.tenants x WHERE x.id = p_tenant;
|
|
75
|
+
IF t.id IS NULL THEN RETURN NULL; END IF;
|
|
76
|
+
|
|
77
|
+
b := coalesce(t.settings -> 'branding', '{}'::jsonb);
|
|
78
|
+
|
|
79
|
+
RETURN jsonb_strip_nulls(jsonb_build_object(
|
|
80
|
+
'identity', jsonb_build_object(
|
|
81
|
+
'name', coalesce(app.brand_token(b, '{identity,name}', 'app_name', 'appName'), t.name),
|
|
82
|
+
'tagline', app.brand_token(b, '{identity,tagline}', 'tagline', 'tagline'),
|
|
83
|
+
'mission', app.brand_token(b, '{identity,mission}', 'mission', 'mission'),
|
|
84
|
+
'logoUrl', coalesce(app.brand_token(b, '{identity,logoUrl}', 'logo_url', 'logoUrl'), t.logo_url),
|
|
85
|
+
'faviconUrl',app.brand_token(b, '{identity,faviconUrl}', 'favicon_url', 'faviconUrl'),
|
|
86
|
+
'customDomain', app.brand_token(b, '{identity,customDomain}', 'custom_domain', 'customDomain')
|
|
87
|
+
),
|
|
88
|
+
'tokens', jsonb_build_object(
|
|
89
|
+
'color', jsonb_strip_nulls(jsonb_build_object(
|
|
90
|
+
'primary', app.brand_token(b, '{tokens,color,primary}', 'primary_color', 'primaryColor'),
|
|
91
|
+
'accent', app.brand_token(b, '{tokens,color,accent}', 'accent_color', 'accentColor')
|
|
92
|
+
)),
|
|
93
|
+
'font', jsonb_strip_nulls(jsonb_build_object(
|
|
94
|
+
'heading', app.brand_token(b, '{tokens,font,heading}', 'heading_font', 'headingFont'),
|
|
95
|
+
'body', app.brand_token(b, '{tokens,font,body}', 'body_font', 'bodyFont')
|
|
96
|
+
))
|
|
97
|
+
),
|
|
98
|
+
-- A camada verbal. Arrays e não texto livre: o agente compara termo a
|
|
99
|
+
-- termo, e "evite gírias" não é comparável.
|
|
100
|
+
'voice', jsonb_strip_nulls(jsonb_build_object(
|
|
101
|
+
'tone', app.brand_token(b, '{voice,tone}', 'tone_of_voice', 'toneOfVoice'),
|
|
102
|
+
'lexicon', b #> '{voice,lexicon}',
|
|
103
|
+
'avoid', b #> '{voice,avoid}',
|
|
104
|
+
'ctas', b #> '{voice,ctas}',
|
|
105
|
+
'hashtags', b #> '{voice,hashtags}'
|
|
106
|
+
))
|
|
107
|
+
));
|
|
108
|
+
END $$;
|
|
109
|
+
|
|
110
|
+
COMMENT ON FUNCTION app.brand_of(uuid) IS
|
|
111
|
+
'A marca como conjunto de tokens em três camadas: identity, tokens (cor/tipografia) e voice. Voz é a dimensão VERBAL do mesmo conjunto, não uma funcionalidade de marketing (032).';
|
|
112
|
+
|
|
113
|
+
REVOKE ALL ON FUNCTION app.brand_of(uuid) FROM PUBLIC, anon;
|
|
114
|
+
|
|
115
|
+
-- ── o resolvedor antigo continua idêntico por fora ─────────────────────────
|
|
116
|
+
--
|
|
117
|
+
-- Mesmas chaves, mesma forma. O que muda é que ele passa a ENXERGAR o que a
|
|
118
|
+
-- tela sempre gravou: antes lia só `primary_color`, e a tela gravava
|
|
119
|
+
-- `primaryColor`. É esta linha que faz a cor escolhida em Configurações chegar
|
|
120
|
+
-- ao switcher e à loja pública pela primeira vez.
|
|
121
|
+
CREATE OR REPLACE FUNCTION app.branding_of(p_tenant uuid) RETURNS jsonb
|
|
122
|
+
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
|
123
|
+
SET search_path TO ''
|
|
124
|
+
AS $$
|
|
125
|
+
DECLARE t record; b jsonb;
|
|
126
|
+
BEGIN
|
|
127
|
+
SELECT x.id, x.name, x.slug, x.logo_url, x.settings INTO t FROM public.tenants x WHERE x.id = p_tenant;
|
|
128
|
+
IF t.id IS NULL THEN
|
|
129
|
+
RETURN jsonb_build_object('tenant_id', NULL, 'name', NULL, 'slug', NULL, 'logo_url', NULL, 'primary_color', NULL, 'custom_domain', NULL);
|
|
130
|
+
END IF;
|
|
131
|
+
b := coalesce(t.settings -> 'branding', '{}'::jsonb);
|
|
132
|
+
RETURN jsonb_build_object(
|
|
133
|
+
'tenant_id', t.id,
|
|
134
|
+
'name', coalesce(app.brand_token(b, '{identity,name}', 'app_name', 'appName'), t.name),
|
|
135
|
+
'slug', t.slug,
|
|
136
|
+
'logo_url', coalesce(app.brand_token(b, '{identity,logoUrl}', 'logo_url', 'logoUrl'), t.logo_url),
|
|
137
|
+
'primary_color', app.brand_token(b, '{tokens,color,primary}', 'primary_color', 'primaryColor'),
|
|
138
|
+
'custom_domain', app.brand_token(b, '{identity,customDomain}', 'custom_domain', 'customDomain')
|
|
139
|
+
);
|
|
140
|
+
EXCEPTION WHEN OTHERS THEN
|
|
141
|
+
RETURN jsonb_build_object('tenant_id', p_tenant, 'name', NULL, 'slug', NULL, 'logo_url', NULL, 'primary_color', NULL, 'custom_domain', NULL,
|
|
142
|
+
'error', 'branding unavailable');
|
|
143
|
+
END $$;
|
|
144
|
+
|
|
145
|
+
-- ── a marca, para quem está dentro ─────────────────────────────────────────
|
|
146
|
+
CREATE OR REPLACE FUNCTION public.tenant_brand() RETURNS jsonb
|
|
147
|
+
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
|
148
|
+
SET search_path TO ''
|
|
149
|
+
AS $$
|
|
150
|
+
BEGIN
|
|
151
|
+
RETURN coalesce(app.brand_of(app.current_tenant_id()), '{}'::jsonb);
|
|
152
|
+
EXCEPTION WHEN OTHERS THEN
|
|
153
|
+
RETURN '{}'::jsonb;
|
|
154
|
+
END $$;
|
|
155
|
+
|
|
156
|
+
COMMENT ON FUNCTION public.tenant_brand() IS
|
|
157
|
+
'A marca em camadas do tenant da sessão. Consumidores: tema da shell (tokens.color), agente (voice), páginas públicas (tudo) (032).';
|
|
158
|
+
|
|
159
|
+
REVOKE ALL ON FUNCTION public.tenant_brand() FROM PUBLIC, anon;
|
|
160
|
+
GRANT EXECUTE ON FUNCTION public.tenant_brand() TO authenticated;
|
|
161
|
+
GRANT EXECUTE ON FUNCTION public.tenant_brand() TO service_role;
|
|
162
|
+
|
|
163
|
+
-- ── escrever uma camada sem apagar as outras ───────────────────────────────
|
|
164
|
+
--
|
|
165
|
+
-- A tela salva por SEÇÃO — cores, ou voz, ou identidade. Um `update` que troca
|
|
166
|
+
-- `settings.branding` inteiro faria salvar a aba de cores apagar o tom de voz
|
|
167
|
+
-- que outra pessoa acabou de escrever. Fundir por caminho é o que impede isso.
|
|
168
|
+
CREATE OR REPLACE FUNCTION public.tenant_brand_patch(p_patch jsonb)
|
|
169
|
+
RETURNS jsonb
|
|
170
|
+
LANGUAGE plpgsql
|
|
171
|
+
SECURITY DEFINER
|
|
172
|
+
SET search_path TO ''
|
|
173
|
+
AS $$
|
|
174
|
+
DECLARE v_tenant uuid := app.current_tenant_id();
|
|
175
|
+
BEGIN
|
|
176
|
+
IF v_tenant IS NULL THEN
|
|
177
|
+
RAISE EXCEPTION 'tenant_brand_patch: sem tenant na sessão' USING ERRCODE = '42501';
|
|
178
|
+
END IF;
|
|
179
|
+
-- `settings.edit` e não `tenant.edit`: a marca é configuração da casa, e quem
|
|
180
|
+
-- mexe nela é quem mexe nas configurações.
|
|
181
|
+
IF NOT app.has_permission('settings.edit', NULL::uuid) THEN
|
|
182
|
+
RAISE EXCEPTION 'tenant_brand_patch: exige settings.edit' USING ERRCODE = '42501';
|
|
183
|
+
END IF;
|
|
184
|
+
|
|
185
|
+
UPDATE public.tenants t
|
|
186
|
+
SET settings = jsonb_set(
|
|
187
|
+
coalesce(t.settings, '{}'::jsonb),
|
|
188
|
+
'{branding}',
|
|
189
|
+
-- `||` funde raso, e é o que se quer POR CAMADA: mandar
|
|
190
|
+
-- {"voice":{...}} troca a voz inteira e não toca em tokens. Mandar
|
|
191
|
+
-- {"tokens":{"color":{...}}} trocaria `tokens` inteiro, então a tela
|
|
192
|
+
-- manda a camada `tokens` completa quando mexe em qualquer cor.
|
|
193
|
+
coalesce(t.settings -> 'branding', '{}'::jsonb) || coalesce(p_patch, '{}'::jsonb)
|
|
194
|
+
),
|
|
195
|
+
updated_at = now()
|
|
196
|
+
WHERE t.id = v_tenant;
|
|
197
|
+
|
|
198
|
+
RETURN coalesce(app.brand_of(v_tenant), '{}'::jsonb);
|
|
199
|
+
END $$;
|
|
200
|
+
|
|
201
|
+
COMMENT ON FUNCTION public.tenant_brand_patch(jsonb) IS
|
|
202
|
+
'Funde uma camada da marca sem apagar as outras — salvar a aba de cores não pode apagar o tom de voz de quem escreveu antes (032).';
|
|
203
|
+
|
|
204
|
+
REVOKE ALL ON FUNCTION public.tenant_brand_patch(jsonb) FROM PUBLIC, anon;
|
|
205
|
+
GRANT EXECUTE ON FUNCTION public.tenant_brand_patch(jsonb) TO authenticated;
|
|
206
|
+
GRANT EXECUTE ON FUNCTION public.tenant_brand_patch(jsonb) TO service_role;
|
|
207
|
+
|
|
208
|
+
-- ── a segunda marca vem para casa ──────────────────────────────────────────
|
|
209
|
+
--
|
|
210
|
+
-- `plg_marketing_brand` durou uma onda. Ela nasceu com o kit de marca do porte
|
|
211
|
+
-- do V1, num plugin, sem saber que o núcleo já tinha uma marca com resolvedor e
|
|
212
|
+
-- três consumidores. As linhas dela entram na camada certa e a tabela sai — não
|
|
213
|
+
-- fica como view: uma segunda porta para a mesma coisa é justamente o que esta
|
|
214
|
+
-- migration existe para desfazer.
|
|
215
|
+
--
|
|
216
|
+
-- A fusão preserva o que já estivesse em `settings.branding`: o kit do plugin
|
|
217
|
+
-- perde para a configuração do núcleo em caso de conflito, porque é a do núcleo
|
|
218
|
+
-- que o tema e a loja pública leem hoje.
|
|
219
|
+
DO $$
|
|
220
|
+
DECLARE r record; v_patch jsonb; v_n integer := 0;
|
|
221
|
+
BEGIN
|
|
222
|
+
IF to_regclass('public.plg_marketing_brand') IS NULL THEN RETURN; END IF;
|
|
223
|
+
|
|
224
|
+
FOR r IN SELECT * FROM public.plg_marketing_brand LOOP
|
|
225
|
+
v_patch := jsonb_strip_nulls(jsonb_build_object(
|
|
226
|
+
'identity', jsonb_strip_nulls(jsonb_build_object(
|
|
227
|
+
'name', nullif(btrim(coalesce(r.name, '')), ''),
|
|
228
|
+
'tagline', nullif(btrim(coalesce(r.tagline, '')), ''),
|
|
229
|
+
'mission', nullif(btrim(coalesce(r.mission, '')), ''),
|
|
230
|
+
'logoUrl', nullif(btrim(coalesce(r.logo_url, '')), '')
|
|
231
|
+
)),
|
|
232
|
+
-- A COR DO PLUGIN SÓ ENTRA SE O NÚCLEO NÃO TIVER UMA.
|
|
233
|
+
--
|
|
234
|
+
-- `||` funde raso e não sobrescreve caminho diferente: com a cor do
|
|
235
|
+
-- núcleo em `branding.primaryColor` (plana) e a do plugin em
|
|
236
|
+
-- `branding.tokens.color.primary` (em camadas), AS DUAS sobrevivem — e o
|
|
237
|
+
-- leitor tolerante prefere a forma nova, então a do plugin venceria.
|
|
238
|
+
-- Isto foi pego no container: `#FF0000` do plugin passou na frente do
|
|
239
|
+
-- `#2563EB` do núcleo. Quem manda é o núcleo, porque é o que o tema e a
|
|
240
|
+
-- loja pública leem hoje.
|
|
241
|
+
'tokens', jsonb_strip_nulls(jsonb_build_object(
|
|
242
|
+
'color', CASE
|
|
243
|
+
WHEN app.brand_token((SELECT x.settings -> 'branding' FROM public.tenants x WHERE x.id = r.tenant_id),
|
|
244
|
+
'{tokens,color,primary}', 'primary_color', 'primaryColor') IS NULL
|
|
245
|
+
THEN nullif(coalesce(r.colors, '{}'::jsonb), '{}'::jsonb)
|
|
246
|
+
END,
|
|
247
|
+
'font', nullif(coalesce(r.fonts, '{}'::jsonb), '{}'::jsonb)
|
|
248
|
+
)),
|
|
249
|
+
'voice', jsonb_strip_nulls(jsonb_build_object(
|
|
250
|
+
'tone', nullif(btrim(coalesce(r.tone_of_voice, '')), ''),
|
|
251
|
+
'lexicon', to_jsonb(coalesce(r.keywords, ARRAY[]::text[])),
|
|
252
|
+
'avoid', to_jsonb(coalesce(r.avoid_words, ARRAY[]::text[])),
|
|
253
|
+
'ctas', to_jsonb(coalesce(r.ctas, ARRAY[]::text[])),
|
|
254
|
+
'hashtags', to_jsonb(coalesce(r.hashtags, ARRAY[]::text[]))
|
|
255
|
+
))
|
|
256
|
+
));
|
|
257
|
+
|
|
258
|
+
UPDATE public.tenants t
|
|
259
|
+
SET settings = jsonb_set(
|
|
260
|
+
coalesce(t.settings, '{}'::jsonb), '{branding}',
|
|
261
|
+
-- `patch || existente` deixa o que já estava vencer NO MESMO
|
|
262
|
+
-- caminho. Para caminhos diferentes (plana × camadas) a defesa é
|
|
263
|
+
-- o CASE acima, na montagem do patch.
|
|
264
|
+
v_patch || coalesce(t.settings -> 'branding', '{}'::jsonb))
|
|
265
|
+
WHERE t.id = r.tenant_id;
|
|
266
|
+
v_n := v_n + 1;
|
|
267
|
+
END LOOP;
|
|
268
|
+
|
|
269
|
+
RAISE NOTICE '032: % kit(s) de marca trazidos do plugin para o núcleo', v_n;
|
|
270
|
+
END $$;
|
|
271
|
+
|
|
272
|
+
DROP TABLE IF EXISTS public.plg_marketing_brand;
|
|
273
|
+
DELETE FROM app.scaffold_registry WHERE table_name = 'plg_marketing_brand';
|
|
274
|
+
|
|
275
|
+
-- ── a grafia velha, normalizada de uma vez ─────────────────────────────────
|
|
276
|
+
--
|
|
277
|
+
-- O leitor tolera as três grafias para sempre — é o que protege quem tiver dado
|
|
278
|
+
-- antigo. Mas os tenants que existem HOJE ganham a forma nova agora, para que a
|
|
279
|
+
-- tela edite e leia o mesmo lugar desde o primeiro clique.
|
|
280
|
+
UPDATE public.tenants t
|
|
281
|
+
SET settings = jsonb_set(
|
|
282
|
+
coalesce(t.settings, '{}'::jsonb), '{branding}',
|
|
283
|
+
jsonb_strip_nulls(jsonb_build_object(
|
|
284
|
+
'tokens', jsonb_build_object('color', jsonb_strip_nulls(jsonb_build_object(
|
|
285
|
+
'primary', app.brand_token(t.settings -> 'branding', '{tokens,color,primary}', 'primary_color', 'primaryColor'),
|
|
286
|
+
'accent', app.brand_token(t.settings -> 'branding', '{tokens,color,accent}', 'accent_color', 'accentColor')
|
|
287
|
+
)))
|
|
288
|
+
)) || coalesce(t.settings -> 'branding', '{}'::jsonb))
|
|
289
|
+
WHERE t.settings -> 'branding' IS NOT NULL
|
|
290
|
+
AND app.brand_token(t.settings -> 'branding', '{tokens,color,primary}', 'primary_color', 'primaryColor') IS NOT NULL
|
|
291
|
+
AND t.settings #>> '{branding,tokens,color,primary}' IS NULL;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 041_a_entrega_tem_onde_guardar_a_politica.sql — o quadro de Delivery existe
|
|
3
|
+
-- desde sempre e a política dele não tinha onde morar.
|
|
4
|
+
--
|
|
5
|
+
-- ── O defeito ──────────────────────────────────────────────────────────────
|
|
6
|
+
-- Taxa de entrega, pedido mínimo, tempo estimado e raio são decisões da CASA:
|
|
7
|
+
-- mudam com a estação, com o combustível e com o bairro. Não estavam em lugar
|
|
8
|
+
-- nenhum — o app desenhava um interruptor de Delivery em `useState`, que volta
|
|
9
|
+
-- ao padrão a cada F5, e o resto era combinado por telefone.
|
|
10
|
+
--
|
|
11
|
+
-- ── A escolha ──────────────────────────────────────────────────────────────
|
|
12
|
+
-- `tenants.settings` (150/ADR 0023) já é onde a conta guarda o que muda sem
|
|
13
|
+
-- deploy, com `tenant_setting_set` cobrando `settings.manage` e conferindo o
|
|
14
|
+
-- tipo. Só faltava a chave existir: a função recusa chave não registrada, de
|
|
15
|
+
-- propósito, para que um erro de digitação no cliente não vire uma
|
|
16
|
+
-- configuração fantasma que ninguém lê.
|
|
17
|
+
--
|
|
18
|
+
-- Registradas com `reader = 'plugin:orders'`: quem as lê é o plugin de
|
|
19
|
+
-- pedidos, e é ele quem some junto se um dia o app não montar delivery.
|
|
20
|
+
-- ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
SELECT app.seed_setting_keys($$[
|
|
23
|
+
{
|
|
24
|
+
"key": "delivery.enabled", "type": "boolean", "default": true,
|
|
25
|
+
"reader": "plugin:orders",
|
|
26
|
+
"description": "Se a casa entrega. Desligado, o quadro de despacho segue existindo para o histórico e nenhum canal novo nasce como entrega."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"key": "delivery.fee", "type": "number", "default": 0,
|
|
30
|
+
"reader": "plugin:orders",
|
|
31
|
+
"description": "Taxa de entrega padrão, na moeda da casa. Zero = entrega sem taxa."
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"key": "delivery.free_over", "type": "number", "default": null,
|
|
35
|
+
"reader": "plugin:orders",
|
|
36
|
+
"description": "Acima deste valor de pedido a taxa é zerada. Nulo = a taxa vale sempre."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"key": "delivery.min_order", "type": "number", "default": 0,
|
|
40
|
+
"reader": "plugin:orders",
|
|
41
|
+
"description": "Pedido mínimo para entregar. Zero = sem mínimo."
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"key": "delivery.eta_minutes", "type": "integer", "default": 45,
|
|
45
|
+
"reader": "plugin:orders",
|
|
46
|
+
"description": "Tempo estimado de entrega, em minutos — o que o cliente ouve quando pergunta."
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"key": "delivery.radius_km", "type": "number", "default": 0,
|
|
50
|
+
"reader": "plugin:orders",
|
|
51
|
+
"description": "Raio de entrega em quilômetros. Zero = sem limite declarado."
|
|
52
|
+
}
|
|
53
|
+
]$$::jsonb);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 042_o_token_de_marca_fecha_a_porta_anon.sql
|
|
3
|
+
--
|
|
4
|
+
-- A 032 criou app.brand_token como helper interno, mas funções novas do
|
|
5
|
+
-- PostgreSQL nascem executáveis por PUBLIC. Isso deixou `anon` chamar uma
|
|
6
|
+
-- função do schema privado `app` e fez a postura N15c ficar vermelha.
|
|
7
|
+
--
|
|
8
|
+
-- A correção não pode morar retroativamente na 032: os clusters ChefControl
|
|
9
|
+
-- já registraram o checksum original dela em public._migrations. Editar um
|
|
10
|
+
-- arquivo aplicado produz drift e faz `fayz db apply` parar antes de alcançar
|
|
11
|
+
-- qualquer correção posterior. Por isso a 032 mantém seus bytes históricos e
|
|
12
|
+
-- esta migration nova fecha a permissão nos bancos existentes.
|
|
13
|
+
-- ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
REVOKE ALL
|
|
16
|
+
ON FUNCTION app.brand_token(jsonb, text[], text, text)
|
|
17
|
+
FROM PUBLIC, anon;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 043_fiscalcontrol_joins_the_family.sql — a contabilidade ganha uma linha.
|
|
3
|
+
--
|
|
4
|
+
-- O oitavo produto do registro, e o segundo que é FUNÇÃO e não trade (como o
|
|
5
|
+
-- LeadControl, 011): FiscalControl é o escritório de contabilidade da família.
|
|
6
|
+
-- Todo tenant que opera um Control já produz o material contábil sem saber —
|
|
7
|
+
-- partida dobrada em plg_financial_movements, balancete em
|
|
8
|
+
-- v_financial_trial_balance, imposto por linha em plg_financial_invoice_item_taxes.
|
|
9
|
+
-- O que falta é o app que olha esse material como um contador olha: plano de
|
|
10
|
+
-- contas, lançamento, DRE, apuração. Com a reforma tributária tornando o
|
|
11
|
+
-- destaque de IBS/CBS obrigatório em agosto/2026, esse olhar deixa de ser
|
|
12
|
+
-- opcional para qualquer tenant fora do Simples.
|
|
13
|
+
--
|
|
14
|
+
-- ── `serves_verticals` fica VAZIO, de propósito ────────────────────────────
|
|
15
|
+
-- Contabilidade não é um ramo, é uma função que atravessa todos. Semear por
|
|
16
|
+
-- vertical (010) daria um app de contador para todo restaurante novo — uma tela
|
|
17
|
+
-- de balancete que ninguém pediu. Este é opt-in: quem liga é `tenant_app_set`
|
|
18
|
+
-- ou a mão da casa.
|
|
19
|
+
--
|
|
20
|
+
-- ── `requires` nomeia um plugin só ─────────────────────────────────────────
|
|
21
|
+
-- `financial` é o razão inteiro: invoices, movimentos, plano de contas, caixa.
|
|
22
|
+
-- Sem ele o rail é Painel e placeholders. `reports` e `fiscal-br` entram como
|
|
23
|
+
-- opt-in quando os marcos M3/M4 fecharem — a migration que criar as telas é a
|
|
24
|
+
-- que atualiza este array (mesma regra da 031).
|
|
25
|
+
-- ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
-- ── §1 a linha do registro ────────────────────────────────────────────────
|
|
28
|
+
--
|
|
29
|
+
-- #334155 (slate): o único setor sóbrio ainda livre na roda — os oito vizinhos
|
|
30
|
+
-- já tomaram vermelho, ouro, verde, dois roxos, azul e teal. Combina com o que
|
|
31
|
+
-- o app é: o produto sério da família. Branco sobre ele dá 9,6:1.
|
|
32
|
+
--
|
|
33
|
+
-- `url` é a origem de dev enquanto o app não é publicado; a coluna é a
|
|
34
|
+
-- allow-list do handoff, e o coalesce impede que uma reaplicação devolva uma
|
|
35
|
+
-- frota publicada para localhost (regra da 028/031).
|
|
36
|
+
INSERT INTO app.apps (id, name, icon, accent_color, url, requires, serves_verticals, active) VALUES
|
|
37
|
+
('fiscal', 'FiscalControl', 'Calculator', '#334155', 'http://localhost:5313',
|
|
38
|
+
ARRAY['financial'],
|
|
39
|
+
ARRAY[]::text[], true)
|
|
40
|
+
ON CONFLICT (id) DO UPDATE
|
|
41
|
+
SET name = EXCLUDED.name, icon = EXCLUDED.icon, accent_color = EXCLUDED.accent_color,
|
|
42
|
+
requires = EXCLUDED.requires, active = true,
|
|
43
|
+
url = coalesce(app.apps.url, EXCLUDED.url);
|
|
44
|
+
|
|
45
|
+
-- ── §2 quem recebe agora ──────────────────────────────────────────────────
|
|
46
|
+
--
|
|
47
|
+
-- Só as contas do dogfood que JÁ têm o financeiro ativo — a evidência honesta
|
|
48
|
+
-- de que existe um razão para o contador ler. O app está em M0; distribuir para
|
|
49
|
+
-- o cluster inteiro seria entregar um produto pela metade (regra da 031). O
|
|
50
|
+
-- tenant ControlGroup, criado fora desta migration por platform_tenant_create,
|
|
51
|
+
-- entra pelo p_apps da própria chamada.
|
|
52
|
+
INSERT INTO app.tenant_apps (tenant_id, app_id, status, source)
|
|
53
|
+
SELECT DISTINCT m.tenant_id, 'fiscal', 'active', 'manual'
|
|
54
|
+
FROM app.memberships m
|
|
55
|
+
JOIN auth.users u ON u.id = m.user_id
|
|
56
|
+
WHERE u.email = 'creators@fayalabs.com'
|
|
57
|
+
AND m.active
|
|
58
|
+
AND EXISTS (
|
|
59
|
+
SELECT 1 FROM app.tenant_plugins tp
|
|
60
|
+
WHERE tp.tenant_id = m.tenant_id AND tp.plugin_id = 'financial'
|
|
61
|
+
AND tp.facet = '' AND tp.status = 'active'
|
|
62
|
+
)
|
|
63
|
+
ON CONFLICT (tenant_id, app_id) DO NOTHING;
|
|
64
|
+
|
|
65
|
+
-- E o plugin que ele precisa, para exatamente essas contas. O teto do plano
|
|
66
|
+
-- ganha do padrão, sempre.
|
|
67
|
+
INSERT INTO app.tenant_plugins (tenant_id, plugin_id, facet, status, source)
|
|
68
|
+
SELECT DISTINCT ta.tenant_id, r.plugin_id, '', 'active', 'default'
|
|
69
|
+
FROM app.tenant_apps ta
|
|
70
|
+
JOIN app.apps a ON a.id = ta.app_id
|
|
71
|
+
CROSS JOIN LATERAL unnest(a.requires) AS r(plugin_id)
|
|
72
|
+
WHERE ta.app_id = 'fiscal'
|
|
73
|
+
AND ta.status = 'active'
|
|
74
|
+
AND public.plan_permits(r.plugin_id, '', ta.tenant_id)
|
|
75
|
+
ON CONFLICT (tenant_id, plugin_id, facet) DO NOTHING;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 044_o_teste_gratis_dura_trinta_dias.sql
|
|
3
|
+
--
|
|
4
|
+
-- A família Control abre para signup público e a promessa comercial passa a
|
|
5
|
+
-- ser "30 dias grátis" — 7 dias não dão para um restaurante montar cardápio,
|
|
6
|
+
-- treinar a equipe e rodar um mês de pedidos.
|
|
7
|
+
--
|
|
8
|
+
-- O prazo vive num único lugar: o DEFAULT da coluna. Só tenants NOVOS são
|
|
9
|
+
-- afetados; quem já tem data estampada mantém a sua, e quem nasceu antes do
|
|
10
|
+
-- trial (NULL) continua sem trial — nunca "expirado". O trigger
|
|
11
|
+
-- tenants_trial_is_readonly segue impedindo escrita client-side na data.
|
|
12
|
+
-- ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
ALTER TABLE public.tenants
|
|
15
|
+
ALTER COLUMN trial_ends_at SET DEFAULT (now() + interval '30 days');
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
-- 046_the_tick_advances_the_flows.sql
|
|
2
|
+
--
|
|
3
|
+
-- A cadence needs a clock, and the database already has one: `fayz_event_runner`
|
|
4
|
+
-- is a pg_cron job calling `event_runner_tick(200)` every minute. What it does
|
|
5
|
+
-- not have is a call to the thing that advances a multi-step flow.
|
|
6
|
+
--
|
|
7
|
+
-- Fifth call, same shape as the other four: guarded by `to_regprocedure`, so a
|
|
8
|
+
-- pool without plugin-automations ticks exactly as it does today. The guard is
|
|
9
|
+
-- the whole reason this belongs in the spine rather than in the plugin — the
|
|
10
|
+
-- tick is one function, and a plugin cannot append to it.
|
|
11
|
+
--
|
|
12
|
+
-- ORDER MATTERS, AND IT IS DELIBERATE. The flows run AFTER `notifications_drain`
|
|
13
|
+
-- and not before. A step enqueues with `scheduled_for = now()`, so draining
|
|
14
|
+
-- first means a step enqueued on this tick waits for the next one — sixty
|
|
15
|
+
-- seconds, once, at the start of a cadence that spans days. Running the flows
|
|
16
|
+
-- first would instead let a step be enqueued and drained in the same tick,
|
|
17
|
+
-- which sounds better and is not: the drain would be doing work the advisory
|
|
18
|
+
-- lock is already holding the whole database's tick for, and a slow provider
|
|
19
|
+
-- would then delay every other plugin's due work behind it. Cheap latency,
|
|
20
|
+
-- bought with predictable tick length.
|
|
21
|
+
--
|
|
22
|
+
-- The limit is 50 rather than the caller's `p_limit`. A cadence step is a write
|
|
23
|
+
-- plus an enqueue; two hundred of them in one tick is a long transaction under
|
|
24
|
+
-- an advisory lock that stops everything else. `marketing_run_due(20)` made the
|
|
25
|
+
-- same trade for the same reason.
|
|
26
|
+
|
|
27
|
+
CREATE OR REPLACE FUNCTION public.event_runner_tick(p_limit integer DEFAULT 200) RETURNS jsonb
|
|
28
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
29
|
+
SET search_path TO ''
|
|
30
|
+
AS $_$
|
|
31
|
+
DECLARE
|
|
32
|
+
v_lock boolean;
|
|
33
|
+
v_events record;
|
|
34
|
+
v_notif record;
|
|
35
|
+
v_out jsonb := '{}'::jsonb;
|
|
36
|
+
v_extra jsonb;
|
|
37
|
+
v_start timestamptz := clock_timestamp();
|
|
38
|
+
BEGIN
|
|
39
|
+
-- 1 tick at a time per database. A second caller returns immediately rather
|
|
40
|
+
-- than waiting, so a cron that fires while the previous one is still going
|
|
41
|
+
-- does not queue up behind it.
|
|
42
|
+
SELECT pg_try_advisory_xact_lock(hashtextextended('fayz.event_runner', 0)) INTO v_lock;
|
|
43
|
+
IF NOT v_lock THEN
|
|
44
|
+
RETURN jsonb_build_object('skipped', 'another tick is running');
|
|
45
|
+
END IF;
|
|
46
|
+
|
|
47
|
+
SELECT * INTO v_events FROM public.consume_event_log(p_limit);
|
|
48
|
+
v_out := v_out || jsonb_build_object('events', to_jsonb(v_events));
|
|
49
|
+
|
|
50
|
+
-- The notification queue drains in its OWN pass. A provider timing out costs
|
|
51
|
+
-- a retry there and must never hold up the event log — which is why these are
|
|
52
|
+
-- two functions and not one loop.
|
|
53
|
+
IF to_regprocedure('public.notifications_drain(integer)') IS NOT NULL THEN
|
|
54
|
+
EXECUTE 'SELECT * FROM public.notifications_drain($1)' INTO v_notif USING p_limit;
|
|
55
|
+
v_out := v_out || jsonb_build_object('notifications', to_jsonb(v_notif));
|
|
56
|
+
END IF;
|
|
57
|
+
|
|
58
|
+
-- Campaigns, scheduled reports and cadence flows ENQUEUE; they never send. So
|
|
59
|
+
-- they run on the same tick as the log and drain through the same
|
|
60
|
+
-- notification queue.
|
|
61
|
+
IF to_regprocedure('public.marketing_run_due(integer)') IS NOT NULL THEN
|
|
62
|
+
EXECUTE 'SELECT public.marketing_run_due(20)' INTO v_extra;
|
|
63
|
+
v_out := v_out || jsonb_build_object('marketing', v_extra);
|
|
64
|
+
END IF;
|
|
65
|
+
|
|
66
|
+
IF to_regprocedure('public.reports_deliver_due(integer)') IS NOT NULL THEN
|
|
67
|
+
EXECUTE 'SELECT public.reports_deliver_due(50)' INTO v_extra;
|
|
68
|
+
v_out := v_out || jsonb_build_object('reports', v_extra);
|
|
69
|
+
END IF;
|
|
70
|
+
|
|
71
|
+
IF to_regprocedure('public.automations_flows_run_due(integer)') IS NOT NULL THEN
|
|
72
|
+
EXECUTE 'SELECT public.automations_flows_run_due(50)' INTO v_extra;
|
|
73
|
+
v_out := v_out || jsonb_build_object('flows', v_extra);
|
|
74
|
+
END IF;
|
|
75
|
+
|
|
76
|
+
RETURN v_out || jsonb_build_object('took_ms', (extract(epoch FROM clock_timestamp() - v_start) * 1000)::integer);
|
|
77
|
+
END $_$;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
-- 047_the_tick_sweeps_the_offers.sql
|
|
2
|
+
--
|
|
3
|
+
-- Sixth call on the minute tick: expiring a lead offer that nobody took.
|
|
4
|
+
--
|
|
5
|
+
-- A five-minute window means the sweep's resolution has to be finer than the
|
|
6
|
+
-- window, and one minute is what the runner already gives. A rep therefore gets
|
|
7
|
+
-- their full five minutes and loses the lead within sixty seconds of the window
|
|
8
|
+
-- closing — which is the behaviour the rota is sold on.
|
|
9
|
+
--
|
|
10
|
+
-- It runs LAST, after the flows. Both write, and the offer sweep can cascade —
|
|
11
|
+
-- one expiry creating the next offer — so it is the call most likely to be slow
|
|
12
|
+
-- on a busy tenant. Last means a slow sweep delays nothing but itself.
|
|
13
|
+
--
|
|
14
|
+
-- The whole function is restated because a plugin cannot append to it, and
|
|
15
|
+
-- `CREATE OR REPLACE` needs the entire body. Migration 046 added the flows call
|
|
16
|
+
-- immediately before this one; if this file is ever reordered ahead of it, the
|
|
17
|
+
-- flows call disappears.
|
|
18
|
+
|
|
19
|
+
CREATE OR REPLACE FUNCTION public.event_runner_tick(p_limit integer DEFAULT 200) RETURNS jsonb
|
|
20
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
21
|
+
SET search_path TO ''
|
|
22
|
+
AS $_$
|
|
23
|
+
DECLARE
|
|
24
|
+
v_lock boolean;
|
|
25
|
+
v_events record;
|
|
26
|
+
v_notif record;
|
|
27
|
+
v_out jsonb := '{}'::jsonb;
|
|
28
|
+
v_extra jsonb;
|
|
29
|
+
v_start timestamptz := clock_timestamp();
|
|
30
|
+
BEGIN
|
|
31
|
+
-- 1 tick at a time per database. A second caller returns immediately rather
|
|
32
|
+
-- than waiting, so a cron that fires while the previous one is still going
|
|
33
|
+
-- does not queue up behind it.
|
|
34
|
+
SELECT pg_try_advisory_xact_lock(hashtextextended('fayz.event_runner', 0)) INTO v_lock;
|
|
35
|
+
IF NOT v_lock THEN
|
|
36
|
+
RETURN jsonb_build_object('skipped', 'another tick is running');
|
|
37
|
+
END IF;
|
|
38
|
+
|
|
39
|
+
SELECT * INTO v_events FROM public.consume_event_log(p_limit);
|
|
40
|
+
v_out := v_out || jsonb_build_object('events', to_jsonb(v_events));
|
|
41
|
+
|
|
42
|
+
-- The notification queue drains in its OWN pass. A provider timing out costs
|
|
43
|
+
-- a retry there and must never hold up the event log — which is why these are
|
|
44
|
+
-- two functions and not one loop.
|
|
45
|
+
IF to_regprocedure('public.notifications_drain(integer)') IS NOT NULL THEN
|
|
46
|
+
EXECUTE 'SELECT * FROM public.notifications_drain($1)' INTO v_notif USING p_limit;
|
|
47
|
+
v_out := v_out || jsonb_build_object('notifications', to_jsonb(v_notif));
|
|
48
|
+
END IF;
|
|
49
|
+
|
|
50
|
+
-- Campaigns, scheduled reports and cadence flows ENQUEUE; they never send. So
|
|
51
|
+
-- they run on the same tick as the log and drain through the same
|
|
52
|
+
-- notification queue.
|
|
53
|
+
IF to_regprocedure('public.marketing_run_due(integer)') IS NOT NULL THEN
|
|
54
|
+
EXECUTE 'SELECT public.marketing_run_due(20)' INTO v_extra;
|
|
55
|
+
v_out := v_out || jsonb_build_object('marketing', v_extra);
|
|
56
|
+
END IF;
|
|
57
|
+
|
|
58
|
+
IF to_regprocedure('public.reports_deliver_due(integer)') IS NOT NULL THEN
|
|
59
|
+
EXECUTE 'SELECT public.reports_deliver_due(50)' INTO v_extra;
|
|
60
|
+
v_out := v_out || jsonb_build_object('reports', v_extra);
|
|
61
|
+
END IF;
|
|
62
|
+
|
|
63
|
+
IF to_regprocedure('public.automations_flows_run_due(integer)') IS NOT NULL THEN
|
|
64
|
+
EXECUTE 'SELECT public.automations_flows_run_due(50)' INTO v_extra;
|
|
65
|
+
v_out := v_out || jsonb_build_object('flows', v_extra);
|
|
66
|
+
END IF;
|
|
67
|
+
|
|
68
|
+
IF to_regprocedure('public.crm_offers_sweep(integer)') IS NOT NULL THEN
|
|
69
|
+
EXECUTE 'SELECT public.crm_offers_sweep(100)' INTO v_extra;
|
|
70
|
+
v_out := v_out || jsonb_build_object('offers', v_extra);
|
|
71
|
+
END IF;
|
|
72
|
+
|
|
73
|
+
RETURN v_out || jsonb_build_object('took_ms', (extract(epoch FROM clock_timestamp() - v_start) * 1000)::integer);
|
|
74
|
+
END $_$;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 052_fullcontrol_joins_the_family.sql — o funil vira a casa inteira.
|
|
3
|
+
--
|
|
4
|
+
-- A 011 deu uma linha ao LeadControl porque o funil tinha transbordado de uma
|
|
5
|
+
-- aba dentro do app do ofício. O que transbordou agora é o app inteiro: a
|
|
6
|
+
-- mesma base que guarda o lead guarda o título, o saldo de estoque e a nota, e
|
|
7
|
+
-- o produto que olha tudo isso de uma vez não é um CRM — é um ERP. Este
|
|
8
|
+
-- arquivo é o registro admitindo isso.
|
|
9
|
+
--
|
|
10
|
+
-- O recorte também mudou de público. Os outros Controls são OFÍCIOS (cozinha,
|
|
11
|
+
-- loja, estúdio, escola, bilheteria, escritório de contabilidade). Este é para
|
|
12
|
+
-- a empresa cujo RAMO não é o assunto do software — a distribuidora, a
|
|
13
|
+
-- confecção, a prestadora, a indústria pequena. Elas não precisam de um app
|
|
14
|
+
-- que entenda de comida; precisam de um que entenda de conta a pagar.
|
|
15
|
+
--
|
|
16
|
+
-- ── Por que um ID NOVO e não um UPDATE no 'crm' ────────────────────────────
|
|
17
|
+
--
|
|
18
|
+
-- `app.apps.id` é chave, e `app.tenant_apps.app_id` é a única FK que aponta
|
|
19
|
+
-- para ela (001 §2, ON DELETE RESTRICT) — então trocar o id é ADITIVO e
|
|
20
|
+
-- reversível, e custa as poucas linhas do §2. O que não é reversível é o que
|
|
21
|
+
-- sobra se não trocarmos: um app cujo id é o nome de UM dos seus módulos,
|
|
22
|
+
-- numa linha que passaria a ler `id = 'crm' … requires ARRAY['crm', …]` — um
|
|
23
|
+
-- app que exige a si mesmo. `tenant_app_set('crm')`, `tenant_plugin_set('crm')`
|
|
24
|
+
-- e `plan_permits('crm')` continuariam querendo dizer três coisas diferentes.
|
|
25
|
+
--
|
|
26
|
+
-- Hoje é o dia mais barato para fazer isso: `url` ainda é `localhost`, nenhum
|
|
27
|
+
-- domínio de cliente aponta para cá, e não existe união de ids em TypeScript
|
|
28
|
+
-- (packages/admin/src/directory/app-id.ts é explícito: "a rename is a row, not
|
|
29
|
+
-- a release"). O que o app lê é `VITE_APP_ID`, e ele acompanha este arquivo.
|
|
30
|
+
--
|
|
31
|
+
-- A linha antiga NÃO é apagada — é APOSENTADA (§3). A FK a protege, as linhas
|
|
32
|
+
-- de `app.tenant_apps` com app_id='crm' são o histórico de quem teve o produto,
|
|
33
|
+
-- e enquanto elas existirem a volta atrás é um UPDATE e não um INSERT.
|
|
34
|
+
--
|
|
35
|
+
-- ── `serves_verticals` fica VAZIO, e agora o argumento é o OPOSTO ──────────
|
|
36
|
+
--
|
|
37
|
+
-- Nas 011/031/043 o array ficou vazio porque aquele app servia a um recorte.
|
|
38
|
+
-- Aqui ele fica vazio porque o app serve a TODOS — e é exatamente por isso que
|
|
39
|
+
-- semear por vertical (010) seria o pior erro possível: todo restaurante criado
|
|
40
|
+
-- amanhã acordaria com um segundo produto no switcher, com financeiro e estoque
|
|
41
|
+
-- acesos que ninguém configurou. Um app que chega sem ser convidado não é
|
|
42
|
+
-- funcionalidade, é chamado de suporte. Opt-in via `tenant_app_set('full', …)`.
|
|
43
|
+
--
|
|
44
|
+
-- ── `requires` cresce em DEGRAUS, um marco por vez ─────────────────────────
|
|
45
|
+
--
|
|
46
|
+
-- Regra da 031/043: o array ganha um plugin no arquivo que traz as telas dele.
|
|
47
|
+
-- Aqui ele nomeia o que o bundle do app JÁ monta hoje (src/config/app.tsx):
|
|
48
|
+
-- crm, conversations, marketing, automations, notifications, reports,
|
|
49
|
+
-- financial e inventory.
|
|
50
|
+
--
|
|
51
|
+
-- E há um motivo mais duro para não listar o resto de uma vez:
|
|
52
|
+
-- **app.tenant_plugins não tem app_id**. Um plugin exigido aqui acende para o
|
|
53
|
+
-- TENANT, e aparece em qualquer outro Control que aquele tenant rode e que
|
|
54
|
+
-- empacote o mesmo plugin. Agenda, tasks, forms e orders entram quando este app
|
|
55
|
+
-- desenhar as telas deles.
|
|
56
|
+
--
|
|
57
|
+
-- Os addons ficam de fora por definição: `fiscal-br` e `banking-br` são opt-in
|
|
58
|
+
-- contratados, e o que entra em `requires` é ligado para todo mundo.
|
|
59
|
+
--
|
|
60
|
+
-- ── O que a 011 dizia sobre `financial`, e por que caiu ────────────────────
|
|
61
|
+
--
|
|
62
|
+
-- A 011 recusou `financial` com o argumento certo para o produto de então:
|
|
63
|
+
-- "um negócio ganho neste produto passa a bola para o app que é dono do
|
|
64
|
+
-- dinheiro; duplicar o razão aqui daria duas portas de entrada para a mesma
|
|
65
|
+
-- fatura." O argumento não mudou — mudou de lado. **O FullControl É o app que
|
|
66
|
+
-- é dono do dinheiro.** O orçamento aprovado no CRM vira título e aterrissa em
|
|
67
|
+
-- /financial/receivables/detail/:id sem sair do app, e é a mesma linha de
|
|
68
|
+
-- `plg_financial_invoices` que o ChefControl do mesmo tenant leria.
|
|
69
|
+
-- ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
-- ── §1 a linha nova ───────────────────────────────────────────────────────
|
|
72
|
+
--
|
|
73
|
+
-- #1E3A8A (blue-900) e não o teal que este arquivo carregou por uma tarde: o
|
|
74
|
+
-- CourseControl está com #0D9488 no cluster — a linha divergiu do âmbar que a
|
|
75
|
+
-- 001 deu a ele —, e dois teais no launcher leem como um produto em dois tons.
|
|
76
|
+
-- Também não é o #2563EB do LeadControl por acaso: é ele um degrau mais grave.
|
|
77
|
+
-- Para as nove contas que já tinham o ladrilho azul, o produto CRESCEU; a cor
|
|
78
|
+
-- diz produção onde a antiga dizia vendas. Branco sobre ele dá 10,4:1.
|
|
79
|
+
--
|
|
80
|
+
-- 'Layers' existe no registro CURADO de ícones (packages/ui/src/icons.ts) —
|
|
81
|
+
-- conferido, e não é zelo: 'Scissors', que a 001 deu ao StudioControl, NÃO
|
|
82
|
+
-- existe lá, e aquele ladrilho desenha iniciais desde então, sem erro nenhum.
|
|
83
|
+
-- Camadas sobre uma base é a figura literal de um ERP: vários módulos sobre um
|
|
84
|
+
-- tenant só. Um FullControlMark próprio entra quando existir marca em livro —
|
|
85
|
+
-- inventar um SVG que todos os irmãos passam a desenhar é pior que um glifo
|
|
86
|
+
-- bem escolhido (foi o que a 012 pagou para o ChefControl, e só depois do
|
|
87
|
+
-- brand book).
|
|
88
|
+
--
|
|
89
|
+
-- `url` é a origem de dev; a coluna é a allow-list do handoff, e o coalesce
|
|
90
|
+
-- impede que uma reaplicação devolva uma frota publicada para localhost
|
|
91
|
+
-- (regra da 031/043).
|
|
92
|
+
INSERT INTO app.apps (id, name, icon, accent_color, url, requires, serves_verticals, active) VALUES
|
|
93
|
+
('full', 'FullControl', 'Layers', '#1E3A8A', 'http://localhost:5307',
|
|
94
|
+
ARRAY['crm', 'conversations', 'marketing', 'automations', 'notifications',
|
|
95
|
+
'reports', 'financial', 'inventory'],
|
|
96
|
+
ARRAY[]::text[], true)
|
|
97
|
+
ON CONFLICT (id) DO UPDATE
|
|
98
|
+
SET name = EXCLUDED.name, icon = EXCLUDED.icon, accent_color = EXCLUDED.accent_color,
|
|
99
|
+
active = true,
|
|
100
|
+
-- `requires` só CRESCE numa reaplicação: um marco posterior pode ter
|
|
101
|
+
-- somado um plugin depois deste arquivo, e sobrescrever aqui desfaria
|
|
102
|
+
-- aquele marco em silêncio.
|
|
103
|
+
requires = (SELECT array_agg(DISTINCT p ORDER BY p)
|
|
104
|
+
FROM unnest(app.apps.requires || EXCLUDED.requires) AS p),
|
|
105
|
+
url = coalesce(app.apps.url, EXCLUDED.url);
|
|
106
|
+
|
|
107
|
+
COMMENT ON TABLE app.apps IS
|
|
108
|
+
'Which applications exist on this cluster (001, 011, 043, 052). The registry the app switcher reads. An app is a LENS over a tenant''s rows — it never scopes them; two apps on one tenant share public.people by design. `requires` names the plugins the app cannot work without, and it is a SEEDING list, not a dependency check — app.tenant_plugins has no app_id, so a plugin required here is lit for the whole tenant. `url` doubles as the allow-list for the session handoff; NULL takes the app off every switcher. `active = false` retires a row without breaking the FK from app.tenant_apps.';
|
|
109
|
+
|
|
110
|
+
-- ── §2 quem recebe: exatamente quem tinha o LeadControl ───────────────────
|
|
111
|
+
--
|
|
112
|
+
-- Carrega `status`, `source` e `added_by` da linha antiga: uma conta que tinha
|
|
113
|
+
-- DESLIGADO o LeadControl não pode acordar com o FullControl aceso, e uma linha
|
|
114
|
+
-- semeada pela plataforma ('default', 011 §3) não pode virar escolha de gente.
|
|
115
|
+
INSERT INTO app.tenant_apps (tenant_id, app_id, status, source, added_by)
|
|
116
|
+
SELECT ta.tenant_id, 'full', ta.status, ta.source, ta.added_by
|
|
117
|
+
FROM app.tenant_apps ta
|
|
118
|
+
WHERE ta.app_id = 'crm'
|
|
119
|
+
ON CONFLICT (tenant_id, app_id) DO NOTHING;
|
|
120
|
+
|
|
121
|
+
-- ── §3 a linha antiga é aposentada, não apagada ───────────────────────────
|
|
122
|
+
--
|
|
123
|
+
-- `active = false` já a tira do `app_config()` (008 filtra `a.active`), e
|
|
124
|
+
-- `url = NULL` é o segundo cinto: o cliente também descarta app sem url
|
|
125
|
+
-- (packages/admin/src/directory/directory.store.ts, normalize()). O nome fica,
|
|
126
|
+
-- para que uma consulta de suporte sobre uma linha antiga ainda leia.
|
|
127
|
+
UPDATE app.apps
|
|
128
|
+
SET active = false,
|
|
129
|
+
url = NULL
|
|
130
|
+
WHERE id = 'crm';
|
|
131
|
+
|
|
132
|
+
-- ── §4 o teto do plano passa a ter a linha do PLUGIN, não só das facets ───
|
|
133
|
+
--
|
|
134
|
+
-- Este bloco conserta um defeito que existia ANTES deste app e que só não
|
|
135
|
+
-- apareceu porque as contas do dogfood não têm plano.
|
|
136
|
+
--
|
|
137
|
+
-- `plan_permits(plugin, facet, tenant)` casa a facet EXATAMENTE
|
|
138
|
+
-- (000_baseline.sql), e "o plugin é a facet de nome vazio"
|
|
139
|
+
-- (packages/core/src/types/plugins.ts). Todo caminho de semeadura chama com
|
|
140
|
+
-- `facet = ''`: tenant_app_set (001), tenant_apply_vertical_defaults (010) e os
|
|
141
|
+
-- backfills das migrations de app. Mas o seed de `app.plan_grants` só escreveu
|
|
142
|
+
-- linhas de FACET (`financial.ledger`, `crm.contacts`, `inventory.stock`,
|
|
143
|
+
-- `agenda.scheduling`) — com `facet = ''` existem apenas `tasks`, `forms` e
|
|
144
|
+
-- `scale/marketing`.
|
|
145
|
+
--
|
|
146
|
+
-- Consequência: qualquer tenant COM plano é recusado no próprio plugin cujas
|
|
147
|
+
-- facets o plano dele inclui, e `tenant_apply_plan` acende facets órfãs que a
|
|
148
|
+
-- casca corretamente não lê como plugin (packages/core/src/app/app-config.ts).
|
|
149
|
+
-- Sem este §4, o FullControl acende hoje (contas sem plano) e para de acender
|
|
150
|
+
-- no dia em que alguém vender o primeiro plano — que é a pior hora possível
|
|
151
|
+
-- para descobrir.
|
|
152
|
+
--
|
|
153
|
+
-- `included` só onde o plano JÁ inclui uma facet daquele plugin: ali a linha
|
|
154
|
+
-- que falta é um defeito, não uma decisão de produto (é o "defeito 2" que a
|
|
155
|
+
-- 010 §2 nomeia). Todo o resto entra como `optional`, e isso é deliberado:
|
|
156
|
+
-- `plan_permits` NÃO lê o `mode`, então `optional` levanta o teto sem que
|
|
157
|
+
-- `tenant_apply_plan` imponha o módulo a quem não pediu.
|
|
158
|
+
DO $$
|
|
159
|
+
DECLARE p text;
|
|
160
|
+
BEGIN
|
|
161
|
+
IF to_regprocedure('public.register_plan_grant(text,text,text,text,text)') IS NULL THEN
|
|
162
|
+
RAISE NOTICE '052: register_plan_grant ausente — §4 pulado';
|
|
163
|
+
RETURN;
|
|
164
|
+
END IF;
|
|
165
|
+
|
|
166
|
+
-- 4.1 a linha do plugin para cada plugin cujas facets os planos já incluem.
|
|
167
|
+
FOREACH p IN ARRAY ARRAY['financial', 'inventory', 'crm', 'agenda'] LOOP
|
|
168
|
+
PERFORM public.register_plan_grant('essential', p, '', 'included',
|
|
169
|
+
'O plugin é a facet de nome vazio: sem esta linha o teto recusa o plugin cujas facets este plano inclui (052 §4).');
|
|
170
|
+
PERFORM public.register_plan_grant('pro', p, '', 'included', NULL);
|
|
171
|
+
PERFORM public.register_plan_grant('scale', p, '', 'included', NULL);
|
|
172
|
+
END LOOP;
|
|
173
|
+
|
|
174
|
+
-- 4.2 o que o ERP soma, por plano. `optional` = permitido, não imposto.
|
|
175
|
+
-- No essential entram só os dois que atendem e cobram — um ERP inteiro não é
|
|
176
|
+
-- produto de plano básico.
|
|
177
|
+
PERFORM public.register_plan_grant('essential', 'conversations', '', 'optional', 'Onde o cliente e o fornecedor falam.');
|
|
178
|
+
PERFORM public.register_plan_grant('essential', 'notifications', '', 'optional', 'Toda cobrança e toda confirmação saem por aqui.');
|
|
179
|
+
|
|
180
|
+
FOREACH p IN ARRAY ARRAY['conversations', 'notifications', 'marketing', 'automations',
|
|
181
|
+
'reports', 'tasks', 'forms'] LOOP
|
|
182
|
+
PERFORM public.register_plan_grant('pro', p, '', 'optional', NULL);
|
|
183
|
+
PERFORM public.register_plan_grant('scale', p, '', 'optional', NULL);
|
|
184
|
+
END LOOP;
|
|
185
|
+
|
|
186
|
+
-- 4.3 os addons: permitidos só no topo, e nunca `included`.
|
|
187
|
+
PERFORM public.register_plan_grant('scale', 'fiscal-br', '', 'optional', 'Addon: a emissão fiscal é contratada, não incluída.');
|
|
188
|
+
PERFORM public.register_plan_grant('scale', 'banking-br', '', 'optional', 'Addon: a conciliação bancária é contratada.');
|
|
189
|
+
END $$;
|
|
190
|
+
|
|
191
|
+
-- ── §5 os plugins que o app exige, com o teto do plano respeitado ─────────
|
|
192
|
+
--
|
|
193
|
+
-- DISTINCT porque um tenant que chega por dois caminhos faria o ON CONFLICT
|
|
194
|
+
-- recusar tocar a linha duas vezes; `plan_permits` porque o teto do plano vence
|
|
195
|
+
-- sempre sobre um padrão — uma conta cujo plano não inclui o plugin não o
|
|
196
|
+
-- ganha aceso, e a semeadura não falha por isso (011 §3, mesma regra).
|
|
197
|
+
INSERT INTO app.tenant_plugins (tenant_id, plugin_id, facet, status, source)
|
|
198
|
+
SELECT DISTINCT ta.tenant_id, r.plugin_id, '', 'active', 'default'
|
|
199
|
+
FROM app.tenant_apps ta
|
|
200
|
+
JOIN app.apps a ON a.id = ta.app_id
|
|
201
|
+
CROSS JOIN LATERAL unnest(a.requires) AS r(plugin_id)
|
|
202
|
+
WHERE ta.app_id = 'full'
|
|
203
|
+
AND ta.status = 'active'
|
|
204
|
+
AND public.plan_permits(r.plugin_id, '', ta.tenant_id)
|
|
205
|
+
ON CONFLICT (tenant_id, plugin_id, facet) DO NOTHING;
|
|
206
|
+
|
|
207
|
+
-- ── §6 (comentado) o ramo órfão ───────────────────────────────────────────
|
|
208
|
+
--
|
|
209
|
+
-- 'agency' está em app.vertical_defaults desde o baseline e NENHUM app o serve:
|
|
210
|
+
-- um tenant criado com vertical_id='agency' ganha plugins e zero apps. Se
|
|
211
|
+
-- alguém decidir que o ERP genérico é a resposta honesta para esse ramo, é UMA
|
|
212
|
+
-- linha — e ela merece uma migration própria, para poder ser revista sozinha:
|
|
213
|
+
-- UPDATE app.apps SET serves_verticals = ARRAY['agency'] WHERE id = 'full';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 053_o_erp_para_de_exigir_o_que_nao_desenha.sql — o FullControl encolhe o
|
|
3
|
+
-- `requires` para o que ele realmente monta.
|
|
4
|
+
--
|
|
5
|
+
-- A 052 registrou o app com oito plugins, e naquele dia ele montava os oito. No
|
|
6
|
+
-- mesmo dia o recorte apertou: o FullControl é para a empresa que vende SERVIÇO
|
|
7
|
+
-- e SOFTWARE — agência, software house, consultoria —, e duas coisas saíram do
|
|
8
|
+
-- bundle por não existirem nessa casa:
|
|
9
|
+
--
|
|
10
|
+
-- `inventory` prateleira. Quem tem estoque é atendido pelo StoreControl e
|
|
11
|
+
-- pelo ChefControl, e um ERP que oferece contagem de inventário
|
|
12
|
+
-- a quem não tem depósito é um formulário a mais.
|
|
13
|
+
-- `marketing` campanha e canal de aquisição. O funil fica (o orçamento é o
|
|
14
|
+
-- começo do título), mas a máquina de aquisição é produto do
|
|
15
|
+
-- LeadControl, que é justamente de onde este app veio.
|
|
16
|
+
--
|
|
17
|
+
-- ── Por que isso é uma migration, e não uma edição na 052 ──────────────────
|
|
18
|
+
--
|
|
19
|
+
-- A 052 já foi aplicada e o ledger guarda o sha256 dela; arquivo aplicado é
|
|
20
|
+
-- congelado. E a 052 tem, de propósito, um `ON CONFLICT` que só faz `requires`
|
|
21
|
+
-- CRESCER numa reaplicação — então nem reaplicá-la desfaria isto. Encolher é um
|
|
22
|
+
-- ato explícito, e é este arquivo.
|
|
23
|
+
--
|
|
24
|
+
-- ── O que este arquivo NÃO faz: desligar plugin de ninguém ─────────────────
|
|
25
|
+
--
|
|
26
|
+
-- `requires` é lista de SEMEADURA, não de dependência: ele decide o que um
|
|
27
|
+
-- tenant ganha ao ATIVAR o app daqui para a frente. As contas que já receberam
|
|
28
|
+
-- `inventory` e `marketing` ficam com eles acesos, e isso está certo — a
|
|
29
|
+
-- 052 §5 os acendeu para o TENANT (app.tenant_plugins não tem app_id), e o
|
|
30
|
+
-- ChefControl e o StoreControl das mesmas contas estão de pé sobre `inventory`
|
|
31
|
+
-- neste exato momento. Apagar a linha aqui apagaria o estoque do restaurante.
|
|
32
|
+
-- ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
UPDATE app.apps
|
|
35
|
+
SET requires = ARRAY['crm', 'conversations', 'automations', 'notifications',
|
|
36
|
+
'reports', 'financial']
|
|
37
|
+
WHERE id = 'full';
|
package/package.json
CHANGED