@fayz-ai/db 0.20.0 → 0.21.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.
@@ -0,0 +1,129 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 158_a_producao_e_do_restaurante_e_mora_no_estoque.sql
3
+ -- ---------------------------------------------------------------------------
4
+ -- Produção é um módulo de quem FABRICA o que vende — e, na frota de hoje, isso
5
+ -- é o ChefControl. Uma clínica, um salão, uma escola e uma loja compilam o
6
+ -- mesmo pacote e não têm o que produzir.
7
+ --
8
+ -- ── A porta é a OFERTA, não uma consulta na tela ────────────────────────────
9
+ -- `preset.modules` é o que este ofício oferece (079): `resolvePluginRuntime`
10
+ -- recebe a união do que os aplicativos da conta oferecem e deixa FORA todo
11
+ -- plugin que ninguém ofereceu, mesmo o que nasce ligado. É a mesma porta que
12
+ -- já mantém cozinha, mesa e reserva longe de um consultório — e o motivo de
13
+ -- não haver `if (tenant.temResto)` em componente nenhum.
14
+ --
15
+ -- ── E o rail: «Estoque» passa a abrir em dois ───────────────────────────────
16
+ -- Hoje o ChefControl põe o Estoque dentro de «Produtos», ao lado do Cardápio.
17
+ -- Com a Produção ao lado, o Estoque vira rubrica própria com duas telas:
18
+ --
19
+ -- Estoque
20
+ -- Geral → /inventory
21
+ -- Produção → /production
22
+ --
23
+ -- «Produtos» fica com o Cardápio, que é o que ela sempre foi na cabeça de quem
24
+ -- usa: o que se VENDE. Estoque é o que se GUARDA.
25
+ --
26
+ -- O `full` (aplicativo central) ganha a mesma rubrica por simetria, e nele ela
27
+ -- se recolhe sozinha: sem a Produção oferecida, uma rubrica com um destino só
28
+ -- VIRA o destino e volta a ser a linha «Estoque» de hoje (ver applyRailPreset).
29
+ -- Nenhuma conta sem `resto` muda de menu.
30
+ --
31
+ -- Idempotente: recalcula rail, modules e labels a partir do que está lá.
32
+ -- ---------------------------------------------------------------------------
33
+
34
+ -- ── 1. A oferta: só o ChefControl oferece Produção ──────────────────────────
35
+ UPDATE app.apps
36
+ SET preset = jsonb_set(
37
+ preset,
38
+ '{modules}',
39
+ (
40
+ SELECT to_jsonb(array_agg(m ORDER BY m))
41
+ FROM (
42
+ SELECT jsonb_array_elements_text(coalesce(preset -> 'modules', '[]'::jsonb)) AS m
43
+ UNION
44
+ SELECT 'production'
45
+ ) AS mods
46
+ ),
47
+ true
48
+ )
49
+ WHERE id = 'resto';
50
+
51
+ -- ── 2. O vocabulário: dentro da rubrica, o Estoque chama-se «Geral» ─────────
52
+ -- Fora dela o nome continua sendo o do plugin: a rubrica leva o próprio nome
53
+ -- quando se recolhe, então uma conta sem Produção nunca lê «Geral» sozinho.
54
+ UPDATE app.apps
55
+ SET preset = jsonb_set(
56
+ preset,
57
+ '{labels}',
58
+ coalesce(preset -> 'labels', '{}'::jsonb)
59
+ || jsonb_build_object('/inventory', 'Geral', '/production', 'Produção'),
60
+ true
61
+ )
62
+ WHERE id IN ('resto', 'full');
63
+
64
+ -- ── 3. «Produtos» não existe: o que sobra dela é o Cardápio ─────────────────
65
+ -- A rubrica juntava o que se VENDE (/menu) e o que se GUARDA (/inventory) sob
66
+ -- um nome que não é nenhum dos dois. Tirado o Estoque, o que resta é uma
67
+ -- rubrica de um destino só — e `applyRailPreset` recolhe essas, mantendo o
68
+ -- nome da RUBRICA e não o do destino. Por isso o nome muda aqui: sem esta
69
+ -- linha o rail leria «Produtos» apontando para o cardápio, que é o mal-entendido
70
+ -- de partida com uma tela a menos.
71
+ UPDATE app.apps
72
+ SET preset = jsonb_set(
73
+ preset,
74
+ '{rail}',
75
+ (
76
+ SELECT jsonb_agg(
77
+ CASE
78
+ WHEN entrada -> 'under' IS NOT NULL AND entrada ->> 'label' = 'Produtos'
79
+ THEN jsonb_build_object('label', 'Cardápio', 'icon', 'BookOpen',
80
+ 'under', jsonb_build_array('/menu'))
81
+ ELSE entrada
82
+ END
83
+ ORDER BY ord
84
+ )
85
+ FROM jsonb_array_elements(preset -> 'rail') WITH ORDINALITY AS r(entrada, ord)
86
+ ),
87
+ true
88
+ )
89
+ WHERE id = 'resto'
90
+ AND preset -> 'rail' IS NOT NULL;
91
+
92
+ -- ── 4. A rubrica «Estoque», nos dois aplicativos ────────────────────────────
93
+ -- Ela entra ENCOSTADA no vizinho certo, e não no fim: no ChefControl logo
94
+ -- depois do «Cardápio» (o que se vende e o que se guarda leem-se juntos), e no
95
+ -- aplicativo central logo depois da última rubrica antes de «Cadastros» — que
96
+ -- é sempre a última de todo preset.
97
+ --
98
+ -- As posições são multiplicadas por dez para a nova caber no meio sem renumerar
99
+ -- as vizinhas; `+5` é o «logo depois daquela».
100
+ UPDATE app.apps a
101
+ SET preset = jsonb_set(
102
+ a.preset,
103
+ '{rail}',
104
+ (
105
+ WITH atual AS (
106
+ SELECT entrada, ord
107
+ FROM jsonb_array_elements(a.preset -> 'rail') WITH ORDINALITY AS r(entrada, ord)
108
+ WHERE entrada ->> 'label' IS DISTINCT FROM 'Estoque'
109
+ AND NOT (entrada ? 'under' AND entrada -> 'under' @> '["/inventory"]'::jsonb)
110
+ ),
111
+ vizinha AS (
112
+ SELECT coalesce(
113
+ (SELECT ord FROM atual WHERE entrada -> 'under' @> '["/menu"]'::jsonb ORDER BY ord LIMIT 1),
114
+ (SELECT max(ord) FROM atual WHERE entrada ->> 'label' IS DISTINCT FROM 'Cadastros')
115
+ ) AS ord
116
+ )
117
+ SELECT jsonb_agg(entrada ORDER BY ord)
118
+ FROM (
119
+ SELECT entrada, ord * 10 AS ord FROM atual
120
+ UNION ALL
121
+ SELECT jsonb_build_object('label', 'Estoque', 'icon', 'Package',
122
+ 'under', jsonb_build_array('/inventory', '/production')),
123
+ (SELECT ord FROM vizinha) * 10 + 5
124
+ ) AS reordenado
125
+ ),
126
+ true
127
+ )
128
+ WHERE a.id IN ('resto', 'full')
129
+ AND a.preset -> 'rail' IS NOT NULL;
@@ -0,0 +1,98 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 159_a_loja_tambem_confundia_o_que_vende_com_o_que_guarda.sql
3
+ -- ---------------------------------------------------------------------------
4
+ -- A 158 separou Cardápio de Estoque no ChefControl e parou aí. Mas quem
5
+ -- empresta o rail a uma conta é o PRIMEIRO aplicativo de `app_config()` — e
6
+ -- numa churrascaria que roda StoreControl + FullControl + FiscalControl o
7
+ -- primeiro é o StoreControl, porque `commerce` é ofício e `generic` não é.
8
+ -- O menu que a Artorius lê nunca foi o do `full`.
9
+ --
10
+ -- E o StoreControl trazia o mesmo mal-entendido ao contrário:
11
+ --
12
+ -- Estoque → /inventory + /menu
13
+ --
14
+ -- O cardápio DENTRO do estoque. Vender e guardar são dois trabalhos; quem
15
+ -- procura o que a casa oferece hoje e quem procura quanto ainda há na câmara
16
+ -- não estão fazendo a mesma coisa.
17
+ --
18
+ -- Depois desta:
19
+ --
20
+ -- Cardápio → /menu
21
+ -- Estoque → /inventory + /production
22
+ --
23
+ -- A Produção entra aqui como entrou no `full`: pela porta que já existe. Sem
24
+ -- o app `resto` ninguém a OFERECE, `resolvePluginRuntime` a descarta, e uma
25
+ -- rubrica de um destino só vira o destino (applyRailPreset) — a loja que não
26
+ -- fabrica continua lendo a linha «Estoque» de sempre. Numa conta que também
27
+ -- rode ChefControl, ela abre em Geral | Produção.
28
+ --
29
+ -- Idempotente: reescreve as duas rubricas a partir do que está lá.
30
+ -- ---------------------------------------------------------------------------
31
+
32
+ -- ── 1. O Estoque solta o Cardápio e recebe a Produção ───────────────────────
33
+ UPDATE app.apps
34
+ SET preset = jsonb_set(
35
+ preset,
36
+ '{rail}',
37
+ (
38
+ SELECT jsonb_agg(
39
+ CASE
40
+ WHEN entrada ? 'under' AND entrada -> 'under' @> '["/inventory"]'::jsonb
41
+ THEN jsonb_build_object('label', 'Estoque', 'icon', 'Package',
42
+ 'under', jsonb_build_array('/inventory', '/production'))
43
+ ELSE entrada
44
+ END
45
+ ORDER BY ord
46
+ )
47
+ FROM jsonb_array_elements(preset -> 'rail') WITH ORDINALITY AS r(entrada, ord)
48
+ ),
49
+ true
50
+ )
51
+ WHERE id = 'shop'
52
+ AND preset -> 'rail' IS NOT NULL;
53
+
54
+ -- ── 2. O Cardápio ganha rubrica própria, logo antes do Estoque ──────────────
55
+ -- Encostado e não no fim: o que se vende e o que se guarda leem-se juntos.
56
+ -- Posições ×10 para a nova caber no meio sem renumerar as vizinhas; `-5` é o
57
+ -- «logo antes daquela».
58
+ UPDATE app.apps a
59
+ SET preset = jsonb_set(
60
+ a.preset,
61
+ '{rail}',
62
+ (
63
+ WITH atual AS (
64
+ SELECT entrada, ord
65
+ FROM jsonb_array_elements(a.preset -> 'rail') WITH ORDINALITY AS r(entrada, ord)
66
+ WHERE entrada ->> 'label' IS DISTINCT FROM 'Cardápio'
67
+ AND NOT (entrada ? 'under' AND entrada -> 'under' @> '["/menu"]'::jsonb)
68
+ ),
69
+ vizinha AS (
70
+ SELECT (SELECT ord FROM atual
71
+ WHERE entrada -> 'under' @> '["/inventory"]'::jsonb
72
+ ORDER BY ord LIMIT 1) AS ord
73
+ )
74
+ SELECT jsonb_agg(entrada ORDER BY ord)
75
+ FROM (
76
+ SELECT entrada, ord * 10 AS ord FROM atual
77
+ UNION ALL
78
+ SELECT jsonb_build_object('label', 'Cardápio', 'icon', 'BookOpen',
79
+ 'under', jsonb_build_array('/menu')),
80
+ (SELECT ord FROM vizinha) * 10 - 5
81
+ WHERE (SELECT ord FROM vizinha) IS NOT NULL
82
+ ) AS reordenado
83
+ ),
84
+ true
85
+ )
86
+ WHERE a.id = 'shop'
87
+ AND a.preset -> 'rail' IS NOT NULL;
88
+
89
+ -- ── 3. O vocabulário dentro da rubrica ──────────────────────────────────────
90
+ UPDATE app.apps
91
+ SET preset = jsonb_set(
92
+ preset,
93
+ '{labels}',
94
+ coalesce(preset -> 'labels', '{}'::jsonb)
95
+ || jsonb_build_object('/inventory', 'Geral', '/production', 'Produção'),
96
+ true
97
+ )
98
+ WHERE id = 'shop';
@@ -0,0 +1,73 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 161_vender_e_falar_com_quem_ainda_nao_comprou_sao_o_mesmo_assunto.sql
3
+ -- ---------------------------------------------------------------------------
4
+ -- No rail de hoje «CRM» e «Marketing» são duas linhas vizinhas e soltas. Quem
5
+ -- usa não separa as duas coisas: a campanha existe para gerar o lead, e o lead
6
+ -- é o começo do funil. No V1 elas nem são dois módulos — o `CRMSidebar` do
7
+ -- beautyplace desenha as views de venda E as de marketing na mesma barra, com
8
+ -- os prefixos `mkt-` misturados no mesmo `CRMView`.
9
+ --
10
+ -- Depois desta:
11
+ --
12
+ -- CRM
13
+ -- Vendas → /sales
14
+ -- Marketing → /marketing
15
+ --
16
+ -- Onde só um dos dois está ligado, `applyRailPreset` recolhe a rubrica no
17
+ -- destino que sobrou e ela volta a ser a linha de hoje — o `beauty`, que tem
18
+ -- CRM no rail e não tem Marketing, não muda de menu.
19
+ --
20
+ -- «Vendas» e não «CRM» no filho: o pai já se chama CRM, e pai e filho com o
21
+ -- mesmo nome é uma linha que não diz para onde leva (a mesma correção que o
22
+ -- Funil levou no plugin, onde o modo «Funil» virou «Quadro»).
23
+ --
24
+ -- Idempotente: reescreve a rubrica a partir do que está lá.
25
+ -- ---------------------------------------------------------------------------
26
+
27
+ -- ── 1. O vocabulário dentro da rubrica ──────────────────────────────────────
28
+ UPDATE app.apps
29
+ SET preset = jsonb_set(
30
+ preset,
31
+ '{labels}',
32
+ coalesce(preset -> 'labels', '{}'::jsonb)
33
+ || jsonb_build_object('/sales', 'Vendas', '/marketing', 'Marketing'),
34
+ true
35
+ )
36
+ WHERE preset -> 'rail' @> '["crm"]'::jsonb;
37
+
38
+ -- ── 2. As duas linhas viram uma rubrica, no lugar da primeira delas ─────────
39
+ -- No lugar da PRIMEIRA e não no fim: o CRM já está posicionado no rail de cada
40
+ -- ofício (depois de Clientes no `full`, depois de Marketing no `shop`), e uma
41
+ -- rubrica que muda de altura muda o menu de quem já sabia onde clicar.
42
+ UPDATE app.apps a
43
+ SET preset = jsonb_set(
44
+ a.preset,
45
+ '{rail}',
46
+ (
47
+ WITH numerada AS (
48
+ SELECT entrada, ord
49
+ FROM jsonb_array_elements(a.preset -> 'rail') WITH ORDINALITY AS r(entrada, ord)
50
+ ),
51
+ alvo AS (
52
+ SELECT min(ord) AS ord
53
+ FROM numerada
54
+ WHERE entrada IN ('"crm"'::jsonb, '"marketing"'::jsonb)
55
+ ),
56
+ restante AS (
57
+ SELECT entrada, ord
58
+ FROM numerada
59
+ WHERE entrada NOT IN ('"crm"'::jsonb, '"marketing"'::jsonb)
60
+ AND entrada ->> 'label' IS DISTINCT FROM 'CRM'
61
+ )
62
+ SELECT jsonb_agg(entrada ORDER BY ord)
63
+ FROM (
64
+ SELECT entrada, ord * 10 AS ord FROM restante
65
+ UNION ALL
66
+ SELECT jsonb_build_object('label', 'CRM', 'icon', 'Filter',
67
+ 'under', jsonb_build_array('/sales', '/marketing')),
68
+ (SELECT ord FROM alvo) * 10 - 1
69
+ ) AS reordenado
70
+ ),
71
+ true
72
+ )
73
+ WHERE a.preset -> 'rail' @> '["crm"]'::jsonb;
@@ -0,0 +1,71 @@
1
+ -- The Beauty app offers the turn queue as an independent module. Walter's is
2
+ -- the first tenant to turn it on; every other salon keeps today's behavior
3
+ -- until an operator enables it from Settings > Modules.
4
+
5
+ -- Walter's is a salon account. Keep Beauty active so app_config selects the
6
+ -- StudioControl rail even if the tenant also owns a back-office application.
7
+ INSERT INTO app.tenant_apps (tenant_id, app_id, status, source)
8
+ VALUES ('c3a8f61d-742b-4e05-9d16-8b47e2f0a5c3', 'beauty', 'active', 'manual')
9
+ ON CONFLICT (tenant_id, app_id)
10
+ DO UPDATE SET status = 'active', source = 'manual', updated_at = now();
11
+
12
+ UPDATE public.tenants
13
+ SET vertical_id = 'beauty', updated_at = now()
14
+ WHERE id = 'c3a8f61d-742b-4e05-9d16-8b47e2f0a5c3'
15
+ AND vertical_id IS DISTINCT FROM 'beauty';
16
+
17
+ UPDATE app.apps
18
+ SET preset = jsonb_set(
19
+ preset,
20
+ '{modules}',
21
+ coalesce(preset -> 'modules', '[]'::jsonb) || '["agenda-turn-queue"]'::jsonb
22
+ )
23
+ WHERE id = 'beauty'
24
+ AND NOT (coalesce(preset -> 'modules', '[]'::jsonb) ? 'agenda-turn-queue');
25
+
26
+ SELECT app.seed_permissions($$[
27
+ {"key":"agenda.turn_queue.read","plugin":"agenda-turn-queue","description":"See the live turn queue and its audit trail"},
28
+ {"key":"agenda.turn_queue.create","plugin":"agenda-turn-queue","description":"Enter the turn queue"},
29
+ {"key":"agenda.turn_queue.edit","plugin":"agenda-turn-queue","description":"Enter or leave your own turn"},
30
+ {"key":"agenda.turn_queue.manage","plugin":"agenda-turn-queue","description":"Record front-desk exceptions in the turn queue"}
31
+ ]$$::jsonb);
32
+
33
+ SELECT app.seed_role_template_rows('salon', 'manager', 'Gerente', 1,
34
+ ARRAY['agenda.turn_queue.read','agenda.turn_queue.edit','agenda.turn_queue.manage']);
35
+ SELECT app.seed_role_template_rows('salon', 'reception', 'Recepção', 2,
36
+ ARRAY['agenda.turn_queue.read','agenda.turn_queue.manage']);
37
+ SELECT app.seed_role_template_rows('salon', 'professional', 'Profissional', 3,
38
+ ARRAY['agenda.turn_queue.read','agenda.turn_queue.edit']);
39
+ SELECT app.seed_role_template_rows('clinic', 'manager', 'Coordenação', 1,
40
+ ARRAY['agenda.turn_queue.read','agenda.turn_queue.edit','agenda.turn_queue.manage']);
41
+ SELECT app.seed_role_template_rows('clinic', 'reception', 'Recepção', 2,
42
+ ARRAY['agenda.turn_queue.read','agenda.turn_queue.manage']);
43
+ SELECT app.seed_role_template_rows('clinic', 'professional', 'Profissional', 3,
44
+ ARRAY['agenda.turn_queue.read','agenda.turn_queue.edit']);
45
+
46
+ INSERT INTO app.role_permissions (tenant_id, role_id, permission)
47
+ SELECT r.tenant_id, r.id, p.permission
48
+ FROM app.roles r
49
+ CROSS JOIN LATERAL (
50
+ SELECT unnest(CASE r.key
51
+ WHEN 'owner' THEN ARRAY['agenda.turn_queue.read','agenda.turn_queue.create','agenda.turn_queue.edit','agenda.turn_queue.manage']
52
+ WHEN 'manager' THEN ARRAY['agenda.turn_queue.read','agenda.turn_queue.edit','agenda.turn_queue.manage']
53
+ WHEN 'reception' THEN ARRAY['agenda.turn_queue.read','agenda.turn_queue.manage']
54
+ WHEN 'professional' THEN ARRAY['agenda.turn_queue.read','agenda.turn_queue.edit']
55
+ ELSE ARRAY[]::text[] END) AS permission
56
+ ) p
57
+ WHERE r.tenant_id = 'c3a8f61d-742b-4e05-9d16-8b47e2f0a5c3'
58
+ AND r.key IN ('owner','manager','reception','professional')
59
+ ON CONFLICT DO NOTHING;
60
+
61
+ INSERT INTO app.tenant_plugins (tenant_id, plugin_id, facet, status, source)
62
+ VALUES ('c3a8f61d-742b-4e05-9d16-8b47e2f0a5c3', 'agenda-turn-queue', '', 'active', 'manual')
63
+ ON CONFLICT (tenant_id, plugin_id, facet)
64
+ DO UPDATE SET status = 'active', source = 'manual', updated_at = now();
65
+
66
+ -- Walter's must see the existing check-in entry even if the tenant previously
67
+ -- hid it while shaping its menu. The Beauty preset already groups it in Agenda.
68
+ INSERT INTO app.tenant_nav (tenant_id, entry_key, hidden)
69
+ VALUES ('c3a8f61d-742b-4e05-9d16-8b47e2f0a5c3', '/checkin', false)
70
+ ON CONFLICT (tenant_id, entry_key)
71
+ DO UPDATE SET hidden = false, updated_at = now();
@@ -0,0 +1,29 @@
1
+ -- 164: check-in e confirmações eram opção de BUILD, nunca um item de plano —
2
+ -- toda conta com Agenda os tinha, de graça. Virar facet (rail-preset, mesma
3
+ -- onda que criou `requiresFacet`) sem seguir esse fato para `app.plan_grants`
4
+ -- quebrou o teto: `plan_permits('agenda','checkin',tenant)` nega em qualquer
5
+ -- plano que já restrinja QUALQUER OUTRA coisa (a regra é "silêncio total
6
+ -- permite", não "silêncio por facet") — então `tenant_plugin_set` rejeitava o
7
+ -- toggle em todo plano com ao menos uma linha em `plan_grants`, e ligar
8
+ -- Check-in em Módulos não fazia nada.
9
+ --
10
+ -- A correção segue o mesmo plano que já concede a Agenda: onde a Agenda em si
11
+ -- está `included` ou `optional`, as duas facets entram `included` — elas
12
+ -- nunca foram um upsell, só eram invisíveis para o catálogo de planos.
13
+
14
+ DO $$
15
+ DECLARE
16
+ v_plan record;
17
+ BEGIN
18
+ IF to_regprocedure('public.register_plan_grant(text,text,text,text,text)') IS NULL THEN
19
+ RAISE NOTICE '164: register_plan_grant ausente — pulado (pool sem app.plan_grants ainda)';
20
+ RETURN;
21
+ END IF;
22
+
23
+ FOR v_plan IN SELECT DISTINCT plan_id FROM app.plan_grants WHERE plugin_id = 'agenda' AND facet = '' LOOP
24
+ PERFORM public.register_plan_grant(v_plan.plan_id, 'agenda', 'checkin', 'included',
25
+ 'Vem com a Agenda desde sempre — nunca foi upsell, só nunca tinha linha própria.');
26
+ PERFORM public.register_plan_grant(v_plan.plan_id, 'agenda', 'confirmations', 'included',
27
+ 'Vem com a Agenda desde sempre — nunca foi upsell, só nunca tinha linha própria.');
28
+ END LOOP;
29
+ END $$;
@@ -0,0 +1,258 @@
1
+ -- Unit coordinates must follow the address they describe. Queue an asynchronous
2
+ -- geocode only when the normalized, location-bearing fields actually change.
3
+ -- Saving remains available if the provider or pg_net is temporarily offline.
4
+
5
+ CREATE OR REPLACE FUNCTION app.unit_address_geocode_fingerprint(
6
+ p_street text,
7
+ p_number text,
8
+ p_district text,
9
+ p_city text,
10
+ p_state text,
11
+ p_postal_code text,
12
+ p_country text
13
+ ) RETURNS text
14
+ LANGUAGE sql
15
+ IMMUTABLE
16
+ SET search_path = ''
17
+ AS $$
18
+ SELECT md5(concat_ws('|',
19
+ lower(regexp_replace(btrim(coalesce(p_street, '')), '\s+', ' ', 'g')),
20
+ lower(regexp_replace(btrim(coalesce(p_number, '')), '\s+', ' ', 'g')),
21
+ lower(regexp_replace(btrim(coalesce(p_district, '')), '\s+', ' ', 'g')),
22
+ lower(regexp_replace(btrim(coalesce(p_city, '')), '\s+', ' ', 'g')),
23
+ upper(regexp_replace(btrim(coalesce(p_state, '')), '\s+', ' ', 'g')),
24
+ regexp_replace(coalesce(p_postal_code, ''), '\D', '', 'g'),
25
+ upper(btrim(coalesce(p_country, 'BR')))
26
+ ));
27
+ $$;
28
+
29
+ REVOKE ALL ON FUNCTION app.unit_address_geocode_fingerprint(text, text, text, text, text, text, text) FROM PUBLIC;
30
+
31
+ CREATE OR REPLACE FUNCTION app.trg_queue_unit_address_geocode() RETURNS trigger
32
+ LANGUAGE plpgsql
33
+ SECURITY DEFINER
34
+ SET search_path = ''
35
+ AS $$
36
+ DECLARE
37
+ v_fingerprint text;
38
+ v_old_fingerprint text;
39
+ v_request_id bigint;
40
+ v_coordinates_supplied boolean;
41
+ BEGIN
42
+ IF NEW.owner_type <> 'unit' THEN
43
+ RETURN NEW;
44
+ END IF;
45
+
46
+ v_fingerprint := app.unit_address_geocode_fingerprint(
47
+ NEW.street, NEW.number, NEW.district, NEW.city, NEW.state,
48
+ NEW.postal_code, NEW.country
49
+ );
50
+
51
+ IF TG_OP = 'UPDATE' THEN
52
+ v_old_fingerprint := app.unit_address_geocode_fingerprint(
53
+ OLD.street, OLD.number, OLD.district, OLD.city, OLD.state,
54
+ OLD.postal_code, OLD.country
55
+ );
56
+ IF v_fingerprint = v_old_fingerprint THEN
57
+ RETURN NEW;
58
+ END IF;
59
+ END IF;
60
+
61
+ -- A coordinate pair edited alongside the address is an explicit answer and
62
+ -- wins over automatic geocoding. An unchanged old pair does not: it points
63
+ -- to the previous address and must be invalidated before the async lookup.
64
+ v_coordinates_supplied := NEW.latitude IS NOT NULL AND NEW.longitude IS NOT NULL
65
+ AND (TG_OP = 'INSERT'
66
+ OR NEW.latitude IS DISTINCT FROM OLD.latitude
67
+ OR NEW.longitude IS DISTINCT FROM OLD.longitude);
68
+
69
+ IF v_coordinates_supplied THEN
70
+ NEW.metadata := jsonb_set(
71
+ coalesce(NEW.metadata, '{}'::jsonb),
72
+ '{geocode}',
73
+ jsonb_build_object(
74
+ 'status', 'manual',
75
+ 'fingerprint', v_fingerprint,
76
+ 'updated_at', now()
77
+ ),
78
+ true
79
+ );
80
+ RETURN NEW;
81
+ END IF;
82
+
83
+ NEW.latitude := NULL;
84
+ NEW.longitude := NULL;
85
+ IF to_regprocedure('net.http_get(text,jsonb,jsonb,integer)') IS NULL THEN
86
+ NEW.metadata := jsonb_set(
87
+ coalesce(NEW.metadata, '{}'::jsonb), '{geocode}',
88
+ jsonb_build_object('status', 'unconfigured', 'fingerprint', v_fingerprint, 'updated_at', now()), true
89
+ );
90
+ RETURN NEW;
91
+ END IF;
92
+
93
+ BEGIN
94
+ v_request_id := net.http_get(
95
+ url := 'https://nominatim.openstreetmap.org/search',
96
+ params := jsonb_strip_nulls(jsonb_build_object(
97
+ 'format', 'jsonv2',
98
+ 'limit', '1',
99
+ 'street', concat_ws(' ', NEW.number, NEW.street),
100
+ 'city', NEW.city,
101
+ 'state', NEW.state,
102
+ 'postalcode', NEW.postal_code,
103
+ 'country', coalesce(nullif(NEW.country, ''), 'BR'),
104
+ 'countrycodes', CASE WHEN coalesce(NEW.country, 'BR') ~* '^[a-z]{2}$'
105
+ THEN lower(coalesce(NEW.country, 'BR')) END,
106
+ 'email', 'support@fayz.ai'
107
+ )),
108
+ headers := jsonb_build_object(
109
+ 'Accept', 'application/json',
110
+ 'User-Agent', 'Fayz/1.0 (support@fayz.ai)',
111
+ 'Referer', 'https://fayz.ai/'
112
+ )
113
+ );
114
+ EXCEPTION WHEN OTHERS THEN
115
+ v_request_id := NULL;
116
+ END;
117
+
118
+ NEW.metadata := jsonb_set(
119
+ coalesce(NEW.metadata, '{}'::jsonb),
120
+ '{geocode}',
121
+ jsonb_build_object(
122
+ 'status', CASE WHEN v_request_id IS NULL THEN 'delivery_failed' ELSE 'pending' END,
123
+ 'fingerprint', v_fingerprint,
124
+ 'request_id', v_request_id,
125
+ 'requested_at', now()
126
+ ),
127
+ true
128
+ );
129
+
130
+ RETURN NEW;
131
+ END;
132
+ $$;
133
+
134
+ REVOKE ALL ON FUNCTION app.trg_queue_unit_address_geocode() FROM PUBLIC;
135
+
136
+ DROP TRIGGER IF EXISTS queue_unit_address_geocode ON public.addresses;
137
+ CREATE TRIGGER queue_unit_address_geocode
138
+ BEFORE INSERT OR UPDATE OF street, number, district, city, state, postal_code,
139
+ country, latitude, longitude
140
+ ON public.addresses
141
+ FOR EACH ROW
142
+ EXECUTE FUNCTION app.trg_queue_unit_address_geocode();
143
+
144
+ COMMENT ON FUNCTION app.trg_queue_unit_address_geocode() IS
145
+ 'Invalidates stale unit coordinates and asynchronously requests geocoding when the normalized address changes.';
146
+
147
+ CREATE OR REPLACE FUNCTION app.process_unit_address_geocodes(p_limit integer DEFAULT 100)
148
+ RETURNS integer
149
+ LANGUAGE plpgsql
150
+ SECURITY DEFINER
151
+ SET search_path = ''
152
+ AS $$
153
+ DECLARE
154
+ v_row record;
155
+ v_body jsonb;
156
+ v_hit jsonb;
157
+ v_lat numeric;
158
+ v_lng numeric;
159
+ v_processed integer := 0;
160
+ v_status text;
161
+ BEGIN
162
+ FOR v_row IN
163
+ SELECT a.id,
164
+ a.metadata,
165
+ a.metadata #>> '{geocode,fingerprint}' AS fingerprint,
166
+ r.status_code,
167
+ r.error_msg,
168
+ r.content
169
+ FROM public.addresses a
170
+ JOIN net._http_response r
171
+ ON r.id = CASE
172
+ WHEN a.metadata #>> '{geocode,request_id}' ~ '^[0-9]+$'
173
+ THEN (a.metadata #>> '{geocode,request_id}')::bigint
174
+ END
175
+ WHERE a.owner_type = 'unit'
176
+ AND a.metadata #>> '{geocode,status}' = 'pending'
177
+ AND a.metadata #>> '{geocode,request_id}' ~ '^[0-9]+$'
178
+ ORDER BY r.created
179
+ LIMIT greatest(1, least(coalesce(p_limit, 100), 500))
180
+ FOR UPDATE OF a SKIP LOCKED
181
+ LOOP
182
+ v_status := 'failed';
183
+ v_lat := NULL;
184
+ v_lng := NULL;
185
+
186
+ IF v_row.status_code = 200 THEN
187
+ BEGIN
188
+ v_body := v_row.content::jsonb;
189
+ IF jsonb_typeof(v_body) = 'array' AND jsonb_array_length(v_body) > 0 THEN
190
+ v_hit := v_body -> 0;
191
+ IF v_hit ->> 'lat' ~ '^-?[0-9]+(\.[0-9]+)?$'
192
+ AND v_hit ->> 'lon' ~ '^-?[0-9]+(\.[0-9]+)?$' THEN
193
+ v_lat := (v_hit ->> 'lat')::numeric;
194
+ v_lng := (v_hit ->> 'lon')::numeric;
195
+ IF v_lat BETWEEN -90 AND 90 AND v_lng BETWEEN -180 AND 180 THEN
196
+ v_status := 'resolved';
197
+ END IF;
198
+ ELSE
199
+ v_status := 'not_found';
200
+ END IF;
201
+ ELSE
202
+ v_status := 'not_found';
203
+ END IF;
204
+ EXCEPTION WHEN OTHERS THEN
205
+ v_status := 'failed';
206
+ END;
207
+ END IF;
208
+
209
+ UPDATE public.addresses a
210
+ SET latitude = CASE WHEN v_status = 'resolved' THEN v_lat ELSE NULL END,
211
+ longitude = CASE WHEN v_status = 'resolved' THEN v_lng ELSE NULL END,
212
+ metadata = jsonb_set(
213
+ coalesce(a.metadata, '{}'::jsonb),
214
+ '{geocode}',
215
+ jsonb_strip_nulls(jsonb_build_object(
216
+ 'status', v_status,
217
+ 'fingerprint', v_row.fingerprint,
218
+ 'source', CASE WHEN v_status = 'resolved' THEN 'nominatim_openstreetmap' END,
219
+ 'precision', CASE WHEN v_status = 'resolved'
220
+ THEN coalesce(v_hit ->> 'addresstype', v_hit ->> 'type') END,
221
+ 'updated_at', now()
222
+ )),
223
+ true
224
+ )
225
+ WHERE a.id = v_row.id
226
+ AND a.metadata #>> '{geocode,fingerprint}' = v_row.fingerprint;
227
+
228
+ v_processed := v_processed + 1;
229
+ END LOOP;
230
+
231
+ RETURN v_processed;
232
+ END;
233
+ $$;
234
+
235
+ REVOKE ALL ON FUNCTION app.process_unit_address_geocodes(integer) FROM PUBLIC;
236
+
237
+ COMMENT ON FUNCTION app.process_unit_address_geocodes(integer) IS
238
+ 'Consumes completed pg_net geocoding responses and writes coordinates only while the address fingerprint still matches.';
239
+
240
+ DO $$
241
+ DECLARE
242
+ v_job_id bigint;
243
+ BEGIN
244
+ IF to_regnamespace('cron') IS NULL THEN RETURN; END IF;
245
+
246
+ FOR v_job_id IN SELECT jobid FROM cron.job WHERE jobname = 'fayz-unit-address-geocode' LOOP
247
+ PERFORM cron.unschedule(v_job_id);
248
+ END LOOP;
249
+
250
+ PERFORM cron.schedule(
251
+ 'fayz-unit-address-geocode',
252
+ '10 seconds',
253
+ 'SELECT app.process_unit_address_geocodes()'
254
+ );
255
+ EXCEPTION WHEN OTHERS THEN
256
+ RAISE WARNING 'unit address geocoding scheduler was not installed: %', SQLERRM;
257
+ END;
258
+ $$;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "fayz": {
4
4
  "status": "beta"
5
5
  },
6
- "version": "0.20.0",
6
+ "version": "0.21.0",
7
7
  "description": "Fayz SDK database layer — Drizzle schema primitives, spine references, and migration helpers shared across plugins.",
8
8
  "type": "module",
9
9
  "sideEffects": false,