added efficenciy

This commit is contained in:
Rajpreet0 2026-09-29 21:47:44 +02:00
parent bc9b0b925e
commit 7ac5713193
8 changed files with 168 additions and 38 deletions

View file

@ -0,0 +1,49 @@
import * as z from "zod"
import { getSession } from "@/lib/supabase/dal"
import { createClient } from "@/lib/supabase/server"
import { DOCUMENTS_BUCKET, MAX_THUMBNAIL_BYTES, previewKind } from "@/modules/shared/schemas/documents"
const notFound = () => new Response(null, { status: 404, headers: { "Cache-Control": "no-store" } })
// Feste URL pro Dokument statt ablaufender Signed URL: der Browser cached das Vorschaubild dauerhaft.
// Zugriff prüft RLS (Tabelle + Storage) bei jedem nicht gecachten Aufruf.
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getSession()
if (!user) return new Response(null, { status: 401, headers: { "Cache-Control": "no-store" } })
const { id } = await params
if (!z.string().uuid().safeParse(id).success) return notFound()
const supabase = await createClient()
const { data: doc } = await supabase
.from("documents")
.select("storage_path, thumbnail_path, mime_type")
.eq("id", id)
.maybeSingle()
if (!doc) return notFound()
if (doc.thumbnail_path) {
const { data: blob, error } = await supabase.storage.from(DOCUMENTS_BUCKET).download(doc.thumbnail_path)
if (error || !blob || blob.size > MAX_THUMBNAIL_BYTES) return notFound()
return new Response(blob, {
headers: {
// Inhalt stammt vom Nutzer und wird von unserer Domain ausgeliefert: Typ fest setzen, Sniffing und Skripte aus.
"Content-Type": "image/jpeg",
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
"Cache-Control": "private, max-age=31536000, immutable",
},
})
}
// Ältere Bilder ohne Vorschaubild: kurz gültige Weiterleitung aufs Original, bewusst nicht gecached.
if (previewKind(doc.mime_type) === "image") {
const { data } = await supabase.storage.from(DOCUMENTS_BUCKET).createSignedUrl(doc.storage_path, 5 * 60)
if (data?.signedUrl) {
return new Response(null, { status: 302, headers: { Location: data.signedUrl, "Cache-Control": "no-store" } })
}
}
return notFound()
}

View file

@ -0,0 +1,7 @@
-- Kleine Vorschaubilder (JPEG, im Browser beim Upload erzeugt) neben dem Original
alter table public.documents
add column if not exists thumbnail_path text;
alter table public.documents
add constraint documents_thumbnail_path_matches_client
check (thumbnail_path is null or split_part(thumbnail_path, '/', 1) = client_id::text);

View file

@ -1,6 +1,7 @@
"use server"; "use server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import * as z from "zod"; import * as z from "zod";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
@ -49,11 +50,14 @@ export async function login(
return { error: "Profil konnte nicht geladen werden. Bitte kontaktiere den Support." } return { error: "Profil konnte nicht geladen werden. Bitte kontaktiere den Support." }
} }
// Zwischengespeicherte Seiten eines vorherigen Nutzers im selben Tab verwerfen.
revalidatePath("/", "layout")
redirect(profile.role === "admin" ? "/admin/dashboard" : "/client/dashboard") redirect(profile.role === "admin" ? "/admin/dashboard" : "/client/dashboard")
} }
export async function logout() { export async function logout() {
const supabase = await createClient(); const supabase = await createClient();
await supabase.auth.signOut(); await supabase.auth.signOut();
revalidatePath("/", "layout");
redirect("/"); redirect("/");
} }

View file

