@fayz-ai/db 0.22.2 → 0.23.1

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,25 @@
1
+ -- The reach walk UNIONs (id, depth): a branch the person reaches directly AND
2
+ -- through its HQ came back twice, and every unit picker listed it twice.
3
+ -- Same body as 017, deduplicated on the way out.
4
+
5
+ CREATE OR REPLACE FUNCTION app.user_unit_ids_in(p_tenant uuid)
6
+ RETURNS SETOF uuid
7
+ LANGUAGE sql STABLE SECURITY DEFINER
8
+ SET search_path TO ''
9
+ AS $$
10
+ WITH RECURSIVE seed AS (
11
+ SELECT a.unit_id AS id, 0 AS depth
12
+ FROM app.user_unit_access a
13
+ WHERE a.user_id = (SELECT auth.uid())
14
+ AND a.tenant_id = p_tenant
15
+ ),
16
+ tree AS (
17
+ SELECT id, depth FROM seed
18
+ UNION
19
+ SELECT u.id, t.depth + 1
20
+ FROM app.units u
21
+ JOIN tree t ON u.parent_unit_id = t.id
22
+ WHERE u.tenant_id = p_tenant AND t.depth < 8
23
+ )
24
+ SELECT DISTINCT id FROM tree;
25
+ $$;
@@ -0,0 +1,87 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 174 — a conta de quem recebe é dado do financeiro.
3
+ --
4
+ -- Three permissive policies that let any member of a tenant do what the gated
5
+ -- policies next to them refuse. Postgres ORs permissive policies, so a
6
+ -- tenant-only policy beside an `app.has_permission` one wins every time.
7
+ --
8
+ -- · payee_accounts (149): bank/PIX of whoever the house pays. Any member,
9
+ -- viewer and SDR included, could rewrite where a payout lands. Now read
10
+ -- needs financial.read; insert/update financial.edit; delete
11
+ -- financial.delete (financial.manage satisfies all three).
12
+ -- · fulfillments: `fulfillments_member_all` (FOR ALL) shadowed the
13
+ -- fulfillments_authz_* write policies. The write side goes; a tenant-wide
14
+ -- SELECT stays so shop screens and plg_shop_rep_orders keep reading as today.
15
+ -- Every writer outside the authz policies is SECURITY DEFINER or the
16
+ -- service role.
17
+ -- · audit_logs: any member could insert any action, backdate it, stamp
18
+ -- before/after and pick a unit. The CRUD trail (admin stores/audit.ts) is
19
+ -- the only client writer and only writes `record.*`; server namespaces are
20
+ -- written by SECURITY DEFINER functions owned by postgres, which bypass RLS.
21
+ -- audit_log() leaves the client surface for the same reason.
22
+ --
23
+ -- Additive and replay-safe: DROP POLICY IF EXISTS + CREATE POLICY.
24
+ -- ---------------------------------------------------------------------------
25
+
26
+ -- ── payee_accounts ──────────────────────────────────────────────────────
27
+ DROP POLICY IF EXISTS payee_accounts_member_all ON public.payee_accounts;
28
+
29
+ DROP POLICY IF EXISTS payee_accounts_authz_select ON public.payee_accounts;
30
+ CREATE POLICY payee_accounts_authz_select ON public.payee_accounts
31
+ FOR SELECT TO authenticated
32
+ USING (tenant_id = (SELECT app.current_tenant_id())
33
+ AND (SELECT app.has_permission('financial.read')));
34
+
35
+ DROP POLICY IF EXISTS payee_accounts_authz_insert ON public.payee_accounts;
36
+ CREATE POLICY payee_accounts_authz_insert ON public.payee_accounts
37
+ FOR INSERT TO authenticated
38
+ WITH CHECK (tenant_id = (SELECT app.current_tenant_id())
39
+ AND (SELECT app.has_permission('financial.edit')));
40
+
41
+ -- trg_payee_accounts_one_default is SECURITY INVOKER: its demote-the-old-default
42
+ -- UPDATE runs under this policy too, which is why insert also asks for edit.
43
+ DROP POLICY IF EXISTS payee_accounts_authz_update ON public.payee_accounts;
44
+ CREATE POLICY payee_accounts_authz_update ON public.payee_accounts
45
+ FOR UPDATE TO authenticated
46
+ USING (tenant_id = (SELECT app.current_tenant_id())
47
+ AND (SELECT app.has_permission('financial.edit')))
48
+ WITH CHECK (tenant_id = (SELECT app.current_tenant_id())
49
+ AND (SELECT app.has_permission('financial.edit')));
50
+
51
+ DROP POLICY IF EXISTS payee_accounts_authz_delete ON public.payee_accounts;
52
+ CREATE POLICY payee_accounts_authz_delete ON public.payee_accounts
53
+ FOR DELETE TO authenticated
54
+ USING (tenant_id = (SELECT app.current_tenant_id())
55
+ AND (SELECT app.has_permission('financial.delete')));
56
+
57
+ -- ── fulfillments ────────────────────────────────────────────────────────
58
+ DROP POLICY IF EXISTS fulfillments_member_all ON public.fulfillments;
59
+
60
+ DROP POLICY IF EXISTS fulfillments_member_read ON public.fulfillments;
61
+ CREATE POLICY fulfillments_member_read ON public.fulfillments
62
+ FOR SELECT TO authenticated
63
+ USING (tenant_id = (SELECT app.current_tenant_id()));
64
+
65
+ -- ── audit_logs ──────────────────────────────────────────────────────────
66
+ -- Checked after the BEFORE trigger (handle_audit_actor), so user_id and
67
+ -- tenant_id are already the session's here.
68
+ DROP POLICY IF EXISTS audit_insert ON public.audit_logs;
69
+ CREATE POLICY audit_insert ON public.audit_logs
70
+ FOR INSERT TO authenticated
71
+ WITH CHECK (
72
+ tenant_id = (SELECT app.current_tenant_id())
73
+ AND user_id = (SELECT auth.uid())
74
+ AND action LIKE 'record.%'
75
+ AND before IS NULL
76
+ AND after IS NULL
77
+ AND (unit_id IS NULL OR (SELECT app.has_unit(unit_id)))
78
+ AND created_at BETWEEN now() - interval '1 minute' AND now() + interval '1 minute'
79
+ );
80
+
81
+ -- audit_log() is SECURITY DEFINER and takes any action, before/after and unit:
82
+ -- open to members it is the same forgery by another door. Nothing calls it
83
+ -- from a client or another function; server code keeps it via service_role.
84
+ REVOKE EXECUTE ON FUNCTION public.audit_log(text, text, uuid, text, jsonb, jsonb, uuid, jsonb, uuid)
85
+ FROM PUBLIC, anon, authenticated;
86
+ GRANT EXECUTE ON FUNCTION public.audit_log(text, text, uuid, text, jsonb, jsonb, uuid, jsonb, uuid)
87
+ TO service_role;
@@ -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;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "fayz": {
4
4
  "status": "beta"
5
5
  },
6
- "version": "0.22.2",
6
+ "version": "0.23.1",
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,