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:
@@ -1,5 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
uploads/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
Generated
+163
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 ?? {};
|
||||||
|
|||||||
@@ -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]) });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { query } from '../config/db.js';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { getRole } from './guards.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
// GET /api/chat/:workspaceId — last 100 messages
|
||||||
|
router.get('/:workspaceId', async (req, res) => {
|
||||||
|
const role = await getRole(req.params.workspaceId, req.user.id);
|
||||||
|
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
|
||||||
|
const { rows } = await query(
|
||||||
|
`SELECT m.*, u.name AS author_name, u.avatar AS author_avatar
|
||||||
|
FROM chat_messages m LEFT JOIN users u ON u.id = m.user_id
|
||||||
|
WHERE m.workspace_id = $1 ORDER BY m.created_at DESC LIMIT 100`,
|
||||||
|
[req.params.workspaceId],
|
||||||
|
);
|
||||||
|
return res.json({ messages: rows.reverse() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/chat/:workspaceId { body } — post + realtime broadcast + notify others
|
||||||
|
router.post('/:workspaceId', async (req, res) => {
|
||||||
|
const wsId = req.params.workspaceId;
|
||||||
|
const role = await getRole(wsId, req.user.id);
|
||||||
|
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
|
||||||
|
const { body = '' } = req.body ?? {};
|
||||||
|
if (String(body).trim().length < 1 || String(body).length > 2000) {
|
||||||
|
return res.status(400).json({ error: 'Body must be 1-2000 chars' });
|
||||||
|
}
|
||||||
|
const { rows } = await query(
|
||||||
|
'INSERT INTO chat_messages (workspace_id, user_id, body) VALUES ($1,$2,$3) RETURNING *',
|
||||||
|
[wsId, req.user.id, String(body).trim()],
|
||||||
|
);
|
||||||
|
const { rows: author } = await query('SELECT name, avatar FROM users WHERE id = $1', [req.user.id]);
|
||||||
|
const msg = { ...rows[0], author_name: author[0]?.name, author_avatar: author[0]?.avatar };
|
||||||
|
const io = req.app.get('io');
|
||||||
|
io?.to(`chat:${wsId}`).emit('chat:message', { message: msg });
|
||||||
|
// Notify all other members
|
||||||
|
const { rows: members } = await query(
|
||||||
|
'SELECT user_id FROM workspace_members WHERE workspace_id = $1 AND user_id <> $2',
|
||||||
|
[wsId, req.user.id],
|
||||||
|
);
|
||||||
|
const { rows: wsRows } = await query('SELECT name FROM workspaces WHERE id = $1', [wsId]);
|
||||||
|
for (const m of members) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO notifications (user_id, type, title, body, link)
|
||||||
|
VALUES ($1,'chat',$2,$3,$4)`,
|
||||||
|
[m.user_id, `💬 ${author[0]?.name || 'Someone'} in ${wsRows[0]?.name || ''}`,
|
||||||
|
String(body).trim().slice(0, 140), '/chat'],
|
||||||
|
);
|
||||||
|
io?.to(`user:${m.user_id}`).emit('notification:new', { type: 'chat' });
|
||||||
|
}
|
||||||
|
return res.status(201).json({ message: msg });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/chat/message/:id (own or admin+)
|
||||||
|
router.delete('/message/:id', async (req, res) => {
|
||||||
|
const { rows } = await query('SELECT * FROM chat_messages WHERE id = $1', [req.params.id]);
|
||||||
|
const msg = rows[0];
|
||||||
|
if (!msg) return res.status(404).json({ error: 'Not found' });
|
||||||
|
const role = await getRole(msg.workspace_id, req.user.id);
|
||||||
|
const { rows: me } = await query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]);
|
||||||
|
if (msg.user_id !== req.user.id && !me[0]?.is_admin && role !== 'owner' && role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
await query('DELETE FROM chat_messages WHERE id = $1', [msg.id]);
|
||||||
|
req.app.get('io')?.to(`chat:${msg.workspace_id}`).emit('chat:deleted', { id: msg.id });
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { query } from '../config/db.js';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { getRole, ensureDefaultGroups, PERMS } from './guards.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
async function isWsAdmin(workspaceId, userId) {
|
||||||
|
const role = await getRole(workspaceId, userId);
|
||||||
|
return role === 'owner' || role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/groups/by-workspace/:workspaceId (seeds defaults on first call)
|
||||||
|
router.get('/by-workspace/:workspaceId', async (req, res) => {
|
||||||
|
const role = await getRole(req.params.workspaceId, req.user.id);
|
||||||
|
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
|
||||||
|
await ensureDefaultGroups(req.params.workspaceId);
|
||||||
|
const { rows } = await query(
|
||||||
|
`SELECT g.*, (SELECT COUNT(*)::int FROM group_members m WHERE m.group_id = g.id) AS members
|
||||||
|
FROM groups g WHERE g.workspace_id = $1 ORDER BY g.created_at`,
|
||||||
|
[req.params.workspaceId],
|
||||||
|
);
|
||||||
|
return res.json({ groups: rows, availablePermissions: PERMS });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/groups { workspace_id, name, permissions[] } (workspace admin+)
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const { workspace_id, name = '', permissions = [] } = req.body ?? {};
|
||||||
|
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
|
||||||
|
if (!(await isWsAdmin(workspace_id, req.user.id))) {
|
||||||
|
return res.status(403).json({ error: 'Workspace admin only' });
|
||||||
|
}
|
||||||
|
if (String(name).trim().length < 1 || String(name).length > 120) {
|
||||||
|
return res.status(400).json({ error: 'Name must be 1-120 chars' });
|
||||||
|
}
|
||||||
|
const perms = [...new Set((Array.isArray(permissions) ? permissions : []).filter((p) => PERMS.includes(p)))];
|
||||||
|
try {
|
||||||
|
const { rows } = await query(
|
||||||
|
'INSERT INTO groups (workspace_id, name, permissions) VALUES ($1,$2,$3) RETURNING *',
|
||||||
|
[workspace_id, String(name).trim(), perms],
|
||||||
|
);
|
||||||
|
return res.status(201).json({ group: rows[0] });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === '23505') return res.status(409).json({ error: 'Group name taken' });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/groups/:id { name?, permissions[] } (workspace admin+)
|
||||||
|
router.patch('/:id', async (req, res) => {
|
||||||
|
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
|
||||||
|
const group = found[0];
|
||||||
|
if (!group) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (!(await isWsAdmin(group.workspace_id, req.user.id))) {
|
||||||
|
return res.status(403).json({ error: 'Workspace admin only' });
|
||||||
|
}
|
||||||
|
const { name, permissions } = req.body ?? {};
|
||||||
|
const perms = permissions !== undefined
|
||||||
|
? [...new Set((Array.isArray(permissions) ? permissions : []).filter((p) => PERMS.includes(p)))]
|
||||||
|
: null;
|
||||||
|
const { rows } = await query(
|
||||||
|
`UPDATE groups SET name = COALESCE($1, name),
|
||||||
|
permissions = COALESCE($2, permissions) WHERE id = $3 RETURNING *`,
|
||||||
|
[
|
||||||
|
name !== undefined ? String(name).trim().slice(0, 120) : null,
|
||||||
|
perms,
|
||||||
|
group.id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return res.json({ group: rows[0] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/groups/:id (workspace admin+)
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
|
||||||
|
const group = found[0];
|
||||||
|
if (!group) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (!(await isWsAdmin(group.workspace_id, req.user.id))) {
|
||||||
|
return res.status(403).json({ error: 'Workspace admin only' });
|
||||||
|
}
|
||||||
|
await query('DELETE FROM groups WHERE id = $1', [group.id]);
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/groups/:id/members
|
||||||
|
router.get('/:id/members', async (req, res) => {
|
||||||
|
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
|
||||||
|
const group = found[0];
|
||||||
|
if (!group) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (!(await getRole(group.workspace_id, req.user.id))) {
|
||||||
|
return res.status(403).json({ error: 'Not a workspace member' });
|
||||||
|
}
|
||||||
|
const { rows } = await query(
|
||||||
|
`SELECT u.id, u.name, u.email, u.avatar FROM group_members m
|
||||||
|
JOIN users u ON u.id = m.user_id WHERE m.group_id = $1`,
|
||||||
|
[group.id],
|
||||||
|
);
|
||||||
|
return res.json({ members: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/groups/:id/members { user_id } (workspace admin+)
|
||||||
|
router.post('/:id/members', async (req, res) => {
|
||||||
|
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
|
||||||
|
const group = found[0];
|
||||||
|
if (!group) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (!(await isWsAdmin(group.workspace_id, req.user.id))) {
|
||||||
|
return res.status(403).json({ error: 'Workspace admin only' });
|
||||||
|
}
|
||||||
|
const { user_id } = req.body ?? {};
|
||||||
|
if (!(await getRole(group.workspace_id, user_id))) {
|
||||||
|
return res.status(400).json({ error: 'User must be a workspace member first' });
|
||||||
|
}
|
||||||
|
await query(
|
||||||
|
'INSERT INTO group_members (group_id, user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
||||||
|
[group.id, user_id],
|
||||||
|
);
|
||||||
|
return res.status(201).json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/groups/:id/members/:userId (workspace admin+)
|
||||||
|
router.delete('/:id/members/:userId', async (req, res) => {
|
||||||
|
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
|
||||||
|
const group = found[0];
|
||||||
|
if (!group) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (!(await isWsAdmin(group.workspace_id, req.user.id))) {
|
||||||
|
return res.status(403).json({ error: 'Workspace admin only' });
|
||||||
|
}
|
||||||
|
await query('DELETE FROM group_members WHERE group_id = $1 AND user_id = $2', [group.id, req.params.userId]);
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -13,8 +13,7 @@ export async function getRole(workspaceId, userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Require membership; optionally require minimum role. Attaches req.membership. */
|
/** 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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { query } from '../config/db.js';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
// GET /api/invites/mine — my pending workspace invites
|
||||||
|
router.get('/mine', async (req, res) => {
|
||||||
|
const { rows } = await query(
|
||||||
|
`SELECT i.*, w.name AS workspace_name, u.name AS inviter_name
|
||||||
|
FROM workspace_invites i
|
||||||
|
JOIN workspaces w ON w.id = i.workspace_id
|
||||||
|
LEFT JOIN users u ON u.id = i.invited_by
|
||||||
|
WHERE (i.user_id = $1 OR i.email = $2) AND i.status = 'pending'
|
||||||
|
ORDER BY i.created_at DESC`,
|
||||||
|
[req.user.id, req.user.email],
|
||||||
|
);
|
||||||
|
return res.json({ invites: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/invites/:id/accept
|
||||||
|
router.post('/:id/accept', async (req, res) => {
|
||||||
|
const { rows } = await query('SELECT * FROM workspace_invites WHERE id = $1', [req.params.id]);
|
||||||
|
const inv = rows[0];
|
||||||
|
if (!inv) return res.status(404).json({ error: 'Invite not found' });
|
||||||
|
if (inv.status !== 'pending') return res.status(409).json({ error: `Invite ${inv.status}` });
|
||||||
|
if (inv.user_id !== req.user.id && inv.email !== req.user.email) {
|
||||||
|
return res.status(403).json({ error: 'Not your invite' });
|
||||||
|
}
|
||||||
|
await query(
|
||||||
|
`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ($1,$2,$3)
|
||||||
|
ON CONFLICT (workspace_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
|
||||||
|
[inv.workspace_id, req.user.id, inv.role],
|
||||||
|
);
|
||||||
|
await query(`UPDATE workspace_invites SET status = 'accepted', user_id = $1 WHERE id = $2`, [
|
||||||
|
req.user.id, inv.id,
|
||||||
|
]);
|
||||||
|
return res.json({ ok: true, workspace_id: inv.workspace_id });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/invites/:id/decline
|
||||||
|
router.post('/:id/decline', async (req, res) => {
|
||||||
|
const { rows } = await query('SELECT * FROM workspace_invites WHERE id = $1', [req.params.id]);
|
||||||
|
const inv = rows[0];
|
||||||
|
if (!inv) return res.status(404).json({ error: 'Invite not found' });
|
||||||
|
if (inv.user_id !== req.user.id && inv.email !== req.user.email) {
|
||||||
|
return res.status(403).json({ error: 'Not your invite' });
|
||||||
|
}
|
||||||
|
await query(`UPDATE workspace_invites SET status = 'declined' WHERE id = $1`, [inv.id]);
|
||||||
|
return res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { query } from '../config/db.js';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
// GET /api/notifications/mine?unread=1
|
||||||
|
router.get('/mine', async (req, res) => {
|
||||||
|
const params = [req.user.id];
|
||||||
|
let cond = 'user_id = $1';
|
||||||
|
if (req.query.unread === '1') cond += ' AND is_read = FALSE';
|
||||||
|
const { rows } = await query(
|
||||||
|
`SELECT * FROM notifications WHERE ${cond} ORDER BY created_at DESC LIMIT 50`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
const { rows: counts } = await query(
|
||||||
|
'SELECT COUNT(*)::int AS c FROM notifications WHERE user_id = $1 AND is_read = FALSE',
|
||||||
|
[req.user.id],
|
||||||
|
);
|
||||||
|
return res.json({ notifications: rows, unread: counts[0]?.c ?? 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/notifications/:id/read
|
||||||
|
router.patch('/:id/read', async (req, res) => {
|
||||||
|
await query('UPDATE notifications SET is_read = TRUE WHERE id = $1 AND user_id = $2', [
|
||||||
|
req.params.id, req.user.id,
|
||||||
|
]);
|
||||||
|
return res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/notifications/read-all
|
||||||
|
router.post('/read-all', async (req, res) => {
|
||||||
|
await query('UPDATE notifications SET is_read = TRUE WHERE user_id = $1', [req.user.id]);
|
||||||
|
return res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
+37
-13
@@ -13,7 +13,7 @@ async function canWrite(userId, workspaceId) {
|
|||||||
return role === 'owner' || role === 'admin' || role === 'member';
|
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);
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { query } from '../config/db.js';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { getRole, hasPerm, canManageTeam, ensureDefaultGroups } from './guards.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
// GET /api/teams/by-workspace/:workspaceId (with member counts)
|
||||||
|
router.get('/by-workspace/:workspaceId', async (req, res) => {
|
||||||
|
const role = await getRole(req.params.workspaceId, req.user.id);
|
||||||
|
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
|
||||||
|
const { rows } = await query(
|
||||||
|
`SELECT t.*, (SELECT COUNT(*)::int FROM team_members m WHERE m.team_id = t.id) AS members,
|
||||||
|
EXISTS(SELECT 1 FROM team_members m WHERE m.team_id = t.id AND m.user_id = $2) AS joined
|
||||||
|
FROM teams t WHERE t.workspace_id = $1 ORDER BY t.created_at`,
|
||||||
|
[req.params.workspaceId, req.user.id],
|
||||||
|
);
|
||||||
|
return res.json({ teams: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/teams { workspace_id, name, icon? } (teams.create perm; creator becomes manager)
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const { workspace_id, name = '', icon = '👥' } = req.body ?? {};
|
||||||
|
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
|
||||||
|
if (!(await hasPerm(workspace_id, req.user.id, 'teams.create'))) {
|
||||||
|
return res.status(403).json({ error: 'No permission to create teams' });
|
||||||
|
}
|
||||||
|
if (String(name).trim().length < 1 || String(name).length > 120) {
|
||||||
|
return res.status(400).json({ error: 'Name must be 1-120 chars' });
|
||||||
|
}
|
||||||
|
const { rows } = await query(
|
||||||
|
'INSERT INTO teams (workspace_id, name, icon, created_by) VALUES ($1,$2,$3,$4) RETURNING *',
|
||||||
|
[workspace_id, String(name).trim(), String(icon).slice(0, 20) || '👥', req.user.id],
|
||||||
|
);
|
||||||
|
await query(
|
||||||
|
`INSERT INTO team_members (team_id, user_id, role) VALUES ($1,$2,'manager')
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
[rows[0].id, req.user.id],
|
||||||
|
);
|
||||||
|
return res.status(201).json({ team: { ...rows[0], members: 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/teams/:id { name?, icon? } (manager or teams.edit)
|
||||||
|
router.patch('/:id', async (req, res) => {
|
||||||
|
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
|
||||||
|
const team = found[0];
|
||||||
|
if (!team) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (!(await canManageTeam(team, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
const { name, icon } = req.body ?? {};
|
||||||
|
const { rows } = await query(
|
||||||
|
'UPDATE teams SET name = COALESCE($1, name), icon = COALESCE($2, icon) WHERE id = $3 RETURNING *',
|
||||||
|
[
|
||||||
|
name !== undefined ? String(name).trim().slice(0, 120) : null,
|
||||||
|
icon !== undefined ? String(icon).slice(0, 20) : null,
|
||||||
|
team.id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return res.json({ team: rows[0] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/teams/:id (manager or teams.edit; linked workspaces keep running, badge cleared)
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
|
||||||
|
const team = found[0];
|
||||||
|
if (!team) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (!(await canManageTeam(team, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
await query('DELETE FROM teams WHERE id = $1', [team.id]);
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/teams/:id/members
|
||||||
|
router.get('/:id/members', async (req, res) => {
|
||||||
|
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
|
||||||
|
const team = found[0];
|
||||||
|
if (!team) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (!(await getRole(team.workspace_id, req.user.id))) {
|
||||||
|
return res.status(403).json({ error: 'Not a workspace member' });
|
||||||
|
}
|
||||||
|
const { rows } = await query(
|
||||||
|
`SELECT u.id, u.name, u.email, u.avatar, m.role FROM team_members m
|
||||||
|
JOIN users u ON u.id = m.user_id WHERE m.team_id = $1 ORDER BY m.created_at`,
|
||||||
|
[team.id],
|
||||||
|
);
|
||||||
|
return res.json({ members: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/teams/:id/members { user_id, role? } (manager or teams.invite; user must be workspace member)
|
||||||
|
router.post('/:id/members', async (req, res) => {
|
||||||
|
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
|
||||||
|
const team = found[0];
|
||||||
|
if (!team) return res.status(404).json({ error: 'Not found' });
|
||||||
|
const manager = await canManageTeam(team, req.user.id);
|
||||||
|
const mayInvite = manager || (await hasPerm(team.workspace_id, req.user.id, 'teams.invite'));
|
||||||
|
if (!mayInvite) return res.status(403).json({ error: 'No permission to invite to teams' });
|
||||||
|
const { user_id, role = 'member' } = req.body ?? {};
|
||||||
|
if (!['manager', 'member'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
|
||||||
|
if (!(await getRole(team.workspace_id, user_id))) {
|
||||||
|
return res.status(400).json({ error: 'User must be a workspace member first' });
|
||||||
|
}
|
||||||
|
await query(
|
||||||
|
`INSERT INTO team_members (team_id, user_id, role) VALUES ($1,$2,$3)
|
||||||
|
ON CONFLICT (team_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
|
||||||
|
[team.id, user_id, role],
|
||||||
|
);
|
||||||
|
return res.status(201).json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/teams/:id/members/:userId (manager or teams.invite)
|
||||||
|
router.delete('/:id/members/:userId', async (req, res) => {
|
||||||
|
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
|
||||||
|
const team = found[0];
|
||||||
|
if (!team) return res.status(404).json({ error: 'Not found' });
|
||||||
|
const manager = await canManageTeam(team, req.user.id);
|
||||||
|
const mayInvite = manager || (await hasPerm(team.workspace_id, req.user.id, 'teams.invite'));
|
||||||
|
if (!mayInvite) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
await query('DELETE FROM team_members WHERE team_id = $1 AND user_id = $2', [team.id, req.params.userId]);
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/teams/:id/workspace { name } — team creates its own workspace.
|
||||||
|
// The UI shows a TEAM_"name" badge behind the workspace name.
|
||||||
|
router.post('/:id/workspace', async (req, res) => {
|
||||||
|
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
|
||||||
|
const team = found[0];
|
||||||
|
if (!team) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (!(await canManageTeam(team, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
const { name = '' } = req.body ?? {};
|
||||||
|
if (String(name).trim().length < 1 || String(name).length > 120) {
|
||||||
|
return res.status(400).json({ error: 'Name must be 1-120 chars' });
|
||||||
|
}
|
||||||
|
const { rows } = await query(
|
||||||
|
'INSERT INTO workspaces (name, owner_id, team_id) VALUES ($1,$2,$3) RETURNING *',
|
||||||
|
[String(name).trim(), req.user.id, team.id],
|
||||||
|
);
|
||||||
|
await ensureDefaultGroups(rows[0].id);
|
||||||
|
// Add all team members to the new workspace (as members)
|
||||||
|
const { rows: tm } = await query('SELECT user_id FROM team_members WHERE team_id = $1', [team.id]);
|
||||||
|
for (const m of tm) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ($1,$2,'member')
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
[rows[0].id, m.user_id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await query(
|
||||||
|
`UPDATE workspace_members SET role = 'owner' WHERE workspace_id = $1 AND user_id = $2`,
|
||||||
|
[rows[0].id, req.user.id],
|
||||||
|
);
|
||||||
|
return res.status(201).json({ workspace: { ...rows[0], team_name: team.name } });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const uploadDir = path.join(process.cwd(), 'uploads');
|
||||||
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (_req, _file, cb) => cb(null, uploadDir),
|
||||||
|
filename: (_req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname || '').toLowerCase().slice(0, 8);
|
||||||
|
cb(null, `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 },
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
if (file.mimetype.startsWith('image/')) cb(null, true);
|
||||||
|
else cb(new Error('Only images allowed'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/upload — avatar / background image upload (returns relative URL)
|
||||||
|
router.post('/', requireAuth, (req, res) => {
|
||||||
|
upload.single('file')(req, res, (err) => {
|
||||||
|
if (err) return res.status(400).json({ error: err.message || 'Upload failed' });
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'No file' });
|
||||||
|
return res.status(201).json({ url: `/uploads/${req.file.filename}` });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -36,7 +36,7 @@ router.get('/discover', async (req, res) => {
|
|||||||
|
|
||||||
// PATCH /api/users/me — profile + appearance settings
|
// 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) {
|
||||||
|
|||||||
@@ -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],
|
||||||
|
);
|
||||||
|
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+)
|
// PATCH /api/workspaces/:id/members/:userId { role } (admin+)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user