# Committee Module — Feature Documentation

> **آخر تحديث:** 2026-06-09
> **الـ Stack:** Laravel 12 / PHP 8.2 / MySQL 8
> **الـ Timezone:** Asia/Riyadh (`BookingsTimezone::business()`)
> **الـ Audience:** المطورين اللي هيعدّلوا على الفيتشر في المستقبل

---

## 1. الفكرة (Business Workflow)

الـ Committee module هو **نظام تنسيق اجتماعات شهرية بين المستثمرين والشركات الناشئة**. كل شهر:

1. **الأدمن ينشئ committee واحدة** لـ شهر محدد (يوم 9 افتراضيًا)
2. يحدد **slots** (فترات زمنية) لكل startup داخل الاجتماع
3. يضيف **investors** المؤهلين (category = C-Committee)
4. النظام بيبعت reminders قبل الاجتماع تلقائيًا (لو الـ automations مفعّلة)
5. المستثمر يدخل **portal خارجي** بـ OTP، يأكّد حضوره، يقرا تفاصيل الـ startups، يكتب notes ويـ vote (Pass/Fail)
6. الأدمن يـ track الحضور (investor + startup) ويـ verify
7. بعد الاجتماع: الـ committee تـ expire تلقائيًا في 00:05 اليوم اللي بعده

> **القيد الأساسي:** committee واحدة بس لكل شهر. enforced بـ UNIQUE constraint على `meeting_month_key`.

---

## 2. الـ Architecture (نظرة عامة)

```
┌──────────────────────────────────────────────────────────┐
│         ADMIN: /admin/committees                         │
│         - Create / Edit / Show / Delete                  │
│         - Assign slots, members, evaluations             │
└──────────────────────┬───────────────────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────────────────┐
│   Committee Entity (status: active | expired)            │
│        │                                                  │
│        ├─→ Slots (committee_slots, ordered)              │
│        ├─→ Investors (committee_investors pivot)         │
│        ├─→ Startups (via slots)                          │
│        ├─→ Attendances (investor + startup tracks)       │
│        ├─→ Evaluations (per member × startup)            │
│        └─→ Notes (per member × startup)                  │
└─────────────┬────────────────────────┬───────────────────┘
              │                        │
              ▼                        ▼
┌──────────────────────────┐  ┌─────────────────────────┐
│  AUTOMATIONS (4 rules)   │  │  PUBLIC PORTAL          │
│  - 9, 10: Creation       │  │  /committee-attendance  │
│  - 7, 8:  Reminder       │  │  (Phone + OTP gated)    │
└──────────┬───────────────┘  └─────────────────────────┘
           │
           ▼
┌──────────────────────────────────────────────┐
│  CRON SCHEDULERS (every minute)              │
│  - automations:scan-committee-reminders      │
│  - committees:expire-past (daily 00:05)      │
└──────────────────────────────────────────────┘
```

---

## 3. خطوات الاستخدام (Step-by-Step Usage)

### 3.1 إنشاء Committee

```
1. الأدمن يفتح /admin/committees/create
2. الفورم بتيجي pre-filled:
   - meeting_date: 9 من الشهر القادم اللي مفيش له committee
   - meeting_time: 19:30
   - meeting_url: Teams meeting URL (hardcoded — لاحظ نقطة 14.1)
   - status: active
   - investors: كل المستثمرين اللي عندهم category = C-Committee مختارين تلقائيًا
3. الأدمن يضيف الـ startups (slots):
   - Drag & drop ترتيب
   - start_time و end_time لكل slot (default duration: 30min)
4. يضغط Save
```

**عند الـ store:**
1. ✅ DB transaction: committee row + slots + investors pivot
2. ✅ Audit log: `committee_created`
3. ✅ **Event: `CommitteeCreated` يـ fire** ← هنا الـ automation 9 + 10 بتشتغل
4. ✅ Flash: "تم إنشاء لجنة شهر [الشهر]"

> **مهم:** الـ event بيتـ fire **بعد** الـ transaction commit (مش جواه). لو فشل الـ commit، الـ event مش بيتـ fire — الـ data integrity مضمونة.

### 3.2 تعديل Committee

```
PATCH /admin/committees/{id}
```

- يقدر يعدّل `meeting_date`, `meeting_time`, `meeting_url`, slots, investors
- **❌ مفيش re-dispatch للـ automations** (الـ event بـ يـ fire على store() بس مش update())
- **❌ status مش بيتغيّر** — لو محتاج تغيّر `expired` يدوي، خليها عبر DB

### 3.3 المتابعة بعد الإنشاء

```
الأدمن يفتح /admin/committees/{id} (show page)
   ↓
يشوف:
   - Slots بـ timing
   - Members بـ attendance status (self + admin tracks)
   - Startups بـ attendance status
   - Evaluations matrix (Pass/Fail لكل member × startup)
   - Notes من المستثمرين على كل startup
   - Notification dispatches (audit trail)
   - Notes الـ admin observations field
```

### 3.4 الـ Investor Portal Flow

```
1. مستثمر يفتح /committee-attendance على موبايله
2. يدخل رقم تليفونه → OTP يتبعت SMS
3. الـ eligibility check بيجري:
   ✓ category_option_id = C-Committee?
   ✓ في active committee الشهر ده؟
   ✓ هو مدعو في الـ roster؟
4. يدخل الـ OTP → يدخل البورتال
5. البورتال يعرض:
   - تفاصيل الاجتماع + الـ Teams URL
   - زرار "أكّد حضوري" / "ألغي حضوري"
   - قائمة الـ startups
6. يضغط على startup → يفتح صفحة تفصيلية:
   - معلومات الشركة + الفريق + الملفات
   - textarea لكتابة notes
7. يحفظ → الـ note يتـ upsert في DB
```

