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 committed 2026-08-17 14:30:28 +02:00
1 parent 5c76fcc266
commit c7927f2883
240 files changed
+26096 -137

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
import { prisma } from "@/lib/prisma";
import { insensitiveEquals } from "@/lib/prisma-search";
import type { Order, OrderItem, Shipment, Pickup, Invoice, Address, ReturnRequest } from "@/generated/prisma/client";
export type OrderWithDetails = Order & {
items: (OrderItem & { product: { slug: string } })[];
shipment: Shipment | null;
pickup: Pickup | null;
invoice: Invoice | null;
returnRequest: ReturnRequest | null;
};
export async function getOrdersForCustomer(customerId: string): Promise<OrderWithDetails[]> {
return prisma.order.findMany({
where: { customerId },
include: {
items: { include: { product: { select: { slug: true } } } },
shipment: true,
pickup: true,
invoice: true,
returnRequest: true,
},
orderBy: { createdAt: "desc" },
});
}
/**
* Oeffentliche Bestellverfolgung ohne Login (auch fuer Gastbestellungen):
* Bestellnummer allein reicht nicht — die E-Mail-Adresse muss zusaetzlich
* uebereinstimmen, damit eine Bestellnummer (kryptografisch zufaellig, aber
* theoretisch in URLs/E-Mails sichtbar) allein keinen Zugriff gewaehrt.
*/
export async function getOrderForTracking(
orderNumber: string,
email: string
): Promise<OrderWithDetails | null> {
return prisma.order.findFirst({
where: { orderNumber, email: insensitiveEquals(email) },
include: {
items: { include: { product: { select: { slug: true } } } },
shipment: true,
pickup: true,
invoice: true,
returnRequest: true,
},
});
}
export async function getAddressesForCustomer(customerId: string): Promise<Address[]> {
return prisma.address.findMany({
where: { customerId },
orderBy: [{ isDefault: "desc" }, { createdAt: "desc" }],
});
}
export async function getInvoiceForCustomer(invoiceNumber: string, customerId: string) {
return prisma.invoice.findFirst({
where: { invoiceNumber, order: { customerId } },
include: { order: { include: { items: true, discountCode: { select: { code: true } } } } },
});
}
/** Ohne Besitzpruefung — nur fuer bereits als ADMIN verifizierte Aufrufer. */
export async function getInvoiceByNumber(invoiceNumber: string) {
return prisma.invoice.findUnique({
where: { invoiceNumber },
include: { order: { include: { items: true, discountCode: { select: { code: true } } } } },
});
}
+35
View File
@@ -0,0 +1,35 @@
import { prisma } from "@/lib/prisma";
interface AuditLogInput {
action: string;
entityType: string;
entityId?: string;
detail?: string;
}
/**
* Protokolliert eine Admin-Mutation. adminEmail wird als Snapshot
* gespeichert (nicht nur adminId), damit der Log-Eintrag auch nach
* Loeschung des Admin-Kontos noch lesbar bleibt. Ein Fehler beim Schreiben
* des Logs darf die eigentliche Admin-Aktion nicht verhindern.
*/
export async function logAdminAction(
admin: { id: string; email: string },
input: AuditLogInput
): Promise<void> {
try {
await prisma.auditLog.create({
data: {
adminId: admin.id,
adminEmail: admin.email,
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
detail: input.detail,
},
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
console.error(`[audit-log] Eintrag fuer "${input.action}" konnte nicht gespeichert werden: ${message}`);
}
}
+185
View File
@@ -0,0 +1,185 @@
import { cookies } from "next/headers";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import {
SESSION_COOKIE,
SESSION_MAX_AGE_SECONDS,
ADMIN_SESSION_MAX_AGE_SECONDS,
TWO_FACTOR_CHALLENGE_COOKIE,
TWO_FACTOR_SETUP_COOKIE,
TWO_FACTOR_BACKUP_CODES_COOKIE,
signSessionToken,
verifySessionToken,
verifyTwoFactorChallenge,
verifyTwoFactorSetup,
verifyTwoFactorBackupCodesDisplay,
type SessionRole,
} from "@/lib/session";
import type { Customer } from "@/generated/prisma/client";
const BCRYPT_ROUNDS = 12;
// Vorberechneter Hash, gegen den bei unbekannter E-Mail-Adresse verglichen
// wird: so dauert ein Login-Versuch mit falscher E-Mail genauso lange wie
// einer mit falschem Passwort (kein User-Enumeration ueber Timing).
const dummyHashPromise: Promise<string> = bcrypt.hash("wabenhain-timing-dummy", BCRYPT_ROUNDS);
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, BCRYPT_ROUNDS);
}
/**
* Prueft ein Passwort gegen den Hash eines (moeglicherweise nicht
* existierenden) Kontos in konstanter Zeit. Bei `hash === null` wird gegen
* einen Dummy-Hash verglichen und immer false zurueckgegeben.
*/
export async function verifyPassword(password: string, hash: string | null): Promise<boolean> {
const effectiveHash = hash ?? (await dummyHashPromise);
const matches = await bcrypt.compare(password, effectiveHash);
return matches && hash !== null;
}
/**
* Erzeugt das Session-JWT inkl. Rolle und sessionVersion. Hinweis: Rolle und
* Version im Token dienen dem Proxy als schneller Vorfilter — autoritativ
* ist immer die Datenbank (getCurrentCustomer/getCurrentAdmin).
*/
export async function createSession(customerId: string, role: SessionRole, sessionVersion: number): Promise<void> {
const token = await signSessionToken({ customerId, role, sessionVersion });
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: role === "ADMIN" ? ADMIN_SESSION_MAX_AGE_SECONDS : SESSION_MAX_AGE_SECONDS,
});
}
/**
* Loescht nur das Session-Cookie auf diesem Geraet — erhoeht bewusst NICHT
* sessionVersion (das ist global pro Kunde, nicht pro Geraet/Sitzung). Ein
* bereits vor dem Logout kopiertes/gestohlenes JWT bliebe dadurch bis zum
* natuerlichen Ablauf gueltig; das ist der bekannte Kompromiss der
* Einzel-Zaehler-Architektur (kein separates Session-Token pro Geraet).
* Fuer den Fall eines vermuteten Diebstahls existiert bewusst "Alle Geraete
* abmelden" (erhoeht sessionVersion) als eigene, staerkere Aktion — wuerde
* ein normales Logout dasselbe tun, waeren beide Aktionen identisch und man
* koennte sich nicht mehr auf nur einem Geraet abmelden, ohne alle anderen
* aktiven Sitzungen ebenfalls zu beenden.
*/
export async function destroySession(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE);
}
export async function getCurrentCustomer(): Promise<Customer | null> {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token) return null;
const payload = await verifySessionToken(token);
if (!payload) return null;
const customer = await prisma.customer.findUnique({ where: { id: payload.customerId } });
if (!customer) return null;
// Session widerrufen (Passwort-Reset, "alle Geraete abmelden", 2FA-Aenderung)?
// Das Token selbst ist noch gueltig/unmanipuliert, zaehlt aber nicht mehr.
if (customer.sessionVersion !== payload.sessionVersion) return null;
// Ohne diese Pruefung wuerde eine bestehende Session einer admin-gesperrten
// Kundschaft weiter funktionieren, bis das Cookie natuerlich ablaeuft —
// die Sperre in /admin/benutzer muss auch aktive Sitzungen sofort beenden.
if (customer.blockedAt) return null;
return customer;
}
/**
* Autoritative Admin-Pruefung gegen die Datenbank (nicht nur das JWT-Claim).
* Muss in jeder Admin-Server-Action und im Admin-Layout aufgerufen werden.
*/
export async function getCurrentAdmin(): Promise<Customer | null> {
const customer = await getCurrentCustomer();
return customer?.role === "ADMIN" ? customer : null;
}
/** customerId aus einem gueltigen 2FA-Zwischenschritt-Cookie (Passwort ok, Code steht noch aus). */
export async function getTwoFactorChallengeCustomerId(): Promise<string | null> {
const cookieStore = await cookies();
const token = cookieStore.get(TWO_FACTOR_CHALLENGE_COOKIE)?.value;
if (!token) return null;
return (await verifyTwoFactorChallenge(token))?.customerId ?? null;
}
export async function setTwoFactorChallengeCookie(token: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(TWO_FACTOR_CHALLENGE_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 5 * 60,
});
}
export async function clearTwoFactorChallengeCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(TWO_FACTOR_CHALLENGE_COOKIE);
}
export async function setTwoFactorSetupCookie(token: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(TWO_FACTOR_SETUP_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 10 * 60,
});
}
/** Liest secret + customerId aus dem laufenden 2FA-Einrichtungsversuch, ohne das Cookie zu verbrauchen. */
export async function getTwoFactorSetup(): Promise<{ customerId: string; secret: string } | null> {
const cookieStore = await cookies();
const token = cookieStore.get(TWO_FACTOR_SETUP_COOKIE)?.value;
if (!token) return null;
return verifyTwoFactorSetup(token);
}
export async function clearTwoFactorSetupCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(TWO_FACTOR_SETUP_COOKIE);
}
export async function setTwoFactorBackupCodesDisplayCookie(token: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(TWO_FACTOR_BACKUP_CODES_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 2 * 60,
});
}
/**
* Liest die frisch generierten Backup-Codes aus (ohne das Cookie zu loeschen —
* Cookies duerfen waehrend des Server-Component-Renderns nicht mutiert werden,
* nur innerhalb einer Server Action/Route Handler). Das Cookie hat ohnehin nur
* eine 2-Minuten-TTL; zusaetzlich raeumt clearTwoFactorBackupCodesDisplayCookie
* es aktiv auf, sobald der Nutzer die Anzeige bestaetigt.
*/
export async function getTwoFactorBackupCodesDisplay(): Promise<string[] | null> {
const cookieStore = await cookies();
const token = cookieStore.get(TWO_FACTOR_BACKUP_CODES_COOKIE)?.value;
if (!token) return null;
const payload = await verifyTwoFactorBackupCodesDisplay(token);
return payload?.codes ?? null;
}
export async function clearTwoFactorBackupCodesDisplayCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(TWO_FACTOR_BACKUP_CODES_COOKIE);
}
+48
View File
@@ -0,0 +1,48 @@
import { describe, test, expect } from "vitest";
import { resolveBulkDiscount, getNextBulkDiscountTier } from "./bulk-discount-calc";
const tiers = [
{ code: "MENGE10", minQuantity: 3, percent: 10 },
{ code: "MENGE15", minQuantity: 6, percent: 15 },
];
describe("resolveBulkDiscount", () => {
test("returns null when no tier is configured", () => {
expect(resolveBulkDiscount([], 5)).toBeNull();
});
test("returns null below the lowest threshold", () => {
expect(resolveBulkDiscount(tiers, 2)).toBeNull();
});
test("picks the lowest tier at its boundary", () => {
expect(resolveBulkDiscount(tiers, 3)).toEqual({ code: "MENGE10", minQuantity: 3, percent: 10 });
});
test("picks the higher tier once its threshold is reached", () => {
expect(resolveBulkDiscount(tiers, 6)).toEqual({ code: "MENGE15", minQuantity: 6, percent: 15 });
});
test("is unaffected by tier input order", () => {
const shuffled = [...tiers].reverse();
expect(resolveBulkDiscount(shuffled, 6)).toEqual({ code: "MENGE15", minQuantity: 6, percent: 15 });
});
});
describe("getNextBulkDiscountTier", () => {
test("returns null when no tier is configured", () => {
expect(getNextBulkDiscountTier([], 1)).toBeNull();
});
test("returns null once the highest tier is reached", () => {
expect(getNextBulkDiscountTier(tiers, 6)).toBeNull();
});
test("reports units and percent of the next tier", () => {
expect(getNextBulkDiscountTier(tiers, 1)).toEqual({ unitsUntilNext: 2, percent: 10 });
});
test("reports the tier after the currently active one", () => {
expect(getNextBulkDiscountTier(tiers, 3)).toEqual({ unitsUntilNext: 3, percent: 15 });
});
});
+33
View File
@@ -0,0 +1,33 @@
export interface BulkDiscountTierLike {
code: string;
minQuantity: number;
percent: number;
}
/**
* Reine Zuordnungsfunktionen ohne Datenbankzugriff — analog zu
* shipping-calc.ts. Mengenrabatte sind normale DiscountCode-Zeilen mit
* gesetztem minQuantity (im Admin-Bereich unter Gutscheine editierbar), diese
* Funktionen waehlen daraus die passende(n) Stufe fuer Anzeige/Anwendung.
*/
export function resolveBulkDiscount(
tiers: BulkDiscountTierLike[],
totalQuantity: number
): BulkDiscountTierLike | null {
const sorted = [...tiers].sort((a, b) => a.minQuantity - b.minQuantity);
let best: BulkDiscountTierLike | null = null;
for (const tier of sorted) {
if (totalQuantity >= tier.minQuantity) best = tier;
}
return best;
}
export function getNextBulkDiscountTier(
tiers: BulkDiscountTierLike[],
totalQuantity: number
): { unitsUntilNext: number; percent: number } | null {
const sorted = [...tiers].sort((a, b) => a.minQuantity - b.minQuantity);
const next = sorted.find((tier) => tier.minQuantity > totalQuantity);
if (!next) return null;
return { unitsUntilNext: next.minQuantity - totalQuantity, percent: next.percent };
}
+44
View File
@@ -0,0 +1,44 @@
import { prisma } from "@/lib/prisma";
import type { BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
// Startwert, falls noch kein Mengenrabatt-Code existiert — danach frei im
// Admin-Bereich (Gutscheine) editierbar, siehe ensureBulkDiscountCode().
const BULK_DISCOUNT_CODE = "MENGE10";
const BULK_DISCOUNT_PERCENT = 10;
const BULK_DISCOUNT_MIN_QUANTITY = 3;
/**
* Idempotent — legt einen Mengenrabatt-Code als Startwert an, falls noch
* keiner existiert. Danach ist er im Admin-Bereich (Gutscheine) frei in Hoehe
* (value) und Menge (minQuantity) editierbar — update:{} ueberschreibt
* bestehende Admin-Anpassungen nie.
*/
export async function ensureBulkDiscountCode(): Promise<void> {
await prisma.discountCode.upsert({
where: { code: BULK_DISCOUNT_CODE },
create: {
code: BULK_DISCOUNT_CODE,
type: "PERCENT",
value: BULK_DISCOUNT_PERCENT,
minOrderCents: 0,
minQuantity: BULK_DISCOUNT_MIN_QUANTITY,
},
update: {},
});
}
/**
* Alle aktuell gueltigen Mengenrabatt-Codes (minQuantity gesetzt) als leichte
* Vorschau-Daten fuer Warenkorb/Checkout — die eigentliche Anwendung im
* Checkout laeuft weiterhin ueber die volle DiscountCode-Zeile (fuer
* usedCount/Order-Verknuepfung), s. api/checkout/route.ts.
*/
export async function getBulkDiscountTiers(): Promise<BulkDiscountTierLike[]> {
const now = new Date();
const codes = await prisma.discountCode.findMany({
where: { minQuantity: { not: null }, type: "PERCENT", isActive: true },
});
return codes
.filter((code) => (!code.validFrom || code.validFrom <= now) && (!code.validUntil || code.validUntil >= now))
.map((code) => ({ code: code.code, minQuantity: code.minQuantity as number, percent: code.value }));
}
+44
View File
@@ -0,0 +1,44 @@
import { siteConfig } from "@/lib/site-config";
import { getContentValues } from "@/lib/content";
const COMPANY_KEYS = [
"company.name",
"company.claim",
"company.claimAlt",
"company.description",
"company.location",
"company.email",
"company.phone",
"company.street",
"company.zip",
"company.city",
] as const;
/**
* Drop-in-Ersatz fuer siteConfig mit admin-gepflegten Overrides (siehe
* admin/inhalte) — gleiche Feldnamen/Form wie siteConfig, damit bestehende
* Konsumenten nur `siteConfig` gegen `await getCompanyInfo()` tauschen
* muessen. domain/country bleiben aus siteConfig (keine Content-Felder,
* echte Infra-Werte).
*/
export async function getCompanyInfo() {
// Firmendaten sind nicht lokalisiert (siehe content-registry.ts) — die
// uebergebene Locale wirkt sich fuer diese Schluessel nicht aus.
const v = await getContentValues([...COMPANY_KEYS], "de");
return {
...siteConfig,
name: v["company.name"],
claim: v["company.claim"],
claimAlt: v["company.claimAlt"],
description: v["company.description"],
location: v["company.location"],
email: v["company.email"],
phone: v["company.phone"],
address: {
...siteConfig.address,
street: v["company.street"],
zip: v["company.zip"],
city: v["company.city"],
},
};
}
+91
View File
@@ -0,0 +1,91 @@
import { describe, test, expect } from "vitest";
import { parseListSimple, parseListTitled, parseListNumbered, parseSections } from "./content-parsers";
describe("parseListSimple", () => {
test("splits one item per line", () => {
expect(parseListSimple("Erste Zeile\nZweite Zeile")).toEqual(["Erste Zeile", "Zweite Zeile"]);
});
test("trims whitespace and drops blank lines", () => {
expect(parseListSimple(" Erste \n\n \nZweite")).toEqual(["Erste", "Zweite"]);
});
test("returns an empty array for empty input", () => {
expect(parseListSimple("")).toEqual([]);
});
});
describe("parseListTitled", () => {
test("splits Title | Text pairs", () => {
expect(parseListTitled("Regional | Aus der Region\nEhrlich | Klare Herkunft")).toEqual([
{ title: "Regional", text: "Aus der Region" },
{ title: "Ehrlich", text: "Klare Herkunft" },
]);
});
test("trims whitespace around title and text", () => {
expect(parseListTitled(" Regional | Aus der Region ")).toEqual([
{ title: "Regional", text: "Aus der Region" },
]);
});
test("keeps additional pipe characters as part of the text", () => {
expect(parseListTitled("Regional | Text mit | einem Pipe-Zeichen")).toEqual([
{ title: "Regional", text: "Text mit | einem Pipe-Zeichen" },
]);
});
test("a line without a pipe becomes the title with empty text", () => {
expect(parseListTitled("Nur ein Titel")).toEqual([{ title: "Nur ein Titel", text: "" }]);
});
test("drops blank lines", () => {
expect(parseListTitled("Regional | Text\n\n")).toEqual([{ title: "Regional", text: "Text" }]);
});
});
describe("parseListNumbered", () => {
test("splits Nummer | Titel | Text triples", () => {
expect(parseListNumbered("01 | Ernten | Von Hand geerntet\n02 | Schleudern | Kalt geschleudert")).toEqual([
{ number: "01", title: "Ernten", text: "Von Hand geerntet" },
{ number: "02", title: "Schleudern", text: "Kalt geschleudert" },
]);
});
test("keeps additional pipe characters as part of the text", () => {
expect(parseListNumbered("01 | Ernten | Text mit | einem Pipe")).toEqual([
{ number: "01", title: "Ernten", text: "Text mit | einem Pipe" },
]);
});
test("a line with only a number gets empty title and text", () => {
expect(parseListNumbered("01")).toEqual([{ number: "01", title: "", text: "" }]);
});
});
describe("parseSections", () => {
test("splits multiple ## headings into separate sections", () => {
const text = "## Erste Sektion\n\nAbsatz eins.\n\n## Zweite Sektion\n\nAbsatz zwei.";
expect(parseSections(text)).toEqual([
{ heading: "Erste Sektion", paragraphs: ["Absatz eins."] },
{ heading: "Zweite Sektion", paragraphs: ["Absatz zwei."] },
]);
});
test("keeps multiple paragraphs within one section, split on blank lines", () => {
const text = "## Sektion\n\nErster Absatz.\n\nZweiter Absatz.";
expect(parseSections(text)).toEqual([
{ heading: "Sektion", paragraphs: ["Erster Absatz.", "Zweiter Absatz."] },
]);
});
test("a section with no body text gets an empty paragraphs array", () => {
expect(parseSections("## Nur eine Ueberschrift")).toEqual([
{ heading: "Nur eine Ueberschrift", paragraphs: [] },
]);
});
test("returns an empty array for empty input", () => {
expect(parseSections("")).toEqual([]);
});
});
+50
View File
@@ -0,0 +1,50 @@
import type { TitledText, LegalSectionContent } from "@/i18n/dictionary-type";
/**
* Reine Parser-Funktionen fuer das Klartext-Format, in dem die
* Content-Registry-Felder (siehe content-registry.ts) gespeichert werden.
* Bewusst in einer eigenen Datei ohne DB-/Prisma-Import: content.ts zieht
* @/lib/prisma nach sich, dessen Modul-Ladezeit-Treiber-Adapter ohne gesetzte
* DATABASE_URL wirft — das wuerde jeden Test dieser reinen Funktionen
* zwingen, eine DB-Verbindung vorzutaeuschen, obwohl sie keine brauchen.
*/
export function parseListSimple(text: string): string[] {
return text.split("\n").map((line) => line.trim()).filter(Boolean);
}
export function parseListTitled(text: string): TitledText[] {
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [title, ...rest] = line.split("|");
return { title: (title ?? "").trim(), text: rest.join("|").trim() };
});
}
export function parseListNumbered(text: string): { number: string; title: string; text: string }[] {
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [number, title, ...rest] = line.split("|");
return { number: (number ?? "").trim(), title: (title ?? "").trim(), text: rest.join("|").trim() };
});
}
export function parseSections(text: string): LegalSectionContent[] {
const blocks = text.split(/\n(?=##\s)/).map((b) => b.trim()).filter(Boolean);
return blocks.map((block) => {
const headingMatch = block.match(/^##\s*(.*)$/m);
const heading = headingMatch ? headingMatch[1].trim() : "";
const body = block.replace(/^##\s*.*$/m, "").trim();
const paragraphs = body
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter(Boolean);
return { heading, paragraphs };
});
}
+80
View File
@@ -0,0 +1,80 @@
import { getDictionary } from "@/i18n/get-dictionary";
import { siteConfig } from "@/lib/site-config";
import type { Locale } from "@/i18n/config";
import type { TitledText, LegalSectionContent } from "@/i18n/dictionary-type";
export type ContentFieldType =
| "text"
| "textarea"
| "list-simple"
| "list-titled"
| "list-numbered"
| "richtext-sections";
export interface ContentFieldDef {
key: string;
group: "company" | "home" | "legal";
labelDe: string;
labelEn: string;
type: ContentFieldType;
/** false = ein einziger Wert unabhaengig von der Locale (Firmendaten). */
localized: boolean;
/** Liefert den aktuellen Fallback-Text im selben Format, das der Parser fuer diesen Typ erwartet. */
getDefault: (locale: Locale) => string;
}
function joinSimpleList(items: string[]): string {
return items.join("\n");
}
function joinTitledList(items: TitledText[]): string {
return items.map((i) => `${i.title} | ${i.text}`).join("\n");
}
function joinNumberedList(items: { number: string; title: string; text: string }[]): string {
return items.map((i) => `${i.number} | ${i.title} | ${i.text}`).join("\n");
}
function joinSections(sections: LegalSectionContent[]): string {
return sections
.map((s) => `## ${s.heading}\n\n${s.paragraphs.join("\n\n")}`)
.join("\n\n");
}
export const CONTENT_REGISTRY: ContentFieldDef[] = [
// --- Firmendaten (nicht lokalisiert) ---
{ key: "company.name", group: "company", labelDe: "Firmenname", labelEn: "Company name", type: "text", localized: false, getDefault: () => siteConfig.name },
{ key: "company.claim", group: "company", labelDe: "Slogan", labelEn: "Tagline", type: "text", localized: false, getDefault: () => siteConfig.claim },
{ key: "company.claimAlt", group: "company", labelDe: "Alternativer Slogan", labelEn: "Alternate tagline", type: "text", localized: false, getDefault: () => siteConfig.claimAlt },
{ key: "company.description", group: "company", labelDe: "Kurzbeschreibung", labelEn: "Short description", type: "textarea", localized: false, getDefault: () => siteConfig.description },
{ key: "company.location", group: "company", labelDe: "Standort (Anzeigetext)", labelEn: "Location (display text)", type: "text", localized: false, getDefault: () => siteConfig.location },
{ key: "company.email", group: "company", labelDe: "E-Mail", labelEn: "Email", type: "text", localized: false, getDefault: () => siteConfig.email },
{ key: "company.phone", group: "company", labelDe: "Telefon", labelEn: "Phone", type: "text", localized: false, getDefault: () => siteConfig.phone },
{ key: "company.street", group: "company", labelDe: "Straße", labelEn: "Street", type: "text", localized: false, getDefault: () => siteConfig.address.street },
{ key: "company.zip", group: "company", labelDe: "PLZ", labelEn: "ZIP", type: "text", localized: false, getDefault: () => siteConfig.address.zip },
{ key: "company.city", group: "company", labelDe: "Ort", labelEn: "City", type: "text", localized: false, getDefault: () => siteConfig.address.city },
// --- Homepage (lokalisiert) ---
{ key: "home.hero.intro", group: "home", labelDe: "Hero: Einleitungstext", labelEn: "Hero: Intro text", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.hero.intro },
{ key: "home.hero.footnote", group: "home", labelDe: "Hero: Fußnote", labelEn: "Hero: Footnote", type: "text", localized: true, getDefault: (l) => getDictionary(l).home.hero.footnote },
{ key: "home.originStory.paragraph1", group: "home", labelDe: "Herkunft: Absatz 1", labelEn: "Origin: Paragraph 1", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.originStory.paragraph1 },
{ key: "home.originStory.paragraph2", group: "home", labelDe: "Herkunft: Absatz 2", labelEn: "Origin: Paragraph 2", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.originStory.paragraph2 },
{ key: "home.originStory.facts", group: "home", labelDe: "Herkunft: Fakten (eine Zeile je Fakt)", labelEn: "Origin: Facts (one per line)", type: "list-simple", localized: true, getDefault: (l) => joinSimpleList(getDictionary(l).home.originStory.facts) },
{ key: "home.philosophy.items", group: "home", labelDe: "Philosophie: Punkte (Titel | Text, eine Zeile je Punkt)", labelEn: "Philosophy: Items (Title | Text, one per line)", type: "list-titled", localized: true, getDefault: (l) => joinTitledList(getDictionary(l).home.philosophy.items) },
{ key: "home.processTimeline.steps", group: "home", labelDe: "Ablauf: Schritte (Nr. | Titel | Text, eine Zeile je Schritt)", labelEn: "Process: Steps (No. | Title | Text, one per line)", type: "list-numbered", localized: true, getDefault: (l) => joinNumberedList(getDictionary(l).home.processTimeline.steps) },
{ key: "home.regionalSection.paragraph1", group: "home", labelDe: "Regional: Absatz 1", labelEn: "Regional: Paragraph 1", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.regionalSection.paragraph1 },
{ key: "home.regionalSection.paragraph2", group: "home", labelDe: "Regional: Absatz 2", labelEn: "Regional: Paragraph 2", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.regionalSection.paragraph2 },
{ key: "home.sustainability.items", group: "home", labelDe: "Nachhaltigkeit: Punkte (Titel | Text, eine Zeile je Punkt)", labelEn: "Sustainability: Items (Title | Text, one per line)", type: "list-titled", localized: true, getDefault: (l) => joinTitledList(getDictionary(l).home.sustainability.items) },
{ key: "home.businessTeaser.paragraph", group: "home", labelDe: "Geschäftskunden-Hinweis: Text", labelEn: "Business teaser: Text", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.businessTeaser.paragraph },
// --- Rechtstexte (lokalisiert) ---
{ key: "legal.impressum", group: "legal", labelDe: "Impressum", labelEn: "Legal notice", type: "richtext-sections", localized: true, getDefault: (l) => joinSections(getDictionary(l).impressumPage.sections) },
{ key: "legal.datenschutz", group: "legal", labelDe: "Datenschutz", labelEn: "Privacy policy", type: "richtext-sections", localized: true, getDefault: (l) => joinSections(getDictionary(l).datenschutzPage.sections) },
{ key: "legal.agb", group: "legal", labelDe: "AGB", labelEn: "Terms and conditions", type: "richtext-sections", localized: true, getDefault: (l) => joinSections(getDictionary(l).agbPage.sections) },
{ key: "legal.widerruf", group: "legal", labelDe: "Widerrufsbelehrung", labelEn: "Right of withdrawal", type: "richtext-sections", localized: true, getDefault: (l) => joinSections(getDictionary(l).widerrufPage.sections) },
{ key: "legal.cookieRichtlinie", group: "legal", labelDe: "Cookie-Richtlinie", labelEn: "Cookie policy", type: "richtext-sections", localized: true, getDefault: (l) => joinSections(getDictionary(l).cookieRichtliniePage.sections) },
];
export function getContentFieldDef(key: string): ContentFieldDef | undefined {
return CONTENT_REGISTRY.find((f) => f.key === key);
}
+50
View File
@@ -0,0 +1,50 @@
import { prisma } from "@/lib/prisma";
import { CONTENT_REGISTRY, getContentFieldDef, type ContentFieldDef } from "@/lib/content-registry";
import type { Locale } from "@/i18n/config";
/**
* Liefert den aktuell wirksamen Text fuer einen Registry-Schluessel — den
* Admin-Override aus SiteContent, falls gepflegt, sonst den urspruenglichen
* Dictionary-/site-config-Text. Nie leer/undefined, solange der Schluessel in
* der Registry existiert.
*/
export async function getContentValue(key: string, locale: Locale): Promise<string> {
const def = getContentFieldDef(key);
if (!def) return "";
const row = await prisma.siteContent.findUnique({ where: { key } });
if (row) {
const value = def.localized ? (locale === "de" ? row.valueDe : (row.valueEn ?? row.valueDe)) : row.valueDe;
if (value.trim() !== "") return value;
}
return def.getDefault(locale);
}
/** Batch-Variante fuer Seiten, die mehrere Schluessel auf einmal brauchen (ein DB-Roundtrip statt vieler). */
export async function getContentValues(keys: string[], locale: Locale): Promise<Record<string, string>> {
const rows = await prisma.siteContent.findMany({ where: { key: { in: keys } } });
const rowsByKey = new Map(rows.map((r) => [r.key, r]));
const result: Record<string, string> = {};
for (const key of keys) {
const def = getContentFieldDef(key);
if (!def) {
result[key] = "";
continue;
}
const row = rowsByKey.get(key);
const overridden = row
? def.localized
? locale === "de"
? row.valueDe
: (row.valueEn ?? row.valueDe)
: row.valueDe
: "";
result[key] = overridden.trim() !== "" ? overridden : def.getDefault(locale);
}
return result;
}
export { parseListSimple, parseListTitled, parseListNumbered, parseSections } from "@/lib/content-parsers";
export { CONTENT_REGISTRY };
export type { ContentFieldDef };
+132
View File
@@ -0,0 +1,132 @@
import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array";
import type { Product, Harvest, Batch, ProductCategory, Prisma } from "@/generated/prisma/client";
// tasteTags/images sind im Schema als Json modelliert (MySQL-Kompatibilitaet,
// siehe schema.prisma) — hier auf das tatsaechliche Laufzeitformat
// (String-Array) zurueckgeschmalert, damit der Rest der App wie zuvor damit
// arbeiten kann. normalizeProduct() unten stellt sicher, dass das an jeder
// Rueckgabestelle in diesem Modul auch wirklich so ankommt.
export type ProductWithRelations = Omit<Product, "tasteTags" | "images"> & {
tasteTags: string[];
images: string[];
harvest: Harvest | null;
batch: Batch | null;
inventory: { quantity: number } | null;
};
function normalizeProduct<T extends { tasteTags: unknown; images: unknown }>(
product: T
): Omit<T, "tasteTags" | "images"> & { tasteTags: string[]; images: string[] } {
return { ...product, tasteTags: asStringArray(product.tasteTags), images: asStringArray(product.images) };
}
export type ProductSort = "preis-auf" | "preis-ab" | "verfuegbarkeit";
export interface GetActiveProductsOptions {
query?: string;
category?: ProductCategory;
sort?: ProductSort;
/** Nur Produkte mit Lagerbestand > 0 anzeigen. */
onlyAvailable?: boolean;
}
export async function getActiveProducts(
options: GetActiveProductsOptions = {}
): Promise<ProductWithRelations[]> {
const trimmed = options.query?.trim();
let orderBy: Prisma.ProductOrderByWithRelationInput = { createdAt: "asc" };
if (options.sort === "preis-auf") orderBy = { priceCents: "asc" };
else if (options.sort === "preis-ab") orderBy = { priceCents: "desc" };
else if (options.sort === "verfuegbarkeit") orderBy = { inventory: { quantity: "desc" } };
const products = (
await prisma.product.findMany({
where: {
isActive: true,
...(options.category ? { category: options.category } : {}),
...(options.onlyAvailable ? { inventory: { quantity: { gt: 0 } } } : {}),
},
include: { harvest: true, batch: true, inventory: { select: { quantity: true } } },
orderBy,
})
).map(normalizeProduct);
if (!trimmed) return products;
// Teilstring-Suche auf einzelnen tasteTags-Elementen unterstuetzt Prisma
// fuer Postgres-Arrays nicht (nur exakte Gleichheit via "has") — bei der
// ueberschaubaren Katalogsgroesse wird deshalb hier statt in der DB-Query
// gefiltert. Das behebt zugleich, dass mehrwortige, gemischt geschriebene
// Tags wie "reines Bienenwachs" bei case-insensitiver Suche nie trafen.
const words = trimmed.toLowerCase().split(/\s+/).filter(Boolean);
return products.filter((product) => {
const haystack = [product.name, product.description, ...product.tasteTags]
.join(" ")
.toLowerCase();
return words.every((word) => haystack.includes(word));
});
}
/** Bis zu `limit` weitere aktive Produkte derselben Kategorie fuer "Das koennte dir auch gefallen". */
export async function getRelatedProducts(
productId: string,
category: ProductCategory,
limit = 3
): Promise<ProductWithRelations[]> {
const products = await prisma.product.findMany({
where: { isActive: true, category, id: { not: productId } },
include: { harvest: true, batch: true, inventory: { select: { quantity: true } } },
orderBy: { createdAt: "desc" },
take: limit,
});
return products.map(normalizeProduct);
}
export async function getProductBySlug(
slug: string
): Promise<ProductWithRelations | null> {
const product = await prisma.product.findUnique({
where: { slug },
include: { harvest: true, batch: true, inventory: { select: { quantity: true } } },
});
return product ? normalizeProduct(product) : null;
}
export async function getProductsByIds(
ids: string[]
): Promise<ProductWithRelations[]> {
if (ids.length === 0) return [];
const products = await prisma.product.findMany({
where: { id: { in: ids } },
include: { harvest: true, batch: true, inventory: { select: { quantity: true } } },
});
return products.map(normalizeProduct);
}
export async function getHarvests(): Promise<(Harvest & { batches: Batch[] })[]> {
return prisma.harvest.findMany({
include: { batches: true },
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
});
}
export async function getHarvestByYearType(
year: number,
type: string
): Promise<(Harvest & { batches: Batch[]; products: Product[] }) | null> {
return prisma.harvest.findUnique({
where: { year_type: { year, type } },
include: { batches: true, products: true },
});
}
export async function getBatchByCode(
code: string
): Promise<(Batch & { harvest: Harvest; products: Product[] }) | null> {
return prisma.batch.findUnique({
where: { code },
include: { harvest: true, products: true },
});
}
+22
View File
@@ -0,0 +1,22 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
/**
* Prisma 7 liest die Verbindung nicht mehr aus dem Schema, sondern erfordert
* einen expliziten Treiber-Adapter im Client — welcher, haengt von
* DATABASE_PROVIDER ab (muss zum "provider" passen, den
* scripts/prisma-provider.mjs zuletzt in prisma/schema.prisma geschrieben
* hat). Gemeinsam genutzt von lib/prisma.ts (App-Laufzeit) und
* prisma/seed.ts (Standalone-Skript), damit beide immer denselben Provider
* verwenden.
*/
export function createDbAdapter(): PrismaPg | PrismaMariaDb {
const provider = (process.env.DATABASE_PROVIDER ?? "postgresql").trim();
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error("DATABASE_URL ist nicht gesetzt.");
if (provider === "mysql") {
return new PrismaMariaDb(connectionString);
}
return new PrismaPg({ connectionString });
}
+90
View File
@@ -0,0 +1,90 @@
import { describe, test, expect } from "vitest";
import { isDiscountCodeValid, calculateDiscountCents, type DiscountCodeLike } from "./discount-calc";
const now = new Date("2026-08-12T12:00:00Z");
function makeCode(overrides: Partial<DiscountCodeLike> = {}): DiscountCodeLike {
return {
isActive: true,
type: "PERCENT",
value: 15,
minOrderCents: 0,
minQuantity: null,
maxUses: null,
usedCount: 0,
validFrom: null,
validUntil: null,
...overrides,
};
}
describe("isDiscountCodeValid", () => {
test("rejects a missing code", () => {
expect(isDiscountCodeValid(null, 1000, now)).toBe(false);
});
test("rejects a deactivated code", () => {
expect(isDiscountCodeValid(makeCode({ isActive: false }), 1000, now)).toBe(false);
});
test("rejects a code that has not started yet", () => {
const code = makeCode({ validFrom: new Date("2026-09-01T00:00:00Z") });
expect(isDiscountCodeValid(code, 1000, now)).toBe(false);
});
test("rejects a code that has already expired", () => {
const code = makeCode({ validUntil: new Date("2026-01-01T00:00:00Z") });
expect(isDiscountCodeValid(code, 1000, now)).toBe(false);
});
test("rejects a code that has reached its usage limit", () => {
const code = makeCode({ maxUses: 5, usedCount: 5 });
expect(isDiscountCodeValid(code, 1000, now)).toBe(false);
});
test("accepts a code below its usage limit", () => {
const code = makeCode({ maxUses: 5, usedCount: 4 });
expect(isDiscountCodeValid(code, 1000, now)).toBe(true);
});
test("rejects an order below the minimum order value", () => {
const code = makeCode({ minOrderCents: 5000 });
expect(isDiscountCodeValid(code, 1000, now)).toBe(false);
});
test("accepts a code with no restrictions", () => {
expect(isDiscountCodeValid(makeCode(), 1000, now)).toBe(true);
});
test("rejects a quantity-gated code below its minimum quantity", () => {
const code = makeCode({ minQuantity: 3 });
expect(isDiscountCodeValid(code, 1000, now, 2)).toBe(false);
});
test("accepts a quantity-gated code at its minimum quantity", () => {
const code = makeCode({ minQuantity: 3 });
expect(isDiscountCodeValid(code, 1000, now, 3)).toBe(true);
});
test("ignores quantity when totalQuantity is omitted and no gate would apply anyway", () => {
expect(isDiscountCodeValid(makeCode(), 1000, now)).toBe(true);
});
});
describe("calculateDiscountCents", () => {
test("rounds a percent discount to the nearest cent", () => {
expect(calculateDiscountCents({ type: "PERCENT", value: 15 }, 5450)).toBe(818);
});
test("caps a fixed discount at the subtotal so the order can never go negative", () => {
expect(calculateDiscountCents({ type: "FIXED", value: 2000 }, 1500)).toBe(1500);
});
test("applies a fixed discount fully when it is smaller than the subtotal", () => {
expect(calculateDiscountCents({ type: "FIXED", value: 500 }, 5000)).toBe(500);
});
test("a 100% percent discount zeroes out the subtotal", () => {
expect(calculateDiscountCents({ type: "PERCENT", value: 100 }, 5450)).toBe(5450);
});
});
+40
View File
@@ -0,0 +1,40 @@
export interface DiscountCodeLike {
isActive: boolean;
type: "PERCENT" | "FIXED";
value: number;
minOrderCents: number;
minQuantity: number | null;
maxUses: number | null;
usedCount: number;
validFrom: Date | null;
validUntil: Date | null;
}
/**
* Reine Pruef-/Berechnungsfunktionen ohne Datenbankzugriff — bewusst getrennt
* von der Checkout-Route (analog zu shipping-calc.ts), damit die Geldlogik
* isoliert testbar ist. totalQuantity ist optional (Default: praktisch immer
* erfuellt) — nur Codes mit gesetztem minQuantity (Mengenrabatte) pruefen ihn
* ueberhaupt, normale Gutscheine sind davon unberuehrt.
*/
export function isDiscountCodeValid(
code: DiscountCodeLike | null,
subtotalCents: number,
now: Date,
totalQuantity: number = Infinity
): boolean {
if (!code) return false;
return (
code.isActive &&
(!code.validFrom || code.validFrom <= now) &&
(!code.validUntil || code.validUntil >= now) &&
(code.maxUses === null || code.usedCount < code.maxUses) &&
subtotalCents >= code.minOrderCents &&
(code.minQuantity === null || totalQuantity >= code.minQuantity)
);
}
/** Rabatt gilt nur auf den Warenwert, nie auf den Versand, und kappt bei FIXED an der Zwischensumme (kein negativer Betrag). */
export function calculateDiscountCents(code: Pick<DiscountCodeLike, "type" | "value">, subtotalCents: number): number {
return code.type === "PERCENT" ? Math.round((subtotalCents * code.value) / 100) : Math.min(code.value, subtotalCents);
}
+309
View File
@@ -0,0 +1,309 @@
import type { Locale } from "@/i18n/config";
/**
* Eigenstaendiges Uebersetzungs-Woerterbuch nur fuer Transaktions-E-Mails —
* bewusst getrennt vom UI-Dictionary (i18n/dictionary-type.ts), da E-Mails
* eigene, laengere Fliesstexte haben und unabhaengig vom UI-Kontext (z.B.
* beim Stripe-Webhook ohne Request/Cookie-Zugriff) gerendert werden.
*/
interface EmailStrings {
orderConfirmation: {
preheader: (orderNumber: string) => string;
heading: (firstName: string) => string;
intro: (orderNumber: string) => string;
subtotal: string;
shipping: string;
freeShipping: string;
discount: string;
total: string;
deliveryTo: string;
pickupNote: string;
giftNote: string;
trackOrder: string;
subject: (orderNumber: string) => string;
};
newsletterConfirm: {
preheader: string;
heading: string;
intro: string;
button: string;
ignoreNote: string;
subject: string;
};
passwordReset: {
preheader: string;
heading: string;
intro: string;
button: string;
ignoreNote: string;
subject: string;
};
securityAlert: {
preheader: string;
heading: string;
contactNote: string;
subject: string;
passwordChanged: string;
twoFactorDisabled: string;
accountDeleted: string;
};
emailVerification: {
preheader: string;
heading: string;
intro: string;
button: string;
ignoreNote: string;
subject: string;
};
shipment: {
preheader: (orderNumber: string) => string;
heading: (firstName: string) => string;
intro: (orderNumber: string) => string;
trackButton: string;
subject: (orderNumber: string) => string;
};
pickupReady: {
preheader: (orderNumber: string) => string;
heading: (firstName: string) => string;
intro: (orderNumber: string) => string;
subject: (orderNumber: string) => string;
};
backInStock: {
preheader: (name: string) => string;
heading: string;
intro: (name: string) => string;
button: string;
subject: (name: string) => string;
};
returnAccepted: {
preheader: (orderNumber: string) => string;
heading: (firstName: string) => string;
intro: (orderNumber: string) => string;
subject: (orderNumber: string) => string;
};
returnRejected: {
preheader: (orderNumber: string) => string;
heading: (firstName: string) => string;
intro: (orderNumber: string) => string;
subject: (orderNumber: string) => string;
};
returnRefunded: {
preheader: (orderNumber: string) => string;
heading: (firstName: string) => string;
intro: (orderNumber: string) => string;
subject: (orderNumber: string) => string;
};
paymentFailed: {
preheader: (orderNumber: string) => string;
heading: (firstName: string) => string;
intro: (orderNumber: string) => string;
retryNote: string;
subject: (orderNumber: string) => string;
};
}
const de: EmailStrings = {
orderConfirmation: {
preheader: (orderNumber) => `Deine Bestellung ${orderNumber} ist bestätigt.`,
heading: (firstName) => `Danke für deine Bestellung, ${firstName}!`,
intro: (orderNumber) => `Wir haben deine Bestellung <strong>${orderNumber}</strong> erhalten und die Zahlung bestätigt.`,
subtotal: "Zwischensumme",
shipping: "Versand",
freeShipping: "kostenlos",
discount: "Rabatt",
total: "Gesamt",
deliveryTo: "Lieferung an:",
pickupNote: "Abholung in Altenmünster — wir melden uns, sobald deine Bestellung bereitsteht.",
giftNote: "🎁 Deine persönliche Nachricht wird von Hand auf eine Karte geschrieben und der Bestellung beigelegt.",
trackOrder: "BESTELLUNG VERFOLGEN",
subject: (orderNumber) => `Bestellbestätigung ${orderNumber} — Wabenhain`,
},
newsletterConfirm: {
preheader: "Bitte bestätige deine Anmeldung zur Erntepost.",
heading: "Fast geschafft!",
intro: "Bitte bestätige deine Anmeldung zur Erntepost, damit wir dich informieren dürfen, sobald neuer Honig verfügbar ist.",
button: "ANMELDUNG BESTÄTIGEN",
ignoreNote: "Falls du dich nicht angemeldet hast, kannst du diese E-Mail einfach ignorieren.",
subject: "Bitte bestätige deine Anmeldung — Wabenhain Erntepost",
},
passwordReset: {
preheader: "Setze dein Wabenhain-Passwort zurück.",
heading: "Passwort zurücksetzen",
intro: "Wir haben eine Anfrage erhalten, das Passwort für dein Wabenhain-Konto zurückzusetzen. Der Link ist eine Stunde gültig.",
button: "NEUES PASSWORT VERGEBEN",
ignoreNote: "Falls du das nicht warst, kannst du diese E-Mail ignorieren — dein Passwort bleibt unverändert.",
subject: "Passwort zurücksetzen — Wabenhain",
},
securityAlert: {
preheader: "Sicherheitshinweis zu deinem Wabenhain-Konto.",
heading: "Sicherheitshinweis",
contactNote: "Falls das nicht du warst, kontaktiere uns bitte umgehend unter [email protected].",
subject: "Sicherheitshinweis — Wabenhain",
passwordChanged: "Das Passwort für dein Wabenhain-Konto wurde soeben geändert. Alle anderen angemeldeten Geräte wurden abgemeldet.",
twoFactorDisabled: "Die Zwei-Faktor-Authentifizierung für dein Wabenhain-Konto wurde soeben deaktiviert.",
accountDeleted: "Dein Wabenhain-Konto und alle persönlichen Daten wurden soeben endgültig gelöscht.",
},
emailVerification: {
preheader: "Bitte bestätige deine E-Mail-Adresse.",
heading: "Willkommen bei Wabenhain!",
intro: "Bitte bestätige deine E-Mail-Adresse, damit du mit deinem Konto bestellen kannst. Der Link ist 24 Stunden gültig.",
button: "E-MAIL-ADRESSE BESTÄTIGEN",
ignoreNote: "Falls du dieses Konto nicht erstellt hast, kannst du diese E-Mail einfach ignorieren.",
subject: "Bitte bestätige deine E-Mail-Adresse — Wabenhain",
},
shipment: {
preheader: (orderNumber) => `Deine Bestellung ${orderNumber} ist unterwegs.`,
heading: (firstName) => `Deine Bestellung ist unterwegs, ${firstName}!`,
intro: (orderNumber) => `Wir haben deine Bestellung <strong>${orderNumber}</strong> soeben versendet.`,
trackButton: "SENDUNG VERFOLGEN",
subject: (orderNumber) => `Deine Bestellung ${orderNumber} wurde versendet — Wabenhain`,
},
pickupReady: {
preheader: (orderNumber) => `Deine Bestellung ${orderNumber} ist abholbereit.`,
heading: (firstName) => `Deine Bestellung wartet auf dich, ${firstName}!`,
intro: (orderNumber) => `Deine Bestellung <strong>${orderNumber}</strong> ist abholbereit.`,
subject: (orderNumber) => `Deine Bestellung ${orderNumber} ist abholbereit — Wabenhain`,
},
backInStock: {
preheader: (name) => `${name} ist wieder verfügbar.`,
heading: "Gute Nachrichten!",
intro: (name) => `<strong>${name}</strong> ist wieder auf Lager.`,
button: "JETZT BESTELLEN",
subject: (name) => `${name} ist wieder verfügbar — Wabenhain`,
},
returnAccepted: {
preheader: (orderNumber) => `Deine Rücksendeanfrage zu ${orderNumber} wurde angenommen.`,
heading: (firstName) => `Deine Rücksendung wurde angenommen, ${firstName}.`,
intro: (orderNumber) =>
`Wir haben deine Rücksendeanfrage zu Bestellung <strong>${orderNumber}</strong> angenommen. Bitte sende uns die Ware zurück — sobald sie bei uns eingegangen ist, veranlassen wir die Erstattung.`,
subject: (orderNumber) => `Rücksendung zu ${orderNumber} angenommen — Wabenhain`,
},
returnRejected: {
preheader: (orderNumber) => `Deine Rücksendeanfrage zu ${orderNumber} wurde abgelehnt.`,
heading: (firstName) => `Deine Rücksendeanfrage wurde leider abgelehnt, ${firstName}.`,
intro: (orderNumber) =>
`Wir haben deine Rücksendeanfrage zu Bestellung <strong>${orderNumber}</strong> geprüft und können sie leider nicht annehmen. Bei Fragen dazu melde dich gerne bei uns.`,
subject: (orderNumber) => `Rücksendung zu ${orderNumber} abgelehnt — Wabenhain`,
},
returnRefunded: {
preheader: (orderNumber) => `Deine Bestellung ${orderNumber} wurde erstattet.`,
heading: (firstName) => `Deine Erstattung ist unterwegs, ${firstName}.`,
intro: (orderNumber) =>
`Wir haben die Rückerstattung für deine Bestellung <strong>${orderNumber}</strong> veranlasst. Je nach Zahlungsmethode kann es einige Werktage dauern, bis der Betrag bei dir ankommt.`,
subject: (orderNumber) => `Bestellung ${orderNumber} erstattet — Wabenhain`,
},
paymentFailed: {
preheader: (orderNumber) => `Die Zahlung zu Bestellung ${orderNumber} konnte nicht abgeschlossen werden.`,
heading: (firstName) => `Leider hat die Zahlung nicht geklappt, ${firstName}.`,
intro: (orderNumber) =>
`Der Zahlungseinzug für deine Bestellung <strong>${orderNumber}</strong> konnte nicht abgeschlossen werden. Wir haben die Bestellung daher storniert, es sind dir keine Kosten entstanden.`,
retryNote: "Du kannst die Bestellung gerne erneut aufgeben, zum Beispiel mit einer anderen Zahlungsmethode.",
subject: (orderNumber) => `Zahlung zu Bestellung ${orderNumber} fehlgeschlagen — Wabenhain`,
},
};
const en: EmailStrings = {
orderConfirmation: {
preheader: (orderNumber) => `Your order ${orderNumber} is confirmed.`,
heading: (firstName) => `Thanks for your order, ${firstName}!`,
intro: (orderNumber) => `We've received your order <strong>${orderNumber}</strong> and confirmed payment.`,
subtotal: "Subtotal",
shipping: "Shipping",
freeShipping: "free",
discount: "Discount",
total: "Total",
deliveryTo: "Delivering to:",
pickupNote: "Pickup in Altenmünster — we'll be in touch once your order is ready.",
giftNote: "🎁 Your personal message will be handwritten on a card and included with your order.",
trackOrder: "TRACK ORDER",
subject: (orderNumber) => `Order confirmation ${orderNumber} — Wabenhain`,
},
newsletterConfirm: {
preheader: "Please confirm your harvest post subscription.",
heading: "Almost there!",
intro: "Please confirm your harvest post subscription so we can let you know as soon as new honey is available.",
button: "CONFIRM SUBSCRIPTION",
ignoreNote: "If you didn't sign up, you can simply ignore this email.",
subject: "Please confirm your subscription — Wabenhain harvest post",
},
passwordReset: {
preheader: "Reset your Wabenhain password.",
heading: "Reset password",
intro: "We received a request to reset the password for your Wabenhain account. This link is valid for one hour.",
button: "SET NEW PASSWORD",
ignoreNote: "If this wasn't you, you can ignore this email — your password stays unchanged.",
subject: "Reset password — Wabenhain",
},
securityAlert: {
preheader: "Security notice for your Wabenhain account.",
heading: "Security notice",
contactNote: "If this wasn't you, please contact us immediately at [email protected].",
subject: "Security notice — Wabenhain",
passwordChanged: "The password for your Wabenhain account was just changed. All other signed-in devices have been logged out.",
twoFactorDisabled: "Two-factor authentication for your Wabenhain account was just disabled.",
accountDeleted: "Your Wabenhain account and all personal data have just been permanently deleted.",
},
emailVerification: {
preheader: "Please confirm your email address.",
heading: "Welcome to Wabenhain!",
intro: "Please confirm your email address so you can order with your account. This link is valid for 24 hours.",
button: "CONFIRM EMAIL ADDRESS",
ignoreNote: "If you didn't create this account, you can simply ignore this email.",
subject: "Please confirm your email address — Wabenhain",
},
shipment: {
preheader: (orderNumber) => `Your order ${orderNumber} is on its way.`,
heading: (firstName) => `Your order is on its way, ${firstName}!`,
intro: (orderNumber) => `We've just shipped your order <strong>${orderNumber}</strong>.`,
trackButton: "TRACK PACKAGE",
subject: (orderNumber) => `Your order ${orderNumber} has shipped — Wabenhain`,
},
pickupReady: {
preheader: (orderNumber) => `Your order ${orderNumber} is ready for pickup.`,
heading: (firstName) => `Your order is waiting for you, ${firstName}!`,
intro: (orderNumber) => `Your order <strong>${orderNumber}</strong> is ready for pickup.`,
subject: (orderNumber) => `Your order ${orderNumber} is ready for pickup — Wabenhain`,
},
backInStock: {
preheader: (name) => `${name} is back in stock.`,
heading: "Good news!",
intro: (name) => `<strong>${name}</strong> is back in stock.`,
button: "ORDER NOW",
subject: (name) => `${name} is back in stock — Wabenhain`,
},
returnAccepted: {
preheader: (orderNumber) => `Your return request for ${orderNumber} was accepted.`,
heading: (firstName) => `Your return was accepted, ${firstName}.`,
intro: (orderNumber) =>
`We've accepted your return request for order <strong>${orderNumber}</strong>. Please send the item back to us — once it arrives, we'll process your refund.`,
subject: (orderNumber) => `Return for ${orderNumber} accepted — Wabenhain`,
},
returnRejected: {
preheader: (orderNumber) => `Your return request for ${orderNumber} was declined.`,
heading: (firstName) => `Your return request was unfortunately declined, ${firstName}.`,
intro: (orderNumber) =>
`We've reviewed your return request for order <strong>${orderNumber}</strong> and are unable to accept it. Feel free to reach out with any questions.`,
subject: (orderNumber) => `Return for ${orderNumber} declined — Wabenhain`,
},
returnRefunded: {
preheader: (orderNumber) => `Your order ${orderNumber} has been refunded.`,
heading: (firstName) => `Your refund is on its way, ${firstName}.`,
intro: (orderNumber) =>
`We've processed the refund for your order <strong>${orderNumber}</strong>. Depending on your payment method, it may take a few business days to arrive.`,
subject: (orderNumber) => `Order ${orderNumber} refunded — Wabenhain`,
},
paymentFailed: {
preheader: (orderNumber) => `Payment for order ${orderNumber} could not be completed.`,
heading: (firstName) => `Unfortunately your payment didn't go through, ${firstName}.`,
intro: (orderNumber) =>
`The payment for your order <strong>${orderNumber}</strong> could not be completed. We've cancelled the order, so no costs were incurred.`,
retryNote: "Feel free to place the order again, for example with a different payment method.",
subject: (orderNumber) => `Payment for order ${orderNumber} failed — Wabenhain`,
},
};
const emailStrings: Record<Locale, EmailStrings> = { de, en };
export function getEmailStrings(locale: Locale): EmailStrings {
return emailStrings[locale];
}
+371
View File
@@ -0,0 +1,371 @@
import { formatPrice } from "@/lib/format";
import { siteConfig } from "@/lib/site-config";
import { getCompanyInfo } from "@/lib/company-info";
import { getEmailStrings } from "@/lib/email-strings";
import type { Locale } from "@/i18n/config";
interface EmailContent {
subject: string;
html: string;
text: string;
}
/**
* Gemeinsamer Rahmen fuer alle Transaktions-E-Mails: bewusst mit
* Inline-Styles und Tabellen-Layout statt Tailwind/externem CSS, da viele
* E-Mail-Clients (Outlook, ältere Gmail-Ansichten) beides nicht zuverlaessig
* unterstuetzen.
*/
async function emailLayout(locale: Locale, preheader: string, bodyHtml: string): Promise<string> {
const company = await getCompanyInfo();
return `<!doctype html>
<html lang="${locale}">
<body style="margin:0;padding:0;background-color:#FAF8F3;font-family:Arial,Helvetica,sans-serif;color:#242424;">
<span style="display:none;font-size:1px;color:#FAF8F3;line-height:1px;">${preheader}</span>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#FAF8F3;padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" style="max-width:560px;background-color:#ffffff;">
<tr>
<td style="background-color:#24352B;padding:24px 32px;">
<span style="font-size:20px;letter-spacing:2px;color:#F4EFE5;">WABENHAIN</span>
<div style="font-size:10px;letter-spacing:1px;color:#B88A44;margin-top:4px;">
HONIG AUS DEN WESTLICHEN WÄLDERN
</div>
</td>
</tr>
<tr>
<td style="padding:32px;">${bodyHtml}</td>
</tr>
<tr>
<td style="padding:20px 32px;background-color:#F4EFE5;font-size:12px;color:#666;">
${company.name} · ${company.address.street}, ${company.address.zip} ${company.address.city}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
function button(url: string, label: string): string {
return `<a href="${url}" style="display:inline-block;margin-top:16px;padding:12px 24px;background-color:#24352B;color:#F4EFE5;text-decoration:none;font-size:14px;letter-spacing:1px;">${label}</a>`;
}
interface OrderConfirmationInput {
locale: Locale;
orderNumber: string;
firstName: string;
fulfillmentMethod: "VERSAND" | "ABHOLUNG";
items: { productName: string; quantity: number; weightGrams: number; totalCents: number }[];
subtotalCents: number;
shippingCents: number;
discountCents?: number;
totalCents: number;
street: string;
zip: string;
city: string;
giftMessage?: string | null;
trackingUrl?: string;
}
export async function orderConfirmationEmail(order: OrderConfirmationInput): Promise<EmailContent> {
const s = getEmailStrings(order.locale).orderConfirmation;
const rows = order.items
.map(
(item) =>
`<tr><td style="padding:6px 0;">${item.quantity} × ${item.productName} (${item.weightGrams} g)</td><td style="padding:6px 0;text-align:right;">${formatPrice(item.totalCents)}</td></tr>`
)
.join("");
const deliveryHtml =
order.fulfillmentMethod === "VERSAND"
? `<p style="margin:4px 0;">${s.deliveryTo}<br>${order.street}<br>${order.zip} ${order.city}</p>`
: `<p style="margin:4px 0;">${s.pickupNote}</p>`;
const giftHtml = order.giftMessage
? `<p style="margin:16px 0 0;padding:12px 16px;background-color:#F4EFE5;font-size:13px;color:#24352B;">
${s.giftNote}
</p>`
: "";
const html = await emailLayout(
order.locale,
s.preheader(order.orderNumber),
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
<p style="margin:0 0 20px;line-height:1.5;">${s.intro(order.orderNumber)}</p>
<table role="presentation" width="100%" style="font-size:14px;border-top:1px solid #eee;border-bottom:1px solid #eee;padding:8px 0;">
${rows}
</table>
<table role="presentation" width="100%" style="font-size:14px;margin-top:12px;">
<tr><td>${s.subtotal}</td><td style="text-align:right;">${formatPrice(order.subtotalCents)}</td></tr>
<tr><td>${s.shipping}</td><td style="text-align:right;">${order.shippingCents === 0 ? s.freeShipping : formatPrice(order.shippingCents)}</td></tr>
${order.discountCents ? `<tr><td>${s.discount}</td><td style="text-align:right;">−${formatPrice(order.discountCents)}</td></tr>` : ""}
<tr><td style="font-weight:bold;color:#24352B;padding-top:8px;">${s.total}</td><td style="text-align:right;font-weight:bold;color:#24352B;padding-top:8px;">${formatPrice(order.totalCents)}</td></tr>
</table>
${deliveryHtml}
${giftHtml}
${order.trackingUrl ? `<div style="margin-top:20px;">${button(order.trackingUrl, s.trackOrder)}</div>` : ""}
`
);
const text = `${s.heading(order.firstName)}\n\n${order.orderNumber}\n\n${order.items
.map((i) => `${i.quantity} x ${i.productName} (${i.weightGrams} g) — ${formatPrice(i.totalCents)}`)
.join("\n")}\n\n${s.subtotal}: ${formatPrice(order.subtotalCents)}\n${s.shipping}: ${
order.shippingCents === 0 ? s.freeShipping : formatPrice(order.shippingCents)
}${order.discountCents ? `\n${s.discount}: −${formatPrice(order.discountCents)}` : ""}\n${s.total}: ${formatPrice(order.totalCents)}${
order.giftMessage ? `\n\n${s.giftNote}` : ""
}${order.trackingUrl ? `\n\n${s.trackOrder}: ${order.trackingUrl}` : ""}`;
return { subject: s.subject(order.orderNumber), html, text };
}
export async function newsletterConfirmEmail(locale: Locale, confirmUrl: string): Promise<EmailContent> {
const s = getEmailStrings(locale).newsletterConfirm;
const html = await emailLayout(
locale,
s.preheader,
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1>
<p style="margin:0 0 8px;line-height:1.5;">${s.intro}</p>
${button(confirmUrl, s.button)}
<p style="margin:20px 0 0;font-size:12px;color:#888;">${s.ignoreNote}</p>
`
);
return {
subject: s.subject,
html,
text: `${s.intro}: ${confirmUrl}`,
};
}
export async function passwordResetEmail(locale: Locale, resetUrl: string): Promise<EmailContent> {
const s = getEmailStrings(locale).passwordReset;
const html = await emailLayout(
locale,
s.preheader,
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1>
<p style="margin:0 0 8px;line-height:1.5;">${s.intro}</p>
${button(resetUrl, s.button)}
<p style="margin:20px 0 0;font-size:12px;color:#888;">${s.ignoreNote}</p>
`
);
return {
subject: s.subject,
html,
text: `${s.intro} ${resetUrl}`,
};
}
/**
* Sicherheitshinweis-Mail fuer sensible Kontoaenderungen (Passwort geaendert,
* 2FA deaktiviert, Konto geloescht) — unabhaengig davon, ob die Aenderung
* durch die Kundschaft selbst oder potenziell durch jemand anderen ausgeloest
* wurde, damit ein Missbrauch schnellstmoeglich auffaellt.
*/
export type SecurityAlertEvent = "passwordChanged" | "twoFactorDisabled" | "accountDeleted";
export async function securityAlertEmail(locale: Locale, event: SecurityAlertEvent): Promise<EmailContent> {
const s = getEmailStrings(locale).securityAlert;
const eventDescription = s[event];
const html = await emailLayout(
locale,
s.preheader,
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1>
<p style="margin:0 0 8px;line-height:1.5;">${eventDescription}</p>
<p style="margin:20px 0 0;font-size:12px;color:#888;">${s.contactNote}</p>
`
);
return {
subject: s.subject,
html,
text: `${eventDescription}\n\n${s.contactNote}`,
};
}
export async function emailVerificationEmail(locale: Locale, verifyUrl: string): Promise<EmailContent> {
const s = getEmailStrings(locale).emailVerification;
const html = await emailLayout(
locale,
s.preheader,
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1>
<p style="margin:0 0 8px;line-height:1.5;">${s.intro}</p>
${button(verifyUrl, s.button)}
<p style="margin:20px 0 0;font-size:12px;color:#888;">${s.ignoreNote}</p>
`
);
return {
subject: s.subject,
html,
text: `${s.intro} ${verifyUrl}`,
};
}
interface OrderStatusUpdateInput {
locale: Locale;
orderNumber: string;
firstName: string;
}
interface ShipmentEmailInput extends OrderStatusUpdateInput {
carrier?: string | null;
trackingNumber?: string | null;
trackingUrl?: string | null;
}
export async function shipmentEmail(order: ShipmentEmailInput): Promise<EmailContent> {
const s = getEmailStrings(order.locale).shipment;
const hasTracking = Boolean(order.carrier && order.trackingNumber);
const trackingHtml = hasTracking
? `
<p style="margin:16px 0 0;line-height:1.5;">
${order.carrier}: <strong>${order.trackingNumber}</strong>
</p>
${order.trackingUrl ? button(order.trackingUrl, s.trackButton) : ""}
`
: "";
const html = await emailLayout(
order.locale,
s.preheader(order.orderNumber),
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
<p style="margin:0 0 8px;line-height:1.5;">${s.intro(order.orderNumber)}</p>
${trackingHtml}
`
);
const trackingText = hasTracking
? `\n\n${order.carrier}: ${order.trackingNumber}${order.trackingUrl ? `\n${order.trackingUrl}` : ""}`
: "";
return {
subject: s.subject(order.orderNumber),
html,
text: `${s.heading(order.firstName)}${trackingText}`,
};
}
export async function pickupReadyEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
const s = getEmailStrings(order.locale).pickupReady;
const company = await getCompanyInfo();
const html = await emailLayout(
order.locale,
s.preheader(order.orderNumber),
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
<p style="margin:0 0 8px;line-height:1.5;">${s.intro(order.orderNumber)}</p>
<p style="margin:16px 0 0;line-height:1.5;">
${company.address.street}<br>${company.address.zip} ${company.address.city}
</p>
`
);
return {
subject: s.subject(order.orderNumber),
html,
text: `${s.intro(order.orderNumber).replace(/<\/?strong>/g, "")}\n\n${company.address.street}\n${company.address.zip} ${company.address.city}`,
};
}
/**
* Fuer Zahlungsmethoden mit verzoegerter Bestaetigung (z.B.
* SEPA-Lastschrift), bei denen der Zahlungseinzug im Nachhinein fehlschlaegt
* (checkout.session.async_payment_failed) — die Bestellung wurde bereits
* storniert und der Bestand zurueckgebucht (siehe api/checkout/webhook).
*/
interface BackInStockInput {
locale: Locale;
name: string;
slug: string;
}
export async function backInStockEmail(product: BackInStockInput): Promise<EmailContent> {
const s = getEmailStrings(product.locale).backInStock;
const url = `https://${siteConfig.domain}/honig/${product.slug}`;
const html = await emailLayout(
product.locale,
s.preheader(product.name),
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1>
<p style="margin:0;line-height:1.5;">${s.intro(product.name)}</p>
${button(url, s.button)}
`
);
return {
subject: s.subject(product.name),
html,
text: `${s.preheader(product.name)} ${url}`,
};
}
export async function returnAcceptedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
const s = getEmailStrings(order.locale).returnAccepted;
const html = await emailLayout(
order.locale,
s.preheader(order.orderNumber),
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
<p style="margin:0;line-height:1.5;">${s.intro(order.orderNumber)}</p>
`
);
return {
subject: s.subject(order.orderNumber),
html,
text: s.intro(order.orderNumber).replace(/<\/?strong>/g, ""),
};
}
export async function returnRejectedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
const s = getEmailStrings(order.locale).returnRejected;
const html = await emailLayout(
order.locale,
s.preheader(order.orderNumber),
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
<p style="margin:0;line-height:1.5;">${s.intro(order.orderNumber)}</p>
`
);
return {
subject: s.subject(order.orderNumber),
html,
text: s.intro(order.orderNumber).replace(/<\/?strong>/g, ""),
};
}
export async function returnRefundedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
const s = getEmailStrings(order.locale).returnRefunded;
const html = await emailLayout(
order.locale,
s.preheader(order.orderNumber),
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
<p style="margin:0;line-height:1.5;">${s.intro(order.orderNumber)}</p>
`
);
return {
subject: s.subject(order.orderNumber),
html,
text: s.intro(order.orderNumber).replace(/<\/?strong>/g, ""),
};
}
export async function paymentFailedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
const s = getEmailStrings(order.locale).paymentFailed;
const html = await emailLayout(
order.locale,
s.preheader(order.orderNumber),
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
<p style="margin:0 0 8px;line-height:1.5;">${s.intro(order.orderNumber)}</p>
<p style="margin:0;line-height:1.5;">${s.retryNote}</p>
`
);
return {
subject: s.subject(order.orderNumber),
html,
text: `${s.intro(order.orderNumber).replace(/<\/?strong>/g, "")} ${s.retryNote}`,
};
}
+33
View File
@@ -0,0 +1,33 @@
import { randomBytes, createHash } from "node:crypto";
import { prisma } from "@/lib/prisma";
import { sendMail } from "@/lib/mail";
import { emailVerificationEmail } from "@/lib/email-templates";
import { getBaseUrl } from "@/lib/url";
import { getLocale } from "@/i18n/locale";
const VERIFICATION_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; // 24 Stunden
export function hashVerificationToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
/**
* Erzeugt einen einmaligen E-Mail-Bestaetigungs-Token und verschickt den
* Link. Wird nach der Selbst-Registrierung aufgerufen sowie ueber den
* "Link erneut senden"-Button auf /konto, solange das Konto unbestaetigt ist.
*/
export async function createAndSendVerificationEmail(customer: { id: string; email: string }): Promise<void> {
const rawToken = randomBytes(32).toString("hex");
await prisma.emailVerificationToken.deleteMany({ where: { customerId: customer.id, usedAt: null } });
await prisma.emailVerificationToken.create({
data: {
customerId: customer.id,
tokenHash: hashVerificationToken(rawToken),
expiresAt: new Date(Date.now() + VERIFICATION_TOKEN_TTL_MS),
},
});
const verifyUrl = `${getBaseUrl()}/api/konto/email-bestaetigen?token=${rawToken}`;
const { subject, html, text } = await emailVerificationEmail(await getLocale(), verifyUrl);
await sendMail({ to: customer.email, subject, html, text });
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, test, expect } from "vitest";
import { formatPrice, formatPricePerKg, formatDate, formatDateTime } from "./format";
// Node's Intl.NumberFormat inserts a narrow no-break space (U+202F) before
// "€", which is visually indistinguishable from a regular space in a
// terminal/diff — matching against \s (which covers both) instead of a
// hardcoded literal keeps the test correct regardless of ICU version.
describe("formatPrice", () => {
test("formats whole euros without leftover cents", () => {
expect(formatPrice(990)).toMatch(/^9,90\s€$/);
});
test("formats zero as a valid price", () => {
expect(formatPrice(0)).toMatch(/^0,00\s€$/);
});
test("supports a non-default currency", () => {
expect(formatPrice(1000, "USD")).toContain("10,00");
});
});
describe("formatPricePerKg", () => {
test("appends the per-kilogram suffix", () => {
expect(formatPricePerKg(49666)).toMatch(/^496,66\s€ \/ kg$/);
});
});
describe("formatDate", () => {
test("renders a long German date without a time component", () => {
const result = formatDate(new Date("2026-05-10T12:00:00Z"));
expect(result).toBe("10. Mai 2026");
});
});
describe("formatDateTime", () => {
test("includes both date and time", () => {
const result = formatDateTime(new Date("2026-08-12T10:15:00Z"));
expect(result).toMatch(/12\.08\.2026/);
expect(result).toMatch(/\d{2}:\d{2}/);
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* Formatiert Cent-Betraege als deutsche Euro-Preisangabe, z.B. 990 -> "9,90 €".
*/
export function formatPrice(cents: number, currency = "EUR"): string {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency,
}).format(cents / 100);
}
/**
* Formatiert den Grundpreis (Preis pro Kilogramm), z.B. fuer Vergleichbarkeit im Shop.
*/
export function formatPricePerKg(cents: number, currency = "EUR"): string {
return `${formatPrice(cents, currency)} / kg`;
}
export function formatDate(date: Date): string {
return new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "long",
year: "numeric",
}).format(date);
}
export function formatDateTime(date: Date): string {
return new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
export function formatMonthYear(date: Date): string {
return new Intl.DateTimeFormat("de-DE", {
month: "long",
year: "numeric",
}).format(date);
}
+74
View File
@@ -0,0 +1,74 @@
export interface GoogleReview {
author: string;
rating: number;
text: string;
relativeTime: string;
}
interface PlacesDetailsResponse {
result?: {
rating?: number;
user_ratings_total?: number;
reviews?: {
author_name: string;
rating: number;
text: string;
relative_time_description: string;
}[];
};
status: string;
}
/**
* Ruft aktuelle Google-Rezensionen fuer den in GOOGLE_PLACE_ID hinterlegten
* Google-Maps-Eintrag ab (Places API, Legacy "Place Details"-Endpoint).
* Ohne konfigurierte Zugangsdaten wird null zurueckgegeben, sodass die
* Startseite auf die statischen Beispiel-Stimmen zurueckfaellt.
*
* Setup: Google-Cloud-Projekt mit aktivierter "Places API", API-Key als
* GOOGLE_PLACES_API_KEY, die Place-ID des Google-Maps-Eintrags als
* GOOGLE_PLACE_ID (zu finden ueber https://developers.google.com/maps/documentation/places/web-service/place-id).
*/
export async function fetchGoogleReviews(): Promise<{
reviews: GoogleReview[];
rating: number | null;
totalCount: number | null;
} | null> {
const apiKey = process.env.GOOGLE_PLACES_API_KEY;
const placeId = process.env.GOOGLE_PLACE_ID;
if (!apiKey || !placeId) return null;
const url = new URL("https://maps.googleapis.com/maps/api/place/details/json");
url.searchParams.set("place_id", placeId);
url.searchParams.set("fields", "review,rating,user_ratings_total");
url.searchParams.set("language", "de");
url.searchParams.set("reviews_sort", "newest");
url.searchParams.set("key", apiKey);
try {
// 1 Stunde Cache: haelt die Seite schnell und reduziert API-Kosten,
// ohne die Rezensionen spuerbar veralten zu lassen.
const response = await fetch(url, { next: { revalidate: 3600 } });
if (!response.ok) return null;
const data = (await response.json()) as PlacesDetailsResponse;
if (data.status !== "OK" || !data.result) return null;
const reviews: GoogleReview[] = (data.result.reviews ?? []).map((r) => ({
author: r.author_name,
rating: r.rating,
text: r.text,
relativeTime: r.relative_time_description,
}));
return {
reviews,
rating: data.result.rating ?? null,
totalCount: data.result.user_ratings_total ?? null,
};
} catch {
// Netzwerk-/API-Fehler duerfen die Startseite nicht zum Absturz bringen —
// stattdessen greift der statische Fallback.
return null;
}
}
+19
View File
@@ -0,0 +1,19 @@
import type { Prisma } from "@/generated/prisma/client";
/**
* Bucht reservierten/verkauften Bestand zurueck — genutzt beim Stornieren
* einer Bestellung (admin/actions.ts, api/checkout/webhook/route.ts) und bei
* einer angenommenen Retoure (admin/retouren/actions.ts). Alle drei Stellen
* hatten dieselbe Schleife dupliziert; jetzt ein gemeinsamer Aufruf.
*/
export async function restockOrderItems(
tx: Prisma.TransactionClient,
items: { productId: string; quantity: number }[]
): Promise<void> {
for (const item of items) {
await tx.inventory.updateMany({
where: { productId: item.productId },
data: { quantity: { increment: item.quantity } },
});
}
}
+130
View File
@@ -0,0 +1,130 @@
import PDFDocument from "pdfkit";
import { formatPrice, formatDate } from "@/lib/format";
import { getCompanyInfo } from "@/lib/company-info";
import { getDictionary } from "@/i18n/get-dictionary";
import type { Locale } from "@/i18n/config";
interface InvoicePdfInput {
locale: Locale;
invoiceNumber: string;
issuedAt: Date;
totalCents: number;
order: {
orderNumber: string;
firstName: string;
lastName: string;
street: string;
zip: string;
city: string;
currency: string;
subtotalCents: number;
shippingCents: number;
discountCents: number;
discountCode?: { code: string } | null;
items: { productName: string; weightGrams: number; quantity: number; unitPriceCents: number; totalCents: number }[];
};
}
/**
* Erzeugt die Rechnung als PDF (pdfkit — reine JS-Bibliothek ohne
* Headless-Browser/native Abhaengigkeiten, haelt das Docker-Image schlank).
* Wird on-the-fly beim Download erzeugt statt dauerhaft gespeichert. Nutzt
* dieselben Uebersetzungen wie die HTML-Rechnungsansicht (t.invoicePage),
* damit beide Darstellungen derselben Rechnung konsistent bleiben.
*/
export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer> {
const t = getDictionary(input.locale).invoicePage;
const company = await getCompanyInfo();
const doc = new PDFDocument({ margin: 50, size: "A4" });
const chunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
const done = new Promise<Buffer>((resolve, reject) => {
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
});
doc.fontSize(20).fillColor("#24352B").text(company.name.toUpperCase(), { continued: false });
doc
.fontSize(9)
.fillColor("#666666")
.text(`${company.address.street}, ${company.address.zip} ${company.address.city}`);
doc.moveDown(1.5);
doc.fontSize(14).fillColor("#24352B").text(t.invoiceLabel(input.invoiceNumber));
doc
.fontSize(9)
.fillColor("#666666")
.text(t.orderLabel(input.order.orderNumber))
.text(`${t.dateLabel} ${formatDate(input.issuedAt)}`);
doc.moveDown(1);
doc
.fontSize(10)
.fillColor("#242424")
.text(`${input.order.firstName} ${input.order.lastName}`)
.text(input.order.street)
.text(`${input.order.zip} ${input.order.city}`);
doc.moveDown(1.5);
const tableTop = doc.y;
const columns = { name: 50, qty: 320, price: 380, total: 470 };
doc
.fontSize(9)
.fillColor("#666666")
.text(t.itemHeader, columns.name, tableTop)
.text(t.quantityHeader, columns.qty, tableTop)
.text(t.priceHeader, columns.price, tableTop)
.text(t.totalHeader, columns.total, tableTop);
doc
.moveTo(50, tableTop + 14)
.lineTo(545, tableTop + 14)
.strokeColor("#dddddd")
.stroke();
let y = tableTop + 22;
doc.fontSize(10).fillColor("#242424");
for (const item of input.order.items) {
doc.text(`${item.productName} (${item.weightGrams} g)`, columns.name, y, { width: 260 });
doc.text(String(item.quantity), columns.qty, y);
doc.text(formatPrice(item.unitPriceCents, input.order.currency), columns.price, y);
doc.text(formatPrice(item.totalCents, input.order.currency), columns.total, y);
y += 20;
}
y += 10;
doc.moveTo(350, y).lineTo(545, y).strokeColor("#dddddd").stroke();
y += 10;
doc.fontSize(10).fillColor("#666666").text(t.subtotalLabel, 350, y);
doc.fillColor("#242424").text(formatPrice(input.order.subtotalCents, input.order.currency), columns.total, y);
y += 16;
doc.fillColor("#666666").text(t.shippingLabel, 350, y);
doc
.fillColor("#242424")
.text(
input.order.shippingCents === 0 ? t.freeShipping : formatPrice(input.order.shippingCents, input.order.currency),
columns.total,
y
);
if (input.order.discountCents > 0) {
y += 16;
doc
.fillColor("#666666")
.text(input.order.discountCode ? `${t.discountLabel} (${input.order.discountCode.code})` : t.discountLabel, 350, y);
doc
.fillColor("#242424")
.text(`−${formatPrice(input.order.discountCents, input.order.currency)}`, columns.total, y);
}
y += 20;
doc.fontSize(11).fillColor("#24352B").text(t.totalLabel, 350, y);
doc.text(formatPrice(input.totalCents, input.order.currency), columns.total, y);
doc
.fontSize(8)
.fillColor("#999999")
.text(t.legalNote, 50, 760, { width: 495 });
doc.end();
return done;
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, test, expect } from "vitest";
import { asStringArray } from "./json-array";
describe("asStringArray", () => {
test("passes an array of strings through unchanged", () => {
expect(asStringArray(["mild", "floral"])).toEqual(["mild", "floral"]);
});
test("returns an empty array for an empty array", () => {
expect(asStringArray([])).toEqual([]);
});
test("drops non-string entries from a mixed array", () => {
expect(asStringArray(["mild", 5, null, "floral", { a: 1 }])).toEqual(["mild", "floral"]);
});
test("returns an empty array for null", () => {
expect(asStringArray(null)).toEqual([]);
});
test("returns an empty array for undefined", () => {
expect(asStringArray(undefined)).toEqual([]);
});
test("returns an empty array for a plain object", () => {
expect(asStringArray({ tasteTags: ["mild"] })).toEqual([]);
});
test("returns an empty array for a scalar string", () => {
expect(asStringArray("mild")).toEqual([]);
});
});
+12
View File
@@ -0,0 +1,12 @@
/**
* Normalisiert den Rueckgabewert eines Json-Feldes (tasteTags/images auf
* Harvest/Product — siehe schema.prisma-Kommentare dort) zu einem
* String-Array. Diese Felder sind bewusst als Json statt String[] modelliert,
* da MySQL keine nativen Skalar-Arrays kennt; zur Laufzeit steht dort aber
* immer ein JSON-Array aus Strings, solange nur ueber die App geschrieben
* wird. Fremdartige/fehlerhafte Werte werden defensiv zu [] statt zu werfen.
*/
export function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === "string");
}
+74
View File
@@ -0,0 +1,74 @@
import nodemailer from "nodemailer";
interface SendMailInput {
to: string;
subject: string;
html: string;
text: string;
}
/** Maskiert eine E-Mail-Adresse fuer Log-Ausgaben, z.B. "ma***@example.com". */
function maskEmail(email: string): string {
const [local, domain] = email.split("@");
if (!domain) return "***";
return `${local.slice(0, 2)}***@${domain}`;
}
let cachedTransport: ReturnType<typeof nodemailer.createTransport> | null = null;
// Nur einmal geprueft (nicht bei jedem sendMail-Aufruf) — macht ein in
// Produktion fehlendes SMTP-Setup im Server-Log sichtbar, statt dass
// Bestellbestaetigungen/Passwort-Resets fortlaufend lautlos verschwinden.
let warnedAboutMissingSmtpInProduction = false;
function getTransport() {
if (cachedTransport) return cachedTransport;
const host = process.env.SMTP_HOST;
const port = Number(process.env.SMTP_PORT ?? 587);
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASSWORD;
if (!host || !user || !pass) {
if (process.env.NODE_ENV === "production" && !warnedAboutMissingSmtpInProduction) {
warnedAboutMissingSmtpInProduction = true;
console.warn(
"SMTP_HOST/SMTP_USER/SMTP_PASSWORD sind in Produktion nicht gesetzt — Bestellbestaetigungen, Passwort-Resets und weitere Transaktions-E-Mails werden nur geloggt, nicht versendet."
);
}
return null;
}
cachedTransport = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user, pass },
});
return cachedTransport;
}
/**
* Versendet eine E-Mail ueber den in SMTP_HOST/SMTP_USER/SMTP_PASSWORD
* konfigurierten Mailserver. Ohne Konfiguration wird die Mail NICHT
* gesendet, sondern nur geloggt — analog zum Stripe-Fallback ohne Keys,
* damit lokale Entwicklung und Deployments ohne Mail-Setup nicht abstuerzen.
* Wirft absichtlich keinen Fehler: ein Mail-Ausfall darf eine Bestellung
* oder Registrierung nicht verhindern.
*/
export async function sendMail(input: SendMailInput): Promise<void> {
const transport = getTransport();
const from = process.env.SMTP_FROM || "Wabenhain <[email protected]>";
if (!transport) {
console.warn(
`[mail] SMTP nicht konfiguriert — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`
);
return;
}
try {
await transport.sendMail({ from, to: input.to, subject: input.subject, html: input.html, text: input.text });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen: ${message}`);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { prisma } from "@/lib/prisma";
// Fester Willkommensrabatt-Code fuer neu bestaetigte Newsletter-Abonnenten.
// Bewusst EIN geteilter Code statt eines pro Kunde generierten (Schema hat
// keine Kunden-Bindung an DiscountCode) — analog zu oeffentlich bekannten
// "WELCOME10"-Codes bei anderen Shops. minOrderCents/PERCENT-Wert sind eine
// Einschaetzung und lassen sich im Admin-Bereich (DiscountCode-Verwaltung)
// jederzeit nachjustieren.
export const NEWSLETTER_DISCOUNT_CODE = "WILLKOMMEN10";
export const NEWSLETTER_DISCOUNT_PERCENT = 10;
export const NEWSLETTER_DISCOUNT_MIN_ORDER_CENTS = 2000;
/** Idempotent — legt den Willkommensrabatt-Code an, falls er noch nicht existiert. */
export async function ensureNewsletterDiscountCode(): Promise<void> {
await prisma.discountCode.upsert({
where: { code: NEWSLETTER_DISCOUNT_CODE },
create: {
code: NEWSLETTER_DISCOUNT_CODE,
type: "PERCENT",
value: NEWSLETTER_DISCOUNT_PERCENT,
minOrderCents: NEWSLETTER_DISCOUNT_MIN_ORDER_CENTS,
},
update: {},
});
}
+39
View File
@@ -0,0 +1,39 @@
import { createHash } from "node:crypto";
const HIBP_TIMEOUT_MS = 3000;
/**
* Prueft ueber die Have-I-Been-Pwned-API (k-Anonymitaet: nur die ersten 5
* Hex-Zeichen des SHA-1-Hashes verlassen den Server, niemals das Passwort
* selbst oder der volle Hash) ob ein Passwort in bekannten Datenlecks
* vorkommt. Bei Netzwerk- oder Zeitueberschreitungsfehlern wird bewusst
* "nicht kompromittiert" zurueckgegeben (fail-open) — ein Ausfall des
* externen Diensts darf Registrierung oder Passwort-Reset nicht blockieren.
*/
export async function isPasswordBreached(password: string): Promise<boolean> {
try {
const sha1 = createHash("sha1").update(password, "utf8").digest("hex").toUpperCase();
const prefix = sha1.slice(0, 5);
const suffix = sha1.slice(5);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), HIBP_TIMEOUT_MS);
try {
const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
signal: controller.signal,
headers: { "Add-Padding": "true" },
});
if (!response.ok) return false;
const body = await response.text();
return body.split("\n").some((line) => {
const [lineSuffix, count] = line.trim().split(":");
return lineSuffix === suffix && Number(count) > 0;
});
} finally {
clearTimeout(timeout);
}
} catch {
return false;
}
}
+34
View File
@@ -0,0 +1,34 @@
import { randomBytes, createHash } from "node:crypto";
import { prisma } from "@/lib/prisma";
import { sendMail } from "@/lib/mail";
import { passwordResetEmail } from "@/lib/email-templates";
import { getBaseUrl } from "@/lib/url";
import { getLocale } from "@/i18n/locale";
const RESET_TOKEN_TTL_MS = 60 * 60 * 1000; // 1 Stunde
export function hashResetToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
/**
* Erzeugt einen einmaligen Passwort-Reset-Token und verschickt den Link per
* E-Mail. Gemeinsam genutzt vom Kunden-Self-Service ("Passwort vergessen")
* und der Admin-Benutzerverwaltung (statt dass ein Admin dort direkt ein
* neues Passwort setzen kann, bekommt der Kunde immer nur diesen Link).
*/
export async function createAndSendPasswordResetEmail(customer: { id: string; email: string }): Promise<void> {
const rawToken = randomBytes(32).toString("hex");
await prisma.passwordResetToken.deleteMany({ where: { customerId: customer.id, usedAt: null } });
await prisma.passwordResetToken.create({
data: {
customerId: customer.id,
tokenHash: hashResetToken(rawToken),
expiresAt: new Date(Date.now() + RESET_TOKEN_TTL_MS),
},
});
const resetUrl = `${getBaseUrl()}/konto/passwort-zuruecksetzen?token=${rawToken}`;
const { subject, html, text } = await passwordResetEmail(await getLocale(), resetUrl);
await sendMail({ to: customer.email, subject, html, text });
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
// insensitiveContains/insensitiveEquals read DATABASE_PROVIDER once at module
// load (not per call, see prisma-search.ts) — vi.resetModules() + a fresh
// dynamic import per test is required to observe both branches.
const originalProvider = process.env.DATABASE_PROVIDER;
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
if (originalProvider === undefined) delete process.env.DATABASE_PROVIDER;
else process.env.DATABASE_PROVIDER = originalProvider;
});
describe("insensitiveContains", () => {
test("adds mode: insensitive for the postgresql provider", async () => {
process.env.DATABASE_PROVIDER = "postgresql";
const { insensitiveContains } = await import("./prisma-search");
expect(insensitiveContains("mild")).toEqual({ contains: "mild", mode: "insensitive" });
});
test("adds mode: insensitive when DATABASE_PROVIDER is unset (default)", async () => {
delete process.env.DATABASE_PROVIDER;
const { insensitiveContains } = await import("./prisma-search");
expect(insensitiveContains("mild")).toEqual({ contains: "mild", mode: "insensitive" });
});
test("omits mode for the mysql provider", async () => {
process.env.DATABASE_PROVIDER = "mysql";
const { insensitiveContains } = await import("./prisma-search");
expect(insensitiveContains("mild")).toEqual({ contains: "mild" });
});
});
describe("insensitiveEquals", () => {
test("adds mode: insensitive for the postgresql provider", async () => {
process.env.DATABASE_PROVIDER = "postgresql";
const { insensitiveEquals } = await import("./prisma-search");
expect(insensitiveEquals("mild")).toEqual({ equals: "mild", mode: "insensitive" });
});
test("omits mode for the mysql provider", async () => {
process.env.DATABASE_PROVIDER = "mysql";
const { insensitiveEquals } = await import("./prisma-search");
expect(insensitiveEquals("mild")).toEqual({ equals: "mild" });
});
});
+16
View File
@@ -0,0 +1,16 @@
const isPostgres = (process.env.DATABASE_PROVIDER ?? "postgresql") === "postgresql";
/**
* Case-insensitive Teilstring-/Gleichheitssuche, Provider-uebergreifend:
* Postgres braucht dafuer "mode: insensitive" explizit auf dem Filter,
* MySQL kennt dieses Feld auf StringFilter gar nicht (Typfehler beim
* MySQL-generierten Client) — dort reicht die Standard-Kollation
* (*_ci, z.B. utf8mb4_0900_ai_ci) bereits case-insensitiv aus.
*/
export function insensitiveContains(value: string): { contains: string; mode?: "insensitive" } {
return isPostgres ? { contains: value, mode: "insensitive" } : { contains: value };
}
export function insensitiveEquals(value: string): { equals: string; mode?: "insensitive" } {
return isPostgres ? { equals: value, mode: "insensitive" } : { equals: value };
}
+37
View File
@@ -0,0 +1,37 @@
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaClient as MySqlPrismaClient } from "@/generated/prisma-mysql/client";
import { createDbAdapter } from "@/lib/db-adapter";
// Verhindert im Next.js Dev-Modus (Hot Reload) das mehrfache Erzeugen von
// PrismaClient-Instanzen und damit zu viele offene DB-Verbindungen.
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
// Waehlt den Treiber-Adapter (Postgres oder MySQL) anhand von
// DATABASE_PROVIDER — siehe lib/db-adapter.ts.
const adapter = createDbAdapter();
const logLevels = process.env.NODE_ENV === "development" ? (["warn", "error"] as const) : (["error"] as const);
/**
* Ein einzelner generierter Prisma-Client ist fest an EINEN Provider
* gebunden — deshalb generiert scripts/generate-prisma-clients.mjs beide
* Varianten ins Image, und hier wird zur Laufzeit anhand von
* DATABASE_PROVIDER zwischen ihnen gewaehlt (ein Docker-Image, umschaltbar
* per Env-Variable statt Neubau). Der MySQL-Client hat dieselbe oeffentliche
* Form wie der Postgres-Client (identisches schema.prisma), der Cast ist nur
* noetig, weil beide als separate, nicht strukturell identische
* TS-Klassen generiert werden.
*/
function instantiateClient(): PrismaClient {
if ((process.env.DATABASE_PROVIDER ?? "postgresql").trim() === "mysql") {
return new MySqlPrismaClient({ adapter, log: [...logLevels] }) as unknown as PrismaClient;
}
return new PrismaClient({ adapter, log: [...logLevels] });
}
export const prisma: PrismaClient = globalForPrisma.prisma ?? instantiateClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+57
View File
@@ -0,0 +1,57 @@
import { headers } from "next/headers";
interface Bucket {
count: number;
resetAt: number;
}
// In-Memory-Limiter: ausreichend fuer den Single-Container-Betrieb dieser
// Seite (siehe docker-compose). Bei horizontaler Skalierung muss der Zustand
// in einen gemeinsamen Store (z.B. Redis) wandern.
const buckets = new Map<string, Bucket>();
const MAX_BUCKETS = 10_000;
/**
* Sliding-Window-Zaehler: erlaubt `limit` Aufrufe pro `windowMs` je `key`.
* Gibt true zurueck, wenn der Aufruf noch erlaubt ist.
*/
export function rateLimit(key: string, limit: number, windowMs: number): boolean {
const now = Date.now();
// Speicher begrenzen: abgelaufene Eintraege gelegentlich aufraeumen.
if (buckets.size > MAX_BUCKETS) {
for (const [k, bucket] of buckets) {
if (now > bucket.resetAt) buckets.delete(k);
}
}
const existing = buckets.get(key);
if (!existing || now > existing.resetAt) {
buckets.set(key, { count: 1, resetAt: now + windowMs });
return true;
}
if (existing.count >= limit) return false;
buckets.set(key, { ...existing, count: existing.count + 1 });
return true;
}
/**
* Client-IP fuer Rate-Limit-Schluessel in Server Actions/Route Handlern.
* Hinter einem Reverse-Proxy muss dieser x-forwarded-for korrekt setzen.
*
* Nimmt bewusst den LETZTEN Eintrag von x-forwarded-for, nicht den ersten:
* jeder Proxy in der Kette haengt die von IHM beobachtete Adresse ans Ende
* an, der letzte Eintrag ist also der vom eigenen (vertrauenswuerdigen)
* Reverse-Proxy gesehene Absender. Der erste Eintrag ist dagegen ein vom
* Client frei waehlbarer Header-Wert und liesse sich zum Umgehen jedes
* Rate-Limits in dieser Datei faelschen (Login, 2FA, Checkout, Reset, ...).
*/
export async function getClientIp(): Promise<string> {
const headerStore = await headers();
return (
headerStore.get("x-forwarded-for")?.split(",").pop()?.trim() ||
headerStore.get("x-real-ip") ||
"unknown"
);
}
+73
View File
@@ -0,0 +1,73 @@
import { prisma } from "@/lib/prisma";
import type { Review } from "@/generated/prisma/client";
export type ReviewWithCustomer = Review & {
customer: { firstName: string; lastName: string };
};
export interface ReviewSummary {
average: number | null;
count: number;
}
export async function getApprovedReviews(productId: string): Promise<ReviewWithCustomer[]> {
return prisma.review.findMany({
where: { productId, isApproved: true },
include: { customer: { select: { firstName: true, lastName: true } } },
orderBy: { createdAt: "desc" },
});
}
export async function getReviewSummary(productId: string): Promise<ReviewSummary> {
const result = await prisma.review.aggregate({
where: { productId, isApproved: true },
_avg: { rating: true },
_count: true,
});
return { average: result._avg.rating, count: result._count };
}
/**
* Wie getReviewSummary, aber fuer mehrere Produkte in einer Abfrage — vermeidet
* N+1-Abfragen beim Rendern von Produktrastern (Shop-Uebersicht u.ae.).
*/
export async function getReviewSummariesByProductIds(
productIds: string[]
): Promise<Map<string, ReviewSummary>> {
if (productIds.length === 0) return new Map();
const results = await prisma.review.groupBy({
by: ["productId"],
where: { productId: { in: productIds }, isApproved: true },
_avg: { rating: true },
_count: true,
});
return new Map(results.map((r) => [r.productId, { average: r._avg.rating, count: r._count }]));
}
/** Eigene Bewertung des Kunden fuer dieses Produkt, unabhaengig vom Freischaltungsstatus. */
export async function getCustomerReview(productId: string, customerId: string): Promise<Review | null> {
return prisma.review.findUnique({
where: { productId_customerId: { productId, customerId } },
});
}
/**
* "Verifizierter Kauf": mindestens eine bezahlte, nicht stornierte Bestellung
* des Kunden enthaelt dieses Produkt. Gastbestellungen (ohne customerId)
* zaehlen bewusst nicht, da eine Bewertung an ein Kundenkonto gebunden ist.
*/
export async function hasCustomerPurchased(productId: string, customerId: string): Promise<boolean> {
const orderItem = await prisma.orderItem.findFirst({
where: {
productId,
order: {
customerId,
status: { notIn: ["NEU", "STORNIERT"] },
},
},
select: { id: true },
});
return orderItem !== null;
}
+176
View File
@@ -0,0 +1,176 @@
import { SignJWT, jwtVerify } from "jose";
// Bewusst ohne Prisma-/next-headers-Importe: dieses Modul wird auch vom
// Proxy (src/proxy.ts) geladen, der schlank bleiben muss und keinen
// Datenbankzugriff haben soll.
export const SESSION_COOKIE = "wabenhain_session";
export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 Tage
// Admin-Konten haben Zugriff auf Bestellungen, Rueckerstattungen und
// Kundendaten — eine kuerzere Session-Laufzeit begrenzt das Zeitfenster,
// in dem ein gestohlenes/vergessenes Admin-Cookie missbraucht werden kann.
export const ADMIN_SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 Tage
export const TWO_FACTOR_CHALLENGE_COOKIE = "wabenhain_2fa_challenge";
const TWO_FACTOR_CHALLENGE_TTL_SECONDS = 5 * 60; // 5 Minuten, nach Passwort-Schritt bis 2FA-Code
export const TWO_FACTOR_SETUP_COOKIE = "wabenhain_2fa_setup";
const TWO_FACTOR_SETUP_TTL_SECONDS = 10 * 60; // 10 Minuten, um die Einrichtung abzuschliessen
const JWT_ISSUER = "wabenhain";
const JWT_AUDIENCE = "wabenhain-web";
const MIN_SECRET_LENGTH = 32;
export type SessionRole = "KUNDE" | "ADMIN";
export interface SessionPayload {
customerId: string;
role: SessionRole;
sessionVersion: number;
}
function getSecretKey(): Uint8Array {
const secret = process.env.SESSION_SECRET;
if (!secret) {
throw new Error("SESSION_SECRET ist nicht gesetzt. Bitte in der .env-Datei konfigurieren.");
}
if (process.env.NODE_ENV === "production" && secret.length < MIN_SECRET_LENGTH) {
throw new Error(
`SESSION_SECRET muss in Produktion mindestens ${MIN_SECRET_LENGTH} Zeichen lang sein (z.B. via "openssl rand -base64 32" erzeugen).`
);
}
return new TextEncoder().encode(secret);
}
export async function signSessionToken(payload: SessionPayload): Promise<string> {
const maxAge = payload.role === "ADMIN" ? ADMIN_SESSION_MAX_AGE_SECONDS : SESSION_MAX_AGE_SECONDS;
return new SignJWT({ role: payload.role, sessionVersion: payload.sessionVersion })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setSubject(payload.customerId)
.setIssuer(JWT_ISSUER)
.setAudience(JWT_AUDIENCE)
.setIssuedAt()
.setExpirationTime(`${maxAge}s`)
.sign(getSecretKey());
}
export async function verifySessionToken(token: string): Promise<SessionPayload | null> {
try {
const { payload } = await jwtVerify(token, getSecretKey(), {
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
algorithms: ["HS256"],
});
if (!payload.sub) return null;
const role: SessionRole = payload.role === "ADMIN" ? "ADMIN" : "KUNDE";
const sessionVersion = typeof payload.sessionVersion === "number" ? payload.sessionVersion : 0;
return { customerId: payload.sub, role, sessionVersion };
} catch {
// Abgelaufene oder manipulierte Tokens werden wie "nicht angemeldet" behandelt.
return null;
}
}
// --- Kurzlebiger Zwischenschritt-Token: Passwort korrekt, 2FA-Code steht noch aus. ---
// Bewusst ein eigener, separat benannter Token-Typ (nicht das normale Session-JWT):
// so kann dieser Zustand niemals versehentlich als vollstaendig angemeldet durchgehen.
interface TwoFactorChallengePayload {
customerId: string;
}
export async function signTwoFactorChallenge(customerId: string): Promise<string> {
return new SignJWT({ type: "2fa-challenge" })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setSubject(customerId)
.setIssuer(JWT_ISSUER)
.setAudience(JWT_AUDIENCE)
.setIssuedAt()
.setExpirationTime(`${TWO_FACTOR_CHALLENGE_TTL_SECONDS}s`)
.sign(getSecretKey());
}
export async function verifyTwoFactorChallenge(token: string): Promise<TwoFactorChallengePayload | null> {
try {
const { payload } = await jwtVerify(token, getSecretKey(), {
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
algorithms: ["HS256"],
});
if (!payload.sub || payload.type !== "2fa-challenge") return null;
return { customerId: payload.sub };
} catch {
return null;
}
}
// --- Kurzlebiger Setup-Token: haelt das frisch generierte TOTP-Secret, bis der
// Nutzer den ersten Code bestaetigt hat. Erst danach landet es in der DB. ---
interface TwoFactorSetupPayload {
customerId: string;
secret: string;
}
export async function signTwoFactorSetup(customerId: string, secret: string): Promise<string> {
return new SignJWT({ type: "2fa-setup", secret })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setSubject(customerId)
.setIssuer(JWT_ISSUER)
.setAudience(JWT_AUDIENCE)
.setIssuedAt()
.setExpirationTime(`${TWO_FACTOR_SETUP_TTL_SECONDS}s`)
.sign(getSecretKey());
}
export async function verifyTwoFactorSetup(token: string): Promise<TwoFactorSetupPayload | null> {
try {
const { payload } = await jwtVerify(token, getSecretKey(), {
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
algorithms: ["HS256"],
});
if (!payload.sub || payload.type !== "2fa-setup" || typeof payload.secret !== "string") return null;
return { customerId: payload.sub, secret: payload.secret };
} catch {
return null;
}
}
// --- Einmalige Backup-Codes-Anzeige: die Klartext-Codes existieren nur den
// Moment lang zwischen Generierung und Anzeige, danach ist ausschliesslich
// der Hash in der DB vorhanden. Das Cookie wird beim Lesen sofort geloescht,
// ein Neuladen der Seite zeigt die Codes daher kein zweites Mal.
export const TWO_FACTOR_BACKUP_CODES_COOKIE = "wabenhain_2fa_backup_display";
const TWO_FACTOR_BACKUP_CODES_TTL_SECONDS = 2 * 60;
interface TwoFactorBackupCodesPayload {
customerId: string;
codes: string[];
}
export async function signTwoFactorBackupCodesDisplay(customerId: string, codes: string[]): Promise<string> {
return new SignJWT({ type: "2fa-backup-display", codes })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setSubject(customerId)
.setIssuer(JWT_ISSUER)
.setAudience(JWT_AUDIENCE)
.setIssuedAt()
.setExpirationTime(`${TWO_FACTOR_BACKUP_CODES_TTL_SECONDS}s`)
.sign(getSecretKey());
}
export async function verifyTwoFactorBackupCodesDisplay(token: string): Promise<TwoFactorBackupCodesPayload | null> {
try {
const { payload } = await jwtVerify(token, getSecretKey(), {
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
algorithms: ["HS256"],
});
if (!payload.sub || payload.type !== "2fa-backup-display" || !Array.isArray(payload.codes)) return null;
return { customerId: payload.sub, codes: payload.codes as string[] };
} catch {
return null;
}
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Erzeugt einen direkten Tracking-Link nur fuer Dienstleister mit bekanntem,
* stabilem URL-Schema (aktuell DHL, der in Deutschland dominante Versender).
* Fuer alle anderen/unbekannten Namen wird bewusst kein Link erzeugt — ein
* falsch geratenes URL-Schema waere schlechter als gar kein Link, da die
* Sendungsnummer selbst (immer angezeigt) ohnehin manuell nachschlagbar bleibt.
*/
export function getCarrierTrackingUrl(carrier: string | null, trackingNumber: string | null): string | null {
if (!carrier || !trackingNumber) return null;
const normalized = carrier.trim().toLowerCase();
if (normalized === "dhl") {
return `https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode=${encodeURIComponent(trackingNumber)}`;
}
return null;
}
+71
View File
@@ -0,0 +1,71 @@
import { describe, test, expect } from "vitest";
import { resolveShippingCostCents, getNextShippingTier, FALLBACK_SHIPPING_CENTS } from "./shipping-calc";
const tiers = [
{ minQuantity: 0, maxQuantity: 3, priceCents: 700 },
{ minQuantity: 4, maxQuantity: 9, priceCents: 850 },
{ minQuantity: 10, maxQuantity: null, priceCents: 1100 },
];
describe("resolveShippingCostCents", () => {
test("returns 0 when the cart is empty", () => {
expect(resolveShippingCostCents(tiers, 0)).toBe(0);
});
test("falls back to a fixed price when no tiers are configured", () => {
expect(resolveShippingCostCents([], 5)).toBe(FALLBACK_SHIPPING_CENTS);
});
test("picks the lowest tier for a small quantity", () => {
expect(resolveShippingCostCents(tiers, 2)).toBe(700);
});
test("picks the middle tier at its lower boundary", () => {
expect(resolveShippingCostCents(tiers, 4)).toBe(850);
});
test("picks the middle tier at its upper boundary", () => {
expect(resolveShippingCostCents(tiers, 9)).toBe(850);
});
test("applies the open-ended top tier for large quantities", () => {
expect(resolveShippingCostCents(tiers, 500)).toBe(1100);
});
test("uses the topmost tier when the quantity exceeds every defined tier", () => {
const closedTiers = [{ minQuantity: 0, maxQuantity: 3, priceCents: 700 }];
expect(resolveShippingCostCents(closedTiers, 50)).toBe(700);
});
test("is unaffected by tier input order", () => {
const shuffled = [...tiers].reverse();
expect(resolveShippingCostCents(shuffled, 4)).toBe(850);
});
});
describe("getNextShippingTier", () => {
test("returns null for an empty cart", () => {
expect(getNextShippingTier(tiers, 0)).toBeNull();
});
test("returns null when no tiers are configured", () => {
expect(getNextShippingTier([], 2)).toBeNull();
});
test("returns null when already in the topmost open-ended tier", () => {
expect(getNextShippingTier(tiers, 15)).toBeNull();
});
test("reports units and price of the next tier", () => {
expect(getNextShippingTier(tiers, 2)).toEqual({ unitsUntilNext: 2, priceCents: 850 });
});
test("reports 1 unit remaining right below a boundary", () => {
expect(getNextShippingTier(tiers, 3)).toEqual({ unitsUntilNext: 1, priceCents: 850 });
});
test("is unaffected by tier input order", () => {
const shuffled = [...tiers].reverse();
expect(getNextShippingTier(shuffled, 9)).toEqual({ unitsUntilNext: 1, priceCents: 1100 });
});
});
+50
View File
@@ -0,0 +1,50 @@
export interface ShippingTierLike {
minQuantity: number;
maxQuantity: number | null;
priceCents: number;
}
// Greift nur, wenn im Admin-Bereich (noch) keine Staffeln gepflegt sind —
// verhindert einen kaputten Checkout statt einer korrekten Preisauskunft.
export const FALLBACK_SHIPPING_CENTS = 490;
/**
* Reine Zuordnungsfunktion ohne Datenbankzugriff — bewusst getrennt von
* shipping.ts, damit sowohl der serverseitige Checkout (lib/shipping.ts) als
* auch die client-seitige Warenkorb-Live-Vorschau (warenkorb-content.tsx)
* exakt dieselbe Staffel-Logik verwenden, ohne Prisma ins Client-Bundle zu
* ziehen.
*/
export function resolveShippingCostCents(tiers: ShippingTierLike[], totalQuantity: number): number {
if (totalQuantity <= 0) return 0;
if (tiers.length === 0) return FALLBACK_SHIPPING_CENTS;
const sorted = [...tiers].sort((a, b) => a.minQuantity - b.minQuantity);
const matching = sorted.find(
(tier) => totalQuantity >= tier.minQuantity && (tier.maxQuantity === null || totalQuantity <= tier.maxQuantity)
);
// Menge liegt oberhalb aller gepflegten Staffeln (z.B. nur bis 19 Stueck
// definiert) — die teuerste/oberste Staffel greift, statt die Bestellung
// mangels passendem Tarif abzulehnen.
return matching ? matching.priceCents : sorted[sorted.length - 1].priceCents;
}
/**
* Naechste (teurere) Staffel oberhalb der aktuellen Menge — fuer eine ehrliche
* Transparenz-Anzeige im Warenkorb ("noch X Stueck bis zur naechsten Stufe").
* Anders als ein "kostenloser Versand ab X"-Hinweis wird hier bewusst NICHT
* suggeriert, dass mehr kaufen guenstiger wird — die Staffeln steigen mit der
* Menge, siehe FALLBACK_SHIPPING_CENTS-Kommentar oben.
*/
export function getNextShippingTier(
tiers: ShippingTierLike[],
totalQuantity: number
): { unitsUntilNext: number; priceCents: number } | null {
if (tiers.length === 0 || totalQuantity <= 0) return null;
const sorted = [...tiers].sort((a, b) => a.minQuantity - b.minQuantity);
const next = sorted.find((tier) => tier.minQuantity > totalQuantity);
if (!next) return null;
return { unitsUntilNext: next.minQuantity - totalQuantity, priceCents: next.priceCents };
}
+13
View File
@@ -0,0 +1,13 @@
import { prisma } from "@/lib/prisma";
import { resolveShippingCostCents } from "@/lib/shipping-calc";
import type { ShippingTier } from "@/generated/prisma/client";
export async function getShippingTiers(): Promise<ShippingTier[]> {
return prisma.shippingTier.findMany({ orderBy: { minQuantity: "asc" } });
}
/** Gesamtmenge = Summe aller Artikelmengen im Warenkorb, nicht pro Position. */
export async function getShippingCostCents(totalQuantity: number): Promise<number> {
const tiers = await getShippingTiers();
return resolveShippingCostCents(tiers, totalQuantity);
}
+93
View File
@@ -0,0 +1,93 @@
// Zentrale, austauschbare Inhalte fuer Navigation, Footer und Firmendaten.
// email/phone sind noch keine echten Kontaktdaten (auf dem Etikett nicht
// abgebildet) und muessen vor dem Live-Gang ersetzt werden — bewusst ohne
// sichtbaren "[PLATZHALTER]"-Text im String, da dieser sonst 1:1 im Footer
// und auf der Kontaktseite mitgerendert wuerde.
export const categoryLabels = {
HONIG: "Honig",
KERZEN: "Bienenwachskerzen",
PROPOLIS: "Propolis",
} as const;
export const siteConfig = {
name: "WABENHAIN",
claim: "Honig aus den Westlichen Wäldern.",
claimAlt: "Eine Landschaft. Eine Ernte. Ein ehrliches Glas.",
domain: "wabenhain.com",
description:
"Entdecke hochwertigen Honig aus eigener Imkerei in Altenmünster. Kleine Ernten, klare Herkunft und echtes Imkerhandwerk.",
location: "Altenmünster, Bayern",
email: "[email protected]",
phone: "+49 (0) 000 000000",
address: {
street: "Buhlweg 10",
zip: "86450",
city: "Altenmünster",
country: "Deutschland",
},
} as const;
// "key" verweist auf i18n/dictionary-type.ts (nav.* bzw. footer.*/legalTitles.*)
// statt eines fest verdrahteten Labels — site-chrome.tsx loest den Text zur
// Laufzeit aus der aktiven Dictionary auf, damit Navigation und Footer mit
// der Sprachumschaltung mitziehen.
export const mainNav = [
{ href: "/shop", key: "shop" },
{ href: "/ernten", key: "harvests" },
{ href: "/herkunft", key: "origin" },
{ href: "/imkerei", key: "apiary" },
{ href: "/unternehmen", key: "business" },
{ href: "/wissen", key: "knowledge" },
{ href: "/kontakt", key: "contact" },
] as const;
export const footerNav = {
wabenhain: [
{ href: "/imkerei", key: "aboutUs" },
{ href: "/herkunft", key: "origin" },
{ href: "/ernten", key: "harvests" },
{ href: "/kontakt", key: "contact" },
],
einkaufen: [
{ href: "/shop", key: "shop" },
{ href: "/versand-zahlung", key: "shipping" },
{ href: "/versand-zahlung", key: "pickup" },
{ href: "/unternehmen", key: "businessCustomers" },
{ href: "/bestellung-verfolgen", key: "trackOrder" },
],
informationen: [
{ href: "/wissen", label: "Honigwissen" },
{ href: "/kontakt", label: "Kontakt" },
],
rechtliches: [
{ href: "/impressum", key: "impressum" },
{ href: "/datenschutz", key: "datenschutz" },
{ href: "/agb", key: "agb" },
{ href: "/widerrufsbelehrung", key: "widerruf" },
{ href: "/versand-zahlung", key: "versandZahlung" },
{ href: "/cookie-richtlinie", key: "cookieRichtlinie" },
],
} as const;
// philosophyItems, processSteps, sustainabilityItems, die Fallback-Reviews
// und businessPackages leben jetzt uebersetzt in i18n/dictionaries/{de,en}.ts
// (home.philosophy, home.processTimeline, home.sustainability,
// home.reviews.testimonials, unternehmenPage.packages).
// Nur noch die Slugs (locale-unabhaengig, fuer generateStaticParams/Sitemap) —
// Titel/Anrisstext/Fliesstext jedes Artikels leben jetzt uebersetzt unter
// i18n/dictionaries/{de,en}.ts (wissenArticles), siehe i18n/dictionary-type.ts
// fuer die WissenSlug-Union, die exakt diese Liste widerspiegelt.
export const wissenSlugs = [
"warum-kristallisiert-honig",
"fluessiger-oder-cremiger-honig",
"was-bedeutet-fruehtracht",
"was-bedeutet-sommertracht",
"wie-entsteht-honig",
"warum-schmeckt-jede-honigernte-anders",
"wie-lange-ist-honig-haltbar",
"was-macht-regionalen-honig-aus",
"wie-honig-richtig-lagern",
"warum-nie-ueber-28-grad-erwaermt",
] as const;
+37
View File
@@ -0,0 +1,37 @@
import { prisma } from "@/lib/prisma";
import { sendMail } from "@/lib/mail";
import { backInStockEmail } from "@/lib/email-templates";
import { isLocale, defaultLocale } from "@/i18n/config";
/**
* Versendet die Wiederverfuegbarkeits-E-Mail an alle wartenden Anmeldungen
* fuer ein Produkt und markiert sie als benachrichtigt. Wird vom Admin-Bereich
* aufgerufen, wenn der Lagerbestand eines zuvor ausverkauften Produkts wieder
* auf > 0 gesetzt wird (siehe updateProductAction in admin/actions.ts).
*/
export async function notifyStockAvailable(productId: string): Promise<void> {
const pending = await prisma.stockNotification.findMany({
where: { productId, notifiedAt: null },
});
if (pending.length === 0) return;
const product = await prisma.product.findUnique({
where: { id: productId },
select: { name: true, slug: true },
});
if (!product) return;
// E-Mail-Fehler duerfen den Bestands-Update selbst nicht scheitern lassen —
// sendMail wirft nie (siehe lib/mail.ts). Jede Anmeldung wird in der bei
// der Anmeldung gespeicherten Sprache benachrichtigt (koennen unterschiedliche sein).
for (const notification of pending) {
const locale = isLocale(notification.locale) ? notification.locale : defaultLocale;
const { subject, html, text } = await backInStockEmail({ locale, ...product });
await sendMail({ to: notification.email, subject, html, text });
}
await prisma.stockNotification.updateMany({
where: { id: { in: pending.map((n) => n.id) } },
data: { notifiedAt: new Date() },
});
}
+32
View File
@@ -0,0 +1,32 @@
import Stripe from "stripe";
let stripeClient: Stripe | null = null;
/**
* Lazily erzeugter Stripe-Client. Wirft erst beim tatsächlichen Checkout einen
* verstaendlichen Fehler, wenn STRIPE_SECRET_KEY fehlt, statt den Server-Start
* (z.B. in Docker ohne konfigurierte Zahlungsdaten) zu blockieren.
*/
export function getStripeClient(): Stripe {
if (stripeClient) return stripeClient;
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) {
throw new Error(
"STRIPE_SECRET_KEY ist nicht gesetzt. Bitte in der .env-Datei konfigurieren."
);
}
// Ein sk_test_-Schluessel in Produktion faellt sonst nicht auf: Bestellungen
// wirken vollstaendig erfolgreich (Stripe-Testmodus quittiert brav), es
// fliesst aber nie echtes Geld — ein stiller Umsatzausfall statt eines
// Fehlers.
if (process.env.NODE_ENV === "production" && secretKey.startsWith("sk_test_")) {
console.warn(
"STRIPE_SECRET_KEY ist in Produktion ein Test-Schluessel (sk_test_...) — Zahlungen werden im Stripe-Testmodus verarbeitet, es fliesst kein echtes Geld."
);
}
stripeClient = new Stripe(secretKey);
return stripeClient;
}
+40
View File
@@ -0,0 +1,40 @@
const VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
// Nur beim ersten Modul-Laden geprueft (nicht pro Request) — macht ein in
// Produktion vergessenes TURNSTILE_SECRET_KEY im Server-Log sichtbar, statt
// die CAPTCHA-Pruefung stillschweigend fuer alle Formulare zu ueberspringen.
if (process.env.NODE_ENV === "production" && !process.env.TURNSTILE_SECRET_KEY) {
console.warn(
"TURNSTILE_SECRET_KEY ist in Produktion nicht gesetzt — CAPTCHA-Pruefung auf Registrierung/Newsletter/Firmenanfrage ist deaktiviert."
);
}
/**
* Prueft ein Cloudflare-Turnstile-Token serverseitig gegen die Siteverify-
* API. Ohne konfigurierten TURNSTILE_SECRET_KEY wird die Pruefung
* uebersprungen (true) — analog zum Stripe/Google-Fallback ohne
* Zugangsdaten, damit die Formulare auch ohne CAPTCHA-Setup nutzbar bleiben.
*/
export async function verifyTurnstileToken(token: string | null, remoteIp?: string): Promise<boolean> {
const secret = process.env.TURNSTILE_SECRET_KEY;
if (!secret) return true;
if (!token) return false;
try {
const response = await fetch(VERIFY_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ secret, response: token, ...(remoteIp ? { remoteip: remoteIp } : {}) }),
});
const data = (await response.json()) as { success: boolean };
return data.success === true;
} catch {
// Anders als bei fehlendem Secret (bewusst nicht konfiguriert) ist hier
// ein Secret vorhanden — Turnstile wurde also aktiv gewuenscht. Ein
// Netzwerkfehler bei der Pruefung selbst darf dann nicht stillschweigend
// als "bestanden" gewertet werden, sonst waere die Pruefung durch simples
// Stoeren der Verbindung zu Cloudflare umgehbar. Das Rate-Limiting bleibt
// in diesem Fall die einzige Verteidigungslinie.
return false;
}
}
+67
View File
@@ -0,0 +1,67 @@
import * as OTPAuth from "otpauth";
import QRCode from "qrcode";
import { randomBytes, createHash } from "node:crypto";
const ISSUER = "Wabenhain";
/** Erzeugt ein neues, zufaelliges Base32-TOTP-Secret. */
export function generateTotpSecret(): string {
return new OTPAuth.Secret({ size: 20 }).base32;
}
function buildTotp(secret: string, accountLabel: string): OTPAuth.TOTP {
return new OTPAuth.TOTP({
issuer: ISSUER,
label: accountLabel,
algorithm: "SHA1",
digits: 6,
period: 30,
secret,
});
}
/**
* Prueft einen 6-stelligen TOTP-Code gegen das Secret. `window: 1` erlaubt
* eine Toleranz von einem 30-Sekunden-Schritt vor/zurueck fuer leichte
* Uhrenabweichungen zwischen Server und Authenticator-App.
*/
export function verifyTotpCode(secret: string, code: string, accountLabel: string): boolean {
const totp = buildTotp(secret, accountLabel);
return totp.validate({ token: code.trim(), window: 1 }) !== null;
}
/**
* Wie verifyTotpCode, gibt bei einem gueltigen Code zusaetzlich den
* absoluten Zeitschritt zurueck (Unix-Zeit / Periode). Fuer Replay-Schutz
* beim Login: der Aufrufer vergleicht/speichert diesen Wert gegen
* Customer.lastTotpStep, damit ein mitgelesener Code nicht ein zweites Mal
* innerhalb desselben oder eines frueheren Zeitfensters funktioniert.
*/
export function verifyTotpCodeAndGetStep(secret: string, code: string, accountLabel: string): number | null {
const totp = buildTotp(secret, accountLabel);
const delta = totp.validate({ token: code.trim(), window: 1 });
if (delta === null) return null;
return Math.floor(Date.now() / 1000 / totp.period) + delta;
}
/** Data-URI eines QR-Codes fuer die otpauth://-URI, direkt in <img src> einsetzbar. */
export async function generateTotpQrCodeDataUrl(secret: string, accountLabel: string): Promise<string> {
const totp = buildTotp(secret, accountLabel);
return QRCode.toDataURL(totp.toString(), { margin: 1, width: 240 });
}
const BACKUP_CODE_COUNT = 8;
function hashBackupCode(code: string): string {
return createHash("sha256").update(code).digest("hex");
}
/** Erzeugt N lesbare Einmal-Codes (Klartext) plus deren Hashes zum Speichern. */
export function generateBackupCodes(): { plain: string[]; hashes: string[] } {
const plain = Array.from({ length: BACKUP_CODE_COUNT }, () =>
randomBytes(5).toString("hex").toUpperCase().match(/.{1,5}/g)!.join("-")
);
return { plain, hashes: plain.map(hashBackupCode) };
}
export { hashBackupCode };
+8
View File
@@ -0,0 +1,8 @@
/**
* Basis-URL fuer absolute Links in E-Mails (Bestellbestaetigung,
* Newsletter-Opt-in, Passwort-Reset). Webhooks/Server Actions haben keinen
* verlaesslichen Request-Origin, deshalb NEXT_PUBLIC_SITE_URL statt Header.
*/
export function getBaseUrl(): string {
return process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
}
+139
View File
@@ -0,0 +1,139 @@
import { describe, test, expect } from "vitest";
import { registerSchema, checkoutSchema, reviewSchema, discountCodeSchema } from "./validation";
describe("registerSchema", () => {
test("accepts a valid registration", () => {
const result = registerSchema.safeParse({
firstName: "Erika",
lastName: "Musterfrau",
email: "[email protected]",
password: "sicheres-passwort",
});
expect(result.success).toBe(true);
});
test("normalizes the email to lowercase and trims whitespace", () => {
const result = registerSchema.safeParse({
firstName: "Erika",
lastName: "Musterfrau",
email: " [email protected] ",
password: "sicheres-passwort",
});
expect(result.success).toBe(true);
if (result.success) expect(result.data.email).toBe("[email protected]");
});
test("rejects a password shorter than 8 characters", () => {
const result = registerSchema.safeParse({
firstName: "Erika",
lastName: "Musterfrau",
email: "[email protected]",
password: "kurz",
});
expect(result.success).toBe(false);
});
test("rejects a password beyond bcrypt's 72-byte limit", () => {
const result = registerSchema.safeParse({
firstName: "Erika",
lastName: "Musterfrau",
email: "[email protected]",
password: "a".repeat(73),
});
expect(result.success).toBe(false);
});
test("rejects an invalid email address", () => {
const result = registerSchema.safeParse({
firstName: "Erika",
lastName: "Musterfrau",
email: "not-an-email",
password: "sicheres-passwort",
});
expect(result.success).toBe(false);
});
});
describe("checkoutSchema", () => {
const baseInput = {
email: "[email protected]",
firstName: "Erika",
lastName: "Musterfrau",
fulfillmentMethod: "ABHOLUNG" as const,
items: [{ productId: "prod_1", quantity: 2 }],
};
test("accepts a minimal pickup order without an address", () => {
expect(checkoutSchema.safeParse(baseInput).success).toBe(true);
});
test("rejects an empty cart", () => {
const result = checkoutSchema.safeParse({ ...baseInput, items: [] });
expect(result.success).toBe(false);
});
test("rejects a quantity above the per-item limit", () => {
const result = checkoutSchema.safeParse({
...baseInput,
items: [{ productId: "prod_1", quantity: 21 }],
});
expect(result.success).toBe(false);
});
test("accepts an optional discount code", () => {
const result = checkoutSchema.safeParse({ ...baseInput, discountCode: "SOMMER15" });
expect(result.success).toBe(true);
});
});
describe("reviewSchema", () => {
test("accepts a rating of 5 with a comment", () => {
const result = reviewSchema.safeParse({ productId: "prod_1", rating: 5, comment: "Sehr gut!" });
expect(result.success).toBe(true);
});
test("rejects a rating of 0", () => {
expect(reviewSchema.safeParse({ productId: "prod_1", rating: 0 }).success).toBe(false);
});
test("rejects a rating above 5", () => {
expect(reviewSchema.safeParse({ productId: "prod_1", rating: 6 }).success).toBe(false);
});
test("allows an omitted comment", () => {
expect(reviewSchema.safeParse({ productId: "prod_1", rating: 3 }).success).toBe(true);
});
});
describe("discountCodeSchema", () => {
const baseCode = { code: "SOMMER15", type: "PERCENT" as const, value: 15 };
test("accepts a valid percent code", () => {
expect(discountCodeSchema.safeParse(baseCode).success).toBe(true);
});
test("rejects a percent value above 100", () => {
const result = discountCodeSchema.safeParse({ ...baseCode, value: 150 });
expect(result.success).toBe(false);
});
test("allows a fixed value above 100", () => {
const result = discountCodeSchema.safeParse({ ...baseCode, type: "FIXED", value: 500 });
expect(result.success).toBe(true);
});
test("rejects an end date before the start date", () => {
const result = discountCodeSchema.safeParse({
...baseCode,
validFrom: "2026-09-01",
validUntil: "2026-08-01",
});
expect(result.success).toBe(false);
});
test("uppercases the code", () => {
const result = discountCodeSchema.safeParse({ ...baseCode, code: "sommer15" });
expect(result.success).toBe(true);
if (result.success) expect(result.data.code).toBe("SOMMER15");
});
});
+195
View File
@@ -0,0 +1,195 @@
import { z } from "zod";
// Gemeinsame, gehaertete Basisfelder: Laengenlimits verhindern uebergrosse
// Payloads in der Datenbank, trim/lowercase normalisieren E-Mail-Adressen,
// damit "[email protected]" und "[email protected] " dasselbe Konto treffen.
const emailSchema = z
.string()
.trim()
.toLowerCase()
.max(254, "Die E-Mail-Adresse ist zu lang.")
.email("Bitte gib eine gültige E-Mail-Adresse ein.");
const nameSchema = (message: string) => z.string().trim().min(1, message).max(100);
export const newsletterSchema = z.object({
email: emailSchema,
});
export type NewsletterInput = z.infer<typeof newsletterSchema>;
export const stockNotificationSchema = z.object({
productId: z.string().min(1).max(64),
email: emailSchema,
});
export type StockNotificationInput = z.infer<typeof stockNotificationSchema>;
export const returnRequestSchema = z.object({
orderId: z.string().min(1).max(64),
reason: z.string().trim().min(3, "Bitte gib einen Grund für die Rücksendung an.").max(2000),
});
export type ReturnRequestInput = z.infer<typeof returnRequestSchema>;
export const businessInquirySchema = z.object({
companyName: z.string().trim().min(2, "Bitte gib den Firmennamen an.").max(200),
contactPerson: z.string().trim().min(2, "Bitte gib einen Ansprechpartner an.").max(100),
email: emailSchema,
phone: z.string().trim().max(40).optional(),
desiredQuantity: z.coerce
.number()
.int()
.min(1, "Bitte gib eine Menge von mindestens 1 an.")
.max(100_000),
desiredDeliveryDate: z.string().max(30).optional(),
package: z.enum(["KLASSIK", "GESCHENK", "INDIVIDUELL"]),
message: z.string().trim().max(5000).optional(),
});
export type BusinessInquiryInput = z.infer<typeof businessInquirySchema>;
export const registerSchema = z.object({
firstName: nameSchema("Bitte gib deinen Vornamen an."),
lastName: nameSchema("Bitte gib deinen Nachnamen an."),
email: emailSchema,
// bcrypt verarbeitet maximal 72 Bytes — laengere Passwoerter wuerden sonst
// stillschweigend abgeschnitten, deshalb hartes Limit mit klarer Meldung.
password: z
.string()
.min(8, "Das Passwort muss mindestens 8 Zeichen lang sein.")
.max(72, "Das Passwort darf höchstens 72 Zeichen lang sein."),
});
export type RegisterInput = z.infer<typeof registerSchema>;
export const loginSchema = z.object({
email: emailSchema,
password: z.string().min(1, "Bitte gib dein Passwort ein.").max(72),
});
export type LoginInput = z.infer<typeof loginSchema>;
export const requestPasswordResetSchema = z.object({
email: emailSchema,
});
export type RequestPasswordResetInput = z.infer<typeof requestPasswordResetSchema>;
export const resetPasswordSchema = z.object({
token: z.string().trim().min(1, "Ungültiger oder abgelaufener Link."),
password: z
.string()
.min(8, "Das Passwort muss mindestens 8 Zeichen lang sein.")
.max(72, "Das Passwort darf höchstens 72 Zeichen lang sein."),
});
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
export const adminCreateCustomerSchema = z.object({
firstName: nameSchema("Bitte gib den Vornamen an."),
lastName: nameSchema("Bitte gib den Nachnamen an."),
email: emailSchema,
role: z.enum(["KUNDE", "ADMIN"]),
});
export type AdminCreateCustomerInput = z.infer<typeof adminCreateCustomerSchema>;
export const adminUpdateCustomerSchema = z.object({
firstName: nameSchema("Bitte gib den Vornamen an."),
lastName: nameSchema("Bitte gib den Nachnamen an."),
email: emailSchema,
phone: z.string().trim().max(40).optional(),
role: z.enum(["KUNDE", "ADMIN"]),
});
export type AdminUpdateCustomerInput = z.infer<typeof adminUpdateCustomerSchema>;
// Nur die Zahlenfelder aus dem Admin-Produktformular — name/description/
// tasteTags/category/consistency laufen weiterhin ueber die einfachen
// str()/strOrNull()-Helfer in admin/actions/products.ts, da dort keine
// Vorzeichen-/Bereichspruefung noetig ist. num() dort prueft nur NaN/
// Infinity, nicht Vorzeichen — ohne dieses Schema waeren negative Preise/
// Gewichte/Intensitaeten ueber ein manipuliertes Formularfeld moeglich.
export const adminProductNumbersSchema = z.object({
intensityMild: z.coerce.number().int().min(1).max(5),
intensityFloral: z.coerce.number().int().min(1).max(5),
intensityKraeftig: z.coerce.number().int().min(1).max(5),
weightGrams: z.coerce.number().int().min(1, "Das Gewicht muss mindestens 1 g betragen."),
priceCents: z.coerce.number().int().min(0, "Der Preis darf nicht negativ sein."),
pricePerKgCents: z.coerce.number().int().min(0, "Der Kilopreis darf nicht negativ sein."),
quantity: z.coerce.number().int().min(0, "Der Lagerbestand darf nicht negativ sein."),
});
export type AdminProductNumbersInput = z.infer<typeof adminProductNumbersSchema>;
export const shippingTierSchema = z
.object({
minQuantity: z.coerce.number().int().min(0, "Die Mindestmenge darf nicht negativ sein."),
maxQuantity: z.coerce.number().int().min(1, "Die Höchstmenge muss mindestens 1 sein.").optional(),
priceCents: z.coerce.number().int().min(0, "Der Preis darf nicht negativ sein."),
})
.refine((data) => data.maxQuantity === undefined || data.maxQuantity >= data.minQuantity, {
message: "Die Höchstmenge muss größer oder gleich der Mindestmenge sein.",
path: ["maxQuantity"],
});
export type ShippingTierInput = z.infer<typeof shippingTierSchema>;
export const addressSchema = z.object({
label: z.string().trim().min(1, "Bitte gib eine Bezeichnung an.").max(60),
firstName: nameSchema("Bitte gib den Vornamen an."),
lastName: nameSchema("Bitte gib den Nachnamen an."),
street: z.string().trim().min(1, "Bitte gib die Straße an.").max(200),
zip: z.string().trim().min(4, "Bitte gib eine gültige Postleitzahl an.").max(10),
city: z.string().trim().min(1, "Bitte gib den Ort an.").max(100),
country: z.string().trim().max(2).default("DE"),
isDefault: z.boolean().default(false),
});
export type AddressInput = z.infer<typeof addressSchema>;
export const checkoutSchema = z.object({
email: emailSchema,
firstName: nameSchema("Bitte gib deinen Vornamen an."),
lastName: nameSchema("Bitte gib deinen Nachnamen an."),
phone: z.string().trim().max(40).optional(),
fulfillmentMethod: z.enum(["VERSAND", "ABHOLUNG"]),
street: z.string().trim().max(200).optional(),
zip: z.string().trim().max(10).optional(),
city: z.string().trim().max(100).optional(),
country: z.string().trim().max(2).default("DE"),
items: z
.array(
z.object({
productId: z.string().min(1).max(64),
quantity: z.coerce.number().int().min(1).max(20),
})
)
.min(1, "Der Warenkorb ist leer.")
.max(50, "Zu viele verschiedene Positionen im Warenkorb."),
discountCode: z.string().trim().max(50).optional(),
giftMessage: z.string().trim().max(500, "Die Geschenknachricht darf höchstens 500 Zeichen lang sein.").optional(),
});
export type CheckoutInput = z.infer<typeof checkoutSchema>;
export const reviewSchema = z.object({
productId: z.string().min(1).max(64),
rating: z.coerce.number().int().min(1, "Bitte wähle eine Bewertung von 1 bis 5 Sternen.").max(5),
comment: z.string().trim().max(2000).optional(),
});
export type ReviewInput = z.infer<typeof reviewSchema>;
export const discountCodeSchema = z
.object({
code: z
.string()
.trim()
.min(3, "Der Code muss mindestens 3 Zeichen lang sein.")
.max(30)
.toUpperCase(),
type: z.enum(["PERCENT", "FIXED"]),
value: z.coerce.number().int().min(1, "Der Wert muss mindestens 1 sein."),
minOrderCents: z.coerce.number().int().min(0, "Der Mindestbestellwert darf nicht negativ sein.").default(0),
minQuantity: z.coerce.number().int().min(1, "Die Mindestmenge muss mindestens 1 sein.").optional(),
maxUses: z.coerce.number().int().min(1, "Das Nutzungslimit muss mindestens 1 sein.").optional(),
isActive: z.boolean().default(true),
validFrom: z.coerce.date().optional(),
validUntil: z.coerce.date().optional(),
})
.refine((data) => data.type !== "PERCENT" || data.value <= 100, {
message: "Ein Prozent-Rabatt darf höchstens 100 sein.",
path: ["value"],
})
.refine((data) => !data.validFrom || !data.validUntil || data.validFrom <= data.validUntil, {
message: "Das Enddatum muss nach dem Startdatum liegen.",
path: ["validUntil"],
});
export type DiscountCodeInput = z.infer<typeof discountCodeSchema>;
+29
View File
@@ -0,0 +1,29 @@
import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array";
import type { ProductWithRelations } from "@/lib/data";
export async function isProductWishlisted(productId: string, customerId: string): Promise<boolean> {
const item = await prisma.wishlistItem.findUnique({
where: { customerId_productId: { customerId, productId } },
select: { id: true },
});
return item !== null;
}
/** Gespeicherte Produkte einer Kundin/eines Kunden, neueste zuerst. */
export async function getWishlistProducts(customerId: string): Promise<ProductWithRelations[]> {
const items = await prisma.wishlistItem.findMany({
where: { customerId },
orderBy: { createdAt: "desc" },
select: {
product: {
include: { harvest: true, batch: true, inventory: { select: { quantity: true } } },
},
},
});
return items.map((item) => ({
...item.product,
tasteTags: asStringArray(item.product.tasteTags),
images: asStringArray(item.product.images),
}));
}