@pramen/cms 0.0.14 → 0.0.16
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/dist/index.d.ts +1076 -0
- package/dist/index.js +1360 -0
- package/dist/react.d.ts +41 -0
- package/dist/react.js +30 -0
- package/package.json +6 -3
- package/src/index.ts +34 -1
package/dist/index.js
ADDED
|
@@ -0,0 +1,1360 @@
|
|
|
1
|
+
// @pramen/cms — a Drupal-Paragraphs-style block/page builder, built as an ordinary
|
|
2
|
+
// pramen app fragment (schema + handlers + ACL + tasks). Nothing here is a new runtime
|
|
3
|
+
// primitive: it composes the ones pramen already ships — `t.json()` for block field
|
|
4
|
+
// payloads, `t.fileRef()`+R2 for media, relations for page↔block traversal, the ACL for
|
|
5
|
+
// editor RBAC, and `ctx.tasks` (the transactional outbox) for scheduled publish/unpublish.
|
|
6
|
+
//
|
|
7
|
+
// Model (borrowed from WollyCMS / Drupal Paragraphs):
|
|
8
|
+
// - a BLOCK TYPE is a schema — a slug + a recursive field schema (data-driven, so a
|
|
9
|
+
// webmaster adds a type with no deploy);
|
|
10
|
+
// - a BLOCK is an instance — content matching a type's field schema (optionally reusable);
|
|
11
|
+
// - a PAGE has a content type, which declares named REGIONS (each with an allow-list of
|
|
12
|
+
// block types) and optional DEFAULT BLOCKS;
|
|
13
|
+
// - a PAGE_BLOCK places a block into a page's region at a position, optionally as a
|
|
14
|
+
// SHARED block with per-placement field OVERRIDES.
|
|
15
|
+
//
|
|
16
|
+
// Publishing writes a fully-assembled JSON snapshot into a revision; the public content
|
|
17
|
+
// API serves that snapshot (fast, and it never exposes unpublished block rows directly).
|
|
18
|
+
// Rendering stays the frontend's job — see `@pramen/cms/react` (BlockRenderer).
|
|
19
|
+
//
|
|
20
|
+
// Usage:
|
|
21
|
+
// import { cmsSchema, cmsHandlers, cmsPolicies, cmsTasks } from "@pramen/cms";
|
|
22
|
+
// const schema = defineSchema({ ...cmsSchema, ...yourEntities });
|
|
23
|
+
// const handlers = { ...cmsHandlers, ...yourHandlers };
|
|
24
|
+
// const acl = [ role("anonymous", [...cmsPolicies().public]),
|
|
25
|
+
// role("editor", [...cmsPolicies().editor]) ];
|
|
26
|
+
// const app = { schema, handlers, acl, tasks: { ...cmsTasks } };
|
|
27
|
+
import { Entity, query, mutation, primaryKey, generated, notNull, unique, indexed, defaultTo, expr, policy, allow, BadRequest, Forbidden, PramenError, } from "@pramen/server";
|
|
28
|
+
/** Declare a typed block type. Pass `fields as const` to preserve the literals so
|
|
29
|
+
* `BlockFieldsOf<typeof def>` infers the field shape:
|
|
30
|
+
*
|
|
31
|
+
* const hero = defineBlockType("hero", [
|
|
32
|
+
* { name: "heading", type: "text", required: true },
|
|
33
|
+
* { name: "image", type: "media" },
|
|
34
|
+
* ] as const);
|
|
35
|
+
* type HeroFields = BlockFieldsOf<typeof hero>; // { heading: string; image: ResolvedMedia|null|undefined }
|
|
36
|
+
*
|
|
37
|
+
* Spread `hero` (minus fieldsSchema key naming) into `createBlockType`, and use
|
|
38
|
+
* `BlockFieldsOf<typeof hero>` to type the block's React component. */
|
|
39
|
+
export function defineBlockType(slug, fields, opts = {}) {
|
|
40
|
+
return { slug, name: opts.name ?? slug, fieldsSchema: fields, description: opts.description, icon: opts.icon, category: opts.category };
|
|
41
|
+
}
|
|
42
|
+
// --- codegen: emit .ts field interfaces from DB-stored block schemas ------------------
|
|
43
|
+
//
|
|
44
|
+
// The data-driven half (webmaster-created block types) has no static type. This is the
|
|
45
|
+
// runtime mirror of `InferBlockFields`: read `cms_block_types.fieldsSchema` rows and emit a
|
|
46
|
+
// `.ts` module of per-slug field interfaces + a `BlockFieldsBySlug` registry. A future
|
|
47
|
+
// `pramen cms codegen` CLI command fetches the rows over HTTP and writes the output.
|
|
48
|
+
const pascal = (s) => s.replace(/(^|[_-])(\w)/g, (_m, _sep, c) => c.toUpperCase());
|
|
49
|
+
function tsTypeOf(f) {
|
|
50
|
+
switch (f.type) {
|
|
51
|
+
case "text":
|
|
52
|
+
case "textarea":
|
|
53
|
+
case "url":
|
|
54
|
+
case "select":
|
|
55
|
+
case "date":
|
|
56
|
+
case "datetime":
|
|
57
|
+
return "string";
|
|
58
|
+
case "richtext":
|
|
59
|
+
return "RichText";
|
|
60
|
+
case "number":
|
|
61
|
+
return "number";
|
|
62
|
+
case "boolean":
|
|
63
|
+
return "boolean";
|
|
64
|
+
case "media":
|
|
65
|
+
return "ResolvedMedia | null";
|
|
66
|
+
case "group":
|
|
67
|
+
return `{ ${(f.fields ?? []).map(tsFieldLine).join(" ")} }`;
|
|
68
|
+
case "repeater":
|
|
69
|
+
return `Array<{ ${(f.fields ?? []).map(tsFieldLine).join(" ")} }>`;
|
|
70
|
+
default:
|
|
71
|
+
return "unknown";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const tsFieldLine = (f) => `${JSON.stringify(f.name)}${f.required ? "" : "?"}: ${tsTypeOf(f)};`;
|
|
75
|
+
/** Emit a `.ts` module of per-slug field interfaces + a `BlockFieldsBySlug` registry from
|
|
76
|
+
* DB-stored block types (`{ slug, fieldsSchema }` rows). The runtime counterpart to the
|
|
77
|
+
* compile-time `InferBlockFields`, for webmaster-authored (data-driven) block types. */
|
|
78
|
+
export function generateBlockTypes(blockTypes) {
|
|
79
|
+
const interfaces = blockTypes
|
|
80
|
+
.map((bt) => {
|
|
81
|
+
const fields = Array.isArray(bt.fieldsSchema) ? bt.fieldsSchema : [];
|
|
82
|
+
const body = fields.map((f) => ` ${tsFieldLine(f)}`).join("\n");
|
|
83
|
+
return `export interface ${pascal(bt.slug)}Fields {\n${body}\n}`;
|
|
84
|
+
})
|
|
85
|
+
.join("\n\n");
|
|
86
|
+
const registry = blockTypes.map((bt) => ` ${JSON.stringify(bt.slug)}: ${pascal(bt.slug)}Fields;`).join("\n");
|
|
87
|
+
return (`// AUTO-GENERATED by @pramen/cms — do not edit.\n` +
|
|
88
|
+
`import type { ResolvedMedia, RichText } from "@pramen/cms";\n\n` +
|
|
89
|
+
`${interfaces}\n\nexport interface BlockFieldsBySlug {\n${registry}\n}\n`);
|
|
90
|
+
}
|
|
91
|
+
// --- schema fragment: spread into your defineSchema so the tables migrate --------
|
|
92
|
+
/** The block/page builder tables. All in the default partition (relations can't cross
|
|
93
|
+
* partitions). Prefixed `cms_` to avoid colliding with your own entities. */
|
|
94
|
+
export const cmsSchema = {
|
|
95
|
+
cms_content_types: Entity((t) => ({
|
|
96
|
+
id: primaryKey(generated(t.uuid())),
|
|
97
|
+
name: notNull(t.text()),
|
|
98
|
+
slug: unique(notNull(t.text())),
|
|
99
|
+
description: t.text(),
|
|
100
|
+
fieldsSchema: t.json(), // FieldDefinition[] for page-level fields
|
|
101
|
+
regions: t.json(), // RegionDefinition[]
|
|
102
|
+
defaultBlocks: t.json(), // DefaultBlockDefinition[]
|
|
103
|
+
createdAt: defaultTo(t.text(), expr.now()),
|
|
104
|
+
})),
|
|
105
|
+
cms_block_types: Entity((t) => ({
|
|
106
|
+
id: primaryKey(generated(t.uuid())),
|
|
107
|
+
name: notNull(t.text()),
|
|
108
|
+
slug: unique(notNull(t.text())),
|
|
109
|
+
description: t.text(),
|
|
110
|
+
fieldsSchema: t.json(), // FieldDefinition[]
|
|
111
|
+
icon: t.text(),
|
|
112
|
+
category: t.text(),
|
|
113
|
+
createdAt: defaultTo(t.text(), expr.now()),
|
|
114
|
+
})),
|
|
115
|
+
cms_blocks: Entity((t) => ({
|
|
116
|
+
id: primaryKey(generated(t.uuid())),
|
|
117
|
+
typeId: notNull(t.uuid()),
|
|
118
|
+
title: t.text(),
|
|
119
|
+
fields: t.json(), // content matching the block type's fieldsSchema
|
|
120
|
+
isReusable: defaultTo(t.bool(), false),
|
|
121
|
+
createdAt: defaultTo(t.text(), expr.now()),
|
|
122
|
+
updatedAt: defaultTo(t.text(), expr.now()),
|
|
123
|
+
}), (r) => ({ type: r.belongsTo("cms_block_types", "typeId") })),
|
|
124
|
+
cms_pages: Entity((t) => ({
|
|
125
|
+
id: primaryKey(generated(t.uuid())),
|
|
126
|
+
typeId: notNull(t.uuid()),
|
|
127
|
+
title: notNull(t.text()),
|
|
128
|
+
// NOT globally unique — a slug is unique PER LOCALE (`/en/about` + `/cs/about`).
|
|
129
|
+
// pramen's unique() is single-column only, so (slug, locale) uniqueness is enforced
|
|
130
|
+
// in createPage/createTranslation; this index just speeds the lookup.
|
|
131
|
+
slug: indexed(notNull(t.text())),
|
|
132
|
+
status: defaultTo(t.text(), "draft"), // draft | published | archived
|
|
133
|
+
locale: defaultTo(t.text(), "en"),
|
|
134
|
+
// Links a page to its translations: all locales of one logical page share this id.
|
|
135
|
+
// Auto-minted for a standalone page; a translation is created with the source's id.
|
|
136
|
+
translationGroupId: generated(t.uuid()),
|
|
137
|
+
fields: t.json(),
|
|
138
|
+
publishedAt: t.text(),
|
|
139
|
+
scheduledAt: t.text(),
|
|
140
|
+
unpublishAt: t.text(),
|
|
141
|
+
// The revision the public content API serves — set on publish. A direct pointer
|
|
142
|
+
// (not "latest by timestamp") so selection is deterministic even when two publishes
|
|
143
|
+
// land in the same second (expr.now() is second-precision).
|
|
144
|
+
currentRevisionId: t.uuid(),
|
|
145
|
+
// SEO
|
|
146
|
+
metaTitle: t.text(),
|
|
147
|
+
metaDescription: t.text(),
|
|
148
|
+
canonicalUrl: t.text(),
|
|
149
|
+
robots: t.text(), // e.g. "noindex, nofollow"
|
|
150
|
+
ogTitle: t.text(),
|
|
151
|
+
ogDescription: t.text(),
|
|
152
|
+
ogImage: t.uuid(), // a cms_media id, resolved to a URL at assemble time
|
|
153
|
+
structuredData: t.json(), // JSON-LD, emitted as-is into <head>
|
|
154
|
+
createdAt: defaultTo(t.text(), expr.now()),
|
|
155
|
+
updatedAt: defaultTo(t.text(), expr.now()),
|
|
156
|
+
}), (r) => ({
|
|
157
|
+
type: r.belongsTo("cms_content_types", "typeId"),
|
|
158
|
+
placements: r.hasMany("cms_page_blocks", "pageId"),
|
|
159
|
+
})),
|
|
160
|
+
cms_page_blocks: Entity((t) => ({
|
|
161
|
+
id: primaryKey(generated(t.uuid())),
|
|
162
|
+
pageId: notNull(t.uuid()),
|
|
163
|
+
blockId: notNull(t.uuid()),
|
|
164
|
+
region: notNull(t.text()),
|
|
165
|
+
position: notNull(t.int()),
|
|
166
|
+
isShared: defaultTo(t.bool(), false),
|
|
167
|
+
overrides: t.json(), // per-placement field overrides (merged over the block's fields)
|
|
168
|
+
}), (r) => ({
|
|
169
|
+
page: r.belongsTo("cms_pages", "pageId"),
|
|
170
|
+
block: r.belongsTo("cms_blocks", "blockId"),
|
|
171
|
+
})),
|
|
172
|
+
cms_page_revisions: Entity((t) => ({
|
|
173
|
+
id: primaryKey(generated(t.uuid())),
|
|
174
|
+
pageId: notNull(t.uuid()),
|
|
175
|
+
title: t.text(),
|
|
176
|
+
status: t.text(),
|
|
177
|
+
snapshot: t.json(), // the fully-assembled page + regions at publish time
|
|
178
|
+
note: t.text(),
|
|
179
|
+
actor: t.text(), // the identity.userId that published (null = system/unknown)
|
|
180
|
+
createdAt: defaultTo(t.text(), expr.now()),
|
|
181
|
+
}), (r) => ({ page: r.belongsTo("cms_pages", "pageId") })),
|
|
182
|
+
// Append-only audit trail of workflow transitions (who moved a page between states,
|
|
183
|
+
// when, with an optional note). In the DEFAULT partition — the transition handlers write
|
|
184
|
+
// it synchronously (transactional with the state change, and they hold the actor from
|
|
185
|
+
// ctx.identity); a handler can't write across a partition boundary, so an isolated
|
|
186
|
+
// audit partition isn't reachable from here.
|
|
187
|
+
cms_audit: Entity((t) => ({
|
|
188
|
+
id: primaryKey(generated(t.uuid())),
|
|
189
|
+
pageId: indexed(t.uuid()),
|
|
190
|
+
action: notNull(t.text()), // submit | approve | reject | publish | unpublish
|
|
191
|
+
fromStatus: t.text(),
|
|
192
|
+
toStatus: t.text(),
|
|
193
|
+
actor: t.text(),
|
|
194
|
+
note: t.text(),
|
|
195
|
+
createdAt: defaultTo(t.text(), expr.now()),
|
|
196
|
+
})),
|
|
197
|
+
// Media: a fileRef column holds only R2 metadata; bytes live in R2, uploaded via
|
|
198
|
+
// ctx.files + the Worker /files/* route. Block `fields` reference a media id.
|
|
199
|
+
cms_media: Entity((t) => ({
|
|
200
|
+
id: primaryKey(generated(t.uuid())),
|
|
201
|
+
file: t.fileRef(),
|
|
202
|
+
alt: t.text(),
|
|
203
|
+
createdAt: defaultTo(t.text(), expr.now()),
|
|
204
|
+
})),
|
|
205
|
+
};
|
|
206
|
+
/** Validate a block/page's `fields` payload against a field schema, throwing a 400 on
|
|
207
|
+
* the first violation. Recursive (repeater/group). Lenient on unknown field types. */
|
|
208
|
+
/** A calendar date, `YYYY-MM-DD` (what an <input type="date"> emits) — must also parse. */
|
|
209
|
+
function isDateString(v) {
|
|
210
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(v) && Number.isFinite(Date.parse(v));
|
|
211
|
+
}
|
|
212
|
+
/** A date-time: `YYYY-MM-DDTHH:MM[:SS[.sss]][Z|±HH:MM]` (ISO 8601 / datetime-local). */
|
|
213
|
+
function isDateTimeString(v) {
|
|
214
|
+
return /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/.test(v) && Number.isFinite(Date.parse(v));
|
|
215
|
+
}
|
|
216
|
+
export function validateFields(schema, values, path = "", opts = {}) {
|
|
217
|
+
const requireRequired = opts.requireRequired !== false;
|
|
218
|
+
const defs = Array.isArray(schema) ? schema : [];
|
|
219
|
+
const obj = (values ?? {});
|
|
220
|
+
if (typeof obj !== "object" || Array.isArray(obj))
|
|
221
|
+
throw new BadRequest(`${path || "fields"} must be an object`);
|
|
222
|
+
for (const def of defs) {
|
|
223
|
+
const at = path ? `${path}.${def.name}` : def.name;
|
|
224
|
+
const v = obj[def.name];
|
|
225
|
+
const missing = v === undefined || v === null || v === "";
|
|
226
|
+
if (missing) {
|
|
227
|
+
if (def.required && requireRequired)
|
|
228
|
+
throw new BadRequest(`field '${at}' is required`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
switch (def.type) {
|
|
232
|
+
case "text":
|
|
233
|
+
case "textarea":
|
|
234
|
+
case "url":
|
|
235
|
+
case "select":
|
|
236
|
+
if (typeof v !== "string")
|
|
237
|
+
throw new BadRequest(`field '${at}' must be a string`);
|
|
238
|
+
if (def.type === "select" && def.options && !def.options.includes(v)) {
|
|
239
|
+
throw new BadRequest(`field '${at}' must be one of: ${def.options.join(", ")}`);
|
|
240
|
+
}
|
|
241
|
+
break;
|
|
242
|
+
case "richtext":
|
|
243
|
+
if (typeof v !== "string" && typeof v !== "object")
|
|
244
|
+
throw new BadRequest(`field '${at}' must be rich text`);
|
|
245
|
+
break;
|
|
246
|
+
case "number":
|
|
247
|
+
if (typeof v !== "number")
|
|
248
|
+
throw new BadRequest(`field '${at}' must be a number`);
|
|
249
|
+
break;
|
|
250
|
+
case "boolean":
|
|
251
|
+
if (typeof v !== "boolean")
|
|
252
|
+
throw new BadRequest(`field '${at}' must be a boolean`);
|
|
253
|
+
break;
|
|
254
|
+
case "date":
|
|
255
|
+
if (typeof v !== "string" || !isDateString(v))
|
|
256
|
+
throw new BadRequest(`field '${at}' must be a date (YYYY-MM-DD)`);
|
|
257
|
+
break;
|
|
258
|
+
case "datetime":
|
|
259
|
+
if (typeof v !== "string" || !isDateTimeString(v))
|
|
260
|
+
throw new BadRequest(`field '${at}' must be a date-time (ISO 8601)`);
|
|
261
|
+
break;
|
|
262
|
+
case "media":
|
|
263
|
+
// Media ids are uuids (strings) — reject numbers so the value always resolves
|
|
264
|
+
// (collectMediaIds/resolveMediaFields only handle string ids).
|
|
265
|
+
if (typeof v !== "string")
|
|
266
|
+
throw new BadRequest(`field '${at}' must be a media id (string)`);
|
|
267
|
+
break;
|
|
268
|
+
case "group":
|
|
269
|
+
validateFields(def.fields, v, at, opts);
|
|
270
|
+
break;
|
|
271
|
+
case "repeater": {
|
|
272
|
+
if (!Array.isArray(v))
|
|
273
|
+
throw new BadRequest(`field '${at}' must be a list`);
|
|
274
|
+
if (def.min != null && v.length < def.min)
|
|
275
|
+
throw new BadRequest(`field '${at}' needs at least ${def.min} item(s)`);
|
|
276
|
+
if (def.max != null && v.length > def.max)
|
|
277
|
+
throw new BadRequest(`field '${at}' allows at most ${def.max} item(s)`);
|
|
278
|
+
v.forEach((item, i) => validateFields(def.fields, item, `${at}[${i}]`, opts));
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
default:
|
|
282
|
+
break; // unknown type — don't block
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/** The public serving path for a media blob (relative; the client resolves it against
|
|
287
|
+
* its base). Served by the Worker's public `GET /media/<key>` route. */
|
|
288
|
+
export function mediaPath(key) {
|
|
289
|
+
return `/media/${key}`;
|
|
290
|
+
}
|
|
291
|
+
/** Build a URL for a media blob, optionally with Cloudflare Image Resizing transforms
|
|
292
|
+
* (`/cdn-cgi/image/<opts>/…`). With no transform opts it's just `mediaPath` (optionally
|
|
293
|
+
* prefixed by `origin`). Transforms need the deploy's origin to resolve the source path. */
|
|
294
|
+
export function imageUrl(key, opts = {}) {
|
|
295
|
+
const path = mediaPath(key);
|
|
296
|
+
const origin = opts.origin ?? "";
|
|
297
|
+
const hasTransform = opts.width || opts.height || opts.quality || opts.format;
|
|
298
|
+
if (!hasTransform)
|
|
299
|
+
return `${origin}${path}`;
|
|
300
|
+
const params = [];
|
|
301
|
+
if (opts.width)
|
|
302
|
+
params.push(`width=${opts.width}`);
|
|
303
|
+
if (opts.height)
|
|
304
|
+
params.push(`height=${opts.height}`);
|
|
305
|
+
if (opts.quality)
|
|
306
|
+
params.push(`quality=${opts.quality}`);
|
|
307
|
+
params.push(`format=${opts.format ?? "auto"}`);
|
|
308
|
+
return `${origin}/cdn-cgi/image/${params.join(",")}${path}`;
|
|
309
|
+
}
|
|
310
|
+
/** Collect the media ids referenced by a fields payload, walking group/repeater nesting. */
|
|
311
|
+
function collectMediaIds(fields, schema, acc) {
|
|
312
|
+
if (!Array.isArray(schema))
|
|
313
|
+
return;
|
|
314
|
+
for (const def of schema) {
|
|
315
|
+
const v = fields[def.name];
|
|
316
|
+
if (v == null)
|
|
317
|
+
continue;
|
|
318
|
+
if (def.type === "media") {
|
|
319
|
+
if (typeof v === "string")
|
|
320
|
+
acc.add(v);
|
|
321
|
+
}
|
|
322
|
+
else if (def.type === "group") {
|
|
323
|
+
collectMediaIds(asObj(v), def.fields, acc);
|
|
324
|
+
}
|
|
325
|
+
else if (def.type === "repeater" && Array.isArray(v)) {
|
|
326
|
+
for (const item of v)
|
|
327
|
+
collectMediaIds(asObj(item), def.fields, acc);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/** Return a copy of `fields` with every `"media"` field resolved from its id to a
|
|
332
|
+
* `ResolvedMedia` (or null), recursing into group/repeater nesting. */
|
|
333
|
+
function resolveMediaFields(fields, schema, mediaById) {
|
|
334
|
+
if (!Array.isArray(schema))
|
|
335
|
+
return fields;
|
|
336
|
+
const out = { ...fields };
|
|
337
|
+
for (const def of schema) {
|
|
338
|
+
const v = out[def.name];
|
|
339
|
+
if (v == null)
|
|
340
|
+
continue;
|
|
341
|
+
if (def.type === "media") {
|
|
342
|
+
if (typeof v === "string")
|
|
343
|
+
out[def.name] = mediaById.get(v) ?? null;
|
|
344
|
+
}
|
|
345
|
+
else if (def.type === "group") {
|
|
346
|
+
out[def.name] = resolveMediaFields(asObj(v), def.fields, mediaById);
|
|
347
|
+
}
|
|
348
|
+
else if (def.type === "repeater" && Array.isArray(v)) {
|
|
349
|
+
out[def.name] = v.map((item) => resolveMediaFields(asObj(item), def.fields, mediaById));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
const cdb = (ctx) => ctx.db;
|
|
355
|
+
const notFound = (what) => new PramenError(`${what} not found`, 404, "not_found");
|
|
356
|
+
const asObj = (v) => (v && typeof v === "object" ? v : {});
|
|
357
|
+
// Timestamps in the SAME shape as the `expr.now()` column default (`datetime('now')`:
|
|
358
|
+
// "YYYY-MM-DD HH:MM:SS", UTC, second precision) so a column's insert-default and its
|
|
359
|
+
// handler-written updates stay lexically comparable (an ISO `T`/`Z` string sorts wrong).
|
|
360
|
+
const nowStamp = () => new Date().toISOString().slice(0, 19).replace("T", " ");
|
|
361
|
+
const isEditor = (ctx, roles) => {
|
|
362
|
+
const held = ctx.identity?.roles ?? (ctx.identity?.role ? [ctx.identity.role] : []);
|
|
363
|
+
return held.some((r) => roles.includes(r));
|
|
364
|
+
};
|
|
365
|
+
/** Assemble a page LIVE from its placements/blocks/types, grouped by region and ordered
|
|
366
|
+
* by position, merging each shared placement's `overrides` over its block's fields. */
|
|
367
|
+
async function assembleLive(db, page) {
|
|
368
|
+
const placements = await db.find({
|
|
369
|
+
from: "cms_page_blocks",
|
|
370
|
+
where: { pageId: page.id },
|
|
371
|
+
orderBy: [{ column: "region" }, { column: "position" }],
|
|
372
|
+
with: { block: true },
|
|
373
|
+
});
|
|
374
|
+
const typeIds = [...new Set(placements.map((p) => asObj(p.block).typeId).filter((v) => typeof v === "string"))];
|
|
375
|
+
const types = typeIds.length ? await db.find({ from: "cms_block_types", where: { id: { in: typeIds } } }) : [];
|
|
376
|
+
const typeById = new Map(types.map((t) => [t.id, { slug: t.slug, fieldsSchema: t.fieldsSchema }]));
|
|
377
|
+
// Merge each placement's fields (base + shared overrides), then resolve `"media"`
|
|
378
|
+
// fields (id → ResolvedMedia) in one batched lookup across the whole page.
|
|
379
|
+
const merged = placements.map((p) => {
|
|
380
|
+
const block = asObj(p.block);
|
|
381
|
+
const fields = { ...asObj(block.fields), ...(p.isShared ? asObj(p.overrides) : {}) };
|
|
382
|
+
return { p, block, fields, schema: typeById.get(String(block.typeId))?.fieldsSchema };
|
|
383
|
+
});
|
|
384
|
+
const mediaIds = new Set();
|
|
385
|
+
for (const m of merged)
|
|
386
|
+
collectMediaIds(m.fields, m.schema, mediaIds);
|
|
387
|
+
const mediaById = new Map();
|
|
388
|
+
if (mediaIds.size) {
|
|
389
|
+
const rows = await db.find({ from: "cms_media", where: { id: { in: [...mediaIds] } } });
|
|
390
|
+
for (const r of rows) {
|
|
391
|
+
const file = asObj(r.file);
|
|
392
|
+
mediaById.set(String(r.id), {
|
|
393
|
+
id: String(r.id),
|
|
394
|
+
key: String(file.key ?? ""),
|
|
395
|
+
url: mediaPath(String(file.key ?? "")),
|
|
396
|
+
alt: r.alt ?? null,
|
|
397
|
+
contentType: file.contentType ?? null,
|
|
398
|
+
filename: file.filename ?? null,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const regions = {};
|
|
403
|
+
for (const m of merged) {
|
|
404
|
+
const region = String(m.p.region);
|
|
405
|
+
(regions[region] ??= []).push({
|
|
406
|
+
id: String(m.p.id),
|
|
407
|
+
block_id: String(m.block.id),
|
|
408
|
+
block_type: typeById.get(String(m.block.typeId))?.slug ?? "unknown",
|
|
409
|
+
title: m.block.title ?? null,
|
|
410
|
+
fields: resolveMediaFields(m.fields, m.schema, mediaById),
|
|
411
|
+
is_shared: Boolean(m.p.isShared),
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
const [translations, ogImage] = await Promise.all([siblingTranslations(db, page), resolveMediaId(db, page.ogImage)]);
|
|
415
|
+
return { page: pageMeta(page, translations, ogImage), regions };
|
|
416
|
+
}
|
|
417
|
+
/** Resolve a single media id to a ResolvedMedia (for og:image etc.), or null. */
|
|
418
|
+
async function resolveMediaId(db, id) {
|
|
419
|
+
if (typeof id !== "string" || !id)
|
|
420
|
+
return null;
|
|
421
|
+
const rows = await db.find({ from: "cms_media", where: { id }, limit: 1 });
|
|
422
|
+
if (!rows[0])
|
|
423
|
+
return null;
|
|
424
|
+
const file = asObj(rows[0].file);
|
|
425
|
+
return {
|
|
426
|
+
id: String(rows[0].id),
|
|
427
|
+
key: String(file.key ?? ""),
|
|
428
|
+
url: mediaPath(String(file.key ?? "")),
|
|
429
|
+
alt: rows[0].alt ?? null,
|
|
430
|
+
contentType: file.contentType ?? null,
|
|
431
|
+
filename: file.filename ?? null,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
/** Publish a page: assemble a snapshot, write a revision (recording the actor), and point
|
|
435
|
+
* the page at it. Shared by publishPage, approve, and the scheduled cms:publish task.
|
|
436
|
+
* `clearSchedule` (a MANUAL publish) also clears the pending auto-unpublish token so a
|
|
437
|
+
* stale scheduled unpublish can't later archive the page behind the editor's back; the
|
|
438
|
+
* SCHEDULED cms:publish task passes false so a publish+unpublish pair both still fire. */
|
|
439
|
+
async function doPublish(db, page, actor, note, clearSchedule = false) {
|
|
440
|
+
const now = nowStamp();
|
|
441
|
+
const snapshot = await assembleLive(db, { ...page, status: "published" });
|
|
442
|
+
snapshot.page.status = "published";
|
|
443
|
+
const rev = await db.insert("cms_page_revisions", { pageId: page.id, title: page.title, status: "published", snapshot, note: note ?? null, actor });
|
|
444
|
+
const patch = { status: "published", publishedAt: now, scheduledAt: null, currentRevisionId: rev.id, updatedAt: now };
|
|
445
|
+
if (clearSchedule)
|
|
446
|
+
patch.unpublishAt = null;
|
|
447
|
+
return db.update("cms_pages", String(page.id), patch);
|
|
448
|
+
}
|
|
449
|
+
/** Published sibling translations of a page (other locales in the same translation group),
|
|
450
|
+
* for hreflang. Excludes the page itself. */
|
|
451
|
+
async function siblingTranslations(db, page) {
|
|
452
|
+
const group = page.translationGroupId;
|
|
453
|
+
if (!group)
|
|
454
|
+
return [];
|
|
455
|
+
const rows = await db.find({ from: "cms_pages", where: { translationGroupId: group, status: "published" } });
|
|
456
|
+
return rows
|
|
457
|
+
.filter((r) => String(r.id) !== String(page.id))
|
|
458
|
+
.map((r) => ({ locale: String(r.locale ?? "en"), slug: String(r.slug) }));
|
|
459
|
+
}
|
|
460
|
+
/** Project a page row to the public AssembledPage.page shape. */
|
|
461
|
+
function pageMeta(page, translations = [], ogImage = null) {
|
|
462
|
+
const metaTitle = page.metaTitle ?? null;
|
|
463
|
+
const metaDescription = page.metaDescription ?? null;
|
|
464
|
+
return {
|
|
465
|
+
id: String(page.id),
|
|
466
|
+
title: String(page.title),
|
|
467
|
+
slug: String(page.slug),
|
|
468
|
+
status: String(page.status),
|
|
469
|
+
locale: String(page.locale ?? "en"),
|
|
470
|
+
translationGroupId: page.translationGroupId ?? null,
|
|
471
|
+
translations,
|
|
472
|
+
fields: page.fields ?? null,
|
|
473
|
+
metaTitle,
|
|
474
|
+
metaDescription,
|
|
475
|
+
seo: {
|
|
476
|
+
metaTitle,
|
|
477
|
+
metaDescription,
|
|
478
|
+
canonicalUrl: page.canonicalUrl ?? null,
|
|
479
|
+
robots: page.robots ?? null,
|
|
480
|
+
ogTitle: page.ogTitle ?? null,
|
|
481
|
+
ogDescription: page.ogDescription ?? null,
|
|
482
|
+
ogImage,
|
|
483
|
+
structuredData: page.structuredData ?? null,
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
async function nextPosition(db, pageId, region) {
|
|
488
|
+
const rows = await db.exec("SELECT COALESCE(MAX(position), -1) AS m FROM cms_page_blocks WHERE pageId = ? AND region = ?", pageId, region);
|
|
489
|
+
return Number(rows[0]?.m ?? -1) + 1;
|
|
490
|
+
}
|
|
491
|
+
async function loadBlockTypeBySlug(db, slug) {
|
|
492
|
+
const rows = await db.find({ from: "cms_block_types", where: { slug }, limit: 1 });
|
|
493
|
+
if (!rows[0])
|
|
494
|
+
throw new BadRequest(`unknown block type '${slug}'`);
|
|
495
|
+
return rows[0];
|
|
496
|
+
}
|
|
497
|
+
async function assertRegionAllows(db, page, region, blockTypeSlug) {
|
|
498
|
+
const ctRows = await db.find({ from: "cms_content_types", where: { id: page.typeId }, limit: 1 });
|
|
499
|
+
const regions = ctRows[0]?.regions ?? [];
|
|
500
|
+
const def = regions.find((r) => r.name === region);
|
|
501
|
+
if (!def)
|
|
502
|
+
throw new BadRequest(`region '${region}' is not defined on this page's content type`);
|
|
503
|
+
// A non-empty allowedTypes restricts; null/undefined OR an empty array means "any type"
|
|
504
|
+
// (matching the editor, which treats `[]` as unrestricted — otherwise the region is unusable).
|
|
505
|
+
if (def.allowedTypes && def.allowedTypes.length && !def.allowedTypes.includes(blockTypeSlug)) {
|
|
506
|
+
throw new BadRequest(`block type '${blockTypeSlug}' is not allowed in region '${region}'`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/** Build the CMS handler map. Spread into your app's handlers. Editor mutations are
|
|
510
|
+
* gated both by `auth` (fast 403 before the body) and by the row ACL (cmsPolicies). */
|
|
511
|
+
export function createCmsHandlers(opts = {}) {
|
|
512
|
+
const editorRoles = opts.editorRoles ?? ["editor", "admin"];
|
|
513
|
+
const editor = { auth: editorRoles };
|
|
514
|
+
const mediaMaxSize = opts.mediaMaxSize ?? 25_000_000;
|
|
515
|
+
const defaultLocale = opts.defaultLocale ?? "en";
|
|
516
|
+
const reviewerRoles = opts.reviewerRoles ?? ["reviewer", "admin"];
|
|
517
|
+
const reviewer = { auth: reviewerRoles };
|
|
518
|
+
// Anyone who edits OR reviews may VIEW content (a reviewer must preview a page + load its
|
|
519
|
+
// content type/blocks before approving). Read/preview handlers use this; writes stay editor.
|
|
520
|
+
const viewerRoles = [...new Set([...editorRoles, ...reviewerRoles])];
|
|
521
|
+
const viewer = { auth: viewerRoles };
|
|
522
|
+
// The caller's identity id (the audit actor), or null for a system/unauthenticated write.
|
|
523
|
+
const actorOf = (ctx) => (typeof ctx.identity?.userId === "string" ? ctx.identity.userId : null);
|
|
524
|
+
// Append an audit row for a workflow transition (synchronous, in the mutation's txn).
|
|
525
|
+
const writeAudit = (db, e) => db.insert("cms_audit", { pageId: e.pageId, action: e.action, fromStatus: e.from ?? null, toStatus: e.to ?? null, actor: e.actor, note: e.note ?? null });
|
|
526
|
+
// (slug, locale) uniqueness is enforced here because pramen's unique() is single-column.
|
|
527
|
+
const assertSlugFree = async (db, slug, locale, exceptId) => {
|
|
528
|
+
const rows = await db.exec("SELECT id FROM cms_pages WHERE slug = ? AND locale = ? LIMIT 1", slug, locale);
|
|
529
|
+
if (rows[0] && String(rows[0].id) !== exceptId)
|
|
530
|
+
throw new BadRequest(`slug '${slug}' already exists for locale '${locale}'`);
|
|
531
|
+
};
|
|
532
|
+
const TASK_PUBLISH = "cms:publish";
|
|
533
|
+
const TASK_UNPUBLISH = "cms:unpublish";
|
|
534
|
+
return {
|
|
535
|
+
// ---- block types & content types (data-driven definitions) ----
|
|
536
|
+
listBlockTypes: query((ctx) => cdb(ctx).find({ from: "cms_block_types", orderBy: { column: "name" } })),
|
|
537
|
+
createBlockType: mutation(async (ctx, input) => {
|
|
538
|
+
return cdb(ctx).insert("cms_block_types", {
|
|
539
|
+
name: input.name,
|
|
540
|
+
slug: input.slug,
|
|
541
|
+
description: input.description ?? null,
|
|
542
|
+
fieldsSchema: input.fieldsSchema ?? [],
|
|
543
|
+
icon: input.icon ?? null,
|
|
544
|
+
category: input.category ?? null,
|
|
545
|
+
});
|
|
546
|
+
}, {
|
|
547
|
+
...editor,
|
|
548
|
+
input: (raw) => {
|
|
549
|
+
const o = asObj(raw);
|
|
550
|
+
if (typeof o.name !== "string" || typeof o.slug !== "string")
|
|
551
|
+
throw new BadRequest("name and slug are required");
|
|
552
|
+
return o;
|
|
553
|
+
},
|
|
554
|
+
}),
|
|
555
|
+
createContentType: mutation(async (ctx, input) => {
|
|
556
|
+
return cdb(ctx).insert("cms_content_types", {
|
|
557
|
+
name: input.name,
|
|
558
|
+
slug: input.slug,
|
|
559
|
+
regions: input.regions ?? [],
|
|
560
|
+
fieldsSchema: input.fieldsSchema ?? [],
|
|
561
|
+
defaultBlocks: input.defaultBlocks ?? [],
|
|
562
|
+
});
|
|
563
|
+
}, {
|
|
564
|
+
...editor,
|
|
565
|
+
input: (raw) => {
|
|
566
|
+
const o = asObj(raw);
|
|
567
|
+
if (typeof o.name !== "string" || typeof o.slug !== "string")
|
|
568
|
+
throw new BadRequest("name and slug are required");
|
|
569
|
+
if (!Array.isArray(o.regions) || o.regions.length === 0)
|
|
570
|
+
throw new BadRequest("at least one region is required");
|
|
571
|
+
return o;
|
|
572
|
+
},
|
|
573
|
+
}),
|
|
574
|
+
// ---- media library ----
|
|
575
|
+
/** Mint a signed upload URL for a media blob (keyed under the tenant's `media/`
|
|
576
|
+
* prefix so the public /media route can serve it). The client PUTs the bytes to
|
|
577
|
+
* `url`, then calls `createMedia` with the returned `ref`. */
|
|
578
|
+
signMediaUpload: mutation((ctx, input) => {
|
|
579
|
+
return ctx.files.signUpload({ contentType: input.contentType, filename: input.filename, prefix: "media", maxSize: mediaMaxSize });
|
|
580
|
+
}, {
|
|
581
|
+
...editor,
|
|
582
|
+
input: (raw) => {
|
|
583
|
+
const o = asObj(raw);
|
|
584
|
+
if (typeof o.contentType !== "string")
|
|
585
|
+
throw new BadRequest("contentType is required");
|
|
586
|
+
return { contentType: o.contentType, filename: typeof o.filename === "string" ? o.filename : undefined };
|
|
587
|
+
},
|
|
588
|
+
}),
|
|
589
|
+
/** Confirm an uploaded blob is really in storage (capturing its true size) and
|
|
590
|
+
* persist a `cms_media` row. Mirrors the notes attach flow. */
|
|
591
|
+
createMedia: mutation(async (ctx, input) => {
|
|
592
|
+
const head = await ctx.files.head(input.ref.key);
|
|
593
|
+
if (!head)
|
|
594
|
+
throw new BadRequest("uploaded file not found in storage");
|
|
595
|
+
const file = {
|
|
596
|
+
key: input.ref.key,
|
|
597
|
+
size: head.size,
|
|
598
|
+
contentType: head.contentType ?? input.ref.contentType,
|
|
599
|
+
filename: input.ref.filename,
|
|
600
|
+
uploadedAt: Date.now(),
|
|
601
|
+
};
|
|
602
|
+
return cdb(ctx).insert("cms_media", { file, alt: input.alt ?? null });
|
|
603
|
+
}, {
|
|
604
|
+
...editor,
|
|
605
|
+
input: (raw) => {
|
|
606
|
+
const o = asObj(raw);
|
|
607
|
+
const ref = asObj(o.ref);
|
|
608
|
+
if (typeof ref.key !== "string")
|
|
609
|
+
throw new BadRequest("ref.key is required");
|
|
610
|
+
return {
|
|
611
|
+
ref: {
|
|
612
|
+
key: ref.key,
|
|
613
|
+
size: typeof ref.size === "number" ? ref.size : 0,
|
|
614
|
+
contentType: typeof ref.contentType === "string" ? ref.contentType : "application/octet-stream",
|
|
615
|
+
filename: typeof ref.filename === "string" ? ref.filename : undefined,
|
|
616
|
+
},
|
|
617
|
+
alt: typeof o.alt === "string" ? o.alt : undefined,
|
|
618
|
+
};
|
|
619
|
+
},
|
|
620
|
+
}),
|
|
621
|
+
listMedia: query((ctx, input) => {
|
|
622
|
+
const limit = Math.min(Math.max(Math.trunc(Number(input?.limit ?? 50)) || 50, 1), 200);
|
|
623
|
+
const offset = Math.max(Math.trunc(Number(input?.offset ?? 0)) || 0, 0);
|
|
624
|
+
return cdb(ctx).find({ from: "cms_media", orderBy: { column: "createdAt", dir: "desc" }, limit, offset });
|
|
625
|
+
}, viewer),
|
|
626
|
+
getMedia: query(async (ctx, input) => {
|
|
627
|
+
const rows = await cdb(ctx).find({ from: "cms_media", where: { id: input.id }, limit: 1 });
|
|
628
|
+
return rows[0] ?? null;
|
|
629
|
+
}, {
|
|
630
|
+
...viewer,
|
|
631
|
+
input: (raw) => {
|
|
632
|
+
const o = asObj(raw);
|
|
633
|
+
if (typeof o.id !== "string")
|
|
634
|
+
throw new BadRequest("id is required");
|
|
635
|
+
return o;
|
|
636
|
+
},
|
|
637
|
+
}),
|
|
638
|
+
/** Edit a media asset's metadata (currently just `alt` text). Editor-gated. */
|
|
639
|
+
updateMedia: mutation(async (ctx, input) => {
|
|
640
|
+
const updated = await cdb(ctx).update("cms_media", input.id, { alt: input.alt ?? null });
|
|
641
|
+
if (!updated)
|
|
642
|
+
throw notFound("media");
|
|
643
|
+
return updated;
|
|
644
|
+
}, {
|
|
645
|
+
...editor,
|
|
646
|
+
input: (raw) => {
|
|
647
|
+
const o = asObj(raw);
|
|
648
|
+
if (typeof o.id !== "string")
|
|
649
|
+
throw new BadRequest("id is required");
|
|
650
|
+
return { id: o.id, alt: typeof o.alt === "string" ? o.alt : null };
|
|
651
|
+
},
|
|
652
|
+
}),
|
|
653
|
+
/** Delete a media row AND its R2 blob. (Automatic orphan sweeping — media no longer
|
|
654
|
+
* referenced by any block — is future work; refs live inside opaque block JSON.) */
|
|
655
|
+
deleteMedia: mutation(async (ctx, input) => {
|
|
656
|
+
const db = cdb(ctx);
|
|
657
|
+
const rows = await db.find({ from: "cms_media", where: { id: input.id }, limit: 1 });
|
|
658
|
+
const media = rows[0];
|
|
659
|
+
if (!media)
|
|
660
|
+
throw notFound("media");
|
|
661
|
+
const key = String(asObj(media.file).key ?? "");
|
|
662
|
+
await db.delete("cms_media", input.id);
|
|
663
|
+
if (key)
|
|
664
|
+
await ctx.files.delete(key).catch(() => { });
|
|
665
|
+
return { ok: true };
|
|
666
|
+
}, {
|
|
667
|
+
...editor,
|
|
668
|
+
input: (raw) => {
|
|
669
|
+
const o = asObj(raw);
|
|
670
|
+
if (typeof o.id !== "string")
|
|
671
|
+
throw new BadRequest("id is required");
|
|
672
|
+
return o;
|
|
673
|
+
},
|
|
674
|
+
}),
|
|
675
|
+
listContentTypes: query((ctx) => cdb(ctx).find({ from: "cms_content_types", orderBy: { column: "name" } }), viewer),
|
|
676
|
+
getContentType: query(async (ctx, input) => {
|
|
677
|
+
const rows = await cdb(ctx).find({ from: "cms_content_types", where: { id: input.id }, limit: 1 });
|
|
678
|
+
return rows[0] ?? null;
|
|
679
|
+
}, {
|
|
680
|
+
...viewer,
|
|
681
|
+
input: (raw) => {
|
|
682
|
+
const o = asObj(raw);
|
|
683
|
+
if (typeof o.id !== "string")
|
|
684
|
+
throw new BadRequest("id is required");
|
|
685
|
+
return o;
|
|
686
|
+
},
|
|
687
|
+
}),
|
|
688
|
+
// ---- pages ----
|
|
689
|
+
listPages: query((ctx) => cdb(ctx).find({ from: "cms_pages", orderBy: { column: "createdAt", dir: "desc" }, limit: 100 })),
|
|
690
|
+
/** Public: list published pages (slug, locale, updatedAt) for sitemap generation. The
|
|
691
|
+
* anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose. */
|
|
692
|
+
listPublishedPages: query(async (ctx) => {
|
|
693
|
+
const rows = await cdb(ctx).find({ from: "cms_pages", where: { status: "published" }, orderBy: { column: "updatedAt", dir: "desc" }, limit: 5000 });
|
|
694
|
+
return rows.map((r) => ({ slug: String(r.slug), locale: String(r.locale ?? "en"), updatedAt: String(r.updatedAt ?? r.createdAt ?? "") }));
|
|
695
|
+
}),
|
|
696
|
+
/** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
|
|
697
|
+
updatePageSeo: mutation(async (ctx, input) => {
|
|
698
|
+
const db = cdb(ctx);
|
|
699
|
+
const patch = { updatedAt: nowStamp() };
|
|
700
|
+
for (const k of ["metaTitle", "metaDescription", "canonicalUrl", "robots", "ogTitle", "ogDescription", "ogImage"]) {
|
|
701
|
+
if (k in input)
|
|
702
|
+
patch[k] = input[k];
|
|
703
|
+
}
|
|
704
|
+
if ("structuredData" in input)
|
|
705
|
+
patch.structuredData = input.structuredData ?? null;
|
|
706
|
+
const updated = await db.update("cms_pages", input.pageId, patch);
|
|
707
|
+
if (!updated)
|
|
708
|
+
throw notFound("page");
|
|
709
|
+
return { ok: true, page: updated };
|
|
710
|
+
}, {
|
|
711
|
+
...editor,
|
|
712
|
+
input: (raw) => {
|
|
713
|
+
const o = asObj(raw);
|
|
714
|
+
if (typeof o.pageId !== "string")
|
|
715
|
+
throw new BadRequest("pageId is required");
|
|
716
|
+
return o;
|
|
717
|
+
},
|
|
718
|
+
}),
|
|
719
|
+
/** Create a page and auto-scaffold its content type's default blocks. */
|
|
720
|
+
createPage: mutation(async (ctx, input) => {
|
|
721
|
+
const db = cdb(ctx);
|
|
722
|
+
const ctRows = await db.find({ from: "cms_content_types", where: { id: input.typeId }, limit: 1 });
|
|
723
|
+
const ct = ctRows[0];
|
|
724
|
+
if (!ct)
|
|
725
|
+
throw new BadRequest("unknown content type");
|
|
726
|
+
validateFields(ct.fieldsSchema, input.fields ?? {}, "page.fields", { requireRequired: false });
|
|
727
|
+
const locale = input.locale ?? defaultLocale;
|
|
728
|
+
await assertSlugFree(db, input.slug, locale);
|
|
729
|
+
const page = await db.insert("cms_pages", {
|
|
730
|
+
typeId: input.typeId,
|
|
731
|
+
title: input.title,
|
|
732
|
+
slug: input.slug,
|
|
733
|
+
locale,
|
|
734
|
+
fields: input.fields ?? {},
|
|
735
|
+
status: "draft",
|
|
736
|
+
});
|
|
737
|
+
const defaults = ct.defaultBlocks ?? [];
|
|
738
|
+
for (const d of defaults) {
|
|
739
|
+
// A default block must pass the SAME guards as addBlock — a known type, an
|
|
740
|
+
// allowed region, and schema-valid fields — or the page would be scaffolded with
|
|
741
|
+
// an invalid/disallowed block that later renders on the published snapshot. A
|
|
742
|
+
// misconfigured default is skipped with a warning rather than aborting the page.
|
|
743
|
+
try {
|
|
744
|
+
const bts = await db.find({ from: "cms_block_types", where: { slug: d.blockTypeSlug }, limit: 1 });
|
|
745
|
+
if (!bts[0])
|
|
746
|
+
throw new BadRequest(`unknown block type '${d.blockTypeSlug}'`);
|
|
747
|
+
await assertRegionAllows(db, page, d.region, d.blockTypeSlug);
|
|
748
|
+
validateFields(bts[0].fieldsSchema, d.fields ?? {}, "", { requireRequired: false });
|
|
749
|
+
const block = await db.insert("cms_blocks", { typeId: bts[0].id, fields: d.fields ?? {} });
|
|
750
|
+
const position = await nextPosition(db, String(page.id), d.region);
|
|
751
|
+
await db.insert("cms_page_blocks", { pageId: page.id, blockId: block.id, region: d.region, position });
|
|
752
|
+
}
|
|
753
|
+
catch (e) {
|
|
754
|
+
console.warn(`@pramen/cms: content type '${ct.slug}' default block (${d.blockTypeSlug} → ${d.region}) skipped: ${e instanceof Error ? e.message : String(e)}`);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return page;
|
|
758
|
+
}, {
|
|
759
|
+
...editor,
|
|
760
|
+
input: (raw) => {
|
|
761
|
+
const o = asObj(raw);
|
|
762
|
+
if (typeof o.typeId !== "string" || typeof o.title !== "string" || typeof o.slug !== "string") {
|
|
763
|
+
throw new BadRequest("typeId, title and slug are required");
|
|
764
|
+
}
|
|
765
|
+
if (o.locale !== undefined && typeof o.locale !== "string")
|
|
766
|
+
throw new BadRequest("locale must be a string");
|
|
767
|
+
return o;
|
|
768
|
+
},
|
|
769
|
+
}),
|
|
770
|
+
/** Create a translation of an existing page: a new page in `locale` sharing the
|
|
771
|
+
* source's translationGroupId (and content type). Content starts empty — the editor
|
|
772
|
+
* fills in the translated blocks. Slug defaults to the source's (allowed in a new locale). */
|
|
773
|
+
createTranslation: mutation(async (ctx, input) => {
|
|
774
|
+
const db = cdb(ctx);
|
|
775
|
+
const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
|
|
776
|
+
const src = rows[0];
|
|
777
|
+
if (!src)
|
|
778
|
+
throw notFound("page");
|
|
779
|
+
if (String(src.locale) === input.locale)
|
|
780
|
+
throw new BadRequest("page is already in that locale");
|
|
781
|
+
// A legacy page (migrated in before this column existed) has a NULL group — a NULL
|
|
782
|
+
// match would collapse ALL legacy pages together. Backfill the source with its own
|
|
783
|
+
// group first, so this translation joins only it.
|
|
784
|
+
let group = src.translationGroupId;
|
|
785
|
+
if (typeof group !== "string" || !group) {
|
|
786
|
+
group = crypto.randomUUID();
|
|
787
|
+
await db.update("cms_pages", String(src.id), { translationGroupId: group });
|
|
788
|
+
}
|
|
789
|
+
const existing = await db.find({ from: "cms_pages", where: { translationGroupId: group, locale: input.locale }, limit: 1 });
|
|
790
|
+
if (existing[0])
|
|
791
|
+
throw new BadRequest(`a '${input.locale}' translation already exists`);
|
|
792
|
+
const slug = input.slug ?? String(src.slug);
|
|
793
|
+
await assertSlugFree(db, slug, input.locale);
|
|
794
|
+
return db.insert("cms_pages", {
|
|
795
|
+
typeId: src.typeId,
|
|
796
|
+
title: input.title ?? String(src.title),
|
|
797
|
+
slug,
|
|
798
|
+
locale: input.locale,
|
|
799
|
+
translationGroupId: group,
|
|
800
|
+
fields: {},
|
|
801
|
+
status: "draft",
|
|
802
|
+
});
|
|
803
|
+
}, {
|
|
804
|
+
...editor,
|
|
805
|
+
input: (raw) => {
|
|
806
|
+
const o = asObj(raw);
|
|
807
|
+
if (typeof o.pageId !== "string" || typeof o.locale !== "string")
|
|
808
|
+
throw new BadRequest("pageId and locale are required");
|
|
809
|
+
return o;
|
|
810
|
+
},
|
|
811
|
+
}),
|
|
812
|
+
/** List all locales of a page (the translation group), including the page itself. */
|
|
813
|
+
listTranslations: query(async (ctx, input) => {
|
|
814
|
+
const db = cdb(ctx);
|
|
815
|
+
const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
|
|
816
|
+
const self = rows[0];
|
|
817
|
+
if (!self)
|
|
818
|
+
throw notFound("page");
|
|
819
|
+
const proj = (r) => ({ id: String(r.id), locale: String(r.locale ?? "en"), slug: String(r.slug), title: String(r.title), status: String(r.status) });
|
|
820
|
+
// A NULL group (legacy page) matches all legacy pages — guard it and return just self.
|
|
821
|
+
if (typeof self.translationGroupId !== "string" || !self.translationGroupId)
|
|
822
|
+
return [proj(self)];
|
|
823
|
+
const group = await db.find({ from: "cms_pages", where: { translationGroupId: self.translationGroupId } });
|
|
824
|
+
return group.map(proj);
|
|
825
|
+
}, {
|
|
826
|
+
...viewer,
|
|
827
|
+
input: (raw) => {
|
|
828
|
+
const o = asObj(raw);
|
|
829
|
+
if (typeof o.pageId !== "string")
|
|
830
|
+
throw new BadRequest("pageId is required");
|
|
831
|
+
return o;
|
|
832
|
+
},
|
|
833
|
+
}),
|
|
834
|
+
/** Distinct locales present across all pages. */
|
|
835
|
+
listLocales: query(async (ctx) => {
|
|
836
|
+
const rows = await cdb(ctx).exec("SELECT DISTINCT locale FROM cms_pages ORDER BY locale");
|
|
837
|
+
return rows.map((r) => String(r.locale ?? "en"));
|
|
838
|
+
}, viewer),
|
|
839
|
+
// ---- blocks & placement ----
|
|
840
|
+
/** Create a block instance and place it into a page region in one call (the common
|
|
841
|
+
* editor action). Validates the fields against the block type's schema and the region
|
|
842
|
+
* against the content type's allow-list. */
|
|
843
|
+
addBlock: mutation(async (ctx, input) => {
|
|
844
|
+
const db = cdb(ctx);
|
|
845
|
+
const pages = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
|
|
846
|
+
const page = pages[0];
|
|
847
|
+
if (!page)
|
|
848
|
+
throw notFound("page");
|
|
849
|
+
const bt = await loadBlockTypeBySlug(db, input.blockTypeSlug);
|
|
850
|
+
await assertRegionAllows(db, page, input.region, input.blockTypeSlug);
|
|
851
|
+
validateFields(bt.fieldsSchema, input.fields ?? {}, "", { requireRequired: false });
|
|
852
|
+
const block = await db.insert("cms_blocks", {
|
|
853
|
+
typeId: bt.id,
|
|
854
|
+
title: input.title ?? null,
|
|
855
|
+
fields: input.fields ?? {},
|
|
856
|
+
isReusable: input.isReusable ?? false,
|
|
857
|
+
});
|
|
858
|
+
const position = input.position ?? (await nextPosition(db, input.pageId, input.region));
|
|
859
|
+
const placement = await db.insert("cms_page_blocks", {
|
|
860
|
+
pageId: input.pageId,
|
|
861
|
+
blockId: block.id,
|
|
862
|
+
region: input.region,
|
|
863
|
+
position,
|
|
864
|
+
isShared: input.isReusable ?? false,
|
|
865
|
+
});
|
|
866
|
+
return { block, placement };
|
|
867
|
+
}, {
|
|
868
|
+
...editor,
|
|
869
|
+
input: (raw) => {
|
|
870
|
+
const o = asObj(raw);
|
|
871
|
+
if (typeof o.pageId !== "string" || typeof o.blockTypeSlug !== "string" || typeof o.region !== "string") {
|
|
872
|
+
throw new BadRequest("pageId, blockTypeSlug and region are required");
|
|
873
|
+
}
|
|
874
|
+
return o;
|
|
875
|
+
},
|
|
876
|
+
}),
|
|
877
|
+
/** Place an EXISTING (typically reusable) block into a page region as a SHARED
|
|
878
|
+
* placement, with optional per-placement `overrides` merged over the block's fields at
|
|
879
|
+
* read time. This is the "edit once, appear on many pages" workflow — the same block id
|
|
880
|
+
* can be placed on several pages; editing it updates them all, while `overrides` let one
|
|
881
|
+
* placement diverge. The merged (base + overrides) result is validated against the
|
|
882
|
+
* block type's field schema. */
|
|
883
|
+
placeBlock: mutation(async (ctx, input) => {
|
|
884
|
+
const db = cdb(ctx);
|
|
885
|
+
const pages = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
|
|
886
|
+
const page = pages[0];
|
|
887
|
+
if (!page)
|
|
888
|
+
throw notFound("page");
|
|
889
|
+
const blocks = await db.find({ from: "cms_blocks", where: { id: input.blockId }, limit: 1 });
|
|
890
|
+
const block = blocks[0];
|
|
891
|
+
if (!block)
|
|
892
|
+
throw notFound("block");
|
|
893
|
+
const bts = await db.find({ from: "cms_block_types", where: { id: block.typeId }, limit: 1 });
|
|
894
|
+
const slug = String(bts[0]?.slug ?? "");
|
|
895
|
+
await assertRegionAllows(db, page, input.region, slug);
|
|
896
|
+
if (input.overrides !== undefined) {
|
|
897
|
+
validateFields(bts[0]?.fieldsSchema, { ...asObj(block.fields), ...input.overrides }, "", { requireRequired: false });
|
|
898
|
+
}
|
|
899
|
+
const position = input.position ?? (await nextPosition(db, input.pageId, input.region));
|
|
900
|
+
return db.insert("cms_page_blocks", {
|
|
901
|
+
pageId: input.pageId,
|
|
902
|
+
blockId: input.blockId,
|
|
903
|
+
region: input.region,
|
|
904
|
+
position,
|
|
905
|
+
isShared: true,
|
|
906
|
+
overrides: input.overrides ?? null,
|
|
907
|
+
});
|
|
908
|
+
}, {
|
|
909
|
+
...editor,
|
|
910
|
+
input: (raw) => {
|
|
911
|
+
const o = asObj(raw);
|
|
912
|
+
if (typeof o.pageId !== "string" || typeof o.blockId !== "string" || typeof o.region !== "string") {
|
|
913
|
+
throw new BadRequest("pageId, blockId and region are required");
|
|
914
|
+
}
|
|
915
|
+
return o;
|
|
916
|
+
},
|
|
917
|
+
}),
|
|
918
|
+
/** Fetch a block's RAW content (media fields as ids, not resolved) — for editing. */
|
|
919
|
+
getBlock: query(async (ctx, input) => {
|
|
920
|
+
const rows = await cdb(ctx).find({ from: "cms_blocks", where: { id: input.blockId }, limit: 1 });
|
|
921
|
+
return rows[0] ?? null;
|
|
922
|
+
}, {
|
|
923
|
+
...viewer,
|
|
924
|
+
input: (raw) => {
|
|
925
|
+
const o = asObj(raw);
|
|
926
|
+
if (typeof o.blockId !== "string")
|
|
927
|
+
throw new BadRequest("blockId is required");
|
|
928
|
+
return o;
|
|
929
|
+
},
|
|
930
|
+
}),
|
|
931
|
+
/** Update a block's content (re-validated against its type's field schema). */
|
|
932
|
+
updateBlock: mutation(async (ctx, input) => {
|
|
933
|
+
const db = cdb(ctx);
|
|
934
|
+
const rows = await db.find({ from: "cms_blocks", where: { id: input.blockId }, limit: 1 });
|
|
935
|
+
const block = rows[0];
|
|
936
|
+
if (!block)
|
|
937
|
+
throw notFound("block");
|
|
938
|
+
if (input.fields !== undefined) {
|
|
939
|
+
const bt = await db.find({ from: "cms_block_types", where: { id: block.typeId }, limit: 1 });
|
|
940
|
+
validateFields(bt[0]?.fieldsSchema, input.fields, "", { requireRequired: false });
|
|
941
|
+
}
|
|
942
|
+
const patch = { updatedAt: nowStamp() };
|
|
943
|
+
if (input.fields !== undefined)
|
|
944
|
+
patch.fields = input.fields;
|
|
945
|
+
if (input.title !== undefined)
|
|
946
|
+
patch.title = input.title;
|
|
947
|
+
return db.update("cms_blocks", input.blockId, patch);
|
|
948
|
+
}, {
|
|
949
|
+
...editor,
|
|
950
|
+
input: (raw) => {
|
|
951
|
+
const o = asObj(raw);
|
|
952
|
+
if (typeof o.blockId !== "string")
|
|
953
|
+
throw new BadRequest("blockId is required");
|
|
954
|
+
return o;
|
|
955
|
+
},
|
|
956
|
+
}),
|
|
957
|
+
/** Reorder a region: `order` is the page_block ids in their new order. It must cover
|
|
958
|
+
* EXACTLY the region's current placements (same set, no dups) — otherwise a partial or
|
|
959
|
+
* stale list would leave untouched placements colliding at a shared position. */
|
|
960
|
+
reorderRegion: mutation(async (ctx, input) => {
|
|
961
|
+
const db = cdb(ctx);
|
|
962
|
+
const current = await db.find({ from: "cms_page_blocks", where: { pageId: input.pageId, region: input.region } });
|
|
963
|
+
const currentIds = new Set(current.map((p) => String(p.id)));
|
|
964
|
+
const orderSet = new Set(input.order.map(String));
|
|
965
|
+
if (orderSet.size !== input.order.length)
|
|
966
|
+
throw new BadRequest("order contains duplicate ids");
|
|
967
|
+
if (orderSet.size !== currentIds.size || ![...orderSet].every((id) => currentIds.has(id))) {
|
|
968
|
+
throw new BadRequest("order must list exactly this region's placements");
|
|
969
|
+
}
|
|
970
|
+
for (let i = 0; i < input.order.length; i++) {
|
|
971
|
+
await db.exec("UPDATE cms_page_blocks SET position = ? WHERE id = ? AND pageId = ? AND region = ?", i, input.order[i], input.pageId, input.region);
|
|
972
|
+
}
|
|
973
|
+
return { ok: true, count: input.order.length };
|
|
974
|
+
}, {
|
|
975
|
+
...editor,
|
|
976
|
+
input: (raw) => {
|
|
977
|
+
const o = asObj(raw);
|
|
978
|
+
if (typeof o.pageId !== "string" || typeof o.region !== "string" || !Array.isArray(o.order)) {
|
|
979
|
+
throw new BadRequest("pageId, region and order[] are required");
|
|
980
|
+
}
|
|
981
|
+
if (!o.order.every((id) => typeof id === "string"))
|
|
982
|
+
throw new BadRequest("order must be string ids");
|
|
983
|
+
return o;
|
|
984
|
+
},
|
|
985
|
+
}),
|
|
986
|
+
/** Remove a placement. A reusable/shared block stays in the library (it may be placed
|
|
987
|
+
* elsewhere); a non-reusable block with no remaining placements is deleted too, so
|
|
988
|
+
* add/remove churn doesn't accumulate unreachable block rows. */
|
|
989
|
+
removeBlock: mutation(async (ctx, input) => {
|
|
990
|
+
const db = cdb(ctx);
|
|
991
|
+
const rows = await db.find({ from: "cms_page_blocks", where: { id: input.pageBlockId }, limit: 1 });
|
|
992
|
+
const placement = rows[0];
|
|
993
|
+
if (!placement)
|
|
994
|
+
throw notFound("placement");
|
|
995
|
+
await db.delete("cms_page_blocks", input.pageBlockId);
|
|
996
|
+
const blockId = String(placement.blockId);
|
|
997
|
+
const stillUsed = await db.find({ from: "cms_page_blocks", where: { blockId }, limit: 1 });
|
|
998
|
+
if (stillUsed.length === 0) {
|
|
999
|
+
const blk = await db.find({ from: "cms_blocks", where: { id: blockId }, limit: 1 });
|
|
1000
|
+
if (blk[0] && !blk[0].isReusable)
|
|
1001
|
+
await db.delete("cms_blocks", blockId);
|
|
1002
|
+
}
|
|
1003
|
+
return { ok: true };
|
|
1004
|
+
}, {
|
|
1005
|
+
...editor,
|
|
1006
|
+
input: (raw) => {
|
|
1007
|
+
const o = asObj(raw);
|
|
1008
|
+
if (typeof o.pageBlockId !== "string")
|
|
1009
|
+
throw new BadRequest("pageBlockId is required");
|
|
1010
|
+
return o;
|
|
1011
|
+
},
|
|
1012
|
+
}),
|
|
1013
|
+
// ---- editorial workflow ----
|
|
1014
|
+
/** Move a draft (or rejected) page into review. Editor-gated. */
|
|
1015
|
+
submitForReview: mutation(async (ctx, input) => {
|
|
1016
|
+
const db = cdb(ctx);
|
|
1017
|
+
const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
|
|
1018
|
+
const page = rows[0];
|
|
1019
|
+
if (!page)
|
|
1020
|
+
throw notFound("page");
|
|
1021
|
+
const from = String(page.status);
|
|
1022
|
+
if (from !== "draft" && from !== "rejected")
|
|
1023
|
+
throw new BadRequest(`cannot submit a '${from}' page for review`);
|
|
1024
|
+
const updated = await db.update("cms_pages", input.pageId, { status: "review", updatedAt: nowStamp() });
|
|
1025
|
+
await writeAudit(db, { pageId: input.pageId, action: "submit", from, to: "review", actor: actorOf(ctx), note: input.note });
|
|
1026
|
+
return { ok: true, page: updated };
|
|
1027
|
+
}, {
|
|
1028
|
+
...editor,
|
|
1029
|
+
input: (raw) => {
|
|
1030
|
+
const o = asObj(raw);
|
|
1031
|
+
if (typeof o.pageId !== "string")
|
|
1032
|
+
throw new BadRequest("pageId is required");
|
|
1033
|
+
return o;
|
|
1034
|
+
},
|
|
1035
|
+
}),
|
|
1036
|
+
/** Approve a page in review → publish it (snapshot + currentRevisionId). Reviewer-gated. */
|
|
1037
|
+
approve: mutation(async (ctx, input) => {
|
|
1038
|
+
const db = cdb(ctx);
|
|
1039
|
+
const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
|
|
1040
|
+
const page = rows[0];
|
|
1041
|
+
if (!page)
|
|
1042
|
+
throw notFound("page");
|
|
1043
|
+
if (String(page.status) !== "review")
|
|
1044
|
+
throw new BadRequest("only a page in review can be approved");
|
|
1045
|
+
const updated = await doPublish(db, page, actorOf(ctx), input.note, true); // manual → clear pending auto-unpublish
|
|
1046
|
+
await writeAudit(db, { pageId: input.pageId, action: "approve", from: "review", to: "published", actor: actorOf(ctx), note: input.note });
|
|
1047
|
+
return { ok: true, page: updated };
|
|
1048
|
+
}, {
|
|
1049
|
+
...reviewer,
|
|
1050
|
+
input: (raw) => {
|
|
1051
|
+
const o = asObj(raw);
|
|
1052
|
+
if (typeof o.pageId !== "string")
|
|
1053
|
+
throw new BadRequest("pageId is required");
|
|
1054
|
+
return o;
|
|
1055
|
+
},
|
|
1056
|
+
}),
|
|
1057
|
+
/** Reject a page in review → back to draft. Reviewer-gated. */
|
|
1058
|
+
reject: mutation(async (ctx, input) => {
|
|
1059
|
+
const db = cdb(ctx);
|
|
1060
|
+
const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
|
|
1061
|
+
const page = rows[0];
|
|
1062
|
+
if (!page)
|
|
1063
|
+
throw notFound("page");
|
|
1064
|
+
if (String(page.status) !== "review")
|
|
1065
|
+
throw new BadRequest("only a page in review can be rejected");
|
|
1066
|
+
const updated = await db.update("cms_pages", input.pageId, { status: "rejected", updatedAt: nowStamp() });
|
|
1067
|
+
await writeAudit(db, { pageId: input.pageId, action: "reject", from: "review", to: "rejected", actor: actorOf(ctx), note: input.note });
|
|
1068
|
+
return { ok: true, page: updated };
|
|
1069
|
+
}, {
|
|
1070
|
+
...reviewer,
|
|
1071
|
+
input: (raw) => {
|
|
1072
|
+
const o = asObj(raw);
|
|
1073
|
+
if (typeof o.pageId !== "string")
|
|
1074
|
+
throw new BadRequest("pageId is required");
|
|
1075
|
+
return o;
|
|
1076
|
+
},
|
|
1077
|
+
}),
|
|
1078
|
+
/** The workflow audit trail for a page (most recent first). Handler-gated to editors;
|
|
1079
|
+
* reads the append-only log via `exec` (a plain admin-scoped read of a gated log). */
|
|
1080
|
+
listPageAudit: query(async (ctx, input) => {
|
|
1081
|
+
const limit = Math.min(Math.max(Math.trunc(Number(input?.limit ?? 50)) || 50, 1), 200);
|
|
1082
|
+
return cdb(ctx).exec(`SELECT "id", "pageId", "action", "fromStatus", "toStatus", "actor", "note", "createdAt" FROM "cms_audit" WHERE "pageId" = ? ORDER BY "createdAt" DESC LIMIT ${limit}`, input.pageId);
|
|
1083
|
+
}, {
|
|
1084
|
+
...viewer,
|
|
1085
|
+
input: (raw) => {
|
|
1086
|
+
const o = asObj(raw);
|
|
1087
|
+
if (typeof o.pageId !== "string")
|
|
1088
|
+
throw new BadRequest("pageId is required");
|
|
1089
|
+
return o;
|
|
1090
|
+
},
|
|
1091
|
+
}),
|
|
1092
|
+
// ---- publishing ----
|
|
1093
|
+
/** Publish a page directly: snapshot the assembled page into a revision and flip
|
|
1094
|
+
* status to `published` (records an audit entry). The public content API serves the
|
|
1095
|
+
* snapshot. `approve` is the review-gated path to the same outcome. */
|
|
1096
|
+
publishPage: mutation(async (ctx, input) => {
|
|
1097
|
+
const db = cdb(ctx);
|
|
1098
|
+
const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
|
|
1099
|
+
const page = rows[0];
|
|
1100
|
+
if (!page)
|
|
1101
|
+
throw notFound("page");
|
|
1102
|
+
const from = String(page.status);
|
|
1103
|
+
const updated = await doPublish(db, page, actorOf(ctx), input.note, true); // manual → clear pending auto-unpublish
|
|
1104
|
+
await writeAudit(db, { pageId: input.pageId, action: "publish", from, to: "published", actor: actorOf(ctx), note: input.note });
|
|
1105
|
+
return { ok: true, page: updated };
|
|
1106
|
+
}, {
|
|
1107
|
+
// Reviewer-gated: publishing makes content live, so it requires the same authority as
|
|
1108
|
+
// `approve` — an editor can't bypass review by calling publishPage directly. (Editors
|
|
1109
|
+
// author + submitForReview; reviewers approve/publish.)
|
|
1110
|
+
...reviewer,
|
|
1111
|
+
input: (raw) => {
|
|
1112
|
+
const o = asObj(raw);
|
|
1113
|
+
if (typeof o.pageId !== "string")
|
|
1114
|
+
throw new BadRequest("pageId is required");
|
|
1115
|
+
return o;
|
|
1116
|
+
},
|
|
1117
|
+
}),
|
|
1118
|
+
unpublishPage: mutation(async (ctx, input) => {
|
|
1119
|
+
// Clear any pending schedule tokens too: a queued cms:publish/cms:unpublish task
|
|
1120
|
+
// validates its token against these at fire time, so nulling them cancels the
|
|
1121
|
+
// schedule (the editor is taking manual control).
|
|
1122
|
+
const db = cdb(ctx);
|
|
1123
|
+
const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
|
|
1124
|
+
const from = rows[0] ? String(rows[0].status) : undefined;
|
|
1125
|
+
const updated = await db.update("cms_pages", input.pageId, { status: "draft", scheduledAt: null, unpublishAt: null, updatedAt: nowStamp() });
|
|
1126
|
+
if (!updated)
|
|
1127
|
+
throw notFound("page");
|
|
1128
|
+
await writeAudit(db, { pageId: input.pageId, action: "unpublish", from, to: "draft", actor: actorOf(ctx) });
|
|
1129
|
+
return { ok: true, page: updated };
|
|
1130
|
+
}, {
|
|
1131
|
+
...editor,
|
|
1132
|
+
input: (raw) => {
|
|
1133
|
+
const o = asObj(raw);
|
|
1134
|
+
if (typeof o.pageId !== "string")
|
|
1135
|
+
throw new BadRequest("pageId is required");
|
|
1136
|
+
return o;
|
|
1137
|
+
},
|
|
1138
|
+
}),
|
|
1139
|
+
/** Schedule a page to publish at `publishAt` (epoch ms), and optionally unpublish at
|
|
1140
|
+
* `unpublishAt`. Enqueues delayed outbox tasks (atomic with this write).
|
|
1141
|
+
*
|
|
1142
|
+
* Outbox tasks can't be recalled, so cancellation/rescheduling is handled by INTENT
|
|
1143
|
+
* TOKENS: the page stores the scheduled times (`scheduledAt`/`unpublishAt`, ISO), and
|
|
1144
|
+
* each task carries the token it was enqueued for. At fire time the task acts ONLY if
|
|
1145
|
+
* its token still equals the page's current token — so rescheduling (new token),
|
|
1146
|
+
* manual publish/unpublish (token cleared), and duplicate deliveries all make a stale
|
|
1147
|
+
* task a no-op. `unpublishAt` must be after `publishAt`. */
|
|
1148
|
+
schedulePage: mutation(async (ctx, input) => {
|
|
1149
|
+
const db = cdb(ctx);
|
|
1150
|
+
const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
|
|
1151
|
+
if (!rows[0])
|
|
1152
|
+
throw notFound("page");
|
|
1153
|
+
const now = Date.now();
|
|
1154
|
+
const publishToken = new Date(input.publishAt).toISOString();
|
|
1155
|
+
const unpublishToken = input.unpublishAt !== undefined ? new Date(input.unpublishAt).toISOString() : null;
|
|
1156
|
+
await db.update("cms_pages", input.pageId, { scheduledAt: publishToken, unpublishAt: unpublishToken });
|
|
1157
|
+
await ctx.tasks.enqueue({ kind: TASK_PUBLISH, payload: { pageId: input.pageId, token: publishToken }, delayMs: Math.max(0, input.publishAt - now) });
|
|
1158
|
+
if (input.unpublishAt !== undefined) {
|
|
1159
|
+
await ctx.tasks.enqueue({ kind: TASK_UNPUBLISH, payload: { pageId: input.pageId, token: unpublishToken }, delayMs: Math.max(0, input.unpublishAt - now) });
|
|
1160
|
+
}
|
|
1161
|
+
return { ok: true, publishInMs: Math.max(0, input.publishAt - now) };
|
|
1162
|
+
}, {
|
|
1163
|
+
// Reviewer-gated like publishPage — a scheduled publish is still a publish.
|
|
1164
|
+
...reviewer,
|
|
1165
|
+
input: (raw) => {
|
|
1166
|
+
const o = asObj(raw);
|
|
1167
|
+
if (typeof o.pageId !== "string" || !Number.isFinite(o.publishAt))
|
|
1168
|
+
throw new BadRequest("pageId and a finite publishAt (epoch ms) are required");
|
|
1169
|
+
if (o.unpublishAt !== undefined) {
|
|
1170
|
+
if (!Number.isFinite(o.unpublishAt))
|
|
1171
|
+
throw new BadRequest("unpublishAt must be a finite epoch ms");
|
|
1172
|
+
if (o.unpublishAt <= o.publishAt)
|
|
1173
|
+
throw new BadRequest("unpublishAt must be after publishAt");
|
|
1174
|
+
}
|
|
1175
|
+
return o;
|
|
1176
|
+
},
|
|
1177
|
+
}),
|
|
1178
|
+
// ---- public content API ----
|
|
1179
|
+
/** Fetch an assembled page by slug (+ locale). Anonymous callers get the published
|
|
1180
|
+
* snapshot (the ACL scopes `cms_pages` reads to `status = published`). Editors may pass
|
|
1181
|
+
* `preview: true` to assemble the current DRAFT live from the tables. `locale` defaults
|
|
1182
|
+
* to the configured default locale; a slug is unique per locale. */
|
|
1183
|
+
getPage: query(async (ctx, input) => {
|
|
1184
|
+
const db = cdb(ctx);
|
|
1185
|
+
// Preview is an editor capability — gate it before the lookup so a non-editor gets a
|
|
1186
|
+
// clear 403 (rather than a 404 that merely reflects the published-only read scope).
|
|
1187
|
+
if (input.preview && !isEditor(ctx, viewerRoles))
|
|
1188
|
+
throw new Forbidden("preview requires an editor or reviewer role");
|
|
1189
|
+
const locale = input.locale ?? defaultLocale;
|
|
1190
|
+
const rows = await db.find({ from: "cms_pages", where: { slug: input.slug, locale }, limit: 1 });
|
|
1191
|
+
const page = rows[0];
|
|
1192
|
+
if (!page)
|
|
1193
|
+
throw notFound("page"); // also the anonymous-vs-draft case: ACL yields no row
|
|
1194
|
+
if (input.preview)
|
|
1195
|
+
return assembleLive(db, page);
|
|
1196
|
+
// Public path: serve the page's current published revision snapshot (selected by the
|
|
1197
|
+
// page's `currentRevisionId` pointer — deterministic, unlike ordering by a
|
|
1198
|
+
// second-precision timestamp). We do NOT assemble live here: anonymous has no read
|
|
1199
|
+
// grant on the block tables (only pages + revisions), so a page without a current
|
|
1200
|
+
// revision returns its meta with empty regions rather than a spurious 403. In
|
|
1201
|
+
// practice publish always sets currentRevisionId, so that fallback is defensive.
|
|
1202
|
+
if (page.currentRevisionId) {
|
|
1203
|
+
const revs = await db.find({ from: "cms_page_revisions", where: { id: page.currentRevisionId }, limit: 1 });
|
|
1204
|
+
if (revs[0]?.snapshot) {
|
|
1205
|
+
const snap = revs[0].snapshot;
|
|
1206
|
+
// hreflang alternates are computed LIVE (not from the snapshot): a sibling
|
|
1207
|
+
// published AFTER this page won't be in the baked snapshot. Anonymous can read
|
|
1208
|
+
// published pages, so this query is in-policy on the public path.
|
|
1209
|
+
snap.page.translations = await siblingTranslations(db, page);
|
|
1210
|
+
// Back-compat: a snapshot baked before `seo`/`translationGroupId` existed lacks
|
|
1211
|
+
// those keys, but AssembledPage now types them as present — backfill from the live
|
|
1212
|
+
// page row so a frontend head template never hits `page.seo` === undefined.
|
|
1213
|
+
if (!snap.page.seo)
|
|
1214
|
+
snap.page.seo = pageMeta(page).seo;
|
|
1215
|
+
if (snap.page.translationGroupId === undefined)
|
|
1216
|
+
snap.page.translationGroupId = page.translationGroupId ?? null;
|
|
1217
|
+
return snap;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
return { page: pageMeta(page, await siblingTranslations(db, page), await resolveMediaId(db, page.ogImage)), regions: {} };
|
|
1221
|
+
}, {
|
|
1222
|
+
input: (raw) => {
|
|
1223
|
+
const o = asObj(raw);
|
|
1224
|
+
if (typeof o.slug !== "string")
|
|
1225
|
+
throw new BadRequest("slug is required");
|
|
1226
|
+
if (o.locale !== undefined && typeof o.locale !== "string")
|
|
1227
|
+
throw new BadRequest("locale must be a string");
|
|
1228
|
+
return o;
|
|
1229
|
+
},
|
|
1230
|
+
}),
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
/** The default CMS handlers (editor roles `["editor", "admin"]`). */
|
|
1234
|
+
export const cmsHandlers = createCmsHandlers();
|
|
1235
|
+
/** ACL fragments. Spread `public` into your anonymous role and `editor` into your
|
|
1236
|
+
* editor/admin role:
|
|
1237
|
+
*
|
|
1238
|
+
* role("anonymous", [...cmsPolicies().public])
|
|
1239
|
+
* role("editor", [...cmsPolicies().editor])
|
|
1240
|
+
*
|
|
1241
|
+
* `public` grants read of PUBLISHED pages + their revision snapshots only (the public
|
|
1242
|
+
* content API reads the snapshot, so unpublished block rows are never exposed).
|
|
1243
|
+
* `editor` grants full CRUD across every cms_ table. */
|
|
1244
|
+
export function cmsPolicies(opts = {}) {
|
|
1245
|
+
const p = opts.prefix ?? "cms";
|
|
1246
|
+
const tables = ["cms_content_types", "cms_block_types", "cms_blocks", "cms_pages", "cms_page_blocks", "cms_page_revisions", "cms_media", "cms_audit"];
|
|
1247
|
+
const editorPolicies = [];
|
|
1248
|
+
for (const table of tables) {
|
|
1249
|
+
for (const action of ["read", "create", "update", "delete"]) {
|
|
1250
|
+
editorPolicies.push(policy(`${p}:editor:${table}:${action}`, table, action, allow()));
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
return {
|
|
1254
|
+
public: [
|
|
1255
|
+
// Only published pages are readable; the snapshot carries the content.
|
|
1256
|
+
policy(`${p}:public:pages:read`, "cms_pages", "read", { where: { status: "published" } }),
|
|
1257
|
+
// getPage reads the latest revision snapshot. Scope the grant by the revision's
|
|
1258
|
+
// PAGE being currently published (a relation-traversal where, compiled to a
|
|
1259
|
+
// subquery), so a revision of a later-unpublished/archived page is never publicly
|
|
1260
|
+
// readable — least-privilege even for a future revision-listing handler.
|
|
1261
|
+
policy(`${p}:public:revisions:read`, "cms_page_revisions", "read", { where: { page: { status: "published" } } }),
|
|
1262
|
+
// Media metadata is public (the bytes are separately gated by signed urls).
|
|
1263
|
+
policy(`${p}:public:media:read`, "cms_media", "read", allow()),
|
|
1264
|
+
],
|
|
1265
|
+
editor: editorPolicies,
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
// --- deferred tasks (scheduled publish/unpublish) ----------------------------
|
|
1269
|
+
/** Task handlers backing `schedulePage`. Register via `app.tasks = { ...cmsTasks }`.
|
|
1270
|
+
* They run with a privileged, system-scoped ctx off the write path (the outbox drain).
|
|
1271
|
+
*
|
|
1272
|
+
* Each task validates its INTENT TOKEN against the page's current `scheduledAt`/`unpublishAt`
|
|
1273
|
+
* (set by `schedulePage`, cleared/overwritten by a manual publish/unpublish or a reschedule).
|
|
1274
|
+
* A task whose token no longer matches is a no-op — that's how a superseded/cancelled
|
|
1275
|
+
* schedule, and an at-least-once duplicate delivery, are neutralized (outbox tasks can't be
|
|
1276
|
+
* recalled). NOTE on atomicity: unlike the interactive `publishPage` (one mutation
|
|
1277
|
+
* transaction), the drain runs a handler WITHOUT a surrounding transaction, so a crash
|
|
1278
|
+
* between the revision insert and the page update leaves the page unpublished with an orphan
|
|
1279
|
+
* revision until the next at-least-once redelivery re-runs (the token still matches, so it
|
|
1280
|
+
* completes). Acceptable for a scheduled job; the interactive path is atomic. */
|
|
1281
|
+
export const cmsTasks = {
|
|
1282
|
+
"cms:publish": async (ctx, payload) => {
|
|
1283
|
+
const { pageId, token } = asObj(payload);
|
|
1284
|
+
if (!pageId)
|
|
1285
|
+
return;
|
|
1286
|
+
const db = cdb(ctx);
|
|
1287
|
+
const rows = await db.find({ from: "cms_pages", where: { id: pageId }, limit: 1 });
|
|
1288
|
+
const page = rows[0];
|
|
1289
|
+
if (!page)
|
|
1290
|
+
return;
|
|
1291
|
+
// Intent check: only publish if this task is still the page's active schedule. A
|
|
1292
|
+
// reschedule (new token), a manual publish/unpublish (token cleared), or a duplicate
|
|
1293
|
+
// delivery after this task already ran (token cleared below) all make this a no-op.
|
|
1294
|
+
if (String(page.scheduledAt ?? "") !== String(token ?? ""))
|
|
1295
|
+
return;
|
|
1296
|
+
const from = String(page.status);
|
|
1297
|
+
await doPublish(db, page, null, "scheduled publish"); // actor null = system
|
|
1298
|
+
await db.insert("cms_audit", { pageId, action: "publish", fromStatus: from, toStatus: "published", actor: null, note: "scheduled" });
|
|
1299
|
+
},
|
|
1300
|
+
"cms:unpublish": async (ctx, payload) => {
|
|
1301
|
+
const { pageId, token } = asObj(payload);
|
|
1302
|
+
if (!pageId)
|
|
1303
|
+
return;
|
|
1304
|
+
const db = cdb(ctx);
|
|
1305
|
+
const rows = await db.find({ from: "cms_pages", where: { id: pageId }, limit: 1 });
|
|
1306
|
+
const page = rows[0];
|
|
1307
|
+
if (!page)
|
|
1308
|
+
return;
|
|
1309
|
+
if (String(page.unpublishAt ?? "") !== String(token ?? ""))
|
|
1310
|
+
return; // superseded/cancelled
|
|
1311
|
+
const from = String(page.status);
|
|
1312
|
+
await db.update("cms_pages", pageId, { status: "archived", unpublishAt: null, updatedAt: nowStamp() });
|
|
1313
|
+
await db.insert("cms_audit", { pageId, action: "unpublish", fromStatus: from, toStatus: "archived", actor: null, note: "scheduled" });
|
|
1314
|
+
},
|
|
1315
|
+
};
|
|
1316
|
+
const xmlEscape = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1317
|
+
/** Build a sitemap.xml body from published-page entries. */
|
|
1318
|
+
export function sitemapXml(entries, opts) {
|
|
1319
|
+
const toUrl = opts.pageUrl ?? ((e, origin) => `${origin}/${e.locale}/${e.slug}`);
|
|
1320
|
+
const urls = entries
|
|
1321
|
+
.map((e) => {
|
|
1322
|
+
const loc = xmlEscape(toUrl(e, opts.origin));
|
|
1323
|
+
const lastmod = e.updatedAt ? `<lastmod>${xmlEscape(e.updatedAt.slice(0, 10))}</lastmod>` : "";
|
|
1324
|
+
return ` <url><loc>${loc}</loc>${lastmod}</url>`;
|
|
1325
|
+
})
|
|
1326
|
+
.join("\n");
|
|
1327
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
|
|
1328
|
+
}
|
|
1329
|
+
/** Build a robots.txt body pointing at the sitemap. */
|
|
1330
|
+
export function robotsTxt(opts) {
|
|
1331
|
+
const dis = (opts.disallow ?? []).map((p) => `Disallow: ${p}`).join("\n");
|
|
1332
|
+
return `User-agent: *\n${dis ? dis + "\n" : "Allow: /\n"}Sitemap: ${opts.origin}/sitemap.xml\n`;
|
|
1333
|
+
}
|
|
1334
|
+
/** Turnkey public routes for `GET /sitemap.xml` and `GET /robots.txt`. Spread into
|
|
1335
|
+
* `app.routes`. The sitemap pulls published pages via `callPrivileged(listPublishedPages)`.
|
|
1336
|
+
* `origin` defaults to the request's origin; `pageUrl` customizes the URL shape. */
|
|
1337
|
+
export function cmsRoutes(opts = {}) {
|
|
1338
|
+
const tenant = opts.tenant ?? "main";
|
|
1339
|
+
return [
|
|
1340
|
+
{
|
|
1341
|
+
method: "GET",
|
|
1342
|
+
path: "/sitemap.xml",
|
|
1343
|
+
handler: async (request, _env, ctx) => {
|
|
1344
|
+
const origin = opts.origin ?? new URL(request.url).origin;
|
|
1345
|
+
const res = await ctx.callPrivileged({ name: "listPublishedPages", tenant, roles: ["admin"] });
|
|
1346
|
+
const body = (await res.json().catch(() => ({})));
|
|
1347
|
+
const xml = sitemapXml(body.result ?? [], { origin, pageUrl: opts.pageUrl });
|
|
1348
|
+
return new Response(xml, { headers: { "content-type": "application/xml; charset=utf-8" } });
|
|
1349
|
+
},
|
|
1350
|
+
},
|
|
1351
|
+
{
|
|
1352
|
+
method: "GET",
|
|
1353
|
+
path: "/robots.txt",
|
|
1354
|
+
handler: async (request) => {
|
|
1355
|
+
const origin = opts.origin ?? new URL(request.url).origin;
|
|
1356
|
+
return new Response(robotsTxt({ origin, disallow: opts.disallow }), { headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
1357
|
+
},
|
|
1358
|
+
},
|
|
1359
|
+
];
|
|
1360
|
+
}
|