@agent-native/core 0.176.1 → 0.177.0-nightly-20260828155142
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/corpus/README.md +1 -1
- package/corpus/templates/analytics/actions/set-workspace-feature-flag.ts +2 -2
- package/corpus/templates/analytics/app/components/agents/FeatureFlagsFleetPanel.tsx +16 -1
- package/corpus/templates/analytics/changelog/2026-08-27-show-analytics-s-own-registered-flags-in-fleet-feature-flag-.md +6 -0
- package/corpus/templates/analytics/server/lib/workspace-feature-flags.ts +204 -32
- package/corpus/templates/assets/changelog/2026-08-26-templates-across-brand-kits.md +1 -0
- package/corpus/templates/calendar/app/components/calendar/CreateEventDialog.tsx +61 -68
- package/corpus/templates/calendar/app/components/calendar/DayView.tsx +9 -3
- package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +3 -3
- package/corpus/templates/calendar/app/components/calendar/WeekView.tsx +8 -3
- package/corpus/templates/calendar/app/lib/calendar-timezone.ts +12 -3
- package/corpus/templates/calendar/changelog/2026-08-28-all-day-events-stay-in-the-compact-top-bar-in-day-and-week-v.md +6 -0
- package/corpus/templates/calendar/changelog/2026-08-28-event-creators-can-choose-whether-a-meeting-shows-as-free-or.md +6 -0
- package/corpus/templates/clips/app/global.css +0 -27
- package/corpus/templates/clips/app/i18n/en-US.ts +2 -0
- package/corpus/templates/clips/app/routes/download.tsx +131 -16
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +35 -20
- package/corpus/templates/clips/server/routes/api/clips-latest.json.get.ts +77 -22
- package/corpus/templates/forms/actions/response-insights.ts +4 -4
- package/corpus/templates/forms/actions/retry-response-deliveries.ts +26 -0
- package/corpus/templates/forms/server/db/schema.ts +64 -17
- package/corpus/templates/forms/server/handlers/submissions.ts +1022 -94
- package/corpus/templates/forms/server/lib/integrations.ts +100 -33
- package/corpus/templates/forms/server/middleware/00-public-cors.ts +1 -1
- package/corpus/templates/forms/server/plugins/db.ts +40 -0
- package/corpus/templates/plan/agent-native.json +1 -4
- package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +6 -1
- package/corpus/templates/slides/app/components/editor/EditorActionCluster.tsx +2 -2
- package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +6 -6
- package/corpus/templates/slides/app/components/editor/ImageOverlay.tsx +78 -12
- package/corpus/templates/slides/app/components/editor/SlideContextToolbar.tsx +4 -4
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +111 -27
- package/corpus/templates/slides/app/components/editor/SlidesLayersPanel.tsx +20 -34
- package/corpus/templates/slides/app/components/editor/slide-style.ts +2 -0
- package/corpus/templates/slides/app/global.css +35 -0
- package/corpus/templates/slides/app/lib/sanitize-slide-html.ts +133 -5
- package/corpus/templates/slides/app/lib/slide-image-replacement.ts +675 -0
- package/corpus/templates/slides/app/lib/validate-native-slide-html.ts +1 -0
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +44 -44
- package/corpus/templates/slides/app/root.tsx +1 -22
- package/corpus/templates/slides/changelog/2026-08-27-image-fit-controls-apply-cover-crops-and-position.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-28-shared-slides-with-a-style-block-render-again.md +6 -0
- package/dist/action.d.ts +3 -1
- package/dist/client/FeedbackButton.d.ts +12 -0
- package/dist/client/FeedbackButton.js +62 -28
- package/dist/client/app-providers.js +13 -1
- package/dist/client/feedback-context.d.ts +1 -0
- package/dist/client/feedback-context.js +3 -2
- package/dist/client/host-bridge.d.ts +1 -0
- package/dist/client/observability/ThumbsFeedback.js +73 -10
- package/dist/client/webmcp.d.ts +4 -0
- package/dist/client/webmcp.js +51 -4
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/index.browser.d.ts +1 -1
- package/dist/index.browser.js +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/observability/routes.js +8 -2
- package/dist/observability/store.d.ts +1 -1
- package/dist/observability/store.js +18 -3
- package/dist/observability/types.d.ts +1 -0
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/action-routes.d.ts +1 -6
- package/dist/server/action-routes.js +58 -17
- package/dist/server/agent-chat-plugin.js +8 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/docs/content/webmcp.mdx +31 -12
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
verifyCaptcha,
|
|
6
6
|
} from "@agent-native/core/server";
|
|
7
7
|
import { assertAccess } from "@agent-native/core/sharing";
|
|
8
|
-
import { and,
|
|
8
|
+
import { and, desc, eq, isNull, lt, or, sql } from "drizzle-orm";
|
|
9
9
|
import {
|
|
10
10
|
defineEventHandler,
|
|
11
11
|
getRouterParam,
|
|
@@ -28,13 +28,20 @@ import {
|
|
|
28
28
|
} from "../../shared/submitter-email.js";
|
|
29
29
|
import type {
|
|
30
30
|
FormField,
|
|
31
|
-
FormIntegration,
|
|
32
31
|
FormResponse,
|
|
33
32
|
FormSettings,
|
|
34
33
|
} from "../../shared/types.js";
|
|
35
34
|
import { getDb, schema } from "../db/index.js";
|
|
36
|
-
import {
|
|
37
|
-
|
|
35
|
+
import {
|
|
36
|
+
buildIntegrationDeliverySnapshots,
|
|
37
|
+
deliverIntegrationDelivery,
|
|
38
|
+
fireIntegrations,
|
|
39
|
+
type IntegrationDeliverySnapshot,
|
|
40
|
+
} from "../lib/integrations.js";
|
|
41
|
+
import {
|
|
42
|
+
sendNewResponseEmail,
|
|
43
|
+
type NewResponseEmailArgs,
|
|
44
|
+
} from "../lib/response-email.js";
|
|
38
45
|
import {
|
|
39
46
|
isEmptySubmissionValue,
|
|
40
47
|
validateSubmissionField,
|
|
@@ -44,6 +51,75 @@ const MAX_PAYLOAD_BYTES = 100 * 1024; // 100KB
|
|
|
44
51
|
const MIN_FILL_TIME_MS = 500; // reject submits faster than this
|
|
45
52
|
const MAX_META_TEXT_LENGTH = 500;
|
|
46
53
|
const MAX_CHAT_SESSION_IDS = 5;
|
|
54
|
+
const MAX_IDEMPOTENCY_KEY_LENGTH = 200;
|
|
55
|
+
const APPLICATION_STATE_DESTINATION = "application-state";
|
|
56
|
+
const RESPONSE_EMAIL_DESTINATION = "email";
|
|
57
|
+
const DELIVERY_CLAIM_LEASE_MS = 60_000;
|
|
58
|
+
const DELIVERY_CLAIM_HEARTBEAT_MS = Math.floor(DELIVERY_CLAIM_LEASE_MS / 3);
|
|
59
|
+
|
|
60
|
+
type ResponseDeliveryStatus = "pending" | "processing" | "succeeded" | "failed";
|
|
61
|
+
|
|
62
|
+
interface DeliverySnapshot {
|
|
63
|
+
formId: string;
|
|
64
|
+
formTitle: string;
|
|
65
|
+
ownerEmail: string | null;
|
|
66
|
+
orgId: string | null;
|
|
67
|
+
fields: FormField[];
|
|
68
|
+
data: Record<string, unknown>;
|
|
69
|
+
submittedAt: string;
|
|
70
|
+
submitterEmail: string | null;
|
|
71
|
+
chatSessionIds: string[];
|
|
72
|
+
activeRunId: string | null;
|
|
73
|
+
pageUrl: string | null;
|
|
74
|
+
clientSurface: string | null;
|
|
75
|
+
emailOnNewResponses: boolean;
|
|
76
|
+
integrations: IntegrationDeliverySnapshot[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type StoredDeliveryPayload =
|
|
80
|
+
| {
|
|
81
|
+
kind: "application-state";
|
|
82
|
+
ownerEmail: string;
|
|
83
|
+
value: { formId: string; responseId: string; timestamp: string };
|
|
84
|
+
}
|
|
85
|
+
| { kind: "email"; args: NewResponseEmailArgs; orgId: string | null }
|
|
86
|
+
| { kind: "integration"; snapshot: IntegrationDeliverySnapshot };
|
|
87
|
+
|
|
88
|
+
function configuredDeliveryStatuses(snapshot: DeliverySnapshot) {
|
|
89
|
+
const statuses: Record<string, "pending"> = {};
|
|
90
|
+
if (snapshot.ownerEmail) statuses[APPLICATION_STATE_DESTINATION] = "pending";
|
|
91
|
+
if (snapshot.emailOnNewResponses && snapshot.ownerEmail) {
|
|
92
|
+
statuses[RESPONSE_EMAIL_DESTINATION] = "pending";
|
|
93
|
+
}
|
|
94
|
+
for (const integration of snapshot.integrations) {
|
|
95
|
+
statuses[`integration:${integration.id}`] = "pending";
|
|
96
|
+
}
|
|
97
|
+
return statuses;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function submissionMetadata(body: Record<string, unknown>, anonymous: boolean) {
|
|
101
|
+
const meta =
|
|
102
|
+
!anonymous && typeof body._meta === "object" && body._meta !== null
|
|
103
|
+
? (body._meta as {
|
|
104
|
+
chatSessionId?: unknown;
|
|
105
|
+
chatSessionIds?: unknown;
|
|
106
|
+
activeRunId?: unknown;
|
|
107
|
+
pageUrl?: unknown;
|
|
108
|
+
clientSurface?: unknown;
|
|
109
|
+
submitterEmail?: unknown;
|
|
110
|
+
})
|
|
111
|
+
: null;
|
|
112
|
+
return {
|
|
113
|
+
submitterEmail: cleanSubmitterEmail(meta?.submitterEmail),
|
|
114
|
+
chatSessionIds: cleanChatSessionIds([
|
|
115
|
+
meta?.chatSessionId,
|
|
116
|
+
meta?.chatSessionIds,
|
|
117
|
+
]),
|
|
118
|
+
activeRunId: cleanMetaText(meta?.activeRunId),
|
|
119
|
+
pageUrl: scrubPageUrl(meta?.pageUrl),
|
|
120
|
+
clientSurface: cleanClientSurface(meta?.clientSurface),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
47
123
|
|
|
48
124
|
function cleanMetaText(value: unknown): string | null {
|
|
49
125
|
if (typeof value !== "string") return null;
|
|
@@ -77,10 +153,775 @@ function cleanChatSessionIds(value: unknown): string[] {
|
|
|
77
153
|
return ids;
|
|
78
154
|
}
|
|
79
155
|
|
|
156
|
+
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
157
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function parseIntegrationSnapshot(value: unknown): IntegrationDeliverySnapshot {
|
|
161
|
+
if (
|
|
162
|
+
!isObjectRecord(value) ||
|
|
163
|
+
typeof value.id !== "string" ||
|
|
164
|
+
typeof value.type !== "string" ||
|
|
165
|
+
typeof value.name !== "string" ||
|
|
166
|
+
typeof value.url !== "string" ||
|
|
167
|
+
!("payload" in value)
|
|
168
|
+
) {
|
|
169
|
+
throw new Error("Invalid response integration delivery snapshot");
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
id: value.id,
|
|
173
|
+
type: value.type as IntegrationDeliverySnapshot["type"],
|
|
174
|
+
name: value.name,
|
|
175
|
+
url: value.url,
|
|
176
|
+
payload: value.payload,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parseDeliverySnapshot(
|
|
181
|
+
value: string | null | undefined,
|
|
182
|
+
): DeliverySnapshot | null {
|
|
183
|
+
if (!value) return null;
|
|
184
|
+
let parsed: unknown;
|
|
185
|
+
try {
|
|
186
|
+
parsed = JSON.parse(value);
|
|
187
|
+
} catch {
|
|
188
|
+
throw new Error("Unreadable response delivery snapshot");
|
|
189
|
+
}
|
|
190
|
+
if (
|
|
191
|
+
!isObjectRecord(parsed) ||
|
|
192
|
+
typeof parsed.formId !== "string" ||
|
|
193
|
+
typeof parsed.formTitle !== "string" ||
|
|
194
|
+
(parsed.ownerEmail !== null && typeof parsed.ownerEmail !== "string") ||
|
|
195
|
+
(parsed.orgId !== null && typeof parsed.orgId !== "string") ||
|
|
196
|
+
!Array.isArray(parsed.fields) ||
|
|
197
|
+
!isObjectRecord(parsed.data) ||
|
|
198
|
+
typeof parsed.submittedAt !== "string" ||
|
|
199
|
+
(parsed.submitterEmail !== null &&
|
|
200
|
+
typeof parsed.submitterEmail !== "string") ||
|
|
201
|
+
!Array.isArray(parsed.chatSessionIds) ||
|
|
202
|
+
!parsed.chatSessionIds.every((id) => typeof id === "string") ||
|
|
203
|
+
(parsed.activeRunId !== null && typeof parsed.activeRunId !== "string") ||
|
|
204
|
+
(parsed.pageUrl !== null && typeof parsed.pageUrl !== "string") ||
|
|
205
|
+
(parsed.clientSurface !== null &&
|
|
206
|
+
typeof parsed.clientSurface !== "string") ||
|
|
207
|
+
typeof parsed.emailOnNewResponses !== "boolean" ||
|
|
208
|
+
!Array.isArray(parsed.integrations)
|
|
209
|
+
) {
|
|
210
|
+
throw new Error("Invalid response delivery snapshot");
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
formId: parsed.formId,
|
|
214
|
+
formTitle: parsed.formTitle,
|
|
215
|
+
ownerEmail: parsed.ownerEmail,
|
|
216
|
+
orgId: parsed.orgId,
|
|
217
|
+
fields: parsed.fields as FormField[],
|
|
218
|
+
data: parsed.data,
|
|
219
|
+
submittedAt: parsed.submittedAt,
|
|
220
|
+
submitterEmail: parsed.submitterEmail,
|
|
221
|
+
chatSessionIds: parsed.chatSessionIds,
|
|
222
|
+
activeRunId: parsed.activeRunId,
|
|
223
|
+
pageUrl: parsed.pageUrl,
|
|
224
|
+
clientSurface: parsed.clientSurface,
|
|
225
|
+
emailOnNewResponses: parsed.emailOnNewResponses,
|
|
226
|
+
integrations: parsed.integrations.map(parseIntegrationSnapshot),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function buildDeliverySnapshot({
|
|
231
|
+
form,
|
|
232
|
+
settings,
|
|
233
|
+
fields,
|
|
234
|
+
data,
|
|
235
|
+
responseId,
|
|
236
|
+
submittedAt,
|
|
237
|
+
submitterEmail,
|
|
238
|
+
pageUrl,
|
|
239
|
+
clientSurface,
|
|
240
|
+
chatSessionIds,
|
|
241
|
+
activeRunId,
|
|
242
|
+
}: {
|
|
243
|
+
form: typeof schema.forms.$inferSelect;
|
|
244
|
+
settings: FormSettings;
|
|
245
|
+
fields: FormField[];
|
|
246
|
+
data: Record<string, unknown>;
|
|
247
|
+
responseId: string;
|
|
248
|
+
submittedAt: string;
|
|
249
|
+
submitterEmail: string | null;
|
|
250
|
+
pageUrl: string | null;
|
|
251
|
+
clientSurface: string | null;
|
|
252
|
+
chatSessionIds: string[];
|
|
253
|
+
activeRunId: string | null;
|
|
254
|
+
}): DeliverySnapshot {
|
|
255
|
+
const submission = {
|
|
256
|
+
formId: form.id,
|
|
257
|
+
formTitle: form.title,
|
|
258
|
+
responseId,
|
|
259
|
+
fields,
|
|
260
|
+
data,
|
|
261
|
+
submittedAt,
|
|
262
|
+
submitterEmail,
|
|
263
|
+
chatSessionIds,
|
|
264
|
+
activeRunId,
|
|
265
|
+
pageUrl,
|
|
266
|
+
clientSurface,
|
|
267
|
+
};
|
|
268
|
+
return {
|
|
269
|
+
formId: form.id,
|
|
270
|
+
formTitle: form.title,
|
|
271
|
+
ownerEmail: form.ownerEmail ?? null,
|
|
272
|
+
orgId: form.orgId ?? null,
|
|
273
|
+
fields,
|
|
274
|
+
data,
|
|
275
|
+
submittedAt,
|
|
276
|
+
submitterEmail,
|
|
277
|
+
chatSessionIds,
|
|
278
|
+
activeRunId,
|
|
279
|
+
pageUrl,
|
|
280
|
+
clientSurface,
|
|
281
|
+
emailOnNewResponses: settings.emailOnNewResponses === true,
|
|
282
|
+
integrations: buildIntegrationDeliverySnapshots(
|
|
283
|
+
settings.integrations ?? [],
|
|
284
|
+
submission,
|
|
285
|
+
),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function buildResponseDeliveryRows(
|
|
290
|
+
snapshot: DeliverySnapshot,
|
|
291
|
+
responseId: string,
|
|
292
|
+
) {
|
|
293
|
+
const createdAt = new Date().toISOString();
|
|
294
|
+
const rows: Array<typeof schema.responseDeliveries.$inferInsert> = [];
|
|
295
|
+
const add = (
|
|
296
|
+
destination: string,
|
|
297
|
+
kind: "application-state" | "email" | "integration",
|
|
298
|
+
payload: StoredDeliveryPayload,
|
|
299
|
+
) => {
|
|
300
|
+
rows.push({
|
|
301
|
+
id: nanoid(),
|
|
302
|
+
responseId,
|
|
303
|
+
destination,
|
|
304
|
+
kind,
|
|
305
|
+
payload: JSON.stringify(payload),
|
|
306
|
+
status: "pending",
|
|
307
|
+
claimToken: null,
|
|
308
|
+
claimedAt: null,
|
|
309
|
+
errorMessage: null,
|
|
310
|
+
createdAt,
|
|
311
|
+
updatedAt: createdAt,
|
|
312
|
+
});
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
if (snapshot.ownerEmail) {
|
|
316
|
+
add(APPLICATION_STATE_DESTINATION, "application-state", {
|
|
317
|
+
kind: "application-state",
|
|
318
|
+
ownerEmail: snapshot.ownerEmail,
|
|
319
|
+
value: {
|
|
320
|
+
formId: snapshot.formId,
|
|
321
|
+
responseId,
|
|
322
|
+
timestamp: snapshot.submittedAt,
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
if (snapshot.emailOnNewResponses && snapshot.ownerEmail) {
|
|
327
|
+
add(RESPONSE_EMAIL_DESTINATION, "email", {
|
|
328
|
+
kind: "email",
|
|
329
|
+
orgId: snapshot.orgId,
|
|
330
|
+
args: {
|
|
331
|
+
to: snapshot.ownerEmail,
|
|
332
|
+
formTitle: snapshot.formTitle,
|
|
333
|
+
fields: snapshot.fields,
|
|
334
|
+
data: snapshot.data,
|
|
335
|
+
submittedAt: snapshot.submittedAt,
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
for (const integration of snapshot.integrations) {
|
|
340
|
+
add(`integration:${integration.id}`, "integration", {
|
|
341
|
+
kind: "integration",
|
|
342
|
+
snapshot: integration,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return rows;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function ensureResponseDeliveryRows(
|
|
349
|
+
db: ReturnType<typeof getDb>,
|
|
350
|
+
responseId: string,
|
|
351
|
+
snapshot: DeliverySnapshot,
|
|
352
|
+
) {
|
|
353
|
+
let rows = await db
|
|
354
|
+
.select()
|
|
355
|
+
.from(schema.responseDeliveries)
|
|
356
|
+
.where(eq(schema.responseDeliveries.responseId, responseId));
|
|
357
|
+
if (rows.length > 0) return rows;
|
|
358
|
+
|
|
359
|
+
const values = buildResponseDeliveryRows(snapshot, responseId);
|
|
360
|
+
if (values.length > 0) {
|
|
361
|
+
await db
|
|
362
|
+
.insert(schema.responseDeliveries)
|
|
363
|
+
.values(values)
|
|
364
|
+
.onConflictDoNothing();
|
|
365
|
+
rows = await db
|
|
366
|
+
.select()
|
|
367
|
+
.from(schema.responseDeliveries)
|
|
368
|
+
.where(eq(schema.responseDeliveries.responseId, responseId));
|
|
369
|
+
}
|
|
370
|
+
return rows;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function claimResponseDelivery(
|
|
374
|
+
db: ReturnType<typeof getDb>,
|
|
375
|
+
deliveryId: string,
|
|
376
|
+
): Promise<string | null> {
|
|
377
|
+
const claimToken = nanoid();
|
|
378
|
+
const now = new Date();
|
|
379
|
+
const nowIso = now.toISOString();
|
|
380
|
+
const staleIso = new Date(
|
|
381
|
+
now.getTime() - DELIVERY_CLAIM_LEASE_MS,
|
|
382
|
+
).toISOString();
|
|
383
|
+
const [claimed] = await db
|
|
384
|
+
.update(schema.responseDeliveries)
|
|
385
|
+
.set({
|
|
386
|
+
status: "processing",
|
|
387
|
+
claimToken,
|
|
388
|
+
claimedAt: nowIso,
|
|
389
|
+
updatedAt: nowIso,
|
|
390
|
+
errorMessage: null,
|
|
391
|
+
})
|
|
392
|
+
.where(
|
|
393
|
+
and(
|
|
394
|
+
eq(schema.responseDeliveries.id, deliveryId),
|
|
395
|
+
or(
|
|
396
|
+
eq(schema.responseDeliveries.status, "pending"),
|
|
397
|
+
eq(schema.responseDeliveries.status, "failed"),
|
|
398
|
+
and(
|
|
399
|
+
eq(schema.responseDeliveries.status, "processing"),
|
|
400
|
+
lt(schema.responseDeliveries.claimedAt, staleIso),
|
|
401
|
+
),
|
|
402
|
+
),
|
|
403
|
+
),
|
|
404
|
+
)
|
|
405
|
+
.returning({ id: schema.responseDeliveries.id });
|
|
406
|
+
return claimed ? claimToken : null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function renewResponseDeliveryClaim(
|
|
410
|
+
db: ReturnType<typeof getDb>,
|
|
411
|
+
deliveryId: string,
|
|
412
|
+
claimToken: string,
|
|
413
|
+
): Promise<void> {
|
|
414
|
+
const nowIso = new Date().toISOString();
|
|
415
|
+
const [renewed] = await db
|
|
416
|
+
.update(schema.responseDeliveries)
|
|
417
|
+
.set({ claimedAt: nowIso, updatedAt: nowIso })
|
|
418
|
+
.where(
|
|
419
|
+
and(
|
|
420
|
+
eq(schema.responseDeliveries.id, deliveryId),
|
|
421
|
+
eq(schema.responseDeliveries.claimToken, claimToken),
|
|
422
|
+
eq(schema.responseDeliveries.status, "processing"),
|
|
423
|
+
),
|
|
424
|
+
)
|
|
425
|
+
.returning({ id: schema.responseDeliveries.id });
|
|
426
|
+
if (!renewed) throw new Error("Response delivery claim was lost");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function startResponseDeliveryLeaseHeartbeat(
|
|
430
|
+
db: ReturnType<typeof getDb>,
|
|
431
|
+
deliveryId: string,
|
|
432
|
+
claimToken: string,
|
|
433
|
+
) {
|
|
434
|
+
let stopped = false;
|
|
435
|
+
let failure: unknown = null;
|
|
436
|
+
let pendingRenewal: Promise<void> | null = null;
|
|
437
|
+
const timer = setInterval(() => {
|
|
438
|
+
if (stopped || failure || pendingRenewal) return;
|
|
439
|
+
pendingRenewal = renewResponseDeliveryClaim(db, deliveryId, claimToken)
|
|
440
|
+
.catch((error) => {
|
|
441
|
+
failure = error;
|
|
442
|
+
clearInterval(timer);
|
|
443
|
+
})
|
|
444
|
+
.finally(() => {
|
|
445
|
+
pendingRenewal = null;
|
|
446
|
+
});
|
|
447
|
+
}, DELIVERY_CLAIM_HEARTBEAT_MS);
|
|
448
|
+
(timer as ReturnType<typeof setInterval> & { unref?: () => void }).unref?.();
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
assertHealthy() {
|
|
452
|
+
if (failure) throw failure;
|
|
453
|
+
},
|
|
454
|
+
async stop() {
|
|
455
|
+
stopped = true;
|
|
456
|
+
clearInterval(timer);
|
|
457
|
+
await pendingRenewal;
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function markResponseDelivery(
|
|
463
|
+
db: ReturnType<typeof getDb>,
|
|
464
|
+
deliveryId: string,
|
|
465
|
+
claimToken: string,
|
|
466
|
+
status: Exclude<ResponseDeliveryStatus, "processing">,
|
|
467
|
+
errorMessage: string | null = null,
|
|
468
|
+
): Promise<void> {
|
|
469
|
+
const nowIso = new Date().toISOString();
|
|
470
|
+
try {
|
|
471
|
+
const [updated] = await db
|
|
472
|
+
.update(schema.responseDeliveries)
|
|
473
|
+
.set({
|
|
474
|
+
status,
|
|
475
|
+
claimToken: null,
|
|
476
|
+
claimedAt: null,
|
|
477
|
+
errorMessage,
|
|
478
|
+
updatedAt: nowIso,
|
|
479
|
+
})
|
|
480
|
+
.where(
|
|
481
|
+
and(
|
|
482
|
+
eq(schema.responseDeliveries.id, deliveryId),
|
|
483
|
+
eq(schema.responseDeliveries.claimToken, claimToken),
|
|
484
|
+
),
|
|
485
|
+
)
|
|
486
|
+
.returning({ id: schema.responseDeliveries.id });
|
|
487
|
+
if (updated) return;
|
|
488
|
+
throw new Error("Response delivery claim was lost");
|
|
489
|
+
} catch (error) {
|
|
490
|
+
// The database client can report a transport error after applying the
|
|
491
|
+
// update. Re-read before retrying an external effect so that accepted
|
|
492
|
+
// delivery is reconciled instead of duplicated.
|
|
493
|
+
const [current] = await db
|
|
494
|
+
.select({
|
|
495
|
+
status: schema.responseDeliveries.status,
|
|
496
|
+
claimToken: schema.responseDeliveries.claimToken,
|
|
497
|
+
})
|
|
498
|
+
.from(schema.responseDeliveries)
|
|
499
|
+
.where(eq(schema.responseDeliveries.id, deliveryId));
|
|
500
|
+
if (current?.status === status && current.claimToken === null) return;
|
|
501
|
+
throw error;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function parseStoredDeliveryPayload(value: string): StoredDeliveryPayload {
|
|
506
|
+
let parsed: unknown;
|
|
507
|
+
try {
|
|
508
|
+
parsed = JSON.parse(value);
|
|
509
|
+
} catch {
|
|
510
|
+
throw new Error("Unreadable response delivery payload");
|
|
511
|
+
}
|
|
512
|
+
if (!isObjectRecord(parsed) || typeof parsed.kind !== "string") {
|
|
513
|
+
throw new Error("Invalid response delivery payload");
|
|
514
|
+
}
|
|
515
|
+
if (
|
|
516
|
+
parsed.kind === "application-state" &&
|
|
517
|
+
typeof parsed.ownerEmail === "string" &&
|
|
518
|
+
isObjectRecord(parsed.value) &&
|
|
519
|
+
typeof parsed.value.formId === "string" &&
|
|
520
|
+
typeof parsed.value.responseId === "string" &&
|
|
521
|
+
typeof parsed.value.timestamp === "string"
|
|
522
|
+
) {
|
|
523
|
+
return {
|
|
524
|
+
kind: "application-state",
|
|
525
|
+
ownerEmail: parsed.ownerEmail,
|
|
526
|
+
value: {
|
|
527
|
+
formId: parsed.value.formId,
|
|
528
|
+
responseId: parsed.value.responseId,
|
|
529
|
+
timestamp: parsed.value.timestamp,
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
if (
|
|
534
|
+
parsed.kind === "email" &&
|
|
535
|
+
(parsed.orgId === null || typeof parsed.orgId === "string") &&
|
|
536
|
+
isObjectRecord(parsed.args) &&
|
|
537
|
+
typeof parsed.args.to === "string" &&
|
|
538
|
+
typeof parsed.args.formTitle === "string" &&
|
|
539
|
+
Array.isArray(parsed.args.fields) &&
|
|
540
|
+
isObjectRecord(parsed.args.data) &&
|
|
541
|
+
typeof parsed.args.submittedAt === "string"
|
|
542
|
+
) {
|
|
543
|
+
return {
|
|
544
|
+
kind: "email",
|
|
545
|
+
orgId: parsed.orgId,
|
|
546
|
+
args: {
|
|
547
|
+
to: parsed.args.to,
|
|
548
|
+
formTitle: parsed.args.formTitle,
|
|
549
|
+
fields: parsed.args.fields as FormField[],
|
|
550
|
+
data: parsed.args.data,
|
|
551
|
+
submittedAt: parsed.args.submittedAt,
|
|
552
|
+
},
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
if (parsed.kind === "integration") {
|
|
556
|
+
return {
|
|
557
|
+
kind: "integration",
|
|
558
|
+
snapshot: parseIntegrationSnapshot(parsed.snapshot),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
throw new Error("Invalid response delivery payload");
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function deliveryErrorMessage(error: unknown): string {
|
|
565
|
+
return (error instanceof Error ? error.message : String(error)).slice(0, 500);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function deliverStoredResponseDelivery(
|
|
569
|
+
row: typeof schema.responseDeliveries.$inferSelect,
|
|
570
|
+
): Promise<void> {
|
|
571
|
+
const payload = parseStoredDeliveryPayload(row.payload);
|
|
572
|
+
switch (payload.kind) {
|
|
573
|
+
case "application-state": {
|
|
574
|
+
const { appStatePut } =
|
|
575
|
+
await import("@agent-native/core/application-state");
|
|
576
|
+
await appStatePut(payload.ownerEmail, "new-submission", payload.value);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
case "email":
|
|
580
|
+
await runWithRequestContext(
|
|
581
|
+
{
|
|
582
|
+
userEmail: payload.args.to,
|
|
583
|
+
orgId: payload.orgId ?? undefined,
|
|
584
|
+
},
|
|
585
|
+
() => sendNewResponseEmail(payload.args),
|
|
586
|
+
);
|
|
587
|
+
return;
|
|
588
|
+
case "integration":
|
|
589
|
+
await deliverIntegrationDelivery(
|
|
590
|
+
payload.snapshot,
|
|
591
|
+
`forms:${row.responseId}:${row.destination}`,
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function deliverStoredResponseDeliveries(
|
|
597
|
+
db: ReturnType<typeof getDb>,
|
|
598
|
+
responseId: string,
|
|
599
|
+
snapshot: DeliverySnapshot,
|
|
600
|
+
): Promise<boolean> {
|
|
601
|
+
const rows = await ensureResponseDeliveryRows(db, responseId, snapshot);
|
|
602
|
+
await Promise.all(
|
|
603
|
+
rows.map(async (row) => {
|
|
604
|
+
const claimToken = await claimResponseDelivery(db, row.id);
|
|
605
|
+
if (!claimToken) return;
|
|
606
|
+
const heartbeat = startResponseDeliveryLeaseHeartbeat(
|
|
607
|
+
db,
|
|
608
|
+
row.id,
|
|
609
|
+
claimToken,
|
|
610
|
+
);
|
|
611
|
+
try {
|
|
612
|
+
await deliverStoredResponseDelivery(row);
|
|
613
|
+
await heartbeat.stop();
|
|
614
|
+
heartbeat.assertHealthy();
|
|
615
|
+
await markResponseDelivery(db, row.id, claimToken, "succeeded");
|
|
616
|
+
} catch (error) {
|
|
617
|
+
await heartbeat.stop();
|
|
618
|
+
console.warn(`[forms] ${row.destination} delivery failed:`, error);
|
|
619
|
+
await markResponseDelivery(
|
|
620
|
+
db,
|
|
621
|
+
row.id,
|
|
622
|
+
claimToken,
|
|
623
|
+
"failed",
|
|
624
|
+
deliveryErrorMessage(error),
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
}),
|
|
628
|
+
);
|
|
629
|
+
await refreshResponseDeliverySummary(db, responseId);
|
|
630
|
+
const currentRows = await db
|
|
631
|
+
.select()
|
|
632
|
+
.from(schema.responseDeliveries)
|
|
633
|
+
.where(eq(schema.responseDeliveries.responseId, responseId));
|
|
634
|
+
return currentRows.some((row) => row.status !== "succeeded");
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export async function reconcileResponseDeliveries(responseId: string) {
|
|
638
|
+
const db = getDb();
|
|
639
|
+
const [response] = await db
|
|
640
|
+
.select({
|
|
641
|
+
id: schema.responses.id,
|
|
642
|
+
deliverySnapshot: schema.responses.deliverySnapshot,
|
|
643
|
+
})
|
|
644
|
+
.from(schema.responses)
|
|
645
|
+
.where(eq(schema.responses.id, responseId));
|
|
646
|
+
if (!response) throw new Error(`Response ${responseId} not found`);
|
|
647
|
+
|
|
648
|
+
const snapshot = parseDeliverySnapshot(response.deliverySnapshot);
|
|
649
|
+
if (!snapshot) {
|
|
650
|
+
throw new Error(`Response ${responseId} has no delivery snapshot`);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const pending = await deliverStoredResponseDeliveries(
|
|
654
|
+
db,
|
|
655
|
+
response.id,
|
|
656
|
+
snapshot,
|
|
657
|
+
);
|
|
658
|
+
return {
|
|
659
|
+
success: !pending,
|
|
660
|
+
retryable: pending,
|
|
661
|
+
id: response.id,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function deliverySummary(
|
|
666
|
+
rows: Array<typeof schema.responseDeliveries.$inferSelect>,
|
|
667
|
+
) {
|
|
668
|
+
const summary: Record<string, "pending" | "succeeded" | "failed"> = {};
|
|
669
|
+
for (const row of [...rows].sort((a, b) =>
|
|
670
|
+
a.destination.localeCompare(b.destination),
|
|
671
|
+
)) {
|
|
672
|
+
summary[row.destination] =
|
|
673
|
+
row.status === "processing" ? "pending" : row.status;
|
|
674
|
+
}
|
|
675
|
+
return summary;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function refreshResponseDeliverySummary(
|
|
679
|
+
db: ReturnType<typeof getDb>,
|
|
680
|
+
responseId: string,
|
|
681
|
+
): Promise<void> {
|
|
682
|
+
await db.transaction(async (tx) => {
|
|
683
|
+
const [response] = await tx
|
|
684
|
+
.select({ deliveryStatus: schema.responses.deliveryStatus })
|
|
685
|
+
.from(schema.responses)
|
|
686
|
+
.where(eq(schema.responses.id, responseId));
|
|
687
|
+
if (!response) return;
|
|
688
|
+
|
|
689
|
+
// Serialize all summary writers on the response row before reading the
|
|
690
|
+
// delivery rows, so a retry cannot commit an older aggregate after a
|
|
691
|
+
// newer destination status has been recorded.
|
|
692
|
+
await tx
|
|
693
|
+
.update(schema.responses)
|
|
694
|
+
.set({ deliveryStatus: response.deliveryStatus })
|
|
695
|
+
.where(eq(schema.responses.id, responseId));
|
|
696
|
+
const rows = await tx
|
|
697
|
+
.select()
|
|
698
|
+
.from(schema.responseDeliveries)
|
|
699
|
+
.where(eq(schema.responseDeliveries.responseId, responseId));
|
|
700
|
+
await tx
|
|
701
|
+
.update(schema.responses)
|
|
702
|
+
.set({ deliveryStatus: JSON.stringify(deliverySummary(rows)) })
|
|
703
|
+
.where(eq(schema.responses.id, responseId));
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async function deliverBestEffortResponseSideEffects({
|
|
708
|
+
form,
|
|
709
|
+
settings,
|
|
710
|
+
fields,
|
|
711
|
+
data,
|
|
712
|
+
responseId,
|
|
713
|
+
submittedAt,
|
|
714
|
+
submitterEmail,
|
|
715
|
+
pageUrl,
|
|
716
|
+
clientSurface,
|
|
717
|
+
chatSessionIds,
|
|
718
|
+
activeRunId,
|
|
719
|
+
}: {
|
|
720
|
+
form: typeof schema.forms.$inferSelect;
|
|
721
|
+
settings: FormSettings;
|
|
722
|
+
fields: FormField[];
|
|
723
|
+
data: Record<string, unknown>;
|
|
724
|
+
responseId: string;
|
|
725
|
+
submittedAt: string;
|
|
726
|
+
submitterEmail: string | null;
|
|
727
|
+
pageUrl: string | null;
|
|
728
|
+
clientSurface: string | null;
|
|
729
|
+
chatSessionIds: string[];
|
|
730
|
+
activeRunId: string | null;
|
|
731
|
+
}): Promise<void> {
|
|
732
|
+
const run = async (label: string, effect: () => Promise<void>) => {
|
|
733
|
+
try {
|
|
734
|
+
await effect();
|
|
735
|
+
} catch (error) {
|
|
736
|
+
console.warn(`[forms] ${label} delivery failed:`, error);
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
if (form.ownerEmail) {
|
|
740
|
+
await run("application state", async () => {
|
|
741
|
+
const { appStatePut } =
|
|
742
|
+
await import("@agent-native/core/application-state");
|
|
743
|
+
await appStatePut(form.ownerEmail!, "new-submission", {
|
|
744
|
+
formId: form.id,
|
|
745
|
+
responseId,
|
|
746
|
+
timestamp: submittedAt,
|
|
747
|
+
});
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
if (settings.emailOnNewResponses === true && form.ownerEmail) {
|
|
751
|
+
await run("new response email", async () => {
|
|
752
|
+
await runWithRequestContext(
|
|
753
|
+
{ userEmail: form.ownerEmail!, orgId: form.orgId ?? undefined },
|
|
754
|
+
() =>
|
|
755
|
+
sendNewResponseEmail({
|
|
756
|
+
to: form.ownerEmail!,
|
|
757
|
+
formTitle: form.title,
|
|
758
|
+
fields,
|
|
759
|
+
data,
|
|
760
|
+
submittedAt,
|
|
761
|
+
}),
|
|
762
|
+
);
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
const integrations = settings.integrations ?? [];
|
|
766
|
+
if (integrations.length > 0) {
|
|
767
|
+
await fireIntegrations(integrations, {
|
|
768
|
+
formId: form.id,
|
|
769
|
+
formTitle: form.title,
|
|
770
|
+
responseId,
|
|
771
|
+
fields,
|
|
772
|
+
data,
|
|
773
|
+
submittedAt,
|
|
774
|
+
submitterEmail,
|
|
775
|
+
chatSessionIds,
|
|
776
|
+
activeRunId,
|
|
777
|
+
pageUrl,
|
|
778
|
+
clientSurface,
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
async function deliverResponseSideEffects({
|
|
784
|
+
db,
|
|
785
|
+
form,
|
|
786
|
+
settings,
|
|
787
|
+
fields,
|
|
788
|
+
data,
|
|
789
|
+
responseId,
|
|
790
|
+
submittedAt,
|
|
791
|
+
submitterEmail,
|
|
792
|
+
pageUrl,
|
|
793
|
+
clientSurface,
|
|
794
|
+
chatSessionIds,
|
|
795
|
+
activeRunId,
|
|
796
|
+
trackDelivery,
|
|
797
|
+
deliverySnapshot,
|
|
798
|
+
}: {
|
|
799
|
+
db: ReturnType<typeof getDb>;
|
|
800
|
+
form: typeof schema.forms.$inferSelect;
|
|
801
|
+
settings: FormSettings;
|
|
802
|
+
fields: FormField[];
|
|
803
|
+
data: Record<string, unknown>;
|
|
804
|
+
responseId: string;
|
|
805
|
+
submittedAt: string;
|
|
806
|
+
submitterEmail: string | null;
|
|
807
|
+
pageUrl: string | null;
|
|
808
|
+
clientSurface: string | null;
|
|
809
|
+
chatSessionIds: string[];
|
|
810
|
+
activeRunId: string | null;
|
|
811
|
+
trackDelivery: boolean;
|
|
812
|
+
deliverySnapshot: DeliverySnapshot | null;
|
|
813
|
+
}): Promise<boolean> {
|
|
814
|
+
if (!trackDelivery) {
|
|
815
|
+
await deliverBestEffortResponseSideEffects({
|
|
816
|
+
form,
|
|
817
|
+
settings,
|
|
818
|
+
fields,
|
|
819
|
+
data,
|
|
820
|
+
responseId,
|
|
821
|
+
submittedAt,
|
|
822
|
+
submitterEmail,
|
|
823
|
+
pageUrl,
|
|
824
|
+
clientSurface,
|
|
825
|
+
chatSessionIds,
|
|
826
|
+
activeRunId,
|
|
827
|
+
});
|
|
828
|
+
return false;
|
|
829
|
+
}
|
|
830
|
+
const snapshot =
|
|
831
|
+
deliverySnapshot ??
|
|
832
|
+
buildDeliverySnapshot({
|
|
833
|
+
form,
|
|
834
|
+
settings,
|
|
835
|
+
fields,
|
|
836
|
+
data,
|
|
837
|
+
responseId,
|
|
838
|
+
submittedAt,
|
|
839
|
+
submitterEmail,
|
|
840
|
+
pageUrl,
|
|
841
|
+
clientSurface,
|
|
842
|
+
chatSessionIds,
|
|
843
|
+
activeRunId,
|
|
844
|
+
});
|
|
845
|
+
return deliverStoredResponseDeliveries(db, responseId, snapshot);
|
|
846
|
+
}
|
|
847
|
+
|
|
80
848
|
export const submitForm = defineEventHandler(async (event: H3Event) => {
|
|
81
849
|
const db = getDb();
|
|
82
850
|
const id = getRouterParam(event, "id") as string;
|
|
83
851
|
|
|
852
|
+
// coercion-ok: malformed request bodies are rejected as invalid submissions.
|
|
853
|
+
const body = await readBody(event).catch(() => null);
|
|
854
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
855
|
+
setResponseStatus(event, 400);
|
|
856
|
+
return { error: "Invalid submission payload" };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Check overall payload size
|
|
860
|
+
const bodyStr = JSON.stringify(body);
|
|
861
|
+
if (Buffer.byteLength(bodyStr, "utf8") > MAX_PAYLOAD_BYTES) {
|
|
862
|
+
setResponseStatus(event, 413);
|
|
863
|
+
return { error: "Payload too large" };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const rawIdempotencyKey = getRequestHeader(event, "idempotency-key");
|
|
867
|
+
if (
|
|
868
|
+
rawIdempotencyKey &&
|
|
869
|
+
rawIdempotencyKey.trim().length > MAX_IDEMPOTENCY_KEY_LENGTH
|
|
870
|
+
) {
|
|
871
|
+
setResponseStatus(event, 400);
|
|
872
|
+
return { error: "Idempotency-Key is too long" };
|
|
873
|
+
}
|
|
874
|
+
const idempotencyKey = rawIdempotencyKey?.trim() || null;
|
|
875
|
+
|
|
876
|
+
// A retry must be able to reconcile a persisted response even after its
|
|
877
|
+
// form is unpublished or soft-deleted. The immutable snapshot is the only
|
|
878
|
+
// input needed for delivery, so do this lookup before loading the active
|
|
879
|
+
// form or applying one-time submission checks.
|
|
880
|
+
if (idempotencyKey) {
|
|
881
|
+
const [existing] = await db
|
|
882
|
+
.select({
|
|
883
|
+
id: schema.responses.id,
|
|
884
|
+
deliverySnapshot: schema.responses.deliverySnapshot,
|
|
885
|
+
})
|
|
886
|
+
.from(schema.responses)
|
|
887
|
+
.where(
|
|
888
|
+
and(
|
|
889
|
+
eq(schema.responses.formId, id),
|
|
890
|
+
eq(schema.responses.idempotencyKey, idempotencyKey),
|
|
891
|
+
),
|
|
892
|
+
);
|
|
893
|
+
if (existing) {
|
|
894
|
+
try {
|
|
895
|
+
const snapshot = parseDeliverySnapshot(existing.deliverySnapshot);
|
|
896
|
+
if (!snapshot) {
|
|
897
|
+
throw new Error(`Response ${existing.id} has no delivery snapshot`);
|
|
898
|
+
}
|
|
899
|
+
const deliveryPending = await deliverStoredResponseDeliveries(
|
|
900
|
+
db,
|
|
901
|
+
existing.id,
|
|
902
|
+
snapshot,
|
|
903
|
+
);
|
|
904
|
+
if (deliveryPending) {
|
|
905
|
+
setResponseStatus(event, 503);
|
|
906
|
+
return {
|
|
907
|
+
error: "Response delivery is still pending",
|
|
908
|
+
retryable: true,
|
|
909
|
+
id: existing.id,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
} catch (error) {
|
|
913
|
+
console.warn("[forms] response delivery retry failed:", error);
|
|
914
|
+
setResponseStatus(event, 503);
|
|
915
|
+
return {
|
|
916
|
+
error: "Response delivery is still pending",
|
|
917
|
+
retryable: true,
|
|
918
|
+
id: existing.id,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
return { success: true, id: existing.id };
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
84
925
|
// guard:allow-unscoped — public submission endpoint intentionally accepts anonymous responses for published forms by id; it returns no owner data and rejects non-published forms.
|
|
85
926
|
// Public submission endpoint: published forms are intentionally readable
|
|
86
927
|
// without an authenticated viewer, but only by exact id and published status.
|
|
@@ -116,18 +957,95 @@ export const submitForm = defineEventHandler(async (event: H3Event) => {
|
|
|
116
957
|
}
|
|
117
958
|
}
|
|
118
959
|
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
960
|
+
const finishResponse = async ({
|
|
961
|
+
responseId,
|
|
962
|
+
fields,
|
|
963
|
+
data,
|
|
964
|
+
submittedAt,
|
|
965
|
+
submitterEmail,
|
|
966
|
+
pageUrl,
|
|
967
|
+
clientSurface,
|
|
968
|
+
chatSessionIds,
|
|
969
|
+
activeRunId,
|
|
970
|
+
deliverySnapshot,
|
|
971
|
+
}: {
|
|
972
|
+
responseId: string;
|
|
973
|
+
fields: FormField[];
|
|
974
|
+
data: Record<string, unknown>;
|
|
975
|
+
submittedAt: string;
|
|
976
|
+
submitterEmail: string | null;
|
|
977
|
+
pageUrl: string | null;
|
|
978
|
+
clientSurface: string | null;
|
|
979
|
+
chatSessionIds: string[];
|
|
980
|
+
activeRunId: string | null;
|
|
981
|
+
deliverySnapshot: string | null | undefined;
|
|
982
|
+
}) => {
|
|
983
|
+
try {
|
|
984
|
+
const tracked = idempotencyKey !== null;
|
|
985
|
+
let parsedDeliverySnapshot = tracked
|
|
986
|
+
? parseDeliverySnapshot(deliverySnapshot)
|
|
987
|
+
: null;
|
|
988
|
+
if (tracked && !parsedDeliverySnapshot) {
|
|
989
|
+
parsedDeliverySnapshot = buildDeliverySnapshot({
|
|
990
|
+
form,
|
|
991
|
+
settings,
|
|
992
|
+
fields,
|
|
993
|
+
data,
|
|
994
|
+
responseId,
|
|
995
|
+
submittedAt,
|
|
996
|
+
submitterEmail,
|
|
997
|
+
pageUrl,
|
|
998
|
+
clientSurface,
|
|
999
|
+
chatSessionIds,
|
|
1000
|
+
activeRunId,
|
|
1001
|
+
});
|
|
1002
|
+
await db
|
|
1003
|
+
.update(schema.responses)
|
|
1004
|
+
.set({ deliverySnapshot: JSON.stringify(parsedDeliverySnapshot) })
|
|
1005
|
+
.where(
|
|
1006
|
+
and(
|
|
1007
|
+
eq(schema.responses.id, responseId),
|
|
1008
|
+
isNull(schema.responses.deliverySnapshot),
|
|
1009
|
+
),
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
const deliveryPending = await deliverResponseSideEffects({
|
|
1013
|
+
db,
|
|
1014
|
+
form,
|
|
1015
|
+
settings,
|
|
1016
|
+
fields,
|
|
1017
|
+
data,
|
|
1018
|
+
responseId,
|
|
1019
|
+
submittedAt,
|
|
1020
|
+
submitterEmail,
|
|
1021
|
+
pageUrl,
|
|
1022
|
+
clientSurface,
|
|
1023
|
+
chatSessionIds,
|
|
1024
|
+
activeRunId,
|
|
1025
|
+
trackDelivery: tracked,
|
|
1026
|
+
deliverySnapshot: parsedDeliverySnapshot,
|
|
1027
|
+
});
|
|
1028
|
+
if (deliveryPending) {
|
|
1029
|
+
setResponseStatus(event, 503);
|
|
1030
|
+
return {
|
|
1031
|
+
error: "Response delivery is still pending",
|
|
1032
|
+
retryable: true,
|
|
1033
|
+
id: responseId,
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
console.warn("[forms] response delivery status update failed:", error);
|
|
1038
|
+
if (idempotencyKey) {
|
|
1039
|
+
setResponseStatus(event, 503);
|
|
1040
|
+
return {
|
|
1041
|
+
error: "Response delivery is still pending",
|
|
1042
|
+
retryable: true,
|
|
1043
|
+
id: responseId,
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
return { success: true, id: responseId };
|
|
1048
|
+
};
|
|
131
1049
|
|
|
132
1050
|
// Honeypot: silently accept-and-drop if filled. Bots that fire-and-forget
|
|
133
1051
|
// get a 200 and never know they were caught.
|
|
@@ -207,30 +1125,31 @@ export const submitForm = defineEventHandler(async (event: H3Event) => {
|
|
|
207
1125
|
// who sent feedback in Slack). Never required. Prefer the Forms-host session
|
|
208
1126
|
// when present; cross-app feedback submissions fall back to the client hint,
|
|
209
1127
|
// which is useful context but not verified identity.
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
chatSessionId?: unknown;
|
|
215
|
-
chatSessionIds?: unknown;
|
|
216
|
-
activeRunId?: unknown;
|
|
217
|
-
pageUrl?: unknown;
|
|
218
|
-
clientSurface?: unknown;
|
|
219
|
-
})
|
|
220
|
-
: null;
|
|
1128
|
+
const metadata = submissionMetadata(
|
|
1129
|
+
body as Record<string, unknown>,
|
|
1130
|
+
anonymous,
|
|
1131
|
+
);
|
|
221
1132
|
const session = anonymous ? null : await getSession(event).catch(() => null);
|
|
222
1133
|
const submitterEmail =
|
|
223
|
-
cleanSubmitterEmail(session?.email) ??
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
1134
|
+
cleanSubmitterEmail(session?.email) ?? metadata.submitterEmail;
|
|
1135
|
+
const { chatSessionIds, activeRunId, pageUrl, clientSurface } = metadata;
|
|
1136
|
+
const deliverySnapshot = idempotencyKey
|
|
1137
|
+
? buildDeliverySnapshot({
|
|
1138
|
+
form,
|
|
1139
|
+
settings,
|
|
1140
|
+
fields,
|
|
1141
|
+
data,
|
|
1142
|
+
responseId,
|
|
1143
|
+
submittedAt: now,
|
|
1144
|
+
submitterEmail,
|
|
1145
|
+
pageUrl,
|
|
1146
|
+
clientSurface,
|
|
1147
|
+
chatSessionIds,
|
|
1148
|
+
activeRunId,
|
|
1149
|
+
})
|
|
1150
|
+
: null;
|
|
1151
|
+
|
|
1152
|
+
const responseValues = {
|
|
234
1153
|
id: responseId,
|
|
235
1154
|
formId: id,
|
|
236
1155
|
data: JSON.stringify(data),
|
|
@@ -239,69 +1158,78 @@ export const submitForm = defineEventHandler(async (event: H3Event) => {
|
|
|
239
1158
|
submitterEmail,
|
|
240
1159
|
pageUrl,
|
|
241
1160
|
clientSurface,
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
responseId,
|
|
251
|
-
timestamp: now,
|
|
252
|
-
});
|
|
253
|
-
} catch {
|
|
254
|
-
// Non-critical — don't fail the submission
|
|
255
|
-
}
|
|
1161
|
+
idempotencyKey,
|
|
1162
|
+
deliveryStatus: deliverySnapshot
|
|
1163
|
+
? JSON.stringify(configuredDeliveryStatuses(deliverySnapshot))
|
|
1164
|
+
: null,
|
|
1165
|
+
deliverySnapshot: deliverySnapshot
|
|
1166
|
+
? JSON.stringify(deliverySnapshot)
|
|
1167
|
+
: null,
|
|
1168
|
+
};
|
|
256
1169
|
|
|
257
|
-
if (
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
1170
|
+
if (idempotencyKey) {
|
|
1171
|
+
const [inserted] = await db
|
|
1172
|
+
.insert(schema.responses)
|
|
1173
|
+
.values(responseValues)
|
|
1174
|
+
.onConflictDoNothing()
|
|
1175
|
+
.returning({ id: schema.responses.id });
|
|
1176
|
+
if (!inserted) {
|
|
1177
|
+
const [existing] = await db
|
|
1178
|
+
.select({
|
|
1179
|
+
id: schema.responses.id,
|
|
1180
|
+
data: schema.responses.data,
|
|
1181
|
+
submittedAt: schema.responses.submittedAt,
|
|
1182
|
+
submitterEmail: schema.responses.submitterEmail,
|
|
1183
|
+
pageUrl: schema.responses.pageUrl,
|
|
1184
|
+
clientSurface: schema.responses.clientSurface,
|
|
1185
|
+
deliveryStatus: schema.responses.deliveryStatus,
|
|
1186
|
+
deliverySnapshot: schema.responses.deliverySnapshot,
|
|
1187
|
+
})
|
|
1188
|
+
.from(schema.responses)
|
|
1189
|
+
.where(
|
|
1190
|
+
and(
|
|
1191
|
+
eq(schema.responses.formId, id),
|
|
1192
|
+
eq(schema.responses.idempotencyKey, idempotencyKey),
|
|
1193
|
+
),
|
|
1194
|
+
);
|
|
1195
|
+
if (!existing) {
|
|
1196
|
+
throw new Error(
|
|
1197
|
+
"Submission idempotency conflict could not be resolved",
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
const existingMetadata = submissionMetadata(
|
|
1201
|
+
body as Record<string, unknown>,
|
|
1202
|
+
anonymous,
|
|
269
1203
|
);
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
// submission that was already persisted successfully.
|
|
273
|
-
console.warn("[forms] new response email failed:", error);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Fire integrations best-effort and never fail the submission. Keep this
|
|
278
|
-
// awaited: serverless hosts can freeze fire-and-forget work as soon as the
|
|
279
|
-
// HTTP response returns, which silently drops Slack/webhook delivery.
|
|
280
|
-
// pageUrl and clientSurface are persisted on the response (above) so owners
|
|
281
|
-
// can see which screen and which app feedback came from; chat session ids and
|
|
282
|
-
// run ids remain integration-only debug breadcrumbs we don't retain in SQL.
|
|
283
|
-
try {
|
|
284
|
-
const integrations: FormIntegration[] = settings.integrations ?? [];
|
|
285
|
-
if (integrations.length > 0) {
|
|
286
|
-
await fireIntegrations(integrations, {
|
|
287
|
-
formId: id,
|
|
288
|
-
formTitle: form.title,
|
|
289
|
-
responseId,
|
|
1204
|
+
return finishResponse({
|
|
1205
|
+
responseId: existing.id,
|
|
290
1206
|
fields,
|
|
291
|
-
data,
|
|
292
|
-
submittedAt:
|
|
293
|
-
submitterEmail,
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
1207
|
+
data: JSON.parse(existing.data),
|
|
1208
|
+
submittedAt: existing.submittedAt,
|
|
1209
|
+
submitterEmail: existing.submitterEmail,
|
|
1210
|
+
pageUrl: existing.pageUrl,
|
|
1211
|
+
clientSurface: existing.clientSurface,
|
|
1212
|
+
chatSessionIds: existingMetadata.chatSessionIds,
|
|
1213
|
+
activeRunId: existingMetadata.activeRunId,
|
|
1214
|
+
deliverySnapshot: existing.deliverySnapshot,
|
|
298
1215
|
});
|
|
299
1216
|
}
|
|
300
|
-
}
|
|
301
|
-
|
|
1217
|
+
} else {
|
|
1218
|
+
await db.insert(schema.responses).values(responseValues);
|
|
302
1219
|
}
|
|
303
1220
|
|
|
304
|
-
return {
|
|
1221
|
+
return finishResponse({
|
|
1222
|
+
responseId,
|
|
1223
|
+
fields,
|
|
1224
|
+
data,
|
|
1225
|
+
submittedAt: now,
|
|
1226
|
+
submitterEmail,
|
|
1227
|
+
pageUrl,
|
|
1228
|
+
clientSurface,
|
|
1229
|
+
chatSessionIds,
|
|
1230
|
+
activeRunId,
|
|
1231
|
+
deliverySnapshot: responseValues.deliverySnapshot,
|
|
1232
|
+
});
|
|
305
1233
|
});
|
|
306
1234
|
|
|
307
1235
|
export const listResponses = defineEventHandler(async (event: H3Event) => {
|