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