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]>
211 lines
9.2 KiB
TypeScript
211 lines
9.2 KiB
TypeScript
import bcrypt from "bcryptjs";
|
||
import { PrismaClient } from "../src/generated/prisma/client";
|
||
import { PrismaClient as MySqlPrismaClient } from "../src/generated/prisma-mysql/client";
|
||
import { createDbAdapter } from "../src/lib/db-adapter";
|
||
import { isPasswordBreached } from "../src/lib/password-breach-check";
|
||
|
||
// Bewusst kein Import aus "@/lib/auth": das Modul zieht next/headers (cookies())
|
||
// nach sich, das ausserhalb eines Next.js-Request-Kontexts (hier: Standalone-
|
||
// Skript via tsx) fehlschlaegt. Der Hash-Rundenwert 12 ist mit auth.ts identisch.
|
||
const BCRYPT_ROUNDS = 12;
|
||
|
||
const adapter = createDbAdapter();
|
||
// Gleiches Prinzip wie lib/prisma.ts: zwei generierte Clients im Image,
|
||
// Auswahl zur Laufzeit ueber DATABASE_PROVIDER.
|
||
const prisma: PrismaClient =
|
||
(process.env.DATABASE_PROVIDER ?? "postgresql").trim() === "mysql"
|
||
? (new MySqlPrismaClient({ adapter }) as unknown as PrismaClient)
|
||
: new PrismaClient({ adapter });
|
||
|
||
/**
|
||
* Laeuft automatisch bei jedem Container-Start (siehe Dockerfile-CMD in der
|
||
* runner-Stage) — legt/erneuert NUR das Admin-Konto, keine Beispieldaten.
|
||
* Ein produktiv gebautes Image soll nie automatisch Fake-Produkte in einer
|
||
* echten Datenbank anlegen. Beispieldaten fuer lokales Testen/Screenshots:
|
||
* "pnpm db:seed:demo" (prisma/seed-demo.ts), manuell und getrennt.
|
||
*/
|
||
async function main() {
|
||
const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase();
|
||
const adminPassword = process.env.ADMIN_PASSWORD;
|
||
|
||
if (adminEmail && adminPassword) {
|
||
// Dieselben Mindestanforderungen wie bei der normalen Kundenregistrierung
|
||
// (registration.ts) — ein Admin-Konto hat Zugriff auf Bestellungen,
|
||
// Rueckerstattungen und Kundendaten und verdient keine schwaecheren
|
||
// Passwort-Regeln nur weil es per Seed statt per Formular angelegt wird.
|
||
if (adminPassword.length < 8) {
|
||
throw new Error("ADMIN_PASSWORD muss mindestens 8 Zeichen lang sein.");
|
||
}
|
||
if (await isPasswordBreached(adminPassword)) {
|
||
throw new Error(
|
||
"ADMIN_PASSWORD wurde in bekannten Datenlecks gefunden (Have I Been Pwned). Bitte ein anderes Passwort waehlen."
|
||
);
|
||
}
|
||
|
||
const existingAtEmail = await prisma.customer.findUnique({ where: { email: adminEmail } });
|
||
|
||
if (!existingAtEmail) {
|
||
await prisma.customer.create({
|
||
data: {
|
||
email: adminEmail,
|
||
passwordHash: await bcrypt.hash(adminPassword, BCRYPT_ROUNDS),
|
||
firstName: "Admin",
|
||
lastName: "Wabenhain",
|
||
role: "ADMIN",
|
||
},
|
||
});
|
||
console.log(`Admin-Konto angelegt: ${adminEmail} (Passwort aus ADMIN_PASSWORD).`);
|
||
} else if (existingAtEmail.role === "ADMIN") {
|
||
// Bereits Admin: Passwort-Rotation ueber ein geaendertes ADMIN_PASSWORD
|
||
// weiterhin unterstuetzen (dokumentierter Weg, ein Admin-Passwort zu
|
||
// erneuern) — unproblematisch, da das Konto schon Admin ist.
|
||
await prisma.customer.update({
|
||
where: { id: existingAtEmail.id },
|
||
data: { passwordHash: await bcrypt.hash(adminPassword, BCRYPT_ROUNDS) },
|
||
});
|
||
console.log(`Admin-Passwort synchronisiert: ${adminEmail}.`);
|
||
} else {
|
||
// Sicherheitsrelevant: seed befoerdert NIE automatisch ein bestehendes
|
||
// Nicht-Admin-Konto zu ADMIN, nur weil dessen E-Mail mit ADMIN_EMAIL
|
||
// uebereinstimmt — sonst koennte sich jemand die Admin-Mail vor dem
|
||
// ersten Deploy-Seed-Lauf selbst registrieren und beim naechsten
|
||
// "pnpm prisma db seed" automatisch Admin werden, mit selbstgewaehltem
|
||
// Passwort. Promotion eines bestehenden Kontos daher nur manuell ueber
|
||
// die Datenbank, siehe README "Admin-Zugang".
|
||
console.warn(
|
||
`WARNUNG: ${adminEmail} existiert bereits als Kundenkonto (nicht Admin) und wird NICHT automatisch befoerdert. ` +
|
||
'Siehe README, Abschnitt "Admin-Zugang", fuer die manuelle Befoerderung.'
|
||
);
|
||
}
|
||
} else {
|
||
console.log(
|
||
"Kein Admin-Konto angelegt: ADMIN_EMAIL/ADMIN_PASSWORD sind nicht gesetzt. " +
|
||
"Siehe README, wie ein bestehendes Konto nachtraeglich zu ADMIN befoerdert wird."
|
||
);
|
||
}
|
||
|
||
await seedSponsorshipDefaults();
|
||
}
|
||
|
||
/**
|
||
* Startangebote der Bienenpatenschaft und Treuestufen — nur wenn es noch
|
||
* keine gibt, und die Angebote INAKTIV: dieser Seed laeuft bei jedem
|
||
* Containerstart auch in Produktion, und Preise, Honig und Rechtstexte muss
|
||
* der Inhaber vor der Veroeffentlichung pruefen (Spec, "Geschaeftliche
|
||
* Konfiguration"). Erst das Aktivieren im Admin macht /patenschaft sichtbar.
|
||
*/
|
||
async function seedSponsorshipDefaults(): Promise<void> {
|
||
if ((await prisma.sponsorshipOffer.count()) === 0) {
|
||
const packages = [
|
||
{
|
||
slug: "bienenfreund",
|
||
name: "Bienenfreund",
|
||
nameEn: "Bee Friend",
|
||
priceCents: 1900,
|
||
isPhysical: false,
|
||
honeyJars: 0,
|
||
medalTier: "BRONZE" as const,
|
||
description: "Symbolische Namensgebung mit individuell gestalteter Urkunde als PDF.",
|
||
descriptionEn: "Symbolic naming with an individually designed certificate as a PDF.",
|
||
features: ["Symbolische Namensgebung", "Individuell gestaltete PDF-Urkunde", "Medaille und 3D-Ansicht im Kundenkonto", "Sicherer Download"],
|
||
featuresEn: ["Symbolic naming", "Individually designed PDF certificate", "Medal and 3D view in your account", "Secure download"],
|
||
},
|
||
{
|
||
slug: "honigfreund",
|
||
name: "Honigfreund",
|
||
nameEn: "Honey Friend",
|
||
priceCents: 3900,
|
||
isPhysical: true,
|
||
honeyJars: 1,
|
||
medalTier: "SILBER" as const,
|
||
description: "Digitale Leistungen plus gedruckte Urkunde, ein Glas Wabenhain-Honig, Grußkarte und Geschenkverpackung.",
|
||
descriptionEn: "Digital items plus a printed certificate, a jar of Wabenhain honey, a greeting card and gift wrapping.",
|
||
features: ["Alle digitalen Leistungen", "Hochwertig gedruckte Urkunde", "1 × 500 g Wabenhain-Honig", "Grußkarte und Geschenkverpackung"],
|
||
featuresEn: ["All digital items", "High-quality printed certificate", "1 × 500 g Wabenhain honey", "Greeting card and gift wrapping"],
|
||
},
|
||
{
|
||
slug: "wabenfreund",
|
||
name: "Wabenfreund",
|
||
nameEn: "Comb Friend",
|
||
priceCents: 5900,
|
||
isPhysical: true,
|
||
honeyJars: 3,
|
||
medalTier: "GOLD" as const,
|
||
description: "Digitale Leistungen plus gedruckte Urkunde, drei Gläser Wabenhain-Honig, Grußkarte und Geschenkverpackung.",
|
||
descriptionEn: "Digital items plus a printed certificate, three jars of Wabenhain honey, a greeting card and gift wrapping.",
|
||
features: ["Alle digitalen Leistungen", "Hochwertig gedruckte Urkunde", "3 × 500 g Wabenhain-Honig", "Grußkarte und Geschenkverpackung"],
|
||
featuresEn: ["All digital items", "High-quality printed certificate", "3 × 500 g Wabenhain honey", "Greeting card and gift wrapping"],
|
||
},
|
||
];
|
||
for (const [index, pkg] of packages.entries()) {
|
||
// Honiganteil als Vorschlag (7 € je Glas) — der Inhaber prueft ihn mit
|
||
// der Steuerberatung, bevor er das Angebot aktiviert.
|
||
const honeyShareCentsPerJar = pkg.honeyJars > 0 ? 700 : 0;
|
||
const product = await prisma.product.create({
|
||
data: {
|
||
slug: `patenschaft-${pkg.slug}`,
|
||
name: `Bienenpatenschaft ${pkg.name}`,
|
||
category: "PATENSCHAFT",
|
||
description: pkg.description,
|
||
weightGrams: 0,
|
||
priceCents: pkg.priceCents - pkg.honeyJars * honeyShareCentsPerJar,
|
||
pricePerKgCents: 0,
|
||
isActive: true,
|
||
},
|
||
});
|
||
await prisma.sponsorshipOffer.create({
|
||
data: {
|
||
slug: pkg.slug,
|
||
kind: "EINMALIG",
|
||
name: pkg.name,
|
||
nameEn: pkg.nameEn,
|
||
description: pkg.description,
|
||
descriptionEn: pkg.descriptionEn,
|
||
features: pkg.features,
|
||
featuresEn: pkg.featuresEn,
|
||
priceCents: pkg.priceCents,
|
||
isActive: false,
|
||
sortOrder: index,
|
||
isPhysical: pkg.isPhysical,
|
||
productId: product.id,
|
||
honeyJars: pkg.honeyJars,
|
||
honeyShareCentsPerJar,
|
||
medalTier: pkg.medalTier,
|
||
},
|
||
});
|
||
}
|
||
console.log("Bienenpatenschaft: drei Startangebote angelegt (inaktiv, bitte im Admin pruefen und aktivieren).");
|
||
}
|
||
|
||
if ((await prisma.loyaltyLevel.count()) === 0) {
|
||
const regular = [
|
||
{ threshold: 2, name: "Blütenbesuch", nameEn: "Blossom Visitor" },
|
||
{ threshold: 5, name: "Wabenbau", nameEn: "Comb Builder" },
|
||
{ threshold: 10, name: "Honigschatz", nameEn: "Honey Treasure" },
|
||
{ threshold: 25, name: "Goldwabe", nameEn: "Golden Comb" },
|
||
];
|
||
const subscription = Array.from({ length: 24 }, (_, i) => ({
|
||
threshold: i + 1,
|
||
name: `Stufe ${i + 1}`,
|
||
nameEn: `Level ${i + 1}`,
|
||
}));
|
||
await prisma.loyaltyLevel.createMany({
|
||
data: [
|
||
...regular.map((level) => ({ kind: "STAMMKUNDE" as const, ...level })),
|
||
...subscription.map((level) => ({ kind: "ABO" as const, ...level })),
|
||
{ kind: "ABO" as const, threshold: null, name: "VIP", nameEn: "VIP" },
|
||
],
|
||
});
|
||
console.log("Treuestufen angelegt (Stammkunde 2/5/10/25, Abo 1–24 und VIP).");
|
||
}
|
||
}
|
||
|
||
main()
|
||
.catch((error: unknown) => {
|
||
console.error(error);
|
||
process.exitCode = 1;
|
||
})
|
||
.finally(async () => {
|
||
await prisma.$disconnect();
|
||
});
|