SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%

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>
Simon-Pierre Boucher committed 13 days ago (Sep 11, 2026) parent acd9e2b

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