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%
10.9 KB · 213 lines typescript
Raw Blame History
1import { sql } from 'drizzle-orm';2import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';3import { z } from 'zod';4import { paginate } from '../lib/envelope.js';5import { BadRequest, NotFound } from '../lib/errors.js';6import { pageQuery } from '../lib/pagination.js';7import { resolveCancer } from '../lib/resolve.js';8import { AnyList, num, ok, respond } from '../lib/respond.js';910const MAX_CANCERS = 8;11const METRIC_RE = /^[a-z][a-z0-9_]{1,63}$/;1213/** Labels shared with the web app (apps/web/src/lib/queries/epidemiology.ts EPI_METRIC_LABEL). */14const METRIC_LABEL: Record<string, string> = {15  incidence_count: 'New cases',16  incidence_rate: 'Incidence rate (crude)',17  as_incidence_rate: 'Incidence rate (age-standardized)',18  mortality_count: 'Deaths',19  mortality_rate: 'Mortality rate (crude)',20  as_mortality_rate: 'Mortality rate (age-standardized)',21  prevalence: 'Prevalence',22  prevalence_5y: '5-year prevalence',23};2425/** `cancer=a&cancer=b` or `cancer=a,b` (or both) → distinct trimmed refs, max MAX_CANCERS. */26export function splitRefs(v: string | string[] | undefined, max = MAX_CANCERS): string[] {27  const raw = (Array.isArray(v) ? v : v == null ? [] : [v]).flatMap((s) => String(s).split(','));28  const out: string[] = [];29  for (const t of raw.map((s) => s.trim()).filter(Boolean)) if (!out.includes(t)) out.push(t);30  if (out.length > max) throw new BadRequest(`at most ${max} cancers per request (got ${out.length})`);31  return out;32}3334const cancerParam = z.union([z.string(), z.array(z.string())]).optional().describe('Cancer id or slug — repeatable or comma-separated, max 8');35const geographyParam = z.string().trim().min(1).max(100).optional().describe('Geography slug or ISO3 code (e.g. united-states, USA)');3637type GeoRow = Record<string, unknown> & { id: string; slug: string; name: string; iso3: string | null };3839/**40 * Epidemiology routes (SPEC §20, §63, §110). Observations are returned exactly as stored — one row per41 * (cancer, geography, year, sex, age group, metric, source, site definition) — with the standard population,42 * estimate type and full provenance so consumers can apply the comparability rules43 * (docs/methodology/data-explorer.md): never overlay different standard populations, sources or age groups.44 */45export const epidemiologyRoutes: FastifyPluginAsyncZod = async (app) => {46  async function resolveGeography(ref: string): Promise<GeoRow> {47    const rows = await app.db.execute<GeoRow>(sql`SELECT id, slug, name, iso3 FROM geographies WHERE slug = ${ref.toLowerCase()} OR upper(iso3) = ${ref.toUpperCase()} OR id = ${ref} ORDER BY (slug = ${ref.toLowerCase()}) DESC LIMIT 1`);48    const g = rows[0];49    if (!g) throw new NotFound('geography', ref);50    return g;51  }5253  app.get(54    '/epidemiology',55    {56      schema: {57        tags: ['epidemiology'],58        summary: 'Epidemiology observations filtered by metric, cancer(s), geography, sex, age group, years, source and estimate type',59        querystring: z.object({60          metric: z.string().regex(METRIC_RE).optional().describe('incidence_count | as_incidence_rate | mortality_count | mortality_rate | as_mortality_rate | …'),61          cancer: cancerParam,62          geography: geographyParam,63          sex: z.enum(['all', 'male', 'female']).optional(),64          age: z.string().trim().max(24).optional().describe('Age group label as stored (default: any; "all" = all ages)'),65          from: z.coerce.number().int().min(1900).max(2100).optional().describe('First year (inclusive)'),66          to: z.coerce.number().int().min(1900).max(2100).optional().describe('Last year (inclusive)'),67          source: z.string().trim().max(64).optional().describe('Source slug or CI-SOURCE id'),68          estimateType: z.enum(['observed', 'estimated', 'projected']).optional(),69          ...pageQuery,70        }),71        response: ok(AnyList, true),72      },73    },74    async (req) => {75      const q = req.query;76      const conds = [sql`true`];77      if (q.metric) conds.push(sql`o.metric = ${q.metric}`);78      const refs = splitRefs(q.cancer);79      if (refs.length > 0) {80        const ids = await Promise.all(refs.map((r) => resolveCancer(app.db, r).then((c) => c.id)));81        conds.push(sql`o.cancer_id = ANY(${sql.param(ids)}::text[])`);82      }83      if (q.geography) {84        const g = await resolveGeography(q.geography);85        conds.push(sql`o.geography_id = ${g.id}`);86      }87      if (q.sex) conds.push(sql`o.sex = ${q.sex}`);88      if (q.age) conds.push(sql`o.age_group = ${q.age}`);89      if (q.from != null) conds.push(sql`coalesce(o.year_end, o.year) >= ${q.from}`);90      if (q.to != null) conds.push(sql`o.year <= ${q.to}`);91      if (q.source) conds.push(q.source.startsWith('CI-SOURCE-') ? sql`o.source_id = ${q.source}` : sql`s.slug = ${q.source.toLowerCase()}`);92      if (q.estimateType) conds.push(sql`o.estimate_type = ${q.estimateType}`);93      if (q.from != null && q.to != null && q.from > q.to) throw new BadRequest('`from` must not exceed `to`');9495      const rows = await app.db.execute<Record<string, unknown> & { total: string }>(sql`96        SELECT o.id, o.cancer_id, c.slug AS cancer_slug, c.canonical_name AS cancer_name,97               o.geography_id, g.slug AS geography_slug, g.name AS geography_name, g.iso3,98               o.year, o.year_end, o.sex, o.age_group, o.metric, o.value, o.unit, o.lower_ci, o.upper_ci, o.standard_population, o.estimate_type, o.site_definition,99               o.source_id, s.slug AS source_slug, o.provenance_id, p.dataset, p.dataset_version, p.source_url, p.retrieved_at, o.updated_at,100               count(*) OVER() AS total101        FROM epidemiology_observations o102        JOIN cancers c ON c.id = o.cancer_id103        JOIN geographies g ON g.id = o.geography_id104        JOIN sources s ON s.id = o.source_id105        LEFT JOIN provenance p ON p.id = o.provenance_id106        WHERE ${sql.join(conds, sql` AND `)}107        ORDER BY c.canonical_name, c.id, g.name, o.sex, o.year, s.slug, o.site_definition108        LIMIT ${q.limit} OFFSET ${q.offset}`);109      const total = rows.length ? num(rows[0]!.total) : 0;110      const data = rows.map((r) => ({111        id: num(r.id),112        cancer: { id: r.cancer_id, slug: r.cancer_slug, name: r.cancer_name },113        geography: { id: r.geography_id, slug: r.geography_slug, name: r.geography_name, iso3: r.iso3 },114        year: num(r.year),115        yearEnd: r.year_end == null ? null : num(r.year_end),116        sex: r.sex,117        ageGroup: r.age_group,118        metric: r.metric,119        value: num(r.value),120        unit: r.unit,121        lowerCi: r.lower_ci == null ? null : num(r.lower_ci),122        upperCi: r.upper_ci == null ? null : num(r.upper_ci),123        standardPopulation: r.standard_population,124        estimateType: r.estimate_type,125        siteDefinition: r.site_definition,126        source: { id: r.source_id, slug: r.source_slug },127        provenance: { id: num(r.provenance_id), dataset: r.dataset, datasetVersion: r.dataset_version, sourceUrl: r.source_url, retrievedAt: r.retrieved_at },128        updatedAt: r.updated_at,129      }));130      return respond(app, data, data.map((d) => d.source.id as string), paginate(total, q.limit, q.offset));131    },132  );133134  app.get(135    '/epidemiology/coverage',136    {137      schema: {138        tags: ['epidemiology'],139        summary: 'Coverage matrix: metric × geography × sex × age group × source × standard population with year span and counts',140        querystring: z.object({ cancer: cancerParam, geography: geographyParam, metric: z.string().regex(METRIC_RE).optional() }),141        response: ok(AnyList),142      },143    },144    async (req) => {145      const q = req.query;146      const conds = [sql`true`];147      const refs = splitRefs(q.cancer);148      if (refs.length > 0) {149        const ids = await Promise.all(refs.map((r) => resolveCancer(app.db, r).then((c) => c.id)));150        conds.push(sql`o.cancer_id = ANY(${sql.param(ids)}::text[])`);151      }152      if (q.geography) {153        const g = await resolveGeography(q.geography);154        conds.push(sql`o.geography_id = ${g.id}`);155      }156      if (q.metric) conds.push(sql`o.metric = ${q.metric}`);157      const rows = await app.db.execute<Record<string, unknown>>(sql`158        SELECT o.metric, min(o.unit) AS unit, g.id AS geography_id, g.slug AS geography_slug, g.name AS geography_name, g.iso3, o.sex, o.age_group,159               o.source_id, s.slug AS source_slug, o.standard_population, array_agg(DISTINCT o.estimate_type) AS estimate_types,160               min(o.year) AS year_from, max(coalesce(o.year_end, o.year)) AS year_to, count(DISTINCT o.year) AS years, count(*) AS observations, count(DISTINCT o.cancer_id) AS cancers, max(o.updated_at) AS last_updated161        FROM epidemiology_observations o JOIN geographies g ON g.id = o.geography_id JOIN sources s ON s.id = o.source_id162        WHERE ${sql.join(conds, sql` AND `)}163        GROUP BY o.metric, g.id, g.slug, g.name, g.iso3, o.sex, o.age_group, o.source_id, s.slug, o.standard_population164        ORDER BY o.metric, g.name, (o.sex = 'all') DESC, o.sex, (o.age_group = 'all') DESC, o.age_group, s.slug, o.standard_population`);165      const data = rows.map((r) => ({166        metric: r.metric,167        unit: r.unit,168        geography: { id: r.geography_id, slug: r.geography_slug, name: r.geography_name, iso3: r.iso3 },169        sex: r.sex,170        ageGroup: r.age_group,171        source: { id: r.source_id, slug: r.source_slug },172        standardPopulation: r.standard_population,173        estimateTypes: r.estimate_types,174        yearFrom: num(r.year_from),175        yearTo: num(r.year_to),176        years: num(r.years),177        observations: num(r.observations),178        cancers: num(r.cancers),179        lastUpdated: r.last_updated,180      }));181      return respond(182        app,183        data,184        data.map((d) => d.source.id as string),185      );186    },187  );188189  app.get('/epidemiology/metrics', { schema: { tags: ['epidemiology'], summary: 'Distinct epidemiology metrics present, with unit, label, year span and counts', response: ok(AnyList) } }, async () => {190    const rows = await app.db.execute<Record<string, unknown>>(sql`191      SELECT o.metric, min(o.unit) AS unit, count(*) AS n, count(DISTINCT o.cancer_id) AS cancers, count(DISTINCT o.geography_id) AS geographies, min(o.year) AS year_from, max(coalesce(o.year_end, o.year)) AS year_to,192             array_agg(DISTINCT s.slug ORDER BY s.slug) AS sources, array_remove(array_agg(DISTINCT o.standard_population), NULL) AS standard_populations193      FROM epidemiology_observations o JOIN sources s ON s.id = o.source_id GROUP BY o.metric ORDER BY o.metric`);194    const data = rows.map((r) => ({195      metric: r.metric,196      label: METRIC_LABEL[r.metric as string] ?? String(r.metric).replace(/_/g, ' '),197      unit: r.unit,198      n: num(r.n),199      cancers: num(r.cancers),200      geographies: num(r.geographies),201      yearFrom: num(r.year_from),202      yearTo: num(r.year_to),203      sources: r.sources,204      standardPopulations: r.standard_populations,205    }));206    return respond(207      app,208      data,209      data.flatMap((d) => d.sources as string[]),210    );211  });212};213