@bygghammar/cms-platform 0.32.2 → 0.33.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 +4 -2
- package/src/server/pages-core.ts +799 -0
- package/src/server/resolve-globals.ts +71 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.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,9 @@
|
|
|
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"
|
|
145
147
|
},
|
|
146
148
|
"dependencies": {
|
|
147
149
|
"fflate": "^0.8.3"
|
|
@@ -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
|
+
}
|