feat(backend): books, tasks, calendar feed, time tracking, profiles, admin API

- Schema: books, tasks (book/page links, status, priority, due_at),
  time_entries (open entry = running timer), user profile columns
  (avatar, locale, theme, bg_image, bg_blur)
- Books CRUD; pages can live in a book (book_id, validated same-workspace)
- Tasks CRUD with status filter and due_at range query for the calendar
- Time API: start (409 if running), stop (computes duration), list with
  open timer, update, delete
- Users: fuzzy discovery for invites, PATCH /me profile/appearance,
  password change; members list now includes avatars
- Admin API (is_admin guard): overview counts, user list, grant/revoke
  admin, delete user (self-demote/delete blocked)
This commit is contained in:
x3
2026-09-22 13:56:25 +02:00
parent 663f7c16cc
commit b852583499
11 changed files with 589 additions and 14 deletions
+62
View File
@@ -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;
+14 -3
View File
@@ -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]) });
});
+71
View File
@@ -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;
+7
View File
@@ -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();
}
+20 -6
View File
@@ -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];
+128
View File
@@ -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;
+116
View File
@@ -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;
+97 -4
View File
@@ -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;
+1 -1
View File
@@ -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],