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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user