@ydtb/tk-scope-capture 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/dist/src/client/pages/CaptureBuilderPage.d.ts +2 -0
  3. package/dist/src/client/pages/CaptureBuilderPage.d.ts.map +1 -0
  4. package/dist/src/client/pages/CaptureBuilderPage.js +308 -0
  5. package/dist/src/client/pages/CaptureBuilderPage.js.map +1 -0
  6. package/dist/src/client/pages/CaptureSubmissionDetailPage.d.ts +2 -0
  7. package/dist/src/client/pages/CaptureSubmissionDetailPage.d.ts.map +1 -0
  8. package/dist/src/client/pages/CaptureSubmissionDetailPage.js +64 -0
  9. package/dist/src/client/pages/CaptureSubmissionDetailPage.js.map +1 -0
  10. package/dist/src/client/pages/CaptureSubmissionsPage.d.ts +2 -0
  11. package/dist/src/client/pages/CaptureSubmissionsPage.d.ts.map +1 -0
  12. package/dist/src/client/pages/CaptureSubmissionsPage.js +22 -0
  13. package/dist/src/client/pages/CaptureSubmissionsPage.js.map +1 -0
  14. package/dist/src/client/pages/PublicFormPage.d.ts +2 -0
  15. package/dist/src/client/pages/PublicFormPage.d.ts.map +1 -0
  16. package/dist/src/client/pages/PublicFormPage.js +62 -0
  17. package/dist/src/client/pages/PublicFormPage.js.map +1 -0
  18. package/dist/src/client/pages/PublicQuizPage.d.ts +2 -0
  19. package/dist/src/client/pages/PublicQuizPage.d.ts.map +1 -0
  20. package/dist/src/client/pages/PublicQuizPage.js +87 -0
  21. package/dist/src/client/pages/PublicQuizPage.js.map +1 -0
  22. package/dist/src/client/pages/QuizBuilderPage.d.ts +2 -0
  23. package/dist/src/client/pages/QuizBuilderPage.d.ts.map +1 -0
  24. package/dist/src/client/pages/QuizBuilderPage.js +118 -0
  25. package/dist/src/client/pages/QuizBuilderPage.js.map +1 -0
  26. package/dist/src/client.d.ts +617 -0
  27. package/dist/src/client.d.ts.map +1 -0
  28. package/dist/src/client.js +39 -0
  29. package/dist/src/client.js.map +1 -0
  30. package/dist/src/index.d.ts +3 -0
  31. package/dist/src/index.d.ts.map +1 -0
  32. package/dist/src/index.js +2 -0
  33. package/dist/src/index.js.map +1 -0
  34. package/dist/src/server/api/action-adapters.d.ts +93 -0
  35. package/dist/src/server/api/action-adapters.d.ts.map +1 -0
  36. package/dist/src/server/api/action-adapters.js +147 -0
  37. package/dist/src/server/api/action-adapters.js.map +1 -0
  38. package/dist/src/server/api/router.d.ts +621 -0
  39. package/dist/src/server/api/router.d.ts.map +1 -0
  40. package/dist/src/server/api/router.js +903 -0
  41. package/dist/src/server/api/router.js.map +1 -0
  42. package/dist/src/server.d.ts +3 -0
  43. package/dist/src/server.d.ts.map +1 -0
  44. package/dist/src/server.js +15 -0
  45. package/dist/src/server.js.map +1 -0
  46. package/dist/src/shared/conditions.d.ts +5 -0
  47. package/dist/src/shared/conditions.d.ts.map +1 -0
  48. package/dist/src/shared/conditions.js +39 -0
  49. package/dist/src/shared/conditions.js.map +1 -0
  50. package/dist/src/shared/db/schema.d.ts +983 -0
  51. package/dist/src/shared/db/schema.d.ts.map +1 -0
  52. package/dist/src/shared/db/schema.js +113 -0
  53. package/dist/src/shared/db/schema.js.map +1 -0
  54. package/dist/src/shared/field-types.d.ts +7 -0
  55. package/dist/src/shared/field-types.d.ts.map +1 -0
  56. package/dist/src/shared/field-types.js +116 -0
  57. package/dist/src/shared/field-types.js.map +1 -0
  58. package/dist/src/shared/scoring.d.ts +3 -0
  59. package/dist/src/shared/scoring.d.ts.map +1 -0
  60. package/dist/src/shared/scoring.js +47 -0
  61. package/dist/src/shared/scoring.js.map +1 -0
  62. package/dist/src/shared/templates.d.ts +20 -0
  63. package/dist/src/shared/templates.d.ts.map +1 -0
  64. package/dist/src/shared/templates.js +122 -0
  65. package/dist/src/shared/templates.js.map +1 -0
  66. package/dist/src/shared/tool.d.ts +2 -0
  67. package/dist/src/shared/tool.d.ts.map +1 -0
  68. package/dist/src/shared/tool.js +7 -0
  69. package/dist/src/shared/tool.js.map +1 -0
  70. package/dist/src/shared/types.d.ts +251 -0
  71. package/dist/src/shared/types.d.ts.map +1 -0
  72. package/dist/src/shared/types.js +10 -0
  73. package/dist/src/shared/types.js.map +1 -0
  74. package/package.json +84 -0
