mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
772da42348
commit
6770bf3b7f
7 files changed
+788
-3
No files matched your search
+244
-3
@@ -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:
|
||||
|
||||
@@ -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";
|
||||
@@ -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"],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user