68 Commits

Author SHA1 Message Date
7e2bb9ef36 Merge pull request 'feat: Migrate Gemini SDK to google-genai (#231)' (#236) from issue-231-migrate-gemini-sdk-google-genai into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 37s
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: #236
2026-03-01 04:08:09 +00:00
Eric Gullickson
56df5d48f3 fix: revert unsupported AFC config and add diagnostic logging for VIN decode (refs #231)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 12m33s
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
- Remove AutomaticFunctionCallingConfig(max_remote_calls=3) which caused
  pydantic validation error on the installed google-genai version
- Log full Gemini raw JSON response in OCR engine for debugging
- Add engine/transmission to backend raw values log
- Add hasTrim/hasEngine/hasTransmission to decode success log

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:16:56 -06:00
Eric Gullickson
1add6c8240 fix: remove unsupported AutomaticFunctionCallingConfig parameter (refs #231)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 39s
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
The installed google-genai version does not support max_remote_calls on
AutomaticFunctionCallingConfig, causing a pydantic validation error that
broke VIN decode on staging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:59:04 -06:00
Eric Gullickson
936753fac2 fix: VIN Decoding timeouts and logic errors
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m33s
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-28 12:02:26 -06:00
Eric Gullickson
96e1dde7b2 docs: update CLAUDE.md references from Vertex AI to google-genai (refs #231)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 8m4s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 24s
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
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:21:58 -06:00
Eric Gullickson
1464a0e1af feat: update test mocks for google-genai SDK (refs #235)
Replace engine._model/engine._generation_config mocks with
engine._client/engine._model_name. Update sys.modules patches
from vertexai to google.genai. Remove dead if-False branch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:21:10 -06:00
Eric Gullickson
9f51e62b94 feat: migrate MaintenanceReceiptExtractor to google-genai SDK (refs #234)
Replace vertexai.generative_models with google.genai client pattern.
Fix pre-existing bug: raise GeminiUnavailableError instead of bare
RuntimeError for missing credentials. Add proper try/except blocks
matching GeminiEngine error handling pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:17:14 -06:00
Eric Gullickson
b7f472b3e8 feat: migrate GeminiEngine to google-genai SDK with Google Search grounding (refs #233)
Replace vertexai.generative_models with google.genai client pattern.
Add Google Search grounding tool to VIN decode for improved accuracy.
Convert response schema types to uppercase per Vertex AI Schema spec.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:16:18 -06:00
Eric Gullickson
398d67304f feat: replace google-cloud-aiplatform with google-genai dependency (refs #232)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:13:54 -06:00
Eric Gullickson
0055d9f0f3 fix: VIN decoding year fixes
All checks were successful
Deploy to Staging / Build Images (push) Successful in 35s
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
Mirror Base Images / Mirror Base Images (push) Successful in 1m2s
2026-02-28 11:09:46 -06:00
Eric Gullickson
9dc56a3773 fix: distribute plan storage to sub-issues for context efficiency
Some checks failed
Deploy to Staging / Build Images (push) Successful in 38s
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
Split monolithic parent-issue plan into per-sub-issue comments.
  Updated workflow contract to enforce self-contained sub-issue plans.
2026-02-28 11:08:49 -06:00
Eric Gullickson
283ba6b108 fix: Remove VIN Cache
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m36s
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
Mirror Base Images / Mirror Base Images (push) Successful in 42s
2026-02-20 08:26:39 -06:00
Eric Gullickson
7d90f4b25a fix: add VIN year code table to Gemini decode prompt (refs #229)
All checks were successful
Deploy to Staging / Build Images (push) Successful in 37s
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
gemini-3-flash-preview was hallucinating year (e.g., returning 1993
instead of 2023 for position-10 code P). Prompt now includes the full
1980-2039 year code table and position-7 disambiguation rule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 21:55:21 -06:00
e2e6471c5e Merge pull request 'fix: increase VIN decode timeout for Gemini cold start' (#230) from issue-223-replace-nhtsa-vin-decode-gemini 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 10s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #230
2026-02-20 03:37:49 +00:00
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
d9df9193dc Merge pull request 'feat: Replace NHTSA VIN decode with Google Gemini via OCR service (#223)' (#229) from issue-223-replace-nhtsa-vin-decode-gemini 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 8s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #229
2026-02-20 03:10:46 +00: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
163 changed files with 6266 additions and 2294 deletions

View File

@@ -52,7 +52,8 @@
"ONE PR for the parent issue. The PR closes the parent and all sub-issues.", "ONE PR for the parent issue. The PR closes the parent and all sub-issues.",
"Commits reference the specific sub-issue index they implement.", "Commits reference the specific sub-issue index they implement.",
"Sub-issues should be small enough to fit in a single AI context window.", "Sub-issues should be small enough to fit in a single AI context window.",
"Plan milestones map 1:1 to sub-issues." "Plan milestones map 1:1 to sub-issues.",
"Each sub-issue receives its own plan comment with duplicated shared context. An agent must be able to execute from the sub-issue alone."
], ],
"examples": { "examples": {
"parent": "#105 'feat: Add Grafana dashboards and alerting'", "parent": "#105 'feat: Add Grafana dashboards and alerting'",
@@ -103,8 +104,9 @@
"[SKILL] Problem Analysis if complex problem.", "[SKILL] Problem Analysis if complex problem.",
"[SKILL] Decision Critic if uncertain approach.", "[SKILL] Decision Critic if uncertain approach.",
"If multi-file feature (3+ files): decompose into sub-issues per sub_issues rules. Each sub-issue = one plan milestone.", "If multi-file feature (3+ files): decompose into sub-issues per sub_issues rules. Each sub-issue = one plan milestone.",
"[SKILL] Planner writes plan as parent issue comment. Plan milestones map 1:1 to sub-issues.", "[SKILL] Planner writes plan summary as parent issue comment: shared context + milestone index linking each milestone to its sub-issue. M5 (doc-sync) stays on parent if no sub-issue exists.",
"[SKILL] Plan review cycle: QR plan-completeness -> TW plan-scrub -> QR plan-code -> QR plan-docs.", "[SKILL] Planner posts each milestone's self-contained implementation plan as a comment on the corresponding sub-issue. Each sub-issue plan duplicates relevant shared context (API maps, state changes, auth, error handling, risk) so an agent can execute from the sub-issue alone without reading the parent.",
"[SKILL] Plan review cycle: QR plan-completeness -> TW plan-scrub -> QR plan-code -> QR plan-docs. Distribute milestone-specific review findings to sub-issue plan comments.",
"Create ONE branch issue-{parent_index}-{slug} from main.", "Create ONE branch issue-{parent_index}-{slug} from main.",
"[SKILL] Planner executes plan, delegates to Developer per milestone/sub-issue.", "[SKILL] Planner executes plan, delegates to Developer per milestone/sub-issue.",
"[SKILL] QR post-implementation per milestone (results in parent issue comment).", "[SKILL] QR post-implementation per milestone (results in parent issue comment).",
@@ -123,7 +125,7 @@
"execution_review": ["QR post-implementation per milestone"], "execution_review": ["QR post-implementation per milestone"],
"final_review": ["Quality Agent RULE 0/1/2"] "final_review": ["Quality Agent RULE 0/1/2"]
}, },
"plan_storage": "gitea_issue_comments", "plan_storage": "gitea_issue_comments: summary on parent issue, milestone detail on sub-issues",
"tracking_storage": "gitea_issue_comments", "tracking_storage": "gitea_issue_comments",
"issue_comment_operations": { "issue_comment_operations": {
"create_comment": "mcp__gitea-mcp__create_issue_comment", "create_comment": "mcp__gitea-mcp__create_issue_comment",

View File

@@ -1,39 +1,5 @@
{ {
"testModules": [ "testModules": [
{
"moduleId": "/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/src/features/dashboard/hooks/__tests__/useDashboardData.test.ts",
"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/testWorker.js:275:16)\n at runTest (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-runner/build/testWorker.js:343:7)\n at Object.worker (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-runner/build/testWorker.js:497:12)"
}
]
}
]
},
{
"moduleId": "/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/src/features/dashboard/components/__tests__/DashboardScreen.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/testWorker.js:275:16)\n at runTest (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-runner/build/testWorker.js:343:7)\n at Object.worker (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-runner/build/testWorker.js:497:12)"
}
]
}
]
},
{ {
"moduleId": "/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/src/features/dashboard/components/__tests__/ActionBar.test.tsx", "moduleId": "/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/src/features/dashboard/components/__tests__/ActionBar.test.tsx",
"tests": [ "tests": [
@@ -45,24 +11,7 @@
{ {
"message": "File not found: tsconfig.json (resolved as: /Users/egullickson/Documents/Technology/coding/motovaultpro/tsconfig.json)", "message": "File not found: tsconfig.json (resolved as: /Users/egullickson/Documents/Technology/coding/motovaultpro/tsconfig.json)",
"name": "Error", "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/testWorker.js:275:16)\n at runTest (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-runner/build/testWorker.js:343:7)\n at Object.worker (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-runner/build/testWorker.js:497:12)" "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)"
}
]
}
]
},
{
"moduleId": "/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/src/features/dashboard/components/__tests__/VehicleRosterCard.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/testWorker.js:275:16)\n at runTest (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-runner/build/testWorker.js:343:7)\n at Object.worker (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-runner/build/testWorker.js:497:12)"
} }
] ]
} }

36
.env.example Normal file
View File

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

View File

@@ -99,6 +99,7 @@ jobs:
docker-compose.yml docker-compose.yml
docker-compose.blue-green.yml docker-compose.blue-green.yml
docker-compose.prod.yml docker-compose.prod.yml
.env.example
sparse-checkout-cone-mode: false sparse-checkout-cone-mode: false
fetch-depth: 1 fetch-depth: 1
@@ -115,11 +116,20 @@ jobs:
mkdir -p "$DEPLOY_PATH/secrets/app" mkdir -p "$DEPLOY_PATH/secrets/app"
cp "$GITHUB_WORKSPACE/secrets/app/google-wif-config.json" "$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: | run: |
cd "$DEPLOY_PATH" 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 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 - name: Login to registry
run: | run: |

View File

@@ -124,11 +124,20 @@ jobs:
mkdir -p "$DEPLOY_PATH/secrets/app" mkdir -p "$DEPLOY_PATH/secrets/app"
cp "$GITHUB_WORKSPACE/secrets/app/google-wif-config.json" "$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: | run: |
cd "$DEPLOY_PATH" 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 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 - name: Login to registry
run: | run: |

View File

@@ -31,6 +31,7 @@ const MIGRATION_ORDER = [
'features/audit-log', // Centralized audit logging; independent 'features/audit-log', // Centralized audit logging; independent
'features/ownership-costs', // Depends on vehicles and documents; TCO recurring costs 'features/ownership-costs', // Depends on vehicles and documents; TCO recurring costs
'features/subscriptions', // Stripe subscriptions; depends on user-profile, vehicles '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) // Base directory where migrations are copied inside the image (set by Dockerfile)

View File

@@ -41,14 +41,6 @@ const configSchema = z.object({
audience: z.string(), audience: z.string(),
}), }),
// External APIs configuration (optional)
external: z.object({
vpic: z.object({
url: z.string(),
timeout: z.string(),
}).optional(),
}).optional(),
// Service configuration // Service configuration
service: z.object({ service: z.object({
name: z.string(), name: z.string(),

View File

@@ -29,7 +29,7 @@ export const FEATURE_TIERS: Record<string, FeatureConfig> = {
'vehicle.vinDecode': { 'vehicle.vinDecode': {
minTier: 'pro', minTier: 'pro',
name: 'VIN Decode', 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': { 'fuelLog.receiptScan': {
minTier: 'pro', minTier: 'pro',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,8 +10,8 @@ export const createAdminSchema = z.object({
role: z.enum(['admin', 'super_admin']).default('admin'), role: z.enum(['admin', 'super_admin']).default('admin'),
}); });
export const adminAuth0SubSchema = z.object({ export const adminIdSchema = z.object({
auth0Sub: z.string().min(1, 'auth0Sub is required'), id: z.string().uuid('Invalid admin ID format'),
}); });
export const auditLogsQuerySchema = z.object({ export const auditLogsQuerySchema = z.object({
@@ -29,14 +29,14 @@ export const bulkCreateAdminSchema = z.object({
}); });
export const bulkRevokeAdminSchema = z.object({ export const bulkRevokeAdminSchema = z.object({
auth0Subs: z.array(z.string().min(1, 'auth0Sub cannot be empty')) ids: z.array(z.string().uuid('Invalid admin ID format'))
.min(1, 'At least one auth0Sub must be provided') .min(1, 'At least one admin ID must be provided')
.max(100, 'Maximum 100 admins per batch'), .max(100, 'Maximum 100 admins per batch'),
}); });
export const bulkReinstateAdminSchema = z.object({ export const bulkReinstateAdminSchema = z.object({
auth0Subs: z.array(z.string().min(1, 'auth0Sub cannot be empty')) ids: z.array(z.string().uuid('Invalid admin ID format'))
.min(1, 'At least one auth0Sub must be provided') .min(1, 'At least one admin ID must be provided')
.max(100, 'Maximum 100 admins per batch'), .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 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 AuditLogsQueryInput = z.infer<typeof auditLogsQuerySchema>;
export type BulkCreateAdminInput = z.infer<typeof bulkCreateAdminSchema>; export type BulkCreateAdminInput = z.infer<typeof bulkCreateAdminSchema>;
export type BulkRevokeAdminInput = z.infer<typeof bulkRevokeAdminSchema>; 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 { logger } from '../../../core/logging/logger';
import { import {
listUsersQuerySchema, listUsersQuerySchema,
userAuth0SubSchema, userIdSchema,
updateTierSchema, updateTierSchema,
deactivateUserSchema, deactivateUserSchema,
updateProfileSchema, updateProfileSchema,
promoteToAdminSchema, promoteToAdminSchema,
ListUsersQueryInput, ListUsersQueryInput,
UserAuth0SubInput, UserIdInput,
UpdateTierInput, UpdateTierInput,
DeactivateUserInput, DeactivateUserInput,
UpdateProfileInput, 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( async getUserVehicles(
request: FastifyRequest<{ Params: UserAuth0SubInput }>, request: FastifyRequest<{ Params: UserIdInput }>,
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
@@ -119,7 +119,7 @@ export class UsersController {
} }
// Validate path param // Validate path param
const parseResult = userAuth0SubSchema.safeParse(request.params); const parseResult = userIdSchema.safeParse(request.params);
if (!parseResult.success) { if (!parseResult.success) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Validation error', error: 'Validation error',
@@ -127,14 +127,14 @@ export class UsersController {
}); });
} }
const { auth0Sub } = parseResult.data; const { userId } = parseResult.data;
const vehicles = await this.userProfileRepository.getUserVehiclesForAdmin(auth0Sub); const vehicles = await this.userProfileRepository.getUserVehiclesForAdmin(userId);
return reply.code(200).send({ vehicles }); return reply.code(200).send({ vehicles });
} catch (error) { } catch (error) {
logger.error('Error getting user vehicles', { logger.error('Error getting user vehicles', {
error: error instanceof Error ? error.message : 'Unknown error', error: error instanceof Error ? error.message : 'Unknown error',
auth0Sub: request.params?.auth0Sub, userId: (request.params as any)?.userId,
}); });
return reply.code(500).send({ 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( async getUser(
request: FastifyRequest<{ Params: UserAuth0SubInput }>, request: FastifyRequest<{ Params: UserIdInput }>,
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
@@ -202,7 +202,7 @@ export class UsersController {
} }
// Validate path param // Validate path param
const parseResult = userAuth0SubSchema.safeParse(request.params); const parseResult = userIdSchema.safeParse(request.params);
if (!parseResult.success) { if (!parseResult.success) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Validation error', error: 'Validation error',
@@ -210,8 +210,8 @@ export class UsersController {
}); });
} }
const { auth0Sub } = parseResult.data; const { userId } = parseResult.data;
const user = await this.userProfileService.getUserDetails(auth0Sub); const user = await this.userProfileService.getUserDetails(userId);
if (!user) { if (!user) {
return reply.code(404).send({ return reply.code(404).send({
@@ -224,7 +224,7 @@ export class UsersController {
} catch (error) { } catch (error) {
logger.error('Error getting user details', { logger.error('Error getting user details', {
error: error instanceof Error ? error.message : 'Unknown error', error: error instanceof Error ? error.message : 'Unknown error',
auth0Sub: request.params?.auth0Sub, userId: (request.params as any)?.userId,
}); });
return reply.code(500).send({ 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 * Uses subscriptionsService.adminOverrideTier() to sync both subscriptions.tier
* and user_profiles.subscription_tier atomically * and user_profiles.subscription_tier atomically
*/ */
async updateTier( async updateTier(
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: UpdateTierInput }>, request: FastifyRequest<{ Params: UserIdInput; Body: UpdateTierInput }>,
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
@@ -253,7 +253,7 @@ export class UsersController {
} }
// Validate path param // Validate path param
const paramsResult = userAuth0SubSchema.safeParse(request.params); const paramsResult = userIdSchema.safeParse(request.params);
if (!paramsResult.success) { if (!paramsResult.success) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Validation error', error: 'Validation error',
@@ -270,11 +270,11 @@ export class UsersController {
}); });
} }
const { auth0Sub } = paramsResult.data; const { userId } = paramsResult.data;
const { subscriptionTier } = bodyResult.data; const { subscriptionTier } = bodyResult.data;
// Verify user exists before attempting tier change // Verify user exists before attempting tier change
const currentUser = await this.userProfileService.getUserDetails(auth0Sub); const currentUser = await this.userProfileService.getUserDetails(userId);
if (!currentUser) { if (!currentUser) {
return reply.code(404).send({ return reply.code(404).send({
error: 'Not found', error: 'Not found',
@@ -285,34 +285,34 @@ export class UsersController {
const previousTier = currentUser.subscriptionTier; const previousTier = currentUser.subscriptionTier;
// Use subscriptionsService to update both tables atomically // Use subscriptionsService to update both tables atomically
await this.subscriptionsService.adminOverrideTier(auth0Sub, subscriptionTier); await this.subscriptionsService.adminOverrideTier(userId, subscriptionTier);
// Log audit action // Log audit action
await this.adminRepository.logAuditAction( await this.adminRepository.logAuditAction(
actorId, actorId,
'UPDATE_TIER', 'UPDATE_TIER',
auth0Sub, userId,
'user_profile', 'user_profile',
currentUser.id, currentUser.id,
{ previousTier, newTier: subscriptionTier } { previousTier, newTier: subscriptionTier }
); );
logger.info('User subscription tier updated via admin', { logger.info('User subscription tier updated via admin', {
auth0Sub, userId,
previousTier, previousTier,
newTier: subscriptionTier, newTier: subscriptionTier,
actorAuth0Sub: actorId, actorId,
}); });
// Return updated user profile // Return updated user profile
const updatedUser = await this.userProfileService.getUserDetails(auth0Sub); const updatedUser = await this.userProfileService.getUserDetails(userId);
return reply.code(200).send(updatedUser); return reply.code(200).send(updatedUser);
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error updating user tier', { logger.error('Error updating user tier', {
error: errorMessage, error: errorMessage,
auth0Sub: request.params?.auth0Sub, userId: (request.params as any)?.userId,
}); });
if (errorMessage === 'User not found') { 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( async deactivateUser(
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: DeactivateUserInput }>, request: FastifyRequest<{ Params: UserIdInput; Body: DeactivateUserInput }>,
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
@@ -346,7 +346,7 @@ export class UsersController {
} }
// Validate path param // Validate path param
const paramsResult = userAuth0SubSchema.safeParse(request.params); const paramsResult = userIdSchema.safeParse(request.params);
if (!paramsResult.success) { if (!paramsResult.success) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Validation error', error: 'Validation error',
@@ -363,11 +363,11 @@ export class UsersController {
}); });
} }
const { auth0Sub } = paramsResult.data; const { userId } = paramsResult.data;
const { reason } = bodyResult.data; const { reason } = bodyResult.data;
const deactivatedUser = await this.userProfileService.deactivateUser( const deactivatedUser = await this.userProfileService.deactivateUser(
auth0Sub, userId,
actorId, actorId,
reason reason
); );
@@ -378,7 +378,7 @@ export class UsersController {
logger.error('Error deactivating user', { logger.error('Error deactivating user', {
error: errorMessage, error: errorMessage,
auth0Sub: request.params?.auth0Sub, userId: (request.params as any)?.userId,
}); });
if (errorMessage === 'User not found') { 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( async reactivateUser(
request: FastifyRequest<{ Params: UserAuth0SubInput }>, request: FastifyRequest<{ Params: UserIdInput }>,
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
@@ -426,7 +426,7 @@ export class UsersController {
} }
// Validate path param // Validate path param
const paramsResult = userAuth0SubSchema.safeParse(request.params); const paramsResult = userIdSchema.safeParse(request.params);
if (!paramsResult.success) { if (!paramsResult.success) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Validation error', error: 'Validation error',
@@ -434,10 +434,10 @@ export class UsersController {
}); });
} }
const { auth0Sub } = paramsResult.data; const { userId } = paramsResult.data;
const reactivatedUser = await this.userProfileService.reactivateUser( const reactivatedUser = await this.userProfileService.reactivateUser(
auth0Sub, userId,
actorId actorId
); );
@@ -447,7 +447,7 @@ export class UsersController {
logger.error('Error reactivating user', { logger.error('Error reactivating user', {
error: errorMessage, error: errorMessage,
auth0Sub: request.params?.auth0Sub, userId: (request.params as any)?.userId,
}); });
if (errorMessage === 'User not found') { 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( async updateProfile(
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: UpdateProfileInput }>, request: FastifyRequest<{ Params: UserIdInput; Body: UpdateProfileInput }>,
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
@@ -488,7 +488,7 @@ export class UsersController {
} }
// Validate path param // Validate path param
const paramsResult = userAuth0SubSchema.safeParse(request.params); const paramsResult = userIdSchema.safeParse(request.params);
if (!paramsResult.success) { if (!paramsResult.success) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Validation error', error: 'Validation error',
@@ -505,11 +505,11 @@ export class UsersController {
}); });
} }
const { auth0Sub } = paramsResult.data; const { userId } = paramsResult.data;
const updates = bodyResult.data; const updates = bodyResult.data;
const updatedUser = await this.userProfileService.adminUpdateProfile( const updatedUser = await this.userProfileService.adminUpdateProfile(
auth0Sub, userId,
updates, updates,
actorId actorId
); );
@@ -520,7 +520,7 @@ export class UsersController {
logger.error('Error updating user profile', { logger.error('Error updating user profile', {
error: errorMessage, error: errorMessage,
auth0Sub: request.params?.auth0Sub, userId: (request.params as any)?.userId,
}); });
if (errorMessage === 'User not found') { 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( async promoteToAdmin(
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: PromoteToAdminInput }>, request: FastifyRequest<{ Params: UserIdInput; Body: PromoteToAdminInput }>,
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
@@ -554,7 +554,7 @@ export class UsersController {
} }
// Validate path param // Validate path param
const paramsResult = userAuth0SubSchema.safeParse(request.params); const paramsResult = userIdSchema.safeParse(request.params);
if (!paramsResult.success) { if (!paramsResult.success) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Validation error', error: 'Validation error',
@@ -571,11 +571,11 @@ export class UsersController {
}); });
} }
const { auth0Sub } = paramsResult.data; const { userId } = paramsResult.data;
const { role } = bodyResult.data; const { role } = bodyResult.data;
// Get the user profile first to verify they exist and get their email // Get the user profile to verify they exist and get their email
const user = await this.userProfileService.getUserDetails(auth0Sub); const user = await this.userProfileService.getUserDetails(userId);
if (!user) { if (!user) {
return reply.code(404).send({ return reply.code(404).send({
error: 'Not found', 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( const adminUser = await this.adminService.createAdmin(
user.email, user.email,
role, role,
auth0Sub, // Use the real auth0Sub from the user profile userId,
actorId actorAdmin?.id || actorId
); );
return reply.code(201).send(adminUser); return reply.code(201).send(adminUser);
@@ -605,7 +608,7 @@ export class UsersController {
logger.error('Error promoting user to admin', { logger.error('Error promoting user to admin', {
error: errorMessage, error: errorMessage,
auth0Sub: request.params?.auth0Sub, userId: (request.params as any)?.userId,
}); });
if (errorMessage.includes('already exists')) { 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( async hardDeleteUser(
request: FastifyRequest<{ Params: UserAuth0SubInput }>, request: FastifyRequest<{ Params: UserIdInput }>,
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
@@ -639,7 +642,7 @@ export class UsersController {
} }
// Validate path param // Validate path param
const paramsResult = userAuth0SubSchema.safeParse(request.params); const paramsResult = userIdSchema.safeParse(request.params);
if (!paramsResult.success) { if (!paramsResult.success) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Validation error', error: 'Validation error',
@@ -647,14 +650,14 @@ export class UsersController {
}); });
} }
const { auth0Sub } = paramsResult.data; const { userId } = paramsResult.data;
// Optional reason from query params // Optional reason from query params
const reason = (request.query as any)?.reason; const reason = (request.query as any)?.reason;
// Hard delete user // Hard delete user
await this.userProfileService.adminHardDeleteUser( await this.userProfileService.adminHardDeleteUser(
auth0Sub, userId,
actorId, actorId,
reason reason
); );
@@ -667,7 +670,7 @@ export class UsersController {
logger.error('Error hard deleting user', { logger.error('Error hard deleting user', {
error: errorMessage, error: errorMessage,
auth0Sub: request.params?.auth0Sub, userId: (request.params as any)?.userId,
}); });
if (errorMessage === 'Cannot delete your own account') { 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'), sortOrder: z.enum(['asc', 'desc']).default('desc'),
}); });
// Path param for user auth0Sub // Path param for user UUID
export const userAuth0SubSchema = z.object({ export const userIdSchema = z.object({
auth0Sub: z.string().min(1, 'auth0Sub is required'), userId: z.string().uuid('Invalid user ID format'),
}); });
// Body for updating subscription tier // Body for updating subscription tier
@@ -50,7 +50,7 @@ export const promoteToAdminSchema = z.object({
// Type exports // Type exports
export type ListUsersQueryInput = z.infer<typeof listUsersQuerySchema>; 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 UpdateTierInput = z.infer<typeof updateTierSchema>;
export type DeactivateUserInput = z.infer<typeof deactivateUserSchema>; export type DeactivateUserInput = z.infer<typeof deactivateUserSchema>;
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>; export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;

View File

@@ -10,29 +10,49 @@ import { logger } from '../../../core/logging/logger';
export class AdminRepository { export class AdminRepository {
constructor(private pool: Pool) {} constructor(private pool: Pool) {}
async getAdminByAuth0Sub(auth0Sub: string): Promise<AdminUser | null> { async getAdminById(id: string): Promise<AdminUser | null> {
const query = ` 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 FROM admin_users
WHERE auth0_sub = $1 WHERE id = $1
LIMIT 1 LIMIT 1
`; `;
try { try {
const result = await this.pool.query(query, [auth0Sub]); const result = await this.pool.query(query, [id]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
return null; return null;
} }
return this.mapRowToAdminUser(result.rows[0]); return this.mapRowToAdminUser(result.rows[0]);
} catch (error) { } 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; throw error;
} }
} }
async getAdminByEmail(email: string): Promise<AdminUser | null> { async getAdminByEmail(email: string): Promise<AdminUser | null> {
const query = ` 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 FROM admin_users
WHERE LOWER(email) = LOWER($1) WHERE LOWER(email) = LOWER($1)
LIMIT 1 LIMIT 1
@@ -52,7 +72,7 @@ export class AdminRepository {
async getAllAdmins(): Promise<AdminUser[]> { async getAllAdmins(): Promise<AdminUser[]> {
const query = ` 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 FROM admin_users
ORDER BY created_at DESC ORDER BY created_at DESC
`; `;
@@ -68,7 +88,7 @@ export class AdminRepository {
async getActiveAdmins(): Promise<AdminUser[]> { async getActiveAdmins(): Promise<AdminUser[]> {
const query = ` 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 FROM admin_users
WHERE revoked_at IS NULL WHERE revoked_at IS NULL
ORDER BY created_at DESC 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 = ` 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) 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 { 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) { if (result.rows.length === 0) {
throw new Error('Failed to create admin user'); throw new Error('Failed to create admin user');
} }
return this.mapRowToAdminUser(result.rows[0]); return this.mapRowToAdminUser(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error creating admin', { error, auth0Sub, email }); logger.error('Error creating admin', { error, userProfileId, email });
throw error; throw error;
} }
} }
async revokeAdmin(auth0Sub: string): Promise<AdminUser> { async revokeAdmin(id: string): Promise<AdminUser> {
const query = ` const query = `
UPDATE admin_users UPDATE admin_users
SET revoked_at = CURRENT_TIMESTAMP SET revoked_at = CURRENT_TIMESTAMP
WHERE auth0_sub = $1 WHERE id = $1
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 { try {
const result = await this.pool.query(query, [auth0Sub]); const result = await this.pool.query(query, [id]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
throw new Error('Admin user not found'); throw new Error('Admin user not found');
} }
return this.mapRowToAdminUser(result.rows[0]); return this.mapRowToAdminUser(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error revoking admin', { error, auth0Sub }); logger.error('Error revoking admin', { error, id });
throw error; throw error;
} }
} }
async reinstateAdmin(auth0Sub: string): Promise<AdminUser> { async reinstateAdmin(id: string): Promise<AdminUser> {
const query = ` const query = `
UPDATE admin_users UPDATE admin_users
SET revoked_at = NULL SET revoked_at = NULL
WHERE auth0_sub = $1 WHERE id = $1
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 { try {
const result = await this.pool.query(query, [auth0Sub]); const result = await this.pool.query(query, [id]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
throw new Error('Admin user not found'); throw new Error('Admin user not found');
} }
return this.mapRowToAdminUser(result.rows[0]); return this.mapRowToAdminUser(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error reinstating admin', { error, auth0Sub }); logger.error('Error reinstating admin', { error, id });
throw error; 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 { private mapRowToAdminUser(row: any): AdminUser {
return { return {
auth0Sub: row.auth0_sub, id: row.id,
userProfileId: row.user_profile_id,
email: row.email, email: row.email,
role: row.role, role: row.role,
createdAt: new Date(row.created_at), createdAt: new Date(row.created_at),

View File

@@ -11,11 +11,20 @@ import { auditLogService } from '../../audit-log';
export class AdminService { export class AdminService {
constructor(private repository: AdminRepository) {} constructor(private repository: AdminRepository) {}
async getAdminByAuth0Sub(auth0Sub: string): Promise<AdminUser | null> { async getAdminById(id: string): Promise<AdminUser | null> {
try { try {
return await this.repository.getAdminByAuth0Sub(auth0Sub); return await this.repository.getAdminById(id);
} catch (error) { } 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; 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 { try {
// Check if admin already exists // Check if admin already exists
const normalizedEmail = email.trim().toLowerCase(); const normalizedEmail = email.trim().toLowerCase();
@@ -57,10 +66,10 @@ export class AdminService {
} }
// Create new admin // 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) // 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, email,
role role
}); });
@@ -68,10 +77,10 @@ export class AdminService {
// Log to unified audit log // Log to unified audit log
await auditLogService.info( await auditLogService.info(
'admin', 'admin',
createdBy, userProfileId,
`Admin user created: ${admin.email}`, `Admin user created: ${admin.email}`,
'admin_user', 'admin_user',
admin.auth0Sub, admin.id,
{ email: admin.email, role } { email: admin.email, role }
).catch(err => logger.error('Failed to log admin create audit event', { error: err })); ).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 { try {
// Check that at least one active admin will remain // Check that at least one active admin will remain
const activeAdmins = await this.repository.getActiveAdmins(); const activeAdmins = await this.repository.getActiveAdmins();
@@ -92,51 +101,51 @@ export class AdminService {
} }
// Revoke the admin // Revoke the admin
const admin = await this.repository.revokeAdmin(auth0Sub); const admin = await this.repository.revokeAdmin(id);
// Log audit action (legacy) // 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 // Log to unified audit log
await auditLogService.info( await auditLogService.info(
'admin', 'admin',
revokedBy, admin.userProfileId,
`Admin user revoked: ${admin.email}`, `Admin user revoked: ${admin.email}`,
'admin_user', 'admin_user',
auth0Sub, id,
{ email: admin.email } { email: admin.email }
).catch(err => logger.error('Failed to log admin revoke audit event', { error: err })); ).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; return admin;
} catch (error) { } catch (error) {
logger.error('Error revoking admin', { error, auth0Sub }); logger.error('Error revoking admin', { error, id });
throw error; throw error;
} }
} }
async reinstateAdmin(auth0Sub: string, reinstatedBy: string): Promise<AdminUser> { async reinstateAdmin(id: string, reinstatedByAdminId: string): Promise<AdminUser> {
try { try {
// Reinstate the admin // Reinstate the admin
const admin = await this.repository.reinstateAdmin(auth0Sub); const admin = await this.repository.reinstateAdmin(id);
// Log audit action (legacy) // 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 // Log to unified audit log
await auditLogService.info( await auditLogService.info(
'admin', 'admin',
reinstatedBy, admin.userProfileId,
`Admin user reinstated: ${admin.email}`, `Admin user reinstated: ${admin.email}`,
'admin_user', 'admin_user',
auth0Sub, id,
{ email: admin.email } { email: admin.email }
).catch(err => logger.error('Failed to log admin reinstate audit event', { error: err })); ).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; return admin;
} catch (error) { } catch (error) {
logger.error('Error reinstating admin', { error, auth0Sub }); logger.error('Error reinstating admin', { error, id });
throw error; 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 { export interface AdminUser {
auth0Sub: string; id: string;
userProfileId: string;
email: string; email: string;
role: 'admin' | 'super_admin'; role: 'admin' | 'super_admin';
createdAt: Date; createdAt: Date;
@@ -19,11 +20,11 @@ export interface CreateAdminRequest {
} }
export interface RevokeAdminRequest { export interface RevokeAdminRequest {
auth0Sub: string; id: string;
} }
export interface ReinstateAdminRequest { export interface ReinstateAdminRequest {
auth0Sub: string; id: string;
} }
export interface AdminAuditLog { export interface AdminAuditLog {
@@ -71,25 +72,25 @@ export interface BulkCreateAdminResponse {
} }
export interface BulkRevokeAdminRequest { export interface BulkRevokeAdminRequest {
auth0Subs: string[]; ids: string[];
} }
export interface BulkRevokeAdminResponse { export interface BulkRevokeAdminResponse {
revoked: AdminUser[]; revoked: AdminUser[];
failed: Array<{ failed: Array<{
auth0Sub: string; id: string;
error: string; error: string;
}>; }>;
} }
export interface BulkReinstateAdminRequest { export interface BulkReinstateAdminRequest {
auth0Subs: string[]; ids: string[];
} }
export interface BulkReinstateAdminResponse { export interface BulkReinstateAdminResponse {
reinstated: AdminUser[]; reinstated: AdminUser[];
failed: Array<{ failed: Array<{
auth0Sub: string; id: string;
error: string; error: string;
}>; }>;
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -126,7 +126,7 @@ export class AuditLogRepository {
al.resource_type, al.resource_id, al.details, al.created_at, al.resource_type, al.resource_id, al.details, al.created_at,
up.email as user_email up.email as user_email
FROM audit_logs al 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} ${whereClause}
ORDER BY al.created_at DESC ORDER BY al.created_at DESC
LIMIT $${nextParamIndex} OFFSET $${nextParamIndex + 1} LIMIT $${nextParamIndex} OFFSET $${nextParamIndex + 1}
@@ -170,7 +170,7 @@ export class AuditLogRepository {
al.resource_type, al.resource_id, al.details, al.created_at, al.resource_type, al.resource_id, al.details, al.created_at,
up.email as user_email up.email as user_email
FROM audit_logs al 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} ${whereClause}
ORDER BY al.created_at DESC ORDER BY al.created_at DESC
LIMIT ${MAX_EXPORT_RECORDS} LIMIT ${MAX_EXPORT_RECORDS}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,22 +27,22 @@ export class EmailIngestionController {
async getPendingAssociations(request: FastifyRequest, reply: FastifyReply): Promise<void> { async getPendingAssociations(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try { try {
const userId = (request as any).user.sub; const userId = request.userContext!.userId;
const associations = await this.repository.getPendingAssociations(userId); const associations = await this.repository.getPendingAssociations(userId);
return reply.code(200).send(associations); return reply.code(200).send(associations);
} catch (error: any) { } catch (error: any) {
logger.error('Error listing pending associations', { error: error.message, userId: (request as any).user?.sub }); logger.error('Error listing pending associations', { error: error.message, userId: request.userContext?.userId });
return reply.code(500).send({ error: 'Failed to list pending associations' }); return reply.code(500).send({ error: 'Failed to list pending associations' });
} }
} }
async getPendingAssociationCount(request: FastifyRequest, reply: FastifyReply): Promise<void> { async getPendingAssociationCount(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try { try {
const userId = (request as any).user.sub; const userId = request.userContext!.userId;
const count = await this.repository.getPendingAssociationCount(userId); const count = await this.repository.getPendingAssociationCount(userId);
return reply.code(200).send({ count }); return reply.code(200).send({ count });
} catch (error: any) { } catch (error: any) {
logger.error('Error counting pending associations', { error: error.message, userId: (request as any).user?.sub }); logger.error('Error counting pending associations', { error: error.message, userId: request.userContext?.userId });
return reply.code(500).send({ error: 'Failed to count pending associations' }); return reply.code(500).send({ error: 'Failed to count pending associations' });
} }
} }
@@ -52,7 +52,7 @@ export class EmailIngestionController {
reply: FastifyReply reply: FastifyReply
): Promise<void> { ): Promise<void> {
try { try {
const userId = (request as any).user.sub; const userId = request.userContext!.userId;
const { id } = request.params; const { id } = request.params;
const { vehicleId } = request.body; const { vehicleId } = request.body;
@@ -63,7 +63,7 @@ export class EmailIngestionController {
const result = await this.service.resolveAssociation(id, vehicleId, userId); const result = await this.service.resolveAssociation(id, vehicleId, userId);
return reply.code(200).send(result); return reply.code(200).send(result);
} catch (error: any) { } catch (error: any) {
const userId = (request as any).user?.sub; const userId = request.userContext?.userId;
logger.error('Error resolving pending association', { logger.error('Error resolving pending association', {
error: error.message, error: error.message,
associationId: request.params.id, associationId: request.params.id,
@@ -89,13 +89,13 @@ export class EmailIngestionController {
reply: FastifyReply reply: FastifyReply
): Promise<void> { ): Promise<void> {
try { try {
const userId = (request as any).user.sub; const userId = request.userContext!.userId;
const { id } = request.params; const { id } = request.params;
await this.service.dismissAssociation(id, userId); await this.service.dismissAssociation(id, userId);
return reply.code(204).send(); return reply.code(204).send();
} catch (error: any) { } catch (error: any) {
const userId = (request as any).user?.sub; const userId = request.userContext?.userId;
logger.error('Error dismissing pending association', { logger.error('Error dismissing pending association', {
error: error.message, error: error.message,
associationId: request.params.id, associationId: request.params.id,

View File

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

View File

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

View File

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

View File

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

View File

@@ -97,7 +97,7 @@ Templates use `{{variableName}}` syntax for variable substitution.
### Environment Variables ### Environment Variables
- `RESEND_API_KEY` - Resend API key (required, stored in secrets) - `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 ### Email Delivery
- Uses Resend API for transactional emails - Uses Resend API for transactional emails

View File

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

View File

@@ -7,7 +7,7 @@
import { EMAIL_STYLES } from './email-styles'; import { EMAIL_STYLES } from './email-styles';
// External logo URL - hosted on GitHub for reliability // 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 * 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> <a href="https://motovaultpro.com" style="${EMAIL_STYLES.footerLink}" target="_blank">Login to MotoVaultPro</a>
</p> </p>
<p style="${EMAIL_STYLES.footerText}"> <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>
<p style="${EMAIL_STYLES.copyright}"> <p style="${EMAIL_STYLES.copyright}">
&copy; {new Date().getFullYear()} MotoVaultPro. All rights reserved. &copy; ${new Date().getFullYear()} MotoVaultPro. All rights reserved.
</p> </p>
</td> </td>
</tr> </tr>

View File

@@ -16,7 +16,7 @@ export class EmailService {
} }
this.resend = new Resend(apiKey); 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, to,
subject, subject,
html, html,
headers: {
'List-Unsubscribe': '<https://motovaultpro.com/settings>',
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
},
}); });
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage = error instanceof Error ? error.message : 'Unknown error';

View File

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

View File

@@ -33,7 +33,7 @@ export class OcrController {
request: FastifyRequest<{ Querystring: ExtractQuery }>, request: FastifyRequest<{ Querystring: ExtractQuery }>,
reply: FastifyReply reply: FastifyReply
) { ) {
const userId = (request as any).user?.sub as string; const userId = request.userContext?.userId as string;
const preprocess = request.query.preprocess !== false; const preprocess = request.query.preprocess !== false;
logger.info('OCR extract requested', { logger.info('OCR extract requested', {
@@ -140,7 +140,7 @@ export class OcrController {
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply reply: FastifyReply
) { ) {
const userId = (request as any).user?.sub as string; const userId = request.userContext?.userId as string;
logger.info('VIN extract requested', { logger.info('VIN extract requested', {
operation: 'ocr.controller.extractVin', operation: 'ocr.controller.extractVin',
@@ -240,7 +240,7 @@ export class OcrController {
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply reply: FastifyReply
) { ) {
const userId = (request as any).user?.sub as string; const userId = request.userContext?.userId as string;
logger.info('Receipt extract requested', { logger.info('Receipt extract requested', {
operation: 'ocr.controller.extractReceipt', operation: 'ocr.controller.extractReceipt',
@@ -352,7 +352,7 @@ export class OcrController {
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply reply: FastifyReply
) { ) {
const userId = (request as any).user?.sub as string; const userId = request.userContext?.userId as string;
logger.info('Maintenance receipt extract requested', { logger.info('Maintenance receipt extract requested', {
operation: 'ocr.controller.extractMaintenanceReceipt', operation: 'ocr.controller.extractMaintenanceReceipt',
@@ -460,7 +460,7 @@ export class OcrController {
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply reply: FastifyReply
) { ) {
const userId = (request as any).user?.sub as string; const userId = request.userContext?.userId as string;
logger.info('Manual extract requested', { logger.info('Manual extract requested', {
operation: 'ocr.controller.extractManual', operation: 'ocr.controller.extractManual',
@@ -584,7 +584,7 @@ export class OcrController {
request: FastifyRequest<{ Body: JobSubmitBody }>, request: FastifyRequest<{ Body: JobSubmitBody }>,
reply: FastifyReply reply: FastifyReply
) { ) {
const userId = (request as any).user?.sub as string; const userId = request.userContext?.userId as string;
logger.info('OCR job submit requested', { logger.info('OCR job submit requested', {
operation: 'ocr.controller.submitJob', operation: 'ocr.controller.submitJob',
@@ -691,7 +691,7 @@ export class OcrController {
request: FastifyRequest<{ Params: JobIdParams }>, request: FastifyRequest<{ Params: JobIdParams }>,
reply: FastifyReply reply: FastifyReply
) { ) {
const userId = (request as any).user?.sub as string; const userId = request.userContext?.userId as string;
const { jobId } = request.params; const { jobId } = request.params;
logger.debug('OCR job status requested', { logger.debug('OCR job status requested', {

View File

@@ -131,3 +131,21 @@ export interface ManualJobResponse {
result?: ManualExtractionResult; result?: ManualExtractionResult;
error?: string; error?: string;
} }
/** Response from VIN decode via Gemini (OCR service) */
export interface VinDecodeResponse {
success: boolean;
vin: string;
year: number | null;
make: string | null;
model: string | null;
trimLevel: string | null;
bodyType: string | null;
driveType: string | null;
fuelType: string | null;
engine: string | null;
transmission: string | null;
confidence: number;
processingTimeMs: number;
error: string | null;
}

View File

@@ -2,7 +2,7 @@
* @ai-summary HTTP client for OCR service communication * @ai-summary HTTP client for OCR service communication
*/ */
import { logger } from '../../../core/logging/logger'; import { logger } from '../../../core/logging/logger';
import type { JobResponse, ManualJobResponse, OcrResponse, ReceiptExtractionResponse, VinExtractionResponse } from '../domain/ocr.types'; import type { JobResponse, ManualJobResponse, OcrResponse, ReceiptExtractionResponse, VinDecodeResponse, VinExtractionResponse } from '../domain/ocr.types';
/** OCR service configuration */ /** OCR service configuration */
const OCR_SERVICE_URL = process.env.OCR_SERVICE_URL || 'http://mvp-ocr:8000'; const OCR_SERVICE_URL = process.env.OCR_SERVICE_URL || 'http://mvp-ocr:8000';
@@ -373,6 +373,55 @@ export class OcrClient {
return result; 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. * Check if the OCR service is healthy.
* *

View File

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

View File

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

View File

@@ -117,7 +117,7 @@ platform/
When implemented, VIN decoding will use: When implemented, VIN decoding will use:
1. **Cache First**: Check Redis (7-day TTL for success, 1-hour for failures) 1. **Cache First**: Check Redis (7-day TTL for success, 1-hour for failures)
2. **PostgreSQL**: Database function for high-confidence decode 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 4. **Graceful Degradation**: Return meaningful errors when all sources fail
### Database Schema ### Database Schema
@@ -164,7 +164,7 @@ When VIN decoding is implemented:
### External APIs (Planned/Future) ### External APIs (Planned/Future)
When VIN decoding is implemented: 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 ### Database Tables
- **vehicle_options** - Hierarchical vehicle data (years, makes, models, trims, engines, transmissions) - **vehicle_options** - Hierarchical vehicle data (years, makes, models, trims, engines, transmissions)
@@ -269,7 +269,7 @@ npm run lint
## Future Considerations ## Future Considerations
### Planned Features ### 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 - Circuit breaker pattern for external API resilience
### Potential Enhancements ### Potential Enhancements

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ export class SubscriptionsRepository {
/** /**
* Create a new subscription * Create a new subscription
*/ */
async create(data: CreateSubscriptionRequest & { stripeCustomerId: string }): Promise<Subscription> { async create(data: CreateSubscriptionRequest & { stripeCustomerId?: string | null }): Promise<Subscription> {
const query = ` const query = `
INSERT INTO subscriptions ( INSERT INTO subscriptions (
user_id, stripe_customer_id, tier, billing_cycle user_id, stripe_customer_id, tier, billing_cycle
@@ -38,7 +38,7 @@ export class SubscriptionsRepository {
const values = [ const values = [
data.userId, data.userId,
data.stripeCustomerId, data.stripeCustomerId ?? null,
data.tier, data.tier,
data.billingCycle, data.billingCycle,
]; ];
@@ -146,6 +146,10 @@ export class SubscriptionsRepository {
const values = []; const values = [];
let paramCount = 1; let paramCount = 1;
if (data.stripeCustomerId !== undefined) {
fields.push(`stripe_customer_id = $${paramCount++}`);
values.push(data.stripeCustomerId);
}
if (data.stripeSubscriptionId !== undefined) { if (data.stripeSubscriptionId !== undefined) {
fields.push(`stripe_subscription_id = $${paramCount++}`); fields.push(`stripe_subscription_id = $${paramCount++}`);
values.push(data.stripeSubscriptionId); values.push(data.stripeSubscriptionId);
@@ -575,18 +579,16 @@ export class SubscriptionsRepository {
client?: any client?: any
): Promise<Subscription> { ): Promise<Subscription> {
const queryClient = client || this.pool; 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 = ` const query = `
INSERT INTO subscriptions ( INSERT INTO subscriptions (
user_id, stripe_customer_id, tier, billing_cycle, status user_id, stripe_customer_id, tier, billing_cycle, status
) )
VALUES ($1, $2, $3, 'monthly', 'active') VALUES ($1, NULL, $2, 'monthly', 'active')
RETURNING * RETURNING *
`; `;
const values = [userId, placeholderCustomerId, tier]; const values = [userId, tier];
try { try {
const result = await queryClient.query(query, values); const result = await queryClient.query(query, values);
@@ -619,7 +621,7 @@ export class SubscriptionsRepository {
return { return {
id: row.id, id: row.id,
userId: row.user_id, userId: row.user_id,
stripeCustomerId: row.stripe_customer_id, stripeCustomerId: row.stripe_customer_id ?? null,
stripeSubscriptionId: row.stripe_subscription_id || undefined, stripeSubscriptionId: row.stripe_subscription_id || undefined,
tier: row.tier, tier: row.tier,
billingCycle: row.billing_cycle || undefined, 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 * Upgrade from current tier to new tier
*/ */
@@ -172,7 +211,8 @@ export class SubscriptionsService {
userId: string, userId: string,
newTier: 'pro' | 'enterprise', newTier: 'pro' | 'enterprise',
billingCycle: 'monthly' | 'yearly', billingCycle: 'monthly' | 'yearly',
paymentMethodId: string paymentMethodId: string,
email: string
): Promise<Subscription> { ): Promise<Subscription> {
try { try {
logger.info('Upgrading subscription', { userId, newTier, billingCycle }); logger.info('Upgrading subscription', { userId, newTier, billingCycle });
@@ -183,12 +223,15 @@ export class SubscriptionsService {
throw new Error('No subscription found for user'); 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 // Determine price ID from environment variables
const priceId = this.getPriceId(newTier, billingCycle); const priceId = this.getPriceId(newTier, billingCycle);
// Create or update Stripe subscription // Create or update Stripe subscription
const stripeSubscription = await this.stripeClient.createSubscription( const stripeSubscription = await this.stripeClient.createSubscription(
currentSubscription.stripeCustomerId, stripeCustomerId,
priceId, priceId,
paymentMethodId paymentMethodId
); );
@@ -256,6 +299,10 @@ export class SubscriptionsService {
throw new Error('No subscription found for user'); throw new Error('No subscription found for user');
} }
if (!currentSubscription.stripeCustomerId) {
throw new Error('Cannot cancel subscription without active Stripe billing');
}
if (!currentSubscription.stripeSubscriptionId) { if (!currentSubscription.stripeSubscriptionId) {
throw new Error('No active Stripe subscription to cancel'); throw new Error('No active Stripe subscription to cancel');
} }
@@ -303,6 +350,10 @@ export class SubscriptionsService {
throw new Error('No subscription found for user'); throw new Error('No subscription found for user');
} }
if (!currentSubscription.stripeCustomerId) {
throw new Error('Cannot reactivate subscription without active Stripe billing');
}
if (!currentSubscription.stripeSubscriptionId) { if (!currentSubscription.stripeSubscriptionId) {
throw new Error('No active Stripe subscription to reactivate'); throw new Error('No active Stripe subscription to reactivate');
} }
@@ -519,11 +570,13 @@ export class SubscriptionsService {
} }
// Update subscription with Stripe subscription ID // 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, { await this.repository.update(subscription.id, {
stripeSubscriptionId: stripeSubscription.id, stripeSubscriptionId: stripeSubscription.id,
status: this.mapStripeStatus(stripeSubscription.status), status: this.mapStripeStatus(stripeSubscription.status),
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000), currentPeriodStart: new Date((item?.current_period_start ?? 0) * 1000),
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000), currentPeriodEnd: new Date((item?.current_period_end ?? 0) * 1000),
}); });
// Log event // Log event
@@ -557,11 +610,13 @@ export class SubscriptionsService {
const tier = this.determineTierFromStripeSubscription(stripeSubscription); const tier = this.determineTierFromStripeSubscription(stripeSubscription);
// Update subscription // Update subscription
// Period dates moved from subscription to items in API 2025-03-31.basil
const item = stripeSubscription.items?.data?.[0];
const updateData: UpdateSubscriptionData = { const updateData: UpdateSubscriptionData = {
status: this.mapStripeStatus(stripeSubscription.status), status: this.mapStripeStatus(stripeSubscription.status),
tier, tier,
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000), currentPeriodStart: new Date((item?.current_period_start ?? 0) * 1000),
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000), currentPeriodEnd: new Date((item?.current_period_end ?? 0) * 1000),
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end || false, cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end || false,
}; };
@@ -731,7 +786,7 @@ export class SubscriptionsService {
): Promise<void> { ): Promise<void> {
try { try {
// Get user profile for email and name // Get user profile for email and name
const userProfile = await this.userProfileRepository.getByAuth0Sub(userId); const userProfile = await this.userProfileRepository.getById(userId);
if (!userProfile) { if (!userProfile) {
logger.warn('User profile not found for tier change notification', { userId }); logger.warn('User profile not found for tier change notification', { userId });
return; return;
@@ -766,17 +821,8 @@ export class SubscriptionsService {
* Sync subscription tier to user_profiles table * Sync subscription tier to user_profiles table
*/ */
private async syncTierToUserProfile(userId: string, tier: SubscriptionTier): Promise<void> { private async syncTierToUserProfile(userId: string, tier: SubscriptionTier): Promise<void> {
try {
await this.userProfileRepository.updateSubscriptionTier(userId, tier); await this.userProfileRepository.updateSubscriptionTier(userId, tier);
logger.info('Subscription tier synced to user profile', { 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
}
} }
/** /**
@@ -807,6 +853,7 @@ export class SubscriptionsService {
switch (stripeStatus) { switch (stripeStatus) {
case 'active': case 'active':
case 'trialing': case 'trialing':
case 'incomplete':
return 'active'; return 'active';
case 'past_due': case 'past_due':
return 'past_due'; return 'past_due';
@@ -889,7 +936,7 @@ export class SubscriptionsService {
// Sync tier to user_profiles table (within same transaction) // Sync tier to user_profiles table (within same transaction)
await client.query( 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] [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 * Get invoices for a user's subscription
*/ */

View File

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

View File

@@ -75,10 +75,18 @@ export class StripeClient {
try { try {
logger.info('Creating Stripe subscription', { customerId, priceId, paymentMethodId }); 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 = { const subscriptionParams: Stripe.SubscriptionCreateParams = {
customer: customerId, customer: customerId,
items: [{ price: priceId }], items: [{ price: priceId }],
payment_behavior: 'default_incomplete', payment_behavior: 'error_if_incomplete',
payment_settings: { payment_settings: {
save_default_payment_method: 'on_subscription', save_default_payment_method: 'on_subscription',
}, },
@@ -93,13 +101,16 @@ export class StripeClient {
logger.info('Stripe subscription created', { subscriptionId: subscription.id }); 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 { return {
id: subscription.id, id: subscription.id,
customer: subscription.customer as string, customer: subscription.customer as string,
status: subscription.status as StripeSubscription['status'], status: subscription.status as StripeSubscription['status'],
items: subscription.items, items: subscription.items,
currentPeriodStart: (subscription as any).current_period_start || 0, currentPeriodStart: item?.current_period_start ?? 0,
currentPeriodEnd: (subscription as any).current_period_end || 0, currentPeriodEnd: item?.current_period_end ?? 0,
cancelAtPeriodEnd: subscription.cancel_at_period_end, cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at || undefined, canceledAt: subscription.canceled_at || undefined,
created: subscription.created, created: subscription.created,
@@ -140,13 +151,15 @@ export class StripeClient {
logger.info('Stripe subscription canceled immediately', { subscriptionId }); logger.info('Stripe subscription canceled immediately', { subscriptionId });
} }
const item = subscription.items?.data?.[0];
return { return {
id: subscription.id, id: subscription.id,
customer: subscription.customer as string, customer: subscription.customer as string,
status: subscription.status as StripeSubscription['status'], status: subscription.status as StripeSubscription['status'],
items: subscription.items, items: subscription.items,
currentPeriodStart: (subscription as any).current_period_start || 0, currentPeriodStart: item?.current_period_start ?? 0,
currentPeriodEnd: (subscription as any).current_period_end || 0, currentPeriodEnd: item?.current_period_end ?? 0,
cancelAtPeriodEnd: subscription.cancel_at_period_end, cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at || undefined, canceledAt: subscription.canceled_at || undefined,
created: subscription.created, 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 * Retrieve a subscription by ID
*/ */
@@ -268,14 +299,15 @@ export class StripeClient {
logger.info('Retrieving Stripe subscription', { subscriptionId }); logger.info('Retrieving Stripe subscription', { subscriptionId });
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
const item = subscription.items?.data?.[0];
return { return {
id: subscription.id, id: subscription.id,
customer: subscription.customer as string, customer: subscription.customer as string,
status: subscription.status as StripeSubscription['status'], status: subscription.status as StripeSubscription['status'],
items: subscription.items, items: subscription.items,
currentPeriodStart: (subscription as any).current_period_start || 0, currentPeriodStart: item?.current_period_start ?? 0,
currentPeriodEnd: (subscription as any).current_period_end || 0, currentPeriodEnd: item?.current_period_end ?? 0,
cancelAtPeriodEnd: subscription.cancel_at_period_end, cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at || undefined, canceledAt: subscription.canceled_at || undefined,
created: subscription.created, created: subscription.created,

View File

@@ -50,7 +50,7 @@ export async function processGracePeriodExpirations(): Promise<GracePeriodResult
up.notification_email, up.notification_email,
up.display_name up.display_name
FROM subscriptions s 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' WHERE s.status = 'past_due'
AND s.grace_period_end < NOW() AND s.grace_period_end < NOW()
ORDER BY s.grace_period_end ASC ORDER BY s.grace_period_end ASC
@@ -89,13 +89,13 @@ export async function processGracePeriodExpirations(): Promise<GracePeriodResult
await client.query(updateQuery, [subscription.id]); 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 = ` const syncQuery = `
UPDATE user_profiles UPDATE user_profiles
SET SET
subscription_tier = 'free', subscription_tier = 'free',
updated_at = NOW() updated_at = NOW()
WHERE auth0_sub = $1 WHERE id = $1
`; `;
await client.query(syncQuery, [subscription.user_id]); 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> { 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 }); logger.info('User export requested', { userId });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
/** /**
* @ai-summary Business logic for vehicles feature * @ai-summary Business logic for vehicles feature
* @ai-context Handles VIN decoding, caching, and business rules * @ai-context Handles VIN decoding and business rules
*/ */
import { Pool } from 'pg'; import { Pool } from 'pg';
@@ -24,7 +24,8 @@ import { isValidVIN, isValidPreModernVIN } from '../../../shared-minimal/utils/v
import { normalizeMakeName, normalizeModelName } from './name-normalizer'; import { normalizeMakeName, normalizeModelName } from './name-normalizer';
import { getVehicleDataService, getPool } from '../../platform'; import { getVehicleDataService, getPool } from '../../platform';
import { auditLogService } from '../../audit-log'; import { auditLogService } from '../../audit-log';
import { NHTSAClient, NHTSADecodeResponse, DecodedVehicleData, MatchedField } from '../external/nhtsa'; import type { VinDecodeResponse } from '../../ocr/domain/ocr.types';
import type { DecodedVehicleData, MatchedField } from './vehicles.types';
import { canAddVehicle, getVehicleLimitConfig } from '../../../core/config/feature-tiers'; import { canAddVehicle, getVehicleLimitConfig } from '../../../core/config/feature-tiers';
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository'; import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
import { SubscriptionTier } from '../../user-profile/domain/user-profile.types'; import { SubscriptionTier } from '../../user-profile/domain/user-profile.types';
@@ -82,7 +83,7 @@ export class VehiclesService {
} }
// Get user's tier for limit enforcement // Get user's tier for limit enforcement
const userProfile = await this.userProfileRepository.getByAuth0Sub(userId); const userProfile = await this.userProfileRepository.getById(userId);
if (!userProfile) { if (!userProfile) {
throw new Error('User profile not found'); throw new Error('User profile not found');
} }
@@ -227,7 +228,7 @@ export class VehiclesService {
*/ */
async getUserVehiclesWithTierStatus(userId: string): Promise<Array<VehicleResponse & { tierStatus: 'active' | 'locked' }>> { async getUserVehiclesWithTierStatus(userId: string): Promise<Array<VehicleResponse & { tierStatus: 'active' | 'locked' }>> {
// Get user's subscription tier // Get user's subscription tier
const userProfile = await this.userProfileRepository.getByAuth0Sub(userId); const userProfile = await this.userProfileRepository.getById(userId);
if (!userProfile) { if (!userProfile) {
throw new Error('User profile not found'); throw new Error('User profile not found');
} }
@@ -657,82 +658,89 @@ export class VehiclesService {
} }
/** /**
* Map NHTSA decode response to internal decoded vehicle data format * Map VIN decode response to internal decoded vehicle data format
* with dropdown matching and confidence levels * with dropdown matching and confidence levels
*/ */
async mapNHTSAResponse(response: NHTSADecodeResponse): Promise<DecodedVehicleData> { async mapVinDecodeResponse(response: VinDecodeResponse): Promise<DecodedVehicleData> {
const vehicleDataService = getVehicleDataService(); const vehicleDataService = getVehicleDataService();
const pool = getPool(); const pool = getPool();
// Extract raw values from NHTSA response // Read flat fields directly from Gemini response
const nhtsaYear = NHTSAClient.extractYear(response); const sourceYear = response.year;
const nhtsaMake = NHTSAClient.extractValue(response, 'Make'); const sourceMake = response.make;
const nhtsaModel = NHTSAClient.extractValue(response, 'Model'); const sourceModel = response.model;
const nhtsaTrim = NHTSAClient.extractValue(response, 'Trim'); const sourceTrim = response.trimLevel;
const nhtsaBodyType = NHTSAClient.extractValue(response, 'Body Class'); const sourceBodyType = response.bodyType;
const nhtsaDriveType = NHTSAClient.extractValue(response, 'Drive Type'); const sourceDriveType = response.driveType;
const nhtsaFuelType = NHTSAClient.extractValue(response, 'Fuel Type - Primary'); const sourceFuelType = response.fuelType;
const nhtsaEngine = NHTSAClient.extractEngine(response); const sourceEngine = response.engine;
const nhtsaTransmission = NHTSAClient.extractValue(response, 'Transmission Style'); const sourceTransmission = response.transmission;
logger.debug('VIN decode raw values', {
vin: response.vin,
year: sourceYear, make: sourceMake, model: sourceModel,
trim: sourceTrim, engine: sourceEngine, transmission: sourceTransmission,
confidence: response.confidence
});
// Year is always high confidence if present (exact numeric match) // Year is always high confidence if present (exact numeric match)
const year: MatchedField<number> = { const year: MatchedField<number> = {
value: nhtsaYear, value: sourceYear,
nhtsaValue: nhtsaYear?.toString() || null, sourceValue: sourceYear?.toString() || null,
confidence: nhtsaYear ? 'high' : 'none' confidence: sourceYear ? 'high' : 'none'
}; };
// Match make against dropdown options // Match make against dropdown options
let make: MatchedField<string> = { value: null, nhtsaValue: nhtsaMake, confidence: 'none' }; let make: MatchedField<string> = { value: null, sourceValue: sourceMake, confidence: 'none' };
if (nhtsaYear && nhtsaMake) { if (sourceYear && sourceMake) {
const makes = await vehicleDataService.getMakes(pool, nhtsaYear); const makes = await vehicleDataService.getMakes(pool, sourceYear);
make = this.matchField(nhtsaMake, makes); make = this.matchField(sourceMake, makes);
} }
// Match model against dropdown options // Match model against dropdown options
let model: MatchedField<string> = { value: null, nhtsaValue: nhtsaModel, confidence: 'none' }; let model: MatchedField<string> = { value: null, sourceValue: sourceModel, confidence: 'none' };
if (nhtsaYear && make.value && nhtsaModel) { if (sourceYear && make.value && sourceModel) {
const models = await vehicleDataService.getModels(pool, nhtsaYear, make.value); const models = await vehicleDataService.getModels(pool, sourceYear, make.value);
model = this.matchField(nhtsaModel, models); model = this.matchField(sourceModel, models);
} }
// Match trim against dropdown options // Match trim against dropdown options
let trimLevel: MatchedField<string> = { value: null, nhtsaValue: nhtsaTrim, confidence: 'none' }; let trimLevel: MatchedField<string> = { value: null, sourceValue: sourceTrim, confidence: 'none' };
if (nhtsaYear && make.value && model.value && nhtsaTrim) { if (sourceYear && make.value && model.value && sourceTrim) {
const trims = await vehicleDataService.getTrims(pool, nhtsaYear, make.value, model.value); const trims = await vehicleDataService.getTrims(pool, sourceYear, make.value, model.value);
trimLevel = this.matchField(nhtsaTrim, trims); trimLevel = this.matchField(sourceTrim, trims);
} }
// Match engine against dropdown options // Match engine against dropdown options
let engine: MatchedField<string> = { value: null, nhtsaValue: nhtsaEngine, confidence: 'none' }; let engine: MatchedField<string> = { value: null, sourceValue: sourceEngine, confidence: 'none' };
if (nhtsaYear && make.value && model.value && trimLevel.value && nhtsaEngine) { if (sourceYear && make.value && model.value && trimLevel.value && sourceEngine) {
const engines = await vehicleDataService.getEngines(pool, nhtsaYear, make.value, model.value, trimLevel.value); const engines = await vehicleDataService.getEngines(pool, sourceYear, make.value, model.value, trimLevel.value);
engine = this.matchField(nhtsaEngine, engines); engine = this.matchField(sourceEngine, engines);
} }
// Match transmission against dropdown options // Match transmission against dropdown options
let transmission: MatchedField<string> = { value: null, nhtsaValue: nhtsaTransmission, confidence: 'none' }; let transmission: MatchedField<string> = { value: null, sourceValue: sourceTransmission, confidence: 'none' };
if (nhtsaYear && make.value && model.value && trimLevel.value && nhtsaTransmission) { if (sourceYear && make.value && model.value && trimLevel.value && sourceTransmission) {
const transmissions = await vehicleDataService.getTransmissionsForTrim(pool, nhtsaYear, make.value, model.value, trimLevel.value); const transmissions = await vehicleDataService.getTransmissionsForTrim(pool, sourceYear, make.value, model.value, trimLevel.value);
transmission = this.matchField(nhtsaTransmission, transmissions); transmission = this.matchField(sourceTransmission, transmissions);
} }
// Body type, drive type, and fuel type are display-only (no dropdown matching) // Body type, drive type, and fuel type are display-only (no dropdown matching)
const bodyType: MatchedField<string> = { const bodyType: MatchedField<string> = {
value: null, value: null,
nhtsaValue: nhtsaBodyType, sourceValue: sourceBodyType,
confidence: 'none' confidence: 'none'
}; };
const driveType: MatchedField<string> = { const driveType: MatchedField<string> = {
value: null, value: null,
nhtsaValue: nhtsaDriveType, sourceValue: sourceDriveType,
confidence: 'none' confidence: 'none'
}; };
const fuelType: MatchedField<string> = { const fuelType: MatchedField<string> = {
value: null, value: null,
nhtsaValue: nhtsaFuelType, sourceValue: sourceFuelType,
confidence: 'none' confidence: 'none'
}; };
@@ -754,42 +762,62 @@ export class VehiclesService {
* Returns the matched dropdown value with confidence level * Returns the matched dropdown value with confidence level
* Matching order: exact -> normalized -> prefix -> contains * Matching order: exact -> normalized -> prefix -> contains
*/ */
private matchField(nhtsaValue: string, options: string[]): MatchedField<string> { private matchField(sourceValue: string, options: string[]): MatchedField<string> {
if (!nhtsaValue || options.length === 0) { if (!sourceValue || options.length === 0) {
return { value: null, nhtsaValue, confidence: 'none' }; return { value: null, sourceValue, confidence: 'none' };
} }
const normalizedNhtsa = nhtsaValue.toLowerCase().trim(); const normalizedSource = sourceValue.toLowerCase().trim();
// Try exact case-insensitive match // Try exact case-insensitive match
const exactMatch = options.find(opt => opt.toLowerCase().trim() === normalizedNhtsa); const exactMatch = options.find(opt => opt.toLowerCase().trim() === normalizedSource);
if (exactMatch) { if (exactMatch) {
return { value: exactMatch, nhtsaValue, confidence: 'high' }; return { value: exactMatch, sourceValue, confidence: 'high' };
} }
// Try normalized comparison (remove special chars) // Try normalized comparison (remove special chars)
const normalizeForCompare = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, ''); const normalizeForCompare = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
const normalizedNhtsaClean = normalizeForCompare(nhtsaValue); const normalizedSourceClean = normalizeForCompare(sourceValue);
const normalizedMatch = options.find(opt => normalizeForCompare(opt) === normalizedNhtsaClean); const normalizedMatch = options.find(opt => normalizeForCompare(opt) === normalizedSourceClean);
if (normalizedMatch) { if (normalizedMatch) {
return { value: normalizedMatch, nhtsaValue, confidence: 'medium' }; return { value: normalizedMatch, sourceValue, confidence: 'medium' };
} }
// Try prefix match - option starts with NHTSA value // Try prefix match - option starts with source value
const prefixMatch = options.find(opt => opt.toLowerCase().trim().startsWith(normalizedNhtsa)); const prefixMatch = options.find(opt => opt.toLowerCase().trim().startsWith(normalizedSource));
if (prefixMatch) { if (prefixMatch) {
return { value: prefixMatch, nhtsaValue, confidence: 'medium' }; return { value: prefixMatch, sourceValue, confidence: 'medium' };
} }
// Try contains match - option contains NHTSA value // Try contains match - option contains source value
const containsMatch = options.find(opt => opt.toLowerCase().trim().includes(normalizedNhtsa)); const containsMatch = options.find(opt => opt.toLowerCase().trim().includes(normalizedSource));
if (containsMatch) { if (containsMatch) {
return { value: containsMatch, nhtsaValue, confidence: 'medium' }; return { value: containsMatch, sourceValue, confidence: 'medium' };
} }
// No match found - return NHTSA value as hint with no match // Try reverse contains - source value contains option (e.g., source "X5 xDrive35i" contains option "X5")
return { value: null, nhtsaValue, confidence: 'none' }; // Prefer the longest matching option to avoid false positives (e.g., "X5 M" over "X5")
const reverseMatches = options.filter(opt => {
const normalizedOpt = opt.toLowerCase().trim();
return normalizedSource.includes(normalizedOpt) && normalizedOpt.length > 0;
});
if (reverseMatches.length > 0) {
const bestMatch = reverseMatches.reduce((a, b) => a.length >= b.length ? a : b);
return { value: bestMatch, sourceValue, confidence: 'medium' };
}
// Try word-start match - source starts with option + separator (e.g., "X5 xDrive" starts with "X5 ")
const wordStartMatch = options.find(opt => {
const normalizedOpt = opt.toLowerCase().trim();
return normalizedSource.startsWith(normalizedOpt + ' ') || normalizedSource.startsWith(normalizedOpt + '-');
});
if (wordStartMatch) {
return { value: wordStartMatch, sourceValue, confidence: 'medium' };
}
// No match found - return source value as hint with no match
return { value: null, sourceValue, confidence: 'none' };
} }
private toResponse(vehicle: Vehicle): VehicleResponse { private toResponse(vehicle: Vehicle): VehicleResponse {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,15 +36,13 @@ describe('Vehicles Integration Tests', () => {
afterAll(async () => { afterAll(async () => {
// Clean up test database // Clean up test database
await pool.query('DROP TABLE IF EXISTS vehicles CASCADE'); await pool.query('DROP TABLE IF EXISTS vehicles CASCADE');
await pool.query('DROP TABLE IF EXISTS vin_cache CASCADE');
await pool.query('DROP FUNCTION IF EXISTS update_updated_at_column() CASCADE'); await pool.query('DROP FUNCTION IF EXISTS update_updated_at_column() CASCADE');
await pool.end(); await pool.end();
}); });
beforeEach(async () => { beforeEach(async () => {
// Clean up test data before each test - more thorough cleanup // Clean up test data before each test
await pool.query('DELETE FROM vehicles WHERE user_id = $1', ['test-user-123']); await pool.query('DELETE FROM vehicles WHERE user_id = $1', ['test-user-123']);
await pool.query('DELETE FROM vin_cache WHERE vin LIKE $1', ['1HGBH41JXMN%']);
// Clear Redis cache for the test user // Clear Redis cache for the test user
try { try {

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,18 +6,14 @@
# #
# This file removes development-only configurations: # This file removes development-only configurations:
# - Database port exposure (PostgreSQL, Redis) # - Database port exposure (PostgreSQL, Redis)
# - Development-specific settings # - Traefik dashboard auth middleware
#
# Environment-specific values (log levels, Stripe IDs) are driven by .env
# generated by CI/CD from Gitea variables + scripts/ci/generate-log-config.sh
services: services:
# Traefik - Production log level and dashboard auth # Traefik - Dashboard auth middleware
mvp-traefik: mvp-traefik:
environment:
# Traefik log levels: TRACE | DEBUG | INFO | WARN | ERROR
LOG_LEVEL: error
command:
- --configFile=/etc/traefik/traefik.yml
# Traefik log levels: TRACE | DEBUG | INFO | WARN | ERROR
- --log.level=ERROR
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.traefik-dashboard.rule=Host(`traefik.motovaultpro.local`)" - "traefik.http.routers.traefik-dashboard.rule=Host(`traefik.motovaultpro.local`)"
@@ -26,58 +22,10 @@ services:
- "traefik.http.services.traefik-dashboard.loadbalancer.server.port=8080" - "traefik.http.services.traefik-dashboard.loadbalancer.server.port=8080"
- "traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$$2y$$10$$foobar" - "traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$$2y$$10$$foobar"
# Backend - Production log level # PostgreSQL - Remove dev ports
mvp-backend:
environment:
NODE_ENV: production
CONFIG_PATH: /app/config/production.yml
SECRETS_DIR: /run/secrets
# Pino log levels: trace | debug | info | warn | error | fatal
LOG_LEVEL: error
DATABASE_HOST: mvp-postgres
REDIS_HOST: mvp-redis
STRIPE_PRO_MONTHLY_PRICE_ID: prod_Toj6BG9Z9JwREl
STRIPE_PRO_YEARLY_PRICE_ID: prod_Toj8oo0RpVBQmB
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: prod_Toj8xGEui9jl6j
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: prod_Toj9A7A773xrdn
# OCR - Production log level + engine config
mvp-ocr:
environment:
# Python log levels: DEBUG | INFO | WARNING | ERROR | CRITICAL
LOG_LEVEL: error
REDIS_HOST: mvp-redis
REDIS_PORT: 6379
REDIS_DB: 1
# OCR engine configuration (Google Vision primary, PaddleOCR fallback)
OCR_PRIMARY_ENGINE: google_vision
OCR_FALLBACK_ENGINE: paddleocr
OCR_CONFIDENCE_THRESHOLD: "0.6"
OCR_FALLBACK_THRESHOLD: "0.6"
GOOGLE_VISION_KEY_PATH: /run/secrets/google-wif-config.json
VISION_MONTHLY_LIMIT: "1000"
# Vertex AI / Gemini configuration (maintenance schedule extraction)
VERTEX_AI_PROJECT: motovaultpro
VERTEX_AI_LOCATION: us-central1
GEMINI_MODEL: gemini-2.5-flash
# PostgreSQL - Remove dev ports, production log level
mvp-postgres: mvp-postgres:
ports: [] ports: []
environment:
POSTGRES_DB: motovaultpro
POSTGRES_USER: postgres
POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password
POSTGRES_INITDB_ARGS: --encoding=UTF8
LOG_LEVEL: error
# PostgreSQL log statements: none | ddl | mod | all
POSTGRES_LOG_STATEMENT: none
# Minimum query duration to log: -1 (disabled) | 0 (all) | N (ms threshold)
POSTGRES_LOG_MIN_DURATION_STATEMENT: -1
PGDATA: /var/lib/postgresql/data/pgdata
# Redis - Remove dev ports, production log level # Redis - Remove dev ports
mvp-redis: mvp-redis:
ports: [] ports: []
# Redis log levels: debug | verbose | notice | warning
command: redis-server --appendonly yes --loglevel warning

View File

@@ -63,27 +63,6 @@ services:
mvp-ocr: mvp-ocr:
image: ${OCR_IMAGE:-git.motovaultpro.com/egullickson/ocr:latest} image: ${OCR_IMAGE:-git.motovaultpro.com/egullickson/ocr:latest}
container_name: mvp-ocr-staging container_name: mvp-ocr-staging
environment:
# Python log levels: DEBUG | INFO | WARNING | ERROR | CRITICAL
LOG_LEVEL: debug
REDIS_HOST: mvp-redis
REDIS_PORT: 6379
REDIS_DB: 1
# OCR engine configuration (Google Vision primary, PaddleOCR fallback)
OCR_PRIMARY_ENGINE: google_vision
OCR_FALLBACK_ENGINE: paddleocr
OCR_CONFIDENCE_THRESHOLD: "0.6"
OCR_FALLBACK_THRESHOLD: "0.6"
GOOGLE_VISION_KEY_PATH: /run/secrets/google-wif-config.json
VISION_MONTHLY_LIMIT: "1000"
# Vertex AI / Gemini configuration (maintenance schedule extraction)
VERTEX_AI_PROJECT: motovaultpro
VERTEX_AI_LOCATION: us-central1
GEMINI_MODEL: gemini-2.5-flash
volumes:
- ./secrets/app/auth0-ocr-client-id.txt:/run/secrets/auth0-ocr-client-id:ro
- ./secrets/app/auth0-ocr-client-secret.txt:/run/secrets/auth0-ocr-client-secret:ro
- ./secrets/app/google-wif-config.json:/run/secrets/google-wif-config.json:ro
# ======================================== # ========================================
# PostgreSQL (Staging - Separate Database) # PostgreSQL (Staging - Separate Database)

View File

@@ -11,8 +11,9 @@ services:
command: command:
- --configFile=/etc/traefik/traefik.yml - --configFile=/etc/traefik/traefik.yml
environment: environment:
# Traefik log levels: TRACE | DEBUG | INFO | WARN | ERROR # Traefik natively reads TRAEFIK_LOG_LEVEL (maps to --log.level)
LOG_LEVEL: debug # Levels: TRACE | DEBUG | INFO | WARN | ERROR
TRAEFIK_LOG_LEVEL: ${TRAEFIK_LOG_LEVEL:-DEBUG}
CLOUDFLARE_DNS_API_TOKEN_FILE: /run/secrets/cloudflare-dns-token CLOUDFLARE_DNS_API_TOKEN_FILE: /run/secrets/cloudflare-dns-token
ports: ports:
- "80:80" - "80:80"
@@ -60,7 +61,7 @@ services:
VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3} VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3}
VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com} VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com}
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api} VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api}
VITE_STRIPE_PUBLISHABLE_KEY: ${VITE_STRIPE_PUBLISHABLE_KEY:-pk_live_51Sr2yQJk87CpWj04YNBIaUWUtnJjeVTgk5NqHdpjqxgsbjy3dMKkIsqhjcpSkCzp3KvLi23BGgxhwV021EnEW3H400HhPYVyfN} VITE_STRIPE_PUBLISHABLE_KEY: ${VITE_STRIPE_PUBLISHABLE_KEY}
container_name: mvp-frontend container_name: mvp-frontend
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -115,15 +116,15 @@ services:
CONFIG_PATH: /app/config/production.yml CONFIG_PATH: /app/config/production.yml
SECRETS_DIR: /run/secrets SECRETS_DIR: /run/secrets
# Pino log levels: trace | debug | info | warn | error | fatal # Pino log levels: trace | debug | info | warn | error | fatal
LOG_LEVEL: debug LOG_LEVEL: ${BACKEND_LOG_LEVEL:-debug}
# Service references # Service references
DATABASE_HOST: mvp-postgres DATABASE_HOST: mvp-postgres
REDIS_HOST: mvp-redis REDIS_HOST: mvp-redis
#Stripe Variables # Stripe Price IDs (override via .env for staging/production)
STRIPE_PRO_MONTHLY_PRICE_ID: prod_Toj6BG9Z9JwREl STRIPE_PRO_MONTHLY_PRICE_ID: ${STRIPE_PRO_MONTHLY_PRICE_ID:-price_1T1ZHMJXoKkh5RcKwKSSGIlR}
STRIPE_PRO_YEARLY_PRICE_ID: prod_Toj8oo0RpVBQmB STRIPE_PRO_YEARLY_PRICE_ID: ${STRIPE_PRO_YEARLY_PRICE_ID:-price_1T1ZHnJXoKkh5RcKWlG2MPpX}
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: prod_Toj8xGEui9jl6j STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: ${STRIPE_ENTERPRISE_MONTHLY_PRICE_ID:-price_1T1ZIBJXoKkh5RcKu2jyhqBN}
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: prod_Toj9A7A773xrdn STRIPE_ENTERPRISE_YEARLY_PRICE_ID: ${STRIPE_ENTERPRISE_YEARLY_PRICE_ID:-price_1T1ZIQJXoKkh5RcK34YXiJQm}
volumes: volumes:
# Configuration files (K8s ConfigMap equivalent) # Configuration files (K8s ConfigMap equivalent)
- ./config/app/production.yml:/app/config/production.yml:ro - ./config/app/production.yml:/app/config/production.yml:ro
@@ -192,7 +193,7 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
# Python log levels: DEBUG | INFO | WARNING | ERROR | CRITICAL # Python log levels: DEBUG | INFO | WARNING | ERROR | CRITICAL
LOG_LEVEL: debug LOG_LEVEL: ${BACKEND_LOG_LEVEL:-debug}
REDIS_HOST: mvp-redis REDIS_HOST: mvp-redis
REDIS_PORT: 6379 REDIS_PORT: 6379
REDIS_DB: 1 REDIS_DB: 1
@@ -205,8 +206,8 @@ services:
VISION_MONTHLY_LIMIT: "1000" VISION_MONTHLY_LIMIT: "1000"
# Vertex AI / Gemini configuration (maintenance schedule extraction) # Vertex AI / Gemini configuration (maintenance schedule extraction)
VERTEX_AI_PROJECT: motovaultpro VERTEX_AI_PROJECT: motovaultpro
VERTEX_AI_LOCATION: us-central1 VERTEX_AI_LOCATION: global
GEMINI_MODEL: gemini-2.5-flash GEMINI_MODEL: gemini-3-flash-preview
volumes: volumes:
- /tmp/vin-debug:/tmp/vin-debug - /tmp/vin-debug:/tmp/vin-debug
- ./secrets/app/auth0-ocr-client-id.txt:/run/secrets/auth0-ocr-client-id:ro - ./secrets/app/auth0-ocr-client-id.txt:/run/secrets/auth0-ocr-client-id:ro
@@ -239,11 +240,11 @@ services:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password
POSTGRES_INITDB_ARGS: --encoding=UTF8 POSTGRES_INITDB_ARGS: --encoding=UTF8
LOG_LEVEL: debug LOG_LEVEL: ${BACKEND_LOG_LEVEL:-debug}
# PostgreSQL log statements: none | ddl | mod | all # PostgreSQL log statements: none | ddl | mod | all
POSTGRES_LOG_STATEMENT: all POSTGRES_LOG_STATEMENT: ${POSTGRES_LOG_STATEMENT:-all}
# Minimum query duration to log: -1 (disabled) | 0 (all) | N (ms threshold) # Minimum query duration to log: -1 (disabled) | 0 (all) | N (ms threshold)
POSTGRES_LOG_MIN_DURATION_STATEMENT: 0 POSTGRES_LOG_MIN_DURATION_STATEMENT: ${POSTGRES_LOG_MIN_DURATION:-0}
PGDATA: /var/lib/postgresql/data/pgdata PGDATA: /var/lib/postgresql/data/pgdata
volumes: volumes:
- mvp_postgres_data:/var/lib/postgresql/data/pgdata - mvp_postgres_data:/var/lib/postgresql/data/pgdata
@@ -271,7 +272,7 @@ services:
container_name: mvp-redis container_name: mvp-redis
restart: unless-stopped restart: unless-stopped
# Redis log levels: debug | verbose | notice | warning # Redis log levels: debug | verbose | notice | warning
command: redis-server --appendonly yes --loglevel debug command: redis-server --appendonly yes --loglevel ${REDIS_LOGLEVEL:-debug}
volumes: volumes:
- mvp_redis_data:/data - mvp_redis_data:/data
networks: networks:

View File

@@ -35,7 +35,7 @@ The platform provides vehicle hierarchical data lookups:
VIN decoding is planned but not yet implemented. Future capabilities will include: VIN decoding is planned but not yet implemented. Future capabilities will include:
- `GET /api/platform/vehicle?vin={vin}` - Decode VIN to vehicle details - `GET /api/platform/vehicle?vin={vin}` - Decode VIN to vehicle details
- PostgreSQL-based VIN decode function - PostgreSQL-based VIN decode function
- NHTSA vPIC API fallback with circuit breaker - Gemini VIN decode via OCR service
- Redis caching (7-day TTL for successful decodes) - Redis caching (7-day TTL for successful decodes)
**Data Source**: Vehicle data from standardized sources **Data Source**: Vehicle data from standardized sources

View File

@@ -74,7 +74,7 @@ docker compose exec mvp-frontend npm test -- --coverage
Example: `vehicles.service.test.ts` Example: `vehicles.service.test.ts`
- Tests VIN validation logic - Tests VIN validation logic
- Tests vehicle creation with mocked vPIC responses - Tests vehicle creation with mocked OCR service responses
- Tests caching behavior with mocked Redis - Tests caching behavior with mocked Redis
- Tests error handling paths - Tests error handling paths
@@ -194,7 +194,7 @@ All 15 features have test suites with unit and/or integration tests:
- `vehicles` - Unit + integration tests - `vehicles` - Unit + integration tests
### Mock Strategy ### Mock Strategy
- **External APIs**: Completely mocked (vPIC, Google Maps) - **External APIs**: Completely mocked (OCR service, Google Maps)
- **Database**: Real database with transactions - **Database**: Real database with transactions
- **Redis**: Mocked for unit tests, real for integration - **Redis**: Mocked for unit tests, real for integration
- **Auth**: Mocked JWT tokens for protected endpoints - **Auth**: Mocked JWT tokens for protected endpoints
@@ -319,8 +319,8 @@ describe('Error Handling', () => {
).rejects.toThrow('Invalid VIN format'); ).rejects.toThrow('Invalid VIN format');
}); });
it('should handle vPIC API failure', async () => { it('should handle OCR service failure', async () => {
mockVpicClient.decode.mockRejectedValue(new Error('API down')); mockOcrClient.decodeVin.mockRejectedValue(new Error('API down'));
const result = await vehicleService.create(validVehicle, 'user123'); const result = await vehicleService.create(validVehicle, 'user123');
expect(result.make).toBeNull(); // Graceful degradation expect(result.make).toBeNull(); // Graceful degradation

955
docs/USER-GUIDE.md Normal file
View File

@@ -0,0 +1,955 @@
# MotoVaultPro User Guide
Precision Vehicle Management -- Track every mile. Own every detail.
MotoVaultPro is a cloud-based vehicle management platform for car enthusiasts and collectors. It tracks your entire fleet in one place: maintenance histories, fuel logs, documents, gas stations, and performance analytics.
This guide walks through every feature of the application.
---
## Table of Contents
1. [Getting Started](#1-getting-started)
- [Creating an Account](#creating-an-account)
- [Logging In](#logging-in)
- [Onboarding](#onboarding)
- [Trouble Logging In](#trouble-logging-in)
2. [Dashboard](#2-dashboard)
- [Your Fleet Overview](#your-fleet-overview)
- [Quick Actions](#quick-actions)
3. [Vehicles](#3-vehicles)
- [Viewing Your Vehicles](#viewing-your-vehicles)
- [Adding a Vehicle](#adding-a-vehicle)
- [VIN Decode](#vin-decode)
- [Vehicle Detail Page](#vehicle-detail-page)
- [Editing a Vehicle](#editing-a-vehicle)
- [Deleting a Vehicle](#deleting-a-vehicle)
4. [Fuel Logs](#4-fuel-logs)
- [Fuel Logs Overview](#fuel-logs-overview)
- [Logging Fuel](#logging-fuel)
- [Receipt Scanning](#receipt-scanning)
- [Editing and Deleting Fuel Logs](#editing-and-deleting-fuel-logs)
5. [Maintenance](#5-maintenance)
- [Maintenance Records](#maintenance-records)
- [Adding a Maintenance Record](#adding-a-maintenance-record)
- [Maintenance Schedules](#maintenance-schedules)
- [Creating a Schedule](#creating-a-schedule)
6. [Gas Stations](#6-gas-stations)
- [Finding Stations](#finding-stations)
- [Saved Stations](#saved-stations)
- [Premium 93 Stations](#premium-93-stations)
7. [Documents](#7-documents)
- [Documents Overview](#documents-overview)
- [Adding a Document](#adding-a-document)
- [Document Types](#document-types)
8. [Settings](#8-settings)
- [Profile](#profile)
- [Security and Privacy](#security-and-privacy)
- [Subscription](#subscription)
- [Notifications](#notifications)
- [Appearance and Units](#appearance-and-units)
- [Data Import and Export](#data-import-and-export)
- [Account Actions](#account-actions)
9. [Subscription Tiers and Pro Features](#9-subscription-tiers-and-pro-features)
- [Tier Comparison](#tier-comparison)
- [VIN Camera Scanning and Decode (Pro)](#vin-camera-scanning-and-decode-pro)
- [Fuel Receipt Scanning (Pro)](#fuel-receipt-scanning-pro)
- [Maintenance Receipt Scanning (Pro)](#maintenance-receipt-scanning-pro)
- [Maintenance Manual PDF Extraction (Pro)](#maintenance-manual-pdf-extraction-pro)
- [Email Ingestion (Pro)](#email-ingestion-pro)
- [Shared Vehicle Documents (Pro)](#shared-vehicle-documents-pro)
- [Community Station Submissions (Pro)](#community-station-submissions-pro)
- [Managing Your Subscription](#managing-your-subscription)
10. [Mobile Experience](#10-mobile-experience)
---
## 1. Getting Started
### Creating an Account
Navigate to [motovaultpro.com](https://motovaultpro.com) and click the **Sign Up** button in the top-right corner of the navigation bar.
**Sign Up Page**
The registration page displays the MotoVaultPro logo and a clean form with the following fields:
| Field | Required | Details |
|-------|----------|---------|
| Email Address | Yes | Your email address (e.g., your.email@example.com) |
| Password | Yes | Minimum 8 characters, must include one uppercase letter and one number |
| Confirm Password | Yes | Re-enter your password to confirm |
| Terms & Conditions | Yes | Checkbox -- you must agree to the Terms & Conditions before creating your account |
After filling in all fields, click the **Create Account** button.
If you already have an account, click the **Login** link at the bottom of the form.
After registration, you will receive a verification email. Click the link in the email to verify your account before logging in.
### Logging In
Click the **Login** button in the top-right corner of the navigation bar. You will be redirected to the secure login page powered by Auth0.
**Login Page**
Enter your registered email address, then click **Continue**. On the next screen, enter your password and click **Continue** to log in.
After successful authentication, you will be redirected to the Dashboard.
### Onboarding
First-time users see an onboarding flow with three steps:
1. **Preferences** -- Choose your preferred unit system (Imperial or Metric), distance units, and notification preferences.
2. **Add Your First Vehicle** -- Enter your first vehicle's details (you can skip this step and add vehicles later).
3. **Complete** -- A welcome screen with quick links to get started exploring the app.
### Trouble Logging In
If you are having trouble logging in, try the following password reset and account recovery options.
---
## 2. Dashboard
After logging in, you land on the Dashboard -- your fleet headquarters.
**What You See**
The Dashboard displays a "Your Fleet" heading with all your vehicles shown as cards in a grid layout. Each vehicle card shows:
- **Vehicle icon** -- A small colored indicator badge (varies by vehicle)
- **Vehicle name** -- The nickname or full name (e.g., "Beast", "MERLOT")
- **Health status** -- A green dot indicates "All clear" (no overdue maintenance); other colors indicate attention needed
- **Status text** -- "All clear" or a maintenance alert message
- **Odometer reading** -- Current mileage (e.g., "35,000 mi")
**Click any vehicle card** to go directly to that vehicle's detail page.
### Quick Actions
Two action buttons appear in the top-right corner of the Dashboard:
| Button | Action |
|--------|--------|
| **+ Add Vehicle** | Opens the Add Vehicle form on the Vehicles page |
| **LOG FUEL** | Opens the fuel logging modal to quickly record a fill-up |
These quick actions let you perform the most common tasks without navigating away from the Dashboard.
### Navigation Sidebar
The left sidebar provides access to all sections of the app:
| Menu Item | Description |
|-----------|-------------|
| **Dashboard** | Fleet overview (home) |
| **Vehicles** | Manage your vehicle collection |
| **Fuel Logs** | Track fuel purchases and efficiency |
| **Maintenance** | Record service history and set schedules |
| **Gas Stations** | Find and save gas stations |
| **Documents** | Store vehicle-related documents |
| **Settings** | Account, preferences, and data management |
At the bottom of the sidebar you will see your email address and a **Sign Out** button.
The header bar at the top shows a notification bell icon and a "Welcome back" greeting with your email.
---
## 3. Vehicles
### Viewing Your Vehicles
Click **Vehicles** in the sidebar to see the "My Vehicles" page. This page shows:
- **Search bar** -- Search vehicles by name, make, model, or VIN
- **+ Add Vehicle** button -- Top-right corner
- **Vehicle cards** in a grid layout (3 columns on desktop), each displaying:
- Manufacturer logo (e.g., Chevrolet bowtie, GMC logo)
- Vehicle nickname or full name
- Year, Make, and Model
- License plate number
- Current odometer reading (in miles or kilometers)
- **Edit** (pencil icon) and **Delete** (trash icon) buttons at the bottom of each card
### Adding a Vehicle
Click the **+ Add Vehicle** button to expand the "Add New Vehicle" form directly on the Vehicles page. The form has the following sections:
**Vehicle Photo**
Upload a photo of your vehicle. Click **ADD PHOTO** to select an image file.
- Accepted formats: JPEG or PNG
- Maximum file size: 5MB
**VIN Number**
Enter your vehicle's 17-character VIN (Vehicle Identification Number).
- Type the VIN manually in the text field, OR
- Click the **camera icon** to scan the VIN using your device camera (uses OCR technology)
- Click the **Decode VIN** button to automatically populate vehicle details from the VIN
Note: VIN is optional if you provide a License Plate instead.
**Vehicle Specifications**
These fields use cascading dropdowns -- each selection narrows the options for the next field:
| Field | How It Works |
|-------|-------------|
| Year | Select the model year from the dropdown |
| Make | Available after selecting Year (e.g., Chevrolet, GMC, Ford) |
| Model | Available after selecting Make (e.g., Silverado, Camaro, Sierra) |
| Trim | Available after selecting Model (e.g., LT Double Cab 4WD) |
| Engine | Available after selecting Trim (e.g., 6.6L 401 HP V8) |
| Transmission | Available after selecting Trim (e.g., 10-Speed Automatic) |
**Additional Details**
| Field | Example | Notes |
|-------|---------|-------|
| Nickname | Beast, Family Car | A friendly name for your vehicle |
| Color | Black, Blue, Red | Vehicle color |
| License Plate | ABC-123 | Required if VIN is not provided |
| Current Odometer Reading | 50000 | Current mileage in your selected unit |
**Purchase Information**
| Field | Example | Notes |
|-------|---------|-------|
| Purchase Price | 25000 | What you paid for the vehicle |
| Purchase Date | mm/dd/yyyy | When you purchased the vehicle |
Click **Add Vehicle** to save, or **Cancel** to discard.
### VIN Decode
> **Pro Feature:** VIN camera scanning and automatic decode require a Pro or Enterprise subscription. Free tier users can still type a VIN manually. See [VIN Camera Scanning and Decode (Pro)](#vin-camera-scanning-and-decode-pro) for full details.
The VIN Decode feature automatically fills in vehicle details from a VIN:
1. Enter or scan your 17-character VIN
2. Click the **Decode VIN** button
3. The system looks up the VIN and auto-populates: Year, Make, Model, Engine, Transmission, and Trim
4. Review the pre-filled fields and make any corrections
5. Continue filling in the remaining fields (Nickname, Color, etc.)
### Vehicle Detail Page
Click any vehicle card (from Dashboard or Vehicles list) to open the Vehicle Detail Page. This page shows everything about a single vehicle:
**Header Area**
- Back arrow and **BACK** link to return to the previous page
- Vehicle nickname as the page title (e.g., "Beast")
- **Edit Vehicle** button (top-right)
- Quick action buttons: **Add Fuel Log** and **Add Maintenance**
**Vehicle Details Section**
- Manufacturer logo
- Full vehicle description (e.g., "2022 Chevrolet Silverado 2500HD")
- VIN Number
- Year, Make, and Model (displayed in a 3-column row)
- Trim, Engine, and Transmission (displayed in a 3-column row)
- Nickname
- Color and License Plate (side by side)
- Current Odometer Reading
**Purchase Information Section**
- Purchase Price
- Purchase Date
**Ownership Costs Section**
- Tracks insurance, registration, taxes, and other recurring vehicle costs
- Shows "No ownership costs recorded yet" until costs are added
**Vehicle Records Section**
- A table showing all records associated with this vehicle (fuel logs, maintenance records)
- Columns: Date, Type, Summary, Amount, Actions
- **Filter** dropdown to filter by record type (All, Fuel, Maintenance)
### Editing a Vehicle
From the Vehicle Detail Page, click the **Edit Vehicle** button. This opens the vehicle form pre-filled with the current values. Make your changes and save.
From the Vehicles list, click the **pencil icon** on any vehicle card to edit it directly.
### Deleting a Vehicle
From the Vehicles list, click the **trash icon** on any vehicle card. You will be asked to confirm the deletion. Deleting a vehicle is permanent and removes all associated records.
---
## 4. Fuel Logs
### Fuel Logs Overview
Click **Fuel Logs** in the sidebar to see the Fuel Logs page. At the top, you see summary statistics:
| Metric | Description |
|--------|-------------|
| **LOGS** | Total number of fuel entries |
| **TOTAL FUEL** | Total gallons (or liters) across all fill-ups |
| **TOTAL COST** | Total amount spent on fuel |
Below the summary, a table lists all your fuel log entries. If you have no entries yet, you will see "No fuel logs yet."
The **+ Add Fuel Log** button is in the top-right corner.
### Logging Fuel
Click **+ Add Fuel Log** (or the **LOG FUEL** button from the Dashboard) to open the "Log Fuel" modal. The modal title reads "Add Fuel Log" with a note showing your current unit system (e.g., "Displaying in Imperial (miles, gallons, MPG)").
**Receipt Scanning**
At the top of the form, click **SCAN RECEIPT** to use your camera to photograph a fuel receipt. The OCR system will automatically extract:
- Fuel amount (gallons)
- Cost per gallon
- Total cost
- Date and time
- Fuel grade
- Station name
You can review and edit any extracted values before saving.
**Form Fields**
| Field | Required | Description |
|-------|----------|-------------|
| Select Vehicle | Yes | Choose which vehicle this fill-up is for |
| Date & Time | Yes | Pre-filled with the current date and time; click the calendar icon to change |
| MPG | Auto | Calculated automatically from distance and fuel amount |
| Trip Distance / Odometer Reading | One required | Toggle between entering trip distance (miles driven since last fill) or odometer reading. Click the toggle button to switch modes. |
| Fuel Type | Yes | Dropdown: Gasoline, Diesel, Electric, Hybrid, etc. |
| Fuel Grade | Optional | Dropdown: 87 (Regular), 89 (Midgrade), 91 (Premium), 93 (Premium), etc. |
| Fuel Amount | Yes | Number of gallons (or liters) purchased |
| Cost Per Gallon | Yes | Price per gallon (or liter) |
| Total Cost | Auto | Calculated from Fuel Amount x Cost Per Gallon. Displays "Enter fuel amount and cost per unit to see total cost." until both values are provided. |
| Location | Optional | Type a station name to search and select |
| Notes | Optional | Any additional notes about this fill-up |
Click **Add Fuel Log** to save the entry. The button is disabled until all required fields are completed.
### Receipt Scanning
> **Pro Feature:** Receipt scanning requires a Pro or Enterprise subscription. See [Fuel Receipt Scanning (Pro)](#fuel-receipt-scanning-pro) for full details on what is extracted and the review workflow.
The receipt scanning feature uses OCR technology:
1. Click **SCAN RECEIPT** at the top of the Log Fuel form
2. Use your camera to photograph the receipt
3. The system extracts fuel data with confidence indicators
4. A review modal appears showing extracted values
5. Edit any incorrect values inline
6. Click **Accept** to auto-fill the form, or **Reject** to enter manually
### Editing and Deleting Fuel Logs
From the fuel logs table, each entry has action buttons:
- **Edit** -- Opens the fuel log in edit mode to update any fields
- **Delete** -- Removes the fuel log entry (with confirmation)
---
## 5. Maintenance
Click **Maintenance** in the sidebar. This page has two tabs: **RECORDS** and **SCHEDULES**.
At the top is a **Vehicle** dropdown to select which vehicle you are viewing or adding maintenance for.
### Maintenance Records
The **RECORDS** tab shows your maintenance history for the selected vehicle. Below the list is the "Add Maintenance Record" form.
### Adding a Maintenance Record
The form on the RECORDS tab includes:
**Receipt Upload**
> **Pro Feature:** Maintenance receipt scanning requires a Pro or Enterprise subscription. See [Maintenance Receipt Scanning (Pro)](#maintenance-receipt-scanning-pro) for full details.
Click the **ADD RECEIPT** button (dashed outline area) to upload or photograph a maintenance receipt. The OCR system can extract:
- Category and service type
- Cost
- Date
- Shop name
**Form Fields**
| Field | Required | Description |
|-------|----------|-------------|
| Category | Yes | Dropdown with options: Routine Maintenance, Repair, Performance Upgrade. Each category has specific subtypes. |
| Date | Yes | Pre-filled with today's date; click the calendar icon to change |
| Odometer Reading | Optional | Vehicle mileage at time of service |
| Cost | Optional | Total cost of the service (in $) |
| Shop Name | Optional | Name of the service shop |
| Notes | Optional | Additional details about the service (max 1,000 characters) |
Click **Add Record** to save the maintenance record.
**Maintenance Categories**
| Category | Example Services |
|----------|-----------------|
| Routine Maintenance | Oil change, air filter, tire rotation, battery, brakes, coolant flush, transmission fluid, spark plugs, fuel filter, cabin air filter, brake fluid, detailing |
| Repair | Engine repair, transmission repair, brake repair, electrical, cooling system, suspension, steering, fuel system, body work, paint, glass |
| Performance Upgrade | Engine tuning, suspension upgrade, wheels/tires, brake upgrade, exhaust, intake, lighting, audio |
### Maintenance Schedules
Click the **SCHEDULES** tab to set up recurring maintenance reminders.
### Creating a Schedule
The "Create Maintenance Schedule" form includes:
| Field | Required | Description |
|-------|----------|-------------|
| Category | Yes | Same categories as maintenance records |
| Schedule Type | Yes | Three options (radio buttons): |
| | | **Interval-based** -- Every X months or miles (e.g., oil change every 5,000 miles or 6 months) |
| | | **Fixed date** -- A specific calendar date |
| | | **Time since last service** -- Based on when service was last performed |
| Interval (Months) | Conditional | Number of months between services. Optional if miles are specified. |
| Interval (Miles) | Conditional | Number of miles between services. Optional if months are specified. |
| Reminders | Optional | Set up to 3 reminders (Reminder 1, Reminder 2, Reminder 3) via dropdowns |
| Email notifications | Optional | Toggle to receive email reminders when service is due |
Click **Create Schedule** to save.
Below the form, the "Maintenance Schedules" section lists all active schedules for the selected vehicle, showing when each service is next due.
---
## 6. Gas Stations
Click **Gas Stations** in the sidebar. This page helps you find gas stations near you and save your favorites.
The page is split into two sections:
- **Left**: An interactive Google Map showing station locations as markers
- **Right**: Search controls
### Finding Stations
**Search Options**
| Control | Description |
|---------|-------------|
| **Use Current Location** | Large red button -- uses your device's GPS to center the search on your current location |
| **Street** | Enter a street address (e.g., 123 Main St) |
| **City** | Enter a city name |
| **State** | Select from dropdown |
| **ZIP** | Enter a ZIP code |
| **Search Radius** | Slider from 1 mi to 25 mi (default: 5 mi) |
| **Search Stations** | Click to execute the search |
You can either use your current location OR manually enter an address. Search results appear below the map.
**Search Results**
Below the map, there are three tabs:
| Tab | Description |
|-----|-------------|
| **RESULTS (n)** | Stations found by your search, showing count |
| **SAVED (n)** | Your saved/favorite stations |
| **PREMIUM 93** | Stations verified to carry true 93-octane fuel |
Each station result shows:
- Station name (e.g., "Costco Gas Station", "Mobil")
- Street address and city
- Star rating (community-verified)
- Fuel grade badges (e.g., "93 Octane - w/ Ethanol")
- Save/unsave button
### Saved Stations
Click the **SAVED** tab to see your favorite stations. Saved stations also appear as yellow star markers on the map. You can:
- View station details
- Remove a station from your saved list
- Navigate on the map by clicking a station card
### Premium 93 Stations
Click the **PREMIUM 93** tab to see your "Your Premium 93 Stations" -- stations that have been community-verified to carry genuine 93-octane fuel. This is especially useful for performance vehicles that require premium fuel.
---
## 7. Documents
Click **Documents** in the sidebar. This page stores all your vehicle-related paperwork digitally.
### Documents Overview
The page shows the title "Documents" with an **Add Document** button in the top-right corner.
If you have no documents yet, you will see an empty state:
- A document icon
- "No Documents Yet"
- "You haven't added any documents yet. Documents will appear here once you create them."
- A **Go to Vehicles** button (since documents are associated with vehicles)
When documents exist, they appear in a list/grid with preview thumbnails, titles, document types, and associated vehicles.
### Adding a Document
Click **Add Document** to open the "Add Document" modal with these fields:
| Field | Required | Description |
|-------|----------|-------------|
| Vehicle | Yes | Select which vehicle this document belongs to (dropdown of your vehicles) |
| Document Type | Yes | Select the type (see Document Types below) |
| Title | Yes | A descriptive title (e.g., "Honda CBR600RR Service Manual") |
| Notes | Optional | Any additional notes about this document |
| Upload image/PDF | Yes | Click **Choose File** to upload an image or PDF file |
Click **Create Document** to save, or **Cancel** to discard.
### Document Types
| Type | What to Store |
|------|--------------|
| Insurance | Insurance policies, cards, declarations pages |
| Registration | Vehicle registration documents |
| Maintenance Manual | Owner's manuals and service manuals |
| Service Records | Service history documentation from dealers/shops |
| Recall Notices | Vehicle recall notifications |
| Inspection Reports | State inspection or emissions test reports |
| Receipts | Purchase receipts for parts, accessories, services |
| Other | Any other vehicle-related document |
**Insurance documents** have additional fields: Insurance Company, Policy Number, Effective Date, Expiration Date, Coverage amounts (Bodily Injury, Property Damage), and Premium.
**Registration documents** have additional fields: License Plate, Expiration Date, and Registration Cost.
Documents with expiration dates will show countdown badges so you know when renewals are coming up.
> **Pro Feature:** When uploading a Maintenance Manual PDF, Pro and Enterprise users can check **Scan for Maintenance Schedule** to automatically extract a complete maintenance schedule from the document. See [Maintenance Manual PDF Extraction (Pro)](#maintenance-manual-pdf-extraction-pro) for the full workflow.
---
## 8. Settings
Click **Settings** in the sidebar to manage your account, preferences, and data.
### Profile
The Profile section displays your account information:
| Field | Description |
|-------|-------------|
| **Avatar** | Shows your initial in a circle |
| **Name** | Your display name (e.g., "Eric Gullickson") |
| **Email** | Your account email address |
| **Account Status** | Shows "Verified account" if email is verified |
| **Display Name** | Your public-facing name |
| **Notification Email** | The email address used for notifications (defaults to "Using primary email") |
Click the **Edit** button to update your display name or notification email.
### Security and Privacy
The Security & Privacy row shows "Password, two-factor authentication" with a **Manage** button. Click it to:
- Change your password
- Set up two-factor authentication
- Manage active sessions
- Log out all devices
### My Vehicles
A summary list of all your vehicles (with count, e.g., "My Vehicles (4)"). Click the **Manage** button to go to the Vehicles page.
### Subscription
Shows your current subscription plan with a **Manage** button.
| Plan | Features |
|------|----------|
| **FREE** | Basic vehicle management, up to 2 vehicles, basic fuel tracking, document storage |
| **Pro** | Up to 10 vehicles, receipt OCR scanning, maintenance schedules, email ingestion |
| **Enterprise** | Unlimited vehicles, all Pro features |
"Upgrade to Pro or Enterprise for more features and vehicle slots."
Click **Manage** to view plan details, change your subscription, manage payment methods, and view billing history.
### Notifications
| Setting | Description | Default |
|---------|-------------|---------|
| **Push Notifications** | Receive notifications about your vehicles (maintenance due, etc.) | ON |
| **Email Updates** | Receive maintenance reminders and updates via email | OFF |
Toggle each setting on or off.
### Appearance and Units
| Setting | Description | Options |
|---------|-------------|---------|
| **Dark Mode** | Use dark theme for better night viewing | Toggle ON/OFF (default: OFF) |
| **Units for distance and capacity** | Choose between measurement systems | **Imperial**: miles, gallons, MPG, USD / **Metric**: km, liters, L/100km, EUR |
The unit system you select here applies throughout the entire application -- Dashboard, Fuel Logs, Maintenance, and Vehicle Details all update to reflect your preference.
### Data Import and Export
| Action | Description | Button |
|--------|-------------|--------|
| **Import Data** | Upload and restore your vehicle data from a backup file | **Import** |
| **Export Data** | Download your vehicle and fuel log data as a backup file | **Export** |
Export creates a downloadable archive of all your data. Import accepts a previously exported backup file to restore your data.
### Account Actions
At the bottom of the Settings page:
| Button | Action |
|--------|--------|
| **Sign Out** | Log out of your account |
| **DELETE ACCOUNT** | Permanently delete your account and all data. This initiates a 30-day grace period during which you can cancel the deletion by logging back in. |
---
## 9. Subscription Tiers and Pro Features
MotoVaultPro offers three subscription tiers. Higher tiers automatically include all features from lower tiers.
### Tier Comparison
| Feature | Free | Pro | Enterprise |
|---------|:----:|:---:|:----------:|
| **Vehicle Slots** | 2 | 5 | Unlimited |
| Vehicle management | Yes | Yes | Yes |
| Fuel log tracking | Yes | Yes | Yes |
| Document storage | Yes | Yes | Yes |
| Gas station finder | Yes | Yes | Yes |
| Maintenance records | Yes | Yes | Yes |
| Maintenance schedules | Yes | Yes | Yes |
| Data import/export | Yes | Yes | Yes |
| **VIN camera scan and decode** | -- | Yes | Yes |
| **Fuel receipt OCR scanning** | -- | Yes | Yes |
| **Maintenance receipt OCR scanning** | -- | Yes | Yes |
| **Maintenance manual PDF extraction** | -- | Yes | Yes |
| **Email ingestion** (forward receipts) | -- | Yes | Yes |
| **Shared vehicle documents** | -- | Yes | Yes |
| **Community station submissions** | -- | Yes | Yes |
When you attempt to use a Pro feature on the Free tier, an **Upgrade Required** dialog appears explaining the feature and offering a direct link to upgrade.
---
### VIN Camera Scanning and Decode (Pro)
**What it does:** Use your device camera to photograph your vehicle's VIN plate, and the system automatically reads the VIN using OCR (Optical Character Recognition) and decodes it from the vehicle database.
**How to use it:**
1. Go to **Vehicles** and click **+ Add Vehicle**
2. In the VIN Number field, click the **camera icon**
3. Point your camera at the VIN plate on your vehicle (typically on the driver-side dashboard or door jamb)
4. The OCR system reads the 17-character VIN from the image
5. A **VIN OCR Review modal** appears showing the detected VIN with confidence indicators
6. Confirm or correct the VIN, then click **Accept**
7. Click the **Decode VIN** button
8. The system queries the vehicle database and auto-populates: Year, Make, Model, Engine, Transmission, and Trim
9. Review the pre-filled fields and complete the remaining details
This eliminates manual data entry errors and ensures accurate vehicle specifications.
---
### Fuel Receipt Scanning (Pro)
**What it does:** Photograph a fuel receipt and the OCR system extracts all relevant data, automatically filling in your fuel log entry.
**How to use it:**
1. Open the **Log Fuel** modal (from Dashboard or Fuel Logs page)
2. Click the **SCAN RECEIPT** button at the top of the form
3. Use your camera to photograph the fuel receipt
4. The system processes the image and extracts:
| Extracted Field | Description |
|----------------|-------------|
| Fuel Amount | Gallons or liters purchased |
| Cost Per Unit | Price per gallon/liter |
| Total Cost | Total transaction amount |
| Date & Time | Transaction timestamp |
| Fuel Grade | Regular, Midgrade, Premium, etc. |
| Station Name | Merchant name matched to known stations |
5. A **Receipt OCR Review modal** appears showing all extracted values with confidence scores
6. Each field can be edited inline if the OCR got something wrong
7. The station name is automatically matched against known gas stations in the system
8. Click **Accept** to auto-fill the Log Fuel form with the extracted values
9. Click **Reject** to discard the scan and enter data manually
10. Review the pre-filled form and click **Add Fuel Log**
**Tips for best results:**
- Photograph the receipt on a flat, well-lit surface
- Ensure the entire receipt is visible in the frame
- Avoid wrinkled or faded receipts when possible
---
### Maintenance Receipt Scanning (Pro)
**What it does:** Photograph a maintenance or service receipt to automatically extract service details into a maintenance record.
**How to use it:**
1. Go to **Maintenance** and select a vehicle
2. On the **RECORDS** tab, click the **ADD RECEIPT** button (dashed outline area)
3. Use your camera to photograph the service receipt
4. The system processes the image and extracts:
| Extracted Field | Description |
|----------------|-------------|
| Category | Service type (Routine, Repair, Performance) |
| Subtypes | Specific services performed (e.g., Oil Change, Tire Rotation) |
| Cost | Total service cost |
| Date | Service date |
| Shop Name | Name of the service shop |
5. A **Maintenance Receipt Review modal** shows extracted values with confidence indicators
6. Edit any incorrect values inline
7. Click **Accept** to auto-fill the maintenance record form
8. Review and click **Add Record**
---
### Maintenance Manual PDF Extraction (Pro)
**What it does:** Upload your vehicle's owner's manual or maintenance manual as a PDF, and the system automatically extracts the recommended maintenance schedule -- creating maintenance schedules with the correct intervals for your specific vehicle.
**How to use it:**
1. Go to **Documents** and click **Add Document**
2. Select your vehicle and choose **Maintenance Manual** as the document type
3. Upload the PDF file
4. Check the **Scan for Maintenance Schedule** checkbox (Pro feature -- indicated by a lock icon for Free tier users)
5. Click **Create Document**
6. The system submits the PDF for asynchronous processing
7. A progress indicator shows while the document is being analyzed
8. When processing completes, the **Maintenance Schedule Review** screen appears showing:
| Column | Description |
|--------|-------------|
| Checkbox | Select which items to create as schedules |
| Service Name | Extracted maintenance service (e.g., "Engine Oil and Filter Change") |
| Interval | Recommended interval in months and/or miles |
| Details | Additional notes or specifications |
| Confidence | How confident the system is in the extraction (High/Medium/Low) |
9. Check the boxes next to the maintenance items you want to create
10. Edit any service names, intervals, or details inline
11. Click **Create Selected Schedules** to batch-create all selected items as maintenance schedules for your vehicle
This turns a 50-page owner's manual into a complete set of maintenance reminders in minutes.
---
### Email Ingestion (Pro)
**What it does:** Forward vehicle-related emails (service receipts, insurance documents, registration notices) to a dedicated email address, and they automatically appear in your MotoVaultPro account ready to be associated with a vehicle.
**How to use it:**
1. Forward any vehicle-related email to your dedicated MotoVaultPro ingestion address
2. The system processes the email and any attachments
3. On your **Dashboard**, a **Pending Associations** banner appears showing how many items are waiting
4. Click the banner to open the **Pending Association List**
5. For each pending item, you see:
- A preview of the document or receipt
- A vehicle selector dropdown
6. Select the correct vehicle for each item and click **Associate**
7. Or click **Discard** to remove items you do not want
**Bulk actions** are available to discard all pending items at once.
This is especially useful for:
- Forwarding digital receipts from auto parts stores
- Forwarding service confirmation emails from your mechanic
- Forwarding insurance policy documents from your provider
- Forwarding registration renewal notices
---
### Shared Vehicle Documents (Pro)
**What it does:** Associate a single document with multiple vehicles. Useful for fleet insurance policies, multi-vehicle service agreements, or shared maintenance contracts.
**How to use it:**
1. Open an existing document's detail page
2. In the **Shared Vehicles** section, click **Add Vehicle**
3. Select additional vehicles from the dropdown
4. The document now appears in the Documents section for each associated vehicle
5. To remove a vehicle association, click the **Remove** button next to that vehicle
---
### Community Station Submissions (Pro)
**What it does:** Submit new gas stations to the MotoVaultPro community database, helping other enthusiasts find quality fuel locations -- especially stations carrying true 93-octane premium fuel.
**How to use it:**
1. Go to **Gas Stations**
2. Look for the option to submit a new community station
3. Fill in the submission form:
| Field | Description |
|-------|-------------|
| Station Name | Name of the gas station |
| Location | Address or location |
| Fuel Types | Available fuel types and grades |
| Amenities | Available amenities (bathrooms, ATM, convenience store, etc.) |
| Notes | Any additional information |
| Photo | Optional photo of the station |
4. Submit for community review
5. An admin reviews and approves or rejects the submission
6. Approved stations appear in the **PREMIUM 93** tab and search results with a community-verified badge
---
### Managing Your Subscription
**Viewing Your Plan**
Go to **Settings** and find the **Subscription** section. It shows your current plan (FREE, Pro, or Enterprise) with a **Manage** button.
**Upgrading**
1. Click **Manage** in the Subscription section
2. The Subscription page shows tier comparison cards with pricing
3. Toggle between **Monthly** and **Annual** billing (annual saves money)
4. Click **Upgrade** on the plan you want
5. Enter your payment details using the secure Stripe payment form
6. Your new features are available immediately
**Payment Methods**
- Payment is processed through Stripe (credit/debit cards)
- You can save a card for recurring billing
- Update or remove your payment method at any time
- View billing history and download invoices as PDFs
**Billing History**
The billing history table shows all past invoices with:
- Date
- Description
- Amount
- Status (Paid, Pending, Failed)
- Download PDF button for each invoice
**Downgrading**
If you downgrade from a higher tier, you may need to reduce your vehicles to fit within the lower tier's limit:
1. Click **Downgrade** on the lower plan
2. A **Vehicle Selection dialog** appears if you exceed the new tier's vehicle limit
3. Select which vehicles to keep (e.g., keep 2 for Free tier)
4. A warning explains that removed vehicles and their data will be deleted
5. Confirm the downgrade
| Tier | Vehicle Limit |
|------|:------------:|
| Free | 2 |
| Pro | 5 |
| Enterprise | Unlimited |
**Cancelling**
1. On the Subscription page, click **Cancel Subscription**
2. A confirmation dialog appears with retention options
3. Confirm cancellation
4. Your subscription remains active until the end of the current billing period
5. After expiration, your account reverts to the Free tier
6. Click **Reactivate** at any time before expiration to keep your plan
---
## 10. Mobile Experience
MotoVaultPro is fully responsive and works on both desktop and mobile devices.
**Mobile Navigation**
On mobile, the sidebar is replaced by:
- A **bottom navigation bar** with icons for: Dashboard, Vehicles, Stations
- A **floating action button (FAB)** in the center with quick actions:
- Log Fuel
- Add Vehicle
- Add Document
- Add Maintenance
- A **hamburger menu** (accessed from the header) that slides up from the bottom, providing access to all sections: Dashboard, Vehicles, Log Fuel, Maintenance, Documents, Settings
**Mobile Optimizations**
- Touch-friendly buttons and targets (minimum 44px)
- Swipe gestures for image viewing
- Camera integration for VIN scanning and receipt capture
- Full-screen forms for data entry
- Responsive card layouts that stack vertically on smaller screens
All features available on desktop are also available on mobile -- no functionality is lost on smaller screens.
---
## Quick Reference
### Keyboard Shortcuts and Tips
- **Search vehicles** -- Use the search bar on the Vehicles page to quickly find a vehicle by name, make, model, or VIN
- **Quick fuel log** -- Click "LOG FUEL" on the Dashboard or "+ Add Fuel Log" on the Fuel Logs page
- **Switch vehicles on Maintenance** -- Use the Vehicle dropdown at the top of the Maintenance page to switch between vehicles without leaving the page
### Common Workflows
**Record a fuel fill-up**
1. Click **LOG FUEL** on the Dashboard (or go to Fuel Logs > + Add Fuel Log)
2. Select the vehicle
3. Enter the fuel amount and cost per gallon
4. Optionally enter trip distance or odometer reading for MPG calculation
5. Click **Add Fuel Log**
**Schedule recurring maintenance**
1. Go to **Maintenance**
2. Select a vehicle from the dropdown
3. Click the **SCHEDULES** tab
4. Select a category and schedule type
5. Set the interval (months and/or miles)
6. Configure reminders
7. Click **Create Schedule**
**Upload a document**
1. Go to **Documents**
2. Click **Add Document**
3. Select a vehicle and document type
4. Enter a title
5. Upload the file (image or PDF)
6. Click **Create Document**
**Find a gas station**
1. Go to **Gas Stations**
2. Click **Use Current Location** or enter an address
3. Adjust the search radius
4. Click **Search Stations**
5. Browse results and click the save icon to bookmark your favorites
**Export your data**
1. Go to **Settings**
2. Scroll to "Data & Storage"
3. Click **Export**
4. A backup file will download containing all your vehicle data, fuel logs, and documents
---
*MotoVaultPro -- Precision Vehicle Management*
*2026 FB Technologies LLC. All rights reserved.*

View File

@@ -1,166 +1,31 @@
{ {
"testModules": [ "testModules": [
{ {
"moduleId": "/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/src/features/dashboard/components/__tests__/DashboardScreen.test.tsx", "moduleId": "/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/src/features/admin/__tests__/useAdmins.test.tsx",
"tests": [ "tests": [
{ {
"name": "renders vehicle roster cards", "name": "should fetch admin users",
"fullName": "DashboardScreen renders vehicle roster cards", "fullName": "Admin user management hooks useAdmins should fetch admin users",
"state": "passed" "state": "passed"
}, },
{ {
"name": "renders empty state when 0 vehicles", "name": "should create admin and show success toast",
"fullName": "DashboardScreen renders empty state when 0 vehicles", "fullName": "Admin user management hooks useCreateAdmin should create admin and show success toast",
"state": "passed" "state": "passed"
}, },
{ {
"name": "renders loading skeletons when loading", "name": "should handle create admin error",
"fullName": "DashboardScreen renders loading skeletons when loading", "fullName": "Admin user management hooks useCreateAdmin should handle create admin error",
"state": "passed" "state": "passed"
}, },
{ {
"name": "renders \"Your Fleet\" heading", "name": "should revoke admin and show success toast",
"fullName": "DashboardScreen renders \"Your Fleet\" heading", "fullName": "Admin user management hooks useRevokeAdmin should revoke admin and show success toast",
"state": "passed"
}
]
},
{
"moduleId": "/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/src/features/dashboard/components/__tests__/ActionBar.test.tsx",
"tests": [
{
"name": "renders both buttons with correct text",
"fullName": "ActionBar renders both buttons with correct text",
"state": "passed" "state": "passed"
}, },
{ {
"name": "calls onAddVehicle when Add Vehicle button clicked", "name": "should reinstate admin and show success toast",
"fullName": "ActionBar calls onAddVehicle when Add Vehicle button clicked", "fullName": "Admin user management hooks useReinstateAdmin should reinstate admin and show success toast",
"state": "passed"
},
{
"name": "calls onLogFuel when Log Fuel button clicked",
"fullName": "ActionBar calls onLogFuel when Log Fuel button clicked",
"state": "passed"
}
]
},
{
"moduleId": "/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/src/features/dashboard/components/__tests__/VehicleRosterCard.test.tsx",
"tests": [
{
"name": "renders vehicle label with year make model",
"fullName": "VehicleRosterCard renders vehicle label with year make model",
"state": "passed"
},
{
"name": "renders health badge with correct color class for green health",
"fullName": "VehicleRosterCard renders health badge with correct color class for green health",
"state": "passed"
},
{
"name": "renders health badge with correct color class for yellow health",
"fullName": "VehicleRosterCard renders health badge with correct color class for yellow health",
"state": "passed"
},
{
"name": "renders health badge with correct color class for red health",
"fullName": "VehicleRosterCard renders health badge with correct color class for red health",
"state": "passed"
},
{
"name": "renders attention items text",
"fullName": "VehicleRosterCard renders attention items text",
"state": "passed"
},
{
"name": "renders odometer with formatting",
"fullName": "VehicleRosterCard renders odometer with formatting",
"state": "passed"
},
{
"name": "calls onClick with vehicle ID when clicked",
"fullName": "VehicleRosterCard calls onClick with vehicle ID when clicked",
"state": "passed"
},
{
"name": "renders All clear when no attention items",
"fullName": "VehicleRosterCard renders All clear when no attention items",
"state": "passed"
}
]
},
{
"moduleId": "/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/src/features/dashboard/hooks/__tests__/useDashboardData.test.ts",
"tests": [
{
"name": "should return green health with no schedules and no documents",
"fullName": "computeVehicleHealth Green health should return green health with no schedules and no documents",
"state": "passed"
},
{
"name": "should return green health with schedule due in 20 days and 1 upcoming attention item",
"fullName": "computeVehicleHealth Green health should return green health with schedule due in 20 days and 1 upcoming attention item",
"state": "passed"
},
{
"name": "should return yellow health with schedule due in 10 days, no overdue",
"fullName": "computeVehicleHealth Yellow health should return yellow health with schedule due in 10 days, no overdue",
"state": "passed"
},
{
"name": "should return yellow health with registration expiring in 7 days",
"fullName": "computeVehicleHealth Yellow health should return yellow health with registration expiring in 7 days",
"state": "passed"
},
{
"name": "should return red health with maintenance overdue by 5 days",
"fullName": "computeVehicleHealth Red health should return red health with maintenance overdue by 5 days",
"state": "passed"
},
{
"name": "should return red health with insurance expired 3 days ago",
"fullName": "computeVehicleHealth Red health should return red health with insurance expired 3 days ago",
"state": "passed"
},
{
"name": "should return red health with one overdue maintenance and one due-soon document",
"fullName": "computeVehicleHealth Red health should return red health with one overdue maintenance and one due-soon document",
"state": "passed"
},
{
"name": "should sort attention items with overdue first by most overdue, then due-soon by proximity",
"fullName": "computeVehicleHealth Attention items sorting should sort attention items with overdue first by most overdue, then due-soon by proximity",
"state": "passed"
},
{
"name": "should enforce max 3 attention items when 5 items are present",
"fullName": "computeVehicleHealth Max 3 attention items enforcement should enforce max 3 attention items when 5 items are present",
"state": "passed"
},
{
"name": "should ignore inactive schedules (isActive: false)",
"fullName": "computeVehicleHealth Inactive schedule handling should ignore inactive schedules (isActive: false)",
"state": "passed"
},
{
"name": "should ignore schedules without nextDueDate",
"fullName": "computeVehicleHealth Missing date handling should ignore schedules without nextDueDate",
"state": "passed"
},
{
"name": "should ignore documents without expirationDate",
"fullName": "computeVehicleHealth Missing date handling should ignore documents without expirationDate",
"state": "passed"
},
{
"name": "should use first subtype as label when subtypes array is not empty",
"fullName": "computeVehicleHealth Label extraction should use first subtype as label when subtypes array is not empty",
"state": "passed"
},
{
"name": "should use formatted category as label when subtypes array is empty",
"fullName": "computeVehicleHealth Label extraction should use formatted category as label when subtypes array is empty",
"state": "passed" "state": "passed"
} }
] ]

View File

@@ -17,9 +17,13 @@ http {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Prevent nginx from including internal port in redirects
# (Traefik handles external-facing port 443)
absolute_redirect off;
# Handle client-side routing # Handle client-side routing
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri /index.html;
} }
# Enable gzip compression # Enable gzip compression

View File

@@ -1,150 +0,0 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 12 0 R /MediaBox [ 0 0 612 792 ] /Parent 11 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/Contents 13 0 R /MediaBox [ 0 0 612 792 ] /Parent 11 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/Contents 14 0 R /MediaBox [ 0 0 612 792 ] /Parent 11 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
7 0 obj
<<
/Contents 15 0 R /MediaBox [ 0 0 612 792 ] /Parent 11 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
8 0 obj
<<
/Contents 16 0 R /MediaBox [ 0 0 612 792 ] /Parent 11 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
9 0 obj
<<
/PageMode /UseNone /Pages 11 0 R /Type /Catalog
>>
endobj
10 0 obj
<<
/Author (FB Technologies LLC) /CreationDate (D:20260103171454+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260103171454+00'00') /Producer (ReportLab PDF Library - www.reportlab.com)
/Subject (\(unspecified\)) /Title (MotoVaultPro Terms of Service) /Trapped /False
>>
endobj
11 0 obj
<<
/Count 5 /Kids [ 4 0 R 5 0 R 6 0 R 7 0 R 8 0 R ] /Type /Pages
>>
endobj
12 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2145
>>
stream
GatU3gN)%,&:O:Sm-%%*.'uO&(\K@Ti_2CNa,SV]3g1A9"=5<9_#gDQjm0MdOHO;#U>%^F=VS*m4m21Jb(i"Vna6?Z$#m;(bA/3t!*09u$Z?'H)gK-/q"pf_A8+8qHYN5*(YTsUr2M]__sbI_^)'$I"Ve.nl*%*aJ$N*5[p$V*ldC;5i/[,MOmuMWq?j9qD"':$T*-4MIen$B7IY7nFb(>Xk#4S\;-r[]KY)<[]:FlTBVTuCqu0Zr;%Rt<Z@oG8p6KPTpd\?_NbIj38MH&^?$\6BO3d"_q(UeJ@L@4&*C+"X$#E(&j@.6'5@*A(7.g0B(7WCo<#/o7argdNTbRELmR:RF!r,e8<\6LHKtH/J]n,GFU>T0!mQ%pi%K@;^$<2R\48jk5/^lceB+W:>\GNcH7EPC4'_FmiDjY7sO#k<`q9+@e\UGNCo-fUD'c\fh1Bf.4PUp-=morfCXP,h^mJ71SaL7@sC*U6>S"$mJMOi%C_.R13<>eYb'W&SpnWg51'[7+C(E:(^-c\'i-89`'ZUnN,^J)<p$9H*PXhb05_aLnYo&\CCP[GPXb9/tZ8e@R1j8t%r2!Iqo7*[@i+4"9s.+\]kY.!-T-X*;qlncil2X6n"$?hd\YUnhS5bij03OH:22`4eL")1!h[6]%L:b"kGYO"9?(jJW`8<_,dcK\SjN&:Z6S_9*n99Kf&Q\`IO1*rc\WSGbb@?W<<bg(hk`*RMD;/IugAZ4=_R6cRi^X9s@a$Bc5]M.JDnJf``,G6p$P_++aB2gS;'iaUi_]%RtYhmd-*]"<j(3X,hTMgbR5O72M5(fW.8+F9Df/t4#Z1LQ-mG3L67:*+N9RZ!f]bDF5WKJBPit-c;Hm;=[/_VG)8's-[joje"Z0rh%gF_%cXNqZ'nbhEP]L,239;h7q,$25:)@/!35]UlV/7lki7Qnm6ksob"2Y(M1>04K.9pLQ1O^D<E9J/*#$B%ES8<p\pM#7WX%o4-[kit?=D6QP,@oXp_>+hLgH7u@'X*]tMdVXotTQnOL,]>Z.\.<)+r5O!k!bLIEG,1Gn=jQG*N1slEc=+5dlAfA/B8C)eA,D^`*-*Vk@.M+/`6,<.M)&lhY4k+h@o?I:Y%48An6ae#6gi<B$\YHn"LFh'KaWKLh`\qBr(T&C^bZCTZK/P]KVVmGVVG4RD71j_+)!7O_oC&]U]8bl!D7<RB_%9Ar6oKuqMq8r9>bGMblV^fcWfb-JH`eegmfSR.(NZ1AEQQg;`B\&&rDj9<Rc$_Hn-sd*t:24G^c1Xc:&,_H1SU+mUOY52VS9hq^?\sJbK1K^#b_bXNK=<S_^&i18T%s^q\L61:VESKqd]"1>=Rt+f-=W>#;_6ASm:mn5mk-5s"?"'.cN=)Ng$LNPj]-DF$'O'0g^B)Z`PCOFdO&L*//VDt8PR4O8a94X$sSNe#%b</]0D<f%psUUo!`6AU@%WClUJ<b`0ZA<q,,hI0$HE,^<Hj9uGIGdH_K7M$"bFPdOUe?JBt\e,<9oE[K>It;_H_.`mr0aD#),ie*BGYN;`g6uk_pEKaQK+Bq11)*N&J3u^8]>%$.Q,tp3TcD78Iae&T)+/,3J&ge9FAn=a'n;l&:.(31`aEGGCe$EaN`+d)**"PKTi]E<DrBL/%#pG26'"16l\6R"4X%k6jua<sNCLrNfm_&O4DuOs2F<=O\-;kkO+g^'X^lPG?+W"_(3MR3>mX*C\hmISqa#_X,S9htNAXLuA%Drnj0%X`UdK'gaB`gr/Ob%I&KpYO]tcY576YOXRc9^0lCb#I2#G]56[nBIG2B%PO;bd6cngO@)?Z`.d*:!8;du(2IbE9kc18-Bnbpr#oNgLRo/'"uEI;a#BH-X@P8!@5?k0DlPrIWdW5r>m&5]3j4,mC5I&6nhd(.bgC8eZY$E*hpdd3Tg$74"?G>FGU/5g7i-p%aF9P?WY5d;"!+cgNXmOr1`\uj07c!I^sff<1Oh:4=`.^6A]W,:l@?oWR!i;%h+aY>,3_Qer8PZDQ\HArdZXJ-<TT'Qe%+Xf?8$0/FtLmR)!Z%Zk-.nu4bPH\)aZ41r6WU;8?B$TYQ3HNnkBKK?kh/B/P5`<j0UBQ8ACIO9t'7OS0L0+o\VQXqD/p_OK\3S(ZM8C<^C!S)V4Ln,DiZEoW3oB~>endstream
endobj
13 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2354
>>
stream
Gb!#\?#Sc3&q/*0)"bq\E3tr/+hN.p\(RK@G+0i8('X9G8@6WG8Q8:(Nr/-hL8&0>dDeH[m(G+bbPE>ocC?mNUR28\`;Y6e&q*!BG9::O':LR&*^q<B?dmR'm*E"WA`bWSU1q+K%K.A3s#*N!+qDdli[CLBSFChNofd,gIhb]:DN(I'1o2M3TK?l=.[0$Ps"DXsitn=_n<_Spn(Sr1U+pN1Wj(u+$@>A`O4g]0=$_&64W^179b&6:5PVd=jm"fsU*Q;WE9HSI=@u0q]_VeF4opkaMTKrU9`T[$Er6W73/*]$+rVi(F?6,k"sIDbhL.rIYV@(Vn/o!4h@LWTdW?uU9pOJSn*#BAmd*e1[=>]1H$rC/Y-.3Vo(Ir)rVYp>p?KQY:Hr*5Isol;][PqBP"P8Cb,-H[k[`eR7KA]p18fmZ'k.j(DbiY=YgL-h;l+K/d;,"?19H8+%:r#kb#qp+\/*(5;:'qF$<8['?);VsVl"2[<d1R.IEM5[=rNMr>i5cRQ-#a3:?a8']L2K@cGOOiLN1+TP,>#'.$*Wk_5:Kts/m;kZjk$;WbAFP6R']q"2M![%Lu[Rn%f),+FpKF-nBKQjnoNS*HL0=4?>ecN[E4hiitYRKri`\^hd7c)!4G.6t*jjh.ZsjEQSTh$q$hu^t#F$D^2<Q1QW-R`Yaa$E>4Gnfi/,>^Gjn.JbYjh++3L0^IA0FcmNpF!!KuBM/"J$g&7CV62,R'8_i_o<YqL)KbOrQ"V,E=,%!-BbXu1U+Y`K57[ZDp?[$'DNF0r_3#W51anR]Z;OWNaDKhI9_M?Oii3mq[Ue_Y$D+W6"TeaH^X=-%CO<K&KolAoe+7J>U1TW'++UgBfJ5>6c;pohYE[/J&!<aZoVMdcQN]Bt.6=]YP=$,<"@IZ\J+W,K!onqHBh4N//.h02DPnt&6(B,GH33c[M4Fqo9)l4pe+%>_ImTh"2k4"+DW0F$^*e.05&uEpb4uqh7TB[/KRUpoiRM**id)\n\RZG6?"R-bL>h+SjD(Sg=6R6`_mN^od$3=g;iT.Dk"!lGfeS,0/Xc..`X$__'&Yi4WSU<NK&h(mJ3F-/b=R1/HZeHKP<MA4!1\2<Nb\/5:&:,/[NA*h$9JY!V\e8dTOd/Nj=7-r//'5(Cba/&B+F-6q^D&<])Q`,%ZW8"0."0m4faOTZ.?e+X^"J.":8+#%6+V6),G)OK\KP6':_iU8pAYP0A[>]rn0M[>O3phR,\WrIO`+&HQsJ6rI*@JfW\o5D]bQ7U?7_&"c`0Gi8OJG``SQiE[NMee<o4-Qk=p[u*&-;Y3"3#V]C-?WQ%c#OO.h'V>3N!=FuTW,4cJE@KL>S$6c`9HV$^g+<$^/bX@g4c#W$rOCSDolN-].f-hA#5@P+l;h.V&E4V?T1lubIP`pmS%Hp`][?GPb[K@+/;mFu`2NC(80Elhne6s.;&Qg(e.,]NHWGL:RI5sLA1jL]]B*IbYk^DgGl4c&Hc#5nS\63/eZ,n^VK"jQfaM>J.L%MWI@I$Fb7:2D^[?f*2P$&;WkU$-beQQWn9GGeZJ[)XH=R$KTOIXAq^GhbT9g$ue*f7\A(#Q6h];nk0?m4o]4I*m*WA'LNiTC"A63JOKW@^Ba*5#fporZIPdW6]MP"<Ms1;r*gEgO3eN)!oi@Fu>\1WS(?o[4\i-_9`8K%nG\*:-c$>S[WM($D1C?VS:G(RS;I.!K,-krCjj"rNG>G:[l4P,Vn2bSD8WDCRWrt"e*@(6r>e5[BP!JrRJ>XB2FO>hB(]KQ_Jm[hbg*h&57u_aG1Jc:[@_c.4Wi\cnpE<@tt6JU`8LYHXh;r`2M,BS5Fe3?L,k]-f'@2an`^BrcjPXpS77h((X:raL;enDL'*&W_,nDEu:EJ7EFG9nBj]lnM)*%J"XXRJ9.7M]>;*hi'*>(4YFF##luOH](@<=s,nn!/\qsSY\S.hHsQ;R)-ZKZ``e5-J?=71k;3iSj5&hA:HVRV"o!?;kAOp)4R3n6^7J"LN8fDR*Ut@U,78>%&7r?4-Hu3=#D5=sZ#]F1hf"g63;n?,E(jumr=A[o.l;\Dg2cZ5[d3iH!tKL\pnX[[iBM$Z->6QEIPSG'P<lG&qK@J@*.>>Hc1H<?V5O0dnX$IPU<rshO<7bTo)l)h[0?h_VLT"sKW9&Pak3/tpH9BGUV>>TkQLs&#M!!o_dlr.qUmicnS]9$1%lm(a5Yq2a1HLC&Be+S_OPqgF(u78H[j=Zq^B,8$Uq)'WdUKUi8]QYml5!,O1$-"_s(]r%/p/55GCaBe-a!5mc"trkWi>Hft5CYJ?E<s-n%qSRX1nJh:OEG&'nQX/g2L;C>\usq`=aPRLKelpLM)2DOJfL#5D\>e,~>endstream
endobj
14 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2256
>>
stream
GauHLgN);`(4FM1m-6VBR47-'S*R^Ii,lTR>`N[LPH)8$,D^Z)`Rk6\^HY?m&kJ4rVbbj)<"N0cQaPGZ-oTWGA"16tP3=NlgO#E+LQX[N%BDcd8:GUPpXS4of0$!9S\kQQ2(s:Q?7b^Tq]NUa>7S'1Ng@/Ml[`%+JZU^c/gMaU=I(5q'_>cY;Z7U]h^J<V#k,.iC=Cr3kjt(iV!V02&DZKbSg?7`g91CPN8fnY+&4^t-O7m$II@,U[HTZ:e*`BGFEYkDSD4d]rX1PiJ8)oY<jukP!'e3$S+"ZLf#\3/]lt#"A!=1]])D7?"hEVq``'9W)$.ts0@]D`f8nWhmSV`,'"\THTuXS+%Cj,0b/1jG\pcK6UNOq#feckK'T=W=c&h(ll^EuLIZ.lu7%S#VRE-0i<(WpVX">jkrJ[(POk3.on#Aj8JutV5>1SMk1WncF@O]*ND9ccNIIX;>/NY>A/<&Wug@A,WV3.u3U]N+0/7iX5<Ij)9.mRh!ST^o2NJg/t=:2/i1^7qtOri`]&l.tCq@,pZ*U9&&X3$&6D7RY2=1"0,@dqSIXALVA)u^jsQ=@aIkE+n?$AZba0A&)`^]iW:<%ed/(e`HVm57!_#C=`p&=?D=*%]NWJlRM2:SB6#AZ:_j+P?&7+!PC+SQ=5<#ln7gGc;%4.O"ES&o9gsQ>?dc'W#iXX&4^!W):Q;WJMqnn`a4c]p(<-a+gtZ9Q>$S&0%Ks*c5KY(SHMlr;.t-6Cl$9nf=i7Tla^(`=)2,Pp08o@g/OIFrECEkeOTi5ZT&Zr2$dh+RS33\O&[bO<M6e-?>saX&/&.SGQ?^D7;Tlg##*hhsK91oeAS*Ot4r`>+?bWJ0)L+@`aW"HL$N&*8rV?A8*OF\Ug,bR)f\g7=H;L'7glH2``$n[V[nWG.#5JG'J%iNaMV=>n`EiAs>?pj4,BF6Xm23)ar2$2?e#r4;3*P2UP"8N+iV:rgZ&sW!Y$]@PMgb>o,ipW"A&tL;4@*nA!SLpt9E6BRZJP2g*<HGIjnG2(gNWo=;#4aHI8U^;Gj17PC@`V*Y=K:hEa9ZoSddIARjo;`r[(D[fQ=BYF^b<A7A[J5#/X]!8'PIt/\_dQ5E0l-gZ4,mrU+PO;Jcngp':.V)SFXUY/oZF,U,m>EaE)#.F%+7!m8c&6RU1lknDGO:FX<e[B.r:FG*@_1+YhLcVthHf>Pm+#HjMZ)`^kkY(j3#Xp_ID8RQ+aHHOAAil4Z>\;IU->,:^p2[@Vq!W?\F\]&[O<"2d0&j`?n`I[s(C=%',pbq+@m-d^O)d'qc]c2-lG0mP;%Q"l3D;^oR[)XL#JGSL=P_%XcCNQl`XV/H,Cosiu/&],<q3&'N$P$LOn?d;)%?jPC>4`9ERNEIDJtQ/dR*jYt"IR1=Y(IZ/7WUdFaA?TJm1jLrPWl4Gs(UH+i&[np39o&K$O_2o4uIJn0?2G:G$$][ILSiH-ZY7)u:.5$c6Ui0]O$#\,dUb&E_oEk0<qbE!t=9i^b`5D$i\I@b[%/`0&`]EF6W+t<W-(ja)kItdt:+(L>eg;=p)^A.XuW);.#UB`Hj1@b[dYjY(aC()9Q_>b/?6N8D'6ViKGmj0$#O\`Y&,2st62SKK0_f"._@/<_WMJhi,hm_fh1bf6q;<Wdr:_f$]_`.YR9Y_-q./*(W*R+95\F07-+:`@4+1KVsm"Ke%!kl,oDSObim5f:D%"G-tl&$;b2AMXY)Ta#/rNS\5:RKT:mg4(6F(t?ga851]4VDrUqc'cHl=d<'e9nRVD'L!O0:<;`c@qc,O#;U9F:7NJNFq@od=*UOb`$gG4iEd\5S'rBILu;`%51`DJg"ai%h'[*>&Yt`!`cp%:Xj5KJ)oKIP(aW4h([_ara:K2Bf98;hKP3,0$K\B@nYV7p4K)?S&s)`<cPmAj47Y9Cn@j'fSN@WH]41FD7)Cn1-V1Y\lc-m0452`TP;.Y3+j1-U1YM/9f2`Fm($'2<kE@Nb5?%kdj:dJG2)^$d<fmRVaRRU?qg'K1GA8S*>/6RQiU'ebliT1"ihTnAk%&(+1tE9:Z2+1l+K&CDp."DBjp0'!j_j!VeZeLWb&K4mein!]N._M[&u4A@;sTj!8%q5_/fq^_=%9;U8+W"+ZA%FR/^c#4.'m?1F.4V^4Y/>f_'Pj+CBoqIp*"bM.-6"`klb>\2Vf/)RNV4aXs@%:^g*10@^BQ/L>jcm_Ce?PoK^CY@=OI-<)t23tD)TgATaer_^)1c+boDI$oo'82"7Tkj!X`I/:n~>endstream
endobj
15 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2720
>>
stream
GatU4>BALj&q9"FFQJ@L+pto439Oja/3?g>irqFl=8>9#K:,+DN\2[FYMZNROtEpa\B`f.kV=qWIJ^bHO\S:AlfpEfM(X2PI&3$EOM;spdOatPjS?L(HhGHJ%1G+l*mdUR8&s6JR9/U[plV]s1q!jZ(K:>\/^ggg)&7?u9"rC+SC.kK7.qflnWEb<jStp,4!XZ_l1hj&mV+THHV#gAEhH3%[`s+u:D'^X\%H$]NEQ_?"0_U:9XP<s1WC(`)e8>gB0fSAWdhpUYDsJfM\1%K_>SUC<:@F3LLf'l(U@"RL4%=`@C:2o33i)^_R6<@6X!CWS-_JM8hsP-30B]JJC\0SP4a?WA51f8_G?94OiVV/"G2D?JPb\HOlZ[uA:njjh-+D7/5k7]AdirP6Q><fDV/]F&auId+9\QsBi8J-[6R<;#8.=!A8,:5#;XC/g`Ir'!Z>KS,_IH^a[3:dFV&$l^J4<)<)Pb]CI;`FR810]c^dT\M]VaN$);MmP@r$+OJb\>'F>*RBTe0=rb*,QD4onK7g_#>o=VF]3;:i0/#&[JC\[<n8.%'6OHpcL\_me5<\t-:F[H,5s4#9+cUC<%=:]HX/G$+g2IeBqKm]:8[5<]A1I%XB[u4dqP`::.f#a3Wbnaf0-<mKL0iX#Q;,mPgFT\(;b'[8+JsITVUQW#&OIJC)KWBFWkY.`OWSrS(fl)Fh&_S%]L>t`Jc-[o6T`_6][P>%+[2m;l'Oi(*<h&SoST#T0,@G1?ZGscogQ;%k)>"5Slrdi,&@YN_EXZfZNuWVS*1L<iR`R5J-\K9BFfUSQI!%Mbk1$FDpI_C#o<ASQW7Qh\e@s0O^)c_pLS[N+>#0OZWZh:p]l!5t&*4DJW(H.8H5ms3J[07ujo4)kF&sD74nk-;0=)Cq]W","gsX\XH<9k4q1D-mTA6It]O0((idOI?n(O>]Ed%E#?_,:XgGYNLFVW=TNCK?k0uaREPsT=&RnVj)dWCp(Rl$%UW0Knq-*t7_FD18FT.U6n$P3rdNALb$Jp,9Wg8r9C!TjCI!8B-cmIA79.ACT.erRaj&H+*`OhEH2mBT\!e+eS7SD"g.O_[bp4_7N-BV3BJ?j/p[@4$1MG_5N:W:2Qk#!MI54>!27L"<(#+60sUA+$))N$>,=n6s-KAaH*,d?,knpI,>(%@KQphB>)s5KfIbN*fL'[#$SZh@Vo'>kW=LNahr*pkr(Eo6uDqAct-UU0q8b/X-JP<oa6p<ZOGMWEW&rLi/9JLg6F:VJRfl1&]]V),t`DOesTM.9Ir^r/.BI'm)[7d2#6)Gsr9^6"o("ZA/5.)6)FVe7A=T]VpGU,ar,Cl01H/W1^d6?][)jZFuO4b2OZufGu.1^!OjXb.Kg4nT-Wn0^G.O9<*DBlRT4U))c/J0b\MC,K7Jp)4*(7T3PN4#D5$Teai;8__P"8:'5l21Mu;\^c/k4Y%(j#k=;[`nAhh&=)f^N(a*\A2#[u"&L)SnKc>pY`@s;e2%4nHfi>*#;'O?8:fd=Or=?'sE]cuO5FH7u>,=)dm5dsng<OiOAEP8ID4B.:RBhdIU5":5H?f?I?spq;>&NE%qFs/FM2D$90K1`6crN0<q2UhE2+XG38_8;%>%NS_%S3B_e(fHRR>I,&ITNaC9'=DnF`1=B2]E87I$Sa5?QAIZBm'VrCLW^6Q7*R/#@r69'8Qeo,"]`1V(AWS<-4#"Jm(@r%H?4NQ/^[o!.QRNI@)7QNd-;F6(P*%7:/P'oOF<@m@$ERL)".5AqBUU)41ppAJ16H2U1<4+=mdcmXf5=e^SRkEbEa0C$9b_rB5,*BqC&tCi[KPiFE+7O.fJT5>Z?o+QQ]mhdd5B"A]NkIcV&IUMB44?LG>ednnMKP;+Sr5<=R"N1G!tlt%e+C0*8<:8^Xd9<_>6dE$d>XQb7YgGBM"aQr-FNDX"G+\Xno/+'U.kgC?d0mf[X?\ZCA4#;#r?r(M;)2;H>:!Q.8FK4:_'a6F!jPF4kc,*h8FmnMmh/YgLp!WHYY.6`AmdpnC\.<\dSD@N#FRIFl<la&=lAe_ghn=2G[jrPY4^-cV8W2@!"3k_g$[saA?Y>`"kjG>9SNED<%r1^j"ZjF6\TA)H\,PaZMAAkACXq65drj-eX)m6ZGB3fL9(52*G<jWrI*V]P=-)fg=,Zn@de1;s!R"\nG=ul9h>/a"+;d04]LuAWD1#%%)EUDo:YQkMi,cnWIH8UG,Ip:!dI7H3GGqn(LiFG7a$=Nu(q?!l+b2Xf3$[MJFE7_<%Y\9t5F3\mkq$Pa$3SNBclB_D9uc)r-sI]i&$0LbPqC;M-I$Au7W;Mb59'*0Y'B+gkH-e0k.#,/b!^4;(^_V;!A@65N0A,!CRK%ATeo'e=YnoS5>e'(P=*sX.KZ6',#cN;/ocol(/L?5N8I(f"-j]TQ-Viq+9/Y\YDFHFXZA$dFn9!jAfJqkrAONIf:QJfP\Y'i!9(=9%DZo&boSpSr:Q7?_779JMo5:ff\Gq-38%j.RW(4L@['N\2_u@+TV7L[Ld.)ZWaDrS[JNr)i_Y"7_%t"^nV;S]Zd`]u>B'I])k]_DQWAiko#pJ\*o$]O%QFPjhh8]M76os0n]m7(m:1eF-<8,h'fNd@%,!css1pp59dNu!Yd.oW,E/Y_mpKaMo]e@?9I+k2$IPHQ!Dr:mdAYqH9Q;1"<?dP<]/3UPR7c(RV:X`tN@k_+-mY"^543B."5Q%cf,gaEIfYYn3D9~>endstream
endobj
16 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1302
>>
stream
GatU2gMYb8&:N/3bbJDk[!7k+V^=]7'X1fN&J>69bBc%?2I5`#bG>@7lT;GK>Z$e],EW"",.VbTkKFg_0VuNDr0J/6qZU52`t0W&=@&HG)(][`j'qY/eD7YB2l>!8]+Lmu"fLglj:cML^5/NeApM8`.N%&HDa0.Lq`AG!JZc#hY2Bu)Xcdo0Mf5ip,m9hVoU<V\qqo/JU)\>$dfn%Z<)3[Cc?6PP1jjW6G]tnnNXgdsI.!n9c''DFAlk'8R_9@\6@RG7?\IPd0Jfk]RRRIs:Y2N'H9^C';X0Q7"_OY0RCV:V`Z8Y6<ZW:&[4ioXn:8qf#e4'3oP09tf\6Tukn([8n8!\hZP4II/c&>J\gR.cZhWYYZF7=i<(41u`5ZeGIfXagD2$aU,Y/sio_X['`fhgro,&7TY3f^A%MchTc\<"8>'&$@J1sW0C1Lmmd+-\Q%EbcRX$jJ$C04psSU)CqPQ=Ft]5$<MC*iTdI/P.q[IAquVHc\6`RAA;TDET2`#^h^9!%@MH,Xc0U;C>\Or(KjiRt]RMuhk>lZ#?R\"&Y&7E"nr`_u$Dbn8aO.#eH[cKA7n6ubL,J$;r'(tFpW&*1h;U`RjRds;YOk0oP.'BDiGUhj^,/)$[bdQ,hE34s70K=YWt1q4W;<(7N4;VK.c#s9W<Ppn)&:c;)Er?TH2@+iGHCojPMp&.;^?^NWAq7TXcE]an?@Q,Iih*:*Pg1f+Y-)$G(]CBf=X%OL(((+=kh>&47Orp1.)3K!@C5H+%K=2ZU-Ca12VslrCFB,,N<ZI0/Eb5aEnJFG.:Z0#MSt1a0MOl:1(2K`9D!di\&V_Wf#=+E*3F!0=7M<N2@qDHRQ4,?L=+=>N8)^[J(-dLi"l`>Z74a.>%+6$V!:j9TSi<M^OT?:3b-FG`87a'KEtJ8`C:eY4X6Rupeu6^#%'jG8'G$Z!Rk&Ya$"]E5oBiHE9rk\sTe<W`m.F@aV!s.n07q3eSSNd^G9rJk>hCW6fc^,cdJ2(b?&6@Xb\$0?\IR*#ZqsubM021V9Eug]VeQ,;JS+DSY#T2-PNLJ75""#^8Z(ECXZPXr1q'*9c@s=T0H>B7G[Hq.)BCQgP2]"89SKW;a2UNE;CfI=+e#M`Ki.l-p:GHD$QU!$,sac&[aX7$7BgI9Am(8/#-T#L"08oj)\UQ]+J\XE%Kg0DZ"eiC5atN,IEeU4)PQ9EZpNFYCA_"ADe\ot*`2@7Un`@V#VPSI*pPYQrIi6"g1&&0GubC+mtjTG2HJRq^$4\A"%ZkQe#?ri-BAc\\q<L73inHN:H.t9DMHPg)qZA^~>endstream
endobj
xref
0 17
0000000000 65535 f
0000000073 00000 n
0000000114 00000 n
0000000221 00000 n
0000000333 00000 n
0000000528 00000 n
0000000723 00000 n
0000000918 00000 n
0000001113 00000 n
0000001308 00000 n
0000001377 00000 n
0000001683 00000 n
0000001767 00000 n
0000004004 00000 n
0000006450 00000 n
0000008798 00000 n
0000011610 00000 n
trailer
<<
/ID
[<2ea7118942454ce9d6e3514eaf0def7b><2ea7118942454ce9d6e3514eaf0def7b>]
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
/Info 10 0 R
/Root 9 0 R
/Size 17
>>
startxref
13004
%%EOF

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 943 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 677 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 884 KiB

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