Skip to main content

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.ts
  • messaging/notifications/src/lib/notification.processor.ts
  • libs/tee-time-services/src/lib/messaging/teetime-messaging.service.ts

Delivery Channels

ChannelPriorityUse Case
WHATSAPP1 (highest)Primary player communication
PUSH2Mobile app notifications
IN_APP3Dashboard alerts
SMS4Fallback for WhatsApp
EMAIL5Formal 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 KeyTriggerChannels
BOOKING_CONFIRMATIONBooking createdWA, Email
BOOKING_REMINDER24h before tee timeWA, Push
BOOKING_CANCELLATIONBooking cancelledWA, Email
CHECKIN_CONFIRMATIONPlayer checks inPush
PACE_ALERTSlow play detectedPush
PACE_WARNINGPace thresholdPush
WEATHER_ALERTSevere weatherWA, Push, SMS
WAITLIST_OFFERSlot availableWA, Push, SMS
WAITLIST_CONFIRMATIONOffer acceptedWA, Email
PLAYER_MESSAGEAdmin messageWA
BULK_NOTICEMass notificationWA, Email

Security Templates

Template KeyTriggerChannels
OTPAuthenticationSMS, WA
PASSWORD_CHANGEDPassword updateEmail, SMS
PASSWORD_RESET_REQUESTEDReset requestEmail, WA
LOGIN_SUCCESSSuccessful loginEmail
LOGIN_FAILUREFailed attemptsEmail, SMS
ACCOUNT_UNLOCKEDAccount restoredEmail, WA

Benefits Templates

Template KeyTriggerChannels
POLICY_WARNINGCompliance issueEmail
ELIGIBILITY_GAINEDAccess grantedEmail, Push
ELIGIBILITY_LOSTAccess revokedEmail
BULK_IMPORTImport completeEmail
AUDIT_ALERTSuspicious activityEmail

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

CategoryDefaultExamples
SECURITYAlways allowedOTP, password reset
UTILITYAllowedBooking confirmations, reminders
MARKETINGOpt-in requiredNewsletters, 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 IDPurpose
booking_confirmation_v3Booking confirmed
booking_reminder_v224h reminder
waitlist_offer_v1Slot available
buddy_invite_v7Buddy invitation
buddy_accepted_v7Invite 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

StatusDescription
QUEUEDJob in queue
SENTDelivered to provider
DELIVEREDConfirmed delivery
READMessage read (WA, Push)
FAILEDDelivery 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 VarDescriptionDefault
MESSAGING_REDIS_URLRedis for queueREDIS_URL
QUIET_HOURS_ENFORCEEnable quiet hoursfalse
QUIET_HOURS_STARTQuiet start time22:00
QUIET_HOURS_ENDQuiet end time07:00
COMMS_MODEon/off for testingon

Metrics

MetricDescription
notifications_sent_totalBy channel and template
notifications_failed_totalFailed deliveries
notification_latency_secondsQueue to delivery time
notification_queue_depthPending jobs

Troubleshooting

Notifications Not Sending

  1. Check queue health: redis-cli llen bull:notificationQueue:wait
  2. Verify recipient preferences allow notification
  3. Check channel rate limits
  4. Review quiet hours configuration
  5. Check COMMS_MODE is on

Delivery Failures

  1. Check provider credentials (SendGrid, Twilio, etc.)
  2. Review failed job logs in Redis
  3. Verify recipient contact info (email, phone)
  4. Check template exists and is approved (WhatsApp)

Duplicate Notifications

  1. Verify deduplication is enabled
  2. Check job ID computation
  3. Review event listener for double-firing