@ydtb/tk-scope-capture 0.22.0 → 0.23.6

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 (69) hide show
  1. package/dist/src/client/components/CaptureFormSurface.d.ts +58 -0
  2. package/dist/src/client/components/CaptureFormSurface.d.ts.map +1 -0
  3. package/dist/src/client/components/CaptureFormSurface.js +301 -0
  4. package/dist/src/client/components/CaptureFormSurface.js.map +1 -0
  5. package/dist/src/client/components/CaptureSidebar.d.ts +43 -0
  6. package/dist/src/client/components/CaptureSidebar.d.ts.map +1 -0
  7. package/dist/src/client/components/CaptureSidebar.js +38 -0
  8. package/dist/src/client/components/CaptureSidebar.js.map +1 -0
  9. package/dist/src/client/pages/CaptureBuilderPage.d.ts.map +1 -1
  10. package/dist/src/client/pages/CaptureBuilderPage.js +1869 -129
  11. package/dist/src/client/pages/CaptureBuilderPage.js.map +1 -1
  12. package/dist/src/client/pages/CaptureSubmissionDetailPage.d.ts.map +1 -1
  13. package/dist/src/client/pages/CaptureSubmissionDetailPage.js +61 -10
  14. package/dist/src/client/pages/CaptureSubmissionDetailPage.js.map +1 -1
  15. package/dist/src/client/pages/CaptureSubmissionsPage.d.ts.map +1 -1
  16. package/dist/src/client/pages/CaptureSubmissionsPage.js +14 -2
  17. package/dist/src/client/pages/CaptureSubmissionsPage.js.map +1 -1
  18. package/dist/src/client/pages/PublicFormPage.d.ts.map +1 -1
  19. package/dist/src/client/pages/PublicFormPage.js +13 -56
  20. package/dist/src/client/pages/PublicFormPage.js.map +1 -1
  21. package/dist/src/client/pages/PublicQuizPage.d.ts.map +1 -1
  22. package/dist/src/client/pages/PublicQuizPage.js +23 -11
  23. package/dist/src/client/pages/PublicQuizPage.js.map +1 -1
  24. package/dist/src/client/pages/QuizBuilderPage.d.ts.map +1 -1
  25. package/dist/src/client/pages/QuizBuilderPage.js +6 -2
  26. package/dist/src/client/pages/QuizBuilderPage.js.map +1 -1
  27. package/dist/src/client.d.ts +858 -15
  28. package/dist/src/client.d.ts.map +1 -1
  29. package/dist/src/client.js +2 -0
  30. package/dist/src/client.js.map +1 -1
  31. package/dist/src/index.d.ts +2 -0
  32. package/dist/src/index.d.ts.map +1 -1
  33. package/dist/src/index.js +1 -0
  34. package/dist/src/index.js.map +1 -1
  35. package/dist/src/server/api/action-adapters.d.ts +2 -0
  36. package/dist/src/server/api/action-adapters.d.ts.map +1 -1
  37. package/dist/src/server/api/action-adapters.js +1 -0
  38. package/dist/src/server/api/action-adapters.js.map +1 -1
  39. package/dist/src/server/api/router.d.ts +884 -16
  40. package/dist/src/server/api/router.d.ts.map +1 -1
  41. package/dist/src/server/api/router.js +547 -22
  42. package/dist/src/server/api/router.js.map +1 -1
  43. package/dist/src/server.d.ts.map +1 -1
  44. package/dist/src/server.js +51 -1
  45. package/dist/src/server.js.map +1 -1
  46. package/dist/src/shared/conditions.d.ts +2 -2
  47. package/dist/src/shared/conditions.d.ts.map +1 -1
  48. package/dist/src/shared/conditions.js +47 -0
  49. package/dist/src/shared/conditions.js.map +1 -1
  50. package/dist/src/shared/db/schema.d.ts +414 -0
  51. package/dist/src/shared/db/schema.d.ts.map +1 -1
  52. package/dist/src/shared/db/schema.js +40 -0
  53. package/dist/src/shared/db/schema.js.map +1 -1
  54. package/dist/src/shared/document.d.ts +120 -0
  55. package/dist/src/shared/document.d.ts.map +1 -0
  56. package/dist/src/shared/document.js +111 -0
  57. package/dist/src/shared/document.js.map +1 -0
  58. package/dist/src/shared/field-types.d.ts +2 -0
  59. package/dist/src/shared/field-types.d.ts.map +1 -1
  60. package/dist/src/shared/field-types.js +43 -10
  61. package/dist/src/shared/field-types.js.map +1 -1
  62. package/dist/src/shared/types.d.ts +90 -3
  63. package/dist/src/shared/types.d.ts.map +1 -1
  64. package/dist/src/shared/types.js.map +1 -1
  65. package/dist/src/shared/validation.d.ts +6 -0
  66. package/dist/src/shared/validation.d.ts.map +1 -0
  67. package/dist/src/shared/validation.js +87 -0
  68. package/dist/src/shared/validation.js.map +1 -0
  69. package/package.json +11 -7
@@ -4,14 +4,53 @@ import "@ydtb/core-layer-postgres";
4
4
  import { and, desc, eq, gte, isNull, sql } from "@ydtb/tk-scope-db/schema";
5
5
  import { base, requirePermission, scopeAuthed } from "@ydtb/tk-scope-extension/orpc";
