Regulatory layer: Health Canada DPD connector (ATC L01/L02/L03/V10, DIN-level approval records, drug minting + codes), /approvals feed, drug development pipeline (drug_pipeline) with /pipeline, duplicate-drug merge proposals, drug page identifiers/pipeline, API
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
35 changed files +3,266 −17
modified
apps/api/src/routes/approvals.ts
+208 −5
@@ -1,10 +1,213 @@ | ||
| 1 | +import { sql, type SQL } from 'drizzle-orm'; | |
| 1 | 2 | import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; |
| 3 | +import { z } from 'zod'; | |
| 4 | +import { normalizeLabel } from '@cancerindex/shared'; | |
| 5 | +import { descendantIds } from '../lib/descendants.js'; | |
| 6 | +import { paginate } from '../lib/envelope.js'; | |
| 7 | +import { pageQuery } from '../lib/pagination.js'; | |
| 8 | +import { resolveCancer, resolveDrug } from '../lib/resolve.js'; | |
| 9 | +import { AnyList, AnyRecord, camel, num, ok, respond } from '../lib/respond.js'; | |
| 10 | +import { pluck } from '../lib/sources.js'; | |
| 11 | + | |
| 12 | +const PIPELINE_STAGES = ['phase_not_stated', 'phase1', 'phase2', 'phase3', 'phase4', 'approved', 'withdrawn'] as const; | |
| 13 | +const STAGE_RANK_SQL = sql.raw(`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 WHEN 'withdrawn' THEN 0 ELSE -1 END`); | |
| 14 | + | |
| 15 | +/** "2024" → "2024-01-01" (from) / "2024-12-31" (to); full ISO dates pass through. */ | |
| 16 | +function dateBound(v: string | undefined, edge: 'from' | 'to'): string | undefined { | |
| 17 | + if (!v) return undefined; | |
| 18 | + if (/^\d{4}$/.test(v)) return edge === 'from' ? `${v}-01-01` : `${v}-12-31`; | |
| 19 | + return v; | |
| 20 | +} | |
| 21 | + | |
| 22 | +const approvalSelect = (where: SQL, orderLimit: SQL) => sql` | |
| 23 | + SELECT a.id, a.drug_id, a.cancer_id, a.biomarker_ids, a.tumor_agnostic, a.jurisdiction, a.authority, a.indication, a.line_of_therapy, a.disease_stage, | |
| 24 | + a.approval_type, a.accelerated, a.conditional, a.approval_date, a.withdrawal_date, a.status, a.application_number, a.source_id, a.updated_at, | |
| 25 | + a.raw->>'dpdStatus' AS source_status, | |
| 26 | + d.slug AS drug_slug, d.name AS drug_name, c.slug AS cancer_slug, c.canonical_name AS cancer_name, | |
| 27 | + p.source_url, p.retrieved_at, p.dataset, p.dataset_version, count(*) OVER() AS total | |
| 28 | + FROM drug_approvals a JOIN drugs d ON d.id = a.drug_id LEFT JOIN cancers c ON c.id = a.cancer_id LEFT JOIN provenance p ON p.id = a.provenance_id | |
| 29 | + WHERE ${where} ${orderLimit}`; | |
| 30 | + | |
| 31 | +function shapeApproval(r: Record<string, unknown>): Record<string, unknown> { | |
| 32 | + const { total: _t, drug_slug, drug_name, cancer_slug, cancer_name, source_url, retrieved_at, dataset, dataset_version, source_status, ...rest } = r; | |
| 33 | + return { | |
| 34 | + ...camel(rest), | |
| 35 | + sourceStatus: source_status ?? null, | |
| 36 | + drug: { id: r.drug_id, slug: drug_slug, name: drug_name }, | |
| 37 | + cancer: r.cancer_id ? { id: r.cancer_id, slug: cancer_slug, name: cancer_name } : null, | |
| 38 | + provenance: { sourceId: r.source_id, url: source_url, retrievedAt: retrieved_at, dataset, datasetVersion: dataset_version, category: 'regulatory_status' }, | |
| 39 | + }; | |
| 40 | +} | |
| 2 | 41 | |
| 3 | 42 | /** |
| 4 | − * Regulatory routes (SPEC §13-15, §63): `GET /approvals` (jurisdiction-aware, dated, filterable by | |
| 5 | − * authority, cancer, drug, date range), `GET /approvals/recent`, `GET /pipeline` (drug development | |
| 6 | − * stages). Filled by the Approvals & Pipeline work package. | |
| 43 | + * Regulatory routes (SPEC §13-15, §63): jurisdiction-aware, dated approval records (one record = one | |
| 44 | + * authority's decision for one application / DIN — never a bare "approved"), a recent feed grouped | |
| 45 | + * by month, and the derived drug development pipeline (`drug_pipeline`, formula-versioned). | |
| 7 | 46 | */ |
| 8 | −export const approvalRoutes: FastifyPluginAsyncZod = async (_app) => { | |
| 9 | − /* routes added by the approvals work package */ | |
| 47 | +export const approvalRoutes: FastifyPluginAsyncZod = async (app) => { | |
| 48 | + app.get( | |
| 49 | + '/approvals', | |
| 50 | + { | |
| 51 | + schema: { | |
| 52 | + tags: ['regulatory'], | |
| 53 | + summary: 'Approval records (jurisdiction-aware, dated), filterable by authority, jurisdiction, cancer (with descendants), drug, status, date range, text', | |
| 54 | + querystring: z.object({ | |
| 55 | + authority: z.string().trim().min(1).max(60).optional().describe('e.g. FDA, Health Canada'), | |
| 56 | + jurisdiction: z.string().trim().min(2).max(8).optional().describe('e.g. US, CA'), | |
| 57 | + cancer: z.string().trim().min(1).optional().describe('CI-CAN id or slug; descendants included'), | |
| 58 | + drug: z.string().trim().min(1).optional().describe('CI-DRUG id or slug'), | |
| 59 | + status: z.enum(['approved', 'accelerated', 'conditional', 'withdrawn', 'superseded']).optional(), | |
| 60 | + from: z.string().regex(/^\d{4}(-\d{2}-\d{2})?$/).optional().describe('approval_date ≥ (YYYY or YYYY-MM-DD)'), | |
| 61 | + to: z.string().regex(/^\d{4}(-\d{2}-\d{2})?$/).optional().describe('approval_date ≤ (YYYY or YYYY-MM-DD)'), | |
| 62 | + q: z.string().trim().min(1).max(100).optional().describe('drug name/alias or indication text'), | |
| 63 | + ...pageQuery, | |
| 64 | + }), | |
| 65 | + response: ok(AnyList, true), | |
| 66 | + }, | |
| 67 | + }, | |
| 68 | + async (req) => { | |
| 69 | + const q = req.query; | |
| 70 | + const conds: SQL[] = [sql`true`]; | |
| 71 | + if (q.authority) conds.push(sql`lower(a.authority) = lower(${q.authority})`); | |
| 72 | + if (q.jurisdiction) conds.push(sql`upper(a.jurisdiction) = upper(${q.jurisdiction})`); | |
| 73 | + if (q.status) conds.push(sql`a.status = ${q.status}`); | |
| 74 | + const from = dateBound(q.from, 'from'); | |
| 75 | + const to = dateBound(q.to, 'to'); | |
| 76 | + if (from) conds.push(sql`a.approval_date >= ${from}`); | |
| 77 | + if (to) conds.push(sql`a.approval_date <= ${to}`); | |
| 78 | + if (q.cancer) { | |
| 79 | + const c = await resolveCancer(app.db, q.cancer); | |
| 80 | + const ids = await descendantIds(app.db, c.id); | |
| 81 | + conds.push(sql`a.cancer_id = ANY(${sql.param(ids)}::text[])`); | |
| 82 | + } | |
| 83 | + if (q.drug) { | |
| 84 | + const d = await resolveDrug(app.db, q.drug); | |
| 85 | + conds.push(sql`a.drug_id = ${d.id}`); | |
| 86 | + } | |
| 87 | + if (q.q) { | |
| 88 | + const norm = normalizeLabel(q.q); | |
| 89 | + conds.push(sql`(EXISTS (SELECT 1 FROM drug_aliases al WHERE al.drug_id = a.drug_id AND (al.normalized = ${norm} OR al.normalized LIKE ${norm + '%'})) OR a.indication ILIKE ${'%' + q.q + '%'})`); | |
| 90 | + } | |
| 91 | + const rows = await app.db.execute<Record<string, unknown> & { total: string }>(approvalSelect(sql.join(conds, sql` AND `), sql`ORDER BY a.approval_date DESC NULLS LAST, a.id DESC LIMIT ${q.limit} OFFSET ${q.offset}`)); | |
| 92 | + const total = rows.length ? num(rows[0]!.total) : 0; | |
| 93 | + return respond(app, rows.map(shapeApproval), pluck(rows, 'source_id'), paginate(total, q.limit, q.offset)); | |
| 94 | + }, | |
| 95 | + ); | |
| 96 | + | |
| 97 | + app.get( | |
| 98 | + '/approvals/recent', | |
| 99 | + { | |
| 100 | + schema: { | |
| 101 | + tags: ['regulatory'], | |
| 102 | + summary: 'Latest dated approval records across authorities, grouped by month (data.months)', | |
| 103 | + querystring: z.object({ days: z.coerce.number().int().min(1).max(3650).default(90), limit: z.coerce.number().int().min(1).max(500).default(100), authority: z.string().trim().min(1).max(60).optional() }), | |
| 104 | + response: ok(AnyRecord), | |
| 105 | + }, | |
| 106 | + }, | |
| 107 | + async (req) => { | |
| 108 | + const { days, limit, authority } = req.query; | |
| 109 | + const since = new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10); | |
| 110 | + const conds: SQL[] = [sql`a.approval_date IS NOT NULL`, sql`a.approval_date >= ${since}`, sql`a.approval_date <= to_char(now(), 'YYYY-MM-DD')`]; | |
| 111 | + if (authority) conds.push(sql`lower(a.authority) = lower(${authority})`); | |
| 112 | + const rows = await app.db.execute<Record<string, unknown> & { total: string }>(approvalSelect(sql.join(conds, sql` AND `), sql`ORDER BY a.approval_date DESC, a.id DESC LIMIT ${limit}`)); | |
| 113 | + const total = rows.length ? num(rows[0]!.total) : 0; | |
| 114 | + const months = new Map<string, Record<string, unknown>[]>(); | |
| 115 | + for (const r of rows) { | |
| 116 | + const m = String(r.approval_date).slice(0, 7); | |
| 117 | + months.set(m, [...(months.get(m) ?? []), shapeApproval(r)]); | |
| 118 | + } | |
| 119 | + const data = { | |
| 120 | + since, | |
| 121 | + days, | |
| 122 | + total, | |
| 123 | + returned: rows.length, | |
| 124 | + months: [...months.entries()].map(([month, approvals]) => ({ month, count: approvals.length, approvals })), | |
| 125 | + }; | |
| 126 | + return respond(app, data, pluck(rows, 'source_id')); | |
| 127 | + }, | |
| 128 | + ); | |
| 129 | + | |
| 130 | + app.get( | |
| 131 | + '/pipeline', | |
| 132 | + { | |
| 133 | + schema: { | |
| 134 | + tags: ['regulatory'], | |
| 135 | + summary: 'Drug development pipeline rows (derived): stage per drug, or per drug × top-level cancer', | |
| 136 | + querystring: z.object({ | |
| 137 | + cancer: z.string().trim().min(1).optional().describe('Top-level cancer (CI-CAN id or slug): drug × cancer rows'), | |
| 138 | + stage: z.enum(PIPELINE_STAGES).optional(), | |
| 139 | + drug: z.string().trim().min(1).optional().describe('CI-DRUG id or slug'), | |
| 140 | + scope: z.enum(['drug', 'cancer']).default('drug').describe('Without `cancer`: drug = across-all-cancers rows (default), cancer = every drug × top-level cancer row'), | |
| 141 | + ...pageQuery, | |
| 142 | + }), | |
| 143 | + response: ok(AnyList, true), | |
| 144 | + }, | |
| 145 | + }, | |
| 146 | + async (req) => { | |
| 147 | + const q = req.query; | |
| 148 | + const conds: SQL[] = []; | |
| 149 | + if (q.cancer) { | |
| 150 | + const c = await resolveCancer(app.db, q.cancer); | |
| 151 | + conds.push(sql`p.cancer_id = ${c.id}`); | |
| 152 | + } else conds.push(q.scope === 'cancer' ? sql`p.cancer_id IS NOT NULL` : sql`p.cancer_id IS NULL`); | |
| 153 | + if (q.stage) conds.push(sql`p.stage = ${q.stage}`); | |
| 154 | + if (q.drug) { | |
| 155 | + const d = await resolveDrug(app.db, q.drug); | |
| 156 | + conds.push(sql`p.drug_id = ${d.id}`); | |
| 157 | + } | |
| 158 | + const rows = await app.db.execute<Record<string, unknown> & { total: string }>(sql` | |
| 159 | + SELECT p.id, p.drug_id, p.cancer_id, p.stage, p.max_phase, p.active_trials, p.recruiting_trials, p.phase3_trials, p.total_trials, p.approvals, p.jurisdictions, | |
| 160 | + p.first_approval_date, p.latest_approval_date, p.first_trial_date, p.formula_version, p.inputs, p.updated_at AS computed_at, | |
| 161 | + d.slug AS drug_slug, d.name AS drug_name, c.slug AS cancer_slug, c.canonical_name AS cancer_name, count(*) OVER() AS total | |
| 162 | + FROM drug_pipeline p JOIN drugs d ON d.id = p.drug_id LEFT JOIN cancers c ON c.id = p.cancer_id | |
| 163 | + WHERE ${sql.join(conds, sql` AND `)} | |
| 164 | + ORDER BY ${STAGE_RANK_SQL} DESC, p.active_trials DESC, p.total_trials DESC, d.name LIMIT ${q.limit} OFFSET ${q.offset}`); | |
| 165 | + const total = rows.length ? num(rows[0]!.total) : 0; | |
| 166 | + const data = rows.map((r) => { | |
| 167 | + const { total: _t, drug_slug, drug_name, cancer_slug, cancer_name, ...rest } = r; | |
| 168 | + return { ...camel(rest), drug: { id: r.drug_id, slug: drug_slug, name: drug_name }, cancer: r.cancer_id ? { id: r.cancer_id, slug: cancer_slug, name: cancer_name } : null, category: 'computed_metric' }; | |
| 169 | + }); | |
| 170 | + return respond(app, data, await pipelineSources(rows.map((r) => r.drug_id as string)), paginate(total, q.limit, q.offset)); | |
| 171 | + }, | |
| 172 | + ); | |
| 173 | + | |
| 174 | + app.get( | |
| 175 | + '/pipeline/summary', | |
| 176 | + { | |
| 177 | + schema: { | |
| 178 | + tags: ['regulatory'], | |
| 179 | + summary: 'Drug counts per development stage (derived), globally or for one top-level cancer', | |
| 180 | + querystring: z.object({ cancer: z.string().trim().min(1).optional() }), | |
| 181 | + response: ok(AnyRecord), | |
| 182 | + }, | |
| 183 | + }, | |
| 184 | + async (req) => { | |
| 185 | + let cancer: { id: string; slug: string } | null = null; | |
| 186 | + if (req.query.cancer) cancer = await resolveCancer(app.db, req.query.cancer); | |
| 187 | + const where = cancer ? sql`p.cancer_id = ${cancer.id}` : sql`p.cancer_id IS NULL`; | |
| 188 | + const rows = await app.db.execute<{ stage: string; drugs: string; active_trials: string; total_trials: string }>(sql` | |
| 189 | + SELECT p.stage, count(*) AS drugs, coalesce(sum(p.active_trials), 0) AS active_trials, coalesce(sum(p.total_trials), 0) AS total_trials | |
| 190 | + FROM drug_pipeline p WHERE ${where} GROUP BY p.stage`); | |
| 191 | + const meta = await app.db.execute<{ formula_version: string | null; computed_at: Date | null; rows: string }>(sql`SELECT max(formula_version) AS formula_version, max(updated_at) AS computed_at, count(*) AS rows FROM drug_pipeline`); | |
| 192 | + const byStage = Object.fromEntries(PIPELINE_STAGES.map((s) => [s, { drugs: 0, activeTrials: 0, totalTrials: 0 }])); | |
| 193 | + for (const r of rows) byStage[r.stage] = { drugs: num(r.drugs), activeTrials: num(r.active_trials), totalTrials: num(r.total_trials) }; | |
| 194 | + const data = { | |
| 195 | + cancer: cancer ? { id: cancer.id, slug: cancer.slug } : null, | |
| 196 | + stages: PIPELINE_STAGES.map((stage) => ({ stage, ...byStage[stage]! })), | |
| 197 | + drugs: rows.reduce((s, r) => s + num(r.drugs), 0), | |
| 198 | + formulaVersion: meta[0]?.formula_version ?? null, | |
| 199 | + computedAt: meta[0]?.computed_at ?? null, | |
| 200 | + category: 'computed_metric', | |
| 201 | + }; | |
| 202 | + return respond(app, data, await pipelineSources([])); | |
| 203 | + }, | |
| 204 | + ); | |
| 205 | + | |
| 206 | + /** Sources behind derived pipeline rows: the trial registry plus the regulatory sources of the drugs' approvals. */ | |
| 207 | + async function pipelineSources(drugIds: string[]): Promise<string[]> { | |
| 208 | + const regs = drugIds.length | |
| 209 | + ? await app.db.execute<{ source_id: string }>(sql`SELECT DISTINCT source_id FROM drug_approvals WHERE drug_id = ANY(${sql.param(drugIds)}::text[])`) | |
| 210 | + : await app.db.execute<{ source_id: string }>(sql`SELECT DISTINCT source_id FROM drug_approvals`); | |
| 211 | + return ['clinicaltrials', ...pluck(regs, 'source_id')]; | |
| 212 | + } | |
| 10 | 213 | }; |
added
apps/web/src/app/approvals/loading.tsx
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +import { PageSkeleton } from '@/components/ui/skeleton'; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return <PageSkeleton title="Loading oncology approvals" />; | |
| 5 | +} | |
added
apps/web/src/app/approvals/page.tsx
+275 −0
@@ -0,0 +1,275 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { PageHeader, Section, Note } from '@/components/ui/section'; | |
| 4 | +import { Badge, ClaimBadge, StatusBadge } from '@/components/ui/badge'; | |
| 5 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 6 | +import { Freshness } from '@/components/ui/freshness'; | |
| 7 | +import { Pager } from '@/components/ui/pager'; | |
| 8 | +import { SourceBadge, TableProvenance } from '@/components/ui/source-badge'; | |
| 9 | +import { APPROVALS_PAGE_SIZE, approvalFacets, approvalStats, countApprovals, listApprovals, type ApprovalFilters, type FeedApprovalRow } from '@/lib/queries/approvals'; | |
| 10 | +import { loadProvenance, toInfo } from '@/lib/queries/provenance'; | |
| 11 | +import { fmtDate, fmtInt, truncate } from '@/lib/format'; | |
| 12 | +import { pageInfo } from '@/lib/pagination'; | |
| 13 | +import { str, int, withParams, type SP } from '@/lib/search-params'; | |
| 14 | + | |
| 15 | +export const metadata: Metadata = { | |
| 16 | + title: 'Oncology approvals', | |
| 17 | + description: 'Regulatory approval records for oncology drugs by authority and jurisdiction — dated, sourced, one record per application or DIN.', | |
| 18 | + alternates: { canonical: '/approvals' }, | |
| 19 | +}; | |
| 20 | +// Filtered feed: rendered per request (searchParams); 600 s is the CDN/ISR hint shared by list pages. | |
| 21 | +export const revalidate = 600; | |
| 22 | + | |
| 23 | +const STATUSES = ['approved', 'accelerated', 'conditional', 'withdrawn', 'superseded']; | |
| 24 | + | |
| 25 | +function monthKey(d: string | null): string { | |
| 26 | + return d && /^\d{4}-\d{2}/.test(d) ? d.slice(0, 7) : 'undated'; | |
| 27 | +} | |
| 28 | +function monthLabel(k: string): string { | |
| 29 | + return k === 'undated' ? 'Date not stated' : fmtDate(`${k}-01`, { year: 'numeric', month: 'long' }); | |
| 30 | +} | |
| 31 | + | |
| 32 | +export default async function ApprovalsPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 33 | + const sp = await searchParams; | |
| 34 | + const f: ApprovalFilters = { authority: str(sp, 'authority'), jurisdiction: str(sp, 'jurisdiction'), cancer: str(sp, 'cancer'), status: STATUSES.includes(str(sp, 'status')) ? str(sp, 'status') : '', year: /^\d{4}$/.test(str(sp, 'year')) ? str(sp, 'year') : '', q: str(sp, 'q').slice(0, 100) }; | |
| 35 | + const requestedPage = int(sp, 'page', 1, 1, 100_000); | |
| 36 | + const [stats, facets, total] = await Promise.all([approvalStats(), approvalFacets(), countApprovals(f)]); | |
| 37 | + const info = pageInfo(requestedPage, APPROVALS_PAGE_SIZE, total); | |
| 38 | + const rows = total ? await listApprovals(f, info.page) : []; | |
| 39 | + const prov = await loadProvenance(rows.map((r) => r.provenance_id)); | |
| 40 | + const filtered = Object.values(f).some(Boolean); | |
| 41 | + const href = (o: Record<string, string | number | null | undefined>) => `/approvals${withParams({ ...f, page: info.page > 1 ? info.page : '' }, o)}`; | |
| 42 | + | |
| 43 | + // Group the page's rows by month, latest first (rows arrive sorted by approval_date desc). | |
| 44 | + const groups: Array<{ key: string; rows: FeedApprovalRow[] }> = []; | |
| 45 | + for (const r of rows) { | |
| 46 | + const key = monthKey(r.approval_date); | |
| 47 | + const g = groups[groups.length - 1]; | |
| 48 | + if (g && g.key === key) g.rows.push(r); | |
| 49 | + else groups.push({ key, rows: [r] }); | |
| 50 | + } | |
| 51 | + const latestUpdate = stats.byAuthority.reduce<Date | null>((m, a) => (a.updated_at && (!m || new Date(a.updated_at) > m) ? new Date(a.updated_at) : m), null); | |
| 52 | + | |
| 53 | + return ( | |
| 54 | + <div> | |
| 55 | + <PageHeader kicker="Regulatory" title="Oncology approvals" lede="Every record is one authority's decision for one application or DIN, shown with its jurisdiction, date, indication text as published and status. A molecule approved in one jurisdiction for one indication is not 'approved' in general."> | |
| 56 | + <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px]"> | |
| 57 | + <ClaimBadge kind="regulatory" /> | |
| 58 | + <Link className="ci-link" href="/pipeline"> | |
| 59 | + Development pipeline → | |
| 60 | + </Link> | |
| 61 | + <Link className="ci-link" href="/methodology/pipeline"> | |
| 62 | + Methodology | |
| 63 | + </Link> | |
| 64 | + </p> | |
| 65 | + </PageHeader> | |
| 66 | + | |
| 67 | + {stats.total === 0 ? ( | |
| 68 | + <EmptyState knows={[{ label: 'Drugs', href: '/drugs' }, { label: 'Sources', href: '/sources' }]}>No regulatory connector has run on this environment yet.</EmptyState> | |
| 69 | + ) : ( | |
| 70 | + <> | |
| 71 | + <Section id="overview" kicker="Coverage" title="Approval records by authority" description="Counts of records, not of drugs approved: one application can carry several dated records (original approval, efficacy supplements, one row per DIN in Canada)."> | |
| 72 | + <div className="grid gap-3 sm:grid-cols-4"> | |
| 73 | + {[ | |
| 74 | + { k: 'Approval records', v: stats.total }, | |
| 75 | + { k: 'Distinct drugs', v: stats.distinctDrugs }, | |
| 76 | + { k: 'Records naming a cancer', v: stats.withCancer }, | |
| 77 | + { k: 'Dated in the last 12 months', v: stats.last12m }, | |
| 78 | + ].map((t) => ( | |
| 79 | + <div key={t.k} className="border border-rule bg-paper-2 px-3 py-2"> | |
| 80 | + <p className="ci-kicker">{t.k}</p> | |
| 81 | + <p className="ci-num text-xl font-medium">{fmtInt(t.v)}</p> | |
| 82 | + </div> | |
| 83 | + ))} | |
| 84 | + </div> | |
| 85 | + <div className="ci-table-wrap mt-3"> | |
| 86 | + <table className="ci-table"> | |
| 87 | + <thead> | |
| 88 | + <tr> | |
| 89 | + <th scope="col">Authority</th> | |
| 90 | + <th scope="col">Jurisdiction</th> | |
| 91 | + <th scope="col" className="num">Records</th> | |
| 92 | + <th scope="col" className="num">Drugs</th> | |
| 93 | + <th scope="col" className="num">In force</th> | |
| 94 | + <th scope="col" className="num">Withdrawn / cancelled</th> | |
| 95 | + <th scope="col" className="num">Last 12 months</th> | |
| 96 | + <th scope="col">Latest dated record</th> | |
| 97 | + </tr> | |
| 98 | + </thead> | |
| 99 | + <tbody> | |
| 100 | + {stats.byAuthority.map((a) => ( | |
| 101 | + <tr key={`${a.authority}-${a.jurisdiction}`}> | |
| 102 | + <td> | |
| 103 | + <Link className="ci-link" href={href({ authority: a.authority, jurisdiction: a.jurisdiction, page: '' })}> | |
| 104 | + {a.authority} | |
| 105 | + </Link> | |
| 106 | + </td> | |
| 107 | + <td className="ci-mono">{a.jurisdiction}</td> | |
| 108 | + <td className="num">{fmtInt(a.n)}</td> | |
| 109 | + <td className="num">{fmtInt(a.distinct_drugs)}</td> | |
| 110 | + <td className="num">{fmtInt(a.approved_like)}</td> | |
| 111 | + <td className="num">{fmtInt(a.withdrawn)}</td> | |
| 112 | + <td className="num">{fmtInt(a.last_12m)}</td> | |
| 113 | + <td className="whitespace-nowrap">{fmtDate(a.latest_date)}</td> | |
| 114 | + </tr> | |
| 115 | + ))} | |
| 116 | + </tbody> | |
| 117 | + </table> | |
| 118 | + </div> | |
| 119 | + <Freshness dataUpdatedAt={latestUpdate} extra={`${stats.byAuthority.length} authority · jurisdiction pair${stats.byAuthority.length === 1 ? '' : 's'} ingested`} /> | |
| 120 | + </Section> | |
| 121 | + | |
| 122 | + <Section id="feed" kicker="Feed" title={`Approval records (${fmtInt(total)})`} description="Latest approval date first; records without a date are listed last. Filters combine."> | |
| 123 | + <form method="get" action="/approvals" className="flex flex-wrap items-end gap-2 border-y border-rule py-3 text-[13.5px]"> | |
| 124 | + <label className="flex flex-col gap-1"> | |
| 125 | + <span className="ci-kicker">Authority</span> | |
| 126 | + <select name="authority" defaultValue={f.authority} className="border border-rule-strong bg-white px-2 py-1.5"> | |
| 127 | + <option value="">Any</option> | |
| 128 | + {[...new Map(facets.authorities.map((a) => [a.authority, a])).values()].map((a) => ( | |
| 129 | + <option key={a.authority} value={a.authority}> | |
| 130 | + {a.authority} | |
| 131 | + </option> | |
| 132 | + ))} | |
| 133 | + </select> | |
| 134 | + </label> | |
| 135 | + <label className="flex flex-col gap-1"> | |
| 136 | + <span className="ci-kicker">Jurisdiction</span> | |
| 137 | + <select name="jurisdiction" defaultValue={f.jurisdiction} className="border border-rule-strong bg-white px-2 py-1.5"> | |
| 138 | + <option value="">Any</option> | |
| 139 | + {[...new Set(facets.authorities.map((a) => a.jurisdiction))].sort().map((j) => ( | |
| 140 | + <option key={j} value={j}> | |
| 141 | + {j} | |
| 142 | + </option> | |
| 143 | + ))} | |
| 144 | + </select> | |
| 145 | + </label> | |
| 146 | + <label className="flex flex-col gap-1"> | |
| 147 | + <span className="ci-kicker">Status</span> | |
| 148 | + <select name="status" defaultValue={f.status} className="border border-rule-strong bg-white px-2 py-1.5"> | |
| 149 | + <option value="">Any</option> | |
| 150 | + {facets.statuses.map((s) => ( | |
| 151 | + <option key={s.status} value={s.status}> | |
| 152 | + {s.status} ({fmtInt(s.n)}) | |
| 153 | + </option> | |
| 154 | + ))} | |
| 155 | + </select> | |
| 156 | + </label> | |
| 157 | + <label className="flex flex-col gap-1"> | |
| 158 | + <span className="ci-kicker">Year</span> | |
| 159 | + <select name="year" defaultValue={f.year} className="border border-rule-strong bg-white px-2 py-1.5"> | |
| 160 | + <option value="">Any</option> | |
| 161 | + {facets.years.map((y) => ( | |
| 162 | + <option key={y.year} value={y.year}> | |
| 163 | + {y.year} ({fmtInt(y.n)}) | |
| 164 | + </option> | |
| 165 | + ))} | |
| 166 | + </select> | |
| 167 | + </label> | |
| 168 | + <label className="flex flex-col gap-1"> | |
| 169 | + <span className="ci-kicker">Cancer slug</span> | |
| 170 | + <input name="cancer" defaultValue={f.cancer} placeholder="e.g. malignant-lung-neoplasm" className="border border-rule-strong bg-white px-2 py-1.5 outline-none focus:border-accent" /> | |
| 171 | + </label> | |
| 172 | + <label className="flex flex-col gap-1"> | |
| 173 | + <span className="ci-kicker">Drug or indication text</span> | |
| 174 | + <input name="q" defaultValue={f.q} placeholder="e.g. osimertinib, Keytruda, NSCLC" className="border border-rule-strong bg-white px-2 py-1.5 outline-none focus:border-accent" /> | |
| 175 | + </label> | |
| 176 | + <button type="submit" className="border border-ink bg-ink px-3 py-1.5 text-paper hover:bg-ink-2"> | |
| 177 | + Apply | |
| 178 | + </button> | |
| 179 | + {filtered ? ( | |
| 180 | + <Link className="ci-link text-[12.5px]" href="/approvals"> | |
| 181 | + Clear | |
| 182 | + </Link> | |
| 183 | + ) : null} | |
| 184 | + </form> | |
| 185 | + | |
| 186 | + {rows.length === 0 ? ( | |
| 187 | + <div className="mt-3"> | |
| 188 | + <EmptyState title={filtered ? 'No approval record matches' : 'Data not yet available'} knows={[{ label: 'Drugs', href: '/drugs' }, { label: 'Pipeline', href: '/pipeline' }]}> | |
| 189 | + {filtered ? 'Try fewer filters. Cancer filters only match records whose indication text named exactly one cancer; Health Canada records never name a cancer.' : 'No regulatory approval records have been ingested yet.'} | |
| 190 | + </EmptyState> | |
| 191 | + </div> | |
| 192 | + ) : ( | |
| 193 | + <> | |
| 194 | + {groups.map((g) => { | |
| 195 | + const first = g.rows[0]!; | |
| 196 | + const caption = toInfo(prov.get(first.provenance_id)) ?? { sourceSlug: first.source_slug, sourceName: first.source_name }; | |
| 197 | + const sources = new Set(g.rows.map((r) => r.source_slug)); | |
| 198 | + return ( | |
| 199 | + <section key={g.key} aria-labelledby={`m-${g.key}`} className="mt-5"> | |
| 200 | + <h3 id={`m-${g.key}`} className="text-lg"> | |
| 201 | + {monthLabel(g.key)} <span className="text-[13px] font-normal text-ink-3">· {fmtInt(g.rows.length)} record{g.rows.length === 1 ? '' : 's'} on this page</span> | |
| 202 | + </h3> | |
| 203 | + <TableProvenance p={caption} claim={<ClaimBadge kind="regulatory" />}> | |
| 204 | + {sources.size > 1 ? `${sources.size} sources in this month (hover a row badge for its dataset)` : 'authority, jurisdiction, dates and indication text as published'} | |
| 205 | + </TableProvenance> | |
| 206 | + <div className="ci-table-wrap"> | |
| 207 | + <table className="ci-table"> | |
| 208 | + <thead> | |
| 209 | + <tr> | |
| 210 | + <th scope="col">Date</th> | |
| 211 | + <th scope="col">Drug</th> | |
| 212 | + <th scope="col">Authority · jurisdiction</th> | |
| 213 | + <th scope="col">Cancer</th> | |
| 214 | + <th scope="col">Indication (as published)</th> | |
| 215 | + <th scope="col">Type</th> | |
| 216 | + <th scope="col">Status</th> | |
| 217 | + <th scope="col">Source</th> | |
| 218 | + </tr> | |
| 219 | + </thead> | |
| 220 | + <tbody> | |
| 221 | + {g.rows.map((a) => ( | |
| 222 | + <tr key={a.id}> | |
| 223 | + <td className="whitespace-nowrap">{fmtDate(a.approval_date)}</td> | |
| 224 | + <td className="min-w-[140px]"> | |
| 225 | + <Link className="ci-link font-medium" href={`/drug/${a.drug_slug}`}> | |
| 226 | + {a.drug_name} | |
| 227 | + </Link> | |
| 228 | + </td> | |
| 229 | + <td className="whitespace-nowrap"> | |
| 230 | + <span className="text-ink-2">{a.authority}</span> <Badge mono tone="outline">{a.jurisdiction}</Badge> | |
| 231 | + </td> | |
| 232 | + <td className="min-w-[160px] text-[12.5px]"> | |
| 233 | + {a.tumor_agnostic ? <Badge tone="accent">Tumor-agnostic</Badge> : null} | |
| 234 | + {a.cancer_slug ? ( | |
| 235 | + <Link className="ci-link" href={`/cancer/${a.cancer_slug}`}> | |
| 236 | + {a.cancer_name} | |
| 237 | + </Link> | |
| 238 | + ) : !a.tumor_agnostic ? ( | |
| 239 | + <span className="text-ink-3">cancer not stated in this record</span> | |
| 240 | + ) : null} | |
| 241 | + </td> | |
| 242 | + <td className="min-w-[260px] max-w-[460px] text-[12.5px]" title={a.indication}> | |
| 243 | + {truncate(a.indication, 140)} | |
| 244 | + </td> | |
| 245 | + <td className="ci-mono text-[12px]">{a.approval_type ?? '—'}{a.application_number ? <span className="block text-ink-3">{a.application_number}</span> : null}</td> | |
| 246 | + <td> | |
| 247 | + <StatusBadge status={a.status} /> | |
| 248 | + {a.source_status && a.source_status.toLowerCase() !== a.status ? <span className="block text-[11px] text-ink-3">source: {a.source_status}</span> : null} | |
| 249 | + {a.withdrawal_date ? <span className="block text-[11px] text-danger">since {fmtDate(a.withdrawal_date)}</span> : null} | |
| 250 | + </td> | |
| 251 | + <td> | |
| 252 | + <SourceBadge compact p={toInfo(prov.get(a.provenance_id)) ?? { sourceSlug: a.source_slug, sourceName: a.source_name }} /> | |
| 253 | + </td> | |
| 254 | + </tr> | |
| 255 | + ))} | |
| 256 | + </tbody> | |
| 257 | + </table> | |
| 258 | + </div> | |
| 259 | + </section> | |
| 260 | + ); | |
| 261 | + })} | |
| 262 | + <Pager total={total} pageSize={APPROVALS_PAGE_SIZE} page={info.page} hrefFor={(p) => href({ page: p > 1 ? p : '' })} label="Approval pages" noun="approval records" /> | |
| 263 | + </> | |
| 264 | + )} | |
| 265 | + <div className="mt-4"> | |
| 266 | + <Note> | |
| 267 | + A record is one authority's decision for one application/DIN; the same molecule appears once per jurisdiction and indication. Health Canada DPD does not publish indications — its records state the brand, DIN and ATC class only, and a cancelled or dormant DIN is the status of one product, not of the molecule. FDA cancer mappings come from label text and are flagged probabilistic on the drug page. | |
| 268 | + </Note> | |
| 269 | + </div> | |
| 270 | + </Section> | |
| 271 | + </> | |
| 272 | + )} | |
| 273 | + </div> | |
| 274 | + ); | |
| 275 | +} | |
modified
apps/web/src/app/drug/[slug]/page.tsx
+143 −4
@@ -3,24 +3,65 @@ import Link from 'next/link'; | ||
| 3 | 3 | import { notFound } from 'next/navigation'; |
| 4 | 4 | import { ExternalLink } from 'lucide-react'; |
| 5 | 5 | import { PageHeader, Section, KV, Note } from '@/components/ui/section'; |
| 6 | −import { Badge } from '@/components/ui/badge'; | |
| 6 | +import { Badge, ClaimBadge } from '@/components/ui/badge'; | |
| 7 | 7 | import { EmptyState } from '@/components/ui/empty-state'; |
| 8 | 8 | import { Freshness } from '@/components/ui/freshness'; |
| 9 | 9 | import { Pager } from '@/components/ui/pager'; |
| 10 | +import { SourceBadge } from '@/components/ui/source-badge'; | |
| 10 | 11 | import { ApprovalsTable } from '@/components/data/approvals-table'; |
| 11 | 12 | import { EvidenceTable } from '@/components/data/evidence-table'; |
| 12 | 13 | import { TrialTable } from '@/components/data/trial-list'; |
| 13 | −import { getDrugBySlug, approvalsForDrug } from '@/lib/queries/drugs'; | |
| 14 | +import { getDrugBySlug, approvalsForDrug, codesForDrug, pipelineForDrug, type DrugCodeRow } from '@/lib/queries/drugs'; | |
| 14 | 15 | import { evidenceForDrug, evidenceForDrugCount, EVIDENCE_PAGE_SIZE } from '@/lib/queries/evidence'; |
| 15 | 16 | import { trialsForDrug, trialsForDrugCount, TRIAL_PAGE_SIZE } from '@/lib/queries/trials'; |
| 16 | 17 | import { loadProvenance } from '@/lib/queries/provenance'; |
| 17 | 18 | import { jsonLd, drugLd } from '@/lib/seo'; |
| 18 | −import { fmtInt, humanize } from '@/lib/format'; | |
| 19 | +import { fmtDate, fmtInt, humanize, phaseLabel } from '@/lib/format'; | |
| 19 | 20 | import { pageInfo } from '@/lib/pagination'; |
| 20 | 21 | import { str, int, withParams, type SP } from '@/lib/search-params'; |
| 21 | 22 | |
| 22 | 23 | export const revalidate = 3600; |
| 23 | 24 | |
| 25 | +const CODE_SYSTEMS: Record<string, string> = { | |
| 26 | + atc: 'ATC (WHO)', | |
| 27 | + din: 'DIN (Health Canada)', | |
| 28 | + hc_drug_code: 'DPD drug code', | |
| 29 | + unii: 'UNII (FDA GSRS)', | |
| 30 | + rxcui: 'RxCUI (RxNorm)', | |
| 31 | + ncit: 'NCIt', | |
| 32 | + chembl: 'ChEMBL', | |
| 33 | + drugbank: 'DrugBank', | |
| 34 | + pubchem_cid: 'PubChem CID', | |
| 35 | + civic_therapy: 'CIViC therapy', | |
| 36 | + ema_product: 'EMA product', | |
| 37 | + mhra: 'MHRA', | |
| 38 | + tga: 'TGA', | |
| 39 | +}; | |
| 40 | +function codeSystemLabel(system: string): string { | |
| 41 | + return CODE_SYSTEMS[system] ?? humanize(system); | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** External link for code systems with a stable public URL pattern; plain text otherwise. */ | |
| 45 | +function CodeLink({ c, codes }: { c: DrugCodeRow; codes: DrugCodeRow[] }) { | |
| 46 | + let url: string | null = null; | |
| 47 | + if (c.system === 'atc') url = `https://atcddd.fhi.no/atc_ddd_index/?code=${encodeURIComponent(c.code)}`; | |
| 48 | + else if (c.system === 'hc_drug_code') url = `https://health-products.canada.ca/dpd-bdpp/info?lang=eng&code=${encodeURIComponent(c.code)}`; | |
| 49 | + else if (c.system === 'din') { | |
| 50 | + // The DPD public page is keyed by drug code, not DIN: reuse the hc_drug_code row that carries the same brand label. | |
| 51 | + const dc = codes.find((k) => k.system === 'hc_drug_code' && k.label === c.label); | |
| 52 | + url = dc ? `https://health-products.canada.ca/dpd-bdpp/info?lang=eng&code=${encodeURIComponent(dc.code)}` : null; | |
| 53 | + } else if (c.system === 'chembl') url = `https://www.ebi.ac.uk/chembl/compound_report_card/${encodeURIComponent(c.code)}/`; | |
| 54 | + else if (c.system === 'ncit') url = `https://evsexplore.semantics.cancer.gov/evsexplore/concept/ncit/${encodeURIComponent(c.code)}`; | |
| 55 | + else if (c.system === 'pubchem_cid') url = `https://pubchem.ncbi.nlm.nih.gov/compound/${encodeURIComponent(c.code)}`; | |
| 56 | + else if (c.system === 'drugbank') url = `https://go.drugbank.com/drugs/${encodeURIComponent(c.code)}`; | |
| 57 | + if (!url) return <>{c.code}</>; | |
| 58 | + return ( | |
| 59 | + <a className="ci-link inline-flex items-center gap-1" href={url} target="_blank" rel="noopener noreferrer"> | |
| 60 | + {c.code} <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 61 | + </a> | |
| 62 | + ); | |
| 63 | +} | |
| 64 | + | |
| 24 | 65 | export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> { |
| 25 | 66 | const d = await getDrugBySlug((await params).slug); |
| 26 | 67 | return d ? { title: `${d.name} — drug`, description: d.description ?? `${d.name}: regulatory approvals by jurisdiction, curated evidence and clinical trials.`, alternates: { canonical: `/drug/${d.slug}` } } : { title: 'Drug' }; |
@@ -35,7 +76,8 @@ export default async function DrugPage({ params, searchParams }: { params: Promi | ||
| 35 | 76 | const ev = pageInfo(int(sp, 'evPage', 1, 1, 100_000), EVIDENCE_PAGE_SIZE, evTotal); |
| 36 | 77 | const tp = pageInfo(int(sp, 'tPage', 1, 1, 100_000), TRIAL_PAGE_SIZE, tTotal); |
| 37 | 78 | const aPage = int(sp, 'aPage', 1, 1, 100_000); |
| 38 | − const [approvals, evidence, trials] = await Promise.all([approvalsForDrug(d.id), evTotal ? evidenceForDrug(d.id, { page: ev.page, pageSize: ev.pageSize }) : Promise.resolve([]), tTotal ? trialsForDrug(d.id, { page: tp.page, pageSize: tp.pageSize }) : Promise.resolve([])]); | |
| 79 | + const [approvals, evidence, trials, codes, pipeline] = await Promise.all([approvalsForDrug(d.id), evTotal ? evidenceForDrug(d.id, { page: ev.page, pageSize: ev.pageSize }) : Promise.resolve([]), tTotal ? trialsForDrug(d.id, { page: tp.page, pageSize: tp.pageSize }) : Promise.resolve([]), codesForDrug(d.id), pipelineForDrug(d.id)]); | |
| 80 | + const hasCanada = approvals.some((a) => a.jurisdiction === 'CA'); | |
| 39 | 81 | const prov = await loadProvenance([...approvals.map((a) => a.provenance_id), ...evidence.map((e) => e.provenance_id)]); |
| 40 | 82 | const jurisdictions = [...new Set(approvals.map((a) => a.jurisdiction))].sort(); |
| 41 | 83 | const wanted = approvals.length ? str(sp, 'jurisdiction') : ''; |
@@ -91,6 +133,11 @@ export default async function DrugPage({ params, searchParams }: { params: Promi | ||
| 91 | 133 | ))} |
| 92 | 134 | </nav> |
| 93 | 135 | <ApprovalsTable rows={shown} prov={prov} showDrug={false} page={aPage} hrefFor={(p) => href({ aPage: p > 1 ? p : '' }, 'approvals')} /> |
| 136 | + {hasCanada ? ( | |
| 137 | + <p className="mt-2 text-[12px] text-ink-3"> | |
| 138 | + Health Canada records are DIN-level: one row per marketed product (brand, strength, form). The Drug Product Database does not publish indications, so no cancer is stated for these rows, and a cancelled or dormant DIN is the status of that one product — not a withdrawal of the molecule. | |
| 139 | + </p> | |
| 140 | + ) : null} | |
| 94 | 141 | <Freshness dataUpdatedAt={approvals.reduce<Date | string | null>((m, a) => (m == null || String(a.updated_at) > String(m) ? a.updated_at : m), null)} /> |
| 95 | 142 | </> |
| 96 | 143 | ) : ( |
@@ -100,6 +147,70 @@ export default async function DrugPage({ params, searchParams }: { params: Promi | ||
| 100 | 147 | )} |
| 101 | 148 | </Section> |
| 102 | 149 | |
| 150 | + <Section id="pipeline" kicker="Derived" title="Development pipeline" description="Most advanced stage across all cancers, then per top-level cancer reached through trial conditions or approval indications. Approval in any ingested jurisdiction outranks trial phase; counts are interventional studies."> | |
| 151 | + {pipeline.length ? ( | |
| 152 | + <> | |
| 153 | + <div className="ci-table-wrap"> | |
| 154 | + <table className="ci-table"> | |
| 155 | + <thead> | |
| 156 | + <tr> | |
| 157 | + <th scope="col">Scope</th> | |
| 158 | + <th scope="col">Stage</th> | |
| 159 | + <th scope="col">Max phase</th> | |
| 160 | + <th scope="col" className="num">Active</th> | |
| 161 | + <th scope="col" className="num">Recruiting</th> | |
| 162 | + <th scope="col" className="num">Phase 3</th> | |
| 163 | + <th scope="col" className="num">Trials</th> | |
| 164 | + <th scope="col" className="num">Approvals</th> | |
| 165 | + <th scope="col">Jurisdictions</th> | |
| 166 | + <th scope="col">First approval</th> | |
| 167 | + <th scope="col">First trial</th> | |
| 168 | + </tr> | |
| 169 | + </thead> | |
| 170 | + <tbody> | |
| 171 | + {pipeline.map((p) => ( | |
| 172 | + <tr key={p.id}> | |
| 173 | + <td className="min-w-[160px]"> | |
| 174 | + {p.cancer_slug ? ( | |
| 175 | + <Link className="ci-link" href={`/pipeline?cancer=${p.cancer_slug}`}> | |
| 176 | + {p.cancer_name} | |
| 177 | + </Link> | |
| 178 | + ) : ( | |
| 179 | + <span className="font-medium">All cancers</span> | |
| 180 | + )} | |
| 181 | + </td> | |
| 182 | + <td> | |
| 183 | + <Badge tone={p.stage === 'approved' ? 'ok' : p.stage === 'withdrawn' ? 'danger' : p.stage === 'phase_not_stated' ? 'outline' : 'neutral'}>{humanize(p.stage)}</Badge> | |
| 184 | + </td> | |
| 185 | + <td className="text-[12.5px] text-ink-2">{p.max_phase ? phaseLabel(p.max_phase) : '—'}</td> | |
| 186 | + <td className="num">{fmtInt(p.active_trials)}</td> | |
| 187 | + <td className="num">{fmtInt(p.recruiting_trials)}</td> | |
| 188 | + <td className="num">{fmtInt(p.phase3_trials)}</td> | |
| 189 | + <td className="num">{fmtInt(p.total_trials)}</td> | |
| 190 | + <td className="num">{fmtInt(p.approvals)}</td> | |
| 191 | + <td className="ci-mono text-[12px]">{p.jurisdictions.length ? p.jurisdictions.join(', ') : '—'}</td> | |
| 192 | + <td className="whitespace-nowrap">{fmtDate(p.first_approval_date)}</td> | |
| 193 | + <td className="whitespace-nowrap">{p.first_trial_date ? (p.first_trial_date.length >= 10 ? fmtDate(p.first_trial_date) : p.first_trial_date) : '—'}</td> | |
| 194 | + </tr> | |
| 195 | + ))} | |
| 196 | + </tbody> | |
| 197 | + </table> | |
| 198 | + </div> | |
| 199 | + <div className="mt-2 flex flex-wrap items-center gap-1.5 text-[12px] text-ink-3"> | |
| 200 | + <ClaimBadge kind="computed" /> | |
| 201 | + <SourceBadge p={{ sourceSlug: 'clinicaltrials', layer: 'derived', note: 'Inputs: interventional trials linking this drug (trial_interventions × trial_conditions) and its approval records.' }} /> | |
| 202 | + <span className="ci-mono">{pipeline[0]!.formula_version}</span> | |
| 203 | + <Link className="ci-link" href="/methodology/pipeline"> | |
| 204 | + Stage rules | |
| 205 | + </Link> | |
| 206 | + </div> | |
| 207 | + <Freshness dataUpdatedAt={pipeline[0]!.computed_at} /> | |
| 208 | + </> | |
| 209 | + ) : ( | |
| 210 | + <EmptyState compact>No registered interventional trial or approval record places this drug in the pipeline yet.</EmptyState> | |
| 211 | + )} | |
| 212 | + </Section> | |
| 213 | + | |
| 103 | 214 | <Section id="evidence" kicker="Curated evidence" title={`Clinical evidence (${fmtInt(evTotal)})`} description={`CIViC items in which this therapy appears, grouped by cancer context, then molecular profile. ${EVIDENCE_PAGE_SIZE} items per page.`}> |
| 104 | 215 | {evidence.length ? ( |
| 105 | 216 | <> |
@@ -153,6 +264,34 @@ export default async function DrugPage({ params, searchParams }: { params: Promi | ||
| 153 | 264 | /> |
| 154 | 265 | <Freshness dataUpdatedAt={d.updated_at} /> |
| 155 | 266 | </Section> |
| 267 | + <Section id="identifiers" kicker="Identifiers" title={`Codes (${fmtInt(codes.length)})`} level={3} description="Upstream identifiers kept as first-class codes; each links to its registry where a stable public URL exists."> | |
| 268 | + {codes.length ? ( | |
| 269 | + <div className="ci-table-wrap"> | |
| 270 | + <table className="ci-table"> | |
| 271 | + <thead> | |
| 272 | + <tr> | |
| 273 | + <th scope="col">System</th> | |
| 274 | + <th scope="col">Code</th> | |
| 275 | + <th scope="col">Label</th> | |
| 276 | + </tr> | |
| 277 | + </thead> | |
| 278 | + <tbody> | |
| 279 | + {codes.map((c) => ( | |
| 280 | + <tr key={c.id}> | |
| 281 | + <td className="whitespace-nowrap text-[12.5px] text-ink-2">{codeSystemLabel(c.system)}</td> | |
| 282 | + <td className="ci-mono text-[12px]"> | |
| 283 | + <CodeLink c={c} codes={codes} /> | |
| 284 | + </td> | |
| 285 | + <td className="max-w-[200px] text-[12.5px] text-ink-2">{c.label ?? '—'}</td> | |
| 286 | + </tr> | |
| 287 | + ))} | |
| 288 | + </tbody> | |
| 289 | + </table> | |
| 290 | + </div> | |
| 291 | + ) : ( | |
| 292 | + <EmptyState compact>No cross-reference code recorded yet beyond the record identifiers above.</EmptyState> | |
| 293 | + )} | |
| 294 | + </Section> | |
| 156 | 295 | <Note tone="warn">Regulatory status is jurisdiction-specific and time-bound. Nothing on this page is a treatment recommendation.</Note> |
| 157 | 296 | </aside> |
| 158 | 297 | </div> |
added
apps/web/src/app/pipeline/loading.tsx
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +import { PageSkeleton } from '@/components/ui/skeleton'; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return <PageSkeleton title="Loading drug development pipeline" />; | |
| 5 | +} | |
added
apps/web/src/app/pipeline/page.tsx
+241 −0
@@ -0,0 +1,241 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { PageHeader, Section, Note } from '@/components/ui/section'; | |
| 4 | +import { Badge, ClaimBadge } from '@/components/ui/badge'; | |
| 5 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 6 | +import { Freshness } from '@/components/ui/freshness'; | |
| 7 | +import { Pager } from '@/components/ui/pager'; | |
| 8 | +import { SourceBadge } from '@/components/ui/source-badge'; | |
| 9 | +import { PIPELINE_FUNNEL, PIPELINE_PAGE_SIZE, PIPELINE_STAGES, countPipelineRows, listPipelineRows, pipelineSourceSlugs, pipelineSummary, pipelineTopDrugsPerStage, topLevelCancerOptions, type PipelineRow, type PipelineStage } from '@/lib/queries/approvals'; | |
| 10 | +import { fmtDate, fmtInt, phaseLabel } from '@/lib/format'; | |
| 11 | +import { pageInfo } from '@/lib/pagination'; | |
| 12 | +import { str, int, withParams, type SP } from '@/lib/search-params'; | |
| 13 | + | |
| 14 | +export const metadata: Metadata = { | |
| 15 | + title: 'Drug development pipeline', | |
| 16 | + description: 'Development stage of oncology drugs per top-level cancer, derived from registered interventional trials and jurisdiction-aware approvals.', | |
| 17 | + alternates: { canonical: '/pipeline' }, | |
| 18 | +}; | |
| 19 | +export const revalidate = 600; | |
| 20 | + | |
| 21 | +const STAGE_LABEL: Record<PipelineStage, string> = { | |
| 22 | + phase_not_stated: 'Phase not stated', | |
| 23 | + phase1: 'Phase 1', | |
| 24 | + phase2: 'Phase 2', | |
| 25 | + phase3: 'Phase 3', | |
| 26 | + phase4: 'Phase 4', | |
| 27 | + approved: 'Approved', | |
| 28 | + withdrawn: 'Withdrawn', | |
| 29 | +}; | |
| 30 | + | |
| 31 | +function StageBadge({ stage }: { stage: PipelineStage }) { | |
| 32 | + const tone = stage === 'approved' ? 'ok' : stage === 'withdrawn' ? 'danger' : stage === 'phase_not_stated' ? 'outline' : 'neutral'; | |
| 33 | + return <Badge tone={tone}>{STAGE_LABEL[stage] ?? stage}</Badge>; | |
| 34 | +} | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * Funnel: one server-rendered SVG bar per stage (phase 1 → approved), width ∝ number of drugs, | |
| 38 | + * with the representative drugs (most active trials) listed beside each bar. | |
| 39 | + */ | |
| 40 | +function Funnel({ stages, top, hrefFor }: { stages: Array<{ stage: PipelineStage; drugs: number; active_trials: number }>; top: Map<PipelineStage, PipelineRow[]>; hrefFor: (stage: PipelineStage) => string }) { | |
| 41 | + const funnel = PIPELINE_FUNNEL.map((s) => stages.find((x) => x.stage === s) ?? { stage: s, drugs: 0, active_trials: 0 }); | |
| 42 | + const max = Math.max(...funnel.map((s) => s.drugs), 1); | |
| 43 | + return ( | |
| 44 | + <ol className="m-0 list-none space-y-3 p-0"> | |
| 45 | + {funnel.map((s) => { | |
| 46 | + const w = Math.max(s.drugs > 0 ? 1 : 0, Math.round((s.drugs / max) * 100)); | |
| 47 | + const reps = top.get(s.stage) ?? []; | |
| 48 | + return ( | |
| 49 | + <li key={s.stage} className="grid gap-x-4 gap-y-1 sm:grid-cols-[140px_1fr]"> | |
| 50 | + <div className="text-[13.5px]"> | |
| 51 | + <Link className="ci-link font-medium" href={hrefFor(s.stage)}> | |
| 52 | + {STAGE_LABEL[s.stage]} | |
| 53 | + </Link> | |
| 54 | + <p className="m-0 text-[12px] text-ink-3"> | |
| 55 | + <span className="ci-num">{fmtInt(s.drugs)}</span> drug{s.drugs === 1 ? '' : 's'} · <span className="ci-num">{fmtInt(s.active_trials)}</span> active trials | |
| 56 | + </p> | |
| 57 | + </div> | |
| 58 | + <div className="min-w-0"> | |
| 59 | + <svg viewBox="0 0 100 10" preserveAspectRatio="none" width="100%" height="14" role="img" aria-label={`${STAGE_LABEL[s.stage]}: ${s.drugs} drugs`} className="block"> | |
| 60 | + <rect x="0" y="0" width="100" height="10" fill="var(--color-paper-2)" /> | |
| 61 | + <rect x="0" y="0" width={w} height="10" fill={s.stage === 'approved' ? 'var(--color-ink-2)' : 'var(--color-accent)'} /> | |
| 62 | + </svg> | |
| 63 | + <p className="m-0 mt-1 text-[12.5px] text-ink-2"> | |
| 64 | + {reps.length ? ( | |
| 65 | + reps.map((r, i) => ( | |
| 66 | + <span key={r.id}> | |
| 67 | + {i > 0 ? ', ' : ''} | |
| 68 | + <Link className="ci-link" href={`/drug/${r.drug_slug}`} title={`${fmtInt(r.active_trials)} active trials`}> | |
| 69 | + {r.drug_name} | |
| 70 | + </Link> | |
| 71 | + </span> | |
| 72 | + )) | |
| 73 | + ) : ( | |
| 74 | + <span className="text-ink-4">no drug at this stage</span> | |
| 75 | + )} | |
| 76 | + </p> | |
| 77 | + </div> | |
| 78 | + </li> | |
| 79 | + ); | |
| 80 | + })} | |
| 81 | + </ol> | |
| 82 | + ); | |
| 83 | +} | |
| 84 | + | |
| 85 | +export default async function PipelinePage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 86 | + const sp = await searchParams; | |
| 87 | + const cancerSlug = str(sp, 'cancer'); | |
| 88 | + const stage = (PIPELINE_STAGES as readonly string[]).includes(str(sp, 'stage')) ? (str(sp, 'stage') as PipelineStage) : ''; | |
| 89 | + const requestedPage = int(sp, 'page', 1, 1, 100_000); | |
| 90 | + const options = await topLevelCancerOptions(); | |
| 91 | + const cancer = options.find((c) => c.slug === cancerSlug) ?? null; | |
| 92 | + const scopeId = cancer?.id ?? null; | |
| 93 | + const [summary, top, total, sources] = await Promise.all([pipelineSummary(scopeId), pipelineTopDrugsPerStage(scopeId, 5), countPipelineRows(scopeId, stage), pipelineSourceSlugs()]); | |
| 94 | + const info = pageInfo(requestedPage, PIPELINE_PAGE_SIZE, total); | |
| 95 | + const rows = total ? await listPipelineRows(scopeId, stage, info.page) : []; | |
| 96 | + const current = { cancer: cancer?.slug ?? '', stage, page: info.page > 1 ? info.page : '' }; | |
| 97 | + const href = (o: Record<string, string | number | null | undefined>) => `/pipeline${withParams(current, o)}`; | |
| 98 | + const side = summary.stages.filter((s) => s.stage === 'withdrawn' || s.stage === 'phase_not_stated'); | |
| 99 | + | |
| 100 | + return ( | |
| 101 | + <div> | |
| 102 | + <PageHeader kicker="Derived" title={cancer ? `Development pipeline · ${cancer.canonical_name}` : 'Drug development pipeline'} lede="For each drug, the most advanced stage supported by registered interventional trials and jurisdiction-aware approval records. Approval in any ingested jurisdiction outranks trial phase; a drug is counted once, at its highest stage."> | |
| 103 | + <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px]"> | |
| 104 | + <ClaimBadge kind="computed" /> | |
| 105 | + {summary.formulaVersion ? <span className="ci-mono text-ink-3">{summary.formulaVersion}</span> : null} | |
| 106 | + <Link className="ci-link" href="/methodology/pipeline"> | |
| 107 | + Stage rules | |
| 108 | + </Link> | |
| 109 | + <Link className="ci-link" href="/approvals"> | |
| 110 | + Approvals feed → | |
| 111 | + </Link> | |
| 112 | + </p> | |
| 113 | + </PageHeader> | |
| 114 | + | |
| 115 | + <form method="get" action="/pipeline" className="flex flex-wrap items-end gap-2 border-y border-rule py-3 text-[13.5px]"> | |
| 116 | + <label className="flex flex-col gap-1"> | |
| 117 | + <span className="ci-kicker">Top-level cancer</span> | |
| 118 | + <select name="cancer" defaultValue={cancer?.slug ?? ''} className="max-w-[360px] border border-rule-strong bg-white px-2 py-1.5"> | |
| 119 | + <option value="">All cancers (one row per drug)</option> | |
| 120 | + {options.map((c) => ( | |
| 121 | + <option key={c.id} value={c.slug}> | |
| 122 | + {c.canonical_name} ({fmtInt(c.drugs)}) | |
| 123 | + </option> | |
| 124 | + ))} | |
| 125 | + </select> | |
| 126 | + </label> | |
| 127 | + <label className="flex flex-col gap-1"> | |
| 128 | + <span className="ci-kicker">Stage</span> | |
| 129 | + <select name="stage" defaultValue={stage} className="border border-rule-strong bg-white px-2 py-1.5"> | |
| 130 | + <option value="">Any</option> | |
| 131 | + {PIPELINE_STAGES.map((s) => ( | |
| 132 | + <option key={s} value={s}> | |
| 133 | + {STAGE_LABEL[s]} | |
| 134 | + </option> | |
| 135 | + ))} | |
| 136 | + </select> | |
| 137 | + </label> | |
| 138 | + <button type="submit" className="border border-ink bg-ink px-3 py-1.5 text-paper hover:bg-ink-2"> | |
| 139 | + Apply | |
| 140 | + </button> | |
| 141 | + {cancer || stage ? ( | |
| 142 | + <Link className="ci-link text-[12.5px]" href="/pipeline"> | |
| 143 | + Clear | |
| 144 | + </Link> | |
| 145 | + ) : null} | |
| 146 | + </form> | |
| 147 | + | |
| 148 | + {summary.drugs === 0 ? ( | |
| 149 | + <div className="mt-4"> | |
| 150 | + <EmptyState knows={[{ label: 'Approvals feed', href: '/approvals' }, { label: 'Drugs', href: '/drugs' }]}>{cancer ? `No drug is linked to ${cancer.canonical_name} through a registered interventional trial or an approval record yet.` : 'The pipeline has not been computed on this environment yet (pnpm cix intel).'}</EmptyState> | |
| 151 | + </div> | |
| 152 | + ) : ( | |
| 153 | + <> | |
| 154 | + <Section id="funnel" kicker="Funnel" title={`${fmtInt(summary.drugs)} drugs by highest stage`} description={cancer ? `Drugs reach ${cancer.canonical_name} through trial conditions or approval indications mapped to it or to one of its descendant entities.` : 'Across all cancers: a drug is placed once, at the highest stage it reaches anywhere. Representative drugs are those with the most active trials at that stage.'}> | |
| 155 | + <Funnel stages={summary.stages} top={top} hrefFor={(s) => href({ stage: s, page: '' })} /> | |
| 156 | + {side.some((s) => s.drugs > 0) ? ( | |
| 157 | + <p className="mt-3 text-[12.5px] text-ink-3"> | |
| 158 | + Outside the funnel:{' '} | |
| 159 | + {side | |
| 160 | + .filter((s) => s.drugs > 0) | |
| 161 | + .map((s, i) => ( | |
| 162 | + <span key={s.stage}> | |
| 163 | + {i > 0 ? ' · ' : ''} | |
| 164 | + <Link className="ci-link" href={href({ stage: s.stage, page: '' })}> | |
| 165 | + {STAGE_LABEL[s.stage]} | |
| 166 | + </Link>{' '} | |
| 167 | + <span className="ci-num">{fmtInt(s.drugs)}</span> | |
| 168 | + </span> | |
| 169 | + ))} | |
| 170 | + . "Withdrawn" = every approval record for the scope is withdrawn or superseded; "phase not stated" = trials exist but none states a phase. | |
| 171 | + </p> | |
| 172 | + ) : null} | |
| 173 | + <div className="mt-2 flex flex-wrap items-center gap-1.5 text-[12px] text-ink-3"> | |
| 174 | + <ClaimBadge kind="computed" /> | |
| 175 | + {sources.map((s) => ( | |
| 176 | + <SourceBadge key={s} p={{ sourceSlug: s, layer: 'derived', note: 'Inputs: trial_interventions × trial_conditions × clinical_trials (interventional) and drug_approvals.' }} /> | |
| 177 | + ))} | |
| 178 | + {summary.formulaVersion ? <span className="ci-mono">{summary.formulaVersion}</span> : null} | |
| 179 | + </div> | |
| 180 | + <Freshness dataUpdatedAt={summary.computedAt} extra="recomputed with the intelligence layer" /> | |
| 181 | + </Section> | |
| 182 | + | |
| 183 | + <Section id="drugs" kicker="Drugs" title={`${fmtInt(total)} ${stage ? STAGE_LABEL[stage].toLowerCase() : ''} drug${total === 1 ? '' : 's'}${cancer ? ` · ${cancer.canonical_name}` : ''}`} description="Most advanced stage first, then most active trials. Trial counts are interventional studies linking the drug (and, when a cancer is selected, the cancer)."> | |
| 184 | + {rows.length === 0 ? ( | |
| 185 | + <EmptyState compact>No drug at this stage for the selected scope.</EmptyState> | |
| 186 | + ) : ( | |
| 187 | + <> | |
| 188 | + <div className="ci-table-wrap"> | |
| 189 | + <table className="ci-table"> | |
| 190 | + <thead> | |
| 191 | + <tr> | |
| 192 | + <th scope="col">Drug</th> | |
| 193 | + <th scope="col">Stage</th> | |
| 194 | + <th scope="col">Max phase</th> | |
| 195 | + <th scope="col" className="num">Active</th> | |
| 196 | + <th scope="col" className="num">Recruiting</th> | |
| 197 | + <th scope="col" className="num">Phase 3</th> | |
| 198 | + <th scope="col" className="num">Total trials</th> | |
| 199 | + <th scope="col" className="num">Approvals</th> | |
| 200 | + <th scope="col">Jurisdictions</th> | |
| 201 | + <th scope="col">First approval</th> | |
| 202 | + <th scope="col">First trial</th> | |
| 203 | + </tr> | |
| 204 | + </thead> | |
| 205 | + <tbody> | |
| 206 | + {rows.map((r) => ( | |
| 207 | + <tr key={r.id}> | |
| 208 | + <td className="min-w-[160px]"> | |
| 209 | + <Link className="ci-link font-medium" href={`/drug/${r.drug_slug}#pipeline`}> | |
| 210 | + {r.drug_name} | |
| 211 | + </Link> | |
| 212 | + </td> | |
| 213 | + <td> | |
| 214 | + <StageBadge stage={r.stage} /> | |
| 215 | + </td> | |
| 216 | + <td className="text-[12.5px] text-ink-2">{r.max_phase ? phaseLabel(r.max_phase) : '—'}</td> | |
| 217 | + <td className="num">{fmtInt(r.active_trials)}</td> | |
| 218 | + <td className="num">{fmtInt(r.recruiting_trials)}</td> | |
| 219 | + <td className="num">{fmtInt(r.phase3_trials)}</td> | |
| 220 | + <td className="num">{fmtInt(r.total_trials)}</td> | |
| 221 | + <td className="num">{fmtInt(r.approvals)}</td> | |
| 222 | + <td className="ci-mono text-[12px]">{r.jurisdictions.length ? r.jurisdictions.join(', ') : '—'}</td> | |
| 223 | + <td className="whitespace-nowrap">{fmtDate(r.first_approval_date)}</td> | |
| 224 | + <td className="whitespace-nowrap">{r.first_trial_date ? (r.first_trial_date.length >= 10 ? fmtDate(r.first_trial_date) : r.first_trial_date) : '—'}</td> | |
| 225 | + </tr> | |
| 226 | + ))} | |
| 227 | + </tbody> | |
| 228 | + </table> | |
| 229 | + </div> | |
| 230 | + <Pager total={total} pageSize={PIPELINE_PAGE_SIZE} page={info.page} hrefFor={(p) => href({ page: p > 1 ? p : '' })} label="Pipeline pages" noun="drugs" /> | |
| 231 | + </> | |
| 232 | + )} | |
| 233 | + <div className="mt-4"> | |
| 234 | + <Note tone="warn">Stages describe registered activity and ingested regulatory records, not efficacy. Only ingested jurisdictions (currently US and Canada) can place a drug at "approved"; trial phases are as registered by sponsors. Nothing here is a treatment recommendation.</Note> | |
| 235 | + </div> | |
| 236 | + </Section> | |
| 237 | + </> | |
| 238 | + )} | |
| 239 | + </div> | |
| 240 | + ); | |
| 241 | +} | |
modified
apps/web/src/components/data/approvals-table.tsx
+6 −1
@@ -78,7 +78,12 @@ export function ApprovalsTable({ rows: allRows, prov, showDrug = true, showCance | ||
| 78 | 78 | </td> |
| 79 | 79 | <td> |
| 80 | 80 | <StatusBadge status={a.status} /> |
| 81 | − {a.withdrawal_date ? <span className="block text-[11px] text-danger">withdrawn {fmtDate(a.withdrawal_date)}</span> : null} | |
| 81 | + {a.source_status && a.source_status.toLowerCase() !== a.status ? ( | |
| 82 | + <span className="block text-[11px] text-ink-3" title="Status as published by the source for this one product (DIN); not the status of the molecule."> | |
| 83 | + source: {a.source_status} | |
| 84 | + </span> | |
| 85 | + ) : null} | |
| 86 | + {a.withdrawal_date ? <span className="block text-[11px] text-danger">since {fmtDate(a.withdrawal_date)}</span> : null} | |
| 82 | 87 | </td> |
| 83 | 88 | <td className="whitespace-nowrap">{fmtDate(a.approval_date)}</td> |
| 84 | 89 | <td> |
added
apps/web/src/components/home/approvals-module.tsx
+88 −0
@@ -0,0 +1,88 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Section } from '@/components/ui/section'; | |
| 3 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 4 | +import { Freshness } from '@/components/ui/freshness'; | |
| 5 | +import { Badge, ClaimBadge, StatusBadge } from '@/components/ui/badge'; | |
| 6 | +import { SourceBadge } from '@/components/ui/source-badge'; | |
| 7 | +import { recentApprovals } from '@/lib/queries/approvals'; | |
| 8 | +import { fmtDate, toDate } from '@/lib/format'; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * Home module "New oncology approvals": the last dated approval records across every ingested | |
| 12 | + * authority (one record = one authority's decision for one application/DIN — never a bare | |
| 13 | + * "approved"). Regulatory claim label; source badge per row because authorities differ. | |
| 14 | + */ | |
| 15 | +export async function ApprovalsModule({ limit = 8 }: { limit?: number }) { | |
| 16 | + const rows = await recentApprovals(limit); | |
| 17 | + return ( | |
| 18 | + <Section | |
| 19 | + id="approvals" | |
| 20 | + kicker="Regulatory" | |
| 21 | + title="New oncology approvals" | |
| 22 | + description="Latest dated approval records by authority and jurisdiction. A record is one decision for one application or DIN; Health Canada records do not state indications." | |
| 23 | + actions={ | |
| 24 | + <Link href="/approvals" className="ci-link"> | |
| 25 | + Approvals feed → | |
| 26 | + </Link> | |
| 27 | + } | |
| 28 | + > | |
| 29 | + {rows.length === 0 ? ( | |
| 30 | + <EmptyState knows={[{ label: 'Drugs', href: '/drugs' }, { label: 'Sources', href: '/sources' }]}>No regulatory approval record has been ingested yet.</EmptyState> | |
| 31 | + ) : ( | |
| 32 | + <> | |
| 33 | + <div className="ci-table-wrap"> | |
| 34 | + <table className="ci-table"> | |
| 35 | + <thead> | |
| 36 | + <tr> | |
| 37 | + <th>Date</th> | |
| 38 | + <th>Drug</th> | |
| 39 | + <th>Authority</th> | |
| 40 | + <th>Cancer</th> | |
| 41 | + <th>Status</th> | |
| 42 | + <th>Source</th> | |
| 43 | + </tr> | |
| 44 | + </thead> | |
| 45 | + <tbody> | |
| 46 | + {rows.map((a) => ( | |
| 47 | + <tr key={a.id}> | |
| 48 | + <td className="whitespace-nowrap">{fmtDate(a.approval_date)}</td> | |
| 49 | + <td> | |
| 50 | + <Link className="ci-link font-medium" href={`/drug/${a.drug_slug}#approvals`}> | |
| 51 | + {a.drug_name} | |
| 52 | + </Link> | |
| 53 | + {a.approval_type ? <span className="ml-1.5 ci-mono text-[11px] text-ink-3">{a.approval_type}</span> : null} | |
| 54 | + </td> | |
| 55 | + <td className="whitespace-nowrap"> | |
| 56 | + <span className="text-ink-2">{a.authority}</span> <Badge mono tone="outline">{a.jurisdiction}</Badge> | |
| 57 | + </td> | |
| 58 | + <td className="text-[12.5px]"> | |
| 59 | + {a.tumor_agnostic ? <Badge tone="accent">Tumor-agnostic</Badge> : null} | |
| 60 | + {a.cancer_slug ? ( | |
| 61 | + <Link className="ci-link" href={`/cancer/${a.cancer_slug}`}> | |
| 62 | + {a.cancer_name} | |
| 63 | + </Link> | |
| 64 | + ) : !a.tumor_agnostic ? ( | |
| 65 | + <span className="text-ink-3">not stated in this record</span> | |
| 66 | + ) : null} | |
| 67 | + </td> | |
| 68 | + <td> | |
| 69 | + <StatusBadge status={a.status} /> | |
| 70 | + </td> | |
| 71 | + <td> | |
| 72 | + <SourceBadge compact p={{ sourceSlug: a.source_slug, sourceName: a.source_name }} /> | |
| 73 | + </td> | |
| 74 | + </tr> | |
| 75 | + ))} | |
| 76 | + </tbody> | |
| 77 | + </table> | |
| 78 | + </div> | |
| 79 | + <div className="mt-2 flex flex-wrap items-center gap-1.5 text-[12px] text-ink-3"> | |
| 80 | + <ClaimBadge kind="regulatory" /> | |
| 81 | + <span>Jurisdiction-specific and time-bound; not a treatment recommendation.</span> | |
| 82 | + </div> | |
| 83 | + <Freshness dataUpdatedAt={rows.map((r) => toDate(r.updated_at)).filter((d): d is Date => !!d).sort((a, b) => b.getTime() - a.getTime())[0] ?? null} sourceUpdatedAt={rows[0]?.approval_date ?? null} /> | |
| 84 | + </> | |
| 85 | + )} | |
| 86 | + </Section> | |
| 87 | + ); | |
| 88 | +} | |
added
apps/web/src/lib/queries/approvals.ts
+195 −0
@@ -0,0 +1,195 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { run, sql, safe } from '@/lib/db'; | |
| 3 | +import type { SQL } from 'drizzle-orm'; | |
| 4 | +import type { ApprovalRow } from '@/lib/queries/drugs'; | |
| 5 | + | |
| 6 | +export const APPROVALS_PAGE_SIZE = 50; | |
| 7 | +export const PIPELINE_PAGE_SIZE = 50; | |
| 8 | +/** Funnel order (least → most advanced); `withdrawn` is shown apart from the funnel. */ | |
| 9 | +export const PIPELINE_FUNNEL = ['phase1', 'phase2', 'phase3', 'phase4', 'approved'] as const; | |
| 10 | +export const PIPELINE_STAGES = ['phase_not_stated', ...PIPELINE_FUNNEL, 'withdrawn'] as const; | |
| 11 | +export type PipelineStage = (typeof PIPELINE_STAGES)[number]; | |
| 12 | +export const APPROVED_STATUSES = ['approved', 'accelerated', 'conditional'] as const; | |
| 13 | +const MAX_DEPTH = 12; | |
| 14 | + | |
| 15 | +/** Feed row: an approval record plus the verbatim upstream status when the source has one (DPD). */ | |
| 16 | +export interface FeedApprovalRow extends ApprovalRow { | |
| 17 | + source_status: string | null; | |
| 18 | +} | |
| 19 | + | |
| 20 | +export interface ApprovalFilters { | |
| 21 | + authority: string; | |
| 22 | + jurisdiction: string; | |
| 23 | + cancer: string; // slug | |
| 24 | + status: string; | |
| 25 | + year: string; | |
| 26 | + q: string; | |
| 27 | +} | |
| 28 | + | |
| 29 | +const 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_name | |
| 31 | + 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`; | |
| 32 | + | |
| 33 | +function 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 | + UNION | |
| 44 | + 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 | +} | |
| 53 | + | |
| 54 | +export 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 | +} | |
| 58 | + | |
| 59 | +/** Feed page, latest approval date first (undated records last). */ | |
| 60 | +export 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 | +} | |
| 63 | + | |
| 64 | +export 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 | +} | |
| 75 | + | |
| 76 | +export 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_at | |
| 86 | + 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 | +} | |
| 92 | + | |
| 93 | +export 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 | +} | |
| 101 | + | |
| 102 | +/** Last dated approvals across authorities (no future-dated records). */ | |
| 103 | +export 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 | +} | |
| 106 | + | |
| 107 | +/* ------------------------------------------------------------------------------------------------ | |
| 108 | + * Pipeline (derived, drug_pipeline) | |
| 109 | + * ---------------------------------------------------------------------------------------------- */ | |
| 110 | + | |
| 111 | +export 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 | +} | |
| 134 | + | |
| 135 | +const 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_name | |
| 137 | + FROM drug_pipeline p JOIN drugs d ON d.id = p.drug_id LEFT JOIN cancers c ON c.id = p.cancer_id`; | |
| 138 | +const 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`; | |
| 139 | +const scopeWhere = (cancerId: string | null) => (cancerId ? sql`p.cancer_id = ${cancerId}` : sql`p.cancer_id IS NULL`); | |
| 140 | + | |
| 141 | +export 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 drugs | |
| 146 | + FROM cancers c WHERE c.top_level AND c.status = 'active' ORDER BY c.canonical_name`), | |
| 147 | + [], | |
| 148 | + ); | |
| 149 | +} | |
| 150 | + | |
| 151 | +export 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 | +} | |
| 162 | + | |
| 163 | +/** Up to `perStage` representative drugs per stage (most active trials first). */ | |
| 164 | +export 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 rn | |
| 170 | + 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)}) x | |
| 171 | + 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 | +} | |
| 178 | + | |
| 179 | +export 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 | +} | |
| 183 | + | |
| 184 | +export 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 | +} | |
| 190 | + | |
| 191 | +/** Sources feeding the derived pipeline (registry + regulatory sources present in drug_approvals). */ | |
| 192 | +export 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 | +} | |
modified
apps/web/src/lib/queries/drugs.ts
+64 −1
@@ -79,10 +79,12 @@ export interface ApprovalRow { | ||
| 79 | 79 | source_name: string; |
| 80 | 80 | provenance_id: number; |
| 81 | 81 | updated_at: Date; |
| 82 | + /** Verbatim upstream status when the source publishes one (Health Canada DPD: "Cancelled Post Market", "Dormant"…). */ | |
| 83 | + source_status?: string | null; | |
| 82 | 84 | } |
| 83 | 85 | |
| 84 | 86 | const APPROVAL_SELECT = sql` |
| 85 | − SELECT a.*, 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_name | |
| 87 | + 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_name | |
| 86 | 88 | 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`; |
| 87 | 89 | |
| 88 | 90 | export async function approvalsForDrug(drugId: string): Promise<ApprovalRow[]> { |
@@ -94,6 +96,67 @@ export async function approvalsForCancer(cancerIds: string[]): Promise<ApprovalR | ||
| 94 | 96 | return safe(() => run<ApprovalRow>(sql`${APPROVAL_SELECT} WHERE a.cancer_id IN (${sql.join(cancerIds.map((i) => sql`${i}`), sql`, `)}) OR a.tumor_agnostic ORDER BY d.name, a.jurisdiction, a.approval_date DESC NULLS LAST LIMIT 500`), [] as ApprovalRow[]); |
| 95 | 97 | } |
| 96 | 98 | |
| 99 | +/* ------------------------------------------------------------------------------------------------ | |
| 100 | + * Identifiers (drug_codes) and development pipeline (drug_pipeline) for the drug page | |
| 101 | + * ---------------------------------------------------------------------------------------------- */ | |
| 102 | + | |
| 103 | +export interface DrugCodeRow { | |
| 104 | + id: number; | |
| 105 | + system: string; | |
| 106 | + code: string; | |
| 107 | + label: string | null; | |
| 108 | + match_type: string; | |
| 109 | + source_slug: string | null; | |
| 110 | + source_name: string | null; | |
| 111 | +} | |
| 112 | + | |
| 113 | +/** Display order of identifier systems on the drug page. */ | |
| 114 | +export const CODE_SYSTEM_ORDER = ['atc', 'din', 'hc_drug_code', 'unii', 'rxcui', 'ncit', 'chembl', 'drugbank', 'pubchem_cid', 'civic_therapy', 'ema_product', 'mhra', 'tga'] as const; | |
| 115 | + | |
| 116 | +export async function codesForDrug(drugId: string): Promise<DrugCodeRow[]> { | |
| 117 | + const order = sql.raw(`CASE k.system ${CODE_SYSTEM_ORDER.map((s, i) => `WHEN '${s}' THEN ${i}`).join(' ')} ELSE 99 END`); | |
| 118 | + return safe( | |
| 119 | + () => | |
| 120 | + run<DrugCodeRow>(sql` | |
| 121 | + SELECT k.id, k.system, k.code, k.label, k.match_type, s.slug AS source_slug, s.name AS source_name | |
| 122 | + FROM drug_codes k LEFT JOIN sources s ON s.id = k.source_id WHERE k.drug_id = ${drugId} ORDER BY ${order}, k.code`), | |
| 123 | + [] as DrugCodeRow[], | |
| 124 | + ); | |
| 125 | +} | |
| 126 | + | |
| 127 | +export interface DrugPipelineRow { | |
| 128 | + id: number; | |
| 129 | + cancer_id: string | null; | |
| 130 | + cancer_slug: string | null; | |
| 131 | + cancer_name: string | null; | |
| 132 | + stage: string; | |
| 133 | + max_phase: string | null; | |
| 134 | + active_trials: number; | |
| 135 | + recruiting_trials: number; | |
| 136 | + phase3_trials: number; | |
| 137 | + total_trials: number; | |
| 138 | + approvals: number; | |
| 139 | + jurisdictions: string[]; | |
| 140 | + first_approval_date: string | null; | |
| 141 | + latest_approval_date: string | null; | |
| 142 | + first_trial_date: string | null; | |
| 143 | + formula_version: string; | |
| 144 | + computed_at: Date; | |
| 145 | +} | |
| 146 | + | |
| 147 | +/** Across-all-cancers row first, then one row per top-level cancer (most advanced stage first). */ | |
| 148 | +export async function pipelineForDrug(drugId: string): Promise<DrugPipelineRow[]> { | |
| 149 | + return safe( | |
| 150 | + () => | |
| 151 | + run<DrugPipelineRow>(sql` | |
| 152 | + SELECT p.id, p.cancer_id, c.slug AS cancer_slug, c.canonical_name AS cancer_name, p.stage, p.max_phase, p.active_trials, p.recruiting_trials, p.phase3_trials, p.total_trials, p.approvals, p.jurisdictions, | |
| 153 | + p.first_approval_date, p.latest_approval_date, p.first_trial_date, p.formula_version, p.updated_at AS computed_at | |
| 154 | + FROM drug_pipeline p LEFT JOIN cancers c ON c.id = p.cancer_id WHERE p.drug_id = ${drugId} | |
| 155 | + ORDER BY (p.cancer_id IS NOT NULL), 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 DESC, p.active_trials DESC, c.canonical_name`), | |
| 156 | + [] as DrugPipelineRow[], | |
| 157 | + ); | |
| 158 | +} | |
| 159 | + | |
| 97 | 160 | export async function drugSlugsForSitemap(offset: number, limit: number): Promise<Array<{ slug: string; updated_at: Date }>> { |
| 98 | 161 | return safe(() => run<{ slug: string; updated_at: Date }>(sql`SELECT slug, updated_at FROM drugs ORDER BY id LIMIT ${limit} OFFSET ${offset}`), []); |
| 99 | 162 | } |
added
docs/connectors/health-canada-dpd.md
+154 −0
@@ -0,0 +1,154 @@ | ||
| 1 | +# Connector `health-canada-dpd` — Health Canada Drug Product Database (DPD) | |
| 2 | + | |
| 3 | +| | | | |
| 4 | +|---|---| | |
| 5 | +| Source | https://health-products.canada.ca/dpd-bdpp/ — Health Canada, **Drug Product Database** (human, veterinary, radiopharmaceutical and disinfectant products authorized in Canada; one record per DIN) | | |
| 6 | +| Tier / category | 3 / regulatory (CLAUDE.md §13 — jurisdiction **CA**, authority **Health Canada**; never a bare `approved = true`) | | |
| 7 | +| Access | REST `https://health-products.canada.ca/api/drug/`, no authentication, JSON (`type=json`) or XML, `lang=en|fr` | | |
| 8 | +| Docs verified | 2026-09-11 — https://health-products.canada.ca/api/documentation/dpd-documentation-en.html ("DPD API Guide") + live API with `User-Agent: CancerIndex/0.1` | | |
| 9 | +| License | **Open Government Licence – Canada** (https://open.canada.ca/en/open-government-licence-canada) — `licenseStatus: approved`, `commercialUse: allowed`, `redistribution: attribution`; attribution: *"Contains information licensed under the Open Government Licence – Canada. Source: Health Canada, Drug Product Database."* | | |
| 10 | +| Code | `packages/connectors/src/connectors/health-canada-dpd/` (`manifest.ts`, `normalize.ts`, `index.ts`, `health-canada-dpd.test.ts`, `fixtures/`) | | |
| 11 | + | |
| 12 | +## Terms and licence (quoted) | |
| 13 | + | |
| 14 | +The DPD web application carries no licence statement of its own (its footer links to the Canada.ca | |
| 15 | +terms and conditions, verified 2026-09-11). The DPD **data extract** is published on the Open | |
| 16 | +Government portal (dataset `bf55e42a-63cb-4556-bfd8-44f26e5a36fe`, "Drug Product Database - All | |
| 17 | +Files") with **"Licence: Open Government Licence - Canada"**. From that licence | |
| 18 | +(https://open.canada.ca/en/open-government-licence-canada), stored verbatim in `manifest.termsNotes`: | |
| 19 | + | |
| 20 | +> The Information Provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information, including for commercial purposes, subject to the terms below. You are free to: Copy, modify, publish, translate, adapt, distribute or otherwise use the Information in any medium, mode or format for any lawful purpose. You must, where you do any of the above: Acknowledge the source of the Information by including any attribution statement specified by the Information Provider(s) and, where possible, provide a link to this licence. […] you must use the following attribution statement: Contains information licensed under the Open Government Licence – Canada. | |
| 21 | + | |
| 22 | +CancerIndex shows every DPD record as a dated, sourced **market authorization for one product | |
| 23 | +(DIN)** — never as an indication (the DPD publishes none) and never as a treatment recommendation. | |
| 24 | + | |
| 25 | +Rate limits: none documented (2026-09-11). The connector self-limits to **2 requests/s, one at a | |
| 26 | +time** (`rateLimits`), i.e. ≈ 1,500 products × 4 requests ≈ 50 min per full pass; the pass is | |
| 27 | +restartable per product (below). No 429 was observed during the first full pass. | |
| 28 | + | |
| 29 | +## Verified endpoints (2026-09-11) | |
| 30 | + | |
| 31 | +| Endpoint | Verified behaviour | | |
| 32 | +|---|---| | |
| 33 | +| `GET /therapeuticclass/?lang=en&type=json` | Bulk list, **48,042 rows** `{drug_code, tc_atc_number, tc_atc}` (3.7 MB, 0.2 s), one row per `drug_code`. Oncology groups kept: **L01** antineoplastic agents 1,156, **L02** endocrine therapy 255, **L03** immunostimulants 114 (filgrastim, interferons, BCG…), **V10** therapeutic radiopharmaceuticals 14 → **1,539 products**; the ATC group is tagged in `raw.atc.group`. L04 (immunosuppressants, 675) is out of scope. | | |
| 34 | +| `GET /therapeuticclass/?lang=en&type=json&id=92551` | `[{drug_code: 92551, tc_atc_number: "L01FF02", tc_atc: "PEMBROLIZUMAB"}]` — **health check** (must return L01FF02). `tc_atc` is the ATC 5th-level substance name, not a class label. | | |
| 35 | +| `GET /drugproduct/?lang=en&type=json&id=92551` | `{drug_code, class_name "Human" \| "Radiopharmaceutical" \| "Veterinary" \| "Disinfectant", drug_identification_number "02441152", brand_name "KEYTRUDA", descriptor, number_of_ais "1", ai_group_no, company_name, last_update_date "2026-08-13"}`. Brand names often embed the presentation ("PROCYTOX TABLETS 50MG", "VELBE 1MG/ML"). | | |
| 36 | +| `GET /activeingredient/?lang=en&type=json&id=` | `[{ingredient_name "PEMBROLIZUMAB" \| "IMATINIB (IMATINIB MESYLATE)" \| "BORTEZOMIB (BORTEZOMIB MANNITOL BORONIC ESTER)", strength, strength_unit, dosage_value, dosage_unit}]`. Guide: *"Information enclosed within brackets represents the salt and identifies how the ingredient is supplied."* Kits list diluents (WATER, SODIUM CHLORIDE, BUFFER SOLUTION) as ingredients. | | |
| 37 | +| `GET /status/?lang=en&type=json&id=` | `{status, history_date, original_market_date, external_status_code, expiration_date, lot_number}`. Status vocabulary (guide, `drugproduct/?status=`): 1 Approved, 2 Marketed, 3 Cancelled Pre Market, 4 Cancelled Post Market, 6 Dormant, 9 Cancelled (Unreturned Annual), 10 Cancelled (Safety Issue), 11 Authorized By Interim Order, 12 Authorized… `original_market_date` is **null** for "Approved" (not yet marketed) products. | | |
| 38 | +| `GET /route/?lang=en&type=json&id=` | `[{route_of_administration_code, route_of_administration_name "Intravenous"}]` | | |
| 39 | +| Unknown `drug_code` | Object endpoints (`drugproduct`, `status`) answer **HTTP 200 with zeros/nulls** (`{drug_code: 0, brand_name: null, …}`); array endpoints (`activeingredient`, `route`, `therapeuticclass`) answer **HTTP 404 with an empty body**. Both are treated as *missing*, never as a failure (`isMissingProduct`, `getArrayOrEmpty`). | | |
| 40 | +| Public product page | `https://health-products.canada.ca/dpd-bdpp/info?lang=eng&code=<drug_code>` (HTTP 200 verified) — used as `provenance.source_url`. | | |
| 41 | + | |
| 42 | +Schedule/form endpoints exist but are not fetched (4 requests per product: product, ingredients, | |
| 43 | +status, routes). | |
| 44 | + | |
| 45 | +## Ingestion | |
| 46 | + | |
| 47 | +Scope (bounded, CLAUDE.md §227): the oncology therapeutic-class rows, **sorted by `drug_code`**. | |
| 48 | +`ctx.cursor.lastDrugCode` is advanced after each fully persisted product (checkpoint every 50 | |
| 49 | +records or 60 s, `manifest.checkpointEvery`), so a run stopped by the time budget (`--max-minutes`) | |
| 50 | +or a signal resumes exactly where it stopped; a completed pass (`cursor.completedAt`) starts a | |
| 51 | +fresh one on the next schedule (`passCount` increments; change events are recorded from pass 2). | |
| 52 | + | |
| 53 | +1. **Anomaly guard**: `ctx.guardCount('product', n)` on the filtered bulk list (1,539) against the | |
| 54 | + previous successful run; plus a hard floor of 10,000 bulk rows (`anomaly:` error) — a truncated | |
| 55 | + list never triggers deletions (nothing is ever deleted by this connector). | |
| 56 | +2. **Per product** (`fetchBundle`): product → skip if missing; ingredients, status, routes. Each | |
| 57 | + sub-object is validated (zod) and observed for schema drift (`product`, `active_ingredient`, | |
| 58 | + `status`, `route`). One **`product` source record** per `drug_code` with the composite payload | |
| 59 | + `{therapeuticClass, product, ingredients, status, routes}` — self-contained, so `--mode backfill` | |
| 60 | + can replay it. Non-human classes (Veterinary, Disinfectant) are recorded and then skipped. | |
| 61 | +3. **Ingredient → drug** (deterministic, no LLM; `parseIngredientName`): the bracketed salt is | |
| 62 | + split off ("IMATINIB (IMATINIB MESYLATE)" → molecule IMATINIB, salt form IMATINIB MESYLATE), a | |
| 63 | + trailing salt token is stripped ("DOXORUBICIN HYDROCHLORIDE" → DOXORUBICIN; the salt list is | |
| 64 | + `SALT_TOKENS` shared with trial reconciliation). Excipients listed as kit components (WATER, | |
| 65 | + SODIUM CHLORIDE, BUFFER SOLUTION…) are skipped. Lookup in `drug_aliases.normalized` (index | |
| 66 | + loaded once per run and kept in sync): the full name first, then the molecule. Exactly one | |
| 67 | + drug → resolved (`match_type ALIAS`); several → the one whose alias is `generic`, else the one | |
| 68 | + whose own name matches, else **ambiguous** → `unresolved_labels` (`reason ambiguous_alias`). | |
| 69 | + - **Unknown molecule, single-ingredient product** → a drug is **minted** (`mintId 'DRUG'`, | |
| 70 | + `name` = molecule in Title Case — "Vinblastine", not "Vinblastine Sulfate" (CLAUDE.md §7: | |
| 71 | + the base molecule is the entity, salts are aliases), `slug` = slugify(name) with a | |
| 72 | + `-hc-<drug_code>` suffix on clash, `kind` null, `developmentStatus 'marketed_ca'` when the DPD | |
| 73 | + status maps to approved), aliases `generic` (molecule), `salt` (salt form) and `brand` | |
| 74 | + (cleaned brand), change event `created`. | |
| 75 | + - **Unknown molecule, multi-ingredient product** → `unresolved_labels` (`reason | |
| 76 | + component_of_multi_ingredient_product`, with DIN, brand, ATC); the approval row is linked to | |
| 77 | + every component that *did* resolve. | |
| 78 | +4. **Codes and aliases** for every resolved drug: `drug_codes` `atc` (code `tc_atc_number`, label | |
| 79 | + `tc_atc`), `din` (label = brand as published), `hc_drug_code` (label = brand), `match_type | |
| 80 | + EXACT_IDENTIFIER`; a `brand` alias with the presentation noise removed (`cleanBrandName`: | |
| 81 | + "PROCYTOX TABLETS 50MG" → "PROCYTOX", "THIO TEPA INJ 15MG/VIAL" → "THIO TEPA") and a `salt` | |
| 82 | + alias when the ingredient carried one. | |
| 83 | +5. **Approval row** per (product, drug) — `drug_approvals`: `jurisdiction 'CA'`, `authority | |
| 84 | + 'Health Canada'`, `approval_type 'DIN'`, `application_number` = DIN (8 digits), `approval_date` | |
| 85 | + = `original_market_date` (null when the product is approved but not marketed — never | |
| 86 | + fabricated), `status`: Marketed / Approved / Authorized… → **`approved`**; Cancelled Post Market | |
| 87 | + / Pre Market / (Safety Issue) / (Unreturned Annual) / Dormant → **`withdrawn`** with | |
| 88 | + `withdrawal_date = history_date`; anything else → no row + warning (`statusUnknown`). The DPD | |
| 89 | + status is kept verbatim in `raw.dpdStatus` and surfaced on the pages: **a cancelled or dormant | |
| 90 | + DIN is one product's status, not a withdrawal of the molecule** (KEYTRUDA DIN 02441152 is | |
| 91 | + "Cancelled Post Market" since 2019-12-04 while other pembrolizumab DINs are marketed). | |
| 92 | + `indication` is honest text: *"Marketed in Canada as KEYTRUDA (DIN 02441152) under ATC L01FF02 | |
| 93 | + PEMBROLIZUMAB. Indications are not published in the Drug Product Database — see the Health Canada | |
| 94 | + Product Monograph."*; `cancer_id NULL`, `tumor_agnostic false` — **no cancer is ever inferred | |
| 95 | + from an ATC class**. `raw` keeps drug code, DIN, brand, statuses/dates, ATC (code, label, group), | |
| 96 | + class, company, descriptor, all ingredients with strengths, routes, `last_update_date`. | |
| 97 | +6. **Provenance**: one row per product and run when the source record changed (`sourceUrl` = | |
| 98 | + public product page, `dataset 'Health Canada DPD'`, `datasetVersion dpd-<run date>`, | |
| 99 | + `evidenceType regulatory`, `accessLevel open`, `geography 'CA'`, `publishedAt` = | |
| 100 | + original market date, `updatedAtSource` = `last_update_date`, methodology text naming the | |
| 101 | + ingredient and how it was reconciled). Unchanged product + existing row → the previous | |
| 102 | + provenance row is reused (no duplicate evidence per weekly run). | |
| 103 | +7. **Idempotency**: rows are keyed by `(source_id, drug_id, jurisdiction 'CA', application_number = | |
| 104 | + DIN)` — existing rows are updated in place (status changes are recorded as | |
| 105 | + `approval_status_changed` from pass 2), new rows inserted (`approval_added` from pass 2). Nothing | |
| 106 | + is deleted: a DIN that disappears from the bulk list keeps its last known status. | |
| 107 | + | |
| 108 | +`dry_run` walks the first 20 products of the list without writing (resolution against the alias | |
| 109 | +index is logged as "→ CI-DRUG-…", "would mint …" or "AMBIGUOUS"). `--mode backfill` re-derives drugs, | |
| 110 | +codes, aliases and approval rows from the composite payloads in the raw lake — no HTTP, cursor | |
| 111 | +untouched. | |
| 112 | + | |
| 113 | +Health check: `therapeuticclass?id=92551` must contain `L01FF02`. | |
| 114 | + | |
| 115 | +## Observed runs (local copy of production, 2026-09-11) | |
| 116 | + | |
| 117 | +| | | | |
| 118 | +|---|---| | |
| 119 | +| Dry run | 20 products, 81 requests, 40 s; 10 resolved by alias, 10 "would mint" (Vinblastine, Diethylstilbestrol, Dactinomycin, Procarbazine, Busulfan, Thiotepa, Bleomycin…) | | |
| 120 | +| 10-record smoke (`--max-records 10`) | 41 requests, 19 s, status `partial`, cursor `lastDrugCode 960`; 5 drugs minted, 10 approval rows, 27 codes; rerun resumed from the cursor | | |
| 121 | +| Full pass (`--reset-cursor --max-minutes 58`, run ING-HEALTHCANADADPD-20260911-000003) | Still running at reporting time — **455 / 1,539 products** processed (cursor `lastDrugCode 80770`, ≈ 2.1 s per product = 4 requests at 2 req/s, 0 HTTP failures, 0 warnings). The cursor resumes the pass on the next `pnpm cix run health-canada-dpd`. | | |
| 122 | + | |
| 123 | +Counts after those 455 products (plus the 10-record smoke): 507 `product` source records; **52 drugs minted** | |
| 124 | +(Vinblastine, Busulfan, Dactinomycin, Procarbazine, Thiotepa, Bleomycin, Interferon Alfa-2b…); ingredients | |
| 125 | +resolved by alias 303 + 106 after salt stripping; **502 CA approval rows** on 161 drugs (165 `approved`, | |
| 126 | +337 `withdrawn` — old DINs of long-marketed molecules dominate the low drug codes); `drug_codes` 502 DIN + | |
| 127 | +502 hc_drug_code + 119 ATC; aliases 186 brand + 52 generic + 47 salt; 8 labels in `unresolved_labels` | |
| 128 | +(components of multi-ingredient kits: RIBAVIRIN of REBETRON, ANCESTIM of STEMGEN, FORMESTANE, MELACINE | |
| 129 | +lysate components…), 0 ambiguous. `pnpm cix reconcile-drugs` afterwards linked **69,207 → 74,435 trial | |
| 130 | +intervention rows** (+5,228; 5,155 of them to the minted drugs — e.g. Busulfan, Bleomycin, Vinblastine | |
| 131 | +trials were unresolved before). | |
| 132 | + | |
| 133 | +## Limitations / notes | |
| 134 | + | |
| 135 | +- **No indications**: the DPD is a product registry; indications live in the Product Monograph | |
| 136 | + (PDF, not in the API). Canadian rows therefore never carry a cancer and never feed cancer-scoped | |
| 137 | + pipeline stages or `approved_drug_count` per cancer; they do make the unscoped pipeline row | |
| 138 | + "approved" and appear in the approvals feed and on the drug page. | |
| 139 | +- **DIN-level granularity**: one molecule has many DINs (brands, generics, strengths); the feed | |
| 140 | + counts records, not molecules. Status is per DIN. | |
| 141 | +- **Dates**: `original_market_date` is the first marketing date of *that DIN*, often a generic's | |
| 142 | + date; `approval_date` on a CA row is therefore not "the Canadian approval of the molecule" — the | |
| 143 | + earliest CA row of a drug is the best available proxy. Historic products carry year-end | |
| 144 | + placeholders from the registry itself ("1954-12-31") — kept as published. | |
| 145 | +- **Scope by ATC**: products without an ATC assignment in the DPD, or classed outside L01/L02/L03/ | |
| 146 | + V10 (e.g. supportive-care antiemetics, L04 immunosuppressants), are not ingested. L03 brings | |
| 147 | + non-oncology immunostimulants (interferon beta for MS) — they are drugs, honestly recorded with | |
| 148 | + their ATC group, but should not be read as oncology approvals. | |
| 149 | +- **Minted drugs** start with no `kind`, mechanism or targets; ChEMBL/openFDA enrichment and | |
| 150 | + `pnpm cix reconcile-drugs` (trial interventions) run afterwards. Salt-form duplicates against | |
| 151 | + existing entities are avoided by minting the base molecule; remaining duplicates are proposed by | |
| 152 | + `packages/ranking/src/drug-duplicates.ts` (`entity_merges`, status `proposed`). | |
| 153 | +- Only human and radiopharmaceutical classes are ingested; veterinary/disinfectant products are | |
| 154 | + recorded as source records and skipped. | |
added
docs/methodology/pipeline.md
+99 −0
@@ -0,0 +1,99 @@ | ||
| 1 | +# Drug development pipeline — stage rules (`drug_pipeline`, formula `ci-drug-pipeline-v1`) | |
| 2 | + | |
| 3 | +The pipeline is a **derived** layer (claim category `computed_metric`): for each drug, and for each | |
| 4 | +(drug, top-level cancer) pair, the most advanced development stage supported by two canonical | |
| 5 | +relations — registered **interventional** clinical trials (`trial_interventions` × | |
| 6 | +`trial_conditions` × `clinical_trials`) and jurisdiction-aware **approval records** | |
| 7 | +(`drug_approvals`). It is recomputed by `pnpm cix intel` (`packages/ranking/src/drug-pipeline.ts`, | |
| 8 | +`computeDrugPipeline`) in one transaction, set-based SQL over temp tables; the stage itself is | |
| 9 | +decided by a pure, unit-tested function (`stageFor`, `drug-pipeline.test.ts`). Every row carries | |
| 10 | +`formula_version` and its `inputs` (scope, approval counts, phase rank, the status lists and the | |
| 11 | +rule text) so a stage can be reproduced. | |
| 12 | + | |
| 13 | +Code: `/Users/simon-pierreboucher/Desktop/Projets/apps-web/cancerindex/packages/ranking/src/drug-pipeline.ts`. | |
| 14 | +Pages: `/pipeline` (funnel + table, `?cancer=<top-level slug>&stage=`), drug page section | |
| 15 | +"Development pipeline"; API `GET /v1/pipeline`, `GET /v1/pipeline/summary`. | |
| 16 | + | |
| 17 | +## Rows | |
| 18 | + | |
| 19 | +| Row | `cancer_id` | Trials counted | Approvals counted | | |
| 20 | +|---|---|---|---| | |
| 21 | +| Drug, across all cancers | `NULL` | every interventional trial that lists the drug as an intervention (`trial_interventions.drug_id`) | every `drug_approvals` row of the drug, with or without a cancer | | |
| 22 | +| Drug × top-level cancer | the top-level cancer | trials above whose `trial_conditions.cancer_id` is the top-level cancer **or one of its descendants** | approvals whose `cancer_id` is the top-level cancer or one of its descendants | | |
| 23 | + | |
| 24 | +A row is written only when the scope has ≥ 1 trial or ≥ 1 approval (no "preclinical" rows are | |
| 25 | +inferred: absence of registered activity is not evidence of preclinical work). | |
| 26 | + | |
| 27 | +### Ancestor mapping | |
| 28 | + | |
| 29 | +Descendants are resolved with a recursive CTE over `cancer_hierarchy` (all hierarchy types — | |
| 30 | +NCIt and OncoTree — like `entity_counters` and `GET /cancers/:id/descendants`), starting from every | |
| 31 | +`cancers.top_level = true AND status = 'active'` concept, depth ≤ 12 (`PIPELINE_THRESHOLDS. | |
| 32 | +maxHierarchyDepth`). A concept with several top-level ancestors (e.g. a lymphoma subtype under | |
| 33 | +both "Lymphoma" and a hematologic family) feeds every one of them — a trial is never lost, and a | |
| 34 | +drug may legitimately appear under two top-level cancers. Trials are counted DISTINCT per scope; | |
| 35 | +a study with three mapped conditions under one top-level cancer counts once there. | |
| 36 | + | |
| 37 | +Approvals without a `cancer_id` (FDA supplements, label bullets naming 0 or ≥ 2 cancers, every | |
| 38 | +**Health Canada DPD** row — the DPD publishes no indications) feed only the across-all-cancers | |
| 39 | +row. They never place a drug at "approved" for a specific cancer. | |
| 40 | + | |
| 41 | +## Stage rule (`stageFor`) | |
| 42 | + | |
| 43 | +Evaluated in this order, on the counts of the scope: | |
| 44 | + | |
| 45 | +1. **approved** — at least one approval record with `status ∈ {approved, accelerated, | |
| 46 | + conditional}` (`PIPELINE_APPROVED_STATUSES`). Approval in **any ingested jurisdiction** (US/FDA, | |
| 47 | + CA/Health Canada today) suffices; `jurisdictions` lists which. | |
| 48 | +2. **withdrawn** — approval records exist but every one is `withdrawn` or `superseded`. For a DPD | |
| 49 | + product this means every Canadian DIN of the molecule in scope is cancelled/dormant *and* no | |
| 50 | + other jurisdiction has an in-force record. | |
| 51 | +3. otherwise, by the **highest registry phase** among the scope's interventional trials | |
| 52 | + (`maxPhase`, rank PHASE4 4 > PHASE3 3 > PHASE2 2 > PHASE1 = EARLY_PHASE1 1 > NA 0; a trial | |
| 53 | + labelled `PHASE2, PHASE3` ranks 3, `PHASE1, PHASE2` ranks 2): | |
| 54 | + - `PHASE4` → **phase4** | |
| 55 | + - `PHASE3` → **phase3** | |
| 56 | + - `PHASE2` → **phase2** | |
| 57 | + - `PHASE1` or `EARLY_PHASE1` → **phase1** | |
| 58 | + - only `NA` / empty phases → **phase_not_stated** | |
| 59 | +4. no trials and no approvals → **no row**. | |
| 60 | + | |
| 61 | +`max_phase` stores the label (PHASE1 wins over EARLY_PHASE1 when both occur at rank 1) even when | |
| 62 | +the stage is `approved`, so "approved, still in phase 4 trials" stays visible. | |
| 63 | + | |
| 64 | +### Counts and dates | |
| 65 | + | |
| 66 | +| Column | Definition | | |
| 67 | +|---|---| | |
| 68 | +| `total_trials` | DISTINCT interventional trials in scope | | |
| 69 | +| `active_trials` | those with `overall_status ∈ {RECRUITING, NOT_YET_RECRUITING, ENROLLING_BY_INVITATION, ACTIVE_NOT_RECRUITING}` (same list as trial intelligence and counters) | | |
| 70 | +| `recruiting_trials` | `overall_status = RECRUITING` | | |
| 71 | +| `phase3_trials` | trials whose `phases` contains `PHASE3` (so `PHASE2, PHASE3` counts) | | |
| 72 | +| `approvals` | approval records in scope, all statuses | | |
| 73 | +| `jurisdictions` | DISTINCT `jurisdiction` of those records | | |
| 74 | +| `first_approval_date` / `latest_approval_date` | min / max `approval_date` over in-force records only | | |
| 75 | +| `first_trial_date` | min `start_date` (registry text `YYYY-MM-DD` or `YYYY-MM`; lexicographic min) | | |
| 76 | + | |
| 77 | +## Caveats | |
| 78 | + | |
| 79 | +- **Registry phases are declared by sponsors**; `NA` is common for device, behavioural or | |
| 80 | + surgical arms that carry a drug intervention. The stage says nothing about efficacy or about | |
| 81 | + the drug's role (experimental arm vs comparator vs background therapy): a phase 3 trial using | |
| 82 | + cisplatin as backbone places cisplatin at phase 3 for that cancer. | |
| 83 | +- **Only ingested jurisdictions** can produce "approved". A molecule approved by the EMA only is | |
| 84 | + shown at its trial phase until an EMA connector exists. Conversely a Canadian DIN (Health | |
| 85 | + Canada) is a market authorization for a product with no stated indication: the unscoped row | |
| 86 | + becomes "approved" while cancer-scoped rows still follow trial phases. | |
| 87 | +- **Trial ↔ drug linking** is by alias reconciliation of intervention names (`match_type ALIAS`; | |
| 88 | + `pnpm cix reconcile-drugs`). Unresolved intervention names (queued in `unresolved_labels`) do not | |
| 89 | + contribute; new drug entities (e.g. minted by the Health Canada connector) gain their trials after | |
| 90 | + the next reconciliation. | |
| 91 | +- **Trial ↔ cancer linking** depends on `trial_conditions.cancer_id` (CancerResolver, EXACT or | |
| 92 | + ALIAS matches); trials whose conditions stayed unresolved only feed the unscoped row. | |
| 93 | +- **Duplicate drug entities** (salt forms: "Imatinib" and "Imatinib Mesylate") each get their own | |
| 94 | + rows until curators accept a merge. `computeDrugPipeline` ends by *proposing* such merges | |
| 95 | + (`entity_merges`, status `proposed`, `packages/ranking/src/drug-duplicates.ts`: equal names after | |
| 96 | + stripping salt tokens, or ≥ 2 shared generic/brand/development-code aliases; keep = INN-like, | |
| 97 | + shortest name). Nothing is merged automatically. | |
| 98 | +- Rows are rebuilt from scratch on each run (`DELETE` + `INSERT` in one transaction); `computed_at` | |
| 99 | + is the run time shown by the Freshness line. | |
added
packages/connectors/src/connectors/health-canada-dpd/fixtures/activeingredient-101045.json
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +[{"dosage_unit":"","dosage_value":"","drug_code":101045,"ingredient_name":"IMATINIB (IMATINIB MESYLATE)","strength":"400","strength_unit":"MG"}] | |
| \ No newline at end of file | ||
added
packages/connectors/src/connectors/health-canada-dpd/fixtures/activeingredient-92551.json
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +[{"dosage_unit":"VIAL","dosage_value":"","drug_code":92551,"ingredient_name":"PEMBROLIZUMAB","strength":"50","strength_unit":"MG"}] | |
| \ No newline at end of file | ||
added
packages/connectors/src/connectors/health-canada-dpd/fixtures/drugproduct-101045.json
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +{"drug_code":101045,"class_name":"Human","drug_identification_number":"02521210","brand_name":"IMATINIB","descriptor":"","number_of_ais":"1","ai_group_no":"0145503003","company_name":"SIVEM PHARMACEUTICALS ULC","last_update_date":"2026-07-23"} | |
| \ No newline at end of file | ||
added
packages/connectors/src/connectors/health-canada-dpd/fixtures/drugproduct-92551.json
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +{"drug_code":92551,"class_name":"Human","drug_identification_number":"02441152","brand_name":"KEYTRUDA","descriptor":"FOR I.V. INFUSION. SINGLE-USE VIAL","number_of_ais":"1","ai_group_no":"0156910001","company_name":"MERCK CANADA INC","last_update_date":"2026-08-13"} | |
| \ No newline at end of file | ||
added
packages/connectors/src/connectors/health-canada-dpd/fixtures/drugproduct-malformed.json
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +{"drug_code": "abc", "brand_name": 12, "drug_identification_number": ["x"]} | |
| \ No newline at end of file | ||
added
packages/connectors/src/connectors/health-canada-dpd/fixtures/drugproduct-missing.json
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +{"drug_code":0,"class_name":null,"drug_identification_number":null,"brand_name":null,"descriptor":null,"number_of_ais":null,"ai_group_no":null,"company_name":null,"last_update_date":null} | |
| \ No newline at end of file | ||
added
packages/connectors/src/connectors/health-canada-dpd/fixtures/empty-array.json
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +[] | |
added
packages/connectors/src/connectors/health-canada-dpd/fixtures/route-101045.json
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +[{"drug_code":101045,"route_of_administration_code":56,"route_of_administration_name":"Oral"}] | |
| \ No newline at end of file | ||
added
packages/connectors/src/connectors/health-canada-dpd/fixtures/route-92551.json
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +[{"drug_code":92551,"route_of_administration_code":49,"route_of_administration_name":"Intravenous"}] | |
| \ No newline at end of file | ||
added
packages/connectors/src/connectors/health-canada-dpd/fixtures/status-101045.json
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +{"drug_code":101045,"status":"Marketed","history_date":"2024-04-29","original_market_date":"2024-04-29","external_status_code":2,"expiration_date":null,"lot_number":""} | |
| \ No newline at end of file | ||
added
packages/connectors/src/connectors/health-canada-dpd/fixtures/status-92551.json
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +{"drug_code":92551,"status":"Cancelled Post Market","history_date":"2019-12-04","original_market_date":"2015-06-01","external_status_code":4,"expiration_date":"2021-05-31","lot_number":"8SNL81602"} | |
| \ No newline at end of file | ||
added
packages/connectors/src/connectors/health-canada-dpd/fixtures/status-missing.json
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +{"drug_code":0,"status":null,"history_date":null,"original_market_date":null,"external_status_code":0,"expiration_date":null,"lot_number":null} | |
| \ No newline at end of file | ||
added
packages/connectors/src/connectors/health-canada-dpd/fixtures/therapeuticclass-92551.json
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +[{"drug_code":92551,"tc_atc_number":"L01FF02","tc_atc":"PEMBROLIZUMAB"}] | |
| \ No newline at end of file | ||
added
packages/connectors/src/connectors/health-canada-dpd/fixtures/therapeuticclass-sample.json
+170 −0
@@ -0,0 +1,170 @@ | ||
| 1 | +[ | |
| 2 | + { | |
| 3 | + "drug_code": 104387, | |
| 4 | + "tc_atc_number": "L01XK04", | |
| 5 | + "tc_atc": "TALAZOPARIB" | |
| 6 | + }, | |
| 7 | + { | |
| 8 | + "drug_code": 100697, | |
| 9 | + "tc_atc_number": "L01EX23", | |
| 10 | + "tc_atc": "PRALSETINIB" | |
| 11 | + }, | |
| 12 | + { | |
| 13 | + "drug_code": 96139, | |
| 14 | + "tc_atc_number": "L01BC59", | |
| 15 | + "tc_atc": "TRIFLURIDINE, COMBINATIONS" | |
| 16 | + }, | |
| 17 | + { | |
| 18 | + "drug_code": 7318, | |
| 19 | + "tc_atc_number": "L01BC01", | |
| 20 | + "tc_atc": "CYTARABINE" | |
| 21 | + }, | |
| 22 | + { | |
| 23 | + "drug_code": 48640, | |
| 24 | + "tc_atc_number": "L01DB01", | |
| 25 | + "tc_atc": "DOXORUBICIN" | |
| 26 | + }, | |
| 27 | + { | |
| 28 | + "drug_code": 94097, | |
| 29 | + "tc_atc_number": "L01EB02", | |
| 30 | + "tc_atc": "ERLOTINIB" | |
| 31 | + }, | |
| 32 | + { | |
| 33 | + "drug_code": 44115, | |
| 34 | + "tc_atc_number": "L01DB06", | |
| 35 | + "tc_atc": "IDARUBICIN" | |
| 36 | + }, | |
| 37 | + { | |
| 38 | + "drug_code": 98904, | |
| 39 | + "tc_atc_number": "L01EA02", | |
| 40 | + "tc_atc": "DASATINIB" | |
| 41 | + }, | |
| 42 | + { | |
| 43 | + "drug_code": 65627, | |
| 44 | + "tc_atc_number": "L01CD01", | |
| 45 | + "tc_atc": "PACLITAXEL" | |
| 46 | + }, | |
| 47 | + { | |
| 48 | + "drug_code": 201, | |
| 49 | + "tc_atc_number": "L01AA01", | |
| 50 | + "tc_atc": "CYCLOPHOSPHAMIDE" | |
| 51 | + }, | |
| 52 | + { | |
| 53 | + "drug_code": 97619, | |
| 54 | + "tc_atc_number": "L01ED05", | |
| 55 | + "tc_atc": "LORLATINIB" | |
| 56 | + }, | |
| 57 | + { | |
| 58 | + "drug_code": 90813, | |
| 59 | + "tc_atc_number": "L01XG01", | |
| 60 | + "tc_atc": "BORTEZOMIB" | |
| 61 | + }, | |
| 62 | + { | |
| 63 | + "drug_code": 97660, | |
| 64 | + "tc_atc_number": "L02BX03", | |
| 65 | + "tc_atc": "ABIRATERONE" | |
| 66 | + }, | |
| 67 | + { | |
| 68 | + "drug_code": 85591, | |
| 69 | + "tc_atc_number": "L02BG04", | |
| 70 | + "tc_atc": "LETROZOLE" | |
| 71 | + }, | |
| 72 | + { | |
| 73 | + "drug_code": 80799, | |
| 74 | + "tc_atc_number": "L02BG04", | |
| 75 | + "tc_atc": "LETROZOLE" | |
| 76 | + }, | |
| 77 | + { | |
| 78 | + "drug_code": 98622, | |
| 79 | + "tc_atc_number": "L02BB06", | |
| 80 | + "tc_atc": "DAROLUTAMIDE" | |
| 81 | + }, | |
| 82 | + { | |
| 83 | + "drug_code": 107139, | |
| 84 | + "tc_atc_number": "L02BB04", | |
| 85 | + "tc_atc": "ENZALUTAMIDE" | |
| 86 | + }, | |
| 87 | + { | |
| 88 | + "drug_code": 103009, | |
| 89 | + "tc_atc_number": "L03AX13", | |
| 90 | + "tc_atc": "GLATIRAMER ACETATE" | |
| 91 | + }, | |
| 92 | + { | |
| 93 | + "drug_code": 66805, | |
| 94 | + "tc_atc_number": "L03AB10", | |
| 95 | + "tc_atc": "PEGINTERFERON ALFA-2B" | |
| 96 | + }, | |
| 97 | + { | |
| 98 | + "drug_code": 19802, | |
| 99 | + "tc_atc_number": "L03AX03", | |
| 100 | + "tc_atc": "BCG VACCINE" | |
| 101 | + }, | |
| 102 | + { | |
| 103 | + "drug_code": 81548, | |
| 104 | + "tc_atc_number": "V10XA01", | |
| 105 | + "tc_atc": "SODIUM IODIDE 131 I" | |
| 106 | + }, | |
| 107 | + { | |
| 108 | + "drug_code": 92430, | |
| 109 | + "tc_atc_number": "V10XA01", | |
| 110 | + "tc_atc": "SODIUM IODIDE 131 I" | |
| 111 | + }, | |
| 112 | + { | |
| 113 | + "drug_code": 271, | |
| 114 | + "tc_atc_number": "A11EB", | |
| 115 | + "tc_atc": "VITAMIN B-COMPLEX WITH VITAMIN C" | |
| 116 | + }, | |
| 117 | + { | |
| 118 | + "drug_code": 22269, | |
| 119 | + "tc_atc_number": "A11GA01", | |
| 120 | + "tc_atc": "ASCORBIC ACID (VIT C)" | |
| 121 | + }, | |
| 122 | + { | |
| 123 | + "drug_code": 90139, | |
| 124 | + "tc_atc_number": "A10BD07", | |
| 125 | + "tc_atc": "METFORMIN AND SITAGLIPTIN" | |
| 126 | + }, | |
| 127 | + { | |
| 128 | + "drug_code": 85324, | |
| 129 | + "tc_atc_number": "C09CA01", | |
| 130 | + "tc_atc": "LOSARTAN" | |
| 131 | + }, | |
| 132 | + { | |
| 133 | + "drug_code": 49398, | |
| 134 | + "tc_atc_number": "C07AB02", | |
| 135 | + "tc_atc": "METOPROLOL" | |
| 136 | + }, | |
| 137 | + { | |
| 138 | + "drug_code": 51124, | |
| 139 | + "tc_atc_number": "C08DB01", | |
| 140 | + "tc_atc": "DILTIAZEM" | |
| 141 | + }, | |
| 142 | + { | |
| 143 | + "drug_code": 86252, | |
| 144 | + "tc_atc_number": "N02BF02", | |
| 145 | + "tc_atc": "PREGABALIN" | |
| 146 | + }, | |
| 147 | + { | |
| 148 | + "drug_code": 92463, | |
| 149 | + "tc_atc_number": "N06AB04", | |
| 150 | + "tc_atc": "CITALOPRAM" | |
| 151 | + }, | |
| 152 | + { | |
| 153 | + "drug_code": 101045, | |
| 154 | + "tc_atc_number": "L01EA01", | |
| 155 | + "tc_atc": "IMATINIB" | |
| 156 | + }, | |
| 157 | + { | |
| 158 | + "drug_code": 92551, | |
| 159 | + "tc_atc_number": "L01FF02", | |
| 160 | + "tc_atc": "PEMBROLIZUMAB" | |
| 161 | + }, | |
| 162 | + { | |
| 163 | + "drug_code": "not-a-number", | |
| 164 | + "tc_atc_number": "L01XX99", | |
| 165 | + "tc_atc": "MALFORMED" | |
| 166 | + }, | |
| 167 | + { | |
| 168 | + "tc_atc_number": "L01XX98" | |
| 169 | + } | |
| 170 | +] | |
| \ No newline at end of file | ||
added
packages/connectors/src/connectors/health-canada-dpd/health-canada-dpd.test.ts
+229 −0
@@ -0,0 +1,229 @@ | ||
| 1 | +import { readFileSync } from 'node:fs'; | |
| 2 | +import path from 'node:path'; | |
| 3 | +import { describe, expect, it } from 'vitest'; | |
| 4 | +import { HttpError } from '../../sdk/http.js'; | |
| 5 | +import type { RunContext } from '../../sdk/run.js'; | |
| 6 | +import { HealthCanadaDpdConnector, connector } from './index.js'; | |
| 7 | +import { HC_DPD_ATTRIBUTION, HC_DPD_LICENCE_QUOTE, activeIngredientUrl, drugProductUrl, manifest, productProvenanceUrl, routeUrl, statusUrl, therapeuticClassUrl } from './manifest.js'; | |
| 8 | +import { ActiveIngredient, Product, ProductStatus, TherapeuticClassRow, cleanBrandName, dpdDate, indicationText, isExcipient, isHumanProduct, isMissingProduct, mapDpdStatus, normalizeDin, numberOfIngredients, oncologyAtcGroup, parseIngredientName, selectOncologyRows, titleCaseName } from './normalize.js'; | |
| 9 | + | |
| 10 | +const fx = (name: string) => JSON.parse(readFileSync(path.join(import.meta.dirname, 'fixtures', name), 'utf8')) as unknown; | |
| 11 | + | |
| 12 | +describe('health-canada-dpd manifest', () => { | |
| 13 | + it('is a reviewed Open Government Licence source with attribution and verified docs', () => { | |
| 14 | + expect(manifest.id).toBe('health-canada-dpd'); | |
| 15 | + expect(manifest.category).toBe('regulatory'); | |
| 16 | + expect(manifest.licenseStatus).toBe('approved'); | |
| 17 | + expect(manifest.redistribution).toBe('attribution'); | |
| 18 | + expect(manifest.commercialUse).toBe('allowed'); | |
| 19 | + expect(manifest.attribution).toBe(HC_DPD_ATTRIBUTION); | |
| 20 | + expect(manifest.termsNotes).toContain(HC_DPD_LICENCE_QUOTE); | |
| 21 | + expect(manifest.documentationVerifiedAt).toBe('2026-09-11'); | |
| 22 | + expect(manifest.termsReviewedAt).toBe('2026-09-11'); | |
| 23 | + expect(manifest.rateLimits.requestsPerSecond).toBe(2); | |
| 24 | + expect(manifest.rateLimits.maxConcurrency).toBe(1); | |
| 25 | + expect(manifest.entities).toContain('drug_codes'); | |
| 26 | + }); | |
| 27 | + it('builds the verified endpoint URLs', () => { | |
| 28 | + expect(therapeuticClassUrl()).toBe('https://health-products.canada.ca/api/drug/therapeuticclass/?lang=en&type=json'); | |
| 29 | + expect(therapeuticClassUrl(92551)).toBe('https://health-products.canada.ca/api/drug/therapeuticclass/?lang=en&type=json&id=92551'); | |
| 30 | + expect(drugProductUrl(92551)).toContain('/drugproduct/?lang=en&type=json&id=92551'); | |
| 31 | + expect(activeIngredientUrl(92551)).toContain('/activeingredient/?lang=en&type=json&id=92551'); | |
| 32 | + expect(statusUrl(92551)).toContain('/status/?lang=en&type=json&id=92551'); | |
| 33 | + expect(routeUrl(92551)).toContain('/route/?lang=en&type=json&id=92551'); | |
| 34 | + expect(productProvenanceUrl(92551)).toBe('https://health-products.canada.ca/dpd-bdpp/info?lang=eng&code=92551'); | |
| 35 | + }); | |
| 36 | +}); | |
| 37 | + | |
| 38 | +describe('therapeutic-class list selection', () => { | |
| 39 | + it('keeps only L01/L02/L03/V10 rows, sorted by drug_code, and counts malformed rows', () => { | |
| 40 | + const rows = fx('therapeuticclass-sample.json') as unknown[]; | |
| 41 | + const { kept, invalid, byGroup } = selectOncologyRows(rows); | |
| 42 | + expect(invalid).toBe(2); // "not-a-number" drug_code and a row without drug_code | |
| 43 | + expect(byGroup.L01).toBe(14); // 12 sampled + KEYTRUDA (92551) + IMATINIB (101045) | |
| 44 | + expect(byGroup.L01 + byGroup.L02 + byGroup.L03 + byGroup.V10).toBe(kept.length); | |
| 45 | + expect(kept.every((r) => ['L01', 'L02', 'L03', 'V10'].includes(r.atcGroup))).toBe(true); | |
| 46 | + for (let i = 1; i < kept.length; i++) expect(kept[i]!.drug_code).toBeGreaterThan(kept[i - 1]!.drug_code); | |
| 47 | + expect(kept.find((r) => r.drug_code === 92551)).toMatchObject({ tc_atc_number: 'L01FF02', tc_atc: 'PEMBROLIZUMAB', atcGroup: 'L01' }); | |
| 48 | + // Non-oncology classes (A, C, N…) are dropped. | |
| 49 | + expect(kept.some((r) => !r.tc_atc_number.startsWith('L') && !r.tc_atc_number.startsWith('V'))).toBe(false); | |
| 50 | + }); | |
| 51 | + it('empty list → nothing kept', () => { | |
| 52 | + expect(selectOncologyRows(fx('empty-array.json') as unknown[])).toEqual({ kept: [], invalid: 0, byGroup: { L01: 0, L02: 0, L03: 0, V10: 0 } }); | |
| 53 | + }); | |
| 54 | + it('oncologyAtcGroup', () => { | |
| 55 | + expect(oncologyAtcGroup('L01FF02')).toBe('L01'); | |
| 56 | + expect(oncologyAtcGroup('l02bg03')).toBe('L02'); | |
| 57 | + expect(oncologyAtcGroup('L04AX03')).toBeNull(); // immunosuppressants are out of scope | |
| 58 | + expect(oncologyAtcGroup('V10XA01')).toBe('V10'); | |
| 59 | + expect(oncologyAtcGroup(null)).toBeNull(); | |
| 60 | + expect(TherapeuticClassRow.parse({ drug_code: '92551', tc_atc_number: 'L01FF02' }).drug_code).toBe(92551); | |
| 61 | + }); | |
| 62 | +}); | |
| 63 | + | |
| 64 | +describe('product records', () => { | |
| 65 | + it('parses KEYTRUDA (92551): human product, single ingredient, cancelled post-market DIN', () => { | |
| 66 | + const p = Product.parse(fx('drugproduct-92551.json')); | |
| 67 | + expect(p.drug_code).toBe(92551); | |
| 68 | + expect(p.drug_identification_number).toBe('02441152'); | |
| 69 | + expect(p.brand_name).toBe('KEYTRUDA'); | |
| 70 | + expect(isMissingProduct(p)).toBe(false); | |
| 71 | + expect(isHumanProduct(p)).toBe(true); | |
| 72 | + expect(numberOfIngredients(p)).toBe(1); | |
| 73 | + const ai = (fx('activeingredient-92551.json') as unknown[]).map((x) => ActiveIngredient.parse(x)); | |
| 74 | + expect(ai).toHaveLength(1); | |
| 75 | + expect(ai[0]!.ingredient_name).toBe('PEMBROLIZUMAB'); | |
| 76 | + const st = ProductStatus.parse(fx('status-92551.json')); | |
| 77 | + expect(st.status).toBe('Cancelled Post Market'); | |
| 78 | + expect(mapDpdStatus(st.status)).toBe('withdrawn'); | |
| 79 | + expect(dpdDate(st.original_market_date)).toBe('2015-06-01'); | |
| 80 | + expect(dpdDate(st.history_date)).toBe('2019-12-04'); | |
| 81 | + }); | |
| 82 | + it('parses a marketed salt-form product (101045 IMATINIB (IMATINIB MESYLATE))', () => { | |
| 83 | + const p = Product.parse(fx('drugproduct-101045.json')); | |
| 84 | + expect(mapDpdStatus(ProductStatus.parse(fx('status-101045.json')).status)).toBe('approved'); | |
| 85 | + const ai = (fx('activeingredient-101045.json') as unknown[]).map((x) => ActiveIngredient.parse(x)); | |
| 86 | + expect(parseIngredientName(ai[0]!.ingredient_name)).toEqual({ molecule: 'IMATINIB', saltForm: 'IMATINIB MESYLATE', normalizedMolecule: 'imatinib', normalizedFull: 'imatinib imatinib mesylate' }); | |
| 87 | + expect(indicationText(p.brand_name!, p.drug_identification_number!, 'L01EA01', 'IMATINIB')).toBe('Marketed in Canada as IMATINIB (DIN 02521210) under ATC L01EA01 IMATINIB. Indications are not published in the Drug Product Database — see the Health Canada Product Monograph.'); | |
| 88 | + }); | |
| 89 | + it('unknown drug_code: zeros/nulls object is "missing", not a failure; malformed record fails validation', () => { | |
| 90 | + const missing = Product.parse(fx('drugproduct-missing.json')); | |
| 91 | + expect(isMissingProduct(missing)).toBe(true); | |
| 92 | + const st = ProductStatus.parse(fx('status-missing.json')); | |
| 93 | + expect(st.status).toBeNull(); | |
| 94 | + expect(mapDpdStatus(st.status)).toBeNull(); | |
| 95 | + expect(Product.safeParse(fx('drugproduct-malformed.json')).success).toBe(false); | |
| 96 | + expect(Product.safeParse({}).success).toBe(false); | |
| 97 | + }); | |
| 98 | + it('class filter: veterinary and disinfectant products are excluded, radiopharmaceuticals kept', () => { | |
| 99 | + const base = Product.parse(fx('drugproduct-92551.json')); | |
| 100 | + expect(isHumanProduct({ ...base, class_name: 'Veterinary' })).toBe(false); | |
| 101 | + expect(isHumanProduct({ ...base, class_name: 'Disinfectant' })).toBe(false); | |
| 102 | + expect(isHumanProduct({ ...base, class_name: 'Radiopharmaceutical' })).toBe(true); | |
| 103 | + expect(isHumanProduct({ ...base, class_name: null })).toBe(false); | |
| 104 | + }); | |
| 105 | +}); | |
| 106 | + | |
| 107 | +describe('pure rules', () => { | |
| 108 | + it('maps every documented DPD status to approved / withdrawn, never a bare boolean', () => { | |
| 109 | + expect(mapDpdStatus('Marketed')).toBe('approved'); | |
| 110 | + expect(mapDpdStatus('Approved')).toBe('approved'); | |
| 111 | + expect(mapDpdStatus('Authorized By Interim Order')).toBe('approved'); | |
| 112 | + expect(mapDpdStatus('Cancelled Post Market')).toBe('withdrawn'); | |
| 113 | + expect(mapDpdStatus('Cancelled Pre Market')).toBe('withdrawn'); | |
| 114 | + expect(mapDpdStatus('Cancelled (Safety Issue)')).toBe('withdrawn'); | |
| 115 | + expect(mapDpdStatus('Cancelled (Unreturned Annual)')).toBe('withdrawn'); | |
| 116 | + expect(mapDpdStatus('Dormant')).toBe('withdrawn'); | |
| 117 | + expect(mapDpdStatus('')).toBeNull(); | |
| 118 | + expect(mapDpdStatus('Something new')).toBeNull(); | |
| 119 | + }); | |
| 120 | + it('dates are ISO already; garbage is refused', () => { | |
| 121 | + expect(dpdDate('2015-06-01')).toBe('2015-06-01'); | |
| 122 | + expect(dpdDate('20150601')).toBeNull(); | |
| 123 | + expect(dpdDate('2015-13-01')).toBeNull(); | |
| 124 | + expect(dpdDate(null)).toBeNull(); | |
| 125 | + }); | |
| 126 | + it('ingredient names: bracketed salt, trailing salt token, plain molecule', () => { | |
| 127 | + expect(parseIngredientName('PEMBROLIZUMAB')).toMatchObject({ molecule: 'PEMBROLIZUMAB', saltForm: null, normalizedMolecule: 'pembrolizumab' }); | |
| 128 | + expect(parseIngredientName('BORTEZOMIB (BORTEZOMIB MANNITOL BORONIC ESTER)')).toMatchObject({ molecule: 'BORTEZOMIB', saltForm: 'BORTEZOMIB MANNITOL BORONIC ESTER' }); | |
| 129 | + expect(parseIngredientName('DOXORUBICIN HYDROCHLORIDE')).toMatchObject({ molecule: 'DOXORUBICIN', saltForm: 'DOXORUBICIN HYDROCHLORIDE', normalizedMolecule: 'doxorubicin' }); | |
| 130 | + expect(parseIngredientName('SODIUM IODIDE 131 I')).toMatchObject({ molecule: 'SODIUM IODIDE 131 I', saltForm: null }); // "sodium" is not trailing → untouched | |
| 131 | + expect(parseIngredientName(' METHOTREXATE (METHOTREXATE DISODIUM) ')).toMatchObject({ molecule: 'METHOTREXATE', saltForm: 'METHOTREXATE DISODIUM' }); | |
| 132 | + }); | |
| 133 | + it('title case keeps short codes and hyphenated parts readable', () => { | |
| 134 | + expect(titleCaseName('PEMBROLIZUMAB')).toBe('Pembrolizumab'); | |
| 135 | + expect(titleCaseName('INTERFERON ALFA-2B')).toBe('Interferon Alfa-2b'); | |
| 136 | + expect(titleCaseName('BCG')).toBe('BCG'); | |
| 137 | + expect(titleCaseName('SODIUM IODIDE 131 I')).toBe('Sodium Iodide 131 I'); | |
| 138 | + }); | |
| 139 | + it('brand aliases drop the trailing presentation noise, never the brand itself', () => { | |
| 140 | + expect(cleanBrandName('PROCYTOX TABLETS 50MG')).toBe('PROCYTOX'); | |
| 141 | + expect(cleanBrandName('VELBE 1MG/ML')).toBe('VELBE'); | |
| 142 | + expect(cleanBrandName('THIO TEPA INJ 15MG/VIAL')).toBe('THIO TEPA'); | |
| 143 | + expect(cleanBrandName('HONVOL AMPOULES 250MG')).toBe('HONVOL'); | |
| 144 | + expect(cleanBrandName('BLENOXANE PWS 15UNIT')).toBe('BLENOXANE'); | |
| 145 | + expect(cleanBrandName('KEYTRUDA')).toBe('KEYTRUDA'); | |
| 146 | + expect(cleanBrandName('RYBREVANT SC')).toBe('RYBREVANT'); | |
| 147 | + expect(cleanBrandName('BORTEZOMIB FOR INJECTION')).toBe('BORTEZOMIB'); | |
| 148 | + expect(cleanBrandName('50MG')).toBe('50MG'); // single token stays | |
| 149 | + }); | |
| 150 | + it('kit diluents and buffers are excipients, never drug labels', () => { | |
| 151 | + expect(isExcipient('WATER')).toBe(true); | |
| 152 | + expect(isExcipient('STERILE WATER')).toBe(true); | |
| 153 | + expect(isExcipient('SODIUM CHLORIDE')).toBe(true); | |
| 154 | + expect(isExcipient('BUFFER SOLUTION')).toBe(true); | |
| 155 | + expect(isExcipient('ALBUMIN (HUMAN)')).toBe(true); | |
| 156 | + expect(isExcipient('SODIUM IODIDE 131 I')).toBe(false); | |
| 157 | + expect(isExcipient('PEMBROLIZUMAB')).toBe(false); | |
| 158 | + expect(isExcipient('RIBAVIRIN')).toBe(false); // a real component of a multi-ingredient kit → unresolved_labels | |
| 159 | + }); | |
| 160 | + it('DIN normalization pads to 8 digits', () => { | |
| 161 | + expect(normalizeDin('02441152')).toBe('02441152'); | |
| 162 | + expect(normalizeDin(2441152)).toBe('02441152'); | |
| 163 | + }); | |
| 164 | +}); | |
| 165 | + | |
| 166 | +/** Minimal RunContext stand-in for the HTTP-facing methods (no database). */ | |
| 167 | +function fakeCtx(json: (url: string) => Promise<unknown>): RunContext { | |
| 168 | + const ctx = { | |
| 169 | + http: { json }, | |
| 170 | + counters: { fetched: 0, created: 0, updated: 0, unchanged: 0, rejected: 0, validationFailures: 0 }, | |
| 171 | + observe() {}, | |
| 172 | + info() {}, | |
| 173 | + warn() {}, | |
| 174 | + error() {}, | |
| 175 | + }; | |
| 176 | + return ctx as unknown as RunContext; | |
| 177 | +} | |
| 178 | +type Internals = { fetchBundle(ctx: RunContext, tc: { drug_code: number; tc_atc_number: string; tc_atc: string | null; atcGroup: 'L01' }): Promise<unknown> }; | |
| 179 | +const tc = { drug_code: 92551, tc_atc_number: 'L01FF02', tc_atc: 'PEMBROLIZUMAB', atcGroup: 'L01' as const }; | |
| 180 | + | |
| 181 | +describe('connector HTTP behaviour (fixtures, no live API)', () => { | |
| 182 | + it('health check: healthy when therapeuticclass?id=92551 returns L01FF02, degraded otherwise', async () => { | |
| 183 | + const c = new HealthCanadaDpdConnector(); | |
| 184 | + expect((await c.healthCheck(fakeCtx(async () => fx('therapeuticclass-92551.json')))).status).toBe('healthy'); | |
| 185 | + expect((await c.healthCheck(fakeCtx(async () => [{ drug_code: 92551, tc_atc_number: 'A01AA01' }]))).status).toBe('degraded'); | |
| 186 | + expect((await c.healthCheck(fakeCtx(async () => fx('empty-array.json')))).status).toBe('degraded'); | |
| 187 | + expect((await c.healthCheck(fakeCtx(async () => Promise.reject(new HttpError(503, 'u', 'down'))))).status).toBe('failing'); | |
| 188 | + expect(connector.manifest.id).toBe('health-canada-dpd'); | |
| 189 | + }); | |
| 190 | + it('fetchBundle assembles product + ingredients + status + routes from the four endpoints', async () => { | |
| 191 | + const ctx = fakeCtx(async (url) => { | |
| 192 | + if (url.includes('/drugproduct/')) return fx('drugproduct-92551.json'); | |
| 193 | + if (url.includes('/activeingredient/')) return fx('activeingredient-92551.json'); | |
| 194 | + if (url.includes('/status/')) return fx('status-92551.json'); | |
| 195 | + if (url.includes('/route/')) return fx('route-92551.json'); | |
| 196 | + throw new Error(`unexpected ${url}`); | |
| 197 | + }); | |
| 198 | + const b = (await (connector as unknown as Internals).fetchBundle(ctx, tc)) as { product: { brand_name: string }; ingredients: unknown[]; status: { status: string }; routes: Array<{ route_of_administration_name: string }> }; | |
| 199 | + expect(b.product.brand_name).toBe('KEYTRUDA'); | |
| 200 | + expect(b.ingredients).toHaveLength(1); | |
| 201 | + expect(b.status.status).toBe('Cancelled Post Market'); | |
| 202 | + expect(b.routes[0]!.route_of_administration_name).toBe('Intravenous'); | |
| 203 | + }); | |
| 204 | + it('unknown product (zeros) → null without touching the other endpoints; 404 arrays → empty', async () => { | |
| 205 | + const calls: string[] = []; | |
| 206 | + const ctx = fakeCtx(async (url) => { | |
| 207 | + calls.push(url); | |
| 208 | + return fx('drugproduct-missing.json'); | |
| 209 | + }); | |
| 210 | + expect(await (connector as unknown as Internals).fetchBundle(ctx, tc)).toBeNull(); | |
| 211 | + expect(calls).toHaveLength(1); | |
| 212 | + const ctx2 = fakeCtx(async (url) => { | |
| 213 | + if (url.includes('/drugproduct/')) return fx('drugproduct-101045.json'); | |
| 214 | + if (url.includes('/status/')) return fx('status-101045.json'); | |
| 215 | + throw new HttpError(404, url, ''); | |
| 216 | + }); | |
| 217 | + const b = (await (connector as unknown as Internals).fetchBundle(ctx2, tc)) as { ingredients: unknown[]; routes: unknown[] }; | |
| 218 | + expect(b.ingredients).toEqual([]); | |
| 219 | + expect(b.routes).toEqual([]); | |
| 220 | + }); | |
| 221 | + it('malformed product is rejected and counted; server errors and rate limits propagate to the SDK', async () => { | |
| 222 | + const ctx = fakeCtx(async () => fx('drugproduct-malformed.json')); | |
| 223 | + expect(await (connector as unknown as Internals).fetchBundle(ctx, tc)).toBeNull(); | |
| 224 | + expect(ctx.counters.validationFailures).toBe(1); | |
| 225 | + expect(ctx.counters.rejected).toBe(1); | |
| 226 | + await expect((connector as unknown as Internals).fetchBundle(fakeCtx(async () => Promise.reject(new HttpError(500, 'u', 'boom'))), tc)).rejects.toBeInstanceOf(HttpError); | |
| 227 | + await expect((connector as unknown as Internals).fetchBundle(fakeCtx(async () => Promise.reject(new HttpError(429, 'u', 'slow down'))), tc)).rejects.toMatchObject({ status: 429 }); | |
| 228 | + }); | |
| 229 | +}); | |
added
packages/connectors/src/connectors/health-canada-dpd/index.ts
+488 −0
@@ -0,0 +1,488 @@ | ||
| 1 | +import { and, eq, sql } from 'drizzle-orm'; | |
| 2 | +import { normalizeLabel, slugify } from '@cancerindex/shared'; | |
| 3 | +import { drugAliases, drugApprovals, drugCodes, drugs, mintId } from '@cancerindex/database'; | |
| 4 | +import { HttpError } from '../../sdk/http.js'; | |
| 5 | +import { RawLake } from '../../sdk/lake.js'; | |
| 6 | +import { Connector, type ConnectorHealth, type RunContext } from '../../sdk/run.js'; | |
| 7 | +import { DRY_RUN_PRODUCTS, HEALTH_CHECK_ATC, HEALTH_CHECK_DRUG_CODE, activeIngredientUrl, drugProductUrl, manifest, productProvenanceUrl, routeUrl, statusUrl, therapeuticClassUrl } from './manifest.js'; | |
| 8 | +import { ActiveIngredient, Product, ProductStatus, Route, TherapeuticClassRow, cleanBrandName, dpdDate, indicationText, isExcipient, isHumanProduct, isMissingProduct, mapDpdStatus, normalizeDin, numberOfIngredients, parseIngredientName, selectOncologyRows, titleCaseName, type ProductBundle } from './normalize.js'; | |
| 9 | + | |
| 10 | +interface DpdCursor { | |
| 11 | + pass?: string; | |
| 12 | + startedAt?: string; | |
| 13 | + completedAt?: string; | |
| 14 | + /** Last fully persisted drug_code of the sorted oncology list (resume point). */ | |
| 15 | + lastDrugCode?: number; | |
| 16 | + /** Number of passes started; change events are recorded from the second pass on (first pass = bulk load). */ | |
| 17 | + passCount?: number; | |
| 18 | + stats?: Stats; | |
| 19 | +} | |
| 20 | + | |
| 21 | +interface Stats { | |
| 22 | + products: number; | |
| 23 | + missing: number; | |
| 24 | + nonHuman: number; | |
| 25 | + singleIngredient: number; | |
| 26 | + multiIngredient: number; | |
| 27 | + resolvedAlias: number; | |
| 28 | + resolvedSaltStripped: number; | |
| 29 | + minted: number; | |
| 30 | + ambiguous: number; | |
| 31 | + unresolvedIngredients: number; | |
| 32 | + statusUnknown: number; | |
| 33 | + approvalRowsCreated: number; | |
| 34 | + approvalRowsUpdated: number; | |
| 35 | + codes: number; | |
| 36 | + brandAliases: number; | |
| 37 | +} | |
| 38 | + | |
| 39 | +const emptyStats = (): Stats => ({ products: 0, missing: 0, nonHuman: 0, singleIngredient: 0, multiIngredient: 0, resolvedAlias: 0, resolvedSaltStripped: 0, minted: 0, ambiguous: 0, unresolvedIngredients: 0, statusUnknown: 0, approvalRowsCreated: 0, approvalRowsUpdated: 0, codes: 0, brandAliases: 0 }); | |
| 40 | + | |
| 41 | +type ResolveVia = 'alias' | 'salt_stripped'; | |
| 42 | +interface Resolved { | |
| 43 | + drugId: string; | |
| 44 | + via: ResolveVia; | |
| 45 | + alias: string; | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** | |
| 49 | + * In-memory view of drug_aliases (normalized → drug ids with their alias types) plus drug names, | |
| 50 | + * loaded once per run and kept in sync with the aliases this run inserts. ~7k rows. | |
| 51 | + */ | |
| 52 | +class DrugAliasIndex { | |
| 53 | + private byNorm = new Map<string, Map<string, Set<string>>>(); | |
| 54 | + private nameByDrug = new Map<string, string>(); | |
| 55 | + | |
| 56 | + static async load(ctx: RunContext): Promise<DrugAliasIndex> { | |
| 57 | + const idx = new DrugAliasIndex(); | |
| 58 | + const names = await ctx.db.select({ id: drugs.id, name: drugs.name }).from(drugs); | |
| 59 | + for (const d of names) idx.nameByDrug.set(d.id, normalizeLabel(d.name)); | |
| 60 | + const rows = await ctx.db.select({ normalized: drugAliases.normalized, drugId: drugAliases.drugId, aliasType: drugAliases.aliasType }).from(drugAliases); | |
| 61 | + for (const r of rows) idx.add(r.normalized, r.drugId, r.aliasType); | |
| 62 | + return idx; | |
| 63 | + } | |
| 64 | + | |
| 65 | + add(normalized: string, drugId: string, aliasType: string): void { | |
| 66 | + if (!normalized) return; | |
| 67 | + let m = this.byNorm.get(normalized); | |
| 68 | + if (!m) this.byNorm.set(normalized, (m = new Map())); | |
| 69 | + let s = m.get(drugId); | |
| 70 | + if (!s) m.set(drugId, (s = new Set())); | |
| 71 | + s.add(aliasType); | |
| 72 | + } | |
| 73 | + addDrug(drugId: string, name: string): void { | |
| 74 | + this.nameByDrug.set(drugId, normalizeLabel(name)); | |
| 75 | + } | |
| 76 | + has(drugId: string, normalized: string, aliasType: string): boolean { | |
| 77 | + return this.byNorm.get(normalized)?.get(drugId)?.has(aliasType) ?? false; | |
| 78 | + } | |
| 79 | + | |
| 80 | + /** | |
| 81 | + * Exactly one drug for the normalized label → that drug. Several → the one whose alias is | |
| 82 | + * `generic`, then the one whose own name is the label; still several → ambiguous (null, flagged). | |
| 83 | + */ | |
| 84 | + resolve(normalized: string): { drugId: string | null; ambiguous: boolean } { | |
| 85 | + const m = this.byNorm.get(normalized); | |
| 86 | + if (!m || m.size === 0) return { drugId: null, ambiguous: false }; | |
| 87 | + if (m.size === 1) return { drugId: [...m.keys()][0]!, ambiguous: false }; | |
| 88 | + const generic = [...m.entries()].filter(([, types]) => types.has('generic')).map(([id]) => id); | |
| 89 | + if (generic.length === 1) return { drugId: generic[0]!, ambiguous: false }; | |
| 90 | + const own = [...m.keys()].filter((id) => this.nameByDrug.get(id) === normalized); | |
| 91 | + if (own.length === 1) return { drugId: own[0]!, ambiguous: false }; | |
| 92 | + return { drugId: null, ambiguous: true }; | |
| 93 | + } | |
| 94 | +} | |
| 95 | + | |
| 96 | +/** | |
| 97 | + * Health Canada Drug Product Database connector — Canadian regulatory layer (CLAUDE.md §13). | |
| 98 | + * 1. Bulk therapeutic-class list → oncology ATC groups (L01, L02, L03, V10), sorted by drug_code. | |
| 99 | + * 2. Per product (4 requests): product, active ingredients, status, routes → one `product` source | |
| 100 | + * record (composite payload, replayable in backfill). | |
| 101 | + * 3. Ingredients → drugs by alias (salt forms fold onto the molecule); single-ingredient products | |
| 102 | + * whose molecule is unknown mint a new drug; multi-ingredient unknowns go to unresolved_labels. | |
| 103 | + * 4. drug_codes (atc, din, hc_drug_code), brand aliases, and one drug_approvals row per | |
| 104 | + * (product, drug): jurisdiction CA, authority Health Canada, status from the DPD status, no cancer | |
| 105 | + * (the DPD does not publish indications — never inferred from the ATC class). | |
| 106 | + * Restartable: ctx.cursor.lastDrugCode after every product. | |
| 107 | + */ | |
| 108 | +export class HealthCanadaDpdConnector extends Connector { | |
| 109 | + readonly manifest = manifest; | |
| 110 | + | |
| 111 | + async healthCheck(ctx: RunContext): Promise<ConnectorHealth> { | |
| 112 | + try { | |
| 113 | + const rows = await ctx.http.json<unknown>(therapeuticClassUrl(HEALTH_CHECK_DRUG_CODE)); | |
| 114 | + const parsed = Array.isArray(rows) ? rows.map((r) => TherapeuticClassRow.safeParse(r)).filter((r) => r.success).map((r) => r.data) : []; | |
| 115 | + const hit = parsed.find((r) => r.drug_code === HEALTH_CHECK_DRUG_CODE && r.tc_atc_number === HEALTH_CHECK_ATC); | |
| 116 | + if (!hit) return { status: 'degraded', detail: `therapeuticclass?id=${HEALTH_CHECK_DRUG_CODE} did not return ${HEALTH_CHECK_ATC}` }; | |
| 117 | + return { status: 'healthy', detail: `drug_code ${HEALTH_CHECK_DRUG_CODE} → ${hit.tc_atc_number} ${hit.tc_atc ?? ''}`.trim() }; | |
| 118 | + } catch (e) { | |
| 119 | + return { status: 'failing', detail: (e as Error).message }; | |
| 120 | + } | |
| 121 | + } | |
| 122 | + | |
| 123 | + async sync(ctx: RunContext): Promise<void> { | |
| 124 | + if (ctx.mode === 'dry_run') return this.dryRun(ctx); | |
| 125 | + if (ctx.mode === 'backfill') return this.backfill(ctx); | |
| 126 | + const state = ctx.cursor as DpdCursor; | |
| 127 | + if (!state.pass || state.completedAt) { | |
| 128 | + const passCount = (state.passCount ?? 0) + 1; | |
| 129 | + for (const k of Object.keys(state)) delete (state as Record<string, unknown>)[k]; | |
| 130 | + state.pass = new Date().toISOString().slice(0, 10); | |
| 131 | + state.startedAt = new Date().toISOString(); | |
| 132 | + state.passCount = passCount; | |
| 133 | + ctx.info(`starting DPD pass #${passCount}`); | |
| 134 | + } else ctx.info(`resuming DPD pass ${state.pass} after drug_code ${state.lastDrugCode ?? '(start)'}`); | |
| 135 | + const stats = state.stats ?? emptyStats(); | |
| 136 | + state.stats = stats; | |
| 137 | + | |
| 138 | + const list = await this.fetchOncologyList(ctx); | |
| 139 | + await ctx.guardCount('product', list.length); | |
| 140 | + const index = await DrugAliasIndex.load(ctx); | |
| 141 | + const remaining = state.lastDrugCode ? list.filter((r) => r.drug_code > state.lastDrugCode!) : list; | |
| 142 | + ctx.info(`${list.length} oncology products in the DPD (${remaining.length} remaining in this pass)`); | |
| 143 | + | |
| 144 | + for (const tc of remaining) { | |
| 145 | + if (ctx.shouldStop()) { | |
| 146 | + ctx.info(`stopping after drug_code ${state.lastDrugCode ?? '(start)'} — ${stats.products} products this pass; cursor saved`); | |
| 147 | + return; | |
| 148 | + } | |
| 149 | + const bundle = await this.fetchBundle(ctx, tc); | |
| 150 | + if (bundle) await this.persistBundle(ctx, bundle, index, stats, (state.passCount ?? 1) > 1); | |
| 151 | + else stats.missing++; | |
| 152 | + state.lastDrugCode = tc.drug_code; | |
| 153 | + stats.products++; | |
| 154 | + await ctx.checkpoint(0); | |
| 155 | + } | |
| 156 | + state.completedAt = new Date().toISOString(); | |
| 157 | + await ctx.saveCursor(); | |
| 158 | + ctx.info(`DPD pass complete: products ${stats.products} (missing ${stats.missing}, non-human ${stats.nonHuman}, single-ingredient ${stats.singleIngredient}, multi-ingredient ${stats.multiIngredient}), resolved by alias ${stats.resolvedAlias} (+${stats.resolvedSaltStripped} salt-stripped), minted ${stats.minted}, ambiguous ${stats.ambiguous}, unresolved ingredients ${stats.unresolvedIngredients}, status unknown ${stats.statusUnknown}, approval rows created ${stats.approvalRowsCreated} / updated ${stats.approvalRowsUpdated}, codes ${stats.codes}, brand aliases ${stats.brandAliases}`, { ...stats }); | |
| 159 | + } | |
| 160 | + | |
| 161 | + /* ---------------------------------------------------------------------------------------------- */ | |
| 162 | + | |
| 163 | + private async dryRun(ctx: RunContext): Promise<void> { | |
| 164 | + const list = await this.fetchOncologyList(ctx); | |
| 165 | + const index = await DrugAliasIndex.load(ctx); | |
| 166 | + ctx.info(`[dry_run] ${list.length} oncology products; probing the first ${DRY_RUN_PRODUCTS} — no writes`); | |
| 167 | + for (const tc of list.slice(0, DRY_RUN_PRODUCTS)) { | |
| 168 | + if (ctx.shouldStop()) return; | |
| 169 | + const bundle = await this.fetchBundle(ctx, tc); | |
| 170 | + ctx.counters.fetched++; | |
| 171 | + if (!bundle) { | |
| 172 | + ctx.info(`[dry_run] ${tc.drug_code} ${tc.tc_atc_number}: missing product`); | |
| 173 | + continue; | |
| 174 | + } | |
| 175 | + const p = bundle.product; | |
| 176 | + const ings = bundle.ingredients.map((i) => { | |
| 177 | + const parsed = parseIngredientName(i.ingredient_name); | |
| 178 | + const r = index.resolve(parsed.normalizedFull); | |
| 179 | + const r2 = r.drugId ? null : index.resolve(parsed.normalizedMolecule); | |
| 180 | + return `${i.ingredient_name} → ${r.drugId ?? r2?.drugId ?? (r.ambiguous || r2?.ambiguous ? 'AMBIGUOUS' : 'would mint ' + titleCaseName(parsed.molecule))}`; | |
| 181 | + }); | |
| 182 | + ctx.info(`[dry_run] ${tc.drug_code} ${tc.tc_atc_number} ${p.brand_name} DIN ${p.drug_identification_number} [${p.class_name}] status=${bundle.status?.status ?? '?'} → ${mapDpdStatus(bundle.status?.status) ?? 'unknown'} market=${bundle.status?.original_market_date ?? '—'}: ${ings.join('; ')}`); | |
| 183 | + } | |
| 184 | + } | |
| 185 | + | |
| 186 | + /** | |
| 187 | + * `--mode backfill`: re-derive drugs / codes / approval rows from the composite payloads already | |
| 188 | + * in the raw lake (source_records.raw_path) — no HTTP, cursor untouched (CLAUDE.md §2 replayable). | |
| 189 | + */ | |
| 190 | + private async backfill(ctx: RunContext): Promise<void> { | |
| 191 | + const recs = await ctx.db.execute<{ source_record_id: string; raw_path: string | null }>(sql`SELECT source_record_id, raw_path FROM source_records WHERE source_id = ${ctx.sourceId} AND entity_kind = 'product' AND raw_path IS NOT NULL ORDER BY id`); | |
| 192 | + const index = await DrugAliasIndex.load(ctx); | |
| 193 | + const stats = emptyStats(); | |
| 194 | + let unreadable = 0; | |
| 195 | + for (const r of recs) { | |
| 196 | + if (ctx.shouldStop()) break; | |
| 197 | + const payload = (await RawLake.read(r.raw_path!)) as Partial<ProductBundle> | null; | |
| 198 | + const product = payload?.product ? Product.safeParse(payload.product) : null; | |
| 199 | + if (!payload || !product?.success || !payload.therapeuticClass) { | |
| 200 | + unreadable++; | |
| 201 | + continue; | |
| 202 | + } | |
| 203 | + const bundle: ProductBundle = { | |
| 204 | + therapeuticClass: payload.therapeuticClass, | |
| 205 | + product: product.data, | |
| 206 | + ingredients: (payload.ingredients ?? []).map((i) => ActiveIngredient.safeParse(i)).filter((x) => x.success).map((x) => x.data), | |
| 207 | + status: payload.status ? (ProductStatus.safeParse(payload.status).data ?? null) : null, | |
| 208 | + routes: (payload.routes ?? []).map((i) => Route.safeParse(i)).filter((x) => x.success).map((x) => x.data), | |
| 209 | + }; | |
| 210 | + await this.persistBundle(ctx, bundle, index, stats, false, 'unchanged'); | |
| 211 | + stats.products++; | |
| 212 | + } | |
| 213 | + ctx.info(`backfill complete: ${recs.length} lake records (${unreadable} unreadable), products ${stats.products}, resolved ${stats.resolvedAlias} (+${stats.resolvedSaltStripped}), minted ${stats.minted}, approval rows created ${stats.approvalRowsCreated} / updated ${stats.approvalRowsUpdated}`, { ...stats }); | |
| 214 | + } | |
| 215 | + | |
| 216 | + /* ---------------------------------------------------------------------------------------------- */ | |
| 217 | + | |
| 218 | + private async fetchOncologyList(ctx: RunContext): Promise<ProductBundle['therapeuticClass'][]> { | |
| 219 | + const rows = await ctx.http.json<unknown>(therapeuticClassUrl()); | |
| 220 | + if (!Array.isArray(rows)) throw new Error('anomaly: therapeuticclass did not return an array'); | |
| 221 | + const { kept, invalid, byGroup } = selectOncologyRows(rows); | |
| 222 | + if (invalid) ctx.counters.validationFailures += invalid; | |
| 223 | + ctx.info(`therapeutic classes: ${rows.length} rows (${invalid} invalid) → ${kept.length} oncology products (L01 ${byGroup.L01}, L02 ${byGroup.L02}, L03 ${byGroup.L03}, V10 ${byGroup.V10})`); | |
| 224 | + if (rows.length < 10_000) throw new Error(`anomaly: therapeuticclass returned only ${rows.length} rows (expected ≈ 48,000)`); | |
| 225 | + ctx.datasetVersion ??= `dpd-${new Date().toISOString().slice(0, 10)}`; | |
| 226 | + return kept; | |
| 227 | + } | |
| 228 | + | |
| 229 | + /** Array endpoints answer HTTP 404 with an empty body for an unknown drug_code → []. */ | |
| 230 | + private async getArrayOrEmpty(ctx: RunContext, url: string): Promise<unknown[]> { | |
| 231 | + try { | |
| 232 | + const v = await ctx.http.json<unknown>(url); | |
| 233 | + return Array.isArray(v) ? v : []; | |
| 234 | + } catch (e) { | |
| 235 | + if (e instanceof HttpError && e.status === 404) return []; | |
| 236 | + throw e; | |
| 237 | + } | |
| 238 | + } | |
| 239 | + | |
| 240 | + /** The four per-product requests; null when the product does not exist (zeros/nulls). */ | |
| 241 | + private async fetchBundle(ctx: RunContext, tc: ProductBundle['therapeuticClass']): Promise<ProductBundle | null> { | |
| 242 | + const productRaw = await ctx.http.json<unknown>(drugProductUrl(tc.drug_code)); | |
| 243 | + const product = Product.safeParse(productRaw); | |
| 244 | + if (!product.success) { | |
| 245 | + ctx.counters.validationFailures++; | |
| 246 | + ctx.counters.rejected++; | |
| 247 | + ctx.warn(`invalid product record ${tc.drug_code}: ${product.error.issues[0]?.path.join('.')} ${product.error.issues[0]?.message}`); | |
| 248 | + return null; | |
| 249 | + } | |
| 250 | + ctx.observe('product', productRaw); | |
| 251 | + if (isMissingProduct(product.data)) return null; | |
| 252 | + const [ingredientsRaw, statusRaw, routesRaw] = [await this.getArrayOrEmpty(ctx, activeIngredientUrl(tc.drug_code)), await ctx.http.json<unknown>(statusUrl(tc.drug_code)).catch((e) => (e instanceof HttpError && e.status === 404 ? null : Promise.reject(e))), await this.getArrayOrEmpty(ctx, routeUrl(tc.drug_code))]; | |
| 253 | + const ingredients: ActiveIngredient[] = []; | |
| 254 | + for (const raw of ingredientsRaw) { | |
| 255 | + const p = ActiveIngredient.safeParse(raw); | |
| 256 | + if (p.success) { | |
| 257 | + ctx.observe('active_ingredient', raw); | |
| 258 | + ingredients.push(p.data); | |
| 259 | + } else { | |
| 260 | + ctx.counters.validationFailures++; | |
| 261 | + ctx.warn(`invalid active ingredient for ${tc.drug_code}: ${p.error.issues[0]?.message}`); | |
| 262 | + } | |
| 263 | + } | |
| 264 | + let status: ProductStatus | null = null; | |
| 265 | + if (statusRaw) { | |
| 266 | + const p = ProductStatus.safeParse(statusRaw); | |
| 267 | + if (p.success && p.data.status) { | |
| 268 | + ctx.observe('status', statusRaw); | |
| 269 | + status = p.data; | |
| 270 | + } else if (!p.success) { | |
| 271 | + ctx.counters.validationFailures++; | |
| 272 | + ctx.warn(`invalid status for ${tc.drug_code}: ${p.error.issues[0]?.message}`); | |
| 273 | + } | |
| 274 | + } | |
| 275 | + const routes: Route[] = []; | |
| 276 | + for (const raw of routesRaw) { | |
| 277 | + const p = Route.safeParse(raw); | |
| 278 | + if (p.success) { | |
| 279 | + ctx.observe('route', raw); | |
| 280 | + routes.push(p.data); | |
| 281 | + } | |
| 282 | + } | |
| 283 | + return { therapeuticClass: tc, product: product.data, ingredients, status, routes }; | |
| 284 | + } | |
| 285 | + | |
| 286 | + /* ---------------------------------------------------------------------------------------------- */ | |
| 287 | + | |
| 288 | + /** Source record, drug resolution / minting, codes, brand aliases and approval rows for one product. */ | |
| 289 | + private async persistBundle(ctx: RunContext, bundle: ProductBundle, index: DrugAliasIndex, stats: Stats, recordChanges: boolean, forcedStatus?: 'created' | 'updated' | 'unchanged'): Promise<void> { | |
| 290 | + const { product, therapeuticClass: tc } = bundle; | |
| 291 | + const code = product.drug_code; | |
| 292 | + const rec = forcedStatus ? { status: forcedStatus } : await ctx.upsertSourceRecord('product', String(code), bundle, { sourceUpdatedAt: product.last_update_date ? new Date(`${product.last_update_date}T00:00:00Z`) : null }); | |
| 293 | + if (!isHumanProduct(product)) { | |
| 294 | + stats.nonHuman++; | |
| 295 | + return; | |
| 296 | + } | |
| 297 | + const din = normalizeDin(product.drug_identification_number!); | |
| 298 | + const brand = (product.brand_name ?? '').trim(); | |
| 299 | + const nAis = numberOfIngredients(product) ?? bundle.ingredients.length; | |
| 300 | + if (nAis <= 1) stats.singleIngredient++; | |
| 301 | + else stats.multiIngredient++; | |
| 302 | + | |
| 303 | + // 1. Ingredients → drugs | |
| 304 | + const resolved: Array<Resolved & { ingredient: ActiveIngredient; molecule: string; saltForm: string | null }> = []; | |
| 305 | + for (const ing of bundle.ingredients) { | |
| 306 | + if (isExcipient(ing.ingredient_name)) continue; // kit diluents / buffers listed as "active ingredients" | |
| 307 | + const parsed = parseIngredientName(ing.ingredient_name); | |
| 308 | + if (!parsed.normalizedMolecule) continue; | |
| 309 | + let r = index.resolve(parsed.normalizedFull); | |
| 310 | + let via: ResolveVia = 'alias'; | |
| 311 | + let alias = parsed.normalizedFull; | |
| 312 | + if (!r.drugId && !r.ambiguous && parsed.normalizedMolecule !== parsed.normalizedFull) { | |
| 313 | + r = index.resolve(parsed.normalizedMolecule); | |
| 314 | + via = 'salt_stripped'; | |
| 315 | + alias = parsed.normalizedMolecule; | |
| 316 | + } | |
| 317 | + if (r.ambiguous) { | |
| 318 | + stats.ambiguous++; | |
| 319 | + stats.unresolvedIngredients++; | |
| 320 | + await ctx.recordUnresolved('drug', ing.ingredient_name, parsed.normalizedMolecule, { source: 'health-canada-dpd', reason: 'ambiguous_alias', drugCode: code, din, brand, atc: tc.tc_atc_number }); | |
| 321 | + continue; | |
| 322 | + } | |
| 323 | + if (!r.drugId) { | |
| 324 | + if (nAis <= 1 && bundle.ingredients.length === 1) { | |
| 325 | + const drugId = await this.mintDrug(ctx, index, parsed.molecule, parsed.saltForm, brand, tc, bundle.status?.status ?? null); | |
| 326 | + stats.minted++; | |
| 327 | + resolved.push({ drugId, via: 'alias', alias: parsed.normalizedMolecule, ingredient: ing, molecule: parsed.molecule, saltForm: parsed.saltForm }); | |
| 328 | + } else { | |
| 329 | + stats.unresolvedIngredients++; | |
| 330 | + await ctx.recordUnresolved('drug', ing.ingredient_name, parsed.normalizedMolecule, { source: 'health-canada-dpd', reason: 'component_of_multi_ingredient_product', drugCode: code, din, brand, atc: tc.tc_atc_number, numberOfIngredients: nAis }); | |
| 331 | + } | |
| 332 | + continue; | |
| 333 | + } | |
| 334 | + if (via === 'alias') stats.resolvedAlias++; | |
| 335 | + else stats.resolvedSaltStripped++; | |
| 336 | + resolved.push({ drugId: r.drugId, via, alias, ingredient: ing, molecule: parsed.molecule, saltForm: parsed.saltForm }); | |
| 337 | + } | |
| 338 | + if (resolved.length === 0) return; | |
| 339 | + | |
| 340 | + // 2. Codes + brand alias per resolved drug | |
| 341 | + const brandAlias = cleanBrandName(brand); | |
| 342 | + const brandNorm = normalizeLabel(brandAlias); | |
| 343 | + for (const r of [...new Map(resolved.map((x) => [x.drugId, x])).values()]) { | |
| 344 | + stats.codes += await this.upsertCodes(ctx, r.drugId, [ | |
| 345 | + { system: 'atc', code: tc.tc_atc_number, label: tc.tc_atc }, | |
| 346 | + { system: 'din', code: din, label: brand || null }, | |
| 347 | + { system: 'hc_drug_code', code: String(code), label: brand || null }, | |
| 348 | + ]); | |
| 349 | + if (brandNorm && brandNorm !== normalizeLabel(r.molecule) && !index.has(r.drugId, brandNorm, 'brand')) { | |
| 350 | + await ctx.db.insert(drugAliases).values({ drugId: r.drugId, alias: brandAlias, normalized: brandNorm, aliasType: 'brand', sourceId: ctx.sourceId }).onConflictDoNothing(); | |
| 351 | + index.add(brandNorm, r.drugId, 'brand'); | |
| 352 | + stats.brandAliases++; | |
| 353 | + } | |
| 354 | + if (r.saltForm) { | |
| 355 | + const saltNorm = normalizeLabel(r.saltForm); | |
| 356 | + if (saltNorm && saltNorm !== normalizeLabel(r.molecule) && !index.has(r.drugId, saltNorm, 'salt')) { | |
| 357 | + await ctx.db.insert(drugAliases).values({ drugId: r.drugId, alias: titleCaseName(r.saltForm), normalized: saltNorm, aliasType: 'salt', sourceId: ctx.sourceId }).onConflictDoNothing(); | |
| 358 | + index.add(saltNorm, r.drugId, 'salt'); | |
| 359 | + } | |
| 360 | + } | |
| 361 | + } | |
| 362 | + | |
| 363 | + // 3. Approval row per (product, drug) | |
| 364 | + const mapped = mapDpdStatus(bundle.status?.status); | |
| 365 | + if (!mapped) { | |
| 366 | + stats.statusUnknown++; | |
| 367 | + ctx.warn(`drug_code ${code} (${brand}, DIN ${din}): DPD status "${bundle.status?.status ?? 'missing'}" not mapped — no approval row`); | |
| 368 | + return; | |
| 369 | + } | |
| 370 | + const approvalDate = dpdDate(bundle.status?.original_market_date); | |
| 371 | + const historyDate = dpdDate(bundle.status?.history_date); | |
| 372 | + const indication = indicationText(brand || '(brand not stated)', din, tc.tc_atc_number, tc.tc_atc); | |
| 373 | + for (const r of [...new Map(resolved.map((x) => [x.drugId, x])).values()]) { | |
| 374 | + const existing = await ctx.db | |
| 375 | + .select({ id: drugApprovals.id, provenanceId: drugApprovals.provenanceId, status: drugApprovals.status }) | |
| 376 | + .from(drugApprovals) | |
| 377 | + .where(and(eq(drugApprovals.sourceId, ctx.sourceId), eq(drugApprovals.drugId, r.drugId), eq(drugApprovals.jurisdiction, 'CA'), eq(drugApprovals.applicationNumber, din))) | |
| 378 | + .limit(1); | |
| 379 | + const prev = existing[0]; | |
| 380 | + const provenanceId = | |
| 381 | + prev && rec.status === 'unchanged' | |
| 382 | + ? prev.provenanceId | |
| 383 | + : await ctx.addProvenance({ | |
| 384 | + sourceRecordId: String(code), | |
| 385 | + sourceUrl: productProvenanceUrl(code), | |
| 386 | + dataset: 'Health Canada DPD', | |
| 387 | + datasetVersion: ctx.datasetVersion ?? undefined, | |
| 388 | + evidenceType: 'regulatory', | |
| 389 | + accessLevel: 'open', | |
| 390 | + geography: 'CA', | |
| 391 | + publishedAt: approvalDate ?? undefined, | |
| 392 | + updatedAt: product.last_update_date ?? undefined, | |
| 393 | + methodology: `Health Canada Drug Product Database: product ${code} (DIN ${din}, ${brand}) — status "${bundle.status?.status}" as of ${historyDate ?? '?'}; active ingredient ${r.ingredient.ingredient_name} reconciled to the drug by ${r.via === 'alias' ? 'alias' : 'alias after stripping the salt form'} (match_type ALIAS). No indication is published by the DPD.`, | |
| 394 | + confidence: 1, | |
| 395 | + }); | |
| 396 | + const values = { | |
| 397 | + drugId: r.drugId, | |
| 398 | + cancerId: null, | |
| 399 | + tumorAgnostic: false, | |
| 400 | + jurisdiction: 'CA', | |
| 401 | + authority: 'Health Canada', | |
| 402 | + indication, | |
| 403 | + approvalType: 'DIN', | |
| 404 | + accelerated: null, | |
| 405 | + conditional: null, | |
| 406 | + approvalDate, | |
| 407 | + withdrawalDate: mapped === 'withdrawn' ? historyDate : null, | |
| 408 | + status: mapped, | |
| 409 | + applicationNumber: din, | |
| 410 | + sourceId: ctx.sourceId, | |
| 411 | + provenanceId, | |
| 412 | + raw: { | |
| 413 | + key: `DIN|${din}|${r.drugId}`, | |
| 414 | + drugCode: code, | |
| 415 | + din, | |
| 416 | + brand, | |
| 417 | + dpdStatus: bundle.status?.status ?? null, | |
| 418 | + dpdStatusNote: 'A cancelled or dormant DIN is the status of this one product; it does not mean the molecule is withdrawn from the Canadian market.', | |
| 419 | + historyDate, | |
| 420 | + originalMarketDate: approvalDate, | |
| 421 | + externalStatusCode: bundle.status?.external_status_code ?? null, | |
| 422 | + atc: { code: tc.tc_atc_number, label: tc.tc_atc, group: tc.atcGroup }, | |
| 423 | + classDpd: product.class_name, | |
| 424 | + company: product.company_name, | |
| 425 | + descriptor: product.descriptor, | |
| 426 | + numberOfIngredients: nAis, | |
| 427 | + ingredient: { name: r.ingredient.ingredient_name, molecule: r.molecule, saltForm: r.saltForm, strength: r.ingredient.strength, strengthUnit: r.ingredient.strength_unit, matchType: 'ALIAS', via: r.via }, | |
| 428 | + ingredients: bundle.ingredients.map((i) => ({ name: i.ingredient_name, strength: i.strength, unit: i.strength_unit })), | |
| 429 | + routes: bundle.routes.map((x) => x.route_of_administration_name).filter(Boolean), | |
| 430 | + lastUpdateDate: product.last_update_date ?? null, | |
| 431 | + }, | |
| 432 | + updatedAt: new Date(), | |
| 433 | + }; | |
| 434 | + if (prev) { | |
| 435 | + await ctx.db.update(drugApprovals).set(values).where(eq(drugApprovals.id, prev.id)); | |
| 436 | + stats.approvalRowsUpdated++; | |
| 437 | + if (recordChanges && prev.status !== mapped) await ctx.recordChange('drug', r.drugId, 'approval_status_changed', `Health Canada DIN ${din} (${brand}): ${prev.status} → ${mapped} (DPD "${bundle.status?.status}")`, { status: prev.status }, { status: mapped, dpdStatus: bundle.status?.status }); | |
| 438 | + } else { | |
| 439 | + await ctx.db.insert(drugApprovals).values(values); | |
| 440 | + stats.approvalRowsCreated++; | |
| 441 | + if (recordChanges) await ctx.recordChange('drug', r.drugId, 'approval_added', `Health Canada (CA) DIN ${din} ${brand} — ${mapped}${approvalDate ? `, marketed since ${approvalDate}` : ''}`, undefined, { jurisdiction: 'CA', din, status: mapped, approvalDate }); | |
| 442 | + } | |
| 443 | + } | |
| 444 | + } | |
| 445 | + | |
| 446 | + private async upsertCodes(ctx: RunContext, drugId: string, codes: Array<{ system: string; code: string; label: string | null }>): Promise<number> { | |
| 447 | + let n = 0; | |
| 448 | + for (const c of codes) { | |
| 449 | + if (!c.code) continue; | |
| 450 | + const res = await ctx.db | |
| 451 | + .insert(drugCodes) | |
| 452 | + .values({ drugId, system: c.system, code: c.code, label: c.label, matchType: 'EXACT_IDENTIFIER', sourceId: ctx.sourceId }) | |
| 453 | + .onConflictDoNothing({ target: [drugCodes.drugId, drugCodes.system, drugCodes.code] }) | |
| 454 | + .returning({ id: drugCodes.id }); | |
| 455 | + if (res.length) n++; | |
| 456 | + } | |
| 457 | + return n; | |
| 458 | + } | |
| 459 | + | |
| 460 | + /** | |
| 461 | + * Mint a drug for a single-ingredient product whose molecule is unknown (CLAUDE.md §7: the base | |
| 462 | + * molecule is the entity; the salt form and the brand are aliases). Slug clashes get a suffix. | |
| 463 | + */ | |
| 464 | + private async mintDrug(ctx: RunContext, index: DrugAliasIndex, molecule: string, saltForm: string | null, brand: string, tc: ProductBundle['therapeuticClass'], dpdStatus: string | null): Promise<string> { | |
| 465 | + const name = titleCaseName(molecule); | |
| 466 | + const slugBase = slugify(name) || `hc-${tc.drug_code}`; | |
| 467 | + let slug = slugBase; | |
| 468 | + const [clash] = await ctx.db.select({ id: drugs.id }).from(drugs).where(eq(drugs.slug, slug)).limit(1); | |
| 469 | + if (clash) slug = `${slugBase}-hc-${tc.drug_code}`; | |
| 470 | + const drugId = await mintId(ctx.db, 'DRUG'); | |
| 471 | + await ctx.db.insert(drugs).values({ id: drugId, slug, name, kind: null, developmentStatus: mapDpdStatus(dpdStatus) === 'approved' ? 'marketed_ca' : null, description: null }); | |
| 472 | + index.addDrug(drugId, name); | |
| 473 | + const genericNorm = normalizeLabel(name); | |
| 474 | + await ctx.db.insert(drugAliases).values({ drugId, alias: name, normalized: genericNorm, aliasType: 'generic', sourceId: ctx.sourceId }).onConflictDoNothing(); | |
| 475 | + index.add(genericNorm, drugId, 'generic'); | |
| 476 | + if (saltForm) { | |
| 477 | + const saltNorm = normalizeLabel(saltForm); | |
| 478 | + if (saltNorm && saltNorm !== genericNorm) { | |
| 479 | + await ctx.db.insert(drugAliases).values({ drugId, alias: titleCaseName(saltForm), normalized: saltNorm, aliasType: 'salt', sourceId: ctx.sourceId }).onConflictDoNothing(); | |
| 480 | + index.add(saltNorm, drugId, 'salt'); | |
| 481 | + } | |
| 482 | + } | |
| 483 | + await ctx.recordChange('drug', drugId, 'created', `Created from Health Canada DPD product ${tc.drug_code} (${brand}, ATC ${tc.tc_atc_number} ${tc.tc_atc ?? ''}) — active ingredient ${molecule}`, undefined, { name, slug, atc: tc.tc_atc_number, brand }); | |
| 484 | + return drugId; | |
| 485 | + } | |
| 486 | +} | |
| 487 | + | |
| 488 | +export const connector = new HealthCanadaDpdConnector(); | |
added
packages/connectors/src/connectors/health-canada-dpd/manifest.ts
+111 −0
@@ -0,0 +1,111 @@ | ||
| 1 | +import { defineManifest } from '../../sdk/manifest.js'; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Docs verified 2026-09-11 — https://health-products.canada.ca/api/documentation/dpd-documentation-en.html | |
| 5 | + * ("Drug Product Database (DPD) API Guide") and the live API with User-Agent CancerIndex/0.1: | |
| 6 | + * - GET /api/drug/therapeuticclass/?lang=en&type=json → 48,042 rows {drug_code, tc_atc_number, | |
| 7 | + * tc_atc} (3.7 MB, 0.2 s); one row per drug_code; 1,156 L01 (antineoplastic agents), 255 L02 | |
| 8 | + * (endocrine therapy), 114 L03 (immunostimulants), 14 V10 (therapeutic radiopharmaceuticals). | |
| 9 | + * - GET /api/drug/therapeuticclass/?lang=en&type=json&id=92551 → [{drug_code 92551, tc_atc_number | |
| 10 | + * "L01FF02", tc_atc "PEMBROLIZUMAB"}] (health check). | |
| 11 | + * - GET /api/drug/drugproduct/?lang=en&type=json&id=92551 → {drug_code, class_name "Human" | | |
| 12 | + * "Radiopharmaceutical" | "Veterinary" | "Disinfectant", drug_identification_number "02441152", | |
| 13 | + * brand_name "KEYTRUDA", descriptor, number_of_ais "1", ai_group_no, company_name, last_update_date | |
| 14 | + * "2026-08-13"}. The `status=` parameter documents the status vocabulary: 1 Approved, 2 Marketed, | |
| 15 | + * 3 Cancelled Pre Market, 4 Cancelled Post Market, 6 Dormant, 9 Cancelled (Unreturned Annual), | |
| 16 | + * 10 Cancelled (Safety Issue), 11 Authorized By Interim Order, 12 Authorized… | |
| 17 | + * - GET /api/drug/activeingredient/?id= → [{ingredient_name "PEMBROLIZUMAB" | "IMATINIB (IMATINIB | |
| 18 | + * MESYLATE)", strength, strength_unit, dosage_value, dosage_unit}] — the guide: "Information | |
| 19 | + * enclosed within brackets represents the salt and identifies how the ingredient is supplied." | |
| 20 | + * - GET /api/drug/status/?id= → {status, history_date, original_market_date (null for "Approved" but | |
| 21 | + * not yet marketed products), external_status_code, expiration_date, lot_number}. | |
| 22 | + * - GET /api/drug/route/?id= → [{route_of_administration_code, route_of_administration_name}]. | |
| 23 | + * - Unknown drug_code: object endpoints (drugproduct, status) answer HTTP 200 with zeros/nulls | |
| 24 | + * ({drug_code: 0, …}); array endpoints (activeingredient, route, therapeuticclass) answer HTTP 404 | |
| 25 | + * with an empty body → both are "missing", never a failure. | |
| 26 | + * - No documented rate limit or authentication; the connector self-limits to 2 requests/s, one at a | |
| 27 | + * time (≈ 1,500 products × 4 requests ≈ 50 min for a full pass, restartable per product). | |
| 28 | + * | |
| 29 | + * Licence: the DPD data extract on the Open Government portal | |
| 30 | + * (https://open.canada.ca/data/en/dataset/bf55e42a-63cb-4556-bfd8-44f26e5a36fe, "Licence: Open | |
| 31 | + * Government Licence - Canada") — https://open.canada.ca/en/open-government-licence-canada, quoted | |
| 32 | + * verbatim in `termsNotes`. The DPD web application itself carries no separate licence statement | |
| 33 | + * (verified 2026-09-11: https://health-products.canada.ca/dpd-bdpp/index-eng.jsp footer links only to | |
| 34 | + * the Canada.ca terms and conditions). | |
| 35 | + */ | |
| 36 | +export const HC_DPD_ATTRIBUTION = 'Contains information licensed under the Open Government Licence – Canada. Source: Health Canada, Drug Product Database.'; | |
| 37 | + | |
| 38 | +export const HC_DPD_LICENCE_QUOTE = | |
| 39 | + 'The Information Provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information, including for commercial purposes, subject to the terms below. You are free to: Copy, modify, publish, translate, adapt, distribute or otherwise use the Information in any medium, mode or format for any lawful purpose. You must, where you do any of the above: Acknowledge the source of the Information by including any attribution statement specified by the Information Provider(s) and, where possible, provide a link to this licence. […] you must use the following attribution statement: Contains information licensed under the Open Government Licence – Canada.'; | |
| 40 | + | |
| 41 | +export const manifest = defineManifest({ | |
| 42 | + id: 'health-canada-dpd', | |
| 43 | + name: 'Health Canada — Drug Product Database (DPD)', | |
| 44 | + organization: 'Health Canada', | |
| 45 | + category: 'regulatory', | |
| 46 | + tier: 3, | |
| 47 | + description: | |
| 48 | + 'Canadian regulatory layer (CLAUDE.md §13, jurisdiction CA / authority Health Canada): every human drug product of the Drug Product Database whose therapeutic class is ATC L01 (antineoplastic agents), L02 (endocrine therapy), L03 (immunostimulants) or V10 (therapeutic radiopharmaceuticals) — one DIN-level record per product with its active ingredient(s), brand, market status and dates. Active ingredients are reconciled to CancerIndex drugs by alias (salt forms fold onto the molecule); unknown molecules are minted. The DPD does not publish indications: no cancer is ever inferred from an ATC class.', | |
| 49 | + homepage: 'https://health-products.canada.ca/dpd-bdpp/index-eng.jsp', | |
| 50 | + docsUrl: 'https://health-products.canada.ca/api/documentation/dpd-documentation-en.html', | |
| 51 | + termsUrl: 'https://open.canada.ca/en/open-government-licence-canada', | |
| 52 | + access: { type: 'rest', auth: 'none', baseUrl: 'https://health-products.canada.ca/api/drug' }, | |
| 53 | + license: 'Open Government Licence – Canada (https://open.canada.ca/en/open-government-licence-canada)', | |
| 54 | + licenseStatus: 'approved', | |
| 55 | + commercialUse: 'allowed', | |
| 56 | + redistribution: 'attribution', | |
| 57 | + attribution: HC_DPD_ATTRIBUTION, | |
| 58 | + termsReviewedAt: '2026-09-11', | |
| 59 | + termsNotes: `Open Government Licence – Canada (verified 2026-09-11), quoted: "${HC_DPD_LICENCE_QUOTE}" The DPD data extract is published under this licence on the Open Government portal (dataset bf55e42a-63cb-4556-bfd8-44f26e5a36fe, "Licence: Open Government Licence - Canada"). CancerIndex displays every DPD record as a dated, sourced market-authorization fact per DIN — never as an indication (the DPD does not publish indications; see the Product Monograph) and never as a treatment recommendation.`, | |
| 60 | + updateFrequency: 'DPD is updated daily by Health Canada; the connector runs weekly', | |
| 61 | + expectedLatency: 'Days', | |
| 62 | + // A pass walks the oncology therapeutic-class list sorted by drug_code and keeps the last | |
| 63 | + // processed code in ctx.cursor, so a run stopped by the time budget resumes exactly there. | |
| 64 | + supportsIncrementalSync: true, | |
| 65 | + entities: ['drug_approvals', 'drugs', 'drug_aliases', 'drug_codes', 'source_records'], | |
| 66 | + metrics: [], | |
| 67 | + rateLimits: { | |
| 68 | + requestsPerSecond: 2, | |
| 69 | + maxConcurrency: 1, | |
| 70 | + notes: 'No documented rate limit or key (verified 2026-09-11). Self-imposed 2 req/s, sequential: ≈ 1,500 products × 4 requests ≈ 50 min per full pass; restartable per product.', | |
| 71 | + }, | |
| 72 | + rawRetention: 'full', | |
| 73 | + documentationVerifiedAt: '2026-09-11', | |
| 74 | + status: 'active', | |
| 75 | + schedule: '0 5 * * 2', | |
| 76 | + checkpointEvery: 50, | |
| 77 | +}); | |
| 78 | + | |
| 79 | +export const HC_DPD_API = manifest.access.baseUrl!; | |
| 80 | + | |
| 81 | +/** ATC groups kept from the bulk therapeutic-class list (tagged in raw.atcGroup). */ | |
| 82 | +export const ONCOLOGY_ATC_GROUPS = ['L01', 'L02', 'L03', 'V10'] as const; | |
| 83 | +export type OncologyAtcGroup = (typeof ONCOLOGY_ATC_GROUPS)[number]; | |
| 84 | + | |
| 85 | +/** Products of these DPD classes are ingested; Veterinary / Disinfectant products are skipped. */ | |
| 86 | +export const HUMAN_CLASSES = ['Human', 'Radiopharmaceutical'] as const; | |
| 87 | + | |
| 88 | +export const DRY_RUN_PRODUCTS = 20; | |
| 89 | +export const HEALTH_CHECK_DRUG_CODE = 92551; // KEYTRUDA (pembrolizumab) — must return L01FF02 | |
| 90 | +export const HEALTH_CHECK_ATC = 'L01FF02'; | |
| 91 | + | |
| 92 | +export function therapeuticClassUrl(drugCode?: number): string { | |
| 93 | + return `${HC_DPD_API}/therapeuticclass/?lang=en&type=json${drugCode !== undefined ? `&id=${drugCode}` : ''}`; | |
| 94 | +} | |
| 95 | +export function drugProductUrl(drugCode: number): string { | |
| 96 | + return `${HC_DPD_API}/drugproduct/?lang=en&type=json&id=${drugCode}`; | |
| 97 | +} | |
| 98 | +export function activeIngredientUrl(drugCode: number): string { | |
| 99 | + return `${HC_DPD_API}/activeingredient/?lang=en&type=json&id=${drugCode}`; | |
| 100 | +} | |
| 101 | +export function statusUrl(drugCode: number): string { | |
| 102 | + return `${HC_DPD_API}/status/?lang=en&type=json&id=${drugCode}`; | |
| 103 | +} | |
| 104 | +export function routeUrl(drugCode: number): string { | |
| 105 | + return `${HC_DPD_API}/route/?lang=en&type=json&id=${drugCode}`; | |
| 106 | +} | |
| 107 | + | |
| 108 | +/** Public product page used as provenance URL (verified HTTP 200 for code 92551 on 2026-09-11). */ | |
| 109 | +export function productProvenanceUrl(drugCode: number): string { | |
| 110 | + return `https://health-products.canada.ca/dpd-bdpp/info?lang=eng&code=${drugCode}`; | |
| 111 | +} | |
added
packages/connectors/src/connectors/health-canada-dpd/normalize.ts
+230 −0
@@ -0,0 +1,230 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { normalizeLabel } from '@cancerindex/shared'; | |
| 3 | +import { FORMULATION_TOKENS, SALT_TOKENS } from '../clinicaltrials/drugs.js'; | |
| 4 | +import { HUMAN_CLASSES, ONCOLOGY_ATC_GROUPS, type OncologyAtcGroup } from './manifest.js'; | |
| 5 | + | |
| 6 | +/* ------------------------------------------------------------------------------------------------ | |
| 7 | + * Response shapes (verified against the live API on 2026-09-11; unknown fields kept for the lake). | |
| 8 | + * Numbers arrive as numbers (drug_code) or strings (number_of_ais "1"); both are accepted. | |
| 9 | + * ---------------------------------------------------------------------------------------------- */ | |
| 10 | + | |
| 11 | +const intish = z.union([z.number(), z.string()]).transform((v) => (typeof v === 'number' ? v : Number.parseInt(v, 10))); | |
| 12 | + | |
| 13 | +export const TherapeuticClassRow = z | |
| 14 | + .object({ | |
| 15 | + drug_code: intish, | |
| 16 | + tc_atc_number: z.string().nullish(), | |
| 17 | + tc_atc: z.string().nullish(), | |
| 18 | + }) | |
| 19 | + .passthrough(); | |
| 20 | +export type TherapeuticClassRow = z.infer<typeof TherapeuticClassRow>; | |
| 21 | + | |
| 22 | +export const Product = z | |
| 23 | + .object({ | |
| 24 | + drug_code: intish, | |
| 25 | + class_name: z.string().nullish(), | |
| 26 | + drug_identification_number: z.string().nullish(), | |
| 27 | + brand_name: z.string().nullish(), | |
| 28 | + descriptor: z.string().nullish(), | |
| 29 | + number_of_ais: z.union([z.number(), z.string()]).nullish(), | |
| 30 | + ai_group_no: z.string().nullish(), | |
| 31 | + company_name: z.string().nullish(), | |
| 32 | + last_update_date: z.string().nullish(), | |
| 33 | + }) | |
| 34 | + .passthrough(); | |
| 35 | +export type Product = z.infer<typeof Product>; | |
| 36 | + | |
| 37 | +export const ActiveIngredient = z | |
| 38 | + .object({ | |
| 39 | + drug_code: intish.optional(), | |
| 40 | + ingredient_name: z.string(), | |
| 41 | + strength: z.string().nullish(), | |
| 42 | + strength_unit: z.string().nullish(), | |
| 43 | + dosage_value: z.string().nullish(), | |
| 44 | + dosage_unit: z.string().nullish(), | |
| 45 | + }) | |
| 46 | + .passthrough(); | |
| 47 | +export type ActiveIngredient = z.infer<typeof ActiveIngredient>; | |
| 48 | + | |
| 49 | +export const ProductStatus = z | |
| 50 | + .object({ | |
| 51 | + drug_code: intish.optional(), | |
| 52 | + status: z.string().nullish(), | |
| 53 | + history_date: z.string().nullish(), | |
| 54 | + original_market_date: z.string().nullish(), | |
| 55 | + external_status_code: z.union([z.number(), z.string()]).nullish(), | |
| 56 | + expiration_date: z.string().nullish(), | |
| 57 | + lot_number: z.string().nullish(), | |
| 58 | + }) | |
| 59 | + .passthrough(); | |
| 60 | +export type ProductStatus = z.infer<typeof ProductStatus>; | |
| 61 | + | |
| 62 | +export const Route = z | |
| 63 | + .object({ | |
| 64 | + drug_code: intish.optional(), | |
| 65 | + route_of_administration_code: z.union([z.number(), z.string()]).nullish(), | |
| 66 | + route_of_administration_name: z.string().nullish(), | |
| 67 | + }) | |
| 68 | + .passthrough(); | |
| 69 | +export type Route = z.infer<typeof Route>; | |
| 70 | + | |
| 71 | +/** Composite payload persisted per product (source_records entity `product`, replayable in backfill). */ | |
| 72 | +export interface ProductBundle { | |
| 73 | + therapeuticClass: { drug_code: number; tc_atc_number: string; tc_atc: string | null; atcGroup: OncologyAtcGroup }; | |
| 74 | + product: Product; | |
| 75 | + ingredients: ActiveIngredient[]; | |
| 76 | + status: ProductStatus | null; | |
| 77 | + routes: Route[]; | |
| 78 | +} | |
| 79 | + | |
| 80 | +/* ------------------------------------------------------------------------------------------------ | |
| 81 | + * Pure rules | |
| 82 | + * ---------------------------------------------------------------------------------------------- */ | |
| 83 | + | |
| 84 | +/** "L01FF02" → "L01"; anything outside the oncology groups → null. */ | |
| 85 | +export function oncologyAtcGroup(atc: string | null | undefined): OncologyAtcGroup | null { | |
| 86 | + if (!atc) return null; | |
| 87 | + const g = atc.trim().toUpperCase().slice(0, 3); | |
| 88 | + return (ONCOLOGY_ATC_GROUPS as readonly string[]).includes(g) ? (g as OncologyAtcGroup) : null; | |
| 89 | +} | |
| 90 | + | |
| 91 | +/** The therapeutic-class rows to walk: oncology ATC groups, one per drug_code, sorted by drug_code. */ | |
| 92 | +export function selectOncologyRows(rows: unknown[]): { kept: Array<{ drug_code: number; tc_atc_number: string; tc_atc: string | null; atcGroup: OncologyAtcGroup }>; invalid: number; byGroup: Record<OncologyAtcGroup, number> } { | |
| 93 | + const byCode = new Map<number, { drug_code: number; tc_atc_number: string; tc_atc: string | null; atcGroup: OncologyAtcGroup }>(); | |
| 94 | + const byGroup: Record<OncologyAtcGroup, number> = { L01: 0, L02: 0, L03: 0, V10: 0 }; | |
| 95 | + let invalid = 0; | |
| 96 | + for (const raw of rows) { | |
| 97 | + const parsed = TherapeuticClassRow.safeParse(raw); | |
| 98 | + if (!parsed.success || !Number.isFinite(parsed.data.drug_code) || parsed.data.drug_code <= 0) { | |
| 99 | + invalid++; | |
| 100 | + continue; | |
| 101 | + } | |
| 102 | + const group = oncologyAtcGroup(parsed.data.tc_atc_number); | |
| 103 | + if (!group) continue; | |
| 104 | + if (byCode.has(parsed.data.drug_code)) continue; | |
| 105 | + byCode.set(parsed.data.drug_code, { drug_code: parsed.data.drug_code, tc_atc_number: parsed.data.tc_atc_number!.trim().toUpperCase(), tc_atc: parsed.data.tc_atc?.trim() || null, atcGroup: group }); | |
| 106 | + byGroup[group]++; | |
| 107 | + } | |
| 108 | + return { kept: [...byCode.values()].sort((a, b) => a.drug_code - b.drug_code), invalid, byGroup }; | |
| 109 | +} | |
| 110 | + | |
| 111 | +/** Unknown drug_code: `{drug_code: 0, …nulls}` (HTTP 200) — treated as missing, never as a failure. */ | |
| 112 | +export function isMissingProduct(p: Product | null | undefined): boolean { | |
| 113 | + return !p || !p.drug_code || p.drug_code <= 0 || !p.drug_identification_number; | |
| 114 | +} | |
| 115 | + | |
| 116 | +export function isHumanProduct(p: Product): boolean { | |
| 117 | + return (HUMAN_CLASSES as readonly string[]).includes((p.class_name ?? '').trim()); | |
| 118 | +} | |
| 119 | + | |
| 120 | +/** "1" | 1 → 1; garbage → null. */ | |
| 121 | +export function numberOfIngredients(p: Product): number | null { | |
| 122 | + const v = p.number_of_ais; | |
| 123 | + if (v === null || v === undefined || v === '') return null; | |
| 124 | + const n = typeof v === 'number' ? v : Number.parseInt(v, 10); | |
| 125 | + return Number.isFinite(n) && n >= 0 ? n : null; | |
| 126 | +} | |
| 127 | + | |
| 128 | +/** ISO date already ("2015-06-01"); anything else → null (never fabricate a date). */ | |
| 129 | +export function dpdDate(s: string | null | undefined): string | null { | |
| 130 | + if (!s) return null; | |
| 131 | + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s.trim()); | |
| 132 | + if (!m) return null; | |
| 133 | + const month = Number(m[2]); | |
| 134 | + const day = Number(m[3]); | |
| 135 | + if (month < 1 || month > 12 || day < 1 || day > 31) return null; | |
| 136 | + return `${m[1]}-${m[2]}-${m[3]}`; | |
| 137 | +} | |
| 138 | + | |
| 139 | +export type ApprovalStatus = 'approved' | 'withdrawn'; | |
| 140 | + | |
| 141 | +/** | |
| 142 | + * DPD product status → drug_approvals.status (CLAUDE.md §13: country-aware, never a bare boolean). | |
| 143 | + * Marketed, Approved, Authorized By Interim Order, Authorized… → 'approved' (a valid DIN). | |
| 144 | + * Cancelled Post Market / Pre Market / (Safety Issue) / (Unreturned Annual), Dormant → 'withdrawn' | |
| 145 | + * (withdrawal_date = history_date). A cancelled or dormant DIN is one product's status — it does | |
| 146 | + * not mean the molecule left the Canadian market; the verbatim DPD status is kept in raw.dpdStatus. | |
| 147 | + * Unknown / missing status → null (row still written? no — the caller skips it and queues a warning). | |
| 148 | + */ | |
| 149 | +export function mapDpdStatus(status: string | null | undefined): ApprovalStatus | null { | |
| 150 | + const s = (status ?? '').trim().toLowerCase(); | |
| 151 | + if (!s) return null; | |
| 152 | + if (s === 'marketed' || s === 'approved' || s.startsWith('authorized')) return 'approved'; | |
| 153 | + if (s.startsWith('cancelled') || s === 'dormant') return 'withdrawn'; | |
| 154 | + return null; | |
| 155 | +} | |
| 156 | + | |
| 157 | +/** | |
| 158 | + * "IMATINIB (IMATINIB MESYLATE)" → { molecule: "IMATINIB", saltForm: "IMATINIB MESYLATE" }; | |
| 159 | + * "BORTEZOMIB (BORTEZOMIB MANNITOL BORONIC ESTER)" → molecule BORTEZOMIB; "PEMBROLIZUMAB" → no salt. | |
| 160 | + * "DOXORUBICIN HYDROCHLORIDE" (salt without brackets) → molecule DOXORUBICIN, saltForm as given. | |
| 161 | + * Deterministic string rules only; the salt-token list is the one shared with trial reconciliation. | |
| 162 | + */ | |
| 163 | +export function parseIngredientName(name: string): { molecule: string; saltForm: string | null; normalizedMolecule: string; normalizedFull: string } { | |
| 164 | + const full = name.replace(/\s+/g, ' ').trim(); | |
| 165 | + const m = /^([^()]+?)\s*\(([^()]+)\)\s*$/.exec(full); | |
| 166 | + let molecule = (m ? m[1]! : full).trim(); | |
| 167 | + let saltForm: string | null = m ? m[2]!.trim() : null; | |
| 168 | + // Trailing salt tokens outside brackets ("DOXORUBICIN HYDROCHLORIDE"). | |
| 169 | + const tokens = molecule.split(' '); | |
| 170 | + while (tokens.length > 1 && SALT_TOKENS.includes(tokens[tokens.length - 1]!.toLowerCase())) tokens.pop(); | |
| 171 | + if (tokens.length !== molecule.split(' ').length) { | |
| 172 | + saltForm = saltForm ?? molecule; | |
| 173 | + molecule = tokens.join(' '); | |
| 174 | + } | |
| 175 | + return { molecule, saltForm, normalizedMolecule: normalizeLabel(molecule), normalizedFull: normalizeLabel(full) }; | |
| 176 | +} | |
| 177 | + | |
| 178 | +/** | |
| 179 | + * Kit components the DPD lists as "active ingredients" of a multi-ingredient product but that are | |
| 180 | + * never a drug entity (diluents, buffers, carriers). Skipped silently — not queued as drug labels. | |
| 181 | + */ | |
| 182 | +const EXCIPIENT_RE = /^(sterile )?(water|water for injection|sodium chloride|buffer solution|buffer|diluent|dextrose|glucose|mannitol|sucrose|lactose|glycerin|glycerol|benzyl alcohol|ethanol|alcohol|albumin( \(human\))?|human albumin|polysorbate \d+|sodium bicarbonate|sodium phosphate|potassium chloride|calcium chloride|magnesium chloride|acetic acid|hydrochloric acid|sodium hydroxide|edetate disodium|edta)$/i; | |
| 183 | + | |
| 184 | +export function isExcipient(ingredientName: string): boolean { | |
| 185 | + return EXCIPIENT_RE.test(ingredientName.replace(/\s+/g, ' ').trim()); | |
| 186 | +} | |
| 187 | + | |
| 188 | +/** "PEMBROLIZUMAB" → "Pembrolizumab"; "INTERFERON ALFA-2B" → "Interferon Alfa-2b"; short all-caps codes (BCG, I) stay. */ | |
| 189 | +export function titleCaseName(s: string): string { | |
| 190 | + return s | |
| 191 | + .trim() | |
| 192 | + .split(/\s+/) | |
| 193 | + .map((w) => { | |
| 194 | + if (/^[A-Z0-9]{1,3}$/.test(w)) return w; | |
| 195 | + return w | |
| 196 | + .split('-') | |
| 197 | + .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1).toLowerCase() : part)) | |
| 198 | + .join('-'); | |
| 199 | + }) | |
| 200 | + .join(' '); | |
| 201 | +} | |
| 202 | + | |
| 203 | +const BRAND_NOISE = new Set(['tab', 'tabs', 'tablet', 'tablets', 'cap', 'caps', 'capsule', 'capsules', 'inj', 'injection', 'injectable', 'vial', 'vials', 'ampoule', 'ampoules', 'amp', 'amps', 'pws', 'pwr', 'powder', 'sol', 'solution', 'susp', 'suspension', 'for', 'oral', 'iv', 'sc', 'im', 'kit', 'liq', 'liquid', 'cream', 'ointment', 'syrup', 'concentrate', 'infusion', 'lyophilized', 'lyo', 'unit', 'units', 'usp', 'bp']); | |
| 204 | +const DOSE_RE = /^\d+(\.\d+)?(mg|mcg|ug|g|ml|iu|u|units?|meq|mbq|mci|gbq)(\/(ml|kg|m2|vial|dose|hr|h|day|d))?$|^\d+(\.\d+)?%$|^\d+(\.\d+)?$/i; | |
| 205 | + | |
| 206 | +/** | |
| 207 | + * DPD brand names often embed the presentation ("PROCYTOX TABLETS 50MG", "VELBE 1MG/ML", | |
| 208 | + * "THIO TEPA INJ 15MG/VIAL"). For the brand alias, drop trailing dose / form tokens | |
| 209 | + * (never the first token); the published brand stays verbatim in the approval indication text. | |
| 210 | + */ | |
| 211 | +export function cleanBrandName(brand: string): string { | |
| 212 | + const tokens = brand.replace(/\s+/g, ' ').trim().split(' '); | |
| 213 | + while (tokens.length > 1) { | |
| 214 | + const last = tokens[tokens.length - 1]!.toLowerCase().replace(/[(),]/g, ''); | |
| 215 | + if (!last || BRAND_NOISE.has(last) || FORMULATION_TOKENS.includes(last) || DOSE_RE.test(last) || /^\d/.test(last)) tokens.pop(); | |
| 216 | + else break; | |
| 217 | + } | |
| 218 | + return tokens.join(' ').trim() || brand.trim(); | |
| 219 | +} | |
| 220 | + | |
| 221 | +/** Honest indication text: the DPD publishes no indications (CLAUDE.md §3 — never fabricate). */ | |
| 222 | +export function indicationText(brand: string, din: string, atc: string, atcLabel: string | null): string { | |
| 223 | + return `Marketed in Canada as ${brand} (DIN ${din}) under ATC ${atc}${atcLabel ? ` ${atcLabel}` : ''}. Indications are not published in the Drug Product Database — see the Health Canada Product Monograph.`; | |
| 224 | +} | |
| 225 | + | |
| 226 | +/** Zero-pad a DIN to 8 digits (the API already returns "02441152"; tolerate numeric input). */ | |
| 227 | +export function normalizeDin(din: string | number): string { | |
| 228 | + const s = String(din).replace(/\D/g, ''); | |
| 229 | + return s.padStart(8, '0'); | |
| 230 | +} | |
modified
packages/connectors/src/registry.ts
+2 −1
@@ -13,6 +13,7 @@ import { connector as seer } from './connectors/seer/index.js'; | ||
| 13 | 13 | import { connector as seerExplorer } from './connectors/seer/explorer.js'; |
| 14 | 14 | import { connector as iarcGlobocan } from './connectors/iarc-globocan/index.js'; |
| 15 | 15 | import { connector as openfda } from './connectors/openfda/index.js'; |
| 16 | +import { connector as healthCanadaDpd } from './connectors/health-canada-dpd/index.js'; | |
| 16 | 17 | import { connector as cbioportal } from './connectors/cbioportal/index.js'; |
| 17 | 18 | import { connector as mesh } from './connectors/mesh/index.js'; |
| 18 | 19 | import { connector as chembl } from './connectors/chembl/index.js'; |
@@ -23,7 +24,7 @@ import { connector as chembl } from './connectors/chembl/index.js'; | ||
| 23 | 24 | * genomics/trials/literature/variants/evidence, then epidemiology. |
| 24 | 25 | * Add new connectors here; `pnpm cix sources:sync` seeds their manifests into `sources`. |
| 25 | 26 | */ |
| 26 | −export const CONNECTORS: Connector[] = [ncitEvs, oncotree, hgnc, clinicaltrials, pubmed, civic, clinvar, gdc, cdcWonder, cdcUscs, seer, seerExplorer, iarcGlobocan, mesh, chembl, openfda, cbioportal]; | |
| 27 | +export const CONNECTORS: Connector[] = [ncitEvs, oncotree, hgnc, clinicaltrials, pubmed, civic, clinvar, gdc, cdcWonder, cdcUscs, seer, seerExplorer, iarcGlobocan, mesh, chembl, openfda, healthCanadaDpd, cbioportal]; | |
| 27 | 28 | |
| 28 | 29 | export function getConnector(id: string): Connector | undefined { |
| 29 | 30 | return CONNECTORS.find((c) => c.manifest.id === id); |
added
packages/ranking/src/drug-duplicates.test.ts
+62 −0
@@ -0,0 +1,62 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { DRUG_SALT_TOKENS, MIN_SHARED_ALIASES, detectDuplicateCandidates, moleculeKey, pickKeep, type DrugLite } from './drug-duplicates.js'; | |
| 3 | + | |
| 4 | +const d = (id: string, name: string, aliases: Array<[string, string]> = []): DrugLite => ({ id, name, aliases: aliases.map(([normalized, aliasType]) => ({ normalized, aliasType })) }); | |
| 5 | + | |
| 6 | +describe('drug duplicates — molecule key', () => { | |
| 7 | + it('strips trailing salt tokens and parentheticals, keeps the first token', () => { | |
| 8 | + expect(moleculeKey('Imatinib Mesylate')).toBe('imatinib'); | |
| 9 | + expect(moleculeKey('Erlotinib Hydrochloride')).toBe('erlotinib'); | |
| 10 | + expect(moleculeKey('Sorafenib Tosylate')).toBe('sorafenib'); | |
| 11 | + expect(moleculeKey('Abiraterone Acetate')).toBe('abiraterone'); | |
| 12 | + expect(moleculeKey('Trastuzumab Deruxtecan')).toBe('trastuzumab deruxtecan'); | |
| 13 | + expect(moleculeKey('Imatinib (Imatinib Mesylate)')).toBe('imatinib'); | |
| 14 | + expect(moleculeKey('Sodium')).toBe('sodium'); // never empties a name | |
| 15 | + expect(moleculeKey('JQ1')).toBe('jq1'); | |
| 16 | + }); | |
| 17 | + it('the salt list is the connectors list (kept in sync by hand)', () => { | |
| 18 | + expect(DRUG_SALT_TOKENS).toContain('mesylate'); | |
| 19 | + expect(DRUG_SALT_TOKENS).toContain('tosylate'); | |
| 20 | + expect(new Set(DRUG_SALT_TOKENS).size).toBe(DRUG_SALT_TOKENS.length); | |
| 21 | + }); | |
| 22 | +}); | |
| 23 | + | |
| 24 | +describe('drug duplicates — detector', () => { | |
| 25 | + it('salt-form pairs: keep the base molecule, merge the salt', () => { | |
| 26 | + const c = detectDuplicateCandidates([d('CI-DRUG-00000002', 'Imatinib Mesylate'), d('CI-DRUG-00000001', 'Imatinib'), d('CI-DRUG-00000003', 'Osimertinib')]); | |
| 27 | + expect(c).toHaveLength(1); | |
| 28 | + expect(c[0]).toMatchObject({ keepId: 'CI-DRUG-00000001', mergeId: 'CI-DRUG-00000002', reason: 'salt_form' }); | |
| 29 | + expect(c[0]!.evidence).toMatchObject({ moleculeKey: 'imatinib', keepName: 'Imatinib', mergeName: 'Imatinib Mesylate' }); | |
| 30 | + }); | |
| 31 | + it('identical names → same_name, smaller id kept', () => { | |
| 32 | + const c = detectDuplicateCandidates([d('CI-DRUG-00000009', 'Regorafenib'), d('CI-DRUG-00000004', 'Regorafenib')]); | |
| 33 | + expect(c).toEqual([expect.objectContaining({ keepId: 'CI-DRUG-00000004', mergeId: 'CI-DRUG-00000009', reason: 'same_name' })]); | |
| 34 | + }); | |
| 35 | + it('shared generic/brand/development_code aliases (≥ 2) → candidate; synonyms and single shared alias do not count', () => { | |
| 36 | + const a = d('CI-DRUG-00000010', 'Drug A', [['tagrisso', 'brand'], ['azd9291', 'development_code'], ['foo', 'synonym']]); | |
| 37 | + const b = d('CI-DRUG-00000011', 'Drug Beta', [['tagrisso', 'brand'], ['azd9291', 'development_code'], ['foo', 'synonym']]); | |
| 38 | + const c1 = d('CI-DRUG-00000012', 'Drug C', [['tagrisso', 'brand'], ['bar', 'synonym'], ['foo', 'synonym']]); | |
| 39 | + const out = detectDuplicateCandidates([a, b, c1]); | |
| 40 | + expect(out).toHaveLength(1); | |
| 41 | + expect(out[0]).toMatchObject({ keepId: 'CI-DRUG-00000010', mergeId: 'CI-DRUG-00000011', reason: 'shared_aliases' }); | |
| 42 | + expect((out[0]!.evidence.sharedAliases as string[]).length).toBeGreaterThanOrEqual(MIN_SHARED_ALIASES); | |
| 43 | + }); | |
| 44 | + it('one candidate per pair, strongest reason wins', () => { | |
| 45 | + const a = d('CI-DRUG-00000020', 'Sorafenib', [['nexavar', 'brand'], ['bay 43 9006', 'development_code']]); | |
| 46 | + const b = d('CI-DRUG-00000021', 'Sorafenib Tosylate', [['nexavar', 'brand'], ['bay 43 9006', 'development_code']]); | |
| 47 | + const out = detectDuplicateCandidates([a, b]); | |
| 48 | + expect(out).toHaveLength(1); | |
| 49 | + expect(out[0]!.reason).toBe('salt_form'); | |
| 50 | + }); | |
| 51 | + it('pickKeep prefers an INN over a development code, then fewer tokens, then the shorter name, then the smaller id', () => { | |
| 52 | + expect(pickKeep(d('CI-DRUG-00000566', 'TAK-788'), d('CI-DRUG-00000382', 'Mobocertinib')).keep.id).toBe('CI-DRUG-00000382'); | |
| 53 | + expect(pickKeep(d('CI-DRUG-00000499', 'RDEA 119'), d('CI-DRUG-00000511', 'Refametinib')).keep.id).toBe('CI-DRUG-00000511'); | |
| 54 | + expect(pickKeep(d('CI-DRUG-00000002', 'Erlotinib Hydrochloride'), d('CI-DRUG-00000001', 'Erlotinib')).keep.id).toBe('CI-DRUG-00000001'); | |
| 55 | + expect(pickKeep(d('CI-DRUG-00000002', 'Abc'), d('CI-DRUG-00000001', 'Abcd')).keep.id).toBe('CI-DRUG-00000002'); | |
| 56 | + expect(pickKeep(d('CI-DRUG-00000002', 'Same'), d('CI-DRUG-00000001', 'Same')).keep.id).toBe('CI-DRUG-00000001'); | |
| 57 | + }); | |
| 58 | + it('no duplicates → empty', () => { | |
| 59 | + expect(detectDuplicateCandidates([d('CI-DRUG-00000001', 'Osimertinib'), d('CI-DRUG-00000002', 'Pembrolizumab')])).toEqual([]); | |
| 60 | + expect(detectDuplicateCandidates([])).toEqual([]); | |
| 61 | + }); | |
| 62 | +}); | |
added
packages/ranking/src/drug-duplicates.ts
+165 −0
@@ -0,0 +1,165 @@ | ||
| 1 | +import { sql } from 'drizzle-orm'; | |
| 2 | +import type { Database } from '@cancerindex/database'; | |
| 3 | +import { normalizeLabel } from '@cancerindex/shared'; | |
| 4 | + | |
| 5 | +export const DRUG_DUPLICATE_RULES_VERSION = 'ci-drug-duplicates-v1'; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Salt / ester / hydrate tokens that never change the molecule (CLAUDE.md §7). This is a copy of | |
| 9 | + * `SALT_TOKENS` exported by `@cancerindex/connectors` (connectors/clinicaltrials/drugs.ts): the | |
| 10 | + * ranking package must not depend on the connectors package, so the list is duplicated here on | |
| 11 | + * purpose — keep both in sync when adding a token. | |
| 12 | + */ | |
| 13 | +export const DRUG_SALT_TOKENS = [ | |
| 14 | + 'hydrochloride', 'dihydrochloride', 'hcl', 'sulfate', 'sulphate', 'acetate', 'sodium', 'disodium', 'potassium', 'calcium', 'magnesium', 'mesylate', 'mesilate', 'dimesylate', 'citrate', 'tartrate', 'bitartrate', 'maleate', 'malate', 'fumarate', 'phosphate', 'diphosphate', 'succinate', 'tosylate', 'besylate', 'bromide', 'chloride', 'lactate', 'gluconate', 'pamoate', 'trihydrate', 'dihydrate', 'monohydrate', 'hydrate', 'anhydrous', 'ditosylate', 'camsylate', 'hemihydrate', 'hydrobromide', 'nitrate', 'oxalate', 'propionate', 'valerate', 'decanoate', 'enanthate', 'undecanoate', 'cypionate', 'pivalate', 'isethionate', 'trifluoroacetate', | |
| 15 | +] as const; | |
| 16 | +const SALTS = new Set<string>(DRUG_SALT_TOKENS); | |
| 17 | + | |
| 18 | +/** Alias types that count as evidence of identity (synonyms are too noisy). */ | |
| 19 | +export const DUPLICATE_ALIAS_TYPES = ['generic', 'brand', 'development_code'] as const; | |
| 20 | +/** Two drugs sharing at least this many qualifying aliases are duplicate candidates. */ | |
| 21 | +export const MIN_SHARED_ALIASES = 2; | |
| 22 | + | |
| 23 | +export interface DrugLite { | |
| 24 | + id: string; | |
| 25 | + name: string; | |
| 26 | + aliases: Array<{ normalized: string; aliasType: string }>; | |
| 27 | +} | |
| 28 | + | |
| 29 | +export type DuplicateReason = 'same_name' | 'salt_form' | 'shared_aliases'; | |
| 30 | + | |
| 31 | +export interface MergeCandidate { | |
| 32 | + keepId: string; | |
| 33 | + mergeId: string; | |
| 34 | + reason: DuplicateReason; | |
| 35 | + evidence: Record<string, unknown>; | |
| 36 | +} | |
| 37 | + | |
| 38 | +/** | |
| 39 | + * Molecule key of a drug name: normalized label without parentheticals and without trailing salt | |
| 40 | + * tokens ("Imatinib Mesylate" → "imatinib", "Sorafenib Tosylate" → "sorafenib", "JQ1" → "jq1"). | |
| 41 | + * Deterministic; never touches the first token. | |
| 42 | + */ | |
| 43 | +export function moleculeKey(name: string): string { | |
| 44 | + const tokens = normalizeLabel(name.replace(/\([^)]*\)/g, ' ')).split(' ').filter(Boolean); | |
| 45 | + while (tokens.length > 1 && SALTS.has(tokens[tokens.length - 1]!)) tokens.pop(); | |
| 46 | + return tokens.join(' '); | |
| 47 | +} | |
| 48 | + | |
| 49 | +/** Development codes (TAK-788, AZD9291, RDEA 119, BAY 43-9006) carry digits; INNs do not. */ | |
| 50 | +export function looksLikeDevelopmentCode(name: string): boolean { | |
| 51 | + return /\d/.test(name); | |
| 52 | +} | |
| 53 | + | |
| 54 | +/** | |
| 55 | + * Keep = the base-molecule drug: an INN-like name over a development code, then fewer name | |
| 56 | + * tokens, then the shorter name, then the smaller id. | |
| 57 | + */ | |
| 58 | +export function pickKeep(a: DrugLite, b: DrugLite): { keep: DrugLite; merge: DrugLite } { | |
| 59 | + const ca = looksLikeDevelopmentCode(a.name); | |
| 60 | + const cb = looksLikeDevelopmentCode(b.name); | |
| 61 | + if (ca !== cb) return ca ? { keep: b, merge: a } : { keep: a, merge: b }; | |
| 62 | + const ta = a.name.trim().split(/\s+/).length; | |
| 63 | + const tb = b.name.trim().split(/\s+/).length; | |
| 64 | + if (ta !== tb) return ta < tb ? { keep: a, merge: b } : { keep: b, merge: a }; | |
| 65 | + if (a.name.length !== b.name.length) return a.name.length < b.name.length ? { keep: a, merge: b } : { keep: b, merge: a }; | |
| 66 | + return a.id < b.id ? { keep: a, merge: b } : { keep: b, merge: a }; | |
| 67 | +} | |
| 68 | + | |
| 69 | +/** | |
| 70 | + * Duplicate candidates (SPEC §97): (1) equal molecule keys — identical names or salt-form variants — | |
| 71 | + * and (2) ≥ MIN_SHARED_ALIASES shared generic/brand/development_code aliases. Pure; one candidate | |
| 72 | + * per unordered pair, the strongest reason first (same_name > salt_form > shared_aliases). | |
| 73 | + */ | |
| 74 | +export function detectDuplicateCandidates(drugs: DrugLite[]): MergeCandidate[] { | |
| 75 | + const byId = new Map(drugs.map((d) => [d.id, d])); | |
| 76 | + const out = new Map<string, MergeCandidate>(); | |
| 77 | + const pairKey = (a: string, b: string) => (a < b ? `${a}|${b}` : `${b}|${a}`); | |
| 78 | + | |
| 79 | + // 1. Molecule key groups | |
| 80 | + const groups = new Map<string, DrugLite[]>(); | |
| 81 | + for (const d of drugs) { | |
| 82 | + const key = moleculeKey(d.name); | |
| 83 | + if (!key) continue; | |
| 84 | + groups.set(key, [...(groups.get(key) ?? []), d]); | |
| 85 | + } | |
| 86 | + for (const [key, members] of groups) { | |
| 87 | + if (members.length < 2) continue; | |
| 88 | + for (let i = 0; i < members.length; i++) { | |
| 89 | + for (let j = i + 1; j < members.length; j++) { | |
| 90 | + const a = members[i]!; | |
| 91 | + const b = members[j]!; | |
| 92 | + const { keep, merge } = pickKeep(a, b); | |
| 93 | + const sameName = normalizeLabel(a.name) === normalizeLabel(b.name); | |
| 94 | + out.set(pairKey(a.id, b.id), { | |
| 95 | + keepId: keep.id, | |
| 96 | + mergeId: merge.id, | |
| 97 | + reason: sameName ? 'same_name' : 'salt_form', | |
| 98 | + evidence: { rule: sameName ? 'identical normalized names' : 'equal names after stripping salt tokens', version: DRUG_DUPLICATE_RULES_VERSION, moleculeKey: key, keepName: keep.name, mergeName: merge.name, saltTokens: DRUG_SALT_TOKENS.length }, | |
| 99 | + }); | |
| 100 | + } | |
| 101 | + } | |
| 102 | + } | |
| 103 | + | |
| 104 | + // 2. Shared aliases | |
| 105 | + const aliasOwners = new Map<string, Set<string>>(); | |
| 106 | + for (const d of drugs) { | |
| 107 | + for (const a of d.aliases) { | |
| 108 | + if (!(DUPLICATE_ALIAS_TYPES as readonly string[]).includes(a.aliasType) || !a.normalized) continue; | |
| 109 | + aliasOwners.set(a.normalized, (aliasOwners.get(a.normalized) ?? new Set()).add(d.id)); | |
| 110 | + } | |
| 111 | + } | |
| 112 | + const shared = new Map<string, Set<string>>(); | |
| 113 | + for (const [alias, owners] of aliasOwners) { | |
| 114 | + if (owners.size < 2 || owners.size > 6) continue; // very common aliases (class words) are not identity evidence | |
| 115 | + const ids = [...owners].sort(); | |
| 116 | + for (let i = 0; i < ids.length; i++) for (let j = i + 1; j < ids.length; j++) shared.set(pairKey(ids[i]!, ids[j]!), (shared.get(pairKey(ids[i]!, ids[j]!)) ?? new Set()).add(alias)); | |
| 117 | + } | |
| 118 | + for (const [key, aliases] of shared) { | |
| 119 | + if (aliases.size < MIN_SHARED_ALIASES || out.has(key)) continue; | |
| 120 | + const [ia, ib] = key.split('|') as [string, string]; | |
| 121 | + const a = byId.get(ia)!; | |
| 122 | + const b = byId.get(ib)!; | |
| 123 | + const { keep, merge } = pickKeep(a, b); | |
| 124 | + out.set(key, { | |
| 125 | + keepId: keep.id, | |
| 126 | + mergeId: merge.id, | |
| 127 | + reason: 'shared_aliases', | |
| 128 | + evidence: { rule: `≥ ${MIN_SHARED_ALIASES} shared aliases of type ${DUPLICATE_ALIAS_TYPES.join('/')}`, version: DRUG_DUPLICATE_RULES_VERSION, sharedAliases: [...aliases].sort().slice(0, 20), keepName: keep.name, mergeName: merge.name }, | |
| 129 | + }); | |
| 130 | + } | |
| 131 | + return [...out.values()].sort((x, y) => x.keepId.localeCompare(y.keepId) || x.mergeId.localeCompare(y.mergeId)); | |
| 132 | +} | |
| 133 | + | |
| 134 | +export interface ProposeMergesResult { | |
| 135 | + candidates: number; | |
| 136 | + inserted: number; | |
| 137 | + alreadyQueued: number; | |
| 138 | +} | |
| 139 | + | |
| 140 | +/** | |
| 141 | + * Insert `entity_merges` proposals (entity_type 'drug', status 'proposed') for every candidate pair | |
| 142 | + * not already present in any status. Never merges anything (CLAUDE.md §70: reversible, curated). | |
| 143 | + */ | |
| 144 | +export async function proposeDrugMerges(db: Database): Promise<ProposeMergesResult> { | |
| 145 | + const rows = await db.execute<{ id: string; name: string; aliases: Array<{ normalized: string; aliasType: string }> | null }>(sql` | |
| 146 | + SELECT d.id, d.name, (SELECT json_agg(json_build_object('normalized', a.normalized, 'aliasType', a.alias_type)) FROM drug_aliases a WHERE a.drug_id = d.id) AS aliases | |
| 147 | + FROM drugs d`); | |
| 148 | + const drugs: DrugLite[] = rows.map((r) => ({ id: r.id, name: r.name, aliases: r.aliases ?? [] })); | |
| 149 | + const candidates = detectDuplicateCandidates(drugs); | |
| 150 | + const existing = await db.execute<{ keep_id: string; merge_id: string }>(sql`SELECT keep_id, merge_id FROM entity_merges WHERE entity_type = 'drug'`); | |
| 151 | + const seen = new Set(existing.map((e) => (e.keep_id < e.merge_id ? `${e.keep_id}|${e.merge_id}` : `${e.merge_id}|${e.keep_id}`))); | |
| 152 | + let inserted = 0; | |
| 153 | + let alreadyQueued = 0; | |
| 154 | + for (const c of candidates) { | |
| 155 | + const key = c.keepId < c.mergeId ? `${c.keepId}|${c.mergeId}` : `${c.mergeId}|${c.keepId}`; | |
| 156 | + if (seen.has(key)) { | |
| 157 | + alreadyQueued++; | |
| 158 | + continue; | |
| 159 | + } | |
| 160 | + await db.execute(sql`INSERT INTO entity_merges (entity_type, keep_id, merge_id, evidence, status) VALUES ('drug', ${c.keepId}, ${c.mergeId}, ${JSON.stringify({ reason: c.reason, ...c.evidence })}::jsonb, 'proposed')`); | |
| 161 | + seen.add(key); | |
| 162 | + inserted++; | |
| 163 | + } | |
| 164 | + return { candidates: candidates.length, inserted, alreadyQueued }; | |
| 165 | +} | |
added
packages/ranking/src/drug-pipeline.test.ts
+54 −0
@@ -0,0 +1,54 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { DRUG_PIPELINE_FORMULA_VERSION, PIPELINE_STAGES, maxPhase, phaseLabel, stageFor } from './drug-pipeline.js'; | |
| 3 | + | |
| 4 | +describe('drug pipeline — highest phase', () => { | |
| 5 | + it('ranks registry phases and treats EARLY_PHASE1 as phase 1', () => { | |
| 6 | + expect(maxPhase([['PHASE2'], ['PHASE3']])).toBe('PHASE3'); | |
| 7 | + expect(maxPhase([['PHASE2', 'PHASE3']])).toBe('PHASE3'); | |
| 8 | + expect(maxPhase([['PHASE1', 'PHASE2']])).toBe('PHASE2'); | |
| 9 | + expect(maxPhase([['PHASE4'], ['NA']])).toBe('PHASE4'); | |
| 10 | + expect(maxPhase([['EARLY_PHASE1']])).toBe('EARLY_PHASE1'); | |
| 11 | + expect(maxPhase([['EARLY_PHASE1'], ['PHASE1']])).toBe('PHASE1'); | |
| 12 | + expect(maxPhase([['NA'], []])).toBe('NA'); | |
| 13 | + expect(maxPhase([[], ['UNKNOWN_LABEL']])).toBeNull(); | |
| 14 | + expect(maxPhase([])).toBeNull(); | |
| 15 | + }); | |
| 16 | + it('phaseLabel mirrors maxPhase for SQL-side ranks', () => { | |
| 17 | + expect(phaseLabel(4, false)).toBe('PHASE4'); | |
| 18 | + expect(phaseLabel(3, true)).toBe('PHASE3'); | |
| 19 | + expect(phaseLabel(2, false)).toBe('PHASE2'); | |
| 20 | + expect(phaseLabel(1, true)).toBe('PHASE1'); | |
| 21 | + expect(phaseLabel(1, false)).toBe('EARLY_PHASE1'); | |
| 22 | + expect(phaseLabel(0, false)).toBe('NA'); | |
| 23 | + expect(phaseLabel(null, false)).toBeNull(); | |
| 24 | + }); | |
| 25 | +}); | |
| 26 | + | |
| 27 | +describe('drug pipeline — stage rule', () => { | |
| 28 | + it('approval beats everything; withdrawn only when every approval is withdrawn/superseded', () => { | |
| 29 | + expect(stageFor({ approvedLike: 1, withdrawnLike: 0, totalTrials: 0, maxPhase: null })).toBe('approved'); | |
| 30 | + expect(stageFor({ approvedLike: 1, withdrawnLike: 3, totalTrials: 12, maxPhase: 'PHASE3' })).toBe('approved'); | |
| 31 | + expect(stageFor({ approvedLike: 0, withdrawnLike: 2, totalTrials: 40, maxPhase: 'PHASE4' })).toBe('withdrawn'); | |
| 32 | + }); | |
| 33 | + it('otherwise the highest phase among interventional trials decides', () => { | |
| 34 | + expect(stageFor({ approvedLike: 0, withdrawnLike: 0, totalTrials: 3, maxPhase: 'PHASE4' })).toBe('phase4'); | |
| 35 | + expect(stageFor({ approvedLike: 0, withdrawnLike: 0, totalTrials: 3, maxPhase: 'PHASE3' })).toBe('phase3'); | |
| 36 | + expect(stageFor({ approvedLike: 0, withdrawnLike: 0, totalTrials: 3, maxPhase: 'PHASE2' })).toBe('phase2'); | |
| 37 | + expect(stageFor({ approvedLike: 0, withdrawnLike: 0, totalTrials: 3, maxPhase: 'PHASE1' })).toBe('phase1'); | |
| 38 | + expect(stageFor({ approvedLike: 0, withdrawnLike: 0, totalTrials: 3, maxPhase: 'EARLY_PHASE1' })).toBe('phase1'); | |
| 39 | + expect(stageFor({ approvedLike: 0, withdrawnLike: 0, totalTrials: 3, maxPhase: 'NA' })).toBe('phase_not_stated'); | |
| 40 | + expect(stageFor({ approvedLike: 0, withdrawnLike: 0, totalTrials: 3, maxPhase: null })).toBe('phase_not_stated'); | |
| 41 | + }); | |
| 42 | + it('no trials and no approvals → no row', () => { | |
| 43 | + expect(stageFor({ approvedLike: 0, withdrawnLike: 0, totalTrials: 0, maxPhase: null })).toBeNull(); | |
| 44 | + }); | |
| 45 | + it('every stage the rule can return is a declared stage; the formula is versioned', () => { | |
| 46 | + const inputs = [ | |
| 47 | + { approvedLike: 1, withdrawnLike: 0, totalTrials: 0, maxPhase: null }, | |
| 48 | + { approvedLike: 0, withdrawnLike: 1, totalTrials: 0, maxPhase: null }, | |
| 49 | + ...['PHASE4', 'PHASE3', 'PHASE2', 'PHASE1', 'EARLY_PHASE1', 'NA'].map((p) => ({ approvedLike: 0, withdrawnLike: 0, totalTrials: 1, maxPhase: p })), | |
| 50 | + ]; | |
| 51 | + for (const i of inputs) expect(PIPELINE_STAGES).toContain(stageFor(i)); | |
| 52 | + expect(DRUG_PIPELINE_FORMULA_VERSION).toMatch(/^ci-drug-pipeline-v\d+$/); | |
| 53 | + }); | |
| 54 | +}); | |
modified
packages/ranking/src/drug-pipeline.ts
+259 −5
@@ -1,16 +1,270 @@ | ||
| 1 | +import { sql } from 'drizzle-orm'; | |
| 1 | 2 | import type { Database } from '@cancerindex/database'; |
| 3 | +import { proposeDrugMerges } from './drug-duplicates.js'; | |
| 2 | 4 | |
| 3 | 5 | export const DRUG_PIPELINE_FORMULA_VERSION = 'ci-drug-pipeline-v1'; |
| 4 | 6 | |
| 7 | +/** Stages, least to most advanced (funnel order on /pipeline). `withdrawn` sits outside the funnel. */ | |
| 8 | +export const PIPELINE_STAGES = ['phase_not_stated', 'phase1', 'phase2', 'phase3', 'phase4', 'approved', 'withdrawn'] as const; | |
| 9 | +export type PipelineStage = (typeof PIPELINE_STAGES)[number]; | |
| 10 | + | |
| 11 | +/** drug_approvals.status values that count as a current market authorization. */ | |
| 12 | +export const PIPELINE_APPROVED_STATUSES = ['approved', 'accelerated', 'conditional'] as const; | |
| 13 | +/** Registry statuses counted as "active" (same list as trial-intelligence / counters). */ | |
| 14 | +export const PIPELINE_ACTIVE_STATUSES = ['RECRUITING', 'NOT_YET_RECRUITING', 'ENROLLING_BY_INVITATION', 'ACTIVE_NOT_RECRUITING'] as const; | |
| 15 | +/** Registry phase labels → rank. EARLY_PHASE1 and PHASE1 share rank 1 (both → stage phase1); NA → 0. */ | |
| 16 | +export const PHASE_RANK: Record<string, number> = { PHASE4: 4, PHASE3: 3, PHASE2: 2, PHASE1: 1, EARLY_PHASE1: 1, NA: 0 }; | |
| 17 | + | |
| 18 | +export const PIPELINE_THRESHOLDS = { | |
| 19 | + /** cancer_hierarchy traversal depth from a top-level cancer down to trial / approval cancers (same as counters). */ | |
| 20 | + maxHierarchyDepth: 12, | |
| 21 | +} as const; | |
| 22 | + | |
| 5 | 23 | export interface DrugPipelineResult { |
| 6 | 24 | rows: number; |
| 25 | + drugRows: number; | |
| 26 | + cancerRows: number; | |
| 27 | + mergeProposals: number; | |
| 28 | + mergeCandidates: number; | |
| 29 | + ms: number; | |
| 30 | +} | |
| 31 | + | |
| 32 | +/** | |
| 33 | + * Highest registry phase over a set of trials (pure, unit-tested). Returns the phase label: | |
| 34 | + * PHASE4 > PHASE3 > PHASE2 > PHASE1 ≡ EARLY_PHASE1 (PHASE1 wins the label when both occur) > NA; | |
| 35 | + * null when no trial carries a known phase label. | |
| 36 | + */ | |
| 37 | +export function maxPhase(phaseLists: Iterable<readonly string[]>): string | null { | |
| 38 | + let best = -1; | |
| 39 | + let label: string | null = null; | |
| 40 | + let sawPhase1 = false; | |
| 41 | + for (const phases of phaseLists) { | |
| 42 | + for (const p of phases) { | |
| 43 | + const r = PHASE_RANK[p]; | |
| 44 | + if (r === undefined) continue; | |
| 45 | + if (p === 'PHASE1') sawPhase1 = true; | |
| 46 | + if (r > best) { | |
| 47 | + best = r; | |
| 48 | + label = p; | |
| 49 | + } | |
| 50 | + } | |
| 51 | + } | |
| 52 | + if (best < 0) return null; | |
| 53 | + if (best === 1) return sawPhase1 ? 'PHASE1' : 'EARLY_PHASE1'; | |
| 54 | + return label; | |
| 55 | +} | |
| 56 | + | |
| 57 | +/** Phase label from a SQL-side rank (mirrors `maxPhase`). */ | |
| 58 | +export function phaseLabel(rank: number | null, hasPhase1: boolean): string | null { | |
| 59 | + if (rank == null || rank < 0) return null; | |
| 60 | + if (rank >= 4) return 'PHASE4'; | |
| 61 | + if (rank === 3) return 'PHASE3'; | |
| 62 | + if (rank === 2) return 'PHASE2'; | |
| 63 | + if (rank === 1) return hasPhase1 ? 'PHASE1' : 'EARLY_PHASE1'; | |
| 64 | + return 'NA'; | |
| 65 | +} | |
| 66 | + | |
| 67 | +export interface StageInput { | |
| 68 | + /** Approvals with status ∈ PIPELINE_APPROVED_STATUSES (for the cancer when scoped, any cancer when not). */ | |
| 69 | + approvedLike: number; | |
| 70 | + /** Approvals with any other status (withdrawn, superseded). */ | |
| 71 | + withdrawnLike: number; | |
| 72 | + /** Interventional trials linking the drug (and the cancer when scoped). */ | |
| 73 | + totalTrials: number; | |
| 74 | + /** Output of `maxPhase` over those trials. */ | |
| 75 | + maxPhase: string | null; | |
| 76 | +} | |
| 77 | + | |
| 78 | +/** | |
| 79 | + * Stage rule (pure, unit-tested; docs/methodology/pipeline.md): | |
| 80 | + * approved any approval with status approved | accelerated | conditional | |
| 81 | + * withdrawn approvals exist but all are withdrawn / superseded | |
| 82 | + * phase4 … phase1 otherwise, by the highest registry phase among interventional trials | |
| 83 | + * (PHASE2+PHASE3 → phase3, PHASE1+PHASE2 → phase2, EARLY_PHASE1 → phase1) | |
| 84 | + * phase_not_stated trials exist but none states a phase (NA / empty) | |
| 85 | + * null no trials and no approvals → no row | |
| 86 | + */ | |
| 87 | +export function stageFor(input: StageInput): PipelineStage | null { | |
| 88 | + if (input.approvedLike > 0) return 'approved'; | |
| 89 | + if (input.withdrawnLike > 0) return 'withdrawn'; | |
| 90 | + if (input.totalTrials <= 0) return null; | |
| 91 | + switch (input.maxPhase) { | |
| 92 | + case 'PHASE4': | |
| 93 | + return 'phase4'; | |
| 94 | + case 'PHASE3': | |
| 95 | + return 'phase3'; | |
| 96 | + case 'PHASE2': | |
| 97 | + return 'phase2'; | |
| 98 | + case 'PHASE1': | |
| 99 | + case 'EARLY_PHASE1': | |
| 100 | + return 'phase1'; | |
| 101 | + default: | |
| 102 | + return 'phase_not_stated'; | |
| 103 | + } | |
| 7 | 104 | } |
| 8 | 105 | |
| 106 | +type AggRow = { | |
| 107 | + drug_id: string; | |
| 108 | + top_id: string | null; | |
| 109 | + total_trials: string | number | null; | |
| 110 | + active_trials: string | number | null; | |
| 111 | + recruiting_trials: string | number | null; | |
| 112 | + phase3_trials: string | number | null; | |
| 113 | + phase_rank: string | number | null; | |
| 114 | + has_phase1: boolean | null; | |
| 115 | + first_trial_date: string | null; | |
| 116 | + approvals: string | number | null; | |
| 117 | + approved_like: string | number | null; | |
| 118 | + jurisdictions: string[] | null; | |
| 119 | + first_approval_date: string | null; | |
| 120 | + latest_approval_date: string | null; | |
| 121 | +}; | |
| 122 | + | |
| 123 | +const n =(v: string | number | null | undefined) => (v == null ? 0 : Number(v)); | |
| 124 | + | |
| 9 | 125 | /** |
| 10 | − * STUB — implemented by the Approvals & Pipeline work package. | |
| 11 | − * Recomputes `drug_pipeline` (per drug, and per drug × cancer) from trial_interventions × | |
| 12 | − * trial_conditions × clinical_trials and drug_approvals. | |
| 126 | + * Recompute `drug_pipeline`: one row per drug (cancer_id NULL, across all cancers) and one per | |
| 127 | + * (drug, top-level cancer). A trial reaches a top-level cancer through `trial_conditions.cancer_id` | |
| 128 | + * and its ancestors in `cancer_hierarchy` (depth ≤ 12); an approval through `drug_approvals.cancer_id` | |
| 129 | + * the same way (approvals without a cancer only feed the unscoped row). Counts are over | |
| 130 | + * interventional studies (DISTINCT trials). Set-based SQL over temp tables, one transaction, the | |
| 131 | + * stage decided by the pure `stageFor` rule. Finally, salt-form / alias duplicates among drugs are | |
| 132 | + * *proposed* to `entity_merges` (never merged here). | |
| 13 | 133 | */ |
| 14 | −export async function computeDrugPipeline(_db: Database): Promise<DrugPipelineResult> { | |
| 15 | − return { rows: 0 }; | |
| 134 | +export async function computeDrugPipeline(db: Database): Promise<DrugPipelineResult> { | |
| 135 | + const t0 = Date.now(); | |
| 136 | + const depth = sql.raw(String(PIPELINE_THRESHOLDS.maxHierarchyDepth)); | |
| 137 | + const activeSql = sql.raw(`ARRAY[${PIPELINE_ACTIVE_STATUSES.map((s) => `'${s}'`).join(',')}]::text[]`); | |
| 138 | + const approvedSql = sql.raw(`ARRAY[${PIPELINE_APPROVED_STATUSES.map((s) => `'${s}'`).join(',')}]::text[]`); | |
| 139 | + const rankCase = sql.raw(`CASE p ${Object.entries(PHASE_RANK) | |
| 140 | + .map(([k, v]) => `WHEN '${k}' THEN ${v}`) | |
| 141 | + .join(' ')} ELSE NULL END`); | |
| 142 | + | |
| 143 | + const { drugRows, cancerRows } = await db.transaction(async (tx) => { | |
| 144 | + // Top-level cancer → every descendant (itself included), across all hierarchy types. | |
| 145 | + await tx.execute(sql`CREATE TEMP TABLE _dp_top (top_id varchar(32), cancer_id varchar(32), PRIMARY KEY (top_id, cancer_id)) ON COMMIT DROP`); | |
| 146 | + await tx.execute(sql` | |
| 147 | + INSERT INTO _dp_top | |
| 148 | + WITH RECURSIVE d AS ( | |
| 149 | + SELECT id AS top_id, id AS cancer_id, 0 AS depth FROM cancers WHERE top_level AND status = 'active' | |
| 150 | + UNION | |
| 151 | + SELECT d.top_id, h.child_id, d.depth + 1 FROM d JOIN cancer_hierarchy h ON h.parent_id = d.cancer_id WHERE d.depth < ${depth} | |
| 152 | + ) | |
| 153 | + SELECT DISTINCT top_id, cancer_id FROM d`); | |
| 154 | + // Interventional trials with their activity flags and highest phase rank. | |
| 155 | + await tx.execute(sql` | |
| 156 | + CREATE TEMP TABLE _dp_trial ON COMMIT DROP AS | |
| 157 | + SELECT t.id, | |
| 158 | + t.overall_status = ANY(${activeSql}) AS active, | |
| 159 | + t.overall_status = 'RECRUITING' AS recruiting, | |
| 160 | + (SELECT max(${rankCase}) FROM unnest(t.phases) AS p) AS phase_rank, | |
| 161 | + 'PHASE1' = ANY(t.phases) AS has_phase1, | |
| 162 | + 'PHASE3' = ANY(t.phases) AS has_phase3, | |
| 163 | + CASE WHEN t.start_date ~ '^\\d{4}' THEN t.start_date END AS start_date | |
| 164 | + FROM clinical_trials t WHERE t.study_type = 'INTERVENTIONAL'`); | |
| 165 | + await tx.execute(sql`CREATE INDEX ON _dp_trial (id)`); | |
| 166 | + await tx.execute(sql` | |
| 167 | + CREATE TEMP TABLE _dp_td ON COMMIT DROP AS | |
| 168 | + SELECT DISTINCT ti.drug_id, ti.trial_id FROM trial_interventions ti JOIN _dp_trial tr ON tr.id = ti.trial_id WHERE ti.drug_id IS NOT NULL`); | |
| 169 | + await tx.execute(sql` | |
| 170 | + CREATE TEMP TABLE _dp_tdc ON COMMIT DROP AS | |
| 171 | + SELECT DISTINCT td.drug_id, td.trial_id, tp.top_id | |
| 172 | + FROM _dp_td td JOIN trial_conditions tc ON tc.trial_id = td.trial_id AND tc.cancer_id IS NOT NULL JOIN _dp_top tp ON tp.cancer_id = tc.cancer_id`); | |
| 173 | + | |
| 174 | + const trialAgg = (scoped: boolean) => sql` | |
| 175 | + SELECT x.drug_id, ${scoped ? sql`x.top_id` : sql`NULL::varchar`} AS top_id, | |
| 176 | + count(*) AS total_trials, | |
| 177 | + count(*) FILTER (WHERE tr.active) AS active_trials, | |
| 178 | + count(*) FILTER (WHERE tr.recruiting) AS recruiting_trials, | |
| 179 | + count(*) FILTER (WHERE tr.has_phase3) AS phase3_trials, | |
| 180 | + max(tr.phase_rank) AS phase_rank, | |
| 181 | + bool_or(tr.has_phase1) AS has_phase1, | |
| 182 | + min(tr.start_date) AS first_trial_date | |
| 183 | + FROM ${scoped ? sql`_dp_tdc` : sql`_dp_td`} x JOIN _dp_trial tr ON tr.id = x.trial_id | |
| 184 | + GROUP BY x.drug_id${scoped ? sql`, x.top_id` : sql``}`; | |
| 185 | + const approvalAgg = (scoped: boolean) => sql` | |
| 186 | + SELECT a.drug_id, ${scoped ? sql`tp.top_id` : sql`NULL::varchar`} AS top_id, | |
| 187 | + count(*) AS approvals, | |
| 188 | + count(*) FILTER (WHERE a.status = ANY(${approvedSql})) AS approved_like, | |
| 189 | + array_agg(DISTINCT a.jurisdiction ORDER BY a.jurisdiction) AS jurisdictions, | |
| 190 | + min(a.approval_date) FILTER (WHERE a.status = ANY(${approvedSql}) AND a.approval_date IS NOT NULL) AS first_approval_date, | |
| 191 | + max(a.approval_date) FILTER (WHERE a.status = ANY(${approvedSql}) AND a.approval_date IS NOT NULL) AS latest_approval_date | |
| 192 | + FROM drug_approvals a ${scoped ? sql`JOIN _dp_top tp ON tp.cancer_id = a.cancer_id` : sql``} | |
| 193 | + GROUP BY a.drug_id${scoped ? sql`, tp.top_id` : sql``}`; | |
| 194 | + const combined = (scoped: boolean) => sql` | |
| 195 | + SELECT COALESCE(t.drug_id, a.drug_id) AS drug_id, COALESCE(t.top_id, a.top_id) AS top_id, | |
| 196 | + t.total_trials, t.active_trials, t.recruiting_trials, t.phase3_trials, t.phase_rank, t.has_phase1, t.first_trial_date, | |
| 197 | + a.approvals, a.approved_like, a.jurisdictions, a.first_approval_date, a.latest_approval_date | |
| 198 | + FROM (${trialAgg(scoped)}) t FULL OUTER JOIN (${approvalAgg(scoped)}) a ON a.drug_id = t.drug_id ${scoped ? sql`AND a.top_id = t.top_id` : sql``} | |
| 199 | + WHERE EXISTS (SELECT 1 FROM drugs d WHERE d.id = COALESCE(t.drug_id, a.drug_id))`; | |
| 200 | + | |
| 201 | + const unscoped = (await tx.execute<AggRow>(combined(false))) as unknown as AggRow[]; | |
| 202 | + const scoped = (await tx.execute<AggRow>(combined(true))) as unknown as AggRow[]; | |
| 203 | + | |
| 204 | + await tx.execute(sql`DELETE FROM drug_pipeline`); | |
| 205 | + let drugRows = 0; | |
| 206 | + let cancerRows = 0; | |
| 207 | + const batch: Array<Record<string, unknown>> = []; | |
| 208 | + const push = (r: AggRow, scope: 'all' | 'top_level_cancer') => { | |
| 209 | + const approvedLike = n(r.approved_like); | |
| 210 | + const approvals = n(r.approvals); | |
| 211 | + const total = n(r.total_trials); | |
| 212 | + const rank = r.phase_rank == null ? null : Number(r.phase_rank); | |
| 213 | + const mp = total > 0 ? phaseLabel(rank, !!r.has_phase1) : null; | |
| 214 | + const stage = stageFor({ approvedLike, withdrawnLike: approvals - approvedLike, totalTrials: total, maxPhase: mp }); | |
| 215 | + if (!stage) return; | |
| 216 | + batch.push({ | |
| 217 | + drug_id: r.drug_id, | |
| 218 | + cancer_id: r.top_id, | |
| 219 | + stage, | |
| 220 | + max_phase: mp, | |
| 221 | + active_trials: n(r.active_trials), | |
| 222 | + recruiting_trials: n(r.recruiting_trials), | |
| 223 | + phase3_trials: n(r.phase3_trials), | |
| 224 | + total_trials: total, | |
| 225 | + approvals, | |
| 226 | + // text[] inside unnest cannot carry a nested array → serialized as JSON text, expanded in the INSERT below. | |
| 227 | + jurisdictions: JSON.stringify(r.jurisdictions ?? []), | |
| 228 | + first_approval_date: r.first_approval_date, | |
| 229 | + latest_approval_date: r.latest_approval_date, | |
| 230 | + first_trial_date: r.first_trial_date, | |
| 231 | + formula_version: DRUG_PIPELINE_FORMULA_VERSION, | |
| 232 | + inputs: JSON.stringify({ | |
| 233 | + scope, | |
| 234 | + approvedLike, | |
| 235 | + withdrawnLike: approvals - approvedLike, | |
| 236 | + phaseRank: rank, | |
| 237 | + approvedStatuses: PIPELINE_APPROVED_STATUSES, | |
| 238 | + activeStatuses: PIPELINE_ACTIVE_STATUSES, | |
| 239 | + ancestorMapping: `trial_conditions.cancer_id / drug_approvals.cancer_id → top-level ancestor via cancer_hierarchy (depth ≤ ${PIPELINE_THRESHOLDS.maxHierarchyDepth})`, | |
| 240 | + stageRule: 'approved > withdrawn > highest registry phase (PHASE4, PHASE3, PHASE2, PHASE1/EARLY_PHASE1) > phase_not_stated', | |
| 241 | + }), | |
| 242 | + }); | |
| 243 | + if (scope === 'all') drugRows++; | |
| 244 | + else cancerRows++; | |
| 245 | + }; | |
| 246 | + for (const r of unscoped) push(r, 'all'); | |
| 247 | + for (const r of scoped) if (r.top_id) push(r, 'top_level_cancer'); | |
| 248 | + // Insert in batches of 1,000 rows. | |
| 249 | + for (let i = 0; i < batch.length; i += 1000) await flushBatch(tx, batch.slice(i, i + 1000)); | |
| 250 | + return { drugRows, cancerRows }; | |
| 251 | + }); | |
| 252 | + | |
| 253 | + const merges = await proposeDrugMerges(db); | |
| 254 | + return { rows: drugRows + cancerRows, drugRows, cancerRows, mergeProposals: merges.inserted, mergeCandidates: merges.candidates, ms: Date.now() - t0 }; | |
| 255 | +} | |
| 256 | + | |
| 257 | +async function flushBatch(tx: Pick<Database, 'execute'>, rows: Array<Record<string, unknown>>): Promise<void> { | |
| 258 | + if (!rows.length) return; | |
| 259 | + const col = (k: string) => sql.param(rows.map((r) => r[k])); | |
| 260 | + await tx.execute(sql` | |
| 261 | + INSERT INTO drug_pipeline (drug_id, cancer_id, stage, max_phase, active_trials, recruiting_trials, phase3_trials, total_trials, approvals, jurisdictions, first_approval_date, latest_approval_date, first_trial_date, formula_version, inputs) | |
| 262 | + SELECT u.drug_id, u.cancer_id, u.stage, u.max_phase, u.active_trials, u.recruiting_trials, u.phase3_trials, u.total_trials, u.approvals, | |
| 263 | + COALESCE(ARRAY(SELECT jsonb_array_elements_text(u.jurisdictions_json::jsonb)), '{}'::text[]), u.first_approval_date, u.latest_approval_date, u.first_trial_date, u.formula_version, u.inputs::jsonb | |
| 264 | + FROM unnest( | |
| 265 | + ${col('drug_id')}::varchar[], ${col('cancer_id')}::varchar[], ${col('stage')}::text[], ${col('max_phase')}::text[], | |
| 266 | + ${col('active_trials')}::int[], ${col('recruiting_trials')}::int[], ${col('phase3_trials')}::int[], ${col('total_trials')}::int[], ${col('approvals')}::int[], | |
| 267 | + ${col('jurisdictions')}::text[], ${col('first_approval_date')}::text[], ${col('latest_approval_date')}::text[], ${col('first_trial_date')}::text[], | |
| 268 | + ${col('formula_version')}::text[], ${col('inputs')}::text[] | |
| 269 | + ) AS u(drug_id, cancer_id, stage, max_phase, active_trials, recruiting_trials, phase3_trials, total_trials, approvals, jurisdictions_json, first_approval_date, latest_approval_date, first_trial_date, formula_version, inputs)`); | |
| 16 | 270 | } |
| 17 | 271 | |