Notification Service
The Notification Service manages all notifications sent to users across the RovesUp platform. It provides a unified API for other services to send notifications through multiple channels, including email, in-app notifications, push notifications for iOS devices, and SMS.
Features
- Email notifications
- In-app notifications
- Push notifications for iOS devices
- SMS notifications
- Notification preferences management
- Notification templates
- Delivery status tracking
- Notification batching and scheduling
Technical Implementation
Notification Architecture
The Notification Service follows a publisher-subscriber pattern:
- Other services publish notification events to the Notification Service
- The Notification Service processes these events and determines the appropriate delivery channels
- Notifications are sent through the selected channels
- Delivery status is tracked and reported back to the originating service
iOS Push Notifications
We use Apple Push Notification service (APNs) to deliver push notifications to iOS devices. This requires an Apple Developer account and proper configuration of certificates.
import * as apn from 'apn';
import { logger } from '../utils/logger';
// APNs configuration
const apnProvider = new apn.Provider({
token: {
key: process.env.APNS_KEY_PATH,
keyId: process.env.APNS_KEY_ID,
teamId: process.env.APNS_TEAM_ID,
},
production: process.env.NODE_ENV === 'production',
});
/**
* Sends a push notification to an iOS device
* @param deviceToken The device token to send to
* @param notification The notification to send
* @returns Result of the send operation
*/
export async function sendIosNotification(
deviceToken: string,
notification: INotification
): Promise<apn.Responses> {
try {
// Create APNs notification
const apnsNotification = new apn.Notification();
// Set expiry (1 hour)
apnsNotification.expiry = Math.floor(Date.now() / 1000) + 3600;
// Set priority (10 is immediate delivery)
apnsNotification.priority = 10;
// Set sound (default or custom)
apnsNotification.sound = notification.sound || 'default';
// Set alert
apnsNotification.alert = {
title: notification.title,
body: notification.body,
};
// Set custom data
apnsNotification.payload = {
notificationId: notification._id.toString(),
type: notification.type,
data: notification.data,
};
// Set badge count if provided
if (notification.badge) {
apnsNotification.badge = notification.badge;
}
// Send notification
const result = await apnProvider.send(apnsNotification, deviceToken);
// Log results
if (result.failed.length > 0) {
logger.error('Failed to send iOS notification', {
deviceToken,
errors: result.failed.map(item => ({
device: item.device,
status: item.status,
response: item.response,
})),
});
}
if (result.sent.length > 0) {
logger.info('Successfully sent iOS notification', {
deviceToken,
sent: result.sent.length,
});
}
return result;
} catch (error) {
logger.error('Error sending iOS notification', error);
throw new Error('Failed to send iOS notification');
}
}
/**
* Registers a device token for a user
* @param userId The user ID
* @param deviceToken The device token to register
* @param deviceInfo Additional device information
* @returns The registered device
*/
export async function registerDeviceToken(
userId: string,
deviceToken: string,
deviceInfo: DeviceInfo
): Promise<IDevice> {
try {
// Check if device token already exists
const existingDevice = await DeviceModel.findOne({ deviceToken });
if (existingDevice) {
// Update existing device
existingDevice.userId = userId;
existingDevice.deviceInfo = deviceInfo;
existingDevice.updatedAt = new Date();
await existingDevice.save();
return existingDevice;
}
// Create new device
const device = new DeviceModel({
userId,
deviceToken,
deviceInfo,
platform: 'ios',
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
});
await device.save();
return device;
} catch (error) {
logger.error('Error registering device token', error);
throw new Error('Failed to register device token');
}
}
Apple Developer Account Configuration
To use APNs, you need to configure your Apple Developer account:
- Create App ID: Register your app in the Apple Developer portal with push notifications enabled
- Generate Key: Create an APNs Authentication Key in the Apple Developer portal
- Configure Certificates: For legacy APNs, create and download SSL certificates
The following environment variables are required for APNs:
APNS_KEY_PATH: Path to the .p8 key fileAPNS_KEY_ID: Key ID from the Apple Developer portalAPNS_TEAM_ID: Team ID from the Apple Developer accountAPNS_BUNDLE_ID: Bundle ID of your iOS app
Notification Templates
We use a template system for consistent notification formatting across channels:
import * as Handlebars from 'handlebars';
import { readFileSync } from 'fs';
import { join } from 'path';
import { logger } from '../utils/logger';
// Template cache
const templateCache: Record<string, Handlebars.TemplateDelegate> = {};
/**
* Loads a template from the filesystem
* @param templateName The name of the template to load
* @returns Compiled Handlebars template
*/
function loadTemplate(templateName: string): Handlebars.TemplateDelegate {
try {
// Check cache first
if (templateCache[templateName]) {
return templateCache[templateName];
}
// Load template from filesystem
const templatePath = join(__dirname, '../templates', `${templateName}.hbs`);
const templateSource = readFileSync(templatePath, 'utf-8');
// Compile template
const template = Handlebars.compile(templateSource);
// Cache template
templateCache[templateName] = template;
return template;
} catch (error) {
logger.error(`Error loading template: ${templateName}`, error);
throw new Error(`Failed to load template: ${templateName}`);
}
}
/**
* Renders a notification using a template
* @param templateName The name of the template to use
* @param data The data to render the template with
* @returns Rendered notification content
*/
export function renderNotification(
templateName: string,
data: Record<string, any>
): string {
try {
const template = loadTemplate(templateName);
return template(data);
} catch (error) {
logger.error(`Error rendering template: ${templateName}`, error);
throw new Error(`Failed to render template: ${templateName}`);
}
}
Database Schema
The Notification Service uses the following MongoDB schema for notification management:
import mongoose, { Schema, Document } from 'mongoose';
export interface INotification extends Document {
userId: mongoose.Types.ObjectId;
type: string;
title: string;
body: string;
data?: Record<string, any>;
channels: Array<'email' | 'push' | 'sms' | 'in-app'>;
status: 'pending' | 'sent' | 'delivered' | 'failed';
scheduledFor?: Date;
sentAt?: Date;
deliveredAt?: Date;
readAt?: Date;
failureReason?: string;
priority: 'low' | 'normal' | 'high';
badge?: number;
sound?: string;
createdAt: Date;
updatedAt: Date;
}
const NotificationSchema: Schema = new Schema({
userId: { type: mongoose.Schema.Types.ObjectId, required: true, ref: 'User' },
type: { type: String, required: true },
title: { type: String, required: true },
body: { type: String, required: true },
data: { type: Schema.Types.Mixed },
channels: [{
type: String,
required: true,
enum: ['email', 'push', 'sms', 'in-app']
}],
status: {
type: String,
required: true,
enum: ['pending', 'sent', 'delivered', 'failed'],
default: 'pending'
},
scheduledFor: { type: Date },
sentAt: { type: Date },
deliveredAt: { type: Date },
readAt: { type: Date },
failureReason: { type: String },
priority: {
type: String,
required: true,
enum: ['low', 'normal', 'high'],
default: 'normal'
},
badge: { type: Number },
sound: { type: String },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
});
// Pre-save hook to update the updatedAt field
NotificationSchema.pre('save', function(next) {
this.updatedAt = new Date();
next();
});
export const NotificationModel = mongoose.model<INotification>('Notification', NotificationSchema);
Device Schema
export interface IDevice extends Document {
userId: mongoose.Types.ObjectId;
deviceToken: string;
platform: 'ios' | 'android' | 'web';
deviceInfo: {
model?: string;
osVersion?: string;
appVersion?: string;
};
isActive: boolean;
lastUsed?: Date;
createdAt: Date;
updatedAt: Date;
}
const DeviceSchema: Schema = new Schema({
userId: { type: mongoose.Schema.Types.ObjectId, required: true, ref: 'User' },
deviceToken: { type: String, required: true, unique: true },
platform: {
type: String,
required: true,
enum: ['ios', 'android', 'web']
},
deviceInfo: {
model: { type: String },
osVersion: { type: String },
appVersion: { type: String }
},
isActive: { type: Boolean, default: true },
lastUsed: { type: Date },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
});
// Pre-save hook to update the updatedAt field
DeviceSchema.pre('save', function(next) {
this.updatedAt = new Date();
next();
});
export const DeviceModel = mongoose.model<IDevice>('Device', DeviceSchema);
Notification Preferences Schema
export interface INotificationPreferences extends Document {
userId: mongoose.Types.ObjectId;
channels: {
email: boolean;
push: boolean;
sms: boolean;
inApp: boolean;
};
types: Record<string, {
enabled: boolean;
channels?: Array<'email' | 'push' | 'sms' | 'in-app'>;
}>;
quietHours: {
enabled: boolean;
start?: string; // HH:MM format
end?: string; // HH:MM format
timezone?: string;
};
createdAt: Date;
updatedAt: Date;
}
const NotificationPreferencesSchema: Schema = new Schema({
userId: { type: mongoose.Schema.Types.ObjectId, required: true, ref: 'User', unique: true },
channels: {
email: { type: Boolean, default: true },
push: { type: Boolean, default: true },
sms: { type: Boolean, default: true },
inApp: { type: Boolean, default: true }
},
types: { type: Schema.Types.Mixed, default: {} },
quietHours: {
enabled: { type: Boolean, default: false },
start: { type: String },
end: { type: String },
timezone: { type: String }
},
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
});
// Pre-save hook to update the updatedAt field
NotificationPreferencesSchema.pre('save', function(next) {
this.updatedAt = new Date();
next();
});
export const NotificationPreferencesModel = mongoose.model<INotificationPreferences>(
'NotificationPreferences',
NotificationPreferencesSchema
);
API Endpoints
Notification Management
| Endpoint | Method | Description | Authentication Required |
|---|---|---|---|
/api/notifications | POST | Create a new notification | Yes (Service) |
/api/notifications/batch | POST | Create multiple notifications | Yes (Service) |
/api/notifications/user/{userId} | GET | Get user notifications | Yes (User/Service) |
/api/notifications/{id} | GET | Get notification details | Yes (User/Service) |
/api/notifications/{id}/read | PUT | Mark notification as read | Yes (User) |
/api/notifications/read-all | PUT | Mark all notifications as read | Yes (User) |
Device Management
| Endpoint | Method | Description | Authentication Required |
|---|---|---|---|
/api/notifications/devices | POST | Register a device | Yes (User) |
/api/notifications/devices/{token} | DELETE | Unregister a device | Yes (User) |
/api/notifications/devices | GET | List user devices | Yes (User) |
Notification Preferences
| Endpoint | Method | Description | Authentication Required |
|---|---|---|---|
/api/notifications/preferences | GET | Get notification preferences | Yes (User) |
/api/notifications/preferences | PUT | Update notification preferences | Yes (User) |
/api/notifications/preferences/channels | PUT | Update channel preferences | Yes (User) |
/api/notifications/preferences/types | PUT | Update type preferences | Yes (User) |
/api/notifications/preferences/quiet-hours | PUT | Update quiet hours | Yes (User) |
iOS Notification Payload Structure
When sending notifications to iOS devices, we use the following payload structure:
{
"aps": {
"alert": {
"title": "New Job Match",
"body": "A new job matching your profile has been posted"
},
"badge": 1,
"sound": "default"
},
"notificationId": "60a1b2c3d4e5f6g7h8i9j0k1",
"type": "job_match",
"data": {
"jobId": "60a1b2c3d4e5f6g7h8i9j0k2",
"companyId": "60a1b2c3d4e5f6g7h8i9j0k3",
"matchScore": 85
}
}
Notification Categories
For interactive notifications, we define the following categories:
// iOS notification categories
export const IOS_NOTIFICATION_CATEGORIES = [
{
id: 'job_match',
actions: [
{
id: 'view',
title: 'View Job',
options: ['foreground']
},
{
id: 'save',
title: 'Save for Later',
options: ['foreground']
}
]
},
{
id: 'application_update',
actions: [
{
id: 'view',
title: 'View Application',
options: ['foreground']
}
]
},
{
id: 'interview_invitation',
actions: [
{
id: 'accept',
title: 'Accept',
options: ['foreground']
},
{
id: 'decline',
title: 'Decline',
options: ['foreground']
},
{
id: 'reschedule',
title: 'Reschedule',
options: ['foreground']
}
]
}
];
Apple Push Notification Service (APNs) Configuration
Token-Based Authentication
We use token-based authentication for APNs, which requires:
- An Apple Developer account with push notification capability enabled
- A key file (.p8) generated in the Apple Developer portal
- The key ID and team ID from the Apple Developer account
// APNs token configuration
const apnsConfig = {
token: {
key: process.env.APNS_KEY_PATH, // Path to .p8 file
keyId: process.env.APNS_KEY_ID, // Key ID from Apple Developer portal
teamId: process.env.APNS_TEAM_ID, // Team ID from Apple Developer account
},
production: process.env.NODE_ENV === 'production', // Use production APNs in production
};
Certificate-Based Authentication (Legacy)
For legacy systems, we also support certificate-based authentication:
// APNs certificate configuration (legacy)
const apnsCertConfig = {
cert: process.env.APNS_CERT_PATH, // Path to certificate file
key: process.env.APNS_KEY_PATH, // Path to key file
production: process.env.NODE_ENV === 'production', // Use production APNs in production
};
Notification Types
The Notification Service supports the following notification types:
| Type | Description | Default Channels | Priority |
|---|---|---|---|
job_match | New job matching user profile | push, in-app, email | normal |
application_submitted | Job application submitted | push, in-app, email | normal |
application_viewed | Job application viewed by company | push, in-app | normal |
application_status_update | Job application status updated | push, in-app, email | high |
interview_invitation | Invitation to interview | push, in-app, email, sms | high |
interview_reminder | Reminder for upcoming interview | push, in-app, email, sms | high |
message_received | New message received | push, in-app | normal |
profile_view | Profile viewed by company | push, in-app | low |
account_update | Account information updated | normal | |
password_reset | Password reset request | high |
Integration with Other Services
The Notification Service integrates with other RovesUp services:
- Authentication Service: For user authentication and verification
- Student Service: For student-specific notifications
- Company Service: For company-specific notifications
- AI Service: For AI-generated recommendations and alerts
Error Handling
The Notification Service implements robust error handling:
// Error response structure
interface ErrorResponse {
error: string;
details?: any;
code?: string;
}
// Common error handling middleware
export function errorHandler(err: any, req: Request, res: Response, next: NextFunction): void {
logger.error('Error occurred', err);
// Handle specific error types
if (err.name === 'ValidationError') {
return res.status(400).json({
error: 'Validation error',
details: err.errors
});
}
if (err.name === 'UnauthorizedError') {
return res.status(401).json({
error: 'Authentication error',
code: 'UNAUTHORIZED'
});
}
// Default error response
res.status(500).json({
error: 'Internal server error',
code: 'INTERNAL_ERROR'
});
}
Deployment
The Notification Service is deployed as a Docker container in a Kubernetes cluster:
# Kubernetes deployment configuration
apiVersion: apps/v1
kind: Deployment
metadata:
name: notification-service
namespace: rovesup
spec:
replicas: 3
selector:
matchLabels:
app: notification-service
template:
metadata:
labels:
app: notification-service
spec:
containers:
- name: notification-service
image: registry.gitlab.com/rovesup/notification-service:latest
ports:
- containerPort: 3000
env:
- name: NODE_ENV
value: "production"
- name: MONGODB_URI
valueFrom:
secretKeyRef:
name: notification-secrets
key: mongodb-uri
- name: APNS_KEY_PATH
value: "/app/certs/apns.p8"
- name: APNS_KEY_ID
valueFrom:
secretKeyRef:
name: notification-secrets
key: apns-key-id
- name: APNS_TEAM_ID
valueFrom:
secretKeyRef:
name: notification-secrets
key: apns-team-id
- name: APNS_BUNDLE_ID
value: "com.rovesup.app"
- name: AUTH_SERVICE_URL
value: "http://auth-service:3000"
- name: STUDENT_SERVICE_URL
value: "http://student-service:3000"
- name: COMPANY_SERVICE_URL
value: "http://company-service:3000"
volumeMounts:
- name: apns-certs
mountPath: /app/certs
readOnly: true
resources:
limits:
cpu: "500m"
memory: "512Mi"
requests:
cpu: "200m"
memory: "256Mi"
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
volumes:
- name: apns-certs
secret:
secretName: apns-certificates