diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 7609eaf..b68daa7 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -12,6 +12,7 @@ import { authRoutes } from './modules/auth/auth.controller'; import { clinicOnboardingRoutes } from './modules/clinics/onboarding.controller'; import { clinicSettingsRoutes } from './modules/clinics/settings.controller'; import { paymentRoutes } from './modules/payments/payments.controller'; +import { diagnosisRoutes } from './modules/diagnoses/diagnoses.controller'; import { queueRoutes } from './modules/queue/queue.controller'; import { userRoutes } from './modules/users/users.controller'; import { notesRoutes } from './modules/notes/notes.controller'; @@ -30,6 +31,7 @@ app.use('/api/v1/clinics', clinicSettingsRoutes); app.use('/api/v1/users', userRoutes); app.use('/api/v1/payments', paymentRoutes); app.use('/api/v1/queue', queueRoutes); +app.use('/api/v1', diagnosisRoutes); app.use('/api/v1/patients', patientRoutes); app.use('/api/v1/vitals', vitalsRoutes); app.use('/api/v1/encounters', encounterRoutes); diff --git a/apps/api/src/modules/diagnoses/diagnoses.controller.ts b/apps/api/src/modules/diagnoses/diagnoses.controller.ts new file mode 100644 index 0000000..751bb64 --- /dev/null +++ b/apps/api/src/modules/diagnoses/diagnoses.controller.ts @@ -0,0 +1,122 @@ +import { Request, Response, Router } from "express"; +import { authorize, Roles } from "../../middlewares/rbac.middleware"; +import { validateRequest } from "../../middlewares/validate.middleware"; +import { DiagnosisModel } from "./models/diagnosis.model"; +import { Icd10CodeModel } from "./models/icd10-code.model"; +import { + AttachDiagnosisDto, + DiagnosisSearchQueryDto, + EncounterDiagnosisParamsDto, + attachDiagnosisSchema, + diagnosisSearchQuerySchema, + encounterDiagnosisParamsSchema, +} from "./diagnoses.validation"; + +const router = Router(); +const ALL_ROLES: Roles[] = Object.values(Roles); +const CLINICAL_ROLES: Roles[] = [Roles.SUPER_ADMIN, Roles.CLINIC_ADMIN, Roles.DOCTOR, Roles.NURSE]; + +type SearchRequest = Request, unknown, unknown, DiagnosisSearchQueryDto>; +type AttachDiagnosisRequest = Request< + EncounterDiagnosisParamsDto, + unknown, + AttachDiagnosisDto +>; + +router.get( + "/diagnoses/search", + authorize(ALL_ROLES), + validateRequest({ query: diagnosisSearchQuerySchema }), + async (req: SearchRequest, res: Response) => { + const query = req.query.q.trim(); + + const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"); + + const [textHits, regexHits] = await Promise.all([ + Icd10CodeModel.find( + { $text: { $search: query } }, + { score: { $meta: "textScore" }, code: 1, description: 1 }, + ) + .sort({ score: { $meta: "textScore" } }) + .limit(12) + .lean(), + Icd10CodeModel.find( + { + $or: [{ code: { $regex: regex } }, { description: { $regex: regex } }], + }, + { code: 1, description: 1 }, + ) + .limit(12) + .lean(), + ]); + + const merged = [...textHits, ...regexHits]; + const seen = new Set(); + + const data = merged + .filter((item) => { + if (seen.has(item.code)) { + return false; + } + seen.add(item.code); + return true; + }) + .slice(0, 15) + .map((item) => ({ + code: item.code, + description: item.description, + })); + + return res.json({ + status: "success", + data, + }); + }, +); + +router.post( + "/encounters/:encounterId/diagnoses", + authorize(CLINICAL_ROLES), + validateRequest({ params: encounterDiagnosisParamsSchema, body: attachDiagnosisSchema }), + async (req: AttachDiagnosisRequest, res: Response) => { + const clinicId = req.user?.clinicId; + if (!clinicId) { + return res.status(401).json({ + error: "Unauthorized", + message: "Authentication required", + }); + } + + const diagnosis = await DiagnosisModel.findOneAndUpdate( + { + clinicId, + encounterId: req.params.encounterId, + code: req.body.code, + }, + { + $set: { + description: req.body.description, + status: req.body.status, + }, + }, + { + new: true, + upsert: true, + setDefaultsOnInsert: true, + }, + ).lean(); + + return res.status(201).json({ + status: "success", + data: { + id: String(diagnosis?._id), + encounterId: diagnosis?.encounterId, + code: diagnosis?.code, + description: diagnosis?.description, + status: diagnosis?.status, + }, + }); + }, +); + +export const diagnosisRoutes = router; diff --git a/apps/api/src/modules/diagnoses/diagnoses.validation.ts b/apps/api/src/modules/diagnoses/diagnoses.validation.ts new file mode 100644 index 0000000..47a8ba8 --- /dev/null +++ b/apps/api/src/modules/diagnoses/diagnoses.validation.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export const diagnosisSearchQuerySchema = z.object({ + q: z.string().trim().min(1), +}); + +export const encounterDiagnosisParamsSchema = z.object({ + encounterId: z.string().trim().min(1), +}); + +export const attachDiagnosisSchema = z.object({ + code: z.string().trim().min(3), + description: z.string().trim().min(3), + status: z.enum(["SUSPECTED", "CONFIRMED", "RESOLVED"]).default("CONFIRMED"), +}); + +export type DiagnosisSearchQueryDto = z.infer; +export type EncounterDiagnosisParamsDto = z.infer; +export type AttachDiagnosisDto = z.infer; diff --git a/apps/api/src/modules/diagnoses/models/diagnosis.model.ts b/apps/api/src/modules/diagnoses/models/diagnosis.model.ts new file mode 100644 index 0000000..75ac846 --- /dev/null +++ b/apps/api/src/modules/diagnoses/models/diagnosis.model.ts @@ -0,0 +1,55 @@ +import { Schema, model, models } from "mongoose"; + +export type DiagnosisStatus = "SUSPECTED" | "CONFIRMED" | "RESOLVED"; + +export interface DiagnosisDocument { + encounterId: string; + clinicId: string; + code: string; + description: string; + status: DiagnosisStatus; +} + +const diagnosisSchema = new Schema( + { + encounterId: { + type: String, + required: true, + trim: true, + index: true, + }, + clinicId: { + type: String, + required: true, + trim: true, + index: true, + }, + code: { + type: String, + required: true, + trim: true, + index: true, + }, + description: { + type: String, + required: true, + trim: true, + }, + status: { + type: String, + enum: ["SUSPECTED", "CONFIRMED", "RESOLVED"], + required: true, + default: "CONFIRMED", + index: true, + }, + }, + { + timestamps: true, + versionKey: false, + }, +); + +diagnosisSchema.index({ clinicId: 1, encounterId: 1, code: 1 }, { unique: true }); + +export const DiagnosisModel = + models.Diagnosis || model("Diagnosis", diagnosisSchema); diff --git a/apps/api/src/modules/diagnoses/models/icd10-code.model.ts b/apps/api/src/modules/diagnoses/models/icd10-code.model.ts new file mode 100644 index 0000000..a16544a --- /dev/null +++ b/apps/api/src/modules/diagnoses/models/icd10-code.model.ts @@ -0,0 +1,38 @@ +import { Schema, model, models } from "mongoose"; + +export interface Icd10CodeDocument { + code: string; + description: string; + searchText: string; +} + +const icd10CodeSchema = new Schema( + { + code: { + type: String, + required: true, + trim: true, + unique: true, + index: true, + }, + description: { + type: String, + required: true, + trim: true, + }, + searchText: { + type: String, + required: true, + trim: true, + }, + }, + { + timestamps: true, + versionKey: false, + }, +); + +icd10CodeSchema.index({ code: "text", description: "text", searchText: "text" }); + +export const Icd10CodeModel = + models.Icd10Code || model("Icd10Code", icd10CodeSchema); diff --git a/apps/api/tests/icd10.seed.test.ts b/apps/api/tests/icd10.seed.test.ts new file mode 100644 index 0000000..7a3920b --- /dev/null +++ b/apps/api/tests/icd10.seed.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { buildIcd10LiteDataset } from "../../scripts/seed-icd10-lite"; + +describe("buildIcd10LiteDataset", () => { + it("generates 1000 deterministic codes", () => { + const rows = buildIcd10LiteDataset(1000); + expect(rows).toHaveLength(1000); + expect(rows[0]).toMatchObject({ code: "A00.0" }); + + const unique = new Set(rows.map((row) => row.code)); + expect(unique.size).toBe(1000); + }); +}); diff --git a/apps/web/app/dashboard/diagnoses/page.tsx b/apps/web/app/dashboard/diagnoses/page.tsx new file mode 100644 index 0000000..fe703b1 --- /dev/null +++ b/apps/web/app/dashboard/diagnoses/page.tsx @@ -0,0 +1,18 @@ +import { DiagnosesCombobox } from "@/components/diagnoses/DiagnosesCombobox"; + +export default function DiagnosesPage() { + const encounterId = "mock-enc-123"; + + return ( +
+
+

Diagnoses

+

+ Search and attach ICD-10 diagnoses to the active encounter. +

+
+ + +
+ ); +} diff --git a/apps/web/components/diagnoses/DiagnosesCombobox.tsx b/apps/web/components/diagnoses/DiagnosesCombobox.tsx new file mode 100644 index 0000000..e701441 --- /dev/null +++ b/apps/web/components/diagnoses/DiagnosesCombobox.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { apiFetch } from "@/lib/api-client"; + +type DiagnosisStatus = "SUSPECTED" | "CONFIRMED" | "RESOLVED"; + +type DiagnosisOption = { + code: string; + description: string; +}; + +type SelectedDiagnosis = DiagnosisOption & { + status: DiagnosisStatus; +}; + +export const DiagnosesCombobox = ({ encounterId }: { encounterId: string }) => { + const [query, setQuery] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [options, setOptions] = useState([]); + const [selected, setSelected] = useState([]); + const [selectedStatus, setSelectedStatus] = useState("CONFIRMED"); + const [error, setError] = useState(null); + + const normalizedQuery = useMemo(() => query.trim(), [query]); + + useEffect(() => { + if (!normalizedQuery) { + setOptions([]); + return; + } + + const controller = new AbortController(); + + const timer = window.setTimeout(async () => { + setIsLoading(true); + setError(null); + try { + const response = await apiFetch(`/diagnoses/search?q=${encodeURIComponent(normalizedQuery)}`, { + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error("search failed"); + } + + const payload = (await response.json()) as { data?: DiagnosisOption[] }; + setOptions(payload.data ?? []); + } catch (err) { + if ((err as { name?: string }).name === "AbortError") { + return; + } + + setOptions([]); + setError("Unable to search diagnoses."); + } finally { + setIsLoading(false); + } + }, 300); + + return () => { + window.clearTimeout(timer); + controller.abort(); + }; + }, [normalizedQuery]); + + const attachDiagnosis = async (option: DiagnosisOption) => { + const currentStatus = selectedStatus; + + const response = await apiFetch(`/encounters/${encounterId}/diagnoses`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code: option.code, + description: option.description, + status: currentStatus, + }), + }); + + if (!response.ok) { + setError("Failed to attach diagnosis."); + return; + } + + setSelected((current) => { + if (current.some((item) => item.code === option.code)) { + return current; + } + + return [...current, { ...option, status: currentStatus }]; + }); + + setQuery(""); + setOptions([]); + }; + + const removeDiagnosis = (code: string) => { + setSelected((current) => current.filter((item) => item.code !== code)); + }; + + return ( +
+

Diagnosis Selection

+

Search ICD-10 and add diagnosis badges.

+ +
+ setQuery(event.target.value)} + placeholder="Search ICD-10 code or description..." + className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-teal-600 focus:ring-2 focus:ring-teal-100" + /> + + +
+ + {error ? ( +

+ {error} +

+ ) : null} + + {normalizedQuery ? ( +
+ {isLoading ? ( +

Searching...

+ ) : options.length === 0 ? ( +

No diagnosis matches found.

+ ) : ( +
    + {options.map((option) => ( +
  • + +
  • + ))} +
+ )} +
+ ) : null} + +
+ {selected.map((item) => ( + + {item.code} - {item.description} ({item.status}) + + + ))} +
+
+ ); +}; diff --git a/apps/web/layouts/DashboardLayout.tsx b/apps/web/layouts/DashboardLayout.tsx index 196503a..7efa491 100644 --- a/apps/web/layouts/DashboardLayout.tsx +++ b/apps/web/layouts/DashboardLayout.tsx @@ -11,6 +11,7 @@ const NAV = [ { href: "/dashboard/encounters", label: "Encounters", disabled: false }, { href: "/dashboard/vitals", label: "Vitals", disabled: false }, { href: "/dashboard/notes", label: "Notes", disabled: false }, + { href: "/dashboard/diagnoses", label: "Diagnoses", disabled: false }, { href: "/dashboard/patients", label: "Patients", disabled: false }, { href: "/dashboard/settings/staff", label: "Staff", disabled: false }, { href: "/dashboard/audit", label: "Audit Logs", disabled: false }, diff --git a/scripts/seed-icd10-lite.ts b/scripts/seed-icd10-lite.ts new file mode 100644 index 0000000..30d3a09 --- /dev/null +++ b/scripts/seed-icd10-lite.ts @@ -0,0 +1,97 @@ +import mongoose from "mongoose"; +import { config } from "@lumen/config"; +import { Icd10CodeModel } from "../apps/api/src/modules/diagnoses/models/icd10-code.model"; + +type Icd10SeedRow = { + code: string; + description: string; + searchText: string; +}; + +const MAJOR_GROUPS = [ + "Infectious disease", + "Respiratory condition", + "Gastrointestinal disorder", + "Hypertensive disease", + "Endocrine disorder", + "Musculoskeletal condition", + "Skin condition", + "Genitourinary condition", + "Neurologic disorder", + "General symptom", +] as const; + +export const buildIcd10LiteDataset = (size = 1000): Icd10SeedRow[] => { + const rows: Icd10SeedRow[] = []; + + for (let i = 0; i < size; i += 1) { + const letter = String.fromCharCode(65 + (i % 26)); + const major = Math.floor(i / 10) % 100; + const minor = i % 10; + const code = `${letter}${major.toString().padStart(2, "0")}.${minor}`; + const group = MAJOR_GROUPS[i % MAJOR_GROUPS.length]; + const description = `${group} variant ${i + 1}`; + + rows.push({ + code, + description, + searchText: `${code} ${description}`.toLowerCase(), + }); + } + + return rows; +}; + +export const upsertIcd10Lite = async (rows: Icd10SeedRow[]) => { + const operations = rows.map((row) => ({ + updateOne: { + filter: { code: row.code }, + update: { + $set: { + description: row.description, + searchText: row.searchText, + }, + }, + upsert: true, + }, + })); + + if (operations.length === 0) { + return { upsertedCount: 0, modifiedCount: 0 }; + } + + const result = await Icd10CodeModel.bulkWrite(operations, { ordered: false }); + + return { + upsertedCount: result.upsertedCount, + modifiedCount: result.modifiedCount, + }; +}; + +const run = async () => { + const uri = config.mongoUri; + if (!uri) { + throw new Error("MONGO_URI is required"); + } + + await mongoose.connect(uri); + + const rows = buildIcd10LiteDataset(1000); + const result = await upsertIcd10Lite(rows); + + console.log("seed-icd10-lite completed", { + total: rows.length, + upserted: result.upsertedCount, + modified: result.modifiedCount, + }); + + await mongoose.disconnect(); +}; + +if (require.main === module) { + void run().catch(async (error) => { + console.error("seed-icd10-lite failed", error); + await mongoose.disconnect(); + process.exit(1); + }); +}