@fayz-ai/db 0.18.2 → 0.20.1

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 (26) hide show
  1. package/migrations/136_a_baixa_do_espelho_e_do_porte_nao_do_v2.sql +204 -0
  2. package/migrations/137_a_retirada_e_um_canal_de_venda.sql +43 -0
  3. package/migrations/138_o_produto_tem_cor_e_a_conta_so_a_veste_se_pagar.sql +606 -0
  4. package/migrations/139_a_equipe_e_um_agrupamento_declarado.sql +50 -0
  5. package/migrations/140_a_unidade_se_escreve_pela_mesma_porta_que_se_le.sql +130 -0
  6. package/migrations/141_o_endereco_da_unidade_mora_na_tabela_de_enderecos.sql +203 -0
  7. package/migrations/142_a_coordenada_e_do_endereco_nao_da_unidade.sql +157 -0
  8. package/migrations/143_o_preset_para_de_nomear_telas_que_nao_existem.sql +29 -0
  9. package/migrations/144_marketing_e_um_botao_nao_uma_gaveta.sql +46 -0
  10. package/migrations/145_campanhas_e_templates_somem_em_toda_conta.sql +36 -0
  11. package/migrations/146_a_clinica_ve_as_proprias_analises.sql +31 -0
  12. package/migrations/147_tres_saloes_desligam_o_rh.sql +30 -0
  13. package/migrations/148_servicos_ganham_a_permissao_que_nunca_tiveram.sql +37 -0
  14. package/migrations/149_a_conta_do_fornecedor_nao_e_conta_da_casa.sql +131 -0
  15. package/migrations/150_todo_cadastro_tem_observacao_e_etiqueta.sql +62 -0
  16. package/migrations/151_a_parceria_e_uma_especie_de_pessoa.sql +35 -0
  17. package/migrations/152_a_profissao_e_a_especie_de_pet_sao_catalogos.sql +31 -0
  18. package/migrations/153_a_sala_nao_e_a_filial.sql +94 -0
  19. package/migrations/154_as_duas_tabelas_novas_dizem_de_quem_sao.sql +33 -0
  20. package/migrations/155_o_pacote_tem_observacao_e_etiqueta_e_diz_de_quem_e.sql +28 -0
  21. package/migrations/156_restaurante_nao_vende_servico.sql +44 -0
  22. package/migrations/157_o_que_a_porta_da_unidade_criou_tambem_se_revoga.sql +45 -0
  23. package/migrations/158_a_producao_e_do_restaurante_e_mora_no_estoque.sql +129 -0
  24. package/migrations/159_a_loja_tambem_confundia_o_que_vende_com_o_que_guarda.sql +98 -0
  25. package/migrations/161_vender_e_falar_com_quem_ainda_nao_comprou_sao_o_mesmo_assunto.sql +73 -0
  26. package/package.json +1 -1