6
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";
7
+ import { captureActionRuns, captureAnswers, captureDefinitions, captureItemViews, captureSubmissionViews, capturePublications, captureSubmissions, } from "../../shared/db/schema.js";
8
+ import { evaluateCaptureCondition, getVisibleCaptureFields } from "../../shared/conditions.js";
9
+ import { deriveCaptureDocumentFromFields, extractCaptureAnswerFields, normalizeCaptureDocumentForPersistence, resolveCaptureDocument, } from "../../shared/document.js";
10
+ import { REFERENCE_CAPTURE_FIELD_TYPES, isHiddenCaptureField, isHoneypotCaptureField, normalizeCaptureAnswer, validateCaptureAnswer } from "../../shared/field-types.js";
11
+ import { validateCaptureFieldRules } from "../../shared/validation.js";
10
12
  import { evaluateQuizOutcome } from "../../shared/scoring.js";
11
- import { emailActionAdapter, emailActionConfigSchema, executeWorkflowAction, workflowActionAdapter, workflowActionConfigSchema } from "./action-adapters.js";
13
+ import { buildWorkflowSubmissionPayload, emailActionAdapter, emailActionConfigSchema, executeWorkflowAction, workflowActionAdapter, workflowActionConfigSchema } from "./action-adapters.js";
12
14
  function getDb() {
13
15
  return getLayer("database").db;
14
16
  }
17
+ async function resolveCaptureStorageImageUrls(args) {
18
+ const storageItems = args.document.items.filter((item) => {
19
+ if (item.kind !== "content.image")
20
+ return false;
21
+ return item.source?.type === "storage" && !!item.source.storageItemId;
22
+ });
23
+ if (storageItems.length === 0)
24
+ return args.document;
25
+ const resolvedUrls = new Map();
26
+ await Promise.all(storageItems.map(async (item) => {
27
+ const storageItemId = item.source?.storageItemId;
28
+ if (!storageItemId)
29
+ return;
30
+ try {
31
+ const result = await getHooks().doAction("storage:get-download-url", {
32
+ scope: args.scope,
33
+ scopeId: args.scopeId,
34
+ itemId: storageItemId,
35
+ });
36
+ if (result?.url)
37
+ resolvedUrls.set(item.id, result.url);
38
+ }
39
+ catch {
40
+ // Missing/unavailable storage should not make the whole public
41
+ // form unavailable. The renderer falls back to the image
42
+ // placeholder for unresolved decorative content.
43
+ }
44
+ }));
45
+ if (resolvedUrls.size === 0)
46
+ return args.document;
47
+ return {
48
+ ...args.document,
49
+ items: args.document.items.map((item) => item.kind === "content.image" && resolvedUrls.has(item.id)
50
+ ? { ...item, imageUrl: resolvedUrls.get(item.id) }
51
+ : item),
52
+ };
53
+ }
15
54
  const fieldTypeSchema = z.string().min(1).max(80);
