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:
commit
d8dc4e2b81
54 changed files with 10908 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
uploads/
|
||||
*.log
|
||||
8
backend/Dockerfile
Normal file
8
backend/Dockerfile
Normal 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
902
backend/package-lock.json
generated
Normal 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
18
backend/package.json
Normal 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
57
backend/src/auth.js
Normal 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
192
backend/src/db.js
Normal 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
40
backend/src/index.js
Normal 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
80
backend/src/ip-check.js
Normal 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
53
backend/src/lib/mailer.js
Normal 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
28
backend/src/lib/notify.js
Normal 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
73
backend/src/lib/push.js
Normal 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))
|
||||
}
|
||||
25
backend/src/lib/recurrence.js
Normal file
25
backend/src/lib/recurrence.js
Normal 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
|
||||
}
|
||||
34
backend/src/lib/scheduler.js
Normal file
34
backend/src/lib/scheduler.js
Normal 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)
|
||||
}
|
||||
52
backend/src/routes/categories.js
Normal file
52
backend/src/routes/categories.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
24
backend/src/routes/config.js
Normal file
24
backend/src/routes/config.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
66
backend/src/routes/lists.js
Normal file
66
backend/src/routes/lists.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
92
backend/src/routes/projects.js
Normal file
92
backend/src/routes/projects.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
36
backend/src/routes/push.js
Normal file
36
backend/src/routes/push.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
50
backend/src/routes/statuses.js
Normal file
50
backend/src/routes/statuses.js
Normal 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
366
backend/src/routes/tasks.js
Normal 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
|
||||
})
|
||||
}
|
||||
37
docker-compose.yml
Normal file
37
docker-compose.yml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
security_opt:
|
||||
- apparmor=unconfined
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
||||
- SETTINGS_URL=${SETTINGS_URL}
|
||||
- SETTINGS_SECRET=${SETTINGS_SECRET}
|
||||
- APP_SLUG=tasks
|
||||
- VITE_HOTEL_NAME=${VITE_HOTEL_NAME}
|
||||
- OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/health || exit 1"]
|
||||
interval: 10s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
VITE_HOTEL_NAME: ${VITE_HOTEL_NAME}
|
||||
security_opt:
|
||||
- apparmor=unconfined
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3080}:80"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
default:
|
||||
driver: bridge
|
||||
13
frontend/Dockerfile
Normal file
13
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
COPY . .
|
||||
ARG VITE_HOTEL_NAME
|
||||
ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html/tasks
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
18
frontend/index.html
Normal file
18
frontend/index.html
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Tasks" />
|
||||
<link rel="apple-touch-icon" href="/tasks/icons/icon-192.png" />
|
||||
<meta name="theme-color" content="#0d9488" />
|
||||
<title>Tasks</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
40
frontend/nginx.conf
Normal file
40
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
client_max_body_size 12m;
|
||||
|
||||
location /tasks/api/auth/ {
|
||||
proxy_pass http://10.10.10.101:3001/api/auth/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location /tasks/api/ {
|
||||
proxy_pass http://backend:3001/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location /tasks/health {
|
||||
proxy_pass http://backend:3001/health;
|
||||
}
|
||||
|
||||
location ~* /tasks/.*\.(js|css|png|ico|svg|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /tasks/ {
|
||||
add_header Cache-Control "no-cache" always;
|
||||
try_files $uri /tasks/index.html;
|
||||
}
|
||||
|
||||
location = / {
|
||||
return 301 /tasks/;
|
||||
}
|
||||
}
|
||||
6272
frontend/package-lock.json
generated
Normal file
6272
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "hnf-tasks-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/icons/icon-192.png
Normal file
BIN
frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
BIN
frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
40
frontend/src/App.tsx
Normal file
40
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import AuthGate from './components/AuthGate'
|
||||
import { UpdateBanner } from './components/UpdateBanner'
|
||||
import IosInstallHint from './components/IosInstallHint'
|
||||
import { useVersionCheck } from './hooks/useVersionCheck'
|
||||
import Layout from './components/Layout'
|
||||
import { ListsProvider } from './context/ListsContext'
|
||||
import { TaskDrawerProvider } from './components/TaskDrawer'
|
||||
import TodayView from './pages/TodayView'
|
||||
import ListView from './pages/ListView'
|
||||
import BoardView from './pages/BoardView'
|
||||
import Settings from './pages/Settings'
|
||||
|
||||
export default function App() {
|
||||
const updateAvailable = useVersionCheck('/tasks/health')
|
||||
return (
|
||||
<>
|
||||
<BrowserRouter basename="/tasks">
|
||||
<AuthGate>
|
||||
<ListsProvider>
|
||||
<TaskDrawerProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/today" replace />} />
|
||||
<Route path="/today" element={<TodayView />} />
|
||||
<Route path="/board" element={<BoardView />} />
|
||||
<Route path="/list/:id" element={<ListView />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/today" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</TaskDrawerProvider>
|
||||
</ListsProvider>
|
||||
</AuthGate>
|
||||
</BrowserRouter>
|
||||
<UpdateBanner visible={updateAvailable} />
|
||||
<IosInstallHint />
|
||||
</>
|
||||
)
|
||||
}
|
||||
82
frontend/src/api.ts
Normal file
82
frontend/src/api.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig } from './types'
|
||||
|
||||
const BASE = '/tasks/api'
|
||||
|
||||
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...opts.headers },
|
||||
...opts,
|
||||
})
|
||||
if (res.status === 401) {
|
||||
;(window.top ?? window).location.href = '/login'
|
||||
throw new Error('Unauthenticated')
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error(err.error || `Request failed: ${res.status}`)
|
||||
}
|
||||
if (res.status === 204) return undefined as T
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ── Lists ────────────────────────────────────────────────────
|
||||
export function fetchLists(includeArchived = false): Promise<List[]> {
|
||||
return request(`/lists${includeArchived ? '?archived=1' : ''}`)
|
||||
}
|
||||
export function createList(body: { name: string; colour?: string; icon?: string }): Promise<List> {
|
||||
return request('/lists', { method: 'POST', body: JSON.stringify(body) })
|
||||
}
|
||||
export function updateList(id: number, body: Partial<List>): Promise<List> {
|
||||
return request(`/lists/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
|
||||
}
|
||||
export function deleteList(id: number): Promise<{ ok: boolean }> {
|
||||
return request(`/lists/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ── Tasks ────────────────────────────────────────────────────
|
||||
export function fetchTasks(params: { list_id?: number; view?: 'today'; status?: string } = {}): Promise<Task[]> {
|
||||
const qs = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(params)) if (v !== undefined) qs.set(k, String(v))
|
||||
const s = qs.toString()
|
||||
return request(`/tasks${s ? `?${s}` : ''}`)
|
||||
}
|
||||
export function fetchTask(id: number): Promise<TaskDetail> {
|
||||
return request(`/tasks/${id}`)
|
||||
}
|
||||
export function createTask(body: Record<string, unknown>): Promise<Task> {
|
||||
return request('/tasks', { method: 'POST', body: JSON.stringify(body) })
|
||||
}
|
||||
export function updateTask(id: number, body: Record<string, unknown>): Promise<Task> {
|
||||
return request(`/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
|
||||
}
|
||||
export function deleteTask(id: number): Promise<{ ok: boolean }> {
|
||||
return request(`/tasks/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
export function reorderTasks(ids: number[]): Promise<{ ok: boolean }> {
|
||||
return request('/tasks/reorder', { method: 'POST', body: JSON.stringify({ ids }) })
|
||||
}
|
||||
|
||||
// ── Checklist items ──────────────────────────────────────────
|
||||
export function addItem(taskId: number, label: string): Promise<TaskItem> {
|
||||
return request(`/tasks/${taskId}/items`, { method: 'POST', body: JSON.stringify({ label }) })
|
||||
}
|
||||
export function updateItem(itemId: number, body: Partial<TaskItem>): Promise<TaskItem> {
|
||||
return request(`/tasks/items/${itemId}`, { method: 'PATCH', body: JSON.stringify(body) })
|
||||
}
|
||||
export function deleteItem(itemId: number): Promise<{ ok: boolean }> {
|
||||
return request(`/tasks/items/${itemId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ── Comments ─────────────────────────────────────────────────
|
||||
export function addComment(taskId: number, body: string): Promise<TaskComment> {
|
||||
return request(`/tasks/${taskId}/comments`, { method: 'POST', body: JSON.stringify({ body }) })
|
||||
}
|
||||
|
||||
// ── Config ───────────────────────────────────────────────────
|
||||
export function fetchConfig(): Promise<AppConfig> {
|
||||
return request('/config')
|
||||
}
|
||||
export function updateConfig(key: string, value: unknown): Promise<{ ok: boolean }> {
|
||||
return request(`/config/${key}`, { method: 'PUT', body: JSON.stringify({ value }) })
|
||||
}
|
||||
178
frontend/src/components/AuthGate.tsx
Normal file
178
frontend/src/components/AuthGate.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { useEffect, useRef, useState, createContext, useContext } from 'react'
|
||||
import type { User } from '../types'
|
||||
import { usePushSubscription } from '../hooks/usePushSubscription'
|
||||
|
||||
function getInactivityMs(): number | null {
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) return null
|
||||
const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('hnf_inactivity_mins='))
|
||||
if (!c) return null
|
||||
const mins = parseInt(c.split('=')[1])
|
||||
return isNaN(mins) || mins <= 0 ? null : mins * 60 * 1000
|
||||
}
|
||||
|
||||
// Only bounce to the central login when actually embedded in the portal shell.
|
||||
// A standalone PWA or a directly-opened browser tab must never navigate away
|
||||
// from its own start_url/scope — otherwise it loses its installed-app context.
|
||||
function isEmbedded() {
|
||||
return window.top !== window
|
||||
}
|
||||
|
||||
interface AuthCtx { user: User }
|
||||
const Ctx = createContext<AuthCtx | null>(null)
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(Ctx)
|
||||
if (!ctx) throw new Error('useAuth must be used inside AuthGate')
|
||||
return ctx
|
||||
}
|
||||
|
||||
function PushSubscriber({ user }: { user: User }) {
|
||||
usePushSubscription(user)
|
||||
return null
|
||||
}
|
||||
|
||||
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking')
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/verify?app=tasks', { credentials: 'include' })
|
||||
.then(async r => {
|
||||
if (r.ok) {
|
||||
setUser(await r.json())
|
||||
setState('authed')
|
||||
} else if (isEmbedded()) {
|
||||
window.top!.location.href = `/login?from=${encodeURIComponent('/app/tasks')}`
|
||||
} else {
|
||||
setState('login')
|
||||
}
|
||||
})
|
||||
.catch(() => { if (!isEmbedded()) setState('login') })
|
||||
}, [])
|
||||
|
||||
// Inactivity auto-logout — disabled for installed PWAs; configurable per
|
||||
// device (Admin Settings → Device) for shared/front-desk browser sessions.
|
||||
useEffect(() => {
|
||||
const ms = getInactivityMs()
|
||||
if (state !== 'authed' || !ms) return
|
||||
const timeoutMs: number = ms
|
||||
|
||||
async function forceLogout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||
setUser(null)
|
||||
setState('login')
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(forceLogout, timeoutMs)
|
||||
}
|
||||
|
||||
const events = ['mousemove', 'keydown', 'click', 'touchstart'] as const
|
||||
events.forEach(e => window.addEventListener(e, reset, { passive: true }))
|
||||
reset()
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
events.forEach(e => window.removeEventListener(e, reset))
|
||||
}
|
||||
}, [state])
|
||||
|
||||
async function login(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST', credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
if (!res.ok) { setError('Invalid email or password'); return }
|
||||
const verify = await fetch('/api/auth/verify?app=tasks', { credentials: 'include' })
|
||||
if (verify.ok) {
|
||||
setUser(await verify.json())
|
||||
setState('authed')
|
||||
} else {
|
||||
setError("You don't have access to this app.")
|
||||
}
|
||||
} catch {
|
||||
setError('Connection error — please try again')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (state === 'checking') {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '100vh', fontFamily: 'var(--font)', color: 'var(--text-mid)'
|
||||
}}>
|
||||
Loading…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'login') {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
justifyContent: 'center', height: '100dvh', padding: '1.5rem',
|
||||
background: 'var(--navy-dark)',
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--navy)', borderRadius: 'var(--radius)',
|
||||
padding: '2rem', width: '100%', maxWidth: '360px',
|
||||
border: '1px solid var(--surface-2)',
|
||||
}}>
|
||||
<h1 style={{ fontSize: '1.4rem', marginBottom: '0.25rem', color: 'var(--gold)' }}>
|
||||
Tasks
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: '1.5rem' }}>
|
||||
{import.meta.env.VITE_HOTEL_NAME}
|
||||
</p>
|
||||
<form onSubmit={login} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="email" value={email} onChange={e => setEmail(e.target.value)}
|
||||
placeholder="Email" required autoComplete="email"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<input
|
||||
type="password" value={password} onChange={e => setPassword(e.target.value)}
|
||||
placeholder="Password" required autoComplete="current-password"
|
||||
style={inputStyle}
|
||||
/>
|
||||
{error && <p style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>{error}</p>}
|
||||
<button type="submit" disabled={loading} style={{
|
||||
background: loading ? 'var(--surface-2)' : 'var(--gold)',
|
||||
color: loading ? 'var(--text-muted)' : 'var(--navy-dark)',
|
||||
border: 'none', borderRadius: '6px', padding: '0.625rem',
|
||||
fontSize: '1rem', fontWeight: 600, marginTop: '0.25rem',
|
||||
}}>
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ user: user! }}>
|
||||
<PushSubscriber user={user!} />
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
background: 'var(--navy-dark)', border: '1px solid var(--surface-2)',
|
||||
borderRadius: '6px', color: 'var(--text)', padding: '0.625rem 0.75rem',
|
||||
fontSize: '1rem', width: '100%', outline: 'none',
|
||||
}
|
||||
89
frontend/src/components/IosInstallHint.tsx
Normal file
89
frontend/src/components/IosInstallHint.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Share, ExternalLink, X } from 'lucide-react'
|
||||
|
||||
function isIos() {
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
// iPadOS 13+ reports as 'MacIntel' but has touch support, unlike a real Mac
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
|
||||
}
|
||||
|
||||
function isStandalone() {
|
||||
return window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as unknown as { standalone?: boolean }).standalone === true
|
||||
}
|
||||
|
||||
// Safari's UA also matches "Safari", so this checks for the other iOS
|
||||
// browsers/in-app webviews that spoof it — none of them can add to the
|
||||
// home screen; only Safari itself can.
|
||||
function isNonSafariIosBrowser() {
|
||||
return /CriOS|FxiOS|EdgiOS|OPiOS|mercury|GSA|DuckDuckGo|Instagram|FBAN|FBAV|Line\//.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS has no `beforeinstallprompt` API — Safari never fires it, so the
|
||||
* portal's Install button (?install=1) does nothing there. This shows the
|
||||
* manual steps instead: Share -> Add to Home Screen in Safari, or a prompt
|
||||
* to switch to Safari first if the page was opened in another browser/app.
|
||||
*/
|
||||
export default function IosInstallHint() {
|
||||
const [mode, setMode] = useState<'safari' | 'other' | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isIos() || isStandalone()) return
|
||||
if (!new URLSearchParams(window.location.search).has('install')) return
|
||||
setMode(isNonSafariIosBrowser() ? 'other' : 'safari')
|
||||
}, [])
|
||||
|
||||
if (!mode) return null
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(15,15,32,0.55)',
|
||||
display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
|
||||
padding: '0 16px 24px',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
width: '100%', maxWidth: '360px',
|
||||
background: 'var(--card-bg)', borderRadius: 'var(--radius)',
|
||||
border: '1px solid var(--card-border)', boxShadow: 'var(--shadow-md)',
|
||||
padding: '20px', textAlign: 'center',
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setMode(null)}
|
||||
aria-label="Dismiss"
|
||||
style={{
|
||||
position: 'absolute', top: '10px', right: '10px',
|
||||
background: 'none', border: 'none', color: 'var(--text-mid)',
|
||||
padding: '4px', cursor: 'pointer', lineHeight: 0,
|
||||
}}
|
||||
>
|
||||
<X size={16} strokeWidth={1.75} />
|
||||
</button>
|
||||
|
||||
<div style={{
|
||||
width: '44px', height: '44px', margin: '0 auto 12px',
|
||||
borderRadius: '50%', background: 'var(--gold)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{mode === 'safari'
|
||||
? <Share size={22} strokeWidth={1.75} color="var(--navy)" />
|
||||
: <ExternalLink size={22} strokeWidth={1.75} color="var(--navy)" />}
|
||||
</div>
|
||||
|
||||
{mode === 'safari' ? (
|
||||
<p style={{ fontSize: '14px', color: 'var(--text-dark)', lineHeight: 1.5, margin: 0 }}>
|
||||
To install this app, tap the <strong>Share</strong> icon in Safari's
|
||||
toolbar, then choose <strong>Add to Home Screen</strong>.
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ fontSize: '14px', color: 'var(--text-dark)', lineHeight: 1.5, margin: 0 }}>
|
||||
iOS only allows installing apps from <strong>Safari</strong>. Open this
|
||||
page in Safari, then tap Share → <strong>Add to Home Screen</strong>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
106
frontend/src/components/Layout.tsx
Normal file
106
frontend/src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { ListChecks, CalendarDays, Columns3, Settings, Menu, LogOut, Plus } from 'lucide-react'
|
||||
import { useAuth } from './AuthGate'
|
||||
import { useLists } from '../context/ListsContext'
|
||||
import { can } from '../types'
|
||||
import type { List } from '../types'
|
||||
import ListEditor from './ListEditor'
|
||||
|
||||
const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { user } = useAuth()
|
||||
const { lists } = useLists()
|
||||
const location = useLocation()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<null | 'new' | List>(null)
|
||||
|
||||
async function logout() {
|
||||
await fetch('/tasks/api/auth/logout', { method: 'POST', credentials: 'include' })
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
useEffect(() => { setMenuOpen(false) }, [location.pathname])
|
||||
|
||||
return (
|
||||
<div className={`app-shell${menuOpen ? ' menu-open' : ''}`}>
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-logo">
|
||||
<ListChecks size={18} strokeWidth={1.75} />
|
||||
Tasks
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
<NavLink to="/today" className={({ isActive }) => isActive ? 'active' : ''}>
|
||||
<CalendarDays {...ICON_PROPS} /> Today
|
||||
</NavLink>
|
||||
<NavLink to="/board" className={({ isActive }) => isActive ? 'active' : ''}>
|
||||
<Columns3 {...ICON_PROPS} /> Board
|
||||
</NavLink>
|
||||
|
||||
<div className="list-nav">
|
||||
<div className="list-nav-head">
|
||||
Lists
|
||||
<button className="list-nav-add" onClick={() => setEditing('new')} title="New list">
|
||||
<Plus size={15} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
{lists.map(l => (
|
||||
<NavLink key={l.id} to={`/list/${l.id}`} className={({ isActive }) => isActive ? 'active' : ''}>
|
||||
<span className="list-dot" style={{ background: l.colour }} />
|
||||
{l.name}
|
||||
{l.open_count > 0 && <span className="list-count">{l.open_count}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
{lists.length === 0 && (
|
||||
<div style={{ padding: '4px 16px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
No lists yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{can(user, 'settings') && (
|
||||
<NavLink to="/settings" className={({ isActive }) => isActive ? 'active' : ''} style={{ marginTop: 4 }}>
|
||||
<Settings {...ICON_PROPS} /> Settings
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-user" style={{ whiteSpace: 'normal' }}>
|
||||
<div style={{ fontWeight: 600, color: 'var(--text)', fontSize: '12px', marginBottom: '2px' }}>{user.name}</div>
|
||||
<div style={{ fontSize: '11px', marginBottom: '8px' }}>{user.email}</div>
|
||||
<button onClick={logout} style={{
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
background: 'none', border: 'none', color: 'inherit',
|
||||
fontSize: '12px', padding: 0, cursor: 'pointer',
|
||||
}}>
|
||||
<LogOut size={13} strokeWidth={1.75} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{menuOpen && <div className="menu-backdrop" onClick={() => setMenuOpen(false)} />}
|
||||
|
||||
<header className="top-bar">
|
||||
<button className="top-bar-burger" onClick={() => setMenuOpen(o => !o)}>
|
||||
<Menu size={20} strokeWidth={1.75} />
|
||||
</button>
|
||||
<ListChecks size={18} strokeWidth={1.75} color="var(--gold)" />
|
||||
<span className="top-bar-title">Tasks</span>
|
||||
</header>
|
||||
|
||||
<main className="page-content">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{editing && (
|
||||
<ListEditor
|
||||
list={editing === 'new' ? null : editing}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
98
frontend/src/components/ListEditor.tsx
Normal file
98
frontend/src/components/ListEditor.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { X, Trash2 } from 'lucide-react'
|
||||
import type { List } from '../types'
|
||||
import { LIST_COLOURS } from '../types'
|
||||
import { createList, updateList, deleteList } from '../api'
|
||||
import { useLists } from '../context/ListsContext'
|
||||
|
||||
export default function ListEditor({ list, onClose }: { list: List | null; onClose: () => void }) {
|
||||
const { reload } = useLists()
|
||||
const navigate = useNavigate()
|
||||
const [name, setName] = useState(list?.name ?? '')
|
||||
const [colour, setColour] = useState(list?.colour ?? LIST_COLOURS[0])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function save() {
|
||||
if (!name.trim()) return
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
if (list) {
|
||||
await updateList(list.id, { name: name.trim(), colour })
|
||||
} else {
|
||||
const created = await createList({ name: name.trim(), colour })
|
||||
await reload()
|
||||
navigate(`/list/${created.id}`)
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
await reload()
|
||||
onClose()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Save failed')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!list) return
|
||||
if (!confirm(`Delete "${list.name}" and all its tasks? This cannot be undone.`)) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await deleteList(list.id)
|
||||
await reload()
|
||||
navigate('/today')
|
||||
onClose()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Delete failed')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{list ? 'Edit list' : 'New list'}</h2>
|
||||
<button className="modal-close" onClick={onClose}><X size={18} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="field">
|
||||
<label>Name</label>
|
||||
<input type="text" value={name} autoFocus
|
||||
onChange={e => setName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') save() }}
|
||||
placeholder="e.g. Handover, Projects, Waiting on" />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Colour</label>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{LIST_COLOURS.map(c => (
|
||||
<button key={c} onClick={() => setColour(c)} title={c}
|
||||
style={{
|
||||
width: 26, height: 26, borderRadius: '50%', background: c, cursor: 'pointer',
|
||||
border: colour === c ? '3px solid var(--text-dark)' : '3px solid transparent',
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer-actions">
|
||||
{list && (
|
||||
<button className="btn-ghost-sm btn-ghost-danger" onClick={remove} disabled={busy} style={{ marginRight: 'auto' }}>
|
||||
<Trash2 size={14} strokeWidth={1.75} /> Delete
|
||||
</button>
|
||||
)}
|
||||
<button className="btn" onClick={onClose} disabled={busy}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={save} disabled={busy || !name.trim()}>
|
||||
{list ? 'Save' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
214
frontend/src/components/TaskDetail.tsx
Normal file
214
frontend/src/components/TaskDetail.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { X, Trash2, Plus, Bell, Repeat, CalendarClock, FolderInput } from 'lucide-react'
|
||||
import type { TaskDetail as TDetail, Status, Priority, RecurRule } from '../types'
|
||||
import { PRIORITIES, PRIORITY_LABELS, STATUSES, STATUS_LABELS, RECUR_RULES, RECUR_LABELS } from '../types'
|
||||
import {
|
||||
fetchTask, updateTask, deleteTask,
|
||||
addItem, updateItem, deleteItem, addComment,
|
||||
} from '../api'
|
||||
import { useLists } from '../context/ListsContext'
|
||||
import { Check_, formatDateTime, toLocalInput } from './shared'
|
||||
|
||||
export default function TaskDetail({ taskId, onChange, onClose }:
|
||||
{ taskId: number; onChange: () => void; onClose: () => void }) {
|
||||
const { lists } = useLists()
|
||||
const [task, setTask] = useState<TDetail | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [title, setTitle] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [newItem, setNewItem] = useState('')
|
||||
const [comment, setComment] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchTask(taskId).then(t => { setTask(t); setTitle(t.title); setNotes(t.notes ?? '') })
|
||||
.catch(e => setError(e.message))
|
||||
}, [taskId])
|
||||
|
||||
if (error) return (
|
||||
<Shell onClose={onClose}><div className="error-banner">{error}</div></Shell>
|
||||
)
|
||||
if (!task) return <Shell onClose={onClose}><div className="empty-state">Loading…</div></Shell>
|
||||
|
||||
// Patch task fields, merge the result locally, and notify the parent view.
|
||||
async function patch(fields: Record<string, unknown>) {
|
||||
const updated = await updateTask(taskId, fields)
|
||||
setTask(t => t ? { ...t, ...updated } : t)
|
||||
onChange()
|
||||
}
|
||||
|
||||
const toLocalIso = (v: string) => v ? new Date(v).toISOString() : ''
|
||||
|
||||
async function toggleDone() {
|
||||
await patch({ status: task!.status === 'done' ? 'todo' : 'done' })
|
||||
}
|
||||
|
||||
async function addChecklistItem() {
|
||||
if (!newItem.trim()) return
|
||||
const item = await addItem(taskId, newItem.trim())
|
||||
setTask(t => t ? { ...t, items: [...t.items, item] } : t)
|
||||
setNewItem('')
|
||||
onChange()
|
||||
}
|
||||
async function toggleItem(id: number, is_done: boolean) {
|
||||
await updateItem(id, { is_done })
|
||||
setTask(t => t ? { ...t, items: t.items.map(i => i.id === id ? { ...i, is_done } : i) } : t)
|
||||
}
|
||||
async function removeItem(id: number) {
|
||||
await deleteItem(id)
|
||||
setTask(t => t ? { ...t, items: t.items.filter(i => i.id !== id) } : t)
|
||||
}
|
||||
async function postComment() {
|
||||
if (!comment.trim()) return
|
||||
const c = await addComment(taskId, comment.trim())
|
||||
setTask(t => t ? { ...t, comments: [...t.comments, c] } : t)
|
||||
setComment('')
|
||||
}
|
||||
async function removeTask() {
|
||||
if (!confirm('Delete this task?')) return
|
||||
await deleteTask(taskId)
|
||||
onChange()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const doneItems = task.items.filter(i => i.is_done).length
|
||||
|
||||
return (
|
||||
<Shell onClose={onClose} onDelete={removeTask}>
|
||||
{/* Title + completion */}
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', marginBottom: 14 }}>
|
||||
<div style={{ marginTop: 4 }}><Check_ status={task.status} onClick={toggleDone} /></div>
|
||||
<textarea
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
onBlur={() => title.trim() && title !== task.title && patch({ title: title.trim() })}
|
||||
rows={1}
|
||||
style={{
|
||||
flex: 1, border: 'none', outline: 'none', background: 'none', resize: 'none',
|
||||
fontSize: 17, fontWeight: 600, fontFamily: 'var(--font)', color: 'var(--text-dark)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status pills */}
|
||||
<div className="chip-bar">
|
||||
{STATUSES.map(s => (
|
||||
<button key={s} className={`chip${task.status === s ? ' active' : ''}`} onClick={() => patch({ status: s })}>
|
||||
{STATUS_LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Attributes */}
|
||||
<div className="card" style={{ padding: '12px 14px' }}>
|
||||
<div className="field" style={{ marginBottom: 10 }}>
|
||||
<label><FolderInput size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> List</label>
|
||||
<select value={task.list_id} onChange={e => patch({ list_id: Number(e.target.value) })}>
|
||||
{lists.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Priority</label>
|
||||
<select value={task.priority} onChange={e => patch({ priority: e.target.value as Priority })}>
|
||||
{PRIORITIES.map(p => <option key={p} value={p}>{PRIORITY_LABELS[p]}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label><Repeat size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Repeat</label>
|
||||
<select value={task.recur_rule ?? ''} onChange={e => patch({ recur_rule: e.target.value || null })}>
|
||||
<option value="">Doesn't repeat</option>
|
||||
{RECUR_RULES.map(r => <option key={r} value={r}>{RECUR_LABELS[r as RecurRule]}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label><CalendarClock size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Due</label>
|
||||
<input type="datetime-local" value={toLocalInput(task.due_at)}
|
||||
onChange={e => patch({ due_at: toLocalIso(e.target.value) })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label><Bell size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Remind</label>
|
||||
<input type="datetime-local" value={toLocalInput(task.remind_at)}
|
||||
onChange={e => patch({ remind_at: toLocalIso(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="section-title">Notes</div>
|
||||
<div className="field">
|
||||
<textarea value={notes} placeholder="Add notes…"
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
onBlur={() => notes !== (task.notes ?? '') && patch({ notes })} />
|
||||
</div>
|
||||
|
||||
{/* Checklist */}
|
||||
<div className="section-title">Checklist {task.items.length > 0 && `(${doneItems}/${task.items.length})`}</div>
|
||||
{task.items.length > 0 && (
|
||||
<div className="checklist-progress">
|
||||
<div style={{ width: `${(doneItems / task.items.length) * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{task.items.map(i => (
|
||||
<div key={i.id} className={`checklist-item${i.is_done ? ' done' : ''}`}>
|
||||
<button className={`check${i.is_done ? ' checked' : ''}`} onClick={() => toggleItem(i.id, !i.is_done)} type="button" />
|
||||
<span>{i.label}</span>
|
||||
<button className="checklist-del" onClick={() => removeItem(i.id)}><X size={14} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
))}
|
||||
<div className="task-add" style={{ marginTop: 8 }}>
|
||||
<Plus size={16} strokeWidth={1.75} color="var(--text-mid)" />
|
||||
<input value={newItem} placeholder="Add a sub-task"
|
||||
onChange={e => setNewItem(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') addChecklistItem() }} />
|
||||
</div>
|
||||
|
||||
{/* Comments / activity */}
|
||||
<div className="section-title">Activity</div>
|
||||
<div className="timeline">
|
||||
{task.comments.map(c => (
|
||||
<div key={c.id} className="timeline-item">
|
||||
<div className="timeline-body">
|
||||
<div className="timeline-note">{c.body}</div>
|
||||
<div className="timeline-meta">{c.author_name || 'You'} · {formatDateTime(c.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{task.comments.length === 0 && <div className="muted" style={{ fontSize: 12 }}>No activity yet.</div>}
|
||||
</div>
|
||||
<div className="task-add" style={{ marginTop: 8 }}>
|
||||
<input value={comment} placeholder="Add a note…"
|
||||
onChange={e => setComment(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') postComment() }} />
|
||||
<button className="btn btn-sm btn-primary" onClick={postComment} disabled={!comment.trim()}>Post</button>
|
||||
</div>
|
||||
|
||||
<div className="timeline-meta" style={{ marginTop: 14 }}>
|
||||
Created {formatDateTime(task.created_at)}
|
||||
{task.completed_at && ` · Completed ${formatDateTime(task.completed_at)}`}
|
||||
</div>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
function Shell({ children, onClose, onDelete }:
|
||||
{ children: React.ReactNode; onClose: () => void; onDelete?: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<div className="drawer-overlay" onClick={onClose} />
|
||||
<div className="drawer">
|
||||
<div className="drawer-head">
|
||||
<h2 style={{ visibility: 'hidden' }}>Task</h2>
|
||||
{onDelete && (
|
||||
<button className="btn-ghost-sm btn-ghost-danger" onClick={onDelete}>
|
||||
<Trash2 size={15} strokeWidth={1.75} />
|
||||
</button>
|
||||
)}
|
||||
<button className="modal-close" onClick={onClose}><X size={20} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
<div className="drawer-body">{children}</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
44
frontend/src/components/TaskDrawer.tsx
Normal file
44
frontend/src/components/TaskDrawer.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { createContext, useContext, useState, useCallback } from 'react'
|
||||
import { useLists } from '../context/ListsContext'
|
||||
import TaskDetail from './TaskDetail'
|
||||
|
||||
interface DrawerCtx {
|
||||
openTask: (id: number) => void
|
||||
changeVersion: number // bumps whenever a task is mutated via the drawer
|
||||
}
|
||||
|
||||
const Ctx = createContext<DrawerCtx | null>(null)
|
||||
|
||||
export function useTaskDrawer() {
|
||||
const ctx = useContext(Ctx)
|
||||
if (!ctx) throw new Error('useTaskDrawer must be used inside TaskDrawerProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function TaskDrawerProvider({ children }: { children: React.ReactNode }) {
|
||||
const { reload: reloadLists } = useLists()
|
||||
const [openId, setOpenId] = useState<number | null>(null)
|
||||
const [changeVersion, setChangeVersion] = useState(0)
|
||||
|
||||
const openTask = useCallback((id: number) => setOpenId(id), [])
|
||||
|
||||
// Called by the drawer whenever it edits a task: refresh sidebar counts and
|
||||
// signal open views to reload.
|
||||
const notifyChange = useCallback(() => {
|
||||
setChangeVersion(v => v + 1)
|
||||
reloadLists()
|
||||
}, [reloadLists])
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ openTask, changeVersion }}>
|
||||
{children}
|
||||
{openId !== null && (
|
||||
<TaskDetail
|
||||
taskId={openId}
|
||||
onChange={notifyChange}
|
||||
onClose={() => setOpenId(null)}
|
||||
/>
|
||||
)}
|
||||
</Ctx.Provider>
|
||||
)
|
||||
}
|
||||
61
frontend/src/components/TaskRow.tsx
Normal file
61
frontend/src/components/TaskRow.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { GripVertical, Bell, Repeat, ListChecks } from 'lucide-react'
|
||||
import type { Task } from '../types'
|
||||
import { Check_, PriorityBadge, dueInfo } from './shared'
|
||||
|
||||
interface Props {
|
||||
task: Task
|
||||
onToggle: (t: Task) => void
|
||||
onOpen: (t: Task) => void
|
||||
showList?: boolean
|
||||
draggable?: boolean
|
||||
dragging?: boolean
|
||||
onDragStart?: () => void
|
||||
onDragOver?: (e: React.DragEvent) => void
|
||||
onDrop?: () => void
|
||||
}
|
||||
|
||||
export default function TaskRow({
|
||||
task, onToggle, onOpen, showList,
|
||||
draggable, dragging, onDragStart, onDragOver, onDrop,
|
||||
}: Props) {
|
||||
const due = dueInfo(task.due_at)
|
||||
return (
|
||||
<div
|
||||
className={`task-row ${task.priority === 'high' ? 'high' : ''} ${task.status === 'done' ? 'done' : ''} ${dragging ? 'dragging' : ''}`}
|
||||
onClick={() => onOpen(task)}
|
||||
draggable={draggable}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
{draggable && (
|
||||
<span className="task-row-drag" onClick={e => e.stopPropagation()}>
|
||||
<GripVertical size={16} strokeWidth={1.75} />
|
||||
</span>
|
||||
)}
|
||||
<div onClick={e => { e.stopPropagation(); onToggle(task) }}>
|
||||
<Check_ status={task.status} onClick={() => {}} />
|
||||
</div>
|
||||
<div className="task-row-main">
|
||||
<div className="task-row-title">{task.title}</div>
|
||||
<div className="task-row-meta">
|
||||
{showList && task.list_name && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span className="list-dot" style={{ background: task.list_colour, width: 8, height: 8 }} />
|
||||
{task.list_name}
|
||||
</span>
|
||||
)}
|
||||
{due && <span className={`due-chip${due.overdue ? ' overdue' : ''}`}>{due.label}</span>}
|
||||
{task.remind_at && <Bell size={12} strokeWidth={1.75} />}
|
||||
{task.recur_rule && <Repeat size={12} strokeWidth={1.75} />}
|
||||
{!!task.item_count && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
|
||||
<ListChecks size={12} strokeWidth={1.75} />{task.item_done}/{task.item_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PriorityBadge priority={task.priority} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
frontend/src/components/UpdateBanner.tsx
Normal file
44
frontend/src/components/UpdateBanner.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
export function UpdateBanner({ visible }: { visible: boolean }) {
|
||||
if (!visible) return null
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
background: 'var(--sidebar)',
|
||||
color: 'var(--text-light)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
padding: '10px 16px',
|
||||
fontSize: '14px',
|
||||
boxShadow: '0 -2px 8px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
<span>A new version is available.</span>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
background: 'var(--accent)',
|
||||
color: 'var(--sidebar)',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '6px 14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} strokeWidth={1.75} />
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
frontend/src/components/shared.tsx
Normal file
65
frontend/src/components/shared.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Check } from 'lucide-react'
|
||||
import type { Priority, Status } from '../types'
|
||||
import { PRIORITY_LABELS, STATUS_LABELS } from '../types'
|
||||
|
||||
export function PriorityBadge({ priority }: { priority: Priority }) {
|
||||
if (priority === 'medium') return null
|
||||
return <span className={`badge badge-prio-${priority}`}>{PRIORITY_LABELS[priority]}</span>
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: Status }) {
|
||||
return <span className={`badge badge-st-${status}`}>{STATUS_LABELS[status]}</span>
|
||||
}
|
||||
|
||||
// Round completion checkbox. 'doing' shows a partial ring, 'done' a filled tick.
|
||||
export function Check_({ status, onClick }: { status: Status; onClick: (e: React.MouseEvent) => void }) {
|
||||
const cls = status === 'done' ? 'check checked' : status === 'doing' ? 'check doing' : 'check'
|
||||
return (
|
||||
<button className={cls} onClick={onClick} aria-label="toggle done" type="button">
|
||||
{status === 'done' && <Check size={13} strokeWidth={3} />}
|
||||
{status === 'doing' && <span style={{ width: 8, height: 8, borderRadius: '50%', background: 'currentColor' }} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function formatDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
export function formatDateTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) + ' ' +
|
||||
d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function startOfDay(d: Date) { const x = new Date(d); x.setHours(0, 0, 0, 0); return x }
|
||||
|
||||
// Human relative due label + whether it's overdue.
|
||||
export function dueInfo(iso: string | null): { label: string; overdue: boolean } | null {
|
||||
if (!iso) return null
|
||||
const due = new Date(iso)
|
||||
const today = startOfDay(new Date())
|
||||
const dueDay = startOfDay(due)
|
||||
const days = Math.round((dueDay.getTime() - today.getTime()) / 86400000)
|
||||
const overdue = days < 0
|
||||
let label: string
|
||||
if (days === 0) label = 'Today'
|
||||
else if (days === 1) label = 'Tomorrow'
|
||||
else if (days === -1) label = 'Yesterday'
|
||||
else if (days < 0) label = `${-days} days ago`
|
||||
else if (days < 7) label = due.toLocaleDateString('en-GB', { weekday: 'short' })
|
||||
else label = due.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
|
||||
const hasTime = due.getHours() !== 0 || due.getMinutes() !== 0
|
||||
if (hasTime) label += ' ' + due.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||
return { label, overdue }
|
||||
}
|
||||
|
||||
// Convert an ISO timestamp to the value a datetime-local input expects.
|
||||
export function toLocalInput(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
32
frontend/src/context/ListsContext.tsx
Normal file
32
frontend/src/context/ListsContext.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
import type { List } from '../types'
|
||||
import { fetchLists } from '../api'
|
||||
|
||||
interface ListsCtx {
|
||||
lists: List[]
|
||||
loading: boolean
|
||||
reload: () => Promise<void>
|
||||
}
|
||||
|
||||
const Ctx = createContext<ListsCtx | null>(null)
|
||||
|
||||
export function useLists() {
|
||||
const ctx = useContext(Ctx)
|
||||
if (!ctx) throw new Error('useLists must be used inside ListsProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function ListsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [lists, setLists] = useState<List[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const data = await fetchLists().catch(() => [])
|
||||
setLists(data)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { reload() }, [reload])
|
||||
|
||||
return <Ctx.Provider value={{ lists, loading, reload }}>{children}</Ctx.Provider>
|
||||
}
|
||||
57
frontend/src/hooks/usePushSubscription.ts
Normal file
57
frontend/src/hooks/usePushSubscription.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { useEffect } from 'react'
|
||||
import type { User } from '../types'
|
||||
|
||||
const BASE = '/tasks/api'
|
||||
|
||||
function urlBase64ToUint8Array(base64: string): ArrayBuffer {
|
||||
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
|
||||
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const raw = atob(b64)
|
||||
const buf = new Uint8Array(raw.length)
|
||||
for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i)
|
||||
return buf.buffer
|
||||
}
|
||||
|
||||
export function usePushSubscription(user: User) {
|
||||
useEffect(() => {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return
|
||||
if (Notification.permission === 'denied') return
|
||||
|
||||
async function subscribe() {
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready
|
||||
|
||||
const keyRes = await fetch(`${BASE}/push/vapid-key`, { credentials: 'include' })
|
||||
if (!keyRes.ok) return
|
||||
const { publicKey } = await keyRes.json() as { publicKey: string }
|
||||
|
||||
let sub = await reg.pushManager.getSubscription()
|
||||
|
||||
if (!sub) {
|
||||
if (Notification.permission === 'default') {
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') return
|
||||
} else if (Notification.permission !== 'granted') {
|
||||
return
|
||||
}
|
||||
sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
})
|
||||
}
|
||||
|
||||
// Always re-POST so subscription is tied to current user (handles re-login)
|
||||
await fetch(`${BASE}/push/subscribe`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(sub.toJSON()),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[push] subscription failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
subscribe()
|
||||
}, [user.email])
|
||||
}
|
||||
43
frontend/src/hooks/useVersionCheck.ts
Normal file
43
frontend/src/hooks/useVersionCheck.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
const POLL_MS = 2 * 60 * 1000
|
||||
|
||||
export function useVersionCheck(healthUrl: string) {
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let seenVersion: string | null = null
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
const res = await fetch(healthUrl, { cache: 'no-store' })
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
const v: string | undefined = data.version
|
||||
if (!v) return
|
||||
if (seenVersion === null) {
|
||||
seenVersion = v
|
||||
} else if (v !== seenVersion) {
|
||||
setUpdateAvailable(true)
|
||||
}
|
||||
} catch {
|
||||
// network error — skip silently
|
||||
}
|
||||
}
|
||||
|
||||
check()
|
||||
const interval = setInterval(check, POLL_MS)
|
||||
|
||||
function onVisible() {
|
||||
if (document.visibilityState === 'visible') check()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
}
|
||||
}, [healthUrl])
|
||||
|
||||
return updateAvailable
|
||||
}
|
||||
563
frontend/src/index.css
Normal file
563
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,563 @@
|
|||
/* Stack design system tokens — include verbatim in every app */
|
||||
:root {
|
||||
--navy: #1a1a2e;
|
||||
--navy-dark: #0f0f20;
|
||||
--gold: #c9a84c;
|
||||
--gold-light: #e8c96d;
|
||||
--surface: rgba(255,255,255,0.07);
|
||||
--surface-2: rgba(255,255,255,0.08);
|
||||
--text: rgba(255,255,255,0.88);
|
||||
--text-muted: rgba(255,255,255,0.48);
|
||||
--body-bg: #f4f5f7;
|
||||
--card-bg: #ffffff;
|
||||
--card-border: #e4e8ee;
|
||||
--text-dark: #1e293b;
|
||||
--text-mid: #64748b;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
|
||||
--danger: #dc2626;
|
||||
--radius: 10px;
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
body { background: var(--body-bg); color: var(--text-dark); font-family: var(--font); }
|
||||
|
||||
/* App theme + semantic tokens */
|
||||
:root {
|
||||
--app-primary: #0d9488;
|
||||
--app-primary-light: #14b8a6;
|
||||
|
||||
--prio-low: #64748b;
|
||||
--prio-medium: #2563eb;
|
||||
--prio-high: #dc2626;
|
||||
|
||||
--st-todo: #64748b;
|
||||
--st-doing: #2563eb;
|
||||
--st-done: #16a34a;
|
||||
|
||||
--danger-bg: #fef2f2;
|
||||
--warn-bg: #fffbeb;
|
||||
--ok-bg: #f0fdf4;
|
||||
|
||||
--sidebar-w: 240px;
|
||||
--topbar-h: 56px;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; font-size: 14px; }
|
||||
|
||||
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--card-border); border-radius: 2px; }
|
||||
|
||||
/* ── App shell ─────────────────────────────────────────────── */
|
||||
.app-shell { display: flex; height: 100vh; overflow: hidden; }
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-w);
|
||||
background: var(--navy);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sidebar-logo {
|
||||
padding: 20px 16px 12px;
|
||||
color: var(--gold);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .05em;
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.sidebar-logo svg { opacity: .8; }
|
||||
.sidebar-nav { flex: 1; padding: 8px 0; }
|
||||
.sidebar-nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 13.5px;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.sidebar-nav a:hover { background: var(--surface); color: var(--text); }
|
||||
.sidebar-nav a.active { background: rgba(201,168,76,.1); color: var(--gold); }
|
||||
.sidebar-user {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--surface-2);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
display: none;
|
||||
height: var(--topbar-h);
|
||||
background: var(--navy);
|
||||
color: var(--text);
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.top-bar-title { flex: 1; font-size: 15px; font-weight: 600; color: var(--gold); }
|
||||
.top-bar-nav { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; }
|
||||
.top-bar-nav::-webkit-scrollbar { display: none; }
|
||||
.top-bar-nav a {
|
||||
color: var(--text-muted);
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.top-bar-nav a.active { color: var(--gold); }
|
||||
|
||||
.page-content { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0; left: 0; bottom: 0;
|
||||
z-index: 200;
|
||||
transform: translateX(calc(-1 * var(--sidebar-w)));
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
.app-shell.menu-open .sidebar { transform: translateX(0); }
|
||||
.top-bar { display: flex; }
|
||||
.app-shell { flex-direction: column; }
|
||||
.field-row { flex-direction: column; }
|
||||
}
|
||||
|
||||
.menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 199;
|
||||
}
|
||||
|
||||
.top-bar-burger {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Page chrome ───────────────────────────────────────────── */
|
||||
.page { padding: 20px; max-width: 1100px; width: 100%; margin: 0 auto; }
|
||||
.page-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.page-header h1 { font-size: 18px; margin: 0; flex: 1; }
|
||||
|
||||
/* ── Buttons ───────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--card-border);
|
||||
background: var(--card-bg);
|
||||
color: var(--text-dark);
|
||||
border-radius: var(--radius);
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
transition: background .12s, border-color .12s;
|
||||
}
|
||||
.btn:hover { border-color: var(--text-mid); }
|
||||
.btn:disabled { opacity: .5; cursor: default; }
|
||||
.btn-primary { background: var(--gold); border-color: var(--gold); color: var(--navy); font-weight: 600; }
|
||||
.btn-primary:hover { background: var(--gold-light); border-color: var(--gold-light); }
|
||||
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 8px; }
|
||||
|
||||
/* ── Forms ─────────────────────────────────────────────────── */
|
||||
.field { margin-bottom: 12px; }
|
||||
.field label { display: block; font-size: 12px; font-weight: 600; color: var(--text-mid); margin-bottom: 4px; }
|
||||
.field input[type="text"], .field input[type="email"], .field input[type="date"],
|
||||
.field input[type="number"], .field select, .field textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13.5px;
|
||||
font-family: var(--font);
|
||||
color: var(--text-dark);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
.field textarea { min-height: 72px; resize: vertical; }
|
||||
.field-row { display: flex; gap: 12px; }
|
||||
.field-row > .field { flex: 1; }
|
||||
.field-check { display: flex; align-items: center; gap: 8px; font-size: 13.5px; cursor: pointer; }
|
||||
.field-check input { width: 16px; height: 16px; accent-color: var(--gold); }
|
||||
.field-hint { font-size: 11.5px; color: var(--text-mid); margin-top: 3px; }
|
||||
|
||||
/* ── Cards & lists ─────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow .12s;
|
||||
}
|
||||
.task-card:hover { box-shadow: var(--shadow-md); }
|
||||
.task-card.high { border-left: 4px solid var(--prio-high); }
|
||||
.task-card.done { opacity: .55; }
|
||||
.task-card-main { flex: 1; min-width: 0; }
|
||||
.task-card-title { font-weight: 600; font-size: 14px; margin-bottom: 2px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.task-card-meta { font-size: 12px; color: var(--text-mid); display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||
.task-card-side { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex-shrink: 0; }
|
||||
|
||||
/* ── Badges ────────────────────────────────────────────────── */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 20px;
|
||||
padding: 2px 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-prio-low { background: var(--prio-low); }
|
||||
.badge-prio-medium { background: var(--prio-medium); }
|
||||
.badge-prio-high { background: var(--prio-high); }
|
||||
|
||||
.badge-st-todo { background: var(--st-todo); }
|
||||
.badge-st-doing { background: var(--st-doing); }
|
||||
.badge-st-done { background: var(--st-done); }
|
||||
|
||||
.badge-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--card-border);
|
||||
color: var(--text-mid);
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-unusable { background: var(--danger); }
|
||||
|
||||
/* ── Filter chips ──────────────────────────────────────────── */
|
||||
.filter-row { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 4px; }
|
||||
.filter-row .filter-section { flex: 1; min-width: 0; margin-bottom: 0; }
|
||||
.filter-row .sort-chip { flex-shrink: 0; align-self: center; white-space: nowrap; }
|
||||
.filter-section { margin-bottom: 10px; }
|
||||
.filter-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 12px;
|
||||
color: var(--text-mid);
|
||||
transition: border-color .12s;
|
||||
}
|
||||
.filter-section-header:hover { border-color: var(--gold); }
|
||||
.filter-section-label { font-weight: 600; color: var(--text-dark); white-space: nowrap; }
|
||||
.filter-section-summary { flex: 1; color: var(--gold); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.filter-section-summary.muted { color: var(--text-mid); font-weight: 400; }
|
||||
.filter-section-chevron { flex-shrink: 0; transition: transform .18s; }
|
||||
.filter-section-chevron.open { transform: rotate(180deg); }
|
||||
.chip-bar { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0 4px; align-items: center; }
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--card-border);
|
||||
background: var(--card-bg);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-mid);
|
||||
user-select: none;
|
||||
font-family: var(--font);
|
||||
transition: all .12s;
|
||||
}
|
||||
.chip:hover { border-color: var(--gold); color: var(--text-dark); }
|
||||
.chip.active { background: var(--gold); border-color: var(--gold); color: var(--navy); font-weight: 600; }
|
||||
|
||||
/* ── Modal ─────────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15,15,32,.55);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 24px 12px;
|
||||
z-index: 100;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-md);
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
padding: 20px;
|
||||
margin: auto 0;
|
||||
}
|
||||
.modal-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 14px; }
|
||||
.modal-header h2 { font-size: 16px; margin: 0; flex: 1; }
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-mid);
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
}
|
||||
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; flex-wrap: wrap; }
|
||||
.modal-footer-actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--card-border); }
|
||||
.btn-ghost-sm {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 4px 8px; border-radius: 6px; border: none; background: none;
|
||||
font-size: 12px; color: var(--text-mid); cursor: pointer; font-family: var(--font);
|
||||
transition: color .12s, background .12s;
|
||||
}
|
||||
.btn-ghost-sm:hover { color: var(--text-dark); background: var(--card-border); }
|
||||
.btn-ghost-danger { color: var(--danger); }
|
||||
.btn-ghost-danger:hover { color: var(--danger); background: var(--danger-bg); }
|
||||
|
||||
/* ── Timeline / thread ─────────────────────────────────────── */
|
||||
.timeline { margin: 8px 0; }
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.timeline-item:last-child { border-bottom: none; }
|
||||
.timeline-icon { color: var(--text-mid); flex-shrink: 0; margin-top: 1px; }
|
||||
.timeline-body { flex: 1; min-width: 0; }
|
||||
.timeline-note { white-space: pre-wrap; }
|
||||
.timeline-meta { font-size: 11.5px; color: var(--text-mid); margin-top: 2px; }
|
||||
.timeline-photo-link { color: var(--gold); cursor: pointer; font-size: 12px; font-weight: 500; }
|
||||
.timeline-photo-link:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Photos ────────────────────────────────────────────────── */
|
||||
.photo-grid { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
|
||||
.photo-thumb {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--card-border);
|
||||
cursor: pointer;
|
||||
}
|
||||
.photo-thumb-uploading {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--card-border); color: var(--text-mid);
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spin { animation: spin .75s linear infinite; }
|
||||
.photo-thumb-wrap { position: relative; }
|
||||
.photo-del {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Lightbox ──────────────────────────────────────────────── */
|
||||
.lightbox-overlay {
|
||||
position: fixed; inset: 0; z-index: 200;
|
||||
background: rgba(0,0,0,.92);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.lightbox-img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 4px; }
|
||||
.lightbox-close {
|
||||
position: absolute; top: 16px; right: 16px;
|
||||
background: rgba(255,255,255,.15); border: none; border-radius: 50%;
|
||||
width: 38px; height: 38px; display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; color: #fff; transition: background .12s;
|
||||
}
|
||||
.lightbox-close:hover { background: rgba(255,255,255,.28); }
|
||||
|
||||
/* ── Tables ────────────────────────────────────────────────── */
|
||||
.table-wrap { overflow-x: auto; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
|
||||
table.data { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
table.data th {
|
||||
text-align: left;
|
||||
padding: 9px 12px;
|
||||
font-size: 11.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
color: var(--text-mid);
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.data td { padding: 9px 12px; border-bottom: 1px solid var(--card-border); vertical-align: top; }
|
||||
table.data tr:last-child td { border-bottom: none; }
|
||||
table.data tr.clickable { cursor: pointer; }
|
||||
table.data tr.clickable:hover td { background: var(--body-bg); }
|
||||
|
||||
/* ── Stats strip ───────────────────────────────────────────── */
|
||||
.stats-strip { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.stat-box {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 10px 16px;
|
||||
min-width: 110px;
|
||||
}
|
||||
.stat-box .stat-value { font-size: 18px; font-weight: 700; }
|
||||
.stat-box .stat-label { font-size: 11px; color: var(--text-mid); text-transform: uppercase; letter-spacing: .04em; }
|
||||
|
||||
/* ── Misc ──────────────────────────────────────────────────── */
|
||||
.empty-state { text-align: center; color: var(--text-mid); padding: 40px 16px; font-size: 13.5px; }
|
||||
.error-banner {
|
||||
background: var(--danger-bg);
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); margin: 18px 0 8px; }
|
||||
.muted { color: var(--text-mid); }
|
||||
.overdue { color: var(--danger); font-weight: 600; }
|
||||
|
||||
/* Sidebar scrollbar */
|
||||
.nav-scroll::-webkit-scrollbar,
|
||||
.sidebar::-webkit-scrollbar,
|
||||
.sidebar-nav::-webkit-scrollbar { width: 4px; }
|
||||
.nav-scroll::-webkit-scrollbar-track,
|
||||
.sidebar::-webkit-scrollbar-track,
|
||||
.sidebar-nav::-webkit-scrollbar-track { background: transparent; }
|
||||
.nav-scroll::-webkit-scrollbar-thumb,
|
||||
.sidebar::-webkit-scrollbar-thumb,
|
||||
.sidebar-nav::-webkit-scrollbar-thumb { background: rgba(201,168,76,0.35); border-radius: 2px; }
|
||||
.nav-scroll::-webkit-scrollbar-thumb:hover,
|
||||
.sidebar::-webkit-scrollbar-thumb:hover,
|
||||
.sidebar-nav::-webkit-scrollbar-thumb:hover { background: rgba(201,168,76,0.65); }
|
||||
.nav-scroll, .sidebar, .sidebar-nav { scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.35) transparent; }
|
||||
|
||||
/* ── Tasks: list nav (in sidebar) ──────────────────────────── */
|
||||
.list-nav { padding: 4px 0 8px; border-top: 1px solid var(--surface-2); margin-top: 4px; }
|
||||
.list-nav-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 16px 4px; color: var(--text-muted);
|
||||
font-size: 11px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase;
|
||||
}
|
||||
.list-nav-add { background: none; border: none; color: var(--text-muted); cursor: pointer; display: flex; padding: 2px; }
|
||||
.list-nav-add:hover { color: var(--gold); }
|
||||
.list-nav a {
|
||||
display: flex; align-items: center; gap: 10px; padding: 8px 16px;
|
||||
color: var(--text-muted); text-decoration: none; font-size: 13.5px;
|
||||
}
|
||||
.list-nav a:hover { background: var(--surface); color: var(--text); }
|
||||
.list-nav a.active { background: rgba(201,168,76,.1); color: var(--gold); }
|
||||
.list-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||||
.list-nav a .list-count { margin-left: auto; font-size: 11px; color: var(--text-muted); }
|
||||
|
||||
/* ── Tasks: inline add + rows ──────────────────────────────── */
|
||||
.task-add {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: var(--card-bg); border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius); box-shadow: var(--shadow-sm);
|
||||
padding: 4px 10px; margin-bottom: 12px;
|
||||
}
|
||||
.task-add input {
|
||||
flex: 1; border: none; outline: none; background: none;
|
||||
font-size: 14px; font-family: var(--font); color: var(--text-dark); padding: 8px 0;
|
||||
}
|
||||
.task-row {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
background: var(--card-bg); border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius); box-shadow: var(--shadow-sm);
|
||||
padding: 10px 14px; margin-bottom: 6px; cursor: pointer;
|
||||
transition: box-shadow .12s;
|
||||
}
|
||||
.task-row:hover { box-shadow: var(--shadow-md); }
|
||||
.task-row.high { border-left: 3px solid var(--prio-high); }
|
||||
.task-row.done .task-row-title { text-decoration: line-through; color: var(--text-mid); }
|
||||
.task-row-main { flex: 1; min-width: 0; }
|
||||
.task-row-title { font-size: 14px; font-weight: 500; }
|
||||
.task-row-meta { font-size: 11.5px; color: var(--text-mid); display: flex; gap: 10px; flex-wrap: wrap; margin-top: 2px; align-items: center; }
|
||||
.task-row-drag { color: var(--card-border); cursor: grab; display: flex; }
|
||||
.task-row.dragging { opacity: .4; }
|
||||
|
||||
/* Round checkbox */
|
||||
.check {
|
||||
width: 20px; height: 20px; border-radius: 50%; flex-shrink: 0;
|
||||
border: 2px solid var(--card-border); background: none; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; color: #fff; padding: 0;
|
||||
transition: background .12s, border-color .12s;
|
||||
}
|
||||
.check:hover { border-color: var(--st-done); }
|
||||
.check.checked { background: var(--st-done); border-color: var(--st-done); }
|
||||
.check.doing { border-color: var(--st-doing); color: var(--st-doing); }
|
||||
|
||||
.due-chip { color: var(--text-mid); }
|
||||
.due-chip.overdue { color: var(--danger); font-weight: 600; }
|
||||
|
||||
/* ── Slide-over drawer (task detail) ───────────────────────── */
|
||||
.drawer-overlay { position: fixed; inset: 0; background: rgba(15,15,32,.4); z-index: 150; }
|
||||
.drawer {
|
||||
position: fixed; top: 0; right: 0; bottom: 0; z-index: 151;
|
||||
width: 440px; max-width: 100vw; background: var(--body-bg);
|
||||
box-shadow: -4px 0 24px rgba(0,0,0,.15);
|
||||
display: flex; flex-direction: column; overflow-y: auto;
|
||||
animation: drawer-in .2s ease;
|
||||
}
|
||||
@keyframes drawer-in { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
||||
.drawer-head { display: flex; align-items: flex-start; gap: 10px; padding: 16px; background: var(--card-bg); border-bottom: 1px solid var(--card-border); }
|
||||
.drawer-head h2 { font-size: 16px; margin: 0; flex: 1; }
|
||||
.drawer-body { padding: 16px; flex: 1; }
|
||||
|
||||
/* Checklist */
|
||||
.checklist-item { display: flex; align-items: center; gap: 10px; padding: 5px 0; }
|
||||
.checklist-item .check { width: 18px; height: 18px; }
|
||||
.checklist-item.done span { text-decoration: line-through; color: var(--text-mid); }
|
||||
.checklist-item span { flex: 1; font-size: 13.5px; }
|
||||
.checklist-del { background: none; border: none; color: var(--text-mid); cursor: pointer; display: flex; padding: 2px; }
|
||||
.checklist-del:hover { color: var(--danger); }
|
||||
.checklist-progress { height: 5px; background: var(--card-border); border-radius: 3px; overflow: hidden; margin: 6px 0 10px; }
|
||||
.checklist-progress > div { height: 100%; background: var(--st-done); }
|
||||
|
||||
/* ── Board (kanban) ────────────────────────────────────────── */
|
||||
.board { display: flex; gap: 12px; align-items: flex-start; overflow-x: auto; padding-bottom: 8px; }
|
||||
.board-col { flex: 1; min-width: 240px; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); padding: 10px; }
|
||||
.board-col.drop-target { border-color: var(--gold); background: var(--warn-bg); }
|
||||
.board-col-head { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); margin-bottom: 10px; display: flex; align-items: center; gap: 6px; }
|
||||
.board-col-count { margin-left: auto; font-weight: 500; }
|
||||
|
||||
/* Agenda groups (Today view) */
|
||||
.agenda-group-title { font-size: 13px; font-weight: 700; color: var(--text-dark); margin: 16px 0 8px; display: flex; align-items: center; gap: 8px; }
|
||||
.agenda-group-title.overdue { color: var(--danger); }
|
||||
18
frontend/src/main.tsx
Normal file
18
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
|
||||
if (new URLSearchParams(window.location.search).has('install')) {
|
||||
window.addEventListener('beforeinstallprompt', e => {
|
||||
e.preventDefault()
|
||||
;(e as Event & { prompt: () => Promise<void> }).prompt()
|
||||
}, { once: true })
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
78
frontend/src/pages/BoardView.tsx
Normal file
78
frontend/src/pages/BoardView.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { Columns3 } from 'lucide-react'
|
||||
import type { Task, Status } from '../types'
|
||||
import { STATUSES, STATUS_LABELS } from '../types'
|
||||
import { fetchTasks, updateTask } from '../api'
|
||||
import { useTaskDrawer } from '../components/TaskDrawer'
|
||||
import { PriorityBadge, dueInfo } from '../components/shared'
|
||||
|
||||
export default function BoardView() {
|
||||
const { openTask, changeVersion } = useTaskDrawer()
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [over, setOver] = useState<Status | null>(null)
|
||||
const dragId = useRef<number | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
fetchTasks({}).then(setTasks).catch(e => setError(e.message))
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load, changeVersion])
|
||||
|
||||
async function drop(status: Status) {
|
||||
const id = dragId.current
|
||||
dragId.current = null
|
||||
setOver(null)
|
||||
if (id === null) return
|
||||
const task = tasks.find(t => t.id === id)
|
||||
if (!task || task.status === status) return
|
||||
setTasks(ts => ts.map(t => t.id === id ? { ...t, status } : t))
|
||||
await updateTask(id, { status }).catch(() => load())
|
||||
load()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page" style={{ maxWidth: 1200 }}>
|
||||
<div className="page-header">
|
||||
<h1><Columns3 size={18} strokeWidth={1.75} style={{ verticalAlign: -3, marginRight: 6 }} />Board</h1>
|
||||
</div>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="board">
|
||||
{STATUSES.map(status => {
|
||||
const items = tasks.filter(t => t.status === status)
|
||||
return (
|
||||
<div key={status}
|
||||
className={`board-col${over === status ? ' drop-target' : ''}`}
|
||||
onDragOver={e => { e.preventDefault(); setOver(status) }}
|
||||
onDragLeave={() => setOver(o => o === status ? null : o)}
|
||||
onDrop={() => drop(status)}>
|
||||
<div className="board-col-head">
|
||||
{STATUS_LABELS[status]}
|
||||
<span className="board-col-count">{items.length}</span>
|
||||
</div>
|
||||
{items.map(t => (
|
||||
<div key={t.id} className="card" style={{ cursor: 'grab', padding: '10px 12px' }}
|
||||
draggable
|
||||
onDragStart={() => { dragId.current = t.id }}
|
||||
onClick={() => openTask(t.id)}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 500, marginBottom: 4 }}>{t.title}</div>
|
||||
<div className="task-row-meta">
|
||||
{t.list_name && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span className="list-dot" style={{ background: t.list_colour, width: 8, height: 8 }} />
|
||||
{t.list_name}
|
||||
</span>
|
||||
)}
|
||||
{(() => { const d = dueInfo(t.due_at); return d && <span className={`due-chip${d.overdue ? ' overdue' : ''}`}>{d.label}</span> })()}
|
||||
<PriorityBadge priority={t.priority} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <div className="muted" style={{ fontSize: 12, padding: '4px 2px' }}>—</div>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
105
frontend/src/pages/ListView.tsx
Normal file
105
frontend/src/pages/ListView.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Plus, Pencil } from 'lucide-react'
|
||||
import type { Task, List } from '../types'
|
||||
import { fetchTasks, createTask, updateTask, reorderTasks } from '../api'
|
||||
import { useLists } from '../context/ListsContext'
|
||||
import { useTaskDrawer } from '../components/TaskDrawer'
|
||||
import TaskRow from '../components/TaskRow'
|
||||
import ListEditor from '../components/ListEditor'
|
||||
|
||||
export default function ListView() {
|
||||
const { id } = useParams()
|
||||
const listId = Number(id)
|
||||
const { lists } = useLists()
|
||||
const { openTask, changeVersion } = useTaskDrawer()
|
||||
const list = lists.find(l => l.id === listId)
|
||||
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [editing, setEditing] = useState(false)
|
||||
const dragId = useRef<number | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!listId) return
|
||||
fetchTasks({ list_id: listId }).then(setTasks).catch(e => setError(e.message))
|
||||
}, [listId])
|
||||
useEffect(() => { load() }, [load, changeVersion])
|
||||
|
||||
async function add() {
|
||||
if (!newTitle.trim()) return
|
||||
await createTask({ list_id: listId, title: newTitle.trim() })
|
||||
setNewTitle('')
|
||||
load()
|
||||
}
|
||||
async function toggle(t: Task) {
|
||||
await updateTask(t.id, { status: t.status === 'done' ? 'todo' : 'done' })
|
||||
load()
|
||||
}
|
||||
|
||||
// Native drag reorder within the open list.
|
||||
function onDragStart(taskId: number) { dragId.current = taskId }
|
||||
function onDrop(targetId: number) {
|
||||
const from = dragId.current
|
||||
dragId.current = null
|
||||
if (from === null || from === targetId) return
|
||||
const ids = tasks.map(t => t.id)
|
||||
const fromIdx = ids.indexOf(from)
|
||||
const toIdx = ids.indexOf(targetId)
|
||||
ids.splice(toIdx, 0, ids.splice(fromIdx, 1)[0])
|
||||
const reordered = ids.map(i => tasks.find(t => t.id === i)!).filter(Boolean)
|
||||
setTasks(reordered)
|
||||
reorderTasks(ids).catch(() => load())
|
||||
}
|
||||
|
||||
const open = tasks.filter(t => t.status !== 'done')
|
||||
const done = tasks.filter(t => t.status === 'done')
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>
|
||||
{list && <span className="list-dot" style={{ background: list.colour, width: 12, height: 12, display: 'inline-block', marginRight: 8, verticalAlign: 0 }} />}
|
||||
{list?.name ?? 'List'}
|
||||
</h1>
|
||||
{list && (
|
||||
<button className="btn btn-sm" onClick={() => setEditing(true)}>
|
||||
<Pencil size={13} strokeWidth={1.75} /> Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="task-add">
|
||||
<Plus size={18} strokeWidth={1.75} color="var(--text-mid)" />
|
||||
<input value={newTitle} placeholder="Add a task…"
|
||||
onChange={e => setNewTitle(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') add() }} />
|
||||
</div>
|
||||
|
||||
{open.map(t => (
|
||||
<TaskRow key={t.id} task={t} onToggle={toggle} onOpen={() => openTask(t.id)}
|
||||
draggable dragging={dragId.current === t.id}
|
||||
onDragStart={() => onDragStart(t.id)}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={() => onDrop(t.id)} />
|
||||
))}
|
||||
|
||||
{open.length === 0 && done.length === 0 && (
|
||||
<div className="empty-state">No tasks yet — add one above.</div>
|
||||
)}
|
||||
|
||||
{done.length > 0 && (
|
||||
<>
|
||||
<div className="section-title">Completed ({done.length})</div>
|
||||
{done.map(t => (
|
||||
<TaskRow key={t.id} task={t} onToggle={toggle} onOpen={() => openTask(t.id)} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{editing && list && <ListEditor list={list} onClose={() => setEditing(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
frontend/src/pages/Settings.tsx
Normal file
81
frontend/src/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Bell, BellOff } from 'lucide-react'
|
||||
import type { AppConfig } from '../types'
|
||||
import { fetchConfig, updateConfig } from '../api'
|
||||
import { useAuth } from '../components/AuthGate'
|
||||
import { can } from '../types'
|
||||
|
||||
export default function Settings() {
|
||||
const { user } = useAuth()
|
||||
const editable = can(user, 'settings')
|
||||
const [config, setConfig] = useState<AppConfig | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [perm, setPerm] = useState<NotificationPermission>(
|
||||
typeof Notification !== 'undefined' ? Notification.permission : 'denied'
|
||||
)
|
||||
|
||||
useEffect(() => { fetchConfig().then(setConfig).catch(e => setError(e.message)) }, [])
|
||||
|
||||
async function set(key: keyof AppConfig, value: boolean) {
|
||||
if (!config) return
|
||||
setConfig({ ...config, [key]: value })
|
||||
try { await updateConfig(key, value) }
|
||||
catch (e) { setError(e instanceof Error ? e.message : 'Save failed') }
|
||||
}
|
||||
|
||||
async function enablePush() {
|
||||
if (typeof Notification === 'undefined') return
|
||||
const p = await Notification.requestPermission()
|
||||
setPerm(p)
|
||||
}
|
||||
|
||||
if (!config) return <div className="page">{error ? <div className="error-banner">{error}</div> : 'Loading…'}</div>
|
||||
|
||||
return (
|
||||
<div className="page" style={{ maxWidth: 620 }}>
|
||||
<div className="page-header"><h1>Settings</h1></div>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="section-title">Reminders</div>
|
||||
<div className="card">
|
||||
<label className="field-check" style={{ marginBottom: 12 }}>
|
||||
<input type="checkbox" checked={config.remind_by_push} disabled={!editable}
|
||||
onChange={e => set('remind_by_push', e.target.checked)} />
|
||||
Send a push notification when a task reminder is due
|
||||
</label>
|
||||
<label className="field-check">
|
||||
<input type="checkbox" checked={config.remind_by_email} disabled={!editable}
|
||||
onChange={e => set('remind_by_email', e.target.checked)} />
|
||||
Also email me when a reminder is due
|
||||
</label>
|
||||
<div className="field-hint" style={{ marginTop: 8 }}>
|
||||
Reminders are checked every few minutes, so they fire close to the time you set — not to the exact second.
|
||||
Email uses the stack SMTP settings (Settings app → integrations).
|
||||
{!editable && ' Only administrators can change these.'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-title">This device</div>
|
||||
<div className="card">
|
||||
{perm === 'granted' ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--st-done)' }}>
|
||||
<Bell size={16} strokeWidth={1.75} /> Push notifications are enabled on this device.
|
||||
</div>
|
||||
) : perm === 'denied' ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-mid)' }}>
|
||||
<BellOff size={16} strokeWidth={1.75} /> Notifications are blocked in your browser settings for this site.
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ marginBottom: 10, color: 'var(--text-mid)' }}>
|
||||
Allow notifications so reminders can reach you on this device.
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={enablePush}>
|
||||
<Bell size={14} strokeWidth={1.75} /> Enable notifications
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
frontend/src/pages/TodayView.tsx
Normal file
62
frontend/src/pages/TodayView.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { CalendarDays } from 'lucide-react'
|
||||
import type { Task } from '../types'
|
||||
import { fetchTasks, updateTask } from '../api'
|
||||
import { useTaskDrawer } from '../components/TaskDrawer'
|
||||
import TaskRow from '../components/TaskRow'
|
||||
|
||||
function startOfDay(d: Date) { const x = new Date(d); x.setHours(0, 0, 0, 0); return x }
|
||||
|
||||
export default function TodayView() {
|
||||
const { openTask, changeVersion } = useTaskDrawer()
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
fetchTasks({ view: 'today' }).then(setTasks).catch(e => setError(e.message))
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load, changeVersion])
|
||||
|
||||
async function toggle(t: Task) {
|
||||
await updateTask(t.id, { status: t.status === 'done' ? 'todo' : 'done' })
|
||||
load()
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const todayStart = startOfDay(now)
|
||||
const tomorrowStart = new Date(todayStart); tomorrowStart.setDate(tomorrowStart.getDate() + 1)
|
||||
|
||||
const overdue = tasks.filter(t => t.due_at && new Date(t.due_at) < todayStart)
|
||||
const today = tasks.filter(t => t.due_at && new Date(t.due_at) >= todayStart && new Date(t.due_at) < tomorrowStart)
|
||||
const upcoming = tasks.filter(t => t.due_at && new Date(t.due_at) >= tomorrowStart)
|
||||
|
||||
const groups: Array<{ key: string; title: string; items: Task[]; overdue?: boolean }> = [
|
||||
{ key: 'overdue', title: 'Overdue', items: overdue, overdue: true },
|
||||
{ key: 'today', title: 'Today', items: today },
|
||||
{ key: 'upcoming', title: 'Next 7 days', items: upcoming },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1><CalendarDays size={18} strokeWidth={1.75} style={{ verticalAlign: -3, marginRight: 6 }} />Today</h1>
|
||||
</div>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{tasks.length === 0 && (
|
||||
<div className="empty-state">Nothing due in the next 7 days. Enjoy the calm. ☕</div>
|
||||
)}
|
||||
|
||||
{groups.map(g => g.items.length > 0 && (
|
||||
<div key={g.key}>
|
||||
<div className={`agenda-group-title${g.overdue ? ' overdue' : ''}`}>
|
||||
{g.title} <span className="muted" style={{ fontWeight: 400 }}>· {g.items.length}</span>
|
||||
</div>
|
||||
{g.items.map(t => (
|
||||
<TaskRow key={t.id} task={t} showList onToggle={toggle} onOpen={() => openTask(t.id)} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
35
frontend/src/sw.js
Normal file
35
frontend/src/sw.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'
|
||||
import { NavigationRoute, registerRoute } from 'workbox-routing'
|
||||
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
|
||||
// SPA fallback: navigate requests that don't match a cached asset serve index.html
|
||||
registerRoute(
|
||||
new NavigationRoute(createHandlerBoundToURL('/tasks/index.html'), {
|
||||
denylist: [/\/api\//],
|
||||
})
|
||||
)
|
||||
|
||||
self.addEventListener('push', event => {
|
||||
const data = event.data?.json() ?? {}
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'Tasks', {
|
||||
body: data.body || '',
|
||||
icon: '/tasks/icons/icon-192.png',
|
||||
badge: '/tasks/icons/icon-192.png',
|
||||
data: { url: data.url || '/tasks/' },
|
||||
tag: data.tag || 'tasks',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.notification.close()
|
||||
const url = event.notification.data?.url || '/tasks/'
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window' }).then(list => {
|
||||
const existing = list.find(c => c.url.includes('/tasks/') && 'focus' in c)
|
||||
return existing ? existing.focus() : clients.openWindow(url)
|
||||
})
|
||||
)
|
||||
})
|
||||
97
frontend/src/types.ts
Normal file
97
frontend/src/types.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
export type Priority = 'low' | 'medium' | 'high'
|
||||
export type Status = 'todo' | 'doing' | 'done'
|
||||
export type RecurRule = 'daily' | 'weekdays' | 'weekly' | 'monthly'
|
||||
|
||||
export const PRIORITIES: Priority[] = ['low', 'medium', 'high']
|
||||
export const PRIORITY_LABELS: Record<Priority, string> = { low: 'Low', medium: 'Medium', high: 'High' }
|
||||
|
||||
export const STATUSES: Status[] = ['todo', 'doing', 'done']
|
||||
export const STATUS_LABELS: Record<Status, string> = { todo: 'To do', doing: 'Doing', done: 'Done' }
|
||||
|
||||
export const RECUR_RULES: RecurRule[] = ['daily', 'weekdays', 'weekly', 'monthly']
|
||||
export const RECUR_LABELS: Record<RecurRule, string> = {
|
||||
daily: 'Every day',
|
||||
weekdays: 'Every weekday',
|
||||
weekly: 'Every week',
|
||||
monthly: 'Every month',
|
||||
}
|
||||
|
||||
// Preset list colours (user data, not design-system chrome).
|
||||
export const LIST_COLOURS = ['#c9a84c', '#0d9488', '#2563eb', '#7c3aed', '#dc2626', '#d97706', '#16a34a', '#64748b']
|
||||
|
||||
export interface List {
|
||||
id: number
|
||||
owner_id: string
|
||||
name: string
|
||||
colour: string
|
||||
icon: string
|
||||
sort_order: number
|
||||
is_archived: boolean
|
||||
open_count: number
|
||||
total_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: number
|
||||
list_id: number
|
||||
owner_id: string
|
||||
created_by: string
|
||||
assigned_to: string | null
|
||||
title: string
|
||||
notes: string | null
|
||||
status: Status
|
||||
priority: Priority
|
||||
due_at: string | null
|
||||
remind_at: string | null
|
||||
reminded_at: string | null
|
||||
recur_rule: RecurRule | null
|
||||
completed_at: string | null
|
||||
sort_order: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
// joined / derived
|
||||
list_name?: string
|
||||
list_colour?: string
|
||||
item_count?: number
|
||||
item_done?: number
|
||||
}
|
||||
|
||||
export interface TaskItem {
|
||||
id: number
|
||||
task_id: number
|
||||
label: string
|
||||
is_done: boolean
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface TaskComment {
|
||||
id: number
|
||||
task_id: number
|
||||
author_email: string | null
|
||||
author_name: string | null
|
||||
body: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface TaskDetail extends Task {
|
||||
items: TaskItem[]
|
||||
comments: TaskComment[]
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
remind_by_push: boolean
|
||||
remind_by_email: boolean
|
||||
}
|
||||
|
||||
export interface User {
|
||||
user_id: number
|
||||
name: string
|
||||
email: string
|
||||
is_admin: boolean
|
||||
caps: string[] // bare slugs — verify?app=tasks strips the prefix
|
||||
}
|
||||
|
||||
export function can(user: User, cap: string): boolean {
|
||||
return user.is_admin || user.caps.includes(cap)
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
32
frontend/vite.config.ts
Normal file
32
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
base: '/tasks/',
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'sw.js',
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'Tasks',
|
||||
short_name: 'Tasks',
|
||||
start_url: '/tasks/',
|
||||
scope: '/tasks/',
|
||||
display: 'standalone',
|
||||
theme_color: '#0d9488',
|
||||
background_color: '#0d9488',
|
||||
icons: [
|
||||
{ src: '/tasks/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/tasks/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
],
|
||||
},
|
||||
injectManifest: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
58
seed-app.js
Normal file
58
seed-app.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
#!/usr/bin/env node
|
||||
// Run from tasks/ dir: DATABASE_URL=... node seed-app.js
|
||||
import pg from 'pg'
|
||||
|
||||
const { Pool } = pg
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port)
|
||||
VALUES ('tasks', 'Tasks', 'Personal task planner — lists, reminders and recurring to-dos', '/tasks', 'ListChecks', '#0d9488', 'Operations', '10.10.10.130', 3080)
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
base_path = EXCLUDED.base_path,
|
||||
icon = EXCLUDED.icon,
|
||||
theme_color = EXCLUDED.theme_color,
|
||||
category = EXCLUDED.category,
|
||||
internal_host = EXCLUDED.internal_host,
|
||||
internal_port = EXCLUDED.internal_port
|
||||
`)
|
||||
|
||||
// Seed capabilities. 'admin' stays reserved for a future "view/manage every
|
||||
// field of every user's tasks" surface — not built yet. 'reallocate' is the
|
||||
// narrower dispatcher power: see everyone's boards and move tasks between
|
||||
// people, without the full weight of editing/deleting someone else's task.
|
||||
await pool.query(`
|
||||
INSERT INTO app_capabilities (app_id, slug, name, description, sort_order)
|
||||
SELECT a.id, c.slug, c.name, c.description, c.sort_order
|
||||
FROM apps a, (VALUES
|
||||
('use', 'Use Tasks', 'Create and manage your own lists and tasks', 1),
|
||||
('assign', 'Assign Tasks', 'Hand off your own tasks to other users', 2),
|
||||
('reallocate', 'Reallocate Tasks', 'View every user''s board and move any task between people', 3),
|
||||
('admin', 'Manage All', 'View and manage every field of every user''s tasks (not yet built)', 4),
|
||||
('settings', 'Settings', 'Configure the shared status pipeline and categories', 5)
|
||||
) AS c(slug, name, description, sort_order)
|
||||
WHERE a.slug = 'tasks'
|
||||
ON CONFLICT (app_id, slug) DO NOTHING
|
||||
`)
|
||||
|
||||
// Grant 'use' to the Staff role if they have no tasks caps yet.
|
||||
await pool.query(`
|
||||
INSERT INTO role_capabilities (role_id, capability_id)
|
||||
SELECT r.id, ac.id
|
||||
FROM roles r
|
||||
JOIN app_capabilities ac ON ac.slug = 'use'
|
||||
JOIN apps a ON a.id = ac.app_id AND a.slug = 'tasks'
|
||||
WHERE r.name = 'Staff'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM role_capabilities rc2
|
||||
JOIN app_capabilities ac2 ON rc2.capability_id = ac2.id
|
||||
JOIN apps a2 ON ac2.app_id = a2.id AND a2.slug = 'tasks'
|
||||
WHERE rc2.role_id = r.id
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
`)
|
||||
|
||||
console.log('tasks app seeded.')
|
||||
await pool.end()
|
||||
Loading…
Add table
Add a link
Reference in a new issue