Feature: Notification System (In-App + Email)

Metadata

  • Issue ID: #40
  • Status: In Progress
  • Owner: dev
  • Related PRs: feature branch 40-implement-notification-system → PR targeting dev (commit be5bf17). Migration-only commit 866a8bd was historically pushed directly to main under the previous version of the migration convention; under the current convention all migrations — including this one — flow through dev via 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 = false notifications exist
  • Clicking the bell opens a dropdown/panel listing the latest notifications (most recent first)
  • Each notification item shows: title, content, type badge, created_at (relative time), and a read/unread indicator
  • Clicking a notification marks it as read and navigates to action_url if 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)

TypeColor / Icon
systemGrey / info icon
activityBlue / activity icon
subscriptionGreen / billing icon
securityRed / 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_id from the JWT — never accept user_id from 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 when created_at is 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)
  • MailModule is globally registered and exports MailService for 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.js v14 bot client — connects on app startup via OnModuleInit
  • DiscordService methods:
    • sendNotification(message) — sends to DISCORD_DEFAULT_CHANNEL_ID
    • sendToChannel(channelId, message) — sends to any text channel
    • sendEmbed(channelId, embed) — sends a rich embed object
  • Bot gracefully skips sending if not ready (token missing or login failed) — non-blocking
  • DiscordModule is globally registered and exports DiscordService for 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 IDDescriptionStepsInputExpected Result
#40-01Fetch notifications — happy pathAuthenticate as user, GET /notificationsValid JWT, user has 3 notificationsReturns 200 with array of 3 notifications, correct unread_count
#40-02Fetch notifications — empty stateAuthenticate as new user with no notifications, GET /notificationsValid JWT, no recordsReturns 200 with data: [], unread_count: 0
#40-03Mark single notification as readPATCH /notifications/:id/read with valid ID owned by userValid JWT, valid notification IDReturns 200, is_read: true; subsequent GET reflects change
#40-04Mark notification as read — wrong userPATCH /notifications/:id/read with ID belonging to another userValid JWT, foreign notification IDReturns 404
#40-05Mark all as readPATCH /notifications/read-allValid JWT, user has 5 unreadReturns 200 { updated: 5 }; all notifications now is_read: true
#40-06Unauthenticated requestGET /notifications without JWTNo Authorization headerReturns 401
#40-07PaginationGET /notifications?page=2&limit=5Valid JWT, user has 12 notificationsReturns page 2 with up to 5 items
#40-08Filter by unreadGET /notifications?is_read=falseValid JWTReturns only unread notifications
#40-09Expired notifications excludedUser has 1 expired notification (expires_at in the past)GET /notificationsExpired notification does not appear in results
#40-10Duplicate notification dedupQueue triggers same notification type + entity twice for same userTwo identical create callsOnly one notification record created; second call is a no-op
#40-11Delete notificationDELETE /notifications/:idValid JWT, owned notification IDReturns 200; notification no longer appears in GET
#40-12Delete notification — not foundDELETE /notifications/:id with non-existent IDValid JWTReturns 404

Edge Cases

  • Expired notifications: expires_at is 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 = false in the update query
  • Notification for deleted user: if user_id no 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_url absent: notification item is still clickable but performs no navigation (or marks as read only)
  • action_data field: JSON blob for future extensibility (e.g., deep-link params); frontend should handle null gracefully
  • Priority tie-breaking: when two notifications share the same created_at, sort by priority 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 NotificationsService does not enqueue jobs — it only reads and writes to the DB. The queue processor calls NotificationsService.create().
  • Schema: the notifications table is defined in schema.prisma and the migration 202604211440_add_notifications_table has been applied to the Neon PostgreSQL database. The migration SQL file was historically pushed directly to main under the previous version of the migration convention; going forward all migrations flow through dev via PR alongside (or as) the feature work. Other feature code (backend module, frontend component, docs) is on the 40-implement-notification-system branch targeting dev.
  • content vs message: the schema uses content (not message) — use content consistently in DTOs and responses.
  • is_read field: boolean in schema — do not add a separate status enum column.
  • User preferences (not in scope for this PR): future work will add user_notification_preferences to 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.