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:
@@ -76,6 +76,63 @@ CREATE TABLE IF NOT EXISTS app_settings (
|
|||||||
-- Global admin flag (first user created via web installer)
|
-- Global admin flag (first user created via web installer)
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE;
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Profile / appearance / locale settings (edited via settings window)
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS locale TEXT NOT NULL DEFAULT 'en';
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system';
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS bg_image TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS bg_blur BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
|
|
||||||
|
-- Books: independent containers that hold pages (collapsible in sidebar)
|
||||||
|
CREATE TABLE IF NOT EXISTS books (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL DEFAULT 'Untitled book' CHECK (char_length(title) BETWEEN 1 AND 200),
|
||||||
|
icon TEXT NOT NULL DEFAULT '📚',
|
||||||
|
position DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_books_workspace ON books(workspace_id);
|
||||||
|
|
||||||
|
-- Pages can optionally live inside a book (book_id NULL = top-level page)
|
||||||
|
ALTER TABLE pages ADD COLUMN IF NOT EXISTS book_id UUID REFERENCES books(id) ON DELETE CASCADE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pages_book ON pages(book_id);
|
||||||
|
|
||||||
|
-- Tasks: can link to a book and/or a page, optional deadline (feeds the calendar)
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
book_id UUID REFERENCES books(id) ON DELETE SET NULL,
|
||||||
|
page_id UUID REFERENCES pages(id) ON DELETE SET NULL,
|
||||||
|
title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 300),
|
||||||
|
status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo','doing','done')),
|
||||||
|
priority TEXT NOT NULL DEFAULT 'normal' CHECK (priority IN ('low','normal','high')),
|
||||||
|
due_at TIMESTAMPTZ NULL,
|
||||||
|
position DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_workspace ON tasks(workspace_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_due ON tasks(workspace_id, due_at);
|
||||||
|
|
||||||
|
-- Time tracking entries (open entry = running timer, ended_at IS NULL)
|
||||||
|
CREATE TABLE IF NOT EXISTS time_entries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
book_id UUID REFERENCES books(id) ON DELETE SET NULL,
|
||||||
|
page_id UUID REFERENCES pages(id) ON DELETE SET NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
ended_at TIMESTAMPTZ NULL,
|
||||||
|
duration_seconds INTEGER NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_time_ws_user ON time_entries(workspace_id, user_id);
|
||||||
|
|
||||||
-- Ensure workspace owner is always a member with role=owner
|
-- Ensure workspace owner is always a member with role=owner
|
||||||
CREATE OR REPLACE FUNCTION ensure_owner_membership() RETURNS trigger AS $$
|
CREATE OR REPLACE FUNCTION ensure_owner_membership() RETURNS trigger AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -110,3 +167,11 @@ FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
|
|||||||
DROP TRIGGER IF EXISTS trg_pages_touch ON pages;
|
DROP TRIGGER IF EXISTS trg_pages_touch ON pages;
|
||||||
CREATE TRIGGER trg_pages_touch BEFORE UPDATE ON pages
|
CREATE TRIGGER trg_pages_touch BEFORE UPDATE ON pages
|
||||||
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
|
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_books_touch ON books;
|
||||||
|
CREATE TRIGGER trg_books_touch BEFORE UPDATE ON books
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_tasks_touch ON tasks;
|
||||||
|
CREATE TRIGGER trg_tasks_touch BEFORE UPDATE ON tasks
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import setupRoutes, { isInstalled } from './routes/setup.js';
|
|||||||
import workspaceRoutes from './routes/workspaces.js';
|
import workspaceRoutes from './routes/workspaces.js';
|
||||||
import pageRoutes from './routes/pages.js';
|
import pageRoutes from './routes/pages.js';
|
||||||
import userRoutes from './routes/users.js';
|
import userRoutes from './routes/users.js';
|
||||||
|
import bookRoutes from './routes/books.js';
|
||||||
|
import taskRoutes from './routes/tasks.js';
|
||||||
|
import timeRoutes from './routes/time.js';
|
||||||
|
import adminRoutes from './routes/admin.js';
|
||||||
import { initSocket } from './socket.js';
|
import { initSocket } from './socket.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -43,6 +47,10 @@ app.use('/api/auth', rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }), authRout
|
|||||||
app.use('/api/workspaces', workspaceRoutes);
|
app.use('/api/workspaces', workspaceRoutes);
|
||||||
app.use('/api/pages', pageRoutes);
|
app.use('/api/pages', pageRoutes);
|
||||||
app.use('/api/users', userRoutes);
|
app.use('/api/users', userRoutes);
|
||||||
|
app.use('/api/books', bookRoutes);
|
||||||
|
app.use('/api/tasks', taskRoutes);
|
||||||
|
app.use('/api/time', timeRoutes);
|
||||||
|
app.use('/api/admin', adminRoutes);
|
||||||
|
|
||||||
app.get('/api/health', async (_req, res) => {
|
app.get('/api/health', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { query } from '../config/db.js';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { requireAdmin } from './guards.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(requireAuth, requireAdmin);
|
||||||
|
|
||||||
|
// GET /api/admin/overview — counts + recent users
|
||||||
|
router.get('/overview', async (_req, res) => {
|
||||||
|
const [[u], [w], [b], [p], [t], [e]] = await Promise.all(
|
||||||
|
['users', 'workspaces', 'books', 'pages', 'tasks', 'time_entries'].map(
|
||||||
|
(tbl) => query(`SELECT COUNT(*)::int AS c FROM ${tbl}`).then((r) => r.rows),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const { rows: recent } = await query(
|
||||||
|
'SELECT id, name, email, is_admin, created_at FROM users ORDER BY created_at DESC LIMIT 8',
|
||||||
|
);
|
||||||
|
return res.json({
|
||||||
|
counts: {
|
||||||
|
users: u.c, workspaces: w.c, books: b.c, pages: p.c, tasks: t.c, timeEntries: e.c,
|
||||||
|
},
|
||||||
|
recentUsers: recent,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/admin/users
|
||||||
|
router.get('/users', async (_req, res) => {
|
||||||
|
const { rows } = await query(
|
||||||
|
`SELECT u.id, u.name, u.email, u.is_admin, u.locale, u.created_at,
|
||||||
|
(SELECT COUNT(*)::int FROM workspace_members m WHERE m.user_id = u.id) AS workspaces
|
||||||
|
FROM users u ORDER BY u.created_at`,
|
||||||
|
);
|
||||||
|
return res.json({ users: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/admin/users/:id { is_admin }
|
||||||
|
router.patch('/users/:id', async (req, res) => {
|
||||||
|
const { is_admin } = req.body ?? {};
|
||||||
|
if (typeof is_admin !== 'boolean') return res.status(400).json({ error: 'is_admin boolean required' });
|
||||||
|
if (req.params.id === req.user.id && is_admin === false) {
|
||||||
|
return res.status(400).json({ error: 'You cannot demote yourself' });
|
||||||
|
}
|
||||||
|
const { rows } = await query(
|
||||||
|
'UPDATE users SET is_admin = $1 WHERE id = $2 RETURNING id, name, email, is_admin',
|
||||||
|
[is_admin, req.params.id],
|
||||||
|
);
|
||||||
|
if (!rows[0]) return res.status(404).json({ error: 'User not found' });
|
||||||
|
return res.json({ user: rows[0] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/admin/users/:id
|
||||||
|
router.delete('/users/:id', async (req, res) => {
|
||||||
|
if (req.params.id === req.user.id) {
|
||||||
|
return res.status(400).json({ error: 'You cannot delete yourself' });
|
||||||
|
}
|
||||||
|
const { rowCount } = await query('DELETE FROM users WHERE id = $1', [req.params.id]);
|
||||||
|
if (!rowCount) return res.status(404).json({ error: 'User not found' });
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -7,7 +7,18 @@ const router = Router();
|
|||||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||||
|
|
||||||
function publicUser(row) {
|
function publicUser(row) {
|
||||||
return { id: row.id, email: row.email, name: row.name, isAdmin: !!row.is_admin, createdAt: row.created_at };
|
return {
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
name: row.name,
|
||||||
|
isAdmin: !!row.is_admin,
|
||||||
|
avatar: row.avatar || '',
|
||||||
|
locale: row.locale || 'en',
|
||||||
|
theme: row.theme || 'system',
|
||||||
|
bgImage: row.bg_image || '',
|
||||||
|
bgBlur: row.bg_blur !== false,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/auth/register { email, name, password }
|
// POST /api/auth/register { email, name, password }
|
||||||
@@ -27,7 +38,7 @@ router.post('/register', async (req, res) => {
|
|||||||
if (existing.rowCount > 0) return res.status(409).json({ error: 'Email already registered' });
|
if (existing.rowCount > 0) return res.status(409).json({ error: 'Email already registered' });
|
||||||
const hash = await bcrypt.hash(String(password), 12);
|
const hash = await bcrypt.hash(String(password), 12);
|
||||||
const { rows } = await query(
|
const { rows } = await query(
|
||||||
'INSERT INTO users (email, name, password_hash) VALUES ($1,$2,$3) RETURNING id, email, name, created_at',
|
'INSERT INTO users (email, name, password_hash) VALUES ($1,$2,$3) RETURNING id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, created_at',
|
||||||
[String(email).toLowerCase(), String(name).trim(), hash],
|
[String(email).toLowerCase(), String(name).trim(), hash],
|
||||||
);
|
);
|
||||||
const token = signToken(rows[0]);
|
const token = signToken(rows[0]);
|
||||||
@@ -57,7 +68,7 @@ router.post('/login', async (req, res) => {
|
|||||||
|
|
||||||
// GET /api/auth/me
|
// GET /api/auth/me
|
||||||
router.get('/me', requireAuth, async (req, res) => {
|
router.get('/me', requireAuth, async (req, res) => {
|
||||||
const { rows } = await query('SELECT id, email, name, created_at FROM users WHERE id = $1', [req.user.id]);
|
const { rows } = await query('SELECT id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, created_at FROM users WHERE id = $1', [req.user.id]);
|
||||||
if (!rows[0]) return res.status(404).json({ error: 'User not found' });
|
if (!rows[0]) return res.status(404).json({ error: 'User not found' });
|
||||||
return res.json({ user: publicUser(rows[0]) });
|
return res.json({ user: publicUser(rows[0]) });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { query } from '../config/db.js';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { getRole } from './guards.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
// GET /api/books/by-workspace/:workspaceId
|
||||||
|
router.get('/by-workspace/:workspaceId', async (req, res) => {
|
||||||
|
const role = await getRole(req.params.workspaceId, req.user.id);
|
||||||
|
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
|
||||||
|
const { rows } = await query(
|
||||||
|
'SELECT * FROM books WHERE workspace_id = $1 ORDER BY position, updated_at DESC',
|
||||||
|
[req.params.workspaceId],
|
||||||
|
);
|
||||||
|
return res.json({ books: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/books { workspace_id, title?, icon? } (member+)
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const { workspace_id, title = 'Untitled book', icon = '📚' } = req.body ?? {};
|
||||||
|
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
|
||||||
|
const role = await getRole(workspace_id, req.user.id);
|
||||||
|
if (role !== 'owner' && role !== 'admin' && role !== 'member') {
|
||||||
|
return res.status(403).json({ error: 'Viewers cannot create books' });
|
||||||
|
}
|
||||||
|
const { rows } = await query(
|
||||||
|
'INSERT INTO books (workspace_id, title, icon, created_by) VALUES ($1,$2,$3,$4) RETURNING *',
|
||||||
|
[workspace_id, String(title).slice(0, 200) || 'Untitled book', String(icon).slice(0, 20) || '📚', req.user.id],
|
||||||
|
);
|
||||||
|
return res.status(201).json({ book: rows[0] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/books/:id { title?, icon?, position? } (member+)
|
||||||
|
router.patch('/:id', async (req, res) => {
|
||||||
|
const { rows: found } = await query('SELECT * FROM books WHERE id = $1', [req.params.id]);
|
||||||
|
const book = found[0];
|
||||||
|
if (!book) return res.status(404).json({ error: 'Not found' });
|
||||||
|
const role = await getRole(book.workspace_id, req.user.id);
|
||||||
|
if (role !== 'owner' && role !== 'admin' && role !== 'member') {
|
||||||
|
return res.status(403).json({ error: 'Viewers cannot edit' });
|
||||||
|
}
|
||||||
|
const { title, icon, position } = req.body ?? {};
|
||||||
|
const { rows } = await query(
|
||||||
|
`UPDATE books SET title = COALESCE($1, title), icon = COALESCE($2, icon),
|
||||||
|
position = COALESCE($3, position) WHERE id = $4 RETURNING *`,
|
||||||
|
[
|
||||||
|
title !== undefined ? String(title).slice(0, 200) : null,
|
||||||
|
icon !== undefined ? String(icon).slice(0, 20) : null,
|
||||||
|
position !== undefined ? Number(position) : null,
|
||||||
|
book.id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return res.json({ book: rows[0] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/books/:id (member+; pages inside are removed via FK cascade)
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
const { rows: found } = await query('SELECT * FROM books WHERE id = $1', [req.params.id]);
|
||||||
|
const book = found[0];
|
||||||
|
if (!book) return res.status(404).json({ error: 'Not found' });
|
||||||
|
const role = await getRole(book.workspace_id, req.user.id);
|
||||||
|
if (role !== 'owner' && role !== 'admin' && role !== 'member') {
|
||||||
|
return res.status(403).json({ error: 'Viewers cannot delete' });
|
||||||
|
}
|
||||||
|
await query('DELETE FROM books WHERE id = $1', [book.id]);
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -32,3 +32,10 @@ export async function getPageWithWorkspace(pageId) {
|
|||||||
const { rows } = await query('SELECT * FROM pages WHERE id = $1', [pageId]);
|
const { rows } = await query('SELECT * FROM pages WHERE id = $1', [pageId]);
|
||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Require global admin flag (set via web installer / admin dashboard). */
|
||||||
|
export async function requireAdmin(req, res, next) {
|
||||||
|
const { rows } = await query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]);
|
||||||
|
if (!rows[0]?.is_admin) return res.status(403).json({ error: 'Admin only' });
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,24 +30,31 @@ router.get('/by-workspace/:workspaceId', async (req, res) => {
|
|||||||
return res.json({ pages: rows, role });
|
return res.json({ pages: rows, role });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/pages { workspace_id, parent_id?, title? }
|
// POST /api/pages { workspace_id, parent_id?, book_id?, title? }
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
const { workspace_id, parent_id = null, title = 'Untitled' } = req.body ?? {};
|
const { workspace_id, parent_id = null, book_id = null, title = 'Untitled' } = req.body ?? {};
|
||||||
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
|
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
|
||||||
const role = await getRole(workspace_id, req.user.id);
|
const role = await getRole(workspace_id, req.user.id);
|
||||||
if (role !== 'owner' && role !== 'admin' && role !== 'member') {
|
if (role !== 'owner' && role !== 'admin' && role !== 'member') {
|
||||||
return res.status(403).json({ error: 'Viewers cannot create pages' });
|
return res.status(403).json({ error: 'Viewers cannot create pages' });
|
||||||
}
|
}
|
||||||
|
if (book_id) {
|
||||||
|
const { rows } = await query('SELECT id FROM books WHERE id = $1 AND workspace_id = $2', [book_id, workspace_id]);
|
||||||
|
if (!rows[0]) return res.status(400).json({ error: 'Invalid book_id' });
|
||||||
|
}
|
||||||
if (parent_id) {
|
if (parent_id) {
|
||||||
const parent = await getPageWithWorkspace(parent_id);
|
const parent = await getPageWithWorkspace(parent_id);
|
||||||
if (!parent || parent.workspace_id !== workspace_id) {
|
if (!parent || parent.workspace_id !== workspace_id) {
|
||||||
return res.status(400).json({ error: 'Invalid parent_id' });
|
return res.status(400).json({ error: 'Invalid parent_id' });
|
||||||
}
|
}
|
||||||
|
if ((parent.book_id || null) !== (book_id || null)) {
|
||||||
|
return res.status(400).json({ error: 'Parent lives in a different book' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const { rows } = await query(
|
const { rows } = await query(
|
||||||
`INSERT INTO pages (workspace_id, parent_id, title, created_by)
|
`INSERT INTO pages (workspace_id, parent_id, book_id, title, created_by)
|
||||||
VALUES ($1,$2,$3,$4) RETURNING *`,
|
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
|
||||||
[workspace_id, parent_id, String(title).slice(0, 300) || 'Untitled', req.user.id],
|
[workspace_id, parent_id, book_id, String(title).slice(0, 300) || 'Untitled', req.user.id],
|
||||||
);
|
);
|
||||||
return res.status(201).json({ page: rows[0] });
|
return res.status(201).json({ page: rows[0] });
|
||||||
});
|
});
|
||||||
@@ -71,7 +78,7 @@ router.patch('/:id', async (req, res) => {
|
|||||||
const page = await getPageWithWorkspace(req.params.id);
|
const page = await getPageWithWorkspace(req.params.id);
|
||||||
if (!page) return res.status(404).json({ error: 'Not found' });
|
if (!page) return res.status(404).json({ error: 'Not found' });
|
||||||
if (!(await canWrite(req.user.id, page))) return res.status(403).json({ error: 'Viewers cannot edit' });
|
if (!(await canWrite(req.user.id, page))) return res.status(403).json({ error: 'Viewers cannot edit' });
|
||||||
const { title, icon, cover, content_json, content_html, position, parent_id, is_archived } = req.body ?? {};
|
const { title, icon, cover, content_json, content_html, position, parent_id, book_id, is_archived } = req.body ?? {};
|
||||||
if (parent_id !== undefined && parent_id !== null) {
|
if (parent_id !== undefined && parent_id !== null) {
|
||||||
if (parent_id === page.id) return res.status(400).json({ error: 'Page cannot be its own parent' });
|
if (parent_id === page.id) return res.status(400).json({ error: 'Page cannot be its own parent' });
|
||||||
const parent = await getPageWithWorkspace(parent_id);
|
const parent = await getPageWithWorkspace(parent_id);
|
||||||
@@ -79,6 +86,10 @@ router.patch('/:id', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Invalid parent_id' });
|
return res.status(400).json({ error: 'Invalid parent_id' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (book_id !== undefined && book_id !== null) {
|
||||||
|
const { rows } = await query('SELECT id FROM books WHERE id = $1 AND workspace_id = $2', [book_id, page.workspace_id]);
|
||||||
|
if (!rows[0]) return res.status(400).json({ error: 'Invalid book_id' });
|
||||||
|
}
|
||||||
const { rows } = await query(
|
const { rows } = await query(
|
||||||
`UPDATE pages SET
|
`UPDATE pages SET
|
||||||
title = COALESCE($1, title),
|
title = COALESCE($1, title),
|
||||||
@@ -88,6 +99,7 @@ router.patch('/:id', async (req, res) => {
|
|||||||
content_html = COALESCE($5, content_html),
|
content_html = COALESCE($5, content_html),
|
||||||
position = COALESCE($6, position),
|
position = COALESCE($6, position),
|
||||||
parent_id = CASE WHEN $8 THEN $7 ELSE parent_id END,
|
parent_id = CASE WHEN $8 THEN $7 ELSE parent_id END,
|
||||||
|
book_id = CASE WHEN $12 THEN $11 ELSE book_id END,
|
||||||
is_archived = COALESCE($9, is_archived)
|
is_archived = COALESCE($9, is_archived)
|
||||||
WHERE id = $10 RETURNING *`,
|
WHERE id = $10 RETURNING *`,
|
||||||
[
|
[
|
||||||
@@ -101,6 +113,8 @@ router.patch('/:id', async (req, res) => {
|
|||||||
parent_id !== undefined,
|
parent_id !== undefined,
|
||||||
is_archived !== undefined ? Boolean(is_archived) : null,
|
is_archived !== undefined ? Boolean(is_archived) : null,
|
||||||
page.id,
|
page.id,
|
||||||
|
book_id !== undefined ? book_id : null,
|
||||||
|
book_id !== undefined,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const updated = rows[0];
|
const updated = rows[0];
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { query } from '../config/db.js';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { getRole } from './guards.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
const STATUSES = ['todo', 'doing', 'done'];
|
||||||
|
const PRIORITIES = ['low', 'normal', 'high'];
|
||||||
|
|
||||||
|
async function canWrite(userId, workspaceId) {
|
||||||
|
const role = await getRole(workspaceId, userId);
|
||||||
|
return role === 'owner' || role === 'admin' || role === 'member';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkLinks(workspaceId, book_id, page_id) {
|
||||||
|
if (book_id) {
|
||||||
|
const { rows } = await query('SELECT id FROM books WHERE id = $1 AND workspace_id = $2', [book_id, workspaceId]);
|
||||||
|
if (!rows[0]) return 'Invalid book_id for this workspace';
|
||||||
|
}
|
||||||
|
if (page_id) {
|
||||||
|
const { rows } = await query('SELECT id FROM pages WHERE id = $1 AND workspace_id = $2', [page_id, workspaceId]);
|
||||||
|
if (!rows[0]) return 'Invalid page_id for this workspace';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/tasks/by-workspace/:workspaceId?status=&from=&to=
|
||||||
|
// from/to filter due_at (used by the calendar).
|
||||||
|
router.get('/by-workspace/:workspaceId', async (req, res) => {
|
||||||
|
const role = await getRole(req.params.workspaceId, req.user.id);
|
||||||
|
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
|
||||||
|
const { status, from, to } = req.query;
|
||||||
|
const conds = ['t.workspace_id = $1'];
|
||||||
|
const params = [req.params.workspaceId];
|
||||||
|
if (status && STATUSES.includes(String(status))) {
|
||||||
|
params.push(String(status));
|
||||||
|
conds.push(`t.status = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (from) {
|
||||||
|
params.push(new Date(String(from)));
|
||||||
|
conds.push(`t.due_at >= $${params.length}`);
|
||||||
|
}
|
||||||
|
if (to) {
|
||||||
|
params.push(new Date(String(to)));
|
||||||
|
conds.push(`t.due_at <= $${params.length}`);
|
||||||
|
}
|
||||||
|
const { rows } = await query(
|
||||||
|
`SELECT t.*, b.title AS book_title, p.title AS page_title
|
||||||
|
FROM tasks t LEFT JOIN books b ON b.id = t.book_id LEFT JOIN pages p ON p.id = t.page_id
|
||||||
|
WHERE ${conds.join(' AND ')} ORDER BY t.due_at NULLS LAST, t.position, t.created_at`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return res.json({ tasks: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/tasks { workspace_id, book_id?, page_id?, title, status?, priority?, due_at? }
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const { workspace_id, book_id = null, page_id = null, title = '', status = 'todo', priority = 'normal', due_at = null } = req.body ?? {};
|
||||||
|
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
|
||||||
|
if (!(await canWrite(req.user.id, workspace_id))) {
|
||||||
|
return res.status(403).json({ error: 'Viewers cannot create tasks' });
|
||||||
|
}
|
||||||
|
if (String(title).trim().length < 1 || String(title).length > 300) {
|
||||||
|
return res.status(400).json({ error: 'Title must be 1-300 chars' });
|
||||||
|
}
|
||||||
|
if (!STATUSES.includes(status)) return res.status(400).json({ error: 'Invalid status' });
|
||||||
|
if (!PRIORITIES.includes(priority)) return res.status(400).json({ error: 'Invalid priority' });
|
||||||
|
const linkErr = await checkLinks(workspace_id, book_id, page_id);
|
||||||
|
if (linkErr) return res.status(400).json({ error: linkErr });
|
||||||
|
const { rows } = await query(
|
||||||
|
`INSERT INTO tasks (workspace_id, book_id, page_id, title, status, priority, due_at, created_by)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
||||||
|
[workspace_id, book_id, page_id, String(title).trim(), status, priority, due_at ? new Date(due_at) : null, req.user.id],
|
||||||
|
);
|
||||||
|
return res.status(201).json({ task: rows[0] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/tasks/:id
|
||||||
|
router.patch('/:id', async (req, res) => {
|
||||||
|
const { rows: found } = await query('SELECT * FROM tasks WHERE id = $1', [req.params.id]);
|
||||||
|
const task = found[0];
|
||||||
|
if (!task) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (!(await canWrite(req.user.id, task.workspace_id))) {
|
||||||
|
return res.status(403).json({ error: 'Viewers cannot edit' });
|
||||||
|
}
|
||||||
|
const { title, status, priority, due_at, book_id, page_id, position } = req.body ?? {};
|
||||||
|
if (status !== undefined && !STATUSES.includes(status)) return res.status(400).json({ error: 'Invalid status' });
|
||||||
|
if (priority !== undefined && !PRIORITIES.includes(priority)) return res.status(400).json({ error: 'Invalid priority' });
|
||||||
|
if (title !== undefined && (String(title).trim().length < 1 || String(title).length > 300)) {
|
||||||
|
return res.status(400).json({ error: 'Title must be 1-300 chars' });
|
||||||
|
}
|
||||||
|
const nextBook = book_id !== undefined ? book_id : task.book_id;
|
||||||
|
const nextPage = page_id !== undefined ? page_id : task.page_id;
|
||||||
|
const linkErr = await checkLinks(task.workspace_id, nextBook, nextPage);
|
||||||
|
if (linkErr) return res.status(400).json({ error: linkErr });
|
||||||
|
const sets = [];
|
||||||
|
const params = [];
|
||||||
|
const push = (col, val) => { params.push(val); sets.push(`${col} = $${params.length}`); };
|
||||||
|
if (title !== undefined) push('title', String(title).trim());
|
||||||
|
if (status !== undefined) push('status', status);
|
||||||
|
if (priority !== undefined) push('priority', priority);
|
||||||
|
if (due_at !== undefined) push('due_at', due_at ? new Date(due_at) : null);
|
||||||
|
if (book_id !== undefined) push('book_id', book_id);
|
||||||
|
if (page_id !== undefined) push('page_id', page_id);
|
||||||
|
if (position !== undefined) push('position', Number(position));
|
||||||
|
if (sets.length === 0) return res.json({ task });
|
||||||
|
params.push(task.id);
|
||||||
|
const { rows } = await query(
|
||||||
|
`UPDATE tasks SET ${sets.join(', ')} WHERE id = $${params.length} RETURNING *`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return res.json({ task: rows[0] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/tasks/:id
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
const { rows: found } = await query('SELECT * FROM tasks WHERE id = $1', [req.params.id]);
|
||||||
|
const task = found[0];
|
||||||
|
if (!task) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (!(await canWrite(req.user.id, task.workspace_id))) {
|
||||||
|
return res.status(403).json({ error: 'Viewers cannot delete' });
|
||||||
|
}
|
||||||
|
await query('DELETE FROM tasks WHERE id = $1', [task.id]);
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { query } from '../config/db.js';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { getRole } from './guards.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
function serialize(row) {
|
||||||
|
if (!row) return row;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/time/by-workspace/:workspaceId — entries + currently running timer
|
||||||
|
router.get('/by-workspace/:workspaceId', async (req, res) => {
|
||||||
|
const role = await getRole(req.params.workspaceId, req.user.id);
|
||||||
|
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
|
||||||
|
const mine = req.query.mine === '1';
|
||||||
|
const params = [req.params.workspaceId];
|
||||||
|
let cond = 'e.workspace_id = $1';
|
||||||
|
if (mine) {
|
||||||
|
params.push(req.user.id);
|
||||||
|
cond += ` AND e.user_id = $${params.length}`;
|
||||||
|
}
|
||||||
|
const { rows } = await query(
|
||||||
|
`SELECT e.*, b.title AS book_title, p.title AS page_title, u.name AS user_name
|
||||||
|
FROM time_entries e
|
||||||
|
LEFT JOIN books b ON b.id = e.book_id
|
||||||
|
LEFT JOIN pages p ON p.id = e.page_id
|
||||||
|
LEFT JOIN users u ON u.id = e.user_id
|
||||||
|
WHERE ${cond} ORDER BY e.started_at DESC LIMIT 200`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
const open = rows.find((r) => r.user_id === req.user.id && !r.ended_at) || null;
|
||||||
|
return res.json({ entries: rows, open });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/time/start { workspace_id, book_id?, page_id?, description? }
|
||||||
|
router.post('/start', async (req, res) => {
|
||||||
|
const { workspace_id, book_id = null, page_id = null, description = '' } = req.body ?? {};
|
||||||
|
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
|
||||||
|
const role = await getRole(workspace_id, req.user.id);
|
||||||
|
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
|
||||||
|
const { rows: running } = await query(
|
||||||
|
'SELECT id FROM time_entries WHERE workspace_id = $1 AND user_id = $2 AND ended_at IS NULL',
|
||||||
|
[workspace_id, req.user.id],
|
||||||
|
);
|
||||||
|
if (running[0]) return res.status(409).json({ error: 'Timer already running', entry: running[0] });
|
||||||
|
const { rows } = await query(
|
||||||
|
`INSERT INTO time_entries (workspace_id, user_id, book_id, page_id, description)
|
||||||
|
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
|
||||||
|
[workspace_id, req.user.id, book_id, page_id, String(description).slice(0, 500)],
|
||||||
|
);
|
||||||
|
return res.status(201).json({ entry: serialize(rows[0]) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/time/stop { workspace_id } — stops my running timer
|
||||||
|
router.post('/stop', async (req, res) => {
|
||||||
|
const { workspace_id } = req.body ?? {};
|
||||||
|
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
|
||||||
|
const { rows } = await query(
|
||||||
|
`UPDATE time_entries SET ended_at = now(),
|
||||||
|
duration_seconds = EXTRACT(EPOCH FROM (now() - started_at))::int
|
||||||
|
WHERE id = (
|
||||||
|
SELECT id FROM time_entries
|
||||||
|
WHERE workspace_id = $1 AND user_id = $2 AND ended_at IS NULL
|
||||||
|
ORDER BY started_at DESC LIMIT 1
|
||||||
|
) RETURNING *`,
|
||||||
|
[workspace_id, req.user.id],
|
||||||
|
);
|
||||||
|
if (!rows[0]) return res.status(404).json({ error: 'No running timer' });
|
||||||
|
return res.json({ entry: rows[0] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/time/:id { description?, book_id?, page_id? } (own or admin+)
|
||||||
|
router.patch('/:id', async (req, res) => {
|
||||||
|
const { rows: found } = await query('SELECT * FROM time_entries WHERE id = $1', [req.params.id]);
|
||||||
|
const entry = found[0];
|
||||||
|
if (!entry) return res.status(404).json({ error: 'Not found' });
|
||||||
|
const role = await getRole(entry.workspace_id, req.user.id);
|
||||||
|
const { rows: me } = await query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]);
|
||||||
|
if (entry.user_id !== req.user.id && !me[0]?.is_admin && role !== 'owner' && role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
const { description, book_id, page_id } = req.body ?? {};
|
||||||
|
const { rows } = await query(
|
||||||
|
`UPDATE time_entries SET description = COALESCE($1, description),
|
||||||
|
book_id = CASE WHEN $4 THEN $2 ELSE book_id END,
|
||||||
|
page_id = CASE WHEN $5 THEN $3 ELSE page_id END
|
||||||
|
WHERE id = $6 RETURNING *`,
|
||||||
|
[
|
||||||
|
description !== undefined ? String(description).slice(0, 500) : null,
|
||||||
|
book_id !== undefined ? book_id : null,
|
||||||
|
page_id !== undefined ? page_id : null,
|
||||||
|
book_id !== undefined, page_id !== undefined,
|
||||||
|
entry.id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return res.json({ entry: rows[0] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/time/:id (own or admin+)
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
const { rows: found } = await query('SELECT * FROM time_entries WHERE id = $1', [req.params.id]);
|
||||||
|
const entry = found[0];
|
||||||
|
if (!entry) return res.status(404).json({ error: 'Not found' });
|
||||||
|
const role = await getRole(entry.workspace_id, req.user.id);
|
||||||
|
const { rows: me } = await query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]);
|
||||||
|
if (entry.user_id !== req.user.id && !me[0]?.is_admin && role !== 'owner' && role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
await query('DELETE FROM time_entries WHERE id = $1', [entry.id]);
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,18 +1,111 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
import { query } from '../config/db.js';
|
import { query } from '../config/db.js';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(requireAuth);
|
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) => {
|
router.get('/search', async (req, res) => {
|
||||||
const { email = '' } = req.query;
|
const { email = '' } = req.query;
|
||||||
if (!email) return res.status(400).json({ error: 'email required' });
|
if (!email) return res.status(400).json({ error: 'email required' });
|
||||||
const { rows } = await query('SELECT id, email, name FROM users WHERE email = $1', [
|
const { rows } = await query(
|
||||||
String(email).toLowerCase(),
|
'SELECT id, email, name, avatar FROM users WHERE email = $1',
|
||||||
]);
|
[String(email).toLowerCase()],
|
||||||
|
);
|
||||||
return res.json({ user: rows[0] || null });
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ router.delete('/:id', requireWorkspace('owner'), async (req, res) => {
|
|||||||
// GET /api/workspaces/:id/members
|
// GET /api/workspaces/:id/members
|
||||||
router.get('/:id/members', requireWorkspace('viewer'), async (req, res) => {
|
router.get('/:id/members', requireWorkspace('viewer'), async (req, res) => {
|
||||||
const { rows } = await query(
|
const { rows } = await query(
|
||||||
`SELECT u.id, u.email, u.name, m.role, m.created_at
|
`SELECT u.id, u.email, u.name, u.avatar, m.role, m.created_at
|
||||||
FROM workspace_members m JOIN users u ON u.id = m.user_id
|
FROM workspace_members m JOIN users u ON u.id = m.user_id
|
||||||
WHERE m.workspace_id = $1 ORDER BY m.created_at`,
|
WHERE m.workspace_id = $1 ORDER BY m.created_at`,
|
||||||
[req.params.id],
|
[req.params.id],
|
||||||
|
|||||||
Reference in New Issue
Block a user