Skip to main content

Benefits & Reciprocity Notifications

The benefits and reciprocity system integrates with the messaging service to provide automated notifications for agreement lifecycle management, eligibility changes, and administrative alerts.

Architecture

┌─────────────────────────────┐     ┌─────────────────────────────┐
│ ReciprocityAgreement │────▶│ AgreementExpiryScheduler │
│ Repository │ │ Service │
└─────────────────────────────┘ └───────────┬─────────────────┘


┌─────────────────────────────┐ ┌─────────────────────────────┐
│ AdminReciprocityController │────▶│ NotificationService │
│ (Manual Triggers) │ │ (Handler Dispatch) │
└─────────────────────────────┘ └───────────┬─────────────────┘


┌─────────────────────────────┐
│ AgreementExpiryHandler │
│ (Template + Direct Mode) │
└───────────┬─────────────────┘


┌─────────────────────────────┐
│ NotificationEmitter │
│ (BullMQ Jobs) │
└─────────────────────────────┘

Notification Types

Administrative Alerts

TypeDescriptionChannelsStatus
agreement_expiryAgreement approaching expirationEMAIL, WHATSAPPImplemented
agreement_conflictOverlapping/conflicting agreements detectedEMAILImplemented
network_membership_changeClub joined/left reciprocity networkEMAILImplemented
reciprocity_policy_warningConfiguration issues detectedEMAILImplemented

Eligibility Notifications

TypeDescriptionChannelsStatus
reciprocity_eligibility_gainedPlayer gained reciprocity accessEMAIL, PUSHImplemented
reciprocity_eligibility_lostPlayer lost reciprocity accessEMAILImplemented

Audit Notifications

TypeDescriptionChannelsStatus
agreement_bulk_importBulk import completedEMAILImplemented
agreement_audit_alertHigh-risk change detectedEMAILImplemented

Service Integration

AgreementExpirySchedulerService

Located at: libs/tee-time-services/src/lib/benefits/agreement-expiry-scheduler.service.ts

@Injectable()
export class AgreementExpirySchedulerService {
constructor(
private readonly agreementRepo: ReciprocityAgreementRepository,
@Optional() @Inject(NotificationService) private readonly notificationService?: NotificationService,
) {}

@Cron(EXPIRY_CRON, { timeZone: EXPIRY_CRON_TZ })
async handleExpiryCheck(): Promise<void>

async sendManualExpiryNotification(
agreementId: string,
recipientEmail?: string
): Promise<{ sent: boolean }>
}

AgreementExpiryHandler

Located at: messaging/notifications/src/lib/handlers/AgreementExpiryHandler.ts

@Injectable()
export class AgreementExpiryHandler implements INotificationHandler {
public readonly type = NotificationType.AGREEMENT_EXPIRY;

async handleNotification(payload: NotificationPayload): Promise<void>

// Template mode: Uses NotificationEmitter for BullMQ jobs
private async handleTemplateMode(payload, variables): Promise<void>

// Direct mode: Sends email/SMS directly
private async handleDirectMode(payload, variables): Promise<void>
}

ReciprocityConflictService

Located at: libs/tee-time-services/src/lib/benefits/reciprocity-conflict.service.ts

@Injectable()
export class ReciprocityConflictService {
constructor(
private readonly repo: ReciprocityAgreementRepository,
@Optional() @Inject(NotificationService) private readonly notifications?: NotificationService,
) {}

// Detect conflicting agreements (called on create/update)
async detectConflicts(candidate: ConflictCandidate): Promise<AgreementConflictWarning[]>

// Send conflict notification to admin
async notifyConflicts(
conflicts: AgreementConflictWarning[],
newAgreementId: string,
opts?: { parties?: string; resolutionUrl?: string; actorEmail?: string }
): Promise<void>
}

AgreementConflictHandler

Located at: messaging/notifications/src/lib/handlers/AgreementConflictHandler.ts

