268 lines
9.3 KiB
TypeScript
268 lines
9.3 KiB
TypeScript
/**
|
|
* 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<string, string>),
|
|
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<string, unknown>): 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<void> => {
|
|
try {
|
|
const filters = parseAuditQuery(req.query as Record<string, unknown>);
|
|
|
|
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<void> => {
|
|
try {
|
|
// For CSV exports we fetch up to 10,000 rows (no pagination).
|
|
const filters = parseAuditQuery(req.query as Record<string, unknown>);
|
|
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<void> => {
|
|
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<void> => {
|
|
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<CleanupTarget, (days: number, confirm: boolean) => 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<void> => {
|
|
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;
|