@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,210 @@
1
+ -- Quem aparece na agenda é quem atende.
2
+ --
3
+ -- A agenda de uma unidade mostrava gente demais, e a maior parte do excesso
4
+ -- não era profissional. Medido em 20/09/2026, agenda do Millenium:
5
+ --
6
+ -- 23 funcionários administrativos
7
+ -- 15 profissionais (o V1 diz 11)
8
+ -- 1 cliente
9
+ -- ──
10
+ -- 38 colunas, onde deveriam ser 11
11
+ --
12
+ -- ── A causa ─────────────────────────────────────────────────────────────────
13
+ --
14
+ -- `public.people.is_bookable` tem `DEFAULT true`, e `migration.upsert_person`
15
+ -- nunca o define. Então TODA pessoa que o porte escreve nasce agendável —
16
+ -- inclusive os 119.521 clientes, que só não aparecem porque a agenda filtra
17
+ -- por `kind = 'staff'`.
18
+ --
19
+ -- O V1 distingue: quem atende cliente é `professionals`; `employees` é
20
+ -- administrativo e nunca vira coluna na agenda de lá. A informação existia na
21
+ -- origem e não atravessava.
22
+ --
23
+ -- ── Declarado, não derivado ─────────────────────────────────────────────────
24
+ --
25
+ -- O writer já recebe `role` em `writer_args`, e seria fácil escrever
26
+ -- `is_bookable := (v_role = 'professional')` aqui dentro. Não é o que esta
27
+ -- migration faz.
28
+ --
29
+ -- A regra vai para `writer_args.bookable`, por tabela de origem, porque ela é
30
+ -- uma decisão do POOL e não do motor: um cliente onde a recepção também atende
31
+ -- precisa poder dizer isso sem que alguém reescreva o writer. Ausente, o
32
+ -- destino decide — o comportamento de antes, para todo pool que já roda.
33
+ --
34
+ -- Idempotente.
35
+
36
+ CREATE OR REPLACE FUNCTION migration.upsert_person(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
37
+ RETURNS jsonb
38
+ LANGUAGE plpgsql
39
+ SECURITY DEFINER
40
+ SET search_path TO ''
41
+ AS $function$
42
+ DECLARE
43
+ v_role text := coalesce(p_args ->> 'role', 'customer');
44
+ v_doc text := public.people_normalize_document(p_row ->> 'document_number');
45
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
46
+ v_origin jsonb;
47
+ v_existing_tenant uuid;
48
+ v_tombstone uuid;
49
+ v_candidates uuid[];
50
+ v_n integer;
51
+ v_survivor uuid;
52
+ v_standalone boolean := migration._standalone();
53
+ BEGIN
54
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
55
+ IF p_id IS NULL OR nullif(btrim(coalesce(p_row ->> 'name', '')), '') IS NULL THEN
56
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a person needs an id and a name');
57
+ END IF;
58
+ IF v_role NOT IN ('customer', 'professional', 'employee', 'contact', 'supplier', 'lead') THEN
59
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', format('unknown person role %s', v_role));
60
+ END IF;
61
+ v_origin := coalesce(v_meta -> 'migration', '{}'::jsonb) - 'merged_from';
62
+
63
+ SELECT tenant_id, merged_into_id INTO v_existing_tenant, v_tombstone FROM public.people WHERE id = p_id;
64
+ IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN
65
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('person %s already exists in another tenant', p_id));
66
+ END IF;
67
+ IF v_tombstone IS NOT NULL THEN
68
+ -- the row was merged in V2 after it landed: the human's decision stands, the source now maps to the survivor
69
+ PERFORM migration._ensure_role(p_tenant, v_tombstone, v_role);
70
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', v_tombstone, 'reason', 'merged', 'merged', true);
71
+ END IF;
72
+
73
+ IF v_existing_tenant IS NOT NULL THEN
74
+ -- the same source row again (delta): mapped columns override, the rest stays
75
+ UPDATE public.people SET
76
+ name = p_row ->> 'name',
77
+ email = CASE WHEN p_row ? 'email' THEN p_row ->> 'email' ELSE email END,
78
+ phone = CASE WHEN p_row ? 'phone' THEN p_row ->> 'phone' ELSE phone END,
79
+ document_number = CASE WHEN p_row ? 'document_number' THEN v_doc ELSE document_number END,
80
+ avatar_url = CASE WHEN p_row ? 'avatar_url' THEN p_row ->> 'avatar_url' ELSE avatar_url END,
81
+ date_of_birth = CASE WHEN p_row ? 'date_of_birth' THEN (p_row ->> 'date_of_birth')::date ELSE date_of_birth END,
82
+ address = CASE WHEN p_row ? 'address' THEN p_row ->> 'address' ELSE address END,
83
+ city = CASE WHEN p_row ? 'city' THEN p_row ->> 'city' ELSE city END,
84
+ state = CASE WHEN p_row ? 'state' THEN p_row ->> 'state' ELSE state END,
85
+ country = CASE WHEN p_row ? 'country' THEN p_row ->> 'country' ELSE country END,
86
+ postal_code = CASE WHEN p_row ? 'postal_code' THEN p_row ->> 'postal_code' ELSE postal_code END,
87
+ notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,
88
+ is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END,
89
+ metadata = migration._merge_meta(metadata, v_meta)
90
+ WHERE id = p_id;
91
+ PERFORM migration._ensure_role(p_tenant, p_id, v_role);
92
+ PERFORM migration._set_unit('public.people', p_id, (p_row ->> 'unit_id')::uuid);
93
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.people', p_id, jsonb_build_object('reason', 'updated')); END IF;
94
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'updated');
95
+ END IF;
96
+
97
+ IF v_doc IS NOT NULL THEN
98
+ SELECT array_agg(id) INTO v_candidates FROM public.people
99
+ WHERE tenant_id = p_tenant AND document_number = v_doc AND merged_into_id IS NULL;
100
+ ELSIF nullif(btrim(coalesce(p_row ->> 'email', '')), '') IS NOT NULL AND nullif(public.fayz_digits(p_row ->> 'phone'), '') IS NOT NULL THEN
101
+ SELECT array_agg(id) INTO v_candidates FROM public.people
102
+ WHERE tenant_id = p_tenant AND merged_into_id IS NULL AND lower(email) = lower(p_row ->> 'email')
103
+ AND public.fayz_digits(phone) = public.fayz_digits(p_row ->> 'phone');
104
+ END IF;
105
+ v_n := coalesce(array_length(v_candidates, 1), 0);
106
+
107
+ IF v_doc IS NOT NULL AND v_n >= 2 THEN
108
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'identity',
109
+ 'reason', format('ambiguous document: %s existing people share document %s', v_n, v_doc), 'candidates', to_jsonb(v_candidates));
110
+ END IF;
111
+ IF v_doc IS NULL AND v_n >= 1 THEN
112
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'identity',
113
+ 'reason', format('no document: %s existing person(s) share the same email and phone — merge only by exact document', v_n), 'candidates', to_jsonb(v_candidates));
114
+ END IF;
115
+
116
+ IF v_doc IS NOT NULL AND v_n = 1 THEN
117
+ v_survivor := v_candidates[1];
118
+ UPDATE public.people SET
119
+ email = coalesce(email, p_row ->> 'email'),
120
+ phone = coalesce(phone, p_row ->> 'phone'),
121
+ avatar_url = coalesce(avatar_url, p_row ->> 'avatar_url'),
122
+ date_of_birth = coalesce(date_of_birth, (p_row ->> 'date_of_birth')::date),
123
+ address = coalesce(address, p_row ->> 'address'),
124
+ city = coalesce(city, p_row ->> 'city'),
125
+ state = coalesce(state, p_row ->> 'state'),
126
+ country = coalesce(country, p_row ->> 'country'),
127
+ postal_code = coalesce(postal_code, p_row ->> 'postal_code'),
128
+ notes = coalesce(notes, p_row ->> 'notes'),
129
+ metadata = migration._merge_meta(metadata,
130
+ (v_meta - 'migration') || jsonb_build_object('migration', jsonb_build_object('merged_from', jsonb_build_array(v_origin))))
131
+ WHERE id = v_survivor;
132
+ PERFORM migration._ensure_role(p_tenant, v_survivor, v_role);
133
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.people', v_survivor, jsonb_build_object('reason', 'merged', 'origin', v_origin)); END IF;
134
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', v_survivor, 'reason', 'merged', 'merged', true);
135
+ END IF;
136
+
137
+ -- kind is written for compatibility only; the role row below is the truth
138
+ -- (fayz.people_kind_sync = on keeps the kind→role trigger quiet meanwhile)
139
+ PERFORM set_config('fayz.people_kind_sync', 'on', true);
140
+ -- `is_bookable` decide quem vira COLUNA na agenda, e a coluna do destino tem
141
+ -- DEFAULT true — então, sem isto, toda pessoa migrada nasce agendável. O
142
+ -- papel da origem é quem sabe: no V1 quem atende cliente é `professionals`;
143
+ -- `employees` é administrativo e nunca aparece na agenda de lá.
144
+ --
145
+ -- É DECLARADO em `writer_args.bookable`, por tabela de origem, e não
146
+ -- derivado do papel aqui dentro: um pool onde a recepção também atende deve
147
+ -- poder dizer isso sem reescrever o writer. Ausente, o destino decide — que
148
+ -- é o comportamento de antes.
149
+ INSERT INTO public.people (id, tenant_id, kind, name, email, phone, document_number, avatar_url, date_of_birth,
150
+ address, city, state, country, postal_code, notes, is_active, is_bookable, metadata)
151
+ VALUES (p_id, p_tenant, coalesce(public.person_role_to_kind(v_role), v_role), p_row ->> 'name', p_row ->> 'email', p_row ->> 'phone', v_doc,
152
+ p_row ->> 'avatar_url', (p_row ->> 'date_of_birth')::date, p_row ->> 'address', p_row ->> 'city', p_row ->> 'state',
153
+ coalesce(p_row ->> 'country', 'BR'), p_row ->> 'postal_code', p_row ->> 'notes',
154
+ coalesce((p_row ->> 'is_active')::boolean, true),
155
+ coalesce((p_args ->> 'bookable')::boolean, true), v_meta);
156
+ PERFORM set_config('fayz.people_kind_sync', 'off', true);
157
+ PERFORM migration._ensure_role(p_tenant, p_id, v_role);
158
+ PERFORM migration._set_unit('public.people', p_id, (p_row ->> 'unit_id')::uuid);
159
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.people', p_id, jsonb_build_object('reason', 'created')); END IF;
160
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'created');
161
+ END $function$
162
+
163
+ ;
164
+
165
+ -- ─────────────────────────────────────────────────────────────────────────────
166
+ -- A declaração, por tabela de origem
167
+ -- ─────────────────────────────────────────────────────────────────────────────
168
+
169
+ SET LOCAL migration.allowlist_unlock = 'on';
170
+
171
+ UPDATE migration.allowlist
172
+ SET writer_args = coalesce(writer_args, '{}'::jsonb) || jsonb_build_object('bookable', true)
173
+ WHERE source_table = 'professionals';
174
+
175
+ UPDATE migration.allowlist
176
+ SET writer_args = coalesce(writer_args, '{}'::jsonb) || jsonb_build_object('bookable', false)
177
+ WHERE source_table IN ('employees', 'clients', 'contacts');
178
+
179
+ RESET migration.allowlist_unlock;
180
+
181
+ -- ─────────────────────────────────────────────────────────────────────────────
182
+ -- E o que já entrou
183
+ -- ─────────────────────────────────────────────────────────────────────────────
184
+ --
185
+ -- Em LOTES, e não num UPDATE só: a junção com o ledger sobre 120 mil pessoas
186
+ -- não termina dentro do `statement_timeout`, e um reparo que estoura desfaz a
187
+ -- migration inteira junto. Cada volta é uma transação curta.
188
+ --
189
+ -- Só o que o PORTE escreveu — a condição exige linha no `migration.ledger`.
190
+ -- Alguém que o V2 tenha marcado como agendável à mão fica como está: isso é
191
+ -- decisão de quem usa o sistema, não do porte.
192
+
193
+ DO $$
194
+ DECLARE n integer;
195
+ BEGIN
196
+ LOOP
197
+ UPDATE public.people p
198
+ SET is_bookable = false
199
+ WHERE p.ctid IN (
200
+ SELECT p2.ctid FROM public.people p2
201
+ JOIN migration.ledger l
202
+ ON l.target_id = p2.id
203
+ AND l.target_table = 'public.people'
204
+ AND l.source_table IN ('employees', 'clients', 'contacts')
205
+ WHERE p2.is_bookable
206
+ LIMIT 2000);
207
+ GET DIAGNOSTICS n = ROW_COUNT;
208
+ EXIT WHEN n = 0;
209
+ END LOOP;
210
+ END $$;
@@ -0,0 +1,240 @@
1
+ -- O contexto da requisição se calcula uma vez, não uma vez por linha.
2
+ --
3
+ -- Os painéis não carregavam: seis dos sete read models estouravam o
4
+ -- `statement_timeout`. A causa não estava nos relatórios — estava embaixo
5
+ -- deles.
6
+ --
7
+ -- ── A medição ───────────────────────────────────────────────────────────────
8
+ --
9
+ -- Contar UM MÊS de agenda, como `authenticated`, tenant Espaço Facial,
10
+ -- 20/09/2026:
11
+ --
12
+ -- select count(*) from public.appointments
13
+ -- where starts_at >= '2026-08-01' and starts_at < '2026-09-01'
14
+ -- → 8.011 linhas em 21.947 ms
15
+ --
16
+ -- Note que é a TABELA CRUA, sem view nenhuma: **2,7 ms por linha**, tudo em
17
+ -- RLS. E dentro dela:
18
+ --
19
+ -- app.has_unit(unidade) 1,558 ms por chamada
20
+ -- app.has_permission(chave, unidade) 1,002 ms
21
+ -- app.owner_scoped(tipo) 0,408 ms
22
+ --
23
+ -- `app.has_unit` sozinho chama `app.current_unit_id()` DUAS vezes,
24
+ -- `app.unit_scoping_on()` e, dentro dela, `app.current_tenant_id()`. Cada uma
25
+ -- com consulta própria — e todas respondem a MESMA COISA durante a requisição
26
+ -- inteira: quem é o usuário, que tenant ele escolheu, que unidade está em
27
+ -- foco, se a rede usa escopo por unidade.
28
+ --
29
+ -- Calcular isso por linha é o desperdício. Oito mil linhas × quatro perguntas
30
+ -- cuja resposta não muda.
31
+ --
32
+ -- Vale dizer o que piorou: a migration 073, de hoje, ensinou
33
+ -- `current_unit_id()` a ler o cabeçalho `x-fayz-unit` — e com isso acrescentou
34
+ -- uma consulta a `app.user_unit_access` a cada chamada. O conserto estava
35
+ -- certo; o preço dele é esta migration que paga.
36
+ --
37
+ -- ── A forma: invólucro, não reescrita ───────────────────────────────────────
38
+ --
39
+ -- As três funções mantêm o corpo EXATO que tinham, renomeadas para
40
+ -- `app._*_raw()`. Por cima delas entra um invólucro que memoiza em um GUC
41
+ -- transacional (`set_config(..., true)`).
42
+ --
43
+ -- Não reescrevi a lógica de propósito: ela decide quem vê o quê, e uma
44
+ -- otimização não é hora de mexer em regra de acesso. O que muda é QUANTAS
45
+ -- VEZES ela roda, não o que ela responde.
46
+ --
47
+ -- ── Por que memoizar é seguro aqui ──────────────────────────────────────────
48
+ --
49
+ -- O GUC é LOCAL: morre no fim da transação, e o PostgREST abre uma transação
50
+ -- por requisição, com JWT e cabeçalhos fixos. Não há como a resposta mudar
51
+ -- dentro do escopo do cache.
52
+ --
53
+ -- O sentinela `'-'` distingue "já calculei e deu NULL" de "ainda não
54
+ -- calculei". Sem ele o caso NULL — que é o mais comum, "todas as unidades" —
55
+ -- nunca seria memoizado, e o cache não serviria justamente para quem mais
56
+ -- precisa.
57
+ --
58
+ -- Idempotente.
59
+
60
+ -- ─────────────────────────────────────────────────────────────────────────────
61
+ -- 1. O corpo original, renomeado e intocado
62
+ -- ─────────────────────────────────────────────────────────────────────────────
63
+
64
+ CREATE OR REPLACE FUNCTION app._current_tenant_id_raw()
65
+ RETURNS uuid
66
+ LANGUAGE plpgsql
67
+ STABLE SECURITY DEFINER
68
+ SET search_path TO ''
69
+ AS $function$
70
+ DECLARE
71
+ v_uid uuid := auth.uid();
72
+ v_jwt jsonb := coalesce(auth.jwt(), '{}'::jsonb);
73
+ v_raw text;
74
+ v_tenant uuid;
75
+ v_n integer;
76
+ BEGIN
77
+ -- ① a claim assinada, se existir. Inalterado desde 051.
78
+ IF jsonb_exists(v_jwt, 'tenant_id') THEN
79
+ v_raw := v_jwt ->> 'tenant_id';
80
+ -- Presença expressa intenção de estreitar; valor ilegível é nenhum tenant.
81
+ IF v_raw IS NULL OR v_raw !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN
82
+ RETURN NULL;
83
+ END IF;
84
+ v_tenant := v_raw::uuid;
85
+ IF v_uid IS NULL THEN
86
+ RETURN v_tenant;
87
+ END IF;
88
+ IF EXISTS (SELECT 1 FROM app.memberships m
89
+ WHERE m.tenant_id = v_tenant AND m.user_id = v_uid AND m.active) THEN
90
+ RETURN v_tenant;
91
+ END IF;
92
+ RETURN NULL;
93
+ END IF;
94
+
95
+ IF v_uid IS NULL THEN
96
+ RETURN NULL;
97
+ END IF;
98
+
99
+ -- ② o cabeçalho da requisição. A MESMA validação de filiação de ①: ele
100
+ -- escolhe entre as contas do usuário, não concede nenhuma. Sem sessão não há
101
+ -- o que validar, então este braço exige v_uid — que já está garantido acima.
102
+ v_tenant := app.requested_tenant();
103
+ IF v_tenant IS NOT NULL THEN
104
+ IF EXISTS (SELECT 1 FROM app.memberships m
105
+ WHERE m.tenant_id = v_tenant AND m.user_id = v_uid AND m.active) THEN
106
+ RETURN v_tenant;
107
+ END IF;
108
+ -- Cabeçalho apontando para conta que não é sua: nega, não recua. Recuar
109
+ -- para "sua única conta" transformaria um pedido explícito e errado numa
110
+ -- resposta silenciosa sobre outra conta.
111
+ RETURN NULL;
112
+ END IF;
113
+
114
+ -- ③ o recuo: conta única. Quem tem duas e não disse qual continua sem tenant,
115
+ -- que é a resposta correta — só que agora ela é evitável.
116
+ SELECT count(*), (array_agg(m.tenant_id))[1] INTO v_n, v_tenant
117
+ FROM app.memberships m WHERE m.user_id = v_uid AND m.active;
118
+ IF v_n = 1 THEN
119
+ RETURN v_tenant;
120
+ END IF;
121
+ RETURN NULL;
122
+ END $function$
123
+
124
+ ;
125
+ CREATE OR REPLACE FUNCTION app._current_unit_id_raw()
126
+ RETURNS uuid
127
+ LANGUAGE plpgsql
128
+ STABLE SECURITY DEFINER
129
+ SET search_path TO ''
130
+ AS $function$
131
+ DECLARE
132
+ v_jwt jsonb := coalesce(auth.jwt(), '{}'::jsonb);
133
+ v_raw text;
134
+ v_unit uuid;
135
+ v_uid uuid;
136
+ BEGIN
137
+ -- ① a claim assinada, quando existir. Inalterado.
138
+ IF jsonb_exists(v_jwt, 'unit_id') THEN
139
+ v_raw := v_jwt ->> 'unit_id';
140
+ IF v_raw IS NULL OR v_raw !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN
141
+ RETURN '00000000-0000-0000-0000-000000000000'::uuid;
142
+ END IF;
143
+ RETURN v_raw::uuid;
144
+ END IF;
145
+
146
+ -- ② o cabeçalho do app. Ele ESCOLHE entre as unidades que a pessoa já
147
+ -- alcança; não concede nenhuma.
148
+ v_unit := app.requested_unit();
149
+ IF v_unit IS NULL THEN
150
+ RETURN NULL; -- "todas as unidades" — o seletor está em rede
151
+ END IF;
152
+
153
+ v_uid := auth.uid();
154
+ IF v_uid IS NULL THEN
155
+ RETURN '00000000-0000-0000-0000-000000000000'::uuid;
156
+ END IF;
157
+
158
+ IF EXISTS (SELECT 1 FROM app.user_unit_access a
159
+ WHERE a.tenant_id = app.current_tenant_id()
160
+ AND a.user_id = v_uid
161
+ AND a.unit_id = v_unit) THEN
162
+ RETURN v_unit;
163
+ END IF;
164
+
165
+ -- Pediu uma unidade que não é sua: nega. O sentinela não casa com unidade
166
+ -- nenhuma, então a tela vem vazia — que é a resposta certa, e muito melhor
167
+ -- que devolver a rede inteira.
168
+ RETURN '00000000-0000-0000-0000-000000000000'::uuid;
169
+ END $function$
170
+
171
+ ;
172
+ CREATE OR REPLACE FUNCTION app._unit_scoping_on_raw()
173
+ RETURNS boolean
174
+ LANGUAGE sql
175
+ STABLE SECURITY DEFINER
176
+ SET search_path TO ''
177
+ AS $function$
178
+ SELECT coalesce((SELECT t.unit_scoping_enabled FROM public.tenants t
179
+ WHERE t.id = app.current_tenant_id()), false);
180
+ $function$
181
+
182
+ ;
183
+
184
+ -- ─────────────────────────────────────────────────────────────────────────────
185
+ -- 2. Os invólucros que memoizam
186
+ -- ─────────────────────────────────────────────────────────────────────────────
187
+
188
+ CREATE OR REPLACE FUNCTION app.current_tenant_id()
189
+ RETURNS uuid
190
+ LANGUAGE plpgsql
191
+ STABLE SECURITY DEFINER
192
+ SET search_path TO ''
193
+ AS $function$
194
+ DECLARE v_cache text := current_setting('app.ctx_tenant', true); v_out uuid;
195
+ BEGIN
196
+ IF v_cache IS NOT NULL AND v_cache <> '' THEN
197
+ RETURN nullif(v_cache, '-')::uuid;
198
+ END IF;
199
+ v_out := app._current_tenant_id_raw();
200
+ PERFORM set_config('app.ctx_tenant', coalesce(v_out::text, '-'), true);
201
+ RETURN v_out;
202
+ END $function$;
203
+
204
+ CREATE OR REPLACE FUNCTION app.current_unit_id()
205
+ RETURNS uuid
206
+ LANGUAGE plpgsql
207
+ STABLE SECURITY DEFINER
208
+ SET search_path TO ''
209
+ AS $function$
210
+ DECLARE v_cache text := current_setting('app.ctx_unit', true); v_out uuid;
211
+ BEGIN
212
+ IF v_cache IS NOT NULL AND v_cache <> '' THEN
213
+ RETURN nullif(v_cache, '-')::uuid;
214
+ END IF;
215
+ v_out := app._current_unit_id_raw();
216
+ PERFORM set_config('app.ctx_unit', coalesce(v_out::text, '-'), true);
217
+ RETURN v_out;
218
+ END $function$;
219
+
220
+ CREATE OR REPLACE FUNCTION app.unit_scoping_on()
221
+ RETURNS boolean
222
+ LANGUAGE plpgsql
223
+ STABLE SECURITY DEFINER
224
+ SET search_path TO ''
225
+ AS $function$
226
+ DECLARE v_cache text := current_setting('app.ctx_unit_scoping', true); v_out boolean;
227
+ BEGIN
228
+ IF v_cache IS NOT NULL AND v_cache <> '' THEN
229
+ RETURN v_cache = 't';
230
+ END IF;
231
+ v_out := app._unit_scoping_on_raw();
232
+ PERFORM set_config('app.ctx_unit_scoping', CASE WHEN v_out THEN 't' ELSE 'f' END, true);
233
+ RETURN v_out;
234
+ END $function$;
235
+
236
+ COMMENT ON FUNCTION app.current_tenant_id() IS
237
+ 'O tenant da requisição, calculado UMA vez por transação. A regra mora em '
238
+ 'app._current_tenant_id_raw() e não mudou; o que muda é quantas vezes ela '
239
+ 'roda. Sem isto, contar um mês de agenda custava 2,7 ms por linha e os '
240
+ 'painéis estouravam o relógio.';
@@ -0,0 +1,88 @@
1
+ -- A permissão por unidade se resolve uma vez por unidade, não por linha.
2
+ --
3
+ -- Depois da 076 o contexto da requisição já não é recalculado por linha, e
4
+ -- contar um mês de agenda caiu de 21,9 s para 11,8 s. Ainda é muito: 1,47 ms
5
+ -- por linha, e a maior parte do que sobrou é `app.has_permission`.
6
+ --
7
+ -- app.has_permission(chave, unidade) 1,002 ms por chamada
8
+ --
9
+ -- Ela não pôde ser memoizada na 076 porque RECEBE ARGUMENTOS — e é a política
10
+ -- que os fornece, com o `unit_id` da linha:
11
+ --
12
+ -- CASE WHEN unit_id IS NULL THEN app.has_permission('agenda.read', NULL)
13
+ -- ELSE app.has_permission('agenda.read', unit_id) END
14
+ --
15
+ -- Só que os argumentos distintos dentro de UMA consulta são poucos: uma
16
+ -- permissão, e as unidades que aparecem na página. Oito mil linhas produzem
17
+ -- talvez três respostas diferentes — e a função as recalculava oito mil vezes,
18
+ -- cada uma passando por `app.effective_role_id`, que busca o papel efetivo do
19
+ -- usuário naquela unidade.
20
+ --
21
+ -- ── O cache com chave ───────────────────────────────────────────────────────
22
+ --
23
+ -- Um GUC transacional guardando um MAPA jsonb, com chave
24
+ -- `<permissão>|<unidade>`. Ler e escrever um mapa pequeno custa microssegundos
25
+ -- contra o milissegundo da resolução de papel.
26
+ --
27
+ -- O corpo da regra fica intocado em `app._has_permission_raw()`, como na 076 e
28
+ -- pelo mesmo motivo: isto decide quem vê o quê, e uma otimização não é hora de
29
+ -- mexer em regra de acesso. Muda quantas vezes ela roda.
30
+ --
31
+ -- Transacional, então morre com a requisição — o papel de alguém pode mudar a
32
+ -- qualquer momento e a próxima requisição já vê o novo. Dentro de uma
33
+ -- transação ele não muda, e é só isso que o cache assume.
34
+ --
35
+ -- Idempotente.
36
+
37
+ -- ─────────────────────────────────────────────────────────────────────────────
38
+ -- 1. A regra, intocada
39
+ -- ─────────────────────────────────────────────────────────────────────────────
40
+
41
+ CREATE OR REPLACE FUNCTION app._has_permission_raw(p_perm text, p_unit uuid DEFAULT NULL::uuid)
42
+ RETURNS boolean
43
+ LANGUAGE sql
44
+ STABLE SECURITY DEFINER
45
+ SET search_path TO ''
46
+ AS $function$
47
+ SELECT EXISTS (
48
+ SELECT 1
49
+ FROM app.role_permissions rp
50
+ WHERE rp.tenant_id = app.current_tenant_id()
51
+ AND rp.role_id = app.effective_role_id(app.current_tenant_id(), auth.uid(), p_unit)
52
+ AND rp.permission IN (p_perm, regexp_replace(p_perm, '\.[^.]+$', '.manage'))
53
+ );
54
+ $function$;
55
+
56
+ -- ─────────────────────────────────────────────────────────────────────────────
57
+ -- 2. O invólucro com memória
58
+ -- ─────────────────────────────────────────────────────────────────────────────
59
+
60
+ CREATE OR REPLACE FUNCTION app.has_permission(p_perm text, p_unit uuid DEFAULT NULL::uuid)
61
+ RETURNS boolean
62
+ LANGUAGE plpgsql
63
+ STABLE SECURITY DEFINER
64
+ SET search_path TO ''
65
+ AS $function$
66
+ DECLARE
67
+ v_chave text := p_perm || '|' || coalesce(p_unit::text, '-');
68
+ v_mapa jsonb := coalesce(nullif(current_setting('app.ctx_perm', true), '')::jsonb, '{}'::jsonb);
69
+ v_out boolean;
70
+ BEGIN
71
+ IF v_mapa ? v_chave THEN
72
+ RETURN (v_mapa ->> v_chave)::boolean;
73
+ END IF;
74
+ v_out := app._has_permission_raw(p_perm, p_unit);
75
+ PERFORM set_config('app.ctx_perm',
76
+ (v_mapa || jsonb_build_object(v_chave, v_out))::text, true);
77
+ RETURN v_out;
78
+ EXCEPTION
79
+ -- Um cache corrompido não pode virar negação de acesso: se o mapa não
80
+ -- parsear por qualquer motivo, responde a pergunta do jeito caro.
81
+ WHEN OTHERS THEN RETURN app._has_permission_raw(p_perm, p_unit);
82
+ END $function$;
83
+
84
+ COMMENT ON FUNCTION app.has_permission(text, uuid) IS
85
+ 'A permissão do usuário, memoizada por (permissão, unidade) dentro da '
86
+ 'transação. A regra mora em app._has_permission_raw() e não mudou. Sem o '
87
+ 'cache ela custava 1 ms por LINHA lida — oito mil linhas de agenda para '
88
+ 'três respostas distintas.';
@@ -0,0 +1,110 @@
1
+ -- A cerca também se lembra.
2
+ --
3
+ -- As 076 e 077 puseram memória no contexto da requisição e na permissão. O
4
+ -- caminho da agenda caiu de 21,9 s para 7,6 s num mês — mas ainda são 0,95 ms
5
+ -- por linha, e todo o resto está nas duas funções que faltaram:
6
+ --
7
+ -- app.has_unit(unidade) 0,445 ms por chamada, POR LINHA
8
+ -- app.owner_scoped(tipo) 0,408 ms
9
+ --
10
+ -- As duas recebem argumento, e por isso não entraram na 076. Mas os argumentos
11
+ -- distintos dentro de uma consulta são poucos — um tipo de recurso, e as
12
+ -- unidades que aparecem na página. `has_unit` responde no máximo 69 coisas
13
+ -- diferentes neste tenant; ela era chamada oito mil vezes.
14
+ --
15
+ -- Mesma forma das anteriores: a regra fica intocada em `app._*_raw()`, e o
16
+ -- invólucro guarda um mapa jsonb num GUC transacional. As duas dividem um mapa só (`app.ctx_acl`), porque respondem à mesma
17
+ -- pergunta de fundo — o que este usuário alcança — e um mapa é mais barato de
18
+ -- ler que dois.
19
+ --
20
+ -- `grants_exist` fica de fora de propósito: os planos mostram que ela já é
21
+ -- avaliada como InitPlan, uma vez por consulta, porque seus argumentos são
22
+ -- constantes. Memoizá-la seria cache sobre cache.
23
+ --
24
+ -- Idempotente.
25
+
26
+ -- ─────────────────────────────────────────────────────────────────────────────
27
+ -- 1. As regras, intocadas
28
+ -- ─────────────────────────────────────────────────────────────────────────────
29
+
30
+ CREATE OR REPLACE FUNCTION app._has_unit_raw(p_unit uuid)
31
+ RETURNS boolean
32
+ LANGUAGE sql
33
+ STABLE SECURITY DEFINER
34
+ SET search_path TO ''
35
+ AS $function$
36
+ SELECT p_unit IS NOT NULL
37
+ AND (app.current_unit_id() IS NULL OR p_unit = app.current_unit_id())
38
+ AND CASE WHEN app.unit_scoping_on()
39
+ THEN EXISTS (SELECT 1 FROM app.user_unit_access a
40
+ WHERE a.tenant_id = app.current_tenant_id()
41
+ AND a.user_id = auth.uid()
42
+ AND a.unit_id = p_unit)
43
+ ELSE EXISTS (SELECT 1 FROM app.units u
44
+ WHERE u.id = p_unit AND u.tenant_id = app.current_tenant_id())
45
+ END;
46
+ $function$
47
+
48
+ ;
49
+ CREATE OR REPLACE FUNCTION app._owner_scoped_raw(p_resource_type text)
50
+ RETURNS boolean
51
+ LANGUAGE sql
52
+ STABLE SECURITY DEFINER
53
+ SET search_path TO ''
54
+ AS $function$
55
+ SELECT coalesce(
56
+ (SELECT v.owner_scoped FROM app.vertical_owner_scoping v
57
+ JOIN public.tenants t ON t.role_template = v.role_template
58
+ WHERE t.id = app.current_tenant_id() AND v.resource_type = p_resource_type),
59
+ -- no configuration for this vertical: the table's own flag, which defaults
60
+ -- true. Configuration relaxes; it never tightens by omission.
61
+ (SELECT r.owner_scoping FROM app.scaffold_registry r WHERE r.resource_type = p_resource_type LIMIT 1),
62
+ true)
63
+ $function$
64
+
65
+ ;
66
+
67
+ -- ─────────────────────────────────────────────────────────────────────────────
68
+ -- 2. Os invólucros com memória
69
+ -- ─────────────────────────────────────────────────────────────────────────────
70
+
71
+ CREATE OR REPLACE FUNCTION app.has_unit(p_unit uuid)
72
+ RETURNS boolean
73
+ LANGUAGE plpgsql
74
+ STABLE SECURITY DEFINER
75
+ SET search_path TO ''
76
+ AS $function$
77
+ DECLARE
78
+ v_chave text := 'u|' || coalesce(p_unit::text, '-');
79
+ v_mapa jsonb := coalesce(nullif(current_setting('app.ctx_acl', true), '')::jsonb, '{}'::jsonb);
80
+ v_out boolean;
81
+ BEGIN
82
+ IF v_mapa ? v_chave THEN RETURN (v_mapa ->> v_chave)::boolean; END IF;
83
+ v_out := app._has_unit_raw(p_unit);
84
+ PERFORM set_config('app.ctx_acl', (v_mapa || jsonb_build_object(v_chave, v_out))::text, true);
85
+ RETURN v_out;
86
+ EXCEPTION WHEN OTHERS THEN RETURN app._has_unit_raw(p_unit);
87
+ END $function$;
88
+
89
+ CREATE OR REPLACE FUNCTION app.owner_scoped(p_resource_type text)
90
+ RETURNS boolean
91
+ LANGUAGE plpgsql
92
+ STABLE SECURITY DEFINER
93
+ SET search_path TO ''
94
+ AS $function$
95
+ DECLARE
96
+ v_chave text := 'o|' || coalesce(p_resource_type, '-');
97
+ v_mapa jsonb := coalesce(nullif(current_setting('app.ctx_acl', true), '')::jsonb, '{}'::jsonb);
98
+ v_out boolean;
99
+ BEGIN
100
+ IF v_mapa ? v_chave THEN RETURN (v_mapa ->> v_chave)::boolean; END IF;
101
+ v_out := app._owner_scoped_raw(p_resource_type);
102
+ PERFORM set_config('app.ctx_acl', (v_mapa || jsonb_build_object(v_chave, v_out))::text, true);
103
+ RETURN v_out;
104
+ EXCEPTION WHEN OTHERS THEN RETURN app._owner_scoped_raw(p_resource_type);
105
+ END $function$;
106
+
107
+ COMMENT ON FUNCTION app.has_unit(uuid) IS
108
+ 'A cerca da unidade, memoizada por unidade dentro da transação. A regra mora '
109
+ 'em app._has_unit_raw() e não mudou. Ela responde no máximo uma coisa por '
110
+ 'unidade da rede, e era chamada uma vez por linha lida.';