# Notifications Module — Architecture Map

> **Last updated:** 2026-06-10
> **Status:** Living document — incrementally updated as new information is discovered

---

## 1. Purpose & Responsibilities

The Notifications module is the **outbound communication hub** of the ERP. It manages:
- SMS sending (single and bulk) to members, players, coaches, and parents
- Template-based message composition with variable substitution
- Automated event-driven notifications via configurable triggers
- Scheduled/deferred notification delivery with retry logic
- Push notifications to the Player API (player_notifications table)
- Cron-driven time-based notifications (medical expiry, subscription reminders, reservation reminders, membership renewal)
- SMS queue processing for bulk operations
- Deduplication of notifications (same trigger+recipient+day)

It does **NOT** directly manage:
- In-app notifications for employees (no employee notification center exists)
- Email communications (SMS-only currently)
- WhatsApp messaging (table supports it but no provider implemented)

---

## 2. Directory & File Structure

```
app/Modules/Notifications/
├── bootstrap.php                              # Menu + permissions + 27 EventBus listeners
├── Routes.php                                 # 12 web routes
├── Controllers/
│   ├── NotificationController.php            # SMS log, send, bulk send, templates
│   └── TriggerController.php                 # CRUD for automated notification triggers
├── Models/
│   ├── NotificationQueue.php                 # Queue model for deferred SMS
│   ├── NotificationTrigger.php               # Trigger configuration model
│   ├── ScheduledNotification.php             # Scheduled delivery with retry
│   ├── SmsLog.php                            # SMS delivery log model
│   └── SmsTemplate.php                       # Message template model
├── Services/
│   ├── CronNotificationService.php           # Time-based checks (medical, subscription, reservation, membership, coach metrics)
│   ├── NotificationScheduler.php             # Schedule + process pending notifications
│   ├── NotificationTriggerService.php        # Event→notification routing engine
│   ├── SmsNotificationService.php            # Parent/player SMS via EventBus (legacy path)
│   └── SmsService.php                        # Core SMS send + queue + provider call
└── Views/
    ├── log.php                               # SMS delivery log with filters
    ├── send.php                              # Manual send form
    ├── templates.php                         # Template management
    ├── trigger_edit.php                      # Create/edit trigger form
    └── triggers.php                          # Trigger listing
```

---

## 3. Database Schema (Production — Source of Truth)

### 3.1 `sms_log` Table (0 rows — dev mode active)

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| member_id | bigint unsigned | YES | MUL | FK to members |
| phone_number | varchar(20) | NO | MUL | Destination phone |
| template_id | bigint unsigned | YES | MUL | FK to sms_templates |
| message_text | text | NO | | Actual message sent |
| message_type | varchar(20) | NO | | 'single' or 'bulk' |
| status | varchar(30) | NO | MUL | queued/sent/failed/delivered |
| provider_message_id | varchar(200) | YES | | External SMS provider ID |
| provider_response | text | YES | | Raw provider response |
| cost | decimal(10,4) | YES | | Cost per message |
| retry_count | int unsigned | NO | | Default: 0 |
| max_retries | int unsigned | NO | | Default: 3 |
| sent_at | timestamp | YES | | When actually sent |
| delivered_at | timestamp | YES | | Provider delivery confirmation |
| failed_at | timestamp | YES | | When sending failed |
| failure_reason | text | YES | | Error description |
| sent_by | bigint unsigned | YES | | FK to employees |
| created_at | timestamp | NO | MUL | |

### 3.2 `sms_templates` Table (40 rows)

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| template_code | varchar(50) | NO | UNI | Unique lookup code |
| name_ar | varchar(200) | NO | | Arabic template name |
| name_en | varchar(200) | YES | | English template name |
| message_template_ar | text | NO | | Arabic message with {placeholders} |
| message_template_en | text | YES | | English message template |
| variables_json | json | YES | | Available placeholder variables |
| trigger_event | varchar(100) | YES | MUL | Associated event name |
| is_active | tinyint(1) | NO | | Default: 1 |
| created_at | timestamp | NO | | |
| updated_at | timestamp | NO | | |
| created_by | bigint unsigned | YES | | FK to employees |
| updated_by | bigint unsigned | YES | | FK to employees |

