From 91a29d501d22203ee1efd2ff6a8164f238c5ea43 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 26 Mar 2026 10:52:43 +0100 Subject: [PATCH] feat(documents): add delete button to document edit form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DELETE /api/documents/{id} endpoint (204 No Content, WRITE_ALL required) - DocumentService.deleteDocument() — throws 404 if not found, cascades via DB foreign keys (versions, annotations, comments all ON DELETE CASCADE) - Delete form action in edit page server: redirects to / on success - Two-step confirmation in the save bar: first click reveals inline "Wirklich löschen?" + confirm/cancel, avoiding native browser dialogs - i18n key doc_delete_confirm added to de/en/es Co-Authored-By: Claude Sonnet 4.6 --- .../controller/DocumentController.java | 10 +++ .../service/DocumentService.java | 8 +++ .../controller/DocumentControllerTest.java | 26 ++++++++ .../service/DocumentServiceTest.java | 23 +++++++ frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + .../documents/[id]/edit/+page.server.ts | 15 +++++ .../routes/documents/[id]/edit/+page.svelte | 65 +++++++++++++++---- 9 files changed, 138 insertions(+), 12 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java index e09810ae..b3b6118c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -25,6 +25,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.core.io.InputStreamResource; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; @@ -105,6 +106,15 @@ public class DocumentController { } } + // --- DELETE --- + + @DeleteMapping("/{id}") + @RequirePermission(Permission.WRITE_ALL) + public ResponseEntity deleteDocument(@PathVariable UUID id) { + documentService.deleteDocument(id); + return ResponseEntity.noContent().build(); + } + // --- QUICK UPLOAD --- private static final Set ALLOWED_CONTENT_TYPES = Set.of( diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index 4722ce1d..28213d73 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -277,6 +277,14 @@ public class DocumentService { return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort); } + @Transactional + public void deleteDocument(UUID id) { + if (!documentRepository.existsById(id)) { + throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id); + } + documentRepository.deleteById(id); + } + @Transactional public void deleteTagCascading(UUID tagId) { documentRepository.findByTags_Id(tagId).forEach(doc -> { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java index c1637f6c..23a7e1b7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -121,6 +121,32 @@ class DocumentControllerTest { .andExpect(status().isOk()); } + // ─── DELETE /api/documents/{id} ────────────────────────────────────────── + + @Test + void deleteDocument_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .delete("/api/documents/" + UUID.randomUUID())) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void deleteDocument_returns403_whenMissingWritePermission() throws Exception { + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .delete("/api/documents/" + UUID.randomUUID())) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void deleteDocument_returns204_whenHasWritePermission() throws Exception { + UUID id = UUID.randomUUID(); + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .delete("/api/documents/" + id)) + .andExpect(status().isNoContent()); + } + // ─── POST /api/documents/quick-upload ──────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java index b8c889e5..367a86a1 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -35,6 +35,29 @@ class DocumentServiceTest { @Mock AnnotationService annotationService; @InjectMocks DocumentService documentService; + // ─── deleteDocument ─────────────────────────────────────────────────────── + + @Test + void deleteDocument_deletesById_whenExists() { + UUID id = UUID.randomUUID(); + when(documentRepository.existsById(id)).thenReturn(true); + + documentService.deleteDocument(id); + + verify(documentRepository).deleteById(id); + } + + @Test + void deleteDocument_throwsNotFound_whenMissing() { + UUID id = UUID.randomUUID(); + when(documentRepository.existsById(id)).thenReturn(false); + + assertThatThrownBy(() -> documentService.deleteDocument(id)) + .isInstanceOf(DomainException.class) + .hasMessageContaining(id.toString()); + verify(documentRepository, never()).deleteById(any()); + } + // ─── getDocumentById ────────────────────────────────────────────────────── @Test diff --git a/frontend/messages/de.json b/frontend/messages/de.json index b683a77b..d30fccde 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -24,6 +24,7 @@ "btn_edit": "Bearbeiten", "btn_create": "Erstellen", "btn_delete": "Löschen", + "doc_delete_confirm": "Dokument wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.", "btn_back_to_overview": "Zurück zur Übersicht", "btn_back": "Zurück", "btn_back_to_document": "Zurück zum Dokument", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 2d83e6ab..3f826d89 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -24,6 +24,7 @@ "btn_edit": "Edit", "btn_create": "Create", "btn_delete": "Delete", + "doc_delete_confirm": "Really delete this document? This action cannot be undone.", "btn_back_to_overview": "Back to overview", "btn_back": "Back", "btn_back_to_document": "Back to document", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index a8c4fd62..0a93f594 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -24,6 +24,7 @@ "btn_edit": "Editar", "btn_create": "Crear", "btn_delete": "Eliminar", + "doc_delete_confirm": "¿Realmente eliminar este documento? Esta acción no se puede deshacer.", "btn_back_to_overview": "Volver al resumen", "btn_back": "Volver", "btn_back_to_document": "Volver al documento", diff --git a/frontend/src/routes/documents/[id]/edit/+page.server.ts b/frontend/src/routes/documents/[id]/edit/+page.server.ts index e64a534b..bd339151 100644 --- a/frontend/src/routes/documents/[id]/edit/+page.server.ts +++ b/frontend/src/routes/documents/[id]/edit/+page.server.ts @@ -58,5 +58,20 @@ export const actions = { } throw redirect(303, `/documents/${params.id}`); + }, + + delete: async ({ params, fetch }) => { + const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; + + const res = await fetch(`${baseUrl}/api/documents/${params.id}`, { + method: 'DELETE' + }); + + if (!res.ok) { + const backendError = await parseBackendError(res); + return fail(res.status, { error: getErrorMessage(backendError?.code) }); + } + + throw redirect(303, '/'); } }; diff --git a/frontend/src/routes/documents/[id]/edit/+page.svelte b/frontend/src/routes/documents/[id]/edit/+page.svelte index 4d65db42..7ff74f28 100644 --- a/frontend/src/routes/documents/[id]/edit/+page.svelte +++ b/frontend/src/routes/documents/[id]/edit/+page.svelte @@ -17,6 +17,7 @@ let selectedReceivers = $state(doc.receivers ?? []); let dateDisplay = $state(isoToGerman(doc.documentDate ?? '')); let dateIso = $state(doc.documentDate ?? ''); let dateDirty = $state(false); +let confirmDelete = $state(false); const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === ''); @@ -244,18 +245,58 @@ function handleDateInput(e: Event) {
- - {m.btn_cancel()} - - + +
+ {#if confirmDelete} + {m.doc_delete_confirm()} + + + {:else} + + {/if} +
+ + +
+ + {m.btn_cancel()} + + +
+ +