16
55
  const answerValueSchema = z.union([
17
56
  z.string(),
@@ -20,12 +59,71 @@ const answerValueSchema = z.union([
20
59
  z.null(),
21
60
  z.array(z.string()),
22
61
  ]);
23
- const conditionSchema = z.object({
62
+ const legacyConditionSchema = z.object({
24
63
  fieldId: z.string().min(1).max(80),
25
64
  fieldKey: z.string().min(1).max(80),
26
65
  operator: z.enum(["equals", "notEquals", "isEmpty", "isNotEmpty"]),
27
66
  value: answerValueSchema.optional(),
28
67
  });
68
+ const filterConditionSchema = z.object({
69
+ id: z.string().min(1).max(80),
70
+ fieldKey: z.string().min(1).max(80),
71
+ operator: z.enum([
72
+ "is",
73
+ "is_not",
74
+ "contains",
75
+ "does_not_contain",
76
+ "starts_with",
77
+ "ends_with",
78
+ "is_empty",
79
+ "is_not_empty",
80
+ "greater_than",
81
+ "less_than",
82
+ "greater_than_or_equal",
83
+ "less_than_or_equal",
84
+ ]),
85
+ value: z.string().optional(),
86
+ });
87
+ const conditionGroupSchema = z.lazy(() => z.object({
88
+ id: z.string().min(1).max(80),
89
+ logic: z.enum(["and", "or"]),
90
+ conditions: z.array(z.union([filterConditionSchema, conditionGroupSchema])),
91
+ }));
92
+ const conditionSchema = z.union([legacyConditionSchema, conditionGroupSchema]);
93
+ const validationOperatorSchema = z.enum([
94
+ "is",
95
+ "is_not",
96
+ "contains",
97
+ "does_not_contain",
98
+ "starts_with",
99
+ "ends_with",
100
+ "regex",
101
+ "is_empty",
102
+ "is_not_empty",
103
+ "greater_than",
104
+ "less_than",
105
+ "greater_than_or_equal",
106
+ "less_than_or_equal",
107
+ ]);
108
+ const validationExpressionSchema = z.lazy(() => z.union([
109
+ z.object({
110
+ id: z.string().min(1).max(80),
111
+ fieldKey: z.string().min(1).max(80),
112
+ operator: validationOperatorSchema,
113
+ value: z.string().max(500).optional(),
114
+ valueFieldKey: z.string().min(1).max(80).optional(),
115
+ }),
116
+ z.object({
117
+ id: z.string().min(1).max(80),
118
+ logic: z.enum(["and", "or"]),
119
+ conditions: z.array(validationExpressionSchema),
120
+ }),
121
+ ]));
122
+ const validationRuleSchema = z.object({
123
+ id: z.string().min(1).max(80),
124
+ message: z.string().min(1).max(300),
125
+ expression: validationExpressionSchema,
126
+ });
29
127
  const fieldSchema = z.object({
30
128
  id: z.string().min(1).max(80),
31
129
  key: z.string().min(1).max(80),
@@ -34,7 +132,116 @@ const fieldSchema = z.object({
34
132
  required: z.boolean().default(false),
35
133
  options: z.array(z.string().min(1).max(120)).optional(),
36
134
  metadata: z.record(z.string(), z.unknown()).optional(),
135
+ hidden: z.boolean().optional(),
136
+ visibleWhen: conditionSchema.optional(),
137
+ validationRules: z.array(validationRuleSchema).max(1).optional(),
138
+ });
139
+ const documentPageSchema = z.object({
140
+ id: z.string().min(1).max(80),
141
+ title: z.string().min(1).max(160),
142
+ preHeading: z.string().max(80).optional(),
143
+ description: z.string().max(500).optional(),
144
+ headerVisible: z.boolean().optional(),
145
+ headerContentInitialized: z.boolean().optional(),
146
+ hidden: z.boolean().optional(),
37
147
  visibleWhen: conditionSchema.optional(),
148
+ order: z.number().int().min(0),
149
+ });
150
+ const inputItemSchema = fieldSchema.extend({
151
+ kind: z.literal("input"),
152
+ pageId: z.string().min(1).max(80),
153
+ order: z.number().int().min(0),
154
+ });
155
+ const textBlockItemSchema = z.object({
156
+ id: z.string().min(1).max(80),
157
+ kind: z.literal("content.text"),
158
+ pageId: z.string().min(1).max(80),
159
+ order: z.number().int().min(0),
160
+ title: z.string().max(160).optional(),
161
+ body: z.string().max(5000),
162
+ hidden: z.boolean().optional(),
163
+ visibleWhen: conditionSchema.optional(),
164
+ });
165
+ const imageBlockItemSchema = z.object({
166
+ id: z.string().min(1).max(80),
167
+ kind: z.literal("content.image"),
168
+ pageId: z.string().min(1).max(80),
169
+ order: z.number().int().min(0),
170
+ imageUrl: z.string().max(500_000),
171
+ altText: z.string().max(300).optional(),
172
+ caption: z.string().max(500).optional(),
173
+ shape: z.enum(["square", "circle"]).optional(),
174
+ align: z.enum(["left", "center", "right"]).optional(),
175
+ size: z.enum(["small", "medium", "large", "full"]).optional(),
176
+ fit: z.enum(["cover", "contain"]).optional(),
177
+ source: z.object({
178
+ type: z.enum(["url", "upload", "storage"]),
179
+ storageItemId: z.string().min(1).optional(),
180
+ }).optional(),
181
+ hidden: z.boolean().optional(),
182
+ visibleWhen: conditionSchema.optional(),
183
+ });
184
+ const iconBlockItemSchema = z.object({
185
+ id: z.string().min(1).max(80),
186
+ kind: z.literal("content.icon"),
187
+ pageId: z.string().min(1).max(80),
188
+ order: z.number().int().min(0),
189
+ icon: z.string().min(1).max(120),
190
+ iconColor: z.string().max(40).optional(),
191
+ align: z.enum(["left", "center", "right"]),
192
+ hidden: z.boolean().optional(),
193
+ visibleWhen: conditionSchema.optional(),
194
+ });
195
+ const dividerBlockItemSchema = z.object({
196
+ id: z.string().min(1).max(80),
197
+ kind: z.literal("content.divider"),
198
+ pageId: z.string().min(1).max(80),
199
+ order: z.number().int().min(0),
200
+ hidden: z.boolean().optional(),
201
+ visibleWhen: conditionSchema.optional(),
202
+ });
203
+ const pageHeaderBlockItemSchema = z.object({
204
+ id: z.string().min(1).max(80),
205
+ kind: z.literal("content.page-header"),
206
+ pageId: z.string().min(1).max(80),
207
+ order: z.number().int().min(0),
208
+ preHeading: z.string().max(80).optional(),
209
+ title: z.string().min(1).max(160),
210
+ description: z.string().max(500).optional(),
211
+ hidden: z.boolean().optional(),
212
+ visibleWhen: conditionSchema.optional(),
213
+ });
214
+ const pageProgressBlockItemSchema = z.object({
215
+ id: z.string().min(1).max(80),
216
+ kind: z.literal("content.page-progress"),
217
+ pageId: z.string().min(1).max(80),
218
+ order: z.number().int().min(0),
219
+ hidden: z.boolean().optional(),
220
+ visibleWhen: conditionSchema.optional(),
221
+ });
222
+ const formDocumentSchema = z.object({
223
+ version: z.literal(1),
224
+ pages: z.array(documentPageSchema).min(1).max(50),
225
+ items: z.array(z.discriminatedUnion("kind", [
226
+ inputItemSchema,
227
+ textBlockItemSchema,
228
+ imageBlockItemSchema,
229
+ iconBlockItemSchema,
230
+ dividerBlockItemSchema,
231
+ pageHeaderBlockItemSchema,
232
+ pageProgressBlockItemSchema,
233
+ ])).max(200),
234
+ }).superRefine((document, context) => {
235
+ const pageIds = new Set(document.pages.map((page) => page.id));
236
+ const inputCount = document.items.filter((item) => item.kind === "input").length;
237
+ if (inputCount > 50) {
238
+ context.addIssue({ code: "custom", message: "Capture forms can have at most 50 answer fields", path: ["items"] });
239
+ }
240
+ for (const [index, item] of document.items.entries()) {
241
+ if (!pageIds.has(item.pageId)) {
242
+ context.addIssue({ code: "custom", message: "Document item references an unknown page", path: ["items", index, "pageId"] });
243
+ }
244
+ }
38
245
  });
39
246
  const contactsActionConfigSchema = z.object({
40
247
  enabled: z.boolean().default(false),
@@ -62,6 +269,25 @@ const quizScoringConfigSchema = z.object({
62
269
  outcomeKey: z.string().optional(),
63
270
  })).default([]),
64
271
  });
272
+ const captureItemViewColumnSchema = z.object({
273
+ fieldKey: z.string().min(1).max(80),
274
+ visible: z.boolean(),
275
+ position: z.number().int().min(0),
276
+ });
277
+ const captureItemViewSortSchema = z.object({
278
+ field: z.string().min(1).max(80),
279
+ direction: z.enum(["asc", "desc"]),
280
+ });
281
+ const captureItemViewStateSchema = z.object({
282
+ filters: conditionGroupSchema.optional().nullable(),
283
+ sorts: z.array(captureItemViewSortSchema).max(3).optional().nullable(),
284
+ columns: z.array(captureItemViewColumnSchema).max(24).optional().nullable(),
285
+ });
286
+ const captureSubmissionViewStateSchema = z.object({
287
+ filters: conditionGroupSchema.optional().nullable(),
288
+ sorts: z.array(captureItemViewSortSchema).max(3).optional().nullable(),
289
+ columns: z.array(captureItemViewColumnSchema).max(80).optional().nullable(),
290
+ });
65
291
  const actionConfigSchema = z.object({
66
292
  contacts: contactsActionConfigSchema.optional(),
67
293
  email: emailActionConfigSchema.optional(),
@@ -82,6 +308,19 @@ const defaultPublicationSecurity = {
82
308
  repeatPolicy: "allow",
83
309
  };
84
310
  const answersSchema = z.record(z.string().min(1), answerValueSchema);
311
+ function normalizeDefinitionTags(tags) {
312
+ const seen = new Set();
313
+ const normalized = [];
314
+ for (const tag of tags) {
315
+ const value = tag.trim().replace(/\s+/g, " ").slice(0, 60);
316
+ const key = value.toLowerCase();
317
+ if (!value || seen.has(key))
318
+ continue;
319
+ seen.add(key);
320
+ normalized.push(value);
321
+ }
322
+ return normalized;
323
+ }
85
324
  function slugify(value) {
86
325
  const slug = value
87
326
  .toLowerCase()
@@ -98,9 +337,14 @@ function summarizeDefinition(row) {
98
337
  name: row.name,
99
338
  slug: row.slug,
100
339
  status: row.status,
340
+ tags: row.tags ?? [],
341
+ fields: row.fields,
342
+ actionConfig: row.actionConfig ?? {},
101
343
  fieldCount: row.fields.length,
344
+ submissionCount: row.submissionCount ?? 0,
102
345
  createdAt: row.createdAt,
103
346
  updatedAt: row.updatedAt,
347
+ publicationId: row.publicationId ?? undefined,
104
348
  publicationSlug: row.publicationSlug ?? undefined,
105
349
  };
106
350
  }
@@ -127,14 +371,20 @@ const listDefinitions = scopeAuthed.handler(async ({ context }) => {
127
371
  name: captureDefinitions.name,
128
372
  slug: captureDefinitions.slug,
129
373
  status: captureDefinitions.status,
374
+ tags: captureDefinitions.tags,
130
375
  fields: captureDefinitions.fields,
376
+ actionConfig: captureDefinitions.actionConfig,
131
377
  createdAt: captureDefinitions.createdAt,
132
378
  updatedAt: captureDefinitions.updatedAt,
379
+ publicationId: capturePublications.id,
133
380
  publicationSlug: capturePublications.slug,
381
+ submissionCount: sql `count(${captureSubmissions.id})::int`,
134
382
  })
135
383
  .from(captureDefinitions)
136
384
  .leftJoin(capturePublications, eq(capturePublications.definitionId, captureDefinitions.id))
385
+ .leftJoin(captureSubmissions, eq(captureSubmissions.definitionId, captureDefinitions.id))
137
386
  .where(and(eq(captureDefinitions.scope, context.scopeType), eq(captureDefinitions.scopeId, context.scopeId)))
387
+ .groupBy(captureDefinitions.id, captureDefinitions.scope, captureDefinitions.scopeId, captureDefinitions.surface, captureDefinitions.name, captureDefinitions.slug, captureDefinitions.status, captureDefinitions.tags, captureDefinitions.fields, captureDefinitions.actionConfig, captureDefinitions.createdAt, captureDefinitions.updatedAt, capturePublications.id, capturePublications.slug)
138
388
  .orderBy(desc(captureDefinitions.createdAt));
139
389
  return rows.map(summarizeDefinition);
140
390
  });
@@ -154,6 +404,7 @@ const createDefinition = scopeAuthed
154
404
  slug,
155
405
  status: "draft",
156
406
  fields: [],
407
+ document: deriveCaptureDocumentFromFields([]),
157
408
  actionConfig: {},
158
409
  })
159
410
  .returning();
@@ -162,6 +413,116 @@ const createDefinition = scopeAuthed
162
413
  }
163
414
  return row;
164
415
  });
