ba830947d2
Checkout forces 0% VAT without de-grossing prices when the tenant is a Kleinunternehmer (a business decision, not just an engineering default — unlike the existing intra-community VAT exemption, which does de-gross). Snapshotted onto the order at checkout time so a later toggle of the company-settings checkbox never rewrites an already-issued invoice's tax treatment — same reasoning as the existing vatExempt field. Threaded through: checkout route, order creation/confirmation email, on-demand invoice/Storno/Gutschrift downloads, the Bestellbestätigung page, and the account order-detail page. The four storefront "inkl. X% MwSt." price hints (shop grid, cart upsell, ToDo-Karten landing page, homepage spotlight) drop that clause live when the setting is on. The company-settings Live Preview reflects the checkbox in real time too. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
594 lines
23 KiB
TypeScript
594 lines
23 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;
|
|
};
|
|
|
|
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;
|
|
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,
|
|
};
|
|
}
|
|
|
|
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;
|
|
},
|
|
): 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";
|
|
if (status === "shipped" || status === "delivered") return "request-return";
|
|
return null;
|
|
}
|
|
|
|
export type CustomerOrder = {
|
|
orderNumber: string;
|
|
createdAt: string;
|
|
total: number;
|
|
status: string;
|
|
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[];
|
|
};
|
|
|
|
export async function getCustomerOrders(token: string, customerId: number): Promise<CustomerOrder[]> {
|
|
const params = new URLSearchParams({
|
|
"where[customer][equals]": String(customerId),
|
|
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; items: { product: number }[] }[];
|
|
} = await res.json();
|
|
return (data.docs ?? []).map((doc) => ({
|
|
orderNumber: doc.orderNumber,
|
|
createdAt: doc.createdAt,
|
|
total: doc.total,
|
|
status: doc.status,
|
|
itemCount: doc.items.length,
|
|
productIds: doc.items.map((item) => item.product),
|
|
}));
|
|
}
|
|
|
|
export type CustomerOrderDetail = CustomerOrder & {
|
|
id: number;
|
|
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;
|
|
shippingDeliveryMethod: "address" | "packstation" | null;
|
|
shippingStreet: string | null;
|
|
shippingPackstationNumber: string | null;
|
|
shippingPostNumber: string | null;
|
|
shippingZip: string | null;
|
|
shippingCity: string | null;
|
|
shippingCountry: 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;
|
|
};
|
|
|
|
// 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).
|
|
export async function getCustomerOrderDetail(token: string, customerId: number, orderNumber: string): Promise<CustomerOrderDetail | null> {
|
|
const params = new URLSearchParams({
|
|
"where[orderNumber][equals]": orderNumber,
|
|
"where[customer][equals]": String(customerId),
|
|
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 };
|
|
}
|