since3-portal/modules/shared/actions/documents.ts
2026-09-29 16:37:48 +02:00

249 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use server";
import * as z from "zod";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
import { verifySession } from "@/lib/supabase/dal";
import { getClientId } from "@/modules/shared/queries/profile";
import {
DOCUMENTS_BUCKET,
FolderNameSchema,
MAX_FILES_PER_UPLOAD,
UploadFileSchema,
} from "@/modules/shared/schemas/documents";
const IdSchema = z.string().uuid();
const URL_TTL_SECONDS = 5 * 60;
export type DocumentActionState = { error: string } | { success: true } | undefined;
type Access = { clientId: string; isAdmin: boolean };
// Admin: jeder Client. Client: nur das eigene Profil. Storage und Tabellen prüfen zusätzlich per RLS.
async function resolveAccess(profileId: string, adminOnly = false): Promise<Access | { error: string }> {
const session = await verifySession();
if (!IdSchema.safeParse(profileId).success) return { error: "Ungültiger Client." };
if (adminOnly && !session.isAdmin) return { error: "Keine Berechtigung." };
if (!session.isAdmin && session.userId !== profileId) return { error: "Keine Berechtigung." };
const clientId = await getClientId(profileId);
if (!clientId) return { error: "Für diesen Client sind noch keine Stammdaten hinterlegt." };
return { clientId, isAdmin: session.isAdmin };
}
function revalidateDocuments(profileId: string) {
revalidatePath("/client/storage");
revalidatePath(`/admin/clients/${profileId}/storage`);
}
function safeFileName(name: string) {
const cleaned = name
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^\w.-]+/g, "_")
.replace(/_+/g, "_")
.slice(-120);
return cleaned === "" || cleaned === "." ? "datei" : cleaned;
}
export async function createFolder(_prev: DocumentActionState, formData: FormData): Promise<DocumentActionState> {
const profileId = String(formData.get("profileId") ?? "");
const access = await resolveAccess(profileId, true);
if ("error" in access) return access;
const name = FolderNameSchema.safeParse(formData.get("name") ?? "");
if (!name.success) return { error: name.error.issues[0]?.message ?? "Ungültiger Name." };
const supabase = await createClient();
const { error } = await supabase.from("folders").insert({ client_id: access.clientId, name: name.data });
if (error) {
return { error: error.code === "23505" ? "Ein Ordner mit diesem Namen existiert bereits." : "Ordner konnte nicht erstellt werden." };
}
revalidateDocuments(profileId);
return { success: true };
}
export async function deleteFolder(profileId: string, folderId: string): Promise<DocumentActionState> {
const access = await resolveAccess(profileId, true);
if ("error" in access) return access;
if (!IdSchema.safeParse(folderId).success) return { error: "Ungültiger Ordner." };
const supabase = await createClient();
const { data, error } = await supabase
.from("folders")
.delete()
.eq("id", folderId)
.eq("client_id", access.clientId)
.select("id");
if (error) {
return { error: error.code === "23503" ? "Der Ordner ist nicht leer. Bitte zuerst alle Dateien löschen." : "Ordner konnte nicht gelöscht werden." };
}
if (!data?.length) return { error: "Ordner nicht gefunden." };
revalidateDocuments(profileId);
return { success: true };
}
async function assertFolder(clientId: string, folderId: string) {
if (!IdSchema.safeParse(folderId).success) return false;
const supabase = await createClient();
const { data } = await supabase.from("folders").select("id").eq("id", folderId).eq("client_id", clientId).maybeSingle();
return Boolean(data);
}
export type PreparedUpload = { documentId: string; path: string };
// 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(
profileId: string,
folderId: string,
files: { name: string; size: number; type: string }[]
): Promise<{ error: string } | { uploads: PreparedUpload[] }> {
const access = await resolveAccess(profileId);
if ("error" in access) return access;
if (files.length === 0 || files.length > MAX_FILES_PER_UPLOAD) {
return { error: `Bitte 1 bis ${MAX_FILES_PER_UPLOAD} Dateien auf einmal hochladen.` };
}
for (const file of files) {
const parsed = UploadFileSchema.safeParse(file);
if (!parsed.success) return { error: `${file.name}: ${parsed.error.issues[0]?.message ?? "Ungültige Datei."}` };
}
if (!(await assertFolder(access.clientId, folderId))) return { error: "Ordner nicht gefunden." };
return {
uploads: files.map((file) => {
const documentId = crypto.randomUUID();
return { documentId, path: `${access.clientId}/${documentId}/${safeFileName(file.name)}` };
}),
};
}
const FinalizeSchema = z
.array(UploadFileSchema.extend({ documentId: z.string().uuid(), path: z.string().min(1).max(400) }))
.min(1)
.max(MAX_FILES_PER_UPLOAD);
// 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.
async function removeOrphanedObjects(paths: string[]) {
const supabase = await createClient();
const { data: existing, error } = await supabase.from("documents").select("storage_path").in("storage_path", paths);
if (error) return;
const referenced = new Set((existing ?? []).map((row) => row.storage_path as string));
const orphaned = paths.filter((path) => !referenced.has(path));
if (orphaned.length > 0) await supabase.storage.from(DOCUMENTS_BUCKET).remove(orphaned);
}
// Schritt 2: Datensätze anlegen – nur für Pfade im eigenen Client-Ordner, die zur vergebenen ID passen.
export async function finalizeUpload(
profileId: string,
folderId: string,
uploads: { documentId: string; path: string; name: string; size: number; type: string }[]
): Promise<DocumentActionState> {
const access = await resolveAccess(profileId);
if ("error" in access) return access;
const parsed = FinalizeSchema.safeParse(uploads);
if (!parsed.success) return { error: "Ungültige Upload-Daten." };
if (!(await assertFolder(access.clientId, folderId))) return { error: "Ordner nicht gefunden." };
const invalidPath = parsed.data.some(
(upload) => upload.path !== `${access.clientId}/${upload.documentId}/${safeFileName(upload.name)}`
);
if (invalidPath) return { error: "Ungültiger Speicherpfad." };
const session = await verifySession();
const supabase = await createClient();
// Datei muss wirklich im Storage liegen; Größe und Typ kommen von dort, nicht aus dem Request.
const stored = await Promise.all(
parsed.data.map(async (upload) => {
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 };
})
);
const verified = stored.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
if (verified.length !== parsed.data.length) return { error: "Mindestens eine Datei wurde nicht vollständig hochgeladen." };
const { error } = await supabase.from("documents").insert(
verified.map((entry) => ({
id: entry.upload.documentId,
client_id: access.clientId,
folder_id: folderId,
name: entry.upload.name,
storage_path: entry.upload.path,
mime_type: entry.contentType,
size_bytes: entry.size,
uploaded_by: session.userId,
}))
);
if (error) {
await removeOrphanedObjects(parsed.data.map((upload) => upload.path));
return { error: "Dateien konnten nicht gespeichert werden." };
}
revalidateDocuments(profileId);
return { success: true };
}
export async function getDocumentUrls(
profileId: string,
documentIds: string[],
mode: "preview" | "download"
): Promise<{ error: string } | { files: { id: string; name: string; url: string }[] }> {
const access = await resolveAccess(profileId);
if ("error" in access) return access;
const ids = z.array(z.string().uuid()).min(1).max(100).safeParse(documentIds);
if (!ids.success) return { error: "Ungültige Auswahl." };
const supabase = await createClient();
const { data, error } = await supabase
.from("documents")
.select("id, name, storage_path")
.eq("client_id", access.clientId)
.in("id", ids.data);
if (error || !data || data.length !== new Set(ids.data).size) return { error: "Nicht alle Dateien gefunden." };
const signed = await Promise.all(
data.map(async (row) => {
const { data: url } = await supabase.storage
.from(DOCUMENTS_BUCKET)
.createSignedUrl(row.storage_path, URL_TTL_SECONDS, mode === "download" ? { download: row.name } : undefined);
return url?.signedUrl ? { id: row.id as string, name: row.name as string, url: url.signedUrl } : null;
})
);
if (signed.some((entry) => entry === null)) return { error: "Download-Link konnte nicht erstellt werden." };
return { files: signed as { id: string; name: string; url: string }[] };
}
export async function deleteDocument(profileId: string, documentId: string): Promise<DocumentActionState> {
const access = await resolveAccess(profileId, true);
if ("error" in access) return access;
if (!IdSchema.safeParse(documentId).success) return { error: "Ungültige Datei." };
const supabase = await createClient();
const { data, error } = await supabase
.from("documents")
.delete()
.eq("id", documentId)
.eq("client_id", access.clientId)
.select("storage_path");
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]);
revalidateDocuments(profileId);
if (storageError) return { error: "Eintrag gelöscht, die Datei selbst konnte aber nicht entfernt werden." };
return { success: true };
}