From b99b07ad38840df70e808acb18efdc1bc36956b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 1 Jun 2026 23:23:20 +0200 Subject: [PATCH] feat(profile): capture the insurer IK number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional German insurer institution number (IKNR, 9 digits) to the user profile, alongside the existing insurer name and KVNR. The IKNR is a public registry identifier for the institution, not a per-person quasi-identifier, so it is stored plaintext, matching insurer_name rather than the encrypted KVNR column. The profile GET echoes the field; PATCH/PUT accept it via Zod — optional, empty string maps to null, and a non-empty value must be exactly 9 digits (422 otherwise, no checksum). The write is built field-by-field. /api/user/profile gains an OpenAPI registration with the profile read/write schemas. Migration 0102 adds the nullable column, NULL-defaulted, forward-only. --- docs/api/openapi.yaml | 284 ++++++++++++++++++ .../migration.sql | 32 ++ prisma/schema.prisma | 10 + src/app/api/auth/me/route.ts | 1 + src/app/api/auth/profile/route.ts | 1 + .../api/user/profile/__tests__/route.test.ts | 68 +++++ src/app/api/user/profile/route.ts | 3 + .../__tests__/disable-coach-card.test.tsx | 1 + .../__tests__/unit-preference-card.test.tsx | 1 + src/hooks/use-auth.ts | 5 + src/lib/auth/__tests__/profile-update.test.ts | 76 +++++ src/lib/auth/profile-update.ts | 7 + .../__tests__/coach-user-disable.test.tsx | 1 + src/lib/openapi/routes.ts | 138 +++++++++ src/lib/validations/auth.ts | 17 ++ 15 files changed, 645 insertions(+) create mode 100644 prisma/migrations/0102_v186_user_insurer_ik_number/migration.sql diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1a42a3b8b..df45fe609 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1738,6 +1738,46 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a2 "429": *a3 + /api/user/profile: + get: + tags: + - Auth + summary: Read the calling user's profile + description: Flattened profile fields for the native client. Aliased over the same data exposed by /api/auth/me. The + KVNR is decrypted server-side; the IKNR (v1.8.6) is returned plaintext. + responses: + "200": + description: The calling user's profile. + content: + application/json: + schema: + $ref: "#/components/schemas/GetProfileResponse" + "401": *a1 + "422": *a2 + "429": *a3 + patch: + tags: + - Auth + summary: Update the calling user's profile + description: Partial profile update — every field is optional. An explicit null (or empty string) clears a field. A + non-empty `insurerIkNumber` must be exactly 9 digits (422 otherwise); a non-empty `insuranceNumber` (KVNR) must + pass the mod-10 check. `userId` is never accepted from the body. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ProfileUpdateRequest" + responses: + "200": + description: Saved profile echoed back. + content: + application/json: + schema: + $ref: "#/components/schemas/PatchProfileResponse" + "401": *a1 + "422": *a2 + "429": *a3 /api/insights/layout: get: tags: @@ -2489,6 +2529,74 @@ components: required: - disableCoach description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates. + ProfileUpdateRequest: + type: object + properties: + email: + anyOf: + - type: string + format: email + pattern: ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ + - type: "null" + heightCm: + anyOf: + - type: number + minimum: 50 + maximum: 300 + - type: "null" + dateOfBirth: + anyOf: + - type: string + - type: "null" + gender: + anyOf: + - type: string + enum: + - MALE + - FEMALE + - type: "null" + displayName: + anyOf: + - type: string + minLength: 1 + maxLength: 80 + - type: "null" + locale: + anyOf: + - type: string + enum: + - de + - en + - type: "null" + timezone: + type: string + minLength: 1 + maxLength: 64 + moodReminderEnabled: + type: boolean + fullName: + anyOf: + - type: string + maxLength: 120 + - type: "null" + insurerName: + anyOf: + - type: string + maxLength: 120 + - type: "null" + insuranceNumber: + description: German KVNR. Empty/null clears it; mod-10 check enforced. + anyOf: + - type: string + - type: "null" + insurerIkNumber: + description: German insurer institution number (IKNR). Optional; empty/null clears it. A non-empty value must be exactly + 9 digits (no checksum enforced). + anyOf: + - type: string + - type: "null" + description: Partial profile update. Every field is optional; an omitted field is left untouched, an explicit null (or + empty string) clears it. `userId` is never accepted — it is narrowed from the session/token. InsightsLayoutBody: type: object properties: @@ -5370,6 +5478,182 @@ components: - data - error additionalProperties: false + GetProfileResponse: + type: object + properties: + data: + $ref: "#/components/schemas/ProfileResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ProfileResponse: + type: object + properties: + username: + type: string + displayName: + anyOf: + - type: string + - type: "null" + email: + anyOf: + - type: string + - type: "null" + dateOfBirth: + anyOf: + - type: string + 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" + gender: + anyOf: + - type: string + enum: + - MALE + - FEMALE + - type: "null" + heightCm: + anyOf: + - type: number + - type: "null" + locale: + anyOf: + - type: string + - type: "null" + timezone: + type: string + moodReminderEnabled: + type: boolean + fullName: + anyOf: + - type: string + - type: "null" + insurerName: + anyOf: + - type: string + - type: "null" + insurerIkNumber: + anyOf: + - type: string + - type: "null" + description: German insurer institution number (IKNR), 9 digits. + insuranceNumber: + anyOf: + - type: string + - type: "null" + description: German KVNR, decrypted for the form prefill. + required: + - username + - email + - dateOfBirth + - gender + - heightCm + - locale + - timezone + - moodReminderEnabled + - fullName + - insurerName + - insurerIkNumber + - insuranceNumber + additionalProperties: false + description: Flattened profile fields for the native client (GET). The KVNR is decrypted server-side; the IKNR (v1.8.6) + is plaintext at rest. + PatchProfileResponse: + type: object + properties: + data: + $ref: "#/components/schemas/ProfileUpdateResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ProfileUpdateResponse: + type: object + properties: + username: + type: string + displayName: + anyOf: + - type: string + - type: "null" + email: + anyOf: + - type: string + - type: "null" + dateOfBirth: + anyOf: + - type: string + 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" + gender: + anyOf: + - type: string + enum: + - MALE + - FEMALE + - type: "null" + heightCm: + anyOf: + - type: number + - type: "null" + locale: + anyOf: + - type: string + - type: "null" + timezone: + type: string + moodReminderEnabled: + type: boolean + fullName: + anyOf: + - type: string + - type: "null" + insurerName: + anyOf: + - type: string + - type: "null" + insurerIkNumber: + anyOf: + - type: string + - type: "null" + description: German insurer institution number (IKNR), 9 digits. + hasInsuranceNumber: + type: boolean + description: Whether a KVNR is on file (the value itself is not echoed). + required: + - username + - email + - dateOfBirth + - gender + - heightCm + - locale + - timezone + - moodReminderEnabled + - fullName + - insurerName + - insurerIkNumber + - hasInsuranceNumber + additionalProperties: false + description: Profile echo returned by the PATCH/PUT update path. Reports KVNR presence as a boolean rather than + re-emitting the decrypted value. InsightsLayout: type: object properties: diff --git a/prisma/migrations/0102_v186_user_insurer_ik_number/migration.sql b/prisma/migrations/0102_v186_user_insurer_ik_number/migration.sql new file mode 100644 index 000000000..9566841ab --- /dev/null +++ b/prisma/migrations/0102_v186_user_insurer_ik_number/migration.sql @@ -0,0 +1,32 @@ +-- v1.8.6 — optional German insurer institution number (IKNR) on the user +-- profile. +-- +-- The IKNR ("Institutionskennzeichen", 9 digits) identifies the statutory +-- or private health insurer. It sits next to the existing plaintext +-- `insurer_name` and the encrypted `insurance_number_encrypted` (KVNR). +-- Unlike the KVNR — a per-person quasi-identifier kept AES-256-GCM +-- encrypted at rest — the IKNR is a public registry number for the +-- institution, so it is stored plaintext, matching `insurer_name`. +-- +-- Surfaced on the health-record FHIR export as the contained payor +-- `Organization` identifier (system `http://fhir.de/sid/arge-ik/iknr`) +-- inside the new `Coverage` resource. +-- +-- Single additive, nullable, NULL-defaulted column on `users`. No +-- backfill — pre-v1.8.6 rows read NULL (no IKNR captured yet), which the +-- exporter treats identically to an absent value. +-- +-- Idempotent guard (`IF NOT EXISTS`) makes reruns safe. Forward-only. +-- +-- Reversibility: +-- ALTER TABLE "users" DROP COLUMN IF EXISTS "insurer_ik_number"; +-- A roll-back loses the captured IKNR; the FHIR export simply emits the +-- payor `Organization` with a name only (no identifier), and the +-- application reads tolerate a missing column / NULL value. +-- +-- No index — reads are per-user at request time (`WHERE id = $1`), +-- matching `insurer_name` and the other display-identity columns on the +-- same table. + +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "insurer_ik_number" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e03a08b5f..971c85626 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -418,6 +418,16 @@ model User { insurerName String? @map("insurer_name") insuranceNumberEncrypted String? @map("insurance_number_encrypted") + // v1.8.6 profile — optional German insurer institution number (IKNR, + // 9 digits). Surfaced on the FHIR `Coverage` resource's contained + // payor `Organization` (system `http://fhir.de/sid/arge-ik/iknr`). + // Stored plaintext, matching `insurerName` — the IKNR is a public + // registry identifier, not a quasi-identifier like the KVNR, so it + // does not warrant the encrypted column treatment. Optional; an unset + // field collapses the Coverage payor identifier exactly like an + // unset `insurerName` collapses its name. + insurerIkNumber String? @map("insurer_ik_number") + @@map("users") } diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index e19773251..49f74a96a 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -66,6 +66,7 @@ export const GET = apiHandler(async () => { // v1.7.0 — health-record export identity fields. All optional. fullName: user.fullName ?? null, insurerName: user.insurerName ?? null, + insurerIkNumber: user.insurerIkNumber ?? null, insuranceNumber, }); }); diff --git a/src/app/api/auth/profile/route.ts b/src/app/api/auth/profile/route.ts index ede9d4faa..c84a59771 100644 --- a/src/app/api/auth/profile/route.ts +++ b/src/app/api/auth/profile/route.ts @@ -33,6 +33,7 @@ export const PUT = apiHandler(async (request: NextRequest) => { timezone: result.user.timezone, fullName: result.user.fullName, insurerName: result.user.insurerName, + insurerIkNumber: result.user.insurerIkNumber, hasInsuranceNumber: result.user.hasInsuranceNumber, }); }); diff --git a/src/app/api/user/profile/__tests__/route.test.ts b/src/app/api/user/profile/__tests__/route.test.ts index 5c68f57f8..82b9c1b4b 100644 --- a/src/app/api/user/profile/__tests__/route.test.ts +++ b/src/app/api/user/profile/__tests__/route.test.ts @@ -81,6 +81,33 @@ describe("GET /api/user/profile", () => { expect(body.data.heightCm).toBe(180); expect(body.data.locale).toBe("de"); }); + + it("echoes the insurer IK number on GET", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + username: "marc", + displayName: null, + email: null, + dateOfBirth: null, + gender: null, + heightCm: null, + locale: null, + timezone: "Europe/Berlin", + moodReminderEnabled: false, + fullName: null, + insurerName: "Example Insurer", + insurerIkNumber: "101234567", + insuranceNumberEncrypted: null, + } as never); + + const res = await callGet(makeGetReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { insurerIkNumber: string | null; insurerName: string | null }; + }; + expect(body.data.insurerIkNumber).toBe("101234567"); + expect(body.data.insurerName).toBe("Example Insurer"); + }); }); describe("PATCH /api/user/profile", () => { @@ -132,4 +159,45 @@ describe("PATCH /api/user/profile", () => { }), ); }); + + it("accepts a valid IKNR and echoes it back", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.update).mockResolvedValue({ + id: "user-1", + username: "marc", + displayName: null, + email: null, + role: "USER", + heightCm: null, + dateOfBirth: null, + gender: null, + timezone: "Europe/Berlin", + locale: null, + moodReminderEnabled: false, + fullName: null, + insurerName: null, + insurerIkNumber: "101234567", + insuranceNumberEncrypted: null, + } as never); + + const res = await PATCH(req({ insurerIkNumber: "101234567" })); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ insurerIkNumber: "101234567" }), + }), + ); + const body = (await res.json()) as { + data: { insurerIkNumber: string | null }; + }; + expect(body.data.insurerIkNumber).toBe("101234567"); + }); + + it("returns 422 for a malformed IKNR", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await PATCH(req({ insurerIkNumber: "abc" })); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index 615285297..6dd2ed943 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -39,6 +39,7 @@ export const GET = apiHandler(async () => { moodReminderEnabled: true, fullName: true, insurerName: true, + insurerIkNumber: true, insuranceNumberEncrypted: true, }, }); @@ -66,6 +67,7 @@ export const GET = apiHandler(async () => { moodReminderEnabled: dbUser?.moodReminderEnabled ?? false, fullName: dbUser?.fullName ?? null, insurerName: dbUser?.insurerName ?? null, + insurerIkNumber: dbUser?.insurerIkNumber ?? null, insuranceNumber, }); }); @@ -95,6 +97,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => { moodReminderEnabled: result.user.moodReminderEnabled, fullName: result.user.fullName, insurerName: result.user.insurerName, + insurerIkNumber: result.user.insurerIkNumber, hasInsuranceNumber: result.user.hasInsuranceNumber, }); }); diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx index 4830d8f62..387c07b9a 100644 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ b/src/components/settings/__tests__/disable-coach-card.test.tsx @@ -59,6 +59,7 @@ function buildUser(disableCoach: boolean): AuthUser { disableCoach, fullName: null, insurerName: null, + insurerIkNumber: null, insuranceNumber: null, }; } diff --git a/src/components/settings/__tests__/unit-preference-card.test.tsx b/src/components/settings/__tests__/unit-preference-card.test.tsx index 13fd2a391..cbc19421f 100644 --- a/src/components/settings/__tests__/unit-preference-card.test.tsx +++ b/src/components/settings/__tests__/unit-preference-card.test.tsx @@ -50,6 +50,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser { disableCoach: false, fullName: null, insurerName: null, + insurerIkNumber: null, insuranceNumber: null, }; } diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 25156041f..e18298e09 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -53,6 +53,11 @@ export interface AuthUser { */ fullName: string | null; insurerName: string | null; + /** + * v1.8.6 — optional German insurer institution number (IKNR, 9 digits). + * Surfaced on the FHIR `Coverage` resource's payor Organization. + */ + insurerIkNumber: string | null; insuranceNumber: string | null; } diff --git a/src/lib/auth/__tests__/profile-update.test.ts b/src/lib/auth/__tests__/profile-update.test.ts index ccb0b7a9b..6856bb45e 100644 --- a/src/lib/auth/__tests__/profile-update.test.ts +++ b/src/lib/auth/__tests__/profile-update.test.ts @@ -74,3 +74,79 @@ describe("applyProfileUpdate timezone validation", () => { expect(prisma.user.update).not.toHaveBeenCalled(); }); }); + +describe("applyProfileUpdate insurer IK number", () => { + it("accepts a valid 9-digit IKNR and writes it field-by-field", async () => { + const result = await applyProfileUpdate(USER_ID, { + insurerIkNumber: "101234567", + }); + expect(result.ok).toBe(true); + expect(prisma.user.update).toHaveBeenCalledTimes(1); + const arg = vi.mocked(prisma.user.update).mock.calls[0][0] as { + data: Record; + }; + expect(arg.data.insurerIkNumber).toBe("101234567"); + }); + + it("trims surrounding whitespace before validating", async () => { + const result = await applyProfileUpdate(USER_ID, { + insurerIkNumber: " 101234567 ", + }); + expect(result.ok).toBe(true); + const arg = vi.mocked(prisma.user.update).mock.calls[0][0] as { + data: Record; + }; + expect(arg.data.insurerIkNumber).toBe("101234567"); + }); + + it("maps an empty string to null (clears the field)", async () => { + const result = await applyProfileUpdate(USER_ID, { insurerIkNumber: "" }); + expect(result.ok).toBe(true); + const arg = vi.mocked(prisma.user.update).mock.calls[0][0] as { + data: Record; + }; + expect(arg.data.insurerIkNumber).toBeNull(); + }); + + it("maps an explicit null to null", async () => { + const result = await applyProfileUpdate(USER_ID, { insurerIkNumber: null }); + expect(result.ok).toBe(true); + const arg = vi.mocked(prisma.user.update).mock.calls[0][0] as { + data: Record; + }; + expect(arg.data.insurerIkNumber).toBeNull(); + }); + + it("leaves the field untouched when omitted", async () => { + const result = await applyProfileUpdate(USER_ID, { fullName: "Someone" }); + expect(result.ok).toBe(true); + const arg = vi.mocked(prisma.user.update).mock.calls[0][0] as { + data: Record; + }; + expect("insurerIkNumber" in arg.data).toBe(false); + }); + + it("rejects a non-numeric IKNR with 422", async () => { + const result = await applyProfileUpdate(USER_ID, { + insurerIkNumber: "12345678X", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(422); + expect(result.message).toMatch(/IK number/i); + } + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects an IKNR that is not exactly 9 digits with 422", async () => { + for (const bad of ["1234567", "1234567890"]) { + vi.mocked(prisma.user.update).mockClear(); + const result = await applyProfileUpdate(USER_ID, { + insurerIkNumber: bad, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + } + }); +}); diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index 2ac5b9af2..e2901c223 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -43,6 +43,7 @@ export interface ApplyProfileResult { // boolean presence flag for the encrypted KVNR. fullName: string | null; insurerName: string | null; + insurerIkNumber: string | null; hasInsuranceNumber: boolean; }; } @@ -116,6 +117,11 @@ export async function applyProfileUpdate( ? null : data.insurerName; } + // v1.8.6 — IKNR plaintext (matches insurerName). The Zod transform + // already mapped empty → null, so a null here means "clear it". + if (data.insurerIkNumber !== undefined) { + updates.insurerIkNumber = data.insurerIkNumber ?? null; + } if (data.insuranceNumber !== undefined) { updates.insuranceNumberEncrypted = data.insuranceNumber === null || data.insuranceNumber === "" @@ -172,6 +178,7 @@ export async function applyProfileUpdate( moodReminderEnabled: updatedUser.moodReminderEnabled, fullName: updatedUser.fullName, insurerName: updatedUser.insurerName, + insurerIkNumber: updatedUser.insurerIkNumber, hasInsuranceNumber: updatedUser.insuranceNumberEncrypted != null, }, }; diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index 3be32b62b..939418b6d 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -93,6 +93,7 @@ function buildUser(disableCoach: boolean): AuthUser { disableCoach, fullName: null, insurerName: null, + insurerIkNumber: null, insuranceNumber: null, }; } diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 346fda5de..448b91a42 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -151,6 +151,101 @@ const disableCoachFlag = z "Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.", }); +// v1.8.6 — profile read/write surface for the native client. The +// runtime validation lives in `src/lib/validations/auth.ts` +// (`profileSchema`, whose transforms normalise empty → null and run the +// KVNR/IKNR refines); the OpenAPI shapes below mirror the wire contract +// without those transforms so the spec stays a clean nullable-field +// document. `insurerIkNumber` is the new v1.8.6 field: optional, 9-digit +// German IKNR, surfaced on the FHIR `Coverage` payor. +const profileUpdateRequest = z + .object({ + email: z.email().nullable().optional(), + heightCm: z.number().min(50).max(300).nullable().optional(), + dateOfBirth: z.string().nullable().optional(), + gender: z.enum(["MALE", "FEMALE"]).nullable().optional(), + displayName: z.string().min(1).max(80).nullable().optional(), + locale: z.enum(["de", "en"]).nullable().optional(), + timezone: z.string().min(1).max(64).optional(), + moodReminderEnabled: z.boolean().optional(), + fullName: z.string().max(120).nullable().optional(), + insurerName: z.string().max(120).nullable().optional(), + insuranceNumber: z + .string() + .nullable() + .optional() + .describe("German KVNR. Empty/null clears it; mod-10 check enforced."), + insurerIkNumber: z + .string() + .nullable() + .optional() + .describe( + "German insurer institution number (IKNR). Optional; empty/null clears it. A non-empty value must be exactly 9 digits (no checksum enforced).", + ), + }) + .meta({ + id: "ProfileUpdateRequest", + description: + "Partial profile update. Every field is optional; an omitted field is left untouched, an explicit null (or empty string) clears it. `userId` is never accepted — it is narrowed from the session/token.", + }); + +const profileResponse = z + .object({ + username: z.string(), + displayName: z.string().nullable().optional(), + email: z.string().nullable(), + dateOfBirth: z.iso.datetime({ offset: true }).nullable(), + gender: z.enum(["MALE", "FEMALE"]).nullable(), + heightCm: z.number().nullable(), + locale: z.string().nullable(), + timezone: z.string(), + moodReminderEnabled: z.boolean(), + fullName: z.string().nullable(), + insurerName: z.string().nullable(), + insurerIkNumber: z + .string() + .nullable() + .describe("German insurer institution number (IKNR), 9 digits."), + insuranceNumber: z + .string() + .nullable() + .describe("German KVNR, decrypted for the form prefill."), + }) + .meta({ + id: "ProfileResponse", + description: + "Flattened profile fields for the native client (GET). The KVNR is decrypted server-side; the IKNR (v1.8.6) is plaintext at rest.", + }); + +// The PATCH echo mirrors GET but reports KVNR presence as a boolean +// (`hasInsuranceNumber`) rather than re-emitting the decrypted value. +const profileUpdateResponse = z + .object({ + username: z.string(), + displayName: z.string().nullable().optional(), + email: z.string().nullable(), + dateOfBirth: z.iso.datetime({ offset: true }).nullable(), + gender: z.enum(["MALE", "FEMALE"]).nullable(), + heightCm: z.number().nullable(), + locale: z.string().nullable(), + timezone: z.string(), + moodReminderEnabled: z.boolean(), + fullName: z.string().nullable(), + insurerName: z.string().nullable(), + insurerIkNumber: z + .string() + .nullable() + .describe("German insurer institution number (IKNR), 9 digits."), + hasInsuranceNumber: z + .boolean() + .describe("Whether a KVNR is on file (the value itself is not echoed)."), + }) + .meta({ + id: "ProfileUpdateResponse", + description: + "Profile echo returned by the PATCH/PUT update path. Reports KVNR presence as a boolean rather than re-emitting the decrypted value.", + }); + // ── Sub-schemas owned here (route-specific shapes) ─────────────────── const passkeyLoginVerifyRequest = z @@ -2040,6 +2135,49 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/user/profile": { + get: { + tags: ["Auth"], + summary: "Read the calling user's profile", + description: + "Flattened profile fields for the native client. Aliased over the same data exposed by /api/auth/me. The KVNR is decrypted server-side; the IKNR (v1.8.6) is returned plaintext.", + responses: { + "200": { + description: "The calling user's profile.", + content: { + "application/json": { + schema: dataEnvelope(profileResponse, "GetProfileResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Auth"], + summary: "Update the calling user's profile", + description: + "Partial profile update — every field is optional. An explicit null (or empty string) clears a field. A non-empty `insurerIkNumber` must be exactly 9 digits (422 otherwise); a non-empty `insuranceNumber` (KVNR) must pass the mod-10 check. `userId` is never accepted from the body.", + requestBody: { + required: true, + content: { "application/json": { schema: profileUpdateRequest } }, + }, + responses: { + "200": { + description: "Saved profile echoed back.", + content: { + "application/json": { + schema: dataEnvelope( + profileUpdateResponse, + "PatchProfileResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/insights/layout": { get: { tags: ["Insights"], diff --git a/src/lib/validations/auth.ts b/src/lib/validations/auth.ts index 7c3dd841e..e7e94d9db 100644 --- a/src/lib/validations/auth.ts +++ b/src/lib/validations/auth.ts @@ -58,6 +58,23 @@ export const profileSchema = z.object({ .refine((v) => v === null || v === undefined || isValidKvnr(v), { message: "Invalid insurance number (KVNR)", }), + // v1.8.6 — German insurer institution number (IKNR). Optional; + // empty string / null clears it. A non-empty value is trimmed and + // must be exactly 9 digits (no checksum — the IKNR carries a mod-10 + // check digit but we deliberately do not enforce it here, mirroring + // the iOS client contract). Surfaced on the FHIR `Coverage` payor. + insurerIkNumber: z + .string() + .nullable() + .optional() + .transform((v) => { + if (v === null || v === undefined) return v; + const trimmed = v.trim(); + return trimmed.length === 0 ? null : trimmed; + }) + .refine((v) => v === null || v === undefined || /^[0-9]{9}$/.test(v), { + message: "Invalid insurer IK number (expected 9 digits)", + }), }); export const changePasswordSchema = z