Skip to main content

Benefits & Reciprocity Messaging Specification

Overview

Integrate the Benefits & Reciprocity system with the Messaging Service to provide automated notifications for agreement lifecycle management, eligibility changes, and administrative alerts.

Goals

  1. Alert administrators when reciprocity agreements are approaching expiration
  2. Notify clubs of membership status changes in reciprocity networks
  3. Inform administrators of policy conflicts and configuration issues
  4. Support bulk operation confirmations and audit notifications
  5. Enable multi-channel delivery (Email, WhatsApp, SMS, Push)

Existing Architecture Assessment

Key Components

ComponentLocationPurposeStatus
NotificationType enummessaging/notifications/src/lib/types/NotificationTypes.tsDefines notification type keysIMPLEMENTED - AGREEMENT_EXPIRY, AGREEMENT_CONFLICT added
NotificationServicemessaging/notifications/src/lib/notification.service.tsOrchestrates dispatch to handlersEXPORTED
NotificationEmittermessaging/notifications/src/lib/notification.emitter.tsEnqueues jobs to BullMQREUSE
AgreementExpiryHandlermessaging/notifications/src/lib/handlers/AgreementExpiryHandler.tsHandles expiry notificationsIMPLEMENTED
AgreementExpirySchedulerServicelibs/tee-time-services/src/lib/benefits/agreement-expiry-scheduler.service.tsScheduled cron for expiry checksIMPLEMENTED
ContactPreferenceServicemessaging/core/src/lib/contact-preference.service.tsChecks user consentREUSE
TemplateServicemessaging/services/src/template-management/template-management.service.tsResolves Handlebars templatesREUSE

Current Implementation Status

Implemented:

ComponentLocationStatus
AGREEMENT_EXPIRY typeNotificationTypes.ts:30COMPLETE
AgreementExpiryHandlerhandlers/AgreementExpiryHandler.tsCOMPLETE
AgreementExpirySchedulerServicebenefits/agreement-expiry-scheduler.service.tsCOMPLETE
API: GET /admin/reciprocity/agreements/expiringadmin-reciprocity.controller.ts:215-220COMPLETE
API: POST /admin/reciprocity/agreements/:id/send-expiry-noticeadmin-reciprocity.controller.ts:222-241COMPLETE
UI: Expiry warning tagsAgreementsTable.tsx:286-323COMPLETE
UI: Send reminder actionAgreementsTable.tsx:347-356COMPLETE
Hooks: useSendExpiryNotificationuseAgreements.ts:256-293COMPLETE
AGREEMENT_CONFLICT typeNotificationTypes.tsCOMPLETE
AgreementConflictHandlerhandlers/AgreementConflictHandler.tsCOMPLETE
Conflict detection + notificationsreciprocity-conflict.service.ts + admin-reciprocity.controller.tsCOMPLETE
UI conflict warningAgreementFormDrawer.tsxCOMPLETE

Not Yet Implemented:

FeaturePriorityDescription
Membership change notificationsHIGHNotify when clubs join/leave networks
Eligibility change alertsMEDIUMNotify when player eligibility changes
Bulk operation confirmationsLOWEmail summary of bulk import/export
Audit trail notificationsLOWAlert on sensitive changes

Notification Types

Administrative Alerts (SYSTEM scope)

TypeTriggerRecipientsChannelsPriorityStatus
AGREEMENT_EXPIRYCron job or manualConfigured admin emailEmail, WhatsAppHIGH (7d), MEDIUM (30d)IMPLEMENTED
AGREEMENT_CONFLICTCreate/update validationAdmin creating agreementEmailMEDIUMIMPLEMENTED
NETWORK_MEMBERSHIP_CHANGEMembership add/removeNetwork adminsEmailLOWIMPLEMENTED
RECIPROCITY_POLICY_WARNINGDiagnostics checkFacility managersEmailMEDIUMIMPLEMENTED

Eligibility Notifications (UTILITY scope)

TypeTriggerRecipientsChannelsPriorityStatus
RECIPROCITY_ELIGIBILITY_GAINEDAgreement activationPlayer (via club)Email, PushLOWIMPLEMENTED
RECIPROCITY_ELIGIBILITY_LOSTAgreement deactivation/expiryPlayer (via club)EmailMEDIUMIMPLEMENTED

Audit Notifications (SYSTEM scope)

