spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import { explorerObservations, resolveCancerRefs, resolveGeographyRef, explorerOptions } from '@/lib/queries/explorer';2import { parseExplorerParams, yearRangeLabel, sexLabel, ageLabel, SEX_ANY } from '@/lib/explorer-params';3import { csvComment, csvFileName, csvLine, EPI_CSV_COLUMNS } from '@/lib/explorer-csv';4import { EPI_METRIC_LABEL } from '@/lib/queries/epidemiology';5import { SITE_URL } from '@/lib/site';6import { isoDate, toDate } from '@/lib/format';7import type { SP } from '@/lib/search-params';89export const dynamic = 'force-dynamic';1011const MAX_ROWS = 50_000;1213/**14 * GET /api/export/epidemiology.csv?metric=&cancers=a,b&geography=&sex=&age=&from=&to=15 * Same filters as /explore. Attribution header rows (`#`) precede the column header: CancerIndex,16 * each underlying source with its license, dataset version and retrieval date, methodology URL.17 * Every row carries its provenance id and source URL. Capped at 50 000 rows (stated in the header).18 */19export async function GET(req: Request) {20 const url = new URL(req.url);21 const sp: SP = {};22 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];23 const state = parseExplorerParams(sp, { metric: '', geography: '', cancers: [] });24 if (!state.metric) return new Response('metric is required', { status: 400 });25 if (!state.geography) return new Response('geography is required (slug or ISO3)', { status: 400 });26 if (state.cancers.length === 0) return new Response('cancers is required (comma-separated slugs or CI-CAN ids, max 6)', { status: 400 });2728 const [geo, cancers, options] = await Promise.all([resolveGeographyRef(state.geography), resolveCancerRefs(state.cancers), explorerOptions()]);29 if (!geo) return new Response('unknown geography', { status: 404 });30 if (cancers.length === 0) return new Response('no known cancer in the selection', { status: 404 });3132 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 });33 const usedSlugs = new Set(rows.map((r) => r.source_slug));34 const sources = options.sources.filter((s) => usedSlugs.has(s.slug));35 const retrievedBySource = new Map<string, { retrieved: Date | null; dataset: string | null; version: string | null }>();36 for (const r of rows) {37 const d = toDate(r.retrieved_at);38 const cur = retrievedBySource.get(r.source_slug);39 if (!cur || (d && (!cur.retrieved || d > cur.retrieved))) retrievedBySource.set(r.source_slug, { retrieved: d, dataset: r.dataset, version: r.dataset_version });40 }41 const metricLabel = EPI_METRIC_LABEL[state.metric] ?? state.metric;42 const now = new Date();4344 const header = [45 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(', ')}`),46 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.`),47 ...sources.map((s) => {48 const r = retrievedBySource.get(s.slug);49 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}`}`);50 }),51 ...sources.filter((s) => s.attribution).map((s) => csvComment(`Attribution (${s.slug}): ${s.attribution}`)),52 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.`),53 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)}`),54 ];55 const lines = rows.map((r) =>56 csvLine([57 r.cancer_id,58 r.cancer_slug,59 r.cancer_name,60 r.geography_id,61 r.geography_slug,62 r.geography_name,63 r.iso3,64 r.year,65 r.year_end,66 r.sex,67 r.age_group,68 r.metric,69 r.value,70 r.unit,71 r.lower_ci,72 r.upper_ci,73 r.standard_population,74 r.estimate_type,75 r.site_definition,76 r.source_slug,77 r.source_name,78 r.provenance_id,79 r.dataset,80 r.dataset_version,81 r.source_url,82 toDate(r.retrieved_at)?.toISOString() ?? '',83 ]),84 );85 const body = [...header, EPI_CSV_COLUMNS.join(','), ...lines].join('\n') + '\n';86 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)]);87 return new Response(body, { headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="${fname}"`, 'Cache-Control': 'public, max-age=300' } });88}89