البداية
دليل مطوّر تطبيقات أوكتا
هذا الدليل يخاطب المطوّرين الذين يبنون تطبيقاً يتكامل مع منصة أوكتا. يفترض إلمام أساسي بـ HTTP/JSON وبأي إطار خادم لاختيارك (Laravel، Node، Python، إلخ).
فهرس
- أنواع التكامل
- البدء السريع
- نظام النطاقات (Scopes)
- حسابات المنصّة: مَن يفتح تطبيقك
- دورة حياة التطبيق
- التطبيق المدمج (Embedded)
- قاعدة بيانات التطبيق (Migrations)
- توسيع ملف الطالب (Student Profile)
- ويدجتس صفحة الهبوط (Landing Widgets)
- التطبيق الخارجي (External)
- تطبيقات الإشعار (Notification)
- تطبيقات الدفع (Payment)
- لوحة تحكم التطبيق (App Control Panel)
- أدوات المتصفح (Browser Tools)
- الـ Manifest
- الـ API
- الـ Webhooks
- كتالوج الإشعارات
- الطباعة على ترويسة المدرسة (قوالب التقارير)
- نُهج الأمان
- الاختبار محلياً
- التطوير بمساعدة الذكاء الاصطناعي (MCP)
- نظام التصميم وواجهة المستخدم
- دعم الذكاء الاصطناعي
- تطبيق الجوال (Okta Mobile)
- الرسائل الفورية (Realtime)
- النداء المسموع (الصوت والنطق)
- المايك ورفع الملفات (Voice & Files)
- هوية أوكتا داخل التطبيق المصغّر (الزجاج السائل)
- مثال: تطبيق «حضور بالمسح» (Native)
- مثال: تطبيق تشغيل مراكز الرعاية النهارية (External)
- أسئلة متكررة
أنواع التكامل
عند إنشاء تطبيق جديد ستُسأل أوّل شيء عن نوع التكامل. القرار يحدد بقية الرحلة:
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 على فشل.
خبرة عملية: التطبيق المدمج هو الأنسب للميزات التي تشعر أنها "جزء من أوكتا" (مثل واجهة تقارير مخصصة). التطبيق الخارجي هو الأنسب لما يحتاج لخدمة سحابية مستقلة (مثل ربط مع نظام شركة موجود لديك). تطبيق الإشعار خيار مستقل عن الاثنين — لا يضيف صفحة، فقط قناة قابلة للاستهلاك من باقي المنصة.
Payment (مزوّد دفع)
مزوّد بوابة/وسيلة دفع (Tabby / Tamara / Noon Payments / ...) تثبّته الجهة
مرة واحدة، ثم تدفع بقية التطبيقات المبالغ من خلاله عبر عقد charge(...)
موحَّد. مثل الإشعار في الشكل، لكن الفعل هو تحصيل المبالغ لا إرسال رسائل.
ثلاث طرق تسليم أيضاً: api / embedded / hybrid. استهلاك هذا
العقد (إنشاء دفعة من تطبيق آخر) متاح حصراً لتطبيقات Embedded — راجع
استهلاك المدفوعات من تطبيقك.
التفاصيل الكاملة في قسم تطبيقات الدفع (Payment).
البدء السريع
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 → إضافة/تعديل الطلاب (لا حذف)
education.face_embeddings.read → قوالب التعرّف على الوجه (خطِر، قراءة فقط)
employees.directory.read → قراءة الموظفين
reports.builder.read → قراءة التقارير
reports.builder.write → اقتراح قوالب (ترويسات) — لا تعريف تقارير
المبادئ الأساسية
- lowercase + dot-separated بدون wildcards.
- شريكاً يحصل فقط على
readوwrite. الحذف لا يُمنح أبداً للشركاء. - الكتالوج هو المصدر الوحيد. لا تخترع scopes جديدة.
- أوكتا يضيف نطاقات جديدة عبر تحديثات المنصة.
كتالوج النطاقات
اطلب الكتالوج الكامل من:
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). خزّنها كـchar(26)في جداولك، ووسِم العمود في الميغريشن بـcore_reference:<الجدول>وإلا لن يحميه الحارس — راجع المعرّفات والـ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.face_embeddings — قوالب التعرّف على الوجه
نطاق خطِر (
is_dangerous). شاشة التثبيت تعرضه بألوان الخطر، وأدمن الجهة سيتوقّف عنده ويقرأه. اطلبه فقط إن كان تطبيقك يطابق الوجوه فعلاً، واشرح السبب فيreasonبلغة يفهمها مدير مدرسة.
| النطاق | الصلاحيات |
|---|---|
education.face_embeddings.read |
قراءة قوالب الوجه (متجهات رقمية) للطلاب والموظفين المسجَّلين |
لا يوجد .write ولن يوجد. التسجيل يتمّ في المنصة بحضور الشخص أمام
مكتب الاستقبال؛ تطبيقٌ يستطيع كتابة قالب يستطيع أن يجعل أي كاميرا تتعرّف
على أي شخص بأنه شخص آخر.
لماذا هو منفصل عن education.student_photos.read: يبدوان الموافقة
نفسها وليسا كذلك. الصورة ما تطبعه المدرسة على البطاقة وتعرضه عند البوابة؛
القالب مفتاحٌ يطابق الطفل أمام أي كاميرا في أي مكان — وخلافاً لكلمة المرور
لا يمكن إصدار بديل عنه بعد التسريب. جهةٌ وافقت على عرض الوجوه لم تقل شيئاً
عن تسليم الرياضيات التي تتعرّف عليها.
نقاط النهاية:
| Method | Path | Scope | الوصف |
|---|---|---|---|
| GET | /api/apps/education/face-embeddings |
read | قوالب الجهة لنموذج واحد (مُرقَّم الصفحات) |
المعاملات: model (مطلوب)، person_type (student/employee،
اختياري)، page، per_page (افتراضي 50، بحد 100).
model مطلوب بلا افتراضي عمداً: AdaFace1 وArcFace_50r1 ينتجان
متجهات غير قابلة للمقارنة ببعضها. لو اختارت المنصة واحداً نيابةً عنك
لتسلّمت قوالب يفشل محرّكك في مطابقتها بصمت — بلا خطأ في أي مكان.
شكل الناتج:
{
"model": "AdaFace1",
"count": 2,
"total": 431,
"people": [
{
"person_type": "student",
"person_id": "01HXXXXXXXXXXXXXXXXXXXXXXX",
"model": "AdaFace1",
"dimensions": 512,
"captures_count": 3,
"registered_at": "2026-08-25T09:12:44+00:00",
"embedding": [0.0134, -0.0891, "…"]
}
]
}
count ≠ total: الأول ما تحمله هذه الصفحة بعد إسقاط الطلاب المُخلى
سبيلهم والصفوف غير القابلة لفكّ التشفير، والثاني ما تملكه الجهة. رقّم
الصفحات بـtotal — من يشتقّه من count يتوقّف مبكراً عند أول صفحة تصادف
طالباً مُخلى سبيله.
لا أسماء في الناتج. تحصل على متجهات مفهرسة بـ ULID فقط. إن أردت معرفة
صاحب الوجه الذي طابقته، اطلب education.students.read أيضاً وحلّ الـ ULID
هناك — منحة واحدة لا تُسلّمك اثنتين.
مثال Embedded (PHP):
use App\Services\PartnerApi\Education\FaceEmbeddings\ListFaceEmbeddings;
$page = app(ListFaceEmbeddings::class)(model: 'AdaFace1', page: 1, perPage: 50);
foreach ($page['people'] as $person) {
$person['person_id']; // ULID
$person['embedding']; // list<float>
}
مثال من تطبيق مصغّر (Dart) — لا يحتاج رمزاً خاصاً في okta_host:
final res = await Okta.get('/api/apps/education/face-embeddings?model=AdaFace1');
if (res.status == 200) {
final people = res.body['people'];
// people[i]['embedding'] متجه من الأعداد
}
حالات استخدام شائعة: بوّابة تتعرّف على الطلاب عند الدخول والخروج، تحضير آليّ في الفصل، مطابقة هوية قبل تسليم طالب لوليّ أمره.
المطابقة تحدث عندك لا عند المنصة. المتجهات تُسلَّم خاماً لأن المقارنة يجب أن تجري حيث الكاميرا — على بوّابة، داخل المبنى، غالباً بلا شبكة. نقطة نهاية تُعيد درجات تشابه بدلاً من متجهات كانت ستشترط الاتصال الذي لا تملكه البوّابة أصلاً.
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.curriculum — توزيع المنهج
| النطاق | الصلاحيات |
|---|---|
education.curriculum.read |
قراءة توزيع المنهج المنشور لمادة، وتحديد درس حصة بعينها |
للقراءة فقط. التطبيق الذي يشغّل الجدول اليومي يحتاج أن يسمّي درس الحصة،
ولا شأن له بإعادة كتابة توزيع المدرسة — فلا يوجد .write هنا، لأن نطاقاً
تمنحه المدرسة ولا يفعل شيئاً أسوأ من غيابه: شاشة الموافقة توحي بأنه يفعل.
نقاط النهاية:
| Method | Path | Scope |
|---|---|---|
| GET | /api/apps/education/subjects/{subject}/curriculum |
read |
| GET | /api/apps/education/subjects/{subject}/curriculum/session |
read |
القاعدة التي يقوم عليها العقد: أنت تملك الجدول، والمنصّة تملك التوزيع.
الحصص تعيش في تطبيقك لا في okta-web، فالمنصّة لا تعرف أن الحصة التي بدأت الآن
هي حصة العربي الثانية في هذا الأسبوع. تخمينها من التاريخ وحده يعني اختراع جدول
وتسمية الدرس الخطأ بثقة. لذلك أنت تمرّر الرقم الذي تحسبه أصلاً في
session_index، والمنصّة تمشي على أسابيع التوزيع حتى الدرس الذي يقع فيه.
GET /api/apps/education/subjects/{subject}/curriculum/session
?date=2026-08-25 # اختياري، الافتراضي اليوم
&session_index=3 # ترتيب حصة هذه المادة داخل الأسبوع (1-based)
{
"subject_id": "01K3…",
"date": "2026-08-25",
"session_index": 3,
"resolved": true,
"reason": null,
"week": { "number": 1, "position": 1,
"starts_on": "2026-08-23", "ends_on": "2026-08-27",
"kind": "study", "label": null, "lessons": [ … ] },
"lesson": {
"position": 2, "unit": "أسرتي", "title": "الدرس الأول: حرف (م)",
"kind": "lesson", "sessions": 3, "content": "الأهداف… الصفحات 22-27",
"session_of_lesson": 2, "sessions_in_lesson": 3
}
}
session_of_lesson تقول إنها الحصة الثانية من ثلاث لهذا الدرس — والمعلّم
في الحصة الثالثة من درس يحتاج أن يعرف أنها الثالثة.
مفتاحان للأسبوع، والثابت منهما position. number هو الرقم الذي تقرأه
المدرسة على الورقة، وهو عدّ أسابيع الدراسة وحدها: صندوق الإجازة يرسل
number: null — كما تطبع وزارة التعليم تماماً: «الأسبوع ١٣»، «إجازة الخريف»،
«الأسبوع ١٤» — وتعليمُ أسبوعٍ كإجازة يُنقص رقم كل أسبوع بعده واحداً. أما
position فهو موضع الصندوق في الفصل بما فيه الإجازات، ولا يتحرّك. إن خزّنت
إشارة إلى أسبوع فخزّن position، واعرض number ولا تبنِ عليه مفتاحاً.
لا تعامل «لا يوجد درس» كخطأ. ثلاث حالات ترجع 200 مع resolved: false
وreason قابلة للفرع عليها برمجياً، ولكل واحدة بطاقة مختلفة في واجهتك:
reason |
معناه | ما تعرضه |
|---|---|---|
week_holiday / week_exam |
الأسبوع إجازة أو اختبارات | اسم الإجازة من week.label |
week_not_planned |
المدرسة لم تُدخل دروس هذا الأسبوع | «لم يُخطَّط بعد» |
beyond_planned_sessions |
الحصص تجاوزت ما خُطِّط للأسبوع | «الصف متقدّم على التوزيع» |
session_index_missing |
لم تمرّر رقم الحصة | دروس الأسبوع بترتيبها من week.lessons |
no_week_for_date |
التاريخ خارج أسابيع التوزيع | لا شيء |
و404 محجوزة لمعناها الحقيقي وحده: لا يوجد توزيع منشور لهذه المادة.
المسودّة تُقرأ كعدم وجود توزيع لا كتوزيع فارغ — لأن توزيعاً نصف مُدخَل يبدو
مكتملاً، ومن يبني عليه يخطّط فصلاً على أسبوع لم ينتهِ.
الكتاب: textbook_url فقط يعبر الواجهة. الملف المرفوع يبقى خلف دخول
المدرسة — تقديمه عبر توكن صادر لتطبيقٍ لا لشخص قرار آخر لم يُتَّخذ.
حالات استخدام شائعة: عرض درس الحصة داخل الجدول اليومي، تحضير المعلّم، إشعار وليّ الأمر بما دُرِّس اليوم.
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 |
اقتراح قالب (ترويسة) من تأليف تطبيقك، وتعديله وحذفه — ما ألّفه هو وحده |
التقارير نفسها بلا write: الكتالوج تُنسّقه أوكتا ولا يستطيع شريك تعريف
تقرير جديد — أنت تشغّل الموجود وتأخذ مخرجاته. النطاق الثاني يخصّ القوالب
وحدها.
وwrite لا يشتري الطباعة. قالبٌ ألّفه تطبيقك يصل مسودةً ولا يُطبع
حتى تعتمده المدرسة، وتطبيقك لا يمسّ إلا ما ألّفه هو. التفصيل في
أن يقترح تطبيقك ترويسة.
النطاق يفتح سطحين مختلفين: كتالوج التقارير (بيانات تُعيدها أوكتا)، وقوالب المدرسة (ترويسة تطبع عليها أنت محتواك). الثاني موصوف كاملاً في الطباعة على ترويسة المدرسة.
نقاط النهاية:
| Method | Path | Scope |
|---|---|---|
| GET | /api/apps/reports/builder |
read |
| POST | /api/apps/reports/builder/{key}/run |
read (+ idempotent) |
| GET | /api/apps/reports/templates |
read |
| POST | /api/apps/reports/templates/{id}/render |
read |
| POST | /api/apps/reports/templates |
write (+ idempotent) |
| PATCH | /api/apps/reports/templates/{id} |
write |
| DELETE | /api/apps/reports/templates/{id} |
write |
مثال:
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',
]);
حالات استخدام شائعة: لوحات إحصائية، تصدير شهري للأهالي، تقارير المعلمين الدورية.
ساعة الجهة — /api/apps/tenant/clock
بلا نطاق. كل تطبيق مثبَّت يصل إليها بتوكن التثبيت وحده، ولا شيء يُطلب من الجهة.
وهذا قرار لا إغفال: المنطقة الزمنية ليست بيانات الجهة، بل الإطار الذي تُقرأ فيه بياناتها — كاللغة. تطبيقك يستقبل أصلاً طوابع هذه المدرسة الزمنية، فلا شيء هنا يحميه إذن. ووضع نطاق أمامها كان سيشتري صفراً من الخصوصية ويكلّف شيئاً حقيقياً: تطبيقات ترسم الأوقات في منطقة خاطئة لأن أحداً لم يفكّر في طلب منحة لم يفكّر أحد في منحها. وسجلّ حضور بساعة خاطئة لا يبدو مكسوراً — وهذا ما يجعل هذا العطل باهظاً.
| Method | Path | Scope | الوصف |
|---|---|---|---|
| GET | /api/apps/tenant/clock |
— | المنطقة الزمنية التي تقرأ بها الجهة يومها |
شكل الناتج:
{
"timezone": "Asia/Riyadh",
"offset": "+03:00",
"now": "2026-09-02T14:31:07+03:00"
}
خزّن timezone، ولا تخزّن offset أبداً
offset وnow يصفان هذه اللحظة لا هذا المكان. الاسم وحده يحمل القواعد،
فيبقى صحيحاً حين تتغيّر القواعد تحته.
وهذا ليس افتراضاً نظرياً — إحدى الدول المدعومة تعمل بالتوقيت الصيفي:
| المنطقة | يناير | يوليو |
|---|---|---|
Asia/Riyadh |
+03:00 | +03:00 |
Asia/Dubai |
+04:00 | +04:00 |
Africa/Cairo |
+02:00 | +03:00 |
فتطبيق خزّن +02:00 لمدرسة قاهريّة في يناير يصير ساعةً كاملة خطأً في
أبريل — على كل شاشة، بلا خطأ في أي سجلّ، والتاريخ صحيح والصيغة صحيحة والساعة
وحدها مغلوطة. ولو خزّن "Africa/Cairo" لتحرّك معها وحده.
استعمل offset للعرض بجانب وقت، أو لتسليمه لشيء لا يقبل اسم منطقة. لا لشيء
آخر.
التخزين يبقى UTC
كل طابع زمني تُسلّمه المنصّة لك، وكل طابع تُعيده أنت، هو UTC — وهذا لا يتغيّر. هذه النقطة تجيب سؤالاً آخر: ماذا يُعرَض. حوِّل عند العرض، لا عند الكتابة؛ قيمةٌ حُوِّلت وقت الدخول قيمةٌ لا يستطيع أحد مقارنتها بين مدرستين بعد ذلك، والضرر لا يظهر حتى تجتمع مدرستان في تقرير واحد.
من أين تأتي القيمة
المدرسة تختارها من إعداداتها. وإن لم تختر، تُشتقّ من دولتها حين يكون لها منطقة واحدة، وإلا فمن إعداد المنصّة. أنت تحصل على الجواب النهائي دائماً — فلا تعامل غياب اختيار صريح كحالة خاصّة.
مثال Embedded (PHP):
use App\Services\PartnerApi\Tenants\GetTenantClock;
$clock = app(GetTenantClock::class)();
$clock->timezone; // «Asia/Riyadh» — هذا ما يُخزَّن
$clock->offset; // «+03:00» — للعرض فقط
$clock->now; // ISO8601 على ساعة المدرسة
// وموقتٌ مخزَّن UTC مقروءاً على ساعتها:
app(GetTenantClock::class)->at($row->created_at)?->format('Y-m-d H:i');
مثال من تطبيق مصغّر (Dart) — لا يحتاج رمزاً جديداً في okta_host ولا رفع
minContract:
final res = await Okta.get('/api/apps/tenant/clock');
if (res.status == 200) {
final zone = res.body['timezone']; // خزّن هذا
}
حالات استخدام شائعة: طباعة أوقات الحضور كما رآها من سجّلها، تحديد ما هو «اليوم» لدى المدرسة، جدولة تذكير في الصباح المحلّي لا صباح الخادم.
لا تسألها مرّة وتخزّن now. «كم الساعة» سؤالٌ يُسأل حين يُحتاج جوابه؛
والاسم وحده هو ما يستحقّ أن يُحفَظ.
المعرّفات والـcore_reference
كل response يخرج من المنصة يستبدل numeric IDs بـULIDs. الـcore_reference
هو عمود في جدولك أنت يحمل واحداً من هذه المعرّفات: سلسلة 26 حرفاً تشير إلى صف
في جدول منصّة على okta-web (students, subjects, grades, sections,
tenants, tenant_employees, users).
لماذا لا يوجد قيد FK حقيقي: الصف المُشار إليه يعيش في قاعدة بيانات أخرى
على okta-web، وPostgres لا يعرف قيود مفاتيح أجنبية عابرة لقواعد البيانات. فما
تخزّنه هو char(26)/varchar(26) عادي بلا أي قيد — ومن هنا تأتي المشكلة:
char(26) عرض قد تستخدمه لأي شيء آخر (كود قسيمة، رقم فاتورة خارجية، بادئة
hash). العرض وحده ليس دليلاً، ولو عاملت المنصة كل char(26) كمرجع منصّة
لرفضت كتاباتك المشروعة.
ولهذا الحارس لا يخمّن: عليك أن توسم العمود صراحةً في الميغريشن.
الوسم — صيغتان
الصيغة (أ) — تعليق على العمود في كتالوج Postgres (المفضّلة)، لأنها تبقى في قاعدة البيانات الحيّة فتقرأها okta-web وأدوات الفحص لاحقاً:
CREATE TABLE "students" (
"id" BIGSERIAL PRIMARY KEY,
"owner_ulid" CHAR(26) NOT NULL,
"teacher_ulid" CHAR(26)
);
COMMENT ON COLUMN "students"."owner_ulid" IS 'core_reference:tenants';
COMMENT ON COLUMN "students"."teacher_ulid" IS 'core_reference:users';
في ميغريشن Laravel هذه هي ->comment():
$table->string('owner_ulid', 26)->index()->comment('core_reference:tenants');
الصيغة (ب) — تعليق SQL على سطر العمود نفسه، داخل CREATE TABLE أو على
ALTER TABLE … ADD COLUMN من سطر واحد:
CREATE TABLE "students" (
"id" BIGSERIAL PRIMARY KEY,
"owner_ulid" CHAR(26) NOT NULL, -- core_reference: tenants
);
ALTER TABLE "students" ADD COLUMN "teacher_ulid" CHAR(26); -- core_reference: users
الفاصل بين الكلمة والهدف مرن: core_reference: tenants و
core_reference=tenants و core_reference(tenants) كلها مقبولة. و
core_reference بلا هدف تُقبل أيضاً كوسم (قاعدة صيغة الـULID تنطبق مهما كان
الجدول الهدف) — لكن سمِّ الهدف دائماً، فهو ما يظهر في الـ manifest.
ماذا يحدث بلا وسم
لا شيء يُرفَض — وهذه هي النقطة الخطرة. العمود ببساطة لا يراه الحارس، فلا يحميه:
App\Services\Partners\Sandbox\ValidateCoreReferenceWritesلا يعرف أن العمود مرجع منصّة، فيمرّر أي قيمة تكتبها فيه بلا فحص صيغة ULID ولا فحص أن الجدول الهدف مسموح.- كتلة
core_referencesفي الـ manifest تخرج فارغة لأعمدتك غير الموسومة.
الأعمدة بعرض 26 بلا وسم تُصنَّف «مرشّحة» (candidates) وتُعرَض لك في اللوحة — تنبيهاً لا رفضاً. فالفرق بين «هذا الجدول لا يحوي مراجع منصّة» و«قد يحويها ولا نراها» فرق يجب أن تحسمه أنت بالوسم.
ميغريشنات بمسار ملف داخل مستودعك (
.php) لا يمكن قراءة SQL منها في وقت الفحص، فتُحسب ضمنunreadableوتُعرَض على أنها غير مفحوصة — لا تفترض تغطية كاملة معها.
ممنوع (بوسم أو بدونه):
(int) $row->student_id_ulid— كسته إلى integer.where('student_id_ulid', 12345)— مقارنته بـliteral رقمي.
كلاهما يُكشَف من boilerplate scanner ويفشل CI. خزّنه ومرّره دائماً كـstring.
تحديث الكتالوج محلياً (okta-partners)
الكتالوج يُحدَّث تلقائياً من okta-web عبر:
- Cron كل ساعة (افتراضي).
- Webhook فوري على
partner_scopes.catalog.changed. - يدوي:
php artisan partners:sync-scope-catalog --force.
تحديث محلي عبر hash drift detection — لو الـhash لم يتغيّر يتجاوز المزامنة بالكامل.
حسابات المنصّة: مَن يفتح تطبيقك
النطاقات تجيب «أي بيانات يقرأ تطبيقك». وهذا القسم يجيب سؤالاً آخر تماماً: مَن الجالس أمام الشاشة. المنصّة تعرف ثلاثة حسابات، وهي لا تعمل بآلية واحدة — وهذا الفرق هو ما يكسر افتراضات أكثر من أي شيء آخر في التكامل.
| الحساب | أين يُسجَّل | النطاق (scope) |
الجهة في السياق | كيف تخاطبه |
|---|---|---|---|---|
| معلّم / إداري | tenant_employees + دور داخل الجهة |
tenant |
tenant_id |
"roles": ["teacher"] |
| طالب | tenant_students |
general |
portal_tenant_id فقط |
"portal": "student" |
| وليّ أمر | tenant_guardians |
general |
portal_tenant_id فقط |
"portal": "guardian" |
١. الموظّف — دور داخل الجهة
المعلّم والإداري يعيشان داخل الجهة: صفّ في tenant_employees يحمل
type (وظيفته)، وعضوية في الجهة، ودور. الثلاثة لازمة: العضوية تُظهِر الجهة
في منتقي السياق، والدور هو ما يُدخِله فعلاً.
ومَن تُسجّله المدرسة معلّماً يُمنَح دور teacher تلقائياً — دور بلا أي
صلاحية، مفتاح باب لا أكثر. ما يحقّ له فعله يُقرَّر بعدها بإلحاق صلاحيات
بالدور أو بوضعه في مجموعة مدراء.
داخل تطبيقك هذا هو الحساب «العاديّ»: Tenant::current() مضبوطة، والنطاق
tenant، وكل ما تعرفه عن سياق الجهة صحيح.
المطابقة أوسع مما تظنّ. "roles": ["teacher"] تُطابَق على ثلاثة أشياء
معاً: الدور النشط، وكل الأدوار المختارة في الجلسة، وtenant_employees.type
نفسه. كلّها تُطبَّع قبل المقارنة (حروف صغيرة، والمسافات و_ تصير -)،
فـ"Teacher" و"teacher" سواء.
٢ و٣. الطالب ووليّ الأمر — بوّابتان لا دوران
هنا يتغيّر كل شيء. الطالب ووليّ الأمر ليس لهما صفّ دور إطلاقاً: اسم
البوّابة هو الجمهور نفسه. والنطاق general، وtenant_id في السياق فارغ،
والجهة تعيش في مفتاح آخر: portal_tenant_id.
وهذا ليس تفصيلاً في التسمية. حساب البوّابة لا يملك سياق جهة بالمعنى
المعتاد. المنصّة تُقلِع جهته لهذا الطلب وحده، وعلى صفحات التطبيق التي
أعلنها بيانك فقط؛ خارجها يبقى بلا جهة، وصفحات المنصّة المرتبطة بجهة تظلّ
ترفضه. فأيّ كود يفترض أن Tenant::current() موجودة دائماً سيسقط على صفحة
بوّابة.
ووليّ الأمر عابر للجهات: قد يكون له أبناء في مدرستين، فيختار الجهة عند الدخول — وتلك الجهة هي التي تحملها القائمة الجانبية وتُفتَح بها التطبيقات.
كيف تُعلن جمهورك
تبويب «أنواع الحسابات» في محرّر الإصدار — أو set_account_types عبر MCP
— وهو ما يصير menu.audiences[] في okta-web وmobile.audiences[] في تطبيق
أوكتا. لكل نوع:
"account_types": [
{ "key": "teacher", "kind": "primary", "roles": ["teacher"], "web_route": "school-app.staff" },
{ "key": "guardian", "kind": "dependent", "portal": "guardian", "web_route": "school-app.family" }
]
kind—primary(الجمهور الأساسي) أوdependent(تابع).- هدف واحد فقط:
roles(أدوار داخل الجهة) أوportal(بوّابة). - سطح واحد على الأقل:
web_routeو/أوmobile_entry.
وweb_route اسم مسار Laravel لا رابطاً ولا /path — وحدّ فضاء أسماء:
school-app.admin يغطّي school-app.admin.reports ولا يغطّي
school-app.admins. شكل المقاطع يحمل معنى، فلا تكتبه اعتباطاً.
الفخّ: يُنشَر بنجاح ثم لا يعمل أبداً
مفتاح بوّابة داخل roles[]. "roles": ["student"] يبدو سليماً تماماً —
student مفتاح معروف في الكتالوج — لكن المنصّة تطابق مستخدم البوّابة على حقل
portal وحده ولا تطابقه أبداً على أسماء الأدوار. فالإعلان لا يطابق
أحداً: يُنشَر الإصدار، ويبدو البيان صحيحاً، ثم تُرفض كل صفحات ذلك الجمهور لكل
مستخدم. و"custom": true لا تُنقذه — البوّابة ليست دوراً تعرّفه الجهة،
والمنصّة ترفض custom على جمهور portal أصلاً.
ومفتاح دور مخترَع أو مكتوب خطأً ("staff" مثلاً) يفشل بنفس الطريقة
الصامتة. الجهات تعرّف أدوارها الخاصة، فالمفتاح خارج الكتالوج مقبول — لكن
بإعلان صريح: "custom": true. أما بدونها فمرفوض عمداً، لأن البديل أن
تكتشف الخطأ من شكوى مدرسة لا من رسالة نشر.
الفرض حقيقي لا إخفاء
EnsureAppAudience مُطبَّقة على مجموعة web كاملةً — بلا تعاون منك — و
كذلك على نداءات Livewire عبر persistent middleware تُعيد الفحص على مسار
الصفحة الأصلية. فحراسة الصفحة وحدها كانت ستترك النوافذ مفتوحة: كل فعل بعد
الرسم نداء POST إلى مسار آخر.
والبوّابة ترفض بالافتراض. قبلها كان المُشغِّل يخفي الرابط في قائمة التطبيقات فقط، ومَن يعرف المسار ويكتبه في شريط العنوان كان يُخدَم.
مَن يفتح تطبيقك الآن؟ — ULID المستخدم
معرفة نوع الحساب لا تكفي: التطبيق يحتاج أن يعرف أيّ شخص بعينه ليعرض له ما يخصّه هو — حصص هذا المعلّم، أبناء وليّ الأمر هذا.
use App\Services\PartnerApi\Identity\GetCurrentViewer;
$viewer = app(GetCurrentViewer::class)();
if ($viewer === null) {
return; // لا مستخدم من الأنواع الثلاثة يشاهد الآن.
}
$viewer->type; // employee | student | guardian
$viewer->id; // ULID — وهو ما تخزّنه وتربط به
$viewer->displayName;
$viewer->roles; // مفاتيح الأدوار — للموظّف وحده
$viewer->entityId; // ULID الجهة
لا تستعمل auth()->id() لهذا
الفاحص لا يمنعها، لكنها مفتاح رقمي داخلي للمنصّة، وقاعدة العقد أن المعرّفات الرقمية لا تعبر حدّ الشريك أبداً. والأهمّ أنها لا تفيدك: هي الرقم نفسه سواء دخل الشخص معلّماً أو وليَّ أمر، فلا تُطابِق شيئاً مما تملكه.
id يُطابِق ما تُرجِعه خدمات القراءة — وهذا كلّ الفائدة
| النوع | id هو نفسه |
|---|---|
student |
id في StudentDto |
employee |
id في EmployeeDto |
guardian |
id في GuardianDto |
فالربط مباشر بلا تحويل:
$mine = MyAppRecord::query()->where('student_ulid', $viewer->id)->get();
ولاحظ أن هذه المعرّفات تأتي من جداول مختلفة عمداً: الطالب يُعرَّف بصفّ تسجيله، ووليّ الأمر بسجلّ مستخدمه. فلا تحاول اشتقاق المعرّف بنفسك من أي شيء آخر — اقرأه من هنا.
type هي الهويّة التي دخل بها، لا كل ما هو عليه
إنسان واحد قد يكون معلّماً في المدرسة ووليَّ أمر لطالب فيها. يدخل بإحداهما، وهذه تُخبرك بأيّهما دخل — وهي نفس الهويّة التي بنت عليها بوّابة الجماهير قرارها بخدمة الصفحة. فتطبيق يقرّر بـ«هل هذا الشخص وليّ أمر في مكان ما؟» سيعرض للمعلّم شاشة وليّ الأمر.
null ليست خطأ
تعني: لا أحد مسجَّل دخوله، أو أن المُشاهِد ليس من الأنواع الثلاثة — مشرف المنصّة يفتح صفحتك مثال على ذلك. اعرض حالة فارغة مفهومة، لا خطأً.
لا شيء منها يُقرَأ من الطلب
الهويّة تُحلّ في الخادم من سياق الجلسة. لا تقبل معرّف مستخدم من الواجهة ولا من query string: مُشاهِد يستطيع المُنادي تسميته هو مُشاهِد يستطيع تزويره.
أثر الإعلان على ظهور تطبيقك
الإعلان لا يحكم المسارات وحدها، بل ظهور تطبيقك في مشغّل التطبيقات:
- لم تُعلن جماهير أصلاً؟ بطاقتك تظهر لكل من في الجهة، كما كانت دائماً.
- أعلنت جماهير وطابَق المستخدمُ واحداً؟ تظهر البطاقة، ورابطها هو مسار ذلك الجمهور.
- أعلنت جماهير ولم يطابق أياً منها؟ لا بطاقة إطلاقاً.
الحالة الثالثة مقصودة: بطاقة تفتح صفحة سترفضها البوّابة أسوأ من غياب البطاقة، لأن المستخدم يقرؤها عطلاً في صلاحياته لدى المنصّة لا تطبيقاً لم يكن له. فإن اختفى تطبيقك عن نوع حساب تتوقّع أن يراه، فالعطب في إعلانك لا في تثبيت الجهة.
الوضع الحالي: البوّابة تسجّل ولا ترفض بعد
PARTNER_AUDIENCE_GUARD_MODE قيمته الافتراضية log اليوم: البوّابة
تسجّل المخالفة وتخدم الصفحة. وهذا يعني أن إعلاناً خاطئاً — مفتاح بوّابة
داخل roles[]، أو دور مخترَع بلا custom — يبدو عاملاً الآن، ثم يتوقّف
تماماً في اللحظة التي تُقلَب فيها القيمة إلى enforce.
فلا تختبر إعلانك على سلوك اليوم. اختبره تحت الفرض:
config(['partners.audience_guard.mode' => 'enforce']);
والقاعدة عندها الرفض بالافتراض داخل التطبيق المقسَّم: ما إن تُعلن
audiences حتى يصير كل مسار لا يغطّيه أي جمهور مرفوضاً. فصفحة نسيت
إعلانها لا تبقى مفتوحة — تُغلق. أحصِ مساراتك قبل أن تُعلن أول جمهور.
ملاحظة: إخفاء البطاقة في المشغّل يعمل الآن بلا انتظار قلب الوضع، لأنه ترشيح عرض لا حراسة. فقد ترى تطبيقك مخفياً عن مستخدم وما يزال يستطيع فتح مساره بكتابته — هذا هو الفارق بين
logوenforceبعينه.
دورة حياة التطبيق
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).
كيف تصل جداولك إلى ذلك الـ schema — وكيف تصحّح ما أخطأت فيه — في قاعدة بيانات التطبيق (Migrations).
قاعدة بيانات التطبيق (Migrations)
ملفات الترحيل في مستودعك هي مصدر الحقيقة. لا يوجد مصمّم مخططات في البوّابة يرسم لك جدولاً — كان موجوداً وحُذف، لأنه لم يكن يكتب شيئاً في مستودعك، فصار الجدول المرسوم حقيقة ثالثة لا تطابق المستودع ولا القاعدة الحيّة. تكتب ملف Laravel migration عادياً، وتدفعه، وتستورده.
السلسلة كاملة
database/migrations/ في مستودعك
│ «استيراد من المستودع» (أو import_migrations_from_repo)
▼
متجر الترحيلات في البوّابة ──→ manifest.database.migrations[]
│
▼ provision_database (sandbox) / زر البوّابة (production)
schema خاص بتطبيقك في PostgreSQL
كل حلقة لازمة. بلا الاستيراد يُصدِر البيان requiresDatabase:false
وmigrations:[]، فترفض okta-web إنشاء القاعدة بحجّة «لا شيء لتجهيزه» —
والاستيراد نفسه هو ما يقلب requires_database ويملأ database_schema على
إصداراتك.
شكل الملف
database/migrations/2026_07_20_101500_create_exam_papers.php
└─────┬────────┘ └────────┬───────────┘
version اسم الترحيل
الصيغة إلزامية: YYYY_MM_DD_HHMMSS_<name>.php. ما لا يطابقها يُتجاوَز
بصمت.
version هو مفتاح كل شيء بعد ذلك: ترتيب التنفيذ، ومطابقة الاستيراد
المتكرّر، وسجلّ ما نُفِّذ. تغيير الطابع الزمني لملف قائم = ترحيل جديد في نظر
المنصّة، لا تعديل للقديم.
ملف الأساس
000000_create_<app>_database.phpيشحنه القالب باسم يبدأ بـ
000000عمداً: هذا الاسم لا يطابق الصيغة أعلاه، فلا يُستورَد أبداً. إنشاء القاعدة نفسها خطوة تقوم بها okta-web، وملف يفعلها مجدداً يصطدم بها.لكن التجاوز بالاسم لا بالنيّة: إن أعدت تسميته إلى طابع زمني حقيقي (
2026_07_26_100000_create_<app>_database.php) فهو ترحيل عادي في نظر المنصّة، ويُستورَد ويُشحَن — وغالباً بطابع زمني أقدم من ترحيلاتك الحقيقية، وهو ما يقود مباشرةً إلى الخطأ التالي.
«Out-of-order migration» — ولماذا يوقف كل شيء
Out-of-order migration "2026_07_26_100000" precedes already-applied
"2026_07_27_100001" on binding #21. Migrations are immutable once applied
— add a new corrective migration instead.
المُطبِّق يمرّ على الترحيلات بترتيب version، ويرفض تنفيذ ترحيل أقدم من
أحدث ما نُفِّذ فعلاً على تلك القاعدة. السبب أن العكس كذبة: تشغيل ترحيل قديم
بعد أحدث منه يعطيك مخططاً لم يمرّ به أي تثبيت آخر، ويجعل ترتيب التنفيذ
مختلفاً بين قاعدتين تدّعيان النسخة نفسها.
والرفض يُسقِط التشغيل كلّه، لا ذلك الصفّ وحده. فترحيل واحد بطابع زمني خاطئ يجمّد كل ما بعده: تقرأ «مطبَّقة ٧، معلَّقة ١» ولا تتحرّك مهما ضغطت «مزامنة الآن».
كيف تخرج منه:
- إن كان الصفّ لا يجب أن يُشحَن أصلاً — ملف الأساس أعلاه هو الحالة النموذجية — احذفه من الجدول. لم يُنفَّذ على شيء، فالحذف نظيف تماماً، ويزول الحاجز فوراً.
- إن كان ترحيلاً حقيقياً وصل متأخراً بطابع زمني قديم: أعطه طابعاً زمنياً بعد آخر ما نُفِّذ (أي ملفاً جديداً في مستودعك)، ثم احذف الصفّ القديم وأعد الاستيراد.
- إن كان قد نُفِّذ فعلاً على قاعدة ما، فلا تحذفه ولا تعدّله: أضف ترحيلاً تصحيحياً جديداً. هذا هو معنى «immutable once applied» في نصّ الخطأ.
حالتان: draft و published
| الحالة | من أين | قابل للتعديل | يدخل الـ manifest |
|---|---|---|---|
published |
الاستيراد من المستودع | ✗ SQL مجمَّد | ✓ في كل بيان لاحق |
draft |
SQL مباشر تكتبه في البوّابة | ✓ | ✗ حتى يُنشَر |
الاستيراد يكتب published مباشرةً — هذه ملفات إصدار فعلية لا مسودّات
تُجرَّب. لهذا لا تعني «منشور» هنا أن أحداً قرّر الإصدار: ملف كان ناقصاً يوم
دفعتَه يصل مجمَّداً.
تصحيح ترحيل بعد وصوله
الطريق الأول — ترحيل جديد. هذا هو الطبيعي: ALTER يصحّح ما فعله
سابقه. SQL المنشور مجمَّد لسبب: قاعدة مدرسة نفّذته بالفعل، وإعادة كتابته
تحت قدمها تجعل ما في القاعدة مختلفاً عمّا يقوله البيان.
الطريق الثاني — الحذف. متاح لكل صفّ، منشوراً كان أو مسودّة، من جدول قاعدة البيانات في البوّابة. لكن اعرف ما يفعله وما لا يفعله:
- ✓ يزيله من كل بيان قادم → التثبيتات الجديدة لن تحصل عليه.
- ✗ لا يتراجع عن شيء نُفِّذ. جدولٌ أُنشئ الأسبوع الماضي يبقى منشأً.
- ✗ لا يمسّ البيانات المُرسَلة سابقاً إلى okta-web؛ الفرق يبدأ من النشرة التالية.
- ✗ لا يمسّ سجلّ التنفيذ. ذلك السجلّ يقول ما نفّذته قاعدة حقيقية، وتعديله ليطابق قراراً اتُّخذ بعده يفقد المنصّة قدرتها على معرفة ما في تلك القاعدة.
فإن كان الترحيل قد نُفِّذ على قواعد قائمة، الحذف يُنشئ اختلافاً دائماً بينها وبين أي تثبيت جديد. البوّابة تعرض على الصفّ شارة «منفَّذ على N قاعدة» وتغيّر نصّ التأكيد بحسبها — اقرأه، فهو الفرق بين تنظيف قائمة وبين مخططين لن يتطابقا مجدداً.
متى الحذف هو الجواب الصحيح؟ ترحيل ناقص أو معطوب لم يُنفَّذ بعد على أي قاعدة، أو صفٌّ لم يعد له ملف في مستودعك أصلاً.
الصفوف التي لم يعد لها ملف
الاستيراد يضيف ويحدّث ولا يحذف. فلو حذفت ملفاً من مستودعك ودفعت وأعدت الاستيراد، يعود إليك «تم الاستيراد بنجاح» بينما الملف المحذوف ما يزال يُشحَن في كل بيان.
لذلك يُبلِغك الاستيراد بعددها ويضع على صفوفها شارة «غير موجود في
المستودع». لا تُحذف تلقائياً: الاستيراد يجري على مرجع (ref) واحد،
وغياب الملف عنه قد يعني غيابه عن ذلك الفرع فقط، وقد يكون نُفِّذ فعلاً على
قاعدة حيّة. القرار قرارك على الصفّ.
SQL مباشر داخل البوّابة
للتصحيحات الصغيرة التي لا تستحقّ دورة كاملة عبر المستودع. تُحفَظ draft
وتُنشَر مع الإصدار التالي. تُرفَض عبارات لا شأن لترحيل تطبيق بها —
DROP DATABASE/SCHEMA/ROLE/USER، وGRANT/REVOKE،
وALTER ROLE/USER، وCOPY … FROM PROGRAM، ودوالّ قراءة ملفات الخادم.
الشبكة الحقيقية ليست هذه القائمة بل الـ schema المعزول: دور الاتصال لا
يستطيع لمس بيانات تطبيق آخر أصلاً.
أدوات MCP
| الأداة | ماذا تفعل |
|---|---|
list_migrations |
الترحيلات بترتيب التنفيذ + علم requires_database لكل إصدار |
read_migration |
جسم ترحيل واحد — النسخة التي تنفّذها المنصّة فعلاً، وقد تختلف عن مستودعك |
import_migrations_from_repo |
نفس زر «استيراد من المستودع»، ويبلّغ عن الصفوف المفقودة |
provision_database |
إنشاء قاعدة sandbox وتطبيق ما لم يُطبَّق (production من البوّابة فقط) |
database_status |
هل جُهِّزت القاعدة، ونتيجة آخر تشغيل، وكم مطبَّق مقابل معلَّق |
diff_schema |
ما تصفه ملفاتك مقابل ما في قاعدة sandbox فعلاً |
diff_schema يذكر تغطيته دائماً: ترحيل مخزَّن كمسار ملف لا يحمل SQL هنا،
فلا يستطيع الفاحص ادّعاء «غير مُعلَن» على ما لم يقرأه.
توسيع ملف الطالب (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" />.
ويدجتس صفحة الهبوط (Landing Widgets)
يقدر تطبيقك المُضمَّن يضيف بلوكات إلى محرر صفحة الهبوط العامة للجهة: الويدجت يظهر في تصنيف «تطبيقاتك» داخل المحرر (للجهات التي ثبّتت تطبيقك فقط)، والجهة تسحبه إلى موقعها العام فيُعرض لزوّارها كأي بلوك من بلوكات المنصة — دون أن تكتب الجهة سطر كود واحداً. أنت تصرّح بالويدجت في الـ manifest وتشحن مكوّن Livewire واحداً؛ المنصة تتكفّل بالباقي:
- بطاقة المحرر باسم تطبيقك وأيقونته.
- فورم إعدادات البلوك يتولّد آلياً من تصريح
settings_schema— لا تكتب أي Blade للمحرر. - سلوك البلوك (نقل/نسخ/أعمدة/إظهار وإخفاء) يأتي من المحرر نفسه؛
الويدجت يُخزَّن داخل مستند الصفحة كبلوك جسر واحد من نوع
partner-widget— تثبيت تطبيق جديد لا يضيف block class للمنصة. - الأمن مجاناً وإجبارياً: سياق التشغيل على الصفحة العامة تبنيه المنصة، لا أنت.
لتطبيقات Embedded فقط. الويدجت يُرسم in-process داخل okta-web كمكوّن Livewire؛ تطبيق مستضاف خارجياً لا يملك ما يُرسم هناك. تصريح
landing_widgetsمع أيintegrationTypeآخر يُرسِب نشر الإصدار.
اقرأ نموذج الأمن قبل أن تبني. هو الذي يحدّد ما يمكن لويدجتك الوصول إليه أصلاً — بيانات الأشخاص والأموال لن تصل ويدجتك على صفحة عامة أبداً، مهما كانت النطاقات الممنوحة لتثبيتك.
التصريح في الـ manifest
{
"integrationType": "embedded",
// ... بقية الـ manifest ...
"landing_widgets": [
{
"key": "admission_register",
"label": "التسجيل في القبول",
"label_en": "Admission Registration",
"description": "نموذج تقديم طلب قبول من موقع الجهة العام.",
"description_en": "Admission application form on the tenant public site.",
"icon": "clipboard",
"category": "conversion",
"livewire_component": "my-app-landing-register",
"interaction": "static",
"scopes": ["education.grades.read"],
"cache_ttl": 300,
"settings_schema": {
"fields": [
{ "key": "title", "type": "text", "label": "العنوان", "label_en": "Title",
"localized": true, "default": "سجّل الآن", "min": 1, "max": 80 },
{ "key": "show_deadline", "type": "toggle", "label": "إظهار آخر موعد", "default": true }
]
},
"preview": { "thumbnail_url": "https://cdn.example.com/widgets/register.png" }
}
]
}
الحقول
landing_widgets مصفوفة بحد أقصى 10 ويدجت لكل تطبيق.
| الحقل | إلزامي | القيمة |
|---|---|---|
key |
✓ | ^[a-z][a-z0-9_]{1,39}$ — فريد داخل التطبيق. المعرّف العالمي هو المركّب <module-slug>:<key>؛ به يُشار للويدجت من البلوكات المخزَّنة على صفحات الجهات. تغييره بعد النشر = ويدجت جديد والبلوكات القديمة تفقد مرجعها — سمّه جيداً من أول إصدار. |
label / label_en |
label ✓ |
اسم البطاقة في المحرر (2–60 حرفاً). label_en حسب locale ويتراجع إلى label. |
description / description_en |
— | وصف قصير تحت البطاقة (≤ 300 حرف). |
icon |
— | اسم أيقونة (≤ 40 حرفاً، الافتراضي puzzle). القيمة غير المعروفة تتراجع لأيقونة افتراضية بلا خطأ. |
category |
— | مجموعة البطاقة في المحرر: hero | content | media | conversion | layout (الافتراضي conversion). |
livewire_component |
✓ | alias مكوّن Livewire بالصيغة المسطَّحة. أي alias يحوي :: يرفض النشر — Livewire 4 لا يحلّه أصلاً (نفس مصيدة لوحة تحكم التطبيق). |
interaction |
— | static (الافتراضي) أو interactive. التفاعلي عقد أثقل بالكامل — انظر الويدجت التفاعلي. |
scopes |
— | نطاقات القراءة التي يحتاجها الرسم وقت التشغيل (≤ 20)، تنتهي حصراً بـ .read — أي فعل آخر يرفض النشر. تصريح تقييد لا منح: ما تعلنه هنا يدخل تقاطع ضلع القراءة، وما لا تعلنه لا يصل للويدجت حتى لو كان ممنوحاً للتثبيت. |
public_writes |
— | نطاقات الكتابة التي ينفّذها الويدجت نيابةً عن زائر مجهول (≤ 10)، تنتهي حصراً بـ .write. ثلاثة شروط عند النشر: الصيغة، وأن كل مدخل نطاق نشط وموسوم public_writable في كتالوج okta-web (يُفحص ضد الكتالوج الحي — وإن تعذّر الفحص يُرفض التصريح بدل قبوله بلا تحقق)، وأن interaction=interactive — الساكن يُقدَّم من كاش بلا round-trip يمكن أن تحدث فيه الكتابة. |
cache_ttl |
— | ثواني تخزين HTML المرسوم على الصفحة العامة (0–86400، الافتراضي 300). يُتجاهَل كلياً للتفاعلي — جسم مخزَّن كان سيعيد عرض حالة فورم زائر لزائر آخر. |
settings_schema |
— | تصريح حقول الإعدادات — القسم التالي. |
preview.thumbnail_url |
— | صورة مصغّرة للبطاقة. رابط مطلق https حصراً. |
فورم الإعدادات: settings_schema
{ "fields": [ { /* حقل */ }, ... ] } // بحد أقصى 20 حقلاً
الأنواع مفردات مغلقة — ثمانية لا غير:
text, textarea, number, toggle, select, color, image, link.
لا يوجد ولن يوجد نوع html (والتصريح به يرفض النشر برسالة صريحة).
هذا قرار أمني لا نقص: كل قيمة إعداد تمرّ عبر escaping الـ Blade عند
الرسم، فلا يمكن لأدمن جهة (أو لمخترق حسابه) حقن سكربت في الصفحة العامة
عبر إعدادات ويدجتك. إن احتجت تنسيقاً غنياً، ابنه في مكوّنك من حقول
مهيكلة.
خصائص الحقل:
| الخاصية | تنطبق على | الوصف |
|---|---|---|
key |
الكل | ^[a-z][a-z0-9_]{0,39}$، فريد داخل الويدجت. |
type |
الكل | أحد الأنواع الثمانية. |
label / label_en |
الكل | عنوان الحقل في الفاحص (label إلزامي، ≤ 120 حرفاً). |
localized |
الكل | true = المحرر يكتب القيمة للّغة المفتوحة فقط؛ false/غائبة = يعكسها على اللغتين معاً. مكوّنك يستقبل دائماً settings لغةٍ واحدة — لغة الصفحة المعروضة — ولا يحتاج أن يعرف أي الحقول مترجَم. |
default |
الكل | القيمة الابتدائية عند الإدراج. تُفحص ضد النوع: toggle يقبل bool فقط، number رقماً فقط، وdefault لـ select لازم يكون من options المعلنة. |
help / help_en |
الكل | نص مساعدة تحت الحقل (≤ 300 حرف). |
min / max |
text, textarea, number |
حدود الطول للنصوص والقيمة للأرقام. على بقية الأنواع تُسقَط بصمت. |
options |
select |
[{"value": "...", "label": "...", "label_en": "..."}] قائمة ثابتة (≤ 50 خياراً، القيم فريدة). select بلا options يرفض النشر. |
options_endpoint |
select |
محجوز ومرفوض حالياً. الصيغة (<provider>:<name>) تُفحص ثم يُرفض التصريح صراحةً عند النشر ما دام لا resolver على المنصة — رفض مقروء اليوم خير من حقل يتدهور صامتاً إلى صندوق نص في محرر كل جهة. استعمل options الثابتة. |
مثال يغطي الأنواع الثمانية:
{ "fields": [
{ "key": "title", "type": "text", "label": "العنوان", "localized": true,
"default": "سجّل الآن", "min": 1, "max": 80 },
{ "key": "intro", "type": "textarea", "label": "المقدمة", "localized": true, "max": 400 },
{ "key": "max_rows", "type": "number", "label": "عدد الصفوف", "default": 5, "min": 1, "max": 20 },
{ "key": "show_fees","type": "toggle", "label": "إظهار الرسوم", "default": false },
{ "key": "layout", "type": "select", "label": "التخطيط",
"options": [
{ "value": "grid", "label": "شبكة", "label_en": "Grid" },
{ "value": "list", "label": "قائمة", "label_en": "List" }
],
"default": "grid" },
{ "key": "accent", "type": "color", "label": "لون التمييز", "default": "#0ea5e9" },
{ "key": "banner", "type": "image", "label": "صورة الترويسة" },
{ "key": "policy", "type": "link", "label": "رابط سياسة القبول", "localized": true }
]}
نقطتان تحكمان فهمك للتخزين:
- البلوك يُخزَّن لكل لغة داخل مستند الصفحة، والحقول غير المترجَمة تُكرَّر عبر اللغتين بنفس القيمة — لا مخزن «مشترك».
- بلوك أُدرج قبل أن تضيف حقلاً جديداً في إصدار لاحق لن يحمل مفتاحه.
لذلك: كل قراءة من
settingsفي مكوّنك خلف?? $default، دائماً.
كتابة المكوّن
الويدجت مكوّن Livewire عادي داخل تطبيقك، يخضع لنفس عقد الكود Embedded كله — لا استثناء لكونه على صفحة عامة:
- البيانات حصراً عبر
App\Services\PartnerApi\*— الـ policy scanner يرسب البناء على غيرها، وكل خدمة تستدعي فحص النطاق داخلياً فما ليس في السياق يُرفض تلقائياً. - لا تفترض مستخدماً. على الصفحة العامة
auth()->user()هوnullدائماً. كود يفترض جلسة سينكسر هناك حتى لو عمل في معاينة المحرر. - إعدادات المطوّر متاحة:
GetAppSetting/GetAppSettingsتعمل داخل الويدجت كأي سياق تشغيل — تقرأ مخزن تطبيقك أنت فقط، فتغيّر سلوك الويدجت دون نشر إصدار جديد. - الواجهة بمعايير المنصة: مكوّنات
<x-…>والـ tokens الدلالية — الويدجت يُرسم داخل صفحة الجهة بثيمها وبراندها، والألوان الصلبة تكسر ذلك. - افترض الفشل: استثناء من مكوّنك على الصفحة العامة لا يُظهر صفحة خطأ — يُنتج فراغاً صامتاً مكان الويدجت (انظر أدناه).
الويدجت الساكن (static) يُرسم مرة ويُخزَّن ناتجه cache_ttl
ثانية: أي wire:click أو wire:model في blade ساكن يتجمّد سمات ميتة
في HTML مخزَّن. مناسب للعرض: قوائم، إحصاءات، بطاقات، روابط. ومكوّنه
يستقبل عند الرسم بالضبط settings + locale ولا شيء غيرهما — ما
يحتاجه عن الجهة يقرأه من الـ ModuleContext النشط كأي خدمة PartnerApi.
نموذج الأمن — سطحان غير متماثلان
سطحا الرسم غير متماثلين، والفرق بينهما هو جوهر التصميم كله:
- معاينة المحرر: أدمن الجهة مسجّل دخوله، فالسياق يُحلّ كالمعتاد (نفس مسار بقية صفحات تطبيقك). المعاينة ترى ما يراه التطبيق عادةً.
- الصفحة العامة: الزائر مجهول تماماً — لا مستخدم، لا جلسة. المنصة تعيد بناء سياق التشغيل لكل طلب من الجهة وحدها (تُحلّ من الدومين)، ونطاقات هذا السياق هي اتحاد مجموعتين، كلٌّ منهما تقاطع ثلاثي مستقل:
READ = (الممنوح للتثبيت) ∩ (الموسوم public_surface في الكتالوج) ∩ (scopes الويدجت)
WRITE = (الممنوح للتثبيت) ∩ (الموسوم public_writable في الكتالوج) ∩ (public_writes الويدجت)
السياق = READ ∪ WRITE
ليستا مساراً واحداً بعلمٍ مدموج عمداً: القراءة والكتابة تجيبان
سؤالين مختلفين («هل يجوز لغريب أن يرى هذا؟» مقابل «هل يجوز لغريب أن
يسبّب هذا؟»)، ومسار واحد كان سيسمح لتعديل مستقبلي على أحدهما أن
يحرّك الآخر بصمت. ومرشَّح الفعل يعمل مرتين على كل ضلع: .write
داخل scopes[] ليس تصريح قراءة مهما قال الـ manifest، وصف كتالوج
وُسم خطأً لا يركب منحةً إلى السياق.
ضلع الكتابة يحمل شرطين إضافيين: علم تفاعلية عام تفحصه المنصة داخل الـ resolver نفسه، وتذكرة كتابة عامة لا تُسكّ إلا بعد اجتياز الحواجز (انظر الويدجت التفاعلي). بلا تذكرة = مجموعة كتابة فارغة — وهذا ما يحصل عليه كل مسار قراءة، افتراضياً وإلى الأبد.
المبدأ الحاكم: ما لا يدخل السياق لا يحتاج حارساً. لا فحص أذونات جديد ولا middleware إضافي ولا قائمة منع تُصان يدوياً — سياق فقير عمداً، والحارس القائم نفسه الذي يحرس كل خدمات PartnerApi يرفض كل نطاق غير موجود فيه.
النتيجة العملية التي يجب أن تعرفها قبل أن تبني: نطاقات الطلاب
وأولياء الأمور والموظفين والماليات لا تُوسم public_surface ولا
public_writable أبداً. استثناؤها بنيوي لا سياسة: ليست قاعدة في
middleware يمكن أن يخطئها أحد — هي ببساطة لا تدخل السياق، فكل استدعاء
لها يُرفض كأنها لم تُمنح قط. ويدجت يستدعي ListStudents على صفحة
عامة سيفشل دائماً، مهما أعلنت في scopes. إن كان تصوّر ويدجتك
يحتاج بيانات أشخاص على صفحة عامة، فالتصوّر نفسه يحتاج إعادة نظر.
قائمة public_surface اليوم (قد تتوسع بقرار منصّة، بتحفّظ شديد):
countries.directory.read— بيانات مرجعية عامة (قائمة الدول).countries.education_levels.read— سُلّم المراحل التعليمية المرجعي.education.grades.read— صفوف الجهة نفسها (بنية مؤسسية تنشرها المدرسة على موقعها أصلاً — لا شخص ولا سجل فيها).
الحقلان scopes وpublic_writes في تصريحك هما الضلع الثالث من
تقاطعَيهما، وهما في صالحك: صرّح بأقل ما يلزم، فتضمن أن ثغرة في كود
ويدجتك لا تصل إلا لما أعلنته — حتى لو كان تثبيتك ممنوحاً أكثر.
الفشل فراغ صامت — ومخزَّن
فشل الويدجت على الصفحة العامة — استثناء، نطاق مرفوض، alias لا يُحلّ، تطبيق أُزيل تثبيته — يُنتج فراغاً صامتاً مكان البلوك، ولا يُسقط موقع الجهة أبداً. وفشل الساكن يُخزَّن 30 ثانية لمنع الطَرق المتكرر: ويدجت ينهار لن يُعاد تنفيذه مع كل زائر. اعكسها على نفسك أثناء التطوير: إصلاحك قد لا يظهر فوراً بسبب كاش الفشل، وستظن أن «الويدجت لا يعمل بلا سبب» بينما السبب استثناء مبلوع — افحص سجلات okta-web ولا تعتمد على تحديث الصفحة المتكرر كاختبار.
على السطح الآخر — round-trip لويدجت تفاعلي — الفشل ليس صامتاً: طلب لا يمكن نسبته إلى ويدجت تملكه الجهة فعلاً يُرفض 403. الصمت للصفحة، والصراحة للـ round-trip.
الويدجت التفاعلي (interaction=interactive)
ويدجت يقبل إدخالاً من زائر مجهول (فورم تقديم مثلاً). عقد أثقل بكثير من مجرد قلب الحقل، وكل جملة فيه مفروضة بكود لا بمراجعة.
الوراثة إلزامية — و CI يفرضها
مكوّنك يجب أن يرث App\Support\LandingWidgets\PublicWidgetComponent
— صنف تملكه المنصة. قاعدة landing-widget-base-class في policy scanner
تُرسِب البناء لأي class تحت app/LandingWidgets/ في تطبيقك لا يرثه:
Livewire\Component عادي هناك ليس «ويدجت بلا إضافات» — هو مسار كتابة
عام بلا اشتقاق سياق وبلا حواجز، يفشل مفتوحاً لا مغلقاً. (صنف وسيط خاص
بك يرث PublicWidgetComponent شكل مشروع — أعفِه على سطر التصريح بـ
// partner-policy:allow=landing-widget-base-class.)
لماذا: /livewire/update لا يذكر شيئاً
الرسم الأول تصنعه المنصة وهي ممسكة بالجهة والتثبيت والسياق. لكن كل
wire:click بعده يذهب إلى /livewire/update — route لا يعرف صفحات
هبوط ولا جهات؛ كل ما معه snapshot أرسله المتصفح. لذلك الصنف الأساسي
يعيد اشتقاق السياق كاملاً على كل hydrate من بيانات حيّة: جهة
حقيقية → الباقة ما زالت تشمل الويدجتس → التفاعلية مفعّلة → الويدجت ما
زال في كتالوج الجهة → مصرَّح interactive → سياق منحول بالتقاطعات.
#[Locked] على خصائص الهوية يعمل عملاً حقيقياً لكنه ليس الحارس:
tenantUlid مزوَّر يُحلّ على بيانات حيّة ويُرفض لأن تلك الجهة لم
تثبّت تطبيقك. حتى settings المحمولة على الـ snapshot يُعاد ضبطها على
schema الويدجت الحالية قبل أي استخدام.
mount() وboot() وdehydrate() وexception() كلها final عمداً.
نقطتك أنت: mounted() تعمل مرة بعد قيام السياق. ولا تخزّن الـ
context على خاصية — قيم per-request تُعاد صناعتها كل hydrate.
مكوّنك يستقبل عند الرسم — إضافةً إلى settings — ثلاثية الهوية
moduleSlug / widgetKey / tenantUlid + widgetLocale، لأنه سيعيد
بناء السياق بها على كل round-trip. (الاختيار بفحص الصنف المحلول، لا
بحقل interaction المصرَّح.)
الكتابة: withPublicWrite() والحواجز الثلاثة
كل كتابة نيابةً عن الزائر تمرّ حصراً من:
public function submit(): void
{
$this->validate([...]);
$this->withPublicWrite('submit', function (): void {
app(\App\Services\PartnerApi\...\CreateSomething::class)(...);
});
}
بالترتيب، وكلها في الصنف الأساسي فلا تستطيع إسقاط أحدها:
- honeypot — حقل مخفٍ تملكه المنصة (انظر الـ partial أدناه). تعبئته = بوت.
- throttle — لكل (IP × جهة × module × ويدجت × action)؛ الافتراضي
5/دقيقة، أو
maxPerMinuteلكل نداء. دالةthrottle()متاحة لك مستقلةً أيضاً لحماية قراءة مكلفة. - captcha — يعمل فقط حين تربط المنصة provider وتفعّله الجهة (أو تفرضه المنصة عاماً). المنصة لا تشحن provider افتراضياً، فالحاجز غائب بدل أن يحجب جهةً بفحص لا يستطيع العمل.
الرفض ليس استثناء تعالجه: الـ callback ببساطة لا يعمل،
withPublicWrite يرجع null، و$widgetBarrierMessage يحمل جملة
مترجمة يعرضها الـ partial للزائر.
بعد اجتياز الثلاثة — وهناك فقط — تُسكّ تذكرة الكتابة العامة، والسياق
يُعاد اشتقاقه معها فيدخل ضلع الكتابة، يعمل الـ callback، ثم
finally يعيد الاشتقاق بلا تذكرة — تصريح الكتابة يدوم بالضبط
بقدر الكتابة، والـ render التالي بسياق قراءة فقط. لا مكان آخر في
المنصة يسكّ التذكرة، فلا مسار لكتابة عامة تخطّى حاجزاً.
الـ form-guard partial — include إلزامي لا يُتجاوز
@include('landing-builder::blocks.partner-widget.partials.form-guard')
مرة واحدة داخل عنصر جذر مكوّنك. يرسم الـ honeypot + علامة الحارس +
رسالة الرفض. الفرض من الـ HTML الناتج فعلاً: الصنف الأساسي يفحص
مخرَج الرسم ويشتق منه حالة الحارس — فورم بلا الـ partial يصل للكتابة
التالية بحاجز honeypot فاشل دائماً، ولا يتجاوز. العرَض المضلِّل حين
تنساه: الفورم يظهر ويعمل ظاهرياً، والكتابة ترجع null مع رسالة حاجز.
فخّ النشر: SESSION_DOMAIN — اعرفه قبل أن تنشر
الويدجت التفاعلي = POST بجلسة و CSRF، والكوكي يعود للخادم فقط إذا غطّى نطاقَ المضيف المخدوم:
SESSION_DOMAINغير مضبوط: كل نطاق جهة (مخصص أو subdomain) يحصل على جلسته. هذا الترتيب الذي يعمل.SESSION_DOMAINمثبَّت على نطاق المنصة: جهة تخدم صفحتها منexample.comلا تحصل على كوكي جلسة إطلاقاً — الصفحة تُرسم، الفورم يظهر، وأول نقرة 419 أمام زائر لا يستطيع الإبلاغ.
المنصة تكشف الحالة الثانية ولا «تصلحها» (نطاق كوكي منصّي يُضبط عادة عمداً): تقمع الويدجت التفاعلي على ذلك المضيف وتعرض كرتاً مهذّباً بدل فورم ينتهي بـ 419، مع سطر warning في سجلات okta-web. ليس خطأك ولا خطأ كودك — شرط نشر. اختبر على نطاق مخصص إن كانت جهاتك تستعملها.
النطاق الوحيد الموسوم public_writable اليوم
education.admission_applications.write — طلب قبول يقدّمه زائر من
موقع الجهة العام. صندوق بريد: يدخل ولا يخرج — لا يوجد له .read
مقابل إطلاقاً (قراءة عامة كانت ستكشف طلبات المتقدمين الآخرين لأي
زائر)، وهو موسوم is_dangerous فيُعرض بألوان الخطر على شاشة منح
التثبيت عمداً.
توسيع القائمة قرار منصّة لا يُطلب عبر الـ manifest، ومحكوم بثلاث قواعد غير قابلة للتفاوض: intake فقط (إنشاء سجل وارد جديد يخصّ الزائر نفسه — لا update ولا لمس سجل قائم)، لا أشخاص ولا أموال، لا delete أبداً. عندك حالة استخدام؟ قدّمها لفريق المنصة.
دورة الحياة
- النشر: تضيف
landing_widgetsإلى manifest إصدارك وتنشره. الفحص عند القبول (الصيغ، الحدود،integrationType=embedded، وسومpublic_writesضد الكتالوج الحي) — الكتلة المخالفة ترفض الإصدار. - التثبيت: الجهة تثبّت تطبيقك من المتجر وتمنح النطاقات كالمعتاد.
- الظهور في المحرر: كتالوج ويدجتس الجهة يُشتق لكل طلب من تثبيتاتها — ويدجتس التطبيقات المثبَّتة فقط.
- الإدراج: أدمن الجهة يسحب الويدجت، والفاحص يعرض فورم الإعدادات
المولَّد من
settings_schema. المعاينة تُرسم بسياق الأدمن. - نشر الصفحة: على الموقع العام يُرسم الويدجت بالسياق المجهول
(اتحاد التقاطعين). الساكن يُخزَّن ناتجه
cache_ttlثانية (مفتاح الكاش يحمل بصمة الإعدادات — تعديل الإعدادات ونشره يظهر فوراً)؛ التفاعلي يُرسم حيّاً على كل طلب. - إزالة التثبيت: الويدجت يختفي من كتالوج المحرر فوراً. البلوكات المدرَجة سابقاً على صفحات منشورة تفقد مرجعها فتسقط في مسار الفشل نفسه: فراغ صامت، لا خطأ على موقع الجهة. (الجهة تحذف البلوك اليتيم من محررها متى شاءت.)
- تحديث الإصدار: الكتالوج يعكس manifest الإصدار المثبَّت. حقل
settings جديد لا يظهر في بلوكات قديمة إلا بقيمته الافتراضية من كودك
(
?? $default)؛ حذف ويدجت أو تغييرkeyيترك البلوكات القديمة يتيمة كما في الإزالة.
تسجيل الـ alias والمكوّن
// في ServiceProvider::boot() لتطبيقك — الصيغة المسطَّحة إلزامية.
use Livewire\Livewire;
Livewire::component(
'my-app-landing-register',
\Modules\MyApp\Livewire\Landing\RegisterWidget::class,
);
<?php
namespace Modules\MyApp\Livewire\Landing;
use Livewire\Component;
class RegisterWidget extends Component
{
/** قيم settings_schema للّغة المعروضة، كما خزّنها المحرر. */
public array $settings = [];
public function render()
{
// القراءة حصراً عبر App\Services\PartnerApi\* — الخدمة تفحص
// النطاق داخلياً وترفض ما ليس في السياق.
$grades = app(\App\Services\PartnerApi\Education\Grades\ListGrades::class)(
onlyActive: true,
);
return view('my-app::livewire.landing.register-widget', [
'grades' => $grades->data,
'title' => $this->settings['title'] ?? 'سجّل الآن',
]);
}
}
هذا كل شيء للويدجت الساكن. التفاعلي يرث PublicWidgetComponent بدل
Component، يعيش تحت app/LandingWidgets/، ويكتب عبر
withPublicWrite() كما في قسمه أعلاه.
الأخطاء الشائعة وحلولها
| العرَض | السبب | الحل |
|---|---|---|
| الويدجت لا يُرسم أبداً | alias يحوي :: |
الصيغة المسطَّحة في livewire_component وفي Livewire::component() معاً |
| «لا يعمل بلا سبب» أثناء التطوير | الفشل صامت + كاش فشل 30 ثانية | افحص سجلات okta-web؛ لا تعتمد على تحديث الصفحة كاختبار |
| يعمل في المعاينة ويفرغ على الموقع العام | نطاق غير موسوم public_surface أو غير معلَن في scopes |
المعاينة بسياق أدمن كامل؛ اختبر بالسياق الفقير قبل النشر |
| أزرار لا تستجيب على الموقع | ويدجت static فيه wire:* |
التفاعلية تتطلّب interaction=interactive وعقده كاملاً |
| كل كتابة تُرفض برسالة حاجز | نسيان @include(... form-guard) |
مرة واحدة داخل عنصر الجذر |
| 419 عند أول نقرة على نطاق مخصص | SESSION_DOMAIN مثبَّت على نطاق المنصة |
شرط نشر — انظر فخّ SESSION_DOMAIN |
| قيمة إعداد تظهر نصاً حرفياً | كل قيم settings تُهرَّب عند الرسم |
مقصود — لا HTML عبر الإعدادات أبداً |
قائمة تحقق قبل النشر
-
integrationType=embeddedوالكتلةlanding_widgets≤ 10 ويدجت. - كل
keyيطابق^[a-z][a-z0-9_]{1,39}$وفريد داخل التطبيق — واسمه نهائي. -
livewire_componentبالصيغة المسطَّحة (لا::)، ومسجَّل بـLivewire::component()فيboot(). -
scopesهي الحد الأدنى الذي يحتاجه الرسم فعلاً — لا نسخ لقائمة نطاقات التثبيت. - لا استدعاء لأي نطاق أشخاص/ماليات من مسار الرسم العام — لن يُوسم
public_surfaceأبداً. -
settings_schema≤ 20 حقلاً، الأنواع من الثمانية، وكل قراءةsettingsخلف?? $default. - الويدجت الساكن بلا أي
wire:*في الـ blade. - التفاعلي: يرث
PublicWidgetComponent، تحتapp/LandingWidgets/، وكل كتابة داخلwithPublicWrite(). - التفاعلي:
@include('landing-builder::blocks.partner-widget.partials.form-guard')مرة واحدة داخل عنصر الجذر. -
public_writesكلها موسومةpublic_writableفي الكتالوج وinteraction=interactive. - التفاعلي مجرَّب على نطاق جهة مخصص إن كانت جهاتك تستعملها.
- الرسم مجرَّب بسياق فقير (بلا مستخدم، بنطاقات
scopesفقط) وبِلغتي الصفحة وبحالة بيانات فارغة. -
preview.thumbnail_urlرابط https يعمل، والبطاقة مراجَعة في المحرر.
التطبيق الخارجي (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أوhybrid⇐notification.api.send_endpointلازم يكون HTTPS. - إذا
delivery=embeddedأوhybrid⇐notification.embedded.provider_classلازم يكون FQCN صالح (مثلModules\Foo\Providers\Bar). - لا يُسمح بـ
notificationblock إلا حين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.
تطبيقات الدفع (Payment)
تطبيق الدفع يُسجَّل كبوابة/وسيلة دفع قابلة للتوصيل تثبّتها الجهة مرة واحدة،
ثم تدفع بقية التطبيقات المبالغ من خلاله عبر عقد دفع موحَّد في okta-web.
أنت تبني المزوِّد مرة، والمنصة تستدعيه بـ charge(...) بصرف النظر عن
البوابة الفعلية (Tabby، Tamara، Noon Payments، ...).
الـ manifest block
{
"integrationType": "payment",
"payment": {
"payment_methods": ["card", "mada", "applepay", "stcpay", "tabby", "tamara", "bank_transfer", "wallet", "cash"],
"delivery": "api",
"api": {
"charge_endpoint": "https://your-gateway.example/okta/charge",
"auth": "hmac"
},
"embedded": {
"provider_class": "Modules\\AcmePay\\Payments\\AcmePayProvider"
},
"capabilities": {
"supports_refunds": true,
"supports_partial_refunds": false,
"supports_installments": true,
"min_amount": null,
"max_amount": null,
"currencies": ["SAR"]
},
"settings_ui": {
"has_settings_page": true,
"livewire_component": "vendor-x-payment-settings"
}
}
}
قواعد التحقق التي تفرضها المنصة على manifest الدفع:
payment.payment_methodsمصفوفة غير فارغة من قيم معروفة:card,mada,applepay,stcpay,tabby,tamara,bank_transfer,wallet,cash.payment.deliveryلازم تكونapiأوembeddedأوhybrid.- إذا
delivery=apiأوhybrid⇐payment.api.charge_endpointلازم يكون HTTPS. - إذا
delivery=embeddedأوhybrid⇐payment.embedded.provider_classلازم يكون FQCN صالح (مثلModules\AcmePay\Payments\AcmePayProvider). - لا يُسمح بـ
paymentblock إلا حينintegrationType=payment.
وسائل دفع مخصصة
بالإضافة إلى الوسائل التسع القياسية، يمكن لمزوّد الدفع تعريف وسائل
مخصصة عبر بلوك custom_methods بجانب payment_methods:
"payment": {
"payment_methods": ["card", "mada", "my_regional_wallet"],
"custom_methods": [
{ "key": "my_regional_wallet", "label": "محفظتي الإقليمية", "label_en": "My Regional Wallet", "kind": "wallet" }
],
"delivery": "api"
}
القواعد:
key: يطابق^[a-z][a-z0-9_]{1,31}$، ولا يساوي أي وسيلة قياسية، وفريد ضمن القائمة.label(عربي) وlabel_en(إنجليزي): مطلوبان، طول كل منهما 2–60 حرفاً.kind: مطلوب، أحد:card|wallet|bnpl|transfer|cash|other.- كل مفتاح في
custom_methodsلازم يظهر فيpayment_methods(والعكس: أي إدخال غير قياسي فيpayment_methodsلازم يقابله إدخال فيcustom_methods). في نموذج الإنشاء تُضاف مفاتيح الوسائل المخصصة تلقائياً إلىpayment_methods، فلا حاجة لتأشيرها يدوياً. - يُحذف
custom_methodsمن الـ manifest تماماً حين لا توجد وسائل مخصصة.
التطبيقات المستهلِكة ترى الوسائل المخصصة (مع مسمياتها) عبر
GET /api/apps/payments/methods تماماً كالوسائل القياسية، وتستخدم
key عند تنفيذ الدفعة.
الـ scopes (تُمنح تلقائياً)
عند اختيار integrationType=payment من نموذج الإنشاء، المنصة تُلحق
تلقائياً بمنحات تطبيقك:
payments.charges.update(إجباري — يسمح بتحديث حالة الدفعة)payments.charges.read(اختياري — قراءة حالة الدفعة)
لا تحتاج اختيار شيء من picker الصلاحيات لو تطبيقك بحت مزوّد دفع.
مسار 1: delivery=api
العقد بين المنصة وتطبيقك
تستقبل من okta-web طلب دفعة موقَّعاً:
POST https://your-gateway.example/okta/charge
Content-Type: application/json
X-Okta-Timestamp: 1736435261
X-Okta-Signature: <hmac-sha256-hex>
X-Okta-Charge-Id: <uuid>
X-Okta-Idempotency-Key: <key>
{
"method": "tabby",
"amount": 150.00,
"currency": "SAR",
"description": "اشتراك سنوي",
"customer": { "name": "أحمد", "phone": "+966500000000", "email": "a@example.com" },
"metadata": {},
"charge_ref": "chg_01H..."
}
- التوقيع:
X-Okta-Signature=hmac_sha256("<timestamp>.<body>", signingSecret)— نفس آلية الإشعار. - لا إعادة محاولة تلقائية من okta-web؛ يُعتمد على
X-Okta-Idempotency-Keyلضمان عدم الخصم المزدوج إن أعدت الطلب.
الرد المتوقَّع
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "pending",
"provider_ref": "tabby_pay_123",
"redirect_url": "https://checkout.tabby.ai/..."
}
statusواحدة من:pending|paid|failed.provider_refمعرّف العملية لدى المزوّد.redirect_urlاختياري — لو الدفع يحتاج توجيه العميل (Tabby/Tamara).- أي رد 2xx يُعتبر مقبولاً؛ حدِّث الحالة لاحقاً عبر callback الحالة.
تحديث الحالة لاحقاً
عند تغيّر حالة العملية (تم الدفع / فشل)، يرسل تطبيقك تحديثاً لـ okta-web
بـ installation token الخاص به (scope payments.charges.update):
POST https://<okta-web>/api/apps/payments/charges/{charge_ref}/status
Authorization: Bearer <installation-token>
Content-Type: application/json
{ "status": "paid", "provider_ref": "tabby_pay_123" }
مسار 2: delivery=embedded
تشحن class داخل okta-web يُطبِّق App\Contracts\PartnerPaymentProvider
(charge / refund / supports / isConfigured). لا تَرمِ exceptions من
charge() — التف بـ try/catch وأعِد نتيجة فشل.
مسار 3: delivery=hybrid
يبني المنصة مزوّداً هجيناً يجرّب المسار embedded أولاً، ويتراجع لـ api
عند عدم تهيئة الـ class (isConfigured()=false) أو فشل غير دائم.
استهلاك المدفوعات من تطبيقك (Embedded فقط)
قاعدة صارمة: استهلاك الدفع (إنشاء دفعة / قراءتها / سردها / استرجاعها) متاح حصراً لتطبيقات Embedded. أي محاولة استهلاك من تطبيق External تُرفض بخطأ
payment_consumption_embedded_only. السبب: ردّة الفعل على نتيجة الدفعة تصل عبر حدث داخل العملية (in-process) لا webhook — تطبيق مستضاف خارجياً لا يملك مساراً للاستماع لحدث Laravel داخلي، فسيبقى "أعمى" عن النتيجة النهائية لو سُمح له بالدفع. مزوّدو الدفع أنفسهم (integrationType=payment) لا يستهلكون أيضاً — دورهم استقبال نداء الدفع وتحديث حالته فقط (payments.charges.update)، وليس بدء عمليات دفع لصالح تطبيقات أخرى.
كيف تعمل الآلية من طرف إلى طرف
- الجهة تُثبّت مزوّد دفع واحد أو أكثر (
integrationType=payment) من/partner-apps/payment/providers— خطوة إدارية تقوم بها الجهة نفسها، لا علاقة لتطبيقك بها. - تطبيقك المدمج (Embedded) يطلب تنفيذ دفعة بمبلغ عبر عقد
CreateChargeالموحَّد دون أن يعرف أي بوابة فعلية مثبَّتة لدى الجهة. - المنصة (
ResolveTenantPaymentProvider) تحلّ المزوّد النشط الذي يدعم الوسيلة المطلوبة، وتُنفّذ الدفعة عبره (نفس مسارات api/embedded/hybrid الموصوفة أعلاه من جهة المزوّد). - تطبيقك يتابع النتيجة بطريقتين متكاملتين:
- فورياً من قيمة الرد نفسه (
status: paid|pending|failed). - لاحقاً عبر الحدث
ChargeUpdatedالذي تبثّه المنصة عند كل تحوّل حالة — ضروري لحالاتpendingالتي تتحوّل لاحقاً إلىpaidعبر بوابات BNPL بعد أن يُكمل العميل الدفع على صفحة البوابة.
- فورياً من قيمة الرد نفسه (
النطاقات المطلوبة
اطلبها من picker النطاقات عند بناء تطبيقك (resource payments):
| النطاق | يمنح |
|---|---|
payments.methods.read |
قراءة وسائل الدفع المتاحة للجهة |
payments.charges.create |
إنشاء دفعة — يتطلب idempotency_key |
payments.charges.read |
قراءة دفعة واحدة أو سرد كل دفعاتك |
payments.refunds.create |
استرجاع دفعة سبق أن دفعتها |
payments.charges.updateليس ضمن picker المستهلكين — هذا النطاق حصري لمزوّدي الدفع أنفسهم (يُمنح تلقائياً عندintegrationType=paymentفقط، لتحديث حالة الدفعات التي ينفّذونها). استدعاؤه من سياق استهلاك يُرفض بخطأpayment_status_update_provider_only.
الخدمات (in-process — الطريق الأساسي)
نفس نمط بقية App\Services\PartnerApi\* — بدون HTTP، بدون exceptions
للتحكم بالتدفق العادي، مسموحة للسكانر. كل خدمة تُعيد array (لا
Eloquent، لا تسريب schema):
| الخدمة | التوقيع | المكافئ HTTP | Scope |
|---|---|---|---|
ListAvailableMethods |
__invoke(): array |
GET /api/apps/payments/methods |
payments.methods.read |
CreateCharge |
__invoke(array $input): array |
POST /api/apps/payments/charges (+ Idempotency-Key) |
payments.charges.create |
GetCharge |
__invoke(string $chargeRef): array |
GET /api/apps/payments/charges/{ref} |
payments.charges.read |
ListCharges |
__invoke(array $filters = []): array |
GET /api/apps/payments/charges |
payments.charges.read |
RefundCharge |
__invoke(string $chargeRef, array $input): array |
POST /api/apps/payments/charges/{ref}/refund |
payments.refunds.create |
ListCharges تُعيد فقط الدفعات التي أنشأها تطبيقك أنت (تُصفّى
بمعرّف تثبيتك الخاص) — فلاتر مدعومة: status, method, from, to،
مع page/per_page (حتى 100)، وترتيب الأحدث أولاً.
مثال 1 — بيع ميزة/خدمة داخل تطبيقك
// Modules/SchoolPortal/app/Services/Reports/SellPremiumReport.php
namespace Modules\SchoolPortal\Services\Reports;
use App\Services\PartnerApi\Payments\CreateCharge;
final class SellPremiumReport
{
public function __construct(
private readonly CreateCharge $createCharge,
) {}
/**
* @return array<string, mixed>
*/
public function __invoke(string $orderId, float $amount): array
{
return ($this->createCharge)([
'method' => 'tabby',
'amount' => $amount,
'currency' => 'SAR',
'description' => 'تقرير تحليلي مميّز',
'metadata' => [
'feature' => 'premium_reports',
'order_id' => $orderId,
],
'idempotency_key' => "premium-report-{$orderId}",
]);
}
}
من الـ Livewire component المستدعي:
$result = app(SellPremiumReport::class)($orderId, 49.00);
if ($result['status'] === 'paid') {
// فعّل الميزة فوراً — الرد جاء paid مباشرة (بطاقة/مدى).
$this->dispatch('feature-unlocked');
return;
}
if ($result['status'] === 'pending' && $result['redirect_url']) {
// بوابات BNPL (Tabby/Tamara) — وجّه العميل لإكمال الدفع هناك.
$this->redirect($result['redirect_url']);
return;
}
if ($result['status'] === 'failed') {
$this->addError('payment', __('فشلت عملية الدفع. حاول بوسيلة أخرى.'));
return;
}
// pending بدون redirect_url — انتظر حدث ChargeUpdated (مثال 2).
لا تعيد استدعاء CreateCharge عند إعادة تحميل الصفحة أو فشل الشبكة —
مرّر نفس idempotency_key دائماً؛ المنصة تُعيد الدفعة المخزَّنة نفسها
بدل تكرار الدفع (لا إعادة محاولة تلقائية على الدفع).
مثال 2 — فتح الميزة عند اكتمال الدفع (ChargeUpdated)
دفعة بدأت pending (بوابة BNPL) تتحوّل لاحقاً إلى paid/failed من
خارج طلب CreateCharge الأصلي — لا webhook يصلك بهذا (الاستهلاك
in-process حصراً)؛ استمع للحدث داخل نفس العملية من boot() الخاص
بـ ServiceProvider تطبيقك:
// Modules/SchoolPortal/app/Providers/SchoolPortalServiceProvider.php
namespace Modules\SchoolPortal\Providers;
use App\Events\PartnerPayments\ChargeUpdated;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Modules\SchoolPortal\Services\Reports\GrantOrRevokePremiumReport;
class SchoolPortalServiceProvider extends ServiceProvider
{
public function boot(): void
{
Event::listen(function (ChargeUpdated $event): void {
// فلترة إلزامية — المنصة تبثّ نفس الحدث لكل التطبيقات
// المستمعة، وليس فقط تطبيقك.
if ($event->consumerModuleSlug !== 'school-portal') {
return;
}
if (($event->metadata['feature'] ?? null) !== 'premium_reports') {
return;
}
$orderId = $event->metadata['order_id'] ?? null;
match ($event->status) {
'paid' => app(GrantOrRevokePremiumReport::class)->grant($orderId),
'refunded', 'partially_refunded' => app(GrantOrRevokePremiumReport::class)->revoke($orderId),
default => null, // failed — لا فعل إضافي، pending لا يصل هنا أبداً
};
});
}
}
نقاط مهمة حول الحدث:
- يصلك فقط عند تغيّر الحالة فعلياً — لن يصلك على الإنشاء الأولي
بحالة
pending(هذه ليست "تحوّلاً")، بل علىpending→paid|failedأوpaid→refunded|partially_refundedلاحقاً. استرجاع فاشل لا يُطلق الحدث. - اجعل المعالج idempotent — تحقّق من الحالة الحالية لديك قبل التفعيل/الإلغاء، تحسّباً لإعادة تشغيل أو تكرار نادر.
- عالج
refunded/partially_refundedدائماً — الحدث قد يصلك لاحقاً لعملية سبق أن فتحت ميزة؛ أوقفها. - الحقول المتاحة:
chargeRef,tenantId,consumerModuleSlug,previousStatus,status,method,amount,currency,refundedAmount,metadata,occurredAt.
مثال 3 — التسوية والمطابقة (Reconciliation)
use App\Services\PartnerApi\Payments\ListCharges;
$page = app(ListCharges::class)([
'status' => 'paid',
'from' => '2026-07-01',
'to' => '2026-07-31',
'per_page' => 100,
]);
foreach ($page['data'] as $charge) {
// $charge['charge_ref'] هو المعرّف العام الوحيد — لا id رقمي يُكشف أبداً
// طابق $charge['metadata']['order_id'] بسجلك المحلي
}
$page['pagination']; // total / per_page / current_page / last_page
الاسترجاع (Refund)
use App\Services\PartnerApi\Payments\RefundCharge;
$result = app(RefundCharge::class)($chargeRef, ['amount' => null]); // null = استرجاع كامل
ضوابط RefundCharge:
- الدفعة يجب أن تكون ملك تطبيقك (أُنشئت بواسطته — غير ذلك تُعامَل كأنها غير موجودة).
- حالتها الحالية
paid(لا استرجاع علىpending/failed). amount≤ المتبقي القابل للاسترجاع (amount − refunded_amount) — يحترم استرجاعات جزئية سابقة تلقائياً.- لا إعادة محاولة تلقائية هنا أيضاً؛ استرجاع فاشل لا يُطلق
ChargeUpdated.
واجهة الإعدادات (settings_ui) — اختيارية
مثل الإشعار: صرّح has_settings_page + livewire_component لعرض صفحة
إعدادات المزوّد داخل لوحة tenant.
الـ overrides لكل إصدار
كل حقول الدفع (payment_methods, delivery, api.charge_endpoint,
embedded.provider_class, capabilities, settings_ui) قابلة للتخصيص
لكل إصدار من تبويب التكامل في صفحة الإصدار؛ الحقل الفارغ يرث من الـ
module.
لوحة تحكم التطبيق (App Control Panel)
المطوّر يبني لوحة تحكم تطبيقه بنفسه داخل كود التطبيق؛ المنصة تملك ضوابط التصميم والوصول، وتفتحها من بوابة الشركاء عبر رابط موقَّع. لكل تطبيق لوحتان — واحدة لكل بيئة (الإنتاج والتجربة) — حسب الإصدار المنشور/المثبَّت فيها. بالإضافة لما تبنيه بنفسك، تضيف المنصة تلقائياً قسم «بيانات التطبيق» إلى chrome كل لوحة — مخزن key/value معزول لتطبيقك تديره بلا أي كود (راجع بيانات التطبيق (App Data) أدناه).
كيف تعمل
- تُصرّح باللوحة في الـ manifest عبر كتلة
developer_ui. - تظهر في بوابة الشركاء داخل تبويب التكامل بطاقتان: الإنتاج والتجربة. كل بطاقة تُفعَّل فقط عندما يكون هناك إصدار حيّ في تلك البيئة و اللوحة مُصرَّحة في الـ manifest.
- زر «فتح اللوحة» يستدعي endpoint خفيف في okta-partners
(
GET /partner/modules/{slug}/dev-panel/{env}) يُصدر JWT قصير العمر (5 دقائق) ويعيد توجيهك إلى صفحة اللوحة على بيئة okta-web المستهدفة:<web base>/partner-dev/{slug}/panel?token=<jwt>. - الـ JWT موقَّع بـ HS256 بنفس السر المشترك للجسر الذي تستخدمه نداءات
الجسر الصادرة لكل بيئة (production ⇒
outboundToken، sandbox ⇒sandboxOutboundToken)، فلا يوجد سرّ جديد. الـ claims:{ iss:"okta-partners", aud:"okta-web-devpanel", sub:<partner user id>, module:<slug>, env:"production"|"sandbox", iat, exp:iat+300, jti }.
كتلة الـ manifest
لأي نوع تكامل (embedded / payment / notification / external):
"developer_ui": {
"has_developer_page": true,
"livewire_component": "my-app-developer-panel"
}
- embedded / payment / notification ⇒ تعرّف
livewire_component(اسم مكوّن Livewire يُعرَض داخل okta-web؛ الصيغة^[a-z0-9][a-z0-9-]*(::|-)[a-z0-9-.]+$— استخدم صيغة الشرطة مثلmy-app-developer-panel؛ الصيغة بـ::لا يحلّها Livewire 4). - external ⇒ تعرّف
urlبدلاً منه (رابط HTTPS تستضيفه على بيئتك الخاصة — بيئاتك ملكك، فتظهر بطاقة واحدة تفتح الرابط مباشرةً في تبويب جديد):
"developer_ui": {
"has_developer_page": true,
"url": "https://partner.example/console"
}
الحقلان متنافيان (XOR): يُصدِّر الـ manifest الحقل المطابق لنوع التكامل فقط. الكتلة قابلة للتخصيص لكل إصدار من تبويب التكامل في صفحة الإصدار؛ القيمة الفارغة ترث من الـ module.
أقسام اللوحة (sections)
مَن يملك ماذا — هذا هو الأساس الذي يُبنى عليه كل ما تحته:
| المنصة تملك | أنت تملك |
|---|---|
| هيكل اللوحة (chrome) والإطار العام | محتوى أقسامك أنت |
| الترويسة، اسم التطبيق، أيقونته | مكوّن Livewire لكل قسم |
| شريط التنقّل بين الأقسام وترتيبه | عنوان القسم وأيقونته (من قائمة المنصة) |
| الأقسام الأساسية (بيانات التطبيق، الصحة، …) | — |
المنصة تثبّت الأقسام الأساسية في كل لوحة؛ وsections هي نصيبك: أقسام
تخصّ تطبيقك تُعلَّق على تنقّل المنصة، كل قسم يعرض أحد مكوّنات Livewire لديك
داخل الهيكل. المفتاح اختياري — غيابه يعني لوحة بقسم واحد، وهو
السلوك نفسه الذي كان قبل وجوده.
"developer_ui": {
"has_developer_page": true,
"livewire_component": "my-app-developer-panel",
"sections": [
{ "key": "trips", "label": "الرحلات", "label_en": "Trips",
"icon": "map", "livewire_component": "my-app-trips" },
{ "key": "drivers", "label": "السائقون", "label_en": "Drivers",
"icon": "users", "livewire_component": "my-app-drivers" }
]
}
| الحقل | مطلوب | القاعدة |
|---|---|---|
key |
^[a-z][a-z0-9-]*$ — يبدأ بحرف صغير (لا برقم)، ثم أحرف صغيرة/أرقام/شرطات، بلا شرطة سفلية، بحد 32 خانة، وفريد داخل المصفوفة (تنقّل اللوحة يُفهرَس به). |
|
label |
بحد 60 خانة (العنوان العربي في التنقّل). | |
label_en |
بحد 60 خانة. | |
livewire_component |
نفس قاعدة alias اللوحة الرئيسية — وتُسطَّح :: بالطريقة نفسها، لأن Livewire 4 لا يحلّ اسماً فيه ::. |
|
icon |
واحدة من: home, chart, list, map, settings, bell, users, box, calendar, file. |
- الحد الأقصى 10 أقسام، وترتيب المصفوفة هو ترتيب التنقّل.
- صيغة
keyهنا أضيق عمداً من التي يقبلها okta-web (^[a-z][a-z0-9_-]{0,39}$): okta-partners يُنتج الـ manifest وokta-web يستهلكه، فالمنتِج هو الطرف الأصرم. مفتاح نُجيزه هنا ويرفضه okta-web يعني نشراً يفشل بسبب بيانات أجزناها نحن. - التطبيقات External لا تملك
sections: هي تستضيف لوحتها كاملةً بنفسها، فلا يوجد هيكل من المنصة تُعلَّق عليه أقسام — والمفتاح يُسقَط كما يُسقَطlivewire_component. - لا تُشحن كتلة نصف صالحة: قسم ينقصه
keyأوlabelأوlivewire_component(أو يخالف صيغتها) يُسقَط كاملاً — مدخل تنقّل لا يؤدي إلى شيء أسوأ من مدخل غائب.keyمكرَّر يُبقي أول ظهور فقط، وأيقونة خارج القائمة تُسقَط بصمت (القسم يبقى بلا أيقونة — الزخرفة لا تُفشِل إعلاناً)، والقائمة تُقصّ عند 10.
سياسة تصميم اللوحة
اللوحة تعيش داخل هيكل تملكه المنصة، ومن هنا تأتي القواعد — كلها قابلة للتنفيذ حرفياً:
- لا تبنِ ترويسة أو شريط تنقّل داخل القسم. الهيكل يوفّرهما أصلاً؛ أي ترويسة/تنقّل تضيفه يظهر مكرَّراً فوق ترويسة المنصة.
- مكوّنات
<x-…>فقط. لا<button>/<input>/<select>/<textarea>/<table>خام — الهوية البصرية تعيش داخل المكوّنات. - لا ألوان ثابتة من نوع
gray/zinc/slate/indigo؛ استخدم tokens المنصة (neutral,primary,success,warning,danger). - لا
max-w-*. الهيكل يفرض حدود العرض؛ إضافتها تكسر الشبكة وتقطع المحتوى الطويل. - اسم القسم من
label/label_enلا من داخل الـ blade — المنصة هي التي ترسم العنوان في التنقّل. - أيقونتك من القائمة المسموحة فقط؛ لا تشحن SVG خاصاً بك في التنقّل.
القواعد 2–4 يفرضها فاحص التصميم UiScanner وبوّابة الـ CI على كل
*.blade.php في مستودعك، فما يخالفها لا يصل الإنتاج أصلاً.
الضوابط
- التصميم: blade اللوحة يمرّ على نفس فاحص التصميم (UiScanner) وبوابة الـ CI
مثل بقية صفحاتك — مكوّنات
<x-…>وtokens المنصة فقط، لا HTML خام، لا ألوانgray/zinc/slate/indigo، لاmax-w-*. - الوصول: رابط موقَّع من المنصة فقط. سياق المطوّر بلا مستأجر — أنت
تنظر إلى صحّة تطبيقك التجميعية عبر كل التثبيتات، لا إلى بيانات جهة بعينها.
الواجهة المتاحة من مكوّن اللوحة الخاص بك هي namespace واحد فقط:
App\Services\PartnerApi\DeveloperPanel\*— التجميعات للقراءة فقط (GetInstallsCount, ...) بالإضافة إلى CRUD بيانات التطبيق تحتDeveloperPanel\Settings\*(راجع القسم التالي).
مثال كامل
مكوّن Livewire (يُشحن جاهزاً في الـ boilerplate تحت
app/DeveloperPanel/Panel.php.example):
namespace Modules\MyApp\app\DeveloperPanel;
use App\Services\PartnerApi\DeveloperPanel\GetInstallsCount;
use Livewire\Component;
class Panel extends Component
{
// المُدخلان الوحيدان اللذان تمرّرهما المنصة — لا مُعرّف جهة أبداً.
public string $moduleSlug = '';
public string $environment = 'production';
public function render(): \Illuminate\View\View
{
$installs = app(GetInstallsCount::class)($this->moduleSlug);
return view('my-app::developer-panel.panel', [
'installs' => $installs,
]);
}
}
الـ blade يستخدم مكوّنات المنصة فقط:
<div class="space-y-6">
<x-badge :color="$environment === 'production' ? 'success' : 'warning'" variant="soft">
{{ $environment }}
</x-badge>
<x-card>
<div class="p-4">
<p class="text-3xl font-bold text-[var(--color-neutral-900)]">{{ $installs }}</p>
</div>
</x-card>
</div>
الإحصاءات المتاحة
سطح القراءة App\Services\PartnerApi\DeveloperPanel\* على okta-web يوفّر تجميعات
عبر كل تثبيتات تطبيقك (لا بيانات جهة مفردة) — مثل عدد التثبيتات
(GetInstallsCount)، التثبيتات النشطة، وعدّادات الأخطاء. أي عملية دفع تظهر في
هذه التجميعات تُسمّى «دفعة».
بيانات التطبيق (App Data)
جدول key/value (JSON) مرتبط حصراً بتطبيقك — طريقة منخفضة الاحتكاك لتخزين إعدادات/أعلام تشغيلية تريد تعديلها بين الإصدارات دون نشر جديد.
- واجهة إدارة جاهزة بلا كود: قسم «بيانات التطبيق» يظهر تلقائياً داخل chrome اللوحة — كل تطبيق يحصل على CRUD كامل لمفاتيحه (إضافة/تعديل/حذف) من دون أي كود من جهتك.
- سطحان من الخدمات:
- من داخل مكوّن اللوحة الخاص بك (namespace مسموح ضمن سياق اللوحة فقط):
App\Services\PartnerApi\DeveloperPanel\Settings\{ListSettings, PutSetting, DeleteSetting}— كل واحدة تأخذmoduleSlugتطبيقك. - من كود تطبيقك المُشحَّن وقت التشغيل الفعلي عند الجهات (قراءة فقط، لا
كتابة):
App\Services\PartnerApi\AppSettings\{GetAppSetting, GetAppSettings}— نتيجة مكاشة ~60 ثانية.
- من داخل مكوّن اللوحة الخاص بك (namespace مسموح ضمن سياق اللوحة فقط):
- الحدود: 100 مفتاح كحد أقصى لكل تطبيق، قيمة كل مفتاح ≤ 64KB، صيغة
المفتاح
^[a-z][a-z0-9_.-]{0,119}$. - العزل: مضمون من الخادم بالكامل — سياق جلسة اللوحة يُشتق فقط من الـ JWT
الموقَّع (
iss=okta-partners,aud=okta-web-devpanel)؛ جلسة تطبيقك لا يمكن أبداً أن تلمس بيانات تطبيق آخر. - النمط الموصى به: اضبط سلوك تطبيقك (أعلام الميزات، الحدود، معاملات المزوّد، نصوص العرض) من اللوحة بين الإصدارات — بدون إعادة نشر.
قراءة وقت التشغيل من كود تطبيقك (مثلاً داخل service تحت App\Services\PartnerApi\*):
use App\Services\PartnerApi\AppSettings\GetAppSetting;
$maxItems = app(GetAppSetting::class)('max_items_per_page', 20);
كتابة من عمل (action) داخل مكوّن اللوحة نفسه:
use App\Services\PartnerApi\DeveloperPanel\Settings\PutSetting;
public function saveMaxItems(): void
{
app(PutSetting::class)($this->moduleSlug, 'max_items_per_page', (string) $this->maxItems);
$this->dispatch('saved');
}
أدوات المتصفح (Browser Tools)
أداة المتصفح شيفرة تعمل داخل نظام تستخدمه المدرسة أصلاً — نظام نور
مثلاً — لا داخل أوكتا. أنت تكتبها، وتُشحن داخل إصدار تطبيقك، وأوكتا
تخدمها لإضافة «أدوات أوكتا» في متصفح المستخدم. صالحة لأي نوع تكامل:
الأداة قدرةٌ يشحنها تطبيقك لا نوعاً من التطبيقات، فلا يشترطها
integrationType ولا مفتاح آخر.
القاعدة الحاكمة: الإضافة وحدها تشغّل، وأوكتا تخدم
تغيّرت القاعدة عمّا كانت. كان الشريك يستضيف ملف .user.js وأوكتا
تُدرج رابطه ليثبّته المستخدم في Tampermonkey أو أخواته. لم يعد ذلك
مساراً مدعوماً:
- المستخدم لا ينزّل شيئاً ولا يحتاج مدير userscripts.
- إضافة «أدوات أوكتا» (متجر كروم) هي الطريق الوحيد لتشغيل أي أداة.
- أنت تكتب جسم الأداة في حقل
scriptداخل الإصدار، وأوكتا تخدمه من نطاقها للإضافة وحدها.
ما تكسبه من ذلك ثلاثة أشياء لم تكن ممكنة قبله:
- ما يعمل عند الجهة هو ما نُشر ورُوجع. لا يتبدّل تحت أقدامها بين مراجعتين.
- تحديث الشيفرة يصل بلا إعادة تثبيت أي شيء. انشر إصداراً، فتلتقطه الإضافة.
- لا حائط مصادقة يوقف التحديث صامتاً، ولا رابط شريك يتعطّل بعد سنة فتبقى الأداة ميتة بلا أن يلاحظ أحد. تلك كانت أشيع ثلاثة أعطال في المسار القديم، وكلها اختفت مع الاستضافة.
الشكل القديم ما زال مقبولاً: إصدارٌ منشور يحمل
urlينتهي بـ.user.jsبلاscriptلا يُرفض ولا يختفي — إسقاطه كان سيخفي أداةً تعمل اليوم عند جهات مثبِّتة. لكنه لا يُستعمل لعمل جديد، ولا يستفيد من أيٍّ من الثلاثة أعلاه.
delivery: webstore لم يتغيّر: إضافةٌ توزّعها من متجر كروم، والرابط هو
المنتج كلّه.
الإعلان
من صفحة تطبيقك في البوّابة: تبويب التكامل ← قسم أدوات المتصفح ← «إضافة أداة». الحقول:
💡 بديل عن النموذج: أداة MCP
set_browser_toolsتكتب القائمة مباشرةً على إصدار قابل للتعديل (أو على التطبيق)، وget_browser_toolsتقرؤها مع تجاوزات كل إصدار. القائمة المُمرَّرة تستبدل المخزَّن كاملاً، و[]تمسح التصريح — وعلى إصدار تعني إلغاء التجاوز فيعود يرث قائمة التطبيق.والفرق الذي يهمّك: النموذج يُسقط الصفّ المعطوب بصمت (وهو الصواب لبنّاء يحمي الـmanifest)، أما الأداة فترفض بالاسم — مفتاح مكرَّر،
matchغائبة عن userscript،worldمجهول، رابط.user.jsمُعلَنwebstore— لأن الصفّ الساقط لا يظهر إلا كأداة غائبة من كتالوج الجهة بعد النشر.
| الحقل | إلزامي | القاعدة |
|---|---|---|
key |
^[a-z][a-z0-9-]*$، ≤ 32 محرفاً، فريد داخل تطبيقك. أوكتا تعنون الأداة بـ<module>:<key> |
|
name |
≤ 80 محرفاً. و name_en اختياري بالحدّ نفسه |
|
description |
— | ≤ 300 محرفاً، و description_en مثلها |
delivery |
userscript أو webstore |
|
script |
مع userscript |
جسم الأداة، ≤ 128 كيلوبايت |
world |
— | isolated (الافتراضي) أو main. للمحقون فقط — اقرأ القسم التالي قبل أن تختار |
url |
مع webstore |
https مطلق لصفحة الإضافة في المتجر، ≤ 500 محرفاً |
icon |
— | https مطلق. الأيقونة المكسورة تُجرَّد ويبقى الصفّ |
version |
— | ≤ 20 محرفاً، الافتراضي 1.0 |
match |
مع userscript |
سطر لكل نمط، ≤ 20 نمطاً، ≤ 200 محرف للنمط |
بحدّ أقصى 10 أدوات لكل تطبيق. والترتيب في القائمة هو ترتيب العرض عند الجهة — وهو التحكّم الوحيد لديك في أيّها تراه أولاً.
world: أخطر حقل في هذا القسم
عالَم التنفيذ اختيار وظيفي لا تفضيل، وأثره لا يظهر إلا وقت التشغيل عند المستخدم:
| القيمة | ما تراه الأداة |
|---|---|
isolated (الافتراضي) |
شجرة الـDOM فقط. لا ترى متغيّرات الصفحة العامّة ولا دوالّها |
main |
عالَم الصفحة نفسه: متغيّراتها ودوالّها وأطرها |
القاعدة العملية: إن كانت أداتك تنادي أي شيء تعرّفه الصفحة — $find
في تطبيقات ASP.NET، أو jQuery، أو كائن التطبيق العميل — فهي تحتاج
main. في isolated تجد تلك الدوالّ غير معرّفة، فتفشل بـis not a function بينما كل شيء آخر يبدو سليماً: الأداة مثبَّتة، والأزرار
مرسومة، والصفحة تعمل. هذا بالضبط ما حدث مع أداة تصدير نور، وهو صنف
الأعطال التي لا يشتكي منها المستخدم لأنه لا يعرف ما الذي كان يُفترض أن
يحدث.
ابدأ بـisolated — إن كانت أداتك تقرأ الـDOM وتكتب فيه فقط فهي تكفيها
وهي الأضيق صلاحيةً. ولا ترفع إلى main إلا لهذا السبب بعينه.
قيمة غير معروفة (page مثلاً) تُسقط الأداة كاملةً ولا تُخفَّض إلى
isolated: التخفيض الصامت يشحن العطل نفسه الموصوف أعلاه.
التقاطعات التي تُسقط الصفّ
بوّابة الشركاء تفحصها وتمنعك من الحفظ، لكن اعرفها لأنها سبب أكثر الأخطاء شيوعاً:
delivery: userscriptبلاscript. الجسم يسافر داخل الإصدار، فأداةٌ بلا جسم ليست أداة. (الاستثناء الوحيد: الشكل القديم برابط.user.js.)scriptوurlمعاً على صفّ واحد. مصدرٌ واحد للحقيقة لكل أداة — وإلا لم يعد ممكناً معرفة أي جسم تشغّله الجهة.delivery: webstoreبجسمscript. لا شيء يشغّله: إضافة المتجر هي المنتج، فالجسم هنا شيفرةٌ ميتة تُقرأ كأنها تعمل.delivery: userscriptبلا نمطmatch. هي الإفصاح الوحيد للجهة عن المواقع التي تلمسها أداتك، وهي ما تطلب الإضافةُ الإذنَ عليه.delivery: webstoreبرابط ينتهي بـ.user.js. نوع تسليم مكتوب خطأً.
لماذا نمنعك بدل أن نقبل ونصحّح:
BuildBrowserToolsBlockيُسقط أي صفّ لا يستطيع شحنه كاملاً. لو مرّرناه لحُفظ النموذج بنجاح، وخرج الـmanifest بأداةٍ أقل، ولاكتشفت ذلك من بلاغ جهة لم تظهر لها الأداة. فالرفض عند النموذج هو الفرق بين «البوّابة قالت إن أداتي ناقصة» و«أداتي اختفت بصمت».
أنماط match
بصيغة أنماط كروم: <scheme>://<host><path>.
https://noor.moe.gov.sa/*
https://*.moe.gov.sa/reports/*
ثلاث نقاط تُخطئ غالباً:
*في المخطّط تعنيhttp|httpsفقط — لا «أي مخطّط».*.example.comيشملexample.comنفسه لا نطاقاته الفرعية وحدها.- المسار إلزامي:
https://noor.moe.gov.saبلا/نمطٌ غير صالح.
اجعلها أضيق ما يكفي. النمط الواسع يعني نافذة إذنٍ أوسع أمام المستخدم، وسببٌ إضافي لرفضه تشغيل أداتك.
الشكل في الـmanifest
{
"browser_tools": [
{
"key": "noor-helper",
"name": "مساعد نور",
"name_en": "Noor Helper",
"description": "يختصر إدخال الدرجات في نور.",
"delivery": "userscript",
"script": "(function () { 'use strict'; /* ... */ })();",
"world": "main",
"icon": "https://partner.example/tools/icon.png",
"version": "1.2",
"match": ["https://noor.moe.gov.sa/*"]
}
]
}
ترويسة ==UserScript== غير مطلوبة — الإضافة لا تقرأها؛ الـmatch
والـworld في الإعلان هما ما يحكم الحقن. اكتبها إن شئت للتوثيق فحسب.
التجاوز على مستوى الإصدار
القائمة مملوكة على مستوى التطبيق، ويرثها كل إصدار. ولإصدارٍ أن يحمل قائمته الخاصة من محرّر الإصدار (بطاقة «أدوات المتصفح» ← «تخصيص القائمة لهذا الإصدار»).
اعرف ما يترتّب على ذلك قبل أن تفعله:
- القائمة الخاصة تهزم قائمة التطبيق عند بناء الـmanifest. فأي أداة تضيفها لاحقاً على مستوى التطبيق لن تصل إلى ذلك الإصدار.
- القائمة الخاصة الفارغة ليست وراثة، بل إعلانٌ صريح بأن هذا الإصدار يشحن بلا أي أداة. استعملها حين تسحب أداةً في إصدار وتُبقيها في غيره.
- العودة: «العودة إلى قائمة التطبيق» في البطاقة نفسها تمحو التجاوز وتعيد الوراثة الديناميكية.
ولأن الجسم صار جزءاً من الإصدار، فالتجاوز هنا يعني أيضاً تجميد شيفرة الأداة على ذلك الإصدار.
تحديث أداة منشورة
عدّل script، وارفع version، وانشر. الإضافة تقارن الإصدار وتعيد جلب
الجسم عند اختلافه، وتتخطّى ذلك تماماً حين لا يتغيّر شيء. لا يُطلَب من
المستخدم إعادة تثبيت الإضافة ولا الأداة.
وتجميد الرقم يعني تجميد الأداة على نسختها القديمة عند كل من فعّلها، بلا عَرَض ظاهر لأحد — فارفعه مع كل تعديل مهما صغر.
ما لا تفعله الأداة
الأداة لا تصل إلى أوكتا في أي عالَم كانت: لا تحمل توكن التثبيت، ولا
تقرأ نطاقاتك (scopes)، ولا تستدعي /api/apps/* بهوية تطبيقك. world: main يوسّع ما تراه من الصفحة المستهدفة لا ما تملكه من أوكتا. إن
احتجت بيانات من أوكتا فذلك عمل تطبيقك نفسه لا أداته.
الـ 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
(راجع توسيع ملف الطالب).
ولأي نوع تكامل يُضاف بلوك browser_tools حين يشحن التطبيق أدوات متصفح
(راجع أدوات المتصفح).
الـ 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 |
تنفيذ تقرير |
GET /reports/templates |
reports.builder.read |
قوالب المدرسة (الترويسات) |
POST /reports/templates/{id}/render |
reports.builder.read |
طباعة محتواك على ترويسة (يعيد PDF) |
POST /reports/templates |
reports.builder.write |
اقتراح قالب من تأليف تطبيقك (يصل مسودة) |
PATCH /reports/templates/{id} |
reports.builder.write |
تعديل قالب ألّفه تطبيقك (يُسقط الاعتماد) |
DELETE /reports/templates/{id} |
reports.builder.write |
حذف مسودة ألّفها تطبيقك |
القائمة الكاملة دائماً في 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
لا يستطيع تطبيقك إرسال إشعار من الكود قبل تصريحه في الكتالوج. هذه ليست توصية — هي قاعدة مفروضة عبر:
- NotificationScanner في الـCI: يفحص الكود ويُسقط أي
DispatchNotification('<key>', ...)حيث<key>غير مُصرَّح فيmanifest.json["notifications"]. الـPR يفشل. - 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 | الاسم اللي يراه المستأجر في صفحة الإعدادات. ثنائي اللغة إجباري — إشعار بلا اسم عربي يظهر للجهة بمفتاحه الخام (your-app.thing_happened) في واجهة عربية، ويبدو ذلك عطلاً في تطبيقك لا نقصاً في بياناتك. |
description_ar / description_en |
text | شرح اختياري للسياق. مفيد للمستأجر ليفهم متى يُرسَل الإشعار. |
variables_schema |
map | خريطة { name → php-type } للمتغيّرات المتوقّعة في الـpayload عند الإرسال. مثل { "employee_name": "string", "leave_days": "int" }. okta-web يعرضها للمستأجر ليُكوّن قوالب الرسائل. |
default_template |
text | نص الرسالة الافتراضي الذي كتبتَه أنت — بصيغة {{ variable_name }} للمتغيّرات المصرَّحة. هذا ما يظهر للجهة في صندوق «تخصيص الرسالة» كنقطة بداية تعدّل عليها، وهو ما يُرسَل فعلاً ما لم تخصّص الجهة نصّها. بدونه يفتح الصندوق فارغاً ولا تعرف الجهة ماذا تقول رسالتك أصلاً — اعتبره إجبارياً عملياً. |
audience |
array | لمن يتوجّه الإشعار: guardian / student / staff / admin. يظهر للجهة في صفّ الإشعار («موجّهة إلى») لتعرف من يستقبله قبل تفعيله. إعلان توضيحي — التوجيه الفعلي يبقى مع recipient في الـdispatch. |
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>/ و:
- يجمع كل المفاتيح الـ"مُستخدَمة" من 3 patterns:
app(DispatchNotification::class)('key', ...)app(\App\Services\PartnerApi\Notifications\DispatchNotification::class)('key', ...)partner_notify('key', ...)
- يجمع كل المفاتيح الـ"مُصرَّح بها" من
manifest.json["notifications"]. - يقارن:
- مفتاح مستخدَم لكن غير مُصرَّح → 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" في لوحة المستأجر — تعرض كل ما أُرسِل.
الوصول بين التطبيقات (تطبيقك يحتاج بيانات تطبيق آخر)
تطبيق الجدول يحتاج يعرف مين غاب اليوم. بيانات الحضور تعيش في قاعدة بيانات تطبيق الحضور — مخطّط منفصل لا يصل إليه غيره. فالسؤال ليس «كيف أقرأ الجدول الفلاني»، بل «كيف أطلب من مطوّر تطبيق الحضور أن يفتح لي جزءاً محدَّداً من بياناته».
الجواب موافقتان لا واحدة:
| مَن يوافق | على ماذا | أين |
|---|---|---|
| مطوّر التطبيق صاحب البيانات | «يحقّ لهذا التطبيق أن يطلب» | /dashboard/app-links |
| كل جهة تُثبِّت التطبيقين | «نعم، اقرأ بيانات طلابنا نحن» | صفحة التطبيق في المتجر |
الثانية موجودة في okta-web أصلاً. الأولى هي ما نتحدّث عنه هنا، ولا يمكن تخطّيها: إعلان وصول بلا موافقة مطوّر لا يُنشَر.
1. إن كنت صاحب البيانات: أعلِن ما تشاركه
لا أحد يستطيع أن يطلب منك شيئاً لم تعلنه. في محرّر التطبيق (أو الإصدار) → «الوصول بين التطبيقات» → «ما يشاركه هذا التطبيق»:
"provides": [
{
"resource": "records",
"access": "read",
"label": "سجلّات الحضور اليومية",
"label_en": "Daily attendance records",
"description": "حضور الطالب وغيابه وتأخّره، بالتاريخ والحصّة.",
"reader": "Modules\\Attendance\\Services\\PartnerApi\\RecordsReader"
}
]
resource— حروف صغيرة و_فقط، حتى ٤٠ حرفاً. هذا هو المفتاح الذي تُخزَّن به موافقة الجهة في okta-web، فتغييره لاحقاً يفصل كل موافقة قائمة عن معناها.access—readأوwrite. ليستا درجتين على سلّم: عرضreadلا يعني أنك عرضتwrite، والعكس. إن أردت الاثنين فأعلن صفّين.labelوdescription— تكتبهما للمدرسة لا للمبرمج. هذا النص نفسه هو ما يظهر على شاشة الموافقة في المتجر. «سجلّات الحضور اليومية» تُقرَأ ويُقرَّر بناءً عليها؛att_rec_v2لا.reader— الحقل الذي يجعل العرض يُثمِر شيئاً. بقيّة الحقول أوراق موافقة: تُثبِت أن الرابط يجوز. هذا الصنف هو ما تشغّله المنصّة فعلاً لتُنتِج الصفوف. بدونه تستطيع المدرسة أن توافق على السطر، ثم لا يُرجِع شيئاً. مطلوب أن يكون تحتModules\في كودك أنت، وأن ينفّذ الواجهة — انظر القسم التالي.
اكتب القارئ
namespace Modules\Attendance\Services\PartnerApi;
use App\Services\PartnerApi\Contracts\CrossAppReader;
use Modules\Attendance\Models\AttendanceRecord;
final class RecordsReader implements CrossAppReader
{
public function read(string $resource, array $filters, string $consumerSlug): array
{
return AttendanceRecord::query()
->when($filters['from'] ?? null, fn ($q, $d) => $q->whereDate('date', '>=', $d))
->limit(200)
->get()
->map(fn ($r) => [
'student_ulid' => $r->student->ulid,
'date' => $r->date->toDateString(),
'status' => $r->status,
])
->all();
}
}
ثلاث قواعد لا تتفاوض:
- لا تفحص الصلاحية هنا. المنصّة لا تستدعي صنفك إلا بعد أن تكون قد أثبتت
الثلاثة: أن مطوّر التطبيق الطالب مُنِح هذا الزوج، وأن هذه الجهة بالذات
وافقت عليه، وأن المُنادي هو
$consumerSlug(مأخوذاً من سياق التشغيل لا من شيء أرسله المُنادي). فحصك الثاني يضيف تعقيداً لا أماناً. $filtersمدخلات غير موثوقة. تأتي من تطبيق آخر. تحقّق منها كما تتحقّق من$request، وضَع سقفاً للعدد.- أرجِع قيماً صِرفة، وULIDs لا معرّفات رقمية. المُنادي قاعدة كود أخرى قد تكون منشورة بإصدار مختلف؛ تمرير نموذج Eloquent يربط مخطّطَي المنتجين معاً فيصير أيّ تغيير اسم عمود لاحقاً كسراً لتطبيق شريك آخر.
يعمل الصنف داخل سياقك أنت: مخطّطك، وصلاحياتك المعتمَدة، والجهة الحاليّة. فاحصر استعلامك بالجهة الحاليّة كما تفعل في صفحاتك تماماً.
المدمجة فقط. القارئ صنف PHP يعيش في كود تشحنه داخل okta-web. التطبيقات الخارجية تُعلِن
providesوتظهر على شاشة الموافقة، لكن لا قناة قراءة لها بعد.
لا يظهر إلا المنشور. التطبيقات الأخرى ترى
providesمن آخر إصدار منشور لك فقط. ما دام في مسوّدة فهو غير موجود بالنسبة لهم — ومقصود: الرابط التزام بين منتجين يعملان، وبناؤه على مسوّدة يعني أن ما تعتمد عليه قد يتغيّر أو يختفي قبل أن يراه أحد خارج فريقك.
2. إن كنت المحتاج: أرسل الطلب
/dashboard/app-links → «طلب وصول جديد». تختار تطبيقك، ثم التطبيق صاحب
البيانات، ثم من قائمته المنشورة ما تحتاجه، ثم تكتب السبب.
السبب ليس إجراءً شكلياً: هو كلّ ما يقرأه المطوّر الآخر قبل أن يقرّر. اكتب فيه ما ستعرضه للمستخدم تحديداً («نعرض حصص الغياب داخل جدول الطالب، بلا تخزين») لا وصفاً عامّاً («للتكامل»).
3. الردّ: قبول كلّي أو جزئي أو رفض
مطوّر التطبيق صاحب البيانات يرى الطلب في «طلبات واردة»، ويستطيع:
- الموافقة على الكل — الحالة الشائعة، بضغطة واحدة.
- الموافقة على البعض — «اقرأ الملخّص الشهري، لا السجلّ التفصيلي». هذا ردّ طبيعي على طلب حقيقي، وليس حالة استثنائية.
- الرفض — مع ذكر السبب إلزاماً. «لا» بلا سبب تترك المطوّر الآخر بلا شيء يغيّره ولا طريقة يعرف بها إن كان إعادة الطلب مجدية أصلاً.
الموافقة لا تحتاج تعليلاً، والرفض يحتاجه. وبعد الموافقة يبقى للمطوّر أن يسحبها (بسبب أيضاً) — وحينها لا يعود تطبيقك يستطيع إعلان ذلك الوصول في إصداراته القادمة.
4. أعلِن الاعتماد في إصدارك
بعد الموافقة يظهر السطر في محرّر التطبيق تحت «بيانات يحتاجها هذا التطبيق». لا تكتبه يدوياً — القائمة مبنيّة من الموافقات السارية، فما لا يظهر فيها لا يمكن إعلانه أصلاً. تختار لكل سطر أمرين:
"cross_module_access": [
{
"module": "attendance",
"resource": "records",
"access": "read",
"required": true,
"reason": "لعرض حصص الغياب داخل جدول الطالب."
}
]
-
required— هذا هو الحقل الذي تشعر به المدرسة:true→ المتجر يطلب منها تثبيت التطبيقين معاً، وتطبيقك لا يعمل بدونه.false→ بقيّة التطبيق تعمل، وتتعطّل هذه الميزة وحدها.
اقلبه بالخطأ وستجد مدرسة عاجزة عن استخدام ما دفعت ثمنه. اجعله
trueفقط إذا كان التطبيق بلا هذه البيانات بلا معنى. -
reason— يظهر حرفياً في صفحة المتجر بجانب زر الموافقة. اكتبه للمدرسة.
ما يحدث عند التثبيت
okta-web يقرأ cross_module_access من بيانك، ويُنشئ لكل سطر صفّاً في
module_cross_access_grants غير ممنوح (is_granted = false). ثم تعرض
صفحة التطبيق البطاقة على الجهة، وهي التي تمنح أو ترفض كل سطر على حدة. لا
بيانات تتحرّك قبل ذلك.
موافقة المطوّر ليست موافقة الجهة. موافقته تعني «يحقّ لهذا التطبيق أن يطلب» فقط. كل مدرسة تُسأل بعدها على حدة، ولها أن ترفض.
قراءة البيانات فعلياً
استدعاء واحد، من داخل تطبيقك:
use App\Services\PartnerApi\CrossApp\ReadFromApp;
$records = app(ReadFromApp::class)('attendance', 'records', ['from' => '2026-08-01']);
// [['student_ulid' => '01J…', 'date' => '2026-08-16', 'status' => 'absent'], …]
ثلاثة وسائط: التطبيق صاحب البيانات، والمورد كما أعلنه هو في provides،
ومرشِّحات اختيارية تمرّ كما هي إلى قارئه. تُرجِع ما أرجعه القارئ — مصفوفات
وقيماً صِرفة.
لا تُمرِّر هويّتك. لا $tenantId ولا معرّف (slug) تطبيقك: كلاهما مقروء من سياق
التشغيل الذي أنت فيه أصلاً. لو كان معرّف المُنادي وسيطاً لأمكن لأي تطبيق أن
يدّعي أنه غيره ويستعير روابطه.
ما يفحصه النداء قبل أن يُرجِع شيئاً
- المُنادي هو سياق التشغيل الحالي، لا شيء أرسلته.
- الجهة وافقت على هذا السطر بعينه —
(الطالب، المزوّد، المورد)— وهي البطاقة التي رأتها في المتجر. موافقة المطوّر وحدها لا تكفي. - المزوّد ما يزال يعلن المورد وسمّى له قارئاً في بيان الإصدار المُثبَّت، لا في السطر الذي حصلت عليه يوم الموافقة. مَن سحب المورد يتوقّف عن الإجابة عليه.
ثم تدخل المنصّة سياق المزوّد وتستدعي قارئه هناك، فيقرأ بياناته باعتماداته هو، وتستعيد سياقك بعد الرجوع. أنت لا تلمس مخطّطه ولا تعرف عنه شيئاً — وهذا مقصود: لكل تثبيت مخطّط Postgres خاص خلف دور مُقيَّد، فالقراءة المباشرة ليست ممنوعة فحسب، بل مستحيلة تقنياً.
الفشل استثناء لا مصفوفة فارغة
سطر لم تمنحه الجهة يرمي RuntimeException برسالة تقول أيّ الأبواب أُغلق، ولا
يعيد [] — لأن «لا يوجد ما تقرأه» و«لست مسموحاً لك» حالتان مختلفتان تماماً،
وخلطهما يجعلك تشحن لوحة فارغة بدل خطأ قابل للإصلاح.
وهذا يعني أنك تتوقّع الاستثناء وتتعامل معه كحالة طبيعية على سطر
required=false:
try {
$records = app(ReadFromApp::class)('attendance', 'records');
} catch (\RuntimeException) {
$records = null; // اعرض الجدول بلا عمود الغياب.
}
على سطر required=true هو ما يحدث بين التثبيت والموافقة فقط، وبعدها لا يتكرّر.
لماذا لا
CrossModuleAccessService::hasAccess()؟ كان هذا الدليل يوجّهك إليه، وكان خطأً من وجهين: فاحص السياسة يرفض استيراده (internal-service-import— العقد هوApp\Services\PartnerApi\*وحده)، وهو يعيدtrue/falseعلى أي حال، فيُخبرك أنك مسموح لك بالقراءة ولا يعطيك بها سبيلاً.ReadFromAppيفحص الشيء نفسه ثم يُرجِع البيانات.
حدود يجب أن تعرفها
writeغير مفروض بعد. okta-web يطابق حالياً على (الجهة، الطالب، المزوّد، المورد) ولا يقارن عمودaccess، فموافقة صدرتreadتمرّ اليوم في فحص كتابة. الإعلان صادق ويُعرَض للمدرسة كما هو، لكن لا تبنِ حاجزاً أمنياً على هذا الفرق قبل أن يُغلَق.- السحب لا يرجع بالزمن. إن سحب المطوّر موافقته، تتوقّف إصداراتك القادمة عن إعلان الوصول، لكن الجهات التي منحته أصلاً تبقى موافقتها سارية حتى تُحدَّث المنصة.
- تطبيقاك أنت لا يحتاجان طلباً. تطبيقان لجهة واحدة لا شيء بينهما
للتفاوض — تُعلِن الاعتماد مباشرةً. لكن التطبيق الآخر يبقى ملزَماً بأن ينشر
ما تُسمّيه في
provides.
الطباعة على ترويسة المدرسة (قوالب التقارير)
ما هو القالب
القالب ترويسة، لا مستند. المدرسة تملك الإطار — شعارها، ترويستها، تذييلها، علامتها المائية، هوامشها، مقاس الورق واتجاهه — وتُرتّبه مرة واحدة في «منشئ التقارير» داخل أوكتا. الجسم يؤلّفه تطبيقك: القالب لا يحمل محتوى خاصاً به.
هذا هو التقسيم كاملاً:
| مَن يملكه | ماذا |
|---|---|
| المدرسة | الشعار، الترويسة، التذييل، العلامة المائية، الهوامش، الورق، كتلة الاعتماد |
| تطبيقك | محتوى المستند — العناوين والجداول والصفوف |
لماذا لا تطبع بنفسك
كل تطبيق يطبع شيئاً يصطدم بالجدار نفسه: تشكيل العربية، الاتجاه من اليمين، خطٌّ يرسمها، هوامش الصفحة، وترويسةٌ تعرف المدرسة أنها ترويستها. ومحلولاً في كل تطبيق على حدة يُحَل في كل تطبيق بشكل مختلف، فتنتهي المدرسة وفي يدها ستة مستندات بستة تخطيطات — ولا واحد منها ترويستها، والشيء الوحيد الذي أرادته، أن هذه المستندات من مدرستها، هو الشيء الذي لا يحمله أيٌّ منها.
والمحرّك يعرف ذلك كلّه أصلاً، لأن المدرسة أعدّته مرة واحدة.
متى لا يناسبك هذا: إن كان مستندك تصميماً خاصاً بك لا علاقة له بهوية المدرسة (فاتورة تحمل هويّتك أنت مثلاً)، اطبعه بنفسك. القوالب لمستندات تخرج باسم المدرسة.
النطاق المطلوب
reports.builder.read — وهو نفسه نطاق كتالوج التقارير. المدرسة تُسأل سؤالاً
واحداً: هل يقرأ هذا التطبيق سطح التقارير؟ والطباعة على ترويسة قراءة لها.
وهذا كل ما يحتاجه أغلب التطبيقات. بقيّة هذا القسم عن الطباعة على ما
رتّبته المدرسة أصلاً، ولا تحتاج معها write إطلاقاً.
والتأليف نطاق ثانٍ منفصل: reports.builder.write يسمح لتطبيقك أن
يقترح قالباً من عنده حين لا يجد في المدرسة ما يناسب مستنده — ولا يسمح له
بأن يطبع عليه: المقترَح يصل مسودةً حتى تعتمده المدرسة. تفصيله في
أن يقترح تطبيقك ترويسة.
الخطوة ١ — اسرد القوالب
GET /api/apps/reports/templates
{
"data": [
{
"id": "9wXqB4",
"name_ar": "خطاب صادر",
"name_en": "Outgoing letter",
"type": "general",
"orientation": "portrait",
"paper_size": "a4",
"header_enabled": true,
"footer_enabled": true,
"variables": [
{
"key": "letter_no",
"label_ar": "رقم الخطاب",
"label_en": "Letter number",
"type": "text",
"required": true,
"default": ""
}
]
}
]
}
id نصٌّ مبهم — استعمله كما وصلك ولا تبنِه بنفسك ولا تشتقّه من شيء.
وهو ليس ULID بخلاف بقية معرّفات هذا السطح.
اقرأ variables — هذه ليست حقلاً تتخطّاه. هي الحقول التي يطلبها هذا
القالب بعينه عند الطباعة: ترويسة تقول «رقم الخطاب: {{letter_no}}» ستطبع
فراغاً مكانه إن لم تُرسل قيمة، ولا شيء في المستند سيقول لك لماذا. وrequired
إشارتك إلى أن تسأل إنساناً بدل أن تخمّن.
تُسرد القوالب المعتمدة فقط. مدرسة في منتصف إعادة تصميم ترويستها لديها مسودة يجب ألّا تصل وليّ أمر، وتطبيقك لا يستطيع أن يميّز تصميماً منتهياً من آخر تحت التعديل بالنظر إليه — فالمنصّة لا تعرضه عليك أصلاً.
الخطوة ٢ — اطبع
POST /api/apps/reports/templates/{id}/render
{
"blocks": [
{ "type": "heading", "text": "كشف الدرجات", "level": "h1" },
{ "type": "info-row", "label": "الطالب", "value": "أحمد صالح" },
{ "type": "table",
"headers": ["المادة", "الدرجة"],
"rows": [["الرياضيات", "95"], ["العلوم", "88"]] }
],
"variables": { "letter_no": "1447-42" }
}
الجواب هو ملف PDF نفسه (Content-Type: application/pdf) لا رابطاً إليه.
الملف المخزَّن يحتاج عمراً وسياسة تنظيف وقاعدة وصول، لمستندٍ قد يحمل درجات
طفل باسمه — وتسليم البايتات يترك الثلاثة معك.
curl -X POST \
-H "Authorization: Bearer $INSTALLATION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"blocks":[{"type":"heading","text":"كشف الدرجات","level":"h1"}]}' \
--output report.pdf \
https://getokta.io/api/apps/reports/templates/9wXqB4/render
الكتل المتاحة
| النوع | الحقول | ملاحظة |
|---|---|---|
heading |
text (أو html)، level: h1|h2|h3، align |
|
text |
html، align |
HTML بسيط: <p>, <strong>, <br> |
info-row |
label، value |
سطر «تسمية: قيمة» بخطّ سفلي خفيف |
table |
headers، rows، striped |
rows مصفوفة مصفوفات |
divider |
color، thickness |
|
spacer |
height (نقاط) |
|
image |
path، align، width |
path على قرص أوكتا العام — نادراً ما يملكه تطبيق خارجي |
النوع المجهول يُرفَض ولا يُتخطّى. كتلةٌ ساقطة بصمت تُخرج مستنداً ناقصاً
قسماً كاملاً لا يقول عن نفسه شيئاً، وتقريرٌ ينقصه صفٌّ أسوأ من تقرير فشلت
طباعته. الجواب 422 ونصّه يسمّي الكتلة ونوعها:
{ "error": "Block 2 has an unknown type «chart». Known types: heading, text, table, info-row, divider, spacer, image." }
المتغيّرات
القالب قد يُعلن حقولاً خاصة به، وتملؤها أنت عند الطباعة بمفاتيحها:
"variables": { "letter_no": "1447-42", "recipient": "إدارة التعليم" }
قواعد ثلاث تستحق أن تُعرف مسبقاً:
- المتغيّر غير المُعلَن يُسقَط ولا يُرفَض الطلب. بخلاف نوع الكتلة المجهول: الكتلة الساقطة تُنقص المستند قسماً، والمفتاح الزائد لا يُنقص الورقة شيئاً — فتستطيع إرسال حمولة واحدة على عدّة قوالب.
- لا يمكنك الكتابة فوق متغيّرات المنصّة (
{{tenant.name}}،{{date.today}}،{{page.number}}…). لو أمكن، لطبع تطبيقٌ اسم مدرسة أخرى على ورق هذه المدرسة. - ما لم تُرسله يعود إلى القيمة الافتراضية التي ضبطتها المدرسة، لا إلى فراغ — إن كانت لها قيمة افتراضية.
الاعتماد
للقالب دورة حياة عند المدرسة: مسودة → بانتظار الاعتماد → معتمد. ولا
يُطبع إلا المعتمد، ويُعاد الفحص عند كل طباعة لا عند السرد فقط: المُعرّف
يعيش في مخزن تطبيقك بعد أن تكون المدرسة قد أعادت القالب للتصميم.
فإذا خزّنت مُعرّف قالب، تعامل مع 422 عند الطباعة كحالة طبيعية لا
كعطل: اسرد القوالب من جديد ودع المستخدم يختار.
من تطبيق مدمج (Embedded)
التطبيق المدمج ينادي الخدمات مباشرةً في نفس العملية — بلا HTTP وبلا توكن:
use App\Services\PartnerApi\Reports\Templates\ListReportTemplates;
use App\Services\PartnerApi\Reports\Templates\RenderReportTemplate;
$templates = app(ListReportTemplates::class)();
$pdf = app(RenderReportTemplate::class)(
$templates[0]->id,
[
['type' => 'heading', 'text' => 'كشف الدرجات', 'level' => 'h1'],
['type' => 'info-row', 'label' => 'الطالب', 'value' => 'أحمد صالح'],
],
['letter_no' => '1447-42'],
);
// ['filename' => '...', 'mime' => 'application/pdf', 'bytes' => '...']
return response($pdf['bytes'], 200, [
'Content-Type' => $pdf['mime'],
'Content-Disposition' => 'attachment; filename="'.$pdf['filename'].'"',
]);
النطاق نفسه مطلوب، ويُفحص من سياق التثبيت النشط.
أن يقترح تطبيقك ترويسة
كل ما سبق يفترض أن المدرسة قد رتّبت ترويسةً تناسب مستندك. وكثيراً لا تكون قد فعلت: تطبيق درجات يحتاج كشفاً فيه الفصل الدراسي في الترويسة وتوقيعا معلّم المادة ومدير المدرسة أسفله، والمدرسة لا تعرف أن هذا ما يحتاجه — حتى تراه.
فالتطبيق يستطيع أن يقترح القالب الذي يعرف شكله، وتبقى المدرسة هي التي تقرّر.
النطاق: reports.builder.write — منفصل عن read عمداً، فتُسأل المدرسة
سؤالاً ثانياً عند التثبيت بدل أن يكتسب كلُّ تطبيق يقرأ التقارير القدرةَ على
وضع مسوّدات أمامها.
ما لا يشتريه هذا النطاق
هذه الأربع هي العقد كلّه، ولا يُنقض واحدٌ منها بأي حال:
- المقترَح يصل مسودةً ولا يُطبع. حتى تعتمده المدرسة. القالب الذي
ألّفتَه للتوّ لا يظهر في
GET /reports/templatesويُجيبPOST .../renderعليه بـ422. وهذا ليس عطلاً تلتفّ حوله: كل ما يُطبع على ترويسة يخرج باسم المدرسة إلى وليّ أمر أو وزارة، وتطبيقٌ يؤلّف ويطبع في نفس اللحظة تطبيقٌ يُصدر مستندات باسم مدرسةٍ لم يرها فيها أحد. - لا تمسّ إلا ما ألّفتَه أنت. ترويسة المدرسة التي رتّبتها بيدها، وقالبٌ
ألّفه تطبيق آخر على المدرسة نفسها — كلاهما
422عند التعديل أو الحذف. (عزل الجهات لا يقول شيئاً هنا: التطبيقان مثبَّتان على المدرسة نفسها بشرعية.) - التعديل يُسقط الاعتماد. قالبٌ اعتمدته المدرسة ثم عدّلتَه يعود مسودة ويتوقّف عن الطباعة حتى يُعتمد ثانيةً. هذا ثمنٌ حقيقي وهو المقصود: اعتمادٌ يبقى بعد التعديل يعني أن تطبيقاً يُمرّر قالباً بريئاً للاعتماد ثم يكتب فيه ما شاء.
- المعتمد لم يعد لك لتحذفه. الاعتماد هو أخذ المدرسة التصميمَ لنفسها —
وقد يكون على أوراق فصلٍ كامل. الحذف من هنا يُجيب
422، والمدرسة تحذفه من منشئ التقارير حيث تُرى العاقبة أمام من يقرّرها.
إنشاء قالب
POST /api/apps/reports/templates
{
"name_ar": "كشف درجات",
"name_en": "Marks sheet",
"orientation": "portrait",
"paper_size": "a4",
"header_center_content": "كشف درجات — الفصل {{term}}",
"footer_center_content": "{{page.number}} / {{page.total}}",
"signatures_enabled": true,
"signatures_columns": 2,
"signatures": [
{ "title_ar": "معلم المادة", "title_en": "Subject teacher" },
{ "title_ar": "مدير المدرسة", "title_en": "Principal" }
],
"variables": [
{ "key": "term", "label_ar": "الفصل الدراسي", "type": "text", "required": true }
]
}
الجواب 201 وجسمه نفس شكل القالب في السرد — بمعرّفه المبهم وبـ
variables كما سُجِّلت، فترى فوراً ما ستُطالَب بإرساله عند الطباعة.
الشعار واسم الجهة يُورَثان ولا تُرسلهما. المنصّة تنسخهما من ترويسةٍ اعتمدتها المدرسة سابقاً — لأن قالباً بترويسة فارغة أول ما تراه المدرسة يبدو تطبيقاً معطوباً لا مسودةً تنتظر شعارها. وتطبيقك لا يملك تعيينهما: ملفٌّ يصل من خارج المدرسة ليكون شعارها ليس ما يقرّره تطبيق.
ما يُقبل من حقول
قائمة سماح: كل ما ليس فيها غير قابل للكتابة من هنا.
| المجموعة | الحقول |
|---|---|
| الهوية | name_ar (مطلوب)، name_en، description، type (administrative|general) |
| الورق | orientation (portrait|landscape)، paper_size (a4|a3|letter) |
| الترويسة والتذييل | header_enabled، footer_enabled، وheader_*_content / footer_*_content للخانات right/center/left |
| العلامة المائية | watermark_text، watermark_opacity (0–100) |
| الهوامش | margin_top_mm، margin_right_mm، margin_bottom_mm، margin_left_mm (0–40) |
| كتلة الاعتماد | signatures_enabled، signatures_columns (1–4)، signatures_title، وsignatures[]: title_ar (مطلوب)، title_en، name، show_line |
| المتغيّرات | variables[]: key (مطلوب)، label_ar (مطلوب)، label_en، type (text|date|number|currency)، required، default_value |
وخارجها عمداً — والسبب يستحق أن يُقال:
header_logoوbackground_image— علامة المدرسة على ورقها. راجع أعلاه.- حالة الاعتماد ورفيقاتها — لو كانت قابلة للكتابة لاعتمد التطبيق مسوّدته بنفسه، وصار كل ما في هذا القسم زينة.
محتوى الخانات نصّ يقبل المتغيّرات: متغيّرات المنصّة ({{tenant.name}}،
{{date.today}}، {{page.number}} …) ومتغيّرات القالب التي أعلنتَها أنت.
ومفتاحٌ يظلّل متغيّراً جاهزاً يُسقَط بصمت — tenant.name مثلاً لا يصير
حقلاً تملؤه. القاعدة ليست خاصة بالتطبيقات: هي القاعدة نفسها التي يمرّ منها
محرّر المدرسة، فلا تنحرف بين البابين.
التعديل
PATCH /api/apps/reports/templates/{id}
نفس الحقول، وكلها اختيارية. الغياب يعني «اتركه كما هو» ولا يعني «امسحه» —
فتطبيقٌ نسي مفتاحاً لا يمسح تذييل المدرسة. الاستثناء الوحيد variables و
signatures: مصفوفة فارغة صريحة تعني «احذفها كلّها»، وهي الحالة التي
قال فيها المستدعي ما يقصده بوضوح.
وتذكّر البند ٣ أعلاه: بعد التعديل يتوقّف القالب عن الطباعة حتى يُعتمد ثانية.
الحذف
DELETE /api/apps/reports/templates/{id}
لمسودةٍ لم تعتمدها المدرسة. والمعتمد 422.
حدّ العدد
٢٥ قالباً لكل تثبيت. التطبيق يقترح حفنة مستندات يعرف كيف يملؤها؛ حلقةٌ
فيها خطأ تقترح آلافاً، فتكتشفها المدرسة صفحةَ منشئ تقارير لم تعد تُقرأ. تجاوز
الحدّ 422.
ماذا يحدث عند إزالة التطبيق
لا شيء يُحذف. القوالب التي اقترحها تطبيقك تبقى للمدرسة — راجعتها واعتمدتها وطبعت عليها، وهي مستنداتها الآن أيّاً كان من صاغها. تفقد نسبتها إليك فقط، وتصير قوالب مدرسة عادية.
فلا تعتمد على أن إعادة التثبيت تُعيد إليك ملكيّتها: تثبيتٌ جديد لا يملك ما ألّفه تثبيتٌ سابق، وسيحتاج أن يقترح من جديد.
التأليف من تطبيق مدمج
use App\Services\PartnerApi\Reports\Templates\CreateReportTemplate;
use App\Services\PartnerApi\Reports\Templates\UpdateReportTemplate;
use App\Services\PartnerApi\Reports\Templates\DeleteReportTemplate;
$template = app(CreateReportTemplate::class)([
'name_ar' => 'كشف درجات',
'header_center_content' => 'كشف درجات — الفصل {{term}}',
'variables' => [
['key' => 'term', 'label_ar' => 'الفصل الدراسي', 'required' => true],
],
]);
// $template->id — المعرّف المبهم نفسه الذي يعيده HTTP
متى تقترح، ومتى لا
اقترح حين يكون لمستندك شكلٌ يعرفه تطبيقك ولا تعرفه المدرسة: كشف درجات، خطاب قبول، شهادة حضور. اقترحه مرة واحدة عند أول تشغيل — لا في كل مرة يفتح فيها المستخدم شاشة، وإلا امتلأت لوحة المدرسة بمسوّدات متطابقة تنتظر اعتماداً لن يأتي.
لا تقترح حين تجد في GET /reports/templates ما يناسبك: الترويسة التي
رتّبتها المدرسة بيدها أولى دائماً، والمقترَح لا يُغني عن اختيار المستخدم.
ولا تنتظر الاعتماد في حلقة. لا شيء في السطح يخبرك متى يقع، والقرار قد يستغرق أياماً؛ الصحيح أن تسرد القوالب حين يحتاجها المستخدم فعلاً، فيظهر المعتمد وحده.
قائمة تحقق قبل النشر
-
reports.builder.readمعلَن في الـ manifest مع سبب واضح. - تسرد القوالب وتدع المستخدم يختار — لا تُثبّت مُعرّفاً في الكود.
- تقرأ
variablesوتسأل عن كلrequiredقبل الطباعة. -
422عند الطباعة يُعاد به إلى السرد، ولا يُعرض كعطل في التطبيق. - لا تفترض وجود قالب: مدرسة لم تُنشئ ولا ترويسة تُرجع
data: []، وواجهتك تحتاج ما تقوله عندها.
وإن كنت تؤلّف قوالب أيضاً:
-
reports.builder.writeمعلَن ومُبرَّر — ولا تطلبه إن كنت تطبع فقط. - تقترح مرة واحدة عند أول تشغيل، لا في كل فتح شاشة.
- تسرد أولاً وتقترح فقط حين لا تجد ما يناسبك.
- واجهتك تقول للمستخدم إن المقترَح ينتظر اعتماد المدرسة — لا تعرضه جاهزاً للطباعة، فهو ليس كذلك.
- لا حلقة انتظار للاعتماد، ولا افتراض أنه سيقع.
- تعديل قالب معتمد قرارٌ تتّخذه بعلم أنه يوقف طباعته حتى يُعتمد ثانيةً.
نُهج الأمان
Embedded
- ✓ Policy scanner (regex + PHPStan AST) يفحص كل PR في مستودعك.
- ✓ Postgres role/schema منفصلين تماماً عن المنصة.
- ✓
BlocksPartnerDirectAccesstrait يرفض أي وصول خارج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 وستعمل كل الطلبات.
التطوير بمساعدة الذكاء الاصطناعي (MCP)
المنصّة توفّر خادمي MCP (Model Context Protocol) يربطان مساعدك البرمجي (Claude Code، Cursor، أو أي عميل MCP) بمعايير أوكتا وأدواتها — فيلتزم مساعدك بالمعايير من أول سطر بدل اكتشاف المخالفات عند مراجعة الـ PR.
1) الخادم المحلي — داخل مستودعك (بلا حساب)
كل مستودع تطبيق مُنشأ من المنصّة يأتي ومعه خادم MCP محلي جاهز في
scripts/mcp/ مع ملف .mcp.json في الجذر:
- Claude Code: يكتشفه تلقائياً بمجرد فتح المستودع — لا إعداد إطلاقاً.
- Cursor: أضِف إلى
.cursor/mcp.json:
{ "mcpServers": { "okta-partner": { "command": "php", "args": ["scripts/mcp/server.php"] } } }
يتطلّب php في الـ PATH فقط (بلا Composer وبلا اتصال شبكة). أدواته تلفّ
نفس فاحصات CI (scan_code، validate_manifest، lint_permission،
component_catalog، feature_service، search_standards، list_scopes)
— أي ما يمرّ محلياً يمرّ في بوّابة الدمج. راجع scripts/mcp/README.md
داخل مستودعك للتفاصيل.
2) الخادم المستضاف — من حسابك (بموافقة، بلا توكن)
الخادم المستضاف يضيف فوق أدوات المعايير أدوات حيّة مرتبطة بهويّتك: تطبيقاتك وإصداراتها، الكتالوج الحيّ للنطاقات، المتابعة (صحة التطبيق وتسليمات الـ webhooks)، المحاكي، وأدوات التحكّم ولوحة التحكّم.
الربط (مرة واحدة):
# Claude Code
claude mcp add --transport http okta-partner https://partners.getokta.io/mcp
// Cursor — .cursor/mcp.json
{ "mcpServers": { "okta-partner": { "url": "https://partners.getokta.io/mcp" } } }
أول استخدام يفتح المتصفح على شاشة الموافقة في منصّة الشركاء: سجّل دخولك بحسابك المعتاد، راجع ما ستستطيع الأداة فعله، ووافق. لا يوجد أي توكن يُنسخ أو يُلصق — التفويض OAuth (PKCE) بالكامل.
صلاحية الكتابة: افتراضياً الربط قراءة فقط (تطبيقاتك + المعايير + المتابعة). لتمكين أدوات التحكّم (تقديم للمراجعة، إنشاء إصدار، تعديل الـ changelog وقناة الإصدار، ضبط أنواع الحسابات، برمجة لوحة تحكّم التطبيق وإعدادات الـ webhooks) فعّل خيار «سماح بالكتابة (التحكّم في التطبيق)» في شاشة الموافقة نفسها. كل الكتابات تمرّ بنفس ضوابط الواجهة (لا تجاوز لحالة النشر، ولا كتابة على إصدار منشور).
أبرز الأدوات الحيّة:
| الأداة | الوظيفة |
|---|---|
whoami / list_my_modules |
هويّتك وتطبيقات جهتك |
get_module / list_versions |
تفاصيل تطبيق + manifest المبني + إصداراته |
module_health |
تقرير صحة: الحالة، الإصدارات، فحوصات النشر، sandbox |
webhook_deliveries / delivery_stats / replay_delivery |
سجل تسليمات External وإحصاءاتها وإعادة الإرسال |
preview_module / store_listing |
محاكاة بطاقة المتجر والظهور في لوحة الجهة + رابط صفحة المعاينة |
create_module |
إنشاء تطبيق جديد كمسودة — الخطوة الأولى قبل create_version (تتطلّب صلاحية الكتابة) |
submit_module / create_version / update_changelog / set_release_channel / sync_from_manifest |
التحكّم في دورة الحياة (تتطلّب صلاحية الكتابة) |
get/set_developer_ui / get/set_webhook_config |
برمجة لوحة تحكّم التطبيق وإعداداته (تتطلّب صلاحية الكتابة) |
set_landing_widgets |
تصريح ويدجتس صفحة الهبوط على تطبيقك المدمج مباشرةً — أو كتجاوز لإصدار — دون المرور بـ manifest.json. القائمة المُمرَّرة تستبدل المخزَّن، و[] تمسح التصريح (وعلى إصدار تُلغي التجاوز فيعود للوراثة). القواعد هي قواعد النشر في okta-web نفسها (تتطلّب صلاحية الكتابة) |
get/set_notification_config |
قراءة وتعديل كتلة مزوّد الإشعار/الدفع — على مستوى التطبيق أو كتجاوز لإصدار (التعديل يتطلّب صلاحية الكتابة) |
list_notifications / create_notification / update_notification / delete_notification |
كتالوج الإشعارات التي يُرسلها الإصدار (الكتابة تتطلّب صلاحية الكتابة) |
discover_notifications / import_notifications |
فحص المستودع عن مفاتيح إشعارات غير مُعلَنة واستيرادها (الاستيراد يتطلّب صلاحية الكتابة) |
list_account_types / get_account_types / set_account_types |
كتالوج أنواع الحسابات، والمُعلَن منها على إصداراتك، وضبطها (الضبط يتطلّب صلاحية الكتابة) |
list_shared_data |
ما تشاركه التطبيقات الأخرى مع غيرها — القائمة التي تختار منها قبل طلب الوصول. تُقرَأ من آخر إصدار منشور لكل تطبيق، فما زال في مسوّدة لا يظهر |
list_app_links / request_app_access / answer_access_request / revoke_app_access |
الروابط بين التطبيقات: عرضها في الاتجاهين، وطلب الوصول، والردّ عليه، وسحب موافقة سبق منحها (الكتابة تتطلّب صلاحية الكتابة) |
set_shared_data |
إعلان ما يشاركه تطبيقك مع غيره على إصدار قابل للتعديل — الخطوة الأولى، إذ لا يستطيع أحد طلب ما لم تُعلنه. استبدال كامل للقائمة، و[] تمسحها. الأسماء والأوصاف تُكتَب للمدرسة لا للمبرمج (تتطلّب صلاحية الكتابة) |
get_app_dependencies / set_app_dependencies |
ما يعتمد عليه تطبيقك من تطبيقات أخرى: المُعلَن حالياً، والمتاح إعلانه (ما مُنِح لك ولا يزال منشوراً)، وضبطه على إصدار قابل للتعديل. استبدال كامل، ولا يُقبل بند بلا منح ساري (الضبط يتطلّب صلاحية الكتابة) |
get_org_profile / set_org_profile |
ملف جهتك وملف المطوّر الذي يظهر في المتجر، ولغة واجهتك. كلمة مرور الحساب لا تُقرأ ولا تُكتَب عبر MCP إطلاقاً، والشعار يُرفَع من البوابة فقط (التعديل يتطلّب صلاحية الكتابة) |
optimize_screenshots |
إعادة ترميز لقطات إصدارٍ مخزَّنة إلى WebP وتصغير ما هو أعرض من اللازم. اللقطات الجديدة تُحوَّل عند الرفع أصلاً، فهذه للمكتبة الأقدم (تتطلّب صلاحية الكتابة) |
أنواع الحسابات عبر MCP
تبويب «أنواع الحسابات» في محرّر الإصدار متاح كاملاً من مساعدك:
list_account_types— الكتالوج الحيّ (نفس قائمة البوابة): لكل نوعkeyوtarget(role= دور داخل الجهة،portal= بوابة عامّة) وتسمية عربية/إنجليزية. أي مفتاح خارج القائمة يُقبل كـ دور مخصّص؛ البوابتان مجموعة مغلقة (student|guardian).get_account_types— ما يُعلنه تطبيقك فعلاً. أنواع الحسابات تُعلَن على الإصدار (لا قيمة على مستوى التطبيق تُورَّث)، فالتقرير سرد لكل إصدار؛ مرّر{version}للتضييق.set_account_types— استبدال كامل للقائمة على إصدار قابل للتعديل، تماماً كما يفعل حفظ محرّر الإصدار. اقرأ الحالي أولاً وأعد إرساله كاملاً بعد التعديل؛account_types: []تمسح الكل.
كل نوع: key (مطابق لاسم الدور أو البوابة) + kind
(primary | dependent) + هدف واحد فقط: roles (دور واحد) أو
portal + سطح واحد على الأقل: web_route (اسم مسار Laravel مثل
school-app.admin — ليس رابطاً ولا /path) و/أو mobile_entry (بالشكل
الذي يفرضه وضع الجوال على الإصدار). هذا الإعلان الواحد هو ما يبني
menu.audiences[] في okta-web وmobile.audiences[] في تطبيق أوكتا،
والأداة ترفض مسبقاً كل ما يرفضه المُحقِّق عند النشر.
الأدوار المخصّصة للجهة. يُسمَح بمفتاح دور خارج كتالوج أنواع
الحسابات — فالجهات تعرّف أدوارها الخاصة — لكنه يُعلَن صراحةً: الجمهور
الناتج يحمل "custom": true في الـ manifest، وset_account_types
تُنبّهك عند تخزين واحد. أما نشر مفتاح خارج الكتالوج بلا هذه العلامة
فمرفوض عمداً: المنصّة تطابق مفتاح الدور كنص على الأدوار التي يحملها
المستخدمون فعلاً، فالمفتاح المخترَع أو المكتوب خطأً لا يطابق أحداً —
يُنشَر الإصدار، ويبدو الـ manifest سليماً، ثم تُرفض كل صفحات ذلك الجمهور
لكل مستخدم تحت بوّابة الجماهير التي ترفض بالافتراض. إن كنت تقصد نوعاً
معروفاً فاختر مفتاحه بالضبط (list_account_types)، وإن كنت تقصد فعلاً
دوراً تعرّفه الجهة فتأكّد أنها تملكه بهذا الاسم حرفياً. ولا تصحّ
custom أبداً على جمهور portal.
ملاحظة أمان: التوكن مرتبط بك وبجهتك فقط — لا يرى ولا يلمس تطبيقات أي جهة أخرى، وسرّ الـ webhooks لا يُقرأ ولا يُعرض عبر أي أداة. يمكنك إعادة الربط في أي وقت، والموافقة على الكتابة قرارك في كل ربط.
نظام التصميم وواجهة المستخدم
التطبيق المدمج (Embedded) يظهر داخل لوحة المستأجر جنباً إلى جنب مع شاشات أوكتا الأصلية. لذلك أي ميزة بصرية يبنيها شريك يجب أن تتسق مع هوية أوكتا حتى لا يشعر المستأجر بقفزة بصرية. هذا القسم هو المرجع الوحيد الذي تحتاجه أنت — أو أداة AI مثل Claude Code / Cursor / Codex — لبناء واجهة احترافية متناغمة مع المنصة.
اقتباس سريع: في نهاية القسم برومبت جاهز انسخه إلى أي مساعد AI ليبني لك ملفات Livewire + Blade بنفس هويّة أوكتا، بدون أن تحتاج لشرح النظام كل مرة.
المبادئ الأساسية
- استخدم المكوّنات الموجودة، لا تخترع مكوّنات جديدة. كل شيء
موصوف في "كتالوج المكوّنات" أدناه (
<x-card>,<x-button>,<x-badge>,<x-input-field>,<x-textarea>,<x-alert>,<x-spinner>,<x-modal-card>). كل مكوّن آخر هو خروج عن الهوية. - Tailwind tokens من الـpalette أدناه للرماديات وألوان الحالة. ولونك الخاص له مكان مدعوم — راجع «مساحة الإبداع» أدناه؛ الممنوع هو hex حرفيّ بلا مقابل داكن.
- RTL/LTR على مستوى الـlogical properties:
start/end,ms-/me-,ps-/pe-,text-start/text-end. الأسهم والـchevron تُدوَّر بـrtl:rotate-180. - font-mono و dir="ltr" لكل المعرّفات والـURLs والـslugs.
- Card-based composition: كل قسم في
<x-card>بحدود ناعمة وظِلّ خفيف. - States أوّليّة: empty / loading / error دائماً معالَجة بشكل صريح، ليست fallback.
- Save bar واحد ثابت بدل أزرار حفظ متعدّدة لكل قسم فرعي.
- الحركة من طبقة
motion-*لا من keyframes خاصّة — فمفتاحprefers-reduced-motionمطبَّق عليها مرّة واحدة (راجع «مساحة الإبداع»).
لاختصار التحديث على prod، باقي محتوى قسم التصميم (Brand tokens، كتالوج x-*، wire-elements/modal، Layout rules، البرومبت الجاهز، نسخ مرجعية) كما هو على main commit
40ae5c0و prod commit5e29d0f. أي إضافة مستقبلية لقواعد التصميم تذهب هناك أولاً ثم تُنسَخ هنا.
مساحة الإبداع — ما تملك تغييره
القواعد أعلاه تحمي الأساس: التوكنات، والوضع الداكن، وRTL، وإتاحة المدخلات. وهي لا تعني أن تبدو كل التطبيقات نسخة واحدة. هذا القسم هو ما تملكه فعلاً، ووُجد لأن أغلب أسباب هجر الشركاء لنظام التصميم كانت خيارات موجودة أصلاً أو كان يجب أن توجد.
اسأل مساعدك أولاً: أداة
component_catalogفي MCP تعرض كل مكوّن بخصائصه وأمثلته، وأصناف الحركة، ولون تطبيقك، وأي قواعد الفاحص تُرسِب وأيّها لا.
١. البطاقة صارت مرنة
<x-card> كان له شكل واحد، فمن أراد غيره ترك المكوّن وبنى div — فخسر
التوكنات والوضع الداكن ليكسب شكلاً. الآن الشكل من المكوّن:
<x-card tone="accent" padding="none" overflow="visible" elevation="lifted">
| الخاصية | القيم | متى |
|---|---|---|
tone |
raised (افتراضي) · sunken · plain · accent |
plain = تجميع بلا صندوق مرئي؛ accent = بلون تطبيقك |
elevation |
flat · raised · lifted |
lifted يرتفع عند المرور |
padding |
default · tight · none |
none للجدول والخريطة والشريط الإعلامي الذي يجب أن يمتدّ لحافة البطاقة |
radius |
default · lg · none |
|
divider |
true (افتراضي) · false |
الخطّ تحت الترويسة |
overflow |
hidden (افتراضي) · visible |
لقائمة منسدلة تخرج من البطاقة |
كلّها اختيارية، والافتراضي يُنتج الشكل القديم حرفياً — فلا يتغيّر شيء قائم.
٢. لونك أنت
المسار الوحيد للون خاص كان قيمة حرفية bg-[#0F766E]. ولها قيمة واحدة، فهي
صحيحة في وضع وخاطئة في الآخر — والمنصّة لا تستطيع مساعدتك لأن hex داخل صنف
غير مقروء لها.
أعلِنه في البيان بقيمتين، فتصير القيمة معلومة:
"brand": {
"accent": { "light": "#0F766E", "dark": "#2DD4BF" },
"accent_contrast": { "light": "#FFFFFF", "dark": "#042F2E" },
"accent_soft": { "light": "#CCFBF1", "dark": "#134E4A" }
}
ثم استعمله كمتغيّر — يتبع الوضع الداكن كما تفعل توكنات المنصّة:
<div class="bg-[var(--app-accent)] text-[var(--app-accent-contrast)]">…</div>
<x-card tone="accent">…</x-card>
- hex فقط (٣ أو ٦ أو ٨ خانات). القيمة تُكتَب داخل
<style>، فأي صيغة أخرى —rgb(...)،var(...)،color-mix(...)— تُسقَط بلا محاولة تهريب. - ثلاثة أدوار لا غير. قائمة أطول تصير نظام ثيمات ثانياً، وعندها يفقد نظام المنصّة معناه.
- محصور في صفحاتك عبر
[data-app-brand]— لا يبلغ كروم المنصّة ولا تطبيقاً آخر على الشاشة نفسها. - أعلنت
lightوحدها؟ الداكن يرث القيمة نفسها، فلا يبقى نصف مطليّ. - دور معطوب يسقط وحده ولا يُسقِط البقيّة.
٣. الحركة
طبقة جاهزة بدل كتابة @keyframes:
| المجموعة | الأصناف |
|---|---|
| دخول | motion-fade · motion-rise · motion-slide-in · motion-scale-in |
| لفت انتباه | motion-pop · motion-shake · motion-pulse |
| تفاعل | motion-hover-lift · motion-press |
| تعديل | motion-slow · motion-delay-1..3 |
<div class="motion-stagger">
@foreach ($rows as $row)
<x-card class="motion-rise">…</x-card>
@endforeach
</div>
motion-stagger على الحاوية يجعل كل ابن يدخل بعد سابقه بـ40ms — حتى عشرة ثم
يثبت، فذيل جدول طويل لا يصل متأخّراً بثانية ونصف.
ولماذا تستعملها بدل keyframes خاصّة بك: prefers-reduced-motion مطبَّق
عليها كلّها في مكان واحد. ومَن أوقف الحركة في نظامه لم يفعل ذلك ذوقاً — لصاحب
اضطراب دهليزي، حركة لم يطلبها عَرَض لا زينة. كل keyframes تكتبها بنفسك موضع
إضافي يجب أن يتذكّره.
وأعِد التوقيت متى شئت: --motion-duration على أي عنصر يعيد توقيت كل ما تحته،
و0ms توقفه.
٤. الفاحص قناتان لا واحدة
- يُرسِب البناء — ما ينكسر خارج تصميمك:
<input>/<select>/<textarea>الخام، ورمادياتgray|zinc|slate|indigo، وmax-w-*بمقاييس الحاويات (3xlفأعلى،full،screen-*). - يُذكَر ولا يُرسِب — اتساق تملك تجاوزه:
<button>خام، ولون حرفي.
وmax-w-prose وmax-w-[42ch] ليستا مخالفتين — تلك طباعة لا حاوية.
والجدول الخام لم يعد مخالفة إطلاقاً: <x-table> لا يعبّر عن جدول حصص ولا
تقويم، وقاعدة يتخطّاها الجميع تُعلِّم تخطّي القواعد لا احترامها.
٥. النوافذ (Modals)
لا تبنِ نافذة بنفسك بـx-show/x-data وطبقة تعتيم يدوية. المنصّة
تستعمل wire-elements/modal، والحزمة مسجَّلة في التخطيط أصلاً — لا تُسجّلها
ثانيةً.
١. المكوّن يرث ModalComponent لا Component:
use LivewireUI\Modal\ModalComponent;
class WithdrawalRequestModal extends ModalComponent
{
public int $moduleId;
public static function modalMaxWidth(): string
{
return 'lg'; // sm | md | lg | xl | 2xl …
}
public function mount(int $moduleId): void
{
$this->moduleId = $moduleId;
}
public function submit(): void
{
// …
$this->closeModal();
}
}
٢. القالب يستعمل <x-modal-card> دائماً — لا <header> ولا <footer>
خاماً:
<x-modal-card class="w-[min(92vw,32rem)]" :title="__('…')" :subtitle="__('…')">
<form wire:submit="submit" id="my-form" class="space-y-3">
<x-input-field :label="__('…')" wire:model="amount"
:errorMessage="$errors->first('amount')" />
</form>
<x-slot:footer>
<x-button variant="primary" type="submit" form="my-form">{{ __('actions.save') }}</x-button>
<x-button variant="secondary-subtle" type="button"
wire:click="$dispatch('closeModal')">{{ __('actions.cancel') }}</x-button>
</x-slot:footer>
</x-modal-card>
<x-modal-card> يعطيك الترويسة وزرّ الإغلاق والحدود والحشوة و<x-slot:footer>
بفاصل علوي. ولاحظ w-[min(92vw,32rem)] لا max-w-*: العرض المسؤول للنافذة
يُكتب هكذا.
الزرّ خارج
<form>ويشير إليه بـform="my-form"— فالفوتر شقيق للنموذج لا ابنه، وزرّsubmitخارج نموذجه لا يرسله بلا هذه الإشارة.
٣. الفتح من أي مكان بحدث، مع الوسائط التي يستقبلها mount():
wire:click="$dispatch('openModal', {
component: 'partner.modules.new-version-modal',
arguments: { moduleId: {{ $module->id }} }
})"
الاسم هو alias المكوّن بالصيغة المسطَّحة. تجنّب :: فيه — Livewire 4
يمرّر الاسم على normalizeName() قبل البحث، فالـ alias الذي يحوي :: لا
يُحَلّ أبداً.
٤. الإغلاق: $this->closeModal() من الخادم، أو
wire:click="$dispatch('closeModal')" من القالب.
لماذا لا تبني نافذتك: طبقة التعتيم وحبس التركيز وإغلاق Escape وإعادة
التركيز إلى الزرّ الذي فتحها ومنع تمرير الخلفية — كلّها في الحزمة. النافذة
المبنيّة يدوياً تبدو صحيحة وتترك مستخدم لوحة المفاتيح داخل الصفحة خلفها.
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. التصريح هو الاعتماد، ولا خطوة انتظار بعده.
كيف يُعتمد platform فعلاً
بوّابة التشغيل (EnsureAiPlatformApproved) تسأل عمودَين على جدول modules في okta-web: ai_support وai_mode = platform. لا تقرأ بيانك الخام، ولا تنتظر طابوراً. وهذان العمودان يُملآن من بيانك في مسارَين:
| المسار | متى |
|---|---|
| النشر | مراجعة النشر التي وافقت على بيانك هي موافقة المنصّة على aiMode فيه — لا طلب اعتماد ثانٍ |
| الدفع إلى بيئة التجربة | فور دفع التطبيق، بلا نشر إنتاجي |
في بيئة التجربة: أعلِن وادفع، ويعمل — بلا أي موافقة من إدارة المنصّة.
بيئة التجربة تُسقِط سؤال الاعتماد كلّه: التطبيق غير مثبَّت في أي جهة حقيقية، والبيانات ليست بيانات أحد، والغاية من البيئة أن تعرف إن كانت الميزة تستحق التقديم أصلاً — فقرارُ اعتمادٍ يمنع التجربة قرارٌ في سؤال لم يُطرَح بعد. حتى السحب لا يصل إلى بيئة التجربة: إن سحبت الإدارة الاعتماد فالإنتاج يتوقّف وتبقى تجربتك تعمل.
والعكس ليس صحيحاً: الدفع إلى بيئة التجربة لا يُلغي سحباً على الإنتاج. في تركيب مشترك تقرأ جهةُ تجربتك والإنتاجُ صفَّ التطبيق نفسه، فلو كان الدفع يمسح العلم لاستعاد أي شريك وصولاً سُحب منه لسبب — بمجرّد الدفع إلى بيئته.
(وهذا كان معطوباً: مسار تثبيت التجربة لم يكن يستخرج العمودَين إطلاقاً، فتطبيق يعلن aiMode: platform يُدفَع إلى التجربة ويحصل على 403 دائماً — والمخرج الوحيد نشرٌ إنتاجي كامل.)
يبقى لفريق أوكتا سحب الاعتماد عند إساءة الاستخدام. السحب يوقف النداءات فوراً، ويعود الاعتماد عند نشر إصدار جديد يمرّ بمراجعة بشرية.
قراءة الـ 403 حين يأتي
الرفض يحمل الآن reason يقول أيّ الحالتين هي — وهما تحتاجان علاجَين متعاكسين:
{
"error": "ai_platform_not_approved",
"reason": "not_declared", // أو "revoked"
"message": "..."
}
not_declared— بيانك لم يُسجَّل: راجعaiSupport/aiModeفيه، وتأكّد أن التطبيق نُشِر أو دُفِع إلى التجربة. لا تنتظر أحداً.revoked— فريق أوكتا سحب الاعتماد: راجعهم.
الترويسات: X-Ai-Approval-Required: 1 وX-Ai-Denied-Reason: <reason>.
مرجع تفصيلي
راجع 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). دعها تنتشر — middlewareEnsureAiPlatformApprovedسيُرجع للمستخدم رسالة "بانتظار اعتماد المنصة" مع headerX-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مُعلَناً في بيانك ومسجَّلاً على المنصّة (بالنشر أو بالدفع إلى بيئة التجربة) — انظر «كيف يُعتمدplatformفعلاً» أعلاه.
بنية الأداة
كل أداة في تطبيقك هي صفّ 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_installchannel.
الصلاحيات
كل أداة تُعلن 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)
تطبيق أوكتا للجوال يعرض كروت خدمات للمستخدم. يمكن لتطبيقك أن يقدّم خدمة تظهر ككارد داخله. هذا الإعداد مرتبط بكل إصدار على حدة — إصدار لاحق يستطيع تفعيله دون لمس الإصدار الأصلي.
أين تُفعِّله
لوحة الشريك → التطبيق → الإصدار → تبويب التكامل → بطاقة «خدمات داخل تطبيق أوكتا للجوال» → فعّل الخيار، ثم قدّم الإعدادات.
الأوضاع الثلاثة — اختر واحداً لكل إصدار
| الوضع | ما تشحنه | أين | يُعرَض |
|---|---|---|---|
native |
كود Dart حقيقي (Flutter) | okta_app/native/<entry>/lib/ |
واجهة أصلية داخل تطبيق أوكتا |
webview |
ملفات ويب | okta_app/webview/ |
داخل WebView محصور |
external |
لا شيء — تستضيفه بنفسك | رابط HTTPS عندك | WebView على رابطك |
native هو الوضع الوحيد الذي يعطيك واجهة أصلية، وهو الوحيد الذي يصل
عتاد الجهاز عبر نداءات Okta.*. الوضعان الآخران يمرّان بنموذج صلاحيات
المتصفح الذي لا تتوسّطه المنصة.
الإعدادات التي تقدّمها
| الإعداد | الوصف |
|---|---|
| نوع العرض (mode) | native (كود Dart — واجهة أصلية) أو webview (ملفات ويب داخل مستودعك) أو external (رابط تستضيفه أنت). |
| نقطة الدخول (entry) | للـ native: ملف .dart تحت okta_app/native/<entry>/lib/. للـ webview: مسار نسبي داخل okta_app/webview/. للـ external: رابط HTTPS كامل. |
| أرضية العقد (min_contract) | لوضع native فقط. أدنى عقد مضيف يحتاجه كودك. |
| الأصول المسموحة (allowed origins) | للـ external فقط: قائمة أصول HTTPS يُسمح بتحميل الصفحة منها. |
| الصلاحية المطلوبة (required scope) | اختياري. الكارد يظهر فقط إذا كان الدور النشط للمستخدم يملك هذه الصلاحية (من الصلاحيات الممنوحة للإصدار). فارغ = يظهر لأي دور يصل للتطبيق. |
| تمرير الدور (pass role claim) | اختياري، للـ external فقط. يضيف claim الدور داخل JWT الموقّع. لا وصول لقاعدة بيانات في كل الأحوال. |
هيكلة الملفات (وضع webview)
ضع كل ما يُعرض في الجوال داخل مجلد okta_app/webview/ في جذر مستودع تطبيقك:
mobile/
├── README.md
├── manifest.json ← بيانات وصفية اختيارية للسطح الجوّال
├── screens/ ← ملفات نقطة الدخول التي تُعرض في WebView
│ └── dashboard.blade.php
└── assets/ ← css / js / صور هذه الشاشات
نقطة الدخول مثال: okta_app/webview/screens/dashboard.blade.php.
السياسة (مُلزِمة)
خدمات الجوال محصورة في نطاق okta_app/webview/ المخصّص:
entryللـwebviewيجب أن يكون مساراً نسبياً داخلokta_app/webview/حصراً — بدون بروتوكول (http(s)://) وبدون خروج بـ... يُرفض غير ذلك عند الحفظ/المراجعة.- شاشات الجوال لا يُسمح لها بالربط أو الانتقال أو التضمين لأي صفحة
منصّة/مستأجر خارج نطاق
okta_app/webview/. - يُفرض هذا أيضاً وقت التشغيل عبر middleware
app.webviewعلى okta-web (السطح الجوّال محصور على نطاق/app). - في وضع
externalلا تشحن ملفات هنا — تستضيف الصفحة بنفسك، والعزل يكون عبرallowed origins+ صفر ربط بيانات.
لوحة معلومات الجوال (Mobile Dashboard)
الشاشة الأولى في تطبيق أوكتا للجوال لوحة معلومات، لا قائمة تطبيقات. قائمة التطبيقات لها تبويبها الخاص «التطبيقات» في الشريط السفلي؛ واللوحة تعرض ما تقوله التطبيقات المثبَّتة نفسها: أرقام اليوم، طوابير الانتظار، آخر ما حدث.
تطبيقك يستطيع أن يضع بطاقة فيها. وهذا متاح لكل الأوضاع — native
وwebview وexternal سواء — لأن البطاقة بيانات لا واجهة.
وللبوّابات الشاشة الرئيسية نفسها
شاشة الطالب وشاشة وليّ الأمر لوحتا معلومات أيضاً، وقائمة تطبيقات البوابة
في تبويب «التطبيقات» الخاص بها — الشكل نفسه الذي لمدير الجهة. بطاقتك تصل
البوابة تلقائياً متى أعلن تطبيقك جمهوراً لها؛ لا شيء جديداً تعلنه،
وaudiences تُضيّق هناك كما تُضيّق للأدوار — اكتب ["guardian"] لتخاطب
أولياء الأمور وحدهم.
شيئان يختلفان، وكلاهما مقصود:
-
وسيط الجمهور يحمل البوابة. الوسيط الأول لمزوّدك (وحقل
roleفي نداء external) يصلstudentأوguardianحيث يصل مفتاح الدور في نداء الجهة. الوسيط نفسه والمعنى نفسه — من يقرأ — ومزوّد يتجاهله يخاطب كل الجماهير بالأرقام نفسها، وهذا افتراض سليم. -
البوابة عابرة للجهات، وأنت تقرّر كيف يُقرأ ذلك. وليّ أمر أبناؤه في مدرستين ثبّتتا تطبيقك يرى — افتراضياً — بطاقتين، لأن الأرقام تختلف وجمع مدرستين في رقم واحد يصف لا هذه ولا تلك. لكن تطبيقاً موضوعه الابن لا المدرسة يُقرأ رديئاً هكذا: بطاقتان بعنوان «أبنائي» كلٌّ منهما تحمل نصف الجواب. لهذا
portal_scope: "combined"يطوي البطاقتين في بطاقة واحدة فيها قسم لكل مدرسة.ومزوّدك لا يتغيّر في الحالتين: يعمل مرة لكل مدرسة داخل سياقها ويجيب عنها وحدها — عزل المخطّط لكل تثبيت يجعل غير ذلك مستحيلاً أصلاً — والدمج يحدث في okta-web بعد أن تتكلّم كل مدرسة. البطاقة تكسب أقساماً، وأنت لا تكسب شيئاً تحترس منه.
"dashboard": { "enabled": true, "title": "أبنائي", "portal_scope": "combined" }مدرسة واحدة أجابت؟ تُفكّ البطاقة إلى بطاقة عادية بلا أقسام — قسمٌ واحد عنوانه اسم المدرسة يقول ما تقوله البطاقة نفسها. وكل قسم يحمل حالته: مدرسة متعذّرة لا تُعطّل أرقام أختها، بل تظهر ملاحظتها وحدها.
القاعدة التي تحكم كل ما بعدها: أنت تقول ماذا، والمضيف يقرّر كيف
أنت تُرجع أرقاماً وعناوين وأزراراً؛ تطبيق أوكتا هو الذي يرسمها، بنفس اللغة البصرية لكل التطبيقات. الخط الفاصل ليس بين «بسيط» و«غنيّ» — بل بين ما تريده على البطاقة وكيف يُرسَم. الأول لك بسعة، والثاني ليس لأحد منّا وحده.
فالمفردات واسعة: أرقام، صفوف، أيقونات، أشرطة تقدّم، وأزرار تنفّذ عمليات حقيقية (انظر الأفعال أدناه). والممنوع ثلاثة فقط، ولكلٍّ سبب يبقى صحيحاً مهما اتّسعت المفردات:
- لا markup ولا HTML — بطاقة تأتي بتخطيطها الخاص تجعل لوحةً تحمل خمسة تطبيقات خمسَ شاشات متجاورة، وسؤال القارئ الأول («من يقول لي هذا، وكم عمر هذا الرقم؟») يحصل على خمس إجابات.
- لا ألوان سداسية —
toneمعنى يحلّه المضيف إلى لوحته في الوضعين الفاتح والداكن؛ لونٌ تختاره أنت لن يطابق أياً منهما. - لا صور من مضيفك — أصلٌ لم يُراجَع على أول شاشة، لا يتبع الثيم، ويفشل تحميله على الاتصالات نفسها التي وُجدت البطاقة لتخدمها. الأيقونات من مفردات المضيف بدلاً عنها.
وهو أيضاً سبب أن البطاقة لا تكلّف شيئاً: لا ترجمة تطبيق مصغّر، ولا صندوق رملي، ولا نسخة عقد. خمس بطاقات هي خمسة مصفوفات مُعادة، لا خمسة مفسّرات.
الإعلان
داخل mobile في البيان:
"mobile": {
"supported": true,
"mode": "native",
"entry": "main",
"dashboard": {
"enabled": true,
"title": "حضور اليوم",
"title_en": "Today's attendance",
"provider": "Modules\\OktaHdor\\App\\Services\\DashboardProvider",
"audiences": ["tenant-admin", "teacher"],
"cache_ttl": 300
}
}
| المفتاح | إلزامي | الوصف |
|---|---|---|
enabled |
يجب أن يكون true. لا تشحن بطاقة معطَّلة — احذف الكتلة كلها. |
|
title / title_en |
(أحدهما) | عنوان البطاقة. بدونه تُعنون بـ slug تطبيقك، وهو لا يقول لحامل الهاتف شيئاً. |
provider |
للـ embedded | صنف تحت Modules\ ينفّذ MobileDashboardProvider. |
endpoint |
للـ external | رابط https يستقبل POST موقَّعاً. |
audiences |
— | مفاتيح أدوار. تُضيّق ولا تُوسّع (انظر أدناه). |
cache_ttl |
— | ثوانٍ. يُحصر بين 30 و3600. |
portal_scope |
— | per_tenant (افتراضي) أو combined — شكل البطاقة على شاشة البوابة حين يرى الشخص أكثر من مدرسة. |
mode |
— | مَن يرسم الداخل: data (افتراضي — المضيف يرسم أرقامك) أو native (كودك Dart يرسمه — انظر أدناه). |
entry |
مع native |
ملف Dart للبطاقة تحت okta_app/native/<حزمة>/lib/. |
min_contract |
مع native |
عقد مضيف okta-app الذي تحتاجه البطاقة. الأرضية 21، والأدنى يُرفع إليها. |
💡 بديل عن تحرير manifest.json يدوياً: أداة MCP
set_mobile_dashboardتكتب الكتلة مباشرة على إصدار قابل للتعديل وتشغّل فحوص النشر فوراً، وget_mobile_surfaceتقرؤها. والمزامنة من مستودعك (sync_from_manifest) تحملها كذلك.
النقل يتبع integrationType ولا يُعلَن. تطبيق embedded يسمّي صنفاً
تناديه المنصّة داخل عمليتها، وتطبيق external يسمّي رابطاً تُوقّع إليه
المنصّة طلباً واحداً. إعلان الاثنين معاً — أو الخطأ منهما — يُرفض عند
النشر، لأن مفتاحاً ثانياً لسؤال يجيب عنه البيان أصلاً يعني أن أول تطبيق
يضبطهما متناقضين ينشر بطاقة لا تظهر أبداً.
تطبيق مدمج (Embedded): نفّذ العقد
namespace Modules\OktaHdor\App\Services;
use App\Services\PartnerApi\Contracts\MobileDashboardProvider;
final class DashboardProvider implements MobileDashboardProvider
{
public function dashboard(string $roleKey, string $locale): array
{
$today = Attendance::query()->whereDate('taken_at', today());
return [
'stats' => [
[
'label' => $locale === 'en' ? 'Present' : 'حاضر',
'value' => number_format($today->clone()->where('status', 'present')->count()),
'tone' => 'success',
],
[
'label' => $locale === 'en' ? 'Absent' : 'غائب',
'value' => number_format($today->clone()->where('status', 'absent')->count()),
'tone' => 'danger',
],
],
'rows' => [
['title' => 'الصف الأول', 'subtitle' => 'أ', 'value' => '98%'],
],
'open_label' => $locale === 'en' ? 'Open register' : 'فتح السجل',
];
}
}
يعمل داخل سياق مودولك، فيقرأ مخططك باعتماداتك تماماً كما تفعل صفحاتك. ويجب أن تُقصر كل استعلام على الجهة الحالية.
تطبيق خارجي (External): استقبل POST موقَّعاً
POST <endpoint>
X-Okta-Timestamp: 1755859200
X-Okta-Signature: HMAC-SHA256( "<timestamp>.<body>", install_signing_secret )
{"tenant_id": 42, "role": "tenant-admin", "locale": "ar"}
تحقّق من التوقيع قبل الإجابة، وأجب بنفس شكل المصفوفة أعلاه. المهلة 6 ثوانٍ — ما بعدها يُعامَل كتعذّر.
لا يُرسَل إليك أي شيء يعرّف الشخص. الرقم على اللوحة يخصّ المدرسة، ولستَ بحاجة لتعرف أيّ موظّف ينظر إلى هاتفه ليعدّ غياب اليوم.
شكل البيانات
[
'title' => 'حضور اليوم', // اختياري — يتقدّم على عنوان البيان
'stats' => [ ['label' =>, 'value' =>, 'caption' => ?, 'tone' => ?, 'icon' => ?] ],
'rows' => [ ['title' =>, 'subtitle' => ?, 'value' => ?, 'tone' => ?,
'icon' => ?, 'progress' => ?, 'action' => ?] ],
'actions' => [ ['key' =>, 'label' =>, 'tone' => ?, 'style' => ?,
'confirm' => ?, 'icon' => ?] ], // حتى ٣
'open_label' => 'فتح السجل', // اختياري — احذفه فلا يُعرض زر الفتح
'message' => null, // ملاحظة تُعرض مع الأرقام
]
value نصّ صغتَه أنت. النِّسب والعملات والنِّسَب المئوية والأعداد لا
تشترك في مُنسِّق واحد، وأنت تعرف أيّها تُنتج، وحقلٌ رقمي كان سيجبر
العميل على التخمين — أو على حمل مفردات تنسيق تكبر مع كل شريك.
tone معنى لا لون: neutral | success | warning | danger | info.
المضيف يحلّه إلى لوحة تطابق بقية الشاشة في الوضعين الفاتح والداكن؛ لونٌ
سداسي تختاره أنت لن يطابق أياً منهما.
الحدود مفروضة: 6 أرقام و8 صفوف بحد أقصى، و120 حرفاً لكل نص. الزائد يُقصّ بصمت — بطاقة تأخذ الشاشة كلها ليست بطاقة.
المفاتيح خارج المفردات تُسقَط، لا تُمرَّر. مفتاح غير معروف يصل العميل يصير جزءاً واقعياً من العقد أول ما يعتمد عليه شريك.
icon من مفردات المضيف لا رابط صورة:
bell · calendar · chart · check · clock · alert · users · user · phone · message · money · bus · book · flag · pin · star · download · upload · search · settings · lock · shield · heart · home.
اسمٌ خارجها يُسقَط ولا يُستبدَل — صورة خاطئة أسوأ من لا صورة.
progress كسرٌ بين 0 و1 يرسمه المضيف شريطاً. يُحصر ولا يُرفض: نسبة
خرجت 1.02 خطأ تقريب، لا سبب لإسقاط الصف الذي تنتمي إليه.
الأفعال: أزرار تعمل، لا تعرض
كانت البطاقة أرقاماً وزرّاً واحداً يفتح تطبيقك. ذلك جعل اللوحة مكاناً للقراءة فقط: تطبيقٌ حركته المفيدة «نادِ كل من ينتظر» كان يعرض الرقم ٣١ ثم يرسل القارئ للبحث داخل التطبيق. الآن تُعلن أزراراً في البيانات التي تُعيدها، ويصلك مفتاح الزرّ المضغوط.
'actions' => [
['key' => 'call_waiting', 'label' => 'نادِ الجميع', 'style' => 'primary',
'icon' => 'phone', 'confirm' => 'سيُنادى ٣١ طالباً الآن. متابعة؟'],
],
| المفتاح | إلزامي | الوصف |
|---|---|---|
key |
ما يعود إليك عند الضغط. زرّ بلا مفتاح لا يفعل شيئاً فيُسقَط. | |
label |
نصّ الزر. | |
style |
— | primary أو secondary (افتراضي). المضيف يرسمهما. |
tone |
— | معنى لا لون، كما في الأرقام. |
confirm |
— | نصّ تأكيد يعرضه المضيف قبل التنفيذ. |
icon |
— | من مفردات الأيقونات أعلاه. |
ويمكن لصفٍّ أن يكون قابلاً للضغط بإسناد action له بمفتاح فعل — فيصير
الصف نفسه زرّاً دون إضافة زرّ ثالث.
التنفيذ — مدمج (Embedded)
نفّذ MobileDashboardActionProvider على نفس الصنف الذي يقدّم البطاقة:
use App\Services\PartnerApi\Contracts\MobileDashboardActionProvider;
use App\Services\PartnerApi\Contracts\MobileDashboardProvider;
final class DashboardProvider implements MobileDashboardProvider, MobileDashboardActionProvider
{
public function dashboard(string $roleKey, string $locale): array { /* … */ }
public function dashboardAction(string $actionKey, string $roleKey, string $locale): array
{
if ($actionKey !== 'call_waiting') {
return ['ok' => false, 'message' => 'إجراء غير معروف.'];
}
$count = CallWaitingStudents::run(); // ضع البطيء في طابور
return ['ok' => true, 'message' => "نودي {$count} طالباً.", 'refresh' => true];
}
}
التنفيذ — خارجي (External)
نفس الـ endpoint، ويميّزه وجود action في الجسم:
POST <endpoint>
X-Okta-Signature: HMAC-SHA256( "<timestamp>.<body>", install_signing_secret )
{"tenant_id": 42, "role": "tenant-admin", "locale": "ar", "action": "call_waiting"}
أجب {"ok": true, "message": "…", "refresh": true}. المهلة ١٠ ثوانٍ
هنا (لا ٦) لأن هذا كتابة لا قراءة.
ما تضمنه لك المنصّة، وما يبقى عليك
المنصّة تتحقّق أن التطبيق مثبَّت ونشط، وأن الضاغط يرى تطبيقك أصلاً في
كتالوجه تحت (الجهة، الدور) الحالية، وأن audiences تشمل دوره — نفس بوّابة
القراءة حرفياً، فلا يُبلَغ فعلٌ بإرسال slug من دورٍ لا يرى التطبيق. وتقفل
قفلاً قصيراً لكل (جهة، تطبيق، دور، فعل) يبتلع الضغطة المزدوجة وإعادة
المحاولة على شبكة بطيئة. وrefresh: true يُسقط كاش بطاقتك فتُقرأ من جديد.
ويبقى عليك أنت:
- المفتاح المجهول رفضك أنت. المنصّة لا تتحقّق أن المفتاح كان بين ما رسمتَه: البطاقة أمام الشخص قد تكون من الكاش، ومنصّةٌ تحاول ضبط ذلك سترفض ضغطات مشروعة دون أن تجعل المفتاح المجهول آمناً. ارفضه بنفسك.
- الصلاحية والنافذة الزمنية — «هذا الفعل يحتاج صلاحية لا يملكها القارئ»، «انتهت نافذة اليوم» — كلها رفضٌ منك بجملة يفهمها الشخص.
- الفعل مرّة واحدة (idempotency) حيث تستطيع؛ القفل يغطّي الضغطة المزدوجة لا أكثر.
الفشل هنا جواب لا حالة. القراءة تتسامح مع التعذّر (بطاقة «غير محدَّث» والشاشة تُرسَم)، أما الضغط فلا: الشخص ضغط وينتظر جملة تقول هل حدث أم لا.
التضييق بالأدوار: يُضيّق ولا يُوسّع أبداً
audiences مفاتيح أدوار تُقصر البطاقة على بعض من يرون تطبيقك أصلاً.
اللوحة تُبنى من الكتالوج الذي يستلمه المستخدم بالفعل، فالتطبيق الذي
لا يظهر لدور لا يستطيع مخاطبة ذلك الدور من الباب الخلفي. اترك القائمة
فارغة ليراها كل دور يرى التطبيق.
الفشل حالة لا استثناء
اِرمِ استثناءً عند الفشل. المنصّة تلتقطه وتسجّله وتعرض بطاقتك بحالة «غير متصل»، أو بأرقامها السابقة موسومةً «غير محدَّث» إن كانت مخزَّنة.
لا تُرجع مصفوفة فارغة بدل ذلك. الفراغ لا يُفرَّق عن «لا يوجد شيء اليوم فعلاً»، فيُري مشغّلاً صفراً واثقاً في صباحٍ كان الاستعلام فيه معطوباً.
والمنصّة تحتفظ بنسخة احتياطية ليوم كامل تُجيب بها حين لا تُجيب أنت: حضور الأمس موسوماً «غير محدَّث» أنفع لمن يقف عند بوّابة مدرسة من صندوق فارغ.
ارسم البطاقة بنفسك — mode: "native"
كل ما سبق يبقى كما هو — ثم يستطيع التطبيق المدمج (embedded) خطوة أبعد: أن يرسم داخل بطاقته بلغة Dart حقيقية، عبر نفس مسار «المصدر يُترجم على الجهاز» الذي يستعمله تطبيقه المصغّر أصلاً:
"dashboard": {
"enabled": true,
"title": "حضور اليوم",
"title_en": "Today's attendance",
"provider": "Modules\\OktaHdor\\App\\Services\\DashboardProvider",
"mode": "native",
"entry": "okta_app/native/card/lib/main.dart",
"min_contract": 21
}
الإطار يبقى للمضيف. الزوايا والترويسة باسم تطبيقك وأيقونته وشارة المزامنة وزر «فتح» كلها للمنصّة؛ ودجتك تملك داخل صندوق لا تصل حوافّه — وارتفاعه أيضاً: البطاقة تُقصّ عند 320pt ولا تُمرّر، لأن قائمة تتمرّر داخل شاشة تتمرّر تسرق السحبة من القارئ.
provider يبقى إلزامياً، وهذا هو التصميم لا بقايا منه. الأرقام التي
يعيدها هي البطاقة التي يرسمها الهاتف الأقدم — والبطاقة التي يرسمها
هذا الهاتف لحظة يفشل أي شيء. كل فشل يهبط إليها، بصمت، وفي الجلسة
نفسها: مصدر لا يُترجم، ودجت ترمي أثناء البناء، إطار أول لا يصل
خلال 500ms من تسليم المحرّك، جهاز عقده أدنى من min_contract. لا
حالة خطأ للشريك على الشاشة الرئيسية — القارئ يرى بطاقة بياناتك،
والرئيسية لا تنكسر أبداً بسبب شريك. اختبر بطاقتك بأن تجعلها
ترمي عمداً: ما يجب أن تراه هو أرقامك أنت.
entryحزمة مستقلة. يعيش تحتokta_app/native/<حزمة>/lib/ككل مدخل native، واجعله حزمة خاصة (okta_app/native/card/) لا ملفاً ثانياً داخل حزمة تطبيقك — التسليم يقتطع حزمة واحدة لكل مدخل، فمدخل بطاقة داخل حزمة التطبيق يعني تنزيل تطبيقك كله وترجمته لرسم بطاقة واحدة.min_contractإلزامي والأرضية 21 — فيه شُحن محرّك البطاقات، والأدنى منه يُرفع إليه. الجهاز دونه لا يركّب البطاقة أصلاً ويعرض بطاقة البيانات؛ رفضٌ مقصود وغير مرئي، لا خطأ.- الصندوق الرملي هو صندوق التطبيق المصغّر نفسه. نفس
Okta.*، نفس العزل، نفس الثيم. رمز واحد خاص بالبطاقات:Okta.openMiniApp()يفتح تطبيقك الكامل — نفس فعل زر «فتح» في إطار المضيف. الإبحار العميق مكانه التطبيق؛ البطاقة خلاصة لا شاشة. - البوّابات تُضاعف البطاقة لا حالتها. وليّ أمر أبناؤه في مدرستين يرى بطاقتين تُترجمان وتُركّبان منفصلتين، لا تتشاركان حالة ولا جهة ولا delegate — نفس عزل المدرسة-بمدرسة الذي للتطبيق الكامل.
- للمدمج فقط. البطاقة native تُترجم من حزمة مصدرك الموقّعة،
والتطبيق external لا يشحن كوداً —
mode: "native"يُرفض عند النشر؛ ابقَ علىmode: "data"مع endpoint.
set_mobile_dashboard تكتب المفاتيح الثلاثة وتشغّل هذه الفحوص وقت
التطوير؛ ومخطّط البيان وبوّابة النشر يكرّرانها.
ما يجب أن تعرفه قبل النشر
- بطاقتك تظهر فقط لمن يرى تطبيقك أصلاً في الكتالوج.
cache_ttlيُحصر بين 30 و3600 في الطرفين — البيان المنشور يُظهر الرقم الذي سيُستعمل فعلاً، لا الذي كتبته.providerيجب أن يكون تحتModules\. المنصّة تُنشئ هذا الصنف بالاسم، فأي اسم خارج فضاء مودولك هو بيانٌ يختار داخليّة منصّة لتُنشَأ — والفحصinstanceofمتأخّر، لأن المُنشئ يكون قد نُفِّذ.- البطاقة تحمل أزراراً تعمل (حتى ٣) وصفوفاً قابلة للضغط — لكنها تبقى
بيانات: لا HTML، ولا ألوان سداسية، ولا صور من مضيفك. الأيقونات من
مفردات المضيف، والألوان من
tone. - الأزرار تحتاج نصفها الثاني: أزرار ترسمها بلا
MobileDashboardActionProvider(أو بلا استجابة external لـaction) تظهر ثم تعتذر عند أول ضغطة.
شاشات العرض (Okta Screen)
الهاتف يفتحه شخص؛ الشاشة لا يفتحها أحد. تلفاز معلّق في الفصل، لوحة في ردهة المدرسة، شاشة عند باب المجمّع — جهاز يعمل طول اليوم بلا مستخدم أمامه، ويُدار من تطبيق مستقلّ اسمه Okta Screen (Android TV / Google TV، وWindows). يدخل بالاقتران فقط: لا حساب ولا كلمة سرّ، بل رمز يُنشئه مسؤول الجهة من سجلّ الأجهزة ويكتبه على الشاشة مرّة واحدة.
وما يميّز الشاشة عن الهاتف هو مكانها لا صاحبها: عند الاقتران يختار المسؤول أين تقف — الجهة كاملة (ردهة، ممرّ) أو فصل بعينه. تطبيقك يُعلن لأيّ المكانين يقدّم سطحاً، والمنصّة تعرضه على الشاشات التي تقف هناك.
الإعلان — mobile.screen
صفّ لكل مكان، ولكل صفّ ملف دخوله:
"mobile": {
"screen": {
"enabled": true,
"title": "نداء الفصل",
"title_en": "Class roll call",
"min_contract": 21,
"auto_launch": true,
"places": [
{ "scope": "section", "entry": "okta_app/native/screen/lib/main.dart" },
{ "scope": "tenant", "entry": "okta_app/native/screen_lobby/lib/main.dart",
"title": "لوحة الإعلانات", "title_en": "Notice board" }
]
}
}
| المفتاح | الوصف |
|---|---|
places[] |
إلزامي. صفّ أو صفّان — واحد لكل مكان تخدمه. |
places[].scope |
إلزامي. "section" (فصل) أو "tenant" (الجهة كاملة). كل مكان مرّة واحدة؛ المكرّر يُبقي الصفّ الأول ويُرفض الثاني بالاسم. |
places[].entry |
إلزامي لكل صفّ. ملف Dart تحت okta_app/native/<حزمة>/lib/، في حزمة مستقلة. يكشف Widget main(). |
places[].title / title_en |
اختياري. يتجاوز عنوان الكتلة لهذا المكان وحده — «نداء الفصل» في الفصل، «لوحة الإعلانات» في الردهة. |
title / title_en |
العنوان الافتراضي الذي يرثه كل صفّ لم يكتب عنوانه. كل مكان لا بدّ أن ينتهي إلى عنوان — عنوانه أو هذا — وإلا رُفض الإعلان. |
min_contract |
اختياري. أرضية عقد المضيف لكود الشاشة؛ تُترك لتُؤخذ من mobile.minContract. على مستوى الكتلة لأنها تصف التطبيق لا المكان. |
auto_launch |
اختياري، افتراضه true: الشاشة التي لا تجد في كتالوجها غير تطبيقك تفتحه بنفسها. على مستوى الكتلة أيضاً. |
لماذا ملف دخول لكل مكان. الحزمة تُترجَم على الجهاز: الشاشة تنزّل
مصدر الحزمة التي أُعلنت لمكانها وتترجمه عندها. فلو تشارك المكانان ملفاً
واحداً لصار كل تلفاز فصل ينزّل كود الردهة ويترجمه ليرسم نداءً لا علاقة
له به. شاشة الفصل وشاشة الردهة شاشتان مختلفتان، والإعلان يقول ذلك: مكان
واحد = صفّ واحد = حزمة واحدة. القالب يشحن الحزمتين جاهزتين —
okta_app/native/screen/ (نداء الفصل) وokta_app/native/screen_lobby/
(لوحة الإعلانات).
ويجوز أن يشترك المكانان في ملف واحد إن أردت حزمةً واحدة تتفرّع على
Okta.context()['screen']: اكتب المسار نفسه في الصفّين. عندها هي حزمة
واحدة باختيارك لا بحكم الصيغة — والمنصّة تعرف أنها ناتج واحد لا اثنان.
الصيغة القديمة ما زالت تُقرأ. الشكل الأول كان entry واحداً
مع scopes[] مسطّحة؛ إن كان بيانك ما زال عليه فهو يُقرأ صفّاً لكل مكان
مذكور، كلّها بذلك الملف — بيانٌ كان يُنشَر بنجاح يبقى يُنشَر. لكنه لا
يُكتَب أبداً في الاتجاه الآخر: المخزَّن والمنشور دائماً places[].
الكتلة مستقلّة عن mobile.supported. تطبيق يخدم شاشة الفصل ولا
يقدّم شيئاً على الهاتف يُعلن screen وحدها ويترك supported: false —
وتصله المزامنة والمحرّر وأداة MCP كلّها بلا أن يُمحى. وهي للتطبيقات
المدمجة فقط: الشاشة تُترجم كودك من حزمة مصدرك الموقّعة، والتطبيق
external لا يشحن كوداً.
ما تصله الشاشة — Okta.context()
الصندوق الرملي هو صندوق التطبيق المصغّر نفسه: نفس Okta.*، نفس
okta_kit، نفس الثيم. الشاشة تضيف ثلاثة مفاتيح إلى Okta.context()
ولا رمزاً جديداً — فلا رفع عقد ولا minContract أعلى:
final ctx = Okta.context();
final String scope = '${ctx['screen']}'; // 'section' أو 'tenant'
final dynamic sectionId = ctx['section_id']; // ULID الفصل، أو null على شاشة الجهة
final dynamic sectionName = ctx['section_name']; // «1-أ» مثلاً
['screen'] يصلك كما كان — لكنه لم يعد آلية التوجيه: المنصّة تختار
لكل شاشة ملف الدخول المُعلَن لمكانها، فحزمة الفصل لا تعمل إلا في فصل.
يبقى مفيداً لملفٍ اختَرت أن يخدم المكانين معاً (تتفرّع عليه)، ولحارسٍ
يقول «هذه لوحة ردهة عُلِّقت على شاشة فصل» بدل أن يرسم لوحاً فارغاً.
اقرأ المفاتيح بـ ['…'] كما تقرأ locale، وتحقّق من null صراحةً ولا
تُدخل القيمة في نصّ قبل ذلك (راجع درس '$value' في قسم native).
tenant_id وlocale وis_dark تصل كما هي؛ وrole_id فارغ لأن الشاشة
بلا دور.
شاشة الفصل ترى فصلها فقط — بحكم الخادم
هذه ليست توصية لكودك. شاشة مقترنة بفصل تصل بيانات الطلاب عبر
/api/apps/education/students مثبَّتةً على فصلها في الخادم:
- بلا أي مرشّح → طلاب هذا الفصل، لا المدرسة.
?section_id=يسمّي فصلاً آخر → صفحة فارغة./students/{ulid}لطالب فصل آخر → 404، كما لو لم يوجد./education/sections→ الفصل نفسه وحده.
فتطبيق النداء على شاشة الفصل لا يستطيع سرد المدرسة ولو طلب. وشاشة
الجهة (tenant) ترى ما يراه تطبيقك على الهاتف، بالنطاقات نفسها الممنوحة
له عند التثبيت — لا نطاقات خاصة بالشاشات.
مثال: نداء الفصل (okta_app/native/screen/lib/main.dart)
import 'package:flutter/material.dart';
import 'package:okta_host/okta_host.dart';
import 'package:okta_kit/okta_kit.dart';
Widget main() => const RollCallScreen();
class RollCallScreen extends StatefulWidget {
const RollCallScreen({super.key});
@override
State<RollCallScreen> createState() => _RollCallScreenState();
}
class _RollCallScreenState extends State<RollCallScreen> {
List<dynamic> students = [];
@override
void initState() {
super.initState();
load();
}
Future<void> load() async {
// لا مرشّح: الخادم يثبّت الطلب على الفصل الذي تقف فيه الشاشة.
final res = await Okta.get('/api/apps/education/students?per_page=100');
if (res.ok) {
// body يبقى dynamic؛ OktaJson.rows تفكّ غلاف {data: [...]}.
final dynamic body = res.body;
final List<dynamic> rows = OktaJson.rows(body);
setState(() { students = rows; });
}
}
@override
Widget build(BuildContext context) {
final dynamic ctx = Okta.context();
final dynamic section = ctx['section_name'];
final palette = OktaPalette.of(Okta.isDark());
final List<Widget> rows = <Widget>[];
for (final dynamic student in students) {
rows.add(Padding(
padding: const EdgeInsets.all(12),
child: Text(OktaJson.strOr(student, 'full_name', '—'),
style: TextStyle(fontSize: 36, color: palette.textStrong)),
));
}
return Scaffold(
backgroundColor: palette.surface,
appBar: OktaAppBar.build(section == null ? 'نداء' : 'نداء الفصل $section', palette),
body: ListView(children: rows),
);
}
}
مثال: لوحة إعلانات الردهة (okta_app/native/screen_lobby/lib/main.dart)
حزمة أخرى، وملف آخر — لا فرع داخل الملف الأول. ولاحظ أنها لا تسأل عن
طالب: الردهة يمرّ بها الجميع، فالقالب يقرأ من نقطة تطبيقك أنت
(/api/<slug>/notices) ولا يحتاج أي نطاق منصّة.
import 'package:flutter/material.dart';
import 'package:okta_host/okta_host.dart';
import 'package:okta_kit/okta_kit.dart';
Widget main() => const LobbyBoardScreen();
class LobbyBoardScreen extends StatefulWidget {
const LobbyBoardScreen({super.key});
@override
State<LobbyBoardScreen> createState() => _LobbyBoardScreenState();
}
class _LobbyBoardScreenState extends State<LobbyBoardScreen> {
List<dynamic> notices = [];
@override
void initState() {
super.initState();
load();
}
Future<void> load() async {
final res = await Okta.get('/api/my-app/notices');
if (res.ok) {
final dynamic body = res.body;
final List<dynamic> rows = OktaJson.rows(body);
setState(() { notices = rows; });
}
}
@override
Widget build(BuildContext context) {
final palette = OktaPalette.of(Okta.isDark());
final List<Widget> rows = <Widget>[];
for (final dynamic notice in notices) {
rows.add(Padding(
padding: const EdgeInsets.all(12),
child: Text(OktaJson.strOr(notice, 'title', '—'),
style: TextStyle(fontSize: 36, color: palette.textStrong)),
));
}
return Scaffold(
backgroundColor: palette.surface,
appBar: OktaAppBar.build('لوحة الإعلانات', palette),
body: ListView(children: rows),
);
}
}
النسختان الكاملتان — بحلقة تحديث وإيقافها في dispose — تصلانك جاهزتين
في القالب تحت okta_app/native/screen/ وokta_app/native/screen_lobby/.
نصائح للشاشة الكبيرة: خطّ أكبر ممّا تعتاده على الهاتف (يُقرأ من آخر
الفصل)، بلا لمس — لا تعتمد على إيماءات أو حقول كتابة — وحدّث بياناتك
على جدول لا بسحبٍ للتحديث. الشاشة تُبقي تطبيقك مفتوحاً ساعات؛ وTimer
غير مجسور على هذا المحرّك (راجع البند ١٤ في قسم native)، فحلقة
التحديث استدعاء ذاتي بـ Future.delayed مع علم إيقاف تضبطه في dispose،
وبفاصل طويل — كل دورة رحلة إلى المضيف. وشغّل
flutter test tool/validate.dart داخل كل حزمة شاشة تشحنها: الفحص
يترجم حزمة واحدة، فحزمة فصل خضراء لا تقول شيئاً عن الردهة.
دعم مفاتيح الاتجاه (D-Pad) ومفتاح القائمة
لا لمس على الشاشة أبداً: التحكّم بريموت تلفاز (مفاتيح اتجاه + OK) أو بلوحة مفاتيح على Windows (أسهم + Enter). وما يلي مَقيس على السندبوكس نفسه لا مستنتَج من Flutter عموماً — والفرق بين الاثنين هنا كبير.
العناصر القياسية تدعم الريموت تلقائياً وبلا سطر إضافي. ElevatedButton
وTextButton وListTile: الأسهم/Tab تنقل التركيز إليها، وOK/Enter
(وزرّ A على يد التحكّم) يُشغّل onPressed/onTap داخل كودك المُترجَم على
الجهاز. استعملها لأي شيء يُفترض أن يصله الريموت ولا تحتاج أكثر.
وبطاقتك أنت تصلها كذلك — منذ العقد 25 (package:okta_focus). هذا هو
الجديد، وقبله لم يكن ممكناً بأي حيلة:
import 'package:okta_focus/okta_focus.dart';
OktaFocus.first( // ← تحمل التركيز عند أول رسم
myBigCard(student), // ← أي شيء رسمتَه: Container، Row، صورة…
() => _call(student), // ← يُشغَّل على OK/Enter، وعلى اللمس أيضاً
)
OktaFocus.item(child, onSelect)— العادية، والأولى التي تُستعمل.OktaFocus.first(child, onSelect)— نفسها وتحمل التركيز أول رسم. واحدة لكل شاشة: اثنتان تطلبان التركيز الأول سؤالٌ له جوابان، وأيّهما يفوز ليس شيئاً يُبنى عليه.OktaFocus.watched(child, onSelect, onFocusChange)وwatchedFirst(...)— تُخبرك بدخول التركيز وخروجه لتكبّر البطاقة أو تلوّنها.OktaFocus.reachable(child)— يصله السهم ولا شيء يُضغَط: لصفٍّ في قائمة طويلة ينبغي أن تمرّ الحلقة خلاله بدل أن تقفز فوق كتلة من المحتوى.
والحلقة (focus ring) يرسمها المضيف بلون الغلاف، وعرضها محجوز دائماً فلا
يزحزح نيلُ التركيز الصفَّ الذي تجلس فيه بطاقتك. لا ترسمها ولا تستطيع إخفاءها:
لوحةٌ على حائط بلا حلقة مرئية لوحةٌ لا يستطيع أحد في الغرفة التنقّل فيها.
استعمل watched لتضيف تمييزك فوقها، لا بدلاً منها.
وautofocus: true على زرّ قياسي ما زالت لا تعمل، والسبب أن الزرّ المجسور
لا يملك ذلك الوسيط أصلاً. OktaFocus.first هي الجواب.
Focus وFocusNode وFocusTraversalGroup وKeyboardListener وPopScope
غير موجودة أصلاً — أيٌّ منها يفشل بخطأ ترجمة (Could not find declaration)، لا بخطأ تشغيل. وهذا يقرّر شيئين:
GestureDetectorوحده لا يصله الريموت أبداً. العنصر يصير هدفاً للتنقّل حين يملكFocusNode، ولا سبيل لمنحه واحداً بلاFocus. فاللمس على Windows يعمل عليه وريموت التلفاز لا يصله إطلاقاً. لُفّه بـOktaFocus.item(أو ضع الفعل علىListTile) — ولا تترك فعلاً علىGestureDetectorمجرّد على سطح الشاشة أبداً.- لا تستطيع ابتلاع مفتاح القائمة ولو أردت، و
OktaFocusلم تغيّر ذلك. اعتراض حدث لوحة المفاتيح الخام مستحيل من جهتك بنيوياً، لا «غير موصى به»: المفردة الوحيدة التي تصل كودك هي مفتاح الاختيار، ولاKeyEventيعبر إلى داخل الصندوق إطلاقاً. الأسهم تبقى تنقّل التركيز، ومفتاح القائمة (Menu على الريموت، أو F1/Escape على لوحة المفاتيح) ومفتاح الرجوع يبقيان للمضيف ويفتحان قائمته (اللانشر، إعادة تحميل تطبيقك، الإعدادات) — وهي المخرج الوحيد من تطبيق وحيد يعمل بوضع kiosk (auto_launch)، حيث لا «رجوع» لأن لا شيء آخر تعود إليه.
والشاشة السلبية بلا أزرار شكل مشروع تماماً (نداء فصل، لوحة إعلانات): مفتاح القائمة يبقى يعمل لأن المضيف يحمل تركيزاً احتياطياً حين لا يملك شيء في صفحتك التركيز.
لا MediaQuery ولا LayoutBuilder — كلاهما غير متاح (الأول غير مُجسَّر،
والثاني يفشل بخطأ ترجمة كالبقية أعلاه). أي أن شاشتك لا تستطيع قياس لوحتها
إطلاقاً، فصمِّم على القياس الثابت مباشرة: تلفاز 1080p بكثافة ~320 يُبلَّغ
منطقياً بـ960×540 تقريباً لا 1920×1080 — ومن هنا خطوط أكبر من الهاتف
(36 نقطة في الأمثلة أعلاه ليست تعسّفية).
auto_launch وصف نيّتك، لا ضمان. مدير الشاشة يملك مفتاحاً على جهازه
("اعرض الشاشة الرئيسية دائماً") يُبطل auto_launch لكل التطبيقات على
تلك الشاشة بعينها، فتفتح دائماً على شبكة التطبيقات المثبَّتة ولو كان
تطبيقك الوحيد المُعلَن هناك. لا تفترض أن تطبيقك سيُفتح بلا تدخّل: ابنِ أول
شاشة فيه بحيث تُفهَم لو دخلها المستخدم بضغطة من الشبكة لا تلقائياً — وهذا
هو حال الاختبار من المحاكي والمراجعة على أي حال.
تصميم واجهة الشاشة — القياسات والتخطيط وزرّ الرجوع
الشاشة تُرى من بعيد وبعيون كثيرة تمرّ عليها لا عين واحدة تحملها قريبة — هذا يقلب أولويات التصميم المعتادة على الهاتف:
- القياس المرجعي 960×540 منطقياً (تلفاز 1080p بكثافة ~320)، لا الهاتف مكبّراً. صمِّم على هذا الأساس مباشرة بدل تصميم لوحة هاتف ثم تكبير كل شيء بمعامل ثابت — النسب التي تبدو متوازنة على الهاتف تصير فارغة أو مزدحمة على شاشة بنسبة عرض مختلفة كلياً (16:9 عريضة قصيرة).
- هامش أمان من الحواف (Overscan): بعض شاشات التلفاز الحقيقية — خصوصاً الأقدم منها — تقصّ بضع بكسلات من كل حافة عرضاً لا برمجياً. لا تُلصق نصاً أو عنصراً تفاعلياً بحافة الشاشة مباشرة؛ اترك هامشاً لا يقلّ عن 24-32dp حول كامل المحتوى.
- خطوط أكبر بكثير مما تعتاده على الهاتف — تُقرأ من آخر الفصل أو الردهة
لا من مسافة الذراع. 36 نقطة في أمثلة هذا القسم أرضية معقولة للنصّ العادي،
والعناوين أكبر من ذلك بوضوح. تباين لون قويّ بين النص والخلفية (لا درجات
رمادية متقاربة) — استخدم
OktaPalette.of(Okta.isDark())بدل ألوان ثابتة حتى يتبع تطبيقك وضع الشاشة (فاتح/داكن) الذي يضبطه مدير الجهاز من إعداداته هو، لا تطبيقك. - عناصر تفاعلية كبيرة وقليلة، لا شبكة كثيفة صغيرة: التنقّل كلّه بمفاتيح
الاتجاه على الريموت، فكل عنصر إضافي في الشبكة هو ضغطة إضافية على مستخدم
لا يملك لمساً ليقفز مباشرة إليه. فضِّل قائمة قصيرة من بطاقات كبيرة على
شبكة مزدحمة من عناصر صغيرة، والحلقة تُرسَم لك — Flutter على العناصر
القياسية، والمضيف على بلاطات
OktaFocus— فلا تستبدلها بتلوين يدوي خافت يصعب تمييزه من آخر الغرفة. - كثافة معلومات منخفضة عموماً: الشاشة عرضٌ سلبي غالباً (نداء، لوحة إعلانات، إحصاء حيّ) يُلقى عليه نظر عابر، لا لوحة تحكّم يُدقَّق فيها. رسالة واحدة واضحة أو قائمة واحدة مقروءة أفضل من لوحة بأربع بطاقات إحصاء وجدول ورسم بياني في آن.
زرّ الرجوع على الريموت — يملكه المضيف لا تطبيقك. المفتاح الفيزيائي "رجوع" (Back على ريموت التلفاز، أو Escape على لوحة مفاتيح Windows) يُعترَض على مستوى المضيف بالكامل قبل أن يصل شجرة widgets الخاصة بك، وسلوكه ثابت بحكم عدد التطبيقات لا بحكم كودك:
- تطبيق واحد مثبَّت (kiosk): الرجوع يُبتلَع تماماً — لا شيء يحدث، عمداً، حتى لا تنزلق شاشة عرض سلبية عن حالتها بضغطة عابرة. المخرج الوحيد هو مفتاح القائمة (راجع القسم السابق).
- أكثر من تطبيق: الرجوع يعيد المستخدم إلى شبكة التطبيقات (اللانشر) مباشرة — شجرتك لا تُستشار ولا تحصل على أي إشعار بهذا الحدث.
لذلك لا تبنِ تنقّلاً داخلياً (قائمة ← تفصيل مثلاً) يعتمد على زرّ الرجوع
الفيزيائي للصعود مستوى — هذا الحدث لا يصل كودك أبداً بالمعنى الذي تتوقّعه
تطبيقات الهاتف العادية. إن احتجت مساراً للخروج من تفصيل إلى القائمة داخل
شاشتك، ضع زرّ "رجوع" مرئياً على الشاشة نفسها — زرّاً قياسياً أو ListTile
أو أي شيء رسمتَه ملفوفاً بـOktaFocus.item (راجع قاعدة الوصول بالريموت
أعلاه)، وأولاً في ترتيب القراءة أو حاملاً OktaFocus.first — وتحكّم فيه بحالتك الداخلية
(setState) لا بـNavigator.
تجربة الشاشة على الجهاز الافتراضي (المحاكي)
لا تحتاج تلفازاً لتجرّب: «الجهاز الافتراضي» في البوّابة (تبويب التطبيق، أو
/dashboard/simulator) يركّب سطح الشاشة نفسه في المتصفح — نفس محرّك
dart_eval الذي تُترجَم عليه على الشاشة، ونفس حزمة المصدر من فرعك.
- السطح «شاشة عرض (Okta Screen)» يظهر في اختيار السطح حين يُعلن الإصدار
mobile.screen، ويُختار تلقائياً لتطبيق لا سطح هاتف له. - المكان أولاً: صفّ لكل مكان مُعلَن (شاشة الجهة / شاشة الفصل)، وكلٌّ يسمّي ملف دخوله. المحاكي يترجم ملف المكان المختار وحده — كما تفعل الشاشة — فتطبيقٌ يخدم المكانين يُجرَّب مرتين، لكل مكان حزمته.
- الإطار تلفاز 960×540 منطقياً (dpr 2، بلا حواف آمنة) — الحجم الذي يبلّغه Android TV فعلاً، لا هاتف مقلوب. لوحة الجهاز تتيح مقاسات أخرى للمقارنة، لكن الحكم على التخطيط يكون على هذا المقاس.
Okta.context()كما تكتبه الشاشة، مفتاحاً بمفتاح:screen(tenant|section)، وللفصلsection_idوsection_name، وrole_idفارغ لأن لا أحد يدخل إلى شاشة. الفصل تجريبي (sim-section/ «الفصل التجريبي») — ومن لوحة «الشخصية» تكتب ULID فصلٍ حقيقي في السندبوكس واسمه إن أردت أن تتّبع بياناته. وتغيير المكان أو الفصل إعادة إطلاق لا إعادة رسم، لأن السياق يُحقَن عند البداية على الشاشة أيضاً.- المصدر فرع Git فقط: خيار «المثبَّت على السندبوكس» معطَّل للشاشة — لا جهاز شاشة مقترن بالسندبوكس لتُنزَّل حزمته منه.
- الريموت = لوحة مفاتيح الحاسوب: الأسهم وTab تنقل التركيز، وEnter هو OK. ما لا يصله السهم في المحاكي لا يصله الريموت على التلفاز (راجع قسم D-Pad).
- حدّ يجب أن تعرفه — البيانات لا تُقيَّد بالفصل هنا. نداءات
/api/appsفي المحاكي تمرّ بمقعد السندبوكس (حساب مدير) لا بتوكن جهاز شاشة، فيعودeducation/studentsبطلاب المدرسة كلّها لا طلاب الفصل. على الشاشة الحقيقية يثبّت الخادم الفصل على السياق (sectionScopeId) فلا يمرّ غيره — ضمانةُ خادم لا تعتمد على كودك، ولا تُختبَر إلا على شاشة مقترنة. في المحاكي رشّح بـsection_idبنفسك إن أردت أن ترى ما سيراه الفصل. - من MCP:
simulator_start {slug, surface: "screen", screen_scope: "section"}(أو"tenant")، ثمsimulator_screenshot/simulator_ui/simulator_tapكأي جلسة — والأداة تطبع في ردّها المكان والفصل التجريبي وحدّ البيانات أعلاه.
حين يتعطّل تطبيقك على الشاشة — رمز العطل وتبليغ المنصّة
شاشةٌ لا يقف خلفها أحد لا يجوز أن تبيضّ بصمت ولا أن تعرض شجرة خطأ حمراء
لفصلٍ كامل. فإن فشل تطبيقك — خطأ ترجمة، استثناء عند الرسم، انتهاء مهلة
التحميل — تعرض الشاشة شاشةَ حالة تحمل رمز عطل بصيغة MA-XXXXXXX
(MA = تطبيق مصغّر، ثم بصمة مشتقّة من نوع الخطأ ونصّه — نفس خوارزمية رموز
okta-app، فالرمز نفسه لنفس العطل على كل جهاز). المشغّل يقرؤه من على
التلفاز ويبلّغك به.
- الرمز يصل المنصّة تلقائياً: الشاشة تبلّغ okta-web بالعطل مرة واحدة لكل
رمز في الجلسة (
POST /api/device/client-errorsبتوكن الجهاز المقترن) مع الرسالة والمكدّس ومعرّف تطبيقك (module_slug) وهوية الجهاز — فيُنسَب العطل إلى تطبيقك لا إلى الشاشة. - تقرؤه أنت من صفحة «الأخطاء» على تطبيقك في البوّابة، أو من أدوات MCP:
recent_errorsللأحدث،app_errorsلقائمة الفرز، وget_errorبالرمز الذي قرأه المشغّل — من رمزٍ على تلفاز إلى المكدّس الكامل بلا وسيط. - التكرار يُطوى: الأعطال ذات البصمة نفسها تسقط على صفّ واحد بعدّاد
(
occurrences_count)، فمئة شاشة تعرض العطل نفسه صفٌّ واحد لا مئة بلاغ. - ما لا يُبلَّغ: تطبيق يعمل ويعرض شيئاً خاطئاً لا تعرفه المنصّة — البلاغ للفشل الذي يمنع الرسم. والمخرج على الجهاز نفسه هو قائمة المضيف (مفتاح القائمة ← إعادة تحميل التطبيق).
من المحرّر وMCP
- المحاكي: السطح «شاشة عرض» في الجهاز الافتراضي (القسم أعلاه)، ومن MCP
simulator_startبـsurface: "screen"وscreen_scope. - المحرّر: بطاقة «خدمات داخل تطبيق أوكتا» ← قسم شاشات العرض — مربّع لكل مكان يفتح ملف دخوله وعنوانه الخاص، وظاهر حتى لو كان سطح الهاتف مُطفأً.
- MCP:
set_mobile_screenتكتب الكتلة (استبدال كامل، أوnullللإزالة) وتشغّل فحوص النشر مبكراً؛get_mobile_surfaceتقرؤها وتطبع سطراً لكل مكان بملفه. - المزامنة من المستودع (
sync_from_manifest) تحملها كما تحملdashboard.
الوضع native — تطبيق مصغّر بلغة Dart
تكتب Dart حقيقياً (لا JSON ولا DSL). تطبيق أوكتا يُنزّل المصدر،
يترجمه على الجهاز (مرة لكل إصدار منشور ثم يُخزَّن مؤقتاً)، ويعرض
الودجت الذي تُعيده main() — بهوية المنصة كاملة.
لا مُنتَج مترجَم يغادر مستودعك. okta-web يقرأ كل ملفات .dart تحت
okta_app/native/<entry>/lib/ في حزمة مصدر موقَّعة واحدة عند النشر،
فما يُراجَع هو بالضبط ما يعمل.
الهيكل (يصلك جاهزاً في الـ boilerplate)
okta_app/native/main/
├── pubspec.yaml ← يثبّت okta_miniapp — لا ترفع الـ ref بنفسك
├── analysis_options.yaml
├── lib/
│ └── main.dart ← نقطة الدخول: Widget main()
└── tool/
└── validate.dart ← نفس بوابة الـ CI
نقطة الدخول: okta_app/native/main/lib/main.dart — ملف .dart تحت
lib/ حصراً، بلا ... يُفرَض عند الحفظ وعند النشر معاً.
أصغر مثال يعمل
import 'package:flutter/material.dart';
import 'package:okta_host/okta_host.dart';
/// أوكتا يستدعيها ليأخذ ودجت الجذر.
Widget main() => const HomeScreen();
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
// القيم القياسية، لا `Okta.context()`: هذه تعيد **خريطة** مفكوكة
// بمفاتيح snake_case، فـ`ctx.tenantId` لا وجود له و`ctx['tenantId']`
// مفتاح غائب — وقراءة مفتاح غائب هي أخبث فخّ في هذا المحرّك (أدناه).
return Scaffold(
appBar: AppBar(title: const Text('تطبيقي')),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('الجهة: ${Okta.tenantId()} الدور: ${Okta.roleId()}'),
ElevatedButton(
onPressed: () => Okta.toast('مرحباً'),
child: const Text('قل مرحباً'),
),
],
),
);
}
}
عقد المضيف — package:okta_host
المخرج الوحيد من الصندوق هو Okta.*. العقد الحالي 25.
| النداء | ما يفعله | أدنى عقد |
|---|---|---|
Okta.contract() |
رقم عقد المضيف على هذا الجهاز | 1 |
Okta.locale() · Okta.tenantId() · Okta.roleId() · Okta.isDark() |
الهوية والمظهر، قيماً قياسية | 1 |
Okta.context() |
نفس ما سبق خريطةً مفكوكة بمفاتيح locale/tenant_id/role_id/is_dark — فضّل القياسية |
1 |
Okta.get(path) · Okta.getQuery(path, query) · Okta.post(path, body) · Okta.api(method, path, body, query) |
HTTP عبر المضيف — المسارات محصورة في /api/<slug>/… وواجهة الشركاء المحكومة بالنطاقات. المصادقة تُرفَق عنك. تعيد OktaApiResponse — راجع «شكل الناتج» أدناه |
1 |
Okta.scanBarcode() · Okta.scanNfc() |
ماسحان يأخذان الشاشة ويعيدان قراءة واحدة (String?) |
1 |
Okta.uploadFile(path) |
اختيار ورفع لمسار مسموح — خريطة مفكوكة، أو null إن ألغى المستخدم |
1 |
Okta.toast(message) |
إشعار بهوية المضيف | 1 |
Okta.storeGet/storePut/storeDelete/storeKeys |
تخزين مفتاح-قيمة دائم، مُسمّى لتطبيقك وحده. storePut تعيد false عند الرفض ولا ترمي |
5 |
Okta.playSound(name) |
success · error (أو failure) · warning — مفردات مغلقة، ومعها اهتزاز |
5 |
Okta.location() |
موقع تقريبي واحد — خريطة ['latitude']/['longitude']/['accuracy']/['error'] |
5 |
Okta.preciseLocation() |
أدقّ ما يستطيعه الجهاز، أبطأ — نفس الشكل | 6 |
Okta.appIcon(size) |
ودجت أيقونة تطبيقك يرسمها المضيف (الصندوق لا يحمّل صورة) | 7 |
Okta.close() |
الخروج من التطبيق المصغّر — لا يملك الصندوق Navigator |
9 |
Okta.openDocument(path, fileName) |
تنزيل مسار API وتسليمه لتطبيق يفتحه. false جواب عادي |
11 |
Okta.toolKeyStart/toolKeyStop/toolKeyReads/toolKeyChannels |
قرّاء مفاتيح الأدوات (NFC/بلوتوث/سلكي/بوابة LAN) — سحب لا بثّ | 12 |
Okta.hasCapability(name) · Okta.requestCapability(name) |
مُتقاعدة — البوّابة خلفهما أُزيلت، وتُجيبان true دائماً على 19 فأعلى. أُبقيتا كي تبقى التطبيقات المنشورة تُترجَم. |
13 |
Okta.remoteImage(path, size) |
صورة يجلبها المضيف من مسار API ويرسمها. المسار لا الرابط | 15 |
Okta.cameraScanStart/cameraScanStop/cameraScanReads · Okta.cameraPreview(size) |
ماسح مدمج داخل تخطيطك — سحب كقرّاء المفاتيح | 15 |
Okta.studentHashid() · Okta.portal() |
الطالب الذي رُكِّب عليه تبويبك، والبوّابة (student/guardian) — '' على غير ذلك السطح |
17 |
OktaTabs.floatingBar وأخواتها (في okta_kit) |
القائمة السفلية العائمة بهوية أوكتا — ودجت جاهزة، لا نداء مضيف | 18 |
Okta.toolKeyFingerprint(key) |
بصمة البطاقة كما ينشرها كشف الطلاب — لمطابقة مسح بلا شبكة | 20 |
Okta.onMessage(handler) |
تسجيل مُعالِج الرسائل الفورية — واحد، والنداء الثاني يستبدل. لا إلغاء اشتراك: المضيف يغلق المقبس عند الإزالة. راجع «الرسائل الفورية» | 22 |
Okta.playAudio(url) · Okta.stopAudio() |
تشغيل ملف صوتي من رابط http(s) مطلق يخدمه خادمك أنت، وإسكات ما يُسمَع. النداء الثاني يستبدل ما يُسمَع، وstopAudio تُسكت المشغّل والنطق معاً. راجع «النداء المسموع» |
23 |
Okta.speak(text) · Okta.canSpeak() |
نطق نصّ بصوت الجهاز، والاستطلاع قبله. اسأل canSpeak قبل أن تعرض «النداء بالاسم»: أجهزة كثيرة بلا صوت عربي، وfalse جواب سليم لا عطل |
23 |
Okta.canRecord() |
هل يستطيع هذا الجهاز التسجيل أصلاً؟ اسألها قبل أن تعرض زرّ التسجيل — شاشة الفصل بلا مايك، والهاتف قد يكون إذنه مرفوضاً نهائياً. لا تطلب الإذن، فآمنٌ نداؤها أثناء الرسم | 24 |
Okta.recordStart() · Okta.recordStop() · Okta.recordCancel() |
تسجيل واحد في كل وقت. recordStop تعيد مقبضاً يسمّي التسجيل (String?)، والبايتات لا تعبر الجسر. راجع «المايك ورفع الملفات» |
24 |
Okta.playRecording(handle) · Okta.uploadRecording(path, handle) |
أسمِع صاحبه ما سجّله ثم ارفعه. المقبض المتقاعد يُرفَض ولا يُستبدَل به غيره | 24 |
Okta.uploadFileOfKind(path, kind) |
اختيار ورفع، وkind من مفردات مغلقة: image · audio · video · document · any. uploadFile القديمة لم تتغيّر |
24 |
OktaFocus.item(child, onSelect) · OktaFocus.first(...) |
من package:okta_focus: تجعل أي عنصر رسمتَه هدفاً لمفاتيح الاتجاه، وOK يُشغّل onSelect. وfirst هي التي تحمل التركيز عند أول رسم. راجع «دعم مفاتيح الاتجاه» |
25 |
OktaFocus.watched(child, onSelect, onFocusChange) · watchedFirst(...) |
نفسها، وتُخبرك بدخول التركيز وخروجه لترسم تمييزك فوق الحلقة لا بدلاً منها | 25 |
OktaFocus.reachable(child) |
يصله السهم ولا شيء يُضغَط — لصفٍّ ينبغي أن تمرّ الحلقة خلاله | 25 |
التطبيق المصغّر لا يملك أي صلاحية dart_eval: لا شبكة ولا نظام ملفات
إلا عبر هذه النداءات.
صيغة مفتاح البطاقة — واحدة، أياً كان النداء الذي أعادها
Okta.scanNfc() وOkta.toolKeyReads() تُعيدان المفتاح في الصيغة القانونية
نفسها التي يخزّنها okta-web: حروف صغيرة، بلا فواصل (: أو - أو مسافات)،
وبلا محارف تحكّم — فقارئ يرسل AA:BB:CC وآخر يرسل aabbcc يصلانك aabbcc.
قارِن مباشرةً. لا تكتب .toUpperCase() ولا .toLowerCase() قبل المقارنة:
لن تضرّ لأن التطبيع مُنتِج لنفسه، لكنها تُخفي أي انحراف مستقبلي بدل أن
تكشفه، وتجعل شفرتك تبدو كأنها تعرف شيئاً عن الصيغة لا تعرفه.
إن كان تطبيقك يحمل التفافاً على هذا فاحذفه. كان
Okta.scanNfc()يعيد حروفاً كبيرة غير مطبَّعة بينما مسار مفاتيح الأدوات يعيد الصيغة القانونية، فبطاقة تُعرَّف في شاشة الأدوات لا تطابق نفسها حين تُقرأ عبرscanNfc. لم يكن يظهر كعطل: المسح ينجح والبحث يخيب، فيبلّغ التطبيق أن الشخص غير مسجَّل. صُحّح في المضيف، والسطحان متطابقان الآن.
وينطبق الأمر نفسه على جسر WebView: حدث nfc صار يحمل الصيغة القانونية بعد
أن كان يحمل حروفاً كبيرة.
الماسح المدمج: cameraScanStart تُسلّح، وcameraPreview تفتح
Okta.cameraScanStart() تُجهّز الكاميرا ولا تفتحها. العدسة تُفتح حين
تُركِّب Okta.cameraPreview(size) في شجرتك، والمعاينة ليست عرضاً اختيارياً بل
هي مصدر القراءات: تطبيق يُسلّح الماسح ولا يركّب المعاينة لا يحصل على شيء
من cameraScanReads() أبداً.
فالترتيب:
cameraScanStart() → ركِّب cameraPreview(size) → cameraScanReads() في حلقة سحب
و**true تعني «مُسلَّح» لا «مفتوح»**: ما يُحسَم قبل عودتها هو إذن الكاميرا من
نظام التشغيل — وهو ما تعنيه false. ما بعد ذلك يقرّره تركيب المعاينة.
هذه النقطة تغيّرت في المضيف. كانت
cameraScanStartتحاول فتح العدسة بنفسها، وكانت تفشل دائماً على كل جهاز: الحزمة ترفض التشغيل قبل بناء ودجت المعاينة، والمعاينة لم تكن تُبنى إلا بعد نجاح التشغيل — حلقة مغلقة جعلت الماسح المدمج يُجيبfalseمنذ العقد 15. إن كنت جرّبته وتركته، جرّبه ثانيةً بالترتيب أعلاه.
بوّابة تعمل بلا شبكة — كشف الطلاب والبصمة (عقد 20)
Okta.toolKeyResolve عبر GET /tool-keys/resolve يسأل المنصّة «لمن هذه
البطاقة؟» — ويحتاج شبكة. وعند بوّابة مدرسة في السابعة صباحاً، اللحظة التي
تحتاج فيها جواباً هي اللحظة التي تنقطع فيها الشبكة.
فالكشف يُنزَّل مسبقاً:
GET /api/apps/tool-keys/students النطاق: tool_keys.links.read
{
"students": [
{
"student_id": "01J2X…",
"full_name": "…",
"grade_id": "01J2A…",
"section_id": "01J2B…",
"fingerprints": ["9f2c1d…", "4b70aa…"]
}
]
}
الصفوف تحمل بصمات لا مفاتيح، وهذا ليس تقييداً بل هو ما جعل الكشف ممكناً. مفتاح NFC هو UID البطاقة، فكشفٌ بمفاتيح خام هو وسيلة كتابة بطاقة تفتح البوّابة باسم أي طالب فيه. البصمة تُطابِق ولا تُصنِّع.
ولذلك لا تقارن مسحك بالكشف مباشرةً — مرّره أولاً:
final reads = await Okta.toolKeyReads();
for (final read in reads) {
final fp = await Okta.toolKeyFingerprint('${read['key']}');
if (fp == '') {
// لم يستطع البصم — لا مفتاح بصم مخزَّن ولا شبكة لجلبه.
// هذه ليست «بطاقة غير مسجَّلة»، فلا تعاملها كذلك.
continue;
}
final student = rosterByFingerprint[fp]; // كشفك المنزَّل
…
}
ثلاث خصائص تعتمد عليها:
- البصمة ثابتة لبطاقة واحدة في مدرسة واحدة — نفس القيمة في كل مرّة، وسواء أنتج المفتاحَ قارئ NFC أو UHF.
- ومختلفة بين مدرستين للبطاقة الفيزيائية نفسها. فلا تُخزّن بصمة من جهة وتقارنها في أخرى.
- مفتاح البصم لا يصل تطبيقك أبداً. المضيف يحمله ويبصم نيابةً عنك. ولو وصلك لأمكن توليد كل UID من أربعة بايتات وإعادة الكشف إلى بطاقات خام — وهو ما تمنعه البصمة أصلاً.
السلسلة الفارغة ليست «غير مسجَّل».
toolKeyFingerprintتُجيب''حين تعذّر البصم — لا مفتاح بصم بعد، أو جلسة بوّابة بلا جهة. وبصمة فارغة تُقارَن بالكشف فلا تطابق أحداً، فتظهر عند البوّابة كطالب غير مسجَّل. افحصها صراحةً وأظهر للمشغّل أن الجهاز لم يستطع التحقّق، لا أن الطالب مرفوض.
والطالب بلا بطاقة يظهر في الكشف بقائمة فارغة ولا يُحذف منه: «لم يُسجَّل بعد» و«ليس في هذه المدرسة» جوابان مختلفان، وكشف حاملي البطاقات وحدهم يجيب عنهما بصمت واحد.
وأعلِن minContract: 20 إن استعملت toolKeyFingerprint. الدالة رمز
جديد في مكتبة محقونة، والتطبيق يُترجَم على الجهاز — فنداؤها على مضيف أقدم
يموت بـCannot find static method مسمّياً ملفاً لم تكتبه. الكشف نفسه نداء
HTTP عادي ولا يحتاج حدّ عقد.
ملاحظة للتطبيقات القائمة: هذا الكشف تحت النطاق
tool_keys.links.readنفسه الذي يحمله تطبيقك إن كان يستعملresolve. لا نطاق جديد ولا إعادة موافقة من الجهات — لكن معناه اتّسع: كان «تأكَّد من بطاقة سُلّمت لك»، وصار يشمل «نزّل قائمة من يحمل بطاقة».
نداءٌ فوق عقد الجهاز لا يفشل — بل يُفسد الجلسة
كل صفّ عليه عقد غامق أعلاه مبنيّ على دالة خارجية يسجّلها المضيف. على مضيف أقدم لا تكون مسجَّلة، واستدعاؤها لا يُرجع خطأً بل يُتلف المفسّر لبقية الجلسة: ما يلي من نداءات يفشل بلا رابط بالسبب، فتطارد عطباً في شفرة سليمة.
جوابان، اختر واحداً لكل نداء:
- أعلِن
minContractفي بيانك مساوياً لأعلى رقم تستعمله، فيعرض تطبيق أوكتا «حدّث التطبيق» بدل تشغيلك على مضيف لا يفهمك؛ أو- احرس عند الاستدعاء:
if (Okta.contract() >= 11) { … }وقدّم بديلاً.والقياسية و
get/postوtoastوscan*بلا حراسة — كانت موجودة من العقد 1.
القائمة السفلية العائمة — OktaTabs.floatingBar (عقد 18)
قائمة أوكتا السفلية نفسها، داخل تطبيقك المصغّر: شريط مستدير منفصل عن حواف الشاشة يطفو فوق المحتوى — نفس الهيئة التي يتنقّل بها تطبيق أوكتا. مجرد استعمال الودجت يكفي:
final labels = <String>['الرئيسية', 'التحضير', 'التقارير'];
final icons = <IconData>[OktaIcons.home(), OktaIcons.list(), OktaIcons.chart()];
Widget main() {
final palette = OktaPalette.of(Okta.isDark());
return OktaTabs.floatingOver(
content, // صفحتك
OktaTabs.floatingBar(labels, icons, _index, _onSelect, palette),
);
}
floatingOver يضع الشريط طافياً أسفل المحتوى (Scaffold هنا بلا خانة
bottomNavigationBar، فالتركيب Stack — وهذه الدالة هي ذلك الـ Stack كي لا
يعيد كل تطبيق بناءه). أعطِ الجزء المتمرّر حشوة سفلية OktaTabs.floatingReserve()
كي لا يختبئ آخر صفّ تحت الشريط.
التخصيص طبقات — بنفس نمط الصنف كله (دوال منفصلة، لا معاملات اختيارية — dart_eval 0.8.5 ينهار على معامل اختياري محذوف لدالة ساكنة في مكتبة محقونة):
| الدالة | ما تتحكم فيه |
|---|---|
floatingBar(labels, icons, selectedIndex, onSelect, palette) |
الهوية الافتراضية من الباليت |
floatingBarWithDots(… , dots, …) |
+ نقطة غير-مقروء على أي تبويب علمه true (القائمة تقصر بأمان) |
floatingBarTinted(… , background, ring, activePill, activeInk, idleInk) |
كل لون بيدك |
مرّر الألوان كسائنات Color مبنية من قيم حرفية عند موضع النداء
(const Color(0xFF6D428F)): كائن Color يعبر المعاملات المفسَّرة سليماً،
أمّا رقم int خام يُمرَّر ثم يُطعَم لمُنشئ مجسور فيصل مغلَّفاً مرتين ويموت
بخطأ cast.
صلب لا زجاجي، عمداً: BackdropFilter غير مجسور وBoxShadow غير معلَن على هذا المشغّل، فعمق الشريط حلقة بكسل واحد بلون الحدود — هندسة لا Border (المجسر يعلن المعامل BoxBorder ولا يعلن Border نوعاً فرعياً له).
العقد 18. استدعاء هذه العائلة على مضيف 17 يموت داخل
okta_kitبرسالةCannot find static methodتسمّي ملفاً لم تكتبه. أعلِنminContract: 18أو احرس بـOkta.contract() >= 18وقدّمbottomBarالقديمة بديلاً.
flutter analyzeسيشتكي أنOktaوpackage:okta_hostغير معرّفين — هذا متوقَّع. المكتبة تُحقَن من المحرّك وقت الترجمة وليست حزمة pub حقيقية. البوابة المعتمَدة هيflutter test tool/validate.dart.
شكل ناتج Okta.get / Okta.post — وكيف تستهلكه
كل نداءات HTTP تعيد OktaApiResponse بثلاثة حقول وخاصية محسوبة واحدة:
| الحقل | النوع | ماذا يحمل |
|---|---|---|
status |
int |
كود HTTP — و**0 إن فشل النداء قبل أن يصل خادماً** (شبكة، أو مسار مرفوض من قائمة السماح) |
body |
dynamic |
جسم JSON مفكوكاً — أو نصّاً خاماً إن لم يكن JSON |
error |
dynamic |
وصف عطل النقل/الصلاحية، وnull إن لم يقع عطل |
ok |
bool (محسوبة) |
error == null و status بين 200 و299 |
فحصُ ok وحده يكفي للمسار السعيد: يجمع «لا عطل نقل» و«حالة ناجحة» معاً،
فلا حاجة لفحص status >= 200 && status < 300 بنفسك.
final res = await Okta.get('/api/my-app/students');
if (!res.ok) {
// status == 0 يعني أن النداء لم يصل خادماً أصلاً — لا تعرض «خطأ من
// الخادم» عندها، فالخادم لم يقل شيئاً.
Okta.toast(res.status == 0 ? 'تعذّر الاتصال' : 'الخادم ردّ ${res.status}');
return;
}
// body ديناميكي — أبقِه كذلك. راجع البند أدناه.
final dynamic payload = res.body;
final dynamic rows = payload['data'];
if (rows is List) {
for (final dynamic row in rows) {
final dynamic name = row['full_name'];
// …
}
}
الفهرسة أعلاه تفترض أن نقطتك تُعيد كائن JSON — وهي نقطتك أنت، فالافتراض في يدك. أما إن ردّت نصّاً غير JSON فيصلك
bodyنصّاً خاماً، وفهرسته بمفتاح نصّي تفشل وقت التشغيل. ولا يمكن الاحتراز بـif (body is Map): ذلك الحارس يُرقّي المستقبِل فيوقعك في فخّ الفهرسة نفسه.
body من نوع dynamic عمداً — وهذا ليس تفصيلاً. إن «رتّبته» بإعطائه
نوع Map صريحاً:
final Map data = res.body; // ← لا تفعل
final rows = data['items']; // Cannot use variable of type String as index
// to map of type <int, Color>
فأنت تقع في فخّ فهرسة Map الموصوف في قائمة dart_eval أعلاه، وقت
الترجمة. الحقل يصلك dynamic من المضيف لهذا السبب بالذات: تركُه على حاله
هو المسار الصحيح، لا الكسل.
error مقابل status: error يخصّ ما دون HTTP — انقطاع الشبكة، أو مسار
رفضته قائمة السماح فلم يُرسَل أصلاً. أما ردّ الخادم بـ 4xx/5xx فيصل بـ
error == null وstatus يحمل الكود. كلاهما يجعل ok تساوي false، لكنهما
عطلان مختلفان للمستخدم: الأول «لا اتصال»، والثاني «الخادم رفض».
Okta.uploadFile ليست كذلك — تعيد خريطة مفكوكة، لا صنفاً. اقرأها
بالمفاتيح ['status'] و['body'] و['file_name'] و['error'] (لاحظ
file_name بالشرطة السفلية)، و**null إن ألغى المستخدم الاختيار** فافحص
العدم قبل المفاتيح. لا .ok هنا ولا .fileName.
ولماذا تختلف عن
Okta.get؟ لأنOktaApiResponseصنفٌ يذكرهOkta.apiنفسه، وكل تطبيق يناديapiفيَحلّ الصنف. أمّا المكتبة المحقونة فلا يجوز أن تذكر صنفاً لا يستدعيه تطبيقك: dart_eval لا يَحلّ مثل هذا الصنف إلا حين يسحبه المصدر، فلتطبيق لا يرفع ملفاً يكون الاسم غير موجود — والانهيار يقع على المكتبة المحقونة، مسمّياً ملفاً لم يكتبه الشريك:CompileError: Unknown type OktaUploadResult. ولهذا صارتuploadFileوcontextوlocationوpreciseLocationكلّها تعيد خرائط.
final dynamic up = await Okta.uploadFile('/api/my-app/attachments');
if (up == null) return; // ألغى الاختيار
final dynamic status = up['status'];
if (status is int && status >= 200 && status < 300) {
Okta.toast('رُفع ${up['file_name']}');
}
التحقق محلياً — نفس بوابة الـ CI
cd okta_app/native/main
flutter pub get
flutter test tool/validate.dart
هذا يترجم كودك أمام محرّك الجهاز نفسه، فأي شيء خارج المدعوم يسقط في الـ CI لا على هاتف المستخدم.
ولهذا pub get نفسه قد يرفض قبل أن يصل أي شيء إلى CI. pubspec.yaml
يحمل حدّاً على إصدار Dart (وليس Flutter — راجع تعليق environment: في
الملف نفسه؛ حدّ Flutter لا يفرضه pub على المشروع الجذر، مُختبَراً لا
مفترضاً) لأن okta_miniapp يكتب class $Container implements Container
بيده، وFlutter 3.41 أضاف عضواً (Container.isAntiAlias) الحزمة المنشورة
على pub.dev لا تعرفه. Flutter أحدث من ذلك يرفضه pub get بسطر واحد يسمّي
الإصدار المطلوب — لا وهماً بنجاح ثم انهياراً لاحقاً في الترجمة.
مجموعة dart_eval المدعومة — اقرأ هذا قبل أن تكتب
محرّك الجهاز هو dart_eval + flutter_eval، ويدعم جزءاً من
Dart/Flutter. القائمة أدناه ليست أسلوباً مفضّلاً — كل بند منها يكسر:
-
نداءات المضيف عبر
Okta.*الساكنة فقط — لا دوال مضيف عُلوية. -
لا
State.mounted— غير مجسورة. نادِsetStateمباشرة. -
الـ callbacks تُكتب كإغلاقات لا كإحالات دوال:
onPressed: () => _doThing()لاonPressed: _doThing(خطأ «Cannot box Function»). -
لا تمرّر
cond ? null : closureلفتحة callback. مرّر() => …غير مشروطة واحرس داخل الدالة (if (_busy) return;). -
فهرِس JSON على مستقبِل
dynamicلا على مستقبِل نوعهMap.body['key']يعمل ما دامbodyمن نوعdynamic. إن أعطيت المستقبِل نوعMapالخام — بحارسis Mapيُرقّيه، أو بتعليق نوع على متغيّر أو معامل أو حقل — يتحوّلoperator[]إلى تحليل ساكن، فيربط dart_eval الـMapالخام بتنصيب مجسور اعتباطي (Map<int, Color>— شكل لوحة MaterialColor) ويرفض مفتاحك النصّي وقت الترجمة:Cannot use variable of type String as index to map of type <int, Color>. الـ<int, Color>لا تأتي من بياناتك إطلاقاً — لا تبحث عنها. أبقِ الـ JSONdynamicمن طرف لطرف (final dynamic v = body['key'];)، واحرس على النتيجة (v is List) لا على المستقبِل الذي ستفهرسه. ترقيةis Listسليمة — المشكلة فيMapوحدها. -
لا حلقات متداخلة. يرمي dart_eval 0.8.5 خطأ
RangeErrorوقت الترجمة حين يحوي جسم الحلقة — مباشرةً أو عبر دالة يستدعيها — حلقةً أخرى. سطّح البيانات الهرمية في قائمة واحدة (على الخادم يفضَّل) ثم مُرّ عليها بحلقة واحدة ببانٍ صفوف بلا حلقات. -
الأزرار:
ElevatedButtonوTextButtonفقط —FilledButtonوOutlinedButtonغير مجسورين. -
التخطيط:
RowوColumn(+Expanded).WrapوAlignmentDirectionalغير مجسورين — رتّب الشبكات بـRow+Expanded، واستخدمDirectionalityلاAlign+AlignmentDirectionalللـ RTL. -
مرّر
flex:دائماً لـExpanded/Flexible. الجسر لا يطبّق قيمة المعامل الافتراضية، فالإغفال يصلnullويُحوَّل إلىint—type 'Null' is not a subtype of type 'int'أثناء بناء الودجت. اكتبExpanded(flex: 1, child: …). والقاعدة نفسها لأي معامل اختياري نوعهintغير قابل للعدم في ودجت مجسور. -
BoxDecorationيحملcolorوboxShadowفقط.borderRadiusوgradientوimageوshapeموجودة في الجسر معلَّقة، والمعامل الذي لا يعلنه الجسر يُسقَط بصمت لا يُرفَض: يُترجَم نظيفاً والقيمة لا تصل Flutter أبداً. فـBoxDecoration(borderRadius: …)يرسم زوايا قائمة بلا أي خطأ، وvalidate.dartلا يمسكها لأن شيئاً لم يفشل. دوّر الزوايا بـClipRRect(borderRadius: …, clipBehavior: Clip.antiAlias, child: ColoredBox(…)). -
BoxDecoration(border: …)لا يُترجَم أصلاً. المعامل مُعلَن بنوعBoxBorder، لكن الجسر لا يعلنBorderنوعاً فرعياً منه، والمترجم لا يعرف إلا ما يعلنه الجسر. فـBorder.all(...)يفشل بـCannot assign argument of type Border to parameter of type BoxBorder. ارسم الخط الشعري كصندوق بارتفاع 1:Container(height: 1.0, color: …).الأخيرتان فخٌّ واحد من طرفين: معامل لا يعلنه الجسر يُسقَط بلا كلمة، ومعامل يعلنه يُفحَص نوعه بلا رحمة.
-
لا ثوابت
Icons.*اعتباطية — استخدم نصوصاً بدلها. -
فضّل أشكال
Map/Listالبسيطة على الـ generics العميقة عند عبور الجسر.
متى يصل تعديلك إلى الجهاز — ومتى يبقى القديم
تطبيق أوكتا يترجم المصدر على الجهاز مرة واحدة ثم يخزّنه مؤقتاً. والمفتاح ليس محتوى الحزمة — وهذا هو بيت القصيد. مكوّناته أربعة:
| المكوّن | يتحرّك حين |
|---|---|
slug |
تطبيق آخر |
entry |
حزمة دخول أخرى تحت الـ slug نفسه (انظر «عدّة حزم دخول» أدناه) |
payloadVersion |
يتحرّك updated_at لصفّ المودول — وكل تثبيت يحمل commit جديداً يحرّكه |
runtimeSignature |
تتغيّر مكتبة محقونة في المضيف، أي مع إصدار جديد من تطبيق أوكتا |
على sandbox: ادفع ثم أعد التثبيت — لا ترفع إصداراً
لا تحتاج إصداراً جديداً لكل تعديل. دورة التطوير هي:
- ادفع كودك إلى فرع الإصدار على مستودعك؛
- أعد تثبيت الإصدار نفسه على sandbox.
التثبيت يعيد حلّ commit الفرع في كل مرّة (خطوة commit_resolve)، فيصل
okta-web commit مختلفاً، فيتّسخ صفّ المودول، فيتحرّك updated_at، فيتغيّر
payloadVersion، فيسقط كاش الجهاز ويُعيد الترجمة. وinstall_on_sandbox يقبل
أي إصدار قائم لا الإصدارات القابلة للتحرير وحدها.
ومتى يبقى القديم فعلاً
حين لا يتحرّك أيٌّ من الأربعة. والحالة العملية الوحيدة: أعدت التثبيت بلا أن تدفع — نفس الـ commit، فلا شيء يتّسخ، فلا يُعاد سحب المصدر ولا تُعاد الترجمة. وهذا صحيح لا عطل: لا جديد ليصل.
هذا يُضلِّل أكثر مما يُعطِّل. لا رسالة ولا تحذير — تفتح التطبيق فترى السلوك القديم تماماً فتستنتج أن إصلاحك لم ينفع. فأول ما تتحقّق منه: هل وصل دفعك إلى الفرع الذي يشير إليه الإصدار؟ لا الكود.
الحزمة تُبنى مضغوطةً عند التثبيت — وتُعاد إذا تقادمت
منذ تفعيل الضغط، لا تُبنى الحزمة عند كل إطلاق. okta-web يبنيها مرّة واحدة عند التثبيت لكل نقطة دخول native، يضغطها gzip، ويخزّنها؛ فالإطلاق يقدّم ملفاً بدل أن يمشي شجرتك من جديد. وتطبيق أوكتا ينزّله ويفكّه ويشغّله.
والملف لم يعد مجمَّداً. كان الأرشيف يحمل min_contract وcapabilities
كما كانت لحظة التثبيت ولا يتحرّك حتى تعيد التثبيت — وعاش الشركاء بسببه
اليوم الأسوأ في التصحيح: تنشر تعديلاً، يرتفع رقم النسخة على كل جهاز، ويبقى
الكود القديم يعمل خلفه بمظهر محدَّث تماماً. الآن يقارن okta-web عمر الملف،
عند كل إطلاق، بعمر الشجرة القادر فعلاً على تقديمها: متى هبط كودك الجديد
على المنصة أُعيد البناء مرّة واحدة ثم قُدِّم الجديد. وما دام تنزيل كود النشر
في الطريق (أو فشل)، تبقى الأجهزة تُعرَض عليها النسخة السابقة — رقمها وملفها
معاً — لا رقماً جديداً يلفّ كوداً قديماً.
فما يظهر في البوابة هو ما يصل الأجهزة عند فتحها التالي — بلا إعادة تثبيت يدوية لأجل بيانٍ تحرّك.
دورة عملك المعتادة كما هي: ادفع ثم أعد التثبيت حين تتغيّر الملفات (سحب المصدر ما زال فعل التثبيت)؛ أمّا تعديل البيان وحده فيصل من تلقاء نفسه.
وعلى الإنتاج
الجهات تستقبل ما يُنشَر. فهناك الإصدار الجديد هو الآلية — لا لأن الكاش يعمل بشكل مختلف، بل لأن لا أحد يعيد التثبيت نيابةً عنها.
والنشر خطوتان تحت السطح: المنصة تسجّل إصدارك ثم تنزّل كودك بتوكن عمره ساعة. الأجهزة لا تُخبَر إلا بالشجرة التي هبطت فعلاً — والتنزيل المتعثّر لم يعد يبقى متعثّراً: okta-web يعيد المحاولة تلقائياً بتوكن يُسَكّ من جديد، من أول إطلاق جهاز يصادف العطب ومن جولة مجدولة كل عشر دقائق. فإن قالت البوابة 1.4.0 وما زالت الهواتف تعرض 1.3.0 دقائقَ بعد النشر فهذه أمانة النظام لا ضياع تحديثك؛ وإن طالت، افتح حالة التطبيق — فيها خطأ السحب نصاً.
وبالمقابل: تحديث تطبيق أوكتا نفسه يحرّك
runtimeSignatureفيُعيد الترجمة على كل جهاز بلا أن تنشر أنت شيئاً.
min_contract — أرضية عقد المضيف
عقد المضيف مُرقَّم. أعلِن الأرضية التي يعتمد عليها إصدارك، فيعرض تطبيق أوكتا «حدّث التطبيق» بدل تشغيل تطبيق مصغّر لا يفهمه الجهاز.
- أعلن الأرضية التي تحتاجها فعلاً قدرات المضيف التي تناديها. (قاعدة «≥ 13 لأنك أعلنت قدرة جهاز» ذهبت مع البوّابة.)
- ينطبق على
nativeوحده؛ المحرّر يفرّغه في أي وضع آخر.
الـ pubspec.yaml — لا ترفع الـ ref
okta_miniapp:
git:
url: https://github.com/TahdirIT/okta-miniapp.git
ref: __MINIAPP_REF__ ← تملؤه المنصة عند إنشاء المستودع
الـ ref مثبَّت على المحرّك الذي بُني به تطبيق أوكتا المنشور بالضبط، فما يُترجَم عندك يُترجَم مطابقاً على الجهاز. رفعُه يدوياً يكسر هذا الضمان.
شكل الـ manifest
"mobile": {
"supported": true,
"mode": "native",
"runtime": "dart",
"entry": "okta_app/native/main/lib/main.dart",
"minContract": 12,
"audiences": [
{ "key": "teacher", "mode": "native",
"entry": "okta_app/native/main/lib/main.dart" }
]
}
runtime: "dart" تُضاف تلقائياً في وضع native — لا تكتبها بنفسك.
عدّة حزم دخول تحت slug واحد
<entry> في okta_app/native/<entry>/ هو اسم حزمة Dart قائمة بذاتها، ولا
شيء يُلزمك بواحدة. جمهوران مختلفان يجوز أن يشيرا إلى حزمتين مختلفتين:
"audiences": [
{ "key": "staff", "mode": "native",
"entry": "okta_app/native/staff_app/lib/main.dart" },
{ "key": "family", "mode": "native", "portal": "guardian",
"entry": "okta_app/native/family_app/lib/main.dart" }
]
تُجمَع كل حزمة وتُخزَّن على حدة. حزمة المصدر تحمل شجرة lib/ للمدخل
المطلوب وحده — لا تُضمّ الحزم الشقيقة إليها إطلاقاً — ومفتاح الكاش يحمل
الـ entry بجانب الـ slug لهذا السبب بالذات. (لو لم يحمله لتصادمت الحزمتان:
الـ slug واحد وpayloadVersion واحد، فمن يُطلَق أولاً يكسب المدخل ويُشغَّل
الآخر ببايتكود الأول ويموت بـ Cannot find package:….)
فإن كانت واجهة الموظّف وواجهة وليّ الأمر مختلفتين جوهرياً، افصلهما حزمتين —
أنظف من if كبير، وكل واحدة تُترجَم وتُخزَّن وحدها.
حين لا يفتح التطبيق أصلاً — عائلة أخطاء الحمولة
البطاقة تظهر، تنقر، فيفشل التحميل قبل أن يعمل أي من كودك. هذا ليس خطأً في تطبيقك المصغّر: نقطة الإطلاق نجحت (فالتثبيت والنطاق والجمهور سليمة) ثم فشل تنزيل حزمة المصدر.
يُفرَض شكل الـ entry ثلاث مرّات لا مرّتين: عند الحفظ في محرّر الإصدار،
وعند النشر في okta-web، ومرّة ثالثة عند تقديم الحزمة — وهذه الثالثة هي
التي تراها كفشل تحميل. أسبابها الممكنة كلّها من جهة الخادم:
| السبب | ماذا يعني |
|---|---|
| المودول غير منشور ككود على هذه النسخة | الصفّ في قاعدة البيانات موجود والشيفرة ليست على القرص |
okta_app/native/<entry>/lib غير موجودة في المودول المنشور |
نُشر المودول بلا شجرة native |
ملف الدخول غير موجود تحت تلك الـ lib/ |
الـ entry يشير إلى ملف لا وجود له |
الـ entry لا يطابق okta_app/native/<entry>/lib/**.dart |
إصدار قديم موروث من قبل بوّابة الشكل |
| الحزمة > 10 ميغابايت، أو > 128 ملفاً | تجاوزت حدود التجميع |
لا حدّ لحجم الملف الواحد. كان 256 كيلوبايت وأُلغي: الترجمة والنقل لا يريان إلا الإجمالي، فتقسيم ملف لم يكن يخدم شيئاً. والحدّ الباقي هو 10 ميغابايت للمشروع كاملاً و128 ملفاً، ورسالة التجاوز تسمّي الملف الذي كان يُقرأ حين نفدت الميزانية.
حدّ الـ10 ميغابايت يُصاب عادةً ببيانات لا بمنطق. مشروع Dart يبلغ هذا الحجم شبه دائماً يحمل ملفات مولَّدة: صور بترميز base64، أو خرائط ثوابت ضخمة، أو ترجمات مولَّدة. أخرج البيانات من Dart إلى نقطة نهاية تقرأها بـ
Okta.get— فما يصل من الشبكة لا يُترجَم على الجهاز، بينما كل بايت في حزمتك يُترجَم. وهذا ليس عن النقل: الحزمة تُنقل مضغوطة، لكن الترجمة على الجهاز تقع على المصدر كاملاً، وتقع على الـ isolate الذي يرسم الواجهة.
ما تفعله: أبلغ فريق المنصّة بـ slug التطبيق ووقت المحاولة. الخادم يسجّل
سبب الرفض بالضبط، والتطبيق يعرضه نصّاً على الشاشة في البيئات غير الإنتاجية —
فالإجابة موجودة عند أحد الطرفين ولا تحتاج تخميناً. وأول ما تتحقّق منه أنت: أن
مسار entry في بيانك يطابق مساراً موجوداً فعلاً في مستودعك، حرفاً بحرف.
صلاحيات موارد الجهاز — مُزالة، تُبنى من جديد
كان هنا محور صلاحيات ثانٍ: mobile.capabilities[] في البيان، ومفردات مغلقة
بإحدى عشرة قدرة، وفحص عند النشر، وبوّابة داخل تطبيق أوكتا ترفض أي نداء لم
يُعلنه التطبيق ولم يوافق عليه صاحب الجهاز. أُزيل هذا كلّه — المفردات
والبوّابة وسجلّ الموافقة وشاشة الأذونات ومرشّح قنوات القارئات — ويُعاد تصميمه
من الصفر بدل ترقيعه.
ما يعنيه ذلك لك الآن:
- لا تُعلن
mobile.capabilitiesفي بيان جديد. المفتاح ما زال مقبولاً كي لا يفشل نشرٌ تالٍ لأي تطبيق منشور، لكن لا أحد يقرؤه: لا يُطبَّع، ولا يُحمل إلى الجهاز، ولا يظهر في شاشة التثبيت. - كل نداء عتاد يعمل مباشرة.
Okta.scanNfc()وOkta.scanBarcode()وOkta.toolKeyStart()وOkta.location()وOkta.preciseLocation()وOkta.uploadFile()وOkta.openDocument()والتخزين — بلا إعلان وبلا أي سؤال من المنصّة. - نظام التشغيل يبقى يسأل. أندرويد و iOS يعرضان نافذتهما عند أول استخدام للكاميرا أو الموقع أو NFC، ويديرها صاحب الجهاز من إعدادات جهازه. هذه لم تكن يوماً من شأن هذه البوّابة.
Okta.hasCapability()وOkta.requestCapability()ما زالتا تُترجمان وتُجيبانtrueدائماً. أُبقيتا لأنهما سطح ترجمة — تطبيق منشور يناديهما كان سيفشل في الترجمة على الجهاز لو اختفى الرمز — فحراسك القديمة تعمل وتأخذ فرع «مسموح» دائماً. لا تكتب حراساً جديدة عليهما؛ البديل سيعيد استعمال الاسمين نفسيهما.- لم تعد هناك قاعدة «
minContract≥ 13». أعلنminContractلما تعتمد عليه فعلاً من قدرات المضيف، لا أكثر. - الرفض ما زال ليس استثناءً. قد يُجيب نداء العتاد
nullأوfalseأو دقّة سالبة — عتاد غائب، أو نافذة نظام رفضها المستخدم، أو قارئ لم يُقترن. تابع فحص القيم المُعادة؛ هذا الانضباط لم يكن يوماً معتمداً على البوّابة.
ولهذا انتقل عقد المضيف إلى 19. جهاز ما زال على 18 أو أقل يفرض البوّابة القديمة، وتطبيق كُتب للعالم غير المحروس يُرفض هناك بصمت —
scanNfcتُجيب null بلا أثر في أي سجل — لا بخطأ ترجمة يمكن رؤيته.
تبويبك في ملف الطالب (mobile.student_profile_tabs) — وضع native فقط
وليّ الأمر يفتح تطبيق أوكتا، فيرى أبناءه، فيضغط على ابنٍ منهم — وهنا تصير الشاشة ملف الطالب. هذا التصريح يعطيك تبويباً خاصاً بك داخل تلك الشاشة: أنت تملك جسده كاملاً، معلوماتك وعملياتك وتصميمك، لا بطاقة بقالبٍ تملأه.
"mobile": {
"supported": true,
"mode": "native",
"minContract": 17,
"student_profile_tabs": [
{
"key": "attendance",
"title": { "ar": "الحضور", "en": "Attendance" },
"entry": "okta_app/native/student_tab/lib/main.dart",
"portals": ["guardian"],
"order": 10
}
]
}
ليس هذا بلوك studentProfile. ذاك يضيف بطاقاتٍ وأرقاماً وأزراراً إلى
صفحة ملف الطالب في okta-web، يقرأها الموظّف، ويُكتشَف من أصناف
Livewire في مستودعك. وهذا تطبيق مصغّر native يُركَّب في هاتف وليّ
الأمر. جمهوران، ومضيفان، وزمنا تشغيل — فلا يجمعهما مصفوفة واحدة. اقرأ
توسيع ملف الطالب لذاك.
وليس نوع حساب. mobile.audiences[] تجيب مَن يفتح تطبيقك، وهذه تجيب
أين يُركَّب سطحك. لو جُمعا لصار «guardian» يعني شاشتين مختلفتين بحسب حقل
مجاور.
الحقول
| الحقل | مطلوب | الملاحظة |
|---|---|---|
key |
^[a-z][a-z0-9_-]{0,63}$. هويّة لا عنوان: بها يتذكّر النظام آخر تبويب فتحه وليّ الأمر، فتغييرها ينسى اختياره. |
|
title |
{ar, en} — والعربي إلزامي. نصّ مفرد يُقرأ عربياً اختصاراً. حتى 60 محرفاً. |
|
entry |
okta_app/native/<package>/lib/<file>.dart. يجوز أن يكون نفس ملف بطاقة بوّابتك، أو حزمة مستقلّة. |
|
portals |
["guardian"] افتراضاً. "student" هي الشاشة نفسها يفتحها الطالب على نفسه. |
|
order |
ترتيبك بين تبويباتك أنت. موضعك بين تبويبات التطبيقات الأخرى تقرّره المنصّة، لا أنت. | |
icon |
حتى 64 محرفاً. |
أربعة شروط، كلٌّ منها يُسقِط التبويب كاملاً
nativeوحده. التبويب يصله معرّف الطالب. ولو كانwebviewأوexternalلوجب إرسال ذلك المعرّف إلى صفحة يستضيفها الشريك — أي نقل معرّف طفل إلى خادمك في كل مرة تُفتح فيها الشاشة، خارج نداءاتOkta.*التي تتحقّق okta-web من كلٍّ منها على حدة. الإعلان تحت أي وضع آخر يُسقَط.minContract≥ 17. نقطة التركيب لم توجد قبل هذا العقد. الجهاز الأقدم لا يخطئ ولا يشتكي — يعرض ملف الطالب وتبويبك غير موجود، ولا شيء في أي سجلّ يقول لماذا.- النطاق
education.students.read. التبويب لا يصله إلا معرّف الطالب، وبلا هذا النطاق لا يستطيع تحويله إلى طالب يعرفه — فيُركَّب تبويب لا يعرض شيئاً. - ثلاثة تبويبات كحدٍّ أقصى. الشريط مشترك: كل تطبيق مثبَّت لدى الجهة يضع تبويبه في الصفّ نفسه، أمام أبٍ جاء ليرى ابنه. من يأخذ ثمانية مقاعد لا يوسّع الملف بل يستولي عليه، ويدفع الثمن وليُّ الأمر تمريراً أفقياً.
ماذا يصلك عند التركيب — وكيف تقرأه
يُركَّب تبويبك على طالب واحد، ويصلك hashid الطالب لا رقمه. وهذه ليست تفصيلة تجميلية: الرقم المتسلسل يُخمَّن، فيمشي تطبيقٌ على كشف المدرسة بزيادة واحد، والـ hashid لا.
نداءان جديدان في العقد 17:
// الطالب الذي فُتح عليه التبويب. دالة لا خاصيّة.
final studentHashid = Okta.studentHashid();
// مَن يقرأ: 'guardian' أو 'student'. الشاشة واحدة والقارئان اثنان.
final portal = Okta.portal();
كلاهما يعيد '' لا null حين لا يكون السطح تبويباً في ملف الطالب — وهذه
الحالة العادية في كل سطح آخر لتطبيقك. و'' ليست خطأ، بل تعني «هذا ليس تركيباً
على طالب». لا تسقط إلى «المستخدم الحالي» عندها: في سياق وليّ الأمر لا يوجد
طالب في الجلسة تسقط إليه، والتطبيق الذي يخمّن واحداً يعرض لأبٍ ابنَ عائلة أخرى.
لماذا
''لاnull؟ لأن'$value'على قيمة معدومة تُنتج المحارف الأربعةnull— نصّاً سليم الشكل تماماً يمشي إلى داخل مسار API بلا أن يوقفه شيء. السلسلة الفارغة يمكن فحصها، و'null'لا يمكن.
التفويض ليس مسؤوليتك ولا تستطيع تجاوزه. okta-web يعيد الفحص على كل نداء (وليُّ أمر مرتبط، أو الطالب نفسه، أو مفوَّض بتفويضٍ ساري) — فتبويب يحاول قراءة طالب آخر يتلقّى 403 لا بيانات. لا تبنِ تحقّقك أنت، ولا تخزّن الـ hashid لتقرأه لاحقاً في سياق آخر.
مثال كامل يعمل
okta_app/native/student_tab/lib/main.dart — تبويب حضور يعرض غيابات الطالب:
import 'package:flutter/material.dart';
import 'package:okta_host/okta_host.dart';
Widget main() => const AbsencesTab();
class AbsencesTab extends StatefulWidget {
const AbsencesTab({Key? key}) : super(key: key);
@override
State<AbsencesTab> createState() => _AbsencesTabState();
}
class _AbsencesTabState extends State<AbsencesTab> {
bool loading = true;
String error = '';
List<dynamic> rows = <dynamic>[];
@override
void initState() {
super.initState();
load();
}
Future<void> load() async {
final sid = Okta.studentHashid();
// «ليس تركيباً على طالب» حالة مشروعة لا خطأ — اعرض ما يفهمه القارئ
// بدل استدعاء API بمعرّف فارغ.
if (sid.isEmpty) {
setState(() {
loading = false;
error = 'افتح هذا التبويب من ملف أحد أبنائك.';
});
return;
}
// مسار تطبيقك أنت. okta-web يتحقّق من حقّ هذا القارئ في هذا الطالب
// قبل أن يصل النداء إلى كودك.
final res = await Okta.get('/api/my-app/absences?student=' + sid);
setState(() {
loading = false;
if (res.ok) {
rows = res.body['data'];
} else {
error = 'تعذّر تحميل السجلّ.';
}
});
}
@override
Widget build(BuildContext context) {
if (loading) {
return const Center(child: CircularProgressIndicator());
}
if (error != '') {
return Center(child: Text(error));
}
if (rows.isEmpty) {
// «لا غياب» خبر سارّ لوليّ الأمر — قُلْه، ولا تعرض جدولاً فارغاً.
return const Center(child: Text('لا غياب هذا الفصل. 🎉'));
}
// «ابنك» لوليّ الأمر و«أنت» للطالب: الشاشة واحدة والقارئان اثنان.
final who = Okta.portal() == 'guardian' ? 'ابنك' : 'أنت';
return ListView(
children: [
Padding(
padding: const EdgeInsets.all(12.0),
child: Text('$who تغيّب ${rows.length} مرة هذا الفصل'),
),
for (final row in rows)
ListTile(
title: Text('${row['date']}'),
subtitle: Text('${row['reason']}'),
),
],
);
}
}
ثلاث ملاحظات في هذا المثال تحديداً:
main()يعيد Widget كأي تطبيق مصغّر — التبويب ليس نوعاً جديداً من البرامج، بل السطح نفسه بنقطة تركيب مختلفة. فكل ما تعرفه عنnativeيسري هنا: مجموعة dart_eval المدعومة، والحدود، والمصائد أدناه.- حالة الفراغ ليست خطأً — من فتح التبويب من مكان غير ملف طالب يستحق جملة تشرح، لا دوّامة تدور إلى الأبد ولا نداء API بمعرّف فارغ.
- لا تعرض جدولاً فارغاً لوليّ أمر. «لا غياب هذا الفصل» هو المعلومة، وهو ما جاء يبحث عنه.
نطاقك أنت، وبيانات المنصّة
education.students.read شرط لأن التبويب يصله معرّف وحسب. إن أردت أكثر من
تحويل المعرّف إلى طالب — الصفّ، الشعبة، الجدول — اطلب النطاقات المقابلة كما في
نظام النطاقات. وأمّا بياناتك أنت فتقرؤها من مسار
تطبيقك (/api/<slug>/…) بلا نطاق إضافي، وهي عادةً معظم ما يعرضه التبويب.
مصائد
- تبويب يُسقَط لا يترك أثراً. أي شرط من الأربعة أعلاه يُسقِط الصفّ عند
البناء: الـ manifest يُنشَر، والتطبيق يُثبَّت، والتبويب ببساطة غير موجود على
هاتف الأب. لذلك محرّر الإصدار وفاحص
validate_manifestيرفضان الصفّ بخطأ لا بتحذير — الرفض حيث تقرأه أرحم من غياب لا تراه. - الفراغ أسوأ من الغياب. تبويب بعنوان جذّاب لا يعرض شيئاً حتى تُثبَّت بيانات، أفضل ألا يُعلَن. أعلِنه حين يكون له ما يقوله.
- العنوان لجمهور واحد فقط: وليّ الأمر. لا تكتب فيه اسم تطبيقك ولا مصطلحاً داخلياً؛ اسم تطبيقك ظاهر بجانب التبويب أصلاً.
مصائد أثبتتها الأجهزة — اقرأها قبل أن تشحن
كل بند هنا أسقط تطبيقاً حقيقياً على جهاز حقيقي، ولا يمسكه flutter analyze ولا
tool/validate.dart. الترجمة تنجح، ثم يموت المفسّر عند المستخدم.
١. Okta.* نداء جسر، لا قراءة حقل — خزّنه مرة واحدة.
كل نداء يذهب للمضيف ويعود بسلسلة JSON تُفكّ من جديد. في كل مرة. تطبيق حضور
كتب:
bool get _ar => Okta.locale().startsWith('ar'); // getter، ١٨٨ نداءً للبناء الواحد
String _t(String ar, String en) => _ar ? ar : en;
اقرأ الهوية مرة واحدة في initState واحفظها في حقول:
late final bool _ar;
@override
void initState() {
super.initState();
_ar = OktaText.isRtl(Okta.locale());
}
لا يمكن أن تتغيّر دون أن يعيد المضيف بناء التطبيق المصغّر أصلاً. والقاعدة أوسع
من الهوية: عامِل كل Okta.* على أنه شبكة، لا خاصية.
١ب. القيمة الآتية من الخارج ليست قيمة مفسِّر — والرسالة تشير للوسيط لا
للمستقبِل. نداء دالة مجسورة (startsWith، toLowerCase، trim،
contains، substring، وحتى toString) على قيمة وصلت من خارج المفسّر يموت
بـ:
type 'String' is not a subtype of type '$Value?' in type cast
#1 $String._startsWith
args[0] سليم تماماً — العطب في المستقبِل، فلا تطارد الوسيط الذي تسمّيه
الرسالة. Okta.* عولجت من داخلها فلم تعد تُخرِج شيئاً بهذا الشكل، لكن
قارئ JSON الذي تكتبه أنت يفعل:
String _text(dynamic map, String key) {
return map[key] as String; // الكسر يمرّ، والشكل يبقى خاطئاً
}
String _text(dynamic map, String key) {
final dynamic value = map[key];
return '$value'; // الاستيفاء يجعل المفسّر يخصّص السلسلة
}
الإصلاح عند المنبع لا عند مواضع الاستدعاء، وينطبق على فروع int/double
كذلك (value.toString() فيه العيب نفسه ويظهر أندر). وللمنطقي استعمل
== true لا as bool.
٢. لا تُعشّش الثلاثيّات — في وسيط أو في إسناد، سيّان.
final way = a ? (x ? '…' : '…') : (b ? '…' : '…'); // يصل null
المترجم يحجز خانة نتيجة الشرط الخارجي، والداخلي يكتب في خانة أخرى:
*L11: null, L12: null
8484: BoxString (L12) <<< EXCEPTION
فرّع وأعِد (if … return). ثلاثيّة واحدة آمنة. ونوع الخطأ في الرسالة
(String أو int) هو ما أنتجه التعبير فحسب — لا تقرأه كتضييق للقاعدة.
٣. دالة خاصة بلا مستدعٍ تُرسِب بوّابتك.
unused_element تحذير، وflutter analyze جزء من partner-module-policy. إن
حذفت زرّاً فاحذف معالجه معه.
٤. نداء العتاد ما زال قد يرفض — افحص ما يعيده.
بوّابة القدرات في المنصّة ذهبت، فلا شيء يُحجَب لغياب إعلان. الباقي هو الواقع:
عتاد غير موجود، ونافذة إذن من نظام التشغيل رفضها المستخدم، وقارئ لم يُقترن.
كل نداء يُجيب بشكل رفضه الموثَّق — scanNfc ← null، وstorePut /
openDocument / toolKeyStart ← false، وlocation ← دقّة سالبة — ولا يرمي
أيٌّ منها. تجاهُل القيمة المُعادة يعني زرّاً لا يفعل شيئاً بصمت.
٥. ref القديم يجعل بوّابتك تفحص شيئاً لا يملكه الجهاز.
okta_miniapp المثبَّت في okta_app/native/<entry>/pubspec.yaml لا يقرّر
ما يُترجَم عليه الجهاز — المضيف يحقن okta_host/okta_kit/okta_motion من
ثنائيّته. ما يقرّره هو ما تفحصه flutter test tool/validate.dart. فمرجع متأخّر
عن المضيف = بوّابة تصادق على مصدر غير موجود، والعطب يظهر عند المستخدم:
CompileError: Cannot find static method OktaTabs.bottomBar
رمزٌ يعرفه المضيف ولا تعرفه نسختك — أو العكس. المنصّة تدير هذه القيمة؛ إن رأيت فشل ترجمة على الجهاز وبوّابتك خضراء، فأول ما تسأل عنه هو تطابق الـ ref مع المضيف المنشور.
٦. الفشل الصامت أسوأ من الفشل. الالتقاط المحلي يؤكَّد قبل استشارة الشبكة (وهذا صحيح — به ينجو المسح من انقطاع)، لكن معناه أن رفعاً لم يصل يبدو على الشاشة مطابقاً تماماً لرفع وصل. قل ذلك صراحةً: صوت، ورسالة، وسطر في السجل. من يقف على بوابة ينظر إلى الطالب لا إلى شارة في الترويسة.
٧. مفتاح غائب من خريطة يُنتج قيمة لا تستطيع حتى فحصها. أخطر فخّ في هذا
المحرّك. جسر Map في dart_eval يعيد map[key] مباشرةً، وnull الناتج عن مفتاح
غائب هو null دارت خام لا $null المفسّر. فـvalue is String لا تعيد
false — بل ترمي:
type 'Null' is not a subtype of type '$Value' in type cast
ولا حيلة دفاعية: is ترمي، و== null أسوأ — تعطي false فتمرّر القيمة
للنداء التالي فيموت هناك بدلاً منها.
ولا حارس containsKey كذلك. جُرِّب على الجهاز ورُدَّ: تعليبه لا يطابق
تعليب operator[] (وهو معامل مُعالَج معالجةً خاصّة)، فصار كل قراءة محروسة
تسقط إلى بديلها — أي أنه يجيب «غير موجود» عن مفاتيح موجودة. لا تكتبه.
فالدفاع عند المنبع: اجعل نقطتك تُصدِر كل حقل يقرؤه تطبيقك دائماً،
بقيمة null حين لا قيمة له. مفتاح موجود قيمته null آمن تماماً —
jsonDecode يلفّ تعاودياً فيصل $null ويتصرّف طبيعياً — والغياب وحده قاتل.
والنقطة نقطتك، فالضمانة في يدك.
وOktaJson يحميك من الشكل لا من الغياب: at تستبعد جسماً ليس كائناً
(null، قائمة، نصّ، رقم، منطقي) فتنجو من ردٍّ غير متوقَّع، وstrOr/number/
list تتسامح مع النوع الخطأ. أمّا مفتاح غائب فيمرّ منها كما يمرّ من قراءتك
المباشرة. الاستثناء OktaJson.flag — تستعمل == true لا is، فهي آمنة أمام
الغياب.
٨. setState(() => x = y) يصندق مرّتين — اكتب جسماً بأقواس.
الإغلاق السهمي يعيد قيمة جسمه، والإسناد تعبير، فتُعلَّب القيمة مرّة للكتابة
ومرّة للإرجاع، والثانية تقع على معلَّب:
type '$bool' is not a subtype of type 'bool' in type cast
7287: SetObjectProperty (L3._exporting = L1)
7288: BoxBool (L1) <<< EXCEPTION
setState(() { _busy = true; }); // لا قيمة تُرجَع، فلا تعليب ثانٍ
حوّلها كلّها، لا التي تنهار فقط: مواضع متطابقة في الملف نفسه ظلّت تعمل أسابيع، والفرق يعود لتخصيص السجلّات لا لشيء يُرى في المصدر.
٩. قراءة bool من قائمة هي الفخّ نفسه من طريق آخر.
الفهرسة تعيد قيمة معلَّبة أصلاً، والمترجم — إذ يرى نوعاً ساكناً bool — يضيف
BoxBool فوقها. قارِن، لا تقرأ:
final on = i < flags.length && flags[i] == true; // لا `flags[i]`
المقارنة تنتج منطقياً جديداً فلا يُعاد تعليبه. النصوص والودجتس تخرج من القوائم بلا مشكلة؛ المنطقيّات وحدها (والأعداد الذاهبة لمعامل مجسور) هي التي تعضّ.
١٠. لا تُهيّئ عدّاد حلقة من معامل.
عددٌ صحيح عبر معامل مفسَّر يصل معلَّباً. استعماله مباشرةً سليم (المترجم
يفكّه)، لكن int i = from نسخة محليّة: CopyValue ينقل العلبة، ويصدّق المترجم
تصريح int فيظنّها مفكوكة، ثم تموت المقارنة التالية:
type '$int' is not a subtype of type 'num' in type cast
استعمل حدوداً حرفية وكرّر الحلقة، دالةً لكل مدى. ولا توسّع القاعدة إلى «لا
حساب على معامل صحيح» — تلك خاطئة، وindex * 40 يشحن بلا مشكلة.
١١. قيمة أوليّة تعبر معامل دالة مفسَّرة تُعلَّب مرّتين.
حرفٌ يذهب مباشرةً إلى بانٍ مجسور سليم. أمّا المرور بمعامل أوّلاً فيصل معلَّباً
مرّتين ويموت بـ type '$int' is not a subtype of type 'int'. هكذا كسر مساعدٌ
بسيط — IconData _icon(int c) — شريط تبويب من خمسة تبويبات، مرّة لكل تبويب.
ابنِ القيم المجسورة من حروف عند موضع الإنشاء، أو أعِدها من دالة بلا معاملات
كما تفعل OktaIcons. والودجتس والنصوص تمرّ عبر المعاملات بلا أذى.
١٢. مُعلَن، غير موصول: يترجم ثم يرمي.
صنفٌ قد يكون معروفاً للمترجم وغير مسجَّل لدى وقت التشغيل، فيمرّ من
validate.dart ثم يرمي أثناء البناء:
UnimplementedError: Tried to invoke a nonexistent external function
في flutter_eval 0.8.2 هذا حال InkWell وInkResponse وSafeArea. استعمل
GestureDetector بدل الأولَيْن، وحسابات حشو بدل الثالث.
والفخّ نفسه على الدوال: list.sort() بلا مقارِن يُترجَم ثم يرمي، لأن
تنفيذها يقرأ args[0] بلا ?. مرّر المقارِن دائماً أو لا تُرتّب.
١٣. النصّ الطويل يلتفّ ولا يُقصّ.
Text لا يجسر maxLines ولا overflow، وTextOverflow ليس نوعاً مجسوراً
أصلاً — فلا نقاط حذف، والعنوان الأعرض من صندوقه يلتفّ فيشقّ الكلمة. أبقِه
سطراً واحداً بـ FittedBox(fit: BoxFit.scaleDown, child: Text(…)) داخل عرض
محدود، وأغفِل alignment (الغلاف يجعلها Alignment.center).
١٤. لا Timer — لكن هناك ساعة.
DateTime (بـ .now() و.parse وadd وdifference و…) وDuration
وFuture.delayed كلّها مجسورة وموصولة، وjsonEncode/jsonDecode كذلك.
الغائب هو Timer وحده، أي الاستدعاء الدوري لا التأخير. فحلقة السحب
(toolKeyReads/cameraScanReads) تُكتب استدعاءً ذاتياً بـ Future.delayed،
وتحتاج علمك أنت لإيقافها: State.mounted غير مجسورة، فلا تستطيع فجوة
غير متزامنة أن تسأل هل ما زالت على الشاشة.
الرسائل الفورية (Realtime)
التطبيق المصغّر كان يسأل ولا يُخبَر. كل قدرة قبل هذا الرقم هي أن ينادي الصندوقُ المضيفَ وينتظر جواباً، ولا مسار للمضيف أن ينادي إلى الداخل. فتطبيقٌ يريد أن يعرف متى قال خادمُه شيئاً لم يكن أمامه إلا الاستطلاع: شاشة الفصل تسأل كل ثانيتين «هل نُودي على أحد؟» طوال اليوم الدراسي، عن حدث يقع مرّتين في الحصّة. القناة تعكس الاتجاه — تطبيقك يدفع خبراً إلى أسطحه هو.
نصفان، وواحدٌ فقط رفع العقد — والفرق ليس تفصيلاً
الإرسال لم يحتج رفع عقد. النشر نداء API عادي على مسار مسموح أصلاً:
Okta.post('/api/apps/realtime/publish', …) يعمل على العقد 21 بلا رمز جديد،
لأن Okta.post تُرسل منذ العقد 1 وسطح الشركاء محكوم بالنطاقات لا بالرموز.
والاستقبال لا يمكن التعبير عنه أصلاً. لا رمز في المكتبة المحقونة يستقبل
مُعالِجاً، ورمزٌ جديد في مكتبة محقونة هو تغيير عقد: تطبيق ينادي
Okta.onMessage على مضيف 21 لا يفشل فشلاً لطيفاً، بل يموت على الجهاز بـ
Cannot find static method Okta.onMessage — مسمّياً ملفاً لم يكتبه صاحبه.
لذلك 21 ← 22.
وماذا يعني الرقم لك عملياً: تطبيق يُعلن minContract: 22 يُرفَض على مضيف
أقدم بشاشة «حدِّث التطبيق» — وهو الرفض الصحيح، لأن البديل موتٌ في الترجمة على
تلفاز في مدرسة. ومضيف 22 يشغّل كل ما سبق كما هو؛ لا شيء في العقد 21 تغيّر.
أعلِن
minContract: 22فقط إن كنت تستقبل. تطبيق ينشر ولا يستمع يبقى على أرضيته الحالية ويعمل على كل جهاز في الميدان — ورفعُ الأرضية بلا حاجة لا يجعل تطبيقك أحدث، يجعله يرفض أجهزة كان يعمل عليها.
النشر — POST /api/apps/realtime/publish
خلف النطاق realtime.messages.write. أعلِنه في scopes كأي نطاق آخر.
await Okta.post('/api/apps/realtime/publish', {
'event': 'class.called',
'data': {'section': 'أ-٣'},
});
الجسم يقبل مفتاحين لا ثالث لهما: event وdata. و202 يعيد:
{
"published": true,
"channel": "private-app.01M1TTHDSWKEXCCATPVA7MP8N5.roll-call",
"sent_at": "2026-09-06T07:39:46+00:00"
}
وما يصل أسطحك على القناة:
{
"event": "class.called",
"data": {"section": "أ-٣"},
"sent_at": "2026-09-06T07:39:46+00:00",
"from": "server"
}
اسمك لا يصير اسم حدث البثّ أبداً. اسم حدث البثّ هو الثابت app.message
لكل رسالة من كل تطبيق، وclass.called يسافر داخل الحمولة في مفتاح
event. لو بُثّ باسمك لاستطاع تطبيقٌ أن يبثّ device.command.queued — أو أيّ
اسم تربط عليه أسطح المنصّة — فيتصرّف عميلٌ على رسالة كتبها شريك. الإطار واحد:
العميل يربط مرّة، ومفرداتك أنت بيانات.
الرفض — وما تفعله بكلٍّ منه
| الرمز | error |
ما حدث، وما تفعله |
|---|---|---|
403 |
scope_not_granted |
النطاق realtime.messages.write غير ممنوح لهذا التثبيت. أعلِنه وأعد النشر؛ الجهة تمنحه عند التحديث. |
422 |
invalid_event |
اسم الحدث لا يطابق ^[a-z][a-z0-9_.:-]{0,63}$ — حروف صغيرة، يبدأ بحرف، 64 محرفاً على الأكثر. |
422 |
payload_too_large |
كتلة data تجاوزت 8192 بايت من JSON. أرسِل مرجعاً ودع السطح يقرأ الباقي عبر الـ API. |
429 |
rate_limited |
تجاوزت 60 رسالة في الدقيقة لهذا التثبيت. الجسم يحمل retry_after بالثواني، ويحمله الردّ كذلك في ترويسة Retry-After. |
نصوص الرفض تقول لك الإصلاح لا العطل فقط:
{"error":"payload_too_large","message":"The `data` object is 9008 bytes of JSON; the limit is 8192. Send a reference and let the surface fetch the rest over the API."}
{"error":"rate_limited","message":"This installation has published 60 messages in the last minute. Retry in 48s, or batch what you are sending into fewer messages.","retry_after":48}
الحدّان، ولماذا هما حيث هما
8 كيلوبايت على data كي لا تصير القناة طريقاً ثانياً للبيانات: ما يحتاج
بيانات يقرؤها من الـ API بنطاقه المعتاد، فتبقى حراسة النطاقات على باب
واحد. أرسِل «الشعبة أ-٣ نودي عليها» لا كشف الشعبة كاملاً.
60 رسالة في الدقيقة لكل تثبيت — أي لكل زوج (جهة، تطبيق). ولا هي لكل تطبيق ولا لكل جهة، لأن الزوج هو نصف قطر انفجار الحلقة بالضبط: حدٌّ لكل تطبيق يجعل حلقةً في نسخة مدرسةٍ واحدة تُسكِت تطبيقك في كل مدرسة أخرى، وحدٌّ لكل جهة يجعل تطبيقاً ثرثاراً ينفق ميزانية بقيّة تطبيقات مدرسته.
ما لا يوجد في الجسم، عمداً
لا tenant_id ولا module. الجهة والتطبيق يُقرآن من سياق التثبيت على
الخادم وحده، ولا مكان في الجسم لكتابتهما. حقلٌ كهذا كان سيكون ادّعاءً،
واحترامه يجعل تطبيقاً مثبَّتاً في مدرسة يخاطب شاشات مدرسة أخرى — وهو الشيء
الوحيد الذي بُني نموذج التثبيت كلّه ليمنعه. أرسِلها إن شئت؛ لا تغيّر شيئاً.
ولا Idempotency-Key. تلك الترويسة تعيد ردّاً مخزَّناً 24 ساعة كي لا تقع
كتابة مرّتين، ولا شيء يُكتب هنا. ورسالةٌ فورية تُعاد من الكاش رسالةٌ لم
تصل والمستدعي يُقال له إنها وصلت — وهو أسوأ من خطأ ظاهر.
والنشر الذي يفشل بثُّه يبقى 202 ويُسجَّل تحذيراً. المستدعي أنجز العمل الحقيقي — سجّل الحضور، نادى الفصل — والرسالة تُعلن عنه فقط؛ فإسقاط الطلب كان يتراجع عن شيء وقع من أجل إعلانٍ لم يقع.
الاستقبال — Okta.onMessage (عقد 22)
Okta.onMessage((String event, Map data) {
if (event == 'class.called') {
setState(() { _section = '${data['section']}'; });
}
});
مُعالِج واحد، والنداء الثاني يستبدل. لا قائمة مُعالِجين ولا
unsubscribe، وكلا الغيابين مقصود:
- قائمة مُعالِجين تطرح سؤالاً لا يستطيع الصندوق الإجابة عنه: أيّهما يملك هذه الرسالة؟ التطبيق واحد وقناته واحدة، فالمُعالِج واحد.
- إلغاء الاشتراك لا شيء يفعله: المُعالِج يموت مع التطبيق المُركَّب، لأن المضيف هو من يفتح المقبس عند التركيب ويغلقه عند الإزالة. رمزٌ يلغي الاشتراك كان سيضيف طريقة واحدة لا غير — أن تُخطئ في العمر فيصمت تطبيقك وهو على الشاشة.
ولا رمز إرسال. Okta.post تُرسل منذ العقد 1، ورمزٌ ثانٍ لنفس الفعل مسارُ
كتابة ثانٍ ينحرف.
والمقبس للتطبيق المُركَّب وحده. لكل هدف delegate خاص، ولكل delegate مقبسه وقناته — فلا مقبس مشترك يُسرَّب من خلاله شيء، ولا يسمع تطبيقٌ رسائل غيره.
واسم القناة يأتي من الإطلاق لا من الحزمة. الحزمة تُعاد استخدامها ما دام
payload_version ثابتاً، فقناةٌ محمولة عليها تكون قناة الجهة التي فُتحت قبل
تبديل المدرسة — مقبسٌ يُصادَق عليه بنظافة ويسلّم رسائل مدرسة أخرى. الإطلاق
يُعاد سؤاله في كل فتح، ومنه يأخذ المضيف القناة. أنت لا تلمس هذا: تنادي
onMessage فحسب.
والاستماع لا يحتاج نطاقاً — التثبيت هو الإذن. النطاق ما تمنحه الجهة
لتطبيق كي يصل بياناتها، والقناة لا تحمل إلا ما أرسله تطبيقك إلى أسطحه هو
في الجهة التي ثبّتته. ما يجب أن يصحّ هو أن التثبيت قائمٌ ونشط، ويُعاد
فحصه عند كل اشتراك: الإزالة أو التعليق هو ما يُغلق القناة. ولذلك لا يوجد
realtime.messages.read ولن يوجد — منحةٌ تُطلب ولا تشتري شيئاً منحةٌ يظنّ
الشريك أنه يحتاجها.
القاعدة التي تُنجي تطبيقك: لا تنتظر رسالةً لترسم
مقبس ميّت لا يغيّر شيئاً على الشاشة. كتلة realtime في حمولة الإقلاع
اختيارية، وغيابها حالة عادية لا عطل: نشرٌ بلا بثّاث، خادم أقدم من
الميزة، منصّة لا يُفتح فيها المقبس. وكل فشل بعدها مُبتلَع ومُسجَّل: تصريح
مرفوض، مضيف ميّت، إطار مشوَّه، مُعالِج يرمي.
ومن داخل الصندوق، مضيفٌ لا ينادي المُعالِج أبداً وقناةٌ صامتة الشيءُ نفسه — لا سبيل لك للتفريق بينهما. فالقاعدة واحدة:
اقرأ الحالة بـ
Okta.get، ثم دع الرسالة تحدّثها. تطبيقٌ يرسم شاشة فارغة وينتظر أول رسالة يعرض فراغاً إلى الأبد على أي جهاز خارج التغطية — وهو بالضبط ما يبدو عليه تلفاز في مدرسة بشبكة متعثّرة.
فمقبس لا يتصل يكلّفك طزاجةً ولا شيء غير ذلك. والمضيف يعيد المحاولة عنك: تراجعٌ يبدأ بثانيتين ويتضاعف حتى سقف خمس دقائق، بجَور ±٢٠٪، ويُصفَّر العدّاد على نجاح الاشتراك لا على نجاح الاتصال.
ولا ترمِ داخل المُعالِج. هو كود مُفسَّر يُستدعى من نداء مقبس — خارج أي
build، فلا حدود خطأ تحته. المضيف يلفّه مرّتين احتياطاً، لكن اجعل جسمه
دفاعياً: اقرأ من data بحذر، ولا تفترض مفتاحاً.
وأنت الناشر: أصدِر كل حقل، و
nullحين يكون فارغاً. مفتاحٌ غائب من الخريطة المفكوكة هوnullخام في dart_eval، وisترمي عليه، وOktaJson.strنفسها ترمي. اجعل حمولتك ثابتة الشكل ومُلئ الفراغ فيهاnullصراحةً — أنت الطرفان هنا، فالعقد بينك وبين نفسك.
مثال كامل: «نداء الفصل»
تطبيق واحد بسطحين: المعلّم على هاتفه ينادي شعبة، وشاشة الفصل تُحدَّث بلا استطلاع.
سطح الهاتف — ينشر بعد أن يفعل الفعل الحقيقي:
Future<void> callSection(String section) async {
// 1) الفعل الحقيقي أولاً: يُسجَّل في خادمك أنت.
final res = await Okta.post('/api/roll-call/calls', {'section': section});
if (res.status < 200 || res.status >= 300) {
Okta.toast('تعذّر تسجيل النداء');
return;
}
// 2) ثم الإعلان. فشلُه لا يُبطل ما وقع في (1).
await Okta.post('/api/apps/realtime/publish', {
'event': 'class.called',
'data': {'section': section, 'called_at': '${DateTime.now()}'},
});
Okta.playSound('success');
}
سطح الشاشة — يرسم من الحالة، ثم تحدّثه الرسالة:
class Board extends StatefulWidget {
const Board({super.key});
@override
State<Board> createState() => _BoardState();
}
class _BoardState extends State<Board> {
String _section = '';
@override
void initState() {
super.initState();
// أولاً: ارسم ما هو قائم الآن. هذه هي الشاشة على جهاز
// لم يتّصل مقبسه أبداً — وهي شاشة صحيحة.
_loadCurrent();
// ثم: دع الرسالة تحدّثها. لا شيء هنا شرطٌ للرسم.
Okta.onMessage((String event, Map data) {
if (event != 'class.called') return;
final dynamic v = data['section'];
setState(() { _section = '$v'; });
});
}
Future<void> _loadCurrent() async {
final res = await Okta.get('/api/roll-call/calls/current');
if (res.status != 200) return;
final dynamic v = res.json()['section'];
setState(() { _section = '$v'; });
}
@override
Widget build(BuildContext context) {
return Center(
child: Text(
_section.isEmpty ? 'لا نداء الآن' : 'نُودي على: $_section',
),
);
}
}
وفي بيانك، على حزمة الشاشة وحدها إن كانت هي المستقبِلة:
"mobile": {
"minContract": 22,
"screen": {
"enabled": true,
"title": "نداء الفصل",
"places": [
{ "scope": "section", "entry": "okta_app/native/screen/lib/main.dart" }
]
}
}
لاحظ ما ليس في المثال: لا حلقة استطلاع، ولا
Timer، ولا مؤقّتFuture.delayedيعيد السؤال. وإن انقطع المقبس فالشاشة تبقى على آخر ما قرأته من_loadCurrent()— قديمةً، لا فارغة.
كتلة realtime في حمولة الإقلاع
حمولة الإقلاع تحمل كتلة realtime (وعلى الشاشة كذلك) فيها الـ driver
والمفتاح والمضيف والمنفذ واسم القناة ونقطة التصريح. أنت لا تستعملها —
المضيف هو من يستهلكها ويفتح المقبس؛ ذكرناها هنا لأنها تشرح لماذا القناة تأتي
من الإطلاق لا من الحزمة.
وحين لا يكون البثّاث مضبوطاً تصل الكتلة null لا مفتاحاً غائباً: الغياب
كان سيجبر العميل على الخلط بين «خادم أقدم من الميزة» و«الميزة مطفأة» —
حقيقتان بسلوكين.
النداء المسموع (الصوت والنطق)
تطبيق نداء الطلاب يُعلن اسم الطفل حين يصل وليّ أمره إلى البوّابة، وإعداداته
تعرض على المدرسة أربعة أصوات: جرسٌ لطيف، والاسم منطوقاً بصوت صناعي،
وتسجيلٌ سجّلته المدرسة، وتسجيلٌ سجّله وليّ الأمر. في المتصفّح كانت
الأربعة تعمل. وعلى السطحين اللذين يهمّان — الشاشة على الحائط وهاتف الموظّف —
كان يعمل الجرس وحده، لأن سطح الصوت في المضيف كان Okta.playSound(name) كلّه:
مفردات مغلقة من ثلاث نغمات نظام. ثلاثة أوضاع من أربعة لم يكن لها طريق أصلاً.
العقد 23 يفتح الطريق بأربعة نداءات.
الأربعة، وقدرتان لا واحدة
| النداء | ما يفعله | ماذا تعني false |
|---|---|---|
Future<bool> Okta.playAudio(String url) |
تشغيل ملف صوتي من رابط يخدمه خادمك أنت | لم يبدأ التشغيل: رابط غير صالح، أو ملف لا يُبلَغ، أو مكبّر صوت لا يستجيب |
void Okta.stopAudio() |
إسكات ما يُسمَع الآن — الملف والنطق معاً | — (لا تعيد شيئاً، ولا تفشل) |
Future<bool> Okta.speak(String text) |
نطق نصّ بصوت الجهاز | لم يبدأ النطق: لا محرّك نطق، أو لا صوت للّغة المطلوبة |
Future<bool> Okta.canSpeak() |
هل يستطيع هذا الجهاز النطق؟ | لا يستطيع — وهذا جواب سليم لا عطل |
وقدرتان لا واحدة، لأن الأوضاع أشياء مختلفة: التسجيلان — تسجيل المدرسة
وتسجيل وليّ الأمر — يحتاجان ملفاً يُشغَّل (playAudio)، والصوت الصناعي
وحده يحتاج نطقاً (speak). فتطبيقٌ لا يعرض إلا التسجيلات لا يحتاج محرّك
نطق أن يوجد على الجهاز إطلاقاً، ولا يجوز أن يسأل عنه ولا أن يعطّل نفسه لغيابه.
اسأل canSpeak() قبل أن تعرض «النداء بالاسم» — وهذا هو بيت القصيد
كثير من صناديق Android TV تُشحن بلا صوت عربي إطلاقاً. بلا استطلاع يختار تطبيقك الوضع المنطوق، فيصمت الحائط، ولا شيء في أي مكان يقول لماذا: لا خطأ على الشاشة، ولا سطر في سجلّ، ولا فرق ظاهر بين «لا صوت على هذا الجهاز» و«لم يصل النداء أصلاً».
falseحالةٌ عادية على جهاز سليم. ليست عطلاً، وليست شيئاً يُعاد المحاولة عليه — إعادة السؤال بعد ثانيتين تُجيبfalseنفسها إلى أن يثبّت أحدٌ صوتاً على الجهاز. الجواب الصحيح نزولُ درجة: التسجيل، ثم الجرس.
واسأله في موضعين: عند رسم إعدادات تطبيقك كي لا تعرض على مدرسةٍ خياراً لا يعمل على شاشتها، وعند النداء نفسه كي لا تفترض أن ما كان صحيحاً وقت الإعداد ما زال صحيحاً.
ولا يُخبَّأ الجواب — لا في المضيف ولا عندك. مدرسة تُثبّت صوتاً عربياً يوم الثلاثاء تستعمله يوم الثلاثاء؛ وشاشة الحائط لا يُعاد تشغيلها شهوراً، فجوابٌ مخبَّأ عند الإقلاع معناه أن الصوت المُثبَّت لا يُستعمل حتى العطلة القادمة.
وعلى Windows يحلّ المضيف الصوت بسرد لغات المحرّك (استطلاع اللغة الواحدة
خاصّ بأندرويد)، ويعيد إملاء المحرّك نفسه للوسم. لا تفعل شيئاً من هذا: النقطة
التي تخصّك أن canSpeak تجيب بصدق على المنصّتين.
لا شيء يرمي — كل جواب bool
مكبّر صوت ميّت، و404 على التسجيل، ومحرّك نطق غائب: كلّها false. لا
استثناء يصعد إليك من أيٍّ من الأربعة.
وهذا مقصود لأن الجمهور جدار: على شاشة لا يقف خلفها أحد، الاستثناءُ شاشةٌ حمراء
لا يمسحها أحد حتى نهاية اليوم الدراسي. فافحص القيمة المعادة ولا تكتب try
حول النداء.
playAudio يأخذ رابطاً يخدمه خادمك أنت
الملف يجلبه مشغّل الصوت في المنصّة، لا العميل المُصادَق: لا bearer ولا
ترويسة جهة تركب إلى مضيف ملفات شريك. أي أن الرابط يبلغ ما كان الجهاز ليبلغه
مجهولاً، والنداء يعيد bool لا بايتات.
عملياً:
- رابط مطلق
http(s)فقط، وfile:///وأخواتها مرفوضة. - الملف يجب أن يُخدَم بلا مصادقة أوكتا. ورابطٌ موقَّع قصير العمر يُصدره خادمك
أنت حلٌّ سليم؛ ونقطةٌ تنتظر توكن أوكتا ليست حلاً — ستُجيب 401 وتصلك
false. - إن كان التسجيل يعيش خلف نطاق تطبيقك، فأخرجه إلى مسار عام يخدمه خادمك، ولا
تُمرّر مساراً نسبياً على
/api/…: هذا النداء ليسOkta.get.
صوت واحد في القاعة
نداء playAudio ثانٍ يستبدل ما يُسمَع، وstopAudio() تُسكت المشغّل
والنطق معاً. لا تختلط عندك قناتان.
مكبّر صوت واحد على حائط، واسمان يتداخلان في ردهة أسوأ من اسم واحد يصل متأخّراً. فإن وصلك نداءان متتاليان فآخرهما هو الذي يُسمع، وهذا هو السلوك الذي تريده من تلقاء نفسه.
true تعني «بدأ» لا «انتهى»
ليس على هذا السطح إشارةُ انتهاء. الـFuture يُحلّ حين يبدأ الصوت، فلا
تستطيع اليوم أن تسلسل نداءين — «اقرع الجرس ثم انطق الاسم» ليس شيئاً يمكن
التعبير عنه، لأن await على playAudio لا ينتظر انتهاء المقطع.
قيدٌ معروف نقوله الآن بدل أن تكتشفه بطابور نداءات يدهس بعضه بعضاً على حائط مدرسة. وإن احتجت تتابعاً فرتّبه بمهلة تقدّرها أنت، واعلم أنها تقدير.
ولماذا قد تتأخّر false
playAudio قد تُجيب false متأخّرة حين لا يُبلَغ الخادم: المشغّل ينتظر
مهلة تجهيزه حتى نهايتها.
وهذا اختيار لا إغفال. جوابُ «لا» سريع يتبعه صوتٌ متأخّر كان سيجعل الحائط يشغّل الجرس البديل والتسجيل معاً — أي أن السرعة هنا تشتري بالضبط ما يمنعه القسم أعلاه.
playSound باقية ولم تُستبدَل
الجرس تأكيد بمفردات مغلقة يملكها المضيف (success · error · warning،
ومعها اهتزاز)، وهذا السطح نداء. شيئان مختلفان، ولذلك لم يُحذف الأول.
والجرس هو بالضبط ما تسقط إليه حين لا يكون على الجهاز صوت ولا يُبلَغ التسجيل — يعمل على كل جهاز وعلى كل عقد منذ 5.
minContract: 23 — ومتى لا ترفعه
تطبيق يُعلن minContract: 23 يُرفَض على مضيف أقدم بشاشة «حدِّث التطبيق» — وهو
الرفض الصحيح، لأن البديل موتٌ في الترجمة على تلفاز في مدرسة
(Cannot find static method Okta.speak).
لا ترفع الأرضية إلى 23 إلا إن كنت تنادي واحداً من الأربعة فعلاً. تطبيق لا يُصدر صوتاً لا يتحرّك، ورفعُ الأرضية بلا حاجة لا يجعله أحدث — يجعله يرفض أجهزة كان يعمل عليها.
وتطبيقٌ يريد التسجيلات وحدها يحتاج 23 كذلك: playAudio جديدة هي الأخرى،
والجديدُ في مكتبة محقونة أرضيةٌ أياً كان الغرض منه.
المحاكي يجيب false عن الأربعة — عمداً
على «الجهاز الافتراضي» تُجيب الأربعة false دائماً، مع أن تبويب متصفّح يستطيع
تشغيل <audio> واستعمال Web Speech.
السبب أن محاكياً يقول canSpeak() == true دائماً لا يُشغّل أبداً مسار
السقوط الذي يطلبه منك هذا الفصل — وأول مكان تكتشف فيه أنك لم تكتبه يكون قاعةً
ملأى بأولياء الأمور. فاختبر في المحاكي أن تطبيقك ينزل الدرجات بلا رسالة
خطأ وبلا شاشة فارغة، ثم اختبر المسار المنطوق على جهاز حقيقي أو شاشة مقترنة.
مثال: نداء البوّابة
استطلاع، ثم نطق، وإلا تسجيل المدرسة، وإلا الجرس. لاحظ أن كل درجة تسقط
بصمت إلى التي تحتها: لا toast ولا رسالة عطل — لأن ما وقع ليس عطلاً.
// mode: 'voice' | 'recording' | 'bell' — إعداد تختاره المدرسة.
Future<void> announce(String name, String mode, String recordingUrl) async {
// اقطع النداء السابق: مكبّر صوت واحد، واسمان متداخلان أسوأ من اسم متأخّر.
Okta.stopAudio();
if (mode == 'voice') {
final bool can = await Okta.canSpeak();
if (can) {
final bool spoke = await Okta.speak('وصل وليّ أمر $name');
if (spoke) return;
}
// لا صوت على هذا الجهاز — انزل درجة، ولا تُبلّغ أحداً بعطل لم يقع.
}
if (recordingUrl.isNotEmpty) {
final bool played = await Okta.playAudio(recordingUrl);
if (played) return;
}
// الجرس: يعمل على كل جهاز، وهو القاع الذي لا يسقط منه شيء.
Okta.playSound('success');
}
وفي إعداداتك، لا تعرض ما لا يعمل:
class _SettingsState extends State<Settings> {
bool _voiceAvailable = false;
@override
void initState() {
super.initState();
_probe();
}
Future<void> _probe() async {
final bool can = await Okta.canSpeak();
setState(() { _voiceAvailable = can; });
}
// ... وفي build: اعرض خيار «النداء بالاسم» حين _voiceAvailable وحده،
// واعرض بدلاً منه سطراً يقول إن هذا الجهاز بلا صوت مثبَّت.
}
ووصلُه بالرسالة الفورية — الشاشة تسمع، فتنادي، والاسم يصل من الحمولة:
Okta.onMessage((String event, Map data) {
if (event != 'guardian.arrived') return;
final dynamic v = data['student_name'];
setState(() { _name = '$v'; });
announce('$v', _mode, _recordingUrl);
});
وتبقى قاعدة الفصل السابق كما هي: لا تنتظر رسالةً لترسم. النداء المسموع
إعلانٌ فوق حالةٍ قرأتها بـOkta.get، لا بديلٌ عنها.
لا مفتاح في البيان، ولا نطاق
هذه رموز مضيف، لا مفاتيح بيان ولا منح. لا شيء تُضيفه إلى manifest.json
ولا نطاق تطلبه من الجهة لتنطق أو تُشغّل ملفاً: الصوت يخرج من مكبّر صوت الجهاز
الذي فُتح عليه تطبيقك، ولا يقرأ بيانات أحد.
الشيء الوحيد الذي يتحرّك هو minContract — فلا تبحث عن مفتاح تضيفه.
المايك ورفع الملفات (Voice & Files)
كل ما كان تطبيقك يستطيع إرساله إلى خادمك مكتوبٌ أو مصوَّر.
Okta.uploadFile(path) موجودة منذ العقد 1، لكن منتقي المضيف يقبل خمس
امتدادات فقط (pdf, jpg, jpeg, png, heic)، ولا شيء على هذا السطح كان يسجّل
صوتاً. فمعلّمة تريد أن تسجّل جملةً لوليّ أمر، ووليُّ أمرٍ يريد أن يردّ على نداء
الانصراف بصوته، ومشرفٌ يريد أن يرفق تقرير الحادثة الذي تملكه المدرسة بصيغة
.docx — ثلاثتهم بلا طريق.
العقد 24 يفتح الاثنين: مايك، ومنتقٍ تقول له ماذا تريد.
| النداء | ما يفعله |
|---|---|
Okta.canRecord() |
هل يستطيع هذا الجهاز التسجيل أصلاً؟ اسألها قبل أن تعرض زرّ التسجيل |
Okta.recordStart() |
ابدأ التسجيل. false = المضيف رفض (لا مايك، إذن مرفوض، أو تسجيل جارٍ) |
Okta.recordStop() |
أنهِ التسجيل وخُذ مقبضاً (String?) يسمّيه. null = لا شيء لينتهي |
Okta.recordCancel() |
ارمِ التسجيل الجاري وأطلق المايك. void، وآمنة دائماً |
Okta.playRecording(handle) |
أسمِع صاحبه ما سجّله قبل أن يرسله |
Okta.uploadRecording(path, handle) |
ارفع التسجيل إلى مسار API — نفس خريطة uploadFile |
Okta.uploadFileOfKind(path, kind) |
اختيار ورفع، وkind يقول أيّ ملفات يعرضها المنتقي |
كلها من العقد 24، فأعلن "minContract": 24 إن كنت تنادي واحداً منها.
وإن كنت لا تنادي شيئاً منها فلا ترفعه: الرفع بلا حاجة يرفض أجهزة كان
تطبيقك يعمل عليها.
سجِّل ← استمع ← أرسل
ثلاثة أفعال، والأوسط هو سبب وجود ستّة رموز لا ثلاثة: ملاحظةٌ صوتية لا يسمعها صاحبها قبل أن تغادر ملاحظةٌ لا يرسلها أحدٌ مرّتين.
String? _handle;
bool _busy = false;
Future<void> start() async {
if (!await Okta.canRecord()) {
Okta.toast('لا يوجد مايك متاح على هذا الجهاز');
return;
}
if (!await Okta.recordStart()) {
Okta.toast('تعذّر بدء التسجيل');
return;
}
setState(() { _busy = true; });
}
Future<void> stop() async {
final String? handle = await Okta.recordStop();
setState(() { _busy = false; _handle = handle; });
}
Future<void> play() async {
final String? h = _handle;
if (h == null) return;
await Okta.playRecording(h); // نفس المخرج الذي يستعمله playAudio
}
Future<void> send() async {
final String? h = _handle;
if (h == null) return;
final dynamic result = await Okta.uploadRecording('/api/apps/notes', h);
if (result == null) return; // ألغى المستخدم — لا يقع هنا عملياً
final int status = result['status'];
if (status >= 200 && status < 300) {
setState(() { _handle = null; }); // أُرسل، فلا تُرسله مرّتين
Okta.toast('أُرسلت الملاحظة');
} else {
Okta.toast('تعذّر الإرسال: ${result['error']}');
}
}
canRecord() قبل الزرّ — وهذا هو بيت القصيد
شاشة الفصل لها مكبّر ولا مايك. والهاتف قد يكون إذن المايك فيه مرفوضاً نهائياً من إعدادات النظام، حيث لا شيء يفعله التطبيق يرفع نافذة إذن بعدها أبداً.
بلا هذا الاستطلاع ترسم زرّ تسجيل، فيضغطه المستخدم، ولا يحدث شيء: لا نافذة
إذن، ولا رسالة خطأ، ولا سطر في أي سجلّ. وهو بالضبط العطل الذي وُجد هذا
الاستطلاع ليمنعه — نفس درس canSpeak() حرفياً.
و**false جوابٌ عادي عن جهاز سليم**، لا عطلٌ يُبلَّغ ولا محاولةٌ تُعاد: اعرض
الكتابة، أو الملف الذي يملكه المستخدم أصلاً، بدلاً من ذلك.
وهي لا تطلب الإذن، فآمنٌ أن تناديها وأنت ترسم الشاشة. recordStart() هي
التي قد ترفع النافذة.
المقبض يسمّي تسجيلاً بعينه، ولا يُعاد استعماله
recordStop() تعيد نصّاً معتماً يسمّي تسجيلاً يحتفظ به المضيف، لا
التسجيل نفسه: الصندوق بلا نظام ملفات، والبايتات لا تعبر الجسر أبداً. لا شيء
فيه يُقرأ ولا يُحلَّل — سلّمه إلى playRecording وuploadRecording ولا شيء
غير ذلك.
ويُتقاعَد لحظة بدء تسجيل جديد أو إغلاق تطبيقك. والمقبض المتقاعد يُرفَض باسمه ولا يُستبدَل به ما يحمله المضيف الآن — وهذا هو الفرق الذي يستحقّ الوسيط: وليّ أمر يسمع صوته مُعاداً، وتسجيلُ عائلةٍ أخرى هو ما يصل المدرسة.
فاحتفظ بالمقبض ما دامت شاشته، وامسحه بعد إرسال ناجح.
تسجيل واحد في كل وقت
recordStart() على مضيف يسجّل الآن تُجيب false ولا ترمي ما يُقال في
الهاتف بصمت. وplayRecording يمرّ بالمخرج الصوتي نفسه الذي يستعمله
playAudio (العقد 23): بدء أحدهما يُسكت الآخر، وOkta.stopAudio() تُسكت
أيّهما يصدر — فلا تحتاج أن تعرف أيّهما كان.
uploadFileOfKind — والمفردات مغلقة
Okta.uploadFile(path) لم تتغيّر ولن تتغيّر: خمس امتداداتها كما هي. توسيعها
كان سيغيّر بصمت ما يستلمه كل تطبيق منشور من منتقيه، ولا سطر في كوده يقول ذلك.
فمن يريد أكثر يقول أيَّ أكثر:
kind |
ما يعرضه المنتقي |
|---|---|
image |
صور الجهاز |
audio |
ملفات صوتية |
video |
مقاطع |
document |
pdf, doc/docx, xls/xlsx, ppt/pptx, txt, csv, rtf, odt, ods |
any |
كل شيء |
القائمة مغلقة، لأن المضيف هو من يرسم المنتقي: كلمةٌ لا يعرفها كانت
ستتدهور بهدوء إلى شيءٍ آخر على كل جهاز في كل مدرسة. المجهول يعود
{'status': 0, 'error': 'unknown file kind "…"'} — والمحاكي يرفضه كذلك، فخطؤك
المطبعيّ يُكتشَف في المتصفّح لا في مدرسة.
final dynamic picked = await Okta.uploadFileOfKind('/api/apps/files', 'document');
if (picked == null) return; // ألغى المستخدم
if (picked['status'] >= 400 || picked['status'] == 0) {
Okta.toast('تعذّر الرفع: ${picked['error']}');
return;
}
وdocument قائمة امتدادات لا «كل شيء» عمداً: منتقٍ يعرض كل شيء هو كيف يُرفق
مستخدمٌ فيديو 400 ميغابايت بطلب إجازة ثم ينتظره على شبكة مدرسة. اطلب any إن
كنت تقصدها.
null تعني «ألغى المستخدم» — والفشل شيء آخر
هذا هو الفرق الذي يجب أن تكتبه في الشفرة:
null← أغلق المستخدم المنتقي. لا شيء تفعله، ولا رسالة تعرضها.- خريطة بـ
status: 0أو4xx/5xxمعerror← فشل. اعرضه.
وuploadRecording لا تعيد null أبداً لمقبض متقاعد أو مضيف بلا مايك: تلك
نتيجةُ خطأ، لأن أحداً لم يُلغِ شيئاً — وتطبيقٌ يقرؤها إلغاءً يُسقط ملاحظةً
صوتية ويظنّ أنه أرسلها.
لا شيء يرمي
مايك غائب، إذن مرفوض، منتقٍ أُغلق، مقبض متقاعد، شبكة ماتت وسط الرفع — كلّها
false أو null أو نتيجةُ خطأ. لا تكتب try حول أيٍّ من السبعة؛ من يقرأ
«قد يفشل» يكتبها، والاستثناء هنا لا يقع.
أين لا يعمل، ويُقال لك بصدق
- الويب (بناء المتصفّح والمحاكي):
canRecord()تجيبfalse. حزمة التسجيل على الويب تعيد blob لا ملفاً، فالتسجيل لا يستطيع أن يسلك ساق الرفع نفسها. - المحاكي: يرفض السبعة، ومنتقي الملفات يُجاب فيه إلغاءً كما هو الحال
في
uploadFileمنذ البداية. محاكٍ يقول «نعم» دائماً لا يُشغّل مسار السقوط الذي نطلب منك كتابته، وأول مكان تكتشف فيه غيابه شاشةٌ أمام مستخدم حقيقي. - شاشة العرض (okta-screen): ترفض السبعة — تلفازٌ على حائط بلا مايك ولا أحد أمامه ليختار ملفاً.
فجرّب المسار المنطوق والمُسجَّل على جهاز حقيقي، واكتب مسار السقوط الذي
يعمل حين تجيب canRecord() بـfalse.
لا مفتاح في البيان، ولا نطاق
كالعقد 23 تماماً: هذه رموز مضيف. لا شيء تُضيفه إلى manifest.json ولا
نطاق تطلبه من الجهة — المايك مايك الجهاز الذي فُتح عليه تطبيقك، والملف يذهب
إلى مسار API تحكمه نطاقاتك المعتادة. الشيء الوحيد الذي يتحرّك هو
minContract.
هوية أوكتا داخل التطبيق المصغّر (الزجاج السائل)
اعتمد تطبيق الجوال الزجاج السائل لغةً بصرية: القشرة — شريط التطبيق، والأوراق المنبثقة، وشريط التبويب، وبطاقات الجهات — أسطحٌ شفّافة تلتقط ما خلفها وتلمع من حافّتها. هذا القسم يقول لك بالضبط ما نصيبك من ذلك، وما ليس نصيبك، ولماذا.
الخلاصة في سطر: لا تبني الزجاج — اطلبه. المضيف يرسمه لك عبر
package:okta_glass، ويرسمه بمكوّناته هو، فحين تعيد أوكتا تصميم المادة يتغيّر شكل تطبيقك عند أول إصدار مضيف بلا أن تنشر شيئاً.
القسمة: ما نرسمه نحن وما ترسمه أنت
| الطبقة | مَن يرسمها | ملاحظة |
|---|---|---|
| الحقل البنفسجي خلف كل شيء | المضيف | لا تصل إليه ولا تحتاجه |
| شريط التطبيق فوق تطبيقك | المضيف (أو OktaAppBar) |
زجاجيّ في قشرة أوكتا |
| ورقة الأذونات، والتنبيهات | المضيف | تظهر فوق شاشتك بلا استئذانك |
| مساحة تطبيقك | أنت | بالمادة عبر OktaGlass، أو مصمتة عبر OktaSurface |
| بطاقاتك وصفوفك وأزرارك | أنت | بألوان OktaPalette |
package:okta_glass — المادة، جاهزة
import 'package:okta_glass/okta_glass.dart';
Scaffold(
backgroundColor: const Color(0x00000000),
appBar: OktaAppBar.build('حضور اليوم', palette),
body: OktaGlass.background( // الحقل خلف الشاشة كلّها
ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
OktaGlass.card( // لوح بوزن البطاقة
OktaList.row(OktaIcons.calendar(), 'الحصة الأولى', '٧:٣٠', palette),
),
],
),
),
)
| النداء | الوزن | متى |
|---|---|---|
OktaGlass.card(child) |
طمس 22 · نصف قطر 20 · مرتفع | البطاقة — وهو ما تريده في الغالب |
OktaGlass.chrome(child) |
طمس 30 · نصف قطر 26 | ما يطفو فوق محتوى يتحرّك تحته |
OktaGlass.field(child) |
طمس 18 · نصف قطر 16 · مسطّح | حقل إدخال — يجب أن يُقرأ غائراً |
OktaGlass.surface(r, b, e, child) |
كما تحدّد | عند الضرورة وحدها |
OktaGlass.background(child) |
— | الحقل خلف جسم الشاشة |
حقل واحد لكل شاشة، لا حقل لكل بطاقة. الحقل هو ما يجعل كل الألواح تتّفق على مصدر الضوء؛ حقلٌ لكل بطاقة يعطي كلّاً منها شمساً خاصة بها.
وطبقتان كحدٍّ أقصى. الثالثة تفقد الحوافّ تباينها ويصير كل شيء ضبابياً.
لماذا يرسمه المضيف ولا ترسمه أنت
الجسر لا يملك أدواته. الزجاج خمس طبقات، وثلاث منها لا تعبر dart_eval:
BackdropFilterوImageFilter.blurليسا في مفردات الجسر أصلاً — لا طمس.BoxDecorationيحملcolorوboxShadowفقط؛ وgradientمعلَّقة في الجسر، والمعامل غير المعلَن يُسقَط بصمت. فالصبغة المائلة تُترجَم نظيفةً ولا تصل Flutter أبداً — تحصل على مستطيل مسطّح بلا خطأ واحد، وvalidate.dartلا يمسكها لأن شيئاً لم يفشل.BoxDecoration(border: …)لا يُترجَم أصلاً — لا حافّة لامعة.
وهذه الوساطة هي الميزة لا الكلفة. الرسم في ثنائيّة المضيف، لا في
بايتكودك. فحين تعيد أوكتا تصميم المادة — لوناً، أو طمساً، أو حافّة — يصل
التغيير إلى كل تطبيق منشور عند أول إصدار مضيف: بلا إعادة بناء، ولا
إصدار جديد، ولا سطر تعدّله. ومن بنى الشكل بيده من ClipRRect وColoredBox
يبقى مجمّداً عند اليوم الذي شحن فيه.
package:okta_glassيتطلّبmin_contract: 16فأعلى. أعلِنه في البيان، وإلا فشلت الترجمة على جهازٍ يحمل مضيفاً أقدم.وميزةُ عقدٍ ما لا تصير حقيقية إلا حين يصل جهازَ المستخدم بناءُ مضيفٍ يحملها. رفعُ
min_contractقبل ذلك لا يجعل تطبيقك أحدث — يجعله يرفض العمل على كل جهاز في الميدان حتى ينزل التحديث من المتجر. تحقّق من عقد المضيف المنشور في لوحة الشريك قبل أن تعتمد عليه.
اللوحة — المصدر الوحيد للون
لا تكتب قيمة لونية حرفية أبداً. Theme.of(context).colorScheme لا يعبر
الجسر، فاللوحة هي طريقك الوحيد إلى هوية المضيف:
import 'package:okta_host/okta_host.dart';
import 'package:okta_kit/okta_kit.dart';
Widget main() => const HomeScreen();
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
// ابنِها مرّة عند الجذر ومرّرها لأسفل. لا تعِد بناءها في كل ودجت.
final palette = OktaPalette.of(Okta.isDark());
return Scaffold(
backgroundColor: palette.surface,
appBar: OktaAppBar.build('جدول اليوم', palette),
body: /* … */,
);
}
}
| الدور | الحقل | استعمله لـ |
|---|---|---|
| العلامة | palette.brand |
الأزرار الأساسية، التبويب المحدَّد، الأيقونة المميّزة |
| العلامة الخفيفة | palette.brandSoft |
أثر اللمس، خلفية حالة مرور |
| على العلامة | palette.onBrand |
نصّ وأيقونة فوق brand |
| الأرضية | palette.surface |
خلفية الشاشة — Scaffold.backgroundColor |
| السطح المرفوع | palette.surfaceRaised |
البطاقات والكتل التي تجلس على الأرضية |
| النص القوي | palette.textStrong |
العناوين والجسم |
| النص الخافت | palette.textMuted |
الوصف والبيانات الثانوية |
| الحدّ | palette.border |
الخطوط الشعرية |
brand ليس اللون نفسه في الوضعين: #6D428F في الفاتح و#A57BBA في الداكن.
بنفسجيّ العلامة المشبع فوق أرضية شبه سوداء يتوهّج ويؤذي القراءة. اقرأه من
اللوحة ولا تثبّته.
هذه القيم مرآةٌ لـ
OktaTokensفي تطبيق الجوال، ويحرسها اختبارokta_kit_palette_parity_test.dartهناك: إن انحرفت نسخةٌ عن الأخرى سقط البناء. فما تقرأه هنا هو ما يعمل على الجهاز فعلاً.
وصفات تعطيك شكل أوكتا رغم قيود الجسر
الطريق إلى شكل المضيف ليس نسخ ودجتاته — بل مطابقة أرقامه بالأدوات التي تعمل.
بطاقة مدوّرة. لا BoxDecoration(borderRadius:) — تُسقَط بصمت وترسم زوايا
قائمة. الزوايا من ClipRRect:
Widget card(OktaPalette palette, Widget child) {
return ClipRRect(
borderRadius: BorderRadius.circular(16), // radiusXl — نصف قطر البطاقة
clipBehavior: Clip.antiAlias,
child: ColoredBox(
color: palette.surfaceRaised,
child: Padding(
padding: const EdgeInsets.all(16), // space4
child: child,
),
),
);
}
خطّ شعري. لا Border.all — لا يُترجَم. الخطّ صندوقٌ بارتفاع واحد:
Container(height: 1.0, color: palette.border)
ارتفاع — وفخٌّ يستحقّ الانتباه. boxShadow مدعوم، لكن الظلّ والزاوية
المدوّرة لا يجتمعان: التدوير من ClipRRect وحده، والظلّ من BoxDecoration
وحده، وBoxDecoration بلا borderRadius. فظلٌّ خلف بطاقة مدوّرة يُرسم
مربّعاً ويظهر عند كل زاوية.
فإمّا سطح مسطّح مدوّر يفصله لونه (OktaSurface.card يفعل هذا)، وإمّا
OktaGlass.card الذي ينال الطمس والحافّة والظلّ معاً لأن المضيف يرسمه.
الظلّ اليدوي وحده يصلح لصندوق قائم الزوايا لا غير.
الزرّ الأساسي — يُبنى ولا يُضبَط. ElevatedButton وTextButton لا
يقبلان style على هذا المحرّك؛ المعامل غير معلَن في الجسر، فتلوينُك يُسقَط
بصمت ويصل الزرّ رمادياً افتراضياً. الزرّ الحامل لهويّتك يُركَّب:
Widget primaryButton(OktaPalette palette, String label, void Function() onTap) {
return GestureDetector(
onTap: () => onTap(), // إغلاق لا إحالة دالّة
child: ClipRRect(
borderRadius: BorderRadius.circular(999),
clipBehavior: Clip.antiAlias,
child: ColoredBox(
color: palette.brand,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Center(
child: Text(
label,
style: TextStyle(
color: palette.onBrand,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
);
}
استخدم ElevatedButton للفعل الثانوي الذي لا يهمّ لونه، وهذا للفعل الأساسي.
محاذاة النصّ. Text لا يقبل textAlign هنا، وTextAlign ليس نوعاً
مجسوراً أصلاً. المحاذاة من Center أو Align حول النصّ لا من داخله.
الأرقام التي تجعلك تبدو منّا. طابِقها ولا تخترع غيرها:
| القيمة | |
|---|---|
| نصف قطر البطاقة | 16 — وللكبسولة 999 |
| حشوة البطاقة | 16 · والمضغوطة 12 |
| إيقاع المسافات | مضاعفات 4: 4 · 8 · 12 · 16 · 24 · 32 |
| مقاس النص | 12 خافت · 14 ثانوي · 16 جسم · 18 عنوان قسم · 20 عنوان شاشة |
| الأوزان | 400 جسم · 500 تسمية · 600 عنوان — ولا 700 |
| مدد الحركة | 150 سريع · 220 أساسي · 320 بطيء |
الحركة. استخدم package:okta_motion — منحناها easeOutCubic هو منحنى
المضيف نفسه، فلا يبدو تطبيقك أسرع أو أبطأ من الشاشة التي فُتح منها:
import 'package:okta_motion/okta_motion.dart';
OktaMotion.entrance(index * 40, 220, card(palette, row))
entrance(delayMs, ms, child) هي التتابع نفسه الذي تدخل به بطاقات الرئيسية:
40ms بين الصفّ والذي يليه. ثبّت التأخير عند عشرة صفوف — ذيل جدول طويل لا
يجوز أن يصل متأخّراً بثانيتين.
الأيقونات. OktaIcons.* فقط. ثوابت Icons.* الاعتباطية غير مجسورة،
والأخطر منها أن التطبيق يُبنى بـ--no-tree-shake-icons لأن رموزك تُختار وقت
التشغيل: أيقونة خارج المجموعة قد تصل الجهاز مربّعاً فارغاً بلا خطأ.
اتجاه النص
المضيف عربيّ أولاً. AlignmentDirectional غير مجسور، فالاتجاه يأتي من
Directionality ومن OktaText:
final locale = Okta.locale();
Directionality(
textDirection: OktaText.isRtl(locale) ? TextDirection.rtl : TextDirection.ltr,
child: /* … */,
)
Text(OktaText.pick(locale, 'جدول اليوم', "Today's schedule"))
وقاعدة واحدة تُنسى دائماً: المعرّفات والمسارات وأرقام الإصدار تبقى LTR
مهما كانت لغة الواجهة — لُفّها بـDirectionality(textDirection: TextDirection.ltr)
وإلا انقلبت 04:A2:9F على الشاشة.
الوضع الداكن ليس اختيارياً
Okta.isDark() يتبع مظهر النظام على جهاز المستخدم. تطبيقٌ يقرأ
اللوحة مرّة عند الجذر يحصل على الوضعين مجاناً؛ وتطبيقٌ يكتب Color(0xFF...)
مرّةً واحدة يكون صحيحاً في وضع وخاطئاً في الآخر — وهذا أشيع سبب لرفض
المراجعة البصرية.
قائمة الفحص قبل النشر
- لا قيمة لونية حرفية في الكود — كلّها من
palette.* -
Scaffold.backgroundColor=palette.surface - البطاقات على
palette.surfaceRaised، والزوايا منClipRRectلاBoxDecoration - لا
Border.allولاBoxDecoration(gradient:)— الأولى لا تُترجَم والثانية تُسقَط بصمت - لا محاولة لبناء زجاج يدوياً — استخدم
OktaGlass(وmin_contract: 16) - لا ظلّ خلف زاوية مدوّرة — يُرسم مربّعاً
- لا
ElevatedButton(style:)ولاText(textAlign:)— كلاهما يُسقَط بصمت - المكوّنات من
OktaButton/OktaSurface/OktaList/OktaStatesلا مبنيّة يدوياً - الأيقونات من
OktaIconsحصراً - فُتِح في الوضعين الفاتح والداكن، وقُرِئ النصّ في كليهما
- المعرّفات والمسارات ملفوفة LTR
- الحالات الثلاث معالَجة صراحةً: فارغ، تحميل، خطأ
برومبت جاهز لمساعدك البرمجي
انسخه كما هو إلى Claude Code أو Cursor أو Codex قبل أن تطلب منه شاشة:
تبني تطبيقاً مصغّراً بلغة Dart داخل تطبيق أوكتا للجوال، يُترجَم على
الجهاز بـ dart_eval. التزم بالتالي حرفياً:
الهوية:
- كل لون من OktaPalette.of(Okta.isDark()) — لا Color(0xFF..) حرفي.
- الأرضية palette.surface، البطاقات palette.surfaceRaised،
النص palette.textStrong / palette.textMuted، الخطوط palette.border.
- نصف قطر البطاقة 16، الكبسولة 999، الحشوة 16، والمسافات مضاعفات 4.
- مقاسات النص 12/14/16/18/20، والأوزان 400/500/600 فقط.
- الأيقونات من OktaIcons فقط.
- المكوّنات من okta_kit ولا تبنِها يدوياً: OktaButton.primary/secondary،
OktaSurface.card/hairline/pill/iconTile، OktaList.row/sectionHeader،
OktaStates.loading/empty/failure، والأرقام من OktaMetrics.
- الحركة من package:okta_motion، ومدد 150/220/320.
قيود المحرّك — كلّ بند منها يكسر:
- BoxDecoration يحمل color و boxShadow فقط. borderRadius و gradient
تُسقَط بصمت: دوّر الزوايا بـ ClipRRect + ColoredBox.
- BoxDecoration(border:) لا يُترجَم: الخطّ الشعري Container(height: 1.0).
- لا BackdropFilter ولا ImageFilter. المادة من package:okta_glass:
OktaGlass.card / chrome / field / background (يتطلّب min_contract 16).
- الظلّ والزاوية المدوّرة لا يجتمعان — استخدم OktaGlass.card للارتفاع.
- الأزرار: ElevatedButton و TextButton فقط، و**لا تقبلان style**: ابنِ
الزرّ الأساسي بـ GestureDetector + ClipRRect + ColoredBox(palette.brand).
- Text لا يقبل textAlign: حاذِ بـ Center أو Align حوله.
- التخطيط: Row و Column و Expanded — ومرّر flex: دائماً. لا Wrap ولا
AlignmentDirectional.
- لا حلقات متداخلة إطلاقاً — سطّح البيانات في قائمة واحدة.
- الـ callbacks إغلاقات: onPressed: () => f() لا onPressed: f.
- أبقِ JSON dynamic من طرف لطرف؛ لا تُعطِ المستقبِل نوع Map.
- لا State.mounted، ولا Timer (لكن DateTime و Duration و Future.delayed
موجودة، فحلقة السحب استدعاء ذاتي بـ Future.delayed مع علم إيقاف).
- GestureDetector لا InkWell — و InkWell و InkResponse و SafeArea
تُترجَم ثم ترمي UnimplementedError.
ما يقتل التطبيق عند المستخدم — التزم به حرفياً:
- الهوية بالقيم القياسية: Okta.locale() / tenantId() / roleId() / isDark().
Okta.context() خريطة بمفاتيح snake_case، و .locale عليها غير موجود.
- Okta.uploadFile و location و preciseLocation تعيد **خرائط**:
['file_name'] و ['latitude'] — لا .fileName ولا .latitude.
- لا تقرأ مفتاحاً قد يغيب: اجعل الخادم يُصدِر كل حقل دائماً (null حين
لا قيمة). المفتاح الغائب يعطي null خاماً، و `is` عليه ترمي و `== null`
تكذب — ولا containsKey (جُرِّب ورُدَّ). OktaJson يحمي من الشكل لا الغياب.
- setState بجسم أقواس: setState(() { x = y; }) لا setState(() => x = y).
- لا تقرأ bool من قائمة مباشرةً: flags[i] == true.
- لا تُهيّئ عدّاد حلقة من معامل، ولا تمرّر عدداً عبر معامل إلى بانٍ مجسور.
- لا تعشّش الثلاثيّات — فرّع وأعِد.
- قبل أي نداء عقده أعلى من 5: احرس بـ Okta.contract() >= N أو أعلِن
minContract — النداء فوق العقد يُتلف المفسّر لبقية الجلسة ولا يفشل.
- list.sort() بلا مقارِن ترمي: مرّر المقارِن دائماً.
الاتجاه: عربي أولاً عبر Directionality و OktaText.isRtl، والمعرّفات
والمسارات تبقى LTR.
عالج الحالات الثلاث صراحةً: فارغ، تحميل، خطأ. وافحص res.ok، وميّز
status == 0 (لم يصل الخادم) عن ردّ خادم فاشل.
شغّل
dart run tool/audit_bridge_usage.dart lib/main.dartقبل النشر — يمسك المعاملات المُسقَطة بصمت والثلاث مصائد أعلاه قبل أن يمسكها المستخدم.
أداة
component_catalogفي خادم MCP تعرض المجموعة نفسها مُحدَّثة من المنصّة مباشرة — إن كان مساعدك موصولاً بها فاسأله قبل أن تفترض.
مثال: تطبيق «حضور بالمسح» (Native) — من الصفر إلى النشر
فصلٌ عملي واحد يبني تطبيقاً مصغّراً كاملاً بلغة Dart: مشرف يقف على البوّابة،
يمرّر بطاقات الطلاب، وكل قراءة تُسجَّل حضوراً — وتُحفَظ محلياً حين تنقطع
الشبكة. مثالٌ حقيقي لأنه أكثر ما يُبنى فعلاً، ولأنه يمرّ على كل ما يحتاجه
تطبيق native: الحالة، والصفحات، والأذونات، والمسح، والشبكة، والتخزين،
والحالات الثلاث، والهوية.
قبل أن تكتب سطراً: أربعة حدود تشكّل التصميم
هذه ليست تفضيلات. كلٌّ منها يغيّر ما الذي يمكن أن يكون عليه تطبيقك.
١. لا إدخال نصّي. لا TextField في السطح المجسور. تطبيق native هو
تطبيق قراءة وإجراء: يعرض، ويمسح، ويضغط، ويرسل. أي شاشة جوهرها أن يكتب
فيها المستخدم — نموذج تسجيل، بحث حرّ، تعليق — ليست تطبيقاً أصلياً؛ ابنِها
بوضع webview أو external. اكتشاف هذا بعد أسبوعين من العمل هو أغلى درس في
المنصّة، ولهذا هو أوّل سطر في الفصل.
٢. لا تنقّل. Navigator.of معلَن في الجسر بلا تنفيذ ولا إرسال ساكن، فلا
يمكن الحصول على NavigatorState من داخل الصندوق، ولا يوجد مكدّس مسارات أصلاً.
شاشتان داخل تطبيقك تعنيان حالة واحدة تحمل رقم الصفحة — النمط كاملاً أدناه.
٣. لا حلقات متداخلة. dart_eval 0.8.5 يرمي RangeError وقت الترجمة حين
تحوي حلقةٌ حلقةً أخرى — مباشرةً أو عبر دالة تستدعيها. سطّح بياناتك في قائمة
واحدة، على الخادم إن أمكن.
٤. الظلّ والزاوية المدوّرة لا يجتمعان. التدوير من ClipRRect وحده، والظلّ
من BoxDecoration وحده، والأخير بلا borderRadius. من أراد سطحاً مرتفعاً
مدوّراً فـOktaGlass.card — يرسمه المضيف أصلياً فينال الثلاثة معاً.
٠ · الهيكل والبيان
okta_app/native/attendance/
├── pubspec.yaml ← يثبّت okta_miniapp — لا ترفع الـ ref بنفسك
├── analysis_options.yaml
├── lib/
│ └── main.dart ← نقطة الدخول: Widget main()
└── tool/
└── validate.dart ← نفس بوابة الـ CI
والبيان:
"mobile": {
"supported": true,
"mode": "native",
"entry": "okta_app/native/attendance/lib/main.dart",
"minContract": 16
}
minContract: 16 لأننا نستخدم package:okta_glass. ولا شيء آخر تعلنه:
الوصول للعتاد لم يعد له مدخل في البيان بعد إزالة بوّابة الصلاحيات.
١ · الحالة، والفخّ الذي يكلّف يوماً
StatefulWidget وsetState يعملان. وفخّان:
setState(() { _busy = true; }); // جسم كتلة
setState(() => _busy = true); // يصندق مرّتين ويرمي
إغلاقٌ سهميّ يُعيد قيمة جسمه، والإسناد تعبير — فتُصندَق القيمة مرّة
للكتابة ومرّة للإرجاع، والثانية تسقط على مصندَق:
type '$bool' is not a subtype of type 'bool'. حوّل كلّ المواضع لا التي
تسقط فقط: ظهورُ العطب يتوقّف على تخصيص السجلّات لا على شيء يُرى في المصدر.
والثاني: لا State.mounted — غير مجسورة، وذكرُ اسمها لا يُترجَم. نادِ
setState مباشرةً.
٢ · الصفحات الداخلية — النمط، بلا Navigator
صفحةٌ رقمٌ في الحالة، والشريط يقرّر ما يعرضه، وزرّ الرجوع يرجع داخلياً ثم يسلّم للمضيف حين تنفد الصفحات:
class _AttendanceState extends State<Attendance> {
int _page = 0; // 0 = المسح · 1 = سجلّ اليوم
void _openLog() {
setState(() { _page = 1; });
}
// زرّ رجوع واحد لكل الصفحات. يرجع داخلياً ما دام هناك مكان يرجع إليه،
// ويسلّم للمضيف عند الصفحة الأولى — وهو المخرج الوحيد من التطبيق المصغّر.
void _back() {
if (_page != 0) {
setState(() { _page = 0; });
return;
}
Okta.close();
// «لا شيء» ردٌّ صحيح من المضيف على close (قد لا يكون هناك ما يُطوى)،
// فلا تعتمد عليه لإيقاف التنفيذ — ارجع فوراً بعده.
return;
}
AppBar _bar(OktaPalette palette) {
if (_page == 0) {
return OktaAppBar.build('الحضور', palette);
}
return OktaAppBar.withBack(
'سجلّ اليوم', palette, Okta.appIcon(22.0), () => _back());
}
}
لا تضع الأيقونة في
leadingعلى الصفحة الأولى. Flutter يُدرِج زرّ الرجوع فقط حين يكونleading == null، فـOktaAppBar.buildWithIconتحذف المخرج الوحيد بصمت.withBackتستعيد الخانة للزرّ وتُزيح الأيقونة إلىactions.
٣ · الإذن ثم المسح
اطلب القدرة عند لحظة استخدامها، لا عند فتح الشاشة: طلبٌ يصل ومعه سببٌ يراه المستخدم أمامه طلبٌ يستطيع الإجابة عليه، وجدارُ طلبات عند الإقلاع جدارٌ يُغلَق.
Future<void> _scan() async {
final key = await Okta.scanNfc();
if (key == null) {
// null يغطّي كل نهاية ليست بطاقة: ألغى المستخدم، أو الهاتف بلا NFC، أو
// رُفضت نافذة إذن النظام. لا شيء منها استثناء، ولا شيء منها يُعلَن كخطأ.
return;
}
await _submit(key);
}
٤ · الإرسال — وثلاث نهايات لا نهايتان
OktaApiResponse يحمل status وbody وerror وok المحسوبة. والفرق الذي
يُنسى: status == 0 يعني أن النداء لم يبلغ خادماً أصلاً. عرضُ «خطأ من
الخادم» عندها كذبٌ على المشرف، والخادم لم يقل شيئاً.
Future<void> _submit(String key) async {
final res = await Okta.post(
'/api/attendance/reads',
<String, dynamic>{'key': key},
);
if (res.ok) {
Okta.playSound('success');
// body ديناميكيّ من طرف لطرف — لا تُعطِ المستقبِل نوع Map.
final dynamic name = OktaJson.str(res.body, 'student_name');
setState(() { _log.add('$name · حاضر'); });
return;
}
if (res.status == 0) {
await _queue(key);
return;
}
Okta.playSound('error');
setState(() { _log.add('رُفض · ${res.status}'); });
}
٥ · الطابور المحلّي — والفشل الصامت
Future<void> _queue(String key) async {
await Okta.storePut('queued:$key', key);
Okta.playSound('warning');
// قُلها صراحةً. الالتقاط المحلّي يُؤكَّد قبل استشارة الشبكة — وهذا صحيح، به
// ينجو المسح من انقطاع — لكن معناه أن رفعاً لم يصل يبدو على الشاشة
// **مطابقاً تماماً** لرفع وصل. من يقف على بوّابة ينظر إلى الطالب لا إلى
// شارة في الترويسة، فالفرق يُقال بصوت ورسالة وسطر في السجلّ.
Okta.toast('حُفظ محلياً — لا شبكة. سيُرسَل عند عودتها.');
setState(() { _log.add('$key · بانتظار الشبكة'); });
}
Okta.storeKeys() وOkta.storeDelete(key) يفرغان الطابور لاحقاً.
التخزين ليس تخزيناً آمناً. عامِله كأنّ من يملك الجهاز يقرؤه: هو لطابور التقاط، لا لرمز مصادقة.
٦ · القائمة والحالات الثلاث
Widget _logPage(OktaPalette palette) {
if (_loading) {
return OktaStates.loading(palette);
}
if (_log.isEmpty) {
return OktaStates.empty(
OktaIcons.list(), 'لا قراءات بعد', 'مرّر بطاقة لتبدأ.', palette);
}
// حلقة واحدة، وبانٍ صفوف بلا حلقة داخله. حلقة داخل حلقة — مباشرةً أو عبر
// دالة تستدعيها — تُسقط الترجمة بـ RangeError.
final rows = <Widget>[];
for (final entry in _log) {
rows.add(OktaGlass.card(
OktaList.row(OktaIcons.check(), entry, 'اليوم', palette),
));
}
return ListView(
padding: const EdgeInsets.all(OktaMetrics.space4),
children: rows,
);
}
٧ · الهوية — بلا قيمة لونية واحدة
final palette = OktaPalette.of(Okta.isDark());
Scaffold(
backgroundColor: const Color(0x00000000),
appBar: _bar(palette),
body: OktaGlass.background( // حقل واحد للشاشة كلّها
_page == 0 ? _scanPage(palette) : _logPage(palette),
),
)
راجع «هوية أوكتا داخل التطبيق المصغّر» أعلاه للتفصيل. القاعدة هنا سطر واحد:
كل لون من palette، وكل رقم من OktaMetrics، وكل مكوّن من okta_kit —
فحين تعيد أوكتا تصميم المادة يتغيّر تطبيقك بلا أن تنشر شيئاً.
٨ · التحقّق والنشر
flutter test tool/validate.dart # بوابة الـ CI نفسها
dart run tool/audit_bridge_usage.dart lib/main.dart # يمسك المُسقَط بصمت
flutter analyzeسيشتكي أنOktaوOktaPaletteغير معرّفين — هذا متوقَّع. المكتبات تُحقَن من المحرّك وقت الترجمة وليست حزم pub.
ثم ادفع إلى فرع الإصدار وأعد التثبيت على sandbox — بلا إصدار جديد. ذاكرة
البايتكود على الجهاز مفتاحها (slug, entry, payloadVersion, runtimeSignature)
لا محتوى الحزمة، والتثبيت يعيد حلّ commit الفرع فيحرّك payloadVersion من
تلقائه. النسخة القديمة لا تبقى إلا إن أعدت التثبيت بلا دفع — وحينها لا شيء
جديد ليصل أصلاً. انظر «متى يصل تعديلك إلى الجهاز» أعلاه.
الملف كاملاً
import 'package:flutter/material.dart';
import 'package:okta_glass/okta_glass.dart';
import 'package:okta_host/okta_host.dart';
import 'package:okta_kit/okta_kit.dart';
Widget main() => const Attendance();
class Attendance extends StatefulWidget {
const Attendance({super.key});
@override
State<Attendance> createState() => _AttendanceState();
}
class _AttendanceState extends State<Attendance> {
int _page = 0;
bool _busy = false;
final List<String> _log = <String>[];
void _openLog() {
setState(() { _page = 1; });
}
void _back() {
if (_page != 0) {
setState(() { _page = 0; });
return;
}
Okta.close();
return;
}
Future<void> _scan() async {
if (_busy) {
return;
}
setState(() { _busy = true; });
final key = await Okta.scanNfc();
if (key == null) {
setState(() { _busy = false; });
return;
}
final res = await Okta.post(
'/api/attendance/reads',
<String, dynamic>{'key': key},
);
if (res.ok) {
Okta.playSound('success');
final name = OktaJson.strOr(res.body, 'student_name', key);
setState(() {
_log.add('$name · حاضر');
_busy = false;
});
return;
}
if (res.status == 0) {
await Okta.storePut('queued:$key', key);
Okta.playSound('warning');
Okta.toast('حُفظ محلياً — لا شبكة. سيُرسَل عند عودتها.');
setState(() {
_log.add('$key · بانتظار الشبكة');
_busy = false;
});
return;
}
Okta.playSound('error');
setState(() {
_log.add('رُفض · ${res.status}');
_busy = false;
});
}
AppBar _bar(OktaPalette palette) {
if (_page == 0) {
return OktaAppBar.build('الحضور', palette);
}
return OktaAppBar.withBack(
'سجلّ اليوم', palette, Okta.appIcon(22.0), () => _back());
}
Widget _scanPage(OktaPalette palette) {
return ListView(
padding: const EdgeInsets.all(OktaMetrics.space4),
children: <Widget>[
OktaGlass.card(
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
'قرّب بطاقة الطالب من الجهاز',
style: TextStyle(
color: palette.textStrong,
fontSize: OktaMetrics.textBody,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: OktaMetrics.space4),
_busy
? OktaButton.disabled('جارٍ…', palette)
: OktaButton.primary('ابدأ المسح', () => _scan(), palette),
],
),
),
const SizedBox(height: OktaMetrics.space3),
OktaSurface.cardTappable(
palette,
() => _openLog(),
OktaList.rowWithTrailing(
OktaIcons.list(),
'سجلّ اليوم',
'ما سُجّل منذ بداية الدوام',
OktaSurface.pill('${_log.length}', palette.brandSoft, palette.brand),
palette,
),
),
],
);
}
Widget _logPage(OktaPalette palette) {
if (_log.isEmpty) {
return OktaStates.empty(
OktaIcons.list(), 'لا قراءات بعد', 'مرّر بطاقة لتبدأ.', palette);
}
final rows = <Widget>[];
for (final entry in _log) {
rows.add(Padding(
padding: const EdgeInsets.only(bottom: OktaMetrics.space2),
child: OktaGlass.card(
OktaList.row(OktaIcons.check(), entry, 'اليوم', palette),
),
));
}
return ListView(
padding: const EdgeInsets.all(OktaMetrics.space4),
children: rows,
);
}
@override
Widget build(BuildContext context) {
final palette = OktaPalette.of(Okta.isDark());
return Directionality(
textDirection: OktaText.isRtl(Okta.locale())
? TextDirection.rtl
: TextDirection.ltr,
child: Scaffold(
backgroundColor: const Color(0x00000000),
appBar: _bar(palette),
body: OktaGlass.background(
_page == 0 ? _scanPage(palette) : _logPage(palette),
),
),
);
}
}
مثال: تطبيق تشغيل مراكز الرعاية النهارية (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). هذه ليست جزءاً من نظام إشعارات أوكتا، بل بنية الشريك الخاصة.
إن أردت توحيد الإشعارات مع منصة أوكتا مستقبلاً (ليختار المستأجر القنوات من لوحته)، يمكنك إضافة كتالوج إشعارات لتطبيقك — راجع قسم كتالوج الإشعارات.
خلاصة الخطوات
- أنشئ تطبيقاً External من البوّابة، اختر
daycare_centerكنوع مستأجر مستهدف. - اطلب
education.students.readمن picker النطاقات (وwriteإن احتجت). - اشترك في
education.students.created،education.students.updated،partner.installation.token_rotated. - أضف
redirect_urlsلو أردت تدفّق OAuth-style للمزامنة الأولية. - عند التثبيت: اجلب الطلاب كاملاً، ثم اعتمد على Webhooks للتغييرات.
- أرسل للمراجعة — فريق أوكتا يتحقق من الـ 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).
مصادر إضافية
- OpenAPI: https://partners.getokta.io/docs/openapi.json
- Postman: https://partners.getokta.io/docs/postman_collection.json
- Status page: getokta.io/status
- الدعم: partners@getokta.io