@ -9,6 +9,7 @@ import {
DOCUMENTS_BUCKET, DOCUMENTS_BUCKET,
FolderNameSchema, FolderNameSchema,
MAX_FILES_PER_UPLOAD, MAX_FILES_PER_UPLOAD,
MAX_THUMBNAIL_BYTES,
UploadFileSchema, UploadFileSchema,
} from "@/modules/shared/schemas/documents"; } from "@/modules/shared/schemas/documents";
@ -95,7 +96,7 @@ async function assertFolder(clientId: string, folderId: string) {
return Boolean(data); return Boolean(data);
} }
export type PreparedUpload = { documentId: string; path: string }; export type PreparedUpload = { documentId: string; path: string; thumbnailPath: string };
// Schritt 1: Pfade vergeben. Die Dateien lädt der Browser direkt in den Storage (Vercel-Limit ~4,5 MB pro Request). // Schritt 1: Pfade vergeben. Die Dateien lädt der Browser direkt in den Storage (Vercel-Limit ~4,5 MB pro Request).
export async function prepareUpload( export async function prepareUpload(
@ -118,25 +119,45 @@ export async function prepareUpload(
return { return {
uploads: files.map((file) => { uploads: files.map((file) => {
const documentId = crypto.randomUUID(); const documentId = crypto.randomUUID();
return { documentId, path: `${access.clientId}/${documentId}/${safeFileName(file.name)}` }; return {
documentId,
path: `${access.clientId}/${documentId}/${safeFileName(file.name)}`,
thumbnailPath: thumbnailPathFor(access.clientId, documentId),
};
}), }),
}; };
} }
// Eigener Unterordner: kann nie mit einem hochgeladenen Dateinamen kollidieren (safeFileName enthält kein "/").
function thumbnailPathFor(clientId: string, documentId: string) {
return `${clientId}/${documentId}/thumbnail/preview.jpg`;
}
const FinalizeSchema = z const FinalizeSchema = z
.array(UploadFileSchema.extend({ documentId: z.string().uuid(), path: z.string().min(1).max(400) })) .array(
UploadFileSchema.extend({
documentId: z.string().uuid(),
path: z.string().min(1).max(400),
hasThumbnail: z.boolean(),
})
)
.min(1) .min(1)
.max(MAX_FILES_PER_UPLOAD); .max(MAX_FILES_PER_UPLOAD);
// Nur Dateien ohne Dokument-Datensatz entfernen – sonst könnte ein Aufruf mit dem Pfad einer // Nur Dateien ohne Dokument-Datensatz entfernen – sonst könnte ein Aufruf mit dem Pfad einer
// bestehenden Datei (Konflikt beim Insert) genau diese Datei löschen. Ohne Gewissheit: nichts löschen. // bestehenden Datei (Konflikt beim Insert) genau diese Datei löschen. Ohne Gewissheit: nichts löschen.
async function removeOrphanedObjects(paths: string[]) { async function removeOrphanedObjects(clientId: string, uploads: { documentId: string; path: string }[]) {
const supabase = await createClient(); const supabase = await createClient();
const { data: existing, error } = await supabase.from("documents").select("storage_path").in("storage_path", paths); const { data: existing, error } = await supabase
.from("documents")
.select("storage_path")
.in("storage_path", uploads.map((upload) => upload.path));
if (error) return; if (error) return;
const referenced = new Set((existing ?? []).map((row) => row.storage_path as string)); const referenced = new Set((existing ?? []).map((row) => row.storage_path as string));
const orphaned = paths.filter((path) => !referenced.has(path)); const orphaned = uploads
.filter((upload) => !referenced.has(upload.path))
.flatMap((upload) => [upload.path, thumbnailPathFor(clientId, upload.documentId)]);
if (orphaned.length > 0) await supabase.storage.from(DOCUMENTS_BUCKET).remove(orphaned); if (orphaned.length > 0) await supabase.storage.from(DOCUMENTS_BUCKET).remove(orphaned);
} }
@ -144,7 +165,7 @@ async function removeOrphanedObjects(paths: string[]) {
export async function finalizeUpload( export async function finalizeUpload(
profileId: string, profileId: string,
folderId: string, folderId: string,
uploads: { documentId: string; path: string; name: string; size: number; type: string }[] uploads: { documentId: string; path: string; name: string; size: number; type: string; hasThumbnail: boolean }[]
): Promise<DocumentActionState> { ): Promise<DocumentActionState> {
const access = await resolveAccess(profileId); const access = await resolveAccess(profileId);
if ("error" in access) return access; if ("error" in access) return access;
@ -165,7 +186,19 @@ export async function finalizeUpload(
const stored = await Promise.all( const stored = await Promise.all(
parsed.data.map(async (upload) => { parsed.data.map(async (upload) => {
const { data, error } = await supabase.storage.from(DOCUMENTS_BUCKET).info(upload.path); const { data, error } = await supabase.storage.from(DOCUMENTS_BUCKET).info(upload.path);
return error || !data ? null : { upload, size: data.size ?? null, contentType: data.contentType ?? null }; if (error || !data) return null;
// Vorschaubild ist optional: fehlt es, wird einfach keins gespeichert.
const thumbnailPath = thumbnailPathFor(access.clientId, upload.documentId);
const thumbnail = upload.hasThumbnail ? await supabase.storage.from(DOCUMENTS_BUCKET).info(thumbnailPath) : null;
const hasThumbnail = Boolean(thumbnail?.data && (thumbnail.data.size ?? 0) <= MAX_THUMBNAIL_BYTES);
return {
upload,
size: data.size ?? null,
contentType: data.contentType ?? null,
thumbnailPath: hasThumbnail ? thumbnailPath : null,
};
}) })
); );
const verified = stored.filter((entry): entry is NonNullable<typeof entry> => entry !== null); const verified = stored.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
@ -180,12 +213,13 @@ export async function finalizeUpload(
storage_path: entry.upload.path, storage_path: entry.upload.path,
mime_type: entry.contentType, mime_type: entry.contentType,
size_bytes: entry.size, size_bytes: entry.size,
thumbnail_path: entry.thumbnailPath,
uploaded_by: session.userId, uploaded_by: session.userId,
})) }))
); );
if (error) { if (error) {
await removeOrphanedObjects(parsed.data.map((upload) => upload.path)); await removeOrphanedObjects(access.clientId, parsed.data);
return { error: "Dateien konnten nicht gespeichert werden." }; return { error: "Dateien konnten nicht gespeichert werden." };
} }
@ -237,11 +271,12 @@ export async function deleteDocument(profileId: string, documentId: string): Pro
.delete() .delete()
.eq("id", documentId) .eq("id", documentId)
.eq("client_id", access.clientId) .eq("client_id", access.clientId)
.select("storage_path"); .select("storage_path, thumbnail_path");
if (error || !data?.length) return { error: "Datei konnte nicht gelöscht werden." }; if (error || !data?.length) return { error: "Datei konnte nicht gelöscht werden." };
const { error: storageError } = await supabase.storage.from(DOCUMENTS_BUCKET).remove([data[0].storage_path]); const paths = [data[0].storage_path, data[0].thumbnail_path].filter((path): path is string => Boolean(path));
const { error: storageError } = await supabase.storage.from(DOCUMENTS_BUCKET).remove(paths);
revalidateDocuments(profileId); revalidateDocuments(profileId);
if (storageError) return { error: "Eintrag gelöscht, die Datei selbst konnte aber nicht entfernt werden." }; if (storageError) return { error: "Eintrag gelöscht, die Datei selbst konnte aber nicht entfernt werden." };

