diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index 8a61338..69086d5 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -224,7 +224,7 @@ pub async fn upload_document( while let Some(field) = multipart.next_field().await.map_err(|err| { let msg = format!("invalid multipart data: {err}"); - warn!(error = %err, "invalid multipart data"); + error!(error = %err, "invalid multipart data"); AppError::bad_request(msg) })? { let name = field.name().map(|n| n.to_string()); @@ -235,7 +235,7 @@ pub async fn upload_document( content_type = field.content_type().map(|mime| mime.to_string()); let data = field.bytes().await.map_err(|err| { let msg = format!("failed to read file bytes: {err}"); - warn!(error = %err, "failed to read file bytes"); + error!(error = %err, "failed to read file bytes"); AppError::bad_request(msg) })?; file_bytes = Some(data.to_vec()); @@ -243,7 +243,7 @@ pub async fn upload_document( Some("folder_id") => { let value = field.text().await.map_err(|err| { let msg = format!("invalid folder id: {err}"); - warn!(error = %err, "invalid folder id"); + error!(error = %err, "invalid folder id"); AppError::bad_request(msg) })?; if !value.trim().is_empty() { @@ -255,12 +255,12 @@ pub async fn upload_document( Some("metadata") => { let value = field.text().await.map_err(|err| { let msg = format!("invalid metadata: {err}"); - warn!(error = %err, "invalid metadata payload"); + error!(error = %err, "invalid metadata payload"); AppError::bad_request(msg) })?; metadata = serde_json::from_str(&value).map_err(|err| { let msg = format!("metadata must be valid JSON: {err}"); - warn!(error = %err, "metadata parse failure"); + error!(error = %err, "metadata parse failure"); AppError::bad_request(msg) })?; } @@ -269,10 +269,19 @@ pub async fn upload_document( } let file_bytes = file_bytes.ok_or_else(|| { - warn!("upload rejected: missing file field"); + error!("upload rejected: missing file field"); AppError::bad_request("file field is required") })?; - let original_name = original_name.unwrap_or_else(|| "upload.bin".to_string()); + + if file_bytes.is_empty() { + error!("upload rejected: empty file payload"); + return Err(AppError::bad_request("file field must not be empty")); + } + let original_name = original_name.ok_or_else(|| { + error!("upload rejected: missing original filename"); + AppError::bad_request("filename is required") + })?; + let original_name_for_log = original_name.clone(); let request = UploadRequest { bytes: file_bytes, @@ -283,18 +292,21 @@ pub async fn upload_document( }; let outcome = match process_upload(&state, request).await { - Ok(outcome) => outcome, + Ok(outcome) => { + info!( + document_id = %outcome.detail.document.id, + original_name = %outcome.detail.document.original_name, + created = outcome.created, + reused_existing = !outcome.created, + "document upload succeeded" + ); + outcome + } Err(err) => { - error!(error = ?err, "document upload failed"); + error!(error = ?err, original_name = %original_name_for_log, "document upload failed"); return Err(err); } }; - info!( - document_id = %outcome.detail.document.id, - original_name = %outcome.detail.document.original_name, - created = outcome.created, - "document upload succeeded" - ); let status = if outcome.created { StatusCode::CREATED } else { @@ -573,6 +585,12 @@ async fn process_upload(state: &AppState, request: UploadRequest) -> AppResult { if let Ok(mut conn) = self.state.db() { mark_job_succeeded(&mut conn, job.id)?; + info!(job_id = %job.id, job_type = %job.job_type, "job completed successfully"); } else { error!("failed to mark job succeeded due to pool error"); } diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 840d0fa..05bd2c9 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -13,9 +13,6 @@ const API_ROOT = (process.env.API_BASE_URL || '').replace(/\/$/, ''); const api = axios.create({ baseURL: API_ROOT ? `${API_ROOT}/api` : '/api', - headers: { - 'Content-Type': 'application/json', - }, }); const DEFAULT_FOLDER_NAME = 'All Documents'; @@ -858,20 +855,27 @@ function App() { const uploadFile = useCallback( async (file, targetFolderId) => { + if (!file || file.size === 0) { + setStatusMessage('Skipped empty file.', 'error'); + return null; + } + const formData = new FormData(); - formData.append('file', file); + formData.append('file', file, file.name); if (targetFolderId && targetFolderId !== 'root') { formData.append('folder_id', targetFolderId); } + try { - const response = await api.post('/documents', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); - if (response.status === 200) { - setStatusMessage(`${file.name} already exists; reused existing document.`, 'info'); - } else { - setStatusMessage(`Uploaded ${file.name}`, 'success'); - } + const { data, status } = await api.post('/documents', formData); + const duplicate = data?.reused || status === 200; + setStatusMessage( + duplicate + ? `${file.name} already exists; reused existing document.` + : `Uploaded ${file.name}`, + duplicate ? 'info' : 'success', + ); + return data; } catch (error) { console.error(error); const message = error.response?.data?.error || `Failed to upload ${file.name}.`; @@ -911,98 +915,61 @@ function App() { ); const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => { + if ( + !dataTransfer || + !dataTransfer.items || + !window.isSecureContext || + !Array.from(dataTransfer.items).some((item) => typeof item.getAsFileSystemHandle === 'function') + ) { + throw new Error('File System Access API is required for uploads.'); + } + const results = []; - if (!dataTransfer) { - return results; - } + const pushFile = (file, ancestors) => { + if (file) { + results.push({ + file, + segments: ancestors.filter(Boolean), + }); + } + }; const walkDirectoryHandle = async (handle, ancestors) => { - const currentPath = handle.name ? [...ancestors, handle.name] : [...ancestors]; - for await (const childHandle of handle.values()) { - if (childHandle.kind === 'file') { - const file = await childHandle.getFile(); - results.push({ file, segments: currentPath }); - } else if (childHandle.kind === 'directory') { - await walkDirectoryHandle(childHandle, currentPath); + const nextAncestors = handle.name ? [...ancestors, handle.name] : [...ancestors]; + for await (const child of handle.values()) { + if (child.kind === 'file') { + const file = await child.getFile(); + pushFile(file, nextAncestors); + } else if (child.kind === 'directory') { + // eslint-disable-next-line no-await-in-loop + await walkDirectoryHandle(child, nextAncestors); } } }; - const walkEntry = async (entry, ancestors) => { - if (entry.isFile) { - const file = await new Promise((resolve, reject) => entry.file(resolve, reject)); - results.push({ file, segments: ancestors }); - return; + for (const item of Array.from(dataTransfer.items)) { + if (item.kind !== 'file') continue; + const getHandle = item.getAsFileSystemHandle?.bind(item); + if (!getHandle) { + throw new Error('File System Access API handle missing.'); } - if (entry.isDirectory) { - const childAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors]; - const reader = entry.createReader(); - const readEntries = () => - new Promise((resolve, reject) => { - reader.readEntries(resolve, reject); - }); + // eslint-disable-next-line no-await-in-loop + const handle = await getHandle(); + if (!handle) continue; - let entries; - do { - // eslint-disable-next-line no-await-in-loop - entries = await readEntries(); - for (const child of entries) { - // eslint-disable-next-line no-await-in-loop - await walkEntry(child, childAncestors); - } - } while (entries.length); - } - }; - - const items = dataTransfer.items ? Array.from(dataTransfer.items).filter((item) => item.kind === 'file') : []; - - if (items.length) { - for (const item of items) { + if (handle.kind === 'file') { + const file = await handle.getFile(); + pushFile(file, []); + } else if (handle.kind === 'directory') { // eslint-disable-next-line no-await-in-loop - if (item.getAsFileSystemHandle) { - // eslint-disable-next-line no-await-in-loop - const handle = await item.getAsFileSystemHandle(); - if (!handle) continue; - if (handle.kind === 'file') { - // eslint-disable-next-line no-await-in-loop - const file = await handle.getFile(); - results.push({ file, segments: [] }); - } else if (handle.kind === 'directory') { - // eslint-disable-next-line no-await-in-loop - await walkDirectoryHandle(handle, []); - } - continue; - } - - const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null; - if (entry) { - // eslint-disable-next-line no-await-in-loop - await walkEntry(entry, []); - continue; - } - - const file = item.getAsFile ? item.getAsFile() : null; - if (file) { - const segments = file.webkitRelativePath - ? file.webkitRelativePath.split('/').slice(0, -1).filter(Boolean) - : []; - results.push({ file, segments }); - continue; - } + await walkDirectoryHandle(handle, []); } } - if (!results.length && dataTransfer.files) { - results.push( - ...Array.from(dataTransfer.files).map((file) => ({ - file, - segments: file.webkitRelativePath - ? file.webkitRelativePath.split('/').slice(0, -1).filter(Boolean) - : [], - })), - ); + if (!results.length) { + throw new Error('No files detected in drop payload.'); } return results; @@ -1014,33 +981,41 @@ function App() { setStatusMessage('Please log in before uploading.', 'error'); return; } + setLoading(true); + try { folderPathCacheRef.current.clear(); + const extracted = await extractFilesFromDataTransfer(dataTransfer); - const baseFolderId = targetFolderId && targetFolderId !== 'root' ? targetFolderId : null; - - if (!extracted.length && dataTransfer?.files) { - for (const file of Array.from(dataTransfer.files)) { - // eslint-disable-next-line no-await-in-loop - await uploadFile(file, targetFolderId ?? 'root'); - } - } else { - for (const { file, segments } of extracted) { - // eslint-disable-next-line no-await-in-loop - const destinationId = segments.length - ? await ensureFolderPathOnServer(baseFolderId, segments) - : baseFolderId; - - const uploadTarget = destinationId - ?? (targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root'); - - // eslint-disable-next-line no-await-in-loop - await uploadFile(file, uploadTarget); - } + if (!extracted.length) { + setStatusMessage('No files to upload.', 'info'); + return; } + const baseFolderId = + targetFolderId && targetFolderId !== 'root' ? targetFolderId : null; + + for (const { file, segments } of extracted) { + // eslint-disable-next-line no-await-in-loop + const destinationId = segments.length + ? await ensureFolderPathOnServer(baseFolderId, segments) + : baseFolderId; + + const uploadTarget = + destinationId ?? + (targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root'); + + // eslint-disable-next-line no-await-in-loop + await uploadFile(file, uploadTarget); + } + await refreshCurrentFolder(); - if (targetFolderId && targetFolderId !== 'root' && targetFolderId !== selectedFolder) { + + if ( + targetFolderId && + targetFolderId !== 'root' && + targetFolderId !== selectedFolder + ) { await ensureFolderData(targetFolderId, { force: true }); } } catch (error) {