@fayz-ai/db 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/migrations/037_sync_tick_one_at_a_time.sql +256 -0
- package/migrations/038_onboarding_responses.sql +103 -0
- package/migrations/039_unit_tree.sql +270 -0
- package/migrations/040_resource_grants.sql +474 -0
- package/migrations/041_scoped_columns.sql +192 -0
- package/migrations/042_unit_scope_policies.sql +145 -0
- package/migrations/043_view_invoker.sql +81 -0
- package/migrations/044_unit_member_facts.sql +47 -0
- package/migrations/045_unit_entry.sql +236 -0
- package/migrations/046_membership_visible_to_members.sql +85 -0
- package/migrations/047_tasks.sql +266 -0
- package/migrations/048_every_login_is_a_person.sql +190 -0
- package/migrations/049_bookable_people.sql +126 -0
- package/package.json +4 -4
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 037_sync_tick_one_at_a_time.sql — a connection with a dispatch in flight is
|
|
3
|
+
-- not due.
|
|
4
|
+
--
|
|
5
|
+
-- 033 selects what to dispatch by ONE test: `next_attempt_at <= now()`. It never
|
|
6
|
+
-- asks whether the previous dispatch came back. In the ordinary case that is
|
|
7
|
+
-- invisible — the advance pushes the row a whole interval into the future in the
|
|
8
|
+
-- same statement that dispatches it, so nothing is due again for an hour.
|
|
9
|
+
--
|
|
10
|
+
-- It stops being invisible the moment anything else writes `next_attempt_at`.
|
|
11
|
+
-- The shop's wake trigger (packages/shop 0064) sets it to `now()` whenever an
|
|
12
|
+
-- order, a customer or a product changes — which is exactly what a catalogue
|
|
13
|
+
-- pull DOES, by the thousand, while it is still running. Measured on the
|
|
14
|
+
-- ecommerce pool: bling-sync dispatched, pulled, wrote products, each write
|
|
15
|
+
-- moved its own next attempt to now(), the next tick dispatched it again on top
|
|
16
|
+
-- of itself, and the merchant's history filled with an hourly connector running
|
|
17
|
+
-- every sixty seconds. The cadence was never wrong; nothing enforced it.
|
|
18
|
+
--
|
|
19
|
+
-- ── The guard ────────────────────────────────────────────────────────────────
|
|
20
|
+
-- `last_run_id IS NOT NULL` already means "dispatched, no verdict yet" — the
|
|
21
|
+
-- fold and the reaper both key off it. This file makes the selection honour the
|
|
22
|
+
-- same fact: one dispatch per connection per kind at a time, and the reaper
|
|
23
|
+
-- (timeout_seconds) is what ends it if a function never answers. A wake that
|
|
24
|
+
-- lands mid-run is not lost, it is served by the tick after the verdict.
|
|
25
|
+
--
|
|
26
|
+
-- This is a ceiling nobody could opt out of, which is the point: rate limits,
|
|
27
|
+
-- the provider's daily quota and the connector's own cursor all assume its
|
|
28
|
+
-- previous call finished. `max_per_tick` bounds a connector across connections;
|
|
29
|
+
-- nothing bounded ONE connection against itself.
|
|
30
|
+
--
|
|
31
|
+
-- ── And the orphan ───────────────────────────────────────────────────────────
|
|
32
|
+
-- A guard on a pointer needs the pointer to be honest. If the run a schedule
|
|
33
|
+
-- points at no longer exists, the fold cannot fold it and the reaper cannot reap
|
|
34
|
+
-- it, and under the new guard that connection would never be dispatched again.
|
|
35
|
+
-- Cleared before the fold, so a vanished run costs one tick instead of forever.
|
|
36
|
+
--
|
|
37
|
+
-- CREATE OR REPLACE of plg_sync_tick, and nothing else: no table, no grant, no
|
|
38
|
+
-- cron job changes. Idempotent, and safe to re-run — `fayz db apply` re-applies
|
|
39
|
+
-- a file whose checksum moved.
|
|
40
|
+
-- ============================================================================
|
|
41
|
+
|
|
42
|
+
CREATE OR REPLACE FUNCTION public.plg_sync_tick(p_kind text)
|
|
43
|
+
RETURNS integer
|
|
44
|
+
LANGUAGE plpgsql
|
|
45
|
+
SECURITY DEFINER
|
|
46
|
+
SET search_path = public, extensions, net, vault
|
|
47
|
+
AS $fn$
|
|
48
|
+
DECLARE
|
|
49
|
+
v_base text;
|
|
50
|
+
v_secret text;
|
|
51
|
+
v_have_net boolean;
|
|
52
|
+
v_row record;
|
|
53
|
+
v_run_id uuid;
|
|
54
|
+
v_next timestamptz;
|
|
55
|
+
v_expires timestamptz;
|
|
56
|
+
v_offset integer;
|
|
57
|
+
v_dispatched integer := 0;
|
|
58
|
+
v_reason text;
|
|
59
|
+
BEGIN
|
|
60
|
+
IF p_kind NOT IN ('reconcile', 'renew') THEN
|
|
61
|
+
RAISE EXCEPTION 'plg_sync_tick: unknown kind %', p_kind;
|
|
62
|
+
END IF;
|
|
63
|
+
|
|
64
|
+
IF NOT pg_try_advisory_xact_lock(hashtext('plg_sync_tick:' || p_kind)) THEN
|
|
65
|
+
RETURN 0;
|
|
66
|
+
END IF;
|
|
67
|
+
|
|
68
|
+
v_have_net := to_regprocedure('net.http_post(text, jsonb, jsonb, jsonb, integer)') IS NOT NULL;
|
|
69
|
+
|
|
70
|
+
BEGIN
|
|
71
|
+
SELECT decrypted_secret INTO v_base
|
|
72
|
+
FROM vault.decrypted_secrets WHERE name = 'fayz_functions_url'
|
|
73
|
+
ORDER BY created_at DESC LIMIT 1;
|
|
74
|
+
SELECT decrypted_secret INTO v_secret
|
|
75
|
+
FROM vault.decrypted_secrets WHERE name = 'fayz_scheduler_secret'
|
|
76
|
+
ORDER BY created_at DESC LIMIT 1;
|
|
77
|
+
EXCEPTION WHEN OTHERS THEN
|
|
78
|
+
v_base := NULL;
|
|
79
|
+
v_secret := NULL;
|
|
80
|
+
END;
|
|
81
|
+
|
|
82
|
+
-- ── orphan ────────────────────────────────────────────────────────────────
|
|
83
|
+
-- Before anything reads `last_run_id` as "in flight": a pointer at a run that
|
|
84
|
+
-- no longer exists is not a dispatch in flight, it is a dead end that would
|
|
85
|
+
-- park this connection permanently.
|
|
86
|
+
UPDATE public.plg_sync_schedule s
|
|
87
|
+
SET last_run_id = NULL
|
|
88
|
+
WHERE s.kind = p_kind
|
|
89
|
+
AND s.last_run_id IS NOT NULL
|
|
90
|
+
AND NOT EXISTS (SELECT 1 FROM public.plg_sync_runs r WHERE r.id = s.last_run_id);
|
|
91
|
+
|
|
92
|
+
-- ── reap ──────────────────────────────────────────────────────────────────
|
|
93
|
+
UPDATE public.plg_sync_runs r
|
|
94
|
+
SET status = 'error',
|
|
95
|
+
finished_at = now(),
|
|
96
|
+
error = COALESCE(r.error, 'the connector function returned no verdict within the schedule timeout')
|
|
97
|
+
FROM public.plg_sync_schedule s
|
|
98
|
+
JOIN public.plg_connections c ON c.id = s.connection_id
|
|
99
|
+
JOIN public.plg_connector_schedules d
|
|
100
|
+
ON d.connector_id = c.connector_id AND d.kind = s.kind
|
|
101
|
+
WHERE s.kind = p_kind
|
|
102
|
+
AND r.id = s.last_run_id
|
|
103
|
+
AND r.status = 'running'
|
|
104
|
+
AND r.started_at < now() - make_interval(secs => d.timeout_seconds);
|
|
105
|
+
|
|
106
|
+
-- ── fold ──────────────────────────────────────────────────────────────────
|
|
107
|
+
UPDATE public.plg_sync_schedule s
|
|
108
|
+
SET failures = CASE WHEN r.status IN ('success', 'partial') THEN 0 ELSE s.failures + 1 END,
|
|
109
|
+
last_run_id = NULL,
|
|
110
|
+
next_attempt_at = CASE
|
|
111
|
+
WHEN r.status IN ('success', 'partial') THEN s.next_attempt_at
|
|
112
|
+
ELSE GREATEST(
|
|
113
|
+
s.next_attempt_at,
|
|
114
|
+
now() + make_interval(secs => LEAST(
|
|
115
|
+
d.interval_seconds::bigint * (2 ^ LEAST(s.failures + 1, 6))::bigint,
|
|
116
|
+
86400)::double precision)
|
|
117
|
+
)
|
|
118
|
+
END
|
|
119
|
+
FROM public.plg_sync_runs r,
|
|
120
|
+
public.plg_connections c,
|
|
121
|
+
public.plg_connector_schedules d
|
|
122
|
+
WHERE s.kind = p_kind
|
|
123
|
+
AND r.id = s.last_run_id
|
|
124
|
+
AND r.finished_at IS NOT NULL
|
|
125
|
+
AND c.id = s.connection_id
|
|
126
|
+
AND d.connector_id = c.connector_id
|
|
127
|
+
AND d.kind = s.kind;
|
|
128
|
+
|
|
129
|
+
-- ── enroll ────────────────────────────────────────────────────────────────
|
|
130
|
+
INSERT INTO public.plg_sync_schedule (connection_id, kind, next_attempt_at)
|
|
131
|
+
SELECT q.id, p_kind,
|
|
132
|
+
now() + make_interval(secs => mod(abs(hashtext(q.id::text)::bigint), GREATEST(q.jitter_seconds, 1))::double precision)
|
|
133
|
+
FROM (
|
|
134
|
+
SELECT c.id, d.jitter_seconds
|
|
135
|
+
FROM public.plg_connections c
|
|
136
|
+
JOIN public.plg_connector_schedules d
|
|
137
|
+
ON d.connector_id = c.connector_id AND d.kind = p_kind AND d.enabled
|
|
138
|
+
LEFT JOIN public.plg_sync_schedule s
|
|
139
|
+
ON s.connection_id = c.id AND s.kind = p_kind
|
|
140
|
+
WHERE s.connection_id IS NULL
|
|
141
|
+
AND c.active
|
|
142
|
+
LIMIT 500
|
|
143
|
+
) q
|
|
144
|
+
ON CONFLICT (connection_id, kind) DO NOTHING;
|
|
145
|
+
|
|
146
|
+
-- ── dispatch ──────────────────────────────────────────────────────────────
|
|
147
|
+
FOR v_row IN
|
|
148
|
+
SELECT *
|
|
149
|
+
FROM (
|
|
150
|
+
SELECT s.connection_id, s.failures, c.tenant_id, c.connector_id, c.provider_state,
|
|
151
|
+
d.function_name, d.action, d.interval_seconds, d.jitter_seconds,
|
|
152
|
+
d.floor_seconds, d.lead_seconds, d.timeout_seconds, d.max_per_tick,
|
|
153
|
+
row_number() OVER (PARTITION BY c.connector_id ORDER BY s.next_attempt_at, s.connection_id) AS rn
|
|
154
|
+
FROM (
|
|
155
|
+
SELECT s2.*
|
|
156
|
+
FROM public.plg_sync_schedule s2
|
|
157
|
+
WHERE s2.kind = p_kind AND s2.next_attempt_at <= now()
|
|
158
|
+
-- THE GUARD. Due is not enough: a connection whose last dispatch
|
|
159
|
+
-- has no verdict is already being served. Cleared by the fold on
|
|
160
|
+
-- a verdict, or by the reaper at timeout_seconds.
|
|
161
|
+
AND s2.last_run_id IS NULL
|
|
162
|
+
ORDER BY s2.next_attempt_at
|
|
163
|
+
LIMIT 2000
|
|
164
|
+
) s
|
|
165
|
+
JOIN public.plg_connections c ON c.id = s.connection_id
|
|
166
|
+
JOIN public.plg_connector_schedules d
|
|
167
|
+
ON d.connector_id = c.connector_id AND d.kind = s.kind
|
|
168
|
+
WHERE d.enabled
|
|
169
|
+
AND c.active
|
|
170
|
+
AND c.status IN ('connected', 'error')
|
|
171
|
+
) ranked
|
|
172
|
+
WHERE ranked.rn <= ranked.max_per_tick
|
|
173
|
+
LOOP
|
|
174
|
+
INSERT INTO public.plg_sync_runs
|
|
175
|
+
(tenant_id, connection_id, connector_id, direction, trigger_kind, status, stats)
|
|
176
|
+
VALUES
|
|
177
|
+
(v_row.tenant_id, v_row.connection_id, v_row.connector_id,
|
|
178
|
+
CASE WHEN p_kind = 'renew' THEN 'outbound' ELSE 'inbound' END,
|
|
179
|
+
'scheduled', 'running',
|
|
180
|
+
jsonb_build_object('schedule_kind', p_kind, 'attempt', v_row.failures + 1))
|
|
181
|
+
RETURNING id INTO v_run_id;
|
|
182
|
+
|
|
183
|
+
v_reason := NULL;
|
|
184
|
+
IF NOT v_have_net THEN
|
|
185
|
+
v_reason := 'pg_net is not installed on this pool, so the scheduler cannot call the connector function';
|
|
186
|
+
ELSIF COALESCE(v_base, '') = '' OR COALESCE(v_secret, '') = '' THEN
|
|
187
|
+
v_reason := 'the scheduler endpoint is not configured: run plg_set_scheduler_endpoint on this pool';
|
|
188
|
+
ELSE
|
|
189
|
+
BEGIN
|
|
190
|
+
PERFORM net.http_post(
|
|
191
|
+
url := v_base || '/' || v_row.function_name,
|
|
192
|
+
headers := jsonb_build_object(
|
|
193
|
+
'Content-Type', 'application/json',
|
|
194
|
+
'Authorization', 'Bearer ' || v_secret,
|
|
195
|
+
'X-Fayz-Scheduler', '1'
|
|
196
|
+
),
|
|
197
|
+
body := jsonb_build_object(
|
|
198
|
+
'action', v_row.action,
|
|
199
|
+
'kind', p_kind,
|
|
200
|
+
'connectionId', v_row.connection_id,
|
|
201
|
+
'tenantId', v_row.tenant_id,
|
|
202
|
+
'connectorId', v_row.connector_id,
|
|
203
|
+
'runId', v_run_id,
|
|
204
|
+
'attempt', v_row.failures + 1,
|
|
205
|
+
'scheduledAt', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
|
206
|
+
),
|
|
207
|
+
timeout_milliseconds := v_row.timeout_seconds * 1000
|
|
208
|
+
);
|
|
209
|
+
EXCEPTION WHEN OTHERS THEN
|
|
210
|
+
v_reason := 'the scheduler could not post to the connector function: ' || SQLERRM;
|
|
211
|
+
END;
|
|
212
|
+
END IF;
|
|
213
|
+
|
|
214
|
+
IF v_reason IS NOT NULL THEN
|
|
215
|
+
UPDATE public.plg_sync_runs
|
|
216
|
+
SET status = 'error', error = v_reason, finished_at = now()
|
|
217
|
+
WHERE id = v_run_id;
|
|
218
|
+
END IF;
|
|
219
|
+
|
|
220
|
+
-- ── advance ─────────────────────────────────────────────────────────────
|
|
221
|
+
v_offset := mod(abs(hashtext(v_row.connection_id::text)::bigint),
|
|
222
|
+
GREATEST(v_row.jitter_seconds, 1))::integer;
|
|
223
|
+
v_next := now() + make_interval(secs => (v_row.interval_seconds + v_offset)::double precision);
|
|
224
|
+
|
|
225
|
+
IF p_kind = 'renew' THEN
|
|
226
|
+
BEGIN
|
|
227
|
+
v_expires := (v_row.provider_state ->> 'expiresAt')::timestamptz;
|
|
228
|
+
EXCEPTION WHEN OTHERS THEN
|
|
229
|
+
v_expires := NULL;
|
|
230
|
+
END;
|
|
231
|
+
IF v_expires IS NOT NULL THEN
|
|
232
|
+
v_next := LEAST(v_next, v_expires - make_interval(secs => v_row.lead_seconds::double precision));
|
|
233
|
+
END IF;
|
|
234
|
+
END IF;
|
|
235
|
+
|
|
236
|
+
v_next := GREATEST(v_next, now() + make_interval(secs => v_row.floor_seconds::double precision));
|
|
237
|
+
|
|
238
|
+
UPDATE public.plg_sync_schedule
|
|
239
|
+
SET next_attempt_at = v_next,
|
|
240
|
+
last_attempt_at = now(),
|
|
241
|
+
last_run_id = v_run_id
|
|
242
|
+
WHERE connection_id = v_row.connection_id AND kind = p_kind;
|
|
243
|
+
|
|
244
|
+
v_dispatched := v_dispatched + 1;
|
|
245
|
+
END LOOP;
|
|
246
|
+
|
|
247
|
+
RETURN v_dispatched;
|
|
248
|
+
END;
|
|
249
|
+
$fn$;
|
|
250
|
+
|
|
251
|
+
COMMENT ON FUNCTION public.plg_sync_tick(text) IS
|
|
252
|
+
'One tick of the sync clock: clear orphaned pointers, reap dead dispatches, '
|
|
253
|
+
'fold verdicts into backoff, enrol new connections with a deterministic '
|
|
254
|
+
'offset, dispatch what is due AND has no dispatch in flight, up to '
|
|
255
|
+
'max_per_tick per connector. Files a plg_sync_runs row for EVERY attempt, '
|
|
256
|
+
'including one that never reached the function. service_role and cron only.';
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 038 — plg_onboarding_responses: what the owner told us while creating the
|
|
3
|
+
-- workspace, and tenant_slug_available: can this handle still be taken.
|
|
4
|
+
--
|
|
5
|
+
-- The setup wizard asks things no table has a column for — whether they already
|
|
6
|
+
-- run another system, how many chairs, how they hear about us — and the answers
|
|
7
|
+
-- are worth keeping BECAUSE they are not settings: they are how we decide what
|
|
8
|
+
-- to configure for this merchant and what kind of merchant they are.
|
|
9
|
+
--
|
|
10
|
+
-- The answers live in ONE jsonb keyed by question id, not in typed columns. That
|
|
11
|
+
-- is the point: the questionnaire gets reworded every time we learn something
|
|
12
|
+
-- about a vertical, and rewording must never be a migration. The two things we
|
|
13
|
+
-- will actually filter and group by — the vertical and where they came from —
|
|
14
|
+
-- are lifted out as columns, because those are the queries we know we want.
|
|
15
|
+
--
|
|
16
|
+
-- One row per completed run. Nothing is written before the tenant exists: the
|
|
17
|
+
-- wizard holds its draft in localStorage until the org is created, so a fully
|
|
18
|
+
-- abandoned flow leaves no orphan rows to reconcile.
|
|
19
|
+
--
|
|
20
|
+
-- Idempotent.
|
|
21
|
+
-- ============================================================================
|
|
22
|
+
|
|
23
|
+
CREATE TABLE IF NOT EXISTS public.plg_onboarding_responses (
|
|
24
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
25
|
+
|
|
26
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
27
|
+
-- Who answered. Not the same as the tenant owner forever — an owner can be
|
|
28
|
+
-- transferred, and it stays true that THIS account filled the form.
|
|
29
|
+
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
30
|
+
|
|
31
|
+
-- Which questionnaire, and which revision of it. Answers from flow_version 1
|
|
32
|
+
-- are not comparable to version 3 once questions are dropped or rescaled, and
|
|
33
|
+
-- the stamp is what lets a report say so instead of silently averaging both.
|
|
34
|
+
flow_id text NOT NULL,
|
|
35
|
+
flow_version integer NOT NULL DEFAULT 1,
|
|
36
|
+
|
|
37
|
+
-- Copied from the tenant at answer time on purpose: a merchant who later
|
|
38
|
+
-- switches vertical must not retro-relabel the answers they gave as a salon.
|
|
39
|
+
vertical_id text,
|
|
40
|
+
-- Where the flow was opened from: first workspace after signup, or an extra
|
|
41
|
+
-- one from the switcher. Same questions, very different intent.
|
|
42
|
+
source text CHECK (source IS NULL OR source IN ('signup', 'switcher')),
|
|
43
|
+
-- Lifted out of `answers` because acquisition is the one question we will
|
|
44
|
+
-- group by across every tenant, and reaching into jsonb for it every time is
|
|
45
|
+
-- how a "how did they find us" report ends up not being written.
|
|
46
|
+
heard_from text,
|
|
47
|
+
|
|
48
|
+
-- { "<question id>": <answer> }. Scalars for single choice, arrays for multi.
|
|
49
|
+
answers jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
50
|
+
|
|
51
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
-- The tenant's own answers, newest first — the read the admin does.
|
|
55
|
+
CREATE INDEX IF NOT EXISTS plg_onboarding_responses_tenant
|
|
56
|
+
ON public.plg_onboarding_responses (tenant_id, created_at DESC);
|
|
57
|
+
-- The cross-tenant read: "what did restaurants say", "where did they come from".
|
|
58
|
+
CREATE INDEX IF NOT EXISTS plg_onboarding_responses_segment
|
|
59
|
+
ON public.plg_onboarding_responses (vertical_id, heard_from);
|
|
60
|
+
|
|
61
|
+
-- ---------------------------------------------------------------------------
|
|
62
|
+
-- RLS. 011 closed `anon` on new public tables but not `authenticated`, which is
|
|
63
|
+
-- still born with the full grant — here that would be every tenant reading every
|
|
64
|
+
-- other tenant's answers. Stripped first, granted back deliberately.
|
|
65
|
+
-- ---------------------------------------------------------------------------
|
|
66
|
+
REVOKE ALL ON public.plg_onboarding_responses FROM anon, authenticated;
|
|
67
|
+
|
|
68
|
+
ALTER TABLE public.plg_onboarding_responses ENABLE ROW LEVEL SECURITY;
|
|
69
|
+
|
|
70
|
+
-- No UPDATE, no DELETE: this is a record of what someone said at a moment, and
|
|
71
|
+
-- an answer that can be rewritten later is not evidence of anything.
|
|
72
|
+
GRANT SELECT, INSERT ON public.plg_onboarding_responses TO authenticated;
|
|
73
|
+
|
|
74
|
+
DROP POLICY IF EXISTS plg_onboarding_responses_member_read ON public.plg_onboarding_responses;
|
|
75
|
+
CREATE POLICY plg_onboarding_responses_member_read ON public.plg_onboarding_responses
|
|
76
|
+
FOR SELECT TO authenticated
|
|
77
|
+
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
78
|
+
|
|
79
|
+
DROP POLICY IF EXISTS plg_onboarding_responses_member_insert ON public.plg_onboarding_responses;
|
|
80
|
+
CREATE POLICY plg_onboarding_responses_member_insert ON public.plg_onboarding_responses
|
|
81
|
+
FOR INSERT TO authenticated
|
|
82
|
+
WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()) AND user_id = auth.uid());
|
|
83
|
+
|
|
84
|
+
-- ---------------------------------------------------------------------------
|
|
85
|
+
-- tenant_slug_available — is this handle free?
|
|
86
|
+
--
|
|
87
|
+
-- `tenants.slug` is UNIQUE, and RLS keeps a signed-in user from reading tenants
|
|
88
|
+
-- they do not belong to. Without this the wizard cannot tell someone their
|
|
89
|
+
-- handle is taken until the final submit fails, four questions after they chose
|
|
90
|
+
-- it. SECURITY DEFINER, returning only a boolean: it answers "is it free", never
|
|
91
|
+
-- "who has it".
|
|
92
|
+
-- ---------------------------------------------------------------------------
|
|
93
|
+
CREATE OR REPLACE FUNCTION public.tenant_slug_available(p_slug text)
|
|
94
|
+
RETURNS boolean
|
|
95
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
96
|
+
SET search_path = public, pg_temp
|
|
97
|
+
AS $$
|
|
98
|
+
SELECT p_slug ~ '^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$'
|
|
99
|
+
AND NOT EXISTS (SELECT 1 FROM public.tenants t WHERE t.slug = lower(p_slug));
|
|
100
|
+
$$;
|
|
101
|
+
|
|
102
|
+
REVOKE ALL ON FUNCTION public.tenant_slug_available(text) FROM PUBLIC, anon;
|
|
103
|
+
GRANT EXECUTE ON FUNCTION public.tenant_slug_available(text) TO authenticated, service_role;
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 039_unit_tree.sql — the org tree `locations` always was, finally readable.
|
|
3
|
+
--
|
|
4
|
+
-- WHAT A UNIT IS. A franchise brand is ONE tenant with N units: "Alem do Olhar"
|
|
5
|
+
-- has 15 of them. The unit is not a new noun — it is `public.locations`, which
|
|
6
|
+
-- 001_core created with `is_headquarters` and `kind DEFAULT 'branch'` and which
|
|
7
|
+
-- nothing has ever read for authorization. Two kinds and only two:
|
|
8
|
+
--
|
|
9
|
+
-- kind='region' groups units kind='branch' IS the unit
|
|
10
|
+
--
|
|
11
|
+
-- A ROOM IS NOT A UNIT. Sala, cadeira, mesa, consultório are *service
|
|
12
|
+
-- locations* — operational places that HAVE a unit — and they do not belong in
|
|
13
|
+
-- this table; mixing "where the chair is" with "who may see the money" is how
|
|
14
|
+
-- an authorization model stops being explainable. No room concept exists in the
|
|
15
|
+
-- code today (`kind='room'`/`'zone'` appears in prose documentation and in no
|
|
16
|
+
-- table and no screen), so there is nothing to migrate and the tree is exactly
|
|
17
|
+
-- two levels deep.
|
|
18
|
+
--
|
|
19
|
+
-- WHAT THIS FILE DOES NOT DO. Nothing here changes what any query returns. It
|
|
20
|
+
-- adds the tree, the bindings, the per-tenant switch and the five helper
|
|
21
|
+
-- functions; 042 is the file that turns them into a boundary, and even that one
|
|
22
|
+
-- is inert until a tenant sets `unit_scoping_enabled`.
|
|
23
|
+
--
|
|
24
|
+
-- IT ALSO REPAIRS `location_members`. That table has existed since 001_core
|
|
25
|
+
-- with a SELECT policy and NOTHING else: no INSERT, no UPDATE, no DELETE, so no
|
|
26
|
+
-- screen has ever been able to write a binding. It also has no index leading
|
|
27
|
+
-- with `user_id`, which would make `user_unit_ids()` seq-scan it on every
|
|
28
|
+
-- single query. Both are fixed here.
|
|
29
|
+
--
|
|
30
|
+
-- Additive and idempotent: safe to re-run, no-ops when already applied.
|
|
31
|
+
-- ============================================================================
|
|
32
|
+
|
|
33
|
+
-- ── The tree ────────────────────────────────────────────────────────────────
|
|
34
|
+
ALTER TABLE public.locations
|
|
35
|
+
ADD COLUMN IF NOT EXISTS parent_id uuid,
|
|
36
|
+
ADD COLUMN IF NOT EXISTS code text;
|
|
37
|
+
|
|
38
|
+
COMMENT ON COLUMN public.locations.parent_id IS
|
|
39
|
+
'Parent node of the org tree: a branch points at its region. Two levels only.';
|
|
40
|
+
COMMENT ON COLUMN public.locations.code IS
|
|
41
|
+
'The unit code the franchise already uses on its own paperwork (e.g. "RJ-042").';
|
|
42
|
+
|
|
43
|
+
-- A parent in ANOTHER tenant would be a cross-tenant edge inside the very
|
|
44
|
+
-- structure that decides visibility. The composite FK makes it unrepresentable
|
|
45
|
+
-- rather than merely discouraged — cheaper and more honest than a trigger.
|
|
46
|
+
DO $$
|
|
47
|
+
BEGIN
|
|
48
|
+
IF NOT EXISTS (
|
|
49
|
+
SELECT 1 FROM pg_constraint WHERE conname = 'locations_id_tenant_uniq'
|
|
50
|
+
) THEN
|
|
51
|
+
ALTER TABLE public.locations ADD CONSTRAINT locations_id_tenant_uniq UNIQUE (id, tenant_id);
|
|
52
|
+
END IF;
|
|
53
|
+
|
|
54
|
+
IF NOT EXISTS (
|
|
55
|
+
SELECT 1 FROM pg_constraint WHERE conname = 'locations_parent_same_tenant'
|
|
56
|
+
) THEN
|
|
57
|
+
ALTER TABLE public.locations
|
|
58
|
+
ADD CONSTRAINT locations_parent_same_tenant
|
|
59
|
+
FOREIGN KEY (parent_id, tenant_id)
|
|
60
|
+
REFERENCES public.locations (id, tenant_id) ON DELETE SET NULL;
|
|
61
|
+
END IF;
|
|
62
|
+
|
|
63
|
+
-- Self-parenting is the one cycle a CHECK can catch. Deeper cycles are
|
|
64
|
+
-- survivable by construction: user_unit_ids() walks with UNION (which dedupes
|
|
65
|
+
-- and therefore terminates) plus a depth guard. NOT VALID so no live table is
|
|
66
|
+
-- scanned; new and updated rows are checked from here on.
|
|
67
|
+
IF NOT EXISTS (
|
|
68
|
+
SELECT 1 FROM pg_constraint WHERE conname = 'locations_no_self_parent'
|
|
69
|
+
) THEN
|
|
70
|
+
ALTER TABLE public.locations
|
|
71
|
+
ADD CONSTRAINT locations_no_self_parent CHECK (parent_id IS DISTINCT FROM id) NOT VALID;
|
|
72
|
+
END IF;
|
|
73
|
+
END $$;
|
|
74
|
+
|
|
75
|
+
CREATE INDEX IF NOT EXISTS locations_parent_idx
|
|
76
|
+
ON public.locations (parent_id) WHERE parent_id IS NOT NULL;
|
|
77
|
+
|
|
78
|
+
-- ── The bindings ────────────────────────────────────────────────────────────
|
|
79
|
+
-- `tenant_id` denormalised so this table's own policies take the canonical
|
|
80
|
+
-- shape instead of joining back through `locations` on every check.
|
|
81
|
+
ALTER TABLE public.location_members
|
|
82
|
+
ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES public.tenants(id) ON DELETE CASCADE;
|
|
83
|
+
|
|
84
|
+
UPDATE public.location_members lm
|
|
85
|
+
SET tenant_id = l.tenant_id
|
|
86
|
+
FROM public.locations l
|
|
87
|
+
WHERE l.id = lm.location_id
|
|
88
|
+
AND lm.tenant_id IS DISTINCT FROM l.tenant_id;
|
|
89
|
+
|
|
90
|
+
-- The column is derived, so deriving it is the trigger's job — a caller that
|
|
91
|
+
-- has to remember to pass it will eventually not, and a binding with a NULL
|
|
92
|
+
-- tenant is invisible to every policy below.
|
|
93
|
+
CREATE OR REPLACE FUNCTION public.handle_location_member_tenant()
|
|
94
|
+
RETURNS trigger
|
|
95
|
+
LANGUAGE plpgsql
|
|
96
|
+
SECURITY DEFINER
|
|
97
|
+
SET search_path = public, pg_temp
|
|
98
|
+
AS $$
|
|
99
|
+
BEGIN
|
|
100
|
+
SELECT l.tenant_id INTO NEW.tenant_id
|
|
101
|
+
FROM public.locations l WHERE l.id = NEW.location_id;
|
|
102
|
+
RETURN NEW;
|
|
103
|
+
END;
|
|
104
|
+
$$;
|
|
105
|
+
|
|
106
|
+
REVOKE ALL ON FUNCTION public.handle_location_member_tenant() FROM public, anon;
|
|
107
|
+
|
|
108
|
+
DROP TRIGGER IF EXISTS location_members_tenant ON public.location_members;
|
|
109
|
+
CREATE TRIGGER location_members_tenant
|
|
110
|
+
BEFORE INSERT OR UPDATE OF location_id ON public.location_members
|
|
111
|
+
FOR EACH ROW EXECUTE FUNCTION public.handle_location_member_tenant();
|
|
112
|
+
|
|
113
|
+
-- Without this index every RLS-evaluated call of user_unit_ids() seq-scans the
|
|
114
|
+
-- table. 001_core's only index is UNIQUE(location_id, user_id) — wrong leading
|
|
115
|
+
-- column for the one question this table is ever asked.
|
|
116
|
+
CREATE INDEX IF NOT EXISTS location_members_user_idx
|
|
117
|
+
ON public.location_members (user_id, location_id);
|
|
118
|
+
|
|
119
|
+
-- A binding with no role is the normal case: it says "you work here", and the
|
|
120
|
+
-- role you already have in the org applies. A role here only ever ADDS.
|
|
121
|
+
ALTER TABLE public.location_members ALTER COLUMN role DROP DEFAULT;
|
|
122
|
+
|
|
123
|
+
COMMENT ON COLUMN public.location_members.role IS
|
|
124
|
+
'Additional role INSIDE this unit. Unions with the org role, never subtracts. NULL = no extra role.';
|
|
125
|
+
|
|
126
|
+
-- ── The per-tenant switch ───────────────────────────────────────────────────
|
|
127
|
+
-- A column and not a key in `tenants.settings`, following the precedent shop
|
|
128
|
+
-- 0020 set with `storefront_strict_scope`: a security flag read inside a policy
|
|
129
|
+
-- has to be cheap and indexable, and a jsonb extraction is neither.
|
|
130
|
+
ALTER TABLE public.tenants
|
|
131
|
+
ADD COLUMN IF NOT EXISTS unit_scoping_enabled boolean NOT NULL DEFAULT false;
|
|
132
|
+
|
|
133
|
+
COMMENT ON COLUMN public.tenants.unit_scoping_enabled IS
|
|
134
|
+
'Off by default. While false, every unit predicate in 042 short-circuits and this tenant behaves exactly as before.';
|
|
135
|
+
|
|
136
|
+
-- ── Helpers ─────────────────────────────────────────────────────────────────
|
|
137
|
+
-- Every one of these is zero-argument on purpose. A helper that takes the row's
|
|
138
|
+
-- own column (is_tenant_admin(tenant_id) is the cautionary example) is
|
|
139
|
+
-- correlated: RLS injects the predicate into securityQuals, where the planner
|
|
140
|
+
-- never turns a sublink into a semijoin, so it becomes a SubPlan re-executed
|
|
141
|
+
-- once PER ROW. Zero-argument helpers wrapped in `(SELECT …)` become one
|
|
142
|
+
-- InitPlan per query, hashed, then a probe per row.
|
|
143
|
+
--
|
|
144
|
+
-- All are SECURITY DEFINER for the reason 023 spells out: they read the very
|
|
145
|
+
-- tables whose policies will call them, and invoker semantics would recurse.
|
|
146
|
+
|
|
147
|
+
-- The pool-level short circuit. On a pool where nobody uses units this returns
|
|
148
|
+
-- false once per query and the whole predicate in 042 costs one boolean test
|
|
149
|
+
-- per row — no hash build, because OR does not evaluate its later arms.
|
|
150
|
+
CREATE OR REPLACE FUNCTION public.unit_scoping_active()
|
|
151
|
+
RETURNS boolean
|
|
152
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
153
|
+
SET search_path = public, pg_temp
|
|
154
|
+
AS $$
|
|
155
|
+
SELECT EXISTS (SELECT 1 FROM public.tenants WHERE unit_scoping_enabled);
|
|
156
|
+
$$;
|
|
157
|
+
|
|
158
|
+
CREATE OR REPLACE FUNCTION public.unit_scoped_tenants()
|
|
159
|
+
RETURNS SETOF uuid
|
|
160
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
161
|
+
SET search_path = public, pg_temp
|
|
162
|
+
AS $$
|
|
163
|
+
SELECT id FROM public.tenants WHERE unit_scoping_enabled;
|
|
164
|
+
$$;
|
|
165
|
+
|
|
166
|
+
-- The tenants where the caller may see everything regardless of unit. Mirrors
|
|
167
|
+
-- is_tenant_admin's role set exactly (001_core:87) — divergence here would mean
|
|
168
|
+
-- the settings screens and the row predicate disagree about who is an admin.
|
|
169
|
+
CREATE OR REPLACE FUNCTION public.user_admin_tenant_ids()
|
|
170
|
+
RETURNS SETOF uuid
|
|
171
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
172
|
+
SET search_path = public, pg_temp
|
|
173
|
+
AS $$
|
|
174
|
+
SELECT tm.tenant_id
|
|
175
|
+
FROM public.tenant_members tm
|
|
176
|
+
WHERE tm.user_id = auth.uid()
|
|
177
|
+
AND tm.role IN ('owner', 'admin');
|
|
178
|
+
$$;
|
|
179
|
+
|
|
180
|
+
-- The units the caller reaches. A binding to a region reaches its branches;
|
|
181
|
+
-- reach flows DOWN the tree and never up — a unit manager is not a regional.
|
|
182
|
+
--
|
|
183
|
+
-- Deliberately NOT tenant-aware: a user can belong to tenant A with units on
|
|
184
|
+
-- and tenant B with units off, and one answer cannot serve both. The mode check
|
|
185
|
+
-- lives in the predicate (042), where it is free.
|
|
186
|
+
CREATE OR REPLACE FUNCTION public.user_unit_ids()
|
|
187
|
+
RETURNS SETOF uuid
|
|
188
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
189
|
+
SET search_path = public, pg_temp
|
|
190
|
+
AS $$
|
|
191
|
+
WITH RECURSIVE seed AS (
|
|
192
|
+
SELECT lm.location_id AS id, 0 AS depth
|
|
193
|
+
FROM public.location_members lm
|
|
194
|
+
WHERE lm.user_id = auth.uid()
|
|
195
|
+
),
|
|
196
|
+
tree AS (
|
|
197
|
+
SELECT id, depth FROM seed
|
|
198
|
+
UNION -- UNION, not UNION ALL: dedupes, and
|
|
199
|
+
SELECT l.id, t.depth + 1 -- therefore terminates on a cycle
|
|
200
|
+
FROM public.locations l
|
|
201
|
+
JOIN tree t ON l.parent_id = t.id
|
|
202
|
+
WHERE t.depth < 8 -- a policy must never be able to hang
|
|
203
|
+
)
|
|
204
|
+
SELECT id FROM tree;
|
|
205
|
+
$$;
|
|
206
|
+
|
|
207
|
+
-- The unit the browser says it is looking at, read from a request header the
|
|
208
|
+
-- same way shop 0020 reads `x-fayz-store`. It NARROWS a write's default stamp;
|
|
209
|
+
-- it never grants anything — 042 validates the stamped unit against
|
|
210
|
+
-- user_unit_ids() in WITH CHECK. Swallows every parse failure for the reason
|
|
211
|
+
-- 028 states: an RLS-adjacent function is not a place to raise.
|
|
212
|
+
CREATE OR REPLACE FUNCTION public.requested_unit()
|
|
213
|
+
RETURNS uuid
|
|
214
|
+
LANGUAGE plpgsql STABLE
|
|
215
|
+
SET search_path = public, pg_temp
|
|
216
|
+
AS $$
|
|
217
|
+
DECLARE v text;
|
|
218
|
+
BEGIN
|
|
219
|
+
v := nullif(current_setting('request.headers', true), '')::json ->> 'x-fayz-unit';
|
|
220
|
+
RETURN nullif(v, '')::uuid;
|
|
221
|
+
EXCEPTION WHEN OTHERS THEN
|
|
222
|
+
RETURN NULL;
|
|
223
|
+
END;
|
|
224
|
+
$$;
|
|
225
|
+
|
|
226
|
+
GRANT EXECUTE ON FUNCTION public.unit_scoping_active() TO authenticated, service_role;
|
|
227
|
+
GRANT EXECUTE ON FUNCTION public.unit_scoped_tenants() TO authenticated, service_role;
|
|
228
|
+
GRANT EXECUTE ON FUNCTION public.user_admin_tenant_ids() TO authenticated, service_role;
|
|
229
|
+
GRANT EXECUTE ON FUNCTION public.user_unit_ids() TO authenticated, service_role;
|
|
230
|
+
GRANT EXECUTE ON FUNCTION public.requested_unit() TO authenticated, service_role;
|
|
231
|
+
|
|
232
|
+
-- ── Policies on the tree itself ─────────────────────────────────────────────
|
|
233
|
+
-- `locations` and `location_members` are in 002's core_tables exclusion list,
|
|
234
|
+
-- so they carry curated policies and no sweep will overwrite these.
|
|
235
|
+
--
|
|
236
|
+
-- RLS is re-enabled rather than assumed: 001_core enables it, but a pool built
|
|
237
|
+
-- by hand (the ecommerce pool has tables the ledger never heard of) may not have
|
|
238
|
+
-- run 001, and a policy on a table without RLS is decoration.
|
|
239
|
+
ALTER TABLE public.locations ENABLE ROW LEVEL SECURITY;
|
|
240
|
+
ALTER TABLE public.location_members ENABLE ROW LEVEL SECURITY;
|
|
241
|
+
--
|
|
242
|
+
-- The tree stays readable tenant-wide even under unit scoping, on purpose: the
|
|
243
|
+
-- unit switcher, the "Unidade: Ipanema" label on a record and the reports
|
|
244
|
+
-- breakdown all need to name a unit the reader may not have data in. A unit's
|
|
245
|
+
-- NAME is not its data.
|
|
246
|
+
DROP POLICY IF EXISTS "locations_select" ON public.locations;
|
|
247
|
+
CREATE POLICY "locations_select" ON public.locations FOR SELECT TO authenticated
|
|
248
|
+
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
249
|
+
|
|
250
|
+
DROP POLICY IF EXISTS "loc_members_select" ON public.location_members;
|
|
251
|
+
CREATE POLICY "loc_members_select" ON public.location_members FOR SELECT TO authenticated
|
|
252
|
+
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
253
|
+
|
|
254
|
+
-- The missing three quarters of the table. Only a tenant admin hands out
|
|
255
|
+
-- access to a unit — the same bar `tenant_role_overrides` and `invitations`
|
|
256
|
+
-- already set for who may change who can do what.
|
|
257
|
+
DROP POLICY IF EXISTS "loc_members_insert" ON public.location_members;
|
|
258
|
+
CREATE POLICY "loc_members_insert" ON public.location_members FOR INSERT TO authenticated
|
|
259
|
+
WITH CHECK (tenant_id IN (SELECT public.user_admin_tenant_ids()));
|
|
260
|
+
|
|
261
|
+
DROP POLICY IF EXISTS "loc_members_update" ON public.location_members;
|
|
262
|
+
CREATE POLICY "loc_members_update" ON public.location_members FOR UPDATE TO authenticated
|
|
263
|
+
USING (tenant_id IN (SELECT public.user_admin_tenant_ids()));
|
|
264
|
+
|
|
265
|
+
DROP POLICY IF EXISTS "loc_members_delete" ON public.location_members;
|
|
266
|
+
CREATE POLICY "loc_members_delete" ON public.location_members FOR DELETE TO authenticated
|
|
267
|
+
USING (tenant_id IN (SELECT public.user_admin_tenant_ids()));
|
|
268
|
+
|
|
269
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.location_members TO authenticated;
|
|
270
|
+
GRANT ALL ON public.location_members TO service_role;
|