@Injectable()
export class AgreementConflictHandler implements INotificationHandler {
public readonly type = NotificationType.AGREEMENT_CONFLICT;

async handleNotification(payload: NotificationPayload): Promise<void>

// Template mode: Uses NotificationEmitter for BullMQ jobs
private async handleTemplateMode(payload, variables): Promise<void>

// Direct mode: Sends email directly
private async handleDirectMode(payload, variables): Promise<void>
}

ReciprocityMembershipNotifierService

Located at: libs/tee-time-services/src/lib/benefits/reciprocity-membership-notifier.service.ts

@Injectable()
export class ReciprocityMembershipNotifierService {
constructor(
private readonly repo: ReciprocityAgreementRepository,
@Optional() @Inject(NotificationService) private readonly notifications?: NotificationService,
) {}

// Send notification when membership changes (JOINED, LEFT, SUSPENDED)
async notifyMembershipChange(input: {
networkCode: string;
clubId: string;
changeType: 'JOINED' | 'LEFT' | 'SUSPENDED';
effectiveDate: string;
actorEmail?: string;
}): Promise<void>
}

NetworkMembershipChangeHandler

Located at: messaging/notifications/src/lib/handlers/NetworkMembershipChangeHandler.ts

@Injectable()
export class NetworkMembershipChangeHandler implements INotificationHandler {
public readonly type = NotificationType.NETWORK_MEMBERSHIP_CHANGE;

async handleNotification(payload: NotificationPayload): Promise<void>

// Template mode: Uses NotificationEmitter for BullMQ jobs
private async handleTemplateMode(payload, variables): Promise<void>

// Direct mode: Sends email directly
private async handleDirectMode(payload, variables): Promise<void>
}

ReciprocityPolicyWarningHandler

Located at: messaging/notifications/src/lib/handlers/ReciprocityPolicyWarningHandler.ts

@Injectable()
export class ReciprocityPolicyWarningHandler implements INotificationHandler {
public readonly type = NotificationType.RECIPROCITY_POLICY_WARNING;

async handleNotification(payload: NotificationPayload): Promise<void>

// Template mode: Uses NotificationEmitter for BullMQ jobs
private async handleTemplateMode(payload, variables): Promise<void>

// Direct mode: Sends email directly
private async handleDirectMode(payload, variables): Promise<void>
}

ReciprocityEligibilityGainedHandler

Located at: messaging/notifications/src/lib/handlers/ReciprocityEligibilityGainedHandler.ts

@Injectable()
export class ReciprocityEligibilityGainedHandler implements INotificationHandler {
public readonly type = NotificationType.RECIPROCITY_ELIGIBILITY_GAINED;

async handleNotification(payload: NotificationPayload): Promise<void>

// Template mode: Uses NotificationEmitter for BullMQ jobs
private async handleTemplateMode(payload, variables): Promise<void>

// Direct mode: Sends email + optional push notification
private async handleDirectMode(payload, variables): Promise<void>
}

ReciprocityEligibilityLostHandler

Located at: messaging/notifications/src/lib/handlers/ReciprocityEligibilityLostHandler.ts

@Injectable()
export class ReciprocityEligibilityLostHandler implements INotificationHandler {
public readonly type = NotificationType.RECIPROCITY_ELIGIBILITY_LOST;

async handleNotification(payload: NotificationPayload): Promise<void>

// Template mode: Uses NotificationEmitter for BullMQ jobs
private async handleTemplateMode(payload, variables): Promise<void>

// Direct mode: Sends email directly
private async handleDirectMode(payload, variables): Promise<void>
}

AgreementBulkImportHandler

Located at: messaging/notifications/src/lib/handlers/AgreementBulkImportHandler.ts

@Injectable()
export class AgreementBulkImportHandler implements INotificationHandler {
public readonly type = NotificationType.AGREEMENT_BULK_IMPORT;

async handleNotification(payload: NotificationPayload): Promise<void>

// Template mode: Uses NotificationEmitter for BullMQ jobs
private async handleTemplateMode(payload, variables): Promise<void>

// Direct mode: Sends email directly
private async handleDirectMode(payload, variables): Promise<void>
}

AgreementAuditAlertHandler

Located at: messaging/notifications/src/lib/handlers/AgreementAuditAlertHandler.ts

