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
| Type | Description | Channels | Status |
|---|---|---|---|
agreement_expiry | Agreement approaching expiration | EMAIL, WHATSAPP | Implemented |
agreement_conflict | Overlapping/conflicting agreements detected | Implemented | |
network_membership_change | Club joined/left reciprocity network | Implemented | |
reciprocity_policy_warning | Configuration issues detected | Implemented |
Eligibility Notifications
| Type | Description | Channels | Status |
|---|---|---|---|
reciprocity_eligibility_gained | Player gained reciprocity access | EMAIL, PUSH | Implemented |
reciprocity_eligibility_lost | Player lost reciprocity access | Implemented |
Audit Notifications
| Type | Description | Channels | Status |
|---|---|---|---|
agreement_bulk_import | Bulk import completed | Implemented | |
agreement_audit_alert | High-risk change detected | Implemented |
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
| Schedule | Method | Description |
|---|---|---|
| Daily at 9 AM (SAST) | handleExpiryCheck() | Checks for agreements expiring at 30, 14, 7, 1 day thresholds |
Configuration
Environment Variables
| Variable | Description | Default |
|---|---|---|
AGREEMENT_EXPIRY_CRON | Cron schedule for expiry check | 0 9 * * * |
AGREEMENT_EXPIRY_CRON_TZ | Timezone for cron | Africa/Johannesburg |
AGREEMENT_EXPIRY_THRESHOLDS | Days before expiry to alert | 30,14,7,1 |
AGREEMENT_EXPIRY_ADMIN_EMAIL | Recipient email address (also used for conflicts) | (required) |
AGREEMENT_EXPIRY_DASHBOARD_URL | Base URL for agreement links | (optional) |
AGREEMENT_CONFLICT_ADMIN_EMAIL | Recipient for conflict alerts (falls back to EXPIRY_ADMIN_EMAIL) | (optional) |
NETWORK_MEMBERSHIP_ADMIN_EMAIL | Recipient for membership change alerts (falls back to EXPIRY_ADMIN_EMAIL) | (optional) |
POLICY_WARNING_ADMIN_EMAIL | Recipient for policy warning alerts (falls back to EXPIRY_ADMIN_EMAIL) | (optional) |
BULK_IMPORT_ADMIN_EMAIL | Recipient for bulk import reports (falls back to EXPIRY_ADMIN_EMAIL) | (optional) |
AUDIT_ALERT_ADMIN_EMAIL | Recipient 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
| Feature | Description |
|---|---|
| Expiry Warning Tags | Color-coded tags (red/orange/gold) based on days until expiry |
| Send Expiry Reminder | Row action to manually trigger notification |
Expiry Warning Colors
| Days Until Expiry | Color | Icon |
|---|---|---|
| Expired (< 0) | Red | WarningOutlined |
| Today (0) | Red | WarningOutlined |
| 1-7 days | Red | WarningOutlined |
| 8-14 days | Orange | WarningOutlined |
| 15-30 days | Gold | CalendarOutlined |
Trigger Points
Automatic (Cron)
| Schedule | Trigger | Notification |
|---|---|---|
| Daily 9 AM | handleExpiryCheck() | agreement_expiry for exact threshold matches |
Manual (API)
| Endpoint | Trigger | Notification |
|---|---|---|
POST .../send-expiry-notice | sendManualExpiryNotification() | agreement_expiry |
POST .../check-conflicts | detectConflicts() + notifyConflicts() | agreement_conflict |
Automatic (On Create/Update)
| Operation | Trigger | Notification |
|---|---|---|
POST /agreements | detectConflicts() + notifyConflicts() | agreement_conflict |
PUT /agreements/:id | detectConflicts() + notifyConflicts() | agreement_conflict |
PUT /networks/memberships | notifyMembershipChange() | network_membership_change |
DELETE /networks/:code/clubs/:id | notifyMembershipChange() | network_membership_change |
UI Actions
| Action | Trigger | Notification |
|---|---|---|
| Row menu → "Send expiry reminder" | useSendExpiryNotification() | agreement_expiry |
Deduplication
| Notification Type | Dedup Key Pattern | TTL |
|---|---|---|
agreement_expiry | expiry-{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
Related Documentation
- Benefits Overview – Feature summary
- Reciprocity – Reciprocity agreement system
- Benefits Admin – Admin UI documentation
- Messaging Specification – Full messaging specification