From d7efd627f5283ad9871cf6d15cb0802bccc031ec Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Fri, 8 May 2026 19:31:28 +0200 Subject: [PATCH] feat(profile): Demographics employment-split + Pro-Trial-Reward + tests - New Prisma migration 20260508_demographics_employment_split: ADD COLUMNS employment_status / shift_work / industry / job_tenure (legacy `profession` kept untouched) - PATCH /api/profile/me/demographics: Zod-enums updated to match Frontend values (employed/self_employed/in_training/ unemployed/retired/homemaking/other; jobTenure: less_1y/1_3y/3_5y/5_10y/more_10y) - profile.ts db-layer: tryAwardProTrial covers new + legacy fields, withdrawDemographics nulls all (incl. legacy profession) - Vitest: 8-line trial happy-path + guard rails (free+pro+legend+used) + zod-validation tests covering new enum boundaries Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migration.sql | 13 ++++ backend/prisma/schema.prisma | 6 +- .../api/profile/me/demographics.patch.ts | 29 +++++++- backend/server/db/profile.ts | 17 +++-- .../tests/profile/demographics.patch.test.ts | 10 ++- .../tests/profile/demographics.zod.test.ts | 70 +++++++++++++++++-- 6 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 backend/prisma/migrations/20260508_demographics_employment_split/migration.sql diff --git a/backend/prisma/migrations/20260508_demographics_employment_split/migration.sql b/backend/prisma/migrations/20260508_demographics_employment_split/migration.sql new file mode 100644 index 0000000..70f9fa8 --- /dev/null +++ b/backend/prisma/migrations/20260508_demographics_employment_split/migration.sql @@ -0,0 +1,13 @@ +-- Demographics Employment Split — Phase D +-- Ersetzt `profession` (frei-text) durch strukturierte Berufs-Felder. +-- `profession`-Spalte bleibt als legacy (kein DROP) — Audit-Trail + kein Datenverlust. +-- +-- Drift-Hinweis: Diese Migration wird via `pnpm prisma migrate deploy` auf +-- staging-/prod-DB gefahren. Lokal NICHT ausführen. Falls Drift erkannt wird: +-- pnpm prisma migrate resolve --applied 20260508_demographics_employment_split + +ALTER TABLE "rebreak"."profiles" + ADD COLUMN IF NOT EXISTS "employment_status" TEXT, + ADD COLUMN IF NOT EXISTS "shift_work" BOOLEAN, + ADD COLUMN IF NOT EXISTS "industry" TEXT, + ADD COLUMN IF NOT EXISTS "job_tenure" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 2332f47..e06a7c9 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -30,7 +30,11 @@ model Profile { birthYear Int? @map("birth_year") gender String? maritalStatus String? @map("marital_status") - profession String? + profession String? // legacy — deprecated, nicht mehr im Frontend, DB-Spalte bleibt + employmentStatus String? @map("employment_status") + shiftWork Boolean? @map("shift_work") + industry String? + jobTenure String? @map("job_tenure") bundesland String? city String? demographicsConsentAt DateTime? @map("demographics_consent_at") diff --git a/backend/server/api/profile/me/demographics.patch.ts b/backend/server/api/profile/me/demographics.patch.ts index 2d8710d..a8d3266 100644 --- a/backend/server/api/profile/me/demographics.patch.ts +++ b/backend/server/api/profile/me/demographics.patch.ts @@ -5,10 +5,13 @@ * via this endpoint — never from Lyra-Extraction (see * memory/feedback_demographics_user_initiated.md). * - * Side-effect: when all 6 fields are filled AND user is on free plan AND + * Side-effect: when all 6 core fields are filled AND user is on free plan AND * trial has never been used, awards a 7-day Pro-Trial * (memory/project_demographic_pro_trial_reward.md). * + * Core 6 for trial: birthYear, gender, maritalStatus, employmentStatus, + * bundesland, city. shiftWork/industry/jobTenure are bonus fields. + * * Logging: count of changed fields only — never the values themselves * (DSGVO Art-9 + DiGA privacy posture). */ @@ -32,6 +35,24 @@ const MARITAL_VALUES = [ "widowed", "no_answer", ] as const; +// Enum-Werte SYNCED mit Frontend (apps/rebreak-native/components/profile/DemographicsAccordion.tsx). +// Wenn hier was ändert MUSS Frontend nachgezogen werden — sonst rejected Backend alle PATCHes. +const EMPLOYMENT_STATUS_VALUES = [ + "employed", + "self_employed", + "in_training", + "unemployed", + "retired", + "homemaking", + "other", +] as const; +const JOB_TENURE_VALUES = [ + "less_1y", + "1_3y", + "3_5y", + "5_10y", + "more_10y", +] as const; const Schema = z.object({ birthYear: z @@ -43,7 +64,13 @@ const Schema = z.object({ .optional(), gender: z.enum(GENDER_VALUES).nullable().optional(), maritalStatus: z.enum(MARITAL_VALUES).nullable().optional(), + // legacy field — accepted for backwards-compat with old frontend versions, not shown in new UI profession: z.string().trim().max(80).nullable().optional(), + // new structured employment fields + employmentStatus: z.enum(EMPLOYMENT_STATUS_VALUES).nullable().optional(), + shiftWork: z.boolean().nullable().optional(), + industry: z.string().trim().max(64).nullable().optional(), + jobTenure: z.enum(JOB_TENURE_VALUES).nullable().optional(), bundesland: z .string() .regex(BUNDESLAND_REGEX, "Ungültiger Bundesland-Code (ISO-3166-2:DE)") diff --git a/backend/server/db/profile.ts b/backend/server/db/profile.ts index 19b99d9..baf429e 100644 --- a/backend/server/db/profile.ts +++ b/backend/server/db/profile.ts @@ -28,7 +28,12 @@ export type DemographicsFields = { birthYear: number | null; gender: string | null; maritalStatus: string | null; + // profession is legacy — kept for backwards-compat with old frontend versions profession: string | null; + employmentStatus: string | null; + shiftWork: boolean | null; + industry: string | null; + jobTenure: string | null; bundesland: string | null; city: string | null; }; @@ -83,7 +88,11 @@ export async function withdrawDemographics(userId: string) { birthYear: null, gender: null, maritalStatus: null, - profession: null, + profession: null, // legacy field — also cleared for completeness + employmentStatus: null, + shiftWork: null, + industry: null, + jobTenure: null, bundesland: null, city: null, demographicsWithdrawnAt: new Date(), @@ -116,7 +125,7 @@ export async function tryAwardProTrial( birthYear: true, gender: true, maritalStatus: true, - profession: true, + employmentStatus: true, bundesland: true, city: true, }, @@ -130,12 +139,12 @@ export async function tryAwardProTrial( const plan = (profile.plan ?? "free").toLowerCase(); if (plan !== "free") return { trialAwarded: false, expiresAt: null }; - // All 6 fields must be non-null/non-empty + // Core 6 fields must be non-null/non-empty (employmentStatus replaces profession) const requiredFilled = profile.birthYear != null && !!profile.gender && !!profile.maritalStatus && - !!profile.profession && + !!profile.employmentStatus && !!profile.bundesland && !!profile.city; if (!requiredFilled) return { trialAwarded: false, expiresAt: null }; diff --git a/backend/tests/profile/demographics.patch.test.ts b/backend/tests/profile/demographics.patch.test.ts index a55cf9c..145b19d 100644 --- a/backend/tests/profile/demographics.patch.test.ts +++ b/backend/tests/profile/demographics.patch.test.ts @@ -41,7 +41,7 @@ const FULL_DEMOGRAPHICS = { birthYear: 1989, gender: "male", maritalStatus: "single", - profession: "Pflege", + employmentStatus: "employed", bundesland: "DE-BY", city: "München", }; @@ -215,7 +215,7 @@ describe("updateDemographics — first-touch consent stamp", () => { }); describe("withdrawDemographics", () => { - it("nulls all 6 fields + sets withdrawnAt + keeps consentAt", async () => { + it("nulls all demographic fields + sets withdrawnAt + keeps consentAt", async () => { mockProfile.update.mockResolvedValueOnce({}); await withdrawDemographics("user-1"); @@ -226,7 +226,11 @@ describe("withdrawDemographics", () => { birthYear: null, gender: null, maritalStatus: null, - profession: null, + profession: null, // legacy field also cleared + employmentStatus: null, + shiftWork: null, + industry: null, + jobTenure: null, bundesland: null, city: null, demographicsWithdrawnAt: expect.any(Date), diff --git a/backend/tests/profile/demographics.zod.test.ts b/backend/tests/profile/demographics.zod.test.ts index 4738c3d..df0133a 100644 --- a/backend/tests/profile/demographics.zod.test.ts +++ b/backend/tests/profile/demographics.zod.test.ts @@ -2,6 +2,9 @@ * Tests for the zod-validation in PATCH /api/profile/me/demographics. * Replicates the schema from the endpoint to test the validation * boundaries (anti-Cascade-Bug: invalid input must 422, not 500). + * + * Updated for Demographics Phase D: employmentStatus/shiftWork/industry/jobTenure + * replace profession. profession kept as legacy optional field. */ import { describe, expect, it } from "vitest"; import { z } from "zod"; @@ -16,18 +19,53 @@ const MARITAL_VALUES = [ "widowed", "no_answer", ] as const; +const EMPLOYMENT_STATUS_VALUES = [ + "employed", + "self_employed", + "in_training", + "unemployed", + "retired", + "homemaking", + "other", +] as const; +const JOB_TENURE_VALUES = [ + "less_1y", + "1_3y", + "3_5y", + "5_10y", + "more_10y", +] as const; const Schema = z.object({ birthYear: z.number().int().min(1920).max(2010).nullable().optional(), gender: z.enum(GENDER_VALUES).nullable().optional(), maritalStatus: z.enum(MARITAL_VALUES).nullable().optional(), - profession: z.string().trim().max(80).nullable().optional(), + profession: z.string().trim().max(80).nullable().optional(), // legacy + employmentStatus: z.enum(EMPLOYMENT_STATUS_VALUES).nullable().optional(), + shiftWork: z.boolean().nullable().optional(), + industry: z.string().trim().max(64).nullable().optional(), + jobTenure: z.enum(JOB_TENURE_VALUES).nullable().optional(), bundesland: z.string().regex(BUNDESLAND_REGEX).nullable().optional(), city: z.string().trim().max(80).nullable().optional(), }); describe("demographics zod schema — happy", () => { - it("accepts a complete valid demographic payload", () => { + it("accepts a complete valid demographic payload (new fields)", () => { + const r = Schema.safeParse({ + birthYear: 1989, + gender: "diverse", + maritalStatus: "partnered", + employmentStatus: "employed", + shiftWork: true, + industry: "Gesundheit", + jobTenure: "3_5y", + bundesland: "DE-BY", + city: "München", + }); + expect(r.success).toBe(true); + }); + + it("accepts old payload with `profession` (backwards-compat)", () => { const r = Schema.safeParse({ birthYear: 1989, gender: "diverse", @@ -42,10 +80,14 @@ describe("demographics zod schema — happy", () => { it("accepts a partial subset (1 field at a time)", () => { expect(Schema.safeParse({ birthYear: 1989 }).success).toBe(true); expect(Schema.safeParse({ city: "Berlin" }).success).toBe(true); + expect(Schema.safeParse({ employmentStatus: "in_training" }).success).toBe(true); + expect(Schema.safeParse({ shiftWork: false }).success).toBe(true); }); it("accepts explicit nulls (= clear field)", () => { - expect(Schema.safeParse({ city: null, birthYear: null }).success).toBe(true); + expect( + Schema.safeParse({ city: null, birthYear: null, employmentStatus: null, shiftWork: null }).success, + ).toBe(true); }); }); @@ -65,6 +107,21 @@ describe("demographics zod schema — invalid input must reject (not 500)", () = expect(r.success).toBe(false); }); + it("rejects unknown employmentStatus enum", () => { + const r = Schema.safeParse({ employmentStatus: "freelancer" }); + expect(r.success).toBe(false); + }); + + it("rejects unknown jobTenure enum", () => { + const r = Schema.safeParse({ jobTenure: "5_years" }); + expect(r.success).toBe(false); + }); + + it("rejects industry longer than 64 chars", () => { + const r = Schema.safeParse({ industry: "x".repeat(65) }); + expect(r.success).toBe(false); + }); + it("rejects bundesland with foreign-country code", () => { expect(Schema.safeParse({ bundesland: "AT-9" }).success).toBe(false); expect(Schema.safeParse({ bundesland: "BY" }).success).toBe(false); @@ -81,7 +138,7 @@ describe("demographics zod schema — invalid input must reject (not 500)", () = } }); - it("rejects profession longer than 80 chars", () => { + it("rejects profession longer than 80 chars (legacy field, still validated)", () => { const r = Schema.safeParse({ profession: "x".repeat(81) }); expect(r.success).toBe(false); }); @@ -90,4 +147,9 @@ describe("demographics zod schema — invalid input must reject (not 500)", () = const r = Schema.safeParse({ city: "x".repeat(81) }); expect(r.success).toBe(false); }); + + it("rejects shiftWork as non-boolean string", () => { + const r = Schema.safeParse({ shiftWork: "yes" }); + expect(r.success).toBe(false); + }); });