@vantageos/vantage-crm-mcp 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 (48) hide show
  1. package/README.md +260 -0
  2. package/dist/convex/crm/_helpers.js +24 -0
  3. package/dist/convex/crm/activities.js +220 -0
  4. package/dist/convex/crm/briefing.js +198 -0
  5. package/dist/convex/crm/calendarCron.js +92 -0
  6. package/dist/convex/crm/calendarCronDispatch.js +83 -0
  7. package/dist/convex/crm/calendarSync.js +294 -0
  8. package/dist/convex/crm/companies.js +323 -0
  9. package/dist/convex/crm/contacts.js +346 -0
  10. package/dist/convex/crm/deals.js +481 -0
  11. package/dist/convex/crm/emailActions.js +158 -0
  12. package/dist/convex/crm/emailCron.js +210 -0
  13. package/dist/convex/crm/emailCronDispatch.js +76 -0
  14. package/dist/convex/crm/emailSync.js +260 -0
  15. package/dist/convex/crm/onboarding.js +185 -0
  16. package/dist/convex/crm/stats.js +75 -0
  17. package/dist/convex/crm/tasks.js +109 -0
  18. package/dist/convex/crons.js +25 -0
  19. package/dist/convex/integrations.js +183 -0
  20. package/dist/convex/lib/auditLog.js +109 -0
  21. package/dist/convex/lib/auth.js +372 -0
  22. package/dist/convex/lib/rbac.js +123 -0
  23. package/dist/convex/lib/workspace.js +171 -0
  24. package/dist/convex/organizations.js +192 -0
  25. package/dist/convex/schema.js +690 -0
  26. package/dist/convex/users.js +217 -0
  27. package/dist/convex/workspaces.js +603 -0
  28. package/dist/mcp-server/lib/convexClient.js +50 -0
  29. package/dist/mcp-server/lib/scopeEnforcement.js +76 -0
  30. package/dist/mcp-server/registry.js +116 -0
  31. package/dist/mcp-server/server.js +97 -0
  32. package/dist/mcp-server/tests/registry.test.js +163 -0
  33. package/dist/mcp-server/tests/scopeEnforcement.test.js +137 -0
  34. package/dist/mcp-server/tests/security.test.js +257 -0
  35. package/dist/mcp-server/tests/tools.test.js +272 -0
  36. package/dist/mcp-server/tools/activities.js +207 -0
  37. package/dist/mcp-server/tools/admin.js +190 -0
  38. package/dist/mcp-server/tools/companies.js +233 -0
  39. package/dist/mcp-server/tools/contacts.js +306 -0
  40. package/dist/mcp-server/tools/customFields.js +222 -0
  41. package/dist/mcp-server/tools/customObjects.js +235 -0
  42. package/dist/mcp-server/tools/deals.js +297 -0
  43. package/dist/mcp-server/tools/rbac.js +177 -0
  44. package/dist/mcp-server/tools/search.js +155 -0
  45. package/dist/mcp-server/tools/workflows.js +234 -0
  46. package/dist/mcp-server/transport/http.js +257 -0
  47. package/dist/mcp-server/transport/stdio.js +90 -0
  48. package/package.json +45 -0
