Messaging & Notifications
The messaging system provides multi-channel notifications with user preferences, rate limiting, and template management.
Architecture
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Domain Events │────▶│ Notification │────▶│ BullMQ Queue │
│ (booking, etc) │ │ Emitter │ │ (Redis) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│
┌─────────────────┐ ┌──────────────────┐ ▼
│ Channel Ports │◀────│ Notification │◀───────────┘
│ (Email, SMS..) │ │ Processor │
└─────────────────┘ └──────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ Delivery │ │ Preference │
│ Providers │ │ Check │
└─────────────────┘ └──────────────────┘
Sources:
messaging/notifications/src/lib/notification.emitter.tsmessaging/notifications/src/lib/notification.processor.tslibs/tee-time-services/src/lib/messaging/teetime-messaging.service.ts
Delivery Channels
| Channel | Priority | Use Case |
|---|---|---|
WHATSAPP | 1 (highest) | Primary player communication |
PUSH | 2 | Mobile app notifications |
IN_APP | 3 | Dashboard alerts |
SMS | 4 | Fallback for WhatsApp |
EMAIL | 5 | Formal communications, receipts |
Channel Configuration
// Default channel priority for tee time notifications
const CHANNEL_PRIORITY = ['WHATSAPP', 'PUSH', 'IN_APP', 'SMS', 'EMAIL'];
Notification Types
TeeTime Templates
| Template Key | Trigger | Channels |
|---|---|---|
BOOKING_CONFIRMATION | Booking created | WA, Email |
BOOKING_REMINDER | 24h before tee time | WA, Push |
BOOKING_CANCELLATION | Booking cancelled | WA, Email |
CHECKIN_CONFIRMATION | Player checks in | Push |
PACE_ALERT | Slow play detected | Push |
PACE_WARNING | Pace threshold | Push |
WEATHER_ALERT | Severe weather | WA, Push, SMS |
WAITLIST_OFFER | Slot available | WA, Push, SMS |
WAITLIST_CONFIRMATION | Offer accepted | WA, Email |
PLAYER_MESSAGE | Admin message | WA |
BULK_NOTICE | Mass notification | WA, Email |
Security Templates
| Template Key | Trigger | Channels |
|---|---|---|
OTP | Authentication | SMS, WA |
PASSWORD_CHANGED | Password update | Email, SMS |
PASSWORD_RESET_REQUESTED | Reset request | Email, WA |
LOGIN_SUCCESS | Successful login | |
LOGIN_FAILURE | Failed attempts | Email, SMS |
ACCOUNT_UNLOCKED | Account restored | Email, WA |
Benefits Templates
| Template Key | Trigger | Channels |
|---|---|---|
POLICY_WARNING | Compliance issue | |
ELIGIBILITY_GAINED | Access granted | Email, Push |
ELIGIBILITY_LOST | Access revoked | |
BULK_IMPORT | Import complete | |
AUDIT_ALERT | Suspicious activity |
User Preferences
Preference Model
interface ContactPreference {
contactId: string; // Player or user ID
channel: Channel; // SMS, EMAIL, WHATSAPP, etc.
scope: string; // Notification category
preferredChannel: boolean; // Allow/deny
}
Scope Categories
| Category | Default | Examples |
|---|---|---|
SECURITY | Always allowed | OTP, password reset |
UTILITY | Allowed | Booking confirmations, reminders |
MARKETING | Opt-in required | Newsletters, promotions |
Preference-Aware Sending
// In PreferenceAwareNotifierService
async sendIfAllowed(
notification: NotificationPayload,
category: 'SECURITY' | 'UTILITY' | 'MARKETING'
): Promise<boolean> {
const allowed = await this.checkAllowed(
notification.recipient,
notification.channel,
category
);
if (!allowed) {
this.logger.debug('Notification blocked by preference');
return false;
}
return this.send(notification);
}
Domain Event Listeners
Booking Notifications
// In BookingNotificationListener
@OnEvent('BOOKING_CONFIRMED')
async handleBookingConfirmed(event: BookingConfirmedEvent) {
const booking = await this.bookingResolver.resolve(event.bookingId);
for (const player of booking.players) {
await this.messagingService.send({
template: 'BOOKING_CONFIRMATION',
recipient: player,
data: {
courseName: booking.course.name,
date: booking.date,
time: booking.time,
confirmationNumber: booking.confirmationNumber
}
});
}
}
@OnEvent('BOOKING_CANCELLED')
async handleBookingCancelled(event: BookingCancelledEvent) {
// Notify all players of cancellation
}
Weather Notifications
// In WeatherNotificationService
async notifyWeatherAlert(alert: WeatherAlert) {
const affectedBookings = await this.resolver.findAffectedBookings(
alert.courseId,
alert.startTime,
alert.endTime
);
// Deduplicate recipients across bookings
const recipients = this.deduplicateRecipients(affectedBookings);
for (const recipient of recipients) {
await this.messagingService.send({
template: 'WEATHER_ALERT',
recipient,
data: {
alertType: alert.type, // lightning, rain, wind
severity: alert.severity, // warning, watch, advisory
message: alert.message
}
});
}
}
Block Notifications
// In BlockNotificationService
async notifyBlockCreated(block: TeeSheetBlock) {
if (!block.course.notifyOnBlock) return;
const affectedBookings = await this.findAffectedBookings(block);
for (const booking of affectedBookings) {
await this.messagingService.send({
template: 'BLOCK_NOTIFICATION',
recipient: booking.payer,
data: {
blockReason: block.reason,
affectedDate: block.date,
affectedTime: block.startTime
}
});
}
}
Queue Processing
BullMQ Configuration
// Queue setup
const notificationQueue = new Queue('notificationQueue', {
connection: {
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT
},
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 }
}
});
Job Processing
// In NotificationProcessor
@Processor('notificationQueue')
async process(job: Job<NotificationPayload>) {
const { channel, template, recipient, data } = job.data;
// Check quiet hours
if (this.isQuietHours(channel)) {
await this.requeueForLater(job);
return;
}
// Route to appropriate channel
switch (channel) {
case 'EMAIL':
await this.emailSender.send({ to: recipient.email, template, data });
break;
case 'SMS':
await this.smsSender.send({ to: recipient.phone, template, data });
break;
case 'WHATSAPP':
await this.whatsappService.sendTemplate(recipient.whatsappId, template, data);
break;
case 'PUSH':
await this.pushSender.send({ token: recipient.pushToken, ...data });
break;
}
// Log notification
await this.logNotification(job.data);
}
Rate Limiting
Tenant Throttle
Prevents notification storms per tenant:
// In TenantQueueThrottle
async acquire(tenantId: string): Promise<boolean> {
const current = await this.redis.incr(`throttle:${tenantId}`);
if (current > this.maxConcurrent) {
await this.redis.decr(`throttle:${tenantId}`);
return false;
}
return true;
}
async release(tenantId: string): Promise<void> {
await this.redis.decr(`throttle:${tenantId}`);
}
Channel Rate Limiter
Per-channel rate limits:
// Default limits per minute
const CHANNEL_LIMITS = {
EMAIL: 100,
SMS: 50,
WHATSAPP: 60,
PUSH: 200
};
Quiet Hours
Delays non-urgent notifications during configured hours:
// Environment configuration
QUIET_HOURS_ENFORCE=true
QUIET_HOURS_CHANNELS=SMS,WHATSAPP
QUIET_HOURS_START=22:00
QUIET_HOURS_END=07:00
Behavior:
- Jobs are requeued with delay until quiet hours end
- Security notifications (OTP) bypass quiet hours
- Email and Push typically excluded from quiet hours
Template System
Template Resolution
// Template lookup priority
1. Tenant-specific template for channel
2. Default template for channel
3. Fallback generated template
Template Variables
// Common variables available in templates
{
recipientName: string;
courseName: string;
clubName: string;
date: string; // Formatted date
time: string; // Formatted time
confirmationNumber: string;
// ... template-specific variables
}
WhatsApp Templates
Meta-approved templates with specific IDs:
| Template ID | Purpose |
|---|---|
booking_confirmation_v3 | Booking confirmed |
booking_reminder_v2 | 24h reminder |
waitlist_offer_v1 | Slot available |
buddy_invite_v7 | Buddy invitation |
buddy_accepted_v7 | Invite accepted |
Notification Logging
All notifications are logged for audit:
interface NotificationLog {
id: string;
tenantId: string;
bookingId?: string;
channel: Channel;
template: string;
sentAt: Date;
status: 'QUEUED' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED';
payload: JsonValue;
}
Status Tracking
| Status | Description |
|---|---|
QUEUED | Job in queue |
SENT | Delivered to provider |
DELIVERED | Confirmed delivery |
READ | Message read (WA, Push) |
FAILED | Delivery failed |
Channel Ports
Abstract interfaces for channel implementations:
Email Port
interface EmailSender {
send(options: {
to: string;
templateId?: string;
variables?: Record<string, unknown>;
subject?: string;
html?: string;
}): Promise<void>;
}
SMS Port
interface SmsSender {
sendSms(to: string, message: string): Promise<void>;
sendTemplate?(to: string, templateId: string, variables: Record<string, unknown>): Promise<void>;
}
Push Port
interface PushSender {
send(options: {
token: string;
title: string;
body: string;
data?: Record<string, unknown>;
}): Promise<void>;
}
Testing Mode
Intercept mode for non-production environments:
// Environment variable
COMMS_MODE=off // Intercepts all notifications
// Intercepting wrappers log instead of sending
class InterceptingEmailSender implements EmailSender {
async send(options: EmailOptions) {
this.logger.info('INTERCEPTED EMAIL', options);
// Does not actually send
}
}
Configuration
| Env Var | Description | Default |
|---|---|---|
MESSAGING_REDIS_URL | Redis for queue | REDIS_URL |
QUIET_HOURS_ENFORCE | Enable quiet hours | false |
QUIET_HOURS_START | Quiet start time | 22:00 |
QUIET_HOURS_END | Quiet end time | 07:00 |
COMMS_MODE | on/off for testing | on |
Metrics
| Metric | Description |
|---|---|
notifications_sent_total | By channel and template |
notifications_failed_total | Failed deliveries |
notification_latency_seconds | Queue to delivery time |
notification_queue_depth | Pending jobs |
Troubleshooting
Notifications Not Sending
- Check queue health:
redis-cli llen bull:notificationQueue:wait - Verify recipient preferences allow notification
- Check channel rate limits
- Review quiet hours configuration
- Check COMMS_MODE is
on
Delivery Failures
- Check provider credentials (SendGrid, Twilio, etc.)
- Review failed job logs in Redis
- Verify recipient contact info (email, phone)
- Check template exists and is approved (WhatsApp)
Duplicate Notifications
- Verify deduplication is enabled
- Check job ID computation
- Review event listener for double-firing