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:
@@ -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 ?? {};
|
||||
|
||||
@@ -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]) });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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+)
|
||||
|
||||
Reference in New Issue
Block a user