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:
x3
2026-09-22 13:33:22 +02:00
parent 29c35f506d
commit 4ba8f8bd21
14 changed files with 2219 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { query } from '../config/db.js';
import { requireAuth, signToken } from '../middleware/auth.js';
const router = Router();
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
function publicUser(row) {
return { id: row.id, email: row.email, name: row.name, createdAt: row.created_at };
}
// POST /api/auth/register { email, name, password }
router.post('/register', async (req, res) => {
const { email = '', name = '', password = '' } = req.body ?? {};
if (!EMAIL_RE.test(String(email).toLowerCase())) {
return res.status(400).json({ error: 'Valid email required' });
}
if (String(name).trim().length < 1 || String(name).length > 100) {
return res.status(400).json({ error: 'Name must be 1-100 chars' });
}
if (String(password).length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 chars' });
}
try {
const existing = await query('SELECT id FROM users WHERE email = $1', [String(email).toLowerCase()]);
if (existing.rowCount > 0) return res.status(409).json({ error: 'Email already registered' });
const hash = await bcrypt.hash(String(password), 12);
const { rows } = await query(
'INSERT INTO users (email, name, password_hash) VALUES ($1,$2,$3) RETURNING id, email, name, created_at',
[String(email).toLowerCase(), String(name).trim(), hash],
);
const token = signToken(rows[0]);
return res.status(201).json({ user: publicUser(rows[0]), token });
} catch (err) {
console.error('[auth/register]', err.message);
return res.status(500).json({ error: 'Registration failed' });
}
});
// POST /api/auth/login { email, password }
router.post('/login', async (req, res) => {
const { email = '', password = '' } = req.body ?? {};
try {
const { rows } = await query('SELECT * FROM users WHERE email = $1', [String(email).toLowerCase()]);
const user = rows[0];
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const ok = await bcrypt.compare(String(password), user.password_hash);
if (!ok) return res.status(401).json({ error: 'Invalid credentials' });
const token = signToken(user);
return res.json({ user: publicUser(user), token });
} catch (err) {
console.error('[auth/login]', err.message);
return res.status(500).json({ error: 'Login failed' });
}
});
// GET /api/auth/me
router.get('/me', requireAuth, async (req, res) => {
const { rows } = await query('SELECT id, email, name, created_at FROM users WHERE id = $1', [req.user.id]);
if (!rows[0]) return res.status(404).json({ error: 'User not found' });
return res.json({ user: publicUser(rows[0]) });
});
export default router;
+34
View File
@@ -0,0 +1,34 @@
import { query } from '../config/db.js';
export const ROLES = ['owner', 'admin', 'member', 'viewer'];
const RANK = { viewer: 0, member: 1, admin: 2, owner: 3 };
/** Load membership role of user in workspace, or null. */
export async function getRole(workspaceId, userId) {
const { rows } = await query(
'SELECT role FROM workspace_members WHERE workspace_id = $1 AND user_id = $2',
[workspaceId, userId],
);
return rows[0]?.role ?? null;
}
/** Require membership; optionally require minimum role. Attaches req.membership. */
export function requireWorkspace(minRole = 'viewer') {
return async (req, res, next) => {
const workspaceId = req.params.workspaceId || req.params.id || req.body?.workspace_id;
if (!workspaceId) return res.status(400).json({ error: 'workspaceId required' });
const role = await getRole(workspaceId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
if (RANK[role] < RANK[minRole]) {
return res.status(403).json({ error: `Requires role ${minRole} or higher` });
}
req.membership = { workspaceId, role };
return next();
};
}
/** Check page access: user must be member of the page's workspace. */
export async function getPageWithWorkspace(pageId) {
const { rows } = await query('SELECT * FROM pages WHERE id = $1', [pageId]);
return rows[0] || null;
}
+172
View File
@@ -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;
+18
View File
@@ -0,0 +1,18 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
router.use(requireAuth);
// GET /api/users/search?email= — find user by email (for invites)
router.get('/search', async (req, res) => {
const { email = '' } = req.query;
if (!email) return res.status(400).json({ error: 'email required' });
const { rows } = await query('SELECT id, email, name FROM users WHERE email = $1', [
String(email).toLowerCase(),
]);
return res.json({ user: rows[0] || null });
});
export default router;
+111
View File
@@ -0,0 +1,111 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { getRole, requireWorkspace, ROLES } from './guards.js';
const router = Router();
router.use(requireAuth);
// GET /api/workspaces — list my workspaces
router.get('/', async (req, res) => {
const { rows } = await query(
`SELECT w.id, w.name, w.owner_id, w.created_at, w.updated_at, m.role
FROM workspaces w JOIN workspace_members m ON m.workspace_id = w.id
WHERE m.user_id = $1 ORDER BY w.updated_at DESC`,
[req.user.id],
);
return res.json({ workspaces: rows });
});
// POST /api/workspaces { name }
router.post('/', async (req, res) => {
const { name = '' } = req.body ?? {};
if (String(name).trim().length < 1 || String(name).length > 120) {
return res.status(400).json({ error: 'Name must be 1-120 chars' });
}
const { rows } = await query(
'INSERT INTO workspaces (name, owner_id) VALUES ($1,$2) RETURNING *',
[String(name).trim(), req.user.id],
);
return res.status(201).json({ workspace: rows[0] });
});
// GET /api/workspaces/:id
router.get('/:id', requireWorkspace('viewer'), async (req, res) => {
const { rows } = await query('SELECT * FROM workspaces WHERE id = $1', [req.params.id]);
if (!rows[0]) return res.status(404).json({ error: 'Not found' });
return res.json({ workspace: { ...rows[0], role: req.membership.role } });
});
// PATCH /api/workspaces/:id { name } (admin+)
router.patch('/:id', requireWorkspace('admin'), async (req, res) => {
const { name } = req.body ?? {};
if (name !== undefined && (String(name).trim().length < 1 || String(name).length > 120)) {
return res.status(400).json({ error: 'Name must be 1-120 chars' });
}
const { rows } = await query(
'UPDATE workspaces SET name = COALESCE($1, name) WHERE id = $2 RETURNING *',
[name !== undefined ? String(name).trim() : null, req.params.id],
);
return res.json({ workspace: rows[0] });
});
// DELETE /api/workspaces/:id (owner only)
router.delete('/:id', requireWorkspace('owner'), async (req, res) => {
await query('DELETE FROM workspaces WHERE id = $1', [req.params.id]);
return res.status(204).end();
});
// GET /api/workspaces/:id/members
router.get('/:id/members', requireWorkspace('viewer'), async (req, res) => {
const { rows } = await query(
`SELECT u.id, u.email, u.name, m.role, m.created_at
FROM workspace_members m JOIN users u ON u.id = m.user_id
WHERE m.workspace_id = $1 ORDER BY m.created_at`,
[req.params.id],
);
return res.json({ members: rows });
});
// POST /api/workspaces/:id/members { email, role } (admin+; cannot grant owner)
router.post('/:id/members', requireWorkspace('admin'), async (req, res) => {
const { email = '', role = 'member' } = req.body ?? {};
if (!ROLES.includes(role) || role === 'owner') {
return res.status(400).json({ error: 'Role must be admin, member or viewer' });
}
const { rows: users } = await query('SELECT id FROM users WHERE email = $1', [String(email).toLowerCase()]);
if (!users[0]) return res.status(404).json({ error: 'No user with that email (they must register first)' });
await query(
`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ($1,$2,$3)
ON CONFLICT (workspace_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
[req.params.id, users[0].id, role],
);
return res.status(201).json({ ok: true });
});
// PATCH /api/workspaces/:id/members/:userId { role } (admin+)
router.patch('/:id/members/:userId', requireWorkspace('admin'), async (req, res) => {
const { role } = req.body ?? {};
if (!ROLES.includes(role)) return res.status(400).json({ error: 'Invalid role' });
if (role === 'owner') return res.status(400).json({ error: 'Ownership transfer is not supported in MVP' });
const current = await getRole(req.params.id, req.params.userId);
if (current === 'owner') return res.status(400).json({ error: 'Cannot change owner role' });
await query(
'UPDATE workspace_members SET role = $1 WHERE workspace_id = $2 AND user_id = $3',
[role, req.params.id, req.params.userId],
);
return res.json({ ok: true });
});
// DELETE /api/workspaces/:id/members/:userId (admin+; owner cannot be removed)
router.delete('/:id/members/:userId', requireWorkspace('admin'), async (req, res) => {
const current = await getRole(req.params.id, req.params.userId);
if (current === 'owner') return res.status(400).json({ error: 'Cannot remove owner' });
await query('DELETE FROM workspace_members WHERE workspace_id = $1 AND user_id = $2', [
req.params.id,
req.params.userId,
]);
return res.status(204).end();
});
export default router;