From 4c3cc84daf8709ab1eb79ee69fded719a6e32634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 12:28:48 +0200 Subject: [PATCH] feat(medications): carry manufacturer and printed strength on an inventory container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native pen list renders these two as its headline and subhead. The server held neither, so a container registered on the web could not be shown there without fabricating an empty card, and the client kept its own copy locally with no back-fill possible. Both columns are additive and nullable (migration 0255). Every existing row is truthfully NULL — the values were never collected, and a plain supply row such as a blister pack or a bottle has no use for either. Strength stays free text rather than a parsed number + unit: pens state it per dose, per ml, or per cartridge depending on the product, and forcing a numeric split would lose which of the three was printed. On PATCH an omitted field leaves the stored value untouched, the same rule notes already followed, so a client that does not know the fields cannot blank what another populated; an explicit null still clears. The web add-container form collects both for a PEN, so the columns do not ship empty forever. --- docs/api/openapi.yaml | 40 ++++++++++++ messages/de.json | 5 +- messages/en.json | 5 +- messages/es.json | 5 +- messages/fr.json | 5 +- messages/it.json | 5 +- messages/pl.json | 5 +- .../migration.sql | 14 +++++ prisma/schema.prisma | 52 ++++++++++------ .../[itemId]/__tests__/route.test.ts | 61 +++++++++++++++++++ .../[id]/inventory/[itemId]/route.ts | 13 ++++ .../[id]/inventory/__tests__/route.test.ts | 59 ++++++++++++++++++ .../api/medications/[id]/inventory/route.ts | 13 +++- .../sections/inventory-section.tsx | 44 +++++++++++++ .../inventory/__tests__/service.test.ts | 41 +++++++++++++ src/lib/medications/inventory/service.ts | 4 ++ src/lib/openapi/routes/medications/schemas.ts | 12 ++++ src/lib/validations/medication/inventory.ts | 36 +++++++++++ 18 files changed, 391 insertions(+), 28 deletions(-) create mode 100644 prisma/migrations/0255_medication_inventory_pen_labelling/migration.sql diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8e17c3fd7..ca649fc67 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12425,6 +12425,19 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + manufacturer: + description: Marketing-authorisation holder / maker as printed on the carton. Free text, max 120 chars. + anyOf: + - type: string + maxLength: 120 + - type: "null" + doseStrength: + description: Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, max 60 chars — deliberately not split + into a number + unit because pens state strength per dose, per ml, or per cartridge. + anyOf: + - type: string + maxLength: 60 + - type: "null" notes: anyOf: - type: string @@ -12458,6 +12471,19 @@ components: type: number minimum: 0 maximum: 1000 + manufacturer: + description: Marketing-authorisation holder / maker as printed on the carton. Free text, max 120 chars. + anyOf: + - type: string + maxLength: 120 + - type: "null" + doseStrength: + description: Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, max 60 chars — deliberately not split + into a number + unit because pens state strength per dose, per ml, or per cartridge. + anyOf: + - type: string + maxLength: 60 + - type: "null" notes: anyOf: - type: string @@ -19420,6 +19446,18 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + manufacturer: + anyOf: + - type: string + - type: "null" + description: Marketing-authorisation holder / maker as printed on the carton. NULL for a container registered before the + field existed, and for plain supply rows that never carried one. + doseStrength: + anyOf: + - type: string + - type: "null" + description: Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, not split into a number + unit — pens + state strength per dose, per ml, or per cartridge. NULL when unknown. notes: anyOf: - type: string @@ -19444,6 +19482,8 @@ components: - expiresAt - printedExpiry - purchasedAt + - manufacturer + - doseStrength - notes - createdAt - updatedAt diff --git a/messages/de.json b/messages/de.json index fd027918c..7ad5bd619 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1687,7 +1687,10 @@ "packagingDefaultPackLabel": "Standard-Packungsgröße (Einheiten)", "packagingDefaultPackHelper": "Vorbefüllung beim Erfassen neuer Packungen (1–1000). Leer lassen, wenn keine üblich ist.", "packagingSuccess": "Packungsangaben gespeichert", - "packagingFailed": "Speichern fehlgeschlagen" + "packagingFailed": "Speichern fehlgeschlagen", + "addManufacturerLabel": "Hersteller", + "addDoseStrengthLabel": "Wirkstärke", + "addDoseStrengthHint": "Wie auf dem Behälter aufgedruckt, z. B. 5 mg/0,5 ml." }, "erweitert": { "group": { diff --git a/messages/en.json b/messages/en.json index 1460664f9..ec57a5d9b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1687,7 +1687,10 @@ "packagingDefaultPackLabel": "Default pack size (units)", "packagingDefaultPackHelper": "Prefills the quantity when registering new packs (1–1000). Leave empty if none applies.", "packagingSuccess": "Packaging details saved", - "packagingFailed": "Saving failed" + "packagingFailed": "Saving failed", + "addManufacturerLabel": "Manufacturer", + "addDoseStrengthLabel": "Strength", + "addDoseStrengthHint": "As printed on the container, e.g. 5 mg/0.5 ml." }, "erweitert": { "group": { diff --git a/messages/es.json b/messages/es.json index 08e6a0a86..8c9a05e17 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1687,7 +1687,10 @@ "packagingDefaultPackLabel": "Tamaño de envase predeterminado (unidades)", "packagingDefaultPackHelper": "Rellena la cantidad al registrar envases nuevos (1–1000). Déjalo vacío si no aplica.", "packagingSuccess": "Datos del envase guardados", - "packagingFailed": "Error al guardar" + "packagingFailed": "Error al guardar", + "addManufacturerLabel": "Fabricante", + "addDoseStrengthLabel": "Concentración", + "addDoseStrengthHint": "Tal como aparece impreso en el envase, p. ej. 5 mg/0,5 ml." }, "erweitert": { "group": { diff --git a/messages/fr.json b/messages/fr.json index f8e967d66..4a46342e9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1687,7 +1687,10 @@ "packagingDefaultPackLabel": "Taille de boîte par défaut (unités)", "packagingDefaultPackHelper": "Préremplit la quantité lors de l’enregistrement de nouvelles boîtes (1–1000). Laisse vide si aucune ne s’applique.", "packagingSuccess": "Conditionnement enregistré", - "packagingFailed": "Échec de l’enregistrement" + "packagingFailed": "Échec de l’enregistrement", + "addManufacturerLabel": "Fabricant", + "addDoseStrengthLabel": "Dosage", + "addDoseStrengthHint": "Tel qu'imprimé sur le contenant, par ex. 5 mg/0,5 ml." }, "erweitert": { "group": { diff --git a/messages/it.json b/messages/it.json index bee89c07c..420f19bb4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1687,7 +1687,10 @@ "packagingDefaultPackLabel": "Dimensione confezione predefinita (unità)", "packagingDefaultPackHelper": "Precompila la quantità quando registri nuove confezioni (1–1000). Lascia vuoto se non applicabile.", "packagingSuccess": "Dati confezione salvati", - "packagingFailed": "Salvataggio non riuscito" + "packagingFailed": "Salvataggio non riuscito", + "addManufacturerLabel": "Produttore", + "addDoseStrengthLabel": "Dosaggio", + "addDoseStrengthHint": "Come stampato sul contenitore, ad es. 5 mg/0,5 ml." }, "erweitert": { "group": { diff --git a/messages/pl.json b/messages/pl.json index 70027bd7f..7514d89fb 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1687,7 +1687,10 @@ "packagingDefaultPackLabel": "Domyślny rozmiar opakowania (jednostki)", "packagingDefaultPackHelper": "Wstępnie wypełnia ilość przy dodawaniu nowych opakowań (1–1000). Zostaw puste, jeśli nie dotyczy.", "packagingSuccess": "Zapisano dane opakowania", - "packagingFailed": "Zapis nie powiódł się" + "packagingFailed": "Zapis nie powiódł się", + "addManufacturerLabel": "Producent", + "addDoseStrengthLabel": "Moc", + "addDoseStrengthHint": "Zgodnie z nadrukiem na opakowaniu, np. 5 mg/0,5 ml." }, "erweitert": { "group": { diff --git a/prisma/migrations/0255_medication_inventory_pen_labelling/migration.sql b/prisma/migrations/0255_medication_inventory_pen_labelling/migration.sql new file mode 100644 index 000000000..39170c503 --- /dev/null +++ b/prisma/migrations/0255_medication_inventory_pen_labelling/migration.sql @@ -0,0 +1,14 @@ +-- Carton labelling for an inventory container: manufacturer + printed strength. +-- +-- The native client's pen list uses these two as its headline and subhead, so +-- a container created on the web could not be shown there without fabricating +-- an empty card. Nothing on the server held them, so the client kept its own +-- copy locally and no back-fill was possible. +-- +-- Both are additive and nullable. Every pre-existing row is truthfully NULL: +-- the values were never collected, and a plain supply row (a blister pack, a +-- bottle) has no use for either. No backfill, no default, no rewrite of the +-- table's existing contents. +ALTER TABLE "medication_inventory_items" + ADD COLUMN "manufacturer" TEXT, + ADD COLUMN "dose_strength" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f525464f7..aa60be5ab 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2861,6 +2861,18 @@ model MedicationInventoryItem { /// The carton-printed expiry date the user enters at creation. printedExpiry DateTime? @map("printed_expiry") purchasedAt DateTime? @map("purchased_at") + /// Marketing-authorisation holder / maker printed on the carton. Free + /// text: the field is a label the user reads back, not a lookup key, and + /// there is no authority list a self-hoster could be held to. Nullable + /// because every pre-existing row predates it and plain supply rows + /// (blisters, bottles) have no use for it. + manufacturer String? + /// Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text + /// and deliberately NOT parsed into a number + unit: pens state strength + /// per dose, per ml, or per cartridge depending on the product, and a + /// forced numeric split would lose which of the three it was. Nullable + /// for the same reason as `manufacturer`. + doseStrength String? @map("dose_strength") /// Free-text note, application-layer max 200 chars. notes String? /// v1.25 — AES-256-GCM ciphertext for `notes`. The plaintext column @@ -3232,9 +3244,9 @@ model RefreshToken { // the token pair the first exchange issued (refresh-token reuse-detection // posture). 90-second TTL enforced at read; a daily sweep prunes stale rows. model OidcNativeHandoff { - id String @id @default(cuid()) - userId String @map("user_id") - codeHash String @unique @map("code_hash") // hashToken(hlh_<…>) + id String @id @default(cuid()) + userId String @map("user_id") + codeHash String @unique @map("code_hash") // hashToken(hlh_<…>) /// The app's S256 PKCE challenge, bound at mint; the exchange verifies /// s256(code_verifier) against it (constant-time) before consuming. codeChallenge String @map("code_challenge") @@ -3244,8 +3256,8 @@ model OidcNativeHandoff { /// revokes exactly this family member (+ its paired access token). issuedRefreshTokenHash String? @map("issued_refresh_token_hash") createdAt DateTime @default(now()) @map("created_at") - ipAddress String? @map("ip_address") - userAgent String? @map("user_agent") + ipAddress String? @map("ip_address") + userAgent String? @map("user_agent") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -6324,14 +6336,14 @@ model DocumentConditionLink { /// versioned key (no schema column), and key rotation re-tokenises from the /// decrypted `textEncrypted`. model DocumentContentIndex { - id String @id @default(cuid()) - documentId String @unique @map("document_id") - userId String @map("user_id") + id String @id @default(cuid()) + documentId String @unique @map("document_id") + userId String @map("user_id") /// AES-256-GCM ciphertext of the normalised extracted text (capped, first /// ~64 KiB), stored as the `encrypt()`-string-as-UTF-8 Bytes shape shared /// with the Coach columns. The plaintext is recoverable server-side so /// rotation can re-tokenise; the list query never selects it. - textEncrypted Bytes @map("text_encrypted") + textEncrypted Bytes @map("text_encrypted") /// v1.27.33 (Document vault P4 — chat about a document) — AES-256-GCM /// ciphertext of the VERBATIM extracted text (byte-capped, NOT lowercased or /// de-accented), stored additionally alongside `textEncrypted` so a document @@ -6340,22 +6352,22 @@ model DocumentContentIndex { /// UTF-8 Bytes shape as every other PHI column. Nullable: rows indexed before /// P4 carry NULL until re-indexed, and the chat route falls back to the /// normalised `textEncrypted` when it is absent. The list query never selects it. - verbatimTextEncrypted Bytes? @map("verbatim_text_encrypted") + verbatimTextEncrypted Bytes? @map("verbatim_text_encrypted") /// Deduped HMAC-SHA256 (truncated hex) of the normalised tokens — the blind /// index. One-way: opaque without the server-held index subkey. GIN-indexed /// (raw SQL in the migration; Prisma cannot express a GIN index). - searchTokens String[] @default([]) @map("search_tokens") + searchTokens String[] @default([]) @map("search_tokens") /// Provenance of the indexed text: "vision" (provider transcription of the /// stored original), "text-ocr" (browser-OCR text posted by the client), /// "local-pdf" (server-side text-layer extraction, no provider) or "local-ocr" /// (server-side OCR of a scanned document — deferred follow-up). Free String; /// adding a source value needs no migration. - source String + source String /// Provider type that produced the text (diagnostic only), or null on the /// text-ocr path. - providerType String? @map("provider_type") + providerType String? @map("provider_type") /// Tokeniser algorithm version — bump to force a re-index on a scheme change. - tokenizerVersion String @map("tokenizer_version") + tokenizerVersion String @map("tokenizer_version") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -6376,23 +6388,23 @@ model DocumentContentIndex { /// source, so the preview is metadata-free by construction. Owner-scoped /// serve route only; cascades on delete. model DocumentThumbnail { - id String @id @default(cuid()) - documentId String @unique @map("document_id") - userId String @map("user_id") + id String @id @default(cuid()) + documentId String @unique @map("document_id") + userId String @map("user_id") /// AES-256-GCM ciphertext of the JPEG preview, stored as the /// `encrypt()`-string-as-UTF-8 Bytes shape (base64 of the JPEG), the same /// codec `DocumentContentIndex.textEncrypted` uses so the standard /// key-rotation walk covers it. A scanned medical preview is PHI — never /// stored in the clear or logged. The list query never selects it. - thumbnailEncrypted Bytes @map("thumbnail_encrypted") + thumbnailEncrypted Bytes @map("thumbnail_encrypted") /// Decoded thumbnail dimensions (px) — diagnostic + lets a future consumer /// size the tile without decoding the blob. width Int height Int /// Plaintext byte length of the JPEG (pre-encryption), for `Content-Length`. - byteSize Int @map("byte_size") + byteSize Int @map("byte_size") /// Source MIME the preview was rendered from (diagnostic only). - sourceMime String @map("source_mime") + sourceMime String @map("source_mime") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts index 68bc43f27..ff22da059 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts @@ -183,3 +183,64 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — unitsRemaining stoc expect(res.status).toBe(422); }); }); + +describe("PATCH /api/medications/[id]/inventory/[itemId] — carton labelling", () => { + it("updates manufacturer + doseStrength when supplied", async () => { + vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({ + id: "i1", + state: "ACTIVE", + } as never); + + const res = await PATCH( + patchReq({ + manufacturer: "Example Pharma", + doseStrength: "5 mg/0.5 ml", + }), + ROUTE_CTX, + ); + + expect(res.status).toBe(200); + const arg = vi.mocked(prisma.medicationInventoryItem.update).mock + .calls[0]?.[0] as unknown as { + data: Record; + }; + expect(arg.data.manufacturer).toBe("Example Pharma"); + expect(arg.data.doseStrength).toBe("5 mg/0.5 ml"); + }); + + it("leaves both untouched when the request omits them", async () => { + // Absent must mean "I have nothing to say", never "blank it" — a client + // that does not know the fields must not wipe what another populated. + vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({ + id: "i1", + state: "ACTIVE", + } as never); + + const res = await PATCH(patchReq({ unitsRemaining: 2 }), ROUTE_CTX); + + expect(res.status).toBe(200); + const arg = vi.mocked(prisma.medicationInventoryItem.update).mock + .calls[0]?.[0] as unknown as { + data: Record; + }; + expect("manufacturer" in arg.data).toBe(false); + expect("doseStrength" in arg.data).toBe(false); + }); + + it("clears a value on an explicit null", async () => { + vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({ + id: "i1", + state: "ACTIVE", + } as never); + + const res = await PATCH(patchReq({ manufacturer: null }), ROUTE_CTX); + + expect(res.status).toBe(200); + const arg = vi.mocked(prisma.medicationInventoryItem.update).mock + .calls[0]?.[0] as unknown as { + data: Record; + }; + expect(arg.data.manufacturer).toBeNull(); + expect("doseStrength" in arg.data).toBe(false); + }); +}); diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts index df65ba109..449ce8bfd 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts @@ -79,6 +79,8 @@ export const PATCH = apiHandler( markAsUsedUp, printedExpiry, unitsRemaining, + manufacturer, + doseStrength, notes, } = parsed.data; @@ -151,6 +153,16 @@ export const PATCH = apiHandler( ? {} : { notesEncrypted: encryptNote(notes), notes: null }; + // Carton labelling follows the same absent-means-untouched rule as + // `notes`: an omitted field must not blank a value the other client + // populated. Built field by field, never spread from the parsed body. + const labellingData: { + manufacturer?: string | null; + doseStrength?: string | null; + } = {}; + if (manufacturer !== undefined) labellingData.manufacturer = manufacturer; + if (doseStrength !== undefined) labellingData.doseStrength = doseStrength; + const updated = await prisma.medicationInventoryItem.update({ where: { id: itemId }, data: { @@ -159,6 +171,7 @@ export const PATCH = apiHandler( unitsRemaining: nextUnitsRemaining, printedExpiry: nextPrintedExpiry, expiresAt: nextExpiresAt, + ...labellingData, ...notesData, }, }); diff --git a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts index 78f4f33ed..ecbe8bfa3 100644 --- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts @@ -471,3 +471,62 @@ describe("POST /api/medications/[id]/inventory — 422 multi-issue (v1.4.43 W6)" expect(body.details.issues.length).toBeGreaterThanOrEqual(3); }); }); + +describe("POST /api/medications/[id]/inventory — carton labelling", () => { + it("persists manufacturer + doseStrength when the client sends them", async () => { + // The native pen list uses these two as its headline and subhead. With + // nowhere on the server to hold them, a container registered on the web + // could not be rendered there at all. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({ + id: "inv-new", + state: "ACTIVE", + } as never); + + const res = await POST( + jsonReq("http://localhost/api/medications/med-1/inventory", { + unitsTotal: 4, + containerType: "PEN", + manufacturer: "Example Pharma", + doseStrength: "5 mg/0.5 ml", + }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + + expect(res.status).toBe(201); + expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + manufacturer: "Example Pharma", + doseStrength: "5 mg/0.5 ml", + }), + }); + }); + + it("stores null for both when the client omits them", async () => { + // A plain supply row (blister, bottle) carries no carton labelling, and + // every row that predates the columns is truthfully null. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({ + id: "inv-new", + state: "ACTIVE", + } as never); + + const res = await POST( + jsonReq("http://localhost/api/medications/med-1/inventory", { + unitsTotal: 30, + containerType: "BLISTER", + }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + + expect(res.status).toBe(201); + expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + manufacturer: null, + doseStrength: null, + }), + }); + }); +}); diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index d553ddee7..d22a8572b 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -121,8 +121,15 @@ export const POST = apiHandler( return returnAllZodIssues(parsed.error, 422); } - const { unitsTotal, containerType, printedExpiry, purchasedAt, notes } = - parsed.data; + const { + unitsTotal, + containerType, + printedExpiry, + purchasedAt, + manufacturer, + doseStrength, + notes, + } = parsed.data; const created = await prisma.medicationInventoryItem.create({ // The wire field `unitsTotal` counts UNITS (tablets / ampoules / @@ -135,6 +142,8 @@ export const POST = apiHandler( containerType: containerType ?? "OTHER", printedExpiry: printedExpiry ?? null, purchasedAt: purchasedAt ?? null, + manufacturer: manufacturer ?? null, + doseStrength: doseStrength ?? null, notes: notes ?? null, }), }); diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index fff2c12a0..d473fe8d5 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -492,6 +492,11 @@ export function AddInventoryDialog({ const [containerType, setContainerType] = useState(initialContainerType); const [expiry, setExpiry] = useState(""); + // Carton labelling. Only offered for a PEN: the native pen list is the + // one surface that renders them, and asking for a maker + strength on a + // blister pack would be noise on the far commoner path. + const [manufacturer, setManufacturer] = useState(""); + const [doseStrength, setDoseStrength] = useState(""); const [busy, setBusy] = useState(false); const formId = useId(); @@ -513,6 +518,10 @@ export function AddInventoryDialog({ printedExpiry: expiry ? new Date(`${expiry}T00:00:00`).toISOString() : null, + // Trimmed to null so a field the user opened and left blank stores + // absence rather than an empty string. + manufacturer: manufacturer.trim() || null, + doseStrength: doseStrength.trim() || null, }); await invalidateSupplyQueries(queryClient, medicationId); toast.success(t("medications.detail.bestand.addSuccess")); @@ -664,6 +673,41 @@ export function AddInventoryDialog({ onChange={setExpiry} /> + {containerType === "PEN" && ( + <> +
+ + setManufacturer(e.target.value)} + /> +
+
+ + setDoseStrength(e.target.value)} + /> +

+ {t("medications.detail.bestand.addDoseStrengthHint")} +

+
+ + )} ); diff --git a/src/lib/medications/inventory/__tests__/service.test.ts b/src/lib/medications/inventory/__tests__/service.test.ts index 20509e5ae..0c5751b42 100644 --- a/src/lib/medications/inventory/__tests__/service.test.ts +++ b/src/lib/medications/inventory/__tests__/service.test.ts @@ -80,6 +80,8 @@ describe("buildCreateInventoryInput", () => { containerType: "PEN", printedExpiry: printed, purchasedAt: null, + manufacturer: null, + doseStrength: null, notes: null, }); expect(input).toMatchObject({ @@ -100,6 +102,8 @@ describe("buildCreateInventoryInput", () => { containerType: "OTHER", printedExpiry: null, purchasedAt: null, + manufacturer: null, + doseStrength: null, notes: null, }); expect(input.expiresAt).toBeNull(); @@ -157,3 +161,40 @@ describe("serializeInventoryItem (iOS#31)", () => { expect(out).toMatchObject({ id: "i5", state: "ACTIVE" }); }); }); + +describe("buildCreateInventoryInput — carton labelling", () => { + it("carries manufacturer + doseStrength onto the create input", () => { + // The native pen list renders these as its headline and subhead; a + // container created on the web carried neither, so the row could not be + // shown there without fabricating an empty card. + const input = buildCreateInventoryInput({ + userId: "u", + medicationId: "m", + unitsTotal: 4, + containerType: "PEN", + printedExpiry: null, + purchasedAt: null, + manufacturer: "Example Pharma", + doseStrength: "5 mg/0.5 ml", + notes: null, + }); + expect(input.manufacturer).toBe("Example Pharma"); + expect(input.doseStrength).toBe("5 mg/0.5 ml"); + }); + + it("leaves both null for a plain supply container", () => { + const input = buildCreateInventoryInput({ + userId: "u", + medicationId: "m", + unitsTotal: 30, + containerType: "BLISTER", + printedExpiry: null, + purchasedAt: null, + manufacturer: null, + doseStrength: null, + notes: null, + }); + expect(input.manufacturer).toBeNull(); + expect(input.doseStrength).toBeNull(); + }); +}); diff --git a/src/lib/medications/inventory/service.ts b/src/lib/medications/inventory/service.ts index 13b4c4919..2b77e2e34 100644 --- a/src/lib/medications/inventory/service.ts +++ b/src/lib/medications/inventory/service.ts @@ -144,6 +144,8 @@ export function buildCreateInventoryInput(input: { containerType: MedicationContainerType; printedExpiry: Date | null; purchasedAt: Date | null; + manufacturer: string | null; + doseStrength: string | null; notes: string | null; }) { return { @@ -157,6 +159,8 @@ export function buildCreateInventoryInput(input: { printedExpiry: input.printedExpiry, purchasedAt: input.purchasedAt, expiresAt: computeExpiresAt(null, input.printedExpiry), + manufacturer: input.manufacturer, + doseStrength: input.doseStrength, // Encrypt the free-text note at rest; the plaintext column stays null. notesEncrypted: encryptNote(input.notes), notes: null, diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 57df825a7..6e038efaf 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -314,6 +314,18 @@ export const medicationInventoryItemResource = z ), printedExpiry: z.iso.datetime({ offset: true }).nullable(), purchasedAt: z.iso.datetime({ offset: true }).nullable(), + manufacturer: z + .string() + .nullable() + .describe( + "Marketing-authorisation holder / maker as printed on the carton. NULL for a container registered before the field existed, and for plain supply rows that never carried one.", + ), + doseStrength: z + .string() + .nullable() + .describe( + 'Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, not split into a number + unit — pens state strength per dose, per ml, or per cartridge. NULL when unknown.', + ), notes: z.string().nullable(), createdAt: z.iso.datetime({ offset: true }), updatedAt: z.iso.datetime({ offset: true }), diff --git a/src/lib/validations/medication/inventory.ts b/src/lib/validations/medication/inventory.ts index 5e735b085..1c33a83d0 100644 --- a/src/lib/validations/medication/inventory.ts +++ b/src/lib/validations/medication/inventory.ts @@ -54,6 +54,24 @@ export const createInventoryItemSchema = z .transform((s) => new Date(s)) .nullable() .optional(), + /** Carton labelling — free text, see the schema comment on the model + * for why neither is parsed into a structured value. */ + manufacturer: z + .string() + .max(120) + .nullable() + .optional() + .describe( + "Marketing-authorisation holder / maker as printed on the carton. Free text, max 120 chars.", + ), + doseStrength: z + .string() + .max(60) + .nullable() + .optional() + .describe( + 'Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, max 60 chars — deliberately not split into a number + unit because pens state strength per dose, per ml, or per cartridge.', + ), notes: z.string().max(200).nullable().optional(), }) .meta({ @@ -99,6 +117,24 @@ export const updateInventoryItemSchema = z .describe( "Absolute remaining-unit correction (0–1000, fractional allowed). Clamped server-side to the item's `unitsTotal`; the canonical state machine re-derives the state (0 ⇒ USED_UP).", ), + /** Carton labelling — free text, see the schema comment on the model + * for why neither is parsed into a structured value. */ + manufacturer: z + .string() + .max(120) + .nullable() + .optional() + .describe( + "Marketing-authorisation holder / maker as printed on the carton. Free text, max 120 chars.", + ), + doseStrength: z + .string() + .max(60) + .nullable() + .optional() + .describe( + 'Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, max 60 chars — deliberately not split into a number + unit because pens state strength per dose, per ml, or per cartridge.', + ), notes: z.string().max(200).nullable().optional(), }) .meta({