Notification Service
3
API Endpoints
1
Service Deps
3
Infrastructure
1
DB Schemas
API Endpoints
/notifications/stream/:userIdServer-Sent Events (SSE) endpoint for real-time notifications.
/notifications/:userIdGet user's notifications (paginated).
/notifications/:userId/unread-countGet count of unread notifications.
/notifications/:notificationId/readMark specific notification as read.
/notifications/:userId/read-allMark all notifications as read for a user.
/notifications/:notificationIdDelete a notification.
/notifications/:userId/preferencesGet user's notification preferences.
/notifications/:userId/preferencesUpdate user's global notification preferences.
/healthService health check.
Infrastructure
Service Dependencies
Subscribes To
Full Documentation
Notification Service Context
Quick Start:
cd services/notification-service && npm run devPort: 3005 | Health: http://localhost:3005/health
Purpose
Manages user notifications across the platform with template-based messaging, user preferences, and real-time delivery via Server-Sent Events (SSE). Listens to events from other services and creates appropriate notifications for users.
Database Schema
Tables Owned by This Service
-- notifications.notifications
CREATE TABLE notifications.notifications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL, -- 'match_created', 'match_completed', etc.
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
data JSONB DEFAULT '{}', -- Additional notification data
read BOOLEAN DEFAULT FALSE,
action_url VARCHAR(500), -- Deep link or URL to open
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
read_at TIMESTAMP
);
-- notifications.preferences (event-specific)
CREATE TABLE notifications.preferences (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
community_id UUID REFERENCES communities.communities(id) ON DELETE CASCADE,
event_type VARCHAR(50) NOT NULL, -- Which event this preference applies to
in_app_enabled BOOLEAN DEFAULT TRUE,
push_enabled BOOLEAN DEFAULT TRUE,
email_enabled BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, event_type, community_id)
);
-- notifications.global_preferences
CREATE TABLE notifications.global_preferences (
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
in_app_enabled BOOLEAN DEFAULT TRUE,
push_enabled BOOLEAN DEFAULT TRUE,
email_enabled BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- notifications.push_tokens
CREATE TABLE notifications.push_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
token TEXT NOT NULL, -- Expo push token or FCM token
device_type VARCHAR(20) NOT NULL, -- 'ios', 'android', 'web'
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, token)
);
-- Indexes
CREATE INDEX idx_notifications_user_id ON notifications.notifications(user_id);
CREATE INDEX idx_notifications_read ON notifications.notifications(read);
CREATE INDEX idx_notifications_created_at ON notifications.notifications(created_at DESC);
Tables Read by This Service
auth.users- User names for notification messagesrequests.help_requests- Request details for notificationscommunities.communities- Community names for notifications
Notification Types
The service uses template-based notifications for consistency:
| Type | Description | Recipients |
|---|---|---|
match_created | Someone offered to help with your request | Requester |
match_completed | Help exchange was completed | Both requester and helper |
karma_awarded | You earned karma points | User who earned karma |
new_request | New help request in your community | Community members |
request_cancelled | A request you matched with was cancelled | Matched helper |
community_activity | Activity in your community | Community members |
norm_proposed | New norm proposed in your community | Community members |
norm_established | Norm was approved by majority | Community members |
join_request | Someone wants to join your community | Community admins |
member_joined | New member joined the community | Community admins |
badge_earned | You earned a new badge | User who earned badge |
welcome | Welcome to KarmyQ | New users |
Templates: src/templates/notificationTemplates.ts
API Endpoints
GET /notifications/stream/:userId
Server-Sent Events (SSE) endpoint for real-time notifications.
Usage:
const eventSource = new EventSource(`http://localhost:3005/notifications/stream/${userId}`);
eventSource.onmessage = (event) => {
const notification = JSON.parse(event.data);
console.log('New notification:', notification);
};
Response (SSE stream):
data: {"type":"connected"}
data: {"id":"uuid","type":"match_created","title":"Someone wants to help!","body":"Bob Johnson offered to help with your request","read":false,"created_at":"2025-01-10T12:00:00Z"}
Implementation: src/routes/notifications.ts:16
Features:
- Keep-alive heartbeat every 30 seconds
- Automatic cleanup on client disconnect
- Only sends notifications for the specific user
- Real-time push as soon as notification is created
GET /notifications/:userId
Get user's notifications (paginated).
Query Parameters:
limit- Max results (default: 50)offset- Pagination offset (default: 0)
Response:
{
"success": true,
"data": {
"notifications": [
{
"id": "uuid",
"user_id": "uuid",
"type": "match_created",
"title": "Someone wants to help!",
"body": "Bob Johnson offered to help with your request",
"data": {
"match_id": "uuid",
"request_id": "uuid",
"request_title": "Need help moving",
"responder_name": "Bob Johnson"
},
"read": false,
"action_url": "/requests/uuid",
"created_at": "2025-01-10T12:00:00Z"
}
],
"unread_count": 5,
"total": 1
}
}
Implementation: src/routes/notifications.ts:53
GET /notifications/:userId/unread-count
Get count of unread notifications.
Response:
{
"success": true,
"data": {
"count": 5
}
}
Implementation: src/routes/notifications.ts:85
PUT /notifications/:notificationId/read
Mark specific notification as read.
Request:
{
"user_id": "uuid"
}
Response:
{
"success": true,
"data": {
"id": "uuid",
"read": true,
"read_at": "2025-01-10T13:00:00Z"
},
"message": "Notification marked as read"
}
Implementation: src/routes/notifications.ts:104
PUT /notifications/:userId/read-all
Mark all notifications as read for a user.
Response:
{
"success": true,
"data": {
"count": 5
},
"message": "5 notifications marked as read"
}
Implementation: src/routes/notifications.ts:140
DELETE /notifications/:notificationId
Delete a notification.
Request:
{
"user_id": "uuid"
}
Response:
{
"success": true,
"message": "Notification deleted"
}
Implementation: src/routes/notifications.ts:161
GET /notifications/:userId/preferences
Get user's notification preferences.
Response:
{
"success": true,
"data": {
"global": {
"in_app_enabled": true,
"push_enabled": true,
"email_enabled": false
},
"event_specific": [
{
"event_type": "match_created",
"in_app_enabled": true,
"push_enabled": true,
"email_enabled": false
}
]
}
}
Implementation: src/routes/notifications.ts:196
PUT /notifications/:userId/preferences
Update user's global notification preferences.
Request:
{
"in_app_enabled": true,
"push_enabled": false,
"email_enabled": true
}
Response:
{
"success": true,
"data": {
"in_app_enabled": true,
"push_enabled": false,
"email_enabled": true
},
"message": "Preferences updated successfully"
}
GET /health
Service health check.
Response:
{
"status": "ok",
"service": "notification-service"
}
Event-Driven Architecture
The notification service listens to events from other services and creates appropriate notifications.
Events Consumed
match_created - Someone offered to help
- Notifies requester
- Includes helper name and request details
match_completed - Help exchange completed
- Notifies both requester and helper
- Triggers karma notification (if listening)
request_created - New request in community
- Notifies community members (future)
norm_proposed - New norm proposed
- Notifies community members
Event Handler: src/events/subscriber.ts:12-100
Example Event Processing:
eventQueue.process('match_created', async (job) => {
const { payload } = job.data;
const { match_id, request_id, requester_id, responder_id } = payload;
// Get request details
const request = await query('SELECT title FROM requests.help_requests WHERE id = $1', [request_id]);
// Create notification for requester
await createNotification({
user_id: requester_id,
type: 'match_created',
data: {
match_id,
request_id,
request_title: request.title,
responder_name: responder.name,
},
});
});
Notification Templates
Templates ensure consistent messaging across the platform:
// src/templates/notificationTemplates.ts
export function generateNotification(type: NotificationType, data: any) {
switch (type) {
case 'match_created':
return {
type: 'match_created',
title: 'Someone wants to help!',
body: `${data.responder_name} offered to help with "${data.request_title}"`,
data,
action_url: `/requests/${data.request_id}`,
};
case 'match_completed':
return {
type: 'match_completed',
title: 'Help exchange completed!',
body: `You successfully completed "${data.request_title}"`,
data,
action_url: `/matches/${data.match_id}`,
};
// ... other templates
}
}
Dependencies
Calls (Outbound)
- Auth Service (via database) - Get user names
- Request Service (via database) - Get request details
- Community Service (via database) - Get community details
Called By (Inbound)
- Frontend (to fetch notifications, update preferences)
- Mobile App (SSE for real-time notifications)
Events Published
- None (notification service only consumes events)
Events Consumed
match_created- Create notification for requestermatch_completed- Create notifications for both partieskarma_awarded- Notify user of karma points (future)norm_proposed- Notify community members (future)norm_established- Notify community members (future)
External Dependencies
- PostgreSQL (notifications schema)
- Redis (event subscription via Bull queue)
Environment Variables
# Server
PORT=3005
NODE_ENV=development
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/karmyq_db
# Redis
REDIS_URL=redis://localhost:6379
# Logging
LOG_LEVEL=info # debug, info, warn, error
Key Files
Entry Point
src/index.ts- Express app initialization, event subscriber setup, SSE support
Routes
src/routes/notifications.ts- Notification CRUD, preferences, SSE endpoint
Services
src/services/notificationService.ts- Notification creation, preference checkingsrc/templates/notificationTemplates.ts- Notification templates
Events
src/events/subscriber.ts- Listens to match_created, match_completed events
Database
src/database/db.ts- PostgreSQL connection pool
Common Development Tasks
Add New Notification Type
- Add to notification templates:
// src/templates/notificationTemplates.ts
export type NotificationType =
| 'match_created'
| 'match_completed'
| 'new_notification_type'; // Add here
export function generateNotification(type: NotificationType, data: any) {
switch (type) {
// ... existing cases
case 'new_notification_type':
return {
type: 'new_notification_type',
title: 'Notification Title',
body: `Notification body with ${data.field}`,
data,
action_url: `/path/${data.id}`,
};
}
}
- Add event subscriber (if event-driven):
// src/events/subscriber.ts
eventQueue.process('new_event_name', async (job) => {
const { payload } = job.data;
await createNotification({
user_id: payload.user_id,
type: 'new_notification_type',
data: payload,
});
});
Implement Push Notifications (Mobile)
- Add push token registration endpoint:
// src/routes/notifications.ts
router.post('/:userId/push-token', async (req, res) => {
const { userId } = req.params;
const { token, device_type } = req.body;
await query(
`INSERT INTO notifications.push_tokens (user_id, token, device_type)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, token) DO UPDATE SET last_used = CURRENT_TIMESTAMP`,
[userId, token, device_type]
);
res.json({ success: true, message: 'Push token registered' });
});
- Send push notification via Expo:
// src/services/pushNotificationService.ts
import { Expo } from 'expo-server-sdk';
const expo = new Expo();
export async function sendPushNotification(user_id: string, notification: any) {
// Get user's push tokens
const tokens = await query(
`SELECT token FROM notifications.push_tokens WHERE user_id = $1`,
[user_id]
);
const messages = tokens.rows.map(row => ({
to: row.token,
sound: 'default',
title: notification.title,
body: notification.body,
data: notification.data,
}));
const chunks = expo.chunkPushNotifications(messages);
for (const chunk of chunks) {
try {
await expo.sendPushNotificationsAsync(chunk);
} catch (error) {
console.error('Push notification error:', error);
}
}
}
- Call from createNotification:
// src/services/notificationService.ts
export async function createNotification(params: CreateNotificationParams) {
// ... existing code ...
// Send push notification if enabled
if (shouldSend.push_enabled) {
await sendPushNotification(user_id, createdNotification);
}
}
Add Email Notifications
- Install email library:
npm install nodemailer
- Create email service:
// src/services/emailService.ts
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587'),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
});
export async function sendEmail(user_id: string, notification: any) {
// Get user email
const user = await query(
`SELECT email, name FROM auth.users WHERE id = $1`,
[user_id]
);
if (!user.rows[0]) return;
await transporter.sendMail({
from: 'KarmyQ <notifications@karmyq.org>',
to: user.rows[0].email,
subject: notification.title,
text: notification.body,
html: generateEmailHTML(notification),
});
}
function generateEmailHTML(notification: any) {
return `
<h2>${notification.title}</h2>
<p>${notification.body}</p>
${notification.action_url ? `<a href="${notification.action_url}">View Details</a>` : ''}
`;
}
- Call from createNotification:
// src/services/notificationService.ts
if (shouldSend.email_enabled) {
await sendEmail(user_id, createdNotification);
}
Add Event-Specific Preferences
// src/routes/notifications.ts
router.put('/:userId/preferences/:eventType', async (req, res) => {
const { userId, eventType } = req.params;
const { in_app_enabled, push_enabled, email_enabled, community_id } = req.body;
await query(
`INSERT INTO notifications.preferences
(user_id, event_type, community_id, in_app_enabled, push_enabled, email_enabled)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (user_id, event_type, community_id)
DO UPDATE SET
in_app_enabled = $4,
push_enabled = $5,
email_enabled = $6,
updated_at = CURRENT_TIMESTAMP`,
[userId, eventType, community_id || null, in_app_enabled, push_enabled, email_enabled]
);
res.json({ success: true, message: 'Preferences updated' });
});
Add Notification Batching (Daily Digest)
// src/cron/dailyDigest.ts
import cron from 'node-cron';
// Run every day at 8 AM
cron.schedule('0 8 * * *', async () => {
// Get users with daily digest enabled
const users = await query(
`SELECT DISTINCT user_id
FROM notifications.global_preferences
WHERE email_enabled = TRUE`
);
for (const user of users.rows) {
// Get unread notifications from last 24 hours
const notifications = await query(
`SELECT * FROM notifications.notifications
WHERE user_id = $1
AND read = FALSE
AND created_at > NOW() - INTERVAL '24 hours'
ORDER BY created_at DESC`,
[user.user_id]
);
if (notifications.rows.length > 0) {
await sendDigestEmail(user.user_id, notifications.rows);
}
}
});
Security Considerations
User-Only Access
- Notifications can only be read by their owner
- user_id verified on all read/update/delete operations
// src/routes/notifications.ts
const notification = await markAsRead(notificationId, user_id);
// Implementation checks user_id matches
UPDATE notifications.notifications
SET read = TRUE
WHERE id = $1 AND user_id = $2 -- Ensures ownership
SSE Connection Security
- Only sends notifications to correct user
- Automatic cleanup on disconnect
- No cross-user notification leaks
// src/routes/notifications.ts
const notificationHandler = (data: any) => {
if (data.user_id === userId) { // Filter by user
res.write(`data: ${JSON.stringify(data.notification)}\n\n`);
}
};
Preference Enforcement
- Notifications only sent if user preferences allow
- Falls back to global preferences if no event-specific preference
- Cannot be bypassed
Input Validation
- Validate notification types against defined NotificationType
- Sanitize user-provided data in notifications
- Validate user_id exists before creating notification
Debugging Common Issues
SSE connection not working
- Check browser console for connection errors
- Verify port 3005 is accessible
- Check CORS headers are set correctly
- Test with curl:
curl -N http://localhost:3005/notifications/stream/user-uuid - Check service logs for "SSE connection established"
Notifications not appearing
- Check event was published: Look at request-service logs
- Check event subscriber is running: Look for "Event subscriber initialized"
- Check notifications table:
SELECT * FROM notifications.notifications WHERE user_id = '...' ORDER BY created_at DESC LIMIT 5 - Check user preferences: May be disabled
- Verify notification template exists for event type
Real-time notifications delayed
- Check Redis connection
- Verify event queue is processing:
redis-cli LLEN karmyq-events - Check for errors in event subscriber logs
- Test SSE connection is active
- Check network connectivity (SSE can be blocked by proxies)
Unread count incorrect
- Query database directly:
SELECT COUNT(*) FROM notifications.notifications WHERE user_id = '...' AND read = FALSE - Check if mark-as-read is working correctly
- Verify read_at timestamp is being set
- Check for concurrent updates
Preferences not saving
- Check user_id exists in auth.users
- Verify unique constraint on (user_id, event_type, community_id)
- Check ON CONFLICT clause is working
- Look for database errors in logs
Testing
Manual Testing with curl
Get Notifications:
curl "http://localhost:3005/notifications/user-uuid?limit=10"
SSE Stream:
curl -N "http://localhost:3005/notifications/stream/user-uuid"
Mark as Read:
curl -X PUT http://localhost:3005/notifications/notification-uuid/read \
-H "Content-Type: application/json" \
-d '{"user_id":"user-uuid"}'
Update Preferences:
curl -X PUT http://localhost:3005/notifications/user-uuid/preferences \
-H "Content-Type: application/json" \
-d '{
"in_app_enabled": true,
"push_enabled": false,
"email_enabled": true
}'
Trigger Notification (via event):
redis-cli LPUSH karmyq-events '{"event":"match_created","payload":{"match_id":"uuid","request_id":"uuid","requester_id":"uuid","responder_id":"uuid"}}'
Unit Tests
Run tests:
npm test
Test structure:
src/
├── __tests__/
│ ├── notifications.test.ts # Notification CRUD tests
│ ├── preferences.test.ts # Preference management tests
│ ├── sse.test.ts # SSE connection tests
│ └── events.test.ts # Event subscription tests
Performance Considerations
- SSE connections kept alive with 30-second heartbeats
- Notifications paginated (default 50 limit)
- Indexes on user_id, read status, created_at for fast queries
- EventEmitter used for in-memory SSE distribution (no database polling)
- Connection pooling for PostgreSQL (max 20 connections)
- Event queue processes one event at a time per type
Future Enhancements (TODO)
- Push notifications for mobile (Expo/FCM integration)
- Email notifications (transactional emails)
- Daily digest emails (batched notifications)
- Notification batching (group similar notifications)
- Rich media notifications (images, actions)
- Notification sound customization
- Per-community notification preferences
- Notification scheduling (send later)
- Read receipts for critical notifications
- Federation support (cross-instance notifications)
Related Documentation
- Main architecture:
/docs/ARCHITECTURE.md - Database schema:
/infrastructure/postgres/init.sql(lines 209-272) - SSE implementation:
src/routes/notifications.ts:16-50 - Templates:
src/templates/notificationTemplates.ts