### 3.3 `notification_triggers` Table (33 rows)

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| trigger_code | varchar(50) | NO | UNI | Unique trigger identifier |
| trigger_name_ar | varchar(200) | NO | | Arabic display name |
| trigger_name_en | varchar(200) | YES | | English display name |
| event_name | varchar(100) | NO | MUL | EventBus event to listen for |
| template_id | bigint unsigned | YES | | FK to sms_templates |
| is_active | tinyint(1) | NO | MUL | Default: 1 |
| channel | varchar(20) | NO | | 'sms' or 'player_push' |
| recipient_type | varchar(30) | NO | | player/member/coach/parent/booker/group_players/session_players/direct |
| config_json | json | YES | | {delay_minutes, max_attempts} |
| priority | tinyint unsigned | NO | | Default: 5 |
| created_at | timestamp | NO | | |
| updated_at | timestamp | NO | | |

### 3.4 `scheduled_notifications` Table (0 rows)

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| trigger_code | varchar(50) | NO | MUL | FK (logical) to notification_triggers |
| recipient_type | varchar(20) | NO | MUL | player/member/coach/parent/booker |
| recipient_id | bigint unsigned | NO | | ID in the recipient's table |
| recipient_phone | varchar(30) | YES | | Resolved phone number |
| message | text | NO | | Rendered message |
| scheduled_at | datetime | NO | | When to send |
| sent_at | datetime | YES | | Actual send time |
| status | varchar(20) | NO | MUL | pending/sent/failed/cancelled |
| attempts | int unsigned | NO | | Default: 0 |
| max_attempts | int unsigned | NO | | Default: 3 |
| error_message | text | YES | | Last error |
| context_json | json | YES | | Event context for deduplication |
| created_at | timestamp | NO | | |

### 3.5 `notification_queue` Table (0 rows)

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| type | varchar(20) | NO | MUL | 'sms' (only type currently) |
| recipient_type | varchar(20) | NO | MUL | 'member' |
| recipient_id | bigint unsigned | NO | | Member ID |
| template_id | bigint unsigned | YES | | FK to sms_templates |
| subject | varchar(500) | YES | | Not used for SMS |
| message | text | NO | | Message body |
| data_json | json | YES | | {phone: "..."} |
| priority | int | NO | MUL | Default: 0 |
| status | varchar(30) | NO | MUL | pending/processed/failed |
| attempts | int unsigned | NO | | Default: 0 |
| max_attempts | int unsigned | NO | | Default: 3 |
| scheduled_at | timestamp | YES | MUL | When to send (null = immediately) |
| processed_at | timestamp | YES | | When processed |
| created_at | timestamp | NO | | |

### 3.6 `notification_sms_log` Table (legacy)

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | int unsigned | NO | PRI | auto_increment |
| phone | varchar(20) | NO | MUL | |
| message | text | NO | | |
| channel | enum('sms','whatsapp') | NO | | Default: 'sms' |
| status | enum('queued','sent','failed') | NO | MUL | |
| sent_at | datetime | YES | | |
| error_message | text | YES | | |
| created_at | datetime | NO | MUL | |

### 3.7 `player_notifications` Table (push notifications)

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| player_id | int unsigned | NO | MUL | FK to players |
| category | varchar(30) | NO | MUL | sport/booking/rating/medical/attendance/event |
| title_ar | varchar(300) | NO | | Notification title |
| body_ar | text | YES | | Notification body |
| data_json | json | YES | | Extra payload |
| is_read | tinyint(1) | NO | | Default: 0 |
| read_at | datetime | YES | | |
| created_at | datetime | NO | | |

---

## 4. Notification Delivery Architecture

```
Events (27 EventBus listeners)                 Cron Jobs (4 checks)
         │                                              │
         ▼                                              ▼
NotificationTriggerService::processEvent()    CronNotificationService
         │                                              │
         ├── channel = 'sms' ──────────────────────────┤
         │         │                                    │
         │         ▼                                    ▼
         │   Resolve Recipients                NotificationScheduler
         │   Render Template                   ::scheduleReminder()
         │   Deduplicate                              │
         │         │                                   │
         │         ▼                                   ▼
         │   INSERT scheduled_notifications ◄──────────┘
         │                                              
         ├── channel = 'player_push'                   
         │         │                                   
         │         ▼                                   
         │   PlayerNotificationService::notify()       
         │   → INSERT player_notifications             
         │                                              
         ▼                                              
NotificationScheduler::processPendingNotifications() [CRON]
         │
         ▼
SmsService::send()
         │
         ├── SMS disabled → mark as 'sent' (dev mode)
         └── SMS enabled  → callProvider() via HTTP API
                   │
                   ▼
              INSERT sms_log
```

