fix(webdav): native COPY honours destination filename (M8)

Threads `new_name: Option<&str>` through FileWritePort::copy_file and
FileManagementUseCase::copy_file_with_perms so a same-folder
COPY /a.txt → /b.txt picks up the destination name via a single
COALESCE($3::text, name) in the CTE. Without it the new row inherits
the source's filename and collides on the (folder_id, name, user_id)
unique index — the "Already Exists" 500 M8 was hitting.

handle_copy in the native WebDAV surface now passes
`(file.name != dest_name).then(|| dest_name.into())`, keeping the
"same name in a different folder" case at None so existing semantics
are preserved.
This commit is contained in:
Edouard Vanbelle
2026-06-17 01:43:13 +02:00
parent bacd5806d3
commit f9de7ac596
9 changed files with 83 additions and 26 deletions
+6
View File
@@ -255,11 +255,17 @@ pub trait FileManagementUseCase: Send + Sync + 'static {
) -> Result<FileDto, DomainError>;
/// Copies a file, enforcing that `caller_id` is the owner.
///
/// `new_name`, when `Some(_)`, becomes the copy's filename — without it
/// the copy keeps the source's name, which makes "same folder, different
/// name" copies (classic WebDAV `COPY /a.txt → /b.txt`) collide on the
/// `(folder, name, user)` unique index.
async fn copy_file_with_perms(
&self,
file_id: &str,
caller_id: Uuid,
target_folder_id: Option<String>,
new_name: Option<String>,
) -> Result<FileDto, DomainError>;
/// Renames a file, enforcing that `caller_id` is the owner.
+6
View File
@@ -314,10 +314,16 @@ pub trait FileWritePort: Send + Sync + 'static {
///
/// With blob-dedup, this only creates a new metadata row and increments
/// the blob reference count — zero disk I/O for the content.
///
/// `new_name` is honored when `Some(_)` — without it, copying a file to
/// the same folder always collides on the source's filename. WebDAV
/// COPY uses this for the "same folder, different name" case (the
/// classic `COPY /a.txt → /b.txt` pattern).
async fn copy_file(
&self,
file_id: &str,
target_folder_id: Option<String>,
new_name: Option<&str>,
) -> Result<File, DomainError>;
/// Copies an entire folder subtree atomically using ltree.
+6 -1
View File
@@ -145,7 +145,12 @@ impl BatchOperationService {
async move {
let copy_result = mgmt
.copy_file_with_perms(&file_id, user_id, target_folder.map(|s| s.to_string()))
.copy_file_with_perms(
&file_id,
user_id,
target_folder.map(|s| s.to_string()),
None,
)
.await;
(file_id, copy_result)
}
@@ -127,15 +127,16 @@ impl FileManagementService {
&self,
file_id: &str,
target_folder_id: Option<String>,
new_name: Option<&str>,
) -> Result<FileDto, DomainError> {
info!(
"Copying file with ID: {} to folder: {:?}",
file_id, target_folder_id
"Copying file with ID: {} to folder: {:?} as {:?}",
file_id, target_folder_id, new_name
);
let copied_file = self
.file_repository
.copy_file(file_id, target_folder_id)
.copy_file(file_id, target_folder_id, new_name)
.await
.map_err(|e| {
error!("Error copying file (ID: {}): {}", file_id, e);
@@ -260,13 +261,15 @@ impl FileManagementUseCase for FileManagementService {
file_id: &str,
caller_id: Uuid,
target_folder_id: Option<String>,
new_name: Option<String>,
) -> Result<FileDto, DomainError> {
// Copy = Read on the source file + Create on the target folder.
self.require_file_perm(file_id, Permission::Read, caller_id)
.await?;
self.require_target_folder_perm(target_folder_id.as_deref(), Permission::Create, caller_id)
.await?;
self.copy_file(file_id, target_folder_id).await
self.copy_file(file_id, target_folder_id, new_name.as_deref())
.await
}
async fn rename_file_with_perms(
@@ -228,6 +228,7 @@ impl FileWritePort for MockFileWritePort {
&self,
_file_id: &str,
_target_folder_id: Option<String>,
_new_name: Option<&str>,
) -> Result<File, DomainError> {
unimplemented!()
}
@@ -609,6 +609,7 @@ impl FileWritePort for MockFileRepository {
&self,
_file_id: &str,
_target_folder_id: Option<String>,
_new_name: Option<&str>,
) -> std::result::Result<File, DomainError> {
unimplemented!()
}