chahinebrini 68074aa7b7 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>
2026-05-17 15:52:53 +02:00

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 };
});
}