Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m40s
ApiCore now owns the remaining stock per batch; the shop sells from it and reports every paid sale and every return back. - Outbox apicore_stock_events, written in the same transaction as the status change: paid -> SALE, cancelled-after-payment / refunded return -> RETURN onto the batches of the sale, reopened paid order -> SALE. Whether a return is due is decided by the open sales in the outbox, so unpaid orders never book and nothing is booked twice. - Delivery right after the response (next/server after()) plus CronJob wabenhain-apicore-stock every 10 minutes with 1/5/15/60 min backoff; ApiCore deduplicates by event id. Rejected bookings stop and show up on the dashboard. - Reconcile: shop stock = ApiCore remaining - unconfirmed bookings - reservations of unpaid orders, in a serializable transaction like the checkout. Triggered after bookings, by the signed ApiCore webhook stock.changed (ids only, numbers are fetched) and by the sync button. - Manufactured goods (candles, propolis products, ...): one shop product per ApiCore article via Product.apicoreProductId, drafts created by the sync, existing products linkable in the product form; new category SONSTIGES. Stock field is read-only for linked products. - OrderItem.apicoreAllocations keeps which lots were sold (recalls). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
282 lines
12 KiB
TypeScript
282 lines
12 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>;
|
|
|
|
// Elektronische Widerrufsfunktion (§ 356a Abs. 2 BGB): Pflicht sind NUR Name,
|
|
// Vertragsidentifikation und E-Mail — jedes weitere Pflichtfeld waere eine
|
|
// unzulaessige Huerde. Die Nachricht bleibt daher optional.
|
|
export const withdrawalSchema = z.object({
|
|
name: z.string().trim().min(2, "Bitte gib deinen Namen an.").max(120),
|
|
orderNumber: z.string().trim().min(1, "Bitte gib deine Bestellnummer an.").max(40),
|
|
email: emailSchema,
|
|
message: z.string().trim().max(2000).optional().transform((value) => value || null),
|
|
});
|
|
export type WithdrawalInput = z.infer<typeof withdrawalSchema>;
|
|
|
|
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."),
|
|
/**
|
|
* Umsatzsteuersatz als Ausnahme zur Kategorie. Leeres Feld = null = "Satz
|
|
* der Kategorie" (der Normalfall, siehe lib/vat.ts) — deshalb ein eigener
|
|
* Zweig fuer den Leerstring: z.coerce.number() machte daraus sonst eine 0,
|
|
* und 0 bedeutet hier "steuerfrei nach § 19 UStG", also etwas voellig
|
|
* anderes als "nicht gesetzt".
|
|
*/
|
|
vatRatePercent: z
|
|
.union([
|
|
z.literal("").transform(() => null),
|
|
z.coerce
|
|
.number()
|
|
.int("Der Steuersatz muss eine ganze Zahl sein.")
|
|
.min(0, "Der Steuersatz darf nicht negativ sein.")
|
|
.max(100, "Der Steuersatz darf höchstens 100 betragen."),
|
|
])
|
|
.default(null),
|
|
});
|
|
export type AdminProductNumbersInput = z.infer<typeof adminProductNumbersSchema>;
|
|
|
|
const PRODUCT_SLUG_MAX_LENGTH = 80;
|
|
|
|
/** Slug als oeffentliche Adresse (/honig/<slug>): nur a-z, 0-9 und Bindestrich. */
|
|
function toProductSlug(value: string): string {
|
|
return value
|
|
.toLowerCase()
|
|
.replace(/ä/g, "ae")
|
|
.replace(/ö/g, "oe")
|
|
.replace(/ü/g, "ue")
|
|
.replace(/ß/g, "ss")
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, PRODUCT_SLUG_MAX_LENGTH)
|
|
.replace(/-+$/, "");
|
|
}
|
|
|
|
// Kategorie und Slug beim Anlegen eines Produkts. Frueher kam die Kategorie
|
|
// nur per Typ-Cast aus dem Formular (ein manipulierter Wert endete als
|
|
// unbehandelte Prisma-Ausnahme), und ein eigener Slug wurde ungeprueft zur
|
|
// oeffentlichen Adresse — samt Schraegstrichen und Sonderzeichen.
|
|
export const adminProductIdentitySchema = z
|
|
.object({
|
|
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
|
|
slug: z.string().trim().default(""),
|
|
category: z.enum(["HONIG", "KERZEN", "PROPOLIS", "SONSTIGES"], { message: "Unbekannte Kategorie." }),
|
|
})
|
|
.transform((input) => ({ ...input, slug: toProductSlug(input.slug || input.name) }))
|
|
.refine((input) => input.slug.length > 0, {
|
|
message: "Aus Name bzw. Slug laesst sich keine Adresse bilden. Bitte einen Slug mit Buchstaben oder Ziffern eintragen.",
|
|
path: ["slug"],
|
|
});
|
|
export type AdminProductIdentityInput = z.infer<typeof adminProductIdentitySchema>;
|
|
|
|
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>;
|