package/README.md ADDED
@@ -0,0 +1,260 @@
1
+ # VantageCRM MCP Server
2
+
3
+ Model Context Protocol server for VantageCRM — 60 tools across 10 categories exposing the full CRM backend to AI agents.
4
+
5
+ ## Transports
6
+
7
+ ### stdio (local / Claude Desktop)
8
+
9
+ Runs as a subprocess. Full tool access, no scope filtering (trusted local user).
10
+
11
+ ```bash
12
+ CONVEX_URL=https://your-deployment.convex.cloud node dist/server.js
13
+ # or explicitly:
14
+ CONVEX_URL=https://... node dist/server.js --mode stdio
15
+ ```
16
+
17
+ Claude Desktop `.mcp.json`:
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "vantageCRM": {
23
+ "command": "node",
24
+ "args": ["/path/to/vantageos-crm/mcp-server/dist/server.js"],
25
+ "env": {
26
+ "CONVEX_URL": "https://your-deployment.convex.cloud"
27
+ }
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ ### HTTP (multi-tenant / Railway)
34
+
35
+ Runs as an HTTP server. OAuth Bearer tokens required. Tools filtered by token scope.
36
+
37
+ ```bash
38
+ CONVEX_URL=https://your-deployment.convex.cloud \
39
+ VANTAGE_MCP_MODE=http \
40
+ PORT=3001 \
41
+ node dist/server.js --mode http
42
+ ```
43
+
44
+ Endpoints:
45
+
46
+ | Method | Path | Description |
47
+ |--------|------|-------------|
48
+ | GET | `/health` | Liveness probe |
49
+ | GET | `/mcp/tools` | List tools visible to token |
50
+ | POST | `/mcp/call` | Call a tool |
51
+
52
+ Request format:
53
+
54
+ ```http
55
+ POST /mcp/call
56
+ Authorization: Bearer <token>
57
+ Content-Type: application/json
58
+
59
+ {
60
+ "tool": "create_contact",
61
+ "args": { "workspaceId": "ws_xxx", "firstName": "Jane", "lastName": "Doe" }
62
+ }
63
+ ```
64
+
65
+ ## Environment Variables
66
+
67
+ | Variable | Required | Description |
68
+ |----------|----------|-------------|
69
+ | `CONVEX_URL` | Yes | Convex deployment URL |
70
+ | `VANTAGE_MCP_MODE` | No | `stdio` (default) or `http` |
71
+ | `PORT` | No | HTTP port (default: 3001) |
72
+
73
+ ## Tool Catalog
74
+
75
+ ### Contacts (8 tools)
76
+
77
+ | Tool | Scope | Description |
78
+ |------|-------|-------------|
79
+ | `create_contact` | write | Create a CRM contact |
80
+ | `get_contact` | read | Retrieve contact by ID |
81
+ | `update_contact` | write | Update contact fields |
82
+ | `list_contacts` | read | List by workspace/type |
83
+ | `search_contacts` | read | Full-text search |
84
+ | `delete_contact` | write | Soft delete (30-day grace) |
85
+ | `list_contacts_by_custom_field` | read | Filter by custom field value |
86
+ | `restore_contact` | write | Restore soft-deleted contact |
87
+
88
+ ### Companies (6 tools)
89
+
90
+ | Tool | Scope | Description |
91
+ |------|-------|-------------|
92
+ | `create_company` | write | Create company record |
93
+ | `get_company` | read | Retrieve by ID |
94
+ | `update_company` | write | Update fields |
95
+ | `list_companies` | read | List by workspace/industry |
96
+ | `search_companies` | read | Full-text search |
97
+ | `delete_company` | write | Soft delete |
98
+
99
+ ### Deals (8 tools)
100
+
101
+ | Tool | Scope | Description |
102
+ |------|-------|-------------|
103
+ | `create_deal` | write | Create deal in pipeline |
104
+ | `get_deal` | read | Retrieve by ID |
105
+ | `update_deal` | write | Update fields |
106
+ | `list_deals` | read | List by workspace/stage/contact |
107
+ | `search_deals` | read | Search by title |
108
+ | `delete_deal` | write | Soft delete |
109
+ | `move_deal_stage` | write | Move stage with optional probability override |
110
+ | `forecast_pipeline` | read | Weighted pipeline value (Σ value × probability) |
111
+
112
+ ### Activities (5 tools — no delete per OQ-4)
113
+
114
+ | Tool | Scope | Description |
115
+ |------|-------|-------------|
116
+ | `create_activity` | write | Create activity (call/email/meeting/note/task) |
117
+ | `get_activity` | read | Retrieve by ID |
118
+ | `update_activity` | write | Update mutable fields (description, dueAt, completedAt) |
119
+ | `list_activities` | read | List by workspace/contact/deal |
120
+ | `list_activities_by_type` | read | Filter by type with time range |
121
+
122
+ Activities are immutable audit trail — subject and type cannot be changed after creation.
123
+
124
+ ### Custom Fields (5 tools)
125
+
126
+ | Tool | Scope | Description |
127
+ |------|-------|-------------|
128
+ | `add_custom_field` | write | Define custom field for entity type |
129
+ | `update_custom_field_definition` | write | Update label/options/validation |
130
+ | `list_custom_fields` | read | List definitions by workspace |
131
+ | `set_custom_field_value` | write | Set value on an entity |
132
+ | `delete_custom_field` | admin | Delete definition (irreversible) |
133
+
134
+ ### Custom Objects (7 tools)
135
+
136
+ | Tool | Scope | Description |
137
+ |------|-------|-------------|
138
+ | `add_custom_object` | admin | DDL: define new object type |
139
+ | `list_custom_objects` | read | List object type definitions |
140
+ | `create_record` | write | Create a custom object record |
141
+ | `update_record` | write | Update record fields |
142
+ | `delete_record` | write | Soft delete record |
143
+ | `list_records` | read | List records by type |
144
+ | `search_records` | read | Search records by field values |
145
+
146
+ ### Workflows (7 tools)
147
+
148
+ | Tool | Scope | Description |
149
+ |------|-------|-------------|
150
+ | `create_workflow` | write | Create automation workflow |
151
+ | `update_workflow` | write | Update workflow config |
152
+ | `list_workflows` | read | List by workspace |
153
+ | `pause_workflow` | write | Pause (disable) workflow |
154
+ | `resume_workflow` | write | Resume (enable) workflow |
155
+ | `list_executions` | read | List workflow execution history |
156
+ | `replay_execution` | workflow-trigger | Re-run a failed execution |
157
+
158
+ ### Search / Analytics (4 tools)
159
+
160
+ | Tool | Scope | Description |
161
+ |------|-------|-------------|
162
+ | `pipeline_value` | read | Weighted pipeline Σ value × probability |
163
+ | `win_rate` | read | Deals won / total closed in period |
164
+ | `conversion_rate` | read | Stage conversion rate |
165
+ | `activity_summary` | read | Activity counts grouped by type |
166
+
167
+ ### Admin (5 tools)
168
+
169
+ | Tool | Scope | Description |
170
+ |------|-------|-------------|
171
+ | `list_audit_log` | admin | Query audit event log |
172
+ | `get_entity_history` | admin | Change history for an entity |
173
+ | `get_actor_history` | admin | All actions by a user |
174
+ | `purge_archived_records` | admin | Hard delete archived records (30+ days) |
175
+ | `list_workspace_limits` | admin | Workspace plan limits |
176
+
177
+ ### RBAC (5 tools)
178
+
179
+ | Tool | Scope | Description |
180
+ |------|-------|-------------|
181
+ | `list_workspace_members` | read | List workspace members and roles |
182
+ | `add_workspace_member` | admin | Add member with role |
183
+ | `update_member_role` | admin | Change member role |
184
+ | `remove_workspace_member` | admin | Remove member from workspace |
185
+ | `update_member_permissions` | admin | Fine-grained permission overrides |
186
+
187
+ ## Scope Tier Matrix
188
+
189
+ ```
190
+ cloud-admin ──── satisfies all scopes
191
+
192
+ admin ──────── satisfies read + write
193
+
194
+ write ──────── satisfies read
195
+
196
+ read ──────── base tier
197
+
198
+ workflow-trigger ── lateral tier (peer of write, different domain)
199
+ ```
200
+
201
+ Token scopes are extracted from the Bearer token via `validateToken` Convex query. In stdio mode, all tools are always accessible.
202
+
203
+ ## Response Envelope
204
+
205
+ All tool responses use the same envelope:
206
+
207
+ ```typescript
208
+ // Success
209
+ { success: true, data: <result> }
210
+
211
+ // Failure
212
+ { success: false, error: { code: string, message: string } }
213
+ ```
214
+
215
+ Common error codes: `CONVEX_ERROR`, `SCOPE_ERROR`, `ZOD_VALIDATION_ERROR`, `NOT_IMPLEMENTED`.
216
+
217
+ ## Development
218
+
219
+ ```bash
220
+ # Install dependencies
221
+ npm install
222
+
223
+ # Run tests
224
+ npm test
225
+
226
+ # Type check
227
+ npx tsc --noEmit --skipLibCheck
228
+
229
+ # Run in stdio mode (local dev)
230
+ CONVEX_URL=https://... npx tsx mcp-server/server.ts
231
+ ```
232
+
233
+ ## Architecture
234
+
235
+ ```
236
+ mcp-server/
237
+ ├── server.ts # Entry point — mode detection
238
+ ├── registry.ts # All 60 tools + handler map
239
+ ├── lib/
240
+ │ ├── convexClient.ts # ConvexHttpClient wrapper + ToolResult envelope
241
+ │ └── scopeEnforcement.ts # 4-tier scope hierarchy
242
+ ├── transport/
243
+ │ ├── stdio.ts # MCP SDK stdio transport
244
+ │ └── http.ts # HTTP transport with OAuth
245
+ ├── tools/
246
+ │ ├── contacts.ts # 8 tools
247
+ │ ├── companies.ts # 6 tools
248
+ │ ├── deals.ts # 8 tools
249
+ │ ├── activities.ts # 5 tools
250
+ │ ├── customFields.ts # 5 tools
251
+ │ ├── customObjects.ts # 7 tools
252
+ │ ├── workflows.ts # 7 tools
253
+ │ ├── search.ts # 4 tools
254
+ │ ├── admin.ts # 5 tools
255
+ │ └── rbac.ts # 5 tools
256
+ └── tests/
257
+ ├── tools.test.ts # Zod validation + envelope per category
258
+ ├── scopeEnforcement.test.ts # Scope hierarchy coverage
259
+ └── registry.test.ts # 60-tool count + integrity checks
260
+ ```
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ /**
3
+ * CRM Shared Helpers
4
+ *
5
+ * Search text builders for contacts, companies, and deals.
6
+ * Used by create/update mutations to populate searchableText fields.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.buildContactSearchText = buildContactSearchText;
10
+ exports.buildCompanySearchText = buildCompanySearchText;
11
+ exports.buildDealSearchText = buildDealSearchText;
12
+ function buildContactSearchText(contact, companyName) {
13
+ return [contact.firstName, contact.lastName, contact.email, companyName]
14
+ .filter(Boolean)
15
+ .join(' ');
16
+ }
17
+ function buildCompanySearchText(company) {
18
+ return [company.name, company.domain, company.industry]
19
+ .filter(Boolean)
20
+ .join(' ');
21
+ }
22
+ function buildDealSearchText(deal, companyName, contactName) {
23
+ return [deal.title, companyName, contactName].filter(Boolean).join(' ');
24
+ }
@@ -0,0 +1,220 @@
1
+ "use strict";
2
+ /**
3
+ * convex/crm/activities.ts
4
+ *
5
+ * V0.1.0 — Extended from T3 stub.
6
+ * Functions: createActivity, updateActivity, getActivity, listActivities.
7
+ * NO archive, NO delete — OQ-4 audit trail doctrine (Salesforce-grade).
8
+ * OQ-1: actorType includes 'agent:composio' via actorId override.
9
+ * Every create/update mutation creates audit_log entry.
10
+ *
11
+ * Ref: vantage-crm-spec-2026-05-20.md §2 + §5 OQ-1/OQ-4
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.update = exports.updateActivity = exports.list = exports.listActivities = exports.get = exports.getActivity = exports.create = exports.createActivity = void 0;
15
+ const server_1 = require("../_generated/server");
16
+ const values_1 = require("convex/values");
17
+ const workspace_1 = require("../lib/workspace");
18
+ // ---------------------------------------------------------------------------
19
+ // Validators
20
+ // ---------------------------------------------------------------------------
21
+ const activityTypeValidator = values_1.v.union(values_1.v.literal('call'), values_1.v.literal('email'), values_1.v.literal('meeting'), values_1.v.literal('note'), values_1.v.literal('task'), values_1.v.literal('stage-change'), values_1.v.literal('email-received'), values_1.v.literal('email-sent'), values_1.v.literal('calendar-event'), values_1.v.literal('workflow-action'));
22
+ // ---------------------------------------------------------------------------
23
+ // createActivity
24
+ // ---------------------------------------------------------------------------
25
+ exports.createActivity = (0, server_1.mutation)({
26
+ args: {
27
+ workspaceId: values_1.v.id('workspaces'),
28
+ type: activityTypeValidator,
29
+ subject: values_1.v.string(),
30
+ description: values_1.v.optional(values_1.v.string()),
31
+ contactId: values_1.v.optional(values_1.v.id('contacts')),
32
+ companyId: values_1.v.optional(values_1.v.id('companies')),
33
+ dealId: values_1.v.optional(values_1.v.id('deals')),
34
+ dueAt: values_1.v.optional(values_1.v.number()),
35
+ occurredAt: values_1.v.optional(values_1.v.number()),
36
+ // OQ-1: actor tracking — 'agent:composio' supported via actorId
37
+ actorType: values_1.v.optional(values_1.v.union(values_1.v.literal('user'), values_1.v.literal('agent'), values_1.v.literal('system'))),
38
+ actorId: values_1.v.optional(values_1.v.string()), // clerkId | 'agent:composio' | 'system'
39
+ customFields: values_1.v.optional(values_1.v.record(values_1.v.string(), values_1.v.any())),
40
+ },
41
+ returns: values_1.v.id('activities'),
42
+ handler: async (ctx, args) => {
43
+ // Build actor — if actorId='agent:composio', auditActorType='agent'
44
+ const { clerkId, canWrite, actor } = await (0, workspace_1.assertWorkspaceAccess)(ctx, args.workspaceId, args.actorId);
45
+ if (!canWrite)
46
+ throw new Error('Insufficient permissions');
47
+ // Normalize actorType for schema (schema accepts 'user'|'agent'|'system')
48
+ // 'agent:composio' collapses to 'agent' in the activities table
49
+ const schemaActorType = args.actorId === 'agent:composio'
50
+ ? 'agent'
51
+ : args.actorType ?? 'user';
52
+ const now = Date.now();
53
+ const activityId = await ctx.db.insert('activities', {
54
+ type: args.type,
55
+ subject: args.subject,
56
+ description: args.description,
57
+ contactId: args.contactId,
58
+ companyId: args.companyId,
59
+ dealId: args.dealId,
60
+ dueAt: args.dueAt,
61
+ occurredAt: args.occurredAt ?? now,
62
+ ownerId: clerkId,
63
+ workspaceId: args.workspaceId,
64
+ actorType: schemaActorType,
65
+ actorId: actor.actorId,
66
+ customFields: args.customFields,
67
+ createdAt: now,
68
+ updatedAt: now,
69
+ });
70
+ // Update lastActivityAt on the deal if linked
71
+ if (args.dealId) {
72
+ await ctx.db.patch(args.dealId, { lastActivityAt: now, updatedAt: now });
73
+ }
74
+ // Update lastContactedAt on the contact if linked (non-task/note only)
75
+ if (args.contactId && args.type !== 'task' && args.type !== 'note') {
76
+ await ctx.db.patch(args.contactId, { lastContactedAt: now, updatedAt: now });
77
+ }
78
+ // Audit log — note activities themselves ARE the audit trail,
79
+ // but we also log the mutation for compliance completeness.
80
+ await ctx.db.insert('audit_log', {
81
+ workspaceId: args.workspaceId,
82
+ actorId: actor.actorId,
83
+ actorType: actor.auditActorType,
84
+ entityType: 'activity',
85
+ entityId: activityId,
86
+ action: 'create',
87
+ timestamp: Date.now(),
88
+ });
89
+ return activityId;
90
+ },
91
+ });
92
+ // Backward-compatible alias
93
+ exports.create = exports.createActivity;
94
+ // ---------------------------------------------------------------------------
95
+ // getActivity / get
96
+ // ---------------------------------------------------------------------------
97
+ exports.getActivity = (0, server_1.query)({
98
+ args: {
99
+ activityId: values_1.v.id('activities'),
100
+ },
101
+ returns: values_1.v.union(values_1.v.null(), values_1.v.any()),
102
+ handler: async (ctx, args) => {
103
+ const activity = await ctx.db.get(args.activityId);
104
+ if (!activity)
105
+ return null;
106
+ await (0, workspace_1.validateWorkspaceAccess)(ctx, activity.workspaceId);
107
+ return activity;
108
+ },
109
+ });
110
+ exports.get = exports.getActivity;
111
+ // ---------------------------------------------------------------------------
112
+ // listActivities / list
113
+ // ---------------------------------------------------------------------------
114
+ exports.listActivities = (0, server_1.query)({
115
+ args: {
116
+ workspaceId: values_1.v.id('workspaces'),
117
+ contactId: values_1.v.optional(values_1.v.id('contacts')),
118
+ companyId: values_1.v.optional(values_1.v.id('companies')),
119
+ dealId: values_1.v.optional(values_1.v.id('deals')),
120
+ type: values_1.v.optional(activityTypeValidator),
121
+ limit: values_1.v.optional(values_1.v.number()),
122
+ },
123
+ returns: values_1.v.array(values_1.v.any()),
124
+ handler: async (ctx, args) => {
125
+ await (0, workspace_1.validateWorkspaceAccess)(ctx, args.workspaceId);
126
+ const limit = args.limit ?? 20;
127
+ let activities;
128
+ if (args.dealId) {
129
+ const deal = await ctx.db.get(args.dealId);
130
+ if (!deal || deal.workspaceId !== args.workspaceId)
131
+ return [];
132
+ activities = await ctx.db
133
+ .query('activities')
134
+ .withIndex('by_deal', (q) => q.eq('dealId', args.dealId))
135
+ .order('desc')
136
+ .take(limit + 1);
137
+ }
138
+ else if (args.contactId) {
139
+ const contact = await ctx.db.get(args.contactId);
140
+ if (!contact || contact.workspaceId !== args.workspaceId)
141
+ return [];
142
+ activities = await ctx.db
143
+ .query('activities')
144
+ .withIndex('by_contact', (q) => q.eq('contactId', args.contactId))
145
+ .order('desc')
146
+ .take(limit + 1);
147
+ }
148
+ else if (args.companyId) {
149
+ const company = await ctx.db.get(args.companyId);
150
+ if (!company || company.workspaceId !== args.workspaceId)
151
+ return [];
152
+ activities = await ctx.db
153
+ .query('activities')
154
+ .withIndex('by_company', (q) => q.eq('companyId', args.companyId))
155
+ .order('desc')
156
+ .take(limit + 1);
157
+ }
158
+ else if (args.type) {
159
+ activities = await ctx.db
160
+ .query('activities')
161
+ .withIndex('by_workspace_type', (q) => q.eq('workspaceId', args.workspaceId).eq('type', args.type))
162
+ .order('desc')
163
+ .take(limit + 1);
164
+ }
165
+ else {
166
+ activities = await ctx.db
167
+ .query('activities')
168
+ .withIndex('by_workspace', (q) => q.eq('workspaceId', args.workspaceId))
169
+ .order('desc')
170
+ .take(limit + 1);
171
+ }
172
+ return activities.slice(0, limit);
173
+ },
174
+ });
175
+ exports.list = exports.listActivities;
176
+ // ---------------------------------------------------------------------------
177
+ // updateActivity — intentionally limited (OQ-4 audit trail integrity)
178
+ // Only description + dueAt + completedAt + customFields may be updated.
179
+ // subject, type, occurredAt, links (contactId/dealId/companyId) are immutable.
180
+ // ---------------------------------------------------------------------------
181
+ exports.updateActivity = (0, server_1.mutation)({
182
+ args: {
183
+ activityId: values_1.v.id('activities'),
184
+ description: values_1.v.optional(values_1.v.string()),
185
+ dueAt: values_1.v.optional(values_1.v.number()),
186
+ completedAt: values_1.v.optional(values_1.v.number()),
187
+ customFields: values_1.v.optional(values_1.v.record(values_1.v.string(), values_1.v.any())),
188
+ actorId: values_1.v.optional(values_1.v.string()),
189
+ },
190
+ returns: values_1.v.id('activities'),
191
+ handler: async (ctx, args) => {
192
+ const activity = await ctx.db.get(args.activityId);
193
+ if (!activity)
194
+ throw new Error('Activity not found');
195
+ const { canWrite, actor } = await (0, workspace_1.assertWorkspaceAccess)(ctx, activity.workspaceId, args.actorId);
196
+ if (!canWrite)
197
+ throw new Error('Insufficient permissions');
198
+ const { activityId, actorId: _actorId, ...updates } = args;
199
+ await ctx.db.patch(activityId, {
200
+ ...updates,
201
+ updatedAt: Date.now(),
202
+ });
203
+ await ctx.db.insert('audit_log', {
204
+ workspaceId: activity.workspaceId,
205
+ actorId: actor.actorId,
206
+ actorType: actor.auditActorType,
207
+ entityType: 'activity',
208
+ entityId: activityId,
209
+ action: 'update',
210
+ timestamp: Date.now(),
211
+ });
212
+ return activityId;
213
+ },
214
+ });
215
+ exports.update = exports.updateActivity;
216
+ // ---------------------------------------------------------------------------
217
+ // NO remove/delete mutation.
218
+ // Activities are the immutable audit trail. OQ-4 / Salesforce doctrine.
219
+ // This comment is intentional — do not add a remove mutation here.
220
+ // ---------------------------------------------------------------------------