Initial commit: personal task planner with categories, projects, and tiered assignment

Personal-first lists/tasks/reminders/recurrence, plus an admin-editable status
pipeline, shared categories, project containers, and a three-tier permission
model for cross-user work: assign (hand off your own tasks), reallocate
(dispatcher — move anyone's tasks), admin (reserved, full manage-all).
This commit is contained in:
jtricerolph 2026-09-17 08:07:00 +00:00
commit d8dc4e2b81
54 changed files with 10908 additions and 0 deletions

8
backend/Dockerfile Normal file
View file

@ -0,0 +1,8 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json .
RUN npm install --omit=dev
COPY src ./src
RUN mkdir -p /app/uploads
EXPOSE 3001
CMD ["node", "src/index.js"]

902
backend/package-lock.json generated Normal file
View file

@ -0,0 +1,902 @@
{
"name": "hnf-tasks-backend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hnf-tasks-backend",
"version": "1.0.0",
"dependencies": {
"@fastify/cookie": "^9.4.0",
"@fastify/cors": "^9.0.1",
"fastify": "^4.28.1",
"jose": "^5.9.6",
"nodemailer": "^6.9.16",
"pg": "^8.13.1",
"web-push": "^3.6.7"
}
},
"node_modules/@fastify/ajv-compiler": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
"integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.11.0",
"ajv-formats": "^2.1.1",
"fast-uri": "^2.0.0"
}
},
"node_modules/@fastify/cookie": {
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-9.4.0.tgz",
"integrity": "sha512-Th+pt3kEkh4MQD/Q2q1bMuJIB5NX/D5SwSpOKu3G/tjoGbwfpurIMJsWSPS0SJJ4eyjtmQ8OipDQspf8RbUOlg==",
"license": "MIT",
"dependencies": {
"cookie-signature": "^1.1.0",
"fastify-plugin": "^4.0.0"
}
},
"node_modules/@fastify/cors": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-9.0.1.tgz",
"integrity": "sha512-YY9Ho3ovI+QHIL2hW+9X4XqQjXLjJqsU+sMV/xFsxZkE8p3GNnYVFpoOxF7SsP5ZL76gwvbo3V9L+FIekBGU4Q==",
"license": "MIT",
"dependencies": {
"fastify-plugin": "^4.0.0",
"mnemonist": "0.39.6"
}
},
"node_modules/@fastify/error": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz",
"integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==",
"license": "MIT"
},
"node_modules/@fastify/fast-json-stringify-compiler": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz",
"integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==",
"license": "MIT",
"dependencies": {
"fast-json-stringify": "^5.7.0"
}
},
"node_modules/@fastify/merge-json-schemas": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz",
"integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
"license": "MIT"
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ajv/node_modules/fast-uri": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/avvio": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz",
"integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==",
"license": "MIT",
"dependencies": {
"@fastify/error": "^3.3.0",
"fastq": "^1.17.1"
}
},
"node_modules/bn.js": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
"license": "MIT"
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/fast-content-type-parse": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz",
"integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==",
"license": "MIT"
},
"node_modules/fast-decode-uri-component": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
"integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-json-stringify": {
"version": "5.16.1",
"resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz",
"integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==",
"license": "MIT",
"dependencies": {
"@fastify/merge-json-schemas": "^0.1.0",
"ajv": "^8.10.0",
"ajv-formats": "^3.0.1",
"fast-deep-equal": "^3.1.3",
"fast-uri": "^2.1.0",
"json-schema-ref-resolver": "^1.0.1",
"rfdc": "^1.2.0"
}
},
"node_modules/fast-json-stringify/node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/fast-querystring": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
"integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
"license": "MIT",
"dependencies": {
"fast-decode-uri-component": "^1.0.1"
}
},
"node_modules/fast-uri": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.7.tgz",
"integrity": "sha512-b8mggfg2R+m2OAb3Z+2HcJxUgU+2cY1JQondBDyXLdrEKNPxd96MvnB5yWKZdupsuX6GdBaAZHZo9msHX/Bb7A==",
"license": "MIT"
},
"node_modules/fastify": {
"version": "4.29.1",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz",
"integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/ajv-compiler": "^3.5.0",
"@fastify/error": "^3.4.0",
"@fastify/fast-json-stringify-compiler": "^4.3.0",
"abstract-logging": "^2.0.1",
"avvio": "^8.3.0",
"fast-content-type-parse": "^1.1.0",
"fast-json-stringify": "^5.8.0",
"find-my-way": "^8.0.0",
"light-my-request": "^5.11.0",
"pino": "^9.0.0",
"process-warning": "^3.0.0",
"proxy-addr": "^2.0.7",
"rfdc": "^1.3.0",
"secure-json-parse": "^2.7.0",
"semver": "^7.5.4",
"toad-cache": "^3.3.0"
}
},
"node_modules/fastify-plugin": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz",
"integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==",
"license": "MIT"
},
"node_modules/fastq": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz",
"integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/find-my-way": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz",
"integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-querystring": "^1.0.0",
"safe-regex2": "^3.1.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/json-schema-ref-resolver": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz",
"integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/light-my-request": {
"version": "5.14.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz",
"integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==",
"license": "BSD-3-Clause",
"dependencies": {
"cookie": "^0.7.0",
"process-warning": "^3.0.0",
"set-cookie-parser": "^2.4.1"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"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/mnemonist": {
"version": "0.39.6",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz",
"integrity": "sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==",
"license": "MIT",
"dependencies": {
"obliterator": "^2.0.1"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/obliterator": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
"license": "MIT"
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/pg": {
"version": "8.23.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.16.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/pino": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
"integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^2.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^3.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/pino/node_modules/process-warning": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz",
"integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/process-warning": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz",
"integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ret": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz",
"integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-regex2": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz",
"integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==",
"license": "MIT",
"dependencies": {
"ret": "~0.4.0"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/secure-json-parse": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
"integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==",
"license": "BSD-3-Clause"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/thread-stream": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz",
"integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
}
},
"node_modules/toad-cache": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz",
"integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}

18
backend/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "hnf-tasks-backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"@fastify/cookie": "^9.4.0",
"@fastify/cors": "^9.0.1",
"fastify": "^4.28.1",
"jose": "^5.9.6",
"nodemailer": "^6.9.16",
"pg": "^8.13.1",
"web-push": "^3.6.7"
}
}

57
backend/src/auth.js Normal file
View file

@ -0,0 +1,57 @@
import { jwtVerify } from 'jose'
import { isOnsite } from './ip-check.js'
const APP_SLUG = process.env.APP_SLUG || 'tasks'
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
export async function requireAuth(request, reply) {
const token = request.cookies?.hnf_session
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
let payload
try {
const { payload: p } = await jwtVerify(token, secret)
payload = p
} catch {
return reply.status(401).send({ error: 'Invalid session' })
}
if (!payload.apps?.includes(APP_SLUG)) {
return reply.status(403).send({ error: 'No permission for this app' })
}
if (!payload.offsite_allowed) {
const clientIP = request.headers['x-real-ip'] || request.ip
if (!(await isOnsite(clientIP))) {
return reply.status(403).send({ error: 'Access restricted to site network' })
}
}
const prefix = `${APP_SLUG}:`
let caps
if (Array.isArray(payload.caps)) {
caps = payload.caps.filter(c => c.startsWith(prefix)).map(c => c.slice(prefix.length))
} else {
// Legacy token — grant basic use until re-login
caps = ['use']
}
request.user = {
email: payload.sub,
name: payload.name,
is_admin: payload.is_admin ?? false,
caps,
}
}
export function hasCap(request, cap) {
return request.user?.is_admin === true || request.user?.caps?.includes(cap) === true
}
export function requireCap(cap) {
return async (request, reply) => {
if (!hasCap(request, cap)) {
return reply.status(403).send({ error: `Missing capability: ${cap}` })
}
}
}

192
backend/src/db.js Normal file
View file

@ -0,0 +1,192 @@
import pg from 'pg'
const { Pool } = pg
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
export async function initDb() {
await pool.query(`
-- App-wide key/value config (notification defaults, VAPID keys via lib/push.js).
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- A user's containers: "Today", "Handover", "Waiting on", "Projects"…
-- owner_id is the owner's email (the identity the auth service issues).
CREATE TABLE IF NOT EXISTS lists (
id SERIAL PRIMARY KEY,
owner_id TEXT NOT NULL,
name TEXT NOT NULL,
colour TEXT NOT NULL DEFAULT '#c9a84c',
icon TEXT NOT NULL DEFAULT 'ListChecks',
sort_order INT NOT NULL DEFAULT 0,
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS lists_owner_idx ON lists (owner_id, sort_order);
-- Shared, admin-editable status pipeline (replaces a hardcoded todo/doing/done
-- enum). is_done drives completion/recurrence logic; is_hold just flags a
-- "parked" column for the board — it doesn't otherwise change behaviour.
CREATE TABLE IF NOT EXISTS statuses (
id SERIAL PRIMARY KEY,
label TEXT NOT NULL,
value TEXT NOT NULL UNIQUE,
position INT NOT NULL DEFAULT 0,
is_done BOOLEAN NOT NULL DEFAULT FALSE,
is_hold BOOLEAN NOT NULL DEFAULT FALSE
);
-- Flat, shared classification labels (e.g. "Finance", "Maintenance") — one
-- per task, not per-user, so they mean the same thing across owners/assignees.
CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
colour TEXT NOT NULL DEFAULT '#c9a84c',
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- A grouping container for tasks that can span multiple owners (once a task
-- is assigned elsewhere it keeps its project_id). Progress is inferred from
-- child tasks' statuses — projects don't get their own status pipeline.
CREATE TABLE IF NOT EXISTS projects (
id SERIAL PRIMARY KEY,
created_by TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
colour TEXT NOT NULL DEFAULT '#c9a84c',
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- A single to-do. assigned_to is a real ownership transfer (gated behind the
-- 'assign' cap) — when set, owner_id is moved to match it and the task is
-- unfiled (list_id NULL) since the assignee doesn't share the old owner's
-- lists. task_tags (below) is the separate, cap-free "share/watch" path that
-- leaves ownership alone. recur_rule drives the spawn-next-on-complete
-- behaviour; remind_at/reminded_at drive the reminder sweep.
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
list_id INT REFERENCES lists(id) ON DELETE CASCADE,
owner_id TEXT NOT NULL,
created_by TEXT NOT NULL,
assigned_to TEXT,
category_id INT REFERENCES categories(id) ON DELETE SET NULL,
project_id INT REFERENCES projects(id) ON DELETE SET NULL,
title TEXT NOT NULL,
notes TEXT,
status TEXT NOT NULL DEFAULT 'todo' REFERENCES statuses(value),
priority TEXT NOT NULL DEFAULT 'medium', -- low | medium | high
due_at TIMESTAMPTZ,
remind_at TIMESTAMPTZ,
reminded_at TIMESTAMPTZ,
recur_rule TEXT, -- null | daily | weekdays | weekly | monthly
completed_at TIMESTAMPTZ,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS tasks_list_idx ON tasks (list_id, sort_order);
CREATE INDEX IF NOT EXISTS tasks_owner_idx ON tasks (owner_id, status);
CREATE INDEX IF NOT EXISTS tasks_due_idx ON tasks (owner_id, due_at);
CREATE INDEX IF NOT EXISTS tasks_remind_idx ON tasks (remind_at) WHERE reminded_at IS NULL;
CREATE INDEX IF NOT EXISTS tasks_project_idx ON tasks (project_id);
CREATE INDEX IF NOT EXISTS tasks_category_idx ON tasks (category_id);
-- Lightweight share/watch — a tagged user sees the task in their "Shared
-- with me" view and can comment/tick items, but it stays on the owner's
-- list. No capability required to tag someone in (unlike assigned_to).
CREATE TABLE IF NOT EXISTS task_tags (
task_id INT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
user_email TEXT NOT NULL,
tagged_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (task_id, user_email)
);
CREATE INDEX IF NOT EXISTS task_tags_user_idx ON task_tags (user_email);
-- Checklist rows within a task ("buy X, buy Y").
CREATE TABLE IF NOT EXISTS task_items (
id SERIAL PRIMARY KEY,
task_id INT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
label TEXT NOT NULL,
is_done BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS task_items_task_idx ON task_items (task_id, sort_order);
-- Activity / notes thread on a task — visible to the owner and anyone
-- tagged in via task_tags.
CREATE TABLE IF NOT EXISTS task_comments (
id SERIAL PRIMARY KEY,
task_id INT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
author_email TEXT,
author_name TEXT,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS task_comments_task_idx ON task_comments (task_id, created_at);
CREATE TABLE IF NOT EXISTS push_subscriptions (
id SERIAL PRIMARY KEY,
user_email TEXT NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS push_subs_email_idx ON push_subscriptions (user_email);
`)
// Safety net for the pre-existing NOT NULL on list_id, in case this ran
// against a database created before assignment support (drop is a no-op
// if the constraint is already gone).
await pool.query(`ALTER TABLE tasks ALTER COLUMN list_id DROP NOT NULL`)
await seedDefaults()
await seedStatuses()
}
async function seedDefaults() {
const defaults = {
remind_by_push: true, // fire a web-push notification when a reminder is due
remind_by_email: false, // also email the owner (uses stack SMTP)
}
for (const [key, value] of Object.entries(defaults)) {
await pool.query(
`INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`,
[key, JSON.stringify(value)]
)
}
}
async function seedStatuses() {
const defaults = [
{ label: 'To Do', value: 'todo', position: 0, is_done: false, is_hold: false },
{ label: 'In Progress', value: 'doing', position: 1, is_done: false, is_hold: false },
{ label: 'In Review', value: 'review', position: 2, is_done: false, is_hold: false },
{ label: 'On Hold', value: 'hold', position: 3, is_done: false, is_hold: true },
{ label: 'Done', value: 'done', position: 4, is_done: true, is_hold: false },
]
for (const s of defaults) {
await pool.query(
`INSERT INTO statuses (label, value, position, is_done, is_hold) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (value) DO NOTHING`,
[s.label, s.value, s.position, s.is_done, s.is_hold]
)
}
}
export async function getStatuses() {
const { rows } = await pool.query('SELECT * FROM statuses ORDER BY position, id')
return rows
}
export async function getConfig() {
const { rows } = await pool.query('SELECT key, value FROM config')
return Object.fromEntries(rows.map(r => [r.key, r.value]))
}

40
backend/src/index.js Normal file
View file

@ -0,0 +1,40 @@
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import cors from '@fastify/cors'
import { initDb } from './db.js'
import { startScheduler } from './lib/scheduler.js'
import { listRoutes } from './routes/lists.js'
import { taskRoutes } from './routes/tasks.js'
import { configRoutes } from './routes/config.js'
import { pushRoutes } from './routes/push.js'
import { statusRoutes } from './routes/statuses.js'
import { categoryRoutes } from './routes/categories.js'
import { projectRoutes } from './routes/projects.js'
const app = Fastify({ logger: true, trustProxy: true })
const startedAt = Date.now()
await app.register(cookie)
await app.register(cors, {
origin: process.env.CORS_ORIGIN || false,
credentials: true,
})
app.get('/health', async () => ({ status: 'healthy', version: process.env.BUILD_VERSION || String(startedAt) }))
await app.register(listRoutes)
await app.register(taskRoutes)
await app.register(configRoutes)
await app.register(pushRoutes)
await app.register(statusRoutes)
await app.register(categoryRoutes)
await app.register(projectRoutes)
try {
await initDb()
startScheduler(app)
await app.listen({ port: 3001, host: '0.0.0.0' })
} catch (err) {
app.log.error(err)
process.exit(1)
}

80
backend/src/ip-check.js Normal file
View file

@ -0,0 +1,80 @@
import dns from 'dns/promises'
const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim()
const matchers = raw.split(',').map(s => s.trim()).filter(Boolean)
const TTL = 5 * 60 * 1000
const cache = new Map()
const PUBLIC_IP_URLS = [
'https://api.ipify.org',
'https://ifconfig.co/ip',
'https://icanhazip.com',
]
function normalizeIP(ip) {
return ip?.startsWith('::ffff:') ? ip.slice(7) : ip
}
function isIPv4(s) {
return /^\d{1,3}(\.\d{1,3}){3}$/.test(s)
}
function ipInCidr(ip, cidr) {
const [range, bits] = cidr.split('/')
if (!isIPv4(ip) || !isIPv4(range)) return false
const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0
const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0
return (toInt(ip) & mask) === (toInt(range) & mask)
}
async function fetchPublicIP() {
for (const url of PUBLIC_IP_URLS) {
try {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 4000)
const res = await fetch(url, { signal: ctrl.signal })
clearTimeout(timer)
if (!res.ok) continue
const ip = (await res.text()).trim()
if (isIPv4(ip)) return ip
} catch {
// try next
}
}
return null
}
async function resolveDynamic(key, resolver) {
const hit = cache.get(key)
if (hit && Date.now() < hit.expiry) return hit.ip
const ip = await resolver()
if (ip) {
cache.set(key, { ip, expiry: Date.now() + TTL })
return ip
}
return hit ? hit.ip : null
}
export async function isOnsite(requestIP) {
if (matchers.length === 0 || matchers.includes('disabled')) return true
const ip = normalizeIP(requestIP)
if (!ip) return false
for (const m of matchers) {
if (m === 'auto') {
const pub = await resolveDynamic('auto', fetchPublicIP)
if (pub && ip === pub) return true
} else if (m.includes('/')) {
if (ipInCidr(ip, m)) return true
} else if (/[a-zA-Z]/.test(m)) {
const resolved = await resolveDynamic(m, async () => {
try { return (await dns.resolve4(m))[0] } catch { return null }
})
if (resolved && ip === resolved) return true
} else {
if (ip === m) return true
}
}
return false
}

