@bygghammar/cms-platform 0.32.2 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.34.0
4
+
5
+ - Added `server/content-sections-core` — public reads and staff-only upserts of
6
+ site-wide content records, with manual-edit stamping.
7
+ - Added `server/translate-db-core` — host-agnostic auto-translation sweep for
8
+ CMS localized JSONB fields, with brand prompt details and table specs supplied
9
+ by the host.
10
+
11
+ ## 0.33.0
12
+
13
+ - Added `server/pages-core` — host-agnostic page-builder logic: pages, versions,
14
+ publications and scheduling, the page trash bin, global blocks/sections, the
15
+ global-block usage scan and the block-type reference-image scan.
16
+ - Added `server/resolve-globals` — recursive resolution of global block
17
+ references, usable with either an authenticated or a privileged client.
18
+
19
+
20
+
3
21
  ## 0.31.0
4
22
 
5
23
  - Added `backoffice/SharedContentListView` — the shared-content library screen
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.32.2",
3
+ "version": "0.34.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,
@@ -141,7 +141,11 @@
141
141
  "./server/audit-core": "./src/server/audit-core.ts",
142
142
  "./server/review-image-validate": "./src/server/review-image-validate.ts",
143
143
  "./server/media-library-core": "./src/server/media-library-core.ts",
144
- "./server/admin-core": "./src/server/admin-core.ts"
144
+ "./server/admin-core": "./src/server/admin-core.ts",
145
+ "./server/pages-core": "./src/server/pages-core.ts",
146
+ "./server/resolve-globals": "./src/server/resolve-globals.ts",
147
+ "./server/content-sections-core": "./src/server/content-sections-core.ts",
148
+ "./server/translate-db-core": "./src/server/translate-db-core.ts"
145
149
  },