416
+ const resolveStorageImageUrl = scopeAuthed
417
+ .input(z.object({ itemId: z.string().min(1) }))
418
+ .handler(async ({ context, input }) => {
419
+ const result = await getHooks().doAction("storage:get-download-url", {
420
+ scope: context.scopeType,
421
+ scopeId: context.scopeId,
422
+ itemId: input.itemId,
423
+ });
424
+ if (!result?.url) {
425
+ throw new ORPCError("NOT_FOUND", { message: "Storage image not found" });
426
+ }
427
+ return { url: result.url };
428
+ });
429
+ const listItemViews = scopeAuthed.handler(async ({ context }) => {
430
+ const db = getDb();
431
+ return db
432
+ .select()
433
+ .from(captureItemViews)
434
+ .where(and(eq(captureItemViews.scope, context.scopeType), eq(captureItemViews.scopeId, context.scopeId)))
435
+ .orderBy(desc(captureItemViews.updatedAt));
436
+ });
437
+ const createItemView = scopeAuthed
438
+ .input(z.object({ name: z.string().min(1).max(80), icon: z.string().max(40).optional() }).merge(captureItemViewStateSchema))
439
+ .handler(async ({ context, input }) => {
440
+ const db = getDb();
441
+ const [row] = await db.insert(captureItemViews).values({
442
+ scope: context.scopeType,
443
+ scopeId: context.scopeId,
444
+ name: input.name,
445
+ icon: input.icon ?? "list",
446
+ filters: input.filters ?? null,
447
+ sorts: input.sorts ?? null,
448
+ columns: input.columns ?? null,
449
+ }).returning();
450
+ return row;
451
+ });
452
+ const updateItemView = scopeAuthed
453
+ .input(z.object({ id: z.string().min(1), name: z.string().min(1).max(80).optional(), icon: z.string().max(40).optional() }).merge(captureItemViewStateSchema.partial()))
454
+ .handler(async ({ context, input }) => {
455
+ const db = getDb();
456
+ const [row] = await db.update(captureItemViews).set({
457
+ ...(input.name !== undefined ? { name: input.name } : {}),
458
+ ...(input.icon !== undefined ? { icon: input.icon } : {}),
459
+ ...(input.filters !== undefined ? { filters: input.filters } : {}),
460
+ ...(input.sorts !== undefined ? { sorts: input.sorts } : {}),
461
+ ...(input.columns !== undefined ? { columns: input.columns } : {}),
462
+ updatedAt: new Date(),
463
+ }).where(and(eq(captureItemViews.id, input.id), eq(captureItemViews.scope, context.scopeType), eq(captureItemViews.scopeId, context.scopeId))).returning();
464
+ if (!row)
465
+ throw new ORPCError("NOT_FOUND", { message: "Capture item view not found" });
466
+ return row;
467
+ });
468
+ const deleteItemView = scopeAuthed
469
+ .input(z.object({ id: z.string().min(1) }))
470
+ .handler(async ({ context, input }) => {
471
+ const db = getDb();
472
+ await db.delete(captureItemViews).where(and(eq(captureItemViews.id, input.id), eq(captureItemViews.scope, context.scopeType), eq(captureItemViews.scopeId, context.scopeId)));
473
+ return { ok: true };
474
+ });
475
+ const listSubmissionViews = scopeAuthed
476
+ .input(z.object({ definitionId: z.string().min(1) }))
477
+ .handler(async ({ context, input }) => {
478
+ await ensureDefinitionInScope({ id: input.definitionId, scope: context.scopeType, scopeId: context.scopeId });
479
+ const db = getDb();
480
+ return db
481
+ .select()
482
+ .from(captureSubmissionViews)
483
+ .where(and(eq(captureSubmissionViews.scope, context.scopeType), eq(captureSubmissionViews.scopeId, context.scopeId), eq(captureSubmissionViews.definitionId, input.definitionId)))
484
+ .orderBy(desc(captureSubmissionViews.updatedAt));
485
+ });
486
+ const createSubmissionView = scopeAuthed
487
+ .input(z.object({ definitionId: z.string().min(1), name: z.string().min(1).max(80), icon: z.string().max(40).optional() }).merge(captureSubmissionViewStateSchema))
488
+ .handler(async ({ context, input }) => {
489
+ await ensureDefinitionInScope({ id: input.definitionId, scope: context.scopeType, scopeId: context.scopeId });
490
+ const db = getDb();
491
+ const [row] = await db.insert(captureSubmissionViews).values({
492
+ scope: context.scopeType,
493
+ scopeId: context.scopeId,
494
+ definitionId: input.definitionId,
495
+ name: input.name,
496
+ icon: input.icon ?? "list",
497
+ filters: input.filters ?? null,
498
+ sorts: input.sorts ?? null,
499
+ columns: input.columns ?? null,
500
+ }).returning();
501
+ return row;
502
+ });
503
+ const updateSubmissionView = scopeAuthed
504
+ .input(z.object({ id: z.string().min(1), name: z.string().min(1).max(80).optional(), icon: z.string().max(40).optional() }).merge(captureSubmissionViewStateSchema.partial()))
505
+ .handler(async ({ context, input }) => {
506
+ const db = getDb();
507
+ const [row] = await db.update(captureSubmissionViews).set({
508
+ ...(input.name !== undefined ? { name: input.name } : {}),
509
+ ...(input.icon !== undefined ? { icon: input.icon } : {}),
510
+ ...(input.filters !== undefined ? { filters: input.filters } : {}),
511
+ ...(input.sorts !== undefined ? { sorts: input.sorts } : {}),
512
+ ...(input.columns !== undefined ? { columns: input.columns } : {}),
513
+ updatedAt: new Date(),
514
+ }).where(and(eq(captureSubmissionViews.id, input.id), eq(captureSubmissionViews.scope, context.scopeType), eq(captureSubmissionViews.scopeId, context.scopeId))).returning();
515
+ if (!row)
516
+ throw new ORPCError("NOT_FOUND", { message: "Capture submission view not found" });
517
+ return row;
518
+ });
519
+ const deleteSubmissionView = scopeAuthed
520
+ .input(z.object({ id: z.string().min(1) }))
521
+ .handler(async ({ context, input }) => {
522
+ const db = getDb();
523
+ await db.delete(captureSubmissionViews).where(and(eq(captureSubmissionViews.id, input.id), eq(captureSubmissionViews.scope, context.scopeType), eq(captureSubmissionViews.scopeId, context.scopeId)));
524
+ return { ok: true };
525
+ });
165
526
  const getDefinition = scopeAuthed
