mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
9ecd798119
commit
b99b07ad38
15 files changed
+645
No files matched your search
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user