Compare commits
90 Commits
c816dd39ab
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e2bb9ef36 | |||
|
|
56df5d48f3 | ||
|
|
1add6c8240 | ||
|
|
936753fac2 | ||
|
|
96e1dde7b2 | ||
|
|
1464a0e1af | ||
|
|
9f51e62b94 | ||
|
|
b7f472b3e8 | ||
|
|
398d67304f | ||
|
|
0055d9f0f3 | ||
|
|
9dc56a3773 | ||
|
|
283ba6b108 | ||
|
|
7d90f4b25a | ||
| e2e6471c5e | |||
|
|
3b5b84729f | ||
| d9df9193dc | |||
|
|
781241966c | ||
|
|
bf6742f6ea | ||
|
|
5bb44be8bc | ||
|
|
361f58d7c6 | ||
|
|
d96736789e | ||
|
|
f590421058 | ||
|
|
5cbf9c764d | ||
|
|
3cd61256ba | ||
|
|
a75f7b5583 | ||
| 00aa2a5411 | |||
|
|
1dac6d342b | ||
|
|
3b62f5a621 | ||
|
|
4f4fb8a886 | ||
|
|
d57c5d6cf8 | ||
|
|
8a73352ddc | ||
|
|
72e557346c | ||
|
|
853a075e8b | ||
|
|
07c3d8511d | ||
|
|
15956a8711 | ||
|
|
714ed92438 | ||
|
|
bc0be75957 | ||
| 7712ec6661 | |||
|
|
e9093138fa | ||
|
|
dd3b58e061 | ||
|
|
28165e4f4a | ||
|
|
7fc80ab49f | ||
|
|
754639c86d | ||
|
|
3b1112a9fe | ||
|
|
fd9d1add24 | ||
| 5f0da87110 | |||
|
|
b418a503b2 | ||
|
|
1321440cd0 | ||
|
|
6011888e91 | ||
|
|
93e79d1170 | ||
|
|
a6eea6c9e2 | ||
|
|
af11b49e26 | ||
|
|
ddae397cb3 | ||
|
|
c1e8807bda | ||
|
|
bb4d2b9699 | ||
|
|
669b51a6e1 | ||
|
|
856a305c9d | ||
| 9177a38414 | |||
|
|
260641e68c | ||
|
|
1a9081c534 | ||
|
|
bb48c55c2e | ||
|
|
4927b6670d | ||
|
|
b73bfaf590 | ||
|
|
a7f12ad580 | ||
|
|
b047199bc5 | ||
|
|
197aeda2ef | ||
|
|
6196ebfc91 | ||
|
|
864da55cec | ||
|
|
d8ab00970d | ||
|
|
b2c9341342 | ||
| 54de28e0e8 | |||
|
|
f6684e72c0 | ||
|
|
654a7f0fc3 | ||
|
|
767df9e9f2 | ||
|
|
505ab8262c | ||
|
|
b57b835eb3 | ||
| 963c17014c | |||
|
|
7140c7e8d4 | ||
| 8d6434f166 | |||
|
|
850f713310 | ||
|
|
b5b82db532 | ||
|
|
da59168d7b | ||
|
|
38debaad5d | ||
|
|
db127eb24c | ||
|
|
15128bfd50 | ||
|
|
723e25e1a7 | ||
|
|
6e493e9bc7 | ||
|
|
a195fa9231 | ||
| 82e8afc215 | |||
|
|
19cd917c66 |
@@ -52,7 +52,8 @@
|
||||
"ONE PR for the parent issue. The PR closes the parent and all sub-issues.",
|
||||
"Commits reference the specific sub-issue index they implement.",
|
||||
"Sub-issues should be small enough to fit in a single AI context window.",
|
||||
"Plan milestones map 1:1 to sub-issues."
|
||||
"Plan milestones map 1:1 to sub-issues.",
|
||||
"Each sub-issue receives its own plan comment with duplicated shared context. An agent must be able to execute from the sub-issue alone."
|
||||
],
|
||||
"examples": {
|
||||
"parent": "#105 'feat: Add Grafana dashboards and alerting'",
|
||||
@@ -103,8 +104,9 @@
|
||||
"[SKILL] Problem Analysis if complex problem.",
|
||||
"[SKILL] Decision Critic if uncertain approach.",
|
||||
"If multi-file feature (3+ files): decompose into sub-issues per sub_issues rules. Each sub-issue = one plan milestone.",
|
||||
"[SKILL] Planner writes plan as parent issue comment. Plan milestones map 1:1 to sub-issues.",
|
||||
"[SKILL] Plan review cycle: QR plan-completeness -> TW plan-scrub -> QR plan-code -> QR plan-docs.",
|
||||
"[SKILL] Planner writes plan summary as parent issue comment: shared context + milestone index linking each milestone to its sub-issue. M5 (doc-sync) stays on parent if no sub-issue exists.",
|
||||
"[SKILL] Planner posts each milestone's self-contained implementation plan as a comment on the corresponding sub-issue. Each sub-issue plan duplicates relevant shared context (API maps, state changes, auth, error handling, risk) so an agent can execute from the sub-issue alone without reading the parent.",
|
||||
"[SKILL] Plan review cycle: QR plan-completeness -> TW plan-scrub -> QR plan-code -> QR plan-docs. Distribute milestone-specific review findings to sub-issue plan comments.",
|
||||
"Create ONE branch issue-{parent_index}-{slug} from main.",
|
||||
"[SKILL] Planner executes plan, delegates to Developer per milestone/sub-issue.",
|
||||
"[SKILL] QR post-implementation per milestone (results in parent issue comment).",
|
||||
@@ -123,7 +125,7 @@
|
||||
"execution_review": ["QR post-implementation per milestone"],
|
||||
"final_review": ["Quality Agent RULE 0/1/2"]
|
||||
},
|
||||
"plan_storage": "gitea_issue_comments",
|
||||
"plan_storage": "gitea_issue_comments: summary on parent issue, milestone detail on sub-issues",
|
||||
"tracking_storage": "gitea_issue_comments",
|
||||
"issue_comment_operations": {
|
||||
"create_comment": "mcp__gitea-mcp__create_issue_comment",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"testModules": [
|
||||
{
|
||||
"moduleId": "/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/src/features/maintenance/components/MaintenanceScheduleReviewScreen.test.tsx",
|
||||
"moduleId": "/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/src/features/dashboard/components/__tests__/ActionBar.test.tsx",
|
||||
"tests": [
|
||||
{
|
||||
"name": "Module failed to load (Error)",
|
||||
|
||||
36
.env.example
Normal 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
|
||||
@@ -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: |
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}">
|
||||
© {new Date().getFullYear()} MotoVaultPro. All rights reserved.
|
||||
© ${new Date().getFullYear()} MotoVaultPro. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
51
backend/src/features/ocr/external/ocr-client.ts
vendored
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export class SubscriptionsController {
|
||||
*/
|
||||
async getSubscription(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
const subscription = await this.service.getSubscription(userId);
|
||||
|
||||
@@ -39,7 +39,7 @@ export class SubscriptionsController {
|
||||
reply.status(200).send(subscription);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get subscription', {
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
@@ -54,14 +54,14 @@ export class SubscriptionsController {
|
||||
*/
|
||||
async checkNeedsVehicleSelection(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
const result = await this.service.checkNeedsVehicleSelection(userId);
|
||||
|
||||
reply.status(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to check needs vehicle selection', {
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
@@ -85,8 +85,8 @@ export class SubscriptionsController {
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const email = (request as any).user.email;
|
||||
const userId = request.userContext!.userId;
|
||||
const email = request.userContext!.email || '';
|
||||
const { tier, billingCycle, paymentMethodId } = request.body;
|
||||
|
||||
// Validate inputs
|
||||
@@ -134,13 +134,14 @@ export class SubscriptionsController {
|
||||
userId,
|
||||
tier,
|
||||
billingCycle,
|
||||
paymentMethodId || ''
|
||||
paymentMethodId || '',
|
||||
email
|
||||
);
|
||||
|
||||
reply.status(200).send(updatedSubscription);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to create checkout', {
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
@@ -155,14 +156,14 @@ export class SubscriptionsController {
|
||||
*/
|
||||
async cancelSubscription(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
const subscription = await this.service.cancelSubscription(userId);
|
||||
|
||||
reply.status(200).send(subscription);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to cancel subscription', {
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
@@ -177,14 +178,14 @@ export class SubscriptionsController {
|
||||
*/
|
||||
async reactivateSubscription(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
const subscription = await this.service.reactivateSubscription(userId);
|
||||
|
||||
reply.status(200).send(subscription);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to reactivate subscription', {
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
@@ -206,7 +207,8 @@ export class SubscriptionsController {
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
const email = request.userContext!.email || '';
|
||||
const { paymentMethodId } = request.body;
|
||||
|
||||
// Validate input
|
||||
@@ -218,26 +220,15 @@ export class SubscriptionsController {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get subscription
|
||||
const subscription = await this.service.getSubscription(userId);
|
||||
if (!subscription) {
|
||||
reply.status(404).send({
|
||||
error: 'Subscription not found',
|
||||
message: 'No subscription exists for this user',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Update payment method via Stripe
|
||||
const stripeClient = new StripeClient();
|
||||
await stripeClient.updatePaymentMethod(subscription.stripeCustomerId, paymentMethodId);
|
||||
// Update payment method via service (creates Stripe customer if needed)
|
||||
await this.service.updatePaymentMethod(userId, paymentMethodId, email);
|
||||
|
||||
reply.status(200).send({
|
||||
message: 'Payment method updated successfully',
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to update payment method', {
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
@@ -252,14 +243,14 @@ export class SubscriptionsController {
|
||||
*/
|
||||
async getInvoices(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
const invoices = await this.service.getInvoices(userId);
|
||||
|
||||
reply.status(200).send(invoices);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get invoices', {
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
@@ -282,7 +273,7 @@ export class SubscriptionsController {
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
const { targetTier, vehicleIdsToKeep } = request.body;
|
||||
|
||||
// Validate inputs
|
||||
@@ -320,7 +311,7 @@ export class SubscriptionsController {
|
||||
reply.status(200).send(updatedSubscription);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to downgrade subscription', {
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
|
||||
@@ -27,7 +27,7 @@ export class SubscriptionsRepository {
|
||||
/**
|
||||
* Create a new subscription
|
||||
*/
|
||||
async create(data: CreateSubscriptionRequest & { stripeCustomerId: string }): Promise<Subscription> {
|
||||
async create(data: CreateSubscriptionRequest & { stripeCustomerId?: string | null }): Promise<Subscription> {
|
||||
const query = `
|
||||
INSERT INTO subscriptions (
|
||||
user_id, stripe_customer_id, tier, billing_cycle
|
||||
@@ -38,7 +38,7 @@ export class SubscriptionsRepository {
|
||||
|
||||
const values = [
|
||||
data.userId,
|
||||
data.stripeCustomerId,
|
||||
data.stripeCustomerId ?? null,
|
||||
data.tier,
|
||||
data.billingCycle,
|
||||
];
|
||||
@@ -146,6 +146,10 @@ export class SubscriptionsRepository {
|
||||
const values = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (data.stripeCustomerId !== undefined) {
|
||||
fields.push(`stripe_customer_id = $${paramCount++}`);
|
||||
values.push(data.stripeCustomerId);
|
||||
}
|
||||
if (data.stripeSubscriptionId !== undefined) {
|
||||
fields.push(`stripe_subscription_id = $${paramCount++}`);
|
||||
values.push(data.stripeSubscriptionId);
|
||||
@@ -575,18 +579,16 @@ export class SubscriptionsRepository {
|
||||
client?: any
|
||||
): Promise<Subscription> {
|
||||
const queryClient = client || this.pool;
|
||||
// Generate a placeholder Stripe customer ID since admin override bypasses Stripe
|
||||
const placeholderCustomerId = `admin_override_${userId}_${Date.now()}`;
|
||||
|
||||
const query = `
|
||||
INSERT INTO subscriptions (
|
||||
user_id, stripe_customer_id, tier, billing_cycle, status
|
||||
)
|
||||
VALUES ($1, $2, $3, 'monthly', 'active')
|
||||
VALUES ($1, NULL, $2, 'monthly', 'active')
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const values = [userId, placeholderCustomerId, tier];
|
||||
const values = [userId, tier];
|
||||
|
||||
try {
|
||||
const result = await queryClient.query(query, values);
|
||||
@@ -619,7 +621,7 @@ export class SubscriptionsRepository {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
stripeCustomerId: row.stripe_customer_id,
|
||||
stripeCustomerId: row.stripe_customer_id ?? null,
|
||||
stripeSubscriptionId: row.stripe_subscription_id || undefined,
|
||||
tier: row.tier,
|
||||
billingCycle: row.billing_cycle || undefined,
|
||||
|
||||
@@ -165,6 +165,45 @@ export class SubscriptionsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or return existing Stripe customer for a subscription.
|
||||
* Admin-set subscriptions have NULL stripeCustomerId. On first Stripe payment,
|
||||
* the customer is created in-place. Includes cleanup of orphaned Stripe customer
|
||||
* if the DB update fails after customer creation.
|
||||
*/
|
||||
private async ensureStripeCustomer(
|
||||
subscription: Subscription,
|
||||
email: string
|
||||
): Promise<string> {
|
||||
if (subscription.stripeCustomerId) {
|
||||
return subscription.stripeCustomerId;
|
||||
}
|
||||
|
||||
const stripeCustomer = await this.stripeClient.createCustomer(email);
|
||||
try {
|
||||
await this.repository.update(subscription.id, { stripeCustomerId: stripeCustomer.id });
|
||||
logger.info('Created Stripe customer for subscription', {
|
||||
subscriptionId: subscription.id,
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
});
|
||||
return stripeCustomer.id;
|
||||
} catch (error) {
|
||||
// Attempt cleanup of orphaned Stripe customer
|
||||
try {
|
||||
await this.stripeClient.deleteCustomer(stripeCustomer.id);
|
||||
logger.warn('Rolled back orphaned Stripe customer after DB update failure', {
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
});
|
||||
} catch (cleanupError: any) {
|
||||
logger.error('Failed to cleanup orphaned Stripe customer', {
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
cleanupError: cleanupError.message,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade from current tier to new tier
|
||||
*/
|
||||
@@ -172,7 +211,8 @@ export class SubscriptionsService {
|
||||
userId: string,
|
||||
newTier: 'pro' | 'enterprise',
|
||||
billingCycle: 'monthly' | 'yearly',
|
||||
paymentMethodId: string
|
||||
paymentMethodId: string,
|
||||
email: string
|
||||
): Promise<Subscription> {
|
||||
try {
|
||||
logger.info('Upgrading subscription', { userId, newTier, billingCycle });
|
||||
@@ -183,12 +223,15 @@ export class SubscriptionsService {
|
||||
throw new Error('No subscription found for user');
|
||||
}
|
||||
|
||||
// Ensure Stripe customer exists (creates one for admin-set subscriptions)
|
||||
const stripeCustomerId = await this.ensureStripeCustomer(currentSubscription, email);
|
||||
|
||||
// Determine price ID from environment variables
|
||||
const priceId = this.getPriceId(newTier, billingCycle);
|
||||
|
||||
// Create or update Stripe subscription
|
||||
const stripeSubscription = await this.stripeClient.createSubscription(
|
||||
currentSubscription.stripeCustomerId,
|
||||
stripeCustomerId,
|
||||
priceId,
|
||||
paymentMethodId
|
||||
);
|
||||
@@ -256,6 +299,10 @@ export class SubscriptionsService {
|
||||
throw new Error('No subscription found for user');
|
||||
}
|
||||
|
||||
if (!currentSubscription.stripeCustomerId) {
|
||||
throw new Error('Cannot cancel subscription without active Stripe billing');
|
||||
}
|
||||
|
||||
if (!currentSubscription.stripeSubscriptionId) {
|
||||
throw new Error('No active Stripe subscription to cancel');
|
||||
}
|
||||
@@ -303,6 +350,10 @@ export class SubscriptionsService {
|
||||
throw new Error('No subscription found for user');
|
||||
}
|
||||
|
||||
if (!currentSubscription.stripeCustomerId) {
|
||||
throw new Error('Cannot reactivate subscription without active Stripe billing');
|
||||
}
|
||||
|
||||
if (!currentSubscription.stripeSubscriptionId) {
|
||||
throw new Error('No active Stripe subscription to reactivate');
|
||||
}
|
||||
@@ -519,11 +570,13 @@ export class SubscriptionsService {
|
||||
}
|
||||
|
||||
// Update subscription with Stripe subscription ID
|
||||
// Period dates moved from subscription to items in API 2025-03-31.basil
|
||||
const item = stripeSubscription.items?.data?.[0];
|
||||
await this.repository.update(subscription.id, {
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
status: this.mapStripeStatus(stripeSubscription.status),
|
||||
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
|
||||
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
|
||||
currentPeriodStart: new Date((item?.current_period_start ?? 0) * 1000),
|
||||
currentPeriodEnd: new Date((item?.current_period_end ?? 0) * 1000),
|
||||
});
|
||||
|
||||
// Log event
|
||||
@@ -557,11 +610,13 @@ export class SubscriptionsService {
|
||||
const tier = this.determineTierFromStripeSubscription(stripeSubscription);
|
||||
|
||||
// Update subscription
|
||||
// Period dates moved from subscription to items in API 2025-03-31.basil
|
||||
const item = stripeSubscription.items?.data?.[0];
|
||||
const updateData: UpdateSubscriptionData = {
|
||||
status: this.mapStripeStatus(stripeSubscription.status),
|
||||
tier,
|
||||
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
|
||||
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
|
||||
currentPeriodStart: new Date((item?.current_period_start ?? 0) * 1000),
|
||||
currentPeriodEnd: new Date((item?.current_period_end ?? 0) * 1000),
|
||||
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end || false,
|
||||
};
|
||||
|
||||
@@ -731,7 +786,7 @@ export class SubscriptionsService {
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Get user profile for email and name
|
||||
const userProfile = await this.userProfileRepository.getByAuth0Sub(userId);
|
||||
const userProfile = await this.userProfileRepository.getById(userId);
|
||||
if (!userProfile) {
|
||||
logger.warn('User profile not found for tier change notification', { userId });
|
||||
return;
|
||||
@@ -766,17 +821,8 @@ export class SubscriptionsService {
|
||||
* Sync subscription tier to user_profiles table
|
||||
*/
|
||||
private async syncTierToUserProfile(userId: string, tier: SubscriptionTier): Promise<void> {
|
||||
try {
|
||||
await this.userProfileRepository.updateSubscriptionTier(userId, tier);
|
||||
logger.info('Subscription tier synced to user profile', { userId, tier });
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to sync tier to user profile', {
|
||||
userId,
|
||||
tier,
|
||||
error: error.message,
|
||||
});
|
||||
// Don't throw - we don't want to fail the subscription operation if sync fails
|
||||
}
|
||||
await this.userProfileRepository.updateSubscriptionTier(userId, tier);
|
||||
logger.info('Subscription tier synced to user profile', { userId, tier });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -807,6 +853,7 @@ export class SubscriptionsService {
|
||||
switch (stripeStatus) {
|
||||
case 'active':
|
||||
case 'trialing':
|
||||
case 'incomplete':
|
||||
return 'active';
|
||||
case 'past_due':
|
||||
return 'past_due';
|
||||
@@ -889,7 +936,7 @@ export class SubscriptionsService {
|
||||
|
||||
// Sync tier to user_profiles table (within same transaction)
|
||||
await client.query(
|
||||
'UPDATE user_profiles SET subscription_tier = $1 WHERE auth0_sub = $2',
|
||||
'UPDATE user_profiles SET subscription_tier = $1 WHERE id = $2',
|
||||
[newTier, userId]
|
||||
);
|
||||
|
||||
@@ -923,6 +970,19 @@ export class SubscriptionsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update payment method for a user's subscription
|
||||
*/
|
||||
async updatePaymentMethod(userId: string, paymentMethodId: string, email: string): Promise<void> {
|
||||
const subscription = await this.repository.findByUserId(userId);
|
||||
if (!subscription) {
|
||||
throw new Error('No subscription found for user');
|
||||
}
|
||||
|
||||
const stripeCustomerId = await this.ensureStripeCustomer(subscription, email);
|
||||
await this.stripeClient.updatePaymentMethod(stripeCustomerId, paymentMethodId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoices for a user's subscription
|
||||
*/
|
||||
|
||||
@@ -19,7 +19,7 @@ export type DonationStatus = 'pending' | 'succeeded' | 'failed' | 'canceled';
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
userId: string;
|
||||
stripeCustomerId: string;
|
||||
stripeCustomerId: string | null;
|
||||
stripeSubscriptionId?: string;
|
||||
tier: SubscriptionTier;
|
||||
billingCycle?: BillingCycle;
|
||||
@@ -74,7 +74,7 @@ export interface CreateSubscriptionRequest {
|
||||
export interface SubscriptionResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
stripeCustomerId: string;
|
||||
stripeCustomerId: string | null;
|
||||
stripeSubscriptionId?: string;
|
||||
tier: SubscriptionTier;
|
||||
billingCycle?: BillingCycle;
|
||||
@@ -118,6 +118,7 @@ export interface CreateTierVehicleSelectionRequest {
|
||||
|
||||
// Service layer types
|
||||
export interface UpdateSubscriptionData {
|
||||
stripeCustomerId?: string | null;
|
||||
stripeSubscriptionId?: string;
|
||||
tier?: SubscriptionTier;
|
||||
billingCycle?: BillingCycle;
|
||||
|
||||
@@ -75,10 +75,18 @@ export class StripeClient {
|
||||
try {
|
||||
logger.info('Creating Stripe subscription', { customerId, priceId, paymentMethodId });
|
||||
|
||||
// Attach payment method to customer before creating subscription
|
||||
if (paymentMethodId) {
|
||||
await this.stripe.paymentMethods.attach(paymentMethodId, {
|
||||
customer: customerId,
|
||||
});
|
||||
logger.info('Payment method attached to customer', { customerId, paymentMethodId });
|
||||
}
|
||||
|
||||
const subscriptionParams: Stripe.SubscriptionCreateParams = {
|
||||
customer: customerId,
|
||||
items: [{ price: priceId }],
|
||||
payment_behavior: 'default_incomplete',
|
||||
payment_behavior: 'error_if_incomplete',
|
||||
payment_settings: {
|
||||
save_default_payment_method: 'on_subscription',
|
||||
},
|
||||
@@ -93,13 +101,16 @@ export class StripeClient {
|
||||
|
||||
logger.info('Stripe subscription created', { subscriptionId: subscription.id });
|
||||
|
||||
// Period dates moved from subscription to items in API 2025-03-31.basil
|
||||
const item = subscription.items?.data?.[0];
|
||||
|
||||
return {
|
||||
id: subscription.id,
|
||||
customer: subscription.customer as string,
|
||||
status: subscription.status as StripeSubscription['status'],
|
||||
items: subscription.items,
|
||||
currentPeriodStart: (subscription as any).current_period_start || 0,
|
||||
currentPeriodEnd: (subscription as any).current_period_end || 0,
|
||||
currentPeriodStart: item?.current_period_start ?? 0,
|
||||
currentPeriodEnd: item?.current_period_end ?? 0,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
canceledAt: subscription.canceled_at || undefined,
|
||||
created: subscription.created,
|
||||
@@ -140,13 +151,15 @@ export class StripeClient {
|
||||
logger.info('Stripe subscription canceled immediately', { subscriptionId });
|
||||
}
|
||||
|
||||
const item = subscription.items?.data?.[0];
|
||||
|
||||
return {
|
||||
id: subscription.id,
|
||||
customer: subscription.customer as string,
|
||||
status: subscription.status as StripeSubscription['status'],
|
||||
items: subscription.items,
|
||||
currentPeriodStart: (subscription as any).current_period_start || 0,
|
||||
currentPeriodEnd: (subscription as any).current_period_end || 0,
|
||||
currentPeriodStart: item?.current_period_start ?? 0,
|
||||
currentPeriodEnd: item?.current_period_end ?? 0,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
canceledAt: subscription.canceled_at || undefined,
|
||||
created: subscription.created,
|
||||
@@ -260,6 +273,24 @@ export class StripeClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Stripe customer (used for cleanup of orphaned customers)
|
||||
*/
|
||||
async deleteCustomer(customerId: string): Promise<void> {
|
||||
try {
|
||||
logger.info('Deleting Stripe customer', { customerId });
|
||||
await this.stripe.customers.del(customerId);
|
||||
logger.info('Stripe customer deleted', { customerId });
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to delete Stripe customer', {
|
||||
customerId,
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a subscription by ID
|
||||
*/
|
||||
@@ -268,14 +299,15 @@ export class StripeClient {
|
||||
logger.info('Retrieving Stripe subscription', { subscriptionId });
|
||||
|
||||
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
|
||||
const item = subscription.items?.data?.[0];
|
||||
|
||||
return {
|
||||
id: subscription.id,
|
||||
customer: subscription.customer as string,
|
||||
status: subscription.status as StripeSubscription['status'],
|
||||
items: subscription.items,
|
||||
currentPeriodStart: (subscription as any).current_period_start || 0,
|
||||
currentPeriodEnd: (subscription as any).current_period_end || 0,
|
||||
currentPeriodStart: item?.current_period_start ?? 0,
|
||||
currentPeriodEnd: item?.current_period_end ?? 0,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
canceledAt: subscription.canceled_at || undefined,
|
||||
created: subscription.created,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Migration: Make stripe_customer_id NULLABLE
|
||||
-- Removes the NOT NULL constraint that forced admin_override_ placeholder values.
|
||||
-- Admin-set subscriptions (no Stripe billing) use NULL instead of sentinel strings.
|
||||
-- PostgreSQL UNIQUE constraint allows multiple NULLs (SQL standard).
|
||||
|
||||
-- Drop NOT NULL constraint on stripe_customer_id
|
||||
ALTER TABLE subscriptions ALTER COLUMN stripe_customer_id DROP NOT NULL;
|
||||
|
||||
-- Clean up existing admin_override_ placeholder values to NULL
|
||||
UPDATE subscriptions SET stripe_customer_id = NULL
|
||||
WHERE stripe_customer_id LIKE 'admin_override_%';
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,26 +394,39 @@ export class VehiclesController {
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('VIN decode requested', { userId, vin: vin.substring(0, 6) + '...' });
|
||||
// Validate VIN format
|
||||
const sanitizedVin = vin.trim().toUpperCase();
|
||||
const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/;
|
||||
if (!VIN_REGEX.test(sanitizedVin)) {
|
||||
return reply.code(400).send({
|
||||
error: 'INVALID_VIN',
|
||||
message: 'Invalid VIN format. VIN must be exactly 17 characters and contain only letters (except I, O, Q) and numbers.'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate and decode VIN
|
||||
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);
|
||||
// Call OCR service for VIN decode
|
||||
const response = await ocrClient.decodeVin(sanitizedVin);
|
||||
|
||||
// Map response to decoded vehicle data with dropdown matching
|
||||
const decodedData = await this.vehiclesService.mapVinDecodeResponse(response);
|
||||
|
||||
logger.info('VIN decode successful', {
|
||||
userId,
|
||||
hasYear: !!decodedData.year.value,
|
||||
hasMake: !!decodedData.make.value,
|
||||
hasModel: !!decodedData.model.value
|
||||
hasModel: !!decodedData.model.value,
|
||||
hasTrim: !!decodedData.trimLevel.value,
|
||||
hasEngine: !!decodedData.engine.value,
|
||||
hasTransmission: !!decodedData.transmission.value,
|
||||
});
|
||||
|
||||
return reply.code(200).send(decodedData);
|
||||
} 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 +434,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 +468,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 +625,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 +675,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', {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @ai-summary Business logic for vehicles feature
|
||||
* @ai-context Handles VIN decoding, caching, and business rules
|
||||
* @ai-context Handles VIN decoding and business rules
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
@@ -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');
|
||||
}
|
||||
@@ -657,82 +658,89 @@ export class VehiclesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Map NHTSA decode response to internal decoded vehicle data format
|
||||
* Map VIN decode response to internal decoded vehicle data format
|
||||
* with dropdown matching and confidence levels
|
||||
*/
|
||||
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, engine: sourceEngine, transmission: sourceTransmission,
|
||||
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 +762,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 {
|
||||
|
||||
@@ -215,3 +215,41 @@ 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>;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -36,15 +36,13 @@ describe('Vehicles Integration Tests', () => {
|
||||
afterAll(async () => {
|
||||
// Clean up test database
|
||||
await pool.query('DROP TABLE IF EXISTS vehicles CASCADE');
|
||||
await pool.query('DROP TABLE IF EXISTS vin_cache CASCADE');
|
||||
await pool.query('DROP FUNCTION IF EXISTS update_updated_at_column() CASCADE');
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean up test data before each test - more thorough cleanup
|
||||
// Clean up test data before each test
|
||||
await pool.query('DELETE FROM vehicles WHERE user_id = $1', ['test-user-123']);
|
||||
await pool.query('DELETE FROM vin_cache WHERE vin LIKE $1', ['1HGBH41JXMN%']);
|
||||
|
||||
// Clear Redis cache for the test user
|
||||
try {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,63 @@
|
||||
# Shared services (from base compose):
|
||||
# mvp-traefik, mvp-postgres, mvp-redis
|
||||
|
||||
# ========================================
|
||||
# Extension fields (YAML anchors for DRY)
|
||||
# ========================================
|
||||
x-frontend-env: &frontend-env
|
||||
VITE_API_BASE_URL: /api
|
||||
VITE_AUTH0_DOMAIN: ${VITE_AUTH0_DOMAIN:-motovaultpro.us.auth0.com}
|
||||
VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3}
|
||||
VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com}
|
||||
SECRETS_DIR: /run/secrets
|
||||
|
||||
x-frontend-volumes: &frontend-volumes
|
||||
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
|
||||
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
|
||||
|
||||
x-frontend-healthcheck: &frontend-healthcheck
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:3000 || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
x-backend-env: &backend-env
|
||||
NODE_ENV: production
|
||||
CONFIG_PATH: /app/config/production.yml
|
||||
SECRETS_DIR: /run/secrets
|
||||
LOG_LEVEL: ${BACKEND_LOG_LEVEL:-debug}
|
||||
DATABASE_HOST: mvp-postgres
|
||||
REDIS_HOST: mvp-redis
|
||||
STRIPE_PRO_MONTHLY_PRICE_ID: ${STRIPE_PRO_MONTHLY_PRICE_ID:-price_1T1ZHMJXoKkh5RcKwKSSGIlR}
|
||||
STRIPE_PRO_YEARLY_PRICE_ID: ${STRIPE_PRO_YEARLY_PRICE_ID:-price_1T1ZHnJXoKkh5RcKWlG2MPpX}
|
||||
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: ${STRIPE_ENTERPRISE_MONTHLY_PRICE_ID:-price_1T1ZIBJXoKkh5RcKu2jyhqBN}
|
||||
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: ${STRIPE_ENTERPRISE_YEARLY_PRICE_ID:-price_1T1ZIQJXoKkh5RcK34YXiJQm}
|
||||
|
||||
x-backend-volumes: &backend-volumes
|
||||
- ./config/app/production.yml:/app/config/production.yml:ro
|
||||
- ./config/shared/production.yml:/app/config/shared.yml:ro
|
||||
- ./secrets/app/postgres-password.txt:/run/secrets/postgres-password:ro
|
||||
- ./secrets/app/auth0-client-secret.txt:/run/secrets/auth0-client-secret:ro
|
||||
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
|
||||
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
|
||||
- ./secrets/app/resend-api-key.txt:/run/secrets/resend-api-key:ro
|
||||
- ./secrets/app/auth0-management-client-id.txt:/run/secrets/auth0-management-client-id:ro
|
||||
- ./secrets/app/auth0-management-client-secret.txt:/run/secrets/auth0-management-client-secret:ro
|
||||
- ./secrets/app/stripe-secret-key.txt:/run/secrets/stripe-secret-key:ro
|
||||
- ./secrets/app/stripe-webhook-secret.txt:/run/secrets/stripe-webhook-secret:ro
|
||||
- ./data/documents:/app/data/documents
|
||||
- ./data/backups:/app/data/backups
|
||||
|
||||
x-backend-healthcheck: &backend-healthcheck
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- node -e "require('http').get('http://localhost:3001/health', r => process.exit(r.statusCode===200?0:1)).on('error', () => process.exit(1))"
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 180s
|
||||
|
||||
services:
|
||||
# ========================================
|
||||
# BLUE Stack - Frontend
|
||||
@@ -19,25 +76,13 @@ services:
|
||||
image: ${FRONTEND_IMAGE:-git.motovaultpro.com/egullickson/frontend:latest}
|
||||
container_name: mvp-frontend-blue
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
VITE_API_BASE_URL: /api
|
||||
VITE_AUTH0_DOMAIN: ${VITE_AUTH0_DOMAIN:-motovaultpro.us.auth0.com}
|
||||
VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3}
|
||||
VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com}
|
||||
SECRETS_DIR: /run/secrets
|
||||
volumes:
|
||||
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
|
||||
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
|
||||
environment: *frontend-env
|
||||
volumes: *frontend-volumes
|
||||
networks:
|
||||
- frontend
|
||||
depends_on:
|
||||
- mvp-backend-blue
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:3000 || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
healthcheck: *frontend-healthcheck
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -55,44 +100,15 @@ services:
|
||||
image: ${BACKEND_IMAGE:-git.motovaultpro.com/egullickson/backend:latest}
|
||||
container_name: mvp-backend-blue
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
CONFIG_PATH: /app/config/production.yml
|
||||
SECRETS_DIR: /run/secrets
|
||||
DATABASE_HOST: mvp-postgres
|
||||
REDIS_HOST: mvp-redis
|
||||
STRIPE_PRO_MONTHLY_PRICE_ID: prod_Toj6BG9Z9JwREl
|
||||
STRIPE_PRO_YEARLY_PRICE_ID: prod_Toj8oo0RpVBQmB
|
||||
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: prod_Toj8xGEui9jl6j
|
||||
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: prod_Toj9A7A773xrdn
|
||||
volumes:
|
||||
- ./config/app/production.yml:/app/config/production.yml:ro
|
||||
- ./config/shared/production.yml:/app/config/shared.yml:ro
|
||||
- ./secrets/app/postgres-password.txt:/run/secrets/postgres-password:ro
|
||||
- ./secrets/app/auth0-client-secret.txt:/run/secrets/auth0-client-secret:ro
|
||||
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
|
||||
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
|
||||
- ./secrets/app/resend-api-key.txt:/run/secrets/resend-api-key:ro
|
||||
- ./secrets/app/auth0-management-client-id.txt:/run/secrets/auth0-management-client-id:ro
|
||||
- ./secrets/app/auth0-management-client-secret.txt:/run/secrets/auth0-management-client-secret:ro
|
||||
- ./secrets/app/stripe-secret-key.txt:/run/secrets/stripe-secret-key:ro
|
||||
- ./secrets/app/stripe-webhook-secret.txt:/run/secrets/stripe-webhook-secret:ro
|
||||
- ./data/documents:/app/data/documents
|
||||
- ./data/backups:/app/data/backups
|
||||
environment: *backend-env
|
||||
volumes: *backend-volumes
|
||||
networks:
|
||||
- backend
|
||||
- database
|
||||
depends_on:
|
||||
- mvp-postgres
|
||||
- mvp-redis
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- node -e "require('http').get('http://localhost:3001/health', r => process.exit(r.statusCode===200?0:1)).on('error', () => process.exit(1))"
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 180s
|
||||
healthcheck: *backend-healthcheck
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -110,25 +126,13 @@ services:
|
||||
image: ${FRONTEND_IMAGE:-git.motovaultpro.com/egullickson/frontend:latest}
|
||||
container_name: mvp-frontend-green
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
VITE_API_BASE_URL: /api
|
||||
VITE_AUTH0_DOMAIN: ${VITE_AUTH0_DOMAIN:-motovaultpro.us.auth0.com}
|
||||
VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3}
|
||||
VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com}
|
||||
SECRETS_DIR: /run/secrets
|
||||
volumes:
|
||||
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
|
||||
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
|
||||
environment: *frontend-env
|
||||
volumes: *frontend-volumes
|
||||
networks:
|
||||
- frontend
|
||||
depends_on:
|
||||
- mvp-backend-green
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:3000 || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
healthcheck: *frontend-healthcheck
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -146,44 +150,15 @@ services:
|
||||
image: ${BACKEND_IMAGE:-git.motovaultpro.com/egullickson/backend:latest}
|
||||
container_name: mvp-backend-green
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
CONFIG_PATH: /app/config/production.yml
|
||||
SECRETS_DIR: /run/secrets
|
||||
DATABASE_HOST: mvp-postgres
|
||||
REDIS_HOST: mvp-redis
|
||||
STRIPE_PRO_MONTHLY_PRICE_ID: prod_Toj6BG9Z9JwREl
|
||||
STRIPE_PRO_YEARLY_PRICE_ID: prod_Toj8oo0RpVBQmB
|
||||
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: prod_Toj8xGEui9jl6j
|
||||
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: prod_Toj9A7A773xrdn
|
||||
volumes:
|
||||
- ./config/app/production.yml:/app/config/production.yml:ro
|
||||
- ./config/shared/production.yml:/app/config/shared.yml:ro
|
||||
- ./secrets/app/postgres-password.txt:/run/secrets/postgres-password:ro
|
||||
- ./secrets/app/auth0-client-secret.txt:/run/secrets/auth0-client-secret:ro
|
||||
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
|
||||
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
|
||||
- ./secrets/app/resend-api-key.txt:/run/secrets/resend-api-key:ro
|
||||
- ./secrets/app/auth0-management-client-id.txt:/run/secrets/auth0-management-client-id:ro
|
||||
- ./secrets/app/auth0-management-client-secret.txt:/run/secrets/auth0-management-client-secret:ro
|
||||
- ./secrets/app/stripe-secret-key.txt:/run/secrets/stripe-secret-key:ro
|
||||
- ./secrets/app/stripe-webhook-secret.txt:/run/secrets/stripe-webhook-secret:ro
|
||||
- ./data/documents:/app/data/documents
|
||||
- ./data/backups:/app/data/backups
|
||||
environment: *backend-env
|
||||
volumes: *backend-volumes
|
||||
networks:
|
||||
- backend
|
||||
- database
|
||||
depends_on:
|
||||
- mvp-postgres
|
||||
- mvp-redis
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- node -e "require('http').get('http://localhost:3001/health', r => process.exit(r.statusCode===200?0:1)).on('error', () => process.exit(1))"
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 180s
|
||||
healthcheck: *backend-healthcheck
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
|
||||
@@ -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,58 +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
|
||||
STRIPE_PRO_MONTHLY_PRICE_ID: prod_Toj6BG9Z9JwREl
|
||||
STRIPE_PRO_YEARLY_PRICE_ID: prod_Toj8oo0RpVBQmB
|
||||
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: prod_Toj8xGEui9jl6j
|
||||
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: prod_Toj9A7A773xrdn
|
||||
|
||||
# OCR - Production log level + engine config
|
||||
mvp-ocr:
|
||||
environment:
|
||||
# Python log levels: DEBUG | INFO | WARNING | ERROR | CRITICAL
|
||||
LOG_LEVEL: error
|
||||
REDIS_HOST: mvp-redis
|
||||
REDIS_PORT: 6379
|
||||
REDIS_DB: 1
|
||||
# OCR engine configuration (Google Vision primary, PaddleOCR fallback)
|
||||
OCR_PRIMARY_ENGINE: google_vision
|
||||
OCR_FALLBACK_ENGINE: paddleocr
|
||||
OCR_CONFIDENCE_THRESHOLD: "0.6"
|
||||
OCR_FALLBACK_THRESHOLD: "0.6"
|
||||
GOOGLE_VISION_KEY_PATH: /run/secrets/google-wif-config.json
|
||||
VISION_MONTHLY_LIMIT: "1000"
|
||||
# Vertex AI / Gemini configuration (maintenance schedule extraction)
|
||||
VERTEX_AI_PROJECT: motovaultpro
|
||||
VERTEX_AI_LOCATION: us-central1
|
||||
GEMINI_MODEL: gemini-2.5-flash
|
||||
|
||||
# PostgreSQL - Remove dev ports, production log level
|
||||
# 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
955
docs/USER-GUIDE.md
Normal file
@@ -0,0 +1,955 @@
|
||||
# MotoVaultPro User Guide
|
||||
|
||||
Precision Vehicle Management -- Track every mile. Own every detail.
|
||||
|
||||
MotoVaultPro is a cloud-based vehicle management platform for car enthusiasts and collectors. It tracks your entire fleet in one place: maintenance histories, fuel logs, documents, gas stations, and performance analytics.
|
||||
|
||||
This guide walks through every feature of the application.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Getting Started](#1-getting-started)
|
||||
- [Creating an Account](#creating-an-account)
|
||||
- [Logging In](#logging-in)
|
||||
- [Onboarding](#onboarding)
|
||||
- [Trouble Logging In](#trouble-logging-in)
|
||||
2. [Dashboard](#2-dashboard)
|
||||
- [Your Fleet Overview](#your-fleet-overview)
|
||||
- [Quick Actions](#quick-actions)
|
||||
3. [Vehicles](#3-vehicles)
|
||||
- [Viewing Your Vehicles](#viewing-your-vehicles)
|
||||
- [Adding a Vehicle](#adding-a-vehicle)
|
||||
- [VIN Decode](#vin-decode)
|
||||
- [Vehicle Detail Page](#vehicle-detail-page)
|
||||
- [Editing a Vehicle](#editing-a-vehicle)
|
||||
- [Deleting a Vehicle](#deleting-a-vehicle)
|
||||
4. [Fuel Logs](#4-fuel-logs)
|
||||
- [Fuel Logs Overview](#fuel-logs-overview)
|
||||
- [Logging Fuel](#logging-fuel)
|
||||
- [Receipt Scanning](#receipt-scanning)
|
||||
- [Editing and Deleting Fuel Logs](#editing-and-deleting-fuel-logs)
|
||||
5. [Maintenance](#5-maintenance)
|
||||
- [Maintenance Records](#maintenance-records)
|
||||
- [Adding a Maintenance Record](#adding-a-maintenance-record)
|
||||
- [Maintenance Schedules](#maintenance-schedules)
|
||||
- [Creating a Schedule](#creating-a-schedule)
|
||||
6. [Gas Stations](#6-gas-stations)
|
||||
- [Finding Stations](#finding-stations)
|
||||
- [Saved Stations](#saved-stations)
|
||||
- [Premium 93 Stations](#premium-93-stations)
|
||||
7. [Documents](#7-documents)
|
||||
- [Documents Overview](#documents-overview)
|
||||
- [Adding a Document](#adding-a-document)
|
||||
- [Document Types](#document-types)
|
||||
8. [Settings](#8-settings)
|
||||
- [Profile](#profile)
|
||||
- [Security and Privacy](#security-and-privacy)
|
||||
- [Subscription](#subscription)
|
||||
- [Notifications](#notifications)
|
||||
- [Appearance and Units](#appearance-and-units)
|
||||
- [Data Import and Export](#data-import-and-export)
|
||||
- [Account Actions](#account-actions)
|
||||
9. [Subscription Tiers and Pro Features](#9-subscription-tiers-and-pro-features)
|
||||
- [Tier Comparison](#tier-comparison)
|
||||
- [VIN Camera Scanning and Decode (Pro)](#vin-camera-scanning-and-decode-pro)
|
||||
- [Fuel Receipt Scanning (Pro)](#fuel-receipt-scanning-pro)
|
||||
- [Maintenance Receipt Scanning (Pro)](#maintenance-receipt-scanning-pro)
|
||||
- [Maintenance Manual PDF Extraction (Pro)](#maintenance-manual-pdf-extraction-pro)
|
||||
- [Email Ingestion (Pro)](#email-ingestion-pro)
|
||||
- [Shared Vehicle Documents (Pro)](#shared-vehicle-documents-pro)
|
||||
- [Community Station Submissions (Pro)](#community-station-submissions-pro)
|
||||
- [Managing Your Subscription](#managing-your-subscription)
|
||||
10. [Mobile Experience](#10-mobile-experience)
|
||||
|
||||
---
|
||||
|
||||
## 1. Getting Started
|
||||
|
||||
### Creating an Account
|
||||
|
||||
Navigate to [motovaultpro.com](https://motovaultpro.com) and click the **Sign Up** button in the top-right corner of the navigation bar.
|
||||
|
||||
**Sign Up Page**
|
||||
|
||||
The registration page displays the MotoVaultPro logo and a clean form with the following fields:
|
||||
|
||||
| Field | Required | Details |
|
||||
|-------|----------|---------|
|
||||
| Email Address | Yes | Your email address (e.g., your.email@example.com) |
|
||||
| Password | Yes | Minimum 8 characters, must include one uppercase letter and one number |
|
||||
| Confirm Password | Yes | Re-enter your password to confirm |
|
||||
| Terms & Conditions | Yes | Checkbox -- you must agree to the Terms & Conditions before creating your account |
|
||||
|
||||
After filling in all fields, click the **Create Account** button.
|
||||
|
||||
If you already have an account, click the **Login** link at the bottom of the form.
|
||||
|
||||
After registration, you will receive a verification email. Click the link in the email to verify your account before logging in.
|
||||
|
||||
### Logging In
|
||||
|
||||
Click the **Login** button in the top-right corner of the navigation bar. You will be redirected to the secure login page powered by Auth0.
|
||||
|
||||
**Login Page**
|
||||
|
||||
Enter your registered email address, then click **Continue**. On the next screen, enter your password and click **Continue** to log in.
|
||||
|
||||
After successful authentication, you will be redirected to the Dashboard.
|
||||
|
||||
### Onboarding
|
||||
|
||||
First-time users see an onboarding flow with three steps:
|
||||
|
||||
1. **Preferences** -- Choose your preferred unit system (Imperial or Metric), distance units, and notification preferences.
|
||||
2. **Add Your First Vehicle** -- Enter your first vehicle's details (you can skip this step and add vehicles later).
|
||||
3. **Complete** -- A welcome screen with quick links to get started exploring the app.
|
||||
|
||||
### Trouble Logging In
|
||||
|
||||
If you are having trouble logging in, try the following password reset and account recovery options.
|
||||
|
||||
---
|
||||
|
||||
## 2. Dashboard
|
||||
|
||||
After logging in, you land on the Dashboard -- your fleet headquarters.
|
||||
|
||||
**What You See**
|
||||
|
||||
The Dashboard displays a "Your Fleet" heading with all your vehicles shown as cards in a grid layout. Each vehicle card shows:
|
||||
|
||||
- **Vehicle icon** -- A small colored indicator badge (varies by vehicle)
|
||||
- **Vehicle name** -- The nickname or full name (e.g., "Beast", "MERLOT")
|
||||
- **Health status** -- A green dot indicates "All clear" (no overdue maintenance); other colors indicate attention needed
|
||||
- **Status text** -- "All clear" or a maintenance alert message
|
||||
- **Odometer reading** -- Current mileage (e.g., "35,000 mi")
|
||||
|
||||
**Click any vehicle card** to go directly to that vehicle's detail page.
|
||||
|
||||
### Quick Actions
|
||||
|
||||
Two action buttons appear in the top-right corner of the Dashboard:
|
||||
|
||||
| Button | Action |
|
||||
|--------|--------|
|
||||
| **+ Add Vehicle** | Opens the Add Vehicle form on the Vehicles page |
|
||||
| **LOG FUEL** | Opens the fuel logging modal to quickly record a fill-up |
|
||||
|
||||
These quick actions let you perform the most common tasks without navigating away from the Dashboard.
|
||||
|
||||
### Navigation Sidebar
|
||||
|
||||
The left sidebar provides access to all sections of the app:
|
||||
|
||||
| Menu Item | Description |
|
||||
|-----------|-------------|
|
||||
| **Dashboard** | Fleet overview (home) |
|
||||
| **Vehicles** | Manage your vehicle collection |
|
||||
| **Fuel Logs** | Track fuel purchases and efficiency |
|
||||
| **Maintenance** | Record service history and set schedules |
|
||||
| **Gas Stations** | Find and save gas stations |
|
||||
| **Documents** | Store vehicle-related documents |
|
||||
| **Settings** | Account, preferences, and data management |
|
||||
|
||||
At the bottom of the sidebar you will see your email address and a **Sign Out** button.
|
||||
|
||||
The header bar at the top shows a notification bell icon and a "Welcome back" greeting with your email.
|
||||
|
||||
---
|
||||
|
||||
## 3. Vehicles
|
||||
|
||||
### Viewing Your Vehicles
|
||||
|
||||
Click **Vehicles** in the sidebar to see the "My Vehicles" page. This page shows:
|
||||
|
||||
- **Search bar** -- Search vehicles by name, make, model, or VIN
|
||||
- **+ Add Vehicle** button -- Top-right corner
|
||||
- **Vehicle cards** in a grid layout (3 columns on desktop), each displaying:
|
||||
- Manufacturer logo (e.g., Chevrolet bowtie, GMC logo)
|
||||
- Vehicle nickname or full name
|
||||
- Year, Make, and Model
|
||||
- License plate number
|
||||
- Current odometer reading (in miles or kilometers)
|
||||
- **Edit** (pencil icon) and **Delete** (trash icon) buttons at the bottom of each card
|
||||
|
||||
### Adding a Vehicle
|
||||
|
||||
Click the **+ Add Vehicle** button to expand the "Add New Vehicle" form directly on the Vehicles page. The form has the following sections:
|
||||
|
||||
**Vehicle Photo**
|
||||
|
||||
Upload a photo of your vehicle. Click **ADD PHOTO** to select an image file.
|
||||
- Accepted formats: JPEG or PNG
|
||||
- Maximum file size: 5MB
|
||||
|
||||
**VIN Number**
|
||||
|
||||
Enter your vehicle's 17-character VIN (Vehicle Identification Number).
|
||||
- Type the VIN manually in the text field, OR
|
||||
- Click the **camera icon** to scan the VIN using your device camera (uses OCR technology)
|
||||
- Click the **Decode VIN** button to automatically populate vehicle details from the VIN
|
||||
|
||||
Note: VIN is optional if you provide a License Plate instead.
|
||||
|
||||
**Vehicle Specifications**
|
||||
|
||||
These fields use cascading dropdowns -- each selection narrows the options for the next field:
|
||||
|
||||
| Field | How It Works |
|
||||
|-------|-------------|
|
||||
| Year | Select the model year from the dropdown |
|
||||
| Make | Available after selecting Year (e.g., Chevrolet, GMC, Ford) |
|
||||
| Model | Available after selecting Make (e.g., Silverado, Camaro, Sierra) |
|
||||
| Trim | Available after selecting Model (e.g., LT Double Cab 4WD) |
|
||||
| Engine | Available after selecting Trim (e.g., 6.6L 401 HP V8) |
|
||||
| Transmission | Available after selecting Trim (e.g., 10-Speed Automatic) |
|
||||
|
||||
**Additional Details**
|
||||
|
||||
| Field | Example | Notes |
|
||||
|-------|---------|-------|
|
||||
| Nickname | Beast, Family Car | A friendly name for your vehicle |
|
||||
| Color | Black, Blue, Red | Vehicle color |
|
||||
| License Plate | ABC-123 | Required if VIN is not provided |
|
||||
| Current Odometer Reading | 50000 | Current mileage in your selected unit |
|
||||
|
||||
**Purchase Information**
|
||||
|
||||
| Field | Example | Notes |
|
||||
|-------|---------|-------|
|
||||
| Purchase Price | 25000 | What you paid for the vehicle |
|
||||
| Purchase Date | mm/dd/yyyy | When you purchased the vehicle |
|
||||
|
||||
Click **Add Vehicle** to save, or **Cancel** to discard.
|
||||
|
||||
### VIN Decode
|
||||
|
||||
> **Pro Feature:** VIN camera scanning and automatic decode require a Pro or Enterprise subscription. Free tier users can still type a VIN manually. See [VIN Camera Scanning and Decode (Pro)](#vin-camera-scanning-and-decode-pro) for full details.
|
||||
|
||||
The VIN Decode feature automatically fills in vehicle details from a VIN:
|
||||
|
||||
1. Enter or scan your 17-character VIN
|
||||
2. Click the **Decode VIN** button
|
||||
3. The system looks up the VIN and auto-populates: Year, Make, Model, Engine, Transmission, and Trim
|
||||
4. Review the pre-filled fields and make any corrections
|
||||
5. Continue filling in the remaining fields (Nickname, Color, etc.)
|
||||
|
||||
### Vehicle Detail Page
|
||||
|
||||
Click any vehicle card (from Dashboard or Vehicles list) to open the Vehicle Detail Page. This page shows everything about a single vehicle:
|
||||
|
||||
**Header Area**
|
||||
- Back arrow and **BACK** link to return to the previous page
|
||||
- Vehicle nickname as the page title (e.g., "Beast")
|
||||
- **Edit Vehicle** button (top-right)
|
||||
- Quick action buttons: **Add Fuel Log** and **Add Maintenance**
|
||||
|
||||
**Vehicle Details Section**
|
||||
- Manufacturer logo
|
||||
- Full vehicle description (e.g., "2022 Chevrolet Silverado 2500HD")
|
||||
- VIN Number
|
||||
- Year, Make, and Model (displayed in a 3-column row)
|
||||
- Trim, Engine, and Transmission (displayed in a 3-column row)
|
||||
- Nickname
|
||||
- Color and License Plate (side by side)
|
||||
- Current Odometer Reading
|
||||
|
||||
**Purchase Information Section**
|
||||
- Purchase Price
|
||||
- Purchase Date
|
||||
|
||||
**Ownership Costs Section**
|
||||
- Tracks insurance, registration, taxes, and other recurring vehicle costs
|
||||
- Shows "No ownership costs recorded yet" until costs are added
|
||||
|
||||
**Vehicle Records Section**
|
||||
- A table showing all records associated with this vehicle (fuel logs, maintenance records)
|
||||
- Columns: Date, Type, Summary, Amount, Actions
|
||||
- **Filter** dropdown to filter by record type (All, Fuel, Maintenance)
|
||||
|
||||
### Editing a Vehicle
|
||||
|
||||
From the Vehicle Detail Page, click the **Edit Vehicle** button. This opens the vehicle form pre-filled with the current values. Make your changes and save.
|
||||
|
||||
From the Vehicles list, click the **pencil icon** on any vehicle card to edit it directly.
|
||||
|
||||
### Deleting a Vehicle
|
||||
|
||||
From the Vehicles list, click the **trash icon** on any vehicle card. You will be asked to confirm the deletion. Deleting a vehicle is permanent and removes all associated records.
|
||||
|
||||
---
|
||||
|
||||
## 4. Fuel Logs
|
||||
|
||||
### Fuel Logs Overview
|
||||
|
||||
Click **Fuel Logs** in the sidebar to see the Fuel Logs page. At the top, you see summary statistics:
|
||||
|
||||
| Metric | Description |
|
||||
|--------|-------------|
|
||||
| **LOGS** | Total number of fuel entries |
|
||||
| **TOTAL FUEL** | Total gallons (or liters) across all fill-ups |
|
||||
| **TOTAL COST** | Total amount spent on fuel |
|
||||
|
||||
Below the summary, a table lists all your fuel log entries. If you have no entries yet, you will see "No fuel logs yet."
|
||||
|
||||
The **+ Add Fuel Log** button is in the top-right corner.
|
||||
|
||||
### Logging Fuel
|
||||
|
||||
Click **+ Add Fuel Log** (or the **LOG FUEL** button from the Dashboard) to open the "Log Fuel" modal. The modal title reads "Add Fuel Log" with a note showing your current unit system (e.g., "Displaying in Imperial (miles, gallons, MPG)").
|
||||
|
||||
**Receipt Scanning**
|
||||
|
||||
At the top of the form, click **SCAN RECEIPT** to use your camera to photograph a fuel receipt. The OCR system will automatically extract:
|
||||
- Fuel amount (gallons)
|
||||
- Cost per gallon
|
||||
- Total cost
|
||||
- Date and time
|
||||
- Fuel grade
|
||||
- Station name
|
||||
|
||||
You can review and edit any extracted values before saving.
|
||||
|
||||
**Form Fields**
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| Select Vehicle | Yes | Choose which vehicle this fill-up is for |
|
||||
| Date & Time | Yes | Pre-filled with the current date and time; click the calendar icon to change |
|
||||
| MPG | Auto | Calculated automatically from distance and fuel amount |
|
||||
| Trip Distance / Odometer Reading | One required | Toggle between entering trip distance (miles driven since last fill) or odometer reading. Click the toggle button to switch modes. |
|
||||
| Fuel Type | Yes | Dropdown: Gasoline, Diesel, Electric, Hybrid, etc. |
|
||||
| Fuel Grade | Optional | Dropdown: 87 (Regular), 89 (Midgrade), 91 (Premium), 93 (Premium), etc. |
|
||||
| Fuel Amount | Yes | Number of gallons (or liters) purchased |
|
||||
| Cost Per Gallon | Yes | Price per gallon (or liter) |
|
||||
| Total Cost | Auto | Calculated from Fuel Amount x Cost Per Gallon. Displays "Enter fuel amount and cost per unit to see total cost." until both values are provided. |
|
||||
| Location | Optional | Type a station name to search and select |
|
||||
| Notes | Optional | Any additional notes about this fill-up |
|
||||
|
||||
Click **Add Fuel Log** to save the entry. The button is disabled until all required fields are completed.
|
||||
|
||||
### Receipt Scanning
|
||||
|
||||
> **Pro Feature:** Receipt scanning requires a Pro or Enterprise subscription. See [Fuel Receipt Scanning (Pro)](#fuel-receipt-scanning-pro) for full details on what is extracted and the review workflow.
|
||||
|
||||
The receipt scanning feature uses OCR technology:
|
||||
|
||||
1. Click **SCAN RECEIPT** at the top of the Log Fuel form
|
||||
2. Use your camera to photograph the receipt
|
||||
3. The system extracts fuel data with confidence indicators
|
||||
4. A review modal appears showing extracted values
|
||||
5. Edit any incorrect values inline
|
||||
6. Click **Accept** to auto-fill the form, or **Reject** to enter manually
|
||||
|
||||
### Editing and Deleting Fuel Logs
|
||||
|
||||
From the fuel logs table, each entry has action buttons:
|
||||
- **Edit** -- Opens the fuel log in edit mode to update any fields
|
||||
- **Delete** -- Removes the fuel log entry (with confirmation)
|
||||
|
||||
---
|
||||
|
||||
## 5. Maintenance
|
||||
|
||||
Click **Maintenance** in the sidebar. This page has two tabs: **RECORDS** and **SCHEDULES**.
|
||||
|
||||
At the top is a **Vehicle** dropdown to select which vehicle you are viewing or adding maintenance for.
|
||||
|
||||
### Maintenance Records
|
||||
|
||||
The **RECORDS** tab shows your maintenance history for the selected vehicle. Below the list is the "Add Maintenance Record" form.
|
||||
|
||||
### Adding a Maintenance Record
|
||||
|
||||
The form on the RECORDS tab includes:
|
||||
|
||||
**Receipt Upload**
|
||||
|
||||
> **Pro Feature:** Maintenance receipt scanning requires a Pro or Enterprise subscription. See [Maintenance Receipt Scanning (Pro)](#maintenance-receipt-scanning-pro) for full details.
|
||||
|
||||
Click the **ADD RECEIPT** button (dashed outline area) to upload or photograph a maintenance receipt. The OCR system can extract:
|
||||
- Category and service type
|
||||
- Cost
|
||||
- Date
|
||||
- Shop name
|
||||
|
||||
**Form Fields**
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| Category | Yes | Dropdown with options: Routine Maintenance, Repair, Performance Upgrade. Each category has specific subtypes. |
|
||||
| Date | Yes | Pre-filled with today's date; click the calendar icon to change |
|
||||
| Odometer Reading | Optional | Vehicle mileage at time of service |
|
||||
| Cost | Optional | Total cost of the service (in $) |
|
||||
| Shop Name | Optional | Name of the service shop |
|
||||
| Notes | Optional | Additional details about the service (max 1,000 characters) |
|
||||
|
||||
Click **Add Record** to save the maintenance record.
|
||||
|
||||
**Maintenance Categories**
|
||||
|
||||
| Category | Example Services |
|
||||
|----------|-----------------|
|
||||
| Routine Maintenance | Oil change, air filter, tire rotation, battery, brakes, coolant flush, transmission fluid, spark plugs, fuel filter, cabin air filter, brake fluid, detailing |
|
||||
| Repair | Engine repair, transmission repair, brake repair, electrical, cooling system, suspension, steering, fuel system, body work, paint, glass |
|
||||
| Performance Upgrade | Engine tuning, suspension upgrade, wheels/tires, brake upgrade, exhaust, intake, lighting, audio |
|
||||
|
||||
### Maintenance Schedules
|
||||
|
||||
Click the **SCHEDULES** tab to set up recurring maintenance reminders.
|
||||
|
||||
### Creating a Schedule
|
||||
|
||||
The "Create Maintenance Schedule" form includes:
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| Category | Yes | Same categories as maintenance records |
|
||||
| Schedule Type | Yes | Three options (radio buttons): |
|
||||
| | | **Interval-based** -- Every X months or miles (e.g., oil change every 5,000 miles or 6 months) |
|
||||
| | | **Fixed date** -- A specific calendar date |
|
||||
| | | **Time since last service** -- Based on when service was last performed |
|
||||
| Interval (Months) | Conditional | Number of months between services. Optional if miles are specified. |
|
||||
| Interval (Miles) | Conditional | Number of miles between services. Optional if months are specified. |
|
||||
| Reminders | Optional | Set up to 3 reminders (Reminder 1, Reminder 2, Reminder 3) via dropdowns |
|
||||
| Email notifications | Optional | Toggle to receive email reminders when service is due |
|
||||
|
||||
Click **Create Schedule** to save.
|
||||
|
||||
Below the form, the "Maintenance Schedules" section lists all active schedules for the selected vehicle, showing when each service is next due.
|
||||
|
||||
---
|
||||
|
||||
## 6. Gas Stations
|
||||
|
||||
Click **Gas Stations** in the sidebar. This page helps you find gas stations near you and save your favorites.
|
||||
|
||||
The page is split into two sections:
|
||||
- **Left**: An interactive Google Map showing station locations as markers
|
||||
- **Right**: Search controls
|
||||
|
||||
### Finding Stations
|
||||
|
||||
**Search Options**
|
||||
|
||||
| Control | Description |
|
||||
|---------|-------------|
|
||||
| **Use Current Location** | Large red button -- uses your device's GPS to center the search on your current location |
|
||||
| **Street** | Enter a street address (e.g., 123 Main St) |
|
||||
| **City** | Enter a city name |
|
||||
| **State** | Select from dropdown |
|
||||
| **ZIP** | Enter a ZIP code |
|
||||
| **Search Radius** | Slider from 1 mi to 25 mi (default: 5 mi) |
|
||||
| **Search Stations** | Click to execute the search |
|
||||
|
||||
You can either use your current location OR manually enter an address. Search results appear below the map.
|
||||
|
||||
**Search Results**
|
||||
|
||||
Below the map, there are three tabs:
|
||||
|
||||
| Tab | Description |
|
||||
|-----|-------------|
|
||||
| **RESULTS (n)** | Stations found by your search, showing count |
|
||||
| **SAVED (n)** | Your saved/favorite stations |
|
||||
| **PREMIUM 93** | Stations verified to carry true 93-octane fuel |
|
||||
|
||||
Each station result shows:
|
||||
- Station name (e.g., "Costco Gas Station", "Mobil")
|
||||
- Street address and city
|
||||
- Star rating (community-verified)
|
||||
- Fuel grade badges (e.g., "93 Octane - w/ Ethanol")
|
||||
- Save/unsave button
|
||||
|
||||
### Saved Stations
|
||||
|
||||
Click the **SAVED** tab to see your favorite stations. Saved stations also appear as yellow star markers on the map. You can:
|
||||
- View station details
|
||||
- Remove a station from your saved list
|
||||
- Navigate on the map by clicking a station card
|
||||
|
||||
### Premium 93 Stations
|
||||
|
||||
Click the **PREMIUM 93** tab to see your "Your Premium 93 Stations" -- stations that have been community-verified to carry genuine 93-octane fuel. This is especially useful for performance vehicles that require premium fuel.
|
||||
|
||||
---
|
||||
|
||||
## 7. Documents
|
||||
|
||||
Click **Documents** in the sidebar. This page stores all your vehicle-related paperwork digitally.
|
||||
|
||||
### Documents Overview
|
||||
|
||||
The page shows the title "Documents" with an **Add Document** button in the top-right corner.
|
||||
|
||||
If you have no documents yet, you will see an empty state:
|
||||
- A document icon
|
||||
- "No Documents Yet"
|
||||
- "You haven't added any documents yet. Documents will appear here once you create them."
|
||||
- A **Go to Vehicles** button (since documents are associated with vehicles)
|
||||
|
||||
When documents exist, they appear in a list/grid with preview thumbnails, titles, document types, and associated vehicles.
|
||||
|
||||
### Adding a Document
|
||||
|
||||
Click **Add Document** to open the "Add Document" modal with these fields:
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| Vehicle | Yes | Select which vehicle this document belongs to (dropdown of your vehicles) |
|
||||
| Document Type | Yes | Select the type (see Document Types below) |
|
||||
| Title | Yes | A descriptive title (e.g., "Honda CBR600RR Service Manual") |
|
||||
| Notes | Optional | Any additional notes about this document |
|
||||
| Upload image/PDF | Yes | Click **Choose File** to upload an image or PDF file |
|
||||
|
||||
Click **Create Document** to save, or **Cancel** to discard.
|
||||
|
||||
### Document Types
|
||||
|
||||
| Type | What to Store |
|
||||
|------|--------------|
|
||||
| Insurance | Insurance policies, cards, declarations pages |
|
||||
| Registration | Vehicle registration documents |
|
||||
| Maintenance Manual | Owner's manuals and service manuals |
|
||||
| Service Records | Service history documentation from dealers/shops |
|
||||
| Recall Notices | Vehicle recall notifications |
|
||||
| Inspection Reports | State inspection or emissions test reports |
|
||||
| Receipts | Purchase receipts for parts, accessories, services |
|
||||
| Other | Any other vehicle-related document |
|
||||
|
||||
**Insurance documents** have additional fields: Insurance Company, Policy Number, Effective Date, Expiration Date, Coverage amounts (Bodily Injury, Property Damage), and Premium.
|
||||
|
||||
**Registration documents** have additional fields: License Plate, Expiration Date, and Registration Cost.
|
||||
|
||||
Documents with expiration dates will show countdown badges so you know when renewals are coming up.
|
||||
|
||||
> **Pro Feature:** When uploading a Maintenance Manual PDF, Pro and Enterprise users can check **Scan for Maintenance Schedule** to automatically extract a complete maintenance schedule from the document. See [Maintenance Manual PDF Extraction (Pro)](#maintenance-manual-pdf-extraction-pro) for the full workflow.
|
||||
|
||||
---
|
||||
|
||||
## 8. Settings
|
||||
|
||||
Click **Settings** in the sidebar to manage your account, preferences, and data.
|
||||
|
||||
### Profile
|
||||
|
||||
The Profile section displays your account information:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Avatar** | Shows your initial in a circle |
|
||||
| **Name** | Your display name (e.g., "Eric Gullickson") |
|
||||
| **Email** | Your account email address |
|
||||
| **Account Status** | Shows "Verified account" if email is verified |
|
||||
| **Display Name** | Your public-facing name |
|
||||
| **Notification Email** | The email address used for notifications (defaults to "Using primary email") |
|
||||
|
||||
Click the **Edit** button to update your display name or notification email.
|
||||
|
||||
### Security and Privacy
|
||||
|
||||
The Security & Privacy row shows "Password, two-factor authentication" with a **Manage** button. Click it to:
|
||||
- Change your password
|
||||
- Set up two-factor authentication
|
||||
- Manage active sessions
|
||||
- Log out all devices
|
||||
|
||||
### My Vehicles
|
||||
|
||||
A summary list of all your vehicles (with count, e.g., "My Vehicles (4)"). Click the **Manage** button to go to the Vehicles page.
|
||||
|
||||
### Subscription
|
||||
|
||||
Shows your current subscription plan with a **Manage** button.
|
||||
|
||||
| Plan | Features |
|
||||
|------|----------|
|
||||
| **FREE** | Basic vehicle management, up to 2 vehicles, basic fuel tracking, document storage |
|
||||
| **Pro** | Up to 10 vehicles, receipt OCR scanning, maintenance schedules, email ingestion |
|
||||
| **Enterprise** | Unlimited vehicles, all Pro features |
|
||||
|
||||
"Upgrade to Pro or Enterprise for more features and vehicle slots."
|
||||
|
||||
Click **Manage** to view plan details, change your subscription, manage payment methods, and view billing history.
|
||||
|
||||
### Notifications
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| **Push Notifications** | Receive notifications about your vehicles (maintenance due, etc.) | ON |
|
||||
| **Email Updates** | Receive maintenance reminders and updates via email | OFF |
|
||||
|
||||
Toggle each setting on or off.
|
||||
|
||||
### Appearance and Units
|
||||
|
||||
| Setting | Description | Options |
|
||||
|---------|-------------|---------|
|
||||
| **Dark Mode** | Use dark theme for better night viewing | Toggle ON/OFF (default: OFF) |
|
||||
| **Units for distance and capacity** | Choose between measurement systems | **Imperial**: miles, gallons, MPG, USD / **Metric**: km, liters, L/100km, EUR |
|
||||
|
||||
The unit system you select here applies throughout the entire application -- Dashboard, Fuel Logs, Maintenance, and Vehicle Details all update to reflect your preference.
|
||||
|
||||
### Data Import and Export
|
||||
|
||||
| Action | Description | Button |
|
||||
|--------|-------------|--------|
|
||||
| **Import Data** | Upload and restore your vehicle data from a backup file | **Import** |
|
||||
| **Export Data** | Download your vehicle and fuel log data as a backup file | **Export** |
|
||||
|
||||
Export creates a downloadable archive of all your data. Import accepts a previously exported backup file to restore your data.
|
||||
|
||||
### Account Actions
|
||||
|
||||
At the bottom of the Settings page:
|
||||
|
||||
| Button | Action |
|
||||
|--------|--------|
|
||||
| **Sign Out** | Log out of your account |
|
||||
| **DELETE ACCOUNT** | Permanently delete your account and all data. This initiates a 30-day grace period during which you can cancel the deletion by logging back in. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Subscription Tiers and Pro Features
|
||||
|
||||
MotoVaultPro offers three subscription tiers. Higher tiers automatically include all features from lower tiers.
|
||||
|
||||
### Tier Comparison
|
||||
|
||||
| Feature | Free | Pro | Enterprise |
|
||||
|---------|:----:|:---:|:----------:|
|
||||
| **Vehicle Slots** | 2 | 5 | Unlimited |
|
||||
| Vehicle management | Yes | Yes | Yes |
|
||||
| Fuel log tracking | Yes | Yes | Yes |
|
||||
| Document storage | Yes | Yes | Yes |
|
||||
| Gas station finder | Yes | Yes | Yes |
|
||||
| Maintenance records | Yes | Yes | Yes |
|
||||
| Maintenance schedules | Yes | Yes | Yes |
|
||||
| Data import/export | Yes | Yes | Yes |
|
||||
| **VIN camera scan and decode** | -- | Yes | Yes |
|
||||
| **Fuel receipt OCR scanning** | -- | Yes | Yes |
|
||||
| **Maintenance receipt OCR scanning** | -- | Yes | Yes |
|
||||
| **Maintenance manual PDF extraction** | -- | Yes | Yes |
|
||||
| **Email ingestion** (forward receipts) | -- | Yes | Yes |
|
||||
| **Shared vehicle documents** | -- | Yes | Yes |
|
||||
| **Community station submissions** | -- | Yes | Yes |
|
||||
|
||||
When you attempt to use a Pro feature on the Free tier, an **Upgrade Required** dialog appears explaining the feature and offering a direct link to upgrade.
|
||||
|
||||
---
|
||||
|
||||
### VIN Camera Scanning and Decode (Pro)
|
||||
|
||||
**What it does:** Use your device camera to photograph your vehicle's VIN plate, and the system automatically reads the VIN using OCR (Optical Character Recognition) and decodes it from the vehicle database.
|
||||
|
||||
**How to use it:**
|
||||
|
||||
1. Go to **Vehicles** and click **+ Add Vehicle**
|
||||
2. In the VIN Number field, click the **camera icon**
|
||||
3. Point your camera at the VIN plate on your vehicle (typically on the driver-side dashboard or door jamb)
|
||||
4. The OCR system reads the 17-character VIN from the image
|
||||
5. A **VIN OCR Review modal** appears showing the detected VIN with confidence indicators
|
||||
6. Confirm or correct the VIN, then click **Accept**
|
||||
7. Click the **Decode VIN** button
|
||||
8. The system queries the vehicle database and auto-populates: Year, Make, Model, Engine, Transmission, and Trim
|
||||
9. Review the pre-filled fields and complete the remaining details
|
||||
|
||||
This eliminates manual data entry errors and ensures accurate vehicle specifications.
|
||||
|
||||
---
|
||||
|
||||
### Fuel Receipt Scanning (Pro)
|
||||
|
||||
**What it does:** Photograph a fuel receipt and the OCR system extracts all relevant data, automatically filling in your fuel log entry.
|
||||
|
||||
**How to use it:**
|
||||
|
||||
1. Open the **Log Fuel** modal (from Dashboard or Fuel Logs page)
|
||||
2. Click the **SCAN RECEIPT** button at the top of the form
|
||||
3. Use your camera to photograph the fuel receipt
|
||||
4. The system processes the image and extracts:
|
||||
|
||||
| Extracted Field | Description |
|
||||
|----------------|-------------|
|
||||
| Fuel Amount | Gallons or liters purchased |
|
||||
| Cost Per Unit | Price per gallon/liter |
|
||||
| Total Cost | Total transaction amount |
|
||||
| Date & Time | Transaction timestamp |
|
||||
| Fuel Grade | Regular, Midgrade, Premium, etc. |
|
||||
| Station Name | Merchant name matched to known stations |
|
||||
|
||||
5. A **Receipt OCR Review modal** appears showing all extracted values with confidence scores
|
||||
6. Each field can be edited inline if the OCR got something wrong
|
||||
7. The station name is automatically matched against known gas stations in the system
|
||||
8. Click **Accept** to auto-fill the Log Fuel form with the extracted values
|
||||
9. Click **Reject** to discard the scan and enter data manually
|
||||
10. Review the pre-filled form and click **Add Fuel Log**
|
||||
|
||||
**Tips for best results:**
|
||||
- Photograph the receipt on a flat, well-lit surface
|
||||
- Ensure the entire receipt is visible in the frame
|
||||
- Avoid wrinkled or faded receipts when possible
|
||||
|
||||
---
|
||||
|
||||
### Maintenance Receipt Scanning (Pro)
|
||||
|
||||
**What it does:** Photograph a maintenance or service receipt to automatically extract service details into a maintenance record.
|
||||
|
||||
**How to use it:**
|
||||
|
||||
1. Go to **Maintenance** and select a vehicle
|
||||
2. On the **RECORDS** tab, click the **ADD RECEIPT** button (dashed outline area)
|
||||
3. Use your camera to photograph the service receipt
|
||||
4. The system processes the image and extracts:
|
||||
|
||||
| Extracted Field | Description |
|
||||
|----------------|-------------|
|
||||
| Category | Service type (Routine, Repair, Performance) |
|
||||
| Subtypes | Specific services performed (e.g., Oil Change, Tire Rotation) |
|
||||
| Cost | Total service cost |
|
||||
| Date | Service date |
|
||||
| Shop Name | Name of the service shop |
|
||||
|
||||
5. A **Maintenance Receipt Review modal** shows extracted values with confidence indicators
|
||||
6. Edit any incorrect values inline
|
||||
7. Click **Accept** to auto-fill the maintenance record form
|
||||
8. Review and click **Add Record**
|
||||
|
||||
---
|
||||
|
||||
### Maintenance Manual PDF Extraction (Pro)
|
||||
|
||||
**What it does:** Upload your vehicle's owner's manual or maintenance manual as a PDF, and the system automatically extracts the recommended maintenance schedule -- creating maintenance schedules with the correct intervals for your specific vehicle.
|
||||
|
||||
**How to use it:**
|
||||
|
||||
1. Go to **Documents** and click **Add Document**
|
||||
2. Select your vehicle and choose **Maintenance Manual** as the document type
|
||||
3. Upload the PDF file
|
||||
4. Check the **Scan for Maintenance Schedule** checkbox (Pro feature -- indicated by a lock icon for Free tier users)
|
||||
5. Click **Create Document**
|
||||
6. The system submits the PDF for asynchronous processing
|
||||
7. A progress indicator shows while the document is being analyzed
|
||||
8. When processing completes, the **Maintenance Schedule Review** screen appears showing:
|
||||
|
||||
| Column | Description |
|
||||
|--------|-------------|
|
||||
| Checkbox | Select which items to create as schedules |
|
||||
| Service Name | Extracted maintenance service (e.g., "Engine Oil and Filter Change") |
|
||||
| Interval | Recommended interval in months and/or miles |
|
||||
| Details | Additional notes or specifications |
|
||||
| Confidence | How confident the system is in the extraction (High/Medium/Low) |
|
||||
|
||||
9. Check the boxes next to the maintenance items you want to create
|
||||
10. Edit any service names, intervals, or details inline
|
||||
11. Click **Create Selected Schedules** to batch-create all selected items as maintenance schedules for your vehicle
|
||||
|
||||
This turns a 50-page owner's manual into a complete set of maintenance reminders in minutes.
|
||||
|
||||
---
|
||||
|
||||
### Email Ingestion (Pro)
|
||||
|
||||
**What it does:** Forward vehicle-related emails (service receipts, insurance documents, registration notices) to a dedicated email address, and they automatically appear in your MotoVaultPro account ready to be associated with a vehicle.
|
||||
|
||||
**How to use it:**
|
||||
|
||||
1. Forward any vehicle-related email to your dedicated MotoVaultPro ingestion address
|
||||
2. The system processes the email and any attachments
|
||||
3. On your **Dashboard**, a **Pending Associations** banner appears showing how many items are waiting
|
||||
4. Click the banner to open the **Pending Association List**
|
||||
5. For each pending item, you see:
|
||||
- A preview of the document or receipt
|
||||
- A vehicle selector dropdown
|
||||
6. Select the correct vehicle for each item and click **Associate**
|
||||
7. Or click **Discard** to remove items you do not want
|
||||
|
||||
**Bulk actions** are available to discard all pending items at once.
|
||||
|
||||
This is especially useful for:
|
||||
- Forwarding digital receipts from auto parts stores
|
||||
- Forwarding service confirmation emails from your mechanic
|
||||
- Forwarding insurance policy documents from your provider
|
||||
- Forwarding registration renewal notices
|
||||
|
||||
---
|
||||
|
||||
### Shared Vehicle Documents (Pro)
|
||||
|
||||
**What it does:** Associate a single document with multiple vehicles. Useful for fleet insurance policies, multi-vehicle service agreements, or shared maintenance contracts.
|
||||
|
||||
**How to use it:**
|
||||
|
||||
1. Open an existing document's detail page
|
||||
2. In the **Shared Vehicles** section, click **Add Vehicle**
|
||||
3. Select additional vehicles from the dropdown
|
||||
4. The document now appears in the Documents section for each associated vehicle
|
||||
5. To remove a vehicle association, click the **Remove** button next to that vehicle
|
||||
|
||||
---
|
||||
|
||||
### Community Station Submissions (Pro)
|
||||
|
||||
**What it does:** Submit new gas stations to the MotoVaultPro community database, helping other enthusiasts find quality fuel locations -- especially stations carrying true 93-octane premium fuel.
|
||||
|
||||
**How to use it:**
|
||||
|
||||
1. Go to **Gas Stations**
|
||||
2. Look for the option to submit a new community station
|
||||
3. Fill in the submission form:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| Station Name | Name of the gas station |
|
||||
| Location | Address or location |
|
||||
| Fuel Types | Available fuel types and grades |
|
||||
| Amenities | Available amenities (bathrooms, ATM, convenience store, etc.) |
|
||||
| Notes | Any additional information |
|
||||
| Photo | Optional photo of the station |
|
||||
|
||||
4. Submit for community review
|
||||
5. An admin reviews and approves or rejects the submission
|
||||
6. Approved stations appear in the **PREMIUM 93** tab and search results with a community-verified badge
|
||||
|
||||
---
|
||||
|
||||
### Managing Your Subscription
|
||||
|
||||
**Viewing Your Plan**
|
||||
|
||||
Go to **Settings** and find the **Subscription** section. It shows your current plan (FREE, Pro, or Enterprise) with a **Manage** button.
|
||||
|
||||
**Upgrading**
|
||||
|
||||
1. Click **Manage** in the Subscription section
|
||||
2. The Subscription page shows tier comparison cards with pricing
|
||||
3. Toggle between **Monthly** and **Annual** billing (annual saves money)
|
||||
4. Click **Upgrade** on the plan you want
|
||||
5. Enter your payment details using the secure Stripe payment form
|
||||
6. Your new features are available immediately
|
||||
|
||||
**Payment Methods**
|
||||
|
||||
- Payment is processed through Stripe (credit/debit cards)
|
||||
- You can save a card for recurring billing
|
||||
- Update or remove your payment method at any time
|
||||
- View billing history and download invoices as PDFs
|
||||
|
||||
**Billing History**
|
||||
|
||||
The billing history table shows all past invoices with:
|
||||
- Date
|
||||
- Description
|
||||
- Amount
|
||||
- Status (Paid, Pending, Failed)
|
||||
- Download PDF button for each invoice
|
||||
|
||||
**Downgrading**
|
||||
|
||||
If you downgrade from a higher tier, you may need to reduce your vehicles to fit within the lower tier's limit:
|
||||
|
||||
1. Click **Downgrade** on the lower plan
|
||||
2. A **Vehicle Selection dialog** appears if you exceed the new tier's vehicle limit
|
||||
3. Select which vehicles to keep (e.g., keep 2 for Free tier)
|
||||
4. A warning explains that removed vehicles and their data will be deleted
|
||||
5. Confirm the downgrade
|
||||
|
||||
| Tier | Vehicle Limit |
|
||||
|------|:------------:|
|
||||
| Free | 2 |
|
||||
| Pro | 5 |
|
||||
| Enterprise | Unlimited |
|
||||
|
||||
**Cancelling**
|
||||
|
||||
1. On the Subscription page, click **Cancel Subscription**
|
||||
2. A confirmation dialog appears with retention options
|
||||
3. Confirm cancellation
|
||||
4. Your subscription remains active until the end of the current billing period
|
||||
5. After expiration, your account reverts to the Free tier
|
||||
6. Click **Reactivate** at any time before expiration to keep your plan
|
||||
|
||||
---
|
||||
|
||||
## 10. Mobile Experience
|
||||
|
||||
MotoVaultPro is fully responsive and works on both desktop and mobile devices.
|
||||
|
||||
**Mobile Navigation**
|
||||
|
||||
On mobile, the sidebar is replaced by:
|
||||
- A **bottom navigation bar** with icons for: Dashboard, Vehicles, Stations
|
||||
- A **floating action button (FAB)** in the center with quick actions:
|
||||
- Log Fuel
|
||||
- Add Vehicle
|
||||
- Add Document
|
||||
- Add Maintenance
|
||||
- A **hamburger menu** (accessed from the header) that slides up from the bottom, providing access to all sections: Dashboard, Vehicles, Log Fuel, Maintenance, Documents, Settings
|
||||
|
||||
**Mobile Optimizations**
|
||||
- Touch-friendly buttons and targets (minimum 44px)
|
||||
- Swipe gestures for image viewing
|
||||
- Camera integration for VIN scanning and receipt capture
|
||||
- Full-screen forms for data entry
|
||||
- Responsive card layouts that stack vertically on smaller screens
|
||||
|
||||
All features available on desktop are also available on mobile -- no functionality is lost on smaller screens.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Keyboard Shortcuts and Tips
|
||||
|
||||
- **Search vehicles** -- Use the search bar on the Vehicles page to quickly find a vehicle by name, make, model, or VIN
|
||||
- **Quick fuel log** -- Click "LOG FUEL" on the Dashboard or "+ Add Fuel Log" on the Fuel Logs page
|
||||
- **Switch vehicles on Maintenance** -- Use the Vehicle dropdown at the top of the Maintenance page to switch between vehicles without leaving the page
|
||||
|
||||
### Common Workflows
|
||||
|
||||
**Record a fuel fill-up**
|
||||
1. Click **LOG FUEL** on the Dashboard (or go to Fuel Logs > + Add Fuel Log)
|
||||
2. Select the vehicle
|
||||
3. Enter the fuel amount and cost per gallon
|
||||
4. Optionally enter trip distance or odometer reading for MPG calculation
|
||||
5. Click **Add Fuel Log**
|
||||
|
||||
**Schedule recurring maintenance**
|
||||
1. Go to **Maintenance**
|
||||
2. Select a vehicle from the dropdown
|
||||
3. Click the **SCHEDULES** tab
|
||||
4. Select a category and schedule type
|
||||
5. Set the interval (months and/or miles)
|
||||
6. Configure reminders
|
||||
7. Click **Create Schedule**
|
||||
|
||||
**Upload a document**
|
||||
1. Go to **Documents**
|
||||
2. Click **Add Document**
|
||||
3. Select a vehicle and document type
|
||||
4. Enter a title
|
||||
5. Upload the file (image or PDF)
|
||||
6. Click **Create Document**
|
||||
|
||||
**Find a gas station**
|
||||
1. Go to **Gas Stations**
|
||||
2. Click **Use Current Location** or enter an address
|
||||
3. Adjust the search radius
|
||||
4. Click **Search Stations**
|
||||
5. Browse results and click the save icon to bookmark your favorites
|
||||
|
||||
**Export your data**
|
||||
1. Go to **Settings**
|
||||
2. Scroll to "Data & Storage"
|
||||
3. Click **Export**
|
||||
4. A backup file will download containing all your vehicle data, fuel logs, and documents
|
||||
|
||||
---
|
||||
|
||||
*MotoVaultPro -- Precision Vehicle Management*
|
||||
*2026 FB Technologies LLC. All rights reserved.*
|
||||
@@ -17,6 +17,7 @@ const config: Config = {
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'(.*/core/api/client)$': '<rootDir>/src/core/api/__mocks__/client.ts',
|
||||
'\\.(css|less|scss|sass)$': '<rootDir>/test/__mocks__/styleMock.js',
|
||||
'\\.(svg|png|jpg|jpeg|gif)$': '<rootDir>/test/__mocks__/fileMock.js',
|
||||
},
|
||||
|
||||
@@ -17,9 +17,13 @@ http {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Prevent nginx from including internal port in redirects
|
||||
# (Traefik handles external-facing port 443)
|
||||
absolute_redirect off;
|
||||
|
||||
# Handle client-side routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
||||
# Enable gzip compression
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
%PDF-1.4
|
||||
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 12 0 R /MediaBox [ 0 0 612 792 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 13 0 R /MediaBox [ 0 0 612 792 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 14 0 R /MediaBox [ 0 0 612 792 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 612 792 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 16 0 R /MediaBox [ 0 0 612 792 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 11 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Author (FB Technologies LLC) /CreationDate (D:20260103171454+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260103171454+00'00') /Producer (ReportLab PDF Library - www.reportlab.com)
|
||||
/Subject (\(unspecified\)) /Title (MotoVaultPro Terms of Service) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Count 5 /Kids [ 4 0 R 5 0 R 6 0 R 7 0 R 8 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2145
|
||||
>>
|
||||
stream
|
||||
GatU3gN)%,&:O:Sm-%%*.'uO&(\K@Ti_2CNa,SV]3g1A9"=5<9_#gDQjm0MdOHO;#U>%^F=VS*m4m21Jb(i"Vna6?Z$#m;(bA/3t!*09u$Z?'H)gK-/q"pf_A8+8qHYN5*(YTsUr2M]__sbI_^)'$I"Ve.nl*%*aJ$N*5[p$V*ldC;5i/[,MOmuMWq?j9qD"':$T*-4MIen$B7IY7nFb(>Xk#4S\;-r[]KY)<[]:FlTBVTuCqu0Zr;%Rt<Z@oG8p6KPTpd\?_NbIj38MH&^?$\6BO3d"_q(UeJ@L@4&*C+"X$#E(&j@.6'5@*A(7.g0B(7WCo<#/o7argdNTbRELmR:RF!r,e8<\6LHKtH/J]n,GFU>T0!mQ%pi%K@;^$<2R\48jk5/^lceB+W:>\GNcH7EPC4'_FmiDjY7sO#k<`q9+@e\UGNCo-fUD'c\fh1Bf.4PUp-=morfCXP,h^mJ71SaL7@sC*U6>S"$mJMOi%C_.R13<>eYb'W&SpnWg51'[7+C(E:(^-c\'i-89`'ZUnN,^J)<p$9H*PXhb05_aLnYo&\CCP[GPXb9/tZ8e@R1j8t%r2!Iqo7*[@i+4"9s.+\]kY.!-T-X*;qlncil2X6n"$?hd\YUnhS5bij03OH:22`4eL")1!h[6]%L:b"kGYO"9?(jJW`8<_,dcK\SjN&:Z6S_9*n99Kf&Q\`IO1*rc\WSGbb@?W<<bg(hk`*RMD;/IugAZ4=_R6cRi^X9s@a$Bc5]M.JDnJf``,G6p$P_++aB2gS;'iaUi_]%RtYhmd-*]"<j(3X,hTMgbR5O72M5(fW.8+F9Df/t4#Z1LQ-mG3L67:*+N9RZ!f]bDF5WKJBPit-c;Hm;=[/_VG)8's-[joje"Z0rh%gF_%cXNqZ'nbhEP]L,239;h7q,$25:)@/!35]UlV/7lki7Qnm6ksob"2Y(M1>04K.9pLQ1O^D<E9J/*#$B%ES8<p\pM#7WX%o4-[kit?=D6QP,@oXp_>+hLgH7u@'X*]tMdVXotTQnOL,]>Z.\.<)+r5O!k!bLIEG,1Gn=jQG*N1slEc=+5dlAfA/B8C)eA,D^`*-*Vk@.M+/`6,<.M)&lhY4k+h@o?I:Y%48An6ae#6gi<B$\YHn"LFh'KaWKLh`\qBr(T&C^bZCTZK/P]KVVmGVVG4RD71j_+)!7O_oC&]U]8bl!D7<RB_%9Ar6oKuqMq8r9>bGMblV^fcWfb-JH`eegmfSR.(NZ1AEQQg;`B\&&rDj9<Rc$_Hn-sd*t:24G^c1Xc:&,_H1SU+mUOY52VS9hq^?\sJbK1K^#b_bXNK=<S_^&i18T%s^q\L61:VESKqd]"1>=Rt+f-=W>#;_6ASm:mn5mk-5s"?"'.cN=)Ng$LNPj]-DF$'O'0g^B)Z`PCOFdO&L*//VDt8PR4O8a94X$sSNe#%b</]0D<f%psUUo!`6AU@%WClUJ<b`0ZA<q,,hI0$HE,^<Hj9uGIGdH_K7M$"bFPdOUe?JBt\e,<9oE[K>It;_H_.`mr0aD#),ie*BGYN;`g6uk_pEKaQK+Bq11)*N&J3u^8]>%$.Q,tp3TcD78Iae&T)+/,3J&ge9FAn=a'n;l&:.(31`aEGGCe$EaN`+d)**"PKTi]E<DrBL/%#pG26'"16l\6R"4X%k6jua<sNCLrNfm_&O4DuOs2F<=O\-;kkO+g^'X^lPG?+W"_(3MR3>mX*C\hmISqa#_X,S9htNAXLuA%Drnj0%X`UdK'gaB`gr/Ob%I&KpYO]tcY576YOXRc9^0lCb#I2#G]56[nBIG2B%PO;bd6cngO@)?Z`.d*:!8;du(2IbE9kc18-Bnbpr#oNgLRo/'"uEI;a#BH-X@P8!@5?k0DlPrIWdW5r>m&5]3j4,mC5I&6nhd(.bgC8eZY$E*hpdd3Tg$74"?G>FGU/5g7i-p%aF9P?WY5d;"!+cgNXmOr1`\uj07c!I^sff<1Oh:4=`.^6A]W,:l@?oWR!i;%h+aY>,3_Qer8PZDQ\HArdZXJ-<TT'Qe%+Xf?8$0/FtLmR)!Z%Zk-.nu4bPH\)aZ41r6WU;8?B$TYQ3HNnkBKK?kh/B/P5`<j0UBQ8ACIO9t'7OS0L0+o\VQXqD/p_OK\3S(ZM8C<^C!S)V4Ln,DiZEoW3oB~>endstream
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2354
|
||||
>>
|
||||
stream
|
||||
Gb!#\?#Sc3&q/*0)"bq\E3tr/+hN.p\(RK@G+0i8('X9G8@6WG8Q8:(Nr/-hL8&0>dDeH[m(G+bbPE>ocC?mNUR28\`;Y6e&q*!BG9::O':LR&*^q<B?dmR'm*E"WA`bWSU1q+K%K.A3s#*N!+qDdli[CLBSFChNofd,gIhb]:DN(I'1o2M3TK?l=.[0$Ps"DXsitn=_n<_Spn(Sr1U+pN1Wj(u+$@>A`O4g]0=$_&64W^179b&6:5PVd=jm"fsU*Q;WE9HSI=@u0q]_VeF4opkaMTKrU9`T[$Er6W73/*]$+rVi(F?6,k"sIDbhL.rIYV@(Vn/o!4h@LWTdW?uU9pOJSn*#BAmd*e1[=>]1H$rC/Y-.3Vo(Ir)rVYp>p?KQY:Hr*5Isol;][PqBP"P8Cb,-H[k[`eR7KA]p18fmZ'k.j(DbiY=YgL-h;l+K/d;,"?19H8+%:r#kb#qp+\/*(5;:'qF$<8['?);VsVl"2[<d1R.IEM5[=rNMr>i5cRQ-#a3:?a8']L2K@cGOOiLN1+TP,>#'.$*Wk_5:Kts/m;kZjk$;WbAFP6R']q"2M![%Lu[Rn%f),+FpKF-nBKQjnoNS*HL0=4?>ecN[E4hiitYRKri`\^hd7c)!4G.6t*jjh.ZsjEQSTh$q$hu^t#F$D^2<Q1QW-R`Yaa$E>4Gnfi/,>^Gjn.JbYjh++3L0^IA0FcmNpF!!KuBM/"J$g&7CV62,R'8_i_o<YqL)KbOrQ"V,E=,%!-BbXu1U+Y`K57[ZDp?[$'DNF0r_3#W51anR]Z;OWNaDKhI9_M?Oii3mq[Ue_Y$D+W6"TeaH^X=-%CO<K&KolAoe+7J>U1TW'++UgBfJ5>6c;pohYE[/J&!<aZoVMdcQN]Bt.6=]YP=$,<"@IZ\J+W,K!onqHBh4N//.h02DPnt&6(B,GH33c[M4Fqo9)l4pe+%>_ImTh"2k4"+DW0F$^*e.05&uEpb4uqh7TB[/KRUpoiRM**id)\n\RZG6?"R-bL>h+SjD(Sg=6R6`_mN^od$3=g;iT.Dk"!lGfeS,0/Xc..`X$__'&Yi4WSU<NK&h(mJ3F-/b=R1/HZeHKP<MA4!1\2<Nb\/5:&:,/[NA*h$9JY!V\e8dTOd/Nj=7-r//'5(Cba/&B+F-6q^D&<])Q`,%ZW8"0."0m4faOTZ.?e+X^"J.":8+#%6+V6),G)OK\KP6':_iU8pAYP0A[>]rn0M[>O3phR,\WrIO`+&HQsJ6rI*@JfW\o5D]bQ7U?7_&"c`0Gi8OJG``SQiE[NMee<o4-Qk=p[u*&-;Y3"3#V]C-?WQ%c#OO.h'V>3N!=FuTW,4cJE@KL>S$6c`9HV$^g+<$^/bX@g4c#W$rOCSDolN-].f-hA#5@P+l;h.V&E4V?T1lubIP`pmS%Hp`][?GPb[K@+/;mFu`2NC(80Elhne6s.;&Qg(e.,]NHWGL:RI5sLA1jL]]B*IbYk^DgGl4c&Hc#5nS\63/eZ,n^VK"jQfaM>J.L%MWI@I$Fb7:2D^[?f*2P$&;WkU$-beQQWn9GGeZJ[)XH=R$KTOIXAq^GhbT9g$ue*f7\A(#Q6h];nk0?m4o]4I*m*WA'LNiTC"A63JOKW@^Ba*5#fporZIPdW6]MP"<Ms1;r*gEgO3eN)!oi@Fu>\1WS(?o[4\i-_9`8K%nG\*:-c$>S[WM($D1C?VS:G(RS;I.!K,-krCjj"rNG>G:[l4P,Vn2bSD8WDCRWrt"e*@(6r>e5[BP!JrRJ>XB2FO>hB(]KQ_Jm[hbg*h&57u_aG1Jc:[@_c.4Wi\cnpE<@tt6JU`8LYHXh;r`2M,BS5Fe3?L,k]-f'@2an`^BrcjPXpS77h((X:raL;enDL'*&W_,nDEu:EJ7EFG9nBj]lnM)*%J"XXRJ9.7M]>;*hi'*>(4YFF##luOH](@<=s,nn!/\qsSY\S.hHsQ;R)-ZKZ``e5-J?=71k;3iSj5&hA:HVRV"o!?;kAOp)4R3n6^7J"LN8fDR*Ut@U,78>%&7r?4-Hu3=#D5=sZ#]F1hf"g63;n?,E(jumr=A[o.l;\Dg2cZ5[d3iH!tKL\pnX[[iBM$Z->6QEIPSG'P<lG&qK@J@*.>>Hc1H<?V5O0dnX$IPU<rshO<7bTo)l)h[0?h_VLT"sKW9&Pak3/tpH9BGUV>>TkQLs&#M!!o_dlr.qUmicnS]9$1%lm(a5Yq2a1HLC&Be+S_OPqgF(u78H[j=Zq^B,8$Uq)'WdUKUi8]QYml5!,O1$-"_s(]r%/p/55GCaBe-a!5mc"trkWi>Hft5CYJ?E<s-n%qSRX1nJh:OEG&'nQX/g2L;C>\usq`=aPRLKelpLM)2DOJfL#5D\>e,~>endstream
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2256
|
||||
>>
|
||||
stream
|
||||
GauHLgN);`(4FM1m-6VBR47-'S*R^Ii,lTR>`N[LPH)8$,D^Z)`Rk6\^HY?m&kJ4rVbbj)<"N0cQaPGZ-oTWGA"16tP3=NlgO#E+LQX[N%BDcd8:GUPpXS4of0$!9S\kQQ2(s:Q?7b^Tq]NUa>7S'1Ng@/Ml[`%+JZU^c/gMaU=I(5q'_>cY;Z7U]h^J<V#k,.iC=Cr3kjt(iV!V02&DZKbSg?7`g91CPN8fnY+&4^t-O7m$II@,U[HTZ:e*`BGFEYkDSD4d]rX1PiJ8)oY<jukP!'e3$S+"ZLf#\3/]lt#"A!=1]])D7?"hEVq``'9W)$.ts0@]D`f8nWhmSV`,'"\THTuXS+%Cj,0b/1jG\pcK6UNOq#feckK'T=W=c&h(ll^EuLIZ.lu7%S#VRE-0i<(WpVX">jkrJ[(POk3.on#Aj8JutV5>1SMk1WncF@O]*ND9ccNIIX;>/NY>A/<&Wug@A,WV3.u3U]N+0/7iX5<Ij)9.mRh!ST^o2NJg/t=:2/i1^7qtOri`]&l.tCq@,pZ*U9&&X3$&6D7RY2=1"0,@dqSIXALVA)u^jsQ=@aIkE+n?$AZba0A&)`^]iW:<%ed/(e`HVm57!_#C=`p&=?D=*%]NWJlRM2:SB6#AZ:_j+P?&7+!PC+SQ=5<#ln7gGc;%4.O"ES&o9gsQ>?dc'W#iXX&4^!W):Q;WJMqnn`a4c]p(<-a+gtZ9Q>$S&0%Ks*c5KY(SHMlr;.t-6Cl$9nf=i7Tla^(`=)2,Pp08o@g/OIFrECEkeOTi5ZT&Zr2$dh+RS33\O&[bO<M6e-?>saX&/&.SGQ?^D7;Tlg##*hhsK91oeAS*Ot4r`>+?bWJ0)L+@`aW"HL$N&*8rV?A8*OF\Ug,bR)f\g7=H;L'7glH2``$n[V[nWG.#5JG'J%iNaMV=>n`EiAs>?pj4,BF6Xm23)ar2$2?e#r4;3*P2UP"8N+iV:rgZ&sW!Y$]@PMgb>o,ipW"A&tL;4@*nA!SLpt9E6BRZJP2g*<HGIjnG2(gNWo=;#4aHI8U^;Gj17PC@`V*Y=K:hEa9ZoSddIARjo;`r[(D[fQ=BYF^b<A7A[J5#/X]!8'PIt/\_dQ5E0l-gZ4,mrU+PO;Jcngp':.V)SFXUY/oZF,U,m>EaE)#.F%+7!m8c&6RU1lknDGO:FX<e[B.r:FG*@_1+YhLcVthHf>Pm+#HjMZ)`^kkY(j3#Xp_ID8RQ+aHHOAAil4Z>\;IU->,:^p2[@Vq!W?\F\]&[O<"2d0&j`?n`I[s(C=%',pbq+@m-d^O)d'qc]c2-lG0mP;%Q"l3D;^oR[)XL#JGSL=P_%XcCNQl`XV/H,Cosiu/&],<q3&'N$P$LOn?d;)%?jPC>4`9ERNEIDJtQ/dR*jYt"IR1=Y(IZ/7WUdFaA?TJm1jLrPWl4Gs(UH+i&[np39o&K$O_2o4uIJn0?2G:G$$][ILSiH-ZY7)u:.5$c6Ui0]O$#\,dUb&E_oEk0<qbE!t=9i^b`5D$i\I@b[%/`0&`]EF6W+t<W-(ja)kItdt:+(L>eg;=p)^A.XuW);.#UB`Hj1@b[dYjY(aC()9Q_>b/?6N8D'6ViKGmj0$#O\`Y&,2st62SKK0_f"._@/<_WMJhi,hm_fh1bf6q;<Wdr:_f$]_`.YR9Y_-q./*(W*R+95\F07-+:`@4+1KVsm"Ke%!kl,oDSObim5f:D%"G-tl&$;b2AMXY)Ta#/rNS\5:RKT:mg4(6F(t?ga851]4VDrUqc'cHl=d<'e9nRVD'L!O0:<;`c@qc,O#;U9F:7NJNFq@od=*UOb`$gG4iEd\5S'rBILu;`%51`DJg"ai%h'[*>&Yt`!`cp%:Xj5KJ)oKIP(aW4h([_ara:K2Bf98;hKP3,0$K\B@nYV7p4K)?S&s)`<cPmAj47Y9Cn@j'fSN@WH]41FD7)Cn1-V1Y\lc-m0452`TP;.Y3+j1-U1YM/9f2`Fm($'2<kE@Nb5?%kdj:dJG2)^$d<fmRVaRRU?qg'K1GA8S*>/6RQiU'ebliT1"ihTnAk%&(+1tE9:Z2+1l+K&CDp."DBjp0'!j_j!VeZeLWb&K4mein!]N._M[&u4A@;sTj!8%q5_/fq^_=%9;U8+W"+ZA%FR/^c#4.'m?1F.4V^4Y/>f_'Pj+CBoqIp*"bM.-6"`klb>\2Vf/)RNV4aXs@%:^g*10@^BQ/L>jcm_Ce?PoK^CY@=OI-<)t23tD)TgATaer_^)1c+boDI$oo'82"7Tkj!X`I/:n~>endstream
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2720
|
||||
>>
|
||||
stream
|
||||
GatU4>BALj&q9"FFQJ@L+pto439Oja/3?g>irqFl=8>9#K:,+DN\2[FYMZNROtEpa\B`f.kV=qWIJ^bHO\S:AlfpEfM(X2PI&3$EOM;spdOatPjS?L(HhGHJ%1G+l*mdUR8&s6JR9/U[plV]s1q!jZ(K:>\/^ggg)&7?u9"rC+SC.kK7.qflnWEb<jStp,4!XZ_l1hj&mV+THHV#gAEhH3%[`s+u:D'^X\%H$]NEQ_?"0_U:9XP<s1WC(`)e8>gB0fSAWdhpUYDsJfM\1%K_>SUC<:@F3LLf'l(U@"RL4%=`@C:2o33i)^_R6<@6X!CWS-_JM8hsP-30B]JJC\0SP4a?WA51f8_G?94OiVV/"G2D?JPb\HOlZ[uA:njjh-+D7/5k7]AdirP6Q><fDV/]F&auId+9\QsBi8J-[6R<;#8.=!A8,:5#;XC/g`Ir'!Z>KS,_IH^a[3:dFV&$l^J4<)<)Pb]CI;`FR810]c^dT\M]VaN$);MmP@r$+OJb\>'F>*RBTe0=rb*,QD4onK7g_#>o=VF]3;:i0/#&[JC\[<n8.%'6OHpcL\_me5<\t-:F[H,5s4#9+cUC<%=:]HX/G$+g2IeBqKm]:8[5<]A1I%XB[u4dqP`::.f#a3Wbnaf0-<mKL0iX#Q;,mPgFT\(;b'[8+JsITVUQW#&OIJC)KWBFWkY.`OWSrS(fl)Fh&_S%]L>t`Jc-[o6T`_6][P>%+[2m;l'Oi(*<h&SoST#T0,@G1?ZGscogQ;%k)>"5Slrdi,&@YN_EXZfZNuWVS*1L<iR`R5J-\K9BFfUSQI!%Mbk1$FDpI_C#o<ASQW7Qh\e@s0O^)c_pLS[N+>#0OZWZh:p]l!5t&*4DJW(H.8H5ms3J[07ujo4)kF&sD74nk-;0=)Cq]W","gsX\XH<9k4q1D-mTA6It]O0((idOI?n(O>]Ed%E#?_,:XgGYNLFVW=TNCK?k0uaREPsT=&RnVj)dWCp(Rl$%UW0Knq-*t7_FD18FT.U6n$P3rdNALb$Jp,9Wg8r9C!TjCI!8B-cmIA79.ACT.erRaj&H+*`OhEH2mBT\!e+eS7SD"g.O_[bp4_7N-BV3BJ?j/p[@4$1MG_5N:W:2Qk#!MI54>!27L"<(#+60sUA+$))N$>,=n6s-KAaH*,d?,knpI,>(%@KQphB>)s5KfIbN*fL'[#$SZh@Vo'>kW=LNahr*pkr(Eo6uDqAct-UU0q8b/X-JP<oa6p<ZOGMWEW&rLi/9JLg6F:VJRfl1&]]V),t`DOesTM.9Ir^r/.BI'm)[7d2#6)Gsr9^6"o("ZA/5.)6)FVe7A=T]VpGU,ar,Cl01H/W1^d6?][)jZFuO4b2OZufGu.1^!OjXb.Kg4nT-Wn0^G.O9<*DBlRT4U))c/J0b\MC,K7Jp)4*(7T3PN4#D5$Teai;8__P"8:'5l21Mu;\^c/k4Y%(j#k=;[`nAhh&=)f^N(a*\A2#[u"&L)SnKc>pY`@s;e2%4nHfi>*#;'O?8:fd=Or=?'sE]cuO5FH7u>,=)dm5dsng<OiOAEP8ID4B.:RBhdIU5":5H?f?I?spq;>&NE%qFs/FM2D$90K1`6crN0<q2UhE2+XG38_8;%>%NS_%S3B_e(fHRR>I,&ITNaC9'=DnF`1=B2]E87I$Sa5?QAIZBm'VrCLW^6Q7*R/#@r69'8Qeo,"]`1V(AWS<-4#"Jm(@r%H?4NQ/^[o!.QRNI@)7QNd-;F6(P*%7:/P'oOF<@m@$ERL)".5AqBUU)41ppAJ16H2U1<4+=mdcmXf5=e^SRkEbEa0C$9b_rB5,*BqC&tCi[KPiFE+7O.fJT5>Z?o+QQ]mhdd5B"A]NkIcV&IUMB44?LG>ednnMKP;+Sr5<=R"N1G!tlt%e+C0*8<:8^Xd9<_>6dE$d>XQb7YgGBM"aQr-FNDX"G+\Xno/+'U.kgC?d0mf[X?\ZCA4#;#r?r(M;)2;H>:!Q.8FK4:_'a6F!jPF4kc,*h8FmnMmh/YgLp!WHYY.6`AmdpnC\.<\dSD@N#FRIFl<la&=lAe_ghn=2G[jrPY4^-cV8W2@!"3k_g$[saA?Y>`"kjG>9SNED<%r1^j"ZjF6\TA)H\,PaZMAAkACXq65drj-eX)m6ZGB3fL9(52*G<jWrI*V]P=-)fg=,Zn@de1;s!R"\nG=ul9h>/a"+;d04]LuAWD1#%%)EUDo:YQkMi,cnWIH8UG,Ip:!dI7H3GGqn(LiFG7a$=Nu(q?!l+b2Xf3$[MJFE7_<%Y\9t5F3\mkq$Pa$3SNBclB_D9uc)r-sI]i&$0LbPqC;M-I$Au7W;Mb59'*0Y'B+gkH-e0k.#,/b!^4;(^_V;!A@65N0A,!CRK%ATeo'e=YnoS5>e'(P=*sX.KZ6',#cN;/ocol(/L?5N8I(f"-j]TQ-Viq+9/Y\YDFHFXZA$dFn9!jAfJqkrAONIf:QJfP\Y'i!9(=9%DZo&boSpSr:Q7?_779JMo5:ff\Gq-38%j.RW(4L@['N\2_u@+TV7L[Ld.)ZWaDrS[JNr)i_Y"7_%t"^nV;S]Zd`]u>B'I])k]_DQWAiko#pJ\*o$]O%QFPjhh8]M76os0n]m7(m:1eF-<8,h'fNd@%,!css1pp59dNu!Yd.oW,E/Y_mpKaMo]e@?9I+k2$IPHQ!Dr:mdAYqH9Q;1"<?dP<]/3UPR7c(RV:X`tN@k_+-mY"^543B."5Q%cf,gaEIfYYn3D9~>endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1302
|
||||
>>
|
||||
stream
|
||||
GatU2gMYb8&:N/3bbJDk[!7k+V^=]7'X1fN&J>69bBc%?2I5`#bG>@7lT;GK>Z$e],EW"",.VbTkKFg_0VuNDr0J/6qZU52`t0W&=@&HG)(][`j'qY/eD7YB2l>!8]+Lmu"fLglj:cML^5/NeApM8`.N%&HDa0.Lq`AG!JZc#hY2Bu)Xcdo0Mf5ip,m9hVoU<V\qqo/JU)\>$dfn%Z<)3[Cc?6PP1jjW6G]tnnNXgdsI.!n9c''DFAlk'8R_9@\6@RG7?\IPd0Jfk]RRRIs:Y2N'H9^C';X0Q7"_OY0RCV:V`Z8Y6<ZW:&[4ioXn:8qf#e4'3oP09tf\6Tukn([8n8!\hZP4II/c&>J\gR.cZhWYYZF7=i<(41u`5ZeGIfXagD2$aU,Y/sio_X['`fhgro,&7TY3f^A%MchTc\<"8>'&$@J1sW0C1Lmmd+-\Q%EbcRX$jJ$C04psSU)CqPQ=Ft]5$<MC*iTdI/P.q[IAquVHc\6`RAA;TDET2`#^h^9!%@MH,Xc0U;C>\Or(KjiRt]RMuhk>lZ#?R\"&Y&7E"nr`_u$Dbn8aO.#eH[cKA7n6ubL,J$;r'(tFpW&*1h;U`RjRds;YOk0oP.'BDiGUhj^,/)$[bdQ,hE34s70K=YWt1q4W;<(7N4;VK.c#s9W<Ppn)&:c;)Er?TH2@+iGHCojPMp&.;^?^NWAq7TXcE]an?@Q,Iih*:*Pg1f+Y-)$G(]CBf=X%OL(((+=kh>&47Orp1.)3K!@C5H+%K=2ZU-Ca12VslrCFB,,N<ZI0/Eb5aEnJFG.:Z0#MSt1a0MOl:1(2K`9D!di\&V_Wf#=+E*3F!0=7M<N2@qDHRQ4,?L=+=>N8)^[J(-dLi"l`>Z74a.>%+6$V!:j9TSi<M^OT?:3b-FG`87a'KEtJ8`C:eY4X6Rupeu6^#%'jG8'G$Z!Rk&Ya$"]E5oBiHE9rk\sTe<W`m.F@aV!s.n07q3eSSNd^G9rJk>hCW6fc^,cdJ2(b?&6@Xb\$0?\IR*#ZqsubM021V9Eug]VeQ,;JS+DSY#T2-PNLJ75""#^8Z(ECXZPXr1q'*9c@s=T0H>B7G[Hq.)BCQgP2]"89SKW;a2UNE;CfI=+e#M`Ki.l-p:GHD$QU!$,sac&[aX7$7BgI9Am(8/#-T#L"08oj)\UQ]+J\XE%Kg0DZ"eiC5atN,IEeU4)PQ9EZpNFYCA_"ADe\ot*`2@7Un`@V#VPSI*pPYQrIi6"g1&&0GubC+mtjTG2HJRq^$4\A"%ZkQe#?ri-BAc\\q<L73inHN:H.t9DMHPg)qZA^~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 17
|
||||
0000000000 65535 f
|
||||
0000000073 00000 n
|
||||
0000000114 00000 n
|
||||
0000000221 00000 n
|
||||
0000000333 00000 n
|
||||
0000000528 00000 n
|
||||
0000000723 00000 n
|
||||
0000000918 00000 n
|
||||
0000001113 00000 n
|
||||
0000001308 00000 n
|
||||
0000001377 00000 n
|
||||
0000001683 00000 n
|
||||
0000001767 00000 n
|
||||
0000004004 00000 n
|
||||
0000006450 00000 n
|
||||
0000008798 00000 n
|
||||
0000011610 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<2ea7118942454ce9d6e3514eaf0def7b><2ea7118942454ce9d6e3514eaf0def7b>]
|
||||
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
|
||||
|
||||
/Info 10 0 R
|
||||
/Root 9 0 R
|
||||
/Size 17
|
||||
>>
|
||||
startxref
|
||||
13004
|
||||
%%EOF
|
||||
BIN
frontend/public/guide/ maintenance-records-desktop.png
Normal file
|
After Width: | Height: | Size: 758 KiB |
BIN
frontend/public/guide/add-vehicle-form-desktop.png
Normal file
|
After Width: | Height: | Size: 604 KiB |
BIN
frontend/public/guide/dashboard-desktop.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
frontend/public/guide/documents-desktop.png
Normal file
|
After Width: | Height: | Size: 943 KiB |
BIN
frontend/public/guide/fuel-logs-desktop.png
Normal file
|
After Width: | Height: | Size: 839 KiB |
BIN
frontend/public/guide/gas-stations-desktop.png
Normal file
|
After Width: | Height: | Size: 1012 KiB |
BIN
frontend/public/guide/log-fuel-modal-desktop.png
Normal file
|
After Width: | Height: | Size: 677 KiB |
BIN
frontend/public/guide/login-desktop.png
Normal file
|
After Width: | Height: | Size: 234 KiB |
BIN
frontend/public/guide/maintenance-schedules-desktop.png
Normal file
|
After Width: | Height: | Size: 698 KiB |