feat(profile): capture the insurer IK number

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 23:24:10 +02:00
1 parent 9ecd798119
commit b99b07ad38
15 files changed
+645

No files matched your search

+284
View File
@@ -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:
@@ -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;
+10
View File
@@ -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")
}
+1
View File
@@ -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,
});
});
+1
View File
@@ -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,
});
});
@@ -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();
});
});
+3
View File
@@ -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,
});
});
@@ -59,6 +59,7 @@ function buildUser(disableCoach: boolean): AuthUser {
disableCoach,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
};
}
@@ -50,6 +50,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser {
disableCoach: false,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
};
}
+5
View File
@@ -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;
}
@@ -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<string, unknown>;
};
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<string, unknown>;
};
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<string, unknown>;
};
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<string, unknown>;
};
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<string, unknown>;
};
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();
}
});
});
+7
View File
@@ -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,
},
};
@@ -93,6 +93,7 @@ function buildUser(disableCoach: boolean): AuthUser {
disableCoach,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
};
}
+138
View File
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
},
"/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"],
+17
View File
@@ -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