مركز الإشعارات
مركز الإشعارات هو صندوق الوارد الوحيد داخل التطبيق يخدم جميع الأشخاص المعنيين، وقد حلّ محل أسطح التنبيه المتفرقة التي كان يواجهها المعلم وولي الأمر ومدير المدرسة والمدير والمسؤول قبل إطلاق WP-NOTIF-BE. كل إشعار موجَّه إلى مستلم واحد، ولا يستطيع قراءته أو تجاهله إلا ذلك المستلم، وتستند الإشعارات إلى حمولات منظَّمة تجتاز مرشّح سلامة اللغة قبل تخزينها.
يُطلق هذا WP مسار الكتابة (dispatch) والمسارات القارئة الخمسة. تعتمد WPs المنتِجة للتنبيهات (RTI وCBM والمعايير وغيرها) المسار بصورة إضافية في تغييراتها الخاصة.
ما هو المركز (وما لا يتلقاه الطلاب أبدًا)
خمسة أشخاص يتلقون الإشعارات: المعلم، وولي الأمر، ومدير المدرسة، والمدير، والمسؤول. لا يتلقى الطلاب إشعارات تشغيلية أبدًا. يرى الطلاب تغذية راجعة من نظام التحفيز (نجوم وشارات ونقاط XP) عبر سطح منفصل، لا تنبيهات من هذه الوحدة.
كل صف Notification موجَّه إلى recipientUserId واحد فقط (مفتاح أجنبي من نوع Int يشير إلى User.id). لا يوجد صف بثّ ولا صف موجَّه إلى دور؛ التوسيع N صفوف فردية، صف لكل مستلم محدَّد.
| الشخصية | كيفية تحديد المستلمين |
|---|---|
| المعلم | جدول الربط ClassTeacher(classId, teacherUserId)؛ يتلقى كل معلم مشترك في الفصل المصدر صفًا |
| ولي الأمر | جدول الربط ParentStudent(studentUserId, parentUserId)؛ يتلقى كل ولي أمر مرتبط صفًا |
| مدير المدرسة / المدير / المسؤول | يُحدِّد المنتِج recipientUserId واحدًا محددًا من جداول نطاقه الخاصة |
لا يمرّر المنتِج دورًا إلى dispatch؛ يمرّر recipientUserId محددًا. لا يقرأ توسيع المعلمين Class.teacherId المفردة (هذا العمود غير موجود وفق قاعدة ClassTeacher، B.4).
المسارات الخمسة المحدودة بالمستلم
جميع المسارات تتطلب المصادقة (requireAuth). لا يقبل أي مسار معامل مسار :userId.
| الطريقة | المسار | ملاحظات |
|---|---|---|
GET | /api/notifications | عرض قائمة الإشعارات؛ تصفية حسب state وkind وseverity؛ ترقيم صفحات بالمؤشر؛ العاجل أولًا ثم الأحدث |
GET | /api/notifications/unread-count | يُعيد { count }؛ هدف استطلاع REST (بدون WebSocket؛ قاعدة صارمة 8) |
POST | /api/notifications/:id/read | تحديد إشعار واحد مقروءًا؛ للمستلم فقط؛ لا أثر لتكراره؛ الحالة النهائية لا تُغيَّر |
POST | /api/notifications/:id/dismiss | تجاهل إشعار نهائيًا؛ للمستلم فقط؛ لا يظهر مجددًا في القوائم |
POST | /api/notifications/mark-all-read | يُعيد { updated } بعدد الصفوف التي تغيّرت حالتها |
لا توجد بوابة أذونات تحمي المسارات القارئة سوى المصادقة؛ الإشعارات شخصية، وعبارة WHERE في قاعدة البيانات تُقيّد كل نتيجة بصفوف المستدعي. (لا يزال requireTenantContext يعمل للمسؤول الأعلى وتأكيد المستخدم أو المنظمة المعطّلة.)
النطاق المحدود بالمستلم (بدون IDOR)
كل مسار يستمدّ هوية المستلم من ctx.userId (الـ sub في JWT)، لا من معامل مسار. التحريرات على إشعار لا يملكه المستدعي تُعيد 404 لا 403. لا يُكشَف وجود إشعار في صندوق وارد مستخدم آخر (B.6).
جميع استعلامات القراءة والكتابة تحمل عبارة على مستوى الصف: WHERE recipientUserId = ctx.userId. في مسارات التحرير (/read و/dismiss)، يُشغِّل المُعالج أولًا findFirst للتحقق من الملكية؛ إذا أعاد null كانت الاستجابة 404.
تُغطي اختبارات الإثبات سطح انعدام IDOR صراحةً: يُنشئ المستخدم A إشعارًا؛ يستدعي المستخدم B (في المنظمة ذاتها) GET /api/notifications (غائب)، وPOST /:id/read (404)، وPOST /:id/dismiss (404)؛ لا يتأثر unread-count للمستخدم B.
مسار الإرسال
dispatch({ recipientUserId, kind, payload, sourceEntity? }) هو مسار الكتابة الوحيد. يستدعيه المنتِجون داخل العملية بدون طلب HTTP. يُنفّذ المُرسِل ستة خطوات بالترتيب:
- البحث في الكتالوج — يُحدِّد صف
NotificationKindCatalogبحسبkind. يُعيد 422 إن كان النوع غير معروف أو غير نشط. - التحقق من صحة الحمولة (Zod لكل نوع) — يُتحقق من
payloadوفق المخطط المسجَّل للنوع فيschemas/registry.ts. لا يُسمح بأي حقل نص حر في أي مخطط (Q18). يُعيد 422 عند الفشل ولا يُدرج صفًا. - تقديم القالب العربي — يستبدل الرموز
{{var}}من الحمولة المُتحقَّق منها فيarabicTitleTemplateوarabicBodyTemplateمن صف الكتالوج. التقديم حتمي وعديم الحالة (V-6). - تحديد المستلم والمنظمة — يُحدِّد
organizationIdوpersonaمن سجلUserالخاص بالمستلم. إذا كان المستلم غير نشط أو محذوفًا، يتوقف الإرسال مبكرًا مع{ skipped: "recipient_inactive" }ولا يُدرج أي صف. - مرشّح سلامة اللغة 73 — يمرّر كلا النصَّين المُقدَّمَين عبر
sanitize()منmodules/language-safety. تُحمَّل الطبقة الأكثر صرامة للوالدين عندما يكونdefaultPersonaفي الكتالوج هوPARENTأو كانتpersonaالمستلم المحدَّد هيPARENT— بذلك يخضع النوع الافتراضي للمعلمين الموزَّع إلى مستلم من أولياء الأمور للحزام الأكثر صرامة أيضًا (إصلاح BUG-4، موثَّق فيdispatcher.ts). إذا كانت أي قاعدة ستُعيد كتابة أي نص، يُلغى الإدراج، يُسجَّل حدثlevel=error، ويُطرحNotificationLanguageSafetyError. لا يوجد نص بديل ولا مرور صامت (Q15). - الإدراج (INSERT) — يكتب الصف مع النصوص المُقدَّمة والمخزّنة. لا يتدخل أي نموذج لغوي كبير في أي خطوة (V-5)؛ مسار سلامة اللغة حزام قائم على قواعد حتمية.
يُحدِّد المساعدان dispatchToClass وdispatchToStudent مجموعة المستلمين الكاملة عبر جدولَي ClassTeacher وParentStudent (B.4) ثم يستدعيان dispatch مرةً لكل مستلم.
يُطلق هذا WP مسار الإرسال واختباراته. تتبنّى WPs المنتِجة للتنبيهات مسار dispatch في تغييراتها الخاصة.
الأنواع التسعة للموجة الأولى
يحمل جدول NotificationKindCatalog تسعة أنواع نشطة للموجة الأولى. لكل نوع سلسلة kind ثابتة (هدف مفتاح أجنبي في جدول Notification)، وقوالب عنوان وجسم عربية، وdefaultSeverity، وdefaultPersona، وحدّ retentionDays. تُزرع الأنواع التسعة بواسطة catalog-seed.ts:
| النوع | الشخصية الافتراضية | الخطورة الافتراضية |
|---|---|---|
q5_24h_ack | TEACHER | action_required |
skill_alert_assigned | TEACHER | action_required |
student_alert_assigned | TEACHER | urgent |
benchmark_profile_swap | ADMIN | info |
dismissal_expiry | TEACHER | action_required |
plan_modification_suggestion | TEACHER | action_required |
cbm_alert_lifecycle | TEACHER | action_required |
parent_tsp_activation | PARENT | info |
lms_connection_revoked | ADMIN | urgent |
المصدر: apps/server/src/modules/notifications/catalog-seed.ts. النموذج الأولي الذي أنشأه WP-RDM-BE (عنصر نائب لـ notification_kind_catalog بحقول نصية مرنة مُعلَّمة TODO owner-spec 71) أُلغي وأُسقط بترحيل هذا WP. لا يوجد سوى نموذج واحد متعارف عليه.
عزل المستأجر وعزل الأشخاص
organizationId مُكرَّر ومُفهرَس على كل صف Notification. يُحدَّد من User الخاص بالمستلم عند وقت الإرسال، لذا فإن منظمة الإشعار هي دائمًا منظمة المستلم.
يتلقى المستدعون من منظمة أخرى نتائج فارغة (صفر صفوف في القائمة، و404 على /:id/read، و{ count: 0 } على unread-count). عبارة WHERE المحدودة بالمستلم والحد الفاصل للمستأجر طبقتان مستقلتان للدفاع المعمّق.
يمكن أن يكون المسؤول الأعلى (organizationId = null) recipientUserId للأنواع على مستوى المنصة (مثل benchmark_profile_swap). تُقيّد عبارة WHERE المحدودة بالمستلم المسؤولَ الأعلى بصفوفه المخصَّصة فقط. لا يُستشار skipTenantFilter على مسارات القراءة هذه؛ الإشعارات شخصية ولا يوجد عرض صندوق وارد عبر المستأجرين.
العزل بين الأشخاص داخل المنظمة نفسها يتبع الآلية ذاتها: parent_tsp_activation الموجَّه لولي الأمر وskill_alert_assigned الموجَّه للمعلم في المنظمة ذاتها لا يظهران في صندوق وارد بعضهما أبدًا لأن recipientUserId يختلف.
الحمولات المنظَّمة (Q18) وسلامة اللغة (Q15)
Q18 — لا نص حر: لكل kind مخطط Zod مسجَّل في schemas/registry.ts. لا يحتوي أي مخطط على حقل باسم note أو comment أو reason أو freeText أو أي سلسلة غير مقيَّدة. يرجع حقل مبرر الرفض إلى رمز من تعداد OverrideCodeCatalog التسعة المغلق، لا إلى نص حر (B.3). يحرس lint CI pnpm schema:lint:notification-payload ملف السجل. الحمولة التي تفشل في التحقق تجعل dispatch يطرح NotificationPayloadValidationError قبل إدراج أي صف.
Q15 — سلامة اللغة: يجتاز كلا النصَّين المُقدَّمَين مرشّح sanitize() من سلامة اللغة 73 عند وقت الإرسال. تُحمَّل طبقة المرشّح الأكثر صرامة للوالدين (FR-LANG-6) عندما يكون defaultPersona في الكتالوج هو PARENT أو كانت persona المستلم المحدَّد هي PARENT — بذلك يخضع النوع الافتراضي للمعلمين الموزَّع توسيعًا إلى مستلم من أولياء الأمور للحزام الأكثر صرامة أيضًا (إصلاح BUG-4). الانتهاك يُلغي الإدراج بدون بديل. النصوص المُقدَّمة والمُصفَّاة مخزَّنة (renderedTitleAr وrenderedBodyAr) وغير قابلة للتغيير بعد الإدراج. تعديل القالب في الكتالوج لاحقًا ذو اتجاه أمامي فقط؛ لا يُعيد تقديم صف موجود (V-6).
ما يقبل الكتابة مرة واحدة وما يقبل التغيير
الأعمدة التالية كتابة مرة واحدة فحسب: createdAt وrecipientUserId وkind وpayload وorganizationId وrenderedTitleAr وrenderedBodyAr.
العمود الوحيد القابل للتغيير هو deliveryState (بالإضافة إلى الطوابع الزمنية المرتبطة readAt وdismissedAt). كل كاتب من كتّاب انتقال الحالة الثلاثة (markRead وmarkDismissed وmarkAllRead) يحمل العلامة lint-allow-audit-update. الانتقالات الصالحة هي:
delivered_in_app -> read
delivered_in_app -> dismisseddismissed حالة نهائية. الإشعار المُتجاهَل لا يظهر في أي قائمة بعد ذلك. لا يوجد انتقال عكسي.
Notification ليست في مجموعة lint الإلحاق فقط؛ هي رأس مُدرَج في قائمة الاستثناءات المتاح تعديلها، وتتبع النمط ذاته الذي يتبعه bundleAssignment وcbmAlert.
خارج النطاق (الموجة الثانية)
العناصر التالية مؤجَّلة صراحةً:
- قناة البريد الإلكتروني (لا يوجد عمود
deliveryChannelفي النموذج) - خيارات الاشتراك وإلغاء الاشتراك لكل نوع
- الإشعارات الفورية وتكامل Service Worker
- توجيه الرسائل النصية وWhatsApp
- تجميع الإشعارات أو تقسيمها
- وظيفة cron الحذف بالاحتفاظ اليومي — حقل
retentionDaysلكل نوع موجود ومُزرَع في صفوف الكتالوج، لكن العامل المجدوَل للحذف لم يُبنَ بعد؛ هذه مهمة تشغيلية لاحقة. تستمر ضمانة الإلحاق فقط خلال هذه المرحلة. - ربط المنتجين (RTI وCBM والمعايير والـ WPs الأخرى باستدعاء
dispatch) - مسارات CTA بالروابط العميقة (تُحسَم مع WP-NOTIF-FE)
Q-NTF-2 (مفتوح): الاحتفاظ لمدة 90 يومًا الافتراضية لصفوف parent_tsp_activation ينتظر تأكيد فريق الخصوصية لدى الشريك لمواءمة GDPR وFERPA مع مدارس الطيار في الأردن وفلسطين. عامل الحذف مؤجَّل، لذا لا يُحذف أي صف لولي أمر تلقائيًا بعد. هذا السؤال المفتوح ليس حاسمًا في الوقت الحالي.
ذو صلة
- سلامة اللغة: مرشّح
sanitize()وطبقة أولياء الأمور الأكثر صرامة التي تحرس كل نص مُقدَّم - توفير المستخدمين: إنشاء سجلات المعلم وولي الأمر التي تصبح قيم
User.idالمستخدَمة فيrecipientUserId - الفصول وقوائم الطلاب: جدولا
ClassTeacherوParentStudentالمُستخدَمان في مساعدي التوسيع - المسرد: نوع الإشعار، المُرسِل، الإلحاق فقط