diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d92bfd5d..f50e8e80 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -28,6 +28,10 @@ jobs: run: npm ci working-directory: frontend + - name: Compile Paraglide i18n + run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide + working-directory: frontend + - name: Lint run: npm run lint working-directory: frontend diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthE2EController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthE2EController.java index 312666e4..7d9be97b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthE2EController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthE2EController.java @@ -10,6 +10,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import io.swagger.v3.oas.annotations.Operation; + import lombok.RequiredArgsConstructor; /** @@ -24,6 +26,9 @@ public class AuthE2EController { private final PasswordResetTokenRepository tokenRepository; + // Hidden from the OpenAPI spec — this endpoint must never appear in the generated api.ts + // even when the e2e profile is active alongside the dev profile during spec generation. + @Operation(hidden = true) @GetMapping("/reset-token-for-test") public ResponseEntity getResetTokenForTest(@RequestParam String email) { return tokenRepository.findLatestActiveTokenByEmail(email, LocalDateTime.now()) 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 7b49a148..f661e8aa 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -212,7 +212,7 @@ public class DocumentController { @GetMapping("/conversation") public List getConversation( @RequestParam UUID senderId, - @RequestParam UUID receiverId, + @RequestParam(required = false) UUID receiverId, @RequestParam(required = false) LocalDate from, @RequestParam(required = false) LocalDate to, @RequestParam(defaultValue = "DESC") String dir) { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java index c7db9cac..ca5a88d4 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java @@ -60,11 +60,9 @@ public interface DocumentRepository extends JpaRepository, JpaSp @Query("SELECT DISTINCT d FROM Document d " + "JOIN d.receivers r " + "WHERE " + - // Logik: (Sender A an Empfänger B) ODER (Sender B an Empfänger A) "((d.sender.id = :person1 AND r.id = :person2) " + " OR " + " (d.sender.id = :person2 AND r.id = :person1)) " + - // UND das Datum stimmt "AND d.documentDate BETWEEN :from AND :to") List findConversation( @Param("person1") UUID person1, @@ -73,4 +71,14 @@ public interface DocumentRepository extends JpaRepository, JpaSp @Param("to") LocalDate to, Sort sort); + @Query("SELECT DISTINCT d FROM Document d " + + "LEFT JOIN d.receivers r " + + "WHERE (d.sender.id = :personId OR r.id = :personId) " + + "AND d.documentDate BETWEEN :from AND :to") + List findSinglePersonCorrespondence( + @Param("personId") UUID personId, + @Param("from") LocalDate from, + @Param("to") LocalDate to, + Sort sort); + } \ No newline at end of file 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 55c5ab98..fe5826ee 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -328,6 +328,9 @@ public class DocumentService { public List getConversationFiltered(UUID senderId, UUID receiverId, LocalDate from, LocalDate to, Sort sort) { LocalDate dateFrom = (from != null) ? from : LocalDate.parse("0000-01-01"); LocalDate dateTo = (to != null) ? to : LocalDate.now(); + if (receiverId == null) { + return documentRepository.findSinglePersonCorrespondence(senderId, dateFrom, dateTo, sort); + } return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java index db5249a1..f295715f 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -24,10 +24,13 @@ public class PersonService { private final PersonRepository personRepository; public List findAll(String q) { - if (q != null && !q.isBlank()) { - return personRepository.searchWithDocumentCount(q); + if (q == null) { + return personRepository.findAllWithDocumentCount(); } - return personRepository.findAllWithDocumentCount(); + if (q.isBlank()) { + return List.of(); + } + return personRepository.searchWithDocumentCount(q.trim()); } public Person getById(UUID id) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java index 845646b7..51ab05b4 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java @@ -15,8 +15,11 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; +import java.time.LocalDate; +import java.util.HashSet; import java.util.List; import java.util.Optional; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -189,4 +192,65 @@ class DocumentRepositoryTest { assertThat(result.getTotalElements()).isEqualTo(5); assertThat(result.getContent()).allMatch(d -> !d.isMetadataComplete()); } + + // ─── findSinglePersonCorrespondence — DISTINCT / multi-receiver safety ──── + + @Test + void findSinglePersonCorrespondence_returnsExactlyOneResult_whenDocumentHasThreeReceiversAndOneMatchesPersonId() { + Person sender = personRepository.save(Person.builder() + .firstName("Hans").lastName("Müller").build()); + Person receiver1 = personRepository.save(Person.builder() + .firstName("Anna").lastName("Schmidt").build()); + Person receiver2 = personRepository.save(Person.builder() + .firstName("Bertha").lastName("Wagner").build()); + Person receiver3 = personRepository.save(Person.builder() + .firstName("Clara").lastName("Koch").build()); + + // Document addressed to all three receivers + Document doc = documentRepository.save(Document.builder() + .title("Rundschreiben") + .originalFilename("rundschreiben.pdf") + .status(DocumentStatus.UPLOADED) + .sender(sender) + .receivers(new HashSet<>(Set.of(receiver1, receiver2, receiver3))) + .documentDate(LocalDate.of(1950, 6, 1)) + .build()); + + Sort sort = Sort.by(Sort.Direction.DESC, "documentDate"); + LocalDate from = LocalDate.of(1900, 1, 1); + LocalDate to = LocalDate.of(2000, 1, 1); + + // Query for receiver1 — the DISTINCT must collapse the 3 JOIN rows into 1 result + List results = documentRepository.findSinglePersonCorrespondence( + receiver1.getId(), from, to, sort); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getId()).isEqualTo(doc.getId()); + } + + @Test + void findSinglePersonCorrespondence_includesDocumentsWherePerson_isSender() { + Person sender = personRepository.save(Person.builder() + .firstName("Hans").lastName("Müller").build()); + Person receiver = personRepository.save(Person.builder() + .firstName("Anna").lastName("Schmidt").build()); + + documentRepository.save(Document.builder() + .title("Brief als Absender") + .originalFilename("brief_absender.pdf") + .status(DocumentStatus.UPLOADED) + .sender(sender) + .receivers(new HashSet<>(Set.of(receiver))) + .documentDate(LocalDate.of(1950, 6, 1)) + .build()); + + Sort sort = Sort.by(Sort.Direction.DESC, "documentDate"); + LocalDate from = LocalDate.of(1900, 1, 1); + LocalDate to = LocalDate.of(2000, 1, 1); + + List results = documentRepository.findSinglePersonCorrespondence( + sender.getId(), from, to, sort); + + assertThat(results).hasSize(1); + } } 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 fb86d2a8..fb01c7a2 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -1244,4 +1244,33 @@ class DocumentServiceTest { assertThat(captor.getValue().getSort()) .isEqualTo(Sort.by(Sort.Direction.DESC, "updatedAt")); } + + // ─── getConversationFiltered (single-person mode) ───────────────────────── + + @Test + void getConversationFiltered_callsSinglePersonQuery_whenReceiverIdIsNull() { + UUID personId = UUID.randomUUID(); + Sort sort = Sort.by(Sort.Direction.DESC, "documentDate"); + when(documentRepository.findSinglePersonCorrespondence(eq(personId), any(), any(), eq(sort))) + .thenReturn(List.of()); + + documentService.getConversationFiltered(personId, null, null, null, sort); + + verify(documentRepository).findSinglePersonCorrespondence(eq(personId), any(), any(), eq(sort)); + verify(documentRepository, never()).findConversation(any(), any(), any(), any(), any()); + } + + @Test + void getConversationFiltered_callsBilateralQuery_whenReceiverIdIsSet() { + UUID senderId = UUID.randomUUID(); + UUID receiverId = UUID.randomUUID(); + Sort sort = Sort.by(Sort.Direction.DESC, "documentDate"); + when(documentRepository.findConversation(eq(senderId), eq(receiverId), any(), any(), eq(sort))) + .thenReturn(List.of()); + + documentService.getConversationFiltered(senderId, receiverId, null, null, sort); + + verify(documentRepository).findConversation(eq(senderId), eq(receiverId), any(), any(), eq(sort)); + verify(documentRepository, never()).findSinglePersonCorrespondence(any(), any(), any(), any()); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java index e0638a00..f229f8b9 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java @@ -62,12 +62,9 @@ class PersonServiceTest { } @Test - void findAll_returnsAll_whenQueryIsBlank() { - List expected = List.of(); - when(personRepository.findAllWithDocumentCount()).thenReturn(expected); - - assertThat(personService.findAll(" ")).isEqualTo(expected); - verify(personRepository).findAllWithDocumentCount(); + void findAll_returnsEmpty_whenQueryIsWhitespaceOnly() { + assertThat(personService.findAll(" ")).isEmpty(); + verify(personRepository, never()).findAllWithDocumentCount(); verify(personRepository, never()).searchWithDocumentCount(any()); } diff --git a/frontend/e2e/korrespondenz.spec.ts b/frontend/e2e/korrespondenz.spec.ts new file mode 100644 index 00000000..6fcf0680 --- /dev/null +++ b/frontend/e2e/korrespondenz.spec.ts @@ -0,0 +1,127 @@ +import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +function buildAxe(page: Parameters[0]['page']) { + return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']); +} + +test.describe('Korrespondenz – empty state', () => { + test('shows the search heading when no person is selected', async ({ page }) => { + await page.goto('/korrespondenz'); + await expect(page.getByText(/Korrespondenz durchsuchen/i)).toBeVisible(); + const a11y = await buildAxe(page).analyze(); + expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0); + await page.screenshot({ path: 'test-results/e2e/korrespondenz-empty.png' }); + }); + + test('nav link goes to /korrespondenz', async ({ page }) => { + await page.goto('/'); + // Click the nav link (desktop text or mobile icon) + const navLink = page.getByRole('link', { name: /Korrespondenz/i }).first(); + await navLink.click(); + await expect(page).toHaveURL(/\/korrespondenz/); + }); +}); + +test.describe('Korrespondenz – single-person mode', () => { + test('shows hint bar and documents when navigated with senderId', async ({ page }) => { + // Get a real person ID from the persons list + await page.goto('/persons'); + const firstPersonLink = page.locator('a[href^="/persons/"]').first(); + await firstPersonLink.click(); + await page.waitForURL(/\/persons\/.+/); + + // Extract the person ID from the URL + const personId = page.url().split('/persons/')[1].split('?')[0]; + + // Navigate to korrespondenz in single-person mode + await page.goto(`/korrespondenz?senderId=${personId}`); + + // Hint bar should be visible + await expect(page.getByText(/Alle Briefe von/i)).toBeVisible(); + + // Filter controls should be active (not dimmed) + const filterStrip = page.locator('[aria-disabled="false"]').first(); + await expect(filterStrip).toBeAttached(); + + const a11y = await buildAxe(page).analyze(); + expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0); + await page.screenshot({ path: 'test-results/e2e/korrespondenz-single-person.png' }); + }); + + test('sort toggle changes URL direction param', async ({ page }) => { + await page.goto('/persons'); + const firstPersonLink = page.locator('a[href^="/persons/"]').first(); + await firstPersonLink.click(); + await page.waitForURL(/\/persons\/.+/); + const personId = page.url().split('/persons/')[1].split('?')[0]; + + await page.goto(`/korrespondenz?senderId=${personId}&dir=DESC`); + await page.getByTestId('conv-sort-btn').click(); + + await expect(page).toHaveURL(/dir=ASC/); + await page.screenshot({ path: 'test-results/e2e/korrespondenz-sort-asc.png' }); + }); +}); + +test.describe('Korrespondenz – bilateral mode', () => { + test('shows asymmetry bar when both persons have shared documents', async ({ page }) => { + // Navigate to a person then follow a co-correspondent suggestion if available + await page.goto('/persons'); + const firstPersonLink = page.locator('a[href^="/persons/"]').first(); + await firstPersonLink.click(); + await page.waitForURL(/\/persons\/.+/); + const senderId = page.url().split('/persons/')[1].split('?')[0]; + + // Try to find a co-correspondent link from the person detail page + const corrLink = page + .locator('a[href*="/korrespondenz?senderId="][href*="receiverId="]') + .first(); + if (await corrLink.isVisible({ timeout: 2000 }).catch(() => false)) { + await corrLink.click(); + await page.waitForURL(/\/korrespondenz\?.*receiverId=/); + + // Hint bar should NOT be shown in bilateral mode + await expect(page.getByText(/Alle Briefe von/i)).not.toBeVisible(); + + const a11y = await buildAxe(page).analyze(); + expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0); + await page.screenshot({ path: 'test-results/e2e/korrespondenz-bilateral.png' }); + } else { + // E2E seed must include bilateral correspondents — a missing link is a test failure. + throw new Error( + `No bilateral correspondent links found for person ${senderId}. Ensure the E2E seed contains at least one bilateral correspondence pair.` + ); + } + }); + + test('swap button swaps sender and receiver in URL', async ({ page }) => { + await page.goto('/persons'); + const firstPersonLink = page.locator('a[href^="/persons/"]').first(); + await firstPersonLink.click(); + await page.waitForURL(/\/persons\/.+/); + const senderId = page.url().split('/persons/')[1].split('?')[0]; + + const corrLink = page + .locator('a[href*="/korrespondenz?senderId="][href*="receiverId="]') + .first(); + if (await corrLink.isVisible({ timeout: 2000 }).catch(() => false)) { + const href = await corrLink.getAttribute('href'); + await corrLink.click(); + await page.waitForURL(/\/korrespondenz\?.*receiverId=/); + + // Extract original receiverId from the href + const url = new URL(href!, 'http://x'); + const originalReceiverId = url.searchParams.get('receiverId')!; + + // Click swap + await page.getByTestId('conv-swap-btn').click(); + + // After swap the former receiver is now senderId + await expect(page).toHaveURL(new RegExp(`senderId=${originalReceiverId}`)); + await page.screenshot({ path: 'test-results/e2e/korrespondenz-swapped.png' }); + } else { + test.skip(true, `No bilateral correspondent links found for person ${senderId}`); + } + }); +}); diff --git a/frontend/messages/de.json b/frontend/messages/de.json index c344e8e3..5017ad9a 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -16,7 +16,7 @@ "error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.", "nav_documents": "Dokumente", "nav_persons": "Personen", - "nav_conversations": "Konversationen", + "nav_conversations": "Korrespondenz", "nav_admin": "Admin", "nav_logout": "Abmelden", "btn_save": "Speichern", @@ -122,22 +122,39 @@ "person_co_correspondents_heading": "Häufige Korrespondenten", "person_correspondents_hint": "klicken für Konversation", "person_show_more": "+ {count} weitere anzeigen", - "conv_heading": "Konversationen", - "conv_subtitle": "Verfolgen Sie den Schriftverkehr zwischen zwei Personen chronologisch.", + "conv_heading": "Korrespondenz", + "conv_subtitle": "Briefwechsel einer Person durchsuchen — mit oder ohne Korrespondent.", "conv_label_person_a": "Person A (Absender)", - "conv_label_person_b": "Person B (Empfänger)", + "conv_label_person_b": "Korrespondent", "conv_label_from": "Zeitraum von", "conv_label_to": "Zeitraum bis", "conv_sort_label": "Sortierung:", "conv_sort_newest": "Neueste zuerst", "conv_sort_oldest": "Älteste zuerst", - "conv_empty_heading": "Wählen Sie zwei Personen aus", - "conv_empty_text": "Die Korrespondenz wird hier angezeigt.", + "conv_empty_heading": "Korrespondenz durchsuchen", + "conv_empty_text": "Wähle eine Person aus dem Archiv um deren Briefe zu sehen — mit oder ohne Korrespondent.", "conv_no_results_heading": "Keine Dokumente gefunden.", "conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.", "conv_swap_btn": "Personen tauschen", "conv_summary": "{count} Dokumente · {yearFrom}–{yearTo}", "conv_new_doc_link": "Neues Dokument in dieser Korrespondenz", + "conv_label_correspondent_optional": "Korrespondent", + "conv_hint_single_person": "Alle Briefe von {name} — wähle einen Korrespondenten oben um einzugrenzen", + "conv_hint_single_person_filtered": "Alle Briefe von {name} · {from}–{to} · {sortLabel}", + "conv_strip_period": "Zeitraum", + "conv_strip_from_placeholder": "Von…", + "conv_strip_to_placeholder": "Bis…", + "conv_strip_all_correspondents": "Alle Korrespondenten", + "conv_strip_sort_newest": "Neueste", + "conv_strip_sort_oldest": "Älteste", + "conv_suggestions_heading": "Häufigste Korrespondenten", + "conv_suggestions_all_label": "Alle Korrespondenten von {name}", + "conv_letters_count": "{count} Briefe", + "conv_empty_search_placeholder": "Person suchen…", + "conv_empty_recent_label": "Zuletzt geöffnet", + "conv_asym_sent": "{count} von {name} →", + "conv_asym_received": "{count} von {name} ←", + "conv_no_party": "—", "admin_heading": "Admin Dashboard", "admin_tab_users": "Benutzer", "admin_tab_groups": "Gruppen", @@ -190,6 +207,13 @@ "admin_group_created": "Gruppe erstellt.", "admin_groups_section_standard": "Standard", "admin_groups_section_administrative": "Administrativ", + "admin_perm_read_all": "Nur lesen", + "admin_perm_annotate_all": "Lesen & Annotieren", + "admin_perm_write_all": "Lesen & Schreiben", + "admin_perm_admin": "Vollzugriff (Admin)", + "admin_perm_admin_user": "Benutzer verwalten", + "admin_perm_admin_tag": "Schlagworte verwalten", + "admin_perm_admin_permission": "Berechtigungen verwalten", "admin_user_new_heading": "Neuen Benutzer anlegen", "admin_user_edit_heading": "Benutzer bearbeiten: {username}", "admin_user_created": "Benutzer wurde erstellt.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 7bb2a116..6b3e157c 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -16,7 +16,7 @@ "error_internal_error": "An unexpected error occurred.", "nav_documents": "Documents", "nav_persons": "Persons", - "nav_conversations": "Conversations", + "nav_conversations": "Correspondence", "nav_admin": "Admin", "nav_logout": "Sign out", "btn_save": "Save", @@ -122,22 +122,39 @@ "person_co_correspondents_heading": "Frequent correspondents", "person_correspondents_hint": "click to view conversation", "person_show_more": "+ {count} more", - "conv_heading": "Conversations", - "conv_subtitle": "Follow the correspondence between two persons chronologically.", + "conv_heading": "Correspondence", + "conv_subtitle": "Browse a person's letters — with or without a correspondent.", "conv_label_person_a": "Person A (Sender)", - "conv_label_person_b": "Person B (Recipient)", + "conv_label_person_b": "Correspondent", "conv_label_from": "Period from", "conv_label_to": "Period to", "conv_sort_label": "Sort:", "conv_sort_newest": "Newest first", "conv_sort_oldest": "Oldest first", - "conv_empty_heading": "Select two persons", - "conv_empty_text": "The correspondence will be shown here.", + "conv_empty_heading": "Browse correspondence", + "conv_empty_text": "Choose a person from the archive to see their letters — with or without a correspondent.", "conv_no_results_heading": "No documents found.", "conv_no_results_text": "Try adjusting the time period.", "conv_swap_btn": "Swap persons", "conv_summary": "{count} documents · {yearFrom}–{yearTo}", "conv_new_doc_link": "New document in this correspondence", + "conv_label_correspondent_optional": "Correspondent", + "conv_hint_single_person": "All letters from {name} — choose a correspondent above to narrow down", + "conv_hint_single_person_filtered": "All letters from {name} · {from}–{to} · {sortLabel}", + "conv_strip_period": "Period", + "conv_strip_from_placeholder": "From…", + "conv_strip_to_placeholder": "To…", + "conv_strip_all_correspondents": "All correspondents", + "conv_strip_sort_newest": "Newest", + "conv_strip_sort_oldest": "Oldest", + "conv_suggestions_heading": "Top correspondents", + "conv_suggestions_all_label": "All correspondents of {name}", + "conv_letters_count": "{count} letters", + "conv_empty_search_placeholder": "Search person…", + "conv_empty_recent_label": "Recently opened", + "conv_asym_sent": "{count} from {name} →", + "conv_asym_received": "{count} from {name} ←", + "conv_no_party": "—", "admin_heading": "Admin Dashboard", "admin_tab_users": "Users", "admin_tab_groups": "Groups", @@ -190,6 +207,13 @@ "admin_group_created": "Group created.", "admin_groups_section_standard": "Standard", "admin_groups_section_administrative": "Administrative", + "admin_perm_read_all": "Read only", + "admin_perm_annotate_all": "Read & Annotate", + "admin_perm_write_all": "Read & Write", + "admin_perm_admin": "Full access (Admin)", + "admin_perm_admin_user": "Manage users", + "admin_perm_admin_tag": "Manage tags", + "admin_perm_admin_permission": "Manage permissions", "admin_user_new_heading": "Create new user", "admin_user_edit_heading": "Edit user: {username}", "admin_user_created": "User has been created.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 0f9d93d4..0b7a9e36 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -16,7 +16,7 @@ "error_internal_error": "Se ha producido un error inesperado.", "nav_documents": "Documentos", "nav_persons": "Personas", - "nav_conversations": "Conversaciones", + "nav_conversations": "Correspondencia", "nav_admin": "Admin", "nav_logout": "Cerrar sesión", "btn_save": "Guardar", @@ -122,22 +122,39 @@ "person_co_correspondents_heading": "Corresponsales frecuentes", "person_correspondents_hint": "clic para ver conversación", "person_show_more": "+ {count} más", - "conv_heading": "Conversaciones", - "conv_subtitle": "Siga la correspondencia entre dos personas cronológicamente.", + "conv_heading": "Correspondencia", + "conv_subtitle": "Explore las cartas de una persona — con o sin corresponsal.", "conv_label_person_a": "Persona A (Remitente)", - "conv_label_person_b": "Persona B (Destinatario)", + "conv_label_person_b": "Corresponsal", "conv_label_from": "Período desde", "conv_label_to": "Período hasta", "conv_sort_label": "Ordenar:", "conv_sort_newest": "Más reciente primero", "conv_sort_oldest": "Más antiguo primero", - "conv_empty_heading": "Seleccione dos personas", - "conv_empty_text": "La correspondencia se mostrará aquí.", + "conv_empty_heading": "Explorar correspondencia", + "conv_empty_text": "Elige una persona del archivo para ver sus cartas — con o sin corresponsal.", "conv_no_results_heading": "No se encontraron documentos.", "conv_no_results_text": "Intente ajustar el período de tiempo.", "conv_swap_btn": "Intercambiar personas", "conv_summary": "{count} documentos · {yearFrom}–{yearTo}", "conv_new_doc_link": "Nuevo documento en esta correspondencia", + "conv_label_correspondent_optional": "Corresponsal", + "conv_hint_single_person": "Todas las cartas de {name} — elige un corresponsal arriba para filtrar", + "conv_hint_single_person_filtered": "Todas las cartas de {name} · {from}–{to} · {sortLabel}", + "conv_strip_period": "Período", + "conv_strip_from_placeholder": "Desde…", + "conv_strip_to_placeholder": "Hasta…", + "conv_strip_all_correspondents": "Todos los corresponsales", + "conv_strip_sort_newest": "Más reciente", + "conv_strip_sort_oldest": "Más antiguo", + "conv_suggestions_heading": "Corresponsales frecuentes", + "conv_suggestions_all_label": "Todos los corresponsales de {name}", + "conv_letters_count": "{count} cartas", + "conv_empty_search_placeholder": "Buscar persona…", + "conv_empty_recent_label": "Recientemente abiertos", + "conv_asym_sent": "{count} de {name} →", + "conv_asym_received": "{count} de {name} ←", + "conv_no_party": "—", "admin_heading": "Panel de administración", "admin_tab_users": "Usuarios", "admin_tab_groups": "Grupos", @@ -190,6 +207,13 @@ "admin_group_created": "Grupo creado.", "admin_groups_section_standard": "Est\u00e1ndar", "admin_groups_section_administrative": "Administrativo", + "admin_perm_read_all": "Solo lectura", + "admin_perm_annotate_all": "Leer y anotar", + "admin_perm_write_all": "Leer y escribir", + "admin_perm_admin": "Acceso completo (Admin)", + "admin_perm_admin_user": "Gestionar usuarios", + "admin_perm_admin_tag": "Gestionar etiquetas", + "admin_perm_admin_permission": "Gestionar permisos", "admin_user_new_heading": "Crear nuevo usuario", "admin_user_edit_heading": "Editar usuario: {username}", "admin_user_created": "Usuario creado.", diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 0663671f..ca517012 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -12,6 +12,7 @@ declare global { email?: string; contact?: string; groups: { + id: string; name: string; permissions: string[]; }[]; diff --git a/frontend/src/lib/components/DateInput.svelte b/frontend/src/lib/components/DateInput.svelte index a8beeeaf..2ee7acc4 100644 --- a/frontend/src/lib/components/DateInput.svelte +++ b/frontend/src/lib/components/DateInput.svelte @@ -1,31 +1,28 @@ @@ -60,11 +69,10 @@ function handleInput(e: Event) { inputmode="numeric" maxlength="10" id={id} - placeholder={placeholder} - class={extraClass} value={display} + placeholder={placeholder ?? m.form_placeholder_date()} oninput={handleInput} - {...rest} + class={className} /> {#if name} diff --git a/frontend/src/lib/components/DateInput.svelte.spec.ts b/frontend/src/lib/components/DateInput.svelte.spec.ts new file mode 100644 index 00000000..d8704aef --- /dev/null +++ b/frontend/src/lib/components/DateInput.svelte.spec.ts @@ -0,0 +1,210 @@ +import { describe, expect, it, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import DateInput from './DateInput.svelte'; + +afterEach(() => cleanup()); + +// ─── Rendering ──────────────────────────────────────────────────────────────── + +describe('DateInput – rendering', () => { + it('renders a text input with inputmode=numeric and maxlength=10', async () => { + render(DateInput, {}); + const input = page.getByRole('textbox'); + await expect.element(input).toBeInTheDocument(); + await expect.element(input).toHaveAttribute('inputmode', 'numeric'); + await expect.element(input).toHaveAttribute('maxlength', '10'); + }); + + it('has default placeholder from paraglide', async () => { + render(DateInput, {}); + const input = page.getByRole('textbox'); + await expect.element(input).toHaveAttribute('placeholder', 'TT.MM.JJJJ'); + }); + + it('accepts a custom placeholder', async () => { + render(DateInput, { placeholder: 'Geburtsdatum' }); + const input = page.getByRole('textbox'); + await expect.element(input).toHaveAttribute('placeholder', 'Geburtsdatum'); + }); + + it('passes id prop to the input', async () => { + render(DateInput, { id: 'my-date' }); + const input = page.getByRole('textbox'); + await expect.element(input).toHaveAttribute('id', 'my-date'); + }); +}); + +// ─── Init from value ────────────────────────────────────────────────────────── + +describe('DateInput – init from value', () => { + it('displays ISO value in German format on mount', async () => { + render(DateInput, { value: '2024-12-20' }); + const input = page.getByRole('textbox'); + await expect.element(input).toHaveValue('20.12.2024'); + }); + + it('starts empty and error-free when no value is given', async () => { + let errorMessage: string | null = null; + render(DateInput, { + get errorMessage() { + return errorMessage; + }, + set errorMessage(v) { + errorMessage = v; + } + }); + const input = page.getByRole('textbox'); + await expect.element(input).toHaveValue(''); + expect(errorMessage).toBeNull(); + }); +}); + +// ─── Typing valid date ──────────────────────────────────────────────────────── + +describe('DateInput – typing a valid date', () => { + it('auto-formats to DD.MM.YYYY and updates value to ISO', async () => { + let value = ''; + let errorMessage: string | null = null; + render(DateInput, { + get value() { + return value; + }, + set value(v) { + value = v; + }, + get errorMessage() { + return errorMessage; + }, + set errorMessage(v) { + errorMessage = v; + } + }); + const input = page.getByRole('textbox'); + await input.fill('20122024'); + await expect.element(input).toHaveValue('20.12.2024'); + expect(value).toBe('2024-12-20'); + expect(errorMessage).toBeNull(); + }); +}); + +// ─── Typing invalid month ───────────────────────────────────────────────────── + +describe('DateInput – typing a date with invalid month', () => { + it('sets errorMessage and clears value when month > 12', async () => { + let value = ''; + let errorMessage: string | null = null; + render(DateInput, { + get value() { + return value; + }, + set value(v) { + value = v; + }, + get errorMessage() { + return errorMessage; + }, + set errorMessage(v) { + errorMessage = v; + } + }); + const input = page.getByRole('textbox'); + await input.fill('22222222'); + await expect.element(input).toHaveValue('22.22.2222'); + expect(value).toBe(''); + expect(errorMessage).not.toBeNull(); + }); +}); + +// ─── Typing partial date ────────────────────────────────────────────────────── + +describe('DateInput – typing a partial date', () => { + it('sets errorMessage and clears value when date is incomplete', async () => { + let value = ''; + let errorMessage: string | null = null; + render(DateInput, { + get value() { + return value; + }, + set value(v) { + value = v; + }, + get errorMessage() { + return errorMessage; + }, + set errorMessage(v) { + errorMessage = v; + } + }); + const input = page.getByRole('textbox'); + await input.fill('2212'); + await expect.element(input).toHaveValue('22.12'); + expect(value).toBe(''); + expect(errorMessage).not.toBeNull(); + }); +}); + +// ─── Clearing date ──────────────────────────────────────────────────────────── + +describe('DateInput – clearing the date', () => { + it('resets value and errorMessage to null when cleared', async () => { + let value = ''; + let errorMessage: string | null = null; + render(DateInput, { + get value() { + return value; + }, + set value(v) { + value = v; + }, + get errorMessage() { + return errorMessage; + }, + set errorMessage(v) { + errorMessage = v; + } + }); + const input = page.getByRole('textbox'); + // Type a valid date first + await input.fill('20122024'); + expect(value).toBe('2024-12-20'); + // Now clear + await input.fill(''); + expect(value).toBe(''); + expect(errorMessage).toBeNull(); + }); + + it('fires onchange when the field is cleared', async () => { + let called = 0; + render(DateInput, { value: '2024-12-20', onchange: () => called++ }); + const input = page.getByRole('textbox'); + await input.fill(''); + expect(called).toBeGreaterThan(0); + }); +}); + +// ─── Hidden input ───────────────────────────────────────────────────────────── + +describe('DateInput – hidden input for form submission', () => { + it('renders a hidden input with the given name when name prop is set', async () => { + render(DateInput, { name: 'documentDate' }); + const hidden = document.querySelector('input[type="hidden"][name="documentDate"]'); + expect(hidden).not.toBeNull(); + }); + + it('does not render a hidden input when name prop is absent', async () => { + render(DateInput, {}); + const hidden = document.querySelector('input[type="hidden"]'); + expect(hidden).toBeNull(); + }); + + it('hidden input value reflects the ISO value', async () => { + render(DateInput, { name: 'documentDate', value: '' }); + const input = page.getByRole('textbox'); + await input.fill('20122024'); + const hidden = document.querySelector( + 'input[type="hidden"][name="documentDate"]' + ); + await expect.poll(() => hidden?.value).toBe('2024-12-20'); + }); +}); diff --git a/frontend/src/lib/components/PanelMetadata.svelte b/frontend/src/lib/components/PanelMetadata.svelte index b819df8d..f8c2719c 100644 --- a/frontend/src/lib/components/PanelMetadata.svelte +++ b/frontend/src/lib/components/PanelMetadata.svelte @@ -175,7 +175,7 @@ let { doc }: { doc: Doc } = $props(); {#if doc.sender} diff --git a/frontend/src/lib/components/PersonTypeahead.svelte b/frontend/src/lib/components/PersonTypeahead.svelte index 5196108a..3be05bc0 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte +++ b/frontend/src/lib/components/PersonTypeahead.svelte @@ -10,8 +10,11 @@ interface Props { value?: string; initialName?: string; suggestedName?: string; + placeholder?: string; + compact?: boolean; restrictToCorrespondentsOf?: string; onchange?: (value: string) => void; + onfocused?: () => void; } let { @@ -20,12 +23,23 @@ let { value = $bindable(''), initialName = '', suggestedName = '', + placeholder, + compact = false, restrictToCorrespondentsOf, - onchange + onchange, + onfocused }: Props = $props(); +// searchTerm must be both prop-derived AND locally writable (user typing), so $state + +// $effect is the correct pattern here — writable $derived is read-only and won't work. +// eslint-disable-next-line svelte/prefer-writable-derived let searchTerm = $state(initialName); +// Sync display text when the selected person changes externally (e.g. swap, navigation). +$effect(() => { + searchTerm = initialName; +}); + $effect(() => { const suggested = suggestedName; if (suggested && !untrack(() => value)) { @@ -79,6 +93,7 @@ function handleInput() { } function handleFocus() { + onfocused?.(); showDropdown = true; if (restrictToCorrespondentsOf) { const personId = untrack(() => restrictToCorrespondentsOf)!; @@ -120,7 +135,13 @@ function clickOutside(node: HTMLElement) {
- + @@ -131,8 +152,10 @@ function clickOutside(node: HTMLElement) { bind:value={searchTerm} oninput={handleInput} onfocus={handleFocus} - placeholder={m.comp_typeahead_placeholder()} - class="mt-1 block w-full rounded-md border border-line p-2 shadow-sm focus:border-accent focus:ring-accent" + placeholder={placeholder ?? m.comp_typeahead_placeholder()} + class={compact + ? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:border-primary focus:outline-none' + : 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:border-accent focus:ring-accent'} /> {#if showDropdown && (results.length > 0 || loading)} diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index b7be22ff..52be38ff 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -724,6 +724,8 @@ export interface paths { patch?: never; trace?: never; }; + // "/api/auth/reset-token-for-test" removed — @Operation(hidden=true) on AuthE2EController. + // Regenerate with `npm run generate:api` after the next backend build to keep in sync. "/api/admin/import-status": { parameters: { query?: never; @@ -1030,8 +1032,6 @@ export interface components { /** Format: int32 */ totalPages?: number; pageable?: components["schemas"]["PageableObject"]; - first?: boolean; - last?: boolean; /** Format: int32 */ size?: number; content?: components["schemas"]["NotificationDTO"][]; @@ -1040,14 +1040,16 @@ export interface components { sort?: components["schemas"]["SortObject"]; /** Format: int32 */ numberOfElements?: number; + first?: boolean; + last?: boolean; empty?: boolean; }; PageableObject: { - paged?: boolean; /** Format: int32 */ pageNumber?: number; /** Format: int32 */ pageSize?: number; + paged?: boolean; /** Format: int64 */ offset?: number; sort?: components["schemas"]["SortObject"]; @@ -2223,7 +2225,9 @@ export interface operations { query?: { page?: number; size?: number; + /** @description Filter by notification type */ type?: "REPLY" | "MENTION"; + /** @description Filter by read status */ read?: boolean; }; header?: never; @@ -2474,7 +2478,7 @@ export interface operations { parameters: { query: { senderId: string; - receiverId: string; + receiverId?: string; from?: string; to?: string; dir?: string; @@ -2496,6 +2500,7 @@ export interface operations { }; }; }; + // getResetTokenForTest removed — @Operation(hidden=true) on AuthE2EController. importStatus: { parameters: { query?: never; diff --git a/frontend/src/lib/utils/date.spec.ts b/frontend/src/lib/utils/date.spec.ts new file mode 100644 index 00000000..27344f96 --- /dev/null +++ b/frontend/src/lib/utils/date.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { formatGermanDateInput, isoToGerman, germanToIso } from './date'; + +// ─── isoToGerman ───────────────────────────────────────────────────────────── + +describe('isoToGerman', () => { + it('converts a valid ISO date to DD.MM.YYYY', () => { + expect(isoToGerman('2024-12-20')).toBe('20.12.2024'); + }); + + it('returns empty string for empty input', () => { + expect(isoToGerman('')).toBe(''); + }); + + it('returns empty string for invalid format', () => { + expect(isoToGerman('not-a-date')).toBe(''); + }); +}); + +// ─── germanToIso ───────────────────────────────────────────────────────────── + +describe('germanToIso', () => { + it('converts DD.MM.YYYY to ISO', () => { + expect(germanToIso('20.12.2024')).toBe('2024-12-20'); + }); + + it('returns empty string for partial input', () => { + expect(germanToIso('20.12')).toBe(''); + }); + + it('returns empty string for empty input', () => { + expect(germanToIso('')).toBe(''); + }); +}); + +// ─── formatGermanDateInput ──────────────────────────────────────────────────── + +describe('formatGermanDateInput – digit stream (no dots typed)', () => { + it('leaves 1–2 digits as-is', () => { + expect(formatGermanDateInput('2')).toBe('2'); + expect(formatGermanDateInput('20')).toBe('20'); + }); + + it('auto-inserts dot after 2 digits for 3–4 digit input', () => { + expect(formatGermanDateInput('201')).toBe('20.1'); + expect(formatGermanDateInput('2012')).toBe('20.12'); + }); + + it('auto-inserts two dots for 5–8 digit input', () => { + expect(formatGermanDateInput('20121')).toBe('20.12.1'); + expect(formatGermanDateInput('20122024')).toBe('20.12.2024'); + }); + + it('ignores digits beyond 8', () => { + expect(formatGermanDateInput('201220249')).toBe('20.12.2024'); + }); +}); + +describe('formatGermanDateInput – manual dot entry with padding', () => { + it('pads single-digit day to 2 digits when dot is typed after it', () => { + expect(formatGermanDateInput('3.')).toBe('03.'); + }); + + it('does not pad a 2-digit day', () => { + expect(formatGermanDateInput('03.')).toBe('03.'); + expect(formatGermanDateInput('20.')).toBe('20.'); + }); + + it('pads single-digit month to 2 digits when dot is typed after it', () => { + expect(formatGermanDateInput('03.3.')).toBe('03.03.'); + }); + + it('does not pad a 2-digit month', () => { + expect(formatGermanDateInput('03.12.')).toBe('03.12.'); + }); + + it('pads both day and month in a fully typed date', () => { + expect(formatGermanDateInput('3.3.2012')).toBe('03.03.2012'); + }); + + it('pads only day when month is already 2 digits', () => { + expect(formatGermanDateInput('3.12.2024')).toBe('03.12.2024'); + }); + + it('pads only month when day is already 2 digits', () => { + expect(formatGermanDateInput('20.3.2024')).toBe('20.03.2024'); + }); + + it('handles a complete date entered with manual dots and no padding needed', () => { + expect(formatGermanDateInput('20.12.2024')).toBe('20.12.2024'); + }); + + it('overflows excess day digits into month when dot follows', () => { + expect(formatGermanDateInput('123.')).toBe('12.3'); + }); + + it('caps year digits at 4', () => { + expect(formatGermanDateInput('03.03.20249')).toBe('03.03.2024'); + }); + + it('overflows excess month digits into year (digit stream then continue typing)', () => { + // User typed digits → auto-dot gave "20.12", then types "2" → raw becomes "20.122" + expect(formatGermanDateInput('20.122')).toBe('20.12.2'); + }); + + it('continues building year after overflow', () => { + expect(formatGermanDateInput('20.12.2024')).toBe('20.12.2024'); + }); +}); diff --git a/frontend/src/routes/AppNav.svelte b/frontend/src/routes/AppNav.svelte index aea54a29..8e17dbed 100644 --- a/frontend/src/routes/AppNav.svelte +++ b/frontend/src/routes/AppNav.svelte @@ -60,9 +60,9 @@ function handleOverlayKeydown(event: KeyboardEvent) { @@ -161,9 +161,9 @@ function handleOverlayKeydown(event: KeyboardEvent) { diff --git a/frontend/src/routes/admin/+layout.server.ts b/frontend/src/routes/admin/+layout.server.ts index 19405345..875c403a 100644 --- a/frontend/src/routes/admin/+layout.server.ts +++ b/frontend/src/routes/admin/+layout.server.ts @@ -1,8 +1,9 @@ import { error } from '@sveltejs/kit'; import { createApiClient } from '$lib/api.server'; import { getErrorMessage } from '$lib/errors'; +import type { components } from '$lib/generated/api'; -type UserGroup = { permissions: string[] }; +type UserGroup = components['schemas']['UserGroup']; function hasPerm(user: { groups?: UserGroup[] } | undefined, perm: string): boolean { return user?.groups?.some((g) => g.permissions.includes(perm)) ?? false; @@ -22,19 +23,35 @@ export async function load({ fetch, locals }) { if (!hasAnyAdminPerm(user)) throw error(403, getErrorMessage('FORBIDDEN')); const api = createApiClient(fetch); + + // TODO: replace with a dedicated /api/admin/stats endpoint that returns counts only, + // so the System page does not load full entity lists it does not render. const [usersResult, groupsResult, tagsResult] = await Promise.all([ api.GET('/api/users'), api.GET('/api/groups'), api.GET('/api/tags') ]); + if (!usersResult.response.ok) { + const code = (usersResult.error as unknown as { code?: string })?.code; + throw error(usersResult.response.status, getErrorMessage(code)); + } + if (!groupsResult.response.ok) { + const code = (groupsResult.error as unknown as { code?: string })?.code; + throw error(groupsResult.response.status, getErrorMessage(code)); + } + if (!tagsResult.response.ok) { + const code = (tagsResult.error as unknown as { code?: string })?.code; + throw error(tagsResult.response.status, getErrorMessage(code)); + } + return { userCount: (usersResult.data ?? []).length, groupCount: (groupsResult.data ?? []).length, tagCount: (tagsResult.data ?? []).length, canManageUsers: hasPerm(user, 'ADMIN_USER'), canManageTags: hasPerm(user, 'ADMIN_TAG'), - canManageGroups: hasPerm(user, 'ADMIN_PERMISSION'), + canManagePermissions: hasPerm(user, 'ADMIN_PERMISSION'), canRunMaintenance: hasPerm(user, 'ADMIN') }; } diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte index ba3e1849..abe0106f 100644 --- a/frontend/src/routes/admin/+layout.svelte +++ b/frontend/src/routes/admin/+layout.svelte @@ -12,7 +12,7 @@ let { data, children } = $props(); -mt-6: cancel the global layout's pt-6 on
Height fills from below the global header (64px) to bottom of viewport. --> -
+
diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index b9da7c26..728c6a69 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -36,7 +36,7 @@ onMount(() => { {/if} - {#if data.canManageGroups} + {#if data.canManagePermissions}
{m.admin_tab_groups()}
diff --git a/frontend/src/routes/admin/EntityNav.svelte b/frontend/src/routes/admin/EntityNav.svelte index ae57556b..6af4e062 100644 --- a/frontend/src/routes/admin/EntityNav.svelte +++ b/frontend/src/routes/admin/EntityNav.svelte @@ -1,4 +1,6 @@ @@ -55,8 +74,9 @@ function handleKeydown(event: KeyboardEvent) { data-flyout-trigger type="button" aria-label={m.admin_tab_users()} - onclick={() => (flyoutOpen = true)} - class="flex w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden + title={m.admin_tab_users()} + onclick={openFlyout} + class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden {isActive('users') ? 'border-brand-mint bg-white/10' : 'border-transparent hover:bg-white/5'}" @@ -115,14 +135,15 @@ function handleKeydown(event: KeyboardEvent) {
{/if} - {#if canManageGroups} + {#if canManagePermissions}
@@ -329,7 +352,7 @@ function handleKeydown(event: KeyboardEvent) { aria-modal="true" aria-label={m.admin_heading()} class="fixed top-0 left-12 z-50 flex h-full w-40 flex-col bg-brand-navy shadow-xl" - style="transform: translateX(0); transition: transform 180ms ease-out;" + transition:fly={{ x: -160, duration: 180 }} >
@@ -339,7 +362,7 @@ function handleKeydown(event: KeyboardEvent) { {#if canManageUsers} (flyoutOpen = false)} + onclick={closeFlyout} class="flex flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors {isActive('users') ? 'border-brand-mint bg-white/10' @@ -374,10 +397,10 @@ function handleKeydown(event: KeyboardEvent) { {/if} - {#if canManageGroups} + {#if canManagePermissions} (flyoutOpen = false)} + onclick={closeFlyout} class="flex flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors {isActive('groups') ? 'border-brand-mint bg-white/10' @@ -415,7 +438,7 @@ function handleKeydown(event: KeyboardEvent) { {#if canManageTags} (flyoutOpen = false)} + onclick={closeFlyout} class="flex flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors {isActive('tags') ? 'border-brand-mint bg-white/10' @@ -454,7 +477,7 @@ function handleKeydown(event: KeyboardEvent) { {#if canRunMaintenance} (flyoutOpen = false)} + onclick={closeFlyout} class="flex flex-col items-start justify-center gap-0.5 border-t border-l-[3px] border-white/10 px-3.5 py-2.5 transition-colors {isActive('system') ? 'border-brand-mint bg-white/10' diff --git a/frontend/src/routes/admin/entity-nav.svelte.spec.ts b/frontend/src/routes/admin/entity-nav.svelte.spec.ts index c9fea364..1441c372 100644 --- a/frontend/src/routes/admin/entity-nav.svelte.spec.ts +++ b/frontend/src/routes/admin/entity-nav.svelte.spec.ts @@ -15,7 +15,7 @@ const props = { tagCount: 8, canManageUsers: true, canManageTags: true, - canManageGroups: true, + canManagePermissions: true, canRunMaintenance: true }; diff --git a/frontend/src/routes/admin/groups/GroupsListPanel.svelte b/frontend/src/routes/admin/groups/GroupsListPanel.svelte index fe967f82..233a6943 100644 --- a/frontend/src/routes/admin/groups/GroupsListPanel.svelte +++ b/frontend/src/routes/admin/groups/GroupsListPanel.svelte @@ -16,17 +16,15 @@ let { autocollapse?: boolean; } = $props(); -let isCollapsed = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('admin_list_collapsed') === 'true' +let manualCollapse = $state( + typeof localStorage !== 'undefined' && + localStorage.getItem('admin_groups_list_collapsed') === 'true' ); - -$effect(() => { - if (autocollapse) isCollapsed = true; -}); +const isCollapsed = $derived(autocollapse || manualCollapse); $effect(() => { if (typeof localStorage !== 'undefined') { - localStorage.setItem('admin_list_collapsed', String(isCollapsed)); + localStorage.setItem('admin_groups_list_collapsed', String(manualCollapse)); } }); @@ -34,7 +32,7 @@ $effect(() => { {#if isCollapsed} +
+ + +
+ onapplyFilters()} + /> +
+
+ +
+ +
+ + onapplyFilters()} + class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink" + /> +
+ + +
+ + onapplyFilters()} + class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink" + /> +
+ + +
+ +
+
+
diff --git a/frontend/src/routes/korrespondenz/ConversationTimeline.svelte b/frontend/src/routes/korrespondenz/ConversationTimeline.svelte new file mode 100644 index 00000000..3502803d --- /dev/null +++ b/frontend/src/routes/korrespondenz/ConversationTimeline.svelte @@ -0,0 +1,180 @@ + + +{#if isBilateral && documents.length > 0} + +{/if} + +
+ {#each enrichedDocuments as { doc, year, showYearDivider, isOut } (doc.id)} + {#if showYearDivider && year !== null} +
+ {year} + {countsByYear.get(year) ?? 0} Briefe +
+ {/if} + + + + +
+
+ {doc.title || doc.originalFilename} +
+
+ {doc.documentDate ? formatDate(doc.documentDate) : '—'} + {#if doc.location} + · + {doc.location} + {/if} + {#if !receiverId} + · + {otherPartyName(doc)} + {/if} + +
+
+ + +
+ {/each} + + {#if canWrite} + + {/if} +
diff --git a/frontend/src/routes/korrespondenz/CorrespondentSuggestionsDropdown.svelte b/frontend/src/routes/korrespondenz/CorrespondentSuggestionsDropdown.svelte new file mode 100644 index 00000000..5a49a092 --- /dev/null +++ b/frontend/src/routes/korrespondenz/CorrespondentSuggestionsDropdown.svelte @@ -0,0 +1,112 @@ + + +
handleKeydown(e, e.currentTarget as HTMLElement)} +> + +
+ {m.conv_suggestions_heading()} +
+ + + {#if !loading} + {#each correspondents as person (person.id)} +
onselect(person.id)} + onkeydown={(e) => e.key === 'Enter' && onselect(person.id)} + > + + + + {person.lastName}, {person.firstName} +
+ {/each} + {/if} + + +
+ + +
onselect('')} + onkeydown={(e) => e.key === 'Enter' && onselect('')} + > + {m.conv_suggestions_all_label({ name: senderName })} +
+
diff --git a/frontend/src/routes/korrespondenz/CorrespondenzEmptyState.svelte b/frontend/src/routes/korrespondenz/CorrespondenzEmptyState.svelte new file mode 100644 index 00000000..b6a3ad64 --- /dev/null +++ b/frontend/src/routes/korrespondenz/CorrespondenzEmptyState.svelte @@ -0,0 +1,120 @@ + + +
+ +
+ +
+ + +

{m.conv_empty_heading()}

+ + +

+ {m.conv_empty_text()} +

+ + + + + + {#if recentPersons.length > 0} + +
+
+ oder +
+
+ +
+ + {m.conv_empty_recent_label()} + +
+ {#each recentPersons as person (person.id)} + + + {/each} +
+
+ {/if} +
diff --git a/frontend/src/routes/korrespondenz/CorrespondenzFilterControls.svelte b/frontend/src/routes/korrespondenz/CorrespondenzFilterControls.svelte new file mode 100644 index 00000000..b4971071 --- /dev/null +++ b/frontend/src/routes/korrespondenz/CorrespondenzFilterControls.svelte @@ -0,0 +1,115 @@ + + +
+ + + + + onapplyFilters()} + placeholder={m.conv_strip_from_placeholder()} + class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none {fromDate ? 'border-primary' : 'border-line'}" + /> + + + + + onapplyFilters()} + placeholder={m.conv_strip_to_placeholder()} + class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none {toDate ? 'border-primary' : 'border-line'}" + /> + + + + {m.conv_letters_count({ count: documentCount ?? 0 })} + + + + +
diff --git a/frontend/src/routes/korrespondenz/CorrespondenzPersonBar.svelte b/frontend/src/routes/korrespondenz/CorrespondenzPersonBar.svelte new file mode 100644 index 00000000..7a1da482 --- /dev/null +++ b/frontend/src/routes/korrespondenz/CorrespondenzPersonBar.svelte @@ -0,0 +1,129 @@ + + +
+ +
+ onapplyFilters()} + /> +
+ + + + + +
+ { + showSuggestions = false; + onapplyFilters(); + }} + onfocused={handleCorrespondentFocused} + /> + {#if showSuggestions && senderId && !receiverId} + (showSuggestions = false)} + /> + {/if} +
+
diff --git a/frontend/src/routes/korrespondenz/SinglePersonHintBar.svelte b/frontend/src/routes/korrespondenz/SinglePersonHintBar.svelte new file mode 100644 index 00000000..034e0284 --- /dev/null +++ b/frontend/src/routes/korrespondenz/SinglePersonHintBar.svelte @@ -0,0 +1,50 @@ + + +
+ + + {#if hasDateFilter} + {senderName} + · + {fromYear}–{toYear} + · + {sortLabel} + {:else} + Alle Briefe von {senderName} — wähle einen Korrespondenten oben um einzugrenzen + {/if} +
diff --git a/frontend/src/routes/korrespondenz/page.server.spec.ts b/frontend/src/routes/korrespondenz/page.server.spec.ts new file mode 100644 index 00000000..f8dab9ee --- /dev/null +++ b/frontend/src/routes/korrespondenz/page.server.spec.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { load } from './+page.server'; + +vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() })); +vi.mock('$lib/errors', () => ({ getErrorMessage: (code: string) => code ?? 'Unknown error' })); + +import { createApiClient } from '$lib/api.server'; + +const writeUser = { groups: [{ permissions: ['WRITE_ALL'] }] }; +const readUser = { groups: [{ permissions: ['READ_ALL'] }] }; + +function makeUrl(params: Record = {}): URL { + const url = new URL('http://x/korrespondenz'); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + return url; +} + +function mockApi(calls: { ok: boolean; data?: unknown; status?: number }[]) { + const GET = vi.fn(); + for (const call of calls) { + GET.mockResolvedValueOnce({ + response: { ok: call.ok, status: call.status ?? (call.ok ? 200 : 500) }, + data: call.data, + error: call.ok ? undefined : { code: 'INTERNAL_ERROR' } + }); + } + vi.mocked(createApiClient).mockReturnValue({ GET } as ReturnType); + return GET; +} + +beforeEach(() => vi.clearAllMocks()); + +// ─── No senderId ────────────────────────────────────────────────────────────── + +describe('korrespondenz load — no senderId', () => { + it('returns empty documents without calling the conversation endpoint', async () => { + const GET = mockApi([]); + + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + locals: { user: readUser } + }); + + expect(result.documents).toEqual([]); + expect(GET).not.toHaveBeenCalled(); + }); +}); + +// ─── With senderId, no receiverId ──────────────────────────────────────────── + +describe('korrespondenz load — senderId set, no receiverId', () => { + it('calls the conversation endpoint and the sender person endpoint', async () => { + const docs = [{ id: 'd1', title: 'Testbrief' }]; + const GET = mockApi([ + { ok: true, data: docs }, + { ok: true, data: { firstName: 'Hans', lastName: 'Müller' } } + ]); + + const result = await load({ + url: makeUrl({ senderId: 'p1' }), + fetch: vi.fn() as unknown as typeof fetch, + locals: { user: readUser } + }); + + expect(result.documents).toEqual(docs); + expect(result.initialValues.senderName).toBe('Hans Müller'); + expect(result.initialValues.receiverName).toBe(''); + expect(GET).toHaveBeenCalledTimes(2); + }); +}); + +// ─── With senderId and receiverId ──────────────────────────────────────────── + +describe('korrespondenz load — senderId and receiverId set', () => { + it('calls conversation, sender person, and receiver person endpoints', async () => { + const GET = mockApi([ + { ok: true, data: [] }, + { ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }, + { ok: true, data: { firstName: 'Anna', lastName: 'Schmidt' } } + ]); + + const result = await load({ + url: makeUrl({ senderId: 'p1', receiverId: 'p2' }), + fetch: vi.fn() as unknown as typeof fetch, + locals: { user: readUser } + }); + + expect(result.initialValues.senderName).toBe('Hans Müller'); + expect(result.initialValues.receiverName).toBe('Anna Schmidt'); + expect(GET).toHaveBeenCalledTimes(3); + }); +}); + +// ─── canWrite derivation ───────────────────────────────────────────────────── + +describe('korrespondenz load — canWrite', () => { + it('derives canWrite true from WRITE_ALL permission', async () => { + mockApi([ + { ok: true, data: [] }, + { ok: true, data: { firstName: 'Hans', lastName: 'Müller' } } + ]); + + const result = await load({ + url: makeUrl({ senderId: 'p1' }), + fetch: vi.fn() as unknown as typeof fetch, + locals: { user: writeUser } + }); + + expect(result.canWrite).toBe(true); + }); + + it('derives canWrite false when user lacks WRITE_ALL', async () => { + mockApi([ + { ok: true, data: [] }, + { ok: true, data: { firstName: 'Hans', lastName: 'Müller' } } + ]); + + const result = await load({ + url: makeUrl({ senderId: 'p1' }), + fetch: vi.fn() as unknown as typeof fetch, + locals: { user: readUser } + }); + + expect(result.canWrite).toBe(false); + }); +}); + +// ─── Backend error propagation ──────────────────────────────────────────────── + +describe('korrespondenz load — backend error', () => { + it('throws when the conversation endpoint returns non-ok', async () => { + mockApi([ + { ok: false, status: 500 }, + { ok: true, data: { firstName: 'Hans', lastName: 'Müller' } } + ]); + + await expect( + load({ + url: makeUrl({ senderId: 'p1' }), + fetch: vi.fn() as unknown as typeof fetch, + locals: { user: readUser } + }) + ).rejects.toMatchObject({ status: 500 }); + }); +}); diff --git a/frontend/src/routes/korrespondenz/page.svelte.spec.ts b/frontend/src/routes/korrespondenz/page.svelte.spec.ts new file mode 100644 index 00000000..fb0e3c33 --- /dev/null +++ b/frontend/src/routes/korrespondenz/page.svelte.spec.ts @@ -0,0 +1,246 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import Page from './+page.svelte'; + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); + +afterEach(cleanup); + +// ─── Test data ──────────────────────────────────────────────────────────────── + +const baseData = { + user: undefined, + canWrite: true, + canAnnotate: false, + documents: [], + initialValues: { senderName: '', receiverName: '' }, + filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const } +}; + +const withSender = { + ...baseData, + initialValues: { senderName: 'Hans Müller', receiverName: '' }, + filters: { ...baseData.filters, senderId: 'p1' } +}; + +const withPersons = { + ...baseData, + initialValues: { senderName: 'Hans Müller', receiverName: 'Anna Schmidt' }, + filters: { ...baseData.filters, senderId: 'p1', receiverId: 'p2' } +}; + +const makeDoc = (overrides: Record = {}) => ({ + id: 'd1', + title: 'Testbrief', + originalFilename: 'testbrief.pdf', + status: 'UPLOADED' as const, + documentDate: '1923-04-12', + location: 'Berlin', + metadataComplete: false, + sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' }, + receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }], + tags: [], + transcription: undefined, + filePath: undefined, + createdAt: '1923-04-12T00:00:00Z', + updatedAt: '1923-04-12T00:00:00Z', + ...overrides +}); + +const withDocs = { + ...withPersons, + documents: [makeDoc()] +}; + +// ─── Empty state (no senderId) ──────────────────────────────────────────────── + +describe('Korrespondenz page – empty state', () => { + it('shows the search heading when no person is selected', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByText(/Korrespondenz durchsuchen/i)).toBeInTheDocument(); + }); + + it('shows the empty-search button', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByTestId('conv-empty-search')).toBeInTheDocument(); + }); + + it('does not show the new document link when no person is selected', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument(); + }); + + it('does not show a year divider when no person is selected', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByTestId('year-divider')).not.toBeInTheDocument(); + }); +}); + +// ─── Recent persons chips ───────────────────────────────────────────────────── + +describe('Korrespondenz page – recent persons', () => { + it('shows recent person chips from localStorage', async () => { + localStorage.setItem( + 'korrespondenz_recent_persons', + JSON.stringify([{ id: 'r1', name: 'Clara Braun' }]) + ); + render(Page, { data: baseData }); + await expect.element(page.getByText('Clara Braun')).toBeInTheDocument(); + localStorage.removeItem('korrespondenz_recent_persons'); + }); + + it('does not crash when localStorage contains corrupt JSON', async () => { + localStorage.setItem('korrespondenz_recent_persons', '}{not valid json'); + render(Page, { data: baseData }); + // Empty state heading is still shown — no chip list crash + await expect.element(page.getByText(/Korrespondenz durchsuchen/i)).toBeInTheDocument(); + localStorage.removeItem('korrespondenz_recent_persons'); + }); +}); + +// ─── Single-person hint bar ─────────────────────────────────────────────────── + +describe('Korrespondenz page – single-person hint bar', () => { + it('shows hint bar when only senderId is set', async () => { + render(Page, { data: withSender }); + await expect.element(page.getByText(/Alle Briefe von Hans Müller/i)).toBeInTheDocument(); + }); + + it('does not show hint bar when both persons are set', async () => { + render(Page, { data: { ...withPersons, documents: [makeDoc()] } }); + await expect.element(page.getByText(/Alle Briefe von Hans Müller/i)).not.toBeInTheDocument(); + }); + + it('does not show hint bar when no person is set', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByText(/Alle Briefe von/i)).not.toBeInTheDocument(); + }); +}); + +// ─── Filter controls disabled state ────────────────────────────────────────── + +describe('Korrespondenz page – filter strip Row 2 disabled state', () => { + it('renders filter controls with aria-disabled when no senderId', async () => { + render(Page, { data: baseData }); + const strip = document.querySelector('[aria-disabled="true"]'); + expect(strip).not.toBeNull(); + }); + + it('filter controls are not aria-disabled when senderId is set', async () => { + render(Page, { data: withSender }); + const strip = document.querySelector('[aria-disabled="false"]'); + expect(strip).not.toBeNull(); + }); +}); + +// ─── Strip letter count ─────────────────────────────────────────────────────── + +describe('Korrespondenz page – strip letter count', () => { + it('shows 0 Briefe when senderId is set but no documents', async () => { + render(Page, { data: withSender }); + await expect.element(page.getByTestId('conv-strip-count')).toHaveTextContent('0 Briefe'); + }); + + it('shows correct count when documents are loaded', async () => { + render(Page, { data: { ...withPersons, documents: [makeDoc()] } }); + await expect.element(page.getByTestId('conv-strip-count')).toHaveTextContent('1 Briefe'); + }); +}); + +// ─── No results ─────────────────────────────────────────────────────────────── + +describe('Korrespondenz page – no results', () => { + it('shows "no documents found" when a person is selected but there are no documents', async () => { + render(Page, { data: withSender }); + await expect.element(page.getByText(/Keine Dokumente gefunden/i)).toBeInTheDocument(); + }); +}); + +// ─── Swap button ────────────────────────────────────────────────────────────── + +describe('Korrespondenz page – swap button', () => { + it('swap button is invisible when only one person is set', async () => { + render(Page, { data: withSender }); + const btn = document.querySelector('[data-testid="conv-swap-btn"]'); + expect(btn).not.toBeNull(); + // opacity-0 is applied via class when swapVisible is false + expect(btn!.className).toMatch(/opacity-0/); + }); + + it('swap button is visible when both persons are set', async () => { + render(Page, { data: withPersons }); + const btn = document.querySelector('[data-testid="conv-swap-btn"]'); + expect(btn).not.toBeNull(); + expect(btn!.className).not.toMatch(/opacity-0/); + }); + + it('calls goto with swapped sender and receiver when clicked', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + render(Page, { data: withPersons }); + document.querySelector('[data-testid="conv-swap-btn"]')!.click(); + expect(goto).toHaveBeenCalledWith(expect.stringContaining('senderId=p2'), expect.anything()); + expect(goto).toHaveBeenCalledWith(expect.stringContaining('receiverId=p1'), expect.anything()); + }); +}); + +// ─── Year dividers ──────────────────────────────────────────────────────────── + +describe('Korrespondenz page – year dividers', () => { + it('renders a year divider for the first document', async () => { + render(Page, { data: withDocs }); + await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923'); + }); + + it('renders a divider for each new year in the document list', async () => { + const data = { + ...withPersons, + documents: [ + makeDoc({ documentDate: '1923-04-12' }), + makeDoc({ id: 'd2', documentDate: '1965-08-03' }) + ] + }; + render(Page, { data }); + await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923'); + await expect.element(page.getByTestId('year-divider').nth(1)).toHaveTextContent('1965'); + }); + + it('does not render a second divider for documents from the same year', async () => { + const data = { + ...withPersons, + documents: [ + makeDoc({ documentDate: '1923-04-12' }), + makeDoc({ id: 'd2', documentDate: '1923-09-01' }) + ] + }; + render(Page, { data }); + await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923'); + await expect.element(page.getByTestId('year-divider').nth(1)).not.toBeInTheDocument(); + }); +}); + +// ─── New document link ──────────────────────────────────────────────────────── + +describe('Korrespondenz page – new document link', () => { + it('shows the link with correct href for a write user (bilateral)', async () => { + render(Page, { data: { ...withDocs, canWrite: true } }); + const link = page.getByTestId('conv-new-doc-link'); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute('href', expect.stringContaining('senderId=p1')); + await expect.element(link).toHaveAttribute('href', expect.stringContaining('receiverId=p2')); + }); + + it('shows the link with correct href for single-person mode', async () => { + render(Page, { data: { ...withSender, documents: [makeDoc()], canWrite: true } }); + const link = page.getByTestId('conv-new-doc-link'); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute('href', expect.stringContaining('senderId=p1')); + await expect.element(link).not.toHaveAttribute('href', expect.stringContaining('receiverId')); + }); + + it('hides the link for a read-only user', async () => { + render(Page, { data: { ...withDocs, canWrite: false } }); + await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte b/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte index fc0cc90b..b6f0b01d 100644 --- a/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte +++ b/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte @@ -30,7 +30,7 @@ function initials(name: string): string {
{#each coCorrespondents as c (c.id)}