@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,185 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.needsOnboarding = exports.importContacts = exports.setupWorkspace = void 0;
|
|
4
|
+
const server_1 = require("../_generated/server");
|
|
5
|
+
const values_1 = require("convex/values");
|
|
6
|
+
const workspace_1 = require("../lib/workspace");
|
|
7
|
+
const _helpers_1 = require("./_helpers");
|
|
8
|
+
// =============================================================================
|
|
9
|
+
// ONBOARDING: WORKSPACE SETUP
|
|
10
|
+
// =============================================================================
|
|
11
|
+
/**
|
|
12
|
+
* Save workspace description, ICP tags, and pipeline stages in one shot.
|
|
13
|
+
* Called at the end of the onboarding flow.
|
|
14
|
+
*/
|
|
15
|
+
exports.setupWorkspace = (0, server_1.mutation)({
|
|
16
|
+
args: {
|
|
17
|
+
workspaceId: values_1.v.id('workspaces'),
|
|
18
|
+
description: values_1.v.optional(values_1.v.string()),
|
|
19
|
+
icpTags: values_1.v.optional(values_1.v.array(values_1.v.string())),
|
|
20
|
+
pipelineStages: values_1.v.optional(values_1.v.array(values_1.v.object({
|
|
21
|
+
id: values_1.v.string(),
|
|
22
|
+
name: values_1.v.string(),
|
|
23
|
+
order: values_1.v.number(),
|
|
24
|
+
}))),
|
|
25
|
+
},
|
|
26
|
+
handler: async (ctx, args) => {
|
|
27
|
+
const { canWrite } = await (0, workspace_1.validateWorkspaceAccess)(ctx, args.workspaceId);
|
|
28
|
+
if (!canWrite)
|
|
29
|
+
throw new Error('Insufficient permissions');
|
|
30
|
+
const workspace = await ctx.db.get(args.workspaceId);
|
|
31
|
+
if (!workspace)
|
|
32
|
+
throw new Error('Workspace not found');
|
|
33
|
+
const patch = {
|
|
34
|
+
updatedAt: Date.now(),
|
|
35
|
+
};
|
|
36
|
+
if (args.description !== undefined) {
|
|
37
|
+
patch.description = args.description;
|
|
38
|
+
}
|
|
39
|
+
if (args.pipelineStages !== undefined) {
|
|
40
|
+
patch.pipelineStages = args.pipelineStages;
|
|
41
|
+
}
|
|
42
|
+
// Store ICP tags in workspace settings
|
|
43
|
+
if (args.icpTags !== undefined) {
|
|
44
|
+
const existingSettings = workspace.settings ?? {};
|
|
45
|
+
patch.settings = {
|
|
46
|
+
...existingSettings,
|
|
47
|
+
icpTags: args.icpTags,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
await ctx.db.patch(args.workspaceId, patch);
|
|
51
|
+
return args.workspaceId;
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
/**
|
|
55
|
+
* Batch import contacts from onboarding.
|
|
56
|
+
* Parses pre-processed contact data, deduplicates by email,
|
|
57
|
+
* and creates contacts + companies in batch.
|
|
58
|
+
*/
|
|
59
|
+
exports.importContacts = (0, server_1.mutation)({
|
|
60
|
+
args: {
|
|
61
|
+
workspaceId: values_1.v.id('workspaces'),
|
|
62
|
+
contacts: values_1.v.array(values_1.v.object({
|
|
63
|
+
firstName: values_1.v.string(),
|
|
64
|
+
lastName: values_1.v.string(),
|
|
65
|
+
email: values_1.v.optional(values_1.v.string()),
|
|
66
|
+
companyDomain: values_1.v.optional(values_1.v.string()),
|
|
67
|
+
})),
|
|
68
|
+
},
|
|
69
|
+
handler: async (ctx, args) => {
|
|
70
|
+
const { clerkId, canWrite } = await (0, workspace_1.validateWorkspaceAccess)(ctx, args.workspaceId);
|
|
71
|
+
if (!canWrite)
|
|
72
|
+
throw new Error('Insufficient permissions');
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
// Fetch existing contacts for deduplication
|
|
75
|
+
const existingContacts = await ctx.db
|
|
76
|
+
.query('contacts')
|
|
77
|
+
.withIndex('by_workspace', (q) => q.eq('workspaceId', args.workspaceId))
|
|
78
|
+
.take(5000);
|
|
79
|
+
const existingEmails = new Set(existingContacts
|
|
80
|
+
.filter((c) => c.email && c.isArchived !== true)
|
|
81
|
+
.map((c) => c.email.toLowerCase()));
|
|
82
|
+
// Track companies by domain to avoid duplicates
|
|
83
|
+
const companyMap = new Map(); // domain -> companyId
|
|
84
|
+
// Fetch existing companies
|
|
85
|
+
const existingCompanies = await ctx.db
|
|
86
|
+
.query('companies')
|
|
87
|
+
.withIndex('by_workspace', (q) => q.eq('workspaceId', args.workspaceId))
|
|
88
|
+
.take(5000);
|
|
89
|
+
for (const company of existingCompanies) {
|
|
90
|
+
if (company.domain && company.isArchived !== true) {
|
|
91
|
+
companyMap.set(company.domain.toLowerCase(), company._id);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
let created = 0;
|
|
95
|
+
let skipped = 0;
|
|
96
|
+
for (const contact of args.contacts) {
|
|
97
|
+
// Deduplicate by email
|
|
98
|
+
if (contact.email && existingEmails.has(contact.email.toLowerCase())) {
|
|
99
|
+
skipped++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// Create company if domain provided and not yet created
|
|
103
|
+
let companyId;
|
|
104
|
+
if (contact.companyDomain) {
|
|
105
|
+
const domain = contact.companyDomain.toLowerCase();
|
|
106
|
+
const existingCompanyId = companyMap.get(domain);
|
|
107
|
+
if (existingCompanyId) {
|
|
108
|
+
companyId = existingCompanyId;
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
// Create a company from the domain
|
|
112
|
+
const companyName = domain.split('.')[0];
|
|
113
|
+
const capitalizedName = companyName.charAt(0).toUpperCase() + companyName.slice(1);
|
|
114
|
+
const searchableText = (0, _helpers_1.buildCompanySearchText)({
|
|
115
|
+
name: capitalizedName,
|
|
116
|
+
domain,
|
|
117
|
+
});
|
|
118
|
+
const newCompanyId = await ctx.db.insert('companies', {
|
|
119
|
+
name: capitalizedName,
|
|
120
|
+
domain,
|
|
121
|
+
workspaceId: args.workspaceId,
|
|
122
|
+
ownerId: clerkId,
|
|
123
|
+
searchableText,
|
|
124
|
+
createdAt: now,
|
|
125
|
+
updatedAt: now,
|
|
126
|
+
});
|
|
127
|
+
companyMap.set(domain, newCompanyId);
|
|
128
|
+
companyId = newCompanyId;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Resolve company name for search text
|
|
132
|
+
let companyName;
|
|
133
|
+
if (companyId) {
|
|
134
|
+
const company = await ctx.db.get(companyId);
|
|
135
|
+
companyName = company?.name;
|
|
136
|
+
}
|
|
137
|
+
const searchableText = (0, _helpers_1.buildContactSearchText)({
|
|
138
|
+
firstName: contact.firstName,
|
|
139
|
+
lastName: contact.lastName,
|
|
140
|
+
email: contact.email,
|
|
141
|
+
}, companyName);
|
|
142
|
+
await ctx.db.insert('contacts', {
|
|
143
|
+
firstName: contact.firstName,
|
|
144
|
+
lastName: contact.lastName,
|
|
145
|
+
email: contact.email,
|
|
146
|
+
type: 'lead',
|
|
147
|
+
companyId,
|
|
148
|
+
workspaceId: args.workspaceId,
|
|
149
|
+
ownerId: clerkId,
|
|
150
|
+
searchableText,
|
|
151
|
+
createdAt: now,
|
|
152
|
+
updatedAt: now,
|
|
153
|
+
});
|
|
154
|
+
if (contact.email) {
|
|
155
|
+
existingEmails.add(contact.email.toLowerCase());
|
|
156
|
+
}
|
|
157
|
+
created++;
|
|
158
|
+
}
|
|
159
|
+
return { created, skipped, total: args.contacts.length };
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
/**
|
|
163
|
+
* Check if workspace needs onboarding (has zero contacts).
|
|
164
|
+
*/
|
|
165
|
+
exports.needsOnboarding = (0, server_1.query)({
|
|
166
|
+
args: {
|
|
167
|
+
workspaceId: values_1.v.id('workspaces'),
|
|
168
|
+
},
|
|
169
|
+
handler: async (ctx, args) => {
|
|
170
|
+
await (0, workspace_1.validateWorkspaceAccess)(ctx, args.workspaceId);
|
|
171
|
+
const contacts = await ctx.db
|
|
172
|
+
.query('contacts')
|
|
173
|
+
.withIndex('by_workspace', (q) => q.eq('workspaceId', args.workspaceId))
|
|
174
|
+
.take(1);
|
|
175
|
+
const activeContacts = contacts.filter((c) => c.isArchived !== true);
|
|
176
|
+
// Also check if workspace has pipeline stages configured
|
|
177
|
+
const workspace = await ctx.db.get(args.workspaceId);
|
|
178
|
+
const hasPipeline = workspace?.pipelineStages && workspace.pipelineStages.length > 0;
|
|
179
|
+
return {
|
|
180
|
+
needsOnboarding: activeContacts.length === 0 && !hasPipeline,
|
|
181
|
+
hasContacts: activeContacts.length > 0,
|
|
182
|
+
hasPipeline: !!hasPipeline,
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.conversionRate = exports.pipelineStats = void 0;
|
|
4
|
+
const server_1 = require("../_generated/server");
|
|
5
|
+
const values_1 = require("convex/values");
|
|
6
|
+
const workspace_1 = require("../lib/workspace");
|
|
7
|
+
// =============================================================================
|
|
8
|
+
// CRM STATS & ANALYTICS
|
|
9
|
+
// =============================================================================
|
|
10
|
+
exports.pipelineStats = (0, server_1.query)({
|
|
11
|
+
args: {
|
|
12
|
+
workspaceId: values_1.v.id('workspaces'),
|
|
13
|
+
},
|
|
14
|
+
handler: async (ctx, args) => {
|
|
15
|
+
await (0, workspace_1.validateWorkspaceAccess)(ctx, args.workspaceId);
|
|
16
|
+
const deals = await ctx.db
|
|
17
|
+
.query('deals')
|
|
18
|
+
.withIndex('by_workspace', (q) => q.eq('workspaceId', args.workspaceId))
|
|
19
|
+
.take(1000);
|
|
20
|
+
const activeDeals = deals.filter((d) => d.isArchived !== true);
|
|
21
|
+
// Group by stage
|
|
22
|
+
const stageMap = new Map();
|
|
23
|
+
for (const deal of activeDeals) {
|
|
24
|
+
const existing = stageMap.get(deal.stage) ?? {
|
|
25
|
+
count: 0,
|
|
26
|
+
totalValue: 0,
|
|
27
|
+
};
|
|
28
|
+
existing.count += 1;
|
|
29
|
+
existing.totalValue += deal.value ?? 0;
|
|
30
|
+
stageMap.set(deal.stage, existing);
|
|
31
|
+
}
|
|
32
|
+
const stages = Array.from(stageMap.entries()).map(([stage, data]) => ({
|
|
33
|
+
stage,
|
|
34
|
+
count: data.count,
|
|
35
|
+
totalValue: data.totalValue,
|
|
36
|
+
avgDealSize: data.count > 0 ? data.totalValue / data.count : 0,
|
|
37
|
+
}));
|
|
38
|
+
return {
|
|
39
|
+
stages,
|
|
40
|
+
totalDeals: activeDeals.length,
|
|
41
|
+
totalValue: activeDeals.reduce((sum, d) => sum + (d.value ?? 0), 0),
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
exports.conversionRate = (0, server_1.query)({
|
|
46
|
+
args: {
|
|
47
|
+
workspaceId: values_1.v.id('workspaces'),
|
|
48
|
+
startDate: values_1.v.optional(values_1.v.number()),
|
|
49
|
+
endDate: values_1.v.optional(values_1.v.number()),
|
|
50
|
+
},
|
|
51
|
+
handler: async (ctx, args) => {
|
|
52
|
+
await (0, workspace_1.validateWorkspaceAccess)(ctx, args.workspaceId);
|
|
53
|
+
const deals = await ctx.db
|
|
54
|
+
.query('deals')
|
|
55
|
+
.withIndex('by_workspace', (q) => q.eq('workspaceId', args.workspaceId))
|
|
56
|
+
.take(1000);
|
|
57
|
+
let filtered = deals.filter((d) => d.isArchived !== true);
|
|
58
|
+
// Apply date filters based on actualCloseDate
|
|
59
|
+
if (args.startDate) {
|
|
60
|
+
filtered = filtered.filter((d) => d.actualCloseDate && d.actualCloseDate >= args.startDate);
|
|
61
|
+
}
|
|
62
|
+
if (args.endDate) {
|
|
63
|
+
filtered = filtered.filter((d) => d.actualCloseDate && d.actualCloseDate <= args.endDate);
|
|
64
|
+
}
|
|
65
|
+
// Closed deals = those with an actualCloseDate
|
|
66
|
+
const closedDeals = filtered.filter((d) => d.actualCloseDate);
|
|
67
|
+
const wonDeals = closedDeals.filter((d) => !d.stage.toLowerCase().includes('lost'));
|
|
68
|
+
const rate = closedDeals.length > 0 ? wonDeals.length / closedDeals.length : 0;
|
|
69
|
+
return {
|
|
70
|
+
wonCount: wonDeals.length,
|
|
71
|
+
closedCount: closedDeals.length,
|
|
72
|
+
conversionRate: Math.round(rate * 10000) / 100, // percentage with 2 decimals
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.overdue = exports.complete = exports.list = exports.create = void 0;
|
|
4
|
+
const server_1 = require("../_generated/server");
|
|
5
|
+
const values_1 = require("convex/values");
|
|
6
|
+
const workspace_1 = require("../lib/workspace");
|
|
7
|
+
// =============================================================================
|
|
8
|
+
// TASKS (activities where type='task')
|
|
9
|
+
// OQ-4: tasks are part of the activities audit trail — NO delete mutation.
|
|
10
|
+
// =============================================================================
|
|
11
|
+
exports.create = (0, server_1.mutation)({
|
|
12
|
+
args: {
|
|
13
|
+
workspaceId: values_1.v.id('workspaces'),
|
|
14
|
+
subject: values_1.v.string(),
|
|
15
|
+
contactId: values_1.v.optional(values_1.v.id('contacts')),
|
|
16
|
+
dealId: values_1.v.optional(values_1.v.id('deals')),
|
|
17
|
+
companyId: values_1.v.optional(values_1.v.id('companies')),
|
|
18
|
+
dueAt: values_1.v.number(),
|
|
19
|
+
description: values_1.v.optional(values_1.v.string()),
|
|
20
|
+
},
|
|
21
|
+
returns: values_1.v.id('activities'),
|
|
22
|
+
handler: async (ctx, args) => {
|
|
23
|
+
const { clerkId, canWrite } = await (0, workspace_1.validateWorkspaceAccess)(ctx, args.workspaceId);
|
|
24
|
+
if (!canWrite)
|
|
25
|
+
throw new Error('Insufficient permissions');
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
const activityId = await ctx.db.insert('activities', {
|
|
28
|
+
type: 'task',
|
|
29
|
+
subject: args.subject,
|
|
30
|
+
description: args.description,
|
|
31
|
+
contactId: args.contactId,
|
|
32
|
+
dealId: args.dealId,
|
|
33
|
+
companyId: args.companyId,
|
|
34
|
+
dueAt: args.dueAt,
|
|
35
|
+
workspaceId: args.workspaceId,
|
|
36
|
+
ownerId: clerkId,
|
|
37
|
+
occurredAt: now,
|
|
38
|
+
createdAt: now,
|
|
39
|
+
updatedAt: now,
|
|
40
|
+
});
|
|
41
|
+
return activityId;
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
exports.list = (0, server_1.query)({
|
|
45
|
+
args: {
|
|
46
|
+
workspaceId: values_1.v.id('workspaces'),
|
|
47
|
+
ownerId: values_1.v.optional(values_1.v.string()),
|
|
48
|
+
isCompleted: values_1.v.optional(values_1.v.boolean()),
|
|
49
|
+
dueBefore: values_1.v.optional(values_1.v.number()),
|
|
50
|
+
},
|
|
51
|
+
returns: values_1.v.array(values_1.v.any()),
|
|
52
|
+
handler: async (ctx, args) => {
|
|
53
|
+
await (0, workspace_1.validateWorkspaceAccess)(ctx, args.workspaceId);
|
|
54
|
+
const activities = await ctx.db
|
|
55
|
+
.query('activities')
|
|
56
|
+
.withIndex('by_workspace_type', (q) => q.eq('workspaceId', args.workspaceId).eq('type', 'task'))
|
|
57
|
+
.order('desc')
|
|
58
|
+
.take(1000);
|
|
59
|
+
let filtered = activities;
|
|
60
|
+
if (args.ownerId !== undefined) {
|
|
61
|
+
filtered = filtered.filter((a) => a.ownerId === args.ownerId);
|
|
62
|
+
}
|
|
63
|
+
if (args.isCompleted !== undefined) {
|
|
64
|
+
filtered = filtered.filter((a) => args.isCompleted ? a.completedAt !== undefined : a.completedAt === undefined);
|
|
65
|
+
}
|
|
66
|
+
if (args.dueBefore !== undefined) {
|
|
67
|
+
filtered = filtered.filter((a) => a.dueAt !== undefined && a.dueAt < args.dueBefore);
|
|
68
|
+
}
|
|
69
|
+
return filtered;
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
exports.complete = (0, server_1.mutation)({
|
|
73
|
+
args: {
|
|
74
|
+
activityId: values_1.v.id('activities'),
|
|
75
|
+
},
|
|
76
|
+
returns: values_1.v.id('activities'),
|
|
77
|
+
handler: async (ctx, args) => {
|
|
78
|
+
const activity = await ctx.db.get(args.activityId);
|
|
79
|
+
if (!activity)
|
|
80
|
+
throw new Error('Task not found');
|
|
81
|
+
if (activity.type !== 'task')
|
|
82
|
+
throw new Error('Activity is not a task');
|
|
83
|
+
const { canWrite } = await (0, workspace_1.validateWorkspaceAccess)(ctx, activity.workspaceId);
|
|
84
|
+
if (!canWrite)
|
|
85
|
+
throw new Error('Insufficient permissions');
|
|
86
|
+
await ctx.db.patch(args.activityId, {
|
|
87
|
+
completedAt: Date.now(),
|
|
88
|
+
updatedAt: Date.now(),
|
|
89
|
+
});
|
|
90
|
+
return args.activityId;
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
exports.overdue = (0, server_1.query)({
|
|
94
|
+
args: {
|
|
95
|
+
workspaceId: values_1.v.id('workspaces'),
|
|
96
|
+
},
|
|
97
|
+
returns: values_1.v.array(values_1.v.any()),
|
|
98
|
+
handler: async (ctx, args) => {
|
|
99
|
+
await (0, workspace_1.validateWorkspaceAccess)(ctx, args.workspaceId);
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
const tasks = await ctx.db
|
|
102
|
+
.query('activities')
|
|
103
|
+
.withIndex('by_workspace_type', (q) => q.eq('workspaceId', args.workspaceId).eq('type', 'task'))
|
|
104
|
+
.take(1000);
|
|
105
|
+
return tasks.filter((t) => t.completedAt === undefined &&
|
|
106
|
+
t.dueAt !== undefined &&
|
|
107
|
+
t.dueAt < now);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Convex Cron Jobs
|
|
4
|
+
*
|
|
5
|
+
* Scheduled background tasks. Each cron calls an internalAction.
|
|
6
|
+
*
|
|
7
|
+
* Note: Calendar sync requires workspace-specific entityId.
|
|
8
|
+
* The cron triggers a dispatcher that iterates over workspaces
|
|
9
|
+
* with calendar sync enabled.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
const server_1 = require("convex/server");
|
|
13
|
+
const api_1 = require("./_generated/api");
|
|
14
|
+
const crons = (0, server_1.cronJobs)();
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// CALENDAR SYNC — every 5 minutes
|
|
17
|
+
// Dispatches to all workspaces with Google Calendar connected.
|
|
18
|
+
// =============================================================================
|
|
19
|
+
crons.interval('calendar-sync-dispatch', { minutes: 5 }, api_1.internal.crm.calendarCronDispatch.dispatchCalendarSync);
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// EMAIL SYNC — every 5 minutes
|
|
22
|
+
// Dispatches to all workspaces with Gmail connected (emailSyncEnabled setting).
|
|
23
|
+
// =============================================================================
|
|
24
|
+
crons.interval('email-sync-dispatch', { minutes: 5 }, api_1.internal.crm.emailCronDispatch.dispatchEmailSync);
|
|
25
|
+
exports.default = crons;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.remove = exports.disconnect = exports.upsert = exports.isConnected = exports.listActive = exports.list = void 0;
|
|
4
|
+
const server_1 = require("./_generated/server");
|
|
5
|
+
const values_1 = require("convex/values");
|
|
6
|
+
/**
|
|
7
|
+
* List all integrations for the current workspace
|
|
8
|
+
* Uses user.activeWorkspaceId from Convex
|
|
9
|
+
*/
|
|
10
|
+
exports.list = (0, server_1.query)({
|
|
11
|
+
args: {},
|
|
12
|
+
handler: async (ctx) => {
|
|
13
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
14
|
+
if (!identity)
|
|
15
|
+
return [];
|
|
16
|
+
const user = await ctx.db
|
|
17
|
+
.query('users')
|
|
18
|
+
.withIndex('by_clerk_id', (q) => q.eq('clerkId', identity.subject))
|
|
19
|
+
.first();
|
|
20
|
+
const workspaceId = user?.activeWorkspaceId;
|
|
21
|
+
if (!workspaceId)
|
|
22
|
+
return [];
|
|
23
|
+
return await ctx.db
|
|
24
|
+
.query('integrations')
|
|
25
|
+
.withIndex('by_workspace', (q) => q.eq('workspaceId', workspaceId))
|
|
26
|
+
.collect();
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
/**
|
|
30
|
+
* Get active integrations for the current workspace
|
|
31
|
+
* Used by chat route to know which tools to initialize
|
|
32
|
+
*/
|
|
33
|
+
exports.listActive = (0, server_1.query)({
|
|
34
|
+
args: {},
|
|
35
|
+
handler: async (ctx) => {
|
|
36
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
37
|
+
if (!identity)
|
|
38
|
+
return [];
|
|
39
|
+
const user = await ctx.db
|
|
40
|
+
.query('users')
|
|
41
|
+
.withIndex('by_clerk_id', (q) => q.eq('clerkId', identity.subject))
|
|
42
|
+
.first();
|
|
43
|
+
const workspaceId = user?.activeWorkspaceId;
|
|
44
|
+
if (!workspaceId)
|
|
45
|
+
return [];
|
|
46
|
+
const integrations = await ctx.db
|
|
47
|
+
.query('integrations')
|
|
48
|
+
.withIndex('by_workspace_status', (q) => q.eq('workspaceId', workspaceId).eq('status', 'active'))
|
|
49
|
+
.collect();
|
|
50
|
+
return integrations.map((i) => ({
|
|
51
|
+
toolkitSlug: i.toolkitSlug,
|
|
52
|
+
connectionId: i.connectionId,
|
|
53
|
+
}));
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
/**
|
|
57
|
+
* Check if a specific toolkit is connected
|
|
58
|
+
*/
|
|
59
|
+
exports.isConnected = (0, server_1.query)({
|
|
60
|
+
args: { toolkitSlug: values_1.v.string() },
|
|
61
|
+
handler: async (ctx, { toolkitSlug }) => {
|
|
62
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
63
|
+
if (!identity)
|
|
64
|
+
return false;
|
|
65
|
+
const user = await ctx.db
|
|
66
|
+
.query('users')
|
|
67
|
+
.withIndex('by_clerk_id', (q) => q.eq('clerkId', identity.subject))
|
|
68
|
+
.first();
|
|
69
|
+
const workspaceId = user?.activeWorkspaceId;
|
|
70
|
+
if (!workspaceId)
|
|
71
|
+
return false;
|
|
72
|
+
const integration = await ctx.db
|
|
73
|
+
.query('integrations')
|
|
74
|
+
.withIndex('by_workspace_toolkit', (q) => q
|
|
75
|
+
.eq('workspaceId', workspaceId)
|
|
76
|
+
.eq('toolkitSlug', toolkitSlug.toUpperCase()))
|
|
77
|
+
.first();
|
|
78
|
+
return integration?.status === 'active';
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
/**
|
|
82
|
+
* Add or update an integration after OAuth success
|
|
83
|
+
*/
|
|
84
|
+
exports.upsert = (0, server_1.mutation)({
|
|
85
|
+
args: {
|
|
86
|
+
toolkitSlug: values_1.v.string(),
|
|
87
|
+
connectionId: values_1.v.string(),
|
|
88
|
+
},
|
|
89
|
+
handler: async (ctx, { toolkitSlug, connectionId }) => {
|
|
90
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
91
|
+
if (!identity)
|
|
92
|
+
throw new Error('Not authenticated');
|
|
93
|
+
const user = await ctx.db
|
|
94
|
+
.query('users')
|
|
95
|
+
.withIndex('by_clerk_id', (q) => q.eq('clerkId', identity.subject))
|
|
96
|
+
.first();
|
|
97
|
+
if (!user)
|
|
98
|
+
throw new Error('User not found');
|
|
99
|
+
const workspaceId = user.activeWorkspaceId;
|
|
100
|
+
if (!workspaceId)
|
|
101
|
+
throw new Error('No active workspace');
|
|
102
|
+
const normalizedSlug = toolkitSlug.toUpperCase();
|
|
103
|
+
// Check for existing integration
|
|
104
|
+
const existing = await ctx.db
|
|
105
|
+
.query('integrations')
|
|
106
|
+
.withIndex('by_workspace_toolkit', (q) => q.eq('workspaceId', workspaceId).eq('toolkitSlug', normalizedSlug))
|
|
107
|
+
.first();
|
|
108
|
+
if (existing) {
|
|
109
|
+
// Update existing
|
|
110
|
+
await ctx.db.patch(existing._id, {
|
|
111
|
+
connectionId,
|
|
112
|
+
status: 'active',
|
|
113
|
+
connectedAt: Date.now(),
|
|
114
|
+
connectedBy: user._id,
|
|
115
|
+
});
|
|
116
|
+
return existing._id;
|
|
117
|
+
}
|
|
118
|
+
// Create new
|
|
119
|
+
return await ctx.db.insert('integrations', {
|
|
120
|
+
workspaceId,
|
|
121
|
+
toolkitSlug: normalizedSlug,
|
|
122
|
+
connectionId,
|
|
123
|
+
status: 'active',
|
|
124
|
+
connectedAt: Date.now(),
|
|
125
|
+
connectedBy: user._id,
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
/**
|
|
130
|
+
* Disconnect an integration (soft delete)
|
|
131
|
+
*/
|
|
132
|
+
exports.disconnect = (0, server_1.mutation)({
|
|
133
|
+
args: { toolkitSlug: values_1.v.string() },
|
|
134
|
+
handler: async (ctx, { toolkitSlug }) => {
|
|
135
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
136
|
+
if (!identity)
|
|
137
|
+
throw new Error('Not authenticated');
|
|
138
|
+
const user = await ctx.db
|
|
139
|
+
.query('users')
|
|
140
|
+
.withIndex('by_clerk_id', (q) => q.eq('clerkId', identity.subject))
|
|
141
|
+
.first();
|
|
142
|
+
const workspaceId = user?.activeWorkspaceId;
|
|
143
|
+
if (!workspaceId)
|
|
144
|
+
throw new Error('No active workspace');
|
|
145
|
+
const normalizedSlug = toolkitSlug.toUpperCase();
|
|
146
|
+
const integration = await ctx.db
|
|
147
|
+
.query('integrations')
|
|
148
|
+
.withIndex('by_workspace_toolkit', (q) => q.eq('workspaceId', workspaceId).eq('toolkitSlug', normalizedSlug))
|
|
149
|
+
.first();
|
|
150
|
+
if (!integration) {
|
|
151
|
+
throw new Error('Integration not found');
|
|
152
|
+
}
|
|
153
|
+
// Soft delete - mark as disconnected
|
|
154
|
+
await ctx.db.patch(integration._id, {
|
|
155
|
+
status: 'disconnected',
|
|
156
|
+
});
|
|
157
|
+
return { success: true };
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
/**
|
|
161
|
+
* Remove an integration completely
|
|
162
|
+
*/
|
|
163
|
+
exports.remove = (0, server_1.mutation)({
|
|
164
|
+
args: { id: values_1.v.id('integrations') },
|
|
165
|
+
handler: async (ctx, { id }) => {
|
|
166
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
167
|
+
if (!identity)
|
|
168
|
+
throw new Error('Not authenticated');
|
|
169
|
+
const user = await ctx.db
|
|
170
|
+
.query('users')
|
|
171
|
+
.withIndex('by_clerk_id', (q) => q.eq('clerkId', identity.subject))
|
|
172
|
+
.first();
|
|
173
|
+
const workspaceId = user?.activeWorkspaceId;
|
|
174
|
+
if (!workspaceId)
|
|
175
|
+
throw new Error('No active workspace');
|
|
176
|
+
const integration = await ctx.db.get(id);
|
|
177
|
+
if (!integration || integration.workspaceId !== workspaceId) {
|
|
178
|
+
throw new Error('Integration not found');
|
|
179
|
+
}
|
|
180
|
+
await ctx.db.delete(id);
|
|
181
|
+
return { success: true };
|
|
182
|
+
},
|
|
183
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* convex/lib/auditLog.ts
|
|
4
|
+
*
|
|
5
|
+
* withAuditLog<T> — wraps every CRM mutation to capture a compliance trace.
|
|
6
|
+
* Captures before/after field diffs, actorId, actorType, timestamp.
|
|
7
|
+
* Inserts into audit_log table. 7-year retention. OQ-1/OQ-4 doctrine.
|
|
8
|
+
*
|
|
9
|
+
* Ref: vantage-crm-spec-2026-05-20.md §5 D-006
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.diffRecords = diffRecords;
|
|
13
|
+
exports.withAuditLog = withAuditLog;
|
|
14
|
+
exports.withAuditLogDiff = withAuditLogDiff;
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Core helper — diff two plain objects to produce fieldChanges array.
|
|
17
|
+
// Only captures top-level scalar fields (no deep nesting per audit_log schema).
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
function diffRecords(before, after) {
|
|
20
|
+
const changes = [];
|
|
21
|
+
// Skip system fields that are always updated
|
|
22
|
+
const skipFields = new Set(['updatedAt', '_creationTime']);
|
|
23
|
+
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
24
|
+
for (const key of allKeys) {
|
|
25
|
+
if (skipFields.has(key))
|
|
26
|
+
continue;
|
|
27
|
+
const oldRaw = before[key];
|
|
28
|
+
const newRaw = after[key];
|
|
29
|
+
// Only capture top-level scalars
|
|
30
|
+
if ((typeof oldRaw === 'string' ||
|
|
31
|
+
typeof oldRaw === 'number' ||
|
|
32
|
+
typeof oldRaw === 'boolean' ||
|
|
33
|
+
oldRaw === null ||
|
|
34
|
+
oldRaw === undefined) &&
|
|
35
|
+
(typeof newRaw === 'string' ||
|
|
36
|
+
typeof newRaw === 'number' ||
|
|
37
|
+
typeof newRaw === 'boolean' ||
|
|
38
|
+
newRaw === null ||
|
|
39
|
+
newRaw === undefined)) {
|
|
40
|
+
const oldVal = oldRaw === undefined ? null : oldRaw;
|
|
41
|
+
const newVal = newRaw === undefined ? null : newRaw;
|
|
42
|
+
if (oldVal !== newVal) {
|
|
43
|
+
changes.push({ fieldName: key, oldValue: oldVal, newValue: newVal });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return changes;
|
|
48
|
+
}
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// withAuditLog<T> — the primary wrapper for all CRM mutations.
|
|
51
|
+
//
|
|
52
|
+
// Usage:
|
|
53
|
+
// return withAuditLog(ctx, {
|
|
54
|
+
// workspaceId, actorId, actorType, entityType, entityId: contactId, action: 'create',
|
|
55
|
+
// }, async () => {
|
|
56
|
+
// return await ctx.db.insert('contacts', { ... });
|
|
57
|
+
// });
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
async function withAuditLog(ctx, auditCtx, fn) {
|
|
60
|
+
const result = await fn();
|
|
61
|
+
await ctx.db.insert('audit_log', {
|
|
62
|
+
workspaceId: auditCtx.workspaceId,
|
|
63
|
+
actorId: auditCtx.actorId,
|
|
64
|
+
actorType: auditCtx.actorType,
|
|
65
|
+
entityType: auditCtx.entityType,
|
|
66
|
+
entityId: auditCtx.entityId,
|
|
67
|
+
action: auditCtx.action,
|
|
68
|
+
fieldChanges: auditCtx.fieldChanges,
|
|
69
|
+
metadata: auditCtx.metadata,
|
|
70
|
+
ip: auditCtx.ip,
|
|
71
|
+
userAgent: auditCtx.userAgent,
|
|
72
|
+
timestamp: Date.now(),
|
|
73
|
+
});
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// withAuditLogDiff — captures before/after diff automatically.
|
|
78
|
+
// Use for update mutations where you have the record before the patch.
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
async function withAuditLogDiff(ctx, auditCtx, beforeRecord, fn) {
|
|
81
|
+
const result = await fn();
|
|
82
|
+
// Fetch record after mutation to compute diff
|
|
83
|
+
let afterRecord = {};
|
|
84
|
+
try {
|
|
85
|
+
// entityId is a Convex Id string — retrieve the updated record
|
|
86
|
+
const updated = await ctx.db.get(auditCtx.entityId);
|
|
87
|
+
if (updated) {
|
|
88
|
+
afterRecord = updated;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// If we can't fetch (e.g. deleted), leave afterRecord empty
|
|
93
|
+
}
|
|
94
|
+
const fieldChanges = diffRecords(beforeRecord, afterRecord);
|
|
95
|
+
await ctx.db.insert('audit_log', {
|
|
96
|
+
workspaceId: auditCtx.workspaceId,
|
|
97
|
+
actorId: auditCtx.actorId,
|
|
98
|
+
actorType: auditCtx.actorType,
|
|
99
|
+
entityType: auditCtx.entityType,
|
|
100
|
+
entityId: auditCtx.entityId,
|
|
101
|
+
action: auditCtx.action,
|
|
102
|
+
fieldChanges: fieldChanges.length > 0 ? fieldChanges : undefined,
|
|
103
|
+
metadata: auditCtx.metadata,
|
|
104
|
+
ip: auditCtx.ip,
|
|
105
|
+
userAgent: auditCtx.userAgent,
|
|
106
|
+
timestamp: Date.now(),
|
|
107
|
+
});
|
|
108
|
+
return result;
|
|
109
|
+
}
|