// Shared service for CMS page operations. Called by MCP tools today; // admin routes will follow under a separate service-extraction PR // (Phase P1/P4 in plans/mcp-project/mcp-project.md). // // Each operation is responsible for: validation, DB writes (in a // transaction when more than one row changes), activity logging // (post-commit unless inside a transaction), and cache invalidation // (always post-commit). import { sql } from 'kysely' import type { Kysely } from 'kysely' import type { H3Error } from 'h3' import { db } from '../utils/database' import { purgeCmsPage, purgeCmsCategory } from '../utils/cmsCache' import { ENABLED_LANGUAGE_CODES } from '~~/config/languages' import type { Page, PageTranslation } from '../database/pages' import type { Database } from '../database/schema' export interface PageActor { userId: string source: 'admin-ui' | 'mcp' clientId?: string } export interface PageListItem { id: string slug: string category_id: string | null category_slug: string | null theme: 'default' | 'green' menu_order: number translations: Array<{ locale: string title: string status: 'draft' | 'published' updated_at: string }> } export interface ListPagesOptions { category_id?: string | null status?: 'draft' | 'published' | 'any' locale?: string query?: string limit?: number cursor?: string } export interface ListPagesResult { pages: PageListItem[] nextCursor: string | null } interface CursorState { last_menu_order: number last_id: string } function encodeCursor(state: CursorState): string { return Buffer.from(JSON.stringify(state)).toString('base64url') } function decodeCursor(cursor: string): CursorState | null { try { const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) if (typeof parsed?.last_menu_order !== 'number' || typeof parsed?.last_id !== 'string') { return null } return parsed } catch { return null } } const DEFAULT_LIMIT = 50 const MAX_LIMIT = 200 export async function listCmsPages(opts: ListPagesOptions = {}): Promise { const limit = Math.min(MAX_LIMIT, Math.max(1, opts.limit ?? DEFAULT_LIMIT)) const cursor = opts.cursor ? decodeCursor(opts.cursor) : null let q = db .selectFrom('pages') .leftJoin('categories', 'categories.id', 'pages.category_id') .select([ 'pages.id', 'pages.slug', 'pages.category_id', 'pages.menu_order', 'pages.theme', 'categories.slug as category_slug' ]) if (opts.category_id !== undefined) { if (opts.category_id === null) { q = q.where('pages.category_id', 'is', null) } else { q = q.where('pages.category_id', '=', opts.category_id) } } if (opts.query) { const pattern = `%${opts.query.toLowerCase()}%` q = q.where(sql`lower(pages.slug) like ${pattern}`) } if (cursor) { q = q.where(sql`(pages.menu_order, pages.id) > (${cursor.last_menu_order}, ${cursor.last_id})`) } const rows = await q .orderBy('pages.menu_order', 'asc') .orderBy('pages.id', 'asc') .limit(limit + 1) .execute() const pageRows = rows.slice(0, limit) const hasMore = rows.length > limit const nextCursor = hasMore && pageRows.length > 0 ? encodeCursor({ last_menu_order: pageRows[pageRows.length - 1]!.menu_order, last_id: pageRows[pageRows.length - 1]!.id }) : null if (pageRows.length === 0) return { pages: [], nextCursor: null } const pageIds = pageRows.map(r => r.id) let tQuery = db .selectFrom('page_translations') .select(['page_id', 'locale', 'title', 'status', 'updated']) .where('page_id', 'in', pageIds) if (opts.locale) { tQuery = tQuery.where('locale', '=', opts.locale) if (opts.status && opts.status !== 'any') { tQuery = tQuery.where('status', '=', opts.status) } } let translations = await tQuery.execute() // Title substring match: requires a translation row to exist for // the page (any locale). When a query is provided we also keep // pages whose slug matched (already filtered above). if (opts.query) { const pattern = opts.query.toLowerCase() translations = translations.filter(t => t.title.toLowerCase().includes(pattern)) } const tByPage = new Map>() for (const t of translations) { const list = tByPage.get(t.page_id) ?? [] list.push({ locale: t.locale, title: t.title, status: t.status, updated_at: new Date(t.updated as unknown as string).toISOString() }) tByPage.set(t.page_id, list) } const out: PageListItem[] = pageRows.map(p => ({ id: p.id, slug: p.slug, category_id: p.category_id, category_slug: p.category_slug ?? null, theme: p.theme as 'default' | 'green', menu_order: p.menu_order, translations: tByPage.get(p.id) ?? [] })) return { pages: out, nextCursor } } export interface FullPage { page: Page translations: PageTranslation[] category_slug: string | null } export async function getCmsPage(input: { id?: string; slug?: string }): Promise { let pageQuery = db.selectFrom('pages').selectAll() if (input.id) pageQuery = pageQuery.where('id', '=', input.id) else if (input.slug) pageQuery = pageQuery.where('slug', '=', input.slug) else return null const page = await pageQuery.executeTakeFirst() if (!page) return null const [translations, category] = await Promise.all([ db .selectFrom('page_translations') .selectAll() .where('page_id', '=', page.id) .orderBy('locale', 'asc') .execute(), page.category_id ? db .selectFrom('categories') .select('slug') .where('id', '=', page.category_id) .executeTakeFirst() : Promise.resolve(null) ]) return { page, translations, category_slug: category?.slug ?? null } } // Validates a slug shape and uniqueness. Throws an H3-shaped error so // the MCP layer's mcpError mapper can translate to a clean response. function err(statusCode: number, message: string): H3Error { return Object.assign(new Error(message), { statusCode, statusMessage: message, data: { message } }) as unknown as H3Error } // Normalize slug input: trim whitespace, strip leading/trailing // slashes. Single source of truth — admin routes and MCP tools both // pass raw input; services normalize before validating. Empty result // passes through; the regex check downstream rejects empty strings. export function normalizeSlugInput(raw: string | null | undefined): string { return (raw ?? '').trim().replace(/^\/+|\/+$/g, '') } // Pass an `executor` (a Kysely transaction handle) when validation // is part of a larger atomic operation — the read-side category // existence/prefix check and the page-slug-uniqueness check then // run on the same connection as the subsequent insert/update, so // a concurrent category rename or page-slug change can't slip // between validation and write. export async function validatePageSlug( slug: string, opts: { categoryId: string | null; excludePageId?: string }, executor: Kysely = db ): Promise { if (!/^[a-z0-9][a-z0-9/-]*$/.test(slug)) { throw err(400, 'slug must be lowercase letters, digits, dashes, and slashes') } if (slug.endsWith('/')) { throw err(400, 'slug cannot end with /') } if (opts.categoryId) { const category = await executor .selectFrom('categories') .select(['slug']) .where('id', '=', opts.categoryId) .executeTakeFirst() if (!category) throw err(400, 'Category not found') if (!slug.startsWith(`${category.slug}/`)) { throw err(400, `Slug must start with "${category.slug}/"`) } } else { const collidingCategory = await executor .selectFrom('categories') .select('id') .where('slug', '=', slug) .executeTakeFirst() if (collidingCategory) { throw err(409, `"${slug}" is already used by a category.`) } } let pageQuery = executor .selectFrom('pages') .select('id') .where('slug', '=', slug) if (opts.excludePageId) { pageQuery = pageQuery.where('id', '!=', opts.excludePageId) } const collidingPage = await pageQuery.executeTakeFirst() if (collidingPage) { throw err(409, 'A page with that slug already exists') } } export interface CreatePageInput { slug: string category_id?: string | null menu_order?: number theme?: 'default' | 'green' custom_css?: string | null translation?: { locale: string title: string body_json: Record excerpt?: string | null featured_image?: string | null meta_title?: string | null meta_description?: string | null og_image?: string | null status?: 'draft' | 'published' } } // Creates a page (and optionally an inline initial translation) in a // single transaction. The audit hook receives the transaction's // executor; callers (admin routes, MCP tools) are expected to pass a // `throwOnError: true` to logEvent (or use mcpLog, which does this // automatically when an executor is provided) so an audit-insert // failure inside the transaction rolls back the page insert. export interface CreateCmsPageAuditContext { pageId: string // The translation row id, set only when an inline translation was // created in the same transaction. Lets callers emit a separate // CREATE event on page_translations keyed by the actual row id so // audit-log greps on `record_id` find the translation. translationId: string | null } export async function createCmsPage( input: CreatePageInput, audit: (ctx: CreateCmsPageAuditContext, executor: unknown) => Promise ): Promise<{ page: Page; translation: PageTranslation | null; slugsToPurge: string[]; categoriesToPurge: string[] }> { const slug = normalizeSlugInput(input.slug) if (!slug) throw err(400, 'slug is required') if (input.translation) { if (!ENABLED_LANGUAGE_CODES.includes(input.translation.locale)) { throw err(400, 'locale is not enabled') } } // Use the normalized form everywhere downstream — input.slug never // touches the DB after this point. input = { ...input, slug } const result = await db.transaction().execute(async (tx) => { // Validate the slug inside the transaction so the category-prefix // check and slug-uniqueness check run on the same connection as // the insert. Without this a concurrent category rename could // change the prefix between validation and write. await validatePageSlug(slug, { categoryId: input.category_id ?? null }, tx) const page = await tx .insertInto('pages') .values({ slug: input.slug, category_id: input.category_id ?? null, menu_order: input.menu_order ?? 0, ...(input.theme ? { theme: input.theme } : {}), ...(input.custom_css !== undefined ? { custom_css: input.custom_css } : {}) }) .returningAll() .executeTakeFirstOrThrow() let translation: PageTranslation | null = null if (input.translation) { const t = input.translation translation = await tx .insertInto('page_translations') .values({ page_id: page.id, locale: t.locale, title: t.title, body_json: t.body_json, excerpt: t.excerpt ?? null, featured_image: t.featured_image ?? null, meta_title: t.meta_title ?? null, meta_description: t.meta_description ?? null, og_image: t.og_image ?? null, status: t.status ?? 'draft' }) .returningAll() .executeTakeFirstOrThrow() } await audit({ pageId: page.id, translationId: translation?.id ?? null }, tx) return { page, translation } }) const slugsToPurge = [result.page.slug] const categoriesToPurge: string[] = [] if (result.page.category_id) categoriesToPurge.push(result.page.category_id) return { ...result, slugsToPurge, categoriesToPurge } } export interface UpdatePageInput { id: string slug?: string category_id?: string | null menu_order?: number theme?: 'default' | 'green' custom_css?: string | null } export interface UpdatePageResult { page: Page slugsToPurge: string[] categoriesToPurge: string[] changes: Record } export async function updateCmsPage(input: UpdatePageInput): Promise { // Wrap reads + slug validation + UPDATE in one transaction so a // concurrent rename of the source or target category — or a // concurrent slug change on another page — can't slip between the // category-prefix calculation and the row write. The bare-UPDATE // version that lived here briefly had a race window where the // page would commit with a stale prefix relative to the category's // current slug. return await db.transaction().execute(async (tx) => { const existing = await tx .selectFrom('pages') .selectAll() .where('id', '=', input.id) .executeTakeFirst() if (!existing) throw err(404, 'Page not found') const slugsToPurge = new Set([existing.slug]) const categoriesToPurge = new Set() const changes: Record = {} let finalCategoryId = existing.category_id if (input.category_id !== undefined) { finalCategoryId = input.category_id ?? null } let finalSlug = existing.slug if (input.slug !== undefined) { finalSlug = normalizeSlugInput(input.slug) } else if (input.category_id !== undefined && finalCategoryId !== existing.category_id) { // Category change without slug change: rewrite the prefix. let leaf = existing.slug if (existing.category_id) { const oldCat = await tx .selectFrom('categories') .select('slug') .where('id', '=', existing.category_id) .executeTakeFirst() if (oldCat && existing.slug.startsWith(`${oldCat.slug}/`)) { leaf = existing.slug.slice(oldCat.slug.length + 1) } } if (finalCategoryId) { const newCat = await tx .selectFrom('categories') .select('slug') .where('id', '=', finalCategoryId) .executeTakeFirst() if (!newCat) throw err(400, 'Category not found') finalSlug = `${newCat.slug}/${leaf}` } else { finalSlug = leaf } } if (finalSlug !== existing.slug) { await validatePageSlug(finalSlug, { categoryId: finalCategoryId, excludePageId: input.id }, tx) changes.slug = finalSlug } if (finalCategoryId !== existing.category_id) { changes.category_id = finalCategoryId } if (input.menu_order !== undefined && input.menu_order !== existing.menu_order) { changes.menu_order = input.menu_order } if (input.theme !== undefined && input.theme !== existing.theme) { changes.theme = input.theme } if (input.custom_css !== undefined && input.custom_css !== existing.custom_css) { changes.custom_css = input.custom_css } if (Object.keys(changes).length === 0) { return { page: existing, slugsToPurge: Array.from(slugsToPurge), categoriesToPurge: Array.from(categoriesToPurge), changes } } const updated = await tx .updateTable('pages') .set({ ...changes, updated: sql`now()` }) .where('id', '=', input.id) .returningAll() .executeTakeFirstOrThrow() slugsToPurge.add(updated.slug) if (updated.slug !== existing.slug || updated.menu_order !== existing.menu_order || updated.category_id !== existing.category_id) { if (existing.category_id) categoriesToPurge.add(existing.category_id) if (updated.category_id) categoriesToPurge.add(updated.category_id) } return { page: updated, slugsToPurge: Array.from(slugsToPurge), categoriesToPurge: Array.from(categoriesToPurge), changes } }) } export async function deleteCmsPage(id: string): Promise<{ slug: string; categoryId: string | null }> { const existing = await db .selectFrom('pages') .select(['id', 'slug', 'category_id']) .where('id', '=', id) .executeTakeFirst() if (!existing) throw err(404, 'Page not found') await db.deleteFrom('pages').where('id', '=', id).execute() return { slug: existing.slug, categoryId: existing.category_id } } export async function applyPageInvalidations(slugs: string[], categoryIds: string[]): Promise { await Promise.all(slugs.map(s => purgeCmsPage(s))) // Skip the slugs we just purged from the category-sibling fan-out; // otherwise purgeCmsCategory enumerates every member (including the // page we already touched) and re-purges keys for no benefit. await Promise.all(categoryIds.map(id => purgeCmsCategory(id, slugs))) }