@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,1401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FORMS ONTOLOGY
|
|
3
|
+
*
|
|
4
|
+
* Manages forms (templates) and form responses using the universal ontology system.
|
|
5
|
+
*
|
|
6
|
+
* KEY ARCHITECTURE DECISION:
|
|
7
|
+
* Forms are templates that COLLECT data, but the data is EMBEDDED into tickets
|
|
8
|
+
* for fast operational queries at events (QR scanning, reporting, check-in).
|
|
9
|
+
*
|
|
10
|
+
* Data Flow:
|
|
11
|
+
* 1. Form (template) → User fills form → FormResponse (audit trail)
|
|
12
|
+
* 2. FormResponse data → COPIED to ticket.registrationData (operational use)
|
|
13
|
+
* 3. Event managers query tickets directly (no joins needed)
|
|
14
|
+
*
|
|
15
|
+
* Form Types (subtype):
|
|
16
|
+
* - "registration" - Event registration forms (linked to tickets)
|
|
17
|
+
* - "survey" - Feedback surveys (standalone or post-event)
|
|
18
|
+
* - "application" - Speaker proposals, volunteer applications
|
|
19
|
+
*
|
|
20
|
+
* Form Status:
|
|
21
|
+
* - "draft" - Being built
|
|
22
|
+
* - "published" - Active and accepting submissions
|
|
23
|
+
* - "archived" - No longer accepting submissions
|
|
24
|
+
*
|
|
25
|
+
* FormResponse Status:
|
|
26
|
+
* - "partial" - Started but not completed
|
|
27
|
+
* - "complete" - Successfully submitted
|
|
28
|
+
* - "abandoned" - User left before completion
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { query, mutation, internalMutation, internalQuery } from "./_generated/server";
|
|
32
|
+
import { v } from "convex/values";
|
|
33
|
+
import { requireAuthenticatedUser, checkPermission } from "./rbacHelpers";
|
|
34
|
+
import { checkResourceLimit, checkFeatureAccess, getLicenseInternal } from "./licensing/helpers";
|
|
35
|
+
import { ConvexError } from "convex/values";
|
|
36
|
+
|
|
37
|
+
// Helper function for tier upgrade path
|
|
38
|
+
function getNextTier(
|
|
39
|
+
currentTier: "free" | "starter" | "professional" | "agency" | "enterprise"
|
|
40
|
+
): string {
|
|
41
|
+
const tierUpgradePath: Record<string, string> = {
|
|
42
|
+
free: "Starter (€199/month)",
|
|
43
|
+
starter: "Professional (€399/month)",
|
|
44
|
+
professional: "Agency (€599/month)",
|
|
45
|
+
agency: "Enterprise (€1,500+/month)",
|
|
46
|
+
enterprise: "Enterprise (contact sales)",
|
|
47
|
+
};
|
|
48
|
+
return tierUpgradePath[currentTier] || "a higher tier";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* FIELD TYPE DEFINITIONS
|
|
53
|
+
* These are the supported form field types
|
|
54
|
+
*/
|
|
55
|
+
export const FIELD_TYPES = {
|
|
56
|
+
TEXT: "text",
|
|
57
|
+
TEXTAREA: "textarea",
|
|
58
|
+
EMAIL: "email",
|
|
59
|
+
PHONE: "phone",
|
|
60
|
+
NUMBER: "number",
|
|
61
|
+
DATE: "date",
|
|
62
|
+
TIME: "time",
|
|
63
|
+
DATETIME: "datetime",
|
|
64
|
+
SELECT: "select",
|
|
65
|
+
RADIO: "radio",
|
|
66
|
+
CHECKBOX: "checkbox",
|
|
67
|
+
MULTI_SELECT: "multi_select",
|
|
68
|
+
FILE: "file",
|
|
69
|
+
RATING: "rating",
|
|
70
|
+
SECTION_HEADER: "section_header",
|
|
71
|
+
} as const;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* CONDITIONAL LOGIC OPERATORS
|
|
75
|
+
*/
|
|
76
|
+
export const OPERATORS = {
|
|
77
|
+
EQUALS: "equals",
|
|
78
|
+
NOT_EQUALS: "notEquals",
|
|
79
|
+
IN: "in",
|
|
80
|
+
NOT_IN: "notIn",
|
|
81
|
+
GREATER_THAN: "gt",
|
|
82
|
+
LESS_THAN: "lt",
|
|
83
|
+
CONTAINS: "contains",
|
|
84
|
+
IS_EMPTY: "isEmpty",
|
|
85
|
+
IS_NOT_EMPTY: "isNotEmpty",
|
|
86
|
+
} as const;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* GET FORMS
|
|
90
|
+
* Returns all forms for an organization
|
|
91
|
+
*/
|
|
92
|
+
export const getForms = query({
|
|
93
|
+
args: {
|
|
94
|
+
sessionId: v.string(),
|
|
95
|
+
organizationId: v.id("organizations"),
|
|
96
|
+
subtype: v.optional(v.string()), // Filter by form type
|
|
97
|
+
status: v.optional(v.string()), // Filter by status
|
|
98
|
+
},
|
|
99
|
+
handler: async (ctx, args) => {
|
|
100
|
+
await requireAuthenticatedUser(ctx, args.sessionId);
|
|
101
|
+
|
|
102
|
+
const q = ctx.db
|
|
103
|
+
.query("objects")
|
|
104
|
+
.withIndex("by_org_type", (q) =>
|
|
105
|
+
q.eq("organizationId", args.organizationId).eq("type", "form")
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
let forms = await q.collect();
|
|
109
|
+
|
|
110
|
+
// Apply filters
|
|
111
|
+
if (args.subtype) {
|
|
112
|
+
forms = forms.filter((f) => f.subtype === args.subtype);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (args.status) {
|
|
116
|
+
forms = forms.filter((f) => f.status === args.status);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return forms;
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* INTERNAL GET FORMS
|
|
125
|
+
* Internal query for AI tools and actions that already have organizationId
|
|
126
|
+
* This bypasses session authentication since the action layer is already authenticated
|
|
127
|
+
*/
|
|
128
|
+
export const internalGetForms = internalQuery({
|
|
129
|
+
args: {
|
|
130
|
+
organizationId: v.id("organizations"),
|
|
131
|
+
subtype: v.optional(v.string()), // Filter by form type
|
|
132
|
+
status: v.optional(v.string()), // Filter by status
|
|
133
|
+
},
|
|
134
|
+
handler: async (ctx, args) => {
|
|
135
|
+
const q = ctx.db
|
|
136
|
+
.query("objects")
|
|
137
|
+
.withIndex("by_org_type", (q) =>
|
|
138
|
+
q.eq("organizationId", args.organizationId).eq("type", "form")
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
let forms = await q.collect();
|
|
142
|
+
|
|
143
|
+
// Apply filters
|
|
144
|
+
if (args.subtype) {
|
|
145
|
+
forms = forms.filter((f) => f.subtype === args.subtype);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (args.status) {
|
|
149
|
+
forms = forms.filter((f) => f.status === args.status);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return forms;
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* GET FORM
|
|
158
|
+
* Get a single form by ID
|
|
159
|
+
*/
|
|
160
|
+
export const getForm = query({
|
|
161
|
+
args: {
|
|
162
|
+
sessionId: v.string(),
|
|
163
|
+
formId: v.id("objects"),
|
|
164
|
+
},
|
|
165
|
+
handler: async (ctx, args) => {
|
|
166
|
+
await requireAuthenticatedUser(ctx, args.sessionId);
|
|
167
|
+
|
|
168
|
+
const form = await ctx.db.get(args.formId);
|
|
169
|
+
|
|
170
|
+
if (!form || !("type" in form) || form.type !== "form") {
|
|
171
|
+
throw new Error("Form not found");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return form;
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* INTERNAL GET FORM
|
|
180
|
+
* Internal query for AI tools and actions that already have organizationId
|
|
181
|
+
*/
|
|
182
|
+
export const internalGetForm = internalQuery({
|
|
183
|
+
args: {
|
|
184
|
+
formId: v.id("objects"),
|
|
185
|
+
},
|
|
186
|
+
handler: async (ctx, args) => {
|
|
187
|
+
const form = await ctx.db.get(args.formId);
|
|
188
|
+
|
|
189
|
+
if (!form || !("type" in form) || form.type !== "form") {
|
|
190
|
+
throw new Error("Form not found");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return form;
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* GET PUBLIC FORM
|
|
199
|
+
* Get a single published form by ID (no authentication required)
|
|
200
|
+
* Used by public checkout pages
|
|
201
|
+
*/
|
|
202
|
+
export const getPublicForm = query({
|
|
203
|
+
args: {
|
|
204
|
+
formId: v.id("objects"),
|
|
205
|
+
},
|
|
206
|
+
handler: async (ctx, args) => {
|
|
207
|
+
const form = await ctx.db.get(args.formId);
|
|
208
|
+
|
|
209
|
+
if (!form || !("type" in form) || form.type !== "form") {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Only return published forms to public
|
|
214
|
+
if (form.status !== "published") {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return form;
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* CREATE FORM
|
|
224
|
+
* Create a new form template
|
|
225
|
+
*/
|
|
226
|
+
export const createForm = mutation({
|
|
227
|
+
args: {
|
|
228
|
+
sessionId: v.string(),
|
|
229
|
+
organizationId: v.id("organizations"),
|
|
230
|
+
subtype: v.string(), // "registration" | "survey" | "application"
|
|
231
|
+
name: v.string(),
|
|
232
|
+
description: v.optional(v.string()),
|
|
233
|
+
formSchema: v.any(), // Complex nested structure - validated in customProperties
|
|
234
|
+
eventId: v.optional(v.id("objects")), // Optional: Link form to a specific event
|
|
235
|
+
},
|
|
236
|
+
handler: async (ctx, args) => {
|
|
237
|
+
const { userId } = await requireAuthenticatedUser(ctx, args.sessionId);
|
|
238
|
+
|
|
239
|
+
// CHECK LICENSE LIMIT: Enforce form limit for organization's tier
|
|
240
|
+
// Free: 3, Starter: 20, Pro: 100, Agency: Unlimited, Enterprise: Unlimited
|
|
241
|
+
await checkResourceLimit(ctx, args.organizationId, "form", "maxForms");
|
|
242
|
+
|
|
243
|
+
// Validate event exists if provided
|
|
244
|
+
if (args.eventId) {
|
|
245
|
+
const event = await ctx.db.get(args.eventId);
|
|
246
|
+
if (!event || event.type !== "event") {
|
|
247
|
+
throw new Error("Event not found");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Create the form object
|
|
252
|
+
const now = Date.now();
|
|
253
|
+
const formId = await ctx.db.insert("objects", {
|
|
254
|
+
organizationId: args.organizationId,
|
|
255
|
+
type: "form",
|
|
256
|
+
subtype: args.subtype,
|
|
257
|
+
name: args.name,
|
|
258
|
+
description: args.description || "",
|
|
259
|
+
status: "draft",
|
|
260
|
+
customProperties: {
|
|
261
|
+
eventId: args.eventId, // Store event link
|
|
262
|
+
formSchema: args.formSchema || {
|
|
263
|
+
version: "1.0",
|
|
264
|
+
fields: [],
|
|
265
|
+
settings: {
|
|
266
|
+
allowMultipleSubmissions: false,
|
|
267
|
+
showProgressBar: true,
|
|
268
|
+
submitButtonText: "Submit",
|
|
269
|
+
successMessage: "Thank you for your submission!",
|
|
270
|
+
redirectUrl: null,
|
|
271
|
+
displayMode: "all", // "all" | "single-question" | "section-by-section" | "paginated"
|
|
272
|
+
},
|
|
273
|
+
sections: [],
|
|
274
|
+
},
|
|
275
|
+
stats: {
|
|
276
|
+
views: 0,
|
|
277
|
+
submissions: 0,
|
|
278
|
+
completionRate: 0,
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
createdBy: userId,
|
|
282
|
+
createdAt: now,
|
|
283
|
+
updatedAt: now,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Create objectLink: form --[form_for]--> event (if event provided)
|
|
287
|
+
if (args.eventId) {
|
|
288
|
+
await ctx.db.insert("objectLinks", {
|
|
289
|
+
organizationId: args.organizationId,
|
|
290
|
+
fromObjectId: formId,
|
|
291
|
+
toObjectId: args.eventId,
|
|
292
|
+
linkType: "form_for",
|
|
293
|
+
properties: {},
|
|
294
|
+
createdAt: now,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Log the action
|
|
299
|
+
await ctx.db.insert("objectActions", {
|
|
300
|
+
organizationId: args.organizationId,
|
|
301
|
+
objectId: formId,
|
|
302
|
+
actionType: "created",
|
|
303
|
+
performedBy: userId,
|
|
304
|
+
performedAt: Date.now(),
|
|
305
|
+
actionData: {
|
|
306
|
+
status: "draft",
|
|
307
|
+
eventId: args.eventId,
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
return formId;
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* UPDATE FORM
|
|
317
|
+
* Update an existing form
|
|
318
|
+
*/
|
|
319
|
+
export const updateForm = mutation({
|
|
320
|
+
args: {
|
|
321
|
+
sessionId: v.string(),
|
|
322
|
+
formId: v.id("objects"),
|
|
323
|
+
name: v.optional(v.string()),
|
|
324
|
+
description: v.optional(v.string()),
|
|
325
|
+
subtype: v.optional(v.string()), // Allow updating form type
|
|
326
|
+
formSchema: v.optional(v.any()),
|
|
327
|
+
status: v.optional(v.string()),
|
|
328
|
+
eventId: v.optional(v.union(v.id("objects"), v.null())), // Allow updating event link
|
|
329
|
+
publicUrl: v.optional(v.union(v.string(), v.null())), // Allow updating public URL
|
|
330
|
+
},
|
|
331
|
+
handler: async (ctx, args) => {
|
|
332
|
+
const { userId } = await requireAuthenticatedUser(ctx, args.sessionId);
|
|
333
|
+
|
|
334
|
+
const form = await ctx.db.get(args.formId);
|
|
335
|
+
if (!form || !("type" in form) || form.type !== "form") {
|
|
336
|
+
throw new Error("Form not found");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Validate event if provided
|
|
340
|
+
if (args.eventId !== undefined && args.eventId !== null) {
|
|
341
|
+
const event = await ctx.db.get(args.eventId);
|
|
342
|
+
if (!event || event.type !== "event") {
|
|
343
|
+
throw new Error("Event not found");
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const updates: Record<string, unknown> = {};
|
|
348
|
+
const changes: Record<string, unknown> = {};
|
|
349
|
+
|
|
350
|
+
if (args.name !== undefined) {
|
|
351
|
+
updates.name = args.name;
|
|
352
|
+
changes.name = { from: form.name, to: args.name };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (args.description !== undefined) {
|
|
356
|
+
updates.description = args.description;
|
|
357
|
+
changes.description = { from: form.description, to: args.description };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (args.subtype !== undefined) {
|
|
361
|
+
updates.subtype = args.subtype;
|
|
362
|
+
changes.subtype = { from: form.subtype, to: args.subtype };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (args.status !== undefined) {
|
|
366
|
+
updates.status = args.status;
|
|
367
|
+
changes.status = { from: form.status, to: args.status };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (args.formSchema !== undefined) {
|
|
371
|
+
// CHECK FEATURE ACCESS: Multi-step forms, conditional logic, and file uploads require Starter tier
|
|
372
|
+
const schema = args.formSchema;
|
|
373
|
+
|
|
374
|
+
// Check multi-step forms (displayMode != "all")
|
|
375
|
+
if (schema?.settings?.displayMode && schema.settings.displayMode !== "all") {
|
|
376
|
+
await checkFeatureAccess(ctx, form.organizationId, "multiStepFormsEnabled");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Check conditional logic (any field has conditions)
|
|
380
|
+
if (schema?.fields && Array.isArray(schema.fields)) {
|
|
381
|
+
const hasConditionalLogic = schema.fields.some((field: { conditionalLogic?: unknown }) =>
|
|
382
|
+
field.conditionalLogic !== undefined && field.conditionalLogic !== null
|
|
383
|
+
);
|
|
384
|
+
if (hasConditionalLogic) {
|
|
385
|
+
await checkFeatureAccess(ctx, form.organizationId, "conditionalLogicEnabled");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Check file uploads (any field is type "file")
|
|
389
|
+
const hasFileUploads = schema.fields.some((field: { type?: string }) =>
|
|
390
|
+
field.type === "file"
|
|
391
|
+
);
|
|
392
|
+
if (hasFileUploads) {
|
|
393
|
+
await checkFeatureAccess(ctx, form.organizationId, "fileUploadsEnabled");
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
updates.customProperties = {
|
|
398
|
+
...form.customProperties,
|
|
399
|
+
formSchema: args.formSchema,
|
|
400
|
+
};
|
|
401
|
+
changes.formSchema = "updated";
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Update eventId in customProperties
|
|
405
|
+
if (args.eventId !== undefined) {
|
|
406
|
+
updates.customProperties = {
|
|
407
|
+
...(updates.customProperties || form.customProperties),
|
|
408
|
+
eventId: args.eventId,
|
|
409
|
+
};
|
|
410
|
+
changes.eventId = { from: form.customProperties?.eventId, to: args.eventId };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Update publicUrl in customProperties
|
|
414
|
+
if (args.publicUrl !== undefined) {
|
|
415
|
+
updates.customProperties = {
|
|
416
|
+
...(updates.customProperties || form.customProperties),
|
|
417
|
+
publicUrl: args.publicUrl,
|
|
418
|
+
};
|
|
419
|
+
changes.publicUrl = { from: form.customProperties?.publicUrl, to: args.publicUrl };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (Object.keys(updates).length > 0) {
|
|
423
|
+
updates.updatedAt = Date.now();
|
|
424
|
+
await ctx.db.patch(args.formId, updates);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Handle event link updates
|
|
428
|
+
if (args.eventId !== undefined) {
|
|
429
|
+
// Delete existing event link
|
|
430
|
+
const existingLinks = await ctx.db
|
|
431
|
+
.query("objectLinks")
|
|
432
|
+
.filter((q) =>
|
|
433
|
+
q.and(
|
|
434
|
+
q.eq(q.field("fromObjectId"), args.formId),
|
|
435
|
+
q.eq(q.field("linkType"), "form_for")
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
.collect();
|
|
439
|
+
|
|
440
|
+
for (const link of existingLinks) {
|
|
441
|
+
await ctx.db.delete(link._id);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Create new event link if eventId is provided (not null)
|
|
445
|
+
if (args.eventId !== null) {
|
|
446
|
+
await ctx.db.insert("objectLinks", {
|
|
447
|
+
organizationId: form.organizationId,
|
|
448
|
+
fromObjectId: args.formId,
|
|
449
|
+
toObjectId: args.eventId,
|
|
450
|
+
linkType: "form_for",
|
|
451
|
+
properties: {},
|
|
452
|
+
createdAt: Date.now(),
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Log the action
|
|
458
|
+
await ctx.db.insert("objectActions", {
|
|
459
|
+
organizationId: form.organizationId,
|
|
460
|
+
objectId: args.formId,
|
|
461
|
+
actionType: "updated",
|
|
462
|
+
performedBy: userId,
|
|
463
|
+
performedAt: Date.now(),
|
|
464
|
+
actionData: changes,
|
|
465
|
+
});
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* DELETE FORM
|
|
471
|
+
* Permanently delete a form
|
|
472
|
+
*/
|
|
473
|
+
export const deleteForm = mutation({
|
|
474
|
+
args: {
|
|
475
|
+
sessionId: v.string(),
|
|
476
|
+
formId: v.id("objects"),
|
|
477
|
+
},
|
|
478
|
+
handler: async (ctx, args) => {
|
|
479
|
+
const { userId } = await requireAuthenticatedUser(ctx, args.sessionId);
|
|
480
|
+
|
|
481
|
+
const form = await ctx.db.get(args.formId);
|
|
482
|
+
if (!form || !("type" in form) || form.type !== "form") {
|
|
483
|
+
throw new Error("Form not found");
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Check permission
|
|
487
|
+
const hasPermission = await checkPermission(
|
|
488
|
+
ctx,
|
|
489
|
+
userId,
|
|
490
|
+
"manage_forms",
|
|
491
|
+
form.organizationId
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
if (!hasPermission) {
|
|
495
|
+
throw new Error("You do not have permission to delete forms");
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Log the action before deleting
|
|
499
|
+
await ctx.db.insert("objectActions", {
|
|
500
|
+
organizationId: form.organizationId,
|
|
501
|
+
objectId: args.formId,
|
|
502
|
+
actionType: "deleted",
|
|
503
|
+
performedBy: userId,
|
|
504
|
+
performedAt: Date.now(),
|
|
505
|
+
actionData: {
|
|
506
|
+
formName: form.name,
|
|
507
|
+
formType: form.subtype,
|
|
508
|
+
previousStatus: form.status,
|
|
509
|
+
},
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// Hard delete the form
|
|
513
|
+
await ctx.db.delete(args.formId);
|
|
514
|
+
|
|
515
|
+
return { success: true };
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* GET FORM RESPONSES
|
|
521
|
+
* Get all responses for a form
|
|
522
|
+
*/
|
|
523
|
+
export const getFormResponses = query({
|
|
524
|
+
args: {
|
|
525
|
+
sessionId: v.string(),
|
|
526
|
+
formId: v.id("objects"),
|
|
527
|
+
status: v.optional(v.string()), // Filter by completion status
|
|
528
|
+
},
|
|
529
|
+
handler: async (ctx, args) => {
|
|
530
|
+
await requireAuthenticatedUser(ctx, args.sessionId);
|
|
531
|
+
|
|
532
|
+
// Get all form responses
|
|
533
|
+
let responses = await ctx.db
|
|
534
|
+
.query("objects")
|
|
535
|
+
.withIndex("by_type", (q) => q.eq("type", "formResponse"))
|
|
536
|
+
.collect();
|
|
537
|
+
|
|
538
|
+
// Filter by formId
|
|
539
|
+
responses = responses.filter(
|
|
540
|
+
(r) => r.customProperties?.formId === args.formId
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
// Filter by status if provided
|
|
544
|
+
if (args.status) {
|
|
545
|
+
responses = responses.filter((r) => r.status === args.status);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return responses;
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* INTERNAL GET FORM RESPONSES
|
|
554
|
+
* Internal query for AI tools and actions that already have organizationId
|
|
555
|
+
*/
|
|
556
|
+
export const internalGetFormResponses = internalQuery({
|
|
557
|
+
args: {
|
|
558
|
+
formId: v.id("objects"),
|
|
559
|
+
status: v.optional(v.string()), // Filter by completion status
|
|
560
|
+
},
|
|
561
|
+
handler: async (ctx, args) => {
|
|
562
|
+
// Get all form responses
|
|
563
|
+
let responses = await ctx.db
|
|
564
|
+
.query("objects")
|
|
565
|
+
.withIndex("by_type", (q) => q.eq("type", "formResponse"))
|
|
566
|
+
.collect();
|
|
567
|
+
|
|
568
|
+
// Filter by formId
|
|
569
|
+
responses = responses.filter(
|
|
570
|
+
(r) => r.customProperties?.formId === args.formId
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
// Filter by status if provided
|
|
574
|
+
if (args.status) {
|
|
575
|
+
responses = responses.filter((r) => r.status === args.status);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return responses;
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* CREATE FORM RESPONSE
|
|
584
|
+
* Submit a form response (during checkout or standalone)
|
|
585
|
+
*/
|
|
586
|
+
export const createFormResponse = mutation({
|
|
587
|
+
args: {
|
|
588
|
+
sessionId: v.string(),
|
|
589
|
+
organizationId: v.id("organizations"),
|
|
590
|
+
formId: v.id("objects"),
|
|
591
|
+
responses: v.any(), // The actual form data (key-value pairs)
|
|
592
|
+
calculatedPricing: v.optional(v.any()), // Pricing data if applicable
|
|
593
|
+
metadata: v.optional(v.any()), // IP, userAgent, duration, etc.
|
|
594
|
+
},
|
|
595
|
+
handler: async (ctx, args) => {
|
|
596
|
+
const { userId } = await requireAuthenticatedUser(ctx, args.sessionId);
|
|
597
|
+
|
|
598
|
+
// Get the form to verify it exists
|
|
599
|
+
const form = await ctx.db.get(args.formId);
|
|
600
|
+
if (!form || !("type" in form) || form.type !== "form") {
|
|
601
|
+
throw new Error("Form not found");
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// CHECK LICENSE LIMIT: Enforce form response limit per form
|
|
605
|
+
const existingResponses = await ctx.db
|
|
606
|
+
.query("objects")
|
|
607
|
+
.withIndex("by_org_type", (q) =>
|
|
608
|
+
q.eq("organizationId", args.organizationId).eq("type", "formResponse")
|
|
609
|
+
)
|
|
610
|
+
.filter((q) => {
|
|
611
|
+
const customProps = q.field("customProperties") as { formId?: string } | undefined;
|
|
612
|
+
return customProps?.formId === args.formId;
|
|
613
|
+
})
|
|
614
|
+
.collect();
|
|
615
|
+
|
|
616
|
+
const responsesCount = existingResponses.length;
|
|
617
|
+
const license = await getLicenseInternal(ctx, args.organizationId);
|
|
618
|
+
const limit = license.limits.maxResponsesPerForm;
|
|
619
|
+
|
|
620
|
+
if (limit !== -1 && responsesCount >= limit) {
|
|
621
|
+
throw new ConvexError({
|
|
622
|
+
code: "LIMIT_EXCEEDED",
|
|
623
|
+
message: `You've reached your maxResponsesPerForm limit (${limit}). ` +
|
|
624
|
+
`Upgrade to ${getNextTier(license.planTier)} for more capacity.`,
|
|
625
|
+
limitKey: "maxResponsesPerForm",
|
|
626
|
+
currentCount: responsesCount,
|
|
627
|
+
limit,
|
|
628
|
+
planTier: license.planTier,
|
|
629
|
+
nextTier: getNextTier(license.planTier),
|
|
630
|
+
isNested: true,
|
|
631
|
+
parentId: args.formId,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Generate a name from responses (first + last name if available)
|
|
636
|
+
const firstName = args.responses.first_name || args.responses.firstName || "";
|
|
637
|
+
const lastName = args.responses.last_name || args.responses.lastName || "";
|
|
638
|
+
const responseName = firstName && lastName
|
|
639
|
+
? `Response from ${firstName} ${lastName}`
|
|
640
|
+
: `Response ${new Date().toLocaleDateString()}`;
|
|
641
|
+
|
|
642
|
+
// Create the form response
|
|
643
|
+
const now = Date.now();
|
|
644
|
+
const responseId = await ctx.db.insert("objects", {
|
|
645
|
+
organizationId: args.organizationId,
|
|
646
|
+
type: "formResponse",
|
|
647
|
+
subtype: form.subtype || "registration",
|
|
648
|
+
name: responseName,
|
|
649
|
+
description: `Form submission for ${form.name}`,
|
|
650
|
+
status: "complete",
|
|
651
|
+
customProperties: {
|
|
652
|
+
formId: args.formId,
|
|
653
|
+
responses: args.responses,
|
|
654
|
+
calculatedPricing: args.calculatedPricing,
|
|
655
|
+
submittedAt: now,
|
|
656
|
+
...(args.metadata || {}),
|
|
657
|
+
},
|
|
658
|
+
createdBy: userId,
|
|
659
|
+
createdAt: now,
|
|
660
|
+
updatedAt: now,
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// Update form stats
|
|
664
|
+
const currentStats = form.customProperties?.stats || {
|
|
665
|
+
views: 0,
|
|
666
|
+
submissions: 0,
|
|
667
|
+
completionRate: 0,
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
// ⚡ PROFESSIONAL TIER: Form Analytics
|
|
671
|
+
// Professional+ can track detailed form analytics (views, submissions, completion rates)
|
|
672
|
+
const hasFormAnalytics = form.customProperties?.enableAnalytics === true;
|
|
673
|
+
if (hasFormAnalytics) {
|
|
674
|
+
// Check if they have access to analytics
|
|
675
|
+
try {
|
|
676
|
+
await checkFeatureAccess(ctx, args.organizationId, "formAnalyticsEnabled");
|
|
677
|
+
} catch {
|
|
678
|
+
// If they don't have access, analytics won't be detailed
|
|
679
|
+
// Basic submission counting still works for all tiers
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
await ctx.db.patch(args.formId, {
|
|
684
|
+
customProperties: {
|
|
685
|
+
...form.customProperties,
|
|
686
|
+
stats: {
|
|
687
|
+
...currentStats,
|
|
688
|
+
submissions: currentStats.submissions + 1,
|
|
689
|
+
lastSubmittedAt: now,
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
updatedAt: Date.now(),
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// Log the action
|
|
696
|
+
await ctx.db.insert("objectActions", {
|
|
697
|
+
organizationId: args.organizationId,
|
|
698
|
+
objectId: responseId,
|
|
699
|
+
actionType: "created",
|
|
700
|
+
performedBy: userId,
|
|
701
|
+
performedAt: now,
|
|
702
|
+
actionData: {
|
|
703
|
+
status: "complete",
|
|
704
|
+
},
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
return responseId;
|
|
708
|
+
},
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* CREATE PUBLIC FORM RESPONSE (Internal)
|
|
713
|
+
* Submit a form response without authentication
|
|
714
|
+
* Used by public submission API endpoint
|
|
715
|
+
*/
|
|
716
|
+
export const createPublicFormResponse = internalMutation({
|
|
717
|
+
args: {
|
|
718
|
+
formId: v.id("objects"),
|
|
719
|
+
responses: v.any(), // The actual form data (key-value pairs)
|
|
720
|
+
metadata: v.optional(v.any()), // IP, userAgent, submittedAt, etc.
|
|
721
|
+
},
|
|
722
|
+
handler: async (ctx, args) => {
|
|
723
|
+
// Get the form to verify it exists and is published
|
|
724
|
+
const form = await ctx.db.get(args.formId);
|
|
725
|
+
if (!form || !("type" in form) || form.type !== "form") {
|
|
726
|
+
throw new Error("Form not found");
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Only allow submissions to published forms
|
|
730
|
+
if (form.status !== "published") {
|
|
731
|
+
throw new Error("Form is not published");
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// CHECK LICENSE LIMIT: Enforce form response limit per form
|
|
735
|
+
const existingResponses = await ctx.db
|
|
736
|
+
.query("objects")
|
|
737
|
+
.withIndex("by_org_type", (q) =>
|
|
738
|
+
q.eq("organizationId", form.organizationId).eq("type", "formResponse")
|
|
739
|
+
)
|
|
740
|
+
.filter((q) => {
|
|
741
|
+
const customProps = q.field("customProperties") as { formId?: string } | undefined;
|
|
742
|
+
return customProps?.formId === args.formId;
|
|
743
|
+
})
|
|
744
|
+
.collect();
|
|
745
|
+
|
|
746
|
+
const responsesCount = existingResponses.length;
|
|
747
|
+
const license = await getLicenseInternal(ctx, form.organizationId);
|
|
748
|
+
const limit = license.limits.maxResponsesPerForm;
|
|
749
|
+
|
|
750
|
+
if (limit !== -1 && responsesCount >= limit) {
|
|
751
|
+
throw new ConvexError({
|
|
752
|
+
code: "LIMIT_EXCEEDED",
|
|
753
|
+
message: `You've reached your maxResponsesPerForm limit (${limit}). ` +
|
|
754
|
+
`Upgrade to ${getNextTier(license.planTier)} for more capacity.`,
|
|
755
|
+
limitKey: "maxResponsesPerForm",
|
|
756
|
+
currentCount: responsesCount,
|
|
757
|
+
limit,
|
|
758
|
+
planTier: license.planTier,
|
|
759
|
+
nextTier: getNextTier(license.planTier),
|
|
760
|
+
isNested: true,
|
|
761
|
+
parentId: args.formId,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Generate a name from responses (first + last name if available)
|
|
766
|
+
const firstName = args.responses.first_name || args.responses.firstName || args.responses.contact_name || "";
|
|
767
|
+
const lastName = args.responses.last_name || args.responses.lastName || "";
|
|
768
|
+
const responseName = firstName && lastName
|
|
769
|
+
? `Response from ${firstName} ${lastName}`
|
|
770
|
+
: firstName
|
|
771
|
+
? `Response from ${firstName}`
|
|
772
|
+
: `Response ${new Date().toLocaleDateString()}`;
|
|
773
|
+
|
|
774
|
+
// Create the form response
|
|
775
|
+
const now = Date.now();
|
|
776
|
+
const responseId = await ctx.db.insert("objects", {
|
|
777
|
+
organizationId: form.organizationId,
|
|
778
|
+
type: "formResponse",
|
|
779
|
+
subtype: form.subtype || "registration",
|
|
780
|
+
name: responseName,
|
|
781
|
+
description: `Public form submission for ${form.name}`,
|
|
782
|
+
status: "complete",
|
|
783
|
+
customProperties: {
|
|
784
|
+
formId: args.formId,
|
|
785
|
+
responses: args.responses,
|
|
786
|
+
submittedAt: args.metadata?.submittedAt || now,
|
|
787
|
+
userAgent: args.metadata?.userAgent,
|
|
788
|
+
ipAddress: args.metadata?.ipAddress,
|
|
789
|
+
isPublicSubmission: true,
|
|
790
|
+
},
|
|
791
|
+
createdBy: undefined, // No user ID for public submissions
|
|
792
|
+
createdAt: now,
|
|
793
|
+
updatedAt: now,
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
// Update form stats
|
|
797
|
+
const currentStats = form.customProperties?.stats || {
|
|
798
|
+
views: 0,
|
|
799
|
+
submissions: 0,
|
|
800
|
+
completionRate: 0,
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
await ctx.db.patch(args.formId, {
|
|
804
|
+
customProperties: {
|
|
805
|
+
...form.customProperties,
|
|
806
|
+
stats: {
|
|
807
|
+
...currentStats,
|
|
808
|
+
submissions: currentStats.submissions + 1,
|
|
809
|
+
},
|
|
810
|
+
},
|
|
811
|
+
updatedAt: Date.now(),
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
// Log the action (no user, public submission)
|
|
815
|
+
await ctx.db.insert("objectActions", {
|
|
816
|
+
organizationId: form.organizationId,
|
|
817
|
+
objectId: responseId,
|
|
818
|
+
actionType: "created",
|
|
819
|
+
performedBy: undefined, // No user ID for public submissions
|
|
820
|
+
performedAt: now,
|
|
821
|
+
actionData: {
|
|
822
|
+
status: "complete",
|
|
823
|
+
isPublicSubmission: true,
|
|
824
|
+
},
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
return responseId;
|
|
828
|
+
},
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* GET FORM RESPONSE
|
|
833
|
+
* Get a single form response
|
|
834
|
+
*/
|
|
835
|
+
export const getFormResponse = query({
|
|
836
|
+
args: {
|
|
837
|
+
sessionId: v.string(),
|
|
838
|
+
responseId: v.id("objects"),
|
|
839
|
+
},
|
|
840
|
+
handler: async (ctx, args) => {
|
|
841
|
+
await requireAuthenticatedUser(ctx, args.sessionId);
|
|
842
|
+
|
|
843
|
+
const response = await ctx.db.get(args.responseId);
|
|
844
|
+
|
|
845
|
+
if (!response || !("type" in response) || response.type !== "formResponse") {
|
|
846
|
+
throw new Error("Form response not found");
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
return response;
|
|
850
|
+
},
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* LINK FORM TO TICKET
|
|
855
|
+
* Create an objectLink between a ticket/product and a form
|
|
856
|
+
*/
|
|
857
|
+
export const linkFormToTicket = mutation({
|
|
858
|
+
args: {
|
|
859
|
+
sessionId: v.string(),
|
|
860
|
+
ticketId: v.id("objects"),
|
|
861
|
+
formId: v.id("objects"),
|
|
862
|
+
timing: v.string(), // "duringCheckout" | "afterPurchase" | "standalone"
|
|
863
|
+
required: v.boolean(),
|
|
864
|
+
},
|
|
865
|
+
handler: async (ctx, args) => {
|
|
866
|
+
const { userId } = await requireAuthenticatedUser(ctx, args.sessionId);
|
|
867
|
+
|
|
868
|
+
// Verify both objects exist
|
|
869
|
+
const ticket = await ctx.db.get(args.ticketId);
|
|
870
|
+
const form = await ctx.db.get(args.formId);
|
|
871
|
+
|
|
872
|
+
if (!ticket || !("type" in ticket) || ticket.type !== "product") {
|
|
873
|
+
throw new Error("Ticket not found");
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (!form || !("type" in form) || form.type !== "form") {
|
|
877
|
+
throw new Error("Form not found");
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Create the link
|
|
881
|
+
const linkId = await ctx.db.insert("objectLinks", {
|
|
882
|
+
organizationId: ticket.organizationId,
|
|
883
|
+
fromObjectId: args.ticketId,
|
|
884
|
+
toObjectId: args.formId,
|
|
885
|
+
linkType: "requiresForm",
|
|
886
|
+
properties: {
|
|
887
|
+
timing: args.timing,
|
|
888
|
+
required: args.required,
|
|
889
|
+
},
|
|
890
|
+
createdBy: userId,
|
|
891
|
+
createdAt: Date.now(),
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
return linkId;
|
|
895
|
+
},
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* GET LINKED FORM FOR TICKET
|
|
900
|
+
* Get the form associated with a ticket/product
|
|
901
|
+
*/
|
|
902
|
+
export const getLinkedForm = query({
|
|
903
|
+
args: {
|
|
904
|
+
sessionId: v.string(),
|
|
905
|
+
ticketId: v.id("objects"),
|
|
906
|
+
},
|
|
907
|
+
handler: async (ctx, args) => {
|
|
908
|
+
await requireAuthenticatedUser(ctx, args.sessionId);
|
|
909
|
+
|
|
910
|
+
// Find the link
|
|
911
|
+
const links = await ctx.db
|
|
912
|
+
.query("objectLinks")
|
|
913
|
+
.withIndex("by_from_object", (q) => q.eq("fromObjectId", args.ticketId))
|
|
914
|
+
.collect();
|
|
915
|
+
|
|
916
|
+
const formLink = links.find((link) => link.linkType === "requiresForm");
|
|
917
|
+
|
|
918
|
+
if (!formLink) {
|
|
919
|
+
return null;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Get the form
|
|
923
|
+
const form = await ctx.db.get(formLink.toObjectId);
|
|
924
|
+
|
|
925
|
+
if (!form || !("type" in form) || form.type !== "form") {
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
return {
|
|
930
|
+
form,
|
|
931
|
+
linkProperties: formLink.properties,
|
|
932
|
+
};
|
|
933
|
+
},
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* PUBLISH FORM
|
|
938
|
+
* Changes form status from draft to published
|
|
939
|
+
*/
|
|
940
|
+
export const publishForm = mutation({
|
|
941
|
+
args: {
|
|
942
|
+
sessionId: v.string(),
|
|
943
|
+
formId: v.id("objects"),
|
|
944
|
+
},
|
|
945
|
+
handler: async (ctx, args) => {
|
|
946
|
+
const { userId } = await requireAuthenticatedUser(ctx, args.sessionId);
|
|
947
|
+
|
|
948
|
+
const form = await ctx.db.get(args.formId);
|
|
949
|
+
if (!form || !("type" in form) || form.type !== "form") {
|
|
950
|
+
throw new Error("Form not found");
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Check permission
|
|
954
|
+
const hasPermission = await checkPermission(
|
|
955
|
+
ctx,
|
|
956
|
+
userId,
|
|
957
|
+
"manage_forms",
|
|
958
|
+
form.organizationId
|
|
959
|
+
);
|
|
960
|
+
|
|
961
|
+
if (!hasPermission) {
|
|
962
|
+
throw new Error("Permission denied: manage_forms required to publish forms");
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// Update status to published
|
|
966
|
+
await ctx.db.patch(args.formId, {
|
|
967
|
+
status: "published",
|
|
968
|
+
updatedAt: Date.now(),
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
// Log the action
|
|
972
|
+
await ctx.db.insert("objectActions", {
|
|
973
|
+
organizationId: form.organizationId,
|
|
974
|
+
objectId: args.formId,
|
|
975
|
+
actionType: "form_published",
|
|
976
|
+
actionData: {},
|
|
977
|
+
performedBy: userId,
|
|
978
|
+
performedAt: Date.now(),
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
return { success: true };
|
|
982
|
+
},
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* UNPUBLISH FORM
|
|
987
|
+
* Changes form status from published back to draft
|
|
988
|
+
*/
|
|
989
|
+
export const unpublishForm = mutation({
|
|
990
|
+
args: {
|
|
991
|
+
sessionId: v.string(),
|
|
992
|
+
formId: v.id("objects"),
|
|
993
|
+
},
|
|
994
|
+
handler: async (ctx, args) => {
|
|
995
|
+
const { userId } = await requireAuthenticatedUser(ctx, args.sessionId);
|
|
996
|
+
|
|
997
|
+
const form = await ctx.db.get(args.formId);
|
|
998
|
+
if (!form || !("type" in form) || form.type !== "form") {
|
|
999
|
+
throw new Error("Form not found");
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// Check permission
|
|
1003
|
+
const hasPermission = await checkPermission(
|
|
1004
|
+
ctx,
|
|
1005
|
+
userId,
|
|
1006
|
+
"manage_forms",
|
|
1007
|
+
form.organizationId
|
|
1008
|
+
);
|
|
1009
|
+
|
|
1010
|
+
if (!hasPermission) {
|
|
1011
|
+
throw new Error("Permission denied: manage_forms required to unpublish forms");
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Update status to draft
|
|
1015
|
+
await ctx.db.patch(args.formId, {
|
|
1016
|
+
status: "draft",
|
|
1017
|
+
updatedAt: Date.now(),
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
// Log the action
|
|
1021
|
+
await ctx.db.insert("objectActions", {
|
|
1022
|
+
organizationId: form.organizationId,
|
|
1023
|
+
objectId: args.formId,
|
|
1024
|
+
actionType: "form_unpublished",
|
|
1025
|
+
actionData: {},
|
|
1026
|
+
performedBy: userId,
|
|
1027
|
+
performedAt: Date.now(),
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
return { success: true };
|
|
1031
|
+
},
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* DUPLICATE FORM
|
|
1036
|
+
* Create a copy of an existing form with "Copy of [name]"
|
|
1037
|
+
*/
|
|
1038
|
+
export const duplicateForm = mutation({
|
|
1039
|
+
args: {
|
|
1040
|
+
sessionId: v.string(),
|
|
1041
|
+
formId: v.id("objects"),
|
|
1042
|
+
},
|
|
1043
|
+
handler: async (ctx, args) => {
|
|
1044
|
+
const { userId } = await requireAuthenticatedUser(ctx, args.sessionId);
|
|
1045
|
+
|
|
1046
|
+
const form = await ctx.db.get(args.formId);
|
|
1047
|
+
if (!form || !("type" in form) || form.type !== "form") {
|
|
1048
|
+
throw new Error("Form not found");
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Check permission
|
|
1052
|
+
const hasPermission = await checkPermission(
|
|
1053
|
+
ctx,
|
|
1054
|
+
userId,
|
|
1055
|
+
"manage_forms",
|
|
1056
|
+
form.organizationId
|
|
1057
|
+
);
|
|
1058
|
+
|
|
1059
|
+
if (!hasPermission) {
|
|
1060
|
+
throw new Error("Permission denied: manage_forms required to duplicate forms");
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// Create the duplicated form
|
|
1064
|
+
const now = Date.now();
|
|
1065
|
+
const newFormId = await ctx.db.insert("objects", {
|
|
1066
|
+
organizationId: form.organizationId,
|
|
1067
|
+
type: "form",
|
|
1068
|
+
subtype: form.subtype,
|
|
1069
|
+
name: `Copy of ${form.name}`,
|
|
1070
|
+
description: form.description || "",
|
|
1071
|
+
status: "draft", // Always start as draft
|
|
1072
|
+
customProperties: {
|
|
1073
|
+
...form.customProperties,
|
|
1074
|
+
// Reset stats for the new form
|
|
1075
|
+
stats: {
|
|
1076
|
+
views: 0,
|
|
1077
|
+
submissions: 0,
|
|
1078
|
+
completionRate: 0,
|
|
1079
|
+
},
|
|
1080
|
+
},
|
|
1081
|
+
createdBy: userId,
|
|
1082
|
+
createdAt: now,
|
|
1083
|
+
updatedAt: now,
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
// If the original form has an event link, copy it
|
|
1087
|
+
const eventId = form.customProperties?.eventId;
|
|
1088
|
+
if (eventId) {
|
|
1089
|
+
await ctx.db.insert("objectLinks", {
|
|
1090
|
+
organizationId: form.organizationId,
|
|
1091
|
+
fromObjectId: newFormId,
|
|
1092
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1093
|
+
toObjectId: eventId as any,
|
|
1094
|
+
linkType: "form_for",
|
|
1095
|
+
properties: {},
|
|
1096
|
+
createdAt: now,
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// Log the action
|
|
1101
|
+
await ctx.db.insert("objectActions", {
|
|
1102
|
+
organizationId: form.organizationId,
|
|
1103
|
+
objectId: newFormId,
|
|
1104
|
+
actionType: "created",
|
|
1105
|
+
performedBy: userId,
|
|
1106
|
+
performedAt: now,
|
|
1107
|
+
actionData: {
|
|
1108
|
+
status: "draft",
|
|
1109
|
+
duplicatedFrom: args.formId,
|
|
1110
|
+
},
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
return newFormId;
|
|
1114
|
+
},
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* INTERNAL DUPLICATE FORM
|
|
1119
|
+
* Internal mutation for AI tools that already have userId and organizationId
|
|
1120
|
+
*/
|
|
1121
|
+
export const internalDuplicateForm = internalMutation({
|
|
1122
|
+
args: {
|
|
1123
|
+
userId: v.id("users"),
|
|
1124
|
+
organizationId: v.id("organizations"),
|
|
1125
|
+
formId: v.id("objects"),
|
|
1126
|
+
},
|
|
1127
|
+
handler: async (ctx, args) => {
|
|
1128
|
+
const form = await ctx.db.get(args.formId);
|
|
1129
|
+
if (!form || !("type" in form) || form.type !== "form") {
|
|
1130
|
+
throw new Error("Form not found");
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Verify form belongs to the organization
|
|
1134
|
+
if (form.organizationId !== args.organizationId) {
|
|
1135
|
+
throw new Error("Form does not belong to this organization");
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// Check permission
|
|
1139
|
+
const hasPermission = await checkPermission(
|
|
1140
|
+
ctx,
|
|
1141
|
+
args.userId,
|
|
1142
|
+
"manage_forms",
|
|
1143
|
+
form.organizationId
|
|
1144
|
+
);
|
|
1145
|
+
|
|
1146
|
+
if (!hasPermission) {
|
|
1147
|
+
throw new Error("Permission denied: manage_forms required to duplicate forms");
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Create the duplicated form
|
|
1151
|
+
const now = Date.now();
|
|
1152
|
+
const newFormId = await ctx.db.insert("objects", {
|
|
1153
|
+
organizationId: form.organizationId,
|
|
1154
|
+
type: "form",
|
|
1155
|
+
subtype: form.subtype,
|
|
1156
|
+
name: `Copy of ${form.name}`,
|
|
1157
|
+
description: form.description || "",
|
|
1158
|
+
status: "draft", // Always start as draft
|
|
1159
|
+
customProperties: {
|
|
1160
|
+
...form.customProperties,
|
|
1161
|
+
// Reset stats for the new form
|
|
1162
|
+
stats: {
|
|
1163
|
+
views: 0,
|
|
1164
|
+
submissions: 0,
|
|
1165
|
+
completionRate: 0,
|
|
1166
|
+
},
|
|
1167
|
+
},
|
|
1168
|
+
createdBy: args.userId,
|
|
1169
|
+
createdAt: now,
|
|
1170
|
+
updatedAt: now,
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
// If the original form has an event link, copy it
|
|
1174
|
+
const eventId = form.customProperties?.eventId;
|
|
1175
|
+
if (eventId) {
|
|
1176
|
+
await ctx.db.insert("objectLinks", {
|
|
1177
|
+
organizationId: form.organizationId,
|
|
1178
|
+
fromObjectId: newFormId,
|
|
1179
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1180
|
+
toObjectId: eventId as any,
|
|
1181
|
+
linkType: "form_for",
|
|
1182
|
+
properties: {},
|
|
1183
|
+
createdAt: now,
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// Log the action
|
|
1188
|
+
await ctx.db.insert("objectActions", {
|
|
1189
|
+
organizationId: form.organizationId,
|
|
1190
|
+
objectId: newFormId,
|
|
1191
|
+
actionType: "created",
|
|
1192
|
+
performedBy: args.userId,
|
|
1193
|
+
performedAt: now,
|
|
1194
|
+
actionData: {
|
|
1195
|
+
status: "draft",
|
|
1196
|
+
duplicatedFrom: args.formId,
|
|
1197
|
+
},
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
return newFormId;
|
|
1201
|
+
},
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* INTERNAL UPDATE FORM
|
|
1206
|
+
* Internal mutation for AI tools to update form properties
|
|
1207
|
+
*/
|
|
1208
|
+
export const internalUpdateForm = internalMutation({
|
|
1209
|
+
args: {
|
|
1210
|
+
userId: v.id("users"),
|
|
1211
|
+
organizationId: v.id("organizations"),
|
|
1212
|
+
formId: v.id("objects"),
|
|
1213
|
+
name: v.optional(v.string()),
|
|
1214
|
+
description: v.optional(v.string()),
|
|
1215
|
+
status: v.optional(v.string()),
|
|
1216
|
+
},
|
|
1217
|
+
handler: async (ctx, args) => {
|
|
1218
|
+
const form = await ctx.db.get(args.formId);
|
|
1219
|
+
if (!form || !("type" in form) || form.type !== "form") {
|
|
1220
|
+
throw new Error("Form not found");
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// Verify form belongs to the organization
|
|
1224
|
+
if (form.organizationId !== args.organizationId) {
|
|
1225
|
+
throw new Error("Form does not belong to this organization");
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// Check permission
|
|
1229
|
+
const hasPermission = await checkPermission(
|
|
1230
|
+
ctx,
|
|
1231
|
+
args.userId,
|
|
1232
|
+
"manage_forms",
|
|
1233
|
+
form.organizationId
|
|
1234
|
+
);
|
|
1235
|
+
|
|
1236
|
+
if (!hasPermission) {
|
|
1237
|
+
throw new Error("Permission denied: manage_forms required to update forms");
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// Build update object
|
|
1241
|
+
const updates: {
|
|
1242
|
+
updatedAt: number;
|
|
1243
|
+
name?: string;
|
|
1244
|
+
description?: string;
|
|
1245
|
+
status?: string;
|
|
1246
|
+
} = {
|
|
1247
|
+
updatedAt: Date.now(),
|
|
1248
|
+
};
|
|
1249
|
+
|
|
1250
|
+
if (args.name !== undefined) {
|
|
1251
|
+
updates.name = args.name;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
if (args.description !== undefined) {
|
|
1255
|
+
updates.description = args.description;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
if (args.status !== undefined) {
|
|
1259
|
+
updates.status = args.status;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// Update the form
|
|
1263
|
+
await ctx.db.patch(args.formId, updates);
|
|
1264
|
+
|
|
1265
|
+
// Log the action
|
|
1266
|
+
await ctx.db.insert("objectActions", {
|
|
1267
|
+
organizationId: form.organizationId,
|
|
1268
|
+
objectId: args.formId,
|
|
1269
|
+
actionType: "updated",
|
|
1270
|
+
performedBy: args.userId,
|
|
1271
|
+
performedAt: Date.now(),
|
|
1272
|
+
actionData: {
|
|
1273
|
+
updates: {
|
|
1274
|
+
name: args.name,
|
|
1275
|
+
description: args.description,
|
|
1276
|
+
status: args.status,
|
|
1277
|
+
},
|
|
1278
|
+
},
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
return { success: true };
|
|
1282
|
+
},
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* SAVE FORM AS TEMPLATE
|
|
1287
|
+
* Save a form's schema as a reusable template in the system organization
|
|
1288
|
+
*/
|
|
1289
|
+
export const saveFormAsTemplate = mutation({
|
|
1290
|
+
args: {
|
|
1291
|
+
sessionId: v.string(),
|
|
1292
|
+
formId: v.id("objects"),
|
|
1293
|
+
templateName: v.string(),
|
|
1294
|
+
templateDescription: v.optional(v.string()),
|
|
1295
|
+
templateCode: v.string(), // Unique code for the template
|
|
1296
|
+
},
|
|
1297
|
+
handler: async (ctx, args) => {
|
|
1298
|
+
const { userId } = await requireAuthenticatedUser(ctx, args.sessionId);
|
|
1299
|
+
|
|
1300
|
+
const form = await ctx.db.get(args.formId);
|
|
1301
|
+
if (!form || !("type" in form) || form.type !== "form") {
|
|
1302
|
+
throw new Error("Form not found");
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// Check permission
|
|
1306
|
+
const hasPermission = await checkPermission(
|
|
1307
|
+
ctx,
|
|
1308
|
+
userId,
|
|
1309
|
+
"manage_forms",
|
|
1310
|
+
form.organizationId
|
|
1311
|
+
);
|
|
1312
|
+
|
|
1313
|
+
if (!hasPermission) {
|
|
1314
|
+
throw new Error("Permission denied: manage_forms required to save templates");
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// Get system organization
|
|
1318
|
+
const systemOrg = await ctx.db
|
|
1319
|
+
.query("organizations")
|
|
1320
|
+
.withIndex("by_slug", (q) => q.eq("slug", "system"))
|
|
1321
|
+
.first();
|
|
1322
|
+
|
|
1323
|
+
if (!systemOrg) {
|
|
1324
|
+
throw new Error("System organization not found");
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// Check if template code already exists
|
|
1328
|
+
const existingTemplate = await ctx.db
|
|
1329
|
+
.query("objects")
|
|
1330
|
+
.withIndex("by_org_type", (q) =>
|
|
1331
|
+
q.eq("organizationId", systemOrg._id).eq("type", "template")
|
|
1332
|
+
)
|
|
1333
|
+
.filter((q) => q.eq(q.field("subtype"), "form"))
|
|
1334
|
+
.filter((q) => q.eq(q.field("customProperties.code"), args.templateCode))
|
|
1335
|
+
.first();
|
|
1336
|
+
|
|
1337
|
+
if (existingTemplate) {
|
|
1338
|
+
throw new Error(`Template with code "${args.templateCode}" already exists`);
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// Extract the form schema
|
|
1342
|
+
const formSchema = form.customProperties?.formSchema;
|
|
1343
|
+
if (!formSchema) {
|
|
1344
|
+
throw new Error("Form does not have a schema to save as template");
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// Create the template in the system organization
|
|
1348
|
+
const now = Date.now();
|
|
1349
|
+
const templateId = await ctx.db.insert("objects", {
|
|
1350
|
+
organizationId: systemOrg._id,
|
|
1351
|
+
type: "template",
|
|
1352
|
+
subtype: "form",
|
|
1353
|
+
name: args.templateName,
|
|
1354
|
+
description: args.templateDescription || "",
|
|
1355
|
+
status: "published",
|
|
1356
|
+
customProperties: {
|
|
1357
|
+
code: args.templateCode,
|
|
1358
|
+
description: args.templateDescription || "",
|
|
1359
|
+
category: form.subtype || "registration",
|
|
1360
|
+
formSchema: formSchema,
|
|
1361
|
+
createdFromForm: args.formId,
|
|
1362
|
+
originalOrganizationId: form.organizationId,
|
|
1363
|
+
},
|
|
1364
|
+
createdBy: userId,
|
|
1365
|
+
createdAt: now,
|
|
1366
|
+
updatedAt: now,
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
// Auto-enable this template for the current organization
|
|
1370
|
+
await ctx.db.insert("objects", {
|
|
1371
|
+
organizationId: form.organizationId,
|
|
1372
|
+
type: "form_template_availability",
|
|
1373
|
+
name: `Form Template Availability: ${args.templateCode}`,
|
|
1374
|
+
status: "active",
|
|
1375
|
+
customProperties: {
|
|
1376
|
+
templateCode: args.templateCode,
|
|
1377
|
+
available: true,
|
|
1378
|
+
enabledBy: userId,
|
|
1379
|
+
enabledAt: now,
|
|
1380
|
+
},
|
|
1381
|
+
createdBy: userId,
|
|
1382
|
+
createdAt: now,
|
|
1383
|
+
updatedAt: now,
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
// Log the action
|
|
1387
|
+
await ctx.db.insert("objectActions", {
|
|
1388
|
+
organizationId: form.organizationId,
|
|
1389
|
+
objectId: templateId,
|
|
1390
|
+
actionType: "template_created",
|
|
1391
|
+
performedBy: userId,
|
|
1392
|
+
performedAt: now,
|
|
1393
|
+
actionData: {
|
|
1394
|
+
templateCode: args.templateCode,
|
|
1395
|
+
sourceFormId: args.formId,
|
|
1396
|
+
},
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
return templateId;
|
|
1400
|
+
},
|
|
1401
|
+
});
|