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

- Pending workspace invites with accept/decline + bell notifications;
  invite permission extendable via workspace.invite group right
- Notifications API (list/unread, mark read, read-all) + realtime push
  to per-user socket rooms; chat messages notify other members
- Workspace team chat API (list/post/delete) + chat:join socket rooms
- Image uploads (multer, 5MB image-only) served from /uploads
- Teams (managers/members), team-owned workspaces (TEAM badge), team
  task boards (team_id) and task assignees (workspace members only)
- Permission groups with teams.create/edit/invite + workspace.invite,
  default Team Creators seeding, hasPerm/canManageTeam guards
- Public registration disabled after install; admins create users
  (pending invites auto-link); users gain bg_glass setting
- Crash fix: express-async-errors + 400 on malformed IDs instead of
  unhandled rejections killing the server
This commit is contained in:
x3
2026-09-22 14:27:33 +02:00
parent fb396d313a
commit c3c77333db
18 changed files with 934 additions and 36 deletions
+1
View File
@@ -1,5 +1,6 @@
node_modules/ node_modules/
dist/ dist/
uploads/
.env .env
*.log *.log
.DS_Store .DS_Store
+163
View File
@@ -12,10 +12,12 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "express": "^4.19.2",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^7.2.0", "express-rate-limit": "^7.2.0",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.12.0", "pg": "^8.12.0",
"socket.io": "^4.7.5" "socket.io": "^4.7.5"
} }
@@ -66,6 +68,12 @@
"node": ">= 0.6" "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": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -135,6 +143,23 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause" "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": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -173,6 +198,21 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -209,6 +249,12 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT" "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": { "node_modules/cors": {
"version": "2.8.6", "version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -447,6 +493,15 @@
"url": "https://opencollective.com/express" "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": { "node_modules/express-rate-limit": {
"version": "7.5.1", "version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
@@ -636,6 +691,12 @@
"node": ">= 0.10" "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": { "node_modules/jsonwebtoken": {
"version": "9.0.3", "version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -796,6 +857,27 @@
"node": ">= 0.6" "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": { "node_modules/morgan": {
"version": "1.12.1", "version": "1.12.1",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.1.tgz", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.1.tgz",
@@ -822,6 +904,25 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "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": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1016,6 +1117,12 @@
"node": ">=0.10.0" "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": { "node_modules/proxy-addr": {
"version": "2.0.8", "version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@@ -1073,6 +1180,27 @@
"node": ">= 0.8" "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": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1362,6 +1490,29 @@
"node": ">= 0.8" "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": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1384,6 +1535,12 @@
"node": ">= 0.6" "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": { "node_modules/undici-types": {
"version": "8.9.0", "version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
@@ -1399,6 +1556,12 @@
"node": ">= 0.8" "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": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+2
View File
@@ -15,10 +15,12 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "express": "^4.19.2",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^7.2.0", "express-rate-limit": "^7.2.0",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.12.0", "pg": "^8.12.0",
"socket.io": "^4.7.5" "socket.io": "^4.7.5"
} }
+79
View File
@@ -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); 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 -- 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
+16 -1
View File
@@ -1,4 +1,5 @@
import 'dotenv/config'; import 'dotenv/config';
import 'express-async-errors';
import http from 'node:http'; import http from 'node:http';
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
@@ -15,6 +16,12 @@ import bookRoutes from './routes/books.js';
import taskRoutes from './routes/tasks.js'; import taskRoutes from './routes/tasks.js';
import timeRoutes from './routes/time.js'; import timeRoutes from './routes/time.js';
import adminRoutes from './routes/admin.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'; import { initSocket } from './socket.js';
const app = express(); const app = express();
@@ -51,6 +58,13 @@ app.use('/api/books', bookRoutes);
app.use('/api/tasks', taskRoutes); app.use('/api/tasks', taskRoutes);
app.use('/api/time', timeRoutes); app.use('/api/time', timeRoutes);
app.use('/api/admin', adminRoutes); 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) => { app.get('/api/health', async (_req, res) => {
try { try {
@@ -62,7 +76,8 @@ app.get('/api/health', async (_req, res) => {
}); });
app.use((err, _req, res, _next) => { 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' }); res.status(500).json({ error: 'Internal server error' });
}); });
+32
View File
@@ -34,6 +34,38 @@ router.get('/users', async (_req, res) => {
return res.json({ users: rows }); 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 } // PATCH /api/admin/users/:id { is_admin }
router.patch('/users/:id', async (req, res) => { router.patch('/users/:id', async (req, res) => {
const { is_admin } = req.body ?? {}; const { is_admin } = req.body ?? {};
+10 -3
View File
@@ -17,12 +17,19 @@ function publicUser(row) {
theme: row.theme || 'system', theme: row.theme || 'system',
bgImage: row.bg_image || '', bgImage: row.bg_image || '',
bgBlur: row.bg_blur !== false, bgBlur: row.bg_blur !== false,
bgGlass: row.bg_glass !== false,
createdAt: row.created_at, 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) => { 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 ?? {}; const { email = '', name = '', password = '' } = req.body ?? {};
if (!EMAIL_RE.test(String(email).toLowerCase())) { if (!EMAIL_RE.test(String(email).toLowerCase())) {
return res.status(400).json({ error: 'Valid email required' }); 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' }); 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, 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], [String(email).toLowerCase(), String(name).trim(), hash],
); );
const token = signToken(rows[0]); const token = signToken(rows[0]);
@@ -68,7 +75,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, 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' }); 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]) });
}); });
+72
View File
@@ -0,0 +1,72 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { getRole } from './guards.js';
const router = Router();
router.use(requireAuth);
// GET /api/chat/:workspaceId — last 100 messages
router.get('/:workspaceId', async (req, res) => {
const role = await getRole(req.params.workspaceId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
const { rows } = await query(
`SELECT m.*, u.name AS author_name, u.avatar AS author_avatar
FROM chat_messages m LEFT JOIN users u ON u.id = m.user_id
WHERE m.workspace_id = $1 ORDER BY m.created_at DESC LIMIT 100`,
[req.params.workspaceId],
);
return res.json({ messages: rows.reverse() });
});
// POST /api/chat/:workspaceId { body } — post + realtime broadcast + notify others
router.post('/:workspaceId', async (req, res) => {
const wsId = req.params.workspaceId;
const role = await getRole(wsId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
const { body = '' } = req.body ?? {};
if (String(body).trim().length < 1 || String(body).length > 2000) {
return res.status(400).json({ error: 'Body must be 1-2000 chars' });
}
const { rows } = await query(
'INSERT INTO chat_messages (workspace_id, user_id, body) VALUES ($1,$2,$3) RETURNING *',
[wsId, req.user.id, String(body).trim()],
);
const { rows: author } = await query('SELECT name, avatar FROM users WHERE id = $1', [req.user.id]);
const msg = { ...rows[0], author_name: author[0]?.name, author_avatar: author[0]?.avatar };
const io = req.app.get('io');
io?.to(`chat:${wsId}`).emit('chat:message', { message: msg });
// Notify all other members
const { rows: members } = await query(
'SELECT user_id FROM workspace_members WHERE workspace_id = $1 AND user_id <> $2',
[wsId, req.user.id],
);
const { rows: wsRows } = await query('SELECT name FROM workspaces WHERE id = $1', [wsId]);
for (const m of members) {
await query(
`INSERT INTO notifications (user_id, type, title, body, link)
VALUES ($1,'chat',$2,$3,$4)`,
[m.user_id, `💬 ${author[0]?.name || 'Someone'} in ${wsRows[0]?.name || ''}`,
String(body).trim().slice(0, 140), '/chat'],
);
io?.to(`user:${m.user_id}`).emit('notification:new', { type: 'chat' });
}
return res.status(201).json({ message: msg });
});
// DELETE /api/chat/message/:id (own or admin+)
router.delete('/message/:id', async (req, res) => {
const { rows } = await query('SELECT * FROM chat_messages WHERE id = $1', [req.params.id]);
const msg = rows[0];
if (!msg) return res.status(404).json({ error: 'Not found' });
const role = await getRole(msg.workspace_id, req.user.id);
const { rows: me } = await query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]);
if (msg.user_id !== req.user.id && !me[0]?.is_admin && role !== 'owner' && role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
await query('DELETE FROM chat_messages WHERE id = $1', [msg.id]);
req.app.get('io')?.to(`chat:${msg.workspace_id}`).emit('chat:deleted', { id: msg.id });
return res.status(204).end();
});
export default router;
+133
View File
@@ -0,0 +1,133 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { getRole, ensureDefaultGroups, PERMS } from './guards.js';
const router = Router();
router.use(requireAuth);
async function isWsAdmin(workspaceId, userId) {
const role = await getRole(workspaceId, userId);
return role === 'owner' || role === 'admin';
}
// GET /api/groups/by-workspace/:workspaceId (seeds defaults on first call)
router.get('/by-workspace/:workspaceId', async (req, res) => {
const role = await getRole(req.params.workspaceId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
await ensureDefaultGroups(req.params.workspaceId);
const { rows } = await query(
`SELECT g.*, (SELECT COUNT(*)::int FROM group_members m WHERE m.group_id = g.id) AS members
FROM groups g WHERE g.workspace_id = $1 ORDER BY g.created_at`,
[req.params.workspaceId],
);
return res.json({ groups: rows, availablePermissions: PERMS });
});
// POST /api/groups { workspace_id, name, permissions[] } (workspace admin+)
router.post('/', async (req, res) => {
const { workspace_id, name = '', permissions = [] } = req.body ?? {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
if (!(await isWsAdmin(workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Workspace admin only' });
}
if (String(name).trim().length < 1 || String(name).length > 120) {
return res.status(400).json({ error: 'Name must be 1-120 chars' });
}
const perms = [...new Set((Array.isArray(permissions) ? permissions : []).filter((p) => PERMS.includes(p)))];
try {
const { rows } = await query(
'INSERT INTO groups (workspace_id, name, permissions) VALUES ($1,$2,$3) RETURNING *',
[workspace_id, String(name).trim(), perms],
);
return res.status(201).json({ group: rows[0] });
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'Group name taken' });
throw err;
}
});
// PATCH /api/groups/:id { name?, permissions[] } (workspace admin+)
router.patch('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
const group = found[0];
if (!group) return res.status(404).json({ error: 'Not found' });
if (!(await isWsAdmin(group.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Workspace admin only' });
}
const { name, permissions } = req.body ?? {};
const perms = permissions !== undefined
? [...new Set((Array.isArray(permissions) ? permissions : []).filter((p) => PERMS.includes(p)))]
: null;
const { rows } = await query(
`UPDATE groups SET name = COALESCE($1, name),
permissions = COALESCE($2, permissions) WHERE id = $3 RETURNING *`,
[
name !== undefined ? String(name).trim().slice(0, 120) : null,
perms,
group.id,
],
);
return res.json({ group: rows[0] });
});
// DELETE /api/groups/:id (workspace admin+)
router.delete('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
const group = found[0];
if (!group) return res.status(404).json({ error: 'Not found' });
if (!(await isWsAdmin(group.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Workspace admin only' });
}
await query('DELETE FROM groups WHERE id = $1', [group.id]);
return res.status(204).end();
});
// GET /api/groups/:id/members
router.get('/:id/members', async (req, res) => {
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
const group = found[0];
if (!group) return res.status(404).json({ error: 'Not found' });
if (!(await getRole(group.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Not a workspace member' });
}
const { rows } = await query(
`SELECT u.id, u.name, u.email, u.avatar FROM group_members m
JOIN users u ON u.id = m.user_id WHERE m.group_id = $1`,
[group.id],
);
return res.json({ members: rows });
});
// POST /api/groups/:id/members { user_id } (workspace admin+)
router.post('/:id/members', async (req, res) => {
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
const group = found[0];
if (!group) return res.status(404).json({ error: 'Not found' });
if (!(await isWsAdmin(group.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Workspace admin only' });
}
const { user_id } = req.body ?? {};
if (!(await getRole(group.workspace_id, user_id))) {
return res.status(400).json({ error: 'User must be a workspace member first' });
}
await query(
'INSERT INTO group_members (group_id, user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
[group.id, user_id],
);
return res.status(201).json({ ok: true });
});
// DELETE /api/groups/:id/members/:userId (workspace admin+)
router.delete('/:id/members/:userId', async (req, res) => {
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
const group = found[0];
if (!group) return res.status(404).json({ error: 'Not found' });
if (!(await isWsAdmin(group.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Workspace admin only' });
}
await query('DELETE FROM group_members WHERE group_id = $1 AND user_id = $2', [group.id, req.params.userId]);
return res.status(204).end();
});
export default router;
+38 -2
View File
@@ -13,8 +13,7 @@ export async function getRole(workspaceId, userId) {
} }
/** Require membership; optionally require minimum role. Attaches req.membership. */ /** Require membership; optionally require minimum role. Attaches req.membership. */
export function requireWorkspace(minRole = 'viewer') { export function requireWorkspace(minRole = 'viewer') { return async (req, res, next) => {
return async (req, res, next) => {
const workspaceId = req.params.workspaceId || req.params.id || req.body?.workspace_id; const workspaceId = req.params.workspaceId || req.params.id || req.body?.workspace_id;
if (!workspaceId) return res.status(400).json({ error: 'workspaceId required' }); if (!workspaceId) return res.status(400).json({ error: 'workspaceId required' });
const role = await getRole(workspaceId, req.user.id); 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' }); if (!rows[0]?.is_admin) return res.status(403).json({ error: 'Admin only' });
return next(); return next();
} }
export const PERMS = ['teams.create', 'teams.edit', 'teams.invite', 'workspace.invite'];
/** True if user holds a workspace permission via groups (owner/admin always pass). */
export async function hasPerm(workspaceId, userId, perm) {
const role = await getRole(workspaceId, userId);
if (role === 'owner' || role === 'admin') return true;
if (!role) return false;
const { rows } = await query(
`SELECT 1 FROM group_members gm JOIN groups g ON g.id = gm.group_id
WHERE g.workspace_id = $1 AND gm.user_id = $2 AND $3 = ANY(g.permissions) LIMIT 1`,
[workspaceId, userId, perm],
);
return !!rows[0];
}
/** Seed default permission groups for a workspace (idempotent). */
export async function ensureDefaultGroups(workspaceId) {
await query(
`INSERT INTO groups (workspace_id, name, permissions)
VALUES ($1, 'Team Creators', $2)
ON CONFLICT (workspace_id, name) DO NOTHING`,
[workspaceId, ['teams.create', 'teams.edit', 'teams.invite']],
);
}
/** True if user manages the team (manager, workspace owner/admin, or teams.edit perm). */
export async function canManageTeam(team, userId) {
const role = await getRole(team.workspace_id, userId);
if (role === 'owner' || role === 'admin') return true;
const { rows } = await query(
'SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2 AND role = $3',
[team.id, userId, 'manager'],
);
if (rows[0]) return true;
return hasPerm(team.workspace_id, userId, 'teams.edit');
}
+54
View File
@@ -0,0 +1,54 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
router.use(requireAuth);
// GET /api/invites/mine — my pending workspace invites
router.get('/mine', async (req, res) => {
const { rows } = await query(
`SELECT i.*, w.name AS workspace_name, u.name AS inviter_name
FROM workspace_invites i
JOIN workspaces w ON w.id = i.workspace_id
LEFT JOIN users u ON u.id = i.invited_by
WHERE (i.user_id = $1 OR i.email = $2) AND i.status = 'pending'
ORDER BY i.created_at DESC`,
[req.user.id, req.user.email],
);
return res.json({ invites: rows });
});
// POST /api/invites/:id/accept
router.post('/:id/accept', async (req, res) => {
const { rows } = await query('SELECT * FROM workspace_invites WHERE id = $1', [req.params.id]);
const inv = rows[0];
if (!inv) return res.status(404).json({ error: 'Invite not found' });
if (inv.status !== 'pending') return res.status(409).json({ error: `Invite ${inv.status}` });
if (inv.user_id !== req.user.id && inv.email !== req.user.email) {
return res.status(403).json({ error: 'Not your invite' });
}
await query(
`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ($1,$2,$3)
ON CONFLICT (workspace_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
[inv.workspace_id, req.user.id, inv.role],
);
await query(`UPDATE workspace_invites SET status = 'accepted', user_id = $1 WHERE id = $2`, [
req.user.id, inv.id,
]);
return res.json({ ok: true, workspace_id: inv.workspace_id });
});
// POST /api/invites/:id/decline
router.post('/:id/decline', async (req, res) => {
const { rows } = await query('SELECT * FROM workspace_invites WHERE id = $1', [req.params.id]);
const inv = rows[0];
if (!inv) return res.status(404).json({ error: 'Invite not found' });
if (inv.user_id !== req.user.id && inv.email !== req.user.email) {
return res.status(403).json({ error: 'Not your invite' });
}
await query(`UPDATE workspace_invites SET status = 'declined' WHERE id = $1`, [inv.id]);
return res.json({ ok: true });
});
export default router;
+38
View File
@@ -0,0 +1,38 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
router.use(requireAuth);
// GET /api/notifications/mine?unread=1
router.get('/mine', async (req, res) => {
const params = [req.user.id];
let cond = 'user_id = $1';
if (req.query.unread === '1') cond += ' AND is_read = FALSE';
const { rows } = await query(
`SELECT * FROM notifications WHERE ${cond} ORDER BY created_at DESC LIMIT 50`,
params,
);
const { rows: counts } = await query(
'SELECT COUNT(*)::int AS c FROM notifications WHERE user_id = $1 AND is_read = FALSE',
[req.user.id],
);
return res.json({ notifications: rows, unread: counts[0]?.c ?? 0 });
});
// PATCH /api/notifications/:id/read
router.patch('/:id/read', async (req, res) => {
await query('UPDATE notifications SET is_read = TRUE WHERE id = $1 AND user_id = $2', [
req.params.id, req.user.id,
]);
return res.json({ ok: true });
});
// POST /api/notifications/read-all
router.post('/read-all', async (req, res) => {
await query('UPDATE notifications SET is_read = TRUE WHERE user_id = $1', [req.user.id]);
return res.json({ ok: true });
});
export default router;
+37 -13
View File
@@ -13,7 +13,7 @@ async function canWrite(userId, workspaceId) {
return role === 'owner' || role === 'admin' || role === 'member'; 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) { if (book_id) {
const { rows } = await query('SELECT id FROM books WHERE id = $1 AND workspace_id = $2', [book_id, workspaceId]); 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 (!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]); 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 (!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; return null;
} }
// GET /api/tasks/by-workspace/:workspaceId?status=&from=&to= // GET /api/tasks/by-workspace/:workspaceId?status=&from=&to=&team_id=&assignee=
// from/to filter due_at (used by the calendar).
router.get('/by-workspace/:workspaceId', async (req, res) => { router.get('/by-workspace/:workspaceId', async (req, res) => {
const role = await getRole(req.params.workspaceId, req.user.id); const role = await getRole(req.params.workspaceId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' }); 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 conds = ['t.workspace_id = $1'];
const params = [req.params.workspaceId]; const params = [req.params.workspaceId];
if (status && STATUSES.includes(String(status))) { if (status && STATUSES.includes(String(status))) {
@@ -45,18 +55,28 @@ router.get('/by-workspace/:workspaceId', async (req, res) => {
params.push(new Date(String(to))); params.push(new Date(String(to)));
conds.push(`t.due_at <= $${params.length}`); 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( 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 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`, WHERE ${conds.join(' AND ')} ORDER BY t.due_at NULLS LAST, t.position, t.created_at`,
params, params,
); );
return res.json({ tasks: rows }); 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) => { 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 (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
if (!(await canWrite(req.user.id, workspace_id))) { if (!(await canWrite(req.user.id, workspace_id))) {
return res.status(403).json({ error: 'Viewers cannot create tasks' }); 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 (!STATUSES.includes(status)) return res.status(400).json({ error: 'Invalid status' });
if (!PRIORITIES.includes(priority)) return res.status(400).json({ error: 'Invalid priority' }); 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 }); if (linkErr) return res.status(400).json({ error: linkErr });
const { rows } = await query( const { rows } = await query(
`INSERT INTO tasks (workspace_id, book_id, page_id, title, status, priority, due_at, created_by) `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) RETURNING *`, VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,
[workspace_id, book_id, page_id, String(title).trim(), status, priority, due_at ? new Date(due_at) : null, req.user.id], [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] }); 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))) { if (!(await canWrite(req.user.id, task.workspace_id))) {
return res.status(403).json({ error: 'Viewers cannot edit' }); 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 (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 (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)) { 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 nextBook = book_id !== undefined ? book_id : task.book_id;
const nextPage = page_id !== undefined ? page_id : task.page_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 }); if (linkErr) return res.status(400).json({ error: linkErr });
const sets = []; const sets = [];
const params = []; 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 (due_at !== undefined) push('due_at', due_at ? new Date(due_at) : null);
if (book_id !== undefined) push('book_id', book_id); if (book_id !== undefined) push('book_id', book_id);
if (page_id !== undefined) push('page_id', page_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 (position !== undefined) push('position', Number(position));
if (sets.length === 0) return res.json({ task }); if (sets.length === 0) return res.json({ task });
params.push(task.id); params.push(task.id);
+153
View File
@@ -0,0 +1,153 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { getRole, hasPerm, canManageTeam, ensureDefaultGroups } from './guards.js';
const router = Router();
router.use(requireAuth);
// GET /api/teams/by-workspace/:workspaceId (with member counts)
router.get('/by-workspace/:workspaceId', async (req, res) => {
const role = await getRole(req.params.workspaceId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
const { rows } = await query(
`SELECT t.*, (SELECT COUNT(*)::int FROM team_members m WHERE m.team_id = t.id) AS members,
EXISTS(SELECT 1 FROM team_members m WHERE m.team_id = t.id AND m.user_id = $2) AS joined
FROM teams t WHERE t.workspace_id = $1 ORDER BY t.created_at`,
[req.params.workspaceId, req.user.id],
);
return res.json({ teams: rows });
});
// POST /api/teams { workspace_id, name, icon? } (teams.create perm; creator becomes manager)
router.post('/', async (req, res) => {
const { workspace_id, name = '', icon = '👥' } = req.body ?? {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
if (!(await hasPerm(workspace_id, req.user.id, 'teams.create'))) {
return res.status(403).json({ error: 'No permission to create teams' });
}
if (String(name).trim().length < 1 || String(name).length > 120) {
return res.status(400).json({ error: 'Name must be 1-120 chars' });
}
const { rows } = await query(
'INSERT INTO teams (workspace_id, name, icon, created_by) VALUES ($1,$2,$3,$4) RETURNING *',
[workspace_id, String(name).trim(), String(icon).slice(0, 20) || '👥', req.user.id],
);
await query(
`INSERT INTO team_members (team_id, user_id, role) VALUES ($1,$2,'manager')
ON CONFLICT DO NOTHING`,
[rows[0].id, req.user.id],
);
return res.status(201).json({ team: { ...rows[0], members: 1 } });
});
// PATCH /api/teams/:id { name?, icon? } (manager or teams.edit)
router.patch('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
if (!(await canManageTeam(team, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
const { name, icon } = req.body ?? {};
const { rows } = await query(
'UPDATE teams SET name = COALESCE($1, name), icon = COALESCE($2, icon) WHERE id = $3 RETURNING *',
[
name !== undefined ? String(name).trim().slice(0, 120) : null,
icon !== undefined ? String(icon).slice(0, 20) : null,
team.id,
],
);
return res.json({ team: rows[0] });
});
// DELETE /api/teams/:id (manager or teams.edit; linked workspaces keep running, badge cleared)
router.delete('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
if (!(await canManageTeam(team, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
await query('DELETE FROM teams WHERE id = $1', [team.id]);
return res.status(204).end();
});
// GET /api/teams/:id/members
router.get('/:id/members', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
if (!(await getRole(team.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Not a workspace member' });
}
const { rows } = await query(
`SELECT u.id, u.name, u.email, u.avatar, m.role FROM team_members m
JOIN users u ON u.id = m.user_id WHERE m.team_id = $1 ORDER BY m.created_at`,
[team.id],
);
return res.json({ members: rows });
});
// POST /api/teams/:id/members { user_id, role? } (manager or teams.invite; user must be workspace member)
router.post('/:id/members', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
const manager = await canManageTeam(team, req.user.id);
const mayInvite = manager || (await hasPerm(team.workspace_id, req.user.id, 'teams.invite'));
if (!mayInvite) return res.status(403).json({ error: 'No permission to invite to teams' });
const { user_id, role = 'member' } = req.body ?? {};
if (!['manager', 'member'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
if (!(await getRole(team.workspace_id, user_id))) {
return res.status(400).json({ error: 'User must be a workspace member first' });
}
await query(
`INSERT INTO team_members (team_id, user_id, role) VALUES ($1,$2,$3)
ON CONFLICT (team_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
[team.id, user_id, role],
);
return res.status(201).json({ ok: true });
});
// DELETE /api/teams/:id/members/:userId (manager or teams.invite)
router.delete('/:id/members/:userId', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
const manager = await canManageTeam(team, req.user.id);
const mayInvite = manager || (await hasPerm(team.workspace_id, req.user.id, 'teams.invite'));
if (!mayInvite) return res.status(403).json({ error: 'Forbidden' });
await query('DELETE FROM team_members WHERE team_id = $1 AND user_id = $2', [team.id, req.params.userId]);
return res.status(204).end();
});
// POST /api/teams/:id/workspace { name } — team creates its own workspace.
// The UI shows a TEAM_"name" badge behind the workspace name.
router.post('/:id/workspace', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
if (!(await canManageTeam(team, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
const { name = '' } = req.body ?? {};
if (String(name).trim().length < 1 || String(name).length > 120) {
return res.status(400).json({ error: 'Name must be 1-120 chars' });
}
const { rows } = await query(
'INSERT INTO workspaces (name, owner_id, team_id) VALUES ($1,$2,$3) RETURNING *',
[String(name).trim(), req.user.id, team.id],
);
await ensureDefaultGroups(rows[0].id);
// Add all team members to the new workspace (as members)
const { rows: tm } = await query('SELECT user_id FROM team_members WHERE team_id = $1', [team.id]);
for (const m of tm) {
await query(
`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ($1,$2,'member')
ON CONFLICT DO NOTHING`,
[rows[0].id, m.user_id],
);
}
await query(
`UPDATE workspace_members SET role = 'owner' WHERE workspace_id = $1 AND user_id = $2`,
[rows[0].id, req.user.id],
);
return res.status(201).json({ workspace: { ...rows[0], team_name: team.name } });
});
export default router;
+39
View File
@@ -0,0 +1,39 @@
import { Router } from 'express';
import multer from 'multer';
import path from 'node:path';
import crypto from 'node:crypto';
import fs from 'node:fs';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
const uploadDir = path.join(process.cwd(), 'uploads');
fs.mkdirSync(uploadDir, { recursive: true });
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadDir),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname || '').toLowerCase().slice(0, 8);
cb(null, `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Only images allowed'));
},
});
// POST /api/upload — avatar / background image upload (returns relative URL)
router.post('/', requireAuth, (req, res) => {
upload.single('file')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message || 'Upload failed' });
if (!req.file) return res.status(400).json({ error: 'No file' });
return res.status(201).json({ url: `/uploads/${req.file.filename}` });
});
});
export default router;
+8 -5
View File
@@ -36,7 +36,7 @@ router.get('/discover', async (req, res) => {
// PATCH /api/users/me — profile + appearance settings // PATCH /api/users/me — profile + appearance settings
router.patch('/me', async (req, res) => { 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)) { if (name !== undefined && (String(name).trim().length < 1 || String(name).length > 100)) {
return res.status(400).json({ error: 'Name must be 1-100 chars' }); 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), locale = COALESCE($4, locale),
theme = COALESCE($5, theme), theme = COALESCE($5, theme),
bg_image = COALESCE($6, bg_image), bg_image = COALESCE($6, bg_image),
bg_blur = COALESCE($7, bg_blur) bg_blur = COALESCE($7, bg_blur),
WHERE id = $8 bg_glass = COALESCE($8, bg_glass)
RETURNING id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, created_at`, 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, name !== undefined ? String(name).trim() : null,
email !== undefined ? String(email).toLowerCase() : null, email !== undefined ? String(email).toLowerCase() : null,
@@ -75,6 +76,7 @@ router.patch('/me', async (req, res) => {
theme !== undefined ? theme : null, theme !== undefined ? theme : null,
bg_image !== undefined ? String(bg_image).slice(0, 2000) : null, bg_image !== undefined ? String(bg_image).slice(0, 2000) : null,
bg_blur !== undefined ? Boolean(bg_blur) : null, bg_blur !== undefined ? Boolean(bg_blur) : null,
bg_glass !== undefined ? Boolean(bg_glass) : null,
req.user.id, req.user.id,
], ],
); );
@@ -83,7 +85,8 @@ router.patch('/me', async (req, res) => {
user: { user: {
id: u.id, email: u.email, name: u.name, isAdmin: !!u.is_admin, id: u.id, email: u.email, name: u.name, isAdmin: !!u.is_admin,
avatar: u.avatar || '', locale: u.locale, theme: u.theme, 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) { } catch (err) {
+43 -12
View File
@@ -1,16 +1,18 @@
import { Router } from 'express'; import { Router } from 'express';
import { query } from '../config/db.js'; import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.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(); const router = Router();
router.use(requireAuth); router.use(requireAuth);
// GET /api/workspaces — list my workspaces // GET /api/workspaces — list my workspaces (with team badge info)
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
const { rows } = await query( 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 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`, WHERE m.user_id = $1 ORDER BY w.updated_at DESC`,
[req.user.id], [req.user.id],
); );
@@ -27,6 +29,7 @@ router.post('/', async (req, res) => {
'INSERT INTO workspaces (name, owner_id) VALUES ($1,$2) RETURNING *', 'INSERT INTO workspaces (name, owner_id) VALUES ($1,$2) RETURNING *',
[String(name).trim(), req.user.id], [String(name).trim(), req.user.id],
); );
await ensureDefaultGroups(rows[0].id);
return res.status(201).json({ workspace: rows[0] }); 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 }); return res.json({ members: rows });
}); });
// POST /api/workspaces/:id/members { email, role } (admin+; cannot grant owner) // POST /api/workspaces/:id/members { email, role } — creates a pending invite
router.post('/:id/members', requireWorkspace('admin'), async (req, res) => { // + 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 ?? {}; const { email = '', role = 'member' } = req.body ?? {};
if (!ROLES.includes(role) || role === 'owner') { if (!ROLES.includes(role) || role === 'owner') {
return res.status(400).json({ error: 'Role must be admin, member or viewer' }); 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()]); const targetEmail = String(email).toLowerCase();
if (!users[0]) return res.status(404).json({ error: 'No user with that email (they must register first)' }); if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(targetEmail)) {
await query( return res.status(400).json({ error: 'Valid email required' });
`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`, const { rows: users } = await query('SELECT id, name FROM users WHERE email = $1', [targetEmail]);
[req.params.id, users[0].id, role], 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],
); );
return res.status(201).json({ ok: true }); 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],
);
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+) // PATCH /api/workspaces/:id/members/:userId { role } (admin+)
+16
View File
@@ -38,6 +38,22 @@ export function initSocket(httpServer, corsOrigin) {
io.on('connection', (socket) => { io.on('connection', (socket) => {
const user = socket.data.user; 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 }) => { socket.on('page:join', async ({ pageId }) => {
try { try {