@bygghammar/cms-platform 0.37.0 → 0.38.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/CHANGELOG.md +8 -0
- package/package.json +3 -2
- package/src/server/forms-submissions-core.ts +1384 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.38.0
|
|
4
|
+
|
|
5
|
+
- Added `server/forms-submissions-core` — the whole submission lifecycle is now
|
|
6
|
+
shared: public form runtime, spam gates and server-side validation, storage,
|
|
7
|
+
all four delivery channels, drafts (partials), retention purge, delivery
|
|
8
|
+
diagnostics, retry/resolve, notification tests and the webhook ping. A site
|
|
9
|
+
keeps only zod validators plus its privileged client and request metadata.
|
|
10
|
+
|
|
3
11
|
## 0.37.0
|
|
4
12
|
|
|
5
13
|
- Added `server/forms-delivery-core` — the full form submission delivery stack
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -152,7 +152,8 @@
|
|
|
152
152
|
"./server/provider-secrets": "./src/server/provider-secrets.ts",
|
|
153
153
|
"./server/smtp": "./src/server/smtp.ts",
|
|
154
154
|
"./server/email-send-core": "./src/server/email-send-core.ts",
|
|
155
|
-
"./server/forms-delivery-core": "./src/server/forms-delivery-core.ts"
|
|
155
|
+
"./server/forms-delivery-core": "./src/server/forms-delivery-core.ts",
|
|
156
|
+
"./server/forms-submissions-core": "./src/server/forms-submissions-core.ts"
|
|
156
157
|
},
|
|
157
158
|
"dependencies": {
|
|
158
159
|
"fflate": "^0.8.3"
|
|
@@ -0,0 +1,1384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-agnostic forms *submission* server core.
|
|
3
|
+
*
|
|
4
|
+
* Everything that happens after a visitor presses "send" lives here: spam
|
|
5
|
+
* gates, server-side validation, storage, the four delivery channels, drafts
|
|
6
|
+
* (partials), retention purge, delivery diagnostics and the test/retry tools.
|
|
7
|
+
* Hosts supply a privileged database client (and, for staff reads, their auth
|
|
8
|
+
* context); nothing site-specific is referenced.
|
|
9
|
+
*
|
|
10
|
+
* Data-safety invariants preserved here:
|
|
11
|
+
* - a stored submission is never deleted by a delivery failure
|
|
12
|
+
* - drafts live in their own table and can never overwrite a submission
|
|
13
|
+
* - retention purge always supports a dry run and never runs automatically
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
appendToSheetCore,
|
|
17
|
+
notifySubmissionCore,
|
|
18
|
+
postWebhookCore,
|
|
19
|
+
renderHeader,
|
|
20
|
+
renderTemplate,
|
|
21
|
+
sendAutoreplyCore,
|
|
22
|
+
templateTokens,
|
|
23
|
+
} from "./forms-delivery-core";
|
|
24
|
+
import {
|
|
25
|
+
effectiveSettings,
|
|
26
|
+
loadFormDefaultsCore,
|
|
27
|
+
logDeliveryCore,
|
|
28
|
+
notificationTemplate,
|
|
29
|
+
pickLocalized,
|
|
30
|
+
resolveRecipients,
|
|
31
|
+
sheetRow,
|
|
32
|
+
submitterEmail,
|
|
33
|
+
type FormsContext,
|
|
34
|
+
type FormsDb,
|
|
35
|
+
} from "./forms-core";
|
|
36
|
+
import {
|
|
37
|
+
evaluateRule,
|
|
38
|
+
isFieldVisible,
|
|
39
|
+
normalizeCheckbox,
|
|
40
|
+
normalizePolicyUrl,
|
|
41
|
+
validateAll,
|
|
42
|
+
} from "../forms/conditions";
|
|
43
|
+
import {
|
|
44
|
+
mergeFields,
|
|
45
|
+
type FormFieldDef,
|
|
46
|
+
type FormPrivateSettings,
|
|
47
|
+
type FormPublicSettings,
|
|
48
|
+
type PublicForm,
|
|
49
|
+
} from "../forms/field-sets";
|
|
50
|
+
import { assertStaff } from "./guards";
|
|
51
|
+
|
|
52
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
53
|
+
|
|
54
|
+
/** Request metadata used by the {{ip}}, {{user_agent}}, {{referrer}} tags. */
|
|
55
|
+
export type RequestMeta = {
|
|
56
|
+
ip: string | null;
|
|
57
|
+
userAgent: string | null;
|
|
58
|
+
referrer: string | null;
|
|
59
|
+
siteUrl: string | null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const emptyMeta: RequestMeta = { ip: null, userAgent: null, referrer: null, siteUrl: null };
|
|
63
|
+
|
|
64
|
+
const loadDefaults = (admin: FormsDb) => loadFormDefaultsCore(admin);
|
|
65
|
+
const logDelivery = (admin: FormsDb, entry: Parameters<typeof logDeliveryCore>[1]) =>
|
|
66
|
+
logDeliveryCore(admin, entry);
|
|
67
|
+
|
|
68
|
+
/* ------------------------- public: render + submit ------------------------- */
|
|
69
|
+
|
|
70
|
+
export type PublicFormInput = { key: string };
|
|
71
|
+
|
|
72
|
+
/** Render payload for a public form; private settings never leave the server. */
|
|
73
|
+
export async function getPublicFormCore(
|
|
74
|
+
publicDb: any,
|
|
75
|
+
admin: FormsDb,
|
|
76
|
+
key: string,
|
|
77
|
+
): Promise<PublicForm | null> {
|
|
78
|
+
const runtime = await publicDb.rpc("form_public_runtime", { _key: key });
|
|
79
|
+
if (runtime.error) {
|
|
80
|
+
console.error("[forms] form_public_runtime", runtime.error);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const payload = (runtime.data ?? null) as null | {
|
|
84
|
+
id: string;
|
|
85
|
+
key: string;
|
|
86
|
+
name?: unknown;
|
|
87
|
+
form_type: string;
|
|
88
|
+
fields?: unknown;
|
|
89
|
+
public_settings?: unknown;
|
|
90
|
+
spamMinSeconds?: unknown;
|
|
91
|
+
savePartials?: unknown;
|
|
92
|
+
};
|
|
93
|
+
if (!payload?.id) return null;
|
|
94
|
+
|
|
95
|
+
const row = {
|
|
96
|
+
id: payload.id,
|
|
97
|
+
key: payload.key,
|
|
98
|
+
name: (payload.name ?? "") as string,
|
|
99
|
+
form_type: payload.form_type,
|
|
100
|
+
fields: payload.fields ?? [],
|
|
101
|
+
public_settings: payload.public_settings ?? {},
|
|
102
|
+
};
|
|
103
|
+
const defaults = await loadDefaults(admin);
|
|
104
|
+
const eff = effectiveSettings(
|
|
105
|
+
{
|
|
106
|
+
...row,
|
|
107
|
+
private_settings: {
|
|
108
|
+
spamMinSeconds: payload.spamMinSeconds,
|
|
109
|
+
savePartials: payload.savePartials,
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
defaults,
|
|
113
|
+
);
|
|
114
|
+
const pub = eff.pub;
|
|
115
|
+
const mode =
|
|
116
|
+
pub.thankyouMode ?? (defaults?.thankyou_mode as "inline" | "redirect" | undefined) ?? "inline";
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
id: row.id,
|
|
120
|
+
key: row.key,
|
|
121
|
+
name: row.name,
|
|
122
|
+
form_type: row.form_type,
|
|
123
|
+
fields: mergeFields(row.form_type, row.fields as never),
|
|
124
|
+
title: pub.title,
|
|
125
|
+
intro: pub.intro,
|
|
126
|
+
submitLabel: pub.submitLabel ?? { sv: "Skicka", en: "Send" },
|
|
127
|
+
consentText: pub.consentText ?? defaults?.consent_text ?? {},
|
|
128
|
+
privacyPolicyUrl: normalizePolicyUrl(defaults?.privacy_policy_url),
|
|
129
|
+
requireConsent: eff.requireConsent,
|
|
130
|
+
thankyouMode: mode === "redirect" ? "redirect" : "inline",
|
|
131
|
+
thankyouMessage: pub.thankyouMessage ??
|
|
132
|
+
defaults?.thankyou_message ?? {
|
|
133
|
+
sv: "Tack! Vi hör av oss snart.",
|
|
134
|
+
en: "Thank you! We'll be in touch.",
|
|
135
|
+
},
|
|
136
|
+
thankyouRedirect: pub.thankyouRedirect ?? defaults?.thankyou_redirect ?? "",
|
|
137
|
+
honeypot: eff.honeypot,
|
|
138
|
+
minSeconds: eff.minSeconds,
|
|
139
|
+
savePartials: eff.savePartials,
|
|
140
|
+
} as PublicForm;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export type SubmitInput = {
|
|
144
|
+
key: string;
|
|
145
|
+
locale: string;
|
|
146
|
+
pagePath?: string;
|
|
147
|
+
consent?: boolean;
|
|
148
|
+
elapsedMs?: number;
|
|
149
|
+
hp?: string;
|
|
150
|
+
clientToken?: string;
|
|
151
|
+
values: Record<string, string>;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export async function submitFormCore(admin: FormsDb, data: SubmitInput, meta: RequestMeta) {
|
|
155
|
+
const { data: form } = await admin
|
|
156
|
+
.from("forms")
|
|
157
|
+
.select("*")
|
|
158
|
+
.eq("key", data.key)
|
|
159
|
+
.eq("is_active", true)
|
|
160
|
+
.is("deleted_at", null)
|
|
161
|
+
.maybeSingle();
|
|
162
|
+
if (!form) throw new Error("This form is not available.");
|
|
163
|
+
|
|
164
|
+
const defaults = await loadDefaults(admin);
|
|
165
|
+
const eff = effectiveSettings(form, defaults);
|
|
166
|
+
const { ip, userAgent } = meta;
|
|
167
|
+
|
|
168
|
+
// Honeypot: silently accept so bots do not learn anything.
|
|
169
|
+
if (eff.honeypot && (data.hp ?? "").trim() !== "") return { ok: true as const, skipped: true };
|
|
170
|
+
if (typeof data.elapsedMs === "number" && data.elapsedMs < eff.minSeconds * 1000) {
|
|
171
|
+
throw new Error("Please take a moment before submitting.");
|
|
172
|
+
}
|
|
173
|
+
if (eff.maxPerHour > 0 && ip) {
|
|
174
|
+
const since = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
|
175
|
+
const { count } = await admin
|
|
176
|
+
.from("form_submissions")
|
|
177
|
+
.select("id", { count: "exact", head: true })
|
|
178
|
+
.eq("form_key", form.key)
|
|
179
|
+
.eq("ip", ip)
|
|
180
|
+
.gte("created_at", since);
|
|
181
|
+
if ((count ?? 0) >= eff.maxPerHour) {
|
|
182
|
+
throw new Error("Too many submissions from this device. Please try again later.");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const fields = mergeFields(form.form_type, form.fields);
|
|
187
|
+
|
|
188
|
+
// Raw answers first — conditional logic must see every value.
|
|
189
|
+
const raw: Record<string, string> = {};
|
|
190
|
+
for (const f of fields) {
|
|
191
|
+
if (!f.enabled) continue;
|
|
192
|
+
const value = (data.values[f.key] ?? "").toString().slice(0, 5000);
|
|
193
|
+
raw[f.key] = f.type === "checkbox" ? normalizeCheckbox(value) : value.trim();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const fieldErrors = validateAll(fields, raw, data.locale);
|
|
197
|
+
const firstError = Object.values(fieldErrors)[0];
|
|
198
|
+
if (firstError) throw new Error(firstError);
|
|
199
|
+
|
|
200
|
+
// Only store answers for fields the visitor actually saw.
|
|
201
|
+
const payload: Record<string, string> = {};
|
|
202
|
+
for (const f of fields) {
|
|
203
|
+
if (!isFieldVisible(f, raw)) continue;
|
|
204
|
+
payload[f.key] = raw[f.key] ?? "";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (eff.requireConsent && !data.consent) throw new Error("Consent is required.");
|
|
208
|
+
|
|
209
|
+
const { data: inserted, error } = await admin
|
|
210
|
+
.from("form_submissions")
|
|
211
|
+
.insert({
|
|
212
|
+
form_id: form.id,
|
|
213
|
+
form_key: form.key,
|
|
214
|
+
locale: data.locale,
|
|
215
|
+
payload,
|
|
216
|
+
consent_at: data.consent ? new Date().toISOString() : null,
|
|
217
|
+
page_path: data.pagePath ?? null,
|
|
218
|
+
ip,
|
|
219
|
+
user_agent: userAgent,
|
|
220
|
+
})
|
|
221
|
+
.select("id, created_at")
|
|
222
|
+
.single();
|
|
223
|
+
if (error) throw new Error(error.message);
|
|
224
|
+
|
|
225
|
+
// A completed submission closes any draft the visitor left behind.
|
|
226
|
+
if (data.clientToken) {
|
|
227
|
+
await admin
|
|
228
|
+
.from("form_partials")
|
|
229
|
+
.update({ completed_at: new Date().toISOString() })
|
|
230
|
+
.eq("form_key", form.key)
|
|
231
|
+
.eq("client_token", data.clientToken);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const patch: Record<string, unknown> = {};
|
|
235
|
+
const problems: string[] = [];
|
|
236
|
+
|
|
237
|
+
const recipients = eff.notifyEnabled ? resolveRecipients(eff, payload) : "";
|
|
238
|
+
if (recipients) {
|
|
239
|
+
const tpl = notificationTemplate(eff, defaults, data.locale);
|
|
240
|
+
const res = await notifySubmissionCore(admin, {
|
|
241
|
+
...meta,
|
|
242
|
+
to: recipients,
|
|
243
|
+
formName: form.name,
|
|
244
|
+
formKey: form.key,
|
|
245
|
+
locale: data.locale,
|
|
246
|
+
pagePath: data.pagePath,
|
|
247
|
+
fields,
|
|
248
|
+
payload,
|
|
249
|
+
submissionId: inserted.id,
|
|
250
|
+
subject: tpl.subject,
|
|
251
|
+
body: tpl.body,
|
|
252
|
+
fromName: eff.notifyFromName,
|
|
253
|
+
fromEmail: eff.notifyFromEmail,
|
|
254
|
+
replyTo: eff.notifyReplyTo,
|
|
255
|
+
});
|
|
256
|
+
if (res.ok) patch.notified_at = new Date().toISOString();
|
|
257
|
+
else problems.push(`Notification: ${res.error}`);
|
|
258
|
+
await logDelivery(admin, {
|
|
259
|
+
submissionId: inserted.id,
|
|
260
|
+
formKey: form.key,
|
|
261
|
+
channel: "notification",
|
|
262
|
+
ok: res.ok,
|
|
263
|
+
target: recipients,
|
|
264
|
+
error: res.error,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (eff.autoreplyEnabled) {
|
|
269
|
+
const subject = pickLocalized(
|
|
270
|
+
eff.priv.autoreplySubject as Record<string, string> | undefined,
|
|
271
|
+
defaults?.autoreply_subject as Record<string, string> | undefined,
|
|
272
|
+
data.locale,
|
|
273
|
+
);
|
|
274
|
+
const body = pickLocalized(
|
|
275
|
+
eff.priv.autoreplyBody as Record<string, string> | undefined,
|
|
276
|
+
defaults?.autoreply_body as Record<string, string> | undefined,
|
|
277
|
+
data.locale,
|
|
278
|
+
);
|
|
279
|
+
const to = submitterEmail(fields, payload);
|
|
280
|
+
if (subject && body && to) {
|
|
281
|
+
const res = await sendAutoreplyCore(admin, {
|
|
282
|
+
...meta,
|
|
283
|
+
to,
|
|
284
|
+
subject,
|
|
285
|
+
body,
|
|
286
|
+
formKey: form.key,
|
|
287
|
+
formName: form.name,
|
|
288
|
+
payload,
|
|
289
|
+
fields,
|
|
290
|
+
locale: data.locale,
|
|
291
|
+
pagePath: data.pagePath,
|
|
292
|
+
submissionId: inserted.id,
|
|
293
|
+
fromName: eff.notifyFromName,
|
|
294
|
+
fromEmail: eff.notifyFromEmail,
|
|
295
|
+
});
|
|
296
|
+
if (res.ok) patch.autoreply_at = new Date().toISOString();
|
|
297
|
+
else problems.push(`Autoreply: ${res.error}`);
|
|
298
|
+
await logDelivery(admin, {
|
|
299
|
+
submissionId: inserted.id,
|
|
300
|
+
formKey: form.key,
|
|
301
|
+
channel: "autoreply",
|
|
302
|
+
ok: res.ok,
|
|
303
|
+
target: to,
|
|
304
|
+
error: res.error,
|
|
305
|
+
});
|
|
306
|
+
} else if (!to) {
|
|
307
|
+
problems.push("Autoreply: no email address in the submission.");
|
|
308
|
+
await logDelivery(admin, {
|
|
309
|
+
submissionId: inserted.id,
|
|
310
|
+
formKey: form.key,
|
|
311
|
+
channel: "autoreply",
|
|
312
|
+
ok: false,
|
|
313
|
+
error: "No email address in the submission.",
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (eff.sheetLive && eff.sheetId) {
|
|
319
|
+
const res = await appendToSheetCore({
|
|
320
|
+
sheetId: eff.sheetId,
|
|
321
|
+
tab: eff.sheetTab,
|
|
322
|
+
values: sheetRow(inserted.created_at, form.key, data.locale, fields, payload),
|
|
323
|
+
});
|
|
324
|
+
patch.sheet_synced_at = res.ok ? new Date().toISOString() : null;
|
|
325
|
+
patch.sheet_error = res.ok ? null : (res.error ?? "Unknown error");
|
|
326
|
+
await logDelivery(admin, {
|
|
327
|
+
submissionId: inserted.id,
|
|
328
|
+
formKey: form.key,
|
|
329
|
+
channel: "sheet",
|
|
330
|
+
ok: res.ok,
|
|
331
|
+
target: `${eff.sheetId} · ${eff.sheetTab}`,
|
|
332
|
+
error: res.error,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (eff.webhookEnabled) {
|
|
337
|
+
const startedAt = Date.now();
|
|
338
|
+
const res = await postWebhookCore({
|
|
339
|
+
url: eff.webhookUrl,
|
|
340
|
+
secret: eff.webhookSecret,
|
|
341
|
+
formKey: form.key,
|
|
342
|
+
formName: form.name,
|
|
343
|
+
submissionId: inserted.id,
|
|
344
|
+
locale: data.locale,
|
|
345
|
+
pagePath: data.pagePath,
|
|
346
|
+
createdAt: inserted.created_at,
|
|
347
|
+
payload,
|
|
348
|
+
});
|
|
349
|
+
if (!res.ok) problems.push(`Webhook: ${res.error}`);
|
|
350
|
+
await logDelivery(admin, {
|
|
351
|
+
submissionId: inserted.id,
|
|
352
|
+
formKey: form.key,
|
|
353
|
+
channel: "webhook",
|
|
354
|
+
ok: res.ok,
|
|
355
|
+
target: eff.webhookUrl,
|
|
356
|
+
error: res.error,
|
|
357
|
+
durationMs: Date.now() - startedAt,
|
|
358
|
+
meta: { status: res.status ?? null },
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (problems.length) {
|
|
363
|
+
patch.delivery_error = problems.join(" · ").slice(0, 1000);
|
|
364
|
+
console.error(`[forms] delivery issues for ${form.key}:`, patch.delivery_error);
|
|
365
|
+
}
|
|
366
|
+
if (Object.keys(patch).length) {
|
|
367
|
+
await admin.from("form_submissions").update(patch).eq("id", inserted.id);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return { ok: true as const, skipped: false };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/* ------------------------------- submissions ------------------------------ */
|
|
374
|
+
|
|
375
|
+
export type SubmissionRow = {
|
|
376
|
+
id: string;
|
|
377
|
+
form_key: string;
|
|
378
|
+
locale: string;
|
|
379
|
+
payload: Record<string, string>;
|
|
380
|
+
status: string;
|
|
381
|
+
consent_at: string | null;
|
|
382
|
+
sheet_synced_at: string | null;
|
|
383
|
+
sheet_error: string | null;
|
|
384
|
+
notified_at: string | null;
|
|
385
|
+
autoreply_at: string | null;
|
|
386
|
+
delivery_error: string | null;
|
|
387
|
+
page_path: string | null;
|
|
388
|
+
ip: string | null;
|
|
389
|
+
user_agent: string | null;
|
|
390
|
+
created_at: string;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
export type SubmissionsPage = {
|
|
394
|
+
rows: SubmissionRow[];
|
|
395
|
+
total: number;
|
|
396
|
+
counts: Record<string, number>;
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
export type ListSubmissionsInput = {
|
|
400
|
+
formKey?: string;
|
|
401
|
+
status?: "new" | "read" | "handled" | "spam";
|
|
402
|
+
locale?: string;
|
|
403
|
+
search?: string;
|
|
404
|
+
from?: string;
|
|
405
|
+
to?: string;
|
|
406
|
+
page: number;
|
|
407
|
+
pageSize: number;
|
|
408
|
+
sort: "newest" | "oldest";
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
export async function listSubmissionsCore(
|
|
412
|
+
context: FormsContext,
|
|
413
|
+
data: ListSubmissionsInput,
|
|
414
|
+
): Promise<SubmissionsPage> {
|
|
415
|
+
await assertStaff(context);
|
|
416
|
+
|
|
417
|
+
const applyFilters = (q: any) => {
|
|
418
|
+
if (data.formKey) q = q.eq("form_key", data.formKey);
|
|
419
|
+
if (data.status) q = q.eq("status", data.status);
|
|
420
|
+
if (data.locale) q = q.eq("locale", data.locale);
|
|
421
|
+
if (data.from) q = q.gte("created_at", new Date(data.from).toISOString());
|
|
422
|
+
if (data.to) {
|
|
423
|
+
const end = new Date(data.to);
|
|
424
|
+
end.setHours(23, 59, 59, 999);
|
|
425
|
+
q = q.lte("created_at", end.toISOString());
|
|
426
|
+
}
|
|
427
|
+
if (data.search) {
|
|
428
|
+
const term = data.search.replace(/[%,()]/g, " ").trim();
|
|
429
|
+
if (term) q = q.or(`payload::text.ilike.%${term}%,page_path.ilike.%${term}%`);
|
|
430
|
+
}
|
|
431
|
+
return q;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const from = data.page * data.pageSize;
|
|
435
|
+
const {
|
|
436
|
+
data: rows,
|
|
437
|
+
error,
|
|
438
|
+
count,
|
|
439
|
+
} = await applyFilters(context.supabase.from("form_submissions").select("*", { count: "exact" }))
|
|
440
|
+
.order("created_at", { ascending: data.sort === "oldest" })
|
|
441
|
+
.range(from, from + data.pageSize - 1);
|
|
442
|
+
if (error) throw new Error(error.message);
|
|
443
|
+
|
|
444
|
+
// Status tallies for the current form scope (ignoring the status filter).
|
|
445
|
+
let statusQ = context.supabase.from("form_submissions").select("status").limit(5000);
|
|
446
|
+
if (data.formKey) statusQ = statusQ.eq("form_key", data.formKey);
|
|
447
|
+
const { data: statusRows } = await statusQ;
|
|
448
|
+
const counts: Record<string, number> = { new: 0, read: 0, handled: 0, spam: 0 };
|
|
449
|
+
for (const s of statusRows ?? []) counts[s.status] = (counts[s.status] ?? 0) + 1;
|
|
450
|
+
|
|
451
|
+
return { rows: (rows ?? []) as SubmissionRow[], total: count ?? 0, counts };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export async function setSubmissionStatusCore(
|
|
455
|
+
context: FormsContext,
|
|
456
|
+
data: { ids: string[]; status: string },
|
|
457
|
+
) {
|
|
458
|
+
await assertStaff(context);
|
|
459
|
+
const { error } = await context.supabase
|
|
460
|
+
.from("form_submissions")
|
|
461
|
+
.update({ status: data.status })
|
|
462
|
+
.in("id", data.ids);
|
|
463
|
+
if (error) throw new Error(error.message);
|
|
464
|
+
return { ok: true as const, updated: data.ids.length };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Re-send the admin notification (or the autoreply) for one submission. */
|
|
468
|
+
export async function resendDeliveryCore(
|
|
469
|
+
context: FormsContext,
|
|
470
|
+
admin: FormsDb,
|
|
471
|
+
data: { id: string; what: "notification" | "autoreply"; to?: string },
|
|
472
|
+
siteUrl: string | null,
|
|
473
|
+
) {
|
|
474
|
+
await assertStaff(context);
|
|
475
|
+
|
|
476
|
+
const { data: sub, error } = await admin
|
|
477
|
+
.from("form_submissions")
|
|
478
|
+
.select("*")
|
|
479
|
+
.eq("id", data.id)
|
|
480
|
+
.single();
|
|
481
|
+
if (error) throw new Error(error.message);
|
|
482
|
+
|
|
483
|
+
const { data: form } = await admin
|
|
484
|
+
.from("forms")
|
|
485
|
+
.select("*")
|
|
486
|
+
.eq("key", sub.form_key)
|
|
487
|
+
.maybeSingle();
|
|
488
|
+
if (!form) throw new Error("The form for this submission no longer exists.");
|
|
489
|
+
|
|
490
|
+
const defaults = await loadDefaults(admin);
|
|
491
|
+
const eff = effectiveSettings(form, defaults);
|
|
492
|
+
const fields = mergeFields(form.form_type, form.fields);
|
|
493
|
+
const payload = (sub.payload ?? {}) as Record<string, string>;
|
|
494
|
+
// Resends reuse the metadata stored with the submission, not the staff request.
|
|
495
|
+
const meta: RequestMeta = {
|
|
496
|
+
ip: sub.ip ?? null,
|
|
497
|
+
userAgent: sub.user_agent ?? null,
|
|
498
|
+
referrer: null,
|
|
499
|
+
siteUrl,
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
if (data.what === "notification") {
|
|
503
|
+
const to = data.to?.trim() || resolveRecipients(eff, payload);
|
|
504
|
+
if (!to) throw new Error("No notification recipients configured.");
|
|
505
|
+
const tpl = notificationTemplate(eff, defaults, sub.locale);
|
|
506
|
+
const res = await notifySubmissionCore(admin, {
|
|
507
|
+
...meta,
|
|
508
|
+
to,
|
|
509
|
+
formName: form.name,
|
|
510
|
+
formKey: form.key,
|
|
511
|
+
locale: sub.locale,
|
|
512
|
+
pagePath: sub.page_path,
|
|
513
|
+
fields,
|
|
514
|
+
payload,
|
|
515
|
+
submissionId: sub.id,
|
|
516
|
+
subject: tpl.subject,
|
|
517
|
+
body: tpl.body,
|
|
518
|
+
fromName: eff.notifyFromName,
|
|
519
|
+
fromEmail: eff.notifyFromEmail,
|
|
520
|
+
replyTo: eff.notifyReplyTo,
|
|
521
|
+
});
|
|
522
|
+
if (!res.ok) throw new Error(res.error ?? "Could not send the notification.");
|
|
523
|
+
await admin
|
|
524
|
+
.from("form_submissions")
|
|
525
|
+
.update({ notified_at: new Date().toISOString(), delivery_error: null })
|
|
526
|
+
.eq("id", sub.id);
|
|
527
|
+
return { ok: true as const };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const subject = pickLocalized(
|
|
531
|
+
eff.priv.autoreplySubject as Record<string, string> | undefined,
|
|
532
|
+
defaults?.autoreply_subject as Record<string, string> | undefined,
|
|
533
|
+
sub.locale,
|
|
534
|
+
);
|
|
535
|
+
const body = pickLocalized(
|
|
536
|
+
eff.priv.autoreplyBody as Record<string, string> | undefined,
|
|
537
|
+
defaults?.autoreply_body as Record<string, string> | undefined,
|
|
538
|
+
sub.locale,
|
|
539
|
+
);
|
|
540
|
+
const to = data.to?.trim() || submitterEmail(fields, payload);
|
|
541
|
+
if (!to) throw new Error("This submission has no email address.");
|
|
542
|
+
if (!subject || !body) throw new Error("No autoreply subject or body configured.");
|
|
543
|
+
const res = await sendAutoreplyCore(admin, {
|
|
544
|
+
...meta,
|
|
545
|
+
to,
|
|
546
|
+
subject,
|
|
547
|
+
body,
|
|
548
|
+
formKey: form.key,
|
|
549
|
+
formName: form.name,
|
|
550
|
+
payload,
|
|
551
|
+
fields,
|
|
552
|
+
locale: sub.locale,
|
|
553
|
+
pagePath: sub.page_path,
|
|
554
|
+
submissionId: sub.id,
|
|
555
|
+
fromName: eff.notifyFromName,
|
|
556
|
+
fromEmail: eff.notifyFromEmail,
|
|
557
|
+
});
|
|
558
|
+
if (!res.ok) throw new Error(res.error ?? "Could not send the autoreply.");
|
|
559
|
+
await admin
|
|
560
|
+
.from("form_submissions")
|
|
561
|
+
.update({ autoreply_at: new Date().toISOString(), delivery_error: null })
|
|
562
|
+
.eq("id", sub.id);
|
|
563
|
+
return { ok: true as const };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Push specific submissions to the sheet configured for their form. */
|
|
567
|
+
export async function pushSubmissionsToSheetCore(
|
|
568
|
+
context: FormsContext,
|
|
569
|
+
admin: FormsDb,
|
|
570
|
+
ids: string[],
|
|
571
|
+
) {
|
|
572
|
+
await assertStaff(context);
|
|
573
|
+
const { data: rows, error } = await admin
|
|
574
|
+
.from("form_submissions")
|
|
575
|
+
.select("*")
|
|
576
|
+
.in("id", ids)
|
|
577
|
+
.order("created_at", { ascending: true });
|
|
578
|
+
if (error) throw new Error(error.message);
|
|
579
|
+
|
|
580
|
+
const defaults = await loadDefaults(admin);
|
|
581
|
+
const formCache = new Map<string, any>();
|
|
582
|
+
let synced = 0;
|
|
583
|
+
let lastError: string | undefined;
|
|
584
|
+
|
|
585
|
+
for (const r of rows ?? []) {
|
|
586
|
+
let form = formCache.get(r.form_key);
|
|
587
|
+
if (!form) {
|
|
588
|
+
const { data: f } = await admin.from("forms").select("*").eq("key", r.form_key).maybeSingle();
|
|
589
|
+
form = f;
|
|
590
|
+
formCache.set(r.form_key, f);
|
|
591
|
+
}
|
|
592
|
+
if (!form) {
|
|
593
|
+
lastError = `Form "${r.form_key}" no longer exists.`;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
const eff = effectiveSettings(form, defaults);
|
|
597
|
+
if (!eff.sheetId) {
|
|
598
|
+
lastError = `No Google Sheet configured for "${r.form_key}".`;
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
const fields = mergeFields(form.form_type, form.fields);
|
|
602
|
+
const res = await appendToSheetCore({
|
|
603
|
+
sheetId: eff.sheetId,
|
|
604
|
+
tab: eff.sheetTab,
|
|
605
|
+
values: sheetRow(
|
|
606
|
+
r.created_at,
|
|
607
|
+
r.form_key,
|
|
608
|
+
r.locale,
|
|
609
|
+
fields,
|
|
610
|
+
(r.payload ?? {}) as Record<string, string>,
|
|
611
|
+
),
|
|
612
|
+
});
|
|
613
|
+
await admin
|
|
614
|
+
.from("form_submissions")
|
|
615
|
+
.update({
|
|
616
|
+
sheet_synced_at: res.ok ? new Date().toISOString() : null,
|
|
617
|
+
sheet_error: res.ok ? null : (res.error ?? "Unknown error"),
|
|
618
|
+
})
|
|
619
|
+
.eq("id", r.id);
|
|
620
|
+
if (res.ok) synced += 1;
|
|
621
|
+
else lastError = res.error;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (synced === 0 && lastError) throw new Error(lastError);
|
|
625
|
+
return { synced, error: lastError ?? null };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Push every not-yet-synced submission of one form to its sheet. */
|
|
629
|
+
export async function syncSubmissionsToSheetCore(
|
|
630
|
+
context: FormsContext,
|
|
631
|
+
admin: FormsDb,
|
|
632
|
+
formKey: string,
|
|
633
|
+
) {
|
|
634
|
+
await assertStaff(context);
|
|
635
|
+
const { data: form } = await admin.from("forms").select("*").eq("key", formKey).maybeSingle();
|
|
636
|
+
if (!form) throw new Error("Form not found");
|
|
637
|
+
const defaults = await loadDefaults(admin);
|
|
638
|
+
const eff = effectiveSettings(form, defaults);
|
|
639
|
+
if (!eff.sheetId) throw new Error("No Google Sheet configured for this form.");
|
|
640
|
+
|
|
641
|
+
const fields = mergeFields(form.form_type, form.fields);
|
|
642
|
+
const { data: rows } = await admin
|
|
643
|
+
.from("form_submissions")
|
|
644
|
+
.select("*")
|
|
645
|
+
.eq("form_key", formKey)
|
|
646
|
+
.is("sheet_synced_at", null)
|
|
647
|
+
.order("created_at", { ascending: true })
|
|
648
|
+
.limit(200);
|
|
649
|
+
|
|
650
|
+
let synced = 0;
|
|
651
|
+
let lastError: string | undefined;
|
|
652
|
+
for (const r of rows ?? []) {
|
|
653
|
+
const res = await appendToSheetCore({
|
|
654
|
+
sheetId: eff.sheetId,
|
|
655
|
+
tab: eff.sheetTab,
|
|
656
|
+
values: sheetRow(
|
|
657
|
+
r.created_at,
|
|
658
|
+
r.form_key,
|
|
659
|
+
r.locale,
|
|
660
|
+
fields,
|
|
661
|
+
(r.payload ?? {}) as Record<string, string>,
|
|
662
|
+
),
|
|
663
|
+
});
|
|
664
|
+
await admin
|
|
665
|
+
.from("form_submissions")
|
|
666
|
+
.update({
|
|
667
|
+
sheet_synced_at: res.ok ? new Date().toISOString() : null,
|
|
668
|
+
sheet_error: res.ok ? null : (res.error ?? "Unknown error"),
|
|
669
|
+
})
|
|
670
|
+
.eq("id", r.id);
|
|
671
|
+
if (res.ok) synced += 1;
|
|
672
|
+
else {
|
|
673
|
+
lastError = res.error;
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
if (synced === 0 && lastError) throw new Error(lastError);
|
|
678
|
+
return { synced, error: lastError ?? null };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Retention purge. Always call with dryRun first; nothing is ever purged
|
|
683
|
+
* automatically and retention must be explicitly configured.
|
|
684
|
+
*/
|
|
685
|
+
export async function purgeOldSubmissionsCore(
|
|
686
|
+
context: FormsContext,
|
|
687
|
+
admin: FormsDb,
|
|
688
|
+
data: { dryRun: boolean; formKey?: string },
|
|
689
|
+
) {
|
|
690
|
+
await assertStaff(context);
|
|
691
|
+
const defaults = await loadDefaults(admin);
|
|
692
|
+
const days = defaults?.retention_days ?? 0;
|
|
693
|
+
if (!days || days <= 0) {
|
|
694
|
+
throw new Error("Retention is turned off (0 days). Set a retention period first.");
|
|
695
|
+
}
|
|
696
|
+
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
|
|
697
|
+
|
|
698
|
+
let q = admin
|
|
699
|
+
.from("form_submissions")
|
|
700
|
+
.select("id", { count: "exact", head: true })
|
|
701
|
+
.lt("created_at", cutoff);
|
|
702
|
+
if (data.formKey) q = q.eq("form_key", data.formKey);
|
|
703
|
+
const { count } = await q;
|
|
704
|
+
const matched = count ?? 0;
|
|
705
|
+
|
|
706
|
+
if (data.dryRun || matched === 0) return { matched, deleted: 0, cutoff };
|
|
707
|
+
|
|
708
|
+
let del = admin.from("form_submissions").delete().lt("created_at", cutoff);
|
|
709
|
+
if (data.formKey) del = del.eq("form_key", data.formKey);
|
|
710
|
+
const { error } = await del;
|
|
711
|
+
if (error) throw new Error(error.message);
|
|
712
|
+
return { matched, deleted: matched, cutoff };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/* --------------------------- incomplete (draft) forms --------------------- */
|
|
716
|
+
|
|
717
|
+
export type PartialRow = {
|
|
718
|
+
id: string;
|
|
719
|
+
form_key: string;
|
|
720
|
+
locale: string;
|
|
721
|
+
payload: Record<string, string>;
|
|
722
|
+
page_path: string | null;
|
|
723
|
+
completed_at: string | null;
|
|
724
|
+
created_at: string;
|
|
725
|
+
updated_at: string;
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
/** Store what a visitor typed so far. Never blocks and never throws. */
|
|
729
|
+
export async function savePartialSubmissionCore(
|
|
730
|
+
admin: FormsDb,
|
|
731
|
+
data: {
|
|
732
|
+
key: string;
|
|
733
|
+
clientToken: string;
|
|
734
|
+
locale: string;
|
|
735
|
+
pagePath?: string;
|
|
736
|
+
values: Record<string, string>;
|
|
737
|
+
},
|
|
738
|
+
meta: Pick<RequestMeta, "ip" | "userAgent">,
|
|
739
|
+
) {
|
|
740
|
+
try {
|
|
741
|
+
const { data: form } = await admin
|
|
742
|
+
.from("forms")
|
|
743
|
+
.select("id, key, form_type, fields, public_settings, private_settings, is_active")
|
|
744
|
+
.eq("key", data.key)
|
|
745
|
+
.eq("is_active", true)
|
|
746
|
+
.maybeSingle();
|
|
747
|
+
if (!form) return { ok: false as const };
|
|
748
|
+
|
|
749
|
+
const defaults = await loadDefaults(admin);
|
|
750
|
+
const eff = effectiveSettings(form, defaults);
|
|
751
|
+
if (!eff.savePartials) return { ok: false as const };
|
|
752
|
+
|
|
753
|
+
const fields = mergeFields(form.form_type, form.fields);
|
|
754
|
+
const payload: Record<string, string> = {};
|
|
755
|
+
for (const f of fields) {
|
|
756
|
+
const v = (data.values[f.key] ?? "").toString().slice(0, 5000).trim();
|
|
757
|
+
if (v) payload[f.key] = v;
|
|
758
|
+
}
|
|
759
|
+
if (Object.keys(payload).length === 0) return { ok: false as const };
|
|
760
|
+
|
|
761
|
+
await admin.from("form_partials").upsert(
|
|
762
|
+
{
|
|
763
|
+
form_id: form.id,
|
|
764
|
+
form_key: form.key,
|
|
765
|
+
client_token: data.clientToken,
|
|
766
|
+
locale: data.locale,
|
|
767
|
+
payload,
|
|
768
|
+
page_path: data.pagePath ?? null,
|
|
769
|
+
ip: meta.ip,
|
|
770
|
+
user_agent: meta.userAgent,
|
|
771
|
+
updated_at: new Date().toISOString(),
|
|
772
|
+
},
|
|
773
|
+
{ onConflict: "form_key,client_token" },
|
|
774
|
+
);
|
|
775
|
+
return { ok: true as const };
|
|
776
|
+
} catch (e) {
|
|
777
|
+
console.error("[forms] savePartialSubmission", e);
|
|
778
|
+
return { ok: false as const };
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
export async function listPartialsCore(
|
|
783
|
+
context: FormsContext,
|
|
784
|
+
data: { formKey?: string; includeCompleted: boolean; limit: number },
|
|
785
|
+
): Promise<PartialRow[]> {
|
|
786
|
+
await assertStaff(context);
|
|
787
|
+
let q = context.supabase
|
|
788
|
+
.from("form_partials")
|
|
789
|
+
.select("*")
|
|
790
|
+
.order("updated_at", { ascending: false })
|
|
791
|
+
.limit(data.limit);
|
|
792
|
+
if (data.formKey) q = q.eq("form_key", data.formKey);
|
|
793
|
+
if (!data.includeCompleted) q = q.is("completed_at", null);
|
|
794
|
+
const { data: rows, error } = await q;
|
|
795
|
+
if (error) throw new Error(error.message);
|
|
796
|
+
return (rows ?? []) as PartialRow[];
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
export async function deletePartialsCore(context: FormsContext, ids: string[]) {
|
|
800
|
+
await assertStaff(context);
|
|
801
|
+
const { error } = await context.supabase.from("form_partials").delete().in("id", ids);
|
|
802
|
+
if (error) throw new Error(error.message);
|
|
803
|
+
return { ok: true as const, deleted: ids.length };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/* ------------------------------ test delivery ----------------------------- */
|
|
807
|
+
|
|
808
|
+
export type TestNotificationResult = {
|
|
809
|
+
sent: boolean;
|
|
810
|
+
recipients: string[];
|
|
811
|
+
subject: string;
|
|
812
|
+
from: string;
|
|
813
|
+
replyTo: string;
|
|
814
|
+
matchedRules: { to: string; exclusive: boolean; label?: string }[];
|
|
815
|
+
sampleValues: Record<string, string>;
|
|
816
|
+
warnings: string[];
|
|
817
|
+
error?: string;
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
export type TestNotificationInput = {
|
|
821
|
+
formKey: string;
|
|
822
|
+
locale: string;
|
|
823
|
+
fields?: Record<string, unknown>[];
|
|
824
|
+
private_settings?: Record<string, unknown>;
|
|
825
|
+
values?: Record<string, string>;
|
|
826
|
+
to?: string;
|
|
827
|
+
send: boolean;
|
|
828
|
+
what: "notification" | "autoreply";
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Verify a form's notification setup end to end without creating a submission:
|
|
833
|
+
* resolves templates, sender identity and conditional routing against sample
|
|
834
|
+
* answers, then optionally sends the real email.
|
|
835
|
+
*/
|
|
836
|
+
export async function sendTestNotificationCore(
|
|
837
|
+
context: FormsContext,
|
|
838
|
+
admin: FormsDb,
|
|
839
|
+
data: TestNotificationInput,
|
|
840
|
+
meta: RequestMeta,
|
|
841
|
+
): Promise<TestNotificationResult> {
|
|
842
|
+
await assertStaff(context);
|
|
843
|
+
|
|
844
|
+
const { data: stored } = await admin
|
|
845
|
+
.from("forms")
|
|
846
|
+
.select("*")
|
|
847
|
+
.eq("key", data.formKey)
|
|
848
|
+
.maybeSingle();
|
|
849
|
+
if (!stored) throw new Error("Form not found");
|
|
850
|
+
|
|
851
|
+
const form = {
|
|
852
|
+
...stored,
|
|
853
|
+
fields: data.fields ?? stored.fields,
|
|
854
|
+
private_settings: data.private_settings ?? stored.private_settings,
|
|
855
|
+
};
|
|
856
|
+
const defaults = await loadDefaults(admin);
|
|
857
|
+
const eff = effectiveSettings(form, defaults);
|
|
858
|
+
const fields = mergeFields(form.form_type, form.fields);
|
|
859
|
+
|
|
860
|
+
// Sample answers: what the editor typed, otherwise a plausible value.
|
|
861
|
+
const sample: Record<string, string> = {};
|
|
862
|
+
for (const f of fields) {
|
|
863
|
+
if (!f.enabled) continue;
|
|
864
|
+
const given = (data.values?.[f.key] ?? "").trim();
|
|
865
|
+
if (given) {
|
|
866
|
+
sample[f.key] = f.type === "checkbox" ? normalizeCheckbox(given) : given;
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
sample[f.key] =
|
|
870
|
+
f.type === "email"
|
|
871
|
+
? "test@example.com"
|
|
872
|
+
: f.type === "tel"
|
|
873
|
+
? "+46 70 123 45 67"
|
|
874
|
+
: f.type === "number"
|
|
875
|
+
? "2"
|
|
876
|
+
: f.type === "date"
|
|
877
|
+
? new Date().toISOString().slice(0, 10)
|
|
878
|
+
: f.type === "time"
|
|
879
|
+
? "18:00"
|
|
880
|
+
: f.type === "checkbox"
|
|
881
|
+
? "true"
|
|
882
|
+
: f.type === "select"
|
|
883
|
+
? (f.options?.[0]?.value ?? "")
|
|
884
|
+
: `Test ${f.key}`;
|
|
885
|
+
}
|
|
886
|
+
const values: Record<string, string> = {};
|
|
887
|
+
for (const f of fields) {
|
|
888
|
+
if (isFieldVisible(f, sample)) values[f.key] = sample[f.key] ?? "";
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const matchedRules = eff.notificationRules
|
|
892
|
+
.filter((r) => evaluateRule(r.when, values))
|
|
893
|
+
.map((r) => ({ to: r.to, exclusive: r.exclusive === true, label: r.label }));
|
|
894
|
+
|
|
895
|
+
const warnings: string[] = [];
|
|
896
|
+
if (!eff.notifyEnabled) warnings.push("Notifications are switched off for this form.");
|
|
897
|
+
if (!eff.notifyFromEmail) warnings.push("No sender address set — the provider default is used.");
|
|
898
|
+
|
|
899
|
+
const tokens = templateTokens({
|
|
900
|
+
...meta,
|
|
901
|
+
formName: form.name,
|
|
902
|
+
formKey: form.key,
|
|
903
|
+
locale: data.locale,
|
|
904
|
+
pagePath: "/test",
|
|
905
|
+
});
|
|
906
|
+
// Show exactly what the visitor's answers resolve to.
|
|
907
|
+
const resolvedReplyTo = renderHeader(eff.notifyReplyTo, values, tokens) || eff.notifyReplyTo;
|
|
908
|
+
const resolvedFromName = renderHeader(eff.notifyFromName, values, tokens);
|
|
909
|
+
|
|
910
|
+
if (data.what === "autoreply") {
|
|
911
|
+
const subjectTpl = pickLocalized(
|
|
912
|
+
eff.priv.autoreplySubject as Record<string, string> | undefined,
|
|
913
|
+
defaults?.autoreply_subject as Record<string, string> | undefined,
|
|
914
|
+
data.locale,
|
|
915
|
+
);
|
|
916
|
+
const bodyTpl = pickLocalized(
|
|
917
|
+
eff.priv.autoreplyBody as Record<string, string> | undefined,
|
|
918
|
+
defaults?.autoreply_body as Record<string, string> | undefined,
|
|
919
|
+
data.locale,
|
|
920
|
+
);
|
|
921
|
+
const to = data.to?.trim() || submitterEmail(fields, values);
|
|
922
|
+
const subject = renderTemplate(subjectTpl, values, tokens);
|
|
923
|
+
if (!eff.autoreplyEnabled) warnings.push("Autoreply is switched off for this form.");
|
|
924
|
+
if (!subjectTpl || !bodyTpl) warnings.push("Autoreply subject or body is empty.");
|
|
925
|
+
|
|
926
|
+
const base: TestNotificationResult = {
|
|
927
|
+
sent: false,
|
|
928
|
+
recipients: to ? [to] : [],
|
|
929
|
+
subject,
|
|
930
|
+
from: [resolvedFromName, eff.notifyFromEmail].filter(Boolean).join(" "),
|
|
931
|
+
replyTo: resolvedReplyTo,
|
|
932
|
+
matchedRules,
|
|
933
|
+
sampleValues: values,
|
|
934
|
+
warnings,
|
|
935
|
+
};
|
|
936
|
+
if (!data.send) return base;
|
|
937
|
+
if (!to) return { ...base, error: "No recipient — add a test address." };
|
|
938
|
+
const res = await sendAutoreplyCore(admin, {
|
|
939
|
+
...meta,
|
|
940
|
+
to,
|
|
941
|
+
subject: subjectTpl,
|
|
942
|
+
body: bodyTpl,
|
|
943
|
+
formKey: form.key,
|
|
944
|
+
formName: form.name,
|
|
945
|
+
payload: values,
|
|
946
|
+
fields,
|
|
947
|
+
locale: data.locale,
|
|
948
|
+
pagePath: "/test",
|
|
949
|
+
fromName: eff.notifyFromName,
|
|
950
|
+
fromEmail: eff.notifyFromEmail,
|
|
951
|
+
});
|
|
952
|
+
return { ...base, sent: res.ok, error: res.ok ? undefined : res.error };
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const resolved = data.to?.trim() || resolveRecipients(eff, values);
|
|
956
|
+
const recipients = resolved
|
|
957
|
+
.split(/[,;\s]+/)
|
|
958
|
+
.map((s) => s.trim())
|
|
959
|
+
.filter((s) => s.includes("@"));
|
|
960
|
+
if (recipients.length === 0) warnings.push("No recipients resolved for these answers.");
|
|
961
|
+
|
|
962
|
+
const tpl = notificationTemplate(eff, defaults, data.locale);
|
|
963
|
+
const subject = tpl.subject
|
|
964
|
+
? renderTemplate(tpl.subject, values, tokens)
|
|
965
|
+
: `${form.name}: new submission`;
|
|
966
|
+
|
|
967
|
+
const base: TestNotificationResult = {
|
|
968
|
+
sent: false,
|
|
969
|
+
recipients,
|
|
970
|
+
subject,
|
|
971
|
+
from: [resolvedFromName, eff.notifyFromEmail].filter(Boolean).join(" "),
|
|
972
|
+
replyTo: resolvedReplyTo,
|
|
973
|
+
matchedRules,
|
|
974
|
+
sampleValues: values,
|
|
975
|
+
warnings,
|
|
976
|
+
};
|
|
977
|
+
if (!data.send || recipients.length === 0) return base;
|
|
978
|
+
|
|
979
|
+
const res = await notifySubmissionCore(admin, {
|
|
980
|
+
...meta,
|
|
981
|
+
to: recipients.join(","),
|
|
982
|
+
formName: `${form.name} (TEST)`,
|
|
983
|
+
formKey: form.key,
|
|
984
|
+
locale: data.locale,
|
|
985
|
+
pagePath: "/test",
|
|
986
|
+
fields,
|
|
987
|
+
payload: values,
|
|
988
|
+
subject: tpl.subject,
|
|
989
|
+
body: tpl.body,
|
|
990
|
+
fromName: eff.notifyFromName,
|
|
991
|
+
fromEmail: eff.notifyFromEmail,
|
|
992
|
+
replyTo: eff.notifyReplyTo,
|
|
993
|
+
});
|
|
994
|
+
return { ...base, sent: res.ok, error: res.ok ? undefined : res.error };
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/* --------------------------- delivery diagnostics ------------------------- */
|
|
998
|
+
|
|
999
|
+
export type DeliveryChannel = "notification" | "autoreply" | "sheet" | "webhook";
|
|
1000
|
+
|
|
1001
|
+
export type DeliveryAttemptRow = {
|
|
1002
|
+
id: string;
|
|
1003
|
+
submission_id: string | null;
|
|
1004
|
+
form_key: string;
|
|
1005
|
+
channel: DeliveryChannel;
|
|
1006
|
+
status: string;
|
|
1007
|
+
target: string | null;
|
|
1008
|
+
error: string | null;
|
|
1009
|
+
attempt: number;
|
|
1010
|
+
duration_ms: number | null;
|
|
1011
|
+
meta: Record<string, string | number | boolean | null>;
|
|
1012
|
+
resolved_at: string | null;
|
|
1013
|
+
created_at: string;
|
|
1014
|
+
/** Small excerpt of the submission so staff can see what failed. */
|
|
1015
|
+
submission?: {
|
|
1016
|
+
id: string;
|
|
1017
|
+
locale: string;
|
|
1018
|
+
created_at: string;
|
|
1019
|
+
page_path: string | null;
|
|
1020
|
+
payload: Record<string, string>;
|
|
1021
|
+
} | null;
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
export type DeliveryOverview = {
|
|
1025
|
+
rows: DeliveryAttemptRow[];
|
|
1026
|
+
total: number;
|
|
1027
|
+
perForm: {
|
|
1028
|
+
form_key: string;
|
|
1029
|
+
name: string;
|
|
1030
|
+
open: number;
|
|
1031
|
+
byChannel: Record<string, number>;
|
|
1032
|
+
lastFailureAt: string | null;
|
|
1033
|
+
}[];
|
|
1034
|
+
totals: { open: number; resolved: number; last24h: number };
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* One shared place to (re)run a single delivery for a stored submission,
|
|
1039
|
+
* so retries behave exactly like the original attempt.
|
|
1040
|
+
*/
|
|
1041
|
+
export async function deliverChannelCore(
|
|
1042
|
+
admin: FormsDb,
|
|
1043
|
+
submissionId: string,
|
|
1044
|
+
channel: DeliveryChannel,
|
|
1045
|
+
siteUrl: string | null,
|
|
1046
|
+
toOverride?: string,
|
|
1047
|
+
attempt = 1,
|
|
1048
|
+
): Promise<{ ok: boolean; error?: string; target: string }> {
|
|
1049
|
+
const { data: sub, error } = await admin
|
|
1050
|
+
.from("form_submissions")
|
|
1051
|
+
.select("*")
|
|
1052
|
+
.eq("id", submissionId)
|
|
1053
|
+
.single();
|
|
1054
|
+
if (error) throw new Error(error.message);
|
|
1055
|
+
|
|
1056
|
+
const { data: form } = await admin
|
|
1057
|
+
.from("forms")
|
|
1058
|
+
.select("*")
|
|
1059
|
+
.eq("key", sub.form_key)
|
|
1060
|
+
.maybeSingle();
|
|
1061
|
+
if (!form) throw new Error(`The form "${sub.form_key}" no longer exists.`);
|
|
1062
|
+
|
|
1063
|
+
const defaults = await loadDefaults(admin);
|
|
1064
|
+
const eff = effectiveSettings(form, defaults);
|
|
1065
|
+
const fields = mergeFields(form.form_type, form.fields);
|
|
1066
|
+
const payload = (sub.payload ?? {}) as Record<string, string>;
|
|
1067
|
+
const startedAt = Date.now();
|
|
1068
|
+
const meta: RequestMeta = {
|
|
1069
|
+
ip: sub.ip ?? null,
|
|
1070
|
+
userAgent: sub.user_agent ?? null,
|
|
1071
|
+
referrer: null,
|
|
1072
|
+
siteUrl,
|
|
1073
|
+
};
|
|
1074
|
+
|
|
1075
|
+
let result: { ok: boolean; error?: string } = { ok: false, error: "Unknown channel" };
|
|
1076
|
+
let target = "";
|
|
1077
|
+
|
|
1078
|
+
if (channel === "notification") {
|
|
1079
|
+
target = toOverride?.trim() || resolveRecipients(eff, payload);
|
|
1080
|
+
if (!target) throw new Error("No notification recipients configured.");
|
|
1081
|
+
const tpl = notificationTemplate(eff, defaults, sub.locale);
|
|
1082
|
+
result = await notifySubmissionCore(admin, {
|
|
1083
|
+
...meta,
|
|
1084
|
+
to: target,
|
|
1085
|
+
formName: form.name,
|
|
1086
|
+
formKey: form.key,
|
|
1087
|
+
locale: sub.locale,
|
|
1088
|
+
pagePath: sub.page_path,
|
|
1089
|
+
fields,
|
|
1090
|
+
payload,
|
|
1091
|
+
submissionId: sub.id,
|
|
1092
|
+
subject: tpl.subject,
|
|
1093
|
+
body: tpl.body,
|
|
1094
|
+
fromName: eff.notifyFromName,
|
|
1095
|
+
fromEmail: eff.notifyFromEmail,
|
|
1096
|
+
replyTo: eff.notifyReplyTo,
|
|
1097
|
+
});
|
|
1098
|
+
if (result.ok) {
|
|
1099
|
+
await admin
|
|
1100
|
+
.from("form_submissions")
|
|
1101
|
+
.update({ notified_at: new Date().toISOString(), delivery_error: null })
|
|
1102
|
+
.eq("id", sub.id);
|
|
1103
|
+
}
|
|
1104
|
+
} else if (channel === "autoreply") {
|
|
1105
|
+
const subject = pickLocalized(
|
|
1106
|
+
eff.priv.autoreplySubject as Record<string, string> | undefined,
|
|
1107
|
+
defaults?.autoreply_subject as Record<string, string> | undefined,
|
|
1108
|
+
sub.locale,
|
|
1109
|
+
);
|
|
1110
|
+
const body = pickLocalized(
|
|
1111
|
+
eff.priv.autoreplyBody as Record<string, string> | undefined,
|
|
1112
|
+
defaults?.autoreply_body as Record<string, string> | undefined,
|
|
1113
|
+
sub.locale,
|
|
1114
|
+
);
|
|
1115
|
+
target = toOverride?.trim() || submitterEmail(fields, payload);
|
|
1116
|
+
if (!target) throw new Error("This submission has no email address.");
|
|
1117
|
+
if (!subject || !body) throw new Error("No autoreply subject or body configured.");
|
|
1118
|
+
result = await sendAutoreplyCore(admin, {
|
|
1119
|
+
...meta,
|
|
1120
|
+
to: target,
|
|
1121
|
+
subject,
|
|
1122
|
+
body,
|
|
1123
|
+
formKey: form.key,
|
|
1124
|
+
formName: form.name,
|
|
1125
|
+
payload,
|
|
1126
|
+
fields,
|
|
1127
|
+
locale: sub.locale,
|
|
1128
|
+
pagePath: sub.page_path,
|
|
1129
|
+
submissionId: sub.id,
|
|
1130
|
+
fromName: eff.notifyFromName,
|
|
1131
|
+
fromEmail: eff.notifyFromEmail,
|
|
1132
|
+
});
|
|
1133
|
+
if (result.ok) {
|
|
1134
|
+
await admin
|
|
1135
|
+
.from("form_submissions")
|
|
1136
|
+
.update({ autoreply_at: new Date().toISOString(), delivery_error: null })
|
|
1137
|
+
.eq("id", sub.id);
|
|
1138
|
+
}
|
|
1139
|
+
} else if (channel === "sheet") {
|
|
1140
|
+
if (!eff.sheetId) throw new Error(`No Google Sheet configured for "${form.key}".`);
|
|
1141
|
+
target = `${eff.sheetId} · ${eff.sheetTab}`;
|
|
1142
|
+
result = await appendToSheetCore({
|
|
1143
|
+
sheetId: eff.sheetId,
|
|
1144
|
+
tab: eff.sheetTab,
|
|
1145
|
+
values: sheetRow(sub.created_at, sub.form_key, sub.locale, fields, payload),
|
|
1146
|
+
});
|
|
1147
|
+
await admin
|
|
1148
|
+
.from("form_submissions")
|
|
1149
|
+
.update({
|
|
1150
|
+
sheet_synced_at: result.ok ? new Date().toISOString() : null,
|
|
1151
|
+
sheet_error: result.ok ? null : (result.error ?? "Unknown error"),
|
|
1152
|
+
})
|
|
1153
|
+
.eq("id", sub.id);
|
|
1154
|
+
} else if (channel === "webhook") {
|
|
1155
|
+
target = toOverride?.trim() || eff.webhookUrl;
|
|
1156
|
+
if (!target) throw new Error(`No webhook URL configured for "${form.key}".`);
|
|
1157
|
+
result = await postWebhookCore({
|
|
1158
|
+
url: target,
|
|
1159
|
+
secret: eff.webhookSecret,
|
|
1160
|
+
formKey: form.key,
|
|
1161
|
+
formName: form.name,
|
|
1162
|
+
submissionId: sub.id,
|
|
1163
|
+
locale: sub.locale,
|
|
1164
|
+
pagePath: sub.page_path,
|
|
1165
|
+
createdAt: sub.created_at,
|
|
1166
|
+
payload,
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
await logDelivery(admin, {
|
|
1171
|
+
submissionId: sub.id,
|
|
1172
|
+
formKey: sub.form_key,
|
|
1173
|
+
channel,
|
|
1174
|
+
ok: result.ok,
|
|
1175
|
+
target,
|
|
1176
|
+
error: result.error,
|
|
1177
|
+
attempt,
|
|
1178
|
+
durationMs: Date.now() - startedAt,
|
|
1179
|
+
meta: { retry: attempt > 1 },
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
return { ok: result.ok, error: result.error, target };
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/** Failed (and optionally resolved) deliveries, newest first, with rollup. */
|
|
1186
|
+
export async function listDeliveryAttemptsCore(
|
|
1187
|
+
context: FormsContext,
|
|
1188
|
+
data: {
|
|
1189
|
+
formKey?: string;
|
|
1190
|
+
channel?: DeliveryChannel;
|
|
1191
|
+
state: "open" | "resolved" | "all";
|
|
1192
|
+
limit: number;
|
|
1193
|
+
},
|
|
1194
|
+
): Promise<DeliveryOverview> {
|
|
1195
|
+
await assertStaff(context);
|
|
1196
|
+
|
|
1197
|
+
const base = () => {
|
|
1198
|
+
let q = context.supabase.from("form_delivery_attempts").select("*", { count: "exact" });
|
|
1199
|
+
if (data.formKey) q = q.eq("form_key", data.formKey);
|
|
1200
|
+
if (data.channel) q = q.eq("channel", data.channel);
|
|
1201
|
+
if (data.state === "open") q = q.eq("status", "failed").is("resolved_at", null);
|
|
1202
|
+
if (data.state === "resolved") q = q.not("resolved_at", "is", null);
|
|
1203
|
+
return q;
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
const {
|
|
1207
|
+
data: rows,
|
|
1208
|
+
error,
|
|
1209
|
+
count,
|
|
1210
|
+
} = await base().order("created_at", { ascending: false }).limit(data.limit);
|
|
1211
|
+
if (error) throw new Error(error.message);
|
|
1212
|
+
|
|
1213
|
+
// Attach a small excerpt of each failing submission for diagnostics.
|
|
1214
|
+
const ids = Array.from(
|
|
1215
|
+
new Set((rows ?? []).map((r: any) => r.submission_id).filter(Boolean)),
|
|
1216
|
+
) as string[];
|
|
1217
|
+
const subs = new Map<string, any>();
|
|
1218
|
+
if (ids.length) {
|
|
1219
|
+
const { data: srows } = await context.supabase
|
|
1220
|
+
.from("form_submissions")
|
|
1221
|
+
.select("id, locale, created_at, page_path, payload")
|
|
1222
|
+
.in("id", ids);
|
|
1223
|
+
for (const s of srows ?? []) subs.set(s.id, s);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// Rollup across every open failure, not just the current page.
|
|
1227
|
+
const { data: openRows } = await context.supabase
|
|
1228
|
+
.from("form_delivery_attempts")
|
|
1229
|
+
.select("form_key, channel, created_at")
|
|
1230
|
+
.eq("status", "failed")
|
|
1231
|
+
.is("resolved_at", null)
|
|
1232
|
+
.order("created_at", { ascending: false })
|
|
1233
|
+
.limit(2000);
|
|
1234
|
+
const { data: forms } = await context.supabase.from("forms").select("key, name");
|
|
1235
|
+
const names = new Map((forms ?? []).map((f: any) => [f.key, f.name as string]));
|
|
1236
|
+
|
|
1237
|
+
const perForm = new Map<
|
|
1238
|
+
string,
|
|
1239
|
+
{ open: number; byChannel: Record<string, number>; last: string | null }
|
|
1240
|
+
>();
|
|
1241
|
+
for (const r of openRows ?? []) {
|
|
1242
|
+
const e = perForm.get(r.form_key) ?? { open: 0, byChannel: {}, last: null };
|
|
1243
|
+
e.open += 1;
|
|
1244
|
+
e.byChannel[r.channel] = (e.byChannel[r.channel] ?? 0) + 1;
|
|
1245
|
+
if (!e.last) e.last = r.created_at;
|
|
1246
|
+
perForm.set(r.form_key, e);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
const since = new Date(Date.now() - 86_400_000).toISOString();
|
|
1250
|
+
const { count: last24h } = await context.supabase
|
|
1251
|
+
.from("form_delivery_attempts")
|
|
1252
|
+
.select("id", { count: "exact", head: true })
|
|
1253
|
+
.eq("status", "failed")
|
|
1254
|
+
.gte("created_at", since);
|
|
1255
|
+
const { count: resolved } = await context.supabase
|
|
1256
|
+
.from("form_delivery_attempts")
|
|
1257
|
+
.select("id", { count: "exact", head: true })
|
|
1258
|
+
.not("resolved_at", "is", null);
|
|
1259
|
+
|
|
1260
|
+
return {
|
|
1261
|
+
rows: (rows ?? []).map((r: any) => ({
|
|
1262
|
+
...r,
|
|
1263
|
+
meta: (r.meta ?? {}) as Record<string, string | number | boolean | null>,
|
|
1264
|
+
submission: r.submission_id ? (subs.get(r.submission_id) ?? null) : null,
|
|
1265
|
+
})) as DeliveryAttemptRow[],
|
|
1266
|
+
total: count ?? 0,
|
|
1267
|
+
perForm: Array.from(perForm.entries())
|
|
1268
|
+
.map(([form_key, v]) => ({
|
|
1269
|
+
form_key,
|
|
1270
|
+
name: names.get(form_key) ?? form_key,
|
|
1271
|
+
open: v.open,
|
|
1272
|
+
byChannel: v.byChannel,
|
|
1273
|
+
lastFailureAt: v.last,
|
|
1274
|
+
}))
|
|
1275
|
+
.sort((a, b) => b.open - a.open),
|
|
1276
|
+
totals: { open: (openRows ?? []).length, resolved: resolved ?? 0, last24h: last24h ?? 0 },
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/** Re-run the failed deliveries behind the given attempt rows. */
|
|
1281
|
+
export async function retryDeliveryAttemptsCore(
|
|
1282
|
+
context: FormsContext,
|
|
1283
|
+
admin: FormsDb,
|
|
1284
|
+
ids: string[],
|
|
1285
|
+
siteUrl: string | null,
|
|
1286
|
+
) {
|
|
1287
|
+
await assertStaff(context);
|
|
1288
|
+
const { data: attempts, error } = await context.supabase
|
|
1289
|
+
.from("form_delivery_attempts")
|
|
1290
|
+
.select("*")
|
|
1291
|
+
.in("id", ids);
|
|
1292
|
+
if (error) throw new Error(error.message);
|
|
1293
|
+
|
|
1294
|
+
let succeeded = 0;
|
|
1295
|
+
const failures: string[] = [];
|
|
1296
|
+
for (const a of attempts ?? []) {
|
|
1297
|
+
if (!a.submission_id) {
|
|
1298
|
+
failures.push(`${a.form_key}: the submission behind this attempt is gone.`);
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
try {
|
|
1302
|
+
const res = await deliverChannelCore(
|
|
1303
|
+
admin,
|
|
1304
|
+
a.submission_id,
|
|
1305
|
+
a.channel as DeliveryChannel,
|
|
1306
|
+
siteUrl,
|
|
1307
|
+
undefined,
|
|
1308
|
+
(a.attempt ?? 1) + 1,
|
|
1309
|
+
);
|
|
1310
|
+
if (res.ok) succeeded += 1;
|
|
1311
|
+
else failures.push(`${a.form_key}/${a.channel}: ${res.error ?? "failed"}`);
|
|
1312
|
+
} catch (e) {
|
|
1313
|
+
failures.push(`${a.form_key}/${a.channel}: ${e instanceof Error ? e.message : String(e)}`);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
return { retried: (attempts ?? []).length, succeeded, failures };
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/** Mark failures as handled without sending anything. */
|
|
1320
|
+
export async function resolveDeliveryAttemptsCore(context: FormsContext, ids: string[]) {
|
|
1321
|
+
await assertStaff(context);
|
|
1322
|
+
const { error } = await context.supabase
|
|
1323
|
+
.from("form_delivery_attempts")
|
|
1324
|
+
.update({ resolved_at: new Date().toISOString() })
|
|
1325
|
+
.in("id", ids);
|
|
1326
|
+
if (error) throw new Error(error.message);
|
|
1327
|
+
return { ok: true as const, resolved: ids.length };
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/** Fire a harmless ping at a form's webhook so staff can verify the endpoint. */
|
|
1331
|
+
export async function testWebhookCore(
|
|
1332
|
+
context: FormsContext,
|
|
1333
|
+
admin: FormsDb,
|
|
1334
|
+
data: { formKey: string; url?: string; secret?: string },
|
|
1335
|
+
) {
|
|
1336
|
+
await assertStaff(context);
|
|
1337
|
+
const { data: form } = await admin
|
|
1338
|
+
.from("forms")
|
|
1339
|
+
.select("*")
|
|
1340
|
+
.eq("key", data.formKey)
|
|
1341
|
+
.maybeSingle();
|
|
1342
|
+
if (!form) throw new Error("Form not found");
|
|
1343
|
+
const eff = effectiveSettings(form, await loadDefaults(admin));
|
|
1344
|
+
const url = data.url?.trim() || eff.webhookUrl;
|
|
1345
|
+
if (!url) throw new Error("No webhook URL configured for this form.");
|
|
1346
|
+
|
|
1347
|
+
const startedAt = Date.now();
|
|
1348
|
+
const res = await postWebhookCore({
|
|
1349
|
+
url,
|
|
1350
|
+
secret: data.secret ?? eff.webhookSecret,
|
|
1351
|
+
formKey: form.key,
|
|
1352
|
+
formName: form.name,
|
|
1353
|
+
submissionId: "00000000-0000-0000-0000-000000000000",
|
|
1354
|
+
locale: "sv",
|
|
1355
|
+
createdAt: new Date().toISOString(),
|
|
1356
|
+
payload: { test: "true" },
|
|
1357
|
+
});
|
|
1358
|
+
await logDelivery(admin, {
|
|
1359
|
+
formKey: form.key,
|
|
1360
|
+
channel: "webhook",
|
|
1361
|
+
ok: res.ok,
|
|
1362
|
+
target: url,
|
|
1363
|
+
error: res.error,
|
|
1364
|
+
durationMs: Date.now() - startedAt,
|
|
1365
|
+
meta: { test: true, status: res.status ?? null },
|
|
1366
|
+
});
|
|
1367
|
+
return { ok: res.ok, status: res.status ?? null, error: res.error ?? null };
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/* ------------------------------- versioning ------------------------------- */
|
|
1371
|
+
|
|
1372
|
+
export type FormVersionRow = {
|
|
1373
|
+
id: string;
|
|
1374
|
+
form_key: string;
|
|
1375
|
+
version_no: number;
|
|
1376
|
+
name: string;
|
|
1377
|
+
form_type: string;
|
|
1378
|
+
fields: FormFieldDef[];
|
|
1379
|
+
public_settings: FormPublicSettings;
|
|
1380
|
+
private_settings: FormPrivateSettings;
|
|
1381
|
+
note: string | null;
|
|
1382
|
+
created_at: string;
|
|
1383
|
+
created_by_email: string | null;
|
|
1384
|
+
};
|