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:
1 parent
5c76fcc266
commit
c7927f2883
240 files changed
+26096
-137
No files matched your search
@@ -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 } } } } },
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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}/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 } },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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: {},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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() },
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
Reference in new issue
Block a user