@l4yercak3/cli 1.1.12 → 1.2.1
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/bin/cli.js +6 -0
- package/docs/mcp_server/MCP_SERVER_ARCHITECTURE.md +1481 -0
- package/docs/mcp_server/applicationOntology.ts +817 -0
- package/docs/mcp_server/cliApplications.ts +639 -0
- package/docs/mcp_server/crmOntology.ts +1063 -0
- package/docs/mcp_server/eventOntology.ts +1183 -0
- package/docs/mcp_server/formsOntology.ts +1401 -0
- package/docs/mcp_server/ontologySchemas.ts +185 -0
- package/docs/mcp_server/schema.ts +250 -0
- package/package.json +5 -2
- package/src/commands/login.js +0 -6
- package/src/commands/mcp-server.js +32 -0
- package/src/commands/spread.js +54 -1
- package/src/detectors/expo-detector.js +163 -0
- package/src/detectors/registry.js +2 -0
- package/src/mcp/auth.js +127 -0
- package/src/mcp/registry/domains/applications.js +516 -0
- package/src/mcp/registry/domains/codegen.js +894 -0
- package/src/mcp/registry/domains/core.js +326 -0
- package/src/mcp/registry/domains/crm.js +591 -0
- package/src/mcp/registry/domains/events.js +649 -0
- package/src/mcp/registry/domains/forms.js +696 -0
- package/src/mcp/registry/index.js +162 -0
- package/src/mcp/server.js +116 -0
|
@@ -0,0 +1,1063 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRM ONTOLOGY
|
|
3
|
+
*
|
|
4
|
+
* Manages CRM contacts and organizations for customer relationship management.
|
|
5
|
+
* Uses the universal ontology system (objects table).
|
|
6
|
+
*
|
|
7
|
+
* Object Types:
|
|
8
|
+
* - crm_contact: Individual contacts (customers, leads, prospects)
|
|
9
|
+
* - crm_organization: Companies/organizations (customer companies)
|
|
10
|
+
*
|
|
11
|
+
* Contact Types (subtype):
|
|
12
|
+
* - "customer" - Paying customers
|
|
13
|
+
* - "lead" - Potential customers
|
|
14
|
+
* - "prospect" - Qualified leads
|
|
15
|
+
*
|
|
16
|
+
* Organization Types (subtype):
|
|
17
|
+
* - "customer" - Customer companies
|
|
18
|
+
* - "prospect" - Potential customer companies
|
|
19
|
+
* - "partner" - Partner organizations
|
|
20
|
+
*
|
|
21
|
+
* Status Workflow:
|
|
22
|
+
* - "active" - Active contact/org
|
|
23
|
+
* - "inactive" - Temporarily inactive
|
|
24
|
+
* - "unsubscribed" - Opted out (contacts only)
|
|
25
|
+
* - "archived" - Archived/deleted
|
|
26
|
+
*
|
|
27
|
+
* GRAVEL ROAD APPROACH:
|
|
28
|
+
* - Start simple: name, email, phone, basic info
|
|
29
|
+
* - Add fields via customProperties as needed
|
|
30
|
+
* - Use objectLinks for relationships (contact → organization)
|
|
31
|
+
* - Use objectActions for audit trail
|
|
32
|
+
*
|
|
33
|
+
* ADDRESS SUPPORT:
|
|
34
|
+
* - Support multiple addresses per contact/organization
|
|
35
|
+
* - Address types: billing, shipping, mailing, physical, warehouse, other
|
|
36
|
+
* - One primary address per type
|
|
37
|
+
* - Backward compatible with single address field
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { query, mutation, internalQuery } from "./_generated/server";
|
|
41
|
+
import { v } from "convex/values";
|
|
42
|
+
import { requireAuthenticatedUser } from "./rbacHelpers";
|
|
43
|
+
import { checkResourceLimit } from "./licensing/helpers";
|
|
44
|
+
import { internal } from "./_generated/api";
|
|
45
|
+
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// ADDRESS VALIDATORS
|
|
48
|
+
// ============================================================================
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Address type enum
|
|
52
|
+
*/
|
|
53
|
+
export const addressTypes = ["billing", "shipping", "mailing", "physical", "warehouse", "other"] as const;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Single address validator
|
|
57
|
+
*/
|
|
58
|
+
export const addressValidator = v.object({
|
|
59
|
+
type: v.union(
|
|
60
|
+
v.literal("billing"),
|
|
61
|
+
v.literal("shipping"),
|
|
62
|
+
v.literal("mailing"),
|
|
63
|
+
v.literal("physical"),
|
|
64
|
+
v.literal("warehouse"),
|
|
65
|
+
v.literal("other")
|
|
66
|
+
),
|
|
67
|
+
isPrimary: v.boolean(),
|
|
68
|
+
label: v.optional(v.string()), // e.g., "Corporate HQ", "Warehouse 1"
|
|
69
|
+
street: v.optional(v.string()),
|
|
70
|
+
street2: v.optional(v.string()), // Additional address line
|
|
71
|
+
city: v.optional(v.string()),
|
|
72
|
+
state: v.optional(v.string()),
|
|
73
|
+
postalCode: v.optional(v.string()),
|
|
74
|
+
country: v.optional(v.string()),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Addresses array validator
|
|
79
|
+
*/
|
|
80
|
+
export const addressesValidator = v.array(addressValidator);
|
|
81
|
+
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// CRM CONTACT OPERATIONS
|
|
84
|
+
// ============================================================================
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* GET CONTACTS
|
|
88
|
+
* Returns all contacts for an organization
|
|
89
|
+
*/
|
|
90
|
+
export const getContacts = query({
|
|
91
|
+
args: {
|
|
92
|
+
sessionId: v.string(),
|
|
93
|
+
organizationId: v.id("organizations"),
|
|
94
|
+
subtype: v.optional(v.string()), // Filter by contact type
|
|
95
|
+
status: v.optional(v.string()), // Filter by status
|
|
96
|
+
},
|
|
97
|
+
handler: async (ctx, args) => {
|
|
98
|
+
await requireAuthenticatedUser(ctx, args.sessionId);
|
|
99
|
+
|
|
100
|
+
const q = ctx.db
|
|
101
|
+
.query("objects")
|
|
102
|
+
.withIndex("by_org_type", (q) =>
|
|
103
|
+
q.eq("organizationId", args.organizationId).eq("type", "crm_contact")
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
let contacts = await q.collect();
|
|
107
|
+
|
|
108
|
+
// Apply filters
|
|
109
|
+
if (args.subtype) {
|
|
110
|
+
contacts = contacts.filter((c) => c.subtype === args.subtype);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (args.status) {
|
|
114
|
+
contacts = contacts.filter((c) => c.status === args.status);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return contacts;
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* GET CONTACT
|
|
123
|
+
* Get a single contact by ID
|
|
124
|
+
*/
|
|
125
|
+
export const getContact = query({
|
|
126
|
+
args: {
|
|
127
|
+
sessionId: v.string(),
|
|
128
|
+
contactId: v.id("objects"),
|
|
129
|
+
},
|
|
130
|
+
handler: async (ctx, args) => {
|
|
131
|
+
await requireAuthenticatedUser(ctx, args.sessionId);
|
|
132
|
+
|
|
133
|
+
const contact = await ctx.db.get(args.contactId);
|
|
134
|
+
|
|
135
|
+
if (!contact || contact.type !== "crm_contact") {
|
|
136
|
+
throw new Error("Contact not found");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return contact;
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* INTERNAL: Get CRM Contact by ID
|
|
145
|
+
* Used by internal systems (e.g., PDF generation) to fetch contact data
|
|
146
|
+
*/
|
|
147
|
+
export const getContactInternal = internalQuery({
|
|
148
|
+
args: {
|
|
149
|
+
contactId: v.id("objects"),
|
|
150
|
+
},
|
|
151
|
+
handler: async (ctx, args) => {
|
|
152
|
+
const contact = await ctx.db.get(args.contactId);
|
|
153
|
+
|
|
154
|
+
if (!contact || contact.type !== "crm_contact") {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return contact;
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* CREATE CONTACT
|
|
164
|
+
* Create a new CRM contact
|
|
165
|
+
*
|
|
166
|
+
* NOTE: When implementing bulk contact import/export features, add:
|
|
167
|
+
* - checkFeatureAccess(ctx, organizationId, "contactImportExportEnabled")
|
|
168
|
+
* This requires Starter+ tier.
|
|
169
|
+
*/
|
|
170
|
+
export const createContact = mutation({
|
|
171
|
+
args: {
|
|
172
|
+
sessionId: v.string(),
|
|
173
|
+
organizationId: v.id("organizations"),
|
|
174
|
+
subtype: v.string(), // "customer" | "lead" | "prospect"
|
|
175
|
+
firstName: v.string(),
|
|
176
|
+
lastName: v.string(),
|
|
177
|
+
email: v.string(),
|
|
178
|
+
phone: v.optional(v.string()),
|
|
179
|
+
jobTitle: v.optional(v.string()),
|
|
180
|
+
company: v.optional(v.string()),
|
|
181
|
+
// BACKWARD COMPATIBLE: Support old single address field
|
|
182
|
+
address: v.optional(v.object({
|
|
183
|
+
street: v.optional(v.string()),
|
|
184
|
+
city: v.optional(v.string()),
|
|
185
|
+
state: v.optional(v.string()),
|
|
186
|
+
postalCode: v.optional(v.string()),
|
|
187
|
+
country: v.optional(v.string()),
|
|
188
|
+
})),
|
|
189
|
+
// NEW: Support multiple addresses
|
|
190
|
+
addresses: v.optional(addressesValidator),
|
|
191
|
+
source: v.optional(v.string()), // "manual" | "checkout" | "event" | "import"
|
|
192
|
+
sourceRef: v.optional(v.string()), // Reference to source (checkout ID, event ID, etc.)
|
|
193
|
+
tags: v.optional(v.array(v.string())),
|
|
194
|
+
notes: v.optional(v.string()),
|
|
195
|
+
customFields: v.optional(v.any()), // Additional custom fields
|
|
196
|
+
},
|
|
197
|
+
handler: async (ctx, args) => {
|
|
198
|
+
const session = await ctx.db
|
|
199
|
+
.query("sessions")
|
|
200
|
+
.filter((q) => q.eq(q.field("_id"), args.sessionId))
|
|
201
|
+
.first();
|
|
202
|
+
|
|
203
|
+
if (!session) throw new Error("Invalid session");
|
|
204
|
+
|
|
205
|
+
// CHECK LICENSE LIMIT: Enforce contact limit for organization's tier
|
|
206
|
+
// Free: 100, Starter: 1,000, Pro: 5,000, Agency: 10,000, Enterprise: Unlimited
|
|
207
|
+
await checkResourceLimit(ctx, args.organizationId, "crm_contact", "maxContacts");
|
|
208
|
+
|
|
209
|
+
// Handle addresses: convert old address format to new format if needed
|
|
210
|
+
let addresses = args.addresses;
|
|
211
|
+
if (!addresses && args.address) {
|
|
212
|
+
// Backward compatibility: convert single address to addresses array
|
|
213
|
+
addresses = [{
|
|
214
|
+
type: "mailing" as const,
|
|
215
|
+
isPrimary: true,
|
|
216
|
+
label: "Primary Address",
|
|
217
|
+
...args.address,
|
|
218
|
+
}];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const contactId = await ctx.db.insert("objects", {
|
|
222
|
+
organizationId: args.organizationId,
|
|
223
|
+
type: "crm_contact",
|
|
224
|
+
subtype: args.subtype,
|
|
225
|
+
name: `${args.firstName} ${args.lastName}`,
|
|
226
|
+
description: args.jobTitle || "Contact",
|
|
227
|
+
status: "active",
|
|
228
|
+
customProperties: {
|
|
229
|
+
firstName: args.firstName,
|
|
230
|
+
lastName: args.lastName,
|
|
231
|
+
email: args.email,
|
|
232
|
+
phone: args.phone,
|
|
233
|
+
jobTitle: args.jobTitle,
|
|
234
|
+
company: args.company,
|
|
235
|
+
// Keep old address for backward compatibility
|
|
236
|
+
address: args.address,
|
|
237
|
+
// Add new addresses array
|
|
238
|
+
addresses: addresses,
|
|
239
|
+
source: args.source || "manual",
|
|
240
|
+
sourceRef: args.sourceRef,
|
|
241
|
+
tags: args.tags || [],
|
|
242
|
+
notes: args.notes,
|
|
243
|
+
...args.customFields,
|
|
244
|
+
},
|
|
245
|
+
createdBy: session.userId,
|
|
246
|
+
createdAt: Date.now(),
|
|
247
|
+
updatedAt: Date.now(),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Log creation action
|
|
251
|
+
await ctx.db.insert("objectActions", {
|
|
252
|
+
organizationId: args.organizationId,
|
|
253
|
+
objectId: contactId,
|
|
254
|
+
actionType: "created",
|
|
255
|
+
actionData: {
|
|
256
|
+
source: args.source || "manual",
|
|
257
|
+
subtype: args.subtype,
|
|
258
|
+
},
|
|
259
|
+
performedBy: session.userId,
|
|
260
|
+
performedAt: Date.now(),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
return contactId;
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* UPDATE CONTACT
|
|
269
|
+
* Update an existing contact
|
|
270
|
+
*/
|
|
271
|
+
export const updateContact = mutation({
|
|
272
|
+
args: {
|
|
273
|
+
sessionId: v.string(),
|
|
274
|
+
contactId: v.id("objects"),
|
|
275
|
+
updates: v.object({
|
|
276
|
+
firstName: v.optional(v.string()),
|
|
277
|
+
lastName: v.optional(v.string()),
|
|
278
|
+
email: v.optional(v.string()),
|
|
279
|
+
phone: v.optional(v.string()),
|
|
280
|
+
jobTitle: v.optional(v.string()),
|
|
281
|
+
company: v.optional(v.string()),
|
|
282
|
+
// BACKWARD COMPATIBLE: Support old single address field
|
|
283
|
+
address: v.optional(v.any()),
|
|
284
|
+
// NEW: Support multiple addresses
|
|
285
|
+
addresses: v.optional(addressesValidator),
|
|
286
|
+
status: v.optional(v.string()),
|
|
287
|
+
subtype: v.optional(v.string()), // Lifecycle stage: "lead" | "prospect" | "customer" | "partner"
|
|
288
|
+
tags: v.optional(v.array(v.string())),
|
|
289
|
+
notes: v.optional(v.string()),
|
|
290
|
+
customFields: v.optional(v.any()),
|
|
291
|
+
}),
|
|
292
|
+
},
|
|
293
|
+
handler: async (ctx, args) => {
|
|
294
|
+
const session = await ctx.db
|
|
295
|
+
.query("sessions")
|
|
296
|
+
.filter((q) => q.eq(q.field("_id"), args.sessionId))
|
|
297
|
+
.first();
|
|
298
|
+
|
|
299
|
+
if (!session) throw new Error("Invalid session");
|
|
300
|
+
|
|
301
|
+
const contact = await ctx.db.get(args.contactId);
|
|
302
|
+
if (!contact || contact.type !== "crm_contact") {
|
|
303
|
+
throw new Error("Contact not found");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Update name if first/last name changed
|
|
307
|
+
let newName = contact.name;
|
|
308
|
+
if (args.updates.firstName || args.updates.lastName) {
|
|
309
|
+
const firstName = args.updates.firstName || contact.customProperties?.firstName;
|
|
310
|
+
const lastName = args.updates.lastName || contact.customProperties?.lastName;
|
|
311
|
+
newName = `${firstName} ${lastName}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
await ctx.db.patch(args.contactId, {
|
|
315
|
+
name: newName,
|
|
316
|
+
status: args.updates.status || contact.status,
|
|
317
|
+
subtype: args.updates.subtype || contact.subtype, // Update lifecycle stage
|
|
318
|
+
customProperties: {
|
|
319
|
+
...contact.customProperties,
|
|
320
|
+
...args.updates,
|
|
321
|
+
...args.updates.customFields,
|
|
322
|
+
},
|
|
323
|
+
updatedAt: Date.now(),
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Log update action
|
|
327
|
+
await ctx.db.insert("objectActions", {
|
|
328
|
+
organizationId: contact.organizationId,
|
|
329
|
+
objectId: args.contactId,
|
|
330
|
+
actionType: "updated",
|
|
331
|
+
actionData: {
|
|
332
|
+
updatedFields: Object.keys(args.updates),
|
|
333
|
+
},
|
|
334
|
+
performedBy: session.userId,
|
|
335
|
+
performedAt: Date.now(),
|
|
336
|
+
});
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* DELETE CONTACT
|
|
342
|
+
* Permanently delete a contact and all associated links
|
|
343
|
+
*/
|
|
344
|
+
export const deleteContact = mutation({
|
|
345
|
+
args: {
|
|
346
|
+
sessionId: v.string(),
|
|
347
|
+
contactId: v.id("objects"),
|
|
348
|
+
},
|
|
349
|
+
handler: async (ctx, args) => {
|
|
350
|
+
const session = await ctx.db
|
|
351
|
+
.query("sessions")
|
|
352
|
+
.filter((q) => q.eq(q.field("_id"), args.sessionId))
|
|
353
|
+
.first();
|
|
354
|
+
|
|
355
|
+
if (!session) throw new Error("Invalid session");
|
|
356
|
+
|
|
357
|
+
const contact = await ctx.db.get(args.contactId);
|
|
358
|
+
if (!contact || contact.type !== "crm_contact") {
|
|
359
|
+
throw new Error("Contact not found");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Log deletion action BEFORE deleting (so we have the data)
|
|
363
|
+
await ctx.db.insert("objectActions", {
|
|
364
|
+
organizationId: contact.organizationId,
|
|
365
|
+
objectId: args.contactId,
|
|
366
|
+
actionType: "deleted",
|
|
367
|
+
actionData: {
|
|
368
|
+
contactName: contact.name,
|
|
369
|
+
email: contact.customProperties?.email,
|
|
370
|
+
deletedBy: session.userId,
|
|
371
|
+
},
|
|
372
|
+
performedBy: session.userId,
|
|
373
|
+
performedAt: Date.now(),
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// Delete all links involving this contact
|
|
377
|
+
const linksFrom = await ctx.db
|
|
378
|
+
.query("objectLinks")
|
|
379
|
+
.withIndex("by_from_object", (q) => q.eq("fromObjectId", args.contactId))
|
|
380
|
+
.collect();
|
|
381
|
+
|
|
382
|
+
const linksTo = await ctx.db
|
|
383
|
+
.query("objectLinks")
|
|
384
|
+
.withIndex("by_to_object", (q) => q.eq("toObjectId", args.contactId))
|
|
385
|
+
.collect();
|
|
386
|
+
|
|
387
|
+
// Delete all links
|
|
388
|
+
for (const link of [...linksFrom, ...linksTo]) {
|
|
389
|
+
await ctx.db.delete(link._id);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Permanently delete the contact
|
|
393
|
+
await ctx.db.delete(args.contactId);
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// ============================================================================
|
|
398
|
+
// CRM ORGANIZATION OPERATIONS
|
|
399
|
+
// ============================================================================
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* GET CRM ORGANIZATIONS
|
|
403
|
+
* Returns all CRM organizations for an organization
|
|
404
|
+
*/
|
|
405
|
+
export const getCrmOrganizations = query({
|
|
406
|
+
args: {
|
|
407
|
+
sessionId: v.string(),
|
|
408
|
+
organizationId: v.id("organizations"),
|
|
409
|
+
subtype: v.optional(v.string()), // Filter by org type
|
|
410
|
+
status: v.optional(v.string()), // Filter by status
|
|
411
|
+
},
|
|
412
|
+
handler: async (ctx, args) => {
|
|
413
|
+
await requireAuthenticatedUser(ctx, args.sessionId);
|
|
414
|
+
|
|
415
|
+
const q = ctx.db
|
|
416
|
+
.query("objects")
|
|
417
|
+
.withIndex("by_org_type", (q) =>
|
|
418
|
+
q.eq("organizationId", args.organizationId).eq("type", "crm_organization")
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
let orgs = await q.collect();
|
|
422
|
+
|
|
423
|
+
// Apply filters
|
|
424
|
+
if (args.subtype) {
|
|
425
|
+
orgs = orgs.filter((o) => o.subtype === args.subtype);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (args.status) {
|
|
429
|
+
orgs = orgs.filter((o) => o.status === args.status);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return orgs;
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* GET CRM ORGANIZATION
|
|
438
|
+
* Get a single CRM organization by ID
|
|
439
|
+
*/
|
|
440
|
+
export const getCrmOrganization = query({
|
|
441
|
+
args: {
|
|
442
|
+
sessionId: v.string(),
|
|
443
|
+
crmOrganizationId: v.id("objects"),
|
|
444
|
+
},
|
|
445
|
+
handler: async (ctx, args) => {
|
|
446
|
+
await requireAuthenticatedUser(ctx, args.sessionId);
|
|
447
|
+
|
|
448
|
+
const org = await ctx.db.get(args.crmOrganizationId);
|
|
449
|
+
|
|
450
|
+
if (!org || org.type !== "crm_organization") {
|
|
451
|
+
throw new Error("CRM organization not found");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return org;
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* GET PUBLIC CRM ORGANIZATION BILLING INFO
|
|
460
|
+
* Public query for checkout - returns limited billing information only
|
|
461
|
+
* Used during checkout to pre-fill employer billing addresses
|
|
462
|
+
*/
|
|
463
|
+
export const getPublicCrmOrganizationBilling = query({
|
|
464
|
+
args: {
|
|
465
|
+
crmOrganizationId: v.id("objects"),
|
|
466
|
+
},
|
|
467
|
+
handler: async (ctx, args) => {
|
|
468
|
+
const org = await ctx.db.get(args.crmOrganizationId);
|
|
469
|
+
|
|
470
|
+
if (!org || org.type !== "crm_organization") {
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Return only public billing information (no sensitive data)
|
|
475
|
+
return {
|
|
476
|
+
_id: org._id,
|
|
477
|
+
name: org.name,
|
|
478
|
+
customProperties: {
|
|
479
|
+
address: (org.customProperties as { address?: unknown })?.address,
|
|
480
|
+
taxId: (org.customProperties as { taxId?: unknown })?.taxId,
|
|
481
|
+
vatNumber: (org.customProperties as { vatNumber?: unknown })?.vatNumber,
|
|
482
|
+
billingEmail: (org.customProperties as { billingEmail?: unknown })?.billingEmail,
|
|
483
|
+
billingContact: (org.customProperties as { billingContact?: unknown })?.billingContact,
|
|
484
|
+
billingAddress: (org.customProperties as { billingAddress?: unknown })?.billingAddress,
|
|
485
|
+
phone: (org.customProperties as { phone?: unknown })?.phone,
|
|
486
|
+
website: (org.customProperties as { website?: unknown })?.website,
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* CREATE CRM ORGANIZATION
|
|
494
|
+
* Create a new CRM organization
|
|
495
|
+
*/
|
|
496
|
+
export const createCrmOrganization = mutation({
|
|
497
|
+
args: {
|
|
498
|
+
sessionId: v.string(),
|
|
499
|
+
organizationId: v.id("organizations"),
|
|
500
|
+
subtype: v.string(), // "customer" | "prospect" | "partner" | "sponsor"
|
|
501
|
+
name: v.string(),
|
|
502
|
+
website: v.optional(v.string()),
|
|
503
|
+
industry: v.optional(v.string()),
|
|
504
|
+
size: v.optional(v.string()), // "1-10" | "11-50" | "51-200" | "201-500" | "501+"
|
|
505
|
+
// BACKWARD COMPATIBLE: Support old single address field
|
|
506
|
+
address: v.optional(v.object({
|
|
507
|
+
street: v.optional(v.string()),
|
|
508
|
+
city: v.optional(v.string()),
|
|
509
|
+
state: v.optional(v.string()),
|
|
510
|
+
postalCode: v.optional(v.string()),
|
|
511
|
+
country: v.optional(v.string()),
|
|
512
|
+
})),
|
|
513
|
+
// NEW: Support multiple addresses
|
|
514
|
+
addresses: v.optional(addressesValidator),
|
|
515
|
+
// Basic contact info
|
|
516
|
+
taxId: v.optional(v.string()),
|
|
517
|
+
billingEmail: v.optional(v.string()),
|
|
518
|
+
phone: v.optional(v.string()),
|
|
519
|
+
tags: v.optional(v.array(v.string())),
|
|
520
|
+
notes: v.optional(v.string()),
|
|
521
|
+
// B2B Billing fields (DEPRECATED - use addresses array with type="billing")
|
|
522
|
+
billingAddress: v.optional(v.object({
|
|
523
|
+
street: v.optional(v.string()),
|
|
524
|
+
city: v.optional(v.string()),
|
|
525
|
+
state: v.optional(v.string()),
|
|
526
|
+
postalCode: v.optional(v.string()),
|
|
527
|
+
country: v.optional(v.string()),
|
|
528
|
+
})),
|
|
529
|
+
legalEntityType: v.optional(v.string()), // "corporation", "llc", "partnership", "sole_proprietorship", "nonprofit"
|
|
530
|
+
registrationNumber: v.optional(v.string()), // Company registration number
|
|
531
|
+
vatNumber: v.optional(v.string()), // VAT/GST number
|
|
532
|
+
taxExempt: v.optional(v.boolean()),
|
|
533
|
+
paymentTerms: v.optional(v.string()), // "due_on_receipt", "net15", "net30", "net60", "net90"
|
|
534
|
+
creditLimit: v.optional(v.number()),
|
|
535
|
+
preferredPaymentMethod: v.optional(v.string()), // "invoice", "bank_transfer", "credit_card", "check"
|
|
536
|
+
accountingReference: v.optional(v.string()), // External accounting system reference
|
|
537
|
+
costCenter: v.optional(v.string()),
|
|
538
|
+
purchaseOrderRequired: v.optional(v.boolean()),
|
|
539
|
+
billingContact: v.optional(v.string()), // Name of billing contact
|
|
540
|
+
billingContactEmail: v.optional(v.string()),
|
|
541
|
+
billingContactPhone: v.optional(v.string()),
|
|
542
|
+
customFields: v.optional(v.any()),
|
|
543
|
+
},
|
|
544
|
+
handler: async (ctx, args) => {
|
|
545
|
+
const session = await ctx.db
|
|
546
|
+
.query("sessions")
|
|
547
|
+
.filter((q) => q.eq(q.field("_id"), args.sessionId))
|
|
548
|
+
.first();
|
|
549
|
+
|
|
550
|
+
if (!session) throw new Error("Invalid session");
|
|
551
|
+
|
|
552
|
+
// CHECK LICENSE LIMIT: Enforce CRM organization limit for organization's tier
|
|
553
|
+
// Free: 10, Starter: 50, Pro: 200, Agency: 500, Enterprise: Unlimited
|
|
554
|
+
await checkResourceLimit(ctx, args.organizationId, "crm_organization", "maxOrganizations");
|
|
555
|
+
|
|
556
|
+
// Handle addresses: convert old address/billingAddress format to new format if needed
|
|
557
|
+
let addresses = args.addresses;
|
|
558
|
+
if (!addresses) {
|
|
559
|
+
addresses = [];
|
|
560
|
+
// Convert old address field to mailing address
|
|
561
|
+
if (args.address) {
|
|
562
|
+
addresses.push({
|
|
563
|
+
type: "mailing" as const,
|
|
564
|
+
isPrimary: true,
|
|
565
|
+
label: "Primary Address",
|
|
566
|
+
...args.address,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
// Convert old billingAddress field to billing address
|
|
570
|
+
if (args.billingAddress) {
|
|
571
|
+
addresses.push({
|
|
572
|
+
type: "billing" as const,
|
|
573
|
+
isPrimary: true,
|
|
574
|
+
label: "Billing Address",
|
|
575
|
+
...args.billingAddress,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const orgId = await ctx.db.insert("objects", {
|
|
581
|
+
organizationId: args.organizationId,
|
|
582
|
+
type: "crm_organization",
|
|
583
|
+
subtype: args.subtype,
|
|
584
|
+
name: args.name,
|
|
585
|
+
description: `${args.industry || "Company"} organization`,
|
|
586
|
+
status: "active",
|
|
587
|
+
customProperties: {
|
|
588
|
+
// Basic info
|
|
589
|
+
website: args.website,
|
|
590
|
+
industry: args.industry,
|
|
591
|
+
size: args.size,
|
|
592
|
+
// Keep old fields for backward compatibility
|
|
593
|
+
address: args.address,
|
|
594
|
+
billingAddress: args.billingAddress,
|
|
595
|
+
// Add new addresses array
|
|
596
|
+
addresses: addresses,
|
|
597
|
+
phone: args.phone,
|
|
598
|
+
tags: args.tags || [],
|
|
599
|
+
notes: args.notes,
|
|
600
|
+
// Basic billing
|
|
601
|
+
taxId: args.taxId,
|
|
602
|
+
billingEmail: args.billingEmail,
|
|
603
|
+
// B2B Billing
|
|
604
|
+
legalEntityType: args.legalEntityType,
|
|
605
|
+
registrationNumber: args.registrationNumber,
|
|
606
|
+
vatNumber: args.vatNumber,
|
|
607
|
+
taxExempt: args.taxExempt || false,
|
|
608
|
+
paymentTerms: args.paymentTerms || "net30",
|
|
609
|
+
creditLimit: args.creditLimit,
|
|
610
|
+
preferredPaymentMethod: args.preferredPaymentMethod,
|
|
611
|
+
accountingReference: args.accountingReference,
|
|
612
|
+
costCenter: args.costCenter,
|
|
613
|
+
purchaseOrderRequired: args.purchaseOrderRequired || false,
|
|
614
|
+
billingContact: args.billingContact,
|
|
615
|
+
billingContactEmail: args.billingContactEmail,
|
|
616
|
+
billingContactPhone: args.billingContactPhone,
|
|
617
|
+
...args.customFields,
|
|
618
|
+
},
|
|
619
|
+
createdBy: session.userId,
|
|
620
|
+
createdAt: Date.now(),
|
|
621
|
+
updatedAt: Date.now(),
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// Log creation action
|
|
625
|
+
await ctx.db.insert("objectActions", {
|
|
626
|
+
organizationId: args.organizationId,
|
|
627
|
+
objectId: orgId,
|
|
628
|
+
actionType: "created",
|
|
629
|
+
actionData: {
|
|
630
|
+
subtype: args.subtype,
|
|
631
|
+
},
|
|
632
|
+
performedBy: session.userId,
|
|
633
|
+
performedAt: Date.now(),
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
return orgId;
|
|
637
|
+
},
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* UPDATE CRM ORGANIZATION
|
|
642
|
+
* Update an existing CRM organization (including subtype/org type)
|
|
643
|
+
*/
|
|
644
|
+
export const updateCrmOrganization = mutation({
|
|
645
|
+
args: {
|
|
646
|
+
sessionId: v.string(),
|
|
647
|
+
crmOrganizationId: v.id("objects"),
|
|
648
|
+
updates: v.object({
|
|
649
|
+
name: v.optional(v.string()),
|
|
650
|
+
subtype: v.optional(v.string()), // "customer" | "prospect" | "partner" | "sponsor"
|
|
651
|
+
website: v.optional(v.string()),
|
|
652
|
+
industry: v.optional(v.string()),
|
|
653
|
+
size: v.optional(v.string()),
|
|
654
|
+
// BACKWARD COMPATIBLE: Support old single address field
|
|
655
|
+
address: v.optional(v.any()),
|
|
656
|
+
// NEW: Support multiple addresses
|
|
657
|
+
addresses: v.optional(addressesValidator),
|
|
658
|
+
phone: v.optional(v.string()),
|
|
659
|
+
status: v.optional(v.string()),
|
|
660
|
+
tags: v.optional(v.array(v.string())),
|
|
661
|
+
notes: v.optional(v.string()),
|
|
662
|
+
// Basic billing
|
|
663
|
+
taxId: v.optional(v.string()),
|
|
664
|
+
billingEmail: v.optional(v.string()),
|
|
665
|
+
// B2B Billing fields (DEPRECATED - use addresses array)
|
|
666
|
+
billingAddress: v.optional(v.any()),
|
|
667
|
+
legalEntityType: v.optional(v.string()),
|
|
668
|
+
registrationNumber: v.optional(v.string()),
|
|
669
|
+
vatNumber: v.optional(v.string()),
|
|
670
|
+
taxExempt: v.optional(v.boolean()),
|
|
671
|
+
paymentTerms: v.optional(v.string()),
|
|
672
|
+
creditLimit: v.optional(v.number()),
|
|
673
|
+
preferredPaymentMethod: v.optional(v.string()),
|
|
674
|
+
accountingReference: v.optional(v.string()),
|
|
675
|
+
costCenter: v.optional(v.string()),
|
|
676
|
+
purchaseOrderRequired: v.optional(v.boolean()),
|
|
677
|
+
billingContact: v.optional(v.string()),
|
|
678
|
+
billingContactEmail: v.optional(v.string()),
|
|
679
|
+
billingContactPhone: v.optional(v.string()),
|
|
680
|
+
customFields: v.optional(v.any()),
|
|
681
|
+
}),
|
|
682
|
+
},
|
|
683
|
+
handler: async (ctx, args) => {
|
|
684
|
+
const session = await ctx.db
|
|
685
|
+
.query("sessions")
|
|
686
|
+
.filter((q) => q.eq(q.field("_id"), args.sessionId))
|
|
687
|
+
.first();
|
|
688
|
+
|
|
689
|
+
if (!session) throw new Error("Invalid session");
|
|
690
|
+
|
|
691
|
+
const org = await ctx.db.get(args.crmOrganizationId);
|
|
692
|
+
if (!org || org.type !== "crm_organization") {
|
|
693
|
+
throw new Error("CRM organization not found");
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
await ctx.db.patch(args.crmOrganizationId, {
|
|
697
|
+
name: args.updates.name || org.name,
|
|
698
|
+
subtype: args.updates.subtype || org.subtype,
|
|
699
|
+
status: args.updates.status || org.status,
|
|
700
|
+
customProperties: {
|
|
701
|
+
...org.customProperties,
|
|
702
|
+
...args.updates,
|
|
703
|
+
...args.updates.customFields,
|
|
704
|
+
},
|
|
705
|
+
updatedAt: Date.now(),
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
// Log update action
|
|
709
|
+
await ctx.db.insert("objectActions", {
|
|
710
|
+
organizationId: org.organizationId,
|
|
711
|
+
objectId: args.crmOrganizationId,
|
|
712
|
+
actionType: "updated",
|
|
713
|
+
actionData: {
|
|
714
|
+
updatedFields: Object.keys(args.updates),
|
|
715
|
+
},
|
|
716
|
+
performedBy: session.userId,
|
|
717
|
+
performedAt: Date.now(),
|
|
718
|
+
});
|
|
719
|
+
},
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* DELETE CRM ORGANIZATION
|
|
724
|
+
* Permanently delete a CRM organization and all associated links
|
|
725
|
+
*/
|
|
726
|
+
export const deleteCrmOrganization = mutation({
|
|
727
|
+
args: {
|
|
728
|
+
sessionId: v.string(),
|
|
729
|
+
crmOrganizationId: v.id("objects"),
|
|
730
|
+
},
|
|
731
|
+
handler: async (ctx, args) => {
|
|
732
|
+
const session = await ctx.db
|
|
733
|
+
.query("sessions")
|
|
734
|
+
.filter((q) => q.eq(q.field("_id"), args.sessionId))
|
|
735
|
+
.first();
|
|
736
|
+
|
|
737
|
+
if (!session) throw new Error("Invalid session");
|
|
738
|
+
|
|
739
|
+
const org = await ctx.db.get(args.crmOrganizationId);
|
|
740
|
+
if (!org || org.type !== "crm_organization") {
|
|
741
|
+
throw new Error("CRM organization not found");
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Log deletion action BEFORE deleting (so we have the data)
|
|
745
|
+
await ctx.db.insert("objectActions", {
|
|
746
|
+
organizationId: org.organizationId,
|
|
747
|
+
objectId: args.crmOrganizationId,
|
|
748
|
+
actionType: "deleted",
|
|
749
|
+
actionData: {
|
|
750
|
+
organizationName: org.name,
|
|
751
|
+
deletedBy: session.userId,
|
|
752
|
+
},
|
|
753
|
+
performedBy: session.userId,
|
|
754
|
+
performedAt: Date.now(),
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
// Delete all links involving this organization
|
|
758
|
+
const linksFrom = await ctx.db
|
|
759
|
+
.query("objectLinks")
|
|
760
|
+
.withIndex("by_from_object", (q) => q.eq("fromObjectId", args.crmOrganizationId))
|
|
761
|
+
.collect();
|
|
762
|
+
|
|
763
|
+
const linksTo = await ctx.db
|
|
764
|
+
.query("objectLinks")
|
|
765
|
+
.withIndex("by_to_object", (q) => q.eq("toObjectId", args.crmOrganizationId))
|
|
766
|
+
.collect();
|
|
767
|
+
|
|
768
|
+
// Delete all links
|
|
769
|
+
for (const link of [...linksFrom, ...linksTo]) {
|
|
770
|
+
await ctx.db.delete(link._id);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Permanently delete the organization
|
|
774
|
+
await ctx.db.delete(args.crmOrganizationId);
|
|
775
|
+
},
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
// ============================================================================
|
|
779
|
+
// RELATIONSHIP OPERATIONS
|
|
780
|
+
// ============================================================================
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* LINK CONTACT TO ORGANIZATION
|
|
784
|
+
* Create a relationship between a contact and a CRM organization
|
|
785
|
+
*/
|
|
786
|
+
export const linkContactToOrganization = mutation({
|
|
787
|
+
args: {
|
|
788
|
+
sessionId: v.string(),
|
|
789
|
+
contactId: v.id("objects"),
|
|
790
|
+
crmOrganizationId: v.id("objects"),
|
|
791
|
+
jobTitle: v.optional(v.string()),
|
|
792
|
+
isPrimaryContact: v.optional(v.boolean()),
|
|
793
|
+
department: v.optional(v.string()),
|
|
794
|
+
},
|
|
795
|
+
handler: async (ctx, args) => {
|
|
796
|
+
const session = await ctx.db
|
|
797
|
+
.query("sessions")
|
|
798
|
+
.filter((q) => q.eq(q.field("_id"), args.sessionId))
|
|
799
|
+
.first();
|
|
800
|
+
|
|
801
|
+
if (!session) throw new Error("Invalid session");
|
|
802
|
+
|
|
803
|
+
// Validate objects exist
|
|
804
|
+
const contact = await ctx.db.get(args.contactId);
|
|
805
|
+
const org = await ctx.db.get(args.crmOrganizationId);
|
|
806
|
+
|
|
807
|
+
if (!contact || contact.type !== "crm_contact") {
|
|
808
|
+
throw new Error("Invalid contact");
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (!org || org.type !== "crm_organization") {
|
|
812
|
+
throw new Error("Invalid CRM organization");
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Create link
|
|
816
|
+
const linkId = await ctx.db.insert("objectLinks", {
|
|
817
|
+
organizationId: contact.organizationId,
|
|
818
|
+
fromObjectId: args.contactId,
|
|
819
|
+
toObjectId: args.crmOrganizationId,
|
|
820
|
+
linkType: "works_at",
|
|
821
|
+
properties: {
|
|
822
|
+
jobTitle: args.jobTitle,
|
|
823
|
+
isPrimaryContact: args.isPrimaryContact ?? false,
|
|
824
|
+
department: args.department,
|
|
825
|
+
},
|
|
826
|
+
createdBy: session.userId,
|
|
827
|
+
createdAt: Date.now(),
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
return linkId;
|
|
831
|
+
},
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* GET ORGANIZATION CONTACTS
|
|
836
|
+
* Get all contacts for a CRM organization
|
|
837
|
+
*/
|
|
838
|
+
export const getOrganizationContacts = query({
|
|
839
|
+
args: {
|
|
840
|
+
sessionId: v.string(),
|
|
841
|
+
crmOrganizationId: v.id("objects"),
|
|
842
|
+
},
|
|
843
|
+
handler: async (ctx, args) => {
|
|
844
|
+
await requireAuthenticatedUser(ctx, args.sessionId);
|
|
845
|
+
|
|
846
|
+
// Get all links where toObjectId = organization
|
|
847
|
+
const links = await ctx.db
|
|
848
|
+
.query("objectLinks")
|
|
849
|
+
.withIndex("by_to_object", (q) => q.eq("toObjectId", args.crmOrganizationId))
|
|
850
|
+
.collect();
|
|
851
|
+
|
|
852
|
+
const worksAtLinks = links.filter((l) => l.linkType === "works_at");
|
|
853
|
+
|
|
854
|
+
// Get contact objects
|
|
855
|
+
const contacts = await Promise.all(
|
|
856
|
+
worksAtLinks.map(async (link) => {
|
|
857
|
+
const contact = await ctx.db.get(link.fromObjectId);
|
|
858
|
+
return {
|
|
859
|
+
...contact,
|
|
860
|
+
relationship: link.properties,
|
|
861
|
+
linkId: link._id,
|
|
862
|
+
};
|
|
863
|
+
})
|
|
864
|
+
);
|
|
865
|
+
|
|
866
|
+
return contacts;
|
|
867
|
+
},
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* GET CONTACT ORGANIZATIONS
|
|
872
|
+
* Get all organizations for a contact
|
|
873
|
+
*/
|
|
874
|
+
export const getContactOrganizations = query({
|
|
875
|
+
args: {
|
|
876
|
+
sessionId: v.string(),
|
|
877
|
+
contactId: v.id("objects"),
|
|
878
|
+
},
|
|
879
|
+
handler: async (ctx, args) => {
|
|
880
|
+
await requireAuthenticatedUser(ctx, args.sessionId);
|
|
881
|
+
|
|
882
|
+
// Get all links where fromObjectId = contact
|
|
883
|
+
const links = await ctx.db
|
|
884
|
+
.query("objectLinks")
|
|
885
|
+
.withIndex("by_from_object", (q) => q.eq("fromObjectId", args.contactId))
|
|
886
|
+
.collect();
|
|
887
|
+
|
|
888
|
+
const worksAtLinks = links.filter((l) => l.linkType === "works_at");
|
|
889
|
+
|
|
890
|
+
// Get organization objects
|
|
891
|
+
const organizations = await Promise.all(
|
|
892
|
+
worksAtLinks.map(async (link) => {
|
|
893
|
+
const org = await ctx.db.get(link.toObjectId);
|
|
894
|
+
return {
|
|
895
|
+
...org,
|
|
896
|
+
relationship: link.properties,
|
|
897
|
+
linkId: link._id,
|
|
898
|
+
};
|
|
899
|
+
})
|
|
900
|
+
);
|
|
901
|
+
|
|
902
|
+
return organizations;
|
|
903
|
+
},
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
// ============================================================================
|
|
907
|
+
// PORTAL INVITATION CONVENIENCE METHODS
|
|
908
|
+
// ============================================================================
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* INVITE CONTACT TO PORTAL
|
|
912
|
+
*
|
|
913
|
+
* Convenience method to invite a CRM contact to an external portal.
|
|
914
|
+
* Wrapper around portalInvitations.createPortalInvitation.
|
|
915
|
+
*
|
|
916
|
+
* Example:
|
|
917
|
+
* - Invite freelancer to project portal
|
|
918
|
+
* - Invite client to dashboard
|
|
919
|
+
* - Invite vendor to supplier portal
|
|
920
|
+
*/
|
|
921
|
+
export const inviteContactToPortal = mutation({
|
|
922
|
+
args: {
|
|
923
|
+
sessionId: v.string(),
|
|
924
|
+
contactId: v.id("objects"),
|
|
925
|
+
portalType: v.union(
|
|
926
|
+
v.literal("freelancer_portal"),
|
|
927
|
+
v.literal("client_portal"),
|
|
928
|
+
v.literal("vendor_portal"),
|
|
929
|
+
v.literal("custom_portal")
|
|
930
|
+
),
|
|
931
|
+
portalUrl: v.string(),
|
|
932
|
+
authMethod: v.optional(v.union(
|
|
933
|
+
v.literal("oauth"),
|
|
934
|
+
v.literal("magic_link"),
|
|
935
|
+
v.literal("both")
|
|
936
|
+
)),
|
|
937
|
+
expiresInDays: v.optional(v.number()),
|
|
938
|
+
customMessage: v.optional(v.string()),
|
|
939
|
+
},
|
|
940
|
+
handler: async (ctx, args) => {
|
|
941
|
+
const session = await ctx.db
|
|
942
|
+
.query("sessions")
|
|
943
|
+
.filter((q) => q.eq(q.field("_id"), args.sessionId))
|
|
944
|
+
.first();
|
|
945
|
+
|
|
946
|
+
if (!session) throw new Error("Invalid session");
|
|
947
|
+
|
|
948
|
+
// Get contact to verify it exists and get organizationId
|
|
949
|
+
const contact = await ctx.db.get(args.contactId);
|
|
950
|
+
if (!contact || contact.type !== "crm_contact") {
|
|
951
|
+
throw new Error("Contact not found");
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Generate unique invitation token
|
|
955
|
+
const invitationToken = crypto.randomUUID();
|
|
956
|
+
|
|
957
|
+
// Calculate expiration
|
|
958
|
+
const expiresInMs = (args.expiresInDays || 7) * 24 * 60 * 60 * 1000;
|
|
959
|
+
const expiresAt = Date.now() + expiresInMs;
|
|
960
|
+
|
|
961
|
+
const contactEmail = contact.customProperties?.email as string;
|
|
962
|
+
if (!contactEmail) {
|
|
963
|
+
throw new Error("Contact must have an email address");
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Create portal_invitation object
|
|
967
|
+
const invitationId = await ctx.db.insert("objects", {
|
|
968
|
+
organizationId: contact.organizationId,
|
|
969
|
+
type: "portal_invitation",
|
|
970
|
+
subtype: args.portalType,
|
|
971
|
+
name: `Portal Invitation - ${contact.name}`,
|
|
972
|
+
description: `Invitation to ${args.portalType} for ${contact.name}`,
|
|
973
|
+
status: "pending",
|
|
974
|
+
customProperties: {
|
|
975
|
+
contactId: args.contactId,
|
|
976
|
+
contactEmail: contactEmail,
|
|
977
|
+
portalType: args.portalType,
|
|
978
|
+
portalUrl: args.portalUrl,
|
|
979
|
+
authMethod: args.authMethod || "both",
|
|
980
|
+
invitationToken: invitationToken,
|
|
981
|
+
expiresAt: expiresAt,
|
|
982
|
+
customMessage: args.customMessage,
|
|
983
|
+
permissions: [],
|
|
984
|
+
sentAt: Date.now(),
|
|
985
|
+
acceptedAt: null,
|
|
986
|
+
lastAccessedAt: null,
|
|
987
|
+
accessCount: 0,
|
|
988
|
+
},
|
|
989
|
+
createdBy: session.userId,
|
|
990
|
+
createdAt: Date.now(),
|
|
991
|
+
updatedAt: Date.now(),
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
// Link invitation to contact
|
|
995
|
+
await ctx.db.insert("objectLinks", {
|
|
996
|
+
organizationId: contact.organizationId,
|
|
997
|
+
fromObjectId: invitationId,
|
|
998
|
+
toObjectId: args.contactId,
|
|
999
|
+
linkType: "invites",
|
|
1000
|
+
properties: {
|
|
1001
|
+
portalType: args.portalType,
|
|
1002
|
+
invitedAt: Date.now(),
|
|
1003
|
+
},
|
|
1004
|
+
createdBy: session.userId,
|
|
1005
|
+
createdAt: Date.now(),
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
// Schedule invitation email
|
|
1009
|
+
await ctx.scheduler.runAfter(0, internal.portalInvitations.sendInvitationEmail, {
|
|
1010
|
+
invitationId,
|
|
1011
|
+
contactEmail,
|
|
1012
|
+
portalUrl: args.portalUrl,
|
|
1013
|
+
authMethod: args.authMethod || "both",
|
|
1014
|
+
invitationToken,
|
|
1015
|
+
customMessage: args.customMessage,
|
|
1016
|
+
organizationId: contact.organizationId,
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
return {
|
|
1020
|
+
invitationId,
|
|
1021
|
+
invitationToken,
|
|
1022
|
+
expiresAt,
|
|
1023
|
+
};
|
|
1024
|
+
},
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* GET CONTACT PORTAL ACCESS
|
|
1029
|
+
*
|
|
1030
|
+
* Returns all portal invitations for a contact (active, pending, expired).
|
|
1031
|
+
*/
|
|
1032
|
+
export const getContactPortalAccess = query({
|
|
1033
|
+
args: {
|
|
1034
|
+
sessionId: v.string(),
|
|
1035
|
+
contactId: v.id("objects"),
|
|
1036
|
+
},
|
|
1037
|
+
handler: async (ctx, args) => {
|
|
1038
|
+
await requireAuthenticatedUser(ctx, args.sessionId);
|
|
1039
|
+
|
|
1040
|
+
// Get all portal invitations linked to this contact
|
|
1041
|
+
const links = await ctx.db
|
|
1042
|
+
.query("objectLinks")
|
|
1043
|
+
.withIndex("by_to_object", (q) => q.eq("toObjectId", args.contactId))
|
|
1044
|
+
.filter((q) => q.eq(q.field("linkType"), "invites"))
|
|
1045
|
+
.collect();
|
|
1046
|
+
|
|
1047
|
+
// Fetch invitation objects
|
|
1048
|
+
const invitations = await Promise.all(
|
|
1049
|
+
links.map(async (link) => {
|
|
1050
|
+
const invitation = await ctx.db.get(link.fromObjectId);
|
|
1051
|
+
if (invitation && invitation.type === "portal_invitation") {
|
|
1052
|
+
return {
|
|
1053
|
+
...invitation,
|
|
1054
|
+
linkId: link._id,
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
return null;
|
|
1058
|
+
})
|
|
1059
|
+
);
|
|
1060
|
+
|
|
1061
|
+
return invitations.filter((inv) => inv !== null);
|
|
1062
|
+
},
|
|
1063
|
+
});
|