Files
Wabenhain/src/lib/validation.ts
T
Nick SzaunigandClaude Opus 5 9dd71ffb9a
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m8s
feat: Packstation ueber DHL-Standortsuche als Lieferziel waehlbar
Im Checkout laesst sich unter "Versand" jetzt zwischen der eigenen
Anschrift und einer DHL-Packstation waehlen. Postleitzahl eingeben,
suchen, Packstation aus der Trefferliste anklicken, DHL-Postnummer
eintragen — die Auswahl wird an der Bestellung gespeichert.

Aufbau:

- lib/dhl-locations.ts: Client fuer die DHL Location Finder Unified API
  (GET /find-by-address). Bewusst getrennt von lib/dhl.ts — das ist ein
  anderer DHL-Dienst mit anderer Authentifizierung: nur der API-Key im
  Header, kein OAuth und kein Geschaeftskundenvertrag. Die
  Packstation-Auswahl funktioniert damit auch, solange die
  Versandmarken-Erstellung noch nicht eingerichtet ist.
- api/shop/packstationen: serverseitiger Zwischenschritt, damit der
  DHL-API-Key nicht ins Browser-Bundle geraet. Streng rate-limitiert
  (15/Minute statt 60 wie die Produktsuche) und mit 15-Minuten-Cache:
  dahinter steht ein FREMDES Kontingent von 500 Aufrufen pro Tag, das ein
  ungebremstes Suchfeld an einem Nachmittag aufbrauchen koennte.
- Order/Address: packstationNumber + postNumber (Migration
  20260915222938_add_packstation_delivery, beide nullable).
- checkoutSchema: Postnummer als 6-10 Ziffern, plus zwei refine-Regeln —
  ohne Postnummer keine Packstation (DHL verlangt sie zwingend), und eine
  Packstation nur bei Versand, nicht bei Abholung.

Zwei Entscheidungen, die Folgearbeit ersparen:

- Bei Packstation-Auswahl tragen street/zip/city zusaetzlich die lesbare
  Anschrift ("Packstation 145", PLZ, Ort). Dadurch zeigen Rechnung,
  Bestaetigungsmail, Sendungsverfolgung und Admin-Ansicht ohne jede
  Aenderung etwas Sinnvolles; packstationNumber/postNumber tragen daneben
  das, was DHL technisch braucht.
- lib/dhl.ts baut daraus einen Locker-Empfaenger statt einer
  Hausanschrift. DHL kennt fuer den Empfaenger zwei sich ausschliessende
  Formen; eine Packstation als Strassentext zu schicken ergaebe eine Marke
  auf eine Anschrift, die es nicht gibt.

Die Option erscheint nur, wenn DHL_API_KEY gesetzt ist — sonst wuerde
Kundschaft in eine Suche laufen, die immer fehlschlaegt.

Geprueft: sechs Tests fuer das Umsetzen der DHL-Antwort (die
Packstation-Nummer steht in location.keywordId, NICHT in
location.ids[0].locationId — letzteres ist ein Systemcode, mit dem weder
Kundschaft noch DHL etwas anfangen kann). Dazu am laufenden
Produktions-Build: ohne Key liefert der Endpunkt 503 und der Checkout
zeigt die Option gar nicht; mit Key 400 bei ungueltiger PLZ und ein echter
Aufruf an api.dhl.com, der mit 401 quittiert wird — URL, Header und
Anfrageformat stimmen also. Im Browser durchgeklickt: Umschalten blendet
das Strassenfeld aus und Postnummer samt Suche ein, die Suche ruft die
Route und zeigt die Fehlermeldung an.

Feldnamen beider DHL-APIs vorab gegen developer.dhl.com geprueft. Ein
Lauf gegen einen echten Schluessel steht noch aus.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 00:37:00 +02:00

221 lines
9.4 KiB
TypeScript

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(),
/** Nummer der ueber die DHL-Standortsuche gewaehlten Packstation (z.B. "145"). */
packstationNumber: z
.string()
.trim()
.regex(/^d{1,10}$/, "Die Packstation-Nummer besteht nur aus Ziffern.")
.optional(),
/** DHL-Postnummer der empfangenden Person — bei Packstation-Lieferung Pflicht. */
postNumber: z
.string()
.trim()
.regex(/^d{6,10}$/, "Die DHL-Postnummer besteht aus 6 bis 10 Ziffern.")
.optional(),
})
// DHL verlangt bei Lieferung an eine Packstation zwingend die Postnummer
// der empfangenden Person — ohne sie laesst sich spaeter keine
// Versandmarke erstellen, die Bestellung waere also nicht zustellbar.
.refine((data) => !data.packstationNumber || Boolean(data.postNumber), {
message: "Für die Lieferung an eine Packstation wird deine DHL-Postnummer benötigt.",
path: ["postNumber"],
})
// Eine Packstation ist ein Versandziel — bei Abholung ergibt sie keinen Sinn
// und wuerde sonst still mitgespeichert.
.refine((data) => !data.packstationNumber || data.fulfillmentMethod === "VERSAND", {
message: "Eine Packstation ist nur bei Versand möglich.",
path: ["packstationNumber"],
});
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>;