A small event-driven notification system built with Node.js, TypeScript, BullMQ, Redis, PostgreSQL, Prisma, and Express.
The project accepts notification events through an API, queues them in Redis, processes them in a worker, stores delivery records in PostgreSQL, and exposes a Bull Board dashboard to inspect queue activity.
- Accepts events through an HTTP API
- Queues jobs with BullMQ
- Processes notifications asynchronously in a worker
- Stores notification history in PostgreSQL
- Applies simple per-user rate limiting
- Prevents duplicate processing with queue-level and database-level idempotency
- Exposes a Bull Board dashboard for queue monitoring
The system is split into three apps plus one shared package:
apps/producer-apiReceives incoming events and pushes them into thenotification-queue.apps/workerPulls jobs from the queue, checks user preferences, applies rate limiting, simulates sending notifications, and stores the result in the database.apps/dashboardHosts the Bull Board UI for queue inspection.packages/sharedContains shared TypeScript types and the Prisma schema.
- A client sends an event to
POST /api/events. - The producer validates the payload using Zod.
- The producer adds the event to the BullMQ queue.
- The worker consumes the job.
- The worker reads user preferences from PostgreSQL.
- The worker selects channels:
EMAIL,SMS, and/orPUSH. - The worker applies a Redis-based rate limit of 5 notifications per minute per user.
- The worker writes notification records to PostgreSQL.
- The worker marks each notification as
SENTorFAILED. - You can inspect queue state in Bull Board and fetch notification history through the API.
- Node.js
- TypeScript
- Express
- BullMQ
- Redis
- PostgreSQL
- Prisma
- Zod
- Docker Compose
.
├── apps
│ ├── dashboard
│ │ └── src/index.ts
│ ├── producer-api
│ │ └── src/index.ts
│ └── worker
│ └── src
│ ├── handlers
│ │ ├── email.ts
│ │ ├── push.ts
│ │ └── sms.ts
│ └── index.ts
├── packages
│ └── shared
│ ├── prisma/schema.prisma
│ └── src
│ ├── index.ts
│ └── types.ts
├── docker-compose.yml
├── package.json
├── test-event.ps1
└── tsconfig.json
Make sure you have these installed:
- Node.js 18+ recommended
- npm
- Docker Desktop
The project uses a root .env file:
DATABASE_URL="postgresql://notification_user:secret_password@localhost:5432/notification_db"
REDIS_URL="redis://localhost:6379"These defaults match the provided docker-compose.yml.
From the project root, install dependencies:
npm installStart Redis and PostgreSQL:
docker compose up -dTo verify containers are running:
docker psPush the Prisma schema to PostgreSQL:
npm run db:pushThis creates the required tables:
UserPreferencesNotification
Open three terminals in the project root and run one command in each.
npm run start:workernpm run start:producernpm run start:dashboard- Producer API:
http://localhost:3001 - Dashboard:
http://localhost:3000/admin/queues - Redis:
localhost:6379 - PostgreSQL:
localhost:5432
Accepts an event and queues it for processing.
{
"eventId": "evt-123",
"eventType": "ORDER_PLACED",
"userId": "user_123",
"payload": {
"orderAmount": 250,
"items": ["Laptop", "Mouse"]
},
"timestamp": "2026-03-31T16:30:00Z"
}eventIdmust be a stringeventTypemust be a stringuserIdmust be a stringpayloadmust be an objecttimestampmust be a valid ISO datetime string
{
"message": "Event accepted and queued for processing",
"jobId": "evt-123"
}Returns notification records for a user ordered by newest first.
Invoke-WebRequest -UseBasicParsing http://localhost:3001/api/notifications/user_123The project includes test-event.ps1, which posts a sample event to the producer API.
Run:
.\test-event.ps1The script:
- Generates a new GUID for
eventId - Sends an
ORDER_PLACEDevent - Uses
user_123as the test user - Prints the API response
$body = @{
eventId = [guid]::NewGuid().ToString()
eventType = "ORDER_PLACED"
userId = "user_123"
payload = @{
orderAmount = 250.00
items = @("Laptop", "Mouse")
}
timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
} | ConvertTo-Json
Invoke-RestMethod `
-Uri "http://localhost:3001/api/events" `
-Method Post `
-Body $body `
-ContentType "application/json"After sending an event, fetch the stored notifications:
Invoke-WebRequest -UseBasicParsing http://localhost:3001/api/notifications/user_123If the worker processed the event successfully, you should see one record per enabled channel.
Bull Board is available at:
http://localhost:3000/admin/queues
Use it to:
- Inspect queued jobs
- Inspect completed jobs
- Inspect failed jobs
- Monitor queue activity while testing
The worker checks UserPreferences for the current user.
- If no preferences exist, all channels are enabled by default
- If preferences exist, the worker only sends to opted-in channels
The worker uses Redis to enforce a per-user rate limit:
- Maximum
5notifications per minute per user
If the limit is exceeded, the job fails.
The worker is configured with:
- Concurrency:
5 - Global processing limiter:
100jobs per1000ms
The project uses two idempotency safeguards:
- Queue-level idempotency using
jobId = eventId - Database-level uniqueness on
(eventId, channel)
This prevents duplicate channel delivery records for the same event.
The delivery handlers in the worker simulate provider behavior:
- Email handler waits about
500msand randomly fails about20%of the time - SMS handler waits about
300msand randomly fails about10%of the time - Push handler waits about
200msand currently does not simulate failure
Because of this, occasional FAILED records are expected during testing.
Stores per-user notification settings.
Fields:
userIdemailOptInsmsOptInpushOptIncreatedAtupdatedAt
Stores per-channel notification delivery records.
Fields:
ideventIdeventTypeuserIdchannelstatuspayloaderrorcreatedAtupdatedAt
Unique constraint:
(eventId, channel)
Install dependencies:
npm installStart infrastructure:
docker compose up -dStop infrastructure:
docker compose downReset database schema from Prisma:
npm run db:pushStart producer:
npm run start:producerStart worker:
npm run start:workerStart dashboard:
npm run start:dashboardIf you see EADDRINUSE, another process is already using the port.
Default ports:
3000for dashboard3001for producer API5432for PostgreSQL6379for Redis
Check listeners:
Get-NetTCPConnection -State Listen -LocalPort 3000,3001,5432,6379Make sure:
npm installhas been run from the repo root- Docker containers are up
.envexists and points to the correct Redis and PostgreSQL instancesnpm run db:pushhas been executed at least once
Check the worker terminal output. The producer only queues events. The worker is responsible for processing and writing records to PostgreSQL.
Some failures are intentional because the email and SMS handlers simulate provider errors. Retry with a new event if needed.
- No automated test suite is configured yet
- No single-command script exists yet to start all three apps together
- Delivery handlers are mocked and do not call real providers
- No authentication is implemented on the API or dashboard
- Add a root
devscript to start all apps together - Add automated tests for API validation and worker behavior
- Add retry/backoff tuning per channel
- Add user preference seed data and a management endpoint
- Replace mock handlers with real email/SMS/push providers
No license file is currently included in this repository.