@@ -0,0 +1,903 @@
1
+ import { ORPCError } from "@orpc/server";
2
+ import { getHooks, getLayer } from "@ydtb/core-server";
3
+ import "@ydtb/core-layer-postgres";
4
+ import { and, desc, eq, gte, isNull, sql } from "@ydtb/tk-scope-db/schema";
5
+ import { base, requirePermission, scopeAuthed } from "@ydtb/tk-scope-extension/orpc";
6
+ import { z } from "zod";
7
+ import { captureActionRuns, captureAnswers, captureDefinitions, capturePublications, captureSubmissions, } from "../../shared/db/schema.js";
8
+ import { getVisibleCaptureFields } from "../../shared/conditions.js";
9
+ import { REFERENCE_CAPTURE_FIELD_TYPES, normalizeCaptureAnswer, validateCaptureAnswer } from "../../shared/field-types.js";
10
+ import { evaluateQuizOutcome } from "../../shared/scoring.js";
11
+ import { emailActionAdapter, emailActionConfigSchema, executeWorkflowAction, workflowActionAdapter, workflowActionConfigSchema } from "./action-adapters.js";
12
+ function getDb() {
13
+ return getLayer("database").db;
14
+ }
15
+ const fieldTypeSchema = z.string().min(1).max(80);
16
+ const answerValueSchema = z.union([
17
+ z.string(),
18
+ z.number(),
19
+ z.boolean(),
20
+ z.null(),
21
+ z.array(z.string()),
22
+ ]);
23
+ const conditionSchema = z.object({
24
+ fieldId: z.string().min(1).max(80),
25
+ fieldKey: z.string().min(1).max(80),
26
+ operator: z.enum(["equals", "notEquals", "isEmpty", "isNotEmpty"]),
27
+ value: answerValueSchema.optional(),
28
+ });
29
+ const fieldSchema = z.object({
30
+ id: z.string().min(1).max(80),
31
+ key: z.string().min(1).max(80),
32
+ label: z.string().min(1).max(160),
33
+ type: fieldTypeSchema,
34
+ required: z.boolean().default(false),
35
+ options: z.array(z.string().min(1).max(120)).optional(),
36
+ metadata: z.record(z.string(), z.unknown()).optional(),
37
+ visibleWhen: conditionSchema.optional(),
38
+ });
39
+ const contactsActionConfigSchema = z.object({
40
+ enabled: z.boolean().default(false),
41
+ nameFieldKey: z.string().optional(),
42
+ emailFieldKey: z.string().optional(),
43
+ phoneFieldKey: z.string().optional(),
44
+ customFieldMappings: z.record(z.string().min(1), z.string().min(1)).default({}),
45
+ });
46
+ const quizScoringConfigSchema = z.object({
47
+ enabled: z.boolean().default(false),
48
+ version: z.number().int().min(1).default(1),
49
+ outcomes: z.array(z.object({
50
+ id: z.string().min(1),
51
+ key: z.string().min(1),
52
+ label: z.string().min(1),
53
+ description: z.string().optional(),
54
+ bucket: z.string().optional(),
55
+ minScore: z.number().optional(),
56
+ })).default([]),
57
+ rules: z.array(z.object({
58
+ id: z.string().min(1),
59
+ fieldKey: z.string().min(1),
60
+ equals: answerValueSchema,
61
+ score: z.number().optional(),
62
+ outcomeKey: z.string().optional(),
63
+ })).default([]),
64
+ });
65
+ const actionConfigSchema = z.object({
66
+ contacts: contactsActionConfigSchema.optional(),
67
+ email: emailActionConfigSchema.optional(),
68
+ workflow: workflowActionConfigSchema.optional(),
69
+ quiz: quizScoringConfigSchema.optional(),
70
+ });
71
+ const publicationSecuritySchema = z.object({
72
+ allowedOrigins: z.array(z.string().min(1)).optional(),
73
+ minSubmitSeconds: z.number().int().min(0).max(3600).optional(),
74
+ honeypotFieldName: z.string().min(1).max(80).optional(),
75
+ rateLimit: z.object({ maxSubmissions: z.number().int().min(1).max(100), windowSeconds: z.number().int().min(1).max(86400) }).optional(),
76
+ repeatPolicy: z.enum(["allow", "block", "update"]).optional(),
77
+ });
78
+ const defaultPublicationSecurity = {
79
+ minSubmitSeconds: 2,
80
+ honeypotFieldName: "website",
81
+ rateLimit: { maxSubmissions: 5, windowSeconds: 600 },
82
+ repeatPolicy: "allow",
83
+ };
84
+ const answersSchema = z.record(z.string().min(1), answerValueSchema);
85
+ function slugify(value) {
86
+ const slug = value
87
+ .toLowerCase()
88
+ .trim()
89
+ .replace(/[^a-z0-9]+/g, "-")
90
+ .replace(/^-+|-+$/g, "")
91
+ .slice(0, 64);
92
+ return slug || "form";
93
+ }
94
+ function summarizeDefinition(row) {
95
+ return {
96
+ id: row.id,
97
+ surface: row.surface,
98
+ name: row.name,
99
+ slug: row.slug,
100
+ status: row.status,
101
+ fieldCount: row.fields.length,
102
+ createdAt: row.createdAt,
103
+ updatedAt: row.updatedAt,
104
+ publicationSlug: row.publicationSlug ?? undefined,
105
+ };
106
+ }
107
+ async function ensureDefinitionInScope(args) {
108
+ const db = getDb();
109
+ const [row] = await db
110
+ .select()
111
+ .from(captureDefinitions)
112
+ .where(and(eq(captureDefinitions.id, args.id), eq(captureDefinitions.scope, args.scope), eq(captureDefinitions.scopeId, args.scopeId)))
113
+ .limit(1);
114
+ if (!row) {
115
+ throw new ORPCError("NOT_FOUND", { message: "Capture form not found" });
116
+ }
117
+ return row;
118
+ }
119
+ const listDefinitions = scopeAuthed.handler(async ({ context }) => {
120
+ const db = getDb();
121
+ const rows = await db
122
+ .select({
123
+ id: captureDefinitions.id,
124
+ scope: captureDefinitions.scope,
125
+ scopeId: captureDefinitions.scopeId,
126
+ surface: captureDefinitions.surface,
127
+ name: captureDefinitions.name,
128
+ slug: captureDefinitions.slug,
129
+ status: captureDefinitions.status,
130
+ fields: captureDefinitions.fields,
131
+ createdAt: captureDefinitions.createdAt,
132
+ updatedAt: captureDefinitions.updatedAt,
133
+ publicationSlug: capturePublications.slug,
134
+ })
135
+ .from(captureDefinitions)
136
+ .leftJoin(capturePublications, eq(capturePublications.definitionId, captureDefinitions.id))
137
+ .where(and(eq(captureDefinitions.scope, context.scopeType), eq(captureDefinitions.scopeId, context.scopeId)))
138
+ .orderBy(desc(captureDefinitions.createdAt));
139
+ return rows.map(summarizeDefinition);
140
+ });
141
+ const createDefinition = scopeAuthed
142
+ .input(z.object({ name: z.string().min(1).max(160).default("Untitled form") }))
143
+ .handler(async ({ context, input }) => {
144
+ const db = getDb();
145
+ const baseSlug = slugify(input.name);
146
+ const slug = `${baseSlug}-${Date.now().toString(36)}`;
147
+ const [row] = await db
148
+ .insert(captureDefinitions)
149
+ .values({
150
+ scope: context.scopeType,
151
+ scopeId: context.scopeId,
152
+ surface: "form",
153
+ name: input.name,
154
+ slug,
155
+ status: "draft",
156
+ fields: [],
157
+ actionConfig: {},
158
+ })
159
+ .returning();
160
+ if (!row) {
161
+ throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to create Capture form" });
162
+ }
163
+ return row;
164
+ });
165
+ const getDefinition = scopeAuthed
166
+ .input(z.object({ id: z.string().min(1) }))
167
+ .handler(async ({ context, input }) => {
168
+ const definition = await ensureDefinitionInScope({
169
+ id: input.id,
170
+ scope: context.scopeType,
171
+ scopeId: context.scopeId,
172
+ });
173
+ const db = getDb();
174
+ const [publication] = await db
175
+ .select()
176
+ .from(capturePublications)
177
+ .where(eq(capturePublications.definitionId, definition.id))
178
+ .limit(1);
179
+ return { ...definition, publicationSlug: publication?.slug ?? null };
180
+ });
181
+ const updateDefinition = scopeAuthed
182
+ .input(z.object({
183
+ id: z.string().min(1),
184
+ name: z.string().min(1).max(160),
185
+ fields: z.array(fieldSchema).max(50),
186
+ actionConfig: actionConfigSchema.default({}),
187
+ }))
188
+ .handler(async ({ context, input }) => {
189
+ await ensureDefinitionInScope({ id: input.id, scope: context.scopeType, scopeId: context.scopeId });
190
+ const db = getDb();
191
+ const [row] = await db
192
+ .update(captureDefinitions)
193
+ .set({ name: input.name, fields: input.fields, actionConfig: input.actionConfig, updatedAt: new Date() })
194
+ .where(eq(captureDefinitions.id, input.id))
195
+ .returning();
196
+ if (!row) {
197
+ throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to update Capture form" });
198
+ }
199
+ return row;
200
+ });
201
+ const publishDefinition = scopeAuthed
202
+ .input(z.object({ id: z.string().min(1) }))
203
+ .handler(async ({ context, input }) => {
204
+ const definition = await ensureDefinitionInScope({
205
+ id: input.id,
206
+ scope: context.scopeType,
207
+ scopeId: context.scopeId,
208
+ });
209
+ if (definition.fields.length === 0) {
210
+ throw new ORPCError("BAD_REQUEST", { message: "Add at least one field before publishing" });
211
+ }
212
+ const db = getDb();
213
+ const [existing] = await db
214
+ .select()
215
+ .from(capturePublications)
216
+ .where(eq(capturePublications.definitionId, definition.id))
217
+ .limit(1);
218
+ if (existing) {
219
+ const [updated] = await db
220
+ .update(capturePublications)
221
+ .set({ status: "active", slug: definition.slug, revokedAt: null, security: existing.security ?? defaultPublicationSecurity, updatedAt: new Date() })
222
+ .where(eq(capturePublications.id, existing.id))
223
+ .returning();
224
+ await db.update(captureDefinitions).set({ status: "published", updatedAt: new Date() }).where(eq(captureDefinitions.id, definition.id));
225
+ return updated;
226
+ }
227
+ const [publication] = await db
228
+ .insert(capturePublications)
229
+ .values({
230
+ scope: context.scopeType,
231
+ scopeId: context.scopeId,
232
+ definitionId: definition.id,
233
+ slug: definition.slug,
234
+ status: "active",
235
+ security: defaultPublicationSecurity,
236
+ })
237
+ .returning();
238
+ await db.update(captureDefinitions).set({ status: "published", updatedAt: new Date() }).where(eq(captureDefinitions.id, definition.id));
239
+ if (!publication) {
240
+ throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to publish Capture form" });
241
+ }
242
+ return publication;
243
+ });
244
+ const updatePublication = scopeAuthed
245
+ .input(z.object({
246
+ definitionId: z.string().min(1),
247
+ status: z.enum(["active", "disabled", "revoked"]),
248
+ security: publicationSecuritySchema.default(defaultPublicationSecurity),
249
+ }))
250
+ .handler(async ({ context, input }) => {
251
+ const definition = await ensureDefinitionInScope({
252
+ id: input.definitionId,
253
+ scope: context.scopeType,
254
+ scopeId: context.scopeId,
255
+ });
256
+ const db = getDb();
257
+ const [publication] = await db
258
+ .update(capturePublications)
259
+ .set({
260
+ status: input.status,
261
+ security: input.security,
262
+ revokedAt: input.status === "revoked" ? new Date() : null,
263
+ updatedAt: new Date(),
264
+ })
265
+ .where(eq(capturePublications.definitionId, definition.id))
266
+ .returning();
267
+ if (!publication) {
268
+ throw new ORPCError("NOT_FOUND", { message: "Publication not found" });
269
+ }
270
+ return publication;
271
+ });
272
+ function asRecord(value) {
273
+ return value !== null && typeof value === "object" ? value : null;
274
+ }
275
+ function summarizeWorkflowTriggers(graph) {
276
+ const record = asRecord(graph);
277
+ const nodes = Array.isArray(record?.nodes) ? record.nodes : [];
278
+ const triggerNodes = nodes.flatMap((node) => {
279
+ const nodeRecord = asRecord(node);
280
+ const type = typeof nodeRecord?.type === "string" ? nodeRecord.type : null;
281
+ const id = typeof nodeRecord?.id === "string" ? nodeRecord.id : null;
282
+ return type && id && type.endsWith("-trigger") ? [{ id, type }] : [];
283
+ });
284
+ const apiTrigger = triggerNodes.find((node) => node.type === "api-trigger");
285
+ const triggerTypes = [...new Set(triggerNodes.map((node) => node.type))];
286
+ return {
287
+ triggerTypes,
288
+ apiTriggerNodeId: apiTrigger?.id,
289
+ compatible: Boolean(apiTrigger),
290
+ summary: apiTrigger
291
+ ? `API trigger ready (${apiTrigger.id})`
292
+ : triggerTypes.length > 0
293
+ ? `No API trigger; found ${triggerTypes.join(", ")}`
294
+ : "No trigger node found",
295
+ };
296
+ }
297
+ function parseWorkflowRows(rows) {
298
+ if (!Array.isArray(rows))
299
+ return [];
300
+ return rows.flatMap((row) => {
301
+ const record = asRecord(row);
302
+ if (!record)
303
+ return [];
304
+ const id = typeof record.id === "string" ? record.id : null;
305
+ const name = typeof record.name === "string" ? record.name : null;
306
+ const status = typeof record.status === "string" ? record.status : "unknown";
307
+ const currentVersion = typeof record.currentVersion === "number" ? record.currentVersion : Number(record.currentVersion ?? 0);
308
+ if (!id || !name)
309
+ return [];
310
+ return [{ id, name, status, currentVersion, ...summarizeWorkflowTriggers(record.graph) }];
311
+ });
312
+ }
313
+ const listWorkflowDefinitions = scopeAuthed
314
+ .input(z.object({}))
315
+ .handler(async ({ context }) => {
316
+ try {
317
+ const rows = await getDb().execute(sql `
318
+ SELECT
319
+ d.id,
320
+ d.name,
321
+ d.status,
322
+ d."currentVersion" AS "currentVersion",
323
+ COALESCE(v.graph, d."draftGraph") AS graph
324
+ FROM workflow_definitions d
325
+ LEFT JOIN workflow_definition_versions v
326
+ ON v."definitionId" = d.id AND v.version = d."currentVersion"
327
+ WHERE d.scope = ${context.scopeType} AND d."scopeId" = ${context.scopeId}
328
+ ORDER BY d.name ASC
329
+ `);
330
+ return parseWorkflowRows(rows);
331
+ }
332
+ catch (error) {
333
+ const message = error instanceof Error ? error.message : String(error);
334
+ if (message.includes("workflow_definitions") || message.includes("workflow_definition_versions")) {
335
+ return [];
336
+ }
337
+ throw error;
338
+ }
339
+ });
340
+ const listSubmissions = scopeAuthed.handler(async ({ context }) => {
341
+ const db = getDb();
342
+ const rows = await db
343
+ .select({
344
+ id: captureSubmissions.id,
345
+ definitionId: captureSubmissions.definitionId,
346
+ definitionName: captureDefinitions.name,
347
+ submittedAt: captureSubmissions.submittedAt,
348
+ snapshot: captureSubmissions.snapshot,
349
+ actionStatus: captureActionRuns.status,
350
+ contactId: captureActionRuns.contactId,
351
+ })
352
+ .from(captureSubmissions)
353
+ .innerJoin(captureDefinitions, eq(captureDefinitions.id, captureSubmissions.definitionId))
354
+ .leftJoin(captureActionRuns, eq(captureActionRuns.submissionId, captureSubmissions.id))
355
+ .where(and(eq(captureSubmissions.scope, context.scopeType), eq(captureSubmissions.scopeId, context.scopeId)))
356
+ .orderBy(desc(captureSubmissions.submittedAt));
357
+ return rows.map((row) => ({
358
+ id: row.id,
359
+ definitionId: row.definitionId,
360
+ definitionName: row.definitionName,
361
+ submittedAt: row.submittedAt,
362
+ answers: row.snapshot.answers,
363
+ outcome: row.snapshot.outcome,
364
+ actionStatus: row.actionStatus ?? undefined,
365
+ contactId: row.contactId ?? undefined,
366
+ }));
367
+ });
368
+ const getSubmission = scopeAuthed
369
+ .input(z.object({ id: z.string().min(1) }))
370
+ .handler(async ({ context, input }) => {
371
+ const db = getDb();
372
+ const [row] = await db
373
+ .select({
374
+ id: captureSubmissions.id,
375
+ scope: captureSubmissions.scope,
376
+ scopeId: captureSubmissions.scopeId,
377
+ definitionId: captureSubmissions.definitionId,
378
+ definitionName: captureDefinitions.name,
379
+ publicationId: captureSubmissions.publicationId,
380
+ publicationSlug: capturePublications.slug,
381
+ source: captureSubmissions.source,
382
+ snapshot: captureSubmissions.snapshot,
383
+ respondent: captureSubmissions.respondent,
384
+ submittedAt: captureSubmissions.submittedAt,
385
+ })
386
+ .from(captureSubmissions)
387
+ .innerJoin(captureDefinitions, eq(captureDefinitions.id, captureSubmissions.definitionId))
388
+ .leftJoin(capturePublications, eq(capturePublications.id, captureSubmissions.publicationId))
389
+ .where(and(eq(captureSubmissions.id, input.id), eq(captureSubmissions.scope, context.scopeType), eq(captureSubmissions.scopeId, context.scopeId)))
390
+ .limit(1);
391
+ if (!row) {
392
+ throw new ORPCError("NOT_FOUND", { message: "Submission not found" });
393
+ }
394
+ const [normalizedAnswers, actionRuns] = await Promise.all([
395
+ db
396
+ .select({
397
+ id: captureAnswers.id,
398
+ fieldId: captureAnswers.fieldId,
399
+ fieldKey: captureAnswers.fieldKey,
400
+ value: captureAnswers.value,
401
+ })
402
+ .from(captureAnswers)
403
+ .where(eq(captureAnswers.submissionId, row.id)),
404
+ db
405
+ .select({
406
+ id: captureActionRuns.id,
407
+ actionType: captureActionRuns.actionType,
408
+ status: captureActionRuns.status,
409
+ contactId: captureActionRuns.contactId,
410
+ result: captureActionRuns.result,
411
+ error: captureActionRuns.error,
412
+ manualAttempts: captureActionRuns.manualAttempts,
413
+ createdAt: captureActionRuns.createdAt,
414
+ updatedAt: captureActionRuns.updatedAt,
415
+ })
416
+ .from(captureActionRuns)
417
+ .where(eq(captureActionRuns.submissionId, row.id))
418
+ .orderBy(desc(captureActionRuns.createdAt)),
419
+ ]);
420
+ return {
421
+ id: row.id,
422
+ definitionId: row.definitionId,
423
+ definitionName: row.definitionName,
424
+ submittedAt: row.submittedAt,
425
+ source: row.source,
426
+ publicationId: row.publicationId,
427
+ publicationSlug: row.publicationSlug,
428
+ respondent: row.respondent,
429
+ snapshot: row.snapshot,
430
+ answers: row.snapshot.answers,
431
+ normalizedAnswers,
432
+ actionRuns: actionRuns.map((run) => ({
433
+ id: run.id,
434
+ actionType: run.actionType,
435
+ status: run.status,
436
+ contactId: run.contactId ?? undefined,
437
+ result: run.result,
438
+ error: run.error,
439
+ manualAttempts: run.manualAttempts,
440
+ createdAt: run.createdAt,
441
+ updatedAt: run.updatedAt,
442
+ })),
443
+ actionStatus: actionRuns[0]?.status,
444
+ contactId: actionRuns[0]?.contactId ?? undefined,
445
+ };
446
+ });
447
+ const retryActionRun = scopeAuthed
448
+ .use(requirePermission("capture.actions.retry"))
449
+ .input(z.object({
450
+ actionRunId: z.string().min(1),
451
+ mode: z.enum(["retry", "replay"]).default("retry"),
452
+ reason: z.string().min(1).max(500),
453
+ confirmReplay: z.boolean().default(false),
454
+ }))
455
+ .handler(async ({ context, input }) => {
456
+ if (input.mode === "replay" && !input.confirmReplay) {
457
+ throw new ORPCError("BAD_REQUEST", { message: "Replay requires explicit confirmation" });
458
+ }
459
+ const db = getDb();
460
+ const [row] = await db
461
+ .select({
462
+ run: captureActionRuns,
463
+ submission: captureSubmissions,
464
+ definition: captureDefinitions,
465
+ })
466
+ .from(captureActionRuns)
467
+ .innerJoin(captureSubmissions, eq(captureSubmissions.id, captureActionRuns.submissionId))
468
+ .innerJoin(captureDefinitions, eq(captureDefinitions.id, captureActionRuns.definitionId))
469
+ .where(and(eq(captureActionRuns.id, input.actionRunId), eq(captureActionRuns.scope, context.scopeType), eq(captureActionRuns.scopeId, context.scopeId)))
470
+ .limit(1);
471
+ if (!row)
472
+ throw new ORPCError("NOT_FOUND", { message: "Action run not found" });
473
+ if (!["failed", "skipped"].includes(row.run.status)) {
474
+ throw new ORPCError("BAD_REQUEST", { message: "Only failed or skipped action runs can be retried" });
475
+ }
476
+ const idempotencyKeyMode = input.mode === "replay" ? "new" : "same";
477
+ const baseAudit = {
478
+ actorUserId: context.user.id,
479
+ attemptedAt: new Date().toISOString(),
480
+ reason: input.reason,
481
+ mode: input.mode,
482
+ idempotencyKeyMode,
483
+ configSnapshot: row.definition.actionConfig,
484
+ };
485
+ const actionContext = {
486
+ scope: row.definition.scope,
487
+ scopeId: row.definition.scopeId,
488
+ definitionId: row.definition.id,
489
+ definitionName: row.definition.name,
490
+ submissionId: row.submission.id,
491
+ fields: row.definition.fields,
492
+ answers: withOutcomeAnswers(row.submission.snapshot.answers, row.submission.snapshot.outcome),
493
+ outcome: row.submission.snapshot.outcome,
494
+ };
495
+ const attemptResult = await recordActionRun({
496
+ context: actionContext,
497
+ actionType: row.run.actionType,
498
+ manualAttempts: [baseAudit],
499
+ execute: () => executeConfiguredActionByType({
500
+ actionType: row.run.actionType,
501
+ config: row.definition.actionConfig,
502
+ definition: row.definition,
503
+ context: actionContext,
504
+ idempotencyKeyMode,
505
+ }),
506
+ });
507
+ const completedAudit = {
508
+ ...baseAudit,
509
+ actionRunId: attemptResult?.runId,
510
+ result: attemptResult?.result,
511
+ error: attemptResult?.error,
512
+ };
513
+ await db
514
+ .update(captureActionRuns)
515
+ .set({
516
+ manualAttempts: [...row.run.manualAttempts, completedAudit],
517
+ updatedAt: new Date(),
518
+ })
519
+ .where(eq(captureActionRuns.id, row.run.id));
520
+ if (attemptResult?.runId) {
521
+ await db
522
+ .update(captureActionRuns)
523
+ .set({ manualAttempts: [completedAudit], updatedAt: new Date() })
524
+ .where(eq(captureActionRuns.id, attemptResult.runId));
525
+ }
526
+ return {
527
+ ok: true,
528
+ actionRunId: attemptResult?.runId,
529
+ status: attemptResult?.status,
530
+ result: attemptResult?.result,
531
+ error: attemptResult?.error,
532
+ };
533
+ });
534
+ function originFromHeaders(headers) {
535
+ const origin = headers.get("origin");
536
+ if (origin)
537
+ return origin;
538
+ const referer = headers.get("referer");
539
+ if (!referer)
540
+ return null;
541
+ try {
542
+ return new URL(referer).origin;
543
+ }
544
+ catch {
545
+ return null;
546
+ }
547
+ }
548
+ function enforceOrigin(headers, security) {
549
+ const allowedOrigins = security.allowedOrigins ?? [];
550
+ if (allowedOrigins.length === 0)
551
+ return;
552
+ const requestOrigin = originFromHeaders(headers);
553
+ if (!requestOrigin || !allowedOrigins.includes(requestOrigin)) {
554
+ throw new ORPCError("FORBIDDEN", { message: "Form origin is not allowed" });
555
+ }
556
+ }
557
+ const publicFormLookupSchema = z.object({
558
+ slug: z.string().min(1).optional(),
559
+ publishId: z.string().min(1).optional(),
560
+ });
561
+ function publicFormWhere(input) {
562
+ const identifier = input.publishId
563
+ ? eq(capturePublications.id, input.publishId)
564
+ : eq(capturePublications.slug, input.slug ?? "");
565
+ return and(identifier, eq(capturePublications.status, "active"), isNull(capturePublications.revokedAt));
566
+ }
567
+ async function loadPublicForm(input, headers) {
568
+ if (!input.slug && !input.publishId) {
569
+ throw new ORPCError("BAD_REQUEST", { message: "Missing form identifier" });
570
+ }
571
+ const db = getDb();
572
+ const [row] = await db
573
+ .select({
574
+ publication: capturePublications,
575
+ definition: captureDefinitions,
576
+ })
577
+ .from(capturePublications)
578
+ .innerJoin(captureDefinitions, eq(captureDefinitions.id, capturePublications.definitionId))
579
+ .where(publicFormWhere(input))
580
+ .limit(1);
581
+ if (!row) {
582
+ throw new ORPCError("NOT_FOUND", { message: "Form not found" });
583
+ }
584
+ enforceOrigin(headers, row.publication.security);
585
+ return row;
586
+ }
587
+ const getPublicForm = base
588
+ .input(publicFormLookupSchema)
589
+ .handler(async ({ context, input }) => {
590
+ const row = await loadPublicForm(input, context.headers);
591
+ return {
592
+ id: row.definition.id,
593
+ publicationId: row.publication.id,
594
+ title: row.definition.name,
595
+ slug: row.publication.slug,
596
+ fields: row.definition.fields,
597
+ security: {
598
+ minSubmitSeconds: row.publication.security.minSubmitSeconds ?? defaultPublicationSecurity.minSubmitSeconds,
599
+ honeypotFieldName: row.publication.security.honeypotFieldName ?? defaultPublicationSecurity.honeypotFieldName,
600
+ repeatPolicy: row.publication.security.repeatPolicy ?? defaultPublicationSecurity.repeatPolicy,
601
+ },
602
+ };
603
+ });
604
+ function answerToContactString(value) {
605
+ if (value === undefined || value === null)
606
+ return undefined;
607
+ if (Array.isArray(value))
608
+ return value.join(", ");
609
+ const stringValue = String(value).trim();
610
+ return stringValue.length > 0 ? stringValue : undefined;
611
+ }
612
+ function withOutcomeAnswers(answers, outcome) {
613
+ if (!outcome)
614
+ return answers;
615
+ return {
616
+ ...answers,
617
+ "outcome.id": outcome.id,
618
+ "outcome.key": outcome.key,
619
+ "outcome.label": outcome.label,
620
+ "outcome.bucket": outcome.bucket,
621
+ "outcome.score": outcome.scoreSummary.score,
622
+ "outcome.maxScore": outcome.scoreSummary.maxScore,
623
+ };
624
+ }
625
+ function resolveContactsActionInput(args) {
626
+ const customFields = {};
627
+ for (const [contactFieldKey, answerFieldKey] of Object.entries(args.config.customFieldMappings ?? {})) {
628
+ const value = answerToContactString(args.answers[answerFieldKey]);
629
+ if (value)
630
+ customFields[contactFieldKey] = value;
631
+ }
632
+ return {
633
+ scope: args.scope,
634
+ scopeId: args.scopeId,
635
+ name: answerToContactString(args.answers[args.config.nameFieldKey ?? ""]) ?? "Form respondent",
636
+ email: answerToContactString(args.answers[args.config.emailFieldKey ?? ""]),
637
+ phone: answerToContactString(args.answers[args.config.phoneFieldKey ?? ""]),
638
+ source: "capture.form",
639
+ customFields,
640
+ };
641
+ }
642
+ async function recordActionRun(args) {
643
+ const db = getDb();
644
+ const [run] = await db
645
+ .insert(captureActionRuns)
646
+ .values({
647
+ scope: args.context.scope,
648
+ scopeId: args.context.scopeId,
649
+ submissionId: args.context.submissionId,
650
+ definitionId: args.context.definitionId,
651
+ actionType: args.actionType,
652
+ status: "running",
653
+ manualAttempts: args.manualAttempts ?? [],
654
+ })
655
+ .returning({ id: captureActionRuns.id });
656
+ if (!run)
657
+ return undefined;
658
+ try {
659
+ const result = await args.execute();
660
+ await db
661
+ .update(captureActionRuns)
662
+ .set({
663
+ status: "succeeded",
664
+ contactId: result.contactId,
665
+ result,
666
+ updatedAt: new Date(),
667
+ })
668
+ .where(eq(captureActionRuns.id, run.id));
669
+ return { runId: run.id, status: "succeeded", result };
670
+ }
671
+ catch (error) {
672
+ const actionError = { message: error instanceof Error ? error.message : `${args.actionType} failed` };
673
+ await db
674
+ .update(captureActionRuns)
675
+ .set({
676
+ status: "failed",
677
+ error: actionError,
678
+ updatedAt: new Date(),
679
+ })
680
+ .where(eq(captureActionRuns.id, run.id));
681
+ return { runId: run.id, status: "failed", error: actionError };
682
+ }
683
+ }
684
+ async function executeConfiguredActionByType(args) {
685
+ if (args.actionType === "contacts.findOrCreate") {
686
+ const contactsConfig = args.config.contacts;
687
+ if (!contactsConfig?.enabled)
688
+ throw new Error("Contacts action is not enabled");
689
+ const input = resolveContactsActionInput({
690
+ config: contactsConfig,
691
+ answers: args.context.answers,
692
+ scope: args.definition.scope,
693
+ scopeId: args.definition.scopeId,
694
+ });
695
+ const result = await getHooks().doAction("contacts:findOrCreate", input);
696
+ return { contactId: result.contact.id, created: result.created };
697
+ }
698
+ if (args.actionType === emailActionAdapter.type) {
699
+ const emailConfig = args.config.email;
700
+ if (!emailConfig || !emailActionAdapter.isEnabled(emailConfig))
701
+ throw new Error("Email action is not enabled");
702
+ const parsedConfig = emailActionAdapter.configSchema.parse(emailConfig);
703
+ return emailActionAdapter.execute({ config: parsedConfig, context: args.context });
704
+ }
705
+ if (args.actionType === workflowActionAdapter.type) {
706
+ const workflowConfig = args.config.workflow;
707
+ if (!workflowConfig || !workflowActionAdapter.isEnabled(workflowConfig))
708
+ throw new Error("Workflow action is not enabled");
709
+ const parsedConfig = workflowActionAdapter.configSchema.parse(workflowConfig);
710
+ const idempotencyKeyOverride = args.idempotencyKeyMode === "new"
711
+ ? `capture:${args.context.submissionId}:workflow:${parsedConfig.workflowDefinitionId}:replay:${crypto.randomUUID()}`
712
+ : undefined;
713
+ return executeWorkflowAction({ config: parsedConfig, context: args.context, idempotencyKeyOverride });
714
+ }
715
+ throw new Error(`Unsupported action type: ${args.actionType}`);
716
+ }
717
+ async function runConfiguredActions(args) {
718
+ const context = {
719
+ scope: args.definition.scope,
720
+ scopeId: args.definition.scopeId,
721
+ definitionId: args.definition.id,
722
+ definitionName: args.definition.name,
723
+ submissionId: args.submissionId,
724
+ fields: args.definition.fields,
725
+ answers: withOutcomeAnswers(args.answers, args.outcome),
726
+ outcome: args.outcome,
727
+ };
728
+ const contactsConfig = args.config.contacts;
729
+ if (contactsConfig?.enabled) {
730
+ await recordActionRun({
731
+ context,
732
+ actionType: "contacts.findOrCreate",
733
+ execute: () => executeConfiguredActionByType({
734
+ actionType: "contacts.findOrCreate",
735
+ config: args.config,
736
+ definition: args.definition,
737
+ context,
738
+ }),
739
+ });
740
+ }
741
+ const emailConfig = args.config.email;
742
+ if (emailConfig && emailActionAdapter.isEnabled(emailConfig)) {
743
+ const parsedConfig = emailActionAdapter.configSchema.parse(emailConfig);
744
+ await recordActionRun({
745
+ context,
746
+ actionType: emailActionAdapter.type,
747
+ execute: () => emailActionAdapter.execute({ config: parsedConfig, context }),
748
+ });
749
+ }
750
+ const workflowConfig = args.config.workflow;
751
+ if (workflowConfig && workflowActionAdapter.isEnabled(workflowConfig)) {
752
+ const parsedConfig = workflowActionAdapter.configSchema.parse(workflowConfig);
753
+ await recordActionRun({
754
+ context,
755
+ actionType: workflowActionAdapter.type,
756
+ execute: () => workflowActionAdapter.execute({ config: parsedConfig, context }),
757
+ });
758
+ }
759
+ }
760
+ async function enforcePublicSubmitGuards(args) {
761
+ const security = { ...defaultPublicationSecurity, ...args.publication.security };
762
+ if (security.honeypotFieldName && args.honeypot && args.honeypot.trim().length > 0) {
763
+ throw new ORPCError("BAD_REQUEST", { message: "Unable to submit form" });
764
+ }
765
+ if (security.minSubmitSeconds && args.startedAt) {
766
+ const startedAtMs = Date.parse(args.startedAt);
767
+ if (Number.isFinite(startedAtMs) && Date.now() - startedAtMs < security.minSubmitSeconds * 1000) {
768
+ throw new ORPCError("BAD_REQUEST", { message: "Unable to submit form" });
769
+ }
770
+ }
771
+ if (security.rateLimit) {
772
+ const windowStart = new Date(Date.now() - security.rateLimit.windowSeconds * 1000);
773
+ const recent = await getDb()
774
+ .select({ id: captureSubmissions.id })
775
+ .from(captureSubmissions)
776
+ .where(and(eq(captureSubmissions.publicationId, args.publication.id), eq(captureSubmissions.respondentKey, args.respondentKey), gte(captureSubmissions.submittedAt, windowStart)))
777
+ .limit(security.rateLimit.maxSubmissions);
778
+ if (recent.length >= security.rateLimit.maxSubmissions) {
779
+ throw new ORPCError("TOO_MANY_REQUESTS", { message: "Please wait before submitting again" });
780
+ }
781
+ }
782
+ }
783
+ const submitPublicForm = base
784
+ .input(publicFormLookupSchema.extend({
785
+ answers: answersSchema,
786
+ respondentKey: z.string().min(1).max(160).optional(),
787
+ startedAt: z.string().optional(),
788
+ honeypot: z.string().optional(),
789
+ source: z.enum(["hosted", "embed", "quiz"]).default("hosted"),
790
+ }))
791
+ .handler(async ({ context, input }) => {
792
+ const { publication, definition } = await loadPublicForm(input, context.headers);
793
+ const security = { ...defaultPublicationSecurity, ...publication.security };
794
+ const respondentKey = input.respondentKey ?? `anon:${crypto.randomUUID()}`;
795
+ await enforcePublicSubmitGuards({
796
+ publication,
797
+ respondentKey,
798
+ startedAt: input.startedAt,
799
+ honeypot: input.honeypot,
800
+ });
801
+ const normalizedAnswers = {};
802
+ const visibleFields = getVisibleCaptureFields(definition.fields, input.answers);
803
+ for (const field of visibleFields) {
804
+ const value = normalizeCaptureAnswer(field, input.answers[field.key], REFERENCE_CAPTURE_FIELD_TYPES);
805
+ const validationError = validateCaptureAnswer(field, value, REFERENCE_CAPTURE_FIELD_TYPES);
806
+ if (validationError) {
807
+ throw new ORPCError("BAD_REQUEST", { message: validationError });
808
+ }
809
+ normalizedAnswers[field.key] = value;
810
+ }
811
+ const outcome = input.source === "quiz" ? evaluateQuizOutcome(definition.actionConfig.quiz, normalizedAnswers) : undefined;
812
+ const db = getDb();
813
+ const snapshot = {
814
+ definitionId: definition.id,
815
+ title: definition.name,
816
+ fields: definition.fields,
817
+ answers: normalizedAnswers,
818
+ ...(outcome ? { outcome } : {}),
819
+ };
820
+ const respondent = {
821
+ respondentKey,
822
+ origin: originFromHeaders(context.headers),
823
+ userAgent: context.headers.get("user-agent") ?? undefined,
824
+ publicSource: input.source,
825
+ publishId: publication.id,
826
+ };
827
+ const repeatPolicy = security.repeatPolicy ?? "allow";
828
+ const [existingSubmission] = await db
829
+ .select({ id: captureSubmissions.id })
830
+ .from(captureSubmissions)
831
+ .where(and(eq(captureSubmissions.publicationId, publication.id), eq(captureSubmissions.respondentKey, respondentKey)))
832
+ .limit(1);
833
+ if (existingSubmission && repeatPolicy === "block") {
834
+ throw new ORPCError("CONFLICT", { message: "This form has already been submitted" });
835
+ }
836
+ const [submission] = existingSubmission && repeatPolicy === "update"
837
+ ? await db
838
+ .update(captureSubmissions)
839
+ .set({ source: `public:${input.source}:update`, snapshot, respondent, submittedAt: new Date() })
840
+ .where(eq(captureSubmissions.id, existingSubmission.id))
841
+ .returning({ id: captureSubmissions.id })
842
+ : await db
843
+ .insert(captureSubmissions)
844
+ .values({
845
+ scope: definition.scope,
846
+ scopeId: definition.scopeId,
847
+ definitionId: definition.id,
848
+ publicationId: publication.id,
849
+ source: `public:${input.source}`,
850
+ snapshot,
851
+ respondent,
852
+ respondentKey,
853
+ })
854
+ .returning({ id: captureSubmissions.id });
855
+ if (!submission) {
856
+ throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to submit form" });
857
+ }
858
+ if (existingSubmission && repeatPolicy === "update") {
859
+ await db.delete(captureAnswers).where(eq(captureAnswers.submissionId, submission.id));
860
+ }
861
+ const answerRows = visibleFields.map((field) => ({
862
+ scope: definition.scope,
863
+ scopeId: definition.scopeId,
864
+ submissionId: submission.id,
865
+ definitionId: definition.id,
866
+ fieldId: field.id,
867
+ fieldKey: field.key,
868
+ value: normalizedAnswers[field.key] ?? null,
869
+ }));
870
+ if (answerRows.length > 0) {
871
+ await db.insert(captureAnswers).values(answerRows);
872
+ }
873
+ await runConfiguredActions({
874
+ config: definition.actionConfig,
875
+ definition,
876
+ submissionId: submission.id,
877
+ answers: normalizedAnswers,
878
+ outcome,
879
+ });
880
+ return { ok: true, submissionId: submission.id, updated: existingSubmission ? repeatPolicy === "update" : undefined, outcome };
881
+ });
882
+ const captureRouter = {
883
+ definitions: {
884
+ list: listDefinitions,
885
+ create: createDefinition,
886
+ get: getDefinition,
887
+ update: updateDefinition,
888
+ publish: publishDefinition,
889
+ updatePublication,
890
+ listWorkflows: listWorkflowDefinitions,
891
+ },
892
+ submissions: {
893
+ list: listSubmissions,
894
+ get: getSubmission,
895
+ retryAction: retryActionRun,
896
+ },
897
+ public: {
898
+ getForm: getPublicForm,
899
+ submit: submitPublicForm,
900
+ },
901
+ };
902
+ export default captureRouter;
903
+ //# sourceMappingURL=router.js.map