Notification updates

This commit is contained in:
Eric Gullickson
2025-12-21 19:56:52 -06:00
parent 144f1d5bb0
commit 719c80ecd8
80 changed files with 7552 additions and 678 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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');

View File

@@ -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) => {

View File

@@ -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',

View File

@@ -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) {

View File

@@ -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

View 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',
});
}
}
}

View 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>;

View File

@@ -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),
};
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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,
});
}

View File

@@ -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>;

View File

@@ -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]);
}
}

View File

@@ -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;

View File

@@ -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> {

View File

@@ -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,
};
}
}

View File

@@ -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

View 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)

View File

@@ -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'
});
}
}
}

View File

@@ -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)
});
};

View File

@@ -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>;

View File

@@ -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));
}
}

View 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);
}
}

View File

@@ -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');
}
}

View File

@@ -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>;

View File

@@ -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;
}
}

View 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';

View File

@@ -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"]');

View File

@@ -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;

View 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

View 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',
});
}
}
}

View 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),
});
};

View File

@@ -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>;

View File

@@ -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;
}
}
}

View 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;
}
}
}

View File

@@ -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;
}

View 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';

View File

@@ -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();

View File

@@ -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);

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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.

View File

@@ -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>

View File

@@ -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 */}

View File

@@ -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 {

View File

@@ -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;
},
},
};

View File

@@ -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:

View 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'
);
},
});
};

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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';
}

View File

@@ -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) {

View 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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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 }}
/>

View File

@@ -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,
};

View File

@@ -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"
/>

View File

@@ -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] });
},
});

View File

@@ -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

View 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;
},
};

View File

@@ -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>
);
};

View File

@@ -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;
}

View 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';

View File

@@ -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;
}

View 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),
};

View 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');
},
});
};

View File

@@ -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>

View 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;
}

View File

@@ -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>
)}

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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 ""