> **Re-validation:** كل request في البورتال بـ re-run الـ eligibility check. لو الـ admin شال المستثمر من الـ committee، أو غيّر الـ category، أو الـ committee اتـ expire — الـ session تـ revoke فورًا في الـ request اللي بعد كده.

### 3.5 الـ Expiration

```
كل يوم 00:05 (Saudi time):
   ↓
ExpireCommitteesCommand بـ run
   ↓
UPDATE committees
   SET status = 'expired'
   WHERE status = 'active'
   AND meeting_date < TODAY
```

**اللي بيحصل بعد الـ expiration:**
- ❌ Reminders تتوقف فورًا (الـ scanner عنده `Committee::active()` guard)
- ❌ Portal يـ block المستثمر (eligibility fails)
- ✅ Show page لسه شغّال للأدمن (audit trail متاح)
- ❌ مفيش way to un-expire (لو محتاج، update يدوي في DB)

---

## 4. الـ Database Schema — 8 جداول

### 4.1 الـ Main entity

```sql
committees
├── id BIGINT PK
├── meeting_date DATE                       -- INDEX
├── meeting_time TIME DEFAULT 13:00:00
├── meeting_url TEXT NULLABLE
├── meeting_month_key VARCHAR(7)            -- UNIQUE (formato: '2026-06')
├── status VARCHAR(16) DEFAULT 'active'     -- 'active' | 'expired'
├── created_by BIGINT FK → admins.id NULLABLE
├── notes TEXT NULLABLE                     -- admin observations
├── created_at, updated_at, deleted_at
└── INDEX (status, meeting_date)            -- composite for fast active+upcoming
```

> **meeting_month_key:** auto-derived من `meeting_date` في `Committee::booted()` (نمط `Y-m`). الـ UNIQUE constraint بيمنع 2 committees لنفس الشهر.

### 4.2 Members + Slots

```sql
committee_investors                          -- ASSIGNMENT pivot
├── id, committee_id, investor_id
├── created_at, updated_at
└── UNIQUE (committee_id, investor_id)

committee_slots                              -- STARTUP slots with timing
├── id, committee_id FK, startup_id FK
├── slot_order UNSIGNED INT                  -- 0-based, drag-drop order
├── start_time TIME                          -- e.g., 19:00:00
├── end_time TIME                            -- e.g., 19:30:00
├── created_at, updated_at
├── UNIQUE (committee_id, startup_id)        -- 1 startup max per committee
├── UNIQUE (committee_id, slot_order)        -- order is unique
└── INDEX (committee_id, slot_order)
```

### 4.3 Attendance — لانين منفصلين

```sql
committee_attendances                        -- INVESTOR self + admin track
├── id, committee_id FK, investor_id FK
│
│   -- SELF track (investor confirms in portal):
├── status ENUM('pending','confirmed','cancelled') DEFAULT 'pending'
├── confirmed_at TIMESTAMP NULLABLE
├── cancelled_at TIMESTAMP NULLABLE
│
│   -- ADMIN track (admin verifies):
├── admin_status ENUM('pending_verification','attended','did_not_attend')
├── admin_status_at TIMESTAMP NULLABLE
├── admin_status_by BIGINT FK → admins.id
│
│   -- Forensic:
├── last_ip VARCHAR(45)
├── last_user_agent VARCHAR(255)
└── UNIQUE (committee_id, investor_id)

committee_startup_attendances                -- STARTUP track (admin-only)
├── id, committee_id FK, startup_id FK
├── status ENUM('pending','attended','did_not_attend') DEFAULT 'pending'
├── status_at TIMESTAMP NULLABLE
├── status_by BIGINT FK → admins.id
└── UNIQUE (committee_id, startup_id)
```

> **Critical:** الـ self track والـ admin track **مستقلين تمامًا**. الأدمن مش بيكتب على الـ self، والعكس. الـ source field في الـ logs بيفرّق.

### 4.4 Audit Log

```sql
committee_attendance_logs                    -- APPEND-ONLY ledger
├── id
├── committee_id FK NULLABLE                 -- nullable: pre-eligibility events
├── investor_id FK NULLABLE
├── source ENUM('self','admin')
├── action VARCHAR(32)                       -- e.g., 'attendance_confirmed',
│                                            -- 'admin_status_updated', 'access_granted'
├── from_status VARCHAR(32) NULLABLE
├── to_status VARCHAR(32) NULLABLE
├── admin_id BIGINT FK NULLABLE
├── ip_address VARCHAR(45), user_agent VARCHAR(255)
├── context JSON NULLABLE
└── created_at, updated_at
```

### 4.5 Evaluations + Notes

```sql
committee_evaluations                        -- per-member voting matrix
├── id, committee_id FK, startup_id FK
├── member_id BIGINT FK → investors.id       -- relabeled as "member"
├── result ENUM('pass','fail')
└── UNIQUE (committee_id, startup_id, member_id)

committee_investor_startup_notes             -- per-member-per-startup notes
├── id, committee_id FK, investor_id FK, startup_id FK
├── note TEXT NULLABLE
└── UNIQUE (committee_id, investor_id, startup_id)
```

---

## 5. الـ Lifecycle States

```
┌─────────────────┐
│   CREATED       │  status = 'active', meeting_date >= today
│   (active)      │
└────────┬────────┘
         │
         │ admin can: edit, add/remove slots, add/remove members,
         │   record evaluations, mark attendance
         │
         │ reminders fire (if automation enabled)
         │
         │ portal accepts investor logins
         │
         │ time passes...
         ↓
   📅 meeting_date < today
         │
         │ daily cron (00:05) →
         ↓
┌─────────────────┐
│   EXPIRED       │  status = 'expired'
│                 │
└─────────────────┘
         │
         │ no more reminders
         │ portal blocks new logins
         │ admin show page still works (audit access)
         │
         │ ⚠️ NO un-expire path (manual DB only)
```

