Files
Wabenhain/prisma/seed.ts
T
Nick SzaunigandClaude Opus 5.5 f227aeb8f9 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]>
2026-10-16 14:07:30 +02:00

211 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});