166
527
  .input(z.object({ id: z.string().min(1) }))
167
528
  .handler(async ({ context, input }) => {
@@ -176,21 +537,68 @@ const getDefinition = scopeAuthed
176
537
  .from(capturePublications)
177
538
  .where(eq(capturePublications.definitionId, definition.id))
178
539
  .limit(1);
179
- return { ...definition, publicationSlug: publication?.slug ?? null };
540
+ const document = resolveCaptureDocument(definition.fields, definition.document);
541
+ return {
542
+ ...definition,
543
+ document: await resolveCaptureStorageImageUrls({
544
+ scope: definition.scope,
545
+ scopeId: definition.scopeId,
546
+ document,
547
+ }),
548
+ publicationId: publication?.id ?? null,
549
+ publicationSlug: publication?.slug ?? null,
550
+ };
551
+ });
552
+ const updateDefinitionTags = scopeAuthed
553
+ .input(z.object({ id: z.string().min(1), tags: z.array(z.string().min(1).max(60)).max(30) }))
554
+ .handler(async ({ context, input }) => {
555
+ await ensureDefinitionInScope({ id: input.id, scope: context.scopeType, scopeId: context.scopeId });
556
+ const tags = normalizeDefinitionTags(input.tags);
557
+ const db = getDb();
558
+ const [row] = await db
559
+ .update(captureDefinitions)
560
+ .set({ tags, updatedAt: new Date() })
561
+ .where(eq(captureDefinitions.id, input.id))
562
+ .returning();
563
+ if (!row)
564
+ throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to update Capture item tags" });
565
+ return summarizeDefinition(row);
566
+ });
567
+ const updateDefinitionActionConfig = scopeAuthed
568
+ .input(z.object({ id: z.string().min(1), actionConfig: actionConfigSchema.default({}) }))
569
+ .handler(async ({ context, input }) => {
570
+ await ensureDefinitionInScope({ id: input.id, scope: context.scopeType, scopeId: context.scopeId });
571
+ const db = getDb();
572
+ const [row] = await db
573
+ .update(captureDefinitions)
574
+ .set({ actionConfig: input.actionConfig, updatedAt: new Date() })
575
+ .where(eq(captureDefinitions.id, input.id))
576
+ .returning();
577
+ if (!row)
578
+ throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to update Capture item actions" });
579
+ return summarizeDefinition(row);
180
580
  });
