@ydtb/tk-scope-capture 0.22.0 → 0.23.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.
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 +1855 -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 +151 -27
  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 +849 -8
  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 +875 -9
  40. package/dist/src/server/api/router.d.ts.map +1 -1
  41. package/dist/src/server/api/router.js +544 -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 +89 -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 +9 -6
@@ -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,7 +337,11 @@ 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,
104
347
  publicationSlug: row.publicationSlug ?? undefined,
@@ -127,14 +370,19 @@ const listDefinitions = scopeAuthed.handler(async ({ context }) => {
127
370
  name: captureDefinitions.name,
128
371
  slug: captureDefinitions.slug,
129
372
  status: captureDefinitions.status,
373
+ tags: captureDefinitions.tags,
130
374
  fields: captureDefinitions.fields,
375
+ actionConfig: captureDefinitions.actionConfig,
131
376
  createdAt: captureDefinitions.createdAt,
132
377
  updatedAt: captureDefinitions.updatedAt,
133
378
  publicationSlug: capturePublications.slug,
379
+ submissionCount: sql `count(${captureSubmissions.id})::int`,
134
380
  })
135
381
  .from(captureDefinitions)
136
382
  .leftJoin(capturePublications, eq(capturePublications.definitionId, captureDefinitions.id))
383
+ .leftJoin(captureSubmissions, eq(captureSubmissions.definitionId, captureDefinitions.id))
137
384
  .where(and(eq(captureDefinitions.scope, context.scopeType), eq(captureDefinitions.scopeId, context.scopeId)))
385
+ .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.slug)
138
386
  .orderBy(desc(captureDefinitions.createdAt));
139
387
  return rows.map(summarizeDefinition);
140
388
  });
@@ -154,6 +402,7 @@ const createDefinition = scopeAuthed
154
402
  slug,
155
403
  status: "draft",
156
404
  fields: [],
405
+ document: deriveCaptureDocumentFromFields([]),
157
406
  actionConfig: {},
158
407
  })
159
408
  .returning();
@@ -162,6 +411,116 @@ const createDefinition = scopeAuthed
162
411
  }
163
412
  return row;
164
413
  });
