@fayz-ai/db 0.22.3 → 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,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.3",
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,