146
150
  "dependencies": {
147
151
  "fflate": "^0.8.3"
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Host-agnostic logic for site-wide content records (`content_sections`).
3
+ *
4
+ * The host owns the server-function wrappers, input validation and the clients;
5
+ * this module owns the queries and the manual-edit stamping so every site keeps
6
+ * identical behaviour: reads are public, writes are staff-only and manual
7
+ * translations are never clobbered.
8
+ */
9
+
10
+ import type { Json } from "../i18n/json";
11
+ import { stampManualEdits } from "../i18n/translation-meta";
12
+ import { assertStaff, type PlatformAuthContext } from "./guards";
13
+
14
+ /** Structural subset of a Supabase-shaped client used by this core. */
15
+ export type ContentClient = {
16
+ from: (table: string) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
17
+ };
18
+
19
+ export type ContentOverrides = Record<string, Json>;
20
+
21
+ /**
22
+ * Public read of every stored content record, keyed by record key.
23
+ *
24
+ * Never throws: a backend hiccup degrades to "no overrides" so the site still
25
+ * renders its in-code defaults.
26
+ */
27
+ export async function getContentOverridesCore(client: ContentClient): Promise<ContentOverrides> {
28
+ const { data, error } = await client.from("content_sections").select("key, data");
29
+ if (error) {
30
+ console.error("[cms] getContentOverrides", error);
31
+ return {};
32
+ }
33
+ const out: ContentOverrides = {};
34
+ for (const row of (data ?? []) as { key: string; data: Json }[]) out[row.key] = row.data;
35
+ return out;
36
+ }
37
+
38
+ /** True when the caller passes the host's `is_staff` check. */
39
+ export async function isStaffCore(context: PlatformAuthContext): Promise<boolean> {
40
+ const { data, error } = await context.supabase.rpc("is_staff", { _user_id: context.userId });
41
+ if (error) throw new Error(error.message);
42
+ return Boolean(data);
43
+ }
44
+
45
+ /**
46
+ * Staff-only upsert of one content record. Runs as the signed-in user, so RLS
47
+ * still applies. Manually edited locale values in the previous row are
48
+ * preserved by `stampManualEdits`.
49
+ */
50
+ export async function saveContentSectionCore(
51
+ context: PlatformAuthContext & { supabase: ContentClient },
52
+ input: { key: string; data: unknown },
53
+ ): Promise<{ ok: true; key: string }> {
54
+ await assertStaff(context);
55
+
56
+ const { data: prevRow } = await context.supabase
57
+ .from("content_sections")
58
+ .select("data")
59
+ .eq("key", input.key)
60
+ .maybeSingle();
61
+ const stamped = stampManualEdits(input.data, (prevRow?.data as Json) ?? null);
62
+
63
+ const { error } = await context.supabase
64
+ .from("content_sections")
65
+ .upsert({ key: input.key, data: stamped as Json }, { onConflict: "key" });
66
+ if (error) throw new Error(error.message);
67
+ return { ok: true, key: input.key };
68
+ }
@@ -0,0 +1,799 @@
1
+ /**
2
+ * Host-agnostic page-builder server logic.
3
+ *
4
+ * Owns pages, page versions, publications (including scheduling), the page
5
+ * trash bin, global blocks/sections and the two library scans the backoffice
6
+ * uses. Hosts keep only input validation and their own auth middleware; every
7
+ * read and write here runs through the client the host passes, so row-level
8
+ * security still applies as the signed-in user.
9
+ */
10
+ import type { BlockInstance } from "../blocks/types";
11
+ import { diffBlocks, summarizeChanges } from "../cms/version-diff";
12
+ import type { VersionChange } from "../cms/version-diff";
13
+ import { actorEmail, assertStaff, assertSuperadmin } from "./guards";
14
+ import type { PlatformAuthContext } from "./guards";
15
+ import { resolveGlobalsDeep } from "./resolve-globals";
16
+
17
+ /* eslint-disable @typescript-eslint/no-explicit-any */
18
+
19
+ /** Authenticated request context a host must provide. */
20
+ export type PagesContext = PlatformAuthContext & { supabase: any };
21
+
22
+ /** What the page cores need from the host. */
23
+ export type PagesDeps = {
24
+ context: PagesContext;
25
+ /** Privileged client; only used to resolve legacy version authors. */
26
+ admin?: () => Promise<any>;
27
+ };
28
+
29
+ export type ResolvedPage = {
30
+ key: string;
31
+ name: string;
32
+ blocks: BlockInstance[];
33
+ versionId: string | null;
34
+ globals: Record<string, BlockInstance[]>;
35
+ };
36
+
37
+ const blocksOf = (value: unknown): BlockInstance[] =>
38
+ (value as unknown as BlockInstance[] | null) ?? [];
39
+
40
+ /**
41
+ * Public rendering path: the currently-live blocks for a page plus any global
42
+ * blocks they reference. Needs a trusted client because CMS tables are not
43
+ * readable by anonymous callers.
44
+ */
45
+ export async function getPublishedPageCore(
46
+ client: any,
47
+ key: string,
48
+ ): Promise<ResolvedPage | null> {
49
+ const { data: page } = await client
50
+ .from("pages")
51
+ .select("id,key,name,current_version_id")
52
+ .eq("key", key)
53
+ .is("deleted_at", null)
54
+ .maybeSingle();
55
+ if (!page) return null;
56
+
57
+ // Time-based publication: newest active row whose window is open right now.
58
+ const nowIso = new Date().toISOString();
59
+ const { data: pub } = await client
60
+ .from("page_publications")
61
+ .select("version_id, publish_at")
62
+ .eq("page_id", page.id)
63
+ .eq("is_active", true)
64
+ .lte("publish_at", nowIso)
65
+ .or(`unpublish_at.is.null,unpublish_at.gt.${nowIso}`)
66
+ .order("publish_at", { ascending: false })
67
+ .limit(1)
68
+ .maybeSingle();
69
+
70
+ const versionId = pub?.version_id ?? page.current_version_id;
71
+ if (!versionId) return null;
72
+ const { data: version } = await client
73
+ .from("page_versions")
74
+ .select("id, blocks")
75
+ .eq("id", versionId)
76
+ .maybeSingle();
77
+ const blocks = blocksOf(version?.blocks);
78
+ const globals = await resolveGlobalsDeep(client, blocks);
79
+ return { key: page.key, name: page.name, blocks, versionId: version?.id ?? null, globals };
80
+ }
81
+
82
+ export type PageListRow = {
83
+ id: string;
84
+ key: string;
85
+ name: string;
86
+ is_system: boolean;
87
+ status: string;
88
+ updated_at: string;
89
+ current_version_id: string | null;
90
+ };
91
+
92
+ export async function listPagesCore({ context }: PagesDeps): Promise<PageListRow[]> {
93
+ await assertStaff(context);
94
+ const { data, error } = await context.supabase
95
+ .from("pages")
96
+ .select("id,key,name,is_system,status,updated_at,current_version_id")
97
+ .is("deleted_at", null)
98
+ .order("is_system", { ascending: false })
99
+ .order("name");
100
+ if (error) throw new Error(error.message);
101
+ return (data ?? []) as PageListRow[];
102
+ }
103
+
104
+ export async function getPageForEditCore({ context }: PagesDeps, key: string) {
105
+ await assertStaff(context);
106
+ const { data: page, error } = await context.supabase
107
+ .from("pages")
108
+ .select("id,key,name,is_system,status,current_version_id")
109
+ .eq("key", key)
110
+ .is("deleted_at", null)
111
+ .maybeSingle();
112
+ if (error) throw new Error(error.message);
113
+ if (!page) throw new Error("Page not found");
114
+ let blocks: BlockInstance[] = [];
115
+ if (page.current_version_id) {
116
+ const { data: v } = await context.supabase
117
+ .from("page_versions")
118
+ .select("blocks")
119
+ .eq("id", page.current_version_id)
120
+ .maybeSingle();
121
+ blocks = blocksOf(v?.blocks);
122
+ }
123
+ return { page, blocks };
124
+ }
125
+
126
+ export async function savePageVersionCore(
127
+ { context }: PagesDeps,
128
+ input: { pageId: string; blocks: BlockInstance[]; publish?: boolean; note?: string },
129
+ ) {
130
+ await assertStaff(context);
131
+ const { data: last } = await context.supabase
132
+ .from("page_versions")
133
+ .select("version_no")
134
+ .eq("page_id", input.pageId)
135
+ .order("version_no", { ascending: false })
136
+ .limit(1)
137
+ .maybeSingle();
138
+ const nextNo = (last?.version_no ?? 0) + 1;
139
+ const { data: inserted, error } = await context.supabase
140
+ .from("page_versions")
141
+ .insert({
142
+ page_id: input.pageId,
143
+ version_no: nextNo,
144
+ blocks: input.blocks as unknown as any,
145
+ note: input.note ?? null,
146
+ created_by: context.userId,
147
+ created_by_email: actorEmail(context),
148
+ })
149
+ .select("id")
150
+ .single();
151
+ if (error) throw new Error(error.message);
152
+
153
+ await context.supabase
154
+ .from("pages")
155
+ .update({ current_version_id: inserted.id })
156
+ .eq("id", input.pageId);
157
+
158
+ if (input.publish) {
159
+ await context.supabase
160
+ .from("page_publications")
161
+ .update({ is_active: false })
162
+ .eq("page_id", input.pageId)
163
+ .eq("is_active", true);
164
+ await context.supabase.from("page_publications").insert({
165
+ page_id: input.pageId,
166
+ version_id: inserted.id,
167
+ is_active: true,
168
+ published_by: context.userId,
169
+ });
170
+ await context.supabase.from("pages").update({ status: "published" }).eq("id", input.pageId);
171
+ }
172
+ return { versionId: inserted.id as string, versionNo: nextNo };
173
+ }
174
+
175
+ export async function listPageVersionsCore(deps: PagesDeps, pageId: string) {
176
+ const { context, admin } = deps;
177
+ await assertStaff(context);
178
+ const { data: rows, error } = await context.supabase
179
+ .from("page_versions")
180
+ .select("id, version_no, note, created_at, created_by, created_by_email, blocks")
181
+ .eq("page_id", pageId)
182
+ .order("version_no", { ascending: false })
183
+ .limit(50);
184
+ if (error) throw new Error(error.message);
185
+ const list = (rows ?? []) as any[];
186
+
187
+ // Resolve display names for authors that predate created_by_email.
188
+ const missing = Array.from(
189
+ new Set(list.filter((r) => !r.created_by_email && r.created_by).map((r) => r.created_by as string)),
190
+ );
191
+ const emailById = new Map<string, string>();
192
+ if (missing.length > 0 && admin) {
193
+ try {
194
+ const client = await admin();
195
+ for (const id of missing) {
196
+ const { data: u } = await client.auth.admin.getUserById(id);
197
+ if (u?.user?.email) emailById.set(id, u.user.email);
198
+ }
199
+ } catch {
200
+ // best effort only
201
+ }
202
+ }
203
+
204
+ return list.map((row, index) => {
205
+ const current = blocksOf(row.blocks);
206
+ const older = list[index + 1];
207
+ const previous = older ? blocksOf(older.blocks) : [];
208
+ const changes: VersionChange[] = older
209
+ ? diffBlocks(previous, current)
210
+ : current.map((b) => ({ kind: "added" as const, blockType: b.type }));
211
+ return {
212
+ id: row.id as string,
213
+ version_no: row.version_no as number,
214
+ note: row.note as string | null,
215
+ created_at: row.created_at as string,
216
+ created_by: row.created_by as string | null,
217
+ author:
218
+ ((row.created_by_email as string | null) ??
219
+ (row.created_by ? (emailById.get(row.created_by) ?? null) : null)) ||
220
+ "Unknown user",
221
+ block_count: current.length,
222
+ changes,
223
+ summary: summarizeChanges(changes),
224
+ };
225
+ });
226
+ }
227
+
228
+ export async function restorePageVersionCore(deps: PagesDeps, versionId: string) {
229
+ await assertStaff(deps.context);
230
+ const { data: v, error } = await deps.context.supabase
231
+ .from("page_versions")
232
+ .select("page_id, blocks")
233
+ .eq("id", versionId)
234
+ .single();
235
+ if (error) throw new Error(error.message);
236
+ return savePageVersionCore(deps, {
237
+ pageId: v.page_id,
238
+ blocks: blocksOf(v.blocks),
239
+ publish: true,
240
+ note: "Restored from earlier version",
241
+ });
242
+ }
243
+
244
+ export async function createPageCore({ context }: PagesDeps, input: { key: string; name: string }) {
245
+ await assertStaff(context);
246
+ const { data: page, error } = await context.supabase
247
+ .from("pages")
248
+ .insert({ key: input.key, name: input.name, is_system: false, status: "draft" })
249
+ .select("id")
250
+ .single();
251
+ if (error) throw new Error(error.message);
252
+ const { data: v } = await context.supabase
253
+ .from("page_versions")
254
+ .insert({ page_id: page.id, version_no: 1, blocks: [], note: "Empty" })
255
+ .select("id")
256
+ .single();
257
+ if (v) {
258
+ await context.supabase.from("pages").update({ current_version_id: v.id }).eq("id", page.id);
259
+ }
260
+ return { id: page.id as string };
261
+ }
262
+
263
+ export async function renamePageCore(
264
+ { context }: PagesDeps,
265
+ input: { pageId: string; name: string },
266
+ ) {
267
+ await assertStaff(context);
268
+ const { error } = await context.supabase
269
+ .from("pages")
270
+ .update({ name: input.name })
271
+ .eq("id", input.pageId);
272
+ if (error) throw new Error(error.message);
273
+ await context.supabase.from("audit_logs").insert({
274
+ actor_id: context.userId,
275
+ action: "page.rename",
276
+ entity: "pages",
277
+ entity_id: input.pageId,
278
+ meta: { name: input.name },
279
+ });
280
+ return { ok: true as const };
281
+ }
282
+
283
+ /** Soft delete: moves the page to the trash bin. Never destroys data. */
284
+ export async function deletePageCore({ context }: PagesDeps, pageId: string) {
285
+ await assertStaff(context);
286
+ const email = actorEmail(context);
287
+ const { data: page } = await context.supabase
288
+ .from("pages")
289
+ .select("key,name,is_system")
290
+ .eq("id", pageId)
291
+ .maybeSingle();
292
+ if (page?.is_system) throw new Error("System pages cannot be deleted");
293
+ const { error } = await context.supabase
294
+ .from("pages")
295
+ .update({
296
+ deleted_at: new Date().toISOString(),
297
+ deleted_by: context.userId,
298
+ deleted_by_email: email,
299
+ status: "draft",
300
+ })
301
+ .eq("id", pageId)
302
+ .is("deleted_at", null);
303
+ if (error) throw new Error(error.message);
304
+ await context.supabase.from("audit_logs").insert({
305
+ actor_id: context.userId,
306
+ actor_email: email,
307
+ action: "page.soft_delete",
308
+ entity: "pages",
309
+ entity_id: pageId,
310
+ meta: { key: page?.key ?? null, name: page?.name ?? null },
311
+ });
312
+ return { ok: true as const };
313
+ }
314
+
315
+ export type DeletedPageRow = {
316
+ id: string;
317
+ key: string;
318
+ name: string;
319
+ status: string;
320
+ updated_at: string;
321
+ deleted_at: string;
322
+ deleted_by: string | null;
323
+ deleted_by_email: string | null;
324
+ };
325
+
326
+ export async function listDeletedPagesCore({
327
+ context,
328
+ }: PagesDeps): Promise<{ rows: DeletedPageRow[]; canPurge: boolean }> {
329
+ await assertStaff(context);
330
+ const { data, error } = await context.supabase
331
+ .from("pages")
332
+ .select("id,key,name,status,updated_at,deleted_at,deleted_by,deleted_by_email")
333
+ .not("deleted_at", "is", null)
334
+ .order("deleted_at", { ascending: false });
335
+ if (error) throw new Error(error.message);
336
+ const { data: isSuper } = await context.supabase.rpc("has_role", {
337
+ _user_id: context.userId,
338
+ _role: "superadmin",
339
+ });
340
+ return { rows: (data ?? []) as DeletedPageRow[], canPurge: !!isSuper };
341
+ }
342
+
343
+ export async function restorePageCore({ context }: PagesDeps, pageId: string) {
344
+ await assertStaff(context);
345
+ const { error } = await context.supabase
346
+ .from("pages")
347
+ .update({ deleted_at: null, deleted_by: null, deleted_by_email: null })
348
+ .eq("id", pageId);
349
+ if (error) throw new Error(error.message);
350
+ await context.supabase.from("audit_logs").insert({
351
+ actor_id: context.userId,
352
+ actor_email: actorEmail(context),
353
+ action: "page.restore",
354
+ entity: "pages",
355
+ entity_id: pageId,
356
+ meta: {},
357
+ });
358
+ return { ok: true as const };
359
+ }
360
+
361
+ /** Permanent delete from the trash bin — superadmin only. */
362
+ export async function purgePageCore({ context }: PagesDeps, pageId: string) {
363
+ await assertStaff(context);
364
+ await assertSuperadmin(context);
365
+ const { data: page } = await context.supabase
366
+ .from("pages")
367
+ .select("key,name,deleted_at")
368
+ .eq("id", pageId)
369
+ .maybeSingle();
370
+ if (!page?.deleted_at) throw new Error("Move the page to the trash bin first");
371
+ const { error } = await context.supabase.from("pages").delete().eq("id", pageId);
372
+ if (error) throw new Error(error.message);
373
+ await context.supabase.from("audit_logs").insert({
374
+ actor_id: context.userId,
375
+ actor_email: actorEmail(context),
376
+ action: "page.purge",
377
+ entity: "pages",
378
+ entity_id: pageId,
379
+ meta: { key: page.key, name: page.name },
380
+ });
381
+ return { ok: true as const };
382
+ }
383
+
384
+ /* ---------- Global blocks ---------- */
385
+
386
+ /** A global block holds exactly one block and may not reference other globals. */
387
+ export function assertSingleBlock(blocks: BlockInstance[]) {
388
+ if (blocks.length > 1) {
389
+ throw new Error(
390
+ "A global block can only contain one block. Convert it to a global section to add more.",
391
+ );
392
+ }
393
+ if (blocks.some((b) => b.type === "globalRef")) {
394
+ throw new Error("A global block cannot contain another global block or section.");
395
+ }
396
+ }
397
+
398
+ export type GlobalSectionRow = {
399
+ id: string;
400
+ key: string;
401
+ name: string;
402
+ kind: "block" | "section";
403
+ updated_at: string;
404
+ current_version_id: string | null;
405
+ };
406
+
407
+ export async function listGlobalSectionsCore({
408
+ context,
409
+ }: PagesDeps): Promise<(GlobalSectionRow & { blocks: BlockInstance[] })[]> {
410
+ await assertStaff(context);
411
+ const { data, error } = await context.supabase
412
+ .from("global_sections")
413
+ .select("id,key,name,kind,updated_at,current_version_id")
414
+ .order("name");
415
+ if (error) throw new Error(error.message);
416
+ const rows = (data ?? []) as any[];
417
+ const versionIds = rows.map((r) => r.current_version_id).filter((x): x is string => !!x);
418
+ const blocksByVersion = new Map<string, BlockInstance[]>();
419
+ if (versionIds.length > 0) {
420
+ const { data: versions } = await context.supabase
421
+ .from("global_section_versions")
422
+ .select("id,blocks")
423
+ .in("id", versionIds);
424
+ for (const v of (versions ?? []) as any[]) blocksByVersion.set(v.id, blocksOf(v.blocks));
425
+ }
426
+ return (rows as GlobalSectionRow[]).map((r) => ({
427
+ ...r,
428
+ blocks: blocksByVersion.get(r.current_version_id ?? "") ?? [],
429
+ }));
430
+ }
431
+
432
+ export async function getGlobalSectionForEditCore({ context }: PagesDeps, key: string) {
433
+ await assertStaff(context);
434
+ const { data: gs, error } = await context.supabase
435
+ .from("global_sections")
436
+ .select("id,key,name,kind,current_version_id")
437
+ .eq("key", key)
438
+ .maybeSingle();
439
+ if (error) throw new Error(error.message);
440
+ if (!gs) throw new Error("Global block not found");
441
+ let blocks: BlockInstance[] = [];
442
+ if (gs.current_version_id) {
443
+ const { data: v } = await context.supabase
444
+ .from("global_section_versions")
445
+ .select("blocks")
446
+ .eq("id", gs.current_version_id)
447
+ .maybeSingle();
448
+ blocks = blocksOf(v?.blocks);
449
+ }
450
+ return { section: gs, blocks };
451
+ }
452
+
453
+ export async function createGlobalSectionCore(
454
+ { context }: PagesDeps,
455
+ input: { key: string; name: string; blocks?: BlockInstance[]; kind?: "block" | "section" },
456
+ ) {
457
+ await assertStaff(context);
458
+ const kind = input.kind ?? "section";
459
+ if (kind === "block") assertSingleBlock(input.blocks ?? []);
460
+ const { data: gs, error } = await context.supabase
461
+ .from("global_sections")
462
+ .insert({ key: input.key, name: input.name, kind })
463
+ .select("id")
464
+ .single();
465
+ if (error) throw new Error(error.message);
466
+ const { data: v } = await context.supabase
467
+ .from("global_section_versions")
468
+ .insert({
469
+ section_id: gs.id,
470
+ version_no: 1,
471
+ blocks: (input.blocks ?? []) as unknown as any,
472
+ note: "Initial",
473
+ })
474
+ .select("id")
475
+ .single();
476
+ if (v) {
477
+ await context.supabase
478
+ .from("global_sections")
479
+ .update({ current_version_id: v.id })
480
+ .eq("id", gs.id);
481
+ }
482
+ return { id: gs.id as string };
483
+ }
484
+
485
+ export async function setGlobalSectionKindCore(
486
+ { context }: PagesDeps,
487
+ input: { sectionId: string; kind: "block" | "section" },
488
+ ) {
489
+ await assertStaff(context);
490
+ if (input.kind === "block") {
491
+ const { data: gs } = await context.supabase
492
+ .from("global_sections")
493
+ .select("current_version_id")
494
+ .eq("id", input.sectionId)
495
+ .maybeSingle();
496
+ if (gs?.current_version_id) {
497
+ const { data: v } = await context.supabase
498
+ .from("global_section_versions")
499
+ .select("blocks")
500
+ .eq("id", gs.current_version_id)
501
+ .maybeSingle();
502
+ assertSingleBlock(blocksOf(v?.blocks));
503
+ }
504
+ }
505
+ const { error } = await context.supabase
506
+ .from("global_sections")
507
+ .update({ kind: input.kind })
508
+ .eq("id", input.sectionId);
509
+ if (error) throw new Error(error.message);
510
+ return { ok: true as const };
511
+ }
512
+
513
+ export async function saveGlobalSectionVersionCore(
514
+ { context }: PagesDeps,
515
+ input: { sectionId: string; blocks: BlockInstance[]; note?: string },
516
+ ) {
517
+ await assertStaff(context);
518
+ const { data: meta } = await context.supabase
519
+ .from("global_sections")
520
+ .select("kind")
521
+ .eq("id", input.sectionId)
522
+ .maybeSingle();
523
+ if ((meta?.kind ?? "section") === "block") assertSingleBlock(input.blocks);
524
+ const { data: last } = await context.supabase
525
+ .from("global_section_versions")
526
+ .select("version_no")
527
+ .eq("section_id", input.sectionId)
528
+ .order("version_no", { ascending: false })
529
+ .limit(1)
530
+ .maybeSingle();
531
+ const nextNo = (last?.version_no ?? 0) + 1;
532
+ const { data: inserted, error } = await context.supabase
533
+ .from("global_section_versions")
534
+ .insert({
535
+ section_id: input.sectionId,
536
+ version_no: nextNo,
537
+ blocks: input.blocks as unknown as any,
538
+ note: input.note ?? null,
539
+ created_by: context.userId,
540
+ })
541
+ .select("id")
542
+ .single();
543
+ if (error) throw new Error(error.message);
544
+ await context.supabase
545
+ .from("global_sections")
546
+ .update({ current_version_id: inserted.id })
547
+ .eq("id", input.sectionId);
548
+ return { versionId: inserted.id as string, versionNo: nextNo };
549
+ }
550
+
551
+ export async function renameGlobalSectionCore(
552
+ { context }: PagesDeps,
553
+ input: { sectionId: string; name: string },
554
+ ) {
555
+ await assertStaff(context);
556
+ const { error } = await context.supabase
557
+ .from("global_sections")
558
+ .update({ name: input.name })
559
+ .eq("id", input.sectionId);
560
+ if (error) throw new Error(error.message);
561
+ return { ok: true as const };
562
+ }
563
+
564
+ export async function deleteGlobalSectionCore({ context }: PagesDeps, sectionId: string) {
565
+ await assertStaff(context);
566
+ const { error } = await context.supabase
567
+ .from("global_sections")
568
+ .delete()
569
+ .eq("id", sectionId);
570
+ if (error) throw new Error(error.message);
571
+ return { ok: true as const };
572
+ }
573
+
574
+ /* ---------- Scheduling / drafts ---------- */
575
+
576
+ /** A specific version's blocks, for previewing drafts. */
577
+ export async function getPageVersionPreviewCore(
578
+ { context }: PagesDeps,
579
+ versionId: string,
580
+ ): Promise<ResolvedPage | null> {
581
+ await assertStaff(context);
582
+ const { data: v, error } = await context.supabase
583
+ .from("page_versions")
584
+ .select("id, page_id, blocks, pages!inner(key,name)")
585
+ .eq("id", versionId)
586
+ .single();
587
+ if (error) throw new Error(error.message);
588
+ const blocks = blocksOf(v.blocks);
589
+ const globals = await resolveGlobalsDeep(context.supabase, blocks);
590
+ const meta = (v as unknown as { pages: { key: string; name: string } }).pages;
591
+ return { key: meta.key, name: meta.name, blocks, versionId: v.id, globals };
592
+ }
593
+
594
+ export async function schedulePageVersionCore(
595
+ { context }: PagesDeps,
596
+ input: { versionId: string; publishAt: string; unpublishAt?: string | null; note?: string },
597
+ ) {
598
+ await assertStaff(context);
599
+ const { data: v, error: vErr } = await context.supabase
600
+ .from("page_versions")
601
+ .select("page_id")
602
+ .eq("id", input.versionId)
603
+ .single();
604
+ if (vErr) throw new Error(vErr.message);
605
+ const { data: inserted, error } = await context.supabase
606
+ .from("page_publications")
607
+ .insert({
608
+ page_id: v.page_id,
609
+ version_id: input.versionId,
610
+ publish_at: input.publishAt,
611
+ unpublish_at: input.unpublishAt ?? null,
612
+ is_active: true,
613
+ published_by: context.userId,
614
+ note: input.note ?? null,
615
+ })
616
+ .select("id")
617
+ .single();
618
+ if (error) throw new Error(error.message);
619
+ return { publicationId: inserted.id as string };
620
+ }
621
+
622
+ export type PagePublicationRow = {
623
+ id: string;
624
+ version_id: string;
625
+ publish_at: string;
626
+ unpublish_at: string | null;
627
+ is_active: boolean;
628
+ note: string | null;
629
+ published_at: string | null;
630
+ page_versions: { version_no: number } | null;
631
+ };
632
+
633
+ export async function listPagePublicationsCore(
634
+ { context }: PagesDeps,
635
+ pageId: string,
636
+ ): Promise<PagePublicationRow[]> {
637
+ await assertStaff(context);
638
+ const { data: rows, error } = await context.supabase
639
+ .from("page_publications")
640
+ .select(
641
+ "id, version_id, publish_at, unpublish_at, is_active, note, published_at, page_versions(version_no)",
642
+ )
643
+ .eq("page_id", pageId)
644
+ .order("publish_at", { ascending: false })
645
+ .limit(50);
646
+ if (error) throw new Error(error.message);
647
+ return (rows ?? []) as PagePublicationRow[];
648
+ }
649
+
650
+ export async function cancelPagePublicationCore({ context }: PagesDeps, publicationId: string) {
651
+ await assertStaff(context);
652
+ const { error } = await context.supabase
653
+ .from("page_publications")
654
+ .update({ is_active: false })
655
+ .eq("id", publicationId);
656
+ if (error) throw new Error(error.message);
657
+ return { ok: true as const };
658
+ }
659
+
660
+ /** Where is a given global block used? */
661
+ export async function getGlobalSectionUsageCore({ context }: PagesDeps, key: string) {
662
+ await assertStaff(context);
663
+ const { data: pages } = await context.supabase
664
+ .from("pages")
665
+ .select("key,name,current_version_id");
666
+ const { data: sections } = await context.supabase
667
+ .from("global_sections")
668
+ .select("key,name,current_version_id");
669
+
670
+ const pageVersionIds = ((pages ?? []) as any[])
671
+ .map((p) => p.current_version_id)
672
+ .filter((x): x is string => !!x);
673
+ const gsVersionIds = ((sections ?? []) as any[])
674
+ .map((g) => g.current_version_id)
675
+ .filter((x): x is string => !!x);
676
+
677
+ const usedIn: { kind: "page" | "section"; key: string; name: string }[] = [];
678
+
679
+ const hasRef = (blocks: unknown) =>
680
+ blocksOf(blocks).some(
681
+ (b) =>
682
+ b.type === "globalRef" &&
683
+ String((b.props as { sectionKey?: string } | undefined)?.sectionKey ?? "") === key,
684
+ );
685
+
686
+ if (pageVersionIds.length > 0) {
687
+ const { data: pv } = await context.supabase
688
+ .from("page_versions")
689
+ .select("id,blocks")
690
+ .in("id", pageVersionIds);
691
+ const byId = new Map(((pv ?? []) as any[]).map((v) => [v.id, v.blocks]));
692
+ for (const p of (pages ?? []) as any[]) {
693
+ if (p.current_version_id && hasRef(byId.get(p.current_version_id))) {
694
+ usedIn.push({ kind: "page", key: p.key, name: p.name });
695
+ }
696
+ }
697
+ }
698
+ if (gsVersionIds.length > 0) {
699
+ const { data: gv } = await context.supabase
700
+ .from("global_section_versions")
701
+ .select("id,blocks")
702
+ .in("id", gsVersionIds);
703
+ const byId = new Map(((gv ?? []) as any[]).map((v) => [v.id, v.blocks]));
704
+ for (const g of (sections ?? []) as any[]) {
705
+ if (g.key === key) continue;
706
+ if (g.current_version_id && hasRef(byId.get(g.current_version_id))) {
707
+ usedIn.push({ kind: "section", key: g.key, name: g.name });
708
+ }
709
+ }
710
+ }
711
+ return usedIn;
712
+ }
713
+
714
+ /**
715
+ * Reference thumbnails per block type.
716
+ *
717
+ * Scans the current version of every page and global block plus the saved block
718
+ * templates, and returns the first usable image URL found inside a real
719
+ * instance of each block type. The Blocks library uses this so every block type
720
+ * shows a real reference image even when its defaults contain no media.
721
+ */
722
+ export async function listBlockTypeImagesCore({
723
+ context,
724
+ }: PagesDeps): Promise<Record<string, string>> {
725
+ await assertStaff(context);
726
+
727
+ const [{ data: pages }, { data: sections }, { data: templates }] = await Promise.all([
728
+ context.supabase.from("pages").select("current_version_id"),
729
+ context.supabase.from("global_sections").select("current_version_id"),
730
+ context.supabase.from("block_templates").select("block_type,block"),
731
+ ]);
732
+
733
+ const pageVersionIds = ((pages ?? []) as any[])
734
+ .map((p) => p.current_version_id)
735
+ .filter((x): x is string => !!x);
736
+ const gsVersionIds = ((sections ?? []) as any[])
737
+ .map((g) => g.current_version_id)
738
+ .filter((x): x is string => !!x);
739
+
740
+ const blockLists: unknown[] = [];
741
+ if (pageVersionIds.length > 0) {
742
+ const { data } = await context.supabase
743
+ .from("page_versions")
744
+ .select("blocks")
745
+ .in("id", pageVersionIds);
746
+ for (const v of (data ?? []) as any[]) blockLists.push(v.blocks);
747
+ }
748
+ if (gsVersionIds.length > 0) {
749
+ const { data } = await context.supabase
750
+ .from("global_section_versions")
751
+ .select("blocks")
752
+ .in("id", gsVersionIds);
753
+ for (const v of (data ?? []) as any[]) blockLists.push(v.blocks);
754
+ }
755
+
756
+ const isImage = (value: unknown): value is string =>
757
+ typeof value === "string" &&
758
+ value.length > 0 &&
759
+ /^(https?:\/\/|\/)/.test(value) &&
760
+ !/\.(mp4|webm|mov)(\?|$)/i.test(value);
761
+
762
+ const findImage = (node: unknown, depth = 0): string | null => {
763
+ if (node == null || depth > 6) return null;
764
+ if (Array.isArray(node)) {
765
+ for (const item of node) {
766
+ const found = findImage(item, depth + 1);
767
+ if (found) return found;
768
+ }
769
+ return null;
770
+ }
771
+ if (typeof node !== "object") return null;
772
+ const obj = node as Record<string, unknown>;
773
+ for (const key of ["src", "poster", "url", "image", "thumbnail"]) {
774
+ if (isImage(obj[key])) return obj[key] as string;
775
+ }
776
+ for (const value of Object.values(obj)) {
777
+ const found = findImage(value, depth + 1);
778
+ if (found) return found;
779
+ }
780
+ return null;
781
+ };
782
+
783
+ const out: Record<string, string> = {};
784
+ const consider = (block: unknown) => {
785
+ if (!block || typeof block !== "object") return;
786
+ const b = block as { type?: unknown; props?: unknown };
787
+ if (typeof b.type !== "string" || out[b.type]) return;
788
+ const image = findImage(b.props);
789
+ if (image) out[b.type] = image;
790
+ };
791
+
792
+ for (const list of blockLists) {
793
+ if (!Array.isArray(list)) continue;
794
+ for (const block of list) consider(block);
795
+ }
796
+ for (const t of (templates ?? []) as any[]) consider(t.block);
797
+
798
+ return out;
799
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Host-agnostic resolution of global block references.
3
+ *
4
+ * Reads only two CMS tables through whatever Supabase-shaped client the caller
5
+ * passes, so it works with an authenticated client in the backoffice and with a
6
+ * privileged client during public rendering.
7
+ */
8
+ import type { BlockInstance, BlockProps } from "../blocks/types";
9
+
10
+ /** Minimal read surface this helper needs from a Supabase-style client. */
11
+ export type GlobalsReadClient = {
12
+ from: (table: string) => {
13
+ select: (columns: string) => {
14
+ in: (column: string, values: string[]) => PromiseLike<{ data: unknown }>;
15
+ };
16
+ };
17
+ };
18
+
19
+ function refKeysOf(blocks: BlockInstance[]): string[] {
20
+ return blocks
21
+ .filter((b) => b.type === "globalRef")
22
+ .map((b) => String((b.props as BlockProps | undefined)?.sectionKey ?? ""))
23
+ .filter(Boolean);
24
+ }
25
+
26
+ /**
27
+ * Resolve every global block referenced by `blocks`, following references
28
+ * nested inside other global blocks. Cycles are stopped by the visited set.
29
+ */
30
+ export async function resolveGlobalsDeep(
31
+ client: GlobalsReadClient,
32
+ blocks: BlockInstance[],
33
+ ): Promise<Record<string, BlockInstance[]>> {
34
+ const globals: Record<string, BlockInstance[]> = {};
35
+ let pending = Array.from(new Set(refKeysOf(blocks)));
36
+ let guard = 0;
37
+
38
+ while (pending.length > 0 && guard < 10) {
39
+ guard += 1;
40
+ const { data: gs } = await client
41
+ .from("global_sections")
42
+ .select("key, current_version_id")
43
+ .in("key", pending);
44
+ const rows = (gs ?? []) as { key: string; current_version_id: string | null }[];
45
+ const versionIds = rows.map((g) => g.current_version_id).filter((x): x is string => !!x);
46
+ const byId = new Map<string, BlockInstance[]>();
47
+ if (versionIds.length > 0) {
48
+ const { data: gvs } = await client
49
+ .from("global_section_versions")
50
+ .select("id, blocks")
51
+ .in("id", versionIds);
52
+ for (const v of (gvs ?? []) as { id: string; blocks: unknown }[]) {
53
+ byId.set(v.id, (v.blocks as unknown as BlockInstance[] | null) ?? []);
54
+ }
55
+ }
56
+
57
+ const next: string[] = [];
58
+ for (const key of pending) {
59
+ if (key in globals) continue;
60
+ const row = rows.find((r) => r.key === key);
61
+ const inner = row?.current_version_id ? (byId.get(row.current_version_id) ?? []) : [];
62
+ globals[key] = inner;
63
+ for (const k of refKeysOf(inner)) {
64
+ if (!(k in globals)) next.push(k);
65
+ }
66
+ }
67
+ pending = Array.from(new Set(next));
68
+ }
69
+
70
+ return globals;
71
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Host-agnostic auto-translation runner for CMS-backed localized JSONB fields.
3
+ *
4
+ * Rules (owned by `i18n/translation-meta`): manual translations are never
5
+ * overwritten, the source locale is never touched and no customer data is read.
6
+ * The host supplies its auth context, its brand prompt details and the tables to
7
+ * sweep; everything else is identical across sites.
8
+ */
9
+
10
+ import type { Json } from "../i18n/json";
11
+ import { collectMissing } from "../i18n/translation-meta";
12
+ import { assertStaff, type PlatformAuthContext } from "./guards";
13
+
14
+ export type TranslateMode = "missing" | "stale" | "force";
15
+
16
+ /** One table to sweep: its key column plus the localized JSONB columns. */
17
+ export type TranslateTableSpec = {
18
+ table: string;
19
+ keyCol: string;
20
+ jsonCols: string[];
21
+ };
22
+
23
+ /** Default sweep: content records and page SEO. */
24
+ export const DEFAULT_TRANSLATE_TABLES: TranslateTableSpec[] = [
25
+ { table: "content_sections", keyCol: "key", jsonCols: ["data"] },
26
+ { table: "page_seo", keyCol: "page_key", jsonCols: ["slugs", "seo"] },
27
+ ];
28
+
29
+ export type TranslateBrand = {
30
+ /** Brand name, kept unchanged by the translator. */
31
+ name: string;
32
+ /** One short sentence describing the site, for translation tone. */
33
+ description?: string;
34
+ /** Extra proper nouns that must stay untouched (places, product names). */
35
+ properNouns?: string[];
36
+ /** Human-readable source language, defaults to Swedish. */
37
+ sourceLanguage?: string;
38
+ };
39
+
40
+ export type TranslateOptions = {
41
+ target: string;
42
+ mode: TranslateMode;
43
+ brand: TranslateBrand;
44
+ tables?: TranslateTableSpec[];
45
+ /** AI gateway model id. */
46
+ model?: string;
47
+ /** Strings per request. */
48
+ batchSize?: number;
49
+ /** Gateway key; the host reads it from its own runtime env. */
50
+ apiKey: string;
51
+ /** Gateway chat-completions endpoint. */
52
+ endpoint?: string;
53
+ };
54
+
55
+ export type TranslateResult = {
56
+ ok: true;
57
+ translated: number;
58
+ updatedRows: number;
59
+ skippedManual: number;
60
+ mode: TranslateMode;
61
+ };
62
+
63
+ const DEFAULT_MODEL = "google/gemini-2.5-flash";
64
+ const DEFAULT_BATCH = 25;
65
+ const DEFAULT_ENDPOINT = "https://ai.gateway.lovable.dev/v1/chat/completions";
66
+
67
+ function systemPrompt(brand: TranslateBrand, target: string): string {
68
+ const source = brand.sourceLanguage ?? "Swedish";
69
+ const nouns = [brand.name, ...(brand.properNouns ?? [])].join(", ");
70
+ return [
71
+ `You are a professional translator for ${brand.name}${brand.description ? ` — ${brand.description}` : ""}.`,
72
+ `Translate each ${source} source string to ${target === "en" ? "natural, marketing-quality English" : target}.`,
73
+ "Preserve line breaks, HTML tags, {placeholders}, URLs, emails, prices, currency.",
74
+ `Keep proper nouns unchanged: ${nouns}.`,
75
+ "Do not translate URL slugs or numeric-only strings — return unchanged.",
76
+ 'Return ONLY: { "t": [ ...translations in the same order... ] }.',
77
+ ].join("\n");
78
+ }
79
+
80
+ /** Translate one batch of strings through the AI gateway. */
81
+ export async function translateBatchCore(
82
+ texts: string[],
83
+ opts: Pick<TranslateOptions, "apiKey" | "brand" | "target" | "model" | "endpoint">,
84
+ ): Promise<string[]> {
85
+ const res = await fetch(opts.endpoint ?? DEFAULT_ENDPOINT, {
86
+ method: "POST",
87
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.apiKey}` },
88
+ body: JSON.stringify({
89
+ model: opts.model ?? DEFAULT_MODEL,
90
+ messages: [
91
+ { role: "system", content: systemPrompt(opts.brand, opts.target) },
92
+ { role: "user", content: JSON.stringify({ strings: texts }) },
93
+ ],
94
+ response_format: { type: "json_object" },
95
+ }),
96
+ });
97
+ if (!res.ok) {
98
+ const body = await res.text();
99
+ if (res.status === 429) throw new Error("Rate limited — try again shortly.");
100
+ if (res.status === 402) throw new Error("AI credits exhausted.");
101
+ throw new Error(`AI gateway ${res.status}: ${body.slice(0, 300)}`);
102
+ }
103
+ const json = (await res.json()) as { choices?: { message?: { content?: string } }[] };
104
+ const content = json.choices?.[0]?.message?.content ?? "{}";
105
+ const parsed = JSON.parse(content) as { t?: unknown };
106
+ if (!Array.isArray(parsed.t) || parsed.t.length !== texts.length) {
107
+ throw new Error("Translation shape mismatch");
108
+ }
109
+ return parsed.t.map((v) => String(v));
110
+ }
111
+
112
+ type RowPlan = {
113
+ table: string;
114
+ keyCol: string;
115
+ keyVal: string | number;
116
+ patches: { col: string; patch: (m: Map<string, string>) => Json }[];
117
+ };
118
+
119
+ /**
120
+ * Staff-only: fill missing or stale translations for the target locale across
121
+ * every configured table. Runs as the signed-in user, so RLS applies.
122
+ */
123
+ export async function translateMissingCmsCore(
124
+ context: PlatformAuthContext & {
125
+ supabase: { from: (table: string) => any }; // eslint-disable-line @typescript-eslint/no-explicit-any
126
+ },
127
+ opts: TranslateOptions,
128
+ ): Promise<TranslateResult> {
129
+ await assertStaff(context);
130
+ if (!opts.apiKey) throw new Error("AI gateway key missing");
131
+
132
+ const tables = opts.tables ?? DEFAULT_TRANSLATE_TABLES;
133
+ const { target, mode } = opts;
134
+
135
+ // 1) Collect every string needing translation across all rows.
136
+ const needSet = new Set<string>();
137
+ const plans: RowPlan[] = [];
138
+ let skippedManual = 0;
139
+
140
+ for (const spec of tables) {
141
+ const cols = [spec.keyCol, ...spec.jsonCols].join(", ");
142
+ const { data: rows, error } = (await context.supabase.from(spec.table).select(cols)) as {
143
+ data: Record<string, unknown>[] | null;
144
+ error: { message: string } | null;
145
+ };
146
+ if (error) throw new Error(`${spec.table}: ${error.message}`);
147
+ for (const row of rows ?? []) {
148
+ const plan: RowPlan = {
149
+ table: spec.table,
150
+ keyCol: spec.keyCol,
151
+ keyVal: row[spec.keyCol] as string | number,
152
+ patches: [],
153
+ };
154
+ let rowHasWork = false;
155
+ for (const col of spec.jsonCols) {
156
+ const { needs, skippedManual: sm, patch } = collectMissing(row[col], target, mode);
157
+ skippedManual += sm;
158
+ if (needs.length > 0) rowHasWork = true;
159
+ for (const n of needs) needSet.add(n);
160
+ plan.patches.push({ col, patch });
161
+ }
162
+ if (rowHasWork) plans.push(plan);
163
+ }
164
+ }
165
+
166
+ const needs = [...needSet];
167
+ if (needs.length === 0) {
168
+ return { ok: true, translated: 0, updatedRows: 0, skippedManual, mode };
169
+ }
170
+
171
+ // 2) Translate in batches.
172
+ const map = new Map<string, string>();
173
+ const batchSize = opts.batchSize ?? DEFAULT_BATCH;
174
+ for (let i = 0; i < needs.length; i += batchSize) {
175
+ const chunk = needs.slice(i, i + batchSize);
176
+ const out = await translateBatchCore(chunk, opts);
177
+ chunk.forEach((s, idx) => map.set(s, out[idx]));
178
+ }
179
+
180
+ // 3) Patch and write rows back.
181
+ let updatedRows = 0;
182
+ for (const p of plans) {
183
+ const update: Record<string, Json> = {};
184
+ for (const { col, patch } of p.patches) update[col] = patch(map);
185
+ const { error } = (await context.supabase
186
+ .from(p.table)
187
+ .update(update)
188
+ .eq(p.keyCol, p.keyVal)) as { error: { message: string } | null };
189
+ if (error) throw new Error(`${p.table} ${p.keyVal}: ${error.message}`);
190
+ updatedRows += 1;
191
+ }
192
+
193
+ return { ok: true, translated: map.size, updatedRows, skippedManual, mode };
194
+ }