Golf Cart Messaging Specification
- Status: Implemented (v0.6.0)
- Date: 2025-12-06
- Author: Rudi Haarhoff
- Related:
Overview
Integrate the Golf Cart Management system with the Messaging Service to provide automated notifications for fleet operations, maintenance alerts, and incident reporting.
Goals
- Alert facility managers on critical cart conditions (low battery, maintenance due)
- Notify maintenance teams of scheduled services and incidents
- Inform players of cart assignments and return confirmations
- Enable predictive maintenance alerts before equipment failure
- Support multi-channel delivery (Email, SMS, WhatsApp, Push)
Existing Architecture Assessment
Key Components Discovered
| Component | Location | Purpose |
|---|---|---|
NotificationType enum | messaging/notifications/src/lib/types/NotificationTypes.ts | Defines notification type keys |
NotificationEmitter | messaging/notifications/src/lib/notification.emitter.ts | Enqueues jobs to BullMQ |
ContactPreferenceService | messaging/core/src/lib/contact-preference.service.ts | Checks user consent |
NotificationProcessor | messaging/notifications/src/lib/notification.processor.ts | Processes queued jobs, sends via channels |
TemplateService | messaging/services/src/template-management/template-management.service.ts | Resolves Handlebars templates |
TeeTimePreferenceProcessor | messaging/workflows/src/lib/tee-time-notification.processor.ts | Reference integration pattern |
Current GolfCartService Integration Status
File: libs/facilities/facilities-service/src/lib/golf-cart.service.ts
| Method | Lines | Notification Hook | Status |
|---|---|---|---|
updateBattery() | 109-119 | Low battery + out_of_service | Implemented |
reportIncident() | 297-324 | Incident reported | Implemented |
assignCart() | 161-194 | Cart assigned / return reminder | Implemented |
scheduleMaintenance() | 400-422 | Maintenance scheduled/due | Implemented |
markServiced() | 424-455 | Service completed | Implemented |
resolveIncident() | 326-351 | Incident resolved | Implemented |
Integration Pattern (from TeeTimePreferenceProcessor)
// Reference: messaging/workflows/src/lib/tee-time-notification.processor.ts:101-114
private async notifyIfAllowed(
channel: Channel,
base: { key: NotificationType; tenantId: string; userId: string },
contact: Partial<{ email: string; phoneNumber: string; pushToken: string }>,
): Promise<void> {
const lower = CHANNEL_MAP[channel]; // Map Channel enum to MessageChannel
if (await this.preferences.isAllowed(base.userId, lower, base.key)) {
await this.emitChannelNotification(channel, base, contact);
}
}
Notification Types
Operational Alerts (UTILITY scope)
| Type | Trigger | Recipients | Channels | Priority |
|---|---|---|---|---|
GOLF_CART_LOW_BATTERY | Battery < 20% | Facility Managers | Email, SMS | HIGH |
GOLF_CART_OUT_OF_SERVICE | Condition set to OUT_OF_SERVICE | Facility Managers | Email, SMS | HIGH |
GOLF_CART_NOT_RETURNED | Assignment > 6 hours without return | Facility Managers | Email, SMS | MEDIUM |
GOLF_CART_ZONE_ANOMALY | Cart detected outside allowed zones | Facility Managers | SMS, Push | HIGH |
Maintenance Alerts (UTILITY scope)
| Type | Trigger | Recipients | Channels | Priority |
|---|---|---|---|---|
GOLF_CART_MAINTENANCE_DUE | Service date within 7 days or odometer threshold | Maintenance Team | MEDIUM | |
GOLF_CART_MAINTENANCE_SCHEDULED | Maintenance scheduled via API | Maintenance Team | LOW | |
GOLF_CART_SERVICE_COMPLETED | Maintenance marked complete | Facility Managers | LOW | |
GOLF_CART_PREDICTIVE_ALERT | Predictive maintenance flags cart | Maintenance Team | MEDIUM |
Incident Alerts (UTILITY scope)
| Type | Trigger | Recipients | Channels | Priority |
|---|---|---|---|---|
GOLF_CART_INCIDENT_REPORTED | New incident reported | Facility Managers, Maintenance | Email, SMS | HIGH (MAJOR/CRITICAL) |
GOLF_CART_INCIDENT_RESOLVED | Incident resolved | Facility Managers | LOW |
Player Notifications (UTILITY scope)
| Type | Trigger | Recipients | Channels | Priority |
|---|---|---|---|---|
GOLF_CART_ASSIGNED | Cart assigned to booking | Player (primary driver) | Email, Push | LOW |
GOLF_CART_RETURN_REMINDER | 30 min before expected return | Player | Push | LOW |
GOLF_CART_RETURN_CONFIRMED | Cart returned successfully | Player | LOW |
Data Model
NotificationType Enum Additions
// Add to messaging/notifications/src/lib/types/NotificationTypes.ts
export enum NotificationType {
// ... existing types ...
// Golf Cart - Operational
GOLF_CART_LOW_BATTERY = 'golf_cart_low_battery',
GOLF_CART_OUT_OF_SERVICE = 'golf_cart_out_of_service',
GOLF_CART_NOT_RETURNED = 'golf_cart_not_returned',
GOLF_CART_ZONE_ANOMALY = 'golf_cart_zone_anomaly',
// Golf Cart - Maintenance
GOLF_CART_MAINTENANCE_DUE = 'golf_cart_maintenance_due',
GOLF_CART_MAINTENANCE_SCHEDULED = 'golf_cart_maintenance_scheduled',
GOLF_CART_SERVICE_COMPLETED = 'golf_cart_service_completed',
GOLF_CART_PREDICTIVE_ALERT = 'golf_cart_predictive_alert',
// Golf Cart - Incidents
GOLF_CART_INCIDENT_REPORTED = 'golf_cart_incident_reported',
GOLF_CART_INCIDENT_RESOLVED = 'golf_cart_incident_resolved',
// Golf Cart - Player
GOLF_CART_ASSIGNED = 'golf_cart_assigned',
GOLF_CART_RETURN_REMINDER = 'golf_cart_return_reminder',
GOLF_CART_RETURN_CONFIRMED = 'golf_cart_return_confirmed',
}
Notification Scope Configuration
// Category mapping for preference handling
const GOLF_CART_NOTIFICATION_SCOPES: Record<NotificationType, NotificationScope> = {
// Operational - always allowed unless explicit opt-out
[NotificationType.GOLF_CART_LOW_BATTERY]: 'UTILITY',
[NotificationType.GOLF_CART_OUT_OF_SERVICE]: 'UTILITY',
[NotificationType.GOLF_CART_NOT_RETURNED]: 'UTILITY',
[NotificationType.GOLF_CART_ZONE_ANOMALY]: 'UTILITY',
// Maintenance - utility alerts for staff
[NotificationType.GOLF_CART_MAINTENANCE_DUE]: 'UTILITY',
[NotificationType.GOLF_CART_MAINTENANCE_SCHEDULED]: 'UTILITY',
[NotificationType.GOLF_CART_SERVICE_COMPLETED]: 'UTILITY',
[NotificationType.GOLF_CART_PREDICTIVE_ALERT]: 'UTILITY',
// Incidents - high priority utility
[NotificationType.GOLF_CART_INCIDENT_REPORTED]: 'UTILITY',
[NotificationType.GOLF_CART_INCIDENT_RESOLVED]: 'UTILITY',
// Player notifications - optional utility
[NotificationType.GOLF_CART_ASSIGNED]: 'UTILITY',
[NotificationType.GOLF_CART_RETURN_REMINDER]: 'UTILITY',
[NotificationType.GOLF_CART_RETURN_CONFIRMED]: 'UTILITY',
};
Service Architecture
GolfCartNotificationService
// libs/facilities/facilities-service/src/lib/golf-cart-notification.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { Channel } from '@prisma/messaging';
import {
NotificationEmitter,
NotificationType,
type TemplateNotificationJob,
} from '@digiwedge/messaging-notifications';
import type { ContactPreferenceService, MessageChannel } from '@digiwedge/messaging-core';
import type { GolfCartDetails, CartIncident, CartAssignment } from '@prisma/facilities-data';
// Channel mapping (same pattern as tee-time processor)
const CHANNEL_MAP: Record<Channel, MessageChannel> = {
[Channel.EMAIL]: 'email',
[Channel.SMS]: 'sms',
[Channel.WHATSAPP]: 'whatsapp',
[Channel.PUSH]: 'push',
[Channel.CHAT]: 'push',
[Channel.OTHER]: 'push',
};
@Injectable()
export class GolfCartNotificationService {
private readonly logger = new Logger(GolfCartNotificationService.name);
constructor(
private readonly emitter: NotificationEmitter,
private readonly preferences: ContactPreferenceService,
) {}
/**
* Alert on low battery condition
*/
async notifyLowBattery(cart: GolfCartDetails, recipients: RecipientInfo[]): Promise<void> {
const tenantId = String(cart.resource.tenantId);
const variables = {
cartNumber: cart.cartNumber,
batteryLevel: cart.batteryLevel,
model: `${cart.manufacturer} ${cart.model}`,
currentZone: cart.currentZone || 'Unknown',
threshold: 20,
};
for (const recipient of recipients) {
// Email notification
if (recipient.email && recipient.userId) {
await this.notifyIfAllowed(
Channel.EMAIL,
{ key: NotificationType.GOLF_CART_LOW_BATTERY, tenantId, userId: recipient.userId },
{ email: recipient.email },
variables,
);
}
// SMS for critical battery (< 10%)
if (recipient.phoneNumber && recipient.userId && (cart.batteryLevel ?? 100) < 10) {
await this.notifyIfAllowed(
Channel.SMS,
{ key: NotificationType.GOLF_CART_LOW_BATTERY, tenantId, userId: recipient.userId },
{ phoneNumber: recipient.phoneNumber },
variables,
);
}
}
}
/**
* Check preferences and emit notification (same pattern as TeeTimePreferenceProcessor)
*/
private async notifyIfAllowed(
channel: Channel,
base: { key: NotificationType; tenantId: string; userId: string },
contact: Partial<{ email: string; phoneNumber: string; pushToken: string }>,
variables?: Record<string, unknown>,
): Promise<void> {
const lower = CHANNEL_MAP[channel];
if (await this.preferences.isAllowed(base.userId, lower, base.key)) {
const job: TemplateNotificationJob = {
...base,
channel,
...contact,
variables,
};
await this.emitter.emitNotification(job);
this.logger.debug(`Notification queued: ${base.key} via ${channel}`);
} else {
this.logger.debug(`Notification blocked by preferences: ${base.key} for ${base.userId}`);
}
}
/**
* Alert on incident reported
*/
async notifyIncidentReported(
cart: GolfCartDetails,
incident: CartIncident,
recipients: RecipientInfo[],
): Promise<void> {
const tenantId = String(cart.resource.tenantId);
const isCritical = ['MAJOR', 'CRITICAL'].includes(incident.severity);
const variables = {
cartNumber: cart.cartNumber,
incidentType: incident.incidentType,
severity: incident.severity,
description: incident.description,
location: incident.location || 'Not specified',
reportedBy: incident.reportedBy,
reportedAt: incident.reportedAt.toISOString(),
isCritical,
};
for (const recipient of recipients) {
// Email notification for all incidents
if (recipient.email && recipient.userId) {
await this.notifyIfAllowed(
Channel.EMAIL,
{ key: NotificationType.GOLF_CART_INCIDENT_REPORTED, tenantId, userId: recipient.userId },
{ email: recipient.email },
variables,
);
}
// SMS for critical incidents only
if (isCritical && recipient.phoneNumber && recipient.userId) {
await this.notifyIfAllowed(
Channel.SMS,
{ key: NotificationType.GOLF_CART_INCIDENT_REPORTED, tenantId, userId: recipient.userId },
{ phoneNumber: recipient.phoneNumber },
variables,
);
}
}
}
/**
* Alert on maintenance due
*/
async notifyMaintenanceDue(
cart: GolfCartDetails,
reason: string,
recipients: RecipientInfo[],
): Promise<void> {
const tenantId = String(cart.resource.tenantId);
const variables = {
cartNumber: cart.cartNumber,
model: `${cart.manufacturer} ${cart.model}`,
reason,
nextServiceDue: cart.nextServiceDue?.toISOString(),
odometer: cart.odometer,
hoursUsed: cart.hoursUsed,
lastServicedAt: cart.lastServicedAt?.toISOString(),
};
for (const recipient of recipients) {
if (recipient.email && recipient.userId) {
await this.notifyIfAllowed(
Channel.EMAIL,
{ key: NotificationType.GOLF_CART_MAINTENANCE_DUE, tenantId, userId: recipient.userId },
{ email: recipient.email },
variables,
);
}
}
}
/**
* Notify player of cart assignment
*/
async notifyCartAssigned(
cart: GolfCartDetails,
assignment: CartAssignment,
player: RecipientInfo,
): Promise<void> {
const tenantId = String(cart.resource.tenantId);
const variables = {
cartNumber: cart.cartNumber,
model: `${cart.manufacturer} ${cart.model}`,
seatingCapacity: cart.seatingCapacity,
batteryLevel: cart.batteryLevel,
assignedAt: assignment.assignedAt.toISOString(),
};
if (!player.userId) return;
// Email notification
if (player.email) {
await this.notifyIfAllowed(
Channel.EMAIL,
{ key: NotificationType.GOLF_CART_ASSIGNED, tenantId, userId: player.userId },
{ email: player.email },
variables,
);
}
// Push notification
if (player.pushToken) {
await this.notifyIfAllowed(
Channel.PUSH,
{ key: NotificationType.GOLF_CART_ASSIGNED, tenantId, userId: player.userId },
{ pushToken: player.pushToken },
{
...variables,
title: `Cart ${cart.cartNumber} Assigned`,
body: `Your golf cart is ready. Battery: ${cart.batteryLevel}%`,
},
);
}
}
/**
* Alert on cart not returned
*/
async notifyCartNotReturned(
cart: GolfCartDetails,
assignment: CartAssignment,
recipients: RecipientInfo[],
): Promise<void> {
const tenantId = String(cart.resource.tenantId);
const hoursOverdue = this.calculateHoursOverdue(assignment);
const variables = {
cartNumber: cart.cartNumber,
assignedAt: assignment.assignedAt.toISOString(),
hoursOverdue,
lastKnownZone: cart.currentZone || 'Unknown',
playerId: assignment.playerId,
bookingId: assignment.bookingId,
};
for (const recipient of recipients) {
if (!recipient.userId) continue;
// Email notification
if (recipient.email) {
await this.notifyIfAllowed(
Channel.EMAIL,
{ key: NotificationType.GOLF_CART_NOT_RETURNED, tenantId, userId: recipient.userId },
{ email: recipient.email },
variables,
);
}
// SMS if significantly overdue (> 2 hours)
if (recipient.phoneNumber && hoursOverdue > 2) {
await this.notifyIfAllowed(
Channel.SMS,
{ key: NotificationType.GOLF_CART_NOT_RETURNED, tenantId, userId: recipient.userId },
{ phoneNumber: recipient.phoneNumber },
variables,
);
}
}
}
/**
* Alert on zone anomaly (geofence breach)
*/
async notifyZoneAnomaly(
cart: GolfCartDetails,
expectedZones: string[],
actualZone: string,
recipients: RecipientInfo[],
): Promise<void> {
const tenantId = String(cart.resource.tenantId);
const variables = {
cartNumber: cart.cartNumber,
actualZone,
expectedZones: expectedZones.join(', '),
title: `Cart ${cart.cartNumber} Zone Alert`,
body: `Cart detected in ${actualZone}, expected: ${expectedZones.join('/')}`,
};
for (const recipient of recipients) {
if (!recipient.userId) continue;
// SMS for immediate response
if (recipient.phoneNumber) {
await this.notifyIfAllowed(
Channel.SMS,
{ key: NotificationType.GOLF_CART_ZONE_ANOMALY, tenantId, userId: recipient.userId },
{ phoneNumber: recipient.phoneNumber },
variables,
);
}
// Push notification
if (recipient.pushToken) {
await this.notifyIfAllowed(
Channel.PUSH,
{ key: NotificationType.GOLF_CART_ZONE_ANOMALY, tenantId, userId: recipient.userId },
{ pushToken: recipient.pushToken },
variables,
);
}
}
}
// Helper to calculate hours overdue
private calculateHoursOverdue(assignment: CartAssignment): number {
const now = new Date();
const assigned = new Date(assignment.assignedAt);
const expectedDuration = 5 * 60 * 60 * 1000; // 5 hours in ms
const elapsed = now.getTime() - assigned.getTime();
return Math.max(0, (elapsed - expectedDuration) / (60 * 60 * 1000));
}
}
/**
* Recipient info interface - matches existing contact patterns
* userId is required for preference checking
*/
interface RecipientInfo {
userId: string; // Required for ContactPreferenceService.isAllowed()
email?: string;
phoneNumber?: string;
pushToken?: string;
}
Integration Points
1. Battery Level Updates
// In GolfCartService.updateBattery()
async updateBattery(id: number, tenantId: number, level: number): Promise<GolfCartDetails> {
const previousLevel = await this.golfCartRepo.getBatteryLevel(id);
const result = await this.golfCartRepo.updateBattery(id, level);
// Notify when crossing low battery threshold (20%)
if (previousLevel >= 20 && level < 20) {
await this.notifications.notifyLowBattery(result);
}
return result;
}
2. Incident Reporting
// In GolfCartService.reportIncident()
async reportIncident(id: number, tenantId: number, input: ReportIncidentInput): Promise<CartIncident> {
const cart = await this.findOne(id, tenantId);
const incident = await this.golfCartRepo.createIncident({ ... });
// Notify on all incidents (severity determines channels)
await this.notifications.notifyIncidentReported(cart, incident);
return incident;
}
3. Cart Assignment
// In GolfCartService.assignCart()
async assignCart(cartId: number, bookingId: number, playerId?: number): Promise<CartAssignment> {
const cart = await this.golfCartRepo.findOne(cartId);
const assignment = await this.golfCartRepo.createAssignment({ ... });
// Notify player if we have their contact
if (playerId) {
const player = await this.playerService.findOne(playerId);
if (player.email) {
await this.notifications.notifyCartAssigned(cart, assignment, player.email, player.pushToken);
}
}
return assignment;
}
4. Predictive Maintenance (Scheduled Job)
// Cron job or scheduled task
@Cron('0 8 * * *') // Daily at 8 AM
async checkPredictiveMaintenance(): Promise<void> {
const carts = await this.golfCartService.getPredictiveMaintenance({
tenantId: 1, // Run for all tenants
horizonDays: 7,
odometerMargin: 50,
});
for (const cart of carts) {
const reason = this.determineMaintenanceReason(cart);
await this.notifications.notifyMaintenanceDue(cart, reason);
}
}
5. Cart Not Returned (Scheduled Job)
// Cron job every 30 minutes
@Cron('*/30 * * * *')
async checkOverdueAssignments(): Promise<void> {
const overdueAssignments = await this.golfCartRepo.findOverdueAssignments({
thresholdHours: 6,
});
for (const { cart, assignment } of overdueAssignments) {
// Deduplicate: only notify once per assignment
const dedupKey = `cart-not-returned-${assignment.id}`;
await this.notifications.notifyCartNotReturned(cart, assignment, dedupKey);
}
}
Message Templates
Email Templates
GOLF_CART_LOW_BATTERY
Subject: Alert: Cart {{cartNumber}} Low Battery ({{batteryLevel}}%)
<h2>Low Battery Alert</h2>
<p>Cart <strong>{{cartNumber}}</strong> has a low battery level and may need charging.</p>
<table>
<tr><td>Cart Number:</td><td>{{cartNumber}}</td></tr>
<tr><td>Battery Level:</td><td>{{batteryLevel}}%</td></tr>
<tr><td>Model:</td><td>{{model}}</td></tr>
<tr><td>Current Zone:</td><td>{{currentZone}}</td></tr>
</table>
<p>Please arrange for this cart to be charged before it becomes unavailable.</p>
GOLF_CART_INCIDENT_REPORTED
Subject: {{severity}} Incident: Cart {{cartNumber}} - {{incidentType}}
<h2>Cart Incident Report</h2>
<p>A {{severity}} incident has been reported for cart <strong>{{cartNumber}}</strong>.</p>
<table>
<tr><td>Cart Number:</td><td>{{cartNumber}}</td></tr>
<tr><td>Incident Type:</td><td>{{incidentType}}</td></tr>
<tr><td>Severity:</td><td>{{severity}}</td></tr>
<tr><td>Location:</td><td>{{location}}</td></tr>
<tr><td>Description:</td><td>{{description}}</td></tr>
<tr><td>Reported By:</td><td>{{reportedBy}}</td></tr>
<tr><td>Reported At:</td><td>{{reportedAt}}</td></tr>
</table>
{{#if isCritical}}
<p style="color: red;"><strong>This is a critical incident requiring immediate attention.</strong></p>
{{/if}}
GOLF_CART_ASSIGNED
Subject: Your Golf Cart is Ready - Cart {{cartNumber}}
<h2>Cart Assigned</h2>
<p>Your golf cart has been assigned for your round today.</p>
<table>
<tr><td>Cart Number:</td><td>{{cartNumber}}</td></tr>
<tr><td>Model:</td><td>{{model}}</td></tr>
<tr><td>Seating:</td><td>{{seatingCapacity}} passengers</td></tr>
<tr><td>Battery Level:</td><td>{{batteryLevel}}%</td></tr>
</table>
<p>Please return the cart to the designated area after your round. Enjoy your game!</p>
SMS Templates
GOLF_CART_LOW_BATTERY
ALERT: Cart {{cartNumber}} battery at {{batteryLevel}}%. Needs charging. Zone: {{currentZone}}
GOLF_CART_INCIDENT_REPORTED
{{severity}} INCIDENT: Cart {{cartNumber}} - {{incidentType}} at {{location}}. Check email for details.
GOLF_CART_NOT_RETURNED
Cart {{cartNumber}} not returned. {{hoursOverdue}}h overdue. Last seen: {{lastKnownZone}}
GOLF_CART_ZONE_ANOMALY
ZONE ALERT: Cart {{cartNumber}} in {{actualZone}}, expected {{expectedZones}}. Check immediately.
Quiet Hours Configuration
Golf cart operational alerts follow facility operating hours:
const GOLF_CART_QUIET_HOURS = {
// SMS/Push quiet hours (no disturbance)
quietStart: '21:00',
quietEnd: '06:00',
// Exceptions: critical alerts ignore quiet hours
criticalTypes: [
NotificationType.GOLF_CART_ZONE_ANOMALY,
NotificationType.GOLF_CART_INCIDENT_REPORTED, // Only CRITICAL severity
],
};
Deduplication Rules
| Notification Type | Dedup Key Pattern | TTL |
|---|---|---|
| LOW_BATTERY | low-battery-{cartId}-{date} | 24h |
| INCIDENT_REPORTED | incident-{incidentId} | Forever |
| NOT_RETURNED | not-returned-{assignmentId} | Until returned |
| ZONE_ANOMALY | zone-anomaly-{cartId}-{zone}-{hour} | 1h |
| MAINTENANCE_DUE | maint-due-{cartId}-{week} | 7d |
Implementation Phases
- Phase 1: Core Alerts — Complete
- Phase 2: Maintenance Alerts — Complete
- Phase 3: Player Notifications — Complete
- Phase 4: Advanced Alerts — In progress (zone anomaly/predictive optional)
Dependencies & Testing
- Notifications:
GolfCartNotificationService+GolfCartNotificationProcessor(scheduled overdue/reminder checks). - Messaging: templates managed via
@digiwedge/messaging-servicesand seeded withtools/scripts/messaging/seeds/seed-golf-cart-notifications.ts. - Queue: existing
notificationQueue(BullMQ) reused. - Unit coverage:
pnpm nx run facilities-service:test:unit --runInBand.
Risk Assessment
| Risk | Mitigation |
|---|---|
| Breaking existing notifications | Golf cart types are additive; no modification to existing handlers |
| Performance impact | Notifications are async via BullMQ; no synchronous blocking |
| Preference conflicts | Uses existing ContactPreferenceService; consistent behavior |
| Template failures | Processor has fallback logic (see notification.processor.ts:141-157) |
Recommended Implementation Order
- Phase 1 (Low risk): Add NotificationType enum values
- Phase 2 (Low risk): Create GolfCartNotificationService with unit tests
- Phase 3 (Medium risk): Integrate into GolfCartService methods
- Phase 4 (Low risk): Add scheduled processor for overdue carts
- Phase 5 (Low risk): Add database templates
Existing Patterns to Follow
// Reference: messaging/workflows/src/lib/tee-time-notification.processor.ts
// - Uses ContactPreferenceService.isAllowed() before sending
// - Maps Channel enum to MessageChannel via CHANNEL_MAP
// - Emits via NotificationEmitter.emitNotification()
// Reference: messaging/notifications/src/lib/handlers/AgreementExpiryHandler.ts
// - Supports both template mode and direct mode
// - Extracts email/phone from payload
// - Builds jobs array and emits in loop
Success Criteria
- All 13 notification types defined in NotificationType enum
- GolfCartNotificationService passes unit tests
- Low battery alerts fire when battery < 20%
- Incident alerts fire for MAJOR/CRITICAL severity
- Templates render correctly with Handlebars variables
- Preference checks prevent unwanted notifications
- No breaking changes to existing messaging flow