-
Notifications
You must be signed in to change notification settings - Fork 31
Add medication ingestion and retrieval support #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import { Request, Response } from 'express'; | ||
|
|
||
| import { IngestData } from '../models/IngestData'; | ||
| import { IngestResponse } from '../models/IngestResponse'; | ||
| import { MedicationModel, mapMedicationData, getMedicationId } 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<IngestResponse> => { | ||
| 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: { | ||
| medicationId: getMedicationId(medication), | ||
| start: new Date(medication.start), | ||
|
Comment on lines
+57
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The upsert filter relies on Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @codex updated, please review again There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Codex Review: Didn't find any major issues. Nice work! ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
||
| }, | ||
| 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; | ||
| } | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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[]; | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| 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 { | ||
| medicationId: string; // Computed: prefers RxNorm code, then sorted codings, then displayText | ||
| 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<MedicationCoding>( | ||
| { | ||
| system: { type: String, required: true }, | ||
| version: { type: String, required: false }, | ||
| code: { type: String, required: true }, | ||
| }, | ||
| { _id: false }, | ||
| ); | ||
|
|
||
| const MedicationSchema = new Schema<IMedication>( | ||
| { | ||
| medicationId: { type: String, required: true }, | ||
| 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: false }, | ||
| scheduledDosage: { type: Number, required: false }, | ||
| scheduledDate: { type: Date, required: false }, | ||
| }, | ||
| { timestamps: true }, | ||
| ); | ||
|
|
||
| // Unique index on medicationId + start time to prevent duplicate dose logs | ||
| MedicationSchema.index( | ||
| { medicationId: 1, start: 1 }, | ||
| { unique: true }, | ||
| ); | ||
|
|
||
| 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 { | ||
| 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) { | ||
| return { | ||
| medicationId: getMedicationId(data), | ||
| 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<IMedication>( | ||
| 'Medication', | ||
| MedicationSchema, | ||
| 'medications', | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import express from 'express'; | ||
|
|
||
| import { getMedications } from '../controllers/medications'; | ||
|
|
||
| const router = express.Router(); | ||
|
|
||
| router.get('/', getMedications); | ||
|
|
||
| export default router; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If an ingested medication has an empty
codingsarray (or the first entry lackscode), thenmedication.codings[0]?.codeisundefined; Mongoose will drop that predicate (or match missing fields), so the upsert becomes keyed only onstart. In that case, multiple distinct medications taken at the same timestamp will overwrite each other (or collide with the unique index later), causing silent data loss for sources that omit coding info. Consider rejecting such records or deriving a stable code from the codings array before constructing the filter.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@codex updated, please review again