Compare commits

...

2 Commits

Author SHA1 Message Date
Marcel
2171c3702a feat(#145): switch dashboard to show last-activity documents
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 1m57s
CI / Backend Unit Tests (pull_request) Failing after 2m19s
CI / E2E Tests (pull_request) Failing after 3h14m27s
Replace recent-by-creation fetch with GET /api/documents/recent-activity
(sorted by updatedAt) in the dashboard. Update DashboardRecentDocuments
component to use doc.updatedAt, update i18n heading to "Zuletzt aktiv" /
"Recent Activity" / "Actividad reciente", and regenerate API types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 11:30:08 +02:00
Marcel
6976daa910 feat(#145): add recent-activity endpoint sorted by updatedAt
Add GET /api/documents/recent-activity?size=N endpoint that returns
the N most recently updated documents sorted by updatedAt DESC.
Includes TDD: failing tests written first, then production code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 11:29:34 +02:00
11 changed files with 107 additions and 13 deletions

View File

@@ -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.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary; import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO; import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
@@ -167,7 +168,7 @@ public class DocumentController {
@GetMapping("/incomplete") @GetMapping("/incomplete")
public List<IncompleteDocumentDTO> getIncomplete( public List<IncompleteDocumentDTO> getIncomplete(
@RequestParam(defaultValue = "10") int size) { @Parameter(description = "Maximum number of results") @RequestParam(defaultValue = "10") int size) {
return documentService.findIncompleteDocuments(size); return documentService.findIncompleteDocuments(size);
} }
@@ -178,6 +179,12 @@ public class DocumentController {
.orElse(ResponseEntity.noContent().build()); .orElse(ResponseEntity.noContent().build());
} }
@GetMapping("/recent-activity")
public ResponseEntity<List<Document>> getRecentActivity(
@RequestParam(defaultValue = "5") int size) {
return ResponseEntity.ok(documentService.getRecentActivity(size));
}
@GetMapping("/search") @GetMapping("/search")
public ResponseEntity<List<Document>> search( public ResponseEntity<List<Document>> search(
@RequestParam(required = false) String q, @RequestParam(required = false) String q,
@@ -186,7 +193,7 @@ public class DocumentController {
@RequestParam(required = false) UUID senderId, @RequestParam(required = false) UUID senderId,
@RequestParam(required = false) UUID receiverId, @RequestParam(required = false) UUID receiverId,
@RequestParam(required = false, name = "tag") List<String> tags, @RequestParam(required = false, name = "tag") List<String> 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)); return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, status));
} }

View File

@@ -260,6 +260,12 @@ public class DocumentService {
return documentRepository.save(doc); return documentRepository.save(doc);
} }
// 0. Zuletzt aktive Dokumente (sortiert nach updatedAt DESC)
public List<Document> getRecentActivity(int size) {
return documentRepository.findAll(Sort.by(Sort.Direction.DESC, "updatedAt"))
.stream().limit(size).toList();
}
// 1. Allgemeine Suche (für das Suchfeld im Frontend) // 1. Allgemeine Suche (für das Suchfeld im Frontend)
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, DocumentStatus status) { public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, DocumentStatus status) {
Specification<Document> spec = Specification.where(hasText(text)) Specification<Document> spec = Specification.where(hasText(text))

View File

@@ -406,6 +406,27 @@ class DocumentControllerTest {
.andExpect(status().isNoContent()); .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"));
}
// ─── GET /api/documents/{id}/versions ──────────────────────────────────── // ─── GET /api/documents/{id}/versions ────────────────────────────────────
@Test @Test

View File

@@ -1213,4 +1213,26 @@ class DocumentServiceTest {
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
} }
// ─── getRecentActivity ────────────────────────────────────────────────────
@Test
void getRecentActivity_returnsMostRecentlyUpdatedDocuments() {
java.time.LocalDateTime oldest = java.time.LocalDateTime.of(2024, 1, 1, 0, 0);
java.time.LocalDateTime middle = java.time.LocalDateTime.of(2024, 6, 1, 0, 0);
java.time.LocalDateTime newest = java.time.LocalDateTime.of(2024, 12, 1, 0, 0);
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();
// findAll(Sort) returns documents already sorted DESC by updatedAt
when(documentRepository.findAll(Sort.by(Sort.Direction.DESC, "updatedAt")))
.thenReturn(List.of(doc3, doc2, doc1));
List<Document> result = documentService.getRecentActivity(2);
assertThat(result).hasSize(2);
assertThat(result).containsExactly(doc3, doc2);
}
} }

View File

@@ -318,7 +318,7 @@
"dashboard_notification_replied": "hat geantwortet", "dashboard_notification_replied": "hat geantwortet",
"dashboard_needs_metadata_heading": "Metadaten fehlen", "dashboard_needs_metadata_heading": "Metadaten fehlen",
"dashboard_needs_metadata_show_all": "Alle anzeigen", "dashboard_needs_metadata_show_all": "Alle anzeigen",
"dashboard_recent_heading": "Zuletzt hinzugefügt", "dashboard_recent_heading": "Zuletzt aktiv",
"dashboard_resume_label": "Zuletzt geöffnet:", "dashboard_resume_label": "Zuletzt geöffnet:",
"dashboard_resume_fallback": "Unbekanntes Dokument" "dashboard_resume_fallback": "Unbekanntes Dokument"
} }

