feat(backend): invites, notifications, chat, uploads, teams, groups, hardening

- Pending workspace invites with accept/decline + bell notifications;
  invite permission extendable via workspace.invite group right
- Notifications API (list/unread, mark read, read-all) + realtime push
  to per-user socket rooms; chat messages notify other members
- Workspace team chat API (list/post/delete) + chat:join socket rooms
- Image uploads (multer, 5MB image-only) served from /uploads
- Teams (managers/members), team-owned workspaces (TEAM badge), team
  task boards (team_id) and task assignees (workspace members only)
- Permission groups with teams.create/edit/invite + workspace.invite,
  default Team Creators seeding, hasPerm/canManageTeam guards
- Public registration disabled after install; admins create users
  (pending invites auto-link); users gain bg_glass setting
- Crash fix: express-async-errors + 400 on malformed IDs instead of
  unhandled rejections killing the server
This commit is contained in:
x3
2026-09-22 14:27:33 +02:00
parent fb396d313a
commit c3c77333db
18 changed files with 934 additions and 36 deletions
+32
View File
@@ -34,6 +34,38 @@ router.get('/users', async (_req, res) => {
return res.json({ users: rows });
});
// POST /api/admin/users { name, email, password, is_admin? } — admin creates accounts.
// Pending workspace invites for that email are linked automatically.
router.post('/users', async (req, res) => {
const { name = '', email = '', password = '', is_admin = false } = req.body ?? {};
if (String(name).trim().length < 1 || String(name).length > 100) {
return res.status(400).json({ error: 'Name must be 1-100 chars' });
}
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(email).toLowerCase())) {
return res.status(400).json({ error: 'Valid email required' });
}
if (String(password).length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 chars' });
}
const { default: bcrypt } = await import('bcryptjs');
const hash = await bcrypt.hash(String(password), 12);
try {
const { rows } = await query(
`INSERT INTO users (email, name, password_hash, is_admin)
VALUES ($1,$2,$3,$4) RETURNING id, name, email, is_admin, created_at`,
[String(email).toLowerCase(), String(name).trim(), hash, Boolean(is_admin)],
);
const user = rows[0];
await query('UPDATE workspace_invites SET user_id = $1 WHERE email = $2 AND user_id IS NULL', [
user.id, user.email,
]);
return res.status(201).json({ user });
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'Email already registered' });
throw err;
}
});
// PATCH /api/admin/users/:id { is_admin }
router.patch('/users/:id', async (req, res) => {
const { is_admin } = req.body ?? {};
+10 -3
View File
@@ -17,12 +17,19 @@ function publicUser(row) {
theme: row.theme || 'system',
bgImage: row.bg_image || '',
bgBlur: row.bg_blur !== false,
bgGlass: row.bg_glass !== false,
createdAt: row.created_at,
};
}
// POST /api/auth/register { email, name, password }
// POST /api/auth/register — DISABLED after installation.
// The web installer creates the first admin; afterwards only admins
// may create users (POST /api/admin/users).
router.post('/register', async (req, res) => {
const { rows } = await query("SELECT value FROM app_settings WHERE key = 'installed'");
if (rows[0]?.value === 'true') {
return res.status(403).json({ error: 'Public registration is disabled. Ask an admin to create your account.' });
}
const { email = '', name = '', password = '' } = req.body ?? {};
if (!EMAIL_RE.test(String(email).toLowerCase())) {
return res.status(400).json({ error: 'Valid email required' });
@@ -38,7 +45,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, is_admin, avatar, locale, theme, bg_image, bg_blur, 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, bg_glass, created_at',
[String(email).toLowerCase(), String(name).trim(), hash],
);
const token = signToken(rows[0]);
@@ -68,7 +75,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, is_admin, avatar, locale, theme, bg_image, bg_blur, 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, bg_glass, 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]) });
});
+72
View File
@@ -0,0 +1,72 @@
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/chat/:workspaceId — last 100 messages
router.get('/: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 m.*, u.name AS author_name, u.avatar AS author_avatar
FROM chat_messages m LEFT JOIN users u ON u.id = m.user_id
WHERE m.workspace_id = $1 ORDER BY m.created_at DESC LIMIT 100`,
[req.params.workspaceId],
);
return res.json({ messages: rows.reverse() });
});
// POST /api/chat/:workspaceId { body } — post + realtime broadcast + notify others
router.post('/:workspaceId', async (req, res) => {
const wsId = req.params.workspaceId;
const role = await getRole(wsId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
const { body = '' } = req.body ?? {};
if (String(body).trim().length < 1 || String(body).length > 2000) {
return res.status(400).json({ error: 'Body must be 1-2000 chars' });
}
const { rows } = await query(
'INSERT INTO chat_messages (workspace_id, user_id, body) VALUES ($1,$2,$3) RETURNING *',
[wsId, req.user.id, String(body).trim()],
);
const { rows: author } = await query('SELECT name, avatar FROM users WHERE id = $1', [req.user.id]);
const msg = { ...rows[0], author_name: author[0]?.name, author_avatar: author[0]?.avatar };
const io = req.app.get('io');
io?.to(`chat:${wsId}`).emit('chat:message', { message: msg });
// Notify all other members
const { rows: members } = await query(
'SELECT user_id FROM workspace_members WHERE workspace_id = $1 AND user_id <> $2',
[wsId, req.user.id],
);
const { rows: wsRows } = await query('SELECT name FROM workspaces WHERE id = $1', [wsId]);
for (const m of members) {
await query(
`INSERT INTO notifications (user_id, type, title, body, link)
VALUES ($1,'chat',$2,$3,$4)`,
[m.user_id, `💬 ${author[0]?.name || 'Someone'} in ${wsRows[0]?.name || ''}`,
String(body).trim().slice(0, 140), '/chat'],
);
io?.to(`user:${m.user_id}`).emit('notification:new', { type: 'chat' });
}
return res.status(201).json({ message: msg });
});
// DELETE /api/chat/message/:id (own or admin+)
router.delete('/message/:id', async (req, res) => {
const { rows } = await query('SELECT * FROM chat_messages WHERE id = $1', [req.params.id]);
const msg = rows[0];
if (!msg) return res.status(404).json({ error: 'Not found' });
const role = await getRole(msg.workspace_id, req.user.id);
const { rows: me } = await query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]);
if (msg.user_id !== req.user.id && !me[0]?.is_admin && role !== 'owner' && role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
await query('DELETE FROM chat_messages WHERE id = $1', [msg.id]);
req.app.get('io')?.to(`chat:${msg.workspace_id}`).emit('chat:deleted', { id: msg.id });
return res.status(204).end();
});
export default router;
+133
View File
@@ -0,0 +1,133 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { getRole, ensureDefaultGroups, PERMS } from './guards.js';
const router = Router();
router.use(requireAuth);
async function isWsAdmin(workspaceId, userId) {
const role = await getRole(workspaceId, userId);
return role === 'owner' || role === 'admin';
}
// GET /api/groups/by-workspace/:workspaceId (seeds defaults on first call)
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' });
await ensureDefaultGroups(req.params.workspaceId);
const { rows } = await query(
`SELECT g.*, (SELECT COUNT(*)::int FROM group_members m WHERE m.group_id = g.id) AS members
FROM groups g WHERE g.workspace_id = $1 ORDER BY g.created_at`,
[req.params.workspaceId],
);
return res.json({ groups: rows, availablePermissions: PERMS });
});
// POST /api/groups { workspace_id, name, permissions[] } (workspace admin+)
router.post('/', async (req, res) => {
const { workspace_id, name = '', permissions = [] } = req.body ?? {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
if (!(await isWsAdmin(workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Workspace admin only' });
}
if (String(name).trim().length < 1 || String(name).length > 120) {
return res.status(400).json({ error: 'Name must be 1-120 chars' });
}
const perms = [...new Set((Array.isArray(permissions) ? permissions : []).filter((p) => PERMS.includes(p)))];
try {
const { rows } = await query(
'INSERT INTO groups (workspace_id, name, permissions) VALUES ($1,$2,$3) RETURNING *',
[workspace_id, String(name).trim(), perms],
);
return res.status(201).json({ group: rows[0] });
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'Group name taken' });
throw err;
}
});
// PATCH /api/groups/:id { name?, permissions[] } (workspace admin+)
router.patch('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
const group = found[0];
if (!group) return res.status(404).json({ error: 'Not found' });
if (!(await isWsAdmin(group.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Workspace admin only' });
}
const { name, permissions } = req.body ?? {};
const perms = permissions !== undefined
? [...new Set((Array.isArray(permissions) ? permissions : []).filter((p) => PERMS.includes(p)))]
: null;
const { rows } = await query(
`UPDATE groups SET name = COALESCE($1, name),
permissions = COALESCE($2, permissions) WHERE id = $3 RETURNING *`,
[
name !== undefined ? String(name).trim().slice(0, 120) : null,
perms,
group.id,
],
);
return res.json({ group: rows[0] });
});
// DELETE /api/groups/:id (workspace admin+)
router.delete('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
const group = found[0];
if (!group) return res.status(404).json({ error: 'Not found' });
if (!(await isWsAdmin(group.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Workspace admin only' });
}
await query('DELETE FROM groups WHERE id = $1', [group.id]);
return res.status(204).end();
});
// GET /api/groups/:id/members
router.get('/:id/members', async (req, res) => {
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
const group = found[0];
if (!group) return res.status(404).json({ error: 'Not found' });
if (!(await getRole(group.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Not a workspace member' });
}
const { rows } = await query(
`SELECT u.id, u.name, u.email, u.avatar FROM group_members m
JOIN users u ON u.id = m.user_id WHERE m.group_id = $1`,
[group.id],
);
return res.json({ members: rows });
});
// POST /api/groups/:id/members { user_id } (workspace admin+)
router.post('/:id/members', async (req, res) => {
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
const group = found[0];
if (!group) return res.status(404).json({ error: 'Not found' });
if (!(await isWsAdmin(group.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Workspace admin only' });
}
const { user_id } = req.body ?? {};
if (!(await getRole(group.workspace_id, user_id))) {
return res.status(400).json({ error: 'User must be a workspace member first' });
}
await query(
'INSERT INTO group_members (group_id, user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
[group.id, user_id],
);
return res.status(201).json({ ok: true });
});
// DELETE /api/groups/:id/members/:userId (workspace admin+)
router.delete('/:id/members/:userId', async (req, res) => {
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
const group = found[0];
if (!group) return res.status(404).json({ error: 'Not found' });
if (!(await isWsAdmin(group.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Workspace admin only' });
}
await query('DELETE FROM group_members WHERE group_id = $1 AND user_id = $2', [group.id, req.params.userId]);
return res.status(204).end();
});
export default router;
+38 -2
View File
@@ -13,8 +13,7 @@ export async function getRole(workspaceId, userId) {
}
/** Require membership; optionally require minimum role. Attaches req.membership. */
export function requireWorkspace(minRole = 'viewer') {
return async (req, res, next) => {
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);
@@ -39,3 +38,40 @@ export async function requireAdmin(req, res, next) {
if (!rows[0]?.is_admin) return res.status(403).json({ error: 'Admin only' });
return next();
}
export const PERMS = ['teams.create', 'teams.edit', 'teams.invite', 'workspace.invite'];
/** True if user holds a workspace permission via groups (owner/admin always pass). */
export async function hasPerm(workspaceId, userId, perm) {
const role = await getRole(workspaceId, userId);
if (role === 'owner' || role === 'admin') return true;
if (!role) return false;
const { rows } = await query(
`SELECT 1 FROM group_members gm JOIN groups g ON g.id = gm.group_id
WHERE g.workspace_id = $1 AND gm.user_id = $2 AND $3 = ANY(g.permissions) LIMIT 1`,
[workspaceId, userId, perm],
);
return !!rows[0];
}
/** Seed default permission groups for a workspace (idempotent). */
export async function ensureDefaultGroups(workspaceId) {
await query(
`INSERT INTO groups (workspace_id, name, permissions)
VALUES ($1, 'Team Creators', $2)
ON CONFLICT (workspace_id, name) DO NOTHING`,
[workspaceId, ['teams.create', 'teams.edit', 'teams.invite']],
);
}
/** True if user manages the team (manager, workspace owner/admin, or teams.edit perm). */
export async function canManageTeam(team, userId) {
const role = await getRole(team.workspace_id, userId);
if (role === 'owner' || role === 'admin') return true;
const { rows } = await query(
'SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2 AND role = $3',
[team.id, userId, 'manager'],
);
if (rows[0]) return true;
return hasPerm(team.workspace_id, userId, 'teams.edit');
}
+54
View File
@@ -0,0 +1,54 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
router.use(requireAuth);
// GET /api/invites/mine — my pending workspace invites
router.get('/mine', async (req, res) => {
const { rows } = await query(
`SELECT i.*, w.name AS workspace_name, u.name AS inviter_name
FROM workspace_invites i
JOIN workspaces w ON w.id = i.workspace_id
LEFT JOIN users u ON u.id = i.invited_by
WHERE (i.user_id = $1 OR i.email = $2) AND i.status = 'pending'
ORDER BY i.created_at DESC`,
[req.user.id, req.user.email],
);
return res.json({ invites: rows });
});
// POST /api/invites/:id/accept
router.post('/:id/accept', async (req, res) => {
const { rows } = await query('SELECT * FROM workspace_invites WHERE id = $1', [req.params.id]);
const inv = rows[0];
if (!inv) return res.status(404).json({ error: 'Invite not found' });
if (inv.status !== 'pending') return res.status(409).json({ error: `Invite ${inv.status}` });
if (inv.user_id !== req.user.id && inv.email !== req.user.email) {
return res.status(403).json({ error: 'Not your invite' });
}
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`,
[inv.workspace_id, req.user.id, inv.role],
);
await query(`UPDATE workspace_invites SET status = 'accepted', user_id = $1 WHERE id = $2`, [
req.user.id, inv.id,
]);
return res.json({ ok: true, workspace_id: inv.workspace_id });
});
// POST /api/invites/:id/decline
router.post('/:id/decline', async (req, res) => {
const { rows } = await query('SELECT * FROM workspace_invites WHERE id = $1', [req.params.id]);
const inv = rows[0];
if (!inv) return res.status(404).json({ error: 'Invite not found' });
if (inv.user_id !== req.user.id && inv.email !== req.user.email) {
return res.status(403).json({ error: 'Not your invite' });
}
await query(`UPDATE workspace_invites SET status = 'declined' WHERE id = $1`, [inv.id]);
return res.json({ ok: true });
});
export default router;
+38
View File
@@ -0,0 +1,38 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
router.use(requireAuth);
// GET /api/notifications/mine?unread=1
router.get('/mine', async (req, res) => {
const params = [req.user.id];
let cond = 'user_id = $1';
if (req.query.unread === '1') cond += ' AND is_read = FALSE';
const { rows } = await query(
`SELECT * FROM notifications WHERE ${cond} ORDER BY created_at DESC LIMIT 50`,
params,
);
const { rows: counts } = await query(
'SELECT COUNT(*)::int AS c FROM notifications WHERE user_id = $1 AND is_read = FALSE',
[req.user.id],
);
return res.json({ notifications: rows, unread: counts[0]?.c ?? 0 });
});
// PATCH /api/notifications/:id/read
router.patch('/:id/read', async (req, res) => {
await query('UPDATE notifications SET is_read = TRUE WHERE id = $1 AND user_id = $2', [
req.params.id, req.user.id,
]);
return res.json({ ok: true });
});
// POST /api/notifications/read-all
router.post('/read-all', async (req, res) => {
await query('UPDATE notifications SET is_read = TRUE WHERE user_id = $1', [req.user.id]);
return res.json({ ok: true });
});
export default router;
+37 -13
View File
@@ -13,7 +13,7 @@ async function canWrite(userId, workspaceId) {
return role === 'owner' || role === 'admin' || role === 'member';
}
async function checkLinks(workspaceId, book_id, page_id) {
async function checkLinks(workspaceId, book_id, page_id, team_id, assignee_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';
@@ -22,15 +22,25 @@ async function checkLinks(workspaceId, book_id, 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';
}
if (team_id) {
const { rows } = await query('SELECT id FROM teams WHERE id = $1 AND workspace_id = $2', [team_id, workspaceId]);
if (!rows[0]) return 'Invalid team_id for this workspace';
}
if (assignee_id) {
const { rows } = await query(
'SELECT 1 FROM workspace_members WHERE workspace_id = $1 AND user_id = $2',
[workspaceId, assignee_id],
);
if (!rows[0]) return 'Assignee must be a workspace member';
}
return null;
}
// GET /api/tasks/by-workspace/:workspaceId?status=&from=&to=
// from/to filter due_at (used by the calendar).
// GET /api/tasks/by-workspace/:workspaceId?status=&from=&to=&team_id=&assignee=
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 { status, from, to, team_id, assignee } = req.query;
const conds = ['t.workspace_id = $1'];
const params = [req.params.workspaceId];
if (status && STATUSES.includes(String(status))) {
@@ -45,18 +55,28 @@ router.get('/by-workspace/:workspaceId', async (req, res) => {
params.push(new Date(String(to)));
conds.push(`t.due_at <= $${params.length}`);
}
if (team_id) {
params.push(String(team_id));
conds.push(`t.team_id = $${params.length}`);
}
if (assignee) {
params.push(String(assignee));
conds.push(`t.assignee_id = $${params.length}`);
}
const { rows } = await query(
`SELECT t.*, b.title AS book_title, p.title AS page_title
`SELECT t.*, b.title AS book_title, p.title AS page_title,
tm.name AS team_name, u.name AS assignee_name, u.avatar AS assignee_avatar
FROM tasks t LEFT JOIN books b ON b.id = t.book_id LEFT JOIN pages p ON p.id = t.page_id
LEFT JOIN teams tm ON tm.id = t.team_id LEFT JOIN users u ON u.id = t.assignee_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? }
// POST /api/tasks { workspace_id, book_id?, page_id?, team_id?, assignee_id?, title, ... }
router.post('/', async (req, res) => {
const { workspace_id, book_id = null, page_id = null, title = '', status = 'todo', priority = 'normal', due_at = null } = req.body ?? {};
const { workspace_id, book_id = null, page_id = null, team_id = null, assignee_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' });
@@ -66,12 +86,12 @@ router.post('/', async (req, res) => {
}
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);
const linkErr = await checkLinks(workspace_id, book_id, page_id, team_id, assignee_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],
`INSERT INTO tasks (workspace_id, book_id, page_id, team_id, assignee_id, title, status, priority, due_at, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,
[workspace_id, book_id, page_id, team_id, assignee_id, String(title).trim(), status, priority, due_at ? new Date(due_at) : null, req.user.id],
);
return res.status(201).json({ task: rows[0] });
});
@@ -84,7 +104,7 @@ router.patch('/:id', async (req, res) => {
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 ?? {};
const { title, status, priority, due_at, book_id, page_id, team_id, assignee_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)) {
@@ -92,7 +112,9 @@ router.patch('/:id', async (req, res) => {
}
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);
const nextTeam = team_id !== undefined ? team_id : task.team_id;
const nextAssignee = assignee_id !== undefined ? assignee_id : task.assignee_id;
const linkErr = await checkLinks(task.workspace_id, nextBook, nextPage, nextTeam, nextAssignee);
if (linkErr) return res.status(400).json({ error: linkErr });
const sets = [];
const params = [];
@@ -103,6 +125,8 @@ router.patch('/:id', async (req, res) => {
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 (team_id !== undefined) push('team_id', team_id);
if (assignee_id !== undefined) push('assignee_id', assignee_id);
if (position !== undefined) push('position', Number(position));
if (sets.length === 0) return res.json({ task });
params.push(task.id);
+153
View File
@@ -0,0 +1,153 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { getRole, hasPerm, canManageTeam, ensureDefaultGroups } from './guards.js';
const router = Router();
router.use(requireAuth);
// GET /api/teams/by-workspace/:workspaceId (with member counts)
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 t.*, (SELECT COUNT(*)::int FROM team_members m WHERE m.team_id = t.id) AS members,
EXISTS(SELECT 1 FROM team_members m WHERE m.team_id = t.id AND m.user_id = $2) AS joined
FROM teams t WHERE t.workspace_id = $1 ORDER BY t.created_at`,
[req.params.workspaceId, req.user.id],
);
return res.json({ teams: rows });
});
// POST /api/teams { workspace_id, name, icon? } (teams.create perm; creator becomes manager)
router.post('/', async (req, res) => {
const { workspace_id, name = '', icon = '👥' } = req.body ?? {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
if (!(await hasPerm(workspace_id, req.user.id, 'teams.create'))) {
return res.status(403).json({ error: 'No permission to create teams' });
}
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 teams (workspace_id, name, icon, created_by) VALUES ($1,$2,$3,$4) RETURNING *',
[workspace_id, String(name).trim(), String(icon).slice(0, 20) || '👥', req.user.id],
);
await query(
`INSERT INTO team_members (team_id, user_id, role) VALUES ($1,$2,'manager')
ON CONFLICT DO NOTHING`,
[rows[0].id, req.user.id],
);
return res.status(201).json({ team: { ...rows[0], members: 1 } });
});
// PATCH /api/teams/:id { name?, icon? } (manager or teams.edit)
router.patch('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
if (!(await canManageTeam(team, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
const { name, icon } = req.body ?? {};
const { rows } = await query(
'UPDATE teams SET name = COALESCE($1, name), icon = COALESCE($2, icon) WHERE id = $3 RETURNING *',
[
name !== undefined ? String(name).trim().slice(0, 120) : null,
icon !== undefined ? String(icon).slice(0, 20) : null,
team.id,
],
);
return res.json({ team: rows[0] });
});
// DELETE /api/teams/:id (manager or teams.edit; linked workspaces keep running, badge cleared)
router.delete('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
if (!(await canManageTeam(team, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
await query('DELETE FROM teams WHERE id = $1', [team.id]);
return res.status(204).end();
});
// GET /api/teams/:id/members
router.get('/:id/members', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
if (!(await getRole(team.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Not a workspace member' });
}
const { rows } = await query(
`SELECT u.id, u.name, u.email, u.avatar, m.role FROM team_members m
JOIN users u ON u.id = m.user_id WHERE m.team_id = $1 ORDER BY m.created_at`,
[team.id],
);
return res.json({ members: rows });
});
// POST /api/teams/:id/members { user_id, role? } (manager or teams.invite; user must be workspace member)
router.post('/:id/members', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
const manager = await canManageTeam(team, req.user.id);
const mayInvite = manager || (await hasPerm(team.workspace_id, req.user.id, 'teams.invite'));
if (!mayInvite) return res.status(403).json({ error: 'No permission to invite to teams' });
const { user_id, role = 'member' } = req.body ?? {};
if (!['manager', 'member'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
if (!(await getRole(team.workspace_id, user_id))) {
return res.status(400).json({ error: 'User must be a workspace member first' });
}
await query(
`INSERT INTO team_members (team_id, user_id, role) VALUES ($1,$2,$3)
ON CONFLICT (team_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
[team.id, user_id, role],
);
return res.status(201).json({ ok: true });
});
// DELETE /api/teams/:id/members/:userId (manager or teams.invite)
router.delete('/:id/members/:userId', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
const manager = await canManageTeam(team, req.user.id);
const mayInvite = manager || (await hasPerm(team.workspace_id, req.user.id, 'teams.invite'));
if (!mayInvite) return res.status(403).json({ error: 'Forbidden' });
await query('DELETE FROM team_members WHERE team_id = $1 AND user_id = $2', [team.id, req.params.userId]);
return res.status(204).end();
});
// POST /api/teams/:id/workspace { name } — team creates its own workspace.
// The UI shows a TEAM_"name" badge behind the workspace name.
router.post('/:id/workspace', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
if (!(await canManageTeam(team, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
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, team_id) VALUES ($1,$2,$3) RETURNING *',
[String(name).trim(), req.user.id, team.id],
);
await ensureDefaultGroups(rows[0].id);
// Add all team members to the new workspace (as members)
const { rows: tm } = await query('SELECT user_id FROM team_members WHERE team_id = $1', [team.id]);
for (const m of tm) {
await query(
`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ($1,$2,'member')
ON CONFLICT DO NOTHING`,
[rows[0].id, m.user_id],
);
}
await query(
`UPDATE workspace_members SET role = 'owner' WHERE workspace_id = $1 AND user_id = $2`,
[rows[0].id, req.user.id],
);
return res.status(201).json({ workspace: { ...rows[0], team_name: team.name } });
});
export default router;
+39
View File
@@ -0,0 +1,39 @@
import { Router } from 'express';
import multer from 'multer';
import path from 'node:path';
import crypto from 'node:crypto';
import fs from 'node:fs';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
const uploadDir = path.join(process.cwd(), 'uploads');
fs.mkdirSync(uploadDir, { recursive: true });
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadDir),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname || '').toLowerCase().slice(0, 8);
cb(null, `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Only images allowed'));
},
});
// POST /api/upload — avatar / background image upload (returns relative URL)
router.post('/', requireAuth, (req, res) => {
upload.single('file')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message || 'Upload failed' });
if (!req.file) return res.status(400).json({ error: 'No file' });
return res.status(201).json({ url: `/uploads/${req.file.filename}` });
});
});
export default router;
+8 -5
View File
@@ -36,7 +36,7 @@ router.get('/discover', async (req, res) => {
// 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 ?? {};
const { name, email, avatar, locale, theme, bg_image, bg_blur, bg_glass } = 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' });
}
@@ -64,9 +64,10 @@ router.patch('/me', async (req, res) => {
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`,
bg_blur = COALESCE($7, bg_blur),
bg_glass = COALESCE($8, bg_glass)
WHERE id = $9
RETURNING id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, bg_glass, created_at`,
[
name !== undefined ? String(name).trim() : null,
email !== undefined ? String(email).toLowerCase() : null,
@@ -75,6 +76,7 @@ router.patch('/me', async (req, res) => {
theme !== undefined ? theme : null,
bg_image !== undefined ? String(bg_image).slice(0, 2000) : null,
bg_blur !== undefined ? Boolean(bg_blur) : null,
bg_glass !== undefined ? Boolean(bg_glass) : null,
req.user.id,
],
);
@@ -83,7 +85,8 @@ router.patch('/me', async (req, res) => {
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,
bgImage: u.bg_image || '', bgBlur: u.bg_blur !== false,
bgGlass: u.bg_glass !== false, createdAt: u.created_at,
},
});
} catch (err) {
+43 -12
View File
@@ -1,16 +1,18 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { getRole, requireWorkspace, ROLES } from './guards.js';
import { getRole, requireWorkspace, hasPerm, ensureDefaultGroups, ROLES } from './guards.js';
const router = Router();
router.use(requireAuth);
// GET /api/workspaces — list my workspaces
// GET /api/workspaces — list my workspaces (with team badge info)
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
`SELECT w.id, w.name, w.owner_id, w.team_id, w.created_at, w.updated_at, m.role,
t.name AS team_name
FROM workspaces w JOIN workspace_members m ON m.workspace_id = w.id
LEFT JOIN teams t ON t.id = w.team_id
WHERE m.user_id = $1 ORDER BY w.updated_at DESC`,
[req.user.id],
);
@@ -27,6 +29,7 @@ router.post('/', async (req, res) => {
'INSERT INTO workspaces (name, owner_id) VALUES ($1,$2) RETURNING *',
[String(name).trim(), req.user.id],
);
await ensureDefaultGroups(rows[0].id);
return res.status(201).json({ workspace: rows[0] });
});
@@ -67,20 +70,48 @@ router.get('/:id/members', requireWorkspace('viewer'), async (req, res) => {
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) => {
// POST /api/workspaces/:id/members { email, role } — creates a pending invite
// + notification (admin+ or workspace.invite permission; cannot grant owner)
router.post('/:id/members', requireWorkspace('viewer'), async (req, res) => {
const wsId = req.params.id;
const myRole = req.membership.role;
const allowed = myRole === 'owner' || myRole === 'admin' || (await hasPerm(wsId, req.user.id, 'workspace.invite'));
if (!allowed) return res.status(403).json({ error: 'No invite permission' });
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],
const targetEmail = String(email).toLowerCase();
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(targetEmail)) {
return res.status(400).json({ error: 'Valid email required' });
}
const { rows: users } = await query('SELECT id, name FROM users WHERE email = $1', [targetEmail]);
const target = users[0] || null;
if (target) {
const existing = await getRole(wsId, target.id);
if (existing) return res.status(409).json({ error: 'User is already a member' });
const dup = await query(
`SELECT id FROM workspace_invites WHERE workspace_id = $1 AND user_id = $2 AND status = 'pending'`,
[wsId, target.id],
);
if (dup.rows[0]) return res.status(409).json({ error: 'Invite already pending' });
}
const { rows: wsRows } = await query('SELECT name FROM workspaces WHERE id = $1', [wsId]);
const { rows: inv } = await query(
`INSERT INTO workspace_invites (workspace_id, email, user_id, role, invited_by)
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[wsId, targetEmail, target?.id || null, role, req.user.id],
);
return res.status(201).json({ ok: true });
if (target) {
await query(
`INSERT INTO notifications (user_id, type, title, body, link)
VALUES ($1,'invite',$2,$3,$4)`,
[target.id, `Workspace invite: ${wsRows[0]?.name || ''}`,
`You were invited as ${role}. Accept it from the bell menu.`, `/api/invites/${inv[0].id}`],
);
req.app.get('io')?.to(`user:${target.id}`).emit('notification:new', { type: 'invite' });
}
return res.status(201).json({ invite: inv[0] });
});
// PATCH /api/workspaces/:id/members/:userId { role } (admin+)