Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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!' });
Expand Down
6 changes: 4 additions & 2 deletions server/src/controllers/ingester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);
Expand Down
90 changes: 90 additions & 0 deletions server/src/controllers/medications.ts
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate codings before dedupe upsert

If an ingested medication has an empty codings array (or the first entry lacks code), then medication.codings[0]?.code is undefined; Mongoose will drop that predicate (or match missing fields), so the upsert becomes keyed only on start. 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 👍 / 👎.

Copy link
Copy Markdown
Author

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

Comment on lines +57 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use stable medicationId for upsert key

The upsert filter relies on getMedicationId(medication) (which uses the first coding) to dedupe dose logs. Because codings is an array and its order is not guaranteed by the ingest payload, the same dose can arrive with a different first entry between syncs (e.g., RxNorm vs. another system), so the filter misses the existing record and creates a duplicate despite the unique index. This only happens when the coding order changes, but that’s a realistic scenario for multi-coding payloads. Prefer a stable selection (e.g., pick by system or sort codings) before building the key.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

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;
}
};
2 changes: 2 additions & 0 deletions server/src/models/IngestData.ts
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[];
};
}
5 changes: 5 additions & 0 deletions server/src/models/IngestResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,9 @@ export interface IngestResponse {
message?: string;
error?: string;
};
medications?: {
success: boolean;
message?: string;
error?: string;
};
}
110 changes: 110 additions & 0 deletions server/src/models/Medication.ts
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',
);
9 changes: 9 additions & 0 deletions server/src/routes/medications.ts
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;