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
+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;