@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,1183 @@
1
+ /**
2
+ * EVENT ONTOLOGY
3
+ *
4
+ * Manages events as containers/aggregators that reference other objects.
5
+ * Uses the universal ontology system (objects table).
6
+ *
7
+ * Event Types (subtype):
8
+ * - "conference" - Multi-day conferences
9
+ * - "workshop" - Single or multi-session workshops
10
+ * - "concert" - Music/performance events
11
+ * - "meetup" - Casual networking events
12
+ *
13
+ * Status Workflow:
14
+ * - "draft" - Being planned
15
+ * - "published" - Public and accepting registrations
16
+ * - "in_progress" - Event is happening now
17
+ * - "completed" - Event has ended
18
+ * - "cancelled" - Event was cancelled
19
+ *
20
+ * Event Features:
21
+ * - ✅ Agenda/Schedule management (customProperties.agenda)
22
+ * - ✅ Sponsor management (objectLinks with linkType "sponsors")
23
+ * - ✅ Product/Ticket offerings (objectLinks with linkType "offers")
24
+ * - ⏳ Capacity limits (add when events fill up)
25
+ * - ⏳ Attendee management (add when needed)
26
+ */
27
+
28
+ import { query, mutation, internalQuery } from "./_generated/server";
29
+ import { v } from "convex/values";
30
+ import { Id } from "./_generated/dataModel";
31
+ import { requireAuthenticatedUser } from "./rbacHelpers";
32
+ import { checkResourceLimit, checkFeatureAccess } from "./licensing/helpers";
33
+
34
+ /**
35
+ * GET EVENTS
36
+ * Returns all events for an organization
37
+ */
38
+ export const getEvents = query({
39
+ args: {
40
+ sessionId: v.string(),
41
+ organizationId: v.id("organizations"),
42
+ subtype: v.optional(v.string()), // Filter by event type
43
+ status: v.optional(v.string()), // Filter by status
44
+ },
45
+ handler: async (ctx, args) => {
46
+ await requireAuthenticatedUser(ctx, args.sessionId);
47
+
48
+ const q = ctx.db
49
+ .query("objects")
50
+ .withIndex("by_org_type", (q) =>
51
+ q.eq("organizationId", args.organizationId).eq("type", "event")
52
+ );
53
+
54
+ let events = await q.collect();
55
+
56
+ // Apply filters
57
+ if (args.subtype) {
58
+ events = events.filter((e) => e.subtype === args.subtype);
59
+ }
60
+
61
+ if (args.status) {
62
+ events = events.filter((e) => e.status === args.status);
63
+ }
64
+
65
+ return events;
66
+ },
67
+ });
68
+
69
+ /**
70
+ * GET EVENT
71
+ * Get a single event by ID
72
+ */
73
+ export const getEvent = query({
74
+ args: {
75
+ sessionId: v.string(),
76
+ eventId: v.id("objects"),
77
+ },
78
+ handler: async (ctx, args) => {
79
+ await requireAuthenticatedUser(ctx, args.sessionId);
80
+
81
+ const event = await ctx.db.get(args.eventId);
82
+
83
+ if (!event || !("type" in event) || event.type !== "event") {
84
+ throw new Error("Event not found");
85
+ }
86
+
87
+ return event;
88
+ },
89
+ });
90
+
91
+ /**
92
+ * CREATE EVENT
93
+ * Create a new event
94
+ */
95
+ export const createEvent = mutation({
96
+ args: {
97
+ sessionId: v.string(),
98
+ organizationId: v.id("organizations"),
99
+ subtype: v.string(), // "conference" | "workshop" | "concert" | "meetup"
100
+ name: v.string(),
101
+ description: v.optional(v.string()),
102
+ startDate: v.number(), // Unix timestamp
103
+ endDate: v.number(), // Unix timestamp
104
+ location: v.string(), // "San Francisco Convention Center"
105
+ customProperties: v.optional(v.record(v.string(), v.any())),
106
+ },
107
+ handler: async (ctx, args) => {
108
+ const { userId } = await requireAuthenticatedUser(ctx, args.sessionId);
109
+
110
+ // CHECK LICENSE LIMIT: Enforce event limit for organization's tier
111
+ // Free: 3, Starter: 20, Pro: 100, Agency: Unlimited, Enterprise: Unlimited
112
+ await checkResourceLimit(ctx, args.organizationId, "event", "maxEvents");
113
+
114
+ // Validate subtype
115
+ const validSubtypes = ["conference", "workshop", "concert", "meetup", "seminar"];
116
+ if (!validSubtypes.includes(args.subtype)) {
117
+ throw new Error(
118
+ `Invalid event subtype. Must be one of: ${validSubtypes.join(", ")}`
119
+ );
120
+ }
121
+
122
+ // Validate dates
123
+ if (args.endDate < args.startDate) {
124
+ throw new Error("End date must be after start date");
125
+ }
126
+
127
+ // Generate slug from event name
128
+ const slug = args.name
129
+ .toLowerCase()
130
+ .trim()
131
+ .replace(/[^a-z0-9]+/g, "-") // Replace non-alphanumeric with dashes
132
+ .replace(/^-+|-+$/g, ""); // Remove leading/trailing dashes
133
+
134
+ // Build customProperties with event data
135
+ const customProperties = {
136
+ slug, // Add slug for URL-friendly event access
137
+ startDate: args.startDate,
138
+ endDate: args.endDate,
139
+ location: args.location,
140
+ timezone: "America/Los_Angeles", // Default timezone (gravel road)
141
+ agenda: [], // Event schedule/agenda array
142
+ maxCapacity: null, // Optional capacity limit
143
+ ...(args.customProperties || {}),
144
+ };
145
+
146
+ // Create event object
147
+ const eventId = await ctx.db.insert("objects", {
148
+ organizationId: args.organizationId,
149
+ type: "event",
150
+ subtype: args.subtype,
151
+ name: args.name,
152
+ description: args.description,
153
+ status: "draft", // Start as draft
154
+ customProperties,
155
+ createdBy: userId,
156
+ createdAt: Date.now(),
157
+ updatedAt: Date.now(),
158
+ });
159
+
160
+ return eventId;
161
+ },
162
+ });
163
+
164
+ /**
165
+ * UPDATE EVENT
166
+ * Update an existing event
167
+ */
168
+ export const updateEvent = mutation({
169
+ args: {
170
+ sessionId: v.string(),
171
+ eventId: v.id("objects"),
172
+ subtype: v.optional(v.string()), // Allow updating event type
173
+ name: v.optional(v.string()),
174
+ description: v.optional(v.string()),
175
+ startDate: v.optional(v.number()),
176
+ endDate: v.optional(v.number()),
177
+ location: v.optional(v.string()),
178
+ status: v.optional(v.string()), // "draft" | "published" | "in_progress" | "completed" | "cancelled"
179
+ customProperties: v.optional(v.record(v.string(), v.any())),
180
+ },
181
+ handler: async (ctx, args) => {
182
+ await requireAuthenticatedUser(ctx, args.sessionId);
183
+
184
+ const event = await ctx.db.get(args.eventId);
185
+
186
+ if (!event || !("type" in event) || event.type !== "event") {
187
+ throw new Error("Event not found");
188
+ }
189
+
190
+ // Build update object
191
+ const updates: Record<string, unknown> = {
192
+ updatedAt: Date.now(),
193
+ };
194
+
195
+ // If name is being updated, regenerate slug
196
+ let newSlug: string | undefined;
197
+ if (args.name !== undefined) {
198
+ updates.name = args.name;
199
+ newSlug = args.name
200
+ .toLowerCase()
201
+ .trim()
202
+ .replace(/[^a-z0-9]+/g, "-") // Replace non-alphanumeric with dashes
203
+ .replace(/^-+|-+$/g, ""); // Remove leading/trailing dashes
204
+ }
205
+ if (args.description !== undefined) updates.description = args.description;
206
+ if (args.subtype !== undefined) {
207
+ const validSubtypes = ["conference", "workshop", "concert", "meetup", "seminar"];
208
+ if (!validSubtypes.includes(args.subtype)) {
209
+ throw new Error(
210
+ `Invalid event subtype. Must be one of: ${validSubtypes.join(", ")}`
211
+ );
212
+ }
213
+ updates.subtype = args.subtype;
214
+ }
215
+ if (args.status !== undefined) {
216
+ const validStatuses = ["draft", "published", "in_progress", "completed", "cancelled"];
217
+ if (!validStatuses.includes(args.status)) {
218
+ throw new Error(
219
+ `Invalid status. Must be one of: ${validStatuses.join(", ")}`
220
+ );
221
+ }
222
+ updates.status = args.status;
223
+ }
224
+
225
+ // Update customProperties
226
+ if (args.startDate !== undefined || args.endDate !== undefined || args.location !== undefined || args.customProperties || newSlug) {
227
+ const currentProps = event.customProperties || {};
228
+
229
+ // Validate date changes
230
+ const newStartDate = args.startDate ?? currentProps.startDate;
231
+ const newEndDate = args.endDate ?? currentProps.endDate;
232
+ if (newEndDate < newStartDate) {
233
+ throw new Error("End date must be after start date");
234
+ }
235
+
236
+ updates.customProperties = {
237
+ ...currentProps,
238
+ ...(newSlug && { slug: newSlug }), // Update slug if name changed
239
+ ...(args.startDate !== undefined && { startDate: args.startDate }),
240
+ ...(args.endDate !== undefined && { endDate: args.endDate }),
241
+ ...(args.location !== undefined && { location: args.location }),
242
+ ...(args.customProperties || {}),
243
+ };
244
+ }
245
+
246
+ await ctx.db.patch(args.eventId, updates);
247
+
248
+ return args.eventId;
249
+ },
250
+ });
251
+
252
+ /**
253
+ * CANCEL EVENT
254
+ * Soft delete an event (set status to cancelled)
255
+ * Renamed from deleteEvent for clarity
256
+ */
257
+ export const cancelEvent = mutation({
258
+ args: {
259
+ sessionId: v.string(),
260
+ eventId: v.id("objects"),
261
+ },
262
+ handler: async (ctx, args) => {
263
+ await requireAuthenticatedUser(ctx, args.sessionId);
264
+
265
+ const event = await ctx.db.get(args.eventId);
266
+
267
+ if (!event || !("type" in event) || event.type !== "event") {
268
+ throw new Error("Event not found");
269
+ }
270
+
271
+ await ctx.db.patch(args.eventId, {
272
+ status: "cancelled",
273
+ updatedAt: Date.now(),
274
+ });
275
+
276
+ return { success: true };
277
+ },
278
+ });
279
+
280
+ /**
281
+ * DELETE EVENT
282
+ * Permanently delete an event (hard delete)
283
+ */
284
+ export const deleteEvent = mutation({
285
+ args: {
286
+ sessionId: v.string(),
287
+ eventId: v.id("objects"),
288
+ },
289
+ handler: async (ctx, args) => {
290
+ await requireAuthenticatedUser(ctx, args.sessionId);
291
+
292
+ const event = await ctx.db.get(args.eventId);
293
+
294
+ if (!event || !("type" in event) || event.type !== "event") {
295
+ throw new Error("Event not found");
296
+ }
297
+
298
+ // Delete the event permanently
299
+ await ctx.db.delete(args.eventId);
300
+
301
+ return { success: true };
302
+ },
303
+ });
304
+
305
+ /**
306
+ * PUBLISH EVENT
307
+ * Set event status to "published" (make it public)
308
+ */
309
+ export const publishEvent = mutation({
310
+ args: {
311
+ sessionId: v.string(),
312
+ eventId: v.id("objects"),
313
+ },
314
+ handler: async (ctx, args) => {
315
+ await requireAuthenticatedUser(ctx, args.sessionId);
316
+
317
+ const event = await ctx.db.get(args.eventId);
318
+
319
+ if (!event || !("type" in event) || event.type !== "event") {
320
+ throw new Error("Event not found");
321
+ }
322
+
323
+ await ctx.db.patch(args.eventId, {
324
+ status: "published",
325
+ updatedAt: Date.now(),
326
+ });
327
+
328
+ return { success: true };
329
+ },
330
+ });
331
+
332
+ /**
333
+ * LINK PRODUCT TO EVENT
334
+ * Create objectLink: event --[offers]--> product
335
+ */
336
+ export const linkProductToEvent = mutation({
337
+ args: {
338
+ sessionId: v.string(),
339
+ eventId: v.id("objects"),
340
+ productId: v.id("objects"),
341
+ displayOrder: v.optional(v.number()),
342
+ isFeatured: v.optional(v.boolean()),
343
+ },
344
+ handler: async (ctx, args) => {
345
+ await requireAuthenticatedUser(ctx, args.sessionId);
346
+
347
+ // Validate event exists
348
+ const event = await ctx.db.get(args.eventId);
349
+ if (!event || !("type" in event) || event.type !== "event") {
350
+ throw new Error("Event not found");
351
+ }
352
+
353
+ // Validate product exists
354
+ const product = await ctx.db.get(args.productId);
355
+ if (!product || !("type" in product) || product.type !== "product") {
356
+ throw new Error("Product not found");
357
+ }
358
+
359
+ // Check if link already exists
360
+ const existingLinks = await ctx.db
361
+ .query("objectLinks")
362
+ .withIndex("by_from_object", (q) => q.eq("fromObjectId", args.eventId))
363
+ .collect();
364
+
365
+ const existingLink = existingLinks.find(
366
+ (link) => link.toObjectId === args.productId && link.linkType === "offers"
367
+ );
368
+
369
+ if (existingLink) {
370
+ throw new Error("Product is already linked to this event");
371
+ }
372
+
373
+ // Create link
374
+ const linkId = await ctx.db.insert("objectLinks", {
375
+ organizationId: event.organizationId,
376
+ fromObjectId: args.eventId,
377
+ toObjectId: args.productId,
378
+ linkType: "offers",
379
+ properties: {
380
+ displayOrder: args.displayOrder ?? 0,
381
+ isFeatured: args.isFeatured ?? false,
382
+ },
383
+ createdAt: Date.now(),
384
+ });
385
+
386
+ return linkId;
387
+ },
388
+ });
389
+
390
+ /**
391
+ * GET PRODUCTS BY EVENT
392
+ * Get all products offered by an event
393
+ */
394
+ export const getProductsByEvent = query({
395
+ args: {
396
+ sessionId: v.string(),
397
+ eventId: v.id("objects"),
398
+ },
399
+ handler: async (ctx, args) => {
400
+ await requireAuthenticatedUser(ctx, args.sessionId);
401
+
402
+ // Get all links where event is the source with linkType "offers"
403
+ const links = await ctx.db
404
+ .query("objectLinks")
405
+ .withIndex("by_from_link_type", (q) =>
406
+ q.eq("fromObjectId", args.eventId).eq("linkType", "offers")
407
+ )
408
+ .collect();
409
+
410
+ // Get all products from these links
411
+ const products = [];
412
+ for (const link of links) {
413
+ const product = await ctx.db.get(link.toObjectId);
414
+ if (product && ("type" in product) && product.type === "product") {
415
+ products.push({
416
+ ...product,
417
+ linkProperties: link.properties,
418
+ });
419
+ }
420
+ }
421
+
422
+ // Sort by displayOrder
423
+ products.sort((a, b) => {
424
+ const orderA = a.linkProperties?.displayOrder ?? 0;
425
+ const orderB = b.linkProperties?.displayOrder ?? 0;
426
+ return orderA - orderB;
427
+ });
428
+
429
+ return products;
430
+ },
431
+ });
432
+
433
+ /**
434
+ * GET TICKETS BY EVENT
435
+ * Get all tickets that admit to a specific event
436
+ * (Uses the ticketOntology's linkType "admits_to")
437
+ */
438
+ export const getTicketsByEvent = query({
439
+ args: {
440
+ sessionId: v.string(),
441
+ eventId: v.id("objects"),
442
+ },
443
+ handler: async (ctx, args) => {
444
+ await requireAuthenticatedUser(ctx, args.sessionId);
445
+
446
+ // Get all links where event is the target with linkType "admits_to"
447
+ const links = await ctx.db
448
+ .query("objectLinks")
449
+ .withIndex("by_to_link_type", (q) =>
450
+ q.eq("toObjectId", args.eventId).eq("linkType", "admits_to")
451
+ )
452
+ .collect();
453
+
454
+ // Get all tickets from these links
455
+ const tickets = [];
456
+ for (const link of links) {
457
+ const ticket = await ctx.db.get(link.fromObjectId);
458
+ if (ticket && ("type" in ticket) && ticket.type === "ticket") {
459
+ tickets.push({
460
+ ...ticket,
461
+ linkProperties: link.properties,
462
+ });
463
+ }
464
+ }
465
+
466
+ return tickets;
467
+ },
468
+ });
469
+
470
+ /**
471
+ * LINK SPONSOR TO EVENT
472
+ * Create objectLink: event --[sponsored_by]--> crm_organization
473
+ * Allows marking CRM organizations as sponsors for an event
474
+ */
475
+ export const linkSponsorToEvent = mutation({
476
+ args: {
477
+ sessionId: v.string(),
478
+ eventId: v.id("objects"),
479
+ crmOrganizationId: v.id("objects"),
480
+ sponsorLevel: v.optional(v.string()), // "platinum", "gold", "silver", "bronze", "community"
481
+ displayOrder: v.optional(v.number()),
482
+ logoUrl: v.optional(v.string()),
483
+ websiteUrl: v.optional(v.string()),
484
+ description: v.optional(v.string()),
485
+ },
486
+ handler: async (ctx, args) => {
487
+ await requireAuthenticatedUser(ctx, args.sessionId);
488
+
489
+ // Validate event exists
490
+ const event = await ctx.db.get(args.eventId);
491
+ if (!event || !("type" in event) || event.type !== "event") {
492
+ throw new Error("Event not found");
493
+ }
494
+
495
+ // Validate CRM organization exists
496
+ const crmOrg = await ctx.db.get(args.crmOrganizationId);
497
+ if (!crmOrg || !("type" in crmOrg) || crmOrg.type !== "crm_organization") {
498
+ throw new Error("CRM organization not found");
499
+ }
500
+
501
+ // Check if link already exists
502
+ const existingLinks = await ctx.db
503
+ .query("objectLinks")
504
+ .withIndex("by_from_object", (q) => q.eq("fromObjectId", args.eventId))
505
+ .collect();
506
+
507
+ const existingLink = existingLinks.find(
508
+ (link) => link.toObjectId === args.crmOrganizationId && link.linkType === "sponsored_by"
509
+ );
510
+
511
+ if (existingLink) {
512
+ throw new Error("This organization is already a sponsor for this event");
513
+ }
514
+
515
+ // CHECK LICENSE LIMIT: Enforce sponsor limit per event
516
+ const { checkNestedResourceLimit } = await import("./licensing/helpers");
517
+ await checkNestedResourceLimit(
518
+ ctx,
519
+ event.organizationId,
520
+ args.eventId,
521
+ "sponsored_by",
522
+ "maxSponsorsPerEvent"
523
+ );
524
+
525
+ // Update CRM organization's sponsor level to stay consistent
526
+ const sponsorLevelValue = args.sponsorLevel ?? "community";
527
+ await ctx.db.patch(args.crmOrganizationId, {
528
+ customProperties: {
529
+ ...crmOrg.customProperties,
530
+ sponsorLevel: sponsorLevelValue,
531
+ },
532
+ updatedAt: Date.now(),
533
+ });
534
+
535
+ // Create sponsor link
536
+ const linkId = await ctx.db.insert("objectLinks", {
537
+ organizationId: event.organizationId,
538
+ fromObjectId: args.eventId,
539
+ toObjectId: args.crmOrganizationId,
540
+ linkType: "sponsored_by",
541
+ properties: {
542
+ sponsorLevel: sponsorLevelValue,
543
+ displayOrder: args.displayOrder ?? 0,
544
+ logoUrl: args.logoUrl,
545
+ websiteUrl: args.websiteUrl,
546
+ description: args.description,
547
+ },
548
+ createdAt: Date.now(),
549
+ });
550
+
551
+ return linkId;
552
+ },
553
+ });
554
+
555
+ /**
556
+ * GET SPONSORS BY EVENT
557
+ * Get all CRM organizations sponsoring an event
558
+ */
559
+ export const getSponsorsByEvent = query({
560
+ args: {
561
+ sessionId: v.string(),
562
+ eventId: v.id("objects"),
563
+ sponsorLevel: v.optional(v.string()), // Filter by sponsor level
564
+ },
565
+ handler: async (ctx, args) => {
566
+ await requireAuthenticatedUser(ctx, args.sessionId);
567
+
568
+ // Get all sponsor links for this event
569
+ const links = await ctx.db
570
+ .query("objectLinks")
571
+ .withIndex("by_from_link_type", (q) =>
572
+ q.eq("fromObjectId", args.eventId).eq("linkType", "sponsored_by")
573
+ )
574
+ .collect();
575
+
576
+ // Filter by sponsor level if specified
577
+ let filteredLinks = links;
578
+ if (args.sponsorLevel) {
579
+ filteredLinks = links.filter(
580
+ (link) => link.properties?.sponsorLevel === args.sponsorLevel
581
+ );
582
+ }
583
+
584
+ // Get sponsor organizations
585
+ const sponsors = [];
586
+ for (const link of filteredLinks) {
587
+ const sponsor = await ctx.db.get(link.toObjectId);
588
+ if (sponsor && ("type" in sponsor) && sponsor.type === "crm_organization") {
589
+ sponsors.push({
590
+ ...sponsor,
591
+ sponsorshipProperties: link.properties,
592
+ });
593
+ }
594
+ }
595
+
596
+ // Sort by displayOrder
597
+ sponsors.sort((a, b) => {
598
+ const orderA = a.sponsorshipProperties?.displayOrder ?? 999;
599
+ const orderB = b.sponsorshipProperties?.displayOrder ?? 999;
600
+ return orderA - orderB;
601
+ });
602
+
603
+ return sponsors;
604
+ },
605
+ });
606
+
607
+ /**
608
+ * UNLINK SPONSOR FROM EVENT
609
+ * Remove a sponsor relationship from an event
610
+ */
611
+ export const unlinkSponsorFromEvent = mutation({
612
+ args: {
613
+ sessionId: v.string(),
614
+ eventId: v.id("objects"),
615
+ crmOrganizationId: v.id("objects"),
616
+ },
617
+ handler: async (ctx, args) => {
618
+ await requireAuthenticatedUser(ctx, args.sessionId);
619
+
620
+ // Find the sponsor link
621
+ const links = await ctx.db
622
+ .query("objectLinks")
623
+ .withIndex("by_from_link_type", (q) =>
624
+ q.eq("fromObjectId", args.eventId).eq("linkType", "sponsored_by")
625
+ )
626
+ .collect();
627
+
628
+ const sponsorLink = links.find(
629
+ (link) => link.toObjectId === args.crmOrganizationId
630
+ );
631
+
632
+ if (!sponsorLink) {
633
+ throw new Error("Sponsor link not found");
634
+ }
635
+
636
+ await ctx.db.delete(sponsorLink._id);
637
+
638
+ return { success: true };
639
+ },
640
+ });
641
+
642
+ /**
643
+ * UPDATE EVENT SPONSOR
644
+ * Update sponsor level and other properties for an event sponsor
645
+ * This also syncs the sponsor level to the CRM organization for consistency
646
+ */
647
+ export const updateEventSponsor = mutation({
648
+ args: {
649
+ sessionId: v.string(),
650
+ eventId: v.id("objects"),
651
+ crmOrganizationId: v.id("objects"),
652
+ sponsorLevel: v.optional(v.string()),
653
+ displayOrder: v.optional(v.number()),
654
+ logoUrl: v.optional(v.string()),
655
+ websiteUrl: v.optional(v.string()),
656
+ description: v.optional(v.string()),
657
+ },
658
+ handler: async (ctx, args) => {
659
+ await requireAuthenticatedUser(ctx, args.sessionId);
660
+
661
+ // Find the sponsor link
662
+ const links = await ctx.db
663
+ .query("objectLinks")
664
+ .withIndex("by_from_link_type", (q) =>
665
+ q.eq("fromObjectId", args.eventId).eq("linkType", "sponsored_by")
666
+ )
667
+ .collect();
668
+
669
+ const sponsorLink = links.find(
670
+ (link) => link.toObjectId === args.crmOrganizationId
671
+ );
672
+
673
+ if (!sponsorLink) {
674
+ throw new Error("Sponsor link not found");
675
+ }
676
+
677
+ // Get the CRM organization
678
+ const crmOrg = await ctx.db.get(args.crmOrganizationId);
679
+ if (!crmOrg || !("type" in crmOrg) || crmOrg.type !== "crm_organization") {
680
+ throw new Error("CRM organization not found");
681
+ }
682
+
683
+ // Update the sponsor link with new properties
684
+ const updatedProperties = {
685
+ ...sponsorLink.properties,
686
+ ...(args.sponsorLevel && { sponsorLevel: args.sponsorLevel }),
687
+ ...(args.displayOrder !== undefined && { displayOrder: args.displayOrder }),
688
+ ...(args.logoUrl !== undefined && { logoUrl: args.logoUrl }),
689
+ ...(args.websiteUrl !== undefined && { websiteUrl: args.websiteUrl }),
690
+ ...(args.description !== undefined && { description: args.description }),
691
+ };
692
+
693
+ await ctx.db.patch(sponsorLink._id, {
694
+ properties: updatedProperties,
695
+ });
696
+
697
+ // If sponsor level changed, update the CRM organization to stay consistent
698
+ if (args.sponsorLevel) {
699
+ await ctx.db.patch(args.crmOrganizationId, {
700
+ customProperties: {
701
+ ...crmOrg.customProperties,
702
+ sponsorLevel: args.sponsorLevel,
703
+ },
704
+ updatedAt: Date.now(),
705
+ });
706
+ }
707
+
708
+ return { success: true };
709
+ },
710
+ });
711
+
712
+ /**
713
+ * UPDATE EVENT AGENDA
714
+ * Update the event's agenda/schedule
715
+ */
716
+ export const updateEventAgenda = mutation({
717
+ args: {
718
+ sessionId: v.string(),
719
+ eventId: v.id("objects"),
720
+ agenda: v.array(
721
+ v.object({
722
+ time: v.string(), // "09:00 AM" or ISO timestamp
723
+ title: v.string(),
724
+ description: v.optional(v.string()),
725
+ speaker: v.optional(v.string()),
726
+ location: v.optional(v.string()), // Room/venue within event
727
+ duration: v.optional(v.number()), // In minutes
728
+ })
729
+ ),
730
+ },
731
+ handler: async (ctx, args) => {
732
+ await requireAuthenticatedUser(ctx, args.sessionId);
733
+
734
+ const event = await ctx.db.get(args.eventId);
735
+
736
+ if (!event || !("type" in event) || event.type !== "event") {
737
+ throw new Error("Event not found");
738
+ }
739
+
740
+ const currentProps = event.customProperties || {};
741
+
742
+ await ctx.db.patch(args.eventId, {
743
+ customProperties: {
744
+ ...currentProps,
745
+ agenda: args.agenda,
746
+ },
747
+ updatedAt: Date.now(),
748
+ });
749
+
750
+ return { success: true };
751
+ },
752
+ });
753
+
754
+ /**
755
+ * GET EVENT INTERNAL
756
+ * Internal query for use in actions - get event by ID
757
+ */
758
+ export const getEventInternal = internalQuery({
759
+ args: {
760
+ eventId: v.id("objects"),
761
+ },
762
+ handler: async (ctx, args) => {
763
+ return await ctx.db.get(args.eventId);
764
+ },
765
+ });
766
+
767
+ /**
768
+ * LINK MEDIA TO EVENT
769
+ * Store media IDs in event's customProperties instead of using objectLinks
770
+ * (since objectLinks only works between objects table items)
771
+ */
772
+ export const linkMediaToEvent = mutation({
773
+ args: {
774
+ sessionId: v.string(),
775
+ eventId: v.id("objects"),
776
+ mediaId: v.id("organizationMedia"),
777
+ isPrimary: v.optional(v.boolean()),
778
+ displayOrder: v.optional(v.number()),
779
+ },
780
+ handler: async (ctx, args) => {
781
+ await requireAuthenticatedUser(ctx, args.sessionId);
782
+
783
+ // Validate event exists
784
+ const event = await ctx.db.get(args.eventId);
785
+ if (!event || !("type" in event) || event.type !== "event") {
786
+ throw new Error("Event not found");
787
+ }
788
+
789
+ // CHECK FEATURE ACCESS: Media gallery requires Starter+
790
+ const { checkFeatureAccess } = await import("./licensing/helpers");
791
+ await checkFeatureAccess(ctx, event.organizationId, "mediaGalleryEnabled");
792
+
793
+ // Validate media exists
794
+ const media = await ctx.db.get(args.mediaId);
795
+ if (!media) {
796
+ throw new Error("Media not found");
797
+ }
798
+
799
+ const currentProps = event.customProperties || {};
800
+ const currentMediaLinks = (currentProps.mediaLinks as Array<{
801
+ mediaId: string;
802
+ isPrimary: boolean;
803
+ displayOrder: number;
804
+ }>) || [];
805
+
806
+ // Check if media is already linked
807
+ const existingIndex = currentMediaLinks.findIndex(
808
+ (link) => link.mediaId === args.mediaId
809
+ );
810
+
811
+ if (existingIndex !== -1) {
812
+ // Update existing link
813
+ currentMediaLinks[existingIndex] = {
814
+ mediaId: args.mediaId,
815
+ isPrimary: args.isPrimary ?? currentMediaLinks[existingIndex].isPrimary,
816
+ displayOrder: args.displayOrder ?? currentMediaLinks[existingIndex].displayOrder,
817
+ };
818
+ } else {
819
+ // Add new link
820
+ currentMediaLinks.push({
821
+ mediaId: args.mediaId,
822
+ isPrimary: args.isPrimary ?? false,
823
+ displayOrder: args.displayOrder ?? currentMediaLinks.length,
824
+ });
825
+ }
826
+
827
+ // If this is marked as primary, un-primary all others
828
+ if (args.isPrimary) {
829
+ currentMediaLinks.forEach((link) => {
830
+ if (link.mediaId !== args.mediaId) {
831
+ link.isPrimary = false;
832
+ }
833
+ });
834
+ }
835
+
836
+ await ctx.db.patch(args.eventId, {
837
+ customProperties: {
838
+ ...currentProps,
839
+ mediaLinks: currentMediaLinks,
840
+ },
841
+ updatedAt: Date.now(),
842
+ });
843
+
844
+ return { success: true };
845
+ },
846
+ });
847
+
848
+ /**
849
+ * UNLINK MEDIA FROM EVENT
850
+ * Remove media link from event's customProperties
851
+ */
852
+ export const unlinkMediaFromEvent = mutation({
853
+ args: {
854
+ sessionId: v.string(),
855
+ eventId: v.id("objects"),
856
+ mediaId: v.id("organizationMedia"),
857
+ },
858
+ handler: async (ctx, args) => {
859
+ await requireAuthenticatedUser(ctx, args.sessionId);
860
+
861
+ const event = await ctx.db.get(args.eventId);
862
+ if (!event || !("type" in event) || event.type !== "event") {
863
+ throw new Error("Event not found");
864
+ }
865
+
866
+ const currentProps = event.customProperties || {};
867
+ const currentMediaLinks = (currentProps.mediaLinks as Array<{
868
+ mediaId: string;
869
+ isPrimary: boolean;
870
+ displayOrder: number;
871
+ }>) || [];
872
+
873
+ // Filter out the media link
874
+ const updatedMediaLinks = currentMediaLinks.filter(
875
+ (link) => link.mediaId !== args.mediaId
876
+ );
877
+
878
+ await ctx.db.patch(args.eventId, {
879
+ customProperties: {
880
+ ...currentProps,
881
+ mediaLinks: updatedMediaLinks,
882
+ },
883
+ updatedAt: Date.now(),
884
+ });
885
+
886
+ return { success: true };
887
+ },
888
+ });
889
+
890
+ /**
891
+ * GET EVENT MEDIA
892
+ * Get all media linked to an event from customProperties
893
+ */
894
+ export const getEventMedia = query({
895
+ args: {
896
+ sessionId: v.string(),
897
+ eventId: v.id("objects"),
898
+ },
899
+ handler: async (ctx, args) => {
900
+ await requireAuthenticatedUser(ctx, args.sessionId);
901
+
902
+ const event = await ctx.db.get(args.eventId);
903
+ if (!event || !("type" in event) || event.type !== "event") {
904
+ throw new Error("Event not found");
905
+ }
906
+
907
+ const currentProps = event.customProperties || {};
908
+ const mediaLinks = (currentProps.mediaLinks as Array<{
909
+ mediaId: string;
910
+ isPrimary: boolean;
911
+ displayOrder: number;
912
+ }>) || [];
913
+
914
+ // Get media objects with their URLs
915
+ const mediaItems = [];
916
+ for (const link of mediaLinks) {
917
+ const media = await ctx.db.get(link.mediaId as Id<"organizationMedia">);
918
+ if (media && "_id" in media && "_creationTime" in media) {
919
+ // Type guard to ensure we have an organizationMedia document
920
+ const url = await ctx.storage.getUrl(media.storageId as Id<"_storage">);
921
+ mediaItems.push({
922
+ _id: media._id,
923
+ organizationId: media.organizationId,
924
+ uploadedBy: media.uploadedBy,
925
+ storageId: media.storageId,
926
+ filename: media.filename,
927
+ mimeType: media.mimeType,
928
+ sizeBytes: media.sizeBytes,
929
+ width: media.width,
930
+ height: media.height,
931
+ category: media.category,
932
+ tags: media.tags,
933
+ description: media.description,
934
+ usageCount: media.usageCount,
935
+ lastUsedAt: media.lastUsedAt,
936
+ createdAt: media.createdAt,
937
+ updatedAt: media.updatedAt,
938
+ url,
939
+ isPrimary: link.isPrimary,
940
+ displayOrder: link.displayOrder,
941
+ });
942
+ }
943
+ }
944
+
945
+ // Sort by displayOrder, with primary first
946
+ mediaItems.sort((a, b) => {
947
+ if (a.isPrimary && !b.isPrimary) return -1;
948
+ if (!a.isPrimary && b.isPrimary) return 1;
949
+ return a.displayOrder - b.displayOrder;
950
+ });
951
+
952
+ return mediaItems;
953
+ },
954
+ });
955
+
956
+ /**
957
+ * UPDATE EVENT DETAILED DESCRIPTION
958
+ * Update the event's rich HTML description
959
+ */
960
+ export const updateEventDetailedDescription = mutation({
961
+ args: {
962
+ sessionId: v.string(),
963
+ eventId: v.id("objects"),
964
+ detailedDescription: v.string(),
965
+ },
966
+ handler: async (ctx, args) => {
967
+ await requireAuthenticatedUser(ctx, args.sessionId);
968
+
969
+ const event = await ctx.db.get(args.eventId);
970
+
971
+ if (!event || !("type" in event) || event.type !== "event") {
972
+ throw new Error("Event not found");
973
+ }
974
+
975
+ const currentProps = event.customProperties || {};
976
+
977
+ await ctx.db.patch(args.eventId, {
978
+ customProperties: {
979
+ ...currentProps,
980
+ detailedDescription: args.detailedDescription,
981
+ },
982
+ updatedAt: Date.now(),
983
+ });
984
+
985
+ return { success: true };
986
+ },
987
+ });
988
+
989
+ /**
990
+ * UPDATE EVENT LOCATION WITH VALIDATION
991
+ * Updates event location and stores validated address data from Radar
992
+ */
993
+ export const updateEventLocationWithValidation = mutation({
994
+ args: {
995
+ sessionId: v.string(),
996
+ eventId: v.id("objects"),
997
+ location: v.string(),
998
+ formattedAddress: v.optional(v.string()),
999
+ latitude: v.optional(v.number()),
1000
+ longitude: v.optional(v.number()),
1001
+ directionsUrl: v.optional(v.string()),
1002
+ googleMapsUrl: v.optional(v.string()),
1003
+ confidence: v.optional(v.string()),
1004
+ },
1005
+ handler: async (ctx, args) => {
1006
+ await requireAuthenticatedUser(ctx, args.sessionId);
1007
+
1008
+ const event = await ctx.db.get(args.eventId);
1009
+
1010
+ if (!event || !("type" in event) || event.type !== "event") {
1011
+ throw new Error("Event not found");
1012
+ }
1013
+
1014
+ const currentProps = event.customProperties || {};
1015
+
1016
+ // Store validated address information
1017
+ const locationData: Record<string, unknown> = {
1018
+ location: args.location,
1019
+ };
1020
+
1021
+ if (args.formattedAddress) {
1022
+ locationData.formattedAddress = args.formattedAddress;
1023
+ locationData.latitude = args.latitude;
1024
+ locationData.longitude = args.longitude;
1025
+ locationData.directionsUrl = args.directionsUrl;
1026
+ locationData.googleMapsUrl = args.googleMapsUrl;
1027
+ locationData.addressConfidence = args.confidence;
1028
+ locationData.addressValidatedAt = Date.now();
1029
+ }
1030
+
1031
+ await ctx.db.patch(args.eventId, {
1032
+ customProperties: {
1033
+ ...currentProps,
1034
+ ...locationData,
1035
+ },
1036
+ updatedAt: Date.now(),
1037
+ });
1038
+
1039
+ return { success: true };
1040
+ },
1041
+ });
1042
+
1043
+ /**
1044
+ * UPDATE EVENT MEDIA
1045
+ * Update the event's media collection (images and videos)
1046
+ */
1047
+ export const updateEventMedia = mutation({
1048
+ args: {
1049
+ sessionId: v.string(),
1050
+ eventId: v.id("objects"),
1051
+ media: v.object({
1052
+ items: v.array(v.object({
1053
+ id: v.string(),
1054
+ type: v.union(v.literal('image'), v.literal('video')),
1055
+ // Image fields
1056
+ storageId: v.optional(v.string()),
1057
+ filename: v.optional(v.string()),
1058
+ mimeType: v.optional(v.string()),
1059
+ focusPoint: v.optional(v.object({
1060
+ x: v.number(),
1061
+ y: v.number(),
1062
+ })),
1063
+ // Video fields
1064
+ videoUrl: v.optional(v.string()),
1065
+ videoProvider: v.optional(v.union(
1066
+ v.literal('youtube'),
1067
+ v.literal('vimeo'),
1068
+ v.literal('other')
1069
+ )),
1070
+ loop: v.optional(v.boolean()),
1071
+ autostart: v.optional(v.boolean()),
1072
+ thumbnailStorageId: v.optional(v.string()),
1073
+ // Common fields
1074
+ caption: v.optional(v.string()),
1075
+ order: v.number(),
1076
+ })),
1077
+ primaryImageId: v.optional(v.string()),
1078
+ showVideoFirst: v.optional(v.boolean()),
1079
+ enableAnalytics: v.optional(v.boolean()), // ⚡ Professional+ feature: track media views/clicks
1080
+ }),
1081
+ },
1082
+ handler: async (ctx, args) => {
1083
+ await requireAuthenticatedUser(ctx, args.sessionId);
1084
+
1085
+ const event = await ctx.db.get(args.eventId);
1086
+
1087
+ if (!event || !("type" in event) || event.type !== "event") {
1088
+ throw new Error("Event not found");
1089
+ }
1090
+
1091
+ // CHECK FEATURE ACCESS: Media gallery requires Starter+
1092
+ await checkFeatureAccess(ctx, event.organizationId, "mediaGalleryEnabled");
1093
+
1094
+ // ⚡ PROFESSIONAL TIER: Event Analytics
1095
+ // Professional+ can enable analytics tracking on event media (views, interactions)
1096
+ if (args.media.enableAnalytics) {
1097
+ await checkFeatureAccess(ctx, event.organizationId, "eventAnalyticsEnabled");
1098
+ }
1099
+
1100
+ const currentProps = event.customProperties || {};
1101
+
1102
+ await ctx.db.patch(args.eventId, {
1103
+ customProperties: {
1104
+ ...currentProps,
1105
+ media: args.media,
1106
+ },
1107
+ updatedAt: Date.now(),
1108
+ });
1109
+
1110
+ return { success: true };
1111
+ },
1112
+ });
1113
+
1114
+ /**
1115
+ * GET EVENT ATTENDEES
1116
+ * Fetch all attendees (ticket holders) for an event
1117
+ * Returns tickets that are linked to this event via productId
1118
+ */
1119
+ export const getEventAttendees = query({
1120
+ args: {
1121
+ eventId: v.id("objects"),
1122
+ },
1123
+ handler: async (ctx, args) => {
1124
+ // Get the event to verify it exists and get organizationId
1125
+ const event = await ctx.db.get(args.eventId);
1126
+
1127
+ if (!event || !("type" in event) || event.type !== "event") {
1128
+ throw new Error("Event not found");
1129
+ }
1130
+
1131
+ // Find products linked to this event via objectLinks
1132
+ const productLinks = await ctx.db
1133
+ .query("objectLinks")
1134
+ .withIndex("by_from_object", (q) => q.eq("fromObjectId", args.eventId))
1135
+ .filter((q) => q.eq(q.field("linkType"), "offers"))
1136
+ .collect();
1137
+
1138
+ const productIds = productLinks.map(link => link.toObjectId);
1139
+
1140
+ if (productIds.length === 0) {
1141
+ return [];
1142
+ }
1143
+
1144
+ // Find all ticket instances for this event
1145
+ // Ticket instances have type="ticket" (NOT type="product")
1146
+ const allTickets = await ctx.db
1147
+ .query("objects")
1148
+ .withIndex("by_org_type", (q) =>
1149
+ q.eq("organizationId", event.organizationId).eq("type", "ticket")
1150
+ )
1151
+ .collect();
1152
+
1153
+ // Filter for tickets that match the product IDs and are not cancelled
1154
+ const tickets = allTickets.filter(ticket => {
1155
+ const props = ticket.customProperties || {};
1156
+ const ticketProductId = props.productId as string | undefined;
1157
+ const ticketStatus = ticket.status;
1158
+
1159
+ // Check if this ticket is for one of the event's products
1160
+ // and is not cancelled
1161
+ return ticketProductId &&
1162
+ productIds.some(id => id === ticketProductId) &&
1163
+ ticketStatus !== "cancelled";
1164
+ });
1165
+
1166
+ // Map to attendee format
1167
+ return tickets.map(ticket => {
1168
+ const props = ticket.customProperties || {};
1169
+ return {
1170
+ _id: ticket._id,
1171
+ holderName: props.holderName as string || "Unknown",
1172
+ holderEmail: props.holderEmail as string || "",
1173
+ holderPhone: props.holderPhone as string || "",
1174
+ ticketNumber: props.ticketNumber as string || "",
1175
+ ticketType: ticket.subtype || "standard",
1176
+ status: ticket.status || "issued",
1177
+ purchaseDate: props.purchaseDate as number || ticket.createdAt,
1178
+ pricePaid: props.pricePaid as number || 0,
1179
+ formResponses: props.formResponses as Record<string, unknown> || {},
1180
+ };
1181
+ });
1182
+ },
1183
+ });