### اللي بيحصل في الـ states

| Action | Active | Expired |
|---|---|---|
| Admin يـ edit | ✅ يقدر | ✅ يقدر (لكن نادر) |
| Reminder بيـ fire | ✅ لو الـ automation enabled | ❌ Hard guard في الـ scanner |
| Investor portal | ✅ login يـ pass | ❌ eligibility fails |
| Show page | ✅ | ✅ (read-only effectively) |
| Save evaluations / notes | ✅ | ✅ (technically possible — no guard) |
| Delete | ✅ soft delete | ✅ soft delete |

---

## 6. الـ 4 Automations — تفصيل كامل

كل واحدة من الـ 4 automations دي مزروعة في الـ DB عبر migrations. الأدمن يقدر يـ enable/disable + يعدّل الـ message templates من `/admin/automations/{id}/edit`.

### 6.1 Automation #7 — Investor Committee Reminder

| Field | Value |
|---|---|
| **ID** | 7 |
| **Audience** | `investor` |
| **Type** | `reminder` |
| **Trigger Key** | `committee_reminder` |
| **Name (EN)** | Committee Reminder |
| **Name (AR)** | تذكير اللجنة |
| **Default enabled** | ❌ FALSE (محتاج تفعيل يدوي) |
| **`reminder_minutes`** | الأدمن يحدد (مثلاً 60 = ساعة قبل الاجتماع) |
| **`execute_after_*`** | غير مستخدم في reminders |
| **Channels** | الأدمن يختار من: email, sms, whatsapp, notification |
| **Placeholders** | `{{committee_date}}`, `{{committee_time}}`, `{{committee_url}}`, etc. |

**بـ تـ fire إمتا؟**

```
كل دقيقة الـ scanner يـ run.
بيدوّر على committees ACTIVE حيث:
   TIMESTAMP(meeting_date, meeting_time) BETWEEN
       now() + (reminder_minutes - 2)
       AND now() + reminder_minutes

→ لكل committee في الـ window:
    → لكل investor في الـ committee_investors:
        → احفظ AutomationDispatch بـ dedupe_key:
            "automation:7:investor:<investor_id>:committee_reminder:committee:<committee_id>"
        → ابعت notification عبر AutomationNotificationService
```

**Window padding (2 دقيقة):** عشان لو scanner tick فاتت، الـ scan اللي بعدها يلحقها. الـ UNIQUE index على `dedupe_key` بيمنع الـ duplicate sends.

### 6.2 Automation #8 — Startup Committee Reminder

نفس Automation #7 بالظبط، بس **audience = startup**. الـ scanner بيـ enumerate الـ startups (عبر slots) بدل investors.

**Dedupe key شكله:**
```
automation:8:startup:<startup_id>:committee_reminder:committee:<committee_id>
```

### 6.3 Automation #9 — Investor Committee Creation Notification

| Field | Value |
|---|---|
| **ID** | 9 |
| **Audience** | `investor` |
| **Type** | `creation` |
| **Trigger Key** | `committee_created` |
| **Name (AR)** | إنشاء اللجنة |
| **Default enabled** | ❌ FALSE |

**بـ تـ fire إمتا؟**

```
لما الأدمن يضغط Save على /admin/committees/create
   ↓
بعد الـ DB::transaction نجاح:
   ↓
event(new CommitteeCreated($committee))
   ↓
DispatchCommitteeCreationAutomations listener يـ catch
   ↓
لكل automation enabled بـ trigger_key = 'committee_created':
   → لو audience = investor:
       → لكل investor في الـ committee:
           → dispatch notification
```

> **مهم:** يـ fire **مرة واحدة بس على create**. الـ edit() / update() مش بـ يـ fire الـ event ده.

### 6.4 Automation #10 — Startup Committee Creation Notification

نفس Automation #9، بس بـ audience = startup. الـ event listener بـ يـ enumerate startups بدل investors.

### 6.5 الفرق بين Reminder و Creation

| Feature | Reminder (7, 8) | Creation (9, 10) |
|---|---|---|
| **متى يـ fire؟** | قبل الاجتماع بـ reminder_minutes | فور إنشاء الـ committee |
| **آلية الـ trigger** | Cron scan كل دقيقة | Eloquent event listener (sync) |
| **`reminder_minutes`** | ✅ ضروري (لازم > 0) | ❌ غير مستخدم |
| **`execute_after_*`** | ❌ غير مستخدم | ❌ غير مستخدم (immediate) |
| **Dedupe key** | per (committee, recipient) | per (event, recipient) |
| **يـ fire لما؟** | كل scanner tick يلاقي committee في الـ window | مرة واحدة عند store() |
| **يـ block إذا committee expired?** | ✅ Hard guard في scanner | ❌ Not applicable (يـ fire on create فقط) |

---

## 7. متى نستخدم الـ Automations ومتى لا؟

### 7.1 استخدم Automations لو:

✅ **محتاج تذكير منتظم قبل الاجتماع**
- Reminder قبل 24 ساعة → reminder_minutes = 1440
- Reminder قبل ساعة → reminder_minutes = 60
- Reminder قبل 15 دقيقة → reminder_minutes = 15

✅ **محتاج إخطار فوري عند الإنشاء**
- لما تخلص الـ committee، الـ investors يلاقوا رسالة فورًا

✅ **عاوز نفس النمط على كل committee**
- مفيش حاجة بدوية بعد كل create

✅ **عاوز الـ message يكون مختلف لكل audience**
- الـ investors يـ شوفوا رسالة لطيفة بـ committee details
- الـ startups يـ شوفوا instructions مختلفة (مكان الـ pitch)

### 7.2 ما تستخدمش Automations لو:

