Files
Void-Homelab/lib/api/routes/pages.js
root 3f77f3faad feat(pages): explicit position ordering + sectioned space view
Add position column to pages (migration 020), update listBySpace to ORDER BY position, title,
expose position in update(), add to patchSchema, and replace the space view flat table with a
tree renderer grouping pages by parent_id under h4 section headers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:33:10 +10:00

143 lines
4.8 KiB
JavaScript

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';
import { requireWrite, divertToPending } from '../cap.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(),
position: z.number().int().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('/',
requireWrite('page'),
validate({ params: spaceParams, body: createSchema }),
asyncWrap(async (req, res) => {
const payload = { ...req.body, space_id: req.params.space_id };
if (req.capTier === 'suggest') {
return divertToPending(req, res, { entity_type: 'page', action: 'create', payload });
}
try {
const row = await repo.create(payload, 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',
requireWrite('page'),
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');
if (req.capTier === 'suggest') {
return divertToPending(req, res, {
entity_type: 'page', entity_id: req.params.id, action: 'update', payload: req.body
});
}
const row = await repo.update(req.params.id, req.body, req.actor);
res.json(row);
})
);
router.delete('/:id',
requireWrite('page'),
validate({ params: idParams }),
asyncWrap(async (req, res) => {
const existing = await repo.getById(req.params.id);
if (!existing) throw new NotFoundError('page not found');
if (req.capTier === 'suggest') {
return divertToPending(req, res, {
entity_type: 'page', entity_id: req.params.id, action: 'delete', payload: {}
});
}
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);
})
);