@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.
- package/migrations/158_a_producao_e_do_restaurante_e_mora_no_estoque.sql +129 -0
- package/migrations/159_a_loja_tambem_confundia_o_que_vende_com_o_que_guarda.sql +98 -0
- package/migrations/161_vender_e_falar_com_quem_ainda_nao_comprou_sao_o_mesmo_assunto.sql +73 -0
- package/migrations/162_o_walter_liga_a_lista_da_vez.sql +71 -0
- package/migrations/164_checkin_e_confirmacoes_vieram_com_a_agenda.sql +29 -0
- package/migrations/165_unit_address_geocoding.sql +258 -0
- package/package.json +1 -1
|
@@ -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