181
581
  const updateDefinition = scopeAuthed
182
582
  .input(z.object({
183
583
  id: z.string().min(1),
184
584
  name: z.string().min(1).max(160),
185
585
  fields: z.array(fieldSchema).max(50),
586
+ document: formDocumentSchema.optional(),
186
587
  actionConfig: actionConfigSchema.default({}),
187
588
  }))
188
589
  .handler(async ({ context, input }) => {
189
590
  await ensureDefinitionInScope({ id: input.id, scope: context.scopeType, scopeId: context.scopeId });
591
+ const persistedDocument = normalizeCaptureDocumentForPersistence(input.fields, input.document);
190
592
  const db = getDb();
191
593
  const [row] = await db
192
594
  .update(captureDefinitions)
193
- .set({ name: input.name, fields: input.fields, actionConfig: input.actionConfig, updatedAt: new Date() })
595
+ .set({
596
+ name: input.name,
597
+ fields: persistedDocument.fields,
598
+ document: persistedDocument.document,
599
+ actionConfig: input.actionConfig,
600
+ updatedAt: new Date(),
601
+ })
194
602
  .where(eq(captureDefinitions.id, input.id))
195
603
  .returning();
196
604
  if (!row) {
@@ -337,6 +745,41 @@ const listWorkflowDefinitions = scopeAuthed
337
745
  throw error;
338
746
  }
339
747
  });
