feat(api): add emergency profile read and write route

GET/PATCH /api/anamnesis/emergency, wrapped in apiHandler. The PATCH
validates a partial body with per-field length caps, builds the Prisma
data field-by-field from the parsed input (never a spread), narrows the
user from requireAuth rather than the body, encrypts the free text on
write, and audits the change. The GET decrypts fail-soft for the form
prefill. Registers the OpenAPI operations and schema. An integration
test reads the row back to prove the write lands.
This commit is contained in:
Marc-André Bombeck committed 2026-08-10 05:32:59 +02:00
1 parent 772da42348
commit 6770bf3b7f
7 files changed
+788 -3

No files matched your search

+244 -3
View File
@@ -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:
+59
View File
@@ -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";
+64
View File
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
},
"/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"],
+186
View File
@@ -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<ArrayBuffer> | null;
emergencyImplantsEncrypted?: Uint8Array<ArrayBuffer> | null;
emergencyNoteEncrypted?: Uint8Array<ArrayBuffer> | 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<EmergencyProfileDto> {
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<EmergencyProfileDto> {
const data = buildEmergencyData(input);
const row = await prisma.userHealthProfile.upsert({
where: { userId },
create: { userId, ...data },
update: data,
select: EMERGENCY_SELECT,
});
return toEmergencyProfileDto(row, userId);
}
+2
View File
@@ -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,
};
+97
View File
@@ -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<typeof emergencyProfileDtoSchema>;
@@ -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<void> {
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();
});
});