Skip to content

utkarshhxd/Notify-EventDrivenNotificationService

Repository files navigation

Notification System

An event-driven notification system built with Node.js, TypeScript, BullMQ, Redis, PostgreSQL, Prisma, and Express.

It accepts events through an HTTP API, queues them in Redis, processes them asynchronously in a worker, stores delivery state in PostgreSQL, captures terminal failures in a dead-letter queue, and exposes Bull Board for observability.

Features

  • Event ingestion through POST /api/events
  • User preference management for EMAIL, SMS, and PUSH
  • Strict idempotency with queue-level deduplication and database uniqueness on (eventId, channel)
  • Redis-backed per-user rate limiting
  • Modular channel handlers for clean delivery separation
  • Notification retrieval endpoint
  • Retry with exponential backoff
  • Dead-letter persistence and dead-letter queue visibility
  • Automated end-to-end verification script

Architecture

  • apps/producer-api Validates events, manages preferences, exposes retrieval endpoints, and enqueues jobs.
  • apps/worker Applies preferences, rate limits, retries, idempotency protections, and delivery state transitions.
  • apps/dashboard Hosts Bull Board for the main queue and the dead-letter queue.
  • packages/shared Contains shared types, schemas, constants, logging helpers, and the Prisma schema.

Flow

  1. A client sends an event to POST /api/events.
  2. The producer validates the payload with Zod.
  3. The event is queued with jobId = eventId.
  4. The worker loads the user's preferences.
  5. Enabled channels are resolved.
  6. Notification rows are created or reused per (eventId, channel).
  7. Redis rate limiting reserves capacity for the pending channels.
  8. Channel handlers deliver notifications and update status from PENDING to SENT or FAILED.
  9. Failed jobs retry with exponential backoff.
  10. Terminal failures are written to DeadLetterEvent and the dead-letter queue.

Project Structure

.
|-- apps
|   |-- dashboard
|   |   `-- src/index.ts
|   |-- producer-api
|   |   `-- src/index.ts
|   `-- worker
|       `-- src
|           |-- handlers
|           |   |-- email.ts
|           |   |-- index.ts
|           |   |-- push.ts
|           |   |-- sms.ts
|           |   |-- testing.ts
|           |   `-- types.ts
|           |-- lib
|           |   |-- rate-limiter.ts
|           |   `-- redis-lock.ts
|           `-- index.ts
|-- packages
|   `-- shared
|       |-- prisma/schema.prisma
|       `-- src
|           |-- constants.ts
|           |-- index.ts
|           |-- logger.ts
|           |-- schemas.ts
|           `-- types.ts
|-- scripts
|   `-- verify-system.js
|-- docker-compose.yml
|-- package.json
|-- test-event.ps1
`-- tsconfig.json

Prerequisites

  • Node.js 18+ recommended
  • npm
  • Docker Desktop

Environment Variables

The root .env file uses:

DATABASE_URL="postgresql://notification_user:secret_password@localhost:5432/notification_db"
REDIS_URL="redis://localhost:6379"

These values match the included docker-compose.yml.

Setup

Install dependencies:

npm install

Start Redis and PostgreSQL:

docker compose up -d

Push the schema:

npm run db:push

Regenerate Prisma Client manually if needed:

npm run db:generate

Run the Apps

Start each service in its own terminal from the project root.

Worker:

npm run start:worker

Producer API:

npm run start:producer

Dashboard:

npm run start:dashboard

Default URLs

  • Producer API: http://localhost:3001
  • Producer health: http://localhost:3001/health
  • Dashboard: http://localhost:3000/admin/queues
  • Dashboard health: http://localhost:3000/health
  • Redis: localhost:6379
  • PostgreSQL: localhost:5432

API

POST /api/events

Queues a notification event.

Request body:

{
  "eventId": "evt-123",
  "eventType": "ORDER_PLACED",
  "userId": "user_123",
  "payload": {
    "orderAmount": 250,
    "items": ["Laptop", "Mouse"]
  },
  "timestamp": "2026-03-31T16:30:00Z"
}

Success response:

{
  "message": "Event accepted and queued for processing",
  "jobId": "evt-123",
  "duplicate": false
}

If the same eventId is posted again while the job record still exists, the API returns:

{
  "message": "Duplicate event ignored",
  "jobId": "evt-123",
  "duplicate": true
}

GET /api/preferences/:userId

Returns saved preferences for a user. If none exist yet, the API returns default all-enabled preferences with isDefault: true.

PUT /api/preferences/:userId

Creates or updates preferences for a user.

Request body:

{
  "emailOptIn": true,
  "smsOptIn": false,
  "pushOptIn": true
}

GET /api/notifications/:userId

Returns notifications for a user ordered by newest first.

Optional query parameters:

  • eventId
  • channel
  • status
  • limit

Example:

Invoke-WebRequest -UseBasicParsing "http://localhost:3001/api/notifications/user_123?status=SENT&limit=20"

GET /api/dead-letters

