البداية
دليل مطوّر تطبيقات أوكتا
هذا الدليل يخاطب المطوّرين الذين يبنون تطبيقاً يتكامل مع منصة أوكتا. يفترض إلمام أساسي بـ 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" |
| مفوَّض من وليّ أمر | صفّ تفويض — لا صفّ عضوية له | general |
portal_tenant_id فقط |
"portal": "guardian-delegate" |
١. الموظّف — دور داخل الجهة
المعلّم والإداري يعيشان داخل الجهة: صفّ في tenant_employees يحمل
type (وظيفته)، وعضوية في الجهة، ودور. الثلاثة لازمة: العضوية تُظهِر الجهة
في منتقي السياق، والدور هو ما يُدخِله فعلاً.
والموظّف نوع واحد: تسجّله المدرسة معلّماً أو إدارياً — لا الاثنين، ولا
«موظّفاً» بجانب أحدهما، فكلاهما موظّف أصلاً — فيُمنَح دور teacher أو
administrator: دور بلا أي صلاحية، مفتاح باب لا أكثر. ما يحقّ له فعله يُقرَّر
بعدها بإلحاق صلاحيات بالدور أو بوضعه في مجموعة إدارية.
داخل تطبيقك هذا هو الحساب «العاديّ»: Tenant::current() مضبوطة، والنطاق
tenant، وكل ما تعرفه عن سياق الجهة صحيح.
المطابقة أوسع مما تظنّ. "roles": ["teacher"] تُطابَق على ثلاثة أشياء
معاً: الدور النشط، وكل الأدوار المختارة في الجلسة، وtenant_employees.type
نفسه. كلّها تُطبَّع قبل المقارنة (حروف صغيرة، والمسافات و_ تصير -)،
فـ"Teacher" و"teacher" سواء.
٢ و٣. الطالب ووليّ الأمر — بوّابتان لا دوران
هنا يتغيّر كل شيء. الطالب ووليّ الأمر ليس لهما صفّ دور إطلاقاً: اسم
البوّابة هو الجمهور نفسه. والنطاق general، وtenant_id في السياق فارغ،
والجهة تعيش في مفتاح آخر: portal_tenant_id.
وهذا ليس تفصيلاً في التسمية. حساب البوّابة لا يملك سياق جهة بالمعنى
المعتاد. المنصّة تُقلِع جهته لهذا الطلب وحده، وعلى صفحات التطبيق التي
أعلنها بيانك فقط؛ خارجها يبقى بلا جهة، وصفحات المنصّة المرتبطة بجهة تظلّ
ترفضه. فأيّ كود يفترض أن Tenant::current() موجودة دائماً سيسقط على صفحة
بوّابة.
ووليّ الأمر عابر للجهات: قد يكون له أبناء في مدرستين، فيختار الجهة عند الدخول — وتلك الجهة هي التي تحملها القائمة الجانبية وتُفتَح بها التطبيقات.
٤. المفوَّض من وليّ أمر — بوّابة مستقلّة، لا نكهة من وليّ الأمر
وليُّ الأمر يستطيع أن يفوّض شخصاً على شؤون أبنائه: قريباً يستلم طفلاً من البوّابة، أو سائقاً. والتفويض ليس حساباً موازياً لوليّ الأمر، بل شريحةٌ من صلاحياته هو:
- على أبناء بأعيانهم — يسمّيهم وليُّ الأمر واحداً واحداً، لا «كل أبنائي».
- بجزء من صلاحياته — يختار ما يُسلِّمه (النداء، متابعة الحضور…).
- وغالباً إلى تاريخ — تفويضٌ لأسبوع الاختبارات ينتهي بانتهائه.
والمفوَّض لا صفّ عضوية له إطلاقاً: لا tenant_guardians ولا
tenant_students. كلُّ وجوده في المدرسة صفُّ تفويض، فيختفي وحده حين ينتهي
التفويض أو يُسحَب أو يفقد وليُّ الأمر نفسه ارتباطه بالطالب.
ولماذا بوّابة مستقلّة ولم يُلحَق بوليّ الأمر: تطبيقٌ يخدم الآباء لا يخدم
بذلك كلَّ من سلّمه أبٌ مفتاحاً — عليه أن يقول إنه يخدمه. فلو دخل المفوَّض
بمفتاح guardian لطابق كلَّ جمهور وليّ أمر منشور اليوم بلا أن يعلن أحدٌ
شيئاً. والثمن صريح: تطبيقك لا يبلغ المفوَّضين حتى تُعلن هذا الجمهور وتنشر.
"account_types": [
{ "key": "guardian", "kind": "dependent", "portal": "guardian",
"web_route": "call.family", "mobile_entry": "okta_app/webview/family.html" },
{ "key": "guardian-delegate", "kind": "dependent", "portal": "guardian-delegate",
"web_route": "call.delegate", "mobile_entry": "okta_app/webview/delegate.html" }
]
والمنصّة تفرض الحدود، وأنت ترسمها. لا يقع عليك أن ترفض: نداءات قراءة
الطلاب في سياق مفوَّض مقيَّدة خادمياً بأبناء تفويضه — ListStudents تعيد
هؤلاء وحدهم، وGetStudent لطالب خارجهم تجيب بالـ404 نفسه الذي يجيب به معرّفٌ
مجهول (كي لا يتعلّم المفوَّض من الرفض من غيرُهم مسجَّل)، وقائمة أبناء وليّ
الأمر الذي فوّضه تعود بأبناء التفويض لا بعائلته كلّها. وتفويضٌ انتهى لا
يصل شيئاً: يُقيَّد إلى لا أحد، لا يُفكّ قيده.
وما ترسمه أنت هو الزرّ: $viewer في سياق مفوَّض يحمل
| ما تقرؤه | ماذا يعني |
|---|---|
$viewer->type / accountType |
guardian_delegate |
$viewer->isDelegate() |
true — وهو الفرع الذي ترسم عليه شاشة المفوَّض |
$viewer->delegatedChildren() |
[{id, capabilities, expiresAt}] — وid هو نفس مُعرّف الطالب الذي تعيده خدمات الطلاب، فتربط عليه مباشرةً |
$viewer->delegatedChildIds() |
المعرّفات وحدها، لترشيح صفوفك أنت |
$viewer->mayForChild($childId, 'okta_hdor.pickup.call') |
هل يحقّ له هذا الفعل لهذا الابن اليوم |
@if ($viewer->isDelegate())
@foreach ($viewer->delegatedChildren() as $child)
{{-- ارسم البطاقة، وأظهر زرّ النداء لمن يملكه وحده --}}
@if ($viewer->mayForChild($child['id'], 'okta_hdor.pickup.call'))
<x-button wire:click="call('{{ $child['id'] }}')">{{ __('نداء') }}</x-button>
@endif
@endforeach
@endif
delegatedChildren()لا تكون فارغة أبداً في سياق مفوَّض: مفوَّضٌ لم يبقَ له شيء ساري يُجاب بـ$viewer === nullكما يُجاب وليُّ أمرٍ فُكّ ارتباطه — فهي قائمةٌ تُرسَم، لا قائمةٌ تُفحَص هل هي فارغة.expiresAtتاريخ انتهاء التفويض (أوnullلتفويض بلا نهاية) — اعرِضه: «ينتهي الخميس» يشرح للمستخدم اختفاءَ الزرّ بعد غد.mayForChildللرسم، وليست هي ما يمنع: المنصّة ترفض الفعل خارج التفويض بنفسها، فشاشةٌ نسيت الفحص لا تستطيع أن تُنفّذ. ولمن ليس مفوَّضاً تجيبfalseدائماً — فاحرس شاشة وليّ الأمر بكونه وليَّ أمر لا بها.
القدرات كلُّها من عندك — المنصّة لا تُعلن واحدة
كل قدرة يستطيع وليُّ الأمر أن يسلّمها لمفوَّض يعلنها تطبيقٌ مثبَّت. لا توجد قائمة منصّة، ولن تجد ما تختار منه.
وكانت توجد: تسع قدرات ثابتة (pickup_student، view_marks، pay_fees
وأخواتها) تُعرَض على كل وليّ أمر في كل مدرسة. ولا يقرؤها شيء — لا شاشة ولا
نقطة نهاية ولا وسيط في المنصّة سأل يوماً هل يحمل المفوَّض واحدةً منها. القارئ
الوحيد كان، ولا يزال، mayForChild() في تطبيقٍ مثبَّت. وأوّل تطبيقٍ احتاج
قدرةً أعلن قدرته بنفسه بدل أن يستعمل pickup_student — فصار مربّعان لفعل
واحد، ولا يعرف الوالد أيَّهما تحترمه المدرسة. فالمفردات صارت لمن يفرضها.
ما يعنيه لك عملياً: جهةٌ لا يُعلن أيُّ تطبيق مثبَّت فيها قدرةً لا تعرض على وليّ الأمر أيّ مربّع، والتفويض يبقى معناه «هذا الشخص يصل هؤلاء الأبناء». فإن أردت أن يستطيع وليُّ الأمر تسليم شيءٍ من تطبيقك، أعلِنه — وإلا فلا شيء يُسلَّم.
فأعلِنها في manifest.json، توأماً عائلياً لـrbac_permissions:
"guardian_capabilities": [
{ "key": "okta_hdor.pickup.call",
"label": { "ar": "إجراء النداء", "en": "Place the pickup call" } },
{ "key": "okta_hdor.attendance.read",
"label": { "ar": "عرض حضور الابن", "en": "View attendance" },
"gates": ["okta_hdor.attendance.read"] }
]
- المفتاح
<feature>.<resource>.<action>وfeatureهو slug تطبيقك بصيغة snake (okta-hdor→okta_hdor) — نفس قاعدةrbac_permissionsوبنفس الحدّة. والسبب ليس ترتيباً: المفتاح يُخزَّن على تفويض الأسرة ويُقرأ بحسب التطبيق، فإعلانه في مجال تطبيق آخر يضع خانةً في شاشة وليّ الأمر نيابةً عن شريك آخر، تحت عنوانٍ يقرؤه الوالد على أنه من المنصّة. المفتاح خارج مجالك يُرفض بالاسم. label.arإلزامي — هو كلُّ ما يقرأه وليُّ الأمر بجانب الخانة، وخانةٌ بلا عنوان لا يستطيع أحد أن يضغطها بصدق. وlabel.enيسقط إلى العربية.- المدرسة لا تمنحها ولا تمنعها ولا تُستأذَن فيها. وليُّ الأمر يحملها كلَّها بصفته وليَّ أمر في تلك الجهة، والقرار الوحيد قراره هو: أيّها يسلّم. والحدّ الذي بقي هو ارتباط وليّ الأمر بالابن نفسه — ابنٌ لم يعد وليُّ أمره يصله يسقط من التفويض كلّه.
- والمدرسة تقرأ ما جرى ولا تتدخّل فيه: صفحة «المفوَّضون» في لوحة الجهة تسرد من فوّض ومن فُوِّض وعلى أيّ أبناء وبأيّ قدرات وإلى متى — قراءةٌ فقط.
- وتظهر لوليّ الأمر مجموعةً باسم تطبيقك في شاشة التفويض، عند كل جهة ثبّتته. والحدّ يبقى: التفويض نفسه، وتاريخ انتهائه، والأبناء المسمَّون فيه.
واقرأها وقت التشغيل بالدالة نفسها:
if ($viewer->mayForChild($childId, 'okta_hdor.pickup.call')) {
// ارسم زرّ النداء لهذا الابن
}
- والمفوَّض لا يُفوَّض على نفسه: مفتاحٌ يسمّي ابناً حسابُه هو حسابُ المفوَّض يُرفض عند الإنشاء وعند التعديل، ولا يُعرَض في منتقي الأبناء أصلاً.
وgates اختيارية، وتركها فارغةً هو الحال العادي: تقرأ القدرة بنفسك وترسم
عليها، ولا يتغيّر شيء لأي تطبيق منشور. وحين تسمّي فيها نطاقاتك أنت، تنتقل
بوّابة الرفض إلى المنصّة: نداءٌ يستعمل ذلك النطاق في سياق مفوَّض لم يُمنح
القدرة يُرفض قبل أن يصل كودك. اختيارٌ لكل قدرة على حدة، فلا شيء سبق نشره
يتبدّل سلوكه.
والكتلة تصل الإصدار من مستودعك بزرّ «مزامنة من manifest» أو بأداة
sync_from_manifest، وتُقرأ وتُكتب أيضاً بـget_guardian_capabilities و
set_guardian_capabilities. وغائبةً لا تُمَسّ: مستودعٌ توقّف عن ذكر المفتاح
لا يسحب بصمت قدراتٍ عُرضت على الأسر فعلاً.
كيف تُعلن جمهورك
تبويب «أنواع الحسابات» في محرّر الإصدار — أو 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. شكل المقاطع يحمل معنى، فلا تكتبه اعتباطاً.
الهدف الثالث: permissions[] — من منحته المدرسة، لا من خمّنتَ دوره
أسماء الأدوار في roles[] تخمينك لما تسمّيه المدارس، والمدرسة تنشئ أدوارها
المخصّصة وتربط بها موظّفيها: «محاسبة»، «شؤون الموظفين»، «مشرف الحضور». فموظّفةٌ
نوع حسابها administrator وأُعطيت صلاحياتِ الإدارة عن طريق دور مخصّص كانت
تُوجَّه إلى شاشة الإداريين نفسها، لأن المشغّل كان يختار المسار بالدور وحده.
فصار للجمهور هدفٌ ثالث: الصلاحية.
"account_types": [
{ "key": "management", "kind": "primary", "permissions": ["okta_hr.management.view"], "web_route": "okta-hr.admin" },
{ "key": "administrator", "kind": "dependent", "roles": ["administrator"], "web_route": "okta-hr.staff" }
]
- يطابق من يحمل أيّاً من الأسماء المذكورة داخل الجهة — تقرؤها المنصّة من الجداول وقت الطلب، سواء جاءت من دوره الأساسي أو من دور مخصّص أنشأته المدرسة وربطته به. لا يعنيك اسم الدور ولا اختلافه بين مدرسة وأخرى.
- الأسماء صلاحياتك أنت بصيغتها الكاملة
<feature>.<resource>.<action>، وأعلِنها فيrbac_permissionsكذلك — وإلا لم تستطع المدرسة منح أحد إياها. - الترتيب هو القرار: المشغّل يربط البطاقة بـأول جمهور مطابق (الأساسي قبل التبعي، ثم ترتيب الإعلان). فضَع جمهور الصلاحية قبل جماهير الأدوار ليغلب عند من يطابق الاثنين.
- هدف مستقلّ: لا يجتمع مع
roles[]ولا معportal(مستخدم البوّابة لا يحمل صلاحية في الجهة أصلاً)، ويحتاجweb_route. - أوكتا ويب وحدها: تطبيق أوكتا يطابق بطاقاته بالدور والبوّابة، وحاملُ
الصلاحية يصله سطحُ الموظّفين العامّ هناك — فلا
mobile_entryعلى هذا النوع.
وهذا لا يُغني عن can() داخل الشاشة: الجمهور يقرّر أين يدخل، والصلاحية
تقرّر ما يراه ويفعله بعد الدخول.
نوع الجهة: entity_types — شاشة مدير المجمّع غير شاشة مدير المدرسة
مدير الجهة في مجمّع يدير مدارس، ومدير الجهة في مدرسة يدير طلاباً: نوع الحساب
واحد (tenant-admin) والشاشة ليست واحدة. وكان الطريق الوحيد حزمةً واحدة تسأل
عند الفتح عن نوع الجهة التي هي فيها ثم تتفرّع — فتُنزَّل شاشة المجمّع وتُترجَم
على هاتف كل مدرسة. الآن تُعلن صفّين للنوع نفسه، والمنصّة تختار بنوع الجهة
التي يقف فيها صاحبه:
"account_types": [
{ "key": "tenant-admin", "roles": ["tenant-admin"],
"web_route": "okta-hr.dashboard",
"mobile_entry": "okta_app/native/main/lib/main.dart" },
{ "key": "tenant-admin", "roles": ["tenant-admin"],
"entity_types": ["complex", "education_company"],
"web_route": "okta-hr.group",
"mobile_entry": "okta_app/native/group/lib/main.dart" }
]
وفي manifest.json هو entityTypes على الجمهور، في menu.audiences[]
وmobile.audiences[] كليهما:
{ "key": "tenant-admin@complex+education_company", "kind": "primary",
"roles": ["tenant-admin"], "entityTypes": ["complex", "education_company"],
"entry": "okta_app/native/group/lib/main.dart" }
- الأضيق يغلب لا الأسبق. حيث ينطبق الصفّ المقيَّد يغلب صفَّ النوع نفسه بلا أنواع أيّاً كان ترتيب الإعلان، والصفّ بلا أنواع يخدم كل جهة أخرى. فالشكل المعتاد صفّان: عامّ لكل الجهات، ومقيَّد حيث تحتاج شاشةً خاصّة.
- بلا
entity_types= كل أنواع الجهات، وهو حال كل تطبيق نُشر قبل الحقل: لا يتغيّر بيانه بايتاً، ولا يتغيّر ما يراه أحد. - المفردات مغلقة:
individual_teacher،school،complex،college،university،institute،academy،education_company،daycare_center. والاسم المجهول يُرفَض بالاسم ولا يُسقَط: إسقاطه يضيّق الصفّ بصمت، وإسقاط آخر اسمٍ فيه يوسّعه إلى كل الجهات — عكس ما كُتب تماماً. - للموظّفين وحدهم: على
rolesوpermissions. ويُرفَض على البوّابة: الطالب ووليّ الأمر والمفوَّض عابرون للجهات (أبناءٌ في مدرستين)، ولا يُختار جمهورهم بالمكان الذي يقفون فيه. - نوع حساب واحد في نوع جهة واحد = شاشة واحدة: صفّان للنوع نفسه يسمّيان النوع نفسه من الجهات يُرفضان على السطح نفسه، لأن الثاني شاشة لا يصلها أحد هناك. والسطحان مستقلّان: يجوز أن تضيّق أوكتا ويب ولا تضيّق تطبيق أوكتا.
- المفتاح
<الدور>@<الأنواع>(tenant-admin@complex+education_company): يشتقّه المحرّر وset_account_typesلأن النوع الواحد صار صفّين. ومعset_account_typesمرّر الدور وحده أو المفتاح المشتقّ. - نوع الجهة يُقرأ من سجلّها لا من الطلب: لا ترويسة ولا معامل يغيّر أيّ شاشة تُفتح. وجهةٌ نوعها مجهول للمنصّة (سجلّ أقدم من المفردات) لا يبلغها إلا الصفّ العامّ.
- بلا رفع عقد ولا تحديث لتطبيق أوكتا: الخادم يختار، والهاتف يستلم مدخلاً
واحداً كما يستلمه اليوم. ولكل صفّ مقيَّد مدخله وأرضيته (
min_contract) وصفوفversions[]الخاصة به. - وحامل الصلاحية (من يدخل بصلاحية من تطبيقك لا بدوره) يُحسَب في الجهة التي يقف فيها: صفٌّ للمجمّعات لا يسلب المدارس شاشتها الوحيدة.
والمحاكي يعمل على بيئة تجربة من نوع «مدرسة»، فيركّب مدخل المدرسة ويسرد الصفوف المقيَّدة بأسمائها. جرّب شاشة المجمّع على بيئة تجربة من نوع «مجمع تعليمي» (يُختار النوع عند تجهيز البيئة) وادخل إليها من الهاتف.
الفخّ: يُنشَر بنجاح ثم لا يعمل أبداً
مفتاح بوّابة داخل 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 الجهة
$viewer->accountType; // tenant_admin | administrator | teacher | employee | student | guardian
$viewer->groups; // [{key, title}] المجموعات الإدارية التي صنعتها المدرسة — للعرض
$viewer->hasStaffRecord(); // هل للشخص صفّ في دليل موظفي الجهة؟
موظّفٌ بلا صفّ في دليل الموظفين — hasStaffRecord()
في كل جهة موظّفون لم تُدخلهم المدرسة في دليلها: الحساب الذي سجّل المدرسة
(لا أحد يضيف المالك إلى الدليل)، ومن أُلحق بالجهة مباشرةً، وحسابا بيئة
التجربة اللذان تعمل عليهما أنت. هؤلاء يصلك عنهم viewer كامل — نوع الحساب،
والمجموعات، والصلاحيات، والجهة — لكن id يعود فارغاً و
hasStaffRecord() تجيب false، لأن لا صفّ في الدليل يُربَط به.
فاقرأها قبل الربط على id وحده؛ أما can() وaccountType فتُسألان بلا
تفكير. (قبل هذا كان الجواب null عن هؤلاء جميعاً، فكانت الواجهة ميّتة في
بيئة التجربة نفسها.)
لا تستعمل 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: مُشاهِد يستطيع المُنادي تسميته هو مُشاهِد يستطيع تزويره.
نوع الحساب شيء، وما منحته المدرسة شيء آخر
هذا أكثر ما يُخلَط في التكامل، فيُقال صراحةً. أمام شاشتك شخصان قد يبدوان واحداً: إداريّ وشخص مُنح صلاحيات تحت مسمّى «مدير الموارد البشرية». الأول نوع حساب — كلمة المنصّة، ومعناها واحد في كل مدرسة. والثاني مجموعة صلاحيات صنعتها هذه المدرسة في محرّر أدوارها، وسمّتها كما شاءت، ووضعت فيها ما شاءت من الصلاحيات — والمدرسة التالية تسمّي الوظيفة نفسها اسماً آخر، أو لا تملكها أصلاً.
| نوع الحساب | مجموعة الصلاحيات / الصلاحية | |
|---|---|---|
| من يقرّره | المنصّة — مفردات مغلقة | المدرسة — تخترعه وتسمّيه |
| القيم | tenant_admin (مدير الجهة)، administrator (إداري)، teacher (معلّم)، student، guardian |
«مدير الموارد البشرية»، «مشرف الحضور»… بلا حصر |
| كم واحداً للشخص | واحد — الموظّف إمّا معلّم وإمّا إداري، وكلاهما موظّف أصلاً | أيّ عدد، فوق نوع الحساب |
| أين تقرؤه | $viewer->accountType |
$viewer->groups للعرض، و$viewer->can() للقرار |
| ما تبني عليه | الشاشة — بوّابة الأسرة أم سطح الموظّفين، وجمهور audiences[] |
الفعل — هل يرى هذا الزرّ، هل يستطيع هذا الحفظ |
المجموعة ليست حساباً ولا نوع حساب
المستخدم يدخل بحساب واحد. مجموعة الصلاحيات لا تُنشئ حساباً ولا نوع حساب؛
هي تضيف صلاحيات فوق ما يحمله. فالمحاسِبة تدخل بحسابها، ونوع حسابها
administrator كأي إداري، والمجموعة أضافت لها ما أضافت. فلا تسأل «بأي حساب
دخل؟» — اسأل «لماذا يحمل هذه الصلاحية؟»، وللسؤال جواب جاهز في الجدول
أدناه.
ولذلك تحمل الـDTO حقلين لا واحداً، ولكلٍّ سؤاله:
$viewer->type |
$viewer->accountType |
|
|---|---|---|
| يجيب عن | الهويّة التي دخل بها | نوع الحساب الثابت |
| القيم | employee · student · guardian |
tenant_admin · administrator · teacher · employee · student · guardian |
| يُستعمل لـ | الفصل الكبير: بوّابة أسرة أم سطح موظّفين (isStaff() / isPortal()) |
أيّ سطحٍ من أسطح الموظّفين، وإعلان audiences[] |
وبأشخاص حقيقيين:
| الشخص | type |
accountType |
|---|---|---|
| مديرة المدرسة | employee |
tenant_admin |
| إداري | employee |
administrator |
| إدارية في مجموعة «محاسبة» | employee |
administrator — المجموعة لا تغيّرها |
| معلّم | employee |
teacher |
| موظّف لم تُسجَّل صفته | employee |
employee |
| طالب | student |
student |
| وليّ أمر | guardian |
guardian |
tenant_adminتعلو على غيرها: من يدير الجهة يبقى كذلك مهما حمل.employeeليس نوعاً ثالثاً للموظّف: صفٌّ قديم لم تُسجَّل له صفة. عامله موظّفاً عامّاً وأظهر له ما تسمح به صلاحياته، لا صنفاً خاصّاً.- والدور الذي دخل به شيء ثالث: صفحة اختيار السياق تعرض بطاقةً لكل دور
يحمله الشخص في الجهة — ومجموعة الصلاحيات صفُّ دور، فهي بطاقة مثل غيرها —
ويضغط واحدة، فتكتبها المنصّة تحت اسمه طوال الجلسة. تقرأه في
$viewer->activeRole؛ راجع «الباب الذي دخل منه» أدناه، ولا تخلطه بنوع الحساب: الأول يتغيّر بين جلستين للشخص نفسه، والثاني لا يتغيّر.
الصلاحية لا اسم الدور — الدوال التي تفرّق لك
المشكلة التي جاءت هذه الدوال تحلّها: تطبيقٌ يفرّع على «إداري» يعرض شاشات
الإدارة لكل من نوع حسابه administrator — ومنهم المحاسِب الذي وضعته المدرسة
في مجموعة سمّتها «محاسبة» وأعطته صلاحية واحدة. ولا يستطيع التطبيق أن يفرّع
على «محاسبة» لأن المدرسة التالية تسمّيها «المالية» أو لا تملكها. الجواب
الوحيد الثابت عبر المدارس هو الصلاحية، ولذلك يحمل ViewerDto الآن صلاحيات
تطبيقك التي يحملها هذا الشخص في هذه الجهة — مقروءةً من الجداول لا من
الجلسة، فتجيب على الويب وفي الجوال وفي مهمّة طابور سواء — ومعها مصدر كل
صلاحية:
| الدالة | تجيب عن |
|---|---|
$viewer->permissions |
قائمة صلاحيات تطبيقك التي يحملها (<feature>.<resource>.<action>) |
$viewer->can('x.y.view') / canAny([...]) / canAll([...]) |
هل يحملها — هذا ما تبني عليه كل زرّ وكل شاشة داخلية |
$viewer->accessLevel() |
full (مدير الجهة) · granted (يحمل شيئاً من تطبيقك) · none |
$viewer->isTenantAdmin() / isStaff() / isPortal() / hasGroups() |
من هو، بلا مقارنة نصوص |
$viewer->permissionSources |
خريطة: الصلاحية ← الأدوار التي تحملها (administrator دور أساسي، group.accounting مجموعة صلاحيات، direct منحة مباشرة) |
$viewer->grantedBy('x.y.view') |
لماذا يحملها — أيّ أدوار |
$viewer->grantedByBaseRole('x.y.view') |
يحملها لأنه إداري/معلّم (منحة الدور الأساسي من rbac_permissions) |
$viewer->grantedByGroupOnly('x.y.view') |
يحملها فقط لأن المدرسة أعطتها لمجموعته أو له مباشرة |
$viewer->groupOnlyPermissions() |
ما أضافته هذه المدرسة فوق نوع الحساب |
ViewerDto::isBaseRole($key) / isGroupRole($key) |
هل هذا المفتاح دور منصّة أم مجموعة مدرسة |
مفاتيح المجموعات: مجموعة الصلاحيات مفتاحها
group.<slug>. وكانت تُسمّىadministrator.<slug>فتصادمت الكلمة مع نوع الحسابadministrator، فيعود الشخص إدارياً ومجموعتُه إدارية ولا يعرف قارئ الأدوار أيّهما يقصد. المجموعات القديمة أُعيدت تسميتها، وisGroupRole()تقبل الصيغتين — فلا تقارن البادئة بنفسك، اسأل الدالة.
كيف تُصمَّم شاشتك على ذلك — مثال «الإداري يرى ملفه، والمحاسبة ترى الإدارة»:
- أعلن صلاحياتك في
rbac_permissionsوامنح الدور الأساسي أقلّ ما يلزمه؛ ما تريده بيد المدرسة لا تمنحه لدور أساسي:
المدرسة تفتح محرّر أدوارها وتضع"rbac_permissions": { "administrator": ["okta_hr.profile.view"], "tenant-admin": ["okta_hr.profile.view", "okta_hr.management.view"] }okta_hr.management.viewفي مجموعة «محاسبة» — أو «المالية» — بالعنوان الذي كتبته فيrbac_labels. - افحص الصلاحية لا الدور:
المحاسِب — وهو$viewer = app(GetCurrentViewer::class)(); if ($viewer->isPortal()) { return view('okta-hr::family'); // الشاشة: نوع الحساب } $showProfile = $viewer->can('okta_hr.profile.view'); // الإداري (منحة أساسية) $showManagement = $viewer->can('okta_hr.management.view'); // «محاسبة» أو «المالية» أو مدير الجهة if ($viewer->accessLevel() === 'none') { return view('okta-hr::no-access'); }administratorأيضاً — يرى ملفه والإدارة: الأول لأنه إداري والثاني لأن مدرسته قالت ذلك. إن أردت شاشةً تعرض ما أضافته المدرسة وحده فـgroupOnlyPermissions()تعطيك إياه؛ لكن لا تخفِ ما يحمله الشخص بحقّ لأن مصدره الدور الأساسي. - لا تقارن أبداً
$viewer->groups[*]['key']ولا['title']ولا$viewer->rolesبنصّ. الفاحص لن يمسكها كلها؛ المدرسة الثانية ستمسكها.
وauth()->user()->can() تعتمد على الحارس: تجيب false على أي طلب لا مستخدمَ
مربوطاً عليه — وهو حال أي مسار جوال بلا app.installation. ومع الوسيط تجيب،
لكن $viewer->can() تبقى ما تسأله: جوابها واحد على الويب والجوال والطابور،
ولا يتوقّف على حارسٍ لا تملكه.
إن رجعت
permissionsوpermissionSourcesفارغتين لشخص يحملها فعلاً — وخاصّةً إن كانتauth()->user()->can()تقول نعم بينما$viewer->can()تقول لا — فاسأل$viewer->hasAppContext()قبل أن تلمس المنح.القائمة مُرشَّحة إلى تطبيقك، فإن لم يحمل الطلبُ سياقَ التطبيق فلا «تطبيقك» هناك، والقائمة فارغة بالبناء مهما ملك الشخص — وهي تبدو تماماً كمن لا يملك شيئاً، وهذا بالضبط ما كان يرسل الجميع يفتّشون في منحٍ سليمة. و
accessLevel() === 'none'عندئذٍ ليست حكماً على الشخص: لم يُسأل عنه شيء.
hasAppContext()تعودfalse⇐ المسار بلا وسيط يبني السياق، والوسيطُ يختلف بين السطحين — راجع الجدول أدناه. وتعودtrueوالقائمة فارغة ⇐ المنح فعلاً، فاطلب من فريق المنصّةphp artisan partners:viewer-permissions <slug> --user=<معرّفه> --tenant=<id>.
أيّ وسيط يبني السياق — الويب شيء والتوكن شيء آخر
module.context:<slug> وسيط ويب حصراً. يقرأ الجهة من Tenant::current()
(أو جهة البوّابة من الجلسة)، ونداء التوكن بلا جلسة فلا جهة — فيردّ 403
no_tenant_context قبل أن يصل مسارك. وضعُه على مجموعة مسارات الجوال يوقف كل
نداءات التطبيق المصغّر. (كانت هذه الفقرة تقول «ضعه على مسار الصفحة وعلى كل ما
تخدمه Livewire منها» بلا أن تقول إنه للويب وحده — وهو ما أوقع فريقاً في ذلك
بالضبط.)
| السطح | ما يُصادِق | الوسيط |
|---|---|---|
| صفحات مودولك على الويب (Livewire) | جلسة المستخدم | module.context:<slug> — على مسار الصفحة وعلى كل ما تخدمه Livewire منها |
| نقاطك التي يناديها تطبيقك المصغّر على الجوال | توكن المستخدم (Sanctum) | app.installation |
| خادمك أنت (من خارج المنصّة) | توكن تثبيت (bearer) | app.installation |
// مجموعة مسارات الجوال — لا module.context هنا
Route::middleware(['api', 'app.installation'])
->prefix('api/__MODULE_SLUG__')
->group(function () {
Route::get('/me', [MeController::class, 'show']);
});
ولا ترويسة تكتبها أنت: المضيف يرسل مع كل نداء من التطبيق المصغّر
X-Module-Slug وX-Okta-Tenant-Id وX-Okta-Role-Id بجانب توكن المستخدم،
وapp.installation يحلّ التثبيت من (الجهة، الـslug) من طرف الخادم، ويعيد
فحص بوّابة البطاقة للدور النشط، ثم يضبط ModuleContext. فتعمل بعدها
GetCurrentViewer كاملةً على الجوال: accountType وpermissions وcan()
وhasAppContext() وactiveRole.
التوكن الذي يصلك توكن مستخدم لا توكن تثبيت. توكن التثبيت اعتماد خادمي على مستوى الجهة ولا يُشحن إلى هاتف أبداً. لذلك الهويّة من المستخدم والصلاحية من التثبيت — تطبيقك المصغّر لا ينال أكثر ممّا مُنح تطبيقُك.
فالقاعدة العملية:
- لا تفرّع أبداً على مفتاح مجموعة أو عنوانها.
group.hrو«مدير الموارد البشرية» مفردات مدرسة واحدة. تطبيقٌ يكتبif ($viewer->groups[0]['title'] === 'مدير الموارد البشرية')يعمل في مدرسة ويصمت في التي بعدها. المجموعة تصلك كي تعرضها بجانب الاسم، لا أكثر. - ما تشتريه المجموعةُ يجيب عنه
can()وحده. المدرسة وضعت في «مدير الموارد البشرية» صلاحية__MODULE_LOWER__.reports.view؟ فاسأل عن الصلاحية لا عن المجموعة — وستعمل الإجابة في المدرسة التي أعطت الصلاحية نفسها لمجموعة اسمها «الوكيل». - نوع الحساب واحد وثابت، فابنِ عليه الشاشة وحدها. الموظّف معلّم أو إداري
— لا الاثنين — ومدير الجهة يعلوهما إن حمل الصفة؛ و
employeeيصل فقط لصفّ قديم لم يقرّر أحد نوعه، فعامله كموظّف بلا نوع لا كنوع ثالث.
$viewer = app(GetCurrentViewer::class)();
// الشاشة: نوع الحساب
if ($viewer->accountType === 'guardian') {
return view('__MODULE_SLUG__::family');
}
// الفعل: الصلاحية — لا المجموعة ولا الدور
$canApprove = $viewer->can('__MODULE_LOWER__.leave_requests.decide');
// لماذا يحملها — حين يهمّ: منحة أساسية أم شيء أضافته المدرسة
$viewer->grantedByGroupOnly('__MODULE_LOWER__.leave_requests.decide');
// العرض فقط: ما تسمّيه المدرسة
$badge = $viewer->groups[0]['title'] ?? null; // «مدير الموارد البشرية»
الباب الذي دخل منه — activeRole
الحقول الثلاثة أعلاه عن الشخص: roles مجموع ما يحمله في الجهة،
وpermissions مجموع ما تحمله تلك الأدوار. وهذا الحقل عن الجلسة: الدور
الواحد الذي ضغطه على صفحة اختيار السياق، وهو ما تكتبه المنصّة تحت اسمه —
«إداري» مرّة و«محاسبة» أخرى، لأن مجموعة الصلاحيات صفُّ دور فهي بابٌ مثل غيره.
| الحقل / الدالة | تجيب عن |
|---|---|
$viewer->activeRole |
['key' => 'group.accounting', 'name' => 'group.accounting', 'title' => 'محاسبة'] — أو null |
$viewer->enteredAs() |
مفتاح الباب، بنفس صيغة roles[] في جمهورك فيقارَن مباشرةً |
$viewer->enteredAsTitle() |
عنوانه كما تعرضه المنصّة — للعرض فقط، لا تقارنه |
$viewer->enteredAsBaseRole() / enteredAsGroup() |
أدخل بدور منصّة أم بمجموعة صنعتها المدرسة |
$viewer->activePermissions |
شريحة permissions التي يحملها ذلك الباب — أو null |
$viewer->activeCan('x.y.view') / activeCanAny([...]) |
هل يحملها هذا الباب |
$viewer = app(GetCurrentViewer::class)();
// الشاشة تتبع الباب: الإدارية العضو في «محاسبة» ترى شاشة الموظّف حين تدخل
// إدارية، وشاشة المالية حين تدخل محاسِبة.
if ($viewer->activeCan('okta_hr.management.view')) {
return view('okta-hr::management');
}
return view('okta-hr::staff');
وثلاث قواعد تحكمه، وكلٌّ منها كُتبت لأن نقيضها يكسر شيئاً:
- يقسّم ولا يأذن. الدخول بدور أساسي لا يسحب ما منحته المدرسة: بوّابة
المنصّة نفسها لا تضيق بالباب، فـ
can()تبقى تجيب على كل ما يحمله الشخص هنا. فاستعملactiveCan()لتقرّر ما يُعرض، وأبقِcan()لتقرّر ما يُفعل — تطبيقٌ يرفض فعلاً لأن الباب لا يحمله يخالف المنصّة في الشخص نفسه واللحظة نفسها، ويرفض على الويب ما يقبله على الهاتف. nullتعني «بلا باب» لا «بلا دور». حين لا يُسجَّل باب على الطلب يعود الحقلانnull، وactiveCan()تسقط تلقائياً إلىcan()— فشاشةٌ مبنيّة عليها لا تفرغ. لا تكتب?? falseحولها؛ ولا تقرأnullرفضاً.- ولا تقبله من العميل أبداً. لا ترسل «الدور المختار» في جسم نداء ولا في ترويسة ولا تخزّنه في المتصفّح: المنصّة تقرؤه من سياق الخادم وحده، ودورٌ يسمّيه المُنادي دورٌ يزوّره.
وعلى الجوال يصلك الباب أيضاً. الهاتف يختار دوراً عند الدخول ويرسله مع كل
نداء (X-Okta-Role-Id)، وapp.installation يتحقّق أنه دورٌ يحمله المُنادي
في هذه الجهة — بوّابة البطاقة تقوم عليه أصلاً — ثم يُسلّمه للـviewer. فهو
ليس ادّعاءً من العميل: دورٌ يسمّيه ولا يحمله لا يمرّ، ودورٌ يحمله لا يفعل إلا
أن يُضيّق ما تعرضه. وcan() لا تتغيّر في الحالتين.
كان هذا القسم يقول إن
activeRoleتعودnullعلى الجوال وإن «الجوال يبقى على مجموع الصلاحيات». كان الدور يُفحص ثم يُهمَل، فصار يصل — والسطح الوحيد الذي لاcan()فيه لم يعد السطح الوحيد بلا باب.
والانتحال («تصفّح نيابةً عن»): activeRole للـمنتحَل عنه — المنصّة
تُعيد بناء السياق كاملاً للشخص المُنتحَل، فالجلسة جلسته والباب بابه. لكن
انتبه: في الانتحال — وفي الدخول إلى بيئة التجربة — لم يضغط أحدٌ بطاقة،
فالمنصّة تختار أول أدواره. فهو دورٌ صحيح يحمله الشخص فعلاً، لكنه ليس «ما
اختاره»؛ ولذلك لا تجعل activeCan() بوّابةَ فعلٍ: الفرق بين البابين يجب أن
يبقى فرق عرض.
أمر جاهز لمساعدك (Claude / Cursor)
انسخه كما هو إلى مساعد الذكاء الاصطناعي داخل مستودع تطبيقك، ليراجع الكود على هذه القاعدة ويصحّح ما يخالفها:
راجع تطبيقنا على قاعدة «نوع الحساب شيء، وما منحته المدرسة شيء آخر» في منصّة
أوكتا، وصحّح ما يخالفها:
أمام الشاشة شخصان قد يبدوان واحداً: إداريّ، وشخصٌ مُنح صلاحيات تحت مسمّى
«مدير الموارد البشرية». الأول نوع حساب — كلمة المنصّة ومعناها واحد في كل
مدرسة. الثاني مجموعة إدارية صنعتها هذه المدرسة وسمّتها كما شاءت، والمدرسة
التالية تسمّي الوظيفة نفسها اسماً آخر أو لا تملكها.
نقرأ الاثنين من GetCurrentViewer:
- $viewer->accountType — مفردات المنصّة الثابتة، واحدة للشخص:
tenant_admin (مدير الجهة، يعلو ما سواه) | administrator | teacher |
student | guardian — و employee يصل فقط لصفّ قديم لم يقرّر أحد نوعه.
الموظّف إمّا معلّم وإمّا إداري، لا الاثنان.
- $viewer->groups — [{key, title}] المجموعات الإدارية التي صنعتها المدرسة،
بعناوينها. للعرض بجانب الاسم فقط.
المطلوب:
1. الشاشة تُبنى على accountType وحده: أي تفريع «بوّابة الأسرة أم سطح
الموظّفين»، وأي إعلان في audiences[]، يقرؤه من هنا. لا من $viewer->roles
الخام ولا من اسم دور.
2. الفعل يُبنى على الصلاحية وحدها: هل يرى هذا الزرّ، هل يستطيع هذا الحفظ →
$viewer->can('__MODULE_LOWER__.<resource>.<action>') (تجيب على الويب والجوال
سواء؛ auth()->user()->can() تجيب false بلا جلسة). لا «هل هو إداري» ولا
«هل هو في مجموعة X». والمساعدات: canAny/canAll، accessLevel()
(full|granted|none)، isTenantAdmin()/isStaff()/isPortal()،
وgrantedByGroupOnly()/grantedByBaseRole()/permissionSources حين يهمّك
لماذا يحملها — منحة الدور الأساسي من rbac_permissions أم شيء أضافته هذه
المدرسة لمجموعة. أعلن للدور الأساسي أقلّ ما يلزمه، واترك ما تريده بيد
المدرسة لمجموعاتها.
3. لا تفريع أبداً على مفتاح مجموعة أو عنوانها. ابحث في الكود عن أي مقارنة
بنصّ مثل 'مدير الموارد البشرية' أو 'group.hr' أو
$viewer->groups[...]['key'|'title'] داخل if/match، واستبدلها بـ can()
على الصلاحية التي تعنيها فعلاً — وأعلن تلك الصلاحية في manifest.json →
rbac_permissions ببادئة feature = __MODULE_LOWER__، وعنوانها في
rbac_labels.
4. لا تعامل employee كنوع ثالث: هو موظّف بلا نوع مسجَّل. اعرض له السطح
العامّ وما تسمح به صلاحياته.
5. لا تفترض أن حامل الصلاحية إداري، ولا أن الإداري يحمل صلاحية: اعرض ما
تسمح به الصلاحيات، وأخفِ ما عداه بدل 403.
6. إن أردت أن يتبع سطحُ الشاشة الدورَ الذي دخل به الشخص (مرّة «إداري» ومرّة
«محاسبة») فاستعمل $viewer->activeCan()/enteredAs()/enteredAsGroup()
للعرض وحده، وأبقِ can() لكل رفض فعل. activeRole تعود null على نداءات
التوكن وactiveCan() تسقط عندها إلى can() — فلا تكتب ?? false حولها ولا
تقرأ null رفضاً.
مثال الشكل الصحيح:
$viewer = app(GetCurrentViewer::class)();
if ($viewer->accountType === 'guardian') return view('__MODULE_SLUG__::family'); // شاشة
$canApprove = auth()->user()->can('__MODULE_LOWER__.leave_requests.decide'); // فعل
$badge = $viewer->groups[0]['title'] ?? null; // عرض فقط
المرجع: دليل المطوّر، فصل «حسابات المنصّة» → قسم «نوع الحساب شيء، وما منحته
المدرسة شيء آخر» — اقرأه بـ read_guide/search_guide قبل التعديل، ثم شغّل
scan_code و validate_manifest على ما غيّرته.
أثر الإعلان على ظهور تطبيقك
الإعلان لا يحكم المسارات وحدها، بل ظهور تطبيقك في مشغّل التطبيقات:
- لم تُعلن جماهير أصلاً؟ بطاقتك تظهر لكل من في الجهة، كما كانت دائماً.
- أعلنت جماهير وطابَق المستخدمُ واحداً؟ تظهر البطاقة، ورابطها هو مسار ذلك الجمهور.
- أعلنت جماهير ولم يطابق أياً منها؟ لا بطاقة إطلاقاً.
الحالة الثالثة مقصودة: بطاقة تفتح صفحة سترفضها البوّابة أسوأ من غياب البطاقة، لأن المستخدم يقرؤها عطلاً في صلاحياته لدى المنصّة لا تطبيقاً لم يكن له. فإن اختفى تطبيقك عن نوع حساب تتوقّع أن يراه، فالعطب في إعلانك لا في تثبيت الجهة.
المحور الثاني: من يملك صلاحيةً من تطبيقك يدخله
نوع الحساب ليس المحور الوحيد، ولا يجوز أن تبني عليه وحده. أسماء الأدوار
التي تكتبها في roles[] هي تخمينك لما تسمّيه المدارس؛ والجهة تخترع أدوارها
(«مشرف الحضور»، «وكيل الشؤون») ولا تسألك. فتطبيقٌ يعلن
roles: ["tenant-admin", "teacher"] يختفي عن موظّفٍ أعطته المدرسة كل
صلاحياته الاثنتي عشرة — لأن المحورين لم يلتقيا قطّ.
لذلك: من يحمل أي صلاحية من rbac_permissions الخاصة بتطبيقك يصل سطحك
العامّ — بطاقة في المشغّل، والبوّابة تسمح، وبطاقة في كتالوج الجوال. منح
الجهةِ الصلاحيةَ هو قولها إن هذا الموظّف يستعمل تطبيقك.
والمحوران يبقيان منفصلين، وهذا هو الشرط:
| المحور | من يقرّره | ثابت؟ | ما يحكمه |
|---|---|---|---|
audiences[] |
أنت، في البيان | نعم | شاشة لكل نوع حساب — أي مسار يفتح كلٌّ منهم |
rbac_permissions |
الجهة، من محرّر الأدوار | لا | ما يُسمح بفعله داخل تطبيقك |
فالصلاحية تفتح menu.route (سطحك العامّ) ولا تفتح شاشة جمهور آخر أبداً:
من لم تُعلنه لـschool-app.admin لا يدخلها ولو حمل كل صلاحياتك. وحالتان
وحدهما لا يُفتح فيهما شيء:
- أن يملك
menu.routeجمهور بوّابة (طالب أو وليّ أمر): صلاحية موظّف ليست طريقاً إلى شاشة الأسرة مهما منحته المدرسة. - أن تكون شاشات موظّفيك مجزَّأة فعلاً — مساران مختلفان أو أكثر —
و
menu.routeأحدها: فالدخول حينها دخولٌ على شاشة بُنيت لغيره.
وأن تشير جماهير الموظّفين كلّها إلى المسار نفسه ليس تجزئة. تطبيقٌ يعلن
جمهوراً واحداً (tenant-admin → app.dashboard) وmenu.route هو ذلك المسار
عينه له شاشة موظّفين واحدة؛ الجمهور هنا يقول من يلقاها افتراضاً لا أنها
ملكه وحده، فيصلها حاملُ الصلاحية. هذه حال أكثر التطبيقات، وهي الحال التي
تعمل فيها القاعدة بلا أن تغيّر شيئاً في بيانك.
ما يعنيه هذا لك عملياً:
- احرس شاشاتك بـ
can()لا بافتراض الدور.auth()->user()->can('__MODULE_LOWER__.orders.view')هو السؤال الصحيح؛ «هل هذا المستخدم tenant-admin؟» سؤالٌ يجيب خطأً في أول مدرسة تسمّي أدوارها بغير ما توقّعت. - أعلن
rbac_permissionsلكل فعل حقيقي في تطبيقك (راجع صلاحيات الأدوار داخل تطبيقك) — فهي الآن بابُ الدخول لا وصفَ الأفعال وحده. - أبقِ لموظّفيك سطحاً واحداً يستقبل من دخل بالصلاحية، ثم فرّع منه
بـ
can()— إمّاmenu.routeلا يملكه جمهور، وإمّا شاشة موظّفين واحدة يشير إليها جمهورك كلّه. تطبيقٌ يشقّ موظّفيه على مسارات مختلفة يغلق بابه على كل دور لم تتوقّعه.
الوضع الحالي: البوّابة تسجّل ولا ترفض بعد
PARTNER_AUDIENCE_GUARD_MODE قيمته الافتراضية log اليوم: البوّابة
تسجّل المخالفة وتخدم الصفحة. وهذا يعني أن إعلاناً خاطئاً — مفتاح بوّابة
داخل roles[]، أو دور مخترَع بلا custom — يبدو عاملاً الآن، ثم يتوقّف
تماماً في اللحظة التي تُقلَب فيها القيمة إلى enforce.
فلا تختبر إعلانك على سلوك اليوم. اختبره تحت الفرض:
config(['partners.audience_guard.mode' => 'enforce']);
والقاعدة عندها الرفض بالافتراض داخل التطبيق المقسَّم: ما إن تُعلن
audiences حتى يصير كل مسار لا يغطّيه أي جمهور مرفوضاً. فصفحة نسيت
إعلانها لا تبقى مفتوحة — تُغلق. أحصِ مساراتك قبل أن تُعلن أول جمهور.
ملاحظة: إخفاء البطاقة في المشغّل يعمل الآن بلا انتظار قلب الوضع، لأنه ترشيح عرض لا حراسة. فقد ترى تطبيقك مخفياً عن مستخدم وما يزال يستطيع فتح مساره بكتابته — هذا هو الفارق بين
logوenforceبعينه.
الشركات والمجمّعات: شجرة الجهة وبيانات مدارسها
النطاقات تجيب «أيّ بيانات»، والفصل السابق يجيب «مَن الجالس». يبقى سؤال ثالث لا تصله أيّ منهما: في أيّ مكان يعمل تطبيقك، وما فوقه وما تحته.
الجهة على المنصّة ليست مدرسة دائماً. الشركة التعليمية تملك مجمّعات،
والمجمّع يضمّ مدارس — والتطبيق نفسه قد يُثبَّت على أيّ مستوى من الثلاثة.
وعلى المدرسة يعرض تطبيقك طلابها؛ وفي مقرّ الشركة لا طلاب له أصلاً، وعمله أن
يتحرّك عبر المدارس التي تحتها. قبل هذه النقاط كان /whoami يعطي التطبيق رقماً
داخلياً للجهة ولا شيء غيره: تطبيق المقرّ لا يستطيع تسمية مدارسه، وتطبيق
المدرسة لا يعرف أنها تابعة لمجمّع أصلاً.
باختصار — هل يقرأ تطبيق المجمّع بيانات مدارسه؟ نعم، ومن التطبيق المصغّر مباشرةً. سمِّ المدارس في أيّ مسار قراءة:
?entities=<ulid>,<ulid>(غلافٌ بمفتاح كل مدرسة) أو?entity=<ulid>(ردّ مدرسة واحدة كما هو). ويلزم ثلاثة: تطبيقك يحملentity.hierarchy.read، ونطاق البيانات ممنوحٌ على المجمّع أو على المدرسة — أحدهما يكفي — والشخص الذي يستعمل التطبيق يحملtenants.children.view_membersفي المجمّع. التفاصيل في «قراءة بيانات المدارس من المجمّع» أدناه، والمثال الكامل من Dart في فصل الوضعnative.
الشجرة ليست بعمق ثابت
هذا أوّل ما يُخطئ فيه التطبيق. المدرسة قد تكون تحت مجمّع تحت شركة، أو تحت مجمّع هو الجذر ولا شركة فوقه، أو مباشرةً تحت شركة بلا مجمّع بينهما، أو وحدها بلا أحد. والشركة الواحدة قد تحمل الشكلين معاً: مدرسة مباشرة وأخرى داخل مجمّع.
شركة ─┬─ مجمّع ─┬─ مدرسة أ
│ └─ مدرسة ب
└─ مدرسة ج ← مباشرة، بلا مجمّع
مجمّع مستقل ─── مدرسة د ← لا شركة فوقه
مدرسة هـ ← لا أحد فوقها
فلا تكتب «السلف الثاني هو الشركة». اقرأ السلسلة كما تأتيك، وافحص type.
أين أنا؟ — GetCurrentEntity (بلا نطاق)
use App\Services\PartnerApi\Entity\Hierarchy\GetCurrentEntity;
$here = app(GetCurrentEntity::class)();
$here->id; // ULID الجهة — نفس `entityId` في ViewerDto
$here->type; // school | complex | education_company | university | …
$here->isContainer; // هل هذا النوع يضمّ جهات؟
$here->parentId; // ULID الجهة الأمّ مباشرةً، أو null
$here->students; // من تحمله هذه الجهة نفسها — لا مجموع الشجرة
بلا نطاق عمداً، إلى جوار /whoami والساعة وللسبب نفسه: اسم الجهة ونوعها
هما الإطار الذي تُقرأ فيه بيانات تطبيقك، لا بيانات تحميها منحة. أمّا اسم
الأمّ وبقيّة الشجرة فجهات أخرى، وهي خلف النطاق التالي.
ابدأ به. type هو ما يقرّر أيّ واجهة يرسمها تطبيقك: isContainer
صادقة ⇐ شاشة المقرّ (قائمة الجهات)، كاذبة ⇐ شاشة المدرسة (الطلاب).
النطاق الواحد: entity.hierarchy.read
يفتح قراءات الشجرة التالية، للأعلى وللأسفل، ومعناه واحد: «يحقّ لهذا التطبيق
أن يرى شكل الشجرة التي هو فيها» — مَن تحته، وملفّ كل جهة منها، ومَن فوقه.
مدرسةٌ هي عميلة عند شركة لم توافق على أن يعدّها كلّ تطبيق تثبّته الشركة على
نفسها — فمسؤول الجهة يمنح هذا صراحةً. لا .write: الشجرة ترسمها المنصّة
ومسؤولو الشركة، وتطبيق يستطيع نقل مدرسة تحت أمّ أخرى هو تطبيق يستطيع نقل طلاب
بين عملاء.
وهو شرطٌ أيضاً لقراءة بيانات الجهات التابعة (أدناه): تطبيقٌ لم يُسمح له برؤية الشجرة لا يقرأ مدارسها ولو حمل معرّفاتها.
للأسفل: مَن تحتي؟ — ListChildEntities
use App\Services\PartnerApi\Entity\Hierarchy\ListChildEntities;
$direct = app(ListChildEntities::class)(); // المستوى التالي فقط
$all = app(ListChildEntities::class)(descendants: true); // كل المستويات
foreach ($all as $entity) {
$entity->name; // «مدرسة النخبة»
$entity->type; // school
$entity->parentId; // ULID أمّها — لتعيد بناء الشجرة من قائمة مسطّحة
$entity->students; // طلابها هي
$entity->children; // كم جهة تضمّ (للمجمّع)
$entity->installed; // هل لتطبيقك تثبيتٌ هو عليها
$entity->access; // ['installed' => …, 'scopes' => [...]] ← ما يُقرأ فيها
}
installed وaccess هما ما يجعلان القائمة قابلةً للعمل.
installed— هل لتطبيقك تثبيتٌ هو على تلك الجهة. هذا ما زال الطريق الوحيد إلى الكتابة فيها، بالنطاقات التي منحها مسؤولوها.access.scopes— نطاقات القراءة التي يقرأ بها تطبيقك تلك الجهة: ما منحته هي لتثبيتك إن وُجد، ومنحة القراءة التي أعطتها جهتك نازلةً إليها. إحداهما تكفي — فمدرسةٌ لم تثبّت تطبيقك تُقرأ بمنحة المجمّع.
والقائمة تقول أيضاً هل الشخص الذي يستعمل التطبيق الآن يستطيع قراءة الجهات
التابعة: can_read_children في ردّ /entity/children، و
app(CanReadChildEntities::class)() في الكود المدمج. اقرأه قبل أن ترسم
زرّ «تنزيل بيانات المدارس»، فإن كان false لا ترسمه.
(التثبيت على الجهات التابعة دفعةً واحدة يفعله مسؤول الشركة من okta-web: الجهات التابعة ← تثبيت تطبيق — كل جهة تحصل على تثبيتها ومنحتها.)
للأعلى: تابعة لمَن؟ — GetEntityAncestors
use App\Services\PartnerApi\Entity\Hierarchy\GetEntityAncestors;
$chain = app(GetEntityAncestors::class)(); // الأقرب أوّلاً؛ فارغة عند الجذر
// مدرسة تحت مجمّع تحت شركة:
$chain[0]->type; // complex ← المجمّع
$chain[1]->type; // education_company ← الشركة
// مدرسة مباشرة تحت شركة:
$chain[0]->type; // education_company — ولا يوجد [1]
// مدرسة وحدها:
$chain === [];
هذا ما يقرّر أشياءً حقيقية في التطبيق: ترويسة مَن على التقرير، هل الرسوم تُضبط هنا أم تُورَث من فوق، هل التعميم جاء من المدرسة أم من المقرّ. ابحث في السلسلة عن النوع الذي تريده بدل الاعتماد على موضعه:
$company = collect($chain)->firstWhere('type', 'education_company'); // أو null
$complex = collect($chain)->firstWhere('type', 'complex'); // أو null
ملفّ مدرسة واحدة — GET /entity/children/{id}
القائمة خريطة؛ هذا ما ترسمه على بطاقة المدرسة:
{
"id": "01HN…A", "name": "مدرسة النخبة", "type": "school", "parent_id": "01HN…X",
"status": "active", "students": 412, "employees": 38, "children": 0,
"grades": 6, "sections": 18,
"logo": "/api/apps/entity/children/01HN…A/logo",
"phone": "0112345678", "email": "info@…", "website": null, "address": "حي الملقا، الرياض",
"country": { "code": "SA", "name": "Saudi Arabia" },
"timezone": "Asia/Riyadh",
"stage": { "id": 3, "country_id": 1, "name_ar": "الابتدائية", "name_en": "Primary", "order": 1, "is_active": true },
"installed": false,
"access": { "installed": false, "scopes": ["education.students.read", "entity.hierarchy.read"] },
"can_read_children": true
}
- الشعار مسارٌ لا رابط، كصورة الطالب: التطبيق المصغّر لا يجلب رابطاً
بنفسه، فاعرضه بـ
Okta.remoteImage(school['logo'], 48). وnullحين لا شعار. country.codeهو الرمز الذي يعيده/countries، و**stage** هو الكائن نفسه الذي يسرده/countries/education-levels— طابِق بالمعرّف لا بالاسم.- الأعداد للمدرسة نفسها كبقيّة هذا السطح، لا مجموع شجرة.
- مدرسةٌ ليست تحت جهتك، وULID لا يسمّي شيئاً:
404واحد —entity_not_found.
قراءة بيانات المدارس من المجمّع — ?entities= و?entity=
تطبيقك مفتوحٌ في مجمّع، والطلاب في مدارسه. مسارات البيانات نفسها تجيب عن المدارس التابعة حين تسمّيها — بلا سطر كود خادم، ومن التطبيق المصغّر مباشرةً:
final r = await Okta.getQuery('/api/apps/education/students', {
'entities': '$schoolA,$schoolB', // حتى ٢٠ مدرسة في النداء
'per_page': '100',
});
والردّ مغلَّفٌ بمفتاح كل مدرسة، وقيمة كل مفتاح هي نفس الردّ الذي يعطيه المسار لتلك المدرسة — تقرؤه بالكود نفسه الذي تقرأ به ردّ جهتك:
{
"entities": {
"01HN…A": { "data": [ … ], "total": 412, "per_page": 100, "current_page": 1, "last_page": 5 },
"01HN…B": { "data": [ … ], "total": 88, "per_page": 100, "current_page": 1, "last_page": 1 },
"01HN…C": { "error": "missing_scope", "status": 403, "scope": "education.guardians.read", "message": "…" }
}
}
بأيّ منحة تُقرأ المدرسة؟ بإحدى اثنتين، وأيّهما تكفي: ما منحته المدرسة لتثبيت تطبيقك عندها، أو منحة القراءة التي أعطتها جهتك (المجمّع أو الشركة) نازلةً إليها. فمدرسةٌ لم تثبّت تطبيقك، أو أزالته، أو لم تمنحه النطاق، تُقرأ ما دام المجمّع منح. وحدّان:
- ينزل ما ينتهي بـ
.readوحده. الكتابة في مدرسة تبقى بمنحتها هي:ActInChildEntityلشخصٍ يشغّل نسخة المجمّع المُدارة (أدناه)، أوRunAsChildEntityفي مهمّة، أو توكن تلك المدرسة لخادمك. - ينزل لشخص. يجب أن يحمل مستعمل التطبيق صلاحية «السجلّ الموحّد»
(
tenants.children.view_members) في المجمّع — وإلا403 children_not_readable. وخادمك بتوكن التثبيت لا يقرأ هكذا أبداً (403 entities_need_a_person): يقرأ كل مدرسة بتوكنها هي كما كان.
قواعد الردّ:
- الترقيم لكل مدرسة على حدة:
pageوper_pageيُطبَّقان على كل مفتاح. مدرسةٌ بقيت لها صفحات اطلبها وحدها:?entities=01HN…A&page=2. - المدرسة المرفوضة مفتاحٌ لا الردّ كلّه: تحت مفتاحها
errorوstatus(وscopeحين ينقص نطاق)، والباقي يصل، والردّ200. entity_not_foundلمدرسة ليست تحت جهتك ولـULID لا يسمّي شيئاً — الجوابان واحد عمداً، كي لا يصير الغلاف وسيلةً لمعرفة ما هو موجود.- جهتك نفسها يجوز أن تُسمّى في القائمة، فتأتي بياناتها تحت مفتاحها.
- المرشِّح الخاص بمدرسة واحدة (
section_idمثلاً) يُرسَل كما هو لكل المدارس، فتعيد الأخرى صفحةً فارغة لا خطأ.
?entity=<ulid> لمدرسة واحدة: الردّ هو ردّ تلك المدرسة كما هو — بلا
غلاف، وبالحالة والترويسات نفسها. وصورة الطالب لا تُقرأ إلا هكذا، لأن الملف لا
يدخل داخل غلاف:
Okta.remoteImage('/api/apps/education/students/$id/photo?entity=$school', 64);
يُرفض بالاسم، ولا يُتجاهَل (لأن تجاهله يعيد بيانات المجمّع نفسه بمظهر النجاح):
| الرمز | الحالة | متى |
|---|---|---|
entities_read_only |
422 | أيّ كتابة — القدرة «تنزيل» |
entities_not_supported |
422 | مسارٌ يجيب عن جهتك نفسها: whoami، الساعة، الشجرة، المدفوعات، الإشعارات — أو entities على مسار ملف |
too_many_entities |
422 | أكثر من ٢٠ |
entity_and_entities / invalid_entities |
422 | الاثنان معاً، أو قائمة فارغة |
missing_scope (entity.hierarchy.read) |
403 | تطبيقك لا يحمل نطاق الشجرة |
children_not_readable |
403 | الشخص لا يحمل tenants.children.view_members، أو دخل من باب مضيَّق (مفوَّض من وليّ أمر) |
entities_need_a_person |
403 | توكن تثبيت (خادم) أو جهاز بلا شخص |
يعمل على كل مسار قراءة لبيانات الجهة: education/* (الطلاب، الصور، قوالب
الوجه، أولياء الأمور، المواد، المناهج، الصفوف، الشعب، الأعوام، الفصول)،
وemployees/directory، وtool-keys/*، وreports/*. ولا يحتاج رفع
minContract: المعامل جزء من الرابط، وOkta.get/Okta.getQuery يحملانه على
كل مضيف.
العمل داخل جهة تابعة — RunAsChildEntity (المدمج فقط)
المقابل المدمج لـ?entities=: يدخل جهةً تابعة ويشغّل كودك داخلها، فكل خدمة
PartnerApi هناك تعمل كأن تطبيقك مثبَّت على تلك المدرسة. القاعدة نفسها:
داخل المدرسة يحمل تطبيقك ما منحته هي لتثبيته عندها، ومنحة القراءة من جهتك
نازلةً حين يكون من يسأل شخصاً يحمل tenants.children.view_members.
والكتابة بمنحة المدرسة وحدها.
use App\Services\PartnerApi\Entity\Hierarchy\ListChildEntities;
use App\Services\PartnerApi\Entity\Hierarchy\RunAsChildEntity;
use App\Services\PartnerApi\Education\Students\ListStudents;
$children = app(ListChildEntities::class)(descendants: true);
foreach ($children as $school) {
if ($school->type !== 'school' || ! in_array('education.students.read', $school->access['scopes'], true)) {
continue; // ليست مدرسة، أو لا منحة تقرأ بها طلابها
}
$students = app(RunAsChildEntity::class)($school->id, function () {
// هنا أنت «تطبيقٌ على هذه المدرسة» تماماً: Tenant::current() هي
// المدرسة، والنطاقات منحتها هي ومنحة القراءة من جهتك.
return app(ListStudents::class)(perPage: 100)->data;
});
}
ما يُفحص، بالترتيب: (١) تحمل entity.hierarchy.read؛ (٢) الهدف تابع لجهتك
بأيّ عمق — الشقيقة والأمّ والغريبة تُرفض كـ«غير موجودة» لا «ممنوعة»، حتى لا
يصير الخطأ وسيلةً لاستكشاف أيّ جهات موجودة؛ (٣) النداء ليس من داخل فعلٍ
للمجمّع — وإلا ChildActRefusedException (already_acting)؛ (٤) السياق الذي
يسأل لم يُضيَّق عمداً — شاشةٌ مثبَّتة على فصول أو صفوف، أو مفوَّضٌ من وليّ أمر،
أو توكن تثبيت ومعه شخصٌ مسجَّل دخوله في الطلب نفسه — وإلا
ChildActRefusedException (narrowed)؛ (٥) منحةٌ يُعمل بها هناك: تثبيت
تطبيقك نفسه نشطاً على الهدف، أو — لشخصٍ يحمل الصلاحية — منحة القراءة من
جهتك، وإلا MissingScopeException بمفتاح entity.child_installation.
وتُستعاد الجهة الحالية والسياق دائماً بعد الإغلاق حتى لو رمى استثناءً —
فلا يبقى طلبك يعمل باسم المدرسة في السطر التالي.
بلا شخص لا تنزل منحة جهتك. مهمّةٌ مجدولة أو طابور أو خادم: لا أحد يسأل، فداخل المدرسة يحمل تطبيقك ما منحته المدرسة فقط، والمدرسة التي لم تثبّته تُرفض كما كانت. ومع شخصٍ يحمل الصلاحية تنزل قراءات جهتك وحدها، لا كتاباتها.
لا على مسار طلب. لا يسأل عن الشخص شيئاً حيث يهمّ السؤال: المدرسة التي
ثبّتت تطبيقك تُدخَل بمنحتها هي — والكتابة منها — أيّاً كان السائل، ولو كان
شخصاً لم يأذن له المجمّع بدخول مدارسه. وفحص الشخص أعلاه لا يقرّر إلا هل تُضاف
قراءات جهتك فوق تلك المنحة. فأبقِه للمهامّ المجدولة والطوابير والخوادم. وعلى
مسار الطلب يقرأ الشخص المدارس بـ?entities= (أو بقراءات الشجرة) ويكتب فيها
بـActInChildEntity — «التطبيق يعمل داخل مدارس المجمّع» أدناه.
التطبيق الخارجي: توكن لكل جهة
خادمُ التطبيق الخارجي يحمل توكن تثبيت لكل جهة أصلاً: يقرأ الشجرة من الجهة
التي هو فيها، ثم يصل إلى جهة تابعة بتوكن تلك الجهة على الـ API نفسها،
وinstalled يقول له إن كان ذلك التوكن يمكن أن يوجد. أمّا ?entities= فليس
له: التوكن لا شخص خلفه، فيُجاب 403 entities_need_a_person. وواجهات تطبيقك
التي يفتحها مستخدم (تطبيق مصغّر، صفحة ويب) تستعمل ?entities= كغيرها.
| Endpoint | Scope | يجيب |
|---|---|---|
GET /entity |
(لا شيء) | أين أنا: النوع، is_container، parent_id |
GET /entity/children?descendants=1 |
entity.hierarchy.read |
مَن تحتي، مع installed وaccess لكل جهة، وcan_read_children |
GET /entity/children/{id} |
entity.hierarchy.read |
ملفّ جهة تابعة: الشعار، التواصل، الدولة، المرحلة، الأحجام، access |
GET /entity/children/{id}/logo |
entity.hierarchy.read |
شعارها — ملفّاً، 404 حين لا شعار |
GET /entity/ancestors |
entity.hierarchy.read |
تابعة لمَن، الأقرب أوّلاً |
أيّ مسار قراءة بيانات ?entities= / ?entity= |
نطاق المسار + entity.hierarchy.read |
بيانات الجهات التابعة — لشخص يحمل tenants.children.view_members |
كيف تعلن تطبيقك «يدعم الشركات والمجمّعات»
عمل التطبيق على الشكل الحاوي لا يحتاج إعلاناً في الـ manifest. ما يجعله يعمل
هناك ثلاثة أفعال في الكود: (١) افحص type/isContainer عند الفتح وارسم
الشاشة المناسبة؛ (٢) اطلب entity.hierarchy.read في نطاقاتك متى كنت تحتاج
الشجرة؛ (٣) لا تفترض عمقاً. وتطبيقٌ يفعل (١) وحدها يعمل على المدرسة كما كان،
ويعرض في المقرّ شاشةً تقول ما هو، بدل أن يعرض «لا طلاب» ويبدو معطوباً.
والإعلانان في هذا الباب يجيبان عن سؤالين آخرين: لا «هل يعمل تطبيقك في المجمّع؟» بل «هل يجوز للمجمّع أن يضعه في مدارسه فلا تزيله؟» — وهو القسم التالي — و«حين يثبّته المجمّع، هل يُثبَّت في مدارسه معه؟» — وهو الذي يليه.
وحين تكون شاشة المقرّ تطبيقاً آخر لا فرعاً في الشاشة نفسها، فلا تشحنها إلى
كل مدرسة: أعلن لنوع الحساب صفّاً مقيَّداً بـentity_types
(فصل «حسابات المنصّة»، قسم «نوع الجهة»)، فتختار المنصّة الحزمة بنوع الجهة ولا
تصل حزمة المجمّع هاتف مدرسة أبداً.
المجمّع يثبّت تطبيقك في مدارسه — hierarchy.managed_install
المجمّع (أو الشركة التعليمية، أو الجامعة) يستطيع أصلاً أن يثبّت أي تطبيق من المتجر في مدارسه من صفحة «تطبيقات الجهات التابعة» — نسخةً لكل مدرسة تملكها وتزيلها متى شاءت. ولبعض التطبيقات هذا هو الشكل الخطأ: حضور المجمّع، وموارده البشرية، ونقله، قراراتٌ يتّخذها المجمّع لمدارسه كلّها، ومدرسةٌ تزيل واحداً منها بهدوء تترك ثقباً في صورة المجمّع لا يلاحظه أحد حتى يعود تقريرٌ ناقصاً.
إعلانٌ واحد في الـ manifest يُدخل تطبيقك في الشكل الآخر:
{ "hierarchy": { "managed_install": true } }
وحين يكون مفعَّلاً:
| مَن | ما يحدث |
|---|---|
| المجمّع | يثبّت التطبيق لنفسه أولاً — نسخته هي التي تُدير نسخ المدارس. ثم يختار من صفحة «تطبيقات الجهات التابعة» الجهات التي تحصل عليه، على أي عمق (الشركة تبلغ مجمّعاتها ومدارسها معاً)، ويقرّر هل تحصل عليه تلقائياً كل جهة تنضمّ إليه لاحقاً — تُنشأ تحته أو تُنقل إليه. |
| كل مدرسة يختارها | يُثبَّت التطبيق عندها وتبقى لها كل شؤونه: إعداداته، وإشعاراته (والإشعار الذي لم يضبطه أحدٌ فيها يتبع إعداد المجمّع)، وأيّ موظفيها يستعمله (ومشغّلو المجمّع يعملون داخلها أيضاً — «التطبيق يعمل داخل مدارس المجمّع» أدناه). تفقد شيئاً واحداً — زرّ الإزالة — ويظهر لها مكانه «مثبّت من قبل <المجمّع>». وواجهة المتجر البرمجية ترفض الإزالة باسمها (403، error: managed_by_ancestor). |
| مدرسة كانت قد ثبّتت تطبيقك | يبقى عندها كما هو — أخذ إدارته لا يوسّع شيئاً — ويصير المجمّع هو من يُديره. |
| الإزالة | يزيله المجمّع من مدرسة واحدة من الصفحة نفسها، أو منها كلّها دفعةً بإزالة نسخته. وفي الحالتين يتراجع عمّا فعله هو: نسخةٌ ثبّتها هو تُزال، ونسخةٌ كانت المدرسة قد ثبّتتها قبله تعود إليها — مثبّتةً وبلا قفل. |
ثلاثة أمور قبل أن تفعّله:
- المجمّع يعتمد عن كل مدرسة كل النطاقات التي يطلبها تطبيقك — كما يفعل التثبيت الجماعي العادي أصلاً؛ وأخذُ إدارة نسخةٍ ثبّتتها المدرسة بنفسها لا يوسّع شيئاً. وكما في كل تثبيت، كل نشرٍ لك يضبط نطاقات كل نسخة على ما يعلنه بيانك — المراجعة هي نقطة الموافقة في المنصّة. فاطلب ما يحتاجه التطبيق ولا شيء غيره: في التثبيت المُدار لا أحد في المدرسة يؤشّر على القائمة.
- جهةٌ حاوية واحدة في كل مرّة. نسخة المدرسة تُديرها جهة واحدة؛ والشركة التي تُديرها هناك لا يتجاوزها مجمّعٌ تحتها — وصفحة المجمّع تقول من يُديرها.
- الإعلان يحكم ما يُثبَّت من الآن. إطفاؤه في إصدار لاحق يوقف التثبيت المُدار الجديد، والتلقائيَّ في المدارس الجديدة؛ والنسخ المُدارة القائمة تبقى مُدارة حتى يزيلها المجمّع. نشرُك لا يفكّ قفل مدرسة أبداً.
ويُحفَظ مع الإصدار: من تبويب «التكامل» في محرّر الإصدار، أو بأداة MCP
set_managed_install، أو بـsync_from_manifest من مستودعك — أبقِ المستودع
والإصدار متّفقين، وإلا أعادت المزامنة التالية كتابة قيمة المستودع. و
validate_manifest يرفض "1" و1: okta-web لا يقبل إلا true حرفياً.
ولا يتغيّر شيء في كودك: النسخة المُدارة تثبيتٌ عادي بتوكنه وإعداداته والنطاقات المعتمدة له.
تثبيت المجمّع يشمل مدارسه — hierarchy.reach
التثبيت المُدار أعلاه يترك للمجمّع أن يختار المدارس. ولتطبيقٍ تعمل به كل مدارس المجمّع بلا استثناء — الحضور، النقل، الانصراف — هذا الاختيار خطوةٌ زائدة ينساها أحد: يثبّت المجمّع التطبيق لنفسه، ويظنّ أنه صار في مدارسه، ولا يكون. فأنت كمطوّر تجيب مرّةً عن سؤال واحد: حين يثبّت المجمّع تطبيقك، ماذا يشمل التثبيت؟
reach |
ما يحدث حين يثبّته المجمّع (أو الشركة التعليمية، أو الجامعة) |
|---|---|
"container" — المجمّع فقط |
يُثبَّت في المجمّع وحده ويخدمه هو. وهو ما يحدث لكل تطبيق لا يذكر المفتاح، فقولُه وتركُه سواء. |
"container_and_children" — المجمّع ومدارسه |
يُثبَّت في المجمّع، ومعه في كل جهة تحته على أي عمق نسخةً مُدارة (كما في القسم السابق) لا تزيلها المدرسة — بلا أن يختار أحد شيئاً. وكل جهة تنضمّ إلى المجمّع لاحقاً (تُنشأ تحته أو تُنقل إليه، ومعها مدارسها) تحصل عليه تلقائياً. وإزالة المجمّع لنسخته تُزيله من مدارسه. |
{ "hierarchy": { "reach": "container_and_children" } }
- يحمل التثبيت المُدار معه:
"container_and_children"يضع نسخاً مُدارة، فهو يعنيmanaged_install: trueوإن لم تكتبه — والمجمّع يستطيع بعدها إزالته من مدرسة بعينها من صفحة «تطبيقات الجهات التابعة» كما في القسم السابق. و"managed_install": falseبجانبه تناقضٌ يرفضهvalidate_manifestوokta-web بالاسم. - مَن يضغط «تثبيت» يُسأل عن المدارس أيضاً: نافذة المتجر تقول للمجمّع قبل
الزرّ إن التطبيق سيُثبَّت في جهاته التابعة وكم عددها، ولا تقبل التثبيت ممّن لا
يملك صلاحية «تثبيت التطبيقات في الجهات التابعة» (أو باقةً لا تتيحها) — لا
تثبيتاً في المجمّع وحده بصمت. وواجهة المتجر البرمجية ترفضه بـ
403،error: reach_not_allowed. - المدرسة التي ثبّتته قبل المجمّع يبقى عندها كما هو، ويصير المجمّع من يُديره؛ والنسخة التي تُديرها جهةٌ أعلى (شركة فوق المجمّع) لا تُمَسّ. والشركة التي تثبّته تصل مجمّعاتها ومدارسها معاً، والمجمّع الذي وُضع فيه لا يسبقها إلى مدارسه.
- المجمّع يعتمد عن كل مدرسة كل النطاقات التي يطلبها تطبيقك — ولا أحد في المدرسة يؤشّر على القائمة. فاطلب ما يحتاجه التطبيق فعلاً.
- الخيار يحكم ما يُثبَّت من الآن. مجمّعٌ ثبّت إصداراً أقدم لا يُعمَّم عند
نشرك: نشرُك لا يثبّت شيئاً في مدرسة لم يُقرّره مجمّعها. ويستطيع ذلك المجمّع
وضعه في مدارسه كلّها من صفحة «تطبيقات الجهات التابعة». والعكس كذلك: العودة إلى
"container"تمنع التثبيت الجديد، والنسخ القائمة تبقى حتى يزيلها المجمّع. - وتطبيقك يعرف أين يعمل: النسخة في المدرسة تثبيتٌ عادي بتوكن المدرسة، ونسخة
المجمّع تقرأ مدارسه بـ
?entities=كما في أول هذا الفصل، وتكتب فيها عبرActInChildEntity(القسم التالي).
ويُحفَظ مع الإصدار: من تبويب «التكامل» في محرّر الإصدار (خيار «حين يثبّت المجمّع
تطبيقك، ماذا يشمل التثبيت؟»)، أو بأداة MCP set_install_reach، أو
بـsync_from_manifest من مستودعك — أبقِ المستودع والإصدار متّفقين.
التطبيق يعمل داخل مدارس المجمّع — ActInChildEntity
المجمّع الذي يشغّل بوّابةً واحدة لكل مدارسه — الحضور عند بابه، أو الانصراف، أو
الحافلة — يكتب سجلّاتٍ عن طلابٍ مسجَّلين في مدارسه لا في المجمّع نفسه. و
?entities= يقرأ تلك المدارس ولا يكتب فيها، وRunAsChildEntity يدخل المدرسة
دون أن يسأل عن الشخص شيئاً. أمّا ActInChildEntity فهو طريق الشخص الذي
يشغّل نسخة المجمّع من تطبيقك: تسأل المنصّة عن سلطته في المجمّع، ثم تشغّل
كودك داخل مدرسة الطالب تحت نسخة المدرسة نفسها من تطبيقك — فيقع السجلّ،
ومعه إشعار وليّ الأمر، في المدرسة وبإعداداتها.
باختصار: للتطبيق المدمج وحده، داخل العملية — ولا مسار HTTP له. يحمل الشخص في المجمّع مفتاحين:
tenants.children.operate_appsوصلاحيةً من صلاحيات تطبيقك. ولا تُفتح إلا المدارس التي تُدير نسخةَ تطبيقك فيها نسخةُ المجمّع نفسها (hierarchy.managed_install/hierarchy.reach). والمدرسة التي يقع فيها السجلّ تُستنتج من الطالب على الخادم، ولا تُؤخذ من الطلب أبداً.
لمن هذا
- التطبيق المدمج، داخل العملية: خدماتٌ تحت
App\Services\PartnerApi\…. ولا مسار HTTP للشركاء يعمل داخل المدارس — لا شيء تحت/api/apps/*، ولا شيء في مرجع الـ API. - التطبيق المصغّر يكتب عبر نقاطك أنت (خلف
app.installation— فصل «حسابات المنصّة»، قسم «أيّ وسيط يبني السياق»)، ونقاطك هي التي تنادي هذه الخدمات. ويقرأ المدارس بـ?entities=كما كان، أو عبر نقاطك التي تنادي قراءات الشجرة أدناه. - خادم التطبيق الخارجي يبقى على توكن لكل جهة: يكتب في المدرسة بتوكنها هي.
مفتاحان، وكلاهما في المجمّع
tenants.children.operate_apps— «تشغيل التطبيقات داخل الجهات التابعة». صلاحيةٌ من المنصّة يحملها دورtenant-adminالعامّ، ويمنحها المجمّع لأيّ مجموعة أخرى تشغّل تطبيقك عبر مدارسه.- صلاحيةٌ من تطبيقك للفعل نفسه — مثل
my_app.arrivals.record— معلنةٌ فيrbac_permissionsأو تحت بادئتك. وصلاحية التشغيل لا تُقبل على أنها «من تطبيقك»: المفتاحان يبقيان اثنين.
وأحدهما وحده مرفوض: صلاحيتك وحدها يحملها عادةً كل إداري في المجمّع، وصلاحية
التشغيل وحدها لا تسمّي فعلاً. ويُقرأ الاثنان في فريق المجمّع نفسه ومن جداول
الصلاحيات — لا عبر can() («نعم» الشاملة لمشغّل المنصّة لا تُحتسب)، ولا في
المدرسة (مدير الجهة الذي صُنع داخل المدرسة بزرّ «دخول» لا يحمل هنا شيئاً).
ويضيّقهما الدور الذي دخل منه الشخص — البطاقة التي اختارها على الويب،
وX-Okta-Role-Id الذي تحقّق منه app.installation على الجوال؛ أمّا الصلاحية
الممنوحة للشخص مباشرةً لا عبر دور فلا تضيق. وإن كانت مسارات الجوال عندك تمرّ
ببوّابةٍ من صنعك بدل app.installation، فمرّر قيمة تلك الترويسة إلى
VerifyEnteredRole قبل أول فعل (App\Services\PartnerApi\Identity\VerifyEnteredRole،
__invoke(int $tenantId, ?int $claimedRoleId): void): مع دورٍ لا يحمله الشخص
هناك يُرمى AccessDeniedHttpException (403، ورسالته
entered_role_not_assigned)، وبلا هذا النداء لا يضيق شيء، فيعمل الشخص بكل
الأدوار التي يحملها في المجمّع.
ويلزم تطبيقَك نفسه entity.hierarchy.read على نسخة المجمّع.
أيّ المدارس تُفتح
المدرسة التي تُدير نسختَها النشطة من تطبيقك نسخةُ المجمّع نفسها، ولا غيرها —
وضعها فيها المجمّع أو تبنّاها عبر hierarchy.managed_install أو
hierarchy.reach (القسمان السابقان). أمّا نسخةٌ ثبّتتها المدرسة بنفسها ولم
يتبنّها أحد، أو نسخةٌ تُديرها شركةٌ فوق المجمّع، أو مدرسةٌ بلا نسخة، أو مدرسةٌ
معلَّقة، فجوابها child_not_managed. ويُسأل هذا مع كل نداء، فمجمّعٌ أزال
نسخته، أو مدرسةٌ نُقلت من تحته، تُغلق من الطلب التالي.
وداخل المدرسة يحمل تطبيقك منحة نسخة المدرسة ولا شيء غيرها: لا يُضاف نطاقٌ
من نطاقات المجمّع، ولا رجوع إلى منحته أبداً. سمِّ النطاقات التي يحتاجها كودك
في $requiredChildScopes، والمدرسة التي تنقص نسختَها واحدةٌ منها تُرفض
school_grant_missing:<scope> قبل أن يعمل كودك.
مدرسة واحدة — ActInChildEntity
__invoke(string $childEntityUlid, string $appPermission, Closure $callback, array $requiredChildScopes = []): mixed
— ويعيد ما تعيده دالّتك.
داخل دالّتك Tenant::current() هي المدرسة، وسياق الوحدة سياق نسخة المدرسة
(تثبيتها، والنطاقات التي اعتمدتها المدرسة لها)، وGetActiveEntityId()
وGetCurrentEntity() يجيبان بالمدرسة، وDispatchNotification يرسل تحت نسخة
المدرسة. ويُستعاد ذلك كلّه في finally ولو رمت دالّتك استثناءً.
والأسئلة بالترتيب — كل كلمة هي reason في
App\Exceptions\ChildActRefusedException ما لم يقل السطر غير ذلك:
- تطبيقك يحمل
entity.hierarchy.read— وإلاMissingScopeException. - السياق هو تثبيت تطبيقك النشط نفسه في الجهة التي يعمل فيها — وإلا
not_the_app_root(سياقٌ نزل إلى مدرسة ليس جذراً). - لستَ داخل فعلٍ آخر — وإلا
already_acting: المدرسة لا تعمل على ما تحتها بسلطة المجمّع. - الصلاحية من صلاحيات تطبيقك — وإلا
InvalidArgumentException('app_permission_not_declared')؛ وعنصرٌ فارغ أو ليس نصّاً في$requiredChildScopesيُرمىInvalidArgumentException('required_scope_invalid'). هذه أخطاءٌ في كودك، لا أجوبةٌ عن الشخص. - تلك الجهة حاوية — وإلا
not_container— والـ ULID يسمّي جهةً قائمة تحتها — وإلاchild_entity_not_found، والجواب واحد للشقيقة والأمّ والغريبة والـ ULID الذي لا يسمّي شيئاً. - الفاعل، في المجمّع:
no_actor(لا أحد — مهمّةٌ في الطابور، أو توكن تثبيت)، وnarrowed(بابٌ ضُيّق عمداً — شاشةٌ مثبَّتة على فصول، أو مفوَّضٌ من وليّ أمر)، وmissing_permission(ليس عضواً قائماً في المجمّع، أو ينقصه أحد المفتاحين عبر الدور الذي دخل منه)، وdevice_elsewhere(لوحةٌ مقترنة بجهة أخرى). - نسخة المدرسة تُديرها نسخة المجمّع — وإلا
child_not_managed. - منحة المدرسة نفسها تحمل كل نطاق في
$requiredChildScopes— وإلاschool_grant_missing:<scope>.
الخطوات ١–٤ تخصّ نداءك أنت، فتُرمى قبل النظر في أيّ مدرسة. ومن الخطوة ٥
فصاعداً تكتب المنصّة سطر تدقيق واحداً (app-children-act) لكل نداء — لكل مدرسة
لا لكل سجلّ — في الفعل وفي كل رفض: مَن، وعبر أيّ دور، وبأيّ صلاحية، وفي أيّ
مدرسة، وما النتيجة. ولا شيء يُحفظ بين طلبٍ وآخر: سحب صلاحية التشغيل، أو إزالة
نسخة المجمّع، يسري من الطلب التالي.
وإن تركت الاستثناء يمرّ فالجواب أحد اثنين:
404 {"error": "child_entity_not_found"}، أو
403 {"error": "child_act_refused", "reason": "…"}. ولتتفرّع عليه، التقط
ChildActRefusedException (فاحص السياسة يسمح بـApp\Exceptions\*) واقرأ
reason وchildEntityUlid وisNotFound().
دفعة طلاب — ActForStudentsInTree
__invoke(array $studentUlids, string $appPermission, Closure $perSchool, array $requiredChildScopes = []): ChildActBatch
— وتستلم دالّتك (string $entityUlid, array $studentUlids).
- تضع كل طالب في الجهة المسجَّل فيها — على الخادم، من تسجيله القائم — وتشغّل
دالّتك مرّة لكل مدرسة عبر
ActInChildEntity: مئتا سجلّ في خمس مدارس خمسة أفعال. - طلاب المجمّع نفسه تعمل دالّتك لهم في مكانها، تحت نسخة المجمّع، بلا تبديل —
وبلا سؤال عن المفتاحين وبلا سطر تدقيق: هم من جهة تطبيقك نفسها، ويحرسهم ما يحرس
مسارك. وفي مدرسة (ليست حاوية) يعمل النداء أيضاً: طلابها في مكانهم، ومن سواهم
unplaced. - رفضُ مدرسةٍ لا يوقف الأخريات، ولا يُكتب لطلابها شيء في أيّ مكان — المدرسة المرفوضة لا تنقلب كتابةً تحت المجمّع أبداً.
ChildActBatch:results(ULID الجهة ← ما أعادته دالّتك)، وrefused(ULID الجهة ← سبب الرفض)، وunplaced(ULIDs طلابٍ خارج المجمّع، أو أُخلي سبيلهم، أو لا تسمّي أحداً — في سلّة واحدة عمداً)، وpupils(ULID الجهة ← الطلاب الذين جُمعوا فيها)، وentityOf($studentUlid).- يُرمى ولا يُسجَّل على مدرسة:
MissingScopeException، وnot_the_app_root، وalready_acting، واستثناءاتInvalidArgumentException— لأنها تخصّ نداءك. والاستثناء الذي ترميه دالّتك أنت لك: ليس رفضاً، ويمرّ كما هو.
use App\Services\PartnerApi\Entity\Hierarchy\ActForStudentsInTree;
use App\Services\PartnerApi\Notifications\DispatchNotification;
use Modules\MyApp\Models\Arrival;
// البوّابة ترسل ULIDs الطلاب — ولا شيء عن مدارسهم.
$pupils = $request->input('students', []);
$batch = app(ActForStudentsInTree::class)(
$pupils,
'my_app.arrivals.record', // صلاحيتك، يحملها الشخص في المجمّع بجانب operate_apps
function (string $schoolUlid, array $schoolPupils): int {
// مرّة لكل مدرسة، وداخلها: الجهة وسياق تطبيقك وإعدادات الإشعار كلّها
// لتلك المدرسة.
foreach ($schoolPupils as $pupilUlid) {
Arrival::create([
'school_ulid' => $schoolUlid, // حيث الطالب مسجَّل — قرّرته المنصّة
'student_ulid' => $pupilUlid,
'arrived_at' => now(),
]);
app(DispatchNotification::class)('my-app.arrival.recorded', [
'recipient' => ['type' => 'parent_of_student', 'id' => $pupilUlid],
]);
}
return count($schoolPupils);
},
['education.students.read'], // يجب أن تحملها نسخة كل مدرسة هي
);
$batch->results; // ['01HN…A' => 12, '01HN…B' => 3]
$batch->refused; // ['01HN…C' => 'child_not_managed'] — لم يُكتب لطلابها شيء
$batch->unplaced; // خارج المجمّع، أو أُخلي سبيله، أو مجهول
القراءة عبر الشجرة — بلا دخول مدرسة
قراءات الشجرة تعمل في المجمّع ولا تبدّل الجهة. كلٌّ منها يسأل المفتاحين نفسيهما
(وصلاحيتك هنا صلاحية القراءة — مثل my_app.arrivals.view)، ويقرأ كل مدرسة بمنحة
نسختها هي. وهذا ما يفرّقها عن ?entities=، الذي يسأل
tenants.children.view_members ويقرأ أيّ مدرسة تحتك بإحدى المنحتين: الشجرة هنا
هي المجمّع والمدارس التي يُديرها، لا غير.
| النداء | يجيب | النطاق فوق entity.hierarchy.read |
|---|---|---|
ListOperableChildEntities(string $appPermission): OperableEntitiesDto |
الاستطلاع: refusal (null، أو الكلمة) وentities — المجمّع أولاً، ثم مدارسه المُدارة بأسمائها. ولكل OperableEntityDto: id (ULID)، وkey، وname، وtype، وisRoot، وscopes (منحة تلك النسخة نفسها) |
— |
ListOperableStudents(string $appPermission, int $page = 1, int $perPage = 500, ?string $entityUlid = null, ?string $gradeUlid = null, ?string $sectionUlid = null, ?string $search = null): PaginatedResult |
طلاب المجمّع نفسه وطلاب كل مدرسة مُشغَّلة في قائمة واحدة، مرتّبين بالمدرسة ثم بالطالب؛ ويحمل كل StudentDto الحقلين entityId وentityName، وskipped_entities في extra |
education.students.read |
GetOperableStudent(string $appPermission, string $studentUlid): StudentDto |
طالب واحد، ومدرسته في entityId — جواب الخادم |
education.students.read |
GetOperableStudentPhoto(string $appPermission, string $studentUlid): ?string |
مسار صورته على قرص public، أو null — واخدمها عبر مسارٍ من تطبيقك |
education.student_photos.read |
ListOperableStudentFingerprints(string $appPermission, int $page, int $perPage = 1000): array |
كشف البطاقات: صفوف students بالشكل {student_id, full_name, grade_id, section_id, entity_id, entity_name, fingerprints}، وmeta ومعها skipped_entities |
tool_keys.links.read |
والنطاق في العمود الأخير يُسأل مرّتين: عن نسخة المجمّع (وإلا
MissingScopeException)، وعن نسخة كل مدرسة. والمدرسة المُدارة التي تنقص نسختَها
تُسمّى في skipped_entities ({id, name, missing_scope}) — لا تظهر مدرسةً
فارغة — ولطالبٍ واحد يُجاب عنه بأنه غير موجود.
- الاستطلاع يجيب بقيمة.
ListOperableChildEntitiesلا يرمي رفضاً أبداً:refusalهيmissing_scopeأوnot_the_app_rootأو إحدى كلمات الفعل، مع قائمة فارغة — فاسأله قبل أن ترسم شاشة المجمّع. والمجمّع الذي لا تُدير نسختُه أيّ مدرسة يجيب[المجمّع]بلا رفض. ولا يُرمى إلا صلاحيةٌ ليست من تطبيقك. وActInChildEntity::refusal($appPermission)يجيب السؤال نفسه عن الشخص دون أن يسرد شيئاً. - الأربعة الأخرى ترمي. إن نقص نسخةَ المجمّع نطاقٌ —
entity.hierarchy.readأو نطاق القراءة نفسها — رُميMissingScopeException. وكل رفضٍ غيرهApp\Exceptions\ChildEntitiesNotOperableExceptionبـreason—not_the_app_root، وalready_acting، وnot_container، وكلمات الفاعل (no_actor، وnarrowed، وmissing_permission، وdevice_elsewhere)، وtree_too_largeعلى الكشفين — ويُجاب به نداء JSON أو API هكذا:403 {"error": "children_not_operable", "reason": "…"}. - السقف يرفض ولا يقصّ.
ListOperableStudentsوكشف البطاقات يرفضان شجرةً فيها أكثر من ١٠٬٠٠٠ طالب قائم أو ٥٠ مدرسة بـtree_too_large، في كل نداء أيّاً كان مرشِّحه: القائمة المقصوصة تجعل الطلاب بعد القصّ غرباء عند الباب. - 404 واحد لكل ما هو خارج.
GetOperableStudentوGetOperableStudentPhotoيرميانModelNotFoundExceptionنفسه لطالب عميلٍ آخر، ولطالبٍ أُخلي سبيله، ولطالب مدرسةٍ لا يشغّلها المجمّع، ولـ ULID لا يسمّي أحداً. - بصماتٌ لا رموز بطاقات. لا مفتاح
codesفي الكشف، وكل بصمة تحت سرّ المجمّع — السرّ الذي يبصم به تطبيق أوكتا المسحَ وهو مفتوح في المجمّع. فتطابق مسحات البوّابة هذه الصفوف، لا كشف المدرسة نفسها. - رقم الهوية والصورة لا يظهران في الصفّ إلا حين تحمل النطاق نسخةُ المجمّع ونسخةُ مدرسة ذلك الطالب معاً؛ وشطر الهوية في البحث لا يعمل إلا حين منحته كل المدارس المبحوث فيها.
ListOperableStudentsيسرد من أُخلي سبيلهم كما تسردهم قائمة المدرسة نفسها، وكشف البطاقات يتركهم.key(فيOperableEntityDto، وفيManagingEntityDtoأدناه) رقم المنصّة للجهة — القيمة التي يجيب بهاGetActiveEntityId()داخل تلك المدرسة — يُسلَّم لكودك من أجل جداولك أنت. وtoArray()لا يحمله؛ فلا ترسله إلى أيّ مكان.
مَن يُدير هذه النسخة — GetManagingEntity
__invoke(): ?ManagingEntityDto (النطاق entity.hierarchy.read): الجهة الحاوية
التي تُدير نسختُها النسخةَ التي تقف فيها — id (ULID)، وkey، وname، و
clock، وهو TenantClockDto يقول حقلُه source أيّ حلقة من السلسلة أجابت
(platform أو tenant أو country أو default). وnull لنسخةٍ لا يُديرها أحد،
وهو الجواب اليومي. ويُقرأ من القفل الحيّ، فالمجمّع الذي أزال نسخته، أو المدرسة
التي نُقلت من تحته، يُجاب عنهما بـnull فوراً. وداخل الفعل النسخةُ القائمة هناك
نسخة المدرسة، فيجيب بالمجمّع الذي يكتب — وهو ما ترجع إليه إعدادات المدرسة.
الـ viewer داخل الفعل
GetCurrentViewer داخل دالّتك يعيد viewer المجمّع — entityId المجمّع،
وأدواره ومجموعاته، وصلاحيات تطبيقك التي يحملها هناك، لأن هناك سُئلت سلطته —
ومعه actingInEntityId بـ ULID المدرسة وisActingBeneath() صادقة. اختم السجلّ
بالمدرسة لا بـentityId: entityId داخل الفعل هو المجمّع. وخارج الفعل لا
يتغيّر شيء (actingInEntityId فارغة)، والجهاز لا يكون viewer أبداً (null).
المدرسة تُستنتج من الطالب، على الخادم
لا ترويسة، ولا حقل في الجسم، ولا معامل في الرابط، يقول في أيّ مدرسة يقع
السجلّ. وschool_id يرسله العميل ادّعاءٌ ولو بدا صحيحاً. لدفعة طلاب يضعهم
ActForStudentsInTree، ولطالب واحد يجيب
GetOperableStudent($appPermission, $studentUlid)->entityId. والمرشِّح مثل
entityUlid في ListOperableStudents يضيّق داخل الشجرة التي قرّرتها السلطة
أصلاً — ULID خارجها صفحةٌ فارغة، لا مدرسةٌ تُفتح.
الإشعار يقع في المدرسة
- داخل الفعل يرسل
DispatchNotificationتحت نسخة المدرسة: أولياء الأمور هم أولياء الطالب في تلك المدرسة، وصفّ التسليم للمدرسة. - النسخة المُدارة في المدرسة تتبع إعداد المجمّع للإشعار ما دام أحدٌ في المدرسة لم يضبط ذلك الصفّ: مفتاح التشغيل، والقنوات، وساعات الصمت، والنصّ. أمّا المستلمون فيبقون مستلمي المدرسة دائماً، ومعرّف قالب المزوّد لا يعبر إلا حين ترسل الجهتان تلك القناة عبر تطبيق المزوّد نفسه. وما إن يعدّل أحدٌ في المدرسة الصفّ — والصفّ الذي كان مفعَّلاً أو معدَّل النصّ أو غُيّر قبل ذلك يُحسب معدَّلاً — حتى يحكم صفّها هي، ولو كان إطفاءً متعمَّداً، إلى أن يضغط «اتّبع إعداد <المجمّع>». ولا يتغيّر شيء في كودك.
- إشعارٌ يُرسَل من سياق المجمّع نفسه إلى أولياء طالبٍ في مدرسةٍ تحته
(
parent_of_student) لا يصل أحداً: صفّ تسليم واحد، قناتهallوحالتهfailedوخطؤهrecipient_in_child_entity:<ULID المدرسة> — …، ولا يُرسَل شيء. ولا يُعاد إرساله — فالإعادة تجري في المكان نفسه. أرسله من داخل المدرسة، عبرActInChildEntityأوActForStudentsInTree.
ما ليس هنا
- اللوحات المقترنة. قاعدة الجهاز موجودة في المنصّة — اللوحة المقترنة
بالمجمّع تعمل بمنح دورها، بالمفتاحين أنفسهما؛ ومقترنةً بجهة أخرى جوابها
device_elsewhere؛ والشاشة المثبَّتة على أماكنnarrowed— لكن لا سطح في المنصّة يفتح تطبيقك للوحة تشغيل بعد. فالذي يعمل اليوم شخص. RunAsChildEntityليس هذا. لا يسأل عن الشخص شيئاً: المدرسة التي ثبّتت تطبيقك تُدخَل بمنحتها هي — والكتابة منها — أيّاً كان السائل. وسوى فحص الشجرة والمنحة لا يرفض إلا أمرين: الباب المضيَّق (narrowed)، والنداء من داخل فعل (already_acting) — «العمل داخل جهة تابعة» أعلاه. وُجد للمهامّ المجدولة والطوابير والخوادم التي لا يسأل فيها أحد. وعلى مسار الطلب اقرأ بـ?entities=أو بقراءات الشجرة أعلاه، واكتب بـActInChildEntity.- علامة الإطلاق ليست لك لتقرأها. إطلاق الوضع
nativeفي المجمّع يحمل"operates": {"scope": "tree"}حين يجوز للشخص، عبر الدور الذي فتح به، أن يشغّل مدارس المجمّع بتطبيقك ونسختُه تُدير مدرسةً واحدة على الأقل؛ وإطلاق المدرسة — وكل إطلاق لا يستوفي ذلك — بلا مفتاحoperatesأصلاً. لا يقرؤه إلا تطبيق أوكتا: ليس فيOkta.context()، ولا يصل خادمك أبداً. وما تراه منه أنpackage:okta_faceيحمّل شجرة المجمّع المُشغَّلة بدل مدرسة واحدة (فصل الوضعnative، قسم «مَن رأته كاميرا التعرّف»). - لا شيء تعلنه. لا مفتاح جديد في البيان، ولا نطاق جديد، ولا
minContract. وما يُشغّل هذا كلّه موجودٌ في هذا الفصل أصلاً:hierarchy.managed_installأوhierarchy.reach(حتى توجد نسخٌ مُدارة)، وentity.hierarchy.read، وصلاحيتك فيrbac_permissions.
أقسام المجمّع — GET /entity/departments
المجمّع قد يقسّم مدارسه إلى أقسام (اختيارية): لكل قسم مدير وموظّفون ومدارس محدّدة، والمدرسة في قسم واحد على الأكثر. وموظّف القسم يرى مدارس قسمه وحدها، في المنصّة وفي تطبيقك معاً.
باختصار: لا شيء تعلنه ولا نطاق جديد. المنصّة تحصر قراءات الشجرة وأفعالها في قسم الشخص من تلقاء نفسها، و
GET /api/apps/entity/departments(خلفentity.hierarchy.read) يخبرك بالأقسام وبمكان الشخص منها، كي يرسم تطبيقك المدارس تحت أقسامها ويشرح غياب مدرسة بدل أن يبدو معطّلاً.
ما تحصره المنصّة عنك
لشخصٍ في قسمٍ من أقسام الجهة التي ثُبِّت عليها تطبيقك، المدارس خارج قسمه غير موجودة في كل طرق الشجرة:
| الطريق | خارج القسم |
|---|---|
/entity/children (و?descendants=1) |
لا تُذكر في القائمة |
/entity/children/{id} وشعارها |
404 entity_not_found |
?entity=<ulid> / ?entities=… |
entity_not_found للمدرسة |
ActInChildEntity |
child_entity_not_found |
قراءات الشجرة المُشغَّلة (ListOperableChildEntities وأخواتها) |
لا تُذكر |
ومن يرى الشجرة كلّها: مَن ليس في أيّ قسم، ومن يحمل
tenants.children.manage_departments (من يوزّع المدارس على الأقسام). إلا أنّ
مدير المجمّع الذي يدير قسماً أيضاً يختار عند الدخول بين بطاقة «المجمّع» وبطاقة
«مدير قسم X»؛ والثانية تحصره في ذلك القسم. وخادمك بتوكن التثبيت لا يُحصَر
أبداً: لا شخص يُسأل عن قسمه.
فلا تخزّن قائمة المدارس من شخص لتعرضها على آخر: القائمة تختلف بين موظّفَين في الجهة نفسها.
الردّ
{
"data": [
{
"id": "01JB…",
"name": "قسم البنين",
"description": null,
"manager": { "name": "خالد" },
"staff": 4,
"schools": ["01JA…", "01JC…"]
}
],
"viewer": {
"manages": ["01JB…"],
"member_of": [],
"acting_department": "01JB…",
"reach": ["01JA…", "01JC…"]
}
}
schoolsULIDs المدارس كما في/entity/children، فاربطهما بالمعرّف.viewerهوnullحين لا شخص خلف الطلب (خادم).viewer.reachهوnullحين يرى الشخص الشجرة كلّها، وإلا فقائمة ما يبلغه — وقد تكون فارغة: قسمٌ بلا مدارس يُبقي موظّفيه على لا شيء، لا على الكل.viewer.acting_departmentالقسم الذي دخل به مدير المجمّع، أوnull.- جهةٌ بلا أقسام تعيد
data: []، وكل شيء يعمل كما كان.
use App\Services\PartnerApi\Entity\Hierarchy\ListDepartments;
$answer = app(ListDepartments::class)(); // التطبيق المدمج: الشكل نفسه
والتطبيق المصغّر يقرؤه بـOkta.get('/api/apps/entity/departments').
لتفعيل الأقسام في تطبيقك
- اقرأ المدارس من المنصّة في كل جلسة، لا من نسخة مخزّنة:
/entity/childrenيعطيك ما يبلغه هذا الشخص بالضبط. - جمّع المدارس تحت أقسامها في شاشة المقرّ: اقرأ
/entity/departments، واعرض «بلا قسم» للمدارس التي لا يحملها قسم. - قل لماذا القائمة قصيرة:
viewer.reachغيرnull⇐ «تعرض مدارس قسمك»؛ وفارغة ⇐ «لا مدارس في قسمك بعد، راجع مدير المجمّع» — لا شاشة فارغة صامتة. - لا تبنِ سياجك الخاص: لا تقرأ جدول الأقسام ولا تقارن المدارس بنفسك قبل القراءة أو الكتابة. المنصّة ترفض ما خارج القسم، وتطبيقٌ يحصر وحده يفترق عنها يوم يتغيّر القسم.
- تقاريرك ومجاميعك عبر الشجرة تأخذ المدارس من
/entity/childrenنفسها، كي لا يجمع موظّف القسم أرقام مدارس لا يراها.
دورة حياة التطبيق
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": "غاب سارة يوم 2026-09-15",
"title": "غياب طالب",
"template_id": "student_absent_v2",
"variables": { "student_name": "سارة", "date": "2026-09-15" },
"media": []
},
"metadata": {
"notification_key": "okta-attendance.student_absent",
"app_slug": "okta-attendance",
"delivery_id": 48213
}
}
metadata هي هوية الإشعار — راجع القسم التالي؛ وmessage.variables
هي متغيّرات الكتالوج كما أرسلها التطبيق لا الجسم المُصيَّر وحده، و
message.template_id ما اختارته الجهة إن اختارت (أو null).
هوية الإشعار — ما يصل مزوّدك عن كل رسالة
لماذا هذا الفصل موجود: رقم واتساب رسمي (Cloud API) لا يسلّم رسالةً
تبدأها الجهة إلا قالباً معتمداً لدى Meta. مزوّدٌ يلفّ كل إشعار في قالب
افتراضي واحد يبقى هناك ما لم يعرف أيَّ إشعار يحمل — ولم يكن في الحمولة
ما يقوله. صار كل نداء send() — من كل قناة، ومن زرّ «إرسال تجريبي» عند
الجهة — يحمل في metadata ثلاثة مفاتيح ثابتة:
| المفتاح | مثال | معناه |
|---|---|---|
notification_key |
okta-attendance.student_absent |
مفتاح الكتالوج للإشعار — ما تختار به القالب المناسب |
app_slug |
okta-attendance |
التطبيق المُرسِل — لا مزوّدك أنت |
delivery_id |
48213 |
صفّ partner_notification_deliveries الذي يخصّ هذا الإرسال — ما تعيده في تذكرة دعم أو تربط به ردّ Meta |
ومعها:
variables— متغيّرات الكتالوج كما أرسلها التطبيق (student_name،date، …) لا الجسم المُصيَّر وحده. اجعلها هي ما يملأ خانات القالب؛ وbodyهو النصّ الجاهز لمن لا يعمل بالقوالب أو لقالب عام بخانة واحدة.templateId— ما اختارته الجهة لهذا الإشعار من قوالبك (القسم التالي)، أوnullفتقرّر أنت: قالبٌ تربطه بـnotification_key، أو قالبك الافتراضي.test: trueيظهر فقط حين يأتي النداء من زرّ التجربة عند الجهة؛ الهوية الثلاثية تصل معه كاملةً، فاختر للتجربة ما ستختاره للإرسال الحقيقي — تجربةٌ تنجح بقالب افتراضي وإرسالٌ يفشل بقالب لم يُعتمد هما ما جاء هذا الفصل ليمنعه.
فخّ يستحقّ سطره: على عامل الطابور يعمل مزوّدك داخل سياق تثبيته هو.
فلو سألت AppContextManager «أي تطبيق أنا؟» سمعت اسمك أنت لا اسم
المُرسِل. اسم المُرسِل يصلك في metadata.app_slug وحدها.
مسار POST /api/apps/notifications/send الخام (تطبيق يرسل عبرك مباشرةً
بلا كتالوج) يحمل app_slug وrecipient_type فقط — لا مفتاح ولا صفّ تسليم
هناك.
قوالب معتمدة تختارها الجهة — supports_templates
إن كان مزوّدك يعلن notification.capabilities.supports_templates: true، تعرض
الجهة في «تخصيص الرسالة» على /settings/notifications حقلاً إضافياً لكل
إشعار: قالب المزوّد المعتمد. ما تختاره يصلك في كل إرسال
NotificationPayload::$templateId (وmessage.template_id على الـ api).
-
مزوّد مدمج يستطيع أن يدرج قوالبه فتظهر قائمةً، بتطبيق عقد ثانٍ اختياري بجانب
PartnerNotificationProvider:use App\Contracts\ListsNotificationTemplates; final class WaCloudProvider implements PartnerNotificationProvider, ListsNotificationTemplates { /** @return list<array{id: string, name: string, description?: ?string}> */ public function templates(string $channel): array { // قوالب الرقم المعتمدة لدى Meta — تُنادى داخل سياق تثبيتك، // فرقم الجهة هو رقمها. لا ترمِ؛ أعِد [] عند التعذّر. return $this->meta->approvedTemplates($channel); } }والقائمة مغلقة: معرّف خارجها يُرفض عند الحفظ، فلا يصل Meta قالبٌ غير موجود.
-
مزوّد api لا صنف له هنا، فالجهة تكتب معرّف القالب كما هو عندك.
-
لا
supports_templates⇐ لا حقل، وtemplateIdدائماًnull.
المنطق عندك لا يتغيّر بوجود الاختيار: templateId أولاً إن وصل، وإلا ما
تربطه بـnotification_key، وإلا الافتراضي.
كتالوج إشعارات التطبيقات المثبَّتة — مصدرك لبناء القوالب
القالب المعتمد يُبنى قبل أول إرسال، ولا تستطيع بناءه لمفتاح لا تعرفه.
ولذلك للمزوّد نقطة تقرأ كتالوج إشعارات كل التطبيقات المثبَّتة على الجهة
التي ثُبِّت فيها — بنطاقه هو notifications.providers.send، بلا نطاق إضافي:
GET /api/apps/notifications/catalog?channel=whatsapp
Authorization: Bearer <installation token>
{
"tenant_id": 4821,
"apps": [
{
"app_slug": "okta-attendance",
"display_name": "اوكتا حضور",
"installation_id": 918,
"notifications": [
{
"key": "okta-attendance.student_absent",
"display_name_ar": "غياب طالب",
"display_name_en": "Student absent",
"audience": ["guardian"],
"variables": { "student_name": "string", "date": "date" },
"default_template": "غاب {{ student_name }} يوم {{ date }}",
"default_channels": ["whatsapp", "in_app"],
"tenant": { "enabled": true, "channels": ["whatsapp"], "template_id": "student_absent_v2", "has_custom_template": false }
}
]
}
]
}
-
keyهو ما سيصلك فيmetadata.notification_key، و**variables** (اسم ← نوع) هي بالضبط أسماء ما يصل فيvariablesمع كل إرسال — خانات قالبك تُبنى عليها. -
tenantحالة الجهة للمفتاح إن ضبطته (nullإن لم تضبطه بعد): هل هو مفعَّل، وعلى أي قنوات، وأيّ قالب من قوالبك اختارت.default_channelsسقف المطوّر، وtenant.channelsما ضيّقته الجهة منه. -
?channel=whatsappيقتصر على المفاتيح التي ستخرج فعلاً على هذه القناة هنا (اختيار الجهة إن وُجد، وإلا سقف المطوّر) — وهي القائمة التي تحتاج قالباً لكل بند فيها. -
تطبيقات هذه الجهة وحدها، والمُعلَن النشط فقط؛ ولا مستلمين ولا حمولات ولا سجلّ تسليم لتطبيق غيرك.
-
installed_appsتسرد كل تثبيت نشط على الجهة بعدد إشعاراته (حتى مزوّدك نفسه بصفر) — فإن جاءتappsفارغة اقرأها: قائمة تثبيتات بلا إشعارات تعني أن التطبيقات لا تُعلن شيئاً؛ وقائمة فارغة تعني أن توكنك على جهة أخرى (لكل شريك سندبوكس لكل نوع جهة — مدرسة، حضانة… — وكلٌّ بتثبيتاته)، وtenant_idفي الردّ هو ما تقارنه بالجهة التي تفتحها في المتصفّح. -
التوكن على السندبوكس: التثبيت هناك لا يُصدر توكناً تلقائياً. من حساب مدير السندبوكس
POST /api/store/modules/{slug}/installation-token/rotateيُصدر أول توكن للتثبيت (بنطاقاته المعتمدة) ويعيده مرة واحدة — أو استعمل النداء المدمج أدناه بلا توكن. -
المزوّد المدمج يقرؤه بلا HTTP — الصنف نفسه من داخل كودك، وهو تحت
App\Services\PartnerApi\*فالفاحص يسمح باستيراده:use App\Services\PartnerApi\Notifications\Catalog\GetInstalledAppsNotificationCatalog; // على صفحات إعداداتك (الوسيط بنى سياق تثبيتك) أو داخل send() على العامل // (تصل ملفوفاً بسياقك) — الجهة والتثبيت من السياق، لا من وسيط. $catalog = app(GetInstalledAppsNotificationCatalog::class)('whatsapp'); foreach ($catalog['apps'] as $app) { foreach ($app['notifications'] as $n) { // $n['key'] هو ما سيصلك في metadata.notification_key، // و$n['variables'] أسماء خانات قالبك. $this->ensureTemplateFor($n['key'], array_keys($n['variables'])); } }خارج أي سياق تثبيت (من tinker مثلاً) يُرفض بـ
no_app_context— لا يجيب عن جهة مجهولة.
الكتالوج يتغيّر حين تنشر التطبيقات إصدارات جديدة، فأعد قراءته دورياً أو عند أول مفتاح مجهول يصلك — لا تخبّئه إلى الأبد.
التحقق من التوقيع
$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 {
// أيُّ إشعار هذا، ومن أرسله، وأيّ صفّ تسليم — راجع «هوية الإشعار».
$key = $payload->metadata['notification_key'] ?? null; // okta-attendance.student_absent
$template = $payload->templateId // اختيار الجهة إن وُجد
?? $this->templateFor($key); // وإلا ما تربطه أنت بالمفتاح
$response = SmsClient::send([
'to' => $payload->recipientIdentifier,
'text' => $payload->body,
'template' => $template,
'params' => $payload->variables, // متغيّرات الكتالوج تملأ الخانات
'reference' => $payload->metadata['delivery_id'] ?? null,
]);
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 حين يشحن التطبيق أدوات متصفح
(راجع أدوات المتصفح).
وللتطبيق الذي يجوز للمجمّع تثبيته مُداراً في مدارسه، أو الذي يُثبَّت في مدارس
المجمّع مع تثبيته، يُضاف بلوك hierarchy
(راجع المجمّع يثبّت تطبيقك في مدارسه
وتثبيت المجمّع يشمل مدارسه).
الـ manifest يُولَّد آلياً من بيانات النموذج في بوّابة الشركاء؛ لا تحتاج لكتابته يدوياً إلا في حالات متقدمة.
صلاحيات الأدوار داخل تطبيقك (rbac_permissions)
النطاقات تقول ما تسمح المنصّة لتطبيقك بقراءته. وهذه الكتلة تقول ما يسمح
تطبيقك لموظّفي المدرسة بفعله داخله: مَن يرى صفحة الحضور، ومَن يعتمد
الطلب، ومَن يصدّر التقرير. صلاحياتٌ تُنشأ في الجهة وتُمنح لأدوارها، ويحرسها
كودك بـ can() — كأي صلاحية في المنصّة.
الشكل
{
"moduleId": "okta-hdor",
"rbac_permissions": {
"tenant-admin": [
"okta_hdor.attendance.view",
"okta_hdor.attendance.update",
"okta_hdor.reports.export"
],
"teacher": [
"okta_hdor.attendance.view",
"okta_hdor.attendance.update"
]
},
"rbac_labels": {
"feature": { "ar": "الحضور", "en": "Attendance" },
"permissions": {
"okta_hdor.attendance.view": { "ar": "عرض الحضور", "en": "View attendance" },
"okta_hdor.attendance.update": { "ar": "تسجيل الحضور", "en": "Record attendance" },
"okta_hdor.reports.export": { "ar": "تصدير التقارير", "en": "Export reports" }
}
}
}
- المفتاح اسم دور عامّ في المنصّة (
tenant-admin,teacher, …) — دورٌ لا تعرفه المنصّة يُتجاوَز بصمت عند التثبيت، فلا تخترع أدواراً. - كل اسم
<feature>.<resource>.<action>، والـfeatureهو معرّف تطبيقك بصيغة snake_case:okta-hdor→okta_hdor. المنصّة تجمّع محرّر صلاحيات الجهة بالمقطع الأول وتُنشئ الاسم كما هو، فاسمٌ خارج مجموعتك (education.students.deleteمثلاً) يقع داخل مجموعة المنصّة نفسها ويُظلّل مفرداتها — ولذلك يُرفَض بالاسم عند المزامنة ولا يصل النشر. أفعالٌ محدّدة (view,create,update,delete,export,approve) لاmanage. rbac_labelsهي ما تراه المدرسة. المنصّة تركّب أسماء الصلاحيات من ملفات لغتها هي، وتعرض المفتاح الخام لما لا تعرفه — ومواردك بحكم التعريف مجهولة لديها. بلا هذه الكتلة يرى مدير المدرسة مجموعة اسمهاokta_hdorوداخلهاokta_hdor.attendance.view. عنوان للمجموعة وعنوان لكل صلاحية، بالعربية والإنجليزية (٨٠ حرفاً كحدّ)، وعنوانٌ لاسم غير مُعلَن يُسقَط.- حدّ الكتلة ١٠٠ صلاحية.
كيف تصل الإصدار
الكتلة تُقرأ من manifest.json على الفرع بزرّ «مزامنة من manifest» في
محرّر الإصدار (أو أداة MCP sync_from_manifest)، وتظهر في بطاقة «صلاحيات
الأدوار» قراءةً — مع كل بند رُفض وسببه. تستطيع أيضاً ضبطها مباشرةً بأداة
set_rbac_permissions وقراءتها بـ get_rbac_permissions، لكن اجعل المستودع
مصدر الحقيقة: الكود الذي ينادي can('okta_hdor.attendance.view') يعيش هناك،
والاسمان يجب أن يتطابقا حرفاً بحرف.
ثم يمرّ الفاحص: validate_manifest يرفض الاسم خارج مجموعتك ويحذّرك من
صلاحية بلا عنوان قبل أن تنشر.
متى تُنشأ في الجهة
عند التثبيت الأول وعند كل نشر — لا عند التثبيت وحده. حين تنشر إصداراً يعلن صلاحية جديدة، تُنشأ في كل جهة مثبِّتة وتُمنح لأدوارها المسمّاة عند وصول التحديث إليها؛ لا تنتظر المدرسة إعادة تثبيت.
ما تُعلنه لدور هو كل ما يحمله ذلك الدور منك
عند كل نشر تُسوّي المنصّة أدوارها العامّة مع بيانك: كل دور تُسمّيه في
rbac_permissions يصير يحمل من صلاحياتك ما أعلنته له، ولا شيء غيره — فما
أعلنه إصدار قديم لذلك الدور وتوقّفت عن إعلانه يُسحَب منه.
- الدور الذي لا تذكره لا يُمَسّ. الصمت ليس «اسحب كل شيء»: تطبيقٌ لم يذكر
tenant-adminقطّ لم يدّعِ عنه شيئاً، ولو فُهم الصمت سحباً لأخذ الشاشات من مَن يدير المدرسة. - ولإفراغ دور، سمِّه بقائمة فارغة:
"administrator": []. هذه هي الجملة الوحيدة التي تقول «هذا الدور لا يحمل شيئاً من صلاحياتي»، وهي طريقك الوحيد لاسترجاع منحةٍ أعطاها إصدارٌ قديم. - مجموعات المدرسة لا تُمَسّ أبداً («محاسبة»، «شؤون الموظفين»)، ولا الصلاحية المُعطاة لشخص بعينه. تلك قرار المدرسة لا قرارك.
- ولا شيء خارج مجموعتك: التسوية لا ترى إلا الأسماء التي تبدأ بـ
<feature>.الخاص بك.
ولماذا هذا لازم: صفوف منح الأدوار العامّة بلا عمود جهة — الصفّ الواحد هو
كل المدارس معاً. فتطبيقٌ أعلن ست عشرة صلاحية لـadministrator في إصدار قديم
ثم توقّف عن إعلانها كان يترك كل إداري في كل مدرسة يحمل عرض كشوف الرواتب
وتعديل ملفات الموظفين، بلا طريق للمطوّر يستردّها، وبلا شيء في المدرسة يقول من
أين جاءت.
أما ما أنشأته المنصّة في الجهة فيبقى: الصلاحية تبقى موجودة في قائمة الجهة (فتستطيع المدرسة وضعها في مجموعة من مجموعاتها)، والذي يُسحَب هو منحها للدور العامّ. وسمِّ الصلاحية مرّةً واحدةً صحيحةً — إعادة التسمية تعني اسمين حيّين في كل مدرسة.
في الكود
// صفحة Livewire — الحارس في mount()، والقائمة تخفي الرابط بنفس الاسم:
protected ?string $permission = 'okta_hdor.attendance.view';
public function mount(): void
{
abort_unless(auth()->user()?->can($this->permission), 403);
}
php artisan okta-hdor:make-page Attendance --permission=okta_hdor.attendance.view
يولّد الحارس ومدخل القائمة معاً. إخفاء الرابط ليس تحكّماً في الوصول؛ الصفحة
هي التي تُحرَس.
ما ليس هنا: لا نطاق يُطلب من الجهة ولا موافقة — هذه صلاحيات داخل تطبيقك على بيانات تطبيقك، والمدرسة تملك توزيعها على أدوارها من إعدادات الأدوار كما تفعل مع أي ميزة في المنصّة.
الـ API
يتوفّر مرجع API تفاعلي عام بكامل نقاط النهاية والنطاقات وأمثلة cURL على صفحة مرجع الـ API — مناسب للمشاركة المباشرة.
URL الأساسي
https://getokta.io/api/apps
المصادقة
كل طلب يحمل Authorization: Bearer <installation_token>.
نقاط النهاية الجاهزة
| Endpoint | Scope | الوصف |
|---|---|---|
GET /whoami |
(لا شيء) | معلومات الـ installation الحالي |
GET /entity |
(لا شيء) | أين أنا: نوع الجهة، هل هي حاوية، وأمّها |
GET /entity/children |
entity.hierarchy.read |
الجهات التابعة (?descendants=1 لكل المستويات) مع installed وaccess |
GET /entity/children/{id} |
entity.hierarchy.read |
ملفّ جهة تابعة (الشعار مساراً، التواصل، الدولة، المرحلة، الأحجام) |
GET /entity/children/{id}/logo |
entity.hierarchy.read |
شعار الجهة التابعة |
GET /entity/ancestors |
entity.hierarchy.read |
الجهات فوقي، الأقرب أوّلاً |
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 |
حذف مسودة ألّفها تطبيقك |
كل نقطة قراءة لبيانات الجهة (
education/*،employees/directory،tool-keys/*،reports/*) تقبل?entities=<ulid>,<ulid>أو?entity=<ulid>لتقرأ الجهات التابعة من المجمّع — لشخص يحملtenants.children.view_members، لا لتوكن التثبيت. التفاصيل في فصل «الشركات والمجمّعات: شجرة الجهة وبيانات مدارسها».
القائمة الكاملة دائماً في 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 يفشل. - بوّابة وقت التشغيل في okta-web: مفتاحٌ لم يُصرَّح في الكتالوج المُستورَد يُرفَض بالاسم — استثناء تحقّق من
DispatchNotificationداخل المودول، و422بجسم{errors: {key: [...]}}علىPOST /api/apps/notifications/dispatch. ومفتاحٌ مُصرَّح لكن الجهة لم تضبطه بعد (تثبيتٌ سبق الإصدار الذي أضافه) يُبذَر لها مُعطَّلاً وتُسجَّل المحاولةskipped_disabledفي سجلّ التسليم. لا شيء يُسقَط بصمت.
السبب وراء الصرامة: المستأجر يحتاج يعرف مقدّماً كل إشعار ممكن يصله من تطبيقك ليُقرّر:
- هل يفعّله أصلاً؟
- على أي قناة (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 | تعطيل/تفعيل دون حذف. المفتاح المُعطَّل يختفي من صفحة إعدادات الجهة ولا يُبذَر لجهة جديدة، والإرسال له من تثبيت لم يضبطه من قبل يُرفَض كمفتاح غير مُصرَّح. أما جهةٌ فعّلته قبل التعطيل فتبقى تستلمه ما دام كودك يناديه — فالعلم يوقف الانتشار لا الإرسال، وإيقاف ميزة فعلاً هو أن يتوقّف الـdispatch من الكود. |
كيف يعيش قالبك عند الجهات
- صندوق «تخصيص الرسالة» في إعدادات الجهة يفتح على نصك الافتراضي جاهزاً للتعديل — لا على فراغ.
- جهة أبقت نصك دون تعديل تبقى وارثة له: حسّنت الصياغة في إصدار لاحق؟ تصل تلقائياً لكل جهة لم تخصّص. أما من عدّلت فنصها ثابت حتى تعيد ضبطه (إفراغ الصندوق والحفظ يعيد الوراثة).
- زر «إرسال تجريبي» عند الجهة يصيّر قالبك بقيم وهمية بحسب نوع كل متغيّر: الأرقام أرقاماً حقيقية، التواريخ تاريخ اليوم، والنصوص بشكل «اسم_المتغير» — فاجعل قالبك يُقرأ جيداً حتى بهذه العيّنات، فهي أول انطباع للجهة عن رسالتك.
- إن كان مزوّد القناة عند الجهة مقيَّداً بقوالب معتمدة (رقم واتساب
رسمي)، فقد تختار الجهة في الصندوق نفسه قالباً من قوالب المزوّد لهذا
الإشعار. لا شيء عليك فعله: المزوّد يستلم مفتاح إشعارك ومتغيّراتك
(
metadata.notification_key+variables) ويملأ القالب منها — فاجعل متغيّراتك المُعلَنة هي ما يحتاجه نصّ الرسالة فعلاً، لا ما يسهل حسابه.
كيف تُصرّح
من بوّابة الشركاء: التطبيقات ← اختر تطبيقك ← تبويب "الإشعارات".
الـURL المباشر: /dashboard/modules/<your-slug>?tab=notifications.
- اختر الإصدار (يُختار آخر draft تلقائياً).
- إضافة إشعار ← اكتب المفتاح (البادئة
<slug>.مضافة تلقائياً)، املأ display name (ar/en)، اختياري description، أضف المتغيّرات، اكتب نص الرسالة الافتراضي بمتغيّراته، حدّد موجَّهة إلى، حدّد القنوات، اختر severity، اضغط حفظ. - لتعديل: انقر "تعديل" بجانب الصف.
- لحذف: انقر "حذف". لو الإصدار منشور والمفتاح مستخدَم في تثبيتات نشطة، الحذف يُرفَض (استخدم toggle is_active بدلاً).
الإرسال من الكود
نداءٌ واحد بوسيطين — المفتاح والحمولة — ولا شيء غيرهما: لا وسيط ثالث، ولا قناة داخل الحمولة، ولا helper.
use App\Services\PartnerApi\Notifications\DispatchNotification;
app(DispatchNotification::class)('hr-pro.leave_request.approved', [
'recipient' => ['type' => 'host_user', 'id' => $employee->user_ulid],
'variables' => [
'employee_name' => $employee->name,
'leave_days' => 5,
'start_date' => '2026-04-26',
],
'url' => route('hr-pro.leaves.show', $leave), // اختياري
]);
النداء نفسه لتطبيق External عبر HTTP: POST /api/apps/notifications/dispatch
بجسم {key, payload} خلف نطاق notifications.dispatch.send، والجواب 202
مع delivery_ids (صفّ تسليم لكل قناة). التفاصيل في مرجع الـAPI.
شكل الحمولة — هذه المفاتيح محجوزة، وكل مفتاح آخر على المستوى الأعلى
يُنقَل تلقائياً تحت variables:
| المفتاح | النوع | ماذا يفعل |
|---|---|---|
recipient |
{type, id} |
العنوان الوحيد الذي تقرؤه المنصة. parent_of_student (وid هو ulid الطالب — المنصة تحلّ أولياء أمره النشطين)، host_user (id أو ulid المستخدم)، school_admin (بلا id — إداريو الجهة). |
variables |
map | ما تُملأ به {{ placeholders }} القالب. |
subject / body |
string | عنوانٌ ونصٌّ جاهزان، يُستعملان فقط حين لا نصّ مخصَّصاً عند الجهة ولا default_template عندك. |
url |
string | رابط يفتحه الإشعار (in_app / push). |
locale |
string | لغة العرض إن اختلفت عن لغة الجهة. |
metadata |
map | يُحفظ على صفّ التسليم ولا يُعرض. |
القاعدة التي كلّفت أكثر من غيرها: مفتاحٌ مثل student_id أو user_id
بجانب المتغيّرات متغيّرُ قالب لا عنوان. حمولةٌ تعتمد عليه تصل بلا
مستلم، فتسقط إلى مستلمي الجهة المضبوطين على صفّ الإشعار (إداريو المدرسة
افتراضاً) — فيصل إلى المكتب ما كان لوليّ الأمر، أو يُكتب في سجلّ التسليم
failed: No … recipients حين لا مستلم مضبوطاً. لذلك كل مفتاح audience
فيه guardian أو student يحمل recipient دائماً؛ وحده الإشعار
الإداري (audience: admin — إقفال يوم، تقرير أسبوعي) يجوز أن يُرسَل بلا
عنوان ليصل من ضبطتهم الجهة. والفاحص ينبّه — بلا رسوب — حين يرى حمولةً
حرفية بلا recipient لمفتاحٍ كهذا (notification-recipient-missing).
النصّ الذي يقرؤه الإنسان تختاره المنصة بهذا الترتيب: نصّ الجهة المخصَّص
← default_template الذي كتبتَه ← body في الحمولة ← نصّ بديل من اسم
الإشعار. فاعتنِ بـdefault_template، لا بـbody.
ومتى يصل push فعلاً: حين تكون push مُفعَّلة على صفّ الجهة، وللمنصة
حساب FCM مضبوط، وللمستلم جهاز مسجَّل من okta-app. غياب أيٍّ منها لا
يرمي — يُكتب skipped_no_transport أو failed بنصّ السبب على صفّ التسليم
(راجع «تجربة محلياً» أدناه).
ولا يوجد partner_notify() على المنصة. نسخةٌ أقدم من هذا الدليل ذكرته؛
النداء يفشل وقت التشغيل ما لم يعرّفه مودولك بنفسه، والفاحص ينبّه عليه
(notification-helper-undefined).
الإشعار الذي يُفعَل عليه: recipient وurl
الـpayload ليس متغيّرات القالب وحدها. مفتاحان محجوزان فيه يقرؤهما okta-web قبل أن يصل شيء إلى القالب، وهما الفرق بين إشعار يُقرأ وإشعار يُتصرَّف بناءً عليه:
| المفتاح | الشكل | ما يفعله |
|---|---|---|
recipient |
{ type, id } |
لمن. يتجاوز قائمة المستلمين التي ضبطتها الجهة، لأن الإشعار عن شخص بعينه لا بثّ عام. |
url |
نصّ | الفعل. المنصّة تحوّل بطاقة الإشعار إلى رابط إليه. |
recipient — العنوان الفعلي
partner_notify('leaves.request.submitted', [
'student_name' => $student->name,
'recipient' => ['type' => 'parent_of_student', 'id' => $student->ulid],
]);
الأنواع التي تُحَل:
parent_of_student+id= ULID الطالب → أولياء أمره الساريون في هذه الجهة (رابط غير مسحوب، وليّ أمر غير مُفرَج عنه). هذا هو العنوان الذي تحتاجه أغلب إشعارات المدرسة، ولاحظ أنه ULID الطالب لا وليّ الأمر: تطبيقك يعرف الطالب، والمنصّة تعرف من يخصّه.host_user+id= ULID المستخدم → ذلك المستخدم وحده.school_admin(بلاid) → إداريو الجهة.
بغير recipient — أو بنوع لا تعرفه المنصّة — يعود التوجيه إلى ما
ضبطته الجهة في /settings/notifications (أدوار + مستخدمون بأعيانهم).
هذا سلوك سليم للبثّ («تم نشر جدول الاختبارات»)، وخطأ صامت للحدث الشخصي:
«ابنك طلب الخروج مبكراً» تصل حينها لقائمة الجهة كلها ولا تصل الأب.
audienceفي الكتالوج ليست توجيهاً. هي إعلان تقرؤه الجهة قبل أن تفعّل الإشعار («موجَّهة إلى وليّ الأمر»). التوجيه يحصل هنا، في كل نداء على حدة.
url — ما يفتحه القارئ
بطاقة الإشعار — في مركز الإشعارات وفي بطاقة الصفحة الأولى ببوّابة وليّ
الأمر — تصير رابطاً حين يحمل الـpayload مفتاح url، وتبقى بطاقة
صمّاء بدونه:
partner_notify('leaves.request.submitted', [
'student_name' => $student->name,
'recipient' => ['type' => 'parent_of_student', 'id' => $student->ulid],
'subject' => 'طلب استئذان بانتظار موافقتك',
'body' => "{$student->name} طلب الخروج الساعة ١١:٣٠",
'url' => route('leaves.requests.show', $request), // ← الفعل
]);
اجعله رابط الشاشة التي تُنجَز عليها العملية لا صفحة تطبيقك الرئيسية:
وليّ الأمر الذي يصله «طلب بانتظار موافقتك» ثم يُلقى على لوحة التطبيق
عليه أن يبحث عن ابنه ثم عن الطلب — وهي الرحلة نفسها التي جاء الإشعار
ليُلغيها. الرابط النسبي (/apps/leaves/requests/42) كافٍ ومُفضَّل.
نصّ البطاقة
العنوان يأتي من الكتالوج (display_name_ar/display_name_en بحسب
لغة القارئ) لا من الـpayload — ولهذا الاسم العربي إجباري. أما الجسد
فيُقرأ من payload.body ثم payload.subject، ويسقط إلى وصف الكتالوج
حين لا يرسل التطبيق شيئاً. أرسِل body مكتوباً للقارئ: وصف الكتالوج
عام بطبيعته («يُرسَل عند تقديم طلب استئذان») ولا يذكر أيّ ابن ولا أيّ
ساعة.
شرطان قبل أن تظهر البطاقة أصلاً
in_appمُعلَنة فيdefault_channelsلهذا المفتاح — الجهة تستطيع تضييق قنواتك لا توسيعها.- الجهة لم تُطفئ القناة أو الإشعار من
/settings/notifications.
فحص ما حدث فعلاً: mcp__okta__delivery_stats وwebhook_deliveries
تقرآن صفوف التسليم، وصفّ skipped_disabled يعني أن الجهة أطفأته —
لا أن نداءك فشل.
السكانر — ما يفعله
scripts/partner-policy/NotificationScanner.php في الـboilerplate
يفحص كل ملف PHP تحت Modules/<Namespace>/ و:
- يجمع كل المفاتيح الـ"مُستخدَمة" من نداءات الإرسال:
app(DispatchNotification::class)('key', ...)app(\App\Services\PartnerApi\Notifications\DispatchNotification::class)('key', ...)partner_notify('key', ...)— يُلتقط كي يُحسَب مفتاحه، مع تنبيه أن الدالة ليست على المنصة (notification-helper-undefined).
- يجمع كل المفاتيح الـ"مُصرَّح بها" و
audienceكلٍّ منها منmanifest.json["notifications"]. - يقارن:
- مفتاح مستخدَم لكن غير مُصرَّح → violation (يكسر CI، رفض الـPR).
- مفتاح مُصرَّح لكن غير مستخدَم → warning (غير قاطع، لكن يظهر في الـlog ليُذكِّرك بالتنظيف).
- مفتاح جمهوره
guardian/studentيُرسَل بحمولة حرفية بلاrecipient→ warning (notification-recipient-missing) — لا يُرسِب، لكنه يسمّي الحمولة التي ستصل بلا مستلم قبل أن تكتشفها مدرسة.
تشغيل محلياً:
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_name} |
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. ادفع الفرع — الفاحص يمرّ بتنبيهات فقط: المفاتيح مُصرَّحة ولم تُستعمل بعد (notification-key-unused).
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', [
'recipient' => ['type' => 'host_user', 'id' => $request->employee_user_ulid],
'variables' => [
'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. default_channelsسقفٌ لا افتراض: الجهة تُضيّقه ولا توسّعه، فقناةٌ لم تُعلنها لا تستطيع أي مدرسة تفعيلها أبداً. أعلن كل قناة يصلح لها الإشعار (in_appوpushلما يخصّ وليّ الأمر في التطبيق، وwhatsapp/smsلما يستحقّ رسالة مدفوعة) — الصفّ يُبذَر للجهة معطَّلاً على كل حال، والقناة المدفوعة قرارها ومزوّدها هي.- استخدم 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). |
Notification key [x] is not declared in this app's catalog (استثناء / 422) |
المفتاح غير مُصرَّح في الإصدار المثبَّت، أو is_active=false والجهة لم تضبطه من قبل |
صرّح المفتاح وانشر إصداراً يحمله، ثم تأكّد أن الجهة ثبّتت ذلك الإصدار. |
DispatchNotification called outside a partner-app context |
نُودي الإرسال من مهمّة طابور أو فعل Livewire أو نقطة جوال بلا ModuleContext |
ابنِ السياق حول العمل (BootModuleContext وسيطٌ يعيش بطول $next الذي يُسلَّم إليه — لفّ العمل داخله لا بعده). |
سجلّ التسليم: failed: No … recipients |
الحمولة بلا recipient والمفتاح يخصّ طفلاً |
أضف recipient: {type: parent_of_student, id: <ulid الطالب>} — student_id بجانب المتغيّرات متغيّر قالب لا عنوان. |
سجلّ التسليم: skipped_disabled |
المدرسة لم تفعّل المفتاح على صفحتها | لا شيء في كودك — المدرسة تفعّله من /settings/notifications؛ قلها في دليل تطبيقك. |
سجلّ التسليم: skipped_no_transport |
القناة مُفعَّلة عند الجهة والمنصة بلا ناقل لها (push بلا FCM) | ضبط الناقل عند المنصة/الجهة؛ لا شيء في كودك. |
Scanner: notification-helper-undefined (warning) |
الكود ينادي partner_notify() |
استبدله بـapp(DispatchNotification::class)('<key>', $payload). |
Scanner: notification-recipient-missing (warning) |
مفتاح جمهوره وليّ أمر/طالب يُرسَل بحمولة حرفية بلا recipient |
أضف recipient — وحمولةٌ تُبنى في متغيّر لا يحكم عليها الفاحص. |
تجربة محلياً
الإرسال يحتاج سياق تثبيت لا جلسة tinker: DispatchNotification يقرأ
الجهة والتثبيت من ModuleContext الذي يبنيه وسيط module.context على
مسارات مودولك، ويرفض خارجه بـ«outside a partner-app context». فجرّب من داخل
التطبيق على السندبوكس بأحد طريقين:
- من شاشة مودولك (مسار خلف
module.context)، أو من مهمّة طابور/نقطة جوال تبني السياق بنفسها عبرBootModuleContext— بلفّ العمل داخله لا بندائه ثم العمل بعده. - أو بطلب HTTP بتوكن التثبيت (External، أو أي تطبيق من الطرفية):
curl -X POST "<sandbox base url>/api/apps/notifications/dispatch" \
-H "Authorization: Bearer <installation token>" \
-H "Content-Type: application/json" \
-d '{"key":"hr-pro.leave_request.approved","payload":{"recipient":{"type":"host_user","id":"<user ulid>"},"variables":{"employee_name":"سارة","leave_days":3}}}'
ثم اقرأ ما جرى — ثلاثة أماكن تعرض صفّ التسليم نفسه بحالته ونصّ خطئه:
- صفحة الجهة
/settings/notifications← بطاقة «سجلّ التسليم» أسفل الجدول (آخر الأيام: كل محاولة بقناتها وحالتها والخطأ الذي تركته مهمّة القناة). وفي الصفحة نفسها زرّ «إرسال تجريبي» لكل مفتاح يصيّر قالبك بقيم وهمية ويُرسله فعلاً إلى مستلمٍ يختاره المشغّل — أسرع طريقة لتعرف أن FCM مضبوط وأن الجهاز مسجَّل. - من تطبيقك:
GET /api/apps/notifications/logs(نطاقnotifications.logs.read) بمرشّحاتchannel/status/key/from/to. - من مساعدك: أداة MCP
notification_deliveries {slug}— إجماليات بحسب الحالة والقناة والمفتاح عبر كل المدارس التي ثبّتت تطبيقك، ثم آخر الإخفاقات بنصّها.
الحالات التي ستراها: sent / queued وصل أو في الطابور؛ failed بنصّ
السبب — وNo … recipients تحديداً يعني حمولة بلا recipient؛
skipped_disabled المدرسة لم تفعّل المفتاح بعد؛ skipped_quiet ساعات
الهدوء؛ skipped_no_transport قناة بلا ناقل على المنصة (push بلا FCM)؛
skipped_plan_feature باقة الجهة لا تشمل إشعارات التطبيقات.
الوصول بين التطبيقات (تطبيقك يحتاج بيانات تطبيق آخر)
تطبيق الجدول يحتاج يعرف مين غاب اليوم. بيانات الحضور تعيش في قاعدة بيانات تطبيق الحضور — مخطّط منفصل لا يصل إليه غيره. فالسؤال ليس «كيف أقرأ الجدول الفلاني»، بل «كيف أطلب من مطوّر تطبيق الحضور أن يفتح لي جزءاً محدَّداً من بياناته».
الجواب موافقتان لا واحدة:
| مَن يوافق | على ماذا | أين |
|---|---|---|
| مطوّر التطبيق صاحب البيانات | «يحقّ لهذا التطبيق أن يطلب» | /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\في كودك أنت، وأن ينفّذ الواجهة — انظر القسم التالي.writer— نظيرreaderعلى عرضaccess: write: الصنف الذي يقبل ما يرسله التطبيق الآخر ويتحقّق منه ويخزّنه بقواعدك. عرض كتابة بلا كاتب موافقةٌ تُمنَح ولا تُخزِّن شيئاً. انظر «الكتابة في بيانات تطبيق آخر» أدناه.
اكتب القارئ
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من آخر إصدار منشور لك فقط. ما دام في مسوّدة فهو غير موجود بالنسبة لهم — ومقصود: الرابط التزام بين منتجين يعملان، وبناؤه على مسوّدة يعني أن ما تعتمد عليه قد يتغيّر أو يختفي قبل أن يراه أحد خارج فريقك.
وثّق ما تشاركه
label وdescription جملتان للمدرسة. المطوّر الذي يبني على عرضك يحتاج ما لا
يتّسع له البيان: شكل الصفوف التي يُرجعها القارئ، والمرشِّحات التي يفهمها،
والحمولة التي يقبلها الكاتب وما يُرجعه، ومثال نداء. لهذا وثائق البيانات
المشتركة: صفحة Markdown لكل تطبيق، تكتبها من محرّر التطبيق («وثّق ما
تشاركه» في بطاقة «ما يشاركه هذا التطبيق») أو من مساعد MCP:
set_shared_docs {
"slug": "attendance",
"body": "## records (read)\n\nكل صفّ: `student_ulid`, `date` (YYYY-MM-DD), `status` (present|absent|late).\n\nالمرشِّحات: `from` تاريخ البداية. الحدّ ٢٠٠ صفّ.\n\n```php\napp(ReadFromApp::class)('attendance', 'records', ['from' => '2026-09-01']);\n```\n\n## records (write)\n\nالحمولة: `student_ulid`, `date`, `status`. يُرجع `{id, status}`."
}
من يقرؤها: فريقك، ومطوّرو التطبيقات التي وافقتَ على طلب ربطها بتطبيقك — بعد الموافقة لا قبلها. طلب معلّق لا يقرأ شيئاً، وطلب مسحوب لم يعد يقرأ. ليست للمدارس ولا عامّة، فاكتب فيها تفاصيل الحقول التي لا تليق ببطاقة موافقة. وهي خارج البيان: تعديلها يصل القارئ فوراً بلا إصدار جديد.
2. إن كنت المحتاج: أرسل الطلب
/dashboard/app-links → «طلب وصول جديد». تختار تطبيقك، ثم التطبيق صاحب
البيانات، ثم من قائمته المنشورة ما تحتاجه، ثم تكتب السبب.
السبب ليس إجراءً شكلياً: هو كلّ ما يقرأه المطوّر الآخر قبل أن يقرّر. اكتب فيه ما ستعرضه للمستخدم تحديداً («نعرض حصص الغياب داخل جدول الطالب، بلا تخزين») لا وصفاً عامّاً («للتكامل»).
3. الردّ: قبول كلّي أو جزئي أو رفض
مطوّر التطبيق صاحب البيانات يرى الطلب في «طلبات واردة»، ويستطيع:
- الموافقة على الكل — الحالة الشائعة، بضغطة واحدة.
- الموافقة على البعض — «اقرأ الملخّص الشهري، لا السجلّ التفصيلي». هذا ردّ طبيعي على طلب حقيقي، وليس حالة استثنائية.
- الرفض — مع ذكر السبب إلزاماً. «لا» بلا سبب تترك المطوّر الآخر بلا شيء يغيّره ولا طريقة يعرف بها إن كان إعادة الطلب مجدية أصلاً.
الموافقة لا تحتاج تعليلاً، والرفض يحتاجه. وبعد الموافقة يبقى للمطوّر أن يسحبها (بسبب أيضاً) — وحينها لا يعود تطبيقك يستطيع إعلان ذلك الوصول في إصداراته القادمة.
بعد الموافقة تُفتح لك وثائق التطبيق المزوّد: زرّ «الوثائق» على الرابط في
صفحة الروابط، أو get_shared_docs {"slug": "attendance"} من المساعد. اقرأها
قبل أن تكتب سطراً من ReadFromApp أو WriteToApp؛ البيان لا يحمل إلا اسماً
وجملة لكل عرض.
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). ثم تعرض
صفحة التطبيق البطاقة على الجهة، وهي التي تمنح أو ترفض كل سطر على حدة. لا
بيانات تتحرّك قبل ذلك.
والصفوف تُنشأ عند كل تحديث أيضاً، لا عند أول تثبيت فقط. إن أضفت الاعتماد في إصدار لاحق (مثلاً 2.3.17)، فكل جهة كانت قد ثبّتت تطبيقك قبله تحصل على الصفوف الناقصة لحظة نشر الإصدار، وتظهر البطاقة لديها بمفاتيحها — بلا إعادة تثبيت. الصفوف القائمة لا تُمَسّ: ما منحته الجهة يبقى ممنوحاً، وما رفضته يبقى مرفوضاً.
موافقة المطوّر ليست موافقة الجهة. موافقته تعني «يحقّ لهذا التطبيق أن يطلب» فقط. كل مدرسة تُسأل بعدها على حدة، ولها أن ترفض.
ومن في المدرسة يجيب؟ من تحمل أدواره صلاحية «اعتماد مشاركة البيانات بين
التطبيقات» (app_store.cross_access.approve) — تُمنح لدور مدير الجهة
افتراضاً، وللمدرسة أن تمنحها من تشاء. فمن يفتح صفحة تطبيقك بلا هذه الصلاحية
يرى الحالة ولا يستطيع تغييرها. إن أبلغتك مدرسة أن الميزة لا تعمل وأن «لا أحد
يستطيع الموافقة»، فهذا هو السؤال الأول: من عندهم يحملها.
ما تفحصه بوّابة الإصدار — وما لا تفحصه
المنصّة تفحص إعلان cross_module_access في ثلاث لحظات، ولا تفحص الشيء نفسه
في كلٍّ منها:
| اللحظة | ما يُفحص | لماذا |
|---|---|---|
| الحفظ في المحرّر | كل المدخلات، ضدّ كتالوج المزوّد المنشور (وعلى بيئة التجربة: ما ثبّته تطبيقك الشقيق هناك) | هنا تستطيع أن تفعل شيئاً — تحذف سطراً، أو تطلب موافقة جديدة |
| التقديم للمراجعة والنشر | الجديد فقط: المدخلات غير الموجودة في إعلان إصدارك المنشور حالياً، بالمفتاح (module, resource, access) |
ما شُحن فُحص يوم شُحن؛ إعادة فحصه ضدّ كتالوج المزوّد الحالي كانت تعطّل إصدارك بسبب عثرة عند غيرك |
| إعادة النشر | لا شيء | إعادة النشر تدفع ما شُحن أصلاً ولا تضيف سطراً |
القصّة التي فرضت القاعدة: تطبيقٌ يعلن قراءة attendance_status من
«حضور»، نُشر ووافقت عليه المدارس والقراءة تعمل. ثم نشر «حضور» إصداراً سقطت
منه provides سهواً — فصارت إعادة نشر التطبيق الأول تُرفض بـ«"حضور" لا
ينشر okta_hdor.attendance_status.read في آخر إصدار منشور له»، بينما
الإصدار المثبَّت عند المدارس ينشره فعلاً. التطبيق عوقب على تغيّر لم يُحدثه،
والعقوبة تعطّل خطّ نشره كلّه — إصلاحٌ لا علاقة له بالمشاركة لا يستطيع
الخروج.
فلماذا القاعدة آمنة: المدرسة لا تحتاج البوّابة لتحميها من سطر شُحن قبلُ.
وقت التشغيل يرفض القراءة أصلاً إن لم يعد المزوّد ينشر المورد أو لم توافق
المدرسة (ReadFromApp)، ويُسقط العرضَ المعطوب من الكتالوج. البوّابة عند
النشر تحمي من سطر جديد يُشحن بلا موافقة مطوّر أو بلا عرض منشور — وهذا ما
ما زالت تفعله بالضبط.
ما يعنيه لك:
- إن رأيت «لا ينشر … في آخر إصدار منشور له» عند الحفظ على سطر كان يعمل، فالمزوّد غيّر كتالوجه. راسل مطوّره؛ إصدارك المنشور لا يتأثّر، ونشرك التالي لا يُعطَّل بهذا السطر.
- الرسالة نفسها عند النشر تعني سطراً جديداً في هذا الإصدار: المزوّد لا ينشر ما تطلبه، أو لم يوافق بعد. هذا رفضٌ صحيح، ولا يُلتفّ عليه.
writeبجانبreadحيّ سطرٌ جديد — صفّا موافقة لا درجتان على سلّم.
الكتابة في بيانات تطبيق آخر
القناة كانت قناة قراءة بشكلها، والدليل كان يقول إن write يُعلَن ويُعرَض
للمدرسة ولا يُفرَض. لم يعد الأمر كذلك: للكتابة بابها الخاص، والكلمة على
بطاقة الموافقة هي التي تقرّر أيّ باب يُفتح.
عند صاحب البيانات — عرض access: write يسمّي writer:
{
"resource": "records",
"access": "write",
"label": "تسجيل حضور من تطبيق آخر",
"description": "يسمح لتطبيق آخر بتسجيل حضور طالب أو غيابه لهذا اليوم.",
"writer": "Modules\\Attendance\\Services\\PartnerApi\\RecordsWriter"
}
namespace Modules\Attendance\Services\PartnerApi;
use App\Services\PartnerApi\Contracts\CrossAppWriter;
use Illuminate\Support\Facades\Validator;
use Modules\Attendance\Models\AttendanceRecord;
final class RecordsWriter implements CrossAppWriter
{
public function write(string $resource, array $payload, string $consumerSlug): array
{
$data = Validator::validate($payload, [
'student_ulid' => ['required', 'string'],
'date' => ['required', 'date'],
'status' => ['required', 'in:present,absent,late'],
]);
$record = AttendanceRecord::query()->updateOrCreate(
['student_ulid' => $data['student_ulid'], 'date' => $data['date']],
['status' => $data['status'], 'source' => $consumerSlug],
);
return ['id' => $record->ulid, 'status' => $record->status];
}
}
القواعد الثلاث نفسها التي للقارئ، وواحدة فوقها: $payload مدخلات من تطبيق
آخر — تحقّق منها كما تتحقّق من $request، وارفض المعطوب برمي استثناء لا
بالصمت؛ كاتبٌ يعود بلا خطأ عن شيء لم يُخزَّن يجعل التطبيق الآخر يظنّ أنه كتب.
عند المحتاج — نداء واحد، بنفس شكل القراءة:
use App\Services\PartnerApi\CrossApp\WriteToApp;
$written = app(WriteToApp::class)('attendance', 'records', [
'student_ulid' => '01J…',
'date' => '2026-09-13',
'status' => 'absent',
]);
// ['id' => '01K…', 'status' => 'absent']
ما يفحصه النداء هو ما تفحصه القراءة، بفارق واحد يجب أن تعرفه: موافقة
المدرسة على الكتابة موافقة أخرى. read وwrite صفّان منفصلان في
module_cross_access_grants وسطران منفصلان على البطاقة، وokta-web يقارن عمود
access الآن: موافقة read لا تفتح WriteToApp، وموافقة write لا تفتح
ReadFromApp. تطبيقٌ يحتاج الاثنين يُعلن سطرين في cross_module_access
ويُسأل عنهما المدرسة كلاً على حدة.
على بيئة التجربة: تطبيقاتك تقرأ بعضها بلا موافقة
على جهة بيئة التجربة، تطبيقان لك أنت يقرآن بعضهما مباشرةً: لا اعتماد
من مطوّر آخر (لا يوجد)، ولا موافقة من الجهة (هي جهتك)، ولا نشر على الإنتاج.
ما يُحكَم به هو ما ثبّته على التجربة: ثبّت التطبيق المزوّد هناك بإصدار
يعلن المورد في provides ويسمّي له قارئاً، ثم نادِ ReadFromApp من التطبيق
الآخر فتصلك البيانات. بطاقة الموافقة في متجر التجربة تعرض السطر بشارة
«مُنحت تلقائياً» بدل مفتاح.
وفي محرّر الإصدار، تظهر موارد تطبيقاتك الأخرى المثبَّتة على التجربة قابلةً للإعلان حتى لو لم تُنشر بعد، بشارة «على بيئة التجربة فقط». هذا يكفي للحفظ وللتجربة، ولا يكفي للمراجعة: إرسال الإصدار للمراجعة يعود إلى القاعدة الأصلية — التطبيق الآخر يجب أن يكون قد نشر ما تعتمد عليه، لأن مدرسة على الإنتاج لا تُسأل الموافقة على مورد لم يُشحن إليها.
القاعدة لا تمرّ على جهة إنتاج أبداً، ولو كان التطبيقان لك: بيانات المدرسة بيانات المدرسة، والصفّ الممنوح هو الإذن الوحيد هناك.
مثال كامل: كتابة بين تطبيقين لك على التجربة، بلا نشر
الحالة: تطبيق «الحضور» يملك سجلّات الحضور، وتطبيق «البوّابة» يريد أن يسجّل غياب طالب عندما تمرّ بطاقته. كلاهما لك، ولم يُنشر أيّ منهما بعد. الخطوات من مساعد MCP (أو من المحرّر — الشاشتان تكتبان الأعمدة نفسها):
1. المزوّد يعلن عرض كتابة بكاتب — على إصدار الحضور القابل للتعديل:
set_shared_data {
"slug": "attendance",
"shares": [
{
"resource": "records",
"access": "write",
"label": "تسجيل حضور من تطبيق آخر",
"description": "يسمح لتطبيق آخر بتسجيل حضور طالب أو غيابه لهذا اليوم.",
"writer": "Modules\\Attendance\\Services\\PartnerApi\\RecordsWriter"
}
]
}
ثم يكتب الصنف RecordsWriter كما في قسم «الكتابة في بيانات تطبيق آخر» أعلاه،
ويدفعه إلى المستودع.
2. المزوّد يُثبَّت على التجربة — install_on_sandbox {"slug": "attendance"}
(أو زرّ التثبيت في تبويب التجربة). هذا هو ما يُحكَم به: الإصدار المثبَّت على
التجربة، لا آخر إصدار منشور.
3. المستهلك يعلن الاعتماد — يظهر العرض الآن في get_app_dependencies {"slug": "gate"} تحت «المتاح للإعلان» بعلامة «(على التجربة فقط)»:
set_app_dependencies {
"slug": "gate",
"dependencies": [
{
"module": "attendance",
"resource": "records",
"access": "write",
"required": false,
"reason": "لتسجيل غياب الطالب تلقائياً عند مرور بطاقته من البوّابة."
}
]
}
يُقبل الحفظ لأن الاثنين لك والمزوّد مثبَّت على التجربة. (لو كان «الحضور» لمطوّر آخر لرُفض السطر حتى يوافق ذلك المطوّر وينشر العرض.)
4. المستهلك يُثبَّت على التجربة ويكتب — install_on_sandbox {"slug": "gate"}،
ثم من كود البوّابة:
use App\Services\PartnerApi\CrossApp\WriteToApp;
$written = app(WriteToApp::class)('attendance', 'records', [
'student_ulid' => $student->ulid,
'date' => today()->toDateString(),
'status' => 'absent',
]);
لا صفّ موافقة يُنتظر: على جهة التجربة، والتطبيقان لمطوّر واحد، يمرّ النداء
مباشرةً إلى RecordsWriter في سياق الحضور. بطاقة الموافقة في متجر التجربة
تعرض سطر الكتابة بشارة «مُنحت تلقائياً».
5. قبل المراجعة — انشر «الحضور» أولاً (فيصير عرض الكتابة منشوراً)، ثم أرسل «البوّابة» للمراجعة. بالترتيب المعكوس تُرفض البوّابة بـ«لا ينشر»، وهو الرفض الصحيح: مدرسةٌ على الإنتاج ستُسأل عن سطر كتابة، ولا بدّ أن يكون خلفه عرضٌ منشور.
ما يتغيّر على الإنتاج بعد ذلك شيء واحد: موافقة المدرسة على سطر الكتابة تصير الإذن، وتُسأل عنه على حدة عن أيّ سطر قراءة.
قراءة البيانات فعلياً
استدعاء واحد، من داخل تطبيقك:
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تفتح القراءة وحدها، وموافقةwriteتفتح الكتابة وحدها. تطبيقٌ كان يعتمد على أن صفّ قراءة يمرّ في فحص كتابة (وهو ما كان يقوله هذا الدليل) يحتاج الآن عرضwriteبكاتب عند المزوّد وسطرwriteفي إعلانه وموافقة المدرسة عليه.- السحب لا يرجع بالزمن. إن سحب المطوّر موافقته، تتوقّف إصداراتك القادمة عن إعلان الوصول، لكن الجهات التي منحته أصلاً تبقى موافقتها سارية حتى تُحدَّث المنصة.
- تطبيقاك أنت لا يحتاجان طلباً. تطبيقان لجهة واحدة لا شيء بينهما
للتفاوض — تُعلِن الاعتماد مباشرةً. على بيئة التجربة يكفي أن يكون التطبيق
الآخر مثبَّتاً هناك بالمورد؛ وعند المراجعة يبقى ملزَماً بأن يكون قد
نشر ما تُسمّيه في
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 وإحصاءاتها وإعادة الإرسال |
notification_deliveries |
ما جرى لإشعارات كتالوج تطبيقك عبر كل مدرسة ثبّتته: إجماليات بحسب الحالة والقناة والمفتاح، ثم آخر الإخفاقات بنصّ الخطأ — أول مكان تنظر فيه حين تقول مدرسة «لم يصل الإشعار» |
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 (بالشكل
الذي يفرضه وضع الجوال على الإصدار) — ومع صفّ native له mobile_entry،
min_contract اختياري (عدد صحيح ≥ 1: أرضية عقد المضيف لهذا النوع وحده؛
أسقطه ليرث افتراض الكتلة). هذا الإعلان الواحد هو ما يبني
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 وإعادة
التركيز إلى الزرّ الذي فتحها ومنع تمرير الخلفية — كلّها في الحزمة. النافذة
المبنيّة يدوياً تبدو صحيحة وتترك مستخدم لوحة المفاتيح داخل الصفحة خلفها.
الرسوم البيانية — <x-chart>
مكوّن واحد لكل رسم بياني، والمكتبة (ApexCharts) مُدمَجة داخل المنصّة —
لا تحمّل Chart.js ولا ApexCharts من CDN: تطبيقك يعمل داخل صفحة المنصّة، وسكربت
من الخارج يعمل خارج حزمتها وسياسة أمانها ويرسم بألوان لا تعرف الثيم. الفاحص
يُرسِب البناء عليه (remote-chart-library).
{{-- من مكوّن Livewire: مرّر البيانات كما تخرج من الاستعلام --}}
<x-chart
type="bar"
:series="['حضور' => $present, 'غياب' => $absent]"
:categories="$days"
:title="__('my-app::stats.attendance')"
format="percent"
height="280"
/>
<x-chart type="donut" :series="['حاضر' => 42, 'غائب' => 3, 'متأخّر' => 5]" />
<x-chart type="line" :series="[['name' => 'الرسوم', 'data' => $fees]]" :categories="$months" format="currency" currency="SAR" />
type:line(افتراضي) ·area·bar·column·scatter·radar·heatmap·treemap·pie·donut·radial.seriesيقبل ما يخرج من استعلامك بلا إعادة تشكيل: قائمة أرقام، قائمة مفتاحية (المفاتيح تصير الفئات أو الشرائح)، خريطة اسم←قائمة (سلسلة لكل مفتاح)،[{name, data}]، ونقاط{x, y}للـ scatter. الدائرية تقبل[{name, value}]أيضاً.nullفجوة على الخطّ لا صفر.format:number·currency(+currency="SAR") ·percent، معprecisionللمنازل. الأرقام تُنسَّق بلغة الصفحة.- الألوان من tokens المنصّة: لون تطبيقك (من
brandفي البيان) أولاً ثم لوحة المنصّة، ونصّ المحاور والشبكة من الـ tokens نفسها — فالرسم يتبع الوضع الداكن وRTL تلقائياً ويُعاد رسمه عند تبديل الثيم.:colorsتتجاوز اللوحة إن احتجت. - التحديث الحيّ بلا كود: مرّر
:seriesمن خاصية أو دالة في مكوّن Livewire؛ مع كل إعادة تصيير يتحدّث الرسم في مكانه بانتقال متحرّك. لا حدث ولاentangle. - الفراغ مُعالَج: قيم كلها
null(أو مجموع دائري صفر) تُظهر حالة فارغة (emptyTextلتغيير النصّ). الصفر على خطّ قيمة لا غياب. :optionsمخرج الطوارئ: أيّ خيار ApexCharts خام يُدمَج آخراً — قبل أن تكتبnew ApexCharts(بنفسك (وهو ما يحذّر منه الفاحص:hand-driven-chart).- فتحتان:
<x-slot:actions>بجانب العنوان (فلتر، تبديل مدّة) و<x-slot:footer>.
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 فقط. أدنى عقد مضيف يحتاجه كودك — لكل نوع حساب، تحت مدخل كل صفّ في قائمة أنواع حسابات تطبيق أوكتا؛ النوع الذي لا يعلنها يرث افتراض الكتلة، وmobile.minContract المُصدَّر مرآةُ النوع الأساسي. |
| الأصول المسموحة (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" (الجهة كاملة). كل مكان مرّة واحدة؛ المكرّر يُبقي الصفّ الأول ويُرفض الثاني بالاسم. ولا يوجد "grade" في البيان — راجع الفقرة التالية. |
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']: اكتب المسار نفسه في الصفّين. عندها هي حزمة
واحدة باختيارك لا بحكم الصيغة — والمنصّة تعرف أنها ناتج واحد لا اثنان.
والشاشة على صفّ يخدمها إعلان section — ولا تُعلن grade إطلاقاً.
المسؤول يستطيع أن يضع الشاشة على صفوف بعينها (شاشة الجناح)، لكن الإعلان
لا يعرف كلمة grade ولن يعرفها: لا بيان منشور اليوم يحملها، فاشتراطها
كان يشحن المكان ميّتاً — كل تطبيق شاشة قائم يُرفض على أول شاشة جناح. فشاشة
الصفّ تأخذ حزمة section نفسها، لأن ما تعرضه هو ما تعرضه شاشة الفصل
على نطاق أوسع قليلاً.
وإعلان tenant لا يخدمها: لوحة الردهة تخاطب المدرسة كلّها، وعرضها على
شاشة جناح يضع المبنى الخطأ على الحائط. فإن أردت أن تخدم تطبيقك شاشات
الأجنحة، أعلِن section.
الصيغة القديمة ما زالت تُقرأ. الشكل الأول كان 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' أو 'grade' أو 'tenant'
// الأماكن كلّها، مفصولةً وبالترتيب الذي رتّبها به المسؤول:
final List ids = (ctx['section_ids'] as List?) ?? const [];
final List names = (ctx['section_names'] as List?) ?? const [];
// وعلى شاشة جناح، الصفوف بدلاً منها:
final List gradeIds = (ctx['grade_ids'] as List?) ?? const [];
final List gradeNames = (ctx['grade_names'] as List?) ?? const [];
// والمفرد باقٍ يحمل **أوّل** مكان — لمن كتب تطبيقه قبل المجموعة:
final dynamic sectionId = ctx['section_id'];
final dynamic sectionName = ctx['section_name'];
اقرأ القوائم لا المفرد. الشاشة قد تقف في فصلين، وsection_id يسمّي
أولهما وحده — فتطبيقٌ يقرؤه يعرض نصف ما على الحائط ولا شيء يقول له ذلك.
والقوائم تغيب حين لا يكون للشاشة أماكن من ذلك النوع (لوحة الردهة،
أو مضيف أقدم من المجموعة)، فاقرأها كما في السطر أعلاه: as List? ثم
افتراض فارغ. ولا تشقّ نصّاً على فاصل لاستخراجها — اسم فصل قد يحوي
الفاصل نفسه.
والمفرد غائب تماماً على شاشة الجناح، لا null: تلك تقف على صفوف لا
فصول، فلا section_id ولا section_name في الخريطة أصلاً (والعكس على
شاشة الفصل: لا grade_id). فنداء دالّة على المفقود مباشرةً —
ctx['section_id'].toString() وأمثاله — يقتل تطبيقك في المحرّك
بـtype 'Null' is not a subtype of type '$Instance' in type cast، وإن
وقع في initState تكرّر مع كل إطار. وهذا وقع فعلاً على حائط مدرسة. اقرأ
القوائم، أو افحص المفرد قبل استعماله.
ولن تحتاج أن تسمّي الأماكن بنفسك في أغلب الحالات: كل صفّ طالب يصلك
من /api/apps/education/students يحمل فصله وصفّه، فتجمّعهم منه مباشرةً.
القوائم هنا لترسم العناوين وترتّب الأعمدة، لا لتُرشّح البيانات — الترشيح
يقع على الخادم (انظر أدناه).
['screen'] يصلك كما كان — لكنه لم يعد آلية التوجيه: المنصّة تختار
لكل شاشة ملف الدخول المُعلَن لمكانها، فحزمة الفصل لا تعمل إلا في فصل.
يبقى مفيداً لملفٍ اختَرت أن يخدم المكانين معاً (تتفرّع عليه)، ولحارسٍ
يقول «هذه لوحة ردهة عُلِّقت على شاشة فصل» بدل أن يرسم لوحاً فارغاً.
وقيمته 'grade' على شاشة الجناح — وهي تصل حزمة section كما قيل أعلاه،
فإن رسمت عنواناً باسم الفصل فاقرأ grade_names هناك.
اقرأ المفاتيح بـ ['…'] كما تقرأ locale، وتحقّق من null صراحةً ولا
تُدخل القيمة في نصّ قبل ذلك (راجع درس '$value' في قسم native).
tenant_id وlocale وis_dark تصل كما هي؛ وrole_id فارغ لأن الشاشة
بلا دور.
الشاشة ترى أماكنها فقط — بحكم الخادم
هذه ليست توصية لكودك. شاشة مقترنة بفصول تصل بيانات الطلاب عبر
/api/apps/education/students مثبَّتةً على أماكنها في الخادم:
- بلا أي مرشّح → طلاب فصولها هي، لا المدرسة.
?section_id=يسمّي فصلاً خارجها → صفحة فارغة./students/{ulid}لطالب خارجها → 404، كما لو لم يوجد./education/sections→ فصولها هي وحدها.
وشاشة الجناح مثلها بخطوة أوسع: طلاب فصول صفوفها، ومرشّح يسمّي صفّاً أو فصلاً خارجها يُفرِغ الصفحة.
فتطبيق النداء على شاشة الفصل لا يستطيع سرد المدرسة ولو طلب. والمرشّح الذي يسمّي مكاناً خارج الشاشة يُجيب صفحةً فارغة لا خطأً — وهو نفس ما يُجيبه فصلٌ غير موجود، عمداً: التفريق بينهما كان يجعل مشي المُعرّفات كشفاً لما في المدرسة.
وشاشة الجهة (tenant) ترى ما يراه تطبيقك على الهاتف، بالنطاقات نفسها
الممنوحة له عند التثبيت — لا نطاقات خاصة بالشاشات.
ولا نطاق جديد ولا رفع عقد في هذا كلّه: الأماكن تصل في Okta.context()
وهي خريطة يُسلسِلها المضيف كما هي، والترشيح يقع على الخادم من صفّ الجهاز.
تطبيقك يعمل كما هو ولا يُعلن شيئاً جديداً.
مثال: نداء الفصل (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)، فحلقة
التحديث استدعاء ذاتي بعد await 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.*. العقد الحالي 27.
| النداء | ما يفعله | أدنى عقد |
|---|---|---|
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 · chime — مفردات مغلقة، ومعها اهتزاز. وchime نغمة «مرّ طالبٌ وحُسب» وليست تغيير عقد (راجع «playSound باقية ولم تُستبدَل») |
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 |
OktaNfc.canListen/start/reads/stop (في package:okta_nfc) |
محتوى بطاقة NFC — الرقم والتقنيات والنصوص المكتوبة عليها. سحبٌ لا بثّ، وأندرويد وحده، ويحتاج منحة | 26 |
Okta.toolKeyStartOn(channels) |
يفتح القارئات التي تسمّيها وحدها — nfc · keyboard · uhf_bluetooth. toolKeyStart() لم تتغيّر وتبقى تفتح الكل. راجع «افتح ما تحتاجه وحده» |
26 |
OktaFace.availability/start/reads/stop · OktaFace.preview(size) (في package:okta_face) |
مَن رأته كاميرا التعرّف في المدرسة — نوع الشخص ومعرّفه، لا صورته ولا قالبه. availability تجيب كلمة تقول مَن يُصلح الرفض. سحبٌ لا بثّ، وحاسوب Windows يشغّل خدمة التعرّف، ويحتاج منحة. راجع «مَن رأته كاميرا التعرّف» |
27 |
التطبيق المصغّر لا يملك أي صلاحية dart_eval: لا شبكة ولا نظام ملفات
إلا عبر هذه النداءات.
صيغة مفتاح البطاقة — واحدة، أياً كان النداء الذي أعادها
Okta.scanNfc() وOkta.toolKeyReads() وOktaNfc.reads() تُعيد المفتاح في
الصيغة القانونية نفسها التي يخزّنها okta-web: حروف صغيرة، بلا فواصل (: أو - أو مسافات)،
وبلا محارف تحكّم — فقارئ يرسل AA:BB:CC وآخر يرسل aabbcc يصلانك aabbcc.
قارِن مباشرةً. لا تكتب .toUpperCase() ولا .toLowerCase() قبل المقارنة:
لن تضرّ لأن التطبيع مُنتِج لنفسه، لكنها تُخفي أي انحراف مستقبلي بدل أن
تكشفه، وتجعل شفرتك تبدو كأنها تعرف شيئاً عن الصيغة لا تعرفه.
إن كان تطبيقك يحمل التفافاً على هذا فاحذفه. كان
Okta.scanNfc()يعيد حروفاً كبيرة غير مطبَّعة بينما مسار مفاتيح الأدوات يعيد الصيغة القانونية، فبطاقة تُعرَّف في شاشة الأدوات لا تطابق نفسها حين تُقرأ عبرscanNfc. لم يكن يظهر كعطل: المسح ينجح والبحث يخيب، فيبلّغ التطبيق أن الشخص غير مسجَّل. صُحّح في المضيف، والسطحان متطابقان الآن.
وينطبق الأمر نفسه على جسر WebView: حدث nfc صار يحمل الصيغة القانونية بعد
أن كان يحمل حروفاً كبيرة.
افتح ما تحتاجه وحده — toolKeyStartOn (العقد 26)
Okta.toolKeyStart() تفتح كل قارئ يملكه الجهاز، وهو الصواب لبوّابة تقبل
أي بطاقة. وهو خطأ لشاشة تقرأ نوعاً واحداً: بقيّة الراديوهات تُشعَل بلا أن
تُقرأ، وجلسة NFC يمنحها النظام لمالك واحد — فتطبيقٌ يقرأ باركود ويترك NFC
مفتوحة يحجزها عن Okta.scanNfc() وعن OktaNfc ما دامت شاشته قائمة.
// مفردة مغلقة: nfc · keyboard · uhf_bluetooth
final ok = await Okta.toolKeyStartOn(['nfc']);
if (!ok) {
// جهازٌ بلا ذلك القارئ — حالة عادية، اعرض بديلاً
}
الجواب نفسه الذي تعطيه toolKeyStart: true إن فُتحت واحدة على الأقل،
وfalse إن لم يُفتح شيء، ولا ترمي أبداً. وبقيّة النداءات لم تتغيّر —
toolKeyReads وtoolKeyChannels وtoolKeyStop تعمل كما هي أياً كان النداء
الذي فتح.
واقرأ toolKeyChannels() بعدها، ولا تثق بالقائمة التي أرسلت. هذا ليس
احتياطاً زائداً: الاسم الذي لا يعرفه المضيف يُسقَط، وطلبٌ لم يبقَ فيه اسم
معروف يُقرأ «كل القنوات» — أي أن ['NFC'] بحرف كبير تفتح الثلاث لا واحدة.
الاتجاه مقصود: تطبيقٌ يحسب قائمته أثناء التشغيل ويصل إلى لا شيء يُفضَّل أن
يحصل على قارئ يعمل بدل قارئ ميّت — قارئٌ لا يفتح شيئاً بصمت هو العطل الذي
وُجدت هذه القدرة لتمنعه. لكنه يعني أن الخطأ المطبعيّ يوسّع ولا يضيّق، ولا
شيء على الشاشة يستطيع أن يقوله لك. toolKeyChannels() هو ما يقوله:
await Okta.toolKeyStartOn(['nfc']);
final live = await Okta.toolKeyChannels(); // ['nfc'] لو نجح التضييق
وtoolKeyStart() لم تتغيّر حرفاً — لم تكتسب وسيطاً ولم يُعَد توجيهها. إن
كان تطبيقك يفتح الكل فلا شيء عليك أن تفعله، ولا ترفع minContract إلى 26 من
أجل رمز لا تناديه.
والمحاكي قد يتأخّر عن الهاتف، وهذا ليس عطلاً بل بنية. «الجهاز الافتراضي» ناتج بناء من okta-app يُنشَر في البوّابة يدوياً، فله رقم عقد خاصّ به قد يكون أقدم مما تقرؤه في الجدول أعلاه. ورمزٌ فوق عقد المحاكي لا يُجيبك
falseهناك — لا يُترجَم أصلاً، برسالة تسمّي ملفاً لم تكتبه (Cannot find static methodأوCannot find import).اليوم غلافا المحاكي عند العقد 27، فكل ما في الجدول أعلاه يُترجَم فيهما. والقاعدة باقية للعقد التالي: حين يتقدّم الهاتف قبل أن يُعاد بناء الغلافين، جرّب الرمز الجديد على جهاز حقيقي.
قراءة محتوى البطاقة — package:okta_nfc (العقد 26)
كل ما سبق يجيب لمن هذه البطاقة. وهذا يجيب ماذا عليها: الرقم نفسه، والتقنيات التي قدّمتها الشريحة، والنصوص المكتوبة فيها (سجلات NDEF). ولا يكتب على بطاقة ولا يغيّرها — ولن يفعل: الكتابة لا تُتراجَع على عتاد تسلّمه المدرسة لطفل.
والرقم هو الرقم نفسه. row['key'] هنا هو حرفاً بحرف ما يعيده
Okta.scanNfc() لنفس البطاقة: قراءة واحدة على المضيف، ومُطبِّع واحد، وصيغة
قانونية واحدة. فتطبيق ينتقل إلى هذا السطح يبقى يحلّ كل أداة مسجَّلة عبر
GET /tool-keys/resolve نفسها، بلا إعادة ربط وبلا سجلّ ثانٍ. لا تُطبِّعه من
جديد، ولا ترفع حروفه قبل المقارنة.
اسأل قبل أن ترسم
import 'package:okta_nfc/okta_nfc.dart';
if (await OktaNfc.canListen()) {
// ارسم زرّ الاستماع
} else {
// اسقط إلى Okta.scanNfc() — نفس الرقم، بطاقة واحدة في المرّة
}
canListen() تجيب false على كل ما ليس أندرويد، وعلى جهاز بلا NFC،
وحين لا تمنح الجهة تطبيقك هذه القدرة. وثلاثتها أجوبة عادية عن أجهزة
عادية. زرٌّ يُرسم بلا سؤال هو زرٌّ يُضغط فلا يحدث شيء — وهو العطل الذي وُجد
هذا الاستطلاع ليمنعه.
النطاق المطلوب
nfc.android_listener.read في scopes[] من بيانك. وهو مورد مستقل لا
فعلٌ أوسع على نطاقات الأدوات، عمداً: مفتاح الأداة معرّفٌ أصدرته المنصّة
وتستطيع سحبه، أما البايتات بجانبه على الشريحة فكتبها من برمج البطاقة ولا
سجلّ للمدرسة بها — سؤالان مختلفان، وتُسأل الجهة عنهما مرّتين.
والمنحة تُقرأ عند كل فتح، فسحبها من شاشة التثبيت يغلق القدرة في الفتحة التالية بلا أن تنشر شيئاً.
minContract: 26 إلزامي
تطبيق ينادي هذه المكتبة يجب أن يُعلن mobile.minContract: 26. وإغفالها
لا يجعله يعمل على مضيف أقدم: التطبيق المصغّر يُترجَم على الجهاز، فمكتبة
غائبة فشلُ ترجمة. الفرق أن المضيف يتعرّف على الاستيراد المفقود ويعرض
«حدِّث التطبيق» بدل أن يترك المستخدم أمام رسالة مترجِم.
ولا يوجد «جرّب 26 وإلا اسقط إلى 25» في إصدار واحد: المترجم يحلّ الرمز قبل
أن يُنفَّذ أي شرط، فمجرّد وجود النداء في الكود يكفي. الاختيار عند النشر:
إمّا تُعلن 26 وتستعمل الاثنين (هذا السطح وOkta.scanNfc()) بحسب الحالة،
أو لا تُعلن شيئاً وتبقى على القديم في كل مكان.
اسحب في حلقة، ولا تنتظر
لا شيء يستطيع أن ينادي إلى داخل تطبيقك المصغّر، فالقراءات تُجمَع
وتُسحَب — نفس شكل Okta.toolKeyReads():
Future<void> _drain() async {
final rows = await OktaNfc.reads();
for (final row in rows) {
final key = OktaJson.str(row, 'key'); // نفس رقم scanNfc
final text = OktaJson.str(row, 'text'); // المكتوب على الشريحة
_handle(key, text);
}
await Future.delayed(const Duration(milliseconds: 300));
if (_listening) {
_drain();
}
}
احرس الحلقة بعلمك أنت: State.mounted غير مجسور، فلا يستطيع فاصلٌ غير
متزامن أن يسأل هل ما زال على الشاشة. وحلقةٌ تنسى نفسها تعمل حتى يموت
التطبيق.
شكل الصفّ: key · at (ISO-8601 بتوقيت UTC، حين قرأها المضيف) ·
text (كل النصوص مجموعة بأسطر جديدة، أو '') · technologies ·
records (لكل سجلّ tnf وtype وtext).
وtext حقلٌ جاهز بجانب records عمداً: أكثر ما يُسأل هو «ما المكتوب على
البطاقة؟»، والبنى المتداخلة الخارجة من فكّ JSON تحتاج عناية داخل المفسّر.
الحدود
- أندرويد فقط. iOS يعطي نافذة نظام لكل قراءة لا حلقة في الخلفية، وذلك
شكلُ
Okta.scanNfc()لا شكل هذا السطح. والويب بلا جلسة أصلاً. - جلسة واحدة للجهاز.
OktaNfc.start()تأخذ الراديو من قارئ الأدوات متعدّد القنوات، فلا تتوقّع أن يبقىOkta.toolKeyReads()يُخرج قراءات NFC أثناء الاستماع — نادِOkta.toolKeyStart()مرّة أخرى بعدOktaNfc.stop()إن أردته. stop()يرمي ما في الذاكرة. القراءات المخزَّنة بطاقاتٌ لُوِّح بها قبل دقائق، وإعادتها إلى شاشة جديدة تصرّفٌ على ما لم يره أحد.- والمحاكي يرفض بصدق:
canListen()تجيبfalseهناك دائماً، فيُجرَّب مسار السقوط في المتصفّح، والقراءة الحقيقية على جهاز أندرويد.
مَن رأته كاميرا التعرّف — package:okta_face (العقد 27)
المدرسة تشغّل خدمة التعرّف على الوجه على حاسوب Windows في المبنى — عند البوّابة، في مكتب الاستقبال، عند باب القاعة. وهذا الفصل يجعل تطبيقك يطلب منها أن تعمل ما دامت شاشته قائمة، ويخبرك مَن تعرّفت عليه: نوع الشخص ومعرّفه على المنصّة، ووقت رؤيته.
ولا شيء غير ذلك يصلك — لا صورة ولا قالب ولا درجة تطابق ولا اسم. المضيف هو من ينزّل قوالب المدرسة، بصلاحية الشخص المسجَّل على ذلك الجهاز نفسه، ويسلّمها للمحرّك على الحاسوب نفسه. قالبٌ في يد تطبيق قالبٌ يستطيع أن يرسله إلى أيّ مكان، ولا يُصدَر للطفل وجهٌ بديل — فلا رمز في هذه المكتبة يستطيع حمل واحد، في أيّ اتجاه.
اسأل قبل أن ترسم — والجواب كلمة
import 'package:okta_face/okta_face.dart';
final status = await OktaFace.availability();
if (status == 'ready') {
// ارسم زرّ البدء
} else {
// قل لماذا — والكلمة تقول مَن يُصلح
}
| الكلمة | معناها | مَن يُصلحها |
|---|---|---|
ready |
البدء سيعمل | — |
unsupported |
الجهاز ليس Windows — المحرّك يعيش على واحد. والمحاكي وشاشات العرض يجيبونها دائماً | لا أحد — جهاز آخر |
not_granted |
الجهة لم تمنح تطبيقك face_recognition.matches.read |
الجهة، من شاشة التثبيت |
service_offline |
الخدمة لا تُجيب بعد: المضيف يشغّلها بنفسه حين تسأل، وإقلاعها قد يأخذ دقيقة — أو لا خدمة مثبّتة على هذا الحاسوب | لا أحد وهي تُقلع — اسأل ثانيةً. وإن دامت فالفنّي: الإعدادات التقنية في تطبيق أوكتا تقول لماذا |
no_permission |
الشخص المسجَّل لا يملك تحميل وجوه المدرسة على هذا الجهاز. وفي بوّابة المجمّع أيضاً: لم يعد يملك هناك المفتاحين (tenants.children.operate_apps وصلاحيةً من تطبيقك) |
من يمنح صلاحيات المدرسة — أو المجمّع |
no_faces |
لا أحد مسجَّل بوجهه للنموذج الذي يعمل به المحرّك | مكتب التسجيل |
busy |
الكاميرا مع شيء آخر على هذا الحاسوب — شاشة التسجيل عادةً | انتظر واسأل ثانيةً |
unreachable |
تعذّر جلب قائمة المدرسة الآن | الشبكة — أعد المحاولة لاحقاً |
too_large |
في بوّابة المجمّع وحدها: الشجرة أكبر ممّا تحمله بوّابة واحدة — فوق سقف المنصّة، أو أطول من الرسالة الواحدة التي تقبلها الخدمة على هذا الحاسوب. ولا تُحمَّل ناقصةً أبداً | لا أحد عند البوّابة: تشغّل كل مدرسة بوّابتها هي (فتحمّل مدرستها وحدها) |
service_outdated |
في بوّابة المجمّع وحدها: الخدمة على هذا الحاسوب لم تُثبت أن تطبيق أوكتا هو من شغّلها، أو لا تقرأ القائمة المضغوطة — بناءٌ أقدم، أو خدمةٌ شُغّلت باليد. وتبقى تخدم المدرسة الواحدة كما كانت | الفنّي: يحدّث الخدمة، أو يغلق المُشغَّلة باليد ويترك التطبيق يشغّل خدمته |
واقرأ أيّ كلمة لا تعرفها «غير جاهز» — القائمة قد تكبر. ولماذا كلمة لا
bool؟ لأن كل رفضٍ يُصلحه شخصٌ مختلف، وfalse واحدة كانت سترسل الأربعة إلى
المكان الخطأ نفسه.
وثلاث كلمات تزول وحدها، فأعد السؤال: service_offline (الخدمة تُقلع —
المضيف بدأ تشغيلها حين سألت)، وbusy (الكاميرا تتحرّر)، وunreachable (الشبكة
تعود). فلا تسأل مرّة عند فتح الشاشة وتكتفِ: أعد السؤال كل نصف دقيقة ما دامت
الشاشة قائمة ولم يبدأ التعرّف، كما يفعل المثال أدناه. والمضيف لا ينتظر الإقلاع
عنك عمداً: availability() تُجيب خلال ثوانٍ لأنها تُنتظَر في كودك أنت، وربما
على السلسلة نفسها التي تخدم قارئات البطاقات — ودقيقتان من الانتظار هناك دقيقتان
لا تُقرأ فيهما بطاقة.
النطاق المطلوب
face_recognition.matches.read في scopes[] — قراءة فقط، ولن يكون غير ذلك.
ويُقرأ عند كل فتح كنطاق NFC، فسحبه من شاشة التثبيت يغلق القدرة في الفتحة
التالية بلا أن تنشر شيئاً.
وليس بديلاً عن education.students.read: هذه المنحة تخبرك مَن رُئي
بمعرّفه، والاسم تقرؤه عبر واجهة الطلاب إن كانت الجهة منحتك إيّاها. منحةٌ واحدة
لا تصير اثنتين بصمت. وperson_id هو المعرّف نفسه الذي تجيب به
/api/apps/education/students، فالمطابقة مباشرة.
minContract: 27 إلزامي
حجّة العقد 26 حرفاً بحرف: المكتبة تُترجَم على الجهاز، ومضيفٌ أقدم يتعرّف على الاستيراد المفقود ويعرض «حدِّث التطبيق». ولا «جرّب 27 وإلا اسقط» في إصدار واحد — المترجم يحلّ الرمز قبل أن يُنفَّذ أيّ شرط.
ابدأ، واسحب في حلقة، وأوقف عند الخروج
bool _listening = false;
bool _closed = false;
Future<void> _open() async {
if (_closed) return;
final status = await OktaFace.availability();
if (_closed) return;
if (status == 'ready') {
if (await OktaFace.start()) {
_listening = true;
_drain();
return;
}
}
// الخدمة قد تكون تُقلع، والكاميرا قد تتحرّر، والشبكة قد تعود — فاسأل ثانيةً.
if (_passes(status)) {
await Future.delayed(const Duration(seconds: 30));
_open();
}
}
bool _passes(String status) =>
status == 'ready' ||
status == 'service_offline' ||
status == 'busy' ||
status == 'unreachable';
Future<void> _drain() async {
final rows = await OktaFace.reads();
for (final row in rows) {
_arrived(OktaJson.str(row, 'person_type'), OktaJson.str(row, 'person_id'));
}
await Future.delayed(const Duration(milliseconds: 500));
if (_listening) {
_drain();
}
}
void _close() {
_closed = true;
_listening = false;
OktaFace.stop();
}
و_closed مرّتين: قبل السؤال، كي لا يسأل سؤالٌ مؤجَّل — فيشغّل الخدمة — لشاشة
أُغلقت؛ وبعده، كي لا تبدأ الكاميرا لشاشة أُغلقت والجواب في طريقه. نادِ _open()
من initState و_close() من dispose.
شكل الصفّ: person_type (student أو employee) · person_id · at
(ISO-8601 بتوقيت UTC، حين رآه المضيف). والمفاتيح الثلاثة حاضرة دائماً.
واقرأ الصفّ في حلقة for كما أعلاه — لا تمرّره عبر ?: (المصيدة ١٥).
صفٌّ لكل وصول لا لكل إطار: المحرّك يتعرّف على كل إطار مرّتين في الثانية، والمضيف يطوي ذلك — مَن يقف أمام الكاميرا صفٌّ واحد، ولا يعود صفّاً ثانياً إلا بعد أن يغيب بضع ثوانٍ. فلا تكتب منع تكرار خاصّاً بك لهذا السبب. والمخزن يحتفظ بأحدث 256 إن لم تسحب.
والمعاينة: OktaFace.preview(size) ودجت يرسمه المضيف — الكاميرا الحيّة ما
دام تشغيلك أنت يعمل، وصندوقٌ فارغ بالمقاس نفسه قبل start وبعد stop وحين
تكون الكاميرا مع غيرك، فلا يتحرّك ما حولها.
كاميرا واحدة، ولها مالك
للحاسوب كاميرا واحدة والخدمة تشغّل شيئاً واحداً. فـstart() لا تأخذها من
شيء آخر — تجيب false، وavailability() تقول busy. وstop() تُنهي
تشغيلك وحده: إن أخذ مكتب التسجيل الكاميرا في الأثناء تُركت له.
والمضيف يُبقي تشغيلك حيّاً عنك: إن أُعيد تشغيل الخدمة، أو استعار مكتب التسجيل الكاميرا ثم أعادها، يعود التعرّف وحده بلا نداء منك — لأن البديل بوّابةٌ صمتت الساعة 7:40 ولا تقول شيئاً. فلا تكتب حلقة «أعد البدء إن توقّف» — بعد أن يبدأ. أمّا قبل ذلك فإعادة السؤال عليك (أعلاه).
الحدود
- Windows وحده، وحيث تعمل الخدمة. الهواتف وشاشات العرض والمتصفّح تجيب
unsupported، وهو الفرع الذي يسلكه أكثر الأجهزة — اكتبه. - في المدرسة قائمتها، وفي المجمّع شجرته المُشغَّلة. حين تسمح المنصّة لهذا
الفتح بتشغيل مدارس المجمّع (والإطلاق يقول ذلك لتطبيق أوكتا — «التطبيق يعمل
داخل مدارس المجمّع»)، تكون القائمة طلاب المجمّع نفسه وطلاب كل مدرسة تُديرها
نسختُه ومُنحت نسختُها
face_recognition.matches.read. الطلاب وحدهم: لا يُتعرَّف في هذا الوضع على موظّف، لا من المدارس ولا من المجمّع. ويبقى الصفّ بمفاتيحه الثلاثة ولا يسمّي مدرسة — ضع الطالب في مدرسته أنت، منentity_idفي كشف بطاقاتك أو على خادمك بـActForStudentsInTree. وأولavailability()هناك ينتظر تنزيل الشجرة كلّها (والوجوه تُنسى حين تُغلق الشاشة وتُنزَّل ثانيةً في الفتح التالي) — فلا تؤخّر قارئات البطاقات في المجمّع حتى يجيب الوجه. - والمحاكي يجيب
unsupportedبصدق — تبويب متصفّح بلا محرّك. فجرّب فيه فرع «غير جاهز» كلّه، والتعرّف الحقيقي على حاسوب المدرسة.
الماسح المدمج: 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)
شريط مستدير منفصل عن حواف الشاشة يطفو فوق المحتوى. مجرد استعمال الودجت يكفي:
ولم يعد هذا شكل المنصّة. بار أوكتا اليوم صلب ومثبَّت على الحافة —
OktaTabs.bottomBar، راجع «الشكل الثابت لكل تطبيق». العائم باقٍ يعمل كما هو لمن يعتمده، فهو خيارُ هيئةٍ لا الهيئةَ الافتراضية.
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بديلاً — وهي متاحة منذ العقد 1، وهي شكل المنصّة اليوم.
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']}');
}
المجمّع يقرأ مدارسه من التطبيق المصغّر — ?entities=
تطبيقك مفتوحٌ في مجمّع، والطلاب في مدارسه. لا تحتاج نقطة خادم: مسارات
البيانات نفسها تجيب عن المدارس التابعة حين تسمّيها، وOkta.getQuery يحمل
المعامل على كل مضيف (بلا رفع minContract). المثال التالي يرسم لوحة المجمّع:
مدارسه بشعاراتها، وعدد طلاب كل منها في نداء واحد. شُغِّل كما هو مكتوب على
محرّك التطبيقات المصغّرة، والأسطر التي تبدو متكلّفة تفرضها المصائد في قسم
«مصائد أثبتتها الأجهزة» أدناه.
// ١) مدارس المجمّع، وهل يحقّ لهذا الشخص قراءتها
final tree = await Okta.getQuery('/api/apps/entity/children', {'descendants': '1'});
if (!tree.ok) return;
final dynamic payload = tree.body;
// `== true` لا قراءةٌ مباشرة: منصّةٌ أقدم لا تُرسل هذا المفتاح،
// و`== true` هي المقارنة التي تقرأ المفتاح الغائب بأمان.
final bool allowed = payload['can_read_children'] == true;
if (!allowed) {
setState(() { _allowed = false; }); // لا تعرض زرّ التنزيل أصلاً
return;
}
final List<String> ids = [];
String joined = '';
final dynamic nodes = payload['data'];
for (final dynamic node in nodes) {
if (node['type'] == 'school' && ids.length < 20) { // حتى ٢٠ في النداء
final String id = '${node['id']}'; // مستوفاة، فيملكها المفسّر (الفخّ ١ب)
ids.add(id);
joined = joined == '' ? id : '$joined,$id';
}
}
// ٢) عدد طلاب كل مدرسة — نداءٌ واحد، ومفتاحٌ لكل مدرسة في الردّ
final res = await Okta.getQuery('/api/apps/education/students', {
'entities': joined,
'per_page': '1', // نريد الإجمالي لا الصفوف
});
if (!res.ok) return;
final dynamic answers = res.body['entities'];
final List<dynamic> rows = [];
for (final String id in ids) {
final dynamic answer = answers[id];
// المدرسة التي أجابت لا مفتاح `error` فيها، والمرفوضة لا `total` فيها.
// والمفتاح الغائب null خام: اسأل `identical` لا `== null` (الفخّ ٧).
final dynamic error = answer['error'];
if (identical(error, null)) {
rows.add({'id': id, 'total': '${answer['total']}', 'error': ''});
} else {
rows.add({'id': id, 'total': '', 'error': '$error'}); // مدرسةٌ رُفضت لا تُسقط الباقي
}
}
setState(() { _schools = rows; });
وفي build، حلقةٌ واحدة على _schools، والشعار مسارٌ يجلبه المضيف
(404 حين لا شعار، فيُرسم فراغاً):
Okta.remoteImage('/api/apps/entity/children/${school['id']}/logo', 32)
ومدرسة واحدة بطلابها وصورهم — ?entity= يعيد ردّها كما هو، فتقرؤه
بالكود نفسه الذي تقرأ به طلاب جهتك:
final one = await Okta.getQuery('/api/apps/education/students', {
'entity': schoolId,
'per_page': '50',
});
// ثم لكل صفّ:
Okta.remoteImage('/api/apps/education/students/${row['id']}/photo?entity=$schoolId', 40)
ثلاثة أشياء تحكم هذا الشكل:
- لا حلقة داخل حلقة. dart_eval لا يترجمها (راجع «مجموعة dart_eval المدعومة»
أدناه)، فلا تمشِ المدارس ثم طلاب كل مدرسة في دالة واحدة. خذ من الغلاف ما لا
يحتاج حلقة ثانية (
total، أوerror)، واقرأ صفوف مدرسة بعينها بـ?entity=حين تُفتح. - المرفوضة مفتاحٌ لا الردّ كلّه — ومفتاحها بحقول أخرى. الجواب يحمل حقول
المسار ولا
errorفيه، والرفض يحملerror(missing_scopeمعscope، أوentity_not_found) ولاtotalفيه. اعرف أيّهما بين يديك بـidentical(answer['error'], null): أمّاanswer['error'] == nullفتقرأ المفتاح الغائب false، وتخزين ما تعيده يقتل التطبيق المصغّر. can_read_childrenقبل الزرّ، مقروءةً بـ== true. شخصٌ لا يحمل الصلاحية يُرفض الطلب كلّه (children_not_readable)، فلا ترسم له ما سيُرفض. ومنصّةٌ أقدم من هذه الميزة لا تُرسل الحقل أصلاً، و== trueتقرأ ذلك «لا» — الجواب نفسه، وللسبب الصحيح.
والقاعدة كاملةً — أيّ منحة تُقرأ بها المدرسة، ومتى يُرفض الطلب — في فصل «الشركات والمجمّعات: شجرة الجهة وبيانات مدارسها».
التحقق محلياً — نفس بوابة الـ 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 — أرضية عقد المضيف، لكل نوع حساب
عقد المضيف مُرقَّم. أعلِن الأرضية التي يعتمد عليها كودك، فيعرض تطبيق أوكتا «حدّث التطبيق» بدل تشغيل تطبيق مصغّر لا يفهمه الجهاز — ومنذ صار ردّ الإطلاق يحمل الأرضية، يقولها قبل تنزيل الحزمة لا بعد ترجمتها.
- الأرضية لنوع الحساب لا للتطبيق. كل صفّ في قائمة أنواع الحسابات لسطح
تطبيق أوكتا في محرّر الإصدار له حقل «أدنى عقد مضيف» تحت مدخله (في البيان:
audiences[].minContract). حزمة الموظّفين المكتوبة لمضيف أحدث يجب ألّا تُغلق حزمة وليّ الأمر عن الهواتف التي ما زالت تُترجَم عليها. (وحين تريد حزمةً أحدث لهواتف أحدث بلا أن تُقفل الأقدم إطلاقاً، فذاكversions[]— القسم التالي.) - النوع الذي لا يعلن أرضيته يرث افتراض الكتلة (
mobile.minContract، وهو ما تكتبه أداة MCPset_mobile_surface.min_contract). وما يُصدّره المحرّر فيmobile.minContractمرآةُ أرضية نوعك الأساسي، فبيانٌ لم يضبط يوماً إلا قيمة الكتلة يبقى يعني ما كان يعنيه بالضبط — لا شيء نُشر قبل هذا الحقل تغيّر سلوكه. - أعلن الأرضية التي تحتاجها فعلاً قدرات المضيف التي تناديها. (قاعدة «≥ 13 لأنك أعلنت قدرة جهاز» ذهبت مع البوّابة.)
- ينطبق على
nativeوحده؛ المحرّر يفرّغه في أي وضع آخر. - مسار
.dartواحد = عقد واحد. المدخل نفسه مُعلَناً مرّتين بأرضيتين مختلفتين — جمهور وبطاقة اللوحة، أو مكان شاشة — يُبنى بالإعلان الأول ويُسجَّل تحذير عند النشر؛ لا يُرفَض. أعطِ كل أرضية حزمتها. (ويُرفَض فعلاً حين يكون أحد الطرفين سطراً إضافياً — القسم التالي.) - والأرضية اختيارية، والفراغ جوابٌ حقيقي: اتركها فارغةً فيعمل المدخل على
كل الأجهزة بما فيها الأقدم ما لم يستوفِ الجهاز سطراً مقيَّداً بعقد أعلى — الخادم يجرّب الأسطر أولاً ثم يسقط إلى الافتراضي. okta-web يحلّ الأرضية الغائبة
الخاصة ← الكتلة ← 1، والواحد دون كل مضيف شُحن يوماً. وهو ما يفعله كل تطبيق منشور قبل أرضيات الأنواع، فلا شيء يلزمك بالرقم حتى تحتاجه. لكنها — مكتوبةً أو محلولةً — أرضيةٌ لا يجوز أن يكرّرها سطر إضافي في النوع نفسه: فالافتراضي بلا أرضية يطالب بالعقد 1، وسطرٌ عند 1 جوابٌ ثانٍ لسؤال واحد. القسم التالي. - تحت الحقل يعرض المحرّر أيّ إصدارات okta-app تشغّل أيّ عقد
(
1.1.1 → 26 · …)، مرآةً للجدول الذي يديره فريق أوكتا على okta-web (partners:sync-okta-app-releases، كل ساعة). اختر الأرضية التي يحتاجها كودك، ثم اقرأ هناك أيّ هواتف تبلغها. - MCP:
set_account_typesتقبلmin_contractلكل صفّ (صفوف native ذات مدخل فقط؛ وما دون 1 يُرفَض بالاسم)؛get_account_typesوget_mobile_surfaceتطبعان أرضية كل نوع وافتراض الكتلة والمرآة المُصدَّرة؛validate_manifestترفضminContractالمشوَّه وتحذّر من مسار مُعلَن بعقدين.
مداخل إضافية لنوع الحساب — audiences[].versions[]
الأرضية تقول لا تُشغّل هذا المدخل تحت مضيف كذا، ولا تقول أبداً ماذا يُشغَّل بدلاً منه — فحزمةٌ كُتبت لمضيف أحدث كانت تترك أمامك خيارين: أن تُقفل نوع الحساب عن الهواتف الأقدم، أو تبقى على الحزمة القديمة إلى الأبد. السطر الإضافي هو الثالث: أكثر من مدخل لنوع الحساب الواحد، والخادم يختار.
وكل سطر مقيَّد بمحور واحد، تختاره أنت:
| المحور | يقول | متى |
|---|---|---|
minContract |
«البناء الذي يُشغّل المضيف 28 أو أحدث يأخذ هذا» | السؤال الدقيق — العقد هو ما يقرّر فعلاً هل تُترجَم الحزمة على الجهاز |
minAppVersion |
«البناء عند 1.2.0 أو أحدث يأخذ هذا» | للبناءات التي لا تستطيع الإجابة عن السؤال الدقيق: كل okta-app شُحن قبل ترويسة X-App-Contract يبلّغ إصداره ولا يبلّغ عقده |
وسطرٌ بلا محور يُرفَض: يسمّي مدخلاً ولا يقول من يأخذه، فلا يوجد هاتف يستطيع الخادم أن يعطيه إيّاه.
"audiences": [
{ "key": "teacher", "mode": "native",
"entry": "okta_app/native/main/lib/main.dart",
"minContract": 20,
"versions": [
{ "minContract": 28,
"entry": "okta_app/native/main_v3/lib/main.dart" },
{ "minAppVersion": "1.2.0",
"entry": "okta_app/native/main_v2/lib/main.dart" }
] }
]
كيف يختار okta-web، بالترتيب. عقد الهاتف من ترويسة X-App-Contract، أو
يُستنتَج من جدول الإصدارات حين لا يبلّغ إلا إصداره. ثم:
- أسطر العقد (الأعلى أرضيةً أولاً): أوّل أرضية يبلغها عقد الهاتف. وعقدٌ مجهول لا يبلغ شيئاً هنا — السطر يسمّي مضيفاً، و«على الأرجح» ليس مضيفاً.
- أسطر الإصدار (الأحدث حدّاً أولاً): أوّل حدٍّ يستوفيه إصدار الهاتف (وعقده إن كان معلوماً يكفي أرضية السطر).
- المدخل الافتراضي
entryمهما كان عقده — بوّابة الجهاز تعرض «حدِّث التطبيق» كما اليوم.
فهاتفٌ لا يبلّغ عقداً يُقرأ ولا إصداراً يستوفيه يأخذ الافتراضي دائماً. وهذا هو وعد التوافق كلّه: أضِف سطراً ولا يتغيّر شيء ممّا يعمل — كل بناء okta-app أقدم من الترويستين يبقى يستلم ما يستلمه اليوم بالضبط.
العقد الواحد يسمّي مدخلاً واحداً داخل نوع الحساب — وأرضية المدخل الافتراضي
minContract واحدةٌ منها. سطرٌ يجلس على أرضية الافتراضي كان سيغلب كل هاتف
يبلّغ عقده ويخسر كل هاتف لا يبلّغه، وهو جوابٌ لا يقصده أحد. المحرّر وأدوات MCP
والنشر ترفضه بالاسم.
وأرضية المدخل الافتراضي اختيارية: اتركها فارغةً ويعمل المدخل على كل
الأجهزة ما لم يستوفِ الجهاز سطراً مقيَّداً بعقد أعلى — الخادم يجرّب الأسطر أولاً ثم يسقط إلى الافتراضي. وهو الأمر الذي كان دائماً، لأن okta-web يحلّ الغياب الخاصة ← الكتلة ← 1. اكتبها حين تكون حزمتك فعلاً بحاجة إلى مضيف أحدث، لا لأن الحقل يطلبها.
وانتبه إلى أن الافتراضي بلا أرضية يطالب بالعقد 1 على الخادم، فسطرٌ
مقيَّد عند 1 يُرفض بوصفه جواباً ثانياً لنفس السؤال.
وأرضية سطر الإصدار اختيارية: أهملها فيرث أرضية النوع — وهو ما يعطيه إيّاه okta-web أصلاً. اكتبها حين تكون الحزمة بحاجة إلى مضيف أحدث ممّا يحتاجه الافتراضي، ويملأها المحرّر من جدول الإصدارات حين يعرف عقد ذلك الإصدار.
ويجوز أن تترك entry فارغاً حين تُعلن أسطراً، ومعناها «هذا النوع يُخدَم
على تلك البناءات وحدها». قُلها عن قصد: النوع حينها يختفي عن كل هاتف لا يستوفي
أحد الحدود، وعن كل هاتف لا يبلّغ عقده ولا إصداره. المحرّر يحذّر بجانب
القائمة، وردّ أداة MCP يقولها نصّاً، والمراجع يراها شارةً حمراء.
الحدّ الواحد يسمّي مدخلاً واحداً على محور الإصدار كذلك. 1.02.0 و1.2.0
إصدارٌ واحد، وokta-web يثبّت الإملاء المُقنَّن في رابط الإطلاق الموقَّع — فإعلانهما
معاً يُرفَض بدل أن يُترَك صفّاً لا يستطيع أيّ إطلاق أن يبلغه.
ومسار .dart واحد = عقد واحد — وهنا خطأ لا التحذير الذي يناله التداخل
القديم: سطرٌ إضافي يسمّي مساراً يبنيه تصريح آخر بأرضية مختلفة رقمٌ ثانٍ لا يصل
جهازاً أبداً. أعطِ كل أرضية حزمتها.
عشرة أسطر بحدٍّ أقصى. القائمة سجلّ مداخل لا جدول — احذف الحدود التي لم يعد يعمل عليها أحد.
والمحاكي لا يحاكي هذا. تبويب المتصفّح بلا إصدار تطبيق ولا عقد يقارن بهما، فهو يسرد مداخلك الإضافية ويركّب الافتراضي. جرّب الحزمة الإضافية على جهاز يحمل ذلك المضيف.
MCP: set_account_types تقبل versions لكل صفّ (استبدال كامل مع بقيّة النوع،
كالمدخل الافتراضي)؛ وget_account_types وget_mobile_surface تطبعان كل سطر
بالمحور الذي قُيِّد به وتسمّيان النوع بلا افتراضي؛ وvalidate_manifest ترفض
سطراً بلا محور، وحدّاً مشوَّهاً، وحدّاً أو عقداً مكرَّراً، ومساراً إضافياً يصطدم
بعقد تصريح آخر.
والمسار المعتاد أن تكتب الأسطر في manifest.json ثم تستدعي
sync_from_manifest — تعبر إلى الإصدار كما يعبر المدخل الافتراضي. وما دامت
المنصّة تُدرّج الشكل على بيئتَي okta-web، تُرفَض الأسطر بالاسم عند الكتابة
وتقول الأدوات ذلك: رفضٌ يخصّ المنصّة لا بيانك.
الـ 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", "minContract": 12 }
]
}
runtime: "dart" تُضاف تلقائياً في وضع native — لا تكتبها بنفسك. وminContract
على الكتلة مرآةُ النوع الأساسي؛ القيمة التي تُحتسَب هي التي على كل جمهور، والصفّ
الذي لا يحملها يرث قيمة الكتلة.
عدّة حزم دخول تحت slug واحد
<entry> في okta_app/native/<entry>/ هو اسم حزمة Dart قائمة بذاتها، ولا
شيء يُلزمك بواحدة. جمهوران مختلفان يجوز أن يشيرا إلى حزمتين مختلفتين:
"audiences": [
{ "key": "staff", "mode": "native",
"entry": "okta_app/native/staff_app/lib/main.dart", "minContract": 26 },
{ "key": "family", "mode": "native", "portal": "guardian",
"entry": "okta_app/native/family_app/lib/main.dart", "minContract": 20 }
]
تُجمَع كل حزمة وتُخزَّن على حدة. حزمة المصدر تحمل شجرة lib/ للمدخل
المطلوب وحده — لا تُضمّ الحزم الشقيقة إليها إطلاقاً — ومفتاح الكاش يحمل
الـ entry بجانب الـ slug لهذا السبب بالذات. (لو لم يحمله لتصادمت الحزمتان:
الـ slug واحد وpayloadVersion واحد، فمن يُطلَق أولاً يكسب المدخل ويُشغَّل
الآخر ببايتكود الأول ويموت بـ Cannot find package:….)
فإن كانت واجهة الموظّف وواجهة وليّ الأمر مختلفتين جوهرياً، افصلهما حزمتين —
أنظف من if كبير، وكل واحدة تُترجَم وتُخزَّن وحدها.
ولكل حزمة أرضيتها كذلك: في المثال أعلاه تحتاج حزمة الموظّفين العقد 26 بينما ما زالت حزمة الأسرة تعمل على 20، فوليّ أمر هاتفه على 20 يبقى يفتح شاشة الأسرة بينما تعرض شاشة الموظّفين «حدّث التطبيق» — تطبيق واحد بأرضيتين، ولا هاتف يُغلَق عنه بسبب حزمة لا يشغّلها أصلاً.
حين لا يفتح التطبيق أصلاً — عائلة أخطاء الحمولة
البطاقة تظهر، تنقر، فيفشل التحميل قبل أن يعمل أي من كودك. هذا ليس خطأً في تطبيقك المصغّر: نقطة الإطلاق نجحت (فالتثبيت والنطاق والجمهور سليمة) ثم فشل تنزيل حزمة المصدر.
يُفرَض شكل الـ 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 مع المضيف المنشور.
٦. الفشل الصامت أسوأ من الفشل. الالتقاط المحلي يؤكَّد قبل استشارة الشبكة (وهذا صحيح — به ينجو المسح من انقطاع)، لكن معناه أن رفعاً لم يصل يبدو على الشاشة مطابقاً تماماً لرفع وصل. قل ذلك صراحةً: صوت، ورسالة، وسطر في السجل. من يقف على بوابة ينظر إلى الطالب لا إلى شارة في الترويسة.
٧. is ليست فحصاً آمناً على قيمة مجسورة — والمفتاح الغائب أسوأ حالاتها.
أخطر فخّ في هذا المحرّك. جسر 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 فتمرّر القيمة
للنداء التالي فيموت هناك بدلاً منها.
والقاعدة أوسع من المفتاح الغائب: على قيمة مجسورة، is المطابقة تجيب وغير
المطابقة ترمي. المفتاح موجود، وقيمته سليمة، ومع ذلك ترمي — رصده فريق
okta-hr على جهاز حقيقي على accuracy القادمة من Okta.location():
L23: $"accuracy" L24: 16.950000762939453
5436: Unbox (L24)
5437: NumLtEq (L31 <= L24) ← `is double` طابقت ونفّذت المقارنة
5441: IsType (L24 is 12) <<< EXCEPTION
type 'double' is not a subtype of type '$Value' in type cast
is double نجحت، ثم رمت is int على القيمة نفسها بعدها بأربع تعليمات.
فأي سلسلة is متتابعة على قيمة واحدة قنبلة موقوتة: أوّلُ اختبار لا يطابق
يرمي بدل أن يجيب false. والنمط شائع جداً في قراءة JSON (is int ثم
is double ثم is String)، وهو يعمل تماماً حتى تصل قيمة نوعها ليس الأول في
سلسلتك.
والفحص الوحيد الذي يجيب عن الغياب هو identical(value, null) — true
للمفتاح الغائب وللـnull الصريح، وfalse لكل شيء حاضر، مقيساً على المحرّك.
اسأله أولاً، ثم اقرأ القيمة:
final dynamic value = map['accuracy'];
if (identical(value, null)) return; // غائب أو null — الفحص الآمن الوحيد
final accuracy = double.tryParse('$value') ?? 0.0; // حاضر: استوفِه ثم حلّله
وحين يكون المفتاح حاضراً، فالقراءة الآمنة هي الاستيفاء — '$value' — وهو
نفسه إصلاح الفخّ ١ب أعلاه، هناك للتعليب وهنا للفحص: السلسلة الناتجة يخصّصها
المفسّر، فكل نداء عليها آمن، وتجيب عن النوع بلا اختبار is واحد. لكنها لا
تنجو من الغياب: استيفاء مفتاح غائب ينادي toString على الـnull الخام فيرمي
الخطأ نفسه type 'Null' is not a subtype of type '$Instance'. نسخةٌ سابقة من
هذا الدليل أظهرت '${map['accuracy']}' تجيب "null" عند الغياب — وهي لا
تفعل؛ فإن نسختَ ذلك السطر لمفتاح قد يغيب، فضع فحص identical قبله.
ولا تستعمل is إلا حين تكون السلسلة المستوفاة قد أكّدت لك أنها ستطابق.
ولا حارس 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 — لكن هناك ساعة، وFuture.delayed تُنتظَر ولا تُعطى دالّة.
DateTime (بـ .now() و.parse وadd وdifference و…) وDuration
وFuture.delayed كلّها مجسورة وموصولة، وjsonEncode/jsonDecode كذلك.
الغائب هو Timer وحده، أي الاستدعاء الدوري لا التأخير. فحلقة السحب
(toolKeyReads/cameraScanReads) تُكتب استدعاءً ذاتياً بعد
await Future.delayed(مدّة)، وتحتاج علمك أنت لإيقافها: State.mounted
غير مجسورة، فلا تستطيع فجوة غير متزامنة أن تسأل هل ما زالت على الشاشة.
ودالّةٌ تمرّرها إلى Future.delayed تُترجَم ولا تُنادى أبداً: الجسر يأخذ
المدّة وحدها ويُسقط الدالّة بصمت. فحلقةٌ تكتب
Future.delayed(مدّة, () { _drain(); }) تسحب مرّة واحدة ثم تسكت، بلا خطأ في
أيّ مكان. اكتبها هكذا، داخل دالّة async:
await Future.delayed(const Duration(seconds: 1));
_drain();
مقيساً على المحرّك الذي يعمل على الجهاز، وcheck_miniapp_source يرفض الشكل
الأول باسمه (future_delayed_callback). وأمثلة هذا الدليل كانت تستعمله في
حلقات السحب حتى صُحّحت، فإن نسخت حلقة سحب من نسخة أقدم فاستبدلها.
١٥. عنصرٌ من JSON لا يمرّ عبر ?:.
قيمة تعبير ?: أحدُ فرعيه عنصرٌ من قائمة أو خريطة مفكوكة تخرج غير
مُصندَقة، وأوّل OktaJson (أو أيّ is) بعدها يموت:
type '_Map<$Value, $Value>' is not a subtype of type '$Value' in type cast
final first = rows.isEmpty ? null : rows[0]; // يموت في OktaJson.str(first, …)
final first = rows.isEmpty ? {} : rows[0]; // والفرع الآخر لا يغيّر شيئاً
dynamic first; // إسنادٌ داخل if
if (rows.isNotEmpty) { first = rows[0]; }
final name = rows.isEmpty ? '' : OktaJson.str(rows[0], 'name'); // اقرأ داخل الفرع
مقيساً على المحرّك: rows[0] مباشرةً، وrows.first، وrows[0] ?? …، وحلقة
for — كلّها سليمة، و**?: وحده** يفكّ الصندقة. ووجده اختبارٌ على المحرّك
نفسه الذي يعمل على الجهاز لا شكوى من مدرسة: كان يُسقط اختبار العقد 26 في
مستودع المضيف منذ كُتب. فتجنّبه قبل أن يصير شكوى.
الرسائل الفورية (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 ·
chime، ومعها اهتزاز)، وهذا السطح نداء. شيئان مختلفان، ولذلك لم يُحذف
الأول.
والجرس هو ما تسقط إليه حين لا يكون على الجهاز صوت ولا يُبلَغ التسجيل — موجودٌ
على كل عقد منذ 5. لكن success نقرةُ النظام، ونقرة النظام لا تُسمَع على
Windows — أي على حاسوب البوّابة بالذات. هناك نادِ chime.
chime — نغمة «مرّ طالبٌ وحُسب». نغمةٌ قصيرة من نوتتين يشحنها تطبيق أوكتا
نفسه، ووُجدت لأن المرور السليم على حاسوب البوّابة كان صامتاً. ولم تُغيَّر
success: ذلك كان سيغيّر صوت كل تطبيق منشور دفعةً واحدة ولا شيء في أيٍّ منها
طلبه — فمن يريد النغمة يقولها.
- ليست تغيير عقد. كلمةٌ في مفردات المضيف لا رمزٌ في مكتبة محقونة، فلا
ترفع
minContractلأجلها ولا تفحص شيئاً قبلها. مضيفٌ أقدم منها يعاملها اسماً مجهولاً، وذلك في تطبيق أوكتا نقرةُsuccessنفسها — السقوط الصحيح لا عطل. - لا تكون أهدأ من
successأبداً. حين لا تُعزف — لا مخرج صوت، أو آيفون (جلسة الصوت فيه واحدة للتطبيق كلّه، فتبقى النقرة) — تُعزف النقرة بدلها. وعلى أندرويد تحترم وضع الصامت، وتخفض ما يُعزف تحتها ولا تقطعه. - مشغّلها مستقلّ: لا تقطع إعلاناً من
playAudio/speak، ولا تُسكتهاstopAudio(). ونداءٌ ثانٍ قبل أن تنتهي يعيدها من أوّلها. - استعملها لما تعنيه وحده: «رأيناه عند البوّابة». مرورٌ حُسب — حضورٌ أو
انصراف، ومتأخّرٌ كذلك لأن المرور حُسب؛ والحكم يقوله النصّ لا الصوت. وطالبٌ
حُسب مروره من قبل اليوم ثم عاد إلى الكاميرا أو نقر بطاقته تُسمعه النغمة نفسها
مع بطاقةٍ تقول «مسبقاً» (هذا ما تفعله «حضور» منذ 1.6.44 بطلب المدارس: الصمت
عند البوّابة يُقرأ «البوّابة توقّفت») — ولا تُسجّله ثانيةً، واللون أو النصّ يقول
أيّهما. الحفظ والإعدادات تبقى على
success: نغمةٌ تُسمَع مع كل حفظ لا تعود تقول شيئاً عند البوّابة. والتكرار الآليّ (البطاقة نفسها في مدى القارئ، وقارئ UHF الذي يقرأ ما في مداه مرّات في الثانية) لا يُسمَع أصلاً — اصمت عنه في كودك.
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;
}
// الجرس: القاع الذي لا يسقط منه شيء — لكنه نقرة النظام، ولا تُسمَع على
// Windows. وفي تطبيق أوكتا على ويندوز يُسمَع `chime` بدلها.
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.
الشكل الثابت لكل تطبيق — الإطار الذي يرسمه المضيف
كل ما يُفتح على أوكتا — شاشات المنصّة وتطبيقك المصغّر معاً — يجلس في إطار واحد: هيدر داكن بارتفاع 56 في الوضعين، وتحته الصفحة بزاويتين علويتين مدوّرتين على أرضية الهيدر نفسها، وبوتوم بار صلب مثبَّت على الحافة السفلية بارتفاع 72، وأنيميشن فتح واحد «البطاقة تصير الشاشة».
ولا شيء عليك أن تنشره. الإطار يرسمه المضيف حولك، وما تغيّر في
okta_kitهو أجسام دوالّ قائمة بلا أن يتحرّك توقيع واحد — فلا رمز محقون جديد، ولا عقد جديد، ولا ترفعminContract. تطبيقك المنشور اليوم يأخذ الشكل عند أول فتح على مضيف يحمله.
ما يرسمه المضيف حولك
| الطبقة | مَن يرسمها | ملاحظة |
|---|---|---|
| الهيدر الداكن (56) | المضيف | وOktaAppBar يهبط عليه لوناً بلون |
| الزاويتان المدوّرتان تحت الهيدر | المضيف | يقيس أين ينتهي شريطك ويقصّ ما تحته |
| عناصر الهيدر الثابتة (اللغة، المظهر، الأفاتار) | المضيف | لا يرسمها تطبيق مصغّر أبداً |
| أنيميشن الفتح | المضيف | من البطاقة التي ضُغطت إلى الشاشة |
| مساحة تطبيقك | أنت | بألوان OktaPalette |
والقصّ مشروط بأن يكون لتطبيقك شريط: Scaffold بلا appBar لا وصلة تحته
ولا زاويتين. فإن أردت الإطار، ضع شريطاً.
أربعة أسطر، وهي كل نصيبك
١ — OktaAppBar، لا AppBar خاماً بألوانك.
Scaffold(
appBar: OktaAppBar.build('حضور اليوم', palette), // أو withActions / buildWithIcon
body: content,
)
وإن بنيتَ شريطك بنفسك لسببٍ ما، خذ لونيه من الدالّتين ولا تكتب الرقم بيدك:
AppBar(
backgroundColor: OktaAppBar.barBackground(palette),
foregroundColor: OktaAppBar.barForeground(palette),
...
)
الهيدر داكن في الوضعين ولا ينقلب مع المظهر — وهذا هو الشكل الثابت نفسه.
وpalette.textStrong على الهيدر يكاد يكون أسود في الوضع الفاتح، أي حبرٌ لا
يُقرأ على شريط داكن؛ فلذلك barForeground موجودة.
٢ — البار السفلي OktaTabs.bottomBar، وهو بار المنصّة الآن.
Scaffold على هذا المشغّل يقبل appBar وbody وfloatingActionButton
ولا يقبل bottomNavigationBar — فالبار المثبَّت آخرُ ابنٍ في Column
جسمُه Expanded:
final labels = <String>['الرئيسية', 'التقارير'];
final icons = <IconData>[OktaIcons.home(), OktaIcons.chart()];
final rows = <Widget>[];
rows.add(Expanded(flex: 1, child: content)); // جسم الشاشة
rows.add(OktaTabs.bottomBar(labels, icons, _index, _onSelect, palette));
return Scaffold(
appBar: OktaAppBar.build('حضور اليوم', palette),
body: Column(children: rows),
);
واكتب flex: 1 صراحةً. المعامل المسمّى المحذوف يصل null على هذا المشغّل
ويُصبّ في int، فـExpanded(child: …) وحدها تموت على الجهاز — وهذا بالضبط
نوع العطل الذي لا تمسكه قراءةُ الشفرة.
والبار يخرج صلباً على الحافة: ارتفاع 72، خطّ شعري علوي، مؤشّر 28×3 فوق
التبويب النشط لا تعبئة خلفه، أيقونة 24 وتسمية 11 — نفس بار المضيف خليّةً
بخليّة. وbottomBarWithDots تضيف نقطة «غير مقروء» على أي تبويب علمه true.
٣ — لا ترسم الزاويتين ولا الهيدر بنفسك. BoxDecoration(borderRadius:)
يُسقطه الجسر أصلاً، وإطارٌ يرسمه كل تطبيق هو ما وُجد الإطارُ الواحد ليمنعه.
٤ — ولا ترفع minContract. لا رمز جديد هنا، ورفعُه بلا حاجة يرفض أجهزة
كان تطبيقك يعمل عليها.
floatingBar باقية، ولم تعد هي هيئة المنصّة
إن كان تطبيقك يستعمل OktaTabs.floatingBar (عقد 18) فهو يعمل كما هو ولن
ينكسر — لا شيء حُذف. لكنه لم يعد شكل المنصّة: البار العائم يترك شريطاً من
الأرضية ظاهراً تحته على النوافذ العريضة، وهو ما دفع الشكل الموحَّد إلى تثبيت
البار على الحافة وجعل الصفحة تمتدّ إليه.
فإن أردت أن يقرأ تطبيقك كبقيّة المنصّة، بدّل النداء إلى bottomBar بالتركيب
أعلاه — الوسائط نفسها، ويسقط معها floatingOver وfloatingReserve.
هوية أوكتا داخل التطبيق المصغّر (الزجاج السائل)
اعتمد تطبيق الجوال الزجاج السائل لغةً بصرية: القشرة — شريط التطبيق، والأوراق المنبثقة، وشريط التبويب، وبطاقات الجهات — أسطحٌ شفّافة تلتقط ما خلفها وتلمع من حافّتها. هذا القسم يقول لك بالضبط ما نصيبك من ذلك، وما ليس نصيبك، ولماذا.
الخلاصة في سطر: لا تبني الزجاج — اطلبه. المضيف يرسمه لك عبر
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
موجودة، فحلقة السحب استدعاء ذاتي بعد await 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 يحمي من الشكل لا الغياب.
والفحص الوحيد الذي يجيب عن الغياب identical(value, null) — اسأله قبل
أن تنادي على القيمة أو تستوفيها أو تخزّنها.
- setState بجسم أقواس: setState(() { x = y; }) لا setState(() => x = y).
- لا تقرأ bool من قائمة مباشرةً: flags[i] == true.
- لا تُهيّئ عدّاد حلقة من معامل، ولا تمرّر عدداً عبر معامل إلى بانٍ مجسور.
- لا تعشّش الثلاثيّات — فرّع وأعِد.
- عنصرٌ من JSON لا يمرّ عبر ?: — rows.isEmpty ? null : rows[0] يفكّ
صندقته فيموت OktaJson بعده. أسنِد داخل if، أو اقرأ داخل الفرع.
- قبل أي نداء عقده أعلى من 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 ثم ينشره في المتجر.
طباعة التقارير على ترويسة الجهة
كل تطبيق يطبع شيئاً يصطدم بالجدار نفسه: تشكيل العربية، واتجاه RTL، وخطٌّ يرسمها، والهوامش، وترويسةٌ تعرف المدرسة أنها ترويستها. وحين يُحلّ في كل تطبيق على حدة يُحلّ في كل تطبيق على نحو مختلف — فتنتهي المدرسة بستّ وثائق بستّ تخطيطات، لا واحدة منها ترويستها، والشيء الوحيد الذي أرادته (أن هذه صادرة عن مدرستها) هو الذي لا تملكه أيٌّ منها.
المحرّك يعرف ذلك كلّه، لأن المدرسة ضبطته مرّة واحدة في «منشئ التقارير».
تقسيم العمل
القالب إطار، والمدرسة تملكه: الشعار، الترويسة، التذييل، العلامة المائية، الهوامش، حجم الورق، الاتجاه.
والمتن لك، تمرّره أنت. القالب لا يحمل محتوى من عنده.
كيف تستخدمه
use App\Services\PartnerApi\Reports\Templates\ListReportTemplates;
use App\Services\PartnerApi\Reports\Templates\RenderReportTemplate;
// 1) اعرض ترويسات الجهة ليختار المستخدم واحدة
$templates = app(ListReportTemplates::class)();
// [ ['id' => 'aBc12', 'name_ar' => 'ترويسة المدرسة', 'paper_size' => 'a4', …], … ]
// 2) اطبع محتواك عليها
$pdf = app(RenderReportTemplate::class)($templates[0]->id, [
['type' => 'heading', 'text' => 'كشف الدرجات', 'level' => 'h1'],
['type' => 'info-row', 'label' => 'الطالب', 'value' => 'أحمد صالح'],
['type' => 'table',
'headers' => ['المادة', 'الدرجة'],
'rows' => [['الرياضيات', '95'], ['العلوم', '88']]],
['type' => 'divider'],
['type' => 'text', 'html' => '<p>صدر آلياً من نظام المدرسة.</p>'],
]);
// $pdf = ['filename' => 'report-aBc12-20260901-101500.pdf',
// 'mime' => 'application/pdf', 'bytes' => '%PDF…']
أنواع الكتل
heading (مع level: h1/h2/h3 وtext) · text (مع html) · info-row
(مع label وvalue) · table (مع headers وrows) · divider · spacer
· image
نوعٌ لا يعرفه المحرّك يُرفض ولا يُتجاوَز. إسقاط كتلة بصمت يُنتج وثيقةً ناقصةً قسماً لا تقول شيئاً عن نقصه، وتقريرٌ ينقصه صفّ أسوأ من تقرير لم يُطبع.
النطاق
reports.builder.read — لا شيء هنا يكتب. الطباعة على ترويسة أحدهم فعلٌ غير
إعادة تصميمها، ولا تطبيق يفعل الثانية: لا إنشاء قالب ولا تعديله ولا حذفه.
تعود بايتات، لا رابطاً
الملف المخزَّن يحتاج عمراً وسياسة تنظيف وقاعدة وصول، لوثيقةٍ قد تحمل درجات طفلٍ باسمه. إعادة البايتات تترك الثلاثة عند التطبيق الذي طلبها.
أسئلة متكررة
هل يمكنني تغيير نوع التكامل بعد الإنشاء؟
لا. القرار محوري ويغيّر بنية التطبيق بالكامل. أنشئ تطبيقاً جديداً بالنوع المطلوب وأرشف القديم.
كم مدة المراجعة؟
عادةً 1-3 أيام عمل. التطبيقات Embedded أبطأ قليلاً لأنها تتطلب مراجعة كود إضافية.
هل يمكنني الوصول لبيانات مستأجرين متعددين بـ token واحد؟
لا. كل installation token مرتبط بـ (tenant, module) واحد فقط. إذا أردت تطبيقاً يخدم عدة مستأجرين، تحصل على token مستقل لكل تثبيت.
الاستثناء الوحيد ليس توكناً بل شخصاً: تطبيقك وهو مفتوح في مجمّع أو شركة
يقرأ المدارس التابعة لها بـ?entities= — انظر السؤال التالي.
هل يقرأ تطبيق المجمّع بيانات مدارسه من التطبيق المصغّر؟
نعم، بلا نقطة خادم. سمِّ المدارس في مسار القراءة نفسه:
Okta.getQuery('/api/apps/education/students', {'entities': 'A,B'}) يعيد غلافاً
بمفتاح كل مدرسة، و{'entity': 'A'} يعيد ردّ مدرسة واحدة كما هو. يلزم أن يحمل
تطبيقك entity.hierarchy.read، وأن يكون نطاق البيانات ممنوحاً على المجمّع أو
على المدرسة (أحدهما يكفي)، وأن يحمل الشخص tenants.children.view_members في
المجمّع. والمدرسة التي لم تثبّت تطبيقك تُقرأ بمنحة المجمّع. ومفتاح كل مدرسة
إمّا ردّها وإمّا رفضٌ يحمل error — فرّق بينهما بـidentical(answer['error'], null)
لا بـ== null التي تُخطئ قراءة المفتاح الذي لا يحمله الردّ. التفاصيل في فصل
«الشركات والمجمّعات: شجرة الجهة وبيانات مدارسها»، والمثال في فصل الوضع
native.
هل يستطيع المجمّع تثبيت تطبيقي في مدارسه فلا تزيله؟
نعم — حين تُتيح ذلك بـ{"hierarchy": {"managed_install": true}}. يثبّته المجمّع
لنفسه، ويختار مدارسه، وتبقى لكل مدرسة إعدادات التطبيق لكنها لا تستطيع إزالته:
يظهر لها «مثبّت من قبل <المجمّع>» ولا يزيله إلا المجمّع. والمجمّع يعتمد عن
المدرسة كل النطاقات التي يطلبها التطبيق. التفاصيل في «المجمّع يثبّت تطبيقك في
مدارسه — hierarchy.managed_install».
هل يُثبَّت تطبيقي في مدارس المجمّع تلقائياً حين يثبّته المجمّع؟
حين تختار ذلك: {"hierarchy": {"reach": "container_and_children"}} (خيار «المجمّع
ومدارسه» في محرّر الإصدار). عندها يُثبَّت في كل جهة تحت المجمّع نسخةً مُدارة لحظة
يثبّته المجمّع، وفي كل جهة تنضمّ إليه لاحقاً. وبلا المفتاح — أو بـ"container" —
يُثبَّت في المجمّع وحده، وهو سلوك كل تطبيق اليوم. التفاصيل في «تثبيت المجمّع يشمل
مدارسه — hierarchy.reach».
هل يكتب تطبيق المجمّع سجلاً في مدرسة الطالب؟
نعم — للتطبيق المدمج، داخل العملية: ActForStudentsInTree لدفعة طلاب، و
ActInChildEntity لمدرسة واحدة؛ ولا مسار HTTP لذلك. يحمل الشخص في المجمّع
مفتاحين — tenants.children.operate_apps وصلاحية تطبيقك للفعل — ولا تُفتح إلا
المدارس التي تُدير نسخةَ تطبيقك فيها نسخةُ المجمّع نفسها
(hierarchy.managed_install / hierarchy.reach). تضع المنصّة كل طالب في مدرسته
على الخادم، وتشغّل كودك داخلها تحت نسخة المدرسة، فيخرج إشعار وليّ الأمر تحت تلك
النسخة — بإعدادات المدرسة، أو بإعداد المجمّع إلى أن يضبط أحدٌ في المدرسة ذلك
الإشعار. لا تأخذ المدرسة من الطلب أبداً، ولا تستعمل RunAsChildEntity على مسار
طلب. التفاصيل في «التطبيق يعمل داخل مدارس المجمّع — ActInChildEntity».
كيف أصل للأخبار/الأحداث الجديدة؟
اشترك في أحداث المنصة عبر 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