/** * @ai-summary Fastify route handlers for admin user management API * @ai-context HTTP request/response handling for managing all application users (not just admins) */ import { FastifyRequest, FastifyReply } from 'fastify'; import { UserProfileService } from '../../user-profile/domain/user-profile.service'; import { UserProfileRepository } from '../../user-profile/data/user-profile.repository'; import { AdminRepository } from '../data/admin.repository'; import { SubscriptionsService } from '../../subscriptions/domain/subscriptions.service'; import { SubscriptionsRepository } from '../../subscriptions/data/subscriptions.repository'; import { StripeClient } from '../../subscriptions/external/stripe/stripe.client'; import { pool } from '../../../core/config/database'; import { logger } from '../../../core/logging/logger'; import { listUsersQuerySchema, userIdSchema, updateTierSchema, deactivateUserSchema, updateProfileSchema, promoteToAdminSchema, ListUsersQueryInput, UserIdInput, UpdateTierInput, DeactivateUserInput, UpdateProfileInput, PromoteToAdminInput, } from './users.validation'; import { AdminService } from '../domain/admin.service'; export class UsersController { private userProfileService: UserProfileService; private adminService: AdminService; private subscriptionsService: SubscriptionsService; private userProfileRepository: UserProfileRepository; private adminRepository: AdminRepository; constructor() { this.userProfileRepository = new UserProfileRepository(pool); this.adminRepository = new AdminRepository(pool); const subscriptionsRepository = new SubscriptionsRepository(pool); const stripeClient = new StripeClient(); this.userProfileService = new UserProfileService(this.userProfileRepository); this.userProfileService.setAdminRepository(this.adminRepository); this.adminService = new AdminService(this.adminRepository); // Admin feature depends on Subscriptions for tier management // This is intentional - admin has oversight capabilities this.subscriptionsService = new SubscriptionsService(subscriptionsRepository, stripeClient, pool); } /** * GET /api/admin/stats - Get admin dashboard stats */ async getAdminStats( request: FastifyRequest, reply: FastifyReply ) { try { const actorId = request.userContext?.userId; if (!actorId) { return reply.code(401).send({ error: 'Unauthorized', message: 'User context missing', }); } // Defense-in-depth: verify admin status even with requireAdmin guard if (!request.userContext?.isAdmin) { return reply.code(403).send({ error: 'Forbidden', message: 'Admin access required', }); } const [totalVehicles, totalUsers] = await Promise.all([ this.userProfileRepository.getTotalVehicleCount(), this.userProfileRepository.getTotalUserCount(), ]); return reply.code(200).send({ totalVehicles, totalUsers, }); } catch (error) { logger.error('Error getting admin stats', { error: error instanceof Error ? error.message : 'Unknown error', }); return reply.code(500).send({ error: 'Internal server error', message: 'Failed to get admin stats', }); } } /** * GET /api/admin/users/:userId/vehicles - Get user's vehicles (admin view) */ async getUserVehicles( request: FastifyRequest<{ Params: UserIdInput }>, reply: FastifyReply ) { try { const actorId = request.userContext?.userId; if (!actorId) { return reply.code(401).send({ error: 'Unauthorized', message: 'User context missing', }); } // Defense-in-depth: verify admin status even with requireAdmin guard if (!request.userContext?.isAdmin) { return reply.code(403).send({ error: 'Forbidden', message: 'Admin access required', }); } // Validate path param const parseResult = userIdSchema.safeParse(request.params); if (!parseResult.success) { return reply.code(400).send({ error: 'Validation error', message: parseResult.error.errors.map(e => e.message).join(', '), }); } const { userId } = parseResult.data; const vehicles = await this.userProfileRepository.getUserVehiclesForAdmin(userId); return reply.code(200).send({ vehicles }); } catch (error) { logger.error('Error getting user vehicles', { error: error instanceof Error ? error.message : 'Unknown error', userId: (request.params as any)?.userId, }); return reply.code(500).send({ error: 'Internal server error', message: 'Failed to get user vehicles', }); } } /** * GET /api/admin/users - List all users with pagination and filters */ async listUsers( request: FastifyRequest<{ Querystring: ListUsersQueryInput }>, reply: FastifyReply ) { try { const actorId = request.userContext?.userId; if (!actorId) { return reply.code(401).send({ error: 'Unauthorized', message: 'User context missing', }); } // Validate and parse query params const parseResult = listUsersQuerySchema.safeParse(request.query); if (!parseResult.success) { return reply.code(400).send({ error: 'Validation error', message: parseResult.error.errors.map(e => e.message).join(', '), }); } const query = parseResult.data; const result = await this.userProfileService.listAllUsers(query); return reply.code(200).send(result); } catch (error) { logger.error('Error listing users', { error: error instanceof Error ? error.message : 'Unknown error', }); return reply.code(500).send({ error: 'Internal server error', message: 'Failed to list users', }); } } /** * GET /api/admin/users/:userId - Get single user details */ async getUser( request: FastifyRequest<{ Params: UserIdInput }>, reply: FastifyReply ) { try { const actorId = request.userContext?.userId; if (!actorId) { return reply.code(401).send({ error: 'Unauthorized', message: 'User context missing', }); } // Validate path param const parseResult = userIdSchema.safeParse(request.params); if (!parseResult.success) { return reply.code(400).send({ error: 'Validation error', message: parseResult.error.errors.map(e => e.message).join(', '), }); } const { userId } = parseResult.data; const user = await this.userProfileService.getUserDetails(userId); if (!user) { return reply.code(404).send({ error: 'Not found', message: 'User not found', }); } return reply.code(200).send(user); } catch (error) { logger.error('Error getting user details', { error: error instanceof Error ? error.message : 'Unknown error', userId: (request.params as any)?.userId, }); return reply.code(500).send({ error: 'Internal server error', message: 'Failed to get user details', }); } } /** * PATCH /api/admin/users/:userId/tier - Update subscription tier * Uses subscriptionsService.adminOverrideTier() to sync both subscriptions.tier * and user_profiles.subscription_tier atomically */ async updateTier( request: FastifyRequest<{ Params: UserIdInput; Body: UpdateTierInput }>, reply: FastifyReply ) { try { const actorId = request.userContext?.userId; if (!actorId) { return reply.code(401).send({ error: 'Unauthorized', message: 'User context missing', }); } // Validate path param const paramsResult = userIdSchema.safeParse(request.params); if (!paramsResult.success) { return reply.code(400).send({ error: 'Validation error', message: paramsResult.error.errors.map(e => e.message).join(', '), }); } // Validate body const bodyResult = updateTierSchema.safeParse(request.body); if (!bodyResult.success) { return reply.code(400).send({ error: 'Validation error', message: bodyResult.error.errors.map(e => e.message).join(', '), }); } const { userId } = paramsResult.data; const { subscriptionTier } = bodyResult.data; // Verify user exists before attempting tier change const currentUser = await this.userProfileService.getUserDetails(userId); if (!currentUser) { return reply.code(404).send({ error: 'Not found', message: 'User not found', }); } const previousTier = currentUser.subscriptionTier; // Use subscriptionsService to update both tables atomically await this.subscriptionsService.adminOverrideTier(userId, subscriptionTier); // Log audit action await this.adminRepository.logAuditAction( actorId, 'UPDATE_TIER', userId, 'user_profile', currentUser.id, { previousTier, newTier: subscriptionTier } ); logger.info('User subscription tier updated via admin', { userId, previousTier, newTier: subscriptionTier, actorId, }); // Return updated user profile const updatedUser = await this.userProfileService.getUserDetails(userId); return reply.code(200).send(updatedUser); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; logger.error('Error updating user tier', { error: errorMessage, userId: (request.params as any)?.userId, }); if (errorMessage === 'User not found') { return reply.code(404).send({ error: 'Not found', message: errorMessage, }); } return reply.code(500).send({ error: 'Internal server error', message: 'Failed to update subscription tier', }); } } /** * PATCH /api/admin/users/:userId/deactivate - Soft delete user */ async deactivateUser( request: FastifyRequest<{ Params: UserIdInput; Body: DeactivateUserInput }>, reply: FastifyReply ) { try { const actorId = request.userContext?.userId; if (!actorId) { return reply.code(401).send({ error: 'Unauthorized', message: 'User context missing', }); } // Validate path param const paramsResult = userIdSchema.safeParse(request.params); if (!paramsResult.success) { return reply.code(400).send({ error: 'Validation error', message: paramsResult.error.errors.map(e => e.message).join(', '), }); } // Validate body (optional) const bodyResult = deactivateUserSchema.safeParse(request.body || {}); if (!bodyResult.success) { return reply.code(400).send({ error: 'Validation error', message: bodyResult.error.errors.map(e => e.message).join(', '), }); } const { userId } = paramsResult.data; const { reason } = bodyResult.data; const deactivatedUser = await this.userProfileService.deactivateUser( userId, actorId, reason ); return reply.code(200).send(deactivatedUser); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; logger.error('Error deactivating user', { error: errorMessage, userId: (request.params as any)?.userId, }); if (errorMessage === 'User not found') { return reply.code(404).send({ error: 'Not found', message: errorMessage, }); } if (errorMessage === 'Cannot deactivate your own account') { return reply.code(400).send({ error: 'Bad request', message: errorMessage, }); } if (errorMessage === 'User is already deactivated') { return reply.code(400).send({ error: 'Bad request', message: errorMessage, }); } return reply.code(500).send({ error: 'Internal server error', message: 'Failed to deactivate user', }); } } /** * PATCH /api/admin/users/:userId/reactivate - Restore deactivated user */ async reactivateUser( request: FastifyRequest<{ Params: UserIdInput }>, reply: FastifyReply ) { try { const actorId = request.userContext?.userId; if (!actorId) { return reply.code(401).send({ error: 'Unauthorized', message: 'User context missing', }); } // Validate path param const paramsResult = userIdSchema.safeParse(request.params); if (!paramsResult.success) { return reply.code(400).send({ error: 'Validation error', message: paramsResult.error.errors.map(e => e.message).join(', '), }); } const { userId } = paramsResult.data; const reactivatedUser = await this.userProfileService.reactivateUser( userId, actorId ); return reply.code(200).send(reactivatedUser); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; logger.error('Error reactivating user', { error: errorMessage, userId: (request.params as any)?.userId, }); if (errorMessage === 'User not found') { return reply.code(404).send({ error: 'Not found', message: errorMessage, }); } if (errorMessage === 'User is not deactivated') { return reply.code(400).send({ error: 'Bad request', message: errorMessage, }); } return reply.code(500).send({ error: 'Internal server error', message: 'Failed to reactivate user', }); } } /** * PATCH /api/admin/users/:userId/profile - Update user email/displayName */ async updateProfile( request: FastifyRequest<{ Params: UserIdInput; Body: UpdateProfileInput }>, reply: FastifyReply ) { try { const actorId = request.userContext?.userId; if (!actorId) { return reply.code(401).send({ error: 'Unauthorized', message: 'User context missing', }); } // Validate path param const paramsResult = userIdSchema.safeParse(request.params); if (!paramsResult.success) { return reply.code(400).send({ error: 'Validation error', message: paramsResult.error.errors.map(e => e.message).join(', '), }); } // Validate body const bodyResult = updateProfileSchema.safeParse(request.body); if (!bodyResult.success) { return reply.code(400).send({ error: 'Validation error', message: bodyResult.error.errors.map(e => e.message).join(', '), }); } const { userId } = paramsResult.data; const updates = bodyResult.data; const updatedUser = await this.userProfileService.adminUpdateProfile( userId, updates, actorId ); return reply.code(200).send(updatedUser); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; logger.error('Error updating user profile', { error: errorMessage, userId: (request.params as any)?.userId, }); if (errorMessage === 'User not found') { return reply.code(404).send({ error: 'Not found', message: errorMessage, }); } return reply.code(500).send({ error: 'Internal server error', message: 'Failed to update user profile', }); } } /** * PATCH /api/admin/users/:userId/promote - Promote user to admin */ async promoteToAdmin( request: FastifyRequest<{ Params: UserIdInput; Body: PromoteToAdminInput }>, reply: FastifyReply ) { try { const actorId = request.userContext?.userId; if (!actorId) { return reply.code(401).send({ error: 'Unauthorized', message: 'User context missing', }); } // Validate path param const paramsResult = userIdSchema.safeParse(request.params); if (!paramsResult.success) { return reply.code(400).send({ error: 'Validation error', message: paramsResult.error.errors.map(e => e.message).join(', '), }); } // Validate body const bodyResult = promoteToAdminSchema.safeParse(request.body || {}); if (!bodyResult.success) { return reply.code(400).send({ error: 'Validation error', message: bodyResult.error.errors.map(e => e.message).join(', '), }); } const { userId } = paramsResult.data; const { role } = bodyResult.data; // Get the user profile to verify they exist and get their email const user = await this.userProfileService.getUserDetails(userId); if (!user) { return reply.code(404).send({ error: 'Not found', message: 'User not found', }); } // Check if user is already an admin if (user.isAdmin) { return reply.code(400).send({ error: 'Bad request', message: 'User is already an admin', }); } // Get actor's admin record for audit trail const actorAdmin = await this.adminService.getAdminByUserProfileId(actorId); // Create the admin record using the user's UUID const adminUser = await this.adminService.createAdmin( user.email, role, userId, actorAdmin?.id || actorId ); return reply.code(201).send(adminUser); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; logger.error('Error promoting user to admin', { error: errorMessage, userId: (request.params as any)?.userId, }); if (errorMessage.includes('already exists')) { return reply.code(400).send({ error: 'Bad request', message: errorMessage, }); } return reply.code(500).send({ error: 'Internal server error', message: 'Failed to promote user to admin', }); } } /** * DELETE /api/admin/users/:userId - Hard delete user (permanent) */ async hardDeleteUser( request: FastifyRequest<{ Params: UserIdInput }>, reply: FastifyReply ) { try { const actorId = request.userContext?.userId; if (!actorId) { return reply.code(401).send({ error: 'Unauthorized', message: 'User context missing', }); } // Validate path param const paramsResult = userIdSchema.safeParse(request.params); if (!paramsResult.success) { return reply.code(400).send({ error: 'Validation error', message: paramsResult.error.errors.map(e => e.message).join(', '), }); } const { userId } = paramsResult.data; // Optional reason from query params const reason = (request.query as any)?.reason; // Hard delete user await this.userProfileService.adminHardDeleteUser( userId, actorId, reason ); return reply.code(200).send({ message: 'User permanently deleted', }); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; logger.error('Error hard deleting user', { error: errorMessage, userId: (request.params as any)?.userId, }); if (errorMessage === 'Cannot delete your own account') { return reply.code(400).send({ error: 'Bad request', message: errorMessage, }); } if (errorMessage === 'User not found') { return reply.code(404).send({ error: 'Not found', message: errorMessage, }); } return reply.code(500).send({ error: 'Internal server error', message: 'Failed to delete user', }); } } }