❌ **عاوز رسالة one-off**
- مثلاً: "الجلسة دي اتأجلت لـ الأسبوع الجاي"
- اعمل dispatch يدوي من admin panel أو اعمل announcement (مش automation)

❌ **عاوز custom timing لكل committee**
- الـ reminder_minutes ثابت لكل الـ committees اللي بيشغّلها الـ automation
- لو committee واحدة محتاجة reminder 6 ساعات وأخرى 30 دقيقة، تحتاج 2 automation منفصلة (مش practical)

❌ **محتاج dynamic content مش معتمد على الـ placeholders الموجودة**
- الـ placeholders limited لـ COMMITTEE_TRIGGERS constant
- لو محتاج حاجة مش موجودة فيهم، الـ template هتبقى مش مرنة

❌ **في عملية testing أو dry-run**
- لو enabled، الـ scanner هيـ run live ويـ send فعلاً
- استخدم staging environment للـ test

### 7.3 الـ defaults: ليه disabled by default؟

كل الـ 4 automations مزروعة بـ `enabled = FALSE`. السبب:

> "أمان أولًا" — لو يـ deploy على فولاذي ناصب على production من غير الأدمن ما يـ configure الـ message + الـ channels، الـ system مش هيـ spam الناس برسائل فاضية أو placeholder defaults.

الأدمن لازم يفتح `/admin/automations/{7|8|9|10}/edit` و:
1. يـ enable
2. يحدد الـ channels (email / sms / whatsapp / notification)
3. يكتب الـ template strings
4. يحدد `reminder_minutes` (للـ reminders 7 + 8)
5. يحفظ

---

## 8. الـ Cron Schedulers — متى وكيف بيـ run

### 8.1 الـ Scheduler Config

موقعها في `routes/console.php` (lines 104-136):

```php
// Reminder scan — كل دقيقة
Schedule::command('automations:scan-committee-reminders')
    ->everyMinute()
    ->withoutOverlapping(120)
    ->runInBackground();

// Expiration sweep — يوميًا في 00:05 Saudi time
Schedule::command('committees:expire-past')
    ->dailyAt('00:05')
    ->timezone(\App\Support\Time\BookingsTimezone::business())
    ->withoutOverlapping(120)
    ->runInBackground();
```

### 8.2 Scanner تفصيل — `automations:scan-committee-reminders`

**الكود:** `app/Console/Commands/Automations/ScanCommitteeRemindersCommand.php`

```
كل دقيقة:
   ↓
1. Load كل الـ automations enabled بـ trigger_key = 'committee_reminder'
   (عادة 2: Automation #7 + Automation #8)
   ↓
2. لكل automation:
    │
    ├─ احسب الـ window:
    │    minutes = automation.reminder_minutes  (min 1, default 60)
    │    windowStart = now() + (minutes - 2)
    │    windowEnd   = now() + minutes
    │
    ├─ Query: Committee::active()
    │           ->whereRaw("TIMESTAMP(meeting_date, meeting_time)
    │                        BETWEEN ? AND ?", [windowStart, windowEnd])
    │
    ├─ Chunk by 50:
    │     لكل committee:
    │        - Re-check status === 'active' (defensive)
    │        - audience switch:
    │            - investor → committee->investors()->get()
    │            - startup  → committee->startups()->get()
    │        - لكل recipient:
    │            - Build dedupe_key
    │            - UPSERT AutomationDispatch row (skip if duplicate)
    │            - Call AutomationNotificationService::dispatch(
    │                  $automation, $recipient, null, $committee
    │              )
    │            - Update dispatch.status = dispatched / skipped
   ↓
3. Log: "Queued N reminders"
```

**Window padding:** الـ window 2 دقيقة بدل نقطة واحدة. لو scanner ميس tick (مثلاً queue worker بطيء)، الـ scan اللي بعدها لسه يلحقها قبل ما الـ tick window يـ pass. والـ dedupe_key UNIQUE index بيمنع الـ duplicate sends.

**Timezone:** الـ scanner بيـ project `now()` إلى business timezone قبل الـ window calculation. الـ committee meeting times stored as bare TIME (no TZ)، فلازم projection.

### 8.3 الـ Expiration — `committees:expire-past`

**الكود:** `app/Console/Commands/Committees/ExpireCommitteesCommand.php`

```php
public function handle(): int
{
    $today = Carbon::now(BookingsTimezone::business())->toDateString();

    $affected = Committee::query()
        ->where('status', Committee::STATUS_ACTIVE)
        ->whereDate('meeting_date', '<', $today)
        ->update([
            'status'     => Committee::STATUS_EXPIRED,
            'updated_at' => now(),
        ]);

    return self::SUCCESS;
}
```

**اللي مهم:**
- ⏰ يـ run يوميًا في **00:05 Saudi time** (مش UTC).
- 5 دقايق بعد منتصف الليل = ضمان إن "اليوم" استقر قبل ما يـ tick الـ reminder scanner.
- ⚙️ Idempotent — لو يـ run تاني نفس اليوم، مفيش تغيير.
- 🎯 condition: `meeting_date < today` (مش `<=` — committee اليوم لسه active).
- ❌ No event emitted على الـ expiration — silent state change.

### 8.4 الـ ordering بين الـ schedulers

```
00:05 (Saudi)
   ↓
[committees:expire-past]                ← expires yesterday's committees
   ↓
00:05:30 (Saudi)
   ↓
[automations:scan-committee-reminders]  ← scans for reminders
   - active() scope skips just-expired committees ✓
   - re-check inside chunk catches mid-loop expirations ✓
```

**النتيجة:** المستثمر مش هياخد reminder عن اجتماع امبارح.

---

## 9. الـ Public Portal — `/committee-attendance`

### 9.1 Routes (كلها public — no auth middleware)

