diff --git a/backend/prisma/migrations/20260517_add_diga_codes/migration.sql b/backend/prisma/migrations/20260517_add_diga_codes/migration.sql new file mode 100644 index 0000000..2d59b74 --- /dev/null +++ b/backend/prisma/migrations/20260517_add_diga_codes/migration.sql @@ -0,0 +1,48 @@ +-- DiGA-Codes (Rezept-Einlösung für Krankenkassen-Pfad) + Profile-Audit-Feld. +-- +-- Codes werden im Onboarding eingelöst → Plan hochstufung (Default 'legend') +-- + Trial-Modell übersprungen. Reverse-Lookup über used_by_profile_id. +-- +-- Test-Codes werden in DIESER Migration mit-geseeded (REBREAK-TEST-001..010). +-- Wiederverwendung nur per manuellem SQL-Reset. + +-- ─── Profile-Audit-Feld ────────────────────────────────────────────────────── + +ALTER TABLE "rebreak"."profiles" + ADD COLUMN IF NOT EXISTS "diga_code_redeemed_at" TIMESTAMPTZ NULL; + +-- ─── DiGA-Codes-Tabelle ───────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS "rebreak"."diga_codes" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "code" TEXT NOT NULL, + "label" TEXT, + "expires_at" TIMESTAMPTZ, + "used_at" TIMESTAMPTZ, + "used_by_profile_id" UUID, + "grants_plan" TEXT NOT NULL DEFAULT 'legend', + "notes" TEXT, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT "diga_codes_pkey" PRIMARY KEY ("id"), + CONSTRAINT "diga_codes_code_unique" UNIQUE ("code"), + CONSTRAINT "diga_codes_used_by_fk" FOREIGN KEY ("used_by_profile_id") + REFERENCES "rebreak"."profiles"("id") ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS "diga_codes_used_by_idx" + ON "rebreak"."diga_codes"("used_by_profile_id"); + +-- ─── Seed: 10 Test-Codes ──────────────────────────────────────────────────── + +INSERT INTO "rebreak"."diga_codes" ("code", "label", "grants_plan", "notes") VALUES + ('REBREAK-TEST-001', 'test_batch_2026-05', 'legend', 'Internal test code (single-use, reset via SQL)'), + ('REBREAK-TEST-002', 'test_batch_2026-05', 'legend', 'Internal test code (single-use, reset via SQL)'), + ('REBREAK-TEST-003', 'test_batch_2026-05', 'legend', 'Internal test code (single-use, reset via SQL)'), + ('REBREAK-TEST-004', 'test_batch_2026-05', 'legend', 'Internal test code (single-use, reset via SQL)'), + ('REBREAK-TEST-005', 'test_batch_2026-05', 'legend', 'Internal test code (single-use, reset via SQL)'), + ('REBREAK-TEST-006', 'test_batch_2026-05', 'legend', 'Internal test code (single-use, reset via SQL)'), + ('REBREAK-TEST-007', 'test_batch_2026-05', 'legend', 'Internal test code (single-use, reset via SQL)'), + ('REBREAK-TEST-008', 'test_batch_2026-05', 'legend', 'Internal test code (single-use, reset via SQL)'), + ('REBREAK-TEST-009', 'test_batch_2026-05', 'legend', 'Internal test code (single-use, reset via SQL)'), + ('REBREAK-TEST-010', 'test_batch_2026-05', 'legend', 'Internal test code (single-use, reset via SQL)') +ON CONFLICT ("code") DO NOTHING; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 61962ce..d8cbe75 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -58,6 +58,14 @@ model Profile { // wiederherzustellen — auch nach App-Reinstall (DB-State statt AsyncStorage). onboardingStep String @default("welcome") @map("onboarding_step") + // ─── DiGA Rezept-Code-Audit ───────────────────────────────────────────── + // Wenn ein User per Krankenkassen-Rezept reinkommt (DiGA-Pfad), wird der + // Einlöse-Zeitpunkt hier persistiert. Reverse-Lookup auf den Code selbst + // läuft über diga_codes.used_by_profile_id (1:n). + digaCodeRedeemedAt DateTime? @map("diga_code_redeemed_at") + + digaCodes DigaCode[] + // ─── Install-Tracking (für Streak: max(last_resolved_cooldown, last_install)) ── lastInstallAt DateTime? @map("last_install_at") @@ -96,6 +104,32 @@ model Profile { @@schema("rebreak") } +// ─── DiGA-Codes (Rezept-Einlösung für Krankenkassen-Pfad) ───────────────────── +// +// Codes werden vom Backend ausgegeben (später per Krankenkassen-API erstellt +// + an Patient per E-Mail/Brief geschickt) und im Onboarding eingelöst. Bei +// Einlösung wird der User auf den DiGA-Vollzugang (`grants_plan`, Default +// 'legend') hochgestuft und das Trial-Modell übersprungen. +// +// Test-Codes (label='test_*') werden vom Seed angelegt. Wiederverwendung +// nur per SQL-Reset: UPDATE diga_codes SET used_at=NULL, used_by_profile_id=NULL. +model DigaCode { + id String @id @default(uuid()) @db.Uuid + code String @unique + label String? // z.B. 'test_batch_2026-05' oder Krankenkassen-Code + expiresAt DateTime? @map("expires_at") + usedAt DateTime? @map("used_at") + usedByProfileId String? @map("used_by_profile_id") @db.Uuid + usedByProfile Profile? @relation(fields: [usedByProfileId], references: [id], onDelete: SetNull) + grantsPlan String @default("legend") @map("grants_plan") + notes String? + createdAt DateTime @default(now()) @map("created_at") + + @@index([usedByProfileId]) + @@map("diga_codes") + @@schema("rebreak") +} + model Streak { id String @id @default(uuid()) @db.Uuid userId String @map("user_id") @db.Uuid diff --git a/backend/server/api/onboarding/redeem-diga-code.post.ts b/backend/server/api/onboarding/redeem-diga-code.post.ts new file mode 100644 index 0000000..c5ce572 --- /dev/null +++ b/backend/server/api/onboarding/redeem-diga-code.post.ts @@ -0,0 +1,48 @@ +import { redeemDigaCode } from "../../db/diga"; + +/** + * POST /api/onboarding/redeem-diga-code + * + * Löst einen DiGA-Rezept-Code ein und stuft den User auf Vollzugang hoch + * (`grants_plan`, Default 'legend'). Skipped Trial + Onboarding (step='done'). + * + * Body: { code: string } + * Response 200: { success: true, plan: 'legend' } + * Response 400: { error: 'not_found' | 'already_used' | 'expired' | 'invalid_input' } + * + * Test-Codes (siehe Migration `20260517_add_diga_codes`): REBREAK-TEST-001..010 + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const body = await readBody(event).catch(() => ({})); + const code = body?.code; + + if (typeof code !== "string" || code.trim().length === 0) { + throw createError({ + statusCode: 400, + data: { error: "invalid_input", message: "Code fehlt." }, + }); + } + + const res = await redeemDigaCode(user.id, code); + + if (!res.ok) { + throw createError({ + statusCode: 400, + data: { error: res.reason, message: errorMessage(res.reason) }, + }); + } + + return { success: true, plan: res.plan }; +}); + +function errorMessage(reason: "not_found" | "already_used" | "expired"): string { + switch (reason) { + case "not_found": + return "Dieser Code existiert nicht. Bitte prüfe die Schreibweise."; + case "already_used": + return "Dieser Code wurde bereits eingelöst."; + case "expired": + return "Dieser Code ist abgelaufen."; + } +} diff --git a/backend/server/db/diga.ts b/backend/server/db/diga.ts new file mode 100644 index 0000000..0cb3e0d --- /dev/null +++ b/backend/server/db/diga.ts @@ -0,0 +1,63 @@ +import { usePrisma } from "../utils/prisma"; + +/** + * Versucht einen DiGA-Code für den User einzulösen. + * + * Validierung: + * - Code existiert + * - Code wurde noch nicht eingelöst (used_at IS NULL) + * - Code ist nicht abgelaufen (expires_at IS NULL OR expires_at > NOW) + * + * Bei Erfolg (atomar in einer Transaktion): + * - diga_codes: used_at = NOW(), used_by_profile_id = userId + * - profiles: plan = code.grants_plan, onboarding_step = 'done', + * diga_code_redeemed_at = NOW() + * + * Returns `null` wenn der Code ungültig ist (für saubere 4xx-Errors im Endpoint). + */ +export type RedeemResult = + | { ok: true; plan: string; codeId: string } + | { ok: false; reason: "not_found" | "already_used" | "expired" }; + +export async function redeemDigaCode( + userId: string, + rawCode: string, +): Promise { + const code = rawCode.trim().toUpperCase(); + const db = usePrisma(); + + return db.$transaction(async (tx) => { + const found = await tx.digaCode.findUnique({ + where: { code }, + select: { id: true, usedAt: true, expiresAt: true, grantsPlan: true }, + }); + + if (!found) { + return { ok: false as const, reason: "not_found" as const }; + } + if (found.usedAt) { + return { ok: false as const, reason: "already_used" as const }; + } + if (found.expiresAt && found.expiresAt.getTime() < Date.now()) { + return { ok: false as const, reason: "expired" as const }; + } + + const now = new Date(); + + await tx.digaCode.update({ + where: { id: found.id }, + data: { usedAt: now, usedByProfileId: userId }, + }); + + await tx.profile.update({ + where: { id: userId }, + data: { + plan: found.grantsPlan, + onboardingStep: "done", + digaCodeRedeemedAt: now, + }, + }); + + return { ok: true as const, plan: found.grantsPlan, codeId: found.id }; + }); +}