53
backend/src/lib/mailer.js Normal file
View file

@ -0,0 +1,53 @@
import nodemailer from 'nodemailer'
const SETTINGS_URL = process.env.SETTINGS_URL || ''
const SETTINGS_SECRET = process.env.SETTINGS_SECRET || ''
let _smtpCache = null // { config, expires_at }
let _transporter = null
async function getSmtpConfig() {
if (_smtpCache && Date.now() < _smtpCache.expires_at) return _smtpCache.config
const res = await fetch(`${SETTINGS_URL}/settings/api/internal/integration/smtp`, {
headers: { Authorization: `Bearer ${SETTINGS_SECRET}` },
signal: AbortSignal.timeout(5000),
})
if (!res.ok) throw new Error(`Failed to fetch SMTP config from settings: ${res.status}`)
const config = await res.json()
if (!config.host) throw new Error('SMTP not configured in settings')
_smtpCache = { config, expires_at: Date.now() + 5 * 60_000 }
_transporter = null
return config
}
async function getTransporter() {
if (_transporter) return _transporter
const config = await getSmtpConfig()
const port = parseInt(config.port || '587')
_transporter = nodemailer.createTransport({
host: config.host,
port,
secure: port === 465,
auth: config.user ? { user: config.user, pass: config.pass } : undefined,
})
return _transporter
}
const HOTEL_NAME = process.env.VITE_HOTEL_NAME || 'Hotel'
// Fire-and-forget: email failure must never block a task write or the scheduler.
export async function sendMail(to, subject, text) {
if (!to) return
try {
const config = await getSmtpConfig()
const transport = await getTransporter()
await transport.sendMail({
from: config.from || `"${HOTEL_NAME} Tasks" <noreply@localhost>`,
to,
subject,
text,
})
} catch (err) {
console.error(`Tasks mail to ${to} failed: ${err.message}`)
}
}

