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%
11.0 KB · 196 lines typescript
Raw Blame History
1import 'server-only';2import { run, sql, safe } from '@/lib/db';3import type { SQL } from 'drizzle-orm';4import type { ApprovalRow } from '@/lib/queries/drugs';56export const APPROVALS_PAGE_SIZE = 50;7export const PIPELINE_PAGE_SIZE = 50;8/** Funnel order (least → most advanced); `withdrawn` is shown apart from the funnel. */9export const PIPELINE_FUNNEL = ['phase1', 'phase2', 'phase3', 'phase4', 'approved'] as const;10export const PIPELINE_STAGES = ['phase_not_stated', ...PIPELINE_FUNNEL, 'withdrawn'] as const;11export type PipelineStage = (typeof PIPELINE_STAGES)[number];12export const APPROVED_STATUSES = ['approved', 'accelerated', 'conditional'] as const;13const MAX_DEPTH = 12;1415/** Feed row: an approval record plus the verbatim upstream status when the source has one (DPD). */16export interface FeedApprovalRow extends ApprovalRow {17  source_status: string | null;18}1920export interface ApprovalFilters {21  authority: string;22  jurisdiction: string;23  cancer: string; // slug24  status: string;25  year: string;26  q: string;27}2829const FEED_SELECT = sql`30  SELECT a.*, a.raw->>'dpdStatus' AS source_status, d.slug AS drug_slug, d.name AS drug_name, c.slug AS cancer_slug, c.canonical_name AS cancer_name, s.slug AS source_slug, s.name AS source_name31  FROM drug_approvals a JOIN drugs d ON d.id = a.drug_id LEFT JOIN cancers c ON c.id = a.cancer_id JOIN sources s ON s.id = a.source_id`;3233function feedWhere(f: ApprovalFilters): SQL {34  const parts: SQL[] = [sql`true`];35  if (f.authority) parts.push(sql`lower(a.authority) = lower(${f.authority})`);36  if (f.jurisdiction) parts.push(sql`upper(a.jurisdiction) = upper(${f.jurisdiction})`);37  if (f.status) parts.push(sql`a.status = ${f.status}`);38  if (/^\d{4}$/.test(f.year)) parts.push(sql`a.approval_date >= ${`${f.year}-01-01`} AND a.approval_date <= ${`${f.year}-12-31`}`);39  if (f.cancer) {40    parts.push(sql`a.cancer_id IN (41      WITH RECURSIVE dsc AS (42        SELECT id, 0 AS depth FROM cancers WHERE slug = ${f.cancer}43        UNION44        SELECT h.child_id, dsc.depth + 1 FROM dsc JOIN cancer_hierarchy h ON h.parent_id = dsc.id WHERE dsc.depth < ${MAX_DEPTH}45      ) SELECT id FROM dsc)`);46  }47  if (f.q) {48    const like = `%${f.q}%`;49    parts.push(sql`(d.name ILIKE ${like} OR a.indication ILIKE ${like} OR EXISTS (SELECT 1 FROM drug_aliases al WHERE al.drug_id = a.drug_id AND al.alias ILIKE ${like}))`);50  }51  return sql.join(parts, sql` AND `);52}5354export async function countApprovals(f: ApprovalFilters): Promise<number> {55  const r = await safe(() => run<{ n: string }>(sql`SELECT count(*) AS n FROM drug_approvals a JOIN drugs d ON d.id = a.drug_id WHERE ${feedWhere(f)}`), [{ n: '0' }]);56  return Number(r[0]?.n ?? 0);57}5859/** Feed page, latest approval date first (undated records last). */60export async function listApprovals(f: ApprovalFilters, page: number, pageSize = APPROVALS_PAGE_SIZE): Promise<FeedApprovalRow[]> {61  return safe(() => run<FeedApprovalRow>(sql`${FEED_SELECT} WHERE ${feedWhere(f)} ORDER BY a.approval_date DESC NULLS LAST, a.id DESC LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`), [] as FeedApprovalRow[]);62}6364export interface AuthorityStat {65  authority: string;66  jurisdiction: string;67  n: number;68  distinct_drugs: number;69  approved_like: number;70  withdrawn: number;71  last_12m: number;72  latest_date: string | null;73  updated_at: Date | null;74}7576export async function approvalStats(): Promise<{ byAuthority: AuthorityStat[]; total: number; distinctDrugs: number; withCancer: number; last12m: number }> {77  const since = new Date(Date.now() - 365 * 86_400_000).toISOString().slice(0, 10);78  const byAuthority = await safe(79    () =>80      run<AuthorityStat>(sql`81        SELECT a.authority, a.jurisdiction, count(*)::int AS n, count(DISTINCT a.drug_id)::int AS distinct_drugs,82          count(*) FILTER (WHERE a.status IN ('approved','accelerated','conditional'))::int AS approved_like,83          count(*) FILTER (WHERE a.status = 'withdrawn')::int AS withdrawn,84          count(*) FILTER (WHERE a.approval_date >= ${since} AND a.approval_date <= to_char(now(), 'YYYY-MM-DD'))::int AS last_12m,85          max(a.approval_date) FILTER (WHERE a.approval_date <= to_char(now(), 'YYYY-MM-DD')) AS latest_date, max(a.updated_at) AS updated_at86        FROM drug_approvals a GROUP BY a.authority, a.jurisdiction ORDER BY n DESC`),87    [] as AuthorityStat[],88  );89  const totals = await safe(() => run<{ total: string; drugs: string; with_cancer: string }>(sql`SELECT count(*) AS total, count(DISTINCT drug_id) AS drugs, count(cancer_id) AS with_cancer FROM drug_approvals`), [{ total: '0', drugs: '0', with_cancer: '0' }]);90  return { byAuthority, total: Number(totals[0]?.total ?? 0), distinctDrugs: Number(totals[0]?.drugs ?? 0), withCancer: Number(totals[0]?.with_cancer ?? 0), last12m: byAuthority.reduce((s, a) => s + a.last_12m, 0) };91}9293export async function approvalFacets(): Promise<{ authorities: Array<{ authority: string; jurisdiction: string; n: number }>; statuses: Array<{ status: string; n: number }>; years: Array<{ year: string; n: number }> }> {94  const [authorities, statuses, years] = await Promise.all([95    safe(() => run<{ authority: string; jurisdiction: string; n: number }>(sql`SELECT authority, jurisdiction, count(*)::int AS n FROM drug_approvals GROUP BY 1, 2 ORDER BY n DESC`), []),96    safe(() => run<{ status: string; n: number }>(sql`SELECT status, count(*)::int AS n FROM drug_approvals GROUP BY 1 ORDER BY n DESC`), []),97    safe(() => run<{ year: string; n: number }>(sql`SELECT left(approval_date, 4) AS year, count(*)::int AS n FROM drug_approvals WHERE approval_date ~ '^\\d{4}' GROUP BY 1 ORDER BY 1 DESC`), []),98  ]);99  return { authorities, statuses, years };100}101102/** Last dated approvals across authorities (no future-dated records). */103export async function recentApprovals(limit = 8): Promise<FeedApprovalRow[]> {104  return safe(() => run<FeedApprovalRow>(sql`${FEED_SELECT} WHERE a.approval_date IS NOT NULL AND a.approval_date <= to_char(now(), 'YYYY-MM-DD') ORDER BY a.approval_date DESC, a.id DESC LIMIT ${limit}`), [] as FeedApprovalRow[]);105}106107/* ------------------------------------------------------------------------------------------------108 * Pipeline (derived, drug_pipeline)109 * ---------------------------------------------------------------------------------------------- */110111export interface PipelineRow {112  id: number;113  drug_id: string;114  drug_slug: string;115  drug_name: string;116  drug_kind: string | null;117  cancer_id: string | null;118  cancer_slug: string | null;119  cancer_name: string | null;120  stage: PipelineStage;121  max_phase: string | null;122  active_trials: number;123  recruiting_trials: number;124  phase3_trials: number;125  total_trials: number;126  approvals: number;127  jurisdictions: string[];128  first_approval_date: string | null;129  latest_approval_date: string | null;130  first_trial_date: string | null;131  formula_version: string;132  computed_at: Date;133}134135const PIPELINE_SELECT = sql`136  SELECT p.*, p.updated_at AS computed_at, d.slug AS drug_slug, d.name AS drug_name, d.kind AS drug_kind, c.slug AS cancer_slug, c.canonical_name AS cancer_name137  FROM drug_pipeline p JOIN drugs d ON d.id = p.drug_id LEFT JOIN cancers c ON c.id = p.cancer_id`;138const STAGE_RANK = sql`CASE p.stage WHEN 'approved' THEN 6 WHEN 'phase4' THEN 5 WHEN 'phase3' THEN 4 WHEN 'phase2' THEN 3 WHEN 'phase1' THEN 2 WHEN 'phase_not_stated' THEN 1 ELSE 0 END`;139const scopeWhere = (cancerId: string | null) => (cancerId ? sql`p.cancer_id = ${cancerId}` : sql`p.cancer_id IS NULL`);140141export async function topLevelCancerOptions(): Promise<Array<{ id: string; slug: string; canonical_name: string; drugs: number }>> {142  return safe(143    () =>144      run<{ id: string; slug: string; canonical_name: string; drugs: number }>(sql`145        SELECT c.id, c.slug, c.canonical_name, (SELECT count(*) FROM drug_pipeline p WHERE p.cancer_id = c.id)::int AS drugs146        FROM cancers c WHERE c.top_level AND c.status = 'active' ORDER BY c.canonical_name`),147    [],148  );149}150151export async function pipelineSummary(cancerId: string | null): Promise<{ stages: Array<{ stage: PipelineStage; drugs: number; active_trials: number }>; drugs: number; formulaVersion: string | null; computedAt: Date | null }> {152  const rows = await safe(() => run<{ stage: PipelineStage; drugs: number; active_trials: number }>(sql`SELECT p.stage, count(*)::int AS drugs, coalesce(sum(p.active_trials), 0)::int AS active_trials FROM drug_pipeline p WHERE ${scopeWhere(cancerId)} GROUP BY p.stage`), []);153  const meta = await safe(() => run<{ formula_version: string | null; computed_at: Date | null }>(sql`SELECT max(formula_version) AS formula_version, max(updated_at) AS computed_at FROM drug_pipeline`), [{ formula_version: null, computed_at: null }]);154  const by = new Map(rows.map((r) => [r.stage, r]));155  return {156    stages: PIPELINE_STAGES.map((stage) => ({ stage, drugs: by.get(stage)?.drugs ?? 0, active_trials: by.get(stage)?.active_trials ?? 0 })),157    drugs: rows.reduce((s, r) => s + r.drugs, 0),158    formulaVersion: meta[0]?.formula_version ?? null,159    computedAt: meta[0]?.computed_at ?? null,160  };161}162163/** Up to `perStage` representative drugs per stage (most active trials first). */164export async function pipelineTopDrugsPerStage(cancerId: string | null, perStage = 5): Promise<Map<PipelineStage, PipelineRow[]>> {165  const rows = await safe(166    () =>167      run<PipelineRow>(sql`168        SELECT * FROM (SELECT p.*, p.updated_at AS computed_at, d.slug AS drug_slug, d.name AS drug_name, d.kind AS drug_kind, c.slug AS cancer_slug, c.canonical_name AS cancer_name,169          row_number() OVER (PARTITION BY p.stage ORDER BY p.active_trials DESC, p.total_trials DESC, d.name) AS rn170          FROM drug_pipeline p JOIN drugs d ON d.id = p.drug_id LEFT JOIN cancers c ON c.id = p.cancer_id WHERE ${scopeWhere(cancerId)}) x171        WHERE rn <= ${perStage} ORDER BY stage, rn`),172    [] as PipelineRow[],173  );174  const out = new Map<PipelineStage, PipelineRow[]>();175  for (const r of rows) out.set(r.stage, [...(out.get(r.stage) ?? []), r]);176  return out;177}178179export async function countPipelineRows(cancerId: string | null, stage: string): Promise<number> {180  const r = await safe(() => run<{ n: string }>(sql`SELECT count(*) AS n FROM drug_pipeline p WHERE ${scopeWhere(cancerId)} ${stage ? sql`AND p.stage = ${stage}` : sql``}`), [{ n: '0' }]);181  return Number(r[0]?.n ?? 0);182}183184export async function listPipelineRows(cancerId: string | null, stage: string, page: number, pageSize = PIPELINE_PAGE_SIZE): Promise<PipelineRow[]> {185  return safe(186    () => run<PipelineRow>(sql`${PIPELINE_SELECT} WHERE ${scopeWhere(cancerId)} ${stage ? sql`AND p.stage = ${stage}` : sql``} ORDER BY ${STAGE_RANK} DESC, p.active_trials DESC, p.total_trials DESC, d.name LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`),187    [] as PipelineRow[],188  );189}190191/** Sources feeding the derived pipeline (registry + regulatory sources present in drug_approvals). */192export async function pipelineSourceSlugs(): Promise<string[]> {193  const rows = await safe(() => run<{ slug: string }>(sql`SELECT DISTINCT s.slug FROM sources s WHERE s.slug = 'clinicaltrials' OR s.id IN (SELECT DISTINCT source_id FROM drug_approvals) ORDER BY 1`), [] as Array<{ slug: string }>);194  return rows.map((r) => r.slug);195}196