@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,481 @@
1
+ "use strict";
2
+ /**
3
+ * convex/crm/deals.ts
4
+ *
5
+ * V0.1.0 — Extended from T3 stub.
6
+ * Functions: createDeal, updateDeal, moveStage, getDeal, listDeals,
7
+ * archiveDeal, restoreDeal, deleteDeal (admin only).
8
+ * OQ-2: probability auto-set from workspace.pipelineStages at moveStage.
9
+ * OQ-4: soft delete + 30j grace for hard delete.
10
+ * Every mutation creates audit_log entry.
11
+ *
12
+ * Ref: vantage-crm-spec-2026-05-20.md §2 + §5 OQ-2/OQ-4
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.forecast = exports.summary = exports.deleteDeal = exports.restoreDeal = exports.remove = exports.archiveDeal = exports.list = exports.listDeals = exports.moveStage = exports.update = exports.updateDeal = exports.get = exports.getDeal = exports.create = exports.createDeal = void 0;
16
+ const server_1 = require("../_generated/server");
17
+ const values_1 = require("convex/values");
18
+ const workspace_1 = require("../lib/workspace");
19
+ const auditLog_1 = require("../lib/auditLog");
20
+ const rbac_1 = require("../lib/rbac");
21
+ const _helpers_1 = require("./_helpers");
22
+ // ---------------------------------------------------------------------------
23
+ // Internal: resolve pipeline stage probability (OQ-2)
24
+ // ---------------------------------------------------------------------------
25
+ async function resolveStageProbability(ctx, workspaceId, stageName) {
26
+ const workspace = await ctx.db.get(workspaceId);
27
+ if (!workspace?.pipelineStages || workspace.pipelineStages.length === 0)
28
+ return undefined;
29
+ const stageConfig = workspace.pipelineStages.find((s) => s.name === stageName || s.id === stageName);
30
+ return stageConfig?.probability;
31
+ }
32
+ async function validateStage(ctx, workspaceId, stageName) {
33
+ const workspace = await ctx.db.get(workspaceId);
34
+ if (!workspace?.pipelineStages || workspace.pipelineStages.length === 0)
35
+ return;
36
+ const validStages = workspace.pipelineStages.map((s) => s.name);
37
+ if (!validStages.includes(stageName)) {
38
+ throw new Error(`Invalid stage "${stageName}". Valid stages: ${validStages.join(', ')}`);
39
+ }
40
+ }
41
+ // ---------------------------------------------------------------------------
42
+ // createDeal
43
+ // ---------------------------------------------------------------------------
44
+ exports.createDeal = (0, server_1.mutation)({
45
+ args: {
46
+ workspaceId: values_1.v.id('workspaces'),
47
+ title: values_1.v.string(),
48
+ stage: values_1.v.string(),
49
+ contactId: values_1.v.optional(values_1.v.id('contacts')),
50
+ companyId: values_1.v.optional(values_1.v.id('companies')),
51
+ value: values_1.v.optional(values_1.v.number()),
52
+ probability: values_1.v.optional(values_1.v.number()),
53
+ currency: values_1.v.optional(values_1.v.string()),
54
+ expectedCloseDate: values_1.v.optional(values_1.v.number()),
55
+ description: values_1.v.optional(values_1.v.string()),
56
+ tags: values_1.v.optional(values_1.v.array(values_1.v.string())),
57
+ notes: values_1.v.optional(values_1.v.string()),
58
+ source: values_1.v.optional(values_1.v.string()),
59
+ customFields: values_1.v.optional(values_1.v.record(values_1.v.string(), values_1.v.any())),
60
+ actorId: values_1.v.optional(values_1.v.string()),
61
+ },
62
+ returns: values_1.v.id('deals'),
63
+ handler: async (ctx, args) => {
64
+ const { clerkId, canWrite, actor } = await (0, workspace_1.assertWorkspaceAccess)(ctx, args.workspaceId, args.actorId);
65
+ if (!canWrite)
66
+ throw new Error('Insufficient permissions');
67
+ await validateStage(ctx, args.workspaceId, args.stage);
68
+ // OQ-2: auto-set probability from pipeline config if not provided
69
+ const autoProbability = args.probability ?? (await resolveStageProbability(ctx, args.workspaceId, args.stage));
70
+ let companyName;
71
+ let contactName;
72
+ if (args.companyId) {
73
+ const company = await ctx.db.get(args.companyId);
74
+ companyName = company?.name;
75
+ }
76
+ if (args.contactId) {
77
+ const contact = await ctx.db.get(args.contactId);
78
+ contactName = contact ? `${contact.firstName} ${contact.lastName}` : undefined;
79
+ }
80
+ const searchableText = (0, _helpers_1.buildDealSearchText)({ title: args.title }, companyName, contactName);
81
+ const now = Date.now();
82
+ const dealId = await ctx.db.insert('deals', {
83
+ title: args.title,
84
+ stage: args.stage,
85
+ contactId: args.contactId,
86
+ companyId: args.companyId,
87
+ value: args.value,
88
+ probability: autoProbability,
89
+ currency: args.currency,
90
+ expectedCloseDate: args.expectedCloseDate,
91
+ description: args.description,
92
+ tags: args.tags,
93
+ notes: args.notes,
94
+ source: args.source,
95
+ customFields: args.customFields,
96
+ workspaceId: args.workspaceId,
97
+ ownerId: clerkId,
98
+ stageChangedAt: now,
99
+ searchableText,
100
+ createdAt: now,
101
+ updatedAt: now,
102
+ });
103
+ await ctx.db.insert('audit_log', {
104
+ workspaceId: args.workspaceId,
105
+ actorId: actor.actorId,
106
+ actorType: actor.auditActorType,
107
+ entityType: 'deal',
108
+ entityId: dealId,
109
+ action: 'create',
110
+ timestamp: Date.now(),
111
+ });
112
+ return dealId;
113
+ },
114
+ });
115
+ // Backward-compatible alias
116
+ exports.create = exports.createDeal;
117
+ // ---------------------------------------------------------------------------
118
+ // getDeal / get
119
+ // ---------------------------------------------------------------------------
120
+ exports.getDeal = (0, server_1.query)({
121
+ args: {
122
+ dealId: values_1.v.id('deals'),
123
+ },
124
+ returns: values_1.v.union(values_1.v.null(), values_1.v.any()),
125
+ handler: async (ctx, args) => {
126
+ const deal = await ctx.db.get(args.dealId);
127
+ if (!deal)
128
+ return null;
129
+ await (0, workspace_1.validateWorkspaceAccess)(ctx, deal.workspaceId);
130
+ let contact = null;
131
+ if (deal.contactId)
132
+ contact = await ctx.db.get(deal.contactId);
133
+ let company = null;
134
+ if (deal.companyId)
135
+ company = await ctx.db.get(deal.companyId);
136
+ return { ...deal, contact, company };
137
+ },
138
+ });
139
+ exports.get = exports.getDeal;
140
+ // ---------------------------------------------------------------------------
141
+ // updateDeal / update
142
+ // ---------------------------------------------------------------------------
143
+ exports.updateDeal = (0, server_1.mutation)({
144
+ args: {
145
+ dealId: values_1.v.id('deals'),
146
+ title: values_1.v.optional(values_1.v.string()),
147
+ stage: values_1.v.optional(values_1.v.string()),
148
+ contactId: values_1.v.optional(values_1.v.id('contacts')),
149
+ companyId: values_1.v.optional(values_1.v.id('companies')),
150
+ value: values_1.v.optional(values_1.v.number()),
151
+ probability: values_1.v.optional(values_1.v.number()),
152
+ currency: values_1.v.optional(values_1.v.string()),
153
+ expectedCloseDate: values_1.v.optional(values_1.v.number()),
154
+ description: values_1.v.optional(values_1.v.string()),
155
+ tags: values_1.v.optional(values_1.v.array(values_1.v.string())),
156
+ notes: values_1.v.optional(values_1.v.string()),
157
+ source: values_1.v.optional(values_1.v.string()),
158
+ lostReason: values_1.v.optional(values_1.v.string()),
159
+ actualCloseDate: values_1.v.optional(values_1.v.number()),
160
+ customFields: values_1.v.optional(values_1.v.record(values_1.v.string(), values_1.v.any())),
161
+ actorId: values_1.v.optional(values_1.v.string()),
162
+ },
163
+ returns: values_1.v.id('deals'),
164
+ handler: async (ctx, args) => {
165
+ const deal = await ctx.db.get(args.dealId);
166
+ if (!deal)
167
+ throw new Error('Deal not found');
168
+ const { canWrite, actor } = await (0, workspace_1.assertWorkspaceAccess)(ctx, deal.workspaceId, args.actorId);
169
+ if (!canWrite)
170
+ throw new Error('Insufficient permissions');
171
+ const { dealId, actorId: _actorId, ...updates } = args;
172
+ if (updates.stage) {
173
+ await validateStage(ctx, deal.workspaceId, updates.stage);
174
+ // OQ-2: auto-set probability when stage changes
175
+ if (updates.probability === undefined) {
176
+ updates.probability = await resolveStageProbability(ctx, deal.workspaceId, updates.stage);
177
+ }
178
+ }
179
+ const title = updates.title ?? deal.title;
180
+ let companyName;
181
+ let contactName;
182
+ const companyId = updates.companyId ?? deal.companyId;
183
+ if (companyId) {
184
+ const company = await ctx.db.get(companyId);
185
+ companyName = company?.name;
186
+ }
187
+ const contactId = updates.contactId ?? deal.contactId;
188
+ if (contactId) {
189
+ const contact = await ctx.db.get(contactId);
190
+ contactName = contact ? `${contact.firstName} ${contact.lastName}` : undefined;
191
+ }
192
+ const searchableText = (0, _helpers_1.buildDealSearchText)({ title }, companyName, contactName);
193
+ const beforeRecord = { ...deal };
194
+ await ctx.db.patch(dealId, {
195
+ ...updates,
196
+ searchableText,
197
+ updatedAt: Date.now(),
198
+ });
199
+ const afterRecord = (await ctx.db.get(dealId)) ?? {};
200
+ const fieldChanges = (0, auditLog_1.diffRecords)(beforeRecord, afterRecord);
201
+ await ctx.db.insert('audit_log', {
202
+ workspaceId: deal.workspaceId,
203
+ actorId: actor.actorId,
204
+ actorType: actor.auditActorType,
205
+ entityType: 'deal',
206
+ entityId: dealId,
207
+ action: 'update',
208
+ fieldChanges: fieldChanges.length > 0 ? fieldChanges : undefined,
209
+ timestamp: Date.now(),
210
+ });
211
+ return dealId;
212
+ },
213
+ });
214
+ exports.update = exports.updateDeal;
215
+ // ---------------------------------------------------------------------------
216
+ // moveStage (OQ-2: auto-set probability)
217
+ // ---------------------------------------------------------------------------
218
+ exports.moveStage = (0, server_1.mutation)({
219
+ args: {
220
+ dealId: values_1.v.id('deals'),
221
+ newStage: values_1.v.string(),
222
+ reason: values_1.v.optional(values_1.v.string()),
223
+ actorId: values_1.v.optional(values_1.v.string()),
224
+ },
225
+ returns: values_1.v.id('deals'),
226
+ handler: async (ctx, args) => {
227
+ const deal = await ctx.db.get(args.dealId);
228
+ if (!deal)
229
+ throw new Error('Deal not found');
230
+ const { clerkId, canWrite, actor } = await (0, workspace_1.assertWorkspaceAccess)(ctx, deal.workspaceId, args.actorId);
231
+ if (!canWrite)
232
+ throw new Error('Insufficient permissions');
233
+ await validateStage(ctx, deal.workspaceId, args.newStage);
234
+ // OQ-2: auto-set probability from pipeline stage config
235
+ const autoProbability = await resolveStageProbability(ctx, deal.workspaceId, args.newStage);
236
+ const oldStage = deal.stage;
237
+ const now = Date.now();
238
+ const patchData = {
239
+ stage: args.newStage,
240
+ stageChangedAt: now,
241
+ updatedAt: now,
242
+ };
243
+ if (autoProbability !== undefined) {
244
+ patchData.probability = autoProbability;
245
+ }
246
+ if (args.newStage.toLowerCase().includes('lost')) {
247
+ patchData.lostReason = args.reason ?? 'No reason provided';
248
+ }
249
+ await ctx.db.patch(args.dealId, patchData);
250
+ // Create stage-change activity (audit trail)
251
+ await ctx.db.insert('activities', {
252
+ type: 'stage-change',
253
+ subject: `Stage changed: ${oldStage} → ${args.newStage}`,
254
+ description: `${oldStage} → ${args.newStage}${args.reason ? ` (${args.reason})` : ''}`,
255
+ dealId: args.dealId,
256
+ contactId: deal.contactId,
257
+ companyId: deal.companyId,
258
+ workspaceId: deal.workspaceId,
259
+ ownerId: clerkId,
260
+ actorType: actor.auditActorType,
261
+ actorId: actor.actorId,
262
+ occurredAt: now,
263
+ createdAt: now,
264
+ updatedAt: now,
265
+ });
266
+ await ctx.db.insert('audit_log', {
267
+ workspaceId: deal.workspaceId,
268
+ actorId: actor.actorId,
269
+ actorType: actor.auditActorType,
270
+ entityType: 'deal',
271
+ entityId: args.dealId,
272
+ action: 'stage-change',
273
+ fieldChanges: [
274
+ { fieldName: 'stage', oldValue: oldStage, newValue: args.newStage },
275
+ ...(autoProbability !== undefined
276
+ ? [
277
+ {
278
+ fieldName: 'probability',
279
+ oldValue: deal.probability ?? null,
280
+ newValue: autoProbability,
281
+ },
282
+ ]
283
+ : []),
284
+ ],
285
+ timestamp: Date.now(),
286
+ });
287
+ return args.dealId;
288
+ },
289
+ });
290
+ // ---------------------------------------------------------------------------
291
+ // listDeals / list
292
+ // ---------------------------------------------------------------------------
293
+ exports.listDeals = (0, server_1.query)({
294
+ args: {
295
+ workspaceId: values_1.v.id('workspaces'),
296
+ stage: values_1.v.optional(values_1.v.string()),
297
+ ownerId: values_1.v.optional(values_1.v.string()),
298
+ includeArchived: values_1.v.optional(values_1.v.boolean()),
299
+ limit: values_1.v.optional(values_1.v.number()),
300
+ },
301
+ returns: values_1.v.array(values_1.v.any()),
302
+ handler: async (ctx, args) => {
303
+ await (0, workspace_1.validateWorkspaceAccess)(ctx, args.workspaceId);
304
+ const limit = args.limit ?? 50;
305
+ let deals;
306
+ if (args.stage) {
307
+ deals = await ctx.db
308
+ .query('deals')
309
+ .withIndex('by_workspace_stage', (q) => q.eq('workspaceId', args.workspaceId).eq('stage', args.stage))
310
+ .order('desc')
311
+ .take(limit + 1);
312
+ }
313
+ else {
314
+ deals = await ctx.db
315
+ .query('deals')
316
+ .withIndex('by_workspace', (q) => q.eq('workspaceId', args.workspaceId))
317
+ .order('desc')
318
+ .take(limit + 1);
319
+ }
320
+ let filtered = args.includeArchived ? deals : deals.filter((d) => d.isArchived !== true);
321
+ if (args.ownerId) {
322
+ filtered = filtered.filter((d) => d.ownerId === args.ownerId);
323
+ }
324
+ return filtered.slice(0, limit);
325
+ },
326
+ });
327
+ exports.list = exports.listDeals;
328
+ // ---------------------------------------------------------------------------
329
+ // archiveDeal (OQ-4 soft delete)
330
+ // ---------------------------------------------------------------------------
331
+ exports.archiveDeal = (0, server_1.mutation)({
332
+ args: {
333
+ dealId: values_1.v.id('deals'),
334
+ actorId: values_1.v.optional(values_1.v.string()),
335
+ },
336
+ returns: values_1.v.id('deals'),
337
+ handler: async (ctx, args) => {
338
+ const deal = await ctx.db.get(args.dealId);
339
+ if (!deal)
340
+ throw new Error('Deal not found');
341
+ const { canWrite, actor } = await (0, workspace_1.assertWorkspaceAccess)(ctx, deal.workspaceId, args.actorId);
342
+ if (!canWrite)
343
+ throw new Error('Insufficient permissions');
344
+ if (deal.isArchived)
345
+ throw new Error('Deal is already archived');
346
+ const now = Date.now();
347
+ return (0, auditLog_1.withAuditLog)(ctx, {
348
+ workspaceId: deal.workspaceId,
349
+ actorId: actor.actorId,
350
+ actorType: actor.auditActorType,
351
+ entityType: 'deal',
352
+ entityId: args.dealId,
353
+ action: 'archive',
354
+ }, async () => {
355
+ await ctx.db.patch(args.dealId, {
356
+ isArchived: true,
357
+ archivedAt: now,
358
+ updatedAt: now,
359
+ });
360
+ return args.dealId;
361
+ });
362
+ },
363
+ });
364
+ // Backward-compatible alias
365
+ exports.remove = exports.archiveDeal;
366
+ // ---------------------------------------------------------------------------
367
+ // restoreDeal
368
+ // ---------------------------------------------------------------------------
369
+ exports.restoreDeal = (0, server_1.mutation)({
370
+ args: {
371
+ dealId: values_1.v.id('deals'),
372
+ actorId: values_1.v.optional(values_1.v.string()),
373
+ },
374
+ returns: values_1.v.id('deals'),
375
+ handler: async (ctx, args) => {
376
+ const deal = await ctx.db.get(args.dealId);
377
+ if (!deal)
378
+ throw new Error('Deal not found');
379
+ const { canWrite, actor } = await (0, workspace_1.assertWorkspaceAccess)(ctx, deal.workspaceId, args.actorId);
380
+ if (!canWrite)
381
+ throw new Error('Insufficient permissions');
382
+ if (!deal.isArchived)
383
+ throw new Error('Deal is not archived');
384
+ return (0, auditLog_1.withAuditLog)(ctx, {
385
+ workspaceId: deal.workspaceId,
386
+ actorId: actor.actorId,
387
+ actorType: actor.auditActorType,
388
+ entityType: 'deal',
389
+ entityId: args.dealId,
390
+ action: 'restore',
391
+ }, async () => {
392
+ await ctx.db.patch(args.dealId, {
393
+ isArchived: false,
394
+ archivedAt: undefined,
395
+ updatedAt: Date.now(),
396
+ });
397
+ return args.dealId;
398
+ });
399
+ },
400
+ });
401
+ // ---------------------------------------------------------------------------
402
+ // deleteDeal — ADMIN SCOPE ONLY (hard delete, OQ-4: 30j grace)
403
+ // ---------------------------------------------------------------------------
404
+ exports.deleteDeal = (0, server_1.mutation)({
405
+ args: {
406
+ dealId: values_1.v.id('deals'),
407
+ },
408
+ returns: values_1.v.id('deals'),
409
+ handler: async (ctx, args) => {
410
+ const deal = await ctx.db.get(args.dealId);
411
+ if (!deal)
412
+ throw new Error('Deal not found');
413
+ const { role, actor } = await (0, workspace_1.assertWorkspaceAccess)(ctx, deal.workspaceId);
414
+ await (0, rbac_1.assertAdminScope)(ctx, role);
415
+ if (!deal.isArchived) {
416
+ throw new Error('Deal must be archived before hard delete');
417
+ }
418
+ const archivedAt = deal.archivedAt ?? 0;
419
+ const gracePeriodMs = 30 * 24 * 60 * 60 * 1000;
420
+ if (Date.now() - archivedAt < gracePeriodMs) {
421
+ const daysLeft = Math.ceil((gracePeriodMs - (Date.now() - archivedAt)) / (24 * 60 * 60 * 1000));
422
+ throw new Error(`Deal cannot be hard deleted yet. ${daysLeft} day(s) remaining in grace period.`);
423
+ }
424
+ await ctx.db.insert('audit_log', {
425
+ workspaceId: deal.workspaceId,
426
+ actorId: actor.actorId,
427
+ actorType: actor.auditActorType,
428
+ entityType: 'deal',
429
+ entityId: args.dealId,
430
+ action: 'delete',
431
+ timestamp: Date.now(),
432
+ });
433
+ await ctx.db.delete(args.dealId);
434
+ return args.dealId;
435
+ },
436
+ });
437
+ // ---------------------------------------------------------------------------
438
+ // summary + forecast — preserved from T3
439
+ // ---------------------------------------------------------------------------
440
+ exports.summary = (0, server_1.query)({
441
+ args: { dealId: values_1.v.id('deals') },
442
+ returns: values_1.v.union(values_1.v.null(), values_1.v.any()),
443
+ handler: async (ctx, args) => {
444
+ const deal = await ctx.db.get(args.dealId);
445
+ if (!deal)
446
+ return null;
447
+ await (0, workspace_1.validateWorkspaceAccess)(ctx, deal.workspaceId);
448
+ let contact = null;
449
+ if (deal.contactId)
450
+ contact = await ctx.db.get(deal.contactId);
451
+ let company = null;
452
+ if (deal.companyId)
453
+ company = await ctx.db.get(deal.companyId);
454
+ const activities = await ctx.db
455
+ .query('activities')
456
+ .withIndex('by_deal', (q) => q.eq('dealId', args.dealId))
457
+ .order('desc')
458
+ .take(50);
459
+ return { ...deal, contact, company, activities };
460
+ },
461
+ });
462
+ exports.forecast = (0, server_1.query)({
463
+ args: { workspaceId: values_1.v.id('workspaces') },
464
+ returns: values_1.v.any(),
465
+ handler: async (ctx, args) => {
466
+ await (0, workspace_1.validateWorkspaceAccess)(ctx, args.workspaceId);
467
+ const deals = await ctx.db
468
+ .query('deals')
469
+ .withIndex('by_workspace', (q) => q.eq('workspaceId', args.workspaceId))
470
+ .take(1000);
471
+ const activeDeals = deals.filter((d) => d.isArchived !== true &&
472
+ !d.stage.toLowerCase().includes('lost') &&
473
+ !d.stage.toLowerCase().includes('closed-lost'));
474
+ const weightedTotal = activeDeals.reduce((sum, d) => {
475
+ const value = d.value ?? 0;
476
+ const probability = d.probability ?? 0;
477
+ return sum + (value * probability) / 100;
478
+ }, 0);
479
+ return { totalWeightedValue: weightedTotal, dealCount: activeDeals.length };
480
+ },
481
+ });
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+ /**
3
+ * CRM Email Actions — Draft & Send
4
+ *
5
+ * Server-side email actions for the AI tool layer.
6
+ * email_draft: generates email content using AI + contact context
7
+ * email_send: sends via Composio Gmail API, logs activity
8
+ *
9
+ * These are Convex mutations/queries used by the AI tools in crm-tools.ts.
10
+ * The actual Composio calls happen in the Next.js layer (lib/integrations/gmail-sync.ts).
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.findContactByEmail = exports.getContactContext = exports.logSentEmail = void 0;
14
+ const server_1 = require("../_generated/server");
15
+ const values_1 = require("convex/values");
16
+ const workspace_1 = require("../lib/workspace");
17
+ // =============================================================================
18
+ // LOG SENT EMAIL AS ACTIVITY
19
+ // =============================================================================
20
+ /**
21
+ * Log a sent email as an activity linked to contact/company/deal.
22
+ * Called after successful email send from the AI tool layer.
23
+ */
24
+ exports.logSentEmail = (0, server_1.mutation)({
25
+ args: {
26
+ workspaceId: values_1.v.id('workspaces'),
27
+ to: values_1.v.string(),
28
+ subject: values_1.v.string(),
29
+ body: values_1.v.string(),
30
+ contactId: values_1.v.optional(values_1.v.id('contacts')),
31
+ companyId: values_1.v.optional(values_1.v.id('companies')),
32
+ dealId: values_1.v.optional(values_1.v.id('deals')),
33
+ },
34
+ handler: async (ctx, args) => {
35
+ const { clerkId, canWrite } = await (0, workspace_1.validateWorkspaceAccess)(ctx, args.workspaceId);
36
+ if (!canWrite)
37
+ throw new Error('Insufficient permissions');
38
+ const now = Date.now();
39
+ // Create activity of type 'email'
40
+ const activityId = await ctx.db.insert('activities', {
41
+ type: 'email-sent',
42
+ subject: `Sent: ${args.subject}`,
43
+ description: `To: ${args.to}\n\n${args.body.substring(0, 2000)}`,
44
+ contactId: args.contactId,
45
+ companyId: args.companyId,
46
+ dealId: args.dealId,
47
+ actorType: 'agent',
48
+ actorId: 'agent:ai',
49
+ workspaceId: args.workspaceId,
50
+ ownerId: clerkId,
51
+ occurredAt: now,
52
+ createdAt: now,
53
+ updatedAt: now,
54
+ });
55
+ // Update lastActivityAt on deal if linked
56
+ if (args.dealId) {
57
+ await ctx.db.patch(args.dealId, {
58
+ lastActivityAt: now,
59
+ updatedAt: now,
60
+ });
61
+ }
62
+ // Update lastContactedAt on contact if linked
63
+ if (args.contactId) {
64
+ await ctx.db.patch(args.contactId, {
65
+ lastContactedAt: now,
66
+ updatedAt: now,
67
+ });
68
+ }
69
+ return activityId;
70
+ },
71
+ });
72
+ // =============================================================================
73
+ // GET CONTACT CONTEXT (for AI email drafting)
74
+ // =============================================================================
75
+ /**
76
+ * Fetch contact details + recent activities + recent emails for AI context.
77
+ * Used by email_draft tool to generate contextual emails.
78
+ */
79
+ exports.getContactContext = (0, server_1.query)({
80
+ args: {
81
+ workspaceId: values_1.v.id('workspaces'),
82
+ contactId: values_1.v.id('contacts'),
83
+ },
84
+ handler: async (ctx, args) => {
85
+ await (0, workspace_1.validateWorkspaceAccess)(ctx, args.workspaceId);
86
+ const contact = await ctx.db.get(args.contactId);
87
+ if (!contact || contact.workspaceId !== args.workspaceId) {
88
+ return null;
89
+ }
90
+ // Get company if linked
91
+ let company = null;
92
+ if (contact.companyId) {
93
+ company = await ctx.db.get(contact.companyId);
94
+ }
95
+ // Get recent activities
96
+ const activities = await ctx.db
97
+ .query('activities')
98
+ .withIndex('by_contact', (q) => q.eq('contactId', args.contactId))
99
+ .order('desc')
100
+ .take(5);
101
+ // Get recent emails
102
+ const emails = await ctx.db
103
+ .query('emailSync')
104
+ .withIndex('by_contact', (q) => q.eq('contactId', args.contactId))
105
+ .order('desc')
106
+ .take(5);
107
+ return {
108
+ contact: {
109
+ id: contact._id,
110
+ firstName: contact.firstName,
111
+ lastName: contact.lastName,
112
+ email: contact.email,
113
+ phone: contact.phone,
114
+ jobTitle: contact.jobTitle,
115
+ type: contact.type,
116
+ notes: contact.notes,
117
+ },
118
+ company: company
119
+ ? {
120
+ id: company._id,
121
+ name: company.name,
122
+ domain: company.domain,
123
+ industry: company.industry,
124
+ }
125
+ : null,
126
+ recentActivities: activities.map((a) => ({
127
+ type: a.type,
128
+ subject: a.subject,
129
+ occurredAt: a.occurredAt,
130
+ })),
131
+ recentEmails: emails.map((e) => ({
132
+ subject: e.subject,
133
+ from: e.from,
134
+ snippet: e.snippet,
135
+ sentAt: e.sentAt,
136
+ })),
137
+ };
138
+ },
139
+ });
140
+ // =============================================================================
141
+ // FIND CONTACT BY EMAIL (for auto-linking in send flow)
142
+ // =============================================================================
143
+ exports.findContactByEmail = (0, server_1.query)({
144
+ args: {
145
+ workspaceId: values_1.v.id('workspaces'),
146
+ email: values_1.v.string(),
147
+ },
148
+ handler: async (ctx, args) => {
149
+ await (0, workspace_1.validateWorkspaceAccess)(ctx, args.workspaceId);
150
+ const emailLower = args.email.toLowerCase();
151
+ const contact = await ctx.db
152
+ .query('contacts')
153
+ .withIndex('by_workspace', (q) => q.eq('workspaceId', args.workspaceId))
154
+ .filter((q) => q.eq(q.field('email'), emailLower))
155
+ .first();
156
+ return contact;
157
+ },
158
+ });