@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 +5 -3
- package/src/server/forms-core.ts +820 -0
- package/src/server/media-usage-core.ts +478 -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,
|
|
@@ -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
|
+
}
|