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,46 @@
1
+ -- Add can_comment column to agent_permissions
2
+ ALTER TABLE agent_permissions ADD COLUMN can_comment boolean NOT NULL DEFAULT false;
3
+
4
+ -- Update the RPC function to handle can_comment
5
+ CREATE OR REPLACE FUNCTION update_agent_permissions(p_key_id uuid, p_rows jsonb)
6
+ RETURNS void
7
+ LANGUAGE plpgsql
8
+ SECURITY DEFINER
9
+ SET search_path = public
10
+ AS $$
11
+ DECLARE
12
+ v_workspace_id uuid;
13
+ v_row jsonb;
14
+ BEGIN
15
+ SELECT workspace_id INTO v_workspace_id FROM agent_keys WHERE id = p_key_id;
16
+ IF v_workspace_id IS NULL THEN
17
+ RAISE EXCEPTION 'unauthorized: agent key not found';
18
+ END IF;
19
+
20
+ IF auth.uid() IS NOT NULL THEN
21
+ IF NOT EXISTS (SELECT 1 FROM workspace_members WHERE workspace_id = v_workspace_id AND user_id = auth.uid()) THEN
22
+ RAISE EXCEPTION 'unauthorized';
23
+ END IF;
24
+ END IF;
25
+
26
+ DELETE FROM agent_permissions WHERE agent_key_id = p_key_id;
27
+
28
+ FOR v_row IN SELECT * FROM jsonb_array_elements(p_rows) LOOP
29
+ INSERT INTO agent_permissions (
30
+ agent_key_id, project_id, department_id,
31
+ can_read, can_create, can_update, can_assign, can_comment,
32
+ workspace_id
33
+ ) VALUES (
34
+ p_key_id,
35
+ (v_row ->> 'project_id')::uuid,
36
+ (v_row ->> 'department_id')::uuid,
37
+ COALESCE((v_row ->> 'can_read')::boolean, false),
38
+ COALESCE((v_row ->> 'can_create')::boolean, false),
39
+ COALESCE((v_row ->> 'can_update')::boolean, false),
40
+ COALESCE((v_row ->> 'can_assign')::boolean, false),
41
+ COALESCE((v_row ->> 'can_comment')::boolean, false),
42
+ v_workspace_id
43
+ );
44
+ END LOOP;
45
+ END;
46
+ $$;
@@ -0,0 +1,125 @@
1
+ -- Webhook delivery: Postgres trigger on tasks → pg_net → webhook-deliver Edge Function
2
+ --
3
+ -- The trigger function is always created (no extension dependency).
4
+ -- The trigger attachment is gated on pg_net availability, matching the
5
+ -- conditional pattern from 00012_pg_cron_cleanup.sql.
6
+
7
+ -- ══════════════════════════════════════════════════════════════════════
8
+ -- Trigger function: derive webhook events from OLD/NEW diff, call Edge Function via pg_net
9
+ -- ══════════════════════════════════════════════════════════════════════
10
+ CREATE OR REPLACE FUNCTION notify_webhook_subscribers()
11
+ RETURNS trigger
12
+ LANGUAGE plpgsql
13
+ SECURITY DEFINER
14
+ SET search_path = public
15
+ AS $$
16
+ DECLARE
17
+ v_events text[];
18
+ v_payload jsonb;
19
+ v_internal_secret text;
20
+ v_edge_url text;
21
+ BEGIN
22
+ -- 1. Derive webhook event types from OLD/NEW diff
23
+ v_events := '{}';
24
+
25
+ IF TG_OP = 'INSERT' THEN
26
+ v_events := array_append(v_events, 'task.created');
27
+ IF NEW.assigned_to_agent_key_id IS NOT NULL THEN
28
+ v_events := array_append(v_events, 'task.assigned');
29
+ END IF;
30
+ ELSIF TG_OP = 'UPDATE' THEN
31
+ IF NEW.status IS DISTINCT FROM OLD.status THEN
32
+ v_events := array_append(v_events, 'task.updated');
33
+ IF NEW.status = 'done' THEN
34
+ v_events := array_append(v_events, 'task.completed');
35
+ ELSIF NEW.status = 'failed' THEN
36
+ v_events := array_append(v_events, 'task.failed');
37
+ END IF;
38
+ END IF;
39
+
40
+ IF NEW.priority IS DISTINCT FROM OLD.priority
41
+ OR NEW.description IS DISTINCT FROM OLD.description
42
+ OR NEW.notes IS DISTINCT FROM OLD.notes
43
+ OR NEW.department_id IS DISTINCT FROM OLD.department_id
44
+ OR NEW.due_date IS DISTINCT FROM OLD.due_date THEN
45
+ IF NOT ('task.updated' = ANY(v_events)) THEN
46
+ v_events := array_append(v_events, 'task.updated');
47
+ END IF;
48
+ END IF;
49
+
50
+ IF NEW.assigned_to_agent_key_id IS DISTINCT FROM OLD.assigned_to_agent_key_id THEN
51
+ IF OLD.assigned_to_agent_key_id IS NULL THEN
52
+ v_events := array_append(v_events, 'task.assigned');
53
+ ELSE
54
+ v_events := array_append(v_events, 'task.reassigned');
55
+ END IF;
56
+ END IF;
57
+ END IF;
58
+
59
+ -- 2. No events derived → nothing to do
60
+ IF array_length(v_events, 1) IS NULL THEN
61
+ RETURN NEW;
62
+ END IF;
63
+
64
+ -- 3. Build payload for Edge Function
65
+ v_payload := jsonb_build_object(
66
+ 'task_id', NEW.id,
67
+ 'project_id', NEW.project_id,
68
+ 'workspace_id', NEW.workspace_id,
69
+ 'version', NEW.version,
70
+ 'events', to_jsonb(v_events),
71
+ 'new_record', to_jsonb(NEW),
72
+ 'old_record', CASE WHEN TG_OP = 'UPDATE' THEN to_jsonb(OLD) ELSE NULL END
73
+ );
74
+
75
+ -- 4. Get config: prefer Vault (hosted), fall back to GUC (local dev)
76
+ BEGIN
77
+ SELECT decrypted_secret INTO v_internal_secret
78
+ FROM vault.decrypted_secrets WHERE name = 'webhook_internal_secret' LIMIT 1;
79
+ SELECT decrypted_secret INTO v_edge_url
80
+ FROM vault.decrypted_secrets WHERE name = 'edge_function_url' LIMIT 1;
81
+ EXCEPTION WHEN OTHERS THEN
82
+ -- Vault not available (local dev)
83
+ NULL;
84
+ END;
85
+ v_internal_secret := COALESCE(v_internal_secret,
86
+ current_setting('app.settings.webhook_internal_secret', true));
87
+ v_edge_url := COALESCE(v_edge_url,
88
+ current_setting('app.settings.edge_function_url', true));
89
+
90
+ -- 5. Call Edge Function via pg_net (fires after commit)
91
+ IF v_edge_url IS NOT NULL THEN
92
+ BEGIN
93
+ PERFORM net.http_post(
94
+ url := v_edge_url || '/webhook-deliver',
95
+ body := v_payload,
96
+ headers := jsonb_build_object(
97
+ 'Content-Type', 'application/json',
98
+ 'X-Webhook-Internal-Secret', COALESCE(v_internal_secret, '')
99
+ )
100
+ );
101
+ EXCEPTION WHEN OTHERS THEN
102
+ -- pg_net not available — log and continue (task mutation must not fail)
103
+ RAISE NOTICE 'webhook delivery skipped: %', SQLERRM;
104
+ END;
105
+ END IF;
106
+
107
+ RETURN NEW;
108
+ END;
109
+ $$;
110
+
111
+ -- ══════════════════════════════════════════════════════════════════════
112
+ -- Attach trigger only if pg_net is available
113
+ -- ══════════════════════════════════════════════════════════════════════
114
+ DO $outer$
115
+ BEGIN
116
+ IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_net') THEN
117
+ CREATE TRIGGER trg_webhook_notify
118
+ AFTER INSERT OR UPDATE ON tasks
119
+ FOR EACH ROW
120
+ EXECUTE FUNCTION notify_webhook_subscribers();
121
+ ELSE
122
+ RAISE NOTICE 'pg_net not available — skipping webhook trigger attachment';
123
+ END IF;
124
+ END;
125
+ $outer$;
@@ -0,0 +1,380 @@
1
+ -- Task archiving support
2
+ -- 1. Add is_archived column to tasks with partial index
3
+ -- 2. Add can_archive permission to agent_permissions
4
+ -- 3. Recreate get_tasks_page with is_archived support (new return type requires DROP)
5
+ -- 4. Update update_task_with_events to handle is_archived field + event logging
6
+ -- 5. Update notify_webhook_subscribers trigger for archive events
7
+ -- 6. Update update_agent_permissions RPC to include can_archive
8
+
9
+ -- ══════════════════════════════════════════════════════════════════════
10
+ -- 1. Add is_archived column to tasks
11
+ -- ══════════════════════════════════════════════════════════════════════
12
+ ALTER TABLE tasks ADD COLUMN is_archived boolean NOT NULL DEFAULT false;
13
+ CREATE INDEX tasks_not_archived_idx ON tasks (workspace_id, project_id)
14
+ WHERE is_archived = false;
15
+
16
+ -- ══════════════════════════════════════════════════════════════════════
17
+ -- 2. Add can_archive permission
18
+ -- ══════════════════════════════════════════════════════════════════════
19
+ ALTER TABLE agent_permissions ADD COLUMN can_archive boolean NOT NULL DEFAULT false;
20
+
21
+ -- ══════════════════════════════════════════════════════════════════════
22
+ -- 3. Recreate get_tasks_page with is_archived support
23
+ -- ══════════════════════════════════════════════════════════════════════
24
+ DROP FUNCTION get_tasks_page(uuid, uuid, uuid, status, priority, timestamptz, timestamptz, uuid, integer, text, uuid, uuid);
25
+
26
+ CREATE FUNCTION get_tasks_page(
27
+ p_project_id uuid,
28
+ p_workspace_id uuid,
29
+ p_department_id uuid DEFAULT NULL,
30
+ p_status status DEFAULT NULL,
31
+ p_priority priority DEFAULT NULL,
32
+ p_updated_after timestamptz DEFAULT NULL,
33
+ p_cursor_created_at timestamptz DEFAULT NULL,
34
+ p_cursor_id uuid DEFAULT NULL,
35
+ p_limit integer DEFAULT 20,
36
+ p_search text DEFAULT NULL,
37
+ p_assigned_to uuid DEFAULT NULL,
38
+ p_requested_by uuid DEFAULT NULL,
39
+ p_include_archived boolean DEFAULT false
40
+ )
41
+ RETURNS TABLE (
42
+ id uuid,
43
+ project_id uuid,
44
+ department_id uuid,
45
+ priority priority,
46
+ description text,
47
+ notes text,
48
+ due_date timestamptz,
49
+ status status,
50
+ version integer,
51
+ created_at timestamptz,
52
+ updated_at timestamptz,
53
+ created_by_type actor_type,
54
+ created_by_id text,
55
+ updated_by_type actor_type,
56
+ updated_by_id text,
57
+ source source,
58
+ assigned_to_agent_key_id uuid,
59
+ requested_by_agent_key_id uuid,
60
+ delegation_depth integer,
61
+ assignment_chain uuid[],
62
+ is_archived boolean
63
+ )
64
+ LANGUAGE sql
65
+ STABLE
66
+ AS $$
67
+ SELECT
68
+ t.id, t.project_id, t.department_id, t.priority, t.description, t.notes,
69
+ t.due_date, t.status, t.version, t.created_at, t.updated_at,
70
+ t.created_by_type, t.created_by_id, t.updated_by_type, t.updated_by_id, t.source,
71
+ t.assigned_to_agent_key_id, t.requested_by_agent_key_id,
72
+ t.delegation_depth, t.assignment_chain, t.is_archived
73
+ FROM tasks t
74
+ WHERE t.project_id = p_project_id
75
+ AND t.workspace_id = p_workspace_id
76
+ AND (p_include_archived OR t.is_archived = false)
77
+ AND (p_department_id IS NULL OR t.department_id = p_department_id)
78
+ AND (p_status IS NULL OR t.status = p_status)
79
+ AND (p_priority IS NULL OR t.priority = p_priority)
80
+ AND (p_updated_after IS NULL OR t.updated_at > p_updated_after)
81
+ AND (p_assigned_to IS NULL OR t.assigned_to_agent_key_id = p_assigned_to)
82
+ AND (p_requested_by IS NULL OR t.requested_by_agent_key_id = p_requested_by)
83
+ AND (
84
+ p_cursor_created_at IS NULL
85
+ OR (t.created_at, t.id) > (p_cursor_created_at, p_cursor_id)
86
+ )
87
+ AND (
88
+ p_search IS NULL
89
+ OR t.search_vector @@ websearch_to_tsquery('english', p_search)
90
+ )
91
+ ORDER BY t.created_at ASC, t.id ASC
92
+ LIMIT LEAST(p_limit, 50);
93
+ $$;
94
+
95
+ -- ══════════════════════════════════════════════════════════════════════
96
+ -- 4. Update update_task_with_events to handle is_archived
97
+ -- ══════════════════════════════════════════════════════════════════════
98
+ CREATE OR REPLACE FUNCTION update_task_with_events(p_payload jsonb)
99
+ RETURNS tasks
100
+ LANGUAGE plpgsql
101
+ SECURITY DEFINER
102
+ SET search_path = public
103
+ AS $$
104
+ DECLARE
105
+ v_old tasks;
106
+ v_new tasks;
107
+ v_fields jsonb;
108
+ v_task_id uuid;
109
+ v_version int;
110
+ v_new_assigned_to uuid;
111
+ BEGIN
112
+ v_task_id := (p_payload ->> 'task_id')::uuid;
113
+ v_version := (p_payload ->> 'version')::int;
114
+ v_fields := p_payload -> 'fields';
115
+
116
+ -- Fetch existing task
117
+ SELECT * INTO v_old FROM tasks WHERE id = v_task_id;
118
+ IF NOT FOUND THEN
119
+ RAISE EXCEPTION 'task_not_found:Task not found';
120
+ END IF;
121
+
122
+ -- Version check
123
+ IF v_old.version != v_version THEN
124
+ RAISE EXCEPTION 'version_conflict:Expected version %, found %', v_version, v_old.version;
125
+ END IF;
126
+
127
+ -- Delegation safety checks (if reassigning)
128
+ IF v_fields ? 'assigned_to_agent_key_id' THEN
129
+ v_new_assigned_to := (v_fields ->> 'assigned_to_agent_key_id')::uuid;
130
+ IF v_new_assigned_to IS NOT NULL THEN
131
+ -- Validate assignee exists, is active, and in same workspace
132
+ 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
133
+ RAISE EXCEPTION 'invalid_assignee:Assignee not found or inactive';
134
+ END IF;
135
+ -- Circular delegation check
136
+ IF v_new_assigned_to = ANY(v_old.assignment_chain) THEN
137
+ RAISE EXCEPTION 'circular_delegation:Assignee already in delegation chain';
138
+ END IF;
139
+ -- Depth check
140
+ IF v_old.delegation_depth >= 3 THEN
141
+ RAISE EXCEPTION 'delegation_depth_exceeded:Maximum delegation depth (3) reached';
142
+ END IF;
143
+ END IF;
144
+ END IF;
145
+
146
+ -- Update task
147
+ UPDATE tasks SET
148
+ priority = COALESCE((v_fields ->> 'priority')::priority, priority),
149
+ description = COALESCE(v_fields ->> 'description', description),
150
+ notes = CASE WHEN v_fields ? 'notes' THEN v_fields ->> 'notes' ELSE notes END,
151
+ department_id = CASE WHEN v_fields ? 'department_id' THEN (v_fields ->> 'department_id')::uuid ELSE department_id END,
152
+ due_date = CASE WHEN v_fields ? 'due_date' THEN (v_fields ->> 'due_date')::timestamptz ELSE due_date END,
153
+ status = COALESCE((v_fields ->> 'status')::status, status),
154
+ 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,
155
+ delegation_depth = CASE
156
+ 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
157
+ THEN delegation_depth + 1
158
+ ELSE delegation_depth
159
+ END,
160
+ assignment_chain = CASE
161
+ 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
162
+ THEN assignment_chain || assigned_to_agent_key_id
163
+ ELSE assignment_chain
164
+ END,
165
+ is_archived = CASE WHEN v_fields ? 'is_archived' THEN (v_fields->>'is_archived')::boolean ELSE is_archived END,
166
+ updated_by_type = (p_payload ->> 'actor_type')::actor_type,
167
+ updated_by_id = p_payload ->> 'actor_id',
168
+ source = (p_payload ->> 'source')::source,
169
+ version = version + 1
170
+ WHERE id = v_task_id AND version = v_version
171
+ RETURNING * INTO v_new;
172
+
173
+ IF NOT FOUND THEN
174
+ RAISE EXCEPTION 'version_conflict:Concurrent modification detected';
175
+ END IF;
176
+
177
+ -- Log field-level changes
178
+ IF v_old.priority IS DISTINCT FROM v_new.priority THEN
179
+ 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)
180
+ 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);
181
+ END IF;
182
+
183
+ IF v_old.description IS DISTINCT FROM v_new.description THEN
184
+ 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)
185
+ 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);
186
+ END IF;
187
+
188
+ IF v_old.notes IS DISTINCT FROM v_new.notes THEN
189
+ 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)
190
+ 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);
191
+ END IF;
192
+
193
+ IF v_old.department_id IS DISTINCT FROM v_new.department_id THEN
194
+ 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)
195
+ 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);
196
+ END IF;
197
+
198
+ IF v_old.due_date IS DISTINCT FROM v_new.due_date THEN
199
+ 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)
200
+ 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);
201
+ END IF;
202
+
203
+ IF v_old.status IS DISTINCT FROM v_new.status THEN
204
+ 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)
205
+ 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);
206
+ END IF;
207
+
208
+ IF v_old.assigned_to_agent_key_id IS DISTINCT FROM v_new.assigned_to_agent_key_id THEN
209
+ 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)
210
+ 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);
211
+ END IF;
212
+
213
+ IF v_old.is_archived IS DISTINCT FROM v_new.is_archived THEN
214
+ 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)
215
+ VALUES ('task', 'task', v_task_id,
216
+ CASE WHEN v_new.is_archived THEN 'task.archived' ELSE 'task.unarchived' END,
217
+ 'is_archived', to_jsonb(v_old.is_archived), to_jsonb(v_new.is_archived),
218
+ (p_payload ->> 'actor_type')::actor_type, p_payload ->> 'actor_id', p_payload ->> 'actor_label',
219
+ (p_payload ->> 'source')::source, v_old.workspace_id);
220
+ END IF;
221
+
222
+ RETURN v_new;
223
+ END;
224
+ $$;
225
+
226
+ -- ══════════════════════════════════════════════════════════════════════
227
+ -- 5. Update notify_webhook_subscribers trigger for archive events
228
+ -- ══════════════════════════════════════════════════════════════════════
229
+ CREATE OR REPLACE FUNCTION notify_webhook_subscribers()
230
+ RETURNS trigger
231
+ LANGUAGE plpgsql
232
+ SECURITY DEFINER
233
+ SET search_path = public
234
+ AS $$
235
+ DECLARE
236
+ v_events text[];
237
+ v_payload jsonb;
238
+ v_internal_secret text;
239
+ v_edge_url text;
240
+ BEGIN
241
+ -- 1. Derive webhook event types from OLD/NEW diff
242
+ v_events := '{}';
243
+
244
+ IF TG_OP = 'INSERT' THEN
245
+ v_events := array_append(v_events, 'task.created');
246
+ IF NEW.assigned_to_agent_key_id IS NOT NULL THEN
247
+ v_events := array_append(v_events, 'task.assigned');
248
+ END IF;
249
+ ELSIF TG_OP = 'UPDATE' THEN
250
+ IF NEW.status IS DISTINCT FROM OLD.status THEN
251
+ v_events := array_append(v_events, 'task.updated');
252
+ IF NEW.status = 'done' THEN
253
+ v_events := array_append(v_events, 'task.completed');
254
+ ELSIF NEW.status = 'failed' THEN
255
+ v_events := array_append(v_events, 'task.failed');
256
+ END IF;
257
+ END IF;
258
+
259
+ IF NEW.priority IS DISTINCT FROM OLD.priority
260
+ OR NEW.description IS DISTINCT FROM OLD.description
261
+ OR NEW.notes IS DISTINCT FROM OLD.notes
262
+ OR NEW.department_id IS DISTINCT FROM OLD.department_id
263
+ OR NEW.due_date IS DISTINCT FROM OLD.due_date THEN
264
+ IF NOT ('task.updated' = ANY(v_events)) THEN
265
+ v_events := array_append(v_events, 'task.updated');
266
+ END IF;
267
+ END IF;
268
+
269
+ IF NEW.assigned_to_agent_key_id IS DISTINCT FROM OLD.assigned_to_agent_key_id THEN
270
+ IF OLD.assigned_to_agent_key_id IS NULL THEN
271
+ v_events := array_append(v_events, 'task.assigned');
272
+ ELSE
273
+ v_events := array_append(v_events, 'task.reassigned');
274
+ END IF;
275
+ END IF;
276
+
277
+ IF NEW.is_archived IS DISTINCT FROM OLD.is_archived THEN
278
+ v_events := array_append(v_events,
279
+ CASE WHEN NEW.is_archived THEN 'task.archived' ELSE 'task.unarchived' END);
280
+ END IF;
281
+ END IF;
282
+
283
+ -- 2. No events derived → nothing to do
284
+ IF array_length(v_events, 1) IS NULL THEN
285
+ RETURN NEW;
286
+ END IF;
287
+
288
+ -- 3. Build payload for Edge Function
289
+ v_payload := jsonb_build_object(
290
+ 'task_id', NEW.id,
291
+ 'project_id', NEW.project_id,
292
+ 'workspace_id', NEW.workspace_id,
293
+ 'version', NEW.version,
294
+ 'events', to_jsonb(v_events),
295
+ 'new_record', to_jsonb(NEW),
296
+ 'old_record', CASE WHEN TG_OP = 'UPDATE' THEN to_jsonb(OLD) ELSE NULL END
297
+ );
298
+
299
+ -- 4. Get config: prefer Vault (hosted), fall back to GUC (local dev)
300
+ BEGIN
301
+ SELECT decrypted_secret INTO v_internal_secret
302
+ FROM vault.decrypted_secrets WHERE name = 'webhook_internal_secret' LIMIT 1;
303
+ SELECT decrypted_secret INTO v_edge_url
304
+ FROM vault.decrypted_secrets WHERE name = 'edge_function_url' LIMIT 1;
305
+ EXCEPTION WHEN OTHERS THEN
306
+ -- Vault not available (local dev)
307
+ NULL;
308
+ END;
309
+ v_internal_secret := COALESCE(v_internal_secret,
310
+ current_setting('app.settings.webhook_internal_secret', true));
311
+ v_edge_url := COALESCE(v_edge_url,
312
+ current_setting('app.settings.edge_function_url', true));
313
+
314
+ -- 5. Call Edge Function via pg_net (fires after commit)
315
+ IF v_edge_url IS NOT NULL THEN
316
+ BEGIN
317
+ PERFORM net.http_post(
318
+ url := v_edge_url || '/webhook-deliver',
319
+ body := v_payload,
320
+ headers := jsonb_build_object(
321
+ 'Content-Type', 'application/json',
322
+ 'X-Webhook-Internal-Secret', COALESCE(v_internal_secret, '')
323
+ )
324
+ );
325
+ EXCEPTION WHEN OTHERS THEN
326
+ -- pg_net not available — log and continue (task mutation must not fail)
327
+ RAISE NOTICE 'webhook delivery skipped: %', SQLERRM;
328
+ END;
329
+ END IF;
330
+
331
+ RETURN NEW;
332
+ END;
333
+ $$;
334
+
335
+ -- ══════════════════════════════════════════════════════════════════════
336
+ -- 6. Update update_agent_permissions RPC to include can_archive
337
+ -- ══════════════════════════════════════════════════════════════════════
338
+ CREATE OR REPLACE FUNCTION update_agent_permissions(p_key_id uuid, p_rows jsonb)
339
+ RETURNS void
340
+ LANGUAGE plpgsql
341
+ SECURITY DEFINER
342
+ SET search_path = public
343
+ AS $$
344
+ DECLARE
345
+ v_workspace_id uuid;
346
+ v_row jsonb;
347
+ BEGIN
348
+ SELECT workspace_id INTO v_workspace_id FROM agent_keys WHERE id = p_key_id;
349
+ IF v_workspace_id IS NULL THEN
350
+ RAISE EXCEPTION 'unauthorized: agent key not found';
351
+ END IF;
352
+
353
+ IF auth.uid() IS NOT NULL THEN
354
+ IF NOT EXISTS (SELECT 1 FROM workspace_members WHERE workspace_id = v_workspace_id AND user_id = auth.uid()) THEN
355
+ RAISE EXCEPTION 'unauthorized';
356
+ END IF;
357
+ END IF;
358
+
359
+ DELETE FROM agent_permissions WHERE agent_key_id = p_key_id;
360
+
361
+ FOR v_row IN SELECT * FROM jsonb_array_elements(p_rows) LOOP
362
+ INSERT INTO agent_permissions (
363
+ agent_key_id, project_id, department_id,
364
+ can_read, can_create, can_update, can_assign, can_comment, can_archive,
365
+ workspace_id
366
+ ) VALUES (
367
+ p_key_id,
368
+ (v_row ->> 'project_id')::uuid,
369
+ (v_row ->> 'department_id')::uuid,
370
+ COALESCE((v_row ->> 'can_read')::boolean, false),
371
+ COALESCE((v_row ->> 'can_create')::boolean, false),
372
+ COALESCE((v_row ->> 'can_update')::boolean, false),
373
+ COALESCE((v_row ->> 'can_assign')::boolean, false),
374
+ COALESCE((v_row ->> 'can_comment')::boolean, false),
375
+ COALESCE((v_row ->> 'can_archive')::boolean, false),
376
+ v_workspace_id
377
+ );
378
+ END LOOP;
379
+ END;
380
+ $$;
@@ -0,0 +1,21 @@
1
+ -- Top-N tasks by priority: index + RPC
2
+
3
+ CREATE INDEX idx_tasks_project_priority_created
4
+ ON tasks(project_id, priority DESC, created_at ASC);
5
+
6
+ CREATE FUNCTION get_top_tasks(
7
+ p_workspace_id uuid, p_project_id uuid,
8
+ p_department_id uuid DEFAULT NULL,
9
+ p_status text DEFAULT NULL,
10
+ p_limit int DEFAULT 10
11
+ ) RETURNS SETOF tasks LANGUAGE sql STABLE AS $$
12
+ SELECT * FROM tasks
13
+ WHERE workspace_id = p_workspace_id
14
+ AND project_id = p_project_id
15
+ AND is_archived = false
16
+ AND (p_department_id IS NULL OR department_id = p_department_id)
17
+ AND (p_status IS NOT NULL AND status = p_status::status
18
+ OR p_status IS NULL AND status NOT IN ('done', 'cancelled', 'failed', 'blocked'))
19
+ ORDER BY priority DESC, created_at ASC
20
+ LIMIT LEAST(p_limit, 25);
21
+ $$;
@@ -0,0 +1,55 @@
1
+ -- Agent key stored defaults: default_project_id, default_department_id
2
+ -- Pre-fill MCP schema params so agents don't need to specify project/dept every call.
3
+
4
+ ALTER TABLE agent_keys
5
+ ADD COLUMN default_project_id uuid REFERENCES projects(id) ON DELETE SET NULL,
6
+ ADD COLUMN default_department_id uuid REFERENCES departments(id) ON DELETE SET NULL;
7
+
8
+ -- Dept default requires project default to also be set
9
+ ALTER TABLE agent_keys ADD CONSTRAINT agent_keys_default_dept_requires_project
10
+ CHECK (default_department_id IS NULL OR default_project_id IS NOT NULL);
11
+
12
+ -- Extend check_workspace_consistency to validate agent_keys defaults
13
+ CREATE OR REPLACE FUNCTION check_workspace_consistency()
14
+ RETURNS trigger LANGUAGE plpgsql AS $$
15
+ BEGIN
16
+ IF TG_TABLE_NAME = 'agent_permissions' THEN
17
+ IF NOT EXISTS (SELECT 1 FROM projects WHERE id = NEW.project_id AND workspace_id = NEW.workspace_id) THEN
18
+ RAISE EXCEPTION 'cross-workspace reference: project % not in workspace %', NEW.project_id, NEW.workspace_id;
19
+ END IF;
20
+ 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
21
+ RAISE EXCEPTION 'cross-workspace reference: department % not in workspace %', NEW.department_id, NEW.workspace_id;
22
+ END IF;
23
+ END IF;
24
+
25
+ IF TG_TABLE_NAME = 'tasks' THEN
26
+ IF NOT EXISTS (SELECT 1 FROM projects WHERE id = NEW.project_id AND workspace_id = NEW.workspace_id) THEN
27
+ RAISE EXCEPTION 'cross-workspace reference: project % not in workspace %', NEW.project_id, NEW.workspace_id;
28
+ END IF;
29
+ 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
30
+ RAISE EXCEPTION 'cross-workspace reference: assignee % not in workspace %', NEW.assigned_to_agent_key_id, NEW.workspace_id;
31
+ END IF;
32
+ END IF;
33
+
34
+ IF TG_TABLE_NAME = 'webhook_subscriptions' THEN
35
+ IF NOT EXISTS (SELECT 1 FROM projects WHERE id = NEW.project_id AND workspace_id = NEW.workspace_id) THEN
36
+ RAISE EXCEPTION 'cross-workspace reference: project % not in workspace %', NEW.project_id, NEW.workspace_id;
37
+ END IF;
38
+ END IF;
39
+
40
+ IF TG_TABLE_NAME = 'agent_keys' THEN
41
+ IF NEW.default_project_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM projects WHERE id = NEW.default_project_id AND workspace_id = NEW.workspace_id) THEN
42
+ RAISE EXCEPTION 'cross-workspace reference: default project % not in workspace %', NEW.default_project_id, NEW.workspace_id;
43
+ END IF;
44
+ IF NEW.default_department_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM departments WHERE id = NEW.default_department_id AND workspace_id = NEW.workspace_id) THEN
45
+ RAISE EXCEPTION 'cross-workspace reference: default department % not in workspace %', NEW.default_department_id, NEW.workspace_id;
46
+ END IF;
47
+ END IF;
48
+
49
+ RETURN NEW;
50
+ END;
51
+ $$;
52
+
53
+ CREATE TRIGGER check_agent_keys_workspace_consistency
54
+ BEFORE INSERT OR UPDATE ON agent_keys
55
+ FOR EACH ROW EXECUTE FUNCTION check_workspace_consistency();