@fayz-ai/db 0.20.1 → 0.22.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.
@@ -42,7 +42,27 @@ CREATE OR REPLACE VIEW public.v_team_members WITH (security_invoker='true') AS
42
42
  COMMENT ON VIEW public.v_team_members IS
43
43
  'Active memberships with the role KEY (#214). Drop-in successor to public.tenant_members: same columns, security_invoker, so app.memberships RLS decides the rows.';
44
44
 
45
- CREATE OR REPLACE VIEW public.v_units WITH (security_invoker='true') AS
45
+ -- ── replay: quem alargou a view depois é quem manda na forma dela ──────────
46
+ --
47
+ -- `CREATE OR REPLACE VIEW` sabe ACRESCENTAR coluna no fim e não sabe tirar. As
48
+ -- migrations 141, 142, 168 e 169 alargaram `v_units` depois desta; quando a
49
+ -- cadeia é aplicada do zero e depois REPLAYADA, este statement voltaria a
50
+ -- definir a versão ESTREITA e o Postgres recusa com «cannot drop columns from
51
+ -- view» — derrubando o replay inteiro aqui, no arquivo 4 de 141.
52
+ --
53
+ -- A view NASCE aqui e evolui adiante. Então aqui ela se cria uma vez só: se já
54
+ -- existe, a forma atual é de quem a alargou, e o replay não a desfaz. É o mesmo
55
+ -- raciocínio do backfill fora de ordem, que já custou view estreitada antes.
56
+ --
57
+ -- Em cluster que já aplicou este arquivo nada muda — o ledger não o roda de
58
+ -- novo. O que muda é que a cadeia passa a sobreviver ao próprio replay.
59
+ DO $v_units$
60
+ BEGIN
61
+ IF to_regclass('public.v_units') IS NOT NULL THEN
62
+ RETURN;
63
+ END IF;
64
+ EXECUTE $ddl$
65
+ CREATE VIEW public.v_units WITH (security_invoker='true') AS
46
66
  SELECT u.id,
47
67
  u.tenant_id,
48
68
  coalesce(u.metadata ->> 'legacy_kind', u.kind) AS kind,
@@ -79,6 +99,8 @@ CREATE OR REPLACE VIEW public.v_units WITH (security_invoker='true') AS
79
99
  CASE WHEN u.metadata ->> 'longitude' ~ '^-?[0-9]+(\.[0-9]+)?$'
80
100
  THEN (u.metadata ->> 'longitude')::numeric END AS longitude
81
101
  FROM app.units u;
102
+ $ddl$;
103
+ END $v_units$;
82
104
 
83
105
  COMMENT ON VIEW public.v_units IS
84
106
  'Units in the shape the units screen reads (#214). Drop-in successor to public.locations, deriving the same columns from app.units + metadata as app.compat_sync_unit did.';
