diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index e154c0a..faf9fc6 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -76,6 +76,63 @@ CREATE TABLE IF NOT EXISTS app_settings ( -- Global admin flag (first user created via web installer) ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE; +-- Profile / appearance / locale settings (edited via settings window) +ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT NOT NULL DEFAULT ''; +ALTER TABLE users ADD COLUMN IF NOT EXISTS locale TEXT NOT NULL DEFAULT 'en'; +ALTER TABLE users ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system'; +ALTER TABLE users ADD COLUMN IF NOT EXISTS bg_image TEXT NOT NULL DEFAULT ''; +ALTER TABLE users ADD COLUMN IF NOT EXISTS bg_blur BOOLEAN NOT NULL DEFAULT TRUE; + +-- Books: independent containers that hold pages (collapsible in sidebar) +CREATE TABLE IF NOT EXISTS books ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + title TEXT NOT NULL DEFAULT 'Untitled book' CHECK (char_length(title) BETWEEN 1 AND 200), + icon TEXT NOT NULL DEFAULT '📚', + position DOUBLE PRECISION NOT NULL DEFAULT 0, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_books_workspace ON books(workspace_id); + +-- Pages can optionally live inside a book (book_id NULL = top-level page) +ALTER TABLE pages ADD COLUMN IF NOT EXISTS book_id UUID REFERENCES books(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS idx_pages_book ON pages(book_id); + +-- Tasks: can link to a book and/or a page, optional deadline (feeds the calendar) +CREATE TABLE IF NOT EXISTS tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + book_id UUID REFERENCES books(id) ON DELETE SET NULL, + page_id UUID REFERENCES pages(id) ON DELETE SET NULL, + title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 300), + status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo','doing','done')), + priority TEXT NOT NULL DEFAULT 'normal' CHECK (priority IN ('low','normal','high')), + due_at TIMESTAMPTZ NULL, + position DOUBLE PRECISION NOT NULL DEFAULT 0, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_tasks_workspace ON tasks(workspace_id); +CREATE INDEX IF NOT EXISTS idx_tasks_due ON tasks(workspace_id, due_at); + +-- Time tracking entries (open entry = running timer, ended_at IS NULL) +CREATE TABLE IF NOT EXISTS time_entries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + book_id UUID REFERENCES books(id) ON DELETE SET NULL, + page_id UUID REFERENCES pages(id) ON DELETE SET NULL, + description TEXT NOT NULL DEFAULT '', + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ended_at TIMESTAMPTZ NULL, + duration_seconds INTEGER NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_time_ws_user ON time_entries(workspace_id, user_id); + -- Ensure workspace owner is always a member with role=owner CREATE OR REPLACE FUNCTION ensure_owner_membership() RETURNS trigger AS $$ BEGIN @@ -110,3 +167,11 @@ FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); DROP TRIGGER IF EXISTS trg_pages_touch ON pages; CREATE TRIGGER trg_pages_touch BEFORE UPDATE ON pages FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); + +DROP TRIGGER IF EXISTS trg_books_touch ON books; +CREATE TRIGGER trg_books_touch BEFORE UPDATE ON books +FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); + +DROP TRIGGER IF EXISTS trg_tasks_touch ON tasks; +CREATE TRIGGER trg_tasks_touch BEFORE UPDATE ON tasks +FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); diff --git a/backend/src/index.js b/backend/src/index.js index 3c07d00..ec906d9 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -11,6 +11,10 @@ import setupRoutes, { isInstalled } from './routes/setup.js'; import workspaceRoutes from './routes/workspaces.js'; import pageRoutes from './routes/pages.js'; import userRoutes from './routes/users.js'; +import bookRoutes from './routes/books.js'; +import taskRoutes from './routes/tasks.js'; +import timeRoutes from './routes/time.js'; +import adminRoutes from './routes/admin.js'; import { initSocket } from './socket.js'; const app = express(); @@ -43,6 +47,10 @@ app.use('/api/auth', rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }), authRout app.use('/api/workspaces', workspaceRoutes); app.use('/api/pages', pageRoutes); app.use('/api/users', userRoutes); +app.use('/api/books', bookRoutes); +app.use('/api/tasks', taskRoutes); +app.use('/api/time', timeRoutes); +app.use('/api/admin', adminRoutes); app.get('/api/health', async (_req, res) => { try { diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js new file mode 100644 index 0000000..a3abb4f --- /dev/null +++ b/backend/src/routes/admin.js @@ -0,0 +1,62 @@ +import { Router } from 'express'; +import { query } from '../config/db.js'; +import { requireAuth } from '../middleware/auth.js'; +import { requireAdmin } from './guards.js'; + +const router = Router(); +router.use(requireAuth, requireAdmin); + +// GET /api/admin/overview — counts + recent users +router.get('/overview', async (_req, res) => { + const [[u], [w], [b], [p], [t], [e]] = await Promise.all( + ['users', 'workspaces', 'books', 'pages', 'tasks', 'time_entries'].map( + (tbl) => query(`SELECT COUNT(*)::int AS c FROM ${tbl}`).then((r) => r.rows), + ), + ); + const { rows: recent } = await query( + 'SELECT id, name, email, is_admin, created_at FROM users ORDER BY created_at DESC LIMIT 8', + ); + return res.json({ + counts: { + users: u.c, workspaces: w.c, books: b.c, pages: p.c, tasks: t.c, timeEntries: e.c, + }, + recentUsers: recent, + }); +}); + +// GET /api/admin/users +router.get('/users', async (_req, res) => { + const { rows } = await query( + `SELECT u.id, u.name, u.email, u.is_admin, u.locale, u.created_at, + (SELECT COUNT(*)::int FROM workspace_members m WHERE m.user_id = u.id) AS workspaces + FROM users u ORDER BY u.created_at`, + ); + return res.json({ users: rows }); +}); + +// PATCH /api/admin/users/:id { is_admin } +router.patch('/users/:id', async (req, res) => { + const { is_admin } = req.body ?? {}; + if (typeof is_admin !== 'boolean') return res.status(400).json({ error: 'is_admin boolean required' }); + if (req.params.id === req.user.id && is_admin === false) { + return res.status(400).json({ error: 'You cannot demote yourself' }); + } + const { rows } = await query( + 'UPDATE users SET is_admin = $1 WHERE id = $2 RETURNING id, name, email, is_admin', + [is_admin, req.params.id], + ); + if (!rows[0]) return res.status(404).json({ error: 'User not found' }); + return res.json({ user: rows[0] }); +}); + +// DELETE /api/admin/users/:id +router.delete('/users/:id', async (req, res) => { + if (req.params.id === req.user.id) { + return res.status(400).json({ error: 'You cannot delete yourself' }); + } + const { rowCount } = await query('DELETE FROM users WHERE id = $1', [req.params.id]); + if (!rowCount) return res.status(404).json({ error: 'User not found' }); + return res.status(204).end(); +}); + +export default router; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 6de6c39..d551fc9 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -7,7 +7,18 @@ const router = Router(); const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; function publicUser(row) { - return { id: row.id, email: row.email, name: row.name, isAdmin: !!row.is_admin, createdAt: row.created_at }; + return { + id: row.id, + email: row.email, + name: row.name, + isAdmin: !!row.is_admin, + avatar: row.avatar || '', + locale: row.locale || 'en', + theme: row.theme || 'system', + bgImage: row.bg_image || '', + bgBlur: row.bg_blur !== false, + createdAt: row.created_at, + }; } // POST /api/auth/register { email, name, password } @@ -27,7 +38,7 @@ router.post('/register', async (req, res) => { 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', + 'INSERT INTO users (email, name, password_hash) VALUES ($1,$2,$3) RETURNING id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, created_at', [String(email).toLowerCase(), String(name).trim(), hash], ); const token = signToken(rows[0]); @@ -57,7 +68,7 @@ router.post('/login', async (req, res) => { // 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]); + const { rows } = await query('SELECT id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, 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]) }); }); diff --git a/backend/src/routes/books.js b/backend/src/routes/books.js new file mode 100644 index 0000000..e065118 --- /dev/null +++ b/backend/src/routes/books.js @@ -0,0 +1,71 @@ +import { Router } from 'express'; +import { query } from '../config/db.js'; +import { requireAuth } from '../middleware/auth.js'; +import { getRole } from './guards.js'; + +const router = Router(); +router.use(requireAuth); + +// GET /api/books/by-workspace/:workspaceId +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 * FROM books WHERE workspace_id = $1 ORDER BY position, updated_at DESC', + [req.params.workspaceId], + ); + return res.json({ books: rows }); +}); + +// POST /api/books { workspace_id, title?, icon? } (member+) +router.post('/', async (req, res) => { + const { workspace_id, title = 'Untitled book', icon = '📚' } = 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 books' }); + } + const { rows } = await query( + 'INSERT INTO books (workspace_id, title, icon, created_by) VALUES ($1,$2,$3,$4) RETURNING *', + [workspace_id, String(title).slice(0, 200) || 'Untitled book', String(icon).slice(0, 20) || '📚', req.user.id], + ); + return res.status(201).json({ book: rows[0] }); +}); + +// PATCH /api/books/:id { title?, icon?, position? } (member+) +router.patch('/:id', async (req, res) => { + const { rows: found } = await query('SELECT * FROM books WHERE id = $1', [req.params.id]); + const book = found[0]; + if (!book) return res.status(404).json({ error: 'Not found' }); + const role = await getRole(book.workspace_id, req.user.id); + if (role !== 'owner' && role !== 'admin' && role !== 'member') { + return res.status(403).json({ error: 'Viewers cannot edit' }); + } + const { title, icon, position } = req.body ?? {}; + const { rows } = await query( + `UPDATE books SET title = COALESCE($1, title), icon = COALESCE($2, icon), + position = COALESCE($3, position) WHERE id = $4 RETURNING *`, + [ + title !== undefined ? String(title).slice(0, 200) : null, + icon !== undefined ? String(icon).slice(0, 20) : null, + position !== undefined ? Number(position) : null, + book.id, + ], + ); + return res.json({ book: rows[0] }); +}); + +// DELETE /api/books/:id (member+; pages inside are removed via FK cascade) +router.delete('/:id', async (req, res) => { + const { rows: found } = await query('SELECT * FROM books WHERE id = $1', [req.params.id]); + const book = found[0]; + if (!book) return res.status(404).json({ error: 'Not found' }); + const role = await getRole(book.workspace_id, req.user.id); + if (role !== 'owner' && role !== 'admin' && role !== 'member') { + return res.status(403).json({ error: 'Viewers cannot delete' }); + } + await query('DELETE FROM books WHERE id = $1', [book.id]); + return res.status(204).end(); +}); + +export default router; diff --git a/backend/src/routes/guards.js b/backend/src/routes/guards.js index 7ee2ea8..e6c37bc 100644 --- a/backend/src/routes/guards.js +++ b/backend/src/routes/guards.js @@ -32,3 +32,10 @@ export async function getPageWithWorkspace(pageId) { const { rows } = await query('SELECT * FROM pages WHERE id = $1', [pageId]); return rows[0] || null; } + +/** Require global admin flag (set via web installer / admin dashboard). */ +export async function requireAdmin(req, res, next) { + const { rows } = await query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]); + if (!rows[0]?.is_admin) return res.status(403).json({ error: 'Admin only' }); + return next(); +} diff --git a/backend/src/routes/pages.js b/backend/src/routes/pages.js index 31cc6bd..d7004a5 100644 --- a/backend/src/routes/pages.js +++ b/backend/src/routes/pages.js @@ -30,24 +30,31 @@ router.get('/by-workspace/:workspaceId', async (req, res) => { return res.json({ pages: rows, role }); }); -// POST /api/pages { workspace_id, parent_id?, title? } +// POST /api/pages { workspace_id, parent_id?, book_id?, title? } router.post('/', async (req, res) => { - const { workspace_id, parent_id = null, title = 'Untitled' } = req.body ?? {}; + 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, title, created_by) - VALUES ($1,$2,$3,$4) RETURNING *`, - [workspace_id, parent_id, String(title).slice(0, 300) || 'Untitled', req.user.id], + `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] }); }); @@ -71,7 +78,7 @@ 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 ?? {}; + 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); @@ -79,6 +86,10 @@ router.patch('/:id', async (req, res) => { 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), @@ -88,6 +99,7 @@ router.patch('/:id', async (req, res) => { 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 *`, [ @@ -101,6 +113,8 @@ router.patch('/:id', async (req, res) => { 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]; diff --git a/backend/src/routes/tasks.js b/backend/src/routes/tasks.js new file mode 100644 index 0000000..2fc58eb --- /dev/null +++ b/backend/src/routes/tasks.js @@ -0,0 +1,128 @@ +import { Router } from 'express'; +import { query } from '../config/db.js'; +import { requireAuth } from '../middleware/auth.js'; +import { getRole } from './guards.js'; + +const router = Router(); +router.use(requireAuth); +const STATUSES = ['todo', 'doing', 'done']; +const PRIORITIES = ['low', 'normal', 'high']; + +async function canWrite(userId, workspaceId) { + const role = await getRole(workspaceId, userId); + return role === 'owner' || role === 'admin' || role === 'member'; +} + +async function checkLinks(workspaceId, book_id, page_id) { + if (book_id) { + const { rows } = await query('SELECT id FROM books WHERE id = $1 AND workspace_id = $2', [book_id, workspaceId]); + if (!rows[0]) return 'Invalid book_id for this workspace'; + } + if (page_id) { + const { rows } = await query('SELECT id FROM pages WHERE id = $1 AND workspace_id = $2', [page_id, workspaceId]); + if (!rows[0]) return 'Invalid page_id for this workspace'; + } + return null; +} + +// GET /api/tasks/by-workspace/:workspaceId?status=&from=&to= +// from/to filter due_at (used by the calendar). +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 { status, from, to } = req.query; + const conds = ['t.workspace_id = $1']; + const params = [req.params.workspaceId]; + if (status && STATUSES.includes(String(status))) { + params.push(String(status)); + conds.push(`t.status = $${params.length}`); + } + if (from) { + params.push(new Date(String(from))); + conds.push(`t.due_at >= $${params.length}`); + } + if (to) { + params.push(new Date(String(to))); + conds.push(`t.due_at <= $${params.length}`); + } + const { rows } = await query( + `SELECT t.*, b.title AS book_title, p.title AS page_title + FROM tasks t LEFT JOIN books b ON b.id = t.book_id LEFT JOIN pages p ON p.id = t.page_id + WHERE ${conds.join(' AND ')} ORDER BY t.due_at NULLS LAST, t.position, t.created_at`, + params, + ); + return res.json({ tasks: rows }); +}); + +// POST /api/tasks { workspace_id, book_id?, page_id?, title, status?, priority?, due_at? } +router.post('/', async (req, res) => { + const { workspace_id, book_id = null, page_id = null, title = '', status = 'todo', priority = 'normal', due_at = null } = req.body ?? {}; + if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' }); + if (!(await canWrite(req.user.id, workspace_id))) { + return res.status(403).json({ error: 'Viewers cannot create tasks' }); + } + if (String(title).trim().length < 1 || String(title).length > 300) { + return res.status(400).json({ error: 'Title must be 1-300 chars' }); + } + if (!STATUSES.includes(status)) return res.status(400).json({ error: 'Invalid status' }); + if (!PRIORITIES.includes(priority)) return res.status(400).json({ error: 'Invalid priority' }); + const linkErr = await checkLinks(workspace_id, book_id, page_id); + if (linkErr) return res.status(400).json({ error: linkErr }); + const { rows } = await query( + `INSERT INTO tasks (workspace_id, book_id, page_id, title, status, priority, due_at, created_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, + [workspace_id, book_id, page_id, String(title).trim(), status, priority, due_at ? new Date(due_at) : null, req.user.id], + ); + return res.status(201).json({ task: rows[0] }); +}); + +// PATCH /api/tasks/:id +router.patch('/:id', async (req, res) => { + const { rows: found } = await query('SELECT * FROM tasks WHERE id = $1', [req.params.id]); + const task = found[0]; + if (!task) return res.status(404).json({ error: 'Not found' }); + if (!(await canWrite(req.user.id, task.workspace_id))) { + return res.status(403).json({ error: 'Viewers cannot edit' }); + } + const { title, status, priority, due_at, book_id, page_id, position } = req.body ?? {}; + if (status !== undefined && !STATUSES.includes(status)) return res.status(400).json({ error: 'Invalid status' }); + if (priority !== undefined && !PRIORITIES.includes(priority)) return res.status(400).json({ error: 'Invalid priority' }); + if (title !== undefined && (String(title).trim().length < 1 || String(title).length > 300)) { + return res.status(400).json({ error: 'Title must be 1-300 chars' }); + } + const nextBook = book_id !== undefined ? book_id : task.book_id; + const nextPage = page_id !== undefined ? page_id : task.page_id; + const linkErr = await checkLinks(task.workspace_id, nextBook, nextPage); + if (linkErr) return res.status(400).json({ error: linkErr }); + const sets = []; + const params = []; + const push = (col, val) => { params.push(val); sets.push(`${col} = $${params.length}`); }; + if (title !== undefined) push('title', String(title).trim()); + if (status !== undefined) push('status', status); + if (priority !== undefined) push('priority', priority); + if (due_at !== undefined) push('due_at', due_at ? new Date(due_at) : null); + if (book_id !== undefined) push('book_id', book_id); + if (page_id !== undefined) push('page_id', page_id); + if (position !== undefined) push('position', Number(position)); + if (sets.length === 0) return res.json({ task }); + params.push(task.id); + const { rows } = await query( + `UPDATE tasks SET ${sets.join(', ')} WHERE id = $${params.length} RETURNING *`, + params, + ); + return res.json({ task: rows[0] }); +}); + +// DELETE /api/tasks/:id +router.delete('/:id', async (req, res) => { + const { rows: found } = await query('SELECT * FROM tasks WHERE id = $1', [req.params.id]); + const task = found[0]; + if (!task) return res.status(404).json({ error: 'Not found' }); + if (!(await canWrite(req.user.id, task.workspace_id))) { + return res.status(403).json({ error: 'Viewers cannot delete' }); + } + await query('DELETE FROM tasks WHERE id = $1', [task.id]); + return res.status(204).end(); +}); + +export default router; diff --git a/backend/src/routes/time.js b/backend/src/routes/time.js new file mode 100644 index 0000000..1890ba1 --- /dev/null +++ b/backend/src/routes/time.js @@ -0,0 +1,116 @@ +import { Router } from 'express'; +import { query } from '../config/db.js'; +import { requireAuth } from '../middleware/auth.js'; +import { getRole } from './guards.js'; + +const router = Router(); +router.use(requireAuth); + +function serialize(row) { + if (!row) return row; + return row; +} + +// GET /api/time/by-workspace/:workspaceId — entries + currently running timer +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 mine = req.query.mine === '1'; + const params = [req.params.workspaceId]; + let cond = 'e.workspace_id = $1'; + if (mine) { + params.push(req.user.id); + cond += ` AND e.user_id = $${params.length}`; + } + const { rows } = await query( + `SELECT e.*, b.title AS book_title, p.title AS page_title, u.name AS user_name + FROM time_entries e + LEFT JOIN books b ON b.id = e.book_id + LEFT JOIN pages p ON p.id = e.page_id + LEFT JOIN users u ON u.id = e.user_id + WHERE ${cond} ORDER BY e.started_at DESC LIMIT 200`, + params, + ); + const open = rows.find((r) => r.user_id === req.user.id && !r.ended_at) || null; + return res.json({ entries: rows, open }); +}); + +// POST /api/time/start { workspace_id, book_id?, page_id?, description? } +router.post('/start', async (req, res) => { + const { workspace_id, book_id = null, page_id = null, description = '' } = 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) return res.status(403).json({ error: 'Not a workspace member' }); + const { rows: running } = await query( + 'SELECT id FROM time_entries WHERE workspace_id = $1 AND user_id = $2 AND ended_at IS NULL', + [workspace_id, req.user.id], + ); + if (running[0]) return res.status(409).json({ error: 'Timer already running', entry: running[0] }); + const { rows } = await query( + `INSERT INTO time_entries (workspace_id, user_id, book_id, page_id, description) + VALUES ($1,$2,$3,$4,$5) RETURNING *`, + [workspace_id, req.user.id, book_id, page_id, String(description).slice(0, 500)], + ); + return res.status(201).json({ entry: serialize(rows[0]) }); +}); + +// POST /api/time/stop { workspace_id } — stops my running timer +router.post('/stop', async (req, res) => { + const { workspace_id } = req.body ?? {}; + if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' }); + const { rows } = await query( + `UPDATE time_entries SET ended_at = now(), + duration_seconds = EXTRACT(EPOCH FROM (now() - started_at))::int + WHERE id = ( + SELECT id FROM time_entries + WHERE workspace_id = $1 AND user_id = $2 AND ended_at IS NULL + ORDER BY started_at DESC LIMIT 1 + ) RETURNING *`, + [workspace_id, req.user.id], + ); + if (!rows[0]) return res.status(404).json({ error: 'No running timer' }); + return res.json({ entry: rows[0] }); +}); + +// PATCH /api/time/:id { description?, book_id?, page_id? } (own or admin+) +router.patch('/:id', async (req, res) => { + const { rows: found } = await query('SELECT * FROM time_entries WHERE id = $1', [req.params.id]); + const entry = found[0]; + if (!entry) return res.status(404).json({ error: 'Not found' }); + const role = await getRole(entry.workspace_id, req.user.id); + const { rows: me } = await query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]); + if (entry.user_id !== req.user.id && !me[0]?.is_admin && role !== 'owner' && role !== 'admin') { + return res.status(403).json({ error: 'Forbidden' }); + } + const { description, book_id, page_id } = req.body ?? {}; + const { rows } = await query( + `UPDATE time_entries SET description = COALESCE($1, description), + book_id = CASE WHEN $4 THEN $2 ELSE book_id END, + page_id = CASE WHEN $5 THEN $3 ELSE page_id END + WHERE id = $6 RETURNING *`, + [ + description !== undefined ? String(description).slice(0, 500) : null, + book_id !== undefined ? book_id : null, + page_id !== undefined ? page_id : null, + book_id !== undefined, page_id !== undefined, + entry.id, + ], + ); + return res.json({ entry: rows[0] }); +}); + +// DELETE /api/time/:id (own or admin+) +router.delete('/:id', async (req, res) => { + const { rows: found } = await query('SELECT * FROM time_entries WHERE id = $1', [req.params.id]); + const entry = found[0]; + if (!entry) return res.status(404).json({ error: 'Not found' }); + const role = await getRole(entry.workspace_id, req.user.id); + const { rows: me } = await query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]); + if (entry.user_id !== req.user.id && !me[0]?.is_admin && role !== 'owner' && role !== 'admin') { + return res.status(403).json({ error: 'Forbidden' }); + } + await query('DELETE FROM time_entries WHERE id = $1', [entry.id]); + return res.status(204).end(); +}); + +export default router; diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 989cc10..326a57f 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -1,18 +1,111 @@ import { Router } from 'express'; +import bcrypt from 'bcryptjs'; 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) +const LOCALES = ['de', 'en', 'zh']; +const THEMES = ['light', 'dark', 'system']; + +// GET /api/users/search?email= — find user by exact 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(), - ]); + const { rows } = await query( + 'SELECT id, email, name, avatar FROM users WHERE email = $1', + [String(email).toLowerCase()], + ); return res.json({ user: rows[0] || null }); }); +// GET /api/users/discover?q= — fuzzy user discovery for invites/mentions. +// Returns id, name, email, avatar (never password hashes). +router.get('/discover', async (req, res) => { + const q = String(req.query.q || '').trim().slice(0, 60); + if (q.length < 2) return res.json({ users: [] }); + const { rows } = await query( + `SELECT id, name, email, avatar FROM users + WHERE name ILIKE $1 OR email ILIKE $1 + ORDER BY name LIMIT 10`, + [`%${q}%`], + ); + return res.json({ users: rows }); +}); + +// PATCH /api/users/me — profile + appearance settings +router.patch('/me', async (req, res) => { + const { name, email, avatar, locale, theme, bg_image, bg_blur } = req.body ?? {}; + if (name !== undefined && (String(name).trim().length < 1 || String(name).length > 100)) { + return res.status(400).json({ error: 'Name must be 1-100 chars' }); + } + if (email !== undefined && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(email))) { + return res.status(400).json({ error: 'Valid email required' }); + } + if (locale !== undefined && !LOCALES.includes(locale)) { + return res.status(400).json({ error: 'Locale must be de, en or zh' }); + } + if (theme !== undefined && !THEMES.includes(theme)) { + return res.status(400).json({ error: 'Theme must be light, dark or system' }); + } + if (avatar !== undefined && String(avatar).length > 500) { + return res.status(400).json({ error: 'Avatar too long (max 500 chars, emoji or URL)' }); + } + if (bg_image !== undefined && String(bg_image).length > 2000) { + return res.status(400).json({ error: 'Background URL too long' }); + } + try { + const { rows } = await query( + `UPDATE users SET + name = COALESCE($1, name), + email = COALESCE($2, email), + avatar = COALESCE($3, avatar), + locale = COALESCE($4, locale), + theme = COALESCE($5, theme), + bg_image = COALESCE($6, bg_image), + bg_blur = COALESCE($7, bg_blur) + WHERE id = $8 + RETURNING id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, created_at`, + [ + name !== undefined ? String(name).trim() : null, + email !== undefined ? String(email).toLowerCase() : null, + avatar !== undefined ? String(avatar).slice(0, 500) : null, + locale !== undefined ? locale : null, + theme !== undefined ? theme : null, + bg_image !== undefined ? String(bg_image).slice(0, 2000) : null, + bg_blur !== undefined ? Boolean(bg_blur) : null, + req.user.id, + ], + ); + const u = rows[0]; + return res.json({ + user: { + id: u.id, email: u.email, name: u.name, isAdmin: !!u.is_admin, + avatar: u.avatar || '', locale: u.locale, theme: u.theme, + bgImage: u.bg_image || '', bgBlur: u.bg_blur !== false, createdAt: u.created_at, + }, + }); + } catch (err) { + if (err.code === '23505') return res.status(409).json({ error: 'Email already taken' }); + console.error('[users/me]', err.message); + return res.status(500).json({ error: 'Update failed' }); + } +}); + +// PATCH /api/users/me/password { currentPassword, newPassword } +router.patch('/me/password', async (req, res) => { + const { currentPassword = '', newPassword = '' } = req.body ?? {}; + if (String(newPassword).length < 8) { + return res.status(400).json({ error: 'New password must be at least 8 chars' }); + } + const { rows } = await query('SELECT password_hash FROM users WHERE id = $1', [req.user.id]); + if (!rows[0]) return res.status(404).json({ error: 'User not found' }); + const ok = await bcrypt.compare(String(currentPassword), rows[0].password_hash); + if (!ok) return res.status(401).json({ error: 'Current password is wrong' }); + const hash = await bcrypt.hash(String(newPassword), 12); + await query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, req.user.id]); + return res.json({ ok: true }); +}); + export default router; diff --git a/backend/src/routes/workspaces.js b/backend/src/routes/workspaces.js index 5ab2fcd..863df41 100644 --- a/backend/src/routes/workspaces.js +++ b/backend/src/routes/workspaces.js @@ -59,7 +59,7 @@ router.delete('/:id', requireWorkspace('owner'), async (req, res) => { // 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 + `SELECT u.id, u.email, u.name, u.avatar, 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],