@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,276 @@
1
+ -- O apelido da referência não disputa com a variável.
2
+ --
3
+ -- A 071 filtra as referências opcionais com
4
+ -- `jsonb_array_elements(v_unres) x` — e `x` já é uma variável declarada em
5
+ -- `migration._upsert_row_impl` (ela guarda cada destino extra de uma Venda).
6
+ -- O Postgres recusa a ambiguidade em vez de escolher:
7
+ --
8
+ -- column reference "x" is ambiguous
9
+ -- DETAIL: It could refer to either a PL/pgSQL variable or a table column.
10
+ --
11
+ -- E recusa CERTO: adivinhar qual dos dois o autor queria seria pior que parar.
12
+ --
13
+ -- O efeito foi total, não parcial: a etapa `financial_movements` inteira
14
+ -- abortou na primeira linha, e a rodada de quatro minutos que parecia ter
15
+ -- passado não processou nada. Um erro de nome custou a passada toda.
16
+ --
17
+ -- Idempotente.
18
+
19
+ CREATE OR REPLACE FUNCTION migration._upsert_row_impl(p_batch uuid, p_source_table text, p_source_numeric_id bigint, p_source_uuid uuid, p_cursor text, p_row jsonb)
20
+ RETURNS jsonb
21
+ LANGUAGE plpgsql
22
+ SECURITY DEFINER
23
+ SET search_path TO ''
24
+ AS $function$
25
+ DECLARE
26
+ b migration.batches%ROWTYPE;
27
+ a migration.allowlist%ROWTYPE;
28
+ l migration.ledger%ROWTYPE;
29
+ v_checksum text := md5(p_row::text);
30
+ v_target_table text;
31
+ v_target_id uuid;
32
+ v_mapped jsonb; v_row jsonb; v_meta jsonb; v_unres jsonb;
33
+ v_dest jsonb; v_fin jsonb;
34
+ v_res jsonb; v_status text; v_col text; v_excl text;
35
+ v_retry_split boolean := false;
36
+ -- O V1 manda: ver migration.is_mirroring() e o cabeçalho deste arquivo.
37
+ v_mirror boolean := false;
38
+ x jsonb; v_xid bigint;
39
+ BEGIN
40
+ SELECT * INTO b FROM migration.batches WHERE id = p_batch;
41
+ IF NOT FOUND THEN RAISE EXCEPTION 'upsert_row: unknown batch %', p_batch USING ERRCODE = 'P0002'; END IF;
42
+ IF b.status <> 'running' THEN
43
+ RAISE EXCEPTION 'upsert_row: batch % is % (only a running batch accepts rows)', p_batch, b.status USING ERRCODE = '55000';
44
+ END IF;
45
+ PERFORM migration._assert_fence(b.tenant_id);
46
+
47
+ v_mirror := coalesce(migration.is_mirroring(b.tenant_id), false);
48
+
49
+ SELECT * INTO a FROM migration.allowlist WHERE source_table = p_source_table;
50
+ v_target_table := a.target_table;
51
+
52
+ SELECT * INTO l FROM migration.ledger
53
+ WHERE source_project = b.source_project AND source_table = p_source_table
54
+ AND ((p_source_numeric_id IS NOT NULL AND source_numeric_id = p_source_numeric_id)
55
+ OR (p_source_numeric_id IS NULL AND source_uuid = p_source_uuid))
56
+ AND coalesce(target_table, '') = coalesce(v_target_table, '')
57
+ FOR UPDATE;
58
+ IF NOT FOUND THEN
59
+ INSERT INTO migration.ledger (source_project, source_table, source_numeric_id, source_uuid, tenant_id, target_table, checksum, cursor, batch_id, status)
60
+ VALUES (b.source_project, p_source_table, p_source_numeric_id, p_source_uuid, b.tenant_id, v_target_table, v_checksum, p_cursor, p_batch, 'pending')
61
+ RETURNING * INTO l;
62
+ ELSIF l.tenant_id <> b.tenant_id THEN
63
+ RETURN migration._quarantine(l.id, p_batch, 'tenant',
64
+ format('source row already belongs to tenant %s; this batch migrates tenant %s', l.tenant_id, b.tenant_id), p_row);
65
+ END IF;
66
+
67
+ -- allowlist
68
+ IF p_source_table LIKE 'import\_ef\_%' OR EXISTS (SELECT 1 FROM migration.excluded_tables e WHERE e.source_table = p_source_table) THEN
69
+ SELECT reason INTO v_excl FROM migration.excluded_tables e WHERE e.source_table = CASE WHEN p_source_table LIKE 'import\_ef\_%' THEN 'import_ef_*' ELSE p_source_table END;
70
+ RETURN migration._quarantine(l.id, p_batch, 'allowlist', 'source table is explicitly excluded: ' || coalesce(v_excl, 'no reason recorded'), p_row,
71
+ NULL, 'recorded decision: the table is in migration.excluded_tables');
72
+ END IF;
73
+ IF a.source_table IS NULL THEN
74
+ RETURN migration._quarantine(l.id, p_batch, 'allowlist', format('source table %s is not in the core-A allowlist', p_source_table), p_row);
75
+ END IF;
76
+ -- tenant identity of the source row vs the batch
77
+ IF b.source_tenant_id IS NOT NULL AND (p_row ->> 'tenant_id') ~ '^\d+$' AND (p_row ->> 'tenant_id')::bigint <> b.source_tenant_id THEN
78
+ RETURN migration._quarantine(l.id, p_batch, 'tenant',
79
+ format('source row tenant_id %s differs from the batch source_tenant_id %s', p_row ->> 'tenant_id', b.source_tenant_id), p_row);
80
+ END IF;
81
+
82
+ -- idempotency + delta
83
+ IF EXISTS (SELECT 1 FROM migration.quarantine q WHERE q.ledger_id = l.id AND q.resolved_at IS NULL AND md5(q.payload::text) = v_checksum) THEN
84
+ PERFORM migration._count(p_batch, 'skipped');
85
+ RETURN jsonb_build_object('status', 'skipped', 'reason', 'quarantined and unresolved: ' || coalesce(l.reason, ''),
86
+ 'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
87
+ END IF;
88
+ IF l.status = 'migrated' THEN
89
+ -- a split destination that did not exist on the first offer (invoice) and exists now: run the writer again
90
+ -- Duas formas da MESMA pergunta, e não uma com IS NOT DISTINCT FROM: esse
91
+ -- operador não vira condição de índice, então a sondagem virava varredura
92
+ -- da ledger inteira daquela tabela — 83 ms e 66 MB POR LINHA oferecida,
93
+ -- medido com 123.535 linhas de `clients`. Aqui cada ramo é um qual simples
94
+ -- sobre a chave que existe, e os dois têm índice:
95
+ -- numérica → ledger_source_numeric_key (source_project, source_table, source_numeric_id, …)
96
+ -- uuid → ledger_source_uuid_idx (source_project, source_table, source_uuid)
97
+ -- O ramo numérico não repete o casamento de uuid porque não precisa: dentro
98
+ -- de (projeto, tabela) o id numérico JÁ identifica a linha da origem, e as
99
+ -- irmãs no ledger são a mesma linha com outro destino.
100
+ IF l.source_numeric_id IS NOT NULL THEN
101
+ SELECT EXISTS (SELECT 1 FROM migration.ledger s
102
+ WHERE s.source_project = l.source_project AND s.source_table = l.source_table
103
+ AND s.source_numeric_id = l.source_numeric_id
104
+ AND s.id <> l.id AND s.status IN ('pending', 'skipped') AND to_regclass(s.target_table) IS NOT NULL) INTO v_retry_split;
105
+ ELSE
106
+ SELECT EXISTS (SELECT 1 FROM migration.ledger s
107
+ WHERE s.source_project = l.source_project AND s.source_table = l.source_table
108
+ AND s.source_uuid = l.source_uuid
109
+ AND s.id <> l.id AND s.status IN ('pending', 'skipped') AND to_regclass(s.target_table) IS NOT NULL) INTO v_retry_split;
110
+ END IF;
111
+ IF l.checksum = v_checksum AND NOT v_retry_split THEN
112
+ PERFORM migration._count(p_batch, 'skipped');
113
+ RETURN jsonb_build_object('status', 'skipped', 'reason', 'unchanged', 'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
114
+ END IF;
115
+ IF l.checksum <> v_checksum AND migration.cursor_cmp(p_cursor, l.cursor) < 0 THEN
116
+ PERFORM migration._count(p_batch, 'skipped');
117
+ RETURN jsonb_build_object('status', 'skipped', 'reason', format('stale cursor: %s < %s already migrated', p_cursor, l.cursor),
118
+ 'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
119
+ END IF;
120
+ END IF;
121
+
122
+ -- no writer yet: recorded, re-offered later
123
+ IF a.writer IS NULL THEN
124
+ UPDATE migration.ledger SET status = 'pending', reason = format('no destination writer for %s yet', v_target_table),
125
+ checksum = v_checksum, cursor = p_cursor, batch_id = p_batch WHERE id = l.id;
126
+ PERFORM migration._count(p_batch, 'pending');
127
+ RETURN jsonb_build_object('status', 'pending', 'reason', format('no destination writer for %s yet', v_target_table),
128
+ 'target_table', v_target_table, 'target_id', l.target_id, 'ledger_id', l.id);
129
+ END IF;
130
+
131
+ -- map + references
132
+ v_mapped := migration.map_row(b.source_project, p_source_table, p_row);
133
+ v_row := v_mapped -> 'row'; v_meta := v_mapped -> 'metadata'; v_unres := v_mapped -> 'unresolved';
134
+
135
+ -- Referência OPCIONAL: a que pode faltar sem que a linha perca o sentido.
136
+ --
137
+ -- Nem toda referência é identidade. A perna contábil de um movimento é
138
+ -- CLASSIFICAÇÃO: sem ela o dinheiro continua tendo valor, vencimento,
139
+ -- unidade e contraparte — tudo o que a tela de Contas a Receber mostra.
140
+ -- Recusar o movimento inteiro porque a classificação dele aponta para uma
141
+ -- conta que o próprio V1 apagou é perder o fato para preservar o rótulo.
142
+ --
143
+ -- Medido em 20/09/2026 no Espaço Facial: 81 das 84 contas do razão citadas
144
+ -- pelos movimentos recusados NÃO EXISTEM no V1. A referência já estava
145
+ -- quebrada na origem. Custo na tela: R$ 138.027 só no Millenium.
146
+ --
147
+ -- Quais colunas podem faltar é DECLARADO por tabela, em
148
+ -- `migration.allowlist.optional_refs` — nunca adivinhado. O que ficou sem
149
+ -- resolver vai para `metadata.migration.unresolved`: some da tela, não do
150
+ -- registro.
151
+ IF jsonb_array_length(v_unres) > 0 AND a.optional_refs IS NOT NULL THEN
152
+ -- O apelido é `ref`, e não `x`: `x` já é uma variável declarada nesta
153
+ -- função, e o Postgres recusa a ambiguidade em vez de escolher — com
154
+ -- razão.
155
+ v_meta := migration._deep_set(v_meta, ARRAY['migration', 'unresolved'],
156
+ coalesce((SELECT jsonb_agg(ref) FROM jsonb_array_elements(v_unres) AS ref
157
+ WHERE ref ->> 'column' = ANY (a.optional_refs)), '[]'::jsonb));
158
+ v_unres := coalesce((SELECT jsonb_agg(ref) FROM jsonb_array_elements(v_unres) AS ref
159
+ WHERE NOT (ref ->> 'column' = ANY (a.optional_refs))), '[]'::jsonb);
160
+ END IF;
161
+
162
+ IF jsonb_array_length(v_unres) > 0 THEN
163
+ RETURN migration._quarantine(l.id, p_batch, 'fk', 'unresolved reference(s): ' || v_unres::text, p_row);
164
+ END IF;
165
+
166
+ -- destination id (ADR 0007): the source uuid, else the id minted on the first offer
167
+ v_target_id := coalesce(l.target_id, p_source_uuid, gen_random_uuid());
168
+ v_meta := jsonb_set(v_meta, '{migration}', coalesce(v_meta -> 'migration', '{}'::jsonb) || jsonb_build_object(
169
+ 'source_project', b.source_project, 'source_table', p_source_table, 'source_numeric_id', p_source_numeric_id,
170
+ 'source_uuid', p_source_uuid, 'batch_id', p_batch), true);
171
+ v_row := v_row || jsonb_build_object('metadata', v_meta);
172
+ SELECT coalesce(jsonb_object_agg(c, v_row -> c), '{}'::jsonb) INTO v_fin FROM unnest(a.financial_columns) c WHERE v_row ? c;
173
+
174
+ -- As duas travas do corte. Em regime de espelho elas ficam de fora, e é a única
175
+ -- diferença de comportamento deste arquivo:
176
+ --
177
+ -- valor financeiro o V1 é a fonte da verdade enquanto o V2 não escreveu;
178
+ -- recusar a atualização deixaria o V2 com o preço velho de
179
+ -- uma venda que foi corrigida ontem, em silêncio.
180
+ -- linha apagada quem apaga no V2 durante o espelho é o retire_row, e ele
181
+ -- marca o ledger como `retired`. Uma linha ainda
182
+ -- `migrated` cujo destino sumiu foi apagada por fora — e
183
+ -- recriá-la é justamente refletir o V1, que ainda a tem.
184
+ IF l.status = 'migrated' AND NOT v_mirror THEN
185
+ IF cardinality(a.financial_columns) > 0 THEN
186
+ v_dest := migration._destination_values(v_target_table, l.target_id, a.financial_columns);
187
+ IF v_dest IS NULL THEN
188
+ RETURN migration._quarantine(l.id, p_batch, 'state', 'target row no longer exists in the destination (deleted after migration); not re-created', p_row);
189
+ END IF;
190
+ FOREACH v_col IN ARRAY a.financial_columns LOOP
191
+ IF (v_row ? v_col) AND round(coalesce((v_row ->> v_col)::numeric, 0), 2) IS DISTINCT FROM round(coalesce((v_dest ->> v_col)::numeric, 0), 2) THEN
192
+ RETURN migration._quarantine(l.id, p_batch, 'value',
193
+ format('financial column %s: destination %s, incoming %s — an existing financial value is never overwritten', v_col, coalesce(v_dest ->> v_col, 'null'), coalesce(v_row ->> v_col, 'null')), p_row);
194
+ END IF;
195
+ END LOOP;
196
+ ELSIF migration._destination_values(v_target_table, l.target_id, ARRAY['id']) IS NULL THEN
197
+ RETURN migration._quarantine(l.id, p_batch, 'state', 'target row no longer exists in the destination (deleted after migration); not re-created', p_row);
198
+ END IF;
199
+ END IF;
200
+
201
+ -- delegate; any error is a state quarantine, never a partial write
202
+ BEGIN
203
+ -- Dispatch by name rather than by a CASE arm per writer. The CASE meant every
204
+ -- new destination writer had to re-emit this whole function, and a row naming
205
+ -- a writer the CASE did not list fell through to NULL — silently, which is
206
+ -- the failure mode this schema exists to prevent. The allowlist's CHECK is
207
+ -- the vocabulary, `migration` is definer-only, and the name is verified to
208
+ -- exist before it is called, so an allowlist row naming a writer nobody built
209
+ -- quarantines with that sentence instead of vanishing.
210
+ IF to_regprocedure(format('migration.upsert_%s(uuid,uuid,jsonb,jsonb)', a.writer)) IS NULL THEN
211
+ RETURN migration._quarantine(l.id, p_batch, 'state',
212
+ format('the allowlist names writer %L for %s, and migration.upsert_%s does not exist', a.writer, p_source_table, a.writer),
213
+ p_row, v_target_table);
214
+ END IF;
215
+ -- %I on the whole identifier, not on the suffix: format('upsert_%I', 'order')
216
+ -- yields upsert_"order", which is not the function's name.
217
+ EXECUTE format('SELECT migration.%I($1, $2, $3, $4)', 'upsert_' || a.writer)
218
+ INTO v_res USING b.tenant_id, v_target_id, v_row, a.writer_args;
219
+ EXCEPTION WHEN OTHERS THEN
220
+ v_res := jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', format('%s: %s', SQLSTATE, SQLERRM));
221
+ END;
222
+ v_status := coalesce(v_res ->> 'status', 'quarantined');
223
+
224
+ IF v_status = 'quarantined' THEN
225
+ RETURN migration._quarantine(l.id, p_batch, coalesce(v_res ->> 'kind', 'state'), coalesce(v_res ->> 'reason', 'writer refused'), p_row);
226
+ END IF;
227
+ IF v_status = 'skipped' THEN
228
+ UPDATE migration.ledger SET status = 'skipped', reason = v_res ->> 'reason', checksum = v_checksum, cursor = p_cursor, batch_id = p_batch,
229
+ target_id = coalesce((v_res ->> 'target_id')::uuid, target_id) WHERE id = l.id;
230
+ PERFORM migration._count(p_batch, 'skipped');
231
+ RETURN jsonb_build_object('status', 'skipped', 'reason', v_res ->> 'reason', 'target_table', v_target_table, 'target_id', v_res ->> 'target_id', 'ledger_id', l.id);
232
+ END IF;
233
+
234
+ v_target_id := coalesce((v_res ->> 'target_id')::uuid, v_target_id);
235
+ UPDATE migration.ledger
236
+ SET status = 'migrated', target_table = v_target_table, target_id = v_target_id, checksum = v_checksum, cursor = p_cursor,
237
+ batch_id = p_batch, reason = v_res ->> 'reason', financial = nullif(v_fin, '{}'::jsonb)
238
+ WHERE id = l.id;
239
+ UPDATE migration.quarantine SET resolved_at = now(), resolution = 'row migrated on a later offer' WHERE ledger_id = l.id AND resolved_at IS NULL;
240
+
241
+ -- split rows (a Venda's invoice): one more ledger row per extra destination
242
+ FOR x IN SELECT value FROM jsonb_array_elements(coalesce(v_res -> 'extra', '[]'::jsonb)) LOOP
243
+ -- Mesma correção da sondagem de irmã, pelo mesmo motivo: com
244
+ -- IS NOT DISTINCT FROM nenhum dos dois índices da ledger é usado, e esta
245
+ -- busca acontece uma vez por destino extra de cada Venda.
246
+ IF p_source_numeric_id IS NOT NULL THEN
247
+ SELECT id INTO v_xid FROM migration.ledger
248
+ WHERE source_project = b.source_project AND source_table = p_source_table
249
+ AND source_numeric_id = p_source_numeric_id
250
+ AND target_table = x ->> 'target_table';
251
+ ELSE
252
+ SELECT id INTO v_xid FROM migration.ledger
253
+ WHERE source_project = b.source_project AND source_table = p_source_table
254
+ AND source_numeric_id IS NULL AND source_uuid = p_source_uuid
255
+ AND target_table = x ->> 'target_table';
256
+ END IF;
257
+ IF v_xid IS NULL THEN
258
+ INSERT INTO migration.ledger (source_project, source_table, source_numeric_id, source_uuid, tenant_id, target_table, target_id, checksum, cursor, batch_id, status, reason, financial)
259
+ VALUES (b.source_project, p_source_table, p_source_numeric_id, p_source_uuid, b.tenant_id, x ->> 'target_table', (x ->> 'target_id')::uuid,
260
+ v_checksum, p_cursor, p_batch, x ->> 'status', x ->> 'reason', nullif(x -> 'financial', 'null'::jsonb));
261
+ ELSE
262
+ UPDATE migration.ledger SET target_id = (x ->> 'target_id')::uuid, checksum = v_checksum, cursor = p_cursor, batch_id = p_batch,
263
+ status = x ->> 'status', reason = x ->> 'reason', financial = coalesce(nullif(x -> 'financial', 'null'::jsonb), financial) WHERE id = v_xid;
264
+ END IF;
265
+ END LOOP;
266
+
267
+ INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
268
+ VALUES (b.tenant_id, NULL, 'migration.upsert', v_target_table, v_target_id::text,
269
+ jsonb_build_object('batch_id', p_batch, 'source_project', b.source_project, 'source_table', p_source_table,
270
+ 'source_numeric_id', p_source_numeric_id, 'source_uuid', p_source_uuid, 'reason', v_res ->> 'reason'));
271
+ PERFORM migration._count(p_batch, 'migrated');
272
+ RETURN jsonb_build_object('status', 'migrated', 'reason', v_res ->> 'reason', 'target_table', v_target_table, 'target_id', v_target_id,
273
+ 'ledger_id', l.id, 'merged', coalesce((v_res ->> 'merged')::boolean, false), 'extra', coalesce(v_res -> 'extra', '[]'::jsonb));
274
+ END $function$
275
+
276
+ ;
@@ -0,0 +1,110 @@
1
+ -- O desligado também precisa atravessar
2
+ --
3
+ -- `app_config()` filtrava `AND t.status = 'active' AND (expires_at IS NULL OR
4
+ -- expires_at > now())`. Faz sentido para um cliente que lê "ausente =
5
+ -- desligado" — e é exatamente o cliente que não podemos ter.
6
+ --
7
+ -- O shell resolve as ativações de forma ADITIVA: uma linha liga, uma linha
8
+ -- `disabled` desliga, e um plugin que o banco nunca mencionou mantém o padrão
9
+ -- do manifesto. Com o filtro, desligar um plugin o faz SUMIR da resposta — e
10
+ -- sumir quer dizer "use o padrão", que é ligado. Desligar ligava.
11
+ --
12
+ -- O vencimento tem a mesma forma e é pior, porque acontece sozinho: no dia em
13
+ -- que a cortesia vence a linha some e o plugin volta ligado para sempre.
14
+ --
15
+ -- Então a linha atravessa sempre, e o `status` que ela carrega é o EFETIVO —
16
+ -- uma cortesia vencida chega como `disabled`, com o `expires_at` junto para
17
+ -- quem quiser explicar por quê.
18
+ --
19
+ -- (Escrito sobre a versão da 008, que é a vigente. Uma primeira redação deste
20
+ -- arquivo nasceu de uma cópia mais antiga, de um pool que ainda tinha o
21
+ -- esquema `platform`, e trouxe de volta `platform.staff_role` e o aplicativo
22
+ -- da casa — que a 002 já tinha removido da espinha. Num cluster novo isso é
23
+ -- `schema "platform" does not exist`, e foi o que o encadeamento do zero
24
+ -- acusou. Reescrever função a partir de cópia velha é como se perde o que
25
+ -- alguém tirou de propósito.)
26
+ --
27
+ -- Idempotente: substitui uma função.
28
+
29
+ CREATE OR REPLACE FUNCTION public.app_config() RETURNS jsonb
30
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
31
+ SET search_path TO ''
32
+ AS $$
33
+ DECLARE
34
+ v_tenant uuid := app.current_tenant_id();
35
+ BEGIN
36
+ IF v_tenant IS NULL THEN
37
+ -- Fail closed and legibly: a caller with no resolvable tenant gets an empty
38
+ -- configuration, not somebody else's.
39
+ RETURN jsonb_build_object('tenant_id', NULL, 'plugins', '[]'::jsonb, 'nav', '[]'::jsonb, 'apps', '[]'::jsonb);
40
+ END IF;
41
+
42
+ RETURN jsonb_build_object(
43
+ 'tenant_id', v_tenant,
44
+ 'branding', public.tenant_branding(),
45
+ 'settings', public.tenant_settings(),
46
+ 'plugins', coalesce((
47
+ SELECT jsonb_agg(jsonb_build_object(
48
+ 'plugin', t.plugin_id,
49
+ 'facet', nullif(t.facet, ''),
50
+ -- A verdade EFETIVA, não a coluna crua: uma cortesia vencida
51
+ -- vale desligada. E ela é REPORTADA, não escondida — esconder
52
+ -- a linha faz o cliente cair no padrão do manifesto, que é
53
+ -- ligado, e aí o vencimento LIBERA em vez de travar.
54
+ 'status', CASE
55
+ WHEN t.expires_at IS NOT NULL AND t.expires_at <= now() THEN 'disabled'
56
+ ELSE t.status
57
+ END,
58
+ 'source', t.source,
59
+ 'config', t.config,
60
+ 'expires_at', t.expires_at
61
+ ) ORDER BY t.plugin_id, t.facet)
62
+ FROM app.tenant_plugins t
63
+ WHERE t.tenant_id = v_tenant
64
+ ), '[]'::jsonb),
65
+ -- The union across the caller's accounts. One row per app, however many of
66
+ -- the person's accounts run it.
67
+ 'apps', coalesce((
68
+ SELECT jsonb_agg(x.obj ORDER BY x.sort_name)
69
+ FROM (
70
+ SELECT a.name AS sort_name,
71
+ jsonb_build_object(
72
+ 'id', a.id,
73
+ 'name', a.name,
74
+ 'icon', a.icon,
75
+ 'accent_color', a.accent_color,
76
+ 'url', a.url,
77
+ -- Where clicking lands. Staying in the active account wins;
78
+ -- otherwise the first that runs it, ordered by name so the
79
+ -- answer does not move between two identical calls.
80
+ 'tenant_id', (array_agg(ta.tenant_id
81
+ ORDER BY (ta.tenant_id = v_tenant) DESC, tn.name))[1]
82
+ ) AS obj
83
+ FROM app.tenant_apps ta
84
+ JOIN app.apps a ON a.id = ta.app_id
85
+ JOIN public.tenants tn ON tn.id = ta.tenant_id
86
+ WHERE ta.status = 'active'
87
+ AND a.active
88
+ AND ta.tenant_id IN (
89
+ SELECT m.tenant_id FROM app.memberships m
90
+ WHERE m.user_id = auth.uid() AND m.active)
91
+ GROUP BY a.id, a.name, a.icon, a.accent_color, a.url
92
+ ) x
93
+ ), '[]'::jsonb),
94
+ 'nav', coalesce((
95
+ SELECT jsonb_agg(jsonb_build_object(
96
+ 'key', n.entry_key, 'hidden', n.hidden, 'label', n.label,
97
+ 'section', n.section, 'position', n.position
98
+ ) ORDER BY coalesce(n.position, 999), n.entry_key)
99
+ FROM app.tenant_nav n
100
+ WHERE n.tenant_id = v_tenant
101
+ ), '[]'::jsonb),
102
+ 'permissions', coalesce((
103
+ SELECT jsonb_agg(DISTINCT rp.permission)
104
+ FROM app.memberships m
105
+ JOIN app.role_permissions rp ON rp.role_id = m.role_id AND rp.tenant_id = m.tenant_id
106
+ WHERE m.tenant_id = v_tenant AND m.user_id = auth.uid() AND m.active
107
+ ), '[]'::jsonb),
108
+ 'generated_at', now()
109
+ );
110
+ END $$;
@@ -0,0 +1,128 @@
1
+ -- A unidade escolhida chega ao banco.
2
+ --
3
+ -- No modo franquia, quem está em Vila Ema tem que ver as contas de Vila Ema.
4
+ -- Não via: Financeiro → Contas a Pagar mostrava o tenant inteiro, e o mesmo
5
+ -- valia para a receber, extrato, caixa e tudo o mais.
6
+ --
7
+ -- ── Onde estava o corte ─────────────────────────────────────────────────────
8
+ --
9
+ -- Três peças, e só a do meio faltava.
10
+ --
11
+ -- 1. O APP já manda a unidade escolhida:
12
+ -- `packages/admin/src/data/client.ts:83` acrescenta `x-fayz-unit` a toda
13
+ -- requisição, e o cabeçalho está na lista de `vary` do dedupe.
14
+ --
15
+ -- 2. O BANCO nunca leu esse cabeçalho. `app.current_unit_id()` lia SÓ a
16
+ -- claim `unit_id` do JWT — que o GoTrue não emite. Resultado: a função
17
+ -- devolvia NULL, "todas as unidades", sempre.
18
+ --
19
+ -- 3. A RLS já sabia estreitar. `app.has_unit()` diz, na primeira linha:
20
+ -- app.current_unit_id() IS NULL OR p_unit = app.current_unit_id()
21
+ -- Com NULL eterno, o primeiro ramo era sempre verdadeiro.
22
+ --
23
+ -- O filtro do cliente (`packages/core/src/data/unit-scope.ts`) não cobre esse
24
+ -- buraco, e o cabeçalho do próprio arquivo explica por quê: ele é um
25
+ -- ESTREITAMENTO cosmético, aplicado só nos provedores genéricos de CRUD.
26
+ -- Qualquer plugin que escreva a própria consulta passa por fora — e o
27
+ -- financeiro inteiro escreve as dele. Medido: `resolveUnitScope` não aparece
28
+ -- uma vez em `plugins/plugin-financial/src/data/supabase.ts`.
29
+ --
30
+ -- Por isso o conserto é aqui e não lá. No banco ele vale para toda tela de
31
+ -- todo plugin, inclusive as que ainda não existem.
32
+ --
33
+ -- ── A função que faltava, e que o código já citava ──────────────────────────
34
+ --
35
+ -- `app.requested_tenant()` existe e lê `x-fayz-tenant`. O comentário DELA cita
36
+ -- uma vizinha que nunca foi escrita:
37
+ --
38
+ -- -- Engole a falha de parse pela mesma razão que requested_unit(): uma
39
+ -- -- função vizinha da RLS não é lugar de levantar exceção.
40
+ --
41
+ -- Esta migration escreve `app.requested_unit()`, na mesma forma, e liga
42
+ -- `app.current_unit_id()` a ela.
43
+ --
44
+ -- ── O cabeçalho ESCOLHE, nunca CONCEDE ──────────────────────────────────────
45
+ --
46
+ -- Mesma disciplina de `current_tenant_id()`: a unidade pedida só vale se for
47
+ -- uma que a pessoa já alcança (`app.user_unit_access`). Um cabeçalho apontando
48
+ -- para unidade que não é sua NEGA — devolve o sentinela nulo, que não casa com
49
+ -- unidade nenhuma — em vez de recuar para "todas". Recuar transformaria um
50
+ -- pedido explícito e errado numa resposta silenciosa sobre a rede inteira, que
51
+ -- é precisamente o que não pode acontecer numa franquia.
52
+ --
53
+ -- A verificação é uma sondagem na PK de `app.user_unit_access`
54
+ -- (tenant_id, user_id, unit_id).
55
+ --
56
+ -- Idempotente.
57
+
58
+ CREATE OR REPLACE FUNCTION app.requested_unit()
59
+ RETURNS uuid
60
+ LANGUAGE plpgsql
61
+ STABLE
62
+ SET search_path TO ''
63
+ AS $function$
64
+ DECLARE v text;
65
+ BEGIN
66
+ v := nullif(current_setting('request.headers', true), '')::json ->> 'x-fayz-unit';
67
+ RETURN nullif(v, '')::uuid;
68
+ -- Engole a falha de parse pela mesma razão que requested_tenant(): uma função
69
+ -- vizinha da RLS não é lugar de levantar exceção.
70
+ EXCEPTION WHEN OTHERS THEN RETURN NULL;
71
+ END $function$;
72
+
73
+ COMMENT ON FUNCTION app.requested_unit() IS
74
+ 'A unidade que o app escolheu, pelo cabeçalho x-fayz-unit. Par de '
75
+ 'app.requested_tenant(). Só diz o que foi PEDIDO — quem valida é '
76
+ 'app.current_unit_id().';
77
+
78
+ CREATE OR REPLACE FUNCTION app.current_unit_id()
79
+ RETURNS uuid
80
+ LANGUAGE plpgsql
81
+ STABLE SECURITY DEFINER
82
+ SET search_path TO ''
83
+ AS $function$
84
+ DECLARE
85
+ v_jwt jsonb := coalesce(auth.jwt(), '{}'::jsonb);
86
+ v_raw text;
87
+ v_unit uuid;
88
+ v_uid uuid;
89
+ BEGIN
90
+ -- ① a claim assinada, quando existir. Inalterado.
91
+ IF jsonb_exists(v_jwt, 'unit_id') THEN
92
+ v_raw := v_jwt ->> 'unit_id';
93
+ 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
94
+ RETURN '00000000-0000-0000-0000-000000000000'::uuid;
95
+ END IF;
96
+ RETURN v_raw::uuid;
97
+ END IF;
98
+
99
+ -- ② o cabeçalho do app. Ele ESCOLHE entre as unidades que a pessoa já
100
+ -- alcança; não concede nenhuma.
101
+ v_unit := app.requested_unit();
102
+ IF v_unit IS NULL THEN
103
+ RETURN NULL; -- "todas as unidades" — o seletor está em rede
104
+ END IF;
105
+
106
+ v_uid := auth.uid();
107
+ IF v_uid IS NULL THEN
108
+ RETURN '00000000-0000-0000-0000-000000000000'::uuid;
109
+ END IF;
110
+
111
+ IF EXISTS (SELECT 1 FROM app.user_unit_access a
112
+ WHERE a.tenant_id = app.current_tenant_id()
113
+ AND a.user_id = v_uid
114
+ AND a.unit_id = v_unit) THEN
115
+ RETURN v_unit;
116
+ END IF;
117
+
118
+ -- Pediu uma unidade que não é sua: nega. O sentinela não casa com unidade
119
+ -- nenhuma, então a tela vem vazia — que é a resposta certa, e muito melhor
120
+ -- que devolver a rede inteira.
121
+ RETURN '00000000-0000-0000-0000-000000000000'::uuid;
122
+ END $function$;
123
+
124
+ COMMENT ON FUNCTION app.current_unit_id() IS
125
+ 'A unidade em foco: a claim assinada, senão o cabeçalho x-fayz-unit '
126
+ 'validado contra app.user_unit_access. NULL = rede inteira. É o que faz '
127
+ 'app.has_unit() estreitar, e por tabela nenhuma da RLS precisar saber que '
128
+ 'existe um seletor de unidade na tela.';
@@ -0,0 +1,71 @@
1
+ -- Onde o profissional atende atravessa.
2
+ --
3
+ -- A agenda de Vila Ema mostrava **24 profissionais**; o V1 diz que ali
4
+ -- trabalham **5**. No Millenium eram **54** contra **11**. Uma agenda com
5
+ -- cinco vezes mais colunas do que gente não está compartilhando ninguém — ela
6
+ -- deixou de responder a pergunta que lhe fizeram.
7
+ --
8
+ -- ── Não é buraco de modelo: é de porte ──────────────────────────────────────
9
+ --
10
+ -- O V2 sabe dizer que alguém atende em várias unidades. A migration 020 já
11
+ -- escreveu a resposta inteira, em `public.v_bookable_people`:
12
+ --
13
+ -- CASE WHEN p.unit_id IS NOT NULL THEN ARRAY[p.unit_id]
14
+ -- ELSE (SELECT array_agg(g.subject_id) FROM app.resource_grants g
15
+ -- WHERE g.resource_type = 'people.person'
16
+ -- AND g.relation = 'available_in' …) END AS unit_ids
17
+ --
18
+ -- Uma unidade própria vai na coluna; várias vão em tuplas. E `NULL` é
19
+ -- "ninguém disse" — deliberadamente diferente de "em todas".
20
+ --
21
+ -- O V1 também sabe: `professionals.units` é um array jsonb, com média de 2,77
22
+ -- unidades por pessoa e máximo de 56.
23
+ --
24
+ -- O porte é que parou no meio. `upsert_person` faz a primeira metade — uma
25
+ -- unidade vira `unit_id`, várias deixam `unit_id` nulo — e nunca escreveu as
26
+ -- tuplas da segunda. Então quem trabalha em mais de uma loja chegava ao V2
27
+ -- como "ninguém disse", e "ninguém disse" aparece em toda unidade.
28
+ --
29
+ -- ── O que esta migration faz ────────────────────────────────────────────────
30
+ --
31
+ -- Lê `metadata.professional.v1_unit_refs` — que o de-para já grava em toda
32
+ -- pessoa vinda de `professionals` —, resolve cada id pelo ledger e escreve uma
33
+ -- tupla `available_in` por unidade.
34
+ --
35
+ -- Os ids do V1 vêm misturados, uns como número e outros como texto (`["2"]` ao
36
+ -- lado de `[520, 527]`). `jsonb_array_elements_text` normaliza os dois, e o
37
+ -- `~ '^\d+$'` descarta o que não for id sem derrubar a linha.
38
+ --
39
+ -- Quem tem UMA unidade fica como está: a coluna já é a resposta completa, e
40
+ -- escrever tupla para ela seria guardar a mesma informação em dois lugares.
41
+ --
42
+ -- Idempotente: `ON CONFLICT DO NOTHING` sobre a PK de `app.resource_grants`.
43
+
44
+ INSERT INTO app.resource_grants
45
+ (tenant_id, resource_type, resource_id, relation, subject_type, subject_id)
46
+ SELECT DISTINCT
47
+ p.tenant_id,
48
+ 'people.person',
49
+ p.id,
50
+ 'available_in',
51
+ 'unit',
52
+ lu.target_id
53
+ FROM public.people p
54
+ JOIN migration.ledger lp
55
+ ON lp.target_id = p.id
56
+ AND lp.source_table = 'professionals'
57
+ AND lp.target_table = 'public.people'
58
+ AND lp.status = 'migrated'
59
+ CROSS JOIN LATERAL jsonb_array_elements_text(p.metadata #> '{professional,v1_unit_refs}') AS ref(v1)
60
+ JOIN migration.ledger lu
61
+ ON lu.tenant_id = p.tenant_id
62
+ AND lu.source_table = 'companies'
63
+ AND lu.target_table = 'app.units'
64
+ AND lu.status = 'migrated'
65
+ AND lu.source_numeric_id = ref.v1::bigint
66
+ WHERE jsonb_typeof(p.metadata #> '{professional,v1_unit_refs}') = 'array'
67
+ AND ref.v1 ~ '^\d+$'
68
+ -- Só quem NÃO tem coluna. Ver o CASE da 020: a coluna vence a tupla, então
69
+ -- escrever tupla para quem já tem unidade não muda nada e polui o registro.
70
+ AND p.unit_id IS NULL
71
+ ON CONFLICT DO NOTHING;