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%
6.6 KB · 85 lines typescript
Raw Blame History
1import { sql } from 'drizzle-orm';2import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';3import { z } from 'zod';4import { normalizeLabel } from '@cancerindex/shared';5import { paginate } from '../lib/envelope.js';6import { pageQuery } from '../lib/pagination.js';7import { resolveDrug } from '../lib/resolve.js';8import { AnyList, AnyRecord, camel, camelRows, num, ok, respond } from '../lib/respond.js';9import { pluck } from '../lib/sources.js';1011export const drugRoutes: FastifyPluginAsyncZod = async (app) => {12  app.get('/drugs', { schema: { tags: ['drugs'], summary: 'List drugs (generic/INN names; brands are aliases)', querystring: z.object({ q: z.string().trim().min(1).max(100).optional(), kind: z.string().optional(), ...pageQuery }), response: ok(AnyList, true) } }, async (req) => {13    const q = req.query;14    const conds = [sql`true`];15    if (q.kind) conds.push(sql`d.kind = ${q.kind}`);16    if (q.q) {17      const norm = normalizeLabel(q.q);18      conds.push(sql`EXISTS (SELECT 1 FROM drug_aliases a WHERE a.drug_id = d.id AND (a.normalized = ${norm} OR a.normalized LIKE ${norm + '%'}))`);19    }20    const rows = await app.db.execute<Record<string, unknown> & { total: string }>(sql`21      SELECT d.id, d.slug, d.name, d.kind, d.ncit_code, d.chembl_id, d.civic_therapy_id, d.drugbank_id, d.mechanism, d.development_status,22        (SELECT count(*) FROM civic_evidence_items e WHERE e.status = 'ACCEPTED' AND d.id = ANY(e.therapy_ids)) AS evidence_items,23        (SELECT count(*) FROM drug_approvals a WHERE a.drug_id = d.id AND a.status IN ('approved','accelerated','conditional')) AS approvals,24        count(*) OVER() AS total25      FROM drugs d WHERE ${sql.join(conds, sql` AND `)} ORDER BY d.name LIMIT ${q.limit} OFFSET ${q.offset}`);26    const total = rows.length ? num(rows[0]!.total) : 0;27    const data = rows.map((r) => {28      const { total: _t, ...rest } = r;29      return camel(rest);30    });31    const sourceRows = data.length ? await app.db.execute<{ source_id: string }>(sql`SELECT DISTINCT source_id FROM drug_aliases WHERE source_id IS NOT NULL AND drug_id = ANY(${sql.param(data.map((d) => d.id as string))}::text[])`) : [];32    return respond(app, data, pluck(sourceRows, 'source_id'), paginate(total, q.limit, q.offset));33  });3435  app.get('/drugs/:id', { schema: { tags: ['drugs'], summary: 'Drug: aliases, curated evidence grouped by cancer, jurisdiction-aware approvals, trials via interventions', params: z.object({ id: z.string().min(1).describe('CI-DRUG-… id or slug') }), response: ok(AnyRecord) } }, async (req) => {36    const { id } = await resolveDrug(app.db, req.params.id);37    const db = app.db;38    const [drug, aliases, evidence, approvals, trials, targets] = await Promise.all([39      db.execute<Record<string, unknown>>(sql`SELECT * FROM drugs WHERE id = ${id}`),40      db.execute<Record<string, unknown>>(sql`SELECT alias, alias_type, source_id FROM drug_aliases WHERE drug_id = ${id} ORDER BY CASE alias_type WHEN 'generic' THEN 0 WHEN 'brand' THEN 1 ELSE 2 END, alias`),41      db.execute<Record<string, unknown>>(sql`42        SELECT e.cancer_id, c.slug AS cancer_slug, c.canonical_name AS cancer_name, e.disease_name, count(*) AS evidence_items,43          count(*) FILTER (WHERE e.evidence_level = 'A') AS level_a, count(*) FILTER (WHERE e.evidence_level = 'B') AS level_b, count(*) FILTER (WHERE e.evidence_level = 'C') AS level_c, count(*) FILTER (WHERE e.evidence_level IN ('D','E')) AS level_de,44          count(*) FILTER (WHERE e.evidence_direction = 'SUPPORTS') AS supports, count(*) FILTER (WHERE e.evidence_direction = 'DOES_NOT_SUPPORT') AS does_not_support,45          count(*) FILTER (WHERE e.significance = 'SENSITIVITYRESPONSE') AS sensitivity, count(*) FILTER (WHERE e.significance = 'RESISTANCE') AS resistance,46          array_agg(DISTINCT gs) FILTER (WHERE gs IS NOT NULL) AS gene_symbols, array_agg(DISTINCT e.civic_id) AS civic_ids, min(p.source_id) AS source_id47        FROM civic_evidence_items e LEFT JOIN cancers c ON c.id = e.cancer_id LEFT JOIN LATERAL unnest(e.gene_symbols) gs ON true LEFT JOIN provenance p ON p.id = e.provenance_id48        WHERE e.status = 'ACCEPTED' AND ${id} = ANY(e.therapy_ids)49        GROUP BY e.cancer_id, c.slug, c.canonical_name, e.disease_name ORDER BY evidence_items DESC`),50      db.execute<Record<string, unknown>>(sql`51        SELECT a.*, c.slug AS cancer_slug, c.canonical_name AS cancer_name, p.source_url, p.retrieved_at, p.dataset_version52        FROM drug_approvals a LEFT JOIN cancers c ON c.id = a.cancer_id LEFT JOIN provenance p ON p.id = a.provenance_id53        WHERE a.drug_id = ${id} ORDER BY a.approval_date DESC NULLS LAST`),54      db.execute<Record<string, unknown>>(sql`55        SELECT t.id, t.nct_id, t.brief_title, t.phases, t.overall_status, t.study_type, t.start_date, t.enrollment_count, t.lead_sponsor, ti.name AS intervention_name, ti.match_type56        FROM trial_interventions ti JOIN clinical_trials t ON t.id = ti.trial_id WHERE ti.drug_id = ${id}57        ORDER BY t.last_update_posted_date DESC NULLS LAST LIMIT 200`),58      db.execute<Record<string, unknown>>(sql`SELECT g.id, g.symbol, g.name FROM drugs d CROSS JOIN LATERAL unnest(d.target_gene_ids) gid JOIN genes g ON g.id = gid WHERE d.id = ${id}`),59    ]);60    const data = {61      ...camel(drug[0]!),62      targets: camelRows(targets),63      aliases: camelRows(aliases),64      evidenceByCancer: evidence.map((r) => ({65        cancer: r.cancer_id ? { id: r.cancer_id, slug: r.cancer_slug, name: r.cancer_name } : null,66        diseaseName: r.disease_name,67        evidenceItems: num(r.evidence_items),68        byLevel: { A: num(r.level_a), B: num(r.level_b), C: num(r.level_c), DE: num(r.level_de) },69        byDirection: { supports: num(r.supports), doesNotSupport: num(r.does_not_support) },70        bySignificance: { sensitivity: num(r.sensitivity), resistance: num(r.resistance) },71        geneSymbols: r.gene_symbols ?? [],72        civicEvidenceIds: r.civic_ids,73        sourceId: r.source_id,74        category: 'curated_evidence',75      })),76      approvals: approvals.map((r) => {77        const { cancer_slug, cancer_name, source_url, retrieved_at, dataset_version, raw: _raw, provenance_id: _p, ...rest } = r;78        return { ...camel(rest), cancer: r.cancer_id ? { id: r.cancer_id, slug: cancer_slug, name: cancer_name } : null, provenance: { sourceId: r.source_id, url: source_url, retrievedAt: retrieved_at, datasetVersion: dataset_version, category: 'regulatory_status' } };79      }),80      trials: camelRows(trials),81    };82    return respond(app, data, [...pluck(aliases, 'source_id'), ...pluck(evidence, 'source_id'), ...pluck(approvals, 'source_id'), ...(trials.length ? ['clinicaltrials'] : [])]);83  });84};85