414
+ const resolveStorageImageUrl = scopeAuthed
415
+ .input(z.object({ itemId: z.string().min(1) }))
416
+ .handler(async ({ context, input }) => {
417
+ const result = await getHooks().doAction("storage:get-download-url", {
418
+ scope: context.scopeType,
419
+ scopeId: context.scopeId,
420
+ itemId: input.itemId,
421
+ });
422
+ if (!result?.url) {
423
+ throw new ORPCError("NOT_FOUND", { message: "Storage image not found" });
424
+ }
425
+ return { url: result.url };
426
+ });
427
+ const listItemViews = scopeAuthed.handler(async ({ context }) => {
428
+ const db = getDb();
429
+ return db
430
+ .select()
431
+ .from(captureItemViews)
432
+ .where(and(eq(captureItemViews.scope, context.scopeType), eq(captureItemViews.scopeId, context.scopeId)))
433
+ .orderBy(desc(captureItemViews.updatedAt));
434
+ });
435
+ const createItemView = scopeAuthed
436
+ .input(z.object({ name: z.string().min(1).max(80), icon: z.string().max(40).optional() }).merge(captureItemViewStateSchema))
437
+ .handler(async ({ context, input }) => {
438
+ const db = getDb();
439
+ const [row] = await db.insert(captureItemViews).values({
440
+ scope: context.scopeType,
441
+ scopeId: context.scopeId,
442
+ name: input.name,
443
+ icon: input.icon ?? "list",
444
+ filters: input.filters ?? null,
445
+ sorts: input.sorts ?? null,
446
+ columns: input.columns ?? null,
447
+ }).returning();
448
+ return row;
449
+ });
450
+ const updateItemView = scopeAuthed
451
+ .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()))
452
+ .handler(async ({ context, input }) => {
453
+ const db = getDb();
454
+ const [row] = await db.update(captureItemViews).set({
455
+ ...(input.name !== undefined ? { name: input.name } : {}),
456
+ ...(input.icon !== undefined ? { icon: input.icon } : {}),
457
+ ...(input.filters !== undefined ? { filters: input.filters } : {}),
458
+ ...(input.sorts !== undefined ? { sorts: input.sorts } : {}),
459
+ ...(input.columns !== undefined ? { columns: input.columns } : {}),
460
+ updatedAt: new Date(),
461
+ }).where(and(eq(captureItemViews.id, input.id), eq(captureItemViews.scope, context.scopeType), eq(captureItemViews.scopeId, context.scopeId))).returning();
462
+ if (!row)
463
+ throw new ORPCError("NOT_FOUND", { message: "Capture item view not found" });
464
+ return row;
465
+ });
466
+ const deleteItemView = scopeAuthed
467
+ .input(z.object({ id: z.string().min(1) }))
468
+ .handler(async ({ context, input }) => {
469
+ const db = getDb();
470
+ await db.delete(captureItemViews).where(and(eq(captureItemViews.id, input.id), eq(captureItemViews.scope, context.scopeType), eq(captureItemViews.scopeId, context.scopeId)));
471
+ return { ok: true };
472
+ });
473
+ const listSubmissionViews = scopeAuthed
474
+ .input(z.object({ definitionId: z.string().min(1) }))
475
+ .handler(async ({ context, input }) => {
476
+ await ensureDefinitionInScope({ id: input.definitionId, scope: context.scopeType, scopeId: context.scopeId });
477
+ const db = getDb();
478
+ return db
479
+ .select()
480
+ .from(captureSubmissionViews)
481
+ .where(and(eq(captureSubmissionViews.scope, context.scopeType), eq(captureSubmissionViews.scopeId, context.scopeId), eq(captureSubmissionViews.definitionId, input.definitionId)))
482
+ .orderBy(desc(captureSubmissionViews.updatedAt));
483
+ });
484
+ const createSubmissionView = scopeAuthed
485
+ .input(z.object({ definitionId: z.string().min(1), name: z.string().min(1).max(80), icon: z.string().max(40).optional() }).merge(captureSubmissionViewStateSchema))
486
+ .handler(async ({ context, input }) => {
487
+ await ensureDefinitionInScope({ id: input.definitionId, scope: context.scopeType, scopeId: context.scopeId });
488
+ const db = getDb();
489
+ const [row] = await db.insert(captureSubmissionViews).values({
490
+ scope: context.scopeType,
491
+ scopeId: context.scopeId,
492
+ definitionId: input.definitionId,
493
+ name: input.name,
494
+ icon: input.icon ?? "list",
495
+ filters: input.filters ?? null,
496
+ sorts: input.sorts ?? null,
497
+ columns: input.columns ?? null,
498
+ }).returning();
499
+ return row;
500
+ });
501
+ const updateSubmissionView = scopeAuthed
502
+ .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()))
503
+ .handler(async ({ context, input }) => {
504
+ const db = getDb();
505
+ const [row] = await db.update(captureSubmissionViews).set({
506
+ ...(input.name !== undefined ? { name: input.name } : {}),
507
+ ...(input.icon !== undefined ? { icon: input.icon } : {}),
508
+ ...(input.filters !== undefined ? { filters: input.filters } : {}),
509
+ ...(input.sorts !== undefined ? { sorts: input.sorts } : {}),
510
+ ...(input.columns !== undefined ? { columns: input.columns } : {}),
511
+ updatedAt: new Date(),
512
+ }).where(and(eq(captureSubmissionViews.id, input.id), eq(captureSubmissionViews.scope, context.scopeType), eq(captureSubmissionViews.scopeId, context.scopeId))).returning();
513
+ if (!row)
514
+ throw new ORPCError("NOT_FOUND", { message: "Capture submission view not found" });
515
+ return row;
516
+ });
517
+ const deleteSubmissionView = scopeAuthed
518
+ .input(z.object({ id: z.string().min(1) }))
519
+ .handler(async ({ context, input }) => {
520
+ const db = getDb();
521
+ await db.delete(captureSubmissionViews).where(and(eq(captureSubmissionViews.id, input.id), eq(captureSubmissionViews.scope, context.scopeType), eq(captureSubmissionViews.scopeId, context.scopeId)));
522
+ return { ok: true };
523
+ });
165
524
  const getDefinition = scopeAuthed
166
525
  .input(z.object({ id: z.string().min(1) }))