### Bulk SMS Path (separate from triggers):
```
NotificationController::sendBulk()
         │
         ▼
SmsService::queue() → INSERT notification_queue
         │
         ▼
SmsService::processQueue() [CRON] → SmsService::send()
```

---

## 5. Events Consumed (27 EventBus listeners in bootstrap.php)

| Event | Recipient Type | Channel |
|-------|---------------|---------|
| session.cancelled | session_players | sms |
| session.rescheduled | session_players | sms |
| group.player_transferred | player | sms |
| reservation.confirmed | booker | sms |
| reservation.reminder | direct | sms |
| carnet.low_balance | member | sms |
| carnet_guest.entry_recorded | member | sms |
| coach.payment_processed | coach | sms |
| player.evaluation_submitted | player_and_parent | sms |
| player.medical_expiry | player | sms |
| tournament.registration_open | group_players | sms |
| player.injury_reported | parent | sms |
| player.achievement_earned | player_and_parent | sms |
| schedule.change_requested | (admin) | sms |
| group.transfer_approved | player | sms |
| private_match.booked | (facility manager) | sms |
| session.feedback_submitted | coach | sms |
| player.enrolled | player | player_push |
| player.booking_confirmed | player | player_push |
| player.booking_cancelled | player | player_push |
| player.free_time_entry | player | player_push |
| player.transfer_result | player | player_push |
| player.evaluation_completed | player | player_push |
| player.medical_expiring | player | player_push |
| coach.day_off_requested | group_players | sms |
| coach.replacement_confirmed | coach | sms |
| coach.session_reminder | coach | sms |
| facility.attendance_absent | player | player_push |
| facility.payment_reminder | direct | sms |

### SmsNotificationService (legacy) listeners:
| Event | Action |
|-------|--------|
| medical.expiring_soon | SMS to parent(s) |
| session.attendance_marked (absent) | SMS to parent(s) |
| waitlist.spot_offered | SMS to parent(s) |

---

## 6. Events Dispatched

None — the Notifications module is a pure consumer/sink.

---

## 7. Cross-Module Dependencies

### 7.1 Notifications module IMPORTS FROM:

| Module | What it uses |
|--------|-------------|
| PlayerApi | PlayerNotificationService (for player_push channel) |
| Core | EventBus, Logger, App, config('sms') |

### 7.2 Other modules that IMPORT FROM Notifications:

| Module | What it imports |
|--------|----------------|
| Audit/AuditService | sms_log is in IGNORE_TABLES list |
| PlayerAuth | (references notification triggers indirectly) |
| SportsActivity | (dispatches events that triggers listen to) |

### 7.3 Modules that dispatch events consumed by Notifications:

| Module | Events dispatched |
|--------|------------------|
| SportsActivity | session.cancelled, session.rescheduled, session.feedback_submitted |
| PlayerAffairs | group.player_transferred, group.transfer_approved, player.evaluation_submitted, player.achievement_earned, player.injury_reported |
| Reservations | reservation.confirmed, reservation.reminder, private_match.booked |
| Carnets | carnet.low_balance, carnet_guest.entry_recorded |
| Coaches | coach.payment_processed, coach.day_off_requested, coach.replacement_confirmed, coach.session_reminder |
| PlayerApi | player.enrolled, player.booking_confirmed, player.booking_cancelled, player.free_time_entry, player.transfer_result, player.evaluation_completed, player.medical_expiring |
| Facilities | facility.attendance_absent, facility.payment_reminder |
| Tournaments | tournament.registration_open |

---

## 8. Routes (12)

| Method | Path | Handler | Permission |
|--------|------|---------|------------|
| GET | /notifications/templates | NotificationController@templates | sms.view_log |
| POST | /notifications/templates/{id} | NotificationController@updateTemplate | sms.edit_templates |
| GET | /notifications/log | NotificationController@log | sms.view_log |
| GET | /notifications/send | NotificationController@sendForm | sms.send_single |
| POST | /notifications/send | NotificationController@send | sms.send_single |
| POST | /notifications/send-bulk | NotificationController@sendBulk | sms.send_bulk |
| GET | /notifications/triggers | TriggerController@index | notification.triggers.view |
| GET | /notifications/triggers/create | TriggerController@create | notification.triggers.manage |
| POST | /notifications/triggers | TriggerController@store | notification.triggers.manage |
| GET | /notifications/triggers/{id}/edit | TriggerController@edit | notification.triggers.manage |
| POST | /notifications/triggers/{id} | TriggerController@update | notification.triggers.manage |
| POST | /notifications/triggers/{id}/toggle | TriggerController@toggle | notification.triggers.manage |

