# دليل مطوّر تطبيقات أوكتا

> هذا الدليل يخاطب المطوّرين الذين يبنون تطبيقاً يتكامل مع منصة أوكتا.
> يفترض إلمام أساسي بـ HTTP/JSON وبأي إطار خادم لاختيارك (Laravel،
> Node، Python، إلخ).

## فهرس

1. [أنواع التكامل](#أنواع-التكامل)
2. [البدء السريع](#البدء-السريع)
3. [نظام النطاقات (Scopes)](#نظام-النطاقات-scopes)
4. [دورة حياة التطبيق](#دورة-حياة-التطبيق)
5. [التطبيق المدمج (Embedded)](#التطبيق-المدمج-embedded)
6. [توسيع ملف الطالب (Student Profile)](#توسيع-ملف-الطالب-student-profile)
7. [التطبيق الخارجي (External)](#التطبيق-الخارجي-external)
8. [تطبيقات الإشعار (Notification)](#تطبيقات-الإشعار-notification)
9. [الـ Manifest](#الـ-manifest)
10. [الـ API](#الـ-api)
11. [الـ Webhooks](#الـ-webhooks)
12. [كتالوج الإشعارات](#كتالوج-الإشعارات)
13. [نُهج الأمان](#نهج-الأمان)
14. [الاختبار محلياً](#الاختبار-محلياً)
15. [نظام التصميم وواجهة المستخدم](#نظام-التصميم-وواجهة-المستخدم)
16. [دعم الذكاء الاصطناعي](#دعم-الذكاء-الاصطناعي)
17. [تطبيق الجوال (Okta Mobile)](#تطبيق-الجوال-okta-mobile)
18. [مثال: تطبيق تشغيل مراكز الرعاية النهارية (External)](#مثال-تطبيق-تشغيل-مراكز-الرعاية-النهارية-external)
19. [أسئلة متكررة](#أسئلة-متكررة)

---

## أنواع التكامل

عند إنشاء تطبيق جديد ستُسأل أوّل شيء عن **نوع التكامل**. القرار يحدد
بقية الرحلة:

### Embedded (مدمج)

التطبيق يُشحن داخل okta-web ككود ويعمل في نفس الـ runtime.

| السمة | القيمة |
|---|---|
| مكان الاستضافة | داخل أوكتا |
| الواجهة | Livewire/Blade تظهر في لوحة المستأجر |
| التواصل | استدعاء داخلي عبر `App\Services\PartnerApi\*` |
| المستودع | Git repo خاص بك مع boilerplate جاهز |
| الفائدة | تجربة سلسة للمستأجر، أداء عالٍ |
| العيب | يلزمك التزام صارم بقواعد العزل |

### External (خارجي)

التطبيق مستضاف لديك ويتفاعل مع أوكتا حصراً عبر HTTP.

| السمة | القيمة |
|---|---|
| مكان الاستضافة | عند الشريك |
| الواجهة | تطبيقك الخاص (Web/Mobile/Server-only) |
| التواصل | REST API + Webhooks |
| المستودع | لا حاجة لمستودع كود |
| الفائدة | حرية كاملة في اختيار الـ stack |
| العيب | ضرورة إدارة استضافة + أمان webhooks |

### Notification (مزوّد إشعار)

تطبيق إشعار قابل للتوصيل (WhatsApp / SMS / Push / Slack / ...) تستهلكه
المنصة عبر واجهة موحَّدة `send(recipient, message)` بصرف النظر عن قناة
التسليم.

| السمة | القيمة |
|---|---|
| مكان الاستضافة | عند الشريك (api) أو داخل أوكتا (embedded) أو الاثنين (hybrid) |
| الواجهة | لا يوجد UI للمستخدم — تطبيق "خادم فقط" يُستدعى من بقية المنصة |
| التواصل | استدعاء موحَّد من المنصة لكل recipient |
| الفائدة | يضيف قناة جديدة (مثل WhatsApp Cloud أو Twilio) دون تعديل الـ core |
| العيب | المنصة تُحدّد العقد — لا تستطيع توسعته بمفردك |

ثلاث طرق تسليم:
- **`api`** — أوكتا تُرسل `POST` إلى endpoint عندك مع HMAC.
- **`embedded`** — تشحن class داخل okta-web يُطبِّق `PartnerNotificationProvider`.
- **`hybrid`** — embedded بصفته الأساس، يتراجع لـ api على فشل.

> **خبرة عملية:** التطبيق المدمج هو الأنسب للميزات التي تشعر أنها
> "جزء من أوكتا" (مثل واجهة تقارير مخصصة). التطبيق الخارجي هو الأنسب
> لما يحتاج لخدمة سحابية مستقلة (مثل ربط مع نظام شركة موجود لديك).
> تطبيق الإشعار خيار مستقل عن الاثنين — لا يضيف صفحة، فقط قناة قابلة
> للاستهلاك من باقي المنصة.

---

## البدء السريع

### 1. أنشئ حساب شريك

سجّل من بوّابة الشركاء وأكمل بيانات الشركة. ستحصل على tenant خاص بك
يستضيف كل تطبيقاتك.

### 2. أنشئ التطبيق الأول

من **التطبيقات → إنشاء تطبيق جديد**:

- اختر **نوع التكامل**.
- املأ المعلومات الأساسية (الاسم، الوصف، الفئة، الأيقونة).
- لـ Embedded: ستُربط GitHub لإنشاء مستودع جديد.
- لـ External: ستُدخل `webhook_url` و الأحداث المطلوبة.
- اختر **الصلاحيات** المطلوبة من كتالوج النطاقات.

### 3. اطلب المراجعة

عند جاهزية الإصدار، اضغط **إرسال للمراجعة**. الفريق التقني في أوكتا
يتحقق من الـ manifest، يشغّل policy scanner على المستودع (Embedded
فقط)، ويتحقق من الـ webhook URL (External). الردّ خلال 1-3 أيام عمل.

### 4. أُعيد المراجعة بنجاح

التطبيق يُنشر تلقائياً في متجر التطبيقات. كل مستأجر يستطيع تثبيته،
ويوافق على الصلاحيات المطلوبة، ويبدأ الاستخدام.

---

## نظام النطاقات (Scopes)

كل قطعة بيانات في أوكتا محمية بـ **scope** بصيغة:

```
<feature>.<resource>.<action>
```

أمثلة:

```
education.students.read       → قراءة الطلاب
education.students.write      → إضافة/تعديل الطلاب (لا حذف)
employees.directory.read      → قراءة الموظفين
reports.builder.read          → قراءة التقارير
```

### المبادئ الأساسية

1. **lowercase + dot-separated** بدون wildcards.
2. شريكاً يحصل فقط على `read` و `write`. **الحذف لا يُمنح أبداً**
   للشركاء.
3. الكتالوج هو المصدر الوحيد. لا تخترع scopes جديدة.
4. أوكتا يضيف نطاقات جديدة عبر تحديثات المنصة.

### كتالوج النطاقات

اطلب الكتالوج الكامل من:

```
GET /api/partners/permissions/catalog
```

يعيد جميع الـ scopes المتاحة مجمَّعة حسب feature → resource. هذا هو ما
تختار منه عند إنشاء تطبيقك.

### مرجع النطاقات التفصيلي

كل نطاق يفتح وصولاً لمجموعة محدّدة من نقاط نهاية الـPartner-app
runtime API، وكل نقطة نهاية تتحقق من الـscope تلقائياً عبر middleware
`app.scope:<feature>.<resource>.<action>`. الـURL الأساس: `/api/apps`.

> **ملاحظة عامة على المعرّفات**: كل المعرّفات في الاستجابات هي **ULIDs**
> (سلسلة مكوّنة من 26 حرفاً، Crockford base32). لا تكسِتها إلى integer
> ولا تقارنها بأرقام — boilerplate scanner يرفض الـPR لو حاول
> (`ulid-cast-to-int` و `ulid-numeric-comparison`). خزّنها كـ`string`
> في جداولك (نوع العمود `core_reference`).

---

#### `education.students` — الطلاب

| النطاق | الصلاحيات |
|--------|-----------|
| `education.students.read` | عرض قائمة الطلاب، عرض طالب واحد |
| `education.students.write` | + إضافة طالب، تعديل طالب (الحذف غير ممنوح) |

**نقاط النهاية**:

| Method | Path | Scope | الوصف |
|--------|------|-------|-------|
| GET | `/api/apps/education/students` | read | قائمة (مع pagination + فلترة بـ`grade_id`/`section_id`) |
| GET | `/api/apps/education/students/{student}` | read | طالب واحد بـULID |
| POST | `/api/apps/education/students` | write | إضافة طالب |
| PATCH | `/api/apps/education/students/{student}` | write | تعديل طالب |

**مثال Embedded (PHP)**:

```php
use App\Services\PartnerApi\Education\Students\ListStudents;
use App\Services\PartnerApi\Education\Students\GetStudent;

$page = app(ListStudents::class)(page: 1, perPage: 50);
foreach ($page->data as $student) {
    // $student هو StudentDto
    echo $student->id;          // ULID مثل "01H..."
    echo $student->name;
    echo $student->gradeId;     // ULID للصف
    echo $student->sectionId;   // ULID للشعبة
}

$one = app(GetStudent::class)('01HXXXXXXXXXXXXXXXXXXXXXXX');
```

**مثال External (HTTP)**:

```bash
curl -H "Authorization: Bearer $INSTALLATION_TOKEN" \
     -H "Accept: application/json" \
     "https://app.okta.platform/api/apps/education/students?page=1&perPage=50"
```

**حالات استخدام شائعة**: قوائم الفصول، إنشاء جداول الاختبارات، إصدار
شهادات، استيراد طلاب جدد من نظام الشريك.

---

#### `education.subjects` — المواد الدراسية

| النطاق | الصلاحيات |
|--------|-----------|
| `education.subjects.read` | عرض قائمة المواد، عرض مادة |
| `education.subjects.write` | + إضافة مادة، تعديل مادة |

**نقاط النهاية**:

| Method | Path | Scope |
|--------|------|-------|
| GET | `/api/apps/education/subjects` | read |
| GET | `/api/apps/education/subjects/{subject}` | read |
| POST | `/api/apps/education/subjects` | write (+ idempotent) |
| PATCH | `/api/apps/education/subjects/{subject}` | write |

**مثال**:

```php
use App\Services\PartnerApi\Education\Subjects\ListSubjects;

$page = app(ListSubjects::class)(page: 1, perPage: 100, gradeId: $gradeUlid);
// كل subject له ulid في .id و grade_id في .gradeId (كلاهما ULID)
```

**حالات استخدام شائعة**: ربط المواد بالمعلمين، تنظيم الجداول الزمنية،
إعداد الدرجات.

---

#### `education.grades` — الصفوف

| النطاق | الصلاحيات |
|--------|-----------|
| `education.grades.read` | عرض القائمة، عرض صف واحد |
| `education.grades.write` | + إضافة صف، تعديل صف |

**نقاط النهاية**:

| Method | Path | Scope |
|--------|------|-------|
| GET | `/api/apps/education/grades` | read |
| GET | `/api/apps/education/grades/{grade}` | read |
| POST | `/api/apps/education/grades` | write (+ idempotent) |
| PATCH | `/api/apps/education/grades/{grade}` | write |

**مثال**:

```php
use App\Services\PartnerApi\Education\Grades\ListGrades;

$page = app(ListGrades::class)(page: 1, perPage: 50, onlyActive: true);
foreach ($page->data as $grade) {
    echo $grade->id;        // ULID
    echo $grade->nameAr;
    echo $grade->order;
}
```

**حالات استخدام شائعة**: dropdowns لاختيار الصف، تصفية الطلاب،
تجميع التقارير حسب المرحلة.

---

#### `education.sections` — الشُعب

| النطاق | الصلاحيات |
|--------|-----------|
| `education.sections.read` | عرض القائمة، عرض شعبة |
| `education.sections.write` | + إضافة شعبة، تعديل شعبة |

**نقاط النهاية**:

| Method | Path | Scope |
|--------|------|-------|
| GET | `/api/apps/education/sections` | read |
| GET | `/api/apps/education/sections/{section}` | read |
| POST | `/api/apps/education/sections` | write (+ idempotent) |
| PATCH | `/api/apps/education/sections/{section}` | write |

**مثال**:

```php
use App\Services\PartnerApi\Education\Sections\ListSections;

$page = app(ListSections::class)(page: 1, perPage: 50, gradeId: $gradeUlid);
```

**حالات استخدام شائعة**: تنظيم الجلوس في الاختبارات، توزيع المعلمين،
طباعة كشوف الحضور.

---

#### `education.academic_years` — السنوات الدراسية

| النطاق | الصلاحيات |
|--------|-----------|
| `education.academic_years.read` | قراءة السنوات |
| `education.academic_years.write` | + إضافة وتعديل |

**حالة المسارات**: مُسجَّل في الكتالوج للاستخدام المستقبلي. نقاط نهاية
الـrun-time لم تُكشف بعد على `/api/apps/education/academic-years` —
ستُضاف في إصدار قادم. اطلب هذا الـscope إذا كنت تخطط لاستخدامه قريباً.

---

#### `education.terms` — الفصول الدراسية

| النطاق | الصلاحيات |
|--------|-----------|
| `education.terms.read` | قراءة الفصول |
| `education.terms.write` | + إضافة وتعديل |

**حالة المسارات**: مماثل للسنوات الدراسية أعلاه — مُسجَّل في الكتالوج،
نقاط النهاية ستُكشف لاحقاً.

---

#### `employees.directory` — الموظفون

| النطاق | الصلاحيات |
|--------|-----------|
| `employees.directory.read` | عرض القائمة، عرض موظف واحد |
| `employees.directory.write` | + إضافة وتعديل |

**نقاط النهاية**:

| Method | Path | Scope |
|--------|------|-------|
| GET | `/api/apps/employees/directory` | read |
| GET | `/api/apps/employees/directory/{employee}` | read |
| POST | `/api/apps/employees/directory` | write (+ idempotent) |
| PATCH | `/api/apps/employees/directory/{employee}` | write |

**مثال**:

```php
use App\Services\PartnerApi\Employees\Directory\ListEmployees;

$page = app(ListEmployees::class)(
    page: 1,
    perPage: 100,
    type: 'teacher',     // أو 'administrator', null للجميع
    onlyActive: true,
);
```

**حالات استخدام شائعة**: تعيين المعلمين على المواد، إصدار جداول
المراقبين، تطبيقات HR التعليمية.

---

#### `reports.builder` — التقارير

| النطاق | الصلاحيات |
|--------|-----------|
| `reports.builder.read` | عرض كتالوج التقارير وتشغيل تقرير |

**ملاحظة**: لا يوجد `reports.builder.write` — التقارير عبارة عن كتالوج
مُنسَّق من قِبَل أوكتا، لا يمكن للشركاء تعريف تقارير جديدة. أنت تستهلك
التقرير الموجود وتأخذ مخرجاته.

**نقاط النهاية**:

| Method | Path | Scope |
|--------|------|-------|
| GET | `/api/apps/reports/builder` | read |
| POST | `/api/apps/reports/builder/{key}/run` | read (+ idempotent) |

**مثال**:

```php
use App\Services\PartnerApi\Reports\Builder\ListReports;
use App\Services\PartnerApi\Reports\Builder\RunReport;

$catalog = app(ListReports::class)();
$result = app(RunReport::class)('students.attendance.monthly', [
    'gradeId' => $gradeUlid,
    'month'   => '2026-04',
]);
```

**حالات استخدام شائعة**: لوحات إحصائية، تصدير شهري للأهالي، تقارير
المعلمين الدورية.

---

### المعرّفات والـ`core_reference`

كل response يخرج من المنصة يستبدل numeric IDs بـULIDs. عند تخزين هذه
المعرّفات في جداول تطبيقك، استخدم نوع العمود الجديد في الـSchema
Designer:

```
core_reference (يشير إلى: students | subjects | grades | sections | tenants | tenant_employees | users)
```

هذا النوع يُترجَم إلى `string(26)->index()` (بدون FK constraint لأن
الجدول يعيش على okta-web). الـmanifest يصدّر `core_references` block
يصف هذه الأعمدة، وokta-partners يفحص صيغة كل ULID قبل أي insert/update.

**ممنوع**:
- `(int) $row->student_id_ulid` — كسته إلى integer.
- `where('student_id_ulid', 12345)` — مقارنته بـliteral رقمي.

كلاهما يُكشَف من boilerplate scanner ويفشل CI. خزّنه ومرّره دائماً
كـstring.

### تحديث الكتالوج محلياً (okta-partners)

الكتالوج يُحدَّث تلقائياً من okta-web عبر:

1. **Cron** كل ساعة (افتراضي).
2. **Webhook** فوري على `partner_scopes.catalog.changed`.
3. **يدوي**: `php artisan partners:sync-scope-catalog --force`.

تحديث محلي عبر hash drift detection — لو الـhash لم يتغيّر يتجاوز
المزامنة بالكامل.

---

## دورة حياة التطبيق

```
Draft  →  Submitted  →  In Review  →  Approved  →  Published
                              ↓
                          Rejected
```

- **Draft**: تكتب وتعدّل بحريّة.
- **Submitted**: أرسلتَ للمراجعة. لا يمكن التعديل.
- **In Review**: فريق أوكتا يفحص.
- **Approved**: تمت الموافقة. ستُنشر تلقائياً عند تشغيل النشر.
- **Published**: ظاهر في المتجر، يمكن تثبيته من المستأجرين.
- **Rejected**: تُعاد لـ Draft مع تعليقات لإصلاحها.

---

## التطبيق المدمج (Embedded)

### الـ boilerplate

عند إنشاء تطبيق Embedded، يُجهَّز لك مستودع GitHub جديد فيه:

```
.github/workflows/partner-module-policy.yml   ← CI policy scan
scripts/partner-policy/                       ← regex Scanner + AST PHPStan rule
app/                                          ← كود تطبيقك
config/<my-module>.php                        ← كل مفاتيحك تحت هذه الـ namespace
database/migrations/                          ← migrations لجداولك الخاصة
manifest.json
module.json
phpstan.neon                                  ← مع PHPStan rule جاهزة
```

### قواعد العزل

التطبيق المدمج يعيش داخل أوكتا، لكنه ممنوع من:

- ✗ استيراد `App\Models\*` (نماذج المنصة)
- ✗ استيراد `App\Services\*` ما عدا `App\Services\PartnerApi\*`
- ✗ قراءة env keys للمنصة (DB_*, REDIS_*, الخ)
- ✗ قراءة config المنصة (`database.*`, `services.*`، الخ)
- ✗ قراءة `.env` مباشرة

### الطريقة الصحيحة للوصول للبيانات

استخدم خدمات `PartnerApi` فقط:

```php
use App\Services\PartnerApi\Education\Students\ListStudents;

class MyController
{
    public function __invoke(ListStudents $list)
    {
        $page = $list(page: 1, perPage: 20);

        return view('my-module::dashboard', [
            'students' => $page->data,  // → list<StudentDto>
            'total'    => $page->total,
        ]);
    }
}
```

كل خدمة تتحقق من النطاق المطلوب آلياً قبل تنفيذ أي عملية. إذا كان
المستأجر لم يُعطِ تطبيقك الـ scope المطلوب، تحصل على
`MissingScopeException` (يتحوّل إلى 403).

### الجداول الخاصة بك

أنت تملك schema منفصل تماماً عن أوكتا. اسم الـ schema يُولَّد من slug
تطبيقك (`m_<my_module>`). لا يلامس وصولك جداول المنصة على الإطلاق
(PostgreSQL يفرض ذلك على مستوى الـ role).

---

## توسيع ملف الطالب (Student Profile)

يقدر تطبيقك **المُضمَّن** يضيف محتوى لصفحة ملف الطالب في okta-web **من الكود
مباشرة** — بدون أي إعداد في البوّابة. تكتب مكوّن Livewire داخل مجلد ثابت،
تضيف عليه Attribute واحد، والمنصة تكتشفه وتسجّله تلقائياً.

> **لتطبيقات Embedded فقط.** التطبيقات الخارجية تعرض بياناتها عبر
> HTTP/webhooks لا عبر هذا السجلّ.

### هيكلة المجلد (تُشحن جاهزة في الـ boilerplate)

```
Modules/<App>/app/StudentProfile/
├── Panels/    ← بطاقات كاملة (Panel)
├── Stats/     ← أرقام في الشريط العلوي (Stat)
└── Actions/   ← أزرار في الترويسة (Action)
```

أي مكوّن داخل هذه المجلدات يحمل الـ Attribute المناسب يُكتشف ويُسجَّل آلياً
عند الإقلاع — لا كود تسجيل تكتبه بنفسك.

### التعريف: Attribute على الكلاس

```php
namespace Modules\MyApp\app\StudentProfile\Panels;

use App\Support\StudentProfile\Attributes\StudentPanel;
use App\Support\StudentProfile\StudentProfileZone;
use Livewire\Component;

#[StudentPanel(
    title: 'ملخّص الحضور',
    permission: 'my_app.records.view',
    order: 50,
    zone: StudentProfileZone::Main,
)]
class AttendanceSummary extends Component
{
    public string $studentHashid;   // الوحيد المُمرَّر — لا id رقمي

    public function render()
    {
        return view('my-app::student-profile.attendance-summary');
    }
}
```

- `key` و `component` **يُشتقّان تلقائياً** من الكلاس (لا تكتبهما).
- يكفي `title`/`label` + `permission`؛ الباقي اختياري.

### قواعد إلزامية

- استخدم **`studentHashid` فقط** — لا تمرّر id رقمياً أبداً.
- كل مساهمة لها **`permission`** بصيغة `<feature>.<resource>.<action>`.
- الآلية **Embedded فقط**.

### الأنواع الأربعة (معاملات الـ Attribute)

#### 1) `#[StudentPanel]` — بطاقة كاملة

| المعامل | إلزامي | الوصف |
|---|---|---|
| `title` | ✓ | عنوان البطاقة |
| `permission` | ✓ | صلاحية العرض |
| `order` | — | الترتيب (افتراضي `100`، الأصغر أولاً) |
| `zone` | — | `StudentProfileZone::Main` (العمود العريض) أو `::Sidebar` |
| `icon` | — | أيقونة |
| `description` | — | وصف مختصر |
| `key` | — | تجاوز المفتاح المُشتق |

#### 2) `#[StudentStat]` — رقم في الشريط العلوي

| المعامل | إلزامي | الوصف |
|---|---|---|
| `label` | ✓ | التسمية |
| `permission` | ✓ | صلاحية العرض |
| `order` | — | الترتيب |
| `icon` | — | أيقونة |
| `color` | — | `primary`/`success`/`warning`/`danger`/... |

المكوّن نفسه هو الـ widget الحيّ الذي يعرض القيمة.

#### 3) `#[StudentAction]` — زر في الترويسة

| المعامل | إلزامي | الوصف |
|---|---|---|
| `label` | ✓ | نص الزر |
| `permission` | ✓ | صلاحية العرض |
| `url` **أو** `event` | ✓ | فتح رابط أو بثّ حدث |
| `params` | — | وسائط الحدث (مع `event` فقط) |
| `order` | — | الترتيب |
| `variant` | — | `primary`/`secondary`/`danger`/... |
| `icon` | — | أيقونة |

`url` له الأولوية على `event`. الحدث يُبَثّ بـ Alpine `$dispatch` ويلتقطه
Livewire عبر `#[On]` أو `wire-elements` (`openModal`).

#### 4) الزون (Zone)

- **`Main`** — العمود العريض في وسط الصفحة.
- **`Sidebar`** — العمود الجانبي.

### الكروم الموحّد للبلوكات (`<x-profile-block>` / `<x-profile-stat>`)

صفحة ملف الطالب **لوحة بلوكات موحّدة**: كل بطاقة تُعرض داخل **كروم تملكه المنصة**
يُظهر **مصدرها** (تطبيقك) ولونه المميّز و**حالة المزامنة** وآخر تحديث ورابط «فتح في
التطبيق». لتندمج بطاقتك بصرياً مع بقية اللوحة، **لفّ محتوى الـ Panel بـ
`<x-profile-block>`** (بدل `<x-card>`):

```blade
{{-- my-app::student-profile.attendance-summary --}}
<x-profile-block
    :title="__('my-app::profile.attendance')"
    :source="\App\Support\StudentProfile\BlockSource::resolve('my-app')"
    status="live"                    {{-- live | syncing | offline | error --}}
    :last-synced-at="$syncedAt"
    :open-in-app-href="route('store.show', 'my-app')"
    :open-in-app-label="__('my-app::profile.open')">

    {{-- محتواك: مخطط/جدول/قائمة — بيانات الـ tenant من App\Services\PartnerApi\* فقط --}}

    <x-slot:actions>…</x-slot:actions>   {{-- اختياري: قائمة ⋮ --}}
</x-profile-block>
```

- **`BlockSource::resolve('<module-slug>')`** يعطيك هوية المصدر (اسم/لون/أيقونة)
  بشكل ثابت — لا تخترع لوناً لكل بطاقة. (أو مرّر `source-label`/`accent`/`icon` مباشرة.)
- **الحالة ديناميكية**: مرّر `status` و`last-synced-at` وقت العرض حسب حالة مزامنتك
  الفعلية. `status="offline"` يعرض حالة «غير متصل» — استعمل `<x-slot:offline>` لمحتوى
  بديل (دعوة ربط).
- بطاقات **الإحصاء** (`#[StudentStat]`) تستخدم **`<x-profile-stat>`**
  (`:label` + `:value` + `:source`) لتظهر في شريط الإحصاءات منسوبةً لمصدرها.
- المنصة تملك الكروم؛ أنت تقدّم **المحتوى + الحالة** فقط. الشريط العلوي «مصادر
  البيانات» وفلتر المصدر يُشتقّان آلياً من بلوكاتك المسجَّلة (لا إعداد إضافي).

### كيف يظهر في البوّابة

بوّابة الشركاء **لا تشغّل كود تطبيقك**؛ لذا تبويب **ملف الطالب** فيها **للعرض
فقط**: يعرض المساهمات التي اكتشفها okta-web بعد تثبيت الـ sandbox، ويُشتقّ منها
بلوك `studentProfile` في الـ manifest المنشور. أنت تكتب الكود فقط — والباقي
تلقائي.

> المكوّن يستقبل `studentHashid` فقط:
> `<livewire:my-app::student-profile-summary :studentHashid="$studentHashid" />`.

---

## التطبيق الخارجي (External)

### الإعداد

عند إنشاء تطبيق External تُدخل:

- **`webhook_url`**: عنوان HTTPS يستقبل أحداث المستأجر.
- **`webhook_events`**: قائمة أحداث الاشتراك (مثل
  `education.students.created`).
- **`redirect_urls`**: عناوين OAuth callbacks لتدفّق التثبيت.

### Installation Token

عندما يُثبِّت مستأجر تطبيقك، يُصدَر **installation token** فريد لتلك
الـ (مستأجر، تطبيق). هذا الـ token هو ما تستخدمه في كل طلب API.

```
Authorization: Bearer <installation_token>
```

التوكن:
- يدوم بشكل دائم حتى يُلغى أو يُدوَّر.
- المستأجر يستطيع تدويره/إلغاءه من لوحة "تطبيقاتي" في أي وقت.
- يحمل النطاقات التي وافق عليها المستأجر — نطاق غير مُمنح = 403.

### تدوير الـ Token

عند تدوير المستأجر للـ token، تستلم webhook event
`partner.installation.token_rotated`. الـ token الجديد فيه؛ احفظه فوراً.
الـ token القديم يبقى صالحاً لمدة **15 دقيقة** (grace period) لتسهيل
الانتقال بدون انقطاع.

---

## تطبيقات الإشعار (Notification)

تطبيق الإشعار يُسجَّل كقناة قابلة للتوصيل تستهلكها المنصة عبر واجهة
موحَّدة. أنت تبني المزوِّد مرة، والمنصة تستدعيه بـ `send(recipient, message)`
بصرف النظر عن القناة الفعلية (واتساب، SMS، Push، Slack، ...).

### الـ manifest block

```json
{
  "integrationType": "notification",
  "notification": {
    "channels": ["whatsapp", "sms"],
    "delivery": "api",
    "api": {
      "send_endpoint": "https://your-app.example/notifications/send",
      "auth": "hmac"
    },
    "embedded": {
      "provider_class": "Modules\\AcmeSms\\Providers\\AcmeSmsProvider"
    },
    "capabilities": {
      "supports_templates": true,
      "supports_media": false,
      "supports_bulk": true,
      "max_bulk_recipients": 1000
    },
    "settings_ui": {
      "has_settings_page": true,
      "livewire_component": "partner-apps.acme-sms.settings"
    }
  }
}
```

قواعد التحقق التي تفرضها المنصة على manifest الإشعار:
- `notification.channels` مصفوفة غير فارغة من قيم معروفة:
  `whatsapp`, `sms`, `push`, `slack`, `email`, `telegram`, `voice`.
- `notification.delivery` لازم تكون `api` أو `embedded` أو `hybrid`.
- إذا `delivery=api` أو `hybrid` ⇐ `notification.api.send_endpoint`
  لازم يكون HTTPS.
- إذا `delivery=embedded` أو `hybrid` ⇐ `notification.embedded.provider_class`
  لازم يكون FQCN صالح (مثل `Modules\Foo\Providers\Bar`).
- لا يُسمح بـ `notification` block إلا حين `integrationType=notification`.

### الـ scopes (تُمنح تلقائياً)

عند اختيار `integrationType=notification` من نموذج الإنشاء، المنصة
تُلحق تلقائياً بمنحات تطبيقك:
- `notifications.providers.send` (إجباري — يسمح بالإرسال)
- `notifications.logs.read` (اختياري — قراءة سجل الإرسال)

لا تحتاج اختيار شيء من picker الصلاحيات لو تطبيقك بحت إشعار.

### مسار 1: delivery=api (موصى به للبدء السريع)

#### العقد بين المنصة وتطبيقك

تستقبل من okta-web:

```http
POST https://your-app.example/notifications/send
Content-Type: application/json
X-Okta-Timestamp: 1736435261
X-Okta-Signature: <hmac-sha256-hex>
X-Okta-Delivery-Id: <uuid>

{
  "channel": "whatsapp",
  "recipient_identifier": "+966500000000",
  "message": {
    "body": "نص الرسالة",
    "title": null,
    "template_id": null,
    "variables": {},
    "media": []
  },
  "metadata": {
    "recipient_type": "phone"
  }
}
```

#### التحقق من التوقيع

```php
$timestamp = $request->header('X-Okta-Timestamp');
$signature = $request->header('X-Okta-Signature');
$body      = $request->getContent();

$expected = hash_hmac('sha256', $timestamp . '.' . $body, $signingSecret);

if (! hash_equals($expected, $signature)) {
    abort(401, 'invalid_signature');
}

// رفض إن كان الطابع الزمني أقدم من 5 دقائق
if (abs(time() - (int) $timestamp) > 300) {
    abort(401, 'timestamp_skew');
}
```

#### الرد المتوقَّع

```http
HTTP/1.1 200 OK
Content-Type: application/json

{ "provider_id": "wamid.HBgL..." }
```

- **2xx** = نجاح. أعِد `provider_id` لتربط الرسالة بمعرف المزوِّد.
- **4xx** = فشل دائم (رقم خاطئ، رسالة مرفوضة، ...). لا retry.
- **5xx** = فشل مؤقّت. okta-web تعيد المحاولة مرة واحدة.

#### الحصول على `signingSecret`

السر يصدر تلقائياً عند install على المنصة. للحصول عليه برمجياً:

```php
// من جانب okta-partners
$creds = app(\App\Services\OktaWebService::class)
    ->getInstallationCredentials($installationId);

$signingSecret = $creds['signing_secret'] ?? null;
```

أو يدوياً عبر زر **تدوير Token** في صفحة install على okta-web — يعرض
`signing_secret` مرة واحدة. كل تدوير يُنتج سراً جديداً.

### مسار 2: delivery=embedded

تشحن class داخل okta-web يُطبِّق العقد:

```php
namespace Modules\AcmeSms\Providers;

use App\Contracts\PartnerNotificationProvider;
use App\Services\PartnerApi\Notifications\NotificationPayload;
use App\Services\PartnerApi\Notifications\NotificationResult;

final class AcmeSmsProvider implements PartnerNotificationProvider
{
    public function send(NotificationPayload $payload): NotificationResult
    {
        try {
            $response = SmsClient::send([
                'to'   => $payload->recipientIdentifier,
                'text' => $payload->body,
            ]);

            return NotificationResult::success(providerId: $response['id'] ?? null);
        } catch (\Throwable $e) {
            return NotificationResult::failure($e->getMessage());
        }
    }

    public function supports(string $channel): bool
    {
        return $channel === 'sms';
    }

    public function isConfigured(): bool
    {
        return ! empty(config('acme-sms.api_key'));
    }
}
```

ملاحظات:
- المسار لازم يبدأ بأحد الـ namespaces المسموح بها:
  `Modules\` (الافتراضي) أو ما يضيفه فريق المنصة في
  `partners.embedded_notification_namespaces`.
- لا تَرمِ exceptions من `send()` — التف بـ try/catch وأعِد
  `NotificationResult::failure()`.

### مسار 3: delivery=hybrid

يبني المنصة `HybridNotificationProvider` يجرّب المسار `embedded` أولاً،
ويتراجع لـ `api` عند:
- `embedded.isConfigured()` يُرجِع `false`.
- `embedded.send()` يُرجِع `NotificationResult::failure()`.

مفيد للـ canary releases: تشحن class جديد لإصدار، تترك الـ API كاحتياط
لو الـ class تعطّل.

### واجهة الإعدادات (settings_ui) — اختيارية

لو تطبيقك يحتاج صفحة إعدادات داخل لوحة tenant على okta-web، صرّح في
manifest:

```json
"settings_ui": {
  "has_settings_page": true,
  "livewire_component": "partner-apps.acme-sms.settings"
}
```

المنصة تعرض رابط "فتح الإعدادات" في صفحة `/partner-apps/notification/providers`
ويفتح shell موحَّد يحمّل مكوِّن Livewire الخاص بك. المسار لازم يطابق
livewire-component مُسجَّل (تشحنه ضمن نفس module).

### الـ overrides لكل إصدار

كل الحقول السابقة (`channels`, `delivery`, `api.send_endpoint`,
`embedded.provider_class`, `capabilities`, `settings_ui`) قابلة
للتخصيص لكل إصدار من تبويب **التكامل** في صفحة الإصدار. الحقل الذي
يُترك فارغاً يرث من الـ module تلقائياً عند build، فلا حاجة لتكرار
الإعدادات في كل release.

---

## الـ Manifest

كل تطبيق يصدر `manifest.json` يصف قدراته:

```json
{
  "moduleId": "warehouse",
  "displayName": "إدارة المستودعات",
  "version": "1.0.0",
  "category": "logistics",
  "integrationType": "embedded",
  "description": "...",
  "scopes": [
    { "key": "education.students.read",  "required": true,  "reason": "لعرض قوائم الطلاب" },
    { "key": "education.students.write", "required": false, "reason": "لتسجيل النتائج" }
  ]
}
```

لـ External يُضاف:

```json
{
  "external": {
    "webhookUrl":    "https://your-app.example/okta/webhook",
    "webhookEvents": ["education.students.created"],
    "redirectUrls":  ["https://your-app.example/oauth/callback"]
  }
}
```

للتطبيقات المدمجة التي توسّع صفحة ملف الطالب يُضاف بلوك `studentProfile`
(راجع [توسيع ملف الطالب](#توسيع-ملف-الطالب-student-profile)).

> الـ manifest يُولَّد آلياً من بيانات النموذج في بوّابة الشركاء؛ لا
> تحتاج لكتابته يدوياً إلا في حالات متقدمة.

---

## الـ API

> 📘 يتوفّر **مرجع API تفاعلي عام** بكامل نقاط النهاية والنطاقات وأمثلة
> cURL على صفحة [مرجع الـ API](/docs/api) — مناسب للمشاركة المباشرة.

### URL الأساسي

```
https://getokta.io/api/apps
```

### المصادقة

كل طلب يحمل `Authorization: Bearer <installation_token>`.

### نقاط النهاية الجاهزة

| Endpoint | Scope | الوصف |
|---|---|---|
| `GET /whoami` | (لا شيء) | معلومات الـ installation الحالي |
| `GET /education/students` | `education.students.read` | قائمة الطلاب (paginated) |
| `GET /education/students/{id}` | `education.students.read` | طالب واحد |
| `POST /education/students` | `education.students.write` | إنشاء طالب |
| `PATCH /education/students/{id}` | `education.students.write` | تعديل طالب |
| `GET /education/subjects` | `education.subjects.read` | المواد |
| `GET /education/grades` | `education.grades.read` | الصفوف |
| `GET /education/sections` | `education.sections.read` | الشُعب |
| `GET /employees/directory` | `employees.directory.read` | الموظفين |
| `GET /reports/builder` | `reports.builder.read` | كتالوج التقارير |
| `POST /reports/builder/{key}/run` | `reports.builder.read` | تنفيذ تقرير |

> القائمة الكاملة دائماً في [https://partners.getokta.io/docs/openapi.json](https://partners.getokta.io/docs/openapi.json)
> (مواصفة OpenAPI 3.1 محدّثة آلياً). ويُمكنك استيراد
> [Postman collection](https://partners.getokta.io/docs/postman_collection.json) جاهزة.

### Pagination

كل قائمة ترجع:

```json
{
  "data": [ ... ],
  "total": 1242,
  "per_page": 20,
  "current_page": 1,
  "last_page": 63
}
```

استخدم `?page=2&per_page=50` (الحد الأقصى 100 لكل صفحة).

### Idempotency

عمليات الكتابة تقبل header اختياري:

```
Idempotency-Key: <uuid>
```

نفس المفتاح خلال 24 ساعة يعيد نفس الإجابة المخزّنة، حتى لو تم استدعاؤه
عشرات المرات. هذا يضمن أنه لو حصل timeout على جانبك، إعادة المحاولة
لن تُنشئ سجلاً مكرراً.

---

## الـ Webhooks

### الصيغة

كل webhook هو HTTP POST بجسم JSON:

```json
{
  "id":         "550e8400-e29b-41d4-a716-446655440000",
  "event":      "education.students.created",
  "tenant_id":  42,
  "created_at": "2026-04-26T10:30:15+03:00",
  "data": {
    "student": { "id": 123, "full_name": "...", ... }
  }
}
```

### Headers

| Header | الوصف |
|---|---|
| `X-Okta-Event` | اسم الحدث |
| `X-Okta-Delivery-Id` | UUID فريد، **ثابت عبر إعادة المحاولات** للـ deduplication |
| `X-Okta-Timestamp` | Unix timestamp وقت التوقيع |
| `X-Okta-Signature` | HMAC-SHA256 hex |

### التحقق من التوقيع

```php
$expected = hash_hmac(
    'sha256',
    $request->header('X-Okta-Timestamp') . '.' . $request->getContent(),
    $YOUR_WEBHOOK_SECRET,
);

if (! hash_equals($expected, $request->header('X-Okta-Signature'))) {
    return response('invalid signature', 401);
}
```

### حماية من Replay

افحص أن `X-Okta-Timestamp` ضمن آخر 5 دقائق، وخزّن `X-Okta-Delivery-Id`
لمدة 15 دقيقة على الأقل لرفض إعادة الإرسال.

### إعادة المحاولة

أوكتا يعيد المحاولة مع backoff: 30s → 2m → 10m → 1h → 6h. بعد 6
محاولات بدون 2xx، يُعتبر التسليم فاشلاً نهائياً ويُسجَّل في صفحة
"تسليمات Webhook" في لوحة الشريك.

> **مهم:** ردّك يجب أن يكون **2xx خلال 10 ثوانٍ**. لو احتجت معالجة
> طويلة، اقبل الـ webhook بـ 202 Accepted فوراً وعالج خلفياً.

---

## كتالوج الإشعارات

يصرّح كل تطبيق شريك بـ**كتالوج الإشعارات** الخاص به — قائمة الأحداث
التي يستطيع التطبيق إرسالها للمستخدمين، وكل حدث له مفتاح ثابت
ودلالات ومتغيّرات وقنوات تسليم افتراضية. الكتالوج يُعرَّف من بوّابة
الشركاء ثم يُنتقَل إلى okta-web عند النشر، فيظهر للمستأجرين على صفحة
`/settings/notifications` ليُفعّلوا ما يريدون لكل تثبيت ويختاروا
قنوات التسليم.

> **التمييز عن نوع التكامل**: قسم [تطبيقات الإشعار (Notification)](#تطبيقات-الإشعار-notification)
> أعلاه يخص نوع التكامل الذي يكون فيه التطبيق **مزوِّد قناة تسليم**
> (واتساب/SMS/Push). الكتالوج هنا مختلف: متاح لـ**أي** نوع تطبيق
> (Embedded/External/Notification) ليُصرّح بقائمة الأحداث التي
> يُرسلها هو، بصرف النظر عن القنوات.

### الفلسفة: Declare-first

لا يستطيع تطبيقك إرسال إشعار من الكود قبل تصريحه في الكتالوج. هذه
ليست توصية — هي قاعدة مفروضة عبر:

1. **NotificationScanner في الـCI**: يفحص الكود ويُسقط أي `DispatchNotification('<key>', ...)` حيث `<key>` غير مُصرَّح في `manifest.json["notifications"]`. الـPR يفشل.
2. **runtime guard في okta-web**: لو وصل dispatch لمفتاح غير معروف في الكتالوج المُستورَد، يُسقَط بصمت ويُسجَّل في الـlog (لا يُرسَل).

السبب وراء الصرامة: المستأجر يحتاج يعرف **مقدّماً** كل إشعار ممكن
يصله من تطبيقك ليُقرّر:
- هل يفعّله أصلاً؟
- على أي قناة (email/SMS/WhatsApp/push/in-app/webhook)؟
- لمن (admin فقط؟ كل المستخدمين؟ مجموعة محددة؟)

لو سمحنا لتطبيقك بإرسال مفاتيح عشوائية، المستأجر يفقد التحكم.

### دورة الحياة

```
Draft version  →  Edit catalog freely  →  Submit  →  Approved  →  Published
       │                                                              │
       │                                                              ▼
       │                                                       Catalog frozen
       │                                                       Shipped to okta-web
       │                                                       Tenants can install
       │
       └──── Create new version ──→ Catalog cloned forward ──→ Edit freely ──→ ...
```

النقاط المهمة:

- **الكتالوج مربوط بإصدار**، ليس بالـmodule. الإصدار v1.0.0 له كتالوج،
  و v1.1.0 له كتالوجه الخاص.
- **النسخ التلقائي عند إنشاء إصدار جديد**: عند إنشاء v1.1.0 من v1.0.0
  المنشور، كل الإشعارات تُنسخ تلقائياً إلى الإصدار الجديد كنقطة بداية،
  وتقدر تعدّلها/تحذفها/تضيف عليها بحرية. (التنفيذ:
  `App\Services\PartnerModules\Notifications\CloneNotificationsToNewVersion`)
- **التجميد عند النشر**: ما إن يُنشَر الإصدار، كتالوجه يُصبح
  read-only. لتغيير أي شيء، أنشئ إصداراً جديداً.
- **المزامنة المستمرة مع GitHub**: كل تعديل من تبويب الإشعارات
  يُحدِّث `manifest.json["notifications"]` على فرع الإصدار في الـrepo
  تلقائياً. هذا يخلي الـmanifest على disk دائماً مطابق لـDB.

### تشريح إدخال إشعار

كل سجل إشعار يحتوي:

| الحقل | النوع | الشرح |
|---|---|---|
| `key` | string | المعرّف الفريد بصيغة `<your-slug>.<dot.path>`. لوحة الإدارة تفرض البادئة (slug) تلقائياً. lowercase، snake_case، 3 أجزاء على الأقل. **immutable بعد الإنشاء** — احذف وأنشئ من جديد لو تحتاج اسماً مختلفاً (متاح فقط قبل النشر). |
| `display_name_ar` / `display_name_en` | string | الاسم اللي يراه المستأجر في صفحة الإعدادات. ثنائي اللغة إجباري. |
| `description_ar` / `description_en` | text | شرح اختياري للسياق. مفيد للمستأجر ليفهم متى يُرسَل الإشعار. |
| `variables_schema` | map | خريطة `{ name → php-type }` للمتغيّرات المتوقّعة في الـpayload عند الإرسال. مثل `{ "employee_name": "string", "leave_days": "int" }`. okta-web يعرضها للمستأجر ليُكوّن قوالب الرسائل. |
| `default_channels` | array | القنوات التي تبدأ مُفعّلة. القيم المسموحة: `email`, `sms`, `whatsapp`, `push`, `in_app`, `webhook_out`. **المستأجر يستطيع تضييقها لاحقاً** من إعداداته، لكن لا يستطيع توسيعها لقنوات لم تصرّح بها. |
| `severity` | enum | `info` (افتراضي) / `warning` / `critical`. تظهر للمستأجر ليعطي الإشعارات الـcritical أولوية في قنوات الـpush. |
| `is_active` | bool | تطفيش/تفعيل دون حذف. الإشعارات غير النشطة لا تُرسَل من قِبَل المنصة حتى لو طُلبت من الكود (الـrequest يُسقَط بصمت). مفيد لما تبي تعطّل ميزة مؤقتاً بدون كسر المستأجرين. |

### كيف تُصرّح

من بوّابة الشركاء: **التطبيقات ← اختر تطبيقك ← تبويب "الإشعارات"**.

الـURL المباشر: `/dashboard/modules/<your-slug>?tab=notifications`.

- اختر الإصدار (يُختار آخر draft تلقائياً).
- **إضافة إشعار** ← اكتب المفتاح (البادئة `<slug>.` مضافة تلقائياً)،
  املأ display name (ar/en)، اختياري description، أضف المتغيّرات،
  حدّد القنوات، اختر severity، اضغط حفظ.
- لتعديل: انقر "تعديل" بجانب الصف.
- لحذف: انقر "حذف". لو الإصدار منشور والمفتاح مستخدَم في تثبيتات
  نشطة، الحذف يُرفَض (استخدم toggle is_active بدلاً).

### الإرسال من الكود

من PHP داخل تطبيقك:

```php
use App\Services\PartnerApi\Notifications\DispatchNotification;

app(DispatchNotification::class)(
    key: 'hr-pro.leave_request.approved',
    payload: [
        'employee_name' => $request->user()->name,
        'leave_days' => 5,
        'start_date' => '2026-04-26',
        'manager_id' => $manager->id,    // ULID — يُحَل تلقائياً للمستلم
    ],
    recipients: ['employee', 'manager'], // اختياري؛ افتراضياً جميع المعنيين
);
```

الـsignature المختصرة:

```php
app(DispatchNotification::class)('<key>', $payload);
```

أو عبر الـhelper:

```php
partner_notify('<key>', $payload);
```

كلاهما يُكشَف من `NotificationScanner` ويُحقَّق ضد الكتالوج.

### السكانر — ما يفعله

`scripts/partner-policy/NotificationScanner.php` في الـboilerplate
يفحص كل ملف PHP تحت `Modules/<Namespace>/` و:

1. يجمع كل المفاتيح الـ"مُستخدَمة" من 3 patterns:
   - `app(DispatchNotification::class)('key', ...)`
   - `app(\App\Services\PartnerApi\Notifications\DispatchNotification::class)('key', ...)`
   - `partner_notify('key', ...)`
2. يجمع كل المفاتيح الـ"مُصرَّح بها" من `manifest.json["notifications"]`.
3. يقارن:
   - **مفتاح مستخدَم لكن غير مُصرَّح** → violation (يكسر CI، رفض الـPR).
   - **مفتاح مُصرَّح لكن غير مستخدَم** → warning (غير قاطع، لكن يظهر
     في الـlog ليُذكِّرك بالتنظيف).

تشغيل محلياً:

```bash
php scripts/partner-policy/check.php Modules/
```

الـoutput format يدعم `--format=github` للـannotations في PR.

### عقد الـManifest

عند build الإصدار، يُولَّد block جديد في `manifest.json`:

```json
{
  "notifications": [
    {
      "key": "hr-pro.leave_request.approved",
      "display_name": {
        "ar": "تمت الموافقة على طلب الإجازة",
        "en": "Leave request approved"
      },
      "description": {
        "ar": "يُرسَل تلقائياً للموظف عند موافقة مديره.",
        "en": "Sent automatically when the manager approves a leave request."
      },
      "variables": {
        "employee_name": "string",
        "leave_days": "int",
        "start_date": "string"
      },
      "default_channels": ["email", "in_app"],
      "severity": "info",
      "is_active": true
    }
  ]
}
```

okta-web's `SyncCatalogFromManifest` يقرأ هذا الـblock عند النشر
ويُدخل/يحدّث الصفوف في جدول الإشعارات على جانبه. التطبيق يصير
ظاهراً للمستأجرين فوراً.

> **لا تحرر هذا الـblock يدوياً** في الكود. التبويب على البوّابة يكتبه
> تلقائياً. أي تعديل يدوي يُكتسَح في المزامنة التالية.

### من يتحكم بأي قناة

```
┌─────────────────┬──────────────────────────────────────┐
│ الشريك يصرّح    │ القنوات الممكنة (مثل: email, in_app)  │
│ (default_channels) │ ⇣                                  │
├─────────────────┼──────────────────────────────────────┤
│ المستأجر يفعّل  │ subset من القنوات الممكنة            │
│ (per install)   │ + يختار المستلمين                    │
│                 │ + يكتب قالب الرسالة (اختياري)        │
└─────────────────┴──────────────────────────────────────┘
```

النتيجة: تطبيقك يقول `dispatch(key, payload)` بدون قلق حول قناة
التسليم. okta-web يتولّى الـfan-out كاملاً:

- يقرأ إعدادات المستأجر لهذا التثبيت لهذا المفتاح
- يحوّل الـpayload إلى قالب الرسالة المعدّ
- يرسل عبر كل قناة مفعّلة (email عبر Mailable، SMS عبر provider،
  WhatsApp عبر template API، push عبر Web Push/FCM، in_app يكتب في
  جدول notifications، webhook_out يرسل HMAC-signed POST لمستقبِل
  خارجي)

تطبيقك **لا يكتب أي transport class مخصّص**. هذا جوهر النموذج.

### مثال كامل: تطبيق HR

#### 1. صرّح الإشعارات في البوّابة

| Key | Display (ar/en) | Variables | Default channels | Severity |
|---|---|---|---|---|
| `hr-pro.leave_request.submitted` | تم تقديم طلب إجازة / Leave request submitted | `{employee_name, manager_id}` | `in_app`, `email` | info |
| `hr-pro.leave_request.approved` | تمت الموافقة على الإجازة / Leave approved | `{employee_name, leave_days, start_date}` | `email`, `whatsapp`, `in_app` | info |
| `hr-pro.leave_request.rejected` | رُفِض طلب الإجازة / Leave rejected | `{employee_name, reason}` | `email`, `in_app` | warning |
| `hr-pro.attendance.alert` | تنبيه حضور / Attendance alert | `{employee_name, missed_days}` | `email`, `in_app` | critical |

#### 2. ادفع الفرع — السكانر يفشل لأن الـkeys ما زالت غير مستخدمة (warnings فقط).

#### 3. أضف الـdispatch في الكود

```php
// Modules/HrPro/app/Services/LeaveRequests/ApproveLeaveRequest.php

namespace Modules\HrPro\Services\LeaveRequests;

use App\Services\PartnerApi\Notifications\DispatchNotification;
use Modules\HrPro\Models\LeaveRequest;

final class ApproveLeaveRequest
{
    public function __construct(
        private readonly DispatchNotification $notify,
    ) {}

    public function __invoke(LeaveRequest $request, int $approverId): LeaveRequest
    {
        $request->update([
            'status' => 'approved',
            'approved_by' => $approverId,
            'approved_at' => now(),
        ]);

        ($this->notify)('hr-pro.leave_request.approved', [
            'employee_name' => $request->employee_name,
            'leave_days' => $request->days_count,
            'start_date' => $request->starts_at->toDateString(),
        ]);

        return $request->fresh();
    }
}
```

#### 4. ادفع — السكانر يمر:
```
Partner policy scan: clean. No violations found.
```

#### 5. قدّم الإصدار، اقبله، انشره. المستأجر الآن يرى الإشعارات
في `/settings/notifications` ويستطيع تفعيلها.

### نصائح وممارسات

- **سَمِّ المفاتيح حسب الـdomain، ليس التقنية**. ✅
  `hr-pro.leave_request.approved` × ❌ `hr-pro.email.sent`.
- **لا تجعل مفاتيحك عامة جداً**. ✅
  `school-portal.exam.grade_published` × ❌ `school-portal.notification.new`.
- **ابدأ بقنوات افتراضية محافِظة**. ضع `in_app` + `email` فقط ودع
  المستأجر يفعّل SMS/WhatsApp بنفسه — مكلفة.
- **استخدم severity بحرص**. `critical` يكسر سياسات الـquiet hours على
  بعض القنوات (push يبقى يطرق حتى في الليل). احفظها للأحداث
  المهمة فعلاً.
- **خزّن متغيّرات قليلة وذات معنى**. لا تُرسِل كل الـmodel كـpayload —
  فقط ما يحتاجه قالب الرسالة. الحمولة المنتفخة تكلّف في الـbatch
  notifications.
- **استخدم `is_active=false` بدل الحذف على إصدار منشور**. الحذف
  يُرفَض لو هناك تثبيتات نشطة؛ التطفيش حلّ آمن.

### الأخطاء الشائعة وحلولها

| الخطأ | السبب | الحل |
|---|---|---|
| Scanner: `notification-key-not-declared` | الكود يستدعي `dispatch(key)` والمفتاح غير في `manifest.json` | اذهب للتبويب، صرّح المفتاح، اسحب الـmanifest من الفرع. |
| Scanner: `notification-key-unused` (warning) | المفتاح مُصرَّح لكن ما يُستدعى من الكود | احذف المفتاح من الكتالوج، أو أضف الـdispatch المناسب. |
| `cannot_edit_published` | تحاول تعديل كتالوج إصدار منشور | أنشئ إصداراً جديداً (الكتالوج يُنسَخ تلقائياً) وعدّل عليه. |
| `delete_blocked_by_installs` | الحذف على إصدار منشور مع تثبيتات نشطة | استخدم `is_active=false` بدلاً. لو لازم الحذف فعلاً، اطلب من المستأجرين إلغاء تثبيتهم أولاً. |
| `key_prefix` validation error | كتبت مفتاحاً لا يبدأ بـslug تطبيقك | اللوحة تضيف البادئة تلقائياً — اكتب فقط الـsuffix (مثلاً `leave_request.approved`). |

### تجربة محلياً

في sandbox tenant:

```php
// شغّل من tinker بعد إنشاء installation
app(\App\Services\PartnerApi\Notifications\DispatchNotification::class)(
    'hr-pro.leave_request.approved',
    ['employee_name' => 'سارة', 'leave_days' => 3]
);
```

افحص:
- جدول `notifications` على okta-web sandbox — لازم يكون فيه صف جديد.
- صندوق البريد للمستأجر (Mailtrap في sandbox).
- صفحة "Notification log" في لوحة المستأجر — تعرض كل ما أُرسِل.

---

## نُهج الأمان

### Embedded

- ✓ Policy scanner (regex + PHPStan AST) يفحص كل PR في مستودعك.
- ✓ Postgres role/schema منفصلين تماماً عن المنصة.
- ✓ `BlocksPartnerDirectAccess` trait يرفض أي وصول خارج
  `App\Services\PartnerApi\*`.
- ✓ كل scope مُتحقَّق عند الـ runtime عبر `AppPermissionGuard`.

### External

- ✓ Installation token في DB مشفّر.
- ✓ Webhooks موقَّعة + timestamped + replay-protected.
- ✓ HTTPS فقط للـ `webhook_url`.
- ✓ rate limiting من جانب أوكتا (60 طلب/دقيقة لكل installation).

### مسؤولياتك

- 🔒 لا تُسرّب الـ installation token في logs أو error reports.
- 🔒 خزِّن `webhook_secret` في secret manager، ليس في الكود.
- 🔒 طبّق rate limiting داخلي إن كان تطبيقك يُمرّر طلبات لخدمات
  خارجية باستخدام بيانات المستأجر.
- 🔒 لا تخزِّن بيانات المستأجر أكثر مما تحتاج.

---

## الاختبار محلياً

### Sandbox tenant

كل تطبيق Embedded يحصل على **sandbox tenant** يحاكي مستأجراً حقيقياً
ببيانات اختبار. تشغيل ضد sandbox مجاني وغير محدود.

### Webhook tunneling

للـ External، استخدم أداة مثل `ngrok` لإنشاء HTTPS tunnel إلى dev
machine الخاصة بك:

```bash
ngrok http 8000
# انسخ العنوان (مثل https://abc.ngrok.app) إلى webhook_url في
# إعدادات التطبيق sandbox
```

أوكتا يُمكِنك من إعادة بثّ webhook events على عنوانك الجديد دون إنشاء
أحداث جديدة، عبر صفحة "Webhook Deliveries → Replay".

### Postman / Insomnia

استورد:

```
https://partners.getokta.io/docs/openapi.json
https://partners.getokta.io/docs/postman_collection.json
```

ملف الـ Postman يأتي بـ `installationToken` كمتغير — ضع التوكن من
sandbox install وستعمل كل الطلبات.

---

## نظام التصميم وواجهة المستخدم

التطبيق المدمج (Embedded) يظهر داخل لوحة المستأجر جنباً إلى جنب مع
شاشات أوكتا الأصلية. لذلك أي ميزة بصرية يبنيها شريك يجب أن تتسق مع
هوية أوكتا حتى لا يشعر المستأجر بقفزة بصرية. هذا القسم هو **المرجع
الوحيد** الذي تحتاجه أنت — أو أداة AI مثل Claude Code / Cursor /
Codex — لبناء واجهة احترافية متناغمة مع المنصة.

> **اقتباس سريع**: في نهاية القسم برومبت جاهز انسخه إلى أي مساعد AI
> ليبني لك ملفات Livewire + Blade بنفس هويّة أوكتا، بدون أن تحتاج
> لشرح النظام كل مرة.

### المبادئ الأساسية

1. **استخدم المكوّنات الموجودة، لا تخترع مكوّنات جديدة.** كل شيء
   موصوف في "كتالوج المكوّنات" أدناه (`<x-card>`, `<x-button>`,
   `<x-badge>`, `<x-input-field>`, `<x-textarea>`, `<x-alert>`,
   `<x-spinner>`, `<x-modal-card>`). كل مكوّن آخر هو خروج عن الهوية.
2. **Tailwind tokens فقط** من الـpalette أدناه. ممنوع ألوان hex خام.
3. **RTL/LTR على مستوى الـlogical properties**: `start`/`end`,
   `ms-`/`me-`, `ps-`/`pe-`, `text-start`/`text-end`. الأسهم
   والـchevron تُدوَّر بـ`rtl:rotate-180`.
4. **font-mono و dir="ltr" لكل المعرّفات والـURLs والـslugs**.
5. **Card-based composition**: كل قسم في `<x-card>` بحدود ناعمة
   وظِلّ خفيف.
6. **States أوّليّة**: empty / loading / error دائماً معالَجة بشكل
   صريح، ليست fallback.
7. **Save bar واحد ثابت** بدل أزرار حفظ متعدّدة لكل قسم فرعي.
8. **Animation خفيف** فقط (fadeInUp 0.3s) لتقليل التشتيت.

> ⚠️ لاختصار التحديث على prod، باقي محتوى قسم التصميم (Brand tokens،
> كتالوج x-*، wire-elements/modal، Layout rules، البرومبت الجاهز،
> نسخ مرجعية) كما هو على main commit `40ae5c0` و prod commit
> `5e29d0f`. أي إضافة مستقبلية لقواعد التصميم تذهب هناك أولاً ثم
> تُنسَخ هنا.

### Toasts

استخدم نظام الـtoasts المشترك بدل alerts مدمجة في الصفحة:

```php
$this->dispatch('toast', message: __('my-module::students.created'));
$this->dispatch('toast', message: __('errors.generic'), type: 'error');
```

أنواع: `success` (افتراضي), `error`, `warning`, `info`.

### البرومبت الجاهز لمساعد AI

البرومبت الكامل المُوصى به في commit `5e29d0f` على prod. ابدأ منه بدون
تغيير. القاعدة #12 فيه تذكر بضرورة تصريح كل مفتاح إشعار في الكتالوج
أعلاه قبل dispatch من الكود.

---

## دعم الذكاء الاصطناعي

التطبيقات التي تستخدم الذكاء الاصطناعي يجب أن تُعلن ذلك صراحةً في الـ manifest عبر حقلين:

- **`aiSupport`** (boolean): هل يستخدم التطبيق الذكاء الاصطناعي؟
- **`aiMode`** (enum): إمّا `own` (أدوات الشريك الذاتية) أو `platform` (محرّك أوكتا الموحَّد — يحتاج اعتماد).

### واجهة الإعلان داخل المنصة

- **عند إنشاء التطبيق:** بطاقة "دعم الذكاء الاصطناعي" تظهر بين "البيانات الأساسية" و"التسعير" في صفحة `/partner/modules/create`.
- **عند تحديث الإصدارات:** نفس البطاقة تظهر في تبويب "نظرة عامة" داخل محرّر الإصدار، فيمكن إضافة دعم AI لإصدار محدّد دون تعديل التطبيق الأصلي. الإعلان على مستوى الإصدار يتفوّق على إعلان مستوى التطبيق.

### الخيارات

- **`own`** — التطبيق يدير مزوّداته (OpenAI / Anthropic / Gemini / ...) ومفاتيحه بنفسه. لا يحتاج اعتماداً إضافياً من فريق المنصة.
- **`platform`** — التطبيق يستهلك `AiManager` المركزي في okta-web. **التصريح هنا لا يكفي:** يحتاج موافقة فريق المنصة يدوياً عبر علم `ai_platform_approved` على جدول `modules` في okta-web. حتى ذلك الحين يرفض `EnsureAiPlatformApproved` أي نداء AI بـ 403.

### مرجع تفصيلي

راجع [`ai-support.md`](./ai-support.md) للحصول على قواعد التحقّق الكاملة، أمثلة `manifest`، وأسئلة شائعة عن التكلفة والترقية بين `own` و`platform`.

### أمثلة كود استخدام AiManager

> هذه الأمثلة تنطبق على `aiMode=platform` فقط. تطبيقات `aiMode=own` تستخدم مزوّداتها مباشرة (انظر القسم الأخير).

#### حقن AiManager

```php
use App\AI\AiManager;
use App\AI\AiException;

class StudentReportsController extends Controller
{
    public function __construct(private AiManager $ai) {}

    public function summary(Request $request)
    {
        try {
            $summary = $this->ai->summarize($request->long_text);
            return response()->json(['summary' => $summary]);
        } catch (AiException $e) {
            return response()->json(['error' => $e->getMessage()], 502);
        }
    }
}
```

Laravel يحلّ `AiManager` تلقائياً عبر `AiServiceProvider` (يُسجَّل كـ singleton). يمكن أيضاً استخدام `app(AiManager::class)` أو `app('ai')`.

#### `chat()` — محادثة بسيطة

```php
$reply = app(AiManager::class)->chat(
    prompt: 'لخّص لي تقرير الطالب التالي في 3 نقاط',
    context: [
        ['role' => 'user',      'content' => 'تقرير الطالب: ...'],
        ['role' => 'assistant', 'content' => 'حسناً، سأقرأ التقرير.'],
    ],
    opts: [
        'model'         => 'gpt-4o',     // اختياري — افتراضي يحدّده محرّك أوكتا
        'temperature'   => 0.3,
        'max_tokens'    => 500,
        'system_prompt' => 'أنت مساعد تعليمي محترف. أجب بالعربية.',
    ],
);
```

#### `stream()` — استجابة متدفّقة (للواجهات التفاعلية)

```php
use App\AI\AiManager;

return response()->stream(function () {
    $full = app(AiManager::class)->stream(
        prompt: 'اشرح لي مفهوم التحليل العاملي',
        context: [],
        opts: ['temperature' => 0.5],
        onChunk: function (string $chunk) {
            echo "data: " . json_encode(['text' => $chunk]) . "\n\n";
            ob_flush();
            flush();
        },
    );
}, 200, [
    'Content-Type' => 'text/event-stream',
    'Cache-Control' => 'no-cache',
    'X-Accel-Buffering' => 'no',
]);
```

في Livewire، استخدم `wire:stream` أو حدّث خاصية عامة من داخل `onChunk` callback.

#### `complete()` — إكمال نصّ (بدون محادثة)

```php
$completion = app(AiManager::class)->complete(
    text: "كتب الطالب أحمد في مقاله: 'التعليم هو السبيل ل",
    opts: ['max_tokens' => 50],
);
// نتيجة: "...النهضة والتقدّم في المجتمعات الحديثة، ولذلك يجب..."
```

#### `summarize()` — تلخيص نصّ طويل

```php
$summary = app(AiManager::class)->summarize(
    longText: $student->report_full_text,
    opts: [
        'max_tokens' => 300,
        'system_prompt' => 'لخّص في فقرة واحدة، مع التركيز على نقاط القوة والضعف.',
    ],
);
```

#### `translate()` — ترجمة

```php
$en = app(AiManager::class)->translate(
    text: 'الطالب متفوّق في الرياضيات والعلوم',
    toLocale: 'en',
);
// نتيجة: "The student excels in mathematics and sciences"
```

#### معالجة الأخطاء

```php
use App\AI\AiException;
use Illuminate\Http\Client\ConnectionException;

try {
    $result = app(AiManager::class)->chat($prompt);
} catch (AiException $e) {
    // فشل المزوّد، تجاوز الحدّ، نموذج غير متاح، ...
    Log::warning('AI request failed', ['error' => $e->getMessage()]);
    return back()->with('error', 'تعذّر معالجة طلبك حالياً. حاول لاحقاً.');
} catch (ConnectionException $e) {
    // عدم وصول للخدمة (نادر)
    return back()->with('error', 'خدمة الذكاء الاصطناعي غير متاحة.');
}
```

> ⚠️ **لا تلتقط** `PlatformAiNotApprovedException` (HTTP 403). دعها تنتشر — middleware `EnsureAiPlatformApproved` سيُرجع للمستخدم رسالة "بانتظار اعتماد المنصة" مع header `X-Ai-Approval-Required: true`، وهذه رسالة معيارية على المنصة.

#### أمثلة `aiMode=own` — أدوات الشريك الذاتية

إذا اخترت `aiMode=own`، فإن أوكتا لا تشارك في إدارة الطلبات. تستخدم مزوّدك مباشرةً مع SDK الخاص به. مثال OpenAI:

```php
use OpenAI\Laravel\Facades\OpenAI;

$response = OpenAI::chat()->create([
    'model' => 'gpt-4o',
    'messages' => [
        ['role' => 'system', 'content' => 'أنت مساعد تعليمي'],
        ['role' => 'user',   'content' => $userInput],
    ],
]);

$reply = $response->choices[0]->message->content;
```

أو Anthropic Claude:

```php
$response = Http::withHeaders([
    'x-api-key' => config('services.anthropic.key'),
    'anthropic-version' => '2023-06-01',
])->post('https://api.anthropic.com/v1/messages', [
    'model' => 'claude-opus-4-7',
    'max_tokens' => 1024,
    'messages' => [
        ['role' => 'user', 'content' => $userInput],
    ],
])->json();

$reply = $response['content'][0]['text'];
```

**مسؤوليّاتك في وضع `own`:**
- تخزين مفاتيح API بأمان (متغيرات بيئة، KMS، ...)
- إدارة حدود الاستخدام وفواتير المزوّد
- الإفصاح للمستأجر عن أي بيانات تُرسَل خارج المنصة
- التزام سياسات الخصوصية وحماية البيانات

### الوكيل الذكي (AI Agent) مع استخدام الأدوات

التطبيقات التي تريد ذكاءً اصطناعياً **يُنفِّذ عمليات** (لا يُجيب فقط) تستخدم الوكيل (Agent) مع مفهوم "الأدوات" (Tools). أنت تُعرِّف الأدوات كصفّ PHP، والذكاء الاصطناعي يقرّر متى يستدعيها — مثلاً عند طلب "أضف ٥ لجان بتوزيع ٣٠ طالباً لكل لجنة" يقوم الذكاء بنفسه باستدعاء أداة `exams.committees.add` وتمرير المعاملات الصحيحة.

> هذا الجزء يتطلّب `aiMode=platform` + اعتماد منصة أوكتا (`ai_platform_approved=true`).

#### بنية الأداة

كل أداة في تطبيقك هي صفّ PHP يُنفّذ الواجهة `App\AI\Contracts\AiTool` ويعيش في المجلد `Modules/<اسم-التطبيق>/AiTools/`. المنصة تكتشفها تلقائياً عند تحميل التطبيق — لا حاجة لتسجيل يدوي.

#### مثال كامل: `AddCommitteesTool` في تطبيق الاختبارات

ملف: `Modules/Exams/AiTools/AddCommitteesTool.php`

```php
<?php

namespace Modules\Exams\AiTools;

use App\AI\Contracts\AiTool;
use App\AI\Exceptions\AiToolException;
use Modules\Exams\Models\ExamCommittee;
use Modules\Exams\Services\CommitteeDistributor;

class AddCommitteesTool implements AiTool
{
    public function __construct(
        private readonly CommitteeDistributor $distributor,
    ) {}

    public function name(): string
    {
        return 'exams.committees.add';
    }

    public function description(): string
    {
        return 'يُنشئ لجان اختبارات جديدة ويوزّع الطلاب عليها حسب الإعدادات المُعطاة. '
            .'استخدم هذه الأداة عندما يطلب المستخدم إضافة لجان أو توزيع طلاب على لجان.';
    }

    public function parametersSchema(): array
    {
        return [
            'type' => 'object',
            'properties' => [
                'committee_count' => [
                    'type' => 'integer',
                    'minimum' => 1,
                    'maximum' => 100,
                    'description' => 'عدد اللجان المطلوب إنشاؤها',
                ],
                'students_per_committee' => [
                    'type' => 'integer',
                    'minimum' => 1,
                    'maximum' => 60,
                    'description' => 'عدد الطلاب في كل لجنة',
                ],
                'exam_period' => [
                    'type' => 'string',
                    'enum' => ['first', 'second', 'final'],
                    'description' => 'الفترة الامتحانية (first/second/final)',
                ],
                'distribution_strategy' => [
                    'type' => 'string',
                    'enum' => ['alphabetical', 'random', 'by_grade'],
                    'default' => 'alphabetical',
                    'description' => 'استراتيجية التوزيع',
                ],
            ],
            'required' => ['committee_count', 'students_per_committee', 'exam_period'],
        ];
    }

    public function requiredScopes(): array
    {
        return ['exams.committees.write', 'education.students.read'];
    }

    public function handle(array $params): mixed
    {
        $count = (int) $params['committee_count'];
        $perCommittee = (int) $params['students_per_committee'];
        $period = (string) $params['exam_period'];
        $strategy = $params['distribution_strategy'] ?? 'alphabetical';

        $available = $this->distributor->countAvailableStudents($period);
        $needed = $count * $perCommittee;

        if ($available < $needed) {
            throw new AiToolException(
                "عدد الطلاب المتاحين ({$available}) أقل من المطلوب ({$needed}). "
                ."يمكنك تقليل عدد اللجان أو عدد الطلاب لكل لجنة."
            );
        }

        $committees = $this->distributor->createAndDistribute(
            count: $count,
            studentsPerCommittee: $perCommittee,
            period: $period,
            strategy: $strategy,
        );

        return [
            'created' => $committees->count(),
            'total_students_assigned' => $committees->sum('student_count'),
            'period' => $period,
            'committee_ids' => $committees->pluck('id')->all(),
            'message' => "تم إنشاء {$count} لجنة وتوزيع {$needed} طالب بنجاح",
        ];
    }
}
```

#### إضافة واجهة الدردشة في صفحة التطبيق

```blade
<x-ai-agent
    :title="'مساعد اللجان'"
    :placeholder="'مثال: أضف ٥ لجان للدور الأول بتوزيع ٣٠ طالباً لكل لجنة'"
    :system-prompt="'أنت مساعد ذكي لإدارة لجان الاختبارات في تطبيق okta-exams. ساعد المستخدم في إنشاء وتوزيع وإدارة لجان الاختبارات. تكلّم بالعربية دائماً.'"
    height="600px"
/>
```

#### كيف يعمل التدفّق

١. المستخدم يكتب: "أضف ٥ لجان للدور الأول بتوزيع ٣٠ طالباً"
٢. الواجهة ترسل الطلب إلى `POST /api/apps/ai/agent/stream`
٣. المنصة تستخرج التطبيق الحالي من `AppContextManager`، تكتشف أدوات `Modules/Exams/AiTools/` تلقائياً، تُرشّحها حسب `requiredScopes()`، وتُرسلها للنموذج
٤. النموذج يردّ بـ `{"tool": "exams.committees.add", "args": {...}}`
٥. المنصة تنفّذ `AddCommitteesTool::handle()` وتُرجع النتيجة
٦. الواجهة تعرض كل خطوة لحظياً (running → done)

#### معالجة الأخطاء

- **`AiToolException`**: خطأ متعافٍ — يُرسَل للنموذج فيقرّر. استخدمه للتحقّقات.
- **أي خطأ آخر**: يُلغى دور الوكيل بأكمله ويُسجَّل في `partner_install` channel.

#### الصلاحيات

كل أداة تُعلن `requiredScopes()`. حلقة الوكيل تُسقط الأدوات التي لا يملك التطبيق صلاحياتها قبل عرضها على النموذج. هذا يعني أن أداة الكتابة تبقى آمنة حتى لو منح المستأجِر صلاحيات قراءة فقط.

#### حدّ التكرار

كل دور للوكيل محدود بـ ٥ تكرارات. بعد الحدّ، يُطلب من النموذج كتابة ملخّص نهائي.

#### نصائح للتأليف

١. **اسم الأداة**: استخدم نمط `module.resource.action`.
٢. **الوصف**: اكتبه كتوثيق API.
٣. **JSON Schema**: كن صارماً (enums, min/max).
٤. **القيمة المُرجعة**: أعِد بنية بسيطة (array/scalar).
٥. **التحقّق المُسبق**: ارمِ `AiToolException` للأخطاء المتوقّعة.

#### اختبار الأداة محلياً

```php
// tests/Feature/AddCommitteesToolTest.php
$tool = app(\Modules\Exams\AiTools\AddCommitteesTool::class);
$result = $tool->handle([
    'committee_count' => 3,
    'students_per_committee' => 25,
    'exam_period' => 'first',
]);
$this->assertEquals(3, $result['created']);
```

---

## تطبيق الجوال (Okta Mobile)

تطبيق أوكتا للجوال يعرض **كروت خدمات** للمستخدم. يمكن لتطبيقك أن يقدّم
خدمة تظهر ككارد داخله. هذا الإعداد **مرتبط بكل إصدار على حدة** — إصدار
لاحق يستطيع تفعيله دون لمس الإصدار الأصلي.

### أين تُفعِّله

لوحة الشريك → التطبيق → الإصدار → تبويب **التكامل** → بطاقة **«خدمات
داخل تطبيق أوكتا للجوال»** → فعّل الخيار، ثم قدّم الإعدادات.

### الإعدادات التي تقدّمها

| الإعداد | الوصف |
|---|---|
| **نوع العرض (mode)** | `embedded` (تشحن ملفات داخل مستودعك) أو `external` (رابط تستضيفه أنت). |
| **نقطة الدخول (entry)** | للـ embedded: مسار نسبي داخل `mobile/`. للـ external: رابط HTTPS كامل. |
| **الأصول المسموحة (allowed origins)** | للـ external فقط: قائمة أصول HTTPS يُسمح بتحميل الصفحة منها. |
| **الصلاحية المطلوبة (required scope)** | اختياري. الكارد يظهر فقط إذا كان الدور النشط للمستخدم يملك هذه الصلاحية (من الصلاحيات الممنوحة للإصدار). فارغ = يظهر لأي دور يصل للتطبيق. |
| **تمرير الدور (pass role claim)** | اختياري، للـ external فقط. يضيف claim الدور داخل JWT الموقّع. لا وصول لقاعدة بيانات في كل الأحوال. |

### هيكلة الملفات (embedded)

ضع كل ما يُعرض في الجوال داخل مجلد `mobile/` في جذر مستودع تطبيقك:

```
mobile/
├── README.md
├── manifest.json        ← بيانات وصفية اختيارية للسطح الجوّال
├── screens/             ← ملفات نقطة الدخول التي تُعرض في WebView
│   └── dashboard.blade.php
└── assets/              ← css / js / صور هذه الشاشات
```

نقطة الدخول مثال: `mobile/screens/dashboard.blade.php`.

### السياسة (مُلزِمة)

خدمات الجوال **محصورة في نطاق `mobile/` المخصّص**:

- `entry` للـ embedded يجب أن يكون مساراً نسبياً **داخل `mobile/` حصراً**
  — بدون بروتوكول (`http(s)://`) وبدون خروج بـ `..`. يُرفض غير ذلك
  عند الحفظ/المراجعة.
- شاشات الجوال لا يُسمح لها بالربط أو الانتقال أو التضمين لأي صفحة
  منصّة/مستأجر **خارج** نطاق `mobile/`.
- يُفرض هذا أيضاً **وقت التشغيل** عبر middleware `app.webview` على
  okta-web (السطح الجوّال محصور على نطاق `/app`).
- في وضع `external` لا تشحن ملفات هنا — تستضيف الصفحة بنفسك، والعزل
  يكون عبر `allowed origins` + صفر ربط بيانات.

---

## مثال: تطبيق تشغيل مراكز الرعاية النهارية (External)

> هذا القسم مثال عملي متكامل يوضّح كيف تبني تطبيقاً External يستهدف مستأجرين من نوع **مركز الرعاية النهارية** (`daycare_center`) — النوع الجديد الذي أضافته منصة أوكتا لكيانات التشغيل التعليمي.

### لماذا External وليس Embedded؟

مراكز الرعاية النهارية تحتاج عمليات تشغيلية ذات طبيعة خاصة: تسجيل الحضور والمغادرة، إرسال تقارير يومية للأهالي، إدارة قائمة الأشخاص المُصرَّح لهم بالاستلام. هذه بيانات ذات دورة حياة قصيرة (يوميّة) وتحتاج إشعارات فورية، وكثيراً ما يرتبط فيها الشريك بمنصة هاتفية أو قاعدة بيانات مستقلة. لذلك يكون **External** الخيار المناسب:

| السبب | التفصيل |
|---|---|
| **بيانات تشغيلية خاصة** | سجلّات الحضور والتقارير اليومية تعيش في متجر الشريك، ليس في okta-web. |
| **Stack مستقل** | الشريك قد يستخدم نظام تحقّق هوية بيومتري أو قارئ RFID — لا يُشحن داخل okta-web. |
| **إشعارات فورية للأهالي** | بوّابات الرسائل الخاصة بالشريك (واتساب، Push، SMS) مُعدَّة مسبقاً على بنيته. |
| **حرية الـ Stack** | أي لغة/إطار، دون قيود قواعد عزل Embedded. |

> بيانات الحضور والتقارير اليومية والاستلام **لا تُخزَّن في okta-web ولا تُعرَّف كـ scopes للشركاء** — هذه بيانات تشغيلية يمتلكها تطبيق الشريك بالكامل. الـ scopes تُستخدم فقط لقراءة بيانات okta-web الضرورية للربط (قائمة الطلاب وأولياء الأمور).

### نوع المستأجر `daycare_center`

أضافت المنصة نوع المستأجر `daycare_center` (مركز رعاية نهارية) إلى الكتالوج المُشترك `canonicalTenantTypes`، الذي يُرسَل من okta-web إلى okta-partners عبر الجسر. هذا يعني:

- النوع يظهر تلقائياً في بوّابة الشركاء دون أي إجراء من جانبك.
- يمكنك الإعلان عن استهداف `daycare_center` كنوع مستأجر أساسي لتطبيقك من نموذج الإنشاء.
- مستأجرو نوع `daycare_center` يُمكِّنهم تثبيت تطبيقك وتفويض الصلاحيات المطلوبة، تماماً كأي نوع آخر.

لا تحتاج لأي كود إضافي لاستقبال هذا النوع — بمجرد نشر تطبيقك في المتجر، المنصة تعرضه للمستأجرين المؤهَّلين.

### النطاقات المطلوبة

تطبيق الرعاية النهارية يحتاج **قراءة بيانات الطلاب** من okta-web ليربطها بسجلّاته التشغيلية. النطاقات تُختار من picker الصلاحيات في بوّابة الشركاء — الكتالوج مرآةٌ من okta-web ويُزامَن تلقائياً في جدول `partner_available_scopes`. لا تُدخِل scopes بصيغ ثابتة في كودك؛ ما يظهر في الـ picker هو مصدر الحقيقة.

| النطاق | السبب |
|---|---|
| `education.students.read` | قراءة قائمة الأطفال المسجَّلين لدى كل مستأجر وبياناتهم الأساسية لمطابقتها مع سجلّات الحضور. |
| `education.students.write` | **اختياري** — لو أراد التطبيق تحديث حقل مخصَّص على ملف الطالب (مثل رقم بطاقة RFID). اطلبه فقط إن احتجته فعلاً؛ اقرأ مبدأ الأقل صلاحيةً. |

> **قاعدة الأقل صلاحية**: اطلب `education.students.read` فقط في المرحلة الأولى. لو قرّرت لاحقاً الكتابة، أضف `education.students.write` في إصدار جديد مع تبرير واضح في الـ manifest.

لا يوجد scope خاص بالحضور أو التقارير اليومية لأن هذه البيانات تعيش في متجر تطبيقك، ليس في okta-web.

### الـ Manifest

```json
{
  "moduleId": "daycare-ops",
  "displayName": "Daycare Operations",
  "version": "1.0.0",
  "category": "operations",
  "integrationType": "external",
  "description": "Attendance check-in/out, daily guardian reports, and authorized pickup management for daycare centers.",
  "scopes": [
    {
      "key": "education.students.read",
      "required": true,
      "reason": "لمطابقة أطفال المستأجر مع سجلّات الحضور والاستلام"
    }
  ],
  "external": {
    "webhookUrl":    "https://daycare.example/okta/webhook",
    "webhookEvents": [
      "education.students.created",
      "education.students.updated",
      "partner.installation.token_rotated"
    ],
    "redirectUrls":  ["https://daycare.example/oauth/callback"]
  }
}
```

`redirectUrls` مفيد إن أردت تدفّق OAuth-style لمزامنة أولية بعد تثبيت المستأجر: توجّهه لـ `/oauth/callback`، تحصل على installation token، تُجري مزامنة أوّلية للطلاب.

### المزامنة عبر Webhooks

بعد التثبيت تستلم الأحداث المشتركة فيها تلقائياً. يجب التحقّق من التوقيع لكل webhook (انظر [قسم الـ Webhooks](#الـ-webhooks)):

```php
// مثال: معالجة حدث تغيير بيانات طالب
$event = $request->json('event'); // "education.students.updated"
$data  = $request->json('data.student');

match ($event) {
    'education.students.created' => $this->syncNewStudent($data),
    'education.students.updated' => $this->updateStudentRecord($data),
    'partner.installation.token_rotated' => $this->storeNewToken(
        $request->json('data.new_token')
    ),
    default => null,
};

return response()->json(['ok' => true]);
```

> ردّك يجب أن يكون **2xx خلال 10 ثوانٍ**. لو احتجت معالجة أطول، ردّ بـ 202 فوراً وعالج عبر queue.

**الأحداث الموصى بها** لتطبيق مراكز الرعاية:

| الحدث | متى يصلك |
|---|---|
| `education.students.created` | طفل جديد يُضاف للمستأجر |
| `education.students.updated` | تغيير في بيانات الطالب (اسم، جهة اتصال، ...) |
| `partner.installation.token_rotated` | المستأجر دوَّر الـ token — احفظ الجديد فوراً |

أسماء الأحداث بصيغة `<feature>.<resource>.<action>` المعيارية؛ لا تخترع أسماء جديدة.

### المزامنة الأولية بعد التثبيت

عند أول تثبيت، اجلب قائمة الطلاب كاملةً بـ pagination لبناء قاعدة بياناتك المحلية:

```bash
# الصفحة الأولى
GET /api/apps/education/students?page=1&per_page=100
Authorization: Bearer <installation_token>

# كرّر حتى last_page
```

بعد المزامنة الأولية تعتمد على الـ Webhooks للتغييرات التدريجية — هذا يُقلّل استدعاءات الـ API ويُبقيك محدَّثاً لحظياً.

### إشعارات الأهالي

تطبيق الرعاية النهارية يُرسِل إشعارات خاصة به (وصول الطفل، مغادرته، تقرير اليوم) عبر قنواته المستقلة (واتساب/SMS/Push). هذه ليست جزءاً من نظام إشعارات أوكتا، بل بنية الشريك الخاصة.

إن أردت توحيد الإشعارات مع منصة أوكتا مستقبلاً (ليختار المستأجر القنوات من لوحته)، يمكنك إضافة كتالوج إشعارات لتطبيقك — راجع [قسم كتالوج الإشعارات](#كتالوج-الإشعارات).

### خلاصة الخطوات

1. أنشئ تطبيقاً External من البوّابة، اختر `daycare_center` كنوع مستأجر مستهدف.
2. اطلب `education.students.read` من picker النطاقات (و `write` إن احتجت).
3. اشترك في `education.students.created`، `education.students.updated`، `partner.installation.token_rotated`.
4. أضف `redirect_urls` لو أردت تدفّق OAuth-style للمزامنة الأولية.
5. عند التثبيت: اجلب الطلاب كاملاً، ثم اعتمد على Webhooks للتغييرات.
6. أرسل للمراجعة — فريق أوكتا يتحقق من الـ manifest و webhook URL ثم ينشره في المتجر.

---

## أسئلة متكررة

### هل يمكنني تغيير نوع التكامل بعد الإنشاء؟

لا. القرار محوري ويغيّر بنية التطبيق بالكامل. أنشئ تطبيقاً جديداً
بالنوع المطلوب وأرشف القديم.

### كم مدة المراجعة؟

عادةً 1-3 أيام عمل. التطبيقات Embedded أبطأ قليلاً لأنها تتطلب
مراجعة كود إضافية.

### هل يمكنني الوصول لبيانات مستأجرين متعددين بـ token واحد؟

لا. كل installation token مرتبط بـ (tenant, module) واحد فقط. إذا أردت
تطبيقاً يخدم عدة مستأجرين، تحصل على token مستقل لكل تثبيت.

### كيف أصل للأخبار/الأحداث الجديدة؟

اشترك في أحداث المنصة عبر `webhook_events` في تطبيقك External، أو
ارجع إلى الـ Pulse dashboard إن كنت Embedded.

### لو تأخّر تسليم webhook كثيراً، هل تختفي البيانات؟

لا. كل تسليم محفوظ في صفحة "Webhook Deliveries" مع زر replay يدوي.
أي تسليم giving_up يبقى في السجل ولا يُحذف.

### من أين أضيف إشعارات تطبيقي؟

اطّلع على قسم [كتالوج الإشعارات](#كتالوج-الإشعارات) للشرح الكامل.
المسار المباشر: **التطبيقات ← اختر تطبيقك ← تبويب "الإشعارات"**
(`/dashboard/modules/<slug>?tab=notifications`).

---

## مصادر إضافية

<ul class="docs-reslist">
<li><span class="k">OpenAPI:</span> <a href="https://partners.getokta.io/docs/openapi.json">https://partners.getokta.io/docs/openapi.json</a></li>
<li><span class="k">Postman:</span> <a href="https://partners.getokta.io/docs/postman_collection.json">https://partners.getokta.io/docs/postman_collection.json</a></li>
<li><span class="k">Status page:</span> <a href="https://getokta.io/status">getokta.io/status</a></li>
<li><span class="k">الدعم:</span> <a href="mailto:partners@getokta.io">partners@getokta.io</a></li>
</ul>
