mirror of
https://github.com/temetro/temetro.git
synced 2026-08-28 19:37:33 +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,
|
"when": 1781538797034,
|
||||||
"tag": "0019_black_mole_man",
|
"tag": "0019_black_mole_man",
|
||||||
"breakpoints": true
|
"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 "./prescriptions.js";
|
||||||
export * from "./invoices.js";
|
export * from "./invoices.js";
|
||||||
export * from "./inventory.js";
|
export * from "./inventory.js";
|
||||||
|
export * from "./dispenses.js";
|
||||||
export * from "./tasks.js";
|
export * from "./tasks.js";
|
||||||
export * from "./activity.js";
|
export * from "./activity.js";
|
||||||
export * from "./messaging.js";
|
export * from "./messaging.js";
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ export const prescriptions = pgTable(
|
|||||||
frequency: text("frequency").notNull(),
|
frequency: text("frequency").notNull(),
|
||||||
prescriber: text("prescriber").notNull(),
|
prescriber: text("prescriber").notNull(),
|
||||||
prescribedAt: date("prescribed_at").defaultNow().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(),
|
status: text("status").$type<PrescriptionStatus>().notNull(),
|
||||||
duration: text("duration"),
|
duration: text("duration"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { analyticsRouter } from "./routes/analytics.js";
|
|||||||
import { appointmentsRouter } from "./routes/appointments.js";
|
import { appointmentsRouter } from "./routes/appointments.js";
|
||||||
import { chatRouter } from "./routes/chat.js";
|
import { chatRouter } from "./routes/chat.js";
|
||||||
import { conversationsRouter } from "./routes/conversations.js";
|
import { conversationsRouter } from "./routes/conversations.js";
|
||||||
|
import { dispensesRouter } from "./routes/dispenses.js";
|
||||||
import { inventoryRouter } from "./routes/inventory.js";
|
import { inventoryRouter } from "./routes/inventory.js";
|
||||||
import { invoicesRouter } from "./routes/invoices.js";
|
import { invoicesRouter } from "./routes/invoices.js";
|
||||||
import { notesRouter } from "./routes/notes.js";
|
import { notesRouter } from "./routes/notes.js";
|
||||||
@@ -66,6 +67,7 @@ app.use("/api/notes", notesRouter);
|
|||||||
app.use("/api/appointments", appointmentsRouter);
|
app.use("/api/appointments", appointmentsRouter);
|
||||||
app.use("/api/prescriptions", prescriptionsRouter);
|
app.use("/api/prescriptions", prescriptionsRouter);
|
||||||
app.use("/api/inventory", inventoryRouter);
|
app.use("/api/inventory", inventoryRouter);
|
||||||
|
app.use("/api/dispenses", dispensesRouter);
|
||||||
app.use("/api/invoices", invoicesRouter);
|
app.use("/api/invoices", invoicesRouter);
|
||||||
app.use("/api/tasks", tasksRouter);
|
app.use("/api/tasks", tasksRouter);
|
||||||
app.use("/api/staff", staffRouter);
|
app.use("/api/staff", staffRouter);
|
||||||
@@ -92,6 +94,7 @@ server.listen(env.PORT, () => {
|
|||||||
console.log(` • appts: /api/appointments`);
|
console.log(` • appts: /api/appointments`);
|
||||||
console.log(` • rx: /api/prescriptions`);
|
console.log(` • rx: /api/prescriptions`);
|
||||||
console.log(` • stock: /api/inventory`);
|
console.log(` • stock: /api/inventory`);
|
||||||
|
console.log(` • dispense: /api/dispenses`);
|
||||||
console.log(` • tasks: /api/tasks`);
|
console.log(` • tasks: /api/tasks`);
|
||||||
console.log(` • staff: /api/staff`);
|
console.log(` • staff: /api/staff`);
|
||||||
console.log(` • activity: /api/activity`);
|
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()
|
.string()
|
||||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD.")
|
.regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD.")
|
||||||
.optional(),
|
.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"),
|
status: z.enum(["active", "completed", "expired"]).default("active"),
|
||||||
duration: z.string().trim().max(120).nullish(),
|
duration: z.string().trim().max(120).nullish(),
|
||||||
notes: z.string().max(5000).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,
|
frequency: row.frequency,
|
||||||
prescriber: row.prescriber,
|
prescriber: row.prescriber,
|
||||||
prescribedAt: row.prescribedAt,
|
prescribedAt: row.prescribedAt,
|
||||||
|
startDate: row.startDate,
|
||||||
|
endDate: row.endDate,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
duration: row.duration,
|
duration: row.duration,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
@@ -45,6 +47,8 @@ function columns(orgId: string, input: PrescriptionInput, createdBy?: string) {
|
|||||||
duration: input.duration ?? null,
|
duration: input.duration ?? null,
|
||||||
notes: input.notes ?? null,
|
notes: input.notes ?? null,
|
||||||
source: input.source,
|
source: input.source,
|
||||||
|
startDate: input.startDate ?? null,
|
||||||
|
endDate: input.endDate ?? null,
|
||||||
// Only set prescribedAt when supplied; otherwise the column default (today).
|
// Only set prescribedAt when supplied; otherwise the column default (today).
|
||||||
...(input.prescribedAt ? { prescribedAt: input.prescribedAt } : {}),
|
...(input.prescribedAt ? { prescribedAt: input.prescribedAt } : {}),
|
||||||
...(createdBy ? { createdBy } : {}),
|
...(createdBy ? { createdBy } : {}),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type ActivityEntityType =
|
|||||||
| "prescription"
|
| "prescription"
|
||||||
| "invoice"
|
| "invoice"
|
||||||
| "inventory"
|
| "inventory"
|
||||||
|
| "dispense"
|
||||||
| "task";
|
| "task";
|
||||||
|
|
||||||
export type ActivityEntry = {
|
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;
|
frequency: string;
|
||||||
prescriber: string;
|
prescriber: string;
|
||||||
prescribedAt: string; // YYYY-MM-DD
|
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;
|
status: PrescriptionStatus;
|
||||||
duration: string | null;
|
duration: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user