28
backend/src/lib/notify.js Normal file
View file

@ -0,0 +1,28 @@
import { getConfig } from '../db.js'
import { sendPushToUser } from './push.js'
import { sendMail } from './mailer.js'
// Reminder delivery for a single task. Honours the app-wide push/email toggles
// (config table). Both channels are fire-and-forget — a failure here must never
// stop the scheduler stamping reminded_at, or the same reminder fires forever.
export async function notifyReminder(task) {
const cfg = await getConfig().catch(() => ({}))
const target = task.assigned_to || task.owner_id
if (!target) return
if (cfg.remind_by_push !== false) {
await sendPushToUser(target, {
title: `Reminder: ${task.title}`,
body: task.due_at ? `Due ${new Date(task.due_at).toLocaleString('en-GB')}` : 'Task reminder',
url: '/tasks/',
tag: `task-${task.id}`,
}).catch(err => console.error('[notify] push failed:', err.message))
}
if (cfg.remind_by_email === true) {
const lines = [`Reminder for your task:`, ``, task.title]
if (task.due_at) lines.push(`Due: ${new Date(task.due_at).toLocaleString('en-GB')}`)
if (task.notes) lines.push('', task.notes)
await sendMail(target, `[Tasks] Reminder: ${task.title}`, lines.join('\n'))
}
}

73
backend/src/lib/push.js Normal file
View file

