Docs·4ff474d·Updated Mar 14, 2026·43 ADRs
All Services

Notification Service

Port 3005productionimportant

3

API Endpoints

1

Service Deps

3

Infrastructure

1

DB Schemas

API Endpoints

GET
/notifications/stream/:userId

Server-Sent Events (SSE) endpoint for real-time notifications.

GET
/notifications/:userId

Get user's notifications (paginated).

GET
/notifications/:userId/unread-count

Get count of unread notifications.

PUT
/notifications/:notificationId/read

Mark specific notification as read.

PUT
/notifications/:userId/read-all

Mark all notifications as read for a user.

DELETE
/notifications/:notificationId

Delete a notification.

GET
/notifications/:userId/preferences

Get user's notification preferences.

PUT
/notifications/:userId/preferences

Update user's global notification preferences.

GET
/health

Service health check.

Infrastructure

postgresredisbull-queue

Service Dependencies

Subscribes To

match_completedkarma_awardedrequest_createduser_joined_community

Full Documentation

Notification Service Context

Quick Start: cd services/notification-service && npm run dev Port: 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 messages
  • requests.help_requests - Request details for notifications
  • communities.communities - Community names for notifications

Notification Types

The service uses template-based notifications for consistency:

TypeDescriptionRecipients
match_createdSomeone offered to help with your requestRequester
match_completedHelp exchange was completedBoth requester and helper
karma_awardedYou earned karma pointsUser who earned karma
new_requestNew help request in your communityCommunity members
request_cancelledA request you matched with was cancelledMatched helper
community_activityActivity in your communityCommunity members
norm_proposedNew norm proposed in your communityCommunity members
norm_establishedNorm was approved by majorityCommunity members
join_requestSomeone wants to join your communityCommunity admins
member_joinedNew member joined the communityCommunity admins
badge_earnedYou earned a new badgeUser who earned badge
welcomeWelcome to KarmyQNew 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 requester
  • match_completed - Create notifications for both parties
  • karma_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 checking
  • src/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

  1. 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}`,
      };
  }
}
  1. 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)

  1. 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' });
});
  1. 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);
    }
  }
}
  1. 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

  1. Install email library:
npm install nodemailer
  1. 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>` : ''}
  `;
}
  1. 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

  1. Check browser console for connection errors
  2. Verify port 3005 is accessible
  3. Check CORS headers are set correctly
  4. Test with curl: curl -N http://localhost:3005/notifications/stream/user-uuid
  5. Check service logs for "SSE connection established"

Notifications not appearing

  1. Check event was published: Look at request-service logs
  2. Check event subscriber is running: Look for "Event subscriber initialized"
  3. Check notifications table: SELECT * FROM notifications.notifications WHERE user_id = '...' ORDER BY created_at DESC LIMIT 5
  4. Check user preferences: May be disabled
  5. Verify notification template exists for event type

Real-time notifications delayed

  1. Check Redis connection
  2. Verify event queue is processing: redis-cli LLEN karmyq-events
  3. Check for errors in event subscriber logs
  4. Test SSE connection is active
  5. Check network connectivity (SSE can be blocked by proxies)

Unread count incorrect

  1. Query database directly: SELECT COUNT(*) FROM notifications.notifications WHERE user_id = '...' AND read = FALSE
  2. Check if mark-as-read is working correctly
  3. Verify read_at timestamp is being set
  4. Check for concurrent updates

Preferences not saving

  1. Check user_id exists in auth.users
  2. Verify unique constraint on (user_id, event_type, community_id)
  3. Check ON CONFLICT clause is working
  4. 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