From cf429da5349b3c35897367d37c58adfa2f3c9170 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 31 May 2026 20:48:58 +1000 Subject: [PATCH] feat(api): pages routes + revisions + backlinks Add lib/api/routes/pages.js: list by space, create/get/patch/delete, get-by-slug, list revisions, and backlinks via entity_links.listTo enriched with the source entity's title (whitelisted entity_type set to keep the dynamic-table SELECT bounded). Co-Authored-By: Claude Opus 4.7 --- lib/api/index.js | 3 + lib/api/routes/pages.js | 123 ++++++++++++++++++++++++++++++++++++++++ tests/api/pages.test.js | 74 ++++++++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 lib/api/routes/pages.js create mode 100644 tests/api/pages.test.js diff --git a/lib/api/index.js b/lib/api/index.js index 5c58cc5..072deb0 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -8,6 +8,7 @@ import { spacesScopedRouter as tasksBySpaceRouter, projectsScopedRouter as tasksByProjectRouter } from './routes/tasks.js'; +import { router as pagesRouter, spacesScopedRouter as pagesBySpaceRouter } from './routes/pages.js'; export function mountApi(app) { const api = Router(); @@ -16,9 +17,11 @@ export function mountApi(app) { api.use('/spaces', spacesRouter); api.use('/spaces/:space_id/projects', projectsBySpaceRouter); api.use('/spaces/:space_id/tasks', tasksBySpaceRouter); + api.use('/spaces/:space_id/pages', pagesBySpaceRouter); api.use('/projects', projectsRouter); api.use('/projects/:project_id/tasks', tasksByProjectRouter); api.use('/tasks', tasksRouter); + api.use('/pages', pagesRouter); api.use((_req, _res, next) => next(new NotFoundError('route not found'))); diff --git a/lib/api/routes/pages.js b/lib/api/routes/pages.js new file mode 100644 index 0000000..e6c578e --- /dev/null +++ b/lib/api/routes/pages.js @@ -0,0 +1,123 @@ +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); + }) +); diff --git a/tests/api/pages.test.js b/tests/api/pages.test.js new file mode 100644 index 0000000..9b225fa --- /dev/null +++ b/tests/api/pages.test.js @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import request from 'supertest'; +import { setup } from './helpers.js'; +import * as spaces from '../../lib/db/repos/spaces.js'; +import * as links from '../../lib/db/repos/links.js'; + +let app, ownerHeaders, space; +const owner = { kind: 'user', id: null }; + +beforeAll(async () => { ({ app, ownerHeaders } = await setup()); }); +beforeEach(async () => { + space = await spaces.create({ slug: `s-${Date.now()}-${Math.random().toString(36).slice(2,5)}`, name: 'S' }, owner); +}); + +describe('pages routes', () => { + it('POST creates page with revision', async () => { + const res = await request(app).post(`/api/spaces/${space.id}/pages`).set(ownerHeaders) + .send({ slug: 'intro', title: 'Intro', body_md: 'hello' }); + expect(res.status).toBe(201); + const revs = await request(app).get(`/api/pages/${res.body.id}/revisions`).set(ownerHeaders); + expect(revs.body.length).toBe(1); + expect(revs.body[0].body_md).toBe('hello'); + }); + + it('PATCH body_md adds another revision', async () => { + const c = await request(app).post(`/api/spaces/${space.id}/pages`).set(ownerHeaders) + .send({ slug: 'pg', title: 'PG', body_md: 'v1' }); + await request(app).patch(`/api/pages/${c.body.id}`).set(ownerHeaders) + .send({ body_md: 'v2' }); + const revs = await request(app).get(`/api/pages/${c.body.id}/revisions`).set(ownerHeaders); + expect(revs.body.length).toBe(2); + expect(revs.body[0].body_md).toBe('v2'); + expect(revs.body[1].body_md).toBe('v1'); + }); + + it('GET by-slug returns the page', async () => { + await request(app).post(`/api/spaces/${space.id}/pages`).set(ownerHeaders) + .send({ slug: 'lookup', title: 'L', body_md: '' }); + const res = await request(app) + .get(`/api/spaces/${space.id}/pages/by-slug/lookup`).set(ownerHeaders); + expect(res.status).toBe(200); + expect(res.body.slug).toBe('lookup'); + }); + + it('POST duplicate slug → 400', async () => { + await request(app).post(`/api/spaces/${space.id}/pages`).set(ownerHeaders) + .send({ slug: 'dup', title: 'A' }); + const res = await request(app).post(`/api/spaces/${space.id}/pages`).set(ownerHeaders) + .send({ slug: 'dup', title: 'B' }); + expect(res.status).toBe(400); + }); + + it('GET /:id/backlinks returns links pointing at the page', async () => { + const pageRes = await request(app).post(`/api/spaces/${space.id}/pages`).set(ownerHeaders) + .send({ slug: 'target', title: 'Target' }); + const otherRes = await request(app).post(`/api/spaces/${space.id}/pages`).set(ownerHeaders) + .send({ slug: 'src', title: 'Source' }); + await links.create('page', otherRes.body.id, 'page', pageRes.body.id, 'mentions'); + const res = await request(app).get(`/api/pages/${pageRes.body.id}/backlinks`).set(ownerHeaders); + expect(res.status).toBe(200); + expect(res.body.length).toBe(1); + expect(res.body[0].from_id).toBe(otherRes.body.id); + expect(res.body[0].source_title).toBe('Source'); + }); + + it('DELETE returns 204 then GET → 404', async () => { + const c = await request(app).post(`/api/spaces/${space.id}/pages`).set(ownerHeaders) + .send({ slug: 'gone', title: 'Gone' }); + const del = await request(app).delete(`/api/pages/${c.body.id}`).set(ownerHeaders); + expect(del.status).toBe(204); + const g = await request(app).get(`/api/pages/${c.body.id}`).set(ownerHeaders); + expect(g.status).toBe(404); + }); +});