View file

@ -1,8 +1,6 @@
import "server-only" import "server-only"
import { createClient } from "@/lib/supabase/server" import { createClient } from "@/lib/supabase/server"
import { DOCUMENTS_BUCKET, previewKind, type DocumentItem, type Folder } from "@/modules/shared/schemas/documents" import { previewKind, type DocumentItem, type Folder } from "@/modules/shared/schemas/documents"
const THUMBNAIL_TTL_SECONDS = 60 * 60
export async function getFolders(clientId: string): Promise<Folder[]> { export async function getFolders(clientId: string): Promise<Folder[]> {
const supabase = await createClient() const supabase = await createClient()
@ -39,33 +37,21 @@ export async function getDocuments(clientId: string, folderId: string): Promise<
const supabase = await createClient() const supabase = await createClient()
const { data, error } = await supabase const { data, error } = await supabase
.from("documents") .from("documents")
.select("id, name, mime_type, size_bytes, created_at, storage_path") .select("id, name, mime_type, size_bytes, created_at, thumbnail_path")
.eq("client_id", clientId) .eq("client_id", clientId)
.eq("folder_id", folderId) .eq("folder_id", folderId)
.order("created_at", { ascending: false }) .order("created_at", { ascending: false })
if (error) throw new Error("Dokumente konnten nicht geladen werden.") if (error) throw new Error("Dokumente konnten nicht geladen werden.")
const imagePaths = data
.filter((row) => previewKind(row.mime_type) === "image")
.map((row) => row.storage_path as string)
const thumbnails = new Map<string, string>()
if (imagePaths.length > 0) {
const { data: signed } = await supabase.storage
.from(DOCUMENTS_BUCKET)
.createSignedUrls(imagePaths, THUMBNAIL_TTL_SECONDS)
for (const entry of signed ?? []) {
if (entry.path && entry.signedUrl) thumbnails.set(entry.path, entry.signedUrl)
}
}
return data.map((row) => ({ return data.map((row) => ({
id: row.id, id: row.id,
name: row.name, name: row.name,
mimeType: row.mime_type, mimeType: row.mime_type,
sizeBytes: row.size_bytes === null ? null : Number(row.size_bytes), sizeBytes: row.size_bytes === null ? null : Number(row.size_bytes),
createdAt: row.created_at, createdAt: row.created_at,
thumbnailUrl: thumbnails.get(row.storage_path) ?? null, // Feste URL (nicht ablaufend), damit der Browser das Vorschaubild dauerhaft cachen kann.
thumbnailUrl:
row.thumbnail_path || previewKind(row.mime_type) === "image" ? `/api/documents/${row.id}/thumbnail` : null,
})) }))
} }