TypeTriggerRecipientsChannelsPriorityStatus
AGREEMENT_BULK_IMPORTCSV import completedImporting adminEmailLOWIMPLEMENTED
AGREEMENT_AUDIT_ALERTHigh-risk change detectedSuper adminsEmailHIGHIMPLEMENTED

Data Model

NotificationType Enum (Current)

// messaging/notifications/src/lib/types/NotificationTypes.ts
export enum NotificationType {
// ... existing types ...

// Benefits / TeeTime (IMPLEMENTED)
AGREEMENT_EXPIRY = 'agreement_expiry',
AGREEMENT_CONFLICT = 'agreement_conflict',
}

NotificationType Enum (Proposed Additions)

export enum NotificationType {
// ... existing types ...

// Benefits - Administrative
AGREEMENT_EXPIRY = 'agreement_expiry', // IMPLEMENTED
AGREEMENT_CONFLICT = 'agreement_conflict', // IMPLEMENTED
NETWORK_MEMBERSHIP_CHANGE = 'network_membership_change',
RECIPROCITY_POLICY_WARNING = 'reciprocity_policy_warning',

// Benefits - Eligibility
RECIPROCITY_ELIGIBILITY_GAINED = 'reciprocity_eligibility_gained',
RECIPROCITY_ELIGIBILITY_LOST = 'reciprocity_eligibility_lost',

// Benefits - Audit
AGREEMENT_BULK_IMPORT = 'agreement_bulk_import',
AGREEMENT_AUDIT_ALERT = 'agreement_audit_alert',
}

Notification Payload Structures

AGREEMENT_EXPIRY (Implemented)

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
[key: string]: unknown;
}

AGREEMENT_CONFLICT (Planned)

interface AgreementConflictVariables {
newAgreementId: string;
existingAgreementId: string;
conflictType: 'OVERLAP' | 'DUPLICATE' | 'PRIORITY';
parties: string; // Network code or club names
conflictDetails: string; // Human-readable explanation
resolutionUrl?: string;
}

NETWORK_MEMBERSHIP_CHANGE (Planned)

interface NetworkMembershipChangeVariables {
networkCode: string;
networkName: string;
clubId: string;
clubName: string;
changeType: 'JOINED' | 'LEFT' | 'SUSPENDED';
effectiveDate: string;
changedBy?: string;
}

Service Architecture

AgreementExpiryHandler (Implemented)

Location: messaging/notifications/src/lib/handlers/AgreementExpiryHandler.ts

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

constructor(
@Inject(EMAIL_SENDER) private readonly emailService: EmailSender,
@Inject(SMS_SENDER) private readonly sms: SmsSender,
private readonly emitter: NotificationEmitter,
) {}

async handleNotification(payload: NotificationPayload): Promise<void> {
const vars = payload.variables as AgreementExpiryVariables;
if (isTemplateRegistryEnabled()) {
return this.handleTemplateMode(payload, vars);
}
return this.handleDirectMode(payload, vars);
}

// Template mode: Enqueue via NotificationEmitter
// Direct mode: Build HTML/SMS and send directly
}

AgreementConflictHandler (Implemented)

Location: messaging/notifications/src/lib/handlers/AgreementConflictHandler.ts

@Injectable()
export class AgreementConflictHandler implements INotificationHandler {
public readonly type = NotificationType.AGREEMENT_CONFLICT;
// Template mode: enqueue EMAIL job with conflict variables
// Direct mode: send email with details + resolution link
}

Environment:

VariableDefaultDescription
AGREEMENT_CONFLICT_ADMIN_EMAIL(fallback to AGREEMENT_EXPIRY_ADMIN_EMAIL)Recipient for conflict alerts

Variables:

interface AgreementConflictVariables {
newAgreementId?: string;
existingAgreementId?: string;
conflictType?: 'OVERLAP' | 'DUPLICATE' | 'PRIORITY';
parties?: string;
conflictDetails?: string;
resolutionUrl?: string;
}

AgreementExpirySchedulerService (Implemented)

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

