feat(folders): implement copy_folders taking care of ownership
This commit is contained in:
@@ -334,6 +334,16 @@ pub trait FileManagementUseCase: Send + Sync + 'static {
|
||||
"copy_folder_tree not implemented",
|
||||
))
|
||||
}
|
||||
|
||||
/// Copies a folder tree, enforcing that `caller_id` owns both the source folder
|
||||
/// and the target parent folder.
|
||||
async fn copy_folder_tree_owned(
|
||||
&self,
|
||||
source_folder_id: &str,
|
||||
caller_id: Uuid,
|
||||
target_parent_id: Option<String>,
|
||||
dest_name: Option<String>,
|
||||
) -> Result<CopyFolderTreeResult, DomainError>;
|
||||
}
|
||||
|
||||
/// Factory for creating file use case implementations
|
||||
|
||||
@@ -12,6 +12,7 @@ use tracing::info;
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::{FolderDto, MoveFolderDto};
|
||||
use crate::application::ports::file_ports::{FileManagementUseCase, FileRetrievalUseCase};
|
||||
use crate::application::ports::storage_ports::CopyFolderTreeResult;
|
||||
use crate::application::ports::inbound::FolderUseCase;
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::application::services::file_management_service::FileManagementService;
|
||||
@@ -616,6 +617,73 @@ impl BatchOperationService {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Copies multiple folder trees to a target parent in parallel
|
||||
pub async fn copy_folders(
|
||||
&self,
|
||||
folder_ids: Vec<String>,
|
||||
target_folder_id: Option<String>,
|
||||
user_id: Uuid,
|
||||
) -> Result<BatchResult<CopyFolderTreeResult>, BatchOperationError> {
|
||||
info!("Starting batch copy of {} folders", folder_ids.len());
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
let mut result = BatchResult {
|
||||
successful: Vec::new(),
|
||||
failed: Vec::new(),
|
||||
stats: BatchStats {
|
||||
total: folder_ids.len(),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
let target: Option<Arc<str>> = target_folder_id.map(|s| Arc::from(s.as_str()));
|
||||
|
||||
let mut operation_stream = stream::iter(folder_ids.into_iter().map(|folder_id| {
|
||||
let file_management = self.file_management.clone();
|
||||
let target = target.clone();
|
||||
|
||||
async move {
|
||||
let copy_result = file_management
|
||||
.copy_folder_tree_owned(
|
||||
&folder_id,
|
||||
user_id,
|
||||
target.map(|s| s.to_string()),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
(folder_id, copy_result)
|
||||
}
|
||||
}))
|
||||
.buffer_unordered(self.config.concurrency.max_concurrent_files);
|
||||
|
||||
while let Some((folder_id, operation_result)) = operation_stream.next().await {
|
||||
match operation_result {
|
||||
Ok(copy_result) => {
|
||||
result.successful.push(copy_result);
|
||||
result.stats.successful += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
result.failed.push((folder_id, e.to_string()));
|
||||
result.stats.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.stats.execution_time_ms = start_time.elapsed().as_millis();
|
||||
result.stats.max_concurrency = self
|
||||
.config
|
||||
.concurrency
|
||||
.max_concurrent_files
|
||||
.min(result.stats.total);
|
||||
|
||||
info!(
|
||||
"Batch folder copy completed: {}/{} successful in {}ms",
|
||||
result.stats.successful, result.stats.total, result.stats.execution_time_ms
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Downloads multiple files/folders as a single ZIP archive.
|
||||
///
|
||||
/// Writes the archive to a temporary file so RAM usage is O(buffer_size)
|
||||
|
||||
@@ -326,4 +326,31 @@ impl FileManagementUseCase for FileManagementService {
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn copy_folder_tree_owned(
|
||||
&self,
|
||||
source_folder_id: &str,
|
||||
caller_id: Uuid,
|
||||
target_parent_id: Option<String>,
|
||||
dest_name: Option<String>,
|
||||
) -> Result<CopyFolderTreeResult, DomainError> {
|
||||
if let Some(folder_repo) = &self.folder_repo {
|
||||
let owner = folder_repo.get_folder_user_id(source_folder_id).await?;
|
||||
if owner != caller_id {
|
||||
return Err(DomainError::not_found(
|
||||
"Folder",
|
||||
"Source folder not found or access denied",
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(DomainError::internal_error(
|
||||
"FileManagement",
|
||||
"Folder ownership verification unavailable",
|
||||
));
|
||||
}
|
||||
self.verify_target_folder_owner(&target_parent_id, caller_id)
|
||||
.await?;
|
||||
self.copy_folder_tree(source_folder_id, target_parent_id, dest_name)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,6 +671,19 @@ impl FileManagementUseCase for StubFileManagementUseCase {
|
||||
) -> Result<FileDto, DomainError> {
|
||||
Ok(FileDto::default())
|
||||
}
|
||||
|
||||
async fn copy_folder_tree_owned(
|
||||
&self,
|
||||
_source_folder_id: &str,
|
||||
_caller_id: Uuid,
|
||||
_target_parent_id: Option<String>,
|
||||
_dest_name: Option<String>,
|
||||
) -> Result<crate::application::ports::storage_ports::CopyFolderTreeResult, DomainError> {
|
||||
Err(DomainError::internal_error(
|
||||
"StubFileManagement",
|
||||
"copy_folder_tree_owned not implemented",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -9,6 +9,7 @@ use utoipa::ToSchema;
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::application::ports::storage_ports::CopyFolderTreeResult;
|
||||
use crate::application::services::batch_operations::{
|
||||
BatchOperationService, BatchResult, BatchStats,
|
||||
};
|
||||
@@ -851,6 +852,90 @@ pub async fn move_folders_batch(
|
||||
Ok((status_code, Json(response)).into_response())
|
||||
}
|
||||
|
||||
/// DTO returned for each successfully copied folder tree
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct CopiedFolderDto {
|
||||
/// UUID of the newly created root folder
|
||||
pub new_root_folder_id: String,
|
||||
/// Total folders created (including root)
|
||||
pub folders_copied: i64,
|
||||
/// Total files copied (zero-copy via dedup)
|
||||
pub files_copied: i64,
|
||||
}
|
||||
|
||||
impl From<CopyFolderTreeResult> for CopiedFolderDto {
|
||||
fn from(r: CopyFolderTreeResult) -> Self {
|
||||
Self {
|
||||
new_root_folder_id: r.new_root_folder_id,
|
||||
folders_copied: r.folders_copied,
|
||||
files_copied: r.files_copied,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for copying multiple folder trees in batch
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/batch/folders/copy",
|
||||
responses(
|
||||
(status = 200, description = "All folders copied"),
|
||||
(status = 206, description = "Partial success"),
|
||||
(status = 400, description = "Bad request"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
tag = "batch"
|
||||
)]
|
||||
pub async fn copy_folders_batch(
|
||||
State(state): State<BatchHandlerState>,
|
||||
auth_user: AuthUser,
|
||||
Json(request): Json<BatchFolderOperationRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
if request.folder_ids.is_empty() {
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "No folder IDs provided"
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
if request.folder_ids.len() > MAX_BATCH_SIZE {
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": format!("Batch size {} exceeds maximum of {}", request.folder_ids.len(), MAX_BATCH_SIZE)
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let result = state
|
||||
.batch_service
|
||||
.copy_folders(request.folder_ids, request.target_folder_id, auth_user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Batch copy_folders failed: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Batch operation failed".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let response: BatchOperationResponse<CopiedFolderDto> = result.into();
|
||||
|
||||
let status_code = if response.stats.failed > 0 {
|
||||
if response.stats.successful > 0 {
|
||||
StatusCode::PARTIAL_CONTENT
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
} else {
|
||||
StatusCode::OK
|
||||
};
|
||||
|
||||
Ok((status_code, Json(response)).into_response())
|
||||
}
|
||||
|
||||
// Hander as a workarround for drag & drop (does not support POST requests)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
|
||||
@@ -237,6 +237,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
.route("/folders/delete", post(batch_handler::delete_folders_batch))
|
||||
.route("/folders/create", post(batch_handler::create_folders_batch))
|
||||
.route("/folders/get", post(batch_handler::get_folders_batch))
|
||||
.route("/folders/copy", post(batch_handler::copy_folders_batch))
|
||||
.route("/folders/move", post(batch_handler::move_folders_batch))
|
||||
// Trash operations (soft delete)
|
||||
.route("/trash", post(batch_handler::trash_batch))
|
||||
|
||||
@@ -928,23 +928,31 @@ const fileOps = {
|
||||
|
||||
/**
|
||||
* Copy a folder to another folder
|
||||
* Note: Backend folder copy is not yet implemented, this shows a notification
|
||||
* @param {string} _folderId - Folder ID
|
||||
* @param {string} _targetFolderId - Target folder ID
|
||||
* @param {string} folderId - Folder ID
|
||||
* @param {string} targetFolderId - Target folder ID
|
||||
* @returns {Promise<boolean>} - Success status
|
||||
*/
|
||||
async copyFolder(_folderId, _targetFolderId) {
|
||||
// Folder copy is not yet implemented in the backend
|
||||
ui.showNotification('Not implemented', 'Folder copy is not yet supported');
|
||||
return false;
|
||||
async copyFolder(folderId, targetFolderId) {
|
||||
const res = await fetch('/api/batch/folders/copy', {
|
||||
method: 'POST',
|
||||
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder_ids: [folderId], target_folder_id: targetFolderId })
|
||||
});
|
||||
return res.ok;
|
||||
},
|
||||
|
||||
/**
|
||||
* @typedef {Object} BatchCopyReturn
|
||||
* @property {number} success
|
||||
* @property {number} errors
|
||||
*/
|
||||
|
||||
/**
|
||||
* Copy files & folders
|
||||
* @param {string[]} fileIds - File IDs
|
||||
* @param {string[]} folderIds - Folder IDs
|
||||
* @param {string} targetFolderId - Target folder ID
|
||||
* @returns {Promise<boolean>} - Success status
|
||||
* @returns {Promise<BatchCopyReturn>} - Success status
|
||||
*/
|
||||
async batchCopy(fileIds, folderIds, targetFolderId) {
|
||||
// FIXME ensure not moving a folder into itself
|
||||
@@ -964,13 +972,22 @@ const fileOps = {
|
||||
});
|
||||
const data = await res.json();
|
||||
success += data.stats?.successful || 0;
|
||||
errors += data.stats?.failed || 0;
|
||||
errors += data.stats?.failed || (!res.ok && !data.stats ? fileIds.length : 0);
|
||||
}
|
||||
|
||||
// Note: Folder copy is not yet implemented in batch API
|
||||
// Batch copy folders
|
||||
if (folderIds.length > 0) {
|
||||
ui.showNotification('Info', 'Folder copy is not yet supported in batch mode');
|
||||
errors += folderIds.lenngth;
|
||||
const res = await fetch('/api/batch/folders/copy', {
|
||||
method: 'POST',
|
||||
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
folder_ids: folderIds,
|
||||
target_folder_id: targetFolderId
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
success += data.stats?.successful || 0;
|
||||
errors += data.stats?.failed || (!res.ok && !data.stats ? folderIds.length : 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Batch copy error:', err);
|
||||
|
||||
Reference in New Issue
Block a user