diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 92d8907..f738efa 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -4,6 +4,7 @@ import { config } from '@lumen/config'; import { connectDB } from './config/db'; import { startPaymentVerificationWorker } from './modules/payments/worker'; import { patientRoutes } from './modules/patients/patients.controller'; +import { patientHistoryRoutes } from './modules/patients/history.controller'; import { encounterRoutes } from './modules/encounters/encounters.controller'; import { auditRoutes } from './modules/audit/audit.controller'; import { auditMiddleware } from './middlewares/audit.middleware'; @@ -35,6 +36,7 @@ app.use('/api/v1/ai', aiRoutes); app.use('/api/v1/queue', queueRoutes); app.use('/api/v1', diagnosisRoutes); app.use('/api/v1/patients', patientRoutes); +app.use('/api/v1/patients', patientHistoryRoutes); app.use('/api/v1/vitals', vitalsRoutes); app.use('/api/v1/encounters', encounterRoutes); app.use('/api/v1/notes', notesRoutes); diff --git a/apps/api/src/modules/patients/history.controller.ts b/apps/api/src/modules/patients/history.controller.ts new file mode 100644 index 0000000..2a6713d --- /dev/null +++ b/apps/api/src/modules/patients/history.controller.ts @@ -0,0 +1,63 @@ +import { Request, Response, Router } from "express"; +import { ParsedQs } from "qs"; +import { authorize, Roles } from "../../middlewares/rbac.middleware"; +import { validateRequest } from "../../middlewares/validate.middleware"; +import { getPatientHistory } from "./history.service"; +import { + PatientHistoryParamsDto, + patientHistoryParamsSchema, + patientHistoryQuerySchema, +} from "./history.validation"; + +const router = Router(); +const ALL_ROLES: Roles[] = Object.values(Roles); + +type PatientHistoryRequest = Request< + PatientHistoryParamsDto, + unknown, + unknown, + ParsedQs +>; + +router.get( + "/:id/history", + authorize(ALL_ROLES), + validateRequest({ + params: patientHistoryParamsSchema, + query: patientHistoryQuerySchema, + }), + async (req: PatientHistoryRequest, res: Response) => { + const clinicId = req.user?.clinicId; + if (!clinicId) { + return res.status(401).json({ + error: "Unauthorized", + message: "Authentication required", + }); + } + + const query = patientHistoryQuerySchema.parse(req.query); + + const payload = await getPatientHistory({ + patientId: req.params.id, + clinicId, + page: query.page, + limit: query.limit, + }); + + if (!payload) { + return res.status(404).json({ + error: "NotFound", + message: "Patient not found", + }); + } + + return res.json({ + status: "success", + data: payload.patient, + encounters: payload.encounters, + meta: payload.meta, + }); + }, +); + +export const patientHistoryRoutes = router; diff --git a/apps/api/src/modules/patients/history.service.ts b/apps/api/src/modules/patients/history.service.ts new file mode 100644 index 0000000..0030f28 --- /dev/null +++ b/apps/api/src/modules/patients/history.service.ts @@ -0,0 +1,239 @@ +import mongoose from "mongoose"; +import { PatientModel } from "./models/patient.model"; + +type Pagination = { + page: number; + limit: number; + skip: number; +}; + +export const toPagination = (page: number, limit: number): Pagination => ({ + page, + limit, + skip: (page - 1) * limit, +}); + +const iso = (date: Date) => date.toISOString(); + +const makeMockEncounter = ( + id: string, + openedAt: Date, + closedAt: Date, + summary: string, +) => ({ + id, + status: "CLOSED", + openedAt: iso(openedAt), + closedAt: iso(closedAt), + providerId: "mock-provider-1", + vitals: [ + { + id: `${id}-v1`, + timestamp: iso(new Date(openedAt.getTime() + 20 * 60_000)), + bpSystolic: 122, + bpDiastolic: 80, + heartRate: 88, + temperature: 37, + respirationRate: 16, + spO2: 98, + weight: 72, + }, + ], + notes: [ + { + id: `${id}-n1`, + type: "SOAP", + authorId: "mock-provider-1", + timestamp: iso(new Date(openedAt.getTime() + 35 * 60_000)), + content: summary, + }, + ], + diagnoses: [ + { + id: `${id}-d1`, + code: "B50.9", + description: "Malaria, unspecified", + status: "CONFIRMED", + }, + ], +}); + +export const buildMockHistoryEncounters = () => { + const now = Date.now(); + + return [ + makeMockEncounter( + "mock-enc-3003", + new Date(now - 7 * 24 * 60 * 60_000), + new Date(now - 7 * 24 * 60 * 60_000 + 70 * 60_000), + "Patient improved after antimalarial dose.", + ), + makeMockEncounter( + "mock-enc-3002", + new Date(now - 21 * 24 * 60 * 60_000), + new Date(now - 21 * 24 * 60 * 60_000 + 65 * 60_000), + "Follow-up visit with reduced fever.", + ), + makeMockEncounter( + "mock-enc-3001", + new Date(now - 38 * 24 * 60 * 60_000), + new Date(now - 38 * 24 * 60 * 60_000 + 95 * 60_000), + "Initial presentation with high fever and chills.", + ), + ]; +}; + +export const getPatientHistory = async (input: { + patientId: string; + clinicId: string; + page: number; + limit: number; +}) => { + const patient = await PatientModel.findOne({ _id: input.patientId, clinicId: input.clinicId }).lean(); + if (!patient) { + return null; + } + + const pagination = toPagination(input.page, input.limit); + const encountersCollection = mongoose.connection.collection("encounters"); + + const total = await encountersCollection.countDocuments({ + patientId: input.patientId, + clinicId: input.clinicId, + }); + + const encounters = await encountersCollection + .aggregate([ + { + $match: { + patientId: input.patientId, + clinicId: input.clinicId, + }, + }, + { + $sort: { + openedAt: -1, + }, + }, + { + $skip: pagination.skip, + }, + { + $limit: pagination.limit, + }, + { + $lookup: { + from: "vitals", + localField: "_id", + foreignField: "encounterId", + as: "vitals", + }, + }, + { + $lookup: { + from: "clinicalnotes", + localField: "_id", + foreignField: "encounterId", + as: "notes", + }, + }, + { + $lookup: { + from: "diagnoses", + localField: "_id", + foreignField: "encounterId", + as: "diagnoses", + }, + }, + { + $project: { + id: { $toString: "$_id" }, + status: 1, + openedAt: 1, + closedAt: 1, + providerId: 1, + vitals: { + $map: { + input: "$vitals", + as: "v", + in: { + id: { $toString: "$$v._id" }, + timestamp: "$$v.timestamp", + bpSystolic: "$$v.bpSystolic", + bpDiastolic: "$$v.bpDiastolic", + heartRate: "$$v.heartRate", + temperature: "$$v.temperature", + respirationRate: "$$v.respirationRate", + spO2: "$$v.spO2", + weight: "$$v.weight", + }, + }, + }, + notes: { + $map: { + input: "$notes", + as: "n", + in: { + id: { $toString: "$$n._id" }, + type: "$$n.type", + authorId: "$$n.authorId", + timestamp: "$$n.timestamp", + content: "$$n.content", + }, + }, + }, + diagnoses: { + $map: { + input: "$diagnoses", + as: "d", + in: { + id: { $toString: "$$d._id" }, + code: "$$d.code", + description: "$$d.description", + status: "$$d.status", + }, + }, + }, + }, + }, + ]) + .toArray(); + + const mappedEncounters = encounters.map((encounter) => ({ + ...encounter, + openedAt: encounter.openedAt ? new Date(encounter.openedAt).toISOString() : null, + closedAt: encounter.closedAt ? new Date(encounter.closedAt).toISOString() : null, + vitals: (encounter.vitals ?? []).map((v: Record) => ({ + ...v, + timestamp: v.timestamp ? new Date(v.timestamp as string | Date).toISOString() : null, + })), + notes: (encounter.notes ?? []).map((n: Record) => ({ + ...n, + timestamp: n.timestamp ? new Date(n.timestamp as string | Date).toISOString() : null, + })), + })); + + const fallback = buildMockHistoryEncounters(); + const hasReal = mappedEncounters.length > 0; + + const fallbackPageSlice = fallback.slice(pagination.skip, pagination.skip + pagination.limit); + + return { + patient: { + id: String(patient._id), + systemId: patient.systemId, + firstName: patient.firstName, + lastName: patient.lastName, + sex: patient.sex, + dateOfBirth: patient.dateOfBirth.toISOString(), + }, + encounters: hasReal ? mappedEncounters : fallbackPageSlice, + meta: { + page: pagination.page, + limit: pagination.limit, + total: hasReal ? total : fallback.length, + totalPages: Math.max(1, Math.ceil((hasReal ? total : fallback.length) / pagination.limit)), + source: hasReal ? "db" : "mock", + }, + }; +}; diff --git a/apps/api/src/modules/patients/history.validation.ts b/apps/api/src/modules/patients/history.validation.ts new file mode 100644 index 0000000..809ae2e --- /dev/null +++ b/apps/api/src/modules/patients/history.validation.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +export const patientHistoryParamsSchema = z.object({ + id: z.string().trim().min(1), +}); + +export const patientHistoryQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(25).default(5), +}); + +export type PatientHistoryParamsDto = z.infer; +export type PatientHistoryQueryDto = z.infer; diff --git a/apps/api/tests/history.service.test.ts b/apps/api/tests/history.service.test.ts new file mode 100644 index 0000000..ae3846b --- /dev/null +++ b/apps/api/tests/history.service.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { buildMockHistoryEncounters, toPagination } from "../src/modules/patients/history.service"; + +describe("patient history helpers", () => { + it("calculates pagination offsets", () => { + const pagination = toPagination(3, 5); + expect(pagination).toEqual({ page: 3, limit: 5, skip: 10 }); + }); + + it("returns mock encounters sorted newest to oldest", () => { + const rows = buildMockHistoryEncounters(); + expect(rows.length).toBe(3); + + const openedTimes = rows.map((row) => new Date(row.openedAt).getTime()); + expect(openedTimes[0]).toBeGreaterThan(openedTimes[1]); + expect(openedTimes[1]).toBeGreaterThan(openedTimes[2]); + }); +}); diff --git a/apps/web/app/dashboard/patients/[id]/page.tsx b/apps/web/app/dashboard/patients/[id]/page.tsx new file mode 100644 index 0000000..29ce0c4 --- /dev/null +++ b/apps/web/app/dashboard/patients/[id]/page.tsx @@ -0,0 +1,304 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useParams } from "next/navigation"; +import { apiFetch } from "@/lib/api-client"; + +type HistoryVitals = { + id: string; + timestamp: string; + bpSystolic: number; + bpDiastolic: number; + heartRate: number; + temperature: number; + respirationRate: number; + spO2: number; + weight: number; +}; + +type HistoryNote = { + id: string; + timestamp: string; + authorId: string; + type: "SOAP" | "FREE_TEXT" | "AI_SUMMARY" | "CORRECTION"; + content: string; + correctionOfNoteId?: string; +}; + +type HistoryDiagnosis = { + id: string; + code: string; + description: string; + status: "SUSPECTED" | "CONFIRMED" | "RESOLVED"; +}; + +type HistoryEncounter = { + id: string; + status: "OPEN" | "IN_PROGRESS" | "CLOSED"; + openedAt: string; + closedAt: string | null; + vitals: HistoryVitals[]; + notes: HistoryNote[]; + diagnoses: HistoryDiagnosis[]; +}; + +type HistoryPatient = { + id: string; + systemId: string; + firstName: string; + lastName: string; + sex: "M" | "F" | "O"; + dateOfBirth: string; + isActive: boolean; +}; + +type HistoryResponse = { + status: "success"; + data: HistoryPatient; + encounters: HistoryEncounter[]; + meta: { + page: number; + limit: number; + total: number; + hasNextPage: boolean; + }; +}; + +const PAGE_SIZE = 5; + +const formatDateTime = (value: string) => + new Date(value).toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + +const statusBadgeClass = (status: HistoryDiagnosis["status"]) => { + if (status === "CONFIRMED") return "border-green-200 bg-green-50 text-green-700"; + if (status === "RESOLVED") return "border-slate-200 bg-slate-50 text-slate-700"; + return "border-amber-200 bg-amber-50 text-amber-700"; +}; + +export default function PatientHistoryPage() { + const params = useParams<{ id: string }>(); + const patientId = params?.id ?? ""; + + const [patient, setPatient] = useState(null); + const [encounters, setEncounters] = useState([]); + const [page, setPage] = useState(1); + const [hasNextPage, setHasNextPage] = useState(true); + const [isInitialLoading, setIsInitialLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState>({}); + + const loaderRef = useRef(null); + const inFlightRef = useRef(false); + + const loadPage = useCallback( + async (nextPage: number, replace = false) => { + if (!patientId || inFlightRef.current) return; + inFlightRef.current = true; + setError(null); + + if (replace) setIsInitialLoading(true); + else setIsLoadingMore(true); + + try { + const res = await apiFetch( + `/patients/${encodeURIComponent(patientId)}/history?page=${nextPage}&limit=${PAGE_SIZE}`, + ); + + if (!res.ok) { + if (res.status === 404) throw new Error("Patient not found."); + throw new Error("Unable to load patient history."); + } + + const payload = (await res.json()) as HistoryResponse; + + setPatient(payload.data); + setHasNextPage(payload.meta.hasNextPage); + setPage(payload.meta.page); + + setEncounters((current) => { + if (replace) return payload.encounters; + + const existing = new Map(current.map((item) => [item.id, item])); + for (const encounter of payload.encounters) { + existing.set(encounter.id, encounter); + } + return Array.from(existing.values()).sort( + (a, b) => new Date(b.openedAt).getTime() - new Date(a.openedAt).getTime(), + ); + }); + } catch (err) { + setError((err as Error).message || "Unable to load patient history."); + } finally { + inFlightRef.current = false; + setIsInitialLoading(false); + setIsLoadingMore(false); + } + }, + [patientId], + ); + + useEffect(() => { + setPatient(null); + setEncounters([]); + setExpanded({}); + setHasNextPage(true); + setPage(1); + void loadPage(1, true); + }, [loadPage]); + + useEffect(() => { + if (!loaderRef.current || !hasNextPage) return; + + const target = loaderRef.current; + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (!entry?.isIntersecting || inFlightRef.current || isInitialLoading) return; + void loadPage(page + 1, false); + }, + { rootMargin: "200px 0px" }, + ); + + observer.observe(target); + return () => observer.disconnect(); + }, [hasNextPage, isInitialLoading, loadPage, page]); + + const fullName = useMemo(() => { + if (!patient) return ""; + return `${patient.firstName} ${patient.lastName}`.trim(); + }, [patient]); + + return ( +
+
+

Patient Timeline

+ {patient ? ( +

+ {fullName} ({patient.systemId}) · {patient.sex} +

+ ) : ( +

Loading patient profile...

+ )} +
+ + {error ? ( +
{error}
+ ) : null} + + {isInitialLoading ? ( +
+ Loading medical history... +
+ ) : encounters.length === 0 ? ( +
+ No encounter history found. +
+ ) : ( +
+ {encounters.map((encounter) => { + const isOpen = expanded[encounter.id] ?? false; + return ( +
+ + + {isOpen ? ( +
+
+

Vitals

+ {encounter.vitals.length === 0 ? ( +

No vitals recorded.

+ ) : ( +
+ {encounter.vitals.map((vital) => ( +
+

{formatDateTime(vital.timestamp)}

+

+ BP {vital.bpSystolic}/{vital.bpDiastolic} · HR {vital.heartRate} · Temp{" "} + {vital.temperature}°C · RR {vital.respirationRate} · SpO2 {vital.spO2}% · Wt{" "} + {vital.weight}kg +

+
+ ))} +
+ )} +
+ +
+

Notes

+ {encounter.notes.length === 0 ? ( +

No notes recorded.

+ ) : ( +
+ {encounter.notes.map((note) => ( +
+

+ {note.type} · {formatDateTime(note.timestamp)} +

+

{note.content}

+
+ ))} +
+ )} +
+ +
+

Diagnoses

+ {encounter.diagnoses.length === 0 ? ( +

No diagnoses recorded.

+ ) : ( +
+ {encounter.diagnoses.map((diagnosis) => ( + + {diagnosis.code} - {diagnosis.description} ({diagnosis.status}) + + ))} +
+ )} +
+
+ ) : null} +
+ ); + })} + +
+ {isLoadingMore ? ( +

Loading more encounters...

+ ) : hasNextPage ? null : ( +

End of timeline.

+ )} +
+
+ )} +
+ ); +}