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>
710 lines
20 KiB
TypeScript
710 lines
20 KiB
TypeScript
/**
|
|
* @ai-summary Fastify route handlers for admin management API
|
|
* @ai-context HTTP request/response handling with admin authorization and audit logging
|
|
*/
|
|
|
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
|
import { AdminService } from '../domain/admin.service';
|
|
import { AdminRepository } from '../data/admin.repository';
|
|
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
|
import { pool } from '../../../core/config/database';
|
|
import { logger } from '../../../core/logging/logger';
|
|
import {
|
|
CreateAdminInput,
|
|
AdminIdInput,
|
|
AuditLogsQueryInput,
|
|
BulkCreateAdminInput,
|
|
BulkRevokeAdminInput,
|
|
BulkReinstateAdminInput
|
|
} from './admin.validation';
|
|
import {
|
|
createAdminSchema,
|
|
adminIdSchema,
|
|
auditLogsQuerySchema,
|
|
bulkCreateAdminSchema,
|
|
bulkRevokeAdminSchema,
|
|
bulkReinstateAdminSchema
|
|
} from './admin.validation';
|
|
import {
|
|
BulkCreateAdminResponse,
|
|
BulkRevokeAdminResponse,
|
|
BulkReinstateAdminResponse,
|
|
AdminUser
|
|
} from '../domain/admin.types';
|
|
|
|
export class AdminController {
|
|
private adminService: AdminService;
|
|
private userProfileRepository: UserProfileRepository;
|
|
|
|
constructor() {
|
|
const repository = new AdminRepository(pool);
|
|
this.adminService = new AdminService(repository);
|
|
this.userProfileRepository = new UserProfileRepository(pool);
|
|
}
|
|
|
|
/**
|
|
* GET /api/admin/verify - Verify admin access (for frontend auth checks)
|
|
*/
|
|
async verifyAccess(request: FastifyRequest, reply: FastifyReply) {
|
|
try {
|
|
const userId = request.userContext?.userId;
|
|
const userEmail = this.resolveUserEmail(request);
|
|
|
|
if (userEmail && request.userContext) {
|
|
request.userContext.email = userEmail.toLowerCase();
|
|
}
|
|
|
|
if (!userId) {
|
|
return reply.code(401).send({
|
|
error: 'Unauthorized',
|
|
message: 'User context missing'
|
|
});
|
|
}
|
|
|
|
const adminRecord = await this.adminService.getAdminByUserProfileId(userId);
|
|
|
|
if (adminRecord && !adminRecord.revokedAt) {
|
|
if (request.userContext) {
|
|
request.userContext.isAdmin = true;
|
|
request.userContext.adminRecord = adminRecord;
|
|
}
|
|
|
|
return reply.code(200).send({
|
|
isAdmin: true,
|
|
adminRecord: {
|
|
id: adminRecord.id,
|
|
userProfileId: adminRecord.userProfileId,
|
|
email: adminRecord.email,
|
|
role: adminRecord.role
|
|
}
|
|
});
|
|
}
|
|
|
|
if (request.userContext) {
|
|
request.userContext.isAdmin = false;
|
|
request.userContext.adminRecord = undefined;
|
|
}
|
|
|
|
return reply.code(200).send({
|
|
isAdmin: false,
|
|
adminRecord: null
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error verifying admin access', {
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
userId: request.userContext?.userId?.substring(0, 8) + '...'
|
|
});
|
|
|
|
return reply.code(500).send({
|
|
error: 'Internal server error',
|
|
message: 'Admin verification failed'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /api/admin/admins - List all admin users
|
|
*/
|
|
async listAdmins(request: FastifyRequest, reply: FastifyReply) {
|
|
try {
|
|
const actorUserProfileId = request.userContext?.userId;
|
|
|
|
if (!actorUserProfileId) {
|
|
return reply.code(401).send({
|
|
error: 'Unauthorized',
|
|
message: 'User context missing'
|
|
});
|
|
}
|
|
|
|
const admins = await this.adminService.getAllAdmins();
|
|
|
|
return reply.code(200).send({
|
|
total: admins.length,
|
|
admins
|
|
});
|
|
} catch (error: any) {
|
|
logger.error('Error listing admins', {
|
|
error: error.message,
|
|
actorUserProfileId: request.userContext?.userId
|
|
});
|
|
return reply.code(500).send({
|
|
error: 'Internal server error',
|
|
message: 'Failed to list admins'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/admin/admins - Create new admin user
|
|
*/
|
|
async createAdmin(
|
|
request: FastifyRequest<{ Body: CreateAdminInput }>,
|
|
reply: FastifyReply
|
|
) {
|
|
try {
|
|
const actorUserProfileId = request.userContext?.userId;
|
|
|
|
if (!actorUserProfileId) {
|
|
return reply.code(401).send({
|
|
error: 'Unauthorized',
|
|
message: 'User context missing'
|
|
});
|
|
}
|
|
|
|
// Get actor's admin record to get admin ID
|
|
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
|
|
if (!actorAdmin) {
|
|
return reply.code(403).send({
|
|
error: 'Forbidden',
|
|
message: 'Actor is not an admin'
|
|
});
|
|
}
|
|
|
|
// Validate request body
|
|
const validation = createAdminSchema.safeParse(request.body);
|
|
if (!validation.success) {
|
|
return reply.code(400).send({
|
|
error: 'Bad Request',
|
|
message: 'Invalid request body',
|
|
details: validation.error.errors
|
|
});
|
|
}
|
|
|
|
const { email, role } = validation.data;
|
|
|
|
// Look up user profile by email to get UUID
|
|
const userProfile = await this.userProfileRepository.getByEmail(email);
|
|
if (!userProfile) {
|
|
return reply.code(404).send({
|
|
error: 'Not Found',
|
|
message: `No user profile found with email ${email}. User must sign up first.`
|
|
});
|
|
}
|
|
|
|
const admin = await this.adminService.createAdmin(
|
|
email,
|
|
role,
|
|
userProfile.id,
|
|
actorAdmin.id
|
|
);
|
|
|
|
return reply.code(201).send(admin);
|
|
} catch (error: any) {
|
|
logger.error('Error creating admin', {
|
|
error: error.message,
|
|
actorUserProfileId: request.userContext?.userId
|
|
});
|
|
|
|
if (error.message.includes('already exists')) {
|
|
return reply.code(400).send({
|
|
error: 'Bad Request',
|
|
message: error.message
|
|
});
|
|
}
|
|
|
|
return reply.code(500).send({
|
|
error: 'Internal server error',
|
|
message: 'Failed to create admin'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* PATCH /api/admin/admins/:id/revoke - Revoke admin access
|
|
*/
|
|
async revokeAdmin(
|
|
request: FastifyRequest<{ Params: AdminIdInput }>,
|
|
reply: FastifyReply
|
|
) {
|
|
try {
|
|
const actorUserProfileId = request.userContext?.userId;
|
|
|
|
if (!actorUserProfileId) {
|
|
return reply.code(401).send({
|
|
error: 'Unauthorized',
|
|
message: 'User context missing'
|
|
});
|
|
}
|
|
|
|
// Get actor's admin record
|
|
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
|
|
if (!actorAdmin) {
|
|
return reply.code(403).send({
|
|
error: 'Forbidden',
|
|
message: 'Actor is not an admin'
|
|
});
|
|
}
|
|
|
|
// Validate params
|
|
const validation = adminIdSchema.safeParse(request.params);
|
|
if (!validation.success) {
|
|
return reply.code(400).send({
|
|
error: 'Bad Request',
|
|
message: 'Invalid admin ID parameter',
|
|
details: validation.error.errors
|
|
});
|
|
}
|
|
|
|
const { id } = validation.data;
|
|
|
|
// Check if admin exists
|
|
const targetAdmin = await this.adminService.getAdminById(id);
|
|
if (!targetAdmin) {
|
|
return reply.code(404).send({
|
|
error: 'Not Found',
|
|
message: 'Admin user not found'
|
|
});
|
|
}
|
|
|
|
// Revoke the admin (service handles last admin check)
|
|
const admin = await this.adminService.revokeAdmin(id, actorAdmin.id);
|
|
|
|
return reply.code(200).send(admin);
|
|
} catch (error: any) {
|
|
logger.error('Error revoking admin', {
|
|
error: error.message,
|
|
actorUserProfileId: request.userContext?.userId,
|
|
targetAdminId: (request.params as any).id
|
|
});
|
|
|
|
if (error.message.includes('Cannot revoke the last active admin')) {
|
|
return reply.code(400).send({
|
|
error: 'Bad Request',
|
|
message: error.message
|
|
});
|
|
}
|
|
|
|
if (error.message.includes('not found')) {
|
|
return reply.code(404).send({
|
|
error: 'Not Found',
|
|
message: error.message
|
|
});
|
|
}
|
|
|
|
return reply.code(500).send({
|
|
error: 'Internal server error',
|
|
message: 'Failed to revoke admin'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* PATCH /api/admin/admins/:id/reinstate - Restore revoked admin
|
|
*/
|
|
async reinstateAdmin(
|
|
request: FastifyRequest<{ Params: AdminIdInput }>,
|
|
reply: FastifyReply
|
|
) {
|
|
try {
|
|
const actorUserProfileId = request.userContext?.userId;
|
|
|
|
if (!actorUserProfileId) {
|
|
return reply.code(401).send({
|
|
error: 'Unauthorized',
|
|
message: 'User context missing'
|
|
});
|
|
}
|
|
|
|
// Get actor's admin record
|
|
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
|
|
if (!actorAdmin) {
|
|
return reply.code(403).send({
|
|
error: 'Forbidden',
|
|
message: 'Actor is not an admin'
|
|
});
|
|
}
|
|
|
|
// Validate params
|
|
const validation = adminIdSchema.safeParse(request.params);
|
|
if (!validation.success) {
|
|
return reply.code(400).send({
|
|
error: 'Bad Request',
|
|
message: 'Invalid admin ID parameter',
|
|
details: validation.error.errors
|
|
});
|
|
}
|
|
|
|
const { id } = validation.data;
|
|
|
|
// Check if admin exists
|
|
const targetAdmin = await this.adminService.getAdminById(id);
|
|
if (!targetAdmin) {
|
|
return reply.code(404).send({
|
|
error: 'Not Found',
|
|
message: 'Admin user not found'
|
|
});
|
|
}
|
|
|
|
// Reinstate the admin
|
|
const admin = await this.adminService.reinstateAdmin(id, actorAdmin.id);
|
|
|
|
return reply.code(200).send(admin);
|
|
} catch (error: any) {
|
|
logger.error('Error reinstating admin', {
|
|
error: error.message,
|
|
actorUserProfileId: request.userContext?.userId,
|
|
targetAdminId: (request.params as any).id
|
|
});
|
|
|
|
if (error.message.includes('not found')) {
|
|
return reply.code(404).send({
|
|
error: 'Not Found',
|
|
message: error.message
|
|
});
|
|
}
|
|
|
|
return reply.code(500).send({
|
|
error: 'Internal server error',
|
|
message: 'Failed to reinstate admin'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /api/admin/audit-logs - Fetch audit trail
|
|
*/
|
|
async getAuditLogs(
|
|
request: FastifyRequest<{ Querystring: AuditLogsQueryInput }>,
|
|
reply: FastifyReply
|
|
) {
|
|
try {
|
|
const actorId = request.userContext?.userId;
|
|
|
|
if (!actorId) {
|
|
return reply.code(401).send({
|
|
error: 'Unauthorized',
|
|
message: 'User context missing'
|
|
});
|
|
}
|
|
|
|
// Validate query params
|
|
const validation = auditLogsQuerySchema.safeParse(request.query);
|
|
if (!validation.success) {
|
|
return reply.code(400).send({
|
|
error: 'Bad Request',
|
|
message: 'Invalid query parameters',
|
|
details: validation.error.errors
|
|
});
|
|
}
|
|
|
|
const { limit, offset } = validation.data;
|
|
|
|
const result = await this.adminService.getAuditLogs(limit, offset);
|
|
|
|
return reply.code(200).send(result);
|
|
} catch (error: any) {
|
|
logger.error('Error fetching audit logs', {
|
|
error: error.message,
|
|
actorId: request.userContext?.userId
|
|
});
|
|
return reply.code(500).send({
|
|
error: 'Internal server error',
|
|
message: 'Failed to fetch audit logs'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/admin/admins/bulk - Create multiple admin users
|
|
*/
|
|
async bulkCreateAdmins(
|
|
request: FastifyRequest<{ Body: BulkCreateAdminInput }>,
|
|
reply: FastifyReply
|
|
) {
|
|
try {
|
|
const actorUserProfileId = request.userContext?.userId;
|
|
|
|
if (!actorUserProfileId) {
|
|
return reply.code(401).send({
|
|
error: 'Unauthorized',
|
|
message: 'User context missing'
|
|
});
|
|
}
|
|
|
|
// Get actor's admin record
|
|
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
|
|
if (!actorAdmin) {
|
|
return reply.code(403).send({
|
|
error: 'Forbidden',
|
|
message: 'Actor is not an admin'
|
|
});
|
|
}
|
|
|
|
// Validate request body
|
|
const validation = bulkCreateAdminSchema.safeParse(request.body);
|
|
if (!validation.success) {
|
|
return reply.code(400).send({
|
|
error: 'Bad Request',
|
|
message: 'Invalid request body',
|
|
details: validation.error.errors
|
|
});
|
|
}
|
|
|
|
const { admins } = validation.data;
|
|
|
|
const created: AdminUser[] = [];
|
|
const failed: Array<{ email: string; error: string }> = [];
|
|
|
|
// Process each admin creation sequentially to maintain data consistency
|
|
for (const adminInput of admins) {
|
|
try {
|
|
const { email, role = 'admin' } = adminInput;
|
|
|
|
// Look up user profile by email to get UUID
|
|
const userProfile = await this.userProfileRepository.getByEmail(email);
|
|
if (!userProfile) {
|
|
failed.push({
|
|
email,
|
|
error: `No user profile found with email ${email}. User must sign up first.`
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const admin = await this.adminService.createAdmin(
|
|
email,
|
|
role,
|
|
userProfile.id,
|
|
actorAdmin.id
|
|
);
|
|
|
|
created.push(admin);
|
|
} catch (error: any) {
|
|
logger.error('Error creating admin in bulk operation', {
|
|
error: error.message,
|
|
email: adminInput.email,
|
|
actorAdminId: actorAdmin.id
|
|
});
|
|
|
|
failed.push({
|
|
email: adminInput.email,
|
|
error: error.message || 'Failed to create admin'
|
|
});
|
|
}
|
|
}
|
|
|
|
const response: BulkCreateAdminResponse = {
|
|
created,
|
|
failed
|
|
};
|
|
|
|
// Return 207 Multi-Status if there were any failures, 201 if all succeeded
|
|
const statusCode = failed.length > 0 ? 207 : 201;
|
|
|
|
return reply.code(statusCode).send(response);
|
|
} catch (error: any) {
|
|
logger.error('Error in bulk create admins', {
|
|
error: error.message,
|
|
actorUserProfileId: request.userContext?.userId
|
|
});
|
|
|
|
return reply.code(500).send({
|
|
error: 'Internal server error',
|
|
message: 'Failed to process bulk admin creation'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* PATCH /api/admin/admins/bulk-revoke - Revoke multiple admin users
|
|
*/
|
|
async bulkRevokeAdmins(
|
|
request: FastifyRequest<{ Body: BulkRevokeAdminInput }>,
|
|
reply: FastifyReply
|
|
) {
|
|
try {
|
|
const actorUserProfileId = request.userContext?.userId;
|
|
|
|
if (!actorUserProfileId) {
|
|
return reply.code(401).send({
|
|
error: 'Unauthorized',
|
|
message: 'User context missing'
|
|
});
|
|
}
|
|
|
|
// Get actor's admin record
|
|
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
|
|
if (!actorAdmin) {
|
|
return reply.code(403).send({
|
|
error: 'Forbidden',
|
|
message: 'Actor is not an admin'
|
|
});
|
|
}
|
|
|
|
// Validate request body
|
|
const validation = bulkRevokeAdminSchema.safeParse(request.body);
|
|
if (!validation.success) {
|
|
return reply.code(400).send({
|
|
error: 'Bad Request',
|
|
message: 'Invalid request body',
|
|
details: validation.error.errors
|
|
});
|
|
}
|
|
|
|
const { ids } = validation.data;
|
|
|
|
const revoked: AdminUser[] = [];
|
|
const failed: Array<{ id: string; error: string }> = [];
|
|
|
|
// Process each revocation sequentially to maintain data consistency
|
|
for (const id of ids) {
|
|
try {
|
|
// Check if admin exists
|
|
const targetAdmin = await this.adminService.getAdminById(id);
|
|
if (!targetAdmin) {
|
|
failed.push({
|
|
id,
|
|
error: 'Admin user not found'
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Attempt to revoke the admin
|
|
const admin = await this.adminService.revokeAdmin(id, actorAdmin.id);
|
|
revoked.push(admin);
|
|
} catch (error: any) {
|
|
logger.error('Error revoking admin in bulk operation', {
|
|
error: error.message,
|
|
adminId: id,
|
|
actorAdminId: actorAdmin.id
|
|
});
|
|
|
|
failed.push({
|
|
id,
|
|
error: error.message || 'Failed to revoke admin'
|
|
});
|
|
}
|
|
}
|
|
|
|
const response: BulkRevokeAdminResponse = {
|
|
revoked,
|
|
failed
|
|
};
|
|
|
|
// Return 207 Multi-Status if there were any failures, 200 if all succeeded
|
|
const statusCode = failed.length > 0 ? 207 : 200;
|
|
|
|
return reply.code(statusCode).send(response);
|
|
} catch (error: any) {
|
|
logger.error('Error in bulk revoke admins', {
|
|
error: error.message,
|
|
actorUserProfileId: request.userContext?.userId
|
|
});
|
|
|
|
return reply.code(500).send({
|
|
error: 'Internal server error',
|
|
message: 'Failed to process bulk admin revocation'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* PATCH /api/admin/admins/bulk-reinstate - Reinstate multiple revoked admin users
|
|
*/
|
|
async bulkReinstateAdmins(
|
|
request: FastifyRequest<{ Body: BulkReinstateAdminInput }>,
|
|
reply: FastifyReply
|
|
) {
|
|
try {
|
|
const actorUserProfileId = request.userContext?.userId;
|
|
|
|
if (!actorUserProfileId) {
|
|
return reply.code(401).send({
|
|
error: 'Unauthorized',
|
|
message: 'User context missing'
|
|
});
|
|
}
|
|
|
|
// Get actor's admin record
|
|
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
|
|
if (!actorAdmin) {
|
|
return reply.code(403).send({
|
|
error: 'Forbidden',
|
|
message: 'Actor is not an admin'
|
|
});
|
|
}
|
|
|
|
// Validate request body
|
|
const validation = bulkReinstateAdminSchema.safeParse(request.body);
|
|
if (!validation.success) {
|
|
return reply.code(400).send({
|
|
error: 'Bad Request',
|
|
message: 'Invalid request body',
|
|
details: validation.error.errors
|
|
});
|
|
}
|
|
|
|
const { ids } = validation.data;
|
|
|
|
const reinstated: AdminUser[] = [];
|
|
const failed: Array<{ id: string; error: string }> = [];
|
|
|
|
// Process each reinstatement sequentially to maintain data consistency
|
|
for (const id of ids) {
|
|
try {
|
|
// Check if admin exists
|
|
const targetAdmin = await this.adminService.getAdminById(id);
|
|
if (!targetAdmin) {
|
|
failed.push({
|
|
id,
|
|
error: 'Admin user not found'
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Attempt to reinstate the admin
|
|
const admin = await this.adminService.reinstateAdmin(id, actorAdmin.id);
|
|
reinstated.push(admin);
|
|
} catch (error: any) {
|
|
logger.error('Error reinstating admin in bulk operation', {
|
|
error: error.message,
|
|
adminId: id,
|
|
actorAdminId: actorAdmin.id
|
|
});
|
|
|
|
failed.push({
|
|
id,
|
|
error: error.message || 'Failed to reinstate admin'
|
|
});
|
|
}
|
|
}
|
|
|
|
const response: BulkReinstateAdminResponse = {
|
|
reinstated,
|
|
failed
|
|
};
|
|
|
|
// Return 207 Multi-Status if there were any failures, 200 if all succeeded
|
|
const statusCode = failed.length > 0 ? 207 : 200;
|
|
|
|
return reply.code(statusCode).send(response);
|
|
} catch (error: any) {
|
|
logger.error('Error in bulk reinstate admins', {
|
|
error: error.message,
|
|
actorUserProfileId: request.userContext?.userId
|
|
});
|
|
|
|
return reply.code(500).send({
|
|
error: 'Internal server error',
|
|
message: 'Failed to process bulk admin reinstatement'
|
|
});
|
|
}
|
|
}
|
|
|
|
private resolveUserEmail(request: FastifyRequest): string | undefined {
|
|
const candidates: Array<string | undefined> = [
|
|
request.userContext?.email,
|
|
(request as any).user?.email,
|
|
(request as any).user?.['https://motovaultpro.com/email'],
|
|
(request as any).user?.['https://motovaultpro.com/user_email'],
|
|
(request as any).user?.preferred_username,
|
|
];
|
|
|
|
for (const value of candidates) {
|
|
if (typeof value === 'string' && value.includes('@')) {
|
|
return value.trim();
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
}
|