diff --git a/prisma/migrations/20260919010000_order_item_fk_indexes/migration.sql b/prisma/migrations/20260919010000_order_item_fk_indexes/migration.sql new file mode 100644 index 0000000..4c54fc1 --- /dev/null +++ b/prisma/migrations/20260919010000_order_item_fk_indexes/migration.sql @@ -0,0 +1,22 @@ +-- CreateIndex +-- PostgreSQL legt fuer eine Fremdschluessel-Spalte KEINEN Index an (anders als +-- MySQL/InnoDB, das ihn automatisch anlegt). "order_items" ist die am +-- schnellsten wachsende Tabelle des Shops — je Bestellung eine Zeile pro +-- Position, und geloescht wird dort nichts. Ohne diesen Index laeuft jede +-- Abfrage nach productId als vollstaendiger Tabellenscan. +-- +-- Zwei Stellen vertragen das nicht: +-- +-- 1. hasCustomerPurchased (src/lib/reviews.ts) — der Nachweis +-- "verifizierter Kauf". Er laeuft bei JEDEM Aufruf einer Produktseite +-- durch eine angemeldete Kundschaft, also auf dem meistbesuchten +-- Seitentyp ueberhaupt. +-- 2. Das Loeschen eines Produkts im Admin-Bereich: die product-Relation ist +-- "onDelete: Restrict", die Datenbank muss also pruefen, ob noch +-- Positionen daran haengen. +CREATE INDEX "order_items_productId_idx" ON "order_items"("productId"); + +-- CreateIndex +-- Gleicher Grund: die batch-Relation ist ebenfalls "onDelete: Restrict". Das +-- Loeschen einer Charge im Admin-Bereich prueft gegen dieselbe Tabelle. +CREATE INDEX "order_items_batchId_idx" ON "order_items"("batchId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8096fe3..07b8c8b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -491,6 +491,22 @@ model OrderItem { /// Wird bei jedem Laden der Positionen einer Bestellung (Konto, Admin, /// Rechnung) ueber die order.items-Relation abgefragt. @@index([orderId]) + /// PostgreSQL legt fuer eine Fremdschluessel-Spalte KEINEN Index an (anders + /// als MySQL/InnoDB) — ohne diesen hier laeuft jede Abfrage nach productId + /// als vollstaendiger Scan ueber die am schnellsten wachsende Tabelle des + /// Shops. Betroffen sind zwei Stellen, die das nicht vertragen: + /// + /// 1. hasCustomerPurchased (lib/reviews.ts) — der Nachweis "verifizierter + /// Kauf", der bei JEDEM Aufruf einer Produktseite durch eine + /// angemeldete Kundschaft ausgefuehrt wird. + /// 2. onDelete: Restrict auf der product-Relation — die Datenbank muss + /// beim Loeschen eines Produkts pruefen, ob noch Positionen daran + /// haengen. + @@index([productId]) + /// Aus demselben Grund wie productId: die batch-Relation ist ebenfalls + /// "Restrict", das Loeschen einer Charge im Admin-Bereich prueft also gegen + /// diese Tabelle. + @@index([batchId]) @@map("order_items") } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 1ee0aa3..640f1ac 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -130,29 +130,37 @@ export default async function AdminDashboardPage() { _count: true, where: { status: { not: "STORNIERT" } }, }), - // Fuer Top-Produkte/Umsatz nach Kategorie: kein Prisma-groupBy ueber - // Relationen moeglich, deshalb Rohdaten laden und im Code aggregieren - // (bei der Groessenordnung dieses Shops unproblematisch). - prisma.orderItem.findMany({ + // Summiert wird in der DATENBANK, nicht hier. Frueher lud diese Abfrage + // JEDE Bestellposition, die es je gab, samt Produkt-Join in den Speicher, + // nur um daraus fuenf Zeilen "Top-Produkte" zu rechnen. "order_items" ist + // die am schnellsten wachsende Tabelle des Shops und wird nie + // aufgeraeumt: die Abfrage wurde mit jedem Verkauf teurer — auf der Seite, + // die im Admin-Bereich als erste geoeffnet wird. + // + // Gruppiert wird nach productId UND productName, weil productName ein + // Schnappschuss vom Bestellzeitpunkt ist: ein spaeter umbenanntes Produkt + // ergibt zwei Gruppen. Sie werden unten wieder nach productId + // zusammengefuehrt — genau wie bisher. Die Kategorie steht nicht an der + // Position und kommt deshalb aus einer zweiten, kurzen Abfrage (siehe + // unten); ueber eine Relation laesst sich nicht gruppieren. + prisma.orderItem.groupBy({ + by: ["productId", "productName"], where: { order: { status: { not: "STORNIERT" } } }, - select: { - productId: true, - productName: true, - quantity: true, - totalCents: true, - product: { select: { category: true } }, - }, + _sum: { quantity: true, totalCents: true }, }), prisma.order.findMany({ where: { createdAt: { gte: daysAgo(REVENUE_TREND_DAYS - 1) }, status: { not: "STORNIERT" } }, select: { createdAt: true, totalCents: true }, }), // Stammkunden: Kund:innen mit mehr als einer nicht-stornierten Bestellung. - // Kein Prisma-groupBy mit HAVING count > 1 in einer Abfrage moeglich, - // deshalb Rohdaten laden und im Code gruppieren. - prisma.order.findMany({ + // Zaehlen und filtern erledigt die Datenbank per HAVING; zurueck kommt + // eine Zeile je Stammkundin/Stammkunde. Frueher wurde hier JEDE Bestellung + // geladen, die es je gab, nur um im Speicher eine einzige Zahl daraus zu + // machen — mit der Begruendung, Prisma koenne kein HAVING. Es kann. + prisma.order.groupBy({ + by: ["customerId"], where: { customerId: { not: null }, status: { not: "STORNIERT" } }, - select: { customerId: true }, + having: { customerId: { _count: { gt: 1 } } }, }), prisma.discountCode.findMany({ where: { usedCount: { gt: 0 } }, @@ -161,12 +169,24 @@ export default async function AdminDashboardPage() { }), ]); - const ordersPerCustomer = new Map(); - for (const order of repeatCustomerGroups) { - if (!order.customerId) continue; - ordersPerCustomer.set(order.customerId, (ordersPerCustomer.get(order.customerId) ?? 0) + 1); - } - const repeatCustomerCount = [...ordersPerCustomer.values()].filter((count) => count > 1).length; + // Eine Zeile je Stammkundschaft — das HAVING oben hat bereits gefiltert. + const repeatCustomerCount = repeatCustomerGroups.length; + + // Die Kategorie haengt am Produkt, nicht an der Bestellposition, und laesst + // sich deshalb nicht mitgruppieren. Diese Nachfrage betrifft nur die + // tatsaechlich verkauften Produkte — eine Handvoll Zeilen ueber den + // Primaerschluessel, nicht die Bestellhistorie. Ein Produkt mit + // Bestellpositionen kann nicht geloescht werden (onDelete: Restrict), es + // gibt hier also zu jeder ID auch eine Zeile. + const soldProductIds = [...new Set(soldItems.map((group) => group.productId))]; + const soldProducts = + soldProductIds.length > 0 + ? await prisma.product.findMany({ + where: { id: { in: soldProductIds } }, + select: { id: true, category: true }, + }) + : []; + const categoryByProductId = new Map(soldProducts.map((product) => [product.id, product.category])); const statLabels: Record = { revenueToday: t.adminDashboardPage.statRevenueToday, @@ -194,20 +214,26 @@ export default async function AdminDashboardPage() { const productTotals = new Map(); const categoryTotals = new Map(); - for (const item of soldItems) { - const product = productTotals.get(item.productId) ?? { - name: item.productName, + for (const group of soldItems) { + // Ein umbenanntes Produkt kommt als zwei Gruppen an (productName ist ein + // Schnappschuss) — hier wieder zu einer Zeile zusammengefuehrt. Der zuerst + // gesehene Name gewinnt, wie zuvor. + const quantity = group._sum.quantity ?? 0; + const revenueCents = group._sum.totalCents ?? 0; + + const product = productTotals.get(group.productId) ?? { + name: group.productName, quantity: 0, revenueCents: 0, }; - product.quantity += item.quantity; - product.revenueCents += item.totalCents; - productTotals.set(item.productId, product); + product.quantity += quantity; + product.revenueCents += revenueCents; + productTotals.set(group.productId, product); - const category = item.product?.category ?? "HONIG"; + const category = categoryByProductId.get(group.productId) ?? "HONIG"; const categoryTotal = categoryTotals.get(category) ?? { quantity: 0, revenueCents: 0 }; - categoryTotal.quantity += item.quantity; - categoryTotal.revenueCents += item.totalCents; + categoryTotal.quantity += quantity; + categoryTotal.revenueCents += revenueCents; categoryTotals.set(category, categoryTotal); } diff --git a/src/app/api/auth/entra/callback/route.ts b/src/app/api/auth/entra/callback/route.ts index 751fdcd..5b34fd1 100644 --- a/src/app/api/auth/entra/callback/route.ts +++ b/src/app/api/auth/entra/callback/route.ts @@ -2,7 +2,14 @@ import { randomBytes } from "node:crypto"; import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { getBaseUrl } from "@/lib/url"; -import { createSession, hashPassword, getEntraOAuthCookie, clearEntraOAuthCookie } from "@/lib/auth"; +import { + createSession, + hashPassword, + getEntraOAuthCookie, + clearEntraOAuthCookie, + setTwoFactorChallengeCookie, +} from "@/lib/auth"; +import { signTwoFactorChallenge } from "@/lib/session"; import { isEntraConfigured, exchangeEntraAuthorizationCode, @@ -135,11 +142,28 @@ export async function GET(request: Request) { }); } - // Anmeldung per SSO wird bewusst als vollwertig authentifiziert behandelt - // und ueberspringt die App-eigene 2FA-Abfrage (falls fuer das Konto - // aktiviert): Microsoft ist hier die pruefende Authentifizierungsstelle, - // deren eigene Absicherung (bis hin zu Microsofts eigener MFA) die Rolle - // unseres TOTP-Schritts uebernimmt. + // Fuer KUNDENkonten wird die Anmeldung per SSO bewusst als vollwertig + // authentifiziert behandelt und ueberspringt die App-eigene 2FA-Abfrage + // (falls fuer das Konto aktiviert): Microsoft ist hier die pruefende + // Authentifizierungsstelle, deren eigene Absicherung (bis hin zu Microsofts + // eigener MFA) die Rolle unseres TOTP-Schritts uebernimmt. + // + // Fuer ADMINkonten gilt das NICHT. requireAdmin (admin/actions/_shared.ts) + // laesst eine Admin-Aktion nur zu, wenn am Konto twoFactorEnabled steht — + // dieses Flag soll zusichern, dass hinter jeder Rueckerstattung und jedem + // Blick in die Kundendaten tatsaechlich ein zweiter Faktor stand. Ueber + // diesen Weg war das nur noch eine Annahme ueber die MFA-Richtlinie des + // Entra-Mandanten, die dieser Code nicht nachpruefen kann: ein Flag, das + // "geprueft" behauptet, obwohl hier nichts geprueft wurde. Deshalb laeuft + // ein Admin denselben TOTP-Schritt wie beim Passwort-Login. Ausgesperrt + // werden kann dabei niemand — twoFactorEnabled heisst, dass der zweite + // Faktor bereits eingerichtet ist. + if (customer.role === "ADMIN" && customer.twoFactorEnabled) { + const challengeToken = await signTwoFactorChallenge(customer.id); + await setTwoFactorChallengeCookie(challengeToken); + return NextResponse.redirect(new URL("/konto/zwei-faktor-code", baseUrl)); + } + await createSession(customer.id, customer.role, customer.sessionVersion); return NextResponse.redirect(new URL("/konto", baseUrl)); }