feat: Wabenhain Honig-Shop Anwendung

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.
This commit is contained in:
Nick Szaunig
2026-08-17 14:30:28 +02:00
parent 5c76fcc266
commit c7927f2883
240 changed files with 26096 additions and 137 deletions
@@ -0,0 +1,322 @@
-- CreateEnum
CREATE TYPE "OrderStatus" AS ENUM ('NEU', 'BEZAHLT', 'IN_VORBEREITUNG', 'VERSANDBEREIT', 'VERSENDET', 'ABHOLBEREIT', 'ABGEHOLT', 'STORNIERT');
-- CreateEnum
CREATE TYPE "FulfillmentMethod" AS ENUM ('VERSAND', 'ABHOLUNG');
-- CreateEnum
CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'SUCCEEDED', 'FAILED', 'REFUNDED');
-- CreateEnum
CREATE TYPE "BusinessPackage" AS ENUM ('KLASSIK', 'GESCHENK', 'INDIVIDUELL');
-- CreateEnum
CREATE TYPE "InquiryStatus" AS ENUM ('NEU', 'IN_BEARBEITUNG', 'ABGESCHLOSSEN');
-- CreateTable
CREATE TABLE "customers" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"phone" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "customers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "addresses" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"label" TEXT NOT NULL DEFAULT 'Zuhause',
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"street" TEXT NOT NULL,
"zip" TEXT NOT NULL,
"city" TEXT NOT NULL,
"country" TEXT NOT NULL DEFAULT 'DE',
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "addresses_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "harvests" (
"id" TEXT NOT NULL,
"year" INTEGER NOT NULL,
"type" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"harvestDate" TIMESTAMP(3) NOT NULL,
"location" TEXT NOT NULL DEFAULT 'Altenmuenster',
"description" TEXT NOT NULL,
"images" TEXT[] DEFAULT ARRAY[]::TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "harvests_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "batches" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"harvestId" TEXT NOT NULL,
"fillDate" TIMESTAMP(3) NOT NULL,
"bestBeforeDate" TIMESTAMP(3) NOT NULL,
"glassCount" INTEGER NOT NULL,
"remainingGlasses" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "batches_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "products" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"harvestId" TEXT NOT NULL,
"batchId" TEXT NOT NULL,
"description" TEXT NOT NULL,
"tasteTags" TEXT[] DEFAULT ARRAY[]::TEXT[],
"intensityMild" INTEGER NOT NULL DEFAULT 3,
"intensityFloral" INTEGER NOT NULL DEFAULT 3,
"intensityKraeftig" INTEGER NOT NULL DEFAULT 3,
"consistency" TEXT NOT NULL DEFAULT 'feincremig',
"weightGrams" INTEGER NOT NULL,
"priceCents" INTEGER NOT NULL,
"pricePerKgCents" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'EUR',
"images" TEXT[] DEFAULT ARRAY[]::TEXT[],
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "products_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "inventory" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "inventory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "orders" (
"id" TEXT NOT NULL,
"orderNumber" TEXT NOT NULL,
"customerId" TEXT,
"status" "OrderStatus" NOT NULL DEFAULT 'NEU',
"fulfillmentMethod" "FulfillmentMethod" NOT NULL DEFAULT 'VERSAND',
"email" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"phone" TEXT,
"street" TEXT NOT NULL,
"zip" TEXT NOT NULL,
"city" TEXT NOT NULL,
"country" TEXT NOT NULL DEFAULT 'DE',
"subtotalCents" INTEGER NOT NULL,
"shippingCents" INTEGER NOT NULL DEFAULT 0,
"totalCents" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'EUR',
"stripeCheckoutSessionId" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "orders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "order_items" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"batchId" TEXT NOT NULL,
"productName" TEXT NOT NULL,
"unitPriceCents" INTEGER NOT NULL,
"quantity" INTEGER NOT NULL,
"weightGrams" INTEGER NOT NULL,
"totalCents" INTEGER NOT NULL,
CONSTRAINT "order_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payments" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"provider" TEXT NOT NULL DEFAULT 'stripe',
"stripePaymentIntentId" TEXT,
"status" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
"amountCents" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'EUR',
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "payments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "shipments" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"carrier" TEXT,
"trackingNumber" TEXT,
"shippedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "shipments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "pickups" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"location" TEXT NOT NULL DEFAULT 'Altenmuenster',
"readyAt" TIMESTAMP(3),
"pickedUpAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "pickups_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "invoices" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"invoiceNumber" TEXT NOT NULL,
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"totalCents" INTEGER NOT NULL,
"pdfUrl" TEXT,
CONSTRAINT "invoices_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "newsletter_subscribers" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"customerId" TEXT,
"confirmed" BOOLEAN NOT NULL DEFAULT false,
"subscribedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"unsubscribedAt" TIMESTAMP(3),
CONSTRAINT "newsletter_subscribers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "business_inquiries" (
"id" TEXT NOT NULL,
"companyName" TEXT NOT NULL,
"contactPerson" TEXT NOT NULL,
"email" TEXT NOT NULL,
"phone" TEXT,
"desiredQuantity" INTEGER NOT NULL,
"desiredDeliveryDate" TIMESTAMP(3),
"package" "BusinessPackage" NOT NULL DEFAULT 'KLASSIK',
"message" TEXT,
"status" "InquiryStatus" NOT NULL DEFAULT 'NEU',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "business_inquiries_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "customers_email_key" ON "customers"("email");
-- CreateIndex
CREATE UNIQUE INDEX "harvests_slug_key" ON "harvests"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "harvests_year_type_key" ON "harvests"("year", "type");
-- CreateIndex
CREATE UNIQUE INDEX "batches_code_key" ON "batches"("code");
-- CreateIndex
CREATE UNIQUE INDEX "products_slug_key" ON "products"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "inventory_productId_key" ON "inventory"("productId");
-- CreateIndex
CREATE UNIQUE INDEX "orders_orderNumber_key" ON "orders"("orderNumber");
-- CreateIndex
CREATE UNIQUE INDEX "payments_orderId_key" ON "payments"("orderId");
-- CreateIndex
CREATE UNIQUE INDEX "shipments_orderId_key" ON "shipments"("orderId");
-- CreateIndex
CREATE UNIQUE INDEX "pickups_orderId_key" ON "pickups"("orderId");
-- CreateIndex
CREATE UNIQUE INDEX "invoices_orderId_key" ON "invoices"("orderId");
-- CreateIndex
CREATE UNIQUE INDEX "invoices_invoiceNumber_key" ON "invoices"("invoiceNumber");
-- CreateIndex
CREATE UNIQUE INDEX "newsletter_subscribers_email_key" ON "newsletter_subscribers"("email");
-- CreateIndex
CREATE UNIQUE INDEX "newsletter_subscribers_customerId_key" ON "newsletter_subscribers"("customerId");
-- AddForeignKey
ALTER TABLE "addresses" ADD CONSTRAINT "addresses_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "batches" ADD CONSTRAINT "batches_harvestId_fkey" FOREIGN KEY ("harvestId") REFERENCES "harvests"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "products" ADD CONSTRAINT "products_harvestId_fkey" FOREIGN KEY ("harvestId") REFERENCES "harvests"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "products" ADD CONSTRAINT "products_batchId_fkey" FOREIGN KEY ("batchId") REFERENCES "batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventory" ADD CONSTRAINT "inventory_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "orders" ADD CONSTRAINT "orders_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "order_items" ADD CONSTRAINT "order_items_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "order_items" ADD CONSTRAINT "order_items_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "order_items" ADD CONSTRAINT "order_items_batchId_fkey" FOREIGN KEY ("batchId") REFERENCES "batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payments" ADD CONSTRAINT "payments_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "shipments" ADD CONSTRAINT "shipments_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pickups" ADD CONSTRAINT "pickups_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "newsletter_subscribers" ADD CONSTRAINT "newsletter_subscribers_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,10 @@
-- CreateEnum
CREATE TYPE "ProductCategory" AS ENUM ('HONIG', 'KERZEN', 'PROPOLIS');
-- AlterTable
ALTER TABLE "order_items" ALTER COLUMN "batchId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "products" ADD COLUMN "category" "ProductCategory" NOT NULL DEFAULT 'HONIG',
ALTER COLUMN "harvestId" DROP NOT NULL,
ALTER COLUMN "batchId" DROP NOT NULL;
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "CustomerRole" AS ENUM ('KUNDE', 'ADMIN');
-- AlterTable
ALTER TABLE "customers" ADD COLUMN "role" "CustomerRole" NOT NULL DEFAULT 'KUNDE';
@@ -0,0 +1,24 @@
-- AlterTable
ALTER TABLE "newsletter_subscribers" ADD COLUMN "confirmationToken" TEXT;
-- CreateTable
CREATE TABLE "password_reset_tokens" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "password_reset_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "password_reset_tokens_tokenHash_key" ON "password_reset_tokens"("tokenHash");
-- CreateIndex
CREATE UNIQUE INDEX "newsletter_subscribers_confirmationToken_key" ON "newsletter_subscribers"("confirmationToken");
-- AddForeignKey
ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,24 @@
-- AlterTable
ALTER TABLE "customers" ADD COLUMN "failedLoginAttempts" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "lockedUntil" TIMESTAMP(3),
ADD COLUMN "sessionVersion" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "twoFactorSecret" TEXT;
-- CreateTable
CREATE TABLE "two_factor_backup_codes" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"codeHash" TEXT NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "two_factor_backup_codes_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "two_factor_backup_codes_codeHash_key" ON "two_factor_backup_codes"("codeHash");
-- AddForeignKey
ALTER TABLE "two_factor_backup_codes" ADD CONSTRAINT "two_factor_backup_codes_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "customers" ADD COLUMN "blockedAt" TIMESTAMP(3);
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "shipping_tiers" (
"id" TEXT NOT NULL,
"minQuantity" INTEGER NOT NULL,
"maxQuantity" INTEGER,
"priceCents" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "shipping_tiers_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,54 @@
-- CreateEnum
CREATE TYPE "DiscountType" AS ENUM ('PERCENT', 'FIXED');
-- AlterTable
ALTER TABLE "orders" ADD COLUMN "discountCents" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "discountCodeId" TEXT;
-- CreateTable
CREATE TABLE "reviews" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"rating" INTEGER NOT NULL,
"comment" TEXT,
"isApproved" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "reviews_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "discount_codes" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"type" "DiscountType" NOT NULL,
"value" INTEGER NOT NULL,
"minOrderCents" INTEGER NOT NULL DEFAULT 0,
"maxUses" INTEGER,
"usedCount" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"validFrom" TIMESTAMP(3),
"validUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "discount_codes_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "reviews_productId_customerId_key" ON "reviews"("productId", "customerId");
-- CreateIndex
CREATE UNIQUE INDEX "discount_codes_code_key" ON "discount_codes"("code");
-- AddForeignKey
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "orders" ADD CONSTRAINT "orders_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "discount_codes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,57 @@
-- AlterTable
ALTER TABLE "customers" ADD COLUMN "emailVerifiedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "email_verification_tokens" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "email_verification_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "wishlist_items" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wishlist_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" TEXT NOT NULL,
"adminId" TEXT,
"adminEmail" TEXT NOT NULL,
"action" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT,
"detail" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "email_verification_tokens_tokenHash_key" ON "email_verification_tokens"("tokenHash");
-- CreateIndex
CREATE UNIQUE INDEX "wishlist_items_customerId_productId_key" ON "wishlist_items"("customerId", "productId");
-- AddForeignKey
ALTER TABLE "email_verification_tokens" ADD CONSTRAINT "email_verification_tokens_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wishlist_items" ADD CONSTRAINT "wishlist_items_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wishlist_items" ADD CONSTRAINT "wishlist_items_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- Bestandskonten, die vor Einfuehrung der E-Mail-Verifizierung registriert
-- wurden, gelten rueckwirkend als bestaetigt (createdAt als Zeitstempel) --
-- sonst waeren sie ploetzlich vom Checkout ausgesperrt, obwohl sie sich nie
-- verifizieren mussten.
UPDATE "customers" SET "emailVerifiedAt" = "createdAt" WHERE "emailVerifiedAt" IS NULL;
@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "stock_notifications" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"notifiedAt" TIMESTAMP(3),
CONSTRAINT "stock_notifications_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "stock_notifications_productId_email_key" ON "stock_notifications"("productId", "email");
-- AddForeignKey
ALTER TABLE "stock_notifications" ADD CONSTRAINT "stock_notifications_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- AlterEnum
ALTER TYPE "InquiryStatus" ADD VALUE 'ABGELEHNT';
@@ -0,0 +1,21 @@
-- CreateEnum
CREATE TYPE "ReturnStatus" AS ENUM ('ANGEFRAGT', 'ANGENOMMEN', 'ABGELEHNT', 'ERSTATTET');
-- CreateTable
CREATE TABLE "return_requests" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"status" "ReturnStatus" NOT NULL DEFAULT 'ANGEFRAGT',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "return_requests_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "return_requests_orderId_key" ON "return_requests"("orderId");
-- AddForeignKey
ALTER TABLE "return_requests" ADD CONSTRAINT "return_requests_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+633
View File
@@ -0,0 +1,633 @@
// Wabenhain — Datenmodell
// Alle Geldbetraege werden als Cent-Integer gespeichert, um Rundungsfehler zu vermeiden.
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
// "provider" wird NICHT hier per Hand gepflegt: Prisma erlaubt kein env() an
// dieser Stelle, daher schreibt scripts/prisma-provider.mjs diese Zeile vor
// jedem generate/db push/migrate anhand von DATABASE_PROVIDER ("postgresql"
// [Standard] oder "mysql") neu. Siehe README, Abschnitt "Datenbank-Provider
// wechseln".
datasource db {
provider = "postgresql"
}
// ---------------------------------------------------------------------------
// Kunden
// ---------------------------------------------------------------------------
enum CustomerRole {
KUNDE
ADMIN
}
model Customer {
id String @id @default(cuid())
email String @unique
passwordHash String
firstName String
lastName String
phone String?
role CustomerRole @default(KUNDE)
/// Wird bei sicherheitsrelevanten Ereignissen (Passwort-Reset, "alle
/// Geraete abmelden", 2FA an/aus) hochgezaehlt. Im JWT eingebettet — bei
/// Abweichung von diesem Wert gilt das Token als widerrufen, obwohl es
/// technisch noch nicht abgelaufen ist.
sessionVersion Int @default(0)
/// Fuer die Konto-Sperre nach wiederholten Fehlversuchen (zusaetzlich zum
/// IP-basierten Rate-Limit — persistent und ueberlebt einen Neustart/
/// mehrere Server-Instanzen, anders als der In-Memory-Rate-Limiter).
failedLoginAttempts Int @default(0)
lockedUntil DateTime?
/// Base32-TOTP-Secret, erst gesetzt wenn 2FA aktiv bestaetigt wurde.
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
/// Zeitschritt (Unix-Zeit / 30s) des zuletzt beim Login akzeptierten
/// TOTP-Codes — verhindert, dass ein abgefangener/mitgelesener Code im
/// selben oder einem frueheren Zeitfenster ein zweites Mal funktioniert.
lastTotpStep Int?
/// Von einem Admin gesperrtes Konto (Benutzerverwaltung) — unabhaengig von
/// der automatischen Fehlversuchs-Sperre in lockedUntil. Verhindert den
/// Login bis ein Admin das Konto wieder entsperrt.
blockedAt DateTime?
/// Gesetzt, sobald die E-Mail-Adresse per Bestaetigungslink oder durch
/// erfolgreiches Setzen eines Passworts ueber einen zugesandten Reset-Link
/// nachweislich unter der Kontrolle des Kontoinhabers ist. Registrierte
/// Kund:innen benoetigen dies fuer den Checkout (siehe /api/checkout).
emailVerifiedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
addresses Address[]
newsletterSubscriber NewsletterSubscriber?
passwordResetTokens PasswordResetToken[]
emailVerificationTokens EmailVerificationToken[]
twoFactorBackupCodes TwoFactorBackupCode[]
reviews Review[]
wishlistItems WishlistItem[]
auditLogs AuditLog[]
@@map("customers")
}
model EmailVerificationToken {
id String @id @default(cuid())
customerId String
/// SHA-256-Hash des per E-Mail zugesandten Tokens — wie beim
/// Passwort-Reset wird nie der Klartext gespeichert.
tokenHash String @unique
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
@@map("email_verification_tokens")
}
model TwoFactorBackupCode {
id String @id @default(cuid())
customerId String
/// SHA-256-Hash des Codes — der Klartext wird dem Kunden nur einmalig bei
/// der Einrichtung angezeigt und nie wieder gespeichert.
codeHash String @unique
usedAt DateTime?
createdAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
@@map("two_factor_backup_codes")
}
model Address {
id String @id @default(cuid())
customerId String
label String @default("Zuhause")
firstName String
lastName String
street String
zip String
city String
country String @default("DE")
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
@@map("addresses")
}
// ---------------------------------------------------------------------------
// Ernten, Chargen, Lagerbestand, Produkte
// ---------------------------------------------------------------------------
model Harvest {
id String @id @default(cuid())
year Int
/// z.B. "Fruehtracht", "Sommertracht"
type String
slug String @unique
harvestDate DateTime
location String @default("Altenmuenster")
description String
/// Liste von Bild-URLs als JSON-Array (String[] ist nicht Provider-uebergreifend:
/// MySQL kennt keine nativen Skalar-Arrays) — siehe lib/json-array.ts fuer den
/// Normalisierungs-Helfer beim Lesen.
images Json @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
batches Batch[]
products Product[]
@@unique([year, type])
@@map("harvests")
}
model Batch {
id String @id @default(cuid())
/// z.B. FH-26-01
code String @unique
harvestId String
fillDate DateTime
bestBeforeDate DateTime
glassCount Int
remainingGlasses Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
harvest Harvest @relation(fields: [harvestId], references: [id], onDelete: Restrict)
products Product[]
orderItems OrderItem[]
@@map("batches")
}
enum ProductCategory {
HONIG
KERZEN
PROPOLIS
}
model Product {
id String @id @default(cuid())
slug String @unique
name String
category ProductCategory @default(HONIG)
/// Nur fuer Honig-Produkte gesetzt (Kerzen/Propolis sind keiner Ernte/Charge zugeordnet)
harvestId String?
batchId String?
description String
/// Geschmacksprofil-Tags als JSON-Array, z.B. ["mild", "floral", "feincremig"]
/// — String[] ist nicht Provider-uebergreifend (MySQL kennt keine nativen
/// Skalar-Arrays), siehe lib/json-array.ts fuer den Normalisierungs-Helfer.
tasteTags Json @default("[]")
/// Intensitaet 1-5 fuer die visuelle Geschmacksskala
intensityMild Int @default(3)
intensityFloral Int @default(3)
intensityKraeftig Int @default(3)
consistency String @default("feincremig")
weightGrams Int
priceCents Int
pricePerKgCents Int
currency String @default("EUR")
/// Liste von Bild-URLs als JSON-Array, siehe tasteTags-Kommentar oben.
images Json @default("[]")
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
harvest Harvest? @relation(fields: [harvestId], references: [id], onDelete: Restrict)
batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict)
inventory Inventory?
orderItems OrderItem[]
reviews Review[]
wishlistItems WishlistItem[]
stockNotifications StockNotification[]
@@map("products")
}
model StockNotification {
id String @id @default(cuid())
productId String
email String
/// Spracheinstellung bei der Anmeldung — die Benachrichtigungs-E-Mail wird
/// spaeter asynchron (beim naechsten Bestandsupdate) versendet, ohne
/// Cookie-Kontext der urspruenglichen Anfrage.
locale String @default("de")
createdAt DateTime @default(now())
/// Gesetzt, sobald die Benachrichtigungs-E-Mail versendet wurde — verhindert
/// Mehrfachversand, falls der Bestand mehrfach zwischen 0 und >0 wechselt,
/// ohne dass sich die Kundin/der Kunde neu angemeldet hat.
notifiedAt DateTime?
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
/// Eine Anmeldung pro E-Mail-Adresse und Produkt.
@@unique([productId, email])
@@map("stock_notifications")
}
model WishlistItem {
id String @id @default(cuid())
customerId String
productId String
createdAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
/// Ein Produkt kann pro Kunde nur einmal auf der Wunschliste stehen.
@@unique([customerId, productId])
@@map("wishlist_items")
}
model Review {
id String @id @default(cuid())
productId String
customerId String
/// 1-5
rating Int
comment String?
/// Nur freigeschaltete Bewertungen werden auf der Produktseite angezeigt —
/// einfache Missbrauchskontrolle durch den Admin vor Veroeffentlichung.
isApproved Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
/// Ein Kunde kann ein Produkt nur einmal bewerten.
@@unique([productId, customerId])
@@map("reviews")
}
model Inventory {
id String @id @default(cuid())
productId String @unique
quantity Int @default(0)
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@map("inventory")
}
// ---------------------------------------------------------------------------
// Bestellungen
// ---------------------------------------------------------------------------
enum OrderStatus {
NEU
BEZAHLT
IN_VORBEREITUNG
VERSANDBEREIT
VERSENDET
ABHOLBEREIT
ABGEHOLT
STORNIERT
}
enum FulfillmentMethod {
VERSAND
ABHOLUNG
}
model Order {
id String @id @default(cuid())
orderNumber String @unique
customerId String?
status OrderStatus @default(NEU)
fulfillmentMethod FulfillmentMethod @default(VERSAND)
/// Spracheinstellung beim Checkout ("de"/"en") — wird fuer spaeter
/// asynchron versendete E-Mails (Stripe-Webhook) benoetigt, da dort kein
/// Cookie-/Request-Kontext mehr vorhanden ist, um die Sprache erneut zu bestimmen.
locale String @default("de")
// Kontakt- und Versanddaten werden am Bestellzeitpunkt gespeichert,
// damit Gastbestellungen unabhaengig von einem Customer-Datensatz funktionieren.
email String
firstName String
lastName String
phone String?
street String
zip String
city String
country String @default("DE")
subtotalCents Int
shippingCents Int @default(0)
discountCents Int @default(0)
totalCents Int
currency String @default("EUR")
discountCodeId String?
stripeCheckoutSessionId String?
notes String?
/// Optionale persoenliche Nachricht bei Geschenkbestellungen — wird beim
/// Verpacken von Hand beigelegt (siehe Admin-Bestelldetail).
giftMessage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
discountCode DiscountCode? @relation(fields: [discountCodeId], references: [id], onDelete: SetNull)
items OrderItem[]
payment Payment?
shipment Shipment?
pickup Pickup?
invoice Invoice?
returnRequest ReturnRequest?
@@map("orders")
}
enum ReturnStatus {
ANGEFRAGT
ANGENOMMEN
ABGELEHNT
ERSTATTET
}
/// Eine Rueckgabeanfrage pro Bestellung (orderId @unique) — deckt den Fall
/// "gesamte Bestellung zurueckschicken" ab, keine positionsweise Teil-Retoure.
model ReturnRequest {
id String @id @default(cuid())
orderId String @unique
reason String
status ReturnStatus @default(ANGEFRAGT)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@map("return_requests")
}
model OrderItem {
id String @id @default(cuid())
orderId String
productId String
/// Nur gesetzt, wenn das Produkt einer Charge zugeordnet ist (Honig)
batchId String?
// Snapshot der Produktdaten zum Bestellzeitpunkt (Preisaenderungen duerfen
// bestehende Bestellungen nicht rueckwirkend veraendern).
productName String
unitPriceCents Int
quantity Int
weightGrams Int
totalCents Int
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict)
@@map("order_items")
}
enum PaymentStatus {
PENDING
SUCCEEDED
FAILED
REFUNDED
}
model Payment {
id String @id @default(cuid())
orderId String @unique
provider String @default("stripe")
stripePaymentIntentId String?
status PaymentStatus @default(PENDING)
amountCents Int
currency String @default("EUR")
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@map("payments")
}
model Shipment {
id String @id @default(cuid())
orderId String @unique
carrier String?
trackingNumber String?
shippedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@map("shipments")
}
model Pickup {
id String @id @default(cuid())
orderId String @unique
location String @default("Altenmuenster")
readyAt DateTime?
pickedUpAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@map("pickups")
}
model Invoice {
id String @id @default(cuid())
orderId String @unique
invoiceNumber String @unique
issuedAt DateTime @default(now())
totalCents Int
pdfUrl String?
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@map("invoices")
}
// ---------------------------------------------------------------------------
// Marketing / Kontakt
// ---------------------------------------------------------------------------
model NewsletterSubscriber {
id String @id @default(cuid())
email String @unique
customerId String? @unique
confirmed Boolean @default(false)
/// Dient sowohl der Bestaetigung (Double-Opt-In) als auch als dauerhafter
/// Abmelde-Link fuer Abonnenten ohne Kundenkonto.
confirmationToken String? @unique
subscribedAt DateTime @default(now())
unsubscribedAt DateTime?
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
@@map("newsletter_subscribers")
}
model PasswordResetToken {
id String @id @default(cuid())
customerId String
/// SHA-256-Hash des dem Kunden per E-Mail zugesandten Tokens — wie bei
/// Passwoertern wird nie der Klartext gespeichert.
tokenHash String @unique
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
@@map("password_reset_tokens")
}
enum BusinessPackage {
KLASSIK
GESCHENK
INDIVIDUELL
}
enum InquiryStatus {
NEU
IN_BEARBEITUNG
ABGESCHLOSSEN
ABGELEHNT
}
// ---------------------------------------------------------------------------
// Versand
// ---------------------------------------------------------------------------
model ShippingTier {
id String @id @default(cuid())
/// Menge (Stueck ueber alle Artikel im Warenkorb zusammengezaehlt) ab der
/// diese Staffel gilt.
minQuantity Int
/// Bis einschliesslich dieser Menge — null bedeutet "und mehr" (offene,
/// oberste Staffel).
maxQuantity Int?
priceCents Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("shipping_tiers")
}
// ---------------------------------------------------------------------------
// Rabattcodes
// ---------------------------------------------------------------------------
enum DiscountType {
PERCENT
FIXED
}
model DiscountCode {
id String @id @default(cuid())
code String @unique
type DiscountType
/// PERCENT: Prozentpunkte (1-100). FIXED: Cent-Betrag.
value Int
/// Mindestbestellwert (Cent) fuer die Anwendung, 0 = keine Mindestsumme.
minOrderCents Int @default(0)
/// Mindestmenge (Summe aller Artikel im Warenkorb) fuer die Anwendung —
/// null = keine Mengenschwelle. Gesetzt macht aus dem Code einen
/// Mengenrabatt: Checkout wendet ihn automatisch an, sobald die Schwelle
/// erreicht ist (siehe api/checkout/route.ts), ohne dass der Code
/// eingegeben werden muss.
minQuantity Int?
/// Nutzungslimit insgesamt ueber alle Kunden — null = unbegrenzt.
maxUses Int?
usedCount Int @default(0)
isActive Boolean @default(true)
validFrom DateTime?
validUntil DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
@@map("discount_codes")
}
// ---------------------------------------------------------------------------
// Admin-Audit-Log
// ---------------------------------------------------------------------------
/// Protokolliert sicherheitsrelevante Admin-Mutationen (wer hat wann was
/// geaendert) — wichtig bei mehreren Admin-Konten, um Aenderungen
/// nachvollziehen zu koennen. adminEmail ist ein Snapshot, damit der
/// Eintrag auch nach Loeschung des Admin-Kontos lesbar bleibt.
model AuditLog {
id String @id @default(cuid())
adminId String?
adminEmail String
/// z.B. "product.update", "order.status_change", "discount_code.delete"
action String
entityType String
entityId String?
detail String?
createdAt DateTime @default(now())
admin Customer? @relation(fields: [adminId], references: [id], onDelete: SetNull)
@@map("audit_logs")
}
/// Admin-editierbare Website-Inhalte (Firmendaten, Homepage-Texte,
/// Rechtstexte) als generisches Key-Value-Overlay ueber den statischen
/// i18n-Dictionaries — siehe lib/content-registry.ts fuer die Liste aller
/// editierbaren Schluessel und deren Fallback-Werte. Fehlt eine Zeile (kein
/// Admin-Override gepflegt), liefert getContentValue() den urspruenglichen
/// Dictionary-/site-config-Text — die Seite bricht also nie, auch bevor die
/// erste Bearbeitung stattgefunden hat.
model SiteContent {
/// z.B. "company.email", "home.philosophy.items", "legal.impressum"
key String @id
/// Bei nicht lokalisierten Feldern (Firmendaten) einziger Speicherort;
/// bei lokalisierten Feldern (Homepage/Rechtstexte) die deutsche Fassung.
valueDe String @db.Text
/// Nur bei lokalisierten Feldern gesetzt — null bei Firmendaten.
valueEn String? @db.Text
updatedAt DateTime @updatedAt
@@map("site_content")
}
model BusinessInquiry {
id String @id @default(cuid())
companyName String
contactPerson String
email String
phone String?
desiredQuantity Int
desiredDeliveryDate DateTime?
package BusinessPackage @default(KLASSIK)
message String?
status InquiryStatus @default(NEU)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("business_inquiries")
}
+265
View File
@@ -0,0 +1,265 @@
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();
});