chore: migrate user identity from auth0_sub to UUID #219

Merged
egullickson merged 10 commits from issue-206-migrate-user-identity-uuid into main 2026-02-16 20:55:41 +00:00
57 changed files with 1459 additions and 947 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -767,7 +767,7 @@ export class SubscriptionsService {
): Promise<void> { ): Promise<void> {
try { try {
// Get user profile for email and name // Get user profile for email and name
const userProfile = await this.userProfileRepository.getByAuth0Sub(userId); const userProfile = await this.userProfileRepository.getById(userId);
if (!userProfile) { if (!userProfile) {
logger.warn('User profile not found for tier change notification', { userId }); logger.warn('User profile not found for tier change notification', { userId });
return; return;
@@ -925,7 +925,7 @@ export class SubscriptionsService {
// Sync tier to user_profiles table (within same transaction) // Sync tier to user_profiles table (within same transaction)
await client.query( await client.query(
'UPDATE user_profiles SET subscription_tier = $1 WHERE auth0_sub = $2', 'UPDATE user_profiles SET subscription_tier = $1 WHERE id = $2',
[newTier, userId] [newTier, userId]
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ export class VehiclesController {
async getUserVehicles(request: FastifyRequest, reply: FastifyReply) { async getUserVehicles(request: FastifyRequest, reply: FastifyReply) {
try { try {
const userId = (request as any).user.sub; const userId = request.userContext!.userId;
// Use tier-aware method to filter out locked vehicles after downgrade // Use tier-aware method to filter out locked vehicles after downgrade
const vehiclesWithStatus = await this.vehiclesService.getUserVehiclesWithTierStatus(userId); const vehiclesWithStatus = await this.vehiclesService.getUserVehiclesWithTierStatus(userId);
// Only return active vehicles (filter out locked ones) // Only return active vehicles (filter out locked ones)
@@ -37,7 +37,7 @@ export class VehiclesController {
return reply.code(200).send(vehicles); return reply.code(200).send(vehicles);
} catch (error) { } catch (error) {
logger.error('Error getting user vehicles', { error, userId: (request as any).user?.sub }); logger.error('Error getting user vehicles', { error, userId: request.userContext?.userId });
return reply.code(500).send({ return reply.code(500).send({
error: 'Internal server error', error: 'Internal server error',
message: 'Failed to get vehicles' message: 'Failed to get vehicles'
@@ -65,12 +65,12 @@ export class VehiclesController {
} }
} }
const userId = (request as any).user.sub; const userId = request.userContext!.userId;
const vehicle = await this.vehiclesService.createVehicle(request.body, userId); const vehicle = await this.vehiclesService.createVehicle(request.body, userId);
return reply.code(201).send(vehicle); return reply.code(201).send(vehicle);
} catch (error: any) { } catch (error: any) {
logger.error('Error creating vehicle', { error, userId: (request as any).user?.sub }); logger.error('Error creating vehicle', { error, userId: request.userContext?.userId });
if (error instanceof VehicleLimitExceededError) { if (error instanceof VehicleLimitExceededError) {
return reply.code(403).send({ return reply.code(403).send({
@@ -110,7 +110,7 @@ export class VehiclesController {
async getVehicle(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) { async getVehicle(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
try { try {
const userId = (request as any).user.sub; const userId = request.userContext!.userId;
const { id } = request.params; const { id } = request.params;
// Check tier status - block access to locked vehicles // Check tier status - block access to locked vehicles
@@ -131,7 +131,7 @@ export class VehiclesController {
return reply.code(200).send(vehicle); return reply.code(200).send(vehicle);
} catch (error: any) { } catch (error: any) {
logger.error('Error getting vehicle', { error, vehicleId: request.params.id, userId: (request as any).user?.sub }); logger.error('Error getting vehicle', { error, vehicleId: request.params.id, userId: request.userContext?.userId });
if (error.message === 'Vehicle not found' || error.message === 'Unauthorized') { if (error.message === 'Vehicle not found' || error.message === 'Unauthorized') {
return reply.code(404).send({ return reply.code(404).send({
@@ -149,14 +149,14 @@ export class VehiclesController {
async updateVehicle(request: FastifyRequest<{ Params: VehicleParams; Body: UpdateVehicleBody }>, reply: FastifyReply) { async updateVehicle(request: FastifyRequest<{ Params: VehicleParams; Body: UpdateVehicleBody }>, reply: FastifyReply) {
try { try {
const userId = (request as any).user.sub; const userId = request.userContext!.userId;
const { id } = request.params; const { id } = request.params;
const vehicle = await this.vehiclesService.updateVehicle(id, request.body, userId); const vehicle = await this.vehiclesService.updateVehicle(id, request.body, userId);
return reply.code(200).send(vehicle); return reply.code(200).send(vehicle);
} catch (error: any) { } catch (error: any) {
logger.error('Error updating vehicle', { error, vehicleId: request.params.id, userId: (request as any).user?.sub }); logger.error('Error updating vehicle', { error, vehicleId: request.params.id, userId: request.userContext?.userId });
if (error.message === 'Vehicle not found' || error.message === 'Unauthorized') { if (error.message === 'Vehicle not found' || error.message === 'Unauthorized') {
return reply.code(404).send({ return reply.code(404).send({
@@ -183,14 +183,14 @@ export class VehiclesController {
async deleteVehicle(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) { async deleteVehicle(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
try { try {
const userId = (request as any).user.sub; const userId = request.userContext!.userId;
const { id } = request.params; const { id } = request.params;
await this.vehiclesService.deleteVehicle(id, userId); await this.vehiclesService.deleteVehicle(id, userId);
return reply.code(204).send(); return reply.code(204).send();
} catch (error: any) { } catch (error: any) {
logger.error('Error deleting vehicle', { error, vehicleId: request.params.id, userId: (request as any).user?.sub }); logger.error('Error deleting vehicle', { error, vehicleId: request.params.id, userId: request.userContext?.userId });
if (error.message === 'Vehicle not found' || error.message === 'Unauthorized') { if (error.message === 'Vehicle not found' || error.message === 'Unauthorized') {
return reply.code(404).send({ return reply.code(404).send({
@@ -208,13 +208,13 @@ export class VehiclesController {
async getTCO(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) { async getTCO(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
try { try {
const userId = (request as any).user.sub; const userId = request.userContext!.userId;
const { id } = request.params; const { id } = request.params;
const tco = await this.vehiclesService.getTCO(id, userId); const tco = await this.vehiclesService.getTCO(id, userId);
return reply.code(200).send(tco); return reply.code(200).send(tco);
} catch (error: any) { } catch (error: any) {
logger.error('Error getting vehicle TCO', { error, vehicleId: request.params.id, userId: (request as any).user?.sub }); logger.error('Error getting vehicle TCO', { error, vehicleId: request.params.id, userId: request.userContext?.userId });
if (error.statusCode === 404 || error.message === 'Vehicle not found') { if (error.statusCode === 404 || error.message === 'Vehicle not found') {
return reply.code(404).send({ return reply.code(404).send({
@@ -383,7 +383,7 @@ export class VehiclesController {
* Requires Pro or Enterprise tier * Requires Pro or Enterprise tier
*/ */
async decodeVin(request: FastifyRequest<{ Body: DecodeVinRequest }>, reply: FastifyReply) { async decodeVin(request: FastifyRequest<{ Body: DecodeVinRequest }>, reply: FastifyReply) {
const userId = (request as any).user?.sub; const userId = request.userContext?.userId;
try { try {
const { vin } = request.body; const { vin } = request.body;
@@ -447,7 +447,7 @@ export class VehiclesController {
} }
async uploadImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) { async uploadImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
const userId = (request as any).user.sub; const userId = request.userContext!.userId;
const vehicleId = request.params.id; const vehicleId = request.params.id;
logger.info('Vehicle image upload requested', { logger.info('Vehicle image upload requested', {
@@ -604,7 +604,7 @@ export class VehiclesController {
} }
async downloadImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) { async downloadImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
const userId = (request as any).user.sub; const userId = request.userContext!.userId;
const vehicleId = request.params.id; const vehicleId = request.params.id;
logger.info('Vehicle image download requested', { logger.info('Vehicle image download requested', {
@@ -654,7 +654,7 @@ export class VehiclesController {
} }
async deleteImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) { async deleteImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
const userId = (request as any).user.sub; const userId = request.userContext!.userId;
const vehicleId = request.params.id; const vehicleId = request.params.id;
logger.info('Vehicle image delete requested', { logger.info('Vehicle image delete requested', {

View File

@@ -82,7 +82,7 @@ export class VehiclesService {
} }
// Get user's tier for limit enforcement // Get user's tier for limit enforcement
const userProfile = await this.userProfileRepository.getByAuth0Sub(userId); const userProfile = await this.userProfileRepository.getById(userId);
if (!userProfile) { if (!userProfile) {
throw new Error('User profile not found'); throw new Error('User profile not found');
} }
@@ -227,7 +227,7 @@ export class VehiclesService {
*/ */
async getUserVehiclesWithTierStatus(userId: string): Promise<Array<VehicleResponse & { tierStatus: 'active' | 'locked' }>> { async getUserVehiclesWithTierStatus(userId: string): Promise<Array<VehicleResponse & { tierStatus: 'active' | 'locked' }>> {
// Get user's subscription tier // Get user's subscription tier
const userProfile = await this.userProfileRepository.getByAuth0Sub(userId); const userProfile = await this.userProfileRepository.getById(userId);
if (!userProfile) { if (!userProfile) {
throw new Error('User profile not found'); throw new Error('User profile not found');
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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