14 KiB
Plan: Notifications (#71) + @mentions (#72)
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.- Latest migration:
V13__add_file_hash.sql. CommentThread.svelte— uses fetch-based API calls (not SvelteKit form actions), plain<textarea>for input.
Key decisions
- Mention rendering is server-side: backend returns
mentions: [{id, firstName, lastName}]on every comment response; frontend uses this list to turn@Nametext into links. contenteditablediv replaces<textarea>in the comment editor.- Only
AppUseris searchable for mentions — notPersonrecords. - Notification scope: replies in threads you're part of + @mentions only.
- Polling interval configurable via
PUBLIC_NOTIFICATION_POLL_MSVite env var (default 60 000 ms).
Phase 1 — Notifications backend (#71)
Step 1 — Migration V14
File: V14__notifications_and_preferences.sql
-- Notification preferences on users
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;
-- Notifications table
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, -- 'REPLY' | 'MENTION'
document_id UUID,
reference_id UUID, -- commentId
read BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_notifications_recipient ON notifications(recipient_id, read, created_at DESC);
Step 2 — NotificationType enum
public enum NotificationType { REPLY, MENTION }
Step 3 — Notification entity
@Entity @Table(name = "notifications") @Data @NoArgsConstructor @AllArgsConstructor @Builder
public class Notification {
@Id @GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "recipient_id", nullable = false)
private AppUser recipient;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private NotificationType type;
@Column(name = "document_id")
private UUID documentId;
@Column(name = "reference_id")
private UUID referenceId; // commentId
@Column(nullable = false)
@Builder.Default
private boolean read = false;
@CreationTimestamp
private LocalDateTime createdAt;
}
Step 4 — NotificationRepository
Page<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId, Pageable pageable);
long countByRecipientIdAndReadFalse(UUID recipientId);
@Modifying
@Query("UPDATE Notification n SET n.read = true WHERE n.recipient.id = :userId")
void markAllReadByRecipientId(UUID userId);
Step 5 — AppUser model update
Add two boolean fields:
@Column(nullable = false) @Builder.Default private boolean notifyOnReply = false;
@Column(nullable = false) @Builder.Default private boolean notifyOnMention = false;
Step 6 — NotificationService
Injections: 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):
String commentPath = comment.getAnnotationId() != null
? "?commentId=" + comment.getId() + "&annotationId=" + comment.getAnnotationId()
: "?commentId=" + comment.getId();
// → {baseUrl}/documents/{documentId}?commentId={id}[&annotationId={id}]
Step 7 — NotificationController
GET /api/notifications → paginated list (params: page, size)
POST /api/notifications/read-all → mark all read (204)
PATCH /api/notifications/{id}/read → mark one read (200)
GET /api/users/me/notification-preferences → NotificationPreferenceDTO
PUT /api/users/me/notification-preferences → update + return updated DTO
NotificationPreferenceDTO:
public record NotificationPreferenceDTO(boolean notifyOnReply, boolean notifyOnMention) {}
Step 8 — Hook CommentService → NotificationService
Inject NotificationService into CommentService. After replyToComment saves: call notificationService.notifyReply(reply). Mention notifications are wired in Phase 2.
Phase 2 — Mentions backend (#72)
Step 9 — Migration V15
File: V15__comment_mentions.sql
CREATE TABLE comment_mentions (
comment_id UUID NOT NULL REFERENCES document_comments(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (comment_id, user_id)
);
Step 10 — DocumentComment entity update
Add a @ManyToMany join to AppUser via comment_mentions. The serialized form should expose only {id, firstName, lastName} — use a MentionDTO record and a @Transient List<MentionDTO> mentionDTOs field populated by the service, with @JsonIgnore on the mentions JPA collection.
// JPA — not serialized
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "comment_mentions",
joinColumns = @JoinColumn(name = "comment_id"),
inverseJoinColumns = @JoinColumn(name = "user_id"))
@JsonIgnore
@Builder.Default
private List<AppUser> mentions = new ArrayList<>();
// Serialized — populated by service
@Transient
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private List<MentionDTO> mentionDTOs = new ArrayList<>();
MentionDTO:
public record MentionDTO(UUID id, String firstName, String lastName) {}
Step 11 — CreateCommentDTO update
Add List<UUID> mentionedUserIds = new ArrayList<>() field.
Step 12 — CommentService update
- Inject
AppUserRepository. - In
postCommentandreplyToComment: look upAppUserby eachmentionedUserId, setcomment.setMentions(resolvedUsers), save, then callnotificationService.notifyMentions(mentionedUserIds, comment). - In
withRepliesand everywhere a comment is returned: populatementionDTOsfrommentions. - Extract a
withMentionDTOs(DocumentComment c)private helper to keep it DRY.
Step 13 — UserSearchController
GET /api/users/search?q={query} → List<MentionDTO>
- Requires authentication.
- Query matches
LOWER(first_name || ' ' || last_name)LIKE%q%ORLOWER(username)LIKE%q%. - Returns at most 10 results.
- New
AppUserRepositoryquery method:findTop10ByFirstNameContainingIgnoreCaseOrLastNameContainingIgnoreCaseOrUsernameContainingIgnoreCase(q, q, q)— or a@Querywith LIKE for the concat case.
Step 14 — Regenerate TypeScript API types
cd backend && ./mvnw clean package -DskipTests
# start backend with --spring.profiles.active=dev
cd frontend && npm run generate:api
Phase 3 — Notifications frontend (#71)
Step 15 — Vite env variable
Add to frontend/.env:
PUBLIC_NOTIFICATION_POLL_MS=60000
Step 16 — NotificationBell.svelte
New 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.- Bell SVG with badge (
unreadCount > 0). - Click bell → toggle
open, fetch fresh list. - Dropdown: last 10 notifications, each showing type icon + text + relative time + unread dot. Clicking an item →
PATCH /api/notifications/{id}/read+goto('/documents/{documentId}'). - "Alle gelesen" button →
POST /api/notifications/read-all+ resetunreadCount. - Click-outside directive (copy pattern from layout user menu) closes dropdown.
Step 17 — Wire bell into +layout.svelte
Add <NotificationBell /> to the nav right side, between ThemeToggle and user menu. Only render when data.user is present (not on auth pages).
Step 18 — Profile page preferences
In profile/+page.server.ts: add GET /api/users/me/notification-preferences to 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 dedicated PUT fetch call.
Phase 4 — Mentions frontend (#72)
Step 19 — MentionEditor.svelte
Replaces <textarea> in comment forms. Emits onsubmit: (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):- Delete the
@querytext covered bymentionRange. - Create
<span contenteditable="false" data-mention-id="{user.id}" class="mention-chip">@{firstName} {lastName}</span>. - Insert span at current range position.
- Insert a
" "text node after the span. - Move cursor to after the space.
- Delete the
extractContent(el)— walkschildNodes:TEXT_NODE→ appendtextContentSPAN[data-mention-id]→ append@{textContent}, pushdataset.mentionIdtomentionedUserIdsBR→ append\n
onPaste(e)— prevent default,document.execCommand('insertText', false, plainText).onKeyDown(e)— when popup open: ↑↓ navigate, Enter/Tab select (preventDefault), Escape dismiss. Ctrl/Cmd+Enter → submit.- Submit: call
extractContent, invokeonsubmitprop, clear editor (el.innerHTML = '').
Step 20 — MentionPopup.svelte
Props: users, selectedIndex, anchor: DOMRect, onSelect.
position: fixed; top: {anchor.bottom + 4}px; left: {anchor.left}pxrole="listbox", rowsrole="option".- Highlight
selectedIndexrow withbg-accent-bg. - Mouse click on row →
onSelect(user).
Step 21 — CommentThread.svelte updates
- Replace root-comment and reply
<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.- Add
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
| Step | Test | Type |
|---|---|---|
| Step 6 | notifyReply creates notifications for all thread participants except replier |
Unit |
| Step 6 | notifyReply sends email only to users with notifyOnReply=true |
Unit |
| Step 6 | notifyMentions creates MENTION notification per mentioned user |
Unit |
| Step 7 | GET /api/notifications returns 200 + list |
Controller slice |
| Step 7 | POST /api/notifications/read-all marks all as read |
Controller slice |
| Step 7 | GET /api/users/me/notification-preferences returns current prefs |
Controller slice |
| Step 13 | GET /api/users/search?q=Hans returns matching users |
Controller slice |
| Step 13 | Unauthenticated GET /api/users/search returns 401 |
Controller slice |
| Step 19 | detectMention returns query after @ |
Vitest unit |
| Step 19 | extractContent returns correct body + IDs for mixed text+chips |
Vitest unit |
| E2E | Bell badge appears after another user replies | Playwright |
| E2E | @mention popup appears when typing @, user can select |
Playwright |
| E2E | Mentioned user has unread notification after comment is posted | Playwright |
Branch name
feat/71-72-notifications-and-mentions
Commit 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 rendering