@fayz-ai/db 0.10.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.
Files changed (34) hide show
  1. package/dist/index.cjs +39 -0
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +39 -2
  6. package/dist/index.js.map +1 -1
  7. package/dist/schema/spine.d.ts +553 -0
  8. package/dist/schema/spine.d.ts.map +1 -1
  9. package/migrations/025_created_by.sql +75 -0
  10. package/migrations/026_audit_trail.sql +73 -0
  11. package/migrations/027_domain_events.sql +266 -0
  12. package/migrations/028_tenant_scoped_token.sql +127 -0
  13. package/migrations/029_connections.sql +186 -0
  14. package/migrations/030_effect_idempotency.sql +159 -0
  15. package/migrations/031_sync_run_message.sql +39 -0
  16. package/migrations/032_connection_secrets.sql +227 -0
  17. package/migrations/033_sync_schedule.sql +651 -0
  18. package/migrations/034_custom_fields.sql +55 -0
  19. package/migrations/035_field_registry.sql +148 -0
  20. package/migrations/036_analytics_run_batch.sql +84 -0
  21. package/migrations/037_sync_tick_one_at_a_time.sql +256 -0
  22. package/migrations/038_onboarding_responses.sql +103 -0
  23. package/migrations/039_unit_tree.sql +270 -0
  24. package/migrations/040_resource_grants.sql +474 -0
  25. package/migrations/041_scoped_columns.sql +192 -0
  26. package/migrations/042_unit_scope_policies.sql +145 -0
  27. package/migrations/043_view_invoker.sql +81 -0
  28. package/migrations/044_unit_member_facts.sql +47 -0
  29. package/migrations/045_unit_entry.sql +236 -0
  30. package/migrations/046_membership_visible_to_members.sql +85 -0
  31. package/migrations/047_tasks.sql +266 -0
  32. package/migrations/048_every_login_is_a_person.sql +190 -0
  33. package/migrations/049_bookable_people.sql +126 -0
  34. package/package.json +7 -4
@@ -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;