@bygghammar/cms-platform 0.20.0 → 0.21.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/package.json +3 -2
- package/src/server/forms-core.ts +820 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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,
|
|
@@ -108,7 +108,8 @@
|
|
|
108
108
|
"./server/redirects-core": "./src/server/redirects-core.ts",
|
|
109
109
|
"./server/page-seo-core": "./src/server/page-seo-core.ts",
|
|
110
110
|
"./server/media-cleanup-core": "./src/server/media-cleanup-core.ts",
|
|
111
|
-
"./server/media-usage-core": "./src/server/media-usage-core.ts"
|
|
111
|
+
"./server/media-usage-core": "./src/server/media-usage-core.ts",
|
|
112
|
+
"./server/forms-core": "./src/server/forms-core.ts"
|
|
112
113
|
},
|
|
113
114
|
"dependencies": {
|
|
114
115
|
"fflate": "^0.8.3"
|
|
@@ -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
|
+
}
|