feat: Bienenpatenschaft mit Urkunde, Abo, Medaillen und 3D-Konto

Setzt Teilprojekte 1-5 der Spec 2026-10-16 um:
- Urkunden-Engine (lib/certificate): ein Layout fuer PDF, Vorschau und
  3D-Textur, Vorlage classic@1, eingebettete OFL-Schriften, Testmatrix
- Einmal-Patenschaft ueber Traegerprodukte (Kategorie PATENSCHAFT),
  Paket wird im Checkout in Patenschaft (Regelsatz) und Honig aufgeteilt,
  Kontopflicht, rein digitale Bestellungen (FulfillmentMethod DIGITAL)
- Ausstellung nach Zahlung idempotent, Wiederholung im App-Prozess
  (instrumentation + background-jobs), Ablage in CERTIFICATE_DIR
- Verkaufsseite /patenschaft mit Konfigurator und Live-Vorschau,
  Warenkorb mit Schluessel je Position
- Kundenkonto: 3D-Ansicht von Urkunde und Medaille, Downloads, ZIP,
  Uebertragung per befristetem Einmal-Link, Treuestufen bis VIP
- Admin: Angebote, Patenschaften, Druck, Korrektur mit Revisionen,
  Abos, Erntefenster, Deckung, SEPA-Frist, Treuestufen
- Abo ueber Stripe Billing: Lieferung erst nach bezahlten Monaten,
  SEPA-Neukunden-Frist und Rueckbuchungsschutz, Honigreservierung,
  Kuendigungsbutton, Monatsrechnungen, Stripe-Einrichtungsskript

Startangebote werden inaktiv angelegt; Livebetrieb erst nach Freigabe.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 14:07:30 +02:00
1 parent dcb578f062
commit f227aeb8f9
269 files changed
+28054 -141

No files matched your search

@@ -0,0 +1,75 @@
import { prisma } from "@/lib/prisma";
import { defaultLocale, isLocale } from "@/i18n/config";
import { personalizationSchema } from "@/lib/certificate/schema";
import { checkCertificateFits } from "@/lib/certificate/layout";
import { listTemplateVersions } from "@/lib/certificate/templates";
import { renderCertificateDocumentImage } from "@/lib/sponsorship/certificate-document";
import { REVISABLE_FIELDS, revisionTerm } from "@/lib/sponsorship/revise";
import { getAuthorizedAdmin, notFoundResponse } from "../../_lib/admin-guard";
const PREVIEW_WIDTH_PX = 900;
const WATERMARK = "Korrekturvorschau";
/** Ein Korrekturformular ist klein; alles darueber ist kein legitimer Aufruf. */
const MAX_BODY_BYTES = 8 * 1024;
function invalid(body: Record<string, unknown>): Response {
return Response.json(body, { status: 422, headers: { "Cache-Control": "no-store" } });
}
/**
* Live-Vorschau der Korrektur: dieselbe Pruefung (Schema, Glyphen, Platz)
* und dieselbe Laufzeitregel wie reviseSponsorship, gerendert mit dem
* Wasserzeichen "Korrekturvorschau". Ungueltige Eingaben ergeben 422 mit
* Feld und Fehlercode, damit das Formular die Stelle markieren kann.
* Nur fuer Admins mit 2FA, sonst 404.
*/
export async function POST(request: Request, ctx: RouteContext<"/admin/patenschaften/[id]/korrekturvorschau">): Promise<Response> {
if (!(await getAuthorizedAdmin())) return notFoundResponse();
const { id } = await ctx.params;
const sponsorship = await prisma.sponsorship.findUnique({
where: { id },
select: { number: true, locale: true, startDate: true, endDate: true, subscriptionId: true, currentRevision: true },
});
if (!sponsorship || sponsorship.currentRevision === 0) return notFoundResponse();
const raw = await request.text();
if (raw.length > MAX_BODY_BYTES) return invalid({ code: "too_large" });
let body: Record<string, unknown>;
try {
const parsedBody: unknown = JSON.parse(raw);
body = parsedBody && typeof parsedBody === "object" ? (parsedBody as Record<string, unknown>) : {};
} catch {
return invalid({ code: "bad_json" });
}
const isSubscription = sponsorship.subscriptionId !== null;
const startDate = sponsorship.startDate.toISOString().slice(0, 10);
const parsed = personalizationSchema.safeParse(isSubscription ? { ...body, startDate } : body);
if (!parsed.success) {
const issue = parsed.error.issues[0];
const field = REVISABLE_FIELDS.find((name) => name === issue?.path[0]) ?? null;
return invalid({ field, code: issue?.message ?? "invalid" });
}
const templateVersion = typeof body.templateVersion === "string" ? body.templateVersion : "";
if (!listTemplateVersions().includes(templateVersion)) return invalid({ field: null, code: "unknown_template" });
const locale = isLocale(sponsorship.locale) ? sponsorship.locale : defaultLocale;
const term = revisionTerm({
isSubscription,
personalization: parsed.data,
startDate: sponsorship.startDate,
endDate: sponsorship.endDate,
});
const fit = checkCertificateFits(parsed.data, locale, { term, templateVersion, number: sponsorship.number });
if (!fit.ok) {
return invalid({ field: fit.field, code: fit.reason, characters: fit.reason === "glyphs" ? fit.characters : [] });
}
const image = await renderCertificateDocumentImage(
{ personalization: parsed.data, locale, term, number: sponsorship.number, templateVersion },
{ widthPx: PREVIEW_WIDTH_PX, format: "webp", watermark: WATERMARK }
);
return new Response(new Uint8Array(image), {
headers: { "Content-Type": "image/webp", "Cache-Control": "private, no-store" },
});
}