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]>
114 lines
4.4 KiB
TypeScript
114 lines
4.4 KiB
TypeScript
import { composeCertificateText, type CertificateText } from "@/lib/certificate/text";
|
|
import { layoutCertificate } from "@/lib/certificate/layout";
|
|
import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates";
|
|
import { renderCertificatePdf } from "@/lib/certificate/render-pdf";
|
|
import { renderCertificateImage, type WatermarkOption } from "@/lib/certificate/render-image";
|
|
import { fixedTermFor, personalizationSchema, type CertificateTerm, type Personalization } from "@/lib/certificate/schema";
|
|
import type { Locale } from "@/i18n/config";
|
|
|
|
/**
|
|
* Bruecke zwischen Patenschaft (Datenbank) und Urkunden-Engine
|
|
* (lib/certificate). Hier und nur hier wird entschieden, mit welchen Daten
|
|
* eine Urkunde gerendert wird — Ausstellung, Korrektur, Vorschau und die
|
|
* 3D-Textur rufen alle diese Funktionen auf. Dadurch koennen Vorschau und
|
|
* gespeicherte PDF nicht auseinanderlaufen: gleiche Eingabe, gleicher Weg.
|
|
*/
|
|
|
|
/** Was an der Bestellposition bzw. am Abo als Personalisierung gespeichert wird. */
|
|
export interface StoredPersonalization {
|
|
personalization: Personalization;
|
|
templateVersion: string;
|
|
}
|
|
|
|
export interface CertificateDocumentInput {
|
|
personalization: Personalization;
|
|
locale: Locale;
|
|
term: CertificateTerm;
|
|
number: string;
|
|
templateVersion: string;
|
|
}
|
|
|
|
/** Bei Vorschauen vor dem Kauf gibt es noch keine Nummer; sichtbar ist dieser Platzhalter. */
|
|
export const PREVIEW_NUMBER = "WH-P-0000-00000";
|
|
|
|
/**
|
|
* Liest eine gespeicherte Personalisierung und prueft sie erneut gegen das
|
|
* Schema. Was in der Datenbank steht, ist beim Kauf geprueft worden — eine
|
|
* Schemaaenderung seitdem soll aber laut auffallen statt still eine Urkunde
|
|
* mit unerwarteten Daten zu erzeugen.
|
|
*/
|
|
export function parseStoredPersonalization(value: unknown): StoredPersonalization {
|
|
if (!value || typeof value !== "object") {
|
|
throw new Error("Keine Personalisierung gespeichert.");
|
|
}
|
|
const raw = value as { personalization?: unknown; templateVersion?: unknown };
|
|
const parsed = personalizationSchema.safeParse(raw.personalization);
|
|
if (!parsed.success) {
|
|
throw new Error(`Gespeicherte Personalisierung ungueltig: ${parsed.error.issues[0]?.message ?? "unbekannt"}`);
|
|
}
|
|
return {
|
|
personalization: parsed.data,
|
|
templateVersion: typeof raw.templateVersion === "string" ? raw.templateVersion : CURRENT_TEMPLATE_VERSION,
|
|
};
|
|
}
|
|
|
|
export function toStoredPersonalization(personalization: Personalization, templateVersion = CURRENT_TEMPLATE_VERSION): StoredPersonalization {
|
|
return { personalization, templateVersion };
|
|
}
|
|
|
|
/** Laufzeitform einer Patenschaft aus ihren Daten (YYYY-MM-DD). */
|
|
export function termForSponsorship(input: { startDate: Date; endDate: Date | null; isSubscription: boolean }): CertificateTerm {
|
|
const start = toIsoDate(input.startDate);
|
|
if (!input.isSubscription) {
|
|
return input.endDate ? { kind: "FIXED", start, end: toIsoDate(input.endDate) } : fixedTermFor(start);
|
|
}
|
|
return input.endDate ? { kind: "ENDED", start, end: toIsoDate(input.endDate) } : { kind: "OPEN", start };
|
|
}
|
|
|
|
export function composeDocumentText(input: CertificateDocumentInput): CertificateText {
|
|
return composeCertificateText({
|
|
personalization: input.personalization,
|
|
locale: input.locale,
|
|
term: input.term,
|
|
number: input.number,
|
|
});
|
|
}
|
|
|
|
export async function renderCertificateDocumentPdf(
|
|
input: CertificateDocumentInput,
|
|
creationDate: Date
|
|
): Promise<{ pdf: Buffer; text: CertificateText }> {
|
|
const text = composeDocumentText(input);
|
|
const layout = layoutCertificate(text, input.templateVersion);
|
|
const pdf = await renderCertificatePdf(layout, {
|
|
title: `Bienenpatenschaft ${text.beeName}`,
|
|
logo: null,
|
|
creationDate,
|
|
});
|
|
return { pdf, text };
|
|
}
|
|
|
|
export async function renderCertificateDocumentImage(
|
|
input: CertificateDocumentInput,
|
|
options: { widthPx: number; watermark?: WatermarkOption; format: "webp" | "png" }
|
|
): Promise<Buffer> {
|
|
const layout = layoutCertificate(composeDocumentText(input), input.templateVersion);
|
|
return renderCertificateImage(layout, options);
|
|
}
|
|
|
|
/**
|
|
* Wasserzeichen der Vorschau. Die Engine setzt den Wortlaut je Sprache selbst
|
|
* (render-image.ts, WATERMARK_TEXT) — hier wird nur die Sprache gewaehlt.
|
|
*/
|
|
export function previewWatermark(locale: Locale): Locale {
|
|
return locale;
|
|
}
|
|
|
|
export function toIsoDate(date: Date): string {
|
|
return date.toISOString().slice(0, 10);
|
|
}
|
|
|
|
export function fromIsoDate(value: string): Date {
|
|
return new Date(`${value}T00:00:00.000Z`);
|
|
}
|