import { Router } from 'express'; import { query } from '../config/db.js'; import { requireAuth } from '../middleware/auth.js'; import { getRole, getPageWithWorkspace } from './guards.js'; const router = Router(); router.use(requireAuth); async function canRead(userId, page) { if (!page) return false; return (await getRole(page.workspace_id, userId)) !== null; } async function canWrite(userId, page) { if (!page) return false; const role = await getRole(page.workspace_id, userId); return role === 'owner' || role === 'admin' || role === 'member'; } // GET /api/workspaces/:workspaceId/pages — tree list router.get('/by-workspace/:workspaceId', async (req, res) => { const role = await getRole(req.params.workspaceId, req.user.id); if (!role) return res.status(403).json({ error: 'Not a workspace member' }); const { rows } = await query( `SELECT p.*, u.name AS author_name FROM pages p LEFT JOIN users u ON u.id = p.created_by WHERE p.workspace_id = $1 AND p.is_archived = FALSE ORDER BY COALESCE(p.parent_id::text,'') , p.position, p.updated_at DESC`, [req.params.workspaceId], ); return res.json({ pages: rows, role }); }); // POST /api/pages { workspace_id, parent_id?, book_id?, title? } router.post('/', async (req, res) => { const { workspace_id, parent_id = null, book_id = null, title = 'Untitled' } = req.body ?? {}; if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' }); const role = await getRole(workspace_id, req.user.id); if (role !== 'owner' && role !== 'admin' && role !== 'member') { return res.status(403).json({ error: 'Viewers cannot create pages' }); } if (book_id) { const { rows } = await query('SELECT id FROM books WHERE id = $1 AND workspace_id = $2', [book_id, workspace_id]); if (!rows[0]) return res.status(400).json({ error: 'Invalid book_id' }); } if (parent_id) { const parent = await getPageWithWorkspace(parent_id); if (!parent || parent.workspace_id !== workspace_id) { return res.status(400).json({ error: 'Invalid parent_id' }); } if ((parent.book_id || null) !== (book_id || null)) { return res.status(400).json({ error: 'Parent lives in a different book' }); } } const { rows } = await query( `INSERT INTO pages (workspace_id, parent_id, book_id, title, created_by) VALUES ($1,$2,$3,$4,$5) RETURNING *`, [workspace_id, parent_id, book_id, String(title).slice(0, 300) || 'Untitled', req.user.id], ); return res.status(201).json({ page: rows[0] }); }); // GET /api/pages/:id router.get('/:id', async (req, res) => { const page = await getPageWithWorkspace(req.params.id); if (!page) return res.status(404).json({ error: 'Not found' }); if (!(await canRead(req.user.id, page))) return res.status(403).json({ error: 'Forbidden' }); const { rows: comments } = await query( `SELECT c.*, u.name AS author_name FROM comments c LEFT JOIN users u ON u.id = c.author_id WHERE c.page_id = $1 ORDER BY c.created_at`, [page.id], ); return res.json({ page, comments }); }); // PATCH /api/pages/:id { title?, icon?, cover?, content_json?, content_html?, position?, parent_id?, is_archived? } router.patch('/:id', async (req, res) => { const page = await getPageWithWorkspace(req.params.id); if (!page) return res.status(404).json({ error: 'Not found' }); if (!(await canWrite(req.user.id, page))) return res.status(403).json({ error: 'Viewers cannot edit' }); const { title, icon, cover, content_json, content_html, position, parent_id, book_id, is_archived } = req.body ?? {}; if (parent_id !== undefined && parent_id !== null) { if (parent_id === page.id) return res.status(400).json({ error: 'Page cannot be its own parent' }); const parent = await getPageWithWorkspace(parent_id); if (!parent || parent.workspace_id !== page.workspace_id) { return res.status(400).json({ error: 'Invalid parent_id' }); } } if (book_id !== undefined && book_id !== null) { const { rows } = await query('SELECT id FROM books WHERE id = $1 AND workspace_id = $2', [book_id, page.workspace_id]); if (!rows[0]) return res.status(400).json({ error: 'Invalid book_id' }); } const { rows } = await query( `UPDATE pages SET title = COALESCE($1, title), icon = COALESCE($2, icon), cover = COALESCE($3, cover), content_json = COALESCE($4, content_json), content_html = COALESCE($5, content_html), position = COALESCE($6, position), parent_id = CASE WHEN $8 THEN $7 ELSE parent_id END, book_id = CASE WHEN $12 THEN $11 ELSE book_id END, is_archived = COALESCE($9, is_archived) WHERE id = $10 RETURNING *`, [ title !== undefined ? String(title).slice(0, 300) : null, icon !== undefined ? String(icon).slice(0, 20) : null, cover !== undefined ? String(cover).slice(0, 500) : null, content_json !== undefined ? JSON.stringify(content_json).slice(0, 2_000_000) : null, content_html !== undefined ? String(content_html).slice(0, 2_000_000) : null, position !== undefined ? Number(position) : null, parent_id !== undefined ? parent_id : null, parent_id !== undefined, is_archived !== undefined ? Boolean(is_archived) : null, page.id, book_id !== undefined ? book_id : null, book_id !== undefined, ], ); const updated = rows[0]; // Broadcast to realtime subscribers req.app.get('io')?.to(`page:${page.id}`).emit('page:updated', { page: updated }); return res.json({ page: updated }); }); // DELETE /api/pages/:id router.delete('/:id', async (req, res) => { const page = await getPageWithWorkspace(req.params.id); if (!page) return res.status(404).json({ error: 'Not found' }); if (!(await canWrite(req.user.id, page))) return res.status(403).json({ error: 'Viewers cannot delete' }); await query('DELETE FROM pages WHERE id = $1', [page.id]); req.app.get('io')?.to(`page:${page.id}`).emit('page:deleted', { id: page.id }); return res.status(204).end(); }); // GET /api/pages/search?q=&workspace_id= router.get('/search/query', async (req, res) => { const { q = '', workspace_id } = req.query; if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' }); if (!(await getRole(workspace_id, req.user.id))) return res.status(403).json({ error: 'Forbidden' }); const { rows } = await query( `SELECT id, title, parent_id, updated_at FROM pages WHERE workspace_id = $1 AND is_archived = FALSE AND title ILIKE $2 ORDER BY updated_at DESC LIMIT 30`, [workspace_id, `%${String(q).slice(0, 100)}%`], ); return res.json({ results: rows }); }); // POST /api/pages/:id/comments { body } router.post('/:id/comments', async (req, res) => { const page = await getPageWithWorkspace(req.params.id); if (!page) return res.status(404).json({ error: 'Not found' }); if (!(await canRead(req.user.id, page))) return res.status(403).json({ error: 'Forbidden' }); const { body = '' } = req.body ?? {}; if (String(body).trim().length < 1 || String(body).length > 5000) { return res.status(400).json({ error: 'Body must be 1-5000 chars' }); } const { rows } = await query( 'INSERT INTO comments (page_id, author_id, body) VALUES ($1,$2,$3) RETURNING *', [page.id, req.user.id, String(body).trim()], ); req.app.get('io')?.to(`page:${page.id}`).emit('comment:added', { comment: rows[0] }); return res.status(201).json({ comment: rows[0] }); }); // DELETE /api/pages/:pageId/comments/:commentId (author or admin+) router.delete('/:pageId/comments/:commentId', async (req, res) => { const page = await getPageWithWorkspace(req.params.pageId); if (!page) return res.status(404).json({ error: 'Not found' }); const { rows } = await query('SELECT * FROM comments WHERE id = $1 AND page_id = $2', [ req.params.commentId, page.id, ]); const comment = rows[0]; if (!comment) return res.status(404).json({ error: 'Comment not found' }); const role = await getRole(page.workspace_id, req.user.id); const isAuthor = comment.author_id === req.user.id; if (!isAuthor && role !== 'owner' && role !== 'admin') { return res.status(403).json({ error: 'Forbidden' }); } await query('DELETE FROM comments WHERE id = $1', [comment.id]); return res.status(204).end(); }); export default router;