@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.
- package/migrations/173_cada_unidade_alcancada_uma_vez.sql +25 -0
- package/migrations/174_a_conta_de_quem_recebe_e_dado_do_financeiro.sql +87 -0
- 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/package.json +1 -1
|
@@ -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