Returns dead-letter records for terminal failures.

Optional query parameters:

  • eventId
  • userId
  • limit

User Preferences

Preferences are stored in UserPreferences.

  • If no row exists for a user, all channels are enabled by default.
  • If a channel is disabled, the worker does not create or send that notification.

Idempotency

The system protects against duplicate processing in three places:

  • Queue-level idempotency using jobId = eventId
  • Database uniqueness on (eventId, channel)
  • Redis delivery locks for (eventId, channel) to suppress concurrent duplicate sends

Rate Limiting

The worker enforces a Redis-backed sliding-window limit:

  • Maximum 5 notifications per minute per user

The limiter reserves capacity per pending channel. If capacity is unavailable:

  • notification rows are marked FAILED
  • the job retries with backoff
  • after retries are exhausted, the event is dead-lettered

Retry and Dead-Letter Handling

Producer jobs are enqueued with:

  • attempts = 4
  • exponential backoff starting at 1000ms

On final failure:

  • the notification rows remain FAILED
  • a DeadLetterEvent row is upserted
  • the event is added to notification-dead-letter-queue

The dashboard shows both the main queue and the dead-letter queue.

Notification State

Each (eventId, channel) record moves through:

  • PENDING
  • SENT or FAILED

The Notification model also tracks:

  • attemptCount
  • lastAttemptAt
  • processedAt
  • error

Channel Handlers

Delivery logic is split into modular handlers:

  • apps/worker/src/handlers/email.ts
  • apps/worker/src/handlers/sms.ts
  • apps/worker/src/handlers/push.ts

This keeps channel-specific logic isolated and makes it easier to plug in real providers later.

Deterministic Test Controls

The worker supports optional payload-based controls for repeatable verification:

{
  "__test": {
    "delayMs": {
      "EMAIL": 1500
    },
    "failChannels": {
      "EMAIL": 4
    }
  }
}

Supported keys:

  • payload.__test.delayMs.EMAIL|SMS|PUSH
  • payload.__test.failChannels.EMAIL|SMS|PUSH

These controls are used by the automated verification script to prove retries, status transitions, and dead-letter behavior.

Manual Testing

You can send a sample event with the included PowerShell script:

.\test-event.ps1

Then fetch notifications:

Invoke-WebRequest -UseBasicParsing http://localhost:3001/api/notifications/user_123

Automated Verification

Run the full end-to-end verification suite:

npm run verify:e2e

It verifies:

  • event ingestion
  • queue processing
  • persistence in PostgreSQL
  • PENDING -> SENT transition
  • retry and dead-letter handling
  • duplicate suppression
  • user preference enforcement
  • rate limiting
  • concurrent load stability
  • dashboard availability
  • no duplicate (eventId, channel) rows

Database Models

UserPreferences

  • userId
  • emailOptIn
  • smsOptIn
  • pushOptIn
  • createdAt
  • updatedAt

Notification

  • id
  • eventId
  • eventType
  • userId
  • channel
  • status
  • payload
  • error
  • attemptCount
  • lastAttemptAt
  • processedAt
  • createdAt
  • updatedAt

Unique constraint:

  • (eventId, channel)

DeadLetterEvent

  • id
  • eventId
  • eventType
  • userId
  • payload
  • error
  • attemptsMade
  • queueName
  • createdAt
  • updatedAt

Useful Commands

Install dependencies:

npm install

Start infrastructure:

docker compose up -d

Stop infrastructure:

docker compose down

Push schema:

npm run db:push

Generate Prisma Client:

npm run db:generate

Start producer:

npm run start:producer

Start worker:

npm run start:worker

Start dashboard:

npm run start:dashboard

Run end-to-end verification:

npm run verify:e2e

Troubleshooting

Port Already in Use

Default ports:

  • 3000 dashboard
  • 3001 producer API
  • 5432 PostgreSQL
  • 6379 Redis

Check listeners:

Get-NetTCPConnection -State Listen -LocalPort 3000,3001,5432,6379

Prisma Client Looks Out of Date

Run:

npm run db:generate

If Prisma still cannot regenerate on Windows, make sure no running Node processes are holding the Prisma engine binary open.

API Is Up but Notifications Are Not Changing

Check the worker logs. The producer only enqueues jobs. The worker is responsible for:

  • reading preferences
  • enforcing rate limits
  • updating status
  • dead-lettering terminal failures

Rate Limit Failures During Burst Testing

This can be expected if you send more than 5 notifications per minute to the same user. Use different users or wait for the window to expire.

Current Limitations

  • Delivery handlers are mocked and do not call real providers
  • No root dev script starts all three apps together yet
  • No authentication is implemented on the API or dashboard

Suggested Next Improvements

  • Replace mocked handlers with real email, SMS, and push providers
  • Add authentication and authorization
  • Add a root dev script for one-command local startup
  • Add CI around npm run verify:e2e
  • Add metrics export for Prometheus or OpenTelemetry

License

No license file is currently included in this repository.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors