mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +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
@@ -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