```
GET    /committee-attendance              → phone-entry form
POST   /committee-attendance/send-otp     → eligibility + SMS
POST   /committee-attendance/verify-otp   → verify code, populate session
POST   /committee-attendance/resend-otp   → re-send via stashed phone
GET    /committee-attendance/portal       → main portal (session-gated)
GET    /committee-attendance/startups/{id} → startup detail (session-gated)
POST   /committee-attendance/attendance   → toggle self attendance
POST   /committee-attendance/startups/{id}/note → save note
POST   /committee-attendance/logout       → clear session
```

### 9.2 الـ Eligibility Chain (4 checks)

كل step بيـ run قبل ما يـ accept الـ OTP أو يـ allow portal access:

```
1. الـ phone موجود في investors table؟
   لو لا → log: 'phone_not_found' (with null committee_id, null investor_id)
   
2. الـ investor عنده category_option_id = "C-Committee"?
   لو لا → log: 'not_c_committee_category'
   
3. في active committee الشهر ده (meeting_month_key = YYYY-MM)?
   لو لا → log: 'no_active_committee_this_month'
   
4. الـ investor في الـ committee_investors pivot؟
   لو لا → log: 'not_assigned_to_committee'
```

### 9.3 Session Keys

```php
'committee_attendance.investor_id'   // populated بعد OTP verify
'committee_attendance.committee_id'  // populated بعد OTP verify
'committee_attendance.phone'         // populated في send-otp قبل verify
'otp.purpose'                        // 'committee_access' (لـ disambiguation عن meeting booking)
```

### 9.4 الـ Re-validation pattern

كل request gated بـ session بيـ re-run الـ eligibility (في `resolveSession()`). يعني:
- لو الأدمن شال الـ investor من committee → next click يـ revoke
- لو الأدمن غيّر category → next click يـ revoke
- لو الـ committee اتـ expire → next click يـ revoke

ده **secure by default** — مفيش way الـ session يـ stay زي ما هو لو الـ eligibility changed.

### 9.5 OTP Service Reuse

الـ portal بيستخدم `MeetingBookingOtpService` (نفس الـ service بتاع meeting booking):
- ✅ Rate limiting per phone
- ✅ Hashed code storage
- ✅ Attempt tracking
- ✅ Logs to `otp_logs` table

الـ session.otp.purpose بيـ disambiguate بين الـ flows. أي committee-specific events بـ تتـ log كمان في `committee_attendance_logs`.

---

## 10. خريطة الفايلات

### 10.1 الـ Core

```
app/Models/
    Committee.php                            ← الـ main entity + scopes + helpers
    CommitteeSlot.php                        ← startup slot model
    CommitteeAttendance.php                  ← investor attendance (dual track)
    CommitteeStartupAttendance.php           ← startup attendance (admin only)
    CommitteeAttendanceLog.php               ← audit ledger
    CommitteeEvaluation.php                  ← member voting matrix
    CommitteeInvestorStartupNote.php         ← member notes per startup
    Automation.php                           ← الـ automation rules
    AutomationDispatch.php                   ← dispatch tracking + dedupe

app/Http/Controllers/Admin/
    CommitteeController.php                  ← الأدمن CRUD + show + attendance + evaluations
    AutomationController.php                 ← /admin/automations/{id}/edit

app/Http/Controllers/Web/
    CommitteeAttendanceController.php        ← public portal

app/Http/Requests/Admin/
    CommitteeRequest.php                     ← form validator (store + update)

app/Services/Committees/
    CommitteeAttendanceService.php           ← shared transition logic
    ... (helpers for eligibility, transitions)
```

### 10.2 Cron commands

```
app/Console/Commands/Committees/
    ExpireCommitteesCommand.php              ← daily 00:05

app/Console/Commands/Automations/
    ScanCommitteeRemindersCommand.php        ← every minute
    ScanMeetingRemindersCommand.php          ← every minute (different — not committee)
    ScanOnboardingAutomationsCommand.php     ← every minute (different)
```

### 10.3 Events + Listeners

```
app/Events/Committees/
    CommitteeCreated.php                     ← fired after committee store()

app/Listeners/Committees/
    DispatchCommitteeCreationAutomations.php ← finds enabled committee_created automations
```

### 10.4 Migrations + Seeders

```
database/migrations/
    2026_06_02_100000_create_committees_tables.php
        → committees, committee_investors, committee_startups (legacy)
        → seeds automation IDs 7, 8 (reminder)
    2026_06_02_110000_create_committee_attendances_tables.php
        → committee_attendances + log + startup_attendances
    2026_06_04_100000_create_committee_investor_startup_notes_table.php
    2026_06_05_100000_add_status_to_committees.php
    2026_06_05_100100_create_committee_slots_table.php
        → drops legacy committee_startups, migrates to slots
    2026_06_05_100200_drop_legacy_committee_startups.php
    2026_06_05_100300_seed_committee_creation_automations.php
        → seeds automation IDs 9, 10 (creation)
    2026_06_06_100000_add_notes_to_committees.php
    2026_06_06_100100_create_committee_evaluations_table.php

database/seeders/
    AutomationsSeeder.php  (only if a fresh seed run is needed)
```

### 10.5 Views

```
resources/views/admin/committees/
    index.blade.php                  ← list view with filters
    create.blade.php                 ← form (also handles edit via shared partial)
    edit.blade.php
    show.blade.php                   ← detailed view + attendance + evaluations
    partials/
        slot-builder.blade.php       ← drag-drop slots
        attendance-table.blade.php   ← investor + startup tracks
        evaluations-matrix.blade.php ← pass/fail grid
        notes-card.blade.php

resources/views/web/committee-attendance/
    phone-form.blade.php             ← phone entry
    otp-form.blade.php               ← OTP code entry
    portal.blade.php                 ← main investor portal
    startup-detail.blade.php         ← startup detail page
```

