@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
@@ -3,7 +3,7 @@
3
3
  "fayz": {
4
4
  "status": "beta"
5
5
  },
6
- "version": "0.20.1",
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,