البداية
الشركات والمجمّعات: شجرة الجهة وبيانات مدارسها
النطاقات تجيب «أيّ بيانات»، والفصل السابق يجيب «مَن الجالس». يبقى سؤال ثالث لا تصله أيّ منهما: في أيّ مكان يعمل تطبيقك، وما فوقه وما تحته.
الجهة على المنصّة ليست مدرسة دائماً. الشركة التعليمية تملك مجمّعات،
والمجمّع يضمّ مدارس — والتطبيق نفسه قد يُثبَّت على أيّ مستوى من الثلاثة.
وعلى المدرسة يعرض تطبيقك طلابها؛ وفي مقرّ الشركة لا طلاب له أصلاً، وعمله أن
يتحرّك عبر المدارس التي تحتها. قبل هذه النقاط كان /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نفسها، كي لا يجمع موظّف القسم أرقام مدارس لا يراها.