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,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();
|