diff --git a/prisma/migrations/20260925010000_archive_fields/migration.sql b/prisma/migrations/20260925010000_archive_fields/migration.sql new file mode 100644 index 0000000..8f9d933 --- /dev/null +++ b/prisma/migrations/20260925010000_archive_fields/migration.sql @@ -0,0 +1,31 @@ +-- Archiv fuer Bestellungen, Retouren und Produkte (siehe lib/archive.ts). +-- +-- archivedAt gesetzt = Eintrag erscheint im Admin-Tab "Archiv" statt in der +-- aktiven Liste. Die Automatik archiviert abgeschlossene Eintraege nach einem +-- Monat ohne Bearbeitung (Produkte: Bestand 0) und holt sie zurueck, sobald +-- sie wieder offen sind. archivedManually markiert Eintraege, die jemand +-- von Hand archiviert hat — die fasst die Automatik nicht an. +-- +-- Nullable bzw. mit Default: bestehende Zeilen gelten als aktiv; der erste +-- Aufruf der jeweiligen Admin-Liste archiviert faellige Altbestaende. + +-- AlterTable +ALTER TABLE "orders" ADD COLUMN "archivedAt" TIMESTAMP(3), +ADD COLUMN "archivedManually" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "return_requests" ADD COLUMN "archivedAt" TIMESTAMP(3), +ADD COLUMN "archivedManually" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "products" ADD COLUMN "archivedAt" TIMESTAMP(3), +ADD COLUMN "archivedManually" BOOLEAN NOT NULL DEFAULT false; + +-- CreateIndex +CREATE INDEX "orders_archivedAt_idx" ON "orders"("archivedAt"); + +-- CreateIndex +CREATE INDEX "return_requests_archivedAt_idx" ON "return_requests"("archivedAt"); + +-- CreateIndex +CREATE INDEX "products_archivedAt_idx" ON "products"("archivedAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 07b8c8b..afc2f23 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -255,6 +255,13 @@ model Product { /// Liste von Bild-URLs als JSON-Array, siehe tasteTags-Kommentar oben. images Json @default("[]") isActive Boolean @default(true) + /// Archiv (siehe lib/archive.ts): gesetzt = im Admin-Tab "Archiv" statt in + /// der aktiven Liste. Wird beim Oeffnen der Admin-Liste automatisch nach + /// den Regeln dort gesetzt und wieder geloescht; archivedManually + /// kennzeichnet einen Klick auf "Archivieren", den die Automatik nicht + /// rueckgaengig machen darf. + archivedAt DateTime? + archivedManually Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -266,6 +273,7 @@ model Product { wishlistItems WishlistItem[] stockNotifications StockNotification[] + @@index([archivedAt]) @@map("products") } @@ -412,6 +420,14 @@ model Order { /// Verpacken von Hand beigelegt (siehe Admin-Bestelldetail). giftMessage String? + /// Archiv (siehe lib/archive.ts): gesetzt = im Admin-Tab "Archiv" statt in + /// der aktiven Liste. Wird beim Oeffnen der Admin-Liste automatisch nach + /// den Regeln dort gesetzt und wieder geloescht; archivedManually + /// kennzeichnet einen Klick auf "Archivieren", den die Automatik nicht + /// rueckgaengig machen darf. + archivedAt DateTime? + archivedManually Boolean @default(false) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -434,6 +450,8 @@ model Order { @@index([createdAt]) /// Nutzungszaehlung eines Rabattcodes beim Checkout (Order.count nach discountCodeId). @@index([discountCodeId]) + /// Tab-Filter Aktiv/Archiv im Admin-Bereich. + @@index([archivedAt]) @@map("orders") } @@ -451,11 +469,19 @@ model ReturnRequest { orderId String @unique reason String status ReturnStatus @default(ANGEFRAGT) + /// Archiv (siehe lib/archive.ts): gesetzt = im Admin-Tab "Archiv" statt in + /// der aktiven Liste. Wird beim Oeffnen der Admin-Liste automatisch nach + /// den Regeln dort gesetzt und wieder geloescht; archivedManually + /// kennzeichnet einen Klick auf "Archivieren", den die Automatik nicht + /// rueckgaengig machen darf. + archivedAt DateTime? + archivedManually Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + @@index([archivedAt]) @@map("return_requests") } diff --git a/src/app/admin/actions.ts b/src/app/admin/actions.ts index a9a0d52..939cd7c 100644 --- a/src/app/admin/actions.ts +++ b/src/app/admin/actions.ts @@ -14,3 +14,4 @@ export * from "./actions/discount-codes"; export * from "./actions/content"; export * from "./actions/apicore-sync"; export * from "./actions/dhl"; +export * from "./actions/archive"; diff --git a/src/app/admin/actions/archive.ts b/src/app/admin/actions/archive.ts new file mode 100644 index 0000000..cadd7af --- /dev/null +++ b/src/app/admin/actions/archive.ts @@ -0,0 +1,63 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { prisma } from "@/lib/prisma"; +import { logAdminAction } from "@/lib/audit-log"; +import type { ArchiveKind } from "@/lib/archive"; +import { requireAdmin, str } from "./_shared"; + +const LIST_PATH: Record = { + orders: "/admin/bestellungen", + returns: "/admin/retouren", + products: "/admin/produkte", +}; + +const ENTITY_TYPE: Record = { + orders: "Order", + returns: "ReturnRequest", + products: "Product", +}; + +function isArchiveKind(value: string): value is ArchiveKind { + return value in LIST_PATH; +} + +/** + * Archiviert einen Eintrag von Hand oder holt ihn zurueck. + * + * Archivieren setzt archivedManually, damit die Automatik den Eintrag nicht + * beim naechsten Seitenaufruf wieder in die aktive Liste holt. + * Wiederherstellen setzt die Markierung zurueck; da @updatedAt dabei die + * Bearbeitungszeit erneuert, bleibt der Eintrag einen weiteren Monat aktiv. + */ +export async function setArchivedAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const kind = str(formData, "kind"); + const id = str(formData, "id"); + const archive = str(formData, "mode") === "archive"; + if (!isArchiveKind(kind) || !id) return; + + const data = archive + ? { archivedAt: new Date(), archivedManually: true } + : { archivedAt: null, archivedManually: false }; + + // updateMany statt update: eine nicht (mehr) vorhandene ID ist dann kein + // Fehler, sondern schlicht count 0 — z.B. nach dem Loeschen in einem + // zweiten Tab. + const where = { id }; + const result = + kind === "orders" + ? await prisma.order.updateMany({ where, data }) + : kind === "returns" + ? await prisma.returnRequest.updateMany({ where, data }) + : await prisma.product.updateMany({ where, data }); + if (result.count === 0) return; + + await logAdminAction(admin, { + action: archive ? "archive.manual_archive" : "archive.manual_restore", + entityType: ENTITY_TYPE[kind], + entityId: id, + }); + + revalidatePath(LIST_PATH[kind]); +} diff --git a/src/app/admin/bestellungen/page.tsx b/src/app/admin/bestellungen/page.tsx index 6a26d5f..4377683 100644 --- a/src/app/admin/bestellungen/page.tsx +++ b/src/app/admin/bestellungen/page.tsx @@ -8,31 +8,43 @@ import type { OrderStatus } from "@/generated/prisma/client"; import { insensitiveContains } from "@/lib/prisma-search"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; +import { isArchiveView } from "@/lib/archive"; +import { ensureAutoArchived } from "@/lib/archive-runner"; +import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive"; export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) { const t = getDictionary(await getLocale()); const statusLabels = t.orderStatusBadge; - const { q, status, error, message } = await props.searchParams; + const { q, status, error, message, ansicht } = await props.searchParams; const query = typeof q === "string" ? q.trim() : ""; const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined; + const isArchive = isArchiveView(ansicht); - const orders = await prisma.order.findMany({ - where: { - ...(statusFilter ? { status: statusFilter } : {}), - ...(query - ? { - OR: [ - { orderNumber: insensitiveContains(query) }, - { email: insensitiveContains(query) }, - { lastName: insensitiveContains(query) }, - ], - } - : {}), - }, - orderBy: { createdAt: "desc" }, - take: 100, - include: { items: true }, - }); + await ensureAutoArchived("orders"); + + const filterWhere = { + ...(statusFilter ? { status: statusFilter } : {}), + ...(query + ? { + OR: [ + { orderNumber: insensitiveContains(query) }, + { email: insensitiveContains(query) }, + { lastName: insensitiveContains(query) }, + ], + } + : {}), + }; + + const [orders, activeCount, archivedCount] = await Promise.all([ + prisma.order.findMany({ + where: { ...filterWhere, archivedAt: isArchive ? { not: null } : null }, + orderBy: { createdAt: "desc" }, + take: 100, + include: { items: true }, + }), + prisma.order.count({ where: { ...filterWhere, archivedAt: null } }), + prisma.order.count({ where: { ...filterWhere, archivedAt: { not: null } } }), + ]); return (
@@ -48,7 +60,21 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun

{error}

) : null} + + {isArchive ?

{t.adminArchive.autoRuleOrders}

: null} +
+ {isArchive ? : null}
diff --git a/src/app/admin/produkte/page.tsx b/src/app/admin/produkte/page.tsx index 8d72517..a8fea2b 100644 --- a/src/app/admin/produkte/page.tsx +++ b/src/app/admin/produkte/page.tsx @@ -7,20 +7,32 @@ import { deleteProductAction } from "../actions"; import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; +import { isArchiveView } from "@/lib/archive"; +import { ensureAutoArchived } from "@/lib/archive-runner"; +import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive"; export default async function AdminProductsPage(props: PageProps<"/admin/produkte">) { const t = getDictionary(await getLocale()); const searchParams = await props.searchParams; - const products = await prisma.product.findMany({ - include: { - harvest: true, - batch: true, - inventory: true, - // Entscheidet, ob geloescht werden darf — siehe Loeschknopf unten. - _count: { select: { orderItems: true } }, - }, - orderBy: { createdAt: "desc" }, - }); + const isArchive = isArchiveView(searchParams.ansicht); + + await ensureAutoArchived("products"); + + const [products, activeCount, archivedCount] = await Promise.all([ + prisma.product.findMany({ + where: { archivedAt: isArchive ? { not: null } : null }, + include: { + harvest: true, + batch: true, + inventory: true, + // Entscheidet, ob geloescht werden darf — siehe Loeschknopf unten. + _count: { select: { orderItems: true } }, + }, + orderBy: { createdAt: "desc" }, + }), + prisma.product.count({ where: { archivedAt: null } }), + prisma.product.count({ where: { archivedAt: { not: null } } }), + ]); return (
@@ -34,6 +46,19 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt + + {isArchive ?

{t.adminArchive.autoRuleProducts}

: null} +
@@ -75,6 +100,20 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt {t.adminProductsPage.edit} + + + {/* Loeschen nur, solange nie bestellt: OrderItem verweist mit onDelete: Restrict auf das Produkt, damit gestellte Rechnungen ihren Bezug behalten (siehe @@ -100,7 +139,9 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
{products.length === 0 ? ( -

{t.adminProductsPage.noProductsYet}

+

+ {isArchive ? t.adminArchive.emptyArchive : t.adminProductsPage.noProductsYet} +

) : null}
diff --git a/src/app/admin/retouren/page.tsx b/src/app/admin/retouren/page.tsx index 4f38945..7806099 100644 --- a/src/app/admin/retouren/page.tsx +++ b/src/app/admin/retouren/page.tsx @@ -6,6 +6,9 @@ import { insensitiveContains } from "@/lib/prisma-search"; import { updateReturnRequestStatusAction, refundReturnRequestAction } from "./actions"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; +import { isArchiveView } from "@/lib/archive"; +import { ensureAutoArchived } from "@/lib/archive-runner"; +import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive"; export default async function AdminRetourenPage(props: PageProps<"/admin/retouren">) { const t = getDictionary(await getLocale()); @@ -15,35 +18,58 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure ABGELEHNT: t.adminReturnsPage.statusRejected, ERSTATTET: t.adminReturnsPage.statusRefunded, }; - const { q } = await props.searchParams; + const { q, ansicht } = await props.searchParams; const query = typeof q === "string" ? q.trim() : ""; + const isArchive = isArchiveView(ansicht); - const returnRequests = await prisma.returnRequest.findMany({ - where: query - ? { - order: { - OR: [ - { orderNumber: insensitiveContains(query) }, - { firstName: insensitiveContains(query) }, - { lastName: insensitiveContains(query) }, - { email: insensitiveContains(query) }, - ], - }, - } - : undefined, - orderBy: { createdAt: "desc" }, - take: 100, - include: { - order: { include: { items: true } }, - }, - }); + await ensureAutoArchived("returns"); + + const filterWhere = query + ? { + order: { + OR: [ + { orderNumber: insensitiveContains(query) }, + { firstName: insensitiveContains(query) }, + { lastName: insensitiveContains(query) }, + { email: insensitiveContains(query) }, + ], + }, + } + : {}; + + const [returnRequests, activeCount, archivedCount] = await Promise.all([ + prisma.returnRequest.findMany({ + where: { ...filterWhere, archivedAt: isArchive ? { not: null } : null }, + orderBy: { createdAt: "desc" }, + take: 100, + include: { + order: { include: { items: true } }, + }, + }), + prisma.returnRequest.count({ where: { ...filterWhere, archivedAt: null } }), + prisma.returnRequest.count({ where: { ...filterWhere, archivedAt: { not: null } } }), + ]); return (

{t.adminReturnsPage.title}

+ + {isArchive ?

{t.adminArchive.autoRuleReturns}

: null} + + {isArchive ? : null}
-

{statusLabels[request.status] ?? request.status}

+
+

{statusLabels[request.status] ?? request.status}

+
+ +
+
    @@ -119,7 +164,11 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure ))} {returnRequests.length === 0 ? (

    - {query ? t.adminReturnsPage.noResultsFiltered : t.adminReturnsPage.noRequestsYet} + {query + ? t.adminReturnsPage.noResultsFiltered + : isArchive + ? t.adminArchive.emptyArchive + : t.adminReturnsPage.noRequestsYet}

    ) : null} diff --git a/src/components/admin-archive.tsx b/src/components/admin-archive.tsx new file mode 100644 index 0000000..6667619 --- /dev/null +++ b/src/components/admin-archive.tsx @@ -0,0 +1,80 @@ +import Link from "next/link"; +import { setArchivedAction } from "@/app/admin/actions"; +import { ARCHIVE_VIEW_PARAM, ARCHIVE_VIEW_VALUE, type ArchiveKind } from "@/lib/archive"; +import { formatDate } from "@/lib/format"; + +/** + * Tabs "Aktiv" / "Archiv" fuer die Admin-Listen Bestellungen, Retouren und + * Produkte. Reine Links statt Client-State: der Tab steht in der URL + * (?ansicht=archiv), bleibt also beim Neuladen, nach einer Server Action und + * beim Teilen eines Links erhalten. Uebrige Filter (Suche, Status) bleiben + * beim Wechsel stehen. + */ +export function AdminArchiveTabs({ + basePath, + isArchive, + counts, + keepParams, + labels, +}: { + basePath: string; + isArchive: boolean; + counts: { active: number; archived: number }; + keepParams: Record; + labels: { ariaLabel: string; active: string; archive: string }; +}) { + const hrefFor = (archive: boolean): string => { + const params = new URLSearchParams(Object.entries(keepParams).filter(([, value]) => value !== "")); + if (archive) params.set(ARCHIVE_VIEW_PARAM, ARCHIVE_VIEW_VALUE); + const query = params.toString(); + return query ? `${basePath}?${query}` : basePath; + }; + + const tabClass = (selected: boolean): string => + `px-4 py-2 text-sm border-b-2 -mb-px transition-colors ${ + selected ? "border-forest text-forest" : "border-transparent text-ink/60 hover:text-forest" + }`; + + return ( + + ); +} + +/** Knopf "Archivieren" bzw. "Wiederherstellen" samt Archivdatum. */ +export function AdminArchiveToggle({ + kind, + id, + archivedAt, + archivedManually, + labels, +}: { + kind: ArchiveKind; + id: string; + archivedAt: Date | null; + archivedManually: boolean; + labels: { archive: string; restore: string; archivedOn: (date: string) => string; manualHint: string }; +}) { + return ( + + + + + {archivedAt ? ( + + {labels.archivedOn(formatDate(archivedAt))} + {archivedManually ? ` · ${labels.manualHint}` : null} + + ) : null} + + + ); +} diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index bae9da9..adcf9cc 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -927,6 +927,22 @@ export const de: Dictionary = { unknown: "Status unbekannt", }, }, + adminArchive: { + tabsAriaLabel: "Ansicht", + tabActive: "Aktiv", + tabArchive: "Archiv", + archive: "Archivieren", + restore: "Wiederherstellen", + archivedOn: (date) => `Archiviert am ${date}`, + manualHint: "von Hand", + autoRuleOrders: + "Versendete, abgeholte und stornierte Bestellungen ohne offene Retoure landen nach einem Monat ohne Bearbeitung automatisch hier.", + autoRuleReturns: + "Angenommene, abgelehnte und erstattete Retouren landen nach einem Monat ohne Bearbeitung automatisch hier.", + autoRuleProducts: + "Produkte, die seit über einem Monat Bestand 0 haben, landen automatisch hier und kehren zurück, sobald wieder Bestand da ist. Im Shop bleiben sie unverändert sichtbar.", + emptyArchive: "Das Archiv ist leer.", + }, errorPage: { eyebrow: "Fehler", title: "Etwas ist schiefgelaufen.", diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 9458b31..f8860a5 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -915,6 +915,21 @@ export const en: Dictionary = { unknown: "Status unknown", }, }, + adminArchive: { + tabsAriaLabel: "View", + tabActive: "Active", + tabArchive: "Archive", + archive: "Archive", + restore: "Restore", + archivedOn: (date) => `Archived on ${date}`, + manualHint: "manually", + autoRuleOrders: + "Shipped, picked-up and cancelled orders without an open return move here automatically after one month without changes.", + autoRuleReturns: "Accepted, rejected and refunded returns move here automatically after one month without changes.", + autoRuleProducts: + "Products with zero stock for more than a month move here automatically and return once they are back in stock. They stay visible in the shop.", + emptyArchive: "The archive is empty.", + }, errorPage: { eyebrow: "Error", title: "Something went wrong.", diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts index d172d23..beb6fd2 100644 --- a/src/i18n/dictionary-type.ts +++ b/src/i18n/dictionary-type.ts @@ -603,6 +603,19 @@ export interface Dictionary { unknown: string; }; }; + adminArchive: { + tabsAriaLabel: string; + tabActive: string; + tabArchive: string; + archive: string; + restore: string; + archivedOn: (date: string) => string; + manualHint: string; + autoRuleOrders: string; + autoRuleReturns: string; + autoRuleProducts: string; + emptyArchive: string; + }; errorPage: { eyebrow: string; title: string; diff --git a/src/lib/archive-runner.ts b/src/lib/archive-runner.ts new file mode 100644 index 0000000..b2cf283 --- /dev/null +++ b/src/lib/archive-runner.ts @@ -0,0 +1,50 @@ +/** + * Fuehrt die Archiv-Automatik (lib/archive.ts) beim Oeffnen einer Admin-Liste + * aus. Bewusst KEINE Server Action ("use server" fehlt absichtlich): sonst + * waere das ein eigener, von aussen aufrufbarer POST-Endpunkt. + */ +import { prisma } from "@/lib/prisma"; +import { getCurrentAdmin } from "@/lib/auth"; +import { logAdminAction } from "@/lib/audit-log"; +import { runAutoArchive, type ArchiveKind } from "@/lib/archive"; + +/** + * Die Regeln haengen an "ein Monat" — minutengenau muss das nicht sein. Die + * Sperre haelt die zwei UPDATEs aus jedem einzelnen Seitenaufruf heraus + * (Filtern, Blaettern, Zurueck nach einer Aktion). + */ +const MIN_INTERVAL_MS = 5 * 60 * 1000; +const lastRun = new Map(); + +const ENTITY_TYPE: Record = { + orders: "Order", + returns: "ReturnRequest", + products: "Product", +}; + +export async function ensureAutoArchived(kind: ArchiveKind): Promise { + const now = Date.now(); + if (now - (lastRun.get(kind) ?? 0) < MIN_INTERVAL_MS) return; + + // Das Admin-Layout prueft bereits; hier zusaetzlich, weil diese Funktion + // schreibt und nie ohne angemeldeten Admin laufen soll. + const admin = await getCurrentAdmin(); + if (!admin) return; + lastRun.set(kind, now); + + try { + const result = await runAutoArchive(prisma, kind, new Date(now)); + if (result.archived > 0 || result.restored > 0) { + await logAdminAction(admin, { + action: "archive.auto", + entityType: ENTITY_TYPE[kind], + detail: `automatisch: ${result.archived} archiviert, ${result.restored} wiederhergestellt`, + }); + } + } catch (error: unknown) { + // Die Liste soll trotzdem erscheinen; beim naechsten Aufruf nach Ablauf + // der Sperre wird es erneut versucht. + const message = error instanceof Error ? error.message : "Unbekannter Fehler"; + console.error(`[archive] Automatik fuer "${kind}" fehlgeschlagen: ${message}`); + } +} diff --git a/src/lib/archive.test.ts b/src/lib/archive.test.ts new file mode 100644 index 0000000..43f7304 --- /dev/null +++ b/src/lib/archive.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi } from "vitest"; +import { + ARCHIVE_AFTER_DAYS, + archiveCutoff, + orderArchiveWhere, + orderUnarchiveWhere, + productArchiveWhere, + productUnarchiveWhere, + returnArchiveWhere, + returnUnarchiveWhere, + runAutoArchive, + type ArchiveDb, +} from "./archive"; + +const NOW = new Date("2026-09-25T12:00:00Z"); + +function fakeDb(counts: { archived: number; restored: number }) { + const model = () => ({ + updateMany: vi + .fn() + .mockResolvedValueOnce({ count: counts.archived }) + .mockResolvedValueOnce({ count: counts.restored }), + }); + return { order: model(), returnRequest: model(), product: model() }; +} + +describe("archiveCutoff", () => { + it("liegt genau einen Monat (30 Tage) vor jetzt", () => { + expect(ARCHIVE_AFTER_DAYS).toBe(30); + expect(archiveCutoff(NOW).toISOString()).toBe("2026-08-26T12:00:00.000Z"); + }); +}); + +describe("orderArchiveWhere", () => { + const where = orderArchiveWhere(archiveCutoff(NOW)); + + it("archiviert nur versendete, abgeholte und stornierte Bestellungen", () => { + expect(where.status).toEqual({ in: ["VERSENDET", "ABGEHOLT", "STORNIERT"] }); + }); + + it("verlangt, dass die letzte Bearbeitung mehr als einen Monat zurueckliegt", () => { + expect(where.updatedAt).toEqual({ lte: archiveCutoff(NOW) }); + expect(where.archivedAt).toBeNull(); + }); + + it("laesst Bestellungen mit offener oder frisch bearbeiteter Retoure aktiv", () => { + expect(where.OR).toEqual([ + { returnRequest: { is: null } }, + { + returnRequest: { + is: { status: { in: ["ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] }, updatedAt: { lte: archiveCutoff(NOW) } }, + }, + }, + ]); + }); +}); + +describe("orderUnarchiveWhere", () => { + it("holt nur automatisch archivierte Bestellungen zurueck, wenn Status oder Retoure wieder offen sind", () => { + expect(orderUnarchiveWhere()).toEqual({ + archivedAt: { not: null }, + archivedManually: false, + OR: [ + { status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] } }, + { returnRequest: { is: { status: "ANGEFRAGT" } } }, + ], + }); + }); +}); + +describe("returnArchiveWhere / returnUnarchiveWhere", () => { + it("archiviert angenommene, abgelehnte und erstattete Retouren nach einem Monat ohne Bearbeitung", () => { + expect(returnArchiveWhere(archiveCutoff(NOW))).toEqual({ + archivedAt: null, + status: { in: ["ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] }, + updatedAt: { lte: archiveCutoff(NOW) }, + }); + }); + + it("holt eine automatisch archivierte Retoure zurueck, sobald sie wieder offen ist", () => { + expect(returnUnarchiveWhere()).toEqual({ + archivedAt: { not: null }, + archivedManually: false, + status: { notIn: ["ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] }, + }); + }); +}); + +describe("productArchiveWhere / productUnarchiveWhere", () => { + it("archiviert Produkte, die seit ueber einem Monat Bestand 0 haben und nicht bearbeitet wurden", () => { + const cutoff = archiveCutoff(NOW); + expect(productArchiveWhere(cutoff)).toEqual({ + archivedAt: null, + updatedAt: { lte: cutoff }, + OR: [{ inventory: { is: null } }, { inventory: { is: { quantity: { lte: 0 }, updatedAt: { lte: cutoff } } } }], + }); + }); + + it("holt ein automatisch archiviertes Produkt zurueck, sobald wieder Bestand da ist", () => { + expect(productUnarchiveWhere()).toEqual({ + archivedAt: { not: null }, + archivedManually: false, + inventory: { is: { quantity: { gt: 0 } } }, + }); + }); +}); + +describe("runAutoArchive", () => { + it("archiviert faellige Eintraege und holt wieder offene zurueck, jeweils nur fuer die angefragte Liste", async () => { + const db = fakeDb({ archived: 3, restored: 1 }); + + const result = await runAutoArchive(db as unknown as ArchiveDb, "orders", NOW); + + expect(result).toEqual({ archived: 3, restored: 1 }); + expect(db.order.updateMany).toHaveBeenNthCalledWith(1, { + where: orderArchiveWhere(archiveCutoff(NOW)), + data: { archivedAt: NOW, archivedManually: false }, + }); + expect(db.order.updateMany).toHaveBeenNthCalledWith(2, { + where: orderUnarchiveWhere(), + data: { archivedAt: null }, + }); + expect(db.product.updateMany).not.toHaveBeenCalled(); + expect(db.returnRequest.updateMany).not.toHaveBeenCalled(); + }); + + it("nutzt fuer Retouren und Produkte die jeweils eigenen Regeln", async () => { + const db = fakeDb({ archived: 0, restored: 0 }); + + await runAutoArchive(db as unknown as ArchiveDb, "returns", NOW); + await runAutoArchive(db as unknown as ArchiveDb, "products", NOW); + + expect(db.returnRequest.updateMany.mock.calls[0][0].where).toEqual(returnArchiveWhere(archiveCutoff(NOW))); + expect(db.product.updateMany.mock.calls[0][0].where).toEqual(productArchiveWhere(archiveCutoff(NOW))); + }); +}); diff --git a/src/lib/archive.ts b/src/lib/archive.ts new file mode 100644 index 0000000..8873a27 --- /dev/null +++ b/src/lib/archive.ts @@ -0,0 +1,149 @@ +/** + * Archiv fuer Bestellungen, Retouren und Produkte im Admin-Bereich. + * + * Abgeschlossene Eintraege wandern nach einem Monat ohne Bearbeitung in den + * Tab "Archiv", damit die aktiven Listen nur zeigen, was noch zu tun ist: + * - Bestellungen: Status VERSENDET/ABGEHOLT/STORNIERT und keine offene Retoure + * - Retouren: Status ANGENOMMEN/ABGELEHNT/ERSTATTET + * - Produkte: Bestand 0 — Inventory.updatedAt ist dabei der Zeitpunkt der + * letzten Bestandsaenderung, also der Moment, seit dem der Bestand 0 ist. + * Nur Admin-Sicht: der Shop zeigt ein archiviertes Produkt unveraendert. + * + * Die Automatik laeuft beim Oeffnen der jeweiligen Admin-Liste (kein + * CronJob) und holt automatisch archivierte Eintraege zurueck, sobald sie + * wieder offen sind (Status geaendert, neue Retoure, Bestand aufgefuellt). + * Von Hand archivierte Eintraege (archivedManually) fasst sie nicht an. + * "Wiederherstellen" per Hand setzt ueber @updatedAt die Bearbeitungszeit neu + * — der Eintrag bleibt dadurch einen weiteren Monat aktiv, statt beim + * naechsten Seitenaufruf sofort wieder zu verschwinden. + * + * Die Datenbank wird hereingereicht statt importiert, damit sich die Regeln + * ohne DATABASE_URL testen lassen (siehe archive.test.ts). + */ +import type { Prisma } from "@/generated/prisma/client"; + +export const ARCHIVE_AFTER_DAYS = 30; +const DAY_MS = 24 * 60 * 60 * 1000; + +export const ORDER_DONE_STATUSES = ["VERSENDET", "ABGEHOLT", "STORNIERT"] as const; +export const RETURN_DONE_STATUSES = ["ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] as const; + +export type ArchiveKind = "orders" | "returns" | "products"; + +/** URL-Parameter fuer den Archiv-Tab, z.B. /admin/bestellungen?ansicht=archiv */ +export const ARCHIVE_VIEW_PARAM = "ansicht"; +export const ARCHIVE_VIEW_VALUE = "archiv"; + +export function isArchiveView(value: unknown): boolean { + return value === ARCHIVE_VIEW_VALUE; +} + +export function archiveCutoff(now: Date): Date { + return new Date(now.getTime() - ARCHIVE_AFTER_DAYS * DAY_MS); +} + +export function orderArchiveWhere(cutoff: Date) { + return { + archivedAt: null, + status: { in: [...ORDER_DONE_STATUSES] }, + updatedAt: { lte: cutoff }, + // Eine Retoure haengt in eigener Tabelle — ihre Bearbeitung aendert + // Order.updatedAt nicht und muss deshalb eigens abgewartet werden. + OR: [ + { returnRequest: { is: null } }, + { returnRequest: { is: { status: { in: [...RETURN_DONE_STATUSES] }, updatedAt: { lte: cutoff } } } }, + ], + } satisfies Prisma.OrderWhereInput; +} + +export function orderUnarchiveWhere() { + return { + archivedAt: { not: null }, + archivedManually: false, + OR: [ + { status: { notIn: [...ORDER_DONE_STATUSES] } }, + { returnRequest: { is: { status: "ANGEFRAGT" } } }, + ], + } satisfies Prisma.OrderWhereInput; +} + +export function returnArchiveWhere(cutoff: Date) { + return { + archivedAt: null, + status: { in: [...RETURN_DONE_STATUSES] }, + updatedAt: { lte: cutoff }, + } satisfies Prisma.ReturnRequestWhereInput; +} + +export function returnUnarchiveWhere() { + return { + archivedAt: { not: null }, + archivedManually: false, + status: { notIn: [...RETURN_DONE_STATUSES] }, + } satisfies Prisma.ReturnRequestWhereInput; +} + +export function productArchiveWhere(cutoff: Date) { + return { + archivedAt: null, + updatedAt: { lte: cutoff }, + OR: [ + // Ohne Inventory-Zeile gilt der Bestand als 0 (so zeigt ihn auch die Liste). + { inventory: { is: null } }, + { inventory: { is: { quantity: { lte: 0 }, updatedAt: { lte: cutoff } } } }, + ], + } satisfies Prisma.ProductWhereInput; +} + +export function productUnarchiveWhere() { + return { + archivedAt: { not: null }, + archivedManually: false, + inventory: { is: { quantity: { gt: 0 } } }, + } satisfies Prisma.ProductWhereInput; +} + +interface UpdateManyDelegate { + updateMany(args: { where: Where; data: { archivedAt: Date | null; archivedManually?: boolean } }): Promise<{ + count: number; + }>; +} + +/** Der Ausschnitt des Prisma-Clients, den die Automatik braucht. */ +export interface ArchiveDb { + order: UpdateManyDelegate; + returnRequest: UpdateManyDelegate; + product: UpdateManyDelegate; +} + +export interface AutoArchiveResult { + archived: number; + restored: number; +} + +export async function runAutoArchive(db: ArchiveDb, kind: ArchiveKind, now: Date = new Date()): Promise { + const cutoff = archiveCutoff(now); + const archiveData = { archivedAt: now, archivedManually: false }; + const restoreData = { archivedAt: null }; + + // Jeweils ein einzelnes UPDATE ... WHERE: die Bedingung wird atomar gegen + // den aktuellen Zeilenstand geprueft, ein gleichzeitiger Statuswechsel kann + // also keinen gerade wieder offenen Eintrag ins Archiv schieben. + switch (kind) { + case "orders": { + const archived = await db.order.updateMany({ where: orderArchiveWhere(cutoff), data: archiveData }); + const restored = await db.order.updateMany({ where: orderUnarchiveWhere(), data: restoreData }); + return { archived: archived.count, restored: restored.count }; + } + case "returns": { + const archived = await db.returnRequest.updateMany({ where: returnArchiveWhere(cutoff), data: archiveData }); + const restored = await db.returnRequest.updateMany({ where: returnUnarchiveWhere(), data: restoreData }); + return { archived: archived.count, restored: restored.count }; + } + case "products": { + const archived = await db.product.updateMany({ where: productArchiveWhere(cutoff), data: archiveData }); + const restored = await db.product.updateMany({ where: productUnarchiveWhere(), data: restoreData }); + return { archived: archived.count, restored: restored.count }; + } + } +}