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
+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>;