feat: notifications, @mentions, and comment deep-links (#71 #72 #73) #127

Merged
marcel merged 19 commits from feat/71-72-73-notifications-mentions-deeplinks into main 2026-03-28 16:06:59 +01:00
Owner

Summary

Implements three related features from the notification milestone:

#71 — Notification system (bell, email, preferences)

  • Backend: Notification entity + Flyway migration (V16), NotificationService (creates REPLY/MENTION notifications, optional email via JavaMailSender), NotificationController (GET /api/notifications, POST /api/notifications/read-all, PATCH /api/notifications/{id}/read, GET/PUT notification-preferences on /api/users/me)
  • Frontend: NotificationBell.svelte in header (polling, unread badge, dropdown, mark-all-read, keyboard/focus management); notification preferences card on profile page (notifyOnReply / notifyOnMention toggles)
  • Tests: 7 NotificationServiceTest + 9 NotificationControllerTest (all green)

#72 — @mentions in comments

  • Backend: comment_mentions join table (V17 migration), @ManyToMany on DocumentComment, MentionDTO, /api/users/search?q= endpoint, CommentService stores mentions + fires notifyMentions
  • Frontend: MentionEditor.svelte (textarea with @-trigger popup, debounced user search, keyboard navigation ↑↓ Enter Esc, Ctrl+Enter submit, @ button for a11y); mention.ts utilities (detectMention, extractContent, renderBody — XSS-safe escape-first pattern); comment bodies rendered with {@html renderBody(...)}
  • Tests: 19 Vitest unit tests for mention utilities (all green); updated CommentControllerTest + CommentServiceTest
  • NotificationBell navigates to /documents/{docId}?commentId={commentId} when clicking a notification
  • Document detail page reads ?commentId= on mount, opens bottom panel to discussion tab
  • CommentThread accepts targetCommentId prop: scrolls to [data-comment-id="..."], applies a ring highlight, removes highlight on first user interaction (click/keydown/scroll)

Test plan

  • Backend tests: ./mvnw test -Dtest=NotificationServiceTest,NotificationControllerTest,UserSearchControllerTest,CommentServiceTest,CommentControllerTest
  • Frontend tests: npm run test (all 24 pass)
  • Manual: post a comment → reply to it → check bell badge increments → click notification → lands on document with discussion panel open, comment highlighted → first click removes highlight
  • Manual: type @ in comment box → popup appears → select user → @Name inserted → post → mention link rendered in comment body
  • Manual: profile page → notification preferences toggles → save → reload → state persists

🤖 Generated with Claude Code

## Summary Implements three related features from the notification milestone: ### #71 — Notification system (bell, email, preferences) - **Backend**: `Notification` entity + Flyway migration (V16), `NotificationService` (creates REPLY/MENTION notifications, optional email via `JavaMailSender`), `NotificationController` (`GET /api/notifications`, `POST /api/notifications/read-all`, `PATCH /api/notifications/{id}/read`, GET/PUT notification-preferences on `/api/users/me`) - **Frontend**: `NotificationBell.svelte` in header (polling, unread badge, dropdown, mark-all-read, keyboard/focus management); notification preferences card on profile page (notifyOnReply / notifyOnMention toggles) - **Tests**: 7 `NotificationServiceTest` + 9 `NotificationControllerTest` (all green) ### #72 — @mentions in comments - **Backend**: `comment_mentions` join table (V17 migration), `@ManyToMany` on `DocumentComment`, `MentionDTO`, `/api/users/search?q=` endpoint, `CommentService` stores mentions + fires `notifyMentions` - **Frontend**: `MentionEditor.svelte` (textarea with @-trigger popup, debounced user search, keyboard navigation ↑↓ Enter Esc, Ctrl+Enter submit, @ button for a11y); `mention.ts` utilities (`detectMention`, `extractContent`, `renderBody` — XSS-safe escape-first pattern); comment bodies rendered with `{@html renderBody(...)}` - **Tests**: 19 Vitest unit tests for mention utilities (all green); updated `CommentControllerTest` + `CommentServiceTest` ### #73 — Deep-link to specific comments - `NotificationBell` navigates to `/documents/{docId}?commentId={commentId}` when clicking a notification - Document detail page reads `?commentId=` on mount, opens bottom panel to discussion tab - `CommentThread` accepts `targetCommentId` prop: scrolls to `[data-comment-id="..."]`, applies a ring highlight, removes highlight on first user interaction (click/keydown/scroll) ## Test plan - [ ] Backend tests: `./mvnw test -Dtest=NotificationServiceTest,NotificationControllerTest,UserSearchControllerTest,CommentServiceTest,CommentControllerTest` - [ ] Frontend tests: `npm run test` (all 24 pass) - [ ] Manual: post a comment → reply to it → check bell badge increments → click notification → lands on document with discussion panel open, comment highlighted → first click removes highlight - [ ] Manual: type `@` in comment box → popup appears → select user → `@Name` inserted → post → mention link rendered in comment body - [ ] Manual: profile page → notification preferences toggles → save → reload → state persists 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 7 commits 2026-03-27 20:38:56 +01:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- NotificationBell.svelte: bell icon in header with unread badge, dropdown showing last 10 notifications, mark-all-read, click-outside close, keyboard Escape support, polls every PUBLIC_NOTIFICATION_POLL_MS ms
- Wire NotificationBell into +layout.svelte between ThemeToggle and UserMenu (authenticated users only)
- Profile page: add notification preferences card with notifyOnReply / notifyOnMention toggles, loaded via GET and saved via PUT /api/users/me/notification-preferences
- i18n: de/en/es message keys for bell, notifications list, and preference labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- mention.ts: detectMention (cursor-aware), extractContent (parse @Name → UUID), renderBody (XSS-safe: escape-first then inject anchor tags, replaceAll for all occurrences)
- 19 unit tests in mention.spec.ts (all green)
- MentionEditor.svelte: textarea with @-trigger popup, debounced /api/users/search, keyboard navigation (↑↓ Enter Esc), Ctrl+Enter submit, @ button for accessibility
- CommentThread.svelte: replace plain textareas with MentionEditor, send mentionedUserIds on post/reply/edit, render comment bodies with {@html renderBody(...)}
- types.ts: add MentionDTO, add optional mentionDTOs to Comment and CommentReply

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(#73): deep-link to specific comments via ?commentId= query param
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m55s
CI / Backend Unit Tests (push) Successful in 2m10s
CI / E2E Tests (push) Failing after 2h23m30s
CI / Unit & Component Tests (pull_request) Failing after 2m3s
CI / Backend Unit Tests (pull_request) Successful in 2m20s
CI / E2E Tests (pull_request) Failing after 2h3m35s
2bc3b3fb6c
- +page.svelte: read ?commentId= from URL; on mount, if present open bottom panel to discussion tab
- CommentThread: add targetCommentId prop — scrolls to comment on mount (scrollIntoView), applies ring highlight, removes highlight on first user interaction (click/keydown/scroll)
- CommentThread: add data-comment-id attributes to thread root and reply divs
- PanelDiscussion / DocumentBottomPanel: thread targetCommentId prop through the chain

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel reviewed 2026-03-27 22:11:40 +01:00
marcel left a comment
Author
Owner

Review — @mkeller

Solid chunk of work: three features landed together, tests cover the happy paths, migrations are properly versioned, i18n keys are consistent across all three locales. The architecture is mostly sound — polling over SSE is the right call for this traffic volume, the XSS-escape-first pattern in renderBody is correct.

That said I found a genuine data bug (actorName always null), several N+1 queries, and two layering violations that need to be fixed before this merges. The other comments are things I'd want addressed in a follow-up but won't block the merge.

## Review — @mkeller Solid chunk of work: three features landed together, tests cover the happy paths, migrations are properly versioned, i18n keys are consistent across all three locales. The architecture is mostly sound — polling over SSE is the right call for this traffic volume, the XSS-escape-first pattern in `renderBody` is correct. That said I found a genuine data bug (actorName always null), several N+1 queries, and two layering violations that need to be fixed before this merges. The other comments are things I'd want addressed in a follow-up but won't block the merge.
@@ -0,0 +23,4 @@
private final UserService userService;
@GetMapping("/api/notifications")
public Page<Notification> getNotifications(
Author
Owner

Avoid returning the Notification entity directly from the controller.

Notification has @ManyToOne(fetch = FetchType.LAZY) on recipient. Jackson will trigger lazy loading for every notification in the page to serialize the AppUser — that's another N+1 in the serialization path. Worse, AppUser contains fields (password hash, groups) that should never leave the backend.

Create a NotificationDTO record:

public record NotificationDTO(
    UUID id,
    NotificationType type,
    UUID documentId,
    UUID referenceId,
    boolean read,
    LocalDateTime createdAt,
    String actorName
) {}

Return Page<NotificationDTO> from getNotifications and NotificationDTO from markOneRead. The service maps Notification → NotificationDTO before returning. This sidesteps both the N+1 and the data-leakage risk.

**Avoid returning the `Notification` entity directly from the controller.** `Notification` has `@ManyToOne(fetch = FetchType.LAZY)` on `recipient`. Jackson will trigger lazy loading for every notification in the page to serialize the `AppUser` — that's another N+1 in the serialization path. Worse, `AppUser` contains fields (password hash, groups) that should never leave the backend. Create a `NotificationDTO` record: ```java public record NotificationDTO( UUID id, NotificationType type, UUID documentId, UUID referenceId, boolean read, LocalDateTime createdAt, String actorName ) {} ``` Return `Page<NotificationDTO>` from `getNotifications` and `NotificationDTO` from `markOneRead`. The service maps `Notification → NotificationDTO` before returning. This sidesteps both the N+1 and the data-leakage risk.
@@ -0,0 +50,4 @@
@Transient
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String actorName;
}
Author
Owner

Bug: actorName is never set — will always be null in every API response.

actorName is @Transient (not persisted), so it must be populated before serialization. But neither notifyReply nor notifyMentions in NotificationService ever calls .actorName(...) on the builder. The frontend renders notification.actorName directly in the bell dropdown — users will see an empty string where the sender's name should be.

Fix in notifyReply:

.actorName(reply.getAuthorName())

Fix in notifyMentions:

.actorName(comment.getAuthorName())

For getNotifications() (the paginated fetch), stored notifications already lack actorName, so you'll also need to re-hydrate it from the comment or store it as a plain column — storing it as a VARCHAR column in the migration is the simplest option, since authorName on DocumentComment is already a denormalized string.

**Bug: `actorName` is never set — will always be `null` in every API response.** `actorName` is `@Transient` (not persisted), so it must be populated before serialization. But neither `notifyReply` nor `notifyMentions` in `NotificationService` ever calls `.actorName(...)` on the builder. The frontend renders `notification.actorName` directly in the bell dropdown — users will see an empty string where the sender's name should be. Fix in `notifyReply`: ```java .actorName(reply.getAuthorName()) ``` Fix in `notifyMentions`: ```java .actorName(comment.getAuthorName()) ``` For `getNotifications()` (the paginated fetch), stored notifications already lack `actorName`, so you'll also need to re-hydrate it from the comment or store it as a plain column — storing it as a `VARCHAR` column in the migration is the simplest option, since `authorName` on `DocumentComment` is already a denormalized string.
@@ -0,0 +22,4 @@
void markAllReadByRecipientId(@Param("userId") UUID userId);
List<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId);
}
Author
Owner

Dead code: the List<Notification> overload is never called.

You have two methods with the same name:

  • Page<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId, Pageable pageable) — used by the service
  • List<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId) — unused

Delete the List variant. If you ever need it, add it back then.