---

## 9. Permissions

| Key | Description |
|-----|-------------|
| sms.send_single | Send single SMS |
| sms.send_bulk | Send bulk SMS |
| sms.view_log | View SMS log |
| sms.edit_templates | Edit message templates |
| notification.triggers.view | View notification triggers |
| notification.triggers.manage | Create/edit/toggle notification triggers |

---

## 10. Background Processes (Cron)

### CronNotificationService (time-based checks):
- **checkMedicalExpiry()**: Players with medical certs expiring in 7d/3d/today
- **checkSubscriptionPaymentDue()**: Activity subscription payments due in 5d/today/overdue
- **checkReservationReminders()**: Confirmed reservations for tomorrow (sent at 18:00)
- **checkMembershipRenewal()**: Memberships expiring in 30d/7d
- **calculateCoachMetrics()**: Monthly coach performance summary

### NotificationScheduler::processPendingNotifications():
- Processes all `scheduled_notifications` where `scheduled_at <= NOW()` and `status = 'pending'`
- Sends via SmsService::send()
- Retries up to max_attempts on failure

### SmsService::processQueue():
- Processes the `notification_queue` table (bulk SMS path)
- Batch size: 50

---

## 11. Configuration Dependencies

| Config Key | Source | Purpose |
|-----------|--------|---------|
| sms.enabled | config/sms.php | Master SMS on/off switch |
| sms.api_key | config/sms.php | Provider API key |
| sms.api_url | config/sms.php | Provider HTTP endpoint |
| sms.sender_id | config/sms.php | Sender ID (default: 'THECLUB') |
| sms.provider | config/sms.php | 'log' (dev) or provider name |

---

## 12. High-Risk Areas

### 12.1 Dual SMS Paths (CONFUSING)
- **Path 1**: `SmsService` → `sms_log` table (main path via triggers)
- **Path 2**: `SmsNotificationService` → `notification_sms_log` table (legacy path for parent notifications)
- Both paths exist in parallel and are both active. Risk of sending duplicate notifications if same event is handled by both.

### 12.2 Dev Mode Always Active
- SMS provider is currently disabled (`SMS_DISABLED_DEV_MODE`)
- All messages are logged as "sent" but never actually delivered
- When enabling production SMS, no gradual rollout mechanism exists — all 33 triggers activate simultaneously

### 12.3 No Rate Limiting
- No throttling on SMS send frequency per recipient
- Bulk send has no progress/cancellation mechanism
- A buggy event loop could theoretically spam a recipient

### 12.4 Deduplication is Date-Based Only
- `existsForRecipient()` checks by trigger+recipient+date
- If the same event fires again the next day, a new notification is sent regardless
- No "cooldown" concept between notifications

### 12.5 Template Rendering is Simple String Replace
- Only scalar values from event data are substituted
- No formatting (dates, money) applied automatically
- Unresolved `{placeholders}` are silently removed — message may be incomplete if event data is missing keys

### 12.6 Bootstrap Performance
- 27+ EventBus::listen() calls in bootstrap.php execute on every request
- The listeners themselves are lightweight (just delegates to NotificationTriggerService) but the registration overhead adds up

---

## 13. Known Patterns & Gotchas

1. **Two queuing mechanisms**: `notification_queue` (for bulk) and `scheduled_notifications` (for triggered). They use different processing paths.
2. **player_push channel**: Bypasses SMS entirely, writes directly to `player_notifications` table via `PlayerNotificationService`.
3. **Deduplication**: Same trigger+recipient+date won't be re-queued. This is intentional for cron jobs that run daily.
4. **Recipient resolution**: The `resolveRecipients()` method queries the database for phone numbers at event time, not at send time. If phone changes between scheduling and delivery, the old phone is used.
5. **All listeners use priority -10**: They run after all business logic listeners to ensure state is consistent.
6. **No audit trail**: Both `sms_log` and `notification_queue` are in the Audit IGNORE_TABLES list.
7. **Trigger config_json**: Only supports `delay_minutes` and `max_attempts`. No complex scheduling rules.
