@fayz-ai/db 0.20.1 → 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,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