mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(medications): carry manufacturer and printed strength on an inventory container
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.
This commit is contained in:
1 parent
4ddc08f784
commit
4c3cc84daf
18 files changed
+391
-28
No files matched your search
@@ -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
|
||||
|
||||
+4
-1
@@ -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": {
|
||||
|
||||
+4
-1
@@ -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": {
|
||||
|
||||
+4
-1
@@ -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": {
|
||||
|
||||
+4
-1
@@ -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": {
|
||||
|
||||
+4
-1
@@ -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": {
|
||||
|
||||
+4
-1
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
+32
-20
@@ -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")
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
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<string, unknown>;
|
||||
};
|
||||
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<string, unknown>;
|
||||
};
|
||||
expect(arg.data.manufacturer).toBeNull();
|
||||
expect("doseStrength" in arg.data).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -492,6 +492,11 @@ export function AddInventoryDialog({
|
||||
const [containerType, setContainerType] =
|
||||
useState<ContainerType>(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}
|
||||
/>
|
||||
</div>
|
||||
{containerType === "PEN" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-add-manufacturer"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.addManufacturerLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="inventory-add-manufacturer"
|
||||
value={manufacturer}
|
||||
maxLength={120}
|
||||
onChange={(e) => setManufacturer(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-add-dose-strength"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.addDoseStrengthLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="inventory-add-dose-strength"
|
||||
value={doseStrength}
|
||||
maxLength={60}
|
||||
onChange={(e) => setDoseStrength(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.detail.bestand.addDoseStrengthHint")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user