@ -0,0 +1,73 @@
import webPush from 'web-push'
import { pool } from '../db.js'
let _publicKey = null
// VAPID keys: prefer env vars; fall back to auto-generated keys stored in config table.
// Keys are generated once on first boot and persist — regenerating would invalidate all
// existing subscriptions.
export async function ensureVapid() {
if (_publicKey) return _publicKey
let publicKey = process.env.VAPID_PUBLIC_KEY
let privateKey = process.env.VAPID_PRIVATE_KEY
const subject = process.env.VAPID_SUBJECT || 'mailto:noreply@localhost'
if (!publicKey || !privateKey) {
const { rows } = await pool.query("SELECT value FROM config WHERE key = 'vapid_keys'")
if (rows.length && rows[0].value?.publicKey) {
publicKey = rows[0].value.publicKey
privateKey = rows[0].value.privateKey
} else {
const keys = webPush.generateVAPIDKeys()
publicKey = keys.publicKey
privateKey = keys.privateKey
await pool.query(
"INSERT INTO config (key, value) VALUES ('vapid_keys', $1) ON CONFLICT (key) DO UPDATE SET value = $1",
[JSON.stringify({ publicKey, privateKey })]
)
}
}
webPush.setVapidDetails(subject, publicKey, privateKey)
_publicKey = publicKey
return publicKey
}
export async function sendPushToUser(userEmail, payload) {
await ensureVapid()
const { rows } = await pool.query(
'SELECT * FROM push_subscriptions WHERE user_email = $1',
[userEmail]
)
if (!rows.length) return
const results = await Promise.allSettled(
rows.map(sub =>
webPush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify(payload)
)
)
)
// Clean up expired or invalid subscriptions
for (let i = 0; i < results.length; i++) {
const r = results[i]
if (r.status === 'rejected' && [404, 410].includes(r.reason?.statusCode)) {
await pool.query('DELETE FROM push_subscriptions WHERE id = $1', [rows[i].id]).catch(() => {})
}
}
}
// targetEmail overrides task.assigned_to (used when reassigning via PATCH before DB update)
export function notifyAssignmentPush(task, locationName, targetEmail = null) {
const email = targetEmail || (task.assigned_type === 'staff' ? task.assigned_to : null)
if (!email) return Promise.resolve()
return sendPushToUser(email, {
title: `New task: ${task.title}`,
body: `${locationName} — ${task.priority} priority`,
url: '/maintenance/',
tag: `maint-task-${task.id}`,
}).catch(err => console.error('[push] notify failed:', err.message))
}

View file

@ -0,0 +1,25 @@
export const RECUR_RULES = ['daily', 'weekdays', 'weekly', 'monthly']
// Given a recurrence rule and a base date, return the next occurrence date.
// Personal tasks recur on completion (Todoist-style): when a recurring task is
// ticked off we spawn a fresh copy dated one interval on from its old due date
// (or from `now` if it had no due date). Returns a Date, or null for no rule.
export function nextOccurrence(rule, from) {
if (!rule || !RECUR_RULES.includes(rule)) return null
const d = new Date(from)
switch (rule) {
case 'daily':
d.setDate(d.getDate() + 1)
break
case 'weekdays':
do { d.setDate(d.getDate() + 1) } while (d.getDay() === 0 || d.getDay() === 6)
break
case 'weekly':
d.setDate(d.getDate() + 7)
break
case 'monthly':
d.setMonth(d.getMonth() + 1)
break
}
return d
}

View file

@ -0,0 +1,34 @@
import { pool } from '../db.js'
import { notifyReminder } from './notify.js'
const CHECK_INTERVAL_MS = 5 * 60 * 1000 // every 5 minutes
// Fire any reminders whose remind_at has passed and haven't been sent yet.
// reminded_at is stamped first so a delivery failure never re-fires the reminder,
// and so a backlog after downtime sends one notification per task, not per tick.
export async function runReminderSweep(log = console) {
const { rows: due } = await pool.query(
`UPDATE tasks
SET reminded_at = NOW()
WHERE remind_at IS NOT NULL
AND reminded_at IS NULL
AND remind_at <= NOW()
AND status <> 'done'
RETURNING *`
)
for (const task of due) {
try {
await notifyReminder(task)
log.info?.(`Reminder sent for task ${task.id} (${task.title})`)
} catch (err) {
log.error?.(`Reminder failed for task ${task.id}: ${err.message}`)
}
}
return due.length
}
export function startScheduler(app) {
runReminderSweep(app.log).catch(err => app.log.error(err))
setInterval(() => runReminderSweep(app.log).catch(err => app.log.error(err)), CHECK_INTERVAL_MS)
}

View file

@ -0,0 +1,52 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
export async function categoryRoutes(app) {
app.addHook('preHandler', requireAuth)
// Shared across all users — everyone with 'use' picks from the same set.
app.get('/api/categories', { preHandler: requireCap('use') }, async () => {
const { rows } = await pool.query('SELECT * FROM categories ORDER BY sort_order, name')
return rows
})
app.post('/api/categories', { preHandler: requireCap('settings') }, async (req, reply) => {
const { name, colour } = req.body || {}
if (!name?.trim()) return reply.status(400).send({ error: 'name required' })
const { rows: [max] } = await pool.query('SELECT COALESCE(MAX(sort_order), -1) AS m FROM categories')
try {
const { rows: [category] } = await pool.query(
`INSERT INTO categories (name, colour, sort_order) VALUES ($1, COALESCE($2, '#c9a84c'), $3) RETURNING *`,
[name.trim(), colour || null, Number(max.m) + 1]
)
return category
} catch (err) {
if (err.code === '23505') return reply.status(409).send({ error: 'a category with that name already exists' })
throw err
}
})
app.patch('/api/categories/:id', { preHandler: requireCap('settings') }, async (req, reply) => {
const fields = []
const vals = []
let i = 1
for (const key of ['name', 'colour', 'sort_order']) {
if (req.body?.[key] !== undefined) { fields.push(`${key} = $${i++}`); vals.push(req.body[key]) }
}
if (!fields.length) return reply.status(400).send({ error: 'nothing to update' })
vals.push(req.params.id)
const { rows } = await pool.query(
`UPDATE categories SET ${fields.join(', ')} WHERE id = $${i} RETURNING *`,
vals
)
if (!rows.length) return reply.status(404).send({ error: 'category not found' })
return rows[0]
})
app.delete('/api/categories/:id', { preHandler: requireCap('settings') }, async (req, reply) => {
// Tasks referencing this category fall back to uncategorised (ON DELETE SET NULL).
const { rowCount } = await pool.query('DELETE FROM categories WHERE id = $1', [req.params.id])
if (!rowCount) return reply.status(404).send({ error: 'category not found' })
return { ok: true }
})
}

View file