@@ -0,0 +1,92 @@
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
+ -- ── por que as escritas do TENANT são condicionais ─────────────────────────
6
+ --
7
+ -- Três linhas aqui apontam para um tenant por uuid, e `tenant_apps`,
8
+ -- `tenant_plugins` e `tenant_nav` têm FK para `public.tenants`. Num banco que
9
+ -- nasce do zero — a cadeia do `db-from-zero`, o banco do bench — esse tenant
10
+ -- não existe, e a FK derrubava a CADEIA INTEIRA no arquivo 162: nada depois
11
+ -- dele chegava a rodar.
12
+ --
13
+ -- `WHERE EXISTS` não muda nada onde a conta existe: lá a linha entra igual. O
14
+ -- que ele faz é não exigir que todo banco do mundo contenha a conta do Walter
15
+ -- para que o resto do produto possa ser aplicado.
16
+ --
17
+ -- (O que é do PRODUTO — o preset do app, as permissões, os papéis-modelo —
18
+ -- segue incondicional, porque é verdade em qualquer banco.)
19
+
20
+ -- Walter's is a salon account. Keep Beauty active so app_config selects the
21
+ -- StudioControl rail even if the tenant also owns a back-office application.
22
+ INSERT INTO app.tenant_apps (tenant_id, app_id, status, source)
23
+ SELECT t.id, 'beauty', 'active', 'manual'
24
+ FROM public.tenants t
25
+ WHERE t.id = 'c3a8f61d-742b-4e05-9d16-8b47e2f0a5c3'
26
+ ON CONFLICT (tenant_id, app_id)
27
+ DO UPDATE SET status = 'active', source = 'manual', updated_at = now();
28
+
29
+ UPDATE public.tenants
30
+ SET vertical_id = 'beauty', updated_at = now()
31
+ WHERE id = 'c3a8f61d-742b-4e05-9d16-8b47e2f0a5c3'
32
+ AND vertical_id IS DISTINCT FROM 'beauty';
33
+
34
+ UPDATE app.apps
35
+ SET preset = jsonb_set(
36
+ preset,
37
+ '{modules}',
38
+ coalesce(preset -> 'modules', '[]'::jsonb) || '["agenda-turn-queue"]'::jsonb
39
+ )
40
+ WHERE id = 'beauty'
41
+ AND NOT (coalesce(preset -> 'modules', '[]'::jsonb) ? 'agenda-turn-queue');
42
+
43
+ SELECT app.seed_permissions($$[
44
+ {"key":"agenda.turn_queue.read","plugin":"agenda-turn-queue","description":"See the live turn queue and its audit trail"},
45
+ {"key":"agenda.turn_queue.create","plugin":"agenda-turn-queue","description":"Enter the turn queue"},
46
+ {"key":"agenda.turn_queue.edit","plugin":"agenda-turn-queue","description":"Enter or leave your own turn"},
47
+ {"key":"agenda.turn_queue.manage","plugin":"agenda-turn-queue","description":"Record front-desk exceptions in the turn queue"}
48
+ ]$$::jsonb);
49
+
50
+ SELECT app.seed_role_template_rows('salon', 'manager', 'Gerente', 1,
51
+ ARRAY['agenda.turn_queue.read','agenda.turn_queue.edit','agenda.turn_queue.manage']);
52
+ SELECT app.seed_role_template_rows('salon', 'reception', 'Recepção', 2,
53
+ ARRAY['agenda.turn_queue.read','agenda.turn_queue.manage']);
54
+ SELECT app.seed_role_template_rows('salon', 'professional', 'Profissional', 3,
55
+ ARRAY['agenda.turn_queue.read','agenda.turn_queue.edit']);
56
+ SELECT app.seed_role_template_rows('clinic', 'manager', 'Coordenação', 1,
57
+ ARRAY['agenda.turn_queue.read','agenda.turn_queue.edit','agenda.turn_queue.manage']);
58
+ SELECT app.seed_role_template_rows('clinic', 'reception', 'Recepção', 2,
59
+ ARRAY['agenda.turn_queue.read','agenda.turn_queue.manage']);
60
+ SELECT app.seed_role_template_rows('clinic', 'professional', 'Profissional', 3,
61
+ ARRAY['agenda.turn_queue.read','agenda.turn_queue.edit']);
62
+
63
+ INSERT INTO app.role_permissions (tenant_id, role_id, permission)
64
+ SELECT r.tenant_id, r.id, p.permission
65
+ FROM app.roles r
66
+ CROSS JOIN LATERAL (
67
+ SELECT unnest(CASE r.key
68
+ WHEN 'owner' THEN ARRAY['agenda.turn_queue.read','agenda.turn_queue.create','agenda.turn_queue.edit','agenda.turn_queue.manage']
69
+ WHEN 'manager' THEN ARRAY['agenda.turn_queue.read','agenda.turn_queue.edit','agenda.turn_queue.manage']
70
+ WHEN 'reception' THEN ARRAY['agenda.turn_queue.read','agenda.turn_queue.manage']
71
+ WHEN 'professional' THEN ARRAY['agenda.turn_queue.read','agenda.turn_queue.edit']
72
+ ELSE ARRAY[]::text[] END) AS permission
73
+ ) p
74
+ WHERE r.tenant_id = 'c3a8f61d-742b-4e05-9d16-8b47e2f0a5c3'
75
+ AND r.key IN ('owner','manager','reception','professional')
76
+ ON CONFLICT DO NOTHING;
77
+
78
+ INSERT INTO app.tenant_plugins (tenant_id, plugin_id, facet, status, source)
79
+ SELECT t.id, 'agenda-turn-queue', '', 'active', 'manual'
80
+ FROM public.tenants t
81
+ WHERE t.id = 'c3a8f61d-742b-4e05-9d16-8b47e2f0a5c3'
82
+ ON CONFLICT (tenant_id, plugin_id, facet)
83
+ DO UPDATE SET status = 'active', source = 'manual', updated_at = now();
84
+
85
+ -- Walter's must see the existing check-in entry even if the tenant previously
86
+ -- hid it while shaping its menu. The Beauty preset already groups it in Agenda.
87
+ INSERT INTO app.tenant_nav (tenant_id, entry_key, hidden)
88
+ SELECT t.id, '/checkin', false
89
+ FROM public.tenants t
90
+ WHERE t.id = 'c3a8f61d-742b-4e05-9d16-8b47e2f0a5c3'
91
+ ON CONFLICT (tenant_id, entry_key)
92
+ 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
+ $$;