586 lines
No EOL
20 KiB
TypeScript
586 lines
No EOL
20 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useApiRequest } from './useApi';
|
|
|
|
// File interfaces - exactly matching backend FileItem model
|
|
export interface FileInfo {
|
|
id: string;
|
|
mandateId: string; // Required in backend
|
|
fileName: string; // Required in backend
|
|
mimeType: string;
|
|
fileHash: string;
|
|
fileSize: number;
|
|
creationDate: number; // Backend uses float for UTC timestamp in seconds
|
|
}
|
|
|
|
export interface UserFile {
|
|
id: string;
|
|
file_name: string;
|
|
mime_type?: string;
|
|
action: string;
|
|
created_at: number |string;
|
|
size?: number;
|
|
source?: string; // 'user_uploaded', 'agent_created', or 'shared_with_me'
|
|
}
|
|
|
|
// Files list hook
|
|
export function useUserFiles() {
|
|
const [files, setFiles] = useState<UserFile[]>([]);
|
|
const { request, isLoading: loading, error } = useApiRequest<null, FileInfo[]>();
|
|
|
|
const fetchFiles = async () => {
|
|
try {
|
|
console.log('🔍 Fetching files from API...');
|
|
const data = await request({
|
|
url: '/api/files/list',
|
|
method: 'get'
|
|
});
|
|
|
|
console.log('📥 Raw API response:', data);
|
|
|
|
// Ensure data is an array, handle null/undefined responses
|
|
const fileList = Array.isArray(data) ? data : [];
|
|
console.log(`📋 Processing ${fileList.length} files from API`);
|
|
|
|
// Filter out invalid files and map API response to our frontend model
|
|
const validFiles = fileList.filter((apiFile: any): boolean => {
|
|
// Skip files with invalid data that would cause backend validation errors
|
|
// Backend FileItem requires: id, mandateId, fileName, mimeType, fileHash, fileSize, creationDate
|
|
const isValid = !!(
|
|
apiFile &&
|
|
typeof apiFile === 'object' &&
|
|
apiFile.id &&
|
|
typeof apiFile.id === 'string' &&
|
|
apiFile.mandateId &&
|
|
typeof apiFile.mandateId === 'string' &&
|
|
apiFile.fileName &&
|
|
typeof apiFile.fileName === 'string' &&
|
|
apiFile.fileName.trim() !== '' &&
|
|
apiFile.mimeType &&
|
|
typeof apiFile.mimeType === 'string' &&
|
|
apiFile.fileHash &&
|
|
typeof apiFile.fileHash === 'string' &&
|
|
typeof apiFile.fileSize === 'number' &&
|
|
apiFile.fileSize >= 0 &&
|
|
typeof apiFile.creationDate === 'number' &&
|
|
apiFile.creationDate > 0
|
|
);
|
|
|
|
if (!isValid) {
|
|
console.warn('❌ Filtering out invalid file record:', {
|
|
id: apiFile?.id,
|
|
mandateId: apiFile?.mandateId,
|
|
fileName: apiFile?.fileName,
|
|
mimeType: apiFile?.mimeType,
|
|
fileHash: apiFile?.fileHash,
|
|
fileSize: apiFile?.fileSize,
|
|
creationDate: apiFile?.creationDate,
|
|
creationDateType: typeof apiFile?.creationDate,
|
|
fullObject: apiFile
|
|
});
|
|
} else {
|
|
console.log('✅ Valid file:', {
|
|
id: apiFile.id,
|
|
fileName: apiFile.fileName,
|
|
creationDate: apiFile.creationDate
|
|
});
|
|
}
|
|
|
|
return isValid;
|
|
});
|
|
|
|
console.log(`✨ Filtered to ${validFiles.length} valid files`);
|
|
if (validFiles.length !== fileList.length) {
|
|
console.warn(`⚠️ Filtered out ${fileList.length - validFiles.length} invalid files`);
|
|
}
|
|
|
|
const mappedFiles = validFiles.map((apiFile: any): UserFile => {
|
|
// Derive a simplified action from the MIME type
|
|
let action = 'Document';
|
|
if (apiFile.mimeType) {
|
|
const mimePrefix = apiFile.mimeType.split('/')[0];
|
|
switch (mimePrefix) {
|
|
case 'image':
|
|
action = 'Bild';
|
|
break;
|
|
case 'application':
|
|
if (apiFile.mimeType.includes('pdf')) {
|
|
action = 'PDF';
|
|
} else if (apiFile.mimeType.includes('word') || apiFile.mimeType.includes('office')) {
|
|
action = 'Dokument';
|
|
} else if (apiFile.mimeType.includes('excel') || apiFile.mimeType.includes('spreadsheet')) {
|
|
action = 'Tabelle';
|
|
} else {
|
|
action = 'Datei';
|
|
}
|
|
break;
|
|
case 'text':
|
|
action = 'Text';
|
|
break;
|
|
case 'video':
|
|
action = 'Video';
|
|
break;
|
|
case 'audio':
|
|
action = 'Audio';
|
|
break;
|
|
default:
|
|
action = 'Datei';
|
|
}
|
|
}
|
|
|
|
// Convert creationDate from backend float (seconds since epoch) to ISO string
|
|
// Backend always sends creationDate as float seconds since epoch (UTC)
|
|
const createdAt = new Date(apiFile.creationDate * 1000).toISOString();
|
|
|
|
return {
|
|
id: apiFile.id,
|
|
file_name: apiFile.fileName, // Required field from backend
|
|
mime_type: apiFile.mimeType, // Required field from backend
|
|
action: action,
|
|
created_at: createdAt,
|
|
size: apiFile.fileSize, // Required field from backend
|
|
source: 'user_uploaded' // Default source since workflowId is not part of FileItem model
|
|
};
|
|
});
|
|
|
|
console.log(`🎉 Successfully processed ${mappedFiles.length} files for display`);
|
|
setFiles(mappedFiles);
|
|
} catch (error) {
|
|
// Error is already handled by useApiRequest
|
|
console.error('❌ Error fetching files:', error);
|
|
// Set empty array on error to prevent UI issues
|
|
setFiles([]);
|
|
}
|
|
};
|
|
|
|
// Optimistically remove a file from the local state
|
|
const removeFileOptimistically = (fileId: string) => {
|
|
setFiles(prevFiles => prevFiles.filter(file => file.id !== fileId));
|
|
};
|
|
|
|
// Add a file to the local state (for when upload completes)
|
|
const addFileOptimistically = (newFile: UserFile) => {
|
|
setFiles(prevFiles => [newFile, ...prevFiles]);
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchFiles();
|
|
}, []);
|
|
|
|
return {
|
|
files,
|
|
loading,
|
|
error,
|
|
refetch: fetchFiles,
|
|
removeFileOptimistically,
|
|
addFileOptimistically
|
|
};
|
|
}
|
|
|
|
// File operations hook
|
|
export function useFileOperations() {
|
|
const [downloadingFiles, setDownloadingFiles] = useState<Set<string>>(new Set());
|
|
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
|
|
const [uploadingFile, setUploadingFile] = useState(false);
|
|
const { request, isLoading } = useApiRequest();
|
|
const [downloadError, setDownloadError] = useState<string | null>(null);
|
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
const [previewingFiles, setPreviewingFiles] = useState<Set<string>>(new Set());
|
|
const [previewError, setPreviewError] = useState<string | null>(null);
|
|
|
|
const handleFileDownload = async (fileId: string, fileName: string) => {
|
|
setDownloadError(null);
|
|
setDownloadingFiles(prev => new Set(prev).add(fileId));
|
|
|
|
try {
|
|
console.log(`📥 Starting download for file: ${fileName} (ID: ${fileId})`);
|
|
|
|
// Try to get the file download
|
|
const blob = await request({
|
|
url: `/api/files/${fileId}/download`,
|
|
method: 'get',
|
|
// Override axios config for blob response
|
|
additionalConfig: {
|
|
responseType: 'blob',
|
|
// Better error handling for blob responses
|
|
validateStatus: function (status: number) {
|
|
return status >= 200 && status < 300; // default
|
|
}
|
|
}
|
|
});
|
|
console.log(`✅ Download successful for: ${fileName}`, { size: blob.size, type: blob.type });
|
|
|
|
// Create a download link and trigger the download
|
|
const url = window.URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.setAttribute('download', fileName);
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
return true;
|
|
} catch (error: any) {
|
|
console.error(`❌ Download failed for ${fileName}:`, error);
|
|
let errorMessage = error.message;
|
|
|
|
if (error.response?.status === 404) {
|
|
errorMessage = `File "${fileName}" not found or has been deleted.`;
|
|
} else if (error.response?.status === 403) {
|
|
errorMessage = `No permission to download "${fileName}".`;
|
|
}
|
|
|
|
setDownloadError(errorMessage);
|
|
return false;
|
|
} finally {
|
|
setDownloadingFiles(prev => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(fileId);
|
|
return newSet;
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleFileDelete = async (fileId: string, onOptimisticDelete?: () => void) => {
|
|
setDeleteError(null);
|
|
setDeletingFiles(prev => new Set(prev).add(fileId));
|
|
|
|
// Optimistically remove from UI if callback provided
|
|
if (onOptimisticDelete) {
|
|
onOptimisticDelete();
|
|
}
|
|
|
|
try {
|
|
console.log(`🗑️ Starting delete for file ID: ${fileId}`);
|
|
|
|
await request({
|
|
url: `/api/files/${fileId}`,
|
|
method: 'delete'
|
|
});
|
|
|
|
console.log(`✅ Delete successful for file ID: ${fileId}`);
|
|
|
|
// Add a small delay to ensure backend has time to process
|
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|
return true;
|
|
} catch (error: any) {
|
|
console.error(`❌ Delete failed for file ID ${fileId}:`, error);
|
|
let errorMessage = error.message;
|
|
|
|
if (error.response?.status === 404) {
|
|
errorMessage = `File not found or has already been deleted.`;
|
|
// If file doesn't exist, consider it successfully "deleted"
|
|
return true;
|
|
} else if (error.response?.status === 403) {
|
|
errorMessage = `No permission to delete this file.`;
|
|
}
|
|
|
|
setDeleteError(errorMessage);
|
|
// If deletion failed and we optimistically removed it, we should refetch to restore the file
|
|
return false;
|
|
} finally {
|
|
setDeletingFiles(prev => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(fileId);
|
|
return newSet;
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* File upload function - backend bug has been fixed!
|
|
*
|
|
* BACKEND FIXES APPLIED:
|
|
* - Fixed file.fileName → file.filename in routeDataFiles.py
|
|
* - Removed workflowId from FileItem creation in interfaceComponentObjects.py
|
|
* - Upload should now work correctly
|
|
*/
|
|
const handleFileUpload = async (file: globalThis.File, workflowId?: string) => {
|
|
setUploadError(null);
|
|
setUploadingFile(true);
|
|
|
|
try {
|
|
console.log('📤 Starting file upload...', {
|
|
fileName: file.name,
|
|
fileSize: file.size,
|
|
fileType: file.type,
|
|
workflowId: workflowId
|
|
});
|
|
|
|
// Validate file before upload
|
|
if (!file || !file.name || file.name.trim() === '') {
|
|
throw new Error('Invalid file: File must have a valid name');
|
|
}
|
|
|
|
if (file.size === 0) {
|
|
throw new Error('Invalid file: File cannot be empty');
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
if (workflowId) {
|
|
formData.append('workflowId', workflowId);
|
|
}
|
|
|
|
// FormData is now correctly configured for backend
|
|
|
|
console.log('📋 FormData prepared:', {
|
|
hasFile: formData.has('file'),
|
|
hasWorkflowId: formData.has('workflowId'),
|
|
workflowId: workflowId
|
|
});
|
|
|
|
// Log the actual file object being sent
|
|
console.log('📁 File object details:', {
|
|
name: file.name,
|
|
size: file.size,
|
|
type: file.type,
|
|
lastModified: file.lastModified,
|
|
constructor: file.constructor.name
|
|
});
|
|
|
|
console.log('🚀 Sending upload request...');
|
|
|
|
const fileData = await request({
|
|
url: '/api/files/upload',
|
|
method: 'post',
|
|
data: formData,
|
|
// Override axios config for form data
|
|
additionalConfig: {
|
|
headers: {
|
|
'Content-Type': 'multipart/form-data',
|
|
}
|
|
}
|
|
});
|
|
|
|
console.log('✅ Upload successful:', fileData);
|
|
return { success: true, fileData };
|
|
} catch (error: any) {
|
|
console.error('❌ Upload failed:', {
|
|
error: error,
|
|
message: error.message,
|
|
response: error.response,
|
|
status: error.response?.status,
|
|
statusText: error.response?.statusText,
|
|
data: error.response?.data
|
|
});
|
|
|
|
// Provide detailed error analysis for backend team
|
|
let errorMessage = error.message;
|
|
|
|
if (error.response?.status === 500) {
|
|
if (errorMessage.includes('validation')) {
|
|
errorMessage = 'File validation failed. Please ensure the file is valid and try again.';
|
|
} else {
|
|
errorMessage = 'Server error during upload. Please try again later.';
|
|
}
|
|
}
|
|
|
|
console.error('❌ Upload error details:', error);
|
|
|
|
setUploadError(errorMessage);
|
|
return { success: false, error: errorMessage };
|
|
} finally {
|
|
setUploadingFile(false);
|
|
}
|
|
};
|
|
|
|
const handleFileUpdate = async (fileId: string, updateData: Partial<{ fileName: string }>) => {
|
|
setUploadError(null); // Reuse upload error state for update operations
|
|
|
|
try {
|
|
console.log(`✏️ Starting update for file ID: ${fileId}`, updateData);
|
|
|
|
const updatedFile = await request({
|
|
url: `/api/files/${fileId}`,
|
|
method: 'put',
|
|
data: updateData
|
|
});
|
|
|
|
console.log(`✅ Update successful for file ID: ${fileId}`);
|
|
return { success: true, fileData: updatedFile };
|
|
} catch (error: any) {
|
|
console.error(`❌ Update failed for file ID ${fileId}:`, error);
|
|
let errorMessage = error.message;
|
|
|
|
if (error.response?.status === 404) {
|
|
errorMessage = `File not found or has been deleted.`;
|
|
} else if (error.response?.status === 403) {
|
|
errorMessage = `No permission to update this file.`;
|
|
} else if (error.response?.status === 400) {
|
|
errorMessage = `Invalid file update data.`;
|
|
}
|
|
|
|
setUploadError(errorMessage);
|
|
return { success: false, error: errorMessage };
|
|
}
|
|
};
|
|
|
|
const handleFilePreview = async (fileId: string, fileName: string) => {
|
|
setPreviewError(null);
|
|
setPreviewingFiles(prev => new Set(prev).add(fileId));
|
|
|
|
try {
|
|
console.log(`👁️ Starting preview for file: ${fileName} (ID: ${fileId})`);
|
|
|
|
// First try to get JSON response (for text-based files)
|
|
try {
|
|
const jsonResponse = await request({
|
|
url: `/api/files/${fileId}/preview`,
|
|
method: 'get',
|
|
additionalConfig: {
|
|
responseType: 'json',
|
|
validateStatus: function (status: number) {
|
|
return status >= 200 && status < 300;
|
|
}
|
|
}
|
|
});
|
|
|
|
console.log(`✅ JSON preview successful for: ${fileName}`, jsonResponse);
|
|
|
|
// Check if response has content field (structured response)
|
|
if (jsonResponse && typeof jsonResponse === 'object' && 'content' in jsonResponse) {
|
|
const content = jsonResponse.content;
|
|
const mimeType = jsonResponse.mimeType || 'text/plain';
|
|
|
|
console.log('📄 Structured JSON response detected:', {
|
|
hasContent: !!content,
|
|
mimeType: mimeType,
|
|
contentLength: content?.length,
|
|
contentPreview: content?.substring(0, 100) + '...'
|
|
});
|
|
|
|
// Check if content is base64 encoded (common pattern)
|
|
let decodedContent = content;
|
|
try {
|
|
// Try to decode as base64 if it looks like base64
|
|
if (content && typeof content === 'string' && /^[A-Za-z0-9+/=]+$/.test(content)) {
|
|
console.log('📄 Content appears to be base64 encoded, attempting decode...');
|
|
decodedContent = atob(content);
|
|
console.log('📄 Base64 decode successful:', {
|
|
originalLength: content.length,
|
|
decodedLength: decodedContent.length,
|
|
decodedPreview: decodedContent.substring(0, 200) + '...'
|
|
});
|
|
}
|
|
} catch (decodeError) {
|
|
console.log('📄 Base64 decode failed, using original content:', decodeError);
|
|
decodedContent = content;
|
|
}
|
|
|
|
// Create a blob from the (possibly decoded) content
|
|
const blob = new Blob([decodedContent], { type: mimeType });
|
|
const url = window.URL.createObjectURL(blob);
|
|
|
|
console.log('🔗 Created blob URL:', url);
|
|
|
|
return { success: true, previewUrl: url, blob: blob, isJsonContent: true, decodedContent: decodedContent };
|
|
} else if (jsonResponse && typeof jsonResponse === 'object' && 'result' in jsonResponse) {
|
|
// Handle base64 encoded content in 'result' field
|
|
console.log('📄 Base64 encoded content detected in result field');
|
|
|
|
try {
|
|
// Decode base64 content
|
|
const decodedContent = atob(jsonResponse.result);
|
|
const mimeType = jsonResponse.mimeType || 'application/json';
|
|
|
|
console.log('📄 Decoded content:', {
|
|
length: decodedContent.length,
|
|
preview: decodedContent.substring(0, 200) + '...',
|
|
mimeType: mimeType,
|
|
originalResult: jsonResponse.result.substring(0, 100) + '...',
|
|
decodedFirstChars: decodedContent.substring(0, 50)
|
|
});
|
|
|
|
// Create a blob from the decoded content
|
|
const blob = new Blob([decodedContent], { type: mimeType });
|
|
const url = window.URL.createObjectURL(blob);
|
|
|
|
console.log('🔗 Created blob URL for decoded content:', url);
|
|
console.log('🔍 Blob details:', {
|
|
size: blob.size,
|
|
type: blob.type,
|
|
url: url
|
|
});
|
|
|
|
return { success: true, previewUrl: url, blob: blob, isJsonContent: true, decodedContent: decodedContent };
|
|
} catch (decodeError) {
|
|
console.error('❌ Failed to decode base64 content:', decodeError);
|
|
// Fallback to treating as raw JSON
|
|
const blob = new Blob([JSON.stringify(jsonResponse, null, 2)], { type: 'application/json' });
|
|
const url = window.URL.createObjectURL(blob);
|
|
|
|
return { success: true, previewUrl: url, blob: blob, isJsonContent: true };
|
|
}
|
|
} else {
|
|
console.log('📄 Raw JSON response, treating as content');
|
|
// If it's not structured JSON, treat as raw content
|
|
const blob = new Blob([JSON.stringify(jsonResponse, null, 2)], { type: 'application/json' });
|
|
const url = window.URL.createObjectURL(blob);
|
|
|
|
return { success: true, previewUrl: url, blob: blob, isJsonContent: true };
|
|
}
|
|
} catch (jsonError) {
|
|
console.log('JSON preview failed, trying blob response...', jsonError);
|
|
|
|
// Fallback to blob response for binary files
|
|
const previewData = await request({
|
|
url: `/api/files/${fileId}/preview`,
|
|
method: 'get',
|
|
additionalConfig: {
|
|
responseType: 'blob',
|
|
validateStatus: function (status: number) {
|
|
return status >= 200 && status < 300;
|
|
}
|
|
}
|
|
});
|
|
|
|
console.log(`✅ Blob preview successful for: ${fileName}`, { size: previewData.size, type: previewData.type });
|
|
|
|
// Create a blob URL for preview
|
|
const url = window.URL.createObjectURL(previewData);
|
|
|
|
return { success: true, previewUrl: url, blob: previewData, isJsonContent: false };
|
|
}
|
|
} catch (error: any) {
|
|
console.error(`❌ Preview failed for ${fileName}:`, error);
|
|
let errorMessage = error.message;
|
|
|
|
if (error.response?.status === 404) {
|
|
errorMessage = `File "${fileName}" not found or has been deleted.`;
|
|
} else if (error.response?.status === 403) {
|
|
errorMessage = `No permission to preview "${fileName}".`;
|
|
} else if (error.response?.status === 415) {
|
|
errorMessage = `File type "${fileName}" is not supported for preview.`;
|
|
}
|
|
|
|
setPreviewError(errorMessage);
|
|
return { success: false, error: errorMessage };
|
|
} finally {
|
|
setPreviewingFiles(prev => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(fileId);
|
|
return newSet;
|
|
});
|
|
}
|
|
};
|
|
|
|
return {
|
|
downloadingFiles,
|
|
deletingFiles,
|
|
uploadingFile,
|
|
downloadError,
|
|
deleteError,
|
|
uploadError,
|
|
previewingFiles,
|
|
previewError,
|
|
handleFileDownload,
|
|
handleFileDelete,
|
|
handleFileUpload,
|
|
handleFileUpdate,
|
|
handleFilePreview,
|
|
isLoading
|
|
};
|
|
}
|