writbase 0.1.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 (30) hide show
  1. package/README.md +121 -0
  2. package/dist/writbase.js +707 -0
  3. package/migrations/00001_enums.sql +7 -0
  4. package/migrations/00002_core_tables.sql +105 -0
  5. package/migrations/00003_constraints_indexes.sql +69 -0
  6. package/migrations/00004_rls_policies.sql +169 -0
  7. package/migrations/00005_seed_app_settings.sql +2 -0
  8. package/migrations/00006_rate_limit_rpc.sql +14 -0
  9. package/migrations/00007_rate_limit_rpc_search_path.sql +14 -0
  10. package/migrations/00008_indexes_and_cleanup.sql +22 -0
  11. package/migrations/00009_atomic_mutations.sql +175 -0
  12. package/migrations/00010_user_rate_limits.sql +52 -0
  13. package/migrations/00011_atomic_permission_update.sql +31 -0
  14. package/migrations/00012_pg_cron_cleanup.sql +27 -0
  15. package/migrations/00013_max_agent_keys.sql +4 -0
  16. package/migrations/00014_event_log_indexes.sql +2 -0
  17. package/migrations/00015_auth_rate_limits.sql +69 -0
  18. package/migrations/00016_request_log.sql +42 -0
  19. package/migrations/00017_task_search.sql +58 -0
  20. package/migrations/00018_inter_agent_exchange.sql +423 -0
  21. package/migrations/00019_workspaces.sql +782 -0
  22. package/migrations/00020_can_comment.sql +46 -0
  23. package/migrations/00021_webhook_delivery.sql +125 -0
  24. package/migrations/00022_task_archive.sql +380 -0
  25. package/migrations/00023_get_top_tasks.sql +21 -0
  26. package/migrations/00024_agent_key_defaults.sql +55 -0
  27. package/migrations/00025_production_automation.sql +353 -0
  28. package/migrations/00026_top_tasks_exclude_terminal.sql +19 -0
  29. package/migrations/00027_rename_agent_key_defaults.sql +51 -0
  30. package/package.json +34 -0
