Files
einfach-produktiv/app/lib/customerAuth.ts
T
Marco 2f8c184dc6 Add status/paymentStatus/year filters to the order overview
Server-side, URL-search-param-driven filtering (?status=&paymentStatus=&year=)
so the page stays a Server Component — each filter chip is a plain Link
to the same route with one param changed, shareable/bookmarkable/back-
button-safe for free. getCustomerOrders() gained an optional filters
param translated into additional Payload where[] clauses; a new
getCustomerOrderYears() derives the year filter's option list from the
customer's actual orders instead of a hardcoded range. paymentStatus's
"Offen" filter option mirrors PaymentStatusBadge's own grouping
(pending + not_applicable as one option, not two).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:03:30 +00:00

771 lines
31 KiB
TypeScript

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<number | null> {
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<boolean> {
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<AuthResult> {
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<AuthResult> {
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<void> {
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<AuthResult> {
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<CustomerSummary | null> {
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<CustomerProfile | null> {
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 };
}
// 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<boolean> {
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<boolean> {
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<boolean> {
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<CartItem[]> {
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<boolean> {
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<string, string> = {
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<string, string> {
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<string, string> {
if (!filters) return {};
const params: Record<string, string> = {};
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<CustomerOrder[]> {
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<string[]> {
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<CustomerOrderDetail | null> {
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<CustomerOrderDetail, "itemCount">[] } = 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<string | null> {
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 };
}