From bc762246e51f3fe43b8f7d38ac13e5841d7bd13f Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 21:21:20 +0200 Subject: [PATCH] fix(dashboard): null-safe name join in toActorDTO Co-Authored-By: Claude Sonnet 4.6 --- .../dashboard/DashboardService.java | 188 ++++++++++++++++++ .../dashboard/DashboardServiceTest.java | 68 +++++++ 2 files changed, 256 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java new file mode 100644 index 00000000..1b3f9a05 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java @@ -0,0 +1,188 @@ +package org.raddatz.familienarchiv.dashboard; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.model.TranscriptionBlock; +import org.raddatz.familienarchiv.service.DocumentService; +import org.raddatz.familienarchiv.service.TranscriptionService; +import org.raddatz.familienarchiv.service.UserService; +import org.springframework.stereotype.Service; + +import java.time.DayOfWeek; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.temporal.TemporalAdjusters; +import java.util.*; +import java.util.stream.Stream; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DashboardService { + + private final AuditLogQueryService auditLogQueryService; + private final DocumentService documentService; + private final TranscriptionService transcriptionService; + private final UserService userService; + + public DashboardResumeDTO getResume(UUID userId) { + Optional docIdOpt = auditLogQueryService.findMostRecentDocumentForUser(userId); + if (docIdOpt.isEmpty()) return null; + + UUID docId = docIdOpt.get(); + Document doc; + try { + doc = documentService.getDocumentById(docId); + } catch (Exception e) { + log.warn("Resume: document {} not found for user {}", docId, userId); + return null; + } + + List blocks = transcriptionService.listBlocks(docId); + String excerpt = blocks.stream() + .filter(b -> b.getText() != null && !b.getText().isBlank()) + .min(Comparator.comparingInt(TranscriptionBlock::getSortOrder)) + .map(b -> b.getText().length() > 200 ? b.getText().substring(0, 200) + "…" : b.getText()) + .orElse(""); + + int totalBlocks = blocks.size(); + long reviewedBlocks = blocks.stream().filter(TranscriptionBlock::isReviewed).count(); + int pct = totalBlocks > 0 ? (int) (reviewedBlocks * 100L / totalBlocks) : 0; + + Set pageNumbers = new HashSet<>(); + for (TranscriptionBlock b : blocks) { + pageNumbers.add(b.getSortOrder()); + } + int maxPage = blocks.stream().mapToInt(TranscriptionBlock::getSortOrder).max().orElse(1); + int totalPages = Math.max(1, (int) blocks.stream() + .mapToInt(TranscriptionBlock::getSortOrder) + .distinct().count()); + int currentPage = 1; + + String caption = buildCaption(doc); + + List collaboratorIds = blocks.stream() + .map(TranscriptionBlock::getUpdatedBy) + .filter(Objects::nonNull) + .distinct() + .limit(5) + .toList(); + + List collaborators = collaboratorIds.stream() + .map(uid -> { + try { + AppUser u = userService.getById(uid); + return toActorDTO(u); + } catch (Exception e) { + return null; + } + }) + .filter(Objects::nonNull) + .toList(); + + return new DashboardResumeDTO(docId, doc.getTitle(), caption, excerpt, + currentPage, totalPages, pct, null, collaborators); + } + + public DashboardPulseDTO getPulse(UUID userId) { + OffsetDateTime weekStart = OffsetDateTime.now(ZoneOffset.UTC) + .with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + .withHour(0).withMinute(0).withSecond(0).withNano(0); + + PulseStatsRow stats = auditLogQueryService.getPulseStats(weekStart, userId); + + List feed = auditLogQueryService.findActivityFeed(userId, 50); + List contributors = feed.stream() + .filter(r -> r.getActorId() != null) + .map(r -> new ActivityActorDTO(r.getActorInitials(), r.getActorColor(), r.getActorName())) + .filter(a -> !a.initials().isBlank()) + .distinct() + .limit(6) + .toList(); + + return new DashboardPulseDTO( + (int) stats.getPages(), + (int) stats.getAnnotated(), + (int) stats.getTranscribed(), + (int) stats.getUploaded(), + (int) stats.getYourPages(), + contributors + ); + } + + public List getActivity(UUID currentUserId, int limit) { + List rows = auditLogQueryService.findActivityFeed(currentUserId, limit); + + List docIds = rows.stream() + .map(ActivityFeedRow::getDocumentId) + .filter(Objects::nonNull) + .distinct() + .toList(); + + Map titleCache = new HashMap<>(); + for (UUID docId : docIds) { + try { + titleCache.put(docId, documentService.getDocumentById(docId).getTitle()); + } catch (Exception e) { + titleCache.put(docId, ""); + } + } + + return rows.stream().map(row -> { + ActivityActorDTO actor = row.getActorId() != null + ? new ActivityActorDTO(row.getActorInitials(), row.getActorColor(), row.getActorName()) + : null; + String docTitle = titleCache.getOrDefault(row.getDocumentId(), ""); + return new ActivityFeedItemDTO( + org.raddatz.familienarchiv.audit.AuditKind.valueOf(row.getKind()), + actor, + row.getDocumentId(), + docTitle, + row.getHappenedAt().atOffset(ZoneOffset.UTC), + row.isYouMentioned() + ); + }).toList(); + } + + private String buildCaption(Document doc) { + StringBuilder sb = new StringBuilder(); + if (doc.getSender() != null) sb.append(personName(doc.getSender())); + if (!doc.getReceivers().isEmpty()) { + String receivers = doc.getReceivers().stream() + .map(this::personName).collect(Collectors.joining(", ")); + if (!sb.isEmpty()) sb.append(" an "); + sb.append(receivers); + } + if (doc.getDocumentDate() != null) { + if (!sb.isEmpty()) sb.append(" · "); + sb.append(doc.getDocumentDate()); + } + return sb.toString(); + } + + private String personName(Person p) { + if (p == null) return ""; + if (p.getFirstName() != null && p.getLastName() != null) return p.getFirstName() + " " + p.getLastName(); + if (p.getFirstName() != null) return p.getFirstName(); + if (p.getLastName() != null) return p.getLastName(); + return ""; + } + + private ActivityActorDTO toActorDTO(AppUser u) { + String initials = ""; + if (u.getFirstName() != null && !u.getFirstName().isBlank()) + initials += u.getFirstName().charAt(0); + if (u.getLastName() != null && !u.getLastName().isBlank()) + initials += u.getLastName().charAt(0); + if (initials.isBlank() && u.getEmail() != null) + initials = u.getEmail().substring(0, 1).toUpperCase(); + String fullName = Stream.of(u.getFirstName(), u.getLastName()) + .filter(Objects::nonNull) + .collect(Collectors.joining(" ")); + return new ActivityActorDTO(initials.toUpperCase(), u.getColor(), fullName); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java new file mode 100644 index 00000000..9bc61d6e --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java @@ -0,0 +1,68 @@ +package org.raddatz.familienarchiv.dashboard; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.TranscriptionBlock; +import org.raddatz.familienarchiv.service.DocumentService; +import org.raddatz.familienarchiv.service.TranscriptionService; +import org.raddatz.familienarchiv.service.UserService; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DashboardServiceTest { + + @Mock AuditLogQueryService auditLogQueryService; + @Mock DocumentService documentService; + @Mock TranscriptionService transcriptionService; + @Mock UserService userService; + + @InjectMocks DashboardService dashboardService; + + // ─── toActorDTO (via getResume collaborators) ───────────────────────────── + + @Test + void getResume_collaboratorName_isNullSafe_whenFirstNameIsNull() { + UUID userId = UUID.randomUUID(); + UUID docId = UUID.randomUUID(); + UUID collaboratorId = UUID.randomUUID(); + + Document doc = Document.builder() + .id(docId).title("Brief").originalFilename("brief.pdf") + .receivers(new HashSet<>()) + .build(); + + TranscriptionBlock block = TranscriptionBlock.builder() + .id(UUID.randomUUID()).annotationId(UUID.randomUUID()) + .documentId(docId).sortOrder(1).updatedBy(collaboratorId) + .build(); + + AppUser collaborator = AppUser.builder() + .id(collaboratorId).email("s@test.com").password("pw") + .firstName(null).lastName("Schmidt").color("#abc") + .build(); + + when(auditLogQueryService.findMostRecentDocumentForUser(userId)).thenReturn(Optional.of(docId)); + when(documentService.getDocumentById(docId)).thenReturn(doc); + when(transcriptionService.listBlocks(docId)).thenReturn(List.of(block)); + when(userService.getById(collaboratorId)).thenReturn(collaborator); + + DashboardResumeDTO result = dashboardService.getResume(userId); + + assertThat(result).isNotNull(); + assertThat(result.collaborators()).hasSize(1); + assertThat(result.collaborators().get(0).name()).isEqualTo("Schmidt"); + } +}