Skip to main content

Golf Cart Messaging Specification

Overview

Integrate the Golf Cart Management system with the Messaging Service to provide automated notifications for fleet operations, maintenance alerts, and incident reporting.

Goals

  1. Alert facility managers on critical cart conditions (low battery, maintenance due)
  2. Notify maintenance teams of scheduled services and incidents
  3. Inform players of cart assignments and return confirmations
  4. Enable predictive maintenance alerts before equipment failure
  5. Support multi-channel delivery (Email, SMS, WhatsApp, Push)

Existing Architecture Assessment

Key Components Discovered

ComponentLocationPurpose
NotificationType enummessaging/notifications/src/lib/types/NotificationTypes.tsDefines notification type keys
NotificationEmittermessaging/notifications/src/lib/notification.emitter.tsEnqueues jobs to BullMQ
ContactPreferenceServicemessaging/core/src/lib/contact-preference.service.tsChecks user consent
NotificationProcessormessaging/notifications/src/lib/notification.processor.tsProcesses queued jobs, sends via channels
TemplateServicemessaging/services/src/template-management/template-management.service.tsResolves Handlebars templates
TeeTimePreferenceProcessormessaging/workflows/src/lib/tee-time-notification.processor.tsReference integration pattern

Current GolfCartService Integration Status

File: libs/facilities/facilities-service/src/lib/golf-cart.service.ts

MethodLinesNotification HookStatus
updateBattery()109-119Low battery + out_of_serviceImplemented
reportIncident()297-324Incident reportedImplemented
assignCart()161-194Cart assigned / return reminderImplemented
scheduleMaintenance()400-422Maintenance scheduled/dueImplemented
markServiced()424-455Service completedImplemented
resolveIncident()326-351Incident resolvedImplemented

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)

TypeTriggerRecipientsChannelsPriority
GOLF_CART_LOW_BATTERYBattery < 20%Facility ManagersEmail, SMSHIGH
GOLF_CART_OUT_OF_SERVICECondition set to OUT_OF_SERVICEFacility ManagersEmail, SMSHIGH
GOLF_CART_NOT_RETURNEDAssignment > 6 hours without returnFacility ManagersEmail, SMSMEDIUM
GOLF_CART_ZONE_ANOMALYCart detected outside allowed zonesFacility ManagersSMS, PushHIGH

Maintenance Alerts (UTILITY scope)

TypeTriggerRecipientsChannelsPriority
GOLF_CART_MAINTENANCE_DUEService date within 7 days or odometer thresholdMaintenance TeamEmailMEDIUM
GOLF_CART_MAINTENANCE_SCHEDULEDMaintenance scheduled via APIMaintenance TeamEmailLOW
GOLF_CART_SERVICE_COMPLETEDMaintenance marked completeFacility ManagersEmailLOW
GOLF_CART_PREDICTIVE_ALERTPredictive maintenance flags cartMaintenance TeamEmailMEDIUM

Incident Alerts (UTILITY scope)

TypeTriggerRecipientsChannelsPriority
GOLF_CART_INCIDENT_REPORTEDNew incident reportedFacility Managers, MaintenanceEmail, SMSHIGH (MAJOR/CRITICAL)
GOLF_CART_INCIDENT_RESOLVEDIncident resolvedFacility ManagersEmailLOW

Player Notifications (UTILITY scope)

TypeTriggerRecipientsChannelsPriority
GOLF_CART_ASSIGNEDCart assigned to bookingPlayer (primary driver)Email, PushLOW
GOLF_CART_RETURN_REMINDER30 min before expected returnPlayerPushLOW
GOLF_CART_RETURN_CONFIRMEDCart returned successfullyPlayerEmailLOW

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 TypeDedup Key PatternTTL
LOW_BATTERYlow-battery-{cartId}-{date}24h
INCIDENT_REPORTEDincident-{incidentId}Forever
NOT_RETURNEDnot-returned-{assignmentId}Until returned
ZONE_ANOMALYzone-anomaly-{cartId}-{zone}-{hour}1h
MAINTENANCE_DUEmaint-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-services and seeded with tools/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

RiskMitigation
Breaking existing notificationsGolf cart types are additive; no modification to existing handlers
Performance impactNotifications are async via BullMQ; no synchronous blocking
Preference conflictsUses existing ContactPreferenceService; consistent behavior
Template failuresProcessor has fallback logic (see notification.processor.ts:141-157)
  1. Phase 1 (Low risk): Add NotificationType enum values
  2. Phase 2 (Low risk): Create GolfCartNotificationService with unit tests
  3. Phase 3 (Medium risk): Integrate into GolfCartService methods
  4. Phase 4 (Low risk): Add scheduled processor for overdue carts
  5. 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