mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 21:41:16 +00:00
249 lines
10 KiB
TypeScript
249 lines
10 KiB
TypeScript
"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 };
|
||
}
|