Notification updates
This commit is contained in:
489
backend/src/features/admin/api/users.controller.ts
Normal file
489
backend/src/features/admin/api/users.controller.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* @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 { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import {
|
||||
listUsersQuerySchema,
|
||||
userAuth0SubSchema,
|
||||
updateTierSchema,
|
||||
deactivateUserSchema,
|
||||
updateProfileSchema,
|
||||
promoteToAdminSchema,
|
||||
ListUsersQueryInput,
|
||||
UserAuth0SubInput,
|
||||
UpdateTierInput,
|
||||
DeactivateUserInput,
|
||||
UpdateProfileInput,
|
||||
PromoteToAdminInput,
|
||||
} from './users.validation';
|
||||
import { AdminService } from '../domain/admin.service';
|
||||
|
||||
export class UsersController {
|
||||
private userProfileService: UserProfileService;
|
||||
private adminService: AdminService;
|
||||
|
||||
constructor() {
|
||||
const userProfileRepository = new UserProfileRepository(pool);
|
||||
const adminRepository = new AdminRepository(pool);
|
||||
|
||||
this.userProfileService = new UserProfileService(userProfileRepository);
|
||||
this.userProfileService.setAdminRepository(adminRepository);
|
||||
this.adminService = new AdminService(adminRepository);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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/:auth0Sub - Get single user details
|
||||
*/
|
||||
async getUser(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput }>,
|
||||
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 = userAuth0SubSchema.safeParse(request.params);
|
||||
if (!parseResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: parseResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = parseResult.data;
|
||||
const user = await this.userProfileService.getUserDetails(auth0Sub);
|
||||
|
||||
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',
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get user details',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/users/:auth0Sub/tier - Update subscription tier
|
||||
*/
|
||||
async updateTier(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput; 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 = userAuth0SubSchema.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 { auth0Sub } = paramsResult.data;
|
||||
const { subscriptionTier } = bodyResult.data;
|
||||
|
||||
const updatedUser = await this.userProfileService.updateSubscriptionTier(
|
||||
auth0Sub,
|
||||
subscriptionTier,
|
||||
actorId
|
||||
);
|
||||
|
||||
return reply.code(200).send(updatedUser);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
logger.error('Error updating user tier', {
|
||||
error: errorMessage,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
});
|
||||
|
||||
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/:auth0Sub/deactivate - Soft delete user
|
||||
*/
|
||||
async deactivateUser(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput; 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 = userAuth0SubSchema.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 { auth0Sub } = paramsResult.data;
|
||||
const { reason } = bodyResult.data;
|
||||
|
||||
const deactivatedUser = await this.userProfileService.deactivateUser(
|
||||
auth0Sub,
|
||||
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,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
});
|
||||
|
||||
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/:auth0Sub/reactivate - Restore deactivated user
|
||||
*/
|
||||
async reactivateUser(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput }>,
|
||||
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 = userAuth0SubSchema.safeParse(request.params);
|
||||
if (!paramsResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: paramsResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = paramsResult.data;
|
||||
|
||||
const reactivatedUser = await this.userProfileService.reactivateUser(
|
||||
auth0Sub,
|
||||
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,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
});
|
||||
|
||||
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/:auth0Sub/profile - Update user email/displayName
|
||||
*/
|
||||
async updateProfile(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput; 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 = userAuth0SubSchema.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 { auth0Sub } = paramsResult.data;
|
||||
const updates = bodyResult.data;
|
||||
|
||||
const updatedUser = await this.userProfileService.adminUpdateProfile(
|
||||
auth0Sub,
|
||||
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,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
});
|
||||
|
||||
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/:auth0Sub/promote - Promote user to admin
|
||||
*/
|
||||
async promoteToAdmin(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput; 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 = userAuth0SubSchema.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 { auth0Sub } = paramsResult.data;
|
||||
const { role } = bodyResult.data;
|
||||
|
||||
// Get the user profile first to verify they exist and get their email
|
||||
const user = await this.userProfileService.getUserDetails(auth0Sub);
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
// Create the admin record using the user's real auth0Sub
|
||||
const adminUser = await this.adminService.createAdmin(
|
||||
user.email,
|
||||
role,
|
||||
auth0Sub, // Use the real auth0Sub from the user profile
|
||||
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,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user