@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.
- package/README.md +260 -0
- package/dist/convex/crm/_helpers.js +24 -0
- package/dist/convex/crm/activities.js +220 -0
- package/dist/convex/crm/briefing.js +198 -0
- package/dist/convex/crm/calendarCron.js +92 -0
- package/dist/convex/crm/calendarCronDispatch.js +83 -0
- package/dist/convex/crm/calendarSync.js +294 -0
- package/dist/convex/crm/companies.js +323 -0
- package/dist/convex/crm/contacts.js +346 -0
- package/dist/convex/crm/deals.js +481 -0
- package/dist/convex/crm/emailActions.js +158 -0
- package/dist/convex/crm/emailCron.js +210 -0
- package/dist/convex/crm/emailCronDispatch.js +76 -0
- package/dist/convex/crm/emailSync.js +260 -0
- package/dist/convex/crm/onboarding.js +185 -0
- package/dist/convex/crm/stats.js +75 -0
- package/dist/convex/crm/tasks.js +109 -0
- package/dist/convex/crons.js +25 -0
- package/dist/convex/integrations.js +183 -0
- package/dist/convex/lib/auditLog.js +109 -0
- package/dist/convex/lib/auth.js +372 -0
- package/dist/convex/lib/rbac.js +123 -0
- package/dist/convex/lib/workspace.js +171 -0
- package/dist/convex/organizations.js +192 -0
- package/dist/convex/schema.js +690 -0
- package/dist/convex/users.js +217 -0
- package/dist/convex/workspaces.js +603 -0
- package/dist/mcp-server/lib/convexClient.js +50 -0
- package/dist/mcp-server/lib/scopeEnforcement.js +76 -0
- package/dist/mcp-server/registry.js +116 -0
- package/dist/mcp-server/server.js +97 -0
- package/dist/mcp-server/tests/registry.test.js +163 -0
- package/dist/mcp-server/tests/scopeEnforcement.test.js +137 -0
- package/dist/mcp-server/tests/security.test.js +257 -0
- package/dist/mcp-server/tests/tools.test.js +272 -0
- package/dist/mcp-server/tools/activities.js +207 -0
- package/dist/mcp-server/tools/admin.js +190 -0
- package/dist/mcp-server/tools/companies.js +233 -0
- package/dist/mcp-server/tools/contacts.js +306 -0
- package/dist/mcp-server/tools/customFields.js +222 -0
- package/dist/mcp-server/tools/customObjects.js +235 -0
- package/dist/mcp-server/tools/deals.js +297 -0
- package/dist/mcp-server/tools/rbac.js +177 -0
- package/dist/mcp-server/tools/search.js +155 -0
- package/dist/mcp-server/tools/workflows.js +234 -0
- package/dist/mcp-server/transport/http.js +257 -0
- package/dist/mcp-server/transport/stdio.js +90 -0
- 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
|
+
});
|