@Injectable()
export class AgreementAuditAlertHandler implements INotificationHandler {
public readonly type = NotificationType.AGREEMENT_AUDIT_ALERT;

async handleNotification(payload: NotificationPayload): Promise<void>

// Template mode: Uses NotificationEmitter for BullMQ jobs
private async handleTemplateMode(payload, variables): Promise<void>

// Direct mode: Sends email directly
private async handleDirectMode(payload, variables): Promise<void>
}

Scheduled Jobs

ScheduleMethodDescription
Daily at 9 AM (SAST)handleExpiryCheck()Checks for agreements expiring at 30, 14, 7, 1 day thresholds

Configuration

Environment Variables

VariableDescriptionDefault
AGREEMENT_EXPIRY_CRONCron schedule for expiry check0 9 * * *
AGREEMENT_EXPIRY_CRON_TZTimezone for cronAfrica/Johannesburg
AGREEMENT_EXPIRY_THRESHOLDSDays before expiry to alert30,14,7,1
AGREEMENT_EXPIRY_ADMIN_EMAILRecipient email address (also used for conflicts)(required)
AGREEMENT_EXPIRY_DASHBOARD_URLBase URL for agreement links(optional)
AGREEMENT_CONFLICT_ADMIN_EMAILRecipient for conflict alerts (falls back to EXPIRY_ADMIN_EMAIL)(optional)
NETWORK_MEMBERSHIP_ADMIN_EMAILRecipient for membership change alerts (falls back to EXPIRY_ADMIN_EMAIL)(optional)
POLICY_WARNING_ADMIN_EMAILRecipient for policy warning alerts (falls back to EXPIRY_ADMIN_EMAIL)(optional)
BULK_IMPORT_ADMIN_EMAILRecipient for bulk import reports (falls back to EXPIRY_ADMIN_EMAIL)(optional)
AUDIT_ALERT_ADMIN_EMAILRecipient for audit alerts (falls back to EXPIRY_ADMIN_EMAIL)(optional)

Example Configuration

AGREEMENT_EXPIRY_CRON="0 9 * * *"
AGREEMENT_EXPIRY_CRON_TZ="Africa/Johannesburg"
AGREEMENT_EXPIRY_THRESHOLDS="30,14,7,1"
AGREEMENT_EXPIRY_ADMIN_EMAIL="benefits-admin@example.com"
AGREEMENT_EXPIRY_DASHBOARD_URL="https://admin.example.com/benefits"

API Endpoints

Expiring Agreements

GET /admin/reciprocity/agreements/expiring?withinDays=30

Returns agreements expiring within the specified number of days.

Send Manual Expiry Notice

POST /admin/reciprocity/agreements/:id/send-expiry-notice
Content-Type: application/json

{
"recipientEmail": "admin@example.com" // Optional, uses env default
}

Response:

{
"sent": true
}

Check Agreement Conflicts

POST /admin/reciprocity/agreements/:id/check-conflicts
Content-Type: application/json

{
"notify": true, // Optional: send email if conflicts found
"recipientEmail": "admin@example.com" // Optional: override default recipient
}

Response:

{
"conflicts": [
{
"existingAgreementId": "abc123",
"conflictType": "OVERLAP",
"parties": "Club A ↔ Club B",
"conflictDetails": "Overlaps 2025-01-01 - 2025-12-31 (priority 100)"
}
],
"notified": true
}

Notification Payload

AGREEMENT_EXPIRY Variables

interface AgreementExpiryVariables {
agreementId?: string;
networkCode?: string;
clubAId?: string;
clubBId?: string;
agreementName?: string; // "SAGA_NETWORK" or "Club A ↔ Club B"
endDate?: string; // ISO date
daysUntilExpiry?: number;
discountSummary?: string; // "15% discount" or "Fixed rate R250"
dashboardUrl?: string; // Link to view agreement
}

AGREEMENT_CONFLICT Variables

