mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
refactor(api): adopt assertMedicationOwnership + rate-limit option-bag across 9 medication routes
Hoist the ownership-check boilerplate that nine medication sub-routes
were each carrying inline into the shared route-guard helper, and align
the three W19 routes that hand-wrote the rate-limit header loop on the
new apiError option-bag form.
Routes touched:
- titration: shared guard + replaced parseDoseString with the shared
parseDoseMgOrNull + replaced the inline GLP1_DRUGS reverse-lookup
with findDrugIdByBrand.
- side-effects: shared guard + option-bag headers + dropped the
client-supplied 'category' field per code-M6 (server derives via
categoryForEntry).
- inventory: shared guard + option-bag headers.
- cadence: shared guard layered on top of the W21 Fix-O timezone wiring.
- intake (POST + GET): shared guard.
- compliance: shared guard.
- phase-config (GET + PUT + DELETE): shared guard.
- api-endpoint (GET + PUT): shared guard.
- glp1: shared guard replacing Fix-K's inline copy + option-bag headers.
apiError now accepts { headers: Record<string,string> } in its third
argument bag; the headers flow through NextResponse and are stripped
from the meta envelope so the existing { errorCode, ... } meta usage
keeps its shape.
This commit is contained in:
1 parent
2b712429c0
commit
8399c47874
12 files changed
+122
-139
No files matched your search
@@ -6,6 +6,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -22,10 +23,9 @@ export const GET = apiHandler(
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({ where: { id } });
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const scope = medicationScope(id);
|
||||
const now = new Date();
|
||||
@@ -63,8 +63,12 @@ export const PUT = apiHandler(
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const medication = await prisma.medication.findUnique({ where: { id } });
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
if (!medication) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
computeNextDose,
|
||||
} from "@/lib/medications/scheduling/cadence";
|
||||
import { complianceChips } from "@/lib/medications/scheduling/compliance";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -39,11 +40,15 @@ export const GET = apiHandler(
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const med = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: { schedules: true },
|
||||
});
|
||||
if (!med || med.userId !== user.id) {
|
||||
if (!med) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
classifyIntakeTiming,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import type { DailyComplianceEntry } from "@/lib/analytics/compliance";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -15,12 +16,16 @@ export const GET = apiHandler(
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: { schedules: true },
|
||||
});
|
||||
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
if (!medication) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { glp1PostBodySchema } from "@/lib/validations/medication";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -34,20 +35,6 @@ 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) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -148,11 +135,9 @@ export const POST = apiHandler(
|
||||
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;
|
||||
return apiError("Too many requests", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/lib/validations/medication";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { consumeOneDose } from "@/lib/medications/inventory/service";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -26,10 +27,9 @@ async function postIntake(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);
|
||||
}
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
@@ -162,10 +162,9 @@ export const GET = apiHandler(
|
||||
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);
|
||||
}
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const searchParams = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = listIntakeEventsSchema.safeParse(searchParams);
|
||||
|
||||
@@ -28,26 +28,13 @@ import {
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { createInventoryItemSchema } from "@/lib/validations/medication";
|
||||
import { buildCreateInventoryInput } from "@/lib/medications/inventory/service";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
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) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -86,11 +73,9 @@ export const POST = apiHandler(
|
||||
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;
|
||||
return apiError("Too many requests", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { phaseConfigSchema } from "@/lib/validations/phase-config";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -12,13 +13,9 @@ export const GET = apiHandler(
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const config = await prisma.reminderPhaseConfig.findUnique({
|
||||
where: { medicationId: id },
|
||||
@@ -53,13 +50,9 @@ export const PUT = apiHandler(
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
@@ -95,13 +88,9 @@ export const DELETE = apiHandler(
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
await prisma.reminderPhaseConfig.deleteMany({
|
||||
where: { medicationId: id },
|
||||
|
||||
@@ -182,9 +182,20 @@ describe("POST /api/medications/[id]/side-effects", () => {
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when entry / category mismatch", async () => {
|
||||
it("derives category server-side and ignores any client-supplied category (v1.4.25 W21 Fix-N / code-M6)", async () => {
|
||||
// Client maliciously sends category=INJECTION_SITE for a
|
||||
// NAUSEA entry. The server now derives the canonical category
|
||||
// from `entry`, so the row lands with category="GI" regardless
|
||||
// of the client claim. Backwards-compatible with older clients
|
||||
// that still send `category`.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(prisma.medicationSideEffect.create).mockResolvedValue({
|
||||
id: "se-new",
|
||||
entry: "NAUSEA",
|
||||
category: "GI",
|
||||
severity: 2,
|
||||
} as never);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/side-effects", {
|
||||
category: "INJECTION_SITE",
|
||||
@@ -193,7 +204,10 @@ describe("POST /api/medications/[id]/side-effects", () => {
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.status).toBe(201);
|
||||
const callArgs = vi.mocked(prisma.medicationSideEffect.create).mock
|
||||
.calls[0][0] as { data: { category: string } };
|
||||
expect(callArgs.data.category).toBe("GI");
|
||||
});
|
||||
|
||||
it("creates a row with the server-derived category", async () => {
|
||||
|
||||
@@ -34,26 +34,13 @@ import {
|
||||
listSideEffectsSchema,
|
||||
} from "@/lib/medications/side-effects/validators";
|
||||
import { categoryForEntry } from "@/lib/medications/side-effects/taxonomy";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
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) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -117,11 +104,9 @@ export const POST = apiHandler(
|
||||
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;
|
||||
return apiError("Too many requests", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
@@ -132,24 +117,20 @@ export const POST = apiHandler(
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { category, entry, severity, occurredAt, notes } = parsed.data;
|
||||
const { entry, severity, occurredAt, notes } = parsed.data;
|
||||
|
||||
// Authoritative category derivation. The client-supplied category
|
||||
// is sanity-checked, never trusted — a NAUSEA row tagged as
|
||||
// INJECTION_SITE would poison the Coach aggregator.
|
||||
const expectedCategory = categoryForEntry(entry);
|
||||
if (expectedCategory !== category) {
|
||||
return apiError(
|
||||
`Entry ${entry} belongs to category ${expectedCategory}, not ${category}`,
|
||||
422,
|
||||
);
|
||||
}
|
||||
// v1.4.25 W21 Fix-N (code-M6) — category is derived server-side
|
||||
// from the entry via the authoritative taxonomy mapping. The wire
|
||||
// schema no longer accepts `category`; older clients that still
|
||||
// send it now have it ignored by Zod's strict drop, and the row
|
||||
// lands with the correct (entry-derived) category every time.
|
||||
const category = categoryForEntry(entry);
|
||||
|
||||
const created = await prisma.medicationSideEffect.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
category: expectedCategory,
|
||||
category,
|
||||
entry,
|
||||
severity,
|
||||
occurredAt: occurredAt ?? new Date(),
|
||||
|
||||
@@ -25,7 +25,12 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { findDrugByBrand, GLP1_DRUGS, type Glp1DrugId } from "@/lib/medications/glp1-knowledge";
|
||||
import {
|
||||
findDrugByBrand,
|
||||
findDrugIdByBrand,
|
||||
} from "@/lib/medications/glp1-knowledge";
|
||||
import { parseDoseMgOrNull } from "@/lib/medications/dose-string";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import {
|
||||
escalationDue,
|
||||
findCurrentStep,
|
||||
@@ -41,13 +46,19 @@ export const GET = apiHandler(
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper so
|
||||
// the 404 leak shape stays consistent across every medication
|
||||
// sub-route.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const med = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
doseChanges: { orderBy: { effectiveFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!med || med.userId !== user.id) {
|
||||
if (!med) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
if (med.treatmentClass !== "GLP1") {
|
||||
@@ -61,19 +72,11 @@ export const GET = apiHandler(
|
||||
// Resolve the GLP-1 drug record from the medication name. Same
|
||||
// path the DrugLevelChart uses; null when the medication name
|
||||
// doesn't map to a catalog brand (e.g. user typed a generic
|
||||
// INN instead of a brand).
|
||||
// INN instead of a brand). v1.4.25 W21 Fix-N — both lookups go
|
||||
// through the shared helpers in glp1-knowledge.
|
||||
const record = findDrugByBrand(med.name);
|
||||
if (!record) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
let drugId: Glp1DrugId | null = null;
|
||||
for (const [id, r] of Object.entries(GLP1_DRUGS)) {
|
||||
if (r === record) {
|
||||
drugId = id as Glp1DrugId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!drugId) {
|
||||
const drugId = findDrugIdByBrand(med.name);
|
||||
if (!record || !drugId) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
@@ -88,7 +91,7 @@ export const GET = apiHandler(
|
||||
: null;
|
||||
const latestDoseMg = latestChange
|
||||
? latestChange.doseValue
|
||||
: parseDoseString(med.dose);
|
||||
: parseDoseMgOrNull(med.dose);
|
||||
|
||||
const currentStep = findCurrentStep(drugId, latestDoseMg);
|
||||
const next = nextStep(drugId, currentStep);
|
||||
@@ -131,16 +134,3 @@ export const GET = apiHandler(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Parse the legacy free-text `medication.dose` ("0.5 mg", "5 mg",
|
||||
* "0.25") into a numeric mg value. Returns null when the string
|
||||
* doesn't carry a parseable number — the helper above treats null
|
||||
* as "no current step" rather than 0.
|
||||
*/
|
||||
function parseDoseString(dose: string): number | null {
|
||||
const match = dose.match(/[-+]?\d*\.?\d+/);
|
||||
if (!match) return null;
|
||||
const n = Number(match[0]);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return n;
|
||||
}
|
||||
+21
-3
@@ -10,15 +10,33 @@ export function apiSuccess<T>(data: T, status = 200) {
|
||||
* `{ errorCode: "credentials_rejected" }` so the UI translates the message
|
||||
* via `t("settings.testConnection.errors." + errorCode)` instead of
|
||||
* displaying the server's English fallback.
|
||||
*
|
||||
* v1.4.25 W21 Fix-N — the third argument may also carry a `headers`
|
||||
* record (e.g. `{ headers: rateLimitHeaders(rl) }`) so 429 responses
|
||||
* can attach the `X-RateLimit-*` triple in the same call. The `headers`
|
||||
* key is *not* echoed into the JSON body — it is consumed by the
|
||||
* NextResponse constructor and stripped from the meta envelope.
|
||||
*/
|
||||
export function apiError(
|
||||
message: string,
|
||||
status = 400,
|
||||
meta?: { errorCode?: string } & Record<string, unknown>,
|
||||
meta?: {
|
||||
errorCode?: string;
|
||||
headers?: Record<string, string>;
|
||||
} & Record<string, unknown>,
|
||||
) {
|
||||
const { headers, ...rest } = meta ?? {};
|
||||
const metaKeys = Object.keys(rest);
|
||||
return NextResponse.json(
|
||||
{ data: null, error: message, ...(meta ? { meta } : {}) },
|
||||
{ status },
|
||||
{
|
||||
data: null,
|
||||
error: message,
|
||||
...(metaKeys.length > 0 ? { meta: rest } : {}),
|
||||
},
|
||||
{
|
||||
status,
|
||||
...(headers ? { headers } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,8 +49,16 @@ export const SIDE_EFFECT_ENTRY_VALUES = [
|
||||
|
||||
export const SIDE_EFFECT_NOTES_MAX = 280;
|
||||
|
||||
/**
|
||||
* v1.4.25 W21 Fix-N (code-M6) — `category` was dropped from the wire
|
||||
* schema. The route now derives the canonical category from `entry`
|
||||
* via `categoryForEntry`. Older clients that still send `category` see
|
||||
* it silently ignored (Zod strips unknown fields by default), and the
|
||||
* row lands with the correct mapping regardless. No iOS DTO consumes
|
||||
* the old shape, so this is backwards-compatible without a versioned
|
||||
* envelope.
|
||||
*/
|
||||
export const createSideEffectSchema = z.object({
|
||||
category: z.enum(SIDE_EFFECT_CATEGORY_VALUES),
|
||||
entry: z.enum(SIDE_EFFECT_ENTRY_VALUES),
|
||||
severity: z.number().int().min(1).max(5),
|
||||
occurredAt: z.iso
|
||||
|
||||
Reference in new issue
Block a user