Compare commits

..

123 Commits

Author SHA1 Message Date
Eric Gullickson
3b5b84729f fix: increase VIN decode timeout to 60s for Gemini cold start (refs #229)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m31s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Default 10s API client timeout caused frontend "Failed to decode" errors
when Gemini engine cold-starts (34s+ on first call).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 21:30:31 -06:00
Eric Gullickson
781241966c chore: change google region
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 38s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
2026-02-19 20:59:40 -06:00
Eric Gullickson
bf6742f6ea chore: Gemini 3.0 Flash Preview model
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 33s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
2026-02-19 20:36:34 -06:00
Eric Gullickson
5bb44be8bc chore: Change to Gemini 3.0 Flash
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 36s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 21s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
2026-02-19 20:35:06 -06:00
Eric Gullickson
361f58d7c6 fix: resolve VIN decode cache race, fuzzy matching, and silent failure (refs #229)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 6m31s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Prevent lower-confidence Gemini results from overwriting higher-confidence
cache entries, add reverse-contains matching so values like "X5 xDrive35i"
match DB option "X5", and show amber hint when dropdown matching fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 20:14:54 -06:00
Eric Gullickson
d96736789e feat: update frontend for Gemini VIN decode (refs #228)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 6m31s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 23s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Rename nhtsaValue to sourceValue in frontend MatchedField type and
VinOcrReviewModal component. Update NHTSA references to vehicle
database across guide pages, hooks, and API documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:51:45 -06:00
Eric Gullickson
f590421058 chore: remove NHTSA code and update documentation (refs #227)
Delete vehicles/external/nhtsa/ directory (3 files), remove VPICVariable
and VPICResponse from platform models. Update all documentation to
reflect Gemini VIN decode via OCR service architecture.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:51:38 -06:00
Eric Gullickson
5cbf9c764d feat: rewire vehicles controller to OCR VIN decode (refs #226)
Replace NHTSAClient with OcrClient in vehicles controller. Move cache
logic into VehiclesService with format-aware reads (Gemini vs legacy
NHTSA entries). Rename nhtsaValue to sourceValue in MatchedField.
Remove vpic config from Zod schema and YAML config files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:47:47 -06:00
Eric Gullickson
3cd61256ba feat: add backend OCR client method for VIN decode (refs #225)
Add VinDecodeResponse type and OcrClient.decodeVin() method that sends
JSON POST to the new /decode/vin OCR endpoint. Unlike other OCR methods,
this uses JSON body instead of multipart since there is no file upload.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:40:47 -06:00
Eric Gullickson
a75f7b5583 feat: add VIN decode endpoint to OCR Python service (refs #224)
Add POST /decode/vin endpoint using Gemini 2.5 Flash for VIN string
decoding. Returns structured vehicle data (year, make, model, trim,
body/drive/fuel type, engine, transmission) with confidence score.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:40:10 -06:00
00aa2a5411 Merge pull request 'chore: Update email FROM address and fix unsubscribe link' (#222) from issue-221-update-email-from-and-unsubscribe into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 35s
Deploy to Staging / Deploy to Staging (push) Successful in 51s
Deploy to Staging / Verify Staging (push) Successful in 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #222
2026-02-19 02:54:27 +00:00
Eric Gullickson
1dac6d342b fix: evaluate copyright year in email footer template (refs #221)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m40s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Add missing $ prefix to template literal expression so the year
renders as "2026" instead of literal "{new Date().getFullYear()}".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 20:43:00 -06:00
Eric Gullickson
3b62f5a621 fix: Email Logo URL
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m38s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 23s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
2026-02-18 20:32:28 -06:00
Eric Gullickson
4f4fb8a886 chore: update email FROM address and fix unsubscribe link (refs #221)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m36s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Change default FROM to hello@notify.motovaultpro.com across app and CI
senders. Replace broken {{unsubscribeUrl}} placeholder with real Settings
page URL. Add RFC 8058 List-Unsubscribe headers for email client support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 20:19:19 -06:00
Eric Gullickson
d57c5d6cf8 chore: Update from email addresses
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m44s
Deploy to Staging / Deploy to Staging (push) Successful in 53s
Deploy to Staging / Verify Staging (push) Successful in 10s
Deploy to Staging / Notify Staging Ready (push) Successful in 9s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
2026-02-16 21:07:56 -06:00
Eric Gullickson
8a73352ddc fix: charge immediately on subscription and read item-level period dates
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m39s
Deploy to Staging / Deploy to Staging (push) Successful in 53s
Deploy to Staging / Verify Staging (push) Successful in 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Three fixes to the Stripe subscription flow:

1. Change payment_behavior from 'default_incomplete' to
   'error_if_incomplete' so Stripe charges the card immediately instead
   of leaving the subscription in incomplete status waiting for frontend
   payment confirmation that never happens.

2. Read currentPeriodStart/End from subscription items instead of the
   top-level subscription object. Stripe moved these fields to
   items.data[0] in API version 2025-03-31.basil, causing epoch-zero
   dates (Dec 31, 1969).

3. Map Stripe 'incomplete' status to 'active' in mapStripeStatus() so
   it doesn't fall through to the default 'canceled' mapping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 20:40:58 -06:00
Eric Gullickson
72e557346c fix: attach payment method to customer before creating subscription
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m39s
Deploy to Staging / Deploy to Staging (push) Successful in 23s
Deploy to Staging / Verify Staging (push) Successful in 8s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Stripe requires payment methods to be attached to a customer before they
can be set as default_payment_method on a subscription. The
createSubscription() method was skipping this step, causing 500 errors
on checkout with: "The customer does not have a payment method with the
ID pm_xxx".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 20:21:31 -06:00
Eric Gullickson
853a075e8b chore: centralize docker-compose variables into .env
All checks were successful
Deploy to Staging / Build Images (push) Successful in 39s
Deploy to Staging / Deploy to Staging (push) Successful in 52s
Deploy to Staging / Verify Staging (push) Successful in 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Stripe Price IDs were hardcoded and duplicated across 4 compose files.
Log levels were hardcoded per-overlay instead of using generate-log-config.sh.
This refactors all environment-specific variables into a single .env file
that CI/CD generates from Gitea repo variables + generate-log-config.sh.

- Add .env.example template with documented variables
- Replace hardcoded values with ${VAR:-default} substitution in base compose
- Simplify prod overlay from 90 to 32 lines (remove redundant env blocks)
- Add YAML anchors to blue-green overlay (eliminate blue/green duplication)
- Remove redundant OCR env block from staging overlay
- Change generate-log-config.sh to output to stdout (pipe into .env)
- Update staging/production CI/CD to generate .env with Stripe + log vars
- Remove dangerous pk_live_ default from VITE_STRIPE_PUBLISHABLE_KEY

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 19:57:36 -06:00
Eric Gullickson
07c3d8511d fix: Stripe ID's take 3
All checks were successful
Deploy to Staging / Build Images (push) Successful in 38s
Deploy to Staging / Deploy to Staging (push) Successful in 53s
Deploy to Staging / Verify Staging (push) Successful in 10s
Deploy to Staging / Notify Staging Ready (push) Successful in 9s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
2026-02-16 16:38:17 -06:00
Eric Gullickson
15956a8711 fix: Stripe ID's take 2
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m28s
Deploy to Staging / Deploy to Staging (push) Successful in 53s
Deploy to Staging / Verify Staging (push) Successful in 10s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
2026-02-16 15:29:00 -06:00
Eric Gullickson
714ed92438 Merge branch 'main' of 172.30.1.72:egullickson/motovaultpro
Some checks failed
Deploy to Staging / Build Images (push) Has been cancelled
Deploy to Staging / Deploy to Staging (push) Has been cancelled
Deploy to Staging / Verify Staging (push) Has been cancelled
Deploy to Staging / Notify Staging Ready (push) Has been cancelled
Deploy to Staging / Notify Staging Failure (push) Has been cancelled
2026-02-16 15:28:08 -06:00
Eric Gullickson
bc0be75957 fix: Update Stripe ID's 2026-02-16 15:28:05 -06:00
7712ec6661 Merge pull request 'chore: migrate user identity from auth0_sub to UUID' (#219) from issue-206-migrate-user-identity-uuid into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 6m32s
Deploy to Staging / Deploy to Staging (push) Successful in 23s
Deploy to Staging / Verify Staging (push) Successful in 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #219
2026-02-16 20:55:39 +00:00
Eric Gullickson
e9093138fa fix: replace remaining auth0_sub references with UUID identity (refs #220)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m40s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Vehicles service and subscriptions code still queried user_profiles by
auth0_sub after the UUID migration, causing 500 errors on GET /api/vehicles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 11:50:26 -06:00
Eric Gullickson
dd3b58e061 fix: migrate remaining controllers from Auth0 sub to UUID identity (refs #220)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m40s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 24s
Deploy to Staging / Verify Staging (pull_request) Successful in 10s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
16 controllers still used request.user.sub (Auth0 ID) instead of
request.userContext.userId (UUID) after the user_id column migration,
causing 500 errors on all authenticated endpoints including dashboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 11:38:46 -06:00
Eric Gullickson
28165e4f4a fix: deduplicate user_preferences before unique constraint (refs #206)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 37s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Users with both auth0_sub and UUID rows in user_preferences get the same
user_profile_id after backfill, causing unique constraint violation on
rename. Keep the newest row per user_profile_id.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 11:03:35 -06:00
Eric Gullickson
7fc80ab49f fix: handle mixed user_id formats in UUID migration backfill (refs #206)
Some checks failed
Deploy to Staging / Build Images (pull_request) Successful in 3m36s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 53s
Deploy to Staging / Verify Staging (pull_request) Failing after 8s
Deploy to Staging / Notify Staging Ready (pull_request) Has been skipped
Deploy to Staging / Notify Staging Failure (pull_request) Successful in 8s
user_preferences had rows where user_id already contained user_profiles.id
(UUID) instead of auth0_sub. Added second backfill pass matching UUID-format
values directly, and cleanup for 2 orphaned rows with no matching profile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:56:01 -06:00
Eric Gullickson
754639c86d chore: update test fixtures and frontend for UUID identity (refs #217)
Some checks failed
Deploy to Staging / Build Images (pull_request) Successful in 6m41s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Failing after 4m7s
Deploy to Staging / Notify Staging Ready (pull_request) Has been skipped
Deploy to Staging / Notify Staging Failure (pull_request) Successful in 9s
Backend test fixtures:
- Replace auth0|xxx format with UUID in all test userId values
- Update admin tests for new id/userProfileId schema
- Add missing deletionRequestedAt/deletionScheduledFor to auth test mocks
- Fix admin integration test supertest usage (app.server)

Frontend:
- AdminUser type: auth0Sub -> id + userProfileId
- admin.api.ts: all user management methods use userId (UUID) params
- useUsers/useAdmins hooks: auth0Sub -> userId/id in mutations
- AdminUsersPage + AdminUsersMobileScreen: user.auth0Sub -> user.id
- Remove encodeURIComponent (UUIDs don't need encoding)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:21:18 -06:00
Eric Gullickson
3b1112a9fe chore: update supporting code for UUID identity (refs #216)
- audit-log: JOIN on user_profiles.id instead of auth0_sub
- backup: use userContext.userId instead of auth0Sub
- ocr: use request.userContext.userId instead of request.user.sub
- user-profile controller: use getById() with UUID instead of getOrCreateProfile()
- user-profile service: accept UUID userId for all admin-focused methods
- user-profile repository: fix admin JOIN aliases from auth0_sub to id

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:59:05 -06:00
Eric Gullickson
fd9d1add24 chore: refactor admin system for UUID identity (refs #213)
Migrate admin controller, routes, validation, and users controller
from auth0Sub identifiers to UUID. Admin CRUD now uses admin UUID id,
user management routes use user_profiles UUID. Clean up debug logging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:52:09 -06:00
5f0da87110 Merge pull request 'refactor: Clean up subscription admin override and Stripe integration (#205)' (#218) from issue-205-clean-subscription-admin-override into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 38s
Deploy to Staging / Deploy to Staging (push) Successful in 24s
Deploy to Staging / Verify Staging (push) Successful in 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #218
2026-02-16 15:44:10 +00:00
Eric Gullickson
b418a503b2 chore: refactor user profile repository for UUID (refs #214)
Updated user-profile.repository.ts to use UUID instead of auth0_sub:
- Added getById(id) method for UUID-based lookups
- Changed all methods (except getByAuth0Sub, getOrCreate) to accept userId (UUID) instead of auth0Sub
- Updated SQL WHERE clauses from auth0_sub to id for UUID-based queries
- Fixed cross-table joins in listAllUsers and getUserWithAdminStatus to use user_profile_id
- Updated hardDeleteUser to use UUID for all DELETE statements
- Updated auth.plugin.ts to call updateEmail and updateEmailVerified with userId (UUID)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:39:56 -06:00
Eric Gullickson
1321440cd0 chore: update auth plugin and admin guard for UUID (refs #212)
Auth plugin now uses profile.id (UUID) as userContext.userId instead
of raw JWT sub. Admin guard queries admin_users by user_profile_id.
Auth0 Management API calls continue using auth0Sub from JWT.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:36:32 -06:00
Eric Gullickson
6011888e91 chore: add UUID identity migration SQL (refs #211)
Multi-phase SQL migration converting all user_id columns from
VARCHAR(255) auth0_sub to UUID referencing user_profiles.id.
Restructures admin_users with UUID PK and user_profile_id FK.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:33:41 -06:00
Eric Gullickson
93e79d1170 refactor: replace resolveStripeCustomerId with ensureStripeCustomer, harden sync (refs #209, refs #210)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 6m33s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Delete resolveStripeCustomerId() and replace with ensureStripeCustomer()
that includes orphaned Stripe customer cleanup on DB failure. Make
syncTierToUserProfile() blocking (errors propagate). Add null guards to
cancel/reactivate for admin-set subscriptions. Fix getInvoices() null
check. Clean controller comment. Add deleteCustomer() to StripeClient.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:29:02 -06:00
Eric Gullickson
a6eea6c9e2 refactor: update repository for nullable stripe_customer_id (refs #208)
Remove admin_override_ placeholder from createForAdminOverride(), use NULL.
Update mapSubscriptionRow() with ?? null. Make stripeCustomerId optional
in create() method.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:28:52 -06:00
Eric Gullickson
af11b49e26 refactor: add migration and nullable types for stripe_customer_id (refs #207)
Make stripe_customer_id NULLABLE via migration, clean up admin_override_*
values to NULL, and update Subscription/SubscriptionResponse/UpdateSubscriptionData
types in both backend and frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:28:46 -06:00
Eric Gullickson
ddae397cb3 fix: Stripe IDs and admin overrides
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m38s
Deploy to Staging / Deploy to Staging (push) Successful in 53s
Deploy to Staging / Verify Staging (push) Successful in 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
2026-02-15 21:26:38 -06:00
Eric Gullickson
c1e8807bda fix: API errors for Stripe
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m47s
Deploy to Staging / Deploy to Staging (push) Successful in 49s
Deploy to Staging / Verify Staging (push) Successful in 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 9s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
2026-02-15 21:12:15 -06:00
Eric Gullickson
bb4d2b9699 chore: Stripe sandbox setup.
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m34s
Deploy to Staging / Deploy to Staging (push) Successful in 52s
Deploy to Staging / Verify Staging (push) Successful in 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
2026-02-15 21:00:09 -06:00
Eric Gullickson
669b51a6e1 fix: Navigation bug
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m41s
Deploy to Staging / Deploy to Staging (push) Successful in 53s
Deploy to Staging / Verify Staging (push) Successful in 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 9s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
2026-02-15 20:06:10 -06:00
Eric Gullickson
856a305c9d fix: Update log fuel buttons
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m34s
Deploy to Staging / Deploy to Staging (push) Successful in 52s
Deploy to Staging / Verify Staging (push) Successful in 8s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
2026-02-15 19:53:36 -06:00
9177a38414 Merge pull request 'feat: Add online user guide with screenshots (#203)' (#204) from issue-203-add-online-user-guide into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 37s
Deploy to Staging / Deploy to Staging (push) Successful in 52s
Deploy to Staging / Verify Staging (push) Successful in 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 9s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #204
2026-02-16 01:40:34 +00:00
Eric Gullickson
260641e68c fix: links from homepage to guide not working
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m36s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 53s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
2026-02-15 19:32:46 -06:00
Eric Gullickson
1a9081c534 feat: Links on homepage
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m29s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
2026-02-15 19:24:03 -06:00
Eric Gullickson
bb48c55c2e feat: Removed trouble logging in button
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m28s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 53s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 10s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
2026-02-15 18:38:43 -06:00
Eric Gullickson
4927b6670d fix: remove $uri/ from nginx try_files to prevent /guide directory redirect (refs #203)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m31s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
The /guide SPA route conflicts with the static /guide/ screenshot directory.
Nginx's try_files $uri/ matches the directory and issues a 301 redirect to
/guide/ with trailing slash, bypassing SPA routing. Removing $uri/ ensures
all non-file paths fall through to index.html for client-side routing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:59:03 -06:00
Eric Gullickson
b73bfaf590 fix: handle trailing slash on /guide/ route (refs #203)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m29s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 53s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:51:47 -06:00
Eric Gullickson
a7f12ad580 feat: Add desktop screenshots
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m31s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 53s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
2026-02-15 17:44:09 -06:00
Eric Gullickson
b047199bc5 docs: add GuidePage documentation (refs #203)
- Create CLAUDE.md for GuidePage directory with architecture docs
- Create CLAUDE.md index for pages/ directory

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:19:45 -06:00
Eric Gullickson
197aeda2ef feat: add guide navigation integration and tests (refs #203)
- Add Guide link to public nav bar (desktop + mobile) in HomePage
- Add Guide link to authenticated sidebar in Layout.tsx
- Add Guide link to HamburgerDrawer with window.location guard
- Add GuidePage integration tests (6 test scenarios)
- Remove old PDF user guide at public/docs/v2026-01-03.pdf

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:19:40 -06:00
Eric Gullickson
6196ebfc91 feat: add guide content sections 1-10 with screenshot placeholders (refs #203)
All 10 guide sections converted from USER-GUIDE.md to styled React
components using GuideTable and GuideScreenshot shared components.
Sections 1-5: Getting Started, Dashboard, Vehicles, Fuel Logs, Maintenance.
Sections 6-10: Gas Stations, Documents, Settings, Subscription Tiers, Mobile Experience.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 16:55:30 -06:00
Eric Gullickson
864da55cec feat: add guide page foundation and routing (refs #203)
- Create GuidePage with responsive layout (sticky TOC sidebar desktop, collapsible accordion mobile)
- Add GuideTableOfContents with scroll-based active section tracking
- Create GuideScreenshot and GuideTable shared components
- Add guideTypes.ts with section metadata for all 10 sections
- Add lazy-loaded /guide route in App.tsx with public access
- Placeholder section components for all 10 guide sections

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 16:45:17 -06:00
Eric Gullickson
d8ab00970d Merge branch 'main' of 172.30.1.72:egullickson/motovaultpro
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m24s
Deploy to Staging / Deploy to Staging (push) Successful in 22s
Deploy to Staging / Verify Staging (push) Successful in 8s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
2026-02-15 11:14:29 -06:00
Eric Gullickson
b2c9341342 fix: tests 2026-02-15 11:14:25 -06:00
54de28e0e8 Merge pull request 'feat: Redesign dashboard with vehicle-centric layout (#196)' (#202) from issue-196-redesign-dashboard-vehicle-centric into main
Some checks failed
Deploy to Staging / Build Images (push) Successful in 34s
Deploy to Staging / Verify Staging (push) Has been cancelled
Deploy to Staging / Notify Staging Ready (push) Has been cancelled
Deploy to Staging / Notify Staging Failure (push) Has been cancelled
Deploy to Staging / Deploy to Staging (push) Has been cancelled
Reviewed-on: #202
2026-02-15 17:13:29 +00:00
Eric Gullickson
f6684e72c0 test: add dashboard redesign tests (refs #201)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m22s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 11:03:52 -06:00
Eric Gullickson
654a7f0fc3 feat: rewire DashboardScreen with vehicle roster layout (refs #200)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:53:35 -06:00
Eric Gullickson
767df9e9f2 feat: add dashboard ActionBar component (refs #199)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:50:29 -06:00
Eric Gullickson
505ab8262c feat: add VehicleRosterCard component (refs #198)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:50:24 -06:00
Eric Gullickson
b57b835eb3 feat: add vehicle health types and roster data hook (refs #197)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:48:37 -06:00
963c17014c Merge pull request 'fix: Wire up Add Maintenance button on vehicle detail page (#194)' (#195) from issue-194-fix-add-maintenance-button into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 36s
Deploy to Staging / Deploy to Staging (push) Successful in 22s
Deploy to Staging / Verify Staging (push) Successful in 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #195
2026-02-15 16:09:52 +00:00
Eric Gullickson
7140c7e8d4 fix: wire up Add Maintenance button on vehicle detail page (refs #194)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m24s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Rename "Schedule Maintenance" to "Add Maintenance", match contained
button style to "Add Fuel Log", and open inline MaintenanceRecordForm
dialog on click. Applied to both desktop and mobile views.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:01:33 -06:00
8d6434f166 Merge pull request 'fix: Mobile login redirects to homepage without showing Auth0 login page (#188)' (#193) from issue-188-fix-mobile-login-redirect into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 36s
Deploy to Staging / Deploy to Staging (push) Successful in 51s
Deploy to Staging / Verify Staging (push) Successful in 8s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #193
2026-02-15 15:36:37 +00:00
Eric Gullickson
850f713310 fix: prevent URL sync effects from stripping Auth0 callback params (refs #188)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m21s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Root cause: React fires child effects before parent effects. App's URL
sync effect called history.replaceState() on /callback, stripping the
?code= and &state= query params before Auth0Provider's useEffect could
read them via hasAuthParams(). The SDK fell through to checkSession()
instead of handleRedirectCallback(), silently failing with no error.

Guard both URL sync effects to skip on /callback, /signup, /verify-email.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 09:24:56 -06:00
Eric Gullickson
b5b82db532 fix: resolve auth callback failure from IndexedDB cache issues (refs #188)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m23s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Add allKeys() to IndexedDBStorage to eliminate Auth0 CacheKeyManifest
fallback, revert set()/remove() to non-blocking persist, add auth error
display on callback route, remove leaky force-auth-check interceptor,
and migrate debug console calls to centralized logger.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 09:06:40 -06:00
Eric Gullickson
da59168d7b fix: IndexedDB cache broken on page reload - root cause of mobile login failure (refs #190)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m25s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
loadCacheFromDB used store.getAll() which returns raw values, not
key-value pairs. The item.key check always failed, so memoryCache
was empty after every page reload. Auth0 SDK state stored before
redirect was lost on mobile Safari (no bfcache).

Also fixed set()/remove() to await IDB persistence so Auth0 state
is fully written before loginWithRedirect() navigates away.

Added 10s timeout on callback loading state as safety net.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 22:20:34 -06:00
Eric Gullickson
38debaad5d fix: skip stale token validation during callback code exchange (refs #190)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m28s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 22s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 22:09:09 -06:00
Eric Gullickson
db127eb24c fix: address QR review findings for token validation and clearAll reliability (refs #190)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m32s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 22s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:59:31 -06:00
Eric Gullickson
15128bfd50 fix: add missing hook dependencies for stale token effect (refs #190)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:57:28 -06:00
Eric Gullickson
723e25e1a7 fix: add pre-auth session clear mechanism on HomePage (refs #192)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:56:24 -06:00
Eric Gullickson
6e493e9bc7 fix: detect and clear stale IndexedDB auth tokens (refs #190)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:55:54 -06:00
Eric Gullickson
a195fa9231 fix: allow callback route to complete Auth0 code exchange (refs #189)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:55:24 -06:00
82e8afc215 Merge pull request 'fix: Desktop sidebar clips logo after collapse-mode UX changes (#187)' (#191) from issue-187-fix-sidebar-logo-clipping into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 34s
Deploy to Staging / Deploy to Staging (push) Successful in 52s
Deploy to Staging / Verify Staging (push) Successful in 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #191
2026-02-15 03:51:56 +00:00
Eric Gullickson
19cd917c66 fix: resolve sidebar logo clipping with flex-based layout (refs #187)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m41s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:45:03 -06:00
c816dd39ab Merge pull request 'chore: UX design audit cleanup and receipt flow improvements' (#186) from issue-162-ux-design-audit-cleanup into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 36s
Deploy to Staging / Deploy to Staging (push) Successful in 27s
Deploy to Staging / Verify Staging (push) Successful in 8s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Mirror Base Images / Mirror Base Images (push) Successful in 54s
Reviewed-on: #186
2026-02-14 03:50:21 +00:00
Eric Gullickson
7f6e4e0ec2 fix: skip image preview for PDF receipt uploads (refs #182)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m30s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
URL.createObjectURL on a PDF creates a blob URL that cannot render in
an img tag, showing broken image alt text. Skip preview creation for
PDF files so the review modal displays without a thumbnail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:43:47 -06:00
Eric Gullickson
220f8ea3ac fix: increase hybrid engine cloud timeout for WIF token exchange (refs #182)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 37s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
The 5s cloud timeout was too tight for the initial WIF authentication
which requires 3 HTTP round-trips (STS, IAM credentials, resource
manager). First call took 5.5s and was discarded, falling back to slow
CPU-based PaddleOCR. Increased to 10s to accommodate cold-start auth.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:38:05 -06:00
Eric Gullickson
5e4515da7c fix: use PyMuPDF instead of pdf2image for PDF-to-image conversion (refs #182)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 37s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
pdf2image requires poppler-utils which is not installed in the OCR
container. PyMuPDF is already in requirements.txt and can render PDF
pages to PNG at 300 DPI natively without extra system dependencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:34:17 -06:00
Eric Gullickson
5877b531f9 fix: allow PDF uploads in backend OCR controller and service (refs #182)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m41s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
The backend SUPPORTED_IMAGE_TYPES set excluded application/pdf, returning
415 before the request ever reached the OCR microservice. Added PDF to
the allowed types in both controller and service validation layers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:27:40 -06:00
Eric Gullickson
653c535165 chore: add PDF support to receipt OCR pipeline (refs #182)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 38s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 22s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
The receipt extractor only accepted image MIME types, rejecting PDFs at
the OCR layer. Added application/pdf to supported types and PDF-to-image
conversion (first page at 300 DPI) before OCR preprocessing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:22:40 -06:00
Eric Gullickson
83bacf0e2f chore: accept PDF files in receipt upload dialog (refs #182)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m39s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 23s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:14:22 -06:00
Eric Gullickson
812823f2f1 chore: integrate AddReceiptDialog into MaintenanceRecordForm (refs #184)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m31s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 22s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Replace ReceiptCameraButton with "Add Receipt" button that opens
AddReceiptDialog. Upload path feeds handleCaptureImage, camera path
calls startCapture. Tier gating preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:57:37 -06:00
Eric Gullickson
6751766b0a chore: create AddReceiptDialog component with upload and camera options (refs #183)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:55:21 -06:00
Eric Gullickson
bc72f09557 feat: add desktop sidebar collapse to icon-only mode (refs #176)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:07:00 -06:00
Eric Gullickson
f987e94fed chore: verify notification bell functionality and improve empty state (refs #180)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:02:56 -06:00
Eric Gullickson
da4cd858fa chore: use display name instead of email in header greeting (refs #177)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:01:56 -06:00
Eric Gullickson
553877bfc6 chore: add upload date and file type icon to document cards (refs #172)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:00:49 -06:00
Eric Gullickson
daa0cd072e chore: remove Insurance default bias from Add Document modal (refs #175)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:56:34 -06:00
Eric Gullickson
afd4583450 chore: show service type in maintenance schedule names for differentiation (refs #174)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:55:25 -06:00
Eric Gullickson
f03cd420ef chore: add Maintenance page title and remove duplicate vehicle dropdown (refs #169)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:53:13 -06:00
Eric Gullickson
e4be744643 chore: restructure Fuel Logs to list-first with add dialog (refs #168)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:49:46 -06:00
Eric Gullickson
f2b20aab1a feat: add recent activity feed to dashboard (refs #166)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:48:06 -06:00
Eric Gullickson
accb0533c6 feat: add call-to-action links in zero-state dashboard stats cards (refs #179)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:45:14 -06:00
Eric Gullickson
0dc273d238 chore: remove dashboard auto-refresh footer text (refs #178)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:43:57 -06:00
Eric Gullickson
56be3ed348 chore: add Year Make Model subtitle to vehicle cards and hide empty VIN (refs #167)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:41:23 -06:00
Eric Gullickson
bc9c386300 chore: differentiate Stations icon from Fuel Logs in bottom nav (refs #181)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:40:09 -06:00
Eric Gullickson
7a74c7f81f chore: remove redundant Stations from mobile More menu (refs #173)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:39:16 -06:00
Eric Gullickson
73976a7356 fix: add Maintenance to mobile More menu (refs #164)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:38:21 -06:00
Eric Gullickson
0e8c6070ef fix: sync mobile routing with browser URL for direct navigation (refs #163)
URL-to-screen sync on mount and screen-to-URL sync via replaceState
enable direct URL navigation, page refresh, and bookmarks on mobile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:35:53 -06:00
Eric Gullickson
325cf08df0 fix: promote vehicle display utils to core with null safety (refs #165)
Create shared getVehicleLabel/getVehicleSubtitle in core/utils with
VehicleLike interface. Replace all direct year/make/model concatenation
across 17 consumer files to prevent null values in vehicle names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:32:40 -06:00
Eric Gullickson
75e4660c58 Merge branch 'main' of 172.30.1.72:egullickson/motovaultpro
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m25s
Deploy to Staging / Deploy to Staging (push) Successful in 51s
Deploy to Staging / Verify Staging (push) Successful in 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
2026-02-13 16:28:52 -06:00
Eric Gullickson
ff8b04f146 chore: claude updates 2026-02-13 16:28:49 -06:00
f0b1e57089 Merge pull request 'feat: Maintenance Receipt Upload with OCR Auto-populate (#16)' (#161) from issue-16-maintenance-receipt-upload-ocr into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 36s
Deploy to Staging / Deploy to Staging (push) Successful in 51s
Deploy to Staging / Verify Staging (push) Successful in 8s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #161
2026-02-13 22:19:44 +00:00
Eric Gullickson
1bf550ae9b feat: add pending vehicle association resolution UI (refs #160)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 8m40s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Backend: Add authenticated endpoints for pending association CRUD
(GET/POST/DELETE /api/email-ingestion/pending). Service methods for
resolving (creates fuel/maintenance record) and dismissing associations.

Frontend: New email-ingestion feature with types, API client, hooks,
PendingAssociationBanner (dashboard), PendingAssociationList, and
ResolveAssociationDialog. Mobile-first responsive with 44px touch
targets and full-screen dialogs on small screens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 09:39:03 -06:00
Eric Gullickson
8bcac80818 feat: add email ingestion notification handler with logging (refs #159)
- Extract all notification logic from EmailIngestionService into
  dedicated EmailIngestionNotificationHandler class
- Add notification_logs entries for every email sent (success/failure)
- Add in-app user_notifications for all error scenarios (no vehicles,
  no attachments, OCR failure, processing failure)
- Update email templates with enhanced variables: merchantName,
  totalAmount, date, guidance
- Update pending vehicle notification title to 'Vehicle Selection Required'
- Add sample variables for receipt templates in test email flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 09:27:37 -06:00
Eric Gullickson
fce60759cf feat: add vehicle association and record creation (refs #158)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 08:53:08 -06:00
Eric Gullickson
d9a40f7d37 feat: add receipt classifier and OCR integration (refs #157)
- New ReceiptClassifier module with keyword-based classification for
  fuel vs maintenance receipts from email text and OCR raw text
- Classifier-first pipeline: classify from email subject/body keywords
  before falling back to OCR-based classification
- Fuel keywords: gas, fuel, gallons, octane, pump, diesel, unleaded,
  shell, chevron, exxon, bp
- Maintenance keywords: oil change, brake, alignment, tire, rotation,
  inspection, labor, parts, service, repair, transmission, coolant
- Confident classification (>= 2 keyword matches) routes to specific
  OCR endpoint; unclassified falls back to both endpoints + rawText
  classification + field-count heuristic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 08:44:03 -06:00
Eric Gullickson
e7f3728771 feat: add email ingestion processing service and repository (refs #156)
- EmailIngestionRepository: queue CRUD (insert, update status, get,
  find by email ID), pending vehicle association management, mapRow
  pattern for snake_case -> camelCase conversion
- EmailIngestionService: full processing pipeline with sender validation,
  attachment filtering (PDF/PNG/JPG/JPEG/HEIC, <10MB), dual OCR
  classification (fuel vs maintenance), vehicle association logic
  (single-vehicle auto-associate, multi-vehicle pending), retry handling
  (max 3 attempts), and templated email replies (confirmation, failure,
  pending vehicle)
- Updated controller to delegate async processing to service
- Added receipt_processed/receipt_failed/receipt_pending_vehicle to
  TemplateKey union type

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 08:32:10 -06:00
Eric Gullickson
2462fff34d feat: add Resend inbound webhook endpoint and client (refs #155)
- ResendInboundClient: webhook signature verification via Svix, email
  fetch/download/parse with mailparser
- POST /api/webhooks/resend/inbound endpoint with rawBody, signature
  verification, idempotency check, queue insertion, async processing
- Config: resend_webhook_secret (optional) in secrets schema
- Route registration in app.ts following Stripe webhook pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 08:22:25 -06:00
Eric Gullickson
877f844be6 feat: add email ingestion database schema and types (refs #154)
- Create email_ingestion_queue table with UNIQUE email_id constraint
- Create pending_vehicle_associations table with documents FK
- Seed 3 email templates: receipt_processed, receipt_failed, receipt_pending_vehicle
- Add TypeScript types for queue records, associations, and Resend webhook payloads
- Register email-ingestion in migration runner order

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:01:17 -06:00
Eric Gullickson
06ff8101dc feat: add form integration, tier gating, and receipt display (refs #153)
- Add tier-gated "Scan Receipt" button to MaintenanceRecordForm
- Wire useMaintenanceReceiptOcr hook with CameraCapture and ReviewModal
- Auto-populate form fields from accepted OCR results via setValue
- Upload receipt as document and pass receiptDocumentId on record create
- Show receipt thumbnail + "View Receipt" button in edit dialog
- Add receipt indicator chip on records list rows
- Add receiptDocumentId and receiptDocument to frontend types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 21:40:27 -06:00
Eric Gullickson
91166b021c feat: add maintenance receipt OCR hook and review modal (refs #152)
Add useMaintenanceReceiptOcr hook mirroring fuel receipt OCR pattern,
MaintenanceReceiptReviewModal with confidence indicators and inline editing,
and maintenance-receipt.types.ts for extraction field types. Includes
category/subtype suggestion via keyword matching from service descriptions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 21:31:48 -06:00
Eric Gullickson
88d23d2745 feat: add backend migration and API for maintenance receipt linking (refs #151)
Add receipt_document_id FK on maintenance_records, update types/repo/service
to support receipt linking on create and return document metadata on GET.
Add OCR proxy endpoint POST /api/ocr/extract/maintenance-receipt with
tier gating (maintenance.receiptScan) through full chain: routes -> controller
-> service -> client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 21:24:24 -06:00
Eric Gullickson
90401dc1ba feat: add maintenance receipt extraction pipeline with Gemini + regex (refs #150)
- New MaintenanceReceiptExtractor: Gemini-primary extraction with regex
  cross-validation for dates, amounts, and odometer readings
- New maintenance_receipt_validation.py: cross-validation patterns for
  structured field confidence adjustment
- New POST /extract/maintenance-receipt endpoint reusing
  ReceiptExtractionResponse model
- Per-field confidence scores (0.0-1.0) with Gemini base 0.85,
  boosted/reduced by regex agreement

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 21:14:13 -06:00
0e97128a31 Merge pull request 'feat: Expand OCR with fuel receipt scanning and maintenance extraction (#129)' (#147) from issue-129-expand-ocr-fuel-receipt-maintenance into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 36s
Deploy to Staging / Deploy to Staging (push) Successful in 51s
Deploy to Staging / Verify Staging (push) Successful in 8s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #147
2026-02-13 02:25:54 +00:00
Eric Gullickson
80ee2faed8 fix: Replace circle toggle with MUI Switch pill style (refs #148)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 35s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
EmailNotificationToggle used a custom button-based toggle that rendered
as a circle. Replaced with MUI Switch component to match the pill-style
toggles used on the SettingsPage throughout the app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 20:14:01 -06:00
Eric Gullickson
6bb2c575b4 fix: Wire vehicleId into maintenance page to display schedules (refs #148)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m28s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 10s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Maintenance page called useMaintenanceRecords() without a vehicleId,
causing the schedules query (enabled: !!vehicleId) to never execute.
Added vehicle selector to both desktop and mobile pages, auto-selects
first vehicle, and passes selectedVehicleId to the hook. Also fixed
stale query invalidation keys in delete handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 20:01:42 -06:00
Eric Gullickson
59e7f4053a fix: Data validation for scheduled maintenance
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m24s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 25s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
2026-02-11 20:47:46 -06:00
Eric Gullickson
33b489d526 fix: Update auto schedule creation
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m29s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 25s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
2026-02-11 20:29:33 -06:00
Eric Gullickson
55a7bcc874 fix: Manual polling typo
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 36s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
2026-02-11 20:06:03 -06:00
Eric Gullickson
a078962d3f fix: Manual scanning
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 35s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
2026-02-11 19:57:32 -06:00
Eric Gullickson
b97d226d44 fix: Variables
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 34s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
2026-02-11 19:42:42 -06:00
253 changed files with 13904 additions and 3024 deletions

View File

@@ -0,0 +1,23 @@
{
"testModules": [
{
"moduleId": "/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/src/features/dashboard/components/__tests__/ActionBar.test.tsx",
"tests": [
{
"name": "Module failed to load (Error)",
"fullName": "Module failed to load (Error)",
"state": "failed",
"errors": [
{
"message": "File not found: tsconfig.json (resolved as: /Users/egullickson/Documents/Technology/coding/motovaultpro/tsconfig.json)",
"name": "Error",
"stack": "Error: File not found: tsconfig.json (resolved as: /Users/egullickson/Documents/Technology/coding/motovaultpro/tsconfig.json)\n at ConfigSet.resolvePath (/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/node_modules/ts-jest/dist/legacy/config/config-set.js:616:19)\n at ConfigSet._setupConfigSet (/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/node_modules/ts-jest/dist/legacy/config/config-set.js:322:71)\n at new ConfigSet (/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/node_modules/ts-jest/dist/legacy/config/config-set.js:206:14)\n at TsJestTransformer._createConfigSet (/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/node_modules/ts-jest/dist/legacy/ts-jest-transformer.js:119:16)\n at TsJestTransformer._configsFor (/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/node_modules/ts-jest/dist/legacy/ts-jest-transformer.js:98:34)\n at TsJestTransformer.getCacheKey (/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/node_modules/ts-jest/dist/legacy/ts-jest-transformer.js:249:30)\n at ScriptTransformer._getCacheKey (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/@jest/transform/build/index.js:195:41)\n at ScriptTransformer._getFileCachePath (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/@jest/transform/build/index.js:231:27)\n at ScriptTransformer.transformSource (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/@jest/transform/build/index.js:402:32)\n at ScriptTransformer._transformAndBuildScript (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/@jest/transform/build/index.js:519:40)\n at ScriptTransformer.transform (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/@jest/transform/build/index.js:558:19)\n at Runtime.transformFile (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-runtime/build/index.js:1290:53)\n at Runtime._execModule (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-runtime/build/index.js:1243:34)\n at Runtime._loadModule (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-runtime/build/index.js:944:12)\n at Runtime.requireModule (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-runtime/build/index.js:832:12)\n at jestAdapter (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-circus/build/runner.js:84:33)\n at processTicksAndRejections (node:internal/process/task_queues:104:5)\n at runTestInternal (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-runner/build/index.js:275:16)\n at runTest (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-runner/build/index.js:343:7)"
}
]
}
]
}
],
"unhandledErrors": [],
"reason": "failed"
}

36
.env.example Normal file
View File

@@ -0,0 +1,36 @@
# MotoVaultPro Environment Configuration
# Copy to .env and fill in environment-specific values
# Generated .env files should NOT be committed to version control
#
# Local dev: No .env needed -- base docker-compose.yml defaults are sandbox values
# Staging/Production: CI/CD generates .env from Gitea variables + generate-log-config.sh
# ===========================================
# Stripe Price IDs (environment-specific)
# ===========================================
# Sandbox defaults used for local development
STRIPE_PRO_MONTHLY_PRICE_ID=price_1T1ZHMJXoKkh5RcKwKSSGIlR
STRIPE_PRO_YEARLY_PRICE_ID=price_1T1ZHnJXoKkh5RcKWlG2MPpX
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_1T1ZIBJXoKkh5RcKu2jyhqBN
STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_1T1ZIQJXoKkh5RcK34YXiJQm
# ===========================================
# Stripe Publishable Key (baked into frontend at build time)
# ===========================================
# VITE_STRIPE_PUBLISHABLE_KEY=pk_test_...
# ===========================================
# Log Levels (generated by scripts/ci/generate-log-config.sh)
# ===========================================
# Run: ./scripts/ci/generate-log-config.sh DEBUG >> .env
#
# BACKEND_LOG_LEVEL=debug
# TRAEFIK_LOG_LEVEL=DEBUG
# POSTGRES_LOG_STATEMENT=all
# POSTGRES_LOG_MIN_DURATION=0
# REDIS_LOGLEVEL=debug
# ===========================================
# Grafana
# ===========================================
# GRAFANA_ADMIN_PASSWORD=admin

View File

@@ -99,6 +99,7 @@ jobs:
docker-compose.yml
docker-compose.blue-green.yml
docker-compose.prod.yml
.env.example
sparse-checkout-cone-mode: false
fetch-depth: 1
@@ -115,11 +116,20 @@ jobs:
mkdir -p "$DEPLOY_PATH/secrets/app"
cp "$GITHUB_WORKSPACE/secrets/app/google-wif-config.json" "$DEPLOY_PATH/secrets/app/"
- name: Generate logging configuration
- name: Generate environment configuration
run: |
cd "$DEPLOY_PATH"
{
echo "# Generated by CI/CD - DO NOT EDIT"
echo "STRIPE_PRO_MONTHLY_PRICE_ID=${{ vars.STRIPE_PRO_MONTHLY_PRICE_ID }}"
echo "STRIPE_PRO_YEARLY_PRICE_ID=${{ vars.STRIPE_PRO_YEARLY_PRICE_ID }}"
echo "STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=${{ vars.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID }}"
echo "STRIPE_ENTERPRISE_YEARLY_PRICE_ID=${{ vars.STRIPE_ENTERPRISE_YEARLY_PRICE_ID }}"
echo "VITE_STRIPE_PUBLISHABLE_KEY=${{ vars.VITE_STRIPE_PUBLISHABLE_KEY }}"
echo "GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}"
} > .env
chmod +x scripts/ci/generate-log-config.sh
./scripts/ci/generate-log-config.sh "$LOG_LEVEL"
./scripts/ci/generate-log-config.sh "$LOG_LEVEL" >> .env
- name: Login to registry
run: |

View File

@@ -124,11 +124,20 @@ jobs:
mkdir -p "$DEPLOY_PATH/secrets/app"
cp "$GITHUB_WORKSPACE/secrets/app/google-wif-config.json" "$DEPLOY_PATH/secrets/app/"
- name: Generate logging configuration
- name: Generate environment configuration
run: |
cd "$DEPLOY_PATH"
{
echo "# Generated by CI/CD - DO NOT EDIT"
echo "STRIPE_PRO_MONTHLY_PRICE_ID=${{ vars.STRIPE_PRO_MONTHLY_PRICE_ID }}"
echo "STRIPE_PRO_YEARLY_PRICE_ID=${{ vars.STRIPE_PRO_YEARLY_PRICE_ID }}"
echo "STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=${{ vars.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID }}"
echo "STRIPE_ENTERPRISE_YEARLY_PRICE_ID=${{ vars.STRIPE_ENTERPRISE_YEARLY_PRICE_ID }}"
echo "VITE_STRIPE_PUBLISHABLE_KEY=${{ vars.VITE_STRIPE_PUBLISHABLE_KEY }}"
echo "GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}"
} > .env
chmod +x scripts/ci/generate-log-config.sh
./scripts/ci/generate-log-config.sh "$LOG_LEVEL"
./scripts/ci/generate-log-config.sh "$LOG_LEVEL" >> .env
- name: Login to registry
run: |

View File

@@ -24,12 +24,14 @@
"get-jwks": "^11.0.3",
"ioredis": "^5.4.2",
"js-yaml": "^4.1.0",
"mailparser": "^3.9.3",
"node-cron": "^3.0.3",
"opossum": "^8.0.0",
"pg": "^8.13.1",
"pino": "^9.6.0",
"resend": "^3.0.0",
"stripe": "^20.2.0",
"svix": "^1.85.0",
"tar": "^7.4.3",
"zod": "^3.24.1"
},
@@ -37,6 +39,7 @@
"@eslint/js": "^9.17.0",
"@types/jest": "^29.5.10",
"@types/js-yaml": "^4.0.9",
"@types/mailparser": "^3.4.6",
"@types/node": "^22.0.0",
"@types/node-cron": "^3.0.11",
"@types/opossum": "^8.0.0",
@@ -83,7 +86,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1766,6 +1768,12 @@
"@sinonjs/commons": "^3.0.0"
}
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
@@ -1921,6 +1929,30 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/mailparser": {
"version": "3.4.6",
"resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz",
"integrity": "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"iconv-lite": "^0.6.3"
}
},
"node_modules/@types/mailparser/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -1934,7 +1966,6 @@
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -2061,7 +2092,6 @@
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
@@ -2273,6 +2303,17 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@zone-eu/mailsplit": {
"version": "5.4.8",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
"integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libqp": "2.1.1"
}
},
"node_modules/abbrev": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
@@ -2306,7 +2347,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2773,7 +2813,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3470,6 +3509,15 @@
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/encoding-japanese": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
"license": "MIT",
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -3566,7 +3614,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3900,6 +3947,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
@@ -4508,6 +4561,15 @@
"node": ">= 0.4"
}
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"license": "MIT",
"bin": {
"he": "bin/he"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
@@ -4580,6 +4642,22 @@
"node": ">=10.17.0"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -4920,7 +4998,6 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "^29.7.0",
"@jest/types": "^29.6.3",
@@ -5736,6 +5813,42 @@
"node": ">= 0.8.0"
}
},
"node_modules/libbase64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
"license": "MIT"
},
"node_modules/libmime": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.6.3",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/libmime/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/libqp": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
"license": "MIT"
},
"node_modules/light-my-request": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
@@ -5780,6 +5893,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -5827,6 +5949,7 @@
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
@@ -5843,6 +5966,24 @@
"node": "20 || >=22"
}
},
"node_modules/mailparser": {
"version": "3.9.3",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.3.tgz",
"integrity": "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"encoding-japanese": "2.2.0",
"he": "1.2.0",
"html-to-text": "9.0.5",
"iconv-lite": "0.7.2",
"libmime": "5.3.7",
"linkify-it": "5.0.0",
"nodemailer": "7.0.13",
"punycode.js": "2.3.1",
"tlds": "1.261.0"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
@@ -6071,6 +6212,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
@@ -6419,7 +6569,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
@@ -6785,6 +6934,15 @@
"node": ">=6"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pure-rand": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
@@ -7126,6 +7284,7 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
}
@@ -7381,6 +7540,16 @@
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/steed": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz",
@@ -7602,6 +7771,29 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svix": {
"version": "1.85.0",
"resolved": "https://registry.npmjs.org/svix/-/svix-1.85.0.tgz",
"integrity": "sha512-4OxNw++bnNay8SoBwESgzfjMnYmurS1qBX+luhzvljr6EAPn/hqqmkdCR1pbgIe1K1+BzKZEHjAKz9OYrKJYwQ==",
"license": "MIT",
"dependencies": {
"standardwebhooks": "1.0.0",
"uuid": "^10.0.0"
}
},
"node_modules/svix/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/tar": {
"version": "7.5.2",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz",
@@ -7692,7 +7884,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -7700,6 +7891,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tlds": {
"version": "1.261.0",
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
"integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
"license": "MIT",
"bin": {
"tlds": "bin.js"
}
},
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -7841,7 +8041,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -7929,7 +8128,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7962,6 +8160,12 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",

View File

@@ -34,19 +34,22 @@
"get-jwks": "^11.0.3",
"ioredis": "^5.4.2",
"js-yaml": "^4.1.0",
"mailparser": "^3.9.3",
"node-cron": "^3.0.3",
"opossum": "^8.0.0",
"pg": "^8.13.1",
"pino": "^9.6.0",
"resend": "^3.0.0",
"stripe": "^20.2.0",
"svix": "^1.85.0",
"tar": "^7.4.3",
"pino": "^9.6.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/jest": "^29.5.10",
"@types/js-yaml": "^4.0.9",
"@types/mailparser": "^3.4.6",
"@types/node": "^22.0.0",
"@types/node-cron": "^3.0.11",
"@types/opossum": "^8.0.0",

View File

@@ -26,10 +26,12 @@ const MIGRATION_ORDER = [
'features/admin', // Admin role management and oversight; depends on update_updated_at_column()
'features/backup', // Admin backup feature; depends on update_updated_at_column()
'features/notifications', // Depends on maintenance and documents
'features/email-ingestion', // Depends on documents, notifications (extends email_templates)
'features/terms-agreement', // Terms & Conditions acceptance audit trail
'features/audit-log', // Centralized audit logging; independent
'features/ownership-costs', // Depends on vehicles and documents; TCO recurring costs
'features/subscriptions', // Stripe subscriptions; depends on user-profile, vehicles
'core/identity-migration', // Cross-cutting UUID migration; must run after all feature tables exist
];
// Base directory where migrations are copied inside the image (set by Dockerfile)

View File

@@ -35,6 +35,7 @@ import { userImportRoutes } from './features/user-import';
import { ownershipCostsRoutes } from './features/ownership-costs';
import { subscriptionsRoutes, donationsRoutes, webhooksRoutes } from './features/subscriptions';
import { ocrRoutes } from './features/ocr';
import { emailIngestionWebhookRoutes, emailIngestionRoutes } from './features/email-ingestion';
import { pool } from './core/config/database';
import { configRoutes } from './core/config/config.routes';
@@ -96,7 +97,7 @@ async function buildApp(): Promise<FastifyInstance> {
status: 'healthy',
timestamp: new Date().toISOString(),
environment: process.env['NODE_ENV'],
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr']
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr', 'email-ingestion']
});
});
@@ -106,7 +107,7 @@ async function buildApp(): Promise<FastifyInstance> {
status: 'healthy',
scope: 'api',
timestamp: new Date().toISOString(),
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr']
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr', 'email-ingestion']
});
});
@@ -152,6 +153,8 @@ async function buildApp(): Promise<FastifyInstance> {
await app.register(subscriptionsRoutes, { prefix: '/api' });
await app.register(donationsRoutes, { prefix: '/api' });
await app.register(webhooksRoutes, { prefix: '/api' });
await app.register(emailIngestionWebhookRoutes, { prefix: '/api' });
await app.register(emailIngestionRoutes, { prefix: '/api' });
await app.register(ocrRoutes, { prefix: '/api' });
await app.register(configRoutes, { prefix: '/api' });

View File

@@ -41,14 +41,6 @@ const configSchema = z.object({
audience: z.string(),
}),
// External APIs configuration (optional)
external: z.object({
vpic: z.object({
url: z.string(),
timeout: z.string(),
}).optional(),
}).optional(),
// Service configuration
service: z.object({
name: z.string(),
@@ -126,6 +118,7 @@ const secretsSchema = z.object({
auth0_management_client_secret: z.string(),
google_maps_api_key: z.string(),
resend_api_key: z.string(),
resend_webhook_secret: z.string().optional(),
// Stripe secrets (API keys only - price IDs are config, not secrets)
stripe_secret_key: z.string(),
stripe_webhook_secret: z.string(),
@@ -143,6 +136,10 @@ export interface AppConfiguration {
getRedisUrl(): string;
getAuth0Config(): { domain: string; audience: string; clientSecret: string };
getAuth0ManagementConfig(): { domain: string; clientId: string; clientSecret: string };
getResendConfig(): {
apiKey: string;
webhookSecret: string | undefined;
};
getStripeConfig(): {
secretKey: string;
webhookSecret: string;
@@ -185,6 +182,7 @@ class ConfigurationLoader {
'auth0-management-client-secret',
'google-maps-api-key',
'resend-api-key',
'resend-webhook-secret',
'stripe-secret-key',
'stripe-webhook-secret',
];
@@ -250,6 +248,13 @@ class ConfigurationLoader {
};
},
getResendConfig() {
return {
apiKey: secrets.resend_api_key,
webhookSecret: secrets.resend_webhook_secret,
};
},
getStripeConfig() {
return {
secretKey: secrets.stripe_secret_key,
@@ -258,8 +263,11 @@ class ConfigurationLoader {
},
};
// Set RESEND_API_KEY in environment for EmailService
// Set Resend environment variables for EmailService and webhook verification
process.env['RESEND_API_KEY'] = secrets.resend_api_key;
if (secrets.resend_webhook_secret) {
process.env['RESEND_WEBHOOK_SECRET'] = secrets.resend_webhook_secret;
}
logger.info('Configuration loaded successfully', {
configSource: 'yaml',

View File

@@ -29,13 +29,18 @@ export const FEATURE_TIERS: Record<string, FeatureConfig> = {
'vehicle.vinDecode': {
minTier: 'pro',
name: 'VIN Decode',
upgradePrompt: 'Upgrade to Pro to automatically decode VIN and populate vehicle details from the NHTSA database.',
upgradePrompt: 'Upgrade to Pro to automatically decode VIN and populate vehicle details from the vehicle database.',
},
'fuelLog.receiptScan': {
minTier: 'pro',
name: 'Receipt Scan',
upgradePrompt: 'Upgrade to Pro to scan fuel receipts and auto-fill your fuel log entries.',
},
'maintenance.receiptScan': {
minTier: 'pro',
name: 'Maintenance Receipt Scan',
upgradePrompt: 'Upgrade to Pro to scan maintenance receipts and extract service details automatically.',
},
} as const;
/**

View File

@@ -0,0 +1,404 @@
-- Migration: 001_migrate_user_id_to_uuid.sql
-- Feature: identity-migration (cross-cutting)
-- Description: Migrate all user identity columns from VARCHAR(255) storing auth0_sub
-- to UUID referencing user_profiles.id. Admin tables restructured with UUID PKs.
-- Requires: All feature tables must exist (runs last in MIGRATION_ORDER)
BEGIN;
-- ============================================================================
-- PHASE 1: Add new UUID columns alongside existing VARCHAR columns
-- ============================================================================
-- 1a. Feature tables (17 tables with user_id VARCHAR)
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE maintenance_records ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE maintenance_schedules ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE documents ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE notification_logs ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE user_notifications ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE saved_stations ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE ownership_costs ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE email_ingestion_queue ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE pending_vehicle_associations ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE donations ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE tier_vehicle_selections ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE terms_agreements ADD COLUMN IF NOT EXISTS user_profile_id UUID;
-- 1b. Special user-reference columns (submitted_by/reported_by store auth0_sub)
ALTER TABLE community_stations ADD COLUMN IF NOT EXISTS submitted_by_uuid UUID;
ALTER TABLE station_removal_reports ADD COLUMN IF NOT EXISTS reported_by_uuid UUID;
-- 1c. Admin table: add id UUID and user_profile_id UUID
ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS id UUID;
ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS user_profile_id UUID;
-- 1d. Admin-referencing columns: add UUID equivalents
ALTER TABLE admin_audit_logs ADD COLUMN IF NOT EXISTS actor_admin_uuid UUID;
ALTER TABLE admin_audit_logs ADD COLUMN IF NOT EXISTS target_admin_uuid UUID;
ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS created_by_uuid UUID;
ALTER TABLE community_stations ADD COLUMN IF NOT EXISTS reviewed_by_uuid UUID;
ALTER TABLE backup_history ADD COLUMN IF NOT EXISTS created_by_uuid UUID;
ALTER TABLE platform_change_log ADD COLUMN IF NOT EXISTS changed_by_uuid UUID;
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS deactivated_by_uuid UUID;
-- ============================================================================
-- PHASE 2: Backfill UUID values from user_profiles join
-- ============================================================================
-- 2a. Feature tables: map user_id (auth0_sub) -> user_profiles.id (UUID)
UPDATE vehicles SET user_profile_id = up.id
FROM user_profiles up WHERE vehicles.user_id = up.auth0_sub AND vehicles.user_profile_id IS NULL;
UPDATE fuel_logs SET user_profile_id = up.id
FROM user_profiles up WHERE fuel_logs.user_id = up.auth0_sub AND fuel_logs.user_profile_id IS NULL;
UPDATE maintenance_records SET user_profile_id = up.id
FROM user_profiles up WHERE maintenance_records.user_id = up.auth0_sub AND maintenance_records.user_profile_id IS NULL;
UPDATE maintenance_schedules SET user_profile_id = up.id
FROM user_profiles up WHERE maintenance_schedules.user_id = up.auth0_sub AND maintenance_schedules.user_profile_id IS NULL;
UPDATE documents SET user_profile_id = up.id
FROM user_profiles up WHERE documents.user_id = up.auth0_sub AND documents.user_profile_id IS NULL;
UPDATE notification_logs SET user_profile_id = up.id
FROM user_profiles up WHERE notification_logs.user_id = up.auth0_sub AND notification_logs.user_profile_id IS NULL;
UPDATE user_notifications SET user_profile_id = up.id
FROM user_profiles up WHERE user_notifications.user_id = up.auth0_sub AND user_notifications.user_profile_id IS NULL;
UPDATE user_preferences SET user_profile_id = up.id
FROM user_profiles up WHERE user_preferences.user_id = up.auth0_sub AND user_preferences.user_profile_id IS NULL;
-- 2a-fix. user_preferences has rows where user_id already contains user_profiles.id (UUID)
-- instead of auth0_sub. Match these directly by casting to UUID.
UPDATE user_preferences SET user_profile_id = up.id
FROM user_profiles up
WHERE user_preferences.user_id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
AND user_preferences.user_id::uuid = up.id
AND user_preferences.user_profile_id IS NULL;
-- Delete truly orphaned user_preferences (UUID user_id with no matching user_profile)
DELETE FROM user_preferences
WHERE user_profile_id IS NULL
AND user_id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
AND NOT EXISTS (SELECT 1 FROM user_profiles WHERE id = user_preferences.user_id::uuid);
-- Deduplicate user_preferences: same user may have both an auth0_sub row and
-- a UUID row, both now mapping to the same user_profile_id. Keep the newest.
DELETE FROM user_preferences a
USING user_preferences b
WHERE a.user_profile_id = b.user_profile_id
AND a.user_profile_id IS NOT NULL
AND (a.updated_at < b.updated_at OR (a.updated_at = b.updated_at AND a.id < b.id));
UPDATE saved_stations SET user_profile_id = up.id
FROM user_profiles up WHERE saved_stations.user_id = up.auth0_sub AND saved_stations.user_profile_id IS NULL;
UPDATE audit_logs SET user_profile_id = up.id
FROM user_profiles up WHERE audit_logs.user_id = up.auth0_sub AND audit_logs.user_profile_id IS NULL;
UPDATE ownership_costs SET user_profile_id = up.id
FROM user_profiles up WHERE ownership_costs.user_id = up.auth0_sub AND ownership_costs.user_profile_id IS NULL;
UPDATE email_ingestion_queue SET user_profile_id = up.id
FROM user_profiles up WHERE email_ingestion_queue.user_id = up.auth0_sub AND email_ingestion_queue.user_profile_id IS NULL;
UPDATE pending_vehicle_associations SET user_profile_id = up.id
FROM user_profiles up WHERE pending_vehicle_associations.user_id = up.auth0_sub AND pending_vehicle_associations.user_profile_id IS NULL;
UPDATE subscriptions SET user_profile_id = up.id
FROM user_profiles up WHERE subscriptions.user_id = up.auth0_sub AND subscriptions.user_profile_id IS NULL;
UPDATE donations SET user_profile_id = up.id
FROM user_profiles up WHERE donations.user_id = up.auth0_sub AND donations.user_profile_id IS NULL;
UPDATE tier_vehicle_selections SET user_profile_id = up.id
FROM user_profiles up WHERE tier_vehicle_selections.user_id = up.auth0_sub AND tier_vehicle_selections.user_profile_id IS NULL;
UPDATE terms_agreements SET user_profile_id = up.id
FROM user_profiles up WHERE terms_agreements.user_id = up.auth0_sub AND terms_agreements.user_profile_id IS NULL;
-- 2b. Special user columns
UPDATE community_stations SET submitted_by_uuid = up.id
FROM user_profiles up WHERE community_stations.submitted_by = up.auth0_sub AND community_stations.submitted_by_uuid IS NULL;
UPDATE station_removal_reports SET reported_by_uuid = up.id
FROM user_profiles up WHERE station_removal_reports.reported_by = up.auth0_sub AND station_removal_reports.reported_by_uuid IS NULL;
-- ============================================================================
-- PHASE 3: Admin-specific transformations
-- ============================================================================
-- 3a. Create user_profiles entries for any admin_users that lack one
INSERT INTO user_profiles (auth0_sub, email)
SELECT au.auth0_sub, au.email
FROM admin_users au
WHERE NOT EXISTS (
SELECT 1 FROM user_profiles up WHERE up.auth0_sub = au.auth0_sub
)
ON CONFLICT (auth0_sub) DO NOTHING;
-- 3b. Populate admin_users.id (DEFAULT doesn't auto-fill on ALTER ADD COLUMN for existing rows)
UPDATE admin_users SET id = uuid_generate_v4() WHERE id IS NULL;
-- 3c. Backfill admin_users.user_profile_id from user_profiles join
UPDATE admin_users SET user_profile_id = up.id
FROM user_profiles up WHERE admin_users.auth0_sub = up.auth0_sub AND admin_users.user_profile_id IS NULL;
-- 3d. Backfill admin-referencing columns: map auth0_sub -> admin_users.id UUID
UPDATE admin_audit_logs SET actor_admin_uuid = au.id
FROM admin_users au WHERE admin_audit_logs.actor_admin_id = au.auth0_sub AND admin_audit_logs.actor_admin_uuid IS NULL;
UPDATE admin_audit_logs SET target_admin_uuid = au.id
FROM admin_users au WHERE admin_audit_logs.target_admin_id = au.auth0_sub AND admin_audit_logs.target_admin_uuid IS NULL;
UPDATE admin_users au SET created_by_uuid = creator.id
FROM admin_users creator WHERE au.created_by = creator.auth0_sub AND au.created_by_uuid IS NULL;
UPDATE community_stations SET reviewed_by_uuid = au.id
FROM admin_users au WHERE community_stations.reviewed_by = au.auth0_sub AND community_stations.reviewed_by_uuid IS NULL;
UPDATE backup_history SET created_by_uuid = au.id
FROM admin_users au WHERE backup_history.created_by = au.auth0_sub AND backup_history.created_by_uuid IS NULL;
UPDATE platform_change_log SET changed_by_uuid = au.id
FROM admin_users au WHERE platform_change_log.changed_by = au.auth0_sub AND platform_change_log.changed_by_uuid IS NULL;
UPDATE user_profiles SET deactivated_by_uuid = au.id
FROM admin_users au WHERE user_profiles.deactivated_by = au.auth0_sub AND user_profiles.deactivated_by_uuid IS NULL;
-- ============================================================================
-- PHASE 4: Add constraints
-- ============================================================================
-- 4a. Set NOT NULL on feature table UUID columns (audit_logs stays nullable)
ALTER TABLE vehicles ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE fuel_logs ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE maintenance_records ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE maintenance_schedules ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE documents ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE notification_logs ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE user_notifications ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE user_preferences ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE saved_stations ALTER COLUMN user_profile_id SET NOT NULL;
-- audit_logs.user_profile_id stays NULLABLE (system actions have no user)
ALTER TABLE ownership_costs ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE email_ingestion_queue ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE pending_vehicle_associations ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE subscriptions ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE donations ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE tier_vehicle_selections ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE terms_agreements ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE community_stations ALTER COLUMN submitted_by_uuid SET NOT NULL;
ALTER TABLE station_removal_reports ALTER COLUMN reported_by_uuid SET NOT NULL;
-- 4b. Admin table NOT NULL constraints
ALTER TABLE admin_users ALTER COLUMN id SET NOT NULL;
ALTER TABLE admin_users ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE admin_audit_logs ALTER COLUMN actor_admin_uuid SET NOT NULL;
-- target_admin_uuid stays nullable (some actions have no target)
-- created_by_uuid stays nullable (bootstrap admin may not have a creator)
ALTER TABLE platform_change_log ALTER COLUMN changed_by_uuid SET NOT NULL;
-- 4c. Admin table PK transformation
ALTER TABLE admin_users DROP CONSTRAINT admin_users_pkey;
ALTER TABLE admin_users ADD PRIMARY KEY (id);
-- 4d. Add FK constraints to user_profiles(id) with ON DELETE CASCADE
ALTER TABLE vehicles ADD CONSTRAINT fk_vehicles_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE fuel_logs ADD CONSTRAINT fk_fuel_logs_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE maintenance_records ADD CONSTRAINT fk_maintenance_records_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE maintenance_schedules ADD CONSTRAINT fk_maintenance_schedules_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE documents ADD CONSTRAINT fk_documents_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE notification_logs ADD CONSTRAINT fk_notification_logs_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE user_notifications ADD CONSTRAINT fk_user_notifications_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE user_preferences ADD CONSTRAINT fk_user_preferences_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE saved_stations ADD CONSTRAINT fk_saved_stations_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE audit_logs ADD CONSTRAINT fk_audit_logs_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE ownership_costs ADD CONSTRAINT fk_ownership_costs_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE email_ingestion_queue ADD CONSTRAINT fk_email_ingestion_queue_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE pending_vehicle_associations ADD CONSTRAINT fk_pending_vehicle_assoc_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE subscriptions ADD CONSTRAINT fk_subscriptions_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE donations ADD CONSTRAINT fk_donations_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE tier_vehicle_selections ADD CONSTRAINT fk_tier_vehicle_selections_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE terms_agreements ADD CONSTRAINT fk_terms_agreements_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE community_stations ADD CONSTRAINT fk_community_stations_submitted_by
FOREIGN KEY (submitted_by_uuid) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE station_removal_reports ADD CONSTRAINT fk_station_removal_reports_reported_by
FOREIGN KEY (reported_by_uuid) REFERENCES user_profiles(id) ON DELETE CASCADE;
-- 4e. Admin FK constraints
ALTER TABLE admin_users ADD CONSTRAINT fk_admin_users_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id);
ALTER TABLE admin_users ADD CONSTRAINT uq_admin_users_user_profile_id
UNIQUE (user_profile_id);
-- ============================================================================
-- PHASE 5: Drop old columns, rename new ones, recreate indexes
-- ============================================================================
-- 5a. Drop old FK constraints on VARCHAR user_id columns
ALTER TABLE subscriptions DROP CONSTRAINT IF EXISTS fk_subscriptions_user_id;
ALTER TABLE donations DROP CONSTRAINT IF EXISTS fk_donations_user_id;
ALTER TABLE tier_vehicle_selections DROP CONSTRAINT IF EXISTS fk_tier_vehicle_selections_user_id;
-- 5b. Drop old UNIQUE constraints involving VARCHAR columns
ALTER TABLE vehicles DROP CONSTRAINT IF EXISTS unique_user_vin;
ALTER TABLE saved_stations DROP CONSTRAINT IF EXISTS unique_user_station;
ALTER TABLE user_preferences DROP CONSTRAINT IF EXISTS user_preferences_user_id_key;
ALTER TABLE station_removal_reports DROP CONSTRAINT IF EXISTS unique_user_station_report;
-- 5c. Drop old indexes on VARCHAR columns
DROP INDEX IF EXISTS idx_vehicles_user_id;
DROP INDEX IF EXISTS idx_fuel_logs_user_id;
DROP INDEX IF EXISTS idx_maintenance_records_user_id;
DROP INDEX IF EXISTS idx_maintenance_schedules_user_id;
DROP INDEX IF EXISTS idx_documents_user_id;
DROP INDEX IF EXISTS idx_documents_user_vehicle;
DROP INDEX IF EXISTS idx_notification_logs_user_id;
DROP INDEX IF EXISTS idx_user_notifications_user_id;
DROP INDEX IF EXISTS idx_user_notifications_unread;
DROP INDEX IF EXISTS idx_user_preferences_user_id;
DROP INDEX IF EXISTS idx_saved_stations_user_id;
DROP INDEX IF EXISTS idx_audit_logs_user_created;
DROP INDEX IF EXISTS idx_ownership_costs_user_id;
DROP INDEX IF EXISTS idx_email_ingestion_queue_user_id;
DROP INDEX IF EXISTS idx_pending_vehicle_assoc_user_id;
DROP INDEX IF EXISTS idx_subscriptions_user_id;
DROP INDEX IF EXISTS idx_donations_user_id;
DROP INDEX IF EXISTS idx_tier_vehicle_selections_user_id;
DROP INDEX IF EXISTS idx_terms_agreements_user_id;
DROP INDEX IF EXISTS idx_community_stations_submitted_by;
DROP INDEX IF EXISTS idx_removal_reports_reported_by;
DROP INDEX IF EXISTS idx_admin_audit_logs_actor_id;
DROP INDEX IF EXISTS idx_admin_audit_logs_target_id;
DROP INDEX IF EXISTS idx_platform_change_log_changed_by;
-- 5d. Drop old VARCHAR user_id columns from feature tables
ALTER TABLE vehicles DROP COLUMN user_id;
ALTER TABLE fuel_logs DROP COLUMN user_id;
ALTER TABLE maintenance_records DROP COLUMN user_id;
ALTER TABLE maintenance_schedules DROP COLUMN user_id;
ALTER TABLE documents DROP COLUMN user_id;
ALTER TABLE notification_logs DROP COLUMN user_id;
ALTER TABLE user_notifications DROP COLUMN user_id;
ALTER TABLE user_preferences DROP COLUMN user_id;
ALTER TABLE saved_stations DROP COLUMN user_id;
ALTER TABLE audit_logs DROP COLUMN user_id;
ALTER TABLE ownership_costs DROP COLUMN user_id;
ALTER TABLE email_ingestion_queue DROP COLUMN user_id;
ALTER TABLE pending_vehicle_associations DROP COLUMN user_id;
ALTER TABLE subscriptions DROP COLUMN user_id;
ALTER TABLE donations DROP COLUMN user_id;
ALTER TABLE tier_vehicle_selections DROP COLUMN user_id;
ALTER TABLE terms_agreements DROP COLUMN user_id;
-- 5e. Rename user_profile_id -> user_id in feature tables
ALTER TABLE vehicles RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE fuel_logs RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE maintenance_records RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE maintenance_schedules RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE documents RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE notification_logs RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE user_notifications RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE user_preferences RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE saved_stations RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE audit_logs RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE ownership_costs RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE email_ingestion_queue RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE pending_vehicle_associations RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE subscriptions RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE donations RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE tier_vehicle_selections RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE terms_agreements RENAME COLUMN user_profile_id TO user_id;
-- 5f. Drop and rename special user columns
ALTER TABLE community_stations DROP COLUMN submitted_by;
ALTER TABLE community_stations RENAME COLUMN submitted_by_uuid TO submitted_by;
ALTER TABLE station_removal_reports DROP COLUMN reported_by;
ALTER TABLE station_removal_reports RENAME COLUMN reported_by_uuid TO reported_by;
-- 5g. Drop and rename admin-referencing columns
ALTER TABLE admin_users DROP COLUMN auth0_sub;
ALTER TABLE admin_users DROP COLUMN created_by;
ALTER TABLE admin_users RENAME COLUMN created_by_uuid TO created_by;
ALTER TABLE admin_audit_logs DROP COLUMN actor_admin_id;
ALTER TABLE admin_audit_logs DROP COLUMN target_admin_id;
ALTER TABLE admin_audit_logs RENAME COLUMN actor_admin_uuid TO actor_admin_id;
ALTER TABLE admin_audit_logs RENAME COLUMN target_admin_uuid TO target_admin_id;
ALTER TABLE community_stations DROP COLUMN reviewed_by;
ALTER TABLE community_stations RENAME COLUMN reviewed_by_uuid TO reviewed_by;
ALTER TABLE backup_history DROP COLUMN created_by;
ALTER TABLE backup_history RENAME COLUMN created_by_uuid TO created_by;
ALTER TABLE platform_change_log DROP COLUMN changed_by;
ALTER TABLE platform_change_log RENAME COLUMN changed_by_uuid TO changed_by;
ALTER TABLE user_profiles DROP COLUMN deactivated_by;
ALTER TABLE user_profiles RENAME COLUMN deactivated_by_uuid TO deactivated_by;
-- 5h. Recreate indexes on new UUID columns (feature tables)
CREATE INDEX idx_vehicles_user_id ON vehicles(user_id);
CREATE INDEX idx_fuel_logs_user_id ON fuel_logs(user_id);
CREATE INDEX idx_maintenance_records_user_id ON maintenance_records(user_id);
CREATE INDEX idx_maintenance_schedules_user_id ON maintenance_schedules(user_id);
CREATE INDEX idx_documents_user_id ON documents(user_id);
CREATE INDEX idx_documents_user_vehicle ON documents(user_id, vehicle_id);
CREATE INDEX idx_notification_logs_user_id ON notification_logs(user_id);
CREATE INDEX idx_user_notifications_user_id ON user_notifications(user_id);
CREATE INDEX idx_user_notifications_unread ON user_notifications(user_id, created_at DESC) WHERE is_read = false;
CREATE INDEX idx_user_preferences_user_id ON user_preferences(user_id);
CREATE INDEX idx_saved_stations_user_id ON saved_stations(user_id);
CREATE INDEX idx_audit_logs_user_created ON audit_logs(user_id, created_at DESC);
CREATE INDEX idx_ownership_costs_user_id ON ownership_costs(user_id);
CREATE INDEX idx_email_ingestion_queue_user_id ON email_ingestion_queue(user_id);
CREATE INDEX idx_pending_vehicle_assoc_user_id ON pending_vehicle_associations(user_id);
CREATE INDEX idx_subscriptions_user_id ON subscriptions(user_id);
CREATE INDEX idx_donations_user_id ON donations(user_id);
CREATE INDEX idx_tier_vehicle_selections_user_id ON tier_vehicle_selections(user_id);
CREATE INDEX idx_terms_agreements_user_id ON terms_agreements(user_id);
-- 5i. Recreate indexes on special columns
CREATE INDEX idx_community_stations_submitted_by ON community_stations(submitted_by);
CREATE INDEX idx_removal_reports_reported_by ON station_removal_reports(reported_by);
CREATE INDEX idx_admin_audit_logs_actor_id ON admin_audit_logs(actor_admin_id);
CREATE INDEX idx_admin_audit_logs_target_id ON admin_audit_logs(target_admin_id);
CREATE INDEX idx_platform_change_log_changed_by ON platform_change_log(changed_by);
-- 5j. Recreate UNIQUE constraints on new UUID columns
ALTER TABLE vehicles ADD CONSTRAINT unique_user_vin UNIQUE(user_id, vin);
ALTER TABLE saved_stations ADD CONSTRAINT unique_user_station UNIQUE(user_id, place_id);
ALTER TABLE user_preferences ADD CONSTRAINT user_preferences_user_id_key UNIQUE(user_id);
ALTER TABLE station_removal_reports ADD CONSTRAINT unique_user_station_report UNIQUE(station_id, reported_by);
COMMIT;

View File

@@ -17,7 +17,7 @@ const createRequest = (subscriptionTier?: string): Partial<FastifyRequest> => {
}
return {
userContext: {
userId: 'auth0|user123456789',
userId: '550e8400-e29b-41d4-a716-446655440000',
email: 'user@example.com',
emailVerified: true,
onboardingCompleted: true,

View File

@@ -58,9 +58,9 @@ const adminGuardPlugin: FastifyPluginAsync = async (fastify) => {
// Check if user is in admin_users table and not revoked
const query = `
SELECT auth0_sub, email, role, revoked_at
SELECT id, user_profile_id, email, role, revoked_at
FROM admin_users
WHERE auth0_sub = $1 AND revoked_at IS NULL
WHERE user_profile_id = $1 AND revoked_at IS NULL
LIMIT 1
`;

View File

@@ -121,11 +121,14 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
try {
await request.jwtVerify();
const userId = request.user?.sub;
if (!userId) {
// Two identifiers: auth0Sub (external, for Auth0 API) and userId (internal UUID, for all DB operations)
const auth0Sub = request.user?.sub;
if (!auth0Sub) {
throw new Error('Missing user ID in JWT');
}
let userId: string = auth0Sub; // Default to auth0Sub; overwritten with UUID after profile load
// Get or create user profile from database
let email = request.user?.email;
let displayName: string | undefined;
@@ -137,28 +140,29 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
// If JWT doesn't have email, fetch from Auth0 Management API
if (!email || email.includes('@unknown.local')) {
try {
const auth0User = await auth0ManagementClient.getUser(userId);
const auth0User = await auth0ManagementClient.getUser(auth0Sub);
if (auth0User.email) {
email = auth0User.email;
emailVerified = auth0User.emailVerified;
logger.info('Fetched email from Auth0 Management API', {
userId: userId.substring(0, 8) + '...',
userId: auth0Sub.substring(0, 8) + '...',
hasEmail: true,
});
}
} catch (auth0Error) {
logger.warn('Failed to fetch user from Auth0 Management API', {
userId: userId.substring(0, 8) + '...',
userId: auth0Sub.substring(0, 8) + '...',
error: auth0Error instanceof Error ? auth0Error.message : 'Unknown error',
});
}
}
// Get or create profile with correct email
const profile = await profileRepo.getOrCreate(userId, {
email: email || `${userId}@unknown.local`,
const profile = await profileRepo.getOrCreate(auth0Sub, {
email: email || `${auth0Sub}@unknown.local`,
displayName: request.user?.name || request.user?.nickname,
});
userId = profile.id;
// If profile has placeholder email but we now have real email, update it
if (profile.email.includes('@unknown.local') && email && !email.includes('@unknown.local')) {
@@ -178,7 +182,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
// Sync email verification status from Auth0 if needed
if (!emailVerified) {
try {
const isVerifiedInAuth0 = await auth0ManagementClient.checkEmailVerified(userId);
const isVerifiedInAuth0 = await auth0ManagementClient.checkEmailVerified(auth0Sub);
if (isVerifiedInAuth0 && !profile.emailVerified) {
await profileRepo.updateEmailVerified(userId, true);
emailVerified = true;
@@ -197,7 +201,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
} catch (profileError) {
// Log but don't fail auth if profile fetch fails
logger.warn('Failed to fetch user profile', {
userId: userId.substring(0, 8) + '...',
userId: auth0Sub.substring(0, 8) + '...',
error: profileError instanceof Error ? profileError.message : 'Unknown error',
});
// Fall back to JWT email if available

View File

@@ -26,7 +26,7 @@ describe('tier guard plugin', () => {
// Mock authenticate to set userContext
authenticateMock = jest.fn(async (request: FastifyRequest) => {
request.userContext = {
userId: 'auth0|user123',
userId: '550e8400-e29b-41d4-a716-446655440000',
email: 'user@example.com',
emailVerified: true,
onboardingCompleted: true,
@@ -48,7 +48,7 @@ describe('tier guard plugin', () => {
it('allows access when user tier meets minimum', async () => {
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
request.userContext = {
userId: 'auth0|user123',
userId: '550e8400-e29b-41d4-a716-446655440000',
email: 'user@example.com',
emailVerified: true,
onboardingCompleted: true,
@@ -71,7 +71,7 @@ describe('tier guard plugin', () => {
it('allows access when user tier exceeds minimum', async () => {
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
request.userContext = {
userId: 'auth0|user123',
userId: '550e8400-e29b-41d4-a716-446655440000',
email: 'user@example.com',
emailVerified: true,
onboardingCompleted: true,
@@ -130,7 +130,7 @@ describe('tier guard plugin', () => {
it('allows pro tier access to pro feature', async () => {
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
request.userContext = {
userId: 'auth0|user123',
userId: '550e8400-e29b-41d4-a716-446655440000',
email: 'user@example.com',
emailVerified: true,
onboardingCompleted: true,

View File

@@ -6,11 +6,12 @@
import { FastifyRequest, FastifyReply } from 'fastify';
import { AdminService } from '../domain/admin.service';
import { AdminRepository } from '../data/admin.repository';
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
import { pool } from '../../../core/config/database';
import { logger } from '../../../core/logging/logger';
import {
CreateAdminInput,
AdminAuth0SubInput,
AdminIdInput,
AuditLogsQueryInput,
BulkCreateAdminInput,
BulkRevokeAdminInput,
@@ -18,7 +19,7 @@ import {
} from './admin.validation';
import {
createAdminSchema,
adminAuth0SubSchema,
adminIdSchema,
auditLogsQuerySchema,
bulkCreateAdminSchema,
bulkRevokeAdminSchema,
@@ -33,10 +34,12 @@ import {
export class AdminController {
private adminService: AdminService;
private userProfileRepository: UserProfileRepository;
constructor() {
const repository = new AdminRepository(pool);
this.adminService = new AdminService(repository);
this.userProfileRepository = new UserProfileRepository(pool);
}
/**
@@ -47,49 +50,18 @@ export class AdminController {
const userId = request.userContext?.userId;
const userEmail = this.resolveUserEmail(request);
console.log('[DEBUG] Admin verify - userId:', userId);
console.log('[DEBUG] Admin verify - userEmail:', userEmail);
if (userEmail && request.userContext) {
request.userContext.email = userEmail.toLowerCase();
}
if (!userId && !userEmail) {
console.log('[DEBUG] Admin verify - No userId or userEmail, returning 401');
if (!userId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
let adminRecord = userId
? await this.adminService.getAdminByAuth0Sub(userId)
: null;
console.log('[DEBUG] Admin verify - adminRecord by auth0Sub:', adminRecord ? 'FOUND' : 'NOT FOUND');
// Fallback: attempt to resolve admin by email for legacy records
if (!adminRecord && userEmail) {
const emailMatch = await this.adminService.getAdminByEmail(userEmail.toLowerCase());
console.log('[DEBUG] Admin verify - emailMatch:', emailMatch ? 'FOUND' : 'NOT FOUND');
if (emailMatch) {
console.log('[DEBUG] Admin verify - emailMatch.auth0Sub:', emailMatch.auth0Sub);
console.log('[DEBUG] Admin verify - emailMatch.revokedAt:', emailMatch.revokedAt);
}
if (emailMatch && !emailMatch.revokedAt) {
// If the stored auth0Sub differs, link it to the authenticated user
if (userId && emailMatch.auth0Sub !== userId) {
console.log('[DEBUG] Admin verify - Calling linkAdminAuth0Sub to update auth0Sub');
adminRecord = await this.adminService.linkAdminAuth0Sub(userEmail, userId);
console.log('[DEBUG] Admin verify - adminRecord after link:', adminRecord ? 'SUCCESS' : 'FAILED');
} else {
console.log('[DEBUG] Admin verify - Using emailMatch as adminRecord');
adminRecord = emailMatch;
}
}
}
const adminRecord = await this.adminService.getAdminByUserProfileId(userId);
if (adminRecord && !adminRecord.revokedAt) {
if (request.userContext) {
@@ -97,12 +69,11 @@ export class AdminController {
request.userContext.adminRecord = adminRecord;
}
console.log('[DEBUG] Admin verify - Returning isAdmin: true');
// User is an active admin
return reply.code(200).send({
isAdmin: true,
adminRecord: {
auth0Sub: adminRecord.auth0Sub,
id: adminRecord.id,
userProfileId: adminRecord.userProfileId,
email: adminRecord.email,
role: adminRecord.role
}
@@ -114,14 +85,11 @@ export class AdminController {
request.userContext.adminRecord = undefined;
}
console.log('[DEBUG] Admin verify - Returning isAdmin: false');
// User is not an admin
return reply.code(200).send({
isAdmin: false,
adminRecord: null
});
} catch (error) {
console.log('[DEBUG] Admin verify - Error caught:', error instanceof Error ? error.message : 'Unknown error');
logger.error('Error verifying admin access', {
error: error instanceof Error ? error.message : 'Unknown error',
userId: request.userContext?.userId?.substring(0, 8) + '...'
@@ -139,9 +107,9 @@ export class AdminController {
*/
async listAdmins(request: FastifyRequest, reply: FastifyReply) {
try {
const actorId = request.userContext?.userId;
const actorUserProfileId = request.userContext?.userId;
if (!actorId) {
if (!actorUserProfileId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
@@ -150,11 +118,6 @@ export class AdminController {
const admins = await this.adminService.getAllAdmins();
// Log VIEW action
await this.adminService.getAdminByAuth0Sub(actorId);
// Note: Not logging VIEW as it would create excessive audit entries
// VIEW logging can be enabled if needed for compliance
return reply.code(200).send({
total: admins.length,
admins
@@ -162,7 +125,7 @@ export class AdminController {
} catch (error: any) {
logger.error('Error listing admins', {
error: error.message,
actorId: request.userContext?.userId
actorUserProfileId: request.userContext?.userId
});
return reply.code(500).send({
error: 'Internal server error',
@@ -179,15 +142,24 @@ export class AdminController {
reply: FastifyReply
) {
try {
const actorId = request.userContext?.userId;
const actorUserProfileId = request.userContext?.userId;
if (!actorId) {
if (!actorUserProfileId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
// Get actor's admin record to get admin ID
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
if (!actorAdmin) {
return reply.code(403).send({
error: 'Forbidden',
message: 'Actor is not an admin'
});
}
// Validate request body
const validation = createAdminSchema.safeParse(request.body);
if (!validation.success) {
@@ -200,23 +172,27 @@ export class AdminController {
const { email, role } = validation.data;
// Generate auth0Sub for the new admin
// In production, this should be the actual Auth0 user ID
// For now, we'll use email-based identifier
const auth0Sub = `auth0|${email.replace('@', '_at_')}`;
// Look up user profile by email to get UUID
const userProfile = await this.userProfileRepository.getByEmail(email);
if (!userProfile) {
return reply.code(404).send({
error: 'Not Found',
message: `No user profile found with email ${email}. User must sign up first.`
});
}
const admin = await this.adminService.createAdmin(
email,
role,
auth0Sub,
actorId
userProfile.id,
actorAdmin.id
);
return reply.code(201).send(admin);
} catch (error: any) {
logger.error('Error creating admin', {
error: error.message,
actorId: request.userContext?.userId
actorUserProfileId: request.userContext?.userId
});
if (error.message.includes('already exists')) {
@@ -234,36 +210,45 @@ export class AdminController {
}
/**
* PATCH /api/admin/admins/:auth0Sub/revoke - Revoke admin access
* PATCH /api/admin/admins/:id/revoke - Revoke admin access
*/
async revokeAdmin(
request: FastifyRequest<{ Params: AdminAuth0SubInput }>,
request: FastifyRequest<{ Params: AdminIdInput }>,
reply: FastifyReply
) {
try {
const actorId = request.userContext?.userId;
const actorUserProfileId = request.userContext?.userId;
if (!actorId) {
if (!actorUserProfileId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
// Get actor's admin record
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
if (!actorAdmin) {
return reply.code(403).send({
error: 'Forbidden',
message: 'Actor is not an admin'
});
}
// Validate params
const validation = adminAuth0SubSchema.safeParse(request.params);
const validation = adminIdSchema.safeParse(request.params);
if (!validation.success) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid auth0Sub parameter',
message: 'Invalid admin ID parameter',
details: validation.error.errors
});
}
const { auth0Sub } = validation.data;
const { id } = validation.data;
// Check if admin exists
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub);
const targetAdmin = await this.adminService.getAdminById(id);
if (!targetAdmin) {
return reply.code(404).send({
error: 'Not Found',
@@ -272,14 +257,14 @@ export class AdminController {
}
// Revoke the admin (service handles last admin check)
const admin = await this.adminService.revokeAdmin(auth0Sub, actorId);
const admin = await this.adminService.revokeAdmin(id, actorAdmin.id);
return reply.code(200).send(admin);
} catch (error: any) {
logger.error('Error revoking admin', {
error: error.message,
actorId: request.userContext?.userId,
targetAuth0Sub: request.params.auth0Sub
actorUserProfileId: request.userContext?.userId,
targetAdminId: (request.params as any).id
});
if (error.message.includes('Cannot revoke the last active admin')) {
@@ -304,36 +289,45 @@ export class AdminController {
}
/**
* PATCH /api/admin/admins/:auth0Sub/reinstate - Restore revoked admin
* PATCH /api/admin/admins/:id/reinstate - Restore revoked admin
*/
async reinstateAdmin(
request: FastifyRequest<{ Params: AdminAuth0SubInput }>,
request: FastifyRequest<{ Params: AdminIdInput }>,
reply: FastifyReply
) {
try {
const actorId = request.userContext?.userId;
const actorUserProfileId = request.userContext?.userId;
if (!actorId) {
if (!actorUserProfileId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
// Get actor's admin record
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
if (!actorAdmin) {
return reply.code(403).send({
error: 'Forbidden',
message: 'Actor is not an admin'
});
}
// Validate params
const validation = adminAuth0SubSchema.safeParse(request.params);
const validation = adminIdSchema.safeParse(request.params);
if (!validation.success) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid auth0Sub parameter',
message: 'Invalid admin ID parameter',
details: validation.error.errors
});
}
const { auth0Sub } = validation.data;
const { id } = validation.data;
// Check if admin exists
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub);
const targetAdmin = await this.adminService.getAdminById(id);
if (!targetAdmin) {
return reply.code(404).send({
error: 'Not Found',
@@ -342,14 +336,14 @@ export class AdminController {
}
// Reinstate the admin
const admin = await this.adminService.reinstateAdmin(auth0Sub, actorId);
const admin = await this.adminService.reinstateAdmin(id, actorAdmin.id);
return reply.code(200).send(admin);
} catch (error: any) {
logger.error('Error reinstating admin', {
error: error.message,
actorId: request.userContext?.userId,
targetAuth0Sub: request.params.auth0Sub
actorUserProfileId: request.userContext?.userId,
targetAdminId: (request.params as any).id
});
if (error.message.includes('not found')) {
@@ -418,15 +412,24 @@ export class AdminController {
reply: FastifyReply
) {
try {
const actorId = request.userContext?.userId;
const actorUserProfileId = request.userContext?.userId;
if (!actorId) {
if (!actorUserProfileId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
// Get actor's admin record
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
if (!actorAdmin) {
return reply.code(403).send({
error: 'Forbidden',
message: 'Actor is not an admin'
});
}
// Validate request body
const validation = bulkCreateAdminSchema.safeParse(request.body);
if (!validation.success) {
@@ -447,15 +450,21 @@ export class AdminController {
try {
const { email, role = 'admin' } = adminInput;
// Generate auth0Sub for the new admin
// In production, this should be the actual Auth0 user ID
const auth0Sub = `auth0|${email.replace('@', '_at_')}`;
// Look up user profile by email to get UUID
const userProfile = await this.userProfileRepository.getByEmail(email);
if (!userProfile) {
failed.push({
email,
error: `No user profile found with email ${email}. User must sign up first.`
});
continue;
}
const admin = await this.adminService.createAdmin(
email,
role,
auth0Sub,
actorId
userProfile.id,
actorAdmin.id
);
created.push(admin);
@@ -463,7 +472,7 @@ export class AdminController {
logger.error('Error creating admin in bulk operation', {
error: error.message,
email: adminInput.email,
actorId
actorAdminId: actorAdmin.id
});
failed.push({
@@ -485,7 +494,7 @@ export class AdminController {
} catch (error: any) {
logger.error('Error in bulk create admins', {
error: error.message,
actorId: request.userContext?.userId
actorUserProfileId: request.userContext?.userId
});
return reply.code(500).send({
@@ -503,15 +512,24 @@ export class AdminController {
reply: FastifyReply
) {
try {
const actorId = request.userContext?.userId;
const actorUserProfileId = request.userContext?.userId;
if (!actorId) {
if (!actorUserProfileId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
// Get actor's admin record
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
if (!actorAdmin) {
return reply.code(403).send({
error: 'Forbidden',
message: 'Actor is not an admin'
});
}
// Validate request body
const validation = bulkRevokeAdminSchema.safeParse(request.body);
if (!validation.success) {
@@ -522,37 +540,36 @@ export class AdminController {
});
}
const { auth0Subs } = validation.data;
const { ids } = validation.data;
const revoked: AdminUser[] = [];
const failed: Array<{ auth0Sub: string; error: string }> = [];
const failed: Array<{ id: string; error: string }> = [];
// Process each revocation sequentially to maintain data consistency
for (const auth0Sub of auth0Subs) {
for (const id of ids) {
try {
// Check if admin exists
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub);
const targetAdmin = await this.adminService.getAdminById(id);
if (!targetAdmin) {
failed.push({
auth0Sub,
id,
error: 'Admin user not found'
});
continue;
}
// Attempt to revoke the admin
const admin = await this.adminService.revokeAdmin(auth0Sub, actorId);
const admin = await this.adminService.revokeAdmin(id, actorAdmin.id);
revoked.push(admin);
} catch (error: any) {
logger.error('Error revoking admin in bulk operation', {
error: error.message,
auth0Sub,
actorId
adminId: id,
actorAdminId: actorAdmin.id
});
// Special handling for "last admin" constraint
failed.push({
auth0Sub,
id,
error: error.message || 'Failed to revoke admin'
});
}
@@ -570,7 +587,7 @@ export class AdminController {
} catch (error: any) {
logger.error('Error in bulk revoke admins', {
error: error.message,
actorId: request.userContext?.userId
actorUserProfileId: request.userContext?.userId
});
return reply.code(500).send({
@@ -588,15 +605,24 @@ export class AdminController {
reply: FastifyReply
) {
try {
const actorId = request.userContext?.userId;
const actorUserProfileId = request.userContext?.userId;
if (!actorId) {
if (!actorUserProfileId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
// Get actor's admin record
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
if (!actorAdmin) {
return reply.code(403).send({
error: 'Forbidden',
message: 'Actor is not an admin'
});
}
// Validate request body
const validation = bulkReinstateAdminSchema.safeParse(request.body);
if (!validation.success) {
@@ -607,36 +633,36 @@ export class AdminController {
});
}
const { auth0Subs } = validation.data;
const { ids } = validation.data;
const reinstated: AdminUser[] = [];
const failed: Array<{ auth0Sub: string; error: string }> = [];
const failed: Array<{ id: string; error: string }> = [];
// Process each reinstatement sequentially to maintain data consistency
for (const auth0Sub of auth0Subs) {
for (const id of ids) {
try {
// Check if admin exists
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub);
const targetAdmin = await this.adminService.getAdminById(id);
if (!targetAdmin) {
failed.push({
auth0Sub,
id,
error: 'Admin user not found'
});
continue;
}
// Attempt to reinstate the admin
const admin = await this.adminService.reinstateAdmin(auth0Sub, actorId);
const admin = await this.adminService.reinstateAdmin(id, actorAdmin.id);
reinstated.push(admin);
} catch (error: any) {
logger.error('Error reinstating admin in bulk operation', {
error: error.message,
auth0Sub,
actorId
adminId: id,
actorAdminId: actorAdmin.id
});
failed.push({
auth0Sub,
id,
error: error.message || 'Failed to reinstate admin'
});
}
@@ -654,7 +680,7 @@ export class AdminController {
} catch (error: any) {
logger.error('Error in bulk reinstate admins', {
error: error.message,
actorId: request.userContext?.userId
actorUserProfileId: request.userContext?.userId
});
return reply.code(500).send({
@@ -665,9 +691,6 @@ export class AdminController {
}
private resolveUserEmail(request: FastifyRequest): string | undefined {
console.log('[DEBUG] resolveUserEmail - request.userContext:', JSON.stringify(request.userContext, null, 2));
console.log('[DEBUG] resolveUserEmail - request.user:', JSON.stringify((request as any).user, null, 2));
const candidates: Array<string | undefined> = [
request.userContext?.email,
(request as any).user?.email,
@@ -676,15 +699,11 @@ export class AdminController {
(request as any).user?.preferred_username,
];
console.log('[DEBUG] resolveUserEmail - candidates:', candidates);
for (const value of candidates) {
if (typeof value === 'string' && value.includes('@')) {
console.log('[DEBUG] resolveUserEmail - found email:', value);
return value.trim();
}
}
console.log('[DEBUG] resolveUserEmail - no email found');
return undefined;
}
}

View File

@@ -8,7 +8,7 @@ import { AdminController } from './admin.controller';
import { UsersController } from './users.controller';
import {
CreateAdminInput,
AdminAuth0SubInput,
AdminIdInput,
BulkCreateAdminInput,
BulkRevokeAdminInput,
BulkReinstateAdminInput,
@@ -17,7 +17,7 @@ import {
} from './admin.validation';
import {
ListUsersQueryInput,
UserAuth0SubInput,
UserIdInput,
UpdateTierInput,
DeactivateUserInput,
UpdateProfileInput,
@@ -65,14 +65,14 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
handler: adminController.createAdmin.bind(adminController)
});
// PATCH /api/admin/admins/:auth0Sub/revoke - Revoke admin access
fastify.patch<{ Params: AdminAuth0SubInput }>('/admin/admins/:auth0Sub/revoke', {
// PATCH /api/admin/admins/:id/revoke - Revoke admin access
fastify.patch<{ Params: AdminIdInput }>('/admin/admins/:id/revoke', {
preHandler: [fastify.requireAdmin],
handler: adminController.revokeAdmin.bind(adminController)
});
// PATCH /api/admin/admins/:auth0Sub/reinstate - Restore revoked admin
fastify.patch<{ Params: AdminAuth0SubInput }>('/admin/admins/:auth0Sub/reinstate', {
// PATCH /api/admin/admins/:id/reinstate - Restore revoked admin
fastify.patch<{ Params: AdminIdInput }>('/admin/admins/:id/reinstate', {
preHandler: [fastify.requireAdmin],
handler: adminController.reinstateAdmin.bind(adminController)
});
@@ -117,50 +117,50 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
handler: usersController.listUsers.bind(usersController)
});
// GET /api/admin/users/:auth0Sub - Get single user details
fastify.get<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub', {
// GET /api/admin/users/:userId - Get single user details
fastify.get<{ Params: UserIdInput }>('/admin/users/:userId', {
preHandler: [fastify.requireAdmin],
handler: usersController.getUser.bind(usersController)
});
// GET /api/admin/users/:auth0Sub/vehicles - Get user's vehicles (admin view)
fastify.get<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub/vehicles', {
// GET /api/admin/users/:userId/vehicles - Get user's vehicles (admin view)
fastify.get<{ Params: UserIdInput }>('/admin/users/:userId/vehicles', {
preHandler: [fastify.requireAdmin],
handler: usersController.getUserVehicles.bind(usersController)
});
// PATCH /api/admin/users/:auth0Sub/tier - Update subscription tier
fastify.patch<{ Params: UserAuth0SubInput; Body: UpdateTierInput }>('/admin/users/:auth0Sub/tier', {
// PATCH /api/admin/users/:userId/tier - Update subscription tier
fastify.patch<{ Params: UserIdInput; Body: UpdateTierInput }>('/admin/users/:userId/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', {
// PATCH /api/admin/users/:userId/deactivate - Soft delete user
fastify.patch<{ Params: UserIdInput; Body: DeactivateUserInput }>('/admin/users/:userId/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', {
// PATCH /api/admin/users/:userId/reactivate - Restore deactivated user
fastify.patch<{ Params: UserIdInput }>('/admin/users/:userId/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', {
// PATCH /api/admin/users/:userId/profile - Update user email/displayName
fastify.patch<{ Params: UserIdInput; Body: UpdateProfileInput }>('/admin/users/:userId/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', {
// PATCH /api/admin/users/:userId/promote - Promote user to admin
fastify.patch<{ Params: UserIdInput; Body: PromoteToAdminInput }>('/admin/users/:userId/promote', {
preHandler: [fastify.requireAdmin],
handler: usersController.promoteToAdmin.bind(usersController)
});
// DELETE /api/admin/users/:auth0Sub - Hard delete user (permanent)
fastify.delete<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub', {
// DELETE /api/admin/users/:userId - Hard delete user (permanent)
fastify.delete<{ Params: UserIdInput }>('/admin/users/:userId', {
preHandler: [fastify.requireAdmin],
handler: usersController.hardDeleteUser.bind(usersController)
});

View File

@@ -10,8 +10,8 @@ export const createAdminSchema = z.object({
role: z.enum(['admin', 'super_admin']).default('admin'),
});
export const adminAuth0SubSchema = z.object({
auth0Sub: z.string().min(1, 'auth0Sub is required'),
export const adminIdSchema = z.object({
id: z.string().uuid('Invalid admin ID format'),
});
export const auditLogsQuerySchema = z.object({
@@ -29,14 +29,14 @@ export const bulkCreateAdminSchema = z.object({
});
export const bulkRevokeAdminSchema = z.object({
auth0Subs: z.array(z.string().min(1, 'auth0Sub cannot be empty'))
.min(1, 'At least one auth0Sub must be provided')
ids: z.array(z.string().uuid('Invalid admin ID format'))
.min(1, 'At least one admin ID must be provided')
.max(100, 'Maximum 100 admins per batch'),
});
export const bulkReinstateAdminSchema = z.object({
auth0Subs: z.array(z.string().min(1, 'auth0Sub cannot be empty'))
.min(1, 'At least one auth0Sub must be provided')
ids: z.array(z.string().uuid('Invalid admin ID format'))
.min(1, 'At least one admin ID must be provided')
.max(100, 'Maximum 100 admins per batch'),
});
@@ -49,7 +49,7 @@ export const bulkDeleteCatalogSchema = z.object({
});
export type CreateAdminInput = z.infer<typeof createAdminSchema>;
export type AdminAuth0SubInput = z.infer<typeof adminAuth0SubSchema>;
export type AdminIdInput = z.infer<typeof adminIdSchema>;
export type AuditLogsQueryInput = z.infer<typeof auditLogsQuerySchema>;
export type BulkCreateAdminInput = z.infer<typeof bulkCreateAdminSchema>;
export type BulkRevokeAdminInput = z.infer<typeof bulkRevokeAdminSchema>;

View File

@@ -14,13 +14,13 @@ import { pool } from '../../../core/config/database';
import { logger } from '../../../core/logging/logger';
import {
listUsersQuerySchema,
userAuth0SubSchema,
userIdSchema,
updateTierSchema,
deactivateUserSchema,
updateProfileSchema,
promoteToAdminSchema,
ListUsersQueryInput,
UserAuth0SubInput,
UserIdInput,
UpdateTierInput,
DeactivateUserInput,
UpdateProfileInput,
@@ -95,10 +95,10 @@ export class UsersController {
}
/**
* GET /api/admin/users/:auth0Sub/vehicles - Get user's vehicles (admin view)
* GET /api/admin/users/:userId/vehicles - Get user's vehicles (admin view)
*/
async getUserVehicles(
request: FastifyRequest<{ Params: UserAuth0SubInput }>,
request: FastifyRequest<{ Params: UserIdInput }>,
reply: FastifyReply
) {
try {
@@ -119,7 +119,7 @@ export class UsersController {
}
// Validate path param
const parseResult = userAuth0SubSchema.safeParse(request.params);
const parseResult = userIdSchema.safeParse(request.params);
if (!parseResult.success) {
return reply.code(400).send({
error: 'Validation error',
@@ -127,14 +127,14 @@ export class UsersController {
});
}
const { auth0Sub } = parseResult.data;
const vehicles = await this.userProfileRepository.getUserVehiclesForAdmin(auth0Sub);
const { userId } = parseResult.data;
const vehicles = await this.userProfileRepository.getUserVehiclesForAdmin(userId);
return reply.code(200).send({ vehicles });
} catch (error) {
logger.error('Error getting user vehicles', {
error: error instanceof Error ? error.message : 'Unknown error',
auth0Sub: request.params?.auth0Sub,
userId: (request.params as any)?.userId,
});
return reply.code(500).send({
@@ -186,10 +186,10 @@ export class UsersController {
}
/**
* GET /api/admin/users/:auth0Sub - Get single user details
* GET /api/admin/users/:userId - Get single user details
*/
async getUser(
request: FastifyRequest<{ Params: UserAuth0SubInput }>,
request: FastifyRequest<{ Params: UserIdInput }>,
reply: FastifyReply
) {
try {
@@ -202,7 +202,7 @@ export class UsersController {
}
// Validate path param
const parseResult = userAuth0SubSchema.safeParse(request.params);
const parseResult = userIdSchema.safeParse(request.params);
if (!parseResult.success) {
return reply.code(400).send({
error: 'Validation error',
@@ -210,8 +210,8 @@ export class UsersController {
});
}
const { auth0Sub } = parseResult.data;
const user = await this.userProfileService.getUserDetails(auth0Sub);
const { userId } = parseResult.data;
const user = await this.userProfileService.getUserDetails(userId);
if (!user) {
return reply.code(404).send({
@@ -224,7 +224,7 @@ export class UsersController {
} catch (error) {
logger.error('Error getting user details', {
error: error instanceof Error ? error.message : 'Unknown error',
auth0Sub: request.params?.auth0Sub,
userId: (request.params as any)?.userId,
});
return reply.code(500).send({
@@ -235,12 +235,12 @@ export class UsersController {
}
/**
* PATCH /api/admin/users/:auth0Sub/tier - Update subscription tier
* PATCH /api/admin/users/:userId/tier - Update subscription tier
* Uses subscriptionsService.adminOverrideTier() to sync both subscriptions.tier
* and user_profiles.subscription_tier atomically
*/
async updateTier(
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: UpdateTierInput }>,
request: FastifyRequest<{ Params: UserIdInput; Body: UpdateTierInput }>,
reply: FastifyReply
) {
try {
@@ -253,7 +253,7 @@ export class UsersController {
}
// Validate path param
const paramsResult = userAuth0SubSchema.safeParse(request.params);
const paramsResult = userIdSchema.safeParse(request.params);
if (!paramsResult.success) {
return reply.code(400).send({
error: 'Validation error',
@@ -270,11 +270,11 @@ export class UsersController {
});
}
const { auth0Sub } = paramsResult.data;
const { userId } = paramsResult.data;
const { subscriptionTier } = bodyResult.data;
// Verify user exists before attempting tier change
const currentUser = await this.userProfileService.getUserDetails(auth0Sub);
const currentUser = await this.userProfileService.getUserDetails(userId);
if (!currentUser) {
return reply.code(404).send({
error: 'Not found',
@@ -285,34 +285,34 @@ export class UsersController {
const previousTier = currentUser.subscriptionTier;
// Use subscriptionsService to update both tables atomically
await this.subscriptionsService.adminOverrideTier(auth0Sub, subscriptionTier);
await this.subscriptionsService.adminOverrideTier(userId, subscriptionTier);
// Log audit action
await this.adminRepository.logAuditAction(
actorId,
'UPDATE_TIER',
auth0Sub,
userId,
'user_profile',
currentUser.id,
{ previousTier, newTier: subscriptionTier }
);
logger.info('User subscription tier updated via admin', {
auth0Sub,
userId,
previousTier,
newTier: subscriptionTier,
actorAuth0Sub: actorId,
actorId,
});
// Return updated user profile
const updatedUser = await this.userProfileService.getUserDetails(auth0Sub);
const updatedUser = await this.userProfileService.getUserDetails(userId);
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,
userId: (request.params as any)?.userId,
});
if (errorMessage === 'User not found') {
@@ -330,10 +330,10 @@ export class UsersController {
}
/**
* PATCH /api/admin/users/:auth0Sub/deactivate - Soft delete user
* PATCH /api/admin/users/:userId/deactivate - Soft delete user
*/
async deactivateUser(
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: DeactivateUserInput }>,
request: FastifyRequest<{ Params: UserIdInput; Body: DeactivateUserInput }>,
reply: FastifyReply
) {
try {
@@ -346,7 +346,7 @@ export class UsersController {
}
// Validate path param
const paramsResult = userAuth0SubSchema.safeParse(request.params);
const paramsResult = userIdSchema.safeParse(request.params);
if (!paramsResult.success) {
return reply.code(400).send({
error: 'Validation error',
@@ -363,11 +363,11 @@ export class UsersController {
});
}
const { auth0Sub } = paramsResult.data;
const { userId } = paramsResult.data;
const { reason } = bodyResult.data;
const deactivatedUser = await this.userProfileService.deactivateUser(
auth0Sub,
userId,
actorId,
reason
);
@@ -378,7 +378,7 @@ export class UsersController {
logger.error('Error deactivating user', {
error: errorMessage,
auth0Sub: request.params?.auth0Sub,
userId: (request.params as any)?.userId,
});
if (errorMessage === 'User not found') {
@@ -410,10 +410,10 @@ export class UsersController {
}
/**
* PATCH /api/admin/users/:auth0Sub/reactivate - Restore deactivated user
* PATCH /api/admin/users/:userId/reactivate - Restore deactivated user
*/
async reactivateUser(
request: FastifyRequest<{ Params: UserAuth0SubInput }>,
request: FastifyRequest<{ Params: UserIdInput }>,
reply: FastifyReply
) {
try {
@@ -426,7 +426,7 @@ export class UsersController {
}
// Validate path param
const paramsResult = userAuth0SubSchema.safeParse(request.params);
const paramsResult = userIdSchema.safeParse(request.params);
if (!paramsResult.success) {
return reply.code(400).send({
error: 'Validation error',
@@ -434,10 +434,10 @@ export class UsersController {
});
}
const { auth0Sub } = paramsResult.data;
const { userId } = paramsResult.data;
const reactivatedUser = await this.userProfileService.reactivateUser(
auth0Sub,
userId,
actorId
);
@@ -447,7 +447,7 @@ export class UsersController {
logger.error('Error reactivating user', {
error: errorMessage,
auth0Sub: request.params?.auth0Sub,
userId: (request.params as any)?.userId,
});
if (errorMessage === 'User not found') {
@@ -472,10 +472,10 @@ export class UsersController {
}
/**
* PATCH /api/admin/users/:auth0Sub/profile - Update user email/displayName
* PATCH /api/admin/users/:userId/profile - Update user email/displayName
*/
async updateProfile(
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: UpdateProfileInput }>,
request: FastifyRequest<{ Params: UserIdInput; Body: UpdateProfileInput }>,
reply: FastifyReply
) {
try {
@@ -488,7 +488,7 @@ export class UsersController {
}
// Validate path param
const paramsResult = userAuth0SubSchema.safeParse(request.params);
const paramsResult = userIdSchema.safeParse(request.params);
if (!paramsResult.success) {
return reply.code(400).send({
error: 'Validation error',
@@ -505,11 +505,11 @@ export class UsersController {
});
}
const { auth0Sub } = paramsResult.data;
const { userId } = paramsResult.data;
const updates = bodyResult.data;
const updatedUser = await this.userProfileService.adminUpdateProfile(
auth0Sub,
userId,
updates,
actorId
);
@@ -520,7 +520,7 @@ export class UsersController {
logger.error('Error updating user profile', {
error: errorMessage,
auth0Sub: request.params?.auth0Sub,
userId: (request.params as any)?.userId,
});
if (errorMessage === 'User not found') {
@@ -538,10 +538,10 @@ export class UsersController {
}
/**
* PATCH /api/admin/users/:auth0Sub/promote - Promote user to admin
* PATCH /api/admin/users/:userId/promote - Promote user to admin
*/
async promoteToAdmin(
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: PromoteToAdminInput }>,
request: FastifyRequest<{ Params: UserIdInput; Body: PromoteToAdminInput }>,
reply: FastifyReply
) {
try {
@@ -554,7 +554,7 @@ export class UsersController {
}
// Validate path param
const paramsResult = userAuth0SubSchema.safeParse(request.params);
const paramsResult = userIdSchema.safeParse(request.params);
if (!paramsResult.success) {
return reply.code(400).send({
error: 'Validation error',
@@ -571,11 +571,11 @@ export class UsersController {
});
}
const { auth0Sub } = paramsResult.data;
const { userId } = 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);
// Get the user profile to verify they exist and get their email
const user = await this.userProfileService.getUserDetails(userId);
if (!user) {
return reply.code(404).send({
error: 'Not found',
@@ -591,12 +591,15 @@ export class UsersController {
});
}
// Create the admin record using the user's real auth0Sub
// Get actor's admin record for audit trail
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorId);
// Create the admin record using the user's UUID
const adminUser = await this.adminService.createAdmin(
user.email,
role,
auth0Sub, // Use the real auth0Sub from the user profile
actorId
userId,
actorAdmin?.id || actorId
);
return reply.code(201).send(adminUser);
@@ -605,7 +608,7 @@ export class UsersController {
logger.error('Error promoting user to admin', {
error: errorMessage,
auth0Sub: request.params?.auth0Sub,
userId: (request.params as any)?.userId,
});
if (errorMessage.includes('already exists')) {
@@ -623,10 +626,10 @@ export class UsersController {
}
/**
* DELETE /api/admin/users/:auth0Sub - Hard delete user (permanent)
* DELETE /api/admin/users/:userId - Hard delete user (permanent)
*/
async hardDeleteUser(
request: FastifyRequest<{ Params: UserAuth0SubInput }>,
request: FastifyRequest<{ Params: UserIdInput }>,
reply: FastifyReply
) {
try {
@@ -639,7 +642,7 @@ export class UsersController {
}
// Validate path param
const paramsResult = userAuth0SubSchema.safeParse(request.params);
const paramsResult = userIdSchema.safeParse(request.params);
if (!paramsResult.success) {
return reply.code(400).send({
error: 'Validation error',
@@ -647,14 +650,14 @@ export class UsersController {
});
}
const { auth0Sub } = paramsResult.data;
const { userId } = paramsResult.data;
// Optional reason from query params
const reason = (request.query as any)?.reason;
// Hard delete user
await this.userProfileService.adminHardDeleteUser(
auth0Sub,
userId,
actorId,
reason
);
@@ -667,7 +670,7 @@ export class UsersController {
logger.error('Error hard deleting user', {
error: errorMessage,
auth0Sub: request.params?.auth0Sub,
userId: (request.params as any)?.userId,
});
if (errorMessage === 'Cannot delete your own account') {

View File

@@ -19,9 +19,9 @@ export const listUsersQuerySchema = z.object({
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'),
// Path param for user UUID
export const userIdSchema = z.object({
userId: z.string().uuid('Invalid user ID format'),
});
// Body for updating subscription tier
@@ -50,7 +50,7 @@ export const promoteToAdminSchema = z.object({
// Type exports
export type ListUsersQueryInput = z.infer<typeof listUsersQuerySchema>;
export type UserAuth0SubInput = z.infer<typeof userAuth0SubSchema>;
export type UserIdInput = z.infer<typeof userIdSchema>;
export type UpdateTierInput = z.infer<typeof updateTierSchema>;
export type DeactivateUserInput = z.infer<typeof deactivateUserSchema>;
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;

View File

@@ -10,29 +10,49 @@ import { logger } from '../../../core/logging/logger';
export class AdminRepository {
constructor(private pool: Pool) {}
async getAdminByAuth0Sub(auth0Sub: string): Promise<AdminUser | null> {
async getAdminById(id: string): Promise<AdminUser | null> {
const query = `
SELECT auth0_sub, email, role, created_at, created_by, revoked_at, updated_at
SELECT id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
FROM admin_users
WHERE auth0_sub = $1
WHERE id = $1
LIMIT 1
`;
try {
const result = await this.pool.query(query, [auth0Sub]);
const result = await this.pool.query(query, [id]);
if (result.rows.length === 0) {
return null;
}
return this.mapRowToAdminUser(result.rows[0]);
} catch (error) {
logger.error('Error fetching admin by auth0_sub', { error, auth0Sub });
logger.error('Error fetching admin by id', { error, id });
throw error;
}
}
async getAdminByUserProfileId(userProfileId: string): Promise<AdminUser | null> {
const query = `
SELECT id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
FROM admin_users
WHERE user_profile_id = $1
LIMIT 1
`;
try {
const result = await this.pool.query(query, [userProfileId]);
if (result.rows.length === 0) {
return null;
}
return this.mapRowToAdminUser(result.rows[0]);
} catch (error) {
logger.error('Error fetching admin by user_profile_id', { error, userProfileId });
throw error;
}
}
async getAdminByEmail(email: string): Promise<AdminUser | null> {
const query = `
SELECT auth0_sub, email, role, created_at, created_by, revoked_at, updated_at
SELECT id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
FROM admin_users
WHERE LOWER(email) = LOWER($1)
LIMIT 1
@@ -52,7 +72,7 @@ export class AdminRepository {
async getAllAdmins(): Promise<AdminUser[]> {
const query = `
SELECT auth0_sub, email, role, created_at, created_by, revoked_at, updated_at
SELECT id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
FROM admin_users
ORDER BY created_at DESC
`;
@@ -68,7 +88,7 @@ export class AdminRepository {
async getActiveAdmins(): Promise<AdminUser[]> {
const query = `
SELECT auth0_sub, email, role, created_at, created_by, revoked_at, updated_at
SELECT id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
FROM admin_users
WHERE revoked_at IS NULL
ORDER BY created_at DESC
@@ -83,61 +103,61 @@ export class AdminRepository {
}
}
async createAdmin(auth0Sub: string, email: string, role: string, createdBy: string): Promise<AdminUser> {
async createAdmin(userProfileId: string, email: string, role: string, createdBy: string): Promise<AdminUser> {
const query = `
INSERT INTO admin_users (auth0_sub, email, role, created_by)
INSERT INTO admin_users (user_profile_id, email, role, created_by)
VALUES ($1, $2, $3, $4)
RETURNING auth0_sub, email, role, created_at, created_by, revoked_at, updated_at
RETURNING id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
`;
try {
const result = await this.pool.query(query, [auth0Sub, email, role, createdBy]);
const result = await this.pool.query(query, [userProfileId, email, role, createdBy]);
if (result.rows.length === 0) {
throw new Error('Failed to create admin user');
}
return this.mapRowToAdminUser(result.rows[0]);
} catch (error) {
logger.error('Error creating admin', { error, auth0Sub, email });
logger.error('Error creating admin', { error, userProfileId, email });
throw error;
}
}
async revokeAdmin(auth0Sub: string): Promise<AdminUser> {
async revokeAdmin(id: string): Promise<AdminUser> {
const query = `
UPDATE admin_users
SET revoked_at = CURRENT_TIMESTAMP
WHERE auth0_sub = $1
RETURNING auth0_sub, email, role, created_at, created_by, revoked_at, updated_at
WHERE id = $1
RETURNING id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
`;
try {
const result = await this.pool.query(query, [auth0Sub]);
const result = await this.pool.query(query, [id]);
if (result.rows.length === 0) {
throw new Error('Admin user not found');
}
return this.mapRowToAdminUser(result.rows[0]);
} catch (error) {
logger.error('Error revoking admin', { error, auth0Sub });
logger.error('Error revoking admin', { error, id });
throw error;
}
}
async reinstateAdmin(auth0Sub: string): Promise<AdminUser> {
async reinstateAdmin(id: string): Promise<AdminUser> {
const query = `
UPDATE admin_users
SET revoked_at = NULL
WHERE auth0_sub = $1
RETURNING auth0_sub, email, role, created_at, created_by, revoked_at, updated_at
WHERE id = $1
RETURNING id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
`;
try {
const result = await this.pool.query(query, [auth0Sub]);
const result = await this.pool.query(query, [id]);
if (result.rows.length === 0) {
throw new Error('Admin user not found');
}
return this.mapRowToAdminUser(result.rows[0]);
} catch (error) {
logger.error('Error reinstating admin', { error, auth0Sub });
logger.error('Error reinstating admin', { error, id });
throw error;
}
}
@@ -202,30 +222,11 @@ export class AdminRepository {
}
}
async updateAuth0SubByEmail(email: string, auth0Sub: string): Promise<AdminUser> {
const query = `
UPDATE admin_users
SET auth0_sub = $1,
updated_at = CURRENT_TIMESTAMP
WHERE LOWER(email) = LOWER($2)
RETURNING auth0_sub, email, role, created_at, created_by, revoked_at, updated_at
`;
try {
const result = await this.pool.query(query, [auth0Sub, email]);
if (result.rows.length === 0) {
throw new Error(`Admin user with email ${email} not found`);
}
return this.mapRowToAdminUser(result.rows[0]);
} catch (error) {
logger.error('Error updating admin auth0_sub by email', { error, email, auth0Sub });
throw error;
}
}
private mapRowToAdminUser(row: any): AdminUser {
return {
auth0Sub: row.auth0_sub,
id: row.id,
userProfileId: row.user_profile_id,
email: row.email,
role: row.role,
createdAt: new Date(row.created_at),

View File

@@ -11,11 +11,20 @@ import { auditLogService } from '../../audit-log';
export class AdminService {
constructor(private repository: AdminRepository) {}
async getAdminByAuth0Sub(auth0Sub: string): Promise<AdminUser | null> {
async getAdminById(id: string): Promise<AdminUser | null> {
try {
return await this.repository.getAdminByAuth0Sub(auth0Sub);
return await this.repository.getAdminById(id);
} catch (error) {
logger.error('Error getting admin by auth0_sub', { error });
logger.error('Error getting admin by id', { error });
throw error;
}
}
async getAdminByUserProfileId(userProfileId: string): Promise<AdminUser | null> {
try {
return await this.repository.getAdminByUserProfileId(userProfileId);
} catch (error) {
logger.error('Error getting admin by user_profile_id', { error });
throw error;
}
}
@@ -47,7 +56,7 @@ export class AdminService {
}
}
async createAdmin(email: string, role: string, auth0Sub: string, createdBy: string): Promise<AdminUser> {
async createAdmin(email: string, role: string, userProfileId: string, createdByAdminId: string): Promise<AdminUser> {
try {
// Check if admin already exists
const normalizedEmail = email.trim().toLowerCase();
@@ -57,10 +66,10 @@ export class AdminService {
}
// Create new admin
const admin = await this.repository.createAdmin(auth0Sub, normalizedEmail, role, createdBy);
const admin = await this.repository.createAdmin(userProfileId, normalizedEmail, role, createdByAdminId);
// Log audit action (legacy)
await this.repository.logAuditAction(createdBy, 'CREATE', admin.auth0Sub, 'admin_user', admin.email, {
await this.repository.logAuditAction(createdByAdminId, 'CREATE', admin.id, 'admin_user', admin.email, {
email,
role
});
@@ -68,10 +77,10 @@ export class AdminService {
// Log to unified audit log
await auditLogService.info(
'admin',
createdBy,
userProfileId,
`Admin user created: ${admin.email}`,
'admin_user',
admin.auth0Sub,
admin.id,
{ email: admin.email, role }
).catch(err => logger.error('Failed to log admin create audit event', { error: err }));
@@ -83,7 +92,7 @@ export class AdminService {
}
}
async revokeAdmin(auth0Sub: string, revokedBy: string): Promise<AdminUser> {
async revokeAdmin(id: string, revokedByAdminId: string): Promise<AdminUser> {
try {
// Check that at least one active admin will remain
const activeAdmins = await this.repository.getActiveAdmins();
@@ -92,51 +101,51 @@ export class AdminService {
}
// Revoke the admin
const admin = await this.repository.revokeAdmin(auth0Sub);
const admin = await this.repository.revokeAdmin(id);
// Log audit action (legacy)
await this.repository.logAuditAction(revokedBy, 'REVOKE', auth0Sub, 'admin_user', admin.email);
await this.repository.logAuditAction(revokedByAdminId, 'REVOKE', id, 'admin_user', admin.email);
// Log to unified audit log
await auditLogService.info(
'admin',
revokedBy,
admin.userProfileId,
`Admin user revoked: ${admin.email}`,
'admin_user',
auth0Sub,
id,
{ email: admin.email }
).catch(err => logger.error('Failed to log admin revoke audit event', { error: err }));
logger.info('Admin user revoked', { auth0Sub, email: admin.email });
logger.info('Admin user revoked', { id, email: admin.email });
return admin;
} catch (error) {
logger.error('Error revoking admin', { error, auth0Sub });
logger.error('Error revoking admin', { error, id });
throw error;
}
}
async reinstateAdmin(auth0Sub: string, reinstatedBy: string): Promise<AdminUser> {
async reinstateAdmin(id: string, reinstatedByAdminId: string): Promise<AdminUser> {
try {
// Reinstate the admin
const admin = await this.repository.reinstateAdmin(auth0Sub);
const admin = await this.repository.reinstateAdmin(id);
// Log audit action (legacy)
await this.repository.logAuditAction(reinstatedBy, 'REINSTATE', auth0Sub, 'admin_user', admin.email);
await this.repository.logAuditAction(reinstatedByAdminId, 'REINSTATE', id, 'admin_user', admin.email);
// Log to unified audit log
await auditLogService.info(
'admin',
reinstatedBy,
admin.userProfileId,
`Admin user reinstated: ${admin.email}`,
'admin_user',
auth0Sub,
id,
{ email: admin.email }
).catch(err => logger.error('Failed to log admin reinstate audit event', { error: err }));
logger.info('Admin user reinstated', { auth0Sub, email: admin.email });
logger.info('Admin user reinstated', { id, email: admin.email });
return admin;
} catch (error) {
logger.error('Error reinstating admin', { error, auth0Sub });
logger.error('Error reinstating admin', { error, id });
throw error;
}
}
@@ -150,12 +159,4 @@ export class AdminService {
}
}
async linkAdminAuth0Sub(email: string, auth0Sub: string): Promise<AdminUser> {
try {
return await this.repository.updateAuth0SubByEmail(email.trim().toLowerCase(), auth0Sub);
} catch (error) {
logger.error('Error linking admin auth0_sub to email', { error, email, auth0Sub });
throw error;
}
}
}

View File

@@ -4,7 +4,8 @@
*/
export interface AdminUser {
auth0Sub: string;
id: string;
userProfileId: string;
email: string;
role: 'admin' | 'super_admin';
createdAt: Date;
@@ -19,11 +20,11 @@ export interface CreateAdminRequest {
}
export interface RevokeAdminRequest {
auth0Sub: string;
id: string;
}
export interface ReinstateAdminRequest {
auth0Sub: string;
id: string;
}
export interface AdminAuditLog {
@@ -71,25 +72,25 @@ export interface BulkCreateAdminResponse {
}
export interface BulkRevokeAdminRequest {
auth0Subs: string[];
ids: string[];
}
export interface BulkRevokeAdminResponse {
revoked: AdminUser[];
failed: Array<{
auth0Sub: string;
id: string;
error: string;
}>;
}
export interface BulkReinstateAdminRequest {
auth0Subs: string[];
ids: string[];
}
export interface BulkReinstateAdminResponse {
reinstated: AdminUser[];
failed: Array<{
auth0Sub: string;
id: string;
error: string;
}>;
}

View File

@@ -4,18 +4,19 @@
*/
import request from 'supertest';
import { app } from '../../../../app';
import { buildApp } from '../../../../app';
import pool from '../../../../core/config/database';
import { FastifyInstance } from 'fastify';
import { readFileSync } from 'fs';
import { join } from 'path';
import fastifyPlugin from 'fastify-plugin';
import { setAdminGuardPool } from '../../../../core/plugins/admin-guard.plugin';
const DEFAULT_ADMIN_SUB = 'test-admin-123';
const DEFAULT_ADMIN_ID = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const DEFAULT_ADMIN_EMAIL = 'test-admin@motovaultpro.com';
let currentUser = {
sub: DEFAULT_ADMIN_SUB,
sub: 'auth0|test-admin-123',
email: DEFAULT_ADMIN_EMAIL,
};
@@ -25,11 +26,15 @@ jest.mock('../../../../core/plugins/auth.plugin', () => {
default: fastifyPlugin(async function(fastify) {
fastify.decorate('authenticate', async function(request, _reply) {
// Inject dynamic test user context
// JWT sub is still auth0|xxx format
request.user = { sub: currentUser.sub };
request.userContext = {
userId: currentUser.sub,
userId: DEFAULT_ADMIN_ID,
email: currentUser.email,
emailVerified: true,
onboardingCompleted: true,
isAdmin: false, // Will be set by admin guard
subscriptionTier: 'free',
};
});
}, { name: 'auth-plugin' })
@@ -37,10 +42,14 @@ jest.mock('../../../../core/plugins/auth.plugin', () => {
});
describe('Admin Management Integration Tests', () => {
let testAdminAuth0Sub: string;
let testNonAdminAuth0Sub: string;
let app: FastifyInstance;
let testAdminId: string;
beforeAll(async () => {
// Build the app
app = await buildApp();
await app.ready();
// Run the admin migration directly using the migration file
const migrationFile = join(__dirname, '../../migrations/001_create_admin_users.sql');
const migrationSQL = readFileSync(migrationFile, 'utf-8');
@@ -50,33 +59,31 @@ describe('Admin Management Integration Tests', () => {
setAdminGuardPool(pool);
// Create test admin user
testAdminAuth0Sub = DEFAULT_ADMIN_SUB;
testAdminId = DEFAULT_ADMIN_ID;
await pool.query(`
INSERT INTO admin_users (auth0_sub, email, role, created_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (auth0_sub) DO NOTHING
`, [testAdminAuth0Sub, DEFAULT_ADMIN_EMAIL, 'admin', 'system']);
// Create test non-admin auth0Sub for permission tests
testNonAdminAuth0Sub = 'test-non-admin-456';
INSERT INTO admin_users (id, user_profile_id, email, role, created_by)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_profile_id) DO NOTHING
`, [testAdminId, testAdminId, DEFAULT_ADMIN_EMAIL, 'admin', 'system']);
});
afterAll(async () => {
// Clean up test database
await pool.query('DROP TABLE IF EXISTS admin_audit_logs CASCADE');
await pool.query('DROP TABLE IF EXISTS admin_users CASCADE');
await app.close();
await pool.end();
});
beforeEach(async () => {
// Clean up test data before each test (except the test admin)
await pool.query(
'DELETE FROM admin_users WHERE auth0_sub != $1 AND auth0_sub != $2',
[testAdminAuth0Sub, 'system|bootstrap']
'DELETE FROM admin_users WHERE user_profile_id != $1',
[testAdminId]
);
await pool.query('DELETE FROM admin_audit_logs');
currentUser = {
sub: DEFAULT_ADMIN_SUB,
sub: 'auth0|test-admin-123',
email: DEFAULT_ADMIN_EMAIL,
};
});
@@ -85,11 +92,11 @@ describe('Admin Management Integration Tests', () => {
it('should reject non-admin user trying to list admins', async () => {
// Create mock for non-admin user
currentUser = {
sub: testNonAdminAuth0Sub,
sub: 'auth0|test-non-admin-456',
email: 'test-user@example.com',
};
const response = await request(app)
const response = await request(app.server)
.get('/api/admin/admins')
.expect(403);
@@ -101,51 +108,51 @@ describe('Admin Management Integration Tests', () => {
describe('GET /api/admin/verify', () => {
it('should confirm admin access for existing admin', async () => {
currentUser = {
sub: testAdminAuth0Sub,
sub: 'auth0|test-admin-123',
email: DEFAULT_ADMIN_EMAIL,
};
const response = await request(app)
const response = await request(app.server)
.get('/api/admin/verify')
.expect(200);
expect(response.body.isAdmin).toBe(true);
expect(response.body.adminRecord).toMatchObject({
auth0Sub: testAdminAuth0Sub,
id: testAdminId,
email: DEFAULT_ADMIN_EMAIL,
});
});
it('should link admin record by email when auth0_sub differs', async () => {
const placeholderSub = 'auth0|placeholder-sub';
const realSub = 'auth0|real-admin-sub';
it('should link admin record by email when user_profile_id differs', async () => {
const placeholderId = '9b9a1234-1234-1234-1234-123456789abc';
const realId = 'a1b2c3d4-5678-90ab-cdef-123456789def';
const email = 'link-admin@example.com';
await pool.query(`
INSERT INTO admin_users (auth0_sub, email, role, created_by)
VALUES ($1, $2, $3, $4)
`, [placeholderSub, email, 'admin', testAdminAuth0Sub]);
INSERT INTO admin_users (id, user_profile_id, email, role, created_by)
VALUES ($1, $2, $3, $4, $5)
`, [placeholderId, placeholderId, email, 'admin', testAdminId]);
currentUser = {
sub: realSub,
sub: 'auth0|real-admin-sub',
email,
};
const response = await request(app)
const response = await request(app.server)
.get('/api/admin/verify')
.expect(200);
expect(response.body.isAdmin).toBe(true);
expect(response.body.adminRecord).toMatchObject({
auth0Sub: realSub,
userProfileId: realId,
email,
});
const record = await pool.query(
'SELECT auth0_sub FROM admin_users WHERE email = $1',
'SELECT user_profile_id FROM admin_users WHERE email = $1',
[email]
);
expect(record.rows[0].auth0_sub).toBe(realSub);
expect(record.rows[0].user_profile_id).toBe(realId);
});
it('should return non-admin response for unknown user', async () => {
@@ -154,7 +161,7 @@ describe('Admin Management Integration Tests', () => {
email: 'non-admin@example.com',
};
const response = await request(app)
const response = await request(app.server)
.get('/api/admin/verify')
.expect(200);
@@ -166,17 +173,19 @@ describe('Admin Management Integration Tests', () => {
describe('GET /api/admin/admins', () => {
it('should list all admin users', async () => {
// Create additional test admins
const admin1Id = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
const admin2Id = '8f14e45f-ceea-367f-a27f-c9a6d0c67e0e';
await pool.query(`
INSERT INTO admin_users (auth0_sub, email, role, created_by)
INSERT INTO admin_users (id, user_profile_id, email, role, created_by)
VALUES
($1, $2, $3, $4),
($5, $6, $7, $8)
($1, $2, $3, $4, $5),
($6, $7, $8, $9, $10)
`, [
'auth0|admin1', 'admin1@example.com', 'admin', testAdminAuth0Sub,
'auth0|admin2', 'admin2@example.com', 'super_admin', testAdminAuth0Sub
admin1Id, admin1Id, 'admin1@example.com', 'admin', testAdminId,
admin2Id, admin2Id, 'admin2@example.com', 'super_admin', testAdminId
]);
const response = await request(app)
const response = await request(app.server)
.get('/api/admin/admins')
.expect(200);
@@ -184,7 +193,7 @@ describe('Admin Management Integration Tests', () => {
expect(response.body).toHaveProperty('admins');
expect(response.body.admins.length).toBeGreaterThanOrEqual(3); // At least test admin + 2 created
expect(response.body.admins[0]).toMatchObject({
auth0Sub: expect.any(String),
id: expect.any(String),
email: expect.any(String),
role: expect.stringMatching(/^(admin|super_admin)$/),
createdAt: expect.any(String),
@@ -194,12 +203,13 @@ describe('Admin Management Integration Tests', () => {
it('should include revoked admins in the list', async () => {
// Create and revoke an admin
const revokedId = 'f1e2d3c4-b5a6-9788-6543-210fedcba987';
await pool.query(`
INSERT INTO admin_users (auth0_sub, email, role, created_by, revoked_at)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
`, ['auth0|revoked', 'revoked@example.com', 'admin', testAdminAuth0Sub]);
INSERT INTO admin_users (id, user_profile_id, email, role, created_by, revoked_at)
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
`, [revokedId, revokedId, 'revoked@example.com', 'admin', testAdminId]);
const response = await request(app)
const response = await request(app.server)
.get('/api/admin/admins')
.expect(200);
@@ -218,17 +228,17 @@ describe('Admin Management Integration Tests', () => {
role: 'admin'
};
const response = await request(app)
const response = await request(app.server)
.post('/api/admin/admins')
.send(newAdminData)
.expect(201);
expect(response.body).toMatchObject({
auth0Sub: expect.any(String),
id: expect.any(String),
email: 'newadmin@example.com',
role: 'admin',
createdAt: expect.any(String),
createdBy: testAdminAuth0Sub,
createdBy: testAdminId,
revokedAt: null
});
@@ -238,7 +248,7 @@ describe('Admin Management Integration Tests', () => {
['CREATE', 'newadmin@example.com']
);
expect(auditResult.rows.length).toBe(1);
expect(auditResult.rows[0].actor_admin_id).toBe(testAdminAuth0Sub);
expect(auditResult.rows[0].actor_admin_id).toBe(testAdminId);
});
it('should reject invalid email', async () => {
@@ -247,7 +257,7 @@ describe('Admin Management Integration Tests', () => {
role: 'admin'
};
const response = await request(app)
const response = await request(app.server)
.post('/api/admin/admins')
.send(invalidData)
.expect(400);
@@ -263,13 +273,13 @@ describe('Admin Management Integration Tests', () => {
};
// Create first admin
await request(app)
await request(app.server)
.post('/api/admin/admins')
.send(adminData)
.expect(201);
// Try to create duplicate
const response = await request(app)
const response = await request(app.server)
.post('/api/admin/admins')
.send(adminData)
.expect(400);
@@ -284,7 +294,7 @@ describe('Admin Management Integration Tests', () => {
role: 'super_admin'
};
const response = await request(app)
const response = await request(app.server)
.post('/api/admin/admins')
.send(superAdminData)
.expect(201);
@@ -297,7 +307,7 @@ describe('Admin Management Integration Tests', () => {
email: 'defaultrole@example.com'
};
const response = await request(app)
const response = await request(app.server)
.post('/api/admin/admins')
.send(adminData)
.expect(201);
@@ -306,23 +316,24 @@ describe('Admin Management Integration Tests', () => {
});
});
describe('PATCH /api/admin/admins/:auth0Sub/revoke', () => {
describe('PATCH /api/admin/admins/:id/revoke', () => {
it('should revoke admin access', async () => {
// Create admin to revoke
const toRevokeId = 'b1c2d3e4-f5a6-7890-1234-567890abcdef';
const createResult = await pool.query(`
INSERT INTO admin_users (auth0_sub, email, role, created_by)
VALUES ($1, $2, $3, $4)
RETURNING auth0_sub
`, ['auth0|to-revoke', 'torevoke@example.com', 'admin', testAdminAuth0Sub]);
INSERT INTO admin_users (id, user_profile_id, email, role, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, [toRevokeId, toRevokeId, 'torevoke@example.com', 'admin', testAdminId]);
const auth0Sub = createResult.rows[0].auth0_sub;
const adminId = createResult.rows[0].id;
const response = await request(app)
.patch(`/api/admin/admins/${auth0Sub}/revoke`)
const response = await request(app.server)
.patch(`/api/admin/admins/${adminId}/revoke`)
.expect(200);
expect(response.body).toMatchObject({
auth0Sub,
id: adminId,
email: 'torevoke@example.com',
revokedAt: expect.any(String)
});
@@ -330,7 +341,7 @@ describe('Admin Management Integration Tests', () => {
// Verify audit log
const auditResult = await pool.query(
'SELECT * FROM admin_audit_logs WHERE action = $1 AND target_admin_id = $2',
['REVOKE', auth0Sub]
['REVOKE', adminId]
);
expect(auditResult.rows.length).toBe(1);
});
@@ -338,12 +349,12 @@ describe('Admin Management Integration Tests', () => {
it('should prevent revoking last active admin', async () => {
// First, ensure only one active admin exists
await pool.query(
'UPDATE admin_users SET revoked_at = CURRENT_TIMESTAMP WHERE auth0_sub != $1',
[testAdminAuth0Sub]
'UPDATE admin_users SET revoked_at = CURRENT_TIMESTAMP WHERE user_profile_id != $1',
[testAdminId]
);
const response = await request(app)
.patch(`/api/admin/admins/${testAdminAuth0Sub}/revoke`)
const response = await request(app.server)
.patch(`/api/admin/admins/${testAdminId}/revoke`)
.expect(400);
expect(response.body.error).toBe('Bad Request');
@@ -351,8 +362,8 @@ describe('Admin Management Integration Tests', () => {
});
it('should return 404 for non-existent admin', async () => {
const response = await request(app)
.patch('/api/admin/admins/auth0|nonexistent/revoke')
const response = await request(app.server)
.patch('/api/admin/admins/00000000-0000-0000-0000-000000000000/revoke')
.expect(404);
expect(response.body.error).toBe('Not Found');
@@ -360,23 +371,24 @@ describe('Admin Management Integration Tests', () => {
});
});
describe('PATCH /api/admin/admins/:auth0Sub/reinstate', () => {
describe('PATCH /api/admin/admins/:id/reinstate', () => {
it('should reinstate revoked admin', async () => {
// Create revoked admin
const reinstateId = 'c2d3e4f5-a6b7-8901-2345-678901bcdef0';
const createResult = await pool.query(`
INSERT INTO admin_users (auth0_sub, email, role, created_by, revoked_at)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
RETURNING auth0_sub
`, ['auth0|to-reinstate', 'toreinstate@example.com', 'admin', testAdminAuth0Sub]);
INSERT INTO admin_users (id, user_profile_id, email, role, created_by, revoked_at)
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
RETURNING id
`, [reinstateId, reinstateId, 'toreinstate@example.com', 'admin', testAdminId]);
const auth0Sub = createResult.rows[0].auth0_sub;
const adminId = createResult.rows[0].id;
const response = await request(app)
.patch(`/api/admin/admins/${auth0Sub}/reinstate`)
const response = await request(app.server)
.patch(`/api/admin/admins/${adminId}/reinstate`)
.expect(200);
expect(response.body).toMatchObject({
auth0Sub,
id: adminId,
email: 'toreinstate@example.com',
revokedAt: null
});
@@ -384,14 +396,14 @@ describe('Admin Management Integration Tests', () => {
// Verify audit log
const auditResult = await pool.query(
'SELECT * FROM admin_audit_logs WHERE action = $1 AND target_admin_id = $2',
['REINSTATE', auth0Sub]
['REINSTATE', adminId]
);
expect(auditResult.rows.length).toBe(1);
});
it('should return 404 for non-existent admin', async () => {
const response = await request(app)
.patch('/api/admin/admins/auth0|nonexistent/reinstate')
const response = await request(app.server)
.patch('/api/admin/admins/00000000-0000-0000-0000-000000000000/reinstate')
.expect(404);
expect(response.body.error).toBe('Not Found');
@@ -400,16 +412,17 @@ describe('Admin Management Integration Tests', () => {
it('should handle reinstating already active admin', async () => {
// Create active admin
const activeId = 'd3e4f5a6-b7c8-9012-3456-789012cdef01';
const createResult = await pool.query(`
INSERT INTO admin_users (auth0_sub, email, role, created_by)
VALUES ($1, $2, $3, $4)
RETURNING auth0_sub
`, ['auth0|already-active', 'active@example.com', 'admin', testAdminAuth0Sub]);
INSERT INTO admin_users (id, user_profile_id, email, role, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, [activeId, activeId, 'active@example.com', 'admin', testAdminId]);
const auth0Sub = createResult.rows[0].auth0_sub;
const adminId = createResult.rows[0].id;
const response = await request(app)
.patch(`/api/admin/admins/${auth0Sub}/reinstate`)
const response = await request(app.server)
.patch(`/api/admin/admins/${adminId}/reinstate`)
.expect(200);
expect(response.body.revokedAt).toBeNull();
@@ -426,12 +439,12 @@ describe('Admin Management Integration Tests', () => {
($5, $6, $7, $8),
($9, $10, $11, $12)
`, [
testAdminAuth0Sub, 'CREATE', 'admin_user', 'test1@example.com',
testAdminAuth0Sub, 'REVOKE', 'admin_user', 'test2@example.com',
testAdminAuth0Sub, 'REINSTATE', 'admin_user', 'test3@example.com'
testAdminId, 'CREATE', 'admin_user', 'test1@example.com',
testAdminId, 'REVOKE', 'admin_user', 'test2@example.com',
testAdminId, 'REINSTATE', 'admin_user', 'test3@example.com'
]);
const response = await request(app)
const response = await request(app.server)
.get('/api/admin/audit-logs')
.expect(200);
@@ -440,7 +453,7 @@ describe('Admin Management Integration Tests', () => {
expect(response.body.logs.length).toBeGreaterThanOrEqual(3);
expect(response.body.logs[0]).toMatchObject({
id: expect.any(String),
actorAdminId: testAdminAuth0Sub,
actorAdminId: testAdminId,
action: expect.any(String),
resourceType: expect.any(String),
createdAt: expect.any(String)
@@ -453,10 +466,10 @@ describe('Admin Management Integration Tests', () => {
await pool.query(`
INSERT INTO admin_audit_logs (actor_admin_id, action, resource_type, resource_id)
VALUES ($1, $2, $3, $4)
`, [testAdminAuth0Sub, 'CREATE', 'admin_user', `test${i}@example.com`]);
`, [testAdminId, 'CREATE', 'admin_user', `test${i}@example.com`]);
}
const response = await request(app)
const response = await request(app.server)
.get('/api/admin/audit-logs?limit=5&offset=0')
.expect(200);
@@ -473,12 +486,12 @@ describe('Admin Management Integration Tests', () => {
($3, $4, CURRENT_TIMESTAMP - INTERVAL '1 minute'),
($5, $6, CURRENT_TIMESTAMP)
`, [
testAdminAuth0Sub, 'FIRST',
testAdminAuth0Sub, 'SECOND',
testAdminAuth0Sub, 'THIRD'
testAdminId, 'FIRST',
testAdminId, 'SECOND',
testAdminId, 'THIRD'
]);
const response = await request(app)
const response = await request(app.server)
.get('/api/admin/audit-logs?limit=3')
.expect(200);
@@ -491,45 +504,45 @@ describe('Admin Management Integration Tests', () => {
describe('End-to-end workflow', () => {
it('should create, revoke, and reinstate admin with full audit trail', async () => {
// 1. Create new admin
const createResponse = await request(app)
const createResponse = await request(app.server)
.post('/api/admin/admins')
.send({ email: 'workflow@example.com', role: 'admin' })
.expect(201);
const auth0Sub = createResponse.body.auth0Sub;
const adminId = createResponse.body.id;
// 2. Verify admin appears in list
const listResponse = await request(app)
const listResponse = await request(app.server)
.get('/api/admin/admins')
.expect(200);
const createdAdmin = listResponse.body.admins.find(
(admin: any) => admin.auth0Sub === auth0Sub
(admin: any) => admin.id === adminId
);
expect(createdAdmin).toBeDefined();
expect(createdAdmin.revokedAt).toBeNull();
// 3. Revoke admin
const revokeResponse = await request(app)
.patch(`/api/admin/admins/${auth0Sub}/revoke`)
const revokeResponse = await request(app.server)
.patch(`/api/admin/admins/${adminId}/revoke`)
.expect(200);
expect(revokeResponse.body.revokedAt).toBeTruthy();
// 4. Reinstate admin
const reinstateResponse = await request(app)
.patch(`/api/admin/admins/${auth0Sub}/reinstate`)
const reinstateResponse = await request(app.server)
.patch(`/api/admin/admins/${adminId}/reinstate`)
.expect(200);
expect(reinstateResponse.body.revokedAt).toBeNull();
// 5. Verify complete audit trail
const auditResponse = await request(app)
const auditResponse = await request(app.server)
.get('/api/admin/audit-logs')
.expect(200);
const workflowLogs = auditResponse.body.logs.filter(
(log: any) => log.targetAdminId === auth0Sub || log.resourceId === 'workflow@example.com'
(log: any) => log.targetAdminId === adminId || log.resourceId === 'workflow@example.com'
);
expect(workflowLogs.length).toBeGreaterThanOrEqual(3);

View File

@@ -26,7 +26,7 @@ describe('admin guard plugin', () => {
fastify = Fastify();
authenticateMock = jest.fn(async (request: FastifyRequest) => {
request.userContext = {
userId: 'auth0|admin',
userId: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
email: 'admin@motovaultpro.com',
emailVerified: true,
onboardingCompleted: true,
@@ -41,7 +41,7 @@ describe('admin guard plugin', () => {
mockPool = {
query: jest.fn().mockResolvedValue({
rows: [{
auth0_sub: 'auth0|admin',
user_profile_id: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
email: 'admin@motovaultpro.com',
role: 'admin',
revoked_at: null,

View File

@@ -6,13 +6,23 @@
import { AdminService } from '../../domain/admin.service';
import { AdminRepository } from '../../data/admin.repository';
// Mock the audit log service
jest.mock('../../../audit-log', () => ({
auditLogService: {
info: jest.fn().mockResolvedValue(undefined),
warn: jest.fn().mockResolvedValue(undefined),
error: jest.fn().mockResolvedValue(undefined),
},
}));
describe('AdminService', () => {
let adminService: AdminService;
let mockRepository: jest.Mocked<AdminRepository>;
beforeEach(() => {
mockRepository = {
getAdminByAuth0Sub: jest.fn(),
getAdminById: jest.fn(),
getAdminByUserProfileId: jest.fn(),
getAdminByEmail: jest.fn(),
getAllAdmins: jest.fn(),
getActiveAdmins: jest.fn(),
@@ -26,30 +36,31 @@ describe('AdminService', () => {
adminService = new AdminService(mockRepository);
});
describe('getAdminByAuth0Sub', () => {
describe('getAdminById', () => {
it('should return admin when found', async () => {
const mockAdmin = {
auth0Sub: 'auth0|123456',
id: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
userProfileId: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
email: 'admin@motovaultpro.com',
role: 'admin',
role: 'admin' as const,
createdAt: new Date(),
createdBy: 'system',
createdBy: '550e8400-e29b-41d4-a716-446655440000',
revokedAt: null,
updatedAt: new Date(),
};
mockRepository.getAdminByAuth0Sub.mockResolvedValue(mockAdmin);
mockRepository.getAdminById.mockResolvedValue(mockAdmin);
const result = await adminService.getAdminByAuth0Sub('auth0|123456');
const result = await adminService.getAdminById('7c9e6679-7425-40de-944b-e07fc1f90ae7');
expect(result).toEqual(mockAdmin);
expect(mockRepository.getAdminByAuth0Sub).toHaveBeenCalledWith('auth0|123456');
expect(mockRepository.getAdminById).toHaveBeenCalledWith('7c9e6679-7425-40de-944b-e07fc1f90ae7');
});
it('should return null when admin not found', async () => {
mockRepository.getAdminByAuth0Sub.mockResolvedValue(null);
mockRepository.getAdminById.mockResolvedValue(null);
const result = await adminService.getAdminByAuth0Sub('auth0|unknown');
const result = await adminService.getAdminById('00000000-0000-0000-0000-000000000000');
expect(result).toBeNull();
});
@@ -57,12 +68,15 @@ describe('AdminService', () => {
describe('createAdmin', () => {
it('should create new admin and log audit', async () => {
const newAdminId = '8f14e45f-ceea-367f-a27f-c9a6d0c67e0e';
const creatorId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const mockAdmin = {
auth0Sub: 'auth0|newadmin',
id: newAdminId,
userProfileId: newAdminId,
email: 'newadmin@motovaultpro.com',
role: 'admin',
role: 'admin' as const,
createdAt: new Date(),
createdBy: 'auth0|existing',
createdBy: creatorId,
revokedAt: null,
updatedAt: new Date(),
};
@@ -74,16 +88,16 @@ describe('AdminService', () => {
const result = await adminService.createAdmin(
'newadmin@motovaultpro.com',
'admin',
'auth0|newadmin',
'auth0|existing'
newAdminId,
creatorId
);
expect(result).toEqual(mockAdmin);
expect(mockRepository.createAdmin).toHaveBeenCalled();
expect(mockRepository.logAuditAction).toHaveBeenCalledWith(
'auth0|existing',
creatorId,
'CREATE',
mockAdmin.auth0Sub,
mockAdmin.id,
'admin_user',
mockAdmin.email,
expect.any(Object)
@@ -91,12 +105,14 @@ describe('AdminService', () => {
});
it('should reject if admin already exists', async () => {
const existingId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const existingAdmin = {
auth0Sub: 'auth0|existing',
id: existingId,
userProfileId: existingId,
email: 'admin@motovaultpro.com',
role: 'admin',
role: 'admin' as const,
createdAt: new Date(),
createdBy: 'system',
createdBy: '550e8400-e29b-41d4-a716-446655440000',
revokedAt: null,
updatedAt: new Date(),
};
@@ -104,39 +120,46 @@ describe('AdminService', () => {
mockRepository.getAdminByEmail.mockResolvedValue(existingAdmin);
await expect(
adminService.createAdmin('admin@motovaultpro.com', 'admin', 'auth0|new', 'auth0|existing')
adminService.createAdmin('admin@motovaultpro.com', 'admin', '8f14e45f-ceea-367f-a27f-c9a6d0c67e0e', existingId)
).rejects.toThrow('already exists');
});
});
describe('revokeAdmin', () => {
it('should revoke admin when multiple active admins exist', async () => {
const toRevokeId = 'a1b2c3d4-e5f6-7890-1234-567890abcdef';
const admin1Id = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
const admin2Id = '8f14e45f-ceea-367f-a27f-c9a6d0c67e0e';
const revokedAdmin = {
auth0Sub: 'auth0|toadmin',
id: toRevokeId,
userProfileId: toRevokeId,
email: 'toadmin@motovaultpro.com',
role: 'admin',
role: 'admin' as const,
createdAt: new Date(),
createdBy: 'system',
createdBy: '550e8400-e29b-41d4-a716-446655440000',
revokedAt: new Date(),
updatedAt: new Date(),
};
const activeAdmins = [
{
auth0Sub: 'auth0|admin1',
id: admin1Id,
userProfileId: admin1Id,
email: 'admin1@motovaultpro.com',
role: 'admin',
role: 'admin' as const,
createdAt: new Date(),
createdBy: 'system',
createdBy: '550e8400-e29b-41d4-a716-446655440000',
revokedAt: null,
updatedAt: new Date(),
},
{
auth0Sub: 'auth0|admin2',
id: admin2Id,
userProfileId: admin2Id,
email: 'admin2@motovaultpro.com',
role: 'admin',
role: 'admin' as const,
createdAt: new Date(),
createdBy: 'system',
createdBy: '550e8400-e29b-41d4-a716-446655440000',
revokedAt: null,
updatedAt: new Date(),
},
@@ -146,20 +169,22 @@ describe('AdminService', () => {
mockRepository.revokeAdmin.mockResolvedValue(revokedAdmin);
mockRepository.logAuditAction.mockResolvedValue({} as any);
const result = await adminService.revokeAdmin('auth0|toadmin', 'auth0|admin1');
const result = await adminService.revokeAdmin(toRevokeId, admin1Id);
expect(result).toEqual(revokedAdmin);
expect(mockRepository.revokeAdmin).toHaveBeenCalledWith('auth0|toadmin');
expect(mockRepository.revokeAdmin).toHaveBeenCalledWith(toRevokeId);
expect(mockRepository.logAuditAction).toHaveBeenCalled();
});
it('should prevent revoking last active admin', async () => {
const lastAdminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const lastAdmin = {
auth0Sub: 'auth0|lastadmin',
id: lastAdminId,
userProfileId: lastAdminId,
email: 'last@motovaultpro.com',
role: 'admin',
role: 'admin' as const,
createdAt: new Date(),
createdBy: 'system',
createdBy: '550e8400-e29b-41d4-a716-446655440000',
revokedAt: null,
updatedAt: new Date(),
};
@@ -167,19 +192,22 @@ describe('AdminService', () => {
mockRepository.getActiveAdmins.mockResolvedValue([lastAdmin]);
await expect(
adminService.revokeAdmin('auth0|lastadmin', 'auth0|lastadmin')
adminService.revokeAdmin(lastAdminId, lastAdminId)
).rejects.toThrow('Cannot revoke the last active admin');
});
});
describe('reinstateAdmin', () => {
it('should reinstate revoked admin and log audit', async () => {
const reinstateId = 'b2c3d4e5-f6a7-8901-2345-678901bcdef0';
const adminActorId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const reinstatedAdmin = {
auth0Sub: 'auth0|reinstate',
id: reinstateId,
userProfileId: reinstateId,
email: 'reinstate@motovaultpro.com',
role: 'admin',
role: 'admin' as const,
createdAt: new Date(),
createdBy: 'system',
createdBy: '550e8400-e29b-41d4-a716-446655440000',
revokedAt: null,
updatedAt: new Date(),
};
@@ -187,14 +215,14 @@ describe('AdminService', () => {
mockRepository.reinstateAdmin.mockResolvedValue(reinstatedAdmin);
mockRepository.logAuditAction.mockResolvedValue({} as any);
const result = await adminService.reinstateAdmin('auth0|reinstate', 'auth0|admin');
const result = await adminService.reinstateAdmin(reinstateId, adminActorId);
expect(result).toEqual(reinstatedAdmin);
expect(mockRepository.reinstateAdmin).toHaveBeenCalledWith('auth0|reinstate');
expect(mockRepository.reinstateAdmin).toHaveBeenCalledWith(reinstateId);
expect(mockRepository.logAuditAction).toHaveBeenCalledWith(
'auth0|admin',
adminActorId,
'REINSTATE',
'auth0|reinstate',
reinstateId,
'admin_user',
reinstatedAdmin.email
);

View File

@@ -32,7 +32,7 @@ describe('AuditLog Feature Integration', () => {
describe('Vehicle logging integration', () => {
it('should create audit log with vehicle category and correct resource', async () => {
const userId = 'test-user-vehicle-123';
const userId = '550e8400-e29b-41d4-a716-446655440000';
const vehicleId = 'vehicle-uuid-123';
const entry = await service.info(
'vehicle',
@@ -56,7 +56,7 @@ describe('AuditLog Feature Integration', () => {
});
it('should log vehicle update with correct fields', async () => {
const userId = 'test-user-vehicle-456';
const userId = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
const vehicleId = 'vehicle-uuid-456';
const entry = await service.info(
'vehicle',
@@ -75,7 +75,7 @@ describe('AuditLog Feature Integration', () => {
});
it('should log vehicle deletion with vehicle info', async () => {
const userId = 'test-user-vehicle-789';
const userId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const vehicleId = 'vehicle-uuid-789';
const entry = await service.info(
'vehicle',
@@ -96,7 +96,7 @@ describe('AuditLog Feature Integration', () => {
describe('Auth logging integration', () => {
it('should create audit log with auth category for signup', async () => {
const userId = 'test-user-auth-123';
const userId = '550e8400-e29b-41d4-a716-446655440000';
const entry = await service.info(
'auth',
userId,
@@ -116,7 +116,7 @@ describe('AuditLog Feature Integration', () => {
});
it('should create audit log for password reset request', async () => {
const userId = 'test-user-auth-456';
const userId = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
const entry = await service.info(
'auth',
userId,
@@ -134,14 +134,14 @@ describe('AuditLog Feature Integration', () => {
describe('Admin logging integration', () => {
it('should create audit log for admin user creation', async () => {
const adminId = 'admin-user-123';
const targetAdminSub = 'auth0|target-admin-456';
const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const targetAdminId = '8f14e45f-ceea-367f-a27f-c9a6d0c67e0e';
const entry = await service.info(
'admin',
adminId,
'Admin user created: newadmin@example.com',
'admin_user',
targetAdminSub,
targetAdminId,
{ email: 'newadmin@example.com', role: 'admin' }
);
@@ -156,14 +156,14 @@ describe('AuditLog Feature Integration', () => {
});
it('should create audit log for admin revocation', async () => {
const adminId = 'admin-user-123';
const targetAdminSub = 'auth0|target-admin-789';
const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const targetAdminId = 'a1b2c3d4-e5f6-7890-1234-567890abcdef';
const entry = await service.info(
'admin',
adminId,
'Admin user revoked: revoked@example.com',
'admin_user',
targetAdminSub,
targetAdminId,
{ email: 'revoked@example.com' }
);
@@ -174,14 +174,14 @@ describe('AuditLog Feature Integration', () => {
});
it('should create audit log for admin reinstatement', async () => {
const adminId = 'admin-user-123';
const targetAdminSub = 'auth0|target-admin-reinstated';
const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const targetAdminId = 'b2c3d4e5-f6a7-8901-2345-678901bcdef0';
const entry = await service.info(
'admin',
adminId,
'Admin user reinstated: reinstated@example.com',
'admin_user',
targetAdminSub,
targetAdminId,
{ email: 'reinstated@example.com' }
);
@@ -194,7 +194,7 @@ describe('AuditLog Feature Integration', () => {
describe('Backup/System logging integration', () => {
it('should create audit log for backup creation', async () => {
const adminId = 'admin-user-backup-123';
const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const backupId = 'backup-uuid-123';
const entry = await service.info(
'system',
@@ -215,7 +215,7 @@ describe('AuditLog Feature Integration', () => {
});
it('should create audit log for backup restore', async () => {
const adminId = 'admin-user-backup-456';
const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const backupId = 'backup-uuid-456';
const entry = await service.info(
'system',
@@ -233,7 +233,7 @@ describe('AuditLog Feature Integration', () => {
});
it('should create error-level audit log for backup failure', async () => {
const adminId = 'admin-user-backup-789';
const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const backupId = 'backup-uuid-789';
const entry = await service.error(
'system',
@@ -253,7 +253,7 @@ describe('AuditLog Feature Integration', () => {
});
it('should create error-level audit log for restore failure', async () => {
const adminId = 'admin-user-restore-fail';
const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const backupId = 'backup-uuid-restore-fail';
const entry = await service.error(
'system',

View File

@@ -126,7 +126,7 @@ export class AuditLogRepository {
al.resource_type, al.resource_id, al.details, al.created_at,
up.email as user_email
FROM audit_logs al
LEFT JOIN user_profiles up ON al.user_id = up.auth0_sub
LEFT JOIN user_profiles up ON al.user_id = up.id
${whereClause}
ORDER BY al.created_at DESC
LIMIT $${nextParamIndex} OFFSET $${nextParamIndex + 1}
@@ -170,7 +170,7 @@ export class AuditLogRepository {
al.resource_type, al.resource_id, al.details, al.created_at,
up.email as user_email
FROM audit_logs al
LEFT JOIN user_profiles up ON al.user_id = up.auth0_sub
LEFT JOIN user_profiles up ON al.user_id = up.id
${whereClause}
ORDER BY al.created_at DESC
LIMIT ${MAX_EXPORT_RECORDS}

View File

@@ -110,17 +110,17 @@ export class AuthController {
*/
async getVerifyStatus(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const auth0Sub = (request as any).user.sub;
const result = await this.authService.getVerifyStatus(userId);
const result = await this.authService.getVerifyStatus(auth0Sub);
logger.info('Verification status checked', { userId, emailVerified: result.emailVerified });
logger.info('Verification status checked', { userId: request.userContext?.userId, emailVerified: result.emailVerified });
return reply.code(200).send(result);
} catch (error: any) {
logger.error('Failed to get verification status', {
error,
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
});
return reply.code(500).send({
@@ -137,17 +137,17 @@ export class AuthController {
*/
async resendVerification(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const auth0Sub = (request as any).user.sub;
const result = await this.authService.resendVerification(userId);
const result = await this.authService.resendVerification(auth0Sub);
logger.info('Verification email resent', { userId });
logger.info('Verification email resent', { userId: request.userContext?.userId });
return reply.code(200).send(result);
} catch (error: any) {
logger.error('Failed to resend verification email', {
error,
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
});
return reply.code(500).send({
@@ -199,23 +199,26 @@ export class AuthController {
*/
async getUserStatus(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const auth0Sub = (request as any).user.sub;
const userId = request.userContext?.userId;
const result = await this.authService.getUserStatus(userId);
const result = await this.authService.getUserStatus(auth0Sub);
// Log login event to audit trail (called once per Auth0 callback)
const ipAddress = this.getClientIp(request);
await auditLogService.info(
'auth',
userId,
'User login',
'user',
userId,
{ ipAddress }
).catch(err => logger.error('Failed to log login audit event', { error: err }));
if (userId) {
await auditLogService.info(
'auth',
userId,
'User login',
'user',
userId,
{ ipAddress }
).catch(err => logger.error('Failed to log login audit event', { error: err }));
}
logger.info('User status retrieved', {
userId: userId.substring(0, 8) + '...',
userId: userId?.substring(0, 8) + '...',
emailVerified: result.emailVerified,
onboardingCompleted: result.onboardingCompleted,
});
@@ -224,7 +227,7 @@ export class AuthController {
} catch (error: any) {
logger.error('Failed to get user status', {
error,
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
});
return reply.code(500).send({
@@ -241,12 +244,12 @@ export class AuthController {
*/
async getSecurityStatus(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const auth0Sub = (request as any).user.sub;
const result = await this.authService.getSecurityStatus(userId);
const result = await this.authService.getSecurityStatus(auth0Sub);
logger.info('Security status retrieved', {
userId: userId.substring(0, 8) + '...',
userId: request.userContext?.userId,
emailVerified: result.emailVerified,
});
@@ -254,7 +257,7 @@ export class AuthController {
} catch (error: any) {
logger.error('Failed to get security status', {
error,
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
});
return reply.code(500).send({
@@ -271,28 +274,31 @@ export class AuthController {
*/
async requestPasswordReset(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const auth0Sub = (request as any).user.sub;
const userId = request.userContext?.userId;
const result = await this.authService.requestPasswordReset(userId);
const result = await this.authService.requestPasswordReset(auth0Sub);
logger.info('Password reset email requested', {
userId: userId.substring(0, 8) + '...',
userId: userId?.substring(0, 8) + '...',
});
// Log password reset request to unified audit log
await auditLogService.info(
'auth',
userId,
'Password reset requested',
'user',
userId
).catch(err => logger.error('Failed to log password reset audit event', { error: err }));
if (userId) {
await auditLogService.info(
'auth',
userId,
'Password reset requested',
'user',
userId
).catch(err => logger.error('Failed to log password reset audit event', { error: err }));
}
return reply.code(200).send(result);
} catch (error: any) {
logger.error('Failed to request password reset', {
error,
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
});
return reply.code(500).send({
@@ -312,21 +318,23 @@ export class AuthController {
*/
async trackLogout(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext?.userId;
const ipAddress = this.getClientIp(request);
// Log logout event to audit trail
await auditLogService.info(
'auth',
userId,
'User logout',
'user',
userId,
{ ipAddress }
).catch(err => logger.error('Failed to log logout audit event', { error: err }));
if (userId) {
await auditLogService.info(
'auth',
userId,
'User logout',
'user',
userId,
{ ipAddress }
).catch(err => logger.error('Failed to log logout audit event', { error: err }));
}
logger.info('User logout tracked', {
userId: userId.substring(0, 8) + '...',
userId: userId?.substring(0, 8) + '...',
});
return reply.code(200).send({ success: true });
@@ -334,7 +342,7 @@ export class AuthController {
// Don't block logout on audit failure - always return success
logger.error('Failed to track logout', {
error,
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
});
return reply.code(200).send({ success: true });

View File

@@ -19,6 +19,7 @@ jest.mock('../../../../core/plugins/auth.plugin', () => {
return {
default: fastifyPlugin(async function (fastify) {
fastify.decorate('authenticate', async function (request, _reply) {
// JWT sub is still auth0|xxx format
request.user = { sub: 'auth0|test-user-123' };
});
}, { name: 'auth-plugin' }),

View File

@@ -103,6 +103,8 @@ describe('AuthService', () => {
onboardingCompletedAt: null,
deactivatedAt: null,
deactivatedBy: null,
deletionRequestedAt: null,
deletionScheduledFor: null,
createdAt: new Date(),
updatedAt: new Date(),
});
@@ -116,6 +118,8 @@ describe('AuthService', () => {
onboardingCompletedAt: null,
deactivatedAt: null,
deactivatedBy: null,
deletionRequestedAt: null,
deletionScheduledFor: null,
createdAt: new Date(),
updatedAt: new Date(),
});
@@ -149,6 +153,8 @@ describe('AuthService', () => {
onboardingCompletedAt: null,
deactivatedAt: null,
deactivatedBy: null,
deletionRequestedAt: null,
deletionScheduledFor: null,
createdAt: new Date(),
updatedAt: new Date(),
});

View File

@@ -45,12 +45,12 @@ export class BackupController {
request: FastifyRequest<{ Body: CreateBackupBody }>,
reply: FastifyReply
): Promise<void> {
const adminSub = (request as any).userContext?.auth0Sub;
const adminUserId = request.userContext?.userId;
const result = await this.backupService.createBackup({
name: request.body.name,
backupType: 'manual',
createdBy: adminSub,
createdBy: adminUserId,
includeDocuments: request.body.includeDocuments,
});
@@ -58,7 +58,7 @@ export class BackupController {
// Log backup creation to unified audit log
await auditLogService.info(
'system',
adminSub || null,
adminUserId || null,
`Backup created: ${request.body.name || 'Manual backup'}`,
'backup',
result.backupId,
@@ -74,7 +74,7 @@ export class BackupController {
// Log backup failure
await auditLogService.error(
'system',
adminSub || null,
adminUserId || null,
`Backup failed: ${request.body.name || 'Manual backup'}`,
'backup',
result.backupId,
@@ -139,7 +139,7 @@ export class BackupController {
request: FastifyRequest,
reply: FastifyReply
): Promise<void> {
const adminSub = (request as any).userContext?.auth0Sub;
const adminUserId = request.userContext?.userId;
// Handle multipart file upload
const data = await request.file();
@@ -173,7 +173,7 @@ export class BackupController {
const backup = await this.backupService.importUploadedBackup(
tempPath,
filename,
adminSub
adminUserId
);
reply.status(201).send({
@@ -217,7 +217,7 @@ export class BackupController {
request: FastifyRequest<{ Params: BackupIdParam; Body: RestoreBody }>,
reply: FastifyReply
): Promise<void> {
const adminSub = (request as any).userContext?.auth0Sub;
const adminUserId = request.userContext?.userId;
try {
const result = await this.restoreService.executeRestore({
@@ -229,7 +229,7 @@ export class BackupController {
// Log successful restore to unified audit log
await auditLogService.info(
'system',
adminSub || null,
adminUserId || null,
`Backup restored: ${request.params.id}`,
'backup',
request.params.id,
@@ -246,7 +246,7 @@ export class BackupController {
// Log restore failure
await auditLogService.error(
'system',
adminSub || null,
adminUserId || null,
`Backup restore failed: ${request.params.id}`,
'backup',
request.params.id,

View File

@@ -15,7 +15,7 @@ export class DocumentsController {
private readonly service = new DocumentsService();
async list(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
logger.info('Documents list requested', {
operation: 'documents.list',
@@ -43,7 +43,7 @@ export class DocumentsController {
}
async get(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
const documentId = request.params.id;
logger.info('Document get requested', {
@@ -74,7 +74,7 @@ export class DocumentsController {
}
async create(request: FastifyRequest<{ Body: CreateBody }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
const userTier: SubscriptionTier = request.userContext?.subscriptionTier || 'free';
logger.info('Document create requested', {
@@ -120,7 +120,7 @@ export class DocumentsController {
}
async update(request: FastifyRequest<{ Params: IdParams; Body: UpdateBody }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
const userTier: SubscriptionTier = request.userContext?.subscriptionTier || 'free';
const documentId = request.params.id;
@@ -174,7 +174,7 @@ export class DocumentsController {
}
async remove(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
const documentId = request.params.id;
logger.info('Document delete requested', {
@@ -221,7 +221,7 @@ export class DocumentsController {
}
async upload(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
const documentId = request.params.id;
logger.info('Document upload requested', {
@@ -373,7 +373,7 @@ export class DocumentsController {
}
async download(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
const documentId = request.params.id;
logger.info('Document download requested', {
@@ -423,7 +423,7 @@ export class DocumentsController {
}
async listByVehicle(request: FastifyRequest<{ Params: { vehicleId: string } }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
const vehicleId = request.params.vehicleId;
logger.info('Documents by vehicle requested', {
@@ -457,7 +457,7 @@ export class DocumentsController {
}
async addVehicle(request: FastifyRequest<{ Params: { id: string; vehicleId: string } }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
const { id: documentId, vehicleId } = request.params;
logger.info('Add vehicle to document requested', {
@@ -523,7 +523,7 @@ export class DocumentsController {
}
async removeVehicle(request: FastifyRequest<{ Params: { id: string; vehicleId: string } }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
const { id: documentId, vehicleId } = request.params;
logger.info('Remove vehicle from document requested', {

View File

@@ -0,0 +1,188 @@
/**
* @ai-summary Controller for Resend inbound email webhook and user-facing pending association endpoints
* @ai-context Webhook handler (public) + pending association CRUD (JWT-authenticated)
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import { ResendInboundClient } from '../external/resend-inbound.client';
import { EmailIngestionRepository } from '../data/email-ingestion.repository';
import { EmailIngestionService } from '../domain/email-ingestion.service';
import { logger } from '../../../core/logging/logger';
import type { ResendWebhookEvent } from '../domain/email-ingestion.types';
export class EmailIngestionController {
private resendClient: ResendInboundClient;
private repository: EmailIngestionRepository;
private service: EmailIngestionService;
constructor() {
this.resendClient = new ResendInboundClient();
this.repository = new EmailIngestionRepository();
this.service = new EmailIngestionService();
}
// ========================
// Pending Association Endpoints (JWT-authenticated)
// ========================
async getPendingAssociations(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = request.userContext!.userId;
const associations = await this.repository.getPendingAssociations(userId);
return reply.code(200).send(associations);
} catch (error: any) {
logger.error('Error listing pending associations', { error: error.message, userId: request.userContext?.userId });
return reply.code(500).send({ error: 'Failed to list pending associations' });
}
}
async getPendingAssociationCount(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = request.userContext!.userId;
const count = await this.repository.getPendingAssociationCount(userId);
return reply.code(200).send({ count });
} catch (error: any) {
logger.error('Error counting pending associations', { error: error.message, userId: request.userContext?.userId });
return reply.code(500).send({ error: 'Failed to count pending associations' });
}
}
async resolveAssociation(
request: FastifyRequest<{ Params: { id: string }; Body: { vehicleId: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const userId = request.userContext!.userId;
const { id } = request.params;
const { vehicleId } = request.body;
if (!vehicleId || typeof vehicleId !== 'string') {
return reply.code(400).send({ error: 'vehicleId is required' });
}
const result = await this.service.resolveAssociation(id, vehicleId, userId);
return reply.code(200).send(result);
} catch (error: any) {
const userId = request.userContext?.userId;
logger.error('Error resolving pending association', {
error: error.message,
associationId: request.params.id,
userId,
});
if (error.message === 'Pending association not found' || error.message === 'Vehicle not found') {
return reply.code(404).send({ error: error.message });
}
if (error.message === 'Unauthorized') {
return reply.code(403).send({ error: 'Not authorized' });
}
if (error.message === 'Association already resolved') {
return reply.code(409).send({ error: error.message });
}
return reply.code(500).send({ error: 'Failed to resolve association' });
}
}
async dismissAssociation(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const userId = request.userContext!.userId;
const { id } = request.params;
await this.service.dismissAssociation(id, userId);
return reply.code(204).send();
} catch (error: any) {
const userId = request.userContext?.userId;
logger.error('Error dismissing pending association', {
error: error.message,
associationId: request.params.id,
userId,
});
if (error.message === 'Pending association not found') {
return reply.code(404).send({ error: error.message });
}
if (error.message === 'Unauthorized') {
return reply.code(403).send({ error: 'Not authorized' });
}
if (error.message === 'Association already resolved') {
return reply.code(409).send({ error: error.message });
}
return reply.code(500).send({ error: 'Failed to dismiss association' });
}
}
// ========================
// Webhook Endpoint (Public)
// ========================
async handleInboundWebhook(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const rawBody = (request as any).rawBody;
if (!rawBody) {
logger.error('Missing raw body in Resend webhook request');
return reply.status(400).send({ error: 'Missing raw body' });
}
// Extract Svix headers for signature verification
const headers: Record<string, string> = {
'svix-id': (request.headers['svix-id'] as string) || '',
'svix-timestamp': (request.headers['svix-timestamp'] as string) || '',
'svix-signature': (request.headers['svix-signature'] as string) || '',
};
// Verify webhook signature
let event: ResendWebhookEvent;
try {
event = this.resendClient.verifyWebhookSignature(rawBody, headers);
} catch (error: any) {
logger.warn('Invalid Resend webhook signature', { error: error.message });
return reply.status(400).send({ error: 'Invalid signature' });
}
const emailId = event.data.email_id;
const senderEmail = event.data.from;
// Idempotency check: reject if email_id already exists in queue
const existing = await this.repository.findByEmailId(emailId);
if (existing) {
logger.info('Duplicate email webhook received, skipping', { emailId });
return reply.status(200).send({ received: true, duplicate: true });
}
// Insert queue record with status=pending via repository
await this.repository.insertQueueEntry({
emailId,
senderEmail,
userId: senderEmail, // Resolved to auth0_sub during processing
receivedAt: event.data.created_at || new Date().toISOString(),
subject: event.data.subject,
});
logger.info('Inbound email queued for processing', { emailId, senderEmail });
// Return 200 immediately before processing begins
reply.status(200).send({ received: true });
// Trigger async processing via setImmediate
setImmediate(() => {
this.service.processEmail(emailId, event).catch((error) => {
logger.error('Async email processing failed', {
emailId,
error: error instanceof Error ? error.message : String(error),
});
});
});
} catch (error: any) {
logger.error('Resend webhook handler error', {
error: error.message,
stack: error.stack,
});
return reply.status(500).send({ error: 'Webhook processing failed' });
}
}
}

View File

@@ -0,0 +1,60 @@
/**
* @ai-summary Resend inbound webhook + user-facing pending association routes
* @ai-context Public webhook (no JWT) + authenticated CRUD for pending vehicle associations
*/
import { FastifyPluginAsync } from 'fastify';
import { EmailIngestionController } from './email-ingestion.controller';
/** Public webhook route - no JWT auth, uses Svix signature verification */
export const emailIngestionWebhookRoutes: FastifyPluginAsync = async (fastify) => {
const controller = new EmailIngestionController();
// POST /api/webhooks/resend/inbound - PUBLIC endpoint (no JWT auth)
// Resend authenticates via webhook signature verification (Svix)
// rawBody MUST be enabled for signature verification to work
fastify.post(
'/webhooks/resend/inbound',
{
config: {
rawBody: true,
},
},
controller.handleInboundWebhook.bind(controller)
);
};
/** Authenticated user-facing routes for pending vehicle associations */
export const emailIngestionRoutes: FastifyPluginAsync = async (fastify) => {
const controller = new EmailIngestionController();
// GET /api/email-ingestion/pending - List pending associations for authenticated user
fastify.get('/email-ingestion/pending', {
preHandler: [fastify.authenticate],
handler: controller.getPendingAssociations.bind(controller),
});
// GET /api/email-ingestion/pending/count - Get count of pending associations
fastify.get('/email-ingestion/pending/count', {
preHandler: [fastify.authenticate],
handler: controller.getPendingAssociationCount.bind(controller),
});
// POST /api/email-ingestion/pending/:id/resolve - Resolve by selecting vehicle
fastify.post<{ Params: { id: string }; Body: { vehicleId: string } }>(
'/email-ingestion/pending/:id/resolve',
{
preHandler: [fastify.authenticate],
handler: controller.resolveAssociation.bind(controller),
}
);
// DELETE /api/email-ingestion/pending/:id - Dismiss/discard a pending association
fastify.delete<{ Params: { id: string } }>(
'/email-ingestion/pending/:id',
{
preHandler: [fastify.authenticate],
handler: controller.dismissAssociation.bind(controller),
}
);
};

View File

@@ -0,0 +1,257 @@
/**
* @ai-summary Data access layer for email ingestion queue and pending vehicle associations
* @ai-context Provides CRUD operations with standard mapRow() snake_case -> camelCase conversion
*/
import { Pool } from 'pg';
import pool from '../../../core/config/database';
import { logger } from '../../../core/logging/logger';
import type {
EmailIngestionQueueRecord,
EmailIngestionStatus,
EmailProcessingResult,
PendingVehicleAssociation,
PendingAssociationStatus,
EmailRecordType,
ExtractedReceiptData,
} from '../domain/email-ingestion.types';
export class EmailIngestionRepository {
constructor(private readonly db: Pool = pool) {}
// ========================
// Row Mappers
// ========================
private mapQueueRow(row: any): EmailIngestionQueueRecord {
return {
id: row.id,
emailId: row.email_id,
senderEmail: row.sender_email,
userId: row.user_id,
receivedAt: row.received_at,
subject: row.subject,
status: row.status,
processingResult: row.processing_result,
errorMessage: row.error_message,
retryCount: row.retry_count,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
private mapPendingAssociationRow(row: any): PendingVehicleAssociation {
return {
id: row.id,
userId: row.user_id,
recordType: row.record_type,
extractedData: row.extracted_data,
documentId: row.document_id,
status: row.status,
createdAt: row.created_at,
resolvedAt: row.resolved_at,
};
}
// ========================
// Queue Operations
// ========================
async insertQueueEntry(entry: {
emailId: string;
senderEmail: string;
userId: string;
receivedAt: string;
subject: string | null;
}): Promise<EmailIngestionQueueRecord> {
try {
const res = await this.db.query(
`INSERT INTO email_ingestion_queue
(email_id, sender_email, user_id, received_at, subject, status)
VALUES ($1, $2, $3, $4, $5, 'pending')
RETURNING *`,
[
entry.emailId,
entry.senderEmail,
entry.userId,
entry.receivedAt,
entry.subject,
]
);
return this.mapQueueRow(res.rows[0]);
} catch (error) {
logger.error('Error inserting queue entry', { error, emailId: entry.emailId });
throw error;
}
}
async updateQueueStatus(
emailId: string,
status: EmailIngestionStatus,
updates?: {
processingResult?: EmailProcessingResult;
errorMessage?: string;
retryCount?: number;
userId?: string;
}
): Promise<EmailIngestionQueueRecord | null> {
try {
const fields: string[] = ['status = $2'];
const params: any[] = [emailId, status];
let paramIndex = 3;
if (updates?.processingResult !== undefined) {
fields.push(`processing_result = $${paramIndex++}`);
params.push(JSON.stringify(updates.processingResult));
}
if (updates?.errorMessage !== undefined) {
fields.push(`error_message = $${paramIndex++}`);
params.push(updates.errorMessage);
}
if (updates?.retryCount !== undefined) {
fields.push(`retry_count = $${paramIndex++}`);
params.push(updates.retryCount);
}
if (updates?.userId !== undefined) {
fields.push(`user_id = $${paramIndex++}`);
params.push(updates.userId);
}
const res = await this.db.query(
`UPDATE email_ingestion_queue
SET ${fields.join(', ')}
WHERE email_id = $1
RETURNING *`,
params
);
return res.rows[0] ? this.mapQueueRow(res.rows[0]) : null;
} catch (error) {
logger.error('Error updating queue status', { error, emailId, status });
throw error;
}
}
async getQueueEntry(emailId: string): Promise<EmailIngestionQueueRecord | null> {
try {
const res = await this.db.query(
`SELECT * FROM email_ingestion_queue WHERE email_id = $1`,
[emailId]
);
return res.rows[0] ? this.mapQueueRow(res.rows[0]) : null;
} catch (error) {
logger.error('Error fetching queue entry', { error, emailId });
throw error;
}
}
async findByEmailId(emailId: string): Promise<EmailIngestionQueueRecord | null> {
return this.getQueueEntry(emailId);
}
async getRetryableEntries(maxRetries: number = 3): Promise<EmailIngestionQueueRecord[]> {
try {
const res = await this.db.query(
`SELECT * FROM email_ingestion_queue
WHERE status = 'failed'
AND retry_count < $1
ORDER BY created_at ASC`,
[maxRetries]
);
return res.rows.map(row => this.mapQueueRow(row));
} catch (error) {
logger.error('Error fetching retryable entries', { error });
throw error;
}
}
// ========================
// Pending Association Operations
// ========================
async insertPendingAssociation(association: {
userId: string;
recordType: EmailRecordType;
extractedData: ExtractedReceiptData;
documentId: string | null;
}): Promise<PendingVehicleAssociation> {
try {
const res = await this.db.query(
`INSERT INTO pending_vehicle_associations
(user_id, record_type, extracted_data, document_id, status)
VALUES ($1, $2, $3, $4, 'pending')
RETURNING *`,
[
association.userId,
association.recordType,
JSON.stringify(association.extractedData),
association.documentId,
]
);
return this.mapPendingAssociationRow(res.rows[0]);
} catch (error) {
logger.error('Error inserting pending association', { error, userId: association.userId });
throw error;
}
}
async getPendingAssociationById(associationId: string): Promise<PendingVehicleAssociation | null> {
try {
const res = await this.db.query(
`SELECT * FROM pending_vehicle_associations WHERE id = $1`,
[associationId]
);
return res.rows[0] ? this.mapPendingAssociationRow(res.rows[0]) : null;
} catch (error) {
logger.error('Error fetching pending association by id', { error, associationId });
throw error;
}
}
async getPendingAssociationCount(userId: string): Promise<number> {
try {
const res = await this.db.query(
`SELECT COUNT(*)::int AS count FROM pending_vehicle_associations
WHERE user_id = $1 AND status = 'pending'`,
[userId]
);
return res.rows[0]?.count ?? 0;
} catch (error) {
logger.error('Error counting pending associations', { error, userId });
throw error;
}
}
async getPendingAssociations(userId: string): Promise<PendingVehicleAssociation[]> {
try {
const res = await this.db.query(
`SELECT * FROM pending_vehicle_associations
WHERE user_id = $1 AND status = 'pending'
ORDER BY created_at DESC`,
[userId]
);
return res.rows.map(row => this.mapPendingAssociationRow(row));
} catch (error) {
logger.error('Error fetching pending associations', { error, userId });
throw error;
}
}
async resolvePendingAssociation(
associationId: string,
status: PendingAssociationStatus = 'resolved'
): Promise<PendingVehicleAssociation | null> {
try {
const res = await this.db.query(
`UPDATE pending_vehicle_associations
SET status = $2, resolved_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING *`,
[associationId, status]
);
return res.rows[0] ? this.mapPendingAssociationRow(res.rows[0]) : null;
} catch (error) {
logger.error('Error resolving pending association', { error, associationId });
throw error;
}
}
}

View File

@@ -0,0 +1,844 @@
/**
* @ai-summary Core processing service for the email-to-record pipeline
* @ai-context Orchestrates sender validation, OCR extraction, record classification,
* vehicle association, status tracking, and retry logic. Delegates all notifications
* (emails, in-app, logging) to EmailIngestionNotificationHandler.
*/
import { Pool } from 'pg';
import pool from '../../../core/config/database';
import { logger } from '../../../core/logging/logger';
import { EmailIngestionRepository } from '../data/email-ingestion.repository';
import { ResendInboundClient, type ParsedEmailAttachment } from '../external/resend-inbound.client';
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
import { VehiclesRepository } from '../../vehicles/data/vehicles.repository';
import { NotificationsRepository } from '../../notifications/data/notifications.repository';
import { TemplateService } from '../../notifications/domain/template.service';
import { EmailService } from '../../notifications/domain/email.service';
import { ocrService } from '../../ocr/domain/ocr.service';
import type { ReceiptExtractionResponse } from '../../ocr/domain/ocr.types';
import { ReceiptClassifier } from './receipt-classifier';
import { EmailIngestionNotificationHandler } from './notification-handler';
import { FuelLogsService } from '../../fuel-logs/domain/fuel-logs.service';
import { FuelLogsRepository } from '../../fuel-logs/data/fuel-logs.repository';
import { FuelType } from '../../fuel-logs/domain/fuel-logs.types';
import type { EnhancedCreateFuelLogRequest } from '../../fuel-logs/domain/fuel-logs.types';
import { MaintenanceService } from '../../maintenance/domain/maintenance.service';
import type { MaintenanceCategory } from '../../maintenance/domain/maintenance.types';
import { validateSubtypes, getSubtypesForCategory } from '../../maintenance/domain/maintenance.types';
import type {
ResendWebhookEvent,
EmailProcessingResult,
ExtractedReceiptData,
EmailRecordType,
} from './email-ingestion.types';
/** Supported attachment MIME types */
const SUPPORTED_ATTACHMENT_TYPES = new Set([
'application/pdf',
'image/png',
'image/jpeg',
'image/heic',
'image/heif',
]);
/** Image types that work with receipt-specific OCR */
const OCR_RECEIPT_IMAGE_TYPES = new Set([
'image/jpeg',
'image/png',
'image/heic',
'image/heif',
]);
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_RETRY_COUNT = 3;
export class EmailIngestionService {
private repository: EmailIngestionRepository;
private resendClient: ResendInboundClient;
private userProfileRepository: UserProfileRepository;
private vehiclesRepository: VehiclesRepository;
private notificationHandler: EmailIngestionNotificationHandler;
private classifier: ReceiptClassifier;
private fuelLogsService: FuelLogsService;
private maintenanceService: MaintenanceService;
constructor(dbPool?: Pool) {
const p = dbPool || pool;
this.repository = new EmailIngestionRepository(p);
this.resendClient = new ResendInboundClient();
this.userProfileRepository = new UserProfileRepository(p);
this.vehiclesRepository = new VehiclesRepository(p);
const notificationsRepository = new NotificationsRepository(p);
this.notificationHandler = new EmailIngestionNotificationHandler(
notificationsRepository,
new TemplateService(),
new EmailService(),
);
this.classifier = new ReceiptClassifier();
this.fuelLogsService = new FuelLogsService(new FuelLogsRepository(p));
this.maintenanceService = new MaintenanceService();
}
// ========================
// Main Processing Pipeline
// ========================
/**
* Process an inbound email through the full pipeline.
* Called asynchronously after webhook receipt is acknowledged.
*/
async processEmail(emailId: string, event: ResendWebhookEvent): Promise<void> {
const senderEmail = event.data.from;
const subject = event.data.subject;
try {
// 1. Mark as processing
await this.repository.updateQueueStatus(emailId, 'processing');
// 2. Validate sender
const userProfile = await this.validateSender(senderEmail);
if (!userProfile) {
await this.handleUnregisteredSender(emailId, senderEmail);
return;
}
const userId = userProfile.auth0Sub;
const userName = userProfile.displayName || userProfile.email;
// Update queue with resolved user_id
await this.repository.updateQueueStatus(emailId, 'processing', { userId });
// 3. Get attachments (from webhook data or by fetching raw email)
const attachments = await this.getAttachments(emailId, event);
// 4. Filter valid attachments
const validAttachments = this.filterAttachments(attachments);
if (validAttachments.length === 0) {
await this.handleNoValidAttachments(emailId, userId, userName, senderEmail);
return;
}
// 5. Classify receipt from email text first
const emailClassification = this.classifier.classifyFromText(subject, event.data.text);
logger.info('Email text classification result', {
emailId,
type: emailClassification.type,
confidence: emailClassification.confidence,
});
// 6. Process attachments through OCR using classification
const ocrResult = await this.processAttachmentsWithClassification(
userId, validAttachments, emailClassification, emailId
);
if (!ocrResult) {
await this.handleOcrFailure(emailId, userId, userName, senderEmail, 'No receipt data could be extracted from attachments');
return;
}
// 7. Build extracted data from OCR result
const extractedData = this.mapOcrToExtractedData(ocrResult.response);
const recordType = ocrResult.recordType;
// 8. Handle vehicle association
const processingResult = await this.handleVehicleAssociation(
userId, userName, senderEmail, recordType, extractedData
);
// 9. Mark as completed
await this.repository.updateQueueStatus(emailId, 'completed', {
processingResult,
});
logger.info('Email processing completed successfully', {
emailId,
userId,
recordType,
vehicleId: processingResult.vehicleId,
pendingAssociationId: processingResult.pendingAssociationId,
});
} catch (error) {
await this.handleProcessingError(emailId, senderEmail, subject, error);
}
}
// ========================
// Sender Validation
// ========================
private async validateSender(senderEmail: string): Promise<{
auth0Sub: string;
email: string;
displayName: string | null;
} | null> {
// Case-insensitive lookup by lowercasing the sender email
const profile = await this.userProfileRepository.getByEmail(senderEmail.toLowerCase());
if (profile) {
return {
auth0Sub: profile.auth0Sub,
email: profile.email,
displayName: profile.displayName ?? null,
};
}
// Try original case as fallback
if (senderEmail !== senderEmail.toLowerCase()) {
const fallback = await this.userProfileRepository.getByEmail(senderEmail);
if (fallback) {
return {
auth0Sub: fallback.auth0Sub,
email: fallback.email,
displayName: fallback.displayName ?? null,
};
}
}
return null;
}
// ========================
// Attachment Handling
// ========================
/**
* Get attachments from webhook data or by fetching the raw email
*/
private async getAttachments(
emailId: string,
event: ResendWebhookEvent
): Promise<ParsedEmailAttachment[]> {
// If webhook includes attachments with content, use those
if (event.data.attachments && event.data.attachments.length > 0) {
return event.data.attachments.map(att => ({
filename: att.filename,
contentType: att.content_type,
content: Buffer.from(att.content, 'base64'),
size: Buffer.from(att.content, 'base64').length,
}));
}
// Otherwise fetch and parse the raw email
try {
const { downloadUrl } = await this.resendClient.getEmail(emailId);
const rawEmail = await this.resendClient.downloadRawEmail(downloadUrl);
const parsed = await this.resendClient.parseEmail(rawEmail);
return parsed.attachments;
} catch (error) {
logger.warn('Failed to fetch raw email for attachments', {
emailId,
error: error instanceof Error ? error.message : String(error),
});
return [];
}
}
/**
* Filter attachments by supported type and size
*/
private filterAttachments(attachments: ParsedEmailAttachment[]): ParsedEmailAttachment[] {
return attachments.filter(att => {
if (!SUPPORTED_ATTACHMENT_TYPES.has(att.contentType)) {
logger.info('Skipping unsupported attachment type', {
filename: att.filename,
contentType: att.contentType,
});
return false;
}
if (att.size > MAX_ATTACHMENT_SIZE) {
logger.info('Skipping oversized attachment', {
filename: att.filename,
size: att.size,
maxSize: MAX_ATTACHMENT_SIZE,
});
return false;
}
return true;
});
}
// ========================
// OCR Processing
// ========================
/**
* Process attachments using classifier-driven OCR extraction.
* If email text classification is confident, calls the specific OCR endpoint.
* If not, performs general OCR and classifies from rawText.
* Returns null if no usable result or receipt is unclassified.
*/
private async processAttachmentsWithClassification(
userId: string,
attachments: ParsedEmailAttachment[],
emailClassification: { type: string; confidence: number },
emailId: string
): Promise<{ response: ReceiptExtractionResponse; recordType: EmailRecordType } | null> {
const imageAttachments = attachments.filter(att => OCR_RECEIPT_IMAGE_TYPES.has(att.contentType));
for (const attachment of imageAttachments) {
// If email text gave a confident classification, call the specific OCR endpoint first
if (emailClassification.type === 'fuel') {
const result = await this.extractFuelReceipt(userId, attachment);
if (result?.success) return { response: result, recordType: 'fuel_log' };
// Fuel OCR failed, try maintenance as fallback
const fallbackResult = await this.extractMaintenanceReceipt(userId, attachment);
if (fallbackResult?.success) return { response: fallbackResult, recordType: 'maintenance_record' };
continue;
}
if (emailClassification.type === 'maintenance') {
const result = await this.extractMaintenanceReceipt(userId, attachment);
if (result?.success) return { response: result, recordType: 'maintenance_record' };
// Maintenance OCR failed, try fuel as fallback
const fallbackResult = await this.extractFuelReceipt(userId, attachment);
if (fallbackResult?.success) return { response: fallbackResult, recordType: 'fuel_log' };
continue;
}
// Email text was not confident - try both OCR endpoints and classify from rawText
const fuelResult = await this.extractFuelReceipt(userId, attachment);
const maintenanceResult = await this.extractMaintenanceReceipt(userId, attachment);
// Use rawText from whichever succeeded for secondary classification
const rawText = fuelResult?.rawText || maintenanceResult?.rawText || '';
if (rawText) {
const ocrClassification = this.classifier.classifyFromOcrRawText(rawText);
logger.info('OCR rawText classification result', {
emailId,
type: ocrClassification.type,
confidence: ocrClassification.confidence,
});
if (ocrClassification.type === 'fuel' && fuelResult?.success) {
return { response: fuelResult, recordType: 'fuel_log' };
}
if (ocrClassification.type === 'maintenance' && maintenanceResult?.success) {
return { response: maintenanceResult, recordType: 'maintenance_record' };
}
}
// Both classifiers failed - fall back to field-count heuristic
const fallback = this.selectBestResultByFields(fuelResult, maintenanceResult);
if (fallback) return fallback;
}
return null;
}
/**
* Extract fuel receipt via OCR. Returns null on failure.
*/
private async extractFuelReceipt(
userId: string,
attachment: ParsedEmailAttachment
): Promise<ReceiptExtractionResponse | null> {
try {
return await ocrService.extractReceipt(userId, {
fileBuffer: attachment.content,
contentType: attachment.contentType,
receiptType: 'fuel',
});
} catch (error) {
logger.info('Fuel receipt extraction failed', {
filename: attachment.filename,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
/**
* Extract maintenance receipt via OCR. Returns null on failure.
*/
private async extractMaintenanceReceipt(
userId: string,
attachment: ParsedEmailAttachment
): Promise<ReceiptExtractionResponse | null> {
try {
return await ocrService.extractMaintenanceReceipt(userId, {
fileBuffer: attachment.content,
contentType: attachment.contentType,
});
} catch (error) {
logger.info('Maintenance receipt extraction failed', {
filename: attachment.filename,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
/**
* Last-resort fallback: select the better OCR result based on domain-specific
* fields and field count when keyword classifiers could not decide.
*/
private selectBestResultByFields(
fuelResult: ReceiptExtractionResponse | null,
maintenanceResult: ReceiptExtractionResponse | null
): { response: ReceiptExtractionResponse; recordType: EmailRecordType } | null {
const fuelFieldCount = fuelResult?.success
? Object.keys(fuelResult.extractedFields).length
: 0;
const maintenanceFieldCount = maintenanceResult?.success
? Object.keys(maintenanceResult.extractedFields).length
: 0;
if (fuelFieldCount === 0 && maintenanceFieldCount === 0) {
return null;
}
const hasFuelFields = fuelResult?.extractedFields['gallons'] ||
fuelResult?.extractedFields['price_per_gallon'] ||
fuelResult?.extractedFields['fuel_type'];
const hasMaintenanceFields = maintenanceResult?.extractedFields['category'] ||
maintenanceResult?.extractedFields['shop_name'] ||
maintenanceResult?.extractedFields['description'];
if (hasFuelFields && !hasMaintenanceFields) {
return { response: fuelResult!, recordType: 'fuel_log' };
}
if (hasMaintenanceFields && !hasFuelFields) {
return { response: maintenanceResult!, recordType: 'maintenance_record' };
}
if (fuelFieldCount >= maintenanceFieldCount && fuelResult?.success) {
return { response: fuelResult, recordType: 'fuel_log' };
}
if (maintenanceResult?.success) {
return { response: maintenanceResult, recordType: 'maintenance_record' };
}
return null;
}
/**
* Map OCR extracted fields to our ExtractedReceiptData format
*/
private mapOcrToExtractedData(response: ReceiptExtractionResponse): ExtractedReceiptData {
const fields = response.extractedFields;
const getFieldValue = (key: string): string | null =>
fields[key]?.value || null;
const getFieldNumber = (key: string): number | null => {
const val = fields[key]?.value;
if (!val) return null;
const num = parseFloat(val);
return isNaN(num) ? null : num;
};
return {
vendor: getFieldValue('vendor') || getFieldValue('shop_name'),
date: getFieldValue('date'),
total: getFieldNumber('total'),
odometerReading: getFieldNumber('odometer') || getFieldNumber('odometer_reading'),
gallons: getFieldNumber('gallons'),
pricePerGallon: getFieldNumber('price_per_gallon'),
fuelType: getFieldValue('fuel_type'),
category: getFieldValue('category'),
subtypes: fields['subtypes']?.value ? fields['subtypes'].value.split(',').map(s => s.trim()) : null,
shopName: getFieldValue('shop_name'),
description: getFieldValue('description'),
};
}
// ========================
// Vehicle Association
// ========================
/**
* Handle vehicle association based on user's vehicle count.
* No vehicles: send error email.
* Single vehicle: auto-associate and create record.
* Multiple vehicles: create pending association for user selection.
*/
private async handleVehicleAssociation(
userId: string,
userName: string,
userEmail: string,
recordType: EmailRecordType,
extractedData: ExtractedReceiptData
): Promise<EmailProcessingResult> {
const vehicles = await this.vehiclesRepository.findByUserId(userId);
// No vehicles: user must add a vehicle first
if (vehicles.length === 0) {
await this.notificationHandler.notifyNoVehicles(userId, userName, userEmail);
return {
recordType,
vehicleId: null,
recordId: null,
documentId: null,
pendingAssociationId: null,
extractedData,
};
}
// Single vehicle: auto-associate and create record
if (vehicles.length === 1) {
const vehicle = vehicles[0];
let recordId: string | null = null;
try {
recordId = await this.createRecord(userId, vehicle.id, recordType, extractedData);
} catch (error) {
logger.error('Failed to create record from email receipt', {
userId,
vehicleId: vehicle.id,
recordType,
error: error instanceof Error ? error.message : String(error),
});
}
const vehicleName = vehicle.nickname
|| [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ')
|| 'your vehicle';
await this.notificationHandler.notifyReceiptProcessed({
userId,
userName,
userEmail,
vehicleName,
recordType,
recordId,
vehicleId: vehicle.id,
extractedData,
});
return {
recordType,
vehicleId: vehicle.id,
recordId,
documentId: null,
pendingAssociationId: null,
extractedData,
};
}
// Multiple vehicles: create pending association for user selection
const pendingAssociation = await this.repository.insertPendingAssociation({
userId,
recordType,
extractedData,
documentId: null,
});
await this.notificationHandler.notifyPendingVehicleSelection({
userId,
userName,
userEmail,
recordType,
pendingAssociationId: pendingAssociation.id,
extractedData,
});
return {
recordType,
vehicleId: null,
recordId: null,
documentId: null,
pendingAssociationId: pendingAssociation.id,
extractedData,
};
}
// ========================
// Public Resolution API
// ========================
/**
* Resolve a pending vehicle association by creating the record with the selected vehicle.
* Called from the user-facing API when a multi-vehicle user picks a vehicle.
*/
async resolveAssociation(
associationId: string,
vehicleId: string,
userId: string
): Promise<{ recordId: string; recordType: EmailRecordType }> {
const association = await this.repository.getPendingAssociationById(associationId);
if (!association) {
throw new Error('Pending association not found');
}
if (association.userId !== userId) {
throw new Error('Unauthorized');
}
if (association.status !== 'pending') {
throw new Error('Association already resolved');
}
// Verify vehicle belongs to user
const vehicles = await this.vehiclesRepository.findByUserId(userId);
const vehicle = vehicles.find(v => v.id === vehicleId);
if (!vehicle) {
throw new Error('Vehicle not found');
}
// Create the record
const recordId = await this.createRecord(userId, vehicleId, association.recordType, association.extractedData);
// Mark as resolved
await this.repository.resolvePendingAssociation(associationId, 'resolved');
logger.info('Pending association resolved', { associationId, vehicleId, userId, recordType: association.recordType, recordId });
return { recordId, recordType: association.recordType };
}
/**
* Dismiss a pending vehicle association without creating a record.
*/
async dismissAssociation(associationId: string, userId: string): Promise<void> {
const association = await this.repository.getPendingAssociationById(associationId);
if (!association) {
throw new Error('Pending association not found');
}
if (association.userId !== userId) {
throw new Error('Unauthorized');
}
if (association.status !== 'pending') {
throw new Error('Association already resolved');
}
await this.repository.resolvePendingAssociation(associationId, 'expired');
logger.info('Pending association dismissed', { associationId, userId });
}
// ========================
// Record Creation
// ========================
/**
* Create a fuel log or maintenance record from extracted receipt data.
* Returns the created record ID.
*/
private async createRecord(
userId: string,
vehicleId: string,
recordType: EmailRecordType,
extractedData: ExtractedReceiptData
): Promise<string> {
if (recordType === 'fuel_log') {
return this.createFuelLogRecord(userId, vehicleId, extractedData);
}
return this.createMaintenanceRecord(userId, vehicleId, extractedData);
}
/**
* Map extracted receipt data to EnhancedCreateFuelLogRequest and create fuel log.
*/
private async createFuelLogRecord(
userId: string,
vehicleId: string,
data: ExtractedReceiptData
): Promise<string> {
const fuelUnits = data.gallons ?? 0;
const costPerUnit = data.pricePerGallon ?? (data.total && fuelUnits > 0 ? data.total / fuelUnits : 0);
const request: EnhancedCreateFuelLogRequest = {
vehicleId,
dateTime: data.date || new Date().toISOString(),
fuelType: this.mapFuelType(data.fuelType),
fuelUnits,
costPerUnit,
odometerReading: data.odometerReading ?? undefined,
locationData: data.vendor ? { stationName: data.vendor } : undefined,
notes: 'Created from emailed receipt',
};
logger.info('Creating fuel log from email receipt', { userId, vehicleId, fuelUnits, costPerUnit });
const result = await this.fuelLogsService.createFuelLog(request, userId);
return result.id;
}
/**
* Map extracted receipt data to CreateMaintenanceRecordRequest and create maintenance record.
*/
private async createMaintenanceRecord(
userId: string,
vehicleId: string,
data: ExtractedReceiptData
): Promise<string> {
const category = this.mapMaintenanceCategory(data.category);
const subtypes = this.resolveMaintenanceSubtypes(category, data.subtypes);
const record = await this.maintenanceService.createRecord(userId, {
vehicleId,
category,
subtypes,
date: data.date || new Date().toISOString().split('T')[0],
odometerReading: data.odometerReading ?? undefined,
cost: data.total ?? undefined,
shopName: data.shopName || data.vendor || undefined,
notes: data.description
? `${data.description}\n\nCreated from emailed receipt`
: 'Created from emailed receipt',
});
logger.info('Created maintenance record from email receipt', { userId, vehicleId, recordId: record.id, category });
return record.id;
}
/**
* Map OCR fuel type string to FuelType enum. Defaults to gasoline.
*/
private mapFuelType(fuelTypeStr: string | null): FuelType {
if (!fuelTypeStr) return FuelType.GASOLINE;
const normalized = fuelTypeStr.toLowerCase().trim();
if (normalized.includes('diesel') || normalized === '#1' || normalized === '#2') {
return FuelType.DIESEL;
}
if (normalized.includes('electric') || normalized.includes('ev')) {
return FuelType.ELECTRIC;
}
return FuelType.GASOLINE;
}
/**
* Map OCR category string to MaintenanceCategory. Defaults to routine_maintenance.
*/
private mapMaintenanceCategory(categoryStr: string | null): MaintenanceCategory {
if (!categoryStr) return 'routine_maintenance';
const normalized = categoryStr.toLowerCase().trim();
if (normalized.includes('repair')) return 'repair';
if (normalized.includes('performance') || normalized.includes('upgrade')) return 'performance_upgrade';
return 'routine_maintenance';
}
/**
* Validate and resolve maintenance subtypes. Falls back to first valid
* subtype for the category if OCR subtypes are invalid or missing.
*/
private resolveMaintenanceSubtypes(
category: MaintenanceCategory,
ocrSubtypes: string[] | null
): string[] {
if (ocrSubtypes && ocrSubtypes.length > 0 && validateSubtypes(category, ocrSubtypes)) {
return ocrSubtypes;
}
// Attempt to match OCR subtypes against valid options (case-insensitive)
if (ocrSubtypes && ocrSubtypes.length > 0) {
const validOptions = getSubtypesForCategory(category);
const matched = ocrSubtypes
.map(s => validOptions.find(v => v.toLowerCase() === s.toLowerCase().trim()))
.filter((v): v is string => v !== undefined);
if (matched.length > 0) return matched;
}
// Default to first subtype of category
const defaults = getSubtypesForCategory(category);
return [defaults[0] as string];
}
// ========================
// Error Handling & Retries
// ========================
private async handleProcessingError(
emailId: string,
senderEmail: string,
_subject: string | null,
error: unknown
): Promise<void> {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('Email processing pipeline error', { emailId, error: errorMessage });
// Get current queue entry for retry count and userId
const queueEntry = await this.repository.getQueueEntry(emailId);
const currentRetryCount = queueEntry?.retryCount || 0;
const newRetryCount = currentRetryCount + 1;
if (newRetryCount < MAX_RETRY_COUNT) {
// Mark for retry
await this.repository.updateQueueStatus(emailId, 'failed', {
errorMessage,
retryCount: newRetryCount,
});
logger.info('Email queued for retry', {
emailId,
retryCount: newRetryCount,
maxRetries: MAX_RETRY_COUNT,
});
} else {
// Max retries exceeded - permanently failed
await this.repository.updateQueueStatus(emailId, 'failed', {
errorMessage: `Max retries (${MAX_RETRY_COUNT}) exceeded. Last error: ${errorMessage}`,
retryCount: newRetryCount,
});
// Send failure notification (email + in-app if userId available)
await this.notificationHandler.notifyProcessingFailure({
userId: queueEntry?.userId,
userEmail: senderEmail,
errorReason: errorMessage,
}).catch(notifyErr => {
logger.error('Failed to send failure notification', {
emailId,
error: notifyErr instanceof Error ? notifyErr.message : String(notifyErr),
});
});
}
}
private async handleUnregisteredSender(
emailId: string,
senderEmail: string
): Promise<void> {
logger.info('Unregistered sender rejected', { emailId, senderEmail });
await this.repository.updateQueueStatus(emailId, 'failed', {
errorMessage: 'Sender email is not registered with MotoVaultPro',
});
await this.notificationHandler.notifyUnregisteredSender(senderEmail).catch(error => {
logger.error('Failed to send unregistered sender notification', {
emailId,
error: error instanceof Error ? error.message : String(error),
});
});
}
private async handleNoValidAttachments(
emailId: string,
userId: string,
userName: string,
userEmail: string
): Promise<void> {
logger.info('No valid attachments found', { emailId });
await this.repository.updateQueueStatus(emailId, 'failed', {
errorMessage: 'No valid attachments found. Supported types: PDF, PNG, JPG, JPEG, HEIC (max 10MB each)',
});
await this.notificationHandler.notifyNoValidAttachments(userId, userName, userEmail).catch(error => {
logger.error('Failed to send no-attachments notification', {
emailId,
error: error instanceof Error ? error.message : String(error),
});
});
}
private async handleOcrFailure(
emailId: string,
userId: string,
userName: string,
userEmail: string,
reason: string
): Promise<void> {
logger.info('OCR extraction failed for all attachments', { emailId, reason });
await this.repository.updateQueueStatus(emailId, 'failed', {
errorMessage: reason,
});
await this.notificationHandler.notifyOcrFailure(userId, userName, userEmail, reason).catch(error => {
logger.error('Failed to send OCR failure notification', {
emailId,
error: error instanceof Error ? error.message : String(error),
});
});
}
}

View File

@@ -0,0 +1,114 @@
/**
* @ai-summary TypeScript types for the email ingestion feature
* @ai-context Covers database records, status enums, and Resend webhook payloads
*/
// ========================
// Status Enums
// ========================
export type EmailIngestionStatus = 'pending' | 'processing' | 'completed' | 'failed';
export type PendingAssociationStatus = 'pending' | 'resolved' | 'expired';
export type EmailRecordType = 'fuel_log' | 'maintenance_record';
// ========================
// Receipt Classification
// ========================
export type ReceiptClassificationType = 'fuel' | 'maintenance' | 'unclassified';
export interface ClassificationResult {
type: ReceiptClassificationType;
confidence: number;
}
// ========================
// Database Records
// ========================
export interface EmailIngestionQueueRecord {
id: string;
emailId: string;
senderEmail: string;
userId: string;
receivedAt: string;
subject: string | null;
status: EmailIngestionStatus;
processingResult: EmailProcessingResult | null;
errorMessage: string | null;
retryCount: number;
createdAt: string;
updatedAt: string;
}
export interface PendingVehicleAssociation {
id: string;
userId: string;
recordType: EmailRecordType;
extractedData: ExtractedReceiptData;
documentId: string | null;
status: PendingAssociationStatus;
createdAt: string;
resolvedAt: string | null;
}
// ========================
// Processing Results
// ========================
export interface EmailProcessingResult {
recordType: EmailRecordType;
vehicleId: string | null;
recordId: string | null;
documentId: string | null;
pendingAssociationId: string | null;
extractedData: ExtractedReceiptData;
}
export interface ExtractedReceiptData {
vendor: string | null;
date: string | null;
total: number | null;
odometerReading: number | null;
/** Fuel-specific fields */
gallons: number | null;
pricePerGallon: number | null;
fuelType: string | null;
/** Maintenance-specific fields */
category: string | null;
subtypes: string[] | null;
shopName: string | null;
description: string | null;
}
// ========================
// Resend Webhook Payloads
// ========================
/** Top-level Resend webhook event envelope */
export interface ResendWebhookEvent {
type: string;
created_at: string;
data: ResendWebhookEventData;
}
/** Resend email.received webhook event data */
export interface ResendWebhookEventData {
email_id: string;
from: string;
to: string[];
subject: string;
text: string | null;
html: string | null;
created_at: string;
attachments: ResendEmailAttachment[];
}
/** Attachment metadata from Resend inbound email */
export interface ResendEmailAttachment {
filename: string;
content_type: string;
content: string;
}

View File

@@ -0,0 +1,333 @@
/**
* @ai-summary Notification handler for the email ingestion pipeline
* @ai-context Encapsulates all email replies, in-app notifications, and notification logging
* for the email-to-record flow. Every email sent is logged to notification_logs.
*/
import { logger } from '../../../core/logging/logger';
import { NotificationsRepository } from '../../notifications/data/notifications.repository';
import { TemplateService } from '../../notifications/domain/template.service';
import { EmailService } from '../../notifications/domain/email.service';
import type { TemplateKey } from '../../notifications/domain/notifications.types';
import type { EmailRecordType, ExtractedReceiptData } from './email-ingestion.types';
export class EmailIngestionNotificationHandler {
constructor(
private notificationsRepository: NotificationsRepository,
private templateService: TemplateService,
private emailService: EmailService,
) {}
// ========================
// Success Notifications
// ========================
/**
* Notify user that their emailed receipt was successfully processed.
* Sends confirmation email + creates in-app notification + logs to notification_logs.
*/
async notifyReceiptProcessed(params: {
userId: string;
userName: string;
userEmail: string;
vehicleName: string;
recordType: EmailRecordType;
recordId: string | null;
vehicleId: string;
extractedData: ExtractedReceiptData;
}): Promise<void> {
const { userId, userName, userEmail, vehicleName, recordType, recordId, vehicleId, extractedData } = params;
const recordLabel = recordType === 'fuel_log' ? 'Fuel Log' : 'Maintenance Record';
const merchantName = extractedData.vendor || extractedData.shopName || 'Unknown';
// In-app notification
const message = recordId
? `Your emailed ${recordLabel.toLowerCase()} receipt from ${merchantName} has been processed and recorded for ${vehicleName}.`
: `Your emailed ${recordLabel.toLowerCase()} receipt from ${merchantName} was processed but the record could not be created automatically. Please add it manually.`;
await this.notificationsRepository.insertUserNotification({
userId,
notificationType: 'email_ingestion',
title: recordId ? 'Receipt Processed' : 'Receipt Partially Processed',
message,
referenceType: recordType,
referenceId: recordId ?? undefined,
vehicleId,
});
// Confirmation email
await this.sendTemplateEmail({
templateKey: 'receipt_processed',
userId,
userEmail,
variables: {
userName,
vehicleName,
recordType: recordLabel,
merchantName,
totalAmount: extractedData.total?.toFixed(2) || 'N/A',
date: extractedData.date || 'N/A',
},
referenceType: 'email_ingestion',
referenceId: recordId ?? undefined,
});
}
// ========================
// Pending Vehicle Notification
// ========================
/**
* Notify multi-vehicle user that their receipt needs vehicle selection.
* Sends pending-vehicle email + creates in-app notification + logs to notification_logs.
*/
async notifyPendingVehicleSelection(params: {
userId: string;
userName: string;
userEmail: string;
recordType: EmailRecordType;
pendingAssociationId: string;
extractedData: ExtractedReceiptData;
}): Promise<void> {
const { userId, userName, userEmail, recordType, pendingAssociationId, extractedData } = params;
const recordLabel = recordType === 'fuel_log' ? 'Fuel Log' : 'Maintenance Record';
const merchantName = extractedData.vendor || extractedData.shopName || 'Unknown';
// In-app notification
await this.notificationsRepository.insertUserNotification({
userId,
notificationType: 'email_ingestion',
title: 'Vehicle Selection Required',
message: `Your emailed receipt from ${merchantName} has been processed. Please select which vehicle this ${recordLabel.toLowerCase()} belongs to.`,
referenceType: 'pending_vehicle_association',
referenceId: pendingAssociationId,
});
// Pending vehicle email
await this.sendTemplateEmail({
templateKey: 'receipt_pending_vehicle',
userId,
userEmail,
variables: {
userName,
recordType: recordLabel,
merchantName,
totalAmount: extractedData.total?.toFixed(2) || 'N/A',
date: extractedData.date || 'N/A',
},
referenceType: 'email_ingestion',
referenceId: pendingAssociationId,
});
}
// ========================
// Error Notifications
// ========================
/**
* Notify unregistered sender that their email was rejected.
* Email reply only (no in-app notification since no user account).
*/
async notifyUnregisteredSender(userEmail: string): Promise<void> {
await this.sendTemplateEmail({
templateKey: 'receipt_failed',
userEmail,
variables: {
userName: 'MotoVaultPro User',
errorReason: 'This email address is not registered with MotoVaultPro.',
guidance: 'Please send receipts from the email address associated with your account. You can check your registered email in your MotoVaultPro profile settings.',
},
referenceType: 'email_ingestion',
});
}
/**
* Notify user that they must add a vehicle before emailing receipts.
* Sends error email + creates in-app notification + logs to notification_logs.
*/
async notifyNoVehicles(userId: string, userName: string, userEmail: string): Promise<void> {
// In-app notification
await this.notificationsRepository.insertUserNotification({
userId,
notificationType: 'email_ingestion',
title: 'Receipt Processing Failed',
message: 'Your emailed receipt could not be processed because you have no vehicles registered. Please add a vehicle first, then re-send your receipt.',
});
// Error email
await this.sendTemplateEmail({
templateKey: 'receipt_failed',
userId,
userEmail,
variables: {
userName,
errorReason: 'You do not have any vehicles registered in MotoVaultPro.',
guidance: 'Please add a vehicle first in the MotoVaultPro app, then re-send your receipt.',
},
referenceType: 'email_ingestion',
});
}
/**
* Notify user that no valid attachments were found in their email.
* Sends error email + creates in-app notification + logs to notification_logs.
*/
async notifyNoValidAttachments(userId: string, userName: string, userEmail: string): Promise<void> {
// In-app notification
await this.notificationsRepository.insertUserNotification({
userId,
notificationType: 'email_ingestion',
title: 'Receipt Processing Failed',
message: 'No valid attachments were found in your email. Please attach receipt images (PNG, JPG, JPEG, HEIC) or PDFs under 10MB.',
});
// Error email
await this.sendTemplateEmail({
templateKey: 'receipt_failed',
userId,
userEmail,
variables: {
userName,
errorReason: 'No valid attachments were found in your email.',
guidance: 'Please attach receipt images (PNG, JPG, JPEG, HEIC) or PDFs under 10MB. Make sure your receipt is clearly visible in the image.',
},
referenceType: 'email_ingestion',
});
}
/**
* Notify user that OCR extraction failed after all attempts.
* Sends error email + creates in-app notification + logs to notification_logs.
*/
async notifyOcrFailure(userId: string, userName: string, userEmail: string, reason: string): Promise<void> {
// In-app notification
await this.notificationsRepository.insertUserNotification({
userId,
notificationType: 'email_ingestion',
title: 'Receipt Processing Failed',
message: `We could not extract data from your receipt: ${reason}`,
});
// Error email
await this.sendTemplateEmail({
templateKey: 'receipt_failed',
userId,
userEmail,
variables: {
userName,
errorReason: reason,
guidance: 'Please try again with a clearer image or PDF. Tips: use a well-lit, high-contrast photo; ensure text is legible and not cut off; PDF receipts work best.',
},
referenceType: 'email_ingestion',
});
}
/**
* Notify user of a general processing failure (after max retries exceeded).
* Sends error email + creates in-app notification (if userId available) + logs.
*/
async notifyProcessingFailure(params: {
userId?: string;
userName?: string;
userEmail: string;
errorReason: string;
}): Promise<void> {
const { userId, userName, userEmail, errorReason } = params;
// In-app notification (only if we have a userId)
if (userId) {
await this.notificationsRepository.insertUserNotification({
userId,
notificationType: 'email_ingestion',
title: 'Receipt Processing Failed',
message: `Your emailed receipt could not be processed: ${errorReason}`,
});
}
// Error email
await this.sendTemplateEmail({
templateKey: 'receipt_failed',
userId,
userEmail,
variables: {
userName: userName || 'MotoVaultPro User',
errorReason,
guidance: 'Please try again or upload the receipt directly through the MotoVaultPro app.',
},
referenceType: 'email_ingestion',
});
}
// ========================
// Internal Helpers
// ========================
/**
* Send a templated email and log to notification_logs.
* Swallows errors to prevent notification failures from breaking the pipeline.
*/
private async sendTemplateEmail(params: {
templateKey: TemplateKey;
userId?: string;
userEmail: string;
variables: Record<string, string | number | boolean | null | undefined>;
referenceType?: string;
referenceId?: string;
}): Promise<void> {
const { templateKey, userId, userEmail, variables, referenceType, referenceId } = params;
try {
const template = await this.notificationsRepository.getEmailTemplateByKey(templateKey);
if (!template || !template.isActive) {
logger.warn('Email template not found or inactive', { templateKey });
return;
}
const renderedSubject = this.templateService.render(template.subject, variables);
const renderedHtml = this.templateService.renderEmailHtml(template.body, variables);
await this.emailService.send(userEmail, renderedSubject, renderedHtml);
// Log successful send
if (userId) {
await this.notificationsRepository.insertNotificationLog({
user_id: userId,
notification_type: 'email',
template_key: templateKey,
recipient_email: userEmail,
subject: renderedSubject,
reference_type: referenceType,
reference_id: referenceId,
status: 'sent',
});
}
logger.info('Email ingestion notification sent', { templateKey, userEmail });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('Failed to send email ingestion notification', {
templateKey,
userEmail,
error: errorMessage,
});
// Log failed send
if (userId) {
await this.notificationsRepository.insertNotificationLog({
user_id: userId,
notification_type: 'email',
template_key: templateKey,
recipient_email: userEmail,
reference_type: referenceType,
reference_id: referenceId,
status: 'failed',
error_message: errorMessage,
}).catch(logErr => {
logger.error('Failed to log notification failure', {
error: logErr instanceof Error ? logErr.message : String(logErr),
});
});
}
}
}
}

View File

@@ -0,0 +1,130 @@
/**
* @ai-summary Classifies receipt type from email text or OCR raw text
* @ai-context Uses keyword matching to determine fuel vs maintenance receipts
* before falling back to OCR-based classification. Returns confidence score.
*/
import { logger } from '../../../core/logging/logger';
import type { ClassificationResult, ReceiptClassificationType } from './email-ingestion.types';
/** Fuel-related keywords (case-insensitive matching) */
const FUEL_KEYWORDS: string[] = [
'gas',
'fuel',
'gallons',
'octane',
'pump',
'diesel',
'unleaded',
'shell',
'chevron',
'exxon',
'bp',
];
/** Maintenance-related keywords (case-insensitive matching). Multi-word entries matched as phrases. */
const MAINTENANCE_KEYWORDS: string[] = [
'oil change',
'brake',
'alignment',
'tire',
'rotation',
'inspection',
'labor',
'parts',
'service',
'repair',
'transmission',
'coolant',
];
/** Minimum keyword matches required for a confident classification */
const CONFIDENCE_THRESHOLD = 2;
export class ReceiptClassifier {
/**
* Classify receipt type from email subject and body text.
* Returns a confident result if >= 2 keyword matches for one type.
*/
classifyFromText(subject: string | null, body: string | null): ClassificationResult {
const text = [subject || '', body || ''].join(' ');
return this.classifyText(text, 'email');
}
/**
* Classify receipt type from OCR raw text output.
* Uses same keyword matching as email text classification.
*/
classifyFromOcrRawText(rawText: string): ClassificationResult {
return this.classifyText(rawText, 'ocr');
}
/**
* Core keyword matching logic shared by email and OCR classification.
*/
private classifyText(text: string, source: 'email' | 'ocr'): ClassificationResult {
const normalizedText = text.toLowerCase();
const fuelMatches = this.countKeywordMatches(normalizedText, FUEL_KEYWORDS);
const maintenanceMatches = this.countKeywordMatches(normalizedText, MAINTENANCE_KEYWORDS);
logger.info('Receipt classification keyword analysis', {
source,
fuelMatches,
maintenanceMatches,
textLength: text.length,
});
// Both below threshold - unclassified
if (fuelMatches < CONFIDENCE_THRESHOLD && maintenanceMatches < CONFIDENCE_THRESHOLD) {
return { type: 'unclassified', confidence: 0 };
}
// Clear winner with threshold met
if (fuelMatches >= CONFIDENCE_THRESHOLD && fuelMatches > maintenanceMatches) {
return {
type: 'fuel',
confidence: Math.min(fuelMatches / (fuelMatches + maintenanceMatches), 1),
};
}
if (maintenanceMatches >= CONFIDENCE_THRESHOLD && maintenanceMatches > fuelMatches) {
return {
type: 'maintenance',
confidence: Math.min(maintenanceMatches / (fuelMatches + maintenanceMatches), 1),
};
}
// Tie with both meeting threshold - unclassified (ambiguous)
if (fuelMatches >= CONFIDENCE_THRESHOLD && maintenanceMatches >= CONFIDENCE_THRESHOLD) {
return { type: 'unclassified', confidence: 0 };
}
return { type: 'unclassified', confidence: 0 };
}
/**
* Count how many keywords from the list appear in the text.
* Multi-word keywords are matched as phrases.
*/
private countKeywordMatches(normalizedText: string, keywords: string[]): number {
let matches = 0;
for (const keyword of keywords) {
if (normalizedText.includes(keyword)) {
matches++;
}
}
return matches;
}
/**
* Map classifier type to the EmailRecordType used in the processing pipeline.
*/
static toRecordType(classificationType: ReceiptClassificationType): 'fuel_log' | 'maintenance_record' | null {
switch (classificationType) {
case 'fuel': return 'fuel_log';
case 'maintenance': return 'maintenance_record';
case 'unclassified': return null;
}
}
}

View File

@@ -0,0 +1,110 @@
/**
* @ai-summary Resend inbound email client for webhook verification and email parsing
* @ai-context Verifies Resend webhook signatures via Svix, fetches raw emails, parses with mailparser
*/
import { Webhook } from 'svix';
import { simpleParser } from 'mailparser';
import { logger } from '../../../core/logging/logger';
import type { ResendWebhookEvent } from '../domain/email-ingestion.types';
export interface ParsedEmailResult {
text: string | null;
html: string | null;
attachments: ParsedEmailAttachment[];
}
export interface ParsedEmailAttachment {
filename: string;
contentType: string;
content: Buffer;
size: number;
}
export class ResendInboundClient {
private webhookSecret: string | undefined;
private apiKey: string;
constructor() {
this.apiKey = process.env['RESEND_API_KEY'] || '';
this.webhookSecret = process.env['RESEND_WEBHOOK_SECRET'];
}
/**
* Verify Resend webhook signature using Svix
* @throws Error if signature is invalid or secret is not configured
*/
verifyWebhookSignature(rawBody: string | Buffer, headers: Record<string, string>): ResendWebhookEvent {
if (!this.webhookSecret) {
throw new Error('RESEND_WEBHOOK_SECRET is not configured');
}
const wh = new Webhook(this.webhookSecret);
const verified = wh.verify(
typeof rawBody === 'string' ? rawBody : rawBody.toString(),
{
'svix-id': headers['svix-id'] || '',
'svix-timestamp': headers['svix-timestamp'] || '',
'svix-signature': headers['svix-signature'] || '',
}
);
return verified as unknown as ResendWebhookEvent;
}
/**
* Fetch email metadata from Resend API including raw download URL
*/
async getEmail(emailId: string): Promise<{ downloadUrl: string }> {
const response = await fetch(`https://api.resend.com/emails/${emailId}`, {
headers: { 'Authorization': `Bearer ${this.apiKey}` },
});
if (!response.ok) {
throw new Error(`Failed to fetch email ${emailId}: ${response.status} ${response.statusText}`);
}
const data = await response.json() as { raw?: { download_url?: string } };
const downloadUrl = data.raw?.download_url;
if (!downloadUrl) {
throw new Error(`No download URL for email ${emailId}`);
}
logger.info('Fetched email metadata from Resend', { emailId });
return { downloadUrl };
}
/**
* Download raw RFC 5322 email content from Resend download URL
*/
async downloadRawEmail(downloadUrl: string): Promise<string> {
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error(`Failed to download raw email: ${response.status} ${response.statusText}`);
}
const rawEmail = await response.text();
logger.info('Downloaded raw email', { size: rawEmail.length });
return rawEmail;
}
/**
* Parse raw RFC 5322 email into structured text/html body and attachments
*/
async parseEmail(rawEmail: string): Promise<ParsedEmailResult> {
const parsed = await simpleParser(rawEmail);
return {
text: parsed.text || null,
html: typeof parsed.html === 'string' ? parsed.html : null,
attachments: (parsed.attachments || []).map((att) => ({
filename: att.filename || 'unnamed',
contentType: att.contentType || 'application/octet-stream',
content: att.content,
size: att.size,
})),
};
}
}

View File

@@ -0,0 +1,22 @@
/**
* @ai-summary Email ingestion feature barrel export
* @ai-context Exports webhook routes, services, and types for Resend inbound email processing
*/
export { emailIngestionWebhookRoutes, emailIngestionRoutes } from './api/email-ingestion.routes';
export { EmailIngestionService } from './domain/email-ingestion.service';
export { EmailIngestionRepository } from './data/email-ingestion.repository';
export { ReceiptClassifier } from './domain/receipt-classifier';
export { ResendInboundClient } from './external/resend-inbound.client';
export type { ParsedEmailResult, ParsedEmailAttachment } from './external/resend-inbound.client';
export type {
ClassificationResult,
EmailIngestionQueueRecord,
EmailIngestionStatus,
EmailProcessingResult,
ExtractedReceiptData,
PendingVehicleAssociation,
ReceiptClassificationType,
ResendWebhookEvent,
ResendWebhookEventData,
} from './domain/email-ingestion.types';

View File

@@ -0,0 +1,71 @@
/**
* Migration: Create email ingestion tables
* @ai-summary Creates email_ingestion_queue and pending_vehicle_associations tables
* @ai-context Supports inbound email receipt processing via Resend webhooks
*/
-- email_ingestion_queue: Tracks inbound emails from Resend webhooks
CREATE TABLE IF NOT EXISTS email_ingestion_queue (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email_id VARCHAR(255) NOT NULL,
sender_email VARCHAR(255) NOT NULL,
user_id VARCHAR(255) NOT NULL,
received_at TIMESTAMP WITH TIME ZONE NOT NULL,
subject VARCHAR(500),
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN (
'pending', 'processing', 'completed', 'failed'
)),
processing_result JSONB,
error_message TEXT,
retry_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Unique constraint on email_id to prevent duplicate processing
ALTER TABLE email_ingestion_queue
ADD CONSTRAINT uq_email_ingestion_queue_email_id UNIQUE (email_id);
-- Trigger for updated_at (reuses update_updated_at_column() from vehicles feature)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'set_timestamp_email_ingestion_queue'
) THEN
CREATE TRIGGER set_timestamp_email_ingestion_queue
BEFORE UPDATE ON email_ingestion_queue
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
END IF;
END $$;
-- Indexes
CREATE INDEX IF NOT EXISTS idx_email_ingestion_queue_user_id ON email_ingestion_queue(user_id);
CREATE INDEX IF NOT EXISTS idx_email_ingestion_queue_status ON email_ingestion_queue(status);
CREATE INDEX IF NOT EXISTS idx_email_ingestion_queue_sender ON email_ingestion_queue(sender_email);
CREATE INDEX IF NOT EXISTS idx_email_ingestion_queue_received_at ON email_ingestion_queue(received_at DESC);
-- pending_vehicle_associations: Holds records needing vehicle selection (multi-vehicle users)
CREATE TABLE IF NOT EXISTS pending_vehicle_associations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id VARCHAR(255) NOT NULL,
record_type VARCHAR(30) NOT NULL CHECK (record_type IN (
'fuel_log', 'maintenance_record'
)),
extracted_data JSONB NOT NULL,
document_id UUID REFERENCES documents(id) ON DELETE SET NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN (
'pending', 'resolved', 'expired'
)),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
resolved_at TIMESTAMP WITH TIME ZONE
);
-- Trigger for pending_vehicle_associations does not need updated_at (uses resolved_at instead)
-- Indexes
CREATE INDEX IF NOT EXISTS idx_pending_vehicle_assoc_user_id ON pending_vehicle_associations(user_id);
CREATE INDEX IF NOT EXISTS idx_pending_vehicle_assoc_status ON pending_vehicle_associations(status)
WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_pending_vehicle_assoc_document_id ON pending_vehicle_associations(document_id)
WHERE document_id IS NOT NULL;

View File

@@ -0,0 +1,233 @@
/**
* Migration: Add email ingestion email templates
* @ai-summary Extends email_templates CHECK constraint and seeds 3 receipt templates
* @ai-context Templates for receipt processing confirmations, failures, and pending vehicle selection
*/
-- Extend template_key CHECK constraint to include email ingestion templates
ALTER TABLE email_templates
DROP CONSTRAINT IF EXISTS email_templates_template_key_check;
ALTER TABLE email_templates
ADD CONSTRAINT email_templates_template_key_check
CHECK (template_key IN (
'maintenance_due_soon', 'maintenance_overdue',
'document_expiring', 'document_expired',
'payment_failed_immediate', 'payment_failed_7day', 'payment_failed_1day',
'subscription_tier_change',
'receipt_processed', 'receipt_failed', 'receipt_pending_vehicle'
));
-- Insert email ingestion templates
INSERT INTO email_templates (template_key, name, description, subject, body, variables, html_body) VALUES
(
'receipt_processed',
'Receipt Processed Successfully',
'Sent when an emailed receipt is successfully processed and recorded',
'MotoVaultPro: Receipt Processed for {{vehicleName}}',
'Hi {{userName}},
Your emailed receipt has been successfully processed.
Vehicle: {{vehicleName}}
Record Type: {{recordType}}
Merchant: {{merchantName}}
Date: {{date}}
Amount: ${{totalAmount}}
The record has been added to your vehicle history.
Best regards,
MotoVaultPro Team',
'["userName", "vehicleName", "recordType", "merchantName", "totalAmount", "date"]',
'<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Receipt Processed</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #2e7d32; padding: 30px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Receipt Processed</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 30px;">
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Hi {{userName}},</p>
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Your emailed receipt has been successfully processed.</p>
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
<tr>
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #2e7d32;">
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Vehicle:</strong> {{vehicleName}}</p>
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Record Type:</strong> {{recordType}}</p>
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Merchant:</strong> {{merchantName}}</p>
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Date:</strong> {{date}}</p>
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Amount:</strong> ${{totalAmount}}</p>
</td>
</tr>
</table>
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 20px 0;">The record has been added to your vehicle history.</p>
</td>
</tr>
<tr>
<td style="padding: 20px 30px; background-color: #f9f9f9; border-top: 1px solid #e0e0e0;">
<p style="color: #666666; font-size: 14px; line-height: 1.6; margin: 0;">Best regards,<br>MotoVaultPro Team</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>'
),
(
'receipt_failed',
'Receipt Processing Failed',
'Sent when an emailed receipt fails OCR processing or validation',
'MotoVaultPro: Unable to Process Your Receipt',
'Hi {{userName}},
We were unable to process the receipt you emailed to us.
Error: {{errorReason}}
{{guidance}}
Best regards,
MotoVaultPro Team',
'["userName", "errorReason", "guidance"]',
'<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Receipt Processing Failed</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #d32f2f; padding: 30px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Processing Failed</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 30px;">
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Hi {{userName}},</p>
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">We were unable to process the receipt you emailed to us.</p>
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
<tr>
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #d32f2f;">
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Error:</strong> {{errorReason}}</p>
</td>
</tr>
</table>
<div style="background-color: #f5f5f5; padding: 20px; border-radius: 4px; margin: 20px 0;">
<p style="color: #333333; font-size: 14px; font-weight: bold; margin: 0 0 10px 0;">What to do next:</p>
<p style="color: #666666; font-size: 14px; line-height: 1.8; margin: 0;">{{guidance}}</p>
</div>
</td>
</tr>
<tr>
<td style="padding: 20px 30px; background-color: #f9f9f9; border-top: 1px solid #e0e0e0;">
<p style="color: #666666; font-size: 14px; line-height: 1.6; margin: 0;">Best regards,<br>MotoVaultPro Team</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>'
),
(
'receipt_pending_vehicle',
'Receipt Pending Vehicle Selection',
'Sent when a multi-vehicle user needs to select which vehicle a receipt belongs to',
'MotoVaultPro: Select Vehicle for Your Receipt',
'Hi {{userName}},
Your emailed receipt has been processed, but we need your help to complete the record.
Since you have multiple vehicles, please log in to MotoVaultPro and select which vehicle this receipt belongs to.
Record Type: {{recordType}}
Merchant: {{merchantName}}
Date: {{date}}
Amount: ${{totalAmount}}
You can find the pending receipt in your notifications.
Best regards,
MotoVaultPro Team',
'["userName", "recordType", "merchantName", "totalAmount", "date"]',
'<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Select Vehicle for Receipt</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #f57c00; padding: 30px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Vehicle Selection Needed</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 30px;">
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Hi {{userName}},</p>
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Your emailed receipt has been processed, but we need your help to complete the record.</p>
<div style="background-color: #fff3e0; border-left: 4px solid #f57c00; padding: 20px; margin: 20px 0;">
<p style="color: #e65100; font-size: 16px; font-weight: bold; margin: 0 0 10px 0;">Action Required</p>
<p style="color: #333333; font-size: 14px; margin: 0;">Since you have multiple vehicles, please select which vehicle this receipt belongs to.</p>
</div>
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
<tr>
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #f57c00;">
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Record Type:</strong> {{recordType}}</p>
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Merchant:</strong> {{merchantName}}</p>
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Date:</strong> {{date}}</p>
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Amount:</strong> ${{totalAmount}}</p>
</td>
</tr>
</table>
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 20px 0;">You can find the pending receipt in your notifications.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="https://motovaultpro.com/notifications" style="display: inline-block; padding: 14px 28px; background-color: #f57c00; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: bold;">Select Vehicle</a>
</div>
</td>
</tr>
<tr>
<td style="padding: 20px 30px; background-color: #f9f9f9; border-top: 1px solid #e0e0e0;">
<p style="color: #666666; font-size: 14px; line-height: 1.6; margin: 0;">Best regards,<br>MotoVaultPro Team</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>'
)
ON CONFLICT (template_key) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
subject = EXCLUDED.subject,
body = EXCLUDED.body,
variables = EXCLUDED.variables,
html_body = EXCLUDED.html_body,
updated_at = NOW();

View File

@@ -20,12 +20,12 @@ export class FuelLogsController {
async createFuelLog(request: FastifyRequest<{ Body: EnhancedCreateFuelLogRequest }>, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const fuelLog = await this.fuelLogsService.createFuelLog(request.body, userId);
return reply.code(201).send(fuelLog);
} catch (error: any) {
logger.error('Error creating fuel log', { error, userId: (request as any).user?.sub });
logger.error('Error creating fuel log', { error, userId: request.userContext?.userId });
if (error.message.includes('not found')) {
return reply.code(404).send({
@@ -49,14 +49,14 @@ export class FuelLogsController {
async getFuelLogsByVehicle(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const { vehicleId } = request.params;
const fuelLogs = await this.fuelLogsService.getFuelLogsByVehicle(vehicleId, userId);
return reply.code(200).send(fuelLogs);
} catch (error: any) {
logger.error('Error listing fuel logs', { error, vehicleId: request.params.vehicleId, userId: (request as any).user?.sub });
logger.error('Error listing fuel logs', { error, vehicleId: request.params.vehicleId, userId: request.userContext?.userId });
if (error.message.includes('not found')) {
return reply.code(404).send({
@@ -80,12 +80,12 @@ export class FuelLogsController {
async getUserFuelLogs(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const fuelLogs = await this.fuelLogsService.getUserFuelLogs(userId);
return reply.code(200).send(fuelLogs);
} catch (error: any) {
logger.error('Error listing all fuel logs', { error, userId: (request as any).user?.sub });
logger.error('Error listing all fuel logs', { error, userId: request.userContext?.userId });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get fuel logs'
@@ -95,14 +95,14 @@ export class FuelLogsController {
async getFuelLog(request: FastifyRequest<{ Params: FuelLogParams }>, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const { id } = request.params;
const fuelLog = await this.fuelLogsService.getFuelLog(id, userId);
return reply.code(200).send(fuelLog);
} catch (error: any) {
logger.error('Error getting fuel log', { error, fuelLogId: request.params.id, userId: (request as any).user?.sub });
logger.error('Error getting fuel log', { error, fuelLogId: request.params.id, userId: request.userContext?.userId });
if (error.message === 'Fuel log not found') {
return reply.code(404).send({
@@ -126,14 +126,14 @@ export class FuelLogsController {
async updateFuelLog(request: FastifyRequest<{ Params: FuelLogParams; Body: EnhancedUpdateFuelLogRequest }>, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const { id } = request.params;
const updatedFuelLog = await this.fuelLogsService.updateFuelLog(id, request.body, userId);
return reply.code(200).send(updatedFuelLog);
} catch (error: any) {
logger.error('Error updating fuel log', { error, fuelLogId: request.params.id, userId: (request as any).user?.sub });
logger.error('Error updating fuel log', { error, fuelLogId: request.params.id, userId: request.userContext?.userId });
if (error.message.includes('not found')) {
return reply.code(404).send({
@@ -163,14 +163,14 @@ export class FuelLogsController {
async deleteFuelLog(request: FastifyRequest<{ Params: FuelLogParams }>, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const { id } = request.params;
await this.fuelLogsService.deleteFuelLog(id, userId);
return reply.code(204).send();
} catch (error: any) {
logger.error('Error deleting fuel log', { error, fuelLogId: request.params.id, userId: (request as any).user?.sub });
logger.error('Error deleting fuel log', { error, fuelLogId: request.params.id, userId: request.userContext?.userId });
if (error.message.includes('not found')) {
return reply.code(404).send({
@@ -194,14 +194,14 @@ export class FuelLogsController {
async getFuelStats(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const { vehicleId } = request.params;
const stats = await this.fuelLogsService.getVehicleStats(vehicleId, userId);
return reply.code(200).send(stats);
} catch (error: any) {
logger.error('Error getting fuel stats', { error, vehicleId: request.params.vehicleId, userId: (request as any).user?.sub });
logger.error('Error getting fuel stats', { error, vehicleId: request.params.vehicleId, userId: request.userContext?.userId });
if (error.message.includes('not found')) {
return reply.code(404).send({

View File

@@ -73,7 +73,7 @@
},
"responseWithEfficiency": {
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"userId": "auth0|user123",
"userId": "550e8400-e29b-41d4-a716-446655440000",
"vehicleId": "550e8400-e29b-41d4-a716-446655440000",
"dateTime": "2024-01-15T10:30:00Z",
"odometerReading": 52000,

View File

@@ -18,7 +18,7 @@ export class MaintenanceController {
request: FastifyRequest<{ Querystring: { vehicleId?: string; category?: string } }>,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
logger.info('Maintenance records list requested', {
operation: 'maintenance.records.list',
@@ -58,7 +58,7 @@ export class MaintenanceController {
}
async getRecord(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
const recordId = request.params.id;
logger.info('Maintenance record get requested', {
@@ -102,7 +102,7 @@ export class MaintenanceController {
request: FastifyRequest<{ Params: { vehicleId: string } }>,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
const vehicleId = request.params.vehicleId;
logger.info('Maintenance records by vehicle requested', {
@@ -134,7 +134,7 @@ export class MaintenanceController {
}
async createRecord(request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
logger.info('Maintenance record create requested', {
operation: 'maintenance.records.create',
@@ -190,7 +190,7 @@ export class MaintenanceController {
request: FastifyRequest<{ Params: { id: string }; Body: unknown }>,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
const recordId = request.params.id;
logger.info('Maintenance record update requested', {
@@ -255,7 +255,7 @@ export class MaintenanceController {
}
async deleteRecord(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
const recordId = request.params.id;
logger.info('Maintenance record delete requested', {
@@ -289,7 +289,7 @@ export class MaintenanceController {
request: FastifyRequest<{ Params: { vehicleId: string } }>,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
const vehicleId = request.params.vehicleId;
logger.info('Maintenance schedules by vehicle requested', {
@@ -321,7 +321,7 @@ export class MaintenanceController {
}
async createSchedule(request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
logger.info('Maintenance schedule create requested', {
operation: 'maintenance.schedules.create',
@@ -377,7 +377,7 @@ export class MaintenanceController {
request: FastifyRequest<{ Params: { id: string }; Body: unknown }>,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
const scheduleId = request.params.id;
logger.info('Maintenance schedule update requested', {
@@ -442,7 +442,7 @@ export class MaintenanceController {
}
async deleteSchedule(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
const scheduleId = request.params.id;
logger.info('Maintenance schedule delete requested', {
@@ -476,7 +476,7 @@ export class MaintenanceController {
request: FastifyRequest<{ Params: { vehicleId: string }; Querystring: { currentMileage?: string } }>,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
const vehicleId = request.params.vehicleId;
const currentMileage = request.query.currentMileage ? parseInt(request.query.currentMileage, 10) : undefined;
@@ -510,7 +510,7 @@ export class MaintenanceController {
}
async getSubtypes(request: FastifyRequest<{ Params: { category: string } }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
const category = request.params.category;
logger.info('Maintenance subtypes requested', {

View File

@@ -21,6 +21,7 @@ export class MaintenanceRepository {
cost: row.cost,
shopName: row.shop_name,
notes: row.notes,
receiptDocumentId: row.receipt_document_id,
createdAt: row.created_at,
updatedAt: row.updated_at
};
@@ -66,11 +67,12 @@ export class MaintenanceRepository {
cost?: number | null;
shopName?: string | null;
notes?: string | null;
receiptDocumentId?: string | null;
}): Promise<MaintenanceRecord> {
const res = await this.db.query(
`INSERT INTO maintenance_records (
id, user_id, vehicle_id, category, subtypes, date, odometer_reading, cost, shop_name, notes
) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10)
id, user_id, vehicle_id, category, subtypes, date, odometer_reading, cost, shop_name, notes, receipt_document_id
) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11)
RETURNING *`,
[
record.id,
@@ -83,6 +85,7 @@ export class MaintenanceRepository {
record.cost ?? null,
record.shopName ?? null,
record.notes ?? null,
record.receiptDocumentId ?? null,
]
);
return this.mapMaintenanceRecord(res.rows[0]);
@@ -96,6 +99,26 @@ export class MaintenanceRepository {
return res.rows[0] ? this.mapMaintenanceRecord(res.rows[0]) : null;
}
async findRecordByIdWithDocument(id: string, userId: string): Promise<{ record: MaintenanceRecord; receiptDocument: { documentId: string; fileName: string; contentType: string; storageKey: string } | null } | null> {
const res = await this.db.query(
`SELECT mr.*, d.id AS doc_id, d.file_name AS doc_file_name, d.content_type AS doc_content_type, d.storage_key AS doc_storage_key
FROM maintenance_records mr
LEFT JOIN documents d ON mr.receipt_document_id = d.id
WHERE mr.id = $1 AND mr.user_id = $2`,
[id, userId]
);
if (!res.rows[0]) return null;
const row = res.rows[0];
const record = this.mapMaintenanceRecord(row);
const receiptDocument = row.doc_id ? {
documentId: row.doc_id,
fileName: row.doc_file_name,
contentType: row.doc_content_type,
storageKey: row.doc_storage_key,
} : null;
return { record, receiptDocument };
}
async findRecordsByUserId(
userId: string,
filters?: { vehicleId?: string; category?: MaintenanceCategory }

View File

@@ -10,7 +10,8 @@ import type {
MaintenanceScheduleResponse,
MaintenanceCategory,
ScheduleType,
MaintenanceCostStats
MaintenanceCostStats,
ReceiptDocumentMeta
} from './maintenance.types';
import { validateSubtypes } from './maintenance.types';
import { MaintenanceRepository } from '../data/maintenance.repository';
@@ -40,6 +41,7 @@ export class MaintenanceService {
cost: body.cost,
shopName: body.shopName,
notes: body.notes,
receiptDocumentId: body.receiptDocumentId,
});
// Auto-link: Find and update matching 'time_since_last' schedules
@@ -49,9 +51,9 @@ export class MaintenanceService {
}
async getRecord(userId: string, id: string): Promise<MaintenanceRecordResponse | null> {
const record = await this.repo.findRecordById(id, userId);
if (!record) return null;
return this.toRecordResponse(record);
const result = await this.repo.findRecordByIdWithDocument(id, userId);
if (!result) return null;
return this.toRecordResponse(result.record, result.receiptDocument);
}
async getRecords(userId: string, filters?: { vehicleId?: string; category?: MaintenanceCategory }): Promise<MaintenanceRecordResponse[]> {
@@ -272,10 +274,11 @@ export class MaintenanceService {
return { nextDueDate, nextDueMileage };
}
private toRecordResponse(record: MaintenanceRecord): MaintenanceRecordResponse {
private toRecordResponse(record: MaintenanceRecord, receiptDocument?: ReceiptDocumentMeta | null): MaintenanceRecordResponse {
return {
...record,
subtypeCount: record.subtypes.length,
receiptDocument: receiptDocument ?? null,
};
}

View File

@@ -68,6 +68,7 @@ export interface MaintenanceRecord {
cost?: number;
shopName?: string;
notes?: string;
receiptDocumentId?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -113,6 +114,7 @@ export const CreateMaintenanceRecordSchema = z.object({
cost: z.number().positive().optional(),
shopName: z.string().max(200).optional(),
notes: z.string().max(10000).optional(),
receiptDocumentId: z.string().uuid().optional(),
});
export type CreateMaintenanceRecordRequest = z.infer<typeof CreateMaintenanceRecordSchema>;
@@ -157,9 +159,18 @@ export const UpdateScheduleSchema = z.object({
});
export type UpdateScheduleRequest = z.infer<typeof UpdateScheduleSchema>;
// Receipt document metadata returned on GET
export interface ReceiptDocumentMeta {
documentId: string;
fileName: string;
contentType: string;
storageKey: string;
}
// Response types
export interface MaintenanceRecordResponse extends MaintenanceRecord {
subtypeCount: number;
receiptDocument?: ReceiptDocumentMeta | null;
}
// TCO aggregation stats

View File

@@ -0,0 +1,7 @@
-- Add receipt_document_id FK to link maintenance records to scanned receipt documents
ALTER TABLE maintenance_records
ADD COLUMN receipt_document_id UUID REFERENCES documents(id) ON DELETE SET NULL;
-- Index for querying records by receipt document
CREATE INDEX idx_maintenance_records_receipt_document_id ON maintenance_records(receipt_document_id)
WHERE receipt_document_id IS NOT NULL;

View File

@@ -99,7 +99,7 @@
},
"maintenanceScheduleResponse": {
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"userId": "auth0|user123",
"userId": "550e8400-e29b-41d4-a716-446655440000",
"vehicleId": "550e8400-e29b-41d4-a716-446655440000",
"type": "oil_change",
"category": "routine_maintenance",

View File

@@ -97,7 +97,7 @@ Templates use `{{variableName}}` syntax for variable substitution.
### Environment Variables
- `RESEND_API_KEY` - Resend API key (required, stored in secrets)
- `FROM_EMAIL` - Sender email address (default: noreply@motovaultpro.com)
- `FROM_EMAIL` - Sender email address (default: hello@notify.motovaultpro.com)
### Email Delivery
- Uses Resend API for transactional emails

View File

@@ -24,7 +24,7 @@ export class NotificationsController {
// ========================
async getSummary(request: FastifyRequest, reply: FastifyReply) {
const userId = request.user.sub;
const userId = request.userContext!.userId;
try {
const summary = await this.service.getNotificationSummary(userId);
@@ -38,7 +38,7 @@ export class NotificationsController {
}
async getDueMaintenanceItems(request: FastifyRequest, reply: FastifyReply) {
const userId = request.user.sub;
const userId = request.userContext!.userId;
try {
const items = await this.service.getDueMaintenanceItems(userId);
@@ -52,7 +52,7 @@ export class NotificationsController {
}
async getExpiringDocuments(request: FastifyRequest, reply: FastifyReply) {
const userId = request.user.sub;
const userId = request.userContext!.userId;
try {
const documents = await this.service.getExpiringDocuments(userId);
@@ -70,7 +70,7 @@ export class NotificationsController {
// ========================
async getInAppNotifications(request: FastifyRequest, reply: FastifyReply) {
const userId = request.user!.sub!;
const userId = request.userContext!.userId;
const query = request.query as { limit?: string; includeRead?: string };
const limit = query.limit ? parseInt(query.limit, 10) : 20;
const includeRead = query.includeRead === 'true';
@@ -85,7 +85,7 @@ export class NotificationsController {
}
async getUnreadCount(request: FastifyRequest, reply: FastifyReply) {
const userId = request.user!.sub!;
const userId = request.userContext!.userId;
try {
const count = await this.service.getUnreadCount(userId);
@@ -97,7 +97,7 @@ export class NotificationsController {
}
async markAsRead(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
const userId = request.user!.sub!;
const userId = request.userContext!.userId;
const notificationId = request.params.id;
try {
@@ -113,7 +113,7 @@ export class NotificationsController {
}
async markAllAsRead(request: FastifyRequest, reply: FastifyReply) {
const userId = request.user!.sub!;
const userId = request.userContext!.userId;
try {
const count = await this.service.markAllAsRead(userId);
@@ -125,7 +125,7 @@ export class NotificationsController {
}
async deleteNotification(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
const userId = request.user!.sub!;
const userId = request.userContext!.userId;
const notificationId = request.params.id;
try {

View File

@@ -7,7 +7,7 @@
import { EMAIL_STYLES } from './email-styles';
// External logo URL - hosted on GitHub for reliability
const LOGO_URL = 'https://raw.githubusercontent.com/ericgullickson/images/c58b0e4773e8395b532f97f6ab529e38ea4dc8be/motovaultpro-auth0-small.png';
const LOGO_URL = 'https://motovaultpro.com/images/logos/motovaultpro-auth0-small.png';
/**
* Renders the complete HTML email layout with branding
@@ -65,10 +65,10 @@ export function renderEmailLayout(content: string): string {
<a href="https://motovaultpro.com" style="${EMAIL_STYLES.footerLink}" target="_blank">Login to MotoVaultPro</a>
</p>
<p style="${EMAIL_STYLES.footerText}">
<a href="{{unsubscribeUrl}}" style="${EMAIL_STYLES.footerLink}" target="_blank">Manage Email Preferences</a>
<a href="https://motovaultpro.com/settings" style="${EMAIL_STYLES.footerLink}" target="_blank">Manage Email Preferences</a>
</p>
<p style="${EMAIL_STYLES.copyright}">
&copy; {new Date().getFullYear()} MotoVaultPro. All rights reserved.
&copy; ${new Date().getFullYear()} MotoVaultPro. All rights reserved.
</p>
</td>
</tr>

View File

@@ -16,7 +16,7 @@ export class EmailService {
}
this.resend = new Resend(apiKey);
this.fromEmail = process.env['FROM_EMAIL'] || 'noreply@motovaultpro.com';
this.fromEmail = process.env['FROM_EMAIL'] || 'hello@notify.motovaultpro.com';
}
/**
@@ -33,6 +33,10 @@ export class EmailService {
to,
subject,
html,
headers: {
'List-Unsubscribe': '<https://motovaultpro.com/settings>',
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
},
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';

View File

@@ -445,6 +445,29 @@ export class NotificationsService {
reason: 'Subscription upgrade',
additionalInfo: 'You now have access to all the features included in the Pro tier. Enjoy your enhanced MotoVaultPro experience!',
};
case 'receipt_processed':
return {
...baseVariables,
vehicleName: '2024 Toyota Camry',
recordType: 'Fuel Log',
merchantName: 'Shell Gas Station',
totalAmount: '45.50',
date: new Date().toLocaleDateString(),
};
case 'receipt_failed':
return {
...baseVariables,
errorReason: 'Unable to extract receipt data from the attached image.',
guidance: 'Please try again with a clearer image or PDF. Tips: use a well-lit, high-contrast photo; ensure text is legible and not cut off; PDF receipts work best.',
};
case 'receipt_pending_vehicle':
return {
...baseVariables,
recordType: 'Maintenance Record',
merchantName: 'AutoZone',
totalAmount: '89.99',
date: new Date().toLocaleDateString(),
};
default:
return baseVariables;
}

View File

@@ -11,7 +11,10 @@ export type TemplateKey =
| 'maintenance_overdue'
| 'document_expiring'
| 'document_expired'
| 'subscription_tier_change';
| 'subscription_tier_change'
| 'receipt_processed'
| 'receipt_failed'
| 'receipt_pending_vehicle';
// Email template API response type (camelCase for frontend)
export interface EmailTemplate {
@@ -86,7 +89,10 @@ export const TemplateKeySchema = z.enum([
'maintenance_overdue',
'document_expiring',
'document_expired',
'subscription_tier_change'
'subscription_tier_change',
'receipt_processed',
'receipt_failed',
'receipt_pending_vehicle'
]);
export const UpdateEmailTemplateSchema = z.object({

View File

@@ -37,7 +37,7 @@ Backend proxy for the Python OCR microservice. Handles authentication, tier gati
| File | What | When to read |
| ---- | ---- | ------------ |
| `ocr-client.ts` | HTTP client to mvp-ocr Python service (extract, extractVin, extractReceipt, submitJob, submitManualJob, getJobStatus, isHealthy) | OCR service communication, error handling |
| `ocr-client.ts` | HTTP client to mvp-ocr Python service (extract, extractVin, extractReceipt, decodeVin, submitJob, submitManualJob, getJobStatus, isHealthy) | OCR service communication, error handling |
## tests/

View File

@@ -15,12 +15,13 @@ const SUPPORTED_TYPES = new Set([
'application/pdf',
]);
/** Image-only MIME types for receipt extraction (no PDF) */
/** Image-only MIME types for receipt extraction */
const SUPPORTED_IMAGE_TYPES = new Set([
'image/jpeg',
'image/png',
'image/heic',
'image/heif',
'application/pdf',
]);
export class OcrController {
@@ -32,7 +33,7 @@ export class OcrController {
request: FastifyRequest<{ Querystring: ExtractQuery }>,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext?.userId as string;
const preprocess = request.query.preprocess !== false;
logger.info('OCR extract requested', {
@@ -139,7 +140,7 @@ export class OcrController {
request: FastifyRequest,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext?.userId as string;
logger.info('VIN extract requested', {
operation: 'ocr.controller.extractVin',
@@ -239,7 +240,7 @@ export class OcrController {
request: FastifyRequest,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext?.userId as string;
logger.info('Receipt extract requested', {
operation: 'ocr.controller.extractReceipt',
@@ -268,7 +269,7 @@ export class OcrController {
});
return reply.code(415).send({
error: 'Unsupported Media Type',
message: `Unsupported file type: ${contentType}. Supported: JPEG, PNG, HEIC`,
message: `Unsupported file type: ${contentType}. Supported: JPEG, PNG, HEIC, PDF`,
});
}
@@ -342,6 +343,114 @@ export class OcrController {
}
}
/**
* POST /api/ocr/extract/maintenance-receipt
* Extract data from a maintenance receipt image using maintenance-specific OCR.
* Requires Pro tier (maintenance.receiptScan).
*/
async extractMaintenanceReceipt(
request: FastifyRequest,
reply: FastifyReply
) {
const userId = request.userContext?.userId as string;
logger.info('Maintenance receipt extract requested', {
operation: 'ocr.controller.extractMaintenanceReceipt',
userId,
});
const file = await (request as any).file({ limits: { files: 1 } });
if (!file) {
logger.warn('No file provided for maintenance receipt extraction', {
operation: 'ocr.controller.extractMaintenanceReceipt.no_file',
userId,
});
return reply.code(400).send({
error: 'Bad Request',
message: 'No file provided',
});
}
const contentType = file.mimetype as string;
if (!SUPPORTED_IMAGE_TYPES.has(contentType)) {
logger.warn('Unsupported file type for maintenance receipt extraction', {
operation: 'ocr.controller.extractMaintenanceReceipt.unsupported_type',
userId,
contentType,
fileName: file.filename,
});
return reply.code(415).send({
error: 'Unsupported Media Type',
message: `Unsupported file type: ${contentType}. Supported: JPEG, PNG, HEIC, PDF`,
});
}
const chunks: Buffer[] = [];
for await (const chunk of file.file) {
chunks.push(chunk);
}
const fileBuffer = Buffer.concat(chunks);
if (fileBuffer.length === 0) {
logger.warn('Empty file provided for maintenance receipt extraction', {
operation: 'ocr.controller.extractMaintenanceReceipt.empty_file',
userId,
fileName: file.filename,
});
return reply.code(400).send({
error: 'Bad Request',
message: 'Empty file provided',
});
}
try {
const result = await ocrService.extractMaintenanceReceipt(userId, {
fileBuffer,
contentType,
});
logger.info('Maintenance receipt extract completed', {
operation: 'ocr.controller.extractMaintenanceReceipt.success',
userId,
success: result.success,
receiptType: result.receiptType,
processingTimeMs: result.processingTimeMs,
});
return reply.code(200).send(result);
} catch (error: any) {
if (error.statusCode === 413) {
return reply.code(413).send({
error: 'Payload Too Large',
message: error.message,
});
}
if (error.statusCode === 415) {
return reply.code(415).send({
error: 'Unsupported Media Type',
message: error.message,
});
}
if (error.statusCode === 422) {
return reply.code(422).send({
error: 'Unprocessable Entity',
message: error.message,
});
}
logger.error('Maintenance receipt extract failed', {
operation: 'ocr.controller.extractMaintenanceReceipt.error',
userId,
error: error.message,
});
return reply.code(500).send({
error: 'Internal Server Error',
message: 'Maintenance receipt extraction failed',
});
}
}
/**
* POST /api/ocr/extract/manual
* Submit an async manual extraction job for PDF owner's manuals.
@@ -351,7 +460,7 @@ export class OcrController {
request: FastifyRequest,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext?.userId as string;
logger.info('Manual extract requested', {
operation: 'ocr.controller.extractManual',
@@ -475,7 +584,7 @@ export class OcrController {
request: FastifyRequest<{ Body: JobSubmitBody }>,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext?.userId as string;
logger.info('OCR job submit requested', {
operation: 'ocr.controller.submitJob',
@@ -582,7 +691,7 @@ export class OcrController {
request: FastifyRequest<{ Params: JobIdParams }>,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext?.userId as string;
const { jobId } = request.params;
logger.debug('OCR job status requested', {

View File

@@ -30,6 +30,12 @@ export const ocrRoutes: FastifyPluginAsync = async (
handler: ctrl.extractReceipt.bind(ctrl),
});
// POST /api/ocr/extract/maintenance-receipt - Maintenance receipt OCR extraction (Pro tier required)
fastify.post('/ocr/extract/maintenance-receipt', {
preHandler: [requireAuth, requireTier('maintenance.receiptScan')],
handler: ctrl.extractMaintenanceReceipt.bind(ctrl),
});
// POST /api/ocr/extract/manual - Manual extraction (Pro tier required)
fastify.post('/ocr/extract/manual', {
preHandler: [requireAuth, fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' })],

View File

@@ -5,6 +5,7 @@ import { logger } from '../../../core/logging/logger';
import { ocrClient, JobNotFoundError } from '../external/ocr-client';
import type {
JobResponse,
MaintenanceReceiptExtractRequest,
ManualJobResponse,
ManualJobSubmitRequest,
OcrExtractRequest,
@@ -30,12 +31,13 @@ const SUPPORTED_TYPES = new Set([
'application/pdf',
]);
/** Image-only MIME types for receipt extraction (no PDF) */
/** MIME types for receipt extraction */
const SUPPORTED_IMAGE_TYPES = new Set([
'image/jpeg',
'image/png',
'image/heic',
'image/heif',
'application/pdf',
]);
/**
@@ -221,6 +223,63 @@ export class OcrService {
}
}
/**
* Extract data from a maintenance receipt image using maintenance-specific OCR.
*
* @param userId - User ID for logging
* @param request - Maintenance receipt extraction request
* @returns Receipt extraction result
*/
async extractMaintenanceReceipt(userId: string, request: MaintenanceReceiptExtractRequest): Promise<ReceiptExtractionResponse> {
if (request.fileBuffer.length > MAX_SYNC_SIZE) {
const err: any = new Error(
`File too large. Max: ${MAX_SYNC_SIZE / (1024 * 1024)}MB.`
);
err.statusCode = 413;
throw err;
}
if (!SUPPORTED_IMAGE_TYPES.has(request.contentType)) {
const err: any = new Error(
`Unsupported file type: ${request.contentType}. Supported: ${[...SUPPORTED_IMAGE_TYPES].join(', ')}`
);
err.statusCode = 415;
throw err;
}
logger.info('Maintenance receipt extract requested', {
operation: 'ocr.service.extractMaintenanceReceipt',
userId,
contentType: request.contentType,
fileSize: request.fileBuffer.length,
});
try {
const result = await ocrClient.extractMaintenanceReceipt(
request.fileBuffer,
request.contentType
);
logger.info('Maintenance receipt extract completed', {
operation: 'ocr.service.extractMaintenanceReceipt.success',
userId,
success: result.success,
receiptType: result.receiptType,
fieldCount: Object.keys(result.extractedFields).length,
processingTimeMs: result.processingTimeMs,
});
return result;
} catch (error) {
logger.error('Maintenance receipt extract failed', {
operation: 'ocr.service.extractMaintenanceReceipt.error',
userId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
/**
* Submit an async OCR job for large files.
*

View File

@@ -62,6 +62,12 @@ export interface ReceiptExtractRequest {
receiptType?: string;
}
/** Request for maintenance receipt extraction */
export interface MaintenanceReceiptExtractRequest {
fileBuffer: Buffer;
contentType: string;
}
/** Response from VIN-specific extraction */
export interface VinExtractionResponse {
success: boolean;
@@ -125,3 +131,21 @@ export interface ManualJobResponse {
result?: ManualExtractionResult;
error?: string;
}
/** Response from VIN decode via Gemini (OCR service) */
export interface VinDecodeResponse {
success: boolean;
vin: string;
year: number | null;
make: string | null;
model: string | null;
trimLevel: string | null;
bodyType: string | null;
driveType: string | null;
fuelType: string | null;
engine: string | null;
transmission: string | null;
confidence: number;
processingTimeMs: number;
error: string | null;
}

View File

@@ -2,7 +2,7 @@
* @ai-summary HTTP client for OCR service communication
*/
import { logger } from '../../../core/logging/logger';
import type { JobResponse, ManualJobResponse, OcrResponse, ReceiptExtractionResponse, VinExtractionResponse } from '../domain/ocr.types';
import type { JobResponse, ManualJobResponse, OcrResponse, ReceiptExtractionResponse, VinDecodeResponse, VinExtractionResponse } from '../domain/ocr.types';
/** OCR service configuration */
const OCR_SERVICE_URL = process.env.OCR_SERVICE_URL || 'http://mvp-ocr:8000';
@@ -177,6 +177,57 @@ export class OcrClient {
return result;
}
/**
* Extract data from a maintenance receipt image using maintenance-specific OCR.
*
* @param fileBuffer - Image file buffer
* @param contentType - MIME type of the file
* @returns Receipt extraction result (receiptType: "maintenance")
*/
async extractMaintenanceReceipt(
fileBuffer: Buffer,
contentType: string
): Promise<ReceiptExtractionResponse> {
const formData = this.buildFormData(fileBuffer, contentType);
const url = `${this.baseUrl}/extract/maintenance-receipt`;
logger.info('OCR maintenance receipt extract request', {
operation: 'ocr.client.extractMaintenanceReceipt',
url,
contentType,
fileSize: fileBuffer.length,
});
const response = await this.fetchWithTimeout(url, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorText = await response.text();
logger.error('OCR maintenance receipt extract failed', {
operation: 'ocr.client.extractMaintenanceReceipt.error',
status: response.status,
error: errorText,
});
const err: any = new Error(`OCR service error: ${response.status} - ${errorText}`);
err.statusCode = response.status;
throw err;
}
const result = (await response.json()) as ReceiptExtractionResponse;
logger.info('OCR maintenance receipt extract completed', {
operation: 'ocr.client.extractMaintenanceReceipt.success',
success: result.success,
receiptType: result.receiptType,
fieldCount: Object.keys(result.extractedFields).length,
processingTimeMs: result.processingTimeMs,
});
return result;
}
/**
* Submit an async OCR job for large files.
*
@@ -322,6 +373,55 @@ export class OcrClient {
return result;
}
/**
* Decode a VIN string into structured vehicle data via Gemini.
*
* Unlike other OCR methods, this sends JSON (not multipart) because
* VIN decode has no file upload.
*
* @param vin - 17-character Vehicle Identification Number
* @returns Structured vehicle data from Gemini decode
*/
async decodeVin(vin: string): Promise<VinDecodeResponse> {
const url = `${this.baseUrl}/decode/vin`;
logger.info('OCR VIN decode request', {
operation: 'ocr.client.decodeVin',
url,
vin,
});
const response = await this.fetchWithTimeout(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vin }),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('OCR VIN decode failed', {
operation: 'ocr.client.decodeVin.error',
status: response.status,
error: errorText,
});
const err: any = new Error(`OCR service error: ${response.status} - ${errorText}`);
err.statusCode = response.status;
throw err;
}
const result = (await response.json()) as VinDecodeResponse;
logger.info('OCR VIN decode completed', {
operation: 'ocr.client.decodeVin.success',
success: result.success,
vin: result.vin,
confidence: result.confidence,
processingTimeMs: result.processingTimeMs,
});
return result;
}
/**
* Check if the OCR service is healthy.
*

View File

@@ -51,7 +51,7 @@ export class OnboardingController {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error in savePreferences controller', {
error,
userId: (request as AuthenticatedRequest).user?.sub,
userId: request.userContext?.userId,
});
if (errorMessage === 'User profile not found') {
@@ -86,7 +86,7 @@ export class OnboardingController {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error in completeOnboarding controller', {
error,
userId: (request as AuthenticatedRequest).user?.sub,
userId: request.userContext?.userId,
});
if (errorMessage === 'User profile not found') {
@@ -124,7 +124,7 @@ export class OnboardingController {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error in getStatus controller', {
error,
userId: (request as AuthenticatedRequest).user?.sub,
userId: request.userContext?.userId,
});
if (errorMessage === 'User profile not found') {

View File

@@ -7,7 +7,7 @@ export class OwnershipCostsController {
private readonly service = new OwnershipCostsService();
async list(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
logger.info('Ownership costs list requested', {
operation: 'ownership-costs.list',
@@ -35,7 +35,7 @@ export class OwnershipCostsController {
}
async get(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
const costId = request.params.id;
logger.info('Ownership cost get requested', {
@@ -66,7 +66,7 @@ export class OwnershipCostsController {
}
async create(request: FastifyRequest<{ Body: CreateBody }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
logger.info('Ownership cost create requested', {
operation: 'ownership-costs.create',
@@ -91,7 +91,7 @@ export class OwnershipCostsController {
}
async update(request: FastifyRequest<{ Params: IdParams; Body: UpdateBody }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
const costId = request.params.id;
logger.info('Ownership cost update requested', {
@@ -123,7 +123,7 @@ export class OwnershipCostsController {
}
async remove(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext!.userId;
const costId = request.params.id;
logger.info('Ownership cost delete requested', {

View File

@@ -117,7 +117,7 @@ platform/
When implemented, VIN decoding will use:
1. **Cache First**: Check Redis (7-day TTL for success, 1-hour for failures)
2. **PostgreSQL**: Database function for high-confidence decode
3. **vPIC Fallback**: NHTSA vPIC API with circuit breaker protection
3. **OCR Service Fallback**: Gemini VIN decode via OCR service
4. **Graceful Degradation**: Return meaningful errors when all sources fail
### Database Schema
@@ -164,7 +164,7 @@ When VIN decoding is implemented:
### External APIs (Planned/Future)
When VIN decoding is implemented:
- **NHTSA vPIC**: https://vpic.nhtsa.dot.gov/api (VIN decoding fallback)
- **OCR Service**: Gemini VIN decode via mvp-ocr (VIN decoding fallback)
### Database Tables
- **vehicle_options** - Hierarchical vehicle data (years, makes, models, trims, engines, transmissions)
@@ -269,7 +269,7 @@ npm run lint
## Future Considerations
### Planned Features
- VIN decoding endpoint with PostgreSQL + vPIC fallback
- VIN decoding endpoint with PostgreSQL + Gemini/OCR service fallback
- Circuit breaker pattern for external API resilience
### Potential Enhancements

View File

@@ -61,19 +61,3 @@ export interface VINDecodeResponse {
error?: string;
}
/**
* vPIC API response structure (NHTSA)
*/
export interface VPICVariable {
Variable: string;
Value: string | null;
ValueId: string | null;
VariableId: number;
}
export interface VPICResponse {
Count: number;
Message: string;
SearchCriteria: string;
Results: VPICVariable[];
}

View File

@@ -47,7 +47,7 @@ export class CommunityStationsController {
reply: FastifyReply
): Promise<void> {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
// Validate request body
const validation = submitCommunityStationSchema.safeParse(request.body);
@@ -62,7 +62,7 @@ export class CommunityStationsController {
return reply.code(201).send(station);
} catch (error: any) {
logger.error('Error submitting station', { error, userId: (request as any).user?.sub });
logger.error('Error submitting station', { error, userId: request.userContext?.userId });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to submit station'
@@ -79,7 +79,7 @@ export class CommunityStationsController {
reply: FastifyReply
): Promise<void> {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
// Validate query params
const validation = paginationSchema.safeParse(request.query);
@@ -94,7 +94,7 @@ export class CommunityStationsController {
return reply.code(200).send(result);
} catch (error: any) {
logger.error('Error getting user submissions', { error, userId: (request as any).user?.sub });
logger.error('Error getting user submissions', { error, userId: request.userContext?.userId });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to retrieve submissions'
@@ -111,7 +111,7 @@ export class CommunityStationsController {
reply: FastifyReply
): Promise<void> {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
// Validate params
const validation = stationIdSchema.safeParse(request.params);
@@ -128,7 +128,7 @@ export class CommunityStationsController {
} catch (error: any) {
logger.error('Error withdrawing submission', {
error,
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
stationId: request.params.id
});
@@ -252,7 +252,7 @@ export class CommunityStationsController {
reply: FastifyReply
): Promise<void> {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
// Validate params
const paramsValidation = stationIdSchema.safeParse(request.params);
@@ -280,7 +280,7 @@ export class CommunityStationsController {
return reply.code(200).send(result);
} catch (error: any) {
logger.error('Error reporting removal', { error, userId: (request as any).user?.sub });
logger.error('Error reporting removal', { error, userId: request.userContext?.userId });
if (error.message.includes('not found')) {
return reply.code(404).send({
@@ -379,7 +379,7 @@ export class CommunityStationsController {
reply: FastifyReply
): Promise<void> {
try {
const adminId = (request as any).user.sub;
const adminId = request.userContext!.userId;
// Validate params
const paramsValidation = stationIdSchema.safeParse(request.params);
@@ -422,7 +422,7 @@ export class CommunityStationsController {
return reply.code(200).send(station);
} catch (error: any) {
logger.error('Error reviewing station', { error, adminId: (request as any).user?.sub });
logger.error('Error reviewing station', { error, adminId: request.userContext?.userId });
if (error.message.includes('not found')) {
return reply.code(404).send({

View File

@@ -27,7 +27,7 @@ export class StationsController {
async searchStations(request: FastifyRequest<{ Body: StationSearchBody }>, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const { latitude, longitude, radius, fuelType } = request.body;
if (!latitude || !longitude) {
@@ -46,7 +46,7 @@ export class StationsController {
return reply.code(200).send(result);
} catch (error: any) {
logger.error('Error searching stations', { error, userId: (request as any).user?.sub });
logger.error('Error searching stations', { error, userId: request.userContext?.userId });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to search stations'
@@ -79,7 +79,7 @@ export class StationsController {
async saveStation(request: FastifyRequest<{ Body: SaveStationBody }>, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const {
placeId,
nickname,
@@ -106,7 +106,7 @@ export class StationsController {
return reply.code(201).send(result);
} catch (error: any) {
logger.error('Error saving station', { error, userId: (request as any).user?.sub });
logger.error('Error saving station', { error, userId: request.userContext?.userId });
if (error.message.includes('not found')) {
return reply.code(404).send({
@@ -127,7 +127,7 @@ export class StationsController {
reply: FastifyReply
) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const { placeId } = request.params;
const result = await this.stationsService.updateSavedStation(placeId, userId, request.body);
@@ -137,7 +137,7 @@ export class StationsController {
logger.error('Error updating saved station', {
error,
placeId: request.params.placeId,
userId: (request as any).user?.sub
userId: request.userContext?.userId
});
if (error.message.includes('not found')) {
@@ -156,12 +156,12 @@ export class StationsController {
async getSavedStations(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const result = await this.stationsService.getUserSavedStations(userId);
return reply.code(200).send(result);
} catch (error: any) {
logger.error('Error getting saved stations', { error, userId: (request as any).user?.sub });
logger.error('Error getting saved stations', { error, userId: request.userContext?.userId });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get saved stations'
@@ -171,14 +171,14 @@ export class StationsController {
async removeSavedStation(request: FastifyRequest<{ Params: StationParams }>, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const { placeId } = request.params;
await this.stationsService.removeSavedStation(placeId, userId);
return reply.code(204).send();
} catch (error: any) {
logger.error('Error removing saved station', { error, placeId: request.params.placeId, userId: (request as any).user?.sub });
logger.error('Error removing saved station', { error, placeId: request.params.placeId, userId: request.userContext?.userId });
if (error.message.includes('not found')) {
return reply.code(404).send({

View File

@@ -12,8 +12,8 @@ describe('Community Stations API Integration Tests', () => {
let app: FastifyInstance;
let pool: Pool;
const testUserId = 'auth0|test-user-123';
const testAdminId = 'auth0|test-admin-123';
const testUserId = '550e8400-e29b-41d4-a716-446655440000';
const testAdminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const mockStationData = {
name: 'Test Gas Station',

View File

@@ -28,7 +28,7 @@ export class DonationsController {
*/
async createDonation(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const { amount } = request.body as CreateDonationBody;
logger.info('Creating donation', { userId, amount });
@@ -63,7 +63,7 @@ export class DonationsController {
*/
async getDonations(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
logger.info('Getting donations', { userId });

View File

@@ -24,7 +24,7 @@ export class SubscriptionsController {
*/
async getSubscription(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const subscription = await this.service.getSubscription(userId);
@@ -39,7 +39,7 @@ export class SubscriptionsController {
reply.status(200).send(subscription);
} catch (error: any) {
logger.error('Failed to get subscription', {
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
error: error.message,
});
reply.status(500).send({
@@ -54,14 +54,14 @@ export class SubscriptionsController {
*/
async checkNeedsVehicleSelection(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const result = await this.service.checkNeedsVehicleSelection(userId);
reply.status(200).send(result);
} catch (error: any) {
logger.error('Failed to check needs vehicle selection', {
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
error: error.message,
});
reply.status(500).send({
@@ -85,8 +85,8 @@ export class SubscriptionsController {
reply: FastifyReply
): Promise<void> {
try {
const userId = (request as any).user.sub;
const email = (request as any).user.email;
const userId = request.userContext!.userId;
const email = request.userContext!.email || '';
const { tier, billingCycle, paymentMethodId } = request.body;
// Validate inputs
@@ -134,13 +134,14 @@ export class SubscriptionsController {
userId,
tier,
billingCycle,
paymentMethodId || ''
paymentMethodId || '',
email
);
reply.status(200).send(updatedSubscription);
} catch (error: any) {
logger.error('Failed to create checkout', {
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
error: error.message,
});
reply.status(500).send({
@@ -155,14 +156,14 @@ export class SubscriptionsController {
*/
async cancelSubscription(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const subscription = await this.service.cancelSubscription(userId);
reply.status(200).send(subscription);
} catch (error: any) {
logger.error('Failed to cancel subscription', {
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
error: error.message,
});
reply.status(500).send({
@@ -177,14 +178,14 @@ export class SubscriptionsController {
*/
async reactivateSubscription(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const subscription = await this.service.reactivateSubscription(userId);
reply.status(200).send(subscription);
} catch (error: any) {
logger.error('Failed to reactivate subscription', {
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
error: error.message,
});
reply.status(500).send({
@@ -206,7 +207,8 @@ export class SubscriptionsController {
reply: FastifyReply
): Promise<void> {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const email = request.userContext!.email || '';
const { paymentMethodId } = request.body;
// Validate input
@@ -218,26 +220,15 @@ export class SubscriptionsController {
return;
}
// Get subscription
const subscription = await this.service.getSubscription(userId);
if (!subscription) {
reply.status(404).send({
error: 'Subscription not found',
message: 'No subscription exists for this user',
});
return;
}
// Update payment method via Stripe
const stripeClient = new StripeClient();
await stripeClient.updatePaymentMethod(subscription.stripeCustomerId, paymentMethodId);
// Update payment method via service (creates Stripe customer if needed)
await this.service.updatePaymentMethod(userId, paymentMethodId, email);
reply.status(200).send({
message: 'Payment method updated successfully',
});
} catch (error: any) {
logger.error('Failed to update payment method', {
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
error: error.message,
});
reply.status(500).send({
@@ -252,14 +243,14 @@ export class SubscriptionsController {
*/
async getInvoices(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const invoices = await this.service.getInvoices(userId);
reply.status(200).send(invoices);
} catch (error: any) {
logger.error('Failed to get invoices', {
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
error: error.message,
});
reply.status(500).send({
@@ -282,7 +273,7 @@ export class SubscriptionsController {
reply: FastifyReply
): Promise<void> {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const { targetTier, vehicleIdsToKeep } = request.body;
// Validate inputs
@@ -320,7 +311,7 @@ export class SubscriptionsController {
reply.status(200).send(updatedSubscription);
} catch (error: any) {
logger.error('Failed to downgrade subscription', {
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
error: error.message,
});
reply.status(500).send({

View File

@@ -27,7 +27,7 @@ export class SubscriptionsRepository {
/**
* Create a new subscription
*/
async create(data: CreateSubscriptionRequest & { stripeCustomerId: string }): Promise<Subscription> {
async create(data: CreateSubscriptionRequest & { stripeCustomerId?: string | null }): Promise<Subscription> {
const query = `
INSERT INTO subscriptions (
user_id, stripe_customer_id, tier, billing_cycle
@@ -38,7 +38,7 @@ export class SubscriptionsRepository {
const values = [
data.userId,
data.stripeCustomerId,
data.stripeCustomerId ?? null,
data.tier,
data.billingCycle,
];
@@ -146,6 +146,10 @@ export class SubscriptionsRepository {
const values = [];
let paramCount = 1;
if (data.stripeCustomerId !== undefined) {
fields.push(`stripe_customer_id = $${paramCount++}`);
values.push(data.stripeCustomerId);
}
if (data.stripeSubscriptionId !== undefined) {
fields.push(`stripe_subscription_id = $${paramCount++}`);
values.push(data.stripeSubscriptionId);
@@ -575,18 +579,16 @@ export class SubscriptionsRepository {
client?: any
): Promise<Subscription> {
const queryClient = client || this.pool;
// Generate a placeholder Stripe customer ID since admin override bypasses Stripe
const placeholderCustomerId = `admin_override_${userId}_${Date.now()}`;
const query = `
INSERT INTO subscriptions (
user_id, stripe_customer_id, tier, billing_cycle, status
)
VALUES ($1, $2, $3, 'monthly', 'active')
VALUES ($1, NULL, $2, 'monthly', 'active')
RETURNING *
`;
const values = [userId, placeholderCustomerId, tier];
const values = [userId, tier];
try {
const result = await queryClient.query(query, values);
@@ -619,7 +621,7 @@ export class SubscriptionsRepository {
return {
id: row.id,
userId: row.user_id,
stripeCustomerId: row.stripe_customer_id,
stripeCustomerId: row.stripe_customer_id ?? null,
stripeSubscriptionId: row.stripe_subscription_id || undefined,
tier: row.tier,
billingCycle: row.billing_cycle || undefined,

View File

@@ -165,6 +165,45 @@ export class SubscriptionsService {
}
}
/**
* Create or return existing Stripe customer for a subscription.
* Admin-set subscriptions have NULL stripeCustomerId. On first Stripe payment,
* the customer is created in-place. Includes cleanup of orphaned Stripe customer
* if the DB update fails after customer creation.
*/
private async ensureStripeCustomer(
subscription: Subscription,
email: string
): Promise<string> {
if (subscription.stripeCustomerId) {
return subscription.stripeCustomerId;
}
const stripeCustomer = await this.stripeClient.createCustomer(email);
try {
await this.repository.update(subscription.id, { stripeCustomerId: stripeCustomer.id });
logger.info('Created Stripe customer for subscription', {
subscriptionId: subscription.id,
stripeCustomerId: stripeCustomer.id,
});
return stripeCustomer.id;
} catch (error) {
// Attempt cleanup of orphaned Stripe customer
try {
await this.stripeClient.deleteCustomer(stripeCustomer.id);
logger.warn('Rolled back orphaned Stripe customer after DB update failure', {
stripeCustomerId: stripeCustomer.id,
});
} catch (cleanupError: any) {
logger.error('Failed to cleanup orphaned Stripe customer', {
stripeCustomerId: stripeCustomer.id,
cleanupError: cleanupError.message,
});
}
throw error;
}
}
/**
* Upgrade from current tier to new tier
*/
@@ -172,7 +211,8 @@ export class SubscriptionsService {
userId: string,
newTier: 'pro' | 'enterprise',
billingCycle: 'monthly' | 'yearly',
paymentMethodId: string
paymentMethodId: string,
email: string
): Promise<Subscription> {
try {
logger.info('Upgrading subscription', { userId, newTier, billingCycle });
@@ -183,12 +223,15 @@ export class SubscriptionsService {
throw new Error('No subscription found for user');
}
// Ensure Stripe customer exists (creates one for admin-set subscriptions)
const stripeCustomerId = await this.ensureStripeCustomer(currentSubscription, email);
// Determine price ID from environment variables
const priceId = this.getPriceId(newTier, billingCycle);
// Create or update Stripe subscription
const stripeSubscription = await this.stripeClient.createSubscription(
currentSubscription.stripeCustomerId,
stripeCustomerId,
priceId,
paymentMethodId
);
@@ -256,6 +299,10 @@ export class SubscriptionsService {
throw new Error('No subscription found for user');
}
if (!currentSubscription.stripeCustomerId) {
throw new Error('Cannot cancel subscription without active Stripe billing');
}
if (!currentSubscription.stripeSubscriptionId) {
throw new Error('No active Stripe subscription to cancel');
}
@@ -303,6 +350,10 @@ export class SubscriptionsService {
throw new Error('No subscription found for user');
}
if (!currentSubscription.stripeCustomerId) {
throw new Error('Cannot reactivate subscription without active Stripe billing');
}
if (!currentSubscription.stripeSubscriptionId) {
throw new Error('No active Stripe subscription to reactivate');
}
@@ -519,11 +570,13 @@ export class SubscriptionsService {
}
// Update subscription with Stripe subscription ID
// Period dates moved from subscription to items in API 2025-03-31.basil
const item = stripeSubscription.items?.data?.[0];
await this.repository.update(subscription.id, {
stripeSubscriptionId: stripeSubscription.id,
status: this.mapStripeStatus(stripeSubscription.status),
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
currentPeriodStart: new Date((item?.current_period_start ?? 0) * 1000),
currentPeriodEnd: new Date((item?.current_period_end ?? 0) * 1000),
});
// Log event
@@ -557,11 +610,13 @@ export class SubscriptionsService {
const tier = this.determineTierFromStripeSubscription(stripeSubscription);
// Update subscription
// Period dates moved from subscription to items in API 2025-03-31.basil
const item = stripeSubscription.items?.data?.[0];
const updateData: UpdateSubscriptionData = {
status: this.mapStripeStatus(stripeSubscription.status),
tier,
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
currentPeriodStart: new Date((item?.current_period_start ?? 0) * 1000),
currentPeriodEnd: new Date((item?.current_period_end ?? 0) * 1000),
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end || false,
};
@@ -731,7 +786,7 @@ export class SubscriptionsService {
): Promise<void> {
try {
// Get user profile for email and name
const userProfile = await this.userProfileRepository.getByAuth0Sub(userId);
const userProfile = await this.userProfileRepository.getById(userId);
if (!userProfile) {
logger.warn('User profile not found for tier change notification', { userId });
return;
@@ -766,17 +821,8 @@ export class SubscriptionsService {
* Sync subscription tier to user_profiles table
*/
private async syncTierToUserProfile(userId: string, tier: SubscriptionTier): Promise<void> {
try {
await this.userProfileRepository.updateSubscriptionTier(userId, tier);
logger.info('Subscription tier synced to user profile', { userId, tier });
} catch (error: any) {
logger.error('Failed to sync tier to user profile', {
userId,
tier,
error: error.message,
});
// Don't throw - we don't want to fail the subscription operation if sync fails
}
await this.userProfileRepository.updateSubscriptionTier(userId, tier);
logger.info('Subscription tier synced to user profile', { userId, tier });
}
/**
@@ -807,6 +853,7 @@ export class SubscriptionsService {
switch (stripeStatus) {
case 'active':
case 'trialing':
case 'incomplete':
return 'active';
case 'past_due':
return 'past_due';
@@ -889,7 +936,7 @@ export class SubscriptionsService {
// Sync tier to user_profiles table (within same transaction)
await client.query(
'UPDATE user_profiles SET subscription_tier = $1 WHERE auth0_sub = $2',
'UPDATE user_profiles SET subscription_tier = $1 WHERE id = $2',
[newTier, userId]
);
@@ -923,6 +970,19 @@ export class SubscriptionsService {
}
}
/**
* Update payment method for a user's subscription
*/
async updatePaymentMethod(userId: string, paymentMethodId: string, email: string): Promise<void> {
const subscription = await this.repository.findByUserId(userId);
if (!subscription) {
throw new Error('No subscription found for user');
}
const stripeCustomerId = await this.ensureStripeCustomer(subscription, email);
await this.stripeClient.updatePaymentMethod(stripeCustomerId, paymentMethodId);
}
/**
* Get invoices for a user's subscription
*/

View File

@@ -19,7 +19,7 @@ export type DonationStatus = 'pending' | 'succeeded' | 'failed' | 'canceled';
export interface Subscription {
id: string;
userId: string;
stripeCustomerId: string;
stripeCustomerId: string | null;
stripeSubscriptionId?: string;
tier: SubscriptionTier;
billingCycle?: BillingCycle;
@@ -74,7 +74,7 @@ export interface CreateSubscriptionRequest {
export interface SubscriptionResponse {
id: string;
userId: string;
stripeCustomerId: string;
stripeCustomerId: string | null;
stripeSubscriptionId?: string;
tier: SubscriptionTier;
billingCycle?: BillingCycle;
@@ -118,6 +118,7 @@ export interface CreateTierVehicleSelectionRequest {
// Service layer types
export interface UpdateSubscriptionData {
stripeCustomerId?: string | null;
stripeSubscriptionId?: string;
tier?: SubscriptionTier;
billingCycle?: BillingCycle;

View File

@@ -75,10 +75,18 @@ export class StripeClient {
try {
logger.info('Creating Stripe subscription', { customerId, priceId, paymentMethodId });
// Attach payment method to customer before creating subscription
if (paymentMethodId) {
await this.stripe.paymentMethods.attach(paymentMethodId, {
customer: customerId,
});
logger.info('Payment method attached to customer', { customerId, paymentMethodId });
}
const subscriptionParams: Stripe.SubscriptionCreateParams = {
customer: customerId,
items: [{ price: priceId }],
payment_behavior: 'default_incomplete',
payment_behavior: 'error_if_incomplete',
payment_settings: {
save_default_payment_method: 'on_subscription',
},
@@ -93,13 +101,16 @@ export class StripeClient {
logger.info('Stripe subscription created', { subscriptionId: subscription.id });
// Period dates moved from subscription to items in API 2025-03-31.basil
const item = subscription.items?.data?.[0];
return {
id: subscription.id,
customer: subscription.customer as string,
status: subscription.status as StripeSubscription['status'],
items: subscription.items,
currentPeriodStart: (subscription as any).current_period_start || 0,
currentPeriodEnd: (subscription as any).current_period_end || 0,
currentPeriodStart: item?.current_period_start ?? 0,
currentPeriodEnd: item?.current_period_end ?? 0,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at || undefined,
created: subscription.created,
@@ -140,13 +151,15 @@ export class StripeClient {
logger.info('Stripe subscription canceled immediately', { subscriptionId });
}
const item = subscription.items?.data?.[0];
return {
id: subscription.id,
customer: subscription.customer as string,
status: subscription.status as StripeSubscription['status'],
items: subscription.items,
currentPeriodStart: (subscription as any).current_period_start || 0,
currentPeriodEnd: (subscription as any).current_period_end || 0,
currentPeriodStart: item?.current_period_start ?? 0,
currentPeriodEnd: item?.current_period_end ?? 0,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at || undefined,
created: subscription.created,
@@ -260,6 +273,24 @@ export class StripeClient {
}
}
/**
* Delete a Stripe customer (used for cleanup of orphaned customers)
*/
async deleteCustomer(customerId: string): Promise<void> {
try {
logger.info('Deleting Stripe customer', { customerId });
await this.stripe.customers.del(customerId);
logger.info('Stripe customer deleted', { customerId });
} catch (error: any) {
logger.error('Failed to delete Stripe customer', {
customerId,
error: error.message,
code: error.code,
});
throw error;
}
}
/**
* Retrieve a subscription by ID
*/
@@ -268,14 +299,15 @@ export class StripeClient {
logger.info('Retrieving Stripe subscription', { subscriptionId });
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
const item = subscription.items?.data?.[0];
return {
id: subscription.id,
customer: subscription.customer as string,
status: subscription.status as StripeSubscription['status'],
items: subscription.items,
currentPeriodStart: (subscription as any).current_period_start || 0,
currentPeriodEnd: (subscription as any).current_period_end || 0,
currentPeriodStart: item?.current_period_start ?? 0,
currentPeriodEnd: item?.current_period_end ?? 0,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at || undefined,
created: subscription.created,

View File

@@ -50,7 +50,7 @@ export async function processGracePeriodExpirations(): Promise<GracePeriodResult
up.notification_email,
up.display_name
FROM subscriptions s
LEFT JOIN user_profiles up ON s.user_id = up.auth0_sub
LEFT JOIN user_profiles up ON s.user_id = up.id
WHERE s.status = 'past_due'
AND s.grace_period_end < NOW()
ORDER BY s.grace_period_end ASC
@@ -89,13 +89,13 @@ export async function processGracePeriodExpirations(): Promise<GracePeriodResult
await client.query(updateQuery, [subscription.id]);
// Sync tier to user_profiles table (user_id is auth0_sub)
// Sync tier to user_profiles table
const syncQuery = `
UPDATE user_profiles
SET
subscription_tier = 'free',
updated_at = NOW()
WHERE auth0_sub = $1
WHERE id = $1
`;
await client.query(syncQuery, [subscription.user_id]);

View File

@@ -0,0 +1,11 @@
-- Migration: Make stripe_customer_id NULLABLE
-- Removes the NOT NULL constraint that forced admin_override_ placeholder values.
-- Admin-set subscriptions (no Stripe billing) use NULL instead of sentinel strings.
-- PostgreSQL UNIQUE constraint allows multiple NULLs (SQL standard).
-- Drop NOT NULL constraint on stripe_customer_id
ALTER TABLE subscriptions ALTER COLUMN stripe_customer_id DROP NOT NULL;
-- Clean up existing admin_override_ placeholder values to NULL
UPDATE subscriptions SET stripe_customer_id = NULL
WHERE stripe_customer_id LIKE 'admin_override_%';

View File

@@ -15,7 +15,7 @@ export class UserExportController {
}
async downloadExport(request: FastifyRequest, reply: FastifyReply): Promise<void> {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
logger.info('User export requested', { userId });

View File

@@ -24,7 +24,7 @@ export class UserImportController {
* Uploads and imports user data archive
*/
async uploadAndImport(request: FastifyRequest, reply: FastifyReply): Promise<void> {
const userId = request.user?.sub;
const userId = request.userContext?.userId;
if (!userId) {
return reply.code(401).send({ error: 'Unauthorized' });
}
@@ -139,7 +139,7 @@ export class UserImportController {
* Generates preview of import data without executing import
*/
async generatePreview(request: FastifyRequest, reply: FastifyReply): Promise<void> {
const userId = request.user?.sub;
const userId = request.userContext?.userId;
if (!userId) {
return reply.code(401).send({ error: 'Unauthorized' });
}

View File

@@ -18,7 +18,7 @@ export class UserPreferencesController {
async getPreferences(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
let preferences = await this.repository.findByUserId(userId);
// Create default preferences if none exist
@@ -42,7 +42,7 @@ export class UserPreferencesController {
updatedAt: preferences.updatedAt,
});
} catch (error) {
logger.error('Error getting user preferences', { error, userId: (request as any).user?.sub });
logger.error('Error getting user preferences', { error, userId: request.userContext?.userId });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get preferences',
@@ -55,7 +55,7 @@ export class UserPreferencesController {
reply: FastifyReply
) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const { unitSystem, currencyCode, timeZone, darkMode } = request.body;
// Validate unitSystem if provided
@@ -115,7 +115,7 @@ export class UserPreferencesController {
updatedAt: preferences.updatedAt,
});
} catch (error) {
logger.error('Error updating user preferences', { error, userId: (request as any).user?.sub });
logger.error('Error updating user preferences', { error, userId: request.userContext?.userId });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to update preferences',

View File

@@ -18,11 +18,12 @@ import {
export class UserProfileController {
private userProfileService: UserProfileService;
private userProfileRepository: UserProfileRepository;
constructor() {
const repository = new UserProfileRepository(pool);
this.userProfileRepository = new UserProfileRepository(pool);
const adminRepository = new AdminRepository(pool);
this.userProfileService = new UserProfileService(repository);
this.userProfileService = new UserProfileService(this.userProfileRepository);
this.userProfileService.setAdminRepository(adminRepository);
}
@@ -31,27 +32,24 @@ export class UserProfileController {
*/
async getProfile(request: FastifyRequest, reply: FastifyReply) {
try {
const auth0Sub = request.userContext?.userId;
const userId = request.userContext?.userId;
if (!auth0Sub) {
if (!userId) {
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 profile by UUID (auth plugin ensures profile exists during authentication)
const profile = await this.userProfileRepository.getById(userId);
// Get or create profile
const profile = await this.userProfileService.getOrCreateProfile(
auth0Sub,
auth0User
);
if (!profile) {
return reply.code(404).send({
error: 'Not Found',
message: 'User profile not found',
});
}
return reply.code(200).send(profile);
} catch (error: any) {
@@ -75,9 +73,9 @@ export class UserProfileController {
reply: FastifyReply
) {
try {
const auth0Sub = request.userContext?.userId;
const userId = request.userContext?.userId;
if (!auth0Sub) {
if (!userId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing',
@@ -96,9 +94,9 @@ export class UserProfileController {
const updates = validation.data;
// Update profile
// Update profile by UUID
const profile = await this.userProfileService.updateProfile(
auth0Sub,
userId,
updates
);
@@ -138,9 +136,9 @@ export class UserProfileController {
reply: FastifyReply
) {
try {
const auth0Sub = request.userContext?.userId;
const userId = request.userContext?.userId;
if (!auth0Sub) {
if (!userId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing',
@@ -159,9 +157,9 @@ export class UserProfileController {
const { confirmationText } = validation.data;
// Request deletion (user is already authenticated via JWT)
// Request deletion by UUID
const profile = await this.userProfileService.requestDeletion(
auth0Sub,
userId,
confirmationText
);
@@ -210,17 +208,17 @@ export class UserProfileController {
*/
async cancelDeletion(request: FastifyRequest, reply: FastifyReply) {
try {
const auth0Sub = request.userContext?.userId;
const userId = request.userContext?.userId;
if (!auth0Sub) {
if (!userId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing',
});
}
// Cancel deletion
const profile = await this.userProfileService.cancelDeletion(auth0Sub);
// Cancel deletion by UUID
const profile = await this.userProfileService.cancelDeletion(userId);
return reply.code(200).send({
message: 'Account deletion canceled successfully',
@@ -258,27 +256,24 @@ export class UserProfileController {
*/
async getDeletionStatus(request: FastifyRequest, reply: FastifyReply) {
try {
const auth0Sub = request.userContext?.userId;
const userId = request.userContext?.userId;
if (!auth0Sub) {
if (!userId) {
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 profile by UUID (auth plugin ensures profile exists)
const profile = await this.userProfileRepository.getById(userId);
// Get or create profile
const profile = await this.userProfileService.getOrCreateProfile(
auth0Sub,
auth0User
);
if (!profile) {
return reply.code(404).send({
error: 'Not Found',
message: 'User profile not found',
});
}
const deletionStatus = this.userProfileService.getDeletionStatus(profile);

View File

@@ -44,6 +44,26 @@ export class UserProfileRepository {
}
}
async getById(id: string): Promise<UserProfile | null> {
const query = `
SELECT ${USER_PROFILE_COLUMNS}
FROM user_profiles
WHERE id = $1
LIMIT 1
`;
try {
const result = await this.pool.query(query, [id]);
if (result.rows.length === 0) {
return null;
}
return this.mapRowToUserProfile(result.rows[0]);
} catch (error) {
logger.error('Error fetching user profile by id', { error, id });
throw error;
}
}
async getByEmail(email: string): Promise<UserProfile | null> {
const query = `
SELECT ${USER_PROFILE_COLUMNS}
@@ -94,7 +114,7 @@ export class UserProfileRepository {
}
async update(
auth0Sub: string,
userId: string,
updates: { displayName?: string; notificationEmail?: string }
): Promise<UserProfile> {
const setClauses: string[] = [];
@@ -115,12 +135,12 @@ export class UserProfileRepository {
throw new Error('No fields to update');
}
values.push(auth0Sub);
values.push(userId);
const query = `
UPDATE user_profiles
SET ${setClauses.join(', ')}
WHERE auth0_sub = $${paramIndex}
WHERE id = $${paramIndex}
RETURNING ${USER_PROFILE_COLUMNS}
`;
@@ -133,7 +153,7 @@ export class UserProfileRepository {
return this.mapRowToUserProfile(result.rows[0]);
} catch (error) {
logger.error('Error updating user profile', { error, auth0Sub, updates });
logger.error('Error updating user profile', { error, userId, updates });
throw error;
}
}
@@ -174,7 +194,7 @@ export class UserProfileRepository {
private mapRowToUserWithAdminStatus(row: any): UserWithAdminStatus {
return {
...this.mapRowToUserProfile(row),
isAdmin: !!row.admin_auth0_sub,
isAdmin: !!row.admin_id,
adminRole: row.admin_role || null,
vehicleCount: parseInt(row.vehicle_count, 10) || 0,
};
@@ -242,14 +262,14 @@ export class UserProfileRepository {
up.id, up.auth0_sub, up.email, up.display_name, up.notification_email,
up.subscription_tier, up.email_verified, up.onboarding_completed_at,
up.deactivated_at, up.deactivated_by, up.created_at, up.updated_at,
au.auth0_sub as admin_auth0_sub,
au.id as admin_id,
au.role as admin_role,
(SELECT COUNT(*) FROM vehicles v
WHERE v.user_id = up.auth0_sub
WHERE v.user_id = up.id
AND v.is_active = true
AND v.deleted_at IS NULL) as vehicle_count
FROM user_profiles up
LEFT JOIN admin_users au ON up.auth0_sub = au.auth0_sub AND au.revoked_at IS NULL
LEFT JOIN admin_users au ON au.user_profile_id = up.id AND au.revoked_at IS NULL
${whereClause}
ORDER BY ${sortColumn} ${sortOrder === 'desc' ? 'DESC' : 'ASC'} NULLS LAST
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
@@ -274,32 +294,32 @@ export class UserProfileRepository {
/**
* Get single user with admin status
*/
async getUserWithAdminStatus(auth0Sub: string): Promise<UserWithAdminStatus | null> {
async getUserWithAdminStatus(userId: string): Promise<UserWithAdminStatus | null> {
const query = `
SELECT
up.id, up.auth0_sub, up.email, up.display_name, up.notification_email,
up.subscription_tier, up.email_verified, up.onboarding_completed_at,
up.deactivated_at, up.deactivated_by, up.created_at, up.updated_at,
au.auth0_sub as admin_auth0_sub,
au.id as admin_id,
au.role as admin_role,
(SELECT COUNT(*) FROM vehicles v
WHERE v.user_id = up.auth0_sub
WHERE v.user_id = up.id
AND v.is_active = true
AND v.deleted_at IS NULL) as vehicle_count
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
LEFT JOIN admin_users au ON au.user_profile_id = up.id AND au.revoked_at IS NULL
WHERE up.id = $1
LIMIT 1
`;
try {
const result = await this.pool.query(query, [auth0Sub]);
const result = await this.pool.query(query, [userId]);
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 });
logger.error('Error fetching user with admin status', { error, userId });
throw error;
}
}
@@ -308,24 +328,24 @@ export class UserProfileRepository {
* Update user subscription tier
*/
async updateSubscriptionTier(
auth0Sub: string,
userId: string,
tier: SubscriptionTier
): Promise<UserProfile> {
const query = `
UPDATE user_profiles
SET subscription_tier = $1
WHERE auth0_sub = $2
WHERE id = $2
RETURNING ${USER_PROFILE_COLUMNS}
`;
try {
const result = await this.pool.query(query, [tier, auth0Sub]);
const result = await this.pool.query(query, [tier, userId]);
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 });
logger.error('Error updating subscription tier', { error, userId, tier });
throw error;
}
}
@@ -333,22 +353,22 @@ export class UserProfileRepository {
/**
* Deactivate user (soft delete)
*/
async deactivateUser(auth0Sub: string, deactivatedBy: string): Promise<UserProfile> {
async deactivateUser(userId: string, deactivatedBy: string): Promise<UserProfile> {
const query = `
UPDATE user_profiles
SET deactivated_at = NOW(), deactivated_by = $1
WHERE auth0_sub = $2 AND deactivated_at IS NULL
WHERE id = $2 AND deactivated_at IS NULL
RETURNING ${USER_PROFILE_COLUMNS}
`;
try {
const result = await this.pool.query(query, [deactivatedBy, auth0Sub]);
const result = await this.pool.query(query, [deactivatedBy, userId]);
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 });
logger.error('Error deactivating user', { error, userId, deactivatedBy });
throw error;
}
}
@@ -356,22 +376,22 @@ export class UserProfileRepository {
/**
* Reactivate user
*/
async reactivateUser(auth0Sub: string): Promise<UserProfile> {
async reactivateUser(userId: string): Promise<UserProfile> {
const query = `
UPDATE user_profiles
SET deactivated_at = NULL, deactivated_by = NULL
WHERE auth0_sub = $1 AND deactivated_at IS NOT NULL
WHERE id = $1 AND deactivated_at IS NOT NULL
RETURNING ${USER_PROFILE_COLUMNS}
`;
try {
const result = await this.pool.query(query, [auth0Sub]);
const result = await this.pool.query(query, [userId]);
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 });
logger.error('Error reactivating user', { error, userId });
throw error;
}
}
@@ -380,7 +400,7 @@ export class UserProfileRepository {
* Admin update of user profile (can update email and displayName)
*/
async adminUpdateProfile(
auth0Sub: string,
userId: string,
updates: { email?: string; displayName?: string }
): Promise<UserProfile> {
const setClauses: string[] = [];
@@ -401,12 +421,12 @@ export class UserProfileRepository {
throw new Error('No fields to update');
}
values.push(auth0Sub);
values.push(userId);
const query = `
UPDATE user_profiles
SET ${setClauses.join(', ')}, updated_at = NOW()
WHERE auth0_sub = $${paramIndex}
WHERE id = $${paramIndex}
RETURNING ${USER_PROFILE_COLUMNS}
`;
@@ -419,7 +439,7 @@ export class UserProfileRepository {
return this.mapRowToUserProfile(result.rows[0]);
} catch (error) {
logger.error('Error admin updating user profile', { error, auth0Sub, updates });
logger.error('Error admin updating user profile', { error, userId, updates });
throw error;
}
}
@@ -427,22 +447,22 @@ export class UserProfileRepository {
/**
* Update email verification status
*/
async updateEmailVerified(auth0Sub: string, emailVerified: boolean): Promise<UserProfile> {
async updateEmailVerified(userId: string, emailVerified: boolean): Promise<UserProfile> {
const query = `
UPDATE user_profiles
SET email_verified = $1, updated_at = NOW()
WHERE auth0_sub = $2
WHERE id = $2
RETURNING ${USER_PROFILE_COLUMNS}
`;
try {
const result = await this.pool.query(query, [emailVerified, auth0Sub]);
const result = await this.pool.query(query, [emailVerified, userId]);
if (result.rows.length === 0) {
throw new Error('User profile not found');
}
return this.mapRowToUserProfile(result.rows[0]);
} catch (error) {
logger.error('Error updating email verified status', { error, auth0Sub, emailVerified });
logger.error('Error updating email verified status', { error, userId, emailVerified });
throw error;
}
}
@@ -450,19 +470,19 @@ export class UserProfileRepository {
/**
* Mark onboarding as complete
*/
async markOnboardingComplete(auth0Sub: string): Promise<UserProfile> {
async markOnboardingComplete(userId: string): Promise<UserProfile> {
const query = `
UPDATE user_profiles
SET onboarding_completed_at = NOW(), updated_at = NOW()
WHERE auth0_sub = $1 AND onboarding_completed_at IS NULL
WHERE id = $1 AND onboarding_completed_at IS NULL
RETURNING ${USER_PROFILE_COLUMNS}
`;
try {
const result = await this.pool.query(query, [auth0Sub]);
const result = await this.pool.query(query, [userId]);
if (result.rows.length === 0) {
// Check if already completed or profile not found
const existing = await this.getByAuth0Sub(auth0Sub);
const existing = await this.getById(userId);
if (existing && existing.onboardingCompletedAt) {
return existing; // Already completed, return as-is
}
@@ -470,7 +490,7 @@ export class UserProfileRepository {
}
return this.mapRowToUserProfile(result.rows[0]);
} catch (error) {
logger.error('Error marking onboarding complete', { error, auth0Sub });
logger.error('Error marking onboarding complete', { error, userId });
throw error;
}
}
@@ -478,22 +498,22 @@ export class UserProfileRepository {
/**
* Update user email (used when fetching correct email from Auth0)
*/
async updateEmail(auth0Sub: string, email: string): Promise<UserProfile> {
async updateEmail(userId: string, email: string): Promise<UserProfile> {
const query = `
UPDATE user_profiles
SET email = $1, updated_at = NOW()
WHERE auth0_sub = $2
WHERE id = $2
RETURNING ${USER_PROFILE_COLUMNS}
`;
try {
const result = await this.pool.query(query, [email, auth0Sub]);
const result = await this.pool.query(query, [email, userId]);
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 email', { error, auth0Sub });
logger.error('Error updating user email', { error, userId });
throw error;
}
}
@@ -502,7 +522,7 @@ export class UserProfileRepository {
* Request account deletion (sets deletion timestamps and deactivates account)
* 30-day grace period before permanent deletion
*/
async requestDeletion(auth0Sub: string): Promise<UserProfile> {
async requestDeletion(userId: string): Promise<UserProfile> {
const query = `
UPDATE user_profiles
SET
@@ -510,18 +530,18 @@ export class UserProfileRepository {
deletion_scheduled_for = NOW() + INTERVAL '30 days',
deactivated_at = NOW(),
updated_at = NOW()
WHERE auth0_sub = $1 AND deletion_requested_at IS NULL
WHERE id = $1 AND deletion_requested_at IS NULL
RETURNING ${USER_PROFILE_COLUMNS}
`;
try {
const result = await this.pool.query(query, [auth0Sub]);
const result = await this.pool.query(query, [userId]);
if (result.rows.length === 0) {
throw new Error('User profile not found or deletion already requested');
}
return this.mapRowToUserProfile(result.rows[0]);
} catch (error) {
logger.error('Error requesting account deletion', { error, auth0Sub });
logger.error('Error requesting account deletion', { error, userId });
throw error;
}
}
@@ -529,7 +549,7 @@ export class UserProfileRepository {
/**
* Cancel deletion request (clears deletion timestamps and reactivates account)
*/
async cancelDeletion(auth0Sub: string): Promise<UserProfile> {
async cancelDeletion(userId: string): Promise<UserProfile> {
const query = `
UPDATE user_profiles
SET
@@ -538,18 +558,18 @@ export class UserProfileRepository {
deactivated_at = NULL,
deactivated_by = NULL,
updated_at = NOW()
WHERE auth0_sub = $1 AND deletion_requested_at IS NOT NULL
WHERE id = $1 AND deletion_requested_at IS NOT NULL
RETURNING ${USER_PROFILE_COLUMNS}
`;
try {
const result = await this.pool.query(query, [auth0Sub]);
const result = await this.pool.query(query, [userId]);
if (result.rows.length === 0) {
throw new Error('User profile not found or no deletion request pending');
}
return this.mapRowToUserProfile(result.rows[0]);
} catch (error) {
logger.error('Error canceling account deletion', { error, auth0Sub });
logger.error('Error canceling account deletion', { error, userId });
throw error;
}
}
@@ -579,7 +599,7 @@ export class UserProfileRepository {
* Hard delete user and all associated data
* This is a permanent operation - use with caution
*/
async hardDeleteUser(auth0Sub: string): Promise<void> {
async hardDeleteUser(userId: string): Promise<void> {
const client = await this.pool.connect();
try {
@@ -590,51 +610,51 @@ export class UserProfileRepository {
`UPDATE community_stations
SET submitted_by = 'deleted-user'
WHERE submitted_by = $1`,
[auth0Sub]
[userId]
);
// 2. Delete notification logs
await client.query(
'DELETE FROM notification_logs WHERE user_id = $1',
[auth0Sub]
[userId]
);
// 3. Delete user notifications
await client.query(
'DELETE FROM user_notifications WHERE user_id = $1',
[auth0Sub]
[userId]
);
// 4. Delete saved stations
await client.query(
'DELETE FROM saved_stations WHERE user_id = $1',
[auth0Sub]
[userId]
);
// 5. Delete vehicles (cascades to fuel_logs, maintenance_logs, maintenance_schedules, documents)
await client.query(
'DELETE FROM vehicles WHERE user_id = $1',
[auth0Sub]
[userId]
);
// 6. Delete user preferences
await client.query(
'DELETE FROM user_preferences WHERE user_id = $1',
[auth0Sub]
[userId]
);
// 7. Delete user profile (final step)
await client.query(
'DELETE FROM user_profiles WHERE auth0_sub = $1',
[auth0Sub]
'DELETE FROM user_profiles WHERE id = $1',
[userId]
);
await client.query('COMMIT');
logger.info('User hard deleted successfully', { auth0Sub });
logger.info('User hard deleted successfully', { userId });
} catch (error) {
await client.query('ROLLBACK');
logger.error('Error hard deleting user', { error, auth0Sub });
logger.error('Error hard deleting user', { error, userId });
throw error;
} finally {
client.release();
@@ -686,7 +706,7 @@ export class UserProfileRepository {
* Get vehicles for a user (admin view)
* Returns only year, make, model for privacy
*/
async getUserVehiclesForAdmin(auth0Sub: string): Promise<Array<{ year: number; make: string; model: string }>> {
async getUserVehiclesForAdmin(userId: string): Promise<Array<{ year: number; make: string; model: string }>> {
const query = `
SELECT year, make, model
FROM vehicles
@@ -697,14 +717,14 @@ export class UserProfileRepository {
`;
try {
const result = await this.pool.query(query, [auth0Sub]);
const result = await this.pool.query(query, [userId]);
return result.rows.map(row => ({
year: row.year,
make: row.make,
model: row.model,
}));
} catch (error) {
logger.error('Error getting user vehicles for admin', { error, auth0Sub });
logger.error('Error getting user vehicles for admin', { error, userId });
throw error;
}
}

View File

@@ -60,7 +60,7 @@ export class UserProfileService {
}
/**
* Get user profile by Auth0 sub
* Get user profile by Auth0 sub (used during auth flow)
*/
async getProfile(auth0Sub: string): Promise<UserProfile | null> {
try {
@@ -72,10 +72,10 @@ export class UserProfileService {
}
/**
* Update user profile
* Update user profile by UUID
*/
async updateProfile(
auth0Sub: string,
userId: string,
updates: UpdateProfileRequest
): Promise<UserProfile> {
try {
@@ -85,17 +85,17 @@ export class UserProfileService {
}
// Perform the update
const profile = await this.repository.update(auth0Sub, updates);
const profile = await this.repository.update(userId, updates);
logger.info('User profile updated', {
auth0Sub,
userId,
profileId: profile.id,
updatedFields: Object.keys(updates),
});
return profile;
} catch (error) {
logger.error('Error updating user profile', { error, auth0Sub, updates });
logger.error('Error updating user profile', { error, userId, updates });
throw error;
}
}
@@ -117,29 +117,29 @@ export class UserProfileService {
}
/**
* Get user details with admin status (admin-only)
* Get user details with admin status by UUID (admin-only)
*/
async getUserDetails(auth0Sub: string): Promise<UserWithAdminStatus | null> {
async getUserDetails(userId: string): Promise<UserWithAdminStatus | null> {
try {
return await this.repository.getUserWithAdminStatus(auth0Sub);
return await this.repository.getUserWithAdminStatus(userId);
} catch (error) {
logger.error('Error getting user details', { error, auth0Sub });
logger.error('Error getting user details', { error, userId });
throw error;
}
}
/**
* Update user subscription tier (admin-only)
* Update user subscription tier by UUID (admin-only)
* Logs the change to admin audit logs
*/
async updateSubscriptionTier(
auth0Sub: string,
userId: string,
tier: SubscriptionTier,
actorAuth0Sub: string
actorUserId: string
): Promise<UserProfile> {
try {
// Get current user to log the change
const currentUser = await this.repository.getByAuth0Sub(auth0Sub);
const currentUser = await this.repository.getById(userId);
if (!currentUser) {
throw new Error('User not found');
}
@@ -147,14 +147,14 @@ export class UserProfileService {
const previousTier = currentUser.subscriptionTier;
// Perform the update
const updatedProfile = await this.repository.updateSubscriptionTier(auth0Sub, tier);
const updatedProfile = await this.repository.updateSubscriptionTier(userId, tier);
// Log to audit trail
if (this.adminRepository) {
await this.adminRepository.logAuditAction(
actorAuth0Sub,
actorUserId,
'UPDATE_TIER',
auth0Sub,
userId,
'user_profile',
updatedProfile.id,
{ previousTier, newTier: tier }
@@ -162,36 +162,36 @@ export class UserProfileService {
}
logger.info('User subscription tier updated', {
auth0Sub,
userId,
previousTier,
newTier: tier,
actorAuth0Sub,
actorUserId,
});
return updatedProfile;
} catch (error) {
logger.error('Error updating subscription tier', { error, auth0Sub, tier, actorAuth0Sub });
logger.error('Error updating subscription tier', { error, userId, tier, actorUserId });
throw error;
}
}
/**
* Deactivate user account (admin-only soft delete)
* Deactivate user account by UUID (admin-only soft delete)
* Prevents self-deactivation
*/
async deactivateUser(
auth0Sub: string,
actorAuth0Sub: string,
userId: string,
actorUserId: string,
reason?: string
): Promise<UserProfile> {
try {
// Prevent self-deactivation
if (auth0Sub === actorAuth0Sub) {
if (userId === actorUserId) {
throw new Error('Cannot deactivate your own account');
}
// Verify user exists and is not already deactivated
const currentUser = await this.repository.getByAuth0Sub(auth0Sub);
const currentUser = await this.repository.getById(userId);
if (!currentUser) {
throw new Error('User not found');
}
@@ -200,14 +200,14 @@ export class UserProfileService {
}
// Perform the deactivation
const deactivatedProfile = await this.repository.deactivateUser(auth0Sub, actorAuth0Sub);
const deactivatedProfile = await this.repository.deactivateUser(userId, actorUserId);
// Log to audit trail
if (this.adminRepository) {
await this.adminRepository.logAuditAction(
actorAuth0Sub,
actorUserId,
'DEACTIVATE_USER',
auth0Sub,
userId,
'user_profile',
deactivatedProfile.id,
{ reason: reason || 'No reason provided' }
@@ -215,28 +215,28 @@ export class UserProfileService {
}
logger.info('User deactivated', {
auth0Sub,
actorAuth0Sub,
userId,
actorUserId,
reason,
});
return deactivatedProfile;
} catch (error) {
logger.error('Error deactivating user', { error, auth0Sub, actorAuth0Sub });
logger.error('Error deactivating user', { error, userId, actorUserId });
throw error;
}
}
/**
* Reactivate a deactivated user account (admin-only)
* Reactivate a deactivated user account by UUID (admin-only)
*/
async reactivateUser(
auth0Sub: string,
actorAuth0Sub: string
userId: string,
actorUserId: string
): Promise<UserProfile> {
try {
// Verify user exists and is deactivated
const currentUser = await this.repository.getByAuth0Sub(auth0Sub);
const currentUser = await this.repository.getById(userId);
if (!currentUser) {
throw new Error('User not found');
}
@@ -245,14 +245,14 @@ export class UserProfileService {
}
// Perform the reactivation
const reactivatedProfile = await this.repository.reactivateUser(auth0Sub);
const reactivatedProfile = await this.repository.reactivateUser(userId);
// Log to audit trail
if (this.adminRepository) {
await this.adminRepository.logAuditAction(
actorAuth0Sub,
actorUserId,
'REACTIVATE_USER',
auth0Sub,
userId,
'user_profile',
reactivatedProfile.id,
{ previouslyDeactivatedBy: currentUser.deactivatedBy }
@@ -260,29 +260,29 @@ export class UserProfileService {
}
logger.info('User reactivated', {
auth0Sub,
actorAuth0Sub,
userId,
actorUserId,
});
return reactivatedProfile;
} catch (error) {
logger.error('Error reactivating user', { error, auth0Sub, actorAuth0Sub });
logger.error('Error reactivating user', { error, userId, actorUserId });
throw error;
}
}
/**
* Admin update of user profile (email, displayName)
* Admin update of user profile by UUID (email, displayName)
* Logs the change to admin audit logs
*/
async adminUpdateProfile(
auth0Sub: string,
userId: string,
updates: { email?: string; displayName?: string },
actorAuth0Sub: string
actorUserId: string
): Promise<UserProfile> {
try {
// Get current user to log the change
const currentUser = await this.repository.getByAuth0Sub(auth0Sub);
const currentUser = await this.repository.getById(userId);
if (!currentUser) {
throw new Error('User not found');
}
@@ -293,14 +293,14 @@ export class UserProfileService {
};
// Perform the update
const updatedProfile = await this.repository.adminUpdateProfile(auth0Sub, updates);
const updatedProfile = await this.repository.adminUpdateProfile(userId, updates);
// Log to audit trail
if (this.adminRepository) {
await this.adminRepository.logAuditAction(
actorAuth0Sub,
actorUserId,
'UPDATE_PROFILE',
auth0Sub,
userId,
'user_profile',
updatedProfile.id,
{
@@ -311,14 +311,14 @@ export class UserProfileService {
}
logger.info('User profile updated by admin', {
auth0Sub,
userId,
updatedFields: Object.keys(updates),
actorAuth0Sub,
actorUserId,
});
return updatedProfile;
} catch (error) {
logger.error('Error admin updating user profile', { error, auth0Sub, updates, actorAuth0Sub });
logger.error('Error admin updating user profile', { error, userId, updates, actorUserId });
throw error;
}
}
@@ -328,12 +328,12 @@ export class UserProfileService {
// ============================================
/**
* Request account deletion
* Request account deletion by UUID
* Sets 30-day grace period before permanent deletion
* Note: User is already authenticated via JWT, confirmation text is sufficient
*/
async requestDeletion(
auth0Sub: string,
userId: string,
confirmationText: string
): Promise<UserProfile> {
try {
@@ -343,7 +343,7 @@ export class UserProfileService {
}
// Get user profile
const profile = await this.repository.getByAuth0Sub(auth0Sub);
const profile = await this.repository.getById(userId);
if (!profile) {
throw new Error('User not found');
}
@@ -354,14 +354,14 @@ export class UserProfileService {
}
// Request deletion
const updatedProfile = await this.repository.requestDeletion(auth0Sub);
const updatedProfile = await this.repository.requestDeletion(userId);
// Log to audit trail
if (this.adminRepository) {
await this.adminRepository.logAuditAction(
auth0Sub,
userId,
'REQUEST_DELETION',
auth0Sub,
userId,
'user_profile',
updatedProfile.id,
{
@@ -371,42 +371,42 @@ export class UserProfileService {
}
logger.info('Account deletion requested', {
auth0Sub,
userId,
deletionScheduledFor: updatedProfile.deletionScheduledFor,
});
return updatedProfile;
} catch (error) {
logger.error('Error requesting account deletion', { error, auth0Sub });
logger.error('Error requesting account deletion', { error, userId });
throw error;
}
}
/**
* Cancel pending deletion request
* Cancel pending deletion request by UUID
*/
async cancelDeletion(auth0Sub: string): Promise<UserProfile> {
async cancelDeletion(userId: string): Promise<UserProfile> {
try {
// Cancel deletion
const updatedProfile = await this.repository.cancelDeletion(auth0Sub);
const updatedProfile = await this.repository.cancelDeletion(userId);
// Log to audit trail
if (this.adminRepository) {
await this.adminRepository.logAuditAction(
auth0Sub,
userId,
'CANCEL_DELETION',
auth0Sub,
userId,
'user_profile',
updatedProfile.id,
{}
);
}
logger.info('Account deletion canceled', { auth0Sub });
logger.info('Account deletion canceled', { userId });
return updatedProfile;
} catch (error) {
logger.error('Error canceling account deletion', { error, auth0Sub });
logger.error('Error canceling account deletion', { error, userId });
throw error;
}
}
@@ -438,22 +438,22 @@ export class UserProfileService {
}
/**
* Admin hard delete user (permanent deletion)
* Admin hard delete user by UUID (permanent deletion)
* Prevents self-delete
*/
async adminHardDeleteUser(
auth0Sub: string,
actorAuth0Sub: string,
userId: string,
actorUserId: string,
reason?: string
): Promise<void> {
try {
// Prevent self-delete
if (auth0Sub === actorAuth0Sub) {
if (userId === actorUserId) {
throw new Error('Cannot delete your own account');
}
// Get user profile before deletion for audit log
const profile = await this.repository.getByAuth0Sub(auth0Sub);
const profile = await this.repository.getById(userId);
if (!profile) {
throw new Error('User not found');
}
@@ -461,9 +461,9 @@ export class UserProfileService {
// Log to audit trail before deletion
if (this.adminRepository) {
await this.adminRepository.logAuditAction(
actorAuth0Sub,
actorUserId,
'HARD_DELETE_USER',
auth0Sub,
userId,
'user_profile',
profile.id,
{
@@ -475,18 +475,20 @@ export class UserProfileService {
}
// Hard delete from database
await this.repository.hardDeleteUser(auth0Sub);
await this.repository.hardDeleteUser(userId);
// Delete from Auth0
await auth0ManagementClient.deleteUser(auth0Sub);
// Delete from Auth0 (using auth0Sub for Auth0 API)
if (profile.auth0Sub) {
await auth0ManagementClient.deleteUser(profile.auth0Sub);
}
logger.info('User hard deleted by admin', {
auth0Sub,
actorAuth0Sub,
userId,
actorUserId,
reason,
});
} catch (error) {
logger.error('Error hard deleting user', { error, auth0Sub, actorAuth0Sub });
logger.error('Error hard deleting user', { error, userId, actorUserId });
throw error;
}
}

View File

@@ -16,6 +16,6 @@
| `data/` | Repository, database queries | Database operations |
| `docs/` | Feature-specific documentation | Vehicle design details |
| `events/` | Event handlers and emitters | Cross-feature event integration |
| `external/` | External service integrations (NHTSA) | VIN decoding, third-party APIs |
| `external/` | External service integrations | VIN decoding, third-party APIs |
| `migrations/` | Database schema | Schema changes |
| `tests/` | Unit and integration tests | Adding or modifying tests |

View File

@@ -13,7 +13,7 @@ Primary entity for vehicle management consuming MVP Platform Vehicles Service. H
- `DELETE /api/vehicles/:id` - Soft delete vehicle
### VIN Decoding (Pro/Enterprise Only)
- `POST /api/vehicles/decode-vin` - Decode VIN using NHTSA vPIC API
- `POST /api/vehicles/decode-vin` - Decode VIN using Gemini via OCR service
### Hierarchical Vehicle Dropdowns
**Status**: Vehicles service now proxies the platform vehicle catalog to provide fully dynamic dropdowns. Each selection step filters the next list, ensuring only valid combinations are shown.
@@ -104,11 +104,7 @@ vehicles/
├── data/ # Database layer
│ └── vehicles.repository.ts
├── external/ # External service integrations
── CLAUDE.md # Integration pattern docs
│ └── nhtsa/ # NHTSA vPIC API client
│ ├── nhtsa.client.ts
│ ├── nhtsa.types.ts
│ └── index.ts
── CLAUDE.md # Integration pattern docs
├── migrations/ # Feature schema
│ └── 001_create_vehicles_tables.sql
├── tests/ # All tests
@@ -121,14 +117,14 @@ vehicles/
## Key Features
### 🔍 VIN Decoding (NHTSA vPIC API)
### VIN Decoding (Gemini via OCR Service)
- **Tier Gating**: Pro and Enterprise users only (`vehicle.vinDecode` feature key)
- **NHTSA API**: Calls official NHTSA vPIC API for authoritative vehicle data
- **Gemini**: Calls OCR service Gemini VIN decode for authoritative vehicle data
- **Caching**: Results cached in `vin_cache` table (1-year TTL, VIN data is static)
- **Validation**: 17-character VIN format, excludes I/O/Q characters
- **Matching**: Case-insensitive exact match against dropdown options
- **Confidence Levels**: High (exact match), Medium (normalized match), None (hint only)
- **Timeout**: 5-second timeout for NHTSA API calls
- **Timeout**: 5-second timeout for OCR service calls
#### Decode VIN Request
```json
@@ -140,15 +136,15 @@ Authorization: Bearer <jwt>
Response (200):
{
"year": { "value": 2021, "nhtsaValue": "2021", "confidence": "high" },
"make": { "value": "Honda", "nhtsaValue": "HONDA", "confidence": "high" },
"model": { "value": "Civic", "nhtsaValue": "Civic", "confidence": "high" },
"trimLevel": { "value": "EX", "nhtsaValue": "EX", "confidence": "high" },
"engine": { "value": null, "nhtsaValue": "2.0L L4 DOHC 16V", "confidence": "none" },
"transmission": { "value": null, "nhtsaValue": "CVT", "confidence": "none" },
"bodyType": { "value": null, "nhtsaValue": "Sedan", "confidence": "none" },
"driveType": { "value": null, "nhtsaValue": "FWD", "confidence": "none" },
"fuelType": { "value": null, "nhtsaValue": "Gasoline", "confidence": "none" }
"year": { "value": 2021, "decodedValue": "2021", "confidence": "high" },
"make": { "value": "Honda", "decodedValue": "HONDA", "confidence": "high" },
"model": { "value": "Civic", "decodedValue": "Civic", "confidence": "high" },
"trimLevel": { "value": "EX", "decodedValue": "EX", "confidence": "high" },
"engine": { "value": null, "decodedValue": "2.0L L4 DOHC 16V", "confidence": "none" },
"transmission": { "value": null, "decodedValue": "CVT", "confidence": "none" },
"bodyType": { "value": null, "decodedValue": "Sedan", "confidence": "none" },
"driveType": { "value": null, "decodedValue": "FWD", "confidence": "none" },
"fuelType": { "value": null, "decodedValue": "Gasoline", "confidence": "none" }
}
Error (400 - Invalid VIN):
@@ -157,7 +153,7 @@ Error (400 - Invalid VIN):
Error (403 - Tier Required):
{ "error": "TIER_REQUIRED", "requiredTier": "pro", "currentTier": "free", ... }
Error (502 - NHTSA Failure):
Error (502 - OCR Service Failure):
{ "error": "VIN_DECODE_FAILED", "message": "Unable to decode VIN from external service" }
```
@@ -230,7 +226,7 @@ Error (502 - NHTSA Failure):
## Testing
### Unit Tests
- `vehicles.service.test.ts` - Business logic with mocked dependencies (VIN decode, caching, CRUD operations)
- `vehicles.service.test.ts` - Business logic with mocked dependencies (VIN decode via OCR service mock, caching, CRUD operations)
### Integration Tests
- `vehicles.integration.test.ts` - Complete API workflow with test database (create, read, update, delete vehicles)

View File

@@ -10,24 +10,23 @@ import { pool } from '../../../core/config/database';
import { logger } from '../../../core/logging/logger';
import { CreateVehicleBody, UpdateVehicleBody, VehicleParams } from '../domain/vehicles.types';
import { getStorageService } from '../../../core/storage/storage.service';
import { NHTSAClient, DecodeVinRequest } from '../external/nhtsa';
import { ocrClient } from '../../ocr/external/ocr-client';
import type { DecodeVinRequest } from '../domain/vehicles.types';
import crypto from 'crypto';
import FileType from 'file-type';
import path from 'path';
export class VehiclesController {
private vehiclesService: VehiclesService;
private nhtsaClient: NHTSAClient;
constructor() {
const repository = new VehiclesRepository(pool);
this.vehiclesService = new VehiclesService(repository, pool);
this.nhtsaClient = new NHTSAClient(pool);
}
async getUserVehicles(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
// Use tier-aware method to filter out locked vehicles after downgrade
const vehiclesWithStatus = await this.vehiclesService.getUserVehiclesWithTierStatus(userId);
// Only return active vehicles (filter out locked ones)
@@ -37,7 +36,7 @@ export class VehiclesController {
return reply.code(200).send(vehicles);
} catch (error) {
logger.error('Error getting user vehicles', { error, userId: (request as any).user?.sub });
logger.error('Error getting user vehicles', { error, userId: request.userContext?.userId });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get vehicles'
@@ -65,12 +64,12 @@ export class VehiclesController {
}
}
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const vehicle = await this.vehiclesService.createVehicle(request.body, userId);
return reply.code(201).send(vehicle);
} catch (error: any) {
logger.error('Error creating vehicle', { error, userId: (request as any).user?.sub });
logger.error('Error creating vehicle', { error, userId: request.userContext?.userId });
if (error instanceof VehicleLimitExceededError) {
return reply.code(403).send({
@@ -110,7 +109,7 @@ export class VehiclesController {
async getVehicle(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const { id } = request.params;
// Check tier status - block access to locked vehicles
@@ -131,7 +130,7 @@ export class VehiclesController {
return reply.code(200).send(vehicle);
} catch (error: any) {
logger.error('Error getting vehicle', { error, vehicleId: request.params.id, userId: (request as any).user?.sub });
logger.error('Error getting vehicle', { error, vehicleId: request.params.id, userId: request.userContext?.userId });
if (error.message === 'Vehicle not found' || error.message === 'Unauthorized') {
return reply.code(404).send({
@@ -149,14 +148,14 @@ export class VehiclesController {
async updateVehicle(request: FastifyRequest<{ Params: VehicleParams; Body: UpdateVehicleBody }>, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const { id } = request.params;
const vehicle = await this.vehiclesService.updateVehicle(id, request.body, userId);
return reply.code(200).send(vehicle);
} catch (error: any) {
logger.error('Error updating vehicle', { error, vehicleId: request.params.id, userId: (request as any).user?.sub });
logger.error('Error updating vehicle', { error, vehicleId: request.params.id, userId: request.userContext?.userId });
if (error.message === 'Vehicle not found' || error.message === 'Unauthorized') {
return reply.code(404).send({
@@ -183,14 +182,14 @@ export class VehiclesController {
async deleteVehicle(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const { id } = request.params;
await this.vehiclesService.deleteVehicle(id, userId);
return reply.code(204).send();
} catch (error: any) {
logger.error('Error deleting vehicle', { error, vehicleId: request.params.id, userId: (request as any).user?.sub });
logger.error('Error deleting vehicle', { error, vehicleId: request.params.id, userId: request.userContext?.userId });
if (error.message === 'Vehicle not found' || error.message === 'Unauthorized') {
return reply.code(404).send({
@@ -208,13 +207,13 @@ export class VehiclesController {
async getTCO(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const { id } = request.params;
const tco = await this.vehiclesService.getTCO(id, userId);
return reply.code(200).send(tco);
} catch (error: any) {
logger.error('Error getting vehicle TCO', { error, vehicleId: request.params.id, userId: (request as any).user?.sub });
logger.error('Error getting vehicle TCO', { error, vehicleId: request.params.id, userId: request.userContext?.userId });
if (error.statusCode === 404 || error.message === 'Vehicle not found') {
return reply.code(404).send({
@@ -378,12 +377,12 @@ export class VehiclesController {
}
/**
* Decode VIN using NHTSA vPIC API
* Decode VIN using OCR service (Gemini)
* POST /api/vehicles/decode-vin
* Requires Pro or Enterprise tier
*/
async decodeVin(request: FastifyRequest<{ Body: DecodeVinRequest }>, reply: FastifyReply) {
const userId = (request as any).user?.sub;
const userId = request.userContext?.userId;
try {
const { vin } = request.body;
@@ -395,13 +394,34 @@ export class VehiclesController {
});
}
logger.info('VIN decode requested', { userId, vin: vin.substring(0, 6) + '...' });
// Validate VIN format
const sanitizedVin = vin.trim().toUpperCase();
const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/;
if (!VIN_REGEX.test(sanitizedVin)) {
return reply.code(400).send({
error: 'INVALID_VIN',
message: 'Invalid VIN format. VIN must be exactly 17 characters and contain only letters (except I, O, Q) and numbers.'
});
}
// Validate and decode VIN
const response = await this.nhtsaClient.decodeVin(vin);
logger.info('VIN decode requested', { userId, vin: sanitizedVin.substring(0, 6) + '...' });
// Extract and map fields from NHTSA response
const decodedData = await this.vehiclesService.mapNHTSAResponse(response);
// Check cache first
const cached = await this.vehiclesService.getVinCached(sanitizedVin);
if (cached) {
logger.info('VIN decode cache hit', { userId });
const decodedData = await this.vehiclesService.mapVinDecodeResponse(cached);
return reply.code(200).send(decodedData);
}
// Call OCR service for VIN decode
const response = await ocrClient.decodeVin(sanitizedVin);
// Cache the response
await this.vehiclesService.saveVinCache(sanitizedVin, response);
// Map response to decoded vehicle data with dropdown matching
const decodedData = await this.vehiclesService.mapVinDecodeResponse(response);
logger.info('VIN decode successful', {
userId,
@@ -414,7 +434,7 @@ export class VehiclesController {
} catch (error: any) {
logger.error('VIN decode failed', { error, userId });
// Handle validation errors
// Handle VIN validation errors
if (error.message?.includes('Invalid VIN')) {
return reply.code(400).send({
error: 'INVALID_VIN',
@@ -422,16 +442,25 @@ export class VehiclesController {
});
}
// Handle timeout
if (error.message?.includes('timed out')) {
return reply.code(504).send({
error: 'VIN_DECODE_TIMEOUT',
message: 'NHTSA API request timed out. Please try again.'
// Handle OCR service errors by status code
if (error.statusCode === 503 || error.statusCode === 422) {
return reply.code(502).send({
error: 'VIN_DECODE_FAILED',
message: 'VIN decode service unavailable',
details: error.message
});
}
// Handle NHTSA API errors
if (error.message?.includes('NHTSA')) {
// Handle timeout
if (error.message?.includes('timed out') || error.message?.includes('aborted')) {
return reply.code(504).send({
error: 'VIN_DECODE_TIMEOUT',
message: 'VIN decode service timed out. Please try again.'
});
}
// Handle OCR service errors
if (error.message?.includes('OCR service error')) {
return reply.code(502).send({
error: 'VIN_DECODE_FAILED',
message: 'Unable to decode VIN from external service',
@@ -447,7 +476,7 @@ export class VehiclesController {
}
async uploadImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const vehicleId = request.params.id;
logger.info('Vehicle image upload requested', {
@@ -604,7 +633,7 @@ export class VehiclesController {
}
async downloadImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const vehicleId = request.params.id;
logger.info('Vehicle image download requested', {
@@ -654,7 +683,7 @@ export class VehiclesController {
}
async deleteImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const vehicleId = request.params.id;
logger.info('Vehicle image delete requested', {

View File

@@ -75,7 +75,7 @@ export const vehiclesRoutes: FastifyPluginAsync = async (
handler: vehiclesController.getDropdownOptions.bind(vehiclesController)
});
// POST /api/vehicles/decode-vin - Decode VIN using NHTSA vPIC API (Pro/Enterprise only)
// POST /api/vehicles/decode-vin - Decode VIN via OCR service (Pro/Enterprise only)
fastify.post<{ Body: { vin: string } }>('/vehicles/decode-vin', {
preHandler: [fastify.authenticate, fastify.requireTier({ featureKey: 'vehicle.vinDecode' })],
handler: vehiclesController.decodeVin.bind(vehiclesController)

View File

@@ -24,7 +24,8 @@ import { isValidVIN, isValidPreModernVIN } from '../../../shared-minimal/utils/v
import { normalizeMakeName, normalizeModelName } from './name-normalizer';
import { getVehicleDataService, getPool } from '../../platform';
import { auditLogService } from '../../audit-log';
import { NHTSAClient, NHTSADecodeResponse, DecodedVehicleData, MatchedField } from '../external/nhtsa';
import type { VinDecodeResponse } from '../../ocr/domain/ocr.types';
import type { DecodedVehicleData, MatchedField } from './vehicles.types';
import { canAddVehicle, getVehicleLimitConfig } from '../../../core/config/feature-tiers';
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
import { SubscriptionTier } from '../../user-profile/domain/user-profile.types';
@@ -82,7 +83,7 @@ export class VehiclesService {
}
// Get user's tier for limit enforcement
const userProfile = await this.userProfileRepository.getByAuth0Sub(userId);
const userProfile = await this.userProfileRepository.getById(userId);
if (!userProfile) {
throw new Error('User profile not found');
}
@@ -227,7 +228,7 @@ export class VehiclesService {
*/
async getUserVehiclesWithTierStatus(userId: string): Promise<Array<VehicleResponse & { tierStatus: 'active' | 'locked' }>> {
// Get user's subscription tier
const userProfile = await this.userProfileRepository.getByAuth0Sub(userId);
const userProfile = await this.userProfileRepository.getById(userId);
if (!userProfile) {
throw new Error('User profile not found');
}
@@ -592,6 +593,72 @@ export class VehiclesService {
const cacheKey = `${this.cachePrefix}:user:${userId}`;
await cacheService.del(cacheKey);
}
/**
* Check vin_cache for existing VIN data.
* Format-aware: validates raw_data has `success` field (Gemini format).
* Old NHTSA-format entries are treated as cache misses and expire via TTL.
*/
async getVinCached(vin: string): Promise<VinDecodeResponse | null> {
try {
const result = await this.pool.query<{
raw_data: any;
cached_at: Date;
}>(
`SELECT raw_data, cached_at
FROM vin_cache
WHERE vin = $1
AND cached_at > NOW() - INTERVAL '365 days'`,
[vin]
);
if (result.rows.length === 0) {
return null;
}
const rawData = result.rows[0].raw_data;
// Format-aware check: Gemini responses have `success` field,
// old NHTSA responses do not. Treat old format as cache miss.
if (!rawData || typeof rawData !== 'object' || !('success' in rawData)) {
logger.debug('VIN cache format mismatch (legacy NHTSA entry), treating as miss', { vin });
return null;
}
logger.debug('VIN cache hit', { vin });
return rawData as VinDecodeResponse;
} catch (error) {
logger.error('Failed to check VIN cache', { vin, error });
return null;
}
}
/**
* Save VIN decode response to cache with ON CONFLICT upsert.
*/
async saveVinCache(vin: string, response: VinDecodeResponse): Promise<void> {
try {
await this.pool.query(
`INSERT INTO vin_cache (vin, make, model, year, engine_type, body_type, raw_data, cached_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
ON CONFLICT (vin) DO UPDATE SET
make = EXCLUDED.make,
model = EXCLUDED.model,
year = EXCLUDED.year,
engine_type = EXCLUDED.engine_type,
body_type = EXCLUDED.body_type,
raw_data = EXCLUDED.raw_data,
cached_at = NOW()
WHERE (vin_cache.raw_data->>'confidence')::float <= $8`,
[vin, response.make, response.model, response.year, response.engine, response.bodyType, JSON.stringify(response), response.confidence ?? 1]
);
logger.debug('VIN cached', { vin, confidence: response.confidence });
} catch (error) {
logger.error('Failed to cache VIN data', { vin, error });
// Don't throw - caching failure shouldn't break the decode flow
}
}
async getDropdownMakes(year: number): Promise<string[]> {
const vehicleDataService = getVehicleDataService();
@@ -657,82 +724,88 @@ export class VehiclesService {
}
/**
* Map NHTSA decode response to internal decoded vehicle data format
* Map VIN decode response to internal decoded vehicle data format
* with dropdown matching and confidence levels
*/
async mapNHTSAResponse(response: NHTSADecodeResponse): Promise<DecodedVehicleData> {
async mapVinDecodeResponse(response: VinDecodeResponse): Promise<DecodedVehicleData> {
const vehicleDataService = getVehicleDataService();
const pool = getPool();
// Extract raw values from NHTSA response
const nhtsaYear = NHTSAClient.extractYear(response);
const nhtsaMake = NHTSAClient.extractValue(response, 'Make');
const nhtsaModel = NHTSAClient.extractValue(response, 'Model');
const nhtsaTrim = NHTSAClient.extractValue(response, 'Trim');
const nhtsaBodyType = NHTSAClient.extractValue(response, 'Body Class');
const nhtsaDriveType = NHTSAClient.extractValue(response, 'Drive Type');
const nhtsaFuelType = NHTSAClient.extractValue(response, 'Fuel Type - Primary');
const nhtsaEngine = NHTSAClient.extractEngine(response);
const nhtsaTransmission = NHTSAClient.extractValue(response, 'Transmission Style');
// Read flat fields directly from Gemini response
const sourceYear = response.year;
const sourceMake = response.make;
const sourceModel = response.model;
const sourceTrim = response.trimLevel;
const sourceBodyType = response.bodyType;
const sourceDriveType = response.driveType;
const sourceFuelType = response.fuelType;
const sourceEngine = response.engine;
const sourceTransmission = response.transmission;
logger.debug('VIN decode raw values', {
vin: response.vin,
year: sourceYear, make: sourceMake, model: sourceModel,
trim: sourceTrim, confidence: response.confidence
});
// Year is always high confidence if present (exact numeric match)
const year: MatchedField<number> = {
value: nhtsaYear,
nhtsaValue: nhtsaYear?.toString() || null,
confidence: nhtsaYear ? 'high' : 'none'
value: sourceYear,
sourceValue: sourceYear?.toString() || null,
confidence: sourceYear ? 'high' : 'none'
};
// Match make against dropdown options
let make: MatchedField<string> = { value: null, nhtsaValue: nhtsaMake, confidence: 'none' };
if (nhtsaYear && nhtsaMake) {
const makes = await vehicleDataService.getMakes(pool, nhtsaYear);
make = this.matchField(nhtsaMake, makes);
let make: MatchedField<string> = { value: null, sourceValue: sourceMake, confidence: 'none' };
if (sourceYear && sourceMake) {
const makes = await vehicleDataService.getMakes(pool, sourceYear);
make = this.matchField(sourceMake, makes);
}
// Match model against dropdown options
let model: MatchedField<string> = { value: null, nhtsaValue: nhtsaModel, confidence: 'none' };
if (nhtsaYear && make.value && nhtsaModel) {
const models = await vehicleDataService.getModels(pool, nhtsaYear, make.value);
model = this.matchField(nhtsaModel, models);
let model: MatchedField<string> = { value: null, sourceValue: sourceModel, confidence: 'none' };
if (sourceYear && make.value && sourceModel) {
const models = await vehicleDataService.getModels(pool, sourceYear, make.value);
model = this.matchField(sourceModel, models);
}
// Match trim against dropdown options
let trimLevel: MatchedField<string> = { value: null, nhtsaValue: nhtsaTrim, confidence: 'none' };
if (nhtsaYear && make.value && model.value && nhtsaTrim) {
const trims = await vehicleDataService.getTrims(pool, nhtsaYear, make.value, model.value);
trimLevel = this.matchField(nhtsaTrim, trims);
let trimLevel: MatchedField<string> = { value: null, sourceValue: sourceTrim, confidence: 'none' };
if (sourceYear && make.value && model.value && sourceTrim) {
const trims = await vehicleDataService.getTrims(pool, sourceYear, make.value, model.value);
trimLevel = this.matchField(sourceTrim, trims);
}
// Match engine against dropdown options
let engine: MatchedField<string> = { value: null, nhtsaValue: nhtsaEngine, confidence: 'none' };
if (nhtsaYear && make.value && model.value && trimLevel.value && nhtsaEngine) {
const engines = await vehicleDataService.getEngines(pool, nhtsaYear, make.value, model.value, trimLevel.value);
engine = this.matchField(nhtsaEngine, engines);
let engine: MatchedField<string> = { value: null, sourceValue: sourceEngine, confidence: 'none' };
if (sourceYear && make.value && model.value && trimLevel.value && sourceEngine) {
const engines = await vehicleDataService.getEngines(pool, sourceYear, make.value, model.value, trimLevel.value);
engine = this.matchField(sourceEngine, engines);
}
// Match transmission against dropdown options
let transmission: MatchedField<string> = { value: null, nhtsaValue: nhtsaTransmission, confidence: 'none' };
if (nhtsaYear && make.value && model.value && trimLevel.value && nhtsaTransmission) {
const transmissions = await vehicleDataService.getTransmissionsForTrim(pool, nhtsaYear, make.value, model.value, trimLevel.value);
transmission = this.matchField(nhtsaTransmission, transmissions);
let transmission: MatchedField<string> = { value: null, sourceValue: sourceTransmission, confidence: 'none' };
if (sourceYear && make.value && model.value && trimLevel.value && sourceTransmission) {
const transmissions = await vehicleDataService.getTransmissionsForTrim(pool, sourceYear, make.value, model.value, trimLevel.value);
transmission = this.matchField(sourceTransmission, transmissions);
}
// Body type, drive type, and fuel type are display-only (no dropdown matching)
const bodyType: MatchedField<string> = {
value: null,
nhtsaValue: nhtsaBodyType,
sourceValue: sourceBodyType,
confidence: 'none'
};
const driveType: MatchedField<string> = {
value: null,
nhtsaValue: nhtsaDriveType,
sourceValue: sourceDriveType,
confidence: 'none'
};
const fuelType: MatchedField<string> = {
value: null,
nhtsaValue: nhtsaFuelType,
sourceValue: sourceFuelType,
confidence: 'none'
};
@@ -754,42 +827,62 @@ export class VehiclesService {
* Returns the matched dropdown value with confidence level
* Matching order: exact -> normalized -> prefix -> contains
*/
private matchField(nhtsaValue: string, options: string[]): MatchedField<string> {
if (!nhtsaValue || options.length === 0) {
return { value: null, nhtsaValue, confidence: 'none' };
private matchField(sourceValue: string, options: string[]): MatchedField<string> {
if (!sourceValue || options.length === 0) {
return { value: null, sourceValue, confidence: 'none' };
}
const normalizedNhtsa = nhtsaValue.toLowerCase().trim();
const normalizedSource = sourceValue.toLowerCase().trim();
// Try exact case-insensitive match
const exactMatch = options.find(opt => opt.toLowerCase().trim() === normalizedNhtsa);
const exactMatch = options.find(opt => opt.toLowerCase().trim() === normalizedSource);
if (exactMatch) {
return { value: exactMatch, nhtsaValue, confidence: 'high' };
return { value: exactMatch, sourceValue, confidence: 'high' };
}
// Try normalized comparison (remove special chars)
const normalizeForCompare = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
const normalizedNhtsaClean = normalizeForCompare(nhtsaValue);
const normalizedSourceClean = normalizeForCompare(sourceValue);
const normalizedMatch = options.find(opt => normalizeForCompare(opt) === normalizedNhtsaClean);
const normalizedMatch = options.find(opt => normalizeForCompare(opt) === normalizedSourceClean);
if (normalizedMatch) {
return { value: normalizedMatch, nhtsaValue, confidence: 'medium' };
return { value: normalizedMatch, sourceValue, confidence: 'medium' };
}
// Try prefix match - option starts with NHTSA value
const prefixMatch = options.find(opt => opt.toLowerCase().trim().startsWith(normalizedNhtsa));
// Try prefix match - option starts with source value
const prefixMatch = options.find(opt => opt.toLowerCase().trim().startsWith(normalizedSource));
if (prefixMatch) {
return { value: prefixMatch, nhtsaValue, confidence: 'medium' };
return { value: prefixMatch, sourceValue, confidence: 'medium' };
}
// Try contains match - option contains NHTSA value
const containsMatch = options.find(opt => opt.toLowerCase().trim().includes(normalizedNhtsa));
// Try contains match - option contains source value
const containsMatch = options.find(opt => opt.toLowerCase().trim().includes(normalizedSource));
if (containsMatch) {
return { value: containsMatch, nhtsaValue, confidence: 'medium' };
return { value: containsMatch, sourceValue, confidence: 'medium' };
}
// No match found - return NHTSA value as hint with no match
return { value: null, nhtsaValue, confidence: 'none' };
// Try reverse contains - source value contains option (e.g., source "X5 xDrive35i" contains option "X5")
// Prefer the longest matching option to avoid false positives (e.g., "X5 M" over "X5")
const reverseMatches = options.filter(opt => {
const normalizedOpt = opt.toLowerCase().trim();
return normalizedSource.includes(normalizedOpt) && normalizedOpt.length > 0;
});
if (reverseMatches.length > 0) {
const bestMatch = reverseMatches.reduce((a, b) => a.length >= b.length ? a : b);
return { value: bestMatch, sourceValue, confidence: 'medium' };
}
// Try word-start match - source starts with option + separator (e.g., "X5 xDrive" starts with "X5 ")
const wordStartMatch = options.find(opt => {
const normalizedOpt = opt.toLowerCase().trim();
return normalizedSource.startsWith(normalizedOpt + ' ') || normalizedSource.startsWith(normalizedOpt + '-');
});
if (wordStartMatch) {
return { value: wordStartMatch, sourceValue, confidence: 'medium' };
}
// No match found - return source value as hint with no match
return { value: null, sourceValue, confidence: 'none' };
}
private toResponse(vehicle: Vehicle): VehicleResponse {

View File

@@ -215,3 +215,53 @@ export interface TCOResponse {
distanceUnit: string;
currencyCode: string;
}
/** Confidence level for matched dropdown values */
export type MatchConfidence = 'high' | 'medium' | 'none';
/** Matched field with confidence indicator */
export interface MatchedField<T> {
value: T | null;
sourceValue: string | null;
confidence: MatchConfidence;
}
/**
* Decoded vehicle data with match confidence per field.
* Maps VIN decode response fields to internal field names.
*/
export interface DecodedVehicleData {
year: MatchedField<number>;
make: MatchedField<string>;
model: MatchedField<string>;
trimLevel: MatchedField<string>;
bodyType: MatchedField<string>;
driveType: MatchedField<string>;
fuelType: MatchedField<string>;
engine: MatchedField<string>;
transmission: MatchedField<string>;
}
/** Cached VIN data from vin_cache table */
export interface VinCacheEntry {
vin: string;
make: string | null;
model: string | null;
year: number | null;
engineType: string | null;
bodyType: string | null;
rawData: import('../../ocr/domain/ocr.types').VinDecodeResponse;
cachedAt: Date;
}
/** VIN decode request body */
export interface DecodeVinRequest {
vin: string;
}
/** VIN decode error response */
export interface VinDecodeError {
error: 'INVALID_VIN' | 'VIN_DECODE_FAILED' | 'TIER_REQUIRED';
message: string;
details?: string;
}

View File

@@ -5,9 +5,3 @@
| File | What | When to read |
| ---- | ---- | ------------ |
| `README.md` | Integration patterns, adding new services | Understanding external service conventions |
## Subdirectories
| Directory | What | When to read |
| --------- | ---- | ------------ |
| `nhtsa/` | NHTSA vPIC API client for VIN decoding | VIN decode feature work |

View File

@@ -15,7 +15,7 @@ Each integration follows this structure:
## Adding New Integrations
1. Create subdirectory: `external/{service}/`
2. Add client: `{service}.client.ts` following NHTSAClient pattern
2. Add client: `{service}.client.ts` following the axios-based client pattern
3. Add types: `{service}.types.ts`
4. Update `CLAUDE.md` with new directory
5. Add tests in `tests/unit/{service}.client.test.ts`

View File

@@ -1,16 +0,0 @@
/**
* @ai-summary NHTSA vPIC integration exports
* @ai-context Public API for VIN decoding functionality
*/
export { NHTSAClient } from './nhtsa.client';
export type {
NHTSADecodeResponse,
NHTSAResult,
DecodedVehicleData,
MatchedField,
MatchConfidence,
VinCacheEntry,
DecodeVinRequest,
VinDecodeError,
} from './nhtsa.types';

View File

@@ -1,235 +0,0 @@
/**
* @ai-summary NHTSA vPIC API client for VIN decoding
* @ai-context Fetches vehicle data from NHTSA and caches results
*/
import axios, { AxiosError } from 'axios';
import { logger } from '../../../../core/logging/logger';
import { NHTSADecodeResponse, VinCacheEntry } from './nhtsa.types';
import { Pool } from 'pg';
/**
* VIN validation regex
* - 17 characters
* - Excludes I, O, Q (not used in VINs)
* - Alphanumeric only
*/
const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/;
/**
* Cache TTL: 1 year (VIN data is static - vehicle specs don't change)
*/
const CACHE_TTL_SECONDS = 365 * 24 * 60 * 60;
export class NHTSAClient {
private readonly baseURL = 'https://vpic.nhtsa.dot.gov/api';
private readonly timeout = 5000; // 5 seconds
constructor(private readonly pool: Pool) {}
/**
* Validate VIN format
* @throws Error if VIN format is invalid
*/
validateVin(vin: string): string {
const sanitized = vin.trim().toUpperCase();
if (!sanitized) {
throw new Error('VIN is required');
}
if (!VIN_REGEX.test(sanitized)) {
throw new Error('Invalid VIN format. VIN must be exactly 17 characters and contain only letters (except I, O, Q) and numbers.');
}
return sanitized;
}
/**
* Check cache for existing VIN data
*/
async getCached(vin: string): Promise<VinCacheEntry | null> {
try {
const result = await this.pool.query<{
vin: string;
make: string | null;
model: string | null;
year: number | null;
engine_type: string | null;
body_type: string | null;
raw_data: NHTSADecodeResponse;
cached_at: Date;
}>(
`SELECT vin, make, model, year, engine_type, body_type, raw_data, cached_at
FROM vin_cache
WHERE vin = $1
AND cached_at > NOW() - INTERVAL '${CACHE_TTL_SECONDS} seconds'`,
[vin]
);
if (result.rows.length === 0) {
return null;
}
const row = result.rows[0];
return {
vin: row.vin,
make: row.make,
model: row.model,
year: row.year,
engineType: row.engine_type,
bodyType: row.body_type,
rawData: row.raw_data,
cachedAt: row.cached_at,
};
} catch (error) {
logger.error('Failed to check VIN cache', { vin, error });
return null;
}
}
/**
* Save VIN data to cache
*/
async saveToCache(vin: string, response: NHTSADecodeResponse): Promise<void> {
try {
const findValue = (variable: string): string | null => {
const result = response.Results.find(r => r.Variable === variable);
return result?.Value || null;
};
const year = findValue('Model Year');
const make = findValue('Make');
const model = findValue('Model');
const engineType = findValue('Engine Model');
const bodyType = findValue('Body Class');
await this.pool.query(
`INSERT INTO vin_cache (vin, make, model, year, engine_type, body_type, raw_data, cached_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
ON CONFLICT (vin) DO UPDATE SET
make = EXCLUDED.make,
model = EXCLUDED.model,
year = EXCLUDED.year,
engine_type = EXCLUDED.engine_type,
body_type = EXCLUDED.body_type,
raw_data = EXCLUDED.raw_data,
cached_at = NOW()`,
[vin, make, model, year ? parseInt(year) : null, engineType, bodyType, JSON.stringify(response)]
);
logger.debug('VIN cached', { vin });
} catch (error) {
logger.error('Failed to cache VIN data', { vin, error });
// Don't throw - caching failure shouldn't break the decode flow
}
}
/**
* Decode VIN using NHTSA vPIC API
* @param vin - 17-character VIN
* @returns Raw NHTSA decode response
* @throws Error if VIN is invalid or API call fails
*/
async decodeVin(vin: string): Promise<NHTSADecodeResponse> {
// Validate and sanitize VIN
const sanitizedVin = this.validateVin(vin);
// Check cache first
const cached = await this.getCached(sanitizedVin);
if (cached) {
logger.debug('VIN cache hit', { vin: sanitizedVin });
return cached.rawData;
}
// Call NHTSA API
logger.info('Calling NHTSA vPIC API', { vin: sanitizedVin });
try {
const response = await axios.get<NHTSADecodeResponse>(
`${this.baseURL}/vehicles/decodevin/${sanitizedVin}`,
{
params: { format: 'json' },
timeout: this.timeout,
}
);
// Check for NHTSA-level errors
if (response.data.Count === 0) {
throw new Error('NHTSA returned no results for this VIN');
}
// Check for error messages in results
const errorResult = response.data.Results.find(
r => r.Variable === 'Error Code' && r.Value && r.Value !== '0'
);
if (errorResult) {
const errorText = response.data.Results.find(r => r.Variable === 'Error Text');
throw new Error(`NHTSA error: ${errorText?.Value || 'Unknown error'}`);
}
// Cache the successful response
await this.saveToCache(sanitizedVin, response.data);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
if (axiosError.code === 'ECONNABORTED') {
logger.error('NHTSA API timeout', { vin: sanitizedVin });
throw new Error('NHTSA API request timed out. Please try again.');
}
if (axiosError.response) {
logger.error('NHTSA API error response', {
vin: sanitizedVin,
status: axiosError.response.status,
data: axiosError.response.data,
});
throw new Error(`NHTSA API error: ${axiosError.response.status}`);
}
logger.error('NHTSA API network error', { vin: sanitizedVin, message: axiosError.message });
throw new Error('Unable to connect to NHTSA API. Please try again later.');
}
throw error;
}
}
/**
* Extract a specific value from NHTSA response
*/
static extractValue(response: NHTSADecodeResponse, variable: string): string | null {
const result = response.Results.find(r => r.Variable === variable);
return result?.Value?.trim() || null;
}
/**
* Extract year from NHTSA response
*/
static extractYear(response: NHTSADecodeResponse): number | null {
const value = NHTSAClient.extractValue(response, 'Model Year');
if (!value) return null;
const parsed = parseInt(value, 10);
return isNaN(parsed) ? null : parsed;
}
/**
* Extract engine description from NHTSA response
* Combines multiple engine-related fields
*/
static extractEngine(response: NHTSADecodeResponse): string | null {
const engineModel = NHTSAClient.extractValue(response, 'Engine Model');
if (engineModel) return engineModel;
// Build engine description from components
const cylinders = NHTSAClient.extractValue(response, 'Engine Number of Cylinders');
const displacement = NHTSAClient.extractValue(response, 'Displacement (L)');
const fuelType = NHTSAClient.extractValue(response, 'Fuel Type - Primary');
const parts: string[] = [];
if (cylinders) parts.push(`${cylinders}-Cylinder`);
if (displacement) parts.push(`${displacement}L`);
if (fuelType && fuelType !== 'Gasoline') parts.push(fuelType);
return parts.length > 0 ? parts.join(' ') : null;
}
}

View File

@@ -1,96 +0,0 @@
/**
* @ai-summary Type definitions for NHTSA vPIC API
* @ai-context Defines request/response types for VIN decoding
*/
/**
* Individual result from NHTSA DecodeVin API
*/
export interface NHTSAResult {
Value: string | null;
ValueId: string | null;
Variable: string;
VariableId: number;
}
/**
* Raw response from NHTSA DecodeVin API
* GET https://vpic.nhtsa.dot.gov/api/vehicles/decodevin/{VIN}?format=json
*/
export interface NHTSADecodeResponse {
Count: number;
Message: string;
SearchCriteria: string;
Results: NHTSAResult[];
}
/**
* Confidence level for matched dropdown values
*/
export type MatchConfidence = 'high' | 'medium' | 'none';
/**
* Matched field with confidence indicator
*/
export interface MatchedField<T> {
value: T | null;
nhtsaValue: string | null;
confidence: MatchConfidence;
}
/**
* Decoded vehicle data with match confidence per field
* Maps NHTSA response fields to internal field names (camelCase)
*
* NHTSA Field Mappings:
* - ModelYear -> year
* - Make -> make
* - Model -> model
* - Trim -> trimLevel
* - BodyClass -> bodyType
* - DriveType -> driveType
* - FuelTypePrimary -> fuelType
* - EngineModel / EngineCylinders + EngineDisplacementL -> engine
* - TransmissionStyle -> transmission
*/
export interface DecodedVehicleData {
year: MatchedField<number>;
make: MatchedField<string>;
model: MatchedField<string>;
trimLevel: MatchedField<string>;
bodyType: MatchedField<string>;
driveType: MatchedField<string>;
fuelType: MatchedField<string>;
engine: MatchedField<string>;
transmission: MatchedField<string>;
}
/**
* Cached VIN data from vin_cache table
*/
export interface VinCacheEntry {
vin: string;
make: string | null;
model: string | null;
year: number | null;
engineType: string | null;
bodyType: string | null;
rawData: NHTSADecodeResponse;
cachedAt: Date;
}
/**
* VIN decode request body
*/
export interface DecodeVinRequest {
vin: string;
}
/**
* VIN decode error response
*/
export interface VinDecodeError {
error: 'INVALID_VIN' | 'VIN_DECODE_FAILED' | 'TIER_REQUIRED';
message: string;
details?: string;
}

View File

@@ -22,11 +22,6 @@ platform:
url: http://mvp-platform-vehicles-api:8000
timeout: 5s
external:
vpic:
url: https://vpic.nhtsa.dot.gov/api/vehicles
timeout: 10s
service:
name: mvp-backend

View File

@@ -21,5 +21,3 @@ auth0:
domain: motovaultpro.us.auth0.com
audience: https://api.motovaultpro.com
external:
vpic_api_url: https://vpic.nhtsa.dot.gov/api/vehicles

View File

@@ -107,9 +107,6 @@ external_services:
google_maps:
base_url: https://maps.googleapis.com/maps/api
vpic:
base_url: https://vpic.nhtsa.dot.gov/api/vehicles
# Development Configuration
development:
debug_enabled: false

View File

@@ -11,6 +11,63 @@
# Shared services (from base compose):
# mvp-traefik, mvp-postgres, mvp-redis
# ========================================
# Extension fields (YAML anchors for DRY)
# ========================================
x-frontend-env: &frontend-env
VITE_API_BASE_URL: /api
VITE_AUTH0_DOMAIN: ${VITE_AUTH0_DOMAIN:-motovaultpro.us.auth0.com}
VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3}
VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com}
SECRETS_DIR: /run/secrets
x-frontend-volumes: &frontend-volumes
- ./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
x-frontend-healthcheck: &frontend-healthcheck
test: ["CMD-SHELL", "curl -sf http://localhost:3000 || exit 1"]
interval: 5s
timeout: 5s
retries: 3
start_period: 10s
x-backend-env: &backend-env
NODE_ENV: production
CONFIG_PATH: /app/config/production.yml
SECRETS_DIR: /run/secrets
LOG_LEVEL: ${BACKEND_LOG_LEVEL:-debug}
DATABASE_HOST: mvp-postgres
REDIS_HOST: mvp-redis
STRIPE_PRO_MONTHLY_PRICE_ID: ${STRIPE_PRO_MONTHLY_PRICE_ID:-price_1T1ZHMJXoKkh5RcKwKSSGIlR}
STRIPE_PRO_YEARLY_PRICE_ID: ${STRIPE_PRO_YEARLY_PRICE_ID:-price_1T1ZHnJXoKkh5RcKWlG2MPpX}
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: ${STRIPE_ENTERPRISE_MONTHLY_PRICE_ID:-price_1T1ZIBJXoKkh5RcKu2jyhqBN}
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: ${STRIPE_ENTERPRISE_YEARLY_PRICE_ID:-price_1T1ZIQJXoKkh5RcK34YXiJQm}
x-backend-volumes: &backend-volumes
- ./config/app/production.yml:/app/config/production.yml:ro
- ./config/shared/production.yml:/app/config/shared.yml:ro
- ./secrets/app/postgres-password.txt:/run/secrets/postgres-password:ro
- ./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
- ./secrets/app/auth0-management-client-id.txt:/run/secrets/auth0-management-client-id:ro
- ./secrets/app/auth0-management-client-secret.txt:/run/secrets/auth0-management-client-secret:ro
- ./secrets/app/stripe-secret-key.txt:/run/secrets/stripe-secret-key:ro
- ./secrets/app/stripe-webhook-secret.txt:/run/secrets/stripe-webhook-secret:ro
- ./data/documents:/app/data/documents
- ./data/backups:/app/data/backups
x-backend-healthcheck: &backend-healthcheck
test:
- CMD-SHELL
- node -e "require('http').get('http://localhost:3001/health', r => process.exit(r.statusCode===200?0:1)).on('error', () => process.exit(1))"
interval: 5s
timeout: 5s
retries: 5
start_period: 180s
services:
# ========================================
# BLUE Stack - Frontend
@@ -19,25 +76,13 @@ services:
image: ${FRONTEND_IMAGE:-git.motovaultpro.com/egullickson/frontend:latest}
container_name: mvp-frontend-blue
restart: unless-stopped
environment:
VITE_API_BASE_URL: /api
VITE_AUTH0_DOMAIN: ${VITE_AUTH0_DOMAIN:-motovaultpro.us.auth0.com}
VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3}
VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com}
SECRETS_DIR: /run/secrets
volumes:
- ./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
environment: *frontend-env
volumes: *frontend-volumes
networks:
- frontend
depends_on:
- mvp-backend-blue
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:3000 || exit 1"]
interval: 5s
timeout: 5s
retries: 3
start_period: 10s
healthcheck: *frontend-healthcheck
deploy:
resources:
limits:
@@ -55,44 +100,15 @@ services:
image: ${BACKEND_IMAGE:-git.motovaultpro.com/egullickson/backend:latest}
container_name: mvp-backend-blue
restart: unless-stopped
environment:
NODE_ENV: production
CONFIG_PATH: /app/config/production.yml
SECRETS_DIR: /run/secrets
DATABASE_HOST: mvp-postgres
REDIS_HOST: mvp-redis
STRIPE_PRO_MONTHLY_PRICE_ID: prod_Toj6BG9Z9JwREl
STRIPE_PRO_YEARLY_PRICE_ID: prod_Toj8oo0RpVBQmB
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: prod_Toj8xGEui9jl6j
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: prod_Toj9A7A773xrdn
volumes:
- ./config/app/production.yml:/app/config/production.yml:ro
- ./config/shared/production.yml:/app/config/shared.yml:ro
- ./secrets/app/postgres-password.txt:/run/secrets/postgres-password:ro
- ./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
- ./secrets/app/auth0-management-client-id.txt:/run/secrets/auth0-management-client-id:ro
- ./secrets/app/auth0-management-client-secret.txt:/run/secrets/auth0-management-client-secret:ro
- ./secrets/app/stripe-secret-key.txt:/run/secrets/stripe-secret-key:ro
- ./secrets/app/stripe-webhook-secret.txt:/run/secrets/stripe-webhook-secret:ro
- ./data/documents:/app/data/documents
- ./data/backups:/app/data/backups
environment: *backend-env
volumes: *backend-volumes
networks:
- backend
- database
depends_on:
- mvp-postgres
- mvp-redis
healthcheck:
test:
- CMD-SHELL
- node -e "require('http').get('http://localhost:3001/health', r => process.exit(r.statusCode===200?0:1)).on('error', () => process.exit(1))"
interval: 5s
timeout: 5s
retries: 5
start_period: 180s
healthcheck: *backend-healthcheck
deploy:
resources:
limits:
@@ -110,25 +126,13 @@ services:
image: ${FRONTEND_IMAGE:-git.motovaultpro.com/egullickson/frontend:latest}
container_name: mvp-frontend-green
restart: unless-stopped
environment:
VITE_API_BASE_URL: /api
VITE_AUTH0_DOMAIN: ${VITE_AUTH0_DOMAIN:-motovaultpro.us.auth0.com}
VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3}
VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com}
SECRETS_DIR: /run/secrets
volumes:
- ./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
environment: *frontend-env
volumes: *frontend-volumes
networks:
- frontend
depends_on:
- mvp-backend-green
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:3000 || exit 1"]
interval: 5s
timeout: 5s
retries: 3
start_period: 10s
healthcheck: *frontend-healthcheck
deploy:
resources:
limits:
@@ -146,44 +150,15 @@ services:
image: ${BACKEND_IMAGE:-git.motovaultpro.com/egullickson/backend:latest}
container_name: mvp-backend-green
restart: unless-stopped
environment:
NODE_ENV: production
CONFIG_PATH: /app/config/production.yml
SECRETS_DIR: /run/secrets
DATABASE_HOST: mvp-postgres
REDIS_HOST: mvp-redis
STRIPE_PRO_MONTHLY_PRICE_ID: prod_Toj6BG9Z9JwREl
STRIPE_PRO_YEARLY_PRICE_ID: prod_Toj8oo0RpVBQmB
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: prod_Toj8xGEui9jl6j
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: prod_Toj9A7A773xrdn
volumes:
- ./config/app/production.yml:/app/config/production.yml:ro
- ./config/shared/production.yml:/app/config/shared.yml:ro
- ./secrets/app/postgres-password.txt:/run/secrets/postgres-password:ro
- ./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
- ./secrets/app/auth0-management-client-id.txt:/run/secrets/auth0-management-client-id:ro
- ./secrets/app/auth0-management-client-secret.txt:/run/secrets/auth0-management-client-secret:ro
- ./secrets/app/stripe-secret-key.txt:/run/secrets/stripe-secret-key:ro
- ./secrets/app/stripe-webhook-secret.txt:/run/secrets/stripe-webhook-secret:ro
- ./data/documents:/app/data/documents
- ./data/backups:/app/data/backups
environment: *backend-env
volumes: *backend-volumes
networks:
- backend
- database
depends_on:
- mvp-postgres
- mvp-redis
healthcheck:
test:
- CMD-SHELL
- node -e "require('http').get('http://localhost:3001/health', r => process.exit(r.statusCode===200?0:1)).on('error', () => process.exit(1))"
interval: 5s
timeout: 5s
retries: 5
start_period: 180s
healthcheck: *backend-healthcheck
deploy:
resources:
limits:

Some files were not shown because too many files have changed in this diff Show More