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]>
76 lines
3.5 KiB
TypeScript
76 lines
3.5 KiB
TypeScript
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" },
|
|
});
|
|
}
|