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 90ab0bd0..7b49a148 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -11,6 +11,7 @@ import java.util.UUID; +import io.swagger.v3.oas.annotations.Parameter; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; import org.raddatz.familienarchiv.dto.DocumentVersionSummary; import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO; @@ -167,7 +168,7 @@ public class DocumentController { @GetMapping("/incomplete") public List getIncomplete( - @RequestParam(defaultValue = "10") int size) { + @Parameter(description = "Maximum number of results") @RequestParam(defaultValue = "10") int size) { return documentService.findIncompleteDocuments(size); } @@ -178,6 +179,12 @@ public class DocumentController { .orElse(ResponseEntity.noContent().build()); } + @GetMapping("/recent-activity") + public ResponseEntity> getRecentActivity( + @RequestParam(defaultValue = "5") int size) { + return ResponseEntity.ok(documentService.getRecentActivity(size)); + } + @GetMapping("/search") public ResponseEntity> search( @RequestParam(required = false) String q, @@ -186,7 +193,7 @@ public class DocumentController { @RequestParam(required = false) UUID senderId, @RequestParam(required = false) UUID receiverId, @RequestParam(required = false, name = "tag") List tags, - @RequestParam(required = false) DocumentStatus status) { + @Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status) { return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, status)); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/NotificationRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/NotificationRepository.java index 7de51cd8..b2759a99 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/NotificationRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/NotificationRepository.java @@ -15,6 +15,9 @@ public interface NotificationRepository extends JpaRepository findByRecipientIdOrderByCreatedAtDesc(UUID recipientId, Pageable pageable); + Page findByRecipientIdAndTypeOrderByCreatedAtDesc( + UUID recipientId, NotificationType type, Pageable pageable); + Page findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc( UUID recipientId, NotificationType type, Pageable pageable); 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 ae0e360d..e1c7434d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -260,6 +260,13 @@ public class DocumentService { return documentRepository.save(doc); } + // 0. Zuletzt aktive Dokumente (sortiert nach updatedAt DESC) + public List getRecentActivity(int size) { + return documentRepository.findAll( + PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "updatedAt")) + ).getContent(); + } + // 1. Allgemeine Suche (für das Suchfeld im Frontend) public List searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List tags, DocumentStatus status) { Specification spec = Specification.where(hasText(text)) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/NotificationService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/NotificationService.java index 6df48bf8..2f88931d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/NotificationService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/NotificationService.java @@ -98,6 +98,10 @@ public class NotificationService { return notificationRepository.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(userId, type, pageable) .map(this::toDTO); } + if (type != null) { + return notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc(userId, type, pageable) + .map(this::toDTO); + } return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable) .map(this::toDTO); } diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index fb1237fb..aa4c8ceb 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -12,6 +12,7 @@ spring: enabled: false # Managed explicitly via FlywayConfig bean jpa: + open-in-view: false # Prevents holding DB connections for the full HTTP request lifecycle hibernate: ddl-auto: none properties: 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 94284d48..1166d873 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -406,6 +406,38 @@ class DocumentControllerTest { .andExpect(status().isNoContent()); } + // ─── GET /api/documents/recent-activity ────────────────────────────────── + + @Test + void getRecentActivity_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/documents/recent-activity")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getRecentActivity_returnsOkWithDocuments() throws Exception { + Document doc1 = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build(); + Document doc2 = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build(); + when(documentService.getRecentActivity(5)).thenReturn(List.of(doc1, doc2)); + + mockMvc.perform(get("/api/documents/recent-activity").param("size", "5")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].title").value("Alpha")) + .andExpect(jsonPath("$[1].title").value("Beta")); + } + + @Test + @WithMockUser + void getRecentActivity_appliesDefaultSizeOfFive_whenSizeParamOmitted() throws Exception { + when(documentService.getRecentActivity(5)).thenReturn(List.of()); + + mockMvc.perform(get("/api/documents/recent-activity")) + .andExpect(status().isOk()); + + verify(documentService).getRecentActivity(5); + } + // ─── GET /api/documents/{id}/versions ──────────────────────────────────── @Test 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 04cab9d1..845646b7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java @@ -152,6 +152,23 @@ class DocumentRepositoryTest { assertThat(documentRepository.countByMetadataCompleteFalse()).isEqualTo(1); } + // ─── findAll (PageRequest) — recent activity ────────────────────────────── + + @Test + void findAll_withPageRequest_returnsOnlySizeRows_notFullTable() { + for (int i = 0; i < 10; i++) { + documentRepository.save(Document.builder() + .title("Doc " + i).originalFilename("doc" + i + ".pdf") + .status(DocumentStatus.PLACEHOLDER).build()); + } + + Page result = documentRepository.findAll( + PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "updatedAt"))); + + assertThat(result.getContent()).hasSize(3); + assertThat(result.getTotalElements()).isEqualTo(10); + } + // ─── findByMetadataCompleteFalse (Pageable) ─────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/NotificationRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/NotificationRepositoryTest.java index c64ba2db..0f68d992 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/NotificationRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/NotificationRepositoryTest.java @@ -79,6 +79,24 @@ class NotificationRepositoryTest { assertThat(result.getTotalElements()).isEqualTo(5); } + // ─── findByRecipientIdAndType (without read filter) ────────────────────── + + @Test + void findByType_returnsBothReadAndUnreadMentions() { + notificationRepository.save(mention(userA, false)); // unread + notificationRepository.save(mention(userA, true)); // read — should also be included + notificationRepository.save(reply(userA, false)); // REPLY — excluded + notificationRepository.save(mention(userB, false)); // different user — excluded + + Page result = notificationRepository + .findByRecipientIdAndTypeOrderByCreatedAtDesc( + userA.getId(), NotificationType.MENTION, Pageable.ofSize(10)); + + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent()).allMatch(n -> n.getType() == NotificationType.MENTION); + assertThat(result.getContent()).allMatch(n -> n.getRecipient().getId().equals(userA.getId())); + } + // ─── helpers ───────────────────────────────────────────────────────────── private Notification mention(AppUser recipient, boolean read) { 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 80910432..fb86d2a8 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -1213,4 +1213,35 @@ class DocumentServiceTest { verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)); } + + // ─── getRecentActivity ──────────────────────────────────────────────────── + + @Test + void getRecentActivity_returnsMostRecentlyUpdatedDocuments() { + Document doc1 = Document.builder().id(UUID.randomUUID()).title("Oldest").build(); + Document doc2 = Document.builder().id(UUID.randomUUID()).title("Middle").build(); + Document doc3 = Document.builder().id(UUID.randomUUID()).title("Newest").build(); + + Page page = new PageImpl<>(List.of(doc3, doc2)); + when(documentRepository.findAll(any(Pageable.class))).thenReturn(page); + + List result = documentService.getRecentActivity(2); + + assertThat(result).hasSize(2); + assertThat(result).containsExactly(doc3, doc2); + } + + @Test + void getRecentActivity_usesPageRequestWithSizeLimit_notFindAll() { + Page page = new PageImpl<>(List.of()); + when(documentRepository.findAll(any(Pageable.class))).thenReturn(page); + + documentService.getRecentActivity(3); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class); + verify(documentRepository).findAll(captor.capture()); + assertThat(captor.getValue().getPageSize()).isEqualTo(3); + assertThat(captor.getValue().getSort()) + .isEqualTo(Sort.by(Sort.Direction.DESC, "updatedAt")); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java index 28badf84..892d35d8 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java @@ -386,6 +386,39 @@ class NotificationServiceTest { verify(notificationRepository, never()).findByRecipientIdOrderByCreatedAtDesc(any(), any()); } + @Test + void getNotifications_withTypeOnly_usesTypeFilteredRepoMethod() { + when(notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc( + eq(userA.getId()), eq(NotificationType.MENTION), any())) + .thenReturn(Page.empty()); + + notificationService.getNotifications(userA.getId(), NotificationType.MENTION, null, Pageable.ofSize(5)); + + verify(notificationRepository).findByRecipientIdAndTypeOrderByCreatedAtDesc( + eq(userA.getId()), eq(NotificationType.MENTION), any()); + verify(notificationRepository, never()).findByRecipientIdOrderByCreatedAtDesc(any(), any()); + verify(notificationRepository, never()) + .findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(any(), any(), any()); + } + + @Test + void getNotifications_withTypeAndReadTrue_fallsBackToTypeOnlyQuery() { + // read=true with a type filter falls through to the type-only branch — + // it returns all notifications of that type (both read and unread). + // The read=true filter is intentionally not supported on the backend; + // callers that need only-read results must filter client-side. + when(notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc( + eq(userA.getId()), eq(NotificationType.MENTION), any())) + .thenReturn(Page.empty()); + + notificationService.getNotifications(userA.getId(), NotificationType.MENTION, true, Pageable.ofSize(5)); + + verify(notificationRepository).findByRecipientIdAndTypeOrderByCreatedAtDesc( + eq(userA.getId()), eq(NotificationType.MENTION), any()); + verify(notificationRepository, never()) + .findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(any(), any(), any()); + } + // ─── private helpers ────────────────────────────────────────────────────── private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId, String authorName) { diff --git a/frontend/e2e/dashboard-screenshots.spec.ts b/frontend/e2e/dashboard-screenshots.spec.ts new file mode 100644 index 00000000..86b745d4 --- /dev/null +++ b/frontend/e2e/dashboard-screenshots.spec.ts @@ -0,0 +1,62 @@ +/** + * Dashboard proofshots — seeds the admin account with test data so every + * widget is visible, then captures 6 screenshots (3 viewports × 2 themes). + * + * Seeded data is removed in afterAll so it doesn't pollute other tests. + */ +import { test } from '@playwright/test'; +import { execSync } from 'child_process'; +import { captureProofshots } from './proofshots'; + +// A real document that exists in the dev DB (most recently updated) +const SEED_DOC_ID = '24580ce9-9765-40b1-ac59-b0ab15160ce0'; +const SEED_DOC_TITLE = 'Brief aus dem Krieg'; + +// Real comment IDs used as reference_id for deep-linking +const COMMENT_IDS = [ + '46c5171f-1721-4085-a7ed-1eef7b4effb8', + 'a09cefe4-ddf8-47fa-addc-5c582183b459' +]; + +const psql = (sql: string) => + execSync( + `docker exec archive-db psql -U archive_user family_archive_db -c "${sql.replace(/"/g, '\\"')}"` + ); + +test.beforeAll(() => { + // Insert a MENTION and a REPLY notification for the admin user so the + // notifications widget is populated in the screenshots. + psql(` + INSERT INTO notifications (recipient_id, type, document_id, reference_id, read, actor_name) + SELECT id, 'MENTION', '${SEED_DOC_ID}', '${COMMENT_IDS[0]}', false, 'Berit Hoffmann' + FROM users WHERE username = 'admin'; + + INSERT INTO notifications (recipient_id, type, document_id, reference_id, read, actor_name) + SELECT id, 'REPLY', '${SEED_DOC_ID}', '${COMMENT_IDS[1]}', false, 'Marcel Raddatz' + FROM users WHERE username = 'admin'; + `); +}); + +test.afterAll(() => { + // Remove only the seeded rows (identified by the sentinel actor names) + psql(` + DELETE FROM notifications + WHERE actor_name IN ('Berit Hoffmann', 'Marcel Raddatz') + AND recipient_id = (SELECT id FROM users WHERE username = 'admin'); + `); +}); + +captureProofshots('/', 'dashboard', { + setup: async (page) => { + // Navigate to '/' first so the browser has an origin for localStorage, + // then inject the lastVisited entry directly — no document page load needed. + await page.goto('/'); + await page.waitForLoadState('domcontentloaded'); + await page.evaluate( + ({ id, title }) => { + localStorage.setItem('familienarchiv.lastVisited', JSON.stringify({ id, title })); + }, + { id: SEED_DOC_ID, title: SEED_DOC_TITLE } + ); + } +}); diff --git a/frontend/e2e/proofshots.ts b/frontend/e2e/proofshots.ts new file mode 100644 index 00000000..0e34abeb --- /dev/null +++ b/frontend/e2e/proofshots.ts @@ -0,0 +1,85 @@ +/** + * Shared proofshot helper for Playwright. + * + * Basic usage: + * import { captureProofshots } from './proofshots'; + * captureProofshots('/persons', 'persons'); + * + * With per-test setup (e.g. seed localStorage before navigation): + * captureProofshots('/persons', 'persons', { + * setup: async (page) => { + * await page.goto('/persons/some-id'); // populates any localStorage state + * } + * }); + * + * The setup callback runs before each screenshot's page.goto(url), so any + * localStorage values it writes persist into the main navigation. + * + * Screenshots are saved to proofshot-artifacts/{featureName}/. + */ +import { type Page, test } from '@playwright/test'; +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const viewports = [ + { name: 'mobile', width: 390, height: 844 }, + { name: 'tablet', width: 768, height: 1024 }, + { name: 'desktop', width: 1440, height: 900 } +]; + +interface ProofshotOptions { + /** + * Optional async callback that runs before each screenshot's page.goto(url). + * Use it to seed localStorage, visit a prerequisite page, etc. + */ + setup?: (page: Page) => Promise; +} + +/** + * Registers Playwright tests that navigate to `url`, apply each theme, + * and capture full-page screenshots at all standard viewports. + * + * @param url The path to screenshot (e.g. '/', '/persons', '/admin') + * @param featureName Used as the output directory name and screenshot file prefix + * @param options Optional setup callback and other options + */ +export function captureProofshots( + url: string, + featureName: string, + options?: ProofshotOptions +): void { + const outDir = path.join(__dirname, '../../proofshot-artifacts', featureName); + fs.mkdirSync(outDir, { recursive: true }); + + for (const vp of viewports) { + for (const theme of ['light', 'dark'] as const) { + test(`${featureName} – ${vp.name} – ${theme}`, async ({ page }) => { + // Run optional setup before main navigation (e.g. seed localStorage) + if (options?.setup) { + await options.setup(page); + } + + await page.setViewportSize({ width: vp.width, height: vp.height }); + await page.goto(url); + + // Apply theme via data-theme attribute and localStorage + await page.evaluate((t) => { + document.documentElement.setAttribute('data-theme', t); + localStorage.setItem('theme', t); + }, theme); + + // 'networkidle' is unreliable in SvelteKit dev mode due to the HMR WebSocket. + await page.waitForLoadState('domcontentloaded'); + await page.waitForSelector('main', { state: 'visible' }); + + await page.screenshot({ + path: path.join(outDir, `${featureName}-${vp.name}-${theme}.png`), + fullPage: true + }); + }); + } + } +} diff --git a/frontend/messages/de.json b/frontend/messages/de.json index d5745100..9424d65f 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -312,5 +312,13 @@ "page_title_persons": "Personen", "page_title_admin": "Administration", "page_title_login": "Anmelden", - "page_title_error": "Fehler – Familienarchiv" + "page_title_error": "Fehler – Familienarchiv", + "dashboard_notifications_heading": "Benachrichtigungen", + "dashboard_notification_mentioned": "erwähnt Sie", + "dashboard_notification_replied": "hat geantwortet", + "dashboard_needs_metadata_heading": "Metadaten fehlen", + "dashboard_needs_metadata_show_all": "Alle anzeigen", + "dashboard_recent_heading": "Zuletzt aktiv", + "dashboard_resume_label": "Zuletzt geöffnet:", + "dashboard_resume_fallback": "Unbekanntes Dokument" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 66676df3..4e1e071d 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -312,5 +312,13 @@ "page_title_persons": "Persons", "page_title_admin": "Administration", "page_title_login": "Sign in", - "page_title_error": "Error – Family Archive" + "page_title_error": "Error – Family Archive", + "dashboard_notifications_heading": "Notifications", + "dashboard_notification_mentioned": "mentioned you", + "dashboard_notification_replied": "replied", + "dashboard_needs_metadata_heading": "Missing Metadata", + "dashboard_needs_metadata_show_all": "Show all", + "dashboard_recent_heading": "Recent Activity", + "dashboard_resume_label": "Last opened:", + "dashboard_resume_fallback": "Unknown document" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 29e1d755..14c9f868 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -312,5 +312,13 @@ "page_title_persons": "Personas", "page_title_admin": "Administración", "page_title_login": "Iniciar sesión", - "page_title_error": "Error – Archivo familiar" + "page_title_error": "Error – Archivo familiar", + "dashboard_notifications_heading": "Notificaciones", + "dashboard_notification_mentioned": "te mencionó", + "dashboard_notification_replied": "respondió", + "dashboard_needs_metadata_heading": "Metadatos incompletos", + "dashboard_needs_metadata_show_all": "Ver todos", + "dashboard_recent_heading": "Actividad reciente", + "dashboard_resume_label": "Último abierto:", + "dashboard_resume_fallback": "Documento desconocido" } diff --git a/frontend/src/lib/components/DashboardMentions.svelte b/frontend/src/lib/components/DashboardMentions.svelte index 7339879d..b57e2ad2 100644 --- a/frontend/src/lib/components/DashboardMentions.svelte +++ b/frontend/src/lib/components/DashboardMentions.svelte @@ -1,8 +1,12 @@ {#if recentDocs.length > 0}

- Zuletzt hinzugefügt + {m.dashboard_recent_heading()}

{#each recentDocs as doc (doc.id)}
{doc.title} - {#if doc.documentDate} + {#if doc.updatedAt} - {formatDate(doc.documentDate)} + {formatDate(doc.updatedAt)} {/if}
diff --git a/frontend/src/lib/components/DashboardRecentDocuments.svelte.spec.ts b/frontend/src/lib/components/DashboardRecentDocuments.svelte.spec.ts index 74ecf14d..44e932db 100644 --- a/frontend/src/lib/components/DashboardRecentDocuments.svelte.spec.ts +++ b/frontend/src/lib/components/DashboardRecentDocuments.svelte.spec.ts @@ -9,12 +9,12 @@ afterEach(cleanup); type Document = { id: string; title: string; - documentDate?: string; + updatedAt?: string; sender?: { id: string; firstName: string; lastName: string }; }; -function makeDoc(id: string, title: string, date?: string): Document { - return { id, title, documentDate: date }; +function makeDoc(id: string, title: string, updatedAt?: string): Document { + return { id, title, updatedAt }; } describe('DashboardRecentDocuments', () => { diff --git a/frontend/src/lib/components/DashboardResumeStrip.svelte b/frontend/src/lib/components/DashboardResumeStrip.svelte index 218cfa2d..1e7e9e31 100644 --- a/frontend/src/lib/components/DashboardResumeStrip.svelte +++ b/frontend/src/lib/components/DashboardResumeStrip.svelte @@ -1,5 +1,6 @@