From 719c80ecd84cbd4c0fdb053444c8f314cec41068 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 21 Dec 2025 19:56:52 -0600 Subject: [PATCH] Notification updates --- CLAUDE.md | 26 + backend/package-lock.json | 684 ++++++++++++++++- backend/package.json | 3 +- backend/src/_system/migrations/run-all.ts | 4 +- backend/src/app.ts | 8 +- backend/src/core/config/config-loader.ts | 5 + backend/src/core/plugins/auth.plugin.ts | 39 +- .../src/features/admin/api/admin.routes.ts | 56 ++ .../features/admin/api/users.controller.ts | 489 ++++++++++++ .../features/admin/api/users.validation.ts | 57 ++ .../features/admin/data/admin.repository.ts | 3 +- .../documents/api/documents.controller.ts | 188 ++--- .../documents/data/documents.repository.ts | 79 +- .../documents/domain/documents.service.ts | 13 +- .../documents/domain/documents.types.ts | 45 +- .../fuel-logs/data/fuel-logs.repository.ts | 14 +- .../maintenance/api/maintenance.controller.ts | 206 ++--- .../data/maintenance.repository.ts | 163 ++-- .../maintenance/domain/maintenance.service.ts | 105 +-- .../maintenance/domain/maintenance.types.ts | 71 +- backend/src/features/notifications/README.md | 121 +++ .../api/notifications.controller.ts | 197 +++++ .../notifications/api/notifications.routes.ts | 78 ++ .../api/notifications.validation.ts | 33 + .../data/notifications.repository.ts | 272 +++++++ .../notifications/domain/email.service.ts | 54 ++ .../domain/notifications.service.ts | 290 +++++++ .../domain/notifications.types.ts | 99 +++ .../notifications/domain/template.service.ts | 43 ++ backend/src/features/notifications/index.ts | 6 + .../001_create_notifications_tables.sql | 93 +++ .../002_add_email_notifications_columns.sql | 7 + backend/src/features/user-profile/README.md | 90 +++ .../api/user-profile.controller.ts | 124 +++ .../user-profile/api/user-profile.routes.ts | 24 + .../api/user-profile.validation.ts | 18 + .../data/user-profile.repository.ts | 391 ++++++++++ .../domain/user-profile.service.ts | 323 ++++++++ .../user-profile/domain/user-profile.types.ts | 66 ++ backend/src/features/user-profile/index.ts | 10 + .../migrations/001_create_user_profiles.sql | 29 + .../002_add_subscription_and_deactivation.sql | 32 + .../vehicles/domain/name-normalizer.ts | 2 +- docker-compose.yml | 1 + docs/CICD-DEPLOY.md | 2 + docs/PROMPTS.md | 34 +- frontend/src/App.tsx | 32 + frontend/src/components/Layout.tsx | 2 +- frontend/src/core/store/navigation.ts | 2 +- frontend/src/features/admin/api/admin.api.ts | 93 +++ .../features/admin/catalog/catalogSchemas.ts | 8 +- frontend/src/features/admin/hooks/useUsers.ts | 180 +++++ .../AdminEmailTemplatesMobileScreen.tsx | 440 +++++++++++ .../admin/mobile/AdminUsersMobileScreen.tsx | 706 +++++++++++++++++- .../src/features/admin/types/admin.types.ts | 89 ++- .../documents/components/DocumentForm.tsx | 8 +- .../documents/components/DocumentPreview.tsx | 6 +- .../features/documents/hooks/useDocuments.ts | 40 +- .../mobile/DocumentsMobileScreen.tsx | 4 +- .../documents/pages/DocumentDetailPage.tsx | 4 +- .../documents/pages/DocumentsPage.tsx | 4 +- .../documents/types/documents.types.ts | 43 +- .../MaintenanceRecordEditDialog.tsx | 14 +- .../components/MaintenanceRecordForm.tsx | 6 +- .../components/MaintenanceRecordsList.tsx | 10 +- .../hooks/useMaintenanceRecords.ts | 8 +- .../maintenance/types/maintenance.types.ts | 73 +- .../notifications/api/notifications.api.ts | 23 + .../components/EmailNotificationToggle.tsx | 50 ++ .../hooks/useLoginNotifications.ts | 48 ++ frontend/src/features/notifications/index.ts | 8 + .../types/notifications.types.ts | 36 + .../src/features/settings/api/profile.api.ts | 11 + .../src/features/settings/hooks/useProfile.ts | 69 ++ .../settings/mobile/MobileSettingsScreen.tsx | 192 ++++- .../features/settings/types/profile.types.ts | 18 + frontend/src/pages/SettingsPage.tsx | 274 +++++-- .../pages/admin/AdminEmailTemplatesPage.tsx | 395 ++++++++++ frontend/src/pages/admin/AdminUsersPage.tsx | 634 +++++++++++++++- scripts/inject-secrets.sh | 3 + 80 files changed, 7552 insertions(+), 678 deletions(-) create mode 100644 backend/src/features/admin/api/users.controller.ts create mode 100644 backend/src/features/admin/api/users.validation.ts create mode 100644 backend/src/features/notifications/README.md create mode 100644 backend/src/features/notifications/api/notifications.controller.ts create mode 100644 backend/src/features/notifications/api/notifications.routes.ts create mode 100644 backend/src/features/notifications/api/notifications.validation.ts create mode 100644 backend/src/features/notifications/data/notifications.repository.ts create mode 100644 backend/src/features/notifications/domain/email.service.ts create mode 100644 backend/src/features/notifications/domain/notifications.service.ts create mode 100644 backend/src/features/notifications/domain/notifications.types.ts create mode 100644 backend/src/features/notifications/domain/template.service.ts create mode 100644 backend/src/features/notifications/index.ts create mode 100644 backend/src/features/notifications/migrations/001_create_notifications_tables.sql create mode 100644 backend/src/features/notifications/migrations/002_add_email_notifications_columns.sql create mode 100644 backend/src/features/user-profile/README.md create mode 100644 backend/src/features/user-profile/api/user-profile.controller.ts create mode 100644 backend/src/features/user-profile/api/user-profile.routes.ts create mode 100644 backend/src/features/user-profile/api/user-profile.validation.ts create mode 100644 backend/src/features/user-profile/data/user-profile.repository.ts create mode 100644 backend/src/features/user-profile/domain/user-profile.service.ts create mode 100644 backend/src/features/user-profile/domain/user-profile.types.ts create mode 100644 backend/src/features/user-profile/index.ts create mode 100644 backend/src/features/user-profile/migrations/001_create_user_profiles.sql create mode 100644 backend/src/features/user-profile/migrations/002_add_subscription_and_deactivation.sql create mode 100644 frontend/src/features/admin/hooks/useUsers.ts create mode 100644 frontend/src/features/admin/mobile/AdminEmailTemplatesMobileScreen.tsx create mode 100644 frontend/src/features/notifications/api/notifications.api.ts create mode 100644 frontend/src/features/notifications/components/EmailNotificationToggle.tsx create mode 100644 frontend/src/features/notifications/hooks/useLoginNotifications.ts create mode 100644 frontend/src/features/notifications/index.ts create mode 100644 frontend/src/features/notifications/types/notifications.types.ts create mode 100644 frontend/src/features/settings/api/profile.api.ts create mode 100644 frontend/src/features/settings/hooks/useProfile.ts create mode 100644 frontend/src/features/settings/types/profile.types.ts create mode 100644 frontend/src/pages/admin/AdminEmailTemplatesPage.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 0a2b2a3..7dce2bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,32 @@ Maintain professional documentation standards without emoji usage. - **Delete** old code when replacing it - **Meaningful names**: `userID` not `id` +## Naming Conventions + +### Case Standards +| Layer | Convention | Example | +|-------|------------|---------| +| Database columns | snake_case | `user_id`, `created_at`, `is_active` | +| Backend TypeScript types | camelCase | `userId`, `createdAt`, `isActive` | +| API responses | camelCase | `{ "userId": "...", "createdAt": "..." }` | +| Frontend TypeScript types | camelCase | `userId`, `createdAt`, `isActive` | + +### Repository Pattern for Case Conversion +All repositories MUST implement private `mapRow()` or similar mapper functions to convert database snake_case to TypeScript camelCase: + +```typescript +private mapRow(row: any): MyType { + return { + id: row.id, + userId: row.user_id, // snake_case -> camelCase + createdAt: row.created_at, + isActive: row.is_active, + }; +} +``` + +All methods returning data to the API must use these mappers - never return raw database rows. + ## Docker-First Implementation Strategy ### 1. Package.json Updates Only diff --git a/backend/package-lock.json b/backend/package-lock.json index 307e948..87722f8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 665ac24..362c464 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/_system/migrations/run-all.ts b/backend/src/_system/migrations/run-all.ts index 943bfeb..e08e022 100644 --- a/backend/src/_system/migrations/run-all.ts +++ b/backend/src/_system/migrations/run-all.ts @@ -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'); diff --git a/backend/src/app.ts b/backend/src/app.ts index 64a0589..7f35c15 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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 { @@ -80,7 +82,7 @@ async function buildApp(): Promise { 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 { 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 { 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) => { diff --git a/backend/src/core/config/config-loader.ts b/backend/src/core/config/config-loader.ts index 71e0b50..3a456a2 100644 --- a/backend/src/core/config/config-loader.ts +++ b/backend/src/core/config/config-loader.ts @@ -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; @@ -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', diff --git a/backend/src/core/plugins/auth.plugin.ts b/backend/src/core/plugins/auth.plugin.ts index fa316a1..666e833 100644 --- a/backend/src/core/plugins/auth.plugin.ts +++ b/backend/src/core/plugins/auth.plugin.ts @@ -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) { diff --git a/backend/src/features/admin/api/admin.routes.ts b/backend/src/features/admin/api/admin.routes.ts index 1db8425..c3aa74d 100644 --- a/backend/src/features/admin/api/admin.routes.ts +++ b/backend/src/features/admin/api/admin.routes.ts @@ -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 diff --git a/backend/src/features/admin/api/users.controller.ts b/backend/src/features/admin/api/users.controller.ts new file mode 100644 index 0000000..e157844 --- /dev/null +++ b/backend/src/features/admin/api/users.controller.ts @@ -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', + }); + } + } +} diff --git a/backend/src/features/admin/api/users.validation.ts b/backend/src/features/admin/api/users.validation.ts new file mode 100644 index 0000000..5934fe2 --- /dev/null +++ b/backend/src/features/admin/api/users.validation.ts @@ -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; +export type UserAuth0SubInput = z.infer; +export type UpdateTierInput = z.infer; +export type DeactivateUserInput = z.infer; +export type UpdateProfileInput = z.infer; +export type PromoteToAdminInput = z.infer; diff --git a/backend/src/features/admin/data/admin.repository.ts b/backend/src/features/admin/data/admin.repository.ts index 8fc72a8..7e40df0 100644 --- a/backend/src/features/admin/data/admin.repository.ts +++ b/backend/src/features/admin/data/admin.repository.ts @@ -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), }; } diff --git a/backend/src/features/documents/api/documents.controller.ts b/backend/src/features/documents/api/documents.controller.ts index c6955e0..d861fce 100644 --- a/backend/src/features/documents/api/documents.controller.ts +++ b/backend/src/features/documents/api/documents.controller.ts @@ -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 = {}; 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); } } diff --git a/backend/src/features/documents/data/documents.repository.ts b/backend/src/features/documents/data/documents.repository.ts index 24e57ae..af8d1b4 100644 --- a/backend/src/features/documents/data/documents.repository.ts +++ b/backend/src/features/documents/data/documents.repository.ts @@ -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 { 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 { 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 { @@ -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 { 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>): Promise { + async updateMetadata(id: string, userId: string, patch: Partial>): Promise { 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 { 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; } } diff --git a/backend/src/features/documents/domain/documents.service.ts b/backend/src/features/documents/domain/documents.service.ts index 82d35e5..c47bf59 100644 --- a/backend/src/features/documents/domain/documents.service.ts +++ b/backend/src/features/documents/domain/documents.service.ts @@ -7,18 +7,19 @@ export class DocumentsService { private readonly repo = new DocumentsRepository(pool); async createDocument(userId: string, body: CreateDocumentBody): Promise { - 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, }); } diff --git a/backend/src/features/documents/domain/documents.types.ts b/backend/src/features/documents/domain/documents.types.ts index 8e6adb6..182a9ec 100644 --- a/backend/src/features/documents/domain/documents.types.ts +++ b/backend/src/features/documents/domain/documents.types.ts @@ -3,35 +3,39 @@ import { z } from 'zod'; export const DocumentTypeSchema = z.enum(['insurance', 'registration']); export type DocumentType = z.infer; +// 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 | 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; @@ -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; diff --git a/backend/src/features/fuel-logs/data/fuel-logs.repository.ts b/backend/src/features/fuel-logs/data/fuel-logs.repository.ts index 9c79c68..1e03cb5 100644 --- a/backend/src/features/fuel-logs/data/fuel-logs.repository.ts +++ b/backend/src/features/fuel-logs/data/fuel-logs.repository.ts @@ -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 { @@ -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 { @@ -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 { 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 { @@ -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 { @@ -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]); } } diff --git a/backend/src/features/maintenance/api/maintenance.controller.ts b/backend/src/features/maintenance/api/maintenance.controller.ts index d4a0917..815dd1d 100644 --- a/backend/src/features/maintenance/api/maintenance.controller.ts +++ b/backend/src/features/maintenance/api/maintenance.controller.ts @@ -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; diff --git a/backend/src/features/maintenance/data/maintenance.repository.ts b/backend/src/features/maintenance/data/maintenance.repository.ts index 42ed46a..97e6adb 100644 --- a/backend/src/features/maintenance/data/maintenance.repository.ts +++ b/backend/src/features/maintenance/data/maintenance.repository.ts @@ -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 { 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 { @@ -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 { @@ -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> + patch: Partial> ): Promise { 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 { @@ -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 { 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 { @@ -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 { @@ -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 { @@ -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> + patch: Partial> ): Promise { 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 { diff --git a/backend/src/features/maintenance/domain/maintenance.service.ts b/backend/src/features/maintenance/domain/maintenance.service.ts index 4dd43ef..dffac50 100644 --- a/backend/src/features/maintenance/domain/maintenance.service.ts +++ b/backend/src/features/maintenance/domain/maintenance.service.ts @@ -18,7 +18,7 @@ export class MaintenanceService { private readonly repo = new MaintenanceRepository(pool); async createRecord(userId: string, body: CreateMaintenanceRecordRequest): Promise { - 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>; + ) as Partial>; 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 { - 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>; + ) as Partial>; 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, }; } } diff --git a/backend/src/features/maintenance/domain/maintenance.types.ts b/backend/src/features/maintenance/domain/maintenance.types.ts index ea1830c..c6c494e 100644 --- a/backend/src/features/maintenance/domain/maintenance.types.ts +++ b/backend/src/features/maintenance/domain/maintenance.types.ts @@ -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; @@ -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; 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; 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; // 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 diff --git a/backend/src/features/notifications/README.md b/backend/src/features/notifications/README.md new file mode 100644 index 0000000..a99960c --- /dev/null +++ b/backend/src/features/notifications/README.md @@ -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) diff --git a/backend/src/features/notifications/api/notifications.controller.ts b/backend/src/features/notifications/api/notifications.controller.ts new file mode 100644 index 0000000..8e47353 --- /dev/null +++ b/backend/src/features/notifications/api/notifications.controller.ts @@ -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' + }); + } + } +} diff --git a/backend/src/features/notifications/api/notifications.routes.ts b/backend/src/features/notifications/api/notifications.routes.ts new file mode 100644 index 0000000..e211009 --- /dev/null +++ b/backend/src/features/notifications/api/notifications.routes.ts @@ -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) + }); +}; diff --git a/backend/src/features/notifications/api/notifications.validation.ts b/backend/src/features/notifications/api/notifications.validation.ts new file mode 100644 index 0000000..cbc1c0c --- /dev/null +++ b/backend/src/features/notifications/api/notifications.validation.ts @@ -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; + +// 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; + +// 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; diff --git a/backend/src/features/notifications/data/notifications.repository.ts b/backend/src/features/notifications/data/notifications.repository.ts new file mode 100644 index 0000000..0a9fc34 --- /dev/null +++ b/backend/src/features/notifications/data/notifications.repository.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + 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)); + } +} diff --git a/backend/src/features/notifications/domain/email.service.ts b/backend/src/features/notifications/domain/email.service.ts new file mode 100644 index 0000000..2610bdf --- /dev/null +++ b/backend/src/features/notifications/domain/email.service.ts @@ -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 { + 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 { + // Convert plain text to HTML with proper line breaks + const html = text.split('\n').map(line => `

