mirror of
https://github.com/temetro/temetro.git
synced 2026-07-27 20:28:57 +00:00
backend: add POST /api/patients/:fileNumber/labs append endpoint
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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"] }),
|
||||
|
||||
@@ -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<Patient | null> {
|
||||
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<number>`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,
|
||||
|
||||
Reference in New Issue
Block a user