Vollstaendige Next.js/Prisma-E-Commerce-Anwendung fuer eine Imkerei: Shop, Warenkorb, Stripe-Checkout, Kundenkonto mit 2FA, Admin-Bereich (Produkte, Ernten, Chargen, Bestellungen, Gutscheine, admin-editierbare Website-Inhalte), i18n (DE/EN), Rate-Limiting/CSP/Audit-Log-Haertung, PostgreSQL- und MySQL-Unterstuetzung ueber austauschbare Prisma- Treiber-Adapter, Unit-/Komponenten-/E2E-Tests.
266 lines
9.3 KiB
TypeScript
266 lines
9.3 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 });
|
|
|
|
async function main() {
|
|
const fruehtracht = await prisma.harvest.upsert({
|
|
where: { slug: "fruehtracht-2026" },
|
|
update: {},
|
|
create: {
|
|
year: 2026,
|
|
type: "Frühtracht",
|
|
slug: "fruehtracht-2026",
|
|
harvestDate: new Date("2026-05-10"),
|
|
location: "Altenmünster",
|
|
description:
|
|
"Die erste Ernte des Jahres entsteht aus dem Nektar von Obstblüte, Raps und Löwenzahn rund um Altenmünster. Mild, floral und feincremig — der Auftakt jeder Wabenhain-Saison.",
|
|
images: [],
|
|
},
|
|
});
|
|
|
|
const sommertracht = await prisma.harvest.upsert({
|
|
where: { slug: "sommertracht-2026" },
|
|
update: {},
|
|
create: {
|
|
year: 2026,
|
|
type: "Sommertracht",
|
|
slug: "sommertracht-2026",
|
|
harvestDate: new Date("2026-07-18"),
|
|
location: "Altenmünster",
|
|
description:
|
|
"Im Hochsommer sammeln unsere Völker Nektar von Linde, Wiesenblumen und Wald. Das Ergebnis ist kräftiger, aromatischer und charaktervoller als die Frühtracht.",
|
|
images: [],
|
|
},
|
|
});
|
|
|
|
const fhBatch = await prisma.batch.upsert({
|
|
where: { code: "FH-26-01" },
|
|
update: {},
|
|
create: {
|
|
code: "FH-26-01",
|
|
harvestId: fruehtracht.id,
|
|
fillDate: new Date("2026-06-02"),
|
|
bestBeforeDate: new Date("2028-06-02"),
|
|
glassCount: 200,
|
|
remainingGlasses: 167,
|
|
},
|
|
});
|
|
|
|
const stBatch = await prisma.batch.upsert({
|
|
where: { code: "ST-26-01" },
|
|
update: {},
|
|
create: {
|
|
code: "ST-26-01",
|
|
harvestId: sommertracht.id,
|
|
fillDate: new Date("2026-08-05"),
|
|
bestBeforeDate: new Date("2028-08-05"),
|
|
glassCount: 150,
|
|
remainingGlasses: 150,
|
|
},
|
|
});
|
|
|
|
const fhProduct = await prisma.product.upsert({
|
|
where: { slug: "fruehtracht-2026" },
|
|
update: { tasteTags: ["mild", "floral", "feincremig"] },
|
|
create: {
|
|
slug: "fruehtracht-2026",
|
|
name: "Frühtracht 2026",
|
|
harvestId: fruehtracht.id,
|
|
batchId: fhBatch.id,
|
|
description:
|
|
"Mild, floral und feincremig. Unsere erste Ernte des Jahres aus dem Raum Altenmünster.",
|
|
tasteTags: ["mild", "floral", "feincremig"],
|
|
intensityMild: 4,
|
|
intensityFloral: 4,
|
|
intensityKraeftig: 2,
|
|
consistency: "feincremig",
|
|
weightGrams: 500,
|
|
priceCents: 990,
|
|
pricePerKgCents: 1980,
|
|
images: [],
|
|
isActive: true,
|
|
},
|
|
});
|
|
|
|
const stProduct = await prisma.product.upsert({
|
|
where: { slug: "sommertracht-2026" },
|
|
update: { tasteTags: ["kräftig", "aromatisch", "charaktervoll"] },
|
|
create: {
|
|
slug: "sommertracht-2026",
|
|
name: "Sommertracht 2026",
|
|
harvestId: sommertracht.id,
|
|
batchId: stBatch.id,
|
|
description: "Kräftiger, aromatischer und charaktervoller Sommerhonig aus Altenmünster.",
|
|
tasteTags: ["kräftig", "aromatisch", "charaktervoll"],
|
|
intensityMild: 2,
|
|
intensityFloral: 2,
|
|
intensityKraeftig: 5,
|
|
consistency: "cremig",
|
|
weightGrams: 500,
|
|
priceCents: 1090,
|
|
pricePerKgCents: 2180,
|
|
images: [],
|
|
isActive: true,
|
|
},
|
|
});
|
|
|
|
const candleProduct = await prisma.product.upsert({
|
|
where: { slug: "bienenwachskerze-rund" },
|
|
update: { tasteTags: ["handgegossen", "reines Bienenwachs", "rußarm"] },
|
|
create: {
|
|
slug: "bienenwachskerze-rund",
|
|
name: "Bienenwachskerze, rund",
|
|
category: "KERZEN",
|
|
description:
|
|
"Handgegossen aus reinem Bienenwachs unserer eigenen Völker. Brennt gleichmäßig, rußarm und verströmt einen dezenten Honigduft.",
|
|
tasteTags: ["handgegossen", "reines Bienenwachs", "rußarm"],
|
|
consistency: "fest",
|
|
weightGrams: 150,
|
|
priceCents: 1290,
|
|
pricePerKgCents: 8600,
|
|
images: [],
|
|
isActive: true,
|
|
},
|
|
});
|
|
|
|
const propolisProduct = await prisma.product.upsert({
|
|
where: { slug: "propolis-tinktur" },
|
|
update: { tasteTags: ["schonend verarbeitet", "ohne Zusätze"] },
|
|
create: {
|
|
slug: "propolis-tinktur",
|
|
name: "Propolis-Tinktur",
|
|
category: "PROPOLIS",
|
|
description:
|
|
"Schonend angesetzte Propolis-Tinktur aus dem Harz unserer Bienenstöcke rund um Altenmünster. Ohne synthetische Zusätze.",
|
|
tasteTags: ["schonend verarbeitet", "ohne Zusätze"],
|
|
consistency: "flüssig",
|
|
weightGrams: 30,
|
|
priceCents: 1490,
|
|
pricePerKgCents: 49666,
|
|
images: [],
|
|
isActive: true,
|
|
},
|
|
});
|
|
|
|
await prisma.inventory.upsert({
|
|
where: { productId: fhProduct.id },
|
|
update: { quantity: 167 },
|
|
create: { productId: fhProduct.id, quantity: 167 },
|
|
});
|
|
|
|
await prisma.inventory.upsert({
|
|
where: { productId: stProduct.id },
|
|
update: { quantity: 150 },
|
|
create: { productId: stProduct.id, quantity: 150 },
|
|
});
|
|
|
|
await prisma.inventory.upsert({
|
|
where: { productId: candleProduct.id },
|
|
update: { quantity: 60 },
|
|
create: { productId: candleProduct.id, quantity: 60 },
|
|
});
|
|
|
|
await prisma.inventory.upsert({
|
|
where: { productId: propolisProduct.id },
|
|
update: { quantity: 40 },
|
|
create: { productId: propolisProduct.id, quantity: 40 },
|
|
});
|
|
|
|
const existingShippingTiers = await prisma.shippingTier.count();
|
|
if (existingShippingTiers === 0) {
|
|
await prisma.shippingTier.createMany({
|
|
data: [
|
|
{ minQuantity: 0, maxQuantity: 3, priceCents: 700 },
|
|
{ minQuantity: 4, maxQuantity: 9, priceCents: 850 },
|
|
{ minQuantity: 10, maxQuantity: 19, priceCents: 1100 },
|
|
],
|
|
});
|
|
console.log("Versandstaffeln angelegt: 0-3=7,00€, 4-9=8,50€, 10-19=11,00€.");
|
|
}
|
|
|
|
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."
|
|
);
|
|
}
|
|
|
|
console.log("Seed abgeschlossen: 2 Ernten, 2 Chargen, 4 Produkte (Honig, Kerzen, Propolis).");
|
|
}
|
|
|
|
main()
|
|
.catch((error: unknown) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
})
|
|
.finally(async () => {
|
|
await prisma.$disconnect();
|
|
});
|