import "server-only"; import { DEFAULT_ACCENT, isHexColor } from "@/lib/branding-colors"; import { prisma } from "@/lib/db"; import { QrRequestError } from "@/lib/qr/type-module"; export type Branding = { appName: string; footerText: string; accentColor: string; logoUploadId: string | null; faviconUploadId: string | null; /** MIME-Typ des Favicons — der `` braucht ihn. */ faviconMimeType: string | null; }; export const DEFAULT_BRANDING: Branding = { appName: "QR-Code-Generator", footerText: "statische und dynamische QR-Codes", accentColor: DEFAULT_ACCENT, logoUploadId: null, faviconUploadId: null, faviconMimeType: null, }; export const MAX_APP_NAME = 60; export const MAX_FOOTER_TEXT = 120; /** * Bewusst **ohne** Zwischenspeicher. * * Ein erster Versuch hielt den Wert 15 Sekunden lang fest, um die Abfrage je * Seitenaufruf zu sparen. Das ging schief: Die API-Route und das Layout * laufen in getrennten Modulinstanzen, das Verwerfen beim Speichern erreichte * die andere Instanz also gar nicht. Nach dem Speichern blieb die Oberfläche * unverändert — der schlechteste denkbare Eindruck bei einer Einstellung, die * man ansieht. * * Es bleibt bei einer Abfrage auf den Primärschlüssel einer Tabelle mit einer * einzigen Zeile. Das ist zu verschmerzen; jede Seite fragt ohnehin mehr ab. */ export async function getBranding(): Promise { let wert = DEFAULT_BRANDING; try { const zeile = await prisma.branding.findUnique({ where: { id: "branding" }, include: { faviconUpload: { select: { mimeType: true } }, }, }); if (zeile) { wert = { appName: zeile.appName, footerText: zeile.footerText, accentColor: isHexColor(zeile.accentColor) ? zeile.accentColor : DEFAULT_ACCENT, logoUploadId: zeile.logoUploadId, faviconUploadId: zeile.faviconUploadId, faviconMimeType: zeile.faviconUpload?.mimeType ?? null, }; } } catch (error) { // Ein Problem mit der Datenbank darf nicht dazu führen, dass gar keine // Seite mehr erscheint — dann eben im Auslieferungszustand. console.error("Erscheinungsbild konnte nicht geladen werden:", error); } return wert; } export type BrandingUpdate = { appName?: string; footerText?: string; accentColor?: string; /** `null` entfernt das Bild, `undefined` lässt es unverändert. */ logoUploadId?: string | null; faviconUploadId?: string | null; }; /** * Schreibt das Erscheinungsbild und räumt ersetzte Bilder weg. * * Ohne das Aufräumen bliebe jedes einmal hochgeladene Logo für immer in der * Datenbank liegen: Es gehört zu keinem Code, und die Aufräumroutine für * herrenlose Dateien lässt „branding" bewusst in Ruhe. */ export async function saveBranding(update: BrandingUpdate): Promise { if (update.accentColor !== undefined && !isHexColor(update.accentColor)) { throw new QrRequestError( "Die Farbe muss als Hex-Wert angegeben werden, z. B. #4F46E5.", ); } const vorher = await prisma.branding.findUnique({ where: { id: "branding" }, select: { logoUploadId: true, faviconUploadId: true }, }); const daten = { ...(update.appName !== undefined ? { appName: update.appName.trim() } : {}), ...(update.footerText !== undefined ? { footerText: update.footerText.trim() } : {}), ...(update.accentColor !== undefined ? { accentColor: update.accentColor.trim().toUpperCase() } : {}), ...(update.logoUploadId !== undefined ? { logoUploadId: update.logoUploadId } : {}), ...(update.faviconUploadId !== undefined ? { faviconUploadId: update.faviconUploadId } : {}), }; await prisma.branding.upsert({ where: { id: "branding" }, update: daten, create: { id: "branding", ...daten }, }); // Die ersetzten Dateien erst danach löschen — solange die neue Zeile nicht // steht, wird noch die alte gebraucht. const abgeloest = [ update.logoUploadId !== undefined && vorher?.logoUploadId && vorher.logoUploadId !== update.logoUploadId && vorher.logoUploadId, update.faviconUploadId !== undefined && vorher?.faviconUploadId && vorher.faviconUploadId !== update.faviconUploadId && vorher.faviconUploadId, ].filter((id): id is string => typeof id === "string"); if (abgeloest.length > 0) { await prisma.upload .deleteMany({ where: { id: { in: abgeloest } } }) .catch(() => undefined); } return getBranding(); }