/** * @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 { pool } from '../../../core/config/database'; import { logger } from '../../../core/logging/logger'; import { CreateAdminInput, AdminAuth0SubInput, AuditLogsQueryInput } from './admin.validation'; import { createAdminSchema, adminAuth0SubSchema, auditLogsQuerySchema } from './admin.validation'; export class AdminController { private adminService: AdminService; constructor() { const repository = new AdminRepository(pool); this.adminService = new AdminService(repository); } /** * 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); console.log('[DEBUG] Admin verify - userId:', userId); console.log('[DEBUG] Admin verify - userEmail:', userEmail); if (userEmail && request.userContext) { request.userContext.email = userEmail.toLowerCase(); } if (!userId && !userEmail) { console.log('[DEBUG] Admin verify - No userId or userEmail, returning 401'); return reply.code(401).send({ error: 'Unauthorized', message: 'User context missing' }); } let adminRecord = 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 (request.userContext) { request.userContext.isAdmin = true; request.userContext.adminRecord = adminRecord; } console.log('[DEBUG] Admin verify - Returning isAdmin: true'); // User is an active admin return reply.code(200).send({ isAdmin: true, adminRecord: { auth0Sub: adminRecord.auth0Sub, email: adminRecord.email, role: adminRecord.role } }); } if (request.userContext) { request.userContext.isAdmin = false; request.userContext.adminRecord = undefined; } console.log('[DEBUG] Admin verify - Returning isAdmin: false'); // User is not an admin return reply.code(200).send({ isAdmin: false, adminRecord: null }); } catch (error) { console.log('[DEBUG] Admin verify - Error caught:', error instanceof Error ? error.message : 'Unknown 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 actorId = request.userContext?.userId; if (!actorId) { return reply.code(401).send({ error: 'Unauthorized', message: 'User context missing' }); } 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({ total: admins.length, admins }); } catch (error: any) { logger.error('Error listing admins', { error: error.message, actorId: 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 actorId = request.userContext?.userId; if (!actorId) { return reply.code(401).send({ error: 'Unauthorized', message: 'User context missing' }); } // 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; // Generate auth0Sub for the new admin // In production, this should be the actual Auth0 user ID // For now, we'll use email-based identifier const auth0Sub = `auth0|${email.replace('@', '_at_')}`; const admin = await this.adminService.createAdmin( email, role, auth0Sub, actorId ); return reply.code(201).send(admin); } catch (error: any) { logger.error('Error creating admin', { error: error.message, actorId: 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/:auth0Sub/revoke - Revoke admin access */ async revokeAdmin( request: FastifyRequest<{ Params: AdminAuth0SubInput }>, reply: FastifyReply ) { try { const actorId = request.userContext?.userId; if (!actorId) { return reply.code(401).send({ error: 'Unauthorized', message: 'User context missing' }); } // Validate params const validation = adminAuth0SubSchema.safeParse(request.params); if (!validation.success) { return reply.code(400).send({ error: 'Bad Request', message: 'Invalid auth0Sub parameter', details: validation.error.errors }); } const { auth0Sub } = validation.data; // Check if admin exists const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub); 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(auth0Sub, actorId); return reply.code(200).send(admin); } catch (error: any) { logger.error('Error revoking admin', { error: error.message, actorId: request.userContext?.userId, targetAuth0Sub: request.params.auth0Sub }); 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/:auth0Sub/reinstate - Restore revoked admin */ async reinstateAdmin( request: FastifyRequest<{ Params: AdminAuth0SubInput }>, reply: FastifyReply ) { try { const actorId = request.userContext?.userId; if (!actorId) { return reply.code(401).send({ error: 'Unauthorized', message: 'User context missing' }); } // Validate params const validation = adminAuth0SubSchema.safeParse(request.params); if (!validation.success) { return reply.code(400).send({ error: 'Bad Request', message: 'Invalid auth0Sub parameter', details: validation.error.errors }); } const { auth0Sub } = validation.data; // Check if admin exists const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub); if (!targetAdmin) { return reply.code(404).send({ error: 'Not Found', message: 'Admin user not found' }); } // Reinstate the admin const admin = await this.adminService.reinstateAdmin(auth0Sub, actorId); return reply.code(200).send(admin); } catch (error: any) { logger.error('Error reinstating admin', { error: error.message, actorId: request.userContext?.userId, targetAuth0Sub: request.params.auth0Sub }); 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' }); } } 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 = [ 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, ]; console.log('[DEBUG] resolveUserEmail - candidates:', candidates); for (const value of candidates) { if (typeof value === 'string' && value.includes('@')) { console.log('[DEBUG] resolveUserEmail - found email:', value); return value.trim(); } } console.log('[DEBUG] resolveUserEmail - no email found'); return undefined; } }