import { Router } from 'express'; import { z } from 'zod'; import * as repo from '../../db/repos/pages.js'; import * as links from '../../db/repos/links.js'; import { pool } from '../../db/pool.js'; import { validate } from '../validate.js'; import { NotFoundError, ValidationError, asyncWrap } from '../errors.js'; const createSchema = z.object({ slug: z.string().min(1).max(128).regex(/^[a-z0-9-]+$/), title: z.string().min(1).max(500), body_md: z.string().optional(), parent_id: z.string().uuid().nullable().optional() }); const patchSchema = z.object({ slug: z.string().min(1).max(128).regex(/^[a-z0-9-]+$/).optional(), title: z.string().min(1).max(500).optional(), body_md: z.string().optional(), body_html: z.string().nullable().optional(), parent_id: z.string().uuid().nullable().optional() }); const idParams = z.object({ id: z.string().uuid() }); const spaceParams = z.object({ space_id: z.string().uuid() }); const spaceSlugParams = z.object({ space_id: z.string().uuid(), slug: z.string().min(1) }); export const router = Router(); export const spacesScopedRouter = Router({ mergeParams: true }); spacesScopedRouter.get('/', validate({ params: spaceParams }), asyncWrap(async (req, res) => { res.json(await repo.listBySpace(req.params.space_id)); }) ); spacesScopedRouter.post('/', validate({ params: spaceParams, body: createSchema }), asyncWrap(async (req, res) => { try { const row = await repo.create({ ...req.body, space_id: req.params.space_id }, req.actor); res.status(201).json(row); } catch (e) { if (e.code === '23503') throw new ValidationError('invalid space or parent', { space_id: req.params.space_id, parent_id: req.body.parent_id }); if (e.code === '23505') throw new ValidationError('slug already used in space', { slug: req.body.slug }); throw e; } }) ); spacesScopedRouter.get('/by-slug/:slug', validate({ params: spaceSlugParams }), asyncWrap(async (req, res) => { const row = await repo.getBySlug(req.params.space_id, req.params.slug); if (!row) throw new NotFoundError('page not found'); res.json(row); }) ); router.get('/:id', validate({ params: idParams }), asyncWrap(async (req, res) => { const row = await repo.getById(req.params.id); if (!row) throw new NotFoundError('page not found'); res.json(row); }) ); router.patch('/:id', validate({ params: idParams, body: patchSchema }), asyncWrap(async (req, res) => { const existing = await repo.getById(req.params.id); if (!existing) throw new NotFoundError('page not found'); const row = await repo.update(req.params.id, req.body, req.actor); res.json(row); }) ); router.delete('/:id', validate({ params: idParams }), asyncWrap(async (req, res) => { const existing = await repo.getById(req.params.id); if (!existing) throw new NotFoundError('page not found'); await repo.del(req.params.id, req.actor); res.status(204).end(); }) ); router.get('/:id/revisions', validate({ params: idParams }), asyncWrap(async (req, res) => { const existing = await repo.getById(req.params.id); if (!existing) throw new NotFoundError('page not found'); res.json(await repo.listRevisions(req.params.id)); }) ); const TITLE_BY_TYPE = { space: 'name', project: 'name', task: 'title', page: 'title', ref: 'title', resource: 'name', source_doc: 'name', conversation: 'title' }; router.get('/:id/backlinks', validate({ params: idParams }), asyncWrap(async (req, res) => { const existing = await repo.getById(req.params.id); if (!existing) throw new NotFoundError('page not found'); const rows = await links.listTo('page', req.params.id); const enriched = await Promise.all(rows.map(async link => { const col = TITLE_BY_TYPE[link.from_type]; if (!col) return { ...link, source_title: null }; const table = link.from_type + 's'; try { const { rows: [src] } = await pool.query( `SELECT ${col} AS title FROM ${table} WHERE id=$1`, [link.from_id] ); return { ...link, source_title: src?.title || null }; } catch { return { ...link, source_title: null }; } })); res.json(enriched); }) );