SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
5.4 KB · 89 lines typescript
Raw Blame History
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