748
+ function parseContactCustomFieldRows(rows) {
749
+ if (!Array.isArray(rows))
750
+ return [];
751
+ return rows.flatMap((row) => {
752
+ const record = asRecord(row);
753
+ if (!record)
754
+ return [];
755
+ const id = typeof record.id === "string" ? record.id : null;
756
+ const key = typeof record.slug === "string" ? record.slug : null;
757
+ const label = typeof record.name === "string" ? record.name : key;
758
+ const type = typeof record.type === "string" ? record.type : "text";
759
+ if (!id || !key || !label)
760
+ return [];
761
+ return [{ id, key, label, type }];
762
+ });
763
+ }
764
+ const listContactCustomFields = scopeAuthed
765
+ .input(z.object({}))
766
+ .handler(async ({ context }) => {
767
+ try {
768
+ const rows = await getDb().execute(sql `
769
+ SELECT id, slug, name, type
770
+ FROM contact_custom_fields
771
+ WHERE scope = ${context.scopeType} AND "scopeId" = ${context.scopeId} AND "builtIn" = false
772
+ ORDER BY position ASC, name ASC
773
+ `);
774
+ return parseContactCustomFieldRows(rows);
775
+ }
776
+ catch (error) {
777
+ const message = error instanceof Error ? error.message : String(error);
778
+ if (message.includes("contact_custom_fields"))
779
+ return [];
780
+ throw error;
781
+ }
782
+ });
340
783
  const listSubmissions = scopeAuthed.handler(async ({ context }) => {
341
784
  const db = getDb();
342
785
  const rows = await db
@@ -582,7 +1025,18 @@ async function loadPublicForm(input, headers) {
582
1025
  throw new ORPCError("NOT_FOUND", { message: "Form not found" });
583
1026
  }
584
1027
  enforceOrigin(headers, row.publication.security);
585
- return row;
1028
+ const document = resolveCaptureDocument(row.definition.fields, row.definition.document);
1029
+ return {
1030
+ publication: row.publication,
1031
+ definition: {
1032
+ ...row.definition,
1033
+ document: await resolveCaptureStorageImageUrls({
1034
+ scope: row.definition.scope,
1035
+ scopeId: row.definition.scopeId,
1036
+ document,
1037
+ }),
1038
+ },
1039
+ };
586
1040
  }
587
1041
  const getPublicForm = base
588
1042
  .input(publicFormLookupSchema)
@@ -593,7 +1047,8 @@ const getPublicForm = base
593
1047
  publicationId: row.publication.id,
594
1048
  title: row.definition.name,
595
1049
  slug: row.publication.slug,
596
- fields: row.definition.fields,
1050
+ fields: extractCaptureAnswerFields(row.definition.document),
1051
+ document: row.definition.document,
597
1052
  security: {
598
1053
  minSubmitSeconds: row.publication.security.minSubmitSeconds ?? defaultPublicationSecurity.minSubmitSeconds,
599
1054
  honeypotFieldName: row.publication.security.honeypotFieldName ?? defaultPublicationSecurity.honeypotFieldName,
@@ -714,12 +1169,47 @@ async function executeConfiguredActionByType(args) {
714
1169
  }
715
1170
  throw new Error(`Unsupported action type: ${args.actionType}`);
716
1171
  }
1172
+ const CAPTURE_SUBMISSION_TRIGGER_TYPE = "capture-submission-trigger";
1173
+ async function dispatchCaptureSubmissionWorkflowTriggers(context) {
1174
+ const input = buildWorkflowSubmissionPayload(context);
1175
+ const result = await getHooks().tryAction("workflows:trigger.dispatch", {
1176
+ scope: context.scope,
1177
+ scopeId: context.scopeId,
1178
+ triggerType: CAPTURE_SUBMISSION_TRIGGER_TYPE,
1179
+ input,
1180
+ match: {
1181
+ definitionId: context.definitionId,
1182
+ surface: context.definitionSurface,
1183
+ },
1184
+ idempotencyKey: `capture:${context.submissionId}:trigger:${CAPTURE_SUBMISSION_TRIGGER_TYPE}`,
1185
+ callerId: "capture",
1186
+ runTags: {
1187
+ event: "capture.submission.completed",
1188
+ captureDefinitionId: context.definitionId,
1189
+ captureSubmissionId: context.submissionId,
1190
+ },
1191
+ runMetadata: {
1192
+ captureDefinitionName: context.definitionName,
1193
+ },
1194
+ execute: true,
1195
+ });
1196
+ const started = result?.started ?? [];
1197
+ return {
1198
+ message: result == null
1199
+ ? "Workflows tool is not enabled"
1200
+ : started.length === 0
1201
+ ? "No matching workflow trigger found"
1202
+ : `Started ${started.length} workflow run${started.length === 1 ? "" : "s"}`,
1203
+ workflowRuns: started,
1204
+ };
1205
+ }
717
1206
  async function runConfiguredActions(args) {
718
1207
  const context = {
719
1208
  scope: args.definition.scope,
720
1209
  scopeId: args.definition.scopeId,
721
1210
  definitionId: args.definition.id,
722
1211
  definitionName: args.definition.name,
1212
+ definitionSurface: args.definition.surface,
723
1213
  submissionId: args.submissionId,
724
1214
  fields: args.definition.fields,
725
1215
  answers: withOutcomeAnswers(args.answers, args.outcome),
@@ -747,15 +1237,11 @@ async function runConfiguredActions(args) {
747
1237
  execute: () => emailActionAdapter.execute({ config: parsedConfig, context }),
748
1238
  });
749
1239
  }
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
- }
1240
+ await recordActionRun({
1241
+ context,
1242
+ actionType: "workflows.trigger.dispatch",
1243
+ execute: () => dispatchCaptureSubmissionWorkflowTriggers(context),
1244
+ });
759
1245
  }
