/** * Admin API Routes — Audit Log + Data Cleanup * * GET /api/admin/audit-log — paginated, filtered list * GET /api/admin/audit-log/export — CSV download of filtered results * DELETE /api/admin/cleanup/:target — preview / delete old data * * Both endpoints require authentication + admin:access permission. * * Register in app.ts: * import adminRoutes from './routes/admin.routes'; * app.use('/api/admin', adminRoutes); */ import { Router, Request, Response } from 'express'; import { z } from 'zod'; import axios from 'axios'; import { authenticate } from '../middleware/auth.middleware'; import { requirePermission } from '../middleware/rbac.middleware'; import { auditExport } from '../middleware/audit.middleware'; import auditService, { AuditAction, AuditResourceType, AuditFilters } from '../services/audit.service'; import cleanupService from '../services/cleanup.service'; import logger from '../utils/logger'; const router = Router(); // --------------------------------------------------------------------------- // Input validation schemas (Zod) // --------------------------------------------------------------------------- const auditQuerySchema = z.object({ userId: z.string().uuid().optional(), action: z.union([ z.nativeEnum(Object.fromEntries( Object.entries(AuditAction).map(([k, v]) => [k, v]) ) as Record), z.array(z.string()), ]).optional(), resourceType: z.union([ z.string(), z.array(z.string()), ]).optional(), resourceId: z.string().optional(), dateFrom: z.string().datetime({ offset: true }).optional(), dateTo: z.string().datetime({ offset: true }).optional(), page: z.coerce.number().int().positive().default(1), pageSize: z.coerce.number().int().min(1).max(200).default(25), }); // --------------------------------------------------------------------------- // Helper — parse and validate query string // --------------------------------------------------------------------------- function parseAuditQuery(query: Record): AuditFilters { const parsed = auditQuerySchema.parse(query); // Normalise action to array of AuditAction const actions = parsed.action ? (Array.isArray(parsed.action) ? parsed.action : [parsed.action]) as AuditAction[] : undefined; // Normalise resourceType to array of AuditResourceType const resourceTypes = parsed.resourceType ? (Array.isArray(parsed.resourceType) ? parsed.resourceType : [parsed.resourceType]) as AuditResourceType[] : undefined; return { userId: parsed.userId, action: actions && actions.length === 1 ? actions[0] : actions, resourceType: resourceTypes && resourceTypes.length === 1 ? resourceTypes[0] : resourceTypes, resourceId: parsed.resourceId, dateFrom: parsed.dateFrom ? new Date(parsed.dateFrom) : undefined, dateTo: parsed.dateTo ? new Date(parsed.dateTo) : undefined, page: parsed.page, pageSize: parsed.pageSize, }; } // --------------------------------------------------------------------------- // GET /api/admin/audit-log // --------------------------------------------------------------------------- router.get( '/audit-log', authenticate, requirePermission('admin:view'), async (req: Request, res: Response): Promise => { try { const filters = parseAuditQuery(req.query as Record); const result = await auditService.getAuditLog(filters); res.status(200).json({ success: true, data: result, }); } catch (error) { if (error instanceof z.ZodError) { res.status(400).json({ success: false, message: 'Invalid query parameters', errors: error.issues, }); return; } logger.error('Failed to fetch audit log', { error }); res.status(500).json({ success: false, message: 'Failed to fetch audit log', }); } } ); // --------------------------------------------------------------------------- // GET /api/admin/audit-log/export // --------------------------------------------------------------------------- router.get( '/audit-log/export', authenticate, requirePermission('admin:view'), async (req: Request, res: Response): Promise => { try { // For CSV exports we fetch up to 10,000 rows (no pagination). const filters = parseAuditQuery(req.query as Record); const exportFilters: AuditFilters = { ...filters, page: 1, pageSize: 10_000, }; // Audit the export action itself before streaming the response auditExport(req, AuditResourceType.SYSTEM, { export_format: 'csv', filters: JSON.stringify(exportFilters), }); const result = await auditService.getAuditLog(exportFilters); const csv = auditService.entriesToCsv(result.entries); const filename = `audit_log_${new Date().toISOString().replace(/[:.]/g, '-')}.csv`; res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); // Add BOM for Excel UTF-8 compatibility res.send('\uFEFF' + csv); } catch (error) { if (error instanceof z.ZodError) { res.status(400).json({ success: false, message: 'Invalid query parameters', errors: error.issues, }); return; } logger.error('Failed to export audit log', { error }); res.status(500).json({ success: false, message: 'Failed to export audit log', }); } } ); // --------------------------------------------------------------------------- // FDISK Sync proxy — forwards to the fdisk-sync sidecar service // --------------------------------------------------------------------------- const FDISK_SYNC_URL = process.env.FDISK_SYNC_URL ?? ''; router.get( '/fdisk-sync/logs', authenticate, requirePermission('admin:view'), async (_req: Request, res: Response): Promise => { if (!FDISK_SYNC_URL) { res.status(503).json({ success: false, message: 'FDISK sync service not configured' }); return; } try { const response = await axios.get(`${FDISK_SYNC_URL}/logs`, { timeout: 5000 }); res.json({ success: true, data: response.data }); } catch { res.status(502).json({ success: false, message: 'Could not reach sync service' }); } } ); router.post( '/fdisk-sync/trigger', authenticate, requirePermission('admin:view'), async (req: Request, res: Response): Promise => { if (!FDISK_SYNC_URL) { res.status(503).json({ success: false, message: 'FDISK sync service not configured' }); return; } try { const response = await axios.post(`${FDISK_SYNC_URL}/trigger`, req.body, { timeout: 5000 }); res.json({ success: true, data: response.data }); } catch (err: unknown) { if (axios.isAxiosError(err) && err.response?.status === 409) { res.status(409).json({ success: false, message: 'Sync already in progress' }); return; } res.status(502).json({ success: false, message: 'Could not reach sync service' }); } } ); // --------------------------------------------------------------------------- // Cleanup / Data Management endpoints // --------------------------------------------------------------------------- const cleanupBodySchema = z.object({ olderThanDays: z.number().int().min(1).max(3650), confirm: z.boolean().optional().default(false), }); type CleanupTarget = 'notifications' | 'audit-log' | 'events' | 'bookings' | 'orders' | 'vehicle-history' | 'equipment-history'; const CLEANUP_TARGETS: Record Promise<{ count: number; deleted: boolean }>> = { 'notifications': (d, c) => cleanupService.cleanupNotifications(d, c), 'audit-log': (d, c) => cleanupService.cleanupAuditLog(d, c), 'events': (d, c) => cleanupService.cleanupEvents(d, c), 'bookings': (d, c) => cleanupService.cleanupBookings(d, c), 'orders': (d, c) => cleanupService.cleanupOrders(d, c), 'vehicle-history': (d, c) => cleanupService.cleanupVehicleHistory(d, c), 'equipment-history': (d, c) => cleanupService.cleanupEquipmentHistory(d, c), }; router.delete( '/cleanup/:target', authenticate, requirePermission('admin:write'), async (req: Request, res: Response): Promise => { try { const target = req.params.target as CleanupTarget; const handler = CLEANUP_TARGETS[target]; if (!handler) { res.status(400).json({ success: false, message: `Unknown cleanup target: ${target}` }); return; } const { olderThanDays, confirm } = cleanupBodySchema.parse(req.body); const result = await handler(olderThanDays, confirm); res.json({ success: true, data: result }); } catch (error) { if (error instanceof z.ZodError) { res.status(400).json({ success: false, message: 'Invalid parameters', errors: error.issues }); return; } logger.error('Cleanup failed', { error, target: req.params.target }); res.status(500).json({ success: false, message: 'Cleanup failed' }); } } ); export default router;