import { cookies } from "next/headers"; import { randomUUID } from "node:crypto"; import type { CartItem } from "./cart"; import { sendVerificationEmail } from "./alertAdmin"; // Server-only — imported by app/api/account/*/route.ts, app/api/checkout/ // route.ts, and the /checkout and /konto/* Server Components. Never touch // Payload's own auth cookie directly: Payload (payload.mk360.de) and this // app (einfach-produktiv.mk360.de) are different origins, so instead this // app mints its OWN httpOnly cookie holding the JWT Payload issued, and // simply forwards that token as an Authorization header on every // subsequent Payload call — no shared-domain cookie config, no CORS setup // needed on the Payload side. const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de"; const TENANT_SLUG = "einfach-produktiv"; const SESSION_COOKIE = "ep_customer_token"; // Reused from the order-creation service call (see orderServer.ts) for // the handful of customer-collection operations that legitimately have no // customer session of their own yet — the email-verification link click // (cold, from an email client) being the main one. Same trust level // ("this app's own backend acting on its own behalf"), so a third secret // felt like unnecessary sprawl rather than added security. const SERVICE_SECRET = process.env.ORDER_SERVICE_SECRET || ""; async function resolveTenantId(): Promise { const params = new URLSearchParams({ "where[slug][equals]": TENANT_SLUG, limit: "1" }); const res = await fetch(`${PAYLOAD_URL}/api/tenants?${params}`, { cache: "no-store" }); if (!res.ok) return null; const data: { docs?: { id: number }[] } = await res.json(); return data.docs?.[0]?.id ?? null; } export type CustomerSummary = { id: number; customerNumber: string; firstName: string; lastName: string; email: string; emailVerified: boolean; }; export type AuthResult = | { ok: true; token: string; customer: CustomerSummary } | { ok: false; reason: string; emailExists?: boolean }; // Called from app/api/account/check-email/route.ts — lets the checkout // form detect an existing account *before* a submit attempt fails (see // CheckoutContent.tsx's email field onBlur), not just after. Service-secret // authenticated (same reasoning as the other service calls in this file) — // Customers' read access isn't public, and there's no customer session yet // at this point either way. export async function checkEmailExists(email: string): Promise { const params = new URLSearchParams({ "where[email][equals]": email, limit: "1" }); const res = await fetch(`${PAYLOAD_URL}/api/customers?${params}`, { headers: { "x-order-service-secret": SERVICE_SECRET }, cache: "no-store", }); if (!res.ok) return false; const data: { docs?: unknown[] } = await res.json(); return (data.docs?.length ?? 0) > 0; } export async function registerCustomer(input: { firstName: string; lastName: string; email: string; password: string; }): Promise { const tenantId = await resolveTenantId(); if (tenantId == null) return { ok: false, reason: "Registrierung ist gerade nicht möglich." }; const res = await fetch(`${PAYLOAD_URL}/api/customers`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...input, tenant: tenantId }), }); if (!res.ok) { const data = await res.json().catch(() => null); const message: string | undefined = data?.errors?.[0]?.message; // Payload's own message for a duplicate unique field is a generic // "The following field is invalid: email" — not distinguishable from // any other email-field validation failure by content alone, but at // this point the client's own type="email" + required already ruled // out a malformed/missing address, so "email" being the flagged field // here in practice only ever means one thing: this address is already // registered. emailExists lets the checkout UI react to that // specifically (switch to the login toggle) instead of just showing // an error the customer has no clear next step for. const emailExists = Boolean(message?.toLowerCase().includes("email")); return { ok: false, reason: emailExists ? "Diese E-Mail-Adresse ist bereits registriert. Bitte logge dich stattdessen ein." : (message ?? "Registrierung ist gerade nicht möglich."), emailExists, }; } return loginCustomer(input); } export async function loginCustomer(input: { email: string; password: string }): Promise { const res = await fetch(`${PAYLOAD_URL}/api/customers/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), }); if (!res.ok) return { ok: false, reason: "E-Mail-Adresse oder Passwort ist falsch." }; const data: { token: string; user: { id: number; customerNumber: string; firstName: string; lastName: string; email: string; emailVerified: boolean }; } = await res.json(); return { ok: true, token: data.token, customer: { id: data.user.id, customerNumber: data.user.customerNumber, firstName: data.user.firstName, lastName: data.user.lastName, email: data.user.email, emailVerified: data.user.emailVerified, }, }; } // Always resolves — never throws or returns a distinguishable "email not // found" shape. Mirrors Payload's own forgot-password operation, which // fails silently on a non-existent email specifically to avoid leaking // which addresses are registered (see auth/operations/forgotPassword.js); // the caller (app/api/account/forgot-password/route.ts) must preserve that // by always responding the same way regardless of this call's outcome. export async function requestPasswordReset(email: string): Promise { await fetch(`${PAYLOAD_URL}/api/customers/forgot-password`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }), }).catch(() => { // Best-effort — same "never reveal anything" reasoning as above. }); } // Payload's reset-password operation logs the customer in on success (see // auth/operations/resetPassword.js) and returns the same {token, user} // shape as login — reused here so the frontend route can set the session // cookie immediately, no separate login step needed after a reset. export async function resetPassword(token: string, newPassword: string): Promise { const res = await fetch(`${PAYLOAD_URL}/api/customers/reset-password`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, password: newPassword }), }); if (!res.ok) return { ok: false, reason: "Der Link ist ungültig oder abgelaufen." }; const data: { token: string; user: { id: number; customerNumber: string; firstName: string; lastName: string; email: string; emailVerified: boolean }; } = await res.json(); return { ok: true, token: data.token, customer: { id: data.user.id, customerNumber: data.user.customerNumber, firstName: data.user.firstName, lastName: data.user.lastName, email: data.user.email, emailVerified: data.user.emailVerified, }, }; } export async function getCustomerFromToken(token: string): Promise { const res = await fetch(`${PAYLOAD_URL}/api/customers/me`, { headers: { Authorization: `JWT ${token}` }, cache: "no-store", }); if (!res.ok) return null; const data: { user: { id: number; customerNumber: string; firstName: string; lastName: string; email: string; emailVerified: boolean } | null; } = await res.json(); if (!data.user) return null; return { id: data.user.id, customerNumber: data.user.customerNumber, firstName: data.user.firstName, lastName: data.user.lastName, email: data.user.email, emailVerified: data.user.emailVerified, }; } export type CustomerAddress = { deliveryMethod: "address" | "packstation" | null; street: string | null; packstationNumber: string | null; postNumber: string | null; zip: string | null; city: string | null; country: string | null; // Optional B2B profile default — see Customers.ts's own comment. Prefills // /checkout's Firma/USt-IdNr. fields for a returning customer. companyName: string | null; vatId: string | null; // Optional second/shipping address — see Customers.ts's "Lieferadresse" // tab. Prefills /checkout's "Abweichende Lieferadresse" section once // hasDifferentShippingAddress is set here; still fully overwritable per // order (Orders keeps its own shipping* snapshot regardless). hasDifferentShippingAddress: boolean; shippingFirstName: string | null; shippingLastName: string | null; shippingCompanyName: string | null; shippingDeliveryMethod: "address" | "packstation" | null; shippingStreet: string | null; shippingPackstationNumber: string | null; shippingPostNumber: string | null; shippingZip: string | null; shippingCity: string | null; shippingCountry: string | null; shippingContactEmail: string | null; shippingContactPhone: string | null; }; export type CustomerProfile = CustomerSummary & CustomerAddress; type PayloadCustomerMe = { id: number; customerNumber: string; firstName: string; lastName: string; email: string; emailVerified: boolean; deliveryMethod: "address" | "packstation" | null; street: string | null; packstationNumber: string | null; postNumber: string | null; zip: string | null; city: string | null; country: string | null; companyName: string | null; vatId: string | null; hasDifferentShippingAddress: boolean | null; shippingFirstName: string | null; shippingLastName: string | null; shippingCompanyName: string | null; shippingDeliveryMethod: "address" | "packstation" | null; shippingStreet: string | null; shippingPackstationNumber: string | null; shippingPostNumber: string | null; shippingZip: string | null; shippingCity: string | null; shippingCountry: string | null; shippingContactEmail: string | null; shippingContactPhone: string | null; cart: { product: number; productSlug: string; quantity: number; variantName: string | null }[] | null; }; export async function getCustomerProfile(token: string): Promise { const res = await fetch(`${PAYLOAD_URL}/api/customers/me`, { headers: { Authorization: `JWT ${token}` }, cache: "no-store", }); if (!res.ok) return null; const data: { user: PayloadCustomerMe | null } = await res.json(); if (!data.user) return null; const u = data.user; return { id: u.id, customerNumber: u.customerNumber, firstName: u.firstName, lastName: u.lastName, email: u.email, emailVerified: u.emailVerified, deliveryMethod: u.deliveryMethod, street: u.street, packstationNumber: u.packstationNumber, postNumber: u.postNumber, zip: u.zip, city: u.city, country: u.country, companyName: u.companyName, vatId: u.vatId, hasDifferentShippingAddress: Boolean(u.hasDifferentShippingAddress), shippingFirstName: u.shippingFirstName, shippingLastName: u.shippingLastName, shippingCompanyName: u.shippingCompanyName, shippingDeliveryMethod: u.shippingDeliveryMethod, shippingStreet: u.shippingStreet, shippingPackstationNumber: u.shippingPackstationNumber, shippingPostNumber: u.shippingPostNumber, shippingZip: u.shippingZip, shippingCity: u.shippingCity, shippingCountry: u.shippingCountry, shippingContactEmail: u.shippingContactEmail, shippingContactPhone: u.shippingContactPhone, }; } export async function updateCustomerProfile( token: string, customerId: number, data: { firstName: string; lastName: string; deliveryMethod: "address" | "packstation"; street?: string; packstationNumber?: string; postNumber?: string; zip: string; city: string; country: string; companyName?: string; vatId?: string; hasDifferentShippingAddress?: boolean; shippingFirstName?: string; shippingLastName?: string; shippingCompanyName?: string; shippingDeliveryMethod?: "address" | "packstation"; shippingStreet?: string; shippingPackstationNumber?: string; shippingPostNumber?: string; shippingZip?: string; shippingCity?: string; shippingCountry?: string; shippingContactEmail?: string; shippingContactPhone?: string; }, ): Promise<{ ok: true } | { ok: false; reason: string }> { const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, { method: "PATCH", headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" }, body: JSON.stringify(data), }); if (!res.ok) return { ok: false, reason: "Profil konnte nicht gespeichert werden." }; return { ok: true }; } // Verifies the current password by attempting a real login with it (rather // than trusting the caller) before changing anything — self-update access // alone (see Customers.ts) would let an already-authenticated request set // any password without proving it knows the old one. export async function changeCustomerPassword( email: string, currentPassword: string, newPassword: string, ): Promise<{ ok: true } | { ok: false; reason: string }> { const verify = await loginCustomer({ email, password: currentPassword }); if (!verify.ok) return { ok: false, reason: "Aktuelles Passwort ist falsch." }; const res = await fetch(`${PAYLOAD_URL}/api/customers/${verify.customer.id}`, { method: "PATCH", headers: { Authorization: `JWT ${verify.token}`, "Content-Type": "application/json" }, body: JSON.stringify({ password: newPassword }), }); if (!res.ok) return { ok: false, reason: "Passwort konnte nicht geändert werden." }; return { ok: true }; } export type WishlistItem = { id: number; productId: number; variant: string; }; // `variant` empty string, not undefined — matches WishlistItems.ts's own // defaultValue: '' so the (customer, product, variant) unique index // actually catches a duplicate add for a variant-less product too. export async function getWishlist(token: string, customerId: number): Promise { const params = new URLSearchParams({ "where[customer][equals]": String(customerId), depth: "0", limit: "200", sort: "-createdAt", }); const res = await fetch(`${PAYLOAD_URL}/api/wishlist-items?${params}`, { headers: { Authorization: `JWT ${token}` }, cache: "no-store", }); if (!res.ok) return []; const data: { docs?: { id: number; product: number; variant?: string }[] } = await res.json(); return (data.docs ?? []).map((doc) => ({ id: doc.id, productId: doc.product, variant: doc.variant ?? "" })); } // Toggles a single (product, variant) — tries to create first; a 400 here // means the unique (customer, product, variant) index rejected it because // it already exists, so this falls back to finding + deleting that row // instead. Avoids a separate "is it already wishlisted" read before every // toggle (the common case, adding something new, only needs one request). export async function toggleWishlistItem( token: string, productId: number, variant: string, ): Promise<{ ok: true; wishlisted: boolean } | { ok: false }> { const createRes = await fetch(`${PAYLOAD_URL}/api/wishlist-items`, { method: "POST", headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ product: productId, variant }), }); if (createRes.ok) return { ok: true, wishlisted: true }; const findParams = new URLSearchParams({ "where[product][equals]": String(productId), "where[variant][equals]": variant, depth: "0", limit: "1", }); const findRes = await fetch(`${PAYLOAD_URL}/api/wishlist-items?${findParams}`, { headers: { Authorization: `JWT ${token}` }, cache: "no-store", }); if (!findRes.ok) return { ok: false }; const found: { docs?: { id: number }[] } = await findRes.json(); const existingId = found.docs?.[0]?.id; if (!existingId) return { ok: false }; const deleteRes = await fetch(`${PAYLOAD_URL}/api/wishlist-items/${existingId}`, { method: "DELETE", headers: { Authorization: `JWT ${token}` }, }); if (!deleteRes.ok) return { ok: false }; return { ok: true, wishlisted: false }; } // Called from app/api/account/verify-email/route.ts — no customer session // exists at this point (cold click from an email client), so this // authenticates as the service instead (see SERVICE_SECRET above). export async function verifyEmailByToken(token: string): Promise { const params = new URLSearchParams({ "where[emailVerificationToken][equals]": token, limit: "1" }); const res = await fetch(`${PAYLOAD_URL}/api/customers?${params}`, { headers: { "x-order-service-secret": SERVICE_SECRET }, cache: "no-store", }); if (!res.ok) return false; const data: { docs?: { id: number; emailVerificationExpires: string | null }[] } = await res.json(); const doc = data.docs?.[0]; if (!doc) return false; if (doc.emailVerificationExpires && new Date(doc.emailVerificationExpires).getTime() < Date.now()) return false; const patchRes = await fetch(`${PAYLOAD_URL}/api/customers/${doc.id}`, { method: "PATCH", headers: { "x-order-service-secret": SERVICE_SECRET, "Content-Type": "application/json" }, body: JSON.stringify({ emailVerified: true }), }); return patchRes.ok; } // Called by an already-logged-in customer (app/api/account/resend- // verification/route.ts) — updates the token via their own session (self- // update access, see Customers.ts), then sends the mail directly (no // Payload afterChange hook to piggyback on for a plain update — that hook // only fires on create, see Customers.ts's own comment). export async function resendVerificationEmail(session: { token: string; customer: CustomerSummary }): Promise { const newToken = randomUUID(); const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); const res = await fetch(`${PAYLOAD_URL}/api/customers/${session.customer.id}`, { method: "PATCH", headers: { Authorization: `JWT ${session.token}`, "Content-Type": "application/json" }, body: JSON.stringify({ emailVerificationToken: newToken, emailVerificationExpires: expires }), }); if (!res.ok) return false; await sendVerificationEmail(session.customer.email, session.customer.firstName, newToken); return true; } // Self-service GDPR deletion (app/api/account/delete/route.ts) — password // re-verification happens there via loginCustomer() before this is ever // called. orders.customer is ON DELETE SET NULL (see the Payload // migration) — past orders keep their own name/address/items snapshot for // tax-retention purposes (§147 AO / GDPR Art. 17(3)(b)), only the account // itself disappears. export async function deleteCustomerAccount(token: string, customerId: number): Promise { const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, { method: "DELETE", headers: { Authorization: `JWT ${token}` }, }); return res.ok; } export async function getServerCart(token: string): Promise { const res = await fetch(`${PAYLOAD_URL}/api/customers/me`, { headers: { Authorization: `JWT ${token}` }, cache: "no-store", }); if (!res.ok) return []; const data: { user: PayloadCustomerMe | null } = await res.json(); return (data.user?.cart ?? []).map((line) => line.variantName ? { id: line.productSlug, qty: line.quantity, variant: line.variantName } : { id: line.productSlug, qty: line.quantity }, ); } export async function saveServerCart( token: string, customerId: number, cart: { productId: number; productSlug: string; quantity: number; variant?: string }[], ): Promise { const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, { method: "PATCH", headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ cart: cart.map((line) => ({ product: line.productId, productSlug: line.productSlug, quantity: line.quantity, variantName: line.variant ?? null })), }), }); return res.ok; } export const ORDER_STATUS_LABEL: Record = { received: "Eingegangen", processing: "In Bearbeitung", shipped: "Versandt", delivered: "Zugestellt", cancelled: "Storniert", return_requested: "Rücksendung angefragt", returned: "Zurückgesendet", }; // Which self-service action is available given the order's current // status — mirrors CUSTOMER_ALLOWED_TRANSITIONS in Orders.ts exactly // (that hook is the real security boundary; this is just so the UI can // decide which button, if any, to show). export function customerOrderAction(status: string): "cancel" | "request-return" | null { if (status === "received") return "cancel"; // Only once actually delivered — a (partial) return before the package // even arrived doesn't make sense yet. The backend's own // CUSTOMER_ALLOWED_TRANSITIONS still technically permits 'shipped' → // 'return_requested' too (an extensive existing test suite is built // around that as its base fixture) — this only narrows what the UI // itself offers, a stricter subset of what the backend already allows, // not a security boundary being loosened. if (status === "delivered") return "request-return"; return null; } export type CustomerOrder = { orderNumber: string; createdAt: string; total: number; status: string; paymentStatus: "not_applicable" | "pending" | "paid" | "failed" | "refunded" | "partially_refunded"; itemCount: number; /** Raw product relationship ids, in item order — depth=0 keeps them as * plain numbers, not populated objects. Callers resolve these to image * URLs separately via payload.ts's getProductImagesByIds(), not here — * this file already deliberately doesn't fetch from lib/payload.ts. */ productIds: number[]; }; // Excludes orders that never actually happened from the customer's own // point of view — a `pending_payment` order whose Stripe payment failed // (or timed out, see the backend's expirePendingPayments job) transitions // straight to `cancelled` without ever getting an `invoiceNumber` // (deferred until payment confirms, see confirmPayment.ts). A *real* // cancellation (Storno) is always of an already-`received`, already- // invoiced order, so `invoiceNumber` is always present there. That // distinction — `status: 'cancelled'` with no `invoiceNumber` — is what // separates "a real order that got cancelled" (show it) from "a checkout // attempt whose payment never went through" (nothing to show — the row // stays in Payload for admin/audit purposes, just not surfaced here). function excludeFailedPaymentAttemptsQuery(): Record { return { "where[and][1][or][0][status][not_equals]": "cancelled", "where[and][1][or][1][invoiceNumber][exists]": "true", }; } // "Offen" in the account UI's PaymentStatusBadge covers two distinct // backend values (an unconfirmed Stripe payment vs. an Überweisung order // awaiting manual reconciliation) — the filter chip mirrors that same // grouping rather than exposing the internal distinction as two options. const OPEN_PAYMENT_STATUSES = ["pending", "not_applicable"] as const; export type CustomerOrderFilters = { status?: string; paymentStatus?: string; /** Calendar year as a string, e.g. "2026" — matches createdAt within * [Jan 1, Jan 1 of next year). */ year?: string; }; function customerOrderFilterQuery(filters: CustomerOrderFilters | undefined, whereIndex: number): Record { if (!filters) return {}; const params: Record = {}; let i = whereIndex; if (filters.status) { params[`where[and][${i}][status][equals]`] = filters.status; i += 1; } if (filters.paymentStatus) { if (filters.paymentStatus === "open") { OPEN_PAYMENT_STATUSES.forEach((value, j) => { params[`where[and][${i}][or][${j}][paymentStatus][equals]`] = value; }); } else { params[`where[and][${i}][paymentStatus][equals]`] = filters.paymentStatus; } i += 1; } if (filters.year && /^\d{4}$/.test(filters.year)) { const year = Number(filters.year); params[`where[and][${i}][createdAt][greater_than_equal]`] = new Date(Date.UTC(year, 0, 1)).toISOString(); params[`where[and][${i}][createdAt][less_than]`] = new Date(Date.UTC(year + 1, 0, 1)).toISOString(); } return params; } // `excludeFailedPaymentAttempts` defaults to true (list views) — the one // exception is /api/account/export/route.ts's GDPR data export, which // passes false: a legal completeness export must include every order // row that exists about this customer, not just the ones normally shown // in "Meine Bestellungen". export async function getCustomerOrders( token: string, customerId: number, excludeFailedPaymentAttempts = true, filters?: CustomerOrderFilters, ): Promise { const params = new URLSearchParams({ "where[and][0][customer][equals]": String(customerId), ...(excludeFailedPaymentAttempts ? excludeFailedPaymentAttemptsQuery() : {}), ...customerOrderFilterQuery(filters, excludeFailedPaymentAttempts ? 2 : 1), sort: "-createdAt", depth: "0", limit: "50", }); const res = await fetch(`${PAYLOAD_URL}/api/orders?${params}`, { headers: { Authorization: `JWT ${token}` }, cache: "no-store", }); if (!res.ok) return []; const data: { docs?: { orderNumber: string; createdAt: string; total: number; status: string; paymentStatus: CustomerOrder["paymentStatus"]; items: { product: number }[]; }[]; } = await res.json(); return (data.docs ?? []).map((doc) => ({ orderNumber: doc.orderNumber, createdAt: doc.createdAt, total: doc.total, status: doc.status, paymentStatus: doc.paymentStatus, itemCount: doc.items.length, productIds: doc.items.map((item) => item.product), })); } // All years that have at least one (non-filtered-out) order for this // customer — powers the year filter's option list without hardcoding a // range. Cheap: reuses the same excludeFailedPaymentAttempts query, no // separate collection/aggregation endpoint needed for this order volume. export async function getCustomerOrderYears(token: string, customerId: number): Promise { const orders = await getCustomerOrders(token, customerId, true); const years = new Set(orders.map((o) => new Date(o.createdAt).getUTCFullYear().toString())); return Array.from(years).sort((a, b) => Number(b) - Number(a)); } export type CustomerOrderDetail = CustomerOrder & { id: number; // 'manual' (Überweisung) vs 'stripe' (Kreditkarte/PayPal) — see // api/account/orders/[orderNumber]/switch-to-stripe/route.ts, which only // offers a payment-method switch for a still-'manual' order. paymentProvider: "manual" | "stripe"; // 'not_applicable' for Überweisung orders (never gated); see // spicy-leaping-pizza.md §1 — read by /api/checkout/status for the // post-Stripe-redirect polling page. paymentStatus: "not_applicable" | "pending" | "paid" | "failed" | "refunded" | "partially_refunded"; invoiceNumber: string | null; invoiceIssuedAt: string | null; correctionInvoiceNumber: string | null; correctionInvoiceIssuedAt: string | null; carrier: string | null; trackingNumber: string | null; customerFirstName: string; customerLastName: string; customerEmail: string; companyName: string | null; vatId: string | null; vatExempt: boolean; kleinunternehmer: boolean; vatIdValidatedAt: string | null; deliveryMethod: "address" | "packstation"; street: string | null; packstationNumber: string | null; postNumber: string | null; zip: string; city: string; country: string; hasDifferentShippingAddress: boolean; shippingFirstName: string | null; shippingLastName: string | null; shippingCompanyName: string | null; shippingDeliveryMethod: "address" | "packstation" | null; shippingStreet: string | null; shippingPackstationNumber: string | null; shippingPostNumber: string | null; shippingZip: string | null; shippingCity: string | null; shippingCountry: string | null; shippingContactEmail: string | null; shippingContactPhone: string | null; subtotal: number; shippingCost: number; shippingMethodTitle: string; paymentMethodTitle: string; discountCode: string | null; discountAmount: number; returnReason: string | null; items: CustomerOrderItem[]; }; export type CustomerOrderItem = { product: number; productName: string; quantity: number; unitPrice: number; taxRatePercent: number; bundleContents: string | null; variantName: string | null; returnQuantity: number; sku: string | null; }; // Access control (Orders.ts) already scopes a customer's own JWT to only // their own orders — the where[customer] filter here is redundant with // that, kept only so a wrong/foreign orderNumber returns "not found" // instead of leaking whether that order number exists for someone else. // depth=0 — every field this type reads is already flat; keeping `product` // as a plain id (not populated) is what lets requestOrderStatusChange's // caller round-trip a full, valid items array back on a return request // (Orders.ts's field-lock hook needs every required item field present, // not just returnQuantity — see that hook's own comment). // `excludeFailedPaymentAttempts` defaults to false because this function // is shared with /api/checkout/status/route.ts's polling right after a // Stripe payment fails — that flow needs to keep seeing the // `cancelled`/no-`invoiceNumber` order (to show "Zahlung fehlgeschlagen, // bitte erneut versuchen") for exactly the same order this flag would // otherwise hide. Only /konto/bestellungen/[orderNumber] (a customer // browsing their own history, not mid-checkout) opts in. export async function getCustomerOrderDetail( token: string, customerId: number, orderNumber: string, excludeFailedPaymentAttempts = false, ): Promise { const params = new URLSearchParams({ "where[orderNumber][equals]": orderNumber, "where[customer][equals]": String(customerId), ...(excludeFailedPaymentAttempts ? excludeFailedPaymentAttemptsQuery() : {}), depth: "0", limit: "1", }); const res = await fetch(`${PAYLOAD_URL}/api/orders?${params}`, { headers: { Authorization: `JWT ${token}` }, cache: "no-store", }); if (!res.ok) return null; const data: { docs?: Omit[] } = await res.json(); const doc = data.docs?.[0]; if (!doc) return null; return { ...doc, itemCount: doc.items.length }; } // Called from app/api/account/orders/[orderNumber]/route.ts. Security // lives in Orders.ts's beforeChange hook (only `status` can change, plus // each item's `returnQuantity` alongside a return_requested transition — // see that hook's own comment) — this is just the authenticated call; a // request the hook rejects comes back as a non-ok response here. // // `items`, when provided, must be the order's FULL current items array // with only `returnQuantity` adjusted on the returned lines — Payload's // array field expects every required sub-field present on each row, not // a sparse "just the changed key" patch (the field-lock hook's own diff // also expects to see the untouched fields, not their absence). The // caller (the API route, which already has the order loaded) builds this // from getCustomerOrderDetail()'s own `items`. export async function requestOrderStatusChange( token: string, orderId: number, action: "cancel" | "request-return", extra?: { returnReason?: string; items?: CustomerOrderItem[] }, ): Promise<{ ok: true } | { ok: false; reason: string }> { const status = action === "cancel" ? "cancelled" : "return_requested"; const body: { status: string; returnReason?: string; items?: CustomerOrderItem[] } = { status }; if (action === "request-return") { if (extra?.returnReason) body.returnReason = extra.returnReason; if (extra?.items) body.items = extra.items; } const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}`, { method: "PATCH", headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { const data = await res.json().catch(() => null); return { ok: false, reason: data?.errors?.[0]?.message ?? "Aktion war nicht möglich." }; } return { ok: true }; } // Cookie helpers — Next.js's async cookies() API (Next 15+), usable in // Route Handlers (read/write) and Server Components (read-only). export async function setSessionCookie(token: string) { const store = await cookies(); store.set(SESSION_COOKIE, token, { httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 60 * 60 * 2, // matches Payload's default JWT lifetime — no refresh flow in this stage }); } export async function clearSessionCookie() { const store = await cookies(); store.delete(SESSION_COOKIE); } export async function readSessionToken(): Promise { const store = await cookies(); return store.get(SESSION_COOKIE)?.value ?? null; } // Convenience for Server Components (checkout page, /konto/*) that just // need "who's logged in, if anyone" without touching the cookie API twice. export async function getSessionCustomer(): Promise<{ token: string; customer: CustomerSummary } | null> { const token = await readSessionToken(); if (!token) return null; const customer = await getCustomerFromToken(token); if (!customer) return null; return { token, customer }; }