From 84c71de2d48c33ef842a35faa30d08ed72f9536e Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Thu, 11 Jun 2026 21:03:38 +0300 Subject: [PATCH] backend: add POST /api/patients/:fileNumber/labs append endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lab staff hold lab:write but not patient:write, so they can't go through the wholesale PUT update. The new endpoint appends lab rows (positions continue after the current max), bumps updatedAt, records activity and notifies the clinic — without touching the rest of the record. Co-Authored-By: Claude Fable 5 --- backend/src/routes/patients.ts | 44 ++++++++++++++++++++++++++++++- backend/src/services/patients.ts | 45 +++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/patients.ts b/backend/src/routes/patients.ts index 3a9b1c8..6528a63 100644 --- a/backend/src/routes/patients.ts +++ b/backend/src/routes/patients.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import { db } from "../db/index.js"; import { member, user } from "../db/schema/auth.js"; import { HttpError } from "../lib/http-error.js"; -import { patientInputSchema } from "../lib/patient-validation.js"; +import { labSchema, patientInputSchema } from "../lib/patient-validation.js"; import { requireAuth, requireOrg, @@ -23,6 +23,10 @@ const transferInputSchema = z.object({ providerId: z.string().trim().min(1), }); +const labsAppendSchema = z.object({ + labs: z.array(labSchema).min(1).max(50), +}); + // Only the `doctor` role is scoped to its own panel of patients. Any elevated // clinical role (owner / admin / member) sees the whole clinic, so scoping never // applies when the caller also holds one of those. Returns the user id to scope @@ -243,6 +247,44 @@ patientsRouter.post( }, ); +// Append lab results to a patient's record. Gated by the dedicated `lab` +// statement (not patient:write) so lab staff can submit analyses without +// being able to edit the rest of the record. Full clinicians also hold +// lab:write. +patientsRouter.post( + "/:fileNumber/labs", + requirePermission({ lab: ["write"] }), + async (req, res, next) => { + try { + const { labs } = labsAppendSchema.parse(req.body); + const updated = await service.appendLabs( + req.organizationId!, + req.params.fileNumber as string, + labs, + ); + if (!updated) throw new HttpError(404, "Patient not found."); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Added ${labs.length} lab result${labs.length === 1 ? "" : "s"} for ${updated.name}`, + entityType: "patient", + entityId: updated.fileNumber, + patientName: updated.name, + patientFileNumber: updated.fileNumber, + }); + await notifyClinic( + req.organizationId!, + { id: req.user!.id, name: req.user!.name }, + `${req.user!.name} added lab results for ${updated.name}`, + updated.fileNumber, + ); + res.status(201).json(updated); + } catch (err) { + next(err); + } + }, +); + patientsRouter.delete( "/:fileNumber", requirePermission({ patient: ["delete"] }), diff --git a/backend/src/services/patients.ts b/backend/src/services/patients.ts index 9dade0e..f70f920 100644 --- a/backend/src/services/patients.ts +++ b/backend/src/services/patients.ts @@ -1,4 +1,4 @@ -import { and, asc, eq, inArray, isNull, or } from "drizzle-orm"; +import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm"; import type { SQL } from "drizzle-orm"; import { db } from "../db/index.js"; @@ -461,6 +461,49 @@ export async function updatePatient( } } +// Append lab results without touching the rest of the record — lab staff hold +// `lab:write`, not `patient:write`, so they must not go through updatePatient's +// wholesale child replacement. Positions continue after the current max so the +// new rows sort last. +export async function appendLabs( + orgId: string, + fileNumber: string, + entries: Lab[], +): Promise { + const inserted = await db.transaction(async (tx) => { + const [existing] = await tx + .select({ id: patients.id }) + .from(patients) + .where( + and( + eq(patients.organizationId, orgId), + eq(patients.fileNumber, fileNumber), + ), + ); + if (!existing) return false; + + const [pos] = await tx + .select({ max: sql`coalesce(max(${labs.position}), -1)` }) + .from(labs) + .where(eq(labs.patientId, existing.id)); + await tx.insert(labs).values( + entries.map((lab, i) => ({ + patientId: existing.id, + position: (pos?.max ?? -1) + 1 + i, + ...lab, + })), + ); + await tx + .update(patients) + .set({ updatedAt: new Date() }) + .where(eq(patients.id, existing.id)); + return true; + }); + if (!inserted) return null; + // Reload after commit — loadChildren queries via the non-tx `db` client. + return getPatient(orgId, fileNumber); +} + export async function deletePatient( orgId: string, fileNumber: string,