interface AgreementConflictVariables {
newAgreementId?: string; // ID of the newly created/updated agreement
existingAgreementId?: string; // ID of the conflicting agreement
conflictType?: 'OVERLAP' | 'DUPLICATE' | 'PRIORITY';
parties?: string; // "SAGA_NETWORK" or "Club A ↔ Club B"
conflictDetails?: string; // Human-readable conflict description
resolutionUrl?: string; // Link to resolve conflict in admin UI
}

NETWORK_MEMBERSHIP_CHANGE Variables

interface NetworkMembershipChangeVariables {
networkCode?: string; // e.g., "SAGA_NETWORK"
clubId?: string; // Club identifier
clubName?: string; // Human-readable club name
changeType?: 'JOINED' | 'LEFT' | 'SUSPENDED';
effectiveDate?: string; // ISO date when change takes effect
dashboardUrl?: string; // Link to network management in admin UI
}

RECIPROCITY_POLICY_WARNING Variables

interface ReciprocityPolicyWarningVariables {
warningType?: 'MISSING_RATE' | 'INVALID_DATES' | 'ORPHAN_MEMBERSHIP' | 'CONFIG_ERROR';
entityType?: 'AGREEMENT' | 'NETWORK' | 'MEMBERSHIP';
entityId?: string;
entityName?: string;
details?: string; // Human-readable description of the issue
suggestedAction?: string; // Recommended resolution steps
dashboardUrl?: string; // Link to fix the issue in admin UI
}

RECIPROCITY_ELIGIBILITY_GAINED Variables

interface ReciprocityEligibilityGainedVariables {
playerId?: string;
playerName?: string;
playerEmail?: string;
homeClubId?: string;
homeClubName?: string;
partnerClubId?: string;
partnerClubName?: string;
networkCode?: string;
networkName?: string;
benefitSummary?: string; // e.g., "15% discount on green fees"
effectiveDate?: string; // ISO date when access begins
expiryDate?: string; // ISO date when access expires
bookingUrl?: string; // Link to book a tee time
}

RECIPROCITY_ELIGIBILITY_LOST Variables

interface ReciprocityEligibilityLostVariables {
playerId?: string;
playerName?: string;
playerEmail?: string;
homeClubId?: string;
homeClubName?: string;
partnerClubId?: string;
partnerClubName?: string;
networkCode?: string;
networkName?: string;
reason?: 'AGREEMENT_EXPIRED' | 'MEMBERSHIP_SUSPENDED' | 'NETWORK_LEFT' | 'POLICY_CHANGE';
effectiveDate?: string; // ISO date when access is lost
contactEmail?: string; // Contact for questions
}

AGREEMENT_BULK_IMPORT Variables

interface AgreementBulkImportVariables {
importId?: string;
fileName?: string;
totalRows?: number;
successCount?: number;
errorCount?: number;
skippedCount?: number;
errors?: Array<{ row: number; message: string }>; // First 10 errors
importedBy?: string; // Email of user who initiated import
completedAt?: string; // ISO timestamp
duration?: string; // e.g., "2m 34s"
reportUrl?: string; // Link to full import report
}

AGREEMENT_AUDIT_ALERT Variables

interface AgreementAuditAlertVariables {
alertType?: 'HIGH_VALUE_CHANGE' | 'BULK_DELETE' | 'RATE_CHANGE' | 'NETWORK_REMOVAL' | 'SUSPICIOUS_ACTIVITY';
severity?: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
entityType?: 'AGREEMENT' | 'NETWORK' | 'MEMBERSHIP' | 'RATE_CONFIG';
entityId?: string;
entityName?: string;
action?: 'CREATE' | 'UPDATE' | 'DELETE';
actorId?: string;
actorEmail?: string;
actorName?: string;
previousValue?: string; // JSON string of previous state
newValue?: string; // JSON string of new state
changedFields?: string[]; // List of modified field names
timestamp?: string; // ISO timestamp
ipAddress?: string; // Actor's IP address
auditUrl?: string; // Link to audit log
}

Email Templates

AGREEMENT_EXPIRY (Direct Mode)

Subject: Reciprocity agreement expiring soon

<h3>Agreement expiry reminder</h3>
<p>The agreement {{agreementName}} is expiring on {{endDate}}.</p>
<p>{{daysUntilExpiry}} day(s) remaining.</p>
<p>Network: {{networkCode}}</p>
<p>Clubs: {{clubAId}} ↔ {{clubBId}}</p>