167
526
  .handler(async ({ context, input }) => {
@@ -176,21 +535,67 @@ const getDefinition = scopeAuthed
176
535
  .from(capturePublications)
177
536
  .where(eq(capturePublications.definitionId, definition.id))
178
537
  .limit(1);
179
- return { ...definition, publicationSlug: publication?.slug ?? null };
538
+ const document = resolveCaptureDocument(definition.fields, definition.document);
539
+ return {
540
+ ...definition,
541
+ document: await resolveCaptureStorageImageUrls({
542
+ scope: definition.scope,
543
+ scopeId: definition.scopeId,
544
+ document,
545
+ }),
546
+ publicationSlug: publication?.slug ?? null,
547
+ };
548
+ });
549
+ const updateDefinitionTags = scopeAuthed
550
+ .input(z.object({ id: z.string().min(1), tags: z.array(z.string().min(1).max(60)).max(30) }))
551
+ .handler(async ({ context, input }) => {
552
+ await ensureDefinitionInScope({ id: input.id, scope: context.scopeType, scopeId: context.scopeId });
553
+ const tags = normalizeDefinitionTags(input.tags);
554
+ const db = getDb();
555
+ const [row] = await db
556
+ .update(captureDefinitions)
557
+ .set({ tags, updatedAt: new Date() })
558
+ .where(eq(captureDefinitions.id, input.id))
559
+ .returning();
560
+ if (!row)
561
+ throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to update Capture item tags" });
562
+ return summarizeDefinition(row);
563
+ });
564
+ const updateDefinitionActionConfig = scopeAuthed
565
+ .input(z.object({ id: z.string().min(1), actionConfig: actionConfigSchema.default({}) }))
566
+ .handler(async ({ context, input }) => {
567
+ await ensureDefinitionInScope({ id: input.id, scope: context.scopeType, scopeId: context.scopeId });
568
+ const db = getDb();
569
+ const [row] = await db
570
+ .update(captureDefinitions)
571
+ .set({ actionConfig: input.actionConfig, updatedAt: new Date() })
572
+ .where(eq(captureDefinitions.id, input.id))
573
+ .returning();
574
+ if (!row)
575
+ throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to update Capture item actions" });
576
+ return summarizeDefinition(row);
180
577
  });
181
578
  const updateDefinition = scopeAuthed
182
579
  .input(z.object({
183
580
  id: z.string().min(1),
184
581
  name: z.string().min(1).max(160),
185
582
  fields: z.array(fieldSchema).max(50),
583
+ document: formDocumentSchema.optional(),
186
584
  actionConfig: actionConfigSchema.default({}),
187
585
  }))
188
586
  .handler(async ({ context, input }) => {
189
587
  await ensureDefinitionInScope({ id: input.id, scope: context.scopeType, scopeId: context.scopeId });
588
+ const persistedDocument = normalizeCaptureDocumentForPersistence(input.fields, input.document);
190
589
  const db = getDb();
191
590
  const [row] = await db
192
591
  .update(captureDefinitions)
193
- .set({ name: input.name, fields: input.fields, actionConfig: input.actionConfig, updatedAt: new Date() })
592
+ .set({
593
+ name: input.name,
594
+ fields: persistedDocument.fields,
595
+ document: persistedDocument.document,
596
+ actionConfig: input.actionConfig,
597
+ updatedAt: new Date(),
598
+ })
194
599
  .where(eq(captureDefinitions.id, input.id))
195
600
  .returning();
196
601
  if (!row) {
@@ -337,6 +742,41 @@ const listWorkflowDefinitions = scopeAuthed
337
742
  throw error;
338
743
  }
339
744
  });
