@fayz-ai/db 0.14.0 → 0.16.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.
Files changed (68) hide show
  1. package/migrations/011_leadcontrol_joins_the_family.sql +5 -1
  2. package/migrations/029_o_administrador_enxerga_o_que_existe.sql +167 -0
  3. package/migrations/031_ticketcontrol_joins_the_family.sql +109 -0
  4. package/migrations/032_a_marca_e_um_conjunto_de_tokens.sql +291 -0
  5. package/migrations/041_a_entrega_tem_onde_guardar_a_politica.sql +53 -0
  6. package/migrations/042_o_token_de_marca_fecha_a_porta_anon.sql +17 -0
  7. package/migrations/043_fiscalcontrol_joins_the_family.sql +75 -0
  8. package/migrations/044_o_teste_gratis_dura_trinta_dias.sql +15 -0
  9. package/migrations/045_o_espelho_manda_enquanto_o_v2_nao_escreveu.sql +519 -0
  10. package/migrations/046_the_tick_advances_the_flows.sql +77 -0
  11. package/migrations/047_the_tick_sweeps_the_offers.sql +74 -0
  12. package/migrations/052_fullcontrol_joins_the_family.sql +213 -0
  13. package/migrations/053_o_erp_para_de_exigir_o_que_nao_desenha.sql +37 -0
  14. package/migrations/054_o_arquivo_migrado_ganha_bytes.sql +439 -0
  15. package/migrations/061_a_linha_sem_data_pode_ser_estrutura.sql +253 -0
  16. package/migrations/063_o_anexo_ja_registrado_recebe_os_bytes.sql +303 -0
  17. package/migrations/064_um_arquivo_anexado_a_dezenove_contas.sql +178 -0
  18. package/migrations/065_apagar_o_sobrevivente_nao_apaga_o_tenant.sql +59 -0
  19. package/migrations/066_the_port_machinery_closes_its_doors.sql +51 -0
  20. package/migrations/067_the_erp_keeps_the_front_door.sql +56 -0
  21. package/migrations/068_o_ledger_encontra_a_linha_pelo_caminho_que_o_writer_usa.sql +55 -0
  22. package/migrations/069_a_sondagem_de_irma_usa_indice.sql +293 -0
  23. package/migrations/071_o_sdr_trabalha_o_funil_e_mais_nada.sql +110 -0
  24. package/migrations/072_o_porte_nao_avisa_a_plataforma_de_uma_venda_antiga.sql +91 -0
  25. package/migrations/073_a_exclusao_e_contrato_do_cliente_nao_do_cluster.sql +300 -0
  26. package/migrations/074_a_classificacao_quebrada_nao_leva_o_dinheiro_junto.sql +332 -0
  27. package/migrations/075_o_apelido_da_referencia_nao_disputa_com_a_variavel.sql +276 -0
  28. package/migrations/076_o_desligado_tambem_precisa_atravessar.sql +110 -0
  29. package/migrations/077_a_unidade_escolhida_chega_ao_banco.sql +128 -0
  30. package/migrations/078_onde_o_profissional_atende_atravessa.sql +71 -0
  31. package/migrations/079_quem_aparece_na_agenda_e_quem_atende.sql +210 -0
  32. package/migrations/080_o_contexto_da_requisicao_se_calcula_uma_vez.sql +240 -0
  33. package/migrations/081_a_permissao_por_unidade_se_resolve_uma_vez_por_unidade.sql +88 -0
  34. package/migrations/082_a_cerca_tambem_se_lembra.sql +110 -0
  35. package/migrations/083_quem_e_profissional_continua_agendavel.sql +32 -0
  36. package/migrations/084_quem_atende_em_varias_nao_cabe_numa_coluna.sql +60 -0
  37. package/migrations/085_a_unidade_do_profissional_vem_de_onde_ele_atende.sql +47 -0
  38. package/migrations/086_a_ponte_de_clientes_volta_a_existir.sql +89 -0
  39. package/migrations/087_a_liberacao_pergunta_ao_indice_antes_da_funcao.sql +79 -0
  40. package/migrations/088_quem_pode_executar_o_que_o_porte_criou.sql +50 -0
  41. package/migrations/089_a_memoria_de_transacao_nao_serve_para_isto.sql +86 -0
  42. package/migrations/090_a_marca_tambem_conta_como_mudanca_de_configuracao.sql +75 -0
  43. package/migrations/091_o_shell_tambem_e_configuracao_do_tenant.sql +71 -0
  44. package/migrations/092_a_marca_tem_uma_grafia_so_e_uma_porta_so.sql +159 -0
  45. package/migrations/093_a_marca_carrega_o_tema_inteiro.sql +179 -0
  46. package/migrations/094_a_porta_do_dominio_aprende_a_grafia_nova.sql +55 -0
  47. package/migrations/095_o_aplicativo_tem_um_preset_que_a_conta_estende.sql +98 -0
  48. package/migrations/096_o_sublinhado_tambem_e_nome_de_modulo.sql +100 -0
  49. package/migrations/097_o_oficio_diz_o_que_oferece_e_a_conta_o_que_usa.sql +137 -0
  50. package/migrations/098_a_arvore_do_oficio_nomeia_quem_ainda_desenha.sql +22 -0
  51. package/migrations/099_o_estoque_mora_dentro_de_produtos.sql +48 -0
  52. package/migrations/100_o_chefcontrol_desenha_abas_de_modulo.sql +24 -0
  53. package/migrations/101_o_oficio_agrupa_o_plugin_nomeia.sql +93 -0
  54. package/migrations/102_tres_marcas_ganham_fundo_e_contraste.sql +73 -0
  55. package/migrations/103_great_djs_e_iam_club_entram_na_frota.sql +75 -0
  56. package/migrations/104_o_curso_e_a_bilheteria_ganham_preset.sql +29 -0
  57. package/migrations/105_o_que_o_oficio_exige_ele_tambem_oferece.sql +78 -0
  58. package/migrations/106_a_comunidade_se_gere_como_escola.sql +84 -0
  59. package/migrations/107_o_logo_da_coluna_e_projecao_do_documento.sql +53 -0
  60. package/migrations/108_o_modulo_do_oficio_vizinho_tem_onde_ser_visto.sql +78 -0
  61. package/migrations/109_a_conta_diz_o_que_nao_usa_e_a_marca_pinta_a_casa.sql +67 -0
  62. package/migrations/110_a_tabela_de_ramos_aprende_o_vocabulario_fechado.sql +60 -0
  63. package/migrations/111_o_erp_generico_nao_vende_por_pedido.sql +50 -0
  64. package/migrations/112_o_cache_do_contexto_sabe_de_quem_ele_e.sql +327 -0
  65. package/migrations/113_o_ramo_volta_a_saber_que_papeis_a_conta_nasce_tendo.sql +124 -0
  66. package/migrations/114_a_resposta_inicial_volta_a_dizer_o_que_a_pessoa_pode.sql +93 -0
  67. package/migrations/115_o_rail_e_de_quem_tem_um.sql +108 -0
  68. package/package.json +1 -1
