backend: org-scoped pharmacy inventory model

Add an `inventory` resource mirroring the prescriptions feature end-to-end:
Drizzle table (org-scoped, indexed), domain type, zod validation, service
(list/get/create/update/delete), and an RBAC-gated CRUD router mounted at
/api/inventory. Grant the new `inventory` statement to roles — pharmacy gets
read/write, full clinicians read/write/delete, reception/lab none — in both the
backend access control and (mirrored) the frontend. Record writes in the
activity log via a new `inventory` entity type. Includes the migration and an
idempotent demo seed script.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-12 19:16:28 +03:00
parent 91e4f4ed30
commit 67fdf15de4
13 changed files with 3061 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
CREATE TABLE "inventory" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" text NOT NULL,
"name" text NOT NULL,
"form" text DEFAULT '' NOT NULL,
"strength" text DEFAULT '' NOT NULL,
"unit" text DEFAULT '' NOT NULL,
"stock_quantity" integer DEFAULT 0 NOT NULL,
"reorder_threshold" integer DEFAULT 0 NOT NULL,
"location" text DEFAULT '' NOT NULL,
"expires_at" date,
"notes" text,
"created_by" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "inventory" ADD CONSTRAINT "inventory_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "inventory" ADD CONSTRAINT "inventory_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "inventory_org_idx" ON "inventory" USING btree ("organization_id");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -85,6 +85,13 @@
"when": 1781196252617,
"tag": "0011_remap_viewer_to_member",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1781279151289,
"tag": "0012_wonderful_terrax",
"breakpoints": true
}
]
}
+1
View File
@@ -3,6 +3,7 @@ export * from "./patients.js";
export * from "./notes.js";
export * from "./appointments.js";
export * from "./prescriptions.js";
export * from "./inventory.js";
export * from "./tasks.js";
export * from "./activity.js";
export * from "./messaging.js";
+44
View File
@@ -0,0 +1,44 @@
import {
date,
index,
integer,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import { organization, user } from "./auth.js";
// One row per medication held in a clinic's pharmacy stock, scoped to a clinic
// (organization). Unlike prescriptions (dispensed courses), this is the standing
// inventory the pharmacy searches to check availability. Availability
// (in-stock / low / out) is derived from `stockQuantity` vs `reorderThreshold`
// at read time, not stored.
export const inventory = pgTable(
"inventory",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
name: text("name").notNull(),
form: text("form").notNull().default(""),
strength: text("strength").notNull().default(""),
unit: text("unit").notNull().default(""),
stockQuantity: integer("stock_quantity").notNull().default(0),
reorderThreshold: integer("reorder_threshold").notNull().default(0),
location: text("location").notNull().default(""),
expiresAt: date("expires_at"),
notes: text("notes"),
createdBy: text("created_by").references(() => user.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(t) => [index("inventory_org_idx").on(t.organizationId)],
);
+3
View File
@@ -12,6 +12,7 @@ import { activityRouter } from "./routes/activity.js";
import { analyticsRouter } from "./routes/analytics.js";
import { appointmentsRouter } from "./routes/appointments.js";
import { conversationsRouter } from "./routes/conversations.js";
import { inventoryRouter } from "./routes/inventory.js";
import { notesRouter } from "./routes/notes.js";
import { notificationsRouter } from "./routes/notifications.js";
import { patientsRouter } from "./routes/patients.js";
@@ -59,6 +60,7 @@ app.use("/api/patients", patientsRouter);
app.use("/api/notes", notesRouter);
app.use("/api/appointments", appointmentsRouter);
app.use("/api/prescriptions", prescriptionsRouter);
app.use("/api/inventory", inventoryRouter);
app.use("/api/tasks", tasksRouter);
app.use("/api/staff", staffRouter);
app.use("/api/activity", activityRouter);
@@ -81,6 +83,7 @@ server.listen(env.PORT, () => {
console.log(` • notes: /api/notes`);
console.log(` • appts: /api/appointments`);
console.log(` • rx: /api/prescriptions`);
console.log(` • stock: /api/inventory`);
console.log(` • tasks: /api/tasks`);
console.log(` • staff: /api/staff`);
console.log(` • activity: /api/activity`);
+6
View File
@@ -17,6 +17,7 @@ export const statements = {
patient: ["read", "write", "delete"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
inventory: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
} as const;
@@ -39,6 +40,7 @@ export const owner = ac.newRole({
patient: ["read", "write", "delete"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
inventory: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
});
@@ -48,6 +50,7 @@ export const admin = ac.newRole({
patient: ["read", "write", "delete"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
inventory: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
});
@@ -58,6 +61,7 @@ export const member = ac.newRole({
patient: ["read", "write"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
inventory: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
});
@@ -70,6 +74,7 @@ export const doctor = ac.newRole({
patient: ["read", "write"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
inventory: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
});
@@ -96,6 +101,7 @@ export const pharmacy = ac.newRole({
patient: ["read"],
appointment: ["read"],
prescription: ["read", "write"],
inventory: ["read", "write"],
task: ["read", "write"],
});
+23
View File
@@ -0,0 +1,23 @@
import { z } from "zod";
const nonEmpty = z.string().trim().min(1);
// Payload accepted by POST/PUT /api/inventory. Only the medication name is
// required; quantities default to 0 and the descriptive fields to empty strings
// so a quick add (name only) is valid.
export const inventoryInputSchema = z.object({
name: nonEmpty.max(200),
form: z.string().trim().max(120).default(""),
strength: z.string().trim().max(120).default(""),
unit: z.string().trim().max(60).default(""),
stockQuantity: z.number().int().min(0).max(1_000_000).default(0),
reorderThreshold: z.number().int().min(0).max(1_000_000).default(0),
location: z.string().trim().max(200).default(""),
expiresAt: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD.")
.nullish(),
notes: z.string().max(5000).nullish(),
});
export type InventoryInput = z.infer<typeof inventoryInputSchema>;
+102
View File
@@ -0,0 +1,102 @@
import { Router } from "express";
import { HttpError } from "../lib/http-error.js";
import { inventoryInputSchema } from "../lib/inventory-validation.js";
import {
requireAuth,
requireOrg,
requirePermission,
} from "../middleware/auth.js";
import { recordActivity } from "../services/activity.js";
import * as service from "../services/inventory.js";
export const inventoryRouter = Router();
inventoryRouter.use(requireAuth, requireOrg);
inventoryRouter.get(
"/",
requirePermission({ inventory: ["read"] }),
async (req, res, next) => {
try {
res.json(await service.listInventory(req.organizationId!));
} catch (err) {
next(err);
}
},
);
inventoryRouter.post(
"/",
requirePermission({ inventory: ["write"] }),
async (req, res, next) => {
try {
const input = inventoryInputSchema.parse(req.body);
const created = await service.createInventory(
req.organizationId!,
req.user!.id,
input,
);
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `Added ${created.name} to inventory`,
entityType: "inventory",
entityId: created.id,
});
res.status(201).json(created);
} catch (err) {
next(err);
}
},
);
inventoryRouter.put(
"/:id",
requirePermission({ inventory: ["write"] }),
async (req, res, next) => {
try {
const input = inventoryInputSchema.parse(req.body);
const updated = await service.updateInventory(
req.organizationId!,
req.params.id as string,
input,
);
if (!updated) throw new HttpError(404, "Inventory item not found.");
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `Updated inventory — ${updated.name}`,
entityType: "inventory",
entityId: updated.id,
});
res.json(updated);
} catch (err) {
next(err);
}
},
);
inventoryRouter.delete(
"/:id",
requirePermission({ inventory: ["delete"] }),
async (req, res, next) => {
try {
const ok = await service.deleteInventory(
req.organizationId!,
req.params.id as string,
);
if (!ok) throw new HttpError(404, "Inventory item not found.");
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: "Deleted inventory item",
entityType: "inventory",
entityId: req.params.id as string,
});
res.status(204).end();
} catch (err) {
next(err);
}
},
);
+63
View File
@@ -0,0 +1,63 @@
// Demo-only: populate each clinic that has no inventory yet with a starter set
// of common pharmacy stock so the Inventory page isn't empty on first view.
// Idempotent — skips any organization that already has inventory rows.
//
// Run with: npx tsx src/seed-inventory.ts
import { eq, sql } from "drizzle-orm";
import { db } from "./db/index.js";
import { organization } from "./db/schema/auth.js";
import { inventory } from "./db/schema/inventory.js";
type Seed = {
name: string;
form: string;
strength: string;
unit: string;
stockQuantity: number;
reorderThreshold: number;
location: string;
};
// A representative, deliberately varied set (in-stock / low / out) so the
// availability badges are all visible in the demo.
const DEMO_STOCK: Seed[] = [
{ name: "Paracetamol", form: "Tablet", strength: "500 mg", unit: "tablets", stockQuantity: 1200, reorderThreshold: 200, location: "A1" },
{ name: "Ibuprofen", form: "Tablet", strength: "400 mg", unit: "tablets", stockQuantity: 640, reorderThreshold: 150, location: "A2" },
{ name: "Amoxicillin", form: "Capsule", strength: "500 mg", unit: "capsules", stockQuantity: 90, reorderThreshold: 100, location: "B1" },
{ name: "Azithromycin", form: "Tablet", strength: "250 mg", unit: "tablets", stockQuantity: 0, reorderThreshold: 60, location: "B2" },
{ name: "Amlodipine", form: "Tablet", strength: "5 mg", unit: "tablets", stockQuantity: 480, reorderThreshold: 120, location: "C1" },
{ name: "Lisinopril", form: "Tablet", strength: "10 mg", unit: "tablets", stockQuantity: 75, reorderThreshold: 100, location: "C2" },
{ name: "Metformin", form: "Tablet", strength: "850 mg", unit: "tablets", stockQuantity: 900, reorderThreshold: 200, location: "C3" },
{ name: "Atorvastatin", form: "Tablet", strength: "20 mg", unit: "tablets", stockQuantity: 320, reorderThreshold: 100, location: "C4" },
{ name: "Omeprazole", form: "Capsule", strength: "20 mg", unit: "capsules", stockQuantity: 540, reorderThreshold: 150, location: "D1" },
{ name: "Salbutamol", form: "Inhaler", strength: "100 mcg", unit: "inhalers", stockQuantity: 38, reorderThreshold: 40, location: "D2" },
{ name: "Ceftriaxone", form: "Injection", strength: "1 g", unit: "vials", stockQuantity: 0, reorderThreshold: 25, location: "E1" },
{ name: "Insulin Glargine", form: "Injection", strength: "100 U/mL", unit: "pens", stockQuantity: 60, reorderThreshold: 30, location: "Fridge 1" },
{ name: "Prednisolone", form: "Tablet", strength: "5 mg", unit: "tablets", stockQuantity: 210, reorderThreshold: 80, location: "F1" },
{ name: "Furosemide", form: "Tablet", strength: "40 mg", unit: "tablets", stockQuantity: 18, reorderThreshold: 60, location: "F2" },
{ name: "Warfarin", form: "Tablet", strength: "5 mg", unit: "tablets", stockQuantity: 140, reorderThreshold: 50, location: "G1" },
];
async function main() {
const orgs = await db.select({ id: organization.id }).from(organization);
let seeded = 0;
for (const org of orgs) {
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(inventory)
.where(eq(inventory.organizationId, org.id));
if (count > 0) continue;
await db
.insert(inventory)
.values(DEMO_STOCK.map((s) => ({ ...s, organizationId: org.id })));
seeded += 1;
}
console.log(`Seeded demo inventory for ${seeded} clinic(s).`);
process.exit(0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+92
View File
@@ -0,0 +1,92 @@
import { and, asc, eq } from "drizzle-orm";
import { db } from "../db/index.js";
import { inventory } from "../db/schema/inventory.js";
import type { InventoryInput } from "../lib/inventory-validation.js";
import type { InventoryItem } from "../types/inventory.js";
type InventoryRow = typeof inventory.$inferSelect;
// Postgres throws on a malformed uuid; treat non-uuid ids as "not found".
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function toInventoryItem(row: InventoryRow): InventoryItem {
return {
id: row.id,
name: row.name,
form: row.form,
strength: row.strength,
unit: row.unit,
stockQuantity: row.stockQuantity,
reorderThreshold: row.reorderThreshold,
location: row.location,
expiresAt: row.expiresAt,
notes: row.notes,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
function columns(orgId: string, input: InventoryInput, createdBy?: string) {
return {
organizationId: orgId,
name: input.name,
form: input.form,
strength: input.strength,
unit: input.unit,
stockQuantity: input.stockQuantity,
reorderThreshold: input.reorderThreshold,
location: input.location,
expiresAt: input.expiresAt ?? null,
notes: input.notes ?? null,
...(createdBy ? { createdBy } : {}),
};
}
export async function listInventory(orgId: string): Promise<InventoryItem[]> {
const rows = await db
.select()
.from(inventory)
.where(eq(inventory.organizationId, orgId))
.orderBy(asc(inventory.name));
return rows.map(toInventoryItem);
}
export async function createInventory(
orgId: string,
userId: string,
input: InventoryInput,
): Promise<InventoryItem> {
const [row] = await db
.insert(inventory)
.values(columns(orgId, input, userId))
.returning();
return toInventoryItem(row!);
}
export async function updateInventory(
orgId: string,
id: string,
input: InventoryInput,
): Promise<InventoryItem | null> {
if (!UUID_RE.test(id)) return null;
const [row] = await db
.update(inventory)
.set(columns(orgId, input))
.where(and(eq(inventory.id, id), eq(inventory.organizationId, orgId)))
.returning();
return row ? toInventoryItem(row) : null;
}
export async function deleteInventory(
orgId: string,
id: string,
): Promise<boolean> {
if (!UUID_RE.test(id)) return false;
const deleted = await db
.delete(inventory)
.where(and(eq(inventory.id, id), eq(inventory.organizationId, orgId)))
.returning({ id: inventory.id });
return deleted.length > 0;
}
+1
View File
@@ -6,6 +6,7 @@ export type ActivityEntityType =
| "note"
| "appointment"
| "prescription"
| "inventory"
| "task";
export type ActivityEntry = {
+18
View File
@@ -0,0 +1,18 @@
// The canonical InventoryItem shape returned by the API. Mirrors the frontend
// `lib/inventory.ts` InventoryItem type. Scoped to the active clinic. The
// derived availability ("in-stock" | "low" | "out") is computed on the client
// from stockQuantity vs reorderThreshold, not stored.
export type InventoryItem = {
id: string;
name: string;
form: string;
strength: string;
unit: string;
stockQuantity: number;
reorderThreshold: number;
location: string;
expiresAt: string | null; // YYYY-MM-DD
notes: string | null;
createdAt: string;
updatedAt: string;
};