From 0e94ddb68a500ee2d64141679b536e1d8efa0afc Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Fri, 8 May 2026 21:31:53 +0200 Subject: [PATCH] feat(api): GET /api/profile/me/demographics endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-counterpart zum existierenden PATCH/DELETE. Frontend braucht den endpoint um nach Page-Reload die schon-gespeicherten Werte zu fetchen — sonst sieht User leere Felder und denkt save funktioniert nicht. - backend/server/db/profile.ts: getDemographics(userId) — SELECT der 9 fields + demographics_consent_at + demographics_withdrawn_at - backend/server/api/profile/me/demographics.get.ts: requireUser + getDemographics + ISO-string conversion. 404 wenn Profile-row fehlt. - backend/tests/profile/demographics.get.test.ts: 5 vitest cases (null fields, 404, populated, withdrawn, 401) Response shape kompatibel mit PATCH-input (gleiche field names, camelCase) plus metadata consentAt/withdrawnAt. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../server/api/profile/me/demographics.get.ts | 18 ++ backend/server/db/profile.ts | 35 ++++ .../tests/profile/demographics.get.test.ts | 165 ++++++++++++++++++ 3 files changed, 218 insertions(+) create mode 100644 backend/server/api/profile/me/demographics.get.ts create mode 100644 backend/tests/profile/demographics.get.test.ts diff --git a/backend/server/api/profile/me/demographics.get.ts b/backend/server/api/profile/me/demographics.get.ts new file mode 100644 index 0000000..3b28e60 --- /dev/null +++ b/backend/server/api/profile/me/demographics.get.ts @@ -0,0 +1,18 @@ +/** + * GET /api/profile/me/demographics + * + * Returns the 9 demographic fields + 2 consent-timestamps for the current + * user. All fields are null when not yet filled. Frontend uses this on + * page-open to hydrate the DemographicsAccordion form. + * + * DSGVO note: only the authenticated user can read their own demographics. + * Fields are never exposed in public profile endpoints. + */ +import { requireUser } from "../../../utils/auth"; +import { getDemographics } from "../../../db/profile"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const data = await getDemographics(user.id); + return { success: true, data }; +}); diff --git a/backend/server/db/profile.ts b/backend/server/db/profile.ts index baf429e..51977b8 100644 --- a/backend/server/db/profile.ts +++ b/backend/server/db/profile.ts @@ -101,6 +101,41 @@ export async function withdrawDemographics(userId: string) { }); } +/** Read demographic fields + consent-state for the current user. */ +export async function getDemographics(userId: string) { + const db = usePrisma(); + const row = await db.profile.findUnique({ + where: { id: userId }, + select: { + birthYear: true, + gender: true, + maritalStatus: true, + employmentStatus: true, + shiftWork: true, + industry: true, + jobTenure: true, + bundesland: true, + city: true, + demographicsConsentAt: true, + demographicsWithdrawnAt: true, + }, + }); + if (!row) throw createError({ statusCode: 404, message: "Profil nicht gefunden" }); + return { + birthYear: row.birthYear, + gender: row.gender, + maritalStatus: row.maritalStatus, + employmentStatus: row.employmentStatus, + shiftWork: row.shiftWork, + industry: row.industry, + jobTenure: row.jobTenure, + bundesland: row.bundesland, + city: row.city, + consentAt: row.demographicsConsentAt?.toISOString() ?? null, + withdrawnAt: row.demographicsWithdrawnAt?.toISOString() ?? null, + }; +} + // ─── Pro-Trial-Reward ────────────────────────────────────────────────────── export const PRO_TRIAL_DAYS = 7; diff --git a/backend/tests/profile/demographics.get.test.ts b/backend/tests/profile/demographics.get.test.ts new file mode 100644 index 0000000..bfd87bc --- /dev/null +++ b/backend/tests/profile/demographics.get.test.ts @@ -0,0 +1,165 @@ +/** + * Tests for GET /api/profile/me/demographics + * + * Covers: + * - returns all-null fields when Profile row has no demographics yet + * - returns 401 when not authenticated + * - returns saved values when row is populated + * - returns withdrawnAt when data was withdrawn + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mocks = vi.hoisted(() => ({ + profile: { + findUnique: vi.fn(), + }, + requireUser: vi.fn(), +})); + +vi.mock("../../server/utils/prisma", () => ({ + usePrisma: () => ({ profile: mocks.profile }), +})); + +vi.mock("../../server/utils/auth", () => ({ + requireUser: mocks.requireUser, +})); + +import { getDemographics } from "../../server/db/profile"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ─── getDemographics DB-layer ───────────────────────────────────────────── + +describe("getDemographics — all-null row", () => { + it("returns null for all 9 demographic fields + consent fields when not yet filled", async () => { + mocks.profile.findUnique.mockResolvedValueOnce({ + birthYear: null, + gender: null, + maritalStatus: null, + employmentStatus: null, + shiftWork: null, + industry: null, + jobTenure: null, + bundesland: null, + city: null, + demographicsConsentAt: null, + demographicsWithdrawnAt: null, + }); + + const result = await getDemographics("user-1"); + + expect(result).toEqual({ + birthYear: null, + gender: null, + maritalStatus: null, + employmentStatus: null, + shiftWork: null, + industry: null, + jobTenure: null, + bundesland: null, + city: null, + consentAt: null, + withdrawnAt: null, + }); + + expect(mocks.profile.findUnique).toHaveBeenCalledWith({ + where: { id: "user-1" }, + select: expect.objectContaining({ + birthYear: true, + gender: true, + demographicsConsentAt: true, + demographicsWithdrawnAt: true, + }), + }); + }); +}); + +describe("getDemographics — 404 when profile missing", () => { + it("throws 404 when profile row does not exist", async () => { + mocks.profile.findUnique.mockResolvedValueOnce(null); + + await expect(getDemographics("ghost-user")).rejects.toMatchObject({ + statusCode: 404, + }); + }); +}); + +describe("getDemographics — populated row", () => { + it("returns ISO strings for dates and correct field values", async () => { + const consentDate = new Date("2026-04-01T10:00:00Z"); + mocks.profile.findUnique.mockResolvedValueOnce({ + birthYear: 1989, + gender: "male", + maritalStatus: "single", + employmentStatus: "employed", + shiftWork: false, + industry: "IT", + jobTenure: "3_5y", + bundesland: "DE-BY", + city: "München", + demographicsConsentAt: consentDate, + demographicsWithdrawnAt: null, + }); + + const result = await getDemographics("user-2"); + + expect(result.birthYear).toBe(1989); + expect(result.gender).toBe("male"); + expect(result.consentAt).toBe("2026-04-01T10:00:00.000Z"); + expect(result.withdrawnAt).toBeNull(); + }); +}); + +describe("getDemographics — withdrawn row", () => { + it("returns withdrawnAt as ISO string when data was withdrawn", async () => { + const consentDate = new Date("2026-03-01T00:00:00Z"); + const withdrawnDate = new Date("2026-04-15T12:00:00Z"); + mocks.profile.findUnique.mockResolvedValueOnce({ + birthYear: null, + gender: null, + maritalStatus: null, + employmentStatus: null, + shiftWork: null, + industry: null, + jobTenure: null, + bundesland: null, + city: null, + demographicsConsentAt: consentDate, + demographicsWithdrawnAt: withdrawnDate, + }); + + const result = await getDemographics("user-3"); + + expect(result.consentAt).toBe("2026-03-01T00:00:00.000Z"); + expect(result.withdrawnAt).toBe("2026-04-15T12:00:00.000Z"); + // all data fields nulled after withdrawal + expect(result.birthYear).toBeNull(); + expect(result.city).toBeNull(); + }); +}); + +// ─── 401 guard (endpoint-level) ─────────────────────────────────────────── +// We test the auth guard by importing the handler and simulating a +// requireUser rejection — no real Nitro boot needed. + +describe("demographics.get endpoint — 401 when not authenticated", () => { + it("propagates the 401 error from requireUser", async () => { + const authError = Object.assign(new Error("Unauthorized"), { + statusCode: 401, + }); + mocks.requireUser.mockRejectedValueOnce(authError); + + // Import handler (defineEventHandler stub in setup.ts returns the fn as-is) + const mod = await import( + "../../server/api/profile/me/demographics.get" + ); + const handler = + typeof mod.default === "function" ? mod.default : (mod.default as { handler?: unknown }).handler; + + await expect( + (handler as (e: unknown) => Promise)({ body: null }), + ).rejects.toMatchObject({ statusCode: 401 }); + }); +});