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 }; 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; return { ok: false, reason: message ?? "Diese E-Mail-Adresse ist bereits registriert." }; } 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, }, }; } 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; }; 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; cart: { product: number; productSlug: string; quantity: number }[] | 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, }; } 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; }, ): 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 { 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) => ({ id: line.productSlug, qty: line.quantity })); } export async function saveServerCart( token: string, customerId: number, cart: { productId: number; productSlug: string; quantity: number }[], ): 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 })), }), }); 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"; if (status === "shipped" || status === "delivered") return "request-return"; return null; } export type CustomerOrder = { orderNumber: string; createdAt: string; total: number; status: string; itemCount: number; }; export async function getCustomerOrders(token: string, customerId: number): Promise { 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: unknown[] }[] } = await res.json(); return (data.docs ?? []).map((doc) => ({ orderNumber: doc.orderNumber, createdAt: doc.createdAt, total: doc.total, status: doc.status, itemCount: doc.items.length, })); } export type CustomerOrderDetail = CustomerOrder & { id: number; customerFirstName: string; customerLastName: string; customerEmail: string; deliveryMethod: "address" | "packstation"; street: string | null; packstationNumber: string | null; postNumber: string | null; zip: string; city: string; country: string; subtotal: number; shippingCost: number; shippingMethodTitle: string; paymentMethodTitle: string; discountCode: string | null; discountAmount: number; items: { productName: string; quantity: number; unitPrice: 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. export async function getCustomerOrderDetail(token: string, customerId: number, orderNumber: string): Promise { const params = new URLSearchParams({ "where[orderNumber][equals]": orderNumber, "where[customer][equals]": String(customerId), 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 & { items: { productName: string; quantity: number; unitPrice: number }[] })[] } = 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, and // only via an allowed transition) — this is just the authenticated call; // a request the hook rejects comes back as a non-ok response here. export async function requestOrderStatusChange( token: string, orderId: number, action: "cancel" | "request-return", ): Promise<{ ok: true } | { ok: false; reason: string }> { const status = action === "cancel" ? "cancelled" : "return_requested"; const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}`, { method: "PATCH", headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ status }), }); 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 }; }