feat(backend): Express + Socket.IO API with PostgreSQL
- Auth: register/login/me with bcrypt (12 rounds) and JWT bearer tokens - Workspaces: CRUD, role-based members (owner/admin/member/viewer), invite by email - Pages: nested hierarchy, TipTap JSON+HTML content, archive, search, comments - Realtime: Socket.IO rooms per page with JWT handshake, live edits, presence, typing - DB schema with triggers (owner membership, updated_at) and migrate script - Helmet, CORS allow-list, rate limiting, /api/health with DB check - Dockerfile (migrate-on-boot) for compose/production
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
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?, title? }
|
||||
router.post('/', async (req, res) => {
|
||||
const { workspace_id, parent_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 (parent_id) {
|
||||
const parent = await getPageWithWorkspace(parent_id);
|
||||
if (!parent || parent.workspace_id !== workspace_id) {
|
||||
return res.status(400).json({ error: 'Invalid parent_id' });
|
||||
}
|
||||
}
|
||||
const { rows } = await query(
|
||||
`INSERT INTO pages (workspace_id, parent_id, title, created_by)
|
||||
VALUES ($1,$2,$3,$4) RETURNING *`,
|
||||
[workspace_id, parent_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, 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' });
|
||||
}
|
||||
}
|
||||
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,
|
||||
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,
|
||||
],
|
||||
);
|
||||
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;
|
||||
Reference in New Issue
Block a user