import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { errorResponse } from '@/lib/errors'; import { searchAuditLogs } from '@/lib/services/audit-search.service'; /** * M-AU03 — CSV export of audit log search results. * * Accepts the same query-string filters as `GET /api/v1/admin/audit` * (q, userId, action, entityType, entityId, severity, source, from, to) * and streams up to 10 000 rows back as a CSV download. The 10k cap * keeps the response under a couple of megabytes; reps wanting deeper * history should narrow the filter or run multiple exports. * * Permission gate matches the read endpoint: `admin.view_audit_log`. */ export const GET = withAuth( withPermission('admin', 'view_audit_log', async (req, ctx) => { try { const url = new URL(req.url); const params = url.searchParams; const parseDate = (v: string | null): Date | undefined => { if (!v) return undefined; const d = new Date(v); return Number.isFinite(d.getTime()) ? d : undefined; }; // Cap the export at 10 000 rows. Anyone needing deeper history // can scroll through the paginated UI or narrow the date range. const HARD_CAP = 10_000; let collected: Awaited>['rows'] = []; let cursor: { createdAt: Date; id: string } | undefined; // Run a small loop so we paginate through the cursor-based search // service to fill up to HARD_CAP rows. while (collected.length < HARD_CAP) { const remaining = HARD_CAP - collected.length; const page = await searchAuditLogs({ portId: ctx.portId, q: params.get('q') ?? undefined, userId: params.get('userId') ?? undefined, action: params.get('action') ?? undefined, entityType: params.get('entityType') ?? undefined, entityId: params.get('entityId') ?? undefined, severity: params.get('severity') ?? undefined, source: params.get('source') ?? undefined, from: parseDate(params.get('from')), to: parseDate(params.get('to')), limit: Math.min(remaining, 500), cursor, }); collected = collected.concat(page.rows); if (!page.nextCursor) break; cursor = page.nextCursor; } const csv = buildCsv(collected); const filename = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`; return new NextResponse(csv, { headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="${filename}"`, }, }); } catch (error) { return errorResponse(error); } }), ); /** * RFC 4180 CSV serializer. Escapes embedded quotes by doubling them and * wraps any field containing comma / quote / newline in double-quotes. * Trailing CRLF terminator per spec. */ function buildCsv(rows: Awaited>['rows']): string { const headers = [ 'createdAt', 'id', 'portId', 'userId', 'action', 'entityType', 'entityId', 'severity', 'source', 'ipAddress', 'userAgent', 'metadata', 'oldValue', 'newValue', ]; const escape = (v: unknown): string => { if (v === null || v === undefined) return ''; const s = typeof v === 'object' ? JSON.stringify(v) : String(v); if (/[",\n\r]/.test(s)) { return `"${s.replace(/"/g, '""')}"`; } return s; }; const lines = [headers.join(',')]; for (const r of rows) { lines.push( [ r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt, r.id, r.portId, r.userId, r.action, r.entityType, r.entityId, r.severity, r.source, r.ipAddress, r.userAgent, r.metadata, r.oldValue, r.newValue, ] .map(escape) .join(','), ); } return lines.join('\r\n') + '\r\n'; }