Notification updates
This commit is contained in:
26
CLAUDE.md
26
CLAUDE.md
@@ -19,6 +19,32 @@ Maintain professional documentation standards without emoji usage.
|
||||
- **Delete** old code when replacing it
|
||||
- **Meaningful names**: `userID` not `id`
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Case Standards
|
||||
| Layer | Convention | Example |
|
||||
|-------|------------|---------|
|
||||
| Database columns | snake_case | `user_id`, `created_at`, `is_active` |
|
||||
| Backend TypeScript types | camelCase | `userId`, `createdAt`, `isActive` |
|
||||
| API responses | camelCase | `{ "userId": "...", "createdAt": "..." }` |
|
||||
| Frontend TypeScript types | camelCase | `userId`, `createdAt`, `isActive` |
|
||||
|
||||
### Repository Pattern for Case Conversion
|
||||
All repositories MUST implement private `mapRow()` or similar mapper functions to convert database snake_case to TypeScript camelCase:
|
||||
|
||||
```typescript
|
||||
private mapRow(row: any): MyType {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id, // snake_case -> camelCase
|
||||
createdAt: row.created_at,
|
||||
isActive: row.is_active,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
All methods returning data to the API must use these mappers - never return raw database rows.
|
||||
|
||||
## Docker-First Implementation Strategy
|
||||
|
||||
### 1. Package.json Updates Only
|
||||
|
||||
684
backend/package-lock.json
generated
684
backend/package-lock.json
generated
@@ -24,6 +24,7 @@
|
||||
"js-yaml": "^4.1.0",
|
||||
"opossum": "^8.0.0",
|
||||
"pg": "^8.13.1",
|
||||
"resend": "^3.0.0",
|
||||
"winston": "^3.17.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
@@ -1094,6 +1095,102 @@
|
||||
"integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^5.1.2",
|
||||
"string-width-cjs": "npm:string-width@^4.2.0",
|
||||
"strip-ansi": "^7.0.1",
|
||||
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
|
||||
"wrap-ansi": "^8.1.0",
|
||||
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui/node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui/node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/cliui/node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eastasianwidth": "^0.2.0",
|
||||
"emoji-regex": "^9.2.2",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui/node_modules/strip-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.1.0",
|
||||
"string-width": "^5.0.1",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
|
||||
@@ -1582,6 +1679,12 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@one-ini/wasm": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
|
||||
"integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||
@@ -1598,6 +1701,47 @@
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-email/render": {
|
||||
"version": "0.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-0.0.16.tgz",
|
||||
"integrity": "sha512-wDaMy27xAq1cJHtSFptp0DTKPuV2GYhloqia95ub/DH9Dea1aWYsbdM918MOc/b/HvVS3w1z8DWzfAk13bGStQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"html-to-text": "9.0.5",
|
||||
"js-beautify": "^1.14.11",
|
||||
"react-promise-suspense": "0.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@selderee/plugin-htmlparser2": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
|
||||
"integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domhandler": "^5.0.3",
|
||||
"selderee": "^0.11.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.34.41",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz",
|
||||
@@ -2140,6 +2284,15 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
|
||||
"integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
@@ -2271,7 +2424,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -2281,7 +2433,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -2511,7 +2662,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
@@ -2900,7 +3050,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -2913,7 +3062,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-string": {
|
||||
@@ -2970,6 +3118,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
|
||||
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/component-emitter": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
|
||||
@@ -2987,6 +3144,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/config-chain": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
|
||||
"integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ini": "^1.3.4",
|
||||
"proto-list": "~1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -3047,7 +3214,6 @@
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
@@ -3101,7 +3267,6 @@
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -3175,6 +3340,61 @@
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -3189,6 +3409,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
@@ -3198,6 +3424,48 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/editorconfig": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
|
||||
"integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@one-ini/wasm": "0.1.1",
|
||||
"commander": "^10.0.0",
|
||||
"minimatch": "9.0.1",
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"bin": {
|
||||
"editorconfig": "bin/editorconfig"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/editorconfig/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/editorconfig/node_modules/minimatch": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
|
||||
"integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.267",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
|
||||
@@ -3237,7 +3505,6 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/enabled": {
|
||||
@@ -3246,6 +3513,18 @@
|
||||
"integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||
@@ -3907,6 +4186,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
"signal-exit": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child/node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
@@ -4240,6 +4547,41 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-to-text": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||
"integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@selderee/plugin-htmlparser2": "^0.11.0",
|
||||
"deepmerge": "^4.3.1",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"htmlparser2": "^8.0.2",
|
||||
"selderee": "^0.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1",
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/human-signals": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||
@@ -4352,6 +4694,12 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz",
|
||||
@@ -4435,7 +4783,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -4490,7 +4837,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
@@ -4564,6 +4910,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
|
||||
@@ -5148,11 +5509,84 @@
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/js-beautify": {
|
||||
"version": "1.15.4",
|
||||
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
|
||||
"integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"config-chain": "^1.1.13",
|
||||
"editorconfig": "^1.0.4",
|
||||
"glob": "^10.4.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"nopt": "^7.2.1"
|
||||
},
|
||||
"bin": {
|
||||
"css-beautify": "js/bin/css-beautify.js",
|
||||
"html-beautify": "js/bin/html-beautify.js",
|
||||
"js-beautify": "js/bin/js-beautify.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/js-beautify/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-beautify/node_modules/glob": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
"jackspeak": "^3.1.2",
|
||||
"minimatch": "^9.0.4",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^1.11.1"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-beautify/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-cookie": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
@@ -5277,6 +5711,15 @@
|
||||
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/leac": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
|
||||
"integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||
@@ -5404,6 +5847,18 @@
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
||||
@@ -5565,6 +6020,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/mnemonist": {
|
||||
"version": "0.40.3",
|
||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
|
||||
@@ -5660,6 +6124,21 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/nopt": {
|
||||
"version": "7.2.1",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz",
|
||||
"integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"abbrev": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"nopt": "bin/nopt.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -5815,6 +6294,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -5847,6 +6332,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parseley": {
|
||||
"version": "0.12.1",
|
||||
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
|
||||
"integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"leac": "^0.6.0",
|
||||
"peberminta": "^0.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -5871,7 +6369,6 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -5884,6 +6381,37 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^10.2.0",
|
||||
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry/node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/peberminta": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
|
||||
"integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/peek-readable": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz",
|
||||
@@ -6239,6 +6767,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/proto-list": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
|
||||
"integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@@ -6301,6 +6835,33 @@
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
@@ -6308,6 +6869,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-promise-suspense": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz",
|
||||
"integrity": "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-promise-suspense/node_modules/fast-deep-equal": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
|
||||
"integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
@@ -6416,6 +6992,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resend": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/resend/-/resend-3.5.0.tgz",
|
||||
"integrity": "sha512-bKu4LhXSecP6krvhfDzyDESApYdNfjirD5kykkT1xO0Cj9TKSiGh5Void4pGTs3Am+inSnp4dg0B5XzdwHBJOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-email/render": "0.0.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -6559,6 +7147,15 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
|
||||
@@ -6575,6 +7172,18 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/selderee": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
|
||||
"integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parseley": "^0.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
@@ -6597,7 +7206,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
@@ -6610,7 +7218,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -6853,7 +7460,21 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs": {
|
||||
"name": "string-width",
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -6868,7 +7489,19 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi-cjs": {
|
||||
"name": "strip-ansi",
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -7449,7 +8082,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
@@ -7532,6 +8164,24 @@
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs": {
|
||||
"name": "wrap-ansi",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"@fastify/autoload": "^6.0.1",
|
||||
"get-jwks": "^11.0.3",
|
||||
"file-type": "^16.5.4"
|
||||
"file-type": "^16.5.4",
|
||||
"resend": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@@ -23,6 +23,8 @@ const MIGRATION_ORDER = [
|
||||
'features/maintenance', // Depends on vehicles
|
||||
'features/stations', // Independent
|
||||
'features/admin', // Admin role management and oversight; depends on update_updated_at_column()
|
||||
'features/notifications', // Depends on maintenance and documents
|
||||
'features/user-profile', // User profile management; independent
|
||||
];
|
||||
|
||||
// Base directory where migrations are copied inside the image (set by Dockerfile)
|
||||
@@ -101,7 +103,7 @@ async function main() {
|
||||
// Wait for database to be reachable (handles cold starts)
|
||||
const waitForDb = async (timeoutMs = 60000) => {
|
||||
const start = Date.now();
|
||||
/* eslint-disable no-constant-condition */
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
|
||||
@@ -23,6 +23,8 @@ import { documentsRoutes } from './features/documents/api/documents.routes';
|
||||
import { maintenanceRoutes } from './features/maintenance';
|
||||
import { platformRoutes } from './features/platform';
|
||||
import { adminRoutes } from './features/admin/api/admin.routes';
|
||||
import { notificationsRoutes } from './features/notifications';
|
||||
import { userProfileRoutes } from './features/user-profile';
|
||||
import { pool } from './core/config/database';
|
||||
|
||||
async function buildApp(): Promise<FastifyInstance> {
|
||||
@@ -80,7 +82,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: process.env['NODE_ENV'],
|
||||
features: ['admin', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform']
|
||||
features: ['admin', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile']
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,7 +92,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
status: 'healthy',
|
||||
scope: 'api',
|
||||
timestamp: new Date().toISOString(),
|
||||
features: ['admin', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform']
|
||||
features: ['admin', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile']
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,6 +126,8 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
await app.register(communityStationsRoutes, { prefix: '/api' });
|
||||
await app.register(maintenanceRoutes, { prefix: '/api' });
|
||||
await app.register(adminRoutes, { prefix: '/api' });
|
||||
await app.register(notificationsRoutes, { prefix: '/api' });
|
||||
await app.register(userProfileRoutes, { prefix: '/api' });
|
||||
|
||||
// 404 handler
|
||||
app.setNotFoundHandler(async (_request, reply) => {
|
||||
|
||||
@@ -123,6 +123,7 @@ const secretsSchema = z.object({
|
||||
postgres_password: z.string(),
|
||||
auth0_client_secret: z.string(),
|
||||
google_maps_api_key: z.string(),
|
||||
resend_api_key: z.string(),
|
||||
});
|
||||
|
||||
type Config = z.infer<typeof configSchema>;
|
||||
@@ -171,6 +172,7 @@ class ConfigurationLoader {
|
||||
'postgres-password',
|
||||
'auth0-client-secret',
|
||||
'google-maps-api-key',
|
||||
'resend-api-key',
|
||||
];
|
||||
|
||||
for (const secretFile of secretFiles) {
|
||||
@@ -227,6 +229,9 @@ class ConfigurationLoader {
|
||||
},
|
||||
};
|
||||
|
||||
// Set RESEND_API_KEY in environment for EmailService
|
||||
process.env['RESEND_API_KEY'] = secrets.resend_api_key;
|
||||
|
||||
logger.info('Configuration loaded successfully', {
|
||||
configSource: 'yaml',
|
||||
secretsSource: 'files',
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* @ai-summary Fastify JWT authentication plugin using Auth0
|
||||
* @ai-context Validates JWT tokens against Auth0 JWKS endpoint
|
||||
* @ai-context Validates JWT tokens against Auth0 JWKS endpoint, hydrates userContext with profile
|
||||
*/
|
||||
import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
import buildGetJwks from 'get-jwks';
|
||||
import { appConfig } from '../config/config-loader';
|
||||
import { logger } from '../logging/logger';
|
||||
import { UserProfileRepository } from '../../features/user-profile/data/user-profile.repository';
|
||||
import { pool } from '../config/database';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -18,6 +20,7 @@ declare module 'fastify' {
|
||||
userContext?: {
|
||||
userId: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
isAdmin: boolean;
|
||||
adminRecord?: any;
|
||||
};
|
||||
@@ -70,21 +73,51 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize profile repository for user profile hydration
|
||||
const profileRepo = new UserProfileRepository(pool);
|
||||
|
||||
// Decorate with authenticate function that validates JWT
|
||||
fastify.decorate('authenticate', async function(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
|
||||
// Hydrate userContext with basic auth info
|
||||
const userId = request.user?.sub;
|
||||
|
||||
// Get or create user profile from database
|
||||
// This ensures we have reliable email/displayName for notifications
|
||||
let email = request.user?.email;
|
||||
let displayName: string | undefined;
|
||||
|
||||
try {
|
||||
const profile = await profileRepo.getOrCreate(userId, {
|
||||
email: request.user?.email || `${userId}@unknown.local`,
|
||||
displayName: request.user?.name || request.user?.nickname,
|
||||
});
|
||||
|
||||
// Use notificationEmail if set, otherwise fall back to profile email
|
||||
email = profile.notificationEmail || profile.email;
|
||||
displayName = profile.displayName || undefined;
|
||||
} catch (profileError) {
|
||||
// Log but don't fail auth if profile fetch fails
|
||||
logger.warn('Failed to fetch user profile', {
|
||||
userId: userId?.substring(0, 8) + '...',
|
||||
error: profileError instanceof Error ? profileError.message : 'Unknown error',
|
||||
});
|
||||
// Fall back to JWT email if available
|
||||
email = request.user?.email;
|
||||
}
|
||||
|
||||
// Hydrate userContext with profile data
|
||||
request.userContext = {
|
||||
userId,
|
||||
email: request.user?.email,
|
||||
email,
|
||||
displayName,
|
||||
isAdmin: false, // Default to false; admin status checked by admin guard
|
||||
};
|
||||
|
||||
logger.info('JWT authentication successful', {
|
||||
userId: userId?.substring(0, 8) + '...',
|
||||
hasEmail: !!email,
|
||||
audience: auth0Config.audience
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { UsersController } from './users.controller';
|
||||
import {
|
||||
CreateAdminInput,
|
||||
AdminAuth0SubInput,
|
||||
@@ -15,6 +16,14 @@ import {
|
||||
BulkDeleteCatalogInput,
|
||||
CatalogEntity
|
||||
} from './admin.validation';
|
||||
import {
|
||||
ListUsersQueryInput,
|
||||
UserAuth0SubInput,
|
||||
UpdateTierInput,
|
||||
DeactivateUserInput,
|
||||
UpdateProfileInput,
|
||||
PromoteToAdminInput,
|
||||
} from './users.validation';
|
||||
import { AdminRepository } from '../data/admin.repository';
|
||||
import { StationOversightService } from '../domain/station-oversight.service';
|
||||
import { StationsController } from './stations.controller';
|
||||
@@ -28,6 +37,7 @@ import { CommunityStationsController } from '../../stations/api/community-statio
|
||||
|
||||
export const adminRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const adminController = new AdminController();
|
||||
const usersController = new UsersController();
|
||||
|
||||
// Initialize station oversight dependencies
|
||||
const adminRepository = new AdminRepository(pool);
|
||||
@@ -99,6 +109,52 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
handler: adminController.bulkReinstateAdmins.bind(adminController)
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// User Management endpoints (subscription tiers, deactivation)
|
||||
// ============================================
|
||||
|
||||
// GET /api/admin/users - List all users with pagination and filters
|
||||
fastify.get<{ Querystring: ListUsersQueryInput }>('/admin/users', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.listUsers.bind(usersController)
|
||||
});
|
||||
|
||||
// GET /api/admin/users/:auth0Sub - Get single user details
|
||||
fastify.get<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.getUser.bind(usersController)
|
||||
});
|
||||
|
||||
// PATCH /api/admin/users/:auth0Sub/tier - Update subscription tier
|
||||
fastify.patch<{ Params: UserAuth0SubInput; Body: UpdateTierInput }>('/admin/users/:auth0Sub/tier', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.updateTier.bind(usersController)
|
||||
});
|
||||
|
||||
// PATCH /api/admin/users/:auth0Sub/deactivate - Soft delete user
|
||||
fastify.patch<{ Params: UserAuth0SubInput; Body: DeactivateUserInput }>('/admin/users/:auth0Sub/deactivate', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.deactivateUser.bind(usersController)
|
||||
});
|
||||
|
||||
// PATCH /api/admin/users/:auth0Sub/reactivate - Restore deactivated user
|
||||
fastify.patch<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub/reactivate', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.reactivateUser.bind(usersController)
|
||||
});
|
||||
|
||||
// PATCH /api/admin/users/:auth0Sub/profile - Update user email/displayName
|
||||
fastify.patch<{ Params: UserAuth0SubInput; Body: UpdateProfileInput }>('/admin/users/:auth0Sub/profile', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.updateProfile.bind(usersController)
|
||||
});
|
||||
|
||||
// PATCH /api/admin/users/:auth0Sub/promote - Promote user to admin
|
||||
fastify.patch<{ Params: UserAuth0SubInput; Body: PromoteToAdminInput }>('/admin/users/:auth0Sub/promote', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.promoteToAdmin.bind(usersController)
|
||||
});
|
||||
|
||||
// Phase 3: Catalog CRUD endpoints
|
||||
|
||||
// Makes endpoints
|
||||
|
||||
489
backend/src/features/admin/api/users.controller.ts
Normal file
489
backend/src/features/admin/api/users.controller.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* @ai-summary Fastify route handlers for admin user management API
|
||||
* @ai-context HTTP request/response handling for managing all application users (not just admins)
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { UserProfileService } from '../../user-profile/domain/user-profile.service';
|
||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||
import { AdminRepository } from '../data/admin.repository';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import {
|
||||
listUsersQuerySchema,
|
||||
userAuth0SubSchema,
|
||||
updateTierSchema,
|
||||
deactivateUserSchema,
|
||||
updateProfileSchema,
|
||||
promoteToAdminSchema,
|
||||
ListUsersQueryInput,
|
||||
UserAuth0SubInput,
|
||||
UpdateTierInput,
|
||||
DeactivateUserInput,
|
||||
UpdateProfileInput,
|
||||
PromoteToAdminInput,
|
||||
} from './users.validation';
|
||||
import { AdminService } from '../domain/admin.service';
|
||||
|
||||
export class UsersController {
|
||||
private userProfileService: UserProfileService;
|
||||
private adminService: AdminService;
|
||||
|
||||
constructor() {
|
||||
const userProfileRepository = new UserProfileRepository(pool);
|
||||
const adminRepository = new AdminRepository(pool);
|
||||
|
||||
this.userProfileService = new UserProfileService(userProfileRepository);
|
||||
this.userProfileService.setAdminRepository(adminRepository);
|
||||
this.adminService = new AdminService(adminRepository);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/users - List all users with pagination and filters
|
||||
*/
|
||||
async listUsers(
|
||||
request: FastifyRequest<{ Querystring: ListUsersQueryInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
if (!actorId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate and parse query params
|
||||
const parseResult = listUsersQuerySchema.safeParse(request.query);
|
||||
if (!parseResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: parseResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
const query = parseResult.data;
|
||||
const result = await this.userProfileService.listAllUsers(query);
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error) {
|
||||
logger.error('Error listing users', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to list users',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/users/:auth0Sub - Get single user details
|
||||
*/
|
||||
async getUser(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
if (!actorId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate path param
|
||||
const parseResult = userAuth0SubSchema.safeParse(request.params);
|
||||
if (!parseResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: parseResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = parseResult.data;
|
||||
const user = await this.userProfileService.getUserDetails(auth0Sub);
|
||||
|
||||
if (!user) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not found',
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(200).send(user);
|
||||
} catch (error) {
|
||||
logger.error('Error getting user details', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get user details',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/users/:auth0Sub/tier - Update subscription tier
|
||||
*/
|
||||
async updateTier(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: UpdateTierInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
if (!actorId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate path param
|
||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
||||
if (!paramsResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: paramsResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
// Validate body
|
||||
const bodyResult = updateTierSchema.safeParse(request.body);
|
||||
if (!bodyResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: bodyResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = paramsResult.data;
|
||||
const { subscriptionTier } = bodyResult.data;
|
||||
|
||||
const updatedUser = await this.userProfileService.updateSubscriptionTier(
|
||||
auth0Sub,
|
||||
subscriptionTier,
|
||||
actorId
|
||||
);
|
||||
|
||||
return reply.code(200).send(updatedUser);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
logger.error('Error updating user tier', {
|
||||
error: errorMessage,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
});
|
||||
|
||||
if (errorMessage === 'User not found') {
|
||||
return reply.code(404).send({
|
||||
error: 'Not found',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to update subscription tier',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/users/:auth0Sub/deactivate - Soft delete user
|
||||
*/
|
||||
async deactivateUser(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: DeactivateUserInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
if (!actorId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate path param
|
||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
||||
if (!paramsResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: paramsResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
// Validate body (optional)
|
||||
const bodyResult = deactivateUserSchema.safeParse(request.body || {});
|
||||
if (!bodyResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: bodyResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = paramsResult.data;
|
||||
const { reason } = bodyResult.data;
|
||||
|
||||
const deactivatedUser = await this.userProfileService.deactivateUser(
|
||||
auth0Sub,
|
||||
actorId,
|
||||
reason
|
||||
);
|
||||
|
||||
return reply.code(200).send(deactivatedUser);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
logger.error('Error deactivating user', {
|
||||
error: errorMessage,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
});
|
||||
|
||||
if (errorMessage === 'User not found') {
|
||||
return reply.code(404).send({
|
||||
error: 'Not found',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (errorMessage === 'Cannot deactivate your own account') {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad request',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (errorMessage === 'User is already deactivated') {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad request',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to deactivate user',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/users/:auth0Sub/reactivate - Restore deactivated user
|
||||
*/
|
||||
async reactivateUser(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
if (!actorId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate path param
|
||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
||||
if (!paramsResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: paramsResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = paramsResult.data;
|
||||
|
||||
const reactivatedUser = await this.userProfileService.reactivateUser(
|
||||
auth0Sub,
|
||||
actorId
|
||||
);
|
||||
|
||||
return reply.code(200).send(reactivatedUser);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
logger.error('Error reactivating user', {
|
||||
error: errorMessage,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
});
|
||||
|
||||
if (errorMessage === 'User not found') {
|
||||
return reply.code(404).send({
|
||||
error: 'Not found',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (errorMessage === 'User is not deactivated') {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad request',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to reactivate user',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/users/:auth0Sub/profile - Update user email/displayName
|
||||
*/
|
||||
async updateProfile(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: UpdateProfileInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
if (!actorId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate path param
|
||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
||||
if (!paramsResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: paramsResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
// Validate body
|
||||
const bodyResult = updateProfileSchema.safeParse(request.body);
|
||||
if (!bodyResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: bodyResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = paramsResult.data;
|
||||
const updates = bodyResult.data;
|
||||
|
||||
const updatedUser = await this.userProfileService.adminUpdateProfile(
|
||||
auth0Sub,
|
||||
updates,
|
||||
actorId
|
||||
);
|
||||
|
||||
return reply.code(200).send(updatedUser);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
logger.error('Error updating user profile', {
|
||||
error: errorMessage,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
});
|
||||
|
||||
if (errorMessage === 'User not found') {
|
||||
return reply.code(404).send({
|
||||
error: 'Not found',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to update user profile',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/users/:auth0Sub/promote - Promote user to admin
|
||||
*/
|
||||
async promoteToAdmin(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: PromoteToAdminInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
if (!actorId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate path param
|
||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
||||
if (!paramsResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: paramsResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
// Validate body
|
||||
const bodyResult = promoteToAdminSchema.safeParse(request.body || {});
|
||||
if (!bodyResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: bodyResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = paramsResult.data;
|
||||
const { role } = bodyResult.data;
|
||||
|
||||
// Get the user profile first to verify they exist and get their email
|
||||
const user = await this.userProfileService.getUserDetails(auth0Sub);
|
||||
if (!user) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not found',
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is already an admin
|
||||
if (user.isAdmin) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad request',
|
||||
message: 'User is already an admin',
|
||||
});
|
||||
}
|
||||
|
||||
// Create the admin record using the user's real auth0Sub
|
||||
const adminUser = await this.adminService.createAdmin(
|
||||
user.email,
|
||||
role,
|
||||
auth0Sub, // Use the real auth0Sub from the user profile
|
||||
actorId
|
||||
);
|
||||
|
||||
return reply.code(201).send(adminUser);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
logger.error('Error promoting user to admin', {
|
||||
error: errorMessage,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
});
|
||||
|
||||
if (errorMessage.includes('already exists')) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad request',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to promote user to admin',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
57
backend/src/features/admin/api/users.validation.ts
Normal file
57
backend/src/features/admin/api/users.validation.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @ai-summary Request validation schemas for admin user management API
|
||||
* @ai-context Uses Zod for runtime validation and type safety
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// Subscription tier enum
|
||||
export const subscriptionTierSchema = z.enum(['free', 'pro', 'enterprise']);
|
||||
|
||||
// Query params for listing users
|
||||
export const listUsersQuerySchema = z.object({
|
||||
page: z.coerce.number().min(1).default(1),
|
||||
pageSize: z.coerce.number().min(1).max(100).default(20),
|
||||
search: z.string().optional(),
|
||||
tier: subscriptionTierSchema.optional(),
|
||||
status: z.enum(['active', 'deactivated', 'all']).default('all'),
|
||||
sortBy: z.enum(['email', 'createdAt', 'displayName', 'subscriptionTier']).default('createdAt'),
|
||||
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
||||
});
|
||||
|
||||
// Path param for user auth0Sub
|
||||
export const userAuth0SubSchema = z.object({
|
||||
auth0Sub: z.string().min(1, 'auth0Sub is required'),
|
||||
});
|
||||
|
||||
// Body for updating subscription tier
|
||||
export const updateTierSchema = z.object({
|
||||
subscriptionTier: subscriptionTierSchema,
|
||||
});
|
||||
|
||||
// Body for deactivating a user
|
||||
export const deactivateUserSchema = z.object({
|
||||
reason: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
// Body for updating user profile (admin edit)
|
||||
export const updateProfileSchema = z.object({
|
||||
email: z.string().email('Invalid email address').optional(),
|
||||
displayName: z.string().max(100, 'Display name must be 100 characters or less').optional(),
|
||||
}).refine(
|
||||
(data) => data.email !== undefined || data.displayName !== undefined,
|
||||
{ message: 'At least one field (email or displayName) must be provided' }
|
||||
);
|
||||
|
||||
// Body for promoting user to admin
|
||||
export const promoteToAdminSchema = z.object({
|
||||
role: z.enum(['admin', 'super_admin']).default('admin'),
|
||||
});
|
||||
|
||||
// Type exports
|
||||
export type ListUsersQueryInput = z.infer<typeof listUsersQuerySchema>;
|
||||
export type UserAuth0SubInput = z.infer<typeof userAuth0SubSchema>;
|
||||
export type UpdateTierInput = z.infer<typeof updateTierSchema>;
|
||||
export type DeactivateUserInput = z.infer<typeof deactivateUserSchema>;
|
||||
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
|
||||
export type PromoteToAdminInput = z.infer<typeof promoteToAdminSchema>;
|
||||
@@ -243,7 +243,8 @@ export class AdminRepository {
|
||||
action: row.action,
|
||||
resourceType: row.resource_type,
|
||||
resourceId: row.resource_id,
|
||||
context: row.context ? JSON.parse(row.context) : undefined,
|
||||
// JSONB columns are automatically parsed by pg driver - no JSON.parse needed
|
||||
context: row.context || undefined,
|
||||
createdAt: new Date(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Documents list requested', {
|
||||
operation: 'documents.list',
|
||||
user_id: userId,
|
||||
userId,
|
||||
filters: {
|
||||
vehicle_id: request.query.vehicleId,
|
||||
vehicleId: request.query.vehicleId,
|
||||
type: request.query.type,
|
||||
expires_before: request.query.expiresBefore,
|
||||
expiresBefore: request.query.expiresBefore,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -33,8 +33,8 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Documents list retrieved', {
|
||||
operation: 'documents.list.success',
|
||||
user_id: userId,
|
||||
document_count: docs.length,
|
||||
userId,
|
||||
documentCount: docs.length,
|
||||
});
|
||||
|
||||
return reply.code(200).send(docs);
|
||||
@@ -46,26 +46,26 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Document get requested', {
|
||||
operation: 'documents.get',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
userId,
|
||||
documentId,
|
||||
});
|
||||
|
||||
const doc = await this.service.getDocument(userId, documentId);
|
||||
if (!doc) {
|
||||
logger.warn('Document not found', {
|
||||
operation: 'documents.get.not_found',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
userId,
|
||||
documentId,
|
||||
});
|
||||
return reply.code(404).send({ error: 'Not Found' });
|
||||
}
|
||||
|
||||
logger.info('Document retrieved', {
|
||||
operation: 'documents.get.success',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
vehicle_id: doc.vehicle_id,
|
||||
document_type: doc.document_type,
|
||||
userId,
|
||||
documentId,
|
||||
vehicleId: doc.vehicleId,
|
||||
documentType: doc.documentType,
|
||||
});
|
||||
|
||||
return reply.code(200).send(doc);
|
||||
@@ -76,9 +76,9 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Document create requested', {
|
||||
operation: 'documents.create',
|
||||
user_id: userId,
|
||||
vehicle_id: request.body.vehicle_id,
|
||||
document_type: request.body.document_type,
|
||||
userId,
|
||||
vehicleId: request.body.vehicleId,
|
||||
documentType: request.body.documentType,
|
||||
title: request.body.title,
|
||||
});
|
||||
|
||||
@@ -86,10 +86,10 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Document created', {
|
||||
operation: 'documents.create.success',
|
||||
user_id: userId,
|
||||
document_id: created.id,
|
||||
vehicle_id: created.vehicle_id,
|
||||
document_type: created.document_type,
|
||||
userId,
|
||||
documentId: created.id,
|
||||
vehicleId: created.vehicleId,
|
||||
documentType: created.documentType,
|
||||
title: created.title,
|
||||
});
|
||||
|
||||
@@ -102,26 +102,26 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Document update requested', {
|
||||
operation: 'documents.update',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
update_fields: Object.keys(request.body),
|
||||
userId,
|
||||
documentId,
|
||||
updateFields: Object.keys(request.body),
|
||||
});
|
||||
|
||||
const updated = await this.service.updateDocument(userId, documentId, request.body);
|
||||
if (!updated) {
|
||||
logger.warn('Document not found for update', {
|
||||
operation: 'documents.update.not_found',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
userId,
|
||||
documentId,
|
||||
});
|
||||
return reply.code(404).send({ error: 'Not Found' });
|
||||
}
|
||||
|
||||
logger.info('Document updated', {
|
||||
operation: 'documents.update.success',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
vehicle_id: updated.vehicle_id,
|
||||
userId,
|
||||
documentId,
|
||||
vehicleId: updated.vehicleId,
|
||||
title: updated.title,
|
||||
});
|
||||
|
||||
@@ -134,28 +134,28 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Document delete requested', {
|
||||
operation: 'documents.delete',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
userId,
|
||||
documentId,
|
||||
});
|
||||
|
||||
// If object exists, delete it from storage first
|
||||
const existing = await this.service.getDocument(userId, documentId);
|
||||
if (existing && existing.storage_bucket && existing.storage_key) {
|
||||
if (existing && existing.storageBucket && existing.storageKey) {
|
||||
const storage = getStorageService();
|
||||
try {
|
||||
await storage.deleteObject(existing.storage_bucket, existing.storage_key);
|
||||
await storage.deleteObject(existing.storageBucket, existing.storageKey);
|
||||
logger.info('Document file deleted from storage', {
|
||||
operation: 'documents.delete.storage_cleanup',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
storage_key: existing.storage_key,
|
||||
userId,
|
||||
documentId,
|
||||
storageKey: existing.storageKey,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.warn('Failed to delete document file from storage', {
|
||||
operation: 'documents.delete.storage_cleanup_failed',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
storage_key: existing.storage_key,
|
||||
userId,
|
||||
documentId,
|
||||
storageKey: existing.storageKey,
|
||||
error: e instanceof Error ? e.message : 'Unknown error',
|
||||
});
|
||||
// Non-fatal: proceed with soft delete
|
||||
@@ -166,10 +166,10 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Document deleted', {
|
||||
operation: 'documents.delete.success',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
vehicle_id: existing?.vehicle_id,
|
||||
had_file: !!(existing?.storage_key),
|
||||
userId,
|
||||
documentId,
|
||||
vehicleId: existing?.vehicleId,
|
||||
hadFile: !!(existing?.storageKey),
|
||||
});
|
||||
|
||||
return reply.code(204).send();
|
||||
@@ -181,16 +181,16 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Document upload requested', {
|
||||
operation: 'documents.upload',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
userId,
|
||||
documentId,
|
||||
});
|
||||
|
||||
const doc = await this.service.getDocument(userId, documentId);
|
||||
if (!doc) {
|
||||
logger.warn('Document not found for upload', {
|
||||
operation: 'documents.upload.not_found',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
userId,
|
||||
documentId,
|
||||
});
|
||||
return reply.code(404).send({ error: 'Not Found' });
|
||||
}
|
||||
@@ -199,8 +199,8 @@ export class DocumentsController {
|
||||
if (!mp) {
|
||||
logger.warn('No file provided for upload', {
|
||||
operation: 'documents.upload.no_file',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
userId,
|
||||
documentId,
|
||||
});
|
||||
return reply.code(400).send({ error: 'Bad Request', message: 'No file provided' });
|
||||
}
|
||||
@@ -216,10 +216,10 @@ export class DocumentsController {
|
||||
if (!contentType || !allowedTypes.has(contentType)) {
|
||||
logger.warn('Unsupported file type for upload (header validation)', {
|
||||
operation: 'documents.upload.unsupported_type',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
content_type: contentType,
|
||||
file_name: mp.filename,
|
||||
userId,
|
||||
documentId,
|
||||
contentType,
|
||||
fileName: mp.filename,
|
||||
});
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
@@ -248,10 +248,10 @@ export class DocumentsController {
|
||||
if (!detectedType) {
|
||||
logger.warn('Unable to detect file type from content', {
|
||||
operation: 'documents.upload.type_detection_failed',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
content_type: contentType,
|
||||
file_name: mp.filename,
|
||||
userId,
|
||||
documentId,
|
||||
contentType,
|
||||
fileName: mp.filename,
|
||||
});
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
@@ -264,11 +264,11 @@ export class DocumentsController {
|
||||
if (!allowedDetectedTypes || !allowedDetectedTypes.has(detectedType.mime)) {
|
||||
logger.warn('File content does not match Content-Type header', {
|
||||
operation: 'documents.upload.type_mismatch',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
claimed_type: contentType,
|
||||
detected_type: detectedType.mime,
|
||||
file_name: mp.filename,
|
||||
userId,
|
||||
documentId,
|
||||
claimedType: contentType,
|
||||
detectedType: detectedType.mime,
|
||||
fileName: mp.filename,
|
||||
});
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
@@ -310,29 +310,29 @@ export class DocumentsController {
|
||||
const bucket = 'documents';
|
||||
const version = 'v1';
|
||||
const unique = cryptoRandom();
|
||||
const key = `documents/${userId}/${doc.vehicle_id}/${doc.id}/${version}/${unique}.${ext}`;
|
||||
const key = `documents/${userId}/${doc.vehicleId}/${doc.id}/${version}/${unique}.${ext}`;
|
||||
|
||||
await storage.putObject(bucket, key, counter, contentType, { 'x-original-filename': originalName });
|
||||
|
||||
const updated = await this.service['repo'].updateStorageMeta(doc.id, userId, {
|
||||
storage_bucket: bucket,
|
||||
storage_key: key,
|
||||
file_name: originalName,
|
||||
content_type: contentType,
|
||||
file_size: counter.bytes,
|
||||
file_hash: null,
|
||||
storageBucket: bucket,
|
||||
storageKey: key,
|
||||
fileName: originalName,
|
||||
contentType: contentType,
|
||||
fileSize: counter.bytes,
|
||||
fileHash: null,
|
||||
});
|
||||
|
||||
logger.info('Document upload completed', {
|
||||
operation: 'documents.upload.success',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
vehicle_id: doc.vehicle_id,
|
||||
file_name: originalName,
|
||||
content_type: contentType,
|
||||
detected_type: detectedType.mime,
|
||||
file_size: counter.bytes,
|
||||
storage_key: key,
|
||||
userId,
|
||||
documentId,
|
||||
vehicleId: doc.vehicleId,
|
||||
fileName: originalName,
|
||||
contentType,
|
||||
detectedType: detectedType.mime,
|
||||
fileSize: counter.bytes,
|
||||
storageKey: key,
|
||||
});
|
||||
|
||||
return reply.code(200).send(updated);
|
||||
@@ -344,18 +344,18 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Document download requested', {
|
||||
operation: 'documents.download',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
userId,
|
||||
documentId,
|
||||
});
|
||||
|
||||
const doc = await this.service.getDocument(userId, documentId);
|
||||
if (!doc || !doc.storage_bucket || !doc.storage_key) {
|
||||
if (!doc || !doc.storageBucket || !doc.storageKey) {
|
||||
logger.warn('Document or file not found for download', {
|
||||
operation: 'documents.download.not_found',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
has_document: !!doc,
|
||||
has_storage_info: !!(doc?.storage_bucket && doc?.storage_key),
|
||||
userId,
|
||||
documentId,
|
||||
hasDocument: !!doc,
|
||||
hasStorageInfo: !!(doc?.storageBucket && doc?.storageKey),
|
||||
});
|
||||
return reply.code(404).send({ error: 'Not Found' });
|
||||
}
|
||||
@@ -363,10 +363,10 @@ export class DocumentsController {
|
||||
const storage = getStorageService();
|
||||
let head: Partial<import('../../../core/storage/storage.service').HeadObjectResult> = {};
|
||||
try {
|
||||
head = await storage.headObject(doc.storage_bucket, doc.storage_key);
|
||||
head = await storage.headObject(doc.storageBucket, doc.storageKey);
|
||||
} catch { /* ignore */ }
|
||||
const contentType = head.contentType || doc.content_type || 'application/octet-stream';
|
||||
const filename = doc.file_name || path.basename(doc.storage_key);
|
||||
const contentType = head.contentType || doc.contentType || 'application/octet-stream';
|
||||
const filename = doc.fileName || path.basename(doc.storageKey);
|
||||
const inlineTypes = new Set(['application/pdf', 'image/jpeg', 'image/png']);
|
||||
const disposition = inlineTypes.has(contentType) ? 'inline' : 'attachment';
|
||||
|
||||
@@ -375,16 +375,16 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Document download initiated', {
|
||||
operation: 'documents.download.success',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
vehicle_id: doc.vehicle_id,
|
||||
file_name: filename,
|
||||
content_type: contentType,
|
||||
disposition: disposition,
|
||||
file_size: head.size || doc.file_size,
|
||||
userId,
|
||||
documentId,
|
||||
vehicleId: doc.vehicleId,
|
||||
fileName: filename,
|
||||
contentType,
|
||||
disposition,
|
||||
fileSize: head.size || doc.fileSize,
|
||||
});
|
||||
|
||||
const stream = await storage.getObjectStream(doc.storage_bucket, doc.storage_key);
|
||||
const stream = await storage.getObjectStream(doc.storageBucket, doc.storageKey);
|
||||
return reply.send(stream);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,40 +5,74 @@ import type { DocumentRecord, DocumentType } from '../domain/documents.types';
|
||||
export class DocumentsRepository {
|
||||
constructor(private readonly db: Pool = pool) {}
|
||||
|
||||
// ========================
|
||||
// Row Mapper
|
||||
// ========================
|
||||
|
||||
private mapDocumentRecord(row: any): DocumentRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
vehicleId: row.vehicle_id,
|
||||
documentType: row.document_type,
|
||||
title: row.title,
|
||||
notes: row.notes,
|
||||
details: row.details,
|
||||
storageBucket: row.storage_bucket,
|
||||
storageKey: row.storage_key,
|
||||
fileName: row.file_name,
|
||||
contentType: row.content_type,
|
||||
fileSize: row.file_size,
|
||||
fileHash: row.file_hash,
|
||||
issuedDate: row.issued_date,
|
||||
expirationDate: row.expiration_date,
|
||||
emailNotifications: row.email_notifications,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
deletedAt: row.deleted_at
|
||||
};
|
||||
}
|
||||
|
||||
// ========================
|
||||
// CRUD Operations
|
||||
// ========================
|
||||
|
||||
async insert(doc: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
vehicle_id: string;
|
||||
document_type: DocumentType;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
documentType: DocumentType;
|
||||
title: string;
|
||||
notes?: string | null;
|
||||
details?: any;
|
||||
issued_date?: string | null;
|
||||
expiration_date?: string | null;
|
||||
issuedDate?: string | null;
|
||||
expirationDate?: string | null;
|
||||
emailNotifications?: boolean;
|
||||
}): Promise<DocumentRecord> {
|
||||
const res = await this.db.query(
|
||||
`INSERT INTO documents (
|
||||
id, user_id, vehicle_id, document_type, title, notes, details, issued_date, expiration_date
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
id, user_id, vehicle_id, document_type, title, notes, details, issued_date, expiration_date, email_notifications
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
RETURNING *`,
|
||||
[
|
||||
doc.id,
|
||||
doc.user_id,
|
||||
doc.vehicle_id,
|
||||
doc.document_type,
|
||||
doc.userId,
|
||||
doc.vehicleId,
|
||||
doc.documentType,
|
||||
doc.title,
|
||||
doc.notes ?? null,
|
||||
doc.details ?? null,
|
||||
doc.issued_date ?? null,
|
||||
doc.expiration_date ?? null,
|
||||
doc.issuedDate ?? null,
|
||||
doc.expirationDate ?? null,
|
||||
doc.emailNotifications ?? false,
|
||||
]
|
||||
);
|
||||
return res.rows[0] as DocumentRecord;
|
||||
return this.mapDocumentRecord(res.rows[0]);
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<DocumentRecord | null> {
|
||||
const res = await this.db.query(`SELECT * FROM documents WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL`, [id, userId]);
|
||||
return res.rows[0] || null;
|
||||
return res.rows[0] ? this.mapDocumentRecord(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async listByUser(userId: string, filters?: { vehicleId?: string; type?: DocumentType; expiresBefore?: string }): Promise<DocumentRecord[]> {
|
||||
@@ -50,31 +84,32 @@ export class DocumentsRepository {
|
||||
if (filters?.expiresBefore) { conds.push(`expiration_date <= $${i++}`); params.push(filters.expiresBefore); }
|
||||
const sql = `SELECT * FROM documents WHERE ${conds.join(' AND ')} ORDER BY created_at DESC`;
|
||||
const res = await this.db.query(sql, params);
|
||||
return res.rows as DocumentRecord[];
|
||||
return res.rows.map(row => this.mapDocumentRecord(row));
|
||||
}
|
||||
|
||||
async softDelete(id: string, userId: string): Promise<void> {
|
||||
await this.db.query(`UPDATE documents SET deleted_at = NOW() WHERE id = $1 AND user_id = $2`, [id, userId]);
|
||||
}
|
||||
|
||||
async updateMetadata(id: string, userId: string, patch: Partial<Pick<DocumentRecord, 'title'|'notes'|'details'|'issued_date'|'expiration_date'>>): Promise<DocumentRecord | null> {
|
||||
async updateMetadata(id: string, userId: string, patch: Partial<Pick<DocumentRecord, 'title'|'notes'|'details'|'issuedDate'|'expirationDate'|'emailNotifications'>>): Promise<DocumentRecord | null> {
|
||||
const fields: string[] = [];
|
||||
const params: any[] = [];
|
||||
let i = 1;
|
||||
if (patch.title !== undefined) { fields.push(`title = $${i++}`); params.push(patch.title); }
|
||||
if (patch.notes !== undefined) { fields.push(`notes = $${i++}`); params.push(patch.notes); }
|
||||
if (patch.details !== undefined) { fields.push(`details = $${i++}`); params.push(patch.details); }
|
||||
if (patch.issued_date !== undefined) { fields.push(`issued_date = $${i++}`); params.push(patch.issued_date); }
|
||||
if (patch.expiration_date !== undefined) { fields.push(`expiration_date = $${i++}`); params.push(patch.expiration_date); }
|
||||
if (patch.issuedDate !== undefined) { fields.push(`issued_date = $${i++}`); params.push(patch.issuedDate); }
|
||||
if (patch.expirationDate !== undefined) { fields.push(`expiration_date = $${i++}`); params.push(patch.expirationDate); }
|
||||
if (patch.emailNotifications !== undefined) { fields.push(`email_notifications = $${i++}`); params.push(patch.emailNotifications); }
|
||||
if (!fields.length) return this.findById(id, userId);
|
||||
params.push(id, userId);
|
||||
const sql = `UPDATE documents SET ${fields.join(', ')} WHERE id = $${i++} AND user_id = $${i++} AND deleted_at IS NULL RETURNING *`;
|
||||
const res = await this.db.query(sql, params);
|
||||
return res.rows[0] || null;
|
||||
return res.rows[0] ? this.mapDocumentRecord(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async updateStorageMeta(id: string, userId: string, meta: {
|
||||
storage_bucket: string; storage_key: string; file_name: string; content_type: string; file_size: number; file_hash?: string | null;
|
||||
storageBucket: string; storageKey: string; fileName: string; contentType: string; fileSize: number; fileHash?: string | null;
|
||||
}): Promise<DocumentRecord | null> {
|
||||
const res = await this.db.query(
|
||||
`UPDATE documents SET
|
||||
@@ -86,9 +121,9 @@ export class DocumentsRepository {
|
||||
file_hash = $6
|
||||
WHERE id = $7 AND user_id = $8 AND deleted_at IS NULL
|
||||
RETURNING *`,
|
||||
[meta.storage_bucket, meta.storage_key, meta.file_name, meta.content_type, meta.file_size, meta.file_hash ?? null, id, userId]
|
||||
[meta.storageBucket, meta.storageKey, meta.fileName, meta.contentType, meta.fileSize, meta.fileHash ?? null, id, userId]
|
||||
);
|
||||
return res.rows[0] || null;
|
||||
return res.rows[0] ? this.mapDocumentRecord(res.rows[0]) : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,18 +7,19 @@ export class DocumentsService {
|
||||
private readonly repo = new DocumentsRepository(pool);
|
||||
|
||||
async createDocument(userId: string, body: CreateDocumentBody): Promise<DocumentRecord> {
|
||||
await this.assertVehicleOwnership(userId, body.vehicle_id);
|
||||
await this.assertVehicleOwnership(userId, body.vehicleId);
|
||||
const id = randomUUID();
|
||||
return this.repo.insert({
|
||||
id,
|
||||
user_id: userId,
|
||||
vehicle_id: body.vehicle_id,
|
||||
document_type: body.document_type as DocumentType,
|
||||
userId,
|
||||
vehicleId: body.vehicleId,
|
||||
documentType: body.documentType as DocumentType,
|
||||
title: body.title,
|
||||
notes: body.notes ?? null,
|
||||
details: body.details ?? null,
|
||||
issued_date: body.issued_date ?? null,
|
||||
expiration_date: body.expiration_date ?? null,
|
||||
issuedDate: body.issuedDate ?? null,
|
||||
expirationDate: body.expirationDate ?? null,
|
||||
emailNotifications: body.emailNotifications ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,35 +3,39 @@ import { z } from 'zod';
|
||||
export const DocumentTypeSchema = z.enum(['insurance', 'registration']);
|
||||
export type DocumentType = z.infer<typeof DocumentTypeSchema>;
|
||||
|
||||
// API response type (camelCase for frontend)
|
||||
export interface DocumentRecord {
|
||||
id: string;
|
||||
user_id: string;
|
||||
vehicle_id: string;
|
||||
document_type: DocumentType;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
documentType: DocumentType;
|
||||
title: string;
|
||||
notes?: string | null;
|
||||
details?: Record<string, any> | null;
|
||||
storage_bucket?: string | null;
|
||||
storage_key?: string | null;
|
||||
file_name?: string | null;
|
||||
content_type?: string | null;
|
||||
file_size?: number | null;
|
||||
file_hash?: string | null;
|
||||
issued_date?: string | null;
|
||||
expiration_date?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at?: string | null;
|
||||
storageBucket?: string | null;
|
||||
storageKey?: string | null;
|
||||
fileName?: string | null;
|
||||
contentType?: string | null;
|
||||
fileSize?: number | null;
|
||||
fileHash?: string | null;
|
||||
issuedDate?: string | null;
|
||||
expirationDate?: string | null;
|
||||
emailNotifications?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
// API request schemas (camelCase for frontend)
|
||||
export const CreateDocumentBodySchema = z.object({
|
||||
vehicle_id: z.string().uuid(),
|
||||
document_type: DocumentTypeSchema,
|
||||
vehicleId: z.string().uuid(),
|
||||
documentType: DocumentTypeSchema,
|
||||
title: z.string().min(1).max(200),
|
||||
notes: z.string().max(10000).optional(),
|
||||
details: z.record(z.any()).optional(),
|
||||
issued_date: z.string().optional(),
|
||||
expiration_date: z.string().optional(),
|
||||
issuedDate: z.string().optional(),
|
||||
expirationDate: z.string().optional(),
|
||||
emailNotifications: z.boolean().optional(),
|
||||
});
|
||||
export type CreateDocumentBody = z.infer<typeof CreateDocumentBodySchema>;
|
||||
|
||||
@@ -39,8 +43,9 @@ export const UpdateDocumentBodySchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
notes: z.string().max(10000).nullable().optional(),
|
||||
details: z.record(z.any()).optional(),
|
||||
issued_date: z.string().nullable().optional(),
|
||||
expiration_date: z.string().nullable().optional(),
|
||||
issuedDate: z.string().nullable().optional(),
|
||||
expirationDate: z.string().nullable().optional(),
|
||||
emailNotifications: z.boolean().optional(),
|
||||
});
|
||||
export type UpdateDocumentBody = z.infer<typeof UpdateDocumentBodySchema>;
|
||||
|
||||
|
||||
@@ -247,7 +247,7 @@ export class FuelLogsRepository {
|
||||
data.notes ?? null
|
||||
];
|
||||
const res = await this.pool.query(query, values);
|
||||
return res.rows[0];
|
||||
return res.rows[0] ? this.mapRow(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async findByVehicleIdEnhanced(vehicleId: string): Promise<any[]> {
|
||||
@@ -255,7 +255,7 @@ export class FuelLogsRepository {
|
||||
`SELECT * FROM fuel_logs WHERE vehicle_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC`,
|
||||
[vehicleId]
|
||||
);
|
||||
return res.rows;
|
||||
return res.rows.map(row => this.mapRow(row));
|
||||
}
|
||||
|
||||
async findByUserIdEnhanced(userId: string): Promise<any[]> {
|
||||
@@ -263,12 +263,12 @@ export class FuelLogsRepository {
|
||||
`SELECT * FROM fuel_logs WHERE user_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
return res.rows;
|
||||
return res.rows.map(row => this.mapRow(row));
|
||||
}
|
||||
|
||||
async findByIdEnhanced(id: string): Promise<any | null> {
|
||||
const res = await this.pool.query(`SELECT * FROM fuel_logs WHERE id = $1`, [id]);
|
||||
return res.rows[0] || null;
|
||||
return res.rows[0] ? this.mapRow(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async getPreviousLogByOdometer(vehicleId: string, odometerReading: number): Promise<any | null> {
|
||||
@@ -276,7 +276,7 @@ export class FuelLogsRepository {
|
||||
`SELECT * FROM fuel_logs WHERE vehicle_id = $1 AND odometer IS NOT NULL AND odometer < $2 ORDER BY odometer DESC LIMIT 1`,
|
||||
[vehicleId, odometerReading]
|
||||
);
|
||||
return res.rows[0] || null;
|
||||
return res.rows[0] ? this.mapRow(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async getLatestLogForVehicle(vehicleId: string): Promise<any | null> {
|
||||
@@ -284,7 +284,7 @@ export class FuelLogsRepository {
|
||||
`SELECT * FROM fuel_logs WHERE vehicle_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC LIMIT 1`,
|
||||
[vehicleId]
|
||||
);
|
||||
return res.rows[0] || null;
|
||||
return res.rows[0] ? this.mapRow(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async updateEnhanced(id: string, data: {
|
||||
@@ -370,6 +370,6 @@ export class FuelLogsRepository {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
return this.mapRow(result.rows[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,9 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance records list requested', {
|
||||
operation: 'maintenance.records.list',
|
||||
user_id: userId,
|
||||
userId,
|
||||
filters: {
|
||||
vehicle_id: request.query.vehicleId,
|
||||
vehicleId: request.query.vehicleId,
|
||||
category: request.query.category,
|
||||
},
|
||||
});
|
||||
@@ -42,15 +42,15 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance records list retrieved', {
|
||||
operation: 'maintenance.records.list.success',
|
||||
user_id: userId,
|
||||
record_count: records.length,
|
||||
userId,
|
||||
recordCount: records.length,
|
||||
});
|
||||
|
||||
return reply.code(200).send(records);
|
||||
} catch (error) {
|
||||
logger.error('Failed to list maintenance records', {
|
||||
operation: 'maintenance.records.list.error',
|
||||
user_id: userId,
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -63,8 +63,8 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance record get requested', {
|
||||
operation: 'maintenance.records.get',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
userId,
|
||||
recordId,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -72,17 +72,17 @@ export class MaintenanceController {
|
||||
if (!record) {
|
||||
logger.warn('Maintenance record not found', {
|
||||
operation: 'maintenance.records.get.not_found',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
userId,
|
||||
recordId,
|
||||
});
|
||||
return reply.code(404).send({ error: 'Not Found' });
|
||||
}
|
||||
|
||||
logger.info('Maintenance record retrieved', {
|
||||
operation: 'maintenance.records.get.success',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
vehicle_id: record.vehicle_id,
|
||||
userId,
|
||||
recordId,
|
||||
vehicleId: record.vehicleId,
|
||||
category: record.category,
|
||||
});
|
||||
|
||||
@@ -90,8 +90,8 @@ export class MaintenanceController {
|
||||
} catch (error) {
|
||||
logger.error('Failed to get maintenance record', {
|
||||
operation: 'maintenance.records.get.error',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
userId,
|
||||
recordId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -107,8 +107,8 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance records by vehicle requested', {
|
||||
operation: 'maintenance.records.by_vehicle',
|
||||
user_id: userId,
|
||||
vehicle_id: vehicleId,
|
||||
userId,
|
||||
vehicleId,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -116,17 +116,17 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance records by vehicle retrieved', {
|
||||
operation: 'maintenance.records.by_vehicle.success',
|
||||
user_id: userId,
|
||||
vehicle_id: vehicleId,
|
||||
record_count: records.length,
|
||||
userId,
|
||||
vehicleId,
|
||||
recordCount: records.length,
|
||||
});
|
||||
|
||||
return reply.code(200).send(records);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get maintenance records by vehicle', {
|
||||
operation: 'maintenance.records.by_vehicle.error',
|
||||
user_id: userId,
|
||||
vehicle_id: vehicleId,
|
||||
userId,
|
||||
vehicleId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -138,7 +138,7 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance record create requested', {
|
||||
operation: 'maintenance.records.create',
|
||||
user_id: userId,
|
||||
userId,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -148,11 +148,11 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance record created', {
|
||||
operation: 'maintenance.records.create.success',
|
||||
user_id: userId,
|
||||
record_id: record.id,
|
||||
vehicle_id: record.vehicle_id,
|
||||
userId,
|
||||
recordId: record.id,
|
||||
vehicleId: record.vehicleId,
|
||||
category: record.category,
|
||||
subtype_count: record.subtypes.length,
|
||||
subtypeCount: record.subtypes.length,
|
||||
});
|
||||
|
||||
return reply.code(201).send(record);
|
||||
@@ -160,7 +160,7 @@ export class MaintenanceController {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn('Maintenance record validation failed', {
|
||||
operation: 'maintenance.records.create.validation_error',
|
||||
user_id: userId,
|
||||
userId,
|
||||
errors: error.errors,
|
||||
});
|
||||
return reply.code(400).send({ error: 'Bad Request', details: error.errors });
|
||||
@@ -170,8 +170,8 @@ export class MaintenanceController {
|
||||
const statusCode = (error as any).statusCode;
|
||||
logger.warn('Maintenance record creation failed', {
|
||||
operation: 'maintenance.records.create.error',
|
||||
user_id: userId,
|
||||
status_code: statusCode,
|
||||
userId,
|
||||
statusCode,
|
||||
error: error.message,
|
||||
});
|
||||
return reply.code(statusCode).send({ error: error.message });
|
||||
@@ -179,7 +179,7 @@ export class MaintenanceController {
|
||||
|
||||
logger.error('Failed to create maintenance record', {
|
||||
operation: 'maintenance.records.create.error',
|
||||
user_id: userId,
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -195,8 +195,8 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance record update requested', {
|
||||
operation: 'maintenance.records.update',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
userId,
|
||||
recordId,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -206,17 +206,17 @@ export class MaintenanceController {
|
||||
if (!record) {
|
||||
logger.warn('Maintenance record not found for update', {
|
||||
operation: 'maintenance.records.update.not_found',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
userId,
|
||||
recordId,
|
||||
});
|
||||
return reply.code(404).send({ error: 'Not Found' });
|
||||
}
|
||||
|
||||
logger.info('Maintenance record updated', {
|
||||
operation: 'maintenance.records.update.success',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
vehicle_id: record.vehicle_id,
|
||||
userId,
|
||||
recordId,
|
||||
vehicleId: record.vehicleId,
|
||||
category: record.category,
|
||||
});
|
||||
|
||||
@@ -225,8 +225,8 @@ export class MaintenanceController {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn('Maintenance record update validation failed', {
|
||||
operation: 'maintenance.records.update.validation_error',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
userId,
|
||||
recordId,
|
||||
errors: error.errors,
|
||||
});
|
||||
return reply.code(400).send({ error: 'Bad Request', details: error.errors });
|
||||
@@ -236,9 +236,9 @@ export class MaintenanceController {
|
||||
const statusCode = (error as any).statusCode;
|
||||
logger.warn('Maintenance record update failed', {
|
||||
operation: 'maintenance.records.update.error',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
status_code: statusCode,
|
||||
userId,
|
||||
recordId,
|
||||
statusCode,
|
||||
error: error.message,
|
||||
});
|
||||
return reply.code(statusCode).send({ error: error.message });
|
||||
@@ -246,8 +246,8 @@ export class MaintenanceController {
|
||||
|
||||
logger.error('Failed to update maintenance record', {
|
||||
operation: 'maintenance.records.update.error',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
userId,
|
||||
recordId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -260,8 +260,8 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance record delete requested', {
|
||||
operation: 'maintenance.records.delete',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
userId,
|
||||
recordId,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -269,16 +269,16 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance record deleted', {
|
||||
operation: 'maintenance.records.delete.success',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
userId,
|
||||
recordId,
|
||||
});
|
||||
|
||||
return reply.code(204).send();
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete maintenance record', {
|
||||
operation: 'maintenance.records.delete.error',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
userId,
|
||||
recordId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -294,8 +294,8 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance schedules by vehicle requested', {
|
||||
operation: 'maintenance.schedules.by_vehicle',
|
||||
user_id: userId,
|
||||
vehicle_id: vehicleId,
|
||||
userId,
|
||||
vehicleId,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -303,17 +303,17 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance schedules by vehicle retrieved', {
|
||||
operation: 'maintenance.schedules.by_vehicle.success',
|
||||
user_id: userId,
|
||||
vehicle_id: vehicleId,
|
||||
schedule_count: schedules.length,
|
||||
userId,
|
||||
vehicleId,
|
||||
scheduleCount: schedules.length,
|
||||
});
|
||||
|
||||
return reply.code(200).send(schedules);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get maintenance schedules by vehicle', {
|
||||
operation: 'maintenance.schedules.by_vehicle.error',
|
||||
user_id: userId,
|
||||
vehicle_id: vehicleId,
|
||||
userId,
|
||||
vehicleId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -325,7 +325,7 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance schedule create requested', {
|
||||
operation: 'maintenance.schedules.create',
|
||||
user_id: userId,
|
||||
userId,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -335,11 +335,11 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance schedule created', {
|
||||
operation: 'maintenance.schedules.create.success',
|
||||
user_id: userId,
|
||||
schedule_id: schedule.id,
|
||||
vehicle_id: schedule.vehicle_id,
|
||||
userId,
|
||||
scheduleId: schedule.id,
|
||||
vehicleId: schedule.vehicleId,
|
||||
category: schedule.category,
|
||||
subtype_count: schedule.subtypes.length,
|
||||
subtypeCount: schedule.subtypes.length,
|
||||
});
|
||||
|
||||
return reply.code(201).send(schedule);
|
||||
@@ -347,7 +347,7 @@ export class MaintenanceController {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn('Maintenance schedule validation failed', {
|
||||
operation: 'maintenance.schedules.create.validation_error',
|
||||
user_id: userId,
|
||||
userId,
|
||||
errors: error.errors,
|
||||
});
|
||||
return reply.code(400).send({ error: 'Bad Request', details: error.errors });
|
||||
@@ -357,8 +357,8 @@ export class MaintenanceController {
|
||||
const statusCode = (error as any).statusCode;
|
||||
logger.warn('Maintenance schedule creation failed', {
|
||||
operation: 'maintenance.schedules.create.error',
|
||||
user_id: userId,
|
||||
status_code: statusCode,
|
||||
userId,
|
||||
statusCode,
|
||||
error: error.message,
|
||||
});
|
||||
return reply.code(statusCode).send({ error: error.message });
|
||||
@@ -366,7 +366,7 @@ export class MaintenanceController {
|
||||
|
||||
logger.error('Failed to create maintenance schedule', {
|
||||
operation: 'maintenance.schedules.create.error',
|
||||
user_id: userId,
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -382,8 +382,8 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance schedule update requested', {
|
||||
operation: 'maintenance.schedules.update',
|
||||
user_id: userId,
|
||||
schedule_id: scheduleId,
|
||||
userId,
|
||||
scheduleId,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -393,17 +393,17 @@ export class MaintenanceController {
|
||||
if (!schedule) {
|
||||
logger.warn('Maintenance schedule not found for update', {
|
||||
operation: 'maintenance.schedules.update.not_found',
|
||||
user_id: userId,
|
||||
schedule_id: scheduleId,
|
||||
userId,
|
||||
scheduleId,
|
||||
});
|
||||
return reply.code(404).send({ error: 'Not Found' });
|
||||
}
|
||||
|
||||
logger.info('Maintenance schedule updated', {
|
||||
operation: 'maintenance.schedules.update.success',
|
||||
user_id: userId,
|
||||
schedule_id: scheduleId,
|
||||
vehicle_id: schedule.vehicle_id,
|
||||
userId,
|
||||
scheduleId,
|
||||
vehicleId: schedule.vehicleId,
|
||||
category: schedule.category,
|
||||
});
|
||||
|
||||
@@ -412,8 +412,8 @@ export class MaintenanceController {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn('Maintenance schedule update validation failed', {
|
||||
operation: 'maintenance.schedules.update.validation_error',
|
||||
user_id: userId,
|
||||
schedule_id: scheduleId,
|
||||
userId,
|
||||
scheduleId,
|
||||
errors: error.errors,
|
||||
});
|
||||
return reply.code(400).send({ error: 'Bad Request', details: error.errors });
|
||||
@@ -423,9 +423,9 @@ export class MaintenanceController {
|
||||
const statusCode = (error as any).statusCode;
|
||||
logger.warn('Maintenance schedule update failed', {
|
||||
operation: 'maintenance.schedules.update.error',
|
||||
user_id: userId,
|
||||
schedule_id: scheduleId,
|
||||
status_code: statusCode,
|
||||
userId,
|
||||
scheduleId,
|
||||
statusCode,
|
||||
error: error.message,
|
||||
});
|
||||
return reply.code(statusCode).send({ error: error.message });
|
||||
@@ -433,8 +433,8 @@ export class MaintenanceController {
|
||||
|
||||
logger.error('Failed to update maintenance schedule', {
|
||||
operation: 'maintenance.schedules.update.error',
|
||||
user_id: userId,
|
||||
schedule_id: scheduleId,
|
||||
userId,
|
||||
scheduleId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -447,8 +447,8 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance schedule delete requested', {
|
||||
operation: 'maintenance.schedules.delete',
|
||||
user_id: userId,
|
||||
schedule_id: scheduleId,
|
||||
userId,
|
||||
scheduleId,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -456,16 +456,16 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance schedule deleted', {
|
||||
operation: 'maintenance.schedules.delete.success',
|
||||
user_id: userId,
|
||||
schedule_id: scheduleId,
|
||||
userId,
|
||||
scheduleId,
|
||||
});
|
||||
|
||||
return reply.code(204).send();
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete maintenance schedule', {
|
||||
operation: 'maintenance.schedules.delete.error',
|
||||
user_id: userId,
|
||||
schedule_id: scheduleId,
|
||||
userId,
|
||||
scheduleId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -482,9 +482,9 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Upcoming maintenance requested', {
|
||||
operation: 'maintenance.upcoming',
|
||||
user_id: userId,
|
||||
vehicle_id: vehicleId,
|
||||
current_mileage: currentMileage,
|
||||
userId,
|
||||
vehicleId,
|
||||
currentMileage,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -492,17 +492,17 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Upcoming maintenance retrieved', {
|
||||
operation: 'maintenance.upcoming.success',
|
||||
user_id: userId,
|
||||
vehicle_id: vehicleId,
|
||||
upcoming_count: upcoming.length,
|
||||
userId,
|
||||
vehicleId,
|
||||
upcomingCount: upcoming.length,
|
||||
});
|
||||
|
||||
return reply.code(200).send(upcoming);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get upcoming maintenance', {
|
||||
operation: 'maintenance.upcoming.error',
|
||||
user_id: userId,
|
||||
vehicle_id: vehicleId,
|
||||
userId,
|
||||
vehicleId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -515,16 +515,16 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance subtypes requested', {
|
||||
operation: 'maintenance.subtypes',
|
||||
user_id: userId,
|
||||
category: category,
|
||||
userId,
|
||||
category,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!['routine_maintenance', 'repair', 'performance_upgrade'].includes(category)) {
|
||||
logger.warn('Invalid maintenance category', {
|
||||
operation: 'maintenance.subtypes.invalid_category',
|
||||
user_id: userId,
|
||||
category: category,
|
||||
userId,
|
||||
category,
|
||||
});
|
||||
return reply.code(400).send({ error: 'Bad Request', message: 'Invalid category' });
|
||||
}
|
||||
@@ -533,17 +533,17 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance subtypes retrieved', {
|
||||
operation: 'maintenance.subtypes.success',
|
||||
user_id: userId,
|
||||
category: category,
|
||||
subtype_count: subtypes.length,
|
||||
userId,
|
||||
category,
|
||||
subtypeCount: subtypes.length,
|
||||
});
|
||||
|
||||
return reply.code(200).send({ category, subtypes: Array.from(subtypes) });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get maintenance subtypes', {
|
||||
operation: 'maintenance.subtypes.error',
|
||||
user_id: userId,
|
||||
category: category,
|
||||
userId,
|
||||
category,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
|
||||
@@ -5,20 +5,61 @@ import type { MaintenanceRecord, MaintenanceSchedule, MaintenanceCategory } from
|
||||
export class MaintenanceRepository {
|
||||
constructor(private readonly db: Pool = pool) {}
|
||||
|
||||
// ========================
|
||||
// Row Mappers
|
||||
// ========================
|
||||
|
||||
private mapMaintenanceRecord(row: any): MaintenanceRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
vehicleId: row.vehicle_id,
|
||||
category: row.category,
|
||||
subtypes: row.subtypes,
|
||||
date: row.date,
|
||||
odometerReading: row.odometer_reading,
|
||||
cost: row.cost,
|
||||
shopName: row.shop_name,
|
||||
notes: row.notes,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
private mapMaintenanceSchedule(row: any): MaintenanceSchedule {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
vehicleId: row.vehicle_id,
|
||||
category: row.category,
|
||||
subtypes: row.subtypes,
|
||||
intervalMonths: row.interval_months,
|
||||
intervalMiles: row.interval_miles,
|
||||
lastServiceDate: row.last_service_date,
|
||||
lastServiceMileage: row.last_service_mileage,
|
||||
nextDueDate: row.next_due_date,
|
||||
nextDueMileage: row.next_due_mileage,
|
||||
isActive: row.is_active,
|
||||
emailNotifications: row.email_notifications,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Maintenance Records
|
||||
// ========================
|
||||
|
||||
async insertRecord(record: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
vehicle_id: string;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
category: MaintenanceCategory;
|
||||
subtypes: string[];
|
||||
date: string;
|
||||
odometer_reading?: number | null;
|
||||
odometerReading?: number | null;
|
||||
cost?: number | null;
|
||||
shop_name?: string | null;
|
||||
shopName?: string | null;
|
||||
notes?: string | null;
|
||||
}): Promise<MaintenanceRecord> {
|
||||
const res = await this.db.query(
|
||||
@@ -28,18 +69,18 @@ export class MaintenanceRepository {
|
||||
RETURNING *`,
|
||||
[
|
||||
record.id,
|
||||
record.user_id,
|
||||
record.vehicle_id,
|
||||
record.userId,
|
||||
record.vehicleId,
|
||||
record.category,
|
||||
record.subtypes,
|
||||
record.date,
|
||||
record.odometer_reading ?? null,
|
||||
record.odometerReading ?? null,
|
||||
record.cost ?? null,
|
||||
record.shop_name ?? null,
|
||||
record.shopName ?? null,
|
||||
record.notes ?? null,
|
||||
]
|
||||
);
|
||||
return res.rows[0] as MaintenanceRecord;
|
||||
return this.mapMaintenanceRecord(res.rows[0]);
|
||||
}
|
||||
|
||||
async findRecordById(id: string, userId: string): Promise<MaintenanceRecord | null> {
|
||||
@@ -47,7 +88,7 @@ export class MaintenanceRepository {
|
||||
`SELECT * FROM maintenance_records WHERE id = $1 AND user_id = $2`,
|
||||
[id, userId]
|
||||
);
|
||||
return res.rows[0] || null;
|
||||
return res.rows[0] ? this.mapMaintenanceRecord(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async findRecordsByUserId(
|
||||
@@ -69,7 +110,7 @@ export class MaintenanceRepository {
|
||||
|
||||
const sql = `SELECT * FROM maintenance_records WHERE ${conds.join(' AND ')} ORDER BY date DESC`;
|
||||
const res = await this.db.query(sql, params);
|
||||
return res.rows as MaintenanceRecord[];
|
||||
return res.rows.map(row => this.mapMaintenanceRecord(row));
|
||||
}
|
||||
|
||||
async findRecordsByVehicleId(vehicleId: string, userId: string): Promise<MaintenanceRecord[]> {
|
||||
@@ -77,13 +118,13 @@ export class MaintenanceRepository {
|
||||
`SELECT * FROM maintenance_records WHERE vehicle_id = $1 AND user_id = $2 ORDER BY date DESC`,
|
||||
[vehicleId, userId]
|
||||
);
|
||||
return res.rows as MaintenanceRecord[];
|
||||
return res.rows.map(row => this.mapMaintenanceRecord(row));
|
||||
}
|
||||
|
||||
async updateRecord(
|
||||
id: string,
|
||||
userId: string,
|
||||
patch: Partial<Pick<MaintenanceRecord, 'category' | 'subtypes' | 'date' | 'odometer_reading' | 'cost' | 'shop_name' | 'notes'>>
|
||||
patch: Partial<Pick<MaintenanceRecord, 'category' | 'subtypes' | 'date' | 'odometerReading' | 'cost' | 'shopName' | 'notes'>>
|
||||
): Promise<MaintenanceRecord | null> {
|
||||
const fields: string[] = [];
|
||||
const params: any[] = [];
|
||||
@@ -101,17 +142,17 @@ export class MaintenanceRepository {
|
||||
fields.push(`date = $${i++}`);
|
||||
params.push(patch.date);
|
||||
}
|
||||
if (patch.odometer_reading !== undefined) {
|
||||
if (patch.odometerReading !== undefined) {
|
||||
fields.push(`odometer_reading = $${i++}`);
|
||||
params.push(patch.odometer_reading);
|
||||
params.push(patch.odometerReading);
|
||||
}
|
||||
if (patch.cost !== undefined) {
|
||||
fields.push(`cost = $${i++}`);
|
||||
params.push(patch.cost);
|
||||
}
|
||||
if (patch.shop_name !== undefined) {
|
||||
if (patch.shopName !== undefined) {
|
||||
fields.push(`shop_name = $${i++}`);
|
||||
params.push(patch.shop_name);
|
||||
params.push(patch.shopName);
|
||||
}
|
||||
if (patch.notes !== undefined) {
|
||||
fields.push(`notes = $${i++}`);
|
||||
@@ -123,7 +164,7 @@ export class MaintenanceRepository {
|
||||
params.push(id, userId);
|
||||
const sql = `UPDATE maintenance_records SET ${fields.join(', ')} WHERE id = $${i++} AND user_id = $${i++} RETURNING *`;
|
||||
const res = await this.db.query(sql, params);
|
||||
return res.rows[0] || null;
|
||||
return res.rows[0] ? this.mapMaintenanceRecord(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async deleteRecord(id: string, userId: string): Promise<void> {
|
||||
@@ -139,40 +180,42 @@ export class MaintenanceRepository {
|
||||
|
||||
async insertSchedule(schedule: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
vehicle_id: string;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
category: MaintenanceCategory;
|
||||
subtypes: string[];
|
||||
interval_months?: number | null;
|
||||
interval_miles?: number | null;
|
||||
last_service_date?: string | null;
|
||||
last_service_mileage?: number | null;
|
||||
next_due_date?: string | null;
|
||||
next_due_mileage?: number | null;
|
||||
is_active: boolean;
|
||||
intervalMonths?: number | null;
|
||||
intervalMiles?: number | null;
|
||||
lastServiceDate?: string | null;
|
||||
lastServiceMileage?: number | null;
|
||||
nextDueDate?: string | null;
|
||||
nextDueMileage?: number | null;
|
||||
isActive: boolean;
|
||||
emailNotifications?: boolean;
|
||||
}): Promise<MaintenanceSchedule> {
|
||||
const res = await this.db.query(
|
||||
`INSERT INTO maintenance_schedules (
|
||||
id, user_id, vehicle_id, category, subtypes, interval_months, interval_miles,
|
||||
last_service_date, last_service_mileage, next_due_date, next_due_mileage, is_active
|
||||
) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11, $12)
|
||||
last_service_date, last_service_mileage, next_due_date, next_due_mileage, is_active, email_notifications
|
||||
) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING *`,
|
||||
[
|
||||
schedule.id,
|
||||
schedule.user_id,
|
||||
schedule.vehicle_id,
|
||||
schedule.userId,
|
||||
schedule.vehicleId,
|
||||
schedule.category,
|
||||
schedule.subtypes,
|
||||
schedule.interval_months ?? null,
|
||||
schedule.interval_miles ?? null,
|
||||
schedule.last_service_date ?? null,
|
||||
schedule.last_service_mileage ?? null,
|
||||
schedule.next_due_date ?? null,
|
||||
schedule.next_due_mileage ?? null,
|
||||
schedule.is_active,
|
||||
schedule.intervalMonths ?? null,
|
||||
schedule.intervalMiles ?? null,
|
||||
schedule.lastServiceDate ?? null,
|
||||
schedule.lastServiceMileage ?? null,
|
||||
schedule.nextDueDate ?? null,
|
||||
schedule.nextDueMileage ?? null,
|
||||
schedule.isActive,
|
||||
schedule.emailNotifications ?? false,
|
||||
]
|
||||
);
|
||||
return res.rows[0] as MaintenanceSchedule;
|
||||
return this.mapMaintenanceSchedule(res.rows[0]);
|
||||
}
|
||||
|
||||
async findScheduleById(id: string, userId: string): Promise<MaintenanceSchedule | null> {
|
||||
@@ -180,7 +223,7 @@ export class MaintenanceRepository {
|
||||
`SELECT * FROM maintenance_schedules WHERE id = $1 AND user_id = $2`,
|
||||
[id, userId]
|
||||
);
|
||||
return res.rows[0] || null;
|
||||
return res.rows[0] ? this.mapMaintenanceSchedule(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async findSchedulesByVehicleId(vehicleId: string, userId: string): Promise<MaintenanceSchedule[]> {
|
||||
@@ -188,7 +231,7 @@ export class MaintenanceRepository {
|
||||
`SELECT * FROM maintenance_schedules WHERE vehicle_id = $1 AND user_id = $2 ORDER BY created_at DESC`,
|
||||
[vehicleId, userId]
|
||||
);
|
||||
return res.rows as MaintenanceSchedule[];
|
||||
return res.rows.map(row => this.mapMaintenanceSchedule(row));
|
||||
}
|
||||
|
||||
async findActiveSchedulesByVehicleId(vehicleId: string, userId: string): Promise<MaintenanceSchedule[]> {
|
||||
@@ -196,13 +239,13 @@ export class MaintenanceRepository {
|
||||
`SELECT * FROM maintenance_schedules WHERE vehicle_id = $1 AND user_id = $2 AND is_active = true ORDER BY created_at DESC`,
|
||||
[vehicleId, userId]
|
||||
);
|
||||
return res.rows as MaintenanceSchedule[];
|
||||
return res.rows.map(row => this.mapMaintenanceSchedule(row));
|
||||
}
|
||||
|
||||
async updateSchedule(
|
||||
id: string,
|
||||
userId: string,
|
||||
patch: Partial<Pick<MaintenanceSchedule, 'category' | 'subtypes' | 'interval_months' | 'interval_miles' | 'last_service_date' | 'last_service_mileage' | 'next_due_date' | 'next_due_mileage' | 'is_active'>>
|
||||
patch: Partial<Pick<MaintenanceSchedule, 'category' | 'subtypes' | 'intervalMonths' | 'intervalMiles' | 'lastServiceDate' | 'lastServiceMileage' | 'nextDueDate' | 'nextDueMileage' | 'isActive' | 'emailNotifications'>>
|
||||
): Promise<MaintenanceSchedule | null> {
|
||||
const fields: string[] = [];
|
||||
const params: any[] = [];
|
||||
@@ -216,33 +259,37 @@ export class MaintenanceRepository {
|
||||
fields.push(`subtypes = $${i++}::text[]`);
|
||||
params.push(patch.subtypes);
|
||||
}
|
||||
if (patch.interval_months !== undefined) {
|
||||
if (patch.intervalMonths !== undefined) {
|
||||
fields.push(`interval_months = $${i++}`);
|
||||
params.push(patch.interval_months);
|
||||
params.push(patch.intervalMonths);
|
||||
}
|
||||
if (patch.interval_miles !== undefined) {
|
||||
if (patch.intervalMiles !== undefined) {
|
||||
fields.push(`interval_miles = $${i++}`);
|
||||
params.push(patch.interval_miles);
|
||||
params.push(patch.intervalMiles);
|
||||
}
|
||||
if (patch.last_service_date !== undefined) {
|
||||
if (patch.lastServiceDate !== undefined) {
|
||||
fields.push(`last_service_date = $${i++}`);
|
||||
params.push(patch.last_service_date);
|
||||
params.push(patch.lastServiceDate);
|
||||
}
|
||||
if (patch.last_service_mileage !== undefined) {
|
||||
if (patch.lastServiceMileage !== undefined) {
|
||||
fields.push(`last_service_mileage = $${i++}`);
|
||||
params.push(patch.last_service_mileage);
|
||||
params.push(patch.lastServiceMileage);
|
||||
}
|
||||
if (patch.next_due_date !== undefined) {
|
||||
if (patch.nextDueDate !== undefined) {
|
||||
fields.push(`next_due_date = $${i++}`);
|
||||
params.push(patch.next_due_date);
|
||||
params.push(patch.nextDueDate);
|
||||
}
|
||||
if (patch.next_due_mileage !== undefined) {
|
||||
if (patch.nextDueMileage !== undefined) {
|
||||
fields.push(`next_due_mileage = $${i++}`);
|
||||
params.push(patch.next_due_mileage);
|
||||
params.push(patch.nextDueMileage);
|
||||
}
|
||||
if (patch.is_active !== undefined) {
|
||||
if (patch.isActive !== undefined) {
|
||||
fields.push(`is_active = $${i++}`);
|
||||
params.push(patch.is_active);
|
||||
params.push(patch.isActive);
|
||||
}
|
||||
if (patch.emailNotifications !== undefined) {
|
||||
fields.push(`email_notifications = $${i++}`);
|
||||
params.push(patch.emailNotifications);
|
||||
}
|
||||
|
||||
if (!fields.length) return this.findScheduleById(id, userId);
|
||||
@@ -250,7 +297,7 @@ export class MaintenanceRepository {
|
||||
params.push(id, userId);
|
||||
const sql = `UPDATE maintenance_schedules SET ${fields.join(', ')} WHERE id = $${i++} AND user_id = $${i++} RETURNING *`;
|
||||
const res = await this.db.query(sql, params);
|
||||
return res.rows[0] || null;
|
||||
return res.rows[0] ? this.mapMaintenanceSchedule(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async deleteSchedule(id: string, userId: string): Promise<void> {
|
||||
|
||||
@@ -18,7 +18,7 @@ export class MaintenanceService {
|
||||
private readonly repo = new MaintenanceRepository(pool);
|
||||
|
||||
async createRecord(userId: string, body: CreateMaintenanceRecordRequest): Promise<MaintenanceRecord> {
|
||||
await this.assertVehicleOwnership(userId, body.vehicle_id);
|
||||
await this.assertVehicleOwnership(userId, body.vehicleId);
|
||||
|
||||
if (!validateSubtypes(body.category, body.subtypes)) {
|
||||
const err: any = new Error('Invalid subtypes for selected category');
|
||||
@@ -29,14 +29,14 @@ export class MaintenanceService {
|
||||
const id = randomUUID();
|
||||
return this.repo.insertRecord({
|
||||
id,
|
||||
user_id: userId,
|
||||
vehicle_id: body.vehicle_id,
|
||||
userId,
|
||||
vehicleId: body.vehicleId,
|
||||
category: body.category,
|
||||
subtypes: body.subtypes,
|
||||
date: body.date,
|
||||
odometer_reading: body.odometer_reading,
|
||||
odometerReading: body.odometerReading,
|
||||
cost: body.cost,
|
||||
shop_name: body.shop_name,
|
||||
shopName: body.shopName,
|
||||
notes: body.notes,
|
||||
});
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export class MaintenanceService {
|
||||
// Convert nulls to undefined for repository compatibility
|
||||
const cleanPatch = Object.fromEntries(
|
||||
Object.entries(patch).map(([k, v]) => [k, v === null ? undefined : v])
|
||||
) as Partial<Pick<MaintenanceRecord, 'date' | 'notes' | 'category' | 'subtypes' | 'odometer_reading' | 'cost' | 'shop_name'>>;
|
||||
) as Partial<Pick<MaintenanceRecord, 'date' | 'notes' | 'category' | 'subtypes' | 'odometerReading' | 'cost' | 'shopName'>>;
|
||||
|
||||
const updated = await this.repo.updateRecord(id, userId, cleanPatch);
|
||||
if (!updated) return null;
|
||||
@@ -86,7 +86,7 @@ export class MaintenanceService {
|
||||
}
|
||||
|
||||
async createSchedule(userId: string, body: CreateScheduleRequest): Promise<MaintenanceSchedule> {
|
||||
await this.assertVehicleOwnership(userId, body.vehicle_id);
|
||||
await this.assertVehicleOwnership(userId, body.vehicleId);
|
||||
|
||||
if (!validateSubtypes(body.category, body.subtypes)) {
|
||||
const err: any = new Error('Invalid subtypes for selected category');
|
||||
@@ -94,7 +94,7 @@ export class MaintenanceService {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!body.interval_months && !body.interval_miles) {
|
||||
if (!body.intervalMonths && !body.intervalMiles) {
|
||||
const err: any = new Error('At least one interval (months or miles) is required');
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
@@ -103,13 +103,14 @@ export class MaintenanceService {
|
||||
const id = randomUUID();
|
||||
return this.repo.insertSchedule({
|
||||
id,
|
||||
user_id: userId,
|
||||
vehicle_id: body.vehicle_id,
|
||||
userId,
|
||||
vehicleId: body.vehicleId,
|
||||
category: body.category,
|
||||
subtypes: body.subtypes,
|
||||
interval_months: body.interval_months,
|
||||
interval_miles: body.interval_miles,
|
||||
is_active: true,
|
||||
intervalMonths: body.intervalMonths,
|
||||
intervalMiles: body.intervalMiles,
|
||||
isActive: true,
|
||||
emailNotifications: body.emailNotifications ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,25 +144,25 @@ export class MaintenanceService {
|
||||
}
|
||||
|
||||
const needsRecalculation =
|
||||
patch.interval_months !== undefined ||
|
||||
patch.interval_miles !== undefined;
|
||||
patch.intervalMonths !== undefined ||
|
||||
patch.intervalMiles !== undefined;
|
||||
|
||||
let patchWithRecalc: any = { ...patch };
|
||||
const patchWithRecalc: any = { ...patch };
|
||||
if (needsRecalculation) {
|
||||
const nextDue = this.calculateNextDue({
|
||||
last_service_date: existing.last_service_date,
|
||||
last_service_mileage: existing.last_service_mileage,
|
||||
interval_months: patch.interval_months ?? existing.interval_months,
|
||||
interval_miles: patch.interval_miles ?? existing.interval_miles,
|
||||
lastServiceDate: existing.lastServiceDate,
|
||||
lastServiceMileage: existing.lastServiceMileage,
|
||||
intervalMonths: patch.intervalMonths ?? existing.intervalMonths,
|
||||
intervalMiles: patch.intervalMiles ?? existing.intervalMiles,
|
||||
});
|
||||
patchWithRecalc.next_due_date = nextDue.next_due_date ?? undefined;
|
||||
patchWithRecalc.next_due_mileage = nextDue.next_due_mileage ?? undefined;
|
||||
patchWithRecalc.nextDueDate = nextDue.nextDueDate ?? undefined;
|
||||
patchWithRecalc.nextDueMileage = nextDue.nextDueMileage ?? undefined;
|
||||
}
|
||||
|
||||
// Convert nulls to undefined for repository compatibility
|
||||
const cleanPatch = Object.fromEntries(
|
||||
Object.entries(patchWithRecalc).map(([k, v]) => [k, v === null ? undefined : v])
|
||||
) as Partial<Pick<MaintenanceSchedule, 'category' | 'subtypes' | 'interval_months' | 'interval_miles' | 'is_active' | 'last_service_date' | 'last_service_mileage' | 'next_due_date' | 'next_due_mileage'>>;
|
||||
) as Partial<Pick<MaintenanceSchedule, 'category' | 'subtypes' | 'intervalMonths' | 'intervalMiles' | 'isActive' | 'emailNotifications' | 'lastServiceDate' | 'lastServiceMileage' | 'nextDueDate' | 'nextDueMileage'>>;
|
||||
|
||||
const updated = await this.repo.updateSchedule(id, userId, cleanPatch);
|
||||
if (!updated) return null;
|
||||
@@ -178,7 +179,7 @@ export class MaintenanceService {
|
||||
|
||||
return schedules
|
||||
.map(s => this.toScheduleResponse(s, today, currentMileage))
|
||||
.filter(s => s.is_due_soon || s.is_overdue);
|
||||
.filter(s => s.isDueSoon || s.isOverdue);
|
||||
}
|
||||
|
||||
private async assertVehicleOwnership(userId: string, vehicleId: string) {
|
||||
@@ -191,66 +192,66 @@ export class MaintenanceService {
|
||||
}
|
||||
|
||||
private calculateNextDue(schedule: {
|
||||
last_service_date?: string | null;
|
||||
last_service_mileage?: number | null;
|
||||
interval_months?: number | null;
|
||||
interval_miles?: number | null;
|
||||
}): { next_due_date: string | null; next_due_mileage: number | null } {
|
||||
let next_due_date: string | null = null;
|
||||
let next_due_mileage: number | null = null;
|
||||
lastServiceDate?: string | null;
|
||||
lastServiceMileage?: number | null;
|
||||
intervalMonths?: number | null;
|
||||
intervalMiles?: number | null;
|
||||
}): { nextDueDate: string | null; nextDueMileage: number | null } {
|
||||
let nextDueDate: string | null = null;
|
||||
let nextDueMileage: number | null = null;
|
||||
|
||||
if (schedule.last_service_date && schedule.interval_months) {
|
||||
const lastDate = new Date(schedule.last_service_date);
|
||||
if (schedule.lastServiceDate && schedule.intervalMonths) {
|
||||
const lastDate = new Date(schedule.lastServiceDate);
|
||||
const nextDate = new Date(lastDate);
|
||||
nextDate.setMonth(nextDate.getMonth() + schedule.interval_months);
|
||||
next_due_date = nextDate.toISOString().split('T')[0];
|
||||
nextDate.setMonth(nextDate.getMonth() + schedule.intervalMonths);
|
||||
nextDueDate = nextDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
if (schedule.last_service_mileage !== null && schedule.last_service_mileage !== undefined && schedule.interval_miles) {
|
||||
next_due_mileage = schedule.last_service_mileage + schedule.interval_miles;
|
||||
if (schedule.lastServiceMileage !== null && schedule.lastServiceMileage !== undefined && schedule.intervalMiles) {
|
||||
nextDueMileage = schedule.lastServiceMileage + schedule.intervalMiles;
|
||||
}
|
||||
|
||||
return { next_due_date, next_due_mileage };
|
||||
return { nextDueDate, nextDueMileage };
|
||||
}
|
||||
|
||||
private toRecordResponse(record: MaintenanceRecord): MaintenanceRecordResponse {
|
||||
return {
|
||||
...record,
|
||||
subtype_count: record.subtypes.length,
|
||||
subtypeCount: record.subtypes.length,
|
||||
};
|
||||
}
|
||||
|
||||
private toScheduleResponse(schedule: MaintenanceSchedule, today?: string, currentMileage?: number): MaintenanceScheduleResponse {
|
||||
const todayStr = today || new Date().toISOString().split('T')[0];
|
||||
let is_due_soon = false;
|
||||
let is_overdue = false;
|
||||
let isDueSoon = false;
|
||||
let isOverdue = false;
|
||||
|
||||
if (schedule.next_due_date) {
|
||||
const nextDue = new Date(schedule.next_due_date);
|
||||
if (schedule.nextDueDate) {
|
||||
const nextDue = new Date(schedule.nextDueDate);
|
||||
const todayDate = new Date(todayStr);
|
||||
const daysUntilDue = Math.floor((nextDue.getTime() - todayDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysUntilDue < 0) {
|
||||
is_overdue = true;
|
||||
isOverdue = true;
|
||||
} else if (daysUntilDue <= 30) {
|
||||
is_due_soon = true;
|
||||
isDueSoon = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentMileage !== undefined && schedule.next_due_mileage !== null && schedule.next_due_mileage !== undefined) {
|
||||
const milesUntilDue = schedule.next_due_mileage - currentMileage;
|
||||
if (currentMileage !== undefined && schedule.nextDueMileage !== null && schedule.nextDueMileage !== undefined) {
|
||||
const milesUntilDue = schedule.nextDueMileage - currentMileage;
|
||||
if (milesUntilDue < 0) {
|
||||
is_overdue = true;
|
||||
isOverdue = true;
|
||||
} else if (milesUntilDue <= 500) {
|
||||
is_due_soon = true;
|
||||
isDueSoon = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...schedule,
|
||||
subtype_count: schedule.subtypes.length,
|
||||
is_due_soon,
|
||||
is_overdue,
|
||||
subtypeCount: schedule.subtypes.length,
|
||||
isDueSoon,
|
||||
isOverdue,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,50 +55,51 @@ export const PERFORMANCE_UPGRADE_SUBTYPES = [
|
||||
'Exterior'
|
||||
] as const;
|
||||
|
||||
// Database record types
|
||||
// Database record types (camelCase for TypeScript)
|
||||
export interface MaintenanceRecord {
|
||||
id: string;
|
||||
user_id: string;
|
||||
vehicle_id: string;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
category: MaintenanceCategory;
|
||||
subtypes: string[];
|
||||
date: string;
|
||||
odometer_reading?: number;
|
||||
odometerReading?: number;
|
||||
cost?: number;
|
||||
shop_name?: string;
|
||||
shopName?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MaintenanceSchedule {
|
||||
id: string;
|
||||
user_id: string;
|
||||
vehicle_id: string;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
category: MaintenanceCategory;
|
||||
subtypes: string[];
|
||||
interval_months?: number;
|
||||
interval_miles?: number;
|
||||
last_service_date?: string;
|
||||
last_service_mileage?: number;
|
||||
next_due_date?: string;
|
||||
next_due_mileage?: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
intervalMonths?: number;
|
||||
intervalMiles?: number;
|
||||
lastServiceDate?: string;
|
||||
lastServiceMileage?: number;
|
||||
nextDueDate?: string;
|
||||
nextDueMileage?: number;
|
||||
isActive: boolean;
|
||||
emailNotifications: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Zod schemas for validation
|
||||
// Zod schemas for validation (camelCase for API)
|
||||
export const MaintenanceCategorySchema = z.enum(['routine_maintenance', 'repair', 'performance_upgrade']);
|
||||
|
||||
export const CreateMaintenanceRecordSchema = z.object({
|
||||
vehicle_id: z.string().uuid(),
|
||||
vehicleId: z.string().uuid(),
|
||||
category: MaintenanceCategorySchema,
|
||||
subtypes: z.array(z.string()).min(1),
|
||||
date: z.string(),
|
||||
odometer_reading: z.number().int().positive().optional(),
|
||||
odometerReading: z.number().int().positive().optional(),
|
||||
cost: z.number().positive().optional(),
|
||||
shop_name: z.string().max(200).optional(),
|
||||
shopName: z.string().max(200).optional(),
|
||||
notes: z.string().max(10000).optional(),
|
||||
});
|
||||
export type CreateMaintenanceRecordRequest = z.infer<typeof CreateMaintenanceRecordSchema>;
|
||||
@@ -107,40 +108,42 @@ export const UpdateMaintenanceRecordSchema = z.object({
|
||||
category: MaintenanceCategorySchema.optional(),
|
||||
subtypes: z.array(z.string()).min(1).optional(),
|
||||
date: z.string().optional(),
|
||||
odometer_reading: z.number().int().positive().nullable().optional(),
|
||||
odometerReading: z.number().int().positive().nullable().optional(),
|
||||
cost: z.number().positive().nullable().optional(),
|
||||
shop_name: z.string().max(200).nullable().optional(),
|
||||
shopName: z.string().max(200).nullable().optional(),
|
||||
notes: z.string().max(10000).nullable().optional(),
|
||||
});
|
||||
export type UpdateMaintenanceRecordRequest = z.infer<typeof UpdateMaintenanceRecordSchema>;
|
||||
|
||||
export const CreateScheduleSchema = z.object({
|
||||
vehicle_id: z.string().uuid(),
|
||||
vehicleId: z.string().uuid(),
|
||||
category: MaintenanceCategorySchema,
|
||||
subtypes: z.array(z.string()).min(1),
|
||||
interval_months: z.number().int().positive().optional(),
|
||||
interval_miles: z.number().int().positive().optional(),
|
||||
intervalMonths: z.number().int().positive().optional(),
|
||||
intervalMiles: z.number().int().positive().optional(),
|
||||
emailNotifications: z.boolean().optional(),
|
||||
});
|
||||
export type CreateScheduleRequest = z.infer<typeof CreateScheduleSchema>;
|
||||
|
||||
export const UpdateScheduleSchema = z.object({
|
||||
category: MaintenanceCategorySchema.optional(),
|
||||
subtypes: z.array(z.string()).min(1).optional(),
|
||||
interval_months: z.number().int().positive().nullable().optional(),
|
||||
interval_miles: z.number().int().positive().nullable().optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
intervalMonths: z.number().int().positive().nullable().optional(),
|
||||
intervalMiles: z.number().int().positive().nullable().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
emailNotifications: z.boolean().optional(),
|
||||
});
|
||||
export type UpdateScheduleRequest = z.infer<typeof UpdateScheduleSchema>;
|
||||
|
||||
// Response types
|
||||
export interface MaintenanceRecordResponse extends MaintenanceRecord {
|
||||
subtype_count: number;
|
||||
subtypeCount: number;
|
||||
}
|
||||
|
||||
export interface MaintenanceScheduleResponse extends MaintenanceSchedule {
|
||||
subtype_count: number;
|
||||
is_due_soon?: boolean;
|
||||
is_overdue?: boolean;
|
||||
subtypeCount: number;
|
||||
isDueSoon?: boolean;
|
||||
isOverdue?: boolean;
|
||||
}
|
||||
|
||||
// Validation helpers
|
||||
|
||||
121
backend/src/features/notifications/README.md
Normal file
121
backend/src/features/notifications/README.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Notifications Feature Capsule
|
||||
|
||||
## Quick Summary
|
||||
|
||||
Email and toast notification system for maintenance due/overdue items and expiring documents. Uses Resend for email delivery and provides admin-editable email templates. User-scoped data with per-entry email notification toggles.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### User Endpoints
|
||||
- `GET /api/notifications/summary` - Get notification summary (counts for login toast)
|
||||
- `GET /api/notifications/maintenance` - Get due/overdue maintenance items
|
||||
- `GET /api/notifications/documents` - Get expiring/expired documents
|
||||
|
||||
### Admin Endpoints
|
||||
- `GET /api/admin/email-templates` - List all email templates
|
||||
- `GET /api/admin/email-templates/:key` - Get single email template
|
||||
- `PUT /api/admin/email-templates/:key` - Update email template
|
||||
- `POST /api/admin/email-templates/:key/preview` - Preview template with sample variables
|
||||
|
||||
## Structure
|
||||
|
||||
- **api/** - HTTP endpoints, routes, validators
|
||||
- **domain/** - Business logic, services, types
|
||||
- **data/** - Repository, database queries
|
||||
- **migrations/** - Feature-specific schema
|
||||
- **tests/** - All feature tests
|
||||
|
||||
## Email Templates
|
||||
|
||||
### Predefined Templates (4 total)
|
||||
1. **maintenance_due_soon** - Sent when maintenance is due within 30 days or 500 miles
|
||||
2. **maintenance_overdue** - Sent when maintenance is past due
|
||||
3. **document_expiring** - Sent when document expires within 30 days
|
||||
4. **document_expired** - Sent when document has expired
|
||||
|
||||
### Template Variables
|
||||
Templates use `{{variableName}}` syntax for variable substitution.
|
||||
|
||||
**Maintenance templates:**
|
||||
- userName, vehicleName, category, subtypes, dueDate, dueMileage
|
||||
|
||||
**Document templates:**
|
||||
- userName, vehicleName, documentType, documentTitle, expirationDate
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Internal
|
||||
- `core/auth` - Authentication plugin
|
||||
- `core/logging` - Structured logging
|
||||
- `core/config` - Database pool and secrets
|
||||
|
||||
### External
|
||||
- `resend` - Email delivery service
|
||||
|
||||
### Database
|
||||
- Tables: `email_templates`, `notification_logs`
|
||||
- FK: `maintenance_schedules(email_notifications)`, `documents(email_notifications)`
|
||||
|
||||
## Business Rules
|
||||
|
||||
### Notification Triggers
|
||||
|
||||
**Maintenance Due Soon:**
|
||||
- Next due date within 30 days OR
|
||||
- Next due mileage within 500 miles of current odometer
|
||||
|
||||
**Maintenance Overdue:**
|
||||
- Next due date in the past OR
|
||||
- Current odometer exceeds next due mileage
|
||||
|
||||
**Document Expiring Soon:**
|
||||
- Expiration date within 30 days
|
||||
|
||||
**Document Expired:**
|
||||
- Expiration date in the past
|
||||
|
||||
### Email Notification Toggle
|
||||
- Per-entry toggle on `maintenance_schedules.email_notifications`
|
||||
- Per-entry toggle on `documents.email_notifications`
|
||||
- Default: `false` (opt-in)
|
||||
|
||||
### Login Toast Summary
|
||||
- Shows count of maintenance items requiring attention
|
||||
- Shows count of documents requiring attention
|
||||
- Displayed once per session on successful login
|
||||
|
||||
## Security Requirements
|
||||
|
||||
1. All queries user-scoped (filter by user_id)
|
||||
2. Prepared statements (never concatenate SQL)
|
||||
3. User endpoints require JWT authentication
|
||||
4. Admin endpoints require admin role
|
||||
5. Template editing restricted to admins
|
||||
6. Email logs track all sent notifications
|
||||
|
||||
## Email Service Configuration
|
||||
|
||||
### Environment Variables
|
||||
- `RESEND_API_KEY` - Resend API key (required, stored in secrets)
|
||||
- `FROM_EMAIL` - Sender email address (default: noreply@motovaultpro.com)
|
||||
|
||||
### Email Delivery
|
||||
- Uses Resend API for transactional emails
|
||||
- Converts plain text templates to HTML with line breaks
|
||||
- Tracks all sent emails in `notification_logs` table
|
||||
- Logs failures with error messages for debugging
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run feature tests
|
||||
npm test -- features/notifications
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Batch notification processing (scheduled job)
|
||||
- Notification frequency controls (daily digest, etc.)
|
||||
- User preference for notification types
|
||||
- SMS notifications (via Twilio or similar)
|
||||
- Push notifications (via FCM or similar)
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* @ai-summary Controller for notifications API endpoints
|
||||
* @ai-context Handles requests for notification summary, templates, and sending
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { NotificationsService } from '../domain/notifications.service';
|
||||
import type { TemplateKey } from '../domain/notifications.types';
|
||||
import type {
|
||||
TemplateKeyParam,
|
||||
UpdateEmailTemplateRequest,
|
||||
PreviewTemplateRequest
|
||||
} from './notifications.validation';
|
||||
|
||||
export class NotificationsController {
|
||||
private service: NotificationsService;
|
||||
|
||||
constructor(service?: NotificationsService) {
|
||||
this.service = service || new NotificationsService();
|
||||
}
|
||||
|
||||
// ========================
|
||||
// User Endpoints
|
||||
// ========================
|
||||
|
||||
async getSummary(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = request.user.sub;
|
||||
|
||||
try {
|
||||
const summary = await this.service.getNotificationSummary(userId);
|
||||
return reply.code(200).send(summary);
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Failed to get notification summary');
|
||||
return reply.code(500).send({
|
||||
error: 'Failed to get notification summary'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getDueMaintenanceItems(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = request.user.sub;
|
||||
|
||||
try {
|
||||
const items = await this.service.getDueMaintenanceItems(userId);
|
||||
return reply.code(200).send(items);
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Failed to get due maintenance items');
|
||||
return reply.code(500).send({
|
||||
error: 'Failed to get due maintenance items'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getExpiringDocuments(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = request.user.sub;
|
||||
|
||||
try {
|
||||
const documents = await this.service.getExpiringDocuments(userId);
|
||||
return reply.code(200).send(documents);
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Failed to get expiring documents');
|
||||
return reply.code(500).send({
|
||||
error: 'Failed to get expiring documents'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Admin Endpoints
|
||||
// ========================
|
||||
|
||||
async listTemplates(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const templates = await this.service.listTemplates();
|
||||
return reply.code(200).send(templates);
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Failed to list email templates');
|
||||
return reply.code(500).send({
|
||||
error: 'Failed to list email templates'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getTemplate(
|
||||
request: FastifyRequest<{ Params: TemplateKeyParam }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { key } = request.params;
|
||||
|
||||
try {
|
||||
const template = await this.service.getTemplate(key as TemplateKey);
|
||||
|
||||
if (!template) {
|
||||
return reply.code(404).send({
|
||||
error: 'Template not found'
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(200).send(template);
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Failed to get email template');
|
||||
return reply.code(500).send({
|
||||
error: 'Failed to get email template'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateTemplate(
|
||||
request: FastifyRequest<{
|
||||
Params: TemplateKeyParam;
|
||||
Body: UpdateEmailTemplateRequest;
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { key } = request.params;
|
||||
const updates = request.body;
|
||||
|
||||
try {
|
||||
const template = await this.service.updateTemplate(key as TemplateKey, updates);
|
||||
|
||||
if (!template) {
|
||||
return reply.code(404).send({
|
||||
error: 'Template not found'
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(200).send(template);
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Failed to update email template');
|
||||
return reply.code(500).send({
|
||||
error: 'Failed to update email template'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async previewTemplate(
|
||||
request: FastifyRequest<{
|
||||
Params: TemplateKeyParam;
|
||||
Body: PreviewTemplateRequest;
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { subject, body, variables } = request.body;
|
||||
|
||||
try {
|
||||
const preview = await this.service.previewTemplate(subject, body, variables);
|
||||
return reply.code(200).send(preview);
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Failed to preview template');
|
||||
return reply.code(500).send({
|
||||
error: 'Failed to preview template'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async sendTestEmail(
|
||||
request: FastifyRequest<{ Params: TemplateKeyParam }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { key } = request.params;
|
||||
const userEmail = request.userContext?.email;
|
||||
const userName = request.userContext?.displayName || 'Test User';
|
||||
|
||||
if (!userEmail) {
|
||||
return reply.code(400).send({
|
||||
error: 'No email address available. Please set your email in Settings.'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.service.sendTestEmail(
|
||||
key as any,
|
||||
userEmail,
|
||||
userName
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return reply.code(500).send({
|
||||
error: result.error || 'Failed to send test email',
|
||||
subject: result.subject,
|
||||
body: result.body
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(200).send({
|
||||
message: `Test email sent to ${userEmail}`,
|
||||
subject: result.subject,
|
||||
body: result.body
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Failed to send test email');
|
||||
return reply.code(500).send({
|
||||
error: 'Failed to send test email'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @ai-summary Notifications feature routes
|
||||
* @ai-context Registers notification API endpoints with proper guards
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import type {
|
||||
TemplateKeyParam,
|
||||
UpdateEmailTemplateRequest,
|
||||
PreviewTemplateRequest
|
||||
} from './notifications.validation';
|
||||
|
||||
export const notificationsRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const controller = new NotificationsController();
|
||||
|
||||
// ========================
|
||||
// User Endpoints
|
||||
// ========================
|
||||
|
||||
// GET /api/notifications/summary - Get notification summary for login toast
|
||||
fastify.get('/notifications/summary', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.getSummary.bind(controller)
|
||||
});
|
||||
|
||||
// GET /api/notifications/maintenance - Get due maintenance items
|
||||
fastify.get('/notifications/maintenance', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.getDueMaintenanceItems.bind(controller)
|
||||
});
|
||||
|
||||
// GET /api/notifications/documents - Get expiring documents
|
||||
fastify.get('/notifications/documents', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.getExpiringDocuments.bind(controller)
|
||||
});
|
||||
|
||||
// ========================
|
||||
// Admin Endpoints
|
||||
// ========================
|
||||
|
||||
// GET /api/admin/email-templates - List all email templates
|
||||
fastify.get('/admin/email-templates', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: controller.listTemplates.bind(controller)
|
||||
});
|
||||
|
||||
// GET /api/admin/email-templates/:key - Get single email template
|
||||
fastify.get<{ Params: TemplateKeyParam }>('/admin/email-templates/:key', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: controller.getTemplate.bind(controller)
|
||||
});
|
||||
|
||||
// PUT /api/admin/email-templates/:key - Update email template
|
||||
fastify.put<{
|
||||
Params: TemplateKeyParam;
|
||||
Body: UpdateEmailTemplateRequest;
|
||||
}>('/admin/email-templates/:key', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: controller.updateTemplate.bind(controller)
|
||||
});
|
||||
|
||||
// POST /api/admin/email-templates/:key/preview - Preview template with variables
|
||||
fastify.post<{
|
||||
Params: TemplateKeyParam;
|
||||
Body: PreviewTemplateRequest;
|
||||
}>('/admin/email-templates/:key/preview', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: controller.previewTemplate.bind(controller)
|
||||
});
|
||||
|
||||
// POST /api/admin/email-templates/:key/test - Send test email to admin
|
||||
fastify.post<{ Params: TemplateKeyParam }>('/admin/email-templates/:key/test', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: controller.sendTestEmail.bind(controller)
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @ai-summary Validation schemas for notifications API
|
||||
* @ai-context Zod schemas for request validation
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// Template key parameter validation
|
||||
export const TemplateKeyParamSchema = z.object({
|
||||
key: z.enum([
|
||||
'maintenance_due_soon',
|
||||
'maintenance_overdue',
|
||||
'document_expiring',
|
||||
'document_expired'
|
||||
])
|
||||
});
|
||||
export type TemplateKeyParam = z.infer<typeof TemplateKeyParamSchema>;
|
||||
|
||||
// Update email template request
|
||||
export const UpdateEmailTemplateSchema = z.object({
|
||||
subject: z.string().min(1).max(255).optional(),
|
||||
body: z.string().min(1).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
export type UpdateEmailTemplateRequest = z.infer<typeof UpdateEmailTemplateSchema>;
|
||||
|
||||
// Preview template request
|
||||
export const PreviewTemplateSchema = z.object({
|
||||
subject: z.string().min(1).max(255),
|
||||
body: z.string().min(1),
|
||||
variables: z.record(z.string()),
|
||||
});
|
||||
export type PreviewTemplateRequest = z.infer<typeof PreviewTemplateSchema>;
|
||||
@@ -0,0 +1,272 @@
|
||||
import { Pool } from 'pg';
|
||||
import pool from '../../../core/config/database';
|
||||
import type {
|
||||
EmailTemplate,
|
||||
NotificationLog,
|
||||
NotificationSummary,
|
||||
DueMaintenanceItem,
|
||||
ExpiringDocument,
|
||||
TemplateKey
|
||||
} from '../domain/notifications.types';
|
||||
|
||||
export class NotificationsRepository {
|
||||
constructor(private readonly db: Pool = pool) {}
|
||||
|
||||
// ========================
|
||||
// Row Mappers
|
||||
// ========================
|
||||
|
||||
private mapEmailTemplate(row: any): EmailTemplate {
|
||||
return {
|
||||
id: row.id,
|
||||
templateKey: row.template_key,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
subject: row.subject,
|
||||
body: row.body,
|
||||
variables: row.variables,
|
||||
isActive: row.is_active,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
private mapDueMaintenanceItem(row: any): DueMaintenanceItem {
|
||||
return {
|
||||
id: row.id,
|
||||
vehicleId: row.vehicle_id,
|
||||
vehicleName: row.vehicle_name,
|
||||
category: row.category,
|
||||
subtypes: row.subtypes,
|
||||
nextDueDate: row.next_due_date,
|
||||
nextDueMileage: row.next_due_mileage,
|
||||
isDueSoon: row.is_due_soon,
|
||||
isOverdue: row.is_overdue,
|
||||
emailNotifications: row.email_notifications
|
||||
};
|
||||
}
|
||||
|
||||
private mapExpiringDocument(row: any): ExpiringDocument {
|
||||
return {
|
||||
id: row.id,
|
||||
vehicleId: row.vehicle_id,
|
||||
vehicleName: row.vehicle_name,
|
||||
documentType: row.document_type,
|
||||
title: row.title,
|
||||
expirationDate: row.expiration_date,
|
||||
isExpiringSoon: row.is_expiring_soon,
|
||||
isExpired: row.is_expired,
|
||||
emailNotifications: row.email_notifications
|
||||
};
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Email Templates
|
||||
// ========================
|
||||
|
||||
async getEmailTemplates(): Promise<EmailTemplate[]> {
|
||||
const res = await this.db.query(
|
||||
`SELECT * FROM email_templates ORDER BY template_key`
|
||||
);
|
||||
return res.rows.map(row => this.mapEmailTemplate(row));
|
||||
}
|
||||
|
||||
async getEmailTemplateByKey(key: TemplateKey): Promise<EmailTemplate | null> {
|
||||
const res = await this.db.query(
|
||||
`SELECT * FROM email_templates WHERE template_key = $1`,
|
||||
[key]
|
||||
);
|
||||
return res.rows[0] ? this.mapEmailTemplate(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async updateEmailTemplate(
|
||||
key: TemplateKey,
|
||||
updates: { subject?: string; body?: string; isActive?: boolean }
|
||||
): Promise<EmailTemplate | null> {
|
||||
const fields: string[] = [];
|
||||
const params: (string | boolean)[] = [];
|
||||
let i = 1;
|
||||
|
||||
if (updates.subject !== undefined) {
|
||||
fields.push(`subject = $${i++}`);
|
||||
params.push(updates.subject);
|
||||
}
|
||||
if (updates.body !== undefined) {
|
||||
fields.push(`body = $${i++}`);
|
||||
params.push(updates.body);
|
||||
}
|
||||
if (updates.isActive !== undefined) {
|
||||
fields.push(`is_active = $${i++}`);
|
||||
params.push(updates.isActive);
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return this.getEmailTemplateByKey(key);
|
||||
}
|
||||
|
||||
fields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||
params.push(key);
|
||||
|
||||
const sql = `
|
||||
UPDATE email_templates
|
||||
SET ${fields.join(', ')}
|
||||
WHERE template_key = $${i}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const res = await this.db.query(sql, params);
|
||||
return res.rows[0] ? this.mapEmailTemplate(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Notification Logs
|
||||
// ========================
|
||||
|
||||
async insertNotificationLog(log: {
|
||||
user_id: string;
|
||||
notification_type: 'email' | 'toast';
|
||||
template_key: TemplateKey;
|
||||
recipient_email?: string;
|
||||
subject?: string;
|
||||
reference_type?: string;
|
||||
reference_id?: string;
|
||||
status?: 'pending' | 'sent' | 'failed';
|
||||
error_message?: string;
|
||||
}): Promise<NotificationLog> {
|
||||
const res = await this.db.query(
|
||||
`INSERT INTO notification_logs (
|
||||
user_id, notification_type, template_key, recipient_email, subject,
|
||||
reference_type, reference_id, status, error_message
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *`,
|
||||
[
|
||||
log.user_id,
|
||||
log.notification_type,
|
||||
log.template_key,
|
||||
log.recipient_email ?? null,
|
||||
log.subject ?? null,
|
||||
log.reference_type ?? null,
|
||||
log.reference_id ?? null,
|
||||
log.status ?? 'sent',
|
||||
log.error_message ?? null,
|
||||
]
|
||||
);
|
||||
return res.rows[0] as NotificationLog;
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Notification Summary
|
||||
// ========================
|
||||
|
||||
async getNotificationSummary(userId: string): Promise<NotificationSummary> {
|
||||
// Get counts of due soon vs overdue maintenance items
|
||||
const maintenanceRes = await this.db.query(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE ms.next_due_date <= CURRENT_DATE) as overdue_count,
|
||||
COUNT(*) FILTER (WHERE ms.next_due_date > CURRENT_DATE AND ms.next_due_date <= CURRENT_DATE + INTERVAL '30 days') as due_soon_count
|
||||
FROM maintenance_schedules ms
|
||||
WHERE ms.user_id = $1
|
||||
AND ms.is_active = true
|
||||
AND ms.next_due_date IS NOT NULL
|
||||
AND ms.next_due_date <= CURRENT_DATE + INTERVAL '30 days'`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
// Get counts of expiring soon vs expired documents
|
||||
const documentRes = await this.db.query(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE d.expiration_date < CURRENT_DATE) as expired_count,
|
||||
COUNT(*) FILTER (WHERE d.expiration_date >= CURRENT_DATE AND d.expiration_date <= CURRENT_DATE + INTERVAL '30 days') as expiring_soon_count
|
||||
FROM documents d
|
||||
WHERE d.user_id = $1
|
||||
AND d.deleted_at IS NULL
|
||||
AND d.expiration_date IS NOT NULL
|
||||
AND d.expiration_date <= CURRENT_DATE + INTERVAL '30 days'`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
return {
|
||||
maintenanceDueSoon: parseInt(maintenanceRes.rows[0]?.due_soon_count || '0', 10),
|
||||
maintenanceOverdue: parseInt(maintenanceRes.rows[0]?.overdue_count || '0', 10),
|
||||
documentsExpiringSoon: parseInt(documentRes.rows[0]?.expiring_soon_count || '0', 10),
|
||||
documentsExpired: parseInt(documentRes.rows[0]?.expired_count || '0', 10),
|
||||
};
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Due Maintenance Items
|
||||
// ========================
|
||||
|
||||
async getDueMaintenanceItems(userId: string): Promise<DueMaintenanceItem[]> {
|
||||
const res = await this.db.query(
|
||||
`SELECT
|
||||
ms.id,
|
||||
ms.vehicle_id,
|
||||
v.name as vehicle_name,
|
||||
ms.category,
|
||||
ms.subtypes,
|
||||
ms.next_due_date,
|
||||
ms.next_due_mileage,
|
||||
ms.email_notifications,
|
||||
CASE
|
||||
WHEN ms.next_due_date <= CURRENT_DATE THEN true
|
||||
WHEN ms.next_due_mileage IS NOT NULL AND v.odometer >= ms.next_due_mileage THEN true
|
||||
ELSE false
|
||||
END as is_overdue,
|
||||
CASE
|
||||
WHEN ms.next_due_date <= CURRENT_DATE + INTERVAL '30 days' AND ms.next_due_date > CURRENT_DATE THEN true
|
||||
WHEN ms.next_due_mileage IS NOT NULL AND v.odometer >= ms.next_due_mileage - 500 AND v.odometer < ms.next_due_mileage THEN true
|
||||
ELSE false
|
||||
END as is_due_soon
|
||||
FROM maintenance_schedules ms
|
||||
JOIN vehicles v ON ms.vehicle_id = v.id
|
||||
WHERE ms.user_id = $1
|
||||
AND ms.is_active = true
|
||||
AND (
|
||||
ms.next_due_date <= CURRENT_DATE + INTERVAL '30 days'
|
||||
OR (ms.next_due_mileage IS NOT NULL AND v.odometer >= ms.next_due_mileage - 500)
|
||||
)
|
||||
ORDER BY
|
||||
CASE WHEN ms.next_due_date <= CURRENT_DATE THEN 0 ELSE 1 END,
|
||||
ms.next_due_date ASC NULLS LAST`,
|
||||
[userId]
|
||||
);
|
||||
return res.rows.map(row => this.mapDueMaintenanceItem(row));
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Expiring Documents
|
||||
// ========================
|
||||
|
||||
async getExpiringDocuments(userId: string): Promise<ExpiringDocument[]> {
|
||||
const res = await this.db.query(
|
||||
`SELECT
|
||||
d.id,
|
||||
d.vehicle_id,
|
||||
v.name as vehicle_name,
|
||||
d.document_type,
|
||||
d.title,
|
||||
d.expiration_date,
|
||||
d.email_notifications,
|
||||
CASE
|
||||
WHEN d.expiration_date < CURRENT_DATE THEN true
|
||||
ELSE false
|
||||
END as is_expired,
|
||||
CASE
|
||||
WHEN d.expiration_date >= CURRENT_DATE AND d.expiration_date <= CURRENT_DATE + INTERVAL '30 days' THEN true
|
||||
ELSE false
|
||||
END as is_expiring_soon
|
||||
FROM documents d
|
||||
JOIN vehicles v ON d.vehicle_id = v.id
|
||||
WHERE d.user_id = $1
|
||||
AND d.deleted_at IS NULL
|
||||
AND d.expiration_date IS NOT NULL
|
||||
AND d.expiration_date <= CURRENT_DATE + INTERVAL '30 days'
|
||||
ORDER BY
|
||||
CASE WHEN d.expiration_date < CURRENT_DATE THEN 0 ELSE 1 END,
|
||||
d.expiration_date ASC`,
|
||||
[userId]
|
||||
);
|
||||
return res.rows.map(row => this.mapExpiringDocument(row));
|
||||
}
|
||||
}
|
||||
54
backend/src/features/notifications/domain/email.service.ts
Normal file
54
backend/src/features/notifications/domain/email.service.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @ai-summary Email service using Resend
|
||||
* @ai-context Sends transactional emails with error handling
|
||||
*/
|
||||
|
||||
import { Resend } from 'resend';
|
||||
|
||||
export class EmailService {
|
||||
private resend: Resend;
|
||||
private fromEmail: string;
|
||||
|
||||
constructor() {
|
||||
const apiKey = process.env['RESEND_API_KEY'];
|
||||
if (!apiKey) {
|
||||
throw new Error('RESEND_API_KEY is not configured');
|
||||
}
|
||||
|
||||
this.resend = new Resend(apiKey);
|
||||
this.fromEmail = process.env['FROM_EMAIL'] || 'noreply@motovaultpro.com';
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email using Resend
|
||||
* @param to Recipient email address
|
||||
* @param subject Email subject line
|
||||
* @param html Email body (HTML format)
|
||||
* @returns Promise that resolves when email is sent
|
||||
*/
|
||||
async send(to: string, subject: string, html: string): Promise<void> {
|
||||
try {
|
||||
await this.resend.emails.send({
|
||||
from: this.fromEmail,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
throw new Error(`Failed to send email: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email with plain text body (converted to HTML)
|
||||
* @param to Recipient email address
|
||||
* @param subject Email subject line
|
||||
* @param text Email body (plain text)
|
||||
*/
|
||||
async sendText(to: string, subject: string, text: string): Promise<void> {
|
||||
// Convert plain text to HTML with proper line breaks
|
||||
const html = text.split('\n').map(line => `<p>${line}</p>`).join('');
|
||||
await this.send(to, subject, html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* @ai-summary Notifications service with core business logic
|
||||
* @ai-context Manages notification summary, due items, and email sending
|
||||
*/
|
||||
|
||||
import { NotificationsRepository } from '../data/notifications.repository';
|
||||
import { TemplateService } from './template.service';
|
||||
import { EmailService } from './email.service';
|
||||
import type {
|
||||
NotificationSummary,
|
||||
DueMaintenanceItem,
|
||||
ExpiringDocument,
|
||||
EmailTemplate,
|
||||
TemplateKey
|
||||
} from './notifications.types';
|
||||
|
||||
export class NotificationsService {
|
||||
private repository: NotificationsRepository;
|
||||
private templateService: TemplateService;
|
||||
private emailService: EmailService;
|
||||
|
||||
constructor(
|
||||
repository?: NotificationsRepository,
|
||||
templateService?: TemplateService,
|
||||
emailService?: EmailService
|
||||
) {
|
||||
this.repository = repository || new NotificationsRepository();
|
||||
this.templateService = templateService || new TemplateService();
|
||||
this.emailService = emailService || new EmailService();
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Summary and Queries
|
||||
// ========================
|
||||
|
||||
async getNotificationSummary(userId: string): Promise<NotificationSummary> {
|
||||
return this.repository.getNotificationSummary(userId);
|
||||
}
|
||||
|
||||
async getDueMaintenanceItems(userId: string): Promise<DueMaintenanceItem[]> {
|
||||
return this.repository.getDueMaintenanceItems(userId);
|
||||
}
|
||||
|
||||
async getExpiringDocuments(userId: string): Promise<ExpiringDocument[]> {
|
||||
return this.repository.getExpiringDocuments(userId);
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Email Templates
|
||||
// ========================
|
||||
|
||||
async listTemplates(): Promise<EmailTemplate[]> {
|
||||
return this.repository.getEmailTemplates();
|
||||
}
|
||||
|
||||
async getTemplate(key: TemplateKey): Promise<EmailTemplate | null> {
|
||||
return this.repository.getEmailTemplateByKey(key);
|
||||
}
|
||||
|
||||
async updateTemplate(
|
||||
key: TemplateKey,
|
||||
updates: { subject?: string; body?: string; isActive?: boolean }
|
||||
): Promise<EmailTemplate | null> {
|
||||
return this.repository.updateEmailTemplate(key, updates);
|
||||
}
|
||||
|
||||
async previewTemplate(
|
||||
subject: string,
|
||||
body: string,
|
||||
variables: Record<string, string | number | boolean | null | undefined>
|
||||
): Promise<{ subject: string; body: string }> {
|
||||
return {
|
||||
subject: this.templateService.render(subject, variables),
|
||||
body: this.templateService.render(body, variables),
|
||||
};
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Email Sending
|
||||
// ========================
|
||||
|
||||
async sendMaintenanceNotification(
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
userName: string,
|
||||
item: DueMaintenanceItem
|
||||
): Promise<void> {
|
||||
const templateKey: TemplateKey = item.isOverdue
|
||||
? 'maintenance_overdue'
|
||||
: 'maintenance_due_soon';
|
||||
|
||||
const template = await this.repository.getEmailTemplateByKey(templateKey);
|
||||
if (!template || !template.isActive) {
|
||||
throw new Error(`Template ${templateKey} not found or inactive`);
|
||||
}
|
||||
|
||||
const variables = {
|
||||
userName,
|
||||
vehicleName: item.vehicleName,
|
||||
category: item.category,
|
||||
subtypes: item.subtypes.join(', '),
|
||||
dueDate: item.nextDueDate || 'N/A',
|
||||
dueMileage: item.nextDueMileage?.toString() || 'N/A',
|
||||
};
|
||||
|
||||
const subject = this.templateService.render(template.subject, variables);
|
||||
const body = this.templateService.render(template.body, variables);
|
||||
|
||||
try {
|
||||
await this.emailService.sendText(userEmail, subject, body);
|
||||
|
||||
await this.repository.insertNotificationLog({
|
||||
user_id: userId,
|
||||
notification_type: 'email',
|
||||
template_key: templateKey,
|
||||
recipient_email: userEmail,
|
||||
subject,
|
||||
reference_type: 'maintenance_schedule',
|
||||
reference_id: item.id,
|
||||
status: 'sent',
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
await this.repository.insertNotificationLog({
|
||||
user_id: userId,
|
||||
notification_type: 'email',
|
||||
template_key: templateKey,
|
||||
recipient_email: userEmail,
|
||||
subject,
|
||||
reference_type: 'maintenance_schedule',
|
||||
reference_id: item.id,
|
||||
status: 'failed',
|
||||
error_message: errorMessage,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async sendDocumentNotification(
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
userName: string,
|
||||
document: ExpiringDocument
|
||||
): Promise<void> {
|
||||
const templateKey: TemplateKey = document.isExpired
|
||||
? 'document_expired'
|
||||
: 'document_expiring';
|
||||
|
||||
const template = await this.repository.getEmailTemplateByKey(templateKey);
|
||||
if (!template || !template.isActive) {
|
||||
throw new Error(`Template ${templateKey} not found or inactive`);
|
||||
}
|
||||
|
||||
const variables = {
|
||||
userName,
|
||||
vehicleName: document.vehicleName,
|
||||
documentType: document.documentType,
|
||||
documentTitle: document.title,
|
||||
expirationDate: document.expirationDate || 'N/A',
|
||||
};
|
||||
|
||||
const subject = this.templateService.render(template.subject, variables);
|
||||
const body = this.templateService.render(template.body, variables);
|
||||
|
||||
try {
|
||||
await this.emailService.sendText(userEmail, subject, body);
|
||||
|
||||
await this.repository.insertNotificationLog({
|
||||
user_id: userId,
|
||||
notification_type: 'email',
|
||||
template_key: templateKey,
|
||||
recipient_email: userEmail,
|
||||
subject,
|
||||
reference_type: 'document',
|
||||
reference_id: document.id,
|
||||
status: 'sent',
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
await this.repository.insertNotificationLog({
|
||||
user_id: userId,
|
||||
notification_type: 'email',
|
||||
template_key: templateKey,
|
||||
recipient_email: userEmail,
|
||||
subject,
|
||||
reference_type: 'document',
|
||||
reference_id: document.id,
|
||||
status: 'failed',
|
||||
error_message: errorMessage,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a test email for a template to verify email configuration
|
||||
* @param key Template key to test
|
||||
* @param recipientEmail Email address to send test to
|
||||
* @param recipientName Name to use in template
|
||||
* @returns Rendered subject and body that was sent
|
||||
*/
|
||||
async sendTestEmail(
|
||||
key: TemplateKey,
|
||||
recipientEmail: string,
|
||||
recipientName: string
|
||||
): Promise<{ subject: string; body: string; success: boolean; error?: string }> {
|
||||
const template = await this.repository.getEmailTemplateByKey(key);
|
||||
if (!template) {
|
||||
return {
|
||||
subject: '',
|
||||
body: '',
|
||||
success: false,
|
||||
error: `Template '${key}' not found`
|
||||
};
|
||||
}
|
||||
|
||||
// Sample variables based on template type
|
||||
const sampleVariables = this.getSampleVariables(key, recipientName);
|
||||
|
||||
const subject = this.templateService.render(template.subject, sampleVariables);
|
||||
const body = this.templateService.render(template.body, sampleVariables);
|
||||
|
||||
try {
|
||||
await this.emailService.sendText(recipientEmail, `[TEST] ${subject}`, body);
|
||||
|
||||
return {
|
||||
subject,
|
||||
body,
|
||||
success: true
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
subject,
|
||||
body,
|
||||
success: false,
|
||||
error: errorMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sample variables for a template based on its type
|
||||
*/
|
||||
private getSampleVariables(key: TemplateKey, userName: string): Record<string, string> {
|
||||
const baseVariables = { userName };
|
||||
|
||||
switch (key) {
|
||||
case 'maintenance_due_soon':
|
||||
case 'maintenance_overdue':
|
||||
return {
|
||||
...baseVariables,
|
||||
vehicleName: '2024 Toyota Camry',
|
||||
category: 'Routine Maintenance',
|
||||
subtypes: 'Oil Change, Air Filter',
|
||||
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toLocaleDateString(),
|
||||
dueMileage: '50,000',
|
||||
};
|
||||
case 'document_expiring':
|
||||
case 'document_expired':
|
||||
return {
|
||||
...baseVariables,
|
||||
vehicleName: '2024 Toyota Camry',
|
||||
documentType: 'Insurance',
|
||||
documentTitle: 'State Farm Auto Policy',
|
||||
expirationDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toLocaleDateString(),
|
||||
};
|
||||
default:
|
||||
return baseVariables;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all pending notifications (called by scheduled job)
|
||||
* This would typically be called by a cron job or scheduler
|
||||
*/
|
||||
async processNotifications(): Promise<void> {
|
||||
// This is a placeholder for batch notification processing
|
||||
// In a production system, this would:
|
||||
// 1. Query for users with email_notifications enabled
|
||||
// 2. Check which items need notifications
|
||||
// 3. Send batch emails
|
||||
// 4. Track sent notifications to avoid duplicates
|
||||
throw new Error('Batch notification processing not yet implemented');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @ai-summary Type definitions for notifications feature
|
||||
* @ai-context Email and toast notifications for maintenance and documents
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// Template key union type
|
||||
export type TemplateKey =
|
||||
| 'maintenance_due_soon'
|
||||
| 'maintenance_overdue'
|
||||
| 'document_expiring'
|
||||
| 'document_expired';
|
||||
|
||||
// Email template API response type (camelCase for frontend)
|
||||
export interface EmailTemplate {
|
||||
id: string;
|
||||
templateKey: TemplateKey;
|
||||
name: string;
|
||||
description?: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
variables: string[];
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Notification log database type
|
||||
export interface NotificationLog {
|
||||
id: string;
|
||||
user_id: string;
|
||||
notification_type: 'email' | 'toast';
|
||||
template_key: TemplateKey;
|
||||
recipient_email?: string;
|
||||
subject?: string;
|
||||
reference_type?: string;
|
||||
reference_id?: string;
|
||||
status: 'pending' | 'sent' | 'failed';
|
||||
error_message?: string;
|
||||
sent_at: string;
|
||||
}
|
||||
|
||||
// Summary for login toast (camelCase for frontend compatibility)
|
||||
export interface NotificationSummary {
|
||||
maintenanceDueSoon: number;
|
||||
maintenanceOverdue: number;
|
||||
documentsExpiringSoon: number;
|
||||
documentsExpired: number;
|
||||
}
|
||||
|
||||
// Due maintenance item (camelCase for frontend)
|
||||
export interface DueMaintenanceItem {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
vehicleName: string;
|
||||
category: string;
|
||||
subtypes: string[];
|
||||
nextDueDate?: string;
|
||||
nextDueMileage?: number;
|
||||
isDueSoon: boolean;
|
||||
isOverdue: boolean;
|
||||
emailNotifications: boolean;
|
||||
}
|
||||
|
||||
// Expiring document (camelCase for frontend)
|
||||
export interface ExpiringDocument {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
vehicleName: string;
|
||||
documentType: string;
|
||||
title: string;
|
||||
expirationDate?: string;
|
||||
isExpiringSoon: boolean;
|
||||
isExpired: boolean;
|
||||
emailNotifications: boolean;
|
||||
}
|
||||
|
||||
// Zod schemas for validation
|
||||
export const TemplateKeySchema = z.enum([
|
||||
'maintenance_due_soon',
|
||||
'maintenance_overdue',
|
||||
'document_expiring',
|
||||
'document_expired'
|
||||
]);
|
||||
|
||||
export const UpdateEmailTemplateSchema = z.object({
|
||||
subject: z.string().min(1).max(255).optional(),
|
||||
body: z.string().min(1).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
export type UpdateEmailTemplateRequest = z.infer<typeof UpdateEmailTemplateSchema>;
|
||||
|
||||
export const PreviewTemplateSchema = z.object({
|
||||
subject: z.string().min(1).max(255),
|
||||
body: z.string().min(1),
|
||||
variables: z.record(z.string()),
|
||||
});
|
||||
export type PreviewTemplateRequest = z.infer<typeof PreviewTemplateSchema>;
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @ai-summary Simple template variable substitution service
|
||||
* @ai-context Replaces {{variableName}} with values
|
||||
*/
|
||||
|
||||
export class TemplateService {
|
||||
/**
|
||||
* Render a template string by replacing {{variableName}} with values
|
||||
* @param template Template string with {{variable}} placeholders
|
||||
* @param variables Object mapping variable names to values
|
||||
* @returns Rendered string with variables replaced
|
||||
*/
|
||||
render(template: string, variables: Record<string, string | number | boolean | null | undefined>): string {
|
||||
let result = template;
|
||||
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
const placeholder = `{{${key}}}`;
|
||||
const replacement = value !== null && value !== undefined ? String(value) : '';
|
||||
result = result.split(placeholder).join(replacement);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract variable names from a template string
|
||||
* @param template Template string with {{variable}} placeholders
|
||||
* @returns Array of variable names found in template
|
||||
*/
|
||||
extractVariables(template: string): string[] {
|
||||
const regex = /\{\{(\w+)\}\}/g;
|
||||
const variables: string[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(template)) !== null) {
|
||||
if (!variables.includes(match[1])) {
|
||||
variables.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return variables;
|
||||
}
|
||||
}
|
||||
6
backend/src/features/notifications/index.ts
Normal file
6
backend/src/features/notifications/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @ai-summary Notifications feature module export
|
||||
* @ai-context Exports routes for registration in app.ts
|
||||
*/
|
||||
|
||||
export { notificationsRoutes } from './api/notifications.routes';
|
||||
@@ -0,0 +1,93 @@
|
||||
-- email_templates: Admin-editable predefined templates
|
||||
CREATE TABLE email_templates (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
template_key VARCHAR(50) NOT NULL UNIQUE CHECK (template_key IN (
|
||||
'maintenance_due_soon', 'maintenance_overdue',
|
||||
'document_expiring', 'document_expired'
|
||||
)),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
subject VARCHAR(255) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
variables JSONB DEFAULT '[]'::jsonb,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- notification_logs: Track sent notifications
|
||||
CREATE TABLE notification_logs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
notification_type VARCHAR(20) NOT NULL CHECK (notification_type IN ('email', 'toast')),
|
||||
template_key VARCHAR(50) NOT NULL,
|
||||
recipient_email VARCHAR(255),
|
||||
subject VARCHAR(255),
|
||||
reference_type VARCHAR(50), -- 'maintenance_schedule' or 'document'
|
||||
reference_id UUID,
|
||||
status VARCHAR(20) DEFAULT 'sent' CHECK (status IN ('pending', 'sent', 'failed')),
|
||||
error_message TEXT,
|
||||
sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_notification_logs_user_id ON notification_logs(user_id);
|
||||
CREATE INDEX idx_notification_logs_reference ON notification_logs(reference_type, reference_id);
|
||||
CREATE INDEX idx_notification_logs_sent_at ON notification_logs(sent_at DESC);
|
||||
|
||||
-- Seed 4 default templates
|
||||
INSERT INTO email_templates (template_key, name, description, subject, body, variables) VALUES
|
||||
('maintenance_due_soon', 'Maintenance Due Soon', 'Sent when maintenance is due within 30 days or 500 miles',
|
||||
'MotoVaultPro: Maintenance Due Soon for {{vehicleName}}',
|
||||
'Hi {{userName}},
|
||||
|
||||
Your {{category}} maintenance for {{vehicleName}} is due soon.
|
||||
|
||||
Due Date: {{dueDate}}
|
||||
Due Mileage: {{dueMileage}} miles
|
||||
Items: {{subtypes}}
|
||||
|
||||
Best regards,
|
||||
MotoVaultPro',
|
||||
'["userName", "vehicleName", "category", "subtypes", "dueDate", "dueMileage"]'),
|
||||
('maintenance_overdue', 'Maintenance Overdue', 'Sent when maintenance is past due',
|
||||
'MotoVaultPro: OVERDUE Maintenance for {{vehicleName}}',
|
||||
'Hi {{userName}},
|
||||
|
||||
Your {{category}} maintenance for {{vehicleName}} is OVERDUE.
|
||||
|
||||
Was Due: {{dueDate}}
|
||||
Was Due At: {{dueMileage}} miles
|
||||
Items: {{subtypes}}
|
||||
|
||||
Please schedule service as soon as possible.
|
||||
|
||||
Best regards,
|
||||
MotoVaultPro',
|
||||
'["userName", "vehicleName", "category", "subtypes", "dueDate", "dueMileage"]'),
|
||||
('document_expiring', 'Document Expiring Soon', 'Sent when document expires within 30 days',
|
||||
'MotoVaultPro: {{documentTitle}} Expiring Soon',
|
||||
'Hi {{userName}},
|
||||
|
||||
Your {{documentType}} document "{{documentTitle}}" for {{vehicleName}} is expiring soon.
|
||||
|
||||
Expiration Date: {{expirationDate}}
|
||||
|
||||
Please renew before expiration.
|
||||
|
||||
Best regards,
|
||||
MotoVaultPro',
|
||||
'["userName", "vehicleName", "documentType", "documentTitle", "expirationDate"]'),
|
||||
('document_expired', 'Document Expired', 'Sent when document has expired',
|
||||
'MotoVaultPro: {{documentTitle}} Has EXPIRED',
|
||||
'Hi {{userName}},
|
||||
|
||||
Your {{documentType}} document "{{documentTitle}}" for {{vehicleName}} has EXPIRED.
|
||||
|
||||
Expired On: {{expirationDate}}
|
||||
|
||||
Please renew immediately.
|
||||
|
||||
Best regards,
|
||||
MotoVaultPro',
|
||||
'["userName", "vehicleName", "documentType", "documentTitle", "expirationDate"]');
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE maintenance_schedules ADD COLUMN email_notifications BOOLEAN DEFAULT false;
|
||||
ALTER TABLE documents ADD COLUMN email_notifications BOOLEAN DEFAULT false;
|
||||
|
||||
CREATE INDEX idx_maintenance_schedules_email_notifications
|
||||
ON maintenance_schedules(email_notifications) WHERE email_notifications = true AND is_active = true;
|
||||
CREATE INDEX idx_documents_email_notifications
|
||||
ON documents(email_notifications) WHERE email_notifications = true AND deleted_at IS NULL;
|
||||
90
backend/src/features/user-profile/README.md
Normal file
90
backend/src/features/user-profile/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# User Profile Feature Capsule
|
||||
|
||||
## Quick Summary
|
||||
|
||||
User profile management system that stores user information (email, display name, notification email) with defaults from Auth0 but allows user edits. Profile is automatically created on first login and persists user preferences.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### User Endpoints
|
||||
- `GET /api/user/profile` - Get current user's profile (creates if not exists)
|
||||
- `PUT /api/user/profile` - Update user profile (display name, notification email)
|
||||
|
||||
## Structure
|
||||
|
||||
- **api/** - HTTP endpoints, routes, validators
|
||||
- **domain/** - Business logic, services, types
|
||||
- **data/** - Repository, database queries
|
||||
- **migrations/** - Feature-specific schema
|
||||
|
||||
## Profile Data Model
|
||||
|
||||
### Fields
|
||||
- `id` - UUID primary key
|
||||
- `auth0_sub` - Auth0 user ID (unique, indexed)
|
||||
- `email` - User email (from Auth0)
|
||||
- `display_name` - Optional user-defined display name
|
||||
- `notification_email` - Optional alternate email for notifications
|
||||
- `created_at` - Profile creation timestamp
|
||||
- `updated_at` - Last update timestamp (auto-updated)
|
||||
|
||||
### Default Values
|
||||
On first login, profile is created with:
|
||||
- `email` - From Auth0 user token
|
||||
- `display_name` - From Auth0 user name (optional)
|
||||
- Other fields - null/empty
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Internal
|
||||
- `core/auth` - Authentication plugin (fastify.authenticate)
|
||||
- `core/logging` - Structured logging
|
||||
- `core/config` - Database pool
|
||||
|
||||
### External
|
||||
- None (Auth0 integration via core/auth)
|
||||
|
||||
### Database
|
||||
- Tables: `user_profiles`
|
||||
- Indexes: `idx_user_profiles_auth0_sub` on `auth0_sub`
|
||||
|
||||
## Business Rules
|
||||
|
||||
### Profile Creation
|
||||
- Profile created automatically on first API call after login
|
||||
- Uses Auth0 token data (sub, email, name) as defaults
|
||||
- `getOrCreate` pattern ensures idempotent profile access
|
||||
|
||||
### Profile Updates
|
||||
- Users can update `display_name` and `notification_email`
|
||||
- Email field is read-only (managed by Auth0)
|
||||
- At least one field required for update request
|
||||
- Validation enforced via Zod schemas
|
||||
|
||||
### Data Privacy
|
||||
- All queries scoped to authenticated user (auth0_sub)
|
||||
- No cross-user profile access
|
||||
- Profile data isolated per user
|
||||
|
||||
## Security Requirements
|
||||
|
||||
1. All endpoints require JWT authentication
|
||||
2. Users can only access their own profile
|
||||
3. Prepared statements prevent SQL injection
|
||||
4. Input validation via Zod schemas
|
||||
5. Email format validation for notification_email
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run feature tests (when implemented)
|
||||
npm test -- features/user-profile
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Avatar/profile photo upload
|
||||
- Additional user preferences (timezone, language, etc.)
|
||||
- Profile completion progress indicator
|
||||
- User account deletion/deactivation
|
||||
- Profile history/audit trail
|
||||
124
backend/src/features/user-profile/api/user-profile.controller.ts
Normal file
124
backend/src/features/user-profile/api/user-profile.controller.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @ai-summary Fastify route handlers for user profile API
|
||||
* @ai-context HTTP request/response handling with user authentication
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { UserProfileService } from '../domain/user-profile.service';
|
||||
import { UserProfileRepository } from '../data/user-profile.repository';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { UpdateProfileInput, updateProfileSchema } from './user-profile.validation';
|
||||
|
||||
export class UserProfileController {
|
||||
private userProfileService: UserProfileService;
|
||||
|
||||
constructor() {
|
||||
const repository = new UserProfileRepository(pool);
|
||||
this.userProfileService = new UserProfileService(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/user/profile - Get current user's profile
|
||||
*/
|
||||
async getProfile(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const auth0Sub = request.userContext?.userId;
|
||||
|
||||
if (!auth0Sub) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Get user data from Auth0 token
|
||||
const auth0User = {
|
||||
sub: auth0Sub,
|
||||
email: (request as any).user?.email || request.userContext?.email || '',
|
||||
name: (request as any).user?.name,
|
||||
};
|
||||
|
||||
// Get or create profile
|
||||
const profile = await this.userProfileService.getOrCreateProfile(
|
||||
auth0Sub,
|
||||
auth0User
|
||||
);
|
||||
|
||||
return reply.code(200).send(profile);
|
||||
} catch (error: any) {
|
||||
logger.error('Error getting user profile', {
|
||||
error: error.message,
|
||||
userId: request.userContext?.userId,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to retrieve user profile',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/user/profile - Update user profile
|
||||
*/
|
||||
async updateProfile(
|
||||
request: FastifyRequest<{ Body: UpdateProfileInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const auth0Sub = request.userContext?.userId;
|
||||
|
||||
if (!auth0Sub) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate request body
|
||||
const validation = updateProfileSchema.safeParse(request.body);
|
||||
if (!validation.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: validation.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const updates = validation.data;
|
||||
|
||||
// Update profile
|
||||
const profile = await this.userProfileService.updateProfile(
|
||||
auth0Sub,
|
||||
updates
|
||||
);
|
||||
|
||||
return reply.code(200).send(profile);
|
||||
} catch (error: any) {
|
||||
logger.error('Error updating user profile', {
|
||||
error: error.message,
|
||||
userId: request.userContext?.userId,
|
||||
});
|
||||
|
||||
if (error.message.includes('not found')) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: 'User profile not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message.includes('At least one field')) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to update user profile',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
24
backend/src/features/user-profile/api/user-profile.routes.ts
Normal file
24
backend/src/features/user-profile/api/user-profile.routes.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @ai-summary User profile feature routes
|
||||
* @ai-context Registers user profile API endpoints with authentication
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { UserProfileController } from './user-profile.controller';
|
||||
import { UpdateProfileInput } from './user-profile.validation';
|
||||
|
||||
export const userProfileRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const userProfileController = new UserProfileController();
|
||||
|
||||
// GET /api/user/profile - Get current user's profile
|
||||
fastify.get('/user/profile', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: userProfileController.getProfile.bind(userProfileController),
|
||||
});
|
||||
|
||||
// PUT /api/user/profile - Update user profile
|
||||
fastify.put<{ Body: UpdateProfileInput }>('/user/profile', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: userProfileController.updateProfile.bind(userProfileController),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @ai-summary Request validation schemas for user profile API
|
||||
* @ai-context Uses Zod for runtime validation and type safety
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export const updateProfileSchema = z.object({
|
||||
displayName: z.string().max(100, 'Display name must be 100 characters or less').optional(),
|
||||
notificationEmail: z.string().email('Invalid email format').optional(),
|
||||
}).refine(
|
||||
(data) => data.displayName !== undefined || data.notificationEmail !== undefined,
|
||||
{
|
||||
message: 'At least one field (displayName or notificationEmail) must be provided',
|
||||
}
|
||||
);
|
||||
|
||||
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
|
||||
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* @ai-summary User profile data access layer
|
||||
* @ai-context Provides parameterized SQL queries for user profile operations
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import {
|
||||
UserProfile,
|
||||
UserWithAdminStatus,
|
||||
ListUsersQuery,
|
||||
ListUsersResponse,
|
||||
SubscriptionTier,
|
||||
} from '../domain/user-profile.types';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
|
||||
// Base columns for user profile queries
|
||||
const USER_PROFILE_COLUMNS = `
|
||||
id, auth0_sub, email, display_name, notification_email,
|
||||
subscription_tier, deactivated_at, deactivated_by,
|
||||
created_at, updated_at
|
||||
`;
|
||||
|
||||
export class UserProfileRepository {
|
||||
constructor(private pool: Pool) {}
|
||||
|
||||
async getByAuth0Sub(auth0Sub: string): Promise<UserProfile | null> {
|
||||
const query = `
|
||||
SELECT ${USER_PROFILE_COLUMNS}
|
||||
FROM user_profiles
|
||||
WHERE auth0_sub = $1
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [auth0Sub]);
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching user profile by auth0_sub', { error, auth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async create(
|
||||
auth0Sub: string,
|
||||
email: string,
|
||||
displayName?: string
|
||||
): Promise<UserProfile> {
|
||||
const query = `
|
||||
INSERT INTO user_profiles (auth0_sub, email, display_name, subscription_tier)
|
||||
VALUES ($1, $2, $3, 'free')
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [
|
||||
auth0Sub,
|
||||
email,
|
||||
displayName || null,
|
||||
]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Failed to create user profile');
|
||||
}
|
||||
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error creating user profile', { error, auth0Sub, email });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
auth0Sub: string,
|
||||
updates: { displayName?: string; notificationEmail?: string }
|
||||
): Promise<UserProfile> {
|
||||
const setClauses: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (updates.displayName !== undefined) {
|
||||
setClauses.push(`display_name = $${paramIndex++}`);
|
||||
values.push(updates.displayName);
|
||||
}
|
||||
|
||||
if (updates.notificationEmail !== undefined) {
|
||||
setClauses.push(`notification_email = $${paramIndex++}`);
|
||||
values.push(updates.notificationEmail);
|
||||
}
|
||||
|
||||
if (setClauses.length === 0) {
|
||||
throw new Error('No fields to update');
|
||||
}
|
||||
|
||||
values.push(auth0Sub);
|
||||
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET ${setClauses.join(', ')}
|
||||
WHERE auth0_sub = $${paramIndex}
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, values);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User profile not found');
|
||||
}
|
||||
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error updating user profile', { error, auth0Sub, updates });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getOrCreate(
|
||||
auth0Sub: string,
|
||||
defaults: { email: string; displayName?: string }
|
||||
): Promise<UserProfile> {
|
||||
// Try to find existing profile
|
||||
const existing = await this.getByAuth0Sub(auth0Sub);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create new profile if not found
|
||||
return await this.create(auth0Sub, defaults.email, defaults.displayName);
|
||||
}
|
||||
|
||||
private mapRowToUserProfile(row: any): UserProfile {
|
||||
return {
|
||||
id: row.id,
|
||||
auth0Sub: row.auth0_sub,
|
||||
email: row.email,
|
||||
displayName: row.display_name,
|
||||
notificationEmail: row.notification_email,
|
||||
subscriptionTier: row.subscription_tier || 'free',
|
||||
deactivatedAt: row.deactivated_at ? new Date(row.deactivated_at) : null,
|
||||
deactivatedBy: row.deactivated_by || null,
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: new Date(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
private mapRowToUserWithAdminStatus(row: any): UserWithAdminStatus {
|
||||
return {
|
||||
...this.mapRowToUserProfile(row),
|
||||
isAdmin: !!row.admin_auth0_sub,
|
||||
adminRole: row.admin_role || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all users with pagination, search, and filters
|
||||
* Includes admin status by joining with admin_users table
|
||||
*/
|
||||
async listAllUsers(query: ListUsersQuery): Promise<ListUsersResponse> {
|
||||
const page = query.page || 1;
|
||||
const pageSize = query.pageSize || 20;
|
||||
const offset = (page - 1) * pageSize;
|
||||
const sortBy = query.sortBy || 'createdAt';
|
||||
const sortOrder = query.sortOrder || 'desc';
|
||||
|
||||
// Map sortBy to column names
|
||||
const sortColumnMap: Record<string, string> = {
|
||||
email: 'up.email',
|
||||
createdAt: 'up.created_at',
|
||||
displayName: 'up.display_name',
|
||||
subscriptionTier: 'up.subscription_tier',
|
||||
};
|
||||
const sortColumn = sortColumnMap[sortBy] || 'up.created_at';
|
||||
|
||||
const whereClauses: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// Search filter (email or display_name)
|
||||
if (query.search) {
|
||||
whereClauses.push(`(up.email ILIKE $${paramIndex} OR up.display_name ILIKE $${paramIndex})`);
|
||||
values.push(`%${query.search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Tier filter
|
||||
if (query.tier) {
|
||||
whereClauses.push(`up.subscription_tier = $${paramIndex}`);
|
||||
values.push(query.tier);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (query.status === 'active') {
|
||||
whereClauses.push('up.deactivated_at IS NULL');
|
||||
} else if (query.status === 'deactivated') {
|
||||
whereClauses.push('up.deactivated_at IS NOT NULL');
|
||||
}
|
||||
// 'all' means no filter
|
||||
|
||||
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
|
||||
|
||||
// Count query
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM user_profiles up
|
||||
${whereClause}
|
||||
`;
|
||||
|
||||
// Data query with admin status join
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
up.id, up.auth0_sub, up.email, up.display_name, up.notification_email,
|
||||
up.subscription_tier, up.deactivated_at, up.deactivated_by,
|
||||
up.created_at, up.updated_at,
|
||||
au.auth0_sub as admin_auth0_sub,
|
||||
au.role as admin_role
|
||||
FROM user_profiles up
|
||||
LEFT JOIN admin_users au ON up.auth0_sub = au.auth0_sub AND au.revoked_at IS NULL
|
||||
${whereClause}
|
||||
ORDER BY ${sortColumn} ${sortOrder === 'desc' ? 'DESC' : 'ASC'} NULLS LAST
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
try {
|
||||
const [countResult, dataResult] = await Promise.all([
|
||||
this.pool.query(countQuery, values),
|
||||
this.pool.query(dataQuery, [...values, pageSize, offset]),
|
||||
]);
|
||||
|
||||
const total = parseInt(countResult.rows[0]?.total || '0', 10);
|
||||
const users = dataResult.rows.map((row) => this.mapRowToUserWithAdminStatus(row));
|
||||
|
||||
return { users, total, page, pageSize };
|
||||
} catch (error) {
|
||||
logger.error('Error listing users', { error, query });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single user with admin status
|
||||
*/
|
||||
async getUserWithAdminStatus(auth0Sub: string): Promise<UserWithAdminStatus | null> {
|
||||
const query = `
|
||||
SELECT
|
||||
up.id, up.auth0_sub, up.email, up.display_name, up.notification_email,
|
||||
up.subscription_tier, up.deactivated_at, up.deactivated_by,
|
||||
up.created_at, up.updated_at,
|
||||
au.auth0_sub as admin_auth0_sub,
|
||||
au.role as admin_role
|
||||
FROM user_profiles up
|
||||
LEFT JOIN admin_users au ON up.auth0_sub = au.auth0_sub AND au.revoked_at IS NULL
|
||||
WHERE up.auth0_sub = $1
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [auth0Sub]);
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.mapRowToUserWithAdminStatus(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching user with admin status', { error, auth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user subscription tier
|
||||
*/
|
||||
async updateSubscriptionTier(
|
||||
auth0Sub: string,
|
||||
tier: SubscriptionTier
|
||||
): Promise<UserProfile> {
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET subscription_tier = $1
|
||||
WHERE auth0_sub = $2
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [tier, auth0Sub]);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User profile not found');
|
||||
}
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error updating subscription tier', { error, auth0Sub, tier });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate user (soft delete)
|
||||
*/
|
||||
async deactivateUser(auth0Sub: string, deactivatedBy: string): Promise<UserProfile> {
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET deactivated_at = NOW(), deactivated_by = $1
|
||||
WHERE auth0_sub = $2 AND deactivated_at IS NULL
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [deactivatedBy, auth0Sub]);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User profile not found or already deactivated');
|
||||
}
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error deactivating user', { error, auth0Sub, deactivatedBy });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate user
|
||||
*/
|
||||
async reactivateUser(auth0Sub: string): Promise<UserProfile> {
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET deactivated_at = NULL, deactivated_by = NULL
|
||||
WHERE auth0_sub = $1 AND deactivated_at IS NOT NULL
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [auth0Sub]);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User profile not found or not deactivated');
|
||||
}
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error reactivating user', { error, auth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin update of user profile (can update email and displayName)
|
||||
*/
|
||||
async adminUpdateProfile(
|
||||
auth0Sub: string,
|
||||
updates: { email?: string; displayName?: string }
|
||||
): Promise<UserProfile> {
|
||||
const setClauses: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (updates.email !== undefined) {
|
||||
setClauses.push(`email = $${paramIndex++}`);
|
||||
values.push(updates.email);
|
||||
}
|
||||
|
||||
if (updates.displayName !== undefined) {
|
||||
setClauses.push(`display_name = $${paramIndex++}`);
|
||||
values.push(updates.displayName);
|
||||
}
|
||||
|
||||
if (setClauses.length === 0) {
|
||||
throw new Error('No fields to update');
|
||||
}
|
||||
|
||||
values.push(auth0Sub);
|
||||
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET ${setClauses.join(', ')}, updated_at = NOW()
|
||||
WHERE auth0_sub = $${paramIndex}
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, values);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User profile not found');
|
||||
}
|
||||
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error admin updating user profile', { error, auth0Sub, updates });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
323
backend/src/features/user-profile/domain/user-profile.service.ts
Normal file
323
backend/src/features/user-profile/domain/user-profile.service.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* @ai-summary User profile business logic
|
||||
* @ai-context Handles user profile operations with Auth0 integration
|
||||
*/
|
||||
|
||||
import { UserProfileRepository } from '../data/user-profile.repository';
|
||||
import {
|
||||
UserProfile,
|
||||
UserWithAdminStatus,
|
||||
UpdateProfileRequest,
|
||||
Auth0UserData,
|
||||
ListUsersQuery,
|
||||
ListUsersResponse,
|
||||
SubscriptionTier,
|
||||
} from './user-profile.types';
|
||||
import { AdminRepository } from '../../admin/data/admin.repository';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
|
||||
export class UserProfileService {
|
||||
private adminRepository: AdminRepository | null = null;
|
||||
|
||||
constructor(private repository: UserProfileRepository) {}
|
||||
|
||||
/**
|
||||
* Set admin repository for audit logging (optional dependency injection)
|
||||
*/
|
||||
setAdminRepository(adminRepository: AdminRepository): void {
|
||||
this.adminRepository = adminRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create user profile from Auth0 user data
|
||||
* This method is called when a user logs in to ensure their profile exists
|
||||
*/
|
||||
async getOrCreateProfile(
|
||||
auth0Sub: string,
|
||||
auth0User: Auth0UserData
|
||||
): Promise<UserProfile> {
|
||||
try {
|
||||
const profile = await this.repository.getOrCreate(auth0Sub, {
|
||||
email: auth0User.email,
|
||||
displayName: auth0User.name,
|
||||
});
|
||||
|
||||
logger.info('User profile retrieved or created', {
|
||||
auth0Sub,
|
||||
profileId: profile.id,
|
||||
});
|
||||
|
||||
return profile;
|
||||
} catch (error) {
|
||||
logger.error('Error getting or creating user profile', {
|
||||
error,
|
||||
auth0Sub,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user profile by Auth0 sub
|
||||
*/
|
||||
async getProfile(auth0Sub: string): Promise<UserProfile | null> {
|
||||
try {
|
||||
return await this.repository.getByAuth0Sub(auth0Sub);
|
||||
} catch (error) {
|
||||
logger.error('Error getting user profile', { error, auth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
*/
|
||||
async updateProfile(
|
||||
auth0Sub: string,
|
||||
updates: UpdateProfileRequest
|
||||
): Promise<UserProfile> {
|
||||
try {
|
||||
// Validate that at least one field is being updated
|
||||
if (!updates.displayName && !updates.notificationEmail) {
|
||||
throw new Error('At least one field must be provided for update');
|
||||
}
|
||||
|
||||
// Perform the update
|
||||
const profile = await this.repository.update(auth0Sub, updates);
|
||||
|
||||
logger.info('User profile updated', {
|
||||
auth0Sub,
|
||||
profileId: profile.id,
|
||||
updatedFields: Object.keys(updates),
|
||||
});
|
||||
|
||||
return profile;
|
||||
} catch (error) {
|
||||
logger.error('Error updating user profile', { error, auth0Sub, updates });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Admin-focused methods (for user management)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* List all users with pagination and filters (admin-only)
|
||||
*/
|
||||
async listAllUsers(query: ListUsersQuery): Promise<ListUsersResponse> {
|
||||
try {
|
||||
return await this.repository.listAllUsers(query);
|
||||
} catch (error) {
|
||||
logger.error('Error listing all users', { error, query });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user details with admin status (admin-only)
|
||||
*/
|
||||
async getUserDetails(auth0Sub: string): Promise<UserWithAdminStatus | null> {
|
||||
try {
|
||||
return await this.repository.getUserWithAdminStatus(auth0Sub);
|
||||
} catch (error) {
|
||||
logger.error('Error getting user details', { error, auth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user subscription tier (admin-only)
|
||||
* Logs the change to admin audit logs
|
||||
*/
|
||||
async updateSubscriptionTier(
|
||||
auth0Sub: string,
|
||||
tier: SubscriptionTier,
|
||||
actorAuth0Sub: string
|
||||
): Promise<UserProfile> {
|
||||
try {
|
||||
// Get current user to log the change
|
||||
const currentUser = await this.repository.getByAuth0Sub(auth0Sub);
|
||||
if (!currentUser) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const previousTier = currentUser.subscriptionTier;
|
||||
|
||||
// Perform the update
|
||||
const updatedProfile = await this.repository.updateSubscriptionTier(auth0Sub, tier);
|
||||
|
||||
// Log to audit trail
|
||||
if (this.adminRepository) {
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorAuth0Sub,
|
||||
'UPDATE_TIER',
|
||||
auth0Sub,
|
||||
'user_profile',
|
||||
updatedProfile.id,
|
||||
{ previousTier, newTier: tier }
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('User subscription tier updated', {
|
||||
auth0Sub,
|
||||
previousTier,
|
||||
newTier: tier,
|
||||
actorAuth0Sub,
|
||||
});
|
||||
|
||||
return updatedProfile;
|
||||
} catch (error) {
|
||||
logger.error('Error updating subscription tier', { error, auth0Sub, tier, actorAuth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate user account (admin-only soft delete)
|
||||
* Prevents self-deactivation
|
||||
*/
|
||||
async deactivateUser(
|
||||
auth0Sub: string,
|
||||
actorAuth0Sub: string,
|
||||
reason?: string
|
||||
): Promise<UserProfile> {
|
||||
try {
|
||||
// Prevent self-deactivation
|
||||
if (auth0Sub === actorAuth0Sub) {
|
||||
throw new Error('Cannot deactivate your own account');
|
||||
}
|
||||
|
||||
// Verify user exists and is not already deactivated
|
||||
const currentUser = await this.repository.getByAuth0Sub(auth0Sub);
|
||||
if (!currentUser) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
if (currentUser.deactivatedAt) {
|
||||
throw new Error('User is already deactivated');
|
||||
}
|
||||
|
||||
// Perform the deactivation
|
||||
const deactivatedProfile = await this.repository.deactivateUser(auth0Sub, actorAuth0Sub);
|
||||
|
||||
// Log to audit trail
|
||||
if (this.adminRepository) {
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorAuth0Sub,
|
||||
'DEACTIVATE_USER',
|
||||
auth0Sub,
|
||||
'user_profile',
|
||||
deactivatedProfile.id,
|
||||
{ reason: reason || 'No reason provided' }
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('User deactivated', {
|
||||
auth0Sub,
|
||||
actorAuth0Sub,
|
||||
reason,
|
||||
});
|
||||
|
||||
return deactivatedProfile;
|
||||
} catch (error) {
|
||||
logger.error('Error deactivating user', { error, auth0Sub, actorAuth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate a deactivated user account (admin-only)
|
||||
*/
|
||||
async reactivateUser(
|
||||
auth0Sub: string,
|
||||
actorAuth0Sub: string
|
||||
): Promise<UserProfile> {
|
||||
try {
|
||||
// Verify user exists and is deactivated
|
||||
const currentUser = await this.repository.getByAuth0Sub(auth0Sub);
|
||||
if (!currentUser) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
if (!currentUser.deactivatedAt) {
|
||||
throw new Error('User is not deactivated');
|
||||
}
|
||||
|
||||
// Perform the reactivation
|
||||
const reactivatedProfile = await this.repository.reactivateUser(auth0Sub);
|
||||
|
||||
// Log to audit trail
|
||||
if (this.adminRepository) {
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorAuth0Sub,
|
||||
'REACTIVATE_USER',
|
||||
auth0Sub,
|
||||
'user_profile',
|
||||
reactivatedProfile.id,
|
||||
{ previouslyDeactivatedBy: currentUser.deactivatedBy }
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('User reactivated', {
|
||||
auth0Sub,
|
||||
actorAuth0Sub,
|
||||
});
|
||||
|
||||
return reactivatedProfile;
|
||||
} catch (error) {
|
||||
logger.error('Error reactivating user', { error, auth0Sub, actorAuth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin update of user profile (email, displayName)
|
||||
* Logs the change to admin audit logs
|
||||
*/
|
||||
async adminUpdateProfile(
|
||||
auth0Sub: string,
|
||||
updates: { email?: string; displayName?: string },
|
||||
actorAuth0Sub: string
|
||||
): Promise<UserProfile> {
|
||||
try {
|
||||
// Get current user to log the change
|
||||
const currentUser = await this.repository.getByAuth0Sub(auth0Sub);
|
||||
if (!currentUser) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const previousValues = {
|
||||
email: currentUser.email,
|
||||
displayName: currentUser.displayName,
|
||||
};
|
||||
|
||||
// Perform the update
|
||||
const updatedProfile = await this.repository.adminUpdateProfile(auth0Sub, updates);
|
||||
|
||||
// Log to audit trail
|
||||
if (this.adminRepository) {
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorAuth0Sub,
|
||||
'UPDATE_PROFILE',
|
||||
auth0Sub,
|
||||
'user_profile',
|
||||
updatedProfile.id,
|
||||
{
|
||||
previousValues,
|
||||
newValues: updates,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('User profile updated by admin', {
|
||||
auth0Sub,
|
||||
updatedFields: Object.keys(updates),
|
||||
actorAuth0Sub,
|
||||
});
|
||||
|
||||
return updatedProfile;
|
||||
} catch (error) {
|
||||
logger.error('Error admin updating user profile', { error, auth0Sub, updates, actorAuth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @ai-summary User profile domain types
|
||||
* @ai-context Type definitions for user profile data models
|
||||
*/
|
||||
|
||||
// Subscription tier enum
|
||||
export type SubscriptionTier = 'free' | 'pro' | 'enterprise';
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
auth0Sub: string;
|
||||
email: string;
|
||||
displayName?: string;
|
||||
notificationEmail?: string;
|
||||
subscriptionTier: SubscriptionTier;
|
||||
deactivatedAt: Date | null;
|
||||
deactivatedBy: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// User with admin status for admin views (joined from admin_users table)
|
||||
export interface UserWithAdminStatus extends UserProfile {
|
||||
isAdmin: boolean;
|
||||
adminRole: 'admin' | 'super_admin' | null;
|
||||
}
|
||||
|
||||
// Pagination and filter query params for listing users
|
||||
export interface ListUsersQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
tier?: SubscriptionTier;
|
||||
status?: 'active' | 'deactivated' | 'all';
|
||||
sortBy?: 'email' | 'createdAt' | 'displayName' | 'subscriptionTier';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// Paginated response for user listing
|
||||
export interface ListUsersResponse {
|
||||
users: UserWithAdminStatus[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
// Request to update subscription tier
|
||||
export interface UpdateTierRequest {
|
||||
subscriptionTier: SubscriptionTier;
|
||||
}
|
||||
|
||||
// Request to deactivate a user
|
||||
export interface DeactivateUserRequest {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProfileRequest {
|
||||
displayName?: string;
|
||||
notificationEmail?: string;
|
||||
}
|
||||
|
||||
export interface Auth0UserData {
|
||||
sub: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
10
backend/src/features/user-profile/index.ts
Normal file
10
backend/src/features/user-profile/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* @ai-summary User profile feature exports
|
||||
* @ai-context Barrel export for user profile feature
|
||||
*/
|
||||
|
||||
export { userProfileRoutes } from './api/user-profile.routes';
|
||||
export { UserProfileController } from './api/user-profile.controller';
|
||||
export { UserProfileService } from './domain/user-profile.service';
|
||||
export { UserProfileRepository } from './data/user-profile.repository';
|
||||
export type { UserProfile, UpdateProfileRequest } from './domain/user-profile.types';
|
||||
@@ -0,0 +1,29 @@
|
||||
-- User Profile Table
|
||||
-- Stores user profile information that defaults from Auth0 but allows user edits
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
auth0_sub VARCHAR(255) NOT NULL UNIQUE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(100),
|
||||
notification_email VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Index for efficient lookup by Auth0 user ID
|
||||
CREATE INDEX IF NOT EXISTS idx_user_profiles_auth0_sub ON user_profiles(auth0_sub);
|
||||
|
||||
-- Trigger to automatically update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_user_profiles_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER user_profiles_updated_at
|
||||
BEFORE UPDATE ON user_profiles
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_user_profiles_updated_at();
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Add subscription tier and soft delete support to user_profiles
|
||||
-- Migration: 002_add_subscription_and_deactivation.sql
|
||||
|
||||
-- Create subscription tier ENUM type
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE subscription_tier AS ENUM ('free', 'pro', 'enterprise');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- Add subscription_tier column (default to 'free' for all users)
|
||||
ALTER TABLE user_profiles
|
||||
ADD COLUMN IF NOT EXISTS subscription_tier subscription_tier NOT NULL DEFAULT 'free';
|
||||
|
||||
-- Add soft delete fields
|
||||
ALTER TABLE user_profiles
|
||||
ADD COLUMN IF NOT EXISTS deactivated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL;
|
||||
|
||||
ALTER TABLE user_profiles
|
||||
ADD COLUMN IF NOT EXISTS deactivated_by VARCHAR(255) DEFAULT NULL;
|
||||
|
||||
-- Index for filtering by subscription tier
|
||||
CREATE INDEX IF NOT EXISTS idx_user_profiles_subscription_tier
|
||||
ON user_profiles(subscription_tier);
|
||||
|
||||
-- Index for filtering active vs deactivated users
|
||||
CREATE INDEX IF NOT EXISTS idx_user_profiles_deactivated_at
|
||||
ON user_profiles(deactivated_at);
|
||||
|
||||
-- Composite index for efficient admin queries (tier + active status)
|
||||
CREATE INDEX IF NOT EXISTS idx_user_profiles_tier_status
|
||||
ON user_profiles(subscription_tier, deactivated_at);
|
||||
@@ -39,7 +39,7 @@ export function normalizeModelName(input?: string | null): string | undefined {
|
||||
|
||||
export function normalizeMakeName(input?: string | null): string | undefined {
|
||||
if (input == null) return input ?? undefined;
|
||||
let s = String(input).replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const s = String(input).replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (s.length === 0) return s;
|
||||
const title = s.toLowerCase().split(' ').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
||||
// Special cases
|
||||
|
||||
@@ -109,6 +109,7 @@ services:
|
||||
- ./secrets/app/auth0-client-secret.txt:/run/secrets/auth0-client-secret:ro
|
||||
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
|
||||
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
|
||||
- ./secrets/app/resend-api-key.txt:/run/secrets/resend-api-key:ro
|
||||
# Filesystem storage for documents
|
||||
- ./data/documents:/app/data/documents
|
||||
networks:
|
||||
|
||||
@@ -87,6 +87,7 @@ These variables use GitLab's **File** type, which writes the value to a temporar
|
||||
| `GOOGLE_MAPS_API_KEY` | File | Yes | Yes | Google Maps API key |
|
||||
| `GOOGLE_MAPS_MAP_ID` | File | Yes | No | Google Maps Map ID |
|
||||
| `CF_DNS_API_TOKEN` | File | Yes | Yes | Cloudflare API token for Let's Encrypt DNS challenge |
|
||||
| `RESEND_API_KEY` | File | Yes | Yes | Resend API key for email notifications |
|
||||
|
||||
### Configuration Variables
|
||||
|
||||
@@ -146,6 +147,7 @@ secrets/app/
|
||||
google-maps-api-key.txt -> /run/secrets/google-maps-api-key
|
||||
google-maps-map-id.txt -> /run/secrets/google-maps-map-id
|
||||
cloudflare-dns-token.txt -> /run/secrets/cloudflare-dns-token
|
||||
resend-api-key.txt -> /run/secrets/resend-api-key
|
||||
```
|
||||
|
||||
### Security Benefits
|
||||
|
||||
@@ -5,21 +5,37 @@
|
||||
You are a senior software engineer specializsing in NodeJS, Typescript, front end and back end development. You will be delegating tasks to the platform-agent, feature-agent, first-frontend-agent and quality-agent when appropriate.
|
||||
|
||||
*** ACTION ***
|
||||
- Improving the Gas Stations search result and default result logic.
|
||||
- You will be implementing the "User Management" feature of this web application.
|
||||
- Make no assumptions.
|
||||
- Ask clarifying questions.
|
||||
- Ultrathink
|
||||
|
||||
*** CONTEXT ***
|
||||
- This is a modern web app for managing a vehicle fleet. It has both a desktop and mobile versions of the site that both need to maintain feature parity. It's currently deployed via docker compose but in the future will be deployed via k8s.
|
||||
- The URL for this change is here. https://motovaultpro.com/garage/stations
|
||||
- Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change.
|
||||
- There currently is no user management system in this application.
|
||||
- We need to do basic CRUD operations on user accounts
|
||||
- We need to set the groundwork for a tiered paid system in the future. Start with four types of users.
|
||||
- 1. Free 2. Pro 3. Enterprise 4. Administrator
|
||||
|
||||
*** CHANGES TO IMPLEMENT ***
|
||||
- When a user searches gas stations the Premium 93 tab doesn't update with their saved stations.
|
||||
- The "Results" tab correctly displays their saved station first
|
||||
- The "Saved" tab has the correct gas station
|
||||
- The "Premium 93" tab does NOT display the saved gas station.
|
||||
- The gas station correctly displays under "Community Verified" section but even though it's saved it's not showing in the "Your Premium 93 Stations"
|
||||
- Update the "Saved" and "Premium 93" result cards to act and look the same.
|
||||
- Look at the screenshots of all three tabs after a search result. You can see the missing "Your Premium 93 Stations" and the icon for "Premium 93" under "Saved" is not highlighted.
|
||||
- Research this code base and look for any gaps in user account management.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*** ROLE ***
|
||||
- You are a senior DevOps SRE with expert knowledge of Python, Ansible, GitHub and GitLab pipelines.
|
||||
|
||||
*** ACTION ***
|
||||
- You need to update the secrets scripts to include the newly added Resend email notification API key.
|
||||
- Make no assumptions.
|
||||
- Ask clarifying questions.
|
||||
- Ultrathink
|
||||
|
||||
*** CONTEXT ***
|
||||
- The secrets architecture is based of a future state of being deployed into k8s. Right now it's in docker compose with files that are copied in via the pipeline.
|
||||
|
||||
*** ACTION - CHANGES TO IMPLEMENT ***
|
||||
- Replicate the same secrets process that's implemented with the Google API and Auth0 API keys.
|
||||
@@ -32,11 +32,13 @@ const DocumentsMobileScreen = lazy(() => import('./features/documents/mobile/Doc
|
||||
const AdminUsersPage = lazy(() => import('./pages/admin/AdminUsersPage').then(m => ({ default: m.AdminUsersPage })));
|
||||
const AdminCatalogPage = lazy(() => import('./pages/admin/AdminCatalogPage').then(m => ({ default: m.AdminCatalogPage })));
|
||||
const AdminStationsPage = lazy(() => import('./pages/admin/AdminStationsPage').then(m => ({ default: m.AdminStationsPage })));
|
||||
const AdminEmailTemplatesPage = lazy(() => import('./pages/admin/AdminEmailTemplatesPage').then(m => ({ default: m.AdminEmailTemplatesPage })));
|
||||
|
||||
// Admin mobile screens (lazy-loaded)
|
||||
const AdminUsersMobileScreen = lazy(() => import('./features/admin/mobile/AdminUsersMobileScreen').then(m => ({ default: m.AdminUsersMobileScreen })));
|
||||
const AdminCatalogMobileScreen = lazy(() => import('./features/admin/mobile/AdminCatalogMobileScreen').then(m => ({ default: m.AdminCatalogMobileScreen })));
|
||||
const AdminStationsMobileScreen = lazy(() => import('./features/admin/mobile/AdminStationsMobileScreen').then(m => ({ default: m.AdminStationsMobileScreen })));
|
||||
const AdminEmailTemplatesMobileScreen = lazy(() => import('./features/admin/mobile/AdminEmailTemplatesMobileScreen'));
|
||||
|
||||
// Admin Community Stations (lazy-loaded)
|
||||
const AdminCommunityStationsPage = lazy(() => import('./features/admin/pages/AdminCommunityStationsPage').then(m => ({ default: m.AdminCommunityStationsPage })));
|
||||
@@ -62,6 +64,7 @@ import { useNavigationStore, useUserStore } from './core/store';
|
||||
import { useDataSync } from './core/hooks/useDataSync';
|
||||
import { MobileDebugPanel } from './core/debug/MobileDebugPanel';
|
||||
import { MobileErrorBoundary } from './core/error-boundaries/MobileErrorBoundary';
|
||||
import { useLoginNotifications } from './features/notifications/hooks/useLoginNotifications';
|
||||
|
||||
// Hoisted mobile screen components to stabilize identity and prevent remounts
|
||||
const DashboardScreen: React.FC = () => (
|
||||
@@ -259,6 +262,9 @@ function App() {
|
||||
// Initialize data synchronization
|
||||
const { prefetchForNavigation } = useDataSync();
|
||||
|
||||
// Initialize login notifications
|
||||
useLoginNotifications();
|
||||
|
||||
// Enhanced navigation and user state management
|
||||
const {
|
||||
activeScreen,
|
||||
@@ -726,6 +732,31 @@ function App() {
|
||||
</MobileErrorBoundary>
|
||||
</motion.div>
|
||||
)}
|
||||
{activeScreen === "AdminEmailTemplates" && (
|
||||
<motion.div
|
||||
key="admin-email-templates"
|
||||
initial={{opacity:0, y:8}}
|
||||
animate={{opacity:1, y:0}}
|
||||
exit={{opacity:0, y:-8}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<MobileErrorBoundary screenName="AdminEmailTemplates">
|
||||
<React.Suspense fallback={
|
||||
<div className="space-y-4">
|
||||
<GlassCard>
|
||||
<div className="p-4">
|
||||
<div className="text-slate-500 py-6 text-center">
|
||||
Loading email templates...
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
}>
|
||||
<AdminEmailTemplatesMobileScreen />
|
||||
</React.Suspense>
|
||||
</MobileErrorBoundary>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<DebugInfo />
|
||||
</Layout>
|
||||
@@ -795,6 +826,7 @@ function App() {
|
||||
<Route path="/garage/settings/admin/catalog" element={<AdminCatalogPage />} />
|
||||
<Route path="/garage/settings/admin/stations" element={<AdminStationsPage />} />
|
||||
<Route path="/garage/settings/admin/community-stations" element={<AdminCommunityStationsPage />} />
|
||||
<Route path="/garage/settings/admin/email-templates" element={<AdminEmailTemplatesPage />} />
|
||||
<Route path="*" element={<Navigate to="/garage/vehicles" replace />} />
|
||||
</Routes>
|
||||
</RouteSuspense>
|
||||
|
||||
@@ -67,7 +67,7 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
|
||||
<div className="px-5 pt-5 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-lg font-semibold tracking-tight">MotoVaultPro</div>
|
||||
<div className="text-xs text-slate-500">v0.1</div>
|
||||
<div className="text-xs text-slate-500">v1.0</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Content area */}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { safeStorage } from '../utils/safe-storage';
|
||||
|
||||
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Stations' | 'Documents' | 'Settings' | 'AdminUsers' | 'AdminCatalog' | 'AdminStations' | 'AdminCommunityStations';
|
||||
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Stations' | 'Documents' | 'Settings' | 'AdminUsers' | 'AdminCatalog' | 'AdminStations' | 'AdminCommunityStations' | 'AdminEmailTemplates';
|
||||
export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit';
|
||||
|
||||
interface NavigationHistory {
|
||||
|
||||
@@ -31,6 +31,16 @@ import {
|
||||
ImportPreviewResult,
|
||||
ImportApplyResult,
|
||||
CascadeDeleteResult,
|
||||
EmailTemplate,
|
||||
UpdateEmailTemplateRequest,
|
||||
// User management types
|
||||
ManagedUser,
|
||||
ListUsersResponse,
|
||||
ListUsersParams,
|
||||
UpdateUserTierRequest,
|
||||
DeactivateUserRequest,
|
||||
UpdateUserProfileRequest,
|
||||
PromoteToAdminRequest,
|
||||
} from '../types/admin.types';
|
||||
|
||||
export interface AuditLogsResponse {
|
||||
@@ -267,4 +277,87 @@ export const adminApi = {
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Email Templates
|
||||
emailTemplates: {
|
||||
list: async (): Promise<EmailTemplate[]> => {
|
||||
const response = await apiClient.get<EmailTemplate[]>('/admin/email-templates');
|
||||
return response.data;
|
||||
},
|
||||
get: async (key: string): Promise<EmailTemplate> => {
|
||||
const response = await apiClient.get<EmailTemplate>(`/admin/email-templates/${key}`);
|
||||
return response.data;
|
||||
},
|
||||
update: async (key: string, data: UpdateEmailTemplateRequest): Promise<EmailTemplate> => {
|
||||
const response = await apiClient.put<EmailTemplate>(`/admin/email-templates/${key}`, data);
|
||||
return response.data;
|
||||
},
|
||||
preview: async (key: string, variables: Record<string, string>): Promise<{ subject: string; body: string }> => {
|
||||
const response = await apiClient.post<{ subject: string; body: string }>(
|
||||
`/admin/email-templates/${key}/preview`,
|
||||
{ variables }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
sendTest: async (key: string): Promise<{ message?: string; error?: string; subject: string; body: string }> => {
|
||||
const response = await apiClient.post<{ message?: string; error?: string; subject: string; body: string }>(
|
||||
`/admin/email-templates/${key}/test`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
|
||||
// User Management
|
||||
users: {
|
||||
list: async (params: ListUsersParams = {}): Promise<ListUsersResponse> => {
|
||||
const response = await apiClient.get<ListUsersResponse>('/admin/users', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (auth0Sub: string): Promise<ManagedUser> => {
|
||||
const response = await apiClient.get<ManagedUser>(
|
||||
`/admin/users/${encodeURIComponent(auth0Sub)}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateTier: async (auth0Sub: string, data: UpdateUserTierRequest): Promise<ManagedUser> => {
|
||||
const response = await apiClient.patch<ManagedUser>(
|
||||
`/admin/users/${encodeURIComponent(auth0Sub)}/tier`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deactivate: async (auth0Sub: string, data?: DeactivateUserRequest): Promise<ManagedUser> => {
|
||||
const response = await apiClient.patch<ManagedUser>(
|
||||
`/admin/users/${encodeURIComponent(auth0Sub)}/deactivate`,
|
||||
data || {}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
reactivate: async (auth0Sub: string): Promise<ManagedUser> => {
|
||||
const response = await apiClient.patch<ManagedUser>(
|
||||
`/admin/users/${encodeURIComponent(auth0Sub)}/reactivate`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateProfile: async (auth0Sub: string, data: UpdateUserProfileRequest): Promise<ManagedUser> => {
|
||||
const response = await apiClient.patch<ManagedUser>(
|
||||
`/admin/users/${encodeURIComponent(auth0Sub)}/profile`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
promoteToAdmin: async (auth0Sub: string, data?: PromoteToAdminRequest): Promise<AdminUser> => {
|
||||
const response = await apiClient.patch<AdminUser>(
|
||||
`/admin/users/${encodeURIComponent(auth0Sub)}/promote`,
|
||||
data || {}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ export type CatalogFormValues = {
|
||||
trimId?: string;
|
||||
displacement?: string;
|
||||
cylinders?: number;
|
||||
fuel_type?: string;
|
||||
fuelType?: string;
|
||||
};
|
||||
|
||||
export const makeSchema = z.object({
|
||||
@@ -63,7 +63,7 @@ export const engineSchema = z.object({
|
||||
.positive('Cylinders must be positive')
|
||||
.optional()
|
||||
),
|
||||
fuel_type: z.string().optional(),
|
||||
fuelType: z.string().optional(),
|
||||
});
|
||||
|
||||
export const getSchemaForLevel = (level: CatalogLevel) => {
|
||||
@@ -114,7 +114,7 @@ export const buildDefaultValues = (
|
||||
trimId: String((entity as CatalogEngine).trimId),
|
||||
displacement: (entity as CatalogEngine).displacement ?? undefined,
|
||||
cylinders: (entity as CatalogEngine).cylinders ?? undefined,
|
||||
fuel_type: (entity as CatalogEngine).fuel_type ?? undefined,
|
||||
fuelType: (entity as CatalogEngine).fuelType ?? undefined,
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
@@ -142,7 +142,7 @@ export const buildDefaultValues = (
|
||||
name: '',
|
||||
trimId: context.trim?.id ? String(context.trim.id) : '',
|
||||
displacement: '',
|
||||
fuel_type: '',
|
||||
fuelType: '',
|
||||
};
|
||||
case 'makes':
|
||||
default:
|
||||
|
||||
180
frontend/src/features/admin/hooks/useUsers.ts
Normal file
180
frontend/src/features/admin/hooks/useUsers.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @ai-summary React Query hooks for admin user management
|
||||
* @ai-context List users, change tiers, deactivate/reactivate
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { adminApi } from '../api/admin.api';
|
||||
import {
|
||||
ListUsersParams,
|
||||
UpdateUserTierRequest,
|
||||
DeactivateUserRequest,
|
||||
UpdateUserProfileRequest,
|
||||
PromoteToAdminRequest,
|
||||
} from '../types/admin.types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface ApiError {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Query keys for user management
|
||||
export const userQueryKeys = {
|
||||
all: ['admin-users'] as const,
|
||||
list: (params: ListUsersParams) => [...userQueryKeys.all, 'list', params] as const,
|
||||
detail: (auth0Sub: string) => [...userQueryKeys.all, 'detail', auth0Sub] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to list users with pagination and filters
|
||||
*/
|
||||
export const useUsers = (params: ListUsersParams = {}) => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
return useQuery({
|
||||
queryKey: userQueryKeys.list(params),
|
||||
queryFn: () => adminApi.users.list(params),
|
||||
enabled: isAuthenticated && !isLoading,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes cache time
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get a single user's details
|
||||
*/
|
||||
export const useUser = (auth0Sub: string) => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
return useQuery({
|
||||
queryKey: userQueryKeys.detail(auth0Sub),
|
||||
queryFn: () => adminApi.users.get(auth0Sub),
|
||||
enabled: isAuthenticated && !isLoading && !!auth0Sub,
|
||||
staleTime: 2 * 60 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to update a user's subscription tier
|
||||
*/
|
||||
export const useUpdateUserTier = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ auth0Sub, data }: { auth0Sub: string; data: UpdateUserTierRequest }) =>
|
||||
adminApi.users.updateTier(auth0Sub, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
|
||||
toast.success('Subscription tier updated');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
'Failed to update tier'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to deactivate a user (soft delete)
|
||||
*/
|
||||
export const useDeactivateUser = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ auth0Sub, data }: { auth0Sub: string; data?: DeactivateUserRequest }) =>
|
||||
adminApi.users.deactivate(auth0Sub, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
|
||||
toast.success('User deactivated');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
'Failed to deactivate user'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to reactivate a deactivated user
|
||||
*/
|
||||
export const useReactivateUser = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (auth0Sub: string) => adminApi.users.reactivate(auth0Sub),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
|
||||
toast.success('User reactivated');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
'Failed to reactivate user'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to update a user's profile (email, displayName)
|
||||
*/
|
||||
export const useUpdateUserProfile = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ auth0Sub, data }: { auth0Sub: string; data: UpdateUserProfileRequest }) =>
|
||||
adminApi.users.updateProfile(auth0Sub, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
|
||||
toast.success('User profile updated');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
'Failed to update user profile'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to promote a user to admin
|
||||
*/
|
||||
export const usePromoteToAdmin = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ auth0Sub, data }: { auth0Sub: string; data?: PromoteToAdminRequest }) =>
|
||||
adminApi.users.promoteToAdmin(auth0Sub, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
|
||||
toast.success('User promoted to admin');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
'Failed to promote user to admin'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* @ai-summary Admin Email Templates mobile screen for managing notification email templates
|
||||
* @ai-context Mobile-optimized version with touch-friendly UI
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Edit, Visibility, Close, Send } from '@mui/icons-material';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { adminApi } from '../api/admin.api';
|
||||
import { EmailTemplate, UpdateEmailTemplateRequest } from '../types/admin.types';
|
||||
|
||||
const SAMPLE_VARIABLES: Record<string, string> = {
|
||||
userName: 'John Doe',
|
||||
vehicleName: '2024 Toyota Camry',
|
||||
category: 'Routine Maintenance',
|
||||
subtypes: 'Oil Change, Air Filter',
|
||||
dueDate: '2025-01-15',
|
||||
dueMileage: '50,000',
|
||||
documentType: 'Insurance',
|
||||
documentTitle: 'State Farm Policy',
|
||||
expirationDate: '2025-02-28',
|
||||
};
|
||||
|
||||
export const AdminEmailTemplatesMobileScreen: React.FC = () => {
|
||||
const { loading: authLoading, isAdmin } = useAdminAccess();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// State
|
||||
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
|
||||
const [previewTemplate, setPreviewTemplate] = useState<EmailTemplate | null>(null);
|
||||
const [editSubject, setEditSubject] = useState('');
|
||||
const [editBody, setEditBody] = useState('');
|
||||
const [editIsActive, setEditIsActive] = useState(true);
|
||||
const [previewSubject, setPreviewSubject] = useState('');
|
||||
const [previewBody, setPreviewBody] = useState('');
|
||||
|
||||
// Queries
|
||||
const { data: templates, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'emailTemplates'],
|
||||
queryFn: () => adminApi.emailTemplates.list(),
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ key, data }: { key: string; data: UpdateEmailTemplateRequest }) =>
|
||||
adminApi.emailTemplates.update(key, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'emailTemplates'] });
|
||||
toast.success('Template updated');
|
||||
handleCloseEdit();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update template');
|
||||
},
|
||||
});
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: ({ key, variables }: { key: string; variables: Record<string, string> }) =>
|
||||
adminApi.emailTemplates.preview(key, variables),
|
||||
onSuccess: (data) => {
|
||||
setPreviewSubject(data.subject);
|
||||
setPreviewBody(data.body);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to generate preview');
|
||||
},
|
||||
});
|
||||
|
||||
const sendTestMutation = useMutation({
|
||||
mutationFn: (key: string) => adminApi.emailTemplates.sendTest(key),
|
||||
onSuccess: (data) => {
|
||||
if (data.error) {
|
||||
toast.error(`Test email failed: ${data.error}`);
|
||||
} else if (data.message) {
|
||||
toast.success(data.message);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to send test email');
|
||||
},
|
||||
});
|
||||
|
||||
// Handlers
|
||||
const handleEditClick = useCallback((template: EmailTemplate) => {
|
||||
setEditingTemplate(template);
|
||||
setEditSubject(template.subject);
|
||||
setEditBody(template.body);
|
||||
setEditIsActive(template.isActive);
|
||||
}, []);
|
||||
|
||||
const handlePreviewClick = useCallback((template: EmailTemplate) => {
|
||||
setPreviewTemplate(template);
|
||||
previewMutation.mutate({
|
||||
key: template.templateKey,
|
||||
variables: SAMPLE_VARIABLES,
|
||||
});
|
||||
}, [previewMutation]);
|
||||
|
||||
const handleSendTestClick = useCallback((template: EmailTemplate) => {
|
||||
sendTestMutation.mutate(template.templateKey);
|
||||
}, [sendTestMutation]);
|
||||
|
||||
const handleCloseEdit = useCallback(() => {
|
||||
setEditingTemplate(null);
|
||||
setEditSubject('');
|
||||
setEditBody('');
|
||||
setEditIsActive(true);
|
||||
}, []);
|
||||
|
||||
const handleClosePreview = useCallback(() => {
|
||||
setPreviewTemplate(null);
|
||||
setPreviewSubject('');
|
||||
setPreviewBody('');
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!editingTemplate) return;
|
||||
|
||||
const data: UpdateEmailTemplateRequest = {
|
||||
subject: editSubject !== editingTemplate.subject ? editSubject : undefined,
|
||||
body: editBody !== editingTemplate.body ? editBody : undefined,
|
||||
isActive: editIsActive !== editingTemplate.isActive ? editIsActive : undefined,
|
||||
};
|
||||
|
||||
// Only update if there are changes
|
||||
if (data.subject || data.body || data.isActive !== undefined) {
|
||||
updateMutation.mutate({
|
||||
key: editingTemplate.templateKey,
|
||||
data,
|
||||
});
|
||||
} else {
|
||||
handleCloseEdit();
|
||||
}
|
||||
}, [editingTemplate, editSubject, editBody, editIsActive, updateMutation, handleCloseEdit]);
|
||||
|
||||
// Auth loading
|
||||
if (authLoading) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="text-slate-500 mb-2">Loading admin access...</div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Not admin
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/garage/settings" replace />;
|
||||
}
|
||||
|
||||
// Edit view
|
||||
if (editingTemplate) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-24 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Edit Template</h1>
|
||||
<p className="text-sm text-slate-500">{editingTemplate.name}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCloseEdit}
|
||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||
>
|
||||
<Close />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Edit Form */}
|
||||
<GlassCard>
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Active Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">Active</span>
|
||||
<button
|
||||
onClick={() => setEditIsActive(!editIsActive)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors min-h-[44px] min-w-[44px] ${
|
||||
editIsActive ? 'bg-blue-600' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
editIsActive ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Subject
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editSubject}
|
||||
onChange={(e) => setEditSubject(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[44px]"
|
||||
placeholder="Email subject"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Use variables like {editingTemplate.variables.map((v) => `{{${v}}}`).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Body
|
||||
</label>
|
||||
<textarea
|
||||
value={editBody}
|
||||
onChange={(e) => setEditBody(e.target.value)}
|
||||
rows={12}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
|
||||
placeholder="Email body"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Use variables like {editingTemplate.variables.map((v) => `{{${v}}}`).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Available Variables */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p className="text-xs font-medium text-blue-900 mb-2">Available Variables</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{editingTemplate.variables.map((variable) => (
|
||||
<span
|
||||
key={variable}
|
||||
className="inline-block px-2 py-1 bg-white border border-blue-300 rounded text-xs font-mono text-blue-700"
|
||||
>
|
||||
{`{{${variable}}}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={handleCloseEdit}
|
||||
className="flex-1 px-4 py-3 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors min-h-[44px]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
className="flex-1 px-4 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:bg-gray-300 min-h-[44px]"
|
||||
>
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Preview view
|
||||
if (previewTemplate) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-24 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Preview</h1>
|
||||
<p className="text-sm text-slate-500">{previewTemplate.name}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClosePreview}
|
||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||
>
|
||||
<Close />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preview Content */}
|
||||
<GlassCard>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p className="text-xs text-blue-700">
|
||||
This preview uses sample data to show how the template will appear.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{previewMutation.isPending ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
||||
<p className="text-slate-500 mt-2 text-sm">Generating preview...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Subject
|
||||
</label>
|
||||
<div className="px-3 py-2 bg-gray-50 border border-gray-300 rounded-lg min-h-[44px] flex items-center">
|
||||
{previewSubject}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Body
|
||||
</label>
|
||||
<div className="px-3 py-2 bg-gray-50 border border-gray-300 rounded-lg font-mono text-sm whitespace-pre-wrap">
|
||||
{previewBody}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleClosePreview}
|
||||
className="w-full px-4 py-3 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors min-h-[44px]"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// List view
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-24 p-4">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">Email Templates</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
{templates?.length || 0} notification templates
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Templates List */}
|
||||
{isLoading ? (
|
||||
<GlassCard>
|
||||
<div className="p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
||||
<p className="text-slate-500 mt-2 text-sm">Loading templates...</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{templates?.map((template) => (
|
||||
<GlassCard key={template.id}>
|
||||
<div className="p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-slate-800">{template.name}</h3>
|
||||
{template.description && (
|
||||
<p className="text-xs text-slate-500 mt-1">{template.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`ml-2 px-2 py-1 rounded-full text-xs font-medium ${
|
||||
template.isActive
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{template.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-medium text-slate-600 mb-1">Subject</p>
|
||||
<p className="text-sm text-slate-700 truncate">{template.subject}</p>
|
||||
</div>
|
||||
|
||||
{/* Variables */}
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-medium text-slate-600 mb-1">Variables</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{template.variables.map((variable) => (
|
||||
<span
|
||||
key={variable}
|
||||
className="inline-block px-2 py-1 bg-gray-100 border border-gray-300 rounded text-xs font-mono text-slate-600"
|
||||
>
|
||||
{`{{${variable}}}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => handleEditClick(template)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors min-h-[44px]"
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePreviewClick(template)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors min-h-[44px]"
|
||||
>
|
||||
<Visibility fontSize="small" />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSendTestClick(template)}
|
||||
disabled={sendTestMutation.isPending}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 transition-colors disabled:bg-gray-300 disabled:text-gray-500 min-h-[44px]"
|
||||
>
|
||||
<Send fontSize="small" />
|
||||
{sendTestMutation.isPending ? 'Sending...' : 'Send Test Email'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminEmailTemplatesMobileScreen;
|
||||
@@ -1,61 +1,721 @@
|
||||
/**
|
||||
* @ai-summary Mobile admin screen for user management
|
||||
* @ai-context Manage admin users with mobile-optimized interface
|
||||
* @ai-context List users, search, filter, change tiers, deactivate/reactivate
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
||||
import {
|
||||
useUsers,
|
||||
useUpdateUserTier,
|
||||
useDeactivateUser,
|
||||
useReactivateUser,
|
||||
useUpdateUserProfile,
|
||||
usePromoteToAdmin,
|
||||
} from '../hooks/useUsers';
|
||||
import {
|
||||
ManagedUser,
|
||||
SubscriptionTier,
|
||||
ListUsersParams,
|
||||
} from '../types/admin.types';
|
||||
|
||||
// Modal component for dialogs
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, actions }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl p-6 max-w-sm w-full shadow-xl">
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">{title}</h3>
|
||||
{children}
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
{actions || (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Tier badge component
|
||||
const TierBadge: React.FC<{ tier: SubscriptionTier }> = ({ tier }) => {
|
||||
const colors: Record<SubscriptionTier, string> = {
|
||||
free: 'bg-gray-100 text-gray-700',
|
||||
pro: 'bg-blue-100 text-blue-700',
|
||||
enterprise: 'bg-purple-100 text-purple-700',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colors[tier]}`}>
|
||||
{tier.charAt(0).toUpperCase() + tier.slice(1)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Status badge component
|
||||
const StatusBadge: React.FC<{ active: boolean }> = ({ active }) => (
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{active ? 'Active' : 'Deactivated'}
|
||||
</span>
|
||||
);
|
||||
|
||||
export const AdminUsersMobileScreen: React.FC = () => {
|
||||
const { isAdmin, loading } = useAdminAccess();
|
||||
const { isAdmin, loading: adminLoading } = useAdminAccess();
|
||||
|
||||
if (loading) {
|
||||
// Filter state
|
||||
const [params, setParams] = useState<ListUsersParams>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
status: 'all',
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'desc',
|
||||
});
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Query
|
||||
const { data, isLoading, error, refetch } = useUsers(params);
|
||||
|
||||
// Mutations
|
||||
const updateTierMutation = useUpdateUserTier();
|
||||
const deactivateMutation = useDeactivateUser();
|
||||
const reactivateMutation = useReactivateUser();
|
||||
const updateProfileMutation = useUpdateUserProfile();
|
||||
const promoteToAdminMutation = usePromoteToAdmin();
|
||||
|
||||
// Selected user for actions
|
||||
const [selectedUser, setSelectedUser] = useState<ManagedUser | null>(null);
|
||||
const [showUserActions, setShowUserActions] = useState(false);
|
||||
const [showTierPicker, setShowTierPicker] = useState(false);
|
||||
const [showDeactivateConfirm, setShowDeactivateConfirm] = useState(false);
|
||||
const [deactivateReason, setDeactivateReason] = useState('');
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editEmail, setEditEmail] = useState('');
|
||||
const [editDisplayName, setEditDisplayName] = useState('');
|
||||
const [showPromoteModal, setShowPromoteModal] = useState(false);
|
||||
const [promoteRole, setPromoteRole] = useState<'admin' | 'super_admin'>('admin');
|
||||
|
||||
// Handlers
|
||||
const handleSearch = useCallback(() => {
|
||||
setParams(prev => ({ ...prev, search: searchInput || undefined, page: 1 }));
|
||||
}, [searchInput]);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchInput('');
|
||||
setParams(prev => ({ ...prev, search: undefined, page: 1 }));
|
||||
}, []);
|
||||
|
||||
const handleTierFilterChange = useCallback((tier: SubscriptionTier | '') => {
|
||||
setParams(prev => ({
|
||||
...prev,
|
||||
tier: tier || undefined,
|
||||
page: 1,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleStatusFilterChange = useCallback((status: 'active' | 'deactivated' | 'all') => {
|
||||
setParams(prev => ({ ...prev, status, page: 1 }));
|
||||
}, []);
|
||||
|
||||
const handleUserClick = useCallback((user: ManagedUser) => {
|
||||
setSelectedUser(user);
|
||||
setShowUserActions(true);
|
||||
}, []);
|
||||
|
||||
const handleTierChange = useCallback(
|
||||
(newTier: SubscriptionTier) => {
|
||||
if (selectedUser) {
|
||||
updateTierMutation.mutate(
|
||||
{ auth0Sub: selectedUser.auth0Sub, data: { subscriptionTier: newTier } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowTierPicker(false);
|
||||
setShowUserActions(false);
|
||||
setSelectedUser(null);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
[selectedUser, updateTierMutation]
|
||||
);
|
||||
|
||||
const handleDeactivate = useCallback(() => {
|
||||
if (selectedUser) {
|
||||
deactivateMutation.mutate(
|
||||
{ auth0Sub: selectedUser.auth0Sub, data: { reason: deactivateReason || undefined } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowDeactivateConfirm(false);
|
||||
setShowUserActions(false);
|
||||
setDeactivateReason('');
|
||||
setSelectedUser(null);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [selectedUser, deactivateReason, deactivateMutation]);
|
||||
|
||||
const handleReactivate = useCallback(() => {
|
||||
if (selectedUser) {
|
||||
reactivateMutation.mutate(selectedUser.auth0Sub, {
|
||||
onSuccess: () => {
|
||||
setShowUserActions(false);
|
||||
setSelectedUser(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [selectedUser, reactivateMutation]);
|
||||
|
||||
const handleEditClick = useCallback(() => {
|
||||
if (selectedUser) {
|
||||
setEditEmail(selectedUser.email);
|
||||
setEditDisplayName(selectedUser.displayName || '');
|
||||
setShowUserActions(false);
|
||||
setShowEditModal(true);
|
||||
}
|
||||
}, [selectedUser]);
|
||||
|
||||
const handleEditConfirm = useCallback(() => {
|
||||
if (selectedUser) {
|
||||
const updates: { email?: string; displayName?: string } = {};
|
||||
if (editEmail !== selectedUser.email) {
|
||||
updates.email = editEmail;
|
||||
}
|
||||
if (editDisplayName !== (selectedUser.displayName || '')) {
|
||||
updates.displayName = editDisplayName;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updateProfileMutation.mutate(
|
||||
{ auth0Sub: selectedUser.auth0Sub, data: updates },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowEditModal(false);
|
||||
setEditEmail('');
|
||||
setEditDisplayName('');
|
||||
setSelectedUser(null);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [selectedUser, editEmail, editDisplayName, updateProfileMutation]);
|
||||
|
||||
const handleEditCancel = useCallback(() => {
|
||||
setShowEditModal(false);
|
||||
setEditEmail('');
|
||||
setEditDisplayName('');
|
||||
setSelectedUser(null);
|
||||
}, []);
|
||||
|
||||
const handlePromoteClick = useCallback(() => {
|
||||
setPromoteRole('admin');
|
||||
setShowUserActions(false);
|
||||
setShowPromoteModal(true);
|
||||
}, []);
|
||||
|
||||
const handlePromoteConfirm = useCallback(() => {
|
||||
if (selectedUser) {
|
||||
promoteToAdminMutation.mutate(
|
||||
{ auth0Sub: selectedUser.auth0Sub, data: { role: promoteRole } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowPromoteModal(false);
|
||||
setPromoteRole('admin');
|
||||
setSelectedUser(null);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [selectedUser, promoteRole, promoteToAdminMutation]);
|
||||
|
||||
const handlePromoteCancel = useCallback(() => {
|
||||
setShowPromoteModal(false);
|
||||
setPromoteRole('admin');
|
||||
setSelectedUser(null);
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
setParams(prev => ({ ...prev, page: (prev.page || 1) + 1 }));
|
||||
}, []);
|
||||
|
||||
// Loading state
|
||||
if (adminLoading) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="text-slate-500 mb-2">Loading admin access...</div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Not admin redirect
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/garage/settings" replace />;
|
||||
}
|
||||
|
||||
const users = data?.users || [];
|
||||
const total = data?.total || 0;
|
||||
const hasMore = users.length < total;
|
||||
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-20 p-4">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-800">User Management</h1>
|
||||
<p className="text-slate-500 mt-2">Manage admin users and permissions</p>
|
||||
<p className="text-slate-500 mt-2">
|
||||
{total} user{total !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Admin Users</h2>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Admin user management interface coming soon.
|
||||
</p>
|
||||
<div className="space-y-2 text-sm text-slate-600">
|
||||
<p className="font-semibold">Features:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>List all admin users</li>
|
||||
<li>Add new admin users</li>
|
||||
<li>Revoke admin access</li>
|
||||
<li>Reinstate revoked admins</li>
|
||||
<li>View audit logs</li>
|
||||
</ul>
|
||||
{/* Search Bar */}
|
||||
<GlassCard padding="sm">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by email or name..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="w-full px-4 py-3 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[44px]"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="px-4 py-3 bg-blue-600 text-white rounded-lg font-medium min-h-[44px]"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter Toggle */}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="mt-3 text-blue-600 text-sm font-medium flex items-center gap-1 min-h-[44px]"
|
||||
>
|
||||
<svg className={`w-4 h-4 transition-transform ${showFilters ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
{showFilters ? 'Hide Filters' : 'Show Filters'}
|
||||
</button>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="mt-3 pt-3 border-t border-slate-200 space-y-3">
|
||||
{/* Tier Filter */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 block mb-1">Tier</label>
|
||||
<select
|
||||
value={params.tier || ''}
|
||||
onChange={(e) => handleTierFilterChange(e.target.value as SubscriptionTier | '')}
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 min-h-[44px]"
|
||||
>
|
||||
<option value="">All Tiers</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="pro">Pro</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 block mb-1">Status</label>
|
||||
<select
|
||||
value={params.status || 'all'}
|
||||
onChange={(e) => handleStatusFilterChange(e.target.value as 'active' | 'deactivated' | 'all')}
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 min-h-[44px]"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="deactivated">Deactivated</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && users.length === 0 && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<GlassCard padding="md">
|
||||
<div className="text-center text-red-600">
|
||||
<p>Failed to load users. Please try again.</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="mt-2 text-blue-600 font-medium min-h-[44px]"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && !error && users.length === 0 && (
|
||||
<GlassCard padding="md">
|
||||
<div className="text-center text-slate-500">
|
||||
<p>No users found matching your criteria.</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* User List */}
|
||||
{users.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{users.map((user) => (
|
||||
<GlassCard key={user.auth0Sub} padding="md">
|
||||
<button
|
||||
onClick={() => handleUserClick(user)}
|
||||
className="w-full text-left min-h-[44px]"
|
||||
style={{ opacity: user.deactivatedAt ? 0.6 : 1 }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-slate-800 truncate">{user.email}</p>
|
||||
{user.displayName && (
|
||||
<p className="text-sm text-slate-500 truncate">{user.displayName}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<TierBadge tier={user.subscriptionTier} />
|
||||
<StatusBadge active={!user.deactivatedAt} />
|
||||
{user.isAdmin && (
|
||||
<span className="px-2 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<svg className="w-5 h-5 text-slate-400 flex-shrink-0 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</GlassCard>
|
||||
))}
|
||||
|
||||
{/* Load More */}
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={isLoading}
|
||||
className="w-full py-3 text-blue-600 font-medium min-h-[44px]"
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Load More'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Actions Modal */}
|
||||
<Modal
|
||||
isOpen={showUserActions}
|
||||
onClose={() => {
|
||||
setShowUserActions(false);
|
||||
setSelectedUser(null);
|
||||
}}
|
||||
title="User Actions"
|
||||
actions={
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserActions(false);
|
||||
setSelectedUser(null);
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{selectedUser && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-slate-600">
|
||||
<p className="font-medium">{selectedUser.email}</p>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<TierBadge tier={selectedUser.subscriptionTier} />
|
||||
<StatusBadge active={!selectedUser.deactivatedAt} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 pt-3 space-y-2">
|
||||
<button
|
||||
onClick={handleEditClick}
|
||||
className="w-full py-3 text-left text-blue-600 font-medium min-h-[44px]"
|
||||
>
|
||||
Edit User
|
||||
</button>
|
||||
|
||||
{!selectedUser.isAdmin && (
|
||||
<button
|
||||
onClick={handlePromoteClick}
|
||||
className="w-full py-3 text-left text-purple-600 font-medium min-h-[44px]"
|
||||
>
|
||||
Promote to Admin
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserActions(false);
|
||||
setShowTierPicker(true);
|
||||
}}
|
||||
disabled={!!selectedUser.deactivatedAt}
|
||||
className="w-full py-3 text-left text-blue-600 font-medium disabled:text-slate-300 min-h-[44px]"
|
||||
>
|
||||
Change Subscription Tier
|
||||
</button>
|
||||
|
||||
{selectedUser.deactivatedAt ? (
|
||||
<button
|
||||
onClick={handleReactivate}
|
||||
disabled={reactivateMutation.isPending}
|
||||
className="w-full py-3 text-left text-green-600 font-medium disabled:opacity-50 min-h-[44px]"
|
||||
>
|
||||
{reactivateMutation.isPending ? 'Reactivating...' : 'Reactivate User'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserActions(false);
|
||||
setShowDeactivateConfirm(true);
|
||||
}}
|
||||
className="w-full py-3 text-left text-red-600 font-medium min-h-[44px]"
|
||||
>
|
||||
Deactivate User
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Tier Picker Modal */}
|
||||
<Modal
|
||||
isOpen={showTierPicker}
|
||||
onClose={() => setShowTierPicker(false)}
|
||||
title="Change Subscription Tier"
|
||||
actions={
|
||||
<button
|
||||
onClick={() => setShowTierPicker(false)}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{(['free', 'pro', 'enterprise'] as SubscriptionTier[]).map((tier) => (
|
||||
<button
|
||||
key={tier}
|
||||
onClick={() => handleTierChange(tier)}
|
||||
disabled={updateTierMutation.isPending || tier === selectedUser?.subscriptionTier}
|
||||
className={`w-full py-3 px-4 rounded-lg text-left font-medium min-h-[44px] ${
|
||||
tier === selectedUser?.subscriptionTier
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{tier.charAt(0).toUpperCase() + tier.slice(1)}
|
||||
{tier === selectedUser?.subscriptionTier && ' (Current)'}
|
||||
</button>
|
||||
))}
|
||||
{updateTierMutation.isPending && (
|
||||
<p className="text-center text-sm text-slate-500">Updating...</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Deactivate Confirmation Modal */}
|
||||
<Modal
|
||||
isOpen={showDeactivateConfirm}
|
||||
onClose={() => {
|
||||
setShowDeactivateConfirm(false);
|
||||
setDeactivateReason('');
|
||||
}}
|
||||
title="Deactivate User"
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDeactivateConfirm(false);
|
||||
setDeactivateReason('');
|
||||
}}
|
||||
disabled={deactivateMutation.isPending}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeactivate}
|
||||
disabled={deactivateMutation.isPending}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg font-medium min-h-[44px] disabled:opacity-50"
|
||||
>
|
||||
{deactivateMutation.isPending ? 'Deactivating...' : 'Deactivate'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-slate-600">
|
||||
Are you sure you want to deactivate{' '}
|
||||
<strong>{selectedUser?.email}</strong>?
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
The user will no longer be able to log in, but their data will be preserved.
|
||||
</p>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 block mb-1">
|
||||
Reason (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={deactivateReason}
|
||||
onChange={(e) => setDeactivateReason(e.target.value)}
|
||||
placeholder="Enter a reason for deactivation..."
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Edit User Modal */}
|
||||
<Modal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => !updateProfileMutation.isPending && handleEditCancel()}
|
||||
title="Edit User"
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
onClick={handleEditCancel}
|
||||
disabled={updateProfileMutation.isPending}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEditConfirm}
|
||||
disabled={
|
||||
updateProfileMutation.isPending ||
|
||||
(editEmail === selectedUser?.email && editDisplayName === (selectedUser?.displayName || ''))
|
||||
}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium min-h-[44px] disabled:opacity-50"
|
||||
>
|
||||
{updateProfileMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 block mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={editEmail}
|
||||
onChange={(e) => setEditEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 min-h-[44px]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 block mb-1">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editDisplayName}
|
||||
onChange={(e) => setEditDisplayName(e.target.value)}
|
||||
placeholder="Enter display name..."
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 min-h-[44px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Promote to Admin Modal */}
|
||||
<Modal
|
||||
isOpen={showPromoteModal}
|
||||
onClose={() => !promoteToAdminMutation.isPending && handlePromoteCancel()}
|
||||
title="Promote to Admin"
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
onClick={handlePromoteCancel}
|
||||
disabled={promoteToAdminMutation.isPending}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePromoteConfirm}
|
||||
disabled={promoteToAdminMutation.isPending}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg font-medium min-h-[44px] disabled:opacity-50"
|
||||
>
|
||||
{promoteToAdminMutation.isPending ? 'Promoting...' : 'Promote'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-slate-600">
|
||||
Promote <strong>{selectedUser?.email}</strong> to an administrator role.
|
||||
</p>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 block mb-1">Admin Role</label>
|
||||
<select
|
||||
value={promoteRole}
|
||||
onChange={(e) => setPromoteRole(e.target.value as 'admin' | 'super_admin')}
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 min-h-[44px]"
|
||||
>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="super_admin">Super Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">
|
||||
Admins can manage users, catalog data, and view audit logs.
|
||||
Super Admins have additional permissions to manage other administrators.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ export interface CatalogEngine {
|
||||
name: string;
|
||||
displacement: string | null;
|
||||
cylinders: number | null;
|
||||
fuel_type: string | null;
|
||||
fuelType: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -115,14 +115,14 @@ export interface CreateCatalogEngineRequest {
|
||||
name: string;
|
||||
displacement?: string;
|
||||
cylinders?: number;
|
||||
fuel_type?: string;
|
||||
fuelType?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCatalogEngineRequest {
|
||||
name?: string;
|
||||
displacement?: string;
|
||||
cylinders?: number;
|
||||
fuel_type?: string;
|
||||
fuelType?: string;
|
||||
}
|
||||
|
||||
// Station types for admin
|
||||
@@ -220,3 +220,86 @@ export interface CascadeDeleteResult {
|
||||
deletedEngines: number;
|
||||
totalDeleted: number;
|
||||
}
|
||||
|
||||
// Email template types
|
||||
export interface EmailTemplate {
|
||||
id: string;
|
||||
templateKey: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
variables: string[];
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UpdateEmailTemplateRequest {
|
||||
subject?: string;
|
||||
body?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// User Management types (subscription tiers)
|
||||
// ============================================
|
||||
|
||||
// Subscription tier enum
|
||||
export type SubscriptionTier = 'free' | 'pro' | 'enterprise';
|
||||
|
||||
// User with admin status for admin views
|
||||
export interface ManagedUser {
|
||||
id: string;
|
||||
auth0Sub: string;
|
||||
email: string;
|
||||
displayName: string | null;
|
||||
notificationEmail: string | null;
|
||||
subscriptionTier: SubscriptionTier;
|
||||
deactivatedAt: string | null;
|
||||
deactivatedBy: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isAdmin: boolean;
|
||||
adminRole: 'admin' | 'super_admin' | null;
|
||||
}
|
||||
|
||||
// List users response with pagination
|
||||
export interface ListUsersResponse {
|
||||
users: ManagedUser[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
// Query params for listing users
|
||||
export interface ListUsersParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
tier?: SubscriptionTier;
|
||||
status?: 'active' | 'deactivated' | 'all';
|
||||
sortBy?: 'email' | 'createdAt' | 'displayName' | 'subscriptionTier';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// Request to update subscription tier
|
||||
export interface UpdateUserTierRequest {
|
||||
subscriptionTier: SubscriptionTier;
|
||||
}
|
||||
|
||||
// Request to deactivate a user
|
||||
export interface DeactivateUserRequest {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// Request to update user profile (admin edit)
|
||||
export interface UpdateUserProfileRequest {
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// Request to promote user to admin
|
||||
export interface PromoteToAdminRequest {
|
||||
role?: 'admin' | 'super_admin';
|
||||
}
|
||||
|
||||
@@ -96,13 +96,13 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
|
||||
}
|
||||
|
||||
const created = await create.mutateAsync({
|
||||
vehicle_id: vehicleID,
|
||||
document_type: documentType,
|
||||
vehicleId: vehicleID,
|
||||
documentType: documentType,
|
||||
title: title.trim(),
|
||||
notes: notes.trim() || undefined,
|
||||
details,
|
||||
issued_date,
|
||||
expiration_date,
|
||||
issuedDate: issued_date,
|
||||
expirationDate: expiration_date,
|
||||
});
|
||||
|
||||
if (file) {
|
||||
|
||||
@@ -12,8 +12,8 @@ export const DocumentPreview: React.FC<Props> = ({ doc }) => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const previewable = useMemo(() => {
|
||||
return doc.content_type === 'application/pdf' || doc.content_type?.startsWith('image/');
|
||||
}, [doc.content_type]);
|
||||
return doc.contentType === 'application/pdf' || doc.contentType?.startsWith('image/');
|
||||
}, [doc.contentType]);
|
||||
|
||||
useEffect(() => {
|
||||
let revoked: string | null = null;
|
||||
@@ -37,7 +37,7 @@ export const DocumentPreview: React.FC<Props> = ({ doc }) => {
|
||||
if (error) return <div className="text-red-600 text-sm">{error}</div>;
|
||||
if (!blobUrl) return <div className="text-slate-500 text-sm">Loading preview...</div>;
|
||||
|
||||
if (doc.content_type === 'application/pdf') {
|
||||
if (doc.contentType === 'application/pdf') {
|
||||
return (
|
||||
<object data={blobUrl} type="application/pdf" className="w-full h-[60vh] rounded-lg border" aria-label="PDF Preview">
|
||||
<a href={blobUrl} target="_blank" rel="noopener noreferrer">Open PDF</a>
|
||||
|
||||
@@ -36,23 +36,23 @@ export function useCreateDocument() {
|
||||
// Create optimistic document record
|
||||
const optimisticDocument: DocumentRecord = {
|
||||
id: `temp-${Date.now()}`, // Temporary ID
|
||||
user_id: '', // Will be filled by server
|
||||
vehicle_id: newDocument.vehicle_id,
|
||||
document_type: newDocument.document_type,
|
||||
userId: '', // Will be filled by server
|
||||
vehicleId: newDocument.vehicleId,
|
||||
documentType: newDocument.documentType,
|
||||
title: newDocument.title,
|
||||
notes: newDocument.notes || null,
|
||||
details: newDocument.details || null,
|
||||
storage_bucket: null,
|
||||
storage_key: null,
|
||||
file_name: null,
|
||||
content_type: null,
|
||||
file_size: null,
|
||||
file_hash: null,
|
||||
issued_date: newDocument.issued_date || null,
|
||||
expiration_date: newDocument.expiration_date || null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
deleted_at: null,
|
||||
storageBucket: null,
|
||||
storageKey: null,
|
||||
fileName: null,
|
||||
contentType: null,
|
||||
fileSize: null,
|
||||
fileHash: null,
|
||||
issuedDate: newDocument.issuedDate || null,
|
||||
expirationDate: newDocument.expirationDate || null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
// Optimistically update cache
|
||||
@@ -96,7 +96,7 @@ export function useUpdateDocument(id: string) {
|
||||
return {
|
||||
...old,
|
||||
...updateData,
|
||||
updated_at: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -105,7 +105,7 @@ export function useUpdateDocument(id: string) {
|
||||
if (!old) return old;
|
||||
return old.map(doc =>
|
||||
doc.id === id
|
||||
? { ...doc, ...updateData, updated_at: new Date().toISOString() }
|
||||
? { ...doc, ...updateData, updatedAt: new Date().toISOString() }
|
||||
: doc
|
||||
);
|
||||
});
|
||||
@@ -186,10 +186,10 @@ export function useUploadDocument(id: string) {
|
||||
|
||||
// Optimistically update with upload in progress state
|
||||
const optimisticUpdate = {
|
||||
file_name: file.name,
|
||||
content_type: file.type,
|
||||
file_size: file.size,
|
||||
updated_at: new Date().toISOString(),
|
||||
fileName: file.name,
|
||||
contentType: file.type,
|
||||
fileSize: file.size,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Update individual document
|
||||
|
||||
@@ -177,12 +177,12 @@ export const DocumentsMobileScreen: React.FC = () => {
|
||||
{!isLoading && !hasError && data && data.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{data.map((doc) => {
|
||||
const vehicleLabel = doc.vehicle_id ? `${doc.vehicle_id.slice(0, 8)}...` : '—';
|
||||
const vehicleLabel = doc.vehicleId ? `${doc.vehicleId.slice(0, 8)}...` : '—';
|
||||
return (
|
||||
<div key={doc.id} className="flex items-center justify-between border rounded-xl p-3">
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">{doc.title}</div>
|
||||
<div className="text-xs text-slate-500">{doc.document_type} • {vehicleLabel}</div>
|
||||
<div className="text-xs text-slate-500">{doc.documentType} • {vehicleLabel}</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button onClick={() => navigate(`/garage/documents/${doc.id}`)}>Open</Button>
|
||||
|
||||
@@ -140,8 +140,8 @@ export const DocumentDetailPage: React.FC = () => {
|
||||
<Card>
|
||||
<div className="p-4 space-y-2">
|
||||
<h2 className="text-xl font-semibold">{doc.title}</h2>
|
||||
<div className="text-sm text-slate-500">Type: {doc.document_type}</div>
|
||||
<div className="text-sm text-slate-500">Vehicle: {doc.vehicle_id}</div>
|
||||
<div className="text-sm text-slate-500">Type: {doc.documentType}</div>
|
||||
<div className="text-sm text-slate-500">Vehicle: {doc.vehicleId}</div>
|
||||
<div className="pt-2">
|
||||
<DocumentPreview doc={doc} />
|
||||
</div>
|
||||
|
||||
@@ -128,8 +128,8 @@ export const DocumentsPage: React.FC = () => {
|
||||
<Card key={doc.id}>
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="font-medium">{doc.title}</div>
|
||||
<div className="text-sm text-slate-500">Type: {doc.document_type}</div>
|
||||
<div className="text-sm text-slate-500">Vehicle: {doc.vehicle_id}</div>
|
||||
<div className="text-sm text-slate-500">Type: {doc.documentType}</div>
|
||||
<div className="text-sm text-slate-500">Vehicle: {doc.vehicleId}</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button onClick={() => navigate(`/garage/documents/${doc.id}`)}>Open</Button>
|
||||
<Button variant="danger" onClick={() => removeDoc.mutate(doc.id)}>Delete</Button>
|
||||
|
||||
@@ -2,40 +2,43 @@ export type DocumentType = 'insurance' | 'registration';
|
||||
|
||||
export interface DocumentRecord {
|
||||
id: string;
|
||||
user_id: string;
|
||||
vehicle_id: string;
|
||||
document_type: DocumentType;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
documentType: DocumentType;
|
||||
title: string;
|
||||
notes?: string | null;
|
||||
details?: Record<string, any> | null;
|
||||
storage_bucket?: string | null;
|
||||
storage_key?: string | null;
|
||||
file_name?: string | null;
|
||||
content_type?: string | null;
|
||||
file_size?: number | null;
|
||||
file_hash?: string | null;
|
||||
issued_date?: string | null;
|
||||
expiration_date?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at?: string | null;
|
||||
storageBucket?: string | null;
|
||||
storageKey?: string | null;
|
||||
fileName?: string | null;
|
||||
contentType?: string | null;
|
||||
fileSize?: number | null;
|
||||
fileHash?: string | null;
|
||||
issuedDate?: string | null;
|
||||
expirationDate?: string | null;
|
||||
emailNotifications?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateDocumentRequest {
|
||||
vehicle_id: string;
|
||||
document_type: DocumentType;
|
||||
vehicleId: string;
|
||||
documentType: DocumentType;
|
||||
title: string;
|
||||
notes?: string;
|
||||
details?: Record<string, any>;
|
||||
issued_date?: string;
|
||||
expiration_date?: string;
|
||||
issuedDate?: string;
|
||||
expirationDate?: string;
|
||||
emailNotifications?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateDocumentRequest {
|
||||
title?: string;
|
||||
notes?: string | null;
|
||||
details?: Record<string, any>;
|
||||
issued_date?: string | null;
|
||||
expiration_date?: string | null;
|
||||
issuedDate?: string | null;
|
||||
expirationDate?: string | null;
|
||||
emailNotifications?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,9 +63,9 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
|
||||
category: record.category,
|
||||
subtypes: record.subtypes,
|
||||
date: record.date,
|
||||
odometer_reading: record.odometer_reading || undefined,
|
||||
odometerReading: record.odometerReading || undefined,
|
||||
cost: record.cost ? Number(record.cost) : undefined,
|
||||
shop_name: record.shop_name || undefined,
|
||||
shopName: record.shopName || undefined,
|
||||
notes: record.notes || undefined,
|
||||
});
|
||||
setError(null);
|
||||
@@ -172,7 +172,7 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
|
||||
fullWidth
|
||||
disabled
|
||||
value={(() => {
|
||||
const vehicle = vehicles?.find((v: Vehicle) => v.id === record.vehicle_id);
|
||||
const vehicle = vehicles?.find((v: Vehicle) => v.id === record.vehicleId);
|
||||
if (!vehicle) return 'Unknown Vehicle';
|
||||
if (vehicle.nickname?.trim()) return vehicle.nickname.trim();
|
||||
const parts = [vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean);
|
||||
@@ -246,10 +246,10 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
|
||||
label="Odometer Reading"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={formData.odometer_reading || ''}
|
||||
value={formData.odometerReading || ''}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
'odometer_reading',
|
||||
'odometerReading',
|
||||
e.target.value ? parseInt(e.target.value) : undefined
|
||||
)
|
||||
}
|
||||
@@ -278,8 +278,8 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
|
||||
<TextField
|
||||
label="Shop/Location"
|
||||
fullWidth
|
||||
value={formData.shop_name || ''}
|
||||
onChange={(e) => handleInputChange('shop_name', e.target.value || undefined)}
|
||||
value={formData.shopName || ''}
|
||||
onChange={(e) => handleInputChange('shopName', e.target.value || undefined)}
|
||||
helperText="Service location"
|
||||
inputProps={{ maxLength: 200 }}
|
||||
/>
|
||||
|
||||
@@ -92,13 +92,13 @@ export const MaintenanceRecordForm: React.FC = () => {
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
const payload: CreateMaintenanceRecordRequest = {
|
||||
vehicle_id: data.vehicle_id,
|
||||
vehicleId: data.vehicle_id,
|
||||
category: data.category as MaintenanceCategory,
|
||||
subtypes: data.subtypes,
|
||||
date: data.date,
|
||||
odometer_reading: data.odometer_reading ? Number(data.odometer_reading) : undefined,
|
||||
odometerReading: data.odometer_reading ? Number(data.odometer_reading) : undefined,
|
||||
cost: data.cost ? Number(data.cost) : undefined,
|
||||
shop_name: data.shop_name || undefined,
|
||||
shopName: data.shop_name || undefined,
|
||||
notes: data.notes || undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ export const MaintenanceRecordsList: React.FC<MaintenanceRecordsListProps> = ({
|
||||
{sortedRecords.map((record) => {
|
||||
const dateText = new Date(record.date).toLocaleDateString();
|
||||
const categoryDisplay = getCategoryDisplayName(record.category);
|
||||
const subtypeCount = record.subtype_count || record.subtypes?.length || 0;
|
||||
const subtypeCount = record.subtypeCount || record.subtypes?.length || 0;
|
||||
|
||||
return (
|
||||
<Card key={record.id} variant="outlined">
|
||||
@@ -105,9 +105,9 @@ export const MaintenanceRecordsList: React.FC<MaintenanceRecordsListProps> = ({
|
||||
{categoryDisplay} ({subtypeCount})
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" sx={{ mt: 1 }}>
|
||||
{record.odometer_reading && (
|
||||
{record.odometerReading && (
|
||||
<Chip
|
||||
label={`${Number(record.odometer_reading).toLocaleString()} miles`}
|
||||
label={`${Number(record.odometerReading).toLocaleString()} miles`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
@@ -120,9 +120,9 @@ export const MaintenanceRecordsList: React.FC<MaintenanceRecordsListProps> = ({
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
{record.shop_name && (
|
||||
{record.shopName && (
|
||||
<Chip
|
||||
label={record.shop_name}
|
||||
label={record.shopName}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
@@ -81,9 +81,9 @@ export const useMaintenanceRecords = (vehicleId?: string) => {
|
||||
const createRecordMutation = useMutation({
|
||||
mutationFn: (data: CreateMaintenanceRecordRequest) => maintenanceApi.createRecord(data),
|
||||
onSuccess: (_res, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceRecords', variables.vehicle_id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceRecords', variables.vehicleId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceRecords', 'all'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceUpcoming', variables.vehicle_id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceUpcoming', variables.vehicleId] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -110,8 +110,8 @@ export const useMaintenanceRecords = (vehicleId?: string) => {
|
||||
const createScheduleMutation = useMutation({
|
||||
mutationFn: (data: CreateScheduleRequest) => maintenanceApi.createSchedule(data),
|
||||
onSuccess: (_res, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceSchedules', variables.vehicle_id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceUpcoming', variables.vehicle_id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceSchedules', variables.vehicleId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceUpcoming', variables.vehicleId] });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -53,48 +53,49 @@ export const PERFORMANCE_UPGRADE_SUBTYPES = [
|
||||
'Exterior'
|
||||
] as const;
|
||||
|
||||
// Database record types
|
||||
// Database record types (camelCase)
|
||||
export interface MaintenanceRecord {
|
||||
id: string;
|
||||
user_id: string;
|
||||
vehicle_id: string;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
category: MaintenanceCategory;
|
||||
subtypes: string[];
|
||||
date: string;
|
||||
odometer_reading?: number;
|
||||
odometerReading?: number;
|
||||
cost?: number;
|
||||
shop_name?: string;
|
||||
shopName?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MaintenanceSchedule {
|
||||
id: string;
|
||||
user_id: string;
|
||||
vehicle_id: string;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
category: MaintenanceCategory;
|
||||
subtypes: string[];
|
||||
interval_months?: number;
|
||||
interval_miles?: number;
|
||||
last_service_date?: string;
|
||||
last_service_mileage?: number;
|
||||
next_due_date?: string;
|
||||
next_due_mileage?: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
intervalMonths?: number;
|
||||
intervalMiles?: number;
|
||||
lastServiceDate?: string;
|
||||
lastServiceMileage?: number;
|
||||
nextDueDate?: string;
|
||||
nextDueMileage?: number;
|
||||
isActive: boolean;
|
||||
emailNotifications?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Request types
|
||||
// Request types (camelCase)
|
||||
export interface CreateMaintenanceRecordRequest {
|
||||
vehicle_id: string;
|
||||
vehicleId: string;
|
||||
category: MaintenanceCategory;
|
||||
subtypes: string[];
|
||||
date: string;
|
||||
odometer_reading?: number;
|
||||
odometerReading?: number;
|
||||
cost?: number;
|
||||
shop_name?: string;
|
||||
shopName?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
@@ -102,37 +103,39 @@ export interface UpdateMaintenanceRecordRequest {
|
||||
category?: MaintenanceCategory;
|
||||
subtypes?: string[];
|
||||
date?: string;
|
||||
odometer_reading?: number | null;
|
||||
odometerReading?: number | null;
|
||||
cost?: number | null;
|
||||
shop_name?: string | null;
|
||||
shopName?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateScheduleRequest {
|
||||
vehicle_id: string;
|
||||
vehicleId: string;
|
||||
category: MaintenanceCategory;
|
||||
subtypes: string[];
|
||||
interval_months?: number;
|
||||
interval_miles?: number;
|
||||
intervalMonths?: number;
|
||||
intervalMiles?: number;
|
||||
emailNotifications?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateScheduleRequest {
|
||||
category?: MaintenanceCategory;
|
||||
subtypes?: string[];
|
||||
interval_months?: number | null;
|
||||
interval_miles?: number | null;
|
||||
is_active?: boolean;
|
||||
intervalMonths?: number | null;
|
||||
intervalMiles?: number | null;
|
||||
isActive?: boolean;
|
||||
emailNotifications?: boolean;
|
||||
}
|
||||
|
||||
// Response types
|
||||
// Response types (camelCase)
|
||||
export interface MaintenanceRecordResponse extends MaintenanceRecord {
|
||||
subtype_count: number;
|
||||
subtypeCount: number;
|
||||
}
|
||||
|
||||
export interface MaintenanceScheduleResponse extends MaintenanceSchedule {
|
||||
subtype_count: number;
|
||||
is_due_soon?: boolean;
|
||||
is_overdue?: boolean;
|
||||
subtypeCount: number;
|
||||
isDueSoon?: boolean;
|
||||
isOverdue?: boolean;
|
||||
}
|
||||
|
||||
// Validation helpers
|
||||
|
||||
23
frontend/src/features/notifications/api/notifications.api.ts
Normal file
23
frontend/src/features/notifications/api/notifications.api.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @ai-summary API calls for notifications feature
|
||||
*/
|
||||
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
import { NotificationSummary, DueMaintenanceItem, ExpiringDocument } from '../types/notifications.types';
|
||||
|
||||
export const notificationsApi = {
|
||||
getSummary: async (): Promise<NotificationSummary> => {
|
||||
const response = await apiClient.get('/notifications/summary');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getDueMaintenanceItems: async (): Promise<DueMaintenanceItem[]> => {
|
||||
const response = await apiClient.get('/notifications/maintenance');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getExpiringDocuments: async (): Promise<ExpiringDocument[]> => {
|
||||
const response = await apiClient.get('/notifications/documents');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @ai-summary Email notification toggle component
|
||||
* @ai-context Mobile-first responsive toggle switch for email notifications
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface EmailNotificationToggleProps {
|
||||
enabled: boolean;
|
||||
onChange: (enabled: boolean) => void;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const EmailNotificationToggle: React.FC<EmailNotificationToggleProps> = ({
|
||||
enabled,
|
||||
onChange,
|
||||
label = 'Email notifications',
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`flex items-center justify-between gap-3 ${className}`}>
|
||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{label}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
aria-label={label}
|
||||
onClick={() => onChange(!enabled)}
|
||||
className={`
|
||||
relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full
|
||||
border-2 border-transparent transition-colors duration-200 ease-in-out
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2
|
||||
${enabled ? 'bg-primary-600' : 'bg-slate-300 dark:bg-slate-600'}
|
||||
`}
|
||||
style={{ minWidth: '44px', minHeight: '44px', padding: '9px 0' }}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
pointer-events-none inline-block h-5 w-5 transform rounded-full
|
||||
bg-white shadow ring-0 transition duration-200 ease-in-out
|
||||
${enabled ? 'translate-x-5' : 'translate-x-0'}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @ai-summary Hook to show login notifications toast based on notification summary
|
||||
* @ai-context Shows once per session on successful authentication
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { notificationsApi } from '../api/notifications.api';
|
||||
|
||||
export function useLoginNotifications() {
|
||||
const { isAuthenticated } = useAuth0();
|
||||
const hasShownToast = useRef(false);
|
||||
|
||||
const { data: summary } = useQuery({
|
||||
queryKey: ['notificationSummary'],
|
||||
queryFn: notificationsApi.getSummary,
|
||||
enabled: isAuthenticated && !hasShownToast.current,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (summary && !hasShownToast.current) {
|
||||
const maintenanceCount = summary.maintenanceDueSoon + summary.maintenanceOverdue;
|
||||
const documentCount = summary.documentsExpiringSoon + summary.documentsExpired;
|
||||
const total = maintenanceCount + documentCount;
|
||||
|
||||
if (total > 0) {
|
||||
const parts: string[] = [];
|
||||
if (maintenanceCount > 0) {
|
||||
parts.push(`${maintenanceCount} maintenance item${maintenanceCount > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (documentCount > 0) {
|
||||
parts.push(`${documentCount} document${documentCount > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
toast(`You have ${parts.join(' and ')} requiring attention`, {
|
||||
duration: 6000,
|
||||
icon: '🔔',
|
||||
});
|
||||
}
|
||||
hasShownToast.current = true;
|
||||
}
|
||||
}, [summary]);
|
||||
|
||||
return summary;
|
||||
}
|
||||
8
frontend/src/features/notifications/index.ts
Normal file
8
frontend/src/features/notifications/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @ai-summary Notifications feature exports
|
||||
*/
|
||||
|
||||
export * from './api/notifications.api';
|
||||
export * from './types/notifications.types';
|
||||
export * from './hooks/useLoginNotifications';
|
||||
export * from './components/EmailNotificationToggle';
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @ai-summary Type definitions for notifications feature
|
||||
* @ai-context Supports maintenance due/overdue and document expiring/expired notifications
|
||||
*/
|
||||
|
||||
export interface NotificationSummary {
|
||||
maintenanceDueSoon: number;
|
||||
maintenanceOverdue: number;
|
||||
documentsExpiringSoon: number;
|
||||
documentsExpired: number;
|
||||
}
|
||||
|
||||
export interface DueMaintenanceItem {
|
||||
scheduleId: string;
|
||||
vehicleId: string;
|
||||
vehicleName: string;
|
||||
category: string;
|
||||
subtypes: string[];
|
||||
dueDate?: string;
|
||||
dueMileage?: number;
|
||||
isDueSoon: boolean;
|
||||
isOverdue: boolean;
|
||||
emailNotifications: boolean;
|
||||
}
|
||||
|
||||
export interface ExpiringDocument {
|
||||
documentId: string;
|
||||
vehicleId: string;
|
||||
vehicleName: string;
|
||||
documentType: string;
|
||||
title: string;
|
||||
expirationDate: string;
|
||||
isExpiringSoon: boolean;
|
||||
isExpired: boolean;
|
||||
emailNotifications: boolean;
|
||||
}
|
||||
11
frontend/src/features/settings/api/profile.api.ts
Normal file
11
frontend/src/features/settings/api/profile.api.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @ai-summary API client for user profile endpoints
|
||||
*/
|
||||
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
import { UserProfile, UpdateProfileRequest } from '../types/profile.types';
|
||||
|
||||
export const profileApi = {
|
||||
getProfile: () => apiClient.get<UserProfile>('/user/profile'),
|
||||
updateProfile: (data: UpdateProfileRequest) => apiClient.put<UserProfile>('/user/profile', data),
|
||||
};
|
||||
69
frontend/src/features/settings/hooks/useProfile.ts
Normal file
69
frontend/src/features/settings/hooks/useProfile.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @ai-summary React hooks for user profile management
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { profileApi } from '../api/profile.api';
|
||||
import { UpdateProfileRequest } from '../types/profile.types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface ApiError {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const useProfile = () => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['user-profile'],
|
||||
queryFn: async () => {
|
||||
const response = await profileApi.getProfile();
|
||||
return response.data;
|
||||
},
|
||||
enabled: isAuthenticated && !isLoading,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes cache time
|
||||
retry: (failureCount, error: any) => {
|
||||
if (error?.response?.status === 401 && failureCount < 3) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (query.data) {
|
||||
console.log('[useProfile] Profile loaded successfully');
|
||||
}
|
||||
if (query.error) {
|
||||
console.error('[useProfile] Error loading profile:', query.error);
|
||||
}
|
||||
}, [query.data, query.error]);
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
export const useUpdateProfile = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: UpdateProfileRequest) => profileApi.updateProfile(data),
|
||||
onSuccess: (response) => {
|
||||
queryClient.setQueryData(['user-profile'], response.data);
|
||||
toast.success('Profile updated successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to update profile');
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
import { useProfile, useUpdateProfile } from '../hooks/useProfile';
|
||||
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
||||
import { useNavigationStore } from '../../../core/store';
|
||||
|
||||
@@ -73,9 +74,22 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
const { user, logout } = useAuth0();
|
||||
const { navigateToScreen } = useNavigationStore();
|
||||
const { settings, updateSetting, isLoading, error } = useSettings();
|
||||
const { data: profile, isLoading: profileLoading } = useProfile();
|
||||
const updateProfileMutation = useUpdateProfile();
|
||||
const { isAdmin, loading: adminLoading } = useAdminAccess();
|
||||
const [showDataExport, setShowDataExport] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [isEditingProfile, setIsEditingProfile] = useState(false);
|
||||
const [editedDisplayName, setEditedDisplayName] = useState('');
|
||||
const [editedNotificationEmail, setEditedNotificationEmail] = useState('');
|
||||
|
||||
// Initialize edit form when profile loads or edit mode starts
|
||||
React.useEffect(() => {
|
||||
if (profile && isEditingProfile) {
|
||||
setEditedDisplayName(profile.displayName || '');
|
||||
setEditedNotificationEmail(profile.notificationEmail || '');
|
||||
}
|
||||
}, [profile, isEditingProfile]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout({
|
||||
@@ -97,6 +111,40 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
};
|
||||
|
||||
const handleEditProfile = () => {
|
||||
setIsEditingProfile(true);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditingProfile(false);
|
||||
setEditedDisplayName(profile?.displayName || '');
|
||||
setEditedNotificationEmail(profile?.notificationEmail || '');
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
const updates: { displayName?: string; notificationEmail?: string } = {};
|
||||
|
||||
if (editedDisplayName !== (profile?.displayName || '')) {
|
||||
updates.displayName = editedDisplayName;
|
||||
}
|
||||
|
||||
if (editedNotificationEmail !== (profile?.notificationEmail || '')) {
|
||||
updates.notificationEmail = editedNotificationEmail || undefined;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
setIsEditingProfile(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateProfileMutation.mutateAsync(updates);
|
||||
setIsEditingProfile(false);
|
||||
} catch (error) {
|
||||
// Error is handled by the mutation hook
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -142,28 +190,128 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
<p className="text-slate-500 mt-2">Manage your account and preferences</p>
|
||||
</div>
|
||||
|
||||
{/* Account Section */}
|
||||
{/* Profile Section */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Account</h2>
|
||||
<div className="flex items-center space-x-3">
|
||||
{user?.picture && (
|
||||
<img
|
||||
src={user.picture}
|
||||
alt="Profile"
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold text-slate-800">Profile</h2>
|
||||
{!isEditingProfile && !profileLoading && (
|
||||
<button
|
||||
onClick={handleEditProfile}
|
||||
className="px-3 py-1.5 bg-blue-100 text-blue-700 rounded-lg text-sm font-medium hover:bg-blue-200 transition-colors"
|
||||
style={{ minHeight: '44px', minWidth: '44px' }}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-slate-800">{user?.name}</p>
|
||||
<p className="text-sm text-slate-500">{user?.email}</p>
|
||||
</div>
|
||||
|
||||
{profileLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-3 mt-3 border-t border-slate-200">
|
||||
<p className="text-sm text-slate-600">
|
||||
Member since {user?.updated_at ? new Date(user.updated_at).toLocaleDateString() : 'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
) : isEditingProfile ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={profile?.email || ''}
|
||||
disabled
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg bg-slate-100 text-slate-500"
|
||||
style={{ fontSize: '16px', minHeight: '44px' }}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Email is managed by your account provider</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedDisplayName}
|
||||
onChange={(e) => setEditedDisplayName(e.target.value)}
|
||||
placeholder="Enter your display name"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
style={{ fontSize: '16px', minHeight: '44px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Notification Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={editedNotificationEmail}
|
||||
onChange={(e) => setEditedNotificationEmail(e.target.value)}
|
||||
placeholder="Leave blank to use your primary email"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
style={{ fontSize: '16px', minHeight: '44px' }}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Optional: Use a different email for notifications</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-2">
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
disabled={updateProfileMutation.isPending}
|
||||
className="flex-1 py-2.5 px-4 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors disabled:opacity-50"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveProfile}
|
||||
disabled={updateProfileMutation.isPending}
|
||||
className="flex-1 py-2.5 px-4 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center justify-center"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
{updateProfileMutation.isPending ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||
) : (
|
||||
'Save'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
{user?.picture ? (
|
||||
<img
|
||||
src={user.picture}
|
||||
alt="Profile"
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold">
|
||||
{profile?.displayName?.charAt(0) || user?.name?.charAt(0) || user?.email?.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-slate-800">
|
||||
{profile?.displayName || user?.name || 'User'}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">{profile?.email || user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-3 border-t border-slate-200">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase">Display Name</p>
|
||||
<p className="text-sm text-slate-800">{profile?.displayName || 'Not set'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase">Notification Email</p>
|
||||
<p className="text-sm text-slate-800">{profile?.notificationEmail || 'Using primary email'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
@@ -281,6 +429,14 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
<div className="font-semibold">Station Management</div>
|
||||
<div className="text-sm text-blue-600 mt-1">Manage gas station data and locations</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigateToScreen('AdminEmailTemplates')}
|
||||
className="w-full text-left p-4 bg-blue-50 text-blue-700 rounded-lg font-medium hover:bg-blue-100 transition-colors active:bg-blue-200"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<div className="font-semibold">Email Templates</div>
|
||||
<div className="text-sm text-blue-600 mt-1">Manage notification email templates</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
18
frontend/src/features/settings/types/profile.types.ts
Normal file
18
frontend/src/features/settings/types/profile.types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @ai-summary User profile types for settings feature
|
||||
*/
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
auth0Sub: string;
|
||||
email: string;
|
||||
displayName?: string;
|
||||
notificationEmail?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UpdateProfileRequest {
|
||||
displayName?: string;
|
||||
notificationEmail?: string;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useUnits } from '../core/units/UnitsContext';
|
||||
import { useAdminAccess } from '../core/auth/useAdminAccess';
|
||||
import { useProfile, useUpdateProfile } from '../features/settings/hooks/useProfile';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
@@ -21,7 +22,9 @@ import {
|
||||
Button as MuiButton,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl
|
||||
FormControl,
|
||||
TextField,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
|
||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||
@@ -29,6 +32,9 @@ import PaletteIcon from '@mui/icons-material/Palette';
|
||||
import SecurityIcon from '@mui/icons-material/Security';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import { Card } from '../shared-minimal/components/Card';
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
@@ -40,10 +46,59 @@ export const SettingsPage: React.FC = () => {
|
||||
const [emailUpdates, setEmailUpdates] = useState(false);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
|
||||
// Profile state
|
||||
const { data: profile, isLoading: profileLoading } = useProfile();
|
||||
const updateProfileMutation = useUpdateProfile();
|
||||
const [isEditingProfile, setIsEditingProfile] = useState(false);
|
||||
const [editedDisplayName, setEditedDisplayName] = useState('');
|
||||
const [editedNotificationEmail, setEditedNotificationEmail] = useState('');
|
||||
|
||||
// Initialize edit form when profile loads or edit mode starts
|
||||
React.useEffect(() => {
|
||||
if (profile && isEditingProfile) {
|
||||
setEditedDisplayName(profile.displayName || '');
|
||||
setEditedNotificationEmail(profile.notificationEmail || '');
|
||||
}
|
||||
}, [profile, isEditingProfile]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout({ logoutParams: { returnTo: window.location.origin } });
|
||||
};
|
||||
|
||||
const handleEditProfile = () => {
|
||||
setIsEditingProfile(true);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditingProfile(false);
|
||||
setEditedDisplayName(profile?.displayName || '');
|
||||
setEditedNotificationEmail(profile?.notificationEmail || '');
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
const updates: { displayName?: string; notificationEmail?: string } = {};
|
||||
|
||||
if (editedDisplayName !== (profile?.displayName || '')) {
|
||||
updates.displayName = editedDisplayName;
|
||||
}
|
||||
|
||||
if (editedNotificationEmail !== (profile?.notificationEmail || '')) {
|
||||
updates.notificationEmail = editedNotificationEmail || undefined;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
setIsEditingProfile(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateProfileMutation.mutateAsync(updates);
|
||||
setIsEditingProfile(false);
|
||||
} catch (error) {
|
||||
// Error is handled by the mutation hook
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ py: 2 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary', mb: 4 }}>
|
||||
@@ -51,69 +106,149 @@ export const SettingsPage: React.FC = () => {
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Account Section */}
|
||||
{/* Profile Section */}
|
||||
<Card>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
|
||||
Account
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
bgcolor: 'primary.main',
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 600,
|
||||
mr: 3
|
||||
}}
|
||||
>
|
||||
{user?.name?.charAt(0) || user?.email?.charAt(0)}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 500 }}>
|
||||
{user?.name || 'User'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{user?.email}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Verified account
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Profile
|
||||
</Typography>
|
||||
{!isEditingProfile && !profileLoading && (
|
||||
<MuiButton
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<EditIcon />}
|
||||
onClick={handleEditProfile}
|
||||
>
|
||||
Edit
|
||||
</MuiButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<List disablePadding>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<AccountCircleIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Profile Information"
|
||||
secondary="Manage your account details"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<MuiButton variant="outlined" size="small">
|
||||
Edit
|
||||
</MuiButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<SecurityIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Security & Privacy"
|
||||
secondary="Password, two-factor authentication"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<MuiButton variant="outlined" size="small">
|
||||
Manage
|
||||
</MuiButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</List>
|
||||
{profileLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
bgcolor: 'primary.main',
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 600,
|
||||
mr: 3
|
||||
}}
|
||||
>
|
||||
{profile?.displayName?.charAt(0) || user?.name?.charAt(0) || user?.email?.charAt(0)}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 500 }}>
|
||||
{profile?.displayName || user?.name || 'User'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{profile?.email || user?.email}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Verified account
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isEditingProfile ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<TextField
|
||||
label="Email"
|
||||
value={profile?.email || ''}
|
||||
disabled
|
||||
fullWidth
|
||||
helperText="Email is managed by your account provider and cannot be changed here"
|
||||
variant="outlined"
|
||||
/>
|
||||
<TextField
|
||||
label="Display Name"
|
||||
value={editedDisplayName}
|
||||
onChange={(e) => setEditedDisplayName(e.target.value)}
|
||||
fullWidth
|
||||
placeholder="Enter your display name"
|
||||
variant="outlined"
|
||||
/>
|
||||
<TextField
|
||||
label="Notification Email"
|
||||
value={editedNotificationEmail}
|
||||
onChange={(e) => setEditedNotificationEmail(e.target.value)}
|
||||
fullWidth
|
||||
placeholder="Leave blank to use your primary email"
|
||||
helperText="Optional: Use a different email address for notifications"
|
||||
variant="outlined"
|
||||
type="email"
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
|
||||
<MuiButton
|
||||
variant="outlined"
|
||||
startIcon={<CancelIcon />}
|
||||
onClick={handleCancelEdit}
|
||||
disabled={updateProfileMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</MuiButton>
|
||||
<MuiButton
|
||||
variant="contained"
|
||||
startIcon={updateProfileMutation.isPending ? <CircularProgress size={20} /> : <SaveIcon />}
|
||||
onClick={handleSaveProfile}
|
||||
disabled={updateProfileMutation.isPending}
|
||||
>
|
||||
Save
|
||||
</MuiButton>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<List disablePadding>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<AccountCircleIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Email"
|
||||
secondary={profile?.email || user?.email || 'Not available'}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Display Name"
|
||||
secondary={profile?.displayName || 'Not set'}
|
||||
sx={{ pl: 7 }}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Notification Email"
|
||||
secondary={profile?.notificationEmail || 'Using primary email'}
|
||||
sx={{ pl: 7 }}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<SecurityIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Security & Privacy"
|
||||
secondary="Password, two-factor authentication"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<MuiButton variant="outlined" size="small">
|
||||
Manage
|
||||
</MuiButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</List>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Notifications Section */}
|
||||
@@ -306,6 +441,23 @@ export const SettingsPage: React.FC = () => {
|
||||
</MuiButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Email Templates"
|
||||
secondary="Manage notification email templates"
|
||||
sx={{ pl: 7 }}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<MuiButton
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => navigate('/garage/settings/admin/email-templates')}
|
||||
>
|
||||
Manage
|
||||
</MuiButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
395
frontend/src/pages/admin/AdminEmailTemplatesPage.tsx
Normal file
395
frontend/src/pages/admin/AdminEmailTemplatesPage.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* @ai-summary Admin Email Templates page for managing notification email templates
|
||||
* @ai-context Desktop version with template list and edit dialog
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
Switch,
|
||||
TextField,
|
||||
Typography,
|
||||
Alert,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import { Email, Edit, Visibility, Send } from '@mui/icons-material';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAdminAccess } from '../../core/auth/useAdminAccess';
|
||||
import { adminApi } from '../../features/admin/api/admin.api';
|
||||
import { Box as MuiBox } from '@mui/material';
|
||||
import { EmailTemplate, UpdateEmailTemplateRequest } from '../../features/admin/types/admin.types';
|
||||
|
||||
const SAMPLE_VARIABLES: Record<string, string> = {
|
||||
userName: 'John Doe',
|
||||
vehicleName: '2024 Toyota Camry',
|
||||
category: 'Routine Maintenance',
|
||||
subtypes: 'Oil Change, Air Filter',
|
||||
dueDate: '2025-01-15',
|
||||
dueMileage: '50,000',
|
||||
documentType: 'Insurance',
|
||||
documentTitle: 'State Farm Policy',
|
||||
expirationDate: '2025-02-28',
|
||||
};
|
||||
|
||||
export const AdminEmailTemplatesPage: React.FC = () => {
|
||||
const { loading: authLoading, isAdmin } = useAdminAccess();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// State
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<EmailTemplate | null>(null);
|
||||
const [editSubject, setEditSubject] = useState('');
|
||||
const [editBody, setEditBody] = useState('');
|
||||
const [editIsActive, setEditIsActive] = useState(true);
|
||||
const [previewSubject, setPreviewSubject] = useState('');
|
||||
const [previewBody, setPreviewBody] = useState('');
|
||||
|
||||
// Queries
|
||||
const { data: templates, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'emailTemplates'],
|
||||
queryFn: () => adminApi.emailTemplates.list(),
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ key, data }: { key: string; data: UpdateEmailTemplateRequest }) =>
|
||||
adminApi.emailTemplates.update(key, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'emailTemplates'] });
|
||||
toast.success('Email template updated successfully');
|
||||
handleCloseEditDialog();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update email template');
|
||||
},
|
||||
});
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: ({ key, variables }: { key: string; variables: Record<string, string> }) =>
|
||||
adminApi.emailTemplates.preview(key, variables),
|
||||
onSuccess: (data) => {
|
||||
setPreviewSubject(data.subject);
|
||||
setPreviewBody(data.body);
|
||||
setPreviewDialogOpen(true);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to generate preview');
|
||||
},
|
||||
});
|
||||
|
||||
const sendTestMutation = useMutation({
|
||||
mutationFn: (key: string) => adminApi.emailTemplates.sendTest(key),
|
||||
onSuccess: (data) => {
|
||||
if (data.error) {
|
||||
toast.error(`Test email failed: ${data.error}`);
|
||||
} else if (data.message) {
|
||||
toast.success(data.message);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to send test email');
|
||||
},
|
||||
});
|
||||
|
||||
// Handlers
|
||||
const handleEditClick = useCallback((template: EmailTemplate) => {
|
||||
setSelectedTemplate(template);
|
||||
setEditSubject(template.subject);
|
||||
setEditBody(template.body);
|
||||
setEditIsActive(template.isActive);
|
||||
setEditDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handlePreviewClick = useCallback((template: EmailTemplate) => {
|
||||
setSelectedTemplate(template);
|
||||
previewMutation.mutate({
|
||||
key: template.templateKey,
|
||||
variables: SAMPLE_VARIABLES,
|
||||
});
|
||||
}, [previewMutation]);
|
||||
|
||||
const handleSendTestClick = useCallback((template: EmailTemplate) => {
|
||||
sendTestMutation.mutate(template.templateKey);
|
||||
}, [sendTestMutation]);
|
||||
|
||||
const handleCloseEditDialog = useCallback(() => {
|
||||
setEditDialogOpen(false);
|
||||
setSelectedTemplate(null);
|
||||
setEditSubject('');
|
||||
setEditBody('');
|
||||
setEditIsActive(true);
|
||||
}, []);
|
||||
|
||||
const handleClosePreviewDialog = useCallback(() => {
|
||||
setPreviewDialogOpen(false);
|
||||
setPreviewSubject('');
|
||||
setPreviewBody('');
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!selectedTemplate) return;
|
||||
|
||||
const data: UpdateEmailTemplateRequest = {
|
||||
subject: editSubject !== selectedTemplate.subject ? editSubject : undefined,
|
||||
body: editBody !== selectedTemplate.body ? editBody : undefined,
|
||||
isActive: editIsActive !== selectedTemplate.isActive ? editIsActive : undefined,
|
||||
};
|
||||
|
||||
// Only update if there are changes
|
||||
if (data.subject || data.body || data.isActive !== undefined) {
|
||||
updateMutation.mutate({
|
||||
key: selectedTemplate.templateKey,
|
||||
data,
|
||||
});
|
||||
} else {
|
||||
handleCloseEditDialog();
|
||||
}
|
||||
}, [selectedTemplate, editSubject, editBody, editIsActive, updateMutation, handleCloseEditDialog]);
|
||||
|
||||
// Auth loading
|
||||
if (authLoading) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// Not admin
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/garage/settings" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<MuiBox sx={{ mb: 4 }}>
|
||||
<Typography variant="h4" component="h1" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Email />
|
||||
Email Templates
|
||||
</Box>
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Manage notification email templates
|
||||
</Typography>
|
||||
</MuiBox>
|
||||
|
||||
<Box sx={{ mt: 4 }}>
|
||||
{isLoading ? (
|
||||
<Box display="flex" justifyContent="center" py={8}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
{templates?.map((template) => (
|
||||
<Grid item xs={12} md={6} key={template.id}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="start" mb={2}>
|
||||
<Typography variant="h6" component="div">
|
||||
{template.name}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={template.isActive ? 'Active' : 'Inactive'}
|
||||
color={template.isActive ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{template.description && (
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{template.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Subject
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
gutterBottom
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{template.subject}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>
|
||||
Available Variables
|
||||
</Typography>
|
||||
<Box display="flex" flexWrap="wrap" gap={0.5}>
|
||||
{template.variables.map((variable) => (
|
||||
<Chip
|
||||
key={variable}
|
||||
label={`{{${variable}}}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
||||
<CardActions>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Edit />}
|
||||
onClick={() => handleEditClick(template)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Visibility />}
|
||||
onClick={() => handlePreviewClick(template)}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Send />}
|
||||
onClick={() => handleSendTestClick(template)}
|
||||
disabled={sendTestMutation.isPending}
|
||||
>
|
||||
{sendTestMutation.isPending ? 'Sending...' : 'Send Test'}
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog
|
||||
open={editDialogOpen}
|
||||
onClose={handleCloseEditDialog}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
Edit Template: {selectedTemplate?.name}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={editIsActive}
|
||||
onChange={(e) => setEditIsActive(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Active"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Subject"
|
||||
fullWidth
|
||||
value={editSubject}
|
||||
onChange={(e) => setEditSubject(e.target.value)}
|
||||
helperText="Use {{variableName}} for dynamic content"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Body"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={12}
|
||||
value={editBody}
|
||||
onChange={(e) => setEditBody(e.target.value)}
|
||||
helperText="Use {{variableName}} for dynamic content"
|
||||
inputProps={{
|
||||
style: { fontFamily: 'monospace' },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Alert severity="info">
|
||||
Available variables:{' '}
|
||||
{selectedTemplate?.variables.map((v) => `{{${v}}}`).join(', ')}
|
||||
</Alert>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseEditDialog}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="contained"
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Preview Dialog */}
|
||||
<Dialog
|
||||
open={previewDialogOpen}
|
||||
onClose={handleClosePreviewDialog}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
Preview: {selectedTemplate?.name}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Alert severity="info">
|
||||
This preview uses sample data to show how the template will appear.
|
||||
</Alert>
|
||||
|
||||
<TextField
|
||||
label="Subject"
|
||||
fullWidth
|
||||
value={previewSubject}
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Body"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={12}
|
||||
value={previewBody}
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
}}
|
||||
inputProps={{
|
||||
style: { fontFamily: 'monospace' },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClosePreviewDialog}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -1,18 +1,269 @@
|
||||
/**
|
||||
* @ai-summary Desktop admin page for user management
|
||||
* @ai-context Manage admin users, revoke, reinstate, and view audit logs
|
||||
* @ai-context List users, filter, search, change tiers, deactivate/reactivate
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Box, Typography, CircularProgress } from '@mui/material';
|
||||
import { Card } from '../../shared-minimal/components/Card';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TablePagination,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
SelectChangeEvent,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search,
|
||||
Clear,
|
||||
MoreVert,
|
||||
AdminPanelSettings,
|
||||
PersonOff,
|
||||
PersonAdd,
|
||||
Edit,
|
||||
Security,
|
||||
} from '@mui/icons-material';
|
||||
import { useAdminAccess } from '../../core/auth/useAdminAccess';
|
||||
import {
|
||||
useUsers,
|
||||
useUpdateUserTier,
|
||||
useDeactivateUser,
|
||||
useReactivateUser,
|
||||
useUpdateUserProfile,
|
||||
usePromoteToAdmin,
|
||||
} from '../../features/admin/hooks/useUsers';
|
||||
import {
|
||||
ManagedUser,
|
||||
SubscriptionTier,
|
||||
ListUsersParams,
|
||||
} from '../../features/admin/types/admin.types';
|
||||
import { AdminSectionHeader } from '../../features/admin/components';
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
|
||||
export const AdminUsersPage: React.FC = () => {
|
||||
const { isAdmin, loading } = useAdminAccess();
|
||||
const { isAdmin, loading: adminLoading } = useAdminAccess();
|
||||
|
||||
if (loading) {
|
||||
// Filter state
|
||||
const [params, setParams] = useState<ListUsersParams>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
status: 'all',
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'desc',
|
||||
});
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
|
||||
// Query
|
||||
const { data, isLoading, error } = useUsers(params);
|
||||
|
||||
// Mutations
|
||||
const updateTierMutation = useUpdateUserTier();
|
||||
const deactivateMutation = useDeactivateUser();
|
||||
const reactivateMutation = useReactivateUser();
|
||||
const updateProfileMutation = useUpdateUserProfile();
|
||||
const promoteToAdminMutation = usePromoteToAdmin();
|
||||
|
||||
// Action menu state
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [selectedUser, setSelectedUser] = useState<ManagedUser | null>(null);
|
||||
|
||||
// Deactivate dialog state
|
||||
const [deactivateDialogOpen, setDeactivateDialogOpen] = useState(false);
|
||||
const [deactivateReason, setDeactivateReason] = useState('');
|
||||
|
||||
// Edit dialog state
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [editEmail, setEditEmail] = useState('');
|
||||
const [editDisplayName, setEditDisplayName] = useState('');
|
||||
|
||||
// Promote to admin dialog state
|
||||
const [promoteDialogOpen, setPromoteDialogOpen] = useState(false);
|
||||
const [promoteRole, setPromoteRole] = useState<'admin' | 'super_admin'>('admin');
|
||||
|
||||
// Handlers
|
||||
const handleSearch = useCallback(() => {
|
||||
setParams(prev => ({ ...prev, search: searchInput || undefined, page: 1 }));
|
||||
}, [searchInput]);
|
||||
|
||||
const handleSearchKeyPress = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
},
|
||||
[handleSearch]
|
||||
);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchInput('');
|
||||
setParams(prev => ({ ...prev, search: undefined, page: 1 }));
|
||||
}, []);
|
||||
|
||||
const handlePageChange = useCallback((_: unknown, newPage: number) => {
|
||||
setParams(prev => ({ ...prev, page: newPage + 1 }));
|
||||
}, []);
|
||||
|
||||
const handleRowsPerPageChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setParams(prev => ({ ...prev, pageSize: parseInt(event.target.value, 10), page: 1 }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleTierFilterChange = useCallback((event: SelectChangeEvent<string>) => {
|
||||
const value = event.target.value;
|
||||
setParams(prev => ({
|
||||
...prev,
|
||||
tier: value ? (value as SubscriptionTier) : undefined,
|
||||
page: 1,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleStatusFilterChange = useCallback((event: SelectChangeEvent<string>) => {
|
||||
setParams(prev => ({
|
||||
...prev,
|
||||
status: event.target.value as 'active' | 'deactivated' | 'all',
|
||||
page: 1,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleTierChange = useCallback(
|
||||
(auth0Sub: string, newTier: SubscriptionTier) => {
|
||||
updateTierMutation.mutate({ auth0Sub, data: { subscriptionTier: newTier } });
|
||||
},
|
||||
[updateTierMutation]
|
||||
);
|
||||
|
||||
const handleMenuOpen = useCallback((event: React.MouseEvent<HTMLElement>, user: ManagedUser) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
setSelectedUser(user);
|
||||
}, []);
|
||||
|
||||
const handleMenuClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setSelectedUser(null);
|
||||
}, []);
|
||||
|
||||
const handleDeactivateClick = useCallback(() => {
|
||||
setDeactivateDialogOpen(true);
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const handleDeactivateConfirm = useCallback(() => {
|
||||
if (selectedUser) {
|
||||
deactivateMutation.mutate(
|
||||
{ auth0Sub: selectedUser.auth0Sub, data: { reason: deactivateReason || undefined } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setDeactivateDialogOpen(false);
|
||||
setDeactivateReason('');
|
||||
setSelectedUser(null);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [selectedUser, deactivateReason, deactivateMutation]);
|
||||
|
||||
const handleReactivate = useCallback(() => {
|
||||
if (selectedUser) {
|
||||
reactivateMutation.mutate(selectedUser.auth0Sub);
|
||||
setAnchorEl(null);
|
||||
setSelectedUser(null);
|
||||
}
|
||||
}, [selectedUser, reactivateMutation]);
|
||||
|
||||
const handleEditClick = useCallback(() => {
|
||||
if (selectedUser) {
|
||||
setEditEmail(selectedUser.email);
|
||||
setEditDisplayName(selectedUser.displayName || '');
|
||||
setEditDialogOpen(true);
|
||||
setAnchorEl(null);
|
||||
}
|
||||
}, [selectedUser]);
|
||||
|
||||
const handleEditConfirm = useCallback(() => {
|
||||
if (selectedUser) {
|
||||
const updates: { email?: string; displayName?: string } = {};
|
||||
if (editEmail !== selectedUser.email) {
|
||||
updates.email = editEmail;
|
||||
}
|
||||
if (editDisplayName !== (selectedUser.displayName || '')) {
|
||||
updates.displayName = editDisplayName;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updateProfileMutation.mutate(
|
||||
{ auth0Sub: selectedUser.auth0Sub, data: updates },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditDialogOpen(false);
|
||||
setEditEmail('');
|
||||
setEditDisplayName('');
|
||||
setSelectedUser(null);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [selectedUser, editEmail, editDisplayName, updateProfileMutation]);
|
||||
|
||||
const handleEditCancel = useCallback(() => {
|
||||
setEditDialogOpen(false);
|
||||
setEditEmail('');
|
||||
setEditDisplayName('');
|
||||
setSelectedUser(null);
|
||||
}, []);
|
||||
|
||||
const handlePromoteClick = useCallback(() => {
|
||||
setPromoteRole('admin');
|
||||
setPromoteDialogOpen(true);
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const handlePromoteConfirm = useCallback(() => {
|
||||
if (selectedUser) {
|
||||
promoteToAdminMutation.mutate(
|
||||
{ auth0Sub: selectedUser.auth0Sub, data: { role: promoteRole } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setPromoteDialogOpen(false);
|
||||
setPromoteRole('admin');
|
||||
setSelectedUser(null);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [selectedUser, promoteRole, promoteToAdminMutation]);
|
||||
|
||||
const handlePromoteCancel = useCallback(() => {
|
||||
setPromoteDialogOpen(false);
|
||||
setPromoteRole('admin');
|
||||
setSelectedUser(null);
|
||||
}, []);
|
||||
|
||||
// Loading state
|
||||
if (adminLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
|
||||
<CircularProgress />
|
||||
@@ -20,34 +271,359 @@ export const AdminUsersPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Not admin redirect
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/garage/settings" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ py: 2 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary', mb: 4 }}>
|
||||
User Management
|
||||
</Typography>
|
||||
const users = data?.users || [];
|
||||
const total = data?.total || 0;
|
||||
|
||||
<Card>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
|
||||
Admin Users
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Admin user management interface coming soon.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||
Features:
|
||||
</Typography>
|
||||
<ul>
|
||||
<li>List all admin users</li>
|
||||
<li>Add new admin users</li>
|
||||
<li>Revoke admin access</li>
|
||||
<li>Reinstate revoked admins</li>
|
||||
<li>View audit logs</li>
|
||||
</ul>
|
||||
</Card>
|
||||
return (
|
||||
<Box sx={{ py: 2, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminSectionHeader
|
||||
title="User Management"
|
||||
stats={[{ label: 'Total Users', value: total }]}
|
||||
/>
|
||||
|
||||
{/* Filters Bar */}
|
||||
<Paper elevation={1} sx={{ p: 2, borderRadius: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
gap: 2,
|
||||
alignItems: { xs: 'stretch', md: 'center' },
|
||||
}}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<TextField
|
||||
placeholder="Search by email or name..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyPress={handleSearchKeyPress}
|
||||
size="small"
|
||||
sx={{ flex: 1, minWidth: 250 }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search color="action" />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: searchInput && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton size="small" onClick={handleClearSearch}>
|
||||
<Clear />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Search Button */}
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSearch}
|
||||
disabled={isLoading}
|
||||
sx={{ textTransform: 'none', minWidth: 100 }}
|
||||
>
|
||||
{isLoading ? <CircularProgress size={20} /> : 'Search'}
|
||||
</Button>
|
||||
|
||||
{/* Tier Filter */}
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel>Tier</InputLabel>
|
||||
<Select
|
||||
value={params.tier || ''}
|
||||
label="Tier"
|
||||
onChange={handleTierFilterChange}
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
<MenuItem value="free">Free</MenuItem>
|
||||
<MenuItem value="pro">Pro</MenuItem>
|
||||
<MenuItem value="enterprise">Enterprise</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Status Filter */}
|
||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
value={params.status || 'all'}
|
||||
label="Status"
|
||||
onChange={handleStatusFilterChange}
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="active">Active</MenuItem>
|
||||
<MenuItem value="deactivated">Deactivated</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Users Table */}
|
||||
<Paper elevation={1} sx={{ borderRadius: 1.5 }}>
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : error ? (
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography color="error">Failed to load users. Please try again.</Typography>
|
||||
</Box>
|
||||
) : users.length === 0 ? (
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography color="text.secondary">No users found matching your criteria.</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Email</TableCell>
|
||||
<TableCell>Display Name</TableCell>
|
||||
<TableCell>Tier</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Admin</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow
|
||||
key={user.auth0Sub}
|
||||
sx={{ opacity: user.deactivatedAt ? 0.6 : 1 }}
|
||||
>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>{user.displayName || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<FormControl size="small" sx={{ minWidth: 100 }}>
|
||||
<Select
|
||||
value={user.subscriptionTier}
|
||||
onChange={(e) =>
|
||||
handleTierChange(user.auth0Sub, e.target.value as SubscriptionTier)
|
||||
}
|
||||
disabled={!!user.deactivatedAt || updateTierMutation.isPending}
|
||||
size="small"
|
||||
>
|
||||
<MenuItem value="free">Free</MenuItem>
|
||||
<MenuItem value="pro">Pro</MenuItem>
|
||||
<MenuItem value="enterprise">Enterprise</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={user.deactivatedAt ? 'Deactivated' : 'Active'}
|
||||
color={user.deactivatedAt ? 'error' : 'success'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.isAdmin && (
|
||||
<Tooltip title={`Admin (${user.adminRole})`}>
|
||||
<AdminPanelSettings color="primary" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => handleMenuOpen(e, user)}
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={(params.page || 1) - 1}
|
||||
rowsPerPage={params.pageSize || 20}
|
||||
onPageChange={handlePageChange}
|
||||
onRowsPerPageChange={handleRowsPerPageChange}
|
||||
rowsPerPageOptions={PAGE_SIZE_OPTIONS}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Action Menu */}
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<MenuItem onClick={handleEditClick}>
|
||||
<Edit sx={{ mr: 1 }} fontSize="small" />
|
||||
Edit User
|
||||
</MenuItem>
|
||||
{!selectedUser?.isAdmin && (
|
||||
<MenuItem onClick={handlePromoteClick}>
|
||||
<Security sx={{ mr: 1 }} fontSize="small" />
|
||||
Promote to Admin
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedUser?.deactivatedAt ? (
|
||||
<MenuItem onClick={handleReactivate}>
|
||||
<PersonAdd sx={{ mr: 1 }} fontSize="small" />
|
||||
Reactivate User
|
||||
</MenuItem>
|
||||
) : (
|
||||
<MenuItem onClick={handleDeactivateClick} sx={{ color: 'error.main' }}>
|
||||
<PersonOff sx={{ mr: 1 }} fontSize="small" />
|
||||
Deactivate User
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
|
||||
{/* Deactivate Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deactivateDialogOpen}
|
||||
onClose={() => !deactivateMutation.isPending && setDeactivateDialogOpen(false)}
|
||||
>
|
||||
<DialogTitle>Deactivate User</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography sx={{ mb: 2 }}>
|
||||
Are you sure you want to deactivate{' '}
|
||||
<strong>{selectedUser?.email}</strong>?
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
The user will no longer be able to log in, but their data will be preserved.
|
||||
</Typography>
|
||||
<TextField
|
||||
label="Reason (optional)"
|
||||
value={deactivateReason}
|
||||
onChange={(e) => setDeactivateReason(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
placeholder="Enter a reason for deactivation..."
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => setDeactivateDialogOpen(false)}
|
||||
disabled={deactivateMutation.isPending}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDeactivateConfirm}
|
||||
disabled={deactivateMutation.isPending}
|
||||
color="error"
|
||||
variant="contained"
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
{deactivateMutation.isPending ? <CircularProgress size={20} /> : 'Deactivate'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit User Dialog */}
|
||||
<Dialog
|
||||
open={editDialogOpen}
|
||||
onClose={() => !updateProfileMutation.isPending && handleEditCancel()}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Edit User</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
<TextField
|
||||
label="Email"
|
||||
type="email"
|
||||
value={editEmail}
|
||||
onChange={(e) => setEditEmail(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Display Name"
|
||||
value={editDisplayName}
|
||||
onChange={(e) => setEditDisplayName(e.target.value)}
|
||||
fullWidth
|
||||
placeholder="Enter display name..."
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={handleEditCancel}
|
||||
disabled={updateProfileMutation.isPending}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleEditConfirm}
|
||||
disabled={updateProfileMutation.isPending || (editEmail === selectedUser?.email && editDisplayName === (selectedUser?.displayName || ''))}
|
||||
color="primary"
|
||||
variant="contained"
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
{updateProfileMutation.isPending ? <CircularProgress size={20} /> : 'Save'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Promote to Admin Dialog */}
|
||||
<Dialog
|
||||
open={promoteDialogOpen}
|
||||
onClose={() => !promoteToAdminMutation.isPending && handlePromoteCancel()}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Promote to Admin</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography sx={{ mb: 3 }}>
|
||||
Promote <strong>{selectedUser?.email}</strong> to an administrator role.
|
||||
</Typography>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Admin Role</InputLabel>
|
||||
<Select
|
||||
value={promoteRole}
|
||||
label="Admin Role"
|
||||
onChange={(e) => setPromoteRole(e.target.value as 'admin' | 'super_admin')}
|
||||
>
|
||||
<MenuItem value="admin">Admin</MenuItem>
|
||||
<MenuItem value="super_admin">Super Admin</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||
Admins can manage users, catalog data, and view audit logs.
|
||||
Super Admins have additional permissions to manage other administrators.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={handlePromoteCancel}
|
||||
disabled={promoteToAdminMutation.isPending}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handlePromoteConfirm}
|
||||
disabled={promoteToAdminMutation.isPending}
|
||||
color="primary"
|
||||
variant="contained"
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
{promoteToAdminMutation.isPending ? <CircularProgress size={20} /> : 'Promote'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
# - GOOGLE_MAPS_API_KEY
|
||||
# - GOOGLE_MAPS_MAP_ID
|
||||
# - CF_DNS_API_TOKEN (Cloudflare DNS API token for Let's Encrypt certificates)
|
||||
# - RESEND_API_KEY (Resend API key for email notifications)
|
||||
#
|
||||
# Required GitLab CI/CD Variables (Variable type):
|
||||
# - DEPLOY_PATH
|
||||
@@ -33,6 +34,7 @@ SECRET_FILES=(
|
||||
"google-maps-api-key.txt"
|
||||
"google-maps-map-id.txt"
|
||||
"cloudflare-dns-token.txt"
|
||||
"resend-api-key.txt"
|
||||
)
|
||||
|
||||
echo "Injecting secrets..."
|
||||
@@ -101,6 +103,7 @@ inject_secret "AUTH0_CLIENT_SECRET" "auth0-client-secret.txt" || FAILED=1
|
||||
inject_secret "GOOGLE_MAPS_API_KEY" "google-maps-api-key.txt" || FAILED=1
|
||||
inject_secret "GOOGLE_MAPS_MAP_ID" "google-maps-map-id.txt" || FAILED=1
|
||||
inject_secret "CF_DNS_API_TOKEN" "cloudflare-dns-token.txt" || FAILED=1
|
||||
inject_secret "RESEND_API_KEY" "resend-api-key.txt" || FAILED=1
|
||||
|
||||
if [ $FAILED -eq 1 ]; then
|
||||
echo ""
|
||||
|
||||
Reference in New Issue
Block a user