البداية

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

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

فهرس

  1. أنواع التكامل
  2. البدء السريع
  3. نظام النطاقات (Scopes)
  4. دورة حياة التطبيق
  5. التطبيق المدمج (Embedded)
  6. توسيع ملف الطالب (Student Profile)
  7. التطبيق الخارجي (External)
  8. تطبيقات الإشعار (Notification)
  9. الـ Manifest
  10. الـ API
  11. الـ Webhooks
  12. كتالوج الإشعارات
  13. نُهج الأمان
  14. الاختبار محلياً
  15. نظام التصميم وواجهة المستخدم
  16. دعم الذكاء الاصطناعي
  17. تطبيق الجوال (Okta Mobile)
  18. مثال: تطبيق تشغيل مراكز الرعاية النهارية (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):

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):

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

مثال:

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

مثال:

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

مثال:

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

مثال:

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)

مثال:

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 فقط:

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 على الكلاس

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>):

{{-- 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

{
  "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 أو hybridnotification.api.send_endpoint لازم يكون HTTPS.
  • إذا delivery=embedded أو hybridnotification.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:

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"
  }
}

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

$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/1.1 200 OK
Content-Type: application/json

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

الحصول على signingSecret

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

// من جانب 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 يُطبِّق العقد:

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:

"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 يصف قدراته:

{
  "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 يُضاف:

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

للتطبيقات المدمجة التي توسّع صفحة ملف الطالب يُضاف بلوك studentProfile (راجع توسيع ملف الطالب).

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


الـ API

يتوفّر مرجع API تفاعلي عام بكامل نقاط النهاية والنطاقات وأمثلة cURL على صفحة مرجع الـ 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 (مواصفة OpenAPI 3.1 محدّثة آلياً). ويُمكنك استيراد Postman collection جاهزة.

Pagination

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

{
  "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:

{
  "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

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

$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) أعلاه يخص نوع التكامل الذي يكون فيه التطبيق مزوِّد قناة تسليم (واتساب/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 داخل تطبيقك:

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 المختصرة:

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

أو عبر الـhelper:

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 ليُذكِّرك بالتنظيف).

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

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

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

عقد الـManifest

عند build الإصدار، يُولَّد block جديد في manifest.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 في الكود

// 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:

// شغّل من 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 الخاصة بك:

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 مدمجة في الصفحة:

$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 للحصول على قواعد التحقّق الكاملة، أمثلة manifest، وأسئلة شائعة عن التكلفة والترقية بين own وplatform.

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

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

حقن AiManager

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() — محادثة بسيطة

$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() — استجابة متدفّقة (للواجهات التفاعلية)

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() — إكمال نصّ (بدون محادثة)

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

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

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

translate() — ترجمة

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

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

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:

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:

$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

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} طالب بنجاح",
        ];
    }
}

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

<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 للأخطاء المتوقّعة.

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

// 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

{
  "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):

// مثال: معالجة حدث تغيير بيانات طالب
$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 لبناء قاعدة بياناتك المحلية:

# الصفحة الأولى
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).


مصادر إضافية


↧ تنزيل هذه الصفحة كـ Markdown · مرجع الـ API ←