@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,32 @@
1
+ -- Quem é profissional continua agendável, mesmo sendo também funcionário.
2
+ --
3
+ -- O reparo da 075 desmarcou `is_bookable` para toda pessoa com linha de ledger
4
+ -- vinda de `employees`, `clients` ou `contacts`. A condição parecia certa e
5
+ -- tinha um furo: **uma pessoa pode ter MAIS DE UMA linha no ledger.**
6
+ --
7
+ -- O V1 tem a mesma pessoa em duas tabelas quando ela atende E é registrada
8
+ -- como funcionária — o que é comum numa clínica: a profissional que também
9
+ -- está na folha. Medido em 20/09/2026, tenant Espaço Facial: 20 pessoas com
10
+ -- duas origens, e **18 profissionais desmarcados** por causa da linha de
11
+ -- funcionário delas.
12
+ --
13
+ -- O efeito era visível na tela: a agenda do Treinamento mostrava 5 colunas
14
+ -- onde o V1 lista 14, e as que faltavam eram exatamente essas.
15
+ --
16
+ -- ── A regra que faltava dizer ───────────────────────────────────────────────
17
+ --
18
+ -- Entre duas origens, quem manda sobre "aparece na agenda" é a que diz SIM.
19
+ -- Ser profissional é o que torna alguém agendável; estar na folha não
20
+ -- desfaz isso. A ausência de uma regra de precedência fez a última linha lida
21
+ -- vencer, que é o pior critério possível — ele depende da ordem do porte.
22
+ --
23
+ -- Idempotente.
24
+
25
+ UPDATE public.people p
26
+ SET is_bookable = true
27
+ WHERE p.tenant_id IS NOT NULL
28
+ AND NOT p.is_bookable
29
+ AND EXISTS (SELECT 1 FROM migration.ledger l
30
+ WHERE l.target_id = p.id
31
+ AND l.target_table = 'public.people'
32
+ AND l.source_table = 'professionals');
@@ -0,0 +1,60 @@
1
+ -- Quem atende em várias não cabe numa coluna.
2
+ --
3
+ -- A 074 escreveu as tuplas `available_in` só para quem estava com
4
+ -- `people.unit_id` nulo. Ficou pela metade, e a metade que faltou é a que a
5
+ -- view não perdoa. `public.v_bookable_people` diz, na 020:
6
+ --
7
+ -- CASE WHEN p.unit_id IS NOT NULL THEN ARRAY[p.unit_id]
8
+ -- ELSE (as tuplas) END
9
+ --
10
+ -- A coluna VENCE a lista. Então quem o V1 registra em três unidades e chegou
11
+ -- ao V2 com uma na coluna aparece em uma só — e some da agenda das outras
12
+ -- duas. As tuplas que a 074 escrevesse para essa pessoa nunca seriam lidas.
13
+ --
14
+ -- Medido em 20/09/2026: 8 profissionais nessa situação. Na tela, a agenda de
15
+ -- Resende mostrava 3 colunas onde o V1 lista 6, e a de Maricá 3 onde lista 5.
16
+ --
17
+ -- ── O conserto ──────────────────────────────────────────────────────────────
18
+ --
19
+ -- Para quem o V1 lista em MAIS DE UMA unidade: a coluna é limpa e todas as
20
+ -- unidades viram tuplas. É o desenho da 020 aplicado até o fim — coluna para
21
+ -- quem tem uma, lista para quem tem várias.
22
+ --
23
+ -- Quem tem uma só fica como está: a coluna já é a resposta completa, e mexer
24
+ -- nela seria trocar de lugar a mesma informação.
25
+ --
26
+ -- Idempotente.
27
+
28
+ -- 1. As tuplas de TODAS as unidades de quem tem várias — inclusive a que já
29
+ -- estava na coluna, que sem isto se perderia no passo 2.
30
+ INSERT INTO app.resource_grants
31
+ (tenant_id, resource_type, resource_id, relation, subject_type, subject_id)
32
+ SELECT DISTINCT p.tenant_id, 'people.person', p.id, 'available_in', 'unit', lu.target_id
33
+ FROM public.people p
34
+ JOIN migration.ledger lp
35
+ ON lp.target_id = p.id AND lp.source_table = 'professionals'
36
+ AND lp.target_table = 'public.people' AND lp.status = 'migrated'
37
+ CROSS JOIN LATERAL jsonb_array_elements_text(p.metadata #> '{professional,v1_unit_refs}') AS ref(v1)
38
+ JOIN migration.ledger lu
39
+ ON lu.tenant_id = p.tenant_id AND lu.source_table = 'companies'
40
+ AND lu.target_table = 'app.units' AND lu.status = 'migrated'
41
+ AND lu.source_numeric_id = ref.v1::bigint
42
+ WHERE jsonb_typeof(p.metadata #> '{professional,v1_unit_refs}') = 'array'
43
+ AND jsonb_array_length(p.metadata #> '{professional,v1_unit_refs}') > 1
44
+ AND ref.v1 ~ '^\d+$'
45
+ ON CONFLICT DO NOTHING;
46
+
47
+ -- 2. E a coluna sai do caminho, para a lista ser lida.
48
+ UPDATE public.people p
49
+ SET unit_id = NULL
50
+ WHERE p.unit_id IS NOT NULL
51
+ AND jsonb_typeof(p.metadata #> '{professional,v1_unit_refs}') = 'array'
52
+ AND jsonb_array_length(p.metadata #> '{professional,v1_unit_refs}') > 1
53
+ AND EXISTS (SELECT 1 FROM migration.ledger l
54
+ WHERE l.target_id = p.id AND l.source_table = 'professionals'
55
+ AND l.target_table = 'public.people')
56
+ -- Só se as tuplas existirem de fato: limpar a coluna sem ter a lista
57
+ -- transformaria "atende em três" em "ninguém disse", que aparece em todas.
58
+ AND EXISTS (SELECT 1 FROM app.resource_grants g
59
+ WHERE g.tenant_id = p.tenant_id AND g.resource_type = 'people.person'
60
+ AND g.resource_id = p.id AND g.relation = 'available_in');
@@ -0,0 +1,47 @@
1
+ -- A unidade do profissional vem de onde ele atende.
2
+ --
3
+ -- Depois da 080 a agenda ainda não batia: Maricá mostrava 3 colunas onde o V1
4
+ -- lista 5. Os dois que faltavam estavam presos a OUTRA unidade.
5
+ --
6
+ -- Leticia Nogueira · V1 units ["600"] (Maricá)
7
+ -- · V2 people.unit_id = outra unidade
8
+ --
9
+ -- `people.unit_id` para quem veio de `professionals` não é preenchido a partir
10
+ -- de `units` — o de-para manda `units` para metadata, e a coluna chega por
11
+ -- outro caminho. E como `public.v_bookable_people` prefere a coluna à lista
12
+ -- (ver 020), essa unidade errada apaga a certa.
13
+ --
14
+ -- ── A regra ─────────────────────────────────────────────────────────────────
15
+ --
16
+ -- Onde o profissional atende é o que o V1 diz em `units`, e nada mais. Quando
17
+ -- essa lista tem exatamente UMA unidade, ela vai para a coluna — que é o
18
+ -- caminho barato e o que a 020 desenhou. A 080 já cuidou de quem tem várias.
19
+ --
20
+ -- Só toca em quem veio de `professionals` E tem a lista em metadata: sem a
21
+ -- lista não há o que afirmar, e sobrescrever a coluna com um palpite seria
22
+ -- trocar um erro por outro.
23
+ --
24
+ -- Idempotente.
25
+
26
+ UPDATE public.people p
27
+ SET unit_id = alvo.unidade
28
+ FROM (
29
+ SELECT p2.id,
30
+ lu.target_id AS unidade
31
+ FROM public.people p2
32
+ JOIN migration.ledger lp
33
+ ON lp.target_id = p2.id
34
+ AND lp.source_table = 'professionals'
35
+ AND lp.target_table = 'public.people'
36
+ JOIN migration.ledger lu
37
+ ON lu.tenant_id = p2.tenant_id
38
+ AND lu.source_table = 'companies'
39
+ AND lu.target_table = 'app.units'
40
+ AND lu.status = 'migrated'
41
+ AND lu.source_numeric_id = (p2.metadata #>> '{professional,v1_unit_refs,0}')::bigint
42
+ WHERE jsonb_typeof(p2.metadata #> '{professional,v1_unit_refs}') = 'array'
43
+ AND jsonb_array_length(p2.metadata #> '{professional,v1_unit_refs}') = 1
44
+ AND (p2.metadata #>> '{professional,v1_unit_refs,0}') ~ '^\d+$'
45
+ ) alvo
46
+ WHERE alvo.id = p.id
47
+ AND p.unit_id IS DISTINCT FROM alvo.unidade;
@@ -0,0 +1,89 @@
1
+ -- A ponte de clientes volta a existir.
2
+ --
3
+ -- O painel pedia `public.v_clients` e levava **404** em toda carga — a view não
4
+ -- existe. Quatro cartões de cliente ficavam vazios e o console acusava a falha
5
+ -- quatro vezes por visita.
6
+ --
7
+ -- Ela foi removida com razão: juntava `public.clients` com `saas_core.persons`,
8
+ -- e as duas deixaram de existir quando a pessoa virou `public.people`. O que
9
+ -- ficou para trás foi o CÓDIGO — `packages/core/src/data/archetype.ts:182`
10
+ -- mapeia `clients → v_clients`, e o painel do StudioControl a chama em quatro
11
+ -- lugares. Um mapa apontando para um lugar demolido.
12
+ --
13
+ -- Esta migration reconstrói a ponte sobre o que existe hoje, com as mesmas
14
+ -- colunas que o consumidor pede.
15
+ --
16
+ -- ── As três colunas derivadas ───────────────────────────────────────────────
17
+ --
18
+ -- `visits`, `total_spent` e `last_visit` não são campos de pessoa: são
19
+ -- consequência do histórico. Vêm de LATERAL correlacionado por `party_id`, e é
20
+ -- por isso que a migration cria também o índice — sem ele cada cliente pagaria
21
+ -- uma varredura dos 199 mil agendamentos, e o cartão de retenção (que filtra
22
+ -- `visits > 1`) levaria o painel junto.
23
+ --
24
+ -- `security_invoker` OBRIGATÓRIO: sem ele a view roda com os direitos do dono e
25
+ -- as políticas de `public.people` não são avaliadas — que é exatamente como
26
+ -- views vazaram entre tenants antes.
27
+ --
28
+ -- Idempotente.
29
+
30
+ CREATE INDEX IF NOT EXISTS appointments_party_idx
31
+ ON public.appointments (tenant_id, party_id)
32
+ WHERE party_id IS NOT NULL;
33
+
34
+ COMMENT ON INDEX public.appointments_party_idx IS
35
+ 'Quantas vezes esta pessoa veio. O caminho de v_clients.visits e de toda '
36
+ 'tela que conta o histórico de um cliente.';
37
+
38
+ DROP VIEW IF EXISTS public.v_clients;
39
+
40
+ CREATE VIEW public.v_clients
41
+ WITH (security_invoker = true)
42
+ AS
43
+ SELECT
44
+ p.id,
45
+ p.tenant_id,
46
+ p.name,
47
+ p.email,
48
+ p.phone,
49
+ p.document_number,
50
+ p.avatar_url,
51
+ p.date_of_birth,
52
+ p.notes,
53
+ p.is_active,
54
+ p.tags,
55
+ p.unit_id,
56
+ p.custom_fields ->> 'gender' AS gender,
57
+ p.metadata #>> '{crm,origin}' AS origin,
58
+ coalesce(h.visits, 0)::integer AS visits,
59
+ coalesce(g.total_spent, 0)::numeric(14,2) AS total_spent,
60
+ h.last_visit,
61
+ p.created_at,
62
+ p.updated_at
63
+ FROM public.people p
64
+ -- Correlacionado em `p.id`: só os agendamentos DESTA pessoa, pelo índice
65
+ -- acima. Um agregado sem correlação aqui seria refeito por cliente.
66
+ LEFT JOIN LATERAL (
67
+ SELECT count(*) AS visits, max(a.starts_at) AS last_visit
68
+ FROM public.appointments a
69
+ WHERE a.tenant_id = p.tenant_id
70
+ AND a.party_id = p.id
71
+ AND a.status = 'completed'
72
+ ) h ON true
73
+ LEFT JOIN LATERAL (
74
+ SELECT sum(o.total) AS total_spent
75
+ FROM public.orders o
76
+ WHERE o.tenant_id = p.tenant_id
77
+ AND o.party_id = p.id
78
+ AND o.status = 'completed'
79
+ ) g ON true
80
+ WHERE p.kind = 'customer'
81
+ AND p.merged_into_id IS NULL;
82
+
83
+ COMMENT ON VIEW public.v_clients IS
84
+ 'A pessoa como CLIENTE, com o que o histórico dela diz: quantas vezes veio, '
85
+ 'quanto gastou, quando foi a última. Ponte para o código que ainda fala o '
86
+ 'vocabulário antigo (archetype.ts mapeia clients → v_clients). O nome '
87
+ 'sobreviveu à tabela que o originou.';
88
+
89
+ GRANT SELECT ON public.v_clients TO authenticated, service_role;
@@ -0,0 +1,79 @@
1
+ -- A liberação pergunta ao índice antes de chamar a função
2
+ --
3
+ -- A 070 pôs dentro de `app.available_in_my_units` a sondagem por RECURSO: sem
4
+ -- tupla nomeando esta linha, devolve `true` sem montar CTE nenhum. A conta
5
+ -- ficou certa e o tempo não: a função é SECURITY DEFINER, e o PostgreSQL NUNCA
6
+ -- incorpora função de definidor no plano. Então cada linha paga a montagem de
7
+ -- um executor inteiro só para chegar naquele `true`.
8
+ --
9
+ -- Com 120.361 pessoas no Espaço Facial isso é o custo da tela de Leads inteira.
10
+ -- Medido, a mesma consulta com e sem esta migração:
11
+ --
12
+ -- lista de leads, unidade Millenium . . . 42.257 ms → 1.144 ms
13
+ -- lista de leads, rede . . . . . . . . . 47.498 ms → 5.894 ms
14
+ --
15
+ -- A sondagem sobe para a política, onde ela é expressão e não chamada: o
16
+ -- planejador a incorpora e ela vira uma varredura de índice no prefixo
17
+ -- (tenant_id, resource_type, resource_id) da PK de `app.resource_grants` —
18
+ -- tabela de 1.656 linhas. Quem não tem concessão alguma (118.9 mil das 120.4
19
+ -- mil pessoas) é liberado ali e a função definidora não chega a ser chamada.
20
+ --
21
+ -- `app.has_release_tuple` NÃO é SECURITY DEFINER, e é de propósito: é o que
22
+ -- permite incorporar. Ela lê `app.resource_grants` com os direitos de quem
23
+ -- pergunta, e a política de leitura daquela tabela é o próprio tenant — a
24
+ -- mesma linha que a função definidora enxergaria.
25
+ --
26
+ -- ── O que esta migração NÃO faz, e por quê ──────────────────────────────────
27
+ --
28
+ -- Uma primeira versão deste arquivo mexia em sete políticas de uma vez
29
+ -- (`people`, `products`, `services`, `price_table_items`, `product_variants`,
30
+ -- `service_packages`, `service_package_items`) e descobria o tipo do recurso
31
+ -- lendo a expressão da política com uma regex. Foi um erro, e vale escrever
32
+ -- qual: a expressão tem duas formas — `available_in_my_units('catalog.product',
33
+ -- …)` e `available_in_my_units((SELECT app.current_tenant_id()),
34
+ -- 'catalog.product', …)`. A regex casava só a primeira, e `regexp_replace`
35
+ -- devolve a ENTRADA INTEIRA quando não casa. O tipo do recurso virava o texto
36
+ -- completo da política antiga, `g.resource_type` nunca casava com nada, o
37
+ -- `NOT EXISTS` dava sempre verdadeiro e a linha era liberada para todo mundo.
38
+ -- Cinco asserções de `075_catalog/006_distribution_polarity` pegaram isso — o
39
+ -- "deny wins" parou de vencer.
40
+ --
41
+ -- Então: uma tabela só, a que foi medida, e o tipo escrito por extenso. Não há
42
+ -- texto de SQL sendo interpretado em lugar nenhum deste arquivo.
43
+ --
44
+ -- Idempotente.
45
+
46
+ CREATE OR REPLACE FUNCTION app.has_release_tuple(p_type text, p_id uuid)
47
+ RETURNS boolean
48
+ LANGUAGE sql
49
+ STABLE
50
+ SET search_path TO ''
51
+ AS $function$
52
+ SELECT EXISTS (
53
+ SELECT 1 FROM app.resource_grants g
54
+ WHERE g.tenant_id = (SELECT app.current_tenant_id())
55
+ AND g.resource_type = p_type
56
+ AND g.resource_id = p_id
57
+ AND g.relation IN ('available_in', 'unavailable_in')
58
+ );
59
+ $function$;
60
+
61
+ REVOKE ALL ON FUNCTION app.has_release_tuple(text, uuid) FROM PUBLIC, anon;
62
+ GRANT EXECUTE ON FUNCTION app.has_release_tuple(text, uuid) TO authenticated, service_role;
63
+
64
+ DO $$
65
+ BEGIN
66
+ IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'people_available_in') THEN
67
+ RAISE NOTICE 'sem people_available_in neste cluster, nada a fazer';
68
+ RETURN;
69
+ END IF;
70
+
71
+ ALTER POLICY people_available_in ON public.people USING (
72
+ (NOT (SELECT app.grants_exist('people.person', ARRAY['available_in', 'unavailable_in'])))
73
+ OR (unit_id IS NOT NULL)
74
+ -- A pergunta barata, incorporável, antes da cara. O ramo acrescentado é,
75
+ -- palavra por palavra, a segunda guarda da própria função.
76
+ OR (NOT app.has_release_tuple('people.person', people.id))
77
+ OR (SELECT app.available_in_my_units('people.person', people.id))
78
+ );
79
+ END $$;
@@ -0,0 +1,50 @@
1
+ -- Quem pode executar o que o porte criou
2
+ --
3
+ -- Função nova nasce `EXECUTE` para PUBLIC, e `anon` herda dali. A postura N15c
4
+ -- da bancada diz o que isso significa, e estava vermelha:
5
+ --
6
+ -- N15c: anon can execute nothing callable in schema app have: 9 want: 0
7
+ --
8
+ -- As que este ramo criou são auxiliares chamadas de
9
+ -- DENTRO de funções SECURITY DEFINER — resolvem identidade com os direitos do
10
+ -- dono e ninguém as chama por fora. A última, `app.has_release_tuple`, é chamada
11
+ -- de FORA: a 087 a pôs dentro de `people_available_in` justamente para o
12
+ -- planejador poder incorporá-la, e isso exige que quem lê a tabela possa
13
+ -- executá-la.
14
+ --
15
+ -- A lista é por extenso, e isso é a lição deste arquivo: uma primeira versão
16
+ -- varria `pg_proc` inteiro nos esquemas `app` e `migration` e revogava de
17
+ -- todos, devolvendo a `authenticated` só uma lista curta que eu supus ser
18
+ -- suficiente. Não era: `app.grants_exist` é citada em política e ficou sem
19
+ -- concessão, e 35 suítes caíram com `permission denied for function
20
+ -- grants_exist`. Revogar em massa é fácil de escrever e impossível de revisar.
21
+ -- Aqui só entram os nomes que este ramo trouxe.
22
+ --
23
+ -- Idempotente.
24
+
25
+ DO $$
26
+ DECLARE
27
+ v_fn text;
28
+ BEGIN
29
+ FOR v_fn IN
30
+ SELECT p.oid::regprocedure::text
31
+ FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
32
+ WHERE n.nspname = 'app'
33
+ AND p.proname IN (
34
+ '_current_tenant_id_raw', '_current_unit_id_raw', '_has_permission_raw',
35
+ '_has_unit_raw', '_owner_scoped_raw', '_unit_scoping_on_raw',
36
+ 'requested_unit')
37
+ LOOP
38
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM PUBLIC, anon, authenticated', v_fn);
39
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', v_fn);
40
+ END LOOP;
41
+
42
+ FOR v_fn IN
43
+ SELECT p.oid::regprocedure::text
44
+ FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
45
+ WHERE n.nspname = 'app' AND p.proname = 'has_release_tuple'
46
+ LOOP
47
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM PUBLIC, anon', v_fn);
48
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO authenticated, service_role', v_fn);
49
+ END LOOP;
50
+ END $$;
@@ -0,0 +1,86 @@
1
+ -- A memória de transação não serve para isto
2
+ --
3
+ -- A 080, a 081 e a 082 puseram o contexto da requisição numa GUC de transação
4
+ -- (`set_config(..., true)`) para não resolvê-lo uma vez por linha. A ideia era
5
+ -- boa e o ganho parecia real. A bancada mostrou, em quarenta e uma asserções e
6
+ -- por três caminhos diferentes, que a premissa não se sustenta: uma transação
7
+ -- NÃO é uma pergunta só.
8
+ --
9
+ -- Primeiro a identidade. A bancada troca `request.jwt.claims` entre asserções
10
+ -- — é o que ela precisa fazer para provar que a claim de outro inquilino não
11
+ -- resolve —, e a segunda pergunta recebia a resposta da primeira:
12
+ --
13
+ -- not ok 2 - a claim naming another tenant resolves to no tenant
14
+ -- have: 3fc8bcfd-923c-8f1e-72cf-c8d5dece7bbd want: NULL
15
+ --
16
+ -- Depois o dado. `UPDATE public.tenants SET unit_scoping_enabled = false` e,
17
+ -- na linha seguinte, `app.unit_scoping_on()` ainda dizia `true`. Vale para
18
+ -- permissão concedida e lida na mesma transação, e para configuração criada e
19
+ -- consultada em seguida — três suítes, três caminhos, todos realistas: uma RPC
20
+ -- que concede e lê é coisa comum.
21
+ --
22
+ -- Houve uma tentativa intermediária de salvar a ideia: digerir o pedido
23
+ -- (claims × cabeçalhos × papel) e invalidar a memória quando a digestão
24
+ -- mudasse. Resolvia a identidade e não resolvia o dado — a digestão resume o
25
+ -- PEDIDO, e o que muda é o BANCO. E mesmo para a identidade ficou incompleta:
26
+ -- `plugin-crm/002_the_lead_offer_cascades` continuou vermelha, porque quem
27
+ -- troca de identidade ali é uma função, por dentro, sem tocar em claim
28
+ -- nenhuma. Medido: com as envolucradoras memorizadas, a suíte aborta; sem
29
+ -- elas, passa inteira.
30
+ --
31
+ -- Então a memória sai. O que fica das três migrações é o que nelas era
32
+ -- separável e correto: a divisão entre a função pública e a `_raw`, que deixa
33
+ -- a resolução num lugar só.
34
+ --
35
+ -- E o desempenho não vinha daqui. Medido no Espaço Facial depois desta
36
+ -- migração — 273.770 negócios, 190.011 orçamentos, 120.361 pessoas:
37
+ --
38
+ -- lista de orçamentos . . . . . . . . . . 21 ms (índice da 029 do CRM)
39
+ -- quadro do funil, unidade . . . . . . . 443 ms (junção comum da 026)
40
+ -- lista de leads, unidade . . . . . . . 1.144 ms (`has_release_tuple`)
41
+ --
42
+ -- Nenhum desses números dependia de guardar resposta entre linhas.
43
+ --
44
+ -- Idempotente.
45
+
46
+ CREATE OR REPLACE FUNCTION app.has_permission(p_perm text, p_unit uuid DEFAULT NULL::uuid)
47
+ RETURNS boolean
48
+ LANGUAGE sql
49
+ STABLE SECURITY DEFINER
50
+ SET search_path TO ''
51
+ AS $function$ SELECT app._has_permission_raw(p_perm, p_unit) $function$;
52
+
53
+ CREATE OR REPLACE FUNCTION app.has_unit(p_unit uuid)
54
+ RETURNS boolean
55
+ LANGUAGE sql
56
+ STABLE SECURITY DEFINER
57
+ SET search_path TO ''
58
+ AS $function$ SELECT app._has_unit_raw(p_unit) $function$;
59
+
60
+ CREATE OR REPLACE FUNCTION app.owner_scoped(p_resource_type text)
61
+ RETURNS boolean
62
+ LANGUAGE sql
63
+ STABLE SECURITY DEFINER
64
+ SET search_path TO ''
65
+ AS $function$ SELECT app._owner_scoped_raw(p_resource_type) $function$;
66
+
67
+ CREATE OR REPLACE FUNCTION app.unit_scoping_on()
68
+ RETURNS boolean
69
+ LANGUAGE sql
70
+ STABLE SECURITY DEFINER
71
+ SET search_path TO ''
72
+ AS $function$ SELECT app._unit_scoping_on_raw() $function$;
73
+
74
+ CREATE OR REPLACE FUNCTION app.current_tenant_id()
75
+ RETURNS uuid
76
+ LANGUAGE sql
77
+ STABLE SECURITY DEFINER
78
+ SET search_path TO ''
79
+ AS $function$ SELECT app._current_tenant_id_raw() $function$;
80
+
81
+ CREATE OR REPLACE FUNCTION app.current_unit_id()
82
+ RETURNS uuid
83
+ LANGUAGE sql
84
+ STABLE SECURITY DEFINER
85
+ SET search_path TO ''
86
+ AS $function$ SELECT app._current_unit_id_raw() $function$;
@@ -0,0 +1,75 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 071_a_marca_tambem_conta_como_mudanca_de_configuracao.sql — o carimbo que o
3
+ -- shell consulta passa a enxergar as settings, e a chave do menu passa a ser a
4
+ -- rota (#280, ADR 0023).
5
+ --
6
+ -- ── §1 O carimbo é cego para metade do que ele carimba ─────────────────────
7
+ -- `app_config()` devolve quatro coisas: branding, settings, ativações e menu.
8
+ -- `tenant_config_version()` — o timestamp que o cliente consulta a cada minuto
9
+ -- para saber se vale reler — olha só DUAS delas: `app.tenant_plugins` e
10
+ -- `app.tenant_nav`.
11
+ --
12
+ -- O resultado é que trocar a logo, a cor, o fuso ou a moeda não chega em aba
13
+ -- nenhuma até alguém recarregar. E o defeito não aparece em teste, porque quem
14
+ -- troca a marca está sempre na tela que acabou de trocá-la: é a SEGUNDA aba,
15
+ -- ou o colega ao lado, que fica com o valor velho.
16
+ --
17
+ -- `tenants.updated_at` já sobe sozinha — `tenant_setting_set` e
18
+ -- `tenant_brand_patch` escrevem em `tenants.settings` e o trigger
19
+ -- `public.handle_updated_at()` carimba. Só faltava lerem juntos.
20
+ --
21
+ -- De passagem, o FULL JOIN sai. Ele cruzava as duas tabelas para depois
22
+ -- descartar quase tudo num max(): dezessete linhas de plugin vezes as linhas de
23
+ -- menu do mesmo tenant, por chamada, uma vez por minuto por aba aberta. Três
24
+ -- selects escalares dizem o mesmo e param no índice.
25
+ --
26
+ -- ── §2 A chave do menu é a ROTA ────────────────────────────────────────────
27
+ -- `app.tenant_nav.entry_key` foi comentada como "`<plugin>:<route>` como o
28
+ -- shell compõe" e o shell nunca compôs assim: ele casa por `item.route`. A
29
+ -- tabela está vazia (nenhum tenant compôs menu ainda), então a grafia ainda
30
+ -- pode ser escolhida em vez de migrada — e a rota é a escolha certa por uma
31
+ -- razão que não é de gosto:
32
+ --
33
+ -- Três dos quatro apps da frota esvaziam a navegação dos plugins com
34
+ -- `hideNav()` e declaram o rail em `FayzAppConfig.pages`. Essas linhas NÃO
35
+ -- TÊM plugin. `<plugin>:<route>` não consegue endereçar a maior parte do
36
+ -- menu que existe hoje; a rota endereça tudo, e é o que o usuário vê na URL.
37
+ --
38
+ -- Comentário, não constraint: a chave continua livre para uma entrada que um
39
+ -- dia não seja uma rota (um link externo, um grupo sem destino).
40
+ --
41
+ -- Idempotente e replay-safe. Nada de DDL destrutivo: uma função substituída e
42
+ -- dois comentários.
43
+ -- ---------------------------------------------------------------------------
44
+
45
+ -- ─────────────────────────────────────────────────────────────────────────
46
+ -- §1 O carimbo enxerga as settings
47
+ -- ─────────────────────────────────────────────────────────────────────────
48
+
49
+ CREATE OR REPLACE FUNCTION public.tenant_config_version(p_tenant uuid DEFAULT NULL::uuid)
50
+ RETURNS timestamptz
51
+ LANGUAGE sql
52
+ STABLE SECURITY DEFINER
53
+ SET search_path TO ''
54
+ AS $function$
55
+ WITH t AS (SELECT coalesce(p_tenant, app.current_tenant_id()) AS id)
56
+ SELECT greatest(
57
+ -- Quais módulos e quais partes deles estão ligados.
58
+ coalesce((SELECT max(tp.updated_at) FROM app.tenant_plugins tp, t WHERE tp.tenant_id = t.id), 'epoch'::timestamptz),
59
+ -- A composição do menu deste tenant.
60
+ coalesce((SELECT max(tn.updated_at) FROM app.tenant_nav tn, t WHERE tn.tenant_id = t.id), 'epoch'::timestamptz),
61
+ -- Marca, locale, fuso, moeda e toda chave registrada: tudo mora em
62
+ -- `tenants.settings`, e `app_config()` devolve os dois.
63
+ coalesce((SELECT te.updated_at FROM public.tenants te, t WHERE te.id = t.id), 'epoch'::timestamptz)
64
+ );
65
+ $function$;
66
+
67
+ COMMENT ON FUNCTION public.tenant_config_version(uuid) IS
68
+ 'Um timestamp que se move quando QUALQUER parte de app_config() se move — ativação, menu ou settings (incluindo a marca). O cliente consulta este número; só relê a configuração inteira quando ele anda.';
69
+
70
+ -- ─────────────────────────────────────────────────────────────────────────
71
+ -- §2 A chave do menu é a rota
72
+ -- ─────────────────────────────────────────────────────────────────────────
73
+
74
+ COMMENT ON COLUMN app.tenant_nav.entry_key IS
75
+ 'A chave estável da entrada: a ROTA (`/financial`, `/orders/new`), que é como o shell compõe e o que o usuário vê na URL. Não `<plugin>:<route>`: três dos quatro apps desenham o rail em `pages`, e essas linhas não têm plugin nenhum para nomear.';
@@ -0,0 +1,71 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 072_o_shell_tambem_e_configuracao_do_tenant.sql — o banco passa a saber COMO
3
+ -- o aplicativo se desenha, não só o que ele tem (#280, ADR 0023).
4
+ --
5
+ -- ── O que faltava ──────────────────────────────────────────────────────────
6
+ -- Depois da 150 o banco sabe QUAIS módulos a conta tem (`app.tenant_plugins`) e
7
+ -- QUAL menu ela compôs (`app.tenant_nav`). O que continuava morando só no
8
+ -- repositório do app é a moldura: rail ou barra no topo, abas ou sub-rail
9
+ -- dentro do módulo, moldura no conteúdo, barra inferior no telefone, e o modo
10
+ -- claro/escuro de partida.
11
+ --
12
+ -- São seis decisões que o `FayzAppConfig` de cada app fixa para TODOS os
13
+ -- inquilinos dele. Uma rede com dez lojas e um escritório que roda o mesmo
14
+ -- aplicativo não tem como pedir topbar no escritório e rail na loja sem um
15
+ -- segundo build — que é exatamente o custo que esta onda existe para matar.
16
+ --
17
+ -- ── Por que chaves, e não uma tabela ───────────────────────────────────────
18
+ -- A 150 já escreveu a regra ao decidir o que NÃO reconstruir: "`tenants.settings`
19
+ -- com o registro em `app.tenant_setting_keys` (076) já governa marca, locale e
20
+ -- toda outra setting com chave". Uma `app.tenant_shell` daria colunas tipadas e
21
+ -- um CHECK de verdade, e em troca duplicaria um mecanismo que já tem tipo,
22
+ -- validação, RPC de leitura e de escrita, permissão e — depois da 071 — o
23
+ -- carimbo de versão. Seis linhas de registro custam nada disso.
24
+ --
25
+ -- O vocabulário fechado, que numa tabela seria CHECK, vira a descrição da
26
+ -- chave: `app.setting_type_ok` valida o TIPO, e o shell ignora um valor que não
27
+ -- conhece em vez de quebrar. Para configuração de aparência, ignorar é a
28
+ -- degradação certa; recusar seria deixar a conta sem moldura nenhuma.
29
+ --
30
+ -- ── reader = core ──────────────────────────────────────────────────────────
31
+ -- Nenhum plugin lê isto: quem lê é a casca. `core` é o mesmo leitor de
32
+ -- `locale`, `timezone` e `branding.*`, que são da mesma natureza.
33
+ --
34
+ -- Idempotente: `app.seed_setting_keys` é upsert por chave.
35
+ -- ---------------------------------------------------------------------------
36
+
37
+ SELECT app.seed_setting_keys($$[
38
+ {
39
+ "key": "shell.layout", "type": "string", "default": null,
40
+ "reader": "core",
41
+ "description": "Onde o menu principal vive: 'sidebar' (rail à esquerda), 'topbar' (barra no topo) ou 'minimal' (sem menu). Nulo = o que o aplicativo escolheu."
42
+ },
43
+ {
44
+ "key": "shell.module_nav", "type": "string", "default": null,
45
+ "reader": "core",
46
+ "description": "Como as telas de um mesmo módulo se apresentam: 'tabs' (abas no cabeçalho) ou 'sidebar' (sub-itens no rail). Nulo = o que o aplicativo escolheu."
47
+ },
48
+ {
49
+ "key": "shell.content_frame", "type": "boolean", "default": null,
50
+ "reader": "core",
51
+ "description": "Se o conteúdo desenha a própria moldura (cartão sobre o fundo) ou encosta na borda. Nulo = o que o aplicativo escolheu."
52
+ },
53
+ {
54
+ "key": "shell.bottom_nav", "type": "boolean", "default": null,
55
+ "reader": "core",
56
+ "description": "Barra de abas no rodapé do telefone. Ela é DERIVADA do menu que já passou pelo papel e pelo plano, então desligar aqui tira a barra, nunca acrescenta um destino."
57
+ },
58
+ {
59
+ "key": "shell.nav_transition", "type": "string", "default": null,
60
+ "reader": "core",
61
+ "description": "A transição entre telas ('fade', 'slide', 'none'). Nulo = o que o aplicativo escolheu."
62
+ },
63
+ {
64
+ "key": "shell.theme_mode", "type": "string", "default": null,
65
+ "reader": "core",
66
+ "description": "O modo de partida: 'light', 'dark' ou 'system'. É o PADRÃO da conta — a escolha de cada pessoa continua vencendo, porque é dela."
67
+ }
68
+ ]$$::jsonb);
69
+
70
+ COMMENT ON TABLE app.tenant_setting_keys IS
71
+ 'O registro de toda setting com chave que o produto aceita: tipo, valor padrão, quem lê (core ou plugin:<id>) e o alcance. Os VALORES moram em tenants.settings; aqui mora o contrato. Uma chave que não está aqui é recusada por tenant_setting_set.';