import { explorerObservations, resolveCancerRefs, resolveGeographyRef, explorerOptions } from '@/lib/queries/explorer'; import { parseExplorerParams, yearRangeLabel, sexLabel, ageLabel, SEX_ANY } from '@/lib/explorer-params'; import { csvComment, csvFileName, csvLine, EPI_CSV_COLUMNS } from '@/lib/explorer-csv'; import { EPI_METRIC_LABEL } from '@/lib/queries/epidemiology'; import { SITE_URL } from '@/lib/site'; import { isoDate, toDate } from '@/lib/format'; import type { SP } from '@/lib/search-params'; export const dynamic = 'force-dynamic'; const MAX_ROWS = 50_000; /** * GET /api/export/epidemiology.csv?metric=&cancers=a,b&geography=&sex=&age=&from=&to= * Same filters as /explore. Attribution header rows (`#`) precede the column header: CancerIndex, * each underlying source with its license, dataset version and retrieval date, methodology URL. * Every row carries its provenance id and source URL. Capped at 50 000 rows (stated in the header). */ export async function GET(req: Request) { const url = new URL(req.url); const sp: SP = {}; for (const [k, v] of url.searchParams) sp[k] = sp[k] == null ? v : [...(Array.isArray(sp[k]) ? (sp[k] as string[]) : [sp[k] as string]), v]; const state = parseExplorerParams(sp, { metric: '', geography: '', cancers: [] }); if (!state.metric) return new Response('metric is required', { status: 400 }); if (!state.geography) return new Response('geography is required (slug or ISO3)', { status: 400 }); if (state.cancers.length === 0) return new Response('cancers is required (comma-separated slugs or CI-CAN ids, max 6)', { status: 400 }); const [geo, cancers, options] = await Promise.all([resolveGeographyRef(state.geography), resolveCancerRefs(state.cancers), explorerOptions()]); if (!geo) return new Response('unknown geography', { status: 404 }); if (cancers.length === 0) return new Response('no known cancer in the selection', { status: 404 }); const rows = await explorerObservations({ metric: state.metric, cancerIds: cancers.map((c) => c.id), geographyId: geo.id, sex: state.sex, age: state.age, from: state.from, to: state.to, limit: MAX_ROWS }); const usedSlugs = new Set(rows.map((r) => r.source_slug)); const sources = options.sources.filter((s) => usedSlugs.has(s.slug)); const retrievedBySource = new Map(); for (const r of rows) { const d = toDate(r.retrieved_at); const cur = retrievedBySource.get(r.source_slug); if (!cur || (d && (!cur.retrieved || d > cur.retrieved))) retrievedBySource.set(r.source_slug, { retrieved: d, dataset: r.dataset, version: r.dataset_version }); } const metricLabel = EPI_METRIC_LABEL[state.metric] ?? state.metric; const now = new Date(); const header = [ csvComment(`CancerIndex epidemiology export — ${metricLabel} · ${geo.name} · ${sexLabel(state.sex)} · ${ageLabel(state.age)} · ${yearRangeLabel(state.from, state.to)} · cancers: ${cancers.map((c) => c.slug).join(', ')}`), csvComment(`Source: CancerIndex (${SITE_URL}) — harmonized observations, CC BY 4.0 for the harmonization. Values are exactly as published by the providers below and remain under their licenses.`), ...sources.map((s) => { const r = retrievedBySource.get(s.slug); return csvComment(`Underlying source: ${s.name} [${s.slug}] · license: ${s.license ?? 'see source page'} · dataset: ${r?.dataset ?? '—'}${r?.version ? ` (${r.version})` : ''} · retrieved: ${r?.retrieved ? r.retrieved.toISOString() : 'unknown'} · ${s.homepage ?? `${SITE_URL}/source/${s.slug}`}`); }), ...sources.filter((s) => s.attribution).map((s) => csvComment(`Attribution (${s.slug}): ${s.attribution}`)), csvComment(`Methodology: ${SITE_URL}/methodology#data-explorer · comparability: never compare rows with different standard_population, source_slug or age_group on one axis · population statistics do not predict individual outcomes.`), csvComment(`Generated: ${now.toISOString()} · rows: ${rows.length}${rows.length >= MAX_ROWS ? ` (capped at ${MAX_ROWS}; narrow the selection or use the API)` : ''} · API: ${SITE_URL}/api/v1/epidemiology?metric=${encodeURIComponent(state.metric)}&geography=${encodeURIComponent(geo.slug)}${cancers.map((c) => `&cancer=${encodeURIComponent(c.slug)}`).join('')}${state.sex !== SEX_ANY ? `&sex=${state.sex}` : ''}&age=${encodeURIComponent(state.age)}`), ]; const lines = rows.map((r) => csvLine([ r.cancer_id, r.cancer_slug, r.cancer_name, r.geography_id, r.geography_slug, r.geography_name, r.iso3, r.year, r.year_end, r.sex, r.age_group, r.metric, r.value, r.unit, r.lower_ci, r.upper_ci, r.standard_population, r.estimate_type, r.site_definition, r.source_slug, r.source_name, r.provenance_id, r.dataset, r.dataset_version, r.source_url, toDate(r.retrieved_at)?.toISOString() ?? '', ]), ); const body = [...header, EPI_CSV_COLUMNS.join(','), ...lines].join('\n') + '\n'; const fname = csvFileName([state.metric, geo.slug, state.sex, state.age, state.from ?? rows[0]?.year, state.to ?? rows[rows.length - 1]?.year, isoDate(now)]); return new Response(body, { headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="${fname}"`, 'Cache-Control': 'public, max-age=300' } }); }