@bygghammar/cms-platform 0.19.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.19.2",
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,
@@ -107,7 +107,9 @@
107
107
  "./server/guards": "./src/server/guards.ts",
108
108
  "./server/redirects-core": "./src/server/redirects-core.ts",
109
109
  "./server/page-seo-core": "./src/server/page-seo-core.ts",
110
- "./server/media-cleanup-core": "./src/server/media-cleanup-core.ts"
110
+ "./server/media-cleanup-core": "./src/server/media-cleanup-core.ts",
111
+ "./server/media-usage-core": "./src/server/media-usage-core.ts",
112
+ "./server/forms-core": "./src/server/forms-core.ts"
111
113
  },
112
114
  "dependencies": {
113
115
  "fflate": "^0.8.3"
@@ -180,4 +182,4 @@
180
182
  "optional": true
181
183
  }
182
184
  }
183
- }
185
+ }
@@ -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
+ }
@@ -0,0 +1,478 @@
1
+ /**
2
+ * Host-agnostic media usage scanning, external-media import and reference
3
+ * rewriting.
4
+ *
5
+ * The scan walks every CMS-owned JSON column, matches the URLs it finds against
6
+ * the media library listing, and reports both library usage and importable
7
+ * external media. The host injects its clients, its storage provider and the
8
+ * facts only a site knows: the backoffice route prefix and the public path of a
9
+ * page key.
10
+ */
11
+ import { variantSiblings } from "../media/srcset";
12
+ import { cleanFileName, splitExt, uniqueFileName } from "../media/filename";
13
+ import type { MediaImportResult, MediaUsage, MediaUsageReport } from "../media/usage-types";
14
+
15
+ /* eslint-disable @typescript-eslint/no-explicit-any */
16
+
17
+ /** Storage surface the usage tools need — satisfied by `getMediaStorage()`. */
18
+ export type UsageStorage = {
19
+ list: () => Promise<Array<{ name: string; size?: number }>>;
20
+ download: (path: string) => Promise<Uint8Array>;
21
+ upload: (
22
+ path: string,
23
+ body: Uint8Array,
24
+ contentType: string,
25
+ options?: { upsert?: boolean },
26
+ ) => Promise<unknown>;
27
+ publicUrl: (path: string) => string;
28
+ };
29
+
30
+ export type MediaUsageHost = {
31
+ /** Reader client; RLS applies as the signed-in user. */
32
+ db: any;
33
+ storage: UsageStorage;
34
+ /** Backoffice route prefix, e.g. `/kontoret`. */
35
+ backofficePrefix?: string;
36
+ /** Public URL path for a CMS page key, when the site has one. */
37
+ pagePublicPath?: (pageKey: string) => string | undefined;
38
+ };
39
+
40
+ export type MediaImportHost = MediaUsageHost & {
41
+ /** Privileged client used to rewrite references across CMS content. */
42
+ admin: any;
43
+ /** User-Agent sent when fetching remote media. */
44
+ userAgent?: string;
45
+ };
46
+
47
+ const URL_RE = /https?:\/\/[^\s"'<>)]+/gi;
48
+ const MEDIA_EXT_RE =
49
+ /\.(png|jpe?g|gif|webp|avif|svg|mp4|webm|mov|m4v|mp3|wav|ogg|pdf)(\?[^\s"'<>)]*)?$/i;
50
+
51
+ function walk(value: unknown, onString: (s: string, path: string) => void, path = "") {
52
+ if (!value) return;
53
+ if (typeof value === "string") {
54
+ if (
55
+ value.length > 4 &&
56
+ (value.startsWith("http") || value.startsWith("/") || MEDIA_EXT_RE.test(value))
57
+ ) {
58
+ onString(value, path);
59
+ }
60
+ return;
61
+ }
62
+ if (Array.isArray(value)) {
63
+ value.forEach((v, i) => walk(v, onString, `${path}[${i}]`));
64
+ return;
65
+ }
66
+ if (typeof value === "object") {
67
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
68
+ walk(v, onString, path ? `${path}.${k}` : k);
69
+ }
70
+ }
71
+ }
72
+
73
+ function extractUrlsFromString(s: string): string[] {
74
+ if (s.startsWith("/") && !s.startsWith("//")) return [s];
75
+ const matches = s.match(URL_RE);
76
+ return matches ?? (MEDIA_EXT_RE.test(s) ? [s] : []);
77
+ }
78
+
79
+ function safeDecodeURIComponent(value: string) {
80
+ try {
81
+ return decodeURIComponent(value);
82
+ } catch {
83
+ return value;
84
+ }
85
+ }
86
+
87
+ function normalizeUrlPath(rawUrl: string, form: "NFC" | "NFD") {
88
+ try {
89
+ const url = new URL(rawUrl);
90
+ const pathname = url.pathname
91
+ .split("/")
92
+ .map((part) => encodeURIComponent(safeDecodeURIComponent(part).normalize(form)))
93
+ .join("/");
94
+ return `${url.origin}${pathname}${url.search}${url.hash}`;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ function encodeUrlPath(rawUrl: string) {
101
+ try {
102
+ const url = new URL(rawUrl);
103
+ const pathname = url.pathname
104
+ .split("/")
105
+ .map((part) => encodeURIComponent(safeDecodeURIComponent(part)))
106
+ .join("/");
107
+ return `${url.origin}${pathname}${url.search}${url.hash}`;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /** WordPress exports link resized copies; the original is the same name without `-WxH`. */
114
+ function removeWordPressSizeSuffix(rawUrl: string) {
115
+ try {
116
+ const url = new URL(rawUrl);
117
+ const decodedPath = url.pathname
118
+ .split("/")
119
+ .map((part) => safeDecodeURIComponent(part))
120
+ .join("/");
121
+ const originalPath = decodedPath.replace(/-\d+x\d+(\.[a-z0-9]+)$/i, "$1");
122
+ if (originalPath === decodedPath) return null;
123
+ const encodedPath = originalPath
124
+ .split("/")
125
+ .map((part) => encodeURIComponent(part))
126
+ .join("/");
127
+ return `${url.origin}${encodedPath}${url.search}${url.hash}`;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /** Every spelling of a remote URL worth trying: encoding, unicode form, original size. */
134
+ export function remoteUrlCandidates(rawUrl: string): string[] {
135
+ const candidates = new Set<string>();
136
+ const add = (candidate: string | null) => {
137
+ if (candidate) candidates.add(candidate);
138
+ };
139
+
140
+ const trimmed = rawUrl.trim();
141
+ add(trimmed);
142
+ add(encodeUrlPath(trimmed));
143
+ add(normalizeUrlPath(trimmed, "NFD"));
144
+ add(normalizeUrlPath(trimmed, "NFC"));
145
+
146
+ const original = removeWordPressSizeSuffix(trimmed);
147
+ add(original);
148
+ if (original) {
149
+ add(encodeUrlPath(original));
150
+ add(normalizeUrlPath(original, "NFD"));
151
+ add(normalizeUrlPath(original, "NFC"));
152
+ }
153
+
154
+ return Array.from(candidates);
155
+ }
156
+
157
+ async function fetchRemoteMedia(rawUrl: string, userAgent: string) {
158
+ let lastStatus: number | null = null;
159
+ let lastError: string | null = null;
160
+
161
+ const headers = {
162
+ "User-Agent": userAgent,
163
+ Accept:
164
+ "image/avif,image/webp,image/apng,image/svg+xml,image/*,video/*,application/pdf,*/*;q=0.8",
165
+ };
166
+
167
+ for (const url of remoteUrlCandidates(rawUrl)) {
168
+ try {
169
+ const response = await fetch(url, { headers, redirect: "follow" });
170
+ if (response.ok) return { response, resolvedUrl: url };
171
+ lastStatus = response.status;
172
+ } catch (error) {
173
+ lastError = error instanceof Error ? error.message : "Network error";
174
+ }
175
+ }
176
+
177
+ const reason = lastStatus ? `Fetch failed: ${lastStatus}` : (lastError ?? "Fetch failed");
178
+ return { error: `${reason} for ${rawUrl}` };
179
+ }
180
+
181
+ /** Scan every CMS content column and report which library files are used where. */
182
+ export async function scanMediaUsageCore(host: MediaUsageHost): Promise<MediaUsageReport> {
183
+ const sb = host.db;
184
+ const prefix = host.backofficePrefix ?? "/kontoret";
185
+ const publicPathOf = host.pagePublicPath ?? (() => undefined);
186
+
187
+ const libPaths = new Set<string>();
188
+ for (const file of await host.storage.list()) libPaths.add(file.name);
189
+
190
+ const [sectionsR, pagesR, versionsR, gSectionsR, gVersionsR, seoR, templatesR] =
191
+ await Promise.all([
192
+ sb.from("content_sections").select("key, data"),
193
+ sb.from("pages").select("id, key, name, current_version_id"),
194
+ sb.from("page_versions").select("id, page_id, blocks"),
195
+ sb.from("global_sections").select("id, key, name, current_version_id"),
196
+ sb.from("global_section_versions").select("id, section_id, blocks"),
197
+ sb.from("page_seo").select("page_key, slugs, seo"),
198
+ sb.from("block_templates").select("id, name, block"),
199
+ ]);
200
+
201
+ const pageById = new Map<string, { key: string; name: string; current: string | null }>();
202
+ for (const page of pagesR.data ?? []) {
203
+ pageById.set(page.id, { key: page.key, name: page.name, current: page.current_version_id });
204
+ }
205
+
206
+ const globalSectionById = new Map<
207
+ string,
208
+ { key: string; name: string; current: string | null }
209
+ >();
210
+ for (const section of gSectionsR.data ?? []) {
211
+ globalSectionById.set(section.id, {
212
+ key: section.key,
213
+ name: section.name,
214
+ current: section.current_version_id,
215
+ });
216
+ }
217
+
218
+ const byPath: Record<string, MediaUsage[]> = {};
219
+ const externalMap = new Map<string, MediaUsage[]>();
220
+ const seen = new Set<string>();
221
+
222
+ /**
223
+ * The library only lists the primary variant of a responsive bundle, so a
224
+ * reference to any sibling width (`…-1200.webp`) must count on that primary.
225
+ */
226
+ const canonicalPath = (path: string) => {
227
+ const siblings = variantSiblings(path);
228
+ if (!siblings.length) return path;
229
+ const suffix = path.match(/-(\d+)\.webp$/i);
230
+ if (!suffix) return path;
231
+ const base = path.slice(0, path.length - suffix[0].length);
232
+ for (const width of [2560, 1920]) {
233
+ const candidate = `${base}-${width}.webp`;
234
+ if (libPaths.has(candidate)) return candidate;
235
+ }
236
+ return path;
237
+ };
238
+
239
+ const record = (rawUrl: string, jsonPath: string, usage: Omit<MediaUsage, "jsonPath">) => {
240
+ const url = rawUrl.trim();
241
+ if (!url) return;
242
+
243
+ let matched: string | null = null;
244
+ for (const path of libPaths) {
245
+ if (url.endsWith(`/${path}`) || url === path || url.endsWith(`/media/${path}`)) {
246
+ matched = path;
247
+ break;
248
+ }
249
+ }
250
+
251
+ const rec: MediaUsage = { ...usage, jsonPath };
252
+ if (matched) {
253
+ const target = canonicalPath(matched);
254
+ // One srcSet string references every width — count that as a single use.
255
+ const dedupeKey = `${target}|${usage.entity}|${usage.entityKey}|${jsonPath}`;
256
+ if (seen.has(dedupeKey)) return;
257
+ seen.add(dedupeKey);
258
+ (byPath[target] ||= []).push(rec);
259
+ } else if (MEDIA_EXT_RE.test(url.split("?")[0]!) || MEDIA_EXT_RE.test(url)) {
260
+ // Only real media files are importable — never link URLs (booking, pages…).
261
+ const usages = externalMap.get(url) ?? [];
262
+ usages.push(rec);
263
+ externalMap.set(url, usages);
264
+ }
265
+ };
266
+
267
+ for (const row of sectionsR.data ?? []) {
268
+ walk(row.data, (s, path) => {
269
+ for (const url of extractUrlsFromString(s)) {
270
+ record(url, path, {
271
+ entity: "section",
272
+ entityKey: row.key,
273
+ label: `Section: ${row.key}`,
274
+ editPath: `${prefix}/sections/${row.key}`,
275
+ publicPath: "/",
276
+ });
277
+ }
278
+ });
279
+ }
280
+
281
+ for (const version of versionsR.data ?? []) {
282
+ const page = pageById.get(version.page_id);
283
+ if (!page || page.current !== version.id) continue;
284
+ walk(version.blocks, (s, path) => {
285
+ for (const url of extractUrlsFromString(s)) {
286
+ record(url, path, {
287
+ entity: "page",
288
+ entityKey: page.key,
289
+ label: `Page: ${page.name}`,
290
+ editPath: `${prefix}/pages-builder/${page.key}`,
291
+ publicPath: publicPathOf(page.key),
292
+ });
293
+ }
294
+ });
295
+ }
296
+
297
+ for (const version of gVersionsR.data ?? []) {
298
+ const section = globalSectionById.get(version.section_id);
299
+ if (!section || section.current !== version.id) continue;
300
+ walk(version.blocks, (s, path) => {
301
+ for (const url of extractUrlsFromString(s)) {
302
+ record(url, path, {
303
+ entity: "global_section",
304
+ entityKey: section.key,
305
+ label: `Global block: ${section.name}`,
306
+ editPath: `${prefix}/global-sections/${section.key}`,
307
+ });
308
+ }
309
+ });
310
+ }
311
+
312
+ for (const row of seoR.data ?? []) {
313
+ walk(row.seo, (s, path) => {
314
+ for (const url of extractUrlsFromString(s)) {
315
+ record(url, `seo.${path}`, {
316
+ entity: "page_seo",
317
+ entityKey: row.page_key,
318
+ label: `SEO: ${row.page_key}`,
319
+ editPath: `${prefix}/seo/${row.page_key}`,
320
+ publicPath: publicPathOf(row.page_key),
321
+ });
322
+ }
323
+ });
324
+ }
325
+
326
+ for (const template of templatesR.data ?? []) {
327
+ walk(template.block, (s, path) => {
328
+ for (const url of extractUrlsFromString(s)) {
329
+ record(url, path, {
330
+ entity: "block_template",
331
+ entityKey: template.id,
332
+ label: `Template: ${template.name}`,
333
+ editPath: `${prefix}/pages-builder`,
334
+ });
335
+ }
336
+ });
337
+ }
338
+
339
+ const external = Array.from(externalMap.entries())
340
+ .map(([url, usages]) => ({ url, usages }))
341
+ .sort((a, b) => b.usages.length - a.usages.length);
342
+
343
+ return { byPath, external };
344
+ }
345
+
346
+ /** Replace every stored reference to `oldUrls` with `newUrl` across CMS content. */
347
+ export async function rewriteMediaReferencesCore(
348
+ sb: any,
349
+ oldUrls: string[],
350
+ newUrl: string,
351
+ ): Promise<number> {
352
+ const targets = Array.from(new Set(oldUrls.map((u) => u.trim()).filter(Boolean)));
353
+ if (!targets.length) return 0;
354
+
355
+ let changes = 0;
356
+ const replaceDeep = (value: any): any => {
357
+ if (typeof value === "string") {
358
+ let out = value;
359
+ for (const t of targets) {
360
+ if (out.includes(t)) {
361
+ out = out.split(t).join(newUrl);
362
+ changes++;
363
+ }
364
+ }
365
+ return out;
366
+ }
367
+ if (Array.isArray(value)) return value.map(replaceDeep);
368
+ if (value && typeof value === "object") {
369
+ const next: Record<string, any> = {};
370
+ for (const [k, v] of Object.entries(value)) next[k] = replaceDeep(v);
371
+ return next;
372
+ }
373
+ return value;
374
+ };
375
+
376
+ const tables: Array<{ table: string; column: string; idColumn: string }> = [
377
+ { table: "content_sections", column: "data", idColumn: "key" },
378
+ { table: "page_versions", column: "blocks", idColumn: "id" },
379
+ { table: "global_section_versions", column: "blocks", idColumn: "id" },
380
+ { table: "page_seo", column: "seo", idColumn: "page_key" },
381
+ { table: "block_templates", column: "block", idColumn: "id" },
382
+ ];
383
+
384
+ for (const { table, column, idColumn } of tables) {
385
+ const { data: rows } = await sb.from(table).select(`${idColumn}, ${column}`);
386
+ for (const row of rows ?? []) {
387
+ const before = JSON.stringify(row[column] ?? null);
388
+ const updated = replaceDeep(row[column]);
389
+ const after = JSON.stringify(updated ?? null);
390
+ if (before === after) continue;
391
+ await sb
392
+ .from(table)
393
+ .update({ [column]: updated })
394
+ .eq(idColumn, row[idColumn]);
395
+ }
396
+ }
397
+
398
+ return changes;
399
+ }
400
+
401
+ async function sha256Hex(bytes: Uint8Array) {
402
+ const hash = await crypto.subtle.digest("SHA-256", bytes.slice().buffer as ArrayBuffer);
403
+ return Array.from(new Uint8Array(hash))
404
+ .map((b) => b.toString(16).padStart(2, "0"))
405
+ .join("");
406
+ }
407
+
408
+ /**
409
+ * Download a remote media file into the library under `imported/`, keeping its
410
+ * original filename for SEO, then repoint CMS references at the local copy.
411
+ * Identical bytes are never stored twice.
412
+ */
413
+ export async function importRemoteUrlCore(
414
+ data: { url: string; filename?: string },
415
+ host: MediaImportHost,
416
+ ): Promise<MediaImportResult> {
417
+ const remote = await fetchRemoteMedia(
418
+ data.url,
419
+ host.userAgent ?? "Mozilla/5.0 (compatible; CmsMediaImporter/1.0)",
420
+ );
421
+ if ("error" in remote) {
422
+ return { path: "", url: "", skipped: true, error: remote.error ?? "Fetch failed" };
423
+ }
424
+
425
+ const contentType = remote.response.headers.get("content-type") ?? "application/octet-stream";
426
+ const buffer = new Uint8Array(await remote.response.arrayBuffer());
427
+ const urlObj = new URL(remote.resolvedUrl);
428
+ const rawName =
429
+ data.filename ||
430
+ urlObj.pathname.split("/").filter(Boolean).map(safeDecodeURIComponent).pop() ||
431
+ "imported-file";
432
+ const clean = cleanFileName(rawName, "imported-file");
433
+
434
+ const all = await host.storage.list();
435
+ const existing = all
436
+ .filter((f) => f.name.startsWith("imported/"))
437
+ .map((f) => ({ name: f.name.slice("imported/".length), size: f.size }));
438
+ const taken = new Set<string>(existing.map((f) => f.name));
439
+
440
+ const incomingHash = await sha256Hex(buffer);
441
+ const { base, ext } = splitExt(clean);
442
+ const family = new RegExp(
443
+ `^${base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(-\\d+)?${ext.replace(/\./g, "\\.")}$`,
444
+ );
445
+ let duplicateOf: string | null = null;
446
+ for (const file of existing) {
447
+ if (!family.test(file.name)) continue;
448
+ if (typeof file.size === "number" && file.size > 0 && file.size !== buffer.byteLength) continue;
449
+ let bytes: Uint8Array;
450
+ try {
451
+ bytes = await host.storage.download(`imported/${file.name}`);
452
+ } catch {
453
+ continue;
454
+ }
455
+ if ((await sha256Hex(bytes)) === incomingHash) {
456
+ duplicateOf = `imported/${file.name}`;
457
+ break;
458
+ }
459
+ }
460
+
461
+ if (duplicateOf) {
462
+ const existingUrl = host.storage.publicUrl(duplicateOf);
463
+ const rewrittenDup = existingUrl
464
+ ? await rewriteMediaReferencesCore(host.admin, remoteUrlCandidates(data.url), existingUrl)
465
+ : 0;
466
+ return { path: duplicateOf, url: existingUrl, rewritten: rewrittenDup, deduped: true };
467
+ }
468
+
469
+ const path = `imported/${uniqueFileName(clean, taken)}`;
470
+ await host.storage.upload(path, buffer, contentType, { upsert: false });
471
+
472
+ const newUrl = host.storage.publicUrl(path);
473
+ const rewritten = newUrl
474
+ ? await rewriteMediaReferencesCore(host.admin, remoteUrlCandidates(data.url), newUrl)
475
+ : 0;
476
+
477
+ return { path, url: newUrl, rewritten };
478
+ }