feat(diga): redeem-code endpoint + 10 test codes seeded
DiGA-Pfad-Foundation: User mit Rezept-Code löst im Onboarding ein, wird auf plan='legend' hochgestuft (Default), Onboarding-Step springt auf 'done', diga_code_redeemed_at als Audit-Trail. Trial-Modell wird übersprungen. - Prisma model DigaCode (code unique, expires_at, used_at, used_by_profile_id, grants_plan, notes, label) - Profile.digaCodeRedeemedAt für Reverse-Audit - Migration 20260517_add_diga_codes mit Table + FK + Index - Seed: REBREAK-TEST-001..010 (single-use, reset via SQL für erneutes Testen) - POST /api/onboarding/redeem-diga-code — atomare Transaction, klare 400-Errors (not_found | already_used | expired | invalid_input) Frontend (Duo-Onboarding) dockt später an — diese Backend-Foundation steht. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
1c9e67c256
commit
68074aa7b7
@ -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;
|
||||||
@ -58,6 +58,14 @@ model Profile {
|
|||||||
// wiederherzustellen — auch nach App-Reinstall (DB-State statt AsyncStorage).
|
// wiederherzustellen — auch nach App-Reinstall (DB-State statt AsyncStorage).
|
||||||
onboardingStep String @default("welcome") @map("onboarding_step")
|
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)) ──
|
// ─── Install-Tracking (für Streak: max(last_resolved_cooldown, last_install)) ──
|
||||||
lastInstallAt DateTime? @map("last_install_at")
|
lastInstallAt DateTime? @map("last_install_at")
|
||||||
|
|
||||||
@ -96,6 +104,32 @@ model Profile {
|
|||||||
@@schema("rebreak")
|
@@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 {
|
model Streak {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
|
|||||||
48
backend/server/api/onboarding/redeem-diga-code.post.ts
Normal file
48
backend/server/api/onboarding/redeem-diga-code.post.ts
Normal file
@ -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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
63
backend/server/db/diga.ts
Normal file
63
backend/server/db/diga.ts
Normal file
@ -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<RedeemResult> {
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user