@l4yercak3/cli 1.1.12 → 1.2.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.
@@ -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
+ });