الـ API والتكامل

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

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

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

الفلسفة: Declare-first

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

  1. NotificationScanner في الـCI: يفحص الكود ويُسقط أي DispatchNotification('<key>', ...) حيث <key> غير مُصرَّح في manifest.json["notifications"]. الـPR يفشل.
  2. بوّابة وقت التشغيل في 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 مكتوباً للقارئ: وصف الكتالوج عام بطبيعته («يُرسَل عند تقديم طلب استئذان») ولا يذكر أيّ ابن ولا أيّ ساعة.

شرطان قبل أن تظهر البطاقة أصلاً

  1. in_app مُعلَنة في default_channels لهذا المفتاح — الجهة تستطيع تضييق قنواتك لا توسيعها.
  2. الجهة لم تُطفئ القناة أو الإشعار من /settings/notifications.

فحص ما حدث فعلاً: mcp__okta__delivery_stats وwebhook_deliveries تقرآن صفوف التسليم، وصفّ skipped_disabled يعني أن الجهة أطفأته — لا أن نداءك فشل.

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

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

  1. يجمع كل المفاتيح الـ"مُستخدَمة" من نداءات الإرسال:
    • app(DispatchNotification::class)('key', ...)
    • app(\App\Services\PartnerApi\Notifications\DispatchNotification::class)('key', ...)
    • partner_notify('key', ...) — يُلتقط كي يُحسَب مفتاحه، مع تنبيه أن الدالة ليست على المنصة (notification-helper-undefined).
  2. يجمع كل المفاتيح الـ"مُصرَّح بها" وaudience كلٍّ منها من manifest.json["notifications"].
  3. يقارن:
    • مفتاح مستخدَم لكن غير مُصرَّح → 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 باقة الجهة لا تشمل إشعارات التطبيقات.


عرض الدليل كاملاً في صفحة واحدة · ↧ Markdown خام