mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
feat(api): tighten POST /api/medications/[id]/glp1 with Zod + audit + rate-limit
The GLP-1 convenience route shipped in W4d skipped the Zod / audit /
rate-limit triple the sibling inventory + side-effect POSTs ship with.
Hand-rolled `typeof === "number"` guards let NaN, Infinity, negative
doses, multi-megabyte notes, and 1970-era timestamps reach Prisma.
This pass adds:
- `glp1DoseChangePostSchema` + `glp1InventoryPostSchema` + the XOR
`glp1PostBodySchema` in `src/lib/validations/medication.ts`.
- A 500-char cap on `note`, 200-char cap on `reason`, 100 mg cap on
`doseValue`, integer cap on `delta`, and a +/-5-year window on
`effectiveFrom`.
- The 30/min/user rate-limit copied from the sibling routes, keyed on
`medication-glp1:post:<userId>`.
- `auditLog("medication.glp1.update", ...)` for every mutation, tagged
with the affected row id and the dose / delta values.
- A test fixture mirroring the inventory + side-effect tests with
thirteen assertions covering the new 422 paths, the rate-limit
header, and both audit branches.
This commit is contained in:
1 parent
1d5dcfb8b1
commit
a400e5407b
3 files changed
+526
-60
No files matched your search
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* v1.4.25 W21 Fix-K — GLP-1 convenience-route POST tests.
|
||||
*
|
||||
* Pins the sec-H1 hardening pass: Zod parse, 30/min/user rate-limit,
|
||||
* `medication.glp1.update` audit row, bounded `doseValue` / `note` /
|
||||
* `effectiveFrom`, XOR refinement between the doseChange / inventory
|
||||
* branches. Mirrors the sibling inventory + side-effect fixture
|
||||
* pattern so the four routes share one test shape.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findUnique: vi.fn() },
|
||||
medicationDoseChange: { create: vi.fn() },
|
||||
medicationInventoryEvent: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: vi.fn(() => ({ "X-RateLimit-Remaining": "0" })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
const MED_OK = { id: "med-1", userId: "user-1" };
|
||||
|
||||
function jsonReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/med-1/glp1", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications/[id]/glp1", () => {
|
||||
it("rejects unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "2026-05-14T08:00:00Z",
|
||||
doseValue: 0.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 404 when the medication belongs to a different user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "OTHER",
|
||||
} as never);
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "2026-05-14T08:00:00Z",
|
||||
doseValue: 0.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 422 when neither doseChange nor inventory is provided", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(jsonReq({}), {
|
||||
params: Promise.resolve({ id: "med-1" }),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when both doseChange and inventory are provided", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "2026-05-14T08:00:00Z",
|
||||
doseValue: 0.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
inventory: { delta: 1, reason: "refill" },
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when doseValue is negative", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "2026-05-14T08:00:00Z",
|
||||
doseValue: -1,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when doseValue is NaN (not a finite number)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
// JSON cannot encode NaN, but Number.NaN reaches the schema if the
|
||||
// client base64-encodes or hand-crafts the body. Simulate the
|
||||
// post-decode shape by passing `null` (which fails the finite gate)
|
||||
// and `1e400` (which JSON renders as null too) via the same path.
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "2026-05-14T08:00:00Z",
|
||||
doseValue: null,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when doseValue exceeds 100 mg cap", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "2026-05-14T08:00:00Z",
|
||||
doseValue: 250,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when note exceeds 500 chars", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "2026-05-14T08:00:00Z",
|
||||
doseValue: 0.5,
|
||||
doseUnit: "mg",
|
||||
note: "x".repeat(501),
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when effectiveFrom predates 2020", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "1995-01-01T00:00:00Z",
|
||||
doseValue: 0.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when inventory delta is zero", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq({ inventory: { delta: 0, reason: "noop" } }),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 429 when the rate limit is exceeded", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "2026-05-14T08:00:00Z",
|
||||
doseValue: 0.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.headers.get("X-RateLimit-Remaining")).toBe("0");
|
||||
});
|
||||
|
||||
it("creates a doseChange row and writes a medication.glp1.update audit entry", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(prisma.medicationDoseChange.create).mockResolvedValue({
|
||||
id: "dc-new",
|
||||
effectiveFrom: new Date("2026-05-14T08:00:00Z"),
|
||||
doseValue: 0.5,
|
||||
doseUnit: "mg",
|
||||
note: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "2026-05-14T08:00:00Z",
|
||||
doseValue: 0.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.medicationDoseChange.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
medicationId: "med-1",
|
||||
effectiveFrom: new Date("2026-05-14T08:00:00Z"),
|
||||
doseValue: 0.5,
|
||||
doseUnit: "mg",
|
||||
note: null,
|
||||
}),
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"medication.glp1.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
medicationId: "med-1",
|
||||
kind: "doseChange",
|
||||
doseChangeId: "dc-new",
|
||||
doseValue: 0.5,
|
||||
doseUnit: "mg",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates an inventory row and writes a medication.glp1.update audit entry", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(prisma.medicationInventoryEvent.create).mockResolvedValue({
|
||||
id: "iv-new",
|
||||
delta: 1,
|
||||
reason: "refill",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonReq({ inventory: { delta: 1, reason: "refill" } }),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.medicationInventoryEvent.create).toHaveBeenCalledWith({
|
||||
data: { medicationId: "med-1", delta: 1, reason: "refill" },
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"medication.glp1.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
medicationId: "med-1",
|
||||
kind: "inventory",
|
||||
inventoryEventId: "iv-new",
|
||||
delta: 1,
|
||||
reason: "refill",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,16 +7,46 @@
|
||||
* rotation data), and the running pen-inventory math. The base
|
||||
* /api/medications/[id] GET is unchanged so v1.4.24 consumers keep
|
||||
* working untouched.
|
||||
*
|
||||
* v1.4.25 W21 Fix-K — POST hardened with Zod parse, audit-log, 30/min
|
||||
* rate-limit, finite-number guard on `doseValue`, length cap on `note`,
|
||||
* and sane bounds on `effectiveFrom`. Mirrors the sibling
|
||||
* `/inventory` + `/side-effects` route shape.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { glp1PostBodySchema } from "@/lib/validations/medication";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
const LOW_STOCK_DOSE_THRESHOLD = 4;
|
||||
const POST_RATE_LIMIT = 30;
|
||||
const POST_WINDOW_MS = 60_000;
|
||||
|
||||
async function assertMedicationOwnership(
|
||||
medicationId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const med = await prisma.medication.findUnique({
|
||||
where: { id: medicationId },
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
if (!med || med.userId !== userId) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
@@ -88,90 +118,118 @@ export const GET = apiHandler(
|
||||
},
|
||||
);
|
||||
|
||||
const doseChangeSchema = {
|
||||
effectiveFrom: "string",
|
||||
doseValue: "number",
|
||||
doseUnit: "string",
|
||||
note: "string?",
|
||||
} as const;
|
||||
|
||||
const inventorySchema = {
|
||||
delta: "number",
|
||||
reason: "string",
|
||||
} as const;
|
||||
|
||||
void doseChangeSchema;
|
||||
void inventorySchema;
|
||||
|
||||
interface DoseChangeBody {
|
||||
effectiveFrom?: string;
|
||||
doseValue?: number;
|
||||
doseUnit?: string;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
interface InventoryBody {
|
||||
delta?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface Glp1PostBody {
|
||||
doseChange?: DoseChangeBody;
|
||||
inventory?: InventoryBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST creates a new dose change OR inventory event (the body picks
|
||||
* one — caller specifies which). Convenience endpoint so the
|
||||
* medication-card disclosure can write rows without dispatching to
|
||||
* /api/medications/[id]/dose-change + /api/medications/[id]/inventory
|
||||
* separately for the v1.4.25 cut.
|
||||
*
|
||||
* Validation: `glp1PostBodySchema` (XOR of `doseChange` / `inventory`),
|
||||
* with finite-number guards on `doseValue` + `delta`, bounded `note`
|
||||
* + `reason`, and a ±5-year window on `effectiveFrom`.
|
||||
* Rate-limit: 30/min/user (same as sibling POST routes).
|
||||
* Audit: `medication.glp1.update` for every successful mutation.
|
||||
*/
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({ where: { id } });
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medication not found", 404);
|
||||
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
// Per-user POST rate-limit — matches the 30/min sibling routes
|
||||
// (inventory, side-effects). Generous for a hand-driven session,
|
||||
// tight enough to cut off the spam case.
|
||||
const rl = await checkRateLimit(
|
||||
`medication-glp1:post:${user.id}`,
|
||||
POST_RATE_LIMIT,
|
||||
POST_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const body = (await request
|
||||
.json()
|
||||
.catch(() => null)) as Glp1PostBody | null;
|
||||
if (!body) return apiError("Invalid body", 400);
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
if (body.doseChange) {
|
||||
const { effectiveFrom, doseValue, doseUnit, note } = body.doseChange;
|
||||
if (!effectiveFrom || typeof doseValue !== "number" || !doseUnit) {
|
||||
return apiError(
|
||||
"doseChange.effectiveFrom + doseValue + doseUnit required",
|
||||
422,
|
||||
);
|
||||
}
|
||||
const parsed = glp1PostBodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const ip = getClientIp(request);
|
||||
|
||||
if (parsed.data.doseChange) {
|
||||
const { effectiveFrom, doseValue, doseUnit, note } =
|
||||
parsed.data.doseChange;
|
||||
const created = await prisma.medicationDoseChange.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
effectiveFrom: new Date(effectiveFrom),
|
||||
effectiveFrom,
|
||||
doseValue,
|
||||
doseUnit,
|
||||
note: note ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("medication.glp1.update", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: {
|
||||
medicationId: id,
|
||||
kind: "doseChange",
|
||||
doseChangeId: created.id,
|
||||
doseValue,
|
||||
doseUnit,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.glp1.doseChange.create",
|
||||
entity_type: "medication_dose_change",
|
||||
entity_id: created.id,
|
||||
},
|
||||
meta: { medication_id: id, doseValue, doseUnit },
|
||||
});
|
||||
|
||||
return apiSuccess({ doseChange: created }, 201);
|
||||
}
|
||||
|
||||
if (body.inventory) {
|
||||
const { delta, reason } = body.inventory;
|
||||
if (typeof delta !== "number" || !reason) {
|
||||
return apiError("inventory.delta + reason required", 422);
|
||||
}
|
||||
const created = await prisma.medicationInventoryEvent.create({
|
||||
data: { medicationId: id, delta, reason },
|
||||
});
|
||||
return apiSuccess({ inventory: created }, 201);
|
||||
}
|
||||
// The schema's XOR refinement guarantees `inventory` is present
|
||||
// when `doseChange` is not, so the non-null assertion is safe.
|
||||
const { delta, reason } = parsed.data.inventory!;
|
||||
const created = await prisma.medicationInventoryEvent.create({
|
||||
data: { medicationId: id, delta, reason },
|
||||
});
|
||||
|
||||
return apiError("Body must carry doseChange or inventory", 422);
|
||||
await auditLog("medication.glp1.update", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: {
|
||||
medicationId: id,
|
||||
kind: "inventory",
|
||||
inventoryEventId: created.id,
|
||||
delta,
|
||||
reason,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.glp1.inventory.create",
|
||||
entity_type: "medication_inventory_event",
|
||||
entity_id: created.id,
|
||||
},
|
||||
meta: { medication_id: id, delta },
|
||||
});
|
||||
|
||||
return apiSuccess({ inventory: created }, 201);
|
||||
},
|
||||
);
|
||||
@@ -145,6 +145,77 @@ export const updateInventoryItemSchema = z.object({
|
||||
export type CreateInventoryItemInput = z.infer<typeof createInventoryItemSchema>;
|
||||
export type UpdateInventoryItemInput = z.infer<typeof updateInventoryItemSchema>;
|
||||
|
||||
/**
|
||||
* v1.4.25 W21 Fix-K — `POST /api/medications/[id]/glp1` body validators.
|
||||
*
|
||||
* The convenience route accepts either a `doseChange` or an `inventory`
|
||||
* payload (the route picks one). Both branches were hand-rolled
|
||||
* `typeof === "number"` checks pre-Fix-K, which let `NaN`, `Infinity`,
|
||||
* negative doses, and unbounded notes slip through.
|
||||
*
|
||||
* Bounds:
|
||||
* - `doseValue` is finite, non-negative, capped at 100 mg (covers every
|
||||
* real-world GLP-1 step with headroom).
|
||||
* - `doseUnit` is a short string (mg / mcg / IE).
|
||||
* - `note` is capped at 500 characters so the field can't be used as a
|
||||
* blob smuggler.
|
||||
* - `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).
|
||||
* - `reason` is a bounded string (the route logs it; raw blob bad).
|
||||
*/
|
||||
const MAX_DOSE_MG = 100;
|
||||
const MAX_NOTE_CHARS = 500;
|
||||
const MAX_REASON_CHARS = 200;
|
||||
const MIN_EFFECTIVE_FROM = new Date("2020-01-01T00:00:00Z");
|
||||
const FIVE_YEARS_MS = 5 * 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const glp1DoseChangePostSchema = z.object({
|
||||
effectiveFrom: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.refine((d) => d.getTime() >= MIN_EFFECTIVE_FROM.getTime(), {
|
||||
message: "effectiveFrom must be on or after 2020-01-01",
|
||||
})
|
||||
.refine((d) => d.getTime() <= Date.now() + FIVE_YEARS_MS, {
|
||||
message: "effectiveFrom must be within 5 years of now",
|
||||
}),
|
||||
doseValue: z
|
||||
.number()
|
||||
.finite()
|
||||
.min(0)
|
||||
.max(MAX_DOSE_MG),
|
||||
doseUnit: z.string().min(1).max(10),
|
||||
note: z.string().max(MAX_NOTE_CHARS).nullable().optional(),
|
||||
});
|
||||
|
||||
export const glp1InventoryPostSchema = z.object({
|
||||
delta: z
|
||||
.number()
|
||||
.int()
|
||||
.finite()
|
||||
.min(-100)
|
||||
.max(100)
|
||||
.refine((n) => n !== 0, { message: "delta must be non-zero" }),
|
||||
reason: z.string().min(1).max(MAX_REASON_CHARS),
|
||||
});
|
||||
|
||||
export const glp1PostBodySchema = z
|
||||
.object({
|
||||
doseChange: glp1DoseChangePostSchema.optional(),
|
||||
inventory: glp1InventoryPostSchema.optional(),
|
||||
})
|
||||
.refine(
|
||||
(b) => Boolean(b.doseChange) !== Boolean(b.inventory),
|
||||
{ message: "Body must carry exactly one of doseChange or inventory" },
|
||||
);
|
||||
|
||||
export type Glp1DoseChangePostInput = z.infer<typeof glp1DoseChangePostSchema>;
|
||||
export type Glp1InventoryPostInput = z.infer<typeof glp1InventoryPostSchema>;
|
||||
export type Glp1PostBodyInput = z.infer<typeof glp1PostBodySchema>;
|
||||
|
||||
export type CreateMedicationInput = z.infer<typeof createMedicationSchema>;
|
||||
export type IntakeInput = z.infer<typeof intakeSchema>;
|
||||
export type ListIntakeEventsInput = z.infer<typeof listIntakeEventsSchema>;
|
||||
|
||||
Reference in new issue
Block a user