# MedicalBoard Module — Architecture Map

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

---

## 1. Purpose & Responsibilities

The MedicalBoard module provides a **unified medical certificate approval interface** for the club's medical committee (board). It is a lightweight UI module that:

- Displays pending medical certificates from BOTH player sources:
  - PlayerAffairs players (`players` table, `player_medical_records.source = 'membership'`)
  - Sports Activity players (`sa_players` table, `player_medical_records.source = 'sports_activity'`)
- Allows board members to approve (with expiry date) or reject (with reason) certificates
- Updates the player's medical status and expiry date on approval
- Updates associated documents status (for SA players)
- Provides filtered views: pending / approved / rejected

It does **NOT** manage:
- Medical record creation (PlayerAffairs and PlayerApi handle uploads)
- Player registration or profile management
- Medical scan file storage (handled by MedicalScanService in PlayerAffairs)
- Certificate expiry monitoring or notifications (MedicalScanService cron)

---

## 2. Directory & File Structure

```
app/Modules/MedicalBoard/
├── bootstrap.php                              # Permission + sidebar menu registration
├── Routes.php                                 # 3 web routes
├── Controllers/
│   └── MedicalBoardController.php             # Index (list), approve, reject
└── Views/
    └── index.php                              # Approval queue table with action forms
```

This is one of the smallest modules in the system — 4 files total.

---

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

MedicalBoard does **not own any tables**. It operates entirely on tables owned by other modules:

### 3.1 Tables Read/Written

| Table | Owner | Operations | Purpose |
|-------|-------|-----------|---------|
| player_medical_records | PlayerAffairs | READ, UPDATE | Primary data source — approval_status, result, expiry_date, approved_by, approved_at, rejection_reason |
| players | PlayerAffairs | UPDATE | Set medical_status='fit'/'unfit', medical_expiry_date |
| sa_players | SportsActivity | UPDATE | Set medical_status, medical_expiry_date for SA players |
| sa_player_documents | SportsActivity | UPDATE | Set approval_status, expiry_date, approved_by, approved_at |
| documents | Documents | READ | Display document file path |

### 3.2 Key Query Pattern

The index query joins `player_medical_records` to BOTH player tables via conditional joins:
```sql
LEFT JOIN players p ON p.id = pmr.player_id AND pmr.source = 'membership'
LEFT JOIN sa_players sp ON sp.id = pmr.sa_player_id AND pmr.source = 'sports_activity'
LEFT JOIN documents d ON d.id = pmr.document_id
LEFT JOIN sa_player_documents spd ON spd.id = pmr.sa_document_id
```

Player name is resolved via `COALESCE(p.full_name_ar, sp.full_name_ar)`.

---

## 4. Routes (3 total)

| Method | Path | Handler | Middleware | Permission |
|--------|------|---------|-----------|------------|
| GET | /medical-board | MedicalBoardController@index | auth | medical.board.view |
| POST | /medical-board/{id}/approve | MedicalBoardController@approve | auth, csrf | medical.board.approve |
| POST | /medical-board/{id}/reject | MedicalBoardController@reject | auth, csrf | medical.board.approve |

---

## 5. Events Dispatched

None. MedicalBoard does not dispatch any events.

**Note**: This is a potential gap — when the board approves/rejects, no event is dispatched. Compare with `MedicalRecordService::approveRecord()` in PlayerAffairs which DOES dispatch `player.medical_approved`/`player.medical_rejected`. The MedicalBoard's direct DB updates bypass those events.

---

## 6. Events Consumed

None. MedicalBoard does not listen to any events.

---

## 7. Cross-Module Dependencies

### 7.1 MedicalBoard IMPORTS FROM:

| Module | What it uses |
|--------|-------------|
| (None) | Only uses core framework classes (Controller, Request, Response, App) |

### 7.2 Related modules with overlapping functionality:

| Module | Overlap |
|--------|---------|
| PlayerAffairs (MedicalApprovalController) | Also approves medical records at `/medical-approvals` — but ONLY for `players` (not SA players) |
| PlayerAffairs (MedicalRecordService) | Has `approveRecord()` and `rejectRecord()` methods that dispatch events — MedicalBoard does NOT use these services |

---

## 8. Permissions

| Key | Description (Arabic) |
|-----|---------------------|
| medical.board.view | View the medical board queue |
| medical.board.approve | Approve or reject medical certificates |

---

## 9. Sidebar Menu Registration

```php
MenuRegistry::register('medical-board', [
    'label_ar'   => 'اللجنة الطبية',
    'label_en'   => 'Medical Board',
    'icon'       => 'heart-pulse',
    'route'      => '/medical-board',
    'permission' => 'medical.board.view',
    'parent'     => null,
    'order'      => 510,
    'children'   => [],
]);
```

This is a top-level menu item (no parent), positioned at order 510.

---

## 10. Core Business Flows

### 10.1 View Pending Records

```
1. GET /medical-board?status=pending (default)
2. Query player_medical_records with conditional joins to both player tables
3. Display: player name, record type, certificate type, exam date, expiry date, doctor, document link
4. SA players tagged with "نشاط رياضي" badge
5. Limit: 100 records per view (no pagination)
```

### 10.2 Approve Medical Certificate

