diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a06955585..e9facda02 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -932,7 +932,7 @@ paths: type: number const: 2 leaves: - maxItems: 93 + maxItems: 94 type: array items: type: string @@ -1037,7 +1037,7 @@ paths: type: number const: 2 leaves: - maxItems: 93 + maxItems: 94 type: array items: type: string @@ -6842,6 +6842,93 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/anamnesis/emergency: + get: + tags: + - Insights + summary: Read the emergency profile + description: "Returns the caller's emergency (Notfalldaten) profile: blood type, organ-donor and advance-directive + declarations, and the decrypted emergency contacts, implants/devices and ICE note. Free text decrypts + fail-soft." + responses: + "200": + description: The emergency profile (fields null when unset). + content: + application/json: + schema: + $ref: "#/components/schemas/GetEmergencyProfileResponse" + "401": *a1 + "422": *a3 + "429": *a4 + patch: + tags: + - Insights + summary: Update the emergency profile + description: Partial edit of the emergency profile on the caller's own record. Free-text fields are AES-256-GCM + encrypted before persistence. Audits as `anamnesis.emergency.update`. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + bloodType: + anyOf: + - type: string + enum: + - A_POS + - A_NEG + - B_POS + - B_NEG + - AB_POS + - AB_NEG + - O_POS + - O_NEG + - UNKNOWN + - type: "null" + organDonor: + anyOf: + - type: string + enum: + - YES + - NO + - UNKNOWN + - type: "null" + advanceDirective: + anyOf: + - type: string + enum: + - EXISTS + - NONE + - UNKNOWN + - type: "null" + contacts: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + implants: + anyOf: + - type: string + maxLength: 1000 + - type: "null" + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + additionalProperties: false + responses: + "200": + description: The updated emergency profile. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateEmergencyProfileResponse" + "401": *a1 + "422": *a3 + "429": *a4 /api/coach/about-me/questions: get: tags: @@ -16157,7 +16244,7 @@ components: type: number const: 2 leaves: - maxItems: 93 + maxItems: 94 type: array items: type: string @@ -29624,6 +29711,160 @@ components: - data - error additionalProperties: false + GetEmergencyProfileResponse: + type: object + properties: + data: + type: object + properties: + bloodType: + anyOf: + - type: string + enum: + - A_POS + - A_NEG + - B_POS + - B_NEG + - AB_POS + - AB_NEG + - O_POS + - O_NEG + - UNKNOWN + - type: "null" + organDonor: + anyOf: + - type: string + enum: + - YES + - NO + - UNKNOWN + - type: "null" + advanceDirective: + anyOf: + - type: string + enum: + - EXISTS + - NONE + - UNKNOWN + - type: "null" + contacts: + anyOf: + - type: string + - type: "null" + contactsUnreadable: + type: boolean + implants: + anyOf: + - type: string + - type: "null" + implantsUnreadable: + type: boolean + note: + anyOf: + - type: string + - type: "null" + noteUnreadable: + type: boolean + required: + - bloodType + - organDonor + - advanceDirective + - contacts + - contactsUnreadable + - implants + - implantsUnreadable + - note + - noteUnreadable + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateEmergencyProfileResponse: + type: object + properties: + data: + type: object + properties: + bloodType: + anyOf: + - type: string + enum: + - A_POS + - A_NEG + - B_POS + - B_NEG + - AB_POS + - AB_NEG + - O_POS + - O_NEG + - UNKNOWN + - type: "null" + organDonor: + anyOf: + - type: string + enum: + - YES + - NO + - UNKNOWN + - type: "null" + advanceDirective: + anyOf: + - type: string + enum: + - EXISTS + - NONE + - UNKNOWN + - type: "null" + contacts: + anyOf: + - type: string + - type: "null" + contactsUnreadable: + type: boolean + implants: + anyOf: + - type: string + - type: "null" + implantsUnreadable: + type: boolean + note: + anyOf: + - type: string + - type: "null" + noteUnreadable: + type: boolean + required: + - bloodType + - organDonor + - advanceDirective + - contacts + - contactsUnreadable + - implants + - implantsUnreadable + - note + - noteUnreadable + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false GetCoachAboutMeQuestionsResponse: type: object properties: diff --git a/src/app/api/anamnesis/emergency/route.ts b/src/app/api/anamnesis/emergency/route.ts new file mode 100644 index 000000000..faa8e1d1b --- /dev/null +++ b/src/app/api/anamnesis/emergency/route.ts @@ -0,0 +1,59 @@ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { annotate } from "@/lib/logging/context"; +import { + readEmergencyProfile, + writeEmergencyProfile, +} from "@/lib/profile/emergency-profile"; +import { emergencyProfileUpdateSchema } from "@/lib/validations/emergency-profile"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + const payload = await readEmergencyProfile(user.id); + annotate({ + action: { name: "anamnesis.emergency.get" }, + meta: { + has_blood_type: payload.bloodType !== null, + has_contacts: payload.contacts !== null, + has_implants: payload.implants !== null, + }, + }); + return apiSuccess(payload); +}); + +export const PATCH = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + const { data: rawBody, error } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (error) return error; + + const parsed = emergencyProfileUpdateSchema.safeParse(rawBody); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const payload = await writeEmergencyProfile(user.id, parsed.data); + await auditLog("anamnesis.emergency.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { fields: Object.keys(parsed.data).sort() }, + }); + annotate({ + action: { + name: "anamnesis.emergency.update", + entity_type: "user_health_profile", + entity_id: user.id, + }, + meta: { fields: Object.keys(parsed.data).sort() }, + }); + return apiSuccess(payload); +}); + +export const dynamic = "force-dynamic"; diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index 268dd7c02..814cdfbf0 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -16,6 +16,10 @@ import { healthProfileFactWriteSchema, removedHealthProfileFactSchema, } from "@/lib/validations/health-profile-facts"; +import { + emergencyProfileDtoSchema, + emergencyProfileUpdateSchema, +} from "@/lib/validations/emergency-profile"; import { ACCEPTED_INSIGHTS_TILE_IDS, INSIGHTS_SECTION_IDS, @@ -51,6 +55,18 @@ import { stdResponses, } from "./shared"; +emergencyProfileUpdateSchema.meta({ + id: "UpdateEmergencyProfileRequest", + description: + "Partial edit of the emergency (Notfalldaten) profile. An omitted key leaves the column untouched; a `null` enum or an emptied free-text field clears it. `bloodType`, `organDonor` and `advanceDirective` are closed enums; `contacts`, `implants` and `note` are encrypted at rest. Rejects unknown keys.", +}); + +emergencyProfileDtoSchema.meta({ + id: "EmergencyProfile", + description: + "The caller's emergency profile: three closed-enum facts plus three decrypted free-text fields. A free-text field is null when unset; its `*Unreadable` flag is true when ciphertext was present but could not be decrypted (a key-rotation gap, fail-soft rather than 500).", +}); + // ── Coach cadence suggestions (v1.18.1) ────────────────────────────── // The action endpoint behind the one-tap reminder-suggestion card. The // client sends ONLY the cadence id + the action; the server resolves the @@ -1360,6 +1376,54 @@ export const coachPaths: NonNullable = { }, }, }, + "/api/anamnesis/emergency": { + get: { + tags: ["Insights"], + summary: "Read the emergency profile", + description: + "Returns the caller's emergency (Notfalldaten) profile: blood type, organ-donor and advance-directive declarations, and the decrypted emergency contacts, implants/devices and ICE note. Free text decrypts fail-soft.", + responses: { + "200": { + description: "The emergency profile (fields null when unset).", + content: { + "application/json": { + schema: dataEnvelope( + emergencyProfileDtoSchema, + "GetEmergencyProfileResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Insights"], + summary: "Update the emergency profile", + description: + "Partial edit of the emergency profile on the caller's own record. Free-text fields are AES-256-GCM encrypted before persistence. Audits as `anamnesis.emergency.update`.", + requestBody: { + required: true, + content: { + "application/json": { schema: emergencyProfileUpdateSchema }, + }, + }, + responses: { + "200": { + description: "The updated emergency profile.", + content: { + "application/json": { + schema: dataEnvelope( + emergencyProfileDtoSchema, + "UpdateEmergencyProfileResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/coach/about-me/questions": { get: { tags: ["Insights"], diff --git a/src/lib/profile/emergency-profile.ts b/src/lib/profile/emergency-profile.ts new file mode 100644 index 000000000..a888d24a6 --- /dev/null +++ b/src/lib/profile/emergency-profile.ts @@ -0,0 +1,186 @@ +/** + * Emergency ("Notfalldaten") profile persistence. + * + * Reads and writes the six emergency columns on the single `UserHealthProfile` + * row, one per user. The three enum columns are plaintext closed sets; the + * three free-text columns are AES-256-GCM at rest via the shared Bytes codec. + * + * Read is fail-soft per encrypted field — a key-rotation gap on one column + * reads as "unreadable" rather than failing the whole surface, the same stance + * the anamnesis conditions and the visit free text take. Write encrypts on the + * way in and never persists plaintext. + */ +import { prisma } from "@/lib/db"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { getEvent } from "@/lib/logging/context"; +import type { + EmergencyProfileDto, + EmergencyProfileUpdate, +} from "@/lib/validations/emergency-profile"; + +interface EmergencyProfileRow { + emergencyBloodType: EmergencyProfileDto["bloodType"] | null; + organDonorStatus: EmergencyProfileDto["organDonor"] | null; + advanceDirectiveStatus: EmergencyProfileDto["advanceDirective"] | null; + emergencyContactsEncrypted: Uint8Array | null; + emergencyImplantsEncrypted: Uint8Array | null; + emergencyNoteEncrypted: Uint8Array | null; +} + +/** + * The column-shaped patch. Only present keys are written; every key can be + * `null` to clear its column. A plain object rather than a Prisma input type so + * it spreads cleanly into both `create` and `update` (Prisma's update input + * wraps scalars in a field-operations union that a create input would reject). + */ +interface EmergencyDataPatch { + emergencyBloodType?: EmergencyProfileDto["bloodType"] | null; + organDonorStatus?: EmergencyProfileDto["organDonor"] | null; + advanceDirectiveStatus?: EmergencyProfileDto["advanceDirective"] | null; + emergencyContactsEncrypted?: Uint8Array | null; + emergencyImplantsEncrypted?: Uint8Array | null; + emergencyNoteEncrypted?: Uint8Array | null; +} + +/** Decrypt one free-text emergency column, fail-soft. */ +function decryptField( + buf: Uint8Array | null, + field: string, + userId: string, +): { value: string | null; unreadable: boolean } { + if (!buf || buf.byteLength === 0) return { value: null, unreadable: false }; + try { + const text = decryptFromBytes(buf).trim(); + return { value: text.length > 0 ? text : null, unreadable: false }; + } catch (error) { + getEvent()?.addWarning( + `emergency profile ${field} decrypt failed for ${userId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return { value: null, unreadable: true }; + } +} + +/** Project a profile row (or its absence) into the read DTO. */ +export function toEmergencyProfileDto( + row: EmergencyProfileRow | null, + userId: string, +): EmergencyProfileDto { + const contacts = decryptField( + row?.emergencyContactsEncrypted ?? null, + "contacts", + userId, + ); + const implants = decryptField( + row?.emergencyImplantsEncrypted ?? null, + "implants", + userId, + ); + const note = decryptField( + row?.emergencyNoteEncrypted ?? null, + "note", + userId, + ); + return { + bloodType: row?.emergencyBloodType ?? null, + organDonor: row?.organDonorStatus ?? null, + advanceDirective: row?.advanceDirectiveStatus ?? null, + contacts: contacts.value, + contactsUnreadable: contacts.unreadable, + implants: implants.value, + implantsUnreadable: implants.unreadable, + note: note.value, + noteUnreadable: note.unreadable, + }; +} + +/** + * Whether an emergency DTO carries anything worth surfacing. An enum resting at + * `UNKNOWN` is the explicit "not stated" answer and does not, on its own, earn a + * page; a real enum answer or any free text (present or unreadable) does. This + * is the presence half of the doctor-report page gate. + */ +export function emergencyProfileHasContent(dto: EmergencyProfileDto): boolean { + const enumSet = + (dto.bloodType !== null && dto.bloodType !== "UNKNOWN") || + (dto.organDonor !== null && dto.organDonor !== "UNKNOWN") || + (dto.advanceDirective !== null && dto.advanceDirective !== "UNKNOWN"); + const textSet = + dto.contacts !== null || + dto.contactsUnreadable || + dto.implants !== null || + dto.implantsUnreadable || + dto.note !== null || + dto.noteUnreadable; + return enumSet || textSet; +} + +const EMERGENCY_SELECT = { + emergencyBloodType: true, + organDonorStatus: true, + advanceDirectiveStatus: true, + emergencyContactsEncrypted: true, + emergencyImplantsEncrypted: true, + emergencyNoteEncrypted: true, +} as const; + +/** Read the caller's emergency profile as the form-prefill DTO. */ +export async function readEmergencyProfile( + userId: string, +): Promise { + const row = await prisma.userHealthProfile.findUnique({ + where: { userId }, + select: EMERGENCY_SELECT, + }); + return toEmergencyProfileDto(row, userId); +} + +/** + * Build the `data` patch field-by-field from the parsed body. Only keys the + * body carried are written (an omitted key leaves the column untouched); an + * explicit `null` clears it. No mass assignment — the parsed object is never + * spread. Returns the encrypted-column shape both `create` and `update` accept. + */ +function buildEmergencyData(input: EmergencyProfileUpdate): EmergencyDataPatch { + const data: EmergencyDataPatch = {}; + if (input.bloodType !== undefined) data.emergencyBloodType = input.bloodType; + if (input.organDonor !== undefined) data.organDonorStatus = input.organDonor; + if (input.advanceDirective !== undefined) { + data.advanceDirectiveStatus = input.advanceDirective; + } + if (input.contacts !== undefined) { + data.emergencyContactsEncrypted = + input.contacts === null ? null : encryptToBytes(input.contacts); + } + if (input.implants !== undefined) { + data.emergencyImplantsEncrypted = + input.implants === null ? null : encryptToBytes(input.implants); + } + if (input.note !== undefined) { + data.emergencyNoteEncrypted = + input.note === null ? null : encryptToBytes(input.note); + } + return data; +} + +/** + * Apply an emergency-profile patch and return the fresh DTO. + * + * Upserts the single profile row: an account that has never opened its profile + * has no row yet, so the first save creates it. The `userId` is the caller's, + * narrowed upstream from `requireAuth()` — it is never read from the body. + */ +export async function writeEmergencyProfile( + userId: string, + input: EmergencyProfileUpdate, +): Promise { + const data = buildEmergencyData(input); + const row = await prisma.userHealthProfile.upsert({ + where: { userId }, + create: { userId, ...data }, + update: data, + select: EMERGENCY_SELECT, + }); + return toEmergencyProfileDto(row, userId); +} diff --git a/src/lib/query-keys/profile.ts b/src/lib/query-keys/profile.ts index 53d0260aa..9349931f8 100644 --- a/src/lib/query-keys/profile.ts +++ b/src/lib/query-keys/profile.ts @@ -1,4 +1,6 @@ /** Query keys for the bounded, read-only profile summary surface. */ export const profileKeys = { profileSummary: () => ["profile", "summary"] as const, + /** Emergency ("Notfalldaten") profile (`GET`/`PATCH /api/anamnesis/emergency`). */ + emergencyProfile: () => ["emergency-profile"] as const, }; diff --git a/src/lib/validations/emergency-profile.ts b/src/lib/validations/emergency-profile.ts new file mode 100644 index 000000000..ffde4f317 --- /dev/null +++ b/src/lib/validations/emergency-profile.ts @@ -0,0 +1,97 @@ +import { z } from "zod/v4"; + +/** + * Emergency ("Notfalldaten") profile — the closed enum sets and the free-text + * caps for the three encrypted columns. + * + * The three enums mirror the Prisma enums one-for-one (`EmergencyBloodType`, + * `OrganDonorStatus`, `AdvanceDirectiveStatus`); the arrays are the single + * source the form dropdowns and the Zod schemas both read, so a value cannot + * exist on one side and not the other. + */ + +export const EMERGENCY_BLOOD_TYPE_VALUES = [ + "A_POS", + "A_NEG", + "B_POS", + "B_NEG", + "AB_POS", + "AB_NEG", + "O_POS", + "O_NEG", + "UNKNOWN", +] as const; + +export const ORGAN_DONOR_STATUS_VALUES = ["YES", "NO", "UNKNOWN"] as const; + +export const ADVANCE_DIRECTIVE_STATUS_VALUES = [ + "EXISTS", + "NONE", + "UNKNOWN", +] as const; + +export type EmergencyBloodTypeValue = + (typeof EMERGENCY_BLOOD_TYPE_VALUES)[number]; +export type OrganDonorStatusValue = (typeof ORGAN_DONOR_STATUS_VALUES)[number]; +export type AdvanceDirectiveStatusValue = + (typeof ADVANCE_DIRECTIVE_STATUS_VALUES)[number]; + +export const emergencyBloodTypeSchema = z.enum(EMERGENCY_BLOOD_TYPE_VALUES); +export const organDonorStatusSchema = z.enum(ORGAN_DONOR_STATUS_VALUES); +export const advanceDirectiveStatusSchema = z.enum( + ADVANCE_DIRECTIVE_STATUS_VALUES, +); + +/** Free-text length caps, applied BEFORE encryption. */ +export const EMERGENCY_CONTACTS_MAX = 2000; +export const EMERGENCY_IMPLANTS_MAX = 1000; +export const EMERGENCY_NOTE_MAX = 2000; + +/** + * One free-text field: trims, treats an emptied field as a clear (`null`), and + * caps the length. `null` clears the column; an omitted key leaves it untouched + * (the route only writes keys the body carried). + */ +function freeText(max: number) { + return z + .string() + .trim() + .max(max) + .transform((value) => (value.length === 0 ? null : value)) + .nullable(); +} + +/** + * PATCH body. Every field is optional so a partial edit leaves the columns it + * omits untouched; an explicit `null` clears that field. `.strict()` refuses an + * unknown key rather than silently dropping it. + */ +export const emergencyProfileUpdateSchema = z + .object({ + bloodType: emergencyBloodTypeSchema.nullable().optional(), + organDonor: organDonorStatusSchema.nullable().optional(), + advanceDirective: advanceDirectiveStatusSchema.nullable().optional(), + contacts: freeText(EMERGENCY_CONTACTS_MAX).optional(), + implants: freeText(EMERGENCY_IMPLANTS_MAX).optional(), + note: freeText(EMERGENCY_NOTE_MAX).optional(), + }) + .strict(); + +export type EmergencyProfileUpdate = z.infer< + typeof emergencyProfileUpdateSchema +>; + +/** The read shape the GET route returns and the form prefills from. */ +export const emergencyProfileDtoSchema = z.object({ + bloodType: emergencyBloodTypeSchema.nullable(), + organDonor: organDonorStatusSchema.nullable(), + advanceDirective: advanceDirectiveStatusSchema.nullable(), + contacts: z.string().nullable(), + contactsUnreadable: z.boolean(), + implants: z.string().nullable(), + implantsUnreadable: z.boolean(), + note: z.string().nullable(), + noteUnreadable: z.boolean(), +}); + +export type EmergencyProfileDto = z.infer; diff --git a/tests/integration/emergency-profile-route.test.ts b/tests/integration/emergency-profile-route.test.ts new file mode 100644 index 000000000..cfe682c01 --- /dev/null +++ b/tests/integration/emergency-profile-route.test.ts @@ -0,0 +1,136 @@ +/** + * Emergency ("Notfalldaten") profile route against a real Postgres. + * + * The point of this file is the write half: a PATCH that reports success but + * never lands looks exactly like one that did. So it PATCHes through the real + * route, then reads the `user_health_profiles` row DIRECTLY and asserts the + * columns changed — the plaintext enums by value, the encrypted contact column + * by decrypting it back. Dropping any field from the handler's data builder + * turns the matching assertion red. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-emergency-profile-integration-32-bytes-1234567890"; +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const USER_ID = "user-emergency-profile"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +async function loginAs(userId: string): Promise { + cookieJar.clear(); + headerJar.clear(); + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); +} + +function patch(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/anamnesis/emergency", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "emergency-owner", + email: "emergency-owner@example.test", + timezone: "UTC", + }, + }); +}); + +describe("emergency profile route — the write lands in the row", () => { + it("persists the enums by value and the contacts column as decryptable ciphertext", async () => { + await loginAs(USER_ID); + const { PATCH } = await import("@/app/api/anamnesis/emergency/route"); + const { decryptFromBytes } = await import("@/lib/ai/coach/bytes-codec"); + + const CONTACTS = "ICE contact, reachable on the recorded number."; + const res = await PATCH( + patch({ + bloodType: "O_NEG", + organDonor: "YES", + advanceDirective: "EXISTS", + contacts: CONTACTS, + }), + ); + expect(res.status).toBe(200); + + const row = await getPrismaClient().userHealthProfile.findUniqueOrThrow({ + where: { userId: USER_ID }, + select: { + emergencyBloodType: true, + organDonorStatus: true, + advanceDirectiveStatus: true, + emergencyContactsEncrypted: true, + emergencyImplantsEncrypted: true, + }, + }); + expect(row.emergencyBloodType).toBe("O_NEG"); + expect(row.organDonorStatus).toBe("YES"); + expect(row.advanceDirectiveStatus).toBe("EXISTS"); + // The column is genuinely ciphertext (not the plaintext), and it decrypts + // back to exactly what was sent. + expect(row.emergencyContactsEncrypted).not.toBeNull(); + expect(decryptFromBytes(row.emergencyContactsEncrypted!)).toBe(CONTACTS); + // An omitted field stays untouched. + expect(row.emergencyImplantsEncrypted).toBeNull(); + }); + + it("reads the same values back through GET, and an emptied field clears its column", async () => { + await loginAs(USER_ID); + const { GET, PATCH } = await import("@/app/api/anamnesis/emergency/route"); + + await PATCH(patch({ bloodType: "A_POS", contacts: "first note" })); + + const cleared = await PATCH(patch({ contacts: "" })); + expect(cleared.status).toBe(200); + + const res = await GET(); + const body = (await res.json()) as { + data: { bloodType: string | null; contacts: string | null }; + }; + expect(body.data.bloodType).toBe("A_POS"); + expect(body.data.contacts).toBeNull(); + + const row = await getPrismaClient().userHealthProfile.findUniqueOrThrow({ + where: { userId: USER_ID }, + select: { emergencyContactsEncrypted: true }, + }); + expect(row.emergencyContactsEncrypted).toBeNull(); + }); +});