@Injectable()
export class AgreementExpirySchedulerService {
@Cron(EXPIRY_CRON, { timeZone: EXPIRY_CRON_TZ })
async handleExpiryCheck(): Promise<void> {
for (const days of EXPIRY_DAYS_THRESHOLDS) {
await this.checkExpiringAgreements(days);
}
}

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

Configuration:

Environment VariableDefaultDescription
AGREEMENT_EXPIRY_CRON0 9 * * *Cron schedule (daily at 9 AM)
AGREEMENT_EXPIRY_CRON_TZAfrica/JohannesburgTimezone for cron
AGREEMENT_EXPIRY_THRESHOLDS30,14,7,1Days before expiry to alert
AGREEMENT_EXPIRY_ADMIN_EMAIL(required)Recipient email address
AGREEMENT_EXPIRY_DASHBOARD_URL(optional)Base URL for agreement links

ReciprocityAgreementRepository Extension (Implemented)

Location: libs/tee-time-services/src/lib/benefits/repositories/reciprocity-agreement.repository.ts

async findExpiringAgreements(input: { withinDays: number }): Promise<AgreementWithConfig[]> {
const now = new Date();
const cutoff = new Date(now);
cutoff.setDate(cutoff.getDate() + input.withinDays);

return this.prisma.reciprocityAgreement.findMany({
where: {
isActive: true,
endDate: {
gte: now, // Not yet expired
lte: cutoff, // Expires within withinDays
},
},
include: { rateConfig: true, clubA: true, clubB: true },
orderBy: [{ endDate: 'asc' }],
});
}

API Endpoints

Expiry Notifications

MethodPathDescriptionStatus
GET/admin/reciprocity/agreements/expiring?withinDays=30List expiring agreementsIMPLEMENTED
POST/admin/reciprocity/agreements/:id/send-expiry-noticeSend manual reminderIMPLEMENTED

Request Body (POST):

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

Response:

{
"sent": true
}

UI Integration

AgreementsTable Enhancements (Implemented)

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

New Props

interface AgreementsTableProps {
// ... existing props ...
onSendExpiryNotice?: (agreement: ReciprocityAgreement) => void;
expiryWarningDays?: number; // Default: 30
}

Expiry Warning Tags

Displays color-coded tags in the Valid column:

Days Until ExpiryColorIconLabel
Expired (< 0)RedWarningOutlined"Expired"
Today (0)RedWarningOutlined"Expires today"
1-7 daysRedWarningOutlined"Expires in Xd"
8-14 daysOrangeWarningOutlined"Expires in Xd"
15-30 daysGoldCalendarOutlined"Expires in Xd"

Row Actions Menu

When agreement is expiring within expiryWarningDays:

