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.
- package/README.md +121 -0
- package/dist/writbase.js +707 -0
- package/migrations/00001_enums.sql +7 -0
- package/migrations/00002_core_tables.sql +105 -0
- package/migrations/00003_constraints_indexes.sql +69 -0
- package/migrations/00004_rls_policies.sql +169 -0
- package/migrations/00005_seed_app_settings.sql +2 -0
- package/migrations/00006_rate_limit_rpc.sql +14 -0
- package/migrations/00007_rate_limit_rpc_search_path.sql +14 -0
- package/migrations/00008_indexes_and_cleanup.sql +22 -0
- package/migrations/00009_atomic_mutations.sql +175 -0
- package/migrations/00010_user_rate_limits.sql +52 -0
- package/migrations/00011_atomic_permission_update.sql +31 -0
- package/migrations/00012_pg_cron_cleanup.sql +27 -0
- package/migrations/00013_max_agent_keys.sql +4 -0
- package/migrations/00014_event_log_indexes.sql +2 -0
- package/migrations/00015_auth_rate_limits.sql +69 -0
- package/migrations/00016_request_log.sql +42 -0
- package/migrations/00017_task_search.sql +58 -0
- package/migrations/00018_inter_agent_exchange.sql +423 -0
- package/migrations/00019_workspaces.sql +782 -0
- package/migrations/00020_can_comment.sql +46 -0
- package/migrations/00021_webhook_delivery.sql +125 -0
- package/migrations/00022_task_archive.sql +380 -0
- package/migrations/00023_get_top_tasks.sql +21 -0
- package/migrations/00024_agent_key_defaults.sql +55 -0
- package/migrations/00025_production_automation.sql +353 -0
- package/migrations/00026_top_tasks_exclude_terminal.sql +19 -0
- package/migrations/00027_rename_agent_key_defaults.sql +51 -0
- 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;
|