@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.
- package/LICENSE +21 -0
- package/dist/src/client/pages/CaptureBuilderPage.d.ts +2 -0
- package/dist/src/client/pages/CaptureBuilderPage.d.ts.map +1 -0
- package/dist/src/client/pages/CaptureBuilderPage.js +308 -0
- package/dist/src/client/pages/CaptureBuilderPage.js.map +1 -0
- package/dist/src/client/pages/CaptureSubmissionDetailPage.d.ts +2 -0
- package/dist/src/client/pages/CaptureSubmissionDetailPage.d.ts.map +1 -0
- package/dist/src/client/pages/CaptureSubmissionDetailPage.js +64 -0
- package/dist/src/client/pages/CaptureSubmissionDetailPage.js.map +1 -0
- package/dist/src/client/pages/CaptureSubmissionsPage.d.ts +2 -0
- package/dist/src/client/pages/CaptureSubmissionsPage.d.ts.map +1 -0
- package/dist/src/client/pages/CaptureSubmissionsPage.js +22 -0
- package/dist/src/client/pages/CaptureSubmissionsPage.js.map +1 -0
- package/dist/src/client/pages/PublicFormPage.d.ts +2 -0
- package/dist/src/client/pages/PublicFormPage.d.ts.map +1 -0
- package/dist/src/client/pages/PublicFormPage.js +62 -0
- package/dist/src/client/pages/PublicFormPage.js.map +1 -0
- package/dist/src/client/pages/PublicQuizPage.d.ts +2 -0
- package/dist/src/client/pages/PublicQuizPage.d.ts.map +1 -0
- package/dist/src/client/pages/PublicQuizPage.js +87 -0
- package/dist/src/client/pages/PublicQuizPage.js.map +1 -0
- package/dist/src/client/pages/QuizBuilderPage.d.ts +2 -0
- package/dist/src/client/pages/QuizBuilderPage.d.ts.map +1 -0
- package/dist/src/client/pages/QuizBuilderPage.js +118 -0
- package/dist/src/client/pages/QuizBuilderPage.js.map +1 -0
- package/dist/src/client.d.ts +617 -0
- package/dist/src/client.d.ts.map +1 -0
- package/dist/src/client.js +39 -0
- package/dist/src/client.js.map +1 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/server/api/action-adapters.d.ts +93 -0
- package/dist/src/server/api/action-adapters.d.ts.map +1 -0
- package/dist/src/server/api/action-adapters.js +147 -0
- package/dist/src/server/api/action-adapters.js.map +1 -0
- package/dist/src/server/api/router.d.ts +621 -0
- package/dist/src/server/api/router.d.ts.map +1 -0
- package/dist/src/server/api/router.js +903 -0
- package/dist/src/server/api/router.js.map +1 -0
- package/dist/src/server.d.ts +3 -0
- package/dist/src/server.d.ts.map +1 -0
- package/dist/src/server.js +15 -0
- package/dist/src/server.js.map +1 -0
- package/dist/src/shared/conditions.d.ts +5 -0
- package/dist/src/shared/conditions.d.ts.map +1 -0
- package/dist/src/shared/conditions.js +39 -0
- package/dist/src/shared/conditions.js.map +1 -0
- package/dist/src/shared/db/schema.d.ts +983 -0
- package/dist/src/shared/db/schema.d.ts.map +1 -0
- package/dist/src/shared/db/schema.js +113 -0
- package/dist/src/shared/db/schema.js.map +1 -0
- package/dist/src/shared/field-types.d.ts +7 -0
- package/dist/src/shared/field-types.d.ts.map +1 -0
- package/dist/src/shared/field-types.js +116 -0
- package/dist/src/shared/field-types.js.map +1 -0
- package/dist/src/shared/scoring.d.ts +3 -0
- package/dist/src/shared/scoring.d.ts.map +1 -0
- package/dist/src/shared/scoring.js +47 -0
- package/dist/src/shared/scoring.js.map +1 -0
- package/dist/src/shared/templates.d.ts +20 -0
- package/dist/src/shared/templates.d.ts.map +1 -0
- package/dist/src/shared/templates.js +122 -0
- package/dist/src/shared/templates.js.map +1 -0
- package/dist/src/shared/tool.d.ts +2 -0
- package/dist/src/shared/tool.d.ts.map +1 -0
- package/dist/src/shared/tool.js +7 -0
- package/dist/src/shared/tool.js.map +1 -0
- package/dist/src/shared/types.d.ts +251 -0
- package/dist/src/shared/types.d.ts.map +1 -0
- package/dist/src/shared/types.js +10 -0
- package/dist/src/shared/types.js.map +1 -0
- 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
|