feat(medications): side-effect logging on GLP-1 detail pages

Adds the /api/medications/[id]/side-effects collection + per-row
endpoints (GET / POST + DELETE), the SideEffectsSection picker UI,
and i18n across the six locales. Mounted on the medication detail
page between the drug-level chart and the intake history; only the
GLP-1 treatmentClass renders the section.

The picker is category → entry chip → 5-Likert severity with an
optional 280-char note. The server derives the category from the
entry server-side — a client-supplied mismatch returns 422, so a
NAUSEA row tagged as INJECTION_SITE never lands in the DB.

POST is rate-limited 30/min/user (matching the inventory route);
DELETE is allowed at any time because the rows are user-owned and
non-clinical, with the audit trail preserving entry + severity +
occurredAt so a forensic reconstruction stays possible.

DE + EN copy hand-curated; FR / ES / IT / PL drafted from EN with
EMA-EPAR-§4.8 terminology. 39 new tests cover the route contract
(auth, ownership, validation, category mismatch, rate-limit) and
the section's static-render shape (empty state, category badge,
severity ladder, entry label, locale switch).
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 21:01:58 +02:00
1 parent 4e2b7be472
commit 7dfc212634
13 files changed
+1693

No files matched your search

@@ -0,0 +1,76 @@
/**
* v1.4.25 W19d — Zod validators for the side-effect API surface.
*
* The category supplied on the wire is sanity-checked against the
* authoritative entry → category mapping in `./taxonomy.ts`; on
* mismatch the route returns 422. This means a client cannot
* accidentally (or maliciously) write a NAUSEA row stamped with the
* INJECTION_SITE category and confuse the Coach aggregator.
*
* Severity is bounded 1-5 — the same range as the DB-level CHECK
* constraint and the `SideEffectSeverity` type-guard. Notes are
* capped to 280 chars (the chat-bubble length budget the timeline
* renders inside).
*/
import { z } from "zod/v4";
export const SIDE_EFFECT_CATEGORY_VALUES = [
"GI",
"METABOLIC",
"INJECTION_SITE",
"COGNITIVE",
"GLP1_SPECIFIC",
] as const;
export const SIDE_EFFECT_ENTRY_VALUES = [
"NAUSEA",
"VOMITING",
"DIARRHEA",
"CONSTIPATION",
"ABDOMINAL_PAIN",
"HYPOGLYCEMIA_SYMPTOMS",
"DEHYDRATION",
"ANOREXIA",
"ELECTROLYTE_FATIGUE",
"INJECTION_REDNESS",
"INJECTION_SWELLING",
"INJECTION_BRUISING",
"INJECTION_INDURATION",
"BRAIN_FOG",
"DIZZINESS",
"LOW_MOOD",
"LOW_ENERGY",
"EARLY_SATIETY",
"GASTROPARESIS_LIKE",
"DYSGEUSIA",
"GALLBLADDER_DISCOMFORT",
] as const;
export const SIDE_EFFECT_NOTES_MAX = 280;
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
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
notes: z.string().max(SIDE_EFFECT_NOTES_MAX).nullable().optional(),
});
export const listSideEffectsSchema = z.object({
from: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
to: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
});
export type CreateSideEffectInput = z.infer<typeof createSideEffectSchema>;
export type ListSideEffectsInput = z.infer<typeof listSideEffectsSchema>;