Compare commits

..

177 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
Eric Gullickson
48993eb311 docs: fix receipt tier gating and add feature tier refs to core docs (refs #146)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 15m57s
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 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:22:38 -06:00
Eric Gullickson
11f52258db feat: add 410 error handling, progress messages, touch targets, and tests (refs #145)
- Handle poll errors including 410 Gone in useManualExtraction hook
- Add specific progress stage messages (Preparing/Processing/Mapping/Complete)
- Enforce 44px minimum touch targets on all interactive elements
- Add tests for inline editing, mobile fullscreen, and desktop modal layouts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:12:29 -06:00
Eric Gullickson
ca33f8ad9d feat: add PDF magic bytes validation, 410 Gone, and manual extraction tests (refs #144)
Add filename .pdf extension fallback and %PDF magic bytes validation to
extractManual controller. Update getJobStatus to return 410 Gone for
expired jobs. Add 16 unit tests covering all acceptance criteria.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:55:06 -06:00
Eric Gullickson
209425a908 feat: rewrite ManualExtractor progress to spec-aligned 10/50/95/100 pattern (refs #143)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:40:11 -06:00
Eric Gullickson
f9a650a4d7 feat: add traceback logging and spec-aligned error message to GeminiEngine (refs #142)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:35:06 -06:00
Eric Gullickson
4e5da4782f feat: add 5s timeout and warning log for station name search (refs #141)
Add 5000ms timeout to Places Text Search API call in searchStationByName.
Timeout errors log a warning instead of error and return null gracefully.
Add timeout test case to station-matching unit tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:03:35 -06:00
Eric Gullickson
c79b610145 feat: enforce 44px minimum touch targets for receipt OCR components (refs #140)
Adds minHeight/minWidth: 44 to ReceiptCameraButton, ReceiptOcrReviewModal
action buttons, and UpgradeRequiredDialog buttons and close icon to meet
mobile accessibility requirements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:33:57 -06:00
Eric Gullickson
88c2d7fbcd feat: add receipt proxy tier guard, 422 forwarding, and tests (refs #139)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:20:58 -06:00
Eric Gullickson
1a6400a6bc feat: add standalone requireTier middleware (refs #138)
Create reusable preHandler middleware for subscription tier gating.
Composable with requireAuth in route preHandler arrays. Returns 403
TIER_REQUIRED with upgrade prompt for insufficient tier, 500 for
unknown feature keys. Includes 9 unit tests covering all acceptance
criteria.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:13:15 -06:00
Eric Gullickson
ab0d8463be docs: update CLAUDE.md indexes and README for OCR expansion (refs #137)
Add/update documentation across backend, Python OCR service, and frontend
for receipt scanning, manual extraction, and Gemini integration. Create
new CLAUDE.md files for engines/, fuel-logs/, documents/, and maintenance/
features.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:04:19 -06:00
Eric Gullickson
40df5e5b58 feat: add frontend manual extraction flow with review screen (refs #136)
- Create useManualExtraction hook: submit PDF to OCR, poll job status, track progress
- Create useCreateSchedulesFromExtraction hook: batch create maintenance schedules from extraction
- Create MaintenanceScheduleReviewScreen: dialog with checkboxes, inline editing, batch create
- Update DocumentForm: remove "(Coming soon)", trigger extraction after upload, show progress
- Add 12 unit tests for review screen (rendering, selection, empty state, errors)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 10:48:46 -06:00
Eric Gullickson
a281cea9c5 feat: add backend OCR manual proxy endpoint (refs #135)
Add POST /api/ocr/extract/manual endpoint that proxies to the Python
OCR service's manual extraction pipeline. Includes Pro tier gating via
document.scanMaintenanceSchedule, PDF-only validation, 200MB file size
limit, and async 202 job response for polling via existing job status
endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 10:37:18 -06:00
Eric Gullickson
57ed04d955 feat: rewrite ManualExtractor to use Gemini engine (refs #134)
Replace traditional OCR pipeline (table_detector, table_parser,
maintenance_patterns) with GeminiEngine for semantic PDF extraction.
Map Gemini serviceName values to 27 maintenance subtypes via
ServiceMapper fuzzy matching. Add 8 unit tests covering normal
extraction, unusual names, empty response, and error handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 10:24:11 -06:00
Eric Gullickson
3705e63fde feat: add Gemini engine module and configuration (refs #133)
Add standalone GeminiEngine class for maintenance schedule extraction
from PDF owners manuals using Vertex AI Gemini 2.5 Flash with structured
JSON output enforcement, 20MB size limit, and lazy initialization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 10:00:47 -06:00
Eric Gullickson
d8dec64538 feat: add station matching from receipt merchant name (refs #132)
Add Google Places Text Search to match receipt merchant names (e.g.
"Shell", "COSTCO #123") to real gas stations. Backend exposes
POST /api/stations/match endpoint. Frontend calls it after OCR
extraction and pre-fills locationData with matched station's placeId,
name, and address. Users can clear the match in the review modal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 09:45:13 -06:00
Eric Gullickson
bc91fbad79 feat: add tier gating for receipt scan in FuelLogForm (refs #131)
Free tier users see locked button with upgrade prompt dialog.
Pro+ users can capture receipts normally. Works on mobile and desktop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 09:32:08 -06:00
Eric Gullickson
399313eb6d feat: update useReceiptOcr to call /ocr/extract/receipt endpoint (refs #131)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 09:30:02 -06:00
Eric Gullickson
dfc3924540 feat: add fuelLog.receiptScan tier gating with pro minTier (refs #131)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 09:29:48 -06:00
Eric Gullickson
e0e578a627 feat: add receipt extraction proxy endpoint (refs #130)
Add POST /api/ocr/extract/receipt endpoint that proxies to the Python
OCR service's /extract/receipt for receipt-specific field extraction.

- ReceiptExtractionResponse type with receiptType, extractedFields, rawText
- OcrClient.extractReceipt() with optional receipt_type form field
- OcrService.extractReceipt() with 10MB max, image-only validation
- OcrController.extractReceipt() with file upload and error mapping
- Route with auth middleware
- 9 unit tests covering normal, edge, and error scenarios

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 09:26:57 -06:00
e98b45eb3a Merge pull request 'feat: Google Vision primary OCR with Auth0 WIF and monthly usage cap (#127)' (#128) from issue-127-google-vision-primary-ocr into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 34s
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: #128
2026-02-11 01:46:20 +00:00
Eric Gullickson
91dc847f56 fix: use correct Auth0 US region domain in WIF token script (refs #127)
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 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Domain was motovaultpro.auth0.com (404) instead of
motovaultpro.us.auth0.com.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 18:44:30 -06:00
Eric Gullickson
7bba28154d fix: capture Auth0 error response in WIF token script (refs #127)
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 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
The set -e + curl --fail-with-body inside $() caused the script to exit
with code 22 and empty stderr, hiding the actual Auth0 error. Switch to
writing the body to a temp file and checking HTTP status manually so the
error response is visible in logs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 18:41:34 -06:00
Eric Gullickson
a416f76c21 fix: copy WIF config to deploy path in CI/CD workflows (refs #127)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 35s
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 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
The google-wif-config.json was never synced to the deploy path, so the
Docker bind mount created a directory artifact instead of a file. Vision
client initialization failed on every request, silently falling back to
PaddleOCR.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 18:34:41 -06:00
Eric Gullickson
e6dd7492a1 test: add monthly limit, counter, and cloud-primary engine tests (refs #127)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 8m46s
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 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
- Update existing hybrid engine tests for new Redis counter behavior
- Add cloud-primary path tests (under/at limit, fallback, errors)
- Add Redis counter increment and TTL verification tests
- Add Redis failure graceful handling test
- Update cloud engine error message assertion for WIF config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 20:56:51 -06:00
Eric Gullickson
f4a28d009f feat: update all Docker Compose files for Vision primary with WIF auth (refs #127)
- Switch OCR engine config to google_vision primary / paddleocr fallback
- Mount Auth0 OCR secrets and WIF config into all OCR containers
- Add WIF config to repo (not a secret, contains no credentials)
- Remove obsolete google-vision-key.json.example

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 20:53:44 -06:00
Eric Gullickson
5e4848c4e2 feat: add Auth0 OCR secrets to injection script and CI/CD workflows (refs #127)
- Add AUTH0_OCR_CLIENT_ID and AUTH0_OCR_CLIENT_SECRET to inject-secrets.sh
- Add new secrets to staging and production workflow env blocks
- Create .example files for new secret documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 20:52:29 -06:00
Eric Gullickson
9209739e75 feat: add Auth0 WIF token script and update Dockerfile (refs #127)
- Create fetch-auth0-token.sh for Auth0 M2M -> GCP WIF token exchange
- Add jq to Dockerfile system dependencies
- Ensure script is executable in container image

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 20:51:30 -06:00
Eric Gullickson
4abd7d8d5b feat: add Vision monthly cap, WIF auth, and cloud-primary hybrid engine (refs #127)
- Add VISION_MONTHLY_LIMIT config setting (default 1000)
- Update CloudEngine to use WIF credential config via ADC
- Rewrite HybridEngine to support cloud-primary with Redis counter
- Pass monthly_limit through engine factory

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 20:50:02 -06:00
Eric Gullickson
4412700e12 fix: use valid Redis log levels and add log level comments to all containers
All checks were successful
Deploy to Staging / Build Images (push) Successful in 33s
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 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Redis only supports debug|verbose|notice|warning -- not info or error.
The command was using ${LOG_LEVEL:-info} which resolved to INFO in
production (from workflow env), causing Redis to crash loop. Hardcode
the correct Redis-native levels (debug for dev, warning for prod) and
add available log level comments above every container's log setting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 21:27:33 -06:00
Eric Gullickson
c6b99ab29a fix: Postgres Fixes for Prod
All checks were successful
Deploy to Staging / Build Images (push) Successful in 1m34s
Deploy to Staging / Deploy to Staging (push) Successful in 23s
Deploy to Staging / Verify Staging (push) Successful in 2m36s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
2026-02-08 20:57:49 -06:00
8248b1a732 Merge pull request 'feat: Improve VIN decode confidence reporting and make/model/trim editability (#125)' (#126) from issue-125-improve-vin-confidence-editability into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 33s
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 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #126
2026-02-09 01:40:14 +00:00
Eric Gullickson
e9020dbb2f feat: improve VIN confidence reporting and editable review dropdowns (refs #125)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m37s
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
VIN OCR confidence now reflects recognition accuracy only (not match quality).
Review modal replaces read-only fields with editable cascade dropdowns
pre-populated from NHTSA decode, with NHTSA reference hints for unmatched fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 19:24:27 -06:00
Eric Gullickson
e7471d5c27 fix: Python Image Pinning
All checks were successful
Deploy to Staging / Build Images (push) Successful in 8m28s
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 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
2026-02-08 19:11:13 -06:00
Eric Gullickson
2c3e432fcf fix: Build errors with python3.13
All checks were successful
Deploy to Staging / Build Images (push) Successful in 8m50s
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 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
2026-02-08 18:54:49 -06:00
ee123a2ffd Merge pull request 'feat: Improve VIN photo capture camera crop (#123)' (#124) from issue-123-improve-vin-camera-crop into main
Some checks failed
Deploy to Staging / Deploy to Staging (push) Has been cancelled
Deploy to Staging / Build Images (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
Reviewed-on: #124
2026-02-09 00:36:43 +00:00
Eric Gullickson
1ff1931864 fix: re-request camera stream on retake when tracks are inactive (refs #123)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m20s
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 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
The retake button failed because the stream tracks could become inactive
during the crop phase, but handleRetake never re-acquired the camera.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 20:26:37 -06:00
Eric Gullickson
efc55cd3db feat: improve VIN camera crop overlay-to-crop alignment and touch targets (refs #123)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m20s
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 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Bridge guidance overlay position to crop tool initial coordinates so the
crop box appears centered matching the viewfinder guide. Increase handle
touch targets to 44px (32px on compact viewports) for mobile usability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 20:05:40 -06:00
dd77cb3836 Merge pull request 'feat: Improve OCR process - replace Tesseract with PaddleOCR (#115)' (#122) from issue-115-improve-ocr-paddleocr 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 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Mirror Base Images / Mirror Base Images (push) Successful in 51s
Reviewed-on: #122
2026-02-08 01:13:33 +00:00
Eric Gullickson
9a2b12c5dc fix: No matches
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 37s
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 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
2026-02-07 16:35:28 -06:00
Eric Gullickson
3adbb10ff6 fix: OCR Timout still
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m23s
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-07 16:26:10 -06:00
Eric Gullickson
fcffb0bb43 fix: PaddleOCR timeout
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m20s
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
2026-02-07 16:18:14 -06:00
Eric Gullickson
9d2d4e57b7 fix: PaddleOCR error
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 36s
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
2026-02-07 16:12:07 -06:00
Eric Gullickson
0499c902a8 fix: Crop box broken
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m22s
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
2026-02-07 16:00:23 -06:00
Eric Gullickson
dab4a3bdf3 fix: PaddleOCR error
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m46s
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-07 15:51:04 -06:00
Eric Gullickson
639ca117f1 fix: Update PaddleOCR API
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 5m6s
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-07 14:44:06 -06:00
Eric Gullickson
b9fe222f12 fix: Build errors and tesseract removal
Some checks failed
Deploy to Staging / Build Images (pull_request) Failing after 4m14s
Deploy to Staging / Deploy to Staging (pull_request) Has been skipped
Deploy to Staging / Verify Staging (pull_request) Has been skipped
Deploy to Staging / Notify Staging Ready (pull_request) Has been skipped
Deploy to Staging / Notify Staging Failure (pull_request) Successful in 8s
2026-02-07 12:12:04 -06:00
Eric Gullickson
cf114fad3c fix: build errors for OpenCV
Some checks failed
Deploy to Staging / Build Images (pull_request) Failing after 3m16s
Deploy to Staging / Deploy to Staging (pull_request) Has been skipped
Deploy to Staging / Verify Staging (pull_request) Has been skipped
Deploy to Staging / Notify Staging Ready (pull_request) Has been skipped
Deploy to Staging / Notify Staging Failure (pull_request) Successful in 8s
2026-02-07 11:58:00 -06:00
Eric Gullickson
47c5676498 chore: update OCR tests and documentation (refs #121)
Some checks failed
Deploy to Staging / Build Images (pull_request) Failing after 7m4s
Deploy to Staging / Deploy to Staging (pull_request) Has been skipped
Deploy to Staging / Verify Staging (pull_request) Has been skipped
Deploy to Staging / Notify Staging Ready (pull_request) Has been skipped
Deploy to Staging / Notify Staging Failure (pull_request) Successful in 7s
Add engine abstraction tests and update docs to reflect PaddleOCR primary
architecture with optional Google Vision cloud fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:42:51 -06:00
Eric Gullickson
1e96baca6f fix: workflow contract 2026-02-07 11:32:36 -06:00
Eric Gullickson
3c1a090ae3 fix: resolve crop tool regression with stale ref and aspect ratio minSize (refs #120)
Three bugs fixed in the draw-first crop tool introduced by PR #114:

1. Stale cropAreaRef: replaced useEffect-based ref sync with direct
   synchronous updates in handleMove and handleDrawStart. The useEffect
   ran after browser paint, so handleDragEnd read stale values (often
   {width:0, height:0}), preventing cropDrawn from being set.

2. Aspect ratio minSize: when aspectRatio=6 (VIN mode), height=width/6
   required width>=60% to pass the height>=10% check. Now only checks
   width>=minSize when aspect ratio constrains height.

3. Bounds clamping: aspect-ratio-forced height could push crop area
   past 100% of container. Now clamps y position to keep within bounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:29:16 -06:00
Eric Gullickson
9b6417379b chore: update Docker and compose files for PaddleOCR engine (refs #119)
- Replace libtesseract-dev with libgomp1 (OpenMP for PaddlePaddle)
- Pre-download PP-OCRv4 models during Docker build
- Add OCR engine env vars to all compose files (base, staging, prod)
- Add optional Google Vision secret mount (commented, enable on demand)
- Create google-vision-key.json.example placeholder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:17:44 -06:00
Eric Gullickson
4ef942cb9d feat: add optional Google Vision cloud fallback engine (refs #118)
CloudEngine wraps Google Vision TEXT_DETECTION with lazy init.
HybridEngine runs primary engine, falls back to cloud when confidence
is below threshold. Disabled by default (OCR_FALLBACK_ENGINE=none).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:12:08 -06:00
Eric Gullickson
013fb0c67a feat: migrate VIN/receipt extractors and OCR service to engine abstraction (refs #117)
Replace direct pytesseract calls with OcrEngine interface in vin_extractor.py,
receipt_extractor.py, and ocr_service.py. PSM mode fallbacks replaced with
engine-agnostic single-line/single-word configs. Dead _process_ocr_data removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 10:56:27 -06:00
Eric Gullickson
ebc633fb36 feat: add OCR engine abstraction layer (refs #116)
Introduce pluggable OcrEngine ABC with PaddleOCR PP-OCRv4 as primary
engine and Tesseract wrapper for backward compatibility. Engine factory
reads OCR_PRIMARY_ENGINE config to instantiate the correct engine.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 10:47:40 -06:00
307 changed files with 20936 additions and 3765 deletions

View File

@@ -108,7 +108,7 @@
},
"mvp-ocr": {
"type": "ocr_service",
"description": "Python-based OCR for document text extraction",
"description": "Python OCR service with pluggable engine abstraction (PaddleOCR PP-OCRv4 primary, optional Google Vision cloud fallback, Tesseract backward compat)",
"port": 8000
},
"mvp-loki": {

View File

@@ -45,7 +45,7 @@
"parent_issue": "The original feature issue. Tracks overall status. Only the parent gets status label transitions.",
"sub_issue_title_format": "{type}: {summary} (#{parent_index})",
"sub_issue_body": "First line must be 'Relates to #{parent_index}'. Each sub-issue is a self-contained unit of work.",
"sub_issue_labels": "status/backlog + same type/* as parent. Sub-issues stay in backlog; parent issue tracks status.",
"sub_issue_labels": "status/in-progress + same type/* as parent. Sub-issues move to in-progress as they are worked on.",
"sub_issue_milestone": "Same sprint milestone as parent.",
"rules": [
"ONE branch for the parent issue. Never create branches per sub-issue.",

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

@@ -22,7 +22,7 @@ env:
BASE_COMPOSE_FILE: docker-compose.yml
COMPOSE_BLUE_GREEN: docker-compose.blue-green.yml
COMPOSE_PROD: docker-compose.prod.yml
HEALTH_CHECK_TIMEOUT: "60"
HEALTH_CHECK_TIMEOUT: "240"
LOG_LEVEL: INFO
jobs:
@@ -95,9 +95,11 @@ jobs:
sparse-checkout: |
scripts/
config/
secrets/app/google-wif-config.json
docker-compose.yml
docker-compose.blue-green.yml
docker-compose.prod.yml
.env.example
sparse-checkout-cone-mode: false
fetch-depth: 1
@@ -108,12 +110,26 @@ jobs:
cp "$GITHUB_WORKSPACE/docker-compose.yml" "$DEPLOY_PATH/"
cp "$GITHUB_WORKSPACE/docker-compose.blue-green.yml" "$DEPLOY_PATH/"
cp "$GITHUB_WORKSPACE/docker-compose.prod.yml" "$DEPLOY_PATH/"
# WIF credential config (not a secret -- references Auth0 token script path)
# Remove any Docker-created directory artifact from failed bind mounts
rm -rf "$DEPLOY_PATH/secrets/app/google-wif-config.json"
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: |
@@ -129,6 +145,8 @@ jobs:
AUTH0_CLIENT_SECRET: ${{ secrets.AUTH0_CLIENT_SECRET }}
AUTH0_MANAGEMENT_CLIENT_ID: ${{ secrets.AUTH0_MANAGEMENT_CLIENT_ID }}
AUTH0_MANAGEMENT_CLIENT_SECRET: ${{ secrets.AUTH0_MANAGEMENT_CLIENT_SECRET }}
AUTH0_OCR_CLIENT_ID: ${{ secrets.AUTH0_OCR_CLIENT_ID }}
AUTH0_OCR_CLIENT_SECRET: ${{ secrets.AUTH0_OCR_CLIENT_SECRET }}
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
GOOGLE_MAPS_MAP_ID: ${{ secrets.GOOGLE_MAPS_MAP_ID }}
CF_DNS_API_TOKEN: ${{ secrets.CF_DNS_API_TOKEN }}
@@ -169,10 +187,32 @@ jobs:
run: |
cd "$DEPLOY_PATH"
# Start shared infrastructure services (database, cache, logging)
# These persist across blue-green deployments
docker compose -f $BASE_COMPOSE_FILE -f $COMPOSE_BLUE_GREEN -f $COMPOSE_PROD up -d \
# --no-recreate prevents restarting postgres/redis when config files change
# These must persist across blue-green deployments to avoid data service disruption
docker compose -f $BASE_COMPOSE_FILE -f $COMPOSE_BLUE_GREEN -f $COMPOSE_PROD up -d --no-recreate \
mvp-postgres mvp-redis mvp-loki mvp-alloy mvp-grafana
- name: Wait for shared services health
run: |
echo "Waiting for PostgreSQL and Redis to be healthy..."
for service in mvp-postgres mvp-redis; do
for i in $(seq 1 24); do
health=$(docker inspect --format='{{.State.Health.Status}}' $service 2>/dev/null || echo "unknown")
if [ "$health" = "healthy" ]; then
echo "OK: $service is healthy"
break
fi
if [ $i -eq 24 ]; then
echo "ERROR: $service health check timed out (status: $health)"
docker logs $service --tail 50 2>/dev/null || true
exit 1
fi
echo "Waiting for $service... (attempt $i/24, status: $health)"
sleep 5
done
done
echo "All shared services healthy"
- name: Start target stack
run: |
cd "$DEPLOY_PATH"

View File

@@ -118,12 +118,26 @@ jobs:
rsync -av --delete "$GITHUB_WORKSPACE/scripts/" "$DEPLOY_PATH/scripts/"
cp "$GITHUB_WORKSPACE/docker-compose.yml" "$DEPLOY_PATH/"
cp "$GITHUB_WORKSPACE/docker-compose.staging.yml" "$DEPLOY_PATH/"
# WIF credential config (not a secret -- references Auth0 token script path)
# Remove any Docker-created directory artifact from failed bind mounts
rm -rf "$DEPLOY_PATH/secrets/app/google-wif-config.json"
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: |
@@ -139,6 +153,8 @@ jobs:
AUTH0_CLIENT_SECRET: ${{ secrets.AUTH0_CLIENT_SECRET }}
AUTH0_MANAGEMENT_CLIENT_ID: ${{ secrets.AUTH0_MANAGEMENT_CLIENT_ID }}
AUTH0_MANAGEMENT_CLIENT_SECRET: ${{ secrets.AUTH0_MANAGEMENT_CLIENT_SECRET }}
AUTH0_OCR_CLIENT_ID: ${{ secrets.AUTH0_OCR_CLIENT_ID }}
AUTH0_OCR_CLIENT_SECRET: ${{ secrets.AUTH0_OCR_CLIENT_SECRET }}
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
GOOGLE_MAPS_MAP_ID: ${{ secrets.GOOGLE_MAPS_MAP_ID }}
CF_DNS_API_TOKEN: ${{ secrets.CF_DNS_API_TOKEN }}

1
.gitignore vendored
View File

@@ -22,6 +22,7 @@ secrets/**
!secrets/
!secrets/**/
!secrets/**/*.example
!secrets/app/google-wif-config.json
# Traefik ACME certificates (contains private keys)
data/traefik/acme.json

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

@@ -11,10 +11,10 @@
| Directory | What | When to read |
| --------- | ---- | ------------ |
| `auth/` | Authentication utilities | JWT handling, user context |
| `config/` | Configuration loading (env, database, redis) | Environment setup, connection pools |
| `config/` | Configuration loading (env, database, redis) and feature tier gating (fuelLog.receiptScan, document.scanMaintenanceSchedule, vehicle.vinDecode) | Environment setup, connection pools, tier requirements |
| `logging/` | Winston structured logging | Log configuration, debugging |
| `middleware/` | Fastify middleware | Request processing, user extraction |
| `plugins/` | Fastify plugins (auth, error, logging) | Plugin registration, hooks |
| `plugins/` | Fastify plugins (auth, error, logging, tier guard) | Plugin registration, hooks, tier gating |
| `scheduler/` | Job scheduling infrastructure | Scheduled tasks, cron jobs |
| `storage/` | Storage abstraction and adapters | File storage, S3/filesystem |
| `user-preferences/` | User preferences data and migrations | User settings storage |

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,7 +29,17 @@ 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

@@ -34,6 +34,30 @@ describe('feature-tiers', () => {
expect(feature.name).toBe('Scan for Maintenance Schedule');
expect(feature.upgradePrompt).toBeTruthy();
});
it('includes fuelLog.receiptScan feature', () => {
const feature = FEATURE_TIERS['fuelLog.receiptScan'];
expect(feature).toBeDefined();
expect(feature.minTier).toBe('pro');
expect(feature.name).toBe('Receipt Scan');
expect(feature.upgradePrompt).toBeTruthy();
});
});
describe('canAccessFeature - fuelLog.receiptScan', () => {
const featureKey = 'fuelLog.receiptScan';
it('denies access for free tier user', () => {
expect(canAccessFeature('free', featureKey)).toBe(false);
});
it('allows access for pro tier user', () => {
expect(canAccessFeature('pro', featureKey)).toBe(true);
});
it('allows access for enterprise tier user (inherits pro)', () => {
expect(canAccessFeature('enterprise', featureKey)).toBe(true);
});
});
describe('getTierLevel', () => {

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

@@ -0,0 +1,191 @@
import { FastifyRequest, FastifyReply } from 'fastify';
import { requireTier } from './require-tier';
// Mock logger to suppress output during tests
jest.mock('../logging/logger', () => ({
logger: {
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
},
}));
const createRequest = (subscriptionTier?: string): Partial<FastifyRequest> => {
if (subscriptionTier === undefined) {
return { userContext: undefined };
}
return {
userContext: {
userId: '550e8400-e29b-41d4-a716-446655440000',
email: 'user@example.com',
emailVerified: true,
onboardingCompleted: true,
isAdmin: false,
subscriptionTier: subscriptionTier as any,
},
};
};
const createReply = (): Partial<FastifyReply> & { statusCode?: number; payload?: unknown } => {
const reply: any = {
sent: false,
code: jest.fn(function (this: any, status: number) {
this.statusCode = status;
return this;
}),
send: jest.fn(function (this: any, payload: unknown) {
this.payload = payload;
this.sent = true;
return this;
}),
};
return reply;
};
describe('requireTier middleware', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('pro user passes fuelLog.receiptScan check', () => {
it('allows pro user through without sending a response', async () => {
const handler = requireTier('fuelLog.receiptScan');
const request = createRequest('pro');
const reply = createReply();
await handler(request as FastifyRequest, reply as FastifyReply);
expect(reply.code).not.toHaveBeenCalled();
expect(reply.send).not.toHaveBeenCalled();
});
});
describe('enterprise user passes all checks (tier inheritance)', () => {
it('allows enterprise user access to pro-gated features', async () => {
const handler = requireTier('fuelLog.receiptScan');
const request = createRequest('enterprise');
const reply = createReply();
await handler(request as FastifyRequest, reply as FastifyReply);
expect(reply.code).not.toHaveBeenCalled();
expect(reply.send).not.toHaveBeenCalled();
});
it('allows enterprise user access to document.scanMaintenanceSchedule', async () => {
const handler = requireTier('document.scanMaintenanceSchedule');
const request = createRequest('enterprise');
const reply = createReply();
await handler(request as FastifyRequest, reply as FastifyReply);
expect(reply.code).not.toHaveBeenCalled();
expect(reply.send).not.toHaveBeenCalled();
});
it('allows enterprise user access to vehicle.vinDecode', async () => {
const handler = requireTier('vehicle.vinDecode');
const request = createRequest('enterprise');
const reply = createReply();
await handler(request as FastifyRequest, reply as FastifyReply);
expect(reply.code).not.toHaveBeenCalled();
expect(reply.send).not.toHaveBeenCalled();
});
});
describe('free user blocked with 403 and correct response body', () => {
it('blocks free user from fuelLog.receiptScan', async () => {
const handler = requireTier('fuelLog.receiptScan');
const request = createRequest('free');
const reply = createReply();
await handler(request as FastifyRequest, reply as FastifyReply);
expect(reply.code).toHaveBeenCalledWith(403);
expect(reply.send).toHaveBeenCalledWith(
expect.objectContaining({
error: 'TIER_REQUIRED',
requiredTier: 'pro',
currentTier: 'free',
featureName: 'Receipt Scan',
upgradePrompt: expect.any(String),
}),
);
});
it('blocks free user from document.scanMaintenanceSchedule', async () => {
const handler = requireTier('document.scanMaintenanceSchedule');
const request = createRequest('free');
const reply = createReply();
await handler(request as FastifyRequest, reply as FastifyReply);
expect(reply.code).toHaveBeenCalledWith(403);
expect(reply.send).toHaveBeenCalledWith(
expect.objectContaining({
error: 'TIER_REQUIRED',
requiredTier: 'pro',
currentTier: 'free',
featureName: 'Scan for Maintenance Schedule',
upgradePrompt: expect.any(String),
}),
);
});
it('response body includes all required fields', async () => {
const handler = requireTier('fuelLog.receiptScan');
const request = createRequest('free');
const reply = createReply();
await handler(request as FastifyRequest, reply as FastifyReply);
const body = (reply.send as jest.Mock).mock.calls[0][0];
expect(body).toHaveProperty('requiredTier', 'pro');
expect(body).toHaveProperty('currentTier', 'free');
expect(body).toHaveProperty('featureName', 'Receipt Scan');
expect(body).toHaveProperty('upgradePrompt');
expect(typeof body.upgradePrompt).toBe('string');
expect(body.upgradePrompt.length).toBeGreaterThan(0);
});
});
describe('unknown feature key returns 500', () => {
it('returns 500 INTERNAL_ERROR for unregistered feature', async () => {
const handler = requireTier('unknown.nonexistent.feature');
const request = createRequest('pro');
const reply = createReply();
await handler(request as FastifyRequest, reply as FastifyReply);
expect(reply.code).toHaveBeenCalledWith(500);
expect(reply.send).toHaveBeenCalledWith(
expect.objectContaining({
error: 'INTERNAL_ERROR',
message: 'Unknown feature configuration',
}),
);
});
});
describe('missing user.tier on request returns 403', () => {
it('defaults to free tier when userContext is undefined', async () => {
const handler = requireTier('fuelLog.receiptScan');
const request = createRequest(); // no tier = undefined userContext
const reply = createReply();
await handler(request as FastifyRequest, reply as FastifyReply);
expect(reply.code).toHaveBeenCalledWith(403);
expect(reply.send).toHaveBeenCalledWith(
expect.objectContaining({
error: 'TIER_REQUIRED',
currentTier: 'free',
requiredTier: 'pro',
}),
);
});
});
});

View File

@@ -0,0 +1,64 @@
/**
* @ai-summary Standalone tier guard middleware for route-level feature gating
* @ai-context Returns a Fastify preHandler that checks user subscription tier against feature requirements.
* Must be composed AFTER requireAuth in preHandler arrays.
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import { canAccessFeature, getFeatureConfig } from '../config/feature-tiers';
import { logger } from '../logging/logger';
/**
* Creates a preHandler middleware that enforces subscription tier requirements.
*
* Reads the user's tier from request.userContext.subscriptionTier (set by auth middleware).
* Must be placed AFTER requireAuth in the preHandler chain.
*
* Usage:
* fastify.post('/premium-route', {
* preHandler: [requireAuth, requireTier('fuelLog.receiptScan')],
* handler: controller.method
* });
*
* @param featureKey - Key from FEATURE_TIERS registry (e.g. 'fuelLog.receiptScan')
* @returns Fastify preHandler function
*/
export function requireTier(featureKey: string) {
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
// Validate feature key exists in registry
const featureConfig = getFeatureConfig(featureKey);
if (!featureConfig) {
logger.error('requireTier: unknown feature key', { featureKey });
return reply.code(500).send({
error: 'INTERNAL_ERROR',
message: 'Unknown feature configuration',
});
}
// Get user tier from userContext (populated by auth middleware)
const currentTier = request.userContext?.subscriptionTier || 'free';
if (!canAccessFeature(currentTier, featureKey)) {
logger.warn('requireTier: access denied', {
userId: request.userContext?.userId?.substring(0, 8) + '...',
currentTier,
requiredTier: featureConfig.minTier,
featureKey,
});
return reply.code(403).send({
error: 'TIER_REQUIRED',
requiredTier: featureConfig.minTier,
currentTier,
featureName: featureConfig.name,
upgradePrompt: featureConfig.upgradePrompt,
});
}
logger.debug('requireTier: access granted', {
userId: request.userContext?.userId?.substring(0, 8) + '...',
currentTier,
featureKey,
});
};
}

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

@@ -12,7 +12,7 @@
| `fuel-logs/` | Fuel consumption tracking | Fuel log CRUD, statistics |
| `maintenance/` | Maintenance record management | Service records, reminders |
| `notifications/` | Email and push notifications | Alert system, email templates |
| `ocr/` | OCR proxy to mvp-ocr service | Image text extraction, async jobs |
| `ocr/` | OCR proxy to mvp-ocr service (VIN, receipt, manual extraction) | Image text extraction, receipt scanning, manual PDF extraction, async jobs |
| `onboarding/` | User onboarding flow | First-time user setup |
| `ownership-costs/` | Ownership cost tracking and reports | Cost aggregation, expense analysis |
| `platform/` | Vehicle data and VIN decoding | Make/model lookup, VIN validation |

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

@@ -1,16 +1,47 @@
# ocr/
Backend proxy for the Python OCR microservice. Handles authentication, tier gating, file validation, and request forwarding for VIN extraction, fuel receipt scanning, and maintenance manual extraction.
## Files
| File | What | When to read |
| ---- | ---- | ------------ |
| `README.md` | Feature documentation | Understanding OCR proxy |
| `README.md` | Feature documentation with architecture diagrams | Understanding OCR proxy, data flows |
| `index.ts` | Feature barrel export | Importing OCR services |
## Subdirectories
| Directory | What | When to read |
| --------- | ---- | ------------ |
| `api/` | HTTP endpoints and routes | API changes |
| `domain/` | Business logic, types | Core OCR proxy logic |
| `external/` | External OCR service client | OCR service integration |
| `api/` | HTTP endpoints, routes, request validation | API changes, adding endpoints |
| `domain/` | Business logic, TypeScript types | Core OCR proxy logic, type definitions |
| `external/` | HTTP client to Python OCR service | OCR service integration, error handling |
| `tests/` | Unit tests for receipt and manual extraction | Test changes, adding test coverage |
## api/
| File | What | When to read |
| ---- | ---- | ------------ |
| `ocr.controller.ts` | Request handlers for all OCR endpoints (extract, extractVin, extractReceipt, extractManual, submitJob, getJobStatus) | Adding/modifying endpoint behavior |
| `ocr.routes.ts` | Fastify route registration with auth and tier guard preHandlers | Route configuration, middleware changes |
| `ocr.validation.ts` | Request/response type definitions for route schemas | Changing request/response shapes |
## domain/
| File | What | When to read |
| ---- | ---- | ------------ |
| `ocr.service.ts` | Business logic layer: file validation, size limits (10MB sync, 200MB async), content type checks, service delegation | Core logic changes, validation rules |
| `ocr.types.ts` | TypeScript types: OcrResponse, VinExtractionResponse, ReceiptExtractionResponse, ManualExtractionResult, JobResponse, ManualJobResponse | Type changes, adding new response shapes |
## external/
| File | What | When to read |
| ---- | ---- | ------------ |
| `ocr-client.ts` | HTTP client to mvp-ocr Python service (extract, extractVin, extractReceipt, decodeVin, submitJob, submitManualJob, getJobStatus, isHealthy) | OCR service communication, error handling |
## tests/
| File | What | When to read |
| ---- | ---- | ------------ |
| `unit/ocr-receipt.test.ts` | Receipt extraction tests with mock client | Receipt flow changes |
| `unit/ocr-manual.test.ts` | Manual PDF extraction tests | Manual extraction flow changes |

View File

@@ -1,54 +1,180 @@
# OCR Feature
Backend proxy for OCR service communication. Handles authentication, validation, and file streaming to the OCR container.
Backend proxy for the Python OCR microservice. Handles authentication, tier gating, file validation, and request forwarding for three extraction types: VIN decoding, fuel receipt scanning, and maintenance manual extraction.
## API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/ocr/extract` | Synchronous OCR extraction (max 10MB) |
| POST | `/api/ocr/jobs` | Submit async OCR job (max 200MB) |
| GET | `/api/ocr/jobs/:jobId` | Poll async job status |
| Method | Endpoint | Description | Auth | Tier | Max Size |
|--------|----------|-------------|------|------|----------|
| POST | `/api/ocr/extract` | Synchronous general OCR extraction | Required | - | 10MB |
| POST | `/api/ocr/extract/vin` | VIN-specific extraction | Required | - | 10MB |
| POST | `/api/ocr/extract/receipt` | Fuel receipt extraction | Required | Pro | 10MB |
| POST | `/api/ocr/extract/manual` | Async maintenance manual extraction | Required | Pro | 200MB |
| POST | `/api/ocr/jobs` | Submit async OCR job | Required | - | 200MB |
| GET | `/api/ocr/jobs/:jobId` | Poll async job status | Required | - | - |
## Architecture
```
api/
ocr.controller.ts # Request handlers
ocr.routes.ts # Route registration
ocr.validation.ts # Request validation types
domain/
ocr.service.ts # Business logic
ocr.types.ts # TypeScript types
external/
ocr-client.ts # HTTP client to OCR service
Frontend
|
v
Backend Proxy (this feature)
|
+-- ocr.routes.ts --------> Route registration (auth + tier preHandlers)
|
+-- ocr.controller.ts ----> Request handlers (file validation, size checks)
|
+-- ocr.service.ts -------> Business logic (content type validation, delegation)
|
+-- ocr-client.ts --------> HTTP client to mvp-ocr:8000
|
v
Python OCR Service
```
## Receipt OCR Flow
```
Mobile Camera / File Upload
|
v
POST /api/ocr/extract/receipt (multipart/form-data)
|
v
OcrController.extractReceipt()
- Validates file size (<= 10MB)
- Validates content type (JPEG, PNG, HEIC)
|
v
OcrService.extractReceipt()
|
v
OcrClient.extractReceipt() --> HTTP POST --> Python /extract/receipt
| |
v v
ReceiptExtractionResponse ReceiptExtractor + HybridEngine
| (Vision API / PaddleOCR fallback)
v
Frontend receives extractedFields:
merchantName, transactionDate, totalAmount,
fuelQuantity, pricePerUnit, fuelGrade
```
After receipt extraction, the frontend calls `POST /api/stations/match` with the `merchantName` to auto-match a gas station via Google Places API. The station match is a separate request handled by the stations feature.
## Manual Extraction Flow
```
PDF Upload + "Scan for Maintenance Schedule"
|
v
POST /api/ocr/extract/manual (multipart/form-data)
- Requires Pro tier (document.scanMaintenanceSchedule)
- Validates file size (<= 200MB)
- Validates content type (application/pdf)
- Validates PDF magic bytes (%PDF header)
|
v
OcrService.submitManualJob()
|
v
OcrClient.submitManualJob() --> HTTP POST --> Python /extract/manual
| |
v v
{ jobId, status: 'pending' } GeminiEngine (Vertex AI)
Gemini 2.5 Flash
Frontend polls: (structured JSON output)
GET /api/ocr/jobs/:jobId |
(progress: 10% -> 50% -> 95% -> 100%) v
| ManualExtractionResult
v { vehicleInfo, maintenanceSchedules[] }
ManualJobResponse with result
|
v
Frontend displays MaintenanceScheduleReviewScreen
- User selects/edits items
- Batch creates maintenance schedules
```
Jobs expire after 2 hours (Redis TTL). Expired job polling returns HTTP 410 Gone.
## Supported File Types
### Sync Endpoints (extract, extractVin, extractReceipt)
- HEIC (converted server-side)
- JPEG
- PNG
- PDF (first page only)
## Response Format
### Async Endpoints (extractManual)
- PDF (validated via magic bytes)
## Response Types
### ReceiptExtractionResponse
```typescript
interface OcrResponse {
{
success: boolean;
documentType: 'vin' | 'receipt' | 'manual' | 'unknown';
receiptType: string;
extractedFields: {
merchantName: { value: string; confidence: number };
transactionDate: { value: string; confidence: number };
totalAmount: { value: string; confidence: number };
fuelQuantity: { value: string; confidence: number };
pricePerUnit: { value: string; confidence: number };
fuelGrade: { value: string; confidence: number };
};
rawText: string;
confidence: number; // 0.0 - 1.0
extractedFields: Record<string, { value: string; confidence: number }>;
processingTimeMs: number;
}
```
## Async Job Flow
### ManualJobResponse
```typescript
{
jobId: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
progress?: { percent: number; message: string };
estimatedSeconds?: number;
result?: ManualExtractionResult;
error?: string;
}
```
1. POST `/api/ocr/jobs` with file
2. Receive `{ jobId, status: 'pending' }`
3. Poll GET `/api/ocr/jobs/:jobId`
4. When `status: 'completed'`, result contains OCR data
### ManualExtractionResult
```typescript
{
success: boolean;
vehicleInfo?: { make: string; model: string; year: number };
maintenanceSchedules: Array<{
serviceName: string;
intervalMiles: number | null;
intervalMonths: number | null;
details: string;
confidence: number;
subtypes: string[];
}>;
rawTables: any[];
processingTimeMs: number;
totalPages: number;
pagesProcessed: number;
}
```
Jobs expire after 1 hour.
## Error Handling
The backend proxy translates Python service error codes:
| Python Status | Backend Status | Meaning |
|---------------|----------------|---------|
| 413 | 413 | File too large |
| 415 | 415 | Unsupported media type |
| 422 | 422 | Extraction failed |
| 410 | 410 | Job expired (TTL) |
| Other | 500 | Internal server error |
## Tier Gating
Manual extraction requires Pro tier. The tier guard middleware (`requireTier` plugin) validates the user's subscription tier before processing. Free-tier users receive HTTP 403 with `TIER_REQUIRED` error code and an upgrade prompt.
VIN extraction is available to all tiers. Receipt extraction requires Pro tier (`fuelLog.receiptScan`).

View File

@@ -15,6 +15,15 @@ const SUPPORTED_TYPES = new Set([
'application/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 {
/**
* POST /api/ocr/extract
@@ -24,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', {
@@ -131,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',
@@ -223,6 +232,350 @@ export class OcrController {
}
}
/**
* POST /api/ocr/extract/receipt
* Extract data from a receipt image using receipt-specific OCR.
*/
async extractReceipt(
request: FastifyRequest,
reply: FastifyReply
) {
const userId = request.userContext?.userId as string;
logger.info('Receipt extract requested', {
operation: 'ocr.controller.extractReceipt',
userId,
});
const file = await (request as any).file({ limits: { files: 1 } });
if (!file) {
logger.warn('No file provided for receipt extraction', {
operation: 'ocr.controller.extractReceipt.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 receipt extraction', {
operation: 'ocr.controller.extractReceipt.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 receipt extraction', {
operation: 'ocr.controller.extractReceipt.empty_file',
userId,
fileName: file.filename,
});
return reply.code(400).send({
error: 'Bad Request',
message: 'Empty file provided',
});
}
// Get optional receipt_type from form fields
const receiptType = file.fields?.receipt_type?.value as string | undefined;
try {
const result = await ocrService.extractReceipt(userId, {
fileBuffer,
contentType,
receiptType,
});
logger.info('Receipt extract completed', {
operation: 'ocr.controller.extractReceipt.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('Receipt extract failed', {
operation: 'ocr.controller.extractReceipt.error',
userId,
error: error.message,
});
return reply.code(500).send({
error: 'Internal Server Error',
message: 'Receipt extraction failed',
});
}
}
/**
* 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.
* Requires Pro tier (document.scanMaintenanceSchedule).
*/
async extractManual(
request: FastifyRequest,
reply: FastifyReply
) {
const userId = request.userContext?.userId as string;
logger.info('Manual extract requested', {
operation: 'ocr.controller.extractManual',
userId,
});
const file = await (request as any).file({ limits: { files: 1 } });
if (!file) {
logger.warn('No file provided for manual extraction', {
operation: 'ocr.controller.extractManual.no_file',
userId,
});
return reply.code(400).send({
error: 'Bad Request',
message: 'No file provided',
});
}
const contentType = file.mimetype as string;
const fileName = file.filename as string | undefined;
const isPdfMime = contentType === 'application/pdf';
const isPdfExtension = fileName?.toLowerCase().endsWith('.pdf') ?? false;
if (!isPdfMime && !isPdfExtension) {
logger.warn('Non-PDF file provided for manual extraction', {
operation: 'ocr.controller.extractManual.not_pdf',
userId,
contentType,
fileName,
});
return reply.code(400).send({
error: 'Bad Request',
message: `Manual extraction requires PDF files. Received: ${contentType}`,
});
}
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 manual extraction', {
operation: 'ocr.controller.extractManual.empty_file',
userId,
fileName,
});
return reply.code(400).send({
error: 'Bad Request',
message: 'Empty file provided',
});
}
// Validate PDF magic bytes (%PDF)
const PDF_MAGIC = Buffer.from('%PDF');
if (fileBuffer.length < 4 || !fileBuffer.subarray(0, 4).equals(PDF_MAGIC)) {
logger.warn('File lacks PDF magic bytes', {
operation: 'ocr.controller.extractManual.invalid_magic',
userId,
fileName,
firstBytes: fileBuffer.subarray(0, 4).toString('hex'),
});
return reply.code(415).send({
error: 'Unsupported Media Type',
message: 'File does not appear to be a valid PDF (missing %PDF header)',
});
}
// Get optional vehicle_id from form fields
const vehicleId = file.fields?.vehicle_id?.value as string | undefined;
try {
const result = await ocrService.submitManualJob(userId, {
fileBuffer,
contentType,
vehicleId,
});
logger.info('Manual extract job submitted', {
operation: 'ocr.controller.extractManual.success',
userId,
jobId: result.jobId,
status: result.status,
estimatedSeconds: result.estimatedSeconds,
});
return reply.code(202).send(result);
} catch (error: any) {
if (error.statusCode === 413) {
return reply.code(413).send({
error: 'Payload Too Large',
message: error.message,
});
}
if (error.statusCode === 400) {
return reply.code(400).send({
error: 'Bad Request',
message: error.message,
});
}
logger.error('Manual extract failed', {
operation: 'ocr.controller.extractManual.error',
userId,
error: error.message,
});
return reply.code(500).send({
error: 'Internal Server Error',
message: 'Manual extraction submission failed',
});
}
}
/**
* POST /api/ocr/jobs
* Submit an async OCR job for large files.
@@ -231,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',
@@ -338,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', {
@@ -352,9 +705,9 @@ export class OcrController {
return reply.code(200).send(result);
} catch (error: any) {
if (error.statusCode === 404) {
return reply.code(404).send({
error: 'Not Found',
if (error.statusCode === 410) {
return reply.code(410).send({
error: 'Gone',
message: error.message,
});
}

View File

@@ -2,6 +2,7 @@
* @ai-summary Fastify routes for OCR API
*/
import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify';
import { requireTier } from '../../../core/middleware/require-tier';
import { OcrController } from './ocr.controller';
export const ocrRoutes: FastifyPluginAsync = async (
@@ -23,6 +24,24 @@ export const ocrRoutes: FastifyPluginAsync = async (
handler: ctrl.extractVin.bind(ctrl),
});
// POST /api/ocr/extract/receipt - Receipt-specific OCR extraction (Pro tier required)
fastify.post('/ocr/extract/receipt', {
preHandler: [requireAuth, requireTier('fuelLog.receiptScan')],
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' })],
handler: ctrl.extractManual.bind(ctrl),
});
// POST /api/ocr/jobs - Submit async OCR job
fastify.post('/ocr/jobs', {
preHandler: [requireAuth],

View File

@@ -5,9 +5,14 @@ import { logger } from '../../../core/logging/logger';
import { ocrClient, JobNotFoundError } from '../external/ocr-client';
import type {
JobResponse,
MaintenanceReceiptExtractRequest,
ManualJobResponse,
ManualJobSubmitRequest,
OcrExtractRequest,
OcrJobSubmitRequest,
OcrResponse,
ReceiptExtractRequest,
ReceiptExtractionResponse,
VinExtractionResponse,
} from './ocr.types';
@@ -26,6 +31,15 @@ const SUPPORTED_TYPES = new Set([
'application/pdf',
]);
/** MIME types for receipt extraction */
const SUPPORTED_IMAGE_TYPES = new Set([
'image/jpeg',
'image/png',
'image/heic',
'image/heif',
'application/pdf',
]);
/**
* Domain service for OCR operations.
* Handles business logic and validation for OCR requests.
@@ -150,6 +164,122 @@ export class OcrService {
}
}
/**
* Extract data from a receipt image using receipt-specific OCR.
*
* @param userId - User ID for logging
* @param request - Receipt extraction request
* @returns Receipt extraction result
*/
async extractReceipt(userId: string, request: ReceiptExtractRequest): 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('Receipt extract requested', {
operation: 'ocr.service.extractReceipt',
userId,
contentType: request.contentType,
fileSize: request.fileBuffer.length,
receiptType: request.receiptType,
});
try {
const result = await ocrClient.extractReceipt(
request.fileBuffer,
request.contentType,
request.receiptType
);
logger.info('Receipt extract completed', {
operation: 'ocr.service.extractReceipt.success',
userId,
success: result.success,
receiptType: result.receiptType,
fieldCount: Object.keys(result.extractedFields).length,
processingTimeMs: result.processingTimeMs,
});
return result;
} catch (error) {
logger.error('Receipt extract failed', {
operation: 'ocr.service.extractReceipt.error',
userId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
/**
* 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.
*
@@ -209,6 +339,66 @@ export class OcrService {
}
}
/**
* Submit an async manual extraction job for PDF owner's manuals.
*
* @param userId - User ID for logging
* @param request - Manual job submission request
* @returns Manual job response with job ID
*/
async submitManualJob(userId: string, request: ManualJobSubmitRequest): Promise<ManualJobResponse> {
// Validate file size for async processing (200MB max)
if (request.fileBuffer.length > MAX_ASYNC_SIZE) {
const err: any = new Error(
`File too large. Max: ${MAX_ASYNC_SIZE / (1024 * 1024)}MB.`
);
err.statusCode = 413;
throw err;
}
// Manual extraction only supports PDF
if (request.contentType !== 'application/pdf') {
const err: any = new Error(
`Unsupported file type: ${request.contentType}. Manual extraction requires PDF files.`
);
err.statusCode = 400;
throw err;
}
logger.info('Manual job submit requested', {
operation: 'ocr.service.submitManualJob',
userId,
contentType: request.contentType,
fileSize: request.fileBuffer.length,
hasVehicleId: !!request.vehicleId,
});
try {
const result = await ocrClient.submitManualJob(
request.fileBuffer,
request.contentType,
request.vehicleId
);
logger.info('Manual job submitted', {
operation: 'ocr.service.submitManualJob.success',
userId,
jobId: result.jobId,
status: result.status,
estimatedSeconds: result.estimatedSeconds,
});
return result;
} catch (error) {
logger.error('Manual job submit failed', {
operation: 'ocr.service.submitManualJob.error',
userId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
/**
* Get the status of an async OCR job.
*
@@ -237,8 +427,8 @@ export class OcrService {
return result;
} catch (error) {
if (error instanceof JobNotFoundError) {
const err: any = new Error(`Job ${jobId} not found. Jobs expire after 1 hour.`);
err.statusCode = 404;
const err: any = new Error('Job expired (max 2 hours). Please resubmit.');
err.statusCode = 410;
throw err;
}

View File

@@ -45,6 +45,29 @@ export interface OcrExtractRequest {
preprocess?: boolean;
}
/** Response from receipt-specific extraction */
export interface ReceiptExtractionResponse {
success: boolean;
receiptType: string;
extractedFields: Record<string, ExtractedField>;
rawText: string;
processingTimeMs: number;
error: string | null;
}
/** Request for receipt extraction */
export interface ReceiptExtractRequest {
fileBuffer: Buffer;
contentType: string;
receiptType?: string;
}
/** Request for maintenance receipt extraction */
export interface MaintenanceReceiptExtractRequest {
fileBuffer: Buffer;
contentType: string;
}
/** Response from VIN-specific extraction */
export interface VinExtractionResponse {
success: boolean;
@@ -62,3 +85,67 @@ export interface OcrJobSubmitRequest {
contentType: string;
callbackUrl?: string;
}
/** Request to submit a manual extraction job */
export interface ManualJobSubmitRequest {
fileBuffer: Buffer;
contentType: string;
vehicleId?: string;
}
/** Vehicle info extracted from a manual */
export interface ManualVehicleInfo {
make: string | null;
model: string | null;
year: number | null;
}
/** A single maintenance schedule item extracted from a manual */
export interface MaintenanceScheduleItem {
service: string;
intervalMiles: number | null;
intervalMonths: number | null;
details: string | null;
confidence: number;
subtypes: string[];
}
/** Result of manual extraction (nested in ManualJobResponse.result) */
export interface ManualExtractionResult {
success: boolean;
vehicleInfo: ManualVehicleInfo;
maintenanceSchedules: MaintenanceScheduleItem[];
rawTables: unknown[];
processingTimeMs: number;
totalPages: number;
pagesProcessed: number;
error: string | null;
}
/** Response for async manual extraction job */
export interface ManualJobResponse {
jobId: string;
status: JobStatus;
progress?: number;
estimatedSeconds?: number;
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,11 +2,11 @@
* @ai-summary HTTP client for OCR service communication
*/
import { logger } from '../../../core/logging/logger';
import type { JobResponse, OcrResponse, 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';
const OCR_TIMEOUT_MS = 30000; // 30 seconds for sync operations
const OCR_TIMEOUT_MS = 120000; // 120 seconds for sync operations (PaddleOCR model loading on first call)
/**
* HTTP client for communicating with the OCR service.
@@ -119,6 +119,115 @@ export class OcrClient {
return result;
}
/**
* Extract data from a receipt image using receipt-specific OCR.
*
* @param fileBuffer - Image file buffer
* @param contentType - MIME type of the file
* @param receiptType - Optional receipt type hint (e.g., 'fuel')
* @returns Receipt extraction result
*/
async extractReceipt(
fileBuffer: Buffer,
contentType: string,
receiptType?: string
): Promise<ReceiptExtractionResponse> {
const formData = this.buildFormData(fileBuffer, contentType);
if (receiptType) {
formData.append('receipt_type', receiptType);
}
const url = `${this.baseUrl}/extract/receipt`;
logger.info('OCR receipt extract request', {
operation: 'ocr.client.extractReceipt',
url,
contentType,
fileSize: fileBuffer.length,
receiptType,
});
const response = await this.fetchWithTimeout(url, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorText = await response.text();
logger.error('OCR receipt extract failed', {
operation: 'ocr.client.extractReceipt.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 receipt extract completed', {
operation: 'ocr.client.extractReceipt.success',
success: result.success,
receiptType: result.receiptType,
fieldCount: Object.keys(result.extractedFields).length,
processingTimeMs: result.processingTimeMs,
});
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.
*
@@ -209,6 +318,110 @@ export class OcrClient {
return (await response.json()) as JobResponse;
}
/**
* Submit an async manual extraction job for PDF owner's manuals.
*
* @param fileBuffer - PDF file buffer
* @param contentType - MIME type of the file (must be application/pdf)
* @param vehicleId - Optional vehicle ID for context
* @returns Manual job submission response
*/
async submitManualJob(
fileBuffer: Buffer,
contentType: string,
vehicleId?: string
): Promise<ManualJobResponse> {
const formData = this.buildFormData(fileBuffer, contentType);
if (vehicleId) {
formData.append('vehicle_id', vehicleId);
}
const url = `${this.baseUrl}/extract/manual`;
logger.info('OCR manual job submit request', {
operation: 'ocr.client.submitManualJob',
url,
contentType,
fileSize: fileBuffer.length,
hasVehicleId: !!vehicleId,
});
const response = await this.fetchWithTimeout(url, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorText = await response.text();
logger.error('OCR manual job submit failed', {
operation: 'ocr.client.submitManualJob.error',
status: response.status,
error: errorText,
});
throw new Error(`OCR service error: ${response.status} - ${errorText}`);
}
const result = (await response.json()) as ManualJobResponse;
logger.info('OCR manual job submitted', {
operation: 'ocr.client.submitManualJob.success',
jobId: result.jobId,
status: result.status,
estimatedSeconds: result.estimatedSeconds,
});
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

@@ -8,4 +8,5 @@ export type {
JobResponse,
JobStatus,
OcrResponse,
ReceiptExtractionResponse,
} from './domain/ocr.types';

View File

@@ -0,0 +1,295 @@
/**
* @ai-summary Unit tests for OCR manual extraction endpoint
*/
import { OcrService } from '../../domain/ocr.service';
import { ocrClient, JobNotFoundError } from '../../external/ocr-client';
import type { ManualJobResponse } from '../../domain/ocr.types';
jest.mock('../../external/ocr-client');
jest.mock('../../../../core/logging/logger');
const mockSubmitManualJob = ocrClient.submitManualJob as jest.MockedFunction<
typeof ocrClient.submitManualJob
>;
const mockGetJobStatus = ocrClient.getJobStatus as jest.MockedFunction<
typeof ocrClient.getJobStatus
>;
describe('OcrService.submitManualJob', () => {
let service: OcrService;
const userId = 'test-user-id';
const mockManualJobResponse: ManualJobResponse = {
jobId: 'manual-job-123',
status: 'pending',
progress: 0,
estimatedSeconds: 45,
result: undefined,
error: undefined,
};
const mockCompletedJobResponse: ManualJobResponse = {
jobId: 'manual-job-123',
status: 'completed',
progress: 100,
result: {
success: true,
vehicleInfo: {
make: 'Honda',
model: 'Civic',
year: 2023,
},
maintenanceSchedules: [
{
service: 'Engine Oil Change',
intervalMiles: 5000,
intervalMonths: 6,
details: 'Use 0W-20 full synthetic oil',
confidence: 0.95,
subtypes: ['oil_change'],
},
{
service: 'Tire Rotation',
intervalMiles: 7500,
intervalMonths: 6,
details: null,
confidence: 0.90,
subtypes: ['tire_rotation'],
},
],
rawTables: [],
processingTimeMs: 45000,
totalPages: 120,
pagesProcessed: 120,
error: null,
},
error: undefined,
};
beforeEach(() => {
jest.clearAllMocks();
service = new OcrService();
});
describe('valid manual job submission', () => {
it('should return 202-style response with jobId for PDF submission', async () => {
mockSubmitManualJob.mockResolvedValue(mockManualJobResponse);
const result = await service.submitManualJob(userId, {
fileBuffer: Buffer.from('fake-pdf-data'),
contentType: 'application/pdf',
});
expect(result.jobId).toBe('manual-job-123');
expect(result.status).toBe('pending');
expect(result.progress).toBe(0);
expect(result.estimatedSeconds).toBe(45);
expect(result.result).toBeUndefined();
});
it('should pass vehicleId to client when provided', async () => {
mockSubmitManualJob.mockResolvedValue(mockManualJobResponse);
await service.submitManualJob(userId, {
fileBuffer: Buffer.from('fake-pdf-data'),
contentType: 'application/pdf',
vehicleId: 'vehicle-abc',
});
expect(mockSubmitManualJob).toHaveBeenCalledWith(
expect.any(Buffer),
'application/pdf',
'vehicle-abc'
);
});
it('should call client without vehicleId when not provided', async () => {
mockSubmitManualJob.mockResolvedValue(mockManualJobResponse);
await service.submitManualJob(userId, {
fileBuffer: Buffer.from('fake-pdf-data'),
contentType: 'application/pdf',
});
expect(mockSubmitManualJob).toHaveBeenCalledWith(
expect.any(Buffer),
'application/pdf',
undefined
);
});
});
describe('completed job result', () => {
it('should return completed result with maintenanceSchedules', async () => {
mockSubmitManualJob.mockResolvedValue(mockCompletedJobResponse);
const result = await service.submitManualJob(userId, {
fileBuffer: Buffer.from('fake-pdf-data'),
contentType: 'application/pdf',
});
expect(result.status).toBe('completed');
expect(result.result).toBeDefined();
expect(result.result!.success).toBe(true);
expect(result.result!.maintenanceSchedules).toHaveLength(2);
expect(result.result!.maintenanceSchedules[0].service).toBe('Engine Oil Change');
expect(result.result!.maintenanceSchedules[0].intervalMiles).toBe(5000);
expect(result.result!.maintenanceSchedules[0].subtypes).toEqual(['oil_change']);
expect(result.result!.vehicleInfo.make).toBe('Honda');
});
});
describe('error handling', () => {
it('should throw 400 for non-PDF file (JPEG)', async () => {
await expect(
service.submitManualJob(userId, {
fileBuffer: Buffer.from('fake-image-data'),
contentType: 'image/jpeg',
})
).rejects.toMatchObject({
statusCode: 400,
});
});
it('should throw 400 for non-PDF file (PNG)', async () => {
await expect(
service.submitManualJob(userId, {
fileBuffer: Buffer.from('fake-image-data'),
contentType: 'image/png',
})
).rejects.toMatchObject({
statusCode: 400,
});
});
it('should throw 400 for text/plain', async () => {
await expect(
service.submitManualJob(userId, {
fileBuffer: Buffer.from('not a pdf'),
contentType: 'text/plain',
})
).rejects.toMatchObject({
statusCode: 400,
});
});
it('should throw 413 for oversized file', async () => {
const largeBuffer = Buffer.alloc(201 * 1024 * 1024); // 201MB
await expect(
service.submitManualJob(userId, {
fileBuffer: largeBuffer,
contentType: 'application/pdf',
})
).rejects.toMatchObject({
statusCode: 413,
});
});
it('should accept file at 200MB boundary', async () => {
mockSubmitManualJob.mockResolvedValue(mockManualJobResponse);
const exactBuffer = Buffer.alloc(200 * 1024 * 1024); // exactly 200MB
const result = await service.submitManualJob(userId, {
fileBuffer: exactBuffer,
contentType: 'application/pdf',
});
expect(result.jobId).toBe('manual-job-123');
});
it('should propagate OCR service errors', async () => {
mockSubmitManualJob.mockRejectedValue(
new Error('OCR service error: 500 - Internal error')
);
await expect(
service.submitManualJob(userId, {
fileBuffer: Buffer.from('fake-pdf-data'),
contentType: 'application/pdf',
})
).rejects.toThrow('OCR service error: 500 - Internal error');
});
});
});
describe('OcrService.getJobStatus (manual job polling)', () => {
let service: OcrService;
const userId = 'test-user-id';
beforeEach(() => {
jest.clearAllMocks();
service = new OcrService();
});
it('should return completed manual job with schedules', async () => {
mockGetJobStatus.mockResolvedValue({
jobId: 'manual-job-123',
status: 'completed',
progress: 100,
});
const result = await service.getJobStatus(userId, 'manual-job-123');
expect(result.jobId).toBe('manual-job-123');
expect(result.status).toBe('completed');
expect(result.progress).toBe(100);
});
it('should return processing status with progress', async () => {
mockGetJobStatus.mockResolvedValue({
jobId: 'manual-job-456',
status: 'processing',
progress: 50,
});
const result = await service.getJobStatus(userId, 'manual-job-456');
expect(result.status).toBe('processing');
expect(result.progress).toBe(50);
});
it('should throw 410 Gone for expired/missing job', async () => {
mockGetJobStatus.mockRejectedValue(new JobNotFoundError('expired-job-789'));
await expect(
service.getJobStatus(userId, 'expired-job-789')
).rejects.toMatchObject({
statusCode: 410,
message: 'Job expired (max 2 hours). Please resubmit.',
});
});
});
describe('Manual extraction controller validations', () => {
it('PDF magic bytes validation rejects non-PDF content', () => {
// Controller validates first 4 bytes match %PDF (0x25504446)
// Files without %PDF header receive 415 Unsupported Media Type
const pdfMagic = Buffer.from('%PDF');
const notPdf = Buffer.from('JFIF');
expect(pdfMagic.subarray(0, 4).equals(Buffer.from('%PDF'))).toBe(true);
expect(notPdf.subarray(0, 4).equals(Buffer.from('%PDF'))).toBe(false);
});
it('accepts files with .pdf extension even if mimetype is octet-stream', () => {
// Controller checks: contentType === 'application/pdf' OR filename.endsWith('.pdf')
// This allows uploads where browser sends generic content type
const filename = 'owners-manual.pdf';
expect(filename.toLowerCase().endsWith('.pdf')).toBe(true);
});
});
describe('Manual route tier guard', () => {
it('route is configured with tier guard for document.scanMaintenanceSchedule', async () => {
// Tier guard is enforced at route level via requireTier('document.scanMaintenanceSchedule')
// preHandler: [requireAuth, requireTier('document.scanMaintenanceSchedule')]
// Free-tier users receive 403 TIER_REQUIRED before the handler executes.
// Middleware behavior is tested in core/middleware/require-tier.test.ts
const { requireTier } = await import('../../../../core/middleware/require-tier');
const handler = requireTier('document.scanMaintenanceSchedule');
expect(typeof handler).toBe('function');
});
});

View File

@@ -0,0 +1,209 @@
/**
* @ai-summary Unit tests for OCR receipt extraction endpoint
*/
import { OcrService } from '../../domain/ocr.service';
import { ocrClient } from '../../external/ocr-client';
import type { ReceiptExtractionResponse } from '../../domain/ocr.types';
jest.mock('../../external/ocr-client');
jest.mock('../../../../core/logging/logger');
const mockExtractReceipt = ocrClient.extractReceipt as jest.MockedFunction<
typeof ocrClient.extractReceipt
>;
describe('OcrService.extractReceipt', () => {
let service: OcrService;
const userId = 'test-user-id';
const mockReceiptResponse: ReceiptExtractionResponse = {
success: true,
receiptType: 'fuel',
extractedFields: {
merchantName: { value: 'Shell Gas Station', confidence: 0.92 },
transactionDate: { value: '2026-02-10', confidence: 0.88 },
totalAmount: { value: '45.67', confidence: 0.95 },
fuelQuantity: { value: '12.345', confidence: 0.87 },
pricePerUnit: { value: '3.699', confidence: 0.90 },
fuelGrade: { value: 'Regular 87', confidence: 0.85 },
},
rawText: 'SHELL\n02/10/2026\nREGULAR 87\n12.345 GAL\n$3.699/GAL\nTOTAL $45.67',
processingTimeMs: 1250,
error: null,
};
beforeEach(() => {
jest.clearAllMocks();
service = new OcrService();
});
describe('valid receipt extraction', () => {
it('should return receipt extraction response for valid image', async () => {
mockExtractReceipt.mockResolvedValue(mockReceiptResponse);
const result = await service.extractReceipt(userId, {
fileBuffer: Buffer.from('fake-image-data'),
contentType: 'image/jpeg',
});
expect(result.success).toBe(true);
expect(result.receiptType).toBe('fuel');
expect(result.extractedFields.merchantName.value).toBe('Shell Gas Station');
expect(result.extractedFields.totalAmount.value).toBe('45.67');
expect(result.extractedFields.fuelQuantity.value).toBe('12.345');
expect(result.extractedFields.pricePerUnit.value).toBe('3.699');
expect(result.extractedFields.fuelGrade.value).toBe('Regular 87');
expect(result.extractedFields.transactionDate.value).toBe('2026-02-10');
expect(result.processingTimeMs).toBe(1250);
});
it('should pass receipt_type hint to client when provided', async () => {
mockExtractReceipt.mockResolvedValue(mockReceiptResponse);
await service.extractReceipt(userId, {
fileBuffer: Buffer.from('fake-image-data'),
contentType: 'image/jpeg',
receiptType: 'fuel',
});
expect(mockExtractReceipt).toHaveBeenCalledWith(
expect.any(Buffer),
'image/jpeg',
'fuel'
);
});
it('should support PNG images', async () => {
mockExtractReceipt.mockResolvedValue(mockReceiptResponse);
const result = await service.extractReceipt(userId, {
fileBuffer: Buffer.from('fake-png-data'),
contentType: 'image/png',
});
expect(result.success).toBe(true);
});
it('should support HEIC images', async () => {
mockExtractReceipt.mockResolvedValue(mockReceiptResponse);
const result = await service.extractReceipt(userId, {
fileBuffer: Buffer.from('fake-heic-data'),
contentType: 'image/heic',
});
expect(result.success).toBe(true);
});
});
describe('missing optional fields', () => {
it('should handle response with some fields not detected', async () => {
const partialResponse: ReceiptExtractionResponse = {
success: true,
receiptType: 'fuel',
extractedFields: {
merchantName: { value: 'Unknown Station', confidence: 0.60 },
totalAmount: { value: '30.00', confidence: 0.88 },
},
rawText: 'UNKNOWN STATION\nTOTAL $30.00',
processingTimeMs: 980,
error: null,
};
mockExtractReceipt.mockResolvedValue(partialResponse);
const result = await service.extractReceipt(userId, {
fileBuffer: Buffer.from('fake-image-data'),
contentType: 'image/jpeg',
});
expect(result.success).toBe(true);
expect(result.extractedFields.merchantName).toBeDefined();
expect(result.extractedFields.totalAmount).toBeDefined();
expect(result.extractedFields.fuelQuantity).toBeUndefined();
expect(result.extractedFields.pricePerUnit).toBeUndefined();
expect(result.extractedFields.fuelGrade).toBeUndefined();
expect(result.extractedFields.transactionDate).toBeUndefined();
});
});
describe('error handling', () => {
it('should throw 415 for unsupported file type (PDF)', async () => {
await expect(
service.extractReceipt(userId, {
fileBuffer: Buffer.from('fake-pdf-data'),
contentType: 'application/pdf',
})
).rejects.toMatchObject({
statusCode: 415,
});
});
it('should throw 415 for text/plain', async () => {
await expect(
service.extractReceipt(userId, {
fileBuffer: Buffer.from('not an image'),
contentType: 'text/plain',
})
).rejects.toMatchObject({
statusCode: 415,
});
});
it('should throw 413 for oversized file', async () => {
const largeBuffer = Buffer.alloc(11 * 1024 * 1024); // 11MB
await expect(
service.extractReceipt(userId, {
fileBuffer: largeBuffer,
contentType: 'image/jpeg',
})
).rejects.toMatchObject({
statusCode: 413,
});
});
it('should propagate Python 422 with statusCode for controller forwarding', async () => {
const err: any = new Error('OCR service error: 422 - Failed to extract receipt data');
err.statusCode = 422;
mockExtractReceipt.mockRejectedValue(err);
await expect(
service.extractReceipt(userId, {
fileBuffer: Buffer.from('fake-image-data'),
contentType: 'image/jpeg',
})
).rejects.toMatchObject({
statusCode: 422,
message: 'OCR service error: 422 - Failed to extract receipt data',
});
});
it('should propagate OCR service errors', async () => {
mockExtractReceipt.mockRejectedValue(
new Error('OCR service error: 500 - Internal error')
);
await expect(
service.extractReceipt(userId, {
fileBuffer: Buffer.from('fake-image-data'),
contentType: 'image/jpeg',
})
).rejects.toThrow('OCR service error: 500 - Internal error');
});
});
});
describe('Receipt route tier guard', () => {
it('route is configured with requireTier fuelLog.receiptScan', async () => {
// Tier guard is enforced at route level via requireTier('fuelLog.receiptScan')
// preHandler: [requireAuth, requireTier('fuelLog.receiptScan')]
// Free-tier users receive 403 TIER_REQUIRED before the handler executes.
// Middleware behavior is tested in core/middleware/require-tier.test.ts
const { requireTier } = await import('../../../../core/middleware/require-tier');
const handler = requireTier('fuelLog.receiptScan');
expect(typeof handler).toBe('function');
});
});

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

@@ -10,6 +10,7 @@ import { pool } from '../../../core/config/database';
import { logger } from '../../../core/logging/logger';
import {
StationSearchBody,
StationMatchBody,
SaveStationBody,
StationParams,
UpdateSavedStationBody
@@ -26,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) {
@@ -45,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'
@@ -53,9 +54,32 @@ export class StationsController {
}
}
async matchStation(request: FastifyRequest<{ Body: StationMatchBody }>, reply: FastifyReply) {
try {
const { merchantName } = request.body;
if (!merchantName || !merchantName.trim()) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Merchant name is required',
});
}
const result = await this.stationsService.matchStationFromReceipt(merchantName);
return reply.code(200).send(result);
} catch (error: any) {
logger.error('Error matching station from receipt', { error, merchantName: request.body?.merchantName });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to match station',
});
}
}
async saveStation(request: FastifyRequest<{ Body: SaveStationBody }>, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const {
placeId,
nickname,
@@ -82,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({
@@ -103,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);
@@ -113,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')) {
@@ -132,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'
@@ -147,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

@@ -7,6 +7,7 @@ import { FastifyInstance, FastifyPluginOptions } from 'fastify';
import { FastifyPluginAsync } from 'fastify';
import {
StationSearchBody,
StationMatchBody,
SaveStationBody,
StationParams,
UpdateSavedStationBody
@@ -25,6 +26,12 @@ export const stationsRoutes: FastifyPluginAsync = async (
handler: stationsController.searchStations.bind(stationsController)
});
// POST /api/stations/match - Match station from receipt merchant name
fastify.post<{ Body: StationMatchBody }>('/stations/match', {
preHandler: [fastify.authenticate],
handler: stationsController.matchStation.bind(stationsController)
});
// POST /api/stations/save - Save a station to user's favorites
fastify.post<{ Body: SaveStationBody }>('/stations/save', {
preHandler: [fastify.authenticate],

View File

@@ -7,6 +7,7 @@ import { googleMapsClient } from '../external/google-maps/google-maps.client';
import {
StationSearchRequest,
StationSearchResponse,
StationMatchResponse,
SavedStation,
StationSavedMetadata,
UpdateSavedStationBody
@@ -154,6 +155,27 @@ export class StationsService {
return enriched;
}
async matchStationFromReceipt(merchantName: string): Promise<StationMatchResponse> {
const trimmed = merchantName.trim();
if (!trimmed) {
return { matched: false, station: null };
}
logger.info('Matching station from receipt merchant name', { merchantName: trimmed });
const station = await googleMapsClient.searchStationByName(trimmed);
if (station) {
// Cache matched station for future reference (e.g. saveStation)
await this.repository.cacheStation(station);
}
return {
matched: station !== null,
station,
};
}
async removeSavedStation(placeId: string, userId: string) {
const removed = await this.repository.deleteSavedStation(userId, placeId);

View File

@@ -89,3 +89,12 @@ export interface StationSavedMetadata {
has93Octane: boolean;
has93OctaneEthanolFree: boolean;
}
export interface StationMatchBody {
merchantName: string;
}
export interface StationMatchResponse {
matched: boolean;
station: Station | null;
}

View File

@@ -7,7 +7,7 @@ import axios from 'axios';
import { appConfig } from '../../../../core/config/config-loader';
import { logger } from '../../../../core/logging/logger';
import { cacheService } from '../../../../core/config/redis';
import { GooglePlacesResponse, GooglePlace } from './google-maps.types';
import { GooglePlacesResponse, GoogleTextSearchResponse, GooglePlace } from './google-maps.types';
import { Station } from '../../domain/stations.types';
export class GoogleMapsClient {
@@ -103,6 +103,92 @@ export class GoogleMapsClient {
return station;
}
/**
* Search for a gas station by merchant name using Google Places Text Search API.
* Used to match receipt merchant names (e.g. "Shell", "COSTCO #123") to actual stations.
*/
async searchStationByName(merchantName: string): Promise<Station | null> {
const query = `${merchantName} gas station`;
const cacheKey = `station-match:${query.toLowerCase().trim()}`;
try {
const cached = await cacheService.get<Station | null>(cacheKey);
if (cached !== undefined && cached !== null) {
logger.debug('Station name match cache hit', { merchantName });
return cached;
}
logger.info('Searching Google Places Text Search for station', { merchantName, query });
const response = await axios.get<GoogleTextSearchResponse>(
`${this.baseURL}/textsearch/json`,
{
params: {
query,
type: 'gas_station',
key: this.apiKey,
},
timeout: 5000,
}
);
if (response.data.status !== 'OK' && response.data.status !== 'ZERO_RESULTS') {
throw new Error(`Google Places Text Search API error: ${response.data.status}`);
}
if (response.data.results.length === 0) {
await cacheService.set(cacheKey, null, this.cacheTTL);
return null;
}
const topResult = response.data.results[0];
const station = this.transformTextSearchResult(topResult);
await cacheService.set(cacheKey, station, this.cacheTTL);
return station;
} catch (error: any) {
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
logger.warn('Station name search timed out', { merchantName, timeoutMs: 5000 });
} else {
logger.error('Station name search failed', { error, merchantName });
}
return null;
}
}
private transformTextSearchResult(place: GooglePlace): Station {
let photoReference: string | undefined;
if (place.photos && place.photos.length > 0 && place.photos[0]) {
photoReference = place.photos[0].photo_reference;
}
// Text Search returns formatted_address instead of vicinity
const address = (place as any).formatted_address || place.vicinity || '';
const station: Station = {
id: place.place_id,
placeId: place.place_id,
name: place.name,
address,
latitude: place.geometry.location.lat,
longitude: place.geometry.location.lng,
};
if (photoReference !== undefined) {
station.photoReference = photoReference;
}
if (place.opening_hours?.open_now !== undefined) {
station.isOpen = place.opening_hours.open_now;
}
if (place.rating !== undefined) {
station.rating = place.rating;
}
return station;
}
/**
* Fetch photo from Google Maps API using photo reference
* Used by photo proxy endpoint to serve photos without exposing API key

View File

@@ -52,4 +52,10 @@ export interface GooglePlaceDetails {
website?: string;
};
status: string;
}
export interface GoogleTextSearchResponse {
results: GooglePlace[];
status: string;
next_page_token?: string;
}

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

@@ -0,0 +1,276 @@
/**
* @ai-summary Unit tests for station matching from receipt merchant names
*/
// Mock config-loader before any imports that use it
jest.mock('../../../../core/config/config-loader', () => ({
appConfig: {
secrets: { google_maps_api_key: 'mock-api-key' },
getDatabaseUrl: () => 'postgresql://mock:mock@localhost/mock',
getRedisUrl: () => 'redis://localhost',
get: () => ({}),
},
}));
jest.mock('axios');
jest.mock('../../../../core/config/redis');
jest.mock('../../../../core/logging/logger');
jest.mock('../../data/stations.repository');
jest.mock('../../external/google-maps/google-maps.client', () => {
const { GoogleMapsClient } = jest.requireActual('../../external/google-maps/google-maps.client');
return {
GoogleMapsClient,
googleMapsClient: {
searchNearbyStations: jest.fn(),
searchStationByName: jest.fn(),
fetchPhoto: jest.fn(),
},
};
});
import axios from 'axios';
import { GoogleMapsClient } from '../../external/google-maps/google-maps.client';
import { StationsService } from '../../domain/stations.service';
import { StationsRepository } from '../../data/stations.repository';
import { googleMapsClient } from '../../external/google-maps/google-maps.client';
import { logger } from '../../../../core/logging/logger';
import { mockStations } from '../fixtures/mock-stations';
describe('Station Matching from Receipt', () => {
describe('GoogleMapsClient.searchStationByName', () => {
let client: GoogleMapsClient;
let mockAxios: jest.Mocked<typeof axios>;
beforeEach(() => {
jest.clearAllMocks();
mockAxios = axios as jest.Mocked<typeof axios>;
client = new GoogleMapsClient();
});
it('should match a known station name like "Shell"', async () => {
mockAxios.get.mockResolvedValue({
data: {
results: [
{
place_id: 'ChIJ_shell_match',
name: 'Shell Gas Station',
formatted_address: '123 Main St, San Francisco, CA 94105',
geometry: { location: { lat: 37.7749, lng: -122.4194 } },
rating: 4.2,
photos: [{ photo_reference: 'shell-photo-ref' }],
opening_hours: { open_now: true },
types: ['gas_station'],
},
],
status: 'OK',
},
});
const result = await client.searchStationByName('Shell');
expect(result).not.toBeNull();
expect(result?.placeId).toBe('ChIJ_shell_match');
expect(result?.name).toBe('Shell Gas Station');
expect(result?.address).toBe('123 Main St, San Francisco, CA 94105');
expect(mockAxios.get).toHaveBeenCalledWith(
expect.stringContaining('textsearch/json'),
expect.objectContaining({
params: expect.objectContaining({
query: 'Shell gas station',
type: 'gas_station',
}),
})
);
});
it('should match abbreviated names like "COSTCO #123"', async () => {
mockAxios.get.mockResolvedValue({
data: {
results: [
{
place_id: 'ChIJ_costco_match',
name: 'Costco Gasoline',
formatted_address: '2000 El Camino Real, Redwood City, CA',
geometry: { location: { lat: 37.4849, lng: -122.2278 } },
rating: 4.5,
types: ['gas_station'],
},
],
status: 'OK',
},
});
const result = await client.searchStationByName('COSTCO #123');
expect(result).not.toBeNull();
expect(result?.name).toBe('Costco Gasoline');
expect(result?.placeId).toBe('ChIJ_costco_match');
});
it('should match "BP" station name', async () => {
mockAxios.get.mockResolvedValue({
data: {
results: [
{
place_id: 'ChIJ_bp_match',
name: 'BP',
formatted_address: '500 Market St, San Francisco, CA',
geometry: { location: { lat: 37.79, lng: -122.40 } },
types: ['gas_station'],
},
],
status: 'OK',
},
});
const result = await client.searchStationByName('BP');
expect(result).not.toBeNull();
expect(result?.name).toBe('BP');
});
it('should return null when no match is found', async () => {
mockAxios.get.mockResolvedValue({
data: {
results: [],
status: 'ZERO_RESULTS',
},
});
const result = await client.searchStationByName('Unknown Station XYZ123');
expect(result).toBeNull();
});
it('should return null gracefully on API error', async () => {
mockAxios.get.mockRejectedValue(new Error('Network error'));
const result = await client.searchStationByName('Shell');
expect(result).toBeNull();
});
it('should return null on API denial', async () => {
mockAxios.get.mockResolvedValue({
data: {
results: [],
status: 'REQUEST_DENIED',
error_message: 'Invalid key',
},
});
const result = await client.searchStationByName('Shell');
expect(result).toBeNull();
});
it('should return null with logged warning on Places API timeout', async () => {
const timeoutError = new Error('timeout of 5000ms exceeded') as any;
timeoutError.code = 'ECONNABORTED';
mockAxios.get.mockRejectedValue(timeoutError);
const mockLogger = logger as jest.Mocked<typeof logger>;
const result = await client.searchStationByName('Shell');
expect(result).toBeNull();
expect(mockLogger.warn).toHaveBeenCalledWith(
'Station name search timed out',
expect.objectContaining({ merchantName: 'Shell', timeoutMs: 5000 })
);
expect(mockLogger.error).not.toHaveBeenCalled();
});
it('should include rating and photo reference when available', async () => {
mockAxios.get.mockResolvedValue({
data: {
results: [
{
place_id: 'ChIJ_rated',
name: 'Chevron',
formatted_address: '789 Oak Ave, Portland, OR',
geometry: { location: { lat: 45.52, lng: -122.68 } },
rating: 4.7,
photos: [{ photo_reference: 'chevron-photo' }],
opening_hours: { open_now: false },
types: ['gas_station'],
},
],
status: 'OK',
},
});
const result = await client.searchStationByName('Chevron');
expect(result?.rating).toBe(4.7);
expect(result?.photoReference).toBe('chevron-photo');
expect(result?.isOpen).toBe(false);
});
});
describe('StationsService.matchStationFromReceipt', () => {
let service: StationsService;
let mockRepository: jest.Mocked<StationsRepository>;
const mockSearchByName = googleMapsClient.searchStationByName as jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
mockRepository = {
cacheStation: jest.fn().mockResolvedValue(undefined),
getCachedStation: jest.fn(),
saveStation: jest.fn(),
getUserSavedStations: jest.fn().mockResolvedValue([]),
updateSavedStation: jest.fn(),
deleteSavedStation: jest.fn(),
} as unknown as jest.Mocked<StationsRepository>;
service = new StationsService(mockRepository);
});
it('should return matched station for known merchant name', async () => {
const matchedStation = mockStations[0]!;
mockSearchByName.mockResolvedValue(matchedStation);
const result = await service.matchStationFromReceipt('Shell');
expect(result.matched).toBe(true);
expect(result.station).not.toBeNull();
expect(result.station?.name).toBe('Shell Gas Station - Downtown');
expect(mockRepository.cacheStation).toHaveBeenCalledWith(matchedStation);
});
it('should return no match for unknown merchant', async () => {
mockSearchByName.mockResolvedValue(null);
const result = await service.matchStationFromReceipt('Unknown Store');
expect(result.matched).toBe(false);
expect(result.station).toBeNull();
expect(mockRepository.cacheStation).not.toHaveBeenCalled();
});
it('should handle empty merchant name', async () => {
const result = await service.matchStationFromReceipt('');
expect(result.matched).toBe(false);
expect(result.station).toBeNull();
});
it('should handle whitespace-only merchant name', async () => {
const result = await service.matchStationFromReceipt(' ');
expect(result.matched).toBe(false);
expect(result.station).toBeNull();
});
it('should cache matched station for future saveStation calls', async () => {
const matchedStation = mockStations[1]!;
mockSearchByName.mockResolvedValue(matchedStation);
await service.matchStationFromReceipt('Chevron');
expect(mockRepository.cacheStation).toHaveBeenCalledWith(matchedStation);
});
});
});

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

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