760
1246
  async function enforcePublicSubmitGuards(args) {
761
1247
  const security = { ...defaultPublicationSecurity, ...args.publication.security };
@@ -799,8 +1285,24 @@ const submitPublicForm = base
799
1285
  honeypot: input.honeypot,
800
1286
  });
801
1287
  const normalizedAnswers = {};
802
- const visibleFields = getVisibleCaptureFields(definition.fields, input.answers);
803
- for (const field of visibleFields) {
1288
+ const answerFields = extractCaptureAnswerFields(definition.document);
1289
+ const visiblePageIds = new Set(definition.document.pages
1290
+ .filter((page) => !page.hidden && evaluateCaptureCondition(page.visibleWhen, answerFields, input.answers))
1291
+ .map((page) => page.id));
1292
+ const itemPageById = new Map(definition.document.items.map((item) => [item.id, item.pageId]));
1293
+ const visibleFields = getVisibleCaptureFields(answerFields, input.answers)
1294
+ .filter((field) => !field.hidden)
1295
+ .filter((field) => !isHiddenCaptureField(field))
1296
+ .filter((field) => visiblePageIds.has(itemPageById.get(field.id) ?? ""));
1297
+ const hiddenFields = answerFields.filter((field) => !field.hidden && isHiddenCaptureField(field));
1298
+ const submittedFields = [...new Map([...visibleFields, ...hiddenFields].map((field) => [field.id, field])).values()];
1299
+ for (const field of hiddenFields.filter(isHoneypotCaptureField)) {
1300
+ const value = normalizeCaptureAnswer(field, input.answers[field.key], REFERENCE_CAPTURE_FIELD_TYPES);
1301
+ if (typeof value === "string" && value.trim().length > 0) {
1302
+ throw new ORPCError("BAD_REQUEST", { message: "Unable to submit form" });
1303
+ }
1304
+ }
1305
+ for (const field of submittedFields.filter((field) => !isHoneypotCaptureField(field))) {
804
1306
  const value = normalizeCaptureAnswer(field, input.answers[field.key], REFERENCE_CAPTURE_FIELD_TYPES);
805
1307
  const validationError = validateCaptureAnswer(field, value, REFERENCE_CAPTURE_FIELD_TYPES);
806
1308
  if (validationError) {
@@ -808,12 +1310,19 @@ const submitPublicForm = base
808
1310
  }
809
1311
  normalizedAnswers[field.key] = value;
810
1312
  }
1313
+ for (const field of submittedFields.filter((field) => !isHoneypotCaptureField(field))) {
1314
+ const ruleError = validateCaptureFieldRules(field, answerFields, normalizedAnswers);
1315
+ if (ruleError) {
1316
+ throw new ORPCError("BAD_REQUEST", { message: ruleError });
1317
+ }
1318
+ }
811
1319
  const outcome = input.source === "quiz" ? evaluateQuizOutcome(definition.actionConfig.quiz, normalizedAnswers) : undefined;
812
1320
  const db = getDb();
813
1321
  const snapshot = {
814
1322
  definitionId: definition.id,
815
1323
  title: definition.name,
816
- fields: definition.fields,
1324
+ fields: answerFields,
1325
+ document: definition.document,
817
1326
  answers: normalizedAnswers,
818
1327
  ...(outcome ? { outcome } : {}),
819
1328
  };
@@ -858,7 +1367,7 @@ const submitPublicForm = base
858
1367
  if (existingSubmission && repeatPolicy === "update") {
859
1368
  await db.delete(captureAnswers).where(eq(captureAnswers.submissionId, submission.id));
860
1369
  }
861
- const answerRows = visibleFields.map((field) => ({
1370
+ const answerRows = submittedFields.filter((field) => !isHoneypotCaptureField(field)).map((field) => ({
862
1371
  scope: definition.scope,
863
1372
  scopeId: definition.scopeId,
864
1373
  submissionId: submission.id,
@@ -882,15 +1391,31 @@ const submitPublicForm = base
882
1391
  const captureRouter = {
883
1392
  definitions: {
884
1393
  list: listDefinitions,
1394
+ views: {
1395
+ list: listItemViews,
1396
+ create: createItemView,
1397
+ update: updateItemView,
1398
+ delete: deleteItemView,
1399
+ },
885
1400
  create: createDefinition,
886
1401
  get: getDefinition,
887
1402
  update: updateDefinition,
1403
+ updateTags: updateDefinitionTags,
1404
+ updateActionConfig: updateDefinitionActionConfig,
888
1405
  publish: publishDefinition,
1406
+ resolveStorageImageUrl,
889
1407
  updatePublication,
890
1408
  listWorkflows: listWorkflowDefinitions,
1409
+ listContactCustomFields,
891
1410
  },
892
1411
  submissions: {
893
1412
  list: listSubmissions,
1413
+ views: {
1414
+ list: listSubmissionViews,
1415
+ create: createSubmissionView,
1416
+ update: updateSubmissionView,
1417
+ delete: deleteSubmissionView,
1418
+ },
894
1419
  get: getSubmission,
895
1420
  retryAction: retryActionRun,
896
1421
  },