@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,327 @@
1
+ -- O cache do contexto sabe de quem ele é
2
+ --
3
+ -- A 076 passou a calcular o contexto da requisição UMA vez por transação, e
4
+ -- está certa no motivo: contar um mês de agenda pagava 2,7 ms por linha para
5
+ -- reconfirmar a mesma resposta. O que ela não fez foi dizer de QUEM é a
6
+ -- resposta guardada.
7
+ --
8
+ -- DECLARE v_cache text := current_setting('app.ctx_tenant', true);
9
+ -- IF v_cache IS NOT NULL AND v_cache <> '' THEN
10
+ -- RETURN nullif(v_cache, '-')::uuid; -- <- de quem?
11
+ --
12
+ -- `set_config(..., true)` é local à TRANSAÇÃO, não à requisição. Enquanto a
13
+ -- claim não muda dentro da transação, as duas coisas coincidem e ninguém nota.
14
+ -- Quando ela muda, o cache responde pelo dono anterior.
15
+ --
16
+ -- ── Onde isso aparece ───────────────────────────────────────────────────────
17
+ --
18
+ -- O pgTAP troca de persona dentro de uma transação só — é o jeito de provar
19
+ -- justamente as regras que esta função implementa. Trinta e sete suítes
20
+ -- quebraram de uma vez, todas na mesma frase:
21
+ --
22
+ -- "a claim naming another tenant resolves to no tenant"
23
+ -- have: 3fc8bcfd-… (o salão, do teste anterior)
24
+ -- want: NULL
25
+ --
26
+ -- O teste não estava errado. A função é que respondia pela persona passada.
27
+ --
28
+ -- Em produção o PostgREST abre uma transação por requisição e a claim não muda
29
+ -- no meio, então o furo não aparece — mas ele existe para qualquer caminho que
30
+ -- troque de sujeito dentro de uma transação, e o primeiro que fizer isso vai
31
+ -- receber a resposta de outra pessoa sem nenhum aviso.
32
+ --
33
+ -- ── O conserto ──────────────────────────────────────────────────────────────
34
+ --
35
+ -- Guardar, junto do valor, a CHAVE de quem o produziu, e só aceitar o cache
36
+ -- quando a chave é a mesma. A chave é a própria claim: `auth.uid()` e
37
+ -- `auth.jwt()` saem ambos de `request.jwt.claims`, então ela cobre todas as
38
+ -- entradas da regra crua. Para a unidade entra também o cabeçalho, que é de
39
+ -- onde `x-fayz-unit` vem.
40
+ --
41
+ -- O ganho da 076 fica de pé: dentro de uma requisição real a chave não muda, o
42
+ -- cache acerta toda vez, e a regra crua continua rodando uma vez só. O que
43
+ -- muda é que agora ele sabe quando não é dele que estão falando.
44
+ --
45
+ -- A regra em si não é tocada: `app._current_tenant_id_raw()` continua igual.
46
+ -- Idempotente.
47
+
48
+ CREATE OR REPLACE FUNCTION app.current_tenant_id()
49
+ RETURNS uuid
50
+ LANGUAGE plpgsql
51
+ STABLE SECURITY DEFINER
52
+ SET search_path TO ''
53
+ AS $function$
54
+ DECLARE
55
+ -- As DUAS grafias: `auth.uid()` le `request.jwt.claim.sub` e, se nao houver,
56
+ -- o `sub` de dentro de `request.jwt.claims`. Uma chave que olhasse so a
57
+ -- segunda nao veria a troca de pessoa feita pela primeira — que e como o
58
+ -- pgTAP e o codigo mais antigo trocam de sujeito.
59
+ v_key text := coalesce(current_setting('request.jwt.claims', true), '')
60
+ || '|' || coalesce(current_setting('request.jwt.claim.sub', true), '');
61
+ v_cache text := current_setting('app.ctx_tenant', true);
62
+ v_owner text := current_setting('app.ctx_tenant_key', true);
63
+ v_out uuid;
64
+ BEGIN
65
+ IF v_cache IS NOT NULL AND v_cache <> '' AND v_owner IS NOT DISTINCT FROM v_key THEN
66
+ RETURN nullif(v_cache, '-')::uuid;
67
+ END IF;
68
+ v_out := app._current_tenant_id_raw();
69
+ PERFORM set_config('app.ctx_tenant', coalesce(v_out::text, '-'), true);
70
+ PERFORM set_config('app.ctx_tenant_key', v_key, true);
71
+ RETURN v_out;
72
+ END $function$;
73
+
74
+ CREATE OR REPLACE FUNCTION app.current_unit_id()
75
+ RETURNS uuid
76
+ LANGUAGE plpgsql
77
+ STABLE SECURITY DEFINER
78
+ SET search_path TO ''
79
+ AS $function$
80
+ DECLARE
81
+ -- A unidade depende de quem pergunta E do cabeçalho que ele mandou.
82
+ v_key text := coalesce(current_setting('request.jwt.claims', true), '')
83
+ || '|' || coalesce(current_setting('request.jwt.claim.sub', true), '')
84
+ || '|' || coalesce(current_setting('request.headers', true), '');
85
+ v_cache text := current_setting('app.ctx_unit', true);
86
+ v_owner text := current_setting('app.ctx_unit_key', true);
87
+ v_out uuid;
88
+ BEGIN
89
+ IF v_cache IS NOT NULL AND v_cache <> '' AND v_owner IS NOT DISTINCT FROM v_key THEN
90
+ RETURN nullif(v_cache, '-')::uuid;
91
+ END IF;
92
+ v_out := app._current_unit_id_raw();
93
+ PERFORM set_config('app.ctx_unit', coalesce(v_out::text, '-'), true);
94
+ PERFORM set_config('app.ctx_unit_key', v_key, true);
95
+ RETURN v_out;
96
+ END $function$;
97
+
98
+ CREATE OR REPLACE FUNCTION app.unit_scoping_on()
99
+ RETURNS boolean
100
+ LANGUAGE plpgsql
101
+ STABLE SECURITY DEFINER
102
+ SET search_path TO ''
103
+ AS $function$
104
+ DECLARE
105
+ v_key text := coalesce(current_setting('request.jwt.claims', true), '')
106
+ || '|' || coalesce(current_setting('request.jwt.claim.sub', true), '');
107
+ v_cache text := current_setting('app.ctx_unit_scoping', true);
108
+ v_owner text := current_setting('app.ctx_unit_scoping_key', true);
109
+ v_out boolean;
110
+ BEGIN
111
+ IF v_cache IS NOT NULL AND v_cache <> '' AND v_owner IS NOT DISTINCT FROM v_key THEN
112
+ RETURN v_cache = 't';
113
+ END IF;
114
+ v_out := app._unit_scoping_on_raw();
115
+ PERFORM set_config('app.ctx_unit_scoping', CASE WHEN v_out THEN 't' ELSE 'f' END, true);
116
+ PERFORM set_config('app.ctx_unit_scoping_key', v_key, true);
117
+ RETURN v_out;
118
+ END $function$;
119
+
120
+ COMMENT ON FUNCTION app.current_tenant_id() IS
121
+ 'O tenant da requisição, calculado UMA vez por transação POR CLAIM. A regra '
122
+ 'mora em app._current_tenant_id_raw() e não mudou. O cache da 076 era local '
123
+ 'à transação sem dizer de quem era a resposta: trocar de sujeito dentro da '
124
+ 'mesma transação devolvia o tenant do sujeito anterior.';
125
+
126
+ -- ─────────────────────────────────────────────────────────────────────────────
127
+ -- Os outros dois mapas da mesma onda
128
+ -- ─────────────────────────────────────────────────────────────────────────────
129
+ --
130
+ -- A 077 (`app.ctx_perm`) e a 078 (`app.ctx_acl`) guardam MAPAS, e não um valor
131
+ -- só — mas o defeito é idêntico: a chave descreve a pergunta e não quem a fez.
132
+ --
133
+ -- v_chave text := p_perm || '|' || coalesce(p_unit::text, '-');
134
+ --
135
+ -- Duas personas na mesma transação perguntam "people.read" e a segunda recebe
136
+ -- a resposta da primeira. Aqui isso aparecia ao contrário do caso do tenant, e
137
+ -- por isso é mais perigoso: a recepcionista da clínica perdia uma permissão
138
+ -- que tem, e o teste do proprietário ganhava uma que não é dele.
139
+ --
140
+ -- O conserto é o mesmo: a chave passa a dizer de quem é a resposta. Entradas
141
+ -- de outra persona simplesmente não casam. O mapa é local à transação, então
142
+ -- carregar algumas chaves a mais não tem custo que se meça.
143
+
144
+ CREATE OR REPLACE FUNCTION app.ctx_owner_key()
145
+ RETURNS text
146
+ LANGUAGE sql
147
+ STABLE
148
+ SET search_path TO ''
149
+ AS $function$
150
+ SELECT coalesce(current_setting('request.jwt.claims', true), '')
151
+ || '|' || coalesce(current_setting('request.jwt.claim.sub', true), '')
152
+ $function$;
153
+
154
+ COMMENT ON FUNCTION app.ctx_owner_key() IS
155
+ 'De quem é a resposta guardada nos caches de contexto (076/077/078). A claim '
156
+ 'inteira, porque auth.uid() e auth.jwt() saem dela — então ela cobre todas as '
157
+ 'entradas das regras cruas.';
158
+
159
+ CREATE OR REPLACE FUNCTION app.has_permission(p_perm text, p_unit uuid DEFAULT NULL::uuid)
160
+ RETURNS boolean
161
+ LANGUAGE plpgsql
162
+ STABLE SECURITY DEFINER
163
+ SET search_path TO ''
164
+ AS $function$
165
+ DECLARE
166
+ v_chave text := app.ctx_owner_key() || '|' || p_perm || '|' || coalesce(p_unit::text, '-');
167
+ v_mapa jsonb := coalesce(nullif(current_setting('app.ctx_perm', true), '')::jsonb, '{}'::jsonb);
168
+ v_out boolean;
169
+ BEGIN
170
+ IF v_mapa ? v_chave THEN
171
+ RETURN (v_mapa ->> v_chave)::boolean;
172
+ END IF;
173
+ v_out := app._has_permission_raw(p_perm, p_unit);
174
+ PERFORM set_config('app.ctx_perm',
175
+ (v_mapa || jsonb_build_object(v_chave, v_out))::text, true);
176
+ RETURN v_out;
177
+ EXCEPTION
178
+ -- Um cache corrompido não pode virar negação de acesso: se o mapa não
179
+ -- parsear por qualquer motivo, responde a pergunta do jeito caro.
180
+ WHEN OTHERS THEN RETURN app._has_permission_raw(p_perm, p_unit);
181
+ END $function$;
182
+
183
+ CREATE OR REPLACE FUNCTION app.has_unit(p_unit uuid)
184
+ RETURNS boolean
185
+ LANGUAGE plpgsql
186
+ STABLE SECURITY DEFINER
187
+ SET search_path TO ''
188
+ AS $function$
189
+ DECLARE
190
+ v_chave text := app.ctx_owner_key() || '|u|' || coalesce(p_unit::text, '-');
191
+ v_mapa jsonb := coalesce(nullif(current_setting('app.ctx_acl', true), '')::jsonb, '{}'::jsonb);
192
+ v_out boolean;
193
+ BEGIN
194
+ IF v_mapa ? v_chave THEN RETURN (v_mapa ->> v_chave)::boolean; END IF;
195
+ v_out := app._has_unit_raw(p_unit);
196
+ PERFORM set_config('app.ctx_acl', (v_mapa || jsonb_build_object(v_chave, v_out))::text, true);
197
+ RETURN v_out;
198
+ EXCEPTION WHEN OTHERS THEN RETURN app._has_unit_raw(p_unit);
199
+ END $function$;
200
+
201
+ CREATE OR REPLACE FUNCTION app.owner_scoped(p_resource_type text)
202
+ RETURNS boolean
203
+ LANGUAGE plpgsql
204
+ STABLE SECURITY DEFINER
205
+ SET search_path TO ''
206
+ AS $function$
207
+ DECLARE
208
+ v_chave text := app.ctx_owner_key() || '|o|' || coalesce(p_resource_type, '-');
209
+ v_mapa jsonb := coalesce(nullif(current_setting('app.ctx_acl', true), '')::jsonb, '{}'::jsonb);
210
+ v_out boolean;
211
+ BEGIN
212
+ IF v_mapa ? v_chave THEN RETURN (v_mapa ->> v_chave)::boolean; END IF;
213
+ v_out := app._owner_scoped_raw(p_resource_type);
214
+ PERFORM set_config('app.ctx_acl', (v_mapa || jsonb_build_object(v_chave, v_out))::text, true);
215
+ RETURN v_out;
216
+ EXCEPTION WHEN OTHERS THEN RETURN app._owner_scoped_raw(p_resource_type);
217
+ END $function$;
218
+
219
+ -- ─────────────────────────────────────────────────────────────────────────────
220
+ -- E as funções novas não nascem abertas
221
+ -- ─────────────────────────────────────────────────────────────────────────────
222
+ --
223
+ -- Toda função criada no Postgres nasce com EXECUTE para PUBLIC. A onda de
224
+ -- memoização (076, 077, 078) criou SEIS auxiliares `_raw` em `app` e não
225
+ -- revogou nenhuma; esta migration criou a sétima. O contrato N15c do
226
+ -- `900_verification` mede exatamente isso e contava oito:
227
+ --
228
+ -- anon can execute nothing callable in schema app have: 8 want: 0
229
+ --
230
+ -- Não é um detalhe de teste. `app._has_permission_raw('people.read')` é a
231
+ -- resposta CRUA da pergunta de permissão, sem cache e sem cerca; deixá-la
232
+ -- aberta a `anon` é deixar a pergunta de autorização atender quem não se
233
+ -- identificou.
234
+ --
235
+ -- As públicas (`current_tenant_id`, `has_permission`, `has_unit`,
236
+ -- `owner_scoped`, `unit_scoping_on`) NÃO são tocadas: elas já existiam, o
237
+ -- `CREATE OR REPLACE` preserva as concessões delas, e as políticas de RLS
238
+ -- dependem de `authenticated` poder chamá-las.
239
+ --
240
+ -- As `_raw` e a chave do cache só são chamadas de dentro de funções
241
+ -- SECURITY DEFINER, que rodam como o dono — então ninguém perde nada.
242
+
243
+ REVOKE ALL ON FUNCTION app._current_tenant_id_raw() FROM PUBLIC, anon, authenticated;
244
+ REVOKE ALL ON FUNCTION app._current_unit_id_raw() FROM PUBLIC, anon, authenticated;
245
+ REVOKE ALL ON FUNCTION app._unit_scoping_on_raw() FROM PUBLIC, anon, authenticated;
246
+ REVOKE ALL ON FUNCTION app._has_permission_raw(text, uuid) FROM PUBLIC, anon, authenticated;
247
+ REVOKE ALL ON FUNCTION app._has_unit_raw(uuid) FROM PUBLIC, anon, authenticated;
248
+ REVOKE ALL ON FUNCTION app._owner_scoped_raw(text) FROM PUBLIC, anon, authenticated;
249
+ REVOKE ALL ON FUNCTION app.ctx_owner_key() FROM PUBLIC, anon, authenticated;
250
+
251
+ -- As duas que faltavam: `requested_unit` mudou de schema na 076 e `brand_of`
252
+ -- nasceu na 089 — as duas em `app`, as duas abertas a PUBLIC por nascimento.
253
+ REVOKE ALL ON FUNCTION app.requested_unit() FROM PUBLIC, anon;
254
+ REVOKE ALL ON FUNCTION app.brand_of(uuid) FROM PUBLIC, anon;
255
+
256
+ -- ─────────────────────────────────────────────────────────────────────────────
257
+ -- E quem MUDA a configuração limpa o cache de quem a lê
258
+ -- ─────────────────────────────────────────────────────────────────────────────
259
+ --
260
+ -- Dizer de quem é a resposta resolve a troca de PESSOA. Falta a outra metade:
261
+ -- a mesma pessoa, na mesma transação, depois que a configuração mudou.
262
+ --
263
+ -- -- ela não alcança B2
264
+ -- INSERT INTO app.membership_units (...) VALUES (... 'caixa-b2' ...);
265
+ -- -- ...e uma linha de configuração depois, alcança
266
+ --
267
+ -- Esse é o contrato que três suítes medem, e é também o que qualquer RPC que
268
+ -- concede acesso e responde em seguida depende. O cache das 076/077/078 é
269
+ -- local à transação: a escrita acontece, e a leitura seguinte devolve a
270
+ -- resposta de antes da escrita. Fica pior que sem cache, porque a função
271
+ -- passa a contradizer uma linha que a própria transação acabou de gravar.
272
+ --
273
+ -- A invalidação não pode ser por tempo nem por versão lida a cada chamada —
274
+ -- as duas custariam o que a memoização economizou. Ela é por EVENTO: quem
275
+ -- escreve na configuração apaga o cache, na mesma transação, de graça.
276
+ --
277
+ -- Gatilho de comando (e não de linha): uma importação de mil vínculos limpa o
278
+ -- cache uma vez, não mil.
279
+
280
+ CREATE OR REPLACE FUNCTION app.ctx_cache_reset()
281
+ RETURNS trigger
282
+ LANGUAGE plpgsql
283
+ SECURITY DEFINER
284
+ SET search_path TO ''
285
+ AS $function$
286
+ BEGIN
287
+ PERFORM set_config('app.ctx_tenant', '', true);
288
+ PERFORM set_config('app.ctx_tenant_key', '', true);
289
+ PERFORM set_config('app.ctx_unit', '', true);
290
+ PERFORM set_config('app.ctx_unit_key', '', true);
291
+ PERFORM set_config('app.ctx_unit_scoping', '', true);
292
+ PERFORM set_config('app.ctx_unit_scoping_key', '', true);
293
+ PERFORM set_config('app.ctx_perm', '', true);
294
+ PERFORM set_config('app.ctx_acl', '', true);
295
+ RETURN NULL;
296
+ END $function$;
297
+
298
+ COMMENT ON FUNCTION app.ctx_cache_reset() IS
299
+ 'Apaga os caches de contexto da transação. Ligado a quem ESCREVE a '
300
+ 'configuração que eles resumem: sem isto, uma leitura logo depois de uma '
301
+ 'escrita na mesma transação responde pelo estado anterior.';
302
+
303
+ REVOKE ALL ON FUNCTION app.ctx_cache_reset() FROM PUBLIC, anon, authenticated;
304
+
305
+ DO $$
306
+ DECLARE
307
+ t text;
308
+ alvo text[] := ARRAY[
309
+ 'app.memberships', -- de que conta eu sou
310
+ 'app.membership_units', -- e com que papel em cada unidade
311
+ 'app.roles',
312
+ 'app.role_permissions', -- o que o papel pode
313
+ 'app.user_unit_access', -- que unidades eu alcanço
314
+ 'app.resource_grants', -- e o que está liberado em cada uma
315
+ 'app.units',
316
+ 'app.vertical_owner_scoping', -- a cerca por ramo tambem e configuracao
317
+ 'public.tenants' -- inclusive o interruptor unit_scoping_enabled
318
+ ];
319
+ BEGIN
320
+ FOREACH t IN ARRAY alvo LOOP
321
+ IF to_regclass(t) IS NULL THEN CONTINUE; END IF;
322
+ EXECUTE format('DROP TRIGGER IF EXISTS ctx_cache_reset ON %s', t);
323
+ EXECUTE format(
324
+ 'CREATE TRIGGER ctx_cache_reset AFTER INSERT OR UPDATE OR DELETE ON %s '
325
+ 'FOR EACH STATEMENT EXECUTE FUNCTION app.ctx_cache_reset()', t);
326
+ END LOOP;
327
+ END $$;
@@ -0,0 +1,124 @@
1
+ -- O ramo volta a saber que papéis a conta nasce tendo
2
+ --
3
+ -- A 093 fechou `tenants.vertical_id` em sete nomes. `create_tenant` derivava o
4
+ -- conjunto de papéis procurando um template com o MESMO nome do ramo:
5
+ --
6
+ -- CASE WHEN EXISTS (SELECT 1 FROM app.role_templates rt
7
+ -- WHERE rt.template = p_vertical_id)
8
+ -- THEN p_vertical_id ELSE 'generic' END
9
+ --
10
+ -- Os templates se chamam `salon`, `clinic`, `restaurant`, `generic` — o
11
+ -- vocabulário de antes. Nenhum se chama `beauty` nem `food`. Desde a 093,
12
+ -- portanto, TODA conta criada nasce com o template `generic`: sem os papéis do
13
+ -- ofício, com o rail certo e a equipe errada. É o mesmo buraco da 107, no
14
+ -- outro lado da criação da conta, e pela mesma causa — fechar um vocabulário
15
+ -- sem visitar quem o lia.
16
+ --
17
+ -- A saída é a mesma também: traduzir num lugar só, e não batizar os templates
18
+ -- de novo. `beauty` cobre salão e clínica (as duas rodam o StudioControl), e é
19
+ -- por isso que ela cai em `salon`: a clínica é a variante, e quem a quer pede
20
+ -- `p_template` explícito — que continua vencendo a derivação, como sempre.
21
+ --
22
+ -- Idempotente.
23
+
24
+ CREATE OR REPLACE FUNCTION app.role_template_for_vertical(p_vertical text)
25
+ RETURNS text
26
+ LANGUAGE sql
27
+ STABLE
28
+ SET search_path TO ''
29
+ AS $function$
30
+ SELECT coalesce(
31
+ (SELECT rt.template FROM app.role_templates rt
32
+ WHERE rt.template = CASE p_vertical
33
+ WHEN 'food' THEN 'restaurant'
34
+ WHEN 'beauty' THEN 'salon'
35
+ ELSE p_vertical
36
+ END
37
+ LIMIT 1),
38
+ 'generic');
39
+ $function$;
40
+
41
+ COMMENT ON FUNCTION app.role_template_for_vertical(text) IS
42
+ 'Que conjunto de papéis uma conta daquele ramo nasce tendo. Traduz o '
43
+ 'vocabulário fechado de tenants.vertical_id (093) para os nomes dos '
44
+ 'templates, que são anteriores a ele. Ramo sem template próprio cai em '
45
+ 'generic — que é a resposta honesta, não um erro.';
46
+
47
+ REVOKE ALL ON FUNCTION app.role_template_for_vertical(text) FROM PUBLIC, anon;
48
+
49
+ -- E `create_tenant` passa a perguntar a ela. Nada mais muda no corpo: a
50
+ -- definição abaixo é a que estava no banco, com a tradução no lugar do CASE.
51
+
52
+ CREATE OR REPLACE FUNCTION public.create_tenant(p_name text, p_slug text, p_kind text DEFAULT 'business'::text, p_vertical_id text DEFAULT NULL::text, p_template text DEFAULT 'generic'::text, p_settings jsonb DEFAULT '{}'::jsonb, p_hq_name text DEFAULT NULL::text)
53
+ RETURNS jsonb
54
+ LANGUAGE plpgsql
55
+ SECURITY DEFINER
56
+ SET search_path TO ''
57
+ AS $function$
58
+ DECLARE
59
+ v_uid uuid := auth.uid();
60
+ v_tenant uuid;
61
+ v_unit uuid;
62
+ v_membership uuid;
63
+ v_owner_role uuid;
64
+ v_template text;
65
+ v_slug text;
66
+ v_name text;
67
+ v_seeded jsonb;
68
+ BEGIN
69
+ IF v_uid IS NULL THEN
70
+ RAISE EXCEPTION 'create_tenant: the actor is derived from the session and there is no session'
71
+ USING ERRCODE = '42501';
72
+ END IF;
73
+ IF p_kind IS NULL OR p_kind NOT IN ('network', 'business') THEN
74
+ RAISE EXCEPTION 'create_tenant: kind must be network or business (got %)', coalesce(p_kind, '<null>')
75
+ USING ERRCODE = '22023';
76
+ END IF;
77
+ v_name := nullif(btrim(coalesce(p_name, '')), '');
78
+ IF v_name IS NULL THEN
79
+ RAISE EXCEPTION 'create_tenant: name is required' USING ERRCODE = '22023';
80
+ END IF;
81
+ v_slug := lower(btrim(coalesce(p_slug, '')));
82
+ IF v_slug !~ '^[a-z0-9]+([._-][a-z0-9]+)*$' OR length(v_slug) > 100 THEN
83
+ RAISE EXCEPTION 'create_tenant: slug must be lowercase letters and digits separated by single . _ - (got %)', coalesce(nullif(v_slug, ''), '<empty>')
84
+ USING ERRCODE = '22023';
85
+ END IF;
86
+ IF EXISTS (SELECT 1 FROM public.tenants t WHERE t.slug = v_slug) THEN
87
+ RAISE EXCEPTION 'create_tenant: slug % is already taken', v_slug USING ERRCODE = '23505';
88
+ END IF;
89
+
90
+ -- Template: explicit, else the vertical's own template when one exists, else generic.
91
+ v_template := coalesce(p_template, app.role_template_for_vertical(p_vertical_id));
92
+ IF NOT EXISTS (SELECT 1 FROM app.role_templates rt WHERE rt.template = v_template) THEN
93
+ RAISE EXCEPTION 'create_tenant: unknown role template %', v_template USING ERRCODE = '22023';
94
+ END IF;
95
+
96
+ INSERT INTO public.tenants (name, slug, plan, vertical_id, kind, settings, role_template)
97
+ VALUES (v_name, v_slug, 'free', p_vertical_id, p_kind, coalesce(p_settings, '{}'::jsonb), v_template)
98
+ RETURNING id INTO v_tenant;
99
+
100
+ v_owner_role := app.seed_role_template(v_tenant, v_template);
101
+
102
+ INSERT INTO app.units (tenant_id, kind, name, slug, active)
103
+ VALUES (v_tenant, 'hq', coalesce(nullif(btrim(coalesce(p_hq_name, '')), ''), v_name), 'hq', true)
104
+ RETURNING id INTO v_unit;
105
+
106
+ -- The owner: role from the template (holds the whole catalog), every unit.
107
+ INSERT INTO app.memberships (tenant_id, user_id, role_id, all_units, active)
108
+ VALUES (v_tenant, v_uid, v_owner_role, true, true)
109
+ RETURNING id INTO v_membership;
110
+
111
+ -- Os plugins do ramo. Mesma transação de propósito: um tenant que existe sem
112
+ -- plugin nenhum abre uma casca vazia, e quem cria acha que perdeu o que
113
+ -- digitou. Sem vertical (ou vertical desconhecido) devolve enabled 0 e segue
114
+ -- — nascer sem ramo é legítimo, nascer pela metade não.
115
+ v_seeded := public.tenant_apply_vertical_defaults(v_tenant, p_vertical_id);
116
+
117
+ RETURN jsonb_build_object(
118
+ 'tenant', (SELECT to_jsonb(t) FROM public.tenants t WHERE t.id = v_tenant),
119
+ 'hq_unit', (SELECT to_jsonb(u) FROM app.units u WHERE u.id = v_unit),
120
+ 'membership', (SELECT to_jsonb(m) FROM app.memberships m WHERE m.id = v_membership),
121
+ 'plugins', v_seeded
122
+ );
123
+ END $function$
124
+ ;
@@ -0,0 +1,93 @@
1
+ -- A resposta inicial volta a dizer o que a pessoa pode, e quando foi tirada
2
+ --
3
+ -- A 092 reescreveu `app_config()` para devolver o preset do ofício — e, ao
4
+ -- repetir a função inteira, deixou cair duas chaves que a 001 entregava:
5
+ --
6
+ -- permissions a cerca do cliente: sem ela a tela monta sem saber o que
7
+ -- esconder, e todo `can()` responde pelo padrão
8
+ -- generated_at a idade da resposta: sem ela o snapshot em localStorage
9
+ -- não tem como saber se ainda vale
10
+ --
11
+ -- Nenhuma das duas some com estrondo. A tela abre, a marca pinta, o rail
12
+ -- desenha — e a cerca fica por conta do padrão enquanto o cache nunca vence.
13
+ -- É a forma mais cara de erro: a que parece estar funcionando.
14
+ --
15
+ -- Repetida inteira de novo, que é como uma função volta a ter dono. Só as
16
+ -- duas chaves mudam; o resto é byte a byte o que a 092 deixou.
17
+ --
18
+ -- Nota sobre `plugins`: ela continua devolvendo TODAS as linhas, inclusive as
19
+ -- desligadas e as de teste vencido (com `status` já resolvido para
20
+ -- 'disabled'). Isso não é descuido — é o contrato novo. A resolução no cliente
21
+ -- é ADITIVA: linha ausente cai no padrão do manifesto, que é LIGADO. Uma conta
22
+ -- só consegue dizer "isto eu não uso" se a linha que diz isso chegar.
23
+ --
24
+ -- Numerada DEPOIS da 113, de propósito: aquela vem do outro ramo e reemite
25
+ -- `app_config()` na linhagem de lá — sem a guarda do schema `platform` e sem
26
+ -- as duas chaves. Quem define por último é quem define; renumerar custa nada e
27
+ -- deixa o arquivo do outro ramo byte a byte como ele é lá.
28
+ --
29
+ -- Idempotente.
30
+
31
+ CREATE OR REPLACE FUNCTION public.app_config()
32
+ RETURNS jsonb
33
+ LANGUAGE plpgsql
34
+ STABLE SECURITY DEFINER
35
+ SET search_path TO ''
36
+ AS $function$
37
+ DECLARE
38
+ v_tenant uuid := app.current_tenant_id();
39
+ v_staff text;
40
+ BEGIN
41
+ -- `platform` e o schema do control-admin: existe no cluster que hospeda a
42
+ -- plataforma e NAO na cadeia canonica, onde esta funcao e reconstruida do
43
+ -- zero. Chamar direto compila (o corpo de plpgsql so resolve nomes na
44
+ -- primeira execucao) e estoura na primeira chamada com "schema platform
45
+ -- does not exist" — que e o pedagio de `search_path` vazio ja pago na
46
+ -- 060. Perguntar antes deixa a mesma definicao servir aos dois: sem o
47
+ -- schema, ninguem e staff, e o control-admin simplesmente nao e oferecido.
48
+ IF pg_catalog.to_regproc('platform.staff_role(uuid)') IS NOT NULL THEN
49
+ EXECUTE 'SELECT platform.staff_role($1)' INTO v_staff USING (SELECT auth.uid());
50
+ END IF;
51
+
52
+ IF v_tenant IS NULL THEN
53
+ RETURN jsonb_build_object('tenant_id', NULL, 'plugins', '[]'::jsonb, 'nav', '[]'::jsonb,
54
+ 'apps', '[]'::jsonb, 'permissions', '[]'::jsonb, 'generated_at', now());
55
+ END IF;
56
+
57
+ RETURN jsonb_build_object(
58
+ 'tenant_id', v_tenant,
59
+ 'branding', coalesce(app.brand_of(v_tenant), '{}'::jsonb),
60
+ 'settings', public.tenant_settings(),
61
+ -- Todas as linhas, inclusive as desligadas: a regra no cliente é aditiva, e
62
+ -- uma linha ausente cai no padrão do manifesto — que é LIGADO.
63
+ 'plugins', coalesce((
64
+ SELECT jsonb_agg(jsonb_build_object('plugin', t.plugin_id, 'facet', nullif(t.facet, ''),
65
+ 'status', CASE WHEN t.expires_at IS NOT NULL AND t.expires_at <= now() THEN 'disabled' ELSE t.status END,
66
+ 'source', t.source, 'config', t.config, 'expires_at', t.expires_at)
67
+ ORDER BY t.plugin_id, t.facet)
68
+ FROM app.tenant_plugins t WHERE t.tenant_id = v_tenant), '[]'::jsonb),
69
+ 'apps', coalesce((
70
+ SELECT jsonb_agg(x ORDER BY x ->> 'house', x ->> 'name') FROM (
71
+ SELECT jsonb_build_object('id', a.id, 'name', a.name, 'icon', a.icon, 'accent_color', a.accent_color,
72
+ 'url', a.url, 'house', false, 'preset', a.preset) AS x
73
+ FROM app.tenant_apps ta JOIN app.apps a ON a.id = ta.app_id
74
+ WHERE ta.tenant_id = v_tenant AND ta.status = 'active' AND a.active
75
+ UNION ALL
76
+ SELECT jsonb_build_object('id', a.id, 'name', a.name, 'icon', a.icon, 'accent_color', a.accent_color,
77
+ 'url', a.url, 'house', true, 'preset', a.preset) AS x
78
+ 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),
79
+ 'nav', coalesce((
80
+ SELECT jsonb_agg(jsonb_build_object('key', n.entry_key, 'hidden', n.hidden, 'label', n.label,
81
+ 'section', n.section, 'position', n.position) ORDER BY n.entry_key)
82
+ FROM app.tenant_nav n WHERE n.tenant_id = v_tenant), '[]'::jsonb),
83
+ -- As duas que a reescrita da 092 deixou cair, de volta com a mesma forma
84
+ -- da 001: o cliente monta a cerca com `permissions` e decide a validade do
85
+ -- snapshot com `generated_at`.
86
+ 'permissions', coalesce((
87
+ SELECT jsonb_agg(DISTINCT rp.permission)
88
+ FROM app.memberships m
89
+ JOIN app.role_permissions rp ON rp.role_id = m.role_id AND rp.tenant_id = m.tenant_id
90
+ WHERE m.tenant_id = v_tenant AND m.user_id = (SELECT auth.uid()) AND m.active
91
+ ), '[]'::jsonb),
92
+ 'generated_at', now());
93
+ END $function$;
@@ -0,0 +1,108 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 115_o_rail_e_de_quem_tem_um.sql
3
+ -- ---------------------------------------------------------------------------
4
+ -- O ControlGroup roda dois aplicativos — o FullControl, que é o sistema dele, e
5
+ -- o FiscalControl, que é a retaguarda contábil. O rail que ele desenhava era o
6
+ -- de NINGUÉM: linhas soltas, sem as rubricas que o FullControl declara.
7
+ --
8
+ -- ── Por que ─────────────────────────────────────────────────────────────────
9
+ -- `useAppPreset` usa o preset do PRIMEIRO aplicativo da lista, e com razão: um
10
+ -- rail com as duas árvores não é a soma de dois sistemas, é nenhum dos dois.
11
+ -- Quem ordena a lista é esta função, e ela ordenava assim:
12
+ --
13
+ -- ORDER BY house, trade DESC, name
14
+ --
15
+ -- `trade` é "tem ofício" (vertical fora de `accounting`/`generic`). Nem o
16
+ -- `full` nem o `fiscal` têm: um é o ERP genérico e o outro é contabilidade. Com
17
+ -- os dois empatados em `trade`, o desempate caía no NOME — e "FiscalControl"
18
+ -- vem antes de "FullControl" no alfabeto.
19
+ --
20
+ -- Então o aplicativo sem opinião nenhuma sobre o rail (o `fiscal` não declara
21
+ -- `preset.rail`) vencia o que declara a árvore inteira, por uma letra.
22
+ --
23
+ -- ── O conserto ──────────────────────────────────────────────────────────────
24
+ -- Um critério a mais, ANTES do nome: quem TEM rail ganha de quem não tem.
25
+ --
26
+ -- Não é um caso particular do ControlGroup. Um aplicativo sem `preset.rail` é
27
+ -- um que não opina sobre a navegação — a retaguarda, o complemento —, e ele
28
+ -- nunca deveria calar o que opina. O alfabeto não sabe disso; esta linha sabe.
29
+ --
30
+ -- `trade` continua vindo primeiro: entre dois que declaram rail, quem tem
31
+ -- ofício manda, que é o que a 084 resolveu para o Artorius.
32
+ --
33
+ -- Idempotente: reemite a função.
34
+ -- ---------------------------------------------------------------------------
35
+
36
+ CREATE OR REPLACE FUNCTION public.app_config()
37
+ RETURNS jsonb
38
+ LANGUAGE plpgsql
39
+ STABLE SECURITY DEFINER
40
+ SET search_path TO ''
41
+ AS $function$
42
+ DECLARE
43
+ v_tenant uuid := app.current_tenant_id();
44
+ v_staff text;
45
+ BEGIN
46
+ -- `platform` é do CLUSTER, não da cadeia canônica: um banco montado do zero
47
+ -- (o db-from-zero, um pool novo) não tem esse schema, e uma chamada direta
48
+ -- estoura na inicialização da variável — antes de qualquer linha do corpo.
49
+ -- Mesma guarda que a 092 já precisou pôr aqui; reemitir a função copiando a
50
+ -- definição VIVA do cluster a trouxe de volta.
51
+ IF pg_catalog.to_regproc('platform.staff_role(uuid)') IS NOT NULL THEN
52
+ EXECUTE 'SELECT platform.staff_role($1)' INTO v_staff USING (SELECT auth.uid());
53
+ END IF;
54
+
55
+ IF v_tenant IS NULL THEN
56
+ RETURN jsonb_build_object('tenant_id', NULL, 'plugins', '[]'::jsonb, 'nav', '[]'::jsonb,
57
+ 'apps', '[]'::jsonb, 'permissions', '[]'::jsonb, 'generated_at', now());
58
+ END IF;
59
+
60
+ RETURN jsonb_build_object(
61
+ 'tenant_id', v_tenant,
62
+ 'branding', coalesce(app.brand_of(v_tenant), '{}'::jsonb),
63
+ 'settings', public.tenant_settings(),
64
+ 'plugins', coalesce((
65
+ SELECT jsonb_agg(jsonb_build_object('plugin', t.plugin_id, 'facet', nullif(t.facet, ''),
66
+ 'status', CASE WHEN t.expires_at IS NOT NULL AND t.expires_at <= now() THEN 'disabled' ELSE t.status END,
67
+ 'source', t.source, 'config', t.config, 'expires_at', t.expires_at)
68
+ ORDER BY t.plugin_id, t.facet)
69
+ FROM app.tenant_plugins t WHERE t.tenant_id = v_tenant), '[]'::jsonb),
70
+ -- `trade` ordena a lista: quem roda dois aplicativos tem UM ofício, e o
71
+ -- outro é retaguarda. FiscalControl vinha antes de StoreControl só por
72
+ -- ordem alfabética, e o Artorius desenhava o rail de ninguém.
73
+ --
74
+ -- E entre dois SEM ofício — o ERP genérico e a contabilidade —, quem
75
+ -- declara `preset.rail` vence quem não declara: um aplicativo que não opina
76
+ -- sobre a navegação não pode calar o que opina só por vir antes no
77
+ -- alfabeto (115).
78
+ 'apps', coalesce((
79
+ SELECT jsonb_agg(x ORDER BY x ->> 'house',
80
+ (x ->> 'trade')::boolean DESC,
81
+ (x -> 'preset' -> 'rail' IS NOT NULL) DESC,
82
+ x ->> 'name') FROM (
83
+ SELECT jsonb_build_object('id', a.id, 'name', a.name, 'icon', a.icon, 'accent_color', a.accent_color,
84
+ 'url', a.url, 'house', false, 'preset', a.preset, 'vertical_id', a.vertical_id,
85
+ 'trade', a.vertical_id IS NOT NULL AND a.vertical_id NOT IN ('accounting','generic')) AS x
86
+ FROM app.tenant_apps ta JOIN app.apps a ON a.id = ta.app_id
87
+ WHERE ta.tenant_id = v_tenant AND ta.status = 'active' AND a.active
88
+ UNION ALL
89
+ SELECT jsonb_build_object('id', a.id, 'name', a.name, 'icon', a.icon, 'accent_color', a.accent_color,
90
+ 'url', a.url, 'house', true, 'preset', a.preset, 'vertical_id', a.vertical_id,
91
+ 'trade', false) AS x
92
+ 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),
93
+ 'nav', coalesce((
94
+ SELECT jsonb_agg(jsonb_build_object('key', n.entry_key, 'hidden', n.hidden, 'label', n.label,
95
+ 'section', n.section, 'position', n.position) ORDER BY n.entry_key)
96
+ FROM app.tenant_nav n WHERE n.tenant_id = v_tenant), '[]'::jsonb),
97
+ -- As duas que a 114 devolveu, e que esta migration quase derrubou de novo:
98
+ -- copiei a definição VIVA do cluster, onde a 114 ainda não rodou. É a
99
+ -- terceira vez que repetir esta função inteira perde uma chave dela — quem
100
+ -- a reemite tem de começar pelo ARQUIVO da última, não pelo banco.
101
+ 'permissions', coalesce((
102
+ SELECT jsonb_agg(DISTINCT rp.permission)
103
+ FROM app.memberships m
104
+ JOIN app.role_permissions rp ON rp.role_id = m.role_id AND rp.tenant_id = m.tenant_id
105
+ WHERE m.tenant_id = v_tenant AND m.user_id = (SELECT auth.uid()) AND m.active
106
+ ), '[]'::jsonb),
107
+ 'generated_at', now());
108
+ END $function$;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "fayz": {
4
4
  "status": "beta"
5
5
  },
6
- "version": "0.14.0",
6
+ "version": "0.16.0",
7
7
  "description": "Fayz SDK database layer — Drizzle schema primitives, spine references, and migration helpers shared across plugins.",
8
8
  "type": "module",
9
9
  "sideEffects": false,