View File

@@ -318,7 +318,7 @@
"dashboard_notification_replied": "replied", "dashboard_notification_replied": "replied",
"dashboard_needs_metadata_heading": "Missing Metadata", "dashboard_needs_metadata_heading": "Missing Metadata",
"dashboard_needs_metadata_show_all": "Show all", "dashboard_needs_metadata_show_all": "Show all",
"dashboard_recent_heading": "Recently Added", "dashboard_recent_heading": "Recent Activity",
"dashboard_resume_label": "Last opened:", "dashboard_resume_label": "Last opened:",
"dashboard_resume_fallback": "Unknown document" "dashboard_resume_fallback": "Unknown document"
} }

View File

@@ -318,7 +318,7 @@
"dashboard_notification_replied": "respondió", "dashboard_notification_replied": "respondió",
"dashboard_needs_metadata_heading": "Metadatos incompletos", "dashboard_needs_metadata_heading": "Metadatos incompletos",
"dashboard_needs_metadata_show_all": "Ver todos", "dashboard_needs_metadata_show_all": "Ver todos",
"dashboard_recent_heading": "Añadidos recientemente", "dashboard_recent_heading": "Actividad reciente",
"dashboard_resume_label": "Último abierto:", "dashboard_resume_label": "Último abierto:",
"dashboard_resume_fallback": "Documento desconocido" "dashboard_resume_fallback": "Documento desconocido"
} }

View File

@@ -5,7 +5,7 @@ import { getLocale } from '$lib/paraglide/runtime.js';
type Document = { type Document = {
id: string; id: string;
title: string; title: string;
createdAt?: string; updatedAt?: string;
sender?: { id: string; firstName: string; lastName: string }; sender?: { id: string; firstName: string; lastName: string };
}; };
@@ -37,12 +37,12 @@ function formatDate(dateStr: string): string {
> >
{doc.title} {doc.title}
</a> </a>
{#if doc.createdAt} {#if doc.updatedAt}
<span <span
data-testid="doc-date-{doc.id}" data-testid="doc-date-{doc.id}"
class="ml-2 shrink-0 font-sans text-xs text-gray-400" class="ml-2 shrink-0 font-sans text-xs text-gray-400"
> >
{formatDate(doc.createdAt)} {formatDate(doc.updatedAt)}
</span> </span>
{/if} {/if}
</div> </div>

View File

@@ -9,12 +9,12 @@ afterEach(cleanup);
type Document = { type Document = {
id: string; id: string;
title: string; title: string;
createdAt?: string; updatedAt?: string;
sender?: { id: string; firstName: string; lastName: string }; sender?: { id: string; firstName: string; lastName: string };
}; };
function makeDoc(id: string, title: string, createdAt?: string): Document { function makeDoc(id: string, title: string, updatedAt?: string): Document {
return { id, title, createdAt }; return { id, title, updatedAt };
} }
describe('DashboardRecentDocuments', () => { describe('DashboardRecentDocuments', () => {

View File

@@ -628,6 +628,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/documents/recent-activity": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getRecentActivity"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/incomplete": { "/api/documents/incomplete": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2328,6 +2344,28 @@ export interface operations {
}; };
}; };
}; };
getRecentActivity: {
parameters: {
query?: {
size?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Document"][];
};
};
};
};
getIncomplete: { getIncomplete: {
parameters: { parameters: {
query?: { query?: {

View File

@@ -60,7 +60,7 @@ export async function load({ url, fetch }) {
const [mentionsResult, incompleteResult, recentResult] = await Promise.allSettled([ const [mentionsResult, incompleteResult, recentResult] = await Promise.allSettled([
api.GET('/api/notifications', { params: { query: { size: 5 } } }), api.GET('/api/notifications', { params: { query: { size: 5 } } }),
api.GET('/api/documents/incomplete', { params: { query: { size: 5 } } }), api.GET('/api/documents/incomplete', { params: { query: { size: 5 } } }),
api.GET('/api/documents/search', { params: { query: {} } }) api.GET('/api/documents/recent-activity', { params: { query: { size: 5 } } })
]); ]);
if (mentionsResult.status === 'fulfilled' && mentionsResult.value.response.ok) { if (mentionsResult.status === 'fulfilled' && mentionsResult.value.response.ok) {
@@ -70,7 +70,7 @@ export async function load({ url, fetch }) {
incompleteDocs = incompleteResult.value.data ?? []; incompleteDocs = incompleteResult.value.data ?? [];
} }
if (recentResult.status === 'fulfilled' && recentResult.value.response.ok) { if (recentResult.status === 'fulfilled' && recentResult.value.response.ok) {
recentDocs = (recentResult.value.data ?? []).slice(0, 5); recentDocs = recentResult.value.data ?? [];
} }
} }