mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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:
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)],
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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!);
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ActivityEntityType =
|
||||
| "prescription"
|
||||
| "invoice"
|
||||
| "inventory"
|
||||
| "dispense"
|
||||
| "task";
|
||||
|
||||
export type ActivityEntry = {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user