From 06f6df9272c616107c46a2031bc2af5c1813e442 Mon Sep 17 00:00:00 2001 From: Nick Christensen Date: Sun, 18 Jan 2026 13:20:59 -0600 Subject: [PATCH 1/3] Add medication ingestion and retrieval support Extends the ingest pipeline to handle medication data from Health Auto Export, storing dose logs with RxNorm coding information. Medications are deduplicated on the combination of medication code and timestamp to prevent duplicate entries from repeated syncs. New endpoint: GET /api/medications with optional date range filtering (from/to). Co-Authored-By: Claude Opus 4.5 --- server/src/app.ts | 2 + server/src/controllers/ingester.ts | 6 +- server/src/controllers/medications.ts | 90 +++++++++++++++++++++++++++ server/src/models/IngestData.ts | 2 + server/src/models/IngestResponse.ts | 5 ++ server/src/models/Medication.ts | 87 ++++++++++++++++++++++++++ server/src/routes/medications.ts | 9 +++ 7 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 server/src/controllers/medications.ts create mode 100644 server/src/models/Medication.ts create mode 100644 server/src/routes/medications.ts diff --git a/server/src/app.ts b/server/src/app.ts index fe9d99c..089ee9b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -5,6 +5,7 @@ import mongodb from './database/mongodb'; import ingesterRouter from './routes/ingester'; import metricsRouter from './routes/metrics'; import workoutsRouter from './routes/workouts'; +import medicationsRouter from './routes/medications'; import { requireReadAuth, requireWriteAuth } from './middleware/auth'; const app = express(); @@ -29,6 +30,7 @@ app.use('/api/data', requireWriteAuth, ingesterRouter); // Apply read auth middleware to data retrieval routes app.use('/api/metrics', requireReadAuth, metricsRouter); app.use('/api/workouts', requireReadAuth, workoutsRouter); +app.use('/api/medications', requireReadAuth, medicationsRouter); app.get('/', (req: express.Request, res: express.Response) => { res.json({ message: 'Hello world!' }); diff --git a/server/src/controllers/ingester.ts b/server/src/controllers/ingester.ts index ddcaa23..61d0724 100644 --- a/server/src/controllers/ingester.ts +++ b/server/src/controllers/ingester.ts @@ -2,6 +2,7 @@ import { Request, Response } from 'express'; import { saveMetrics } from './metrics'; import { saveWorkouts } from './workouts'; +import { saveMedications } from './medications'; import { IngestData } from '../models/IngestData'; import { IngestResponse } from '../models/IngestResponse'; @@ -15,12 +16,13 @@ export const ingestData = async (req: Request, res: Response) => { throw new Error('No data provided'); } - const [metricsResponse, workoutsResponse] = await Promise.all([ + const [metricsResponse, workoutsResponse, medicationsResponse] = await Promise.all([ saveMetrics(data), saveWorkouts(data), + saveMedications(data), ]); - response = { ...metricsResponse, ...workoutsResponse }; + response = { ...metricsResponse, ...workoutsResponse, ...medicationsResponse }; const hasErrors = Object.values(response).some((r) => !r.success); const allFailed = Object.values(response).every((r) => !r.success); diff --git a/server/src/controllers/medications.ts b/server/src/controllers/medications.ts new file mode 100644 index 0000000..8914bbb --- /dev/null +++ b/server/src/controllers/medications.ts @@ -0,0 +1,90 @@ +import { Request, Response } from 'express'; + +import { IngestData } from '../models/IngestData'; +import { IngestResponse } from '../models/IngestResponse'; +import { MedicationModel, mapMedicationData } from '../models/Medication'; +import { filterFields, parseDate } from '../utils'; + +export const getMedications = async (req: Request, res: Response) => { + try { + const { from, to, include, exclude } = req.query; + + const fromDate = parseDate(from as string); + const toDate = parseDate(to as string); + + let query = {}; + + if (fromDate && toDate) { + query = { + start: { + $gte: fromDate, + $lte: toDate, + }, + }; + } + + let medications = await MedicationModel.find(query) + .sort({ start: -1 }) + .lean(); + + if (include || exclude) { + medications = medications.map((med) => filterFields(med, include, exclude)); + } + + console.log(`${medications.length} medications fetched`); + res.status(200).json(medications); + } catch (error) { + console.error('Error fetching medications:', error); + res.status(500).json({ error: 'Error fetching medications' }); + } +}; + +export const saveMedications = async (ingestData: IngestData): Promise => { + try { + const response: IngestResponse = {}; + const medications = ingestData.data.medications; + + if (!medications || !medications.length) { + response.medications = { + success: true, + message: 'No medication data provided', + }; + return response; + } + + const medicationOperations = medications.map((medication) => ({ + updateOne: { + filter: { + 'codings.code': medication.codings[0]?.code, + start: new Date(medication.start), + }, + update: { + $set: mapMedicationData(medication), + }, + upsert: true, + }, + })); + + await MedicationModel.bulkWrite(medicationOperations); + + response.medications = { + success: true, + message: `${medications.length} medications saved successfully`, + }; + + console.debug(`Processed ${medications.length} medications`); + + return response; + } catch (error) { + console.error('Error processing medications:', error); + + const errorResponse: IngestResponse = {}; + errorResponse.medications = { + success: false, + message: 'Medications not saved', + error: error instanceof Error ? error.message : 'An error occurred', + }; + + return errorResponse; + } +}; diff --git a/server/src/models/IngestData.ts b/server/src/models/IngestData.ts index ae33d98..c3ba02a 100644 --- a/server/src/models/IngestData.ts +++ b/server/src/models/IngestData.ts @@ -1,9 +1,11 @@ import { MetricData } from './Metric'; import { WorkoutData } from './Workout'; +import { MedicationData } from './Medication'; export interface IngestData { data: { metrics?: MetricData[]; workouts?: WorkoutData[]; + medications?: MedicationData[]; }; } diff --git a/server/src/models/IngestResponse.ts b/server/src/models/IngestResponse.ts index a2b796e..c018366 100644 --- a/server/src/models/IngestResponse.ts +++ b/server/src/models/IngestResponse.ts @@ -9,4 +9,9 @@ export interface IngestResponse { message?: string; error?: string; }; + medications?: { + success: boolean; + message?: string; + error?: string; + }; } diff --git a/server/src/models/Medication.ts b/server/src/models/Medication.ts new file mode 100644 index 0000000..88f7501 --- /dev/null +++ b/server/src/models/Medication.ts @@ -0,0 +1,87 @@ +import mongoose, { Schema, Document } from 'mongoose'; + +export interface MedicationCoding { + system: string; + version: string; + code: string; +} + +export interface MedicationData { + start: string; + end: string; + displayText: string; + status: string; + dosage: number; + units: string; + isArchived: boolean; + codings: MedicationCoding[]; + scheduledDosage?: number; + scheduledDate?: string; +} + +export interface IMedication extends Document { + start: Date; + end: Date; + displayText: string; + status: string; + dosage: number; + units: string; + isArchived: boolean; + codings: MedicationCoding[]; + scheduledDosage?: number; + scheduledDate?: Date; + createdAt: Date; + updatedAt: Date; +} + +const CodingSchema = new Schema( + { + system: { type: String, required: true }, + version: { type: String, required: false }, + code: { type: String, required: true }, + }, + { _id: false }, +); + +const MedicationSchema = new Schema( + { + start: { type: Date, required: true }, + end: { type: Date, required: true }, + displayText: { type: String, required: true }, + status: { type: String, required: true }, + dosage: { type: Number, required: true }, + units: { type: String, required: true }, + isArchived: { type: Boolean, required: true }, + codings: { type: [CodingSchema], required: true }, + scheduledDosage: { type: Number, required: false }, + scheduledDate: { type: Date, required: false }, + }, + { timestamps: true }, +); + +// Unique index on medication code + start time to prevent duplicate dose logs +MedicationSchema.index( + { 'codings.code': 1, start: 1 }, + { unique: true }, +); + +export function mapMedicationData(data: MedicationData) { + return { + start: new Date(data.start), + end: new Date(data.end), + displayText: data.displayText, + status: data.status, + dosage: data.dosage, + units: data.units, + isArchived: data.isArchived, + codings: data.codings, + scheduledDosage: data.scheduledDosage, + scheduledDate: data.scheduledDate ? new Date(data.scheduledDate) : undefined, + }; +} + +export const MedicationModel = mongoose.model( + 'Medication', + MedicationSchema, + 'medications', +); diff --git a/server/src/routes/medications.ts b/server/src/routes/medications.ts new file mode 100644 index 0000000..5c3068e --- /dev/null +++ b/server/src/routes/medications.ts @@ -0,0 +1,9 @@ +import express from 'express'; + +import { getMedications } from '../controllers/medications'; + +const router = express.Router(); + +router.get('/', getMedications); + +export default router; From 78431236b8d1784d1e0f55eda05cd413d0242e52 Mon Sep 17 00:00:00 2001 From: Nick Christensen Date: Sun, 18 Jan 2026 13:39:12 -0600 Subject: [PATCH 2/3] Use computed medicationId for deduplication Adds a medicationId field that uses the RxNorm code when available, falling back to displayText for manually-entered medications. This ensures consistent deduplication regardless of whether the medication has coding information. Co-Authored-By: Claude Opus 4.5 --- server/src/controllers/medications.ts | 4 ++-- server/src/models/Medication.ts | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/server/src/controllers/medications.ts b/server/src/controllers/medications.ts index 8914bbb..27d8140 100644 --- a/server/src/controllers/medications.ts +++ b/server/src/controllers/medications.ts @@ -2,7 +2,7 @@ import { Request, Response } from 'express'; import { IngestData } from '../models/IngestData'; import { IngestResponse } from '../models/IngestResponse'; -import { MedicationModel, mapMedicationData } from '../models/Medication'; +import { MedicationModel, mapMedicationData, getMedicationId } from '../models/Medication'; import { filterFields, parseDate } from '../utils'; export const getMedications = async (req: Request, res: Response) => { @@ -55,7 +55,7 @@ export const saveMedications = async (ingestData: IngestData): Promise ({ updateOne: { filter: { - 'codings.code': medication.codings[0]?.code, + medicationId: getMedicationId(medication), start: new Date(medication.start), }, update: { diff --git a/server/src/models/Medication.ts b/server/src/models/Medication.ts index 88f7501..0e01ec6 100644 --- a/server/src/models/Medication.ts +++ b/server/src/models/Medication.ts @@ -20,6 +20,7 @@ export interface MedicationData { } export interface IMedication extends Document { + medicationId: string; // Computed: codings[0].code or displayText start: Date; end: Date; displayText: string; @@ -45,6 +46,7 @@ const CodingSchema = new Schema( const MedicationSchema = new Schema( { + medicationId: { type: String, required: true }, start: { type: Date, required: true }, end: { type: Date, required: true }, displayText: { type: String, required: true }, @@ -52,21 +54,27 @@ const MedicationSchema = new Schema( dosage: { type: Number, required: true }, units: { type: String, required: true }, isArchived: { type: Boolean, required: true }, - codings: { type: [CodingSchema], required: true }, + codings: { type: [CodingSchema], required: false }, scheduledDosage: { type: Number, required: false }, scheduledDate: { type: Date, required: false }, }, { timestamps: true }, ); -// Unique index on medication code + start time to prevent duplicate dose logs +// Unique index on medicationId + start time to prevent duplicate dose logs MedicationSchema.index( - { 'codings.code': 1, start: 1 }, + { medicationId: 1, start: 1 }, { unique: true }, ); +// Returns the medication identifier: RxNorm code if available, otherwise displayText +export function getMedicationId(data: MedicationData): string { + return data.codings?.[0]?.code || data.displayText; +} + export function mapMedicationData(data: MedicationData) { return { + medicationId: getMedicationId(data), start: new Date(data.start), end: new Date(data.end), displayText: data.displayText, @@ -74,7 +82,7 @@ export function mapMedicationData(data: MedicationData) { dosage: data.dosage, units: data.units, isArchived: data.isArchived, - codings: data.codings, + codings: data.codings || [], scheduledDosage: data.scheduledDosage, scheduledDate: data.scheduledDate ? new Date(data.scheduledDate) : undefined, }; From 768c31be0b91a3e710816e1e4d35a2acde87fe3a Mon Sep 17 00:00:00 2001 From: Nick Christensen Date: Sun, 18 Jan 2026 13:49:01 -0600 Subject: [PATCH 3/3] Prefer RxNorm coding for stable medication identification When multiple coding systems are present (RxNorm, NDC, SNOMED, etc.), the array order may vary between syncs. This could cause the same dose to generate different medicationId values, leading to failed upserts. Fix by preferring RxNorm (the standard for US medications) when available, then falling back to codings sorted by system name for deterministic selection. Co-Authored-By: Claude Opus 4.5 --- server/src/models/Medication.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/server/src/models/Medication.ts b/server/src/models/Medication.ts index 0e01ec6..8faad64 100644 --- a/server/src/models/Medication.ts +++ b/server/src/models/Medication.ts @@ -20,7 +20,7 @@ export interface MedicationData { } export interface IMedication extends Document { - medicationId: string; // Computed: codings[0].code or displayText + medicationId: string; // Computed: prefers RxNorm code, then sorted codings, then displayText start: Date; end: Date; displayText: string; @@ -67,9 +67,24 @@ MedicationSchema.index( { unique: true }, ); -// Returns the medication identifier: RxNorm code if available, otherwise displayText +const RXNORM_SYSTEM = 'http://www.nlm.nih.gov/research/umls/rxnorm'; + +// Returns the medication identifier, preferring RxNorm code for stability +// when multiple coding systems are present (order may vary between syncs) export function getMedicationId(data: MedicationData): string { - return data.codings?.[0]?.code || data.displayText; + if (!data.codings?.length) { + return data.displayText; + } + + // Prefer RxNorm coding for consistent identification + const rxnorm = data.codings.find((c) => c.system === RXNORM_SYSTEM); + if (rxnorm) { + return rxnorm.code; + } + + // Fall back to first coding, sorted by system for stability + const sorted = [...data.codings].sort((a, b) => a.system.localeCompare(b.system)); + return sorted[0].code; } export function mapMedicationData(data: MedicationData) {