View file

@ -3,6 +3,8 @@ import * as z from "zod";
export const MAX_FILE_BYTES = 50 * 1024 * 1024; export const MAX_FILE_BYTES = 50 * 1024 * 1024;
export const MAX_FILES_PER_UPLOAD = 20; export const MAX_FILES_PER_UPLOAD = 20;
export const DOCUMENTS_BUCKET = "client-documents"; export const DOCUMENTS_BUCKET = "client-documents";
export const MAX_THUMBNAIL_BYTES = 512 * 1024;
export const THUMBNAIL_MAX_EDGE = 480;
export type Folder = { export type Folder = {
id: string; id: string;

View file

@ -12,6 +12,8 @@ import {
DOCUMENTS_BUCKET, DOCUMENTS_BUCKET,
MAX_FILES_PER_UPLOAD, MAX_FILES_PER_UPLOAD,
MAX_FILE_BYTES, MAX_FILE_BYTES,
MAX_THUMBNAIL_BYTES,
THUMBNAIL_MAX_EDGE,
fileKind, fileKind,
previewKind, previewKind,
formatFileSize, formatFileSize,
@ -45,6 +47,32 @@ function uniqueName(name: string, used: Set<string>) {
return next; return next;
} }
const THUMBNAIL_SOURCE_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
// Kleines JPEG (max. 480 px) statt des Originals als Vorschaubild. JPEG, weil Safari WebP per Canvas nicht zuverlässig erzeugt.
async function createThumbnail(file: File): Promise<Blob | null> {
if (!THUMBNAIL_SOURCE_TYPES.has(file.type)) return null;
try {
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
const scale = Math.min(1, THUMBNAIL_MAX_EDGE / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const context = canvas.getContext("2d");
if (!context) return null;
context.fillStyle = "#ffffff";
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close();
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.8));
return blob && blob.size <= MAX_THUMBNAIL_BYTES ? blob : null;
} catch {
return null;
}
}
async function runLimited<T>(items: T[], limit: number, worker: (item: T) => Promise<void>) { async function runLimited<T>(items: T[], limit: number, worker: (item: T) => Promise<void>) {
let index = 0; let index = 0;
await Promise.all( await Promise.all(
@ -249,16 +277,27 @@ export function DocumentsBrowser({
const supabase = createClient(); const supabase = createClient();
const jobs = prepared.uploads.map((target, index) => ({ target, file: files[index] })); const jobs = prepared.uploads.map((target, index) => ({ target, file: files[index] }));
const succeeded: typeof jobs = []; const succeeded: (typeof jobs[number] & { hasThumbnail: boolean })[] = [];
const failed: string[] = []; const failed: string[] = [];
setUpload({ done: 0, total: jobs.length }); setUpload({ done: 0, total: jobs.length });
await runLimited(jobs, PARALLEL_UPLOADS, async (job) => { await runLimited(jobs, PARALLEL_UPLOADS, async (job) => {
const { error } = await supabase.storage const bucket = supabase.storage.from(DOCUMENTS_BUCKET);
.from(DOCUMENTS_BUCKET) const { error } = await bucket.upload(job.target.path, job.file, {
.upload(job.target.path, job.file, { contentType: job.file.type || undefined, upsert: false }); contentType: job.file.type || undefined,
if (error) failed.push(job.file.name); upsert: false,
else succeeded.push(job); });
if (error) {
failed.push(job.file.name);
} else {
// Vorschaubild ist nur Komfort: schlägt es fehl, bleibt das Original trotzdem hochgeladen.
const thumbnail = await createThumbnail(job.file);
const thumbnailUploaded = thumbnail
? !(await bucket.upload(job.target.thumbnailPath, thumbnail, { contentType: "image/jpeg", upsert: false })).error
: false;
succeeded.push({ ...job, hasThumbnail: thumbnailUploaded });
}
setUpload((current) => (current ? { ...current, done: current.done + 1 } : current)); setUpload((current) => (current ? { ...current, done: current.done + 1 } : current));
}); });
@ -266,12 +305,13 @@ export function DocumentsBrowser({
const result = await finalizeUpload( const result = await finalizeUpload(
profileId, profileId,
folderId, folderId,
succeeded.map(({ target, file }) => ({ succeeded.map(({ target, file, hasThumbnail }) => ({
documentId: target.documentId, documentId: target.documentId,
path: target.path, path: target.path,
name: file.name, name: file.name,
size: file.size, size: file.size,
type: file.type, type: file.type,
hasThumbnail,
})) }))
); );
if (result && "error" in result) failed.push(...succeeded.map((job) => job.file.name)); if (result && "error" in result) failed.push(...succeeded.map((job) => job.file.name));
@ -422,7 +462,7 @@ export function DocumentsBrowser({
className="flex flex-col text-left outline-none focus-visible:ring-2 focus-visible:ring-ring" className="flex flex-col text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={doc.name} title={doc.name}
> >
<div className="flex aspect-[4/3] items-center justify-center bg-surface"> <div className="flex aspect-4/3 items-center justify-center bg-surface">
{doc.thumbnailUrl ? ( {doc.thumbnailUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- signierte, kurzlebige Storage-URL // eslint-disable-next-line @next/next/no-img-element -- signierte, kurzlebige Storage-URL
<img src={doc.thumbnailUrl} alt="" loading="lazy" className="size-full object-cover" /> <img src={doc.thumbnailUrl} alt="" loading="lazy" className="size-full object-cover" />

View file

@ -5,6 +5,13 @@ const nextConfig: NextConfig = {
outputFileTracingIncludes: { outputFileTracingIncludes: {
"/api/invoices/*": ["./modules/shared/pdf/assets/**/*"], "/api/invoices/*": ["./modules/shared/pdf/assets/**/*"],
}, },
experimental: {
// Bereits besuchte Seiten 5 Min. im Browser halten. Eigene Änderungen leeren den Cache sofort
// (revalidatePath in den Actions), ebenso Login/Logout (Cookie-Änderung).
staleTimes: {
dynamic: 300,
},
},
}; };
export default nextConfig; export default nextConfig;