import { getWorkspace } from "@/lib/session"; import { parseRequestFilters, REQUESTS_EXPORT_MAX, type SearchParams } from "@/lib/requests-filters"; import { listRequestsBatch, type RequestListRow, type Scope } from "@/lib/queries/dashboard"; export const dynamic = "force-dynamic"; const COLUMNS: Array<[string, (r: RequestListRow) => unknown]> = [ ["request_id", (r) => r.id], ["timestamp", (r) => r.createdAt.toISOString()], ["completed_at", (r) => r.completedAt?.toISOString() ?? ""], ["status", (r) => r.status], ["http_status", (r) => r.httpStatus ?? ""], ["error_code", (r) => r.errorCode ?? ""], ["method", (r) => r.method], ["url", (r) => r.url], ["final_url", (r) => r.finalUrl ?? ""], ["domain", (r) => r.domain], ["source", (r) => r.source], ["requested_network", (r) => r.requestedNetwork], ["network", (r) => r.network ?? ""], ["country", (r) => r.country ?? ""], ["region", (r) => r.region ?? ""], ["city", (r) => r.city ?? ""], ["session_id", (r) => r.sessionId ?? ""], ["latency_ms", (r) => r.latencyMs ?? ""], ["attempts", (r) => r.attempts], ["bytes_in", (r) => r.bytesIn], ["bytes_out", (r) => r.bytesOut], ["price_usd", (r) => r.priceUsd.toFixed(6)], ["cached", (r) => (r.cached ? "true" : "false")], ]; /** RFC 4180 quoting plus a guard against spreadsheet formula injection. */ function cell(v: unknown): string { let s = v === null || v === undefined ? "" : String(v); if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`; return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; } const BATCH = 1000; /** * GET /dashboard/requests/export?…filters — streams a CSV of the current filter set (newest first), * capped at REQUESTS_EXPORT_MAX rows. Upstream cost is never included; `price_usd` is the billed price. */ export async function GET(req: Request) { const ws = await getWorkspace(); const scope: Scope = { organizationId: ws.organization.id, projectId: ws.project.id }; const url = new URL(req.url); const sp: SearchParams = Object.fromEntries(url.searchParams.entries()); const filters = parseRequestFilters(sp); const encoder = new TextEncoder(); const state = { offset: 0, done: false }; const stream = new ReadableStream({ async start(controller) { controller.enqueue(encoder.encode(`${COLUMNS.map(([name]) => name).join(",")}\r\n`)); }, async pull(controller) { // Pull-based: each call fetches one batch; state lives in the closure. const offset = state.offset; if (offset >= REQUESTS_EXPORT_MAX || state.done) { controller.close(); return; } const limit = Math.min(BATCH, REQUESTS_EXPORT_MAX - offset); let rows: RequestListRow[]; try { rows = await listRequestsBatch(scope, filters, offset, limit); } catch (e) { controller.error(e); return; } if (!rows.length) { state.done = true; controller.close(); return; } const chunk = rows.map((r) => COLUMNS.map(([, get]) => cell(get(r))).join(",")).join("\r\n") + "\r\n"; controller.enqueue(encoder.encode(chunk)); state.offset += rows.length; if (rows.length < limit) state.done = true; }, }); const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "").replace(/(\d{8})(\d{6})/, "$1-$2"); const slug = ws.project.slug || "project"; return new Response(stream, { headers: { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="fetcha-requests-${slug}-${stamp}.csv"`, "cache-control": "no-store", "x-content-type-options": "nosniff", }, }); }