diff --git a/.gitignore b/.gitignore index 0900635..5f108b2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +uploads/ .env *.log .DS_Store diff --git a/backend/package-lock.json b/backend/package-lock.json index 7dc3615..70eebb7 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,10 +12,12 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", + "express-async-errors": "^3.1.1", "express-rate-limit": "^7.2.0", "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", "pg": "^8.12.0", "socket.io": "^4.7.5" } @@ -66,6 +68,12 @@ "node": ">= 0.6" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -135,6 +143,23 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -173,6 +198,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -209,6 +249,12 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -447,6 +493,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-async-errors": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz", + "integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==", + "license": "ISC", + "peerDependencies": { + "express": "^4.16.2" + } + }, "node_modules/express-rate-limit": { "version": "7.5.1", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", @@ -636,6 +691,12 @@ "node": ">= 0.10" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -796,6 +857,27 @@ "node": ">= 0.6" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/morgan": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.1.tgz", @@ -822,6 +904,25 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1016,6 +1117,12 @@ "node": ">=0.10.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", @@ -1073,6 +1180,27 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1362,6 +1490,29 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1384,6 +1535,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", @@ -1399,6 +1556,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index 5019204..953b6eb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,10 +15,12 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", + "express-async-errors": "^3.1.1", "express-rate-limit": "^7.2.0", "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", "pg": "^8.12.0", "socket.io": "^4.7.5" } diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index faf9fc6..568fb5a 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -133,6 +133,85 @@ CREATE TABLE IF NOT EXISTS time_entries ( ); CREATE INDEX IF NOT EXISTS idx_time_ws_user ON time_entries(workspace_id, user_id); +-- Pending workspace invites (accept/decline via notification bell) +CREATE TABLE IF NOT EXISTS workspace_invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + email TEXT NOT NULL, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin','member','viewer')), + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','accepted','declined')), + invited_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_invites_email ON workspace_invites(email); +CREATE INDEX IF NOT EXISTS idx_invites_user ON workspace_invites(user_id); + +-- Notifications (invites, chat mentions/messages, etc.) +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL DEFAULT 'info', + title TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + link TEXT NOT NULL DEFAULT '', + is_read BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_notif_user ON notifications(user_id, is_read); + +-- Workspace team chat +CREATE TABLE IF NOT EXISTS chat_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + body TEXT NOT NULL CHECK (char_length(body) BETWEEN 1 AND 2000), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_chat_ws ON chat_messages(workspace_id, created_at); + +-- Teams inside a workspace +CREATE TABLE IF NOT EXISTS teams ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + name TEXT NOT NULL CHECK (char_length(name) BETWEEN 1 AND 120), + icon TEXT NOT NULL DEFAULT '👥', + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +-- Workspace optionally owned by a team (shows TEAM_"name" badge in UI) +ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL; +-- Tasks: assignee (user) + owning team (team boards) +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS assignee_id UUID REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_tasks_team ON tasks(team_id); +CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee_id); +-- Glassmorphism widgets over background image +ALTER TABLE users ADD COLUMN IF NOT EXISTS bg_glass BOOLEAN NOT NULL DEFAULT TRUE; +CREATE TABLE IF NOT EXISTS team_members ( + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('manager','member')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (team_id, user_id) +); + +-- Permission groups (e.g. "Team Creators" with teams.create / teams.edit / teams.invite) +CREATE TABLE IF NOT EXISTS groups ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + name TEXT NOT NULL CHECK (char_length(name) BETWEEN 1 AND 120), + permissions TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (workspace_id, name) +); +CREATE TABLE IF NOT EXISTS group_members ( + group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (group_id, user_id) +); + -- Ensure workspace owner is always a member with role=owner CREATE OR REPLACE FUNCTION ensure_owner_membership() RETURNS trigger AS $$ BEGIN diff --git a/backend/src/index.js b/backend/src/index.js index ec906d9..90822de 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1,4 +1,5 @@ import 'dotenv/config'; +import 'express-async-errors'; import http from 'node:http'; import express from 'express'; import cors from 'cors'; @@ -15,6 +16,12 @@ 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 inviteRoutes from './routes/invites.js'; +import notificationRoutes from './routes/notifications.js'; +import chatRoutes from './routes/chat.js'; +import teamRoutes from './routes/teams.js'; +import groupRoutes from './routes/groups.js'; +import uploadRoutes from './routes/upload.js'; import { initSocket } from './socket.js'; const app = express(); @@ -51,6 +58,13 @@ app.use('/api/books', bookRoutes); app.use('/api/tasks', taskRoutes); app.use('/api/time', timeRoutes); app.use('/api/admin', adminRoutes); +app.use('/api/invites', inviteRoutes); +app.use('/api/notifications', notificationRoutes); +app.use('/api/chat', chatRoutes); +app.use('/api/teams', teamRoutes); +app.use('/api/groups', groupRoutes); +app.use('/api/upload', uploadRoutes); +app.use('/uploads', express.static('uploads')); app.get('/api/health', async (_req, res) => { try { @@ -62,7 +76,8 @@ app.get('/api/health', async (_req, res) => { }); app.use((err, _req, res, _next) => { - console.error('[unhandled]', err); + console.error('[unhandled]', err?.message || err); + if (err?.code === '22P02') return res.status(400).json({ error: 'Invalid ID format' }); res.status(500).json({ error: 'Internal server error' }); }); diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index a3abb4f..d644d5b 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -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 ?? {}; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index d551fc9..e778d0f 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -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]) }); }); diff --git a/backend/src/routes/chat.js b/backend/src/routes/chat.js new file mode 100644 index 0000000..76bd6ce --- /dev/null +++ b/backend/src/routes/chat.js @@ -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; diff --git a/backend/src/routes/groups.js b/backend/src/routes/groups.js new file mode 100644 index 0000000..7607093 --- /dev/null +++ b/backend/src/routes/groups.js @@ -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; diff --git a/backend/src/routes/guards.js b/backend/src/routes/guards.js index e6c37bc..dad8c8e 100644 --- a/backend/src/routes/guards.js +++ b/backend/src/routes/guards.js @@ -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'); +} diff --git a/backend/src/routes/invites.js b/backend/src/routes/invites.js new file mode 100644 index 0000000..0424244 --- /dev/null +++ b/backend/src/routes/invites.js @@ -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; diff --git a/backend/src/routes/notifications.js b/backend/src/routes/notifications.js new file mode 100644 index 0000000..8ad2830 --- /dev/null +++ b/backend/src/routes/notifications.js @@ -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; diff --git a/backend/src/routes/tasks.js b/backend/src/routes/tasks.js index 2fc58eb..20ea8ff 100644 --- a/backend/src/routes/tasks.js +++ b/backend/src/routes/tasks.js @@ -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); diff --git a/backend/src/routes/teams.js b/backend/src/routes/teams.js new file mode 100644 index 0000000..0382291 --- /dev/null +++ b/backend/src/routes/teams.js @@ -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; diff --git a/backend/src/routes/upload.js b/backend/src/routes/upload.js new file mode 100644 index 0000000..9885dd2 --- /dev/null +++ b/backend/src/routes/upload.js @@ -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; diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 326a57f..4731ca5 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -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) { diff --git a/backend/src/routes/workspaces.js b/backend/src/routes/workspaces.js index 863df41..dd68626 100644 --- a/backend/src/routes/workspaces.js +++ b/backend/src/routes/workspaces.js @@ -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+) diff --git a/backend/src/socket.js b/backend/src/socket.js index f1d0ba3..ee01808 100644 --- a/backend/src/socket.js +++ b/backend/src/socket.js @@ -38,6 +38,22 @@ export function initSocket(httpServer, corsOrigin) { io.on('connection', (socket) => { const user = socket.data.user; + // Personal room for notifications (bell). + socket.join(`user:${user.id}`); + + socket.on('chat:join', async ({ workspaceId }) => { + try { + if (!workspaceId) return; + const { rows } = await query( + 'SELECT 1 FROM workspace_members WHERE workspace_id = $1 AND user_id = $2', + [workspaceId, user.id], + ); + if (!rows[0]) return socket.emit('error:message', { error: 'Forbidden' }); + socket.join(`chat:${workspaceId}`); + } catch { + socket.emit('error:message', { error: 'Join failed' }); + } + }); socket.on('page:join', async ({ pageId }) => { try {