Skip to content
Merged
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 apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down
122 changes: 122 additions & 0 deletions apps/api/src/modules/diagnoses/diagnoses.controller.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>, 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<string>();

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;
19 changes: 19 additions & 0 deletions apps/api/src/modules/diagnoses/diagnoses.validation.ts
Original file line number Diff line number Diff line change
@@ -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<typeof diagnosisSearchQuerySchema>;
export type EncounterDiagnosisParamsDto = z.infer<typeof encounterDiagnosisParamsSchema>;
export type AttachDiagnosisDto = z.infer<typeof attachDiagnosisSchema>;
55 changes: 55 additions & 0 deletions apps/api/src/modules/diagnoses/models/diagnosis.model.ts
Original file line number Diff line number Diff line change
@@ -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<DiagnosisDocument>(
{
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<DiagnosisDocument>("Diagnosis", diagnosisSchema);
38 changes: 38 additions & 0 deletions apps/api/src/modules/diagnoses/models/icd10-code.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Schema, model, models } from "mongoose";

export interface Icd10CodeDocument {
code: string;
description: string;
searchText: string;
}

const icd10CodeSchema = new Schema<Icd10CodeDocument>(
{
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<Icd10CodeDocument>("Icd10Code", icd10CodeSchema);
13 changes: 13 additions & 0 deletions apps/api/tests/icd10.seed.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
18 changes: 18 additions & 0 deletions apps/web/app/dashboard/diagnoses/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { DiagnosesCombobox } from "@/components/diagnoses/DiagnosesCombobox";

export default function DiagnosesPage() {
const encounterId = "mock-enc-123";

return (
<main className="space-y-4 p-4 md:p-6">
<header className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<h1 className="text-xl font-semibold text-slate-900 md:text-2xl">Diagnoses</h1>
<p className="mt-1 text-sm text-slate-600">
Search and attach ICD-10 diagnoses to the active encounter.
</p>
</header>

<DiagnosesCombobox encounterId={encounterId} />
</main>
);
}
Loading