@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,198 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getDailyBrief = 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
|
+
// DAILY BRIEFING
|
|
9
|
+
// =============================================================================
|
|
10
|
+
/**
|
|
11
|
+
* Returns a daily briefing snapshot for the CRM workspace.
|
|
12
|
+
* Each section includes a count + top 5 items for display.
|
|
13
|
+
*/
|
|
14
|
+
exports.getDailyBrief = (0, server_1.query)({
|
|
15
|
+
args: {
|
|
16
|
+
workspaceId: values_1.v.id('workspaces'),
|
|
17
|
+
},
|
|
18
|
+
handler: async (ctx, args) => {
|
|
19
|
+
await (0, workspace_1.validateWorkspaceAccess)(ctx, args.workspaceId);
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
const startOfToday = new Date();
|
|
22
|
+
startOfToday.setHours(0, 0, 0, 0);
|
|
23
|
+
const todayStart = startOfToday.getTime();
|
|
24
|
+
const endOfToday = new Date();
|
|
25
|
+
endOfToday.setHours(23, 59, 59, 999);
|
|
26
|
+
const todayEnd = endOfToday.getTime();
|
|
27
|
+
// End of this week (Sunday)
|
|
28
|
+
const endOfWeek = new Date();
|
|
29
|
+
endOfWeek.setDate(endOfWeek.getDate() + (7 - endOfWeek.getDay()));
|
|
30
|
+
endOfWeek.setHours(23, 59, 59, 999);
|
|
31
|
+
const weekEnd = endOfWeek.getTime();
|
|
32
|
+
// 7 days ago for stale contacts
|
|
33
|
+
const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000;
|
|
34
|
+
// -----------------------------------------------------------------------
|
|
35
|
+
// 1. Deals closing this week
|
|
36
|
+
// -----------------------------------------------------------------------
|
|
37
|
+
const allDeals = await ctx.db
|
|
38
|
+
.query('deals')
|
|
39
|
+
.withIndex('by_workspace', (q) => q.eq('workspaceId', args.workspaceId))
|
|
40
|
+
.take(1000);
|
|
41
|
+
const activeDeals = allDeals.filter((d) => d.isArchived !== true &&
|
|
42
|
+
!d.stage.toLowerCase().includes('lost') &&
|
|
43
|
+
!d.stage.toLowerCase().includes('closed'));
|
|
44
|
+
const dealsClosingThisWeek = activeDeals
|
|
45
|
+
.filter((d) => d.expectedCloseDate !== undefined &&
|
|
46
|
+
d.expectedCloseDate >= todayStart &&
|
|
47
|
+
d.expectedCloseDate <= weekEnd)
|
|
48
|
+
.sort((a, b) => (a.expectedCloseDate ?? 0) - (b.expectedCloseDate ?? 0));
|
|
49
|
+
// Enrich with contact/company names
|
|
50
|
+
const enrichedDeals = await Promise.all(dealsClosingThisWeek.slice(0, 5).map(async (deal) => {
|
|
51
|
+
let contactName;
|
|
52
|
+
let companyName;
|
|
53
|
+
if (deal.contactId) {
|
|
54
|
+
const contact = await ctx.db.get(deal.contactId);
|
|
55
|
+
if (contact)
|
|
56
|
+
contactName = `${contact.firstName} ${contact.lastName}`;
|
|
57
|
+
}
|
|
58
|
+
if (deal.companyId) {
|
|
59
|
+
const company = await ctx.db.get(deal.companyId);
|
|
60
|
+
if (company)
|
|
61
|
+
companyName = company.name;
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
_id: deal._id,
|
|
65
|
+
title: deal.title,
|
|
66
|
+
stage: deal.stage,
|
|
67
|
+
value: deal.value,
|
|
68
|
+
currency: deal.currency,
|
|
69
|
+
expectedCloseDate: deal.expectedCloseDate,
|
|
70
|
+
probability: deal.probability,
|
|
71
|
+
contactName,
|
|
72
|
+
companyName,
|
|
73
|
+
};
|
|
74
|
+
}));
|
|
75
|
+
// -----------------------------------------------------------------------
|
|
76
|
+
// 2. Overdue tasks
|
|
77
|
+
// -----------------------------------------------------------------------
|
|
78
|
+
const allTasks = await ctx.db
|
|
79
|
+
.query('activities')
|
|
80
|
+
.withIndex('by_workspace_type', (q) => q.eq('workspaceId', args.workspaceId).eq('type', 'task'))
|
|
81
|
+
.take(1000);
|
|
82
|
+
const overdueTasks = allTasks
|
|
83
|
+
.filter((t) => t.completedAt === undefined &&
|
|
84
|
+
t.dueAt !== undefined &&
|
|
85
|
+
t.dueAt < now)
|
|
86
|
+
.sort((a, b) => (a.dueAt ?? 0) - (b.dueAt ?? 0));
|
|
87
|
+
const enrichedOverdueTasks = await Promise.all(overdueTasks.slice(0, 5).map(async (task) => {
|
|
88
|
+
let contactName;
|
|
89
|
+
let dealTitle;
|
|
90
|
+
if (task.contactId) {
|
|
91
|
+
const contact = await ctx.db.get(task.contactId);
|
|
92
|
+
if (contact)
|
|
93
|
+
contactName = `${contact.firstName} ${contact.lastName}`;
|
|
94
|
+
}
|
|
95
|
+
if (task.dealId) {
|
|
96
|
+
const deal = await ctx.db.get(task.dealId);
|
|
97
|
+
if (deal)
|
|
98
|
+
dealTitle = deal.title;
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
_id: task._id,
|
|
102
|
+
subject: task.subject,
|
|
103
|
+
dueAt: task.dueAt,
|
|
104
|
+
contactName,
|
|
105
|
+
dealTitle,
|
|
106
|
+
};
|
|
107
|
+
}));
|
|
108
|
+
// -----------------------------------------------------------------------
|
|
109
|
+
// 3. Stale contacts (not contacted in 7+ days)
|
|
110
|
+
// -----------------------------------------------------------------------
|
|
111
|
+
const allContacts = await ctx.db
|
|
112
|
+
.query('contacts')
|
|
113
|
+
.withIndex('by_workspace', (q) => q.eq('workspaceId', args.workspaceId))
|
|
114
|
+
.take(1000);
|
|
115
|
+
const staleContacts = allContacts
|
|
116
|
+
.filter((c) => c.isArchived !== true &&
|
|
117
|
+
(c.type === 'lead' || c.type === 'prospect') &&
|
|
118
|
+
(c.lastContactedAt === undefined || c.lastContactedAt < sevenDaysAgo))
|
|
119
|
+
.sort((a, b) => (a.lastContactedAt ?? 0) - (b.lastContactedAt ?? 0));
|
|
120
|
+
const enrichedStaleContacts = await Promise.all(staleContacts.slice(0, 5).map(async (contact) => {
|
|
121
|
+
let companyName;
|
|
122
|
+
if (contact.companyId) {
|
|
123
|
+
const company = await ctx.db.get(contact.companyId);
|
|
124
|
+
if (company)
|
|
125
|
+
companyName = company.name;
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
_id: contact._id,
|
|
129
|
+
firstName: contact.firstName,
|
|
130
|
+
lastName: contact.lastName,
|
|
131
|
+
email: contact.email,
|
|
132
|
+
type: contact.type,
|
|
133
|
+
lastContactedAt: contact.lastContactedAt,
|
|
134
|
+
companyName,
|
|
135
|
+
};
|
|
136
|
+
}));
|
|
137
|
+
// -----------------------------------------------------------------------
|
|
138
|
+
// 4. Today's meetings (from calendarEvents)
|
|
139
|
+
// -----------------------------------------------------------------------
|
|
140
|
+
const calendarEvents = await ctx.db
|
|
141
|
+
.query('calendarEvents')
|
|
142
|
+
.withIndex('by_workspace_start', (q) => q
|
|
143
|
+
.eq('workspaceId', args.workspaceId)
|
|
144
|
+
.gte('startTime', todayStart)
|
|
145
|
+
.lte('startTime', todayEnd))
|
|
146
|
+
.take(50);
|
|
147
|
+
const todaysMeetings = calendarEvents
|
|
148
|
+
.sort((a, b) => a.startTime - b.startTime)
|
|
149
|
+
.slice(0, 5)
|
|
150
|
+
.map((event) => ({
|
|
151
|
+
_id: event._id,
|
|
152
|
+
title: event.title,
|
|
153
|
+
startTime: event.startTime,
|
|
154
|
+
endTime: event.endTime,
|
|
155
|
+
description: event.description,
|
|
156
|
+
attendeeCount: event.attendees?.length ?? 0,
|
|
157
|
+
}));
|
|
158
|
+
// -----------------------------------------------------------------------
|
|
159
|
+
// 5. Pipeline stats
|
|
160
|
+
// -----------------------------------------------------------------------
|
|
161
|
+
const stageMap = new Map();
|
|
162
|
+
for (const deal of activeDeals) {
|
|
163
|
+
const existing = stageMap.get(deal.stage) ?? { count: 0, totalValue: 0 };
|
|
164
|
+
existing.count += 1;
|
|
165
|
+
existing.totalValue += deal.value ?? 0;
|
|
166
|
+
stageMap.set(deal.stage, existing);
|
|
167
|
+
}
|
|
168
|
+
const pipelineStats = {
|
|
169
|
+
totalDeals: activeDeals.length,
|
|
170
|
+
totalValue: activeDeals.reduce((sum, d) => sum + (d.value ?? 0), 0),
|
|
171
|
+
weightedValue: activeDeals.reduce((sum, d) => sum + ((d.value ?? 0) * (d.probability ?? 0)) / 100, 0),
|
|
172
|
+
byStage: Array.from(stageMap.entries()).map(([stage, data]) => ({
|
|
173
|
+
stage,
|
|
174
|
+
count: data.count,
|
|
175
|
+
totalValue: data.totalValue,
|
|
176
|
+
})),
|
|
177
|
+
};
|
|
178
|
+
return {
|
|
179
|
+
dealsClosingThisWeek: {
|
|
180
|
+
count: dealsClosingThisWeek.length,
|
|
181
|
+
items: enrichedDeals,
|
|
182
|
+
},
|
|
183
|
+
overdueTasks: {
|
|
184
|
+
count: overdueTasks.length,
|
|
185
|
+
items: enrichedOverdueTasks,
|
|
186
|
+
},
|
|
187
|
+
staleContacts: {
|
|
188
|
+
count: staleContacts.length,
|
|
189
|
+
items: enrichedStaleContacts,
|
|
190
|
+
},
|
|
191
|
+
todaysMeetings: {
|
|
192
|
+
count: calendarEvents.length,
|
|
193
|
+
items: todaysMeetings,
|
|
194
|
+
},
|
|
195
|
+
pipelineStats,
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Calendar Cron — Action that syncs Google Calendar events via Composio.
|
|
4
|
+
*
|
|
5
|
+
* Runs every 5 minutes via Convex cron scheduler.
|
|
6
|
+
* Flow: fetch events from Composio → transform → upsert via calendarSync.syncEvents
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.triggerSync = exports.syncCalendarEvents = void 0;
|
|
10
|
+
const server_1 = require("../_generated/server");
|
|
11
|
+
const api_1 = require("../_generated/api");
|
|
12
|
+
const values_1 = require("convex/values");
|
|
13
|
+
// =============================================================================
|
|
14
|
+
// SYNC CALENDAR EVENTS — internal action called by cron
|
|
15
|
+
// =============================================================================
|
|
16
|
+
exports.syncCalendarEvents = (0, server_1.internalAction)({
|
|
17
|
+
args: {
|
|
18
|
+
workspaceId: values_1.v.id('workspaces'),
|
|
19
|
+
entityId: values_1.v.string(),
|
|
20
|
+
},
|
|
21
|
+
handler: async (ctx, args) => {
|
|
22
|
+
console.log('[CalendarCron] Starting sync for workspace:', args.workspaceId, 'entity:', args.entityId);
|
|
23
|
+
try {
|
|
24
|
+
// Call the Next.js API route that wraps Composio
|
|
25
|
+
// Convex actions can't import Node.js-only Composio SDK directly,
|
|
26
|
+
// so we call the integration endpoint via fetch.
|
|
27
|
+
const appUrl = process.env.NEXT_PUBLIC_APP_URL;
|
|
28
|
+
if (!appUrl) {
|
|
29
|
+
console.error('[CalendarCron] NEXT_PUBLIC_APP_URL not configured');
|
|
30
|
+
return { success: false, error: 'App URL not configured' };
|
|
31
|
+
}
|
|
32
|
+
const cronSecret = process.env.CRON_SECRET;
|
|
33
|
+
if (!cronSecret) {
|
|
34
|
+
console.error('[CalendarCron] CRON_SECRET not configured');
|
|
35
|
+
return { success: false, error: 'Cron secret not configured' };
|
|
36
|
+
}
|
|
37
|
+
// Fetch events from the calendar sync API endpoint
|
|
38
|
+
const response = await fetch(`${appUrl}/api/integrations/calendar-sync`, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: {
|
|
41
|
+
'Content-Type': 'application/json',
|
|
42
|
+
Authorization: `Bearer ${cronSecret}`,
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify({
|
|
45
|
+
entityId: args.entityId,
|
|
46
|
+
workspaceId: args.workspaceId,
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
const errorText = await response.text();
|
|
51
|
+
console.error('[CalendarCron] API call failed:', response.status, errorText);
|
|
52
|
+
return { success: false, error: `API error: ${response.status}` };
|
|
53
|
+
}
|
|
54
|
+
const data = (await response.json());
|
|
55
|
+
if (!data.events || data.events.length === 0) {
|
|
56
|
+
console.log('[CalendarCron] No events to sync');
|
|
57
|
+
return { success: true, upserted: 0, linked: 0 };
|
|
58
|
+
}
|
|
59
|
+
console.log('[CalendarCron] Fetched', data.events.length, 'events from Composio');
|
|
60
|
+
// Upsert events into Convex via internal mutation
|
|
61
|
+
const result = await ctx.runMutation(api_1.internal.crm.calendarSync.syncEvents, {
|
|
62
|
+
workspaceId: args.workspaceId,
|
|
63
|
+
events: data.events,
|
|
64
|
+
});
|
|
65
|
+
console.log('[CalendarCron] Sync complete:', result.upserted, 'upserted,', result.linked, 'linked');
|
|
66
|
+
return { success: true, upserted: result.upserted, linked: result.linked };
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
console.error('[CalendarCron] Sync failed:', error);
|
|
70
|
+
return {
|
|
71
|
+
success: false,
|
|
72
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
// =============================================================================
|
|
78
|
+
// TRIGGER SYNC — internal action to manually trigger calendar sync
|
|
79
|
+
// =============================================================================
|
|
80
|
+
exports.triggerSync = (0, server_1.internalAction)({
|
|
81
|
+
args: {
|
|
82
|
+
workspaceId: values_1.v.id('workspaces'),
|
|
83
|
+
entityId: values_1.v.string(),
|
|
84
|
+
},
|
|
85
|
+
handler: async (ctx, args) => {
|
|
86
|
+
const result = await ctx.runAction(api_1.internal.crm.calendarCron.syncCalendarEvents, {
|
|
87
|
+
workspaceId: args.workspaceId,
|
|
88
|
+
entityId: args.entityId,
|
|
89
|
+
});
|
|
90
|
+
return result;
|
|
91
|
+
},
|
|
92
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Calendar Cron Dispatcher
|
|
4
|
+
*
|
|
5
|
+
* Finds all workspaces with Google Calendar sync enabled
|
|
6
|
+
* and triggers sync for each one.
|
|
7
|
+
*
|
|
8
|
+
* The cron calls this dispatcher every 5 minutes.
|
|
9
|
+
* The dispatcher then fans out to per-workspace sync actions.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.getCalendarSyncWorkspaces = exports.dispatchCalendarSync = void 0;
|
|
13
|
+
const server_1 = require("../_generated/server");
|
|
14
|
+
const api_1 = require("../_generated/api");
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// DISPATCH — find workspaces and fan out sync actions
|
|
17
|
+
// =============================================================================
|
|
18
|
+
exports.dispatchCalendarSync = (0, server_1.internalAction)({
|
|
19
|
+
args: {},
|
|
20
|
+
handler: async (ctx) => {
|
|
21
|
+
console.log('[CalendarCronDispatch] Starting dispatch...');
|
|
22
|
+
// Get all workspaces that have calendar sync configured
|
|
23
|
+
const workspaces = await ctx.runQuery(api_1.internal.crm.calendarCronDispatch.getCalendarSyncWorkspaces);
|
|
24
|
+
if (workspaces.length === 0) {
|
|
25
|
+
console.log('[CalendarCronDispatch] No workspaces with calendar sync enabled');
|
|
26
|
+
return { dispatched: 0 };
|
|
27
|
+
}
|
|
28
|
+
console.log('[CalendarCronDispatch] Dispatching sync for', workspaces.length, 'workspaces');
|
|
29
|
+
// Fan out sync actions for each workspace
|
|
30
|
+
let dispatched = 0;
|
|
31
|
+
for (const ws of workspaces) {
|
|
32
|
+
try {
|
|
33
|
+
await ctx.runAction(api_1.internal.crm.calendarCron.syncCalendarEvents, {
|
|
34
|
+
workspaceId: ws.workspaceId,
|
|
35
|
+
entityId: ws.entityId,
|
|
36
|
+
});
|
|
37
|
+
dispatched++;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
console.error('[CalendarCronDispatch] Failed for workspace:', ws.workspaceId, error);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
console.log('[CalendarCronDispatch] Dispatched', dispatched, 'sync jobs');
|
|
44
|
+
return { dispatched };
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
// =============================================================================
|
|
48
|
+
// QUERY — get workspaces with calendar sync enabled
|
|
49
|
+
// =============================================================================
|
|
50
|
+
exports.getCalendarSyncWorkspaces = (0, server_1.internalQuery)({
|
|
51
|
+
args: {},
|
|
52
|
+
handler: async (ctx) => {
|
|
53
|
+
// Get all workspaces and filter to those with calendarSyncEnabled setting
|
|
54
|
+
const workspaces = await ctx.db.query('workspaces').collect();
|
|
55
|
+
const results = [];
|
|
56
|
+
for (const ws of workspaces) {
|
|
57
|
+
// Only sync workspaces that have calendar sync enabled
|
|
58
|
+
if (!ws.settings?.calendarSyncEnabled)
|
|
59
|
+
continue;
|
|
60
|
+
// Determine entityId: for personal workspaces use the owner's clerkId,
|
|
61
|
+
// for org workspaces use the orgId
|
|
62
|
+
let entityId;
|
|
63
|
+
if (ws.ownerType === 'user' && ws.ownerId) {
|
|
64
|
+
// Get the user's clerkId from the users table
|
|
65
|
+
const user = await ctx.db.get(ws.ownerId);
|
|
66
|
+
if (user) {
|
|
67
|
+
entityId = user.clerkId;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else if (ws.ownerType === 'organization' && ws.orgId) {
|
|
71
|
+
// For org workspaces, get the Clerk orgId from organizations table
|
|
72
|
+
const org = await ctx.db.get(ws.orgId);
|
|
73
|
+
if (org) {
|
|
74
|
+
entityId = org.clerkId;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (entityId) {
|
|
78
|
+
results.push({ workspaceId: ws._id, entityId });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return results;
|
|
82
|
+
},
|
|
83
|
+
});
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Calendar Sync — Mutations for upserting Google Calendar events
|
|
4
|
+
* and auto-linking attendees to contacts/companies.
|
|
5
|
+
*
|
|
6
|
+
* Sync flow: cron → Composio API → this module upserts into calendarEvents table.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.getMeetingBrief = exports.listByContact = exports.listUpcoming = exports.linkToContact = exports.syncEvents = void 0;
|
|
10
|
+
const server_1 = require("../_generated/server");
|
|
11
|
+
const values_1 = require("convex/values");
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// VALIDATORS
|
|
14
|
+
// =============================================================================
|
|
15
|
+
const attendeeValidator = values_1.v.object({
|
|
16
|
+
email: values_1.v.string(),
|
|
17
|
+
name: values_1.v.optional(values_1.v.string()),
|
|
18
|
+
status: values_1.v.optional(values_1.v.string()),
|
|
19
|
+
});
|
|
20
|
+
const calendarEventValidator = {
|
|
21
|
+
googleEventId: values_1.v.string(),
|
|
22
|
+
calendarId: values_1.v.string(),
|
|
23
|
+
title: values_1.v.string(),
|
|
24
|
+
description: values_1.v.optional(values_1.v.string()),
|
|
25
|
+
location: values_1.v.optional(values_1.v.string()),
|
|
26
|
+
startTime: values_1.v.number(),
|
|
27
|
+
endTime: values_1.v.number(),
|
|
28
|
+
isAllDay: values_1.v.optional(values_1.v.boolean()),
|
|
29
|
+
attendees: values_1.v.optional(values_1.v.array(attendeeValidator)),
|
|
30
|
+
};
|
|
31
|
+
// =============================================================================
|
|
32
|
+
// SYNC EVENTS — upsert by googleEventId (internal, called by cron action)
|
|
33
|
+
// =============================================================================
|
|
34
|
+
exports.syncEvents = (0, server_1.internalMutation)({
|
|
35
|
+
args: {
|
|
36
|
+
workspaceId: values_1.v.id('workspaces'),
|
|
37
|
+
events: values_1.v.array(values_1.v.object(calendarEventValidator)),
|
|
38
|
+
},
|
|
39
|
+
handler: async (ctx, args) => {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
const results = {
|
|
42
|
+
upserted: 0,
|
|
43
|
+
linked: 0,
|
|
44
|
+
};
|
|
45
|
+
for (const event of args.events) {
|
|
46
|
+
// Check if event already exists by googleEventId
|
|
47
|
+
const existing = await ctx.db
|
|
48
|
+
.query('calendarEvents')
|
|
49
|
+
.withIndex('by_google_event', (q) => q.eq('googleEventId', event.googleEventId))
|
|
50
|
+
.first();
|
|
51
|
+
// Auto-link: match attendee emails to contacts and companies
|
|
52
|
+
let contactId;
|
|
53
|
+
let companyId;
|
|
54
|
+
if (event.attendees && event.attendees.length > 0) {
|
|
55
|
+
for (const attendee of event.attendees) {
|
|
56
|
+
if (!attendee.email)
|
|
57
|
+
continue;
|
|
58
|
+
// Match contact by email
|
|
59
|
+
if (!contactId) {
|
|
60
|
+
const contact = await ctx.db
|
|
61
|
+
.query('contacts')
|
|
62
|
+
.withIndex('by_workspace', (q) => q.eq('workspaceId', args.workspaceId))
|
|
63
|
+
.filter((q) => q.eq(q.field('email'), attendee.email))
|
|
64
|
+
.first();
|
|
65
|
+
if (contact) {
|
|
66
|
+
contactId = contact._id;
|
|
67
|
+
// If contact has a company, use that too
|
|
68
|
+
if (contact.companyId) {
|
|
69
|
+
companyId = contact.companyId;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Match company by domain (extract domain from email)
|
|
74
|
+
if (!companyId) {
|
|
75
|
+
const emailDomain = attendee.email.split('@')[1]?.toLowerCase();
|
|
76
|
+
if (emailDomain &&
|
|
77
|
+
!['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'icloud.com', 'protonmail.com'].includes(emailDomain)) {
|
|
78
|
+
const company = await ctx.db
|
|
79
|
+
.query('companies')
|
|
80
|
+
.withIndex('by_workspace', (q) => q.eq('workspaceId', args.workspaceId))
|
|
81
|
+
.filter((q) => q.eq(q.field('domain'), emailDomain))
|
|
82
|
+
.first();
|
|
83
|
+
if (company) {
|
|
84
|
+
companyId = company._id;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Stop early if both found
|
|
89
|
+
if (contactId && companyId)
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
if (contactId || companyId)
|
|
93
|
+
results.linked++;
|
|
94
|
+
}
|
|
95
|
+
if (existing) {
|
|
96
|
+
// Update existing event
|
|
97
|
+
await ctx.db.patch(existing._id, {
|
|
98
|
+
title: event.title,
|
|
99
|
+
description: event.description,
|
|
100
|
+
startTime: event.startTime,
|
|
101
|
+
endTime: event.endTime,
|
|
102
|
+
attendees: event.attendees,
|
|
103
|
+
contactId,
|
|
104
|
+
companyId,
|
|
105
|
+
syncedAt: now,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// Insert new event
|
|
110
|
+
await ctx.db.insert('calendarEvents', {
|
|
111
|
+
googleEventId: event.googleEventId,
|
|
112
|
+
title: event.title,
|
|
113
|
+
description: event.description,
|
|
114
|
+
startTime: event.startTime,
|
|
115
|
+
endTime: event.endTime,
|
|
116
|
+
attendees: event.attendees,
|
|
117
|
+
contactId,
|
|
118
|
+
companyId,
|
|
119
|
+
workspaceId: args.workspaceId,
|
|
120
|
+
syncedAt: now,
|
|
121
|
+
});
|
|
122
|
+
// Auto-create meeting activity if linked to a contact
|
|
123
|
+
if (contactId) {
|
|
124
|
+
await ctx.db.insert('activities', {
|
|
125
|
+
type: 'calendar-event',
|
|
126
|
+
subject: event.title,
|
|
127
|
+
description: event.description,
|
|
128
|
+
contactId,
|
|
129
|
+
companyId,
|
|
130
|
+
workspaceId: args.workspaceId,
|
|
131
|
+
ownerId: 'system',
|
|
132
|
+
actorType: 'agent',
|
|
133
|
+
actorId: 'agent:composio',
|
|
134
|
+
occurredAt: event.startTime,
|
|
135
|
+
createdAt: now,
|
|
136
|
+
updatedAt: now,
|
|
137
|
+
});
|
|
138
|
+
// Update lastContactedAt on contact
|
|
139
|
+
await ctx.db.patch(contactId, {
|
|
140
|
+
lastContactedAt: event.startTime,
|
|
141
|
+
updatedAt: now,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
results.upserted++;
|
|
146
|
+
}
|
|
147
|
+
return results;
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
// =============================================================================
|
|
151
|
+
// LINK TO CONTACT — manually link a calendar event to a contact
|
|
152
|
+
// =============================================================================
|
|
153
|
+
exports.linkToContact = (0, server_1.mutation)({
|
|
154
|
+
args: {
|
|
155
|
+
eventId: values_1.v.id('calendarEvents'),
|
|
156
|
+
contactId: values_1.v.optional(values_1.v.id('contacts')),
|
|
157
|
+
companyId: values_1.v.optional(values_1.v.id('companies')),
|
|
158
|
+
dealId: values_1.v.optional(values_1.v.id('deals')),
|
|
159
|
+
},
|
|
160
|
+
handler: async (ctx, args) => {
|
|
161
|
+
const event = await ctx.db.get(args.eventId);
|
|
162
|
+
if (!event)
|
|
163
|
+
throw new Error('Calendar event not found');
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
await ctx.db.patch(args.eventId, {
|
|
166
|
+
contactId: args.contactId,
|
|
167
|
+
companyId: args.companyId,
|
|
168
|
+
dealId: args.dealId,
|
|
169
|
+
});
|
|
170
|
+
return { success: true };
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
// =============================================================================
|
|
174
|
+
// QUERIES
|
|
175
|
+
// =============================================================================
|
|
176
|
+
/** List upcoming events for a workspace */
|
|
177
|
+
exports.listUpcoming = (0, server_1.query)({
|
|
178
|
+
args: {
|
|
179
|
+
workspaceId: values_1.v.id('workspaces'),
|
|
180
|
+
fromTime: values_1.v.optional(values_1.v.number()),
|
|
181
|
+
toTime: values_1.v.optional(values_1.v.number()),
|
|
182
|
+
limit: values_1.v.optional(values_1.v.number()),
|
|
183
|
+
},
|
|
184
|
+
handler: async (ctx, args) => {
|
|
185
|
+
const from = args.fromTime ?? Date.now();
|
|
186
|
+
const to = args.toTime ?? from + 7 * 24 * 60 * 60 * 1000; // default 7 days ahead
|
|
187
|
+
const events = await ctx.db
|
|
188
|
+
.query('calendarEvents')
|
|
189
|
+
.withIndex('by_workspace_start', (q) => q
|
|
190
|
+
.eq('workspaceId', args.workspaceId)
|
|
191
|
+
.gte('startTime', from)
|
|
192
|
+
.lte('startTime', to))
|
|
193
|
+
.order('asc')
|
|
194
|
+
.take(args.limit ?? 50);
|
|
195
|
+
return events;
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
/** List events linked to a specific contact */
|
|
199
|
+
exports.listByContact = (0, server_1.query)({
|
|
200
|
+
args: {
|
|
201
|
+
contactId: values_1.v.id('contacts'),
|
|
202
|
+
limit: values_1.v.optional(values_1.v.number()),
|
|
203
|
+
},
|
|
204
|
+
handler: async (ctx, args) => {
|
|
205
|
+
return await ctx.db
|
|
206
|
+
.query('calendarEvents')
|
|
207
|
+
.withIndex('by_contact', (q) => q.eq('contactId', args.contactId))
|
|
208
|
+
.order('desc')
|
|
209
|
+
.take(args.limit ?? 20);
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
/** Get pre-meeting brief: contact + deal context for next meeting */
|
|
213
|
+
exports.getMeetingBrief = (0, server_1.query)({
|
|
214
|
+
args: {
|
|
215
|
+
eventId: values_1.v.id('calendarEvents'),
|
|
216
|
+
},
|
|
217
|
+
handler: async (ctx, args) => {
|
|
218
|
+
const event = await ctx.db.get(args.eventId);
|
|
219
|
+
if (!event)
|
|
220
|
+
return null;
|
|
221
|
+
let contact = null;
|
|
222
|
+
let company = null;
|
|
223
|
+
let deal = null;
|
|
224
|
+
const recentActivities = [];
|
|
225
|
+
if (event.contactId) {
|
|
226
|
+
contact = await ctx.db.get(event.contactId);
|
|
227
|
+
// Get recent activities for this contact
|
|
228
|
+
const activities = await ctx.db
|
|
229
|
+
.query('activities')
|
|
230
|
+
.withIndex('by_contact', (q) => q.eq('contactId', event.contactId))
|
|
231
|
+
.order('desc')
|
|
232
|
+
.take(10);
|
|
233
|
+
recentActivities.push(...activities.map((a) => ({
|
|
234
|
+
type: a.type,
|
|
235
|
+
subject: a.subject,
|
|
236
|
+
occurredAt: a.occurredAt,
|
|
237
|
+
})));
|
|
238
|
+
}
|
|
239
|
+
if (event.companyId) {
|
|
240
|
+
company = await ctx.db.get(event.companyId);
|
|
241
|
+
}
|
|
242
|
+
if (event.dealId) {
|
|
243
|
+
deal = await ctx.db.get(event.dealId);
|
|
244
|
+
}
|
|
245
|
+
else if (event.contactId) {
|
|
246
|
+
// Try to find an active deal for this contact
|
|
247
|
+
deal = await ctx.db
|
|
248
|
+
.query('deals')
|
|
249
|
+
.withIndex('by_contact', (q) => q.eq('contactId', event.contactId))
|
|
250
|
+
.filter((q) => q.neq(q.field('isArchived'), true))
|
|
251
|
+
.first();
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
event: {
|
|
255
|
+
title: event.title,
|
|
256
|
+
startTime: event.startTime,
|
|
257
|
+
endTime: event.endTime,
|
|
258
|
+
description: event.description,
|
|
259
|
+
attendees: event.attendees,
|
|
260
|
+
},
|
|
261
|
+
contact: contact
|
|
262
|
+
? {
|
|
263
|
+
id: contact._id,
|
|
264
|
+
name: `${contact.firstName} ${contact.lastName}`,
|
|
265
|
+
email: contact.email,
|
|
266
|
+
phone: contact.phone,
|
|
267
|
+
type: contact.type,
|
|
268
|
+
jobTitle: contact.jobTitle,
|
|
269
|
+
notes: contact.notes,
|
|
270
|
+
lastContactedAt: contact.lastContactedAt,
|
|
271
|
+
}
|
|
272
|
+
: null,
|
|
273
|
+
company: company
|
|
274
|
+
? {
|
|
275
|
+
id: company._id,
|
|
276
|
+
name: company.name,
|
|
277
|
+
domain: company.domain,
|
|
278
|
+
industry: company.industry,
|
|
279
|
+
size: company.size,
|
|
280
|
+
}
|
|
281
|
+
: null,
|
|
282
|
+
deal: deal
|
|
283
|
+
? {
|
|
284
|
+
id: deal._id,
|
|
285
|
+
title: deal.title,
|
|
286
|
+
stage: deal.stage,
|
|
287
|
+
value: deal.value,
|
|
288
|
+
probability: deal.probability,
|
|
289
|
+
}
|
|
290
|
+
: null,
|
|
291
|
+
recentActivities,
|
|
292
|
+
};
|
|
293
|
+
},
|
|
294
|
+
});
|