Module: notifications¶
Purpose¶
Delivers in-app, email, and WebSocket push notifications. Reacts to domain events from other modules — it has no public REST API for creating notifications. The only mutation exposed to clients is marking notifications as read.
Entities Owned¶
| Entity | Table | Key Fields |
|---|---|---|
Notification |
notifications |
userId UUID FK→users NOT NULL, type: NotificationType NOT NULL, title VARCHAR(255) NOT NULL, body TEXT?, link VARCHAR(500)?, read BOOLEAN NOT NULL DEFAULT false |
NotificationType enum values: COMMENT_MENTION, ISSUE_ASSIGNED, AUTOMATION, SLA_BREACHED.
Notifications are append-only from the outside. The only allowed mutation via the API is flipping read to true.
DB Schema¶
notifications (V8)¶
CREATE TABLE notifications (
id UUID NOT NULL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(30) NOT NULL,
title VARCHAR(255) NOT NULL,
body TEXT,
link VARCHAR(500),
read BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
Index: idx_notifications_user on (user_id, read, created_at DESC) — supports the common query pattern of fetching unread notifications for a user in reverse-chronological order.
API Endpoints¶
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/v1/notifications |
USER | List notifications for the authenticated user; paged, default page=0, size=20, sorted by createdAt DESC |
GET |
/api/v1/notifications/unread-count |
USER | Returns { "count": <long> } — count of unread notifications for the authenticated user |
PATCH |
/api/v1/notifications/{id}/read |
USER | Mark a single notification as read; returns updated NotificationResponse |
There is no POST endpoint. Notifications are created exclusively by @EventListener methods in NotificationService reacting to domain events.
Events Emitted¶
None. The notifications module does not publish domain events.
Events Consumed¶
NotificationService¶
| Event | Handler | Action |
|---|---|---|
MentionEvent |
onMention() |
Saves Notification(type=COMMENT_MENTION) for mentionedUser.id; title = "You were mentioned in {issue.key}"; body = first 200 chars of comment |
IssueFieldChangedEvent (field = "assignee") |
onIssueFieldChanged() |
Saves Notification(type=ISSUE_ASSIGNED) for the new assignee; no-ops when field is not "assignee" or when issue.assignee is null |
EmailService¶
| Event | Handler | Action |
|---|---|---|
MentionEvent |
onMention() |
Sends SimpleMailMessage to mentionedUser.email; no-ops when spring.mail.host is blank |
IssueFieldChangedEvent (field = "assignee") |
onAssigned() |
Sends assignment email to assignee.email; no-ops when field is not "assignee", when issue.assignee is null, or when SMTP is not configured |
Email delivery is entirely optional: EmailService.enabled is false when spring.mail.host is blank, so no JavaMailSender calls are made.
Key Files¶
backend/src/main/kotlin/com/taskowolf/notifications/domain/Notification.ktbackend/src/main/kotlin/com/taskowolf/notifications/domain/NotificationType.ktbackend/src/main/kotlin/com/taskowolf/notifications/application/NotificationService.ktbackend/src/main/kotlin/com/taskowolf/notifications/application/EmailService.ktbackend/src/main/kotlin/com/taskowolf/notifications/api/NotificationController.ktbackend/src/main/kotlin/com/taskowolf/notifications/api/dto/NotificationResponse.ktbackend/src/main/resources/db/migration/V8__create_notifications.sql
Extension Points¶
- New notification type: Add the value to
NotificationType. Publish a domain event from the originating module (do not callNotificationServicedirectly). Add an@EventListenermethod inNotificationServicethat maps the event to aNotificationentity and callsnotificationRepository.save(). Push the saved notification to the WebSocket destination/user/{userId}/queue/notificationsso the frontend receives it in real time. - Email for new events: Add a corresponding
@EventListenerinEmailServicefollowing the existing pattern: checkenabled, build aSimpleMailMessage, callmailSender.send().
Common Pitfalls¶
- DO NOT call
NotificationServicedirectly from other modules — publish a domain event and letNotificationServicereact via@EventListener. Direct calls create hidden coupling and bypass the event contract. The internalcreateDirect()method exists for the notification module's own wiring only. Never call it from code outside thenotificationspackage — publish an event instead. - WebSocket push uses STOMP destination
/user/{userId}/queue/notifications— do not change this path without updating the frontend subscription. EmailServiceis a no-op whenspring.mail.hostis blank; tests must constructEmailServicewith an explicit non-blankmailHostto verify thatmailSender.send()is called.IssueFieldChangedEventis emitted for all field changes;NotificationService.onIssueFieldChanged()explicitly guardsif (event.field != "assignee") return— copy this pattern for any listener that handles only specific fields.
Example¶
NotificationService.onMention is the canonical pattern for consuming a domain event and persisting a notification:
@EventListener
@Transactional
fun onMention(event: MentionEvent) {
repository.save(
Notification(
userId = event.mentionedUser.id,
type = NotificationType.COMMENT_MENTION,
title = "You were mentioned in ${event.issue.key}",
body = event.comment.body.take(200),
link = "/issues/${event.issue.key}"
)
)
}
The @EventListener + @Transactional pair ensures the notification is saved in the same transaction as the originating event. If the outer transaction rolls back, the notification is not persisted.
Test Patterns¶
NotificationServiceTest— pure unit test with MockK; mocksNotificationRepository. Verifies:MentionEventsaves aCOMMENT_MENTIONformentionedUser;IssueFieldChangedEvent(field="assignee")saves anISSUE_ASSIGNEDfor the assignee;markReadflipsreadand saves;markReadthrowsNotFoundExceptionfor an unknown notification id.EmailServiceTest— pure unit test; constructsEmailServicewith a mockJavaMailSender. Verifies: mention email sent when SMTP host is set; mention email skipped when SMTP host is blank; assignment email sent on assignee change; assignment email skipped when field is not "assignee".