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%
8.3 KB · 161 lines typescript
Raw Blame History
1import 'server-only';2import { run, sql, safe } from '@/lib/db';3import { STOP_REASON_CATEGORIES, classifyStopReason, type StopReasonCategory } from '@cancerindex/ranking';45/** One `trial_intelligence` row joined with its cancer (snake_case as returned by the driver). */6export interface TrialIntelRow {7  cancer_id: string;8  cancer_slug: string;9  cancer_name: string;10  top_level: boolean;11  entity_level: 'top' | 'all';12  total_trials: number;13  active_trials: number;14  recruiting_trials: number;15  phase1_active: number;16  phase2_active: number;17  phase3_active: number;18  phase3_recruiting: number;19  phase4_active: number;20  completed_trials: number;21  terminated_trials: number;22  withdrawn_trials: number;23  suspended_trials: number;24  with_results: number;25  new_trials_12m: number;26  new_trials_prior_12m: number;27  trial_growth_yoy: number | null;28  avg_enrollment: number | null;29  median_enrollment: number | null;30  total_enrollment_active: number | null;31  distinct_sponsors: number;32  industry_share: number | null;33  sponsor_hhi: number | null;34  top_sponsor: string | null;35  top_sponsor_share: number | null;36  distinct_countries: number;37  us_share: number | null;38  top_country: string | null;39  top_country_share: number | null;40  country_hhi: number | null;41  termination_share: number | null;42  why_stopped_breakdown: Record<string, number>;43  trials_per_1000_deaths: number | null;44  trials_per_100k_cases: number | null;45  burden_geography: string | null;46  burden_year: number | null;47  burden_source_id: string | null;48  burden_source_slug: string | null;49  formula_version: string;50  inputs: Record<string, unknown>;51  computed_at: Date | string;52}5354const COLS = sql`ti.cancer_id, c.slug AS cancer_slug, c.canonical_name AS cancer_name, c.top_level, ti.entity_level,55  ti.total_trials, ti.active_trials, ti.recruiting_trials, ti.phase1_active, ti.phase2_active, ti.phase3_active, ti.phase3_recruiting, ti.phase4_active,56  ti.completed_trials, ti.terminated_trials, ti.withdrawn_trials, ti.suspended_trials, ti.with_results,57  ti.new_trials_12m, ti.new_trials_prior_12m, ti.trial_growth_yoy, ti.avg_enrollment, ti.median_enrollment, ti.total_enrollment_active,58  ti.distinct_sponsors, ti.industry_share, ti.sponsor_hhi, ti.top_sponsor, ti.top_sponsor_share,59  ti.distinct_countries, ti.us_share, ti.top_country, ti.top_country_share, ti.country_hhi,60  ti.termination_share, ti.why_stopped_breakdown, ti.trials_per_1000_deaths, ti.trials_per_100k_cases, ti.burden_geography, ti.burden_year, ti.burden_source_id,61  s.slug AS burden_source_slug, ti.formula_version, ti.inputs, ti.updated_at AS computed_at`;62const FROM = sql`FROM trial_intelligence ti JOIN cancers c ON c.id = ti.cancer_id LEFT JOIN sources s ON s.id = ti.burden_source_id`;6364/** Rows for one entity level, most active first (`limit` caps the "all" level to the most active entities). */65export async function listTrialIntelligence(level: 'top' | 'all', limit = 10_000): Promise<TrialIntelRow[]> {66  return safe(() => run<TrialIntelRow>(sql`SELECT ${COLS} ${FROM} WHERE ti.entity_level = ${level} AND c.status = 'active' ORDER BY ti.active_trials DESC, c.canonical_name LIMIT ${limit}`), [] as TrialIntelRow[]);67}6869/** Both levels for one cancer (a top-level cancer has two rows with identical figures). */70export async function trialIntelligenceFor(cancerId: string): Promise<{ all: TrialIntelRow | null; top: TrialIntelRow | null; primary: TrialIntelRow | null }> {71  const rows = await safe(() => run<TrialIntelRow>(sql`SELECT ${COLS} ${FROM} WHERE ti.cancer_id = ${cancerId}`), [] as TrialIntelRow[]);72  const all = rows.find((r) => r.entity_level === 'all') ?? null;73  const top = rows.find((r) => r.entity_level === 'top') ?? null;74  return { all, top, primary: all ?? top };75}7677/** Home module: top-level cancers with the most recruiting Phase III studies. */78export async function topPhase3Recruiting(limit = 8): Promise<TrialIntelRow[]> {79  return safe(() => run<TrialIntelRow>(sql`SELECT ${COLS} ${FROM} WHERE ti.entity_level = 'top' AND c.status = 'active' AND ti.phase3_recruiting > 0 ORDER BY ti.phase3_recruiting DESC, ti.recruiting_trials DESC, c.canonical_name LIMIT ${limit}`), [] as TrialIntelRow[]);80}8182export const STOPPED_STATUSES = ['TERMINATED', 'WITHDRAWN', 'SUSPENDED'] as const;8384export interface TerminatedFilters {85  cancerIds: string[] | null;86  reason: StopReasonCategory | '';87  status: string;88  since: number | null;89  page: number;90  pageSize: number;91}9293export interface TerminatedRow {94  id: string;95  nct_id: string;96  brief_title: string;97  acronym: string | null;98  study_type: string | null;99  phases: string[];100  overall_status: string | null;101  why_stopped: string | null;102  first_posted_date: string | null;103  last_update_posted_date: string | null;104  enrollment_count: number | null;105  lead_sponsor: string | null;106  lead_sponsor_class: string | null;107  updated_at: Date | string;108  reason_category: StopReasonCategory;109  reason_matches: string[];110}111112function stoppedWhere(f: TerminatedFilters) {113  const parts = [sql`t.overall_status IN ('TERMINATED','WITHDRAWN','SUSPENDED')`];114  if (f.status && (STOPPED_STATUSES as readonly string[]).includes(f.status)) parts.push(sql`t.overall_status = ${f.status}`);115  if (f.since) parts.push(sql`t.first_posted_date >= ${`${f.since}-01-01`}`);116  if (f.cancerIds) parts.push(sql`EXISTS (SELECT 1 FROM trial_conditions tc WHERE tc.trial_id = t.id AND tc.cancer_id = ANY(${sql.param(f.cancerIds)}::text[]))`);117  return sql.join(parts, sql` AND `);118}119120/**121 * Terminated / withdrawn / suspended studies with their registrant-reported reason classified by122 * keyword rules. The category is not stored, so a light pass classifies the whole filtered set (id +123 * text) to build the breakdown and apply the reason filter; full columns are fetched for the page only.124 */125export async function listTerminated(f: TerminatedFilters): Promise<{ rows: TerminatedRow[]; total: number; stopped: number; breakdown: Record<StopReasonCategory, number> }> {126  const breakdown = Object.fromEntries(STOP_REASON_CATEGORIES.map((c) => [c, 0])) as Record<StopReasonCategory, number>;127  if (f.cancerIds && f.cancerIds.length === 0) return { rows: [], total: 0, stopped: 0, breakdown };128  const light = await safe(() => run<{ id: string; why_stopped: string | null }>(sql`SELECT t.id, t.why_stopped FROM clinical_trials t WHERE ${stoppedWhere(f)} ORDER BY t.last_update_posted_date DESC NULLS LAST, t.nct_id`), [] as Array<{ id: string; why_stopped: string | null }>);129  const classified = light.map((r) => {130    const c = classifyStopReason(r.why_stopped);131    breakdown[c.category] += 1;132    return { id: r.id, category: c.category, matched: c.matched };133  });134  const filtered = f.reason ? classified.filter((r) => r.category === f.reason) : classified;135  const offset = (Math.max(1, f.page) - 1) * f.pageSize;136  const page = filtered.slice(offset, offset + f.pageSize);137  if (page.length === 0) return { rows: [], total: filtered.length, stopped: light.length, breakdown };138  const full = await safe(139    () =>140      run<Omit<TerminatedRow, 'reason_category' | 'reason_matches'>>(sql`141        SELECT t.id, t.nct_id, t.brief_title, t.acronym, t.study_type, t.phases, t.overall_status, t.why_stopped, t.first_posted_date, t.last_update_posted_date, t.enrollment_count, t.lead_sponsor, t.lead_sponsor_class, t.updated_at142        FROM clinical_trials t WHERE t.id = ANY(${sql.param(page.map((p) => p.id))}::text[])`),143    [] as Array<Omit<TerminatedRow, 'reason_category' | 'reason_matches'>>,144  );145  const order = new Map(page.map((p, i) => [p.id, i]));146  const byId = new Map(page.map((p) => [p.id, p]));147  const rows = full148    .sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0))149    .map((r) => {150      const c = byId.get(r.id)!;151      return { ...r, reason_category: c.category, reason_matches: c.matched } satisfies TerminatedRow;152    });153  return { rows, total: filtered.length, stopped: light.length, breakdown };154}155156/** Distinct first-posted years among stopped studies (for the year filter). */157export async function stoppedYears(): Promise<number[]> {158  const rows = await safe(() => run<{ y: number }>(sql`SELECT DISTINCT left(first_posted_date, 4)::int AS y FROM clinical_trials WHERE overall_status IN ('TERMINATED','WITHDRAWN','SUSPENDED') AND first_posted_date ~ '^\\d{4}' ORDER BY 1 DESC`), [] as Array<{ y: number }>);159  return rows.map((r) => Number(r.y)).filter((y) => Number.isFinite(y));160}161