### 10.6 Routes

```
routes/web.php
    lines 200-220 (approximately):
        Route::prefix('admin')->middleware(['admin.auth'])->group(function () {
            Route::resource('committees', CommitteeController::class);
            Route::patch('committees/{committee}/notes', ...);
            Route::patch('committees/{committee}/evaluations', ...);
            Route::patch('committees/{committee}/investors/{investor}/attendance', ...);
            Route::patch('committees/{committee}/startups/{startup}/attendance', ...);
        });
    
    lines 300-320 (approximately):
        Route::prefix('committee-attendance')->group(function () {
            Route::get('/', [CommitteeAttendanceController::class, 'start']);
            Route::post('/send-otp', [..., 'sendOtp']);
            // ... etc
        });
```

---

## 11. الـ Translation Keys

في `lang/en/admin.php` و `lang/ar/admin.php`:

```php
'committees' => [
    'title' => 'Committees',
    'create_title' => 'Create Committee',
    'show_title' => 'Committee Details',
    'cols' => [
        'meeting_date', 'meeting_time', 'meeting_month',
        'status', 'investors_count', 'startups_count',
        'created_by', 'actions',
    ],
    'status' => [
        'active' => 'Active',
        'expired' => 'Expired',
    ],
    'attendance' => [
        'self_status' => [...],
        'admin_status' => [...],
    ],
    'evaluations' => [
        'pass', 'fail',
        'summary' => '{n} pass / {m} fail',
    ],
    'flash' => [
        'created' => 'Created committee for :month',
        'updated' => 'Updated committee for :month',
        'deleted' => 'Deleted committee for :month',
    ],
],
```

---

## 12. كيفية التعديل (Maintenance Patterns)

### 12.1 إضافة status جديد للـ Committee

```php
// 1. في Committee.php ضيف constant:
const STATUS_DRAFT = 'draft';

// 2. اعمل migration:
return new class extends Migration {
    public function up(): void {
        // ALTER بـ comment update فقط (الـ column varchar (16) بالفعل)
    }
};

// 3. ضيف scope:
public function scopeDraft($q) { return $q->where('status', self::STATUS_DRAFT); }

// 4. في translation files ضيف:
'status' => ['draft' => 'Draft', ...]

// 5. UI: index page بـ status filter chip
```

### 12.2 إضافة Automation جديدة لـ Committee event

```php
// 1. في Automation.php ضيف:
const TRIGGER_COMMITTEE_CANCELLED = 'committee_cancelled';

// أضفها لـ COMMITTEE_TRIGGERS لو محتاج committee placeholders:
const COMMITTEE_TRIGGERS = [
    self::TRIGGER_COMMITTEE_REMINDER,
    self::TRIGGER_COMMITTEE_CREATED,
    self::TRIGGER_COMMITTEE_CANCELLED,  // ← الجديدة
];

// 2. اعمل migration يـ seed automation rows (audience: investor + startup):
DB::table('automations')->insert([
    [
        'audience' => 'investor', 'type' => 'creation',
        'trigger_key' => 'committee_cancelled',
        'name_en' => 'Committee Cancelled',
        'name_ar' => 'إلغاء اللجنة',
        'enabled' => false,
        ...
    ],
    // نفس الشيء لـ startup
]);

// 3. اعمل event:
class CommitteeCancelled {
    public function __construct(public Committee $committee) {}
}

// 4. اعمل listener:
class DispatchCommitteeCancellationAutomations {
    public function handle(CommitteeCancelled $event): void {
        $automations = Automation::query()
            ->enabled()
            ->where('trigger_key', Automation::TRIGGER_COMMITTEE_CANCELLED)
            ->get();
        // ... dispatch لكل recipient
    }
}

// 5. اربط في EventServiceProvider:
protected $listen = [
    CommitteeCancelled::class => [
        DispatchCommitteeCancellationAutomations::class,
    ],
];

// 6. fire الـ event حيث تحتاج (مثلاً في destroy()):
event(new CommitteeCancelled($committee));
```

### 12.3 إضافة Reminder window جديدة

طالما الـ Automation #7 / #8 الموجودة، الأدمن يقدر يتحكم بدون كود:

1. افتح `/admin/automations/7/edit`
2. غيّر `reminder_minutes` (مثلاً من 60 لـ 1440 لـ reminder قبل 24 ساعة)
3. Save → الـ scanner التالي يستخدم القيمة الجديدة

**لو محتاج 2 reminders لنفس الـ committee** (مثلاً 24h + 1h):

❌ مش ممكن من نفس الـ automation
✅ اعمل migration يـ duplicate الـ automation row بـ ID جديد + reminder_minutes مختلف

```php
DB::table('automations')->insert([
    [
        'audience' => 'investor', 'type' => 'reminder',
        'trigger_key' => 'committee_reminder',
        'name_en' => 'Committee Reminder (24h)',
        'reminder_minutes' => 1440,
        // ... rest of fields
    ],
]);
```

الـ dedupe_key بيـ enforce uniqueness per (automation, recipient, committee) — يعني الـ 24h reminder والـ 1h reminder بـ يـ fire مرة واحدة كل واحد (مفيش collision).

### 12.4 إضافة Field جديد للـ Committee

```
1. Migration:
   Schema::table('committees', function ($t) {
       $t->string('zoom_url')->nullable()->after('meeting_url');
   });

2. Model: ضيف في $fillable.

3. CommitteeRequest: ضيف validation rule.

4. Views: ضيف input + display.

5. (لو محتاج في الـ portal): pass للـ portal view.
```

### 12.5 منع expiration on certain committees

لو محتاج "permanent" committees ما تـ expire:

