Feature: Notification System (In-App + Email)
Metadata
- Issue ID: #40
- Status: In Progress
- Owner: dev
- Related PRs: feature branch
40-implement-notification-system→ PR targetingdev(commitbe5bf17). Migration-only commit866a8bdwas historically pushed directly tomainunder the previous version of the migration convention; under the current convention all migrations — including this one — flow throughdevvia PR instead.
Overview
Centralized notification system that delivers important updates to users via in-app notifications and email. Notifications are stored in the database and surfaced through a REST API. Email delivery is triggered for high-priority events (subscription updates, password reset, security alerts). All notification dispatch is handled asynchronously via a queue (queue implementation owned by a separate workstream — this doc covers the notification module itself).
Frontend Behavior
Notification Panel / Dropdown
- Displays a bell icon in the top navigation bar
- Shows an unread count badge when
is_read = falsenotifications exist - Clicking the bell opens a dropdown/panel listing the latest notifications (most recent first)
- Each notification item shows:
title,content,typebadge,created_at(relative time), and a read/unread indicator - Clicking a notification marks it as read and navigates to
action_urlif present - "Mark all as read" button bulk-updates all unread notifications for the current user
- Empty state: "You have no notifications" when list is empty
- Loading state shown while fetching
- Error state shown if the API call fails
Notification Types (visual distinction)
| Type | Color / Icon |
|---|---|
system | Grey / info icon |
activity | Blue / activity icon |
subscription | Green / billing icon |
security | Red / shield icon |
Backend Behavior
Endpoints
GET /notifications
- Returns paginated list of notifications for the authenticated user
- Query params:
page,limit(default 20),is_read(optional filter) - Ordered by
created_at DESC - Excludes expired notifications (
expires_at < now)
Response:
{
"data": [
{
"id": "uuid",
"type": "activity",
"title": "Your OCR is complete",
"content": "Document 'notes.pdf' has been processed.",
"is_read": false,
"priority": 1,
"action_url": "/documents/uuid",
"created_at": "2026-04-21T14:00:00Z"
}
],
"total": 42,
"unread_count": 5,
"page": 1,
"limit": 20
}
PATCH /notifications/:id/read
- Marks a single notification as read (
is_read = true,updated_at = now) - Returns 404 if notification not found or does not belong to the authenticated user
Response:
{ "id": "uuid", "is_read": true }
PATCH /notifications/read-all
- Marks all unread notifications for the authenticated user as read
- Returns count of updated records
Response:
{ "updated": 5 }
DELETE /notifications/:id
- Soft-delete or hard-delete a single notification
- Returns 404 if not found or not owned by user
Business Logic
- All endpoints require authentication (JWT guard)
- Notifications are always scoped to
user_idfrom the JWT — never acceptuser_idfrom the request body - Deduplication: before creating a notification, check for an existing unread notification with the same
type+related_entity_id+user_id; skip creation if one exists - Expired notifications (
expires_at < now) are excluded from all read queries - Priority values:
1= low,2= medium,3= high — higher priority notifications appear first whencreated_atis equal
Notification Creation (Internal Service — called by queue processor)
The NotificationsService.create() method is the single entry point for persisting notifications. It is called by the queue processor (not directly from controllers).
create(dto: CreateNotificationDto): Promise<notification>
// dto fields: user_id, type, title, content, priority, action_url?, action_data?,
// related_entity_id?, related_entity_type?, related_user_id?, expires_at?
Email Notifications
- Sent for: subscription events, password reset, security alerts
- Email provider: Zoho SMTP via
@nestjs-modules/mailer+nodemailer - Email is triggered from the queue processor via
MailService— never directly from controllers - Three sender addresses supported:
notification,support,info(selected per event type) MailModuleis globally registered and exportsMailServicefor use by any module- Required env vars:
MAIL_HOST=smtp.zoho.com MAIL_PORT=587 MAIL_USERNAME=no-reply@studyboost.com MAIL_PASSWORD=<secret> MAIL_ENVELOPE_FROM=no-reply@studyboost.com STUDYBOOST_MAIL_FROM_NOTIFICATION=no-reply@studyboost.com STUDYBOOST_MAIL_FROM_SUPPORT=support@studyboost.com STUDYBOOST_MAIL_FROM_INFO=info@studyboost.com
Discord Notifications
- Internal/ops channel alerts (e.g. system errors, payment events, admin triggers)
- Uses
discord.jsv14 bot client — connects on app startup viaOnModuleInit DiscordServicemethods:sendNotification(message)— sends toDISCORD_DEFAULT_CHANNEL_IDsendToChannel(channelId, message)— sends to any text channelsendEmbed(channelId, embed)— sends a rich embed object
- Bot gracefully skips sending if not ready (token missing or login failed) — non-blocking
DiscordModuleis globally registered and exportsDiscordServicefor use by any module- Required env vars:
DISCORD_BOT_TOKEN=<secret> DISCORD_DEFAULT_CHANNEL_ID=<channel-id> DISCORD_GUILD_ID=<guild-id>
Failure Modes
- DB write failure → queue processor retries (handled by queue layer)
- Email send failure → logged, does not block in-app notification creation
- Invalid
user_id→ notification silently dropped with error log - Duplicate notification detected → skip, return existing record ID in log
QA Test Scenarios
| Scenario ID | Description | Steps | Input | Expected Result |
|---|---|---|---|---|
| #40-01 | Fetch notifications — happy path | Authenticate as user, GET /notifications | Valid JWT, user has 3 notifications | Returns 200 with array of 3 notifications, correct unread_count |
| #40-02 | Fetch notifications — empty state | Authenticate as new user with no notifications, GET /notifications | Valid JWT, no records | Returns 200 with data: [], unread_count: 0 |
| #40-03 | Mark single notification as read | PATCH /notifications/:id/read with valid ID owned by user | Valid JWT, valid notification ID | Returns 200, is_read: true; subsequent GET reflects change |
| #40-04 | Mark notification as read — wrong user | PATCH /notifications/:id/read with ID belonging to another user | Valid JWT, foreign notification ID | Returns 404 |
| #40-05 | Mark all as read | PATCH /notifications/read-all | Valid JWT, user has 5 unread | Returns 200 { updated: 5 }; all notifications now is_read: true |
| #40-06 | Unauthenticated request | GET /notifications without JWT | No Authorization header | Returns 401 |
| #40-07 | Pagination | GET /notifications?page=2&limit=5 | Valid JWT, user has 12 notifications | Returns page 2 with up to 5 items |
| #40-08 | Filter by unread | GET /notifications?is_read=false | Valid JWT | Returns only unread notifications |
| #40-09 | Expired notifications excluded | User has 1 expired notification (expires_at in the past) | GET /notifications | Expired notification does not appear in results |
| #40-10 | Duplicate notification dedup | Queue triggers same notification type + entity twice for same user | Two identical create calls | Only one notification record created; second call is a no-op |
| #40-11 | Delete notification | DELETE /notifications/:id | Valid JWT, owned notification ID | Returns 200; notification no longer appears in GET |
| #40-12 | Delete notification — not found | DELETE /notifications/:id with non-existent ID | Valid JWT | Returns 404 |
Edge Cases
- Expired notifications:
expires_atis nullable; only filter when the field is set and in the past - Race condition on mark-all-read: concurrent requests should be idempotent — use
WHERE is_read = falsein the update query - Notification for deleted user: if
user_idno longer exists, the queue processor should catch the FK violation and log without rethrowing - Large unread count: badge should cap display at
99+in the UI action_urlabsent: notification item is still clickable but performs no navigation (or marks as read only)action_datafield: JSON blob for future extensibility (e.g., deep-link params); frontend should handlenullgracefully- Priority tie-breaking: when two notifications share the same
created_at, sort bypriority DESC - Email provider down: in-app notification must still be created; email failure is non-blocking
Notes
- Queue dependency: notification creation is triggered exclusively via the queue system (BullMQ/Redis). The
NotificationsServicedoes not enqueue jobs — it only reads and writes to the DB. The queue processor callsNotificationsService.create(). - Schema: the
notificationstable is defined inschema.prismaand the migration202604211440_add_notifications_tablehas been applied to the Neon PostgreSQL database. The migration SQL file was historically pushed directly tomainunder the previous version of the migration convention; going forward all migrations flow throughdevvia PR alongside (or as) the feature work. Other feature code (backend module, frontend component, docs) is on the40-implement-notification-systembranch targetingdev. contentvsmessage: the schema usescontent(notmessage) — usecontentconsistently in DTOs and responses.is_readfield: boolean in schema — do not add a separatestatusenum column.- User preferences (not in scope for this PR): future work will add
user_notification_preferencesto allow per-type opt-out and email toggle. - Rate limiting / spam protection: dedup logic (same
type+related_entity_id+user_id) is the primary guard; additional rate limiting can be added in the queue processor layer. - WebSocket / SSE: not in scope for this PR — in-app delivery uses REST polling. Real-time push can be added as a follow-up.