backend: prescription start/end dates + dispense ledger

Add optional startDate/endDate columns to prescriptions (schema,
validation, types, service) — when set, endDate drives expiry.

Add a new append-only `dispenses` resource (schema, types, validation,
service, route at /api/dispenses) recording who received which medication,
gated on the existing inventory RBAC statement. Migration 0020.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-16 20:44:47 +03:00
parent 15fc7fdf26
commit f0fe501d62
15 changed files with 3749 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
CREATE TABLE "dispenses" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" text NOT NULL,
"patient_file_number" text DEFAULT '' NOT NULL,
"patient_name" text NOT NULL,
"patient_initials" text DEFAULT '' NOT NULL,
"medication" text NOT NULL,
"dose" text DEFAULT '' NOT NULL,
"quantity" integer DEFAULT 0 NOT NULL,
"unit" text DEFAULT '' NOT NULL,
"prescription_id" uuid,
"dispensed_by" text,
"dispensed_by_name" text DEFAULT '' NOT NULL,
"dispensed_at" timestamp DEFAULT now() NOT NULL,
"notes" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "prescriptions" ADD COLUMN "start_date" date;--> statement-breakpoint
ALTER TABLE "prescriptions" ADD COLUMN "end_date" date;--> statement-breakpoint
ALTER TABLE "dispenses" ADD CONSTRAINT "dispenses_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "dispenses" ADD CONSTRAINT "dispenses_dispensed_by_user_id_fk" FOREIGN KEY ("dispensed_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "dispenses_org_idx" ON "dispenses" USING btree ("organization_id");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -141,6 +141,13 @@
"when": 1781538797034,
"tag": "0019_black_mole_man",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1781629630102,
"tag": "0020_freezing_tomas",
"breakpoints": true
}
]
}
+35
View File
@@ -0,0 +1,35 @@
import { index, integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { organization, user } from "./auth.js";
// One row per dispensing event — the record of a medication actually handed to a
// patient at the pharmacy, scoped to a clinic (organization). Distinct from
// `prescriptions` (the order) and `inventory` (standing stock): this is the
// fulfilment ledger ("who took which medication"). Patient + dispenser identity
// are denormalized so the list renders without joining; `prescriptionId` links
// back to the source order when the dispense came from the queue.
export const dispenses = pgTable(
"dispenses",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
patientFileNumber: text("patient_file_number").notNull().default(""),
patientName: text("patient_name").notNull(),
patientInitials: text("patient_initials").notNull().default(""),
medication: text("medication").notNull(),
dose: text("dose").notNull().default(""),
quantity: integer("quantity").notNull().default(0),
unit: text("unit").notNull().default(""),
prescriptionId: uuid("prescription_id"),
dispensedBy: text("dispensed_by").references(() => user.id, {
onDelete: "set null",
}),
dispensedByName: text("dispensed_by_name").notNull().default(""),
dispensedAt: timestamp("dispensed_at").defaultNow().notNull(),
notes: text("notes"),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(t) => [index("dispenses_org_idx").on(t.organizationId)],
);
+1
View File
@@ -5,6 +5,7 @@ export * from "./appointments.js";
export * from "./prescriptions.js";
export * from "./invoices.js";
export * from "./inventory.js";
export * from "./dispenses.js";
export * from "./tasks.js";
export * from "./activity.js";
export * from "./messaging.js";
+5
View File
@@ -28,6 +28,11 @@ export const prescriptions = pgTable(
frequency: text("frequency").notNull(),
prescriber: text("prescriber").notNull(),
prescribedAt: date("prescribed_at").defaultNow().notNull(),
// Optional explicit course window. When set, `endDate` drives expiry instead
// of parsing the free-text `duration`. Both are opt-in (the dialog reveals
// them behind a toggle), so they stay nullable.
startDate: date("start_date"),
endDate: date("end_date"),
status: text("status").$type<PrescriptionStatus>().notNull(),
duration: text("duration"),
notes: text("notes"),
+3
View File
@@ -14,6 +14,7 @@ import { analyticsRouter } from "./routes/analytics.js";
import { appointmentsRouter } from "./routes/appointments.js";
import { chatRouter } from "./routes/chat.js";
import { conversationsRouter } from "./routes/conversations.js";
import { dispensesRouter } from "./routes/dispenses.js";
import { inventoryRouter } from "./routes/inventory.js";
import { invoicesRouter } from "./routes/invoices.js";
import { notesRouter } from "./routes/notes.js";
@@ -66,6 +67,7 @@ app.use("/api/notes", notesRouter);
app.use("/api/appointments", appointmentsRouter);
app.use("/api/prescriptions", prescriptionsRouter);
app.use("/api/inventory", inventoryRouter);
app.use("/api/dispenses", dispensesRouter);
app.use("/api/invoices", invoicesRouter);
app.use("/api/tasks", tasksRouter);
app.use("/api/staff", staffRouter);
@@ -92,6 +94,7 @@ server.listen(env.PORT, () => {
console.log(` • appts: /api/appointments`);
console.log(` • rx: /api/prescriptions`);
console.log(` • stock: /api/inventory`);
console.log(` • dispense: /api/dispenses`);
console.log(` • tasks: /api/tasks`);
console.log(` • staff: /api/staff`);
console.log(` • activity: /api/activity`);
+20
View File
@@ -0,0 +1,20 @@
import { z } from "zod";
const nonEmpty = z.string().trim().min(1);
// Payload accepted by POST /api/dispenses. Recorded when the pharmacy dispenses
// a medication (typically from the dispensing queue). The route fills the
// dispenser identity from the signed-in user and the DB defaults `dispensedAt`.
export const dispenseInputSchema = z.object({
fileNumber: z.string().trim().default(""),
name: nonEmpty.max(200),
initials: z.string().trim().max(4).default(""),
medication: nonEmpty.max(200),
dose: z.string().trim().max(120).default(""),
quantity: z.number().int().min(0).max(1_000_000).default(0),
unit: z.string().trim().max(60).default(""),
prescriptionId: z.string().uuid().nullish(),
notes: z.string().max(5000).nullish(),
});
export type DispenseInput = z.infer<typeof dispenseInputSchema>;
@@ -18,6 +18,14 @@ export const prescriptionInputSchema = z.object({
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD.")
.optional(),
startDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD.")
.nullish(),
endDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD.")
.nullish(),
status: z.enum(["active", "completed", "expired"]).default("active"),
duration: z.string().trim().max(120).nullish(),
notes: z.string().max(5000).nullish(),
+55
View File
@@ -0,0 +1,55 @@
import { Router } from "express";
import { dispenseInputSchema } from "../lib/dispense-validation.js";
import {
requireAuth,
requireOrg,
requirePermission,
} from "../middleware/auth.js";
import { recordActivity } from "../services/activity.js";
import * as service from "../services/dispenses.js";
// The dispensing ledger. Reuses the `inventory` RBAC statement (pharmacy holds
// inventory read/write), so no new access-control role is needed.
export const dispensesRouter = Router();
dispensesRouter.use(requireAuth, requireOrg);
dispensesRouter.get(
"/",
requirePermission({ inventory: ["read"] }),
async (req, res, next) => {
try {
res.json(await service.listDispenses(req.organizationId!));
} catch (err) {
next(err);
}
},
);
dispensesRouter.post(
"/",
requirePermission({ inventory: ["write"] }),
async (req, res, next) => {
try {
const input = dispenseInputSchema.parse(req.body);
const created = await service.createDispense(
req.organizationId!,
{ id: req.user!.id, name: req.user!.name || "Pharmacy" },
input,
);
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `Dispensed ${created.medication} to ${created.name}`,
entityType: "dispense",
entityId: created.id,
patientName: created.name,
patientFileNumber: created.fileNumber || null,
});
res.status(201).json(created);
} catch (err) {
next(err);
}
},
);
+61
View File
@@ -0,0 +1,61 @@
import { desc, eq } from "drizzle-orm";
import { db } from "../db/index.js";
import { dispenses } from "../db/schema/dispenses.js";
import type { DispenseInput } from "../lib/dispense-validation.js";
import type { Dispense } from "../types/dispense.js";
type DispenseRow = typeof dispenses.$inferSelect;
function toDispense(row: DispenseRow): Dispense {
return {
id: row.id,
fileNumber: row.patientFileNumber,
name: row.patientName,
initials: row.patientInitials,
medication: row.medication,
dose: row.dose,
quantity: row.quantity,
unit: row.unit,
prescriptionId: row.prescriptionId,
dispensedBy: row.dispensedBy,
dispensedByName: row.dispensedByName,
dispensedAt: row.dispensedAt.toISOString(),
notes: row.notes,
createdAt: row.createdAt.toISOString(),
};
}
export async function listDispenses(orgId: string): Promise<Dispense[]> {
const rows = await db
.select()
.from(dispenses)
.where(eq(dispenses.organizationId, orgId))
.orderBy(desc(dispenses.dispensedAt));
return rows.map(toDispense);
}
export async function createDispense(
orgId: string,
dispenser: { id: string; name: string },
input: DispenseInput,
): Promise<Dispense> {
const [row] = await db
.insert(dispenses)
.values({
organizationId: orgId,
patientFileNumber: input.fileNumber,
patientName: input.name,
patientInitials: input.initials,
medication: input.medication,
dose: input.dose,
quantity: input.quantity,
unit: input.unit,
prescriptionId: input.prescriptionId ?? null,
dispensedBy: dispenser.id,
dispensedByName: dispenser.name,
notes: input.notes ?? null,
})
.returning();
return toDispense(row!);
}
+4
View File
@@ -22,6 +22,8 @@ function toPrescription(row: PrescriptionRow): Prescription {
frequency: row.frequency,
prescriber: row.prescriber,
prescribedAt: row.prescribedAt,
startDate: row.startDate,
endDate: row.endDate,
status: row.status,
duration: row.duration,
notes: row.notes,
@@ -45,6 +47,8 @@ function columns(orgId: string, input: PrescriptionInput, createdBy?: string) {
duration: input.duration ?? null,
notes: input.notes ?? null,
source: input.source,
startDate: input.startDate ?? null,
endDate: input.endDate ?? null,
// Only set prescribedAt when supplied; otherwise the column default (today).
...(input.prescribedAt ? { prescribedAt: input.prescribedAt } : {}),
...(createdBy ? { createdBy } : {}),
+1
View File
@@ -8,6 +8,7 @@ export type ActivityEntityType =
| "prescription"
| "invoice"
| "inventory"
| "dispense"
| "task";
export type ActivityEntry = {
+19
View File
@@ -0,0 +1,19 @@
// The canonical Dispense shape returned by the API. Mirrors the frontend
// `lib/dispenses.ts` Dispense type. Scoped to the active clinic; patient and
// dispenser fields are denormalized for display.
export type Dispense = {
id: string;
fileNumber: string;
name: string;
initials: string;
medication: string;
dose: string;
quantity: number;
unit: string;
prescriptionId: string | null;
dispensedBy: string | null;
dispensedByName: string;
dispensedAt: string; // ISO timestamp
notes: string | null;
createdAt: string;
};
+2
View File
@@ -13,6 +13,8 @@ export type Prescription = {
frequency: string;
prescriber: string;
prescribedAt: string; // YYYY-MM-DD
startDate: string | null; // YYYY-MM-DD, optional course start
endDate: string | null; // YYYY-MM-DD, optional course end (drives expiry)
status: PrescriptionStatus;
duration: string | null;
notes: string | null;