```php
// في ExpireCommitteesCommand::handle() ضيف whereNull('preserve_status'):
$affected = Committee::query()
    ->where('status', Committee::STATUS_ACTIVE)
    ->whereDate('meeting_date', '<', $today)
    ->whereNull('preserve_status')  // ← skip protected committees
    ->update(...);
```

ولازم تضيف `preserve_status` column في migration.

---

## 13. Troubleshooting Playbook

### 13.1 الـ Reminder مش بيتبعت

**الفحص:**

```bash
# 1. هل الـ automation enabled؟
php artisan tinker --execute='
$a = \App\Models\Automation::find(7);
echo "enabled=" . ($a->enabled ? "yes" : "NO") . "\n";
echo "channels=" . json_encode($a->channels) . "\n";
echo "reminder_minutes=" . $a->reminder_minutes . "\n";
'

# 2. هل الـ committee active؟
php artisan tinker --execute='
$c = \App\Models\Committee::find(5);
echo "status=" . $c->status . "\n";
echo "meeting=" . $c->meeting_date . " " . $c->meeting_time . "\n";
'

# 3. هل الـ scanner شغّال؟
tail -f storage/logs/laravel.log | grep "scan-committee-reminders"

# 4. هل اتعمل dispatch؟
php artisan tinker --execute='
foreach (\App\Models\AutomationDispatch::where("automation_id", 7)->latest()->limit(5)->get() as $d) {
    echo $d->id . " | " . $d->status . " | " . $d->dispatched_at . "\n";
}
'
```

**الحلول الشائعة:**

| المشكلة | الحل |
|---|---|
| `enabled = false` | افتح `/admin/automations/7/edit` و enable |
| `channels = []` | ضيف channel على الأقل |
| `reminder_minutes = 0` | غيّرها (min 1) |
| Committee `status = expired` | غيّرها لـ active في DB لو الموعد لسه ما جاش |
| Dispatch موجود بـ `status = skipped` | معناه الـ notification service رفضها (مثلاً phone مش valid) — شوف الـ logs |

### 13.2 الـ Reminder بيتبعت أكتر من مرة

**السبب المحتمل:**
- dedupe_key UNIQUE constraint ما اشتغلش (DB migration ميس)

**الفحص:**

```sql
SHOW INDEXES FROM automation_dispatches WHERE Key_name LIKE '%dedupe%';
```

لازم يطلع UNIQUE index على `dedupe_key`. لو مش موجود:

```bash
php artisan migrate:status | grep automation_dispatches
# لو في migration pending، شغّله:
php artisan migrate --force
```

### 13.3 الـ Creation Automation مش بـ يـ fire

**الفحص:**

```bash
# 1. EventServiceProvider لازم يربط الـ event + listener
grep -A 5 "CommitteeCreated" app/Providers/EventServiceProvider.php

# 2. الـ event بـ يـ fire بعد الـ transaction
grep -A 2 "CommitteeCreated" app/Http/Controllers/Admin/CommitteeController.php

# 3. الـ automation enabled:
php artisan tinker --execute='
foreach (\App\Models\Automation::where("trigger_key", "committee_created")->get() as $a) {
    echo $a->id . " | enabled=" . ($a->enabled ? "yes" : "NO") . "\n";
}
'
```

**ملاحظة:** الـ event بـ fire **بعد** الـ transaction commit. لو الـ transaction فشل (مثلاً validation error)، الـ event مش هـ يـ fire. ده intentional.

### 13.4 المستثمر مش بيـ login في الـ portal

شوف `committee_attendance_logs`:

```bash
php artisan tinker --execute='
foreach (\App\Models\CommitteeAttendanceLog::latest()->limit(10)->get() as $l) {
    echo $l->action . " | committee=" . ($l->committee_id ?? "null") . " | investor=" . ($l->investor_id ?? "null") . " | ctx=" . $l->context . "\n";
}
'
```

**Actions اللي تـ indicate eligibility failure:**

| Action | المعنى | الحل |
|---|---|---|
| `phone_not_found` | الـ phone مش في DB | اتأكد إن الـ phone متسجل |
| `not_c_committee_category` | الـ category مش C-Committee | غيّر الـ category |
| `no_active_committee_this_month` | الـ committee الشهر ده مش active | اعمل committee أو فعّلها |
| `not_assigned_to_committee` | المستثمر مش في الـ roster | ضيفه |

### 13.5 الـ Expiration مش بيشتغل

**الفحص:**

```bash
# 1. Scheduler شغّال؟
php artisan schedule:list | grep expire-past

# 2. الـ DB time = business time؟
php artisan tinker --execute='
echo "DB now: " . \DB::selectOne("SELECT NOW() as n")->n . "\n";
echo "Business: " . now(\App\Support\Time\BookingsTimezone::business()) . "\n";
'

# 3. هل في committees قديمة لسه active؟
php artisan tinker --execute='
$today = \Carbon\Carbon::now(\App\Support\Time\BookingsTimezone::business())->toDateString();
$count = \App\Models\Committee::where("status","active")->whereDate("meeting_date","<",$today)->count();
echo "Stuck-active committees: $count\n";
'

# 4. الـ command نفسه يـ run:
php artisan committees:expire-past --verbose
```

---

## 14. الـ Gotchas (مهم جدًا)

### 14.1 الـ Teams meeting URL hardcoded

في `CommitteeController::create()`:

```php
'meeting_url' => 'https://teams.microsoft.com/meet/34406262494755?p=46cnNgyqUmb8ih0cbr',
```

ده URL ثابت لـ Teams room واحدة. **لو الـ room اتغيّرت أو اتـ delete، كل committee جديد هياخد URL غلط.**

**الحل المقترح لمستقبل:** خليها configurable في `config/committees.php` أو في DB setting.