745
+ function parseContactCustomFieldRows(rows) {
746
+ if (!Array.isArray(rows))
747
+ return [];
748
+ return rows.flatMap((row) => {
749
+ const record = asRecord(row);
750
+ if (!record)
751
+ return [];
752
+ const id = typeof record.id === "string" ? record.id : null;
753
+ const key = typeof record.slug === "string" ? record.slug : null;
754
+ const label = typeof record.name === "string" ? record.name : key;
755
+ const type = typeof record.type === "string" ? record.type : "text";
756
+ if (!id || !key || !label)
757
+ return [];
758
+ return [{ id, key, label, type }];
759
+ });
760
+ }
761
+ const listContactCustomFields = scopeAuthed
762
+ .input(z.object({}))
763
+ .handler(async ({ context }) => {
764
+ try {
765
+ const rows = await getDb().execute(sql `
766
+ SELECT id, slug, name, type
767
+ FROM contact_custom_fields
768
+ WHERE scope = ${context.scopeType} AND "scopeId" = ${context.scopeId} AND "builtIn" = false
769
+ ORDER BY position ASC, name ASC
770
+ `);
771
+ return parseContactCustomFieldRows(rows);
772
+ }
773
+ catch (error) {
774
+ const message = error instanceof Error ? error.message : String(error);
775
+ if (message.includes("contact_custom_fields"))
776
+ return [];
777
+ throw error;
778
+ }
779
+ });
340
780
  const listSubmissions = scopeAuthed.handler(async ({ context }) => {
341
781
  const db = getDb();
342
782
  const rows = await db
@@ -582,7 +1022,18 @@ async function loadPublicForm(input, headers) {
582
1022
  throw new ORPCError("NOT_FOUND", { message: "Form not found" });
583
1023
  }
584
1024
  enforceOrigin(headers, row.publication.security);
585
- return row;
1025
+ const document = resolveCaptureDocument(row.definition.fields, row.definition.document);
1026
+ return {
1027
+ publication: row.publication,
1028
+ definition: {
1029
+ ...row.definition,
1030
+ document: await resolveCaptureStorageImageUrls({
1031
+ scope: row.definition.scope,
1032
+ scopeId: row.definition.scopeId,
1033
+ document,
1034
+ }),
1035
+ },
1036
+ };
586
1037
  }
587
1038
  const getPublicForm = base
588
1039
  .input(publicFormLookupSchema)
@@ -593,7 +1044,8 @@ const getPublicForm = base
593
1044
  publicationId: row.publication.id,
594
1045
  title: row.definition.name,
595
1046
  slug: row.publication.slug,
596
- fields: row.definition.fields,
1047
+ fields: extractCaptureAnswerFields(row.definition.document),
1048
+ document: row.definition.document,
597
1049
  security: {
598
1050
  minSubmitSeconds: row.publication.security.minSubmitSeconds ?? defaultPublicationSecurity.minSubmitSeconds,
599
1051
  honeypotFieldName: row.publication.security.honeypotFieldName ?? defaultPublicationSecurity.honeypotFieldName,
@@ -714,12 +1166,47 @@ async function executeConfiguredActionByType(args) {
714
1166
  }
715
1167
  throw new Error(`Unsupported action type: ${args.actionType}`);
716
1168
  }
1169
+ const CAPTURE_SUBMISSION_TRIGGER_TYPE = "capture-submission-trigger";
1170
+ async function dispatchCaptureSubmissionWorkflowTriggers(context) {
1171
+ const input = buildWorkflowSubmissionPayload(context);
1172
+ const result = await getHooks().tryAction("workflows:trigger.dispatch", {
1173
+ scope: context.scope,
1174
+ scopeId: context.scopeId,
1175
+ triggerType: CAPTURE_SUBMISSION_TRIGGER_TYPE,
1176
+ input,
1177
+ match: {
1178
+ definitionId: context.definitionId,
1179
+ surface: context.definitionSurface,
1180
+ },
1181
+ idempotencyKey: `capture:${context.submissionId}:trigger:${CAPTURE_SUBMISSION_TRIGGER_TYPE}`,
1182
+ callerId: "capture",
1183
+ runTags: {
1184
+ event: "capture.submission.completed",
1185
+ captureDefinitionId: context.definitionId,
1186
+ captureSubmissionId: context.submissionId,
1187
+ },
1188
+ runMetadata: {
1189
+ captureDefinitionName: context.definitionName,
1190
+ },
1191
+ execute: true,
1192
+ });
1193
+ const started = result?.started ?? [];
1194
+ return {
1195
+ message: result == null
1196
+ ? "Workflows tool is not enabled"
1197
+ : started.length === 0
1198
+ ? "No matching workflow trigger found"
1199
+ : `Started ${started.length} workflow run${started.length === 1 ? "" : "s"}`,
1200
+ workflowRuns: started,
1201
+ };
1202
+ }
717
1203
  async function runConfiguredActions(args) {
718
1204
  const context = {
719
1205
  scope: args.definition.scope,
720
1206
  scopeId: args.definition.scopeId,
721
1207
  definitionId: args.definition.id,
722
1208
  definitionName: args.definition.name,
1209
+ definitionSurface: args.definition.surface,
723
1210
  submissionId: args.submissionId,
724
1211
  fields: args.definition.fields,
725
1212
  answers: withOutcomeAnswers(args.answers, args.outcome),
@@ -747,15 +1234,11 @@ async function runConfiguredActions(args) {
747
1234
  execute: () => emailActionAdapter.execute({ config: parsedConfig, context }),
748
1235
  });
749
1236
  }
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
- }
1237
+ await recordActionRun({
1238
+ context,
1239
+ actionType: "workflows.trigger.dispatch",
1240
+ execute: () => dispatchCaptureSubmissionWorkflowTriggers(context),
1241
+ });
759
1242
  }
760
1243
  async function enforcePublicSubmitGuards(args) {
761
1244
  const security = { ...defaultPublicationSecurity, ...args.publication.security };
@@ -799,8 +1282,24 @@ const submitPublicForm = base
799
1282
  honeypot: input.honeypot,
800
1283
  });
801
1284
  const normalizedAnswers = {};