  • Shows "Send expiry reminder" action with BellOutlined icon
  • Calls onSendExpiryNotice(agreement) when clicked

Hooks (Implemented)

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

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

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

// Calculate days until expiry
export function getDaysUntilExpiry(endDate: string | null | undefined): number | null;

Message Templates

Email Templates

AGREEMENT_EXPIRY (Direct Mode - Implemented)

Subject: [URGENT?] Reciprocity Agreement Expiring - {parties}

<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #1f2937;">Reciprocity Agreement Expiring</h2>

<div style="background: #f3f4f6; padding: 16px; border-radius: 8px; margin: 16px 0;">
<p style="margin: 0 0 8px 0;"><strong>Agreement:</strong> {{agreementName}}</p>
<p style="margin: 0 0 8px 0;"><strong>Type:</strong> {{agreementType}}</p>
<p style="margin: 0 0 8px 0;"><strong>Discount:</strong> {{discountSummary}}</p>
<p style="margin: 0;"><strong>End Date:</strong> {{endDate}}</p>
</div>

<p style="{{urgencyStyle}} font-weight: 600; font-size: 18px;">
This agreement expires {{expiryText}}.
</p>

<p>Please review this agreement and take one of the following actions:</p>
<ul>
<li><strong>Renew</strong> – Extend the end date to continue the discount</li>
<li><strong>Deactivate</strong> – Mark as inactive if no longer needed</li>
<li><strong>No action</strong> – Agreement becomes ineffective after end date</li>
</ul>

{{#if dashboardUrl}}
<p><a href="{{dashboardUrl}}" style="display: inline-block; background: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;">View Agreement</a></p>
{{/if}}

<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
<p style="color: #6b7280; font-size: 12px;">
This is an automated notification from the Benefits Admin system.
</p>
</div>

AGREEMENT_EXPIRY (SMS - Direct Mode)

Agreement {{agreementName}} expires {{expiryText}}. {{daysUntilExpiry}} day(s) remaining.

Template Variables Reference

VariableTypeDescriptionExample
agreementIdstringUUID of agreementabc-123-...
agreementTypestringNETWORK or BILATERALNETWORK
agreementNamestringDisplay nameSAGA_NETWORK or Randpark ↔ Royal
networkCodestring?Network code if NETWORK typeSAGA_NETWORK
clubAIdstring?Club A UUID if BILATERALxyz-456-...
clubBIdstring?Club B UUID if BILATERALuvw-789-...
discountSummarystringFormatted discount15% discount
endDatestringISO date2025-01-31
daysUntilExpirynumberDays remaining7
dashboardUrlstring?Link to agreementhttps://...
expiryTextstringHuman-readablein 7 days
urgencyStylestringCSS for urgencycolor: #dc2626;

Integration Points

1. Scheduled Expiry Check (Implemented)

// In AgreementExpirySchedulerService
@Cron('0 9 * * *', { timeZone: 'Africa/Johannesburg' })
async handleExpiryCheck(): Promise<void> {
for (const days of [30, 14, 7, 1]) {
const expiring = await this.agreementRepo.findExpiringAgreements({ withinDays: days });

// Filter to exact threshold matches to avoid duplicate notifications
const exactMatches = expiring.filter(a => {
const daysUntil = getDaysUntilExpiry(a.endDate);
return daysUntil === days;
});

for (const agreement of exactMatches) {
await this.sendExpiryNotification(agreement, days);
}
}
}

2. Manual Expiry Notification (Implemented)

// In AdminReciprocityController
@Post('agreements/:id/send-expiry-notice')
async sendExpiryNotice(
@Param('id') id: string,
@Body() body: { recipientEmail?: string },
) {
return this.expiryScheduler.sendManualExpiryNotification(id, body.recipientEmail);
}

3. UI Integration (Implemented)

// In BenefitsAdminShell
const handleSendExpiryNotice = async (agreement: ReciprocityAgreement) => {
try {
await sendNotification(agreement.id);
message.success('Expiry reminder sent successfully');
} catch (error) {
message.error('Failed to send reminder');
}
};

<AgreementsTable
// ... other props
onSendExpiryNotice={handleSendExpiryNotice}
expiryWarningDays={30}
/>

Deduplication Rules

Notification TypeDedup Key PatternTTL
AGREEMENT_EXPIRYexpiry-{agreementId}-{days}24h
AGREEMENT_CONFLICTconflict-{agreementId1}-{agreementId2}Until resolved
NETWORK_MEMBERSHIP_CHANGEmembership-{networkCode}-{clubId}-{date}24h
AGREEMENT_BULK_IMPORTimport-{batchId}1h

Quiet Hours Configuration

Benefits administrative alerts follow business hours:

const BENEFITS_QUIET_HOURS = {
// SMS/WhatsApp quiet hours
quietStart: '20:00',
quietEnd: '07:00',

// Exceptions: critical alerts ignore quiet hours
criticalTypes: [
NotificationType.AGREEMENT_EXPIRY, // When daysUntilExpiry <= 1
NotificationType.AGREEMENT_AUDIT_ALERT,
],
};

Implementation Phases

Phase 1: Agreement Expiry (COMPLETE)

  • Add AGREEMENT_EXPIRY to NotificationType enum
  • Create AgreementExpiryHandler with template/direct mode
  • Create AgreementExpirySchedulerService with cron job
  • Add findExpiringAgreements() to repository
  • Add API endpoints (list expiring, send notice)
  • Add UI integration (warning tags, send reminder action)
  • Add hooks (useExpiringAgreements, useSendExpiryNotification)
  • Export NotificationService from messaging-notifications

Phase 2: Conflict Detection (Planned)

  • Add AGREEMENT_CONFLICT to NotificationType enum
  • Create AgreementConflictHandler
  • Integrate conflict detection in create/update endpoints
  • Add conflict warning UI in AgreementFormDrawer
  • Create email template

Phase 3: Membership Changes (Planned)

  • Add NETWORK_MEMBERSHIP_CHANGE to NotificationType enum
  • Create NetworkMembershipHandler
  • Integrate with membership upsert/remove endpoints
  • Create email template

Phase 4: Eligibility Notifications (Planned)

  • Add eligibility notification types
  • Create eligibility notification handlers
  • Integrate with eligibility service
  • Create player-facing email/push templates

Phase 5: Audit & Bulk Operations (Planned)

  • Add audit notification types
  • Create audit alert handler
  • Integrate with bulk import/export
  • Create summary email templates

Dependencies

Existing Packages (No Changes Required)

PackagePurposeStatus
@digiwedge/messaging-notificationsNotificationEmitter, NotificationType, handlersENHANCED
@digiwedge/messaging-coreContactPreferenceServiceREUSE
@digiwedge/messaging-servicesTemplateService (Handlebars)REUSE
@prisma/tee-sheet-dataReciprocityAgreement modelREUSE
bullmqQueue for async notificationsREUSE
@nestjs/scheduleCron schedulingREUSE

Modified Packages

PackageChangeStatus
@digiwedge/messaging-notificationsAdded AGREEMENT_EXPIRY type, handler, exportsCOMPLETE
@digiwedge/tee-time-servicesAdded scheduler, repository method, controller endpointsCOMPLETE
@digiwedge/ui-benefits-adminAdded hooks, table enhancementsCOMPLETE

Testing

Unit Tests

describe('AgreementExpirySchedulerService', () => {
it('should find agreements expiring within threshold', async () => {
const expiring = await repo.findExpiringAgreements({ withinDays: 7 });
expect(expiring).toHaveLength(2);
expect(expiring[0].endDate).toBeDefined();
});

it('should send notification for exact threshold matches', async () => {
await scheduler.handleExpiryCheck();
expect(notificationService.sendNotification).toHaveBeenCalledWith(
NotificationType.AGREEMENT_EXPIRY,
expect.objectContaining({
variables: expect.objectContaining({ daysUntilExpiry: 7 }),
})
);
});

it('should skip if no admin email configured', async () => {
process.env['AGREEMENT_EXPIRY_ADMIN_EMAIL'] = '';
await scheduler.handleExpiryCheck();
expect(notificationService.sendNotification).not.toHaveBeenCalled();
});
});

E2E Tests

describe('Benefits Admin E2E', () => {
it('should show expiry warning for agreements expiring soon', async ({ page }) => {
// Setup: Agreement expiring in 5 days
await page.route('**/admin/reciprocity/agreements**', route =>
route.fulfill({
body: JSON.stringify([{ ...agreement, endDate: fiveDaysFromNow }])
})
);

await page.goto('/benefits');
await expect(page.getByText(/Expires in 5d/)).toBeVisible();
});

it('should send expiry reminder via action menu', async ({ page }) => {
await page.route('**/send-expiry-notice', route =>
route.fulfill({ body: JSON.stringify({ sent: true }) })
);

await page.click('[data-testid="row-actions"]');
await page.click('text=Send expiry reminder');
await expect(page.getByText(/reminder sent/i)).toBeVisible();
});
});

Open Questions

  1. Should expiry notifications go to multiple recipients (club admins, network admins)?
  2. What is the appropriate threshold for "urgent" expiry alerts (7 days proposed)?
  3. Should there be an opt-out mechanism for expiry reminders per agreement?
  4. Should eligibility notifications go directly to players or via their home club?
  5. What audit events should trigger immediate notifications vs. daily summaries?

Risk Assessment

RiskMitigation
Notification spamDeduplication rules prevent repeat alerts; cron filters to exact matches
Missing admin emailGraceful skip with warning log; no crash
Handler failureCaught and logged; continues processing other agreements
Template rendering failureFalls back to direct mode with basic HTML
BullMQ unavailableOptional injection; skips notifications gracefully

Success Criteria

  • Agreement expiry notifications sent at 30, 14, 7, 1 day thresholds
  • Manual expiry reminder can be triggered from UI
  • Expiry warning tags displayed in agreements table
  • All builds passing (lint, test, build)
  • No breaking changes to existing messaging flow
  • Template mode rendering verified (requires template database setup)
  • Contact preferences enforced (planned for future notifications)

Validation Results

Latest validation (2025-12-06):

CheckResult
pnpm nx run ui-benefits-admin:lint --max-warnings=0PASS
pnpm nx run ui-benefits-admin:test7 suites, 12 tests PASS
pnpm nx run ui-benefits-admin:build104.79 kB
pnpm nx run tee-time-services:lint --max-warnings=0PASS
pnpm nx run tee-time-services:buildPASS
pnpm nx run messaging-notifications:lint --max-warnings=0PASS
pnpm nx run messaging-notifications:buildPASS