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:
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user