// CMS pages model. Owns the rules the API surface must not bypass: // - blocks are validated against the block registry and sanitized on every // save (the authoritative gate — a direct API call can't skip it); // - the DB row is mapped to/from the grouped API shape (metadata / settings); // - slug is validated + reserved-checked at create and is immutable after; // - `protected` can be turned ON via a normal update but only OFF via the // dedicated unprotect path (see unprotect()), enforced here regardless of // what the request body contains. // // Business/validation failures throw a PageError carrying an HTTP status + code // so the controller can translate without knowing the rules. const pagesDb = require('./pages.db') const { isReservedSlug } = require('./reservedSlugs') const { validateBlocks, sanitizeBlocks } = require('../../blocks') const LAYOUTS = ['default', 'full_width', 'landing'] const NAV_GROUPS = ['main', 'footer', 'account', 'hidden'] const STATUSES = ['draft', 'published'] const SLUG_RE = /^[a-z0-9-]+$/ const MAX_SLUG = 160 class PageError extends Error { constructor(status, code, message, extra) { super(message) this.name = 'PageError' this.status = status this.code = code if (extra) Object.assign(this, extra) } } // ── Serialization (row → API shape) ─────────────────────────────────── function parseBlocks(raw) { if (raw == null || raw === '') return [] try { const parsed = JSON.parse(raw) return Array.isArray(parsed) ? parsed : [] } catch { return [] } } function serialize(row) { if (!row) return null return { id: row.id, slug: row.slug, title: row.title, status: row.status, blocks: parseBlocks(row.blocks), metadata: { seoTitle: row.seo_title, metaDescription: row.meta_description, ogImage: row.og_image, canonicalUrl: row.canonical_url, robots: row.robots, }, settings: { layout: row.layout, showInNav: Boolean(row.show_in_nav), navGroup: row.nav_group, navOrder: row.nav_order, protected: Boolean(row.protected), }, authorId: row.author_id, createdAt: row.created_at, updatedAt: row.updated_at, publishedAt: row.published_at, } } function serializeSummary(row) { return { id: row.id, slug: row.slug, title: row.title, status: row.status, protected: Boolean(row.protected), showInNav: Boolean(row.show_in_nav), navGroup: row.nav_group, navOrder: row.nav_order, updatedAt: row.updated_at, publishedAt: row.published_at, } } // ── Field validation / mapping ──────────────────────────────────────── function assertSlug(slug) { if (typeof slug !== 'string' || !SLUG_RE.test(slug) || slug.length > MAX_SLUG) { throw new PageError(400, 'invalid_slug', 'Slug must be lowercase letters, numbers and dashes.') } if (isReservedSlug(slug)) { throw new PageError(400, 'reserved_slug', `"${slug}" is a reserved slug.`) } } function assertStatus(status) { if (status !== undefined && !STATUSES.includes(status)) { throw new PageError(400, 'invalid_status', `status must be one of ${STATUSES.join(', ')}.`) } } // Validate + sanitize blocks; returns a JSON string ready to store. function buildBlocks(blocks) { const { valid, errors } = validateBlocks(blocks) if (!valid) { throw new PageError(400, 'invalid_blocks', 'One or more blocks are invalid.', { errors }) } return JSON.stringify(sanitizeBlocks(blocks)) } // Map the grouped `metadata` object to DB columns. Only keys present in the // input are returned, so a PATCH touches only what it sends. function mapMetadata(metadata) { const cols = {} if (!metadata || typeof metadata !== 'object') return cols const strOrNull = (v, max, field) => { if (v === null || v === undefined || v === '') return null if (typeof v !== 'string' || v.length > max) { throw new PageError(400, 'invalid_metadata', `${field} must be a string of at most ${max} characters.`) } return v } if ('seoTitle' in metadata) cols.seo_title = strOrNull(metadata.seoTitle, 200, 'seoTitle') if ('metaDescription' in metadata) cols.meta_description = strOrNull(metadata.metaDescription, 400, 'metaDescription') if ('ogImage' in metadata) cols.og_image = strOrNull(metadata.ogImage, 500, 'ogImage') if ('canonicalUrl' in metadata) cols.canonical_url = strOrNull(metadata.canonicalUrl, 500, 'canonicalUrl') if ('robots' in metadata) cols.robots = strOrNull(metadata.robots, 100, 'robots') return cols } // Map the grouped `settings` object to DB columns (except `protected`, which is // handled by the caller so the unprotect rule stays centralized). function mapSettings(settings) { const cols = {} if (!settings || typeof settings !== 'object') return cols if ('layout' in settings) { if (!LAYOUTS.includes(settings.layout)) { throw new PageError(400, 'invalid_settings', `layout must be one of ${LAYOUTS.join(', ')}.`) } cols.layout = settings.layout } if ('showInNav' in settings) { if (typeof settings.showInNav !== 'boolean') { throw new PageError(400, 'invalid_settings', 'showInNav must be a boolean.') } cols.show_in_nav = settings.showInNav ? 1 : 0 } if ('navGroup' in settings) { if (settings.navGroup !== null && !NAV_GROUPS.includes(settings.navGroup)) { throw new PageError(400, 'invalid_settings', `navGroup must be null or one of ${NAV_GROUPS.join(', ')}.`) } cols.nav_group = settings.navGroup } if ('navOrder' in settings) { if (settings.navOrder !== null && !Number.isInteger(settings.navOrder)) { throw new PageError(400, 'invalid_settings', 'navOrder must be an integer or null.') } cols.nav_order = settings.navOrder } return cols } // ── Reads ───────────────────────────────────────────────────────────── async function list() { const rows = await pagesDb.listSummaries() return rows.map(serializeSummary) } async function getById(id) { return serialize(await pagesDb.findById(id)) } // Public read by slug. Non-admins only see published pages (returns null for a // draft so the caller can 404 it indistinguishably from a missing page). async function getBySlug(slug, { includeUnpublished = false } = {}) { const row = await pagesDb.findBySlug(slug) if (!row) return null if (!includeUnpublished && row.status !== 'published') return null return serialize(row) } // Raw row (for the controller's protected/status checks without re-serializing). async function getRawById(id) { return pagesDb.findById(id) } // ── Writes ──────────────────────────────────────────────────────────── async function create(input, authorId) { const { slug, title, blocks = [], status = 'draft', metadata, settings } = input assertSlug(slug) assertStatus(status) if (typeof title !== 'string' || title.trim() === '' || title.length > 200) { throw new PageError(400, 'invalid_title', 'Title is required (max 200 characters).') } const row = { slug, title: title.trim(), blocks: buildBlocks(blocks), status, author_id: authorId, ...mapMetadata(metadata), ...mapSettings(settings), protected: settings && settings.protected === true ? 1 : 0, published_at: status === 'published' ? new Date() : null, } let id try { id = await pagesDb.insert(row) } catch (err) { if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) { throw new PageError(409, 'slug_taken', `A page with slug "${slug}" already exists.`) } throw err } return getById(id) } async function update(id, patch) { const current = await pagesDb.findById(id) if (!current) throw new PageError(404, 'not_found', 'Page not found.') // slug is immutable after create — reject an attempt rather than silently // ignoring it, so the caller knows their change didn't take. if (patch.slug !== undefined && patch.slug !== current.slug) { throw new PageError(400, 'slug_immutable', 'A page slug cannot be changed after creation.') } const fields = {} if (patch.title !== undefined) { if (typeof patch.title !== 'string' || patch.title.trim() === '' || patch.title.length > 200) { throw new PageError(400, 'invalid_title', 'Title is required (max 200 characters).') } fields.title = patch.title.trim() } if (patch.blocks !== undefined) { fields.blocks = buildBlocks(patch.blocks) } if (patch.status !== undefined) { assertStatus(patch.status) fields.status = patch.status // Stamp published_at the first time a page becomes published. if (patch.status === 'published' && !current.published_at) { fields.published_at = new Date() } } Object.assign(fields, mapMetadata(patch.metadata)) Object.assign(fields, mapSettings(patch.settings)) // Protected transitions: ON is allowed here; OFF is not (must go through the // password-gated unprotect endpoint), regardless of the request body. if (patch.settings && 'protected' in patch.settings) { const want = patch.settings.protected if (want === true) { fields.protected = 1 } else if (want === false && current.protected) { throw new PageError(403, 'unprotect_required', 'Disabling protection requires the unprotect endpoint.') } // want === false while already unprotected → no-op. } await pagesDb.update(id, fields) return getById(id) } async function remove(id) { const current = await pagesDb.findById(id) if (!current) throw new PageError(404, 'not_found', 'Page not found.') if (current.protected) { throw new PageError(403, 'page_protected', 'This page is protected and cannot be deleted.') } await pagesDb.remove(id) return { id } } // Flip protected → false. The controller performs the password step-up before // calling this; the model just applies it. async function unprotect(id) { const current = await pagesDb.findById(id) if (!current) throw new PageError(404, 'not_found', 'Page not found.') await pagesDb.update(id, { protected: 0 }) return getById(id) } module.exports = { PageError, LAYOUTS, NAV_GROUPS, STATUSES, serialize, list, getById, getBySlug, getRawById, create, update, remove, unprotect, }