@fayz-ai/db 0.22.3 → 0.23.2
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/187_a_liberacao_por_unidade_tambem_pergunta_uma_vez.sql +80 -0
- package/migrations/188_a_busca_de_pessoas_passa_pelo_indice.sql +126 -0
- package/migrations/189_aparece_na_agenda_atravessa_o_espelho.sql +117 -0
- package/migrations/195_quem_entra_como_outra_pessoa_deixa_rastro.sql +97 -0
- package/migrations/196_o_full_vira_gocontrol.sql +28 -0
- package/package.json +1 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
-- The unit release on people asks once per statement, also for the person who HAS a tuple.
|
|
2
|
+
--
|
|
3
|
+
-- 121 made "does this person have a release tuple?" a hashed SubPlan, which is cheap only while
|
|
4
|
+
-- few people have one: the expensive `available_in_my_units` ran just for them. On 2026-10-01 the
|
|
5
|
+
-- Espaço Facial got 130,091 `available_in` tuples (one per client, to the V1 home unit), so the
|
|
6
|
+
-- NOT IN now excludes almost everyone and the per-row resolution — every unit of the reader times
|
|
7
|
+
-- a recursive walk up the unit tree — runs ~120k times. Any people search timed out (57014 after
|
|
8
|
+
-- 8s), which left the agenda unable to find a client.
|
|
9
|
+
--
|
|
10
|
+
-- When the tenant has no `unavailable_in` tuple, the resolution collapses to a set question: the
|
|
11
|
+
-- person is visible iff an `available_in` tuple names me, one of my roles, or a unit on the chain
|
|
12
|
+
-- (self + ancestors) of one of the units I reach. Those three sets do not depend on the row, so
|
|
13
|
+
-- they become InitPlans and the tuple lookup a hashed SubPlan. With an `unavailable_in` anywhere
|
|
14
|
+
-- in the tenant the old resolution still answers, unchanged — nearest-ancestor deny needs it.
|
|
15
|
+
--
|
|
16
|
+
-- Same answer as `available_in_my_units` by construction (see its `direct`/`mine`/`verdicts`);
|
|
17
|
+
-- checked on the cluster by comparing both over the whole tenant before applying.
|
|
18
|
+
--
|
|
19
|
+
-- Idempotent.
|
|
20
|
+
|
|
21
|
+
CREATE OR REPLACE FUNCTION app.my_release_units()
|
|
22
|
+
RETURNS uuid[]
|
|
23
|
+
LANGUAGE sql
|
|
24
|
+
STABLE SECURITY DEFINER
|
|
25
|
+
SET search_path TO ''
|
|
26
|
+
AS $function$
|
|
27
|
+
-- `mine` of available_in_my_units, walked up: a tuple on any ancestor releases to me.
|
|
28
|
+
SELECT coalesce(array_agg(DISTINCT c.unit_id), '{}'::uuid[])
|
|
29
|
+
FROM app.user_unit_access a
|
|
30
|
+
CROSS JOIN LATERAL app.unit_chain(a.unit_id) c
|
|
31
|
+
WHERE a.tenant_id = app.current_tenant_id()
|
|
32
|
+
AND a.user_id = auth.uid()
|
|
33
|
+
AND (nullif(app.current_unit_id(), '00000000-0000-0000-0000-000000000000'::uuid) IS NULL
|
|
34
|
+
OR a.unit_id = nullif(app.current_unit_id(), '00000000-0000-0000-0000-000000000000'::uuid));
|
|
35
|
+
$function$;
|
|
36
|
+
|
|
37
|
+
CREATE OR REPLACE FUNCTION app.my_release_roles()
|
|
38
|
+
RETURNS uuid[]
|
|
39
|
+
LANGUAGE sql
|
|
40
|
+
STABLE SECURITY DEFINER
|
|
41
|
+
SET search_path TO ''
|
|
42
|
+
AS $function$
|
|
43
|
+
SELECT coalesce(array_agg(DISTINCT r), '{}'::uuid[]) FROM (
|
|
44
|
+
SELECT m.role_id AS r FROM app.memberships m
|
|
45
|
+
WHERE m.tenant_id = app.current_tenant_id() AND m.user_id = auth.uid() AND m.active
|
|
46
|
+
UNION
|
|
47
|
+
SELECT mu.role_override_id FROM app.membership_units mu
|
|
48
|
+
WHERE mu.tenant_id = app.current_tenant_id() AND mu.user_id = auth.uid()
|
|
49
|
+
AND mu.role_override_id IS NOT NULL
|
|
50
|
+
) x WHERE r IS NOT NULL;
|
|
51
|
+
$function$;
|
|
52
|
+
|
|
53
|
+
REVOKE ALL ON FUNCTION app.my_release_units() FROM PUBLIC, anon;
|
|
54
|
+
REVOKE ALL ON FUNCTION app.my_release_roles() FROM PUBLIC, anon;
|
|
55
|
+
GRANT EXECUTE ON FUNCTION app.my_release_units() TO authenticated, service_role;
|
|
56
|
+
GRANT EXECUTE ON FUNCTION app.my_release_roles() TO authenticated, service_role;
|
|
57
|
+
|
|
58
|
+
ALTER POLICY people_available_in ON public.people USING (
|
|
59
|
+
(NOT (SELECT app.grants_exist('people.person', ARRAY['available_in', 'unavailable_in'])))
|
|
60
|
+
OR (unit_id IS NOT NULL)
|
|
61
|
+
OR (people.id NOT IN (
|
|
62
|
+
SELECT g.resource_id
|
|
63
|
+
FROM app.resource_grants g
|
|
64
|
+
WHERE g.tenant_id = (SELECT app.current_tenant_id())
|
|
65
|
+
AND g.resource_type = 'people.person'
|
|
66
|
+
AND g.relation IN ('available_in', 'unavailable_in')
|
|
67
|
+
AND g.resource_id IS NOT NULL))
|
|
68
|
+
OR ((NOT (SELECT app.grants_exist('people.person', ARRAY['unavailable_in'])))
|
|
69
|
+
AND people.id IN (
|
|
70
|
+
SELECT g.resource_id
|
|
71
|
+
FROM app.resource_grants g
|
|
72
|
+
WHERE g.tenant_id = (SELECT app.current_tenant_id())
|
|
73
|
+
AND g.resource_type = 'people.person'
|
|
74
|
+
AND g.relation = 'available_in'
|
|
75
|
+
AND g.resource_id IS NOT NULL
|
|
76
|
+
AND ( (g.subject_type = 'unit' AND g.subject_id IN (SELECT unnest(app.my_release_units())))
|
|
77
|
+
OR (g.subject_type = 'user' AND g.subject_id = (SELECT auth.uid()))
|
|
78
|
+
OR (g.subject_type = 'role' AND g.subject_id IN (SELECT unnest(app.my_release_roles()))))))
|
|
79
|
+
OR ((SELECT app.grants_exist('people.person', ARRAY['unavailable_in']))
|
|
80
|
+
AND (SELECT app.available_in_my_units('people.person', people.id))));
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
-- People search finds candidates through the index first, then lets RLS judge only those.
|
|
2
|
+
--
|
|
3
|
+
-- `people?name=ilike.*x*` never uses the trigram index under RLS: `~~*` is not leakproof, so it
|
|
4
|
+
-- cannot move below the security barrier and every row of the tenant pays the policies before
|
|
5
|
+
-- the name is even looked at (121 says the same). With 120k people in the Espaço Facial a client
|
|
6
|
+
-- search took 1–8 s and often hit the 8 s statement_timeout; the agenda's company search over
|
|
7
|
+
-- `v_leads` (two LATERALs per row) always did.
|
|
8
|
+
--
|
|
9
|
+
-- The split: `app.people_match_ids` runs as definer, scoped to the caller's tenant, so the
|
|
10
|
+
-- trigram indexes answer which ids match; `public.people_search` runs as the caller and reads
|
|
11
|
+
-- those ids from `people` under its normal RLS. Only rows the caller may see come back — the
|
|
12
|
+
-- definer half never leaves the database (the `app` schema is not exposed by PostgREST).
|
|
13
|
+
--
|
|
14
|
+
-- It matches what the lookup matched before (name, phone digits, document) plus the CRM company
|
|
15
|
+
-- the person belongs to, which is what the agenda's second query over `v_leads` existed for.
|
|
16
|
+
--
|
|
17
|
+
-- Idempotent.
|
|
18
|
+
|
|
19
|
+
CREATE OR REPLACE FUNCTION app.people_match_ids(p_query text, p_kinds text[] DEFAULT NULL, p_cap integer DEFAULT 500)
|
|
20
|
+
RETURNS SETOF uuid
|
|
21
|
+
LANGUAGE plpgsql
|
|
22
|
+
STABLE SECURITY DEFINER
|
|
23
|
+
-- The default estimate (1000) makes the caller walk `people` in name order instead of joining.
|
|
24
|
+
ROWS 50
|
|
25
|
+
SET search_path TO ''
|
|
26
|
+
AS $function$
|
|
27
|
+
DECLARE
|
|
28
|
+
v_tenant uuid := app.current_tenant_id();
|
|
29
|
+
v_q text := btrim(coalesce(p_query, ''));
|
|
30
|
+
v_like text;
|
|
31
|
+
v_digits text := regexp_replace(coalesce(p_query, ''), '\D', '', 'g');
|
|
32
|
+
v_cap integer := least(greatest(coalesce(p_cap, 500), 1), 1000);
|
|
33
|
+
-- The unit release of 187, applied here too: with a unit in focus most matches are invisible,
|
|
34
|
+
-- and capping before dropping them would hide visible people behind invisible ones. Only a
|
|
35
|
+
-- narrowing — the caller's RLS still decides every row.
|
|
36
|
+
v_scoped boolean;
|
|
37
|
+
v_units uuid[];
|
|
38
|
+
v_roles uuid[];
|
|
39
|
+
v_uid uuid := auth.uid();
|
|
40
|
+
BEGIN
|
|
41
|
+
IF v_tenant IS NULL OR v_q = '' THEN
|
|
42
|
+
RETURN;
|
|
43
|
+
END IF;
|
|
44
|
+
v_like := '%' || replace(replace(replace(v_q, '\', '\\'), '%', '\%'), '_', '\_') || '%';
|
|
45
|
+
v_scoped := app.grants_exist('people.person', ARRAY['available_in', 'unavailable_in'])
|
|
46
|
+
AND NOT app.grants_exist('people.person', ARRAY['unavailable_in']);
|
|
47
|
+
IF v_scoped THEN
|
|
48
|
+
v_units := app.my_release_units();
|
|
49
|
+
v_roles := app.my_release_roles();
|
|
50
|
+
END IF;
|
|
51
|
+
|
|
52
|
+
-- One indexed branch per field (a single OR across them falls back to a sequential scan).
|
|
53
|
+
RETURN QUERY
|
|
54
|
+
SELECT p.id FROM public.people p
|
|
55
|
+
WHERE p.tenant_id = v_tenant AND p.is_active
|
|
56
|
+
AND (p_kinds IS NULL OR p.kind = ANY (p_kinds))
|
|
57
|
+
AND p.name ILIKE v_like
|
|
58
|
+
AND (NOT v_scoped OR p.unit_id IS NOT NULL
|
|
59
|
+
OR NOT EXISTS (SELECT 1 FROM app.resource_grants g
|
|
60
|
+
WHERE g.tenant_id = v_tenant AND g.resource_type = 'people.person' AND g.resource_id = p.id)
|
|
61
|
+
OR EXISTS (SELECT 1 FROM app.resource_grants g
|
|
62
|
+
WHERE g.tenant_id = v_tenant AND g.resource_type = 'people.person' AND g.resource_id = p.id
|
|
63
|
+
AND g.relation = 'available_in'
|
|
64
|
+
AND ( (g.subject_type = 'unit' AND g.subject_id = ANY (v_units))
|
|
65
|
+
OR (g.subject_type = 'user' AND g.subject_id = v_uid)
|
|
66
|
+
OR (g.subject_type = 'role' AND g.subject_id = ANY (v_roles)))))
|
|
67
|
+
LIMIT v_cap;
|
|
68
|
+
|
|
69
|
+
IF length(v_digits) >= 3 THEN
|
|
70
|
+
RETURN QUERY
|
|
71
|
+
SELECT p.id FROM public.people p
|
|
72
|
+
WHERE p.tenant_id = v_tenant AND p.is_active
|
|
73
|
+
AND (p_kinds IS NULL OR p.kind = ANY (p_kinds))
|
|
74
|
+
AND (p.phone_digits LIKE '%' || v_digits || '%' OR p.document_number LIKE '%' || v_digits || '%')
|
|
75
|
+
AND (NOT v_scoped OR p.unit_id IS NOT NULL
|
|
76
|
+
OR NOT EXISTS (SELECT 1 FROM app.resource_grants g
|
|
77
|
+
WHERE g.tenant_id = v_tenant AND g.resource_type = 'people.person' AND g.resource_id = p.id)
|
|
78
|
+
OR EXISTS (SELECT 1 FROM app.resource_grants g
|
|
79
|
+
WHERE g.tenant_id = v_tenant AND g.resource_type = 'people.person' AND g.resource_id = p.id
|
|
80
|
+
AND g.relation = 'available_in'
|
|
81
|
+
AND ( (g.subject_type = 'unit' AND g.subject_id = ANY (v_units))
|
|
82
|
+
OR (g.subject_type = 'user' AND g.subject_id = v_uid)
|
|
83
|
+
OR (g.subject_type = 'role' AND g.subject_id = ANY (v_roles)))))
|
|
84
|
+
LIMIT v_cap;
|
|
85
|
+
END IF;
|
|
86
|
+
|
|
87
|
+
-- The company a person is linked to, when the CRM tables exist in this pool.
|
|
88
|
+
IF to_regclass('public.plg_crm_company_people') IS NOT NULL
|
|
89
|
+
AND to_regclass('public.plg_crm_companies') IS NOT NULL THEN
|
|
90
|
+
RETURN QUERY EXECUTE
|
|
91
|
+
'SELECT DISTINCT cp.person_id
|
|
92
|
+
FROM public.plg_crm_company_people cp
|
|
93
|
+
JOIN public.plg_crm_companies c ON c.id = cp.company_id
|
|
94
|
+
JOIN public.people p ON p.id = cp.person_id
|
|
95
|
+
WHERE cp.tenant_id = $1 AND c.name ILIKE $2 AND p.is_active
|
|
96
|
+
AND ($3::text[] IS NULL OR p.kind = ANY ($3))
|
|
97
|
+
LIMIT $4'
|
|
98
|
+
USING v_tenant, v_like, p_kinds, v_cap;
|
|
99
|
+
END IF;
|
|
100
|
+
END
|
|
101
|
+
$function$;
|
|
102
|
+
|
|
103
|
+
REVOKE ALL ON FUNCTION app.people_match_ids(text, text[], integer) FROM PUBLIC, anon;
|
|
104
|
+
GRANT EXECUTE ON FUNCTION app.people_match_ids(text, text[], integer) TO authenticated, service_role;
|
|
105
|
+
|
|
106
|
+
CREATE OR REPLACE FUNCTION public.people_search(p_query text, p_kinds text[] DEFAULT NULL, p_limit integer DEFAULT 20)
|
|
107
|
+
RETURNS SETOF public.people
|
|
108
|
+
LANGUAGE sql
|
|
109
|
+
STABLE SECURITY INVOKER
|
|
110
|
+
SET search_path TO ''
|
|
111
|
+
AS $function$
|
|
112
|
+
WITH m AS MATERIALIZED (
|
|
113
|
+
SELECT DISTINCT id FROM app.people_match_ids(p_query, p_kinds, 500) AS id
|
|
114
|
+
)
|
|
115
|
+
SELECT p.*
|
|
116
|
+
FROM m
|
|
117
|
+
JOIN public.people p ON p.id = m.id
|
|
118
|
+
ORDER BY p.name
|
|
119
|
+
LIMIT least(greatest(coalesce(p_limit, 20), 1), 100);
|
|
120
|
+
$function$;
|
|
121
|
+
|
|
122
|
+
REVOKE ALL ON FUNCTION public.people_search(text, text[], integer) FROM PUBLIC, anon;
|
|
123
|
+
GRANT EXECUTE ON FUNCTION public.people_search(text, text[], integer) TO authenticated, service_role;
|
|
124
|
+
|
|
125
|
+
COMMENT ON FUNCTION public.people_search(text, text[], integer) IS
|
|
126
|
+
'Person search for pickers: candidates by index (app.people_match_ids, definer, tenant-scoped), rows under the caller''s RLS (188).';
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
-- "Aparece na agenda" crosses the services ↔ products mirror.
|
|
2
|
+
--
|
|
3
|
+
-- 120 gave both tables `is_bookable`; the two mirror triggers (archive 085/191) predate it and copy
|
|
4
|
+
-- every other column but this one. The Serviços screen writes `services`, the agenda reads
|
|
5
|
+
-- `products`: switching "Aparece na agenda" on the screen never reached the agenda, and a flag
|
|
6
|
+
-- changed on `products` was shown wrong on the screen (FAY-1613, Espaço Facial: six services
|
|
7
|
+
-- unbookable in products, bookable in services).
|
|
8
|
+
--
|
|
9
|
+
-- Same bodies, plus `is_bookable`. The services side only exists in pools where `services` is still
|
|
10
|
+
-- a table; there the column exists since 120.
|
|
11
|
+
--
|
|
12
|
+
-- Idempotent.
|
|
13
|
+
|
|
14
|
+
-- Only where the mirror still runs. Pools that took archive 191 have no
|
|
15
|
+
-- `public.services` table and no triggers calling these; creating the bodies
|
|
16
|
+
-- there would bring back two orphan functions the retirement removed (the
|
|
17
|
+
-- catalog suite asserts they are gone). Same condition as the backfill below.
|
|
18
|
+
DO $mirror$
|
|
19
|
+
BEGIN
|
|
20
|
+
IF to_regclass('public.services') IS NULL
|
|
21
|
+
OR (SELECT relkind FROM pg_class WHERE oid = to_regclass('public.services')) <> 'r' THEN
|
|
22
|
+
RETURN;
|
|
23
|
+
END IF;
|
|
24
|
+
|
|
25
|
+
EXECUTE $fn$
|
|
26
|
+
CREATE OR REPLACE FUNCTION app.trg_catalog_products_mirror()
|
|
27
|
+
RETURNS trigger
|
|
28
|
+
LANGUAGE plpgsql
|
|
29
|
+
SECURITY DEFINER
|
|
30
|
+
SET search_path TO ''
|
|
31
|
+
AS $function$
|
|
32
|
+
BEGIN
|
|
33
|
+
IF coalesce(current_setting('fayz.catalog_sync', true), '') = 'on' THEN
|
|
34
|
+
RETURN NULL;
|
|
35
|
+
END IF;
|
|
36
|
+
PERFORM set_config('fayz.catalog_sync', 'on', true);
|
|
37
|
+
IF TG_OP = 'DELETE' OR (TG_OP = 'UPDATE' AND NEW.kind <> 'service') THEN
|
|
38
|
+
DELETE FROM public.services s WHERE s.id = OLD.id;
|
|
39
|
+
END IF;
|
|
40
|
+
IF TG_OP IN ('INSERT', 'UPDATE') AND NEW.kind = 'service' THEN
|
|
41
|
+
INSERT INTO public.services (
|
|
42
|
+
id, tenant_id, category_id, name, description, price, cost, currency, duration_minutes, image_url,
|
|
43
|
+
status, is_active, tags, metadata, custom_fields, created_by, created_at, updated_at, unit_id, owner_id,
|
|
44
|
+
is_bookable
|
|
45
|
+
) VALUES (
|
|
46
|
+
NEW.id, NEW.tenant_id, NEW.category_id, NEW.name, NEW.description, NEW.price, NEW.cost, NEW.currency, NEW.duration_minutes, NEW.image_url,
|
|
47
|
+
NEW.status, NEW.is_active, NEW.tags, NEW.metadata, NEW.custom_fields, NEW.created_by, NEW.created_at, NEW.updated_at, NEW.unit_id, NEW.owner_id,
|
|
48
|
+
NEW.is_bookable
|
|
49
|
+
)
|
|
50
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
51
|
+
tenant_id = EXCLUDED.tenant_id, category_id = EXCLUDED.category_id, name = EXCLUDED.name,
|
|
52
|
+
description = EXCLUDED.description, price = EXCLUDED.price, cost = EXCLUDED.cost, currency = EXCLUDED.currency,
|
|
53
|
+
duration_minutes = EXCLUDED.duration_minutes, image_url = EXCLUDED.image_url, status = EXCLUDED.status,
|
|
54
|
+
is_active = EXCLUDED.is_active, tags = EXCLUDED.tags, metadata = EXCLUDED.metadata, custom_fields = EXCLUDED.custom_fields,
|
|
55
|
+
created_by = EXCLUDED.created_by, created_at = EXCLUDED.created_at, updated_at = EXCLUDED.updated_at,
|
|
56
|
+
unit_id = EXCLUDED.unit_id, owner_id = EXCLUDED.owner_id, is_bookable = EXCLUDED.is_bookable;
|
|
57
|
+
END IF;
|
|
58
|
+
PERFORM set_config('fayz.catalog_sync', '', true);
|
|
59
|
+
RETURN NULL;
|
|
60
|
+
END $function$
|
|
61
|
+
$fn$;
|
|
62
|
+
|
|
63
|
+
EXECUTE $fn$
|
|
64
|
+
CREATE OR REPLACE FUNCTION app.trg_catalog_services_forward()
|
|
65
|
+
RETURNS trigger
|
|
66
|
+
LANGUAGE plpgsql
|
|
67
|
+
SECURITY DEFINER
|
|
68
|
+
SET search_path TO ''
|
|
69
|
+
AS $function$
|
|
70
|
+
BEGIN
|
|
71
|
+
IF coalesce(current_setting('fayz.catalog_sync', true), '') = 'on' THEN
|
|
72
|
+
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
73
|
+
END IF;
|
|
74
|
+
IF TG_OP = 'UPDATE' AND NEW.id IS DISTINCT FROM OLD.id THEN
|
|
75
|
+
RAISE EXCEPTION 'services: changing the id of a service is not supported (compat mirror)' USING ERRCODE = '0A000';
|
|
76
|
+
END IF;
|
|
77
|
+
PERFORM set_config('fayz.catalog_sync', 'on', true);
|
|
78
|
+
IF TG_OP = 'DELETE' THEN
|
|
79
|
+
DELETE FROM public.products p WHERE p.id = OLD.id;
|
|
80
|
+
ELSE
|
|
81
|
+
INSERT INTO public.products (
|
|
82
|
+
id, tenant_id, category_id, name, description, price, cost, currency, image_url,
|
|
83
|
+
status, is_active, tags, metadata, custom_fields, created_by, created_at, updated_at,
|
|
84
|
+
unit_id, owner_id, kind, duration_minutes, is_bookable
|
|
85
|
+
) VALUES (
|
|
86
|
+
NEW.id, NEW.tenant_id, NEW.category_id, NEW.name, NEW.description, NEW.price, NEW.cost, NEW.currency, NEW.image_url,
|
|
87
|
+
NEW.status, NEW.is_active, NEW.tags, NEW.metadata, NEW.custom_fields, NEW.created_by, NEW.created_at, NEW.updated_at,
|
|
88
|
+
NEW.unit_id, NEW.owner_id, 'service', NEW.duration_minutes, coalesce(NEW.is_bookable, true)
|
|
89
|
+
)
|
|
90
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
91
|
+
tenant_id = EXCLUDED.tenant_id, category_id = EXCLUDED.category_id, name = EXCLUDED.name,
|
|
92
|
+
description = EXCLUDED.description, price = EXCLUDED.price, cost = EXCLUDED.cost, currency = EXCLUDED.currency,
|
|
93
|
+
image_url = EXCLUDED.image_url, status = EXCLUDED.status, is_active = EXCLUDED.is_active, tags = EXCLUDED.tags,
|
|
94
|
+
metadata = EXCLUDED.metadata, custom_fields = EXCLUDED.custom_fields, created_by = EXCLUDED.created_by,
|
|
95
|
+
updated_at = EXCLUDED.updated_at, unit_id = EXCLUDED.unit_id, owner_id = EXCLUDED.owner_id,
|
|
96
|
+
kind = 'service', duration_minutes = EXCLUDED.duration_minutes, is_bookable = EXCLUDED.is_bookable;
|
|
97
|
+
END IF;
|
|
98
|
+
PERFORM set_config('fayz.catalog_sync', '', true);
|
|
99
|
+
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
100
|
+
END $function$
|
|
101
|
+
$fn$;
|
|
102
|
+
END $mirror$;
|
|
103
|
+
|
|
104
|
+
-- Rows that drifted before this: products is what the agenda reads, so it wins.
|
|
105
|
+
DO $$
|
|
106
|
+
BEGIN
|
|
107
|
+
IF to_regclass('public.services') IS NOT NULL
|
|
108
|
+
AND (SELECT relkind FROM pg_class WHERE oid = to_regclass('public.services')) = 'r'
|
|
109
|
+
AND EXISTS (SELECT 1 FROM information_schema.columns
|
|
110
|
+
WHERE table_schema = 'public' AND table_name = 'services' AND column_name = 'is_bookable') THEN
|
|
111
|
+
PERFORM set_config('fayz.catalog_sync', 'on', true);
|
|
112
|
+
UPDATE public.services s SET is_bookable = p.is_bookable
|
|
113
|
+
FROM public.products p
|
|
114
|
+
WHERE p.id = s.id AND s.is_bookable IS DISTINCT FROM p.is_bookable;
|
|
115
|
+
PERFORM set_config('fayz.catalog_sync', '', true);
|
|
116
|
+
END IF;
|
|
117
|
+
END $$;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
-- Authorises "view as this person" and writes the trail before any session is
|
|
2
|
+
-- minted. Called only by the platform-admin edge function (service_role), which
|
|
3
|
+
-- passes the verified actor: auth.uid() is null under the service key.
|
|
4
|
+
--
|
|
5
|
+
-- Two ways in:
|
|
6
|
+
-- * a tenant admin (an active membership holding members.manage) entering as
|
|
7
|
+
-- someone in the SAME tenant;
|
|
8
|
+
-- * platform staff with platform.can(actor, 'impersonate.user'), which always
|
|
9
|
+
-- requires a written reason.
|
|
10
|
+
-- Never as oneself, never as an owner, never as someone without a login.
|
|
11
|
+
--
|
|
12
|
+
-- The platform schema is applied outside this chain, so its functions are
|
|
13
|
+
-- reached dynamically: on a pool without it, only the tenant-admin path exists.
|
|
14
|
+
|
|
15
|
+
CREATE OR REPLACE FUNCTION public.impersonation_authorize(
|
|
16
|
+
p_actor uuid,
|
|
17
|
+
p_tenant uuid,
|
|
18
|
+
p_target uuid,
|
|
19
|
+
p_reason text DEFAULT NULL
|
|
20
|
+
) RETURNS jsonb
|
|
21
|
+
LANGUAGE plpgsql
|
|
22
|
+
SECURITY DEFINER
|
|
23
|
+
SET search_path TO ''
|
|
24
|
+
AS $$
|
|
25
|
+
DECLARE
|
|
26
|
+
v_reason text := nullif(btrim(coalesce(p_reason, '')), '');
|
|
27
|
+
v_is_admin boolean;
|
|
28
|
+
v_is_staff boolean;
|
|
29
|
+
v_role_key text;
|
|
30
|
+
v_role text;
|
|
31
|
+
v_email text;
|
|
32
|
+
v_name text;
|
|
33
|
+
v_action text;
|
|
34
|
+
BEGIN
|
|
35
|
+
IF p_actor IS NULL OR p_tenant IS NULL OR p_target IS NULL THEN
|
|
36
|
+
RAISE EXCEPTION 'impersonation_authorize: actor, tenant and target are required' USING ERRCODE = '22023';
|
|
37
|
+
END IF;
|
|
38
|
+
IF p_actor = p_target THEN
|
|
39
|
+
RAISE EXCEPTION 'impersonation_authorize: you are already this person' USING ERRCODE = '22023';
|
|
40
|
+
END IF;
|
|
41
|
+
|
|
42
|
+
SELECT EXISTS (
|
|
43
|
+
SELECT 1 FROM app.memberships m
|
|
44
|
+
JOIN app.role_permissions rp ON rp.role_id = m.role_id AND rp.tenant_id = m.tenant_id
|
|
45
|
+
WHERE m.tenant_id = p_tenant AND m.user_id = p_actor AND m.active
|
|
46
|
+
AND rp.permission = 'members.manage'
|
|
47
|
+
) INTO v_is_admin;
|
|
48
|
+
v_is_staff := false;
|
|
49
|
+
IF to_regprocedure('platform.can(uuid,text)') IS NOT NULL THEN
|
|
50
|
+
EXECUTE 'SELECT platform.can($1, $2)' INTO v_is_staff USING p_actor, 'impersonate.user';
|
|
51
|
+
END IF;
|
|
52
|
+
|
|
53
|
+
IF NOT v_is_admin AND NOT v_is_staff THEN
|
|
54
|
+
RAISE EXCEPTION 'impersonation_authorize: not allowed' USING ERRCODE = '42501';
|
|
55
|
+
END IF;
|
|
56
|
+
IF NOT v_is_admin AND v_reason IS NULL THEN
|
|
57
|
+
RAISE EXCEPTION 'impersonation_authorize: informe o motivo' USING ERRCODE = '23514';
|
|
58
|
+
END IF;
|
|
59
|
+
|
|
60
|
+
SELECT r.key, r.name INTO v_role_key, v_role
|
|
61
|
+
FROM app.memberships m JOIN app.roles r ON r.id = m.role_id
|
|
62
|
+
WHERE m.tenant_id = p_tenant AND m.user_id = p_target AND m.active;
|
|
63
|
+
IF v_role_key IS NULL THEN
|
|
64
|
+
RAISE EXCEPTION 'impersonation_authorize: essa pessoa não tem acesso ativo a esta conta' USING ERRCODE = 'P0002';
|
|
65
|
+
END IF;
|
|
66
|
+
IF v_role_key = 'owner' THEN
|
|
67
|
+
RAISE EXCEPTION 'impersonation_authorize: não é possível entrar como o proprietário' USING ERRCODE = '42501';
|
|
68
|
+
END IF;
|
|
69
|
+
|
|
70
|
+
SELECT u.email, coalesce(nullif(u.raw_user_meta_data ->> 'full_name', ''), u.email)
|
|
71
|
+
INTO v_email, v_name
|
|
72
|
+
FROM auth.users u WHERE u.id = p_target;
|
|
73
|
+
IF v_email IS NULL THEN
|
|
74
|
+
RAISE EXCEPTION 'impersonation_authorize: essa pessoa não tem login' USING ERRCODE = 'P0002';
|
|
75
|
+
END IF;
|
|
76
|
+
|
|
77
|
+
v_action := CASE WHEN v_is_admin THEN 'impersonate.member' ELSE 'impersonate.user' END;
|
|
78
|
+
|
|
79
|
+
IF to_regprocedure('platform.audit_write(uuid,text,uuid,text,text,jsonb)') IS NOT NULL THEN
|
|
80
|
+
EXECUTE 'SELECT platform.audit_write($1, $2, $3, $4, $5, $6)'
|
|
81
|
+
USING p_actor, v_action, p_tenant, v_email, v_reason,
|
|
82
|
+
jsonb_build_object('target_user', p_target, 'role', v_role_key);
|
|
83
|
+
END IF;
|
|
84
|
+
|
|
85
|
+
-- The tenant's own trail, so the account owner sees who entered as whom.
|
|
86
|
+
INSERT INTO public.audit_logs (id, tenant_id, user_id, action, entity_type, entity_id, metadata, reason, created_at)
|
|
87
|
+
VALUES (gen_random_uuid(), p_tenant, p_actor, v_action, 'user', p_target::text,
|
|
88
|
+
jsonb_build_object('target_email', v_email, 'target_name', v_name, 'role', v_role_key,
|
|
89
|
+
'by_platform_staff', NOT v_is_admin),
|
|
90
|
+
v_reason, now());
|
|
91
|
+
|
|
92
|
+
RETURN jsonb_build_object('user_id', p_target, 'email', v_email, 'name', v_name,
|
|
93
|
+
'role', v_role, 'role_key', v_role_key, 'action', v_action);
|
|
94
|
+
END $$;
|
|
95
|
+
|
|
96
|
+
REVOKE ALL ON FUNCTION public.impersonation_authorize(uuid, uuid, uuid, text) FROM PUBLIC, anon, authenticated;
|
|
97
|
+
GRANT EXECUTE ON FUNCTION public.impersonation_authorize(uuid, uuid, uuid, text) TO service_role;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 196 — FullControl becomes GoControl
|
|
3
|
+
--
|
|
4
|
+
-- The general-purpose ERP of the Control family (app id `full`) ships under a
|
|
5
|
+
-- new name, on its own domain: https://gocontrol.app. The app's own bundle
|
|
6
|
+
-- (FayaLabs/control-app) carries the new wordmark; this row is what every OTHER
|
|
7
|
+
-- surface reads — the switcher between a tenant's apps, the module catalog's
|
|
8
|
+
-- "Extends: …" line, the theme catalog.
|
|
9
|
+
--
|
|
10
|
+
-- The url also moves. It pointed at the `full-control` Worker, a build nobody
|
|
11
|
+
-- has deployed since 2026-09-20, so the switcher sent people to a version three
|
|
12
|
+
-- weeks old. gocontrol.app serves what control-app's CI ships.
|
|
13
|
+
--
|
|
14
|
+
-- The id stays `full`: it keys tenant_apps, presets and every grant, and a
|
|
15
|
+
-- product rename is not a reason to move a primary key.
|
|
16
|
+
--
|
|
17
|
+
-- Idempotent.
|
|
18
|
+
-- ============================================================================
|
|
19
|
+
|
|
20
|
+
UPDATE app.apps
|
|
21
|
+
SET name = 'GoControl',
|
|
22
|
+
url = 'https://gocontrol.app',
|
|
23
|
+
theme = CASE WHEN theme ? 'name' THEN jsonb_set(theme, '{name}', '"GoControl"') ELSE theme END,
|
|
24
|
+
updated_at = now()
|
|
25
|
+
WHERE id = 'full'
|
|
26
|
+
AND (name IS DISTINCT FROM 'GoControl'
|
|
27
|
+
OR url IS DISTINCT FROM 'https://gocontrol.app'
|
|
28
|
+
OR theme ->> 'name' IS DISTINCT FROM 'GoControl');
|
package/package.json
CHANGED