SMS Template

Agreement {{agreementName}} expiring {{daysUntilExpiry}} day(s).

AGREEMENT_CONFLICT (Direct Mode)

Subject: Reciprocity agreement conflict ({{conflictType}})

<p>A reciprocity agreement conflicts with an existing policy.</p>
<p><strong>Parties:</strong> {{parties}}</p>
<p><strong>Conflict type:</strong> {{conflictType}}</p>
<p><strong>Details:</strong> {{conflictDetails}}</p>
<p><strong>Existing agreement:</strong> {{existingAgreementId}}</p>
<p><strong>New agreement:</strong> {{newAgreementId}}</p>
<p><a href="{{resolutionUrl}}">Open in admin</a></p>

NETWORK_MEMBERSHIP_CHANGE (Direct Mode)

Subject: Network membership {{changeType}}: {{clubName}} – {{networkCode}}

<h3>Network Membership Change</h3>
<p>A club's reciprocity network membership has changed.</p>
<p><strong>Network:</strong> {{networkCode}}</p>
<p><strong>Club:</strong> {{clubName}} ({{clubId}})</p>
<p><strong>Change type:</strong> {{changeType}}</p>
<p><strong>Effective date:</strong> {{effectiveDate}}</p>
<p><a href="{{dashboardUrl}}">View in admin</a></p>

RECIPROCITY_POLICY_WARNING (Direct Mode)

Subject: Reciprocity policy warning: {{warningType}}

<h3>Reciprocity Policy Warning</h3>
<p>A configuration issue was detected in the reciprocity system.</p>
<p><strong>Warning type:</strong> {{warningType}}</p>
<p><strong>Entity type:</strong> {{entityType}}</p>
<p><strong>Entity ID:</strong> {{entityId}}</p>
<p><strong>Entity name:</strong> {{entityName}}</p>
<p><strong>Details:</strong> {{details}}</p>
<p><strong>Suggested action:</strong> {{suggestedAction}}</p>
<p><a href="{{dashboardUrl}}">Open in admin</a></p>

RECIPROCITY_ELIGIBILITY_GAINED (Direct Mode)

Subject: You now have reciprocity access at {{partnerClubName}}

<h3>Reciprocity Access Granted</h3>
<p>Hi {{playerName}},</p>
<p>Great news! You now have reciprocity access to play at partner clubs.</p>
<p><strong>Network:</strong> {{networkName}}</p>
<p><strong>Partner club:</strong> {{partnerClubName}}</p>
<p><strong>Benefits:</strong> {{benefitSummary}}</p>
<p><strong>Valid from:</strong> {{effectiveDate}}</p>
<p><strong>Valid until:</strong> {{expiryDate}}</p>
<p><a href="{{bookingUrl}}">Book a tee time</a></p>

RECIPROCITY_ELIGIBILITY_LOST (Direct Mode)

Subject: Reciprocity access change: {{partnerClubName}}

<h3>Reciprocity Access Update</h3>
<p>Hi {{playerName}},</p>
<p>Your reciprocity access to partner clubs has changed.</p>
<p><strong>Network:</strong> {{networkName}}</p>
<p><strong>Partner club:</strong> {{partnerClubName}}</p>
<p><strong>Reason:</strong> {{reason}}</p>
<p><strong>Effective date:</strong> {{effectiveDate}}</p>
<p>If you have questions, please contact <a href="mailto:{{contactEmail}}">{{contactEmail}}</a>.</p>

AGREEMENT_BULK_IMPORT (Direct Mode)

Subject: Agreement bulk import completed

<h3>Bulk Import Completed</h3>
<p><strong>File:</strong> {{fileName}}</p>
<p><strong>Summary:</strong></p>
<ul>
<li>Total rows: {{totalRows}}</li>
<li>Successfully imported: {{successCount}}</li>
<li>Errors: {{errorCount}}</li>
<li>Skipped: {{skippedCount}}</li>
</ul>
<p><strong>Duration:</strong> {{duration}}</p>
<p><strong>Imported by:</strong> {{importedBy}}</p>
<p><strong>Completed at:</strong> {{completedAt}}</p>
<p><a href="{{reportUrl}}">View full report</a></p>