```
1. POST /medical-board/{id}/approve {expiry_date} (required)
2. Validate expiry_date is provided
3. Update player_medical_records:
   - approval_status = 'approved'
   - result = 'fit'
   - expiry_date = submitted date
   - approved_by = current employee
   - approved_at = now
4. If source = 'sports_activity' AND sa_player_id exists:
   a. UPDATE sa_players SET medical_status='fit', medical_expiry_date=expiry_date
   b. If sa_document_id: UPDATE sa_player_documents SET approval_status='approved', expiry_date, approved_by, approved_at
5. Else (source = 'membership'):
   a. UPDATE players SET medical_status='fit', medical_expiry_date=expiry_date
6. Redirect with success message
```

### 10.3 Reject Medical Certificate

```
1. POST /medical-board/{id}/reject {rejection_reason} (optional, defaults to 'مرفوض')
2. Update player_medical_records:
   - approval_status = 'rejected'
   - result = 'unfit'
   - rejection_reason = provided reason or 'مرفوض'
   - approved_by = current employee
   - approved_at = now
3. If source = 'sports_activity' AND sa_player_id exists:
   a. UPDATE sa_players SET medical_status='unfit'
   b. If sa_document_id: UPDATE sa_player_documents SET approval_status='rejected'
4. Else (source = 'membership'):
   a. UPDATE players SET medical_status='unfit'
5. Redirect with success message
```

---

## 11. High-Risk Areas

### 11.1 Dual Approval Paths (CRITICAL)
- PlayerAffairs has `MedicalApprovalController` at `/medical-approvals` with its own approve/reject logic
- MedicalBoard has `MedicalBoardController` at `/medical-board` with DIFFERENT logic
- Key differences:
  - PlayerAffairs uses `MedicalRecordService::approveRecord()` which **dispatches events** (`player.medical_approved`)
  - MedicalBoard does **direct DB updates** — NO events dispatched
  - MedicalBoard handles both `players` AND `sa_players`; PlayerAffairs only handles `players`
- If a record is approved via MedicalBoard, the `player.medical_approved` event is NEVER fired — any downstream listeners (notifications, logging) are skipped

### 11.2 No Optimistic Locking
- No check that the record is still 'pending' before approving/rejecting
- If two board members load the page simultaneously, both can approve/reject the same record
- Second write wins (last-write-wins race condition)

### 11.3 Expiry Date Override
- On approval, the board sets the expiry_date manually (overrides any previously calculated value)
- No validation that expiry_date is in the future
- No validation against certificate_type validity rules (e.g., recreational=12mo, academy=6mo)

### 11.4 No Audit Trail
- No audit_trail entries created when medical records are approved/rejected
- The `approved_by` and `approved_at` fields provide some traceability
- But no record of who viewed what, or if a decision was changed

### 11.5 Result Hard-Coded
- Approval always sets `result = 'fit'`
- No option for 'conditional' (fit with restrictions)
- Rejection always sets `result = 'unfit'`
- The `restrictions` field on the record is not surfaced in the approval UI

---

## 12. Known Patterns & Gotchas

1. **No service layer**: Controller directly manipulates the DB — all logic is inline in the controller methods
2. **100-record hard limit**: No pagination — if more than 100 pending records exist, only the newest 100 are shown
3. **Rejection reason is optional**: Defaults to 'مرفوض' (rejected) if not provided — some records may have generic reasons
4. **SA player integration**: The `sa_players` and `sa_player_documents` tables are from the SportsActivity module's player system (separate from `players` table)
5. **Document display**: Shows document via simple `<a href="/{path}">` link — no access control on the file itself
6. **CSRF on approve/reject**: Both actions require CSRF token (good — prevents cross-site attacks)
7. **Filter state via query param**: Status filter is passed as `?status=pending|approved|rejected` — defaults to pending
8. **Expiry date required for approval**: The UI includes a date input that is `required` — server also validates
9. **No notification to player**: Unlike PlayerAffairs' `MedicalRecordService`, MedicalBoard does not notify the player when their certificate is approved/rejected

---

## 13. Comparison: MedicalBoard vs PlayerAffairs MedicalApprovalController

| Feature | MedicalBoard | PlayerAffairs/MedicalApprovalController |
|---------|-------------|---------------------------------------|
| Route | /medical-board | /medical-approvals |
| Permission | medical.board.view / medical.board.approve | player.approve_medical |
| Handles SA players | Yes (dual JOIN) | No (only players table) |
| Uses service layer | No (direct DB) | Yes (MedicalRecordService) |
| Dispatches events | No | Yes (player.medical_approved/rejected) |
| Sets expiry date | Yes (from form input) | No (uses record's existing expiry) |
| Handles document status | Yes (sa_player_documents) | No |
| Pagination | No (limit 100) | No (limit 100) |
| Sidebar menu | Yes (order 510) | No (accessed from players navigation) |

---

## Appendix: Files to Check Before Modifying MedicalBoard

1. `app/Modules/PlayerAffairs/Controllers/MedicalApprovalController.php` — Parallel approval path
2. `app/Modules/PlayerAffairs/Services/MedicalRecordService.php` — The service layer this module SHOULD be using
3. `app/Modules/PlayerAffairs/Services/MedicalScanService.php` — Expiry checking and notification dispatch
4. `app/Modules/PlayerApi/Controllers/Api/MedicalController.php` — How players submit records (source of data)
5. `app/Modules/PlayerAffairs/Controllers/PlayerController.php@addMedical` — How staff submit records