802
- const visibleFields = getVisibleCaptureFields(definition.fields, input.answers);
803
- for (const field of visibleFields) {
1285
+ const answerFields = extractCaptureAnswerFields(definition.document);
1286
+ const visiblePageIds = new Set(definition.document.pages
1287
+ .filter((page) => !page.hidden && evaluateCaptureCondition(page.visibleWhen, answerFields, input.answers))
1288
+ .map((page) => page.id));
1289
+ const itemPageById = new Map(definition.document.items.map((item) => [item.id, item.pageId]));
1290
+ const visibleFields = getVisibleCaptureFields(answerFields, input.answers)
1291
+ .filter((field) => !field.hidden)
1292
+ .filter((field) => !isHiddenCaptureField(field))
1293
+ .filter((field) => visiblePageIds.has(itemPageById.get(field.id) ?? ""));
1294
+ const hiddenFields = answerFields.filter((field) => !field.hidden && isHiddenCaptureField(field));
1295
+ const submittedFields = [...new Map([...visibleFields, ...hiddenFields].map((field) => [field.id, field])).values()];
1296
+ for (const field of hiddenFields.filter(isHoneypotCaptureField)) {
1297
+ const value = normalizeCaptureAnswer(field, input.answers[field.key], REFERENCE_CAPTURE_FIELD_TYPES);
1298
+ if (typeof value === "string" && value.trim().length > 0) {
1299
+ throw new ORPCError("BAD_REQUEST", { message: "Unable to submit form" });
1300
+ }
1301
+ }
1302
+ for (const field of submittedFields.filter((field) => !isHoneypotCaptureField(field))) {
804
1303
  const value = normalizeCaptureAnswer(field, input.answers[field.key], REFERENCE_CAPTURE_FIELD_TYPES);
805
1304
  const validationError = validateCaptureAnswer(field, value, REFERENCE_CAPTURE_FIELD_TYPES);
806
1305
  if (validationError) {
@@ -808,12 +1307,19 @@ const submitPublicForm = base
808
1307
  }
809
1308
  normalizedAnswers[field.key] = value;
810
1309
  }
1310
+ for (const field of submittedFields.filter((field) => !isHoneypotCaptureField(field))) {
1311
+ const ruleError = validateCaptureFieldRules(field, answerFields, normalizedAnswers);
1312
+ if (ruleError) {
1313
+ throw new ORPCError("BAD_REQUEST", { message: ruleError });
1314
+ }
1315
+ }
811
1316
  const outcome = input.source === "quiz" ? evaluateQuizOutcome(definition.actionConfig.quiz, normalizedAnswers) : undefined;
812
1317
  const db = getDb();
813
1318
  const snapshot = {
814
1319
  definitionId: definition.id,
815
1320
  title: definition.name,
816
- fields: definition.fields,
1321
+ fields: answerFields,
1322
+ document: definition.document,
817
1323
  answers: normalizedAnswers,
818
1324
  ...(outcome ? { outcome } : {}),
819
1325
  };
@@ -858,7 +1364,7 @@ const submitPublicForm = base
858
1364
  if (existingSubmission && repeatPolicy === "update") {
859
1365
  await db.delete(captureAnswers).where(eq(captureAnswers.submissionId, submission.id));
860
1366
  }
861
- const answerRows = visibleFields.map((field) => ({
1367
+ const answerRows = submittedFields.filter((field) => !isHoneypotCaptureField(field)).map((field) => ({
862
1368
  scope: definition.scope,
863
1369
  scopeId: definition.scopeId,
864
1370
  submissionId: submission.id,
@@ -882,15 +1388,31 @@ const submitPublicForm = base
882
1388
  const captureRouter = {
883
1389
  definitions: {
884
1390
  list: listDefinitions,
1391
+ views: {
1392
+ list: listItemViews,
1393
+ create: createItemView,
1394
+ update: updateItemView,
1395
+ delete: deleteItemView,
1396
+ },
885
1397
  create: createDefinition,
886
1398
  get: getDefinition,
887
1399
  update: updateDefinition,
1400
+ updateTags: updateDefinitionTags,
1401
+ updateActionConfig: updateDefinitionActionConfig,
888
1402
  publish: publishDefinition,
1403
+ resolveStorageImageUrl,
889
1404
  updatePublication,
890
1405
  listWorkflows: listWorkflowDefinitions,
1406
+ listContactCustomFields,
891
1407
  },
892
1408
  submissions: {
893
1409
  list: listSubmissions,
1410
+ views: {
1411
+ list: listSubmissionViews,
1412
+ create: createSubmissionView,
1413
+ update: updateSubmissionView,
1414
+ delete: deleteSubmissionView,
1415
+ },
894
1416
  get: getSubmission,
895
1417
  retryAction: retryActionRun,
896
1418
  },