Migrate admin controller, routes, validation, and users controller from auth0Sub identifiers to UUID. Admin CRUD now uses admin UUID id, user management routes use user_profiles UUID. Clean up debug logging. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
697 lines
20 KiB
TypeScript
697 lines
20 KiB
TypeScript
/**
|
|
* @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',
|
|
});
|
|
}
|
|
}
|
|
}
|