Compare commits

..

34 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:33:41 -06:00
111 changed files with 2481 additions and 1735 deletions

36
.env.example Normal file
View File

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

View File

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

View File

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

View File

@@ -31,6 +31,7 @@ const MIGRATION_ORDER = [
'features/audit-log', // Centralized audit logging; independent
'features/ownership-costs', // Depends on vehicles and documents; TCO recurring costs
'features/subscriptions', // Stripe subscriptions; depends on user-profile, vehicles
'core/identity-migration', // Cross-cutting UUID migration; must run after all feature tables exist
];
// Base directory where migrations are copied inside the image (set by Dockerfile)

View File

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

View File

@@ -29,7 +29,7 @@ export const FEATURE_TIERS: Record<string, FeatureConfig> = {
'vehicle.vinDecode': {
minTier: 'pro',
name: 'VIN Decode',
upgradePrompt: 'Upgrade to Pro to automatically decode VIN and populate vehicle details from the NHTSA database.',
upgradePrompt: 'Upgrade to Pro to automatically decode VIN and populate vehicle details from the vehicle database.',
},
'fuelLog.receiptScan': {
minTier: 'pro',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,9 +19,9 @@ export const listUsersQuerySchema = z.object({
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
// Path param for user auth0Sub
export const userAuth0SubSchema = z.object({
auth0Sub: z.string().min(1, 'auth0Sub is required'),
// Path param for user UUID
export const userIdSchema = z.object({
userId: z.string().uuid('Invalid user ID format'),
});
// Body for updating subscription tier
@@ -50,7 +50,7 @@ export const promoteToAdminSchema = z.object({
// Type exports
export type ListUsersQueryInput = z.infer<typeof listUsersQuerySchema>;
export type UserAuth0SubInput = z.infer<typeof userAuth0SubSchema>;
export type UserIdInput = z.infer<typeof userIdSchema>;
export type UpdateTierInput = z.infer<typeof updateTierSchema>;
export type DeactivateUserInput = z.infer<typeof deactivateUserSchema>;
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
* @ai-summary HTTP client for OCR service communication
*/
import { logger } from '../../../core/logging/logger';
import type { JobResponse, ManualJobResponse, OcrResponse, ReceiptExtractionResponse, VinExtractionResponse } from '../domain/ocr.types';
import type { JobResponse, ManualJobResponse, OcrResponse, ReceiptExtractionResponse, VinDecodeResponse, VinExtractionResponse } from '../domain/ocr.types';
/** OCR service configuration */
const OCR_SERVICE_URL = process.env.OCR_SERVICE_URL || 'http://mvp-ocr:8000';
@@ -373,6 +373,55 @@ export class OcrClient {
return result;
}
/**
* Decode a VIN string into structured vehicle data via Gemini.
*
* Unlike other OCR methods, this sends JSON (not multipart) because
* VIN decode has no file upload.
*
* @param vin - 17-character Vehicle Identification Number
* @returns Structured vehicle data from Gemini decode
*/
async decodeVin(vin: string): Promise<VinDecodeResponse> {
const url = `${this.baseUrl}/decode/vin`;
logger.info('OCR VIN decode request', {
operation: 'ocr.client.decodeVin',
url,
vin,
});
const response = await this.fetchWithTimeout(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vin }),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('OCR VIN decode failed', {
operation: 'ocr.client.decodeVin.error',
status: response.status,
error: errorText,
});
const err: any = new Error(`OCR service error: ${response.status} - ${errorText}`);
err.statusCode = response.status;
throw err;
}
const result = (await response.json()) as VinDecodeResponse;
logger.info('OCR VIN decode completed', {
operation: 'ocr.client.decodeVin.success',
success: result.success,
vin: result.vin,
confidence: result.confidence,
processingTimeMs: result.processingTimeMs,
});
return result;
}
/**
* Check if the OCR service is healthy.
*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ export class SubscriptionsController {
*/
async getSubscription(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const subscription = await this.service.getSubscription(userId);
@@ -39,7 +39,7 @@ export class SubscriptionsController {
reply.status(200).send(subscription);
} catch (error: any) {
logger.error('Failed to get subscription', {
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
error: error.message,
});
reply.status(500).send({
@@ -54,14 +54,14 @@ export class SubscriptionsController {
*/
async checkNeedsVehicleSelection(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const result = await this.service.checkNeedsVehicleSelection(userId);
reply.status(200).send(result);
} catch (error: any) {
logger.error('Failed to check needs vehicle selection', {
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
error: error.message,
});
reply.status(500).send({
@@ -85,8 +85,8 @@ export class SubscriptionsController {
reply: FastifyReply
): Promise<void> {
try {
const userId = (request as any).user.sub;
const email = (request as any).user.email;
const userId = request.userContext!.userId;
const email = request.userContext!.email || '';
const { tier, billingCycle, paymentMethodId } = request.body;
// Validate inputs
@@ -141,7 +141,7 @@ export class SubscriptionsController {
reply.status(200).send(updatedSubscription);
} catch (error: any) {
logger.error('Failed to create checkout', {
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
error: error.message,
});
reply.status(500).send({
@@ -156,14 +156,14 @@ export class SubscriptionsController {
*/
async cancelSubscription(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const subscription = await this.service.cancelSubscription(userId);
reply.status(200).send(subscription);
} catch (error: any) {
logger.error('Failed to cancel subscription', {
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
error: error.message,
});
reply.status(500).send({
@@ -178,14 +178,14 @@ export class SubscriptionsController {
*/
async reactivateSubscription(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const subscription = await this.service.reactivateSubscription(userId);
reply.status(200).send(subscription);
} catch (error: any) {
logger.error('Failed to reactivate subscription', {
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
error: error.message,
});
reply.status(500).send({
@@ -207,8 +207,8 @@ export class SubscriptionsController {
reply: FastifyReply
): Promise<void> {
try {
const userId = (request as any).user.sub;
const email = (request as any).user.email;
const userId = request.userContext!.userId;
const email = request.userContext!.email || '';
const { paymentMethodId } = request.body;
// Validate input
@@ -228,7 +228,7 @@ export class SubscriptionsController {
});
} catch (error: any) {
logger.error('Failed to update payment method', {
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
error: error.message,
});
reply.status(500).send({
@@ -243,14 +243,14 @@ export class SubscriptionsController {
*/
async getInvoices(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const invoices = await this.service.getInvoices(userId);
reply.status(200).send(invoices);
} catch (error: any) {
logger.error('Failed to get invoices', {
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
error: error.message,
});
reply.status(500).send({
@@ -273,7 +273,7 @@ export class SubscriptionsController {
reply: FastifyReply
): Promise<void> {
try {
const userId = (request as any).user.sub;
const userId = request.userContext!.userId;
const { targetTier, vehicleIdsToKeep } = request.body;
// Validate inputs
@@ -311,7 +311,7 @@ export class SubscriptionsController {
reply.status(200).send(updatedSubscription);
} catch (error: any) {
logger.error('Failed to downgrade subscription', {
userId: (request as any).user?.sub,
userId: request.userContext?.userId,
error: error.message,
});
reply.status(500).send({

View File

@@ -570,11 +570,13 @@ export class SubscriptionsService {
}
// Update subscription with Stripe subscription ID
// Period dates moved from subscription to items in API 2025-03-31.basil
const item = stripeSubscription.items?.data?.[0];
await this.repository.update(subscription.id, {
stripeSubscriptionId: stripeSubscription.id,
status: this.mapStripeStatus(stripeSubscription.status),
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
currentPeriodStart: new Date((item?.current_period_start ?? 0) * 1000),
currentPeriodEnd: new Date((item?.current_period_end ?? 0) * 1000),
});
// Log event
@@ -608,11 +610,13 @@ export class SubscriptionsService {
const tier = this.determineTierFromStripeSubscription(stripeSubscription);
// Update subscription
// Period dates moved from subscription to items in API 2025-03-31.basil
const item = stripeSubscription.items?.data?.[0];
const updateData: UpdateSubscriptionData = {
status: this.mapStripeStatus(stripeSubscription.status),
tier,
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
currentPeriodStart: new Date((item?.current_period_start ?? 0) * 1000),
currentPeriodEnd: new Date((item?.current_period_end ?? 0) * 1000),
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end || false,
};
@@ -782,7 +786,7 @@ export class SubscriptionsService {
): Promise<void> {
try {
// Get user profile for email and name
const userProfile = await this.userProfileRepository.getByAuth0Sub(userId);
const userProfile = await this.userProfileRepository.getById(userId);
if (!userProfile) {
logger.warn('User profile not found for tier change notification', { userId });
return;
@@ -849,6 +853,7 @@ export class SubscriptionsService {
switch (stripeStatus) {
case 'active':
case 'trialing':
case 'incomplete':
return 'active';
case 'past_due':
return 'past_due';
@@ -931,7 +936,7 @@ export class SubscriptionsService {
// Sync tier to user_profiles table (within same transaction)
await client.query(
'UPDATE user_profiles SET subscription_tier = $1 WHERE auth0_sub = $2',
'UPDATE user_profiles SET subscription_tier = $1 WHERE id = $2',
[newTier, userId]
);

View File

@@ -75,10 +75,18 @@ export class StripeClient {
try {
logger.info('Creating Stripe subscription', { customerId, priceId, paymentMethodId });
// Attach payment method to customer before creating subscription
if (paymentMethodId) {
await this.stripe.paymentMethods.attach(paymentMethodId, {
customer: customerId,
});
logger.info('Payment method attached to customer', { customerId, paymentMethodId });
}
const subscriptionParams: Stripe.SubscriptionCreateParams = {
customer: customerId,
items: [{ price: priceId }],
payment_behavior: 'default_incomplete',
payment_behavior: 'error_if_incomplete',
payment_settings: {
save_default_payment_method: 'on_subscription',
},
@@ -93,13 +101,16 @@ export class StripeClient {
logger.info('Stripe subscription created', { subscriptionId: subscription.id });
// Period dates moved from subscription to items in API 2025-03-31.basil
const item = subscription.items?.data?.[0];
return {
id: subscription.id,
customer: subscription.customer as string,
status: subscription.status as StripeSubscription['status'],
items: subscription.items,
currentPeriodStart: (subscription as any).current_period_start || 0,
currentPeriodEnd: (subscription as any).current_period_end || 0,
currentPeriodStart: item?.current_period_start ?? 0,
currentPeriodEnd: item?.current_period_end ?? 0,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at || undefined,
created: subscription.created,
@@ -140,13 +151,15 @@ export class StripeClient {
logger.info('Stripe subscription canceled immediately', { subscriptionId });
}
const item = subscription.items?.data?.[0];
return {
id: subscription.id,
customer: subscription.customer as string,
status: subscription.status as StripeSubscription['status'],
items: subscription.items,
currentPeriodStart: (subscription as any).current_period_start || 0,
currentPeriodEnd: (subscription as any).current_period_end || 0,
currentPeriodStart: item?.current_period_start ?? 0,
currentPeriodEnd: item?.current_period_end ?? 0,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at || undefined,
created: subscription.created,
@@ -286,14 +299,15 @@ export class StripeClient {
logger.info('Retrieving Stripe subscription', { subscriptionId });
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
const item = subscription.items?.data?.[0];
return {
id: subscription.id,
customer: subscription.customer as string,
status: subscription.status as StripeSubscription['status'],
items: subscription.items,
currentPeriodStart: (subscription as any).current_period_start || 0,
currentPeriodEnd: (subscription as any).current_period_end || 0,
currentPeriodStart: item?.current_period_start ?? 0,
currentPeriodEnd: item?.current_period_end ?? 0,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at || undefined,
created: subscription.created,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,18 +6,14 @@
#
# This file removes development-only configurations:
# - 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:
# Traefik - Production log level and dashboard auth
# Traefik - Dashboard auth middleware
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:
- "traefik.enable=true"
- "traefik.http.routers.traefik-dashboard.rule=Host(`traefik.motovaultpro.local`)"
@@ -26,64 +22,10 @@ services:
- "traefik.http.services.traefik-dashboard.loadbalancer.server.port=8080"
- "traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$$2y$$10$$foobar"
# Backend - Production log level
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
# Production Variables
#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
# Sandbox Variables
STRIPE_PRO_MONTHLY_PRICE_ID: prod_TzGPZ13at4CGB5
STRIPE_PRO_YEARLY_PRICE_ID: prod_TzGODNOSOwWGFt
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: prod_TzGO76gcuP9XI3
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: prod_TzGM2nhayo7kc7
# 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
# PostgreSQL - Remove dev ports
mvp-postgres:
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:
ports: []
# Redis log levels: debug | verbose | notice | warning
command: redis-server --appendonly yes --loglevel warning

View File

@@ -63,27 +63,6 @@ services:
mvp-ocr:
image: ${OCR_IMAGE:-git.motovaultpro.com/egullickson/ocr:latest}
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)

View File

@@ -11,8 +11,9 @@ services:
command:
- --configFile=/etc/traefik/traefik.yml
environment:
# Traefik log levels: TRACE | DEBUG | INFO | WARN | ERROR
LOG_LEVEL: debug
# Traefik natively reads TRAEFIK_LOG_LEVEL (maps to --log.level)
# Levels: TRACE | DEBUG | INFO | WARN | ERROR
TRAEFIK_LOG_LEVEL: ${TRAEFIK_LOG_LEVEL:-DEBUG}
CLOUDFLARE_DNS_API_TOKEN_FILE: /run/secrets/cloudflare-dns-token
ports:
- "80:80"
@@ -60,7 +61,7 @@ services:
VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3}
VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com}
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
restart: unless-stopped
environment:
@@ -115,15 +116,15 @@ services:
CONFIG_PATH: /app/config/production.yml
SECRETS_DIR: /run/secrets
# Pino log levels: trace | debug | info | warn | error | fatal
LOG_LEVEL: debug
LOG_LEVEL: ${BACKEND_LOG_LEVEL:-debug}
# Service references
DATABASE_HOST: mvp-postgres
REDIS_HOST: mvp-redis
#Stripe Variables
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
# Stripe Price IDs (override via .env for staging/production)
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}
volumes:
# Configuration files (K8s ConfigMap equivalent)
- ./config/app/production.yml:/app/config/production.yml:ro
@@ -192,7 +193,7 @@ services:
restart: unless-stopped
environment:
# Python log levels: DEBUG | INFO | WARNING | ERROR | CRITICAL
LOG_LEVEL: debug
LOG_LEVEL: ${BACKEND_LOG_LEVEL:-debug}
REDIS_HOST: mvp-redis
REDIS_PORT: 6379
REDIS_DB: 1
@@ -205,8 +206,8 @@ services:
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
VERTEX_AI_LOCATION: global
GEMINI_MODEL: gemini-3-flash-preview
volumes:
- /tmp/vin-debug:/tmp/vin-debug
- ./secrets/app/auth0-ocr-client-id.txt:/run/secrets/auth0-ocr-client-id:ro
@@ -239,11 +240,11 @@ services:
POSTGRES_USER: postgres
POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password
POSTGRES_INITDB_ARGS: --encoding=UTF8
LOG_LEVEL: debug
LOG_LEVEL: ${BACKEND_LOG_LEVEL:-debug}
# 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)
POSTGRES_LOG_MIN_DURATION_STATEMENT: 0
POSTGRES_LOG_MIN_DURATION_STATEMENT: ${POSTGRES_LOG_MIN_DURATION:-0}
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- mvp_postgres_data:/var/lib/postgresql/data/pgdata
@@ -271,7 +272,7 @@ services:
container_name: mvp-redis
restart: unless-stopped
# Redis log levels: debug | verbose | notice | warning
command: redis-server --appendonly yes --loglevel debug
command: redis-server --appendonly yes --loglevel ${REDIS_LOGLEVEL:-debug}
volumes:
- mvp_redis_data:/data
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:
- `GET /api/platform/vehicle?vin={vin}` - Decode VIN to vehicle details
- 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)
**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`
- 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 error handling paths
@@ -194,7 +194,7 @@ All 15 features have test suites with unit and/or integration tests:
- `vehicles` - Unit + integration tests
### Mock Strategy
- **External APIs**: Completely mocked (vPIC, Google Maps)
- **External APIs**: Completely mocked (OCR service, Google Maps)
- **Database**: Real database with transactions
- **Redis**: Mocked for unit tests, real for integration
- **Auth**: Mocked JWT tokens for protected endpoints
@@ -319,8 +319,8 @@ describe('Error Handling', () => {
).rejects.toThrow('Invalid VIN format');
});
it('should handle vPIC API failure', async () => {
mockVpicClient.decode.mockRejectedValue(new Error('API down'));
it('should handle OCR service failure', async () => {
mockOcrClient.decodeVin.mockRejectedValue(new Error('API down'));
const result = await vehicleService.create(validVehicle, 'user123');
expect(result.make).toBeNull(); // Graceful degradation

View File

@@ -644,7 +644,7 @@ When you attempt to use a Pro feature on the Free tier, an **Upgrade Required**
### 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 NHTSA database.
**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:**
@@ -655,7 +655,7 @@ When you attempt to use a Pro feature on the Free tier, an **Upgrade Required**
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 NHTSA database and auto-populates: Year, Make, Model, Engine, Transmission, and Trim
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.

View File

@@ -1,21 +1,31 @@
{
"testModules": [
{
"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/admin/__tests__/useAdmins.test.tsx",
"tests": [
{
"name": "renders both buttons with correct text",
"fullName": "ActionBar renders both buttons with correct text",
"name": "should fetch admin users",
"fullName": "Admin user management hooks useAdmins should fetch admin users",
"state": "passed"
},
{
"name": "calls onAddVehicle when Add Vehicle button clicked",
"fullName": "ActionBar calls onAddVehicle when Add Vehicle button clicked",
"name": "should create admin and show success toast",
"fullName": "Admin user management hooks useCreateAdmin should create admin and show success toast",
"state": "passed"
},
{
"name": "calls onLogFuel when Add Fuel Log button clicked",
"fullName": "ActionBar calls onLogFuel when Add Fuel Log button clicked",
"name": "should handle create admin error",
"fullName": "Admin user management hooks useCreateAdmin should handle create admin error",
"state": "passed"
},
{
"name": "should revoke admin and show success toast",
"fullName": "Admin user management hooks useRevokeAdmin should revoke admin and show success toast",
"state": "passed"
},
{
"name": "should reinstate admin and show success toast",
"fullName": "Admin user management hooks useReinstateAdmin should reinstate admin and show success toast",
"state": "passed"
}
]

View File

@@ -48,7 +48,8 @@ describe('AdminUsersPage', () => {
mockUseAdminAccess.mockReturnValue({
isAdmin: true,
adminRecord: {
auth0Sub: 'auth0|123',
id: 'admin-uuid-123',
userProfileId: 'user-uuid-123',
email: 'admin@example.com',
role: 'admin',
createdAt: '2024-01-01',

View File

@@ -55,7 +55,8 @@ describe('useAdminAccess', () => {
mockAdminApi.verifyAccess.mockResolvedValue({
isAdmin: true,
adminRecord: {
auth0Sub: 'auth0|123',
id: 'admin-uuid-123',
userProfileId: 'user-uuid-123',
email: 'admin@example.com',
role: 'admin',
createdAt: '2024-01-01',

View File

@@ -42,7 +42,8 @@ describe('Admin user management hooks', () => {
it('should fetch admin users', async () => {
const mockAdmins = [
{
auth0Sub: 'auth0|123',
id: 'admin-uuid-123',
userProfileId: 'user-uuid-123',
email: 'admin1@example.com',
role: 'admin',
createdAt: '2024-01-01',
@@ -68,11 +69,12 @@ describe('Admin user management hooks', () => {
describe('useCreateAdmin', () => {
it('should create admin and show success toast', async () => {
const newAdmin = {
auth0Sub: 'auth0|456',
id: 'admin-uuid-456',
userProfileId: 'user-uuid-456',
email: 'newadmin@example.com',
role: 'admin',
createdAt: '2024-01-01',
createdBy: 'auth0|123',
createdBy: 'admin-uuid-123',
revokedAt: null,
updatedAt: '2024-01-01',
};
@@ -131,11 +133,11 @@ describe('Admin user management hooks', () => {
wrapper: createWrapper(),
});
result.current.mutate('auth0|123');
result.current.mutate('admin-uuid-123');
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockAdminApi.revokeAdmin).toHaveBeenCalledWith('auth0|123');
expect(mockAdminApi.revokeAdmin).toHaveBeenCalledWith('admin-uuid-123');
expect(toast.success).toHaveBeenCalledWith('Admin revoked successfully');
});
});
@@ -148,11 +150,11 @@ describe('Admin user management hooks', () => {
wrapper: createWrapper(),
});
result.current.mutate('auth0|123');
result.current.mutate('admin-uuid-123');
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockAdminApi.reinstateAdmin).toHaveBeenCalledWith('auth0|123');
expect(mockAdminApi.reinstateAdmin).toHaveBeenCalledWith('admin-uuid-123');
expect(toast.success).toHaveBeenCalledWith('Admin reinstated successfully');
});
});

View File

@@ -101,12 +101,12 @@ export const adminApi = {
return response.data;
},
revokeAdmin: async (auth0Sub: string): Promise<void> => {
await apiClient.patch(`/admin/admins/${auth0Sub}/revoke`);
revokeAdmin: async (id: string): Promise<void> => {
await apiClient.patch(`/admin/admins/${id}/revoke`);
},
reinstateAdmin: async (auth0Sub: string): Promise<void> => {
await apiClient.patch(`/admin/admins/${auth0Sub}/reinstate`);
reinstateAdmin: async (id: string): Promise<void> => {
await apiClient.patch(`/admin/admins/${id}/reinstate`);
},
// Audit logs
@@ -328,62 +328,62 @@ export const adminApi = {
return response.data;
},
get: async (auth0Sub: string): Promise<ManagedUser> => {
get: async (userId: string): Promise<ManagedUser> => {
const response = await apiClient.get<ManagedUser>(
`/admin/users/${encodeURIComponent(auth0Sub)}`
`/admin/users/${userId}`
);
return response.data;
},
getVehicles: async (auth0Sub: string): Promise<AdminUserVehiclesResponse> => {
getVehicles: async (userId: string): Promise<AdminUserVehiclesResponse> => {
const response = await apiClient.get<AdminUserVehiclesResponse>(
`/admin/users/${encodeURIComponent(auth0Sub)}/vehicles`
`/admin/users/${userId}/vehicles`
);
return response.data;
},
updateTier: async (auth0Sub: string, data: UpdateUserTierRequest): Promise<ManagedUser> => {
updateTier: async (userId: string, data: UpdateUserTierRequest): Promise<ManagedUser> => {
const response = await apiClient.patch<ManagedUser>(
`/admin/users/${encodeURIComponent(auth0Sub)}/tier`,
`/admin/users/${userId}/tier`,
data
);
return response.data;
},
deactivate: async (auth0Sub: string, data?: DeactivateUserRequest): Promise<ManagedUser> => {
deactivate: async (userId: string, data?: DeactivateUserRequest): Promise<ManagedUser> => {
const response = await apiClient.patch<ManagedUser>(
`/admin/users/${encodeURIComponent(auth0Sub)}/deactivate`,
`/admin/users/${userId}/deactivate`,
data || {}
);
return response.data;
},
reactivate: async (auth0Sub: string): Promise<ManagedUser> => {
reactivate: async (userId: string): Promise<ManagedUser> => {
const response = await apiClient.patch<ManagedUser>(
`/admin/users/${encodeURIComponent(auth0Sub)}/reactivate`
`/admin/users/${userId}/reactivate`
);
return response.data;
},
updateProfile: async (auth0Sub: string, data: UpdateUserProfileRequest): Promise<ManagedUser> => {
updateProfile: async (userId: string, data: UpdateUserProfileRequest): Promise<ManagedUser> => {
const response = await apiClient.patch<ManagedUser>(
`/admin/users/${encodeURIComponent(auth0Sub)}/profile`,
`/admin/users/${userId}/profile`,
data
);
return response.data;
},
promoteToAdmin: async (auth0Sub: string, data?: PromoteToAdminRequest): Promise<AdminUser> => {
promoteToAdmin: async (userId: string, data?: PromoteToAdminRequest): Promise<AdminUser> => {
const response = await apiClient.patch<AdminUser>(
`/admin/users/${encodeURIComponent(auth0Sub)}/promote`,
`/admin/users/${userId}/promote`,
data || {}
);
return response.data;
},
hardDelete: async (auth0Sub: string, reason?: string): Promise<{ message: string }> => {
hardDelete: async (userId: string, reason?: string): Promise<{ message: string }> => {
const response = await apiClient.delete<{ message: string }>(
`/admin/users/${encodeURIComponent(auth0Sub)}`,
`/admin/users/${userId}`,
{ params: reason ? { reason } : undefined }
);
return response.data;

View File

@@ -51,7 +51,7 @@ export const useRevokeAdmin = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (auth0Sub: string) => adminApi.revokeAdmin(auth0Sub),
mutationFn: (id: string) => adminApi.revokeAdmin(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admins'] });
toast.success('Admin revoked successfully');
@@ -66,7 +66,7 @@ export const useReinstateAdmin = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (auth0Sub: string) => adminApi.reinstateAdmin(auth0Sub),
mutationFn: (id: string) => adminApi.reinstateAdmin(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admins'] });
toast.success('Admin reinstated successfully');

View File

@@ -29,8 +29,8 @@ interface ApiError {
export const userQueryKeys = {
all: ['admin-users'] as const,
list: (params: ListUsersParams) => [...userQueryKeys.all, 'list', params] as const,
detail: (auth0Sub: string) => [...userQueryKeys.all, 'detail', auth0Sub] as const,
vehicles: (auth0Sub: string) => [...userQueryKeys.all, 'vehicles', auth0Sub] as const,
detail: (userId: string) => [...userQueryKeys.all, 'detail', userId] as const,
vehicles: (userId: string) => [...userQueryKeys.all, 'vehicles', userId] as const,
};
// Query keys for admin stats
@@ -58,13 +58,13 @@ export const useUsers = (params: ListUsersParams = {}) => {
/**
* Hook to get a single user's details
*/
export const useUser = (auth0Sub: string) => {
export const useUser = (userId: string) => {
const { isAuthenticated, isLoading } = useAuth0();
return useQuery({
queryKey: userQueryKeys.detail(auth0Sub),
queryFn: () => adminApi.users.get(auth0Sub),
enabled: isAuthenticated && !isLoading && !!auth0Sub,
queryKey: userQueryKeys.detail(userId),
queryFn: () => adminApi.users.get(userId),
enabled: isAuthenticated && !isLoading && !!userId,
staleTime: 2 * 60 * 1000,
gcTime: 5 * 60 * 1000,
retry: 1,
@@ -78,8 +78,8 @@ export const useUpdateUserTier = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ auth0Sub, data }: { auth0Sub: string; data: UpdateUserTierRequest }) =>
adminApi.users.updateTier(auth0Sub, data),
mutationFn: ({ userId, data }: { userId: string; data: UpdateUserTierRequest }) =>
adminApi.users.updateTier(userId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
toast.success('Subscription tier updated');
@@ -101,8 +101,8 @@ export const useDeactivateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ auth0Sub, data }: { auth0Sub: string; data?: DeactivateUserRequest }) =>
adminApi.users.deactivate(auth0Sub, data),
mutationFn: ({ userId, data }: { userId: string; data?: DeactivateUserRequest }) =>
adminApi.users.deactivate(userId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
toast.success('User deactivated');
@@ -124,7 +124,7 @@ export const useReactivateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (auth0Sub: string) => adminApi.users.reactivate(auth0Sub),
mutationFn: (userId: string) => adminApi.users.reactivate(userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
toast.success('User reactivated');
@@ -146,8 +146,8 @@ export const useUpdateUserProfile = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ auth0Sub, data }: { auth0Sub: string; data: UpdateUserProfileRequest }) =>
adminApi.users.updateProfile(auth0Sub, data),
mutationFn: ({ userId, data }: { userId: string; data: UpdateUserProfileRequest }) =>
adminApi.users.updateProfile(userId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
toast.success('User profile updated');
@@ -169,8 +169,8 @@ export const usePromoteToAdmin = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ auth0Sub, data }: { auth0Sub: string; data?: PromoteToAdminRequest }) =>
adminApi.users.promoteToAdmin(auth0Sub, data),
mutationFn: ({ userId, data }: { userId: string; data?: PromoteToAdminRequest }) =>
adminApi.users.promoteToAdmin(userId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
toast.success('User promoted to admin');
@@ -192,8 +192,8 @@ export const useHardDeleteUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ auth0Sub, reason }: { auth0Sub: string; reason?: string }) =>
adminApi.users.hardDelete(auth0Sub, reason),
mutationFn: ({ userId, reason }: { userId: string; reason?: string }) =>
adminApi.users.hardDelete(userId, reason),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
toast.success('User permanently deleted');
@@ -228,13 +228,13 @@ export const useAdminStats = () => {
/**
* Hook to get a user's vehicles (admin view - year, make, model only)
*/
export const useUserVehicles = (auth0Sub: string) => {
export const useUserVehicles = (userId: string) => {
const { isAuthenticated, isLoading } = useAuth0();
return useQuery({
queryKey: userQueryKeys.vehicles(auth0Sub),
queryFn: () => adminApi.users.getVehicles(auth0Sub),
enabled: isAuthenticated && !isLoading && !!auth0Sub,
queryKey: userQueryKeys.vehicles(userId),
queryFn: () => adminApi.users.getVehicles(userId),
enabled: isAuthenticated && !isLoading && !!userId,
staleTime: 2 * 60 * 1000,
gcTime: 5 * 60 * 1000,
retry: 1,

View File

@@ -104,8 +104,8 @@ const VehicleCountBadge: React.FC<{ count: number; onClick?: () => void }> = ({
);
// Expandable vehicle list component
const UserVehiclesList: React.FC<{ auth0Sub: string; isOpen: boolean }> = ({ auth0Sub, isOpen }) => {
const { data, isLoading, error } = useUserVehicles(auth0Sub);
const UserVehiclesList: React.FC<{ userId: string; isOpen: boolean }> = ({ userId, isOpen }) => {
const { data, isLoading, error } = useUserVehicles(userId);
if (!isOpen) return null;
@@ -215,7 +215,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
(newTier: SubscriptionTier) => {
if (selectedUser) {
updateTierMutation.mutate(
{ auth0Sub: selectedUser.auth0Sub, data: { subscriptionTier: newTier } },
{ userId: selectedUser.id, data: { subscriptionTier: newTier } },
{
onSuccess: () => {
setShowTierPicker(false);
@@ -232,7 +232,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
const handleDeactivate = useCallback(() => {
if (selectedUser) {
deactivateMutation.mutate(
{ auth0Sub: selectedUser.auth0Sub, data: { reason: deactivateReason || undefined } },
{ userId: selectedUser.id, data: { reason: deactivateReason || undefined } },
{
onSuccess: () => {
setShowDeactivateConfirm(false);
@@ -247,7 +247,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
const handleReactivate = useCallback(() => {
if (selectedUser) {
reactivateMutation.mutate(selectedUser.auth0Sub, {
reactivateMutation.mutate(selectedUser.id, {
onSuccess: () => {
setShowUserActions(false);
setSelectedUser(null);
@@ -276,7 +276,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
}
if (Object.keys(updates).length > 0) {
updateProfileMutation.mutate(
{ auth0Sub: selectedUser.auth0Sub, data: updates },
{ userId: selectedUser.id, data: updates },
{
onSuccess: () => {
setShowEditModal(false);
@@ -306,7 +306,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
const handlePromoteConfirm = useCallback(() => {
if (selectedUser) {
promoteToAdminMutation.mutate(
{ auth0Sub: selectedUser.auth0Sub, data: { role: promoteRole } },
{ userId: selectedUser.id, data: { role: promoteRole } },
{
onSuccess: () => {
setShowPromoteModal(false);
@@ -332,7 +332,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
const handleHardDeleteConfirm = useCallback(() => {
if (selectedUser && hardDeleteConfirmText === 'DELETE') {
hardDeleteMutation.mutate(
{ auth0Sub: selectedUser.auth0Sub, reason: hardDeleteReason || undefined },
{ userId: selectedUser.id, reason: hardDeleteReason || undefined },
{
onSuccess: () => {
setShowHardDeleteModal(false);
@@ -509,7 +509,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
{users.length > 0 && (
<div className="space-y-3">
{users.map((user) => (
<GlassCard key={user.auth0Sub} padding="md">
<GlassCard key={user.id} padding="md">
<button
onClick={() => handleUserClick(user)}
className="w-full text-left min-h-[44px]"
@@ -526,7 +526,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
<VehicleCountBadge
count={user.vehicleCount}
onClick={user.vehicleCount > 0 ? () => setExpandedUserId(
expandedUserId === user.auth0Sub ? null : user.auth0Sub
expandedUserId === user.id ? null : user.id
) : undefined}
/>
<StatusBadge active={!user.deactivatedAt} />
@@ -543,8 +543,8 @@ export const AdminUsersMobileScreen: React.FC = () => {
</div>
</button>
<UserVehiclesList
auth0Sub={user.auth0Sub}
isOpen={expandedUserId === user.auth0Sub}
userId={user.id}
isOpen={expandedUserId === user.id}
/>
</GlassCard>
))}

View File

@@ -5,7 +5,8 @@
// Admin user types
export interface AdminUser {
auth0Sub: string;
id: string;
userProfileId: string;
email: string;
role: string;
createdAt: string;

View File

@@ -82,11 +82,13 @@ export const vehiclesApi = {
},
/**
* Decode VIN using NHTSA vPIC API
* Decode VIN using VIN decode service
* Requires Pro or Enterprise tier
*/
decodeVin: async (vin: string): Promise<DecodedVehicleData> => {
const response = await apiClient.post('/vehicles/decode-vin', { vin });
const response = await apiClient.post('/vehicles/decode-vin', { vin }, {
timeout: 60000 // 60 seconds for Gemini cold start
});
return response.data;
}
};

View File

@@ -114,6 +114,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
const [isDecoding, setIsDecoding] = useState(false);
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
const [decodeError, setDecodeError] = useState<string | null>(null);
const [decodeHint, setDecodeHint] = useState<string | null>(null);
// VIN OCR capture hook
const vinOcr = useVinOcr();
@@ -507,7 +508,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
/**
* Handle VIN decode button click
* Calls NHTSA API and populates empty form fields
* Calls VIN decode service and populates empty form fields
*/
const handleDecodeVin = async () => {
// Check tier access first
@@ -524,6 +525,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
setIsDecoding(true);
setDecodeError(null);
setDecodeHint(null);
try {
const decoded = await vehiclesApi.decodeVin(vin);
@@ -588,6 +590,21 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
setValue('transmission', decoded.transmission.value);
}
// Check if decode returned data but matching failed for key fields
const hasMatchedValue = decoded.year.value || decoded.make.value || decoded.model.value;
const hasSourceValue = decoded.year.sourceValue || decoded.make.sourceValue || decoded.model.sourceValue;
if (!hasMatchedValue && hasSourceValue) {
const parts = [
decoded.year.sourceValue,
decoded.make.sourceValue,
decoded.model.sourceValue,
decoded.trimLevel.sourceValue
].filter(Boolean);
setDecodeHint(
`Could not match VIN data to dropdowns. Decoded as: ${parts.join(' ')}. Please select values manually.`
);
}
setLoadingDropdowns(false);
isVinDecoding.current = false;
} catch (error: any) {
@@ -671,6 +688,9 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
{decodeError && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{decodeError}</p>
)}
{decodeHint && (
<p className="mt-1 text-sm text-amber-600 dark:text-amber-400">{decodeHint}</p>
)}
{vinOcr.error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{vinOcr.error}</p>
)}

View File

@@ -95,8 +95,8 @@ const ReviewContent: React.FC<{
const [selectedEngine, setSelectedEngine] = useState('');
const [selectedTransmission, setSelectedTransmission] = useState('');
// NHTSA reference values for unmatched fields
const [nhtsaRefs, setNhtsaRefs] = useState<Record<string, string | null>>({});
// Source reference values for unmatched fields
const [sourceRefs, setSourceRefs] = useState<Record<string, string | null>>({});
// Initialize dropdown options and pre-select decoded values
useEffect(() => {
@@ -109,13 +109,13 @@ const ReviewContent: React.FC<{
if (!decodedVehicle) return;
// Store NHTSA reference values for unmatched fields
setNhtsaRefs({
make: decodedVehicle.make.confidence === 'none' ? decodedVehicle.make.nhtsaValue : null,
model: decodedVehicle.model.confidence === 'none' ? decodedVehicle.model.nhtsaValue : null,
trim: decodedVehicle.trimLevel.confidence === 'none' ? decodedVehicle.trimLevel.nhtsaValue : null,
engine: decodedVehicle.engine.confidence === 'none' ? decodedVehicle.engine.nhtsaValue : null,
transmission: decodedVehicle.transmission.confidence === 'none' ? decodedVehicle.transmission.nhtsaValue : null,
// Store source reference values for unmatched fields
setSourceRefs({
make: decodedVehicle.make.confidence === 'none' ? decodedVehicle.make.sourceValue : null,
model: decodedVehicle.model.confidence === 'none' ? decodedVehicle.model.sourceValue : null,
trim: decodedVehicle.trimLevel.confidence === 'none' ? decodedVehicle.trimLevel.sourceValue : null,
engine: decodedVehicle.engine.confidence === 'none' ? decodedVehicle.engine.sourceValue : null,
transmission: decodedVehicle.transmission.confidence === 'none' ? decodedVehicle.transmission.sourceValue : null,
});
const yearValue = decodedVehicle.year.value;
@@ -277,9 +277,9 @@ const ReviewContent: React.FC<{
});
};
/** Show NHTSA reference when field had no dropdown match */
const nhtsaHint = (field: string) => {
const ref = nhtsaRefs[field];
/** Show source reference when field had no dropdown match */
const sourceHint = (field: string) => {
const ref = sourceRefs[field];
if (!ref) return null;
// Only show hint when no value is currently selected
const selected: Record<string, string> = {
@@ -292,7 +292,7 @@ const ReviewContent: React.FC<{
if (selected[field]) return null;
return (
<p className="mt-1 text-xs text-gray-500 dark:text-titanio">
NHTSA returned: {ref}
Decoded value: {ref}
</p>
);
};
@@ -409,7 +409,7 @@ const ReviewContent: React.FC<{
</option>
))}
</select>
{nhtsaHint('make')}
{sourceHint('make')}
</div>
{/* Model */}
@@ -439,7 +439,7 @@ const ReviewContent: React.FC<{
</option>
))}
</select>
{nhtsaHint('model')}
{sourceHint('model')}
</div>
{/* Trim */}
@@ -469,7 +469,7 @@ const ReviewContent: React.FC<{
</option>
))}
</select>
{nhtsaHint('trim')}
{sourceHint('trim')}
</div>
{/* Engine */}
@@ -499,7 +499,7 @@ const ReviewContent: React.FC<{
</option>
))}
</select>
{nhtsaHint('engine')}
{sourceHint('engine')}
</div>
{/* Transmission */}
@@ -529,7 +529,7 @@ const ReviewContent: React.FC<{
</option>
))}
</select>
{nhtsaHint('transmission')}
{sourceHint('transmission')}
</div>
</div>
</Box>

View File

@@ -1,5 +1,5 @@
/**
* @ai-summary Hook to orchestrate VIN OCR extraction and NHTSA decode
* @ai-summary Hook to orchestrate VIN OCR extraction and VIN decode
* @ai-context Handles camera capture -> OCR extraction -> VIN decode flow
*/
@@ -109,7 +109,7 @@ export function useVinOcr(): UseVinOcrReturn {
);
}
// Step 2: Decode VIN using NHTSA
// Step 2: Decode VIN
setProcessingStep('decoding');
let decodedVehicle: DecodedVehicleData | null = null;
let decodeError: string | null = null;
@@ -121,7 +121,7 @@ export function useVinOcr(): UseVinOcrReturn {
if (err.response?.data?.error === 'TIER_REQUIRED') {
decodeError = 'VIN decode requires Pro or Enterprise subscription';
} else if (err.response?.data?.error === 'INVALID_VIN') {
decodeError = 'VIN format is not recognized by NHTSA';
decodeError = 'VIN format is not recognized';
} else {
decodeError = 'Unable to decode vehicle information';
}

View File

@@ -72,12 +72,12 @@ export type MatchConfidence = 'high' | 'medium' | 'none';
*/
export interface MatchedField<T> {
value: T | null;
nhtsaValue: string | null;
sourceValue: string | null;
confidence: MatchConfidence;
}
/**
* Decoded vehicle data from NHTSA vPIC API
* Decoded vehicle data from VIN decode
* with match confidence per field
*/
export interface DecodedVehicleData {

View File

@@ -43,7 +43,7 @@ export const SubscriptionSection = () => {
</h3>
<p className="text-titanio/70 leading-relaxed mb-4">
<strong className="text-avus">What it does:</strong> 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 NHTSA database.
<strong className="text-avus">What it does:</strong> 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.
</p>
<p className="text-titanio/70 leading-relaxed mb-4">
@@ -58,7 +58,7 @@ export const SubscriptionSection = () => {
<li>A <strong className="text-avus">VIN OCR Review modal</strong> appears showing the detected VIN with confidence indicators</li>
<li>Confirm or correct the VIN, then click <strong className="text-avus">Accept</strong></li>
<li>Click the <strong className="text-avus">Decode VIN</strong> button</li>
<li>The system queries the NHTSA database and auto-populates: Year, Make, Model, Engine, Transmission, and Trim</li>
<li>The system queries the vehicle database and auto-populates: Year, Make, Model, Engine, Transmission, and Trim</li>
<li>Review the pre-filled fields and complete the remaining details</li>
</ol>

View File

@@ -141,7 +141,7 @@ export const VehiclesSection = () => {
<GuideScreenshot
src="/guide/vin-decode-desktop.png"
alt="VIN Decode feature showing auto-populated vehicle specifications"
caption="The VIN Decode feature automatically fills in vehicle details from the NHTSA database"
caption="The VIN Decode feature automatically fills in vehicle details from the vehicle database"
/>
</div>

View File

@@ -71,8 +71,8 @@ import { AdminSectionHeader } from '../../features/admin/components';
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
// Expandable vehicle row component
const UserVehiclesRow: React.FC<{ auth0Sub: string; isOpen: boolean }> = ({ auth0Sub, isOpen }) => {
const { data, isLoading, error } = useUserVehicles(auth0Sub);
const UserVehiclesRow: React.FC<{ userId: string; isOpen: boolean }> = ({ userId, isOpen }) => {
const { data, isLoading, error } = useUserVehicles(userId);
if (!isOpen) return null;
@@ -222,8 +222,8 @@ export const AdminUsersPage: React.FC = () => {
}, []);
const handleTierChange = useCallback(
(auth0Sub: string, newTier: SubscriptionTier) => {
updateTierMutation.mutate({ auth0Sub, data: { subscriptionTier: newTier } });
(userId: string, newTier: SubscriptionTier) => {
updateTierMutation.mutate({ userId, data: { subscriptionTier: newTier } });
},
[updateTierMutation]
);
@@ -246,7 +246,7 @@ export const AdminUsersPage: React.FC = () => {
const handleDeactivateConfirm = useCallback(() => {
if (selectedUser) {
deactivateMutation.mutate(
{ auth0Sub: selectedUser.auth0Sub, data: { reason: deactivateReason || undefined } },
{ userId: selectedUser.id, data: { reason: deactivateReason || undefined } },
{
onSuccess: () => {
setDeactivateDialogOpen(false);
@@ -260,7 +260,7 @@ export const AdminUsersPage: React.FC = () => {
const handleReactivate = useCallback(() => {
if (selectedUser) {
reactivateMutation.mutate(selectedUser.auth0Sub);
reactivateMutation.mutate(selectedUser.id);
setAnchorEl(null);
setSelectedUser(null);
}
@@ -286,7 +286,7 @@ export const AdminUsersPage: React.FC = () => {
}
if (Object.keys(updates).length > 0) {
updateProfileMutation.mutate(
{ auth0Sub: selectedUser.auth0Sub, data: updates },
{ userId: selectedUser.id, data: updates },
{
onSuccess: () => {
setEditDialogOpen(false);
@@ -316,7 +316,7 @@ export const AdminUsersPage: React.FC = () => {
const handlePromoteConfirm = useCallback(() => {
if (selectedUser) {
promoteToAdminMutation.mutate(
{ auth0Sub: selectedUser.auth0Sub, data: { role: promoteRole } },
{ userId: selectedUser.id, data: { role: promoteRole } },
{
onSuccess: () => {
setPromoteDialogOpen(false);
@@ -342,7 +342,7 @@ export const AdminUsersPage: React.FC = () => {
const handleHardDeleteConfirm = useCallback(() => {
if (selectedUser && hardDeleteConfirmText === 'DELETE') {
hardDeleteMutation.mutate(
{ auth0Sub: selectedUser.auth0Sub, reason: hardDeleteReason || undefined },
{ userId: selectedUser.id, reason: hardDeleteReason || undefined },
{
onSuccess: () => {
setHardDeleteDialogOpen(false);
@@ -496,11 +496,11 @@ export const AdminUsersPage: React.FC = () => {
</TableHead>
<TableBody>
{users.map((user) => (
<React.Fragment key={user.auth0Sub}>
<React.Fragment key={user.id}>
<TableRow
sx={{
opacity: user.deactivatedAt ? 0.6 : 1,
'& > *': { borderBottom: expandedRow === user.auth0Sub ? 'unset' : undefined },
'& > *': { borderBottom: expandedRow === user.id ? 'unset' : undefined },
}}
>
<TableCell>{user.email}</TableCell>
@@ -510,7 +510,7 @@ export const AdminUsersPage: React.FC = () => {
<Select
value={user.subscriptionTier}
onChange={(e) =>
handleTierChange(user.auth0Sub, e.target.value as SubscriptionTier)
handleTierChange(user.id, e.target.value as SubscriptionTier)
}
disabled={!!user.deactivatedAt || updateTierMutation.isPending}
size="small"
@@ -527,12 +527,12 @@ export const AdminUsersPage: React.FC = () => {
<IconButton
size="small"
onClick={() => setExpandedRow(
expandedRow === user.auth0Sub ? null : user.auth0Sub
expandedRow === user.id ? null : user.id
)}
aria-label="show vehicles"
sx={{ minWidth: 44, minHeight: 44 }}
>
{expandedRow === user.auth0Sub ? (
{expandedRow === user.id ? (
<KeyboardArrowUp fontSize="small" />
) : (
<KeyboardArrowDown fontSize="small" />
@@ -569,8 +569,8 @@ export const AdminUsersPage: React.FC = () => {
</TableCell>
</TableRow>
<UserVehiclesRow
auth0Sub={user.auth0Sub}
isOpen={expandedRow === user.auth0Sub}
userId={user.id}
isOpen={expandedRow === user.id}
/>
</React.Fragment>
))}

View File

@@ -1,6 +1,6 @@
# ocr/
Python OCR microservice. Primary engine: PaddleOCR PP-OCRv4 with optional Google Vision cloud fallback. Gemini 2.5 Flash for maintenance manual PDF extraction. Pluggable engine abstraction in `app/engines/`.
Python OCR microservice. Primary engine: PaddleOCR PP-OCRv4 with optional Google Vision cloud fallback. Gemini 2.5 Flash for maintenance manual PDF extraction and VIN decode. Pluggable engine abstraction in `app/engines/`.
## Files

View File

@@ -19,7 +19,7 @@ Python OCR microservice (FastAPI). Primary engine: PaddleOCR PP-OCRv4 with optio
| `models/` | Data models and schemas | Request/response types |
| `patterns/` | Regex patterns and service name mapping (27 maintenance subtypes) | Pattern matching rules, service categorization |
| `preprocessors/` | Image preprocessing pipeline | Image preparation before OCR |
| `routers/` | FastAPI route handlers (/extract, /extract/receipt, /extract/manual, /jobs) | API endpoint changes |
| `routers/` | FastAPI route handlers (/extract, /extract/receipt, /extract/manual, /decode, /jobs) | API endpoint changes |
| `services/` | Business logic services (job queue with Redis) | Core OCR processing, async job management |
| `table_extraction/` | Table detection and parsing | Structured data extraction from images |
| `validators/` | Input validation | Validation rules |

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