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" },
});
}
@@ -0,0 +1,69 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { startDateRange } from "@/lib/certificate/schema";
import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates";
import { parseStoredPersonalization, toIsoDate } from "@/lib/sponsorship/certificate-document";
import { getLocale } from "@/i18n/locale";
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
import { reviseSponsorshipAction } from "@/app/admin/actions";
import { CorrectionForm } from "../../_components/correction-form";
export default async function AdminSponsorshipCorrectionPage(props: PageProps<"/admin/patenschaften/[id]/korrigieren">) {
const locale = await getLocale();
const t = getSponsorshipAdminStrings(locale).correction;
const { id } = await props.params;
const sponsorship = await prisma.sponsorship.findUnique({
where: { id },
select: {
id: true,
number: true,
currentRevision: true,
subscriptionId: true,
createdAt: true,
orderItem: { select: { order: { select: { createdAt: true } } } },
},
});
if (!sponsorship || sponsorship.currentRevision === 0) notFound();
const revision = await prisma.sponsorshipRevision.findUnique({
where: { sponsorshipId_revision: { sponsorshipId: id, revision: sponsorship.currentRevision } },
select: { personalization: true, templateVersion: true },
});
if (!revision) notFound();
const current = parseStoredPersonalization({ personalization: revision.personalization, templateVersion: revision.templateVersion });
const isSubscription = sponsorship.subscriptionId !== null;
// Dasselbe Fenster wie beim Kauf, gerechnet ab dem Bestelltag (reviseSponsorship prueft es erneut).
const orderDate = toIsoDate(sponsorship.orderItem?.order.createdAt ?? sponsorship.createdAt);
const templateOptions = [
{ value: current.templateVersion, label: t.templatePrevious(current.templateVersion) },
...(current.templateVersion !== CURRENT_TEMPLATE_VERSION
? [{ value: CURRENT_TEMPLATE_VERSION, label: t.templateLatest(CURRENT_TEMPLATE_VERSION) }]
: []),
];
return (
<div className="max-w-6xl">
<BackLink href={`/admin/patenschaften/${id}`} label={t.backToDetail} />
<h1 className="mb-2 text-2xl text-heading">{t.title(sponsorship.number)}</h1>
<p className="mb-8 max-w-2xl text-sm text-ink/70">{t.intro}</p>
<CorrectionForm
sponsorshipId={id}
locale={locale}
action={reviseSponsorshipAction}
isSubscription={isSubscription}
startRange={isSubscription ? null : startDateRange(orderDate)}
templateOptions={templateOptions}
initial={{
beeName: current.personalization.beeName,
recipientName: current.personalization.recipientName,
giverNames: current.personalization.giverNames ?? "",
dedication: current.personalization.dedication ?? "",
occasion: current.personalization.occasion,
startDate: current.personalization.startDate,
templateVersion: current.templateVersion,
}}
/>
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
import type { ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { AdminBadge } from "@/components/admin-list";
import { formatDate, formatDateTime } from "@/lib/format";
import { MAX_ISSUE_ATTEMPTS } from "@/lib/sponsorship/issue";
import { getLocale } from "@/i18n/locale";
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
import { SponsorshipStatusBadge } from "../_components/status-badge";
import { SponsorshipActions } from "../_components/sponsorship-actions";
import { RevisionTable, TransferTable } from "../_components/history-tables";
import { beeNameFromJson } from "../_lib/revision-data";
// A4 hochkant: 595.28 × 841.89 pt — die Vorschau wird in 900 px Breite gerendert.
const PREVIEW_WIDTH = 900;
const PREVIEW_HEIGHT = Math.round((PREVIEW_WIDTH * 841.89) / 595.28);
function Panel({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="border border-ink/10 bg-background p-6">
<h2 className="mb-3 text-xs tracking-wide-caps text-ink/65">{title}</h2>
{children}
</section>
);
}
export default async function AdminSponsorshipDetailPage(props: PageProps<"/admin/patenschaften/[id]">) {
const locale = await getLocale();
const t = getSponsorshipAdminStrings(locale);
const td = t.detail;
const { id } = await props.params;
const sponsorship = await prisma.sponsorship.findUnique({
where: { id },
include: {
owner: { select: { id: true, email: true, firstName: true, lastName: true } },
offer: { select: { id: true, name: true, kind: true, isPhysical: true } },
orderItem: { select: { order: { select: { id: true, orderNumber: true } } } },
subscription: { select: { id: true, status: true } },
revisions: { orderBy: { revision: "desc" } },
transfers: { orderBy: { createdAt: "desc" } },
},
});
if (!sponsorship) notFound();
const accountIds = [...new Set(sponsorship.transfers.flatMap((row) => [row.createdById, row.claimedById ?? ""]).filter(Boolean))];
const accounts =
accountIds.length > 0
? await prisma.customer.findMany({ where: { id: { in: accountIds } }, select: { id: true, email: true } })
: [];
const emailOf = new Map(accounts.map((account) => [account.id, account.email]));
const current = sponsorship.revisions.find((row) => row.revision === sponsorship.currentRevision) ?? null;
const beeName = beeNameFromJson(current?.personalization) ?? "—";
const start = formatDate(sponsorship.startDate, locale);
const order = sponsorship.orderItem?.order ?? null;
return (
<div className="max-w-5xl">
<BackLink href="/admin/patenschaften" label={td.backToList} />
<div className="mb-8 flex flex-wrap items-start justify-between gap-4">
<div>
<p className="font-mono text-xs text-ink/60">{sponsorship.number}</p>
<h1 className="mt-1 font-serif text-3xl text-heading">{beeName}</h1>
<div className="mt-3 flex flex-wrap items-center gap-2">
<SponsorshipStatusBadge status={sponsorship.status} label={t.statuses[sponsorship.status]} />
<AdminBadge tone="neutral">{t.kinds[sponsorship.offer.kind]}</AdminBadge>
</div>
</div>
</div>
<div className="mb-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<Panel title={td.ownerHeading}>
<p>
{sponsorship.owner.firstName} {sponsorship.owner.lastName}
</p>
<p className="break-all text-sm text-ink/70">{sponsorship.owner.email}</p>
<Link href={`/admin/benutzer/${sponsorship.owner.id}`} className="link-draw mt-2 inline-block text-sm text-heading">
{td.openAccount}
</Link>
</Panel>
<Panel title={td.sourceHeading}>
{order ? (
<Link href={`/admin/bestellungen/${order.id}`} className="link-draw text-heading">
{td.order(order.orderNumber)}
</Link>
) : sponsorship.subscription ? (
<Link href="/admin/patenschaften/abos" className="link-draw text-heading">
{td.subscription} · {t.subscriptionStatuses[sponsorship.subscription.status]}
</Link>
) : (
<p className="text-sm text-ink/65">{td.noSource}</p>
)}
</Panel>
<Panel title={td.offerHeading}>
<Link href={`/admin/patenschaften/angebote/${sponsorship.offer.id}`} className="link-draw text-heading">
{sponsorship.offer.name}
</Link>
<p className="mt-1 text-sm text-ink/65">{sponsorship.offer.isPhysical ? td.physical : td.digital}</p>
</Panel>
<Panel title={td.termHeading}>
<p className="tabular-nums">
{sponsorship.endDate ? t.common.range(start, formatDate(sponsorship.endDate, locale)) : td.termOpen(start)}
</p>
</Panel>
</div>
<div className="mb-8 grid gap-6 lg:grid-cols-5">
<section className="border border-ink/10 bg-background p-6 lg:col-span-2">
<h2 className="mb-4 text-xs tracking-wide-caps text-ink/65">{td.previewHeading}</h2>
{current ? (
<Image
src={`/admin/patenschaften/${sponsorship.id}/vorschau.webp?r=${sponsorship.currentRevision}`}
alt={td.previewAlt(beeName, sponsorship.currentRevision)}
width={PREVIEW_WIDTH}
height={PREVIEW_HEIGHT}
unoptimized
className="h-auto w-full border border-ink/10 shadow-[0_18px_40px_-28px_rgba(36,53,43,0.6)]"
/>
) : (
<p className="text-sm text-ink/65">{td.noRevision}</p>
)}
</section>
<div className="flex flex-col gap-6 lg:col-span-3">
<Panel title={td.actionsHeading}>
<SponsorshipActions
id={sponsorship.id}
status={sponsorship.status}
currentRevision={sponsorship.currentRevision}
isPhysical={sponsorship.offer.isPhysical}
printed={sponsorship.printedAt !== null}
ownerEmail={sponsorship.owner.email}
t={td}
/>
</Panel>
<Panel title={td.deliveryHeading}>
<ul className="space-y-1 text-sm">
<li className="tabular-nums">{td.attempts(sponsorship.pdfAttempts, MAX_ISSUE_ATTEMPTS)}</li>
{sponsorship.nextAttemptAt ? <li>{td.nextAttempt(formatDateTime(sponsorship.nextAttemptAt, locale))}</li> : null}
<li>{sponsorship.mailSentAt ? td.mailSent(formatDateTime(sponsorship.mailSentAt, locale)) : td.mailNotSent}</li>
{sponsorship.offer.isPhysical ? (
<li>{sponsorship.printedAt ? td.printed(formatDateTime(sponsorship.printedAt, locale)) : td.notPrinted}</li>
) : null}
</ul>
{sponsorship.lastError ? (
<div className="mt-4 border border-red-200 bg-red-50 p-3">
<p className="text-xs tracking-wide-caps text-red-700">{td.lastError}</p>
<p className="mt-1 whitespace-pre-wrap break-words font-mono text-xs text-red-800">{sponsorship.lastError}</p>
</div>
) : null}
</Panel>
</div>
</div>
<div className="mb-8">
<Panel title={td.revisionsHeading}>
<RevisionTable
sponsorshipId={sponsorship.id}
revisions={sponsorship.revisions}
currentRevision={sponsorship.currentRevision}
locale={locale}
t={td}
/>
</Panel>
</div>
<Panel title={td.transfersHeading}>
<TransferTable
now={new Date()}
locale={locale}
t={td}
transfers={sponsorship.transfers.map((row) => ({
id: row.id,
createdAt: row.createdAt,
expiresAt: row.expiresAt,
createdBy: emailOf.get(row.createdById) ?? row.createdById,
claimedBy: row.claimedById ? (emailOf.get(row.claimedById) ?? row.claimedById) : null,
claimedAt: row.claimedAt,
revokedAt: row.revokedAt,
}))}
/>
</Panel>
</div>
);
}
@@ -0,0 +1,35 @@
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { readCertificatePdf } from "@/lib/sponsorship/storage";
import { getAuthorizedAdmin, notFoundResponse } from "../../../_lib/admin-guard";
/**
* Gespeicherte PDF einer Revision (/admin/patenschaften/<id>/revision/3.pdf).
* Ausgeliefert wird immer die abgelegte Datei, nie ein Neu-Rendering — sie
* ist der Beleg dessen, was die Kundschaft bekommen hat bzw. was gedruckt
* wird. ?inline=1 oeffnet sie im Browser (Druck-PDF), sonst Download.
* Nur fuer Admins mit 2FA, sonst 404.
*/
export async function GET(request: NextRequest, ctx: RouteContext<"/admin/patenschaften/[id]/revision/[rev]">): Promise<Response> {
if (!(await getAuthorizedAdmin())) return notFoundResponse();
const { id, rev } = await ctx.params;
const match = /^(\d{1,6})(?:\.pdf)?$/.exec(rev);
if (!match) return notFoundResponse();
const revision = await prisma.sponsorshipRevision.findUnique({
where: { sponsorshipId_revision: { sponsorshipId: id, revision: Number(match[1]) } },
select: { revision: true, pdfPath: true, sponsorship: { select: { number: true } } },
});
if (!revision) return notFoundResponse();
const pdf = await readCertificatePdf(revision.pdfPath);
if (!pdf) return notFoundResponse();
const filename = `${revision.sponsorship.number}-r${revision.revision}.pdf`;
const disposition = request.nextUrl.searchParams.get("inline") === "1" ? "inline" : "attachment";
return new Response(new Uint8Array(pdf), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `${disposition}; filename="${filename}"`,
"Cache-Control": "private, no-store",
},
});
}
@@ -0,0 +1,27 @@
import { renderCertificateDocumentImage } from "@/lib/sponsorship/certificate-document";
import { getAuthorizedAdmin, notFoundResponse } from "../../_lib/admin-guard";
import { loadRevisionDocument } from "../../_lib/revision-data";
const PREVIEW_WIDTH_PX = 900;
/**
* Bild der aktuellen Revision fuer die Admin-Detailseite. Gerendert aus den
* gespeicherten Revisionsdaten ueber dieselben Helfer wie PDF und Vorschau —
* ohne Wasserzeichen, weil es die tatsaechlich ausgestellte Urkunde zeigt.
* Nur fuer Admins mit 2FA, sonst 404.
*/
export async function GET(_request: Request, ctx: RouteContext<"/admin/patenschaften/[id]/vorschau.webp">): Promise<Response> {
if (!(await getAuthorizedAdmin())) return notFoundResponse();
const { id } = await ctx.params;
const document = await loadRevisionDocument(id);
if (!document) return notFoundResponse();
const image = await renderCertificateDocumentImage(document.input, { widthPx: PREVIEW_WIDTH_PX, format: "webp" });
return new Response(new Uint8Array(image), {
headers: {
"Content-Type": "image/webp",
// Personenbezogen (Namen, Widmung): nie in geteilten Caches ablegen.
"Cache-Control": "private, no-store",
},
});
}