@@ -0,0 +1,159 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 074_a_marca_tem_uma_grafia_so_e_uma_porta_so.sql — as quatro grafias da marca
3
+ -- viram uma, e a porta para de mentir sobre a permissão que exige.
4
+ --
5
+ -- ── O que estava acontecendo ───────────────────────────────────────────────
6
+ -- `app_config()` devolve DOIS campos que falam da mesma coisa: `branding`
7
+ -- (de `tenant_branding()`, que lê o documento) e `settings` (de
8
+ -- `tenant_settings()`, que agrega as chaves registradas). Medido no cluster,
9
+ -- eles discordam:
10
+ --
11
+ -- synthetic-salon-network → settings diz "Synthetic Salon", branding diz NULL
12
+ -- espaco-renova-rio → os dois dizem NULL, e a tela mostra o nome certo
13
+ --
14
+ -- A causa é que a marca é escrita por `tenant_brand_patch`, que NÃO passa pelo
15
+ -- registro, e o registro declara quatro chaves planas (`branding.app_name`,
16
+ -- `.logo_url`, `.primary_color`, `.custom_domain`) que descrevem uma forma que
17
+ -- o produto parou de gravar na 032. O registro descreve um formato morto, e
18
+ -- `tenant_settings()` reporta esse formato morto como se fosse a verdade.
19
+ --
20
+ -- ── Quatro grafias, não três ───────────────────────────────────────────────
21
+ -- A 032 documentou três (camadas, cobra, camelo) e o leitor tolera as três. Há
22
+ -- uma QUARTA que ninguém previu: `branding.domain`, escrita pela migração do V1
23
+ -- (`tenant_domains → jsonb:branding.domain`, no allowlist). Nenhum dos três
24
+ -- leitores a conhece — então o domínio do Espaço Facial está gravado e
25
+ -- invisível para o sistema inteiro, inclusive para a loja pública que deveria
26
+ -- responder nele.
27
+ --
28
+ -- ── Por que a marca NÃO vira chave de registro ─────────────────────────────
29
+ -- A saída óbvia seria registrar as chaves em camadas. Ela não cabe, e não é só
30
+ -- pelo CHECK da coluna (`^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$` recusa
31
+ -- `identity.logoUrl`): o registro existe para valores ESCALARES que se gravam
32
+ -- sozinhos, um `tenant_setting_set` por vez, com checagem de tipo. A marca tem
33
+ -- arrays (`voice.avoid`, `voice.ctas`) e uma semântica de fusão POR CAMADA que
34
+ -- existe de propósito — é o que impede duas pessoas editando abas diferentes de
35
+ -- apagarem o trabalho uma da outra.
36
+ --
37
+ -- Então a decisão é a inversa: a marca é um DOCUMENTO, com porta própria, e as
38
+ -- quatro chaves planas no registro é que são o defeito. Elas saem.
39
+ --
40
+ -- ── A permissão que não existe ─────────────────────────────────────────────
41
+ -- `tenant_brand_patch` exige `settings.edit`. Não há `settings.edit` no
42
+ -- catálogo — só `settings.read` e `settings.manage`. A função só funciona
43
+ -- porque `app.has_permission` casa o irmão `.manage` de qualquer permissão.
44
+ -- Funciona por acidente: no dia em que essa regra de irmão apertar, editar a
45
+ -- marca quebra para todo mundo, sem nada no código dizer por quê. Passa a
46
+ -- exigir `settings.manage`, que é o que ela já exige de fato — e é a mesma
47
+ -- permissão de `tenant_setting_set` e `tenant_plugin_set`, o que faz as três
48
+ -- portas de configuração pedirem a mesma coisa.
49
+ --
50
+ -- Idempotente: a normalização é escrita de forma que rodar duas vezes não muda
51
+ -- nada, porque depois da primeira não sobra grafia velha para mover.
52
+ -- ---------------------------------------------------------------------------
53
+
54
+ -- ─────────────────────────────────────────────────────────────────────────
55
+ -- §1 Uma grafia só, nos dados
56
+ --
57
+ -- Camadas ganham de plano. Só move o que ainda não tem destino em camada, para
58
+ -- que um tenant já normalizado (ou editado pela tela depois desta migração)
59
+ -- nunca tenha o valor novo sobrescrito pelo antigo.
60
+ -- ─────────────────────────────────────────────────────────────────────────
61
+
62
+ UPDATE public.tenants t
63
+ SET settings = app.jsonb_set_path(
64
+ coalesce(t.settings, '{}'::jsonb),
65
+ ARRAY['branding'],
66
+ (
67
+ WITH b AS (SELECT coalesce(t.settings -> 'branding', '{}'::jsonb) AS d)
68
+ SELECT
69
+ -- Tira as grafias antigas depois de tê-las lido.
70
+ ((((((((b.d
71
+ - 'app_name') - 'appName')
72
+ - 'logo_url') - 'logoUrl')
73
+ - 'primary_color') - 'primaryColor')
74
+ - 'custom_domain') - 'customDomain') - 'domain'
75
+ -- E escreve cada valor no lugar em camadas, quando ele existe e o
76
+ -- lugar em camadas ainda está vazio.
77
+ || jsonb_strip_nulls(jsonb_build_object(
78
+ 'identity', jsonb_strip_nulls(jsonb_build_object(
79
+ 'name', coalesce(nullif(b.d #>> '{identity,name}', ''),
80
+ nullif(b.d ->> 'app_name', ''), nullif(b.d ->> 'appName', '')),
81
+ 'logoUrl', coalesce(nullif(b.d #>> '{identity,logoUrl}', ''),
82
+ nullif(b.d ->> 'logo_url', ''), nullif(b.d ->> 'logoUrl', '')),
83
+ 'customDomain', coalesce(nullif(b.d #>> '{identity,customDomain}', ''),
84
+ nullif(b.d ->> 'custom_domain', ''), nullif(b.d ->> 'customDomain', ''),
85
+ -- A quarta grafia, da migração do V1.
86
+ nullif(b.d ->> 'domain', ''))
87
+ ))
88
+ ))
89
+ -- `tokens` é escrito à parte porque `||` funde RASO: mandar
90
+ -- {tokens:{color:{...}}} trocaria `tokens` inteiro e levaria a
91
+ -- fonte junto.
92
+ || CASE
93
+ WHEN coalesce(nullif(b.d #>> '{tokens,color,primary}', ''),
94
+ nullif(b.d ->> 'primary_color', ''), nullif(b.d ->> 'primaryColor', '')) IS NULL
95
+ THEN '{}'::jsonb
96
+ ELSE jsonb_build_object('tokens',
97
+ app.jsonb_set_path(coalesce(b.d -> 'tokens', '{}'::jsonb),
98
+ ARRAY['color','primary'],
99
+ to_jsonb(coalesce(nullif(b.d #>> '{tokens,color,primary}', ''),
100
+ nullif(b.d ->> 'primary_color', ''), nullif(b.d ->> 'primaryColor', '')))))
101
+ END
102
+ FROM b
103
+ ))
104
+ WHERE t.settings -> 'branding' ?| ARRAY['app_name','appName','logo_url','logoUrl',
105
+ 'primary_color','primaryColor',
106
+ 'custom_domain','customDomain','domain'];
107
+
108
+ -- ─────────────────────────────────────────────────────────────────────────
109
+ -- §2 As quatro chaves planas saem do registro
110
+ --
111
+ -- Elas descrevem um formato que o produto parou de gravar, e a presença delas é
112
+ -- o que faz `tenant_settings()` reportar um recorte que contradiz
113
+ -- `tenant_branding()`. A marca continua em `app_config().branding`, que é a
114
+ -- resposta do documento inteiro.
115
+ -- ─────────────────────────────────────────────────────────────────────────
116
+
117
+ DELETE FROM app.tenant_setting_keys
118
+ WHERE key IN ('branding.app_name', 'branding.logo_url', 'branding.primary_color', 'branding.custom_domain');
119
+
120
+ -- ─────────────────────────────────────────────────────────────────────────
121
+ -- §3 A porta pede a permissão que existe
122
+ -- ─────────────────────────────────────────────────────────────────────────
123
+
124
+ CREATE OR REPLACE FUNCTION public.tenant_brand_patch(p_patch jsonb)
125
+ RETURNS jsonb
126
+ LANGUAGE plpgsql
127
+ SECURITY DEFINER
128
+ SET search_path TO ''
129
+ AS $function$
130
+ DECLARE v_tenant uuid := app.current_tenant_id();
131
+ BEGIN
132
+ IF v_tenant IS NULL THEN
133
+ RAISE EXCEPTION 'tenant_brand_patch: sem tenant na sessão' USING ERRCODE = '42501';
134
+ END IF;
135
+ -- `settings.manage`, a mesma de `tenant_setting_set` e `tenant_plugin_set`.
136
+ -- Antes pedia `settings.edit`, que não existe no catálogo e só passava porque
137
+ -- `app.has_permission` casa o irmão `.manage` — funcionava por acidente.
138
+ IF NOT app.has_permission('settings.manage', NULL::uuid) THEN
139
+ RAISE EXCEPTION 'tenant_brand_patch: exige settings.manage' USING ERRCODE = '42501';
140
+ END IF;
141
+
142
+ UPDATE public.tenants t
143
+ SET settings = jsonb_set(
144
+ coalesce(t.settings, '{}'::jsonb),
145
+ '{branding}',
146
+ -- `||` funde raso, e é o que se quer POR CAMADA: mandar
147
+ -- {"voice":{...}} troca a voz inteira e não toca em tokens. Mandar
148
+ -- {"tokens":{"color":{...}}} trocaria `tokens` inteiro, então a tela
149
+ -- manda a camada `tokens` completa quando mexe em qualquer cor.
150
+ coalesce(t.settings -> 'branding', '{}'::jsonb) || coalesce(p_patch, '{}'::jsonb)
151
+ ),
152
+ updated_at = now()
153
+ WHERE t.id = v_tenant;
154
+
155
+ RETURN coalesce(app.brand_of(v_tenant), '{}'::jsonb);
156
+ END $function$;
157
+
158
+ COMMENT ON FUNCTION public.tenant_brand_patch(jsonb) IS
159
+ 'A ÚNICA porta de escrita da marca. Funde por camada (identity / tokens / voice) para que duas pessoas em abas diferentes não apaguem o trabalho uma da outra. A marca é um documento, não um conjunto de chaves registradas — por isso não passa por tenant_setting_set.';
@@ -0,0 +1,179 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 075_a_marca_carrega_o_tema_inteiro.sql — a marca do inquilino deixa de ser
3
+ -- uma cor e passa a ser o tema (#280, ADR 0023).
4
+ --
5
+ -- ── O que a tela mostrou ───────────────────────────────────────────────────
6
+ -- Medido no sistema que o Espaço Facial usa hoje
7
+ -- (https://sistema.espacofacial.com.br), lendo as variáveis da página:
8
+ --
9
+ -- --primary 31 50% 72% o bege
10
+ -- --background 40 33% 97% o papel
11
+ -- rail (elemento) #121212 preto
12
+ -- item ativo do rail #242424 com o texto em #DBB994
13
+ -- --radius 0.75rem
14
+ -- logo um SVG BRANCO, em .../tenant-appearance/1/logo-….svg
15
+ --
16
+ -- Nosso documento de marca carregava `tokens.color` e `tokens.font`. Com isso,
17
+ -- o Espaço Facial recebia o bege e herdava o rail do aplicativo: a marca
18
+ -- chegava pela metade, e a metade que faltava era justamente a que dá a
19
+ -- impressão de "é o meu sistema".
20
+ --
21
+ -- O V1 já tinha resolvido: `software_profiles.theme` guarda um tema inteiro por
22
+ -- perfil, com camadas `rail` / `surface` / `emphasis` / `status` / `chart` /
23
+ -- `radius`. Esta migração traz o mesmo recorte para o documento de marca, no
24
+ -- vocabulário que o `SaasTheme` do shell já consome — o que o banco grava é o
25
+ -- que a casca já sabe pintar, sem tradutor no meio.
26
+ --
27
+ -- ── Duas artes, não uma ────────────────────────────────────────────────────
28
+ -- A logo do Espaço Facial é branca. Sobre papel ela some; sobre o rail preto
29
+ -- ela aparece. Um contrato com um campo só obriga a escolher qual dos dois
30
+ -- fundos vai ficar errado, então `identity` ganha `logoDarkUrl` (fundo escuro)
31
+ -- ao lado de `logoUrl` (fundo claro). O ÍCONE quadrado (`markUrl`) não precisa
32
+ -- do par: o ladrilho que o desenha leva o próprio fundo escuro para qualquer
33
+ -- superfície, então a arte clara serve no rail, no popover e no seletor
34
+ -- pós-login. Só o wordmark tem duas versões, porque ele é desenhado direto
35
+ -- sobre o fundo de quem o hospeda.
36
+ --
37
+ -- ── E `app_config().branding` passa a devolver o documento ─────────────────
38
+ -- Ele devolvia `tenant_branding()`, que é o recorte ACHATADO de seis campos.
39
+ -- Enquanto isso o shell lia o tema de `tenants.settings.branding` direto pela
40
+ -- store da organização — dois leitores da mesma marca, com formas diferentes,
41
+ -- que é a mesma família de defeito que a 074 fechou. Agora os dois recebem
42
+ -- `app.brand_of`, o documento em camadas.
43
+ --
44
+ -- `tenant_branding()` fica, sem mudar de forma: as páginas públicas e o
45
+ -- switcher leem os seis campos achatados dela e não devem ser mexidos por uma
46
+ -- migração de tema.
47
+ --
48
+ -- Idempotente: substitui duas funções.
49
+ -- ---------------------------------------------------------------------------
50
+
51
+ CREATE OR REPLACE FUNCTION app.brand_of(p_tenant uuid)
52
+ RETURNS jsonb
53
+ LANGUAGE plpgsql
54
+ STABLE SECURITY DEFINER
55
+ SET search_path TO ''
56
+ AS $function$
57
+ DECLARE t record; b jsonb;
58
+ BEGIN
59
+ SELECT x.id, x.name, x.slug, x.logo_url, x.settings INTO t
60
+ FROM public.tenants x WHERE x.id = p_tenant;
61
+ IF t.id IS NULL THEN RETURN NULL; END IF;
62
+
63
+ b := coalesce(t.settings -> 'branding', '{}'::jsonb);
64
+
65
+ RETURN jsonb_strip_nulls(jsonb_build_object(
66
+ 'identity', jsonb_build_object(
67
+ 'name', coalesce(app.brand_token(b, '{identity,name}', 'app_name', 'appName'), t.name),
68
+ 'tagline', app.brand_token(b, '{identity,tagline}', 'tagline', 'tagline'),
69
+ 'mission', app.brand_token(b, '{identity,mission}', 'mission', 'mission'),
70
+ 'logoUrl', coalesce(app.brand_token(b, '{identity,logoUrl}', 'logo_url', 'logoUrl'), t.logo_url),
71
+ -- A arte para fundo escuro. Sem ela o rail preto recebe uma logo clara,
72
+ -- ou — no caso do Espaço Facial, cuja logo é branca — recebe um retângulo
73
+ -- invisível sobre papel.
74
+ 'logoDarkUrl', app.brand_token(b, '{identity,logoDarkUrl}', 'logo_dark_url', 'logoDarkUrl'),
75
+ -- O ícone quadrado, numa versão só: a arte CLARA. Sem ele o rail
76
+ -- recolhido corta o wordmark no meio e "Espaço Facial" chega na tela como
77
+ -- "aço". Uma versão basta porque o ladrilho que a desenha leva o próprio
78
+ -- fundo escuro para qualquer superfície — duas versões seriam dois
79
+ -- arquivos entregues pela casa, e um deles envelhecendo.
80
+ 'markUrl', app.brand_token(b, '{identity,markUrl}', 'mark_url', 'markUrl'),
81
+ 'faviconUrl',app.brand_token(b, '{identity,faviconUrl}', 'favicon_url', 'faviconUrl'),
82
+ 'customDomain', app.brand_token(b, '{identity,customDomain}', 'custom_domain', 'customDomain')
83
+ ),
84
+ 'tokens', jsonb_strip_nulls(jsonb_build_object(
85
+ 'color', jsonb_strip_nulls(jsonb_build_object(
86
+ 'primary', app.brand_token(b, '{tokens,color,primary}', 'primary_color', 'primaryColor'),
87
+ 'accent', app.brand_token(b, '{tokens,color,accent}', 'accent_color', 'accentColor')
88
+ )),
89
+ 'font', jsonb_strip_nulls(jsonb_build_object(
90
+ 'heading', app.brand_token(b, '{tokens,font,heading}', 'heading_font', 'headingFont'),
91
+ 'body', app.brand_token(b, '{tokens,font,body}', 'body_font', 'bodyFont')
92
+ )),
93
+ -- O papel e a tinta sobre ele.
94
+ 'surface', b #> '{tokens,surface}',
95
+ -- O rail. Mesmo vocabulário de `SaasTheme.sidebar`, de propósito.
96
+ 'rail', b #> '{tokens,rail}',
97
+ 'radius', b #>> '{tokens,radius}'
98
+ )),
99
+ 'voice', jsonb_strip_nulls(jsonb_build_object(
100
+ 'tone', app.brand_token(b, '{voice,tone}', 'tone_of_voice', 'toneOfVoice'),
101
+ 'lexicon', b #> '{voice,lexicon}',
102
+ 'avoid', b #> '{voice,avoid}',
103
+ 'ctas', b #> '{voice,ctas}',
104
+ 'hashtags', b #> '{voice,hashtags}'
105
+ ))
106
+ ));
107
+ END $function$;
108
+
109
+ COMMENT ON FUNCTION app.brand_of(uuid) IS
110
+ 'A marca do inquilino como DOCUMENTO em três camadas: identity (quem é, com uma arte por fundo), tokens (como se parece — cor, papel, rail, raio, tipo) e voice (como fala). É o que o shell pinta e o que o agente lê. `tenant_branding()` continua devolvendo o recorte achatado de seis campos para o switcher e as páginas públicas.';
111
+
112
+ -- `app_config().branding` passa a devolver o documento, não o recorte.
113
+ CREATE OR REPLACE FUNCTION public.app_config()
114
+ RETURNS jsonb
115
+ LANGUAGE plpgsql
116
+ STABLE SECURITY DEFINER
117
+ SET search_path TO ''
118
+ AS $function$
119
+ DECLARE
120
+ v_tenant uuid := app.current_tenant_id();
121
+ v_staff text;
122
+ BEGIN
123
+ -- `platform` e o schema do control-admin: existe no cluster que hospeda a
124
+ -- plataforma e NAO na cadeia canonica, onde esta funcao e reconstruida do
125
+ -- zero. Chamar direto compila (o corpo de plpgsql so resolve nomes na
126
+ -- primeira execucao) e estoura na primeira chamada com "schema platform
127
+ -- does not exist" — que e o pedagio de `search_path` vazio ja pago na
128
+ -- 060. Perguntar antes deixa a mesma definicao servir aos dois: sem o
129
+ -- schema, ninguem e staff, e o control-admin simplesmente nao e oferecido.
130
+ IF pg_catalog.to_regproc('platform.staff_role(uuid)') IS NOT NULL THEN
131
+ EXECUTE 'SELECT platform.staff_role($1)' INTO v_staff USING (SELECT auth.uid());
132
+ END IF;
133
+
134
+ IF v_tenant IS NULL THEN
135
+ RETURN jsonb_build_object('tenant_id', NULL, 'plugins', '[]'::jsonb, 'nav', '[]'::jsonb, 'apps', '[]'::jsonb);
136
+ END IF;
137
+
138
+ RETURN jsonb_build_object(
139
+ 'tenant_id', v_tenant,
140
+ -- O documento (075). Era o recorte achatado de `tenant_branding()`, e o
141
+ -- shell lia o tema por outro caminho — dois leitores, duas formas.
142
+ 'branding', coalesce(app.brand_of(v_tenant), '{}'::jsonb),
143
+ 'settings', public.tenant_settings(),
144
+ 'plugins', coalesce((
145
+ SELECT jsonb_agg(jsonb_build_object(
146
+ 'plugin', t.plugin_id,
147
+ 'facet', nullif(t.facet, ''),
148
+ 'status', CASE
149
+ WHEN t.expires_at IS NOT NULL AND t.expires_at <= now() THEN 'disabled'
150
+ ELSE t.status
151
+ END,
152
+ 'source', t.source,
153
+ 'config', t.config,
154
+ 'expires_at', t.expires_at
155
+ ) ORDER BY t.plugin_id, t.facet)
156
+ FROM app.tenant_plugins t
157
+ WHERE t.tenant_id = v_tenant
158
+ ), '[]'::jsonb),
159
+ 'apps', coalesce((
160
+ SELECT jsonb_agg(x ORDER BY x ->> 'house', x ->> 'name')
161
+ FROM (
162
+ SELECT jsonb_build_object('id', a.id, 'name', a.name, 'icon', a.icon,
163
+ 'accent_color', a.accent_color, 'url', a.url, 'house', false) AS x
164
+ FROM app.tenant_apps ta JOIN app.apps a ON a.id = ta.app_id
165
+ WHERE ta.tenant_id = v_tenant AND ta.status = 'active' AND a.active
166
+ UNION ALL
167
+ SELECT jsonb_build_object('id', a.id, 'name', a.name, 'icon', a.icon,
168
+ 'accent_color', a.accent_color, 'url', a.url, 'house', true) AS x
169
+ FROM app.apps a
170
+ WHERE v_staff IS NOT NULL AND a.id = 'control-admin' AND a.active AND a.url IS NOT NULL
171
+ ) apps
172
+ ), '[]'::jsonb),
173
+ 'nav', coalesce((
174
+ SELECT jsonb_agg(jsonb_build_object('key', n.entry_key, 'hidden', n.hidden, 'label', n.label,
175
+ 'section', n.section, 'position', n.position) ORDER BY n.entry_key)
176
+ FROM app.tenant_nav n WHERE n.tenant_id = v_tenant
177
+ ), '[]'::jsonb)
178
+ );
179
+ END $function$;
@@ -0,0 +1,55 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 076_a_porta_do_dominio_aprende_a_grafia_nova.sql — conserto de um defeito que
3
+ -- a 074 introduziu.
4
+ --
5
+ -- A 074 normalizou as quatro grafias da marca numa só e moveu o domínio
6
+ -- customizado para `branding.identity.customDomain`. `app.brand_token` tolera
7
+ -- as três leituras, então tudo que passa por ele continuou funcionando.
8
+ --
9
+ -- `public.tenant_branding_public` NÃO passa por ele: ela procura o tenant com
10
+ -- um `WHERE lower(settings #>> '{branding,custom_domain}') = ...`, a grafia
11
+ -- plana, escrita à mão. Depois da normalização essa chave não existe mais em
12
+ -- conta nenhuma, e a função passou a não achar ninguém.
13
+ --
14
+ -- O que isso quebrou: **a porta do domínio**. É a função anônima que responde
15
+ -- "de quem é este endereço" antes de haver sessão — o que faz
16
+ -- `sistema.espacofacial.com.br` abrir já na conta certa, e o que dá marca à
17
+ -- loja pública e à tela de login. Ela falha em silêncio: o `EXCEPTION WHEN
18
+ -- OTHERS` devolve um objeto de nulos, que lê como "domínio não cadastrado".
19
+ --
20
+ -- Passa a procurar pelas três grafias, na mesma ordem de desempate do
21
+ -- `app.brand_token`: camadas primeiro, porque é para onde tudo caminhou.
22
+ --
23
+ -- Idempotente: substitui uma função.
24
+ -- ---------------------------------------------------------------------------
25
+
26
+ CREATE OR REPLACE FUNCTION public.tenant_branding_public(p_slug text DEFAULT NULL::text, p_domain text DEFAULT NULL::text)
27
+ RETURNS jsonb
28
+ LANGUAGE plpgsql
29
+ STABLE SECURITY DEFINER
30
+ SET search_path TO ''
31
+ AS $function$
32
+ DECLARE v_tenant uuid; v_domain text := lower(btrim(coalesce(p_domain, '')));
33
+ BEGIN
34
+ IF nullif(btrim(coalesce(p_slug, '')), '') IS NOT NULL THEN
35
+ SELECT t.id INTO v_tenant FROM public.tenants t WHERE t.slug = lower(btrim(p_slug));
36
+ ELSIF v_domain <> '' THEN
37
+ SELECT t.id INTO v_tenant
38
+ FROM public.tenants t
39
+ WHERE lower(coalesce(
40
+ -- A grafia em camadas (074), que é onde o domínio mora agora.
41
+ t.settings #>> '{branding,identity,customDomain}',
42
+ -- E as duas antigas, para um pool que ainda não normalizou.
43
+ t.settings #>> '{branding,custom_domain}',
44
+ t.settings #>> '{branding,customDomain}'
45
+ )) = v_domain
46
+ ORDER BY t.created_at
47
+ LIMIT 1;
48
+ END IF;
49
+ RETURN app.branding_of(v_tenant) - 'error';
50
+ EXCEPTION WHEN OTHERS THEN
51
+ RETURN jsonb_build_object('tenant_id', NULL, 'name', NULL, 'slug', NULL, 'logo_url', NULL, 'primary_color', NULL, 'custom_domain', NULL);
52
+ END $function$;
53
+
54
+ COMMENT ON FUNCTION public.tenant_branding_public(text, text) IS
55
+ 'De quem é este endereço — a porta ANÔNIMA do domínio, respondida antes de haver sessão. É o que faz um domínio próprio abrir já na conta certa, e o que dá marca à loja pública e à tela de login. Aceita as três grafias do domínio; camadas primeiro.';
@@ -0,0 +1,98 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 077_o_aplicativo_tem_um_preset_que_a_conta_estende.sql — o rail curado de
3
+ -- cada Control vira dado, e a conta passa a ESTENDÊ-LO em vez de recriá-lo.
4
+ --
5
+ -- ── O defeito que isto fecha ───────────────────────────────────────────────
6
+ -- O aplicativo central monta o rail a partir dos plugins ativos da conta, e
7
+ -- para um restaurante isso deu QUINZE linhas planas: Pedidos, Agenda, Cozinha,
8
+ -- Salão, Estoque, CRM, Marketing, Reservas, Financeiro, Automações, Cardápio,
9
+ -- Comunicação, Análises… O ChefControl resolveu isso há muito tempo com SETE
10
+ -- rubricas e filhos dentro — "Operação" abriga Pedidos, Cozinha, Clientes e
11
+ -- Reservas; "Produtos" abriga Cardápio e Estoque.
12
+ --
13
+ -- Esse desenho é conhecimento de produto, validado em cliente. Jogá-lo fora e
14
+ -- deixar cada conta remontar a partir de plugins soltos é pedir a ela que
15
+ -- redescubra o que já sabíamos — e foi exatamente o que aconteceu.
16
+ --
17
+ -- ── O que o preset é, e o que ele NÃO é ────────────────────────────────────
18
+ -- Ele é o ponto de PARTIDA do aplicativo: a árvore do menu e a moldura do
19
+ -- shell, como o ofício pede. Ele não é o que a conta tem — isso é
20
+ -- `app.tenant_plugins`, e uma rubrica cujos filhos todos estão desligados
21
+ -- simplesmente não desenha.
22
+ --
23
+ -- As três camadas, e a ordem delas:
24
+ --
25
+ -- 1. `app.apps.preset` o aplicativo — a árvore curada do ofício
26
+ -- 2. `app.tenant_plugins` a conta — o que dela está ligado
27
+ -- 3. `app.tenant_nav` a conta de novo — a diferença que ela pediu
28
+ --
29
+ -- ── Por que uma coluna e não uma tabela ────────────────────────────────────
30
+ -- O preset é lido inteiro, sempre junto, e só o aplicativo escreve nele — não
31
+ -- há consulta que queira uma rubrica sozinha, nem escrita concorrente que peça
32
+ -- linhas. Uma tabela aqui daria um JOIN e uma montagem em árvore a cada boot
33
+ -- para responder o que um documento responde de uma vez.
34
+ --
35
+ -- Idempotente: uma coluna com IF NOT EXISTS e UPDATEs por id.
36
+ -- ---------------------------------------------------------------------------
37
+
38
+ ALTER TABLE app.apps ADD COLUMN IF NOT EXISTS preset jsonb NOT NULL DEFAULT '{}'::jsonb;
39
+
40
+ COMMENT ON COLUMN app.apps.preset IS
41
+ 'O ponto de partida deste aplicativo: `shell` (layout, navegação de módulo, moldura) e `nav` (a árvore curada do ofício, com rubricas e filhos). A conta ESTENDE isto — o que ela tem vem de app.tenant_plugins e a diferença que ela pediu, de app.tenant_nav.';
42
+
43
+ -- ── ChefControl ────────────────────────────────────────────────────────────
44
+ -- Sete rubricas. A ordem é a da operação: primeiro o que acontece durante o
45
+ -- serviço, depois o que se olha entre um serviço e outro.
46
+ UPDATE app.apps SET preset = $$
47
+ {
48
+ "shell": { "layout": "sidebar", "moduleNav": "sidebar", "contentFrame": true, "navChildConnector": true },
49
+ "nav": [
50
+ { "route": "/orders", "label": "Operação", "icon": "Utensils", "position": 10, "children": [
51
+ { "route": "/orders", "label": "Pedidos", "icon": "ClipboardList" },
52
+ { "route": "/kitchen", "label": "Cozinha", "icon": "ChefHat" },
53
+ { "route": "/clients", "label": "Clientes", "icon": "Users" },
54
+ { "route": "/reservations", "label": "Reservas", "icon": "CalendarClock" },
55
+ { "route": "/tables", "label": "Mesas", "icon": "MapPin" }
56
+ ]},
57
+ { "route": "/delivery", "label": "Delivery", "icon": "Bike", "position": 15 },
58
+ { "route": "/menu", "label": "Produtos", "icon": "Package", "position": 20, "children": [
59
+ { "route": "/menu", "label": "Cardápio", "icon": "UtensilsCrossed" },
60
+ { "route": "/inventory", "label": "Estoque", "icon": "Boxes" }
61
+ ]},
62
+ { "route": "/financial", "label": "Financeiro", "icon": "DollarSign", "position": 30 },
63
+ { "route": "/workforce", "label": "RH", "icon": "UsersRound", "position": 35 },
64
+ { "route": "/company", "label": "Cadastros", "icon": "Database", "position": 40, "children": [
65
+ { "route": "/staff", "label": "Equipe", "icon": "UserCog" }
66
+ ]},
67
+ { "route": "/analytics", "label": "Relatórios", "icon": "BarChart3", "position": 41 }
68
+ ]
69
+ }
70
+ $$::jsonb WHERE id = 'resto';
71
+
72
+ -- ── StudioControl ──────────────────────────────────────────────────────────
73
+ -- O estúdio nunca desenhou rail à mão: ele ORDENA os plugins, e a agenda vem
74
+ -- primeiro porque o dia dele é uma agenda. O preset guarda só isso.
75
+ UPDATE app.apps SET preset = $$
76
+ {
77
+ "shell": { "layout": "sidebar", "moduleNav": "tabs", "contentFrame": true },
78
+ "order": ["agenda", "crm", "financial", "inventory", "marketing", "workforce", "reports"]
79
+ }
80
+ $$::jsonb WHERE id = 'beauty';
81
+
82
+ -- ── StoreControl ───────────────────────────────────────────────────────────
83
+ UPDATE app.apps SET preset = $$
84
+ {
85
+ "shell": { "layout": "sidebar", "moduleNav": "tabs", "contentFrame": true },
86
+ "order": ["shop", "orders", "inventory", "crm", "marketing", "financial", "reports"]
87
+ }
88
+ $$::jsonb WHERE id = 'shop';
89
+
90
+ -- ── FullControl ────────────────────────────────────────────────────────────
91
+ -- O ERP não tem ofício, então não tem rubrica de ofício: a ordem é a do
92
+ -- dinheiro e da venda.
93
+ UPDATE app.apps SET preset = $$
94
+ {
95
+ "shell": { "layout": "sidebar", "moduleNav": "sidebar", "contentFrame": true, "navChildConnector": true },
96
+ "order": ["crm", "conversations", "financial", "inventory", "marketing", "automations", "reports"]
97
+ }
98
+ $$::jsonb WHERE id = 'full';
@@ -0,0 +1,100 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 078_o_sublinhado_tambem_e_nome_de_modulo.sql — o id de plugin passa a aceitar
3
+ -- a mesma grafia que a facet ao lado dele já aceita, e o preset volta pro arquivo.
4
+ -- ---------------------------------------------------------------------------
5
+ -- `app.tenant_plugins` recusava `custom_forms`:
6
+ --
7
+ -- new row for relation "tenant_plugins" violates check constraint
8
+ -- "tenant_plugins_plugin_id_check"
9
+ --
10
+ -- Não é política, é divergência. Na MESMA tabela, uma linha acima, `facet` já
11
+ -- aceita sublinhado (`^[a-z][a-z0-9_-]*$`), e `tenant_setting_keys.reader`
12
+ -- aceita `plugin:[a-z][a-z0-9_-]*` — quer dizer: o banco já sabia escrever
13
+ -- `plugin:custom_forms` num lugar e recusava `custom_forms` no outro.
14
+ --
15
+ -- Das 29 fábricas de plugin publicadas hoje, `custom_forms` é a ÚNICA com
16
+ -- sublinhado. Duas saídas existiam:
17
+ --
18
+ -- renomear pro hífen → 44 arquivos, mais as linhas já gravadas em
19
+ -- `app.permissions`, `role_permissions` e nos ids de
20
+ -- ferramenta do agente (`custom_forms.list-templates`)
21
+ -- alargar a checagem → três linhas, e o id volta a ser o do manifesto
22
+ --
23
+ -- A segunda, porque o defeito é da checagem: ela nunca encontrou o catálogo que
24
+ -- existe. Alargar não abre espaço pra lixo — a forma continua fechada e
25
+ -- continua minúscula; só reconhece o separador que o resto do schema já usa.
26
+ --
27
+ -- O preço honesto: `custom-forms` e `custom_forms` passam a ser dois ids
28
+ -- válidos e DIFERENTES. Quem escreve é `tenant_plugin_set`, e o que ela grava é
29
+ -- o id do manifesto — não há duas grafias em circulação, há uma, e agora ela
30
+ -- cabe.
31
+ -- ---------------------------------------------------------------------------
32
+
33
+ ALTER TABLE app.tenant_plugins DROP CONSTRAINT IF EXISTS tenant_plugins_plugin_id_check;
34
+ ALTER TABLE app.plan_grants DROP CONSTRAINT IF EXISTS plan_grants_plugin_id_check;
35
+ ALTER TABLE app.vertical_defaults DROP CONSTRAINT IF EXISTS vertical_defaults_plugin_id_check;
36
+
37
+ ALTER TABLE app.tenant_plugins
38
+ ADD CONSTRAINT tenant_plugins_plugin_id_check CHECK (plugin_id ~ '^[a-z][a-z0-9_-]*$');
39
+ ALTER TABLE app.plan_grants
40
+ ADD CONSTRAINT plan_grants_plugin_id_check CHECK (plugin_id ~ '^[a-z][a-z0-9_-]*$');
41
+ ALTER TABLE app.vertical_defaults
42
+ ADD CONSTRAINT vertical_defaults_plugin_id_check CHECK (plugin_id ~ '^[a-z][a-z0-9_-]*$');
43
+
44
+ -- ── O preset volta pro arquivo ─────────────────────────────────────────────
45
+ -- A 077 criou `app.apps.preset` e semeou os quatro aplicativos, mas a porta que
46
+ -- ENTREGA o preset foi aplicada só no cluster — o arquivo nunca a escreveu. Um
47
+ -- banco novo ganharia a coluna e nenhum cliente a leria. Repetida aqui inteira,
48
+ -- que é como uma função volta a ter dono.
49
+ CREATE OR REPLACE FUNCTION public.app_config()
50
+ RETURNS jsonb
51
+ LANGUAGE plpgsql
52
+ STABLE SECURITY DEFINER
53
+ SET search_path TO ''
54
+ AS $function$
55
+ DECLARE
56
+ v_tenant uuid := app.current_tenant_id();
57
+ v_staff text;
58
+ BEGIN
59
+ -- `platform` e o schema do control-admin: existe no cluster que hospeda a
60
+ -- plataforma e NAO na cadeia canonica, onde esta funcao e reconstruida do
61
+ -- zero. Chamar direto compila (o corpo de plpgsql so resolve nomes na
62
+ -- primeira execucao) e estoura na primeira chamada com "schema platform
63
+ -- does not exist" — que e o pedagio de `search_path` vazio ja pago na
64
+ -- 060. Perguntar antes deixa a mesma definicao servir aos dois: sem o
65
+ -- schema, ninguem e staff, e o control-admin simplesmente nao e oferecido.
66
+ IF pg_catalog.to_regproc('platform.staff_role(uuid)') IS NOT NULL THEN
67
+ EXECUTE 'SELECT platform.staff_role($1)' INTO v_staff USING (SELECT auth.uid());
68
+ END IF;
69
+
70
+ IF v_tenant IS NULL THEN
71
+ RETURN jsonb_build_object('tenant_id', NULL, 'plugins', '[]'::jsonb, 'nav', '[]'::jsonb, 'apps', '[]'::jsonb);
72
+ END IF;
73
+
74
+ RETURN jsonb_build_object(
75
+ 'tenant_id', v_tenant,
76
+ 'branding', coalesce(app.brand_of(v_tenant), '{}'::jsonb),
77
+ 'settings', public.tenant_settings(),
78
+ -- Todas as linhas, inclusive as desligadas: a regra no cliente é aditiva, e
79
+ -- uma linha ausente cai no padrão do manifesto — que é LIGADO.
80
+ 'plugins', coalesce((
81
+ SELECT jsonb_agg(jsonb_build_object('plugin', t.plugin_id, 'facet', nullif(t.facet, ''),
82
+ 'status', CASE WHEN t.expires_at IS NOT NULL AND t.expires_at <= now() THEN 'disabled' ELSE t.status END,
83
+ 'source', t.source, 'config', t.config, 'expires_at', t.expires_at)
84
+ ORDER BY t.plugin_id, t.facet)
85
+ FROM app.tenant_plugins t WHERE t.tenant_id = v_tenant), '[]'::jsonb),
86
+ 'apps', coalesce((
87
+ SELECT jsonb_agg(x ORDER BY x ->> 'house', x ->> 'name') FROM (
88
+ SELECT jsonb_build_object('id', a.id, 'name', a.name, 'icon', a.icon, 'accent_color', a.accent_color,
89
+ 'url', a.url, 'house', false, 'preset', a.preset) AS x
90
+ FROM app.tenant_apps ta JOIN app.apps a ON a.id = ta.app_id
91
+ WHERE ta.tenant_id = v_tenant AND ta.status = 'active' AND a.active
92
+ UNION ALL
93
+ SELECT jsonb_build_object('id', a.id, 'name', a.name, 'icon', a.icon, 'accent_color', a.accent_color,
94
+ 'url', a.url, 'house', true, 'preset', a.preset) AS x
95
+ FROM app.apps a WHERE v_staff IS NOT NULL AND a.id = 'control-admin' AND a.active AND a.url IS NOT NULL) apps), '[]'::jsonb),
96
+ 'nav', coalesce((
97
+ SELECT jsonb_agg(jsonb_build_object('key', n.entry_key, 'hidden', n.hidden, 'label', n.label,
98
+ 'section', n.section, 'position', n.position) ORDER BY n.entry_key)
99
+ FROM app.tenant_nav n WHERE n.tenant_id = v_tenant), '[]'::jsonb));
100
+ END $function$;