From af11b49e266cc8be98269a2e83fdb027781478b1 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:28:46 -0600 Subject: [PATCH 01/13] refactor: add migration and nullable types for stripe_customer_id (refs #207) Make stripe_customer_id NULLABLE via migration, clean up admin_override_* values to NULL, and update Subscription/SubscriptionResponse/UpdateSubscriptionData types in both backend and frontend. Co-Authored-By: Claude Opus 4.6 --- .../subscriptions/domain/subscriptions.types.ts | 6 +++--- .../migrations/002_nullable_stripe_customer_id.sql | 11 +++++++++++ .../features/subscription/types/subscription.types.ts | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 backend/src/features/subscriptions/migrations/002_nullable_stripe_customer_id.sql diff --git a/backend/src/features/subscriptions/domain/subscriptions.types.ts b/backend/src/features/subscriptions/domain/subscriptions.types.ts index 7c34020..298fe9b 100644 --- a/backend/src/features/subscriptions/domain/subscriptions.types.ts +++ b/backend/src/features/subscriptions/domain/subscriptions.types.ts @@ -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,7 +118,7 @@ export interface CreateTierVehicleSelectionRequest { // Service layer types export interface UpdateSubscriptionData { - stripeCustomerId?: string; + stripeCustomerId?: string | null; stripeSubscriptionId?: string; tier?: SubscriptionTier; billingCycle?: BillingCycle; diff --git a/backend/src/features/subscriptions/migrations/002_nullable_stripe_customer_id.sql b/backend/src/features/subscriptions/migrations/002_nullable_stripe_customer_id.sql new file mode 100644 index 0000000..86dd540 --- /dev/null +++ b/backend/src/features/subscriptions/migrations/002_nullable_stripe_customer_id.sql @@ -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_%'; diff --git a/frontend/src/features/subscription/types/subscription.types.ts b/frontend/src/features/subscription/types/subscription.types.ts index f8a6ac4..045063e 100644 --- a/frontend/src/features/subscription/types/subscription.types.ts +++ b/frontend/src/features/subscription/types/subscription.types.ts @@ -5,7 +5,7 @@ export type SubscriptionStatus = 'active' | 'past_due' | 'canceled' | 'unpaid'; export interface Subscription { id: string; userId: string; - stripeCustomerId: string; + stripeCustomerId: string | null; stripeSubscriptionId?: string; tier: SubscriptionTier; billingCycle?: BillingCycle; From a6eea6c9e26abd3cd571990cc1c49e67bf35b33c Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:28:52 -0600 Subject: [PATCH 02/13] refactor: update repository for nullable stripe_customer_id (refs #208) Remove admin_override_ placeholder from createForAdminOverride(), use NULL. Update mapSubscriptionRow() with ?? null. Make stripeCustomerId optional in create() method. Co-Authored-By: Claude Opus 4.6 --- .../subscriptions/data/subscriptions.repository.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/backend/src/features/subscriptions/data/subscriptions.repository.ts b/backend/src/features/subscriptions/data/subscriptions.repository.ts index 23e83dd..49793f8 100644 --- a/backend/src/features/subscriptions/data/subscriptions.repository.ts +++ b/backend/src/features/subscriptions/data/subscriptions.repository.ts @@ -27,7 +27,7 @@ export class SubscriptionsRepository { /** * Create a new subscription */ - async create(data: CreateSubscriptionRequest & { stripeCustomerId: string }): Promise { + async create(data: CreateSubscriptionRequest & { stripeCustomerId?: string | null }): Promise { 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, ]; @@ -579,18 +579,16 @@ export class SubscriptionsRepository { client?: any ): Promise { 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); @@ -623,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, From 93e79d1170e2f126caad3be4161c6204218db382 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:29:02 -0600 Subject: [PATCH 03/13] refactor: replace resolveStripeCustomerId with ensureStripeCustomer, harden sync (refs #209, refs #210) Delete resolveStripeCustomerId() and replace with ensureStripeCustomer() that includes orphaned Stripe customer cleanup on DB failure. Make syncTierToUserProfile() blocking (errors propagate). Add null guards to cancel/reactivate for admin-set subscriptions. Fix getInvoices() null check. Clean controller comment. Add deleteCustomer() to StripeClient. Co-Authored-By: Claude Opus 4.6 --- .../api/subscriptions.controller.ts | 2 +- .../domain/subscriptions.service.ts | 78 ++++++++++--------- .../external/stripe/stripe.client.ts | 18 +++++ 3 files changed, 61 insertions(+), 37 deletions(-) diff --git a/backend/src/features/subscriptions/api/subscriptions.controller.ts b/backend/src/features/subscriptions/api/subscriptions.controller.ts index e4c6f70..5702bea 100644 --- a/backend/src/features/subscriptions/api/subscriptions.controller.ts +++ b/backend/src/features/subscriptions/api/subscriptions.controller.ts @@ -220,7 +220,7 @@ export class SubscriptionsController { return; } - // Update payment method via service (handles admin_override_ customer IDs) + // Update payment method via service (creates Stripe customer if needed) await this.service.updatePaymentMethod(userId, paymentMethodId, email); reply.status(200).send({ diff --git a/backend/src/features/subscriptions/domain/subscriptions.service.ts b/backend/src/features/subscriptions/domain/subscriptions.service.ts index 9e5fffe..0a96f55 100644 --- a/backend/src/features/subscriptions/domain/subscriptions.service.ts +++ b/backend/src/features/subscriptions/domain/subscriptions.service.ts @@ -166,35 +166,42 @@ export class SubscriptionsService { } /** - * Resolve admin_override_ placeholder customer IDs to real Stripe customers. - * When an admin overrides a user's tier without Stripe, a placeholder ID is stored. - * This method creates a real Stripe customer and updates the subscription record. + * 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 resolveStripeCustomerId( + private async ensureStripeCustomer( subscription: Subscription, email: string ): Promise { - if (!subscription.stripeCustomerId.startsWith('admin_override_')) { + if (subscription.stripeCustomerId) { return subscription.stripeCustomerId; } - logger.info('Replacing admin_override_ placeholder with real Stripe customer', { - subscriptionId: subscription.id, - userId: subscription.userId, - }); - const stripeCustomer = await this.stripeClient.createCustomer(email); - - await this.repository.update(subscription.id, { - stripeCustomerId: stripeCustomer.id, - }); - - logger.info('Stripe customer created for admin-overridden subscription', { - subscriptionId: subscription.id, - stripeCustomerId: stripeCustomer.id, - }); - - return stripeCustomer.id; + 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; + } } /** @@ -216,8 +223,8 @@ export class SubscriptionsService { throw new Error('No subscription found for user'); } - // Resolve admin_override_ placeholder to real Stripe customer if needed - const stripeCustomerId = await this.resolveStripeCustomerId(currentSubscription, email); + // 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); @@ -292,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'); } @@ -339,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'); } @@ -802,17 +817,8 @@ export class SubscriptionsService { * Sync subscription tier to user_profiles table */ private async syncTierToUserProfile(userId: string, tier: SubscriptionTier): Promise { - 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 }); } /** @@ -968,7 +974,7 @@ export class SubscriptionsService { throw new Error('No subscription found for user'); } - const stripeCustomerId = await this.resolveStripeCustomerId(subscription, email); + const stripeCustomerId = await this.ensureStripeCustomer(subscription, email); await this.stripeClient.updatePaymentMethod(stripeCustomerId, paymentMethodId); } @@ -978,7 +984,7 @@ export class SubscriptionsService { async getInvoices(userId: string): Promise { try { const subscription = await this.repository.findByUserId(userId); - if (!subscription?.stripeCustomerId || subscription.stripeCustomerId.startsWith('admin_override_')) { + if (!subscription?.stripeCustomerId) { return []; } return this.stripeClient.listInvoices(subscription.stripeCustomerId); diff --git a/backend/src/features/subscriptions/external/stripe/stripe.client.ts b/backend/src/features/subscriptions/external/stripe/stripe.client.ts index 597b8bb..cf1050f 100644 --- a/backend/src/features/subscriptions/external/stripe/stripe.client.ts +++ b/backend/src/features/subscriptions/external/stripe/stripe.client.ts @@ -260,6 +260,24 @@ export class StripeClient { } } + /** + * Delete a Stripe customer (used for cleanup of orphaned customers) + */ + async deleteCustomer(customerId: string): Promise { + 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 */ From 6011888e91ea5decd5a2f997545be1ad8bdbc415 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:33:14 -0600 Subject: [PATCH 04/13] chore: add UUID identity migration SQL (refs #211) Multi-phase SQL migration converting all user_id columns from VARCHAR(255) auth0_sub to UUID referencing user_profiles.id. Restructures admin_users with UUID PK and user_profile_id FK. Co-Authored-By: Claude Opus 4.6 --- backend/src/_system/migrations/run-all.ts | 1 + .../001_migrate_user_id_to_uuid.sql | 382 ++++++++++++++++++ 2 files changed, 383 insertions(+) create mode 100644 backend/src/core/identity-migration/migrations/001_migrate_user_id_to_uuid.sql diff --git a/backend/src/_system/migrations/run-all.ts b/backend/src/_system/migrations/run-all.ts index 2afd27c..cb573b0 100644 --- a/backend/src/_system/migrations/run-all.ts +++ b/backend/src/_system/migrations/run-all.ts @@ -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) diff --git a/backend/src/core/identity-migration/migrations/001_migrate_user_id_to_uuid.sql b/backend/src/core/identity-migration/migrations/001_migrate_user_id_to_uuid.sql new file mode 100644 index 0000000..8500037 --- /dev/null +++ b/backend/src/core/identity-migration/migrations/001_migrate_user_id_to_uuid.sql @@ -0,0 +1,382 @@ +-- 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; + +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; From 1321440cd06636b57a6e4c2fbe67c5352f2bae64 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:36:32 -0600 Subject: [PATCH 05/13] chore: update auth plugin and admin guard for UUID (refs #212) Auth plugin now uses profile.id (UUID) as userContext.userId instead of raw JWT sub. Admin guard queries admin_users by user_profile_id. Auth0 Management API calls continue using auth0Sub from JWT. Co-Authored-By: Claude Opus 4.6 --- .../src/core/plugins/admin-guard.plugin.ts | 4 +-- backend/src/core/plugins/auth.plugin.ts | 32 +++++++++++-------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/backend/src/core/plugins/admin-guard.plugin.ts b/backend/src/core/plugins/admin-guard.plugin.ts index 066fa64..c8c13e1 100644 --- a/backend/src/core/plugins/admin-guard.plugin.ts +++ b/backend/src/core/plugins/admin-guard.plugin.ts @@ -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 `; diff --git a/backend/src/core/plugins/auth.plugin.ts b/backend/src/core/plugins/auth.plugin.ts index d13355c..4b3a93d 100644 --- a/backend/src/core/plugins/auth.plugin.ts +++ b/backend/src/core/plugins/auth.plugin.ts @@ -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,34 +140,35 @@ 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')) { - await profileRepo.updateEmail(userId, email); + await profileRepo.updateEmail(auth0Sub, email); logger.info('Updated profile with correct email from Auth0', { - userId: userId.substring(0, 8) + '...', + userId: auth0Sub.substring(0, 8) + '...', }); } @@ -178,18 +182,18 @@ 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); + await profileRepo.updateEmailVerified(auth0Sub, true); emailVerified = true; logger.info('Synced email verification status from Auth0', { - userId: userId.substring(0, 8) + '...', + userId: auth0Sub.substring(0, 8) + '...', }); } } catch (syncError) { // Don't fail auth if sync fails, just log logger.warn('Failed to sync email verification status', { - userId: userId.substring(0, 8) + '...', + userId: auth0Sub.substring(0, 8) + '...', error: syncError instanceof Error ? syncError.message : 'Unknown error', }); } @@ -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 From b418a503b2ec08a278a1c712c36a5546cc65a31f Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:39:56 -0600 Subject: [PATCH 06/13] chore: refactor user profile repository for UUID (refs #214) Updated user-profile.repository.ts to use UUID instead of auth0_sub: - Added getById(id) method for UUID-based lookups - Changed all methods (except getByAuth0Sub, getOrCreate) to accept userId (UUID) instead of auth0Sub - Updated SQL WHERE clauses from auth0_sub to id for UUID-based queries - Fixed cross-table joins in listAllUsers and getUserWithAdminStatus to use user_profile_id - Updated hardDeleteUser to use UUID for all DELETE statements - Updated auth.plugin.ts to call updateEmail and updateEmailVerified with userId (UUID) Co-Authored-By: Claude Opus 4.6 --- backend/src/core/plugins/auth.plugin.ts | 10 +- .../data/user-profile.repository.ts | 146 ++++++++++-------- 2 files changed, 88 insertions(+), 68 deletions(-) diff --git a/backend/src/core/plugins/auth.plugin.ts b/backend/src/core/plugins/auth.plugin.ts index 4b3a93d..11d3d49 100644 --- a/backend/src/core/plugins/auth.plugin.ts +++ b/backend/src/core/plugins/auth.plugin.ts @@ -166,9 +166,9 @@ const authPlugin: FastifyPluginAsync = async (fastify) => { // If profile has placeholder email but we now have real email, update it if (profile.email.includes('@unknown.local') && email && !email.includes('@unknown.local')) { - await profileRepo.updateEmail(auth0Sub, email); + await profileRepo.updateEmail(userId, email); logger.info('Updated profile with correct email from Auth0', { - userId: auth0Sub.substring(0, 8) + '...', + userId: userId.substring(0, 8) + '...', }); } @@ -184,16 +184,16 @@ const authPlugin: FastifyPluginAsync = async (fastify) => { try { const isVerifiedInAuth0 = await auth0ManagementClient.checkEmailVerified(auth0Sub); if (isVerifiedInAuth0 && !profile.emailVerified) { - await profileRepo.updateEmailVerified(auth0Sub, true); + await profileRepo.updateEmailVerified(userId, true); emailVerified = true; logger.info('Synced email verification status from Auth0', { - userId: auth0Sub.substring(0, 8) + '...', + userId: userId.substring(0, 8) + '...', }); } } catch (syncError) { // Don't fail auth if sync fails, just log logger.warn('Failed to sync email verification status', { - userId: auth0Sub.substring(0, 8) + '...', + userId: userId.substring(0, 8) + '...', error: syncError instanceof Error ? syncError.message : 'Unknown error', }); } diff --git a/backend/src/features/user-profile/data/user-profile.repository.ts b/backend/src/features/user-profile/data/user-profile.repository.ts index 2bcdf22..fcc78dd 100644 --- a/backend/src/features/user-profile/data/user-profile.repository.ts +++ b/backend/src/features/user-profile/data/user-profile.repository.ts @@ -44,6 +44,26 @@ export class UserProfileRepository { } } + async getById(id: string): Promise { + 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 { 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 { 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; } } @@ -245,11 +265,11 @@ export class UserProfileRepository { au.auth0_sub as admin_auth0_sub, 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,7 +294,7 @@ export class UserProfileRepository { /** * Get single user with admin status */ - async getUserWithAdminStatus(auth0Sub: string): Promise { + async getUserWithAdminStatus(userId: string): Promise { const query = ` SELECT up.id, up.auth0_sub, up.email, up.display_name, up.notification_email, @@ -283,23 +303,23 @@ export class UserProfileRepository { au.auth0_sub as admin_auth0_sub, 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 { 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 { + async deactivateUser(userId: string, deactivatedBy: string): Promise { 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 { + async reactivateUser(userId: string): Promise { 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 { 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 { + async updateEmailVerified(userId: string, emailVerified: boolean): Promise { 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 { + async markOnboardingComplete(userId: string): Promise { 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 { + async updateEmail(userId: string, email: string): Promise { 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 { + async requestDeletion(userId: string): Promise { 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 { + async cancelDeletion(userId: string): Promise { 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 { + async hardDeleteUser(userId: string): Promise { 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> { + async getUserVehiclesForAdmin(userId: string): Promise> { 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; } } From fd9d1add2435f1d1984af69cefcba8014d3056f5 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:52:09 -0600 Subject: [PATCH 07/13] chore: refactor admin system for UUID identity (refs #213) Migrate admin controller, routes, validation, and users controller from auth0Sub identifiers to UUID. Admin CRUD now uses admin UUID id, user management routes use user_profiles UUID. Clean up debug logging. Co-Authored-By: Claude Opus 4.6 --- .../features/admin/api/admin.controller.ts | 261 ++++++++++-------- .../src/features/admin/api/admin.routes.ts | 44 +-- .../features/admin/api/admin.validation.ts | 14 +- .../features/admin/api/users.controller.ts | 121 ++++---- .../features/admin/api/users.validation.ts | 8 +- .../features/admin/data/admin.repository.ts | 89 +++--- .../features/admin/domain/admin.service.ts | 61 ++-- .../src/features/admin/domain/admin.types.ts | 15 +- 8 files changed, 319 insertions(+), 294 deletions(-) diff --git a/backend/src/features/admin/api/admin.controller.ts b/backend/src/features/admin/api/admin.controller.ts index 7666dee..c24e5d7 100644 --- a/backend/src/features/admin/api/admin.controller.ts +++ b/backend/src/features/admin/api/admin.controller.ts @@ -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 = [ 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; } } diff --git a/backend/src/features/admin/api/admin.routes.ts b/backend/src/features/admin/api/admin.routes.ts index 857927f..d7da529 100644 --- a/backend/src/features/admin/api/admin.routes.ts +++ b/backend/src/features/admin/api/admin.routes.ts @@ -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) }); diff --git a/backend/src/features/admin/api/admin.validation.ts b/backend/src/features/admin/api/admin.validation.ts index 2e2d9ba..2d5d45e 100644 --- a/backend/src/features/admin/api/admin.validation.ts +++ b/backend/src/features/admin/api/admin.validation.ts @@ -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; -export type AdminAuth0SubInput = z.infer; +export type AdminIdInput = z.infer; export type AuditLogsQueryInput = z.infer; export type BulkCreateAdminInput = z.infer; export type BulkRevokeAdminInput = z.infer; diff --git a/backend/src/features/admin/api/users.controller.ts b/backend/src/features/admin/api/users.controller.ts index 80af1aa..70566c8 100644 --- a/backend/src/features/admin/api/users.controller.ts +++ b/backend/src/features/admin/api/users.controller.ts @@ -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') { diff --git a/backend/src/features/admin/api/users.validation.ts b/backend/src/features/admin/api/users.validation.ts index 5934fe2..6932691 100644 --- a/backend/src/features/admin/api/users.validation.ts +++ b/backend/src/features/admin/api/users.validation.ts @@ -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; -export type UserAuth0SubInput = z.infer; +export type UserIdInput = z.infer; export type UpdateTierInput = z.infer; export type DeactivateUserInput = z.infer; export type UpdateProfileInput = z.infer; diff --git a/backend/src/features/admin/data/admin.repository.ts b/backend/src/features/admin/data/admin.repository.ts index 7e40df0..7cd66c2 100644 --- a/backend/src/features/admin/data/admin.repository.ts +++ b/backend/src/features/admin/data/admin.repository.ts @@ -10,29 +10,49 @@ import { logger } from '../../../core/logging/logger'; export class AdminRepository { constructor(private pool: Pool) {} - async getAdminByAuth0Sub(auth0Sub: string): Promise { + async getAdminById(id: string): Promise { 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 { + 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 { 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 { 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 { 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 { + async createAdmin(userProfileId: string, email: string, role: string, createdBy: string): Promise { 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 { + async revokeAdmin(id: string): Promise { 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 { + async reinstateAdmin(id: string): Promise { 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 { - 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), diff --git a/backend/src/features/admin/domain/admin.service.ts b/backend/src/features/admin/domain/admin.service.ts index 00216b0..9816f41 100644 --- a/backend/src/features/admin/domain/admin.service.ts +++ b/backend/src/features/admin/domain/admin.service.ts @@ -11,11 +11,20 @@ import { auditLogService } from '../../audit-log'; export class AdminService { constructor(private repository: AdminRepository) {} - async getAdminByAuth0Sub(auth0Sub: string): Promise { + async getAdminById(id: string): Promise { 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 { + 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 { + async createAdmin(email: string, role: string, userProfileId: string, createdByAdminId: string): Promise { 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 { + async revokeAdmin(id: string, revokedByAdminId: string): Promise { 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 { + async reinstateAdmin(id: string, reinstatedByAdminId: string): Promise { 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 { - 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; - } - } } diff --git a/backend/src/features/admin/domain/admin.types.ts b/backend/src/features/admin/domain/admin.types.ts index ac960e1..94801c4 100644 --- a/backend/src/features/admin/domain/admin.types.ts +++ b/backend/src/features/admin/domain/admin.types.ts @@ -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; }>; } From 3b1112a9feb19622e4ea9c33d7de7bed6fa8d4cc Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:59:05 -0600 Subject: [PATCH 08/13] chore: update supporting code for UUID identity (refs #216) - audit-log: JOIN on user_profiles.id instead of auth0_sub - backup: use userContext.userId instead of auth0Sub - ocr: use request.userContext.userId instead of request.user.sub - user-profile controller: use getById() with UUID instead of getOrCreateProfile() - user-profile service: accept UUID userId for all admin-focused methods - user-profile repository: fix admin JOIN aliases from auth0_sub to id Co-Authored-By: Claude Opus 4.6 --- .../audit-log/data/audit-log.repository.ts | 4 +- .../features/backup/api/backup.controller.ts | 18 +- .../src/features/ocr/api/ocr.controller.ts | 14 +- .../api/user-profile.controller.ts | 75 ++++---- .../data/user-profile.repository.ts | 6 +- .../domain/user-profile.service.ts | 160 +++++++++--------- 6 files changed, 137 insertions(+), 140 deletions(-) diff --git a/backend/src/features/audit-log/data/audit-log.repository.ts b/backend/src/features/audit-log/data/audit-log.repository.ts index 622e5b7..26c75c6 100644 --- a/backend/src/features/audit-log/data/audit-log.repository.ts +++ b/backend/src/features/audit-log/data/audit-log.repository.ts @@ -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} diff --git a/backend/src/features/backup/api/backup.controller.ts b/backend/src/features/backup/api/backup.controller.ts index 028e7ca..f84b899 100644 --- a/backend/src/features/backup/api/backup.controller.ts +++ b/backend/src/features/backup/api/backup.controller.ts @@ -45,12 +45,12 @@ export class BackupController { request: FastifyRequest<{ Body: CreateBackupBody }>, reply: FastifyReply ): Promise { - 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 { - 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 { - 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, diff --git a/backend/src/features/ocr/api/ocr.controller.ts b/backend/src/features/ocr/api/ocr.controller.ts index 18860a6..fcc54cd 100644 --- a/backend/src/features/ocr/api/ocr.controller.ts +++ b/backend/src/features/ocr/api/ocr.controller.ts @@ -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', { diff --git a/backend/src/features/user-profile/api/user-profile.controller.ts b/backend/src/features/user-profile/api/user-profile.controller.ts index 68c8732..e157a6f 100644 --- a/backend/src/features/user-profile/api/user-profile.controller.ts +++ b/backend/src/features/user-profile/api/user-profile.controller.ts @@ -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); diff --git a/backend/src/features/user-profile/data/user-profile.repository.ts b/backend/src/features/user-profile/data/user-profile.repository.ts index fcc78dd..f782395 100644 --- a/backend/src/features/user-profile/data/user-profile.repository.ts +++ b/backend/src/features/user-profile/data/user-profile.repository.ts @@ -194,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, }; @@ -262,7 +262,7 @@ 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.id @@ -300,7 +300,7 @@ 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.id diff --git a/backend/src/features/user-profile/domain/user-profile.service.ts b/backend/src/features/user-profile/domain/user-profile.service.ts index 285a264..ac13942 100644 --- a/backend/src/features/user-profile/domain/user-profile.service.ts +++ b/backend/src/features/user-profile/domain/user-profile.service.ts @@ -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 { 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 { 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 { + async getUserDetails(userId: string): Promise { 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 { 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 { 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 { 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 { 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 { 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 { + async cancelDeletion(userId: string): Promise { 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 { 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; } } From 754639c86d5c3ca1020545ca39cf795705551105 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Mon, 16 Feb 2026 10:21:18 -0600 Subject: [PATCH 09/13] chore: update test fixtures and frontend for UUID identity (refs #217) Backend test fixtures: - Replace auth0|xxx format with UUID in all test userId values - Update admin tests for new id/userProfileId schema - Add missing deletionRequestedAt/deletionScheduledFor to auth test mocks - Fix admin integration test supertest usage (app.server) Frontend: - AdminUser type: auth0Sub -> id + userProfileId - admin.api.ts: all user management methods use userId (UUID) params - useUsers/useAdmins hooks: auth0Sub -> userId/id in mutations - AdminUsersPage + AdminUsersMobileScreen: user.auth0Sub -> user.id - Remove encodeURIComponent (UUIDs don't need encoding) Co-Authored-By: Claude Opus 4.6 --- .../src/core/middleware/require-tier.test.ts | 2 +- .../plugins/tests/tier-guard.plugin.test.ts | 8 +- .../integration/admin.integration.test.ts | 233 +++++++++--------- .../admin/tests/unit/admin.guard.test.ts | 4 +- .../admin/tests/unit/admin.service.test.ts | 114 +++++---- .../__tests__/audit-log.integration.test.ts | 36 +-- .../integration/auth.integration.test.ts | 1 + .../auth/tests/unit/auth.service.test.ts | 6 + .../tests/fixtures/fuel-logs.fixtures.json | 2 +- .../tests/fixtures/maintenance.fixtures.json | 2 +- .../community-stations.api.test.ts | 4 +- .../admin/__tests__/AdminUsersPage.test.tsx | 3 +- .../admin/__tests__/useAdminAccess.test.tsx | 3 +- .../admin/__tests__/useAdmins.test.tsx | 16 +- frontend/src/features/admin/api/admin.api.ts | 40 +-- .../src/features/admin/hooks/useAdmins.ts | 4 +- frontend/src/features/admin/hooks/useUsers.ts | 42 ++-- .../admin/mobile/AdminUsersMobileScreen.tsx | 24 +- .../src/features/admin/types/admin.types.ts | 3 +- frontend/src/pages/admin/AdminUsersPage.tsx | 32 +-- 20 files changed, 316 insertions(+), 263 deletions(-) diff --git a/backend/src/core/middleware/require-tier.test.ts b/backend/src/core/middleware/require-tier.test.ts index 13fc80f..cdd32cf 100644 --- a/backend/src/core/middleware/require-tier.test.ts +++ b/backend/src/core/middleware/require-tier.test.ts @@ -17,7 +17,7 @@ const createRequest = (subscriptionTier?: string): Partial => { } return { userContext: { - userId: 'auth0|user123456789', + userId: '550e8400-e29b-41d4-a716-446655440000', email: 'user@example.com', emailVerified: true, onboardingCompleted: true, diff --git a/backend/src/core/plugins/tests/tier-guard.plugin.test.ts b/backend/src/core/plugins/tests/tier-guard.plugin.test.ts index de24c75..f7a754b 100644 --- a/backend/src/core/plugins/tests/tier-guard.plugin.test.ts +++ b/backend/src/core/plugins/tests/tier-guard.plugin.test.ts @@ -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, diff --git a/backend/src/features/admin/tests/integration/admin.integration.test.ts b/backend/src/features/admin/tests/integration/admin.integration.test.ts index 377619d..a7e03bf 100644 --- a/backend/src/features/admin/tests/integration/admin.integration.test.ts +++ b/backend/src/features/admin/tests/integration/admin.integration.test.ts @@ -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); diff --git a/backend/src/features/admin/tests/unit/admin.guard.test.ts b/backend/src/features/admin/tests/unit/admin.guard.test.ts index e2a281e..6cf3887 100644 --- a/backend/src/features/admin/tests/unit/admin.guard.test.ts +++ b/backend/src/features/admin/tests/unit/admin.guard.test.ts @@ -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, diff --git a/backend/src/features/admin/tests/unit/admin.service.test.ts b/backend/src/features/admin/tests/unit/admin.service.test.ts index b1d7aaa..6dfda6a 100644 --- a/backend/src/features/admin/tests/unit/admin.service.test.ts +++ b/backend/src/features/admin/tests/unit/admin.service.test.ts @@ -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; 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 ); diff --git a/backend/src/features/audit-log/__tests__/audit-log.integration.test.ts b/backend/src/features/audit-log/__tests__/audit-log.integration.test.ts index 73b9a23..7b376aa 100644 --- a/backend/src/features/audit-log/__tests__/audit-log.integration.test.ts +++ b/backend/src/features/audit-log/__tests__/audit-log.integration.test.ts @@ -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', diff --git a/backend/src/features/auth/tests/integration/auth.integration.test.ts b/backend/src/features/auth/tests/integration/auth.integration.test.ts index e783ed2..b7a8345 100644 --- a/backend/src/features/auth/tests/integration/auth.integration.test.ts +++ b/backend/src/features/auth/tests/integration/auth.integration.test.ts @@ -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' }), diff --git a/backend/src/features/auth/tests/unit/auth.service.test.ts b/backend/src/features/auth/tests/unit/auth.service.test.ts index c698f75..84f0d18 100644 --- a/backend/src/features/auth/tests/unit/auth.service.test.ts +++ b/backend/src/features/auth/tests/unit/auth.service.test.ts @@ -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(), }); diff --git a/backend/src/features/fuel-logs/tests/fixtures/fuel-logs.fixtures.json b/backend/src/features/fuel-logs/tests/fixtures/fuel-logs.fixtures.json index 2bd28f1..b6aeed8 100644 --- a/backend/src/features/fuel-logs/tests/fixtures/fuel-logs.fixtures.json +++ b/backend/src/features/fuel-logs/tests/fixtures/fuel-logs.fixtures.json @@ -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, diff --git a/backend/src/features/maintenance/tests/fixtures/maintenance.fixtures.json b/backend/src/features/maintenance/tests/fixtures/maintenance.fixtures.json index f551606..8154492 100644 --- a/backend/src/features/maintenance/tests/fixtures/maintenance.fixtures.json +++ b/backend/src/features/maintenance/tests/fixtures/maintenance.fixtures.json @@ -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", diff --git a/backend/src/features/stations/tests/integration/community-stations.api.test.ts b/backend/src/features/stations/tests/integration/community-stations.api.test.ts index 5ee1bda..71e45bf 100644 --- a/backend/src/features/stations/tests/integration/community-stations.api.test.ts +++ b/backend/src/features/stations/tests/integration/community-stations.api.test.ts @@ -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', diff --git a/frontend/src/features/admin/__tests__/AdminUsersPage.test.tsx b/frontend/src/features/admin/__tests__/AdminUsersPage.test.tsx index 191b1bb..4594683 100644 --- a/frontend/src/features/admin/__tests__/AdminUsersPage.test.tsx +++ b/frontend/src/features/admin/__tests__/AdminUsersPage.test.tsx @@ -48,7 +48,8 @@ describe('AdminUsersPage', () => { mockUseAdminAccess.mockReturnValue({ isAdmin: true, adminRecord: { - auth0Sub: 'auth0|123', + id: 'admin-uuid-123', + userProfileId: 'user-uuid-123', email: 'admin@example.com', role: 'admin', createdAt: '2024-01-01', diff --git a/frontend/src/features/admin/__tests__/useAdminAccess.test.tsx b/frontend/src/features/admin/__tests__/useAdminAccess.test.tsx index 4574380..e8a8007 100644 --- a/frontend/src/features/admin/__tests__/useAdminAccess.test.tsx +++ b/frontend/src/features/admin/__tests__/useAdminAccess.test.tsx @@ -55,7 +55,8 @@ describe('useAdminAccess', () => { mockAdminApi.verifyAccess.mockResolvedValue({ isAdmin: true, adminRecord: { - auth0Sub: 'auth0|123', + id: 'admin-uuid-123', + userProfileId: 'user-uuid-123', email: 'admin@example.com', role: 'admin', createdAt: '2024-01-01', diff --git a/frontend/src/features/admin/__tests__/useAdmins.test.tsx b/frontend/src/features/admin/__tests__/useAdmins.test.tsx index 2b1cbb6..9f886f5 100644 --- a/frontend/src/features/admin/__tests__/useAdmins.test.tsx +++ b/frontend/src/features/admin/__tests__/useAdmins.test.tsx @@ -42,7 +42,8 @@ describe('Admin user management hooks', () => { it('should fetch admin users', async () => { const mockAdmins = [ { - auth0Sub: 'auth0|123', + id: 'admin-uuid-123', + userProfileId: 'user-uuid-123', email: 'admin1@example.com', role: 'admin', createdAt: '2024-01-01', @@ -68,11 +69,12 @@ describe('Admin user management hooks', () => { describe('useCreateAdmin', () => { it('should create admin and show success toast', async () => { const newAdmin = { - auth0Sub: 'auth0|456', + id: 'admin-uuid-456', + userProfileId: 'user-uuid-456', email: 'newadmin@example.com', role: 'admin', createdAt: '2024-01-01', - createdBy: 'auth0|123', + createdBy: 'admin-uuid-123', revokedAt: null, updatedAt: '2024-01-01', }; @@ -131,11 +133,11 @@ describe('Admin user management hooks', () => { wrapper: createWrapper(), }); - result.current.mutate('auth0|123'); + result.current.mutate('admin-uuid-123'); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(mockAdminApi.revokeAdmin).toHaveBeenCalledWith('auth0|123'); + expect(mockAdminApi.revokeAdmin).toHaveBeenCalledWith('admin-uuid-123'); expect(toast.success).toHaveBeenCalledWith('Admin revoked successfully'); }); }); @@ -148,11 +150,11 @@ describe('Admin user management hooks', () => { wrapper: createWrapper(), }); - result.current.mutate('auth0|123'); + result.current.mutate('admin-uuid-123'); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(mockAdminApi.reinstateAdmin).toHaveBeenCalledWith('auth0|123'); + expect(mockAdminApi.reinstateAdmin).toHaveBeenCalledWith('admin-uuid-123'); expect(toast.success).toHaveBeenCalledWith('Admin reinstated successfully'); }); }); diff --git a/frontend/src/features/admin/api/admin.api.ts b/frontend/src/features/admin/api/admin.api.ts index 8382ea3..eac73cd 100644 --- a/frontend/src/features/admin/api/admin.api.ts +++ b/frontend/src/features/admin/api/admin.api.ts @@ -101,12 +101,12 @@ export const adminApi = { return response.data; }, - revokeAdmin: async (auth0Sub: string): Promise => { - await apiClient.patch(`/admin/admins/${auth0Sub}/revoke`); + revokeAdmin: async (id: string): Promise => { + await apiClient.patch(`/admin/admins/${id}/revoke`); }, - reinstateAdmin: async (auth0Sub: string): Promise => { - await apiClient.patch(`/admin/admins/${auth0Sub}/reinstate`); + reinstateAdmin: async (id: string): Promise => { + await apiClient.patch(`/admin/admins/${id}/reinstate`); }, // Audit logs @@ -328,62 +328,62 @@ export const adminApi = { return response.data; }, - get: async (auth0Sub: string): Promise => { + get: async (userId: string): Promise => { const response = await apiClient.get( - `/admin/users/${encodeURIComponent(auth0Sub)}` + `/admin/users/${userId}` ); return response.data; }, - getVehicles: async (auth0Sub: string): Promise => { + getVehicles: async (userId: string): Promise => { const response = await apiClient.get( - `/admin/users/${encodeURIComponent(auth0Sub)}/vehicles` + `/admin/users/${userId}/vehicles` ); return response.data; }, - updateTier: async (auth0Sub: string, data: UpdateUserTierRequest): Promise => { + updateTier: async (userId: string, data: UpdateUserTierRequest): Promise => { const response = await apiClient.patch( - `/admin/users/${encodeURIComponent(auth0Sub)}/tier`, + `/admin/users/${userId}/tier`, data ); return response.data; }, - deactivate: async (auth0Sub: string, data?: DeactivateUserRequest): Promise => { + deactivate: async (userId: string, data?: DeactivateUserRequest): Promise => { const response = await apiClient.patch( - `/admin/users/${encodeURIComponent(auth0Sub)}/deactivate`, + `/admin/users/${userId}/deactivate`, data || {} ); return response.data; }, - reactivate: async (auth0Sub: string): Promise => { + reactivate: async (userId: string): Promise => { const response = await apiClient.patch( - `/admin/users/${encodeURIComponent(auth0Sub)}/reactivate` + `/admin/users/${userId}/reactivate` ); return response.data; }, - updateProfile: async (auth0Sub: string, data: UpdateUserProfileRequest): Promise => { + updateProfile: async (userId: string, data: UpdateUserProfileRequest): Promise => { const response = await apiClient.patch( - `/admin/users/${encodeURIComponent(auth0Sub)}/profile`, + `/admin/users/${userId}/profile`, data ); return response.data; }, - promoteToAdmin: async (auth0Sub: string, data?: PromoteToAdminRequest): Promise => { + promoteToAdmin: async (userId: string, data?: PromoteToAdminRequest): Promise => { const response = await apiClient.patch( - `/admin/users/${encodeURIComponent(auth0Sub)}/promote`, + `/admin/users/${userId}/promote`, data || {} ); return response.data; }, - hardDelete: async (auth0Sub: string, reason?: string): Promise<{ message: string }> => { + hardDelete: async (userId: string, reason?: string): Promise<{ message: string }> => { const response = await apiClient.delete<{ message: string }>( - `/admin/users/${encodeURIComponent(auth0Sub)}`, + `/admin/users/${userId}`, { params: reason ? { reason } : undefined } ); return response.data; diff --git a/frontend/src/features/admin/hooks/useAdmins.ts b/frontend/src/features/admin/hooks/useAdmins.ts index 4170ec8..3df42f6 100644 --- a/frontend/src/features/admin/hooks/useAdmins.ts +++ b/frontend/src/features/admin/hooks/useAdmins.ts @@ -51,7 +51,7 @@ export const useRevokeAdmin = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (auth0Sub: string) => adminApi.revokeAdmin(auth0Sub), + mutationFn: (id: string) => adminApi.revokeAdmin(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admins'] }); toast.success('Admin revoked successfully'); @@ -66,7 +66,7 @@ export const useReinstateAdmin = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (auth0Sub: string) => adminApi.reinstateAdmin(auth0Sub), + mutationFn: (id: string) => adminApi.reinstateAdmin(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admins'] }); toast.success('Admin reinstated successfully'); diff --git a/frontend/src/features/admin/hooks/useUsers.ts b/frontend/src/features/admin/hooks/useUsers.ts index d76079a..3d6d9cc 100644 --- a/frontend/src/features/admin/hooks/useUsers.ts +++ b/frontend/src/features/admin/hooks/useUsers.ts @@ -29,8 +29,8 @@ interface ApiError { export const userQueryKeys = { all: ['admin-users'] as const, list: (params: ListUsersParams) => [...userQueryKeys.all, 'list', params] as const, - detail: (auth0Sub: string) => [...userQueryKeys.all, 'detail', auth0Sub] as const, - vehicles: (auth0Sub: string) => [...userQueryKeys.all, 'vehicles', auth0Sub] as const, + detail: (userId: string) => [...userQueryKeys.all, 'detail', userId] as const, + vehicles: (userId: string) => [...userQueryKeys.all, 'vehicles', userId] as const, }; // Query keys for admin stats @@ -58,13 +58,13 @@ export const useUsers = (params: ListUsersParams = {}) => { /** * Hook to get a single user's details */ -export const useUser = (auth0Sub: string) => { +export const useUser = (userId: string) => { const { isAuthenticated, isLoading } = useAuth0(); return useQuery({ - queryKey: userQueryKeys.detail(auth0Sub), - queryFn: () => adminApi.users.get(auth0Sub), - enabled: isAuthenticated && !isLoading && !!auth0Sub, + queryKey: userQueryKeys.detail(userId), + queryFn: () => adminApi.users.get(userId), + enabled: isAuthenticated && !isLoading && !!userId, staleTime: 2 * 60 * 1000, gcTime: 5 * 60 * 1000, retry: 1, @@ -78,8 +78,8 @@ export const useUpdateUserTier = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ auth0Sub, data }: { auth0Sub: string; data: UpdateUserTierRequest }) => - adminApi.users.updateTier(auth0Sub, data), + mutationFn: ({ userId, data }: { userId: string; data: UpdateUserTierRequest }) => + adminApi.users.updateTier(userId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: userQueryKeys.all }); toast.success('Subscription tier updated'); @@ -101,8 +101,8 @@ export const useDeactivateUser = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ auth0Sub, data }: { auth0Sub: string; data?: DeactivateUserRequest }) => - adminApi.users.deactivate(auth0Sub, data), + mutationFn: ({ userId, data }: { userId: string; data?: DeactivateUserRequest }) => + adminApi.users.deactivate(userId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: userQueryKeys.all }); toast.success('User deactivated'); @@ -124,7 +124,7 @@ export const useReactivateUser = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (auth0Sub: string) => adminApi.users.reactivate(auth0Sub), + mutationFn: (userId: string) => adminApi.users.reactivate(userId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: userQueryKeys.all }); toast.success('User reactivated'); @@ -146,8 +146,8 @@ export const useUpdateUserProfile = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ auth0Sub, data }: { auth0Sub: string; data: UpdateUserProfileRequest }) => - adminApi.users.updateProfile(auth0Sub, data), + mutationFn: ({ userId, data }: { userId: string; data: UpdateUserProfileRequest }) => + adminApi.users.updateProfile(userId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: userQueryKeys.all }); toast.success('User profile updated'); @@ -169,8 +169,8 @@ export const usePromoteToAdmin = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ auth0Sub, data }: { auth0Sub: string; data?: PromoteToAdminRequest }) => - adminApi.users.promoteToAdmin(auth0Sub, data), + mutationFn: ({ userId, data }: { userId: string; data?: PromoteToAdminRequest }) => + adminApi.users.promoteToAdmin(userId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: userQueryKeys.all }); toast.success('User promoted to admin'); @@ -192,8 +192,8 @@ export const useHardDeleteUser = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ auth0Sub, reason }: { auth0Sub: string; reason?: string }) => - adminApi.users.hardDelete(auth0Sub, reason), + mutationFn: ({ userId, reason }: { userId: string; reason?: string }) => + adminApi.users.hardDelete(userId, reason), onSuccess: () => { queryClient.invalidateQueries({ queryKey: userQueryKeys.all }); toast.success('User permanently deleted'); @@ -228,13 +228,13 @@ export const useAdminStats = () => { /** * Hook to get a user's vehicles (admin view - year, make, model only) */ -export const useUserVehicles = (auth0Sub: string) => { +export const useUserVehicles = (userId: string) => { const { isAuthenticated, isLoading } = useAuth0(); return useQuery({ - queryKey: userQueryKeys.vehicles(auth0Sub), - queryFn: () => adminApi.users.getVehicles(auth0Sub), - enabled: isAuthenticated && !isLoading && !!auth0Sub, + queryKey: userQueryKeys.vehicles(userId), + queryFn: () => adminApi.users.getVehicles(userId), + enabled: isAuthenticated && !isLoading && !!userId, staleTime: 2 * 60 * 1000, gcTime: 5 * 60 * 1000, retry: 1, diff --git a/frontend/src/features/admin/mobile/AdminUsersMobileScreen.tsx b/frontend/src/features/admin/mobile/AdminUsersMobileScreen.tsx index 4cfc876..d9b5011 100644 --- a/frontend/src/features/admin/mobile/AdminUsersMobileScreen.tsx +++ b/frontend/src/features/admin/mobile/AdminUsersMobileScreen.tsx @@ -104,8 +104,8 @@ const VehicleCountBadge: React.FC<{ count: number; onClick?: () => void }> = ({ ); // Expandable vehicle list component -const UserVehiclesList: React.FC<{ auth0Sub: string; isOpen: boolean }> = ({ auth0Sub, isOpen }) => { - const { data, isLoading, error } = useUserVehicles(auth0Sub); +const UserVehiclesList: React.FC<{ userId: string; isOpen: boolean }> = ({ userId, isOpen }) => { + const { data, isLoading, error } = useUserVehicles(userId); if (!isOpen) return null; @@ -215,7 +215,7 @@ export const AdminUsersMobileScreen: React.FC = () => { (newTier: SubscriptionTier) => { if (selectedUser) { updateTierMutation.mutate( - { auth0Sub: selectedUser.auth0Sub, data: { subscriptionTier: newTier } }, + { userId: selectedUser.id, data: { subscriptionTier: newTier } }, { onSuccess: () => { setShowTierPicker(false); @@ -232,7 +232,7 @@ export const AdminUsersMobileScreen: React.FC = () => { const handleDeactivate = useCallback(() => { if (selectedUser) { deactivateMutation.mutate( - { auth0Sub: selectedUser.auth0Sub, data: { reason: deactivateReason || undefined } }, + { userId: selectedUser.id, data: { reason: deactivateReason || undefined } }, { onSuccess: () => { setShowDeactivateConfirm(false); @@ -247,7 +247,7 @@ export const AdminUsersMobileScreen: React.FC = () => { const handleReactivate = useCallback(() => { if (selectedUser) { - reactivateMutation.mutate(selectedUser.auth0Sub, { + reactivateMutation.mutate(selectedUser.id, { onSuccess: () => { setShowUserActions(false); setSelectedUser(null); @@ -276,7 +276,7 @@ export const AdminUsersMobileScreen: React.FC = () => { } if (Object.keys(updates).length > 0) { updateProfileMutation.mutate( - { auth0Sub: selectedUser.auth0Sub, data: updates }, + { userId: selectedUser.id, data: updates }, { onSuccess: () => { setShowEditModal(false); @@ -306,7 +306,7 @@ export const AdminUsersMobileScreen: React.FC = () => { const handlePromoteConfirm = useCallback(() => { if (selectedUser) { promoteToAdminMutation.mutate( - { auth0Sub: selectedUser.auth0Sub, data: { role: promoteRole } }, + { userId: selectedUser.id, data: { role: promoteRole } }, { onSuccess: () => { setShowPromoteModal(false); @@ -332,7 +332,7 @@ export const AdminUsersMobileScreen: React.FC = () => { const handleHardDeleteConfirm = useCallback(() => { if (selectedUser && hardDeleteConfirmText === 'DELETE') { hardDeleteMutation.mutate( - { auth0Sub: selectedUser.auth0Sub, reason: hardDeleteReason || undefined }, + { userId: selectedUser.id, reason: hardDeleteReason || undefined }, { onSuccess: () => { setShowHardDeleteModal(false); @@ -509,7 +509,7 @@ export const AdminUsersMobileScreen: React.FC = () => { {users.length > 0 && (
{users.map((user) => ( - +
))} diff --git a/frontend/src/features/admin/types/admin.types.ts b/frontend/src/features/admin/types/admin.types.ts index 6ae46bf..4b9ba60 100644 --- a/frontend/src/features/admin/types/admin.types.ts +++ b/frontend/src/features/admin/types/admin.types.ts @@ -5,7 +5,8 @@ // Admin user types export interface AdminUser { - auth0Sub: string; + id: string; + userProfileId: string; email: string; role: string; createdAt: string; diff --git a/frontend/src/pages/admin/AdminUsersPage.tsx b/frontend/src/pages/admin/AdminUsersPage.tsx index d4e5f16..79e60c1 100644 --- a/frontend/src/pages/admin/AdminUsersPage.tsx +++ b/frontend/src/pages/admin/AdminUsersPage.tsx @@ -71,8 +71,8 @@ import { AdminSectionHeader } from '../../features/admin/components'; const PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; // Expandable vehicle row component -const UserVehiclesRow: React.FC<{ auth0Sub: string; isOpen: boolean }> = ({ auth0Sub, isOpen }) => { - const { data, isLoading, error } = useUserVehicles(auth0Sub); +const UserVehiclesRow: React.FC<{ userId: string; isOpen: boolean }> = ({ userId, isOpen }) => { + const { data, isLoading, error } = useUserVehicles(userId); if (!isOpen) return null; @@ -222,8 +222,8 @@ export const AdminUsersPage: React.FC = () => { }, []); const handleTierChange = useCallback( - (auth0Sub: string, newTier: SubscriptionTier) => { - updateTierMutation.mutate({ auth0Sub, data: { subscriptionTier: newTier } }); + (userId: string, newTier: SubscriptionTier) => { + updateTierMutation.mutate({ userId, data: { subscriptionTier: newTier } }); }, [updateTierMutation] ); @@ -246,7 +246,7 @@ export const AdminUsersPage: React.FC = () => { const handleDeactivateConfirm = useCallback(() => { if (selectedUser) { deactivateMutation.mutate( - { auth0Sub: selectedUser.auth0Sub, data: { reason: deactivateReason || undefined } }, + { userId: selectedUser.id, data: { reason: deactivateReason || undefined } }, { onSuccess: () => { setDeactivateDialogOpen(false); @@ -260,7 +260,7 @@ export const AdminUsersPage: React.FC = () => { const handleReactivate = useCallback(() => { if (selectedUser) { - reactivateMutation.mutate(selectedUser.auth0Sub); + reactivateMutation.mutate(selectedUser.id); setAnchorEl(null); setSelectedUser(null); } @@ -286,7 +286,7 @@ export const AdminUsersPage: React.FC = () => { } if (Object.keys(updates).length > 0) { updateProfileMutation.mutate( - { auth0Sub: selectedUser.auth0Sub, data: updates }, + { userId: selectedUser.id, data: updates }, { onSuccess: () => { setEditDialogOpen(false); @@ -316,7 +316,7 @@ export const AdminUsersPage: React.FC = () => { const handlePromoteConfirm = useCallback(() => { if (selectedUser) { promoteToAdminMutation.mutate( - { auth0Sub: selectedUser.auth0Sub, data: { role: promoteRole } }, + { userId: selectedUser.id, data: { role: promoteRole } }, { onSuccess: () => { setPromoteDialogOpen(false); @@ -342,7 +342,7 @@ export const AdminUsersPage: React.FC = () => { const handleHardDeleteConfirm = useCallback(() => { if (selectedUser && hardDeleteConfirmText === 'DELETE') { hardDeleteMutation.mutate( - { auth0Sub: selectedUser.auth0Sub, reason: hardDeleteReason || undefined }, + { userId: selectedUser.id, reason: hardDeleteReason || undefined }, { onSuccess: () => { setHardDeleteDialogOpen(false); @@ -496,11 +496,11 @@ export const AdminUsersPage: React.FC = () => { {users.map((user) => ( - + *': { borderBottom: expandedRow === user.auth0Sub ? 'unset' : undefined }, + '& > *': { borderBottom: expandedRow === user.id ? 'unset' : undefined }, }} > {user.email} @@ -510,7 +510,7 @@ export const AdminUsersPage: React.FC = () => {