@@ -0,0 +1,782 @@
1
+ -- ============================================================================
2
+ -- Migration 00019: Replace admin_users with multi-tenant workspaces
3
+ -- ============================================================================
4
+ -- Replaces the flat admin_users gate with workspace-scoped isolation.
5
+ -- Signup auto-creates a workspace via Postgres trigger. No manual bootstrap.
6
+ -- ============================================================================
7
+
8
+ -- ── 1a. New tables ─────────────────────────────────────────────────────
9
+
10
+ CREATE TABLE workspaces (
11
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
12
+ name text NOT NULL DEFAULT 'My Workspace',
13
+ slug text NOT NULL,
14
+ owner_id uuid NOT NULL REFERENCES auth.users(id),
15
+ created_at timestamptz NOT NULL DEFAULT now(),
16
+ updated_at timestamptz NOT NULL DEFAULT now(),
17
+ UNIQUE(slug),
18
+ UNIQUE(owner_id) -- MVP: one workspace per user
19
+ );
20
+
21
+ CREATE TYPE workspace_role AS ENUM ('owner', 'admin', 'member');
22
+
23
+ CREATE TABLE workspace_members (
24
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
25
+ user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
26
+ role workspace_role NOT NULL DEFAULT 'member',
27
+ created_at timestamptz NOT NULL DEFAULT now(),
28
+ PRIMARY KEY (workspace_id, user_id)
29
+ );
30
+
31
+ CREATE INDEX idx_workspaces_owner ON workspaces(owner_id);
32
+ CREATE INDEX idx_workspace_members_user ON workspace_members(user_id);
33
+
34
+ -- ── 1b. Bootstrap workspace for existing data ─────────────────────────
35
+
36
+ DO $$
37
+ DECLARE v_ws_id uuid;
38
+ DECLARE v_user_id uuid;
39
+ BEGIN
40
+ SELECT user_id INTO v_user_id FROM admin_users LIMIT 1;
41
+ IF v_user_id IS NOT NULL THEN
42
+ INSERT INTO workspaces (name, slug, owner_id)
43
+ VALUES ('My Workspace', 'ws-' || substr(md5(v_user_id::text), 1, 8), v_user_id)
44
+ RETURNING id INTO v_ws_id;
45
+ INSERT INTO workspace_members (workspace_id, user_id, role)
46
+ VALUES (v_ws_id, v_user_id, 'owner');
47
+ INSERT INTO workspace_members (workspace_id, user_id, role)
48
+ SELECT v_ws_id, user_id, 'member'
49
+ FROM admin_users WHERE user_id != v_user_id;
50
+ ELSE
51
+ -- Fresh DB: delete orphan seed data so NOT NULL succeeds on empty tables
52
+ DELETE FROM app_settings;
53
+ END IF;
54
+ END $$;
55
+
56
+ -- ── 1c. Add workspace_id to all data tables ───────────────────────────
57
+
58
+ -- Step 1: Add as nullable
59
+ ALTER TABLE projects ADD COLUMN workspace_id uuid REFERENCES workspaces(id);
60
+ ALTER TABLE departments ADD COLUMN workspace_id uuid REFERENCES workspaces(id);
61
+ ALTER TABLE tasks ADD COLUMN workspace_id uuid REFERENCES workspaces(id);
62
+ ALTER TABLE event_log ADD COLUMN workspace_id uuid REFERENCES workspaces(id);
63
+ ALTER TABLE agent_keys ADD COLUMN workspace_id uuid REFERENCES workspaces(id);
64
+ ALTER TABLE agent_permissions ADD COLUMN workspace_id uuid REFERENCES workspaces(id);
65
+ ALTER TABLE app_settings ADD COLUMN workspace_id uuid REFERENCES workspaces(id);
66
+ ALTER TABLE webhook_subscriptions ADD COLUMN workspace_id uuid REFERENCES workspaces(id);
67
+ ALTER TABLE agent_capabilities ADD COLUMN workspace_id uuid REFERENCES workspaces(id);
68
+ ALTER TABLE request_log ADD COLUMN workspace_id uuid REFERENCES workspaces(id);
69
+
70
+ -- Step 2: Backfill from bootstrap workspace (no-op on fresh DB)
71
+ UPDATE projects SET workspace_id = (SELECT id FROM workspaces LIMIT 1) WHERE workspace_id IS NULL;
72
+ UPDATE departments SET workspace_id = (SELECT id FROM workspaces LIMIT 1) WHERE workspace_id IS NULL;
73
+ UPDATE tasks SET workspace_id = (SELECT id FROM workspaces LIMIT 1) WHERE workspace_id IS NULL;
74
+ UPDATE event_log SET workspace_id = (SELECT id FROM workspaces LIMIT 1) WHERE workspace_id IS NULL;
75
+ UPDATE agent_keys SET workspace_id = (SELECT id FROM workspaces LIMIT 1) WHERE workspace_id IS NULL;
76
+ UPDATE agent_permissions SET workspace_id = (SELECT id FROM workspaces LIMIT 1) WHERE workspace_id IS NULL;
77
+ UPDATE app_settings SET workspace_id = (SELECT id FROM workspaces LIMIT 1) WHERE workspace_id IS NULL;
78
+ UPDATE webhook_subscriptions SET workspace_id = (SELECT id FROM workspaces LIMIT 1) WHERE workspace_id IS NULL;
79
+ UPDATE agent_capabilities SET workspace_id = (SELECT id FROM workspaces LIMIT 1) WHERE workspace_id IS NULL;
80
+ UPDATE request_log SET workspace_id = (SELECT id FROM workspaces LIMIT 1) WHERE workspace_id IS NULL;
81
+
82
+ -- Step 3: Set NOT NULL
83
+ ALTER TABLE projects ALTER COLUMN workspace_id SET NOT NULL;
84
+ ALTER TABLE departments ALTER COLUMN workspace_id SET NOT NULL;
85
+ ALTER TABLE tasks ALTER COLUMN workspace_id SET NOT NULL;
86
+ ALTER TABLE event_log ALTER COLUMN workspace_id SET NOT NULL;
87
+ ALTER TABLE agent_keys ALTER COLUMN workspace_id SET NOT NULL;
88
+ ALTER TABLE agent_permissions ALTER COLUMN workspace_id SET NOT NULL;
89
+ ALTER TABLE app_settings ALTER COLUMN workspace_id SET NOT NULL;
90
+ ALTER TABLE webhook_subscriptions ALTER COLUMN workspace_id SET NOT NULL;
91
+ ALTER TABLE agent_capabilities ALTER COLUMN workspace_id SET NOT NULL;
92
+ ALTER TABLE request_log ALTER COLUMN workspace_id SET NOT NULL;
93
+
94
+ -- Indexes
95
+ CREATE INDEX idx_projects_workspace ON projects(workspace_id);
96
+ CREATE INDEX idx_departments_workspace ON departments(workspace_id);
97
+ CREATE INDEX idx_tasks_workspace ON tasks(workspace_id);
98
+ CREATE INDEX idx_event_log_workspace ON event_log(workspace_id);
99
+ CREATE INDEX idx_agent_keys_workspace ON agent_keys(workspace_id);
100
+ CREATE INDEX idx_agent_permissions_workspace ON agent_permissions(workspace_id);
101
+ CREATE INDEX idx_app_settings_workspace ON app_settings(workspace_id);
102
+ CREATE INDEX idx_webhook_subscriptions_workspace ON webhook_subscriptions(workspace_id);
103
+ CREATE INDEX idx_agent_capabilities_workspace ON agent_capabilities(workspace_id);
104
+ CREATE INDEX idx_request_log_workspace ON request_log(workspace_id);
105
+
106
+ -- ── 1d. Replace global unique constraints with workspace-scoped ───────
107
+
108
+ ALTER TABLE projects DROP CONSTRAINT projects_name_key;
109
+ ALTER TABLE projects DROP CONSTRAINT projects_slug_key;
110
+ ALTER TABLE departments DROP CONSTRAINT departments_name_key;
111
+ ALTER TABLE departments DROP CONSTRAINT departments_slug_key;
112
+
113
+ ALTER TABLE projects ADD CONSTRAINT uq_projects_ws_name UNIQUE (workspace_id, name);
114
+ ALTER TABLE projects ADD CONSTRAINT uq_projects_ws_slug UNIQUE (workspace_id, slug);
115
+ ALTER TABLE departments ADD CONSTRAINT uq_departments_ws_name UNIQUE (workspace_id, name);
116
+ ALTER TABLE departments ADD CONSTRAINT uq_departments_ws_slug UNIQUE (workspace_id, slug);
117
+
118
+ -- app_settings: one row per workspace
119
+ ALTER TABLE app_settings ADD CONSTRAINT uq_app_settings_ws UNIQUE (workspace_id);
120
+
121
+ -- ── 1e. RLS helper function ───────────────────────────────────────────
122
+
123
+ CREATE OR REPLACE FUNCTION get_user_workspace_ids()
124
+ RETURNS SETOF uuid
125
+ LANGUAGE sql STABLE SECURITY DEFINER
126
+ SET search_path = public
127
+ AS $$
128
+ SELECT workspace_id FROM workspace_members
129
+ WHERE user_id = auth.uid();
130
+ $$;
131
+
132
+ -- ── 1f. RLS on new tables ─────────────────────────────────────────────
133
+
134
+ ALTER TABLE workspaces ENABLE ROW LEVEL SECURITY;
135
+ ALTER TABLE workspace_members ENABLE ROW LEVEL SECURITY;
136
+
137
+ CREATE POLICY "member_select" ON workspaces FOR SELECT TO authenticated
138
+ USING (id IN (SELECT get_user_workspace_ids()));
139
+ CREATE POLICY "owner_update" ON workspaces FOR UPDATE TO authenticated
140
+ USING (owner_id = (SELECT auth.uid()))
141
+ WITH CHECK (owner_id = (SELECT auth.uid()));
142
+
143
+ CREATE POLICY "member_select" ON workspace_members FOR SELECT TO authenticated
144
+ USING (workspace_id IN (SELECT get_user_workspace_ids()));
145
+ CREATE POLICY "owner_insert_members" ON workspace_members FOR INSERT TO authenticated
146
+ WITH CHECK (EXISTS (SELECT 1 FROM workspaces WHERE id = workspace_id AND owner_id = (SELECT auth.uid())));
147
+ CREATE POLICY "owner_update_members" ON workspace_members FOR UPDATE TO authenticated
148
+ USING (EXISTS (SELECT 1 FROM workspaces WHERE id = workspace_id AND owner_id = (SELECT auth.uid())))
149
+ WITH CHECK (EXISTS (SELECT 1 FROM workspaces WHERE id = workspace_id AND owner_id = (SELECT auth.uid())));
150
+ CREATE POLICY "owner_delete_members" ON workspace_members FOR DELETE TO authenticated
151
+ USING (EXISTS (SELECT 1 FROM workspaces WHERE id = workspace_id AND owner_id = (SELECT auth.uid())));
152
+
153
+ -- ── 1g. Drop ALL old RLS policies ────────────────────────────────────
154
+
155
+ -- projects (4)
156
+ DROP POLICY "admin_select_projects" ON projects;
157
+ DROP POLICY "admin_insert_projects" ON projects;
158
+ DROP POLICY "admin_update_projects" ON projects;
159
+ DROP POLICY "admin_delete_projects" ON projects;
160
+
161
+ -- departments (4)
162
+ DROP POLICY "admin_select_departments" ON departments;
163
+ DROP POLICY "admin_insert_departments" ON departments;
164
+ DROP POLICY "admin_update_departments" ON departments;
165
+ DROP POLICY "admin_delete_departments" ON departments;
166
+
167
+ -- tasks (4)
168
+ DROP POLICY "admin_select_tasks" ON tasks;
169
+ DROP POLICY "admin_insert_tasks" ON tasks;
170
+ DROP POLICY "admin_update_tasks" ON tasks;
171
+ DROP POLICY "admin_delete_tasks" ON tasks;
172
+
173
+ -- agent_keys (4)
174
+ DROP POLICY "admin_select_agent_keys" ON agent_keys;
175
+ DROP POLICY "admin_insert_agent_keys" ON agent_keys;
176
+ DROP POLICY "admin_update_agent_keys" ON agent_keys;
177
+ DROP POLICY "admin_delete_agent_keys" ON agent_keys;
178
+
179
+ -- agent_permissions (4)
180
+ DROP POLICY "admin_select_agent_permissions" ON agent_permissions;
181
+ DROP POLICY "admin_insert_agent_permissions" ON agent_permissions;
182
+ DROP POLICY "admin_update_agent_permissions" ON agent_permissions;
183
+ DROP POLICY "admin_delete_agent_permissions" ON agent_permissions;
184
+
185
+ -- app_settings (4)
186
+ DROP POLICY "admin_select_app_settings" ON app_settings;
187
+ DROP POLICY "admin_insert_app_settings" ON app_settings;
188
+ DROP POLICY "admin_update_app_settings" ON app_settings;
189
+ DROP POLICY "admin_delete_app_settings" ON app_settings;
190
+
191
+ -- event_log (1)
192
+ DROP POLICY "admin_select_event_log" ON event_log;
193
+
194
+ -- admin_users (4) — drop before table drop
195
+ DROP POLICY "authenticated_select_admin_users" ON admin_users;
196
+ DROP POLICY "admin_insert_admin_users" ON admin_users;
197
+ DROP POLICY "admin_update_admin_users" ON admin_users;
198
+ DROP POLICY "admin_delete_admin_users" ON admin_users;
199
+
200
+ -- request_log (1)
201
+ DROP POLICY "admin_select_request_log" ON request_log;
202
+
203
+ -- ── 1h. Recreate RLS policies with workspace scoping ─────────────────
204
+
205
+ -- projects
206
+ CREATE POLICY "ws_select_projects" ON projects FOR SELECT TO authenticated
207
+ USING (workspace_id IN (SELECT get_user_workspace_ids()));
208
+ CREATE POLICY "ws_insert_projects" ON projects FOR INSERT TO authenticated
209
+ WITH CHECK (workspace_id IN (SELECT get_user_workspace_ids()));
210
+ CREATE POLICY "ws_update_projects" ON projects FOR UPDATE TO authenticated
211
+ USING (workspace_id IN (SELECT get_user_workspace_ids()))
212
+ WITH CHECK (workspace_id IN (SELECT get_user_workspace_ids()));
213
+ CREATE POLICY "ws_delete_projects" ON projects FOR DELETE TO authenticated
214
+ USING (workspace_id IN (SELECT get_user_workspace_ids()));
215
+
216
+ -- departments
217
+ CREATE POLICY "ws_select_departments" ON departments FOR SELECT TO authenticated
218
+ USING (workspace_id IN (SELECT get_user_workspace_ids()));
219
+ CREATE POLICY "ws_insert_departments" ON departments FOR INSERT TO authenticated
220
+ WITH CHECK (workspace_id IN (SELECT get_user_workspace_ids()));
221
+ CREATE POLICY "ws_update_departments" ON departments FOR UPDATE TO authenticated
222
+ USING (workspace_id IN (SELECT get_user_workspace_ids()))
223
+ WITH CHECK (workspace_id IN (SELECT get_user_workspace_ids()));
224
+ CREATE POLICY "ws_delete_departments" ON departments FOR DELETE TO authenticated
225
+ USING (workspace_id IN (SELECT get_user_workspace_ids()));
226
+
227
+ -- tasks
228
+ CREATE POLICY "ws_select_tasks" ON tasks FOR SELECT TO authenticated
229
+ USING (workspace_id IN (SELECT get_user_workspace_ids()));
230
+ CREATE POLICY "ws_insert_tasks" ON tasks FOR INSERT TO authenticated
231
+ WITH CHECK (workspace_id IN (SELECT get_user_workspace_ids()));
232
+ CREATE POLICY "ws_update_tasks" ON tasks FOR UPDATE TO authenticated
233
+ USING (workspace_id IN (SELECT get_user_workspace_ids()))
234
+ WITH CHECK (workspace_id IN (SELECT get_user_workspace_ids()));
235
+ CREATE POLICY "ws_delete_tasks" ON tasks FOR DELETE TO authenticated
236
+ USING (workspace_id IN (SELECT get_user_workspace_ids()));
237
+
238
+ -- agent_keys
239
+ CREATE POLICY "ws_select_agent_keys" ON agent_keys FOR SELECT TO authenticated
240
+ USING (workspace_id IN (SELECT get_user_workspace_ids()));
241
+ CREATE POLICY "ws_insert_agent_keys" ON agent_keys FOR INSERT TO authenticated
242
+ WITH CHECK (workspace_id IN (SELECT get_user_workspace_ids()));
243
+ CREATE POLICY "ws_update_agent_keys" ON agent_keys FOR UPDATE TO authenticated
244
+ USING (workspace_id IN (SELECT get_user_workspace_ids()))
245
+ WITH CHECK (workspace_id IN (SELECT get_user_workspace_ids()));
246
+ CREATE POLICY "ws_delete_agent_keys" ON agent_keys FOR DELETE TO authenticated
247
+ USING (workspace_id IN (SELECT get_user_workspace_ids()));
248
+
249
+ -- agent_permissions
250
+ CREATE POLICY "ws_select_agent_permissions" ON agent_permissions FOR SELECT TO authenticated
251
+ USING (workspace_id IN (SELECT get_user_workspace_ids()));
252
+ CREATE POLICY "ws_insert_agent_permissions" ON agent_permissions FOR INSERT TO authenticated
253
+ WITH CHECK (workspace_id IN (SELECT get_user_workspace_ids()));
254
+ CREATE POLICY "ws_update_agent_permissions" ON agent_permissions FOR UPDATE TO authenticated
255
+ USING (workspace_id IN (SELECT get_user_workspace_ids()))
256
+ WITH CHECK (workspace_id IN (SELECT get_user_workspace_ids()));
257
+ CREATE POLICY "ws_delete_agent_permissions" ON agent_permissions FOR DELETE TO authenticated
258
+ USING (workspace_id IN (SELECT get_user_workspace_ids()));
259
+
260
+ -- app_settings (SELECT + UPDATE only — created by trigger)
261
+ CREATE POLICY "ws_select_app_settings" ON app_settings FOR SELECT TO authenticated
262
+ USING (workspace_id IN (SELECT get_user_workspace_ids()));
263
+ CREATE POLICY "ws_update_app_settings" ON app_settings FOR UPDATE TO authenticated
264
+ USING (workspace_id IN (SELECT get_user_workspace_ids()))
265
+ WITH CHECK (workspace_id IN (SELECT get_user_workspace_ids()));
266
+
267
+ -- event_log (SELECT only — append-only, INSERT via service_role)
268
+ CREATE POLICY "ws_select_event_log" ON event_log FOR SELECT TO authenticated
269
+ USING (workspace_id IN (SELECT get_user_workspace_ids()));
270
+
271
+ -- request_log (SELECT only)
272
+ CREATE POLICY "ws_select_request_log" ON request_log FOR SELECT TO authenticated
273
+ USING (workspace_id IN (SELECT get_user_workspace_ids()));
274
+
275
+ -- webhook_subscriptions (new RLS)
276
+ ALTER TABLE webhook_subscriptions ENABLE ROW LEVEL SECURITY;
277
+ CREATE POLICY "ws_select_webhook_subscriptions" ON webhook_subscriptions FOR SELECT TO authenticated
278
+ USING (workspace_id IN (SELECT get_user_workspace_ids()));
279
+ CREATE POLICY "ws_insert_webhook_subscriptions" ON webhook_subscriptions FOR INSERT TO authenticated
280
+ WITH CHECK (workspace_id IN (SELECT get_user_workspace_ids()));
281
+ CREATE POLICY "ws_update_webhook_subscriptions" ON webhook_subscriptions FOR UPDATE TO authenticated
282
+ USING (workspace_id IN (SELECT get_user_workspace_ids()))
283
+ WITH CHECK (workspace_id IN (SELECT get_user_workspace_ids()));
284
+ CREATE POLICY "ws_delete_webhook_subscriptions" ON webhook_subscriptions FOR DELETE TO authenticated
285
+ USING (workspace_id IN (SELECT get_user_workspace_ids()));
286
+
287
+ -- agent_capabilities (new RLS)
288
+ ALTER TABLE agent_capabilities ENABLE ROW LEVEL SECURITY;
289
+ CREATE POLICY "ws_select_agent_capabilities" ON agent_capabilities FOR SELECT TO authenticated
290
+ USING (workspace_id IN (SELECT get_user_workspace_ids()));
291
+ CREATE POLICY "ws_insert_agent_capabilities" ON agent_capabilities FOR INSERT TO authenticated
292
+ WITH CHECK (workspace_id IN (SELECT get_user_workspace_ids()));
293
+ CREATE POLICY "ws_update_agent_capabilities" ON agent_capabilities FOR UPDATE TO authenticated
294
+ USING (workspace_id IN (SELECT get_user_workspace_ids()))
295
+ WITH CHECK (workspace_id IN (SELECT get_user_workspace_ids()));
296
+ CREATE POLICY "ws_delete_agent_capabilities" ON agent_capabilities FOR DELETE TO authenticated
297
+ USING (workspace_id IN (SELECT get_user_workspace_ids()));
298
+
299
+ -- ── 1i. Workspace_id immutability trigger ─────────────────────────────
300
+
301
+ CREATE OR REPLACE FUNCTION prevent_workspace_reassignment()
302
+ RETURNS trigger LANGUAGE plpgsql AS $$
303
+ BEGIN
304
+ IF NEW.workspace_id != OLD.workspace_id THEN
305
+ RAISE EXCEPTION 'workspace_id cannot be changed after creation';
306
+ END IF;
307
+ RETURN NEW;
308
+ END;
309
+ $$;
310
+
311
+ CREATE TRIGGER immutable_workspace_id BEFORE UPDATE ON projects
312
+ FOR EACH ROW EXECUTE FUNCTION prevent_workspace_reassignment();
313
+ CREATE TRIGGER immutable_workspace_id BEFORE UPDATE ON departments
314
+ FOR EACH ROW EXECUTE FUNCTION prevent_workspace_reassignment();
315
+ CREATE TRIGGER immutable_workspace_id BEFORE UPDATE ON tasks
316
+ FOR EACH ROW EXECUTE FUNCTION prevent_workspace_reassignment();
317
+ CREATE TRIGGER immutable_workspace_id BEFORE UPDATE ON agent_keys
318
+ FOR EACH ROW EXECUTE FUNCTION prevent_workspace_reassignment();
319
+ CREATE TRIGGER immutable_workspace_id BEFORE UPDATE ON agent_permissions
320
+ FOR EACH ROW EXECUTE FUNCTION prevent_workspace_reassignment();
321
+ CREATE TRIGGER immutable_workspace_id BEFORE UPDATE ON app_settings
322
+ FOR EACH ROW EXECUTE FUNCTION prevent_workspace_reassignment();
323
+ CREATE TRIGGER immutable_workspace_id BEFORE UPDATE ON webhook_subscriptions
324
+ FOR EACH ROW EXECUTE FUNCTION prevent_workspace_reassignment();
325
+
326
+ -- ── 1j. Cross-workspace integrity triggers ────────────────────────────
327
+
328
+ CREATE OR REPLACE FUNCTION check_workspace_consistency()
329
+ RETURNS trigger LANGUAGE plpgsql AS $$
330
+ BEGIN
331
+ IF TG_TABLE_NAME = 'agent_permissions' THEN
332
+ IF NOT EXISTS (SELECT 1 FROM projects WHERE id = NEW.project_id AND workspace_id = NEW.workspace_id) THEN
333
+ RAISE EXCEPTION 'cross-workspace reference: project % not in workspace %', NEW.project_id, NEW.workspace_id;
334
+ END IF;
335
+ IF NEW.department_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM departments WHERE id = NEW.department_id AND workspace_id = NEW.workspace_id) THEN
336
+ RAISE EXCEPTION 'cross-workspace reference: department % not in workspace %', NEW.department_id, NEW.workspace_id;
337
+ END IF;
338
+ END IF;
339
+
340
+ IF TG_TABLE_NAME = 'tasks' THEN
341
+ IF NOT EXISTS (SELECT 1 FROM projects WHERE id = NEW.project_id AND workspace_id = NEW.workspace_id) THEN
342
+ RAISE EXCEPTION 'cross-workspace reference: project % not in workspace %', NEW.project_id, NEW.workspace_id;
343
+ END IF;
344
+ IF NEW.assigned_to_agent_key_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM agent_keys WHERE id = NEW.assigned_to_agent_key_id AND workspace_id = NEW.workspace_id) THEN
345
+ RAISE EXCEPTION 'cross-workspace reference: assignee % not in workspace %', NEW.assigned_to_agent_key_id, NEW.workspace_id;
346
+ END IF;
347
+ END IF;
348
+
349
+ IF TG_TABLE_NAME = 'webhook_subscriptions' THEN
350
+ IF NOT EXISTS (SELECT 1 FROM projects WHERE id = NEW.project_id AND workspace_id = NEW.workspace_id) THEN
351
+ RAISE EXCEPTION 'cross-workspace reference: project % not in workspace %', NEW.project_id, NEW.workspace_id;
352
+ END IF;
353
+ END IF;
354
+
355
+ RETURN NEW;
356
+ END;
357
+ $$;
358
+
359
+ CREATE TRIGGER enforce_workspace_consistency BEFORE INSERT OR UPDATE ON agent_permissions
360
+ FOR EACH ROW EXECUTE FUNCTION check_workspace_consistency();
361
+ CREATE TRIGGER enforce_workspace_consistency BEFORE INSERT OR UPDATE ON tasks
362
+ FOR EACH ROW EXECUTE FUNCTION check_workspace_consistency();
363
+ CREATE TRIGGER enforce_workspace_consistency BEFORE INSERT OR UPDATE ON webhook_subscriptions
364
+ FOR EACH ROW EXECUTE FUNCTION check_workspace_consistency();
365
+
366
+ -- ── 1k. Update RPCs ──────────────────────────────────────────────────
367
+
368
+ -- update_agent_permissions: replace admin_users check with workspace membership
369
+ CREATE OR REPLACE FUNCTION update_agent_permissions(p_key_id uuid, p_rows jsonb)
370
+ RETURNS void
371
+ LANGUAGE plpgsql
372
+ SECURITY DEFINER
373
+ SET search_path = public
374
+ AS $$
375
+ DECLARE
376
+ v_workspace_id uuid;
377
+ v_row jsonb;
378
+ BEGIN
379
+ -- Look up workspace from agent key
380
+ SELECT workspace_id INTO v_workspace_id FROM agent_keys WHERE id = p_key_id;
381
+ IF v_workspace_id IS NULL THEN
382
+ RAISE EXCEPTION 'unauthorized: agent key not found';
383
+ END IF;
384
+
385
+ -- Check caller belongs to workspace (auth.uid() is NULL for service_role calls)
386
+ IF auth.uid() IS NOT NULL THEN
387
+ IF NOT EXISTS (SELECT 1 FROM workspace_members WHERE workspace_id = v_workspace_id AND user_id = auth.uid()) THEN
388
+ RAISE EXCEPTION 'unauthorized';
389
+ END IF;
390
+ END IF;
391
+
392
+ -- Atomic delete + insert
393
+ DELETE FROM agent_permissions WHERE agent_key_id = p_key_id;
394
+
395
+ FOR v_row IN SELECT * FROM jsonb_array_elements(p_rows) LOOP
396
+ INSERT INTO agent_permissions (
397
+ agent_key_id, project_id, department_id,
398
+ can_read, can_create, can_update, can_assign,
399
+ workspace_id
400
+ ) VALUES (
401
+ p_key_id,
402
+ (v_row ->> 'project_id')::uuid,
403
+ (v_row ->> 'department_id')::uuid,
404
+ COALESCE((v_row ->> 'can_read')::boolean, false),
405
+ COALESCE((v_row ->> 'can_create')::boolean, false),
406
+ COALESCE((v_row ->> 'can_update')::boolean, false),
407
+ COALESCE((v_row ->> 'can_assign')::boolean, false),
408
+ v_workspace_id
409
+ );
410
+ END LOOP;
411
+ END;
412
+ $$;
413
+
414
+ -- create_task_with_event: add workspace_id support
415
+ CREATE OR REPLACE FUNCTION create_task_with_event(p_payload jsonb)
416
+ RETURNS tasks
417
+ LANGUAGE plpgsql
418
+ SECURITY DEFINER
419
+ SET search_path = public
420
+ AS $$
421
+ DECLARE
422
+ v_task tasks;
423
+ v_project_id uuid;
424
+ v_department_id uuid;
425
+ v_assigned_to uuid;
426
+ v_requested_by uuid;
427
+ v_workspace_id uuid;
428
+ BEGIN
429
+ v_project_id := (p_payload ->> 'project_id')::uuid;
430
+ v_department_id := (p_payload ->> 'department_id')::uuid;
431
+ v_assigned_to := (p_payload ->> 'assigned_to_agent_key_id')::uuid;
432
+ v_requested_by := (p_payload ->> 'requested_by_agent_key_id')::uuid;
433
+ v_workspace_id := (p_payload ->> 'workspace_id')::uuid;
434
+
435
+ -- Validate workspace_id is provided
436
+ IF v_workspace_id IS NULL THEN
437
+ RAISE EXCEPTION 'workspace_required:workspace_id is required';
438
+ END IF;
439
+
440
+ -- Validate project exists, not archived, and belongs to workspace
441
+ IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id AND NOT is_archived AND workspace_id = v_workspace_id) THEN
442
+ -- Check if project exists at all
443
+ IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN
444
+ RAISE EXCEPTION 'project_not_found:Project not found';
445
+ END IF;
446
+ IF EXISTS (SELECT 1 FROM projects WHERE id = v_project_id AND is_archived) THEN
447
+ RAISE EXCEPTION 'project_archived:Project is archived';
448
+ END IF;
449
+ RAISE EXCEPTION 'project_not_found:Project not in workspace';
450
+ END IF;
451
+
452
+ -- Validate department if provided
453
+ IF v_department_id IS NOT NULL THEN
454
+ IF NOT EXISTS (SELECT 1 FROM departments WHERE id = v_department_id AND NOT is_archived AND workspace_id = v_workspace_id) THEN
455
+ IF NOT EXISTS (SELECT 1 FROM departments WHERE id = v_department_id) THEN
456
+ RAISE EXCEPTION 'department_not_found:Department not found';
457
+ END IF;
458
+ IF EXISTS (SELECT 1 FROM departments WHERE id = v_department_id AND is_archived) THEN
459
+ RAISE EXCEPTION 'department_archived:Department is archived';
460
+ END IF;
461
+ RAISE EXCEPTION 'department_not_found:Department not in workspace';
462
+ END IF;
463
+ END IF;
464
+
465
+ -- Validate assignee if provided
466
+ IF v_assigned_to IS NOT NULL THEN
467
+ IF NOT EXISTS (SELECT 1 FROM agent_keys WHERE id = v_assigned_to AND is_active AND workspace_id = v_workspace_id) THEN
468
+ RAISE EXCEPTION 'invalid_assignee:Assignee not found or inactive';
469
+ END IF;
470
+ END IF;
471
+
472
+ -- Insert task
473
+ INSERT INTO tasks (
474
+ project_id, department_id, priority, description, notes, due_date, status,
475
+ created_by_type, created_by_id, updated_by_type, updated_by_id, source,
476
+ assigned_to_agent_key_id, requested_by_agent_key_id,
477
+ delegation_depth, assignment_chain, workspace_id
478
+ ) VALUES (
479
+ v_project_id,
480
+ v_department_id,
481
+ COALESCE(p_payload ->> 'priority', 'medium')::priority,
482
+ p_payload ->> 'description',
483
+ p_payload ->> 'notes',
484
+ (p_payload ->> 'due_date')::timestamptz,
485
+ COALESCE(p_payload ->> 'status', 'todo')::status,
486
+ (p_payload ->> 'created_by_type')::actor_type,
487
+ p_payload ->> 'created_by_id',
488
+ (p_payload ->> 'created_by_type')::actor_type,
489
+ p_payload ->> 'created_by_id',
490
+ (p_payload ->> 'source')::source,
491
+ v_assigned_to,
492
+ v_requested_by,
493
+ 0,
494
+ '{}',
495
+ v_workspace_id
496
+ ) RETURNING * INTO v_task;
497
+
498
+ -- Log task.created event
499
+ INSERT INTO event_log (
500
+ event_category, target_type, target_id, event_type,
501
+ actor_type, actor_id, actor_label, source, workspace_id
502
+ ) VALUES (
503
+ 'task', 'task', v_task.id, 'task.created',
504
+ (p_payload ->> 'actor_type')::actor_type,
505
+ p_payload ->> 'actor_id',
506
+ p_payload ->> 'actor_label',
507
+ (p_payload ->> 'source')::source,
508
+ v_workspace_id
509
+ );
510
+
511
+ -- Log task.assigned event if assigned
512
+ IF v_assigned_to IS NOT NULL THEN
513
+ INSERT INTO event_log (
514
+ event_category, target_type, target_id, event_type,
515
+ field_name, new_value,
516
+ actor_type, actor_id, actor_label, source, workspace_id
517
+ ) VALUES (
518
+ 'task', 'task', v_task.id, 'task.assigned',
519
+ 'assigned_to_agent_key_id', to_jsonb(v_assigned_to::text),
520
+ (p_payload ->> 'actor_type')::actor_type,
521
+ p_payload ->> 'actor_id',
522
+ p_payload ->> 'actor_label',
523
+ (p_payload ->> 'source')::source,
524
+ v_workspace_id
525
+ );
526
+ END IF;
527
+
528
+ RETURN v_task;
529
+ END;
530
+ $$;
531
+
532
+ -- update_task_with_events: add workspace_id to event_log inserts
533
+ CREATE OR REPLACE FUNCTION update_task_with_events(p_payload jsonb)
534
+ RETURNS tasks
535
+ LANGUAGE plpgsql
536
+ SECURITY DEFINER
537
+ SET search_path = public
538
+ AS $$
539
+ DECLARE
540
+ v_old tasks;
541
+ v_new tasks;
542
+ v_fields jsonb;
543
+ v_task_id uuid;
544
+ v_version int;
545
+ v_new_assigned_to uuid;
546
+ BEGIN
547
+ v_task_id := (p_payload ->> 'task_id')::uuid;
548
+ v_version := (p_payload ->> 'version')::int;
549
+ v_fields := p_payload -> 'fields';
550
+
551
+ -- Fetch existing task
552
+ SELECT * INTO v_old FROM tasks WHERE id = v_task_id;
553
+ IF NOT FOUND THEN
554
+ RAISE EXCEPTION 'task_not_found:Task not found';
555
+ END IF;
556
+
557
+ -- Version check
558
+ IF v_old.version != v_version THEN
559
+ RAISE EXCEPTION 'version_conflict:Expected version %, found %', v_version, v_old.version;
560
+ END IF;
561
+
562
+ -- Delegation safety checks (if reassigning)
563
+ IF v_fields ? 'assigned_to_agent_key_id' THEN
564
+ v_new_assigned_to := (v_fields ->> 'assigned_to_agent_key_id')::uuid;
565
+ IF v_new_assigned_to IS NOT NULL THEN
566
+ -- Validate assignee exists, is active, and in same workspace
567
+ IF NOT EXISTS (SELECT 1 FROM agent_keys WHERE id = v_new_assigned_to AND is_active AND workspace_id = v_old.workspace_id) THEN
568
+ RAISE EXCEPTION 'invalid_assignee:Assignee not found or inactive';
569
+ END IF;
570
+ -- Circular delegation check
571
+ IF v_new_assigned_to = ANY(v_old.assignment_chain) THEN
572
+ RAISE EXCEPTION 'circular_delegation:Assignee already in delegation chain';
573
+ END IF;
574
+ -- Depth check
575
+ IF v_old.delegation_depth >= 3 THEN
576
+ RAISE EXCEPTION 'delegation_depth_exceeded:Maximum delegation depth (3) reached';
577
+ END IF;
578
+ END IF;
579
+ END IF;
580
+
581
+ -- Update task
582
+ UPDATE tasks SET
583
+ priority = COALESCE((v_fields ->> 'priority')::priority, priority),
584
+ description = COALESCE(v_fields ->> 'description', description),
585
+ notes = CASE WHEN v_fields ? 'notes' THEN v_fields ->> 'notes' ELSE notes END,
586
+ department_id = CASE WHEN v_fields ? 'department_id' THEN (v_fields ->> 'department_id')::uuid ELSE department_id END,
587
+ due_date = CASE WHEN v_fields ? 'due_date' THEN (v_fields ->> 'due_date')::timestamptz ELSE due_date END,
588
+ status = COALESCE((v_fields ->> 'status')::status, status),
589
+ assigned_to_agent_key_id = CASE WHEN v_fields ? 'assigned_to_agent_key_id' THEN (v_fields ->> 'assigned_to_agent_key_id')::uuid ELSE assigned_to_agent_key_id END,
590
+ delegation_depth = CASE
591
+ WHEN v_fields ? 'assigned_to_agent_key_id' AND (v_fields ->> 'assigned_to_agent_key_id')::uuid IS DISTINCT FROM assigned_to_agent_key_id
592
+ THEN delegation_depth + 1
593
+ ELSE delegation_depth
594
+ END,
595
+ assignment_chain = CASE
596
+ WHEN v_fields ? 'assigned_to_agent_key_id' AND (v_fields ->> 'assigned_to_agent_key_id')::uuid IS DISTINCT FROM assigned_to_agent_key_id AND assigned_to_agent_key_id IS NOT NULL
597
+ THEN assignment_chain || assigned_to_agent_key_id
598
+ ELSE assignment_chain
599
+ END,
600
+ updated_by_type = (p_payload ->> 'actor_type')::actor_type,
601
+ updated_by_id = p_payload ->> 'actor_id',
602
+ source = (p_payload ->> 'source')::source,
603
+ version = version + 1
604
+ WHERE id = v_task_id AND version = v_version
605
+ RETURNING * INTO v_new;
606
+
607
+ IF NOT FOUND THEN
608
+ RAISE EXCEPTION 'version_conflict:Concurrent modification detected';
609
+ END IF;
610
+
611
+ -- Log field-level changes
612
+ IF v_old.priority IS DISTINCT FROM v_new.priority THEN
613
+ INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source, workspace_id)
614
+ VALUES ('task', 'task', v_task_id, 'task.updated', 'priority', to_jsonb(v_old.priority::text), to_jsonb(v_new.priority::text), (p_payload ->> 'actor_type')::actor_type, p_payload ->> 'actor_id', p_payload ->> 'actor_label', (p_payload ->> 'source')::source, v_old.workspace_id);
615
+ END IF;
616
+
617
+ IF v_old.description IS DISTINCT FROM v_new.description THEN
618
+ INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source, workspace_id)
619
+ VALUES ('task', 'task', v_task_id, 'task.updated', 'description', to_jsonb(v_old.description), to_jsonb(v_new.description), (p_payload ->> 'actor_type')::actor_type, p_payload ->> 'actor_id', p_payload ->> 'actor_label', (p_payload ->> 'source')::source, v_old.workspace_id);
620
+ END IF;
621
+
622
+ IF v_old.notes IS DISTINCT FROM v_new.notes THEN
623
+ INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source, workspace_id)
624
+ VALUES ('task', 'task', v_task_id, 'task.updated', 'notes', to_jsonb(v_old.notes), to_jsonb(v_new.notes), (p_payload ->> 'actor_type')::actor_type, p_payload ->> 'actor_id', p_payload ->> 'actor_label', (p_payload ->> 'source')::source, v_old.workspace_id);
625
+ END IF;
626
+
627
+ IF v_old.department_id IS DISTINCT FROM v_new.department_id THEN
628
+ INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source, workspace_id)
629
+ VALUES ('task', 'task', v_task_id, 'task.updated', 'department_id', to_jsonb(v_old.department_id::text), to_jsonb(v_new.department_id::text), (p_payload ->> 'actor_type')::actor_type, p_payload ->> 'actor_id', p_payload ->> 'actor_label', (p_payload ->> 'source')::source, v_old.workspace_id);
630
+ END IF;
631
+
632
+ IF v_old.due_date IS DISTINCT FROM v_new.due_date THEN
633
+ INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source, workspace_id)
634
+ VALUES ('task', 'task', v_task_id, 'task.updated', 'due_date', to_jsonb(v_old.due_date::text), to_jsonb(v_new.due_date::text), (p_payload ->> 'actor_type')::actor_type, p_payload ->> 'actor_id', p_payload ->> 'actor_label', (p_payload ->> 'source')::source, v_old.workspace_id);
635
+ END IF;
636
+
637
+ IF v_old.status IS DISTINCT FROM v_new.status THEN
638
+ INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source, workspace_id)
639
+ VALUES ('task', 'task', v_task_id, 'task.updated', 'status', to_jsonb(v_old.status::text), to_jsonb(v_new.status::text), (p_payload ->> 'actor_type')::actor_type, p_payload ->> 'actor_id', p_payload ->> 'actor_label', (p_payload ->> 'source')::source, v_old.workspace_id);
640
+ END IF;
641
+
642
+ IF v_old.assigned_to_agent_key_id IS DISTINCT FROM v_new.assigned_to_agent_key_id THEN
643
+ INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source, workspace_id)
644
+ VALUES ('task', 'task', v_task_id, 'task.reassigned', 'assigned_to_agent_key_id', to_jsonb(v_old.assigned_to_agent_key_id::text), to_jsonb(v_new.assigned_to_agent_key_id::text), (p_payload ->> 'actor_type')::actor_type, p_payload ->> 'actor_id', p_payload ->> 'actor_label', (p_payload ->> 'source')::source, v_old.workspace_id);
645
+ END IF;
646
+
647
+ RETURN v_new;
648
+ END;
649
+ $$;
650
+
651
+ -- get_tasks_page: add workspace_id parameter
652
+ CREATE OR REPLACE FUNCTION get_tasks_page(
653
+ p_project_id uuid,
654
+ p_workspace_id uuid,
655
+ p_department_id uuid DEFAULT NULL,
656
+ p_status status DEFAULT NULL,
657
+ p_priority priority DEFAULT NULL,
658
+ p_updated_after timestamptz DEFAULT NULL,
659
+ p_cursor_created_at timestamptz DEFAULT NULL,
660
+ p_cursor_id uuid DEFAULT NULL,
661
+ p_limit integer DEFAULT 20,
662
+ p_search text DEFAULT NULL,
663
+ p_assigned_to uuid DEFAULT NULL,
664
+ p_requested_by uuid DEFAULT NULL
665
+ )
666
+ RETURNS TABLE (
667
+ id uuid,
668
+ project_id uuid,
669
+ department_id uuid,
670
+ priority priority,
671
+ description text,
672
+ notes text,
673
+ due_date timestamptz,
674
+ status status,
675
+ version integer,
676
+ created_at timestamptz,
677
+ updated_at timestamptz,
678
+ created_by_type actor_type,
679
+ created_by_id text,
680
+ updated_by_type actor_type,
681
+ updated_by_id text,
682
+ source source,
683
+ assigned_to_agent_key_id uuid,
684
+ requested_by_agent_key_id uuid,
685
+ delegation_depth integer,
686
+ assignment_chain uuid[]
687
+ )
688
+ LANGUAGE sql
689
+ STABLE
690
+ AS $$
691
+ SELECT
692
+ t.id, t.project_id, t.department_id, t.priority, t.description, t.notes,
693
+ t.due_date, t.status, t.version, t.created_at, t.updated_at,
694
+ t.created_by_type, t.created_by_id, t.updated_by_type, t.updated_by_id, t.source,
695
+ t.assigned_to_agent_key_id, t.requested_by_agent_key_id,
696
+ t.delegation_depth, t.assignment_chain
697
+ FROM tasks t
698
+ WHERE t.project_id = p_project_id
699
+ AND t.workspace_id = p_workspace_id
700
+ AND (p_department_id IS NULL OR t.department_id = p_department_id)
701
+ AND (p_status IS NULL OR t.status = p_status)
702
+ AND (p_priority IS NULL OR t.priority = p_priority)
703
+ AND (p_updated_after IS NULL OR t.updated_at > p_updated_after)
704
+ AND (p_assigned_to IS NULL OR t.assigned_to_agent_key_id = p_assigned_to)
705
+ AND (p_requested_by IS NULL OR t.requested_by_agent_key_id = p_requested_by)
706
+ AND (
707
+ p_cursor_created_at IS NULL
708
+ OR (t.created_at, t.id) > (p_cursor_created_at, p_cursor_id)
709
+ )
710
+ AND (
711
+ p_search IS NULL
712
+ OR t.search_vector @@ websearch_to_tsquery('english', p_search)
713
+ )
714
+ ORDER BY t.created_at ASC, t.id ASC
715
+ LIMIT LEAST(p_limit, 50);
716
+ $$;
717
+
718
+ -- ── 1l. Auto-provision trigger ────────────────────────────────────────
719
+
720
+ CREATE OR REPLACE FUNCTION handle_new_user()
721
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
722
+ AS $$
723
+ DECLARE v_ws_id uuid;
724
+ BEGIN
725
+ INSERT INTO workspaces (name, slug, owner_id)
726
+ VALUES ('My Workspace', 'ws-' || substr(md5(NEW.id::text), 1, 8), NEW.id)
727
+ RETURNING id INTO v_ws_id;
728
+
729
+ INSERT INTO workspace_members (workspace_id, user_id, role)
730
+ VALUES (v_ws_id, NEW.id, 'owner');
731
+
732
+ INSERT INTO app_settings (workspace_id, department_required, require_human_approval_for_agent_keys)
733
+ VALUES (v_ws_id, false, false);
734
+
735
+ RETURN NEW;
736
+ EXCEPTION WHEN OTHERS THEN
737
+ RAISE WARNING 'handle_new_user failed for %: %', NEW.id, SQLERRM;
738
+ RETURN NEW;
739
+ END;
740
+ $$;
741
+
742
+ CREATE TRIGGER on_auth_user_created
743
+ AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION handle_new_user();
744
+
745
+ -- ── 1m. ensure_user_workspace RPC ─────────────────────────────────────
746
+
747
+ CREATE OR REPLACE FUNCTION ensure_user_workspace()
748
+ RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
749
+ AS $$
750
+ DECLARE v_ws_id uuid; v_uid uuid;
751
+ BEGIN
752
+ v_uid := auth.uid();
753
+
754
+ -- Fast path: workspace already exists
755
+ SELECT workspace_id INTO v_ws_id FROM workspace_members WHERE user_id = v_uid LIMIT 1;
756
+ IF v_ws_id IS NOT NULL THEN RETURN v_ws_id; END IF;
757
+
758
+ -- Slow path: create workspace
759
+ INSERT INTO workspaces (name, slug, owner_id)
760
+ VALUES ('My Workspace', 'ws-' || substr(md5(v_uid::text), 1, 8), v_uid)
761
+ ON CONFLICT (owner_id) DO NOTHING
762
+ RETURNING id INTO v_ws_id;
763
+
764
+ -- If we lost the race, fetch existing
765
+ IF v_ws_id IS NULL THEN
766
+ SELECT id INTO v_ws_id FROM workspaces WHERE owner_id = v_uid;
767
+ RETURN v_ws_id;
768
+ END IF;
769
+
770
+ INSERT INTO workspace_members (workspace_id, user_id, role)
771
+ VALUES (v_ws_id, v_uid, 'owner')
772
+ ON CONFLICT (workspace_id, user_id) DO NOTHING;
773
+ INSERT INTO app_settings (workspace_id, department_required, require_human_approval_for_agent_keys)
774
+ VALUES (v_ws_id, false, false)
775
+ ON CONFLICT (workspace_id) DO NOTHING;
776
+ RETURN v_ws_id;
777
+ END;
778
+ $$;
779
+
780
+ -- ── 1n. Drop admin_users ──────────────────────────────────────────────
781
+
782
+ DROP TABLE admin_users;