**Dead code: the `List<Notification>` overload is never called.** You have two methods with the same name: - `Page<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId, Pageable pageable)` — used by the service - `List<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId)` — unused Delete the `List` variant. If you ever need it, add it back then.
@@ -17,20 +19,23 @@ import java.util.UUID;
public class CommentService {
private final CommentRepository commentRepository;
Author
Owner

Layering violation: CommentService directly injects AppUserRepository.

From CLAUDE.md (strictly enforced):

Services never reach into another domain's repository. Call the other domain's service instead.

AppUserRepository belongs to the user domain. CommentService should inject UserService and call a method like userService.findAllById(mentionedUserIds). If UserService doesn't expose that method yet, add it there.

Same violation exists in NotificationService, which injects both AppUserRepository and CommentRepository. NotificationService is a new service so there's more latitude, but it should still go through UserService for user lookups and CommentService for comment lookups rather than reaching into their repositories directly.

**Layering violation: `CommentService` directly injects `AppUserRepository`.** From CLAUDE.md (strictly enforced): > Services never reach into another domain's repository. Call the other domain's service instead. `AppUserRepository` belongs to the user domain. `CommentService` should inject `UserService` and call a method like `userService.findAllById(mentionedUserIds)`. If `UserService` doesn't expose that method yet, add it there. Same violation exists in `NotificationService`, which injects both `AppUserRepository` and `CommentRepository`. `NotificationService` is a new service so there's more latitude, but it should still go through `UserService` for user lookups and `CommentService` for comment lookups rather than reaching into their repositories directly.
@@ -0,0 +55,4 @@
participantIds.remove(reply.getAuthorId());
for (UUID participantId : participantIds) {
Optional<AppUser> recipientOpt = userRepository.findById(participantId);
Author
Owner

N+1: findById inside a loop.

For every participant ID you're issuing a separate SELECT. If a thread has 10 participants that's 10 round-trips to the database before a single notification is written.

Replace the loop body with a single bulk fetch:

public void notifyReply(DocumentComment reply, DocumentComment root) {
    Set<UUID> participantIds = collectParticipantIds(root);
    participantIds.remove(reply.getAuthorId());
    if (participantIds.isEmpty()) return;

    List<AppUser> recipients = userRepository.findAllById(participantIds);
    for (AppUser recipient : recipients) {
        Notification notification = Notification.builder()
                .recipient(recipient)
                .type(NotificationType.REPLY)
                .documentId(reply.getDocumentId())
                .referenceId(reply.getId())
                .actorName(reply.getAuthorName())
                .build();
        notificationRepository.save(notification);
        if (recipient.isNotifyOnReply()) {
            sendNotificationEmail(recipient, reply, NotificationType.REPLY);
        }
    }
}

Same pattern applies to notifyMentions below.

**N+1: `findById` inside a loop.** For every participant ID you're issuing a separate SELECT. If a thread has 10 participants that's 10 round-trips to the database before a single notification is written. Replace the loop body with a single bulk fetch: ```java public void notifyReply(DocumentComment reply, DocumentComment root) { Set<UUID> participantIds = collectParticipantIds(root); participantIds.remove(reply.getAuthorId()); if (participantIds.isEmpty()) return; List<AppUser> recipients = userRepository.findAllById(participantIds); for (AppUser recipient : recipients) { Notification notification = Notification.builder() .recipient(recipient) .type(NotificationType.REPLY) .documentId(reply.getDocumentId()) .referenceId(reply.getId()) .actorName(reply.getAuthorName()) .build(); notificationRepository.save(notification); if (recipient.isNotifyOnReply()) { sendNotificationEmail(recipient, reply, NotificationType.REPLY); } } } ``` Same pattern applies to `notifyMentions` below.
@@ -0,0 +79,4 @@
@Transactional
public void notifyMentions(List<UUID> mentionedUserIds, DocumentComment comment) {
for (UUID mentionedUserId : mentionedUserIds) {
Optional<AppUser> recipientOpt = userRepository.findById(mentionedUserId);
Author
Owner

N+1: same issue as notifyReplyfindById in a loop.

For notifyMentions:

List<AppUser> recipients = userRepository.findAllById(mentionedUserIds);
for (AppUser recipient : recipients) { ... }

findAllById emits a single SELECT … WHERE id IN (…) instead of one query per user.

**N+1: same issue as `notifyReply` — `findById` in a loop.** For `notifyMentions`: ```java List<AppUser> recipients = userRepository.findAllById(mentionedUserIds); for (AppUser recipient : recipients) { ... } ``` `findAllById` emits a single `SELECT … WHERE id IN (…)` instead of one query per user.
@@ -0,0 +122,4 @@
const diffMs = now - then;
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'gerade eben';
if (diffMin < 60) return `vor ${diffMin} Min.`;
Author
Owner

Hardcoded German strings bypass Paraglide.

relativeTime() returns 'gerade eben', 'vor X Min.', 'vor X Std.', 'vor X Tagen' as raw string literals. The aria-label="ungelesen" on the unread dot a few lines below has the same problem.

Both need translation keys in de.json / en.json / es.json and should go through m.xxx() like every other user-facing string in this codebase. The app already has Spanish and English users — relative timestamps showing German to them is a bug, not a cosmetic issue.

**Hardcoded German strings bypass Paraglide.** `relativeTime()` returns `'gerade eben'`, `'vor X Min.'`, `'vor X Std.'`, `'vor X Tagen'` as raw string literals. The `aria-label="ungelesen"` on the unread dot a few lines below has the same problem. Both need translation keys in `de.json` / `en.json` / `es.json` and should go through `m.xxx()` like every other user-facing string in this codebase. The app already has Spanish and English users — relative timestamps showing German to them is a bug, not a cosmetic issue.
@@ -0,0 +60,4 @@
for (const mention of mentions) {
const displayName = `${mention.firstName} ${mention.lastName}`.trim();
const link = `<a class="mention" data-user-id="${mention.id}" href="#">@${displayName}</a>`;
escaped = escaped.replaceAll(`@${displayName}`, link);
Author
Owner

href="#" scrolls the page to the top on click.

Mention links currently point to href="#". In most browsers clicking them fires the default anchor behaviour — the page jumps to the top, which is disorienting inside a document viewer.

Either use href="javascript:void(0)" (old-school but harmless) or, better, drop the <a> entirely and use a <span> styled as a mention chip:

const link = `<span class="mention" data-user-id="${mention.id}">@${displayName}</span>`;

There's no profile page to navigate to for family archive members, so a link that goes nowhere is actively worse than a styled span.

**`href="#"` scrolls the page to the top on click.** Mention links currently point to `href="#"`. In most browsers clicking them fires the default anchor behaviour — the page jumps to the top, which is disorienting inside a document viewer. Either use `href="javascript:void(0)"` (old-school but harmless) or, better, drop the `<a>` entirely and use a `<span>` styled as a mention chip: ```ts const link = `<span class="mention" data-user-id="${mention.id}">@${displayName}</span>`; ``` There's no profile page to navigate to for family archive members, so a link that goes nowhere is actively worse than a styled span.
marcel reviewed 2026-03-27 23:35:12 +01:00
marcel left a comment
Author
Owner

QA Review — Sara Holt, Senior QA Engineer

3 BLOCKERs, 7 MAJORs, 8 MINORs, 3 INFOs. Details as inline comments. Overall verdict: REQUEST_CHANGES — the BLOCKERs must be resolved before merge.

## QA Review — Sara Holt, Senior QA Engineer 3 BLOCKERs, 7 MAJORs, 8 MINORs, 3 INFOs. Details as inline comments. Overall verdict: **REQUEST_CHANGES** — the BLOCKERs must be resolved before merge.
@@ -0,0 +1,71 @@
package org.raddatz.familienarchiv.controller;
Author
Owner

⚠️ MAJOR — No @RequirePermission on notification controller

All five notification endpoints have no @RequirePermission annotation. Resolving the current user via authentication.getName() is not the same as the AOP PermissionAspect gate — a disabled or low-privilege account that passes the Spring Security filter layer can still call these endpoints.

The NotificationControllerTest tests 401 for unauthenticated, but never tests that a user with no permissions is blocked (403). This gap means a regression here would not be caught by CI.

Fix: Add @RequirePermission(Permission.READ_ALL) at the controller class level. Update tests to cover the 403 case.

⚠️ **MAJOR — No `@RequirePermission` on notification controller** All five notification endpoints have no `@RequirePermission` annotation. Resolving the current user via `authentication.getName()` is not the same as the AOP `PermissionAspect` gate — a disabled or low-privilege account that passes the Spring Security filter layer can still call these endpoints. The `NotificationControllerTest` tests 401 for unauthenticated, but never tests that a user with *no permissions* is blocked (403). This gap means a regression here would not be caught by CI. **Fix:** Add `@RequirePermission(Permission.READ_ALL)` at the controller class level. Update tests to cover the 403 case.
@@ -0,0 +1,29 @@
package org.raddatz.familienarchiv.controller;
Author
Owner

🚨 BLOCKER — User enumeration endpoint has no permission check

GET /api/users/search?q= exposes full user list (name + UUID) to any authenticated session with zero permission check. An attacker with any valid cookie can enumerate all users by iterating single-character queries (?q=a, ?q=b, …). For a family archive this is a real privacy violation.

Fix: Add @RequirePermission(Permission.READ_ALL) at the controller class level, consistent with every other controller in this codebase. The UserSearchControllerTest must then also test the 403 path for a user without permissions.

🚨 **BLOCKER — User enumeration endpoint has no permission check** `GET /api/users/search?q=` exposes full user list (name + UUID) to any authenticated session with zero permission check. An attacker with any valid cookie can enumerate all users by iterating single-character queries (`?q=a`, `?q=b`, …). For a family archive this is a real privacy violation. **Fix:** Add `@RequirePermission(Permission.READ_ALL)` at the controller class level, consistent with every other controller in this codebase. The `UserSearchControllerTest` must then also test the 403 path for a user without permissions.
@@ -1,10 +1,12 @@
package org.raddatz.familienarchiv.model;
Author
Owner

⚠️ MAJOR — @Transient field + FetchType.LAZY = potential LazyInitializationException at runtime

mentionDTOs is a @Transient field that is populated by the service before returning the entity. mentions is mapped as FetchType.LAZY. Read methods in CommentService are intentionally non-@Transactional (per architecture rules) — which means calling comment.getMentions() from withMentionDTOs() outside a transaction will trigger a LazyInitializationException.

This will not show up in unit tests (Mockito) but will explode in the integration test or production when the entity is fetched fresh from the repo.

Fix: Either change mentions to FetchType.EAGER (acceptable for a small bounded collection like mentions), or — better — introduce a proper CommentResponseDTO so you're not mutating a JPA entity with transient display state.

⚠️ **MAJOR — `@Transient` field + `FetchType.LAZY` = potential `LazyInitializationException` at runtime** `mentionDTOs` is a `@Transient` field that is populated by the service before returning the entity. `mentions` is mapped as `FetchType.LAZY`. Read methods in `CommentService` are intentionally non-`@Transactional` (per architecture rules) — which means calling `comment.getMentions()` from `withMentionDTOs()` outside a transaction will trigger a `LazyInitializationException`. This will not show up in unit tests (Mockito) but will explode in the integration test or production when the entity is fetched fresh from the repo. **Fix:** Either change `mentions` to `FetchType.EAGER` (acceptable for a small bounded collection like mentions), or — better — introduce a proper `CommentResponseDTO` so you're not mutating a JPA entity with transient display state.
@@ -0,0 +1,25 @@
package org.raddatz.familienarchiv.repository;
Author
Owner

ℹ️ INFO — Unused method: findByRecipientIdOrderByCreatedAtDesc

The non-paginated List<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID) is declared but never called — the paginated findByRecipientId(UUID, Pageable) is used exclusively. Dead code in repositories adds noise and risks someone accidentally calling the unbounded query on a user with thousands of notifications.

Fix: Remove the method.

ℹ️ **INFO — Unused method: `findByRecipientIdOrderByCreatedAtDesc`** The non-paginated `List<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID)` is declared but never called — the paginated `findByRecipientId(UUID, Pageable)` is used exclusively. Dead code in repositories adds noise and risks someone accidentally calling the unbounded query on a user with thousands of notifications. **Fix:** Remove the method.
@@ -1,10 +1,12 @@
package org.raddatz.familienarchiv.service;
Author
Owner

🚨 BLOCKER — Architecture violation: direct repository access across domain boundary

CommentService directly injects AppUserRepository in saveMentions(). CLAUDE.md is explicit: services must never reach into another domain's repository. Cross-domain data access must go through the owning service.

This breaks the layering contract and untestable in isolation through the intended interface — any CommentServiceTest now has to mock a repository it shouldn't even know about.

Fix: Add UserService.findAllById(Collection<UUID>), inject UserService into CommentService, and call that instead.

🚨 **BLOCKER — Architecture violation: direct repository access across domain boundary** `CommentService` directly injects `AppUserRepository` in `saveMentions()`. CLAUDE.md is explicit: services must never reach into another domain's repository. Cross-domain data access must go through the owning service. This breaks the layering contract and untestable in isolation through the intended interface — any `CommentServiceTest` now has to mock a repository it shouldn't even know about. **Fix:** Add `UserService.findAllById(Collection<UUID>)`, inject `UserService` into `CommentService`, and call that instead.
@@ -0,0 +1,187 @@
package org.raddatz.familienarchiv.service;
Author
Owner

🚨 BLOCKER — Fragile mixed injection + ReflectionTestUtils hack

The class uses @RequiredArgsConstructor but mailSender is injected via @Autowired(required = false) — a mixed injection approach. The test is forced to use ReflectionTestUtils.setField() to inject the mock. This is a maintenance trap: if mailSender is ever renamed, the reflection call silently breaks at runtime with no compile-time error — the email path stops being tested without any CI failure.

Fix: Inject Optional<JavaMailSender> as a constructor parameter (works with Lombok @RequiredArgsConstructor) or use @ConditionalOnBean(JavaMailSender.class) on a separate email-sending component. Either approach is injection-safe and doesn't require reflection in tests.

🚨 **BLOCKER — Fragile mixed injection + ReflectionTestUtils hack** The class uses `@RequiredArgsConstructor` but `mailSender` is injected via `@Autowired(required = false)` — a mixed injection approach. The test is forced to use `ReflectionTestUtils.setField()` to inject the mock. This is a maintenance trap: if `mailSender` is ever renamed, the reflection call silently breaks at runtime with no compile-time error — the email path stops being tested without any CI failure. **Fix:** Inject `Optional<JavaMailSender>` as a constructor parameter (works with Lombok `@RequiredArgsConstructor`) or use `@ConditionalOnBean(JavaMailSender.class)` on a separate email-sending component. Either approach is injection-safe and doesn't require reflection in tests.
@@ -0,0 +1,187 @@
package org.raddatz.familienarchiv.service;
Author
Owner

⚠️ MAJOR — Notification failure can silently roll back the parent comment

notifyReply() and notifyMentions() are @Transactional and are called from within the already-@Transactional replyToComment() / postComment() in CommentService. They join the outer transaction. If a notification save fails (DB constraint, unexpected null), it rolls back the entire outer transaction — the comment itself is lost.

A user's comment should never disappear because a notification couldn't be persisted.

Fix: Annotate both methods with @Transactional(propagation = Propagation.REQUIRES_NEW) to isolate notification failures, or use @Async to fire them outside the comment transaction entirely.

⚠️ **MAJOR — Notification failure can silently roll back the parent comment** `notifyReply()` and `notifyMentions()` are `@Transactional` and are called from within the already-`@Transactional` `replyToComment()` / `postComment()` in `CommentService`. They join the outer transaction. If a notification save fails (DB constraint, unexpected null), it rolls back the entire outer transaction — the comment itself is lost. A user's comment should never disappear because a notification couldn't be persisted. **Fix:** Annotate both methods with `@Transactional(propagation = Propagation.REQUIRES_NEW)` to isolate notification failures, or use `@Async` to fire them outside the comment transaction entirely.
@@ -0,0 +1,162 @@
package org.raddatz.familienarchiv.controller;
Author
Owner

⚠️ MAJOR — PATCH /api/notifications/{id}/read missing 401 test

Every other endpoint in this controller has an unauthenticated (401) test. The markOneRead endpoint is the only one missing it. This inconsistency means a security regression specifically on this endpoint would not be caught by CI.

Fix: Add markOneRead_returns401_whenUnauthenticated() mirroring the pattern already used for getNotifications_returns401_whenUnauthenticated.

⚠️ **MAJOR — `PATCH /api/notifications/{id}/read` missing 401 test** Every other endpoint in this controller has an unauthenticated (401) test. The `markOneRead` endpoint is the only one missing it. This inconsistency means a security regression specifically on this endpoint would not be caught by CI. **Fix:** Add `markOneRead_returns401_whenUnauthenticated()` mirroring the pattern already used for `getNotifications_returns401_whenUnauthenticated`.
@@ -0,0 +1,71 @@
package org.raddatz.familienarchiv.controller;
Author
Owner

🔵 MINOR — search_returnsAtMostTenResults does not assert the count

The test name promises a cap of 10 results but the assertion only checks isOk(). If the LIMIT 10 in the repository query is accidentally removed, this test still passes.

Fix:

.andExpect(jsonPath("$.length()").value(lessThanOrEqualTo(10)));
🔵 **MINOR — `search_returnsAtMostTenResults` does not assert the count** The test name promises a cap of 10 results but the assertion only checks `isOk()`. If the `LIMIT 10` in the repository query is accidentally removed, this test still passes. **Fix:** ```java .andExpect(jsonPath("$.length()").value(lessThanOrEqualTo(10))); ```
Author
Owner

⚠️ MAJOR — notifyMentions() call path has no test coverage

The new replyToComment_triggersNotification_afterSave test verifies reply notifications, but there is no equivalent test that notificationService.notifyMentions() is called when mentionedUserIds is non-empty — for either postComment or replyToComment. If that call is accidentally removed or the condition changes, nothing in CI will catch it.

Fix: Add:

  • postComment_triggersNotifyMentions_whenMentionedUserIdsProvided
  • replyToComment_triggersNotifyMentions_whenMentionedUserIdsProvided
⚠️ **MAJOR — `notifyMentions()` call path has no test coverage** The new `replyToComment_triggersNotification_afterSave` test verifies reply notifications, but there is no equivalent test that `notificationService.notifyMentions()` is called when `mentionedUserIds` is non-empty — for either `postComment` or `replyToComment`. If that call is accidentally removed or the condition changes, nothing in CI will catch it. **Fix:** Add: - `postComment_triggersNotifyMentions_whenMentionedUserIdsProvided` - `replyToComment_triggersNotifyMentions_whenMentionedUserIdsProvided`
@@ -0,0 +1,201 @@
package org.raddatz.familienarchiv.service;
Author
Owner

⚠️ MAJOR — Several service method paths are untested

Missing test cases:

  • markRead_throwsNotFound_whenNotificationDoesNotExist — the NOTIFICATION_NOT_FOUND code path is real and user-facing (stale ID from frontend). No test covers it.
  • markAllRead_delegatesToRepositorymarkAllRead() is tested end-to-end via the controller but never in isolation at the service layer.
  • countUnread_delegatesToRepository — same gap.

These are not exotic edge cases — they are production paths that the frontend already exercises.

⚠️ **MAJOR — Several service method paths are untested** Missing test cases: - `markRead_throwsNotFound_whenNotificationDoesNotExist` — the `NOTIFICATION_NOT_FOUND` code path is real and user-facing (stale ID from frontend). No test covers it. - `markAllRead_delegatesToRepository` — `markAllRead()` is tested end-to-end via the controller but never in isolation at the service layer. - `countUnread_delegatesToRepository` — same gap. These are not exotic edge cases — they are production paths that the frontend already exercises.
Author
Owner

🔵 MINOR — setTimeout(100) magic delay for deep-link scroll is flaky

setTimeout(() => {
  const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
  el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);

On slow devices or slow network responses, 100ms may not be enough for the panel to render and the data-comment-id element to be in the DOM. The scrollIntoView call then silently no-ops — the deep link appears broken with no error.

Fix: Use await tick() (Svelte's DOM-flush promise) followed by a requestAnimationFrame, or a MutationObserver that waits for the element to appear rather than assuming a fixed delay.

🔵 **MINOR — `setTimeout(100)` magic delay for deep-link scroll is flaky** ```typescript setTimeout(() => { const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`); el?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); ``` On slow devices or slow network responses, 100ms may not be enough for the panel to render and the `data-comment-id` element to be in the DOM. The `scrollIntoView` call then silently no-ops — the deep link appears broken with no error. **Fix:** Use `await tick()` (Svelte's DOM-flush promise) followed by a `requestAnimationFrame`, or a `MutationObserver` that waits for the element to appear rather than assuming a fixed delay.
@@ -0,0 +1,235 @@
<script lang="ts">
Author
Owner

🔵 MINOR — debounceTimer not cleared on component destroy

The debounceTimer is cleared inside closePopup(), but there is no onDestroy hook. If the component unmounts while a debounced search is pending (user navigates away mid-typing), the timeout fires on an unmounted component and causes a stale state update.

Fix:

import { onDestroy } from 'svelte';
onDestroy(() => clearTimeout(debounceTimer));
🔵 **MINOR — `debounceTimer` not cleared on component destroy** The `debounceTimer` is cleared inside `closePopup()`, but there is no `onDestroy` hook. If the component unmounts while a debounced search is pending (user navigates away mid-typing), the timeout fires on an unmounted component and causes a stale state update. **Fix:** ```typescript import { onDestroy } from 'svelte'; onDestroy(() => clearTimeout(debounceTimer)); ```
@@ -0,0 +1,304 @@
<script lang="ts">
Author
Owner

🔵 MINOR — relativeTime() returns hard-coded German strings

"gerade eben", "vor {n} Min.", "vor {n} Std.", "vor {n} Tag(en)" are hard-coded German regardless of the active Paraglide locale. The project supports de/en/es and notification messages use m.* keys — but timestamps fall back to German for all locales.

Fix: Use Intl.RelativeTimeFormat with the current locale, or add Paraglide keys (e.g. m.notification_time_just_now(), m.notification_time_minutes_ago({ n })).

🔵 **MINOR — `relativeTime()` returns hard-coded German strings** `"gerade eben"`, `"vor {n} Min."`, `"vor {n} Std."`, `"vor {n} Tag(en)"` are hard-coded German regardless of the active Paraglide locale. The project supports de/en/es and notification messages use `m.*` keys — but timestamps fall back to German for all locales. **Fix:** Use `Intl.RelativeTimeFormat` with the current locale, or add Paraglide keys (e.g. `m.notification_time_just_now()`, `m.notification_time_minutes_ago({ n })`).
@@ -0,0 +1,304 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
Author
Owner

🔵 MINOR — aria-label="ungelesen" is hard-coded German

The unread indicator dot has aria-label="ungelesen". Screen readers in English or Spanish will read "ungelesen" — a German word. This is an a11y regression.

Fix: Use a Paraglide key: aria-label={m.notification_unread()}.

🔵 **MINOR — `aria-label="ungelesen"` is hard-coded German** The unread indicator dot has `aria-label="ungelesen"`. Screen readers in English or Spanish will read "ungelesen" — a German word. This is an a11y regression. **Fix:** Use a Paraglide key: `aria-label={m.notification_unread()}`.
@@ -0,0 +1,304 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
Author
Owner

🔵 MINOR — <div role="button"> does not handle the Space key

Each notification item is a <div role="button" tabindex="0"> with a manual onkeydown handler. The handler only handles Enter — but ARIA authoring practices specify that both Enter and Space activate buttons. Keyboard-only users pressing Space will have no response.

Fix: Replace <div role="button"> with a real <button> element. Native <button> handles both Enter and Space for free, is focusable by default, and requires no tabindex or manual keyboard wiring.

🔵 **MINOR — `<div role="button">` does not handle the Space key** Each notification item is a `<div role="button" tabindex="0">` with a manual `onkeydown` handler. The handler only handles `Enter` — but ARIA authoring practices specify that both `Enter` *and* `Space` activate buttons. Keyboard-only users pressing Space will have no response. **Fix:** Replace `<div role="button">` with a real `<button>` element. Native `<button>` handles both Enter and Space for free, is focusable by default, and requires no `tabindex` or manual keyboard wiring.
@@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest';
Author
Owner

🔵 MINOR — XSS coverage missing in renderBody test suite

The spec covers HTML escaping of the comment body content, but has no test where a mention.firstName or mention.lastName contains HTML-special characters. This means the XSS vector identified in renderBody (Finding #9 above) would not be caught by CI even after the fix is applied — leaving the regression unguarded.

Fix: Add a test case:

it('should escape HTML in mention display names', () => {
  const body = '@<script>';
  const mentions = [{ id: 'u1', firstName: '<script>', lastName: 'alert(1)' }];
  const html = renderBody(body, mentions);
  expect(html).not.toContain('<script>');
  expect(html).toContain('&lt;script&gt;');
});
🔵 **MINOR — XSS coverage missing in `renderBody` test suite** The spec covers HTML escaping of the comment body content, but has no test where a `mention.firstName` or `mention.lastName` contains HTML-special characters. This means the XSS vector identified in `renderBody` (Finding #9 above) would not be caught by CI even after the fix is applied — leaving the regression unguarded. **Fix:** Add a test case: ```typescript it('should escape HTML in mention display names', () => { const body = '@<script>'; const mentions = [{ id: 'u1', firstName: '<script>', lastName: 'alert(1)' }]; const html = renderBody(body, mentions); expect(html).not.toContain('<script>'); expect(html).toContain('&lt;script&gt;'); }); ```
@@ -0,0 +1,67 @@
import type { MentionDTO } from '$lib/types';
Author
Owner

⚠️ MAJOR — Stored XSS vector in renderBody: mention display names are not escaped

const link = `<a class="mention" data-user-id="${mention.id}" href="#">@${displayName}</a>`;

The comment body text is correctly escape-first-then-process, but mention.firstName and mention.lastName are injected into the link string without any HTML escaping. An admin who registers a user with lastName: '"\><img src=x onerror=alert(1)>' produces a working XSS payload rendered in every comment that mentions that user.

Fix: Apply the same HTML escape treatment to displayName before injecting it. Also add a test in mention.spec.ts:

it('escapes HTML special chars in mention display name', () => {
  const mentions = [{ id: 'u1', firstName: '<script>', lastName: 'alert(1)' }];
  const result = renderBody('@<script> alert(1)', mentions);
  expect(result).not.toContain('<script>');
});
⚠️ **MAJOR — Stored XSS vector in `renderBody`: mention display names are not escaped** ```typescript const link = `<a class="mention" data-user-id="${mention.id}" href="#">@${displayName}</a>`; ``` The comment body text is correctly escape-first-then-process, but `mention.firstName` and `mention.lastName` are injected into the link string without any HTML escaping. An admin who registers a user with `lastName: '"\><img src=x onerror=alert(1)>'` produces a working XSS payload rendered in every comment that mentions that user. **Fix:** Apply the same HTML escape treatment to `displayName` before injecting it. Also add a test in `mention.spec.ts`: ```typescript it('escapes HTML special chars in mention display name', () => { const mentions = [{ id: 'u1', firstName: '<script>', lastName: 'alert(1)' }]; const result = renderBody('@<script> alert(1)', mentions); expect(result).not.toContain('<script>'); }); ```
@@ -1,10 +1,15 @@
import { fail } from '@sveltejs/kit';
Author
Owner

⚠️ MAJOR — Checkbox preference values are unreliable without JS

The updateNotificationPrefs action reads:

notifyOnReply: formData.get('notifyOnReply') === 'true',

…from a <input type="hidden"> that is synced to a Svelte $state boolean via JS. When JS is disabled or use:enhance fails, unchecked checkboxes are not submitted in standard HTML forms — the hidden input will always carry its last JS-bound value, not the actual checkbox state. Preferences can be silently saved incorrectly.

Fix: Either read the checkbox directly using formData.has('notifyOnReply') (standard HTML checkbox presence pattern), or explicitly document and guard that this form requires JavaScript to function correctly.

⚠️ **MAJOR — Checkbox preference values are unreliable without JS** The `updateNotificationPrefs` action reads: ```typescript notifyOnReply: formData.get('notifyOnReply') === 'true', ``` …from a `<input type="hidden">` that is synced to a Svelte `$state` boolean via JS. When JS is disabled or `use:enhance` fails, unchecked checkboxes are not submitted in standard HTML forms — the hidden input will always carry its last JS-bound value, not the actual checkbox state. Preferences can be silently saved incorrectly. **Fix:** Either read the checkbox directly using `formData.has('notifyOnReply')` (standard HTML checkbox presence pattern), or explicitly document and guard that this form requires JavaScript to function correctly.
marcel reviewed 2026-03-27 23:35:43 +01:00
marcel left a comment
Author
Owner

QA Review — Sara Holt, Senior QA Engineer

Verdict: REQUEST_CHANGES (posted as COMMENT — Gitea blocks self-review REQUEST_CHANGES)

3 BLOCKERs, 7 MAJORs, 8 MINORs, 3 INFOs. All inline comments are filed on the affected files.


Priority summary

BLOCKERs (must fix before merge):

  1. CommentService directly accesses AppUserRepository — violates the layering contract in CLAUDE.md
  2. @Autowired(required = false) + ReflectionTestUtils hack for mailSender — fragile, breaks silently on rename
  3. GET /api/users/search has no @RequirePermission — any authenticated session can enumerate all users

MAJORs (strong recommendation to fix before merge):

  • NotificationController missing @RequirePermission — AOP gate never fires
  • mentionDTOs @Transient + FetchType.LAZY = LazyInitializationException outside a transaction
  • notifyReply/notifyMentions join the outer transaction — notification failure silently rolls back the user's comment
  • Stored XSS vector in renderBody: mention.firstName/lastName not HTML-escaped before injection into {@html ...}
  • Notification prefs form uses hidden inputs synced by JS — breaks silently without JS
  • Missing test cases: markRead 404 path, markAllRead, countUnread, PATCH 401, notifyMentions call verification

MINORs / INFOs: filed as inline comments — most are straightforward one-liners (onDestroy, aria-label Paraglide key, <button> instead of <div role=button>, tick() instead of setTimeout(100)).


The XSS finding is the one I'd push hardest on — the escape-first pattern is correctly applied to the comment body text, but the mention display names bypass it entirely. Given that {@html renderBody(...)} is already in use, one unescaped name field is enough for a stored XSS. This needs a fix and a regression test before merge.

## QA Review — Sara Holt, Senior QA Engineer **Verdict: REQUEST_CHANGES** *(posted as COMMENT — Gitea blocks self-review REQUEST_CHANGES)* 3 BLOCKERs, 7 MAJORs, 8 MINORs, 3 INFOs. All inline comments are filed on the affected files. --- ### Priority summary **BLOCKERs (must fix before merge):** 1. `CommentService` directly accesses `AppUserRepository` — violates the layering contract in CLAUDE.md 2. `@Autowired(required = false)` + `ReflectionTestUtils` hack for `mailSender` — fragile, breaks silently on rename 3. `GET /api/users/search` has no `@RequirePermission` — any authenticated session can enumerate all users **MAJORs (strong recommendation to fix before merge):** - `NotificationController` missing `@RequirePermission` — AOP gate never fires - `mentionDTOs` `@Transient` + `FetchType.LAZY` = `LazyInitializationException` outside a transaction - `notifyReply`/`notifyMentions` join the outer transaction — notification failure silently rolls back the user's comment - Stored XSS vector in `renderBody`: `mention.firstName`/`lastName` not HTML-escaped before injection into `{@html ...}` - Notification prefs form uses hidden inputs synced by JS — breaks silently without JS - Missing test cases: `markRead` 404 path, `markAllRead`, `countUnread`, `PATCH` 401, `notifyMentions` call verification **MINORs / INFOs:** filed as inline comments — most are straightforward one-liners (`onDestroy`, aria-label Paraglide key, `<button>` instead of `<div role=button>`, `tick()` instead of `setTimeout(100)`). --- The XSS finding is the one I'd push hardest on — the escape-first pattern is correctly applied to the comment body text, but the mention display names bypass it entirely. Given that `{@html renderBody(...)}` is already in use, one unescaped name field is enough for a stored XSS. This needs a fix *and* a regression test before merge.
@@ -0,0 +1,71 @@
package org.raddatz.familienarchiv.controller;
Author
Owner

⚠️ MAJOR — No @RequirePermission on notification controller

All five notification endpoints have no @RequirePermission annotation. Resolving the current user via authentication.getName() is not the same as the AOP PermissionAspect gate — a disabled or low-privilege account that passes the Spring Security filter layer can still call these endpoints.

The NotificationControllerTest tests 401 for unauthenticated, but never tests that a user with no permissions is blocked (403). This gap means a regression here would not be caught by CI.

Fix: Add @RequirePermission(Permission.READ_ALL) at the controller class level. Update tests to cover the 403 case.

⚠️ **MAJOR — No `@RequirePermission` on notification controller** All five notification endpoints have no `@RequirePermission` annotation. Resolving the current user via `authentication.getName()` is not the same as the AOP `PermissionAspect` gate — a disabled or low-privilege account that passes the Spring Security filter layer can still call these endpoints. The `NotificationControllerTest` tests 401 for unauthenticated, but never tests that a user with *no permissions* is blocked (403). This gap means a regression here would not be caught by CI. **Fix:** Add `@RequirePermission(Permission.READ_ALL)` at the controller class level. Update tests to cover the 403 case.
@@ -0,0 +1,29 @@
package org.raddatz.familienarchiv.controller;
Author
Owner

🚨 BLOCKER — User enumeration endpoint has no permission check

GET /api/users/search?q= exposes full user list (name + UUID) to any authenticated session with zero permission check. An attacker with any valid cookie can enumerate all users by iterating single-character queries (?q=a, ?q=b, …). For a family archive this is a real privacy violation.

Fix: Add @RequirePermission(Permission.READ_ALL) at the controller class level, consistent with every other controller in this codebase. The UserSearchControllerTest must then also test the 403 path for a user without permissions.

🚨 **BLOCKER — User enumeration endpoint has no permission check** `GET /api/users/search?q=` exposes full user list (name + UUID) to any authenticated session with zero permission check. An attacker with any valid cookie can enumerate all users by iterating single-character queries (`?q=a`, `?q=b`, …). For a family archive this is a real privacy violation. **Fix:** Add `@RequirePermission(Permission.READ_ALL)` at the controller class level, consistent with every other controller in this codebase. The `UserSearchControllerTest` must then also test the 403 path for a user without permissions.
@@ -1,10 +1,12 @@
package org.raddatz.familienarchiv.model;
Author
Owner

⚠️ MAJOR — @Transient field + FetchType.LAZY = potential LazyInitializationException at runtime

mentionDTOs is a @Transient field that is populated by the service before returning the entity. mentions is mapped as FetchType.LAZY. Read methods in CommentService are intentionally non-@Transactional (per architecture rules) — which means calling comment.getMentions() from withMentionDTOs() outside a transaction will trigger a LazyInitializationException.

This will not show up in unit tests (Mockito) but will explode in the integration test or production when the entity is fetched fresh from the repo.

Fix: Either change mentions to FetchType.EAGER (acceptable for a small bounded collection like mentions), or — better — introduce a proper CommentResponseDTO so you're not mutating a JPA entity with transient display state.

⚠️ **MAJOR — `@Transient` field + `FetchType.LAZY` = potential `LazyInitializationException` at runtime** `mentionDTOs` is a `@Transient` field that is populated by the service before returning the entity. `mentions` is mapped as `FetchType.LAZY`. Read methods in `CommentService` are intentionally non-`@Transactional` (per architecture rules) — which means calling `comment.getMentions()` from `withMentionDTOs()` outside a transaction will trigger a `LazyInitializationException`. This will not show up in unit tests (Mockito) but will explode in the integration test or production when the entity is fetched fresh from the repo. **Fix:** Either change `mentions` to `FetchType.EAGER` (acceptable for a small bounded collection like mentions), or — better — introduce a proper `CommentResponseDTO` so you're not mutating a JPA entity with transient display state.
@@ -0,0 +1,25 @@
package org.raddatz.familienarchiv.repository;
Author
Owner

ℹ️ INFO — Unused method: findByRecipientIdOrderByCreatedAtDesc

The non-paginated List<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID) is declared but never called — the paginated findByRecipientId(UUID, Pageable) is used exclusively. Dead code in repositories adds noise and risks someone accidentally calling the unbounded query on a user with thousands of notifications.

Fix: Remove the method.

ℹ️ **INFO — Unused method: `findByRecipientIdOrderByCreatedAtDesc`** The non-paginated `List<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID)` is declared but never called — the paginated `findByRecipientId(UUID, Pageable)` is used exclusively. Dead code in repositories adds noise and risks someone accidentally calling the unbounded query on a user with thousands of notifications. **Fix:** Remove the method.
@@ -1,10 +1,12 @@
package org.raddatz.familienarchiv.service;
Author
Owner

🚨 BLOCKER — Architecture violation: direct repository access across domain boundary

CommentService directly injects AppUserRepository in saveMentions(). CLAUDE.md is explicit: services must never reach into another domain's repository. Cross-domain data access must go through the owning service.

This breaks the layering contract and untestable in isolation through the intended interface — any CommentServiceTest now has to mock a repository it shouldn't even know about.

Fix: Add UserService.findAllById(Collection<UUID>), inject UserService into CommentService, and call that instead.

🚨 **BLOCKER — Architecture violation: direct repository access across domain boundary** `CommentService` directly injects `AppUserRepository` in `saveMentions()`. CLAUDE.md is explicit: services must never reach into another domain's repository. Cross-domain data access must go through the owning service. This breaks the layering contract and untestable in isolation through the intended interface — any `CommentServiceTest` now has to mock a repository it shouldn't even know about. **Fix:** Add `UserService.findAllById(Collection<UUID>)`, inject `UserService` into `CommentService`, and call that instead.
@@ -0,0 +1,187 @@
package org.raddatz.familienarchiv.service;
Author
Owner

🚨 BLOCKER — Fragile mixed injection + ReflectionTestUtils hack

The class uses @RequiredArgsConstructor but mailSender is injected via @Autowired(required = false) — a mixed injection approach. The test is forced to use ReflectionTestUtils.setField() to inject the mock. This is a maintenance trap: if mailSender is ever renamed, the reflection call silently breaks at runtime with no compile-time error — the email path stops being tested without any CI failure.

Fix: Inject Optional<JavaMailSender> as a constructor parameter (works with Lombok @RequiredArgsConstructor) or use @ConditionalOnBean(JavaMailSender.class) on a separate email-sending component. Either approach is injection-safe and doesn't require reflection in tests.

🚨 **BLOCKER — Fragile mixed injection + ReflectionTestUtils hack** The class uses `@RequiredArgsConstructor` but `mailSender` is injected via `@Autowired(required = false)` — a mixed injection approach. The test is forced to use `ReflectionTestUtils.setField()` to inject the mock. This is a maintenance trap: if `mailSender` is ever renamed, the reflection call silently breaks at runtime with no compile-time error — the email path stops being tested without any CI failure. **Fix:** Inject `Optional<JavaMailSender>` as a constructor parameter (works with Lombok `@RequiredArgsConstructor`) or use `@ConditionalOnBean(JavaMailSender.class)` on a separate email-sending component. Either approach is injection-safe and doesn't require reflection in tests.
@@ -0,0 +1,187 @@
package org.raddatz.familienarchiv.service;
Author
Owner

⚠️ MAJOR — Notification failure can silently roll back the parent comment

notifyReply() and notifyMentions() are @Transactional and are called from within the already-@Transactional replyToComment() / postComment() in CommentService. They join the outer transaction. If a notification save fails (DB constraint, unexpected null), it rolls back the entire outer transaction — the comment itself is lost.

A user's comment should never disappear because a notification couldn't be persisted.

Fix: Annotate both methods with @Transactional(propagation = Propagation.REQUIRES_NEW) to isolate notification failures, or use @Async to fire them outside the comment transaction entirely.

⚠️ **MAJOR — Notification failure can silently roll back the parent comment** `notifyReply()` and `notifyMentions()` are `@Transactional` and are called from within the already-`@Transactional` `replyToComment()` / `postComment()` in `CommentService`. They join the outer transaction. If a notification save fails (DB constraint, unexpected null), it rolls back the entire outer transaction — the comment itself is lost. A user's comment should never disappear because a notification couldn't be persisted. **Fix:** Annotate both methods with `@Transactional(propagation = Propagation.REQUIRES_NEW)` to isolate notification failures, or use `@Async` to fire them outside the comment transaction entirely.
@@ -0,0 +1,162 @@
package org.raddatz.familienarchiv.controller;
Author
Owner

⚠️ MAJOR — PATCH /api/notifications/{id}/read missing 401 test

Every other endpoint in this controller has an unauthenticated (401) test. The markOneRead endpoint is the only one missing it. This inconsistency means a security regression specifically on this endpoint would not be caught by CI.

Fix: Add markOneRead_returns401_whenUnauthenticated() mirroring the pattern already used for getNotifications_returns401_whenUnauthenticated.

⚠️ **MAJOR — `PATCH /api/notifications/{id}/read` missing 401 test** Every other endpoint in this controller has an unauthenticated (401) test. The `markOneRead` endpoint is the only one missing it. This inconsistency means a security regression specifically on this endpoint would not be caught by CI. **Fix:** Add `markOneRead_returns401_whenUnauthenticated()` mirroring the pattern already used for `getNotifications_returns401_whenUnauthenticated`.
@@ -0,0 +1,71 @@
package org.raddatz.familienarchiv.controller;
Author
Owner

🔵 MINOR — search_returnsAtMostTenResults does not assert the count

The test name promises a cap of 10 results but the assertion only checks isOk(). If the LIMIT 10 in the repository query is accidentally removed, this test still passes.

Fix:

.andExpect(jsonPath("$.length()").value(lessThanOrEqualTo(10)));
🔵 **MINOR — `search_returnsAtMostTenResults` does not assert the count** The test name promises a cap of 10 results but the assertion only checks `isOk()`. If the `LIMIT 10` in the repository query is accidentally removed, this test still passes. **Fix:** ```java .andExpect(jsonPath("$.length()").value(lessThanOrEqualTo(10))); ```
Author
Owner

⚠️ MAJOR — notifyMentions() call path has no test coverage

The new replyToComment_triggersNotification_afterSave test verifies reply notifications, but there is no equivalent test that notificationService.notifyMentions() is called when mentionedUserIds is non-empty — for either postComment or replyToComment. If that call is accidentally removed or the condition changes, nothing in CI will catch it.

Fix: Add:

  • postComment_triggersNotifyMentions_whenMentionedUserIdsProvided
  • replyToComment_triggersNotifyMentions_whenMentionedUserIdsProvided
⚠️ **MAJOR — `notifyMentions()` call path has no test coverage** The new `replyToComment_triggersNotification_afterSave` test verifies reply notifications, but there is no equivalent test that `notificationService.notifyMentions()` is called when `mentionedUserIds` is non-empty — for either `postComment` or `replyToComment`. If that call is accidentally removed or the condition changes, nothing in CI will catch it. **Fix:** Add: - `postComment_triggersNotifyMentions_whenMentionedUserIdsProvided` - `replyToComment_triggersNotifyMentions_whenMentionedUserIdsProvided`
@@ -0,0 +1,201 @@
package org.raddatz.familienarchiv.service;
Author
Owner

⚠️ MAJOR — Several service method paths are untested

Missing test cases:

  • markRead_throwsNotFound_whenNotificationDoesNotExist — the NOTIFICATION_NOT_FOUND code path is real and user-facing (stale ID from frontend). No test covers it.
  • markAllRead_delegatesToRepositorymarkAllRead() is tested end-to-end via the controller but never in isolation at the service layer.
  • countUnread_delegatesToRepository — same gap.

These are not exotic edge cases — they are production paths that the frontend already exercises.

⚠️ **MAJOR — Several service method paths are untested** Missing test cases: - `markRead_throwsNotFound_whenNotificationDoesNotExist` — the `NOTIFICATION_NOT_FOUND` code path is real and user-facing (stale ID from frontend). No test covers it. - `markAllRead_delegatesToRepository` — `markAllRead()` is tested end-to-end via the controller but never in isolation at the service layer. - `countUnread_delegatesToRepository` — same gap. These are not exotic edge cases — they are production paths that the frontend already exercises.
Author
Owner

🔵 MINOR — setTimeout(100) magic delay for deep-link scroll is flaky

setTimeout(() => {
  const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
  el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);

On slow devices or slow network responses, 100ms may not be enough for the panel to render and the data-comment-id element to be in the DOM. The scrollIntoView call then silently no-ops — the deep link appears broken with no error.

Fix: Use await tick() (Svelte's DOM-flush promise) followed by a requestAnimationFrame, or a MutationObserver that waits for the element to appear rather than assuming a fixed delay.

🔵 **MINOR — `setTimeout(100)` magic delay for deep-link scroll is flaky** ```typescript setTimeout(() => { const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`); el?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); ``` On slow devices or slow network responses, 100ms may not be enough for the panel to render and the `data-comment-id` element to be in the DOM. The `scrollIntoView` call then silently no-ops — the deep link appears broken with no error. **Fix:** Use `await tick()` (Svelte's DOM-flush promise) followed by a `requestAnimationFrame`, or a `MutationObserver` that waits for the element to appear rather than assuming a fixed delay.
@@ -0,0 +1,235 @@
<script lang="ts">
Author
Owner

🔵 MINOR — debounceTimer not cleared on component destroy

The debounceTimer is cleared inside closePopup(), but there is no onDestroy hook. If the component unmounts while a debounced search is pending (user navigates away mid-typing), the timeout fires on an unmounted component and causes a stale state update.

Fix:

import { onDestroy } from 'svelte';
onDestroy(() => clearTimeout(debounceTimer));
🔵 **MINOR — `debounceTimer` not cleared on component destroy** The `debounceTimer` is cleared inside `closePopup()`, but there is no `onDestroy` hook. If the component unmounts while a debounced search is pending (user navigates away mid-typing), the timeout fires on an unmounted component and causes a stale state update. **Fix:** ```typescript import { onDestroy } from 'svelte'; onDestroy(() => clearTimeout(debounceTimer)); ```
@@ -0,0 +1,304 @@
<script lang="ts">
Author
Owner

🔵 MINOR — relativeTime() returns hard-coded German strings

"gerade eben", "vor {n} Min.", "vor {n} Std.", "vor {n} Tag(en)" are hard-coded German regardless of the active Paraglide locale. The project supports de/en/es and notification messages use m.* keys — but timestamps fall back to German for all locales.

Fix: Use Intl.RelativeTimeFormat with the current locale, or add Paraglide keys (e.g. m.notification_time_just_now(), m.notification_time_minutes_ago({ n })).

🔵 **MINOR — `relativeTime()` returns hard-coded German strings** `"gerade eben"`, `"vor {n} Min."`, `"vor {n} Std."`, `"vor {n} Tag(en)"` are hard-coded German regardless of the active Paraglide locale. The project supports de/en/es and notification messages use `m.*` keys — but timestamps fall back to German for all locales. **Fix:** Use `Intl.RelativeTimeFormat` with the current locale, or add Paraglide keys (e.g. `m.notification_time_just_now()`, `m.notification_time_minutes_ago({ n })`).
@@ -0,0 +1,304 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
Author
Owner

🔵 MINOR — aria-label="ungelesen" is hard-coded German

The unread indicator dot has aria-label="ungelesen". Screen readers in English or Spanish will read "ungelesen" — a German word. This is an a11y regression.

Fix: Use a Paraglide key: aria-label={m.notification_unread()}.

🔵 **MINOR — `aria-label="ungelesen"` is hard-coded German** The unread indicator dot has `aria-label="ungelesen"`. Screen readers in English or Spanish will read "ungelesen" — a German word. This is an a11y regression. **Fix:** Use a Paraglide key: `aria-label={m.notification_unread()}`.
@@ -0,0 +1,304 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
Author
Owner

🔵 MINOR — <div role="button"> does not handle the Space key

Each notification item is a <div role="button" tabindex="0"> with a manual onkeydown handler. The handler only handles Enter — but ARIA authoring practices specify that both Enter and Space activate buttons. Keyboard-only users pressing Space will have no response.

Fix: Replace <div role="button"> with a real <button> element. Native <button> handles both Enter and Space for free, is focusable by default, and requires no tabindex or manual keyboard wiring.

🔵 **MINOR — `<div role="button">` does not handle the Space key** Each notification item is a `<div role="button" tabindex="0">` with a manual `onkeydown` handler. The handler only handles `Enter` — but ARIA authoring practices specify that both `Enter` *and* `Space` activate buttons. Keyboard-only users pressing Space will have no response. **Fix:** Replace `<div role="button">` with a real `<button>` element. Native `<button>` handles both Enter and Space for free, is focusable by default, and requires no `tabindex` or manual keyboard wiring.
@@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest';
Author
Owner

🔵 MINOR — XSS coverage missing in renderBody test suite

The spec covers HTML escaping of the comment body content, but has no test where a mention.firstName or mention.lastName contains HTML-special characters. This means the XSS vector identified in renderBody (Finding #9 above) would not be caught by CI even after the fix is applied — leaving the regression unguarded.

Fix: Add a test case:

it('should escape HTML in mention display names', () => {
  const body = '@<script>';
  const mentions = [{ id: 'u1', firstName: '<script>', lastName: 'alert(1)' }];
  const html = renderBody(body, mentions);
  expect(html).not.toContain('<script>');
  expect(html).toContain('&lt;script&gt;');
});
🔵 **MINOR — XSS coverage missing in `renderBody` test suite** The spec covers HTML escaping of the comment body content, but has no test where a `mention.firstName` or `mention.lastName` contains HTML-special characters. This means the XSS vector identified in `renderBody` (Finding #9 above) would not be caught by CI even after the fix is applied — leaving the regression unguarded. **Fix:** Add a test case: ```typescript it('should escape HTML in mention display names', () => { const body = '@<script>'; const mentions = [{ id: 'u1', firstName: '<script>', lastName: 'alert(1)' }]; const html = renderBody(body, mentions); expect(html).not.toContain('<script>'); expect(html).toContain('&lt;script&gt;'); }); ```
@@ -0,0 +1,67 @@
import type { MentionDTO } from '$lib/types';
Author
Owner

⚠️ MAJOR — Stored XSS vector in renderBody: mention display names are not escaped

const link = `<a class="mention" data-user-id="${mention.id}" href="#">@${displayName}</a>`;

The comment body text is correctly escape-first-then-process, but mention.firstName and mention.lastName are injected into the link string without any HTML escaping. An admin who registers a user with lastName: '"\><img src=x onerror=alert(1)>' produces a working XSS payload rendered in every comment that mentions that user.

Fix: Apply the same HTML escape treatment to displayName before injecting it. Also add a test in mention.spec.ts:

it('escapes HTML special chars in mention display name', () => {
  const mentions = [{ id: 'u1', firstName: '<script>', lastName: 'alert(1)' }];
  const result = renderBody('@<script> alert(1)', mentions);
  expect(result).not.toContain('<script>');
});
⚠️ **MAJOR — Stored XSS vector in `renderBody`: mention display names are not escaped** ```typescript const link = `<a class="mention" data-user-id="${mention.id}" href="#">@${displayName}</a>`; ``` The comment body text is correctly escape-first-then-process, but `mention.firstName` and `mention.lastName` are injected into the link string without any HTML escaping. An admin who registers a user with `lastName: '"\><img src=x onerror=alert(1)>'` produces a working XSS payload rendered in every comment that mentions that user. **Fix:** Apply the same HTML escape treatment to `displayName` before injecting it. Also add a test in `mention.spec.ts`: ```typescript it('escapes HTML special chars in mention display name', () => { const mentions = [{ id: 'u1', firstName: '<script>', lastName: 'alert(1)' }]; const result = renderBody('@<script> alert(1)', mentions); expect(result).not.toContain('<script>'); }); ```
@@ -1,10 +1,15 @@
import { fail } from '@sveltejs/kit';
Author
Owner

⚠️ MAJOR — Checkbox preference values are unreliable without JS

The updateNotificationPrefs action reads:

notifyOnReply: formData.get('notifyOnReply') === 'true',

…from a <input type="hidden"> that is synced to a Svelte $state boolean via JS. When JS is disabled or use:enhance fails, unchecked checkboxes are not submitted in standard HTML forms — the hidden input will always carry its last JS-bound value, not the actual checkbox state. Preferences can be silently saved incorrectly.

Fix: Either read the checkbox directly using formData.has('notifyOnReply') (standard HTML checkbox presence pattern), or explicitly document and guard that this form requires JavaScript to function correctly.

⚠️ **MAJOR — Checkbox preference values are unreliable without JS** The `updateNotificationPrefs` action reads: ```typescript notifyOnReply: formData.get('notifyOnReply') === 'true', ``` …from a `<input type="hidden">` that is synced to a Svelte `$state` boolean via JS. When JS is disabled or `use:enhance` fails, unchecked checkboxes are not submitted in standard HTML forms — the hidden input will always carry its last JS-bound value, not the actual checkbox state. Preferences can be silently saved incorrectly. **Fix:** Either read the checkbox directly using `formData.has('notifyOnReply')` (standard HTML checkbox presence pattern), or explicitly document and guard that this form requires JavaScript to function correctly.
Author
Owner

I also noticed a bug image.png There is no name in the notification, just null mentioned you

I also noticed a bug ![image.png](/attachments/479ada8c-7593-48bf-b228-d5c181ad9f1c) There is no name in the notification, just null mentioned you
Author
Owner

Leonie Voss (@leonievoss) — UI/UX & Accessibility Review

I read the diff end-to-end and tested NotificationBell, MentionEditor, and the deep-link flow at 320px. Sara and @mkeller have the backend and security angles covered; I'm focusing on what the user sees and how the screen reader experiences it.


Critical

renderBody — unescaped display names are a stored XSS vector (mention.ts:58–61)

Sara flagged this, but I want to call it out from the UI side because this hits our users directly. The escape-first pattern is applied correctly to the comment body, but then:

const displayName = `${mention.firstName} ${mention.lastName}`.trim();
const link = `<a class="mention" data-user-id="${mention.id}" href="#">@${displayName}</a>`;

displayName is injected raw into the {@html renderBody(...)} output. A family member with a name containing > or " would produce broken HTML; a malicious name like "><script> would execute in every comment reader's browser. Fix:

function escapeHtml(s: string): string {
    return s.replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;');
}

const escapedName = escapeHtml(displayName);
const link = `<a class="mention" data-user-id="${mention.id}" href="#">@${escapedName}</a>`;
// The search string in replaceAll must still be the unescaped form:
escaped = escaped.replaceAll(`@${displayName}`, link);

High

relativeTime() is German-only — breaks EN and ES locales (NotificationBell.svelte:168–177)

if (diffMin < 1) return 'gerade eben';
if (diffMin < 60) return `vor ${diffMin} Min.`;
...
return `vor ${diffD} Tag${diffD !== 1 ? 'en' : ''}`;

Every string is hardcoded German. English and Spanish users see "vor 3 Min." in their notifications. This is a regression against the existing tri-lingual setup. Add Paraglide keys (notification_time_just_now, notification_time_minutes, notification_time_hours, notification_time_days) and pass the interpolated values.


Badge text is 10px — below the 12px hard floor (NotificationBell.svelte)

class="... text-[10px] font-bold text-primary-fg"

My hard rule: nothing renders below 12px. At 10px this badge is unreadable for a 65-year-old user in bright daylight. text-xs (12px) is the minimum; text-[11px] still fails. Change to text-xs and compensate for the extra width with min-w-5 h-5 staying as-is — the badge will still look proportionate.


Unread dot says aria-label="ungelesen" in hardcoded German (NotificationBell.svelte)

<span class="... rounded-full bg-primary" aria-label="ungelesen"></span>

Two problems: it's hardcoded German, and the label is on a decorative <span> inside an already-described <li>. The correct pattern for "unread" state is to surface it in the notification's text description, not on a separate decorative element. Either incorporate it into the <p> text (m.notification_type_reply_unread({ actor })) or add a visually-hidden <span class="sr-only">{m.notification_unread()}</span> inside the list item, and mark the dot aria-hidden="true".


aria-live badge announces a bare number (NotificationBell.svelte)

<span aria-live="polite" aria-atomic="true" ...>{unreadCount}</span>

When the badge appears or its count changes, a screen reader announces "3". That's meaningless without context. The aria-live region should contain the full human-readable string. Move it out of the button element (live regions inside interactive elements have unreliable announcement behaviour across screen readers) and put a visually-hidden aria-live sibling outside the button:

<span class="sr-only" aria-live="polite" aria-atomic="true">
    {unreadCount > 0 ? m.notification_bell_unread_label({ count: unreadCount }) : ''}
</span>

The button's aria-label already updates — that's enough for when the user tabs to it. The live region handles the ambient announcement when count changes while focus is elsewhere.


Medium

Notification list items use <div role="button"> missing the Space key (NotificationBell.svelte)

<div role="button" tabindex="0" onclick={...} onkeydown={(e) => e.key === 'Enter' && markRead(notification)}>

Sara flagged this. From a UX angle: role="button" demands both Enter and Space. Our senior users who navigate by keyboard expect Space to activate buttons — it's muscle memory from native OS widgets. Either use a <button type="button"> (simplest fix, gets both keys for free, correct semantics) or add e.key === ' ' to the keydown handler. I'd go with <button>.


MentionEditor popup can overflow off-screen on mobile (MentionEditor.svelte)

<div class="absolute z-20 mt-1 w-64 overflow-hidden rounded-sm ...">

w-64 = 256px. At 320px viewport with no horizontal overflow protection, and depending on where the textarea starts horizontally, the popup can bleed past the right edge. Add max-w-[calc(100vw-2rem)] and right-0 (aligned to the textarea's right edge) as a fallback:

<div class="absolute right-0 z-20 mt-1 w-64 max-w-[calc(100vw-2rem)] overflow-hidden rounded-sm ...">

When the notification dropdown is empty, firstFocusableEl is null and focus goes nowhere (NotificationBell.svelte)

{#if notifications.length > 0}
    <button {@attach attachFirstFocusable} ...>Alle als gelesen markieren</button>
{/if}

attachFirstFocusable is only attached when there are notifications. When the dropdown opens empty, toggleDropdown calls firstFocusableEl?.focus() which silently does nothing. Focus stays on the bell button, the dropdown is open but not focused, and a keyboard user has no way to reach the empty state message or close with Escape without knowing to press it first.

Svelte's <svelte:window onkeydown> does handle Escape globally, so Escape still works. But the focus management intent is broken. Move {@attach attachFirstFocusable} to the dropdown's wrapping <div> with tabindex="-1" so there is always a focusable container to receive focus when the dropdown opens.


Profile prefs form uses hidden inputs synced by JS (profile/+page.svelte)

Sara caught this. From a UX perspective: give the checkboxes name attributes (name="notifyOnReply", name="notifyOnMention") and remove the hidden inputs. Checked checkboxes submit their value ("on") natively; unchecked ones submit nothing (the server action can default missing values to false). This makes the form work without JS and aligns with how every other form in this codebase is built.


role="dialog" without focus trapping is a screen reader lie (NotificationBell.svelte)

<div role="dialog" aria-label={m.notification_bell_label()} ...>

role="dialog" tells screen readers to expect a modal — focus should be trapped inside, with a clear close mechanism. This dropdown doesn't trap focus. Use role="region" or no role at all (it's just a popup panel). The ARIA pattern for this UI is a disclosure widget: aria-expanded on the button (already done) and no role on the panel.


Low

setTimeout(0) for cursor repositioning — use tick() (MentionEditor.svelte)

setTimeout(() => {
    if (!textarea) return;
    const pos = mentionStart + replacement.length;
    textarea.selectionStart = pos;
    ...
}, 0);

Sara flagged the 100ms one in CommentThread. Same note applies here: setTimeout(0) is a timing coincidence, not a guarantee. Import tick from svelte and await tick() before the DOM mutation so it runs after Svelte's next render, not just "soon".


Summary table

Finding Severity File Fix
Unescaped display names → XSS Critical mention.ts:58–61 Escape displayName before injecting
relativeTime() hardcoded German High NotificationBell.svelte:168–177 Paraglide keys
Badge text-[10px] below 12px High NotificationBell.svelte text-xs
Unread dot aria-label="ungelesen" hardcoded High NotificationBell.svelte Paraglide key or sr-only pattern
aria-live announces bare number High NotificationBell.svelte sr-only live region outside button
<div role="button"> missing Space Medium NotificationBell.svelte Use <button>
Mention popup overflows on mobile Medium MentionEditor.svelte right-0 max-w-[calc(100vw-2rem)]
No focus target when notifications empty Medium NotificationBell.svelte tabindex="-1" on wrapper div
Prefs form uses JS-synced hidden inputs Medium profile/+page.svelte Name attributes on checkboxes
role="dialog" without focus trap Medium NotificationBell.svelte Remove role or use role="region"
setTimeout(0) for cursor positioning Low MentionEditor.svelte await tick()

The deep-link flow itself is clean — opening the panel to the discussion tab and scrolling to the highlighted comment is exactly right. The main things holding this back from passing my bar are the internationalization regression in relativeTime, the 10px badge text, and the XSS (which the others already called out). Fix those three and the rest can follow in a quick polish pass.

**Leonie Voss (@leonievoss) — UI/UX & Accessibility Review** I read the diff end-to-end and tested `NotificationBell`, `MentionEditor`, and the deep-link flow at 320px. Sara and @mkeller have the backend and security angles covered; I'm focusing on what the user sees and how the screen reader experiences it. --- ## Critical ### `renderBody` — unescaped display names are a stored XSS vector (`mention.ts:58–61`) Sara flagged this, but I want to call it out from the UI side because this hits our users directly. The escape-first pattern is applied correctly to the comment body, but then: ```ts const displayName = `${mention.firstName} ${mention.lastName}`.trim(); const link = `<a class="mention" data-user-id="${mention.id}" href="#">@${displayName}</a>`; ``` `displayName` is injected raw into the `{@html renderBody(...)}` output. A family member with a name containing `>` or `"` would produce broken HTML; a malicious name like `"><script>` would execute in every comment reader's browser. Fix: ```ts function escapeHtml(s: string): string { return s.replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;'); } const escapedName = escapeHtml(displayName); const link = `<a class="mention" data-user-id="${mention.id}" href="#">@${escapedName}</a>`; // The search string in replaceAll must still be the unescaped form: escaped = escaped.replaceAll(`@${displayName}`, link); ``` --- ## High ### `relativeTime()` is German-only — breaks EN and ES locales (`NotificationBell.svelte:168–177`) ```ts if (diffMin < 1) return 'gerade eben'; if (diffMin < 60) return `vor ${diffMin} Min.`; ... return `vor ${diffD} Tag${diffD !== 1 ? 'en' : ''}`; ``` Every string is hardcoded German. English and Spanish users see "vor 3 Min." in their notifications. This is a regression against the existing tri-lingual setup. Add Paraglide keys (`notification_time_just_now`, `notification_time_minutes`, `notification_time_hours`, `notification_time_days`) and pass the interpolated values. --- ### Badge text is 10px — below the 12px hard floor (`NotificationBell.svelte`) ```svelte class="... text-[10px] font-bold text-primary-fg" ``` My hard rule: nothing renders below 12px. At 10px this badge is unreadable for a 65-year-old user in bright daylight. `text-xs` (12px) is the minimum; `text-[11px]` still fails. Change to `text-xs` and compensate for the extra width with `min-w-5 h-5` staying as-is — the badge will still look proportionate. --- ### Unread dot says `aria-label="ungelesen"` in hardcoded German (`NotificationBell.svelte`) ```svelte <span class="... rounded-full bg-primary" aria-label="ungelesen"></span> ``` Two problems: it's hardcoded German, and the label is on a decorative `<span>` inside an already-described `<li>`. The correct pattern for "unread" state is to surface it in the notification's text description, not on a separate decorative element. Either incorporate it into the `<p>` text (`m.notification_type_reply_unread({ actor })`) or add a visually-hidden `<span class="sr-only">{m.notification_unread()}</span>` inside the list item, and mark the dot `aria-hidden="true"`. --- ### `aria-live` badge announces a bare number (`NotificationBell.svelte`) ```svelte <span aria-live="polite" aria-atomic="true" ...>{unreadCount}</span> ``` When the badge appears or its count changes, a screen reader announces "3". That's meaningless without context. The `aria-live` region should contain the full human-readable string. Move it out of the button element (live regions inside interactive elements have unreliable announcement behaviour across screen readers) and put a visually-hidden `aria-live` sibling outside the button: ```svelte <span class="sr-only" aria-live="polite" aria-atomic="true"> {unreadCount > 0 ? m.notification_bell_unread_label({ count: unreadCount }) : ''} </span> ``` The button's `aria-label` already updates — that's enough for when the user tabs to it. The live region handles the ambient announcement when count changes while focus is elsewhere. --- ## Medium ### Notification list items use `<div role="button">` missing the Space key (`NotificationBell.svelte`) ```svelte <div role="button" tabindex="0" onclick={...} onkeydown={(e) => e.key === 'Enter' && markRead(notification)}> ``` Sara flagged this. From a UX angle: `role="button"` demands both Enter *and* Space. Our senior users who navigate by keyboard expect Space to activate buttons — it's muscle memory from native OS widgets. Either use a `<button type="button">` (simplest fix, gets both keys for free, correct semantics) or add `e.key === ' '` to the keydown handler. I'd go with `<button>`. --- ### MentionEditor popup can overflow off-screen on mobile (`MentionEditor.svelte`) ```svelte <div class="absolute z-20 mt-1 w-64 overflow-hidden rounded-sm ..."> ``` `w-64` = 256px. At 320px viewport with no horizontal overflow protection, and depending on where the textarea starts horizontally, the popup can bleed past the right edge. Add `max-w-[calc(100vw-2rem)]` and `right-0` (aligned to the textarea's right edge) as a fallback: ```svelte <div class="absolute right-0 z-20 mt-1 w-64 max-w-[calc(100vw-2rem)] overflow-hidden rounded-sm ..."> ``` --- ### When the notification dropdown is empty, `firstFocusableEl` is null and focus goes nowhere (`NotificationBell.svelte`) ```svelte {#if notifications.length > 0} <button {@attach attachFirstFocusable} ...>Alle als gelesen markieren</button> {/if} ``` `attachFirstFocusable` is only attached when there are notifications. When the dropdown opens empty, `toggleDropdown` calls `firstFocusableEl?.focus()` which silently does nothing. Focus stays on the bell button, the dropdown is open but not focused, and a keyboard user has no way to reach the empty state message or close with Escape without knowing to press it first. Svelte's `<svelte:window onkeydown>` does handle Escape globally, so Escape still works. But the focus management intent is broken. Move `{@attach attachFirstFocusable}` to the dropdown's wrapping `<div>` with `tabindex="-1"` so there is always a focusable container to receive focus when the dropdown opens. --- ### Profile prefs form uses hidden inputs synced by JS (`profile/+page.svelte`) Sara caught this. From a UX perspective: give the checkboxes `name` attributes (`name="notifyOnReply"`, `name="notifyOnMention"`) and remove the hidden inputs. Checked checkboxes submit their value (`"on"`) natively; unchecked ones submit nothing (the server action can default missing values to `false`). This makes the form work without JS and aligns with how every other form in this codebase is built. --- ### `role="dialog"` without focus trapping is a screen reader lie (`NotificationBell.svelte`) ```svelte <div role="dialog" aria-label={m.notification_bell_label()} ...> ``` `role="dialog"` tells screen readers to expect a modal — focus should be trapped inside, with a clear close mechanism. This dropdown doesn't trap focus. Use `role="region"` or no role at all (it's just a popup panel). The ARIA pattern for this UI is a disclosure widget: `aria-expanded` on the button (already done) and no role on the panel. --- ## Low ### `setTimeout(0)` for cursor repositioning — use `tick()` (`MentionEditor.svelte`) ```ts setTimeout(() => { if (!textarea) return; const pos = mentionStart + replacement.length; textarea.selectionStart = pos; ... }, 0); ``` Sara flagged the 100ms one in `CommentThread`. Same note applies here: `setTimeout(0)` is a timing coincidence, not a guarantee. Import `tick` from `svelte` and `await tick()` before the DOM mutation so it runs after Svelte's next render, not just "soon". --- ## Summary table | Finding | Severity | File | Fix | |---|---|---|---| | Unescaped display names → XSS | Critical | `mention.ts:58–61` | Escape `displayName` before injecting | | `relativeTime()` hardcoded German | High | `NotificationBell.svelte:168–177` | Paraglide keys | | Badge `text-[10px]` below 12px | High | `NotificationBell.svelte` | `text-xs` | | Unread dot `aria-label="ungelesen"` hardcoded | High | `NotificationBell.svelte` | Paraglide key or sr-only pattern | | `aria-live` announces bare number | High | `NotificationBell.svelte` | sr-only live region outside button | | `<div role="button">` missing Space | Medium | `NotificationBell.svelte` | Use `<button>` | | Mention popup overflows on mobile | Medium | `MentionEditor.svelte` | `right-0 max-w-[calc(100vw-2rem)]` | | No focus target when notifications empty | Medium | `NotificationBell.svelte` | `tabindex="-1"` on wrapper div | | Prefs form uses JS-synced hidden inputs | Medium | `profile/+page.svelte` | Name attributes on checkboxes | | `role="dialog"` without focus trap | Medium | `NotificationBell.svelte` | Remove role or use `role="region"` | | `setTimeout(0)` for cursor positioning | Low | `MentionEditor.svelte` | `await tick()` | The deep-link flow itself is clean — opening the panel to the discussion tab and scrolling to the highlighted comment is exactly right. The main things holding this back from passing my bar are the internationalization regression in `relativeTime`, the 10px badge text, and the XSS (which the others already called out). Fix those three and the rest can follow in a quick polish pass.
Author
Owner

Response — @felixbrandt

Both reviews are correct across the board. No pushback from me. Here's what I'll fix and how:


BLOCKERs (fixing before any minor work)

Layering: CommentServiceAppUserRepository directly (Markus #5, Sara #1)
Adding UserService.findAllById(Collection<UUID>) and injecting UserService into CommentService. Same fix in NotificationService — it will go through UserService for user lookups and CommentService for comment lookups instead of hitting their repositories directly.

@Autowired(required = false) + ReflectionTestUtils (Sara #2)
Switching to Optional<JavaMailSender> constructor injection — Lombok handles this cleanly with @RequiredArgsConstructor. No reflection in tests.

GET /api/users/search no permission check (Sara #3)
Adding @RequirePermission(Permission.READ_ALL) at the UserSearchController class level. The test gets a matching 403 case for a no-permission user.


Data bug

actorName always null (Markus #1)
Promoting actorName from @Transient to a real persisted VARCHAR column (new migration V18). Setting it in notifyReply and notifyMentions from reply.getAuthorName() / comment.getAuthorName().


Architecture / design

NotificationController missing @RequirePermission (Sara #4)
Adding at class level. Tests get 403 coverage.

NotificationController returns Notification entity (Markus #4)
Introducing NotificationDTO record with the seven fields needed by the frontend. Service maps entity → DTO before returning. This eliminates the lazy-load N+1 and the AppUser field leakage at once.

@Transient mentionDTOs + FetchType.LAZYLazyInitializationException (Sara #5)
Going with the DTO approach: introducing CommentResponseDTO to carry mention display data instead of mutating the JPA entity with a transient field. Cleaner and no transaction dependency.

Notification failure rolls back the user's comment (Sara #6)
Annotating notifyReply and notifyMentions with @Transactional(propagation = Propagation.REQUIRES_NEW). A failed notification save will never surface to the user as a lost comment.

N+1 in notifyReply and notifyMentions (Markus #2, #3)
Replacing the per-ID findById loops with a single userRepository.findAllById(ids) call in each method (via UserService).


Security

Stored XSS in renderBodydisplayName not escaped (Sara #7, Markus #8 partially)
Applying the same escape-first treatment to firstName/lastName before building the mention string. Also switching from <a href="#"> to <span class="mention" data-user-id="…"> as Markus suggests — no profile page exists to link to, and the anchor's default scroll-to-top behaviour is actively harmful. Adding the regression test in mention.spec.ts for HTML-special chars in display names.


Test gaps

  • markRead_throwsNotFound_whenNotificationDoesNotExist (Sara #9)
  • markAllRead_delegatesToRepository (Sara #9)
  • countUnread_delegatesToRepository (Sara #9)
  • markOneRead_returns401_whenUnauthenticated (Sara #11)
  • postComment_triggersNotifyMentions_whenMentionedUserIdsProvided (Sara #12)
  • replyToComment_triggersNotifyMentions_whenMentionedUserIdsProvided (Sara #12)
  • search_returnsAtMostTenResults asserting jsonPath("$.length()").value(lessThanOrEqualTo(10)) (Sara #10)
  • XSS regression test in mention.spec.ts (Sara #19 / already mentioned above)

All of these will be added.


Frontend minors

  • relativeTime() hard-coded German + aria-label="ungelesen" (Markus, Sara #15/#16): Replacing with Intl.RelativeTimeFormat using the active locale, and adding a Paraglide key notification_unread for the aria-label.
  • <div role="button"><button> (Sara #17): Straightforward swap — native button handles Enter and Space for free.
  • debounceTimer not cleared on destroy (Sara #13): Adding onDestroy(() => clearTimeout(debounceTimer)).
  • setTimeout(100) for deep-link scroll (Sara #14): Replacing with await tick() + requestAnimationFrame.
  • Notification prefs hidden inputs (Sara #8): Reading formData.has('notifyOnReply') — standard HTML checkbox presence pattern.

Dead code

  • Unused List<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID) in NotificationRepository (Markus #7, Sara #18): Deleted.

I'll work through the BLOCKERs and data bug first, then the architecture items, then tests and minors. Will push as a single follow-up commit on this branch.

## Response — @felixbrandt Both reviews are correct across the board. No pushback from me. Here's what I'll fix and how: --- ### BLOCKERs (fixing before any minor work) **Layering: `CommentService` → `AppUserRepository` directly** (Markus #5, Sara #1) Adding `UserService.findAllById(Collection<UUID>)` and injecting `UserService` into `CommentService`. Same fix in `NotificationService` — it will go through `UserService` for user lookups and `CommentService` for comment lookups instead of hitting their repositories directly. **`@Autowired(required = false)` + `ReflectionTestUtils`** (Sara #2) Switching to `Optional<JavaMailSender>` constructor injection — Lombok handles this cleanly with `@RequiredArgsConstructor`. No reflection in tests. **`GET /api/users/search` no permission check** (Sara #3) Adding `@RequirePermission(Permission.READ_ALL)` at the `UserSearchController` class level. The test gets a matching 403 case for a no-permission user. --- ### Data bug **`actorName` always null** (Markus #1) Promoting `actorName` from `@Transient` to a real persisted `VARCHAR` column (new migration V18). Setting it in `notifyReply` and `notifyMentions` from `reply.getAuthorName()` / `comment.getAuthorName()`. --- ### Architecture / design **`NotificationController` missing `@RequirePermission`** (Sara #4) Adding at class level. Tests get 403 coverage. **`NotificationController` returns `Notification` entity** (Markus #4) Introducing `NotificationDTO` record with the seven fields needed by the frontend. Service maps entity → DTO before returning. This eliminates the lazy-load N+1 and the `AppUser` field leakage at once. **`@Transient mentionDTOs` + `FetchType.LAZY` → `LazyInitializationException`** (Sara #5) Going with the DTO approach: introducing `CommentResponseDTO` to carry mention display data instead of mutating the JPA entity with a transient field. Cleaner and no transaction dependency. **Notification failure rolls back the user's comment** (Sara #6) Annotating `notifyReply` and `notifyMentions` with `@Transactional(propagation = Propagation.REQUIRES_NEW)`. A failed notification save will never surface to the user as a lost comment. **N+1 in `notifyReply` and `notifyMentions`** (Markus #2, #3) Replacing the per-ID `findById` loops with a single `userRepository.findAllById(ids)` call in each method (via `UserService`). --- ### Security **Stored XSS in `renderBody` — `displayName` not escaped** (Sara #7, Markus #8 partially) Applying the same escape-first treatment to `firstName`/`lastName` before building the mention string. Also switching from `<a href="#">` to `<span class="mention" data-user-id="…">` as Markus suggests — no profile page exists to link to, and the anchor's default scroll-to-top behaviour is actively harmful. Adding the regression test in `mention.spec.ts` for HTML-special chars in display names. --- ### Test gaps - `markRead_throwsNotFound_whenNotificationDoesNotExist` (Sara #9) - `markAllRead_delegatesToRepository` (Sara #9) - `countUnread_delegatesToRepository` (Sara #9) - `markOneRead_returns401_whenUnauthenticated` (Sara #11) - `postComment_triggersNotifyMentions_whenMentionedUserIdsProvided` (Sara #12) - `replyToComment_triggersNotifyMentions_whenMentionedUserIdsProvided` (Sara #12) - `search_returnsAtMostTenResults` asserting `jsonPath("$.length()").value(lessThanOrEqualTo(10))` (Sara #10) - XSS regression test in `mention.spec.ts` (Sara #19 / already mentioned above) All of these will be added. --- ### Frontend minors - **`relativeTime()` hard-coded German + `aria-label="ungelesen"`** (Markus, Sara #15/#16): Replacing with `Intl.RelativeTimeFormat` using the active locale, and adding a Paraglide key `notification_unread` for the aria-label. - **`<div role="button">` → `<button>`** (Sara #17): Straightforward swap — native button handles Enter and Space for free. - **`debounceTimer` not cleared on destroy** (Sara #13): Adding `onDestroy(() => clearTimeout(debounceTimer))`. - **`setTimeout(100)` for deep-link scroll** (Sara #14): Replacing with `await tick()` + `requestAnimationFrame`. - **Notification prefs hidden inputs** (Sara #8): Reading `formData.has('notifyOnReply')` — standard HTML checkbox presence pattern. --- ### Dead code - Unused `List<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID)` in `NotificationRepository` (Markus #7, Sara #18): Deleted. --- I'll work through the BLOCKERs and data bug first, then the architecture items, then tests and minors. Will push as a single follow-up commit on this branch.
marcel added 7 commits 2026-03-28 11:47:05 +01:00
BLOCKERs:
- Remove direct AppUserRepository/CommentRepository access from CommentService and
  NotificationService — replaced with UserService.findAllById() and UserService
  (fixes layering contract from CLAUDE.md)
- Switch Optional<JavaMailSender> constructor injection — removes @Autowired(required=false)
  field and ReflectionTestUtils hack in tests
- Add @RequirePermission(READ_ALL) to UserSearchController — prevents user enumeration
  without read access

Data bug:
- Promote actorName from @Transient to persisted VARCHAR column (V18 migration)
- Set actorName in notifyReply and notifyMentions from comment.getAuthorName()

Architecture:
- Add @RequirePermission(READ_ALL) to NotificationController
- Introduce NotificationDTO — controller returns DTO instead of Notification entity,
  eliminating lazy-load N+1 and AppUser field leakage
- Change mentions FetchType to EAGER — fixes LazyInitializationException outside transaction
- Add @Transactional(propagation=REQUIRES_NEW) to notifyReply/notifyMentions so a
  notification failure cannot roll back the parent comment
- N+1 fix: replace per-ID findById loops with single findAllById bulk fetch
- Move collectParticipantIds to CommentService; notifyReply accepts Set<UUID> directly

Security:
- Escape displayName before injecting into renderBody HTML span
- Replace <a href="#"> with <span class="mention"> — no profile page to link to, and
  the anchor's scroll-to-top behaviour is harmful

Tests added/fixed:
- markRead_throwsNotFound, markAllRead_delegatesToRepository, countUnread_delegatesToRepository
- markOneRead_returns401, @RequirePermission 403 coverage for both controllers
- postComment/replyToComment_triggersNotifyMentions_whenMentionedUserIdsProvided
- search_returnsAtMostTenResults now asserts $.length() <= 10
- XSS regression test for escaped displayName in mention.spec.ts

Frontend minors:
- relativeTime() uses Intl.RelativeTimeFormat (locale-aware, not German-hardcoded)
- aria-label uses m.notification_unread() Paraglide key (de/en/es added)
- <div role="button"> replaced with <button> (native Enter+Space handling)
- onDestroy clears debounceTimer in MentionEditor
- setTimeout(100) replaced with await tick() + requestAnimationFrame in CommentThread
- Notification prefs form uses checkbox name attributes + formData.has() pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@RequirePermission now accepts Permission[] so a single annotation can
express "any of these" rather than a single required permission.

PermissionAspect updated accordingly — all existing single-value usages
compile unchanged (Java auto-wraps scalars in arrays for annotation attrs).

NotificationController: preference endpoints (GET/PUT /api/users/me/
notification-preferences) override the class-level READ_ALL gate with
{READ_ALL, WRITE_ALL, ANNOTATE_ALL} so users without READ_ALL can still
manage their own settings. Notification list endpoints retain READ_ALL.

UserSearchController: same broadened set so ANNOTATE_ALL users can search
for users to @mention when writing comments.

Tests: added WRITE_ALL and ANNOTATE_ALL passing cases for preferences and
user search; added 403 case for preferences with no permission; confirmed
WRITE_ALL cannot reach notification list endpoints.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove @RequirePermission(READ_ALL) from NotificationController class level so
  authenticated users with any permission (or none) can access their own notifications
- Add V19 migration, annotationId field to Notification entity and NotificationDTO
- NotificationService now stores annotationId from comment on both REPLY and MENTION
- Update controller tests: permission tests now expect 200, DTO constructor includes annotationId

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- NotificationBell now includes annotationId in the deep-link URL when available
- +page.svelte reads ?annotationId= param and sets activeAnnotationId on mount,
  opening the side panel instead of the bottom discussion drawer
- AnnotationSidePanel accepts and forwards targetCommentId to CommentThread
  so the specific comment is highlighted when navigating via a notification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Profile page now greys out the notification checkboxes and save button when
the user has no email set, with a hint to add one first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mention spans injected via {@html} need global CSS since scoped styles
don't reach dynamically inserted content. Uses ink text on accent-bg
background for visible but subtle chip appearance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test: add AnnotationSidePanel spec and fix env mock in layout spec
Some checks failed
CI / Unit & Component Tests (push) Successful in 3m47s
CI / Backend Unit Tests (push) Successful in 2m41s
CI / E2E Tests (push) Failing after 2h25m30s
CI / Unit & Component Tests (pull_request) Successful in 2m48s
CI / Backend Unit Tests (pull_request) Successful in 2m29s
CI / E2E Tests (pull_request) Failing after 2h29m1s
9900d0b54b
- AnnotationSidePanel: cover visibility (null vs set annotationId),
  close button callback, and targetCommentId forwarding
- layout.svelte.spec: mock $env/static/public to satisfy
  PUBLIC_NOTIFICATION_POLL_MS import from NotificationBell
- mention.spec: update assertion to match span-based mention rendering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Review — Leonie Voss, UI/UX & Accessibility

Verdict: APPROVE WITH COMMENTS — all 3 BLOCKERs and all 6 MAJORs from the previous round are resolved. Three findings remain: one to fix before merge, two for a follow-up.


Previous blockers — all fixed

Blocker Resolution
CommentServiceAppUserRepository layering violation Routed through UserSearchService
@Autowired(required=false) + ReflectionTestUtils hack Optional<JavaMailSender>
GET /api/users/search unguarded Class-level @RequirePermission on UserSearchController

Previous majors — all fixed

Major Resolution
NotificationController missing permission gate Preferences endpoints gated; notification CRUD intentionally open to any authenticated user — design choice, explicitly tested
@Transient + FetchType.LAZY = LazyInitializationException Switched to FetchType.EAGER on @ManyToMany mentions
notifyReply/notifyMentions join outer transaction Propagation.REQUIRES_NEW on both methods
Stored XSS in renderBody escapedDisplayName properly HTML-escaped before <span> injection; UUID in data-user-id is inherently safe
Notification prefs hidden-input JS hack Native checkboxes + formData.has() — works without JS, correct SvelteKit pattern
Missing test cases markAllRead 401/204, markOneRead 401/403, full prefs permission matrix — all present

Remaining findings

Medium — fix before merge

1. Unread badge count is wrong when user has >10 notifications

NotificationBell.svelte:19

let unreadCount = $derived(notifications.filter((n) => !n.read).length);

The bell fetches /api/notifications?size=10. If a user has 15 unread notifications, the badge shows at most 10. NotificationService.countUnread() already exists but has no HTTP endpoint.

Simplest fix for this project's notification volume: bump the fetch to size=50. If a proper endpoint is preferred, add GET /api/notifications/unread-count → { count: long } and poll it independently.

2. markOneRead 404 test still missing

Flagged in the previous round. NotificationControllerTest now covers 401 and 403 for PATCH /api/notifications/{id}/read but there's no test for the case where the notification ID doesn't exist. notificationService.markRead() throws DomainException.notFound(...) which the global handler maps to 404 — one test case is still absent.


Minor — fix in follow-up

3. <button role="option"> is invalid ARIA — MentionEditor.svelte:211

<button role="option" aria-selected={i === highlightedIndex} ...>

role="option" overrides the implicit button role; <button> is not a valid child of role="listbox". Screen readers will mis-announce the popup entries. Replace with:

<div
  tabindex="-1"
  role="option"
  aria-selected={i === highlightedIndex}
  onmousedown={(e) => { e.preventDefault(); selectUser(user); }}
>

4. role="dialog" missing aria-modal="true"NotificationBell.svelte:191

<div role="dialog" aria-label={m.notification_bell_label()}>

Without aria-modal="true" VoiceOver and NVDA continue reading the page behind the dropdown. Add aria-modal="true".

5. setTimeout(..., 0) should be tick()MentionEditor.svelte:102, 166

Previously flagged as MINOR, still present. tick() from Svelte flushes the DOM synchronously after reactive state settles — more correct than a 0 ms timer.

import { tick } from 'svelte';
// ...
await tick();
textarea?.focus();

The hard work is done. The badge accuracy issue (#1) is the only thing I'd push to fix before merge since it's user-visible on first load; the ARIA issues are real but won't block users from using the feature.

— @leonievoss

## Review — Leonie Voss, UI/UX & Accessibility **Verdict: APPROVE WITH COMMENTS** — all 3 BLOCKERs and all 6 MAJORs from the previous round are resolved. Three findings remain: one to fix before merge, two for a follow-up. --- ### Previous blockers — all fixed ✅ | Blocker | Resolution | |---|---| | `CommentService` → `AppUserRepository` layering violation | Routed through `UserSearchService` | | `@Autowired(required=false)` + `ReflectionTestUtils` hack | `Optional<JavaMailSender>` | | `GET /api/users/search` unguarded | Class-level `@RequirePermission` on `UserSearchController` | ### Previous majors — all fixed ✅ | Major | Resolution | |---|---| | `NotificationController` missing permission gate | Preferences endpoints gated; notification CRUD intentionally open to any authenticated user — design choice, explicitly tested | | `@Transient` + `FetchType.LAZY` = `LazyInitializationException` | Switched to `FetchType.EAGER` on `@ManyToMany mentions` | | `notifyReply`/`notifyMentions` join outer transaction | `Propagation.REQUIRES_NEW` on both methods | | Stored XSS in `renderBody` | `escapedDisplayName` properly HTML-escaped before `<span>` injection; UUID in `data-user-id` is inherently safe | | Notification prefs hidden-input JS hack | Native checkboxes + `formData.has()` — works without JS, correct SvelteKit pattern | | Missing test cases | `markAllRead` 401/204, `markOneRead` 401/403, full prefs permission matrix — all present | --- ### Remaining findings #### **Medium — fix before merge** **1. Unread badge count is wrong when user has >10 notifications** `NotificationBell.svelte:19` ```typescript let unreadCount = $derived(notifications.filter((n) => !n.read).length); ``` The bell fetches `/api/notifications?size=10`. If a user has 15 unread notifications, the badge shows at most 10. `NotificationService.countUnread()` already exists but has no HTTP endpoint. Simplest fix for this project's notification volume: bump the fetch to `size=50`. If a proper endpoint is preferred, add `GET /api/notifications/unread-count → { count: long }` and poll it independently. **2. `markOneRead` 404 test still missing** Flagged in the previous round. `NotificationControllerTest` now covers 401 and 403 for `PATCH /api/notifications/{id}/read` but there's no test for the case where the notification ID doesn't exist. `notificationService.markRead()` throws `DomainException.notFound(...)` which the global handler maps to 404 — one test case is still absent. --- #### **Minor — fix in follow-up** **3. `<button role="option">` is invalid ARIA — `MentionEditor.svelte:211`** ```svelte <button role="option" aria-selected={i === highlightedIndex} ...> ``` `role="option"` overrides the implicit button role; `<button>` is not a valid child of `role="listbox"`. Screen readers will mis-announce the popup entries. Replace with: ```svelte <div tabindex="-1" role="option" aria-selected={i === highlightedIndex} onmousedown={(e) => { e.preventDefault(); selectUser(user); }} > ``` **4. `role="dialog"` missing `aria-modal="true"` — `NotificationBell.svelte:191`** ```svelte <div role="dialog" aria-label={m.notification_bell_label()}> ``` Without `aria-modal="true"` VoiceOver and NVDA continue reading the page behind the dropdown. Add `aria-modal="true"`. **5. `setTimeout(..., 0)` should be `tick()` — `MentionEditor.svelte:102, 166`** Previously flagged as MINOR, still present. `tick()` from Svelte flushes the DOM synchronously after reactive state settles — more correct than a 0 ms timer. ```typescript import { tick } from 'svelte'; // ... await tick(); textarea?.focus(); ``` --- The hard work is done. The badge accuracy issue (#1) is the only thing I'd push to fix before merge since it's user-visible on first load; the ARIA issues are real but won't block users from using the feature. *— @leonievoss*
Author
Owner

Code Review — @mkeller

Solid feature work. The separation between notification, mention, and deep-link concerns is clean. Tests are present and meaningful. Two issues need to be addressed before merge; the rest are observations.


🔴 Blockers

1. Schema not designed upfront — V16 + V18 + V19 must be collapsed

The migration trail tells the story of the implementation order, not the schema:

  • V16 creates notifications without actor_name and annotation_id
  • V18 adds actor_name via ALTER TABLE
  • V19 adds annotation_id via ALTER TABLE

This is a Flyway anti-pattern. Once on prod you can never reorder migrations, and you carry the ALTER TABLE overhead forever. The branch hasn't merged — fix it now. Collapse V16 + V18 + V19 into a single clean migration that creates the table with all columns:

-- V16__notifications_and_preferences.sql (clean, final version)
ALTER TABLE users ADD COLUMN notify_on_reply   BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE users ADD COLUMN notify_on_mention BOOLEAN NOT NULL DEFAULT false;

CREATE TABLE notifications (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    recipient_id  UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    type          VARCHAR(32) NOT NULL,
    document_id   UUID,
    reference_id  UUID,
    annotation_id UUID,
    read          BOOLEAN NOT NULL DEFAULT false,
    created_at    TIMESTAMP NOT NULL DEFAULT now(),
    actor_name    VARCHAR(255)
);

CREATE INDEX idx_notifications_recipient ON notifications(recipient_id, read, created_at DESC);

V17 stays as-is. Delete V18 and V19.

2. NotificationDTO missing @Schema(requiredMode = REQUIRED) — TypeScript types will be wrong

// current — all fields are optional in the generated TypeScript client
public record NotificationDTO(
        UUID id,
        NotificationType type,
        ...
        boolean read,
        LocalDateTime createdAt,
        String actorName
) {}

Per project convention, all backend-always-populated fields must carry @Schema(requiredMode = Schema.RequiredMode.REQUIRED). Without this, the generated TypeScript client types id, type, read, and createdAt as optional — callers write notification.id! or silently pass undefined.

documentId, referenceId, annotationId, and actorName are genuinely nullable and stay without the annotation.


🟡 Medium Priority

3. @Transient mentionDTOs on the DocumentComment entity is a layering smell

@Transient
@Builder.Default
private List<MentionDTO> mentionDTOs = new ArrayList<>();

A @Transient field that must be populated by the service before returning is an implicit contract that any future code path can silently violate. Every method that returns a DocumentComment now has an invisible obligation to call withMentionDTOs. The entity is doing double duty as a persistence object and a response object.

The project style is to return entities directly as responses — but this case pushes that pattern past its natural limit. A lightweight CommentResponse record would be the clean solution. At minimum, consider a @PostLoad lifecycle hook that hydrates mentionDTOs automatically so the obligation can't be forgotten.

4. Notification endpoints have no @RequirePermission — document the intent

getNotifications, markAllRead, and markOneRead have no permission annotation. The rest of the API requires at least READ_ALL for any data access. If the intent is "any authenticated user gets their own notifications" (reasonable — they're personal), state it with a comment. Silent inconsistency invites confusion.

5. Polling in NotificationBell — SSE is the right tool here

setInterval(loadNotifications, pollMs);

This works, but SSE (EventSource on the client, SseEmitter on the Spring Boot side) would give you true server-push with zero polling overhead and automatic reconnect. For a family archive with < 20 users the polling cost is negligible, but since you're building the notification infrastructure anyway, SSE is the architecturally correct fit. A separate comment follows with a concrete implementation plan.


No action required

  • mention.ts XSS handling — escape-first pattern is correct. The 19 Vitest cases cover the edge cases well.
  • PermissionAspectPermission[] change — clean upgrade. anyOf semantics match the use case.
  • resolveUser(Authentication) — duplicated in NotificationController and CommentController. Minor; a shared SecurityUtils helper would clean this up but it's not blocking.
  • @ManyToMany(fetch = EAGER) on DocumentComment.mentions — acceptable at this data scale.

Fix the two blockers and this is ready to merge.

## Code Review — @mkeller Solid feature work. The separation between notification, mention, and deep-link concerns is clean. Tests are present and meaningful. Two issues need to be addressed before merge; the rest are observations. --- ### 🔴 Blockers #### 1. Schema not designed upfront — V16 + V18 + V19 must be collapsed The migration trail tells the story of the implementation order, not the schema: - **V16** creates `notifications` without `actor_name` and `annotation_id` - **V18** adds `actor_name` via `ALTER TABLE` - **V19** adds `annotation_id` via `ALTER TABLE` This is a Flyway anti-pattern. Once on prod you can never reorder migrations, and you carry the ALTER TABLE overhead forever. The branch hasn't merged — fix it now. Collapse V16 + V18 + V19 into a single clean migration that creates the table with all columns: ```sql -- V16__notifications_and_preferences.sql (clean, final version) ALTER TABLE users ADD COLUMN notify_on_reply BOOLEAN NOT NULL DEFAULT false; ALTER TABLE users ADD COLUMN notify_on_mention BOOLEAN NOT NULL DEFAULT false; CREATE TABLE notifications ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), recipient_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, type VARCHAR(32) NOT NULL, document_id UUID, reference_id UUID, annotation_id UUID, read BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMP NOT NULL DEFAULT now(), actor_name VARCHAR(255) ); CREATE INDEX idx_notifications_recipient ON notifications(recipient_id, read, created_at DESC); ``` V17 stays as-is. Delete V18 and V19. #### 2. `NotificationDTO` missing `@Schema(requiredMode = REQUIRED)` — TypeScript types will be wrong ```java // current — all fields are optional in the generated TypeScript client public record NotificationDTO( UUID id, NotificationType type, ... boolean read, LocalDateTime createdAt, String actorName ) {} ``` Per project convention, all backend-always-populated fields must carry `@Schema(requiredMode = Schema.RequiredMode.REQUIRED)`. Without this, the generated TypeScript client types `id`, `type`, `read`, and `createdAt` as optional — callers write `notification.id!` or silently pass `undefined`. `documentId`, `referenceId`, `annotationId`, and `actorName` are genuinely nullable and stay without the annotation. --- ### 🟡 Medium Priority #### 3. `@Transient mentionDTOs` on the `DocumentComment` entity is a layering smell ```java @Transient @Builder.Default private List<MentionDTO> mentionDTOs = new ArrayList<>(); ``` A `@Transient` field that must be populated by the service before returning is an implicit contract that any future code path can silently violate. Every method that returns a `DocumentComment` now has an invisible obligation to call `withMentionDTOs`. The entity is doing double duty as a persistence object and a response object. The project style is to return entities directly as responses — but this case pushes that pattern past its natural limit. A lightweight `CommentResponse` record would be the clean solution. At minimum, consider a `@PostLoad` lifecycle hook that hydrates `mentionDTOs` automatically so the obligation can't be forgotten. #### 4. Notification endpoints have no `@RequirePermission` — document the intent `getNotifications`, `markAllRead`, and `markOneRead` have no permission annotation. The rest of the API requires at least `READ_ALL` for any data access. If the intent is "any authenticated user gets their own notifications" (reasonable — they're personal), state it with a comment. Silent inconsistency invites confusion. #### 5. Polling in `NotificationBell` — SSE is the right tool here ```svelte setInterval(loadNotifications, pollMs); ``` This works, but SSE (`EventSource` on the client, `SseEmitter` on the Spring Boot side) would give you true server-push with zero polling overhead and automatic reconnect. For a family archive with < 20 users the polling cost is negligible, but since you're building the notification infrastructure anyway, SSE is the architecturally correct fit. A separate comment follows with a concrete implementation plan. --- ### ✅ No action required - **`mention.ts` XSS handling** — escape-first pattern is correct. The 19 Vitest cases cover the edge cases well. - **`PermissionAspect` — `Permission[]` change** — clean upgrade. `anyOf` semantics match the use case. - **`resolveUser(Authentication)`** — duplicated in `NotificationController` and `CommentController`. Minor; a shared `SecurityUtils` helper would clean this up but it's not blocking. - **`@ManyToMany(fetch = EAGER)` on `DocumentComment.mentions`** — acceptable at this data scale. --- Fix the two blockers and this is ready to merge.
Author
Owner

SSE Implementation Plan — replacing polling in NotificationBell

This is an optional follow-up to point 5 in the review. The polling works fine for now, but here is a complete, concrete plan for replacing it with Server-Sent Events when the time comes. No external dependencies required — Spring Boot handles SSE natively.


How SSE works here

  1. The browser opens a persistent HTTP connection to /api/notifications/stream via the browser's EventSource API.
  2. Spring Boot holds that connection open with a SseEmitter.
  3. When NotificationService creates a new notification, it looks up the active emitter for that user and pushes an event directly over the open connection.
  4. The browser receives the event and updates the bell badge — no polling, no latency.
  5. If the connection drops (network blip, tab sleep), EventSource reconnects automatically.

This is unidirectional (server → client only), which is exactly what EventSource and SSE are designed for.


Backend changes

1. SseEmitterRegistry — a simple in-memory map of userId → emitter

// service/SseEmitterRegistry.java
@Component
public class SseEmitterRegistry {

    // One emitter per user (last tab wins — good enough for a family app)
    private final ConcurrentHashMap<UUID, SseEmitter> emitters = new ConcurrentHashMap<>();

    public SseEmitter register(UUID userId) {
        SseEmitter emitter = new SseEmitter(0L); // 0 = no timeout
        emitters.put(userId, emitter);
        emitter.onCompletion(() -> emitters.remove(userId, emitter));
        emitter.onTimeout(()    -> emitters.remove(userId, emitter));
        emitter.onError(e ->    emitters.remove(userId, emitter));
        return emitter;
    }

    public void send(UUID userId, Object data) {
        SseEmitter emitter = emitters.get(userId);
        if (emitter == null) return; // user has no open tab — notification is in the DB, they'll see it on next load
        try {
            emitter.send(SseEmitter.event().name("notification").data(data));
        } catch (IOException e) {
            emitters.remove(userId, emitter);
        }
    }
}

Why ConcurrentHashMap instead of a message broker? This is one server process, < 20 users, zero operational overhead. A broker would be gross over-engineering here.

2. NotificationController — add the stream endpoint

// In NotificationController.java
@GetMapping(value = "/api/notifications/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(Authentication authentication) {
    AppUser user = resolveUser(authentication);
    return sseEmitterRegistry.register(user.getId());
}

No @RequirePermission needed here — same rationale as the other notification endpoints (any authenticated user gets their own stream).

3. NotificationService — push after saving

// In NotificationService.java — inject SseEmitterRegistry
private final SseEmitterRegistry sseEmitterRegistry;

private Notification saveAndPush(Notification notification) {
    Notification saved = notificationRepository.save(notification);
    NotificationDTO dto = toDTO(saved);
    sseEmitterRegistry.send(notification.getRecipient().getId(), dto);
    return saved;
}

Call saveAndPush instead of notificationRepository.save in both notifyReply and notifyMentions.

One important note: saveAndPush is called inside a @Transactional method. The emitter push happens before the transaction commits. For this use case that's fine — the frontend will re-fetch on receipt anyway. If strict read-your-writes is ever required, use @TransactionalEventListener(phase = AFTER_COMMIT) instead.

4. Spring Security — allow SSE through the CORS/async filter chain

Spring Boot's default async support handles SseEmitter automatically with Jetty, but verify that your SecurityConfig does not block the streaming endpoint. If it does:

// In SecurityConfig — add to the permit list if needed
.requestMatchers("/api/notifications/stream").authenticated()

It should already be covered by your existing authenticated-routes pattern, but worth checking.


Frontend changes

Replace setInterval with EventSource in NotificationBell.svelte

Remove the polling interval entirely. Replace with:

// In NotificationBell.svelte <script>
let eventSource: EventSource | null = null;

onMount(() => {
    // Load initial state once
    loadNotifications();

    // Open SSE stream
    eventSource = new EventSource('/api/notifications/stream');
    eventSource.addEventListener('notification', (e) => {
        const notification = JSON.parse(e.data);
        // Prepend to list, increment unread count
        notifications = [notification, ...notifications];
        if (!notification.read) unreadCount += 1;
    });
    eventSource.onerror = () => {
        // EventSource reconnects automatically — no manual retry needed
    };
});

onDestroy(() => {
    eventSource?.close();
});

The PUBLIC_NOTIFICATION_POLL_MS env var and the setInterval / clearInterval logic can be deleted entirely.


What you do NOT need

  • No WebSockets (bidirectional isn't needed here)
  • No RabbitMQ or Kafka (single process, in-memory registry is sufficient)
  • No Redis pub/sub (only relevant if you ever run multiple backend instances behind a load balancer — revisit then)
  • No Spring WebFlux (the blocking SseEmitter API works fine with Jetty and Spring MVC)

Files to touch

File Change
service/SseEmitterRegistry.java New — ~30 lines
controller/NotificationController.java Add GET /api/notifications/stream endpoint (~5 lines)
service/NotificationService.java Inject registry, replace save with saveAndPush (~10 lines)
NotificationBell.svelte Replace setInterval with EventSource (~20 lines net change)

Total: ~65 lines of net new code, ~20 lines deleted. No new dependencies, no new infrastructure.

## SSE Implementation Plan — replacing polling in `NotificationBell` This is an optional follow-up to point 5 in the review. The polling works fine for now, but here is a complete, concrete plan for replacing it with Server-Sent Events when the time comes. No external dependencies required — Spring Boot handles SSE natively. --- ### How SSE works here 1. The browser opens a persistent HTTP connection to `/api/notifications/stream` via the browser's `EventSource` API. 2. Spring Boot holds that connection open with a `SseEmitter`. 3. When `NotificationService` creates a new notification, it looks up the active emitter for that user and pushes an event directly over the open connection. 4. The browser receives the event and updates the bell badge — no polling, no latency. 5. If the connection drops (network blip, tab sleep), `EventSource` reconnects automatically. This is unidirectional (server → client only), which is exactly what `EventSource` and SSE are designed for. --- ### Backend changes #### 1. `SseEmitterRegistry` — a simple in-memory map of userId → emitter ```java // service/SseEmitterRegistry.java @Component public class SseEmitterRegistry { // One emitter per user (last tab wins — good enough for a family app) private final ConcurrentHashMap<UUID, SseEmitter> emitters = new ConcurrentHashMap<>(); public SseEmitter register(UUID userId) { SseEmitter emitter = new SseEmitter(0L); // 0 = no timeout emitters.put(userId, emitter); emitter.onCompletion(() -> emitters.remove(userId, emitter)); emitter.onTimeout(() -> emitters.remove(userId, emitter)); emitter.onError(e -> emitters.remove(userId, emitter)); return emitter; } public void send(UUID userId, Object data) { SseEmitter emitter = emitters.get(userId); if (emitter == null) return; // user has no open tab — notification is in the DB, they'll see it on next load try { emitter.send(SseEmitter.event().name("notification").data(data)); } catch (IOException e) { emitters.remove(userId, emitter); } } } ``` > **Why `ConcurrentHashMap` instead of a message broker?** This is one server process, < 20 users, zero operational overhead. A broker would be gross over-engineering here. #### 2. `NotificationController` — add the stream endpoint ```java // In NotificationController.java @GetMapping(value = "/api/notifications/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream(Authentication authentication) { AppUser user = resolveUser(authentication); return sseEmitterRegistry.register(user.getId()); } ``` No `@RequirePermission` needed here — same rationale as the other notification endpoints (any authenticated user gets their own stream). #### 3. `NotificationService` — push after saving ```java // In NotificationService.java — inject SseEmitterRegistry private final SseEmitterRegistry sseEmitterRegistry; private Notification saveAndPush(Notification notification) { Notification saved = notificationRepository.save(notification); NotificationDTO dto = toDTO(saved); sseEmitterRegistry.send(notification.getRecipient().getId(), dto); return saved; } ``` Call `saveAndPush` instead of `notificationRepository.save` in both `notifyReply` and `notifyMentions`. > **One important note:** `saveAndPush` is called inside a `@Transactional` method. The emitter push happens before the transaction commits. For this use case that's fine — the frontend will re-fetch on receipt anyway. If strict read-your-writes is ever required, use `@TransactionalEventListener(phase = AFTER_COMMIT)` instead. #### 4. Spring Security — allow SSE through the CORS/async filter chain Spring Boot's default async support handles `SseEmitter` automatically with Jetty, but verify that your `SecurityConfig` does not block the streaming endpoint. If it does: ```java // In SecurityConfig — add to the permit list if needed .requestMatchers("/api/notifications/stream").authenticated() ``` It should already be covered by your existing authenticated-routes pattern, but worth checking. --- ### Frontend changes #### Replace `setInterval` with `EventSource` in `NotificationBell.svelte` Remove the polling interval entirely. Replace with: ```typescript // In NotificationBell.svelte <script> let eventSource: EventSource | null = null; onMount(() => { // Load initial state once loadNotifications(); // Open SSE stream eventSource = new EventSource('/api/notifications/stream'); eventSource.addEventListener('notification', (e) => { const notification = JSON.parse(e.data); // Prepend to list, increment unread count notifications = [notification, ...notifications]; if (!notification.read) unreadCount += 1; }); eventSource.onerror = () => { // EventSource reconnects automatically — no manual retry needed }; }); onDestroy(() => { eventSource?.close(); }); ``` The `PUBLIC_NOTIFICATION_POLL_MS` env var and the `setInterval` / `clearInterval` logic can be deleted entirely. --- ### What you do NOT need - No WebSockets (bidirectional isn't needed here) - No RabbitMQ or Kafka (single process, in-memory registry is sufficient) - No Redis pub/sub (only relevant if you ever run multiple backend instances behind a load balancer — revisit then) - No Spring WebFlux (the blocking `SseEmitter` API works fine with Jetty and Spring MVC) --- ### Files to touch | File | Change | |---|---| | `service/SseEmitterRegistry.java` | New — ~30 lines | | `controller/NotificationController.java` | Add `GET /api/notifications/stream` endpoint (~5 lines) | | `service/NotificationService.java` | Inject registry, replace `save` with `saveAndPush` (~10 lines) | | `NotificationBell.svelte` | Replace `setInterval` with `EventSource` (~20 lines net change) | Total: ~65 lines of net new code, ~20 lines deleted. No new dependencies, no new infrastructure.
marcel added 3 commits 2026-03-28 15:42:52 +01:00
- Add SseEmitterRegistry (ConcurrentHashMap, one emitter per user)
- Add GET /api/notifications/stream SSE endpoint and unread-count endpoint
- Push SSE event on every notifyReply / notifyMentions via saveAndPush()
- Collapse V18/V19 migrations into V16 (actor_name + annotation_id upfront)
- Add @Schema(requiredMode=REQUIRED) to NotificationDTO required fields
- Switch NotificationBell from polling to EventSource; seed unread count on open
- Fix MentionEditor: replace setTimeout with await tick(); div role=option
- Add aria-modal=true to NotificationBell dialog
- Tests: SseEmitterRegistryTest (3), NotificationServiceTest (+2), NotificationControllerTest (+5)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaced one-way checked={...} with bind:group={selected} driven by a
writable $derived. In Svelte 5, the $derived pattern guarantees the DOM
checked state is always in sync at FormData capture time, so groupIds
is never accidentally sent as [] when the admin edits their own profile.

Sending groupIds:[] causes adminUpdateUser to clear all groups, which
revokes the admin's own permissions on the next request.

Tests: UserServiceTest (+4 for adminUpdateUser group behaviour),
page.svelte.spec.ts (+1 FormData assertion at submit time).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix: allow WRITE_ALL users to create and delete annotations
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) Successful in 2m42s
CI / Backend Unit Tests (pull_request) Successful in 2m21s
CI / E2E Tests (pull_request) Has been cancelled
affee407ef
@RequirePermission on POST and DELETE annotation endpoints previously
only listed ANNOTATE_ALL. Users with WRITE_ALL (but not ANNOTATE_ALL)
received 403. A user who can write documents should also be able to
annotate them — both permissions now accepted on both methods.

Also updates canAnnotate in +layout.server.ts to match, so the UI
correctly reflects annotation capability for WRITE_ALL users.

Tests: AnnotationControllerTest (+2 RED→GREEN).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-03-28 15:53:14 +01:00
fix: allow WRITE_ALL users to post, reply, and edit comments
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) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
070153a71d
All five comment write endpoints (post doc comment, reply to doc comment,
post annotation comment, reply to annotation comment, edit comment) only
listed ANNOTATE_ALL in @RequirePermission. Users with WRITE_ALL received
403 on every comment action. Same pattern as the annotation fix.

Tests: CommentControllerTest (+5 RED→GREEN for WRITE_ALL on each method).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Follow-up Review — @mkeller

All blockers resolved. All medium-priority items addressed. Ready to merge.


Blockers — resolved

Migration collapse: V16 is now a single clean schema with all columns (annotation_id, actor_name) included from the start. V18 and V19 are gone. This is exactly right.

NotificationDTO @Schema: id, type, read, createdAt are correctly marked REQUIRED. Nullable fields (documentId, referenceId, annotationId, actorName) are left without the annotation. Correct.


Medium items — resolved

Permission intent documented: The controller comment is clear and sufficient:

// These endpoints are intentionally open to any authenticated user —
// they return and mutate only the current user's own notifications, scoped
// by the resolved user identity. No additional permission check is required.

@Transient mentionDTOs: A comment documents the implicit contract. The layering smell remains but the obligation is no longer invisible. Acceptable given the project's convention of returning entities directly.

SSE: Implemented cleanly and matches the plan exactly. One thing worth calling out explicitly — notifyReply and notifyMentions are annotated with @Transactional(propagation = Propagation.REQUIRES_NEW). That is better than what I suggested. It means a notification failure runs in its own transaction and cannot roll back the parent comment save. Good call.


One minor observation

onMount in NotificationBell fires both fetchNotifications() and fetchUnreadCount() — two HTTP requests to populate the initial badge. Since the /api/notifications page response already contains all the data needed to count unreads, you could derive the count from data.content.filter(n => !n.read).length and skip the second request. Not worth blocking the merge for, but a simple cleanup for a follow-up commit.


LGTM. Merge when ready.

## Follow-up Review — @mkeller All blockers resolved. All medium-priority items addressed. Ready to merge. --- ### ✅ Blockers — resolved **Migration collapse:** V16 is now a single clean schema with all columns (`annotation_id`, `actor_name`) included from the start. V18 and V19 are gone. This is exactly right. **`NotificationDTO` `@Schema`:** `id`, `type`, `read`, `createdAt` are correctly marked `REQUIRED`. Nullable fields (`documentId`, `referenceId`, `annotationId`, `actorName`) are left without the annotation. Correct. --- ### ✅ Medium items — resolved **Permission intent documented:** The controller comment is clear and sufficient: ```java // These endpoints are intentionally open to any authenticated user — // they return and mutate only the current user's own notifications, scoped // by the resolved user identity. No additional permission check is required. ``` **`@Transient mentionDTOs`:** A comment documents the implicit contract. The layering smell remains but the obligation is no longer invisible. Acceptable given the project's convention of returning entities directly. **SSE:** Implemented cleanly and matches the plan exactly. One thing worth calling out explicitly — `notifyReply` and `notifyMentions` are annotated with `@Transactional(propagation = Propagation.REQUIRES_NEW)`. That is better than what I suggested. It means a notification failure runs in its own transaction and cannot roll back the parent comment save. Good call. --- ### One minor observation `onMount` in `NotificationBell` fires both `fetchNotifications()` and `fetchUnreadCount()` — two HTTP requests to populate the initial badge. Since the `/api/notifications` page response already contains all the data needed to count unreads, you could derive the count from `data.content.filter(n => !n.read).length` and skip the second request. Not worth blocking the merge for, but a simple cleanup for a follow-up commit. --- LGTM. Merge when ready.
Author
Owner

Why Propagation.REQUIRES_NEW matters here — @mkeller

Short version: it makes sure a broken email server can never delete a user's comment.


The problem without it

CommentService.postComment is @Transactional. When it calls notificationService.notifyReply(...), Spring does not start a new transaction by default — it reuses the one that's already open. That means everything runs in one unit of work:

BEGIN TRANSACTION
  INSERT INTO document_comments ...   ← comment saved
  INSERT INTO notifications ...       ← notification saved
  mailSender.send(...)                ← SMTP call — can throw MailException
COMMIT  ← or ROLLBACK if anything above threw

If mailSender.send() throws (SMTP server is down, misconfigured, network hiccup), Spring catches the exception, marks the transaction for rollback, and the comment is lost too — even though the comment itself was perfectly valid. The user hits "Post" and nothing happens.


What REQUIRES_NEW does

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void notifyReply(...) { ... }

When CommentService calls this method, Spring suspends the outer transaction and opens a brand-new independent one just for the notification work:

BEGIN TRANSACTION A          ← opened by CommentService
  INSERT INTO document_comments ...

  BEGIN TRANSACTION B        ← REQUIRES_NEW suspends A, opens B
    INSERT INTO notifications ...
    mailSender.send(...)     ← throws MailException
  ROLLBACK B                 ← only B rolls back; A is unaffected
  
  (A resumes)
COMMIT A                     ← comment is saved successfully

Transaction B fails and rolls back. Transaction A never knew about it and commits cleanly. The user's comment is saved. They just don't get a notification — which is the correct behaviour. A notification is a side effect, not the core operation.


Why this is the right call specifically here

The two conditions that make REQUIRES_NEW the right choice:

  1. The side effect is non-critical. A missing notification is annoying. A missing comment is data loss. These have very different failure severities, so they shouldn't share a transaction.
  2. The side effect touches external infrastructure (JavaMailSender → SMTP). External calls are inherently unreliable and should never be inside a transaction that guards business data.

If notifications were purely in-DB with no external calls, the default propagation (REQUIRED — join the outer transaction) would be fine. REQUIRES_NEW is the correct upgrade the moment you add the mail send.


One thing to be aware of

REQUIRES_NEW suspends the outer transaction but holds its database connection open while Transaction B runs. Under high concurrency this can exhaust the connection pool faster than the default behaviour. For a family archive with < 20 users this is a complete non-issue. If this ever ran at significant scale, the clean solution is to move notification dispatch to an @TransactionalEventListener(phase = AFTER_COMMIT) — which fires after the outer transaction commits and needs no held connection. Not relevant here, just good to know.

## Why `Propagation.REQUIRES_NEW` matters here — @mkeller Short version: it makes sure a broken email server can never delete a user's comment. --- ### The problem without it `CommentService.postComment` is `@Transactional`. When it calls `notificationService.notifyReply(...)`, Spring does **not** start a new transaction by default — it reuses the one that's already open. That means everything runs in one unit of work: ``` BEGIN TRANSACTION INSERT INTO document_comments ... ← comment saved INSERT INTO notifications ... ← notification saved mailSender.send(...) ← SMTP call — can throw MailException COMMIT ← or ROLLBACK if anything above threw ``` If `mailSender.send()` throws (SMTP server is down, misconfigured, network hiccup), Spring catches the exception, marks the transaction for rollback, and **the comment is lost too** — even though the comment itself was perfectly valid. The user hits "Post" and nothing happens. --- ### What `REQUIRES_NEW` does ```java @Transactional(propagation = Propagation.REQUIRES_NEW) public void notifyReply(...) { ... } ``` When `CommentService` calls this method, Spring **suspends** the outer transaction and opens a brand-new independent one just for the notification work: ``` BEGIN TRANSACTION A ← opened by CommentService INSERT INTO document_comments ... BEGIN TRANSACTION B ← REQUIRES_NEW suspends A, opens B INSERT INTO notifications ... mailSender.send(...) ← throws MailException ROLLBACK B ← only B rolls back; A is unaffected (A resumes) COMMIT A ← comment is saved successfully ``` Transaction B fails and rolls back. Transaction A never knew about it and commits cleanly. The user's comment is saved. They just don't get a notification — which is the correct behaviour. A notification is a side effect, not the core operation. --- ### Why this is the right call specifically here The two conditions that make `REQUIRES_NEW` the right choice: 1. **The side effect is non-critical.** A missing notification is annoying. A missing comment is data loss. These have very different failure severities, so they shouldn't share a transaction. 2. **The side effect touches external infrastructure** (`JavaMailSender` → SMTP). External calls are inherently unreliable and should never be inside a transaction that guards business data. If notifications were purely in-DB with no external calls, the default propagation (`REQUIRED` — join the outer transaction) would be fine. `REQUIRES_NEW` is the correct upgrade the moment you add the mail send. --- ### One thing to be aware of `REQUIRES_NEW` suspends the outer transaction but **holds its database connection open** while Transaction B runs. Under high concurrency this can exhaust the connection pool faster than the default behaviour. For a family archive with < 20 users this is a complete non-issue. If this ever ran at significant scale, the clean solution is to move notification dispatch to an `@TransactionalEventListener(phase = AFTER_COMMIT)` — which fires after the outer transaction commits and needs no held connection. Not relevant here, just good to know.
Author
Owner

Minor cleanup — double request on mount in NotificationBell — @mkeller

Right now onMount fires two parallel requests on every page load:

onMount(() => {
    fetchNotifications();     // GET /api/notifications?size=10
    fetchUnreadCount();       // GET /api/notifications/unread-count
    eventSource = new EventSource('/api/notifications/stream');
    ...
});

fetchNotifications() here is redundant. The notifications list is already fetched inside toggleDropdown() when the user actually opens the bell:

async function toggleDropdown() {
    open = !open;
    if (open) {
        await fetchNotifications();  // already here
        ...
    }
}

So on every page load you're fetching a list of notifications that nobody has asked to see yet — purely to pre-warm a dropdown that may never be opened.

The fix: remove fetchNotifications() from onMount. Keep fetchUnreadCount() — that one is needed immediately to show the badge.

onMount(() => {
    fetchUnreadCount();       // only the badge count — needed on mount
    eventSource = new EventSource('/api/notifications/stream');
    ...
});

The dropdown still feels instant because toggleDropdown fetches just before it renders, and SSE keeps the badge count live after that anyway.

My earlier suggestion to derive the count from data.content.filter(n => !n.read).length was imprecise — it would undercount if the user has more than 10 unread notifications. Dropping the list prefetch is the cleaner fix.

## Minor cleanup — double request on mount in `NotificationBell` — @mkeller Right now `onMount` fires two parallel requests on every page load: ```typescript onMount(() => { fetchNotifications(); // GET /api/notifications?size=10 fetchUnreadCount(); // GET /api/notifications/unread-count eventSource = new EventSource('/api/notifications/stream'); ... }); ``` `fetchNotifications()` here is redundant. The notifications list is already fetched inside `toggleDropdown()` when the user actually opens the bell: ```typescript async function toggleDropdown() { open = !open; if (open) { await fetchNotifications(); // already here ... } } ``` So on every page load you're fetching a list of notifications that nobody has asked to see yet — purely to pre-warm a dropdown that may never be opened. **The fix:** remove `fetchNotifications()` from `onMount`. Keep `fetchUnreadCount()` — that one is needed immediately to show the badge. ```typescript onMount(() => { fetchUnreadCount(); // only the badge count — needed on mount eventSource = new EventSource('/api/notifications/stream'); ... }); ``` The dropdown still feels instant because `toggleDropdown` fetches just before it renders, and SSE keeps the badge count live after that anyway. My earlier suggestion to derive the count from `data.content.filter(n => !n.read).length` was imprecise — it would undercount if the user has more than 10 unread notifications. Dropping the list prefetch is the cleaner fix.
marcel added 1 commit 2026-03-28 16:03:32 +01:00
fix: remove redundant fetchNotifications() from onMount in NotificationBell
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Successful in 2m39s
CI / Backend Unit Tests (push) Successful in 2m21s
CI / E2E Tests (push) Has started running
29f81f48db
Notifications are already fetched lazily inside toggleDropdown() when
the user opens the dropdown. Only fetchUnreadCount() is needed on mount
to show the badge.

Closes #725

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel merged commit 29f81f48db into main 2026-03-28 16:06:59 +01:00
marcel deleted branch feat/71-72-73-notifications-mentions-deeplinks 2026-03-28 16:07:00 +01:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#127