Files
govoplan-files/webui/src/api/files.ts
2026-07-02 14:59:52 +02:00

270 lines
11 KiB
TypeScript

import { ApiError, apiFetch, apiUrl, authHeaders, csrfToken, type ApiSettings, type FilesManagedFileLinkTarget } from "@govoplan/core-webui";
export type FileSpace = {
id: string;
label: string;
owner_type: "user" | "group";
owner_id: string;
description?: string | null;
};
export type FileShare = {
id: string;
target_type: string;
target_id: string;
permission: string;
created_at: string;
revoked_at?: string | null;
};
export type ManagedFile = {
id: string;
tenant_id: string;
owner_type: "user" | "group";
owner_id: string;
display_path: string;
filename: string;
description?: string | null;
size_bytes: number;
content_type?: string | null;
checksum_sha256: string;
version_id: string;
created_at: string;
updated_at: string;
deleted_at?: string | null;
audit_relevant: boolean;
metadata?: Record<string, unknown> | null;
shares?: FileShare[];
};
export type FileListResponse = { files: ManagedFile[] };
export type FileSpacesResponse = { spaces: FileSpace[] };
export type FileUploadResponse = { files: ManagedFile[] };
export type FileUploadProgress = { loaded: number; total?: number; percentage: number | null };
export type FileFolder = {
id: string;
tenant_id: string;
owner_type: "user" | "group";
owner_id: string;
path: string;
created_at: string;
updated_at: string;
deleted_at?: string | null;
};
export type FileFoldersResponse = { folders: FileFolder[] };
export type FolderDeleteResponse = { deleted_folders: number; deleted_files: number };
export type BulkDeleteResponse = { deleted_count: number };
export type RenameResponse = { dry_run: boolean; items: { kind: "file" | "folder"; id: string; file_id?: string | null; folder_path?: string | null; old_path: string; new_path: string }[] };
export type TransferResponse = { operation: "move" | "copy"; files: number; folders: number };
export type ConflictAction = "overwrite" | "rename" | "skip";
export type ConflictStrategy = "reject" | "overwrite" | "rename";
export type ConflictResolution = { target_path: string; action: ConflictAction; new_path?: string };
export type PatternResolveResponse = {
patterns: { pattern: string; matches: ManagedFile[] }[];
unmatched: ManagedFile[];
};
export function listFileSpaces(settings: ApiSettings): Promise<FileSpacesResponse> {
return apiFetch<FileSpacesResponse>(settings, "/api/v1/files/spaces");
}
export function listFolders(settings: ApiSettings, params: { owner_type: "user" | "group"; owner_id: string }): Promise<FileFoldersResponse> {
const search = new URLSearchParams();
search.set("owner_type", params.owner_type);
search.set("owner_id", params.owner_id);
return apiFetch<FileFoldersResponse>(settings, `/api/v1/files/folders?${search.toString()}`);
}
export function createFolder(
settings: ApiSettings,
payload: { owner_type: "user" | "group"; owner_id: string; path: string }
): Promise<FileFolder> {
return apiFetch<FileFolder>(settings, "/api/v1/files/folders", { method: "POST", body: JSON.stringify(payload) });
}
export function deleteFolder(
settings: ApiSettings,
payload: { owner_type: "user" | "group"; owner_id: string; path: string; recursive?: boolean }
): Promise<FolderDeleteResponse> {
return apiFetch<FolderDeleteResponse>(settings, "/api/v1/files/folders/delete", { method: "POST", body: JSON.stringify({ recursive: true, ...payload }) });
}
export function listFiles(settings: ApiSettings, params: { owner_type?: string; owner_id?: string; campaign_id?: string; path_prefix?: string } = {}): Promise<FileListResponse> {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value) search.set(key, value);
}
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<FileListResponse>(settings, `/api/v1/files${suffix}`);
}
export async function uploadFiles(
settings: ApiSettings,
files: File[],
options: {
owner_type: "user" | "group";
owner_id: string;
path?: string;
campaign_id?: string;
unpack_zip?: boolean;
conflict_strategy?: ConflictStrategy;
conflict_resolutions?: ConflictResolution[];
onProgress?: (progress: FileUploadProgress) => void;
}
): Promise<FileUploadResponse> {
const form = new FormData();
files.forEach((file) => form.append("files", file));
form.append("owner_type", options.owner_type);
form.append("owner_id", options.owner_id);
form.append("path", options.path ?? "");
if (options.campaign_id) form.append("campaign_id", options.campaign_id);
if (options.unpack_zip) form.append("unpack_zip", "true");
if (options.conflict_strategy) form.append("conflict_strategy", options.conflict_strategy);
if (options.conflict_resolutions?.length) form.append("conflict_resolutions_json", JSON.stringify(options.conflict_resolutions));
if (options.onProgress) return uploadFilesWithProgress(settings, form, options.onProgress);
return apiFetch<FileUploadResponse>(settings, "/api/v1/files/upload", { method: "POST", body: form });
}
function uploadFilesWithProgress(
settings: ApiSettings,
form: FormData,
onProgress: (progress: FileUploadProgress) => void
): Promise<FileUploadResponse> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", apiUrl(settings, "/api/v1/files/upload"));
xhr.withCredentials = true;
for (const [key, value] of authHeaders(settings)) xhr.setRequestHeader(key, value);
const csrf = csrfToken();
if (csrf) xhr.setRequestHeader("X-CSRF-Token", csrf);
xhr.upload.onprogress = (event) => {
const total = event.lengthComputable ? event.total : undefined;
onProgress({
loaded: event.loaded,
total,
percentage: total && total > 0 ? Math.round((event.loaded / total) * 100) : null
});
};
xhr.onerror = () => reject(new Error("Upload failed because the network request could not be completed."));
xhr.onabort = () => reject(new Error("Upload was cancelled."));
xhr.onload = () => {
const responseText = xhr.responseText || "";
if (xhr.status < 200 || xhr.status >= 300) {
reject(new ApiError(xhr.status, xhr.statusText, responseText));
return;
}
onProgress({ loaded: 1, total: 1, percentage: 100 });
if (xhr.status === 204 || !responseText) {
resolve({ files: [] });
return;
}
const contentType = xhr.getResponseHeader("content-type") || "";
resolve(contentType.includes("application/json") ? JSON.parse(responseText) as FileUploadResponse : { files: [] });
};
onProgress({ loaded: 0, total: undefined, percentage: 0 });
xhr.send(form);
});
}
export function deleteFile(settings: ApiSettings, fileId: string): Promise<BulkDeleteResponse> {
return apiFetch<BulkDeleteResponse>(settings, `/api/v1/files/${fileId}`, { method: "DELETE" });
}
export function bulkDeleteFiles(settings: ApiSettings, fileIds: string[]): Promise<BulkDeleteResponse> {
return apiFetch<BulkDeleteResponse>(settings, "/api/v1/files/bulk-delete", { method: "POST", body: JSON.stringify({ file_ids: fileIds }) });
}
export type FileBulkShareResponse = { shares: FileShare[]; shared_count: number };
export function shareFilesWithTarget(settings: ApiSettings, fileIds: string[], target: FilesManagedFileLinkTarget): Promise<FileBulkShareResponse> {
return apiFetch<FileBulkShareResponse>(settings, "/api/v1/files/bulk-shares", {
method: "POST",
body: JSON.stringify({ file_ids: fileIds, target_type: target.type, target_id: target.id, permission: target.permission ?? "read" })
});
}
export function shareFilesWithCampaign(settings: ApiSettings, fileIds: string[], campaignId: string): Promise<FileBulkShareResponse> {
return shareFilesWithTarget(settings, fileIds, { type: "campaign", id: campaignId, label: "campaign" });
}
export async function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise<FileShare> {
const response = await shareFilesWithCampaign(settings, [fileId], campaignId);
const share = response.shares[0];
if (!share) throw new Error("File share was not created.");
return share;
}
export function bulkRenameFiles(
settings: ApiSettings,
payload: { file_ids: string[]; folder_paths?: string[]; owner_type?: "user" | "group"; owner_id?: string; mode: "direct" | "prefix" | "suffix" | "replace"; new_name?: string; find?: string; replacement?: string; prefix?: string; suffix?: string; recursive?: boolean; dry_run?: boolean }
): Promise<RenameResponse> {
return apiFetch<RenameResponse>(settings, "/api/v1/files/bulk-rename", {
method: "POST",
body: JSON.stringify({ replacement: "", prefix: "", suffix: "", dry_run: true, ...payload })
});
}
export function resolveFilePatterns(
settings: ApiSettings,
payload: { patterns: string[]; owner_type?: "user" | "group"; owner_id?: string; campaign_id?: string; path_prefix?: string; include_unmatched?: boolean; case_sensitive?: boolean }
): Promise<PatternResolveResponse> {
return apiFetch<PatternResolveResponse>(settings, "/api/v1/files/resolve-patterns", { method: "POST", body: JSON.stringify(payload) });
}
export function transferFiles(
settings: ApiSettings,
payload: {
operation: "move" | "copy";
file_ids: string[];
folder_paths: string[];
source_owner_type: "user" | "group";
source_owner_id: string;
target_owner_type: "user" | "group";
target_owner_id: string;
target_folder: string;
conflict_strategy?: ConflictStrategy;
conflict_resolutions?: ConflictResolution[];
}
): Promise<TransferResponse> {
return apiFetch<TransferResponse>(settings, "/api/v1/files/transfer", { method: "POST", body: JSON.stringify(payload) });
}
export async function downloadFile(settings: ApiSettings, file: ManagedFile): Promise<void> {
const response = await fetch(apiUrl(settings, `/api/v1/files/${file.id}/download`), { headers: authHeaders(settings), credentials: "include" });
if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
const blob = await response.blob();
triggerDownload(blob, file.filename);
}
export async function downloadFilesAsZip(settings: ApiSettings, fileIds: string[], filename = "files.zip"): Promise<void> {
const headers = authHeaders(settings);
headers.set("Content-Type", "application/json");
const csrf = csrfToken();
if (csrf) headers.set("X-CSRF-Token", csrf);
const response = await fetch(apiUrl(settings, "/api/v1/files/archive.zip"), {
method: "POST",
headers,
credentials: "include",
body: JSON.stringify({ file_ids: fileIds, filename })
});
if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
const blob = await response.blob();
triggerDownload(blob, filename);
}
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}