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>
64 lines
1.8 KiB
TypeScript
64 lines
1.8 KiB
TypeScript
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 };
|
|
});
|
|
}
|