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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user