As a user I want to receive notifications for archive activity so I stay informed when family members annotate, comment, or start conversations #71
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Background
The archive is a shared family tool. When someone replies in a comment thread or @mentions a user, the relevant person currently has no way of knowing. This issue adds an in-app notification centre (bell icon) and opt-in email notifications with per-user preferences in the profile.
Notification triggers
Document edits, new annotations, and new conversations do not trigger notifications — too noisy for a family archive.
User Journey — in-app bell
User opens the app and sees a bell icon in the nav bar. If there are unread notifications, a small badge shows the count. Clicking the bell opens a dropdown showing recent events in reverse chronological order:
Clicking a notification marks it as read and navigates to the relevant document and thread. "Mark all as read" clears the badge.
User Journey — email notifications
User opens their profile page. Under a new "Benachrichtigungen" section they see two toggles:
Both default to off. Emails are plain text and link directly to the relevant document/thread. The existing
JavaMailSender+SimpleMailMessageinfrastructure (used byPasswordResetService) is reused as-is.E2E Scenarios
Backend implementation notes
Notificationentity:id,recipientUser,type(enum:REPLY,MENTION),documentId,referenceId(comment/annotation id),read(boolean),createdAtAppUser(notifyOnReply,notifyOnMention) — no separate entity neededNotificationService: createsNotificationrecords and conditionally sends email. InjectsJavaMailSenderwith@Autowired(required = false)exactly likePasswordResetService. Usesapp.mail.fromproperty already in place.GET /api/notifications— paginated list for current userPOST /api/notifications/read-all— mark all as readPATCH /api/notifications/{id}/read— mark one as readGET /api/users/me/notification-preferences+PUT— load/save preferencesFrontend implementation notes
/api/notifications?unread=true&size=1on a configurable interval. Interval driven by a Vite env variablePUBLIC_NOTIFICATION_POLL_MS(default60000) so it can be tuned without a code change.Related: #72 (@mention — triggers MENTION notifications via this system)
Implementation plan finalised
See
.agent/current-plan.md(Phases 1 + 3) for the full step-by-step plan. Summary of decisions locked in:Scope — two triggers only: replies in threads you participated in, and @mentions. No document-edit or new-annotation notifications.
Email — reuses existing
JavaMailSender+SimpleMailMessageinfrastructure fromPasswordResetServiceverbatim.@Autowired(required = false)means it degrades gracefully when no SMTP is configured.Preferences — two boolean columns directly on
AppUser(notify_on_reply,notify_on_mention), no separate entity. Both defaultfalse.Polling — frontend polls every
PUBLIC_NOTIFICATION_POLL_MSms (Vite env var, default 60 000). No WebSocket.Migration order — V14 (this issue), V15 (@mentions, #72).
Branch —
feat/71-72-notifications-and-mentions(both issues share one branch and PR)Full Implementation Plan
This plan covers #71 (notifications) and #72 (@mentions) together. Branch:
feat/71-72-notifications-and-mentions.Context — existing code that matters
DocumentComment— entity withid,documentId,annotationId,parentId,authorId,authorName,content,replies(transient). No mention storage yet.CommentService—postComment,replyToComment,editComment,deleteComment. ReturnsDocumentCommentdirectly (no response DTO).CreateCommentDTO— only hascontent. NeedsmentionedUserIdsadded.AppUser— hasid,username,firstName,lastName,email. No notification preferences yet.PasswordResetService— usesJavaMailSender(@Autowired(required = false)) +SimpleMailMessage.NotificationServicefollows the exact same pattern.V13__add_file_hash.sql.CommentThread.svelte— uses fetch-based API calls (not SvelteKit form actions), plain<textarea>for input.Key decisions
mentionDTOs: [{id, firstName, lastName}]on every comment response; frontend uses this list to turn@Nametext into links — no client-side text parsing.contenteditablediv replaces<textarea>in the comment editor. Usebind:this, neverbind:innerHTML— own the DOM directly.AppUseris searchable for mentions — notPersonrecords.PUBLIC_NOTIFICATION_POLL_MSVite env var (default 60 000 ms).?commentId=and optionally?annotationId=— see #73.Phase 1 — Notifications backend (#71)
Step 1 — Migration V14
File:
V14__notifications_and_preferences.sqlStep 2 —
NotificationTypeenumStep 3 —
NotificationentityStep 4 —
NotificationRepositoryStep 5 —
AppUsermodel updateAdd two boolean fields:
Step 6 —
NotificationServiceInjections:
NotificationRepository,CommentRepository,AppUserRepository,JavaMailSender(optional),@Value app.mail.from,@Value app.base-url.Key methods:
notifyReply(DocumentComment reply)— collects all uniqueauthorIds from the root comment + its siblings, removes the replier, creates aREPLYnotification per participant, sends email to those withnotifyOnReply=true.notifyMentions(List<UUID> mentionedUserIds, DocumentComment comment)— creates aMENTIONnotification per mentioned user, sends email to those withnotifyOnMention=true.getNotifications(UUID userId, Pageable pageable)— delegates to repository.countUnread(UUID userId)— delegates to repository.markAllRead(UUID userId)— delegates to repository.markRead(UUID notificationId, UUID userId)— loads, verifies ownership, setsread=true, saves.Email bodies follow the same plain-text pattern as
PasswordResetService. Link includes deep-link params (Refs #73):Step 7 —
NotificationControllerNotificationPreferenceDTO:Step 8 — Hook
CommentService→NotificationServiceInject
NotificationServiceintoCommentService. AfterreplyToCommentsaves: callnotificationService.notifyReply(reply). Mention notifications are wired in Phase 2.Phase 2 — Mentions backend (#72)
Step 9 — Migration V15
File:
V15__comment_mentions.sqlStep 10 —
DocumentCommententity updateAdd a
@ManyToManyjoin toAppUserviacomment_mentions. The serialized form exposes only{id, firstName, lastName}— use aMentionDTOrecord and a@Transient List<MentionDTO> mentionDTOsfield populated by the service, with@JsonIgnoreon the JPA collection.MentionDTO:Step 11 —
CreateCommentDTOupdateAdd
List<UUID> mentionedUserIds = new ArrayList<>()field.Step 12 —
CommentServiceupdateAppUserRepository.postCommentandreplyToComment: look upAppUserby eachmentionedUserId, setcomment.setMentions(resolvedUsers), save, then callnotificationService.notifyMentions(mentionedUserIds, comment).withRepliesand everywhere a comment is returned: populatementionDTOsfrommentions.withMentionDTOs(DocumentComment c)private helper to keep it DRY.Step 13 —
UserSearchControllerLOWER(first_name || ' ' || last_name)LIKE%q%ORLOWER(username)LIKE%q%.AppUserRepositoryquery method or@Querywith LIKE on concatenated name + username.Step 14 — Regenerate TypeScript API types
Phase 3 — Notifications frontend (#71)
Step 15 — Vite env variable
Add to
frontend/.env:Step 16 —
NotificationBell.svelteNew component. Props: none (reads current user implicitly via API).
State:
unreadCount,open,notifications[].Behaviour:
onMount: pollGET /api/notifications?size=10everyPUBLIC_NOTIFICATION_POLL_MSms. Store interval ref for cleanup.unreadCount > 0).open, fetch fresh list.PATCH /api/notifications/{id}/read+goto('/documents/{documentId}?commentId={referenceId}[&annotationId=...]')(see #73).POST /api/notifications/read-all+ resetunreadCount.Step 17 — Wire bell into
+layout.svelteAdd
<NotificationBell />to the nav right side, betweenThemeToggleand user menu. Only render whendata.useris present (not on auth pages).Step 18 — Profile page preferences
In
profile/+page.server.ts: addGET /api/users/me/notification-preferencesto the load function.In
profile/+page.svelte: new "Benachrichtigungen" card with two checkbox toggles. Save via existing form action pattern (POST with new action key) or a dedicatedPUTfetch call.Phase 4 — Mentions frontend (#72)
Step 19 —
MentionEditor.svelteReplaces
<textarea>in comment forms. Emitsonsubmit: (payload: { body: string; mentionedUserIds: string[] }) => void.Internal logic:
detectMention(el)— on eachinputevent: getSelection, scan backwards in the current text node for an@with no whitespace between it and the cursor. Returns{ query, range }or null.fetchUsers(query)— debounced (200 ms), callsGET /api/users/search?q={query}, updatesusersstate.insertChip(el, user, mentionRange):@querytext covered bymentionRange.<span contenteditable="false" data-mention-id="{user.id}" class="mention-chip">@{firstName} {lastName}</span>." "text node after the span.extractContent(el)— walkschildNodes:TEXT_NODE→ appendtextContentSPAN[data-mention-id]→ append@{textContent}, pushdataset.mentionIdtomentionedUserIdsBR→ append\nonPaste(e)— prevent default,document.execCommand('insertText', false, plainText).onKeyDown(e)— when popup open: ↑↓ navigate, Enter/Tab select (preventDefault), Escape dismiss. Ctrl/Cmd+Enter → submit.extractContent, invokeonsubmitprop, clear editor (el.innerHTML = '').Step 20 —
MentionPopup.svelteProps:
users,selectedIndex,anchor: DOMRect,onSelect.position: fixed; top: {anchor.bottom + 4}px; left: {anchor.left}pxrole="listbox", rowsrole="option".selectedIndexrow withbg-accent-bg.onSelect(user).Step 21 —
CommentThread.svelteupdates<textarea>+ submit buttons with<MentionEditor onsubmit={handlePost} />/<MentionEditor onsubmit={handleReply} />.handlePost({ body, mentionedUserIds })— POST{ content: body, mentionedUserIds }.handleReply({ body, mentionedUserIds })— POST reply with same shape.renderBody(comment: DocumentComment): string— for each entry incomment.mentionDTOs, replace@{firstName} {lastName}in the body with<a href="/users/{id}" class="mention-link">@{firstName} {lastName}</a>. Use{@html renderBody(comment)}in the template.DocumentCommentTypeScript type gainsmentionDTOs: { id: string; firstName: string; lastName: string }[].TDD checkpoints
notifyReplycreates notifications for all thread participants except repliernotifyReplysends email only to users withnotifyOnReply=truenotifyMentionscreates MENTION notification per mentioned userGET /api/notificationsreturns 200 + listPOST /api/notifications/read-allmarks all as readGET /api/users/me/notification-preferencesreturns current prefsGET /api/users/search?q=Hansreturns matching usersGET /api/users/searchreturns 401detectMentionreturns query after@extractContentreturns correct body + IDs for mixed text+chips@, user can selectCommit order (each atomic)
feat(backend): add notifications table and user preferences columns — V14feat(backend): add Notification entity, NotificationService, and testsfeat(backend): add NotificationController endpointsfeat(backend): trigger reply notifications from CommentServicefeat(backend): add comment_mentions table — V15feat(backend): add MentionDTO and wire mentions into DocumentCommentfeat(backend): save mentions on comment post/reply and trigger mention notificationsfeat(backend): add GET /api/users/search endpointchore(api): regenerate TypeScript typesfeat(frontend): add NotificationBell component with polling dropdownfeat(frontend): add notification preferences to profile pagefeat(frontend): add MentionEditor and MentionPopup componentsfeat(frontend): wire MentionEditor into CommentThread with mention renderingArchitecture review — @mkeller
Overall the backend design is clean. Schema is minimal, email reuse is pragmatic, preference storage as two boolean columns on
AppUseris the right call (no separate entity needed). A few notes:Polling — correct choice, no change needed
The 60-second poll is the right call for this app. SSE would give real-time push without WebSockets, but the persistent-connection management in SvelteKit streaming adds operational friction that isn't justified for a family archive with a handful of users. Boring technology wins here.
@Transient List<MentionDTO>onDocumentCommentThe plan puts
mentionDTOsas a@Transientfield on the entity, populated by the service before serialization. This is a consequence of the no-response-DTO pattern already established in this codebase — consistent, so not a blocker. Add an explicit comment on the field so it's clear to anyone reading the entity later:The critical issue is in #72 — see my comment there before any frontend work begins.
UX Review — @leonievoss
The backend design (confirmed clean by the architect) and the functional scope are solid. Accessibility and senior usability gaps need addressing on the frontend side.
🔴 Critical — Bell badge is silent to screen readers
When the unread count changes after a poll, there is no announcement. The badge element must use
aria-live="polite"and the bell button needs a meaningful label — not just the number:Without this, screen reader users have no way of knowing new notifications have arrived.
🔴 Critical — Dropdown has no focus management spec
The issue says nothing about keyboard behavior for the dropdown. Two rules are non-negotiable:
Without this, keyboard-only users are stranded after opening the dropdown.
🔴 High — Empty state is unspecified
When there are no notifications, the dropdown opens to a blank box. Users will think the feature is broken. Add to the implementation notes:
🟡 Medium — Badge must meet contrast and size minimums for seniors
A small low-contrast badge will be missed by a 65-year-old user. Specify: minimum 20×20px,
background: #002850(brand-navy), white text. Do not use a muted gray.🟡 Medium — "Alle gelesen" must be visually deprioritized
This button must never appear as a primary CTA — accidental mass-dismissal is a real risk. Use small, secondary styling (e.g.
text-xs font-medium text-gray-500 hover:text-brand-navy), never a filled button.🟡 Medium — Email opt-in copy: plain language for seniors
"Antworten in Kommentarfäden"is technically correct but jargon-heavy. Suggest:Full sentences, no abbreviations.
On polling interval — architect confirmed, no change needed
The architect's assessment that 60s polling is the right call for this app is correct. SSE would give real-time delivery but the persistent-connection management in SvelteKit adds operational friction not justified here. Boring technology wins. ✅
✅ What's good
AppUser— no separate entity needed, right call.QA Review — @saraholt
The implementation plan is thorough and the TDD checkpoint table is a good start. Several critical test cases are missing that must be in this PR before merge.
🔴 Blocking — cross-user notification isolation
We are building per-user data. We must explicitly verify isolation. Two
@WebMvcTestcontroller slice tests:GET /api/notificationsreturns only the current user's notifications — never another user'sPATCH /api/notifications/{id}/readreturns 403 when the notification belongs to a different userThis is the notification equivalent of what we'd test for any tenant-scoped table. If these aren't tested, we won't know until a production bug.
🔴 Blocking —
notifyReplymust not notify the replier themselvesUnit test for
NotificationService.notifyReply:Without this, a user replying to their own thread gets spammed with their own notification.
🟡 Missing unit tests for
NotificationServiceThe TDD table covers happy paths. Add:
notifyReplydeduplicates participants — same user posting twice in a thread gets one notification, not twoauthorIdcollection logicnotifyReplysends email only to users withnotifyOnReply=true, not all participantsPUTpersists both booleans independently — saving one does not overwrite the otherAppUserare independent; verify neither clobbers the other🟡 Missing E2E scenarios
The issue lists the critical journeys. Add these to the Playwright suite:
aria-live—axe-playwrightwill catch this automatically if the attribute is missing, so it's low overhead✅ What's solid
notify_on_reply/notify_on_mentionas booleans directly onAppUseris the right call@Autowired(required = false)onJavaMailSender— graceful degradation without SMTP config, same pattern asPasswordResetServiceThe backend plan is clean. The test gaps above are the only things blocking merge from a QA standpoint.