@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,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
|
+
});
|