AGREEMENT_AUDIT_ALERT (Direct Mode)

Subject: [{{severity}}] Reciprocity audit alert: {{alertType}}

<h3>Audit Alert: {{alertType}}</h3>
<p>A high-risk change has been detected in the reciprocity system.</p>
<p><strong>Severity:</strong> {{severity}}</p>
<p><strong>Action:</strong> {{action}}</p>
<p><strong>Entity type:</strong> {{entityType}}</p>
<p><strong>Entity ID:</strong> {{entityId}}</p>
<p><strong>Entity name:</strong> {{entityName}}</p>
<hr>
<p><strong>Actor details:</strong></p>
<p>Name: {{actorName}}</p>
<p>Email: {{actorEmail}}</p>
<p>IP Address: {{ipAddress}}</p>
<p>Timestamp: {{timestamp}}</p>
<p><strong>Changed fields:</strong> {{changedFields}}</p>
<p><a href="{{auditUrl}}">View audit log</a></p>

UI Integration

useExpiringAgreements Hook

Located at: libs/ui/benefits-admin/src/hooks/useAgreements.ts

export function useExpiringAgreements(
config: BenefitsAdminConfig,
withinDays = 30
): {
agreements: ReciprocityAgreement[];
loading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}

useSendExpiryNotification Hook

export function useSendExpiryNotification(
config: BenefitsAdminConfig
): {
sendNotification: (id: string, recipientEmail?: string) => Promise<{ sent: boolean }>;
loading: boolean;
error: Error | null;
}

AgreementsTable Expiry Features

Located at: libs/ui/benefits-admin/src/components/AgreementsTable/AgreementsTable.tsx

FeatureDescription
Expiry Warning TagsColor-coded tags (red/orange/gold) based on days until expiry
Send Expiry ReminderRow action to manually trigger notification

Expiry Warning Colors

Days Until ExpiryColorIcon
Expired (< 0)RedWarningOutlined
Today (0)RedWarningOutlined
1-7 daysRedWarningOutlined
8-14 daysOrangeWarningOutlined
15-30 daysGoldCalendarOutlined

Trigger Points

Automatic (Cron)

ScheduleTriggerNotification
Daily 9 AMhandleExpiryCheck()agreement_expiry for exact threshold matches

Manual (API)

EndpointTriggerNotification
POST .../send-expiry-noticesendManualExpiryNotification()agreement_expiry
POST .../check-conflictsdetectConflicts() + notifyConflicts()agreement_conflict

Automatic (On Create/Update)

OperationTriggerNotification
POST /agreementsdetectConflicts() + notifyConflicts()agreement_conflict
PUT /agreements/:iddetectConflicts() + notifyConflicts()agreement_conflict
PUT /networks/membershipsnotifyMembershipChange()network_membership_change
DELETE /networks/:code/clubs/:idnotifyMembershipChange()network_membership_change

UI Actions

ActionTriggerNotification
Row menu → "Send expiry reminder"useSendExpiryNotification()agreement_expiry

Deduplication

Notification TypeDedup Key PatternTTL
agreement_expiryexpiry-{agreementId}-{days}24h

Notifications are only sent once per agreement per threshold day (30, 14, 7, 1).

Graceful Degradation

If NotificationService is not configured, expiry checks are skipped:

if (!this.notificationService) {
this.logger.debug('NotificationService not available; skipping expiry check');
return;
}

If AGREEMENT_EXPIRY_ADMIN_EMAIL is not set, notifications are skipped with a warning:

if (!EXPIRY_ADMIN_EMAIL) {
this.logger.warn('AGREEMENT_EXPIRY_ADMIN_EMAIL not configured; skipping notifications');
return;
}

Testing

Unit Tests

pnpm nx run messaging-notifications:test --testPathPattern=notification.processor

E2E Tests

pnpm nx run teetime-admin-e2e:e2e -- --project=chromium