@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 +18 -0
- package/package.json +6 -2
- package/src/server/content-sections-core.ts +68 -0
- package/src/server/pages-core.ts +799 -0
- package/src/server/resolve-globals.ts +71 -0
- package/src/server/translate-db-core.ts +194 -0
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.
|
|
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
|
+
}
|