@@ -0,0 +1,130 @@
1
+ -- ============================================================================
2
+ -- 140 — a unidade se escreve pela mesma porta por onde se lê
3
+ --
4
+ -- `v_units` é a ponte pública de `app.units`: a tela de unidades LÊ tudo por
5
+ -- ela — nome, tipo, telefone, cidade, código, lat/lng — porque `app.units`
6
+ -- guarda o endereço em `metadata` e o PostgREST não alcança o schema `app`.
7
+ --
8
+ -- Escrever, não. A view é auto-atualizável só nas colunas que são referência
9
+ -- simples (name, parent_id, is_active, metadata); `kind` é um
10
+ -- `coalesce(metadata->>'legacy_kind', kind)` e `is_headquarters` é um
11
+ -- `kind = 'hq'` — expressões, e o Postgres recusa:
12
+ --
13
+ -- cannot update column "kind" of view "v_units"
14
+ --
15
+ -- O CRUD genérico manda o formulário inteiro num UPDATE só, então TROCAR O
16
+ -- NOME de uma unidade falhava por causa do campo ao lado. E o mesmo valia para
17
+ -- telefone, cidade, código e coordenadas: escrever qualquer um deles pedia uma
18
+ -- RPC que nunca existiu (só `unit_create`, e só para criar).
19
+ --
20
+ -- O gatilho INSTEAD OF fecha o círculo: a view passa a aceitar a MESMA forma
21
+ -- que devolve, e a tradução para `app.units` + `metadata` mora no banco, ao
22
+ -- lado da tradução de leitura, em vez de virar um caso especial no cliente.
23
+ --
24
+ -- SEM `security definer`, de propósito: a função roda como quem chamou, e a RLS
25
+ -- de `app.units` (units.manage) continua sendo a cerca. A view só deixou de
26
+ -- mentir sobre o que aceita; ela não ganhou permissão nenhuma.
27
+ --
28
+ -- `kind` tem duas casas porque `app.units.kind` só admite hq|branch: 'region'
29
+ -- — que agrupa unidades e onde ninguém trabalha — mora em
30
+ -- `metadata->>'legacy_kind'`, e é isso que a leitura já coalescia.
31
+ -- ============================================================================
32
+
33
+ CREATE OR REPLACE FUNCTION app.trg_v_units_write() RETURNS trigger
34
+ LANGUAGE plpgsql
35
+ SET search_path TO ''
36
+ AS $$
37
+ DECLARE
38
+ -- As chaves de `metadata` que ESTA view governa. Apagadas antes da mesclagem
39
+ -- para que limpar um campo na tela apague a chave, em vez de gravar "" —
40
+ -- `metadata->>'city'` devolvendo string vazia é pior que devolvendo NULL.
41
+ k_managed constant text[] := ARRAY['email','phone','address','city','state','country',
42
+ 'postal_code','notes','code','latitude','longitude','created_by'];
43
+ v_meta jsonb;
44
+ v_overlay jsonb;
45
+ v_kind text;
46
+ v_legacy text;
47
+ v_tenant uuid;
48
+ v_id uuid;
49
+ BEGIN
50
+ IF TG_OP = 'DELETE' THEN
51
+ DELETE FROM app.units u WHERE u.id = OLD.id;
52
+ RETURN OLD;
53
+ END IF;
54
+
55
+ -- Num UPDATE parcial o Postgres preenche NEW com a linha antiga, então o que
56
+ -- ninguém tocou é reescrito igual. É por isso que a base é NEW.metadata: ela
57
+ -- já É a metadata antiga quando o cliente não mandou nenhuma.
58
+ v_meta := coalesce(NEW.metadata, '{}'::jsonb);
59
+
60
+ -- 'hq' não é um tipo que se escolhe na lista: é o interruptor "matriz". Um
61
+ -- valor fora de hq|branch (hoje 'region') não cabe na coluna e vai para
62
+ -- legacy_kind, que é de onde a leitura o tira.
63
+ v_legacy := nullif(btrim(coalesce(NEW.kind, '')), '');
64
+ IF coalesce(NEW.is_headquarters, false) OR v_legacy = 'hq' THEN
65
+ v_kind := 'hq';
66
+ ELSE
67
+ v_kind := 'branch';
68
+ END IF;
69
+ IF v_legacy IN ('hq', 'branch') OR v_legacy IS NULL THEN v_legacy := NULL; END IF;
70
+
71
+ v_overlay := jsonb_strip_nulls(jsonb_build_object(
72
+ 'email', nullif(btrim(coalesce(NEW.email, '')), ''),
73
+ 'phone', nullif(btrim(coalesce(NEW.phone, '')), ''),
74
+ 'address', nullif(btrim(coalesce(NEW.address, '')), ''),
75
+ 'city', nullif(btrim(coalesce(NEW.city, '')), ''),
76
+ 'state', nullif(btrim(coalesce(NEW.state, '')), ''),
77
+ 'country', nullif(btrim(coalesce(NEW.country, '')), ''),
78
+ 'postal_code', nullif(btrim(coalesce(NEW.postal_code, '')), ''),
79
+ 'notes', nullif(btrim(coalesce(NEW.notes, '')), ''),
80
+ 'code', nullif(btrim(coalesce(NEW.code, '')), ''),
81
+ 'latitude', CASE WHEN NEW.latitude IS NULL THEN NULL ELSE NEW.latitude::text END,
82
+ 'longitude', CASE WHEN NEW.longitude IS NULL THEN NULL ELSE NEW.longitude::text END,
83
+ 'created_by', CASE WHEN NEW.created_by IS NULL THEN NULL ELSE NEW.created_by::text END
84
+ ));
85
+
86
+ v_meta := (v_meta - k_managed) || v_overlay;
87
+
88
+ IF v_legacy IS NULL THEN v_meta := v_meta - 'legacy_kind';
89
+ ELSE v_meta := jsonb_set(v_meta, '{legacy_kind}', to_jsonb(v_legacy)); END IF;
90
+
91
+ IF NEW.tags IS NULL OR cardinality(NEW.tags) = 0 THEN v_meta := v_meta - 'tags';
92
+ ELSE v_meta := jsonb_set(v_meta, '{tags}', to_jsonb(NEW.tags)); END IF;
93
+
94
+ IF NEW.custom_fields IS NULL OR NEW.custom_fields = '{}'::jsonb THEN v_meta := v_meta - 'custom_fields';
95
+ ELSE v_meta := jsonb_set(v_meta, '{custom_fields}', NEW.custom_fields); END IF;
96
+
97
+ IF TG_OP = 'INSERT' THEN
98
+ v_tenant := coalesce(NEW.tenant_id, app.current_tenant_id());
99
+ INSERT INTO app.units (id, tenant_id, kind, name, parent_unit_id, active, metadata)
100
+ VALUES (coalesce(NEW.id, gen_random_uuid()), v_tenant, v_kind, btrim(NEW.name),
101
+ NEW.parent_id, coalesce(NEW.is_active, true), v_meta)
102
+ RETURNING id INTO v_id;
103
+ ELSE
104
+ UPDATE app.units u
105
+ SET kind = v_kind,
106
+ name = btrim(NEW.name),
107
+ parent_unit_id = NEW.parent_id,
108
+ active = coalesce(NEW.is_active, true),
109
+ metadata = v_meta,
110
+ updated_at = now()
111
+ WHERE u.id = OLD.id
112
+ RETURNING u.id INTO v_id;
113
+ IF v_id IS NULL THEN
114
+ RAISE EXCEPTION 'v_units: unit % not found or not yours to change', OLD.id USING ERRCODE = '42501';
115
+ END IF;
116
+ END IF;
117
+
118
+ -- O RETURNING do cliente lê a VIEW de novo; devolver NEW cru daria a ele os
119
+ -- valores que ele mandou, e não os que o banco guardou.
120
+ SELECT * INTO NEW FROM public.v_units WHERE id = v_id;
121
+ RETURN NEW;
122
+ END $$;
123
+
124
+ COMMENT ON FUNCTION app.trg_v_units_write() IS
125
+ 'Escreve v_units em app.units: kind fora de hq|branch vai para metadata.legacy_kind, o endereço vai para metadata (140).';
126
+
127
+ DROP TRIGGER IF EXISTS v_units_write ON public.v_units;
128
+ CREATE TRIGGER v_units_write
129
+ INSTEAD OF INSERT OR UPDATE OR DELETE ON public.v_units
130
+ FOR EACH ROW EXECUTE FUNCTION app.trg_v_units_write();
@@ -0,0 +1,203 @@
1
+ -- ============================================================================
2
+ -- 141 — o endereço da unidade mora na tabela de endereços
3
+ --
4
+ -- A unidade guardava o endereço em cinco chaves planas de `metadata`
5
+ -- (address, city, state, country, postal_code), e endereço já é uma TABELA:
6
+ -- `public.addresses`, a mesma da ficha da pessoa, que sempre aceitou
7
+ -- `owner_type = 'unit'` — havia inclusive linha gravada assim.
8
+ --
9
+ -- Duas verdades para a mesma coisa cobram o preço de sempre. Uma conta migrada
10
+ -- do V1 escreveu em `metadata.address` o objeto INTEIRO serializado, e a tela
11
+ -- de unidade mostrava, dentro de um input de uma linha:
12
+ --
13
+ -- {"city": "Rio de Janeiro", "state": "RJ", "number": "60", "street": ...}
14
+ --
15
+ -- Aqui a view passa a LER o endereço de `public.addresses` e o gatilho de
16
+ -- escrita (140) para de tocar naquelas cinco chaves. O formulário ganha o
17
+ -- mesmo bloco da pessoa — com CEP que consulta sozinho, número, complemento e
18
+ -- bairro —, limitado a UM: uma loja tem um endereço.
19
+ --
20
+ -- Uma unidade, um endereço: a linha nasce `kind = 'main'` e padrão, e
21
+ -- `addresses_one_default_idx` (UNIQUE em owner_type, owner_id, kind WHERE
22
+ -- is_default) é quem garante que não apareça um segundo.
23
+ -- ============================================================================
24
+
25
+ -- ── 1. o que estava em metadata vira linha ──────────────────────────────────
26
+ -- Dois formatos no mesmo campo: o objeto serializado que o porte do V1 deixou,
27
+ -- e a string simples de quem digitou na tela. `jsonb_typeof` separa os dois —
28
+ -- e um texto que não é JSON faria o cast estourar, então ele é tentado dentro
29
+ -- de um bloco que devolve NULL em vez de derrubar a migration.
30
+ CREATE OR REPLACE FUNCTION app.parse_legacy_address(p_raw text) RETURNS jsonb
31
+ LANGUAGE plpgsql IMMUTABLE
32
+ SET search_path TO ''
33
+ AS $$
34
+ DECLARE v jsonb;
35
+ BEGIN
36
+ IF p_raw IS NULL OR btrim(p_raw) = '' THEN RETURN NULL; END IF;
37
+ BEGIN v := p_raw::jsonb; EXCEPTION WHEN OTHERS THEN RETURN NULL; END;
38
+ IF jsonb_typeof(v) <> 'object' THEN RETURN NULL; END IF;
39
+ RETURN v;
40
+ END $$;
41
+
42
+ INSERT INTO public.addresses
43
+ (tenant_id, owner_type, owner_id, kind, postal_code, street, number, complement, district, city, state, country, is_default, metadata)
44
+ SELECT u.tenant_id, 'unit', u.id, 'main',
45
+ p.postal_code, p.street, p.number, p.complement, p.district, p.city, p.state,
46
+ coalesce(nullif(btrim(coalesce(u.metadata ->> 'country', '')), ''), 'BR'),
47
+ true,
48
+ jsonb_build_object('backfilled_from', 'app.units.metadata')
49
+ FROM app.units u
50
+ CROSS JOIN LATERAL (
51
+ SELECT j,
52
+ coalesce(nullif(btrim(coalesce(j ->> 'postal_code', u.metadata ->> 'postal_code', '')), ''), '') AS postal_code,
53
+ -- Sem objeto, o campo plano JÁ É a rua. Com objeto, a rua é a chave
54
+ -- `street` — e nunca o objeto inteiro, que é como 52 unidades
55
+ -- ganharam uma linha vazia na primeira volta desta migration.
56
+ coalesce(nullif(btrim(coalesce(j ->> 'street',
57
+ CASE WHEN j IS NULL THEN u.metadata ->> 'address' END, '')), ''), '') AS street,
58
+ nullif(btrim(coalesce(j ->> 'number', '')), '') AS number,
59
+ nullif(btrim(coalesce(j ->> 'complement', '')), '') AS complement,
60
+ nullif(btrim(coalesce(j ->> 'neighborhood', j ->> 'district', '')), '') AS district,
61
+ coalesce(nullif(btrim(coalesce(j ->> 'city', u.metadata ->> 'city', '')), ''), '') AS city,
62
+ coalesce(nullif(btrim(coalesce(j ->> 'state', u.metadata ->> 'state', '')), ''), '') AS state
63
+ FROM (SELECT app.parse_legacy_address(u.metadata ->> 'address') AS j) q
64
+ ) p
65
+ WHERE NOT EXISTS (
66
+ SELECT 1 FROM public.addresses a WHERE a.owner_type = 'unit' AND a.owner_id = u.id)
67
+ -- Só quem tem o que migrar. O porte do V1 deixou objetos com TODAS as
68
+ -- chaves nulas; uma linha vazia para eles seria endereço inventado.
69
+ AND (p.street <> '' OR p.city <> '');
70
+
71
+ -- ── 2. a view lê o endereço de onde ele mora ────────────────────────────────
72
+ CREATE OR REPLACE VIEW public.v_units WITH (security_invoker='true') AS
73
+ SELECT u.id,
74
+ u.tenant_id,
75
+ coalesce(u.metadata ->> 'legacy_kind', u.kind) AS kind,
76
+ u.name,
77
+ u.metadata ->> 'email' AS email,
78
+ u.metadata ->> 'phone' AS phone,
79
+ -- A linha da rua como uma lista a mostra: "Rua X, 60". O número é
80
+ -- coluna própria em `addresses` e não se perde ao voltar para cá.
81
+ nullif(btrim(coalesce(a.street, '') ||
82
+ CASE WHEN nullif(btrim(coalesce(a.number, '')), '') IS NULL
83
+ THEN '' ELSE ', ' || btrim(a.number) END), '') AS address,
84
+ a.city,
85
+ a.state,
86
+ coalesce(a.country, u.metadata ->> 'country', 'BR') AS country,
87
+ a.postal_code,
88
+ (u.kind = 'hq') AS is_headquarters,
89
+ u.active AS is_active,
90
+ coalesce((SELECT array_agg(t) FROM jsonb_array_elements_text(
91
+ CASE WHEN jsonb_typeof(u.metadata -> 'tags') = 'array'
92
+ THEN u.metadata -> 'tags' ELSE '[]'::jsonb END) t), '{}'::text[]) AS tags,
93
+ u.metadata ->> 'notes' AS notes,
94
+ u.metadata,
95
+ CASE WHEN jsonb_typeof(u.metadata -> 'custom_fields') = 'object'
96
+ THEN u.metadata -> 'custom_fields' ELSE '{}'::jsonb END AS custom_fields,
97
+ CASE WHEN u.metadata ->> 'created_by' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
98
+ THEN (u.metadata ->> 'created_by')::uuid END AS created_by,
99
+ u.created_at,
100
+ u.updated_at,
101
+ u.parent_unit_id AS parent_id,
102
+ u.metadata ->> 'code' AS code,
103
+ CASE WHEN u.metadata ->> 'latitude' ~ '^-?[0-9]+(\.[0-9]+)?$'
104
+ THEN (u.metadata ->> 'latitude')::numeric END AS latitude,
105
+ CASE WHEN u.metadata ->> 'longitude' ~ '^-?[0-9]+(\.[0-9]+)?$'
106
+ THEN (u.metadata ->> 'longitude')::numeric END AS longitude
107
+ FROM app.units u
108
+ -- LEFT: uma unidade sem endereço continua aparecendo na lista. Um JOIN
109
+ -- comum a faria sumir da tela por não ter CEP.
110
+ LEFT JOIN LATERAL (
111
+ SELECT x.* FROM public.addresses x
112
+ WHERE x.owner_type = 'unit' AND x.owner_id = u.id
113
+ ORDER BY x.is_default DESC, x.created_at
114
+ LIMIT 1
115
+ ) a ON true;
116
+
117
+ COMMENT ON VIEW public.v_units IS
118
+ 'Units in the shape the units screen reads (#214), com o endereço vindo de public.addresses (141).';
119
+
120
+ -- ── 3. quem escreve o endereço é a tabela de endereços ──────────────────────
121
+ CREATE OR REPLACE FUNCTION app.trg_v_units_write() RETURNS trigger
122
+ LANGUAGE plpgsql
123
+ SET search_path TO ''
124
+ AS $$
125
+ DECLARE
126
+ -- O endereço saiu desta lista (141): as colunas de endereço da view agora são
127
+ -- LEITURA de public.addresses, e aceitá-las aqui recriaria a segunda verdade.
128
+ -- Telefone, e-mail, código e coordenadas continuam sendo da unidade.
129
+ k_managed constant text[] := ARRAY['email','phone','notes','code','latitude','longitude','created_by'];
130
+ v_meta jsonb;
131
+ v_overlay jsonb;
132
+ v_kind text;
133
+ v_legacy text;
134
+ v_tenant uuid;
135
+ v_id uuid;
136
+ BEGIN
137
+ IF TG_OP = 'DELETE' THEN
138
+ DELETE FROM public.addresses a WHERE a.owner_type = 'unit' AND a.owner_id = OLD.id;
139
+ DELETE FROM app.units u WHERE u.id = OLD.id;
140
+ RETURN OLD;
141
+ END IF;
142
+
143
+ v_meta := coalesce(NEW.metadata, '{}'::jsonb);
144
+
145
+ v_legacy := nullif(btrim(coalesce(NEW.kind, '')), '');
146
+ IF coalesce(NEW.is_headquarters, false) OR v_legacy = 'hq' THEN
147
+ v_kind := 'hq';
148
+ ELSE
149
+ v_kind := 'branch';
150
+ END IF;
151
+ IF v_legacy IN ('hq', 'branch') OR v_legacy IS NULL THEN v_legacy := NULL; END IF;
152
+
153
+ v_overlay := jsonb_strip_nulls(jsonb_build_object(
154
+ 'email', nullif(btrim(coalesce(NEW.email, '')), ''),
155
+ 'phone', nullif(btrim(coalesce(NEW.phone, '')), ''),
156
+ 'notes', nullif(btrim(coalesce(NEW.notes, '')), ''),
157
+ 'code', nullif(btrim(coalesce(NEW.code, '')), ''),
158
+ 'latitude', CASE WHEN NEW.latitude IS NULL THEN NULL ELSE NEW.latitude::text END,
159
+ 'longitude', CASE WHEN NEW.longitude IS NULL THEN NULL ELSE NEW.longitude::text END,
160
+ 'created_by', CASE WHEN NEW.created_by IS NULL THEN NULL ELSE NEW.created_by::text END
161
+ ));
162
+
163
+ -- As cinco chaves velhas de endereço saem de vez: elas já viraram linha em
164
+ -- public.addresses, e deixá-las no metadata seria guardar uma cópia que
165
+ -- ninguém mais atualiza.
166
+ v_meta := (v_meta - k_managed - ARRAY['address','city','state','country','postal_code']) || v_overlay;
167
+
168
+ IF v_legacy IS NULL THEN v_meta := v_meta - 'legacy_kind';
169
+ ELSE v_meta := jsonb_set(v_meta, '{legacy_kind}', to_jsonb(v_legacy)); END IF;
170
+
171
+ IF NEW.tags IS NULL OR cardinality(NEW.tags) = 0 THEN v_meta := v_meta - 'tags';
172
+ ELSE v_meta := jsonb_set(v_meta, '{tags}', to_jsonb(NEW.tags)); END IF;
173
+
174
+ IF NEW.custom_fields IS NULL OR NEW.custom_fields = '{}'::jsonb THEN v_meta := v_meta - 'custom_fields';
175
+ ELSE v_meta := jsonb_set(v_meta, '{custom_fields}', NEW.custom_fields); END IF;
176
+
177
+ IF TG_OP = 'INSERT' THEN
178
+ v_tenant := coalesce(NEW.tenant_id, app.current_tenant_id());
179
+ INSERT INTO app.units (id, tenant_id, kind, name, parent_unit_id, active, metadata)
180
+ VALUES (coalesce(NEW.id, gen_random_uuid()), v_tenant, v_kind, btrim(NEW.name),
181
+ NEW.parent_id, coalesce(NEW.is_active, true), v_meta)
182
+ RETURNING id INTO v_id;
183
+ ELSE
184
+ UPDATE app.units u
185
+ SET kind = v_kind,
186
+ name = btrim(NEW.name),
187
+ parent_unit_id = NEW.parent_id,
188
+ active = coalesce(NEW.is_active, true),
189
+ metadata = v_meta,
190
+ updated_at = now()
191
+ WHERE u.id = OLD.id
192
+ RETURNING u.id INTO v_id;
193
+ IF v_id IS NULL THEN
194
+ RAISE EXCEPTION 'v_units: unit % not found or not yours to change', OLD.id USING ERRCODE = '42501';
195
+ END IF;
196
+ END IF;
197
+
198
+ SELECT * INTO NEW FROM public.v_units WHERE id = v_id;
199
+ RETURN NEW;
200
+ END $$;
201
+
202
+ COMMENT ON FUNCTION app.trg_v_units_write() IS
203
+ 'Escreve v_units em app.units; o endereço é de public.addresses e não passa por aqui (141).';
@@ -0,0 +1,157 @@
1
+ -- ============================================================================
2
+ -- 142 — a coordenada é do endereço, não da unidade
3
+ --
4
+ -- Latitude e longitude ficaram no `metadata` da unidade quando o endereço saiu
5
+ -- de lá (141), e não é ali que elas moram: um par de coordenadas é a POSIÇÃO
6
+ -- DE UM ENDEREÇO. Separadas dele, a primeira mudança de rua deixa o ponteiro
7
+ -- apontando para a loja antiga — e nada no banco reclama.
8
+ --
9
+ -- E o mesmo par serve a quem não é unidade: o endereço de entrega de um
10
+ -- cliente quer coordenada pelo mesmo motivo que a loja quer.
11
+ --
12
+ -- A view continua publicando `latitude`/`longitude` — quem sugere "a unidade
13
+ -- mais perto de mim" lê de lá —, só que agora tirando-as da linha de endereço.
14
+ -- ============================================================================
15
+
16
+ ALTER TABLE public.addresses
17
+ ADD COLUMN IF NOT EXISTS latitude numeric,
18
+ ADD COLUMN IF NOT EXISTS longitude numeric;
19
+
20
+ COMMENT ON COLUMN public.addresses.latitude IS
21
+ 'Opcional. Só junto de longitude — meia coordenada não localiza nada (142).';
22
+
23
+ -- O que estava na unidade vai para a linha de endereço dela. Cast guardado: o
24
+ -- valor veio de um campo de texto e uma string torta derrubaria a migration.
25
+ UPDATE public.addresses a
26
+ SET latitude = (u.metadata ->> 'latitude')::numeric,
27
+ longitude = (u.metadata ->> 'longitude')::numeric
28
+ FROM app.units u
29
+ WHERE a.owner_type = 'unit' AND a.owner_id = u.id
30
+ AND a.latitude IS NULL AND a.longitude IS NULL
31
+ AND u.metadata ->> 'latitude' ~ '^-?[0-9]+(\.[0-9]+)?$'
32
+ AND u.metadata ->> 'longitude' ~ '^-?[0-9]+(\.[0-9]+)?$';
33
+
34
+ UPDATE app.units u
35
+ SET metadata = u.metadata - 'latitude' - 'longitude'
36
+ WHERE u.metadata ? 'latitude' OR u.metadata ? 'longitude';
37
+
38
+ CREATE OR REPLACE VIEW public.v_units WITH (security_invoker='true') AS
39
+ SELECT u.id,
40
+ u.tenant_id,
41
+ coalesce(u.metadata ->> 'legacy_kind', u.kind) AS kind,
42
+ u.name,
43
+ u.metadata ->> 'email' AS email,
44
+ u.metadata ->> 'phone' AS phone,
45
+ nullif(btrim(coalesce(a.street, '') ||
46
+ CASE WHEN nullif(btrim(coalesce(a.number, '')), '') IS NULL
47
+ THEN '' ELSE ', ' || btrim(a.number) END), '') AS address,
48
+ a.city,
49
+ a.state,
50
+ coalesce(a.country, 'BR') AS country,
51
+ a.postal_code,
52
+ (u.kind = 'hq') AS is_headquarters,
53
+ u.active AS is_active,
54
+ coalesce((SELECT array_agg(t) FROM jsonb_array_elements_text(
55
+ CASE WHEN jsonb_typeof(u.metadata -> 'tags') = 'array'
56
+ THEN u.metadata -> 'tags' ELSE '[]'::jsonb END) t), '{}'::text[]) AS tags,
57
+ u.metadata ->> 'notes' AS notes,
58
+ u.metadata,
59
+ CASE WHEN jsonb_typeof(u.metadata -> 'custom_fields') = 'object'
60
+ THEN u.metadata -> 'custom_fields' ELSE '{}'::jsonb END AS custom_fields,
61
+ CASE WHEN u.metadata ->> 'created_by' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
62
+ THEN (u.metadata ->> 'created_by')::uuid END AS created_by,
63
+ u.created_at,
64
+ u.updated_at,
65
+ u.parent_unit_id AS parent_id,
66
+ u.metadata ->> 'code' AS code,
67
+ a.latitude,
68
+ a.longitude
69
+ FROM app.units u
70
+ LEFT JOIN LATERAL (
71
+ SELECT x.* FROM public.addresses x
72
+ WHERE x.owner_type = 'unit' AND x.owner_id = u.id
73
+ ORDER BY x.is_default DESC, x.created_at
74
+ LIMIT 1
75
+ ) a ON true;
76
+
77
+ COMMENT ON VIEW public.v_units IS
78
+ 'Units in the shape the units screen reads (#214); endereço e coordenada vêm de public.addresses (141, 142).';
79
+
80
+ CREATE OR REPLACE FUNCTION app.trg_v_units_write() RETURNS trigger
81
+ LANGUAGE plpgsql
82
+ SET search_path TO ''
83
+ AS $$
84
+ DECLARE
85
+ -- Endereço (141) e coordenada (142) saíram daqui: são colunas de
86
+ -- public.addresses, e aceitá-las recriaria a segunda verdade.
87
+ k_managed constant text[] := ARRAY['email','phone','notes','code','created_by'];
88
+ k_moved constant text[] := ARRAY['address','city','state','country','postal_code','latitude','longitude'];
89
+ v_meta jsonb;
90
+ v_overlay jsonb;
91
+ v_kind text;
92
+ v_legacy text;
93
+ v_tenant uuid;
94
+ v_id uuid;
95
+ BEGIN
96
+ IF TG_OP = 'DELETE' THEN
97
+ DELETE FROM public.addresses a WHERE a.owner_type = 'unit' AND a.owner_id = OLD.id;
98
+ DELETE FROM app.units u WHERE u.id = OLD.id;
99
+ RETURN OLD;
100
+ END IF;
101
+
102
+ v_meta := coalesce(NEW.metadata, '{}'::jsonb);
103
+
104
+ v_legacy := nullif(btrim(coalesce(NEW.kind, '')), '');
105
+ IF coalesce(NEW.is_headquarters, false) OR v_legacy = 'hq' THEN
106
+ v_kind := 'hq';
107
+ ELSE
108
+ v_kind := 'branch';
109
+ END IF;
110
+ IF v_legacy IN ('hq', 'branch') OR v_legacy IS NULL THEN v_legacy := NULL; END IF;
111
+
112
+ v_overlay := jsonb_strip_nulls(jsonb_build_object(
113
+ 'email', nullif(btrim(coalesce(NEW.email, '')), ''),
114
+ 'phone', nullif(btrim(coalesce(NEW.phone, '')), ''),
115
+ 'notes', nullif(btrim(coalesce(NEW.notes, '')), ''),
116
+ 'code', nullif(btrim(coalesce(NEW.code, '')), ''),
117
+ 'created_by', CASE WHEN NEW.created_by IS NULL THEN NULL ELSE NEW.created_by::text END
118
+ ));
119
+
120
+ v_meta := (v_meta - k_managed - k_moved) || v_overlay;
121
+
122
+ IF v_legacy IS NULL THEN v_meta := v_meta - 'legacy_kind';
123
+ ELSE v_meta := jsonb_set(v_meta, '{legacy_kind}', to_jsonb(v_legacy)); END IF;
124
+
125
+ IF NEW.tags IS NULL OR cardinality(NEW.tags) = 0 THEN v_meta := v_meta - 'tags';
126
+ ELSE v_meta := jsonb_set(v_meta, '{tags}', to_jsonb(NEW.tags)); END IF;
127
+
128
+ IF NEW.custom_fields IS NULL OR NEW.custom_fields = '{}'::jsonb THEN v_meta := v_meta - 'custom_fields';
129
+ ELSE v_meta := jsonb_set(v_meta, '{custom_fields}', NEW.custom_fields); END IF;
130
+
131
+ IF TG_OP = 'INSERT' THEN
132
+ v_tenant := coalesce(NEW.tenant_id, app.current_tenant_id());
133
+ INSERT INTO app.units (id, tenant_id, kind, name, parent_unit_id, active, metadata)
134
+ VALUES (coalesce(NEW.id, gen_random_uuid()), v_tenant, v_kind, btrim(NEW.name),
135
+ NEW.parent_id, coalesce(NEW.is_active, true), v_meta)
136
+ RETURNING id INTO v_id;
137
+ ELSE
138
+ UPDATE app.units u
139
+ SET kind = v_kind,
140
+ name = btrim(NEW.name),
141
+ parent_unit_id = NEW.parent_id,
142
+ active = coalesce(NEW.is_active, true),
143
+ metadata = v_meta,
144
+ updated_at = now()
145
+ WHERE u.id = OLD.id
146
+ RETURNING u.id INTO v_id;
147
+ IF v_id IS NULL THEN
148
+ RAISE EXCEPTION 'v_units: unit % not found or not yours to change', OLD.id USING ERRCODE = '42501';
149
+ END IF;
150
+ END IF;
151
+
152
+ SELECT * INTO NEW FROM public.v_units WHERE id = v_id;
153
+ RETURN NEW;
154
+ END $$;
155
+
156
+ COMMENT ON FUNCTION app.trg_v_units_write() IS
157
+ 'Escreve v_units em app.units; endereço e coordenada são de public.addresses (141, 142).';
@@ -0,0 +1,29 @@
1
+ -- ============================================================================
2
+ -- 143 — o preset para de nomear telas que não existem
3
+ --
4
+ -- As telas de Campanhas e Templates do plugin de conversas foram removidas:
5
+ -- tomavam duas linhas de rail num plugin que declara que nem a própria caixa
6
+ -- de entrada merece uma, e apareciam DUAS VEZES no rail — uma reivindicada
7
+ -- pela entrada `conversations` do preset, outra na sobra do fim, porque o
8
+ -- `dedupeByRoute` não alcança as `pages` do aplicativo.
9
+ --
10
+ -- O preset do StudioControl ainda falava delas: escondia
11
+ -- `/conversations/campaigns` e renomeava `/conversations/templates` para
12
+ -- "Comunicação". Esconder e rotular uma rota que não existe mais não quebra
13
+ -- nada, e é exatamente por isso que fica: ninguém descobre por erro, só por
14
+ -- leitura — e quem ler daqui a um ano vai procurar a tela.
15
+ -- ============================================================================
16
+
17
+ UPDATE app.apps
18
+ SET preset = jsonb_set(
19
+ preset,
20
+ '{hide}',
21
+ coalesce((
22
+ SELECT jsonb_agg(h) FROM jsonb_array_elements(preset -> 'hide') h
23
+ WHERE h #>> '{}' <> '/conversations/campaigns'
24
+ ), '[]'::jsonb))
25
+ WHERE preset -> 'hide' @> '["/conversations/campaigns"]'::jsonb;
26
+
27
+ UPDATE app.apps
28
+ SET preset = jsonb_set(preset, '{labels}', (preset -> 'labels') - '/conversations/templates')
29
+ WHERE preset -> 'labels' ? '/conversations/templates';
@@ -0,0 +1,46 @@
1
+ -- ============================================================================
2
+ -- 144 — Marketing é um botão, não uma gaveta
3
+ --
4
+ -- No StoreControl a linha de Marketing era uma RUBRICA:
5
+ --
6
+ -- {"icon": "Megaphone", "label": "Marketing", "under": ["/marketing", "/conversations"]}
7
+ --
8
+ -- Uma rubrica é uma sanfona: clicar abre uma lista e o destino fica a dois
9
+ -- cliques. Mas `/marketing` já está em `whole` — ele abre INTEIRO, com as
10
+ -- próprias abas por dentro (é a regra "um assunto, uma linha"). Uma gaveta por
11
+ -- fora de uma tela que já se organiza por dentro é o mesmo menu desenhado duas
12
+ -- vezes, e a segunda vez em outra gramática.
13
+ --
14
+ -- E o que a rubrica agrupava não existe mais: `/conversations` perdeu as linhas
15
+ -- de Campanhas e Templates, então a gaveta ficou com um destino só de verdade —
16
+ -- o caso que o próprio rail já sabe colapsar ("uma rubrica com um destino só
17
+ -- não é uma rubrica"), mas colapsava mantendo o ícone e o rótulo da gaveta em
18
+ -- vez do da tela.
19
+ --
20
+ -- Todo aplicativo: os outros presets já traziam `"marketing"` como linha
21
+ -- simples, e esta migration deixa o StoreControl igual — sem depender de o
22
+ -- colapso acontecer por sorte de configuração.
23
+ -- ============================================================================
24
+
25
+ UPDATE app.apps
26
+ SET preset = jsonb_set(
27
+ preset,
28
+ '{rail}',
29
+ (SELECT jsonb_agg(
30
+ CASE WHEN jsonb_typeof(e) = 'object' AND e ->> 'label' = 'Marketing'
31
+ THEN '"marketing"'::jsonb
32
+ ELSE e END
33
+ ORDER BY ord)
34
+ FROM jsonb_array_elements(preset -> 'rail') WITH ORDINALITY AS t(e, ord)))
35
+ WHERE preset ? 'rail'
36
+ AND EXISTS (
37
+ SELECT 1 FROM jsonb_array_elements(preset -> 'rail') e
38
+ WHERE jsonb_typeof(e) = 'object' AND e ->> 'label' = 'Marketing');
39
+
40
+ -- `/marketing` abre inteiro em todo preset que tenha rail — é o que faz a
41
+ -- linha única levar direto à tela com as abas dentro, em vez de a uma sanfona.
42
+ UPDATE app.apps
43
+ SET preset = jsonb_set(preset, '{whole}',
44
+ coalesce(preset -> 'whole', '[]'::jsonb) || '["/marketing"]'::jsonb)
45
+ WHERE preset ? 'rail'
46
+ AND NOT coalesce(preset -> 'whole', '[]'::jsonb) @> '["/marketing"]'::jsonb;
@@ -0,0 +1,36 @@
1
+ -- ============================================================================
2
+ -- 145 — Campanhas e Templates somem em toda conta
3
+ --
4
+ -- As duas telas foram REMOVIDAS do plugin de conversas, mas remover código só
5
+ -- vale para quem roda o código: cada aplicativo resolve `@fayz-ai/*` do npm na
6
+ -- versão que instalou, e os que estão no ar continuam desenhando as duas linhas
7
+ -- — às vezes DUAS VEZES, dentro de Agenda, de Marketing ou soltas no fim do
8
+ -- rail, conforme a rubrica de cada ofício. Esperar o release para parar de
9
+ -- mostrar uma tela que não deveria existir é esperar tempo demais.
10
+ --
11
+ -- O `hide` do preset não depende de versão: ele mora no banco, o rail o lê a
12
+ -- cada carga, e uma rota calada não é reivindicada por rubrica nenhuma nem
13
+ -- sobra para o fim (ver `applyRailPreset`). Vale para TODO preset, inclusive os
14
+ -- que não tinham `hide`.
15
+ --
16
+ -- E fica depois do release também. Esconder uma rota que não existe mais não
17
+ -- custa nada, e é a garantia de que um aplicativo parado numa versão velha —
18
+ -- ou um `pages` de app que declare a rota de novo por fora do `dedupeByRoute` —
19
+ -- não traga as duas linhas de volta sem ninguém perceber.
20
+ --
21
+ -- (Reverte a parte da 143 que tirou `/conversations/campaigns` do `hide` do
22
+ -- StudioControl: lá a leitura foi "a tela não existe mais, a linha do preset é
23
+ -- letra morta". Não era — era a única coisa segurando a tela nos apps no ar.)
24
+ -- ============================================================================
25
+
26
+ UPDATE app.apps
27
+ SET preset = jsonb_set(
28
+ coalesce(preset, '{}'::jsonb),
29
+ '{hide}',
30
+ (SELECT coalesce(jsonb_agg(DISTINCT h), '[]'::jsonb)
31
+ FROM jsonb_array_elements(
32
+ coalesce(preset -> 'hide', '[]'::jsonb)
33
+ || '["/conversations/campaigns", "/conversations/templates"]'::jsonb) h))
34
+ WHERE preset IS NOT NULL
35
+ AND NOT (coalesce(preset -> 'hide', '[]'::jsonb)
36
+ @> '["/conversations/campaigns", "/conversations/templates"]'::jsonb);
@@ -0,0 +1,31 @@
1
+ -- ============================================================================
2
+ -- 146 — a clínica vê as próprias análises
3
+ --
4
+ -- O preset do StudioControl (clínica e estética: Espaço Renova Rio, Clínica
5
+ -- Sol) dizia duas coisas que não podem ser verdade ao mesmo tempo:
6
+ --
7
+ -- rail: [… "financial", "reports", {Cadastros}]
8
+ -- hide: ["/analytics", …]
9
+ --
10
+ -- O rail nomeia a linha de Análises no lugar certo — entre Financeiro e
11
+ -- Cadastros — e o `hide` a silencia antes de qualquer rubrica poder
12
+ -- reivindicá-la (`applyRailPreset` poda primeiro). Ganha o `hide`, e a casa
13
+ -- que mais precisa olhar ocupação, ticket e retorno ficava sem a tela.
14
+ --
15
+ -- O `hide` veio de quando Análises era uma gaveta de relatórios crus; hoje é
16
+ -- uma tela só, com abas por dentro. Tirar a linha do `hide` é o suficiente:
17
+ -- o rail já sabe onde ela vai.
18
+ -- ============================================================================
19
+
20
+ UPDATE app.apps
21
+ SET preset = jsonb_set(
22
+ preset,
23
+ '{hide}',
24
+ coalesce((
25
+ SELECT jsonb_agg(h) FROM jsonb_array_elements(preset -> 'hide') h
26
+ WHERE h #>> '{}' <> '/analytics'
27
+ ), '[]'::jsonb))
28
+ WHERE preset -> 'hide' @> '["/analytics"]'::jsonb
29
+ -- Só onde o rail JÁ diz onde a linha mora. Num preset sem `reports` no rail
30
+ -- ela cairia solta no fim, que é pior que não aparecer.
31
+ AND preset -> 'rail' @> '["reports"]'::jsonb;