### 14.2 Soft delete + UNIQUE constraint conflict

الـ UNIQUE على `meeting_month_key` مش soft-delete aware في الـ DB. لو committee اتـ soft-delete، نفس الشهر مش هـ يـ allow تاني (الـ DB UNIQUE لسه شايف الـ row).

الـ controller validator بـ يحاول يحل ده بـ `whereNull('deleted_at')` بس الـ DB لسه هترفض الـ insert.

**الحل المؤقت:** force-delete الـ soft-deleted committee قبل ما تعمل واحدة جديدة لنفس الشهر.

### 14.3 الـ event بـ يـ fire على store() فقط

الـ `CommitteeCreated` event مش بـ يـ fire على `update()`. لو غيّرت الـ meeting_date لشهر تاني، الـ investors **مش هـ ياخدوا notification جديد**.

ده intentional عشان مفيش spam، بس ممكن يبقى confusing.

### 14.4 Re-validation كل request في الـ portal

الـ portal session بـ تـ re-check eligibility كل request. ده secure بس بيـ add overhead. لو الـ portal scaled لـ آلاف الـ users، ممكن نـ add caching for the eligibility check (with short TTL).

### 14.5 الـ Window 2-minute padding في الـ scanner

```
windowStart = now() + (reminder_minutes - 2)
windowEnd   = now() + reminder_minutes
```

الـ 2 دقيقة كـ padding عشان لو scanner ميس tick. بس لو الـ DB clock differs من الـ application clock بـ > 2 دقيقة، الـ reminders ممكن تـ miss أو تـ fire قبل وقتها.

**الحل:** اتأكد إن الـ DB + application servers بيستخدموا NTP-synced clocks.

### 14.6 No retry logic للـ failed notifications

لو الـ AutomationNotificationService فشل (مثلاً SMS provider down)، الـ dispatch بـ يـ mark `status = failed`. **مفيش auto-retry**. الأدمن لازم يدخل يدويًا أو اعمل cleanup script.

### 14.7 لو الـ scheduler مش شغّال أصلاً

كل اللي فات بيعتمد على إن `php artisan schedule:work` شغّال (أو cron entry يـ trigger `schedule:run` كل دقيقة).

```bash
# Check:
ps aux | grep schedule
# أو:
crontab -l | grep schedule
```

لو مش شغّال، **ولا automation هـ تـ fire**.

### 14.7 (continued) — Production setup

```cron
* * * * * cd /path/to/project && php artisan schedule:run >> /dev/null 2>&1
```

ده الـ standard Laravel cron entry. لازم يكون set up على production.

---

## 15. Quick Reference Commands

```bash
# ─── Inspect ──────────────────────────────────────

# Show all 4 committee automations status
php artisan tinker --execute='
foreach ([7,8,9,10] as $id) {
    $a = \App\Models\Automation::find($id);
    if ($a) {
        echo "#$id [" . $a->audience . "/" . $a->type . "] enabled=" . ($a->enabled?"Y":"N") .
             " channels=" . json_encode($a->channels) .
             " reminder_min=" . ($a->reminder_minutes ?? "-") . "\n";
    }
}'

# Show all active committees + their status
php artisan tinker --execute='
foreach (\App\Models\Committee::active()->get() as $c) {
    echo "#$c->id meeting=" . $c->meeting_date . " " . $c->meeting_time .
         " investors=" . $c->investors()->count() .
         " startups=" . $c->startups()->count() . "\n";
}'

# Show recent automation dispatches for committees
php artisan tinker --execute='
foreach (\App\Models\AutomationDispatch::whereIn("automation_id", [7,8,9,10])
        ->latest()->limit(10)->get() as $d) {
    echo "#$d->id auto=$d->automation_id status=$d->status " .
         "dispatched_at=" . ($d->dispatched_at ?: "null") . "\n";
}'

# ─── Force ────────────────────────────────────────

# Run reminder scanner manually
php artisan automations:scan-committee-reminders --verbose

# Run expiration sweep manually
php artisan committees:expire-past --verbose

# Force-expire a specific committee (bypass cron)
php artisan tinker --execute='
$c = \App\Models\Committee::find(5);
$c->status = "expired";
$c->save();
'

# Re-activate an expired committee (manual)
php artisan tinker --execute='
$c = \App\Models\Committee::find(5);
$c->status = "active";
$c->save();
'

# ─── Cleanup ──────────────────────────────────────

# Reset all dispatches for a committee (for re-testing)
php artisan tinker --execute='
\App\Models\AutomationDispatch::where("context->committee_id", 5)->delete();
'

# Force-delete a soft-deleted committee (frees up the month)
php artisan tinker --execute='
\App\Models\Committee::withTrashed()->find(5)->forceDelete();
'
```

---

## 16. ملخص — متى نستخدم الفيتشر دي

| السيناريو | استخدم Committee module؟ |
|---|---|
| اجتماع شهري ثابت يضم مستثمرين + startups | ✅ نعم — الـ feature build for this |
| اجتماع one-off غير منتظم | ❌ استخدم MS Bookings مباشرة |
| Workshop / Webinar مفتوح للجميع | ❌ استخدم announcement أو newsletter |
| الـ startups محتاجة تعرض لـ panel أكتر من شهرى مرة | ❌ Workflow غير ده — استخدم events module |
| محتاج لايف voting | ✅ الـ evaluations matrix يدعم Pass/Fail |
| محتاج تذكير قبل الاجتماع | ✅ Automations 7 + 8 + cron scanner |
| محتاج tracking للحضور | ✅ dual-track attendance |

---

**End of documentation.**

> 📅 **آخر مراجعة:** 2026-06-09
> 🔧 **يطلب تحديث لما:** تتغيّر الـ automation IDs، يتـ add new committee event types، أو يتـ change الـ portal flow.
