import { access } from "node:fs/promises"; import path from "node:path"; import { prisma } from "@/lib/prisma"; import { asStringArray } from "@/lib/json-array"; import { UPLOAD_DIR } from "@/lib/uploads"; /** * Gleicht ab, ob die in der Datenbank hinterlegten Bild-/Videopfade auch * tatsaechlich als Dateien vorliegen. * * Hintergrund: Datenbank und Uploads liegen an getrennten Orten. Geht das * Upload-Verzeichnis verloren — etwa weil es in Kubernetes als "emptyDir" * statt als PersistentVolumeClaim gemountet ist und damit an die Lebensdauer * des Pods gebunden bleibt —, behaelt die Datenbank ihre Pfade, die Dateien * dahinter fehlen aber. Nach aussen sieht das nicht wie ein Fehler aus: es * erscheint schlicht ueberall ein kaputtes Bild, ohne Fehlermeldung und ohne * Eintrag im Log. * * Genau diese Luecke schliesst der Abgleich: das Admin-Dashboard zeigt den * Befund beim naechsten Aufruf an, statt dass er erst auffaellt, wenn * Kundschaft im Shop leere Produktkacheln sieht. */ /** Obergrenze fuer die Dateipruefungen — das Dashboard soll nicht an ihnen haengen. */ const MAX_CHECKED_PATHS = 300; /** Anzahl der Beispielpfade im Befund, damit die Meldung nachpruefbar bleibt. */ const EXAMPLE_COUNT = 3; const UPLOAD_PATH_PREFIX = "/uploads/"; export interface UploadHealthReport { /** Wie viele verschiedene Upload-Pfade die Datenbank referenziert. */ referenced: number; /** Wie viele davon geprueft wurden (durch MAX_CHECKED_PATHS begrenzt). */ checked: number; /** Wie viele der geprueften Dateien fehlen. */ missing: number; /** Einige fehlende Pfade als Beleg. */ examples: string[]; } /** Sammelt alle in Inhalten referenzierten Upload-Pfade, ohne Dubletten. */ async function collectReferencedPaths(): Promise { const [products, harvests, batches, content] = await Promise.all([ prisma.product.findMany({ select: { images: true } }), prisma.harvest.findMany({ select: { images: true } }), prisma.batch.findMany({ select: { images: true } }), prisma.siteContent.findMany({ select: { valueDe: true, valueEn: true } }), ]); const paths = new Set(); for (const row of [...products, ...harvests, ...batches]) { for (const image of asStringArray(row.images)) { if (image.startsWith(UPLOAD_PATH_PREFIX)) paths.add(image); } } // Inhaltsfelder halten einen einzelnen Pfad als kompletten Wert (Bild- und // Videofelder der Content-Registry) — alles andere ist Fliesstext und wird // hier nicht durchsucht. for (const row of content) { for (const value of [row.valueDe, row.valueEn]) { const trimmed = value?.trim(); if (trimmed?.startsWith(UPLOAD_PATH_PREFIX)) paths.add(trimmed); } } return [...paths]; } export async function checkUploadFiles(): Promise { const referencedPaths = await collectReferencedPaths(); const toCheck = referencedPaths.slice(0, MAX_CHECKED_PATHS); const results = await Promise.all( toCheck.map(async (uploadPath) => { // Der Pfad in der Datenbank ist die oeffentliche URL ("/uploads/"), // auf der Platte liegt die Datei direkt unter UPLOAD_DIR. const fileName = uploadPath.slice(UPLOAD_PATH_PREFIX.length); try { await access(path.join(UPLOAD_DIR, fileName)); return null; } catch { return uploadPath; } }) ); const missing = results.filter((result): result is string => result !== null); return { referenced: referencedPaths.length, checked: toCheck.length, missing: missing.length, examples: missing.slice(0, EXAMPLE_COUNT), }; }