@bygghammar/cms-platform 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,820 @@
1
+ /**
2
+ * Host-agnostic forms server core.
3
+ *
4
+ * Every rule that governs form definitions — settings resolution, notification
5
+ * routing, snapshots, the audit trail and the definition CRUD/version flow —
6
+ * lives here so that every site behaves identically. Hosts supply a Supabase
7
+ * compatible client plus the acting user; nothing site-specific is referenced.
8
+ *
9
+ * Data-safety invariants preserved here:
10
+ * - submissions are never touched by definition changes
11
+ * - a field key that already has stored answers can never be removed
12
+ * - snapshots and audit entries never block the write they describe
13
+ */
14
+ import {
15
+ defaultFieldsFor,
16
+ mergeFields,
17
+ type FormDefaults,
18
+ type FormFieldDef,
19
+ type FormPrivateSettings,
20
+ type FormPublicSettings,
21
+ } from "../forms/field-sets";
22
+ import { EMAIL_RE, evaluateRule, normalizePolicyUrl } from "../forms/conditions";
23
+ import { diffForms, summarizeChanges, type FormAuditChange } from "../forms/form-audit";
24
+ import { actorEmail, assertStaff, type PlatformAuthContext } from "./guards";
25
+
26
+ /* eslint-disable @typescript-eslint/no-explicit-any */
27
+
28
+ export type FormsDb = any;
29
+
30
+ export type FormsContext = PlatformAuthContext & { supabase: FormsDb };
31
+
32
+ /* ------------------------------ pure helpers ------------------------------ */
33
+
34
+ export const pickText = (a: string | undefined | null, b: string | undefined | null) =>
35
+ (a ?? "").trim() || (b ?? "").trim();
36
+
37
+ export const pickLocalized = (
38
+ a: Record<string, string> | undefined,
39
+ b: Record<string, string> | undefined,
40
+ locale: string,
41
+ ) => {
42
+ const from = (m?: Record<string, string>) =>
43
+ (m?.[locale] ?? m?.["sv"] ?? m?.["en"] ?? "").toString().trim();
44
+ return from(a) || from(b);
45
+ };
46
+
47
+ /** Everything the submit path needs, with per-form overrides applied. */
48
+ export function effectiveSettings(
49
+ form: { public_settings: unknown; private_settings: unknown },
50
+ defaults: FormDefaults | null,
51
+ ) {
52
+ const pub = (form.public_settings ?? {}) as FormPublicSettings;
53
+ const priv = (form.private_settings ?? {}) as FormPrivateSettings;
54
+ return {
55
+ pub,
56
+ priv,
57
+ requireConsent: pub.requireConsent !== false,
58
+ honeypot: defaults?.spam_honeypot !== false,
59
+ minSeconds: priv.spamMinSeconds ?? defaults?.spam_min_seconds ?? 3,
60
+ maxPerHour: priv.spamMaxPerHour ?? defaults?.spam_max_per_hour ?? 0,
61
+ notifyEnabled: priv.notifyEnabled ?? defaults?.notify_enabled ?? true,
62
+ notifyTo: pickText(priv.notifyTo, defaults?.notify_to),
63
+ notifyFromName: pickText(priv.notifyFromName, defaults?.notify_from_name),
64
+ notifyFromEmail: pickText(priv.notifyFromEmail, defaults?.notify_from_email),
65
+ notifyReplyTo: pickText(priv.notifyReplyTo, defaults?.notify_reply_to),
66
+ notificationRules: (priv.notificationRules ?? []).filter((r) => r && r.enabled !== false),
67
+ autoreplyEnabled: priv.autoreplyEnabled ?? defaults?.autoreply_enabled ?? false,
68
+ sheetId: pickText(priv.sheetId, defaults?.sheet_id),
69
+ sheetTab: pickText(priv.sheetTab, defaults?.sheet_tab) || "Sheet1",
70
+ sheetLive: priv.sheetLiveAppend ?? defaults?.sheet_live_append ?? false,
71
+ savePartials: priv.savePartials ?? defaults?.save_partials ?? true,
72
+ retentionDays: defaults?.retention_days ?? 0,
73
+ webhookUrl: (priv.webhookUrl ?? "").trim(),
74
+ webhookSecret: (priv.webhookSecret ?? "").trim(),
75
+ webhookEnabled: priv.webhookEnabled !== false && !!(priv.webhookUrl ?? "").trim(),
76
+ };
77
+ }
78
+
79
+ export type EffectiveSettings = ReturnType<typeof effectiveSettings>;
80
+
81
+ /**
82
+ * Resolve who gets the admin notification for one submission.
83
+ * Conditional rules add recipients; an "exclusive" match replaces the
84
+ * standard recipient list entirely (routing instead of copying).
85
+ */
86
+ export function resolveRecipients(
87
+ eff: EffectiveSettings,
88
+ values: Record<string, string>,
89
+ ): string {
90
+ const matched = eff.notificationRules.filter((r) => evaluateRule(r.when, values));
91
+ const exclusive = matched.filter((r) => r.exclusive);
92
+ const list = exclusive.length
93
+ ? exclusive.map((r) => r.to)
94
+ : [eff.notifyTo, ...matched.map((r) => r.to)];
95
+ return list.filter(Boolean).join(",");
96
+ }
97
+
98
+ /** Localized notification subject/body with per-form override then defaults. */
99
+ export function notificationTemplate(
100
+ eff: EffectiveSettings,
101
+ defaults: FormDefaults | null,
102
+ locale: string,
103
+ ) {
104
+ return {
105
+ subject: pickLocalized(
106
+ eff.priv.notifySubject as Record<string, string> | undefined,
107
+ defaults?.notify_subject as Record<string, string> | undefined,
108
+ locale,
109
+ ),
110
+ body: pickLocalized(
111
+ eff.priv.notifyBody as Record<string, string> | undefined,
112
+ defaults?.notify_body as Record<string, string> | undefined,
113
+ locale,
114
+ ),
115
+ };
116
+ }
117
+
118
+ /** Best guess at the submitter's own address: typed email field, then any value. */
119
+ export function submitterEmail(
120
+ fields: FormFieldDef[],
121
+ payload: Record<string, string>,
122
+ ): string {
123
+ const emailField = fields.find((f) => f.enabled && f.type === "email");
124
+ const direct = emailField ? payload[emailField.key] : "";
125
+ if (direct) return direct;
126
+ return Object.values(payload).find((v) => EMAIL_RE.test(v)) ?? "";
127
+ }
128
+
129
+ /** One spreadsheet row for a submission, column order fixed by the field list. */
130
+ export function sheetRow(
131
+ createdAt: string,
132
+ formKey: string,
133
+ locale: string,
134
+ fields: FormFieldDef[],
135
+ payload: Record<string, string>,
136
+ ) {
137
+ return [
138
+ createdAt,
139
+ formKey,
140
+ locale,
141
+ ...fields.filter((f) => f.enabled).map((f) => payload[f.key] ?? ""),
142
+ ];
143
+ }
144
+
145
+ /** Collect every `formKey` referenced anywhere inside a block tree. */
146
+ export function collectFormKeys(value: unknown, out: Set<string>) {
147
+ if (!value || typeof value !== "object") return;
148
+ if (Array.isArray(value)) {
149
+ for (const v of value) collectFormKeys(v, out);
150
+ return;
151
+ }
152
+ const obj = value as Record<string, unknown>;
153
+ if (typeof obj["formKey"] === "string" && obj["formKey"]) out.add(obj["formKey"] as string);
154
+ for (const v of Object.values(obj)) collectFormKeys(v, out);
155
+ }
156
+
157
+ /** Field keys that already carry stored answers, from a set of payloads. */
158
+ export function usedFieldKeys(payloads: Array<Record<string, unknown> | null | undefined>) {
159
+ const used = new Set<string>();
160
+ for (const payload of payloads) {
161
+ for (const [k, v] of Object.entries((payload ?? {}) as Record<string, unknown>)) {
162
+ if (v !== "" && v != null) used.add(k);
163
+ }
164
+ }
165
+ return used;
166
+ }
167
+
168
+ /* ------------------------- audit + delivery logging ------------------------ */
169
+
170
+ /**
171
+ * Append one entry to the shared audit trail. Auditing must never block the
172
+ * write it describes, so failures are logged and swallowed.
173
+ */
174
+ export async function logFormAuditCore(
175
+ context: FormsContext,
176
+ action: string,
177
+ entityId: string | null,
178
+ meta: Record<string, unknown>,
179
+ ) {
180
+ try {
181
+ await context.supabase.from("audit_logs").insert({
182
+ actor_id: context.userId,
183
+ actor_email: actorEmail(context),
184
+ action,
185
+ entity: "forms",
186
+ entity_id: entityId,
187
+ meta: meta as never,
188
+ });
189
+ } catch (e) {
190
+ console.error("[forms] audit", e);
191
+ }
192
+ }
193
+
194
+ export type DeliveryLogEntry = {
195
+ submissionId?: string | null;
196
+ formKey: string;
197
+ channel: "notification" | "autoreply" | "sheet" | "webhook";
198
+ ok: boolean;
199
+ target?: string | null;
200
+ error?: string | null;
201
+ attempt?: number;
202
+ durationMs?: number | null;
203
+ meta?: Record<string, unknown>;
204
+ };
205
+
206
+ /**
207
+ * Append-only diagnostics for one delivery attempt. Failures here must never
208
+ * affect the submission itself, so everything is swallowed.
209
+ */
210
+ export async function logDeliveryCore(db: FormsDb, entry: DeliveryLogEntry) {
211
+ try {
212
+ await db.from("form_delivery_attempts").insert({
213
+ submission_id: entry.submissionId ?? null,
214
+ form_key: entry.formKey,
215
+ channel: entry.channel,
216
+ status: entry.ok ? "ok" : "failed",
217
+ target: (entry.target ?? "").slice(0, 400) || null,
218
+ error: entry.error ? entry.error.slice(0, 1000) : null,
219
+ attempt: entry.attempt ?? 1,
220
+ duration_ms: entry.durationMs ?? null,
221
+ meta: (entry.meta ?? {}) as never,
222
+ resolved_at: entry.ok ? new Date().toISOString() : null,
223
+ } as never);
224
+ // A successful attempt closes every earlier failure on the same channel.
225
+ if (entry.ok && entry.submissionId) {
226
+ await db
227
+ .from("form_delivery_attempts")
228
+ .update({ resolved_at: new Date().toISOString() } as never)
229
+ .eq("submission_id", entry.submissionId)
230
+ .eq("channel", entry.channel)
231
+ .eq("status", "failed")
232
+ .is("resolved_at", null);
233
+ }
234
+ } catch (e) {
235
+ console.error("[forms] logDelivery", e);
236
+ }
237
+ }
238
+
239
+ /* ------------------------------- definitions ------------------------------ */
240
+
241
+ export type FormRowCore = {
242
+ id: string;
243
+ key: string;
244
+ name: string;
245
+ form_type: string;
246
+ is_active: boolean;
247
+ fields: FormFieldDef[];
248
+ public_settings: FormPublicSettings;
249
+ private_settings: FormPrivateSettings;
250
+ updated_at: string;
251
+ submissions: number;
252
+ newSubmissions: number;
253
+ lastSubmissionAt: string | null;
254
+ /** Field keys that already have stored answers — never deletable. */
255
+ usedFieldKeys: string[];
256
+ };
257
+
258
+ export async function listFormsCore(context: FormsContext): Promise<FormRowCore[]> {
259
+ await assertStaff(context);
260
+ const { data, error } = await context.supabase
261
+ .from("forms")
262
+ .select("*")
263
+ .is("deleted_at", null)
264
+ .order("name", { ascending: true });
265
+ if (error) throw new Error(error.message);
266
+
267
+ const { data: subs } = await context.supabase
268
+ .from("form_submissions")
269
+ .select("form_key, status, created_at, payload")
270
+ .order("created_at", { ascending: false })
271
+ .limit(5000);
272
+
273
+ const counts = new Map<string, number>();
274
+ const news = new Map<string, number>();
275
+ const last = new Map<string, string>();
276
+ const used = new Map<string, Set<string>>();
277
+ for (const s of subs ?? []) {
278
+ counts.set(s.form_key, (counts.get(s.form_key) ?? 0) + 1);
279
+ if (s.status === "new") news.set(s.form_key, (news.get(s.form_key) ?? 0) + 1);
280
+ if (!last.has(s.form_key)) last.set(s.form_key, s.created_at);
281
+ const set = used.get(s.form_key) ?? new Set<string>();
282
+ for (const k of usedFieldKeys([s.payload as Record<string, unknown>])) set.add(k);
283
+ used.set(s.form_key, set);
284
+ }
285
+
286
+ return (data ?? []).map((r: any) => ({
287
+ id: r.id,
288
+ key: r.key,
289
+ name: r.name,
290
+ form_type: r.form_type,
291
+ is_active: r.is_active,
292
+ fields: mergeFields(r.form_type, r.fields),
293
+ public_settings: (r.public_settings ?? {}) as FormPublicSettings,
294
+ private_settings: (r.private_settings ?? {}) as FormPrivateSettings,
295
+ updated_at: r.updated_at,
296
+ submissions: counts.get(r.key) ?? 0,
297
+ newSubmissions: news.get(r.key) ?? 0,
298
+ lastSubmissionAt: last.get(r.key) ?? null,
299
+ usedFieldKeys: Array.from(used.get(r.key) ?? []),
300
+ }));
301
+ }
302
+
303
+ /**
304
+ * Store an immutable snapshot of a form definition. Snapshots are the
305
+ * rollback surface: they never contain submissions, only the definition.
306
+ */
307
+ export async function snapshotFormCore(
308
+ context: FormsContext,
309
+ form: {
310
+ id?: string;
311
+ key: string;
312
+ name: string;
313
+ form_type: string;
314
+ fields: unknown;
315
+ public_settings: unknown;
316
+ private_settings: unknown;
317
+ },
318
+ note: string,
319
+ ) {
320
+ try {
321
+ const { data: last } = await context.supabase
322
+ .from("form_versions")
323
+ .select("version_no, fields, public_settings, private_settings, name")
324
+ .eq("form_key", form.key)
325
+ .order("version_no", { ascending: false })
326
+ .limit(1)
327
+ .maybeSingle();
328
+
329
+ // Skip no-op saves so history stays readable.
330
+ if (last) {
331
+ const same =
332
+ JSON.stringify(last.fields) === JSON.stringify(form.fields) &&
333
+ JSON.stringify(last.public_settings) === JSON.stringify(form.public_settings) &&
334
+ JSON.stringify(last.private_settings) === JSON.stringify(form.private_settings) &&
335
+ last.name === form.name;
336
+ if (same) return;
337
+ }
338
+
339
+ await context.supabase.from("form_versions").insert({
340
+ form_id: form.id ?? null,
341
+ form_key: form.key,
342
+ version_no: (last?.version_no ?? 0) + 1,
343
+ name: form.name,
344
+ form_type: form.form_type,
345
+ fields: form.fields as never,
346
+ public_settings: form.public_settings as never,
347
+ private_settings: form.private_settings as never,
348
+ note,
349
+ created_by: context.userId,
350
+ created_by_email: actorEmail(context),
351
+ });
352
+ } catch (e) {
353
+ // History must never block saving the form itself.
354
+ console.error("[forms] snapshotForm", e);
355
+ }
356
+ }
357
+
358
+ export type SaveFormInput = {
359
+ id?: string;
360
+ key: string;
361
+ name: string;
362
+ form_type: string;
363
+ is_active: boolean;
364
+ fields: Array<Record<string, unknown>>;
365
+ public_settings: Record<string, unknown>;
366
+ private_settings: Record<string, unknown>;
367
+ };
368
+
369
+ export async function saveFormCore(
370
+ context: FormsContext,
371
+ data: SaveFormInput,
372
+ ): Promise<{ id: string }> {
373
+ await assertStaff(context);
374
+
375
+ // Never allow a field key that already has answers to disappear.
376
+ let before: Record<string, unknown> | null = null;
377
+ if (data.id) {
378
+ const { data: existing } = await context.supabase
379
+ .from("forms")
380
+ .select("*")
381
+ .eq("id", data.id)
382
+ .maybeSingle();
383
+ before = (existing as Record<string, unknown> | null) ?? null;
384
+ if (existing?.key) {
385
+ const { data: subs } = await context.supabase
386
+ .from("form_submissions")
387
+ .select("payload")
388
+ .eq("form_key", existing.key)
389
+ .limit(2000);
390
+ const used = usedFieldKeys((subs ?? []).map((s: any) => s.payload));
391
+ const kept = new Set(data.fields.map((f) => String((f as { key?: unknown }).key ?? "")));
392
+ const dropped = Array.from(used).filter((k) => !kept.has(k));
393
+ if (dropped.length) {
394
+ throw new Error(
395
+ `These fields already have submissions and cannot be removed: ${dropped.join(", ")}. Disable them instead.`,
396
+ );
397
+ }
398
+ }
399
+ }
400
+
401
+ const row = {
402
+ key: data.key,
403
+ name: data.name,
404
+ form_type: data.form_type,
405
+ is_active: data.is_active,
406
+ fields: data.fields as never,
407
+ public_settings: data.public_settings as never,
408
+ private_settings: data.private_settings as never,
409
+ };
410
+ const q = data.id
411
+ ? context.supabase.from("forms").update(row).eq("id", data.id).select("*").single()
412
+ : context.supabase.from("forms").insert(row).select("*").single();
413
+ const { data: saved, error } = await q;
414
+ if (error) throw new Error(error.message);
415
+
416
+ await snapshotFormCore(context, saved, data.id ? "Saved" : "Created");
417
+ const changes = diffForms(before as never, {
418
+ key: data.key,
419
+ name: data.name,
420
+ form_type: data.form_type,
421
+ is_active: data.is_active,
422
+ fields: data.fields,
423
+ public_settings: data.public_settings,
424
+ private_settings: data.private_settings,
425
+ } as never);
426
+ await logFormAuditCore(context, data.id ? "form.update" : "form.create", saved.id as string, {
427
+ key: data.key,
428
+ name: data.name,
429
+ changes,
430
+ summary: summarizeChanges(changes),
431
+ fieldKeys: changes.filter((c) => c.scope === "field" && c.key).map((c) => c.key),
432
+ });
433
+ return { id: saved.id as string };
434
+ }
435
+
436
+ export type FormUsageCore = { pages: string[]; globalSections: string[] };
437
+
438
+ /** Where a form is referenced on the site (pages + global sections). */
439
+ export async function getFormUsageCore(
440
+ context: FormsContext,
441
+ key: string,
442
+ ): Promise<FormUsageCore> {
443
+ await assertStaff(context);
444
+ const pages: string[] = [];
445
+ const globalSections: string[] = [];
446
+
447
+ const { data: pageRows } = await context.supabase
448
+ .from("pages")
449
+ .select("name, key, current_version_id")
450
+ .is("deleted_at", null);
451
+ const versionIds = (pageRows ?? [])
452
+ .map((p: any) => p.current_version_id)
453
+ .filter(Boolean) as string[];
454
+ if (versionIds.length) {
455
+ const { data: versions } = await context.supabase
456
+ .from("page_versions")
457
+ .select("id, blocks")
458
+ .in("id", versionIds);
459
+ const byId = new Map((versions ?? []).map((v: any) => [v.id, v.blocks]));
460
+ for (const p of pageRows ?? []) {
461
+ const keys = new Set<string>();
462
+ collectFormKeys(byId.get(p.current_version_id), keys);
463
+ if (keys.has(key)) pages.push(p.name ?? p.key);
464
+ }
465
+ }
466
+
467
+ const { data: sections } = await context.supabase
468
+ .from("global_sections")
469
+ .select("name, key, current_version_id");
470
+ const gIds = (sections ?? []).map((s: any) => s.current_version_id).filter(Boolean) as string[];
471
+ if (gIds.length) {
472
+ const { data: gVersions } = await context.supabase
473
+ .from("global_section_versions")
474
+ .select("id, blocks")
475
+ .in("id", gIds);
476
+ const byId = new Map((gVersions ?? []).map((v: any) => [v.id, v.blocks]));
477
+ for (const s of sections ?? []) {
478
+ const keys = new Set<string>();
479
+ collectFormKeys(byId.get(s.current_version_id), keys);
480
+ if (keys.has(key)) globalSections.push(s.name ?? s.key);
481
+ }
482
+ }
483
+
484
+ return { pages, globalSections };
485
+ }
486
+
487
+ /** Soft delete: moves the form to the bin. Submissions are never touched. */
488
+ export async function deleteFormCore(context: FormsContext, id: string) {
489
+ await assertStaff(context);
490
+ const email = actorEmail(context);
491
+ const { data: form } = await context.supabase
492
+ .from("forms")
493
+ .select("key, name")
494
+ .eq("id", id)
495
+ .maybeSingle();
496
+ const { error } = await context.supabase
497
+ .from("forms")
498
+ .update({
499
+ deleted_at: new Date().toISOString(),
500
+ deleted_by: context.userId,
501
+ deleted_by_email: email,
502
+ is_active: false,
503
+ })
504
+ .eq("id", id)
505
+ .is("deleted_at", null);
506
+ if (error) throw new Error(error.message);
507
+ await logFormAuditCore(context, "form.soft_delete", id, {
508
+ key: form?.key ?? null,
509
+ name: form?.name ?? null,
510
+ });
511
+ return { ok: true as const };
512
+ }
513
+
514
+ export type DeletedFormRowCore = {
515
+ id: string;
516
+ key: string;
517
+ name: string;
518
+ form_type: string;
519
+ deleted_at: string;
520
+ deleted_by_email: string | null;
521
+ };
522
+
523
+ export async function listDeletedFormsCore(
524
+ context: FormsContext,
525
+ ): Promise<{ rows: DeletedFormRowCore[]; canPurge: boolean }> {
526
+ await assertStaff(context);
527
+ const { data, error } = await context.supabase
528
+ .from("forms")
529
+ .select("id, key, name, form_type, deleted_at, deleted_by_email")
530
+ .not("deleted_at", "is", null)
531
+ .order("deleted_at", { ascending: false });
532
+ if (error) throw new Error(error.message);
533
+ const { data: isSuper } = await context.supabase.rpc("has_role", {
534
+ _user_id: context.userId,
535
+ _role: "superadmin",
536
+ });
537
+ return { rows: (data ?? []) as DeletedFormRowCore[], canPurge: !!isSuper };
538
+ }
539
+
540
+ export async function restoreFormCore(context: FormsContext, id: string) {
541
+ await assertStaff(context);
542
+ const { data: form } = await context.supabase
543
+ .from("forms")
544
+ .select("key, name")
545
+ .eq("id", id)
546
+ .maybeSingle();
547
+ const { error } = await context.supabase
548
+ .from("forms")
549
+ .update({ deleted_at: null, deleted_by: null, deleted_by_email: null })
550
+ .eq("id", id);
551
+ if (error) throw new Error(error.message);
552
+ await logFormAuditCore(context, "form.restore", id, {
553
+ key: form?.key ?? null,
554
+ name: form?.name ?? null,
555
+ });
556
+ return { ok: true as const };
557
+ }
558
+
559
+ /** Permanent delete from the bin — superadmin only. Submissions are kept. */
560
+ export async function purgeFormCore(context: FormsContext, id: string) {
561
+ await assertStaff(context);
562
+ const { data: isSuper, error: roleErr } = await context.supabase.rpc("has_role", {
563
+ _user_id: context.userId,
564
+ _role: "superadmin",
565
+ });
566
+ if (roleErr) throw new Error(roleErr.message);
567
+ if (!isSuper) throw new Error("Only a superadmin can permanently delete forms");
568
+ const { data: form } = await context.supabase
569
+ .from("forms")
570
+ .select("key, name, deleted_at")
571
+ .eq("id", id)
572
+ .maybeSingle();
573
+ if (!form?.deleted_at) throw new Error("Move the form to the bin first");
574
+ // Submissions keep their form_key; the FK is ON DELETE SET NULL so
575
+ // customer data is never removed with the form definition.
576
+ const { error } = await context.supabase.from("forms").delete().eq("id", id);
577
+ if (error) throw new Error(error.message);
578
+ await logFormAuditCore(context, "form.purge", id, { key: form.key, name: form.name });
579
+ return { ok: true as const };
580
+ }
581
+
582
+ export async function createFormFromTypeCore(
583
+ context: FormsContext,
584
+ data: { key: string; name: string; form_type: string },
585
+ ): Promise<{ id: string }> {
586
+ await assertStaff(context);
587
+ const { data: saved, error } = await context.supabase
588
+ .from("forms")
589
+ .insert({
590
+ key: data.key,
591
+ name: data.name,
592
+ form_type: data.form_type,
593
+ fields: defaultFieldsFor(data.form_type) as never,
594
+ public_settings: {} as never,
595
+ private_settings: {} as never,
596
+ })
597
+ .select("id")
598
+ .single();
599
+ if (error) throw new Error(error.message);
600
+ await logFormAuditCore(context, "form.create", saved.id as string, {
601
+ key: data.key,
602
+ name: data.name,
603
+ summary: `Created ${data.form_type} form “${data.name}”`,
604
+ });
605
+ return { id: saved.id as string };
606
+ }
607
+
608
+ /** Copy a form definition (settings + fields) under a new key. */
609
+ export async function duplicateFormCore(
610
+ context: FormsContext,
611
+ data: { id: string; key: string; name: string },
612
+ ): Promise<{ id: string; key: string }> {
613
+ await assertStaff(context);
614
+ const { data: src, error: e1 } = await context.supabase
615
+ .from("forms")
616
+ .select("*")
617
+ .eq("id", data.id)
618
+ .single();
619
+ if (e1) throw new Error(e1.message);
620
+ const { data: saved, error } = await context.supabase
621
+ .from("forms")
622
+ .insert({
623
+ key: data.key,
624
+ name: data.name,
625
+ form_type: src.form_type,
626
+ is_active: false,
627
+ fields: src.fields,
628
+ public_settings: src.public_settings,
629
+ private_settings: src.private_settings,
630
+ })
631
+ .select("id")
632
+ .single();
633
+ if (error) throw new Error(error.message);
634
+ await logFormAuditCore(context, "form.duplicate", saved.id as string, {
635
+ key: data.key,
636
+ name: data.name,
637
+ from: src.key,
638
+ summary: `Duplicated “${src.name}” (${src.key}) as “${data.name}” (${data.key})`,
639
+ });
640
+ return { id: saved.id as string, key: data.key };
641
+ }
642
+
643
+ /* --------------------------------- defaults ------------------------------- */
644
+
645
+ export async function getFormDefaultsCore(context: FormsContext): Promise<FormDefaults> {
646
+ await assertStaff(context);
647
+ const { data, error } = await context.supabase
648
+ .from("form_settings")
649
+ .select("*")
650
+ .eq("id", true)
651
+ .maybeSingle();
652
+ if (error) throw new Error(error.message);
653
+ return data as unknown as FormDefaults;
654
+ }
655
+
656
+ /** Read the site-wide defaults with no guard — used by the public submit path. */
657
+ export async function loadFormDefaultsCore(db: FormsDb): Promise<FormDefaults | null> {
658
+ const { data } = await db.from("form_settings").select("*").eq("id", true).maybeSingle();
659
+ return (data as unknown as FormDefaults) ?? null;
660
+ }
661
+
662
+ export async function saveFormDefaultsCore(
663
+ context: FormsContext,
664
+ data: Record<string, unknown>,
665
+ ) {
666
+ await assertStaff(context);
667
+ const patch = { ...data };
668
+ if (typeof patch["privacy_policy_url"] === "string") {
669
+ patch["privacy_policy_url"] = normalizePolicyUrl(patch["privacy_policy_url"] as string);
670
+ }
671
+ const { data: before } = await context.supabase
672
+ .from("form_settings")
673
+ .select("*")
674
+ .eq("id", true)
675
+ .maybeSingle();
676
+ const { error } = await context.supabase
677
+ .from("form_settings")
678
+ .update(patch as never)
679
+ .eq("id", true);
680
+ if (error) throw new Error(error.message);
681
+ const prev = (before ?? {}) as Record<string, unknown>;
682
+ const changes: FormAuditChange[] = Object.keys(patch)
683
+ .filter((k) => JSON.stringify(prev[k] ?? null) !== JSON.stringify(patch[k] ?? null))
684
+ .map((k) => ({
685
+ scope: "settings" as const,
686
+ label: k,
687
+ from: JSON.stringify(prev[k] ?? null).slice(0, 160),
688
+ to: JSON.stringify(patch[k] ?? null).slice(0, 160),
689
+ }));
690
+ if (changes.length) {
691
+ await logFormAuditCore(context, "form.defaults.update", null, {
692
+ changes,
693
+ summary: summarizeChanges(changes),
694
+ });
695
+ }
696
+ return { ok: true as const };
697
+ }
698
+
699
+ /* --------------------------------- activity ------------------------------- */
700
+
701
+ export type FormActivityRowCore = {
702
+ id: string;
703
+ action: string;
704
+ actor_email: string | null;
705
+ actor_id: string | null;
706
+ entity_id: string | null;
707
+ meta: any;
708
+ created_at: string;
709
+ };
710
+
711
+ /**
712
+ * Activity trail for forms. Without a key it returns every form-related event,
713
+ * which powers the module-level activity view.
714
+ */
715
+ export async function listFormActivityCore(
716
+ context: FormsContext,
717
+ data: { formKey?: string; limit: number },
718
+ ): Promise<FormActivityRowCore[]> {
719
+ await assertStaff(context);
720
+ let q = context.supabase
721
+ .from("audit_logs")
722
+ .select("id, action, actor_email, actor_id, entity_id, meta, created_at")
723
+ .like("action", "form.%")
724
+ .order("created_at", { ascending: false })
725
+ .limit(data.limit);
726
+ if (data.formKey) q = q.eq("meta->>key", data.formKey);
727
+ const { data: rows, error } = await q;
728
+ if (error) throw new Error(error.message);
729
+ return (rows ?? []) as FormActivityRowCore[];
730
+ }
731
+
732
+ /* --------------------------------- versions ------------------------------- */
733
+
734
+ export async function listFormVersionsCore(
735
+ context: FormsContext,
736
+ data: { formKey: string; limit: number },
737
+ ) {
738
+ await assertStaff(context);
739
+ const { data: rows, error } = await context.supabase
740
+ .from("form_versions")
741
+ .select("*")
742
+ .eq("form_key", data.formKey)
743
+ .order("version_no", { ascending: false })
744
+ .limit(data.limit);
745
+ if (error) throw new Error(error.message);
746
+ return (rows ?? []).map((r: any) => ({
747
+ ...r,
748
+ fields: mergeFields(r.form_type, r.fields),
749
+ public_settings: (r.public_settings ?? {}) as FormPublicSettings,
750
+ private_settings: (r.private_settings ?? {}) as FormPrivateSettings,
751
+ }));
752
+ }
753
+
754
+ /**
755
+ * Roll the live form back to an earlier snapshot. The current state is
756
+ * snapshotted first, so a rollback can itself always be undone, and field
757
+ * keys that already have answers are never dropped.
758
+ */
759
+ export async function restoreFormVersionCore(
760
+ context: FormsContext,
761
+ versionId: string,
762
+ ): Promise<{ ok: true; carriedFields: string[] }> {
763
+ await assertStaff(context);
764
+ const { data: v, error } = await context.supabase
765
+ .from("form_versions")
766
+ .select("*")
767
+ .eq("id", versionId)
768
+ .single();
769
+ if (error) throw new Error(error.message);
770
+
771
+ const { data: form, error: e2 } = await context.supabase
772
+ .from("forms")
773
+ .select("*")
774
+ .eq("key", v.form_key)
775
+ .maybeSingle();
776
+ if (e2) throw new Error(e2.message);
777
+ if (!form) throw new Error("This form no longer exists.");
778
+
779
+ // Data safety: keep any field key that already has stored answers.
780
+ const { data: subs } = await context.supabase
781
+ .from("form_submissions")
782
+ .select("payload")
783
+ .eq("form_key", v.form_key)
784
+ .limit(2000);
785
+ const used = usedFieldKeys((subs ?? []).map((s: any) => s.payload));
786
+
787
+ const restored = mergeFields(v.form_type, v.fields);
788
+ const current = mergeFields(form.form_type, form.fields);
789
+ const keys = new Set(restored.map((f) => f.key));
790
+ const carried = current
791
+ .filter((f) => used.has(f.key) && !keys.has(f.key))
792
+ .map((f) => ({ ...f, enabled: false }));
793
+
794
+ await snapshotFormCore(context, form, `Auto-saved before rollback to v${v.version_no}`);
795
+
796
+ const { error: e3 } = await context.supabase
797
+ .from("forms")
798
+ .update({
799
+ name: v.name,
800
+ fields: [...restored, ...carried] as never,
801
+ public_settings: v.public_settings,
802
+ private_settings: v.private_settings,
803
+ })
804
+ .eq("id", form.id);
805
+ if (e3) throw new Error(e3.message);
806
+
807
+ await snapshotFormCore(
808
+ context,
809
+ {
810
+ ...form,
811
+ name: v.name,
812
+ fields: [...restored, ...carried],
813
+ public_settings: v.public_settings,
814
+ private_settings: v.private_settings,
815
+ },
816
+ `Rolled back to v${v.version_no}`,
817
+ );
818
+
819
+ return { ok: true as const, carriedFields: carried.map((f) => f.key) };
820
+ }