@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,253 @@
1
+ -- Uma linha sem data pode ser estrutura, e estrutura não tem período.
2
+ --
3
+ -- `analytics_run` recorta todo read model pela janela do seletor:
4
+ --
5
+ -- competence_month >= :de AND competence_month <= :ate
6
+ --
7
+ -- Uma linha com a data NULA falha as duas comparações e desaparece. Para um
8
+ -- FATO isso é o certo — "em que mês isto aconteceu" não tem resposta, e somá-lo
9
+ -- em qualquer janela seria inventar uma.
10
+ --
11
+ -- Mas nem toda linha de um read model é um fato. A DRE precisa da ÁRVORE DO
12
+ -- PLANO DE CONTAS junto com os lançamentos: a conta que não teve movimento no
13
+ -- exercício aparece na tela do V1 com travessão em todos os meses, porque um
14
+ -- plano de contas é a declaração do que o negócio acompanha. A conta zerada é
15
+ -- uma resposta ("não houve"); a conta ausente é uma pergunta que ninguém fez.
16
+ --
17
+ -- Sem isto, o ramo 1 inteiro — as 45 contas de receita do tenant Pertinho do
18
+ -- Céu — sumia do relatório e a árvore começava no 2.
19
+ --
20
+ -- ── Por que uma coluna, e não mudar a regra para todos ───────────────────────
21
+ --
22
+ -- Fazer `(data IS NULL OR data >= :de)` valer para todo read model mudaria, em
23
+ -- silêncio, o que cada relatório do produto responde: uma linha sem data
24
+ -- passaria a contar em QUALQUER janela. Hoje ela não conta em nenhuma, e para a
25
+ -- maioria dos modelos isso é a resposta certa.
26
+ --
27
+ -- `include_undated` deixa a exceção onde ela se lê: na linha do registro, ao
28
+ -- lado do nome do modelo. O padrão é `false`, então nenhum relatório existente
29
+ -- muda de comportamento por causa deste arquivo.
30
+ --
31
+ -- Idempotente.
32
+
33
+ ALTER TABLE public.analytics_read_models
34
+ ADD COLUMN IF NOT EXISTS include_undated boolean NOT NULL DEFAULT false;
35
+
36
+ COMMENT ON COLUMN public.analytics_read_models.include_undated IS
37
+ 'Linha com a coluna de data NULA atravessa o filtro de período em vez de ser '
38
+ 'descartada. Para modelos que emitem ESTRUTURA junto com fato — a árvore do '
39
+ 'plano de contas na DRE, por exemplo, onde a conta sem movimento ainda '
40
+ 'precisa de linha. Padrão false: um fato sem data não pertence a janela '
41
+ 'nenhuma.';
42
+
43
+ -- ─────────────────────────────────────────────────────────────────────────────
44
+ -- O motor passa a consultar a coluna
45
+ -- ─────────────────────────────────────────────────────────────────────────────
46
+ --
47
+ -- Corpo extraído do próprio cluster com `pg_get_functiondef` e alterado em dois
48
+ -- pontos — os dois `format()` do recorte de data. Nada mais desta função muda.
49
+
50
+ CREATE OR REPLACE FUNCTION public.analytics_run(p_source text, p_tenant_id uuid DEFAULT NULL::uuid, p_from timestamp with time zone DEFAULT NULL::timestamp with time zone, p_to timestamp with time zone DEFAULT NULL::timestamp with time zone, p_dimensions jsonb DEFAULT '[]'::jsonb, p_measures jsonb DEFAULT '[]'::jsonb, p_filters jsonb DEFAULT '{}'::jsonb, p_search text DEFAULT NULL::text, p_search_columns text[] DEFAULT NULL::text[], p_sort text DEFAULT NULL::text, p_dir text DEFAULT 'desc'::text, p_limit integer DEFAULT 500, p_offset integer DEFAULT 0)
51
+ RETURNS jsonb
52
+ LANGUAGE plpgsql
53
+ STABLE
54
+ SET search_path TO 'public', 'extensions', 'pg_temp'
55
+ AS $function$
56
+ DECLARE
57
+ v_model public.analytics_read_models%ROWTYPE;
58
+ v_where text[] := ARRAY[]::text[];
59
+ v_where_sql text;
60
+ v_select text[] := ARRAY[]::text[];
61
+ v_group text[] := ARRAY[]::text[];
62
+ v_agg_only text[] := ARRAY[]::text[];
63
+ v_dim jsonb;
64
+ v_meas jsonb;
65
+ v_key text;
66
+ v_col text;
67
+ v_grain text;
68
+ v_agg text;
69
+ v_expr text;
70
+ v_filter_key text;
71
+ v_filter_val jsonb;
72
+ v_search_ors text[] := ARRAY[]::text[];
73
+ v_sort_sql text := '';
74
+ v_rows jsonb;
75
+ v_summary jsonb;
76
+ v_total bigint;
77
+ v_limit int := LEAST(GREATEST(COALESCE(p_limit, 500), 1), 5000);
78
+ v_offset int := GREATEST(COALESCE(p_offset, 0), 0);
79
+ BEGIN
80
+ -- 8.1 The source must be registered. Not "look like a read model" — registered.
81
+ SELECT * INTO v_model FROM public.analytics_read_models WHERE name = p_source;
82
+ IF NOT FOUND THEN
83
+ RAISE EXCEPTION 'analytics: % is not a registered read model', p_source USING ERRCODE = '42P01';
84
+ END IF;
85
+
86
+ -- 8.2 Tenant + date window. The date column comes from the registry, so a
87
+ -- caller cannot redirect the range onto some other timestamp.
88
+ IF p_tenant_id IS NOT NULL THEN
89
+ v_where := v_where || format('%I = %L', v_model.tenant_column, p_tenant_id);
90
+ END IF;
91
+ IF p_from IS NOT NULL THEN
92
+ v_where := v_where || CASE WHEN coalesce(v_model.include_undated, false)
93
+ THEN format('(%I IS NULL OR %I >= %L)', v_model.date_column, v_model.date_column, p_from)
94
+ ELSE format('%I >= %L', v_model.date_column, p_from) END;
95
+ END IF;
96
+ IF p_to IS NOT NULL THEN
97
+ v_where := v_where || CASE WHEN coalesce(v_model.include_undated, false)
98
+ THEN format('(%I IS NULL OR %I <= %L)', v_model.date_column, v_model.date_column, p_to)
99
+ ELSE format('%I <= %L', v_model.date_column, p_to) END;
100
+ END IF;
101
+
102
+ -- 8.3 Equality / IN filters.
103
+ FOR v_filter_key, v_filter_val IN SELECT key, value FROM jsonb_each(COALESCE(p_filters, '{}'::jsonb))
104
+ LOOP
105
+ CONTINUE WHEN v_filter_val IS NULL OR jsonb_typeof(v_filter_val) = 'null';
106
+ v_col := public.analytics_assert_column(p_source, v_filter_key);
107
+
108
+ IF jsonb_typeof(v_filter_val) = 'array' THEN
109
+ CONTINUE WHEN jsonb_array_length(v_filter_val) = 0;
110
+ v_where := v_where || format(
111
+ '%I::text = ANY (SELECT jsonb_array_elements_text(%L::jsonb))', v_col, v_filter_val);
112
+ ELSE
113
+ v_where := v_where || format('%I::text = %L', v_col, v_filter_val #>> '{}');
114
+ END IF;
115
+ END LOOP;
116
+
117
+ -- 8.4 Free-text search across the caller's declared text columns.
118
+ IF p_search IS NOT NULL AND length(btrim(p_search)) > 0 AND p_search_columns IS NOT NULL THEN
119
+ FOREACH v_col IN ARRAY p_search_columns LOOP
120
+ v_col := public.analytics_assert_column(p_source, v_col);
121
+ v_search_ors := v_search_ors || format('%I::text ILIKE %L', v_col, '%' || btrim(p_search) || '%');
122
+ END LOOP;
123
+ IF array_length(v_search_ors, 1) > 0 THEN
124
+ v_where := v_where || ('(' || array_to_string(v_search_ors, ' OR ') || ')');
125
+ END IF;
126
+ END IF;
127
+
128
+ v_where_sql := CASE WHEN array_length(v_where, 1) > 0
129
+ THEN ' WHERE ' || array_to_string(v_where, ' AND ')
130
+ ELSE '' END;
131
+
132
+ -- 8.5 Dimensions. A grain turns a timestamp into a bucket; without one the
133
+ -- raw column value is the group.
134
+ FOR v_dim IN SELECT * FROM jsonb_array_elements(COALESCE(p_dimensions, '[]'::jsonb))
135
+ LOOP
136
+ v_key := v_dim ->> 'key';
137
+ v_col := public.analytics_assert_column(p_source, v_key);
138
+ v_grain := v_dim ->> 'grain';
139
+
140
+ IF v_grain IS NULL THEN
141
+ v_expr := format('%I', v_col);
142
+ ELSIF v_grain IN ('hour','day','week','month','quarter','year') THEN
143
+ v_expr := format('date_trunc(%L, %I)', v_grain, v_col);
144
+ ELSE
145
+ RAISE EXCEPTION 'analytics: unsupported time grain %', v_grain USING ERRCODE = '22023';
146
+ END IF;
147
+
148
+ v_select := v_select || format('%s AS %I', v_expr, v_key);
149
+ v_group := v_group || v_expr;
150
+ END LOOP;
151
+
152
+ -- 8.6 Measures. Fixed aggregate vocabulary — anything else is rejected rather
153
+ -- than passed through, which is the difference between a parameter and an
154
+ -- injection point. 'ratio' is deliberately absent: it is derived on the
155
+ -- client from two real measures so ratio-of-sums never degrades into
156
+ -- avg-of-ratios.
157
+ FOR v_meas IN SELECT * FROM jsonb_array_elements(COALESCE(p_measures, '[]'::jsonb))
158
+ LOOP
159
+ v_key := v_meas ->> 'key';
160
+ IF v_key IS NULL OR v_key !~ '^[a-z_][a-z0-9_]*$' THEN
161
+ RAISE EXCEPTION 'analytics: malformed measure key %', v_key USING ERRCODE = '22023';
162
+ END IF;
163
+ v_agg := lower(COALESCE(v_meas ->> 'agg', 'sum'));
164
+
165
+ IF v_agg = 'count' THEN
166
+ v_expr := 'count(*)';
167
+ ELSE
168
+ v_col := public.analytics_assert_column(p_source, COALESCE(v_meas ->> 'column', v_key));
169
+ v_expr := CASE v_agg
170
+ WHEN 'sum' THEN format('sum(%I)', v_col)
171
+ WHEN 'avg' THEN format('avg(%I)', v_col)
172
+ WHEN 'min' THEN format('min(%I)', v_col)
173
+ WHEN 'max' THEN format('max(%I)', v_col)
174
+ WHEN 'count_distinct' THEN format('count(DISTINCT %I)', v_col)
175
+ ELSE NULL
176
+ END;
177
+ IF v_expr IS NULL THEN
178
+ RAISE EXCEPTION 'analytics: unsupported aggregate %', v_agg USING ERRCODE = '22023';
179
+ END IF;
180
+ END IF;
181
+
182
+ v_select := v_select || format('%s AS %I', v_expr, v_key);
183
+ v_agg_only := v_agg_only || format('%s AS %I', v_expr, v_key);
184
+ END LOOP;
185
+
186
+ -- 8.7 No measures ⇒ a raw detail listing (the classic tabular report).
187
+ IF array_length(v_agg_only, 1) IS NULL AND array_length(v_group, 1) IS NULL THEN
188
+ v_select := ARRAY['*'];
189
+ END IF;
190
+
191
+ -- 8.8 Sort. Must be one of the things actually selected — an alias, not an
192
+ -- arbitrary expression.
193
+ IF p_sort IS NOT NULL AND p_sort ~ '^[a-z_][a-z0-9_]*$' THEN
194
+ IF EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(p_measures,'[]'::jsonb)) m WHERE m->>'key' = p_sort)
195
+ OR EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(p_dimensions,'[]'::jsonb)) d WHERE d->>'key' = p_sort)
196
+ OR (array_length(v_agg_only,1) IS NULL AND array_length(v_group,1) IS NULL)
197
+ THEN
198
+ IF array_length(v_agg_only,1) IS NULL AND array_length(v_group,1) IS NULL THEN
199
+ PERFORM public.analytics_assert_column(p_source, p_sort);
200
+ END IF;
201
+ v_sort_sql := format(' ORDER BY %I %s NULLS LAST',
202
+ p_sort,
203
+ CASE WHEN lower(COALESCE(p_dir,'desc')) = 'asc' THEN 'ASC' ELSE 'DESC' END);
204
+ END IF;
205
+ END IF;
206
+
207
+ -- 8.9 Page of rows.
208
+ EXECUTE format(
209
+ 'SELECT COALESCE(jsonb_agg(t), ''[]''::jsonb) FROM (SELECT %s FROM public.%I%s%s%s LIMIT %s OFFSET %s) t',
210
+ array_to_string(v_select, ', '),
211
+ p_source,
212
+ v_where_sql,
213
+ CASE WHEN array_length(v_group, 1) > 0
214
+ THEN ' GROUP BY ' || array_to_string(v_group, ', ') ELSE '' END,
215
+ v_sort_sql,
216
+ v_limit,
217
+ v_offset
218
+ ) INTO v_rows;
219
+
220
+ -- 8.10 Row count for pagination. This must count what the MAIN query returns,
221
+ -- not what it reads: grouping ⇒ number of groups; aggregating with no
222
+ -- dimensions ⇒ exactly one row, however many rows fed it; otherwise the
223
+ -- underlying rows. Counting reads here made a 1-row KPI claim two pages.
224
+ IF array_length(v_group, 1) > 0 THEN
225
+ EXECUTE format(
226
+ 'SELECT count(*) FROM (SELECT %s FROM public.%I%s GROUP BY %s) c',
227
+ array_to_string(v_group, ', '), p_source, v_where_sql, array_to_string(v_group, ', ')
228
+ ) INTO v_total;
229
+ ELSIF array_length(v_agg_only, 1) > 0 THEN
230
+ v_total := 1;
231
+ ELSE
232
+ EXECUTE format('SELECT count(*) FROM public.%I%s', p_source, v_where_sql) INTO v_total;
233
+ END IF;
234
+
235
+ -- 8.11 The summary: identical filters, identical measures, no group-by. This
236
+ -- is the KPI headline AND the report's total row — one number, one place.
237
+ IF array_length(v_agg_only, 1) > 0 THEN
238
+ EXECUTE format(
239
+ 'SELECT to_jsonb(s) FROM (SELECT %s FROM public.%I%s) s',
240
+ array_to_string(v_agg_only, ', '),
241
+ p_source,
242
+ v_where_sql
243
+ ) INTO v_summary;
244
+ END IF;
245
+
246
+ RETURN jsonb_build_object(
247
+ 'rows', COALESCE(v_rows, '[]'::jsonb),
248
+ 'total', COALESCE(v_total, 0),
249
+ 'summary', COALESCE(v_summary, '{}'::jsonb)
250
+ );
251
+ END;
252
+ $function$
253
+ ;
@@ -0,0 +1,303 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 063_o_anexo_ja_registrado_recebe_os_bytes.sql — o motor de bytes aprende a
3
+ -- ADOTAR um registro, em vez de sempre criar um.
4
+ --
5
+ -- A 054 desenhou o transporte de bytes para as duas espécies que existiam: o
6
+ -- retrato do cadastro e a foto da batida. Nas duas, o registro em
7
+ -- `public.documents` **nasce do próprio transporte** — não havia linha antes, e
8
+ -- `attach_asset` cunha o id a partir do caminho de destino.
9
+ --
10
+ -- A 062 (o recibo da conta) quebra essa suposição, e quebra do jeito certo: o
11
+ -- REGISTRO do anexo atravessa como LINHA, pelo `upsert_document`, com o id que
12
+ -- ele tinha no V1 — e fica marcado `storage_provider = 'v1-pending'` esperando
13
+ -- os bytes. Medido em 19/09/2026 no Pertinho do Céu: **1.192 anexos em 854
14
+ -- contas**, todos registrados, nenhum com arquivo.
15
+ --
16
+ -- Oferecer um desses ao `attach_asset` de hoje criaria a SEGUNDA linha de
17
+ -- `documents` para o mesmo anexo: o ledger não teria `target_id` (é a primeira
18
+ -- oferta daquele objeto de storage), então ele cunharia um id novo. O anexo
19
+ -- apareceria duas vezes na conta, um com bytes e outro sem, e o `v_person_files`
20
+ -- da LGPD contaria dois arquivos onde há um.
21
+ --
22
+ -- ── O que muda ─────────────────────────────────────────────────────────────
23
+ --
24
+ -- Uma coluna no contrato do backlog: `document_id`. Preenchida, quer dizer "o
25
+ -- registro já existe, é este — só ponha os bytes nele". Nula, é o que sempre
26
+ -- foi: o transporte cria o registro.
27
+ --
28
+ -- É a diferença entre as duas origens de um arquivo no porte, e ela é real:
29
+ --
30
+ -- avatar, punch_photo o V1 guarda o caminho DENTRO da linha da pessoa ou da
31
+ -- batida. Não há linha de arquivo para adotar.
32
+ -- attachment o V1 tem uma TABELA de anexos, e ela atravessa como
33
+ -- qualquer outra tabela, pelo de-para.
34
+ --
35
+ -- ── O bucket ───────────────────────────────────────────────────────────────
36
+ --
37
+ -- Privado. É comprovante de pagamento — boleto, nota, print de transferência —
38
+ -- e a régua é a mesma da foto de ponto: o que basta um caminho para ver não
39
+ -- devia ser um documento financeiro de uma empresa.
40
+ --
41
+ -- Idempotente e replay-safe.
42
+ -- ---------------------------------------------------------------------------
43
+
44
+ -- ─────────────────────────────────────────────────────────────────────────
45
+ -- §1 O bucket
46
+ -- ─────────────────────────────────────────────────────────────────────────
47
+
48
+ INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
49
+ VALUES ('attachments', 'attachments', false, 26214400, NULL)
50
+ ON CONFLICT (id) DO UPDATE SET
51
+ public = EXCLUDED.public,
52
+ file_size_limit = EXCLUDED.file_size_limit;
53
+
54
+ DROP POLICY IF EXISTS core_attachments_read ON storage.objects;
55
+ DROP POLICY IF EXISTS core_attachments_insert ON storage.objects;
56
+ DROP POLICY IF EXISTS core_attachments_update ON storage.objects;
57
+
58
+ -- Sem `anon` e sem DELETE: comprovante de pagamento não se lê deslogado e não
59
+ -- se apaga por clique. Remoção definitiva passa pelo `service_role`, que é ato
60
+ -- deliberado — a mesma régua da foto de ponto (plugin-workforce 001).
61
+ CREATE POLICY core_attachments_read ON storage.objects
62
+ FOR SELECT TO authenticated
63
+ USING (
64
+ bucket_id = 'attachments'
65
+ AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)
66
+ );
67
+
68
+ CREATE POLICY core_attachments_insert ON storage.objects
69
+ FOR INSERT TO authenticated
70
+ WITH CHECK (
71
+ bucket_id = 'attachments'
72
+ AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)
73
+ );
74
+
75
+ CREATE POLICY core_attachments_update ON storage.objects
76
+ FOR UPDATE TO authenticated
77
+ USING (
78
+ bucket_id = 'attachments'
79
+ AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)
80
+ )
81
+ WITH CHECK (
82
+ bucket_id = 'attachments'
83
+ AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)
84
+ );
85
+
86
+ -- ─────────────────────────────────────────────────────────────────────────
87
+ -- §2 O backlog ganha `document_id`
88
+ --
89
+ -- `asset_backlog` muda de forma, então é DROP e não REPLACE — o Postgres não
90
+ -- troca o tipo de retorno de uma função no lugar.
91
+ -- ─────────────────────────────────────────────────────────────────────────
92
+
93
+ CREATE OR REPLACE VIEW migration.v_asset_backlog_attachment AS
94
+ SELECT d.tenant_id,
95
+ d.subject_type,
96
+ d.subject_id,
97
+ d.person_id,
98
+ d.storage_path AS source_path,
99
+ -- O bucket de anexos do V1 é público, mas a descida vai pela chave de
100
+ -- serviço do mesmo jeito: uma URL pública montada aqui dependeria do
101
+ -- host do V1, que tem DOIS (o do projeto e o domínio próprio) e nenhum
102
+ -- deles é coisa que o destino deva saber.
103
+ NULL::text AS source_url,
104
+ -- Nomeado pelo REGISTRO, não pelo caminho de origem. O caminho do V1
105
+ -- carrega o uuid da fatura do V1 e um epoch; o do V2 é derivável do id
106
+ -- do documento, que é o que a tela já tem na mão.
107
+ d.tenant_id::text || '/' || d.id::text
108
+ || lower(coalesce(substring(coalesce(d.file_name, '') from '\.[A-Za-z0-9]{2,5}$'), '')) AS target_path,
109
+ coalesce(nullif(btrim(d.title), ''), d.file_name) AS title,
110
+ d.id AS document_id
111
+ FROM public.documents d
112
+ -- `v1-pending` é a marca que a 062 deixa: o registro chegou, o arquivo não.
113
+ -- Escrever os bytes troca o provider e apaga a linha desta lista.
114
+ WHERE d.storage_provider = 'v1-pending'
115
+ AND coalesce(btrim(d.storage_path), '') <> '';
116
+
117
+ COMMENT ON VIEW migration.v_asset_backlog_attachment IS
118
+ 'Anexos cujo REGISTRO atravessou (062) e cujo arquivo ainda está no V1 (063). A condição é o próprio `storage_provider`: gravar os bytes apaga a linha daqui.';
119
+
120
+ DROP FUNCTION IF EXISTS migration.asset_backlog(uuid, text);
121
+
122
+ CREATE FUNCTION migration.asset_backlog(p_tenant uuid, p_kind text DEFAULT NULL)
123
+ RETURNS TABLE (
124
+ kind text, source_bucket text, target_bucket text,
125
+ tenant_id uuid, subject_type text, subject_id uuid, person_id uuid,
126
+ source_path text, source_url text, target_path text, title text,
127
+ -- Preenchido = o registro já existe e deve ser ADOTADO. Nulo = o transporte
128
+ -- cria o registro, que é o caso do avatar e da foto de ponto.
129
+ document_id uuid)
130
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
131
+ AS $$
132
+ DECLARE k migration.asset_kinds%ROWTYPE;
133
+ BEGIN
134
+ FOR k IN SELECT * FROM migration.asset_kinds a
135
+ WHERE p_kind IS NULL OR a.kind = p_kind
136
+ ORDER BY a.kind
137
+ LOOP
138
+ -- Espécie declarada cuja view ainda não existe é o caso do plugin
139
+ -- desinstalado, não um erro.
140
+ IF to_regclass('migration.' || quote_ident(k.backlog_view)) IS NULL THEN
141
+ CONTINUE;
142
+ END IF;
143
+ -- `document_id` é opcional na view: as de antes da 063 não a têm, e
144
+ -- reemitir as duas para acrescentar uma coluna nula seria churn. O
145
+ -- `to_jsonb(v)` lê a linha inteira e devolve NULL para a chave ausente.
146
+ RETURN QUERY EXECUTE format(
147
+ 'SELECT %L::text, %L::text, %L::text, v.tenant_id, v.subject_type, v.subject_id,
148
+ v.person_id, v.source_path, v.source_url, v.target_path, v.title,
149
+ (to_jsonb(v) ->> ''document_id'')::uuid
150
+ FROM migration.%I v
151
+ WHERE v.tenant_id = $1
152
+ AND coalesce(btrim(v.source_path), '''') <> ''''',
153
+ k.kind, k.source_bucket, k.target_bucket, k.backlog_view)
154
+ USING p_tenant;
155
+ END LOOP;
156
+ END $$;
157
+
158
+ COMMENT ON FUNCTION migration.asset_backlog(uuid, text) IS
159
+ 'O que falta de BYTES neste tenant, por espécie (054, 063). `document_id` preenchido = o registro já existe e deve ser adotado em vez de criado.';
160
+
161
+ GRANT EXECUTE ON FUNCTION migration.asset_backlog(uuid, text) TO service_role;
162
+
163
+ -- ─────────────────────────────────────────────────────────────────────────
164
+ -- §3 `attach_asset` adota o registro quando ele vem nomeado
165
+ -- ─────────────────────────────────────────────────────────────────────────
166
+
167
+ CREATE OR REPLACE FUNCTION migration._attach_asset_impl(
168
+ p_batch uuid, p_kind text, p_source_path text, p_row jsonb)
169
+ RETURNS jsonb
170
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
171
+ AS $$
172
+ DECLARE
173
+ b migration.batches%ROWTYPE;
174
+ k migration.asset_kinds%ROWTYPE;
175
+ l migration.ledger%ROWTYPE;
176
+ v_source_table text;
177
+ v_source_uuid uuid;
178
+ v_checksum text := p_row ->> 'checksum';
179
+ v_doc uuid;
180
+ v_person uuid := nullif(p_row ->> 'person_id', '')::uuid;
181
+ v_adotado uuid := nullif(p_row ->> 'document_id', '')::uuid;
182
+ BEGIN
183
+ SELECT * INTO b FROM migration.batches WHERE id = p_batch;
184
+ IF NOT FOUND THEN RAISE EXCEPTION 'attach_asset: lote % desconhecido', p_batch USING ERRCODE = 'P0002'; END IF;
185
+ IF b.status <> 'running' THEN
186
+ RAISE EXCEPTION 'attach_asset: o lote % está % (só um lote em execução aceita arquivo)', p_batch, b.status USING ERRCODE = '55000';
187
+ END IF;
188
+ PERFORM migration._assert_fence(b.tenant_id);
189
+
190
+ SELECT * INTO k FROM migration.asset_kinds WHERE kind = p_kind;
191
+ IF NOT FOUND THEN
192
+ RAISE EXCEPTION 'attach_asset: espécie % não está em migration.asset_kinds', p_kind USING ERRCODE = '22023';
193
+ END IF;
194
+ IF v_checksum IS NULL OR p_row ->> 'target_path' IS NULL THEN
195
+ RAISE EXCEPTION 'attach_asset: preciso de checksum e target_path' USING ERRCODE = '22023';
196
+ END IF;
197
+
198
+ v_source_table := 'storage:' || k.source_bucket;
199
+ v_source_uuid := md5(v_source_table || ':' || p_source_path)::uuid;
200
+
201
+ SELECT * INTO l FROM migration.ledger
202
+ WHERE source_project = b.source_project AND source_table = v_source_table
203
+ AND source_uuid = v_source_uuid AND coalesce(target_table, '') = 'public.documents'
204
+ FOR UPDATE;
205
+ IF NOT FOUND THEN
206
+ INSERT INTO migration.ledger (source_project, source_table, source_uuid, tenant_id,
207
+ target_table, checksum, cursor, batch_id, status)
208
+ VALUES (b.source_project, v_source_table, v_source_uuid, b.tenant_id,
209
+ 'public.documents', v_checksum, p_source_path, p_batch, 'pending')
210
+ RETURNING * INTO l;
211
+ ELSIF l.tenant_id <> b.tenant_id THEN
212
+ RETURN migration._quarantine(l.id, p_batch, 'tenant',
213
+ format('o objeto %s já é do tenant %s; este lote migra o %s', p_source_path, l.tenant_id, b.tenant_id), p_row);
214
+ ELSIF l.status = 'migrated' AND l.checksum = v_checksum THEN
215
+ PERFORM migration._count(p_batch, 'skipped');
216
+ RETURN jsonb_build_object('status', 'skipped', 'reason', 'bytes iguais',
217
+ 'target_table', 'public.documents', 'target_id', l.target_id, 'ledger_id', l.id);
218
+ END IF;
219
+
220
+ -- A ordem é a decisão da 063. O registro ADOTADO vem primeiro: quando o
221
+ -- backlog nomeia um `document_id`, aquela linha é o anexo, e cunhar outra
222
+ -- deixaria o mesmo arquivo duas vezes na conta — um com bytes, outro sem.
223
+ v_doc := coalesce(v_adotado, l.target_id, md5('document:' || p_kind || ':' || (p_row ->> 'target_path'))::uuid);
224
+
225
+ IF v_adotado IS NOT NULL AND NOT EXISTS (
226
+ SELECT 1 FROM public.documents d WHERE d.id = v_adotado AND d.tenant_id = b.tenant_id) THEN
227
+ RETURN migration._quarantine(l.id, p_batch, 'fk',
228
+ format('o registro %s não existe neste tenant — o backlog está apontando para um documento que sumiu', v_adotado), p_row);
229
+ END IF;
230
+
231
+ INSERT INTO public.documents (
232
+ id, tenant_id, kind, person_id, subject_type, subject_id, title, status,
233
+ file_url, file_name, file_size, mime_type,
234
+ storage_provider, storage_bucket, storage_path, checksum, metadata)
235
+ VALUES (
236
+ v_doc, b.tenant_id, p_kind, v_person,
237
+ p_row ->> 'subject_type', nullif(p_row ->> 'subject_id', '')::uuid,
238
+ p_row ->> 'title', 'active',
239
+ p_row ->> 'public_url', p_row ->> 'file_name',
240
+ nullif(p_row ->> 'file_size', '')::integer, p_row ->> 'mime_type',
241
+ 'supabase', k.target_bucket, p_row ->> 'target_path', v_checksum,
242
+ jsonb_build_object('migration', jsonb_build_object(
243
+ 'source_project', b.source_project, 'source_bucket', k.source_bucket,
244
+ 'source_path', p_source_path, 'batch_id', p_batch)))
245
+ ON CONFLICT (id) DO UPDATE SET
246
+ person_id = coalesce(EXCLUDED.person_id, public.documents.person_id),
247
+ -- Num registro adotado, quem sabe o sujeito e o nome é a linha que já está
248
+ -- lá: ela veio do de-para. O que o transporte tem a dizer é onde os bytes
249
+ -- foram parar.
250
+ subject_type = coalesce(public.documents.subject_type, EXCLUDED.subject_type),
251
+ subject_id = coalesce(public.documents.subject_id, EXCLUDED.subject_id),
252
+ title = coalesce(public.documents.title, EXCLUDED.title),
253
+ file_url = EXCLUDED.file_url,
254
+ file_name = coalesce(public.documents.file_name, EXCLUDED.file_name),
255
+ file_size = EXCLUDED.file_size,
256
+ mime_type = coalesce(EXCLUDED.mime_type, public.documents.mime_type),
257
+ storage_provider = EXCLUDED.storage_provider,
258
+ storage_bucket = EXCLUDED.storage_bucket,
259
+ storage_path = EXCLUDED.storage_path,
260
+ checksum = EXCLUDED.checksum,
261
+ is_active = true,
262
+ metadata = migration._merge_meta(public.documents.metadata, EXCLUDED.metadata),
263
+ updated_at = now();
264
+
265
+ IF k.pointer_fn IS NOT NULL THEN
266
+ EXECUTE format('SELECT %s($1, $2, $3)', k.pointer_fn)
267
+ USING b.tenant_id, v_doc, p_row || jsonb_build_object('bucket', k.target_bucket);
268
+ END IF;
269
+
270
+ UPDATE migration.ledger
271
+ SET status = 'migrated', target_id = v_doc, checksum = v_checksum,
272
+ cursor = p_source_path, batch_id = p_batch, reason = NULL
273
+ WHERE id = l.id;
274
+ PERFORM migration._count(p_batch, 'migrated');
275
+
276
+ RETURN jsonb_build_object('status', 'migrated', 'target_table', 'public.documents',
277
+ 'target_id', v_doc, 'ledger_id', l.id);
278
+ END $$;
279
+
280
+ -- ─────────────────────────────────────────────────────────────────────────
281
+ -- §4 A espécie
282
+ --
283
+ -- Sem `pointer_fn`: o ponteiro do anexo é o próprio `public.documents`, e o
284
+ -- `attach_asset` já grava bucket, caminho e provider nele. Não há segunda
285
+ -- tabela para avisar — é o caso que a 054 previu quando deixou a coluna
286
+ -- anulável.
287
+ -- ─────────────────────────────────────────────────────────────────────────
288
+
289
+ SELECT migration.register_asset_kind(
290
+ 'attachment', 'bill-attachments', 'attachments',
291
+ 'v_asset_backlog_attachment', NULL,
292
+ 'O recibo da conta. O REGISTRO atravessa como linha pelo de-para (062) e fica com storage_provider = ''v1-pending''; aqui vêm os bytes, e o registro é ADOTADO em vez de recriado.');
293
+
294
+
295
+ -- ── quem pode executar ────────────────────────────────────────────────────
296
+ --
297
+ -- Função nova nasce executável por PUBLIC, e `anon`/`authenticated` herdam
298
+ -- dali. Estas são de PORTE: SECURITY DEFINER, escrevem dados de qualquer
299
+ -- inquilino. Só o service_role, que é quem roda a onda.
300
+ REVOKE ALL ON FUNCTION migration.asset_backlog(uuid, text) FROM PUBLIC, anon, authenticated;
301
+ GRANT EXECUTE ON FUNCTION migration.asset_backlog(uuid, text) TO service_role;
302
+ REVOKE ALL ON FUNCTION migration._attach_asset_impl(uuid, text, text, jsonb) FROM PUBLIC, anon, authenticated;
303
+ GRANT EXECUTE ON FUNCTION migration._attach_asset_impl(uuid, text, text, jsonb) TO service_role;