Files
pn-new-crm/src/app/api/v1/admin/audit/export/route.ts
Matt 221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00

129 lines
4.0 KiB
TypeScript

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<ReturnType<typeof searchAuditLogs>>['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<ReturnType<typeof searchAuditLogs>>['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';
}