mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
fix(medications): every taken dose moves the stock, in units that match the product
Stock only decremented on the web card's take button — the status toggle, the phone sync, the Telegram buttons and the token API all recorded the dose and left the inventory untouched, which is why a fresh pack kept reading full. Consumption now stamps the intake row itself: every path that turns a row taken consumes through one module — open container first, then earliest expiry with auto-open, spilling into the next pack and flooring at zero — and every path that takes it back (undo, delete, a take turned skip, a converged duplicate) refunds exactly what the stamp recorded. Replays and pending sync echoes can never double-charge, and the nightly convergence pass refunds the rows it tombstones. A medication can declare units per dose, so two 2 mg tablets book as one 4 mg dose and stock reads in doses with the unit count alongside; containers carry a named type including tablet packs, hold up to a thousand units for inhalers, and the GLP-1 card reads the same stock as the inventory tab instead of a separate ledger. The inventory endpoints join the documented API.
This commit is contained in:
1 parent
dacdcccfd2
commit
7d3557a3fd
53 files changed
+4118
-641
No files matched your search
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* v1.16.10 — inventory + units-per-dose validation bounds.
|
||||
*
|
||||
* Pins the raised unit caps (100 → 1000 on container capacity and the
|
||||
* stock correction; `dosesPerUnit` rides along) and the new
|
||||
* `unitsPerDose` (1–100) and `containerType` fields. The GLP-1 legacy
|
||||
* ledger delta deliberately KEEPS its ±100 bound — it counts pens, not
|
||||
* units.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createInventoryItemSchema,
|
||||
updateInventoryItemSchema,
|
||||
createMedicationSchema,
|
||||
updateMedicationSchema,
|
||||
glp1InventoryPostSchema,
|
||||
} from "../medication";
|
||||
|
||||
const MINIMAL_MEDICATION = {
|
||||
name: "Med",
|
||||
dose: "5 mg",
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("createInventoryItemSchema — unit cap 1000", () => {
|
||||
it("accepts 1000 units", () => {
|
||||
const r = createInventoryItemSchema.safeParse({ dosesTotal: 1000 });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects 1001 units", () => {
|
||||
const r = createInventoryItemSchema.safeParse({ dosesTotal: 1001 });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects zero", () => {
|
||||
const r = createInventoryItemSchema.safeParse({ dosesTotal: 0 });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts every container type and defaults to absent", () => {
|
||||
for (const ct of [
|
||||
"PEN",
|
||||
"AMPOULE",
|
||||
"BLISTER",
|
||||
"INHALER",
|
||||
"BOTTLE",
|
||||
"OTHER",
|
||||
]) {
|
||||
const r = createInventoryItemSchema.safeParse({
|
||||
dosesTotal: 4,
|
||||
containerType: ct,
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
}
|
||||
const r = createInventoryItemSchema.safeParse({ dosesTotal: 4 });
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.success && r.data.containerType).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects an unknown container type", () => {
|
||||
const r = createInventoryItemSchema.safeParse({
|
||||
dosesTotal: 4,
|
||||
containerType: "BAG",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateInventoryItemSchema — stock correction cap 1000", () => {
|
||||
it("accepts 1000", () => {
|
||||
const r = updateInventoryItemSchema.safeParse({ dosesRemaining: 1000 });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects 1001", () => {
|
||||
const r = updateInventoryItemSchema.safeParse({ dosesRemaining: 1001 });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("medication schemas — dosesPerUnit cap 1000 + unitsPerDose 1–100", () => {
|
||||
it("create accepts dosesPerUnit 1000 and rejects 1001", () => {
|
||||
expect(
|
||||
createMedicationSchema.safeParse({
|
||||
...MINIMAL_MEDICATION,
|
||||
dosesPerUnit: 1000,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
createMedicationSchema.safeParse({
|
||||
...MINIMAL_MEDICATION,
|
||||
dosesPerUnit: 1001,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("update accepts dosesPerUnit 1000 and rejects 1001", () => {
|
||||
expect(updateMedicationSchema.safeParse({ dosesPerUnit: 1000 }).success).toBe(
|
||||
true,
|
||||
);
|
||||
expect(updateMedicationSchema.safeParse({ dosesPerUnit: 1001 }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("create bounds unitsPerDose to 1–100", () => {
|
||||
expect(
|
||||
createMedicationSchema.safeParse({
|
||||
...MINIMAL_MEDICATION,
|
||||
unitsPerDose: 1,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
createMedicationSchema.safeParse({
|
||||
...MINIMAL_MEDICATION,
|
||||
unitsPerDose: 100,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
createMedicationSchema.safeParse({
|
||||
...MINIMAL_MEDICATION,
|
||||
unitsPerDose: 0,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
createMedicationSchema.safeParse({
|
||||
...MINIMAL_MEDICATION,
|
||||
unitsPerDose: 101,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
createMedicationSchema.safeParse({
|
||||
...MINIMAL_MEDICATION,
|
||||
unitsPerDose: 1.5,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("update bounds unitsPerDose to 1–100", () => {
|
||||
expect(updateMedicationSchema.safeParse({ unitsPerDose: 2 }).success).toBe(
|
||||
true,
|
||||
);
|
||||
expect(updateMedicationSchema.safeParse({ unitsPerDose: 0 }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
updateMedicationSchema.safeParse({ unitsPerDose: 101 }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("glp1InventoryPostSchema — the legacy pen-ledger delta keeps ±100", () => {
|
||||
it("accepts ±100 and rejects ±101", () => {
|
||||
expect(glp1InventoryPostSchema.safeParse({ delta: 100, reason: "purchased" }).success).toBe(true);
|
||||
expect(glp1InventoryPostSchema.safeParse({ delta: -100, reason: "damaged" }).success).toBe(true);
|
||||
expect(glp1InventoryPostSchema.safeParse({ delta: 101, reason: "purchased" }).success).toBe(false);
|
||||
expect(glp1InventoryPostSchema.safeParse({ delta: -101, reason: "damaged" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -456,8 +456,23 @@ export const createMedicationSchema = z
|
||||
category: z.enum(MEDICATION_CATEGORY_VALUES).optional(),
|
||||
/** v1.4.25 W4d — treatment-class discriminator (GENERIC | GLP1). */
|
||||
treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(),
|
||||
/** v1.4.25 W4d — doses per pen/vial for inventory tracking. */
|
||||
dosesPerUnit: z.number().int().min(1).max(100).optional(),
|
||||
/** v1.4.25 W4d — doses per pen/vial for inventory tracking.
|
||||
* v1.16.10 raises the cap to 1000 (large tablet packs). */
|
||||
dosesPerUnit: z.number().int().min(1).max(1000).optional(),
|
||||
/**
|
||||
* v1.16.10 — inventory units one dose consumes (2 × 2 mg tablets
|
||||
* for a 4 mg dose). Default 1. Dose-level readouts divide the
|
||||
* unit counts by this factor.
|
||||
*/
|
||||
unitsPerDose: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe(
|
||||
"Inventory units consumed per dose (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.",
|
||||
),
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(),
|
||||
/**
|
||||
@@ -528,7 +543,17 @@ export const updateMedicationSchema = z
|
||||
dose: z.string().min(1).max(50).optional(),
|
||||
category: z.enum(MEDICATION_CATEGORY_VALUES).optional(),
|
||||
treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(),
|
||||
dosesPerUnit: z.number().int().min(1).max(100).nullable().optional(),
|
||||
dosesPerUnit: z.number().int().min(1).max(1000).nullable().optional(),
|
||||
/** v1.16.10 — inventory units one dose consumes. Default 1. */
|
||||
unitsPerDose: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe(
|
||||
"Inventory units consumed per dose (e.g. 2 tablets of 2 mg for a 4 mg dose). The intake consumption hook decrements this many units per taken dose; already-stamped intake events keep their recorded consumption.",
|
||||
),
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(),
|
||||
/** v1.8.5 — per-medication injection-site tracking opt-in. */
|
||||
@@ -799,41 +824,103 @@ export type BulkDeleteIntakeEventsInput = z.infer<
|
||||
* USED_UP (manual override). EXPIRED is owned by the daily cron and
|
||||
* the intake hook, so the PATCH schema deliberately omits it.
|
||||
*/
|
||||
export const createInventoryItemSchema = z.object({
|
||||
dosesTotal: z.number().int().min(1).max(100),
|
||||
printedExpiry: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional(),
|
||||
purchasedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional(),
|
||||
notes: z.string().max(200).nullable().optional(),
|
||||
});
|
||||
/**
|
||||
* v1.16.10 — container kinds, mirrored from the Prisma
|
||||
* `MedicationContainerType` enum. Display-level classification only.
|
||||
*/
|
||||
export const MEDICATION_CONTAINER_TYPE_VALUES = [
|
||||
"PEN",
|
||||
"AMPOULE",
|
||||
"BLISTER",
|
||||
"INHALER",
|
||||
"BOTTLE",
|
||||
"OTHER",
|
||||
] as const;
|
||||
export type MedicationContainerTypeValue =
|
||||
(typeof MEDICATION_CONTAINER_TYPE_VALUES)[number];
|
||||
|
||||
export const updateInventoryItemSchema = z.object({
|
||||
markAsFirstUseAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
markAsUsedUp: z.boolean().optional(),
|
||||
printedExpiry: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional(),
|
||||
/**
|
||||
* v1.16.1 — stock correction (the Bestand tab's adjust / withdraw
|
||||
* flow). Sets the remaining-dose count directly; the route clamps to
|
||||
* `dosesTotal` and re-runs the canonical state machine (0 ⇒ USED_UP,
|
||||
* a raise out of 0 re-evaluates against the expiry clocks).
|
||||
*/
|
||||
dosesRemaining: z.number().int().min(0).max(100).optional(),
|
||||
notes: z.string().max(200).nullable().optional(),
|
||||
});
|
||||
export const createInventoryItemSchema = z
|
||||
.object({
|
||||
/** Units the container ships with. v1.16.10 raises the cap from
|
||||
* 100 to 1000 (large tablet packs); the wire field name stays
|
||||
* `dosesTotal` — it counts units, mapped to doses via
|
||||
* `Medication.unitsPerDose`. */
|
||||
dosesTotal: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(1000)
|
||||
.describe(
|
||||
"Units the container ships with (tablets / ampoules / puffs; 1–1000). Stored as `unitsTotal`; dose-derived readouts divide by the medication's `unitsPerDose`.",
|
||||
),
|
||||
/** v1.16.10 — container kind. Defaults to OTHER when absent. */
|
||||
containerType: z
|
||||
.enum(MEDICATION_CONTAINER_TYPE_VALUES)
|
||||
.optional()
|
||||
.describe(
|
||||
"Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level only; defaults to OTHER.",
|
||||
),
|
||||
printedExpiry: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional(),
|
||||
purchasedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional(),
|
||||
notes: z.string().max(200).nullable().optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "CreateMedicationInventoryItemRequest",
|
||||
description:
|
||||
"Register a new supply container (pen / blister pack / bottle). `dosesTotal` counts UNITS (1–1000); the item starts ACTIVE with `unitsRemaining = unitsTotal` and the intake consumption hook decrements it per taken dose.",
|
||||
});
|
||||
|
||||
export const updateInventoryItemSchema = z
|
||||
.object({
|
||||
markAsFirstUseAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.optional()
|
||||
.describe(
|
||||
"Manually start the 30-day in-use clock (the user opened the container without logging an intake). ACTIVE flips to IN_USE; a backdated instant whose window already lapsed lands EXPIRED.",
|
||||
),
|
||||
markAsUsedUp: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Terminal override: zero the remaining units and mark the container USED_UP (physically discarded).",
|
||||
),
|
||||
printedExpiry: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional(),
|
||||
/**
|
||||
* v1.16.1 — stock correction (the Bestand tab's adjust / withdraw
|
||||
* flow). Sets the remaining-unit count directly; the route clamps to
|
||||
* `unitsTotal` and re-runs the canonical state machine (0 ⇒ USED_UP,
|
||||
* a raise out of 0 re-evaluates against the expiry clocks).
|
||||
* v1.16.10 raises the cap to 1000 alongside the capacity cap.
|
||||
*/
|
||||
dosesRemaining: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(1000)
|
||||
.optional()
|
||||
.describe(
|
||||
"Absolute remaining-unit correction (0–1000). Clamped server-side to the item's `unitsTotal`; the canonical state machine re-derives the state (0 ⇒ USED_UP).",
|
||||
),
|
||||
notes: z.string().max(200).nullable().optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "UpdateMedicationInventoryItemRequest",
|
||||
description:
|
||||
"Per-item inventory mutation: manual first-use, used-up override, printed-expiry correction, absolute remaining-unit correction, notes. Every field is optional and commutative.",
|
||||
});
|
||||
|
||||
export type CreateInventoryItemInput = z.infer<
|
||||
typeof createInventoryItemSchema
|
||||
@@ -859,8 +946,9 @@ export type UpdateInventoryItemInput = z.infer<
|
||||
* - `effectiveFrom` is constrained to a ±5-year window around now —
|
||||
* a paper-record back-fill or a planned future step both fit, but
|
||||
* "1970" / "9999" do not.
|
||||
* - `delta` is a non-zero finite integer in [−100, 100] (the existing
|
||||
* inventory route caps `dosesTotal` at 100 doses per pen).
|
||||
* - `delta` is a non-zero finite integer in [−100, 100] — the legacy
|
||||
* ledger counts pens, and ±100 pens per correction stays plenty
|
||||
* (deliberately NOT raised with the v1.16.10 per-item unit cap).
|
||||
* - `reason` is a bounded string (the route logs it; raw blob bad).
|
||||
*/
|
||||
const MAX_DOSE_MG = 100;
|
||||
|
||||
Reference in new issue
Block a user