${line}

`).join(''); + await this.send(to, subject, html); + } +} diff --git a/backend/src/features/notifications/domain/notifications.service.ts b/backend/src/features/notifications/domain/notifications.service.ts new file mode 100644 index 0000000..6e68b55 --- /dev/null +++ b/backend/src/features/notifications/domain/notifications.service.ts @@ -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 { + return this.repository.getNotificationSummary(userId); + } + + async getDueMaintenanceItems(userId: string): Promise { + return this.repository.getDueMaintenanceItems(userId); + } + + async getExpiringDocuments(userId: string): Promise { + return this.repository.getExpiringDocuments(userId); + } + + // ======================== + // Email Templates + // ======================== + + async listTemplates(): Promise { + return this.repository.getEmailTemplates(); + } + + async getTemplate(key: TemplateKey): Promise { + return this.repository.getEmailTemplateByKey(key); + } + + async updateTemplate( + key: TemplateKey, + updates: { subject?: string; body?: string; isActive?: boolean } + ): Promise { + return this.repository.updateEmailTemplate(key, updates); + } + + async previewTemplate( + subject: string, + body: string, + variables: Record + ): 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 { + 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 { + 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 { + 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 { + // 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'); + } +} diff --git a/backend/src/features/notifications/domain/notifications.types.ts b/backend/src/features/notifications/domain/notifications.types.ts new file mode 100644 index 0000000..b7bdc46 --- /dev/null +++ b/backend/src/features/notifications/domain/notifications.types.ts @@ -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; + +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; diff --git a/backend/src/features/notifications/domain/template.service.ts b/backend/src/features/notifications/domain/template.service.ts new file mode 100644 index 0000000..68dd16f --- /dev/null +++ b/backend/src/features/notifications/domain/template.service.ts @@ -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 { + 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; + } +} diff --git a/backend/src/features/notifications/index.ts b/backend/src/features/notifications/index.ts new file mode 100644 index 0000000..f1e12a4 --- /dev/null +++ b/backend/src/features/notifications/index.ts @@ -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'; diff --git a/backend/src/features/notifications/migrations/001_create_notifications_tables.sql b/backend/src/features/notifications/migrations/001_create_notifications_tables.sql new file mode 100644 index 0000000..1f6ded9 --- /dev/null +++ b/backend/src/features/notifications/migrations/001_create_notifications_tables.sql @@ -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"]'); diff --git a/backend/src/features/notifications/migrations/002_add_email_notifications_columns.sql b/backend/src/features/notifications/migrations/002_add_email_notifications_columns.sql new file mode 100644 index 0000000..6cbd985 --- /dev/null +++ b/backend/src/features/notifications/migrations/002_add_email_notifications_columns.sql @@ -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; diff --git a/backend/src/features/user-profile/README.md b/backend/src/features/user-profile/README.md new file mode 100644 index 0000000..31055c0 --- /dev/null +++ b/backend/src/features/user-profile/README.md @@ -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 diff --git a/backend/src/features/user-profile/api/user-profile.controller.ts b/backend/src/features/user-profile/api/user-profile.controller.ts new file mode 100644 index 0000000..0881498 --- /dev/null +++ b/backend/src/features/user-profile/api/user-profile.controller.ts @@ -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', + }); + } + } +} diff --git a/backend/src/features/user-profile/api/user-profile.routes.ts b/backend/src/features/user-profile/api/user-profile.routes.ts new file mode 100644 index 0000000..e091a27 --- /dev/null +++ b/backend/src/features/user-profile/api/user-profile.routes.ts @@ -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), + }); +}; diff --git a/backend/src/features/user-profile/api/user-profile.validation.ts b/backend/src/features/user-profile/api/user-profile.validation.ts new file mode 100644 index 0000000..4fb06d8 --- /dev/null +++ b/backend/src/features/user-profile/api/user-profile.validation.ts @@ -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; diff --git a/backend/src/features/user-profile/data/user-profile.repository.ts b/backend/src/features/user-profile/data/user-profile.repository.ts new file mode 100644 index 0000000..a1a88be --- /dev/null +++ b/backend/src/features/user-profile/data/user-profile.repository.ts @@ -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 { + 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 { + 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 { + 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 { + // 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 { + 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 = { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } + } +} diff --git a/backend/src/features/user-profile/domain/user-profile.service.ts b/backend/src/features/user-profile/domain/user-profile.service.ts new file mode 100644 index 0000000..f261b10 --- /dev/null +++ b/backend/src/features/user-profile/domain/user-profile.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } + } +} diff --git a/backend/src/features/user-profile/domain/user-profile.types.ts b/backend/src/features/user-profile/domain/user-profile.types.ts new file mode 100644 index 0000000..1ea146e --- /dev/null +++ b/backend/src/features/user-profile/domain/user-profile.types.ts @@ -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; +} diff --git a/backend/src/features/user-profile/index.ts b/backend/src/features/user-profile/index.ts new file mode 100644 index 0000000..6d6d356 --- /dev/null +++ b/backend/src/features/user-profile/index.ts @@ -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'; diff --git a/backend/src/features/user-profile/migrations/001_create_user_profiles.sql b/backend/src/features/user-profile/migrations/001_create_user_profiles.sql new file mode 100644 index 0000000..dfb2efb --- /dev/null +++ b/backend/src/features/user-profile/migrations/001_create_user_profiles.sql @@ -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(); diff --git a/backend/src/features/user-profile/migrations/002_add_subscription_and_deactivation.sql b/backend/src/features/user-profile/migrations/002_add_subscription_and_deactivation.sql new file mode 100644 index 0000000..5e53427 --- /dev/null +++ b/backend/src/features/user-profile/migrations/002_add_subscription_and_deactivation.sql @@ -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); diff --git a/backend/src/features/vehicles/domain/name-normalizer.ts b/backend/src/features/vehicles/domain/name-normalizer.ts index 78491ef..d1c6d97 100644 --- a/backend/src/features/vehicles/domain/name-normalizer.ts +++ b/backend/src/features/vehicles/domain/name-normalizer.ts @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index a707628..efad84d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/CICD-DEPLOY.md b/docs/CICD-DEPLOY.md index b186360..35aee4a 100644 --- a/docs/CICD-DEPLOY.md +++ b/docs/CICD-DEPLOY.md @@ -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 diff --git a/docs/PROMPTS.md b/docs/PROMPTS.md index 7c1ea91..533d9b0 100644 --- a/docs/PROMPTS.md +++ b/docs/PROMPTS.md @@ -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. \ No newline at end of file +- 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. \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9a7ca9c..f493d3e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { )} + {activeScreen === "AdminEmailTemplates" && ( + + + + +
+
+ Loading email templates... +
+
+
+ + }> + +
+
+
+ )} @@ -795,6 +826,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index cba5356..f5fbcc3 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -67,7 +67,7 @@ export const Layout: React.FC = ({ children, mobileMode = false })
MotoVaultPro
-
v0.1
+
v1.0
{/* Content area */} diff --git a/frontend/src/core/store/navigation.ts b/frontend/src/core/store/navigation.ts index a879055..d417741 100644 --- a/frontend/src/core/store/navigation.ts +++ b/frontend/src/core/store/navigation.ts @@ -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 { diff --git a/frontend/src/features/admin/api/admin.api.ts b/frontend/src/features/admin/api/admin.api.ts index b6131bf..a28aa3c 100644 --- a/frontend/src/features/admin/api/admin.api.ts +++ b/frontend/src/features/admin/api/admin.api.ts @@ -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 => { + const response = await apiClient.get('/admin/email-templates'); + return response.data; + }, + get: async (key: string): Promise => { + const response = await apiClient.get(`/admin/email-templates/${key}`); + return response.data; + }, + update: async (key: string, data: UpdateEmailTemplateRequest): Promise => { + const response = await apiClient.put(`/admin/email-templates/${key}`, data); + return response.data; + }, + preview: async (key: string, variables: Record): 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 => { + const response = await apiClient.get('/admin/users', { params }); + return response.data; + }, + + get: async (auth0Sub: string): Promise => { + const response = await apiClient.get( + `/admin/users/${encodeURIComponent(auth0Sub)}` + ); + return response.data; + }, + + updateTier: async (auth0Sub: string, data: UpdateUserTierRequest): Promise => { + const response = await apiClient.patch( + `/admin/users/${encodeURIComponent(auth0Sub)}/tier`, + data + ); + return response.data; + }, + + deactivate: async (auth0Sub: string, data?: DeactivateUserRequest): Promise => { + const response = await apiClient.patch( + `/admin/users/${encodeURIComponent(auth0Sub)}/deactivate`, + data || {} + ); + return response.data; + }, + + reactivate: async (auth0Sub: string): Promise => { + const response = await apiClient.patch( + `/admin/users/${encodeURIComponent(auth0Sub)}/reactivate` + ); + return response.data; + }, + + updateProfile: async (auth0Sub: string, data: UpdateUserProfileRequest): Promise => { + const response = await apiClient.patch( + `/admin/users/${encodeURIComponent(auth0Sub)}/profile`, + data + ); + return response.data; + }, + + promoteToAdmin: async (auth0Sub: string, data?: PromoteToAdminRequest): Promise => { + const response = await apiClient.patch( + `/admin/users/${encodeURIComponent(auth0Sub)}/promote`, + data || {} + ); + return response.data; + }, + }, }; diff --git a/frontend/src/features/admin/catalog/catalogSchemas.ts b/frontend/src/features/admin/catalog/catalogSchemas.ts index 2d5c93d..dc3429d 100644 --- a/frontend/src/features/admin/catalog/catalogSchemas.ts +++ b/frontend/src/features/admin/catalog/catalogSchemas.ts @@ -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: diff --git a/frontend/src/features/admin/hooks/useUsers.ts b/frontend/src/features/admin/hooks/useUsers.ts new file mode 100644 index 0000000..7ba681e --- /dev/null +++ b/frontend/src/features/admin/hooks/useUsers.ts @@ -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' + ); + }, + }); +}; diff --git a/frontend/src/features/admin/mobile/AdminEmailTemplatesMobileScreen.tsx b/frontend/src/features/admin/mobile/AdminEmailTemplatesMobileScreen.tsx new file mode 100644 index 0000000..5448266 --- /dev/null +++ b/frontend/src/features/admin/mobile/AdminEmailTemplatesMobileScreen.tsx @@ -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 = { + 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(null); + const [previewTemplate, setPreviewTemplate] = useState(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 }) => + 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 ( + +
+
+
Loading admin access...
+
+
+
+ + ); + } + + // Not admin + if (!isAdmin) { + return ; + } + + // Edit view + if (editingTemplate) { + return ( + +
+ {/* Header */} +
+
+

Edit Template

+

{editingTemplate.name}

+
+ +
+ + {/* Edit Form */} + +
+ {/* Active Toggle */} +
+ Active + +
+ + {/* Subject */} +
+ + 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" + /> +

+ Use variables like {editingTemplate.variables.map((v) => `{{${v}}}`).join(', ')} +

+
+ + {/* Body */} +
+ +