@ -0,0 +1,24 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool, getConfig } from '../db.js'
// Only these keys are writable through the API.
const ALLOWED_KEYS = new Set(['remind_by_push', 'remind_by_email'])
export async function configRoutes(app) {
app.addHook('preHandler', requireAuth)
app.get('/api/config', { preHandler: requireCap('use') }, async () => {
return getConfig()
})
app.put('/api/config/:key', { preHandler: requireCap('settings') }, async (req, reply) => {
const { key } = req.params
if (!ALLOWED_KEYS.has(key)) return reply.status(400).send({ error: 'unknown setting' })
await pool.query(
`INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
[key, JSON.stringify(req.body?.value ?? null)]
)
return { ok: true }
})
}

View file

@ -0,0 +1,66 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
export async function listRoutes(app) {
app.addHook('preHandler', requireAuth)
// All of a user's lists, each with its open (non-done) task count.
app.get('/api/lists', { preHandler: requireCap('use') }, async (req) => {
const includeArchived = req.query.archived === '1' || req.query.archived === 'true'
const { rows } = await pool.query(
`SELECT l.*,
COUNT(t.id) FILTER (WHERE t.status <> 'done') AS open_count,
COUNT(t.id) AS total_count
FROM lists l
LEFT JOIN tasks t ON t.list_id = l.id
WHERE l.owner_id = $1
AND ($2::boolean OR l.is_archived = FALSE)
GROUP BY l.id
ORDER BY l.sort_order, l.id`,
[req.user.email, includeArchived]
)
return rows.map(r => ({ ...r, open_count: Number(r.open_count), total_count: Number(r.total_count) }))
})
app.post('/api/lists', { preHandler: requireCap('use') }, async (req, reply) => {
const { name, colour, icon } = req.body || {}
if (!name?.trim()) return reply.status(400).send({ error: 'name required' })
const { rows: [max] } = await pool.query(
'SELECT COALESCE(MAX(sort_order), 0) AS m FROM lists WHERE owner_id = $1',
[req.user.email]
)
const { rows: [list] } = await pool.query(
`INSERT INTO lists (owner_id, name, colour, icon, sort_order)
VALUES ($1, $2, COALESCE($3, '#c9a84c'), COALESCE($4, 'ListChecks'), $5)
RETURNING *`,
[req.user.email, name.trim(), colour || null, icon || null, Number(max.m) + 1]
)
return list
})
app.patch('/api/lists/:id', { preHandler: requireCap('use') }, async (req, reply) => {
const fields = []
const vals = []
let i = 1
for (const key of ['name', 'colour', 'icon', 'is_archived', 'sort_order']) {
if (req.body?.[key] !== undefined) { fields.push(`${key} = $${i++}`); vals.push(req.body[key]) }
}
if (!fields.length) return reply.status(400).send({ error: 'nothing to update' })
vals.push(req.params.id, req.user.email)
const { rows } = await pool.query(
`UPDATE lists SET ${fields.join(', ')} WHERE id = $${i++} AND owner_id = $${i} RETURNING *`,
vals
)
if (!rows.length) return reply.status(404).send({ error: 'list not found' })
return rows[0]
})
app.delete('/api/lists/:id', { preHandler: requireCap('use') }, async (req, reply) => {
const { rowCount } = await pool.query(
'DELETE FROM lists WHERE id = $1 AND owner_id = $2',
[req.params.id, req.user.email]
)
if (!rowCount) return reply.status(404).send({ error: 'list not found' })
return { ok: true }
})
}

View file

@ -0,0 +1,92 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
// A project is visible to whoever created it, plus anyone who owns or is
// tagged on at least one task inside it — so a project stays visible to
// collaborators even though it isn't "theirs". `alias` lets the same clause
// be reused against different table aliases in one query.
const visibleTask = (alias) => `(${alias}.owner_id = $1 OR ${alias}.assigned_to = $1 OR EXISTS (
SELECT 1 FROM task_tags tt WHERE tt.task_id = ${alias}.id AND tt.user_email = $1
))`
async function canManageProject(email, isAdmin, projectId) {
if (isAdmin) return true
const { rows: [p] } = await pool.query('SELECT created_by FROM projects WHERE id = $1', [projectId])
return p?.created_by === email
}
export async function projectRoutes(app) {
app.addHook('preHandler', requireAuth)
app.get('/api/projects', { preHandler: requireCap('use') }, async (req) => {
const { rows } = await pool.query(
`SELECT p.*,
COUNT(t.id) AS task_count,
COUNT(t.id) FILTER (WHERE s.is_done) AS done_count
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id AND ${visibleTask('t')}
LEFT JOIN statuses s ON s.value = t.status
WHERE p.is_archived = FALSE
AND (p.created_by = $1 OR EXISTS (
SELECT 1 FROM tasks t2 WHERE t2.project_id = p.id AND ${visibleTask('t2')}
))
GROUP BY p.id
ORDER BY p.sort_order, p.id`,
[req.user.email]
)
return rows.map(r => ({ ...r, task_count: Number(r.task_count), done_count: Number(r.done_count) }))
})
app.get('/api/projects/:id', { preHandler: requireCap('use') }, async (req, reply) => {
const { rows: [project] } = await pool.query('SELECT * FROM projects WHERE id = $1', [req.params.id])
if (!project) return reply.status(404).send({ error: 'project not found' })
const { rows: tasks } = await pool.query(
`SELECT t.*, s.label AS status_label, s.is_done, s.is_hold
FROM tasks t
JOIN statuses s ON s.value = t.status
WHERE t.project_id = $2 AND ${visibleTask('t')}
ORDER BY s.position, t.sort_order, t.id`,
[req.user.email, req.params.id]
)
return { ...project, tasks }
})
app.post('/api/projects', { preHandler: requireCap('use') }, async (req, reply) => {
const { name, description, colour } = req.body || {}
if (!name?.trim()) return reply.status(400).send({ error: 'name required' })
const { rows: [max] } = await pool.query('SELECT COALESCE(MAX(sort_order), -1) AS m FROM projects')
const { rows: [project] } = await pool.query(
`INSERT INTO projects (created_by, name, description, colour, sort_order)
VALUES ($1, $2, $3, COALESCE($4, '#c9a84c'), $5) RETURNING *`,
[req.user.email, name.trim(), description || null, colour || null, Number(max.m) + 1]
)
return project
})
app.patch('/api/projects/:id', { preHandler: requireCap('use') }, async (req, reply) => {
if (!(await canManageProject(req.user.email, req.user.is_admin, req.params.id)))
return reply.status(403).send({ error: 'only the project creator can edit this' })
const fields = []
const vals = []
let i = 1
for (const key of ['name', 'description', 'colour', 'is_archived', 'sort_order']) {
if (req.body?.[key] !== undefined) { fields.push(`${key} = $${i++}`); vals.push(req.body[key]) }
}
if (!fields.length) return reply.status(400).send({ error: 'nothing to update' })
vals.push(req.params.id)
const { rows } = await pool.query(
`UPDATE projects SET ${fields.join(', ')} WHERE id = $${i} RETURNING *`,
vals
)
if (!rows.length) return reply.status(404).send({ error: 'project not found' })
return rows[0]
})
app.delete('/api/projects/:id', { preHandler: requireCap('use') }, async (req, reply) => {
if (!(await canManageProject(req.user.email, req.user.is_admin, req.params.id)))
return reply.status(403).send({ error: 'only the project creator can delete this' })
const { rowCount } = await pool.query('DELETE FROM projects WHERE id = $1', [req.params.id])
if (!rowCount) return reply.status(404).send({ error: 'project not found' })
return { ok: true }
})
}

View file

@ -0,0 +1,36 @@
import { requireAuth } from '../auth.js'
import { pool } from '../db.js'
import { ensureVapid } from '../lib/push.js'
export async function pushRoutes(app) {
app.addHook('preHandler', requireAuth)
app.get('/api/push/vapid-key', async () => {
const publicKey = await ensureVapid()
return { publicKey }
})
app.post('/api/push/subscribe', async (req, reply) => {
const { endpoint, keys } = req.body || {}
if (!endpoint || !keys?.p256dh || !keys?.auth) {
return reply.status(400).send({ error: 'endpoint and keys (p256dh, auth) required' })
}
await pool.query(
`INSERT INTO push_subscriptions (user_email, endpoint, p256dh, auth)
VALUES ($1, $2, $3, $4)
ON CONFLICT (endpoint) DO UPDATE SET user_email = $1, p256dh = $3, auth = $4, updated_at = NOW()`,
[req.user.email, endpoint, keys.p256dh, keys.auth]
)
return { ok: true }
})
app.delete('/api/push/unsubscribe', async (req, reply) => {
const { endpoint } = req.body || {}
if (endpoint) {
await pool.query('DELETE FROM push_subscriptions WHERE endpoint = $1', [endpoint])
} else {
await pool.query('DELETE FROM push_subscriptions WHERE user_email = $1', [req.user.email])
}
return { ok: true }
})
}

View file

@ -0,0 +1,50 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool, getStatuses } from '../db.js'
export async function statusRoutes(app) {
app.addHook('preHandler', requireAuth)
app.get('/api/statuses', { preHandler: requireCap('use') }, async () => {
return getStatuses()
})
app.post('/api/statuses', { preHandler: requireCap('settings') }, async (req, reply) => {
const { label, value, is_hold } = req.body || {}
if (!label?.trim() || !value?.trim()) return reply.status(400).send({ error: 'label and value required' })
if (!/^[a-z0-9_]+$/.test(value)) return reply.status(400).send({ error: 'value must be lowercase letters, numbers, underscores' })
const { rows: [max] } = await pool.query('SELECT COALESCE(MAX(position), -1) AS m FROM statuses')
const { rows: [status] } = await pool.query(
`INSERT INTO statuses (label, value, position, is_hold) VALUES ($1, $2, $3, $4) RETURNING *`,
[label.trim(), value.trim(), Number(max.m) + 1, !!is_hold]
)
return status
})
app.patch('/api/statuses/:id', { preHandler: requireCap('settings') }, async (req, reply) => {
const fields = []
const vals = []
let i = 1
for (const key of ['label', 'position', 'is_done', 'is_hold']) {
if (req.body?.[key] !== undefined) { fields.push(`${key} = $${i++}`); vals.push(req.body[key]) }
}
if (!fields.length) return reply.status(400).send({ error: 'nothing to update' })
vals.push(req.params.id)
const { rows } = await pool.query(
`UPDATE statuses SET ${fields.join(', ')} WHERE id = $${i} RETURNING *`,
vals
)
if (!rows.length) return reply.status(404).send({ error: 'status not found' })
return rows[0]
})
// A status can only be removed once nothing references it — reassign any
// tasks first (the frontend should prompt for a replacement column).
app.delete('/api/statuses/:id', { preHandler: requireCap('settings') }, async (req, reply) => {
const { rows: [status] } = await pool.query('SELECT value FROM statuses WHERE id = $1', [req.params.id])
if (!status) return reply.status(404).send({ error: 'status not found' })
const { rows: [inUse] } = await pool.query('SELECT 1 FROM tasks WHERE status = $1 LIMIT 1', [status.value])
if (inUse) return reply.status(409).send({ error: 'status is still in use by tasks' })
await pool.query('DELETE FROM statuses WHERE id = $1', [req.params.id])
return { ok: true }
})
}

366
backend/src/routes/tasks.js Normal file
View file

@ -0,0 +1,366 @@
import { requireAuth, requireCap, hasCap } from '../auth.js'
import { pool } from '../db.js'
import { nextOccurrence, RECUR_RULES } from '../lib/recurrence.js'
const PRIORITIES = ['low', 'medium', 'high']
async function getStatus(value) {
const { rows: [s] } = await pool.query('SELECT * FROM statuses WHERE value = $1', [value])
return s || null
}
// Load a task only if the caller owns it — the guard used for full edits
// (reassigning, deleting, changing status/fields). Tagging someone in does
// NOT grant this.
async function loadOwned(id, email) {
const { rows } = await pool.query(
'SELECT * FROM tasks WHERE id = $1 AND owner_id = $2',
[id, email]
)
return rows[0] || null
}
// Load a task the caller can see: they own it, it was assigned to them, or
// they've been tagged in. Used for read access and collaborative actions
// (comments, checklist items) that a tagged-in collaborator should be able
// to do without full edit rights.
async function loadVisible(id, email) {
const { rows } = await pool.query(
`SELECT t.* FROM tasks t
WHERE t.id = $1 AND (t.owner_id = $2 OR t.assigned_to = $2 OR EXISTS (
SELECT 1 FROM task_tags tt WHERE tt.task_id = t.id AND tt.user_email = $2
))`,
[id, email]
)
return rows[0] || null
}
// Spawn the next occurrence of a recurring task when it's completed. The fresh
// copy keeps the title/notes/priority/list/category/project/rule and a reset
// checklist, dated one interval on from the old due date (or from now if it
// had none).
async function spawnRecurrence(task) {
if (!task.recur_rule || !RECUR_RULES.includes(task.recur_rule)) return
const base = task.due_at ? new Date(task.due_at) : new Date()
const newDue = nextOccurrence(task.recur_rule, base)
let newRemind = null
if (task.remind_at) {
if (task.due_at) {
const delta = new Date(task.due_at).getTime() - new Date(task.remind_at).getTime()
newRemind = new Date(newDue.getTime() - delta)
} else {
newRemind = nextOccurrence(task.recur_rule, new Date(task.remind_at))
}
}
const { rows: [next] } = await pool.query(
`INSERT INTO tasks (list_id, owner_id, created_by, category_id, project_id, title, notes, priority, due_at, remind_at, recur_rule, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id`,
[task.list_id, task.owner_id, task.created_by, task.category_id, task.project_id, task.title, task.notes,
task.priority, newDue, newRemind, task.recur_rule, task.sort_order]
)
await pool.query(
`INSERT INTO task_items (task_id, label, is_done, sort_order)
SELECT $1, label, FALSE, sort_order FROM task_items WHERE task_id = $2`,
[next.id, task.id]
)
}
export async function taskRoutes(app) {
app.addHook('preHandler', requireAuth)
// List tasks. ?view=today = cross-list agenda (non-done, due within 8 days
// or overdue). ?view=assigned = tasks handed to me via the 'assign' cap.
// ?view=shared = tasks I've been tagged into (not mine, not assigned to me).
// ?view=all = every user's tasks, dispatcher board for the 'reallocate' cap.
// Otherwise defaults to everything I own. ?list_id/?status/?category_id/
// ?project_id narrow further.
app.get('/api/tasks', { preHandler: requireCap('use') }, async (req, reply) => {
const { list_id, view, status, category_id, project_id } = req.query
const vals = [req.user.email]
let i = 2
const clauses = []
let joinTags = ''
if (view === 'all') {
if (!hasCap(req, 'reallocate')) return reply.status(403).send({ error: 'Missing capability: reallocate' })
// $1 (email) isn't needed for the filter itself, but every other branch
// binds it — keep it referenced so the placeholder numbering below stays
// valid (an unused bind param makes Postgres reject the query outright).
clauses.push('$1::text IS NOT NULL')
} else if (view === 'shared') {
joinTags = 'JOIN task_tags tt ON tt.task_id = t.id AND tt.user_email = $1'
clauses.push('TRUE')
} else if (view === 'assigned') {
clauses.push(`t.assigned_to = $1`)
} else {
clauses.push(`t.owner_id = $1`)
}
if (list_id) { clauses.push(`t.list_id = $${i++}`); vals.push(list_id) }
if (status) { clauses.push(`t.status = $${i++}`); vals.push(status) }
if (category_id) { clauses.push(`t.category_id = $${i++}`); vals.push(category_id) }
if (project_id) { clauses.push(`t.project_id = $${i++}`); vals.push(project_id) }
if (view === 'today') {
clauses.push(`NOT s.is_done`)
clauses.push(`t.due_at IS NOT NULL`)
clauses.push(`t.due_at < (NOW() + INTERVAL '8 days')`)
}
const { rows } = await pool.query(
`SELECT t.*, l.name AS list_name, l.colour AS list_colour,
s.label AS status_label, s.is_done, s.is_hold,
c.name AS category_name, c.colour AS category_colour,
p.name AS project_name, p.colour AS project_colour,
(SELECT COUNT(*) FROM task_items ti WHERE ti.task_id = t.id) AS item_count,
(SELECT COUNT(*) FROM task_items ti WHERE ti.task_id = t.id AND ti.is_done) AS item_done
FROM tasks t
${joinTags}
LEFT JOIN lists l ON l.id = t.list_id
JOIN statuses s ON s.value = t.status
LEFT JOIN categories c ON c.id = t.category_id
LEFT JOIN projects p ON p.id = t.project_id
WHERE ${clauses.join(' AND ')}
ORDER BY s.is_done, s.position, t.sort_order, t.due_at NULLS LAST, t.id`,
vals
)
return rows.map(r => ({ ...r, item_count: Number(r.item_count), item_done: Number(r.item_done) }))
})
app.get('/api/tasks/:id', { preHandler: requireCap('use') }, async (req, reply) => {
const task = await loadVisible(req.params.id, req.user.email)
if (!task) return reply.status(404).send({ error: 'task not found' })
const { rows: items } = await pool.query(
'SELECT * FROM task_items WHERE task_id = $1 ORDER BY sort_order, id',
[task.id]
)
const { rows: comments } = await pool.query(
'SELECT * FROM task_comments WHERE task_id = $1 ORDER BY created_at',
[task.id]
)
const { rows: tags } = await pool.query(
'SELECT user_email, tagged_by, created_at FROM task_tags WHERE task_id = $1 ORDER BY created_at',
[task.id]
)
return { ...task, items, comments, tags }
})
app.post('/api/tasks', { preHandler: requireCap('use') }, async (req, reply) => {
const { list_id, title, notes, priority, due_at, remind_at, recur_rule, category_id, project_id } = req.body || {}
if (!title?.trim()) return reply.status(400).send({ error: 'title required' })
// Verify the target list belongs to the caller.
const { rows: [list] } = await pool.query(
'SELECT id FROM lists WHERE id = $1 AND owner_id = $2',
[list_id, req.user.email]
)
if (!list) return reply.status(400).send({ error: 'invalid list' })
if (priority && !PRIORITIES.includes(priority)) return reply.status(400).send({ error: 'bad priority' })
if (recur_rule && !RECUR_RULES.includes(recur_rule)) return reply.status(400).send({ error: 'bad recur_rule' })
if (category_id) {
const { rows: [c] } = await pool.query('SELECT id FROM categories WHERE id = $1', [category_id])
if (!c) return reply.status(400).send({ error: 'invalid category' })
}
if (project_id) {
const { rows: [p] } = await pool.query('SELECT id FROM projects WHERE id = $1', [project_id])
if (!p) return reply.status(400).send({ error: 'invalid project' })
}
const { rows: [max] } = await pool.query(
'SELECT COALESCE(MAX(sort_order), 0) AS m FROM tasks WHERE list_id = $1',
[list_id]
)
const { rows: [task] } = await pool.query(
`INSERT INTO tasks (list_id, owner_id, created_by, title, notes, priority, due_at, remind_at, recur_rule, category_id, project_id, sort_order)
VALUES ($1, $2, $2, $3, $4, COALESCE($5, 'medium'), $6, $7, $8, $9, $10, $11)
RETURNING *`,
[list_id, req.user.email, title.trim(), notes || null, priority || null,
due_at || null, remind_at || null, recur_rule || null, category_id || null, project_id || null, Number(max.m) + 1]
)
return task
})
app.patch('/api/tasks/:id', { preHandler: requireCap('use') }, async (req, reply) => {
const task = await loadOwned(req.params.id, req.user.email)
if (!task) return reply.status(404).send({ error: 'task not found' })
const b = req.body || {}
let newStatus = null
if (b.status !== undefined) {
newStatus = await getStatus(b.status)
if (!newStatus) return reply.status(400).send({ error: 'bad status' })
}
if (b.priority && !PRIORITIES.includes(b.priority)) return reply.status(400).send({ error: 'bad priority' })
if (b.recur_rule && !RECUR_RULES.includes(b.recur_rule)) return reply.status(400).send({ error: 'bad recur_rule' })
if (b.list_id !== undefined && b.list_id !== null) {
const { rows: [l] } = await pool.query('SELECT id FROM lists WHERE id = $1 AND owner_id = $2', [b.list_id, req.user.email])
if (!l) return reply.status(400).send({ error: 'invalid list' })
}
if (b.category_id !== undefined && b.category_id !== null) {
const { rows: [c] } = await pool.query('SELECT id FROM categories WHERE id = $1', [b.category_id])
if (!c) return reply.status(400).send({ error: 'invalid category' })
}
if (b.project_id !== undefined && b.project_id !== null) {
const { rows: [p] } = await pool.query('SELECT id FROM projects WHERE id = $1', [b.project_id])
if (!p) return reply.status(400).send({ error: 'invalid project' })
}
// Assigning is a real ownership transfer, gated behind the 'assign' cap —
// separate from tagging, which anyone can do to share a task.
if (b.assigned_to !== undefined) {
if (!hasCap(req, 'assign')) return reply.status(403).send({ error: "Missing capability: assign" })
}
const fields = []
const vals = []
let i = 1
for (const key of ['title', 'notes', 'priority', 'due_at', 'remind_at', 'recur_rule', 'sort_order', 'category_id', 'project_id']) {
if (b[key] !== undefined) { fields.push(`${key} = $${i++}`); vals.push(b[key] === '' ? null : b[key]) }
}
// list_id can be explicitly nulled (unfiled) as well as changed.
if (b.list_id !== undefined) { fields.push(`list_id = $${i++}`); vals.push(b.list_id || null) }
// Reset the reminder clock whenever remind_at is (re)set, so the sweep re-fires it.
if (b.remind_at !== undefined) fields.push('reminded_at = NULL')
if (b.assigned_to !== undefined) {
const target = b.assigned_to || null
fields.push(`assigned_to = $${i++}`); vals.push(target)
// Handing off moves the task onto the assignee's board — they don't
// share the old owner's lists, so it lands unfiled. Clearing the
// assignment (target === null) just removes the marker; ownership stays put.
if (target && target !== task.owner_id) {
fields.push(`owner_id = $${i++}`); vals.push(target)
// Only force list_id to NULL if this request didn't already set it explicitly.
if (b.list_id === undefined) fields.push(`list_id = NULL`)
}
}
const oldStatus = newStatus ? await getStatus(task.status) : null
const wasDone = !!oldStatus?.is_done
if (newStatus) {
fields.push(`status = $${i++}`); vals.push(b.status)
if (newStatus.is_done && !wasDone) fields.push('completed_at = NOW()')
if (!newStatus.is_done && wasDone) fields.push('completed_at = NULL')
}
fields.push('updated_at = NOW()')
vals.push(task.id, req.user.email)
const { rows: [updated] } = await pool.query(
`UPDATE tasks SET ${fields.join(', ')} WHERE id = $${i++} AND owner_id = $${i} RETURNING *`,
vals
)
if (newStatus?.is_done && !wasDone) await spawnRecurrence(task)
return updated
})
// Dispatcher move — the 'reallocate' cap can hand ANY task to ANY user,
// regardless of who currently owns it (unlike 'assign', which only lets an
// owner hand off their own work). Deliberately narrow: this only moves the
// task, it doesn't grant editing rights over someone else's task content.
app.post('/api/tasks/:id/reallocate', { preHandler: requireCap('reallocate') }, async (req, reply) => {
const to = req.body?.to?.trim()
if (!to) return reply.status(400).send({ error: 'to (email) required' })
const { rows: [updated] } = await pool.query(
`UPDATE tasks SET owner_id = $1, assigned_to = $1, list_id = NULL, updated_at = NOW()
WHERE id = $2 RETURNING *`,
[to, req.params.id]
)
if (!updated) return reply.status(404).send({ error: 'task not found' })
return updated
})
app.delete('/api/tasks/:id', { preHandler: requireCap('use') }, async (req, reply) => {
const { rowCount } = await pool.query(
'DELETE FROM tasks WHERE id = $1 AND owner_id = $2',
[req.params.id, req.user.email]
)
if (!rowCount) return reply.status(404).send({ error: 'task not found' })
return { ok: true }
})
// Persist a manual reorder within a list. Body: { ids: [taskId, ...] } in order.
app.post('/api/tasks/reorder', { preHandler: requireCap('use') }, async (req, reply) => {
const ids = req.body?.ids
if (!Array.isArray(ids)) return reply.status(400).send({ error: 'ids array required' })
for (let idx = 0; idx < ids.length; idx++) {
await pool.query(
'UPDATE tasks SET sort_order = $1 WHERE id = $2 AND owner_id = $3',
[idx, ids[idx], req.user.email]
)
}
return { ok: true }
})
// ── Sharing (tag/untag) — owner-managed, no cap required ────
app.post('/api/tasks/:id/tags', { preHandler: requireCap('use') }, async (req, reply) => {
const task = await loadOwned(req.params.id, req.user.email)
if (!task) return reply.status(404).send({ error: 'task not found' })
const email = req.body?.email?.trim()
if (!email) return reply.status(400).send({ error: 'email required' })
await pool.query(
`INSERT INTO task_tags (task_id, user_email, tagged_by) VALUES ($1, $2, $3)
ON CONFLICT (task_id, user_email) DO NOTHING`,
[task.id, email, req.user.email]
)
return { ok: true }
})
app.delete('/api/tasks/:id/tags/:email', { preHandler: requireCap('use') }, async (req, reply) => {
const task = await loadOwned(req.params.id, req.user.email)
if (!task) return reply.status(404).send({ error: 'task not found' })
await pool.query('DELETE FROM task_tags WHERE task_id = $1 AND user_email = $2', [task.id, req.params.email])
return { ok: true }
})
// ── Checklist items — owner or tagged collaborators ─────────
app.post('/api/tasks/:id/items', { preHandler: requireCap('use') }, async (req, reply) => {
const task = await loadVisible(req.params.id, req.user.email)
if (!task) return reply.status(404).send({ error: 'task not found' })
if (!req.body?.label?.trim()) return reply.status(400).send({ error: 'label required' })
const { rows: [max] } = await pool.query('SELECT COALESCE(MAX(sort_order), 0) AS m FROM task_items WHERE task_id = $1', [task.id])
const { rows: [item] } = await pool.query(
'INSERT INTO task_items (task_id, label, sort_order) VALUES ($1, $2, $3) RETURNING *',
[task.id, req.body.label.trim(), Number(max.m) + 1]
)
return item
})
app.patch('/api/tasks/items/:itemId', { preHandler: requireCap('use') }, async (req, reply) => {
const { rows: [existing] } = await pool.query('SELECT task_id FROM task_items WHERE id = $1', [req.params.itemId])
if (!existing || !(await loadVisible(existing.task_id, req.user.email)))
return reply.status(404).send({ error: 'item not found' })
const fields = []
const vals = []
let i = 1
for (const key of ['label', 'is_done', 'sort_order']) {
if (req.body?.[key] !== undefined) { fields.push(`${key} = $${i++}`); vals.push(req.body[key]) }
}
if (!fields.length) return reply.status(400).send({ error: 'nothing to update' })
vals.push(req.params.itemId)
const { rows: [item] } = await pool.query(
`UPDATE task_items SET ${fields.join(', ')} WHERE id = $${i} RETURNING *`,
vals
)
return item
})
app.delete('/api/tasks/items/:itemId', { preHandler: requireCap('use') }, async (req, reply) => {
const { rows: [existing] } = await pool.query('SELECT task_id FROM task_items WHERE id = $1', [req.params.itemId])
if (!existing || !(await loadVisible(existing.task_id, req.user.email)))
return reply.status(404).send({ error: 'item not found' })
await pool.query('DELETE FROM task_items WHERE id = $1', [req.params.itemId])
return { ok: true }
})
// ── Comments / activity — owner or tagged collaborators ─────
app.post('/api/tasks/:id/comments', { preHandler: requireCap('use') }, async (req, reply) => {
const task = await loadVisible(req.params.id, req.user.email)
if (!task) return reply.status(404).send({ error: 'task not found' })
if (!req.body?.body?.trim()) return reply.status(400).send({ error: 'body required' })
const { rows: [c] } = await pool.query(
`INSERT INTO task_comments (task_id, author_email, author_name, body)
VALUES ($1, $2, $3, $4) RETURNING *`,
[task.id, req.user.email, req.user.name, req.body.body.trim()]
)
return c
})
}