@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
@@ -0,0 +1,323 @@
1
+ "use strict";
2
+ /**
3
+ * convex/crm/companies.ts
4
+ *
5
+ * V0.1.0 — Extended from T3 stub.
6
+ * Functions: createCompany, updateCompany, getCompany, listCompanies,
7
+ * archiveCompany, restoreCompany, deleteCompany (admin only).
8
+ * Integrates: customFields (D-004) + isArchived/archivedAt (OQ-4).
9
+ * Every mutation wrapped in audit log insertion.
10
+ *
11
+ * Ref: vantage-crm-spec-2026-05-20.md §2 + §5
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.search = exports.deleteCompany = exports.restoreCompany = exports.remove = exports.archiveCompany = exports.list = exports.listCompanies = exports.update = exports.updateCompany = exports.get = exports.getCompany = exports.create = exports.createCompany = void 0;
15
+ const server_1 = require("../_generated/server");
16
+ const values_1 = require("convex/values");
17
+ const workspace_1 = require("../lib/workspace");
18
+ const auditLog_1 = require("../lib/auditLog");
19
+ const rbac_1 = require("../lib/rbac");
20
+ const _helpers_1 = require("./_helpers");
21
+ // ---------------------------------------------------------------------------
22
+ // Validators
23
+ // ---------------------------------------------------------------------------
24
+ const sizeValidator = values_1.v.optional(values_1.v.union(values_1.v.literal('1-10'), values_1.v.literal('11-50'), values_1.v.literal('51-200'), values_1.v.literal('201-1000'), values_1.v.literal('1000+')));
25
+ // ---------------------------------------------------------------------------
26
+ // createCompany
27
+ // ---------------------------------------------------------------------------
28
+ exports.createCompany = (0, server_1.mutation)({
29
+ args: {
30
+ workspaceId: values_1.v.id('workspaces'),
31
+ name: values_1.v.string(),
32
+ domain: values_1.v.optional(values_1.v.string()),
33
+ website: values_1.v.optional(values_1.v.string()),
34
+ industry: values_1.v.optional(values_1.v.string()),
35
+ size: sizeValidator,
36
+ phone: values_1.v.optional(values_1.v.string()),
37
+ address: values_1.v.optional(values_1.v.string()),
38
+ description: values_1.v.optional(values_1.v.string()),
39
+ tags: values_1.v.optional(values_1.v.array(values_1.v.string())),
40
+ notes: values_1.v.optional(values_1.v.string()),
41
+ customFields: values_1.v.optional(values_1.v.record(values_1.v.string(), values_1.v.any())),
42
+ actorId: values_1.v.optional(values_1.v.string()),
43
+ },
44
+ returns: values_1.v.id('companies'),
45
+ handler: async (ctx, args) => {
46
+ const { clerkId, canWrite, actor } = await (0, workspace_1.assertWorkspaceAccess)(ctx, args.workspaceId, args.actorId);
47
+ if (!canWrite)
48
+ throw new Error('Insufficient permissions');
49
+ const searchableText = (0, _helpers_1.buildCompanySearchText)({
50
+ name: args.name,
51
+ domain: args.domain,
52
+ industry: args.industry,
53
+ });
54
+ const now = Date.now();
55
+ const companyId = await ctx.db.insert('companies', {
56
+ name: args.name,
57
+ domain: args.domain,
58
+ website: args.website,
59
+ industry: args.industry,
60
+ size: args.size,
61
+ phone: args.phone,
62
+ address: args.address,
63
+ description: args.description,
64
+ tags: args.tags,
65
+ notes: args.notes,
66
+ customFields: args.customFields,
67
+ workspaceId: args.workspaceId,
68
+ ownerId: clerkId,
69
+ searchableText,
70
+ createdAt: now,
71
+ updatedAt: now,
72
+ });
73
+ await ctx.db.insert('audit_log', {
74
+ workspaceId: args.workspaceId,
75
+ actorId: actor.actorId,
76
+ actorType: actor.auditActorType,
77
+ entityType: 'company',
78
+ entityId: companyId,
79
+ action: 'create',
80
+ timestamp: Date.now(),
81
+ });
82
+ return companyId;
83
+ },
84
+ });
85
+ // Backward-compatible alias
86
+ exports.create = exports.createCompany;
87
+ // ---------------------------------------------------------------------------
88
+ // getCompany / get
89
+ // ---------------------------------------------------------------------------
90
+ exports.getCompany = (0, server_1.query)({
91
+ args: {
92
+ companyId: values_1.v.id('companies'),
93
+ },
94
+ returns: values_1.v.union(values_1.v.null(), values_1.v.any()),
95
+ handler: async (ctx, args) => {
96
+ const company = await ctx.db.get(args.companyId);
97
+ if (!company)
98
+ return null;
99
+ await (0, workspace_1.validateWorkspaceAccess)(ctx, company.workspaceId);
100
+ const contacts = await ctx.db
101
+ .query('contacts')
102
+ .withIndex('by_company', (q) => q.eq('companyId', args.companyId))
103
+ .take(1000);
104
+ const contactCount = contacts.filter((c) => c.isArchived !== true).length;
105
+ const deals = await ctx.db
106
+ .query('deals')
107
+ .withIndex('by_company', (q) => q.eq('companyId', args.companyId))
108
+ .take(1000);
109
+ const dealCount = deals.filter((d) => d.isArchived !== true).length;
110
+ return { ...company, contactCount, dealCount };
111
+ },
112
+ });
113
+ exports.get = exports.getCompany;
114
+ // ---------------------------------------------------------------------------
115
+ // updateCompany / update
116
+ // ---------------------------------------------------------------------------
117
+ exports.updateCompany = (0, server_1.mutation)({
118
+ args: {
119
+ companyId: values_1.v.id('companies'),
120
+ name: values_1.v.optional(values_1.v.string()),
121
+ domain: values_1.v.optional(values_1.v.string()),
122
+ website: values_1.v.optional(values_1.v.string()),
123
+ industry: values_1.v.optional(values_1.v.string()),
124
+ size: sizeValidator,
125
+ phone: values_1.v.optional(values_1.v.string()),
126
+ address: values_1.v.optional(values_1.v.string()),
127
+ description: values_1.v.optional(values_1.v.string()),
128
+ tags: values_1.v.optional(values_1.v.array(values_1.v.string())),
129
+ notes: values_1.v.optional(values_1.v.string()),
130
+ customFields: values_1.v.optional(values_1.v.record(values_1.v.string(), values_1.v.any())),
131
+ actorId: values_1.v.optional(values_1.v.string()),
132
+ },
133
+ returns: values_1.v.id('companies'),
134
+ handler: async (ctx, args) => {
135
+ const company = await ctx.db.get(args.companyId);
136
+ if (!company)
137
+ throw new Error('Company not found');
138
+ const { canWrite, actor } = await (0, workspace_1.assertWorkspaceAccess)(ctx, company.workspaceId, args.actorId);
139
+ if (!canWrite)
140
+ throw new Error('Insufficient permissions');
141
+ const { companyId, actorId: _actorId, ...updates } = args;
142
+ const name = updates.name ?? company.name;
143
+ const domain = updates.domain ?? company.domain;
144
+ const industry = updates.industry ?? company.industry;
145
+ const searchableText = (0, _helpers_1.buildCompanySearchText)({ name, domain, industry });
146
+ const beforeRecord = { ...company };
147
+ await ctx.db.patch(companyId, {
148
+ ...updates,
149
+ searchableText,
150
+ updatedAt: Date.now(),
151
+ });
152
+ const afterRecord = (await ctx.db.get(companyId)) ?? {};
153
+ const fieldChanges = (0, auditLog_1.diffRecords)(beforeRecord, afterRecord);
154
+ await ctx.db.insert('audit_log', {
155
+ workspaceId: company.workspaceId,
156
+ actorId: actor.actorId,
157
+ actorType: actor.auditActorType,
158
+ entityType: 'company',
159
+ entityId: companyId,
160
+ action: 'update',
161
+ fieldChanges: fieldChanges.length > 0 ? fieldChanges : undefined,
162
+ timestamp: Date.now(),
163
+ });
164
+ return companyId;
165
+ },
166
+ });
167
+ exports.update = exports.updateCompany;
168
+ // ---------------------------------------------------------------------------
169
+ // listCompanies / list
170
+ // ---------------------------------------------------------------------------
171
+ exports.listCompanies = (0, server_1.query)({
172
+ args: {
173
+ workspaceId: values_1.v.id('workspaces'),
174
+ industry: values_1.v.optional(values_1.v.string()),
175
+ includeArchived: values_1.v.optional(values_1.v.boolean()),
176
+ limit: values_1.v.optional(values_1.v.number()),
177
+ },
178
+ returns: values_1.v.array(values_1.v.any()),
179
+ handler: async (ctx, args) => {
180
+ await (0, workspace_1.validateWorkspaceAccess)(ctx, args.workspaceId);
181
+ const limit = args.limit ?? 50;
182
+ const companies = await ctx.db
183
+ .query('companies')
184
+ .withIndex('by_workspace', (q) => q.eq('workspaceId', args.workspaceId))
185
+ .order('desc')
186
+ .take(limit + 1);
187
+ let filtered = args.includeArchived
188
+ ? companies
189
+ : companies.filter((c) => c.isArchived !== true);
190
+ if (args.industry) {
191
+ filtered = filtered.filter((c) => c.industry === args.industry);
192
+ }
193
+ return filtered.slice(0, limit);
194
+ },
195
+ });
196
+ exports.list = exports.listCompanies;
197
+ // ---------------------------------------------------------------------------
198
+ // archiveCompany (OQ-4 soft delete)
199
+ // ---------------------------------------------------------------------------
200
+ exports.archiveCompany = (0, server_1.mutation)({
201
+ args: {
202
+ companyId: values_1.v.id('companies'),
203
+ actorId: values_1.v.optional(values_1.v.string()),
204
+ },
205
+ returns: values_1.v.id('companies'),
206
+ handler: async (ctx, args) => {
207
+ const company = await ctx.db.get(args.companyId);
208
+ if (!company)
209
+ throw new Error('Company not found');
210
+ const { canWrite, actor } = await (0, workspace_1.assertWorkspaceAccess)(ctx, company.workspaceId, args.actorId);
211
+ if (!canWrite)
212
+ throw new Error('Insufficient permissions');
213
+ if (company.isArchived)
214
+ throw new Error('Company is already archived');
215
+ const now = Date.now();
216
+ return (0, auditLog_1.withAuditLog)(ctx, {
217
+ workspaceId: company.workspaceId,
218
+ actorId: actor.actorId,
219
+ actorType: actor.auditActorType,
220
+ entityType: 'company',
221
+ entityId: args.companyId,
222
+ action: 'archive',
223
+ }, async () => {
224
+ await ctx.db.patch(args.companyId, {
225
+ isArchived: true,
226
+ archivedAt: now,
227
+ updatedAt: now,
228
+ });
229
+ return args.companyId;
230
+ });
231
+ },
232
+ });
233
+ // Backward-compatible alias
234
+ exports.remove = exports.archiveCompany;
235
+ // ---------------------------------------------------------------------------
236
+ // restoreCompany
237
+ // ---------------------------------------------------------------------------
238
+ exports.restoreCompany = (0, server_1.mutation)({
239
+ args: {
240
+ companyId: values_1.v.id('companies'),
241
+ actorId: values_1.v.optional(values_1.v.string()),
242
+ },
243
+ returns: values_1.v.id('companies'),
244
+ handler: async (ctx, args) => {
245
+ const company = await ctx.db.get(args.companyId);
246
+ if (!company)
247
+ throw new Error('Company not found');
248
+ const { canWrite, actor } = await (0, workspace_1.assertWorkspaceAccess)(ctx, company.workspaceId, args.actorId);
249
+ if (!canWrite)
250
+ throw new Error('Insufficient permissions');
251
+ if (!company.isArchived)
252
+ throw new Error('Company is not archived');
253
+ return (0, auditLog_1.withAuditLog)(ctx, {
254
+ workspaceId: company.workspaceId,
255
+ actorId: actor.actorId,
256
+ actorType: actor.auditActorType,
257
+ entityType: 'company',
258
+ entityId: args.companyId,
259
+ action: 'restore',
260
+ }, async () => {
261
+ await ctx.db.patch(args.companyId, {
262
+ isArchived: false,
263
+ archivedAt: undefined,
264
+ updatedAt: Date.now(),
265
+ });
266
+ return args.companyId;
267
+ });
268
+ },
269
+ });
270
+ // ---------------------------------------------------------------------------
271
+ // deleteCompany — ADMIN SCOPE ONLY (hard delete, OQ-4: 30j grace)
272
+ // ---------------------------------------------------------------------------
273
+ exports.deleteCompany = (0, server_1.mutation)({
274
+ args: {
275
+ companyId: values_1.v.id('companies'),
276
+ },
277
+ returns: values_1.v.id('companies'),
278
+ handler: async (ctx, args) => {
279
+ const company = await ctx.db.get(args.companyId);
280
+ if (!company)
281
+ throw new Error('Company not found');
282
+ const { role, actor } = await (0, workspace_1.assertWorkspaceAccess)(ctx, company.workspaceId);
283
+ await (0, rbac_1.assertAdminScope)(ctx, role);
284
+ if (!company.isArchived) {
285
+ throw new Error('Company must be archived before hard delete');
286
+ }
287
+ const archivedAt = company.archivedAt ?? 0;
288
+ const gracePeriodMs = 30 * 24 * 60 * 60 * 1000;
289
+ if (Date.now() - archivedAt < gracePeriodMs) {
290
+ const daysLeft = Math.ceil((gracePeriodMs - (Date.now() - archivedAt)) / (24 * 60 * 60 * 1000));
291
+ throw new Error(`Company cannot be hard deleted yet. ${daysLeft} day(s) remaining in grace period.`);
292
+ }
293
+ await ctx.db.insert('audit_log', {
294
+ workspaceId: company.workspaceId,
295
+ actorId: actor.actorId,
296
+ actorType: actor.auditActorType,
297
+ entityType: 'company',
298
+ entityId: args.companyId,
299
+ action: 'delete',
300
+ timestamp: Date.now(),
301
+ });
302
+ await ctx.db.delete(args.companyId);
303
+ return args.companyId;
304
+ },
305
+ });
306
+ // ---------------------------------------------------------------------------
307
+ // search — preserved from T3
308
+ // ---------------------------------------------------------------------------
309
+ exports.search = (0, server_1.query)({
310
+ args: {
311
+ workspaceId: values_1.v.id('workspaces'),
312
+ query: values_1.v.string(),
313
+ },
314
+ returns: values_1.v.array(values_1.v.any()),
315
+ handler: async (ctx, args) => {
316
+ await (0, workspace_1.validateWorkspaceAccess)(ctx, args.workspaceId);
317
+ const results = await ctx.db
318
+ .query('companies')
319
+ .withSearchIndex('search_companies', (q) => q.search('searchableText', args.query).eq('workspaceId', args.workspaceId))
320
+ .take(50);
321
+ return results.filter((c) => c.isArchived !== true);
322
+ },
323
+ });
@@ -0,0 +1,346 @@
1
+ "use strict";
2
+ /**
3
+ * convex/crm/contacts.ts
4
+ *
5
+ * V0.1.0 — Extended from T3 stub.
6
+ * Functions: createContact, updateContact, getContact, listContacts,
7
+ * archiveContact, restoreContact, deleteContact (admin only).
8
+ * Integrates: vpProfileId (OQ-3) + customFields (D-004) + isArchived/archivedAt (OQ-4).
9
+ * Every mutation wrapped in withAuditLog. Auth via assertWorkspaceAccess + requireScope.
10
+ *
11
+ * Ref: vantage-crm-spec-2026-05-20.md §2 + §5 OQ-3/OQ-4
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.search = exports.deleteContact = exports.restoreContact = exports.remove = exports.archiveContact = exports.list = exports.listContacts = exports.update = exports.updateContact = exports.get = exports.getContact = exports.create = exports.createContact = void 0;
15
+ const server_1 = require("../_generated/server");
16
+ const values_1 = require("convex/values");
17
+ const workspace_1 = require("../lib/workspace");
18
+ const auditLog_1 = require("../lib/auditLog");
19
+ const rbac_1 = require("../lib/rbac");
20
+ const _helpers_1 = require("./_helpers");
21
+ // ---------------------------------------------------------------------------
22
+ // Validators
23
+ // ---------------------------------------------------------------------------
24
+ const contactTypeValidator = values_1.v.union(values_1.v.literal('lead'), values_1.v.literal('prospect'), values_1.v.literal('client'), values_1.v.literal('partner'), values_1.v.literal('other'));
25
+ // ---------------------------------------------------------------------------
26
+ // createContact
27
+ // ---------------------------------------------------------------------------
28
+ exports.createContact = (0, server_1.mutation)({
29
+ args: {
30
+ workspaceId: values_1.v.id('workspaces'),
31
+ firstName: values_1.v.string(),
32
+ lastName: values_1.v.string(),
33
+ email: values_1.v.optional(values_1.v.string()),
34
+ phone: values_1.v.optional(values_1.v.string()),
35
+ companyId: values_1.v.optional(values_1.v.id('companies')),
36
+ type: contactTypeValidator,
37
+ source: values_1.v.optional(values_1.v.string()),
38
+ tags: values_1.v.optional(values_1.v.array(values_1.v.string())),
39
+ notes: values_1.v.optional(values_1.v.string()),
40
+ jobTitle: values_1.v.optional(values_1.v.string()),
41
+ linkedinUrl: values_1.v.optional(values_1.v.string()),
42
+ address: values_1.v.optional(values_1.v.string()),
43
+ vpProfileId: values_1.v.optional(values_1.v.string()),
44
+ customFields: values_1.v.optional(values_1.v.record(values_1.v.string(), values_1.v.any())),
45
+ actorId: values_1.v.optional(values_1.v.string()),
46
+ },
47
+ returns: values_1.v.id('contacts'),
48
+ handler: async (ctx, args) => {
49
+ const { clerkId, canWrite, actor } = await (0, workspace_1.assertWorkspaceAccess)(ctx, args.workspaceId, args.actorId);
50
+ if (!canWrite)
51
+ throw new Error('Insufficient permissions');
52
+ let companyName;
53
+ if (args.companyId) {
54
+ const company = await ctx.db.get(args.companyId);
55
+ companyName = company?.name;
56
+ }
57
+ const searchableText = (0, _helpers_1.buildContactSearchText)({ firstName: args.firstName, lastName: args.lastName, email: args.email }, companyName);
58
+ const now = Date.now();
59
+ const contactId = await ctx.db.insert('contacts', {
60
+ firstName: args.firstName,
61
+ lastName: args.lastName,
62
+ email: args.email,
63
+ phone: args.phone,
64
+ companyId: args.companyId,
65
+ type: args.type,
66
+ source: args.source,
67
+ tags: args.tags,
68
+ notes: args.notes,
69
+ jobTitle: args.jobTitle,
70
+ linkedinUrl: args.linkedinUrl,
71
+ address: args.address,
72
+ vpProfileId: args.vpProfileId,
73
+ customFields: args.customFields,
74
+ workspaceId: args.workspaceId,
75
+ ownerId: clerkId,
76
+ searchableText,
77
+ createdAt: now,
78
+ updatedAt: now,
79
+ });
80
+ await ctx.db.insert('audit_log', {
81
+ workspaceId: args.workspaceId,
82
+ actorId: actor.actorId,
83
+ actorType: actor.auditActorType,
84
+ entityType: 'contact',
85
+ entityId: contactId,
86
+ action: 'create',
87
+ timestamp: Date.now(),
88
+ });
89
+ return contactId;
90
+ },
91
+ });
92
+ // Backward-compatible alias for T3 API consumers
93
+ exports.create = exports.createContact;
94
+ // ---------------------------------------------------------------------------
95
+ // getContact / get
96
+ // ---------------------------------------------------------------------------
97
+ exports.getContact = (0, server_1.query)({
98
+ args: {
99
+ contactId: values_1.v.id('contacts'),
100
+ },
101
+ returns: values_1.v.union(values_1.v.null(), values_1.v.any()),
102
+ handler: async (ctx, args) => {
103
+ const contact = await ctx.db.get(args.contactId);
104
+ if (!contact)
105
+ return null;
106
+ await (0, workspace_1.validateWorkspaceAccess)(ctx, contact.workspaceId);
107
+ let company = null;
108
+ if (contact.companyId) {
109
+ company = await ctx.db.get(contact.companyId);
110
+ }
111
+ return { ...contact, company };
112
+ },
113
+ });
114
+ exports.get = exports.getContact;
115
+ // ---------------------------------------------------------------------------
116
+ // updateContact / update
117
+ // ---------------------------------------------------------------------------
118
+ exports.updateContact = (0, server_1.mutation)({
119
+ args: {
120
+ contactId: values_1.v.id('contacts'),
121
+ firstName: values_1.v.optional(values_1.v.string()),
122
+ lastName: values_1.v.optional(values_1.v.string()),
123
+ email: values_1.v.optional(values_1.v.string()),
124
+ phone: values_1.v.optional(values_1.v.string()),
125
+ companyId: values_1.v.optional(values_1.v.id('companies')),
126
+ type: values_1.v.optional(contactTypeValidator),
127
+ source: values_1.v.optional(values_1.v.string()),
128
+ tags: values_1.v.optional(values_1.v.array(values_1.v.string())),
129
+ notes: values_1.v.optional(values_1.v.string()),
130
+ jobTitle: values_1.v.optional(values_1.v.string()),
131
+ linkedinUrl: values_1.v.optional(values_1.v.string()),
132
+ address: values_1.v.optional(values_1.v.string()),
133
+ avatarUrl: values_1.v.optional(values_1.v.string()),
134
+ lastContactedAt: values_1.v.optional(values_1.v.number()),
135
+ leadScore: values_1.v.optional(values_1.v.number()),
136
+ vpProfileId: values_1.v.optional(values_1.v.string()),
137
+ customFields: values_1.v.optional(values_1.v.record(values_1.v.string(), values_1.v.any())),
138
+ actorId: values_1.v.optional(values_1.v.string()),
139
+ },
140
+ returns: values_1.v.id('contacts'),
141
+ handler: async (ctx, args) => {
142
+ const contact = await ctx.db.get(args.contactId);
143
+ if (!contact)
144
+ throw new Error('Contact not found');
145
+ const { canWrite, actor } = await (0, workspace_1.assertWorkspaceAccess)(ctx, contact.workspaceId, args.actorId);
146
+ if (!canWrite)
147
+ throw new Error('Insufficient permissions');
148
+ const { contactId, actorId: _actorId, ...updates } = args;
149
+ const firstName = updates.firstName ?? contact.firstName;
150
+ const lastName = updates.lastName ?? contact.lastName;
151
+ const email = updates.email ?? contact.email;
152
+ let companyName;
153
+ const companyId = updates.companyId ?? contact.companyId;
154
+ if (companyId) {
155
+ const company = await ctx.db.get(companyId);
156
+ companyName = company?.name;
157
+ }
158
+ const searchableText = (0, _helpers_1.buildContactSearchText)({ firstName, lastName, email }, companyName);
159
+ const beforeRecord = { ...contact };
160
+ await ctx.db.patch(contactId, {
161
+ ...updates,
162
+ searchableText,
163
+ updatedAt: Date.now(),
164
+ });
165
+ // Fetch updated record for diff
166
+ const afterRecord = (await ctx.db.get(contactId)) ?? {};
167
+ const fieldChanges = (0, auditLog_1.diffRecords)(beforeRecord, afterRecord);
168
+ await ctx.db.insert('audit_log', {
169
+ workspaceId: contact.workspaceId,
170
+ actorId: actor.actorId,
171
+ actorType: actor.auditActorType,
172
+ entityType: 'contact',
173
+ entityId: contactId,
174
+ action: 'update',
175
+ fieldChanges: fieldChanges.length > 0 ? fieldChanges : undefined,
176
+ timestamp: Date.now(),
177
+ });
178
+ return contactId;
179
+ },
180
+ });
181
+ exports.update = exports.updateContact;
182
+ // ---------------------------------------------------------------------------
183
+ // listContacts / list
184
+ // ---------------------------------------------------------------------------
185
+ exports.listContacts = (0, server_1.query)({
186
+ args: {
187
+ workspaceId: values_1.v.id('workspaces'),
188
+ type: values_1.v.optional(contactTypeValidator),
189
+ companyId: values_1.v.optional(values_1.v.id('companies')),
190
+ includeArchived: values_1.v.optional(values_1.v.boolean()),
191
+ limit: values_1.v.optional(values_1.v.number()),
192
+ },
193
+ returns: values_1.v.array(values_1.v.any()),
194
+ handler: async (ctx, args) => {
195
+ await (0, workspace_1.validateWorkspaceAccess)(ctx, args.workspaceId);
196
+ const limit = args.limit ?? 50;
197
+ let contacts;
198
+ if (args.type) {
199
+ contacts = await ctx.db
200
+ .query('contacts')
201
+ .withIndex('by_workspace_type', (q) => q.eq('workspaceId', args.workspaceId).eq('type', args.type))
202
+ .order('desc')
203
+ .take(1000);
204
+ }
205
+ else {
206
+ contacts = await ctx.db
207
+ .query('contacts')
208
+ .withIndex('by_workspace', (q) => q.eq('workspaceId', args.workspaceId))
209
+ .order('desc')
210
+ .take(1000);
211
+ }
212
+ let filtered = args.includeArchived ? contacts : contacts.filter((c) => c.isArchived !== true);
213
+ if (args.companyId) {
214
+ filtered = filtered.filter((c) => c.companyId === args.companyId);
215
+ }
216
+ return filtered.slice(0, limit);
217
+ },
218
+ });
219
+ exports.list = exports.listContacts;
220
+ // ---------------------------------------------------------------------------
221
+ // archiveContact (OQ-4 soft delete)
222
+ // ---------------------------------------------------------------------------
223
+ exports.archiveContact = (0, server_1.mutation)({
224
+ args: {
225
+ contactId: values_1.v.id('contacts'),
226
+ actorId: values_1.v.optional(values_1.v.string()),
227
+ },
228
+ returns: values_1.v.id('contacts'),
229
+ handler: async (ctx, args) => {
230
+ const contact = await ctx.db.get(args.contactId);
231
+ if (!contact)
232
+ throw new Error('Contact not found');
233
+ const { canWrite, actor } = await (0, workspace_1.assertWorkspaceAccess)(ctx, contact.workspaceId, args.actorId);
234
+ if (!canWrite)
235
+ throw new Error('Insufficient permissions');
236
+ if (contact.isArchived)
237
+ throw new Error('Contact is already archived');
238
+ const now = Date.now();
239
+ return (0, auditLog_1.withAuditLog)(ctx, {
240
+ workspaceId: contact.workspaceId,
241
+ actorId: actor.actorId,
242
+ actorType: actor.auditActorType,
243
+ entityType: 'contact',
244
+ entityId: args.contactId,
245
+ action: 'archive',
246
+ }, async () => {
247
+ await ctx.db.patch(args.contactId, {
248
+ isArchived: true,
249
+ archivedAt: now,
250
+ updatedAt: now,
251
+ });
252
+ return args.contactId;
253
+ });
254
+ },
255
+ });
256
+ // Backward-compatible alias (T3 remove was a soft-delete)
257
+ exports.remove = exports.archiveContact;
258
+ // ---------------------------------------------------------------------------
259
+ // restoreContact (OQ-4 reverse soft delete)
260
+ // ---------------------------------------------------------------------------
261
+ exports.restoreContact = (0, server_1.mutation)({
262
+ args: {
263
+ contactId: values_1.v.id('contacts'),
264
+ actorId: values_1.v.optional(values_1.v.string()),
265
+ },
266
+ returns: values_1.v.id('contacts'),
267
+ handler: async (ctx, args) => {
268
+ const contact = await ctx.db.get(args.contactId);
269
+ if (!contact)
270
+ throw new Error('Contact not found');
271
+ const { canWrite, actor } = await (0, workspace_1.assertWorkspaceAccess)(ctx, contact.workspaceId, args.actorId);
272
+ if (!canWrite)
273
+ throw new Error('Insufficient permissions');
274
+ if (!contact.isArchived)
275
+ throw new Error('Contact is not archived');
276
+ return (0, auditLog_1.withAuditLog)(ctx, {
277
+ workspaceId: contact.workspaceId,
278
+ actorId: actor.actorId,
279
+ actorType: actor.auditActorType,
280
+ entityType: 'contact',
281
+ entityId: args.contactId,
282
+ action: 'restore',
283
+ }, async () => {
284
+ await ctx.db.patch(args.contactId, {
285
+ isArchived: false,
286
+ archivedAt: undefined,
287
+ updatedAt: Date.now(),
288
+ });
289
+ return args.contactId;
290
+ });
291
+ },
292
+ });
293
+ // ---------------------------------------------------------------------------
294
+ // deleteContact — ADMIN SCOPE ONLY (hard delete, OQ-4: after 30j grace)
295
+ // ---------------------------------------------------------------------------
296
+ exports.deleteContact = (0, server_1.mutation)({
297
+ args: {
298
+ contactId: values_1.v.id('contacts'),
299
+ },
300
+ returns: values_1.v.id('contacts'),
301
+ handler: async (ctx, args) => {
302
+ const contact = await ctx.db.get(args.contactId);
303
+ if (!contact)
304
+ throw new Error('Contact not found');
305
+ const { role, actor } = await (0, workspace_1.assertWorkspaceAccess)(ctx, contact.workspaceId);
306
+ await (0, rbac_1.assertAdminScope)(ctx, role);
307
+ if (!contact.isArchived) {
308
+ throw new Error('Contact must be archived before hard delete');
309
+ }
310
+ const archivedAt = contact.archivedAt ?? 0;
311
+ const gracePeriodMs = 30 * 24 * 60 * 60 * 1000;
312
+ if (Date.now() - archivedAt < gracePeriodMs) {
313
+ const daysLeft = Math.ceil((gracePeriodMs - (Date.now() - archivedAt)) / (24 * 60 * 60 * 1000));
314
+ throw new Error(`Contact cannot be hard deleted yet. ${daysLeft} day(s) remaining in 30-day grace period.`);
315
+ }
316
+ await ctx.db.insert('audit_log', {
317
+ workspaceId: contact.workspaceId,
318
+ actorId: actor.actorId,
319
+ actorType: actor.auditActorType,
320
+ entityType: 'contact',
321
+ entityId: args.contactId,
322
+ action: 'delete',
323
+ timestamp: Date.now(),
324
+ });
325
+ await ctx.db.delete(args.contactId);
326
+ return args.contactId;
327
+ },
328
+ });
329
+ // ---------------------------------------------------------------------------
330
+ // search — preserved from T3
331
+ // ---------------------------------------------------------------------------
332
+ exports.search = (0, server_1.query)({
333
+ args: {
334
+ workspaceId: values_1.v.id('workspaces'),
335
+ query: values_1.v.string(),
336
+ },
337
+ returns: values_1.v.array(values_1.v.any()),
338
+ handler: async (ctx, args) => {
339
+ await (0, workspace_1.validateWorkspaceAccess)(ctx, args.workspaceId);
340
+ const results = await ctx.db
341
+ .query('contacts')
342
+ .withSearchIndex('search_contacts', (q) => q.search('searchableText', args.query).eq('workspaceId', args.workspaceId))
343
+ .take(50);
344
+ return results.filter((c) => c.isArchived !== true);
345
+ },
346
+ });