Implement verified curriculum, video review, watch sessions, ledger safeguards, and remove demo data
This commit is contained in:
@@ -1,404 +0,0 @@
|
||||
/// ==============================================================================
|
||||
/// SAQEL EDTECH 2.0 - BAKED CURRICULUM DATA SOURCE
|
||||
/// ==============================================================================
|
||||
///
|
||||
/// مستودع المحتوى الوزاري المخبوز والمدمج محلياً للمواد الثلاث الأساسية للصف العاشر:
|
||||
/// 1. الرياضيات (Mathematics 10) — منهاج وزارة التربية والتعليم الأردنية
|
||||
/// 2. الفيزياء (Physics 10) — المتجهات والحركة والتجارب المخبرية
|
||||
/// 3. اللغة الإنجليزية (English 10 — Action Pack) — المفردات والقراءة والقواعد
|
||||
///
|
||||
/// يضمن تشغيل وتصفح الكتب وأوراق العمل والدروس بنسبة 100% حتى في حال غياب الاتصال.
|
||||
class CurriculumBakedData {
|
||||
/// Get baked curriculum document by subject, type, and file path
|
||||
static String getDocument({
|
||||
required String subjectId,
|
||||
required String type,
|
||||
String? filePath,
|
||||
String? title,
|
||||
}) {
|
||||
final s = subjectId.toLowerCase();
|
||||
final f = (filePath ?? '').toLowerCase();
|
||||
final t = (title ?? '').toLowerCase();
|
||||
|
||||
// 1. MATHEMATICS 10
|
||||
if (s.contains('math') || s.contains('رياضيات')) {
|
||||
if (type == 'worksheet' || t.contains('ورقة عمل') || f.contains('worksheet')) {
|
||||
return _math10Worksheet1;
|
||||
}
|
||||
if (f.contains('geogebra') || t.contains('جيوجبرا')) {
|
||||
return _math10GeoGebraLab;
|
||||
}
|
||||
if (f.contains('intro') || t.contains('مقدمة') || t.contains('مشروع')) {
|
||||
return _math10Unit1Intro;
|
||||
}
|
||||
if (f.contains('lesson_02') || t.contains('نظام') || t.contains('خطي')) {
|
||||
return _math10Lesson2;
|
||||
}
|
||||
return _math10TextbookUnit1;
|
||||
}
|
||||
|
||||
// 2. PHYSICS 10
|
||||
if (s.contains('physic') || s.contains('فيزياء')) {
|
||||
if (type == 'worksheet' || t.contains('ورقة عمل') || f.contains('worksheet')) {
|
||||
return _physics10Worksheet1;
|
||||
}
|
||||
if (f.contains('activities') || f.contains('تجرب') || t.contains('تجارب') || t.contains('أنشطة')) {
|
||||
return _physics10ExperimentsBook;
|
||||
}
|
||||
return _physics10TextbookUnit1;
|
||||
}
|
||||
|
||||
// 3. ENGLISH 10
|
||||
if (s.contains('english') || s.contains('إنجليز')) {
|
||||
if (type == 'worksheet' || t.contains('worksheet') || t.contains('ورقة عمل')) {
|
||||
return _english10Worksheet1;
|
||||
}
|
||||
if (f.contains('unit_02') || t.contains('digital mind')) {
|
||||
return _english10Unit2;
|
||||
}
|
||||
return _english10Unit1;
|
||||
}
|
||||
|
||||
// Generic Default Fallback
|
||||
return _defaultGeneralCurriculum;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MATH 10 BAKED DATA
|
||||
// ============================================================================
|
||||
|
||||
static const String _math10TextbookUnit1 = """
|
||||
## كتاب الرياضيات المقرر — الصف العاشر الأساسي (الفصل الدراسي الأول)
|
||||
### الوحدة الأولى: الأسس وأنظمة المعادلات (Equations & Systems)
|
||||
*(منهاج وزارة التربية والتعليم الأردنية المعتمد — صفحات 8 إلى 34)*
|
||||
|
||||
---
|
||||
|
||||
### الدرس 1: حلُّ معادلاتٍ خاصّةٍ (Solving Special Equations)
|
||||
- **فكرة الدرس:** حلُّ معادلاتٍ أُسُّ المتغيّرِ فيها عددٌ صحيحٌ موجبٌ أكبرُ من \$2\$ باستخدام التحليل وإخراج العامل المشترك أو الصورة التربيعية.
|
||||
- **المصطلحات الرياضية:** الصورةُ التربيعيّةُ (Quadratic Form)، العامل المشترك الأكبر (GCF)، التحليل بالتجميع (Grouping).
|
||||
|
||||
#### مسألة اليوم الحياتية (صفحة 8):
|
||||
> صُندوق هدايا على شكل متوازي مستطيلات حجمه \$1152\\text{ cm}^3\$، وأبعاده بدلالة \$w\$:
|
||||
> - الطول: \$(18 - w)\\text{ cm}\$
|
||||
> - العرض: \$w\\text{ cm}\$
|
||||
> - الارتفاع: \$(2w + 4)\\text{ cm}\$
|
||||
> **المطلوب:** إيجاد أبعاد الصندوق من خلال تكوين معادلة تكعيبية وحلها.
|
||||
|
||||
#### البند 1: حل المعادلات بإخراج العامل المشترك الأكبر
|
||||
- **قاعدة:** نجعل طرف المعادلة الأيمن صفراً، ثم نخرج العامل المشترك، ونحل المعادلة الناتجة.
|
||||
- **مثال 1:** حل المعادلة: \$x^3 + 4x^2 = 5x\$
|
||||
\$\$x^3 + 4x^2 - 5x = 0 \\implies x(x^2 + 4x - 5) = 0 \\implies x(x + 5)(x - 1) = 0\$\$
|
||||
إذن جذور المعادلة هي: \$\\{-5, 0, 1\\}\$.
|
||||
|
||||
- **أتحقق من فهمي (صفحة 9):**
|
||||
1. \$x^3 + 12x = 7x^2 \\implies x(x - 3)(x - 4) = 0 \\implies x \\in \\{0, 3, 4\\}\$.
|
||||
2. \$2x^3 = 50x \\implies 2x(x^2 - 25) = 0 \\implies x \\in \\{-5, 0, 5\\}\$.
|
||||
|
||||
---
|
||||
|
||||
### الدرس 2: حل نظام مكوّن من معادلة خطية ومعادلة تربيعية
|
||||
- **المفهوم:** نقطة تقاطع المستقيم والمنحنى تمثل حل النظام المشترك.
|
||||
- **طريقة التعويض:** نجعل أحد المتغيرين موضوعاً للقانون في المعادلة الخطية ونعوضه في المعادلة التربيعية.
|
||||
- **مثال محلول:**
|
||||
\$\$\\begin{cases} y - x = 1 \\\\ x^2 + y^2 = 25 \\end{cases}\$\$
|
||||
من المعادلة الأولى: \$y = x + 1\$. نعوض في الثانية:
|
||||
\$\$x^2 + (x + 1)^2 = 25 \\implies 2x^2 + 2x - 24 = 0 \\implies x^2 + x - 12 = 0\$\$
|
||||
\$\$(x + 4)(x - 3) = 0 \\implies x = 3 \\text{ أو } x = -4\$\$
|
||||
- عندما \$x = 3 \\implies y = 4\$ (النقطة: \$(3, 4)\$)
|
||||
- عندما \$x = -4 \\implies y = -3\$ (النقطة: \$( -4, -3)\$)
|
||||
|
||||
---
|
||||
|
||||
### معمل برمجية جيوجبرا (GeoGebra Lab):
|
||||
- رسم الدائرة \$x^2 + y^2 = 25\$ بيانيا وملاحظة تقاطعها مع المستقيم \$y - x = 1\$ في نقطتين هندسيتين واضحتين.
|
||||
""";
|
||||
|
||||
static const String _math10Lesson2 = """
|
||||
## الدرس 2: حل نظام من معادلة خطية ومعادلة تربيعية
|
||||
### الأهداف والنتاجات الوزارية:
|
||||
- حل نظام مكون من معادلة خطية ومعادلة تربيعية جبرياً باستخدام طريقة التعويض.
|
||||
- تمثيل النظام بيانياً وتحديد عدد الحلول الممكنة (حلان، حل واحد مماس، أو لا يوجد حل حقيقي).
|
||||
|
||||
### خطوات الحل النموذجية:
|
||||
1. جعل أحد المتغيرين موضوعاً للقانون من المعادلة الخطية (الأسهل معامل).
|
||||
2. التعويض في المعادلة التربيعية لتتحول إلى معادلة بمتغير واحد.
|
||||
3. فك الأقواس والتبسيط وترتيب المعادلة على الصورة العامة \$ax^2 + bx + c = 0\$.
|
||||
4. الحل بالتحليل إلى العوامل أو القانون العام.
|
||||
5. إيجاد قيمة المتغير الآخر وكتابة مجموعة الحل على شكل أزواج مرتبة \$(x, y)\$.
|
||||
""";
|
||||
|
||||
static const String _math10GeoGebraLab = """
|
||||
## معملُ برمجيةِ جيوجبرا: حلُّ أنظمةِ المعادلاتِ بيانياً
|
||||
*(الصفحات 16 - 17 — منهاج الرياضيات المعتمد)*
|
||||
|
||||
### النشاط:
|
||||
\$\$\\begin{cases} x^2 + y^2 = 13 \\\\ x^2 - y = 7 \\end{cases}\$\$
|
||||
|
||||
نقاط التقاطع الأربع:
|
||||
\$\$(3, 2), \\quad (-3, 2), \\quad (2, -3), \\quad (-2, -3)\$\$
|
||||
|
||||
### أتدرب (صفحة 17):
|
||||
1) \$\\Delta = -144 < 0 \\implies \\varnothing\$
|
||||
2) \$\\{(2, 4), (-2, 4)\\}\$
|
||||
3) \$\\{(8.625, 7.375)\\}\$
|
||||
4) \$\\Delta = -295 < 0 \\implies \\varnothing\$
|
||||
5) \$\\left\\{\\left(\\frac{3}{\\sqrt{37}}, \\frac{18}{\\sqrt{37}}\\right), \\left(-\\frac{3}{\\sqrt{37}}, -\\frac{18}{\\sqrt{37}}\\right)\\right\\}\$
|
||||
6) \$\\Delta = -59 < 0 \\implies \\varnothing\$
|
||||
|
||||
---
|
||||
""";
|
||||
|
||||
static const String _math10Unit1Intro = """
|
||||
## مقدمة ومشروع الوحدة: أنظمة المعادلات في حياتنا
|
||||
*(الوحدة الأولى — الصف العاشر الأساسي)*
|
||||
|
||||
### ما أهمية هذه الوحدة؟
|
||||
تُستخدَم أنظمةُ المعادلاتِ في كثيرٍ من مجالاتِ الحياةِ. فخبراءُ الأرصادِ الجويّةِ -مثلاً- يُعبِّرون عنِ العلاقةِ بينَ درجةِ الحرارةِ، وسرعةِ الرّياحِ، والضغطِ الجويِّ، ومعدلِ الهطلِ، باستخدامِ نظامِ معادلاتٍ غيرِ خطيٍّ؛ ذلكَ أنَّ أيَّ تغيُّرٍ في أحدِ هذهِ العواملِ يؤدّي إلى تغيُّرٍ في العواملِ الأخرى.
|
||||
|
||||
### تعلَّمْتُ سابقاً:
|
||||
- حلَّ معادلاتٍ تربيعيّةٍ باستعمالِ التحليلِ.
|
||||
- حلَّ معادلاتٍ تربيعيّةٍ باستعمالِ القانونِ العامِّ.
|
||||
- حلَّ أنظمةِ معادلاتٍ تتضمَّنُ معادلتيْنِ خطيّتيْنِ بمتغيِّريْنِ.
|
||||
|
||||
### سأتعلَّمُ في هذهِ الوحدةِ:
|
||||
- حلَّ معادلاتٍ خاصّةٍ أُسُّ المتغيّرِ فيها عددٌ صحيحٌ موجبٌ أكبرُ من \$2\$.
|
||||
- حلَّ نظامٍ مُكوَّنٍ من معادلةٍ خطيّةٍ، وأُخرى تربيعيّةٍ.
|
||||
- حلَّ نظامٍ مُكوَّنٍ من معادلتيْنِ تربيعيّتيْنِ.
|
||||
|
||||
### مشروع الوحدة: أنظمةُ المعادلاتِ في حياتِنا (صفحة 7)
|
||||
- **فكرة المشروع:** البحثُ عن أنظمةِ معادلاتٍ في نماذجَ حياتيّةٍ.
|
||||
- **المواد والأدوات:** شبكةُ الإنترنت، برمجيّةُ جيوجبرا (GeoGebra).
|
||||
- **الصيغة المستخدمة في جيوجبرا:**
|
||||
\$\$\\text{FitPoly}(\\{C, D, E, F, G, H, I, J, K, L\\}, n)\$\$
|
||||
|
||||
---
|
||||
""";
|
||||
|
||||
static const String _math10Worksheet1 = """
|
||||
## ورقة عمل تدريبية رقم (1): الأسس وأنظمة المعادلات
|
||||
### المبحث: الرياضيات — الصف العاشر الأساسي
|
||||
**الجهة المصدرة:** بنك الأسئلة المعتمد لمنصة صَقِل 2.0 بالتعاون مع معلمي الميدان
|
||||
|
||||
---
|
||||
|
||||
### السؤال الأول: حل المعادلات الآتية بإخراج العامل المشترك والتحليل (4 علامات)
|
||||
1. \$x^4 - 13x^2 + 36 = 0\$ (على الصورة التربيعية)
|
||||
> **إرشاد الحل:** افرض \$u = x^2\$، فتصبح المعادلة \$u^2 - 13u + 36 = 0\$.
|
||||
> التحليل: \$(u - 9)(u - 4) = 0 \\implies u = 9 \\text{ أو } u = 4\$.
|
||||
> إذن: \$x = \\pm 3, \\quad x = \\pm 2\$.
|
||||
|
||||
2. \$3x^3 + 6x^2 - 12x - 24 = 0\$ (بالتجميع)
|
||||
> **إرشاد الحل:** \$3x^2(x + 2) - 12(x + 2) = 0 \\implies (x + 2)(3x^2 - 12) = 0\$.
|
||||
> الحلول: \$x = -2\$ (جذر مكرر) و \$x = 2\$.
|
||||
|
||||
---
|
||||
|
||||
### السؤال الثاني: حل النظام الجبري التالي وتحقق من صحة الحل (6 علامات)
|
||||
\$\$\\begin{cases} y = 2x + 3 \\\\ x^2 + y = 6 \\end{cases}\$\$
|
||||
|
||||
**خطوات الإجابة:**
|
||||
- نعوض \$y = 2x + 3\$ في المعادلة الثانية:
|
||||
\$\$x^2 + 2x + 3 = 6 \\implies x^2 + 2x - 3 = 0\$\$
|
||||
- التحليل: \$(x + 3)(x - 1) = 0\$.
|
||||
- إذا \$x = 1 \\implies y = 2(1) + 3 = 5\$ ➔ الحل الأول: \$(1, 5)\$.
|
||||
- إذا \$x = -3 \\implies y = 2(-3) + 3 = -3\$ ➔ الحل الثاني: \$( -3, -3)\$.
|
||||
|
||||
---
|
||||
|
||||
### السؤال الثالث: مسألة تطبيقية (تحدي التفكير الرياضي)
|
||||
> سجادة مستطيلة الشكل محيطها \$28\\text{ m}\$ ومساحتها \$48\\text{ m}^2\$. جِد بعدي السجادة بتكوين نظام معادلات.
|
||||
> **المعادلات:** \$2(x + y) = 28 \\implies x + y = 14\$ و \$x \\cdot y = 48\$.
|
||||
> الأبعاد هي: الطول \$8\\text{ m}\$ والعرض \$6\\text{ m}\$.
|
||||
""";
|
||||
|
||||
// ============================================================================
|
||||
// PHYSICS 10 BAKED DATA
|
||||
// ============================================================================
|
||||
|
||||
static const String _physics10TextbookUnit1 = """
|
||||
## كتاب الفيزياء المقرر — الصف العاشر الأساسي (الفصل الدراسي الأول)
|
||||
### الوحدة الأولى: المتجهات والكميات الفيزيائية (Vectors in Physics)
|
||||
*(منهاج وزارة التربية والتعليم الأردنية المنقح — صفحات 10 إلى 48)*
|
||||
|
||||
---
|
||||
|
||||
### الدرس 1: الكميات القياسية والكميات المتجهة
|
||||
- **الكمية القياسية (Scalar Quantity):** كمية فيزيائية تُحدد بالمقدار ووحدة القياس فقط.
|
||||
- *أمثلة:* الكتلة (\$\\text{kg}\$)، الزمن (\$\\text{s}\$)، المسافة (\$\\text{m}\$)، درجة الحرارة (\$\\text{K}\$)، الطاقة والشغل (\$\\text{J}\$).
|
||||
- **الكمية المتجهة (Vector Quantity):** كمية فيزيائية تُحدد بالمقدار والاتجاه ونقطة التأثير معاً.
|
||||
- *أمثلة:* الإزاحة (\$\\vec{d}\$)، السرعة المتجهة (\$\\vec{v}\$)، التسارع (\$\\vec{a}\$)، القوة (\$\\vec{F}\$).
|
||||
|
||||
### الدرس 2: تمثيل المتجهات بيانياً وتحليلياً
|
||||
- يُرسم المتجه كسهم يبدأ من نقطة الأصل، حيث يدل طول السهم على مقدار المتجه، ويشير رأس السهم إلى اتجاهه.
|
||||
- **زاوية الاتجاه المرجعية (\$\\theta\$):** الزاوية التي يصنعها المتجه مع محور السينات الموجب (\$+x\$) بعكس عقارب الساعة.
|
||||
|
||||
### تحليل المتجهات إلى مركبات (Vector Resolution):
|
||||
لأي متجه \$\\vec{A}\$ يصنع زاوية \$\\theta\$ مع محور السينات:
|
||||
1. **المركبة الأفقية (السينية):** \$A_x = A \\cos\\theta\$
|
||||
2. **المركبة الرأسية (الصادية):** \$A_y = A \\sin\\theta\$
|
||||
3. **مقدار المحصلة:** \$A = \\sqrt{A_x^2 + A_y^2}\$
|
||||
4. **اتجاه المحصلة:** \$\\tan\\theta = \\left|\\frac{A_y}{A_x}\\right|\$
|
||||
|
||||
---
|
||||
|
||||
### ضرب المتجهات (Vector Multiplication):
|
||||
1. **الضرب القياسي / النقطي (Dot Product):**
|
||||
\$\$\\vec{A} \\cdot \\vec{B} = |A| |B| \\cos\\phi\$\$
|
||||
- الناتج كمية قياسية (مثل حساب الشغل: \$W = \\vec{F} \\cdot \\vec{d}\$).
|
||||
- ينعدم الضرب القياسي إذا كان المتجهان متعامدين (\$\\phi = 90^\\circ\$ لأن \$\\cos 90^\\circ = 0\$).
|
||||
|
||||
2. **الضرب المتجهي / التقاطعي (Cross Product):**
|
||||
\$\$|\\vec{A} \\times \\vec{B}| = |A| |B| \\sin\\phi\$\$
|
||||
- الناتج متجه عمودي على المستوى الذي يحوي المتجهين، ويُحدد اتجاهه باستخدام قاعدة اليد اليمنى.
|
||||
""";
|
||||
|
||||
static const String _physics10Worksheet1 = """
|
||||
## ورقة عمل وتطبيقات فيزيائية: تحليل المتجهات وقوانين نيوتن
|
||||
### المبحث: الفيزياء — الصف العاشر الأساسي
|
||||
**إعداد وتدقيق:** نخبة معلمي الفيزياء المعتمدين في منصة صَقِل
|
||||
|
||||
---
|
||||
|
||||
### المسألة الأولى: تحليل قوة مائلة
|
||||
> تؤثر قوة مقدارها \$F = 100\\text{ N}\$ في صندوق خشبي بزاوية \$37^\\circ\$ فوق الأفق.
|
||||
> *(علماً بأن \$\\cos 37^\\circ = 0.8\$ و \$\\sin 37^\\circ = 0.6\$)*
|
||||
> **المطلوب:**
|
||||
> 1. احسب المركبة الأفقية للقوة \$F_x\$.
|
||||
> - **الحل:** \$F_x = F \\cos 37^\\circ = 100 \\times 0.8 = 80\\text{ N}\$ (باتجاه الشرق).
|
||||
> 2. احسب المركبة الرأسية للقوة \$F_y\$.
|
||||
> - **الحل:** \$F_y = F \\sin 37^\\circ = 100 \\times 0.6 = 60\\text{ N}\$ (باتجاه الأعلى).
|
||||
|
||||
---
|
||||
|
||||
### المسألة الثانية: حساب الضرب القياسي والمتجهي
|
||||
> متجهان: \$|A| = 6\\text{ units}\$ و \$|B| = 8\\text{ units}\$، والزاوية بينهما \$\\phi = 30^\\circ\$.
|
||||
> 1. الضرب القياسي: \$\\vec{A} \\cdot \\vec{B} = 6 \\times 8 \\times \\cos 30^\\circ = 48 \\times \\frac{\\sqrt{3}}{2} \\approx 41.57\\text{ units}\$.
|
||||
> 2. الضرب المتجهي: \$|\\vec{A} \\times \\vec{B}| = 6 \\times 8 \\times \\sin 30^\\circ = 48 \\times 0.5 = 24\\text{ units}\$.
|
||||
""";
|
||||
|
||||
static const String _physics10ExperimentsBook = """
|
||||
## كتاب التجارب والأنشطة العلمية والعملية — فيزياء الصف العاشر
|
||||
### التجربة الاستهلالية: إيجاد محصلة قوتين عملياً (طاولة القوى)
|
||||
|
||||
#### هدف التجربة:
|
||||
التحقق من صحة جمع المتجهات بطريقة متوازي الأضلاع وقانون جيب التمام بيانياً وحسابياً.
|
||||
|
||||
#### الأدوات المستخدمة:
|
||||
- طاولة القوى الدائرية المدرجة من \$0^\\circ\$ إلى \$360^\\circ\$.
|
||||
- حلقات ربط خفيفة وخيوط وبكرات ملساء.
|
||||
- كتل أثقال معيارية (جرامات).
|
||||
|
||||
#### خطوات العمل والملاحظة:
|
||||
1. تعليق كتلة \$m_1 = 150\\text{ g}\$ عند زاوية \$0^\\circ\$.
|
||||
2. تعليق كتلة \$m_2 = 200\\text{ g}\$ عند زاوية \$90^\\circ\$.
|
||||
3. اتزان الحلقة في المنتصف بوضع قوة موازنة ثالثة في الربع المقابل بمقدار \$250\\text{ g}\$ وزاوية \$233^\\circ\$.
|
||||
4. **الاستنتاج:** محصلة قوتين متعامدتين تُحسب بفيثاغورس:
|
||||
\$\$R = \\sqrt{F_1^2 + F_2^2} = \\sqrt{150^2 + 200^2} = 250\\text{ gf}\$\$
|
||||
""";
|
||||
|
||||
// ============================================================================
|
||||
// ENGLISH 10 BAKED DATA
|
||||
// ============================================================================
|
||||
|
||||
static const String _english10Unit1 = """
|
||||
## Unit 01: Looking Good — Action Pack 10
|
||||
### Official Ministry of Education Curriculum (Grade 10 — Semester 1)
|
||||
|
||||
---
|
||||
|
||||
### 1. Key Vocabulary & Lexical Collocations
|
||||
- **Casual clothes:** Comfortable, informal garments worn everyday (jeans, polo shirts, sneakers).
|
||||
- **Formal attire:** Dignified clothing worn for ceremonial or official occasions (suits, thobes).
|
||||
- **Traditional costume:** Distinctive heritage garments representing national Jordanian identity.
|
||||
- **First impression:** The immediate opinion someone forms upon encountering another for the first time.
|
||||
- **Subconscious judgement:** Cognitive assumptions made rapidly without deliberate thought.
|
||||
- **Reliability:** The quality of being trustworthy, dependable, and consistent.
|
||||
|
||||
---
|
||||
|
||||
### 2. Reading Passage: The Power of First Impressions
|
||||
Research conducted by behavioral psychologists confirms that humans formulate strong opinions about others within the first **seven seconds** of an encounter. These evaluations encompass trustworthiness, social competence, and authority.
|
||||
|
||||
In a landmark experiment, educators who dressed professionally in tailored coats commanded greater attention and respect from students than those in untidy casual shirts. Clothing functions as a non-verbal language signaling respect for the learning environment.
|
||||
|
||||
---
|
||||
|
||||
### 3. Grammar Workshop: Articles (a, an, the, and Zero Article)
|
||||
1. **Indefinite Article (a / an):**
|
||||
- Used before singular countable nouns mentioned for the first time.
|
||||
- Example: *Hamza bought **a** new laboratory manual yesterday.*
|
||||
- Example: *The teacher gave **an** insightful explanation.*
|
||||
|
||||
2. **Definite Article (the):**
|
||||
- Used when referring to a specific item known to both speaker and listener, or unique phenomena.
|
||||
- Example: *Please open **the** physics textbook on page 24.*
|
||||
- Example: ***The** sun rises in the east.*
|
||||
|
||||
3. **Zero Article (Ø):**
|
||||
- Used with plural countable nouns and uncountable nouns when speaking generally.
|
||||
- Example: ***Ø** Science helps us understand the universe.*
|
||||
- Example: ***Ø** Jordanian students excel in mathematics.*
|
||||
""";
|
||||
|
||||
static const String _english10Unit2 = """
|
||||
## Unit 02: The Digital Mind — Action Pack 10
|
||||
### Artificial Intelligence, Space Exploration & Present Perfect
|
||||
|
||||
---
|
||||
|
||||
### 1. Reading Text: Voyager & The Deep Cosmos
|
||||
In 1977, NASA launched the twin probes **Voyager 1** and **Voyager 2**. More than four decades later, they have crossed into interstellar space, transmitting vital telemetry across billions of miles back to Earth.
|
||||
|
||||
### 2. Grammar: Present Perfect vs. Past Simple
|
||||
- **Present Perfect (have / has + V3):** Expresses actions occurring at an unspecified past time with continuing relevance.
|
||||
- *Example: Scientists **have made** remarkable strides in artificial intelligence.*
|
||||
- **Past Simple (V2):** Expresses completed past events with a specified time marker.
|
||||
- *Example: Engineers **launched** the spacecraft in 1977.*
|
||||
""";
|
||||
|
||||
static const String _english10Worksheet1 = """
|
||||
## Action Pack 10 — Practice Worksheet: Unit 01 (Looking Good)
|
||||
**Issued by:** Saqel Adaptive Learning Platform (Department of English Language)
|
||||
|
||||
---
|
||||
|
||||
### Part A: Vocabulary & Word Choice (Choose the correct word)
|
||||
1. In Jordan, wearing the traditional kuffiyeh represents cultural (attire / friction / hazard).
|
||||
- **Answer:** **attire**
|
||||
2. Within seven seconds, people make a (subconscious / superficial / metallic) judgment about character.
|
||||
- **Answer:** **subconscious**
|
||||
|
||||
---
|
||||
|
||||
### Part B: Grammar — Complete with (a, an, the, or Ø for zero article)
|
||||
1. Ahmad is studying ______ engineering at university.
|
||||
- **Answer:** **Ø (zero article)** — Academic subjects do not take an article.
|
||||
2. We had ______ unforgettable experience at Petra last weekend.
|
||||
- **Answer:** **an** — Starts with a vowel sound.
|
||||
3. Have you finished reading ______ physics worksheet given by Mr. Majali?
|
||||
- **Answer:** **the** — Specific worksheet known to both.
|
||||
|
||||
---
|
||||
|
||||
### Part C: Writing Challenge
|
||||
Write three coherent sentences describing the difference between casual and formal clothing in educational settings.
|
||||
""";
|
||||
|
||||
// ============================================================================
|
||||
// GENERAL FALLBACK
|
||||
// ============================================================================
|
||||
|
||||
static const String _defaultGeneralCurriculum = """
|
||||
## المنهاج الوزاري المعتمد — ملخص النتاجات والأنشطة
|
||||
### وزارة التربية والتعليم — المملكة الأردنية الهاشمية
|
||||
|
||||
---
|
||||
|
||||
### النتاجات العامة للمبحث:
|
||||
1. فهم واستيعاب المفاهيم والمصطلحات الأساسية المعتمدة في الإطار العام للمناهج.
|
||||
2. تنمية مهارات التفكير العلمي وحل المسائل الرياضية والفيزيائية وفق خطوات منهجية.
|
||||
3. الربط بين المعرفة النظرية والتطبيقات الحياتية والمختبرات الذكية.
|
||||
4. التدرب على أنماط أسئلة الاختبارات الوزارية من خلال بنك الأسئلة التكيفي.
|
||||
""";
|
||||
}
|
||||
@@ -79,6 +79,7 @@ class SocraticOptionModel {
|
||||
/// Model for Lesson Playback Data (Streams, Versions, Chapters, Checkpoints)
|
||||
class LessonPlaybackData {
|
||||
final int lessonId;
|
||||
final String? videoVersionId;
|
||||
final String title;
|
||||
final int durationSeconds;
|
||||
final String videoUrl;
|
||||
@@ -89,6 +90,7 @@ class LessonPlaybackData {
|
||||
|
||||
const LessonPlaybackData({
|
||||
required this.lessonId,
|
||||
this.videoVersionId,
|
||||
required this.title,
|
||||
required this.durationSeconds,
|
||||
required this.videoUrl,
|
||||
@@ -129,9 +131,10 @@ class LessonPlaybackData {
|
||||
final lastPos = (json['last_position_seconds'] ?? playback['last_position_seconds'] ?? json['progress']?['position_seconds']) as num?;
|
||||
|
||||
return LessonPlaybackData(
|
||||
lessonId: (lesson['id'] as num?)?.toInt() ?? 0,
|
||||
title: lesson['title']?.toString() ?? 'الدرس التفاعلي',
|
||||
durationSeconds: (lesson['duration_seconds'] as num?)?.toInt() ?? 0,
|
||||
lessonId: (lesson['id'] as num?)?.toInt() ?? (json['lesson_id'] as num?)?.toInt() ?? 0,
|
||||
videoVersionId: json['video_version_id']?.toString(),
|
||||
title: lesson['title']?.toString() ?? json['title']?.toString() ?? 'الدرس التفاعلي',
|
||||
durationSeconds: (lesson['duration_seconds'] as num?)?.toInt() ?? (json['duration_seconds'] as num?)?.toInt() ?? 0,
|
||||
videoUrl: vidUrl,
|
||||
storageType: playback['storage_type']?.toString() ?? 'api_upload',
|
||||
availableVersions: versions,
|
||||
@@ -142,6 +145,7 @@ class LessonPlaybackData {
|
||||
|
||||
LessonPlaybackData copyWith({
|
||||
int? lessonId,
|
||||
String? videoVersionId,
|
||||
String? title,
|
||||
int? durationSeconds,
|
||||
String? videoUrl,
|
||||
@@ -152,6 +156,7 @@ class LessonPlaybackData {
|
||||
}) {
|
||||
return LessonPlaybackData(
|
||||
lessonId: lessonId ?? this.lessonId,
|
||||
videoVersionId: videoVersionId ?? this.videoVersionId,
|
||||
title: title ?? this.title,
|
||||
durationSeconds: durationSeconds ?? this.durationSeconds,
|
||||
videoUrl: videoUrl ?? this.videoUrl,
|
||||
|
||||
@@ -30,7 +30,7 @@ class SubjectModel {
|
||||
this.stream = 'scientific',
|
||||
this.totalUnits = 0,
|
||||
this.totalLessons = 0,
|
||||
this.masteryScore = 85.0,
|
||||
this.masteryScore = 0,
|
||||
this.units = const [],
|
||||
this.textbooks = const [],
|
||||
this.worksheets = const [],
|
||||
@@ -95,7 +95,7 @@ class SubjectModel {
|
||||
stream: json['stream']?.toString() ?? 'scientific',
|
||||
totalUnits: parsedUnits.isNotEmpty ? parsedUnits.length : styling.defaultUnits,
|
||||
totalLessons: parsedUnits.fold(0, (sum, u) => sum + u.lessons.length),
|
||||
masteryScore: (json['mastery_score'] as num?)?.toDouble() ?? 88.0,
|
||||
masteryScore: (json['mastery_score'] as num?)?.toDouble() ?? 0,
|
||||
units: parsedUnits,
|
||||
textbooks: parsedTextbooks,
|
||||
worksheets: parsedWorksheets,
|
||||
@@ -198,6 +198,7 @@ class CurriculumLessonItemModel {
|
||||
final String title;
|
||||
final List<String> outcomes;
|
||||
final String? markdownFilePath;
|
||||
final String? curriculumLessonId;
|
||||
final int durationSeconds;
|
||||
final int checkpointsCount;
|
||||
final bool isCompleted;
|
||||
@@ -208,8 +209,9 @@ class CurriculumLessonItemModel {
|
||||
required this.title,
|
||||
this.outcomes = const [],
|
||||
this.markdownFilePath,
|
||||
this.durationSeconds = 1200, // 20 mins default
|
||||
this.checkpointsCount = 3,
|
||||
this.curriculumLessonId,
|
||||
this.durationSeconds = 0,
|
||||
this.checkpointsCount = 0,
|
||||
this.isCompleted = false,
|
||||
this.hasVideo = false,
|
||||
});
|
||||
@@ -233,24 +235,17 @@ class CurriculumLessonItemModel {
|
||||
? filePath.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_')
|
||||
: 'lesson_${(json['title']?.toString() ?? 'default').hashCode.abs()}');
|
||||
|
||||
// Video availability:
|
||||
// 1. Live server enriched: json['has_video'] == true or json['video_url'] is present
|
||||
// 2. Exact uploaded lessons in Grade 10 Math Unit 1 (Intro, Lesson 1, Lesson 2)
|
||||
final bool hasVideoExplicit = (json['has_video'] == true) ||
|
||||
(json['video_url'] != null && json['video_url'].toString().isNotEmpty) ||
|
||||
(filePath != null && (
|
||||
filePath.contains('math_10/semester_1/unit_01/intro_and_project') ||
|
||||
filePath.contains('math_10/semester_1/unit_01/lesson_01') ||
|
||||
filePath.contains('math_10/semester_1/unit_01/lesson_02')
|
||||
));
|
||||
// Only the server may declare a published lesson playable.
|
||||
final bool hasVideoExplicit = json['has_video'] == true;
|
||||
|
||||
return CurriculumLessonItemModel(
|
||||
id: stableId,
|
||||
title: json['title']?.toString() ?? 'درس بدون عنوان',
|
||||
outcomes: outs,
|
||||
markdownFilePath: filePath,
|
||||
durationSeconds: (json['duration_seconds'] as num?)?.toInt() ?? 1200,
|
||||
checkpointsCount: (json['checkpoints_count'] as num?)?.toInt() ?? 3,
|
||||
curriculumLessonId: json['curriculum_lesson_id']?.toString(),
|
||||
durationSeconds: (json['duration_seconds'] as num?)?.toInt() ?? 0,
|
||||
checkpointsCount: (json['checkpoints_count'] as num?)?.toInt() ?? 0,
|
||||
isCompleted: (json['is_completed'] as bool?) ?? false,
|
||||
hasVideo: hasVideoExplicit,
|
||||
);
|
||||
|
||||
@@ -147,20 +147,30 @@ class CurriculumRepository {
|
||||
return liveSubjects;
|
||||
}
|
||||
|
||||
/// Fetch lesson playback details (streams, checkpoints, and versions) from Live API
|
||||
Future<LessonPlaybackData> getLessonPlayback(String curriculumKey, {String? subjectId, String? unitId, String? title}) async {
|
||||
AppLogger.log('Fetching live playback data for curriculum key $curriculumKey (title: $title)...', tag: 'CURRICULUM_REPO');
|
||||
|
||||
final Map<String, String> queryParams = {'curriculum_key': curriculumKey};
|
||||
if (title != null && title.isNotEmpty) {
|
||||
queryParams['title'] = title;
|
||||
}
|
||||
final res = await _api.get('/api/lessons/playback', queryParams: queryParams);
|
||||
if (res is Map && res['data'] != null) {
|
||||
return LessonPlaybackData.fromJson(Map<String, dynamic>.from(res['data']));
|
||||
}
|
||||
|
||||
throw ApiException('فشل جلب بيانات تشغيل الدرس من الخادم');
|
||||
Future<List<PublishedLessonVideoModel>> getPublishedLessonVideos(String curriculumLessonId) async {
|
||||
final res = await _api.get('/api/curriculum/lessons/$curriculumLessonId/videos');
|
||||
final data = res is Map ? res['data'] : null;
|
||||
final raw = data is Map ? data['items'] : null;
|
||||
if (raw is! List) return const [];
|
||||
return raw.whereType<Map>().map((item) => PublishedLessonVideoModel.fromJson(Map<String, dynamic>.from(item))).where((item) => item.videoVersionId.isNotEmpty).toList();
|
||||
}
|
||||
|
||||
Future<LessonPlaybackData> getVideoVersionPlayback(String videoVersionId) async {
|
||||
final res = await _api.get('/api/video-versions/$videoVersionId/playback');
|
||||
if (res is Map && res['data'] is Map) return LessonPlaybackData.fromJson(Map<String, dynamic>.from(res['data']));
|
||||
throw ApiException('فشل جلب تشغيل الحصة المنشورة من الخادم');
|
||||
}
|
||||
|
||||
Future<String> startWatchSession(String videoVersionId) async {
|
||||
final res = await _api.post('/api/video-versions/$videoVersionId/watch-sessions', body: const {});
|
||||
final data = res is Map ? res['data'] : null;
|
||||
final source = data is Map ? data : res;
|
||||
final id = source is Map ? source['watch_session_id']?.toString() : null;
|
||||
if (id == null || id.isEmpty) throw ApiException('لم ينشئ الخادم جلسة مشاهدة.');
|
||||
return id;
|
||||
}
|
||||
Future<void> recordWatchEvent(String sessionId, int sequenceNo, String eventType, int positionMs) async {
|
||||
await _api.post('/api/watch-sessions/$sessionId/events', body: {'sequence_no': sequenceNo, 'event_type': eventType, 'position_ms': positionMs});
|
||||
}
|
||||
|
||||
Future<void> saveProgress({required int lessonId, required int positionSeconds, required int watchedSeconds}) async {
|
||||
@@ -178,3 +188,16 @@ class CurriculumRepository {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class PublishedLessonVideoModel {
|
||||
final String videoVersionId;
|
||||
final String submissionId;
|
||||
final String teacherName;
|
||||
final double rating;
|
||||
final int ratingCount;
|
||||
final bool isNew;
|
||||
const PublishedLessonVideoModel({required this.videoVersionId, required this.submissionId, required this.teacherName, required this.rating, required this.ratingCount, required this.isNew});
|
||||
factory PublishedLessonVideoModel.fromJson(Map<String, dynamic> json) => PublishedLessonVideoModel(
|
||||
videoVersionId: json['video_version_id']?.toString() ?? '', submissionId: json['submission_id']?.toString() ?? '', teacherName: json['teacher_name']?.toString() ?? 'معلم معتمد', rating: (json['rating'] as num?)?.toDouble() ?? 0, ratingCount: (json['rating_count'] as num?)?.toInt() ?? 0, isNew: json['is_new'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,9 @@ class VideoPlaybackError extends VideoPlaybackState {
|
||||
class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
||||
final CurriculumRepository _repo;
|
||||
int _lastObservedPosition = -1;
|
||||
String? _watchSessionId;
|
||||
int _watchSequence = 1;
|
||||
int _lastWatchEventPosition = 0;
|
||||
|
||||
VideoPlaybackCubit({CurriculumRepository? repo})
|
||||
: _repo = repo ?? CurriculumRepository(),
|
||||
@@ -86,19 +89,30 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
||||
return fallbackId ?? 'lesson_default';
|
||||
}
|
||||
|
||||
Future<void> loadLesson(CurriculumLessonItemModel lesson, {SubjectModel? subject}) async {
|
||||
Future<void> loadLesson(CurriculumLessonItemModel lesson, {SubjectModel? subject, String? selectedVideoVersionId}) async {
|
||||
AppLogger.log('Loading Socratic playback for lesson: ${lesson.title}', tag: 'VIDEO_CUBIT');
|
||||
_lastObservedPosition = -1;
|
||||
emit(VideoPlaybackLoading());
|
||||
final storageKey = _getLessonStorageKey(lesson);
|
||||
|
||||
try {
|
||||
final cleanKey = (lesson.markdownFilePath ?? lesson.id).replaceAll(RegExp(r'\.md$'), '');
|
||||
var playback = await _repo.getLessonPlayback(cleanKey, subjectId: subject?.id, title: lesson.title);
|
||||
final versionId = lesson.curriculumLessonId;
|
||||
if (versionId == null || versionId.isEmpty) {
|
||||
throw StateError('هذا الدرس غير منشور بعد ضمن حزمة المحتوى المعتمدة.');
|
||||
}
|
||||
final published = await _repo.getPublishedLessonVideos(versionId);
|
||||
if (published.isEmpty) throw StateError('لا توجد حصة منشورة ومصرح بها لهذا الدرس بعد.');
|
||||
// Selection is performed in the lesson screen when several teachers exist.
|
||||
var playback = await _repo.getVideoVersionPlayback(selectedVideoVersionId ?? published.first.videoVersionId);
|
||||
if (playback.videoUrl.isEmpty) {
|
||||
throw StateError('لم يربط الخادم فيديو R2 بهذا الدرس بعد.');
|
||||
}
|
||||
|
||||
_watchSessionId = null; _watchSequence = 1; _lastWatchEventPosition = 0;
|
||||
if (playback.videoVersionId != null && playback.videoVersionId!.isNotEmpty) {
|
||||
try { _watchSessionId = await _repo.startWatchSession(playback.videoVersionId!); } catch (_) {}
|
||||
}
|
||||
|
||||
// Load saved resume position strictly per video
|
||||
int resumePos = playback.lastPositionSeconds ?? 0;
|
||||
Set<int> passedIds = {};
|
||||
@@ -151,6 +165,13 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> endWatchSession() async {
|
||||
final session = _watchSessionId;
|
||||
if (session == null) return;
|
||||
_watchSessionId = null;
|
||||
try { await _repo.recordWatchEvent(session, ++_watchSequence, 'end', _lastObservedPosition.clamp(0, 1 << 31) * 1000); } catch (_) {}
|
||||
}
|
||||
|
||||
void togglePlayPause() {
|
||||
final currentState = state;
|
||||
if (currentState is VideoPlaybackReady && currentState.activeCheckpoint == null) {
|
||||
@@ -197,11 +218,11 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
||||
);
|
||||
}
|
||||
if (currentState.playbackData.lessonId > 0) {
|
||||
await _repo.saveProgress(
|
||||
lessonId: currentState.playbackData.lessonId,
|
||||
positionSeconds: positionSeconds,
|
||||
watchedSeconds: watchedSeconds,
|
||||
);
|
||||
await _repo.saveProgress(lessonId: currentState.playbackData.lessonId, positionSeconds: positionSeconds, watchedSeconds: watchedSeconds);
|
||||
}
|
||||
if (_watchSessionId != null && positionSeconds - _lastWatchEventPosition >= 30) {
|
||||
_watchSequence++; _lastWatchEventPosition = positionSeconds;
|
||||
await _repo.recordWatchEvent(_watchSessionId!, _watchSequence, 'heartbeat', positionSeconds * 1000);
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.log('Progress sync deferred: $e', tag: 'VIDEO_CUBIT');
|
||||
|
||||
+18
-16
@@ -4,7 +4,6 @@ import '../../../core/network/api_client.dart';
|
||||
import '../../../core/services/local_document_storage_service.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/utils/saqel_toast.dart';
|
||||
import '../../../data/datasources/curriculum_baked_data.dart';
|
||||
import '../../widgets/luxury_widgets.dart';
|
||||
import '../../widgets/interactive_english_passage_view.dart';
|
||||
import 'physics_interactive_lab_view.dart';
|
||||
@@ -19,7 +18,7 @@ import 'english_interactive_lab_view.dart';
|
||||
/// الهدف المعماري:
|
||||
/// استعراض الكتب المدرسية المقررة والمذكرات الوزارية وأوراق العمل محلياً وسحابياً:
|
||||
/// 1. دعم التخزين المحلي (Offline Storage): قراءة وتصفح دائم دون اتصال بعد الحفظ.
|
||||
/// 2. محتوى وزاري مخبوز وأصيل (CurriculumBakedData) للمواد الثلاث (رياضيات 10، فيزياء 10، إنجليزي 10).
|
||||
/// 2. محتوى وزاري منشور من الخادم أو نسخة محلية موثقة منه فقط.
|
||||
/// 3. دعم الناطق الصوتي الذكي (English TTS) المدمج لقراءة النصوص والمفردات.
|
||||
/// 4. ربط فوري بالمختبر التفاعلي (Virtual Lab) لمفاهيم المتجهات والفيزياء.
|
||||
/// 5. التحكم بحجم الخط وزر الحفظ في الجهاز.
|
||||
@@ -55,6 +54,7 @@ class _CurriculumDocumentViewerScreenState extends State<CurriculumDocumentViewe
|
||||
bool _isSavedLocally = false;
|
||||
String _documentContent = '';
|
||||
List<DocumentSection> _sections = [];
|
||||
String? _loadError;
|
||||
|
||||
bool get _isEnglish =>
|
||||
widget.subjectTitle.contains('إنجليز') ||
|
||||
@@ -126,20 +126,14 @@ class _CurriculumDocumentViewerScreenState extends State<CurriculumDocumentViewe
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// Graceful fallback to rich baked curriculum data
|
||||
} catch (_) {}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loadError = 'المستند المطلوب غير متاح حالياً. لا يمكن عرض بديل لدرس مختلف.';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Fallback to authentic Jordanian curriculum baked content
|
||||
final baked = CurriculumBakedData.getDocument(
|
||||
subjectId: _effectiveSubjectId,
|
||||
type: widget.documentType,
|
||||
filePath: _effectiveFilePath,
|
||||
title: widget.title,
|
||||
);
|
||||
|
||||
_processContent(baked);
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
Future<void> _toggleSaveLocal() async {
|
||||
@@ -266,7 +260,15 @@ class _CurriculumDocumentViewerScreenState extends State<CurriculumDocumentViewe
|
||||
textDirection: _isEnglish ? TextDirection.ltr : TextDirection.rtl,
|
||||
child: _isLoading
|
||||
? const Center(child: CupertinoActivityIndicator(color: AppColors.saqelCyan))
|
||||
: SingleChildScrollView(
|
||||
: _loadError != null
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(_loadError!, textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.textSecondaryDark)),
|
||||
),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
+23
-2217
File diff suppressed because it is too large
Load Diff
@@ -29,8 +29,7 @@ import 'math_interactive_lab_view.dart';
|
||||
import 'english_interactive_lab_view.dart';
|
||||
import '../../../data/models/exam_model.dart';
|
||||
import '../../../data/repositories/app_repositories.dart';
|
||||
import '../../../core/services/local_document_storage_service.dart';
|
||||
import '../../../data/datasources/curriculum_baked_data.dart';
|
||||
import '../../../data/repositories/curriculum_repository.dart';
|
||||
|
||||
/// الشاشة المركزية للمادة الدراسية وبوابات الدروس والامتحانات والمصادر
|
||||
class SubjectHubScreen extends StatefulWidget {
|
||||
@@ -690,29 +689,10 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
onPressed: () async {
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
final docType = title.contains('كتاب') ? 'textbook' : 'worksheet';
|
||||
final content = CurriculumBakedData.getDocument(
|
||||
subjectId: widget.subject.id,
|
||||
type: docType,
|
||||
filePath: filePath,
|
||||
title: title,
|
||||
);
|
||||
await LocalDocumentStorageService.saveDocumentLocally(
|
||||
subjectId: widget.subject.id,
|
||||
type: docType,
|
||||
filePath: filePath ?? title,
|
||||
title: title,
|
||||
content: content,
|
||||
);
|
||||
if (context.mounted) {
|
||||
SaqelToast.showSuccess(
|
||||
context,
|
||||
'تم حفظ المستند في الجهاز — متاح الآن للقراءة دون إنترنت 📖',
|
||||
title: 'حفظ محلي مكتمل',
|
||||
);
|
||||
}
|
||||
SaqelToast.showInfo(context,
|
||||
'افتح المستند أولاً ثم احفظ النسخة المنشورة نفسها في الجهاز.');
|
||||
},
|
||||
icon: const Icon(CupertinoIcons.cloud_download, size: 18),
|
||||
label: const Text('حفظ في الجهاز', style: TextStyle(fontWeight: FontWeight.w700)),
|
||||
@@ -841,23 +821,44 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
|
||||
);
|
||||
}
|
||||
|
||||
/// Direct Launch of Socratic Video Player or Unavailable Notice
|
||||
void _showLessonVideoSelector(BuildContext context, CurriculumLessonItemModel lesson) {
|
||||
if (!lesson.hasVideo) {
|
||||
/// Loads the server-authoritative list: one version opens directly; several
|
||||
/// versions require an explicit teacher choice.
|
||||
Future<void> _showLessonVideoSelector(BuildContext context, CurriculumLessonItemModel lesson) async {
|
||||
final curriculumLessonId = lesson.curriculumLessonId;
|
||||
if (!lesson.hasVideo || curriculumLessonId == null || curriculumLessonId.isEmpty) {
|
||||
_showNoVideoAvailableSheet(context, lesson);
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct authentic launch: zero mockups, zero fake teacher names
|
||||
Navigator.of(context).push(
|
||||
CupertinoPageRoute(
|
||||
builder: (_) => SocraticVideoPlayerScreen(
|
||||
lesson: lesson,
|
||||
subject: widget.subject,
|
||||
instructorName: 'منصة صَقِل التعليمية المعتمدة',
|
||||
videoSourceLabel: 'الشرح الرقمي الرسمي المعتمد',
|
||||
),
|
||||
),
|
||||
);
|
||||
try {
|
||||
final videos = await CurriculumRepository().getPublishedLessonVideos(curriculumLessonId);
|
||||
if (!context.mounted) return;
|
||||
if (videos.isEmpty) { _showNoVideoAvailableSheet(context, lesson); return; }
|
||||
PublishedLessonVideoModel? chosen;
|
||||
if (videos.length == 1) {
|
||||
chosen = videos.first;
|
||||
} else {
|
||||
chosen = await showCupertinoModalPopup<PublishedLessonVideoModel>(
|
||||
context: context,
|
||||
builder: (sheetContext) => CupertinoActionSheet(
|
||||
title: const Text('اختر شرح المعلم'),
|
||||
message: const Text('تُعرض الحصص المنشورة لهذا الدرس فقط، مرتبة بالتقييم الموثق.'),
|
||||
actions: videos.map((video) => CupertinoActionSheetAction(
|
||||
onPressed: () => Navigator.of(sheetContext).pop(video),
|
||||
child: Text(video.ratingCount == 0 ? '${video.teacherName} — جديد' : '${video.teacherName} — ★ ${video.rating.toStringAsFixed(1)} (${video.ratingCount})'),
|
||||
)).toList(),
|
||||
cancelButton: CupertinoActionSheetAction(onPressed: () => Navigator.of(sheetContext).pop(), child: const Text('إلغاء')),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (chosen == null || !context.mounted) return;
|
||||
Navigator.of(context).push(CupertinoPageRoute(builder: (_) => SocraticVideoPlayerScreen(
|
||||
lesson: lesson, subject: widget.subject, instructorName: chosen!.teacherName,
|
||||
videoSourceLabel: chosen.ratingCount == 0 ? 'حصة منشورة جديدة' : 'تقييم موثق: ${chosen.rating.toStringAsFixed(1)}',
|
||||
selectedVideoVersionId: chosen.videoVersionId,
|
||||
)));
|
||||
} catch (e) {
|
||||
if (context.mounted) SaqelToast.showError(context, 'تعذر تحميل الحصص المنشورة لهذا الدرس.');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ class SocraticVideoPlayerScreen extends StatefulWidget {
|
||||
final SubjectModel? subject;
|
||||
final String? instructorName;
|
||||
final String? videoSourceLabel;
|
||||
final String? selectedVideoVersionId;
|
||||
|
||||
const SocraticVideoPlayerScreen({
|
||||
super.key,
|
||||
@@ -42,6 +43,7 @@ class SocraticVideoPlayerScreen extends StatefulWidget {
|
||||
this.subject,
|
||||
this.instructorName,
|
||||
this.videoSourceLabel,
|
||||
this.selectedVideoVersionId,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -127,7 +129,7 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
context.read<VideoPlaybackCubit>().loadLesson(widget.lesson, subject: widget.subject);
|
||||
context.read<VideoPlaybackCubit>().loadLesson(widget.lesson, subject: widget.subject, selectedVideoVersionId: widget.selectedVideoVersionId);
|
||||
|
||||
// Dynamic Floating Forensic Anti-Piracy Watermark Animation
|
||||
_watermarkController = AnimationController(
|
||||
@@ -156,6 +158,7 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
context.read<VideoPlaybackCubit>().endWatchSession();
|
||||
_tts.stop();
|
||||
_playbackTicker?.cancel();
|
||||
_controlsHideTimer?.cancel();
|
||||
|
||||
@@ -188,9 +188,9 @@ class TeacherProfileModel {
|
||||
required this.schoolName,
|
||||
required this.gradesTaught,
|
||||
required this.bio,
|
||||
this.rating = 4.9,
|
||||
this.studentsCount = 42,
|
||||
this.lessonsCount = 28,
|
||||
this.rating = 0,
|
||||
this.studentsCount = 0,
|
||||
this.lessonsCount = 0,
|
||||
});
|
||||
|
||||
factory TeacherProfileModel.fromJson(Map<String, dynamic> json) {
|
||||
|
||||
@@ -28,6 +28,8 @@ class TeacherMonetizationState {
|
||||
final List<TeacherPayoutRequestModel> payoutRequests;
|
||||
final bool isSubmittingPayout;
|
||||
final bool isLoading;
|
||||
final bool financialUnavailable;
|
||||
final String financialMessage;
|
||||
|
||||
const TeacherMonetizationState({
|
||||
required this.institutionalStudents,
|
||||
@@ -44,6 +46,8 @@ class TeacherMonetizationState {
|
||||
required this.payoutRequests,
|
||||
required this.isSubmittingPayout,
|
||||
this.isLoading = false,
|
||||
this.financialUnavailable = false,
|
||||
this.financialMessage = '',
|
||||
});
|
||||
|
||||
TeacherMonetizationState copyWith({
|
||||
@@ -61,6 +65,8 @@ class TeacherMonetizationState {
|
||||
List<TeacherPayoutRequestModel>? payoutRequests,
|
||||
bool? isSubmittingPayout,
|
||||
bool? isLoading,
|
||||
bool? financialUnavailable,
|
||||
String? financialMessage,
|
||||
}) {
|
||||
return TeacherMonetizationState(
|
||||
institutionalStudents:
|
||||
@@ -78,6 +84,8 @@ class TeacherMonetizationState {
|
||||
payoutRequests: payoutRequests ?? this.payoutRequests,
|
||||
isSubmittingPayout: isSubmittingPayout ?? this.isSubmittingPayout,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
financialUnavailable: financialUnavailable ?? this.financialUnavailable,
|
||||
financialMessage: financialMessage ?? this.financialMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -116,16 +124,21 @@ class TeacherMonetizationCubit extends Cubit<TeacherMonetizationState> {
|
||||
final mkt =
|
||||
audience['marketplace_students'] as Map<String, dynamic>? ?? {};
|
||||
final wallet = data['wallet'] as Map<String, dynamic>? ?? {};
|
||||
final unavailable = data['financial_status'] == 'unavailable';
|
||||
|
||||
final instCount = (inst['count'] as num?)?.toDouble() ?? 0.0;
|
||||
final paidCount = (mkt['count'] as num?)?.toDouble() ?? 0.0;
|
||||
final gross =
|
||||
(data['gross_revenue_jod'] as num?)?.toDouble() ?? (paidCount * 20.0);
|
||||
final tShare = gross * 0.55;
|
||||
final dShare = gross * 0.15;
|
||||
final pShare = gross * 0.30;
|
||||
final split = data['revenue_split'] as Map<String, dynamic>? ?? {};
|
||||
// Never infer a wallet from subscriber counts, course prices, or old
|
||||
// percentages. The backend returns null until a provider-backed ledger
|
||||
// and eligible watch-minute policy are live.
|
||||
final gross = (data['gross_revenue_jod'] as num?)?.toDouble() ?? 0.0;
|
||||
final tShare = (split['teacher_amount_jod'] as num?)?.toDouble() ?? 0.0;
|
||||
final dShare =
|
||||
(split['directorate_amount_jod'] as num?)?.toDouble() ?? 0.0;
|
||||
final pShare = (split['platform_amount_jod'] as num?)?.toDouble() ?? 0.0;
|
||||
final avail =
|
||||
(wallet['available_balance_jod'] as num?)?.toDouble() ?? tShare;
|
||||
(wallet['available_balance_jod'] as num?)?.toDouble() ?? 0.0;
|
||||
final withdrawn =
|
||||
(wallet['total_withdrawn_jod'] as num?)?.toDouble() ?? 0.0;
|
||||
final alias = wallet['cliq_payout_alias']?.toString() ?? '';
|
||||
@@ -154,6 +167,8 @@ class TeacherMonetizationCubit extends Cubit<TeacherMonetizationState> {
|
||||
cliqAlias: alias,
|
||||
courses: courses,
|
||||
payoutRequests: payouts,
|
||||
financialUnavailable: unavailable,
|
||||
financialMessage: data['financial_message']?.toString() ?? '',
|
||||
isLoading: false,
|
||||
));
|
||||
} catch (_) {
|
||||
@@ -162,15 +177,8 @@ class TeacherMonetizationCubit extends Cubit<TeacherMonetizationState> {
|
||||
}
|
||||
|
||||
void updateSubscribers(double subscribers) {
|
||||
final gross = subscribers * state.pricePerCourse;
|
||||
emit(state.copyWith(
|
||||
studentSubscribers: subscribers,
|
||||
grossRevenue: gross,
|
||||
teacherShare: gross * 0.55,
|
||||
directorateShare: gross * 0.15,
|
||||
platformShare: gross * 0.30,
|
||||
availableBalance: gross * 0.55,
|
||||
));
|
||||
// Presentation must not fabricate revenue from a manually changed count.
|
||||
emit(state.copyWith(studentSubscribers: subscribers));
|
||||
}
|
||||
|
||||
Future<void> submitCliqPayout({
|
||||
@@ -186,14 +194,8 @@ class TeacherMonetizationCubit extends Cubit<TeacherMonetizationState> {
|
||||
final updatedList =
|
||||
List<TeacherPayoutRequestModel>.from(state.payoutRequests)
|
||||
..insert(0, req);
|
||||
final newAvail =
|
||||
(state.availableBalance - amountJod).clamp(0.0, double.infinity);
|
||||
final newWithdrawn = state.totalWithdrawn + amountJod;
|
||||
|
||||
emit(state.copyWith(
|
||||
payoutRequests: updatedList,
|
||||
availableBalance: newAvail,
|
||||
totalWithdrawn: newWithdrawn,
|
||||
isSubmittingPayout: false,
|
||||
));
|
||||
} catch (_) {
|
||||
|
||||
@@ -286,6 +286,40 @@ class _TeacherMonetizationTabState extends State<TeacherMonetizationTab> {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<TeacherMonetizationCubit, TeacherMonetizationState>(
|
||||
builder: (context, state) {
|
||||
if (state.financialUnavailable) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: TeacherTheme.surfaceCard,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: TeacherTheme.surfaceBorder),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(CupertinoIcons.lock_circle_fill,
|
||||
color: TeacherTheme.royalGold, size: 34),
|
||||
const SizedBox(height: 12),
|
||||
const Text('الأرباح والسحب غير متاحين حالياً',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800)),
|
||||
const SizedBox(height: 8),
|
||||
Text(state.financialMessage,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF94A3B8), fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
|
||||
@@ -36,7 +36,7 @@ class CliqPaymentController
|
||||
}
|
||||
|
||||
$res = CliqPaymentService::initiatePayment($studentId, $courseId);
|
||||
$status = ($res['status'] ?? 'error') === 'success' ? 200 : 400;
|
||||
$status = ($res['status'] ?? 'error') === 'success' ? 200 : (($res['status'] ?? '') === 'unavailable' ? 503 : 400);
|
||||
$response->status($status)->json($res);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ class CliqPaymentController
|
||||
}
|
||||
|
||||
$res = CliqPaymentService::verifyPaymentReceipt($studentId, $referenceCode, $receiptBase64, $manualTxId);
|
||||
$status = ($res['status'] ?? 'error') === 'success' ? 200 : 400;
|
||||
$status = ($res['status'] ?? 'error') === 'success' ? 200 : (($res['status'] ?? '') === 'unavailable' ? 503 : 400);
|
||||
$response->status($status)->json($res);
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ class CliqPaymentController
|
||||
}
|
||||
|
||||
$res = CliqPaymentService::requestTeacherPayout($teacherId, $amountJod, $cliqAlias);
|
||||
$status = ($res['status'] ?? 'error') === 'success' ? 200 : 400;
|
||||
$status = ($res['status'] ?? 'error') === 'success' ? 200 : (($res['status'] ?? '') === 'unavailable' ? 503 : 400);
|
||||
$response->status($status)->json($res);
|
||||
}
|
||||
|
||||
@@ -111,6 +111,6 @@ class CliqPaymentController
|
||||
public function processPayoutQueue(Request $request, Response $response): void
|
||||
{
|
||||
$res = CliqPaymentService::processPayoutQueue();
|
||||
$response->json($res);
|
||||
$response->status(($res['status'] ?? '') === 'unavailable' ? 503 : 200)->json($res);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,11 @@ namespace App\Controllers;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Core\Database;
|
||||
use App\Services\CurriculumService;
|
||||
use App\Services\CurriculumExtractorService;
|
||||
use App\Services\PublishedContentService;
|
||||
use App\Services\LearningPackageService;
|
||||
|
||||
class CurriculumController
|
||||
{
|
||||
@@ -164,16 +167,33 @@ class CurriculumController
|
||||
$response->json(['status' => 'success', 'log' => $logContent]);
|
||||
}
|
||||
|
||||
/** GET /api/curriculum/lessons/{lessonId}/english-package */
|
||||
public function getPublishedEnglishPackage(Request $request, Response $response): void
|
||||
{
|
||||
try {
|
||||
$result=LearningPackageService::publishedEnglish(trim((string)$request->getParam('lessonId','')),(int)$request->user_id,$request->getHeader('x-national-id'));
|
||||
$response->status((int)$result['http_status'])->json(array_diff_key($result,['http_status'=>true]));
|
||||
} catch (\Throwable $e) { error_log('English package read failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر قراءة حزمة الإنجليزية.']); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Complete Live Tree
|
||||
*/
|
||||
|
||||
public function getTree(Request $request, Response $response): void
|
||||
{
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'data' => CurriculumService::getCurriculumTree()
|
||||
]);
|
||||
$tree = CurriculumService::getCurriculumTree();
|
||||
try {
|
||||
$published = Database::select("SELECT cl.uuid, cl.source_manifest_path, COUNT(vv.id) AS video_count FROM curriculum_lessons cl LEFT JOIN teacher_submissions ts ON ts.curriculum_lesson_id=cl.id AND ts.status='published' LEFT JOIN video_versions vv ON vv.id=ts.current_published_video_version_id AND vv.status='published' WHERE cl.source_status='approved' GROUP BY cl.id, cl.uuid, cl.source_manifest_path");
|
||||
$byPath=[];
|
||||
foreach ($published as $row) $byPath[(string)$row['source_manifest_path']]=['curriculum_lesson_id'=>$row['uuid'],'has_video'=>(int)$row['video_count']>0];
|
||||
foreach ($tree as &$grade) foreach (($grade['subjects'] ?? []) as &$subject) foreach (($subject['semesters'] ?? []) as &$semester) foreach (($semester['units'] ?? []) as &$unit) foreach (($unit['lessons'] ?? []) as &$lesson) {
|
||||
$path=(string)($lesson['file'] ?? '');
|
||||
if (isset($byPath[$path])) $lesson=array_merge($lesson,$byPath[$path]);
|
||||
}
|
||||
unset($grade,$subject,$semester,$unit,$lesson);
|
||||
} catch (\Throwable $e) { error_log('Published curriculum tree enrichment unavailable: '.$e->getMessage()); }
|
||||
$response->json(['status'=>'success','data'=>$tree]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -423,30 +443,18 @@ class CurriculumController
|
||||
$type = $params['type'] ?? 'textbook';
|
||||
|
||||
$storage = realpath(__DIR__ . '/../../storage/curriculum');
|
||||
$resolvedFile = null;
|
||||
|
||||
if (!empty($file) && !str_ends_with($file, '.pdf')) {
|
||||
$cleanFile = ltrim($file, '/');
|
||||
if (file_exists("{$storage}/{$cleanFile}")) {
|
||||
$resolvedFile = "{$storage}/{$cleanFile}";
|
||||
}
|
||||
if (!$storage || empty($file) || !str_ends_with(strtolower($file), '.md')) {
|
||||
$response->status(400)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'يجب تحديد ملف Markdown معتمد للوثيقة المطلوبة.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// If not resolved by exact path, resolve by subject
|
||||
if (!$resolvedFile && !empty($subject)) {
|
||||
if (str_contains($subject, 'english') || str_contains($subject, 'إنجليز')) {
|
||||
$mds = glob("{$storage}/grade_10/english_10/semester_1/*/*.md");
|
||||
if (!empty($mds)) $resolvedFile = $mds[0];
|
||||
} elseif (str_contains($subject, 'physic') || str_contains($subject, 'فيزياء')) {
|
||||
$mds = glob("{$storage}/grade_10/physics_10/semester_1/*/*.md");
|
||||
if (!empty($mds)) $resolvedFile = $mds[1] ?? $mds[0];
|
||||
} elseif (str_contains($subject, 'math') || str_contains($subject, 'رياضيات')) {
|
||||
$mds = glob("{$storage}/grade_10/math_10/semester_1/*/*.md");
|
||||
if (!empty($mds)) $resolvedFile = $mds[1] ?? $mds[0];
|
||||
}
|
||||
}
|
||||
|
||||
if ($resolvedFile && file_exists($resolvedFile)) {
|
||||
$candidate = realpath($storage . '/' . ltrim($file, '/'));
|
||||
$storagePrefix = rtrim($storage, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
if ($candidate && str_starts_with($candidate, $storagePrefix) && is_file($candidate)) {
|
||||
$resolvedFile = $candidate;
|
||||
$content = file_get_contents($resolvedFile);
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
@@ -458,26 +466,87 @@ class CurriculumController
|
||||
return;
|
||||
}
|
||||
|
||||
// Tailored subject fallback
|
||||
$content = self::getDefaultSubjectDocument($subject, $type);
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'subject' => $subject,
|
||||
'type' => $type,
|
||||
'content' => $content
|
||||
$response->status(404)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'ملف الوثيقة المعتمد غير موجود أو غير متاح حالياً.'
|
||||
]);
|
||||
}
|
||||
|
||||
private static function getDefaultSubjectDocument(string $subject, string $type): string
|
||||
/**
|
||||
* Serves an approved asset from a published curriculum bundle by UUID only.
|
||||
* GET /api/curriculum/assets/{assetId}
|
||||
*/
|
||||
public function getPublishedAsset(Request $request, Response $response): void
|
||||
{
|
||||
$s = strtolower($subject);
|
||||
if (str_contains($s, 'english') || str_contains($s, 'إنجليز')) {
|
||||
return "## Unit 01: Looking Good — Vocabulary & Reading\n\n### 1. Key Vocabulary & Word Formation\n- **Casual clothing:** Everyday informal garments (jeans, sneakers, hoodie).\n- **Traditional attire:** Cultural heritage wear (Jordanian Thobe and Keffiyeh).\n- **Subconscious influence:** The way external appearance affects cognitive confidence.\n\n### 2. Reading Text: The Power of First Impressions\nResearch shows that within the first seven seconds of meeting someone, people make subconscious judgments about character, competence, and reliability. In a famous experiment, doctors wearing clean white coats demonstrated higher diagnostic focus.\n\n### 3. Grammar Workshop: Articles (a, an, the, zero article)\n- Use **a / an** for non-specific singular countable nouns.\n- Use **the** when the listener knows which specific thing is meant.\n- Use **zero article** with plural or uncountable nouns spoken in general terms.";
|
||||
$assetId = trim((string)$request->getParam('assetId', ''));
|
||||
try {
|
||||
$asset = PublishedContentService::findPublishedAsset($assetId);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Published asset lookup failed: ' . $e->getMessage());
|
||||
$response->status(503)->json([
|
||||
'status' => 'unavailable',
|
||||
'message' => 'فهرس الأصول المنشورة غير متاح حالياً.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (str_contains($s, 'physic') || str_contains($s, 'فيزياء')) {
|
||||
return "## الوحدة الأولى: المتجهات والكميات الفيزيائية\n\n### 1. التمييز بين الكميات القياسية والمتجهة\n- **الكميات القياسية:** تُحدد بالمقدار ووحدة القياس فقط (الكتلة، الزمن، الطاقة، درجة الحرارة).\n- **الكميات المتجهة:** تُحدد بالمقدار والاتجاه ونقطة التأثير (القوة، السرعة المتجهة، التسارع، الإزاحة).\n\n### 2. جمع وتحليل المتجهات بيانياً وتحليلياً\n- **المركبة الأفقية:** A_x = A cos θ\n- **المركبة الرأسية:** A_y = A sin θ\n- **المحصلة:** R = √(R_x² + R_y²)\n- **زاوية الاتجاه:** tan θ = |R_y / R_x|";
|
||||
|
||||
if (!$asset) {
|
||||
$response->status(404)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'الأصل المطلوب غير منشور أو غير متاح لك.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
return "## ملخص الوحدة والنتاجات التعليمية المقررة\n\n### 1. الأهداف العامة للمادة\n- استيعاب المفاهيم والمصطلحات الأساسية وفق المنهاج الوزاري المعتمد.\n- التدرب على حل النماذج والتطبيقات العملية والأسئلة الوزارية.\n- التحقق من اكتساب المهارات من خلال بنك الأسئلة التكيفي والمختبر.";
|
||||
|
||||
$path = PublishedContentService::readLocalAsset($asset);
|
||||
if (!$path) {
|
||||
$response->status(404)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'النسخة المنشورة من الأصل غير متاحة في التخزين.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (str_starts_with((string)$asset['mime_type'], 'text/')) {
|
||||
$content = file_get_contents($path);
|
||||
if ($content === false) {
|
||||
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر قراءة الأصل المنشور.']);
|
||||
return;
|
||||
}
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'asset' => self::assetMetadata($asset),
|
||||
'content' => $content,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$response->setHeader('Content-Type', (string)$asset['mime_type']);
|
||||
$response->setHeader('Content-Length', (string)$asset['byte_size']);
|
||||
$response->setHeader('Content-Disposition', 'inline; filename="' . basename($path) . '"');
|
||||
$response->sendHeaders();
|
||||
readfile($path);
|
||||
exit;
|
||||
}
|
||||
|
||||
private static function assetMetadata(array $asset): array
|
||||
{
|
||||
return [
|
||||
'asset_id' => $asset['uuid'],
|
||||
'asset_type' => $asset['asset_type'],
|
||||
'mime_type' => $asset['mime_type'],
|
||||
'byte_size' => (int)$asset['byte_size'],
|
||||
'sha256' => $asset['sha256'],
|
||||
'bundle_version' => $asset['bundle_version'],
|
||||
'curriculum_lesson_id' => $asset['curriculum_lesson_uuid'],
|
||||
'identity' => [
|
||||
'grade' => $asset['grade_key'],
|
||||
'subject' => $asset['subject_key'],
|
||||
'semester' => $asset['semester_key'],
|
||||
'unit' => $asset['unit_key'],
|
||||
'lesson' => $asset['lesson_key'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -593,4 +662,3 @@ class CurriculumController
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -424,18 +424,7 @@ class ExamController
|
||||
/** Additive production migration for installations created before completed_at. */
|
||||
private static function ensureAttemptSchema(): void
|
||||
{
|
||||
try {
|
||||
$column = Database::selectOne(
|
||||
"SELECT COUNT(*) AS cnt FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'exam_attempts'
|
||||
AND COLUMN_NAME = 'completed_at' LIMIT 1"
|
||||
);
|
||||
if (empty($column['cnt'])) {
|
||||
Database::query("ALTER TABLE exam_attempts ADD COLUMN completed_at TIMESTAMP NULL DEFAULT NULL AFTER ai_diagnostic_report");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Exam schema migration note: ' . $e->getMessage());
|
||||
}
|
||||
// Kept for compatibility with callers. Schema is installed by migrations.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -484,40 +473,7 @@ class ExamController
|
||||
*/
|
||||
public static function ensureSchema(): void
|
||||
{
|
||||
try {
|
||||
Database::query(
|
||||
"CREATE TABLE IF NOT EXISTS `student_question_answers` (
|
||||
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`attempt_id` BIGINT UNSIGNED NOT NULL,
|
||||
`student_id` BIGINT UNSIGNED NOT NULL,
|
||||
`question_id` BIGINT UNSIGNED NOT NULL,
|
||||
`selected_option_id` BIGINT UNSIGNED NULL,
|
||||
`is_correct` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`points_awarded` DECIMAL(5, 2) NOT NULL DEFAULT 0.00,
|
||||
`time_spent_seconds` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY `idx_sqa_attempt` (`attempt_id`),
|
||||
KEY `idx_sqa_student` (`student_id`),
|
||||
KEY `idx_sqa_question` (`question_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
// Ensure completed_at in exam_attempts
|
||||
$colCheck = Database::selectOne(
|
||||
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'exam_attempts' AND COLUMN_NAME = 'completed_at' LIMIT 1"
|
||||
);
|
||||
if (!$colCheck) {
|
||||
Database::query("ALTER TABLE exam_attempts ADD COLUMN completed_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP AFTER ai_diagnostic_report");
|
||||
}
|
||||
|
||||
// Safely ensure scope ENUM covers unit_comprehensive if ever passed
|
||||
try {
|
||||
Database::query("ALTER TABLE exams MODIFY COLUMN scope ENUM('in_video_checkpoint', 'lesson_exam', 'unit_exam', 'unit_comprehensive', 'semester_final') NOT NULL DEFAULT 'in_video_checkpoint'");
|
||||
} catch (\Throwable $ign) {}
|
||||
} catch (\Throwable $e) {
|
||||
error_log("ExamController ensureSchema notice: " . $e->getMessage());
|
||||
}
|
||||
// Kept for compatibility with callers. Schema is installed by migrations.
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,12 +13,7 @@ class TeacherController
|
||||
{
|
||||
private static function ensureTeacherColumns(): void
|
||||
{
|
||||
try {
|
||||
Database::query("ALTER TABLE teachers ADD COLUMN grades_taught TEXT DEFAULT NULL");
|
||||
} catch (\Throwable $e) {}
|
||||
try {
|
||||
Database::query("ALTER TABLE teachers ADD COLUMN school_name VARCHAR(255) DEFAULT NULL");
|
||||
} catch (\Throwable $e) {}
|
||||
// Compatibility hook. These columns are installed by migrations.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,6 +103,7 @@ class TeacherController
|
||||
|
||||
$teacher = Database::selectOne("SELECT * FROM teachers WHERE id = ? LIMIT 1", [$teacherId]);
|
||||
|
||||
$metrics = Database::selectOne('SELECT weighted_student_rating, total_reviews_count FROM teacher_performance_metrics WHERE teacher_id = ? LIMIT 1', [$userId]) ?: [];
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'message' => 'تم توثيق بيانات المعلم والصفوف والمباحث بنجاح!',
|
||||
@@ -143,14 +139,20 @@ class TeacherController
|
||||
[$userId]
|
||||
)['total'];
|
||||
|
||||
$metrics = Database::selectOne(
|
||||
'SELECT weighted_student_rating, total_reviews_count FROM teacher_performance_metrics WHERE teacher_id = ? LIMIT 1',
|
||||
[$userId]
|
||||
) ?: [];
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'courses_count' => $coursesCount,
|
||||
'lessons_count' => $lessonsCount,
|
||||
'students_count' => $studentsCount,
|
||||
'rating' => 4.9,
|
||||
'completion_avg' => 87.5
|
||||
'rating' => isset($metrics['weighted_student_rating']) ? (float)$metrics['weighted_student_rating'] : null,
|
||||
'rating_count' => (int)($metrics['total_reviews_count'] ?? 0),
|
||||
'completion_avg' => null
|
||||
]
|
||||
]);
|
||||
}
|
||||
@@ -413,6 +415,43 @@ class TeacherController
|
||||
return;
|
||||
}
|
||||
|
||||
// Revenue sharing is being rebuilt around provider-backed ledger entries
|
||||
// and eligible watch minutes. Do not turn course prices, platform sales,
|
||||
// or legacy payout rows into a teacher wallet in the meantime.
|
||||
$courses = Database::select(
|
||||
"SELECT c.id, c.title as course_title, c.price_jod, c.is_published,
|
||||
COUNT(l.id) as lessons_total
|
||||
FROM courses c
|
||||
LEFT JOIN lessons l ON l.course_id = c.id
|
||||
WHERE c.teacher_id = ?
|
||||
GROUP BY c.id
|
||||
ORDER BY c.id ASC",
|
||||
[$teacherId]
|
||||
);
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'financial_status' => 'unavailable',
|
||||
'financial_message' => 'الأرباح والسحب متوقفان حتى اكتمال دفتر الاستحقاق وربط مزود الدفع.',
|
||||
'audience_breakdown' => [
|
||||
'institutional_students' => ['count' => 0],
|
||||
'marketplace_students' => ['count' => 0],
|
||||
],
|
||||
'course_price_jod' => null,
|
||||
'gross_revenue_jod' => null,
|
||||
'revenue_split' => null,
|
||||
'wallet' => [
|
||||
'available_balance_jod' => null,
|
||||
'pending_clearance_jod' => null,
|
||||
'total_withdrawn_jod' => null,
|
||||
'cliq_payout_alias' => '',
|
||||
'recent_payouts' => [],
|
||||
],
|
||||
'courses' => $courses,
|
||||
],
|
||||
]);
|
||||
return;
|
||||
|
||||
// 1. Real Student Counts from Database
|
||||
$totalStudents = (int)(Database::selectOne("SELECT COUNT(*) as c FROM students")['c'] ?? 0);
|
||||
|
||||
|
||||
@@ -24,9 +24,169 @@ use App\Core\Security;
|
||||
use App\Services\VideoService;
|
||||
use App\Services\AiVideoAnalyzerService;
|
||||
use App\Services\CurriculumService;
|
||||
use App\Services\TeacherSubmissionService;
|
||||
use App\Services\VideoReviewService;
|
||||
|
||||
class VideoController
|
||||
{
|
||||
/** POST /api/video-versions/{versionId}/watch-sessions */
|
||||
public function startWatchSession(Request $request, Response $response): void
|
||||
{
|
||||
try {
|
||||
$result = \App\Services\WatchSessionService::start((int)$request->user_id, trim((string)$request->getParam('versionId','')), $request->getHeader('x-national-id'));
|
||||
$response->status((int)$result['http_status'])->json(array_diff_key($result,['http_status'=>true]));
|
||||
} catch (\Throwable $e) { error_log('Watch session start failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر بدء جلسة المشاهدة.']); }
|
||||
}
|
||||
|
||||
/** POST /api/watch-sessions/{sessionId}/events */
|
||||
public function recordWatchEvent(Request $request, Response $response): void
|
||||
{
|
||||
$body=$request->getBody();
|
||||
try {
|
||||
$result = \App\Services\WatchSessionService::event((int)$request->user_id,trim((string)$request->getParam('sessionId','')),(int)($body['sequence_no']??0),trim((string)($body['event_type']??'')),(int)($body['position_ms']??-1),is_array($body['payload']??null)?$body['payload']:[]);
|
||||
$response->status((int)$result['http_status'])->json(array_diff_key($result,['http_status'=>true]));
|
||||
} catch (\Throwable $e) { error_log('Watch event failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر حفظ حدث المشاهدة.']); }
|
||||
}
|
||||
/** GET /api/video-versions/{versionId}/playback */
|
||||
public function getVideoVersionPlayback(Request $request, Response $response): void
|
||||
{
|
||||
$versionUuid = trim((string)$request->getParam('versionId', ''));
|
||||
if (!preg_match('/^[0-9a-f-]{36}$/i', $versionUuid)) {
|
||||
$response->status(400)->json(['status'=>'error','message'=>'هوية نسخة الفيديو غير صالحة.']); return;
|
||||
}
|
||||
try {
|
||||
$row = Database::selectOne(
|
||||
"SELECT vv.uuid AS video_version_id, l.id AS lesson_id, l.course_id, l.storage_type, l.video_uuid, l.hls_url, l.ai_video_url, l.bunny_video_id, l.duration_seconds,
|
||||
cl.uuid AS curriculum_lesson_id, cl.title, ts.uuid AS submission_id
|
||||
FROM video_versions vv
|
||||
JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id AND ts.current_published_video_version_id=vv.id AND ts.status='published'
|
||||
JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id
|
||||
JOIN lessons l ON l.id=vv.source_lesson_id AND l.encoding_status='ready'
|
||||
WHERE vv.uuid=? AND vv.status='published' LIMIT 1", [$versionUuid]
|
||||
);
|
||||
if (!$row) { $response->status(404)->json(['status'=>'error','message'=>'نسخة الفيديو المطلوبة غير منشورة أو سُحبت.']); return; }
|
||||
$course=Database::selectOne('SELECT grade_level FROM courses WHERE id=? LIMIT 1',[$row['course_id']]);
|
||||
$access=\App\Services\StudentAccessControlService::validateLessonAccess((int)$request->user_id, $request->getHeader('x-national-id'), \App\Services\StudentAccessControlService::normalizeGrade($course['grade_level'] ?? 'grade_10'), (int)$row['course_id'], (int)$row['lesson_id']);
|
||||
if (empty($access['allowed'])) { $response->status(403)->json(['status'=>'forbidden','message'=>$access['message'] ?? 'غير مصرح بمشاهدة هذه الحصة.']); return; }
|
||||
if ($row['storage_type']==='api_upload') $playback=['storage_type'=>'api_upload','video_url'=>$row['hls_url'] ?: '/api/videos/stream/'.$row['video_uuid'],'hls_url'=>$row['hls_url'] ?: '/api/videos/hls/'.$row['video_uuid'].'/index.m3u8'];
|
||||
elseif (!empty($row['bunny_video_id'])) $playback=array_merge(['storage_type'=>'bunny_stream'],VideoService::generateBunnySignedPlayback($row['bunny_video_id'],10800));
|
||||
elseif (!empty($row['ai_video_url'])) $playback=['storage_type'=>'direct_url','video_url'=>$row['ai_video_url'],'hls_url'=>$row['ai_video_url']];
|
||||
else { $response->status(409)->json(['status'=>'error','message'=>'تخزين نسخة الفيديو غير جاهز.']); return; }
|
||||
$response->json(['status'=>'success','data'=>['video_version_id'=>$row['video_version_id'],'submission_id'=>$row['submission_id'],'curriculum_lesson_id'=>$row['curriculum_lesson_id'],'lesson_id'=>(int)$row['lesson_id'],'title'=>$row['title'],'duration_seconds'=>(int)$row['duration_seconds'],'playback'=>$playback,'checkpoints'=>[]]]);
|
||||
} catch (\Throwable $e) { error_log('Version playback failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر تجهيز تشغيل نسخة الفيديو.']); }
|
||||
}
|
||||
|
||||
/** GET /api/curriculum/lessons/{lessonId}/videos?cursor=&limit= */
|
||||
public function listPublishedLessonVideos(Request $request, Response $response): void
|
||||
{
|
||||
$lessonUuid = trim((string)$request->getParam('lessonId', ''));
|
||||
$cursor = max(0, (int)$request->getQuery('cursor', 0));
|
||||
$limit = min(50, max(1, (int)$request->getQuery('limit', 20)));
|
||||
if (!preg_match('/^[0-9a-f-]{36}$/i', $lessonUuid)) {
|
||||
$response->status(400)->json(['status' => 'error', 'message' => 'هوية الدرس غير صالحة.']); return;
|
||||
}
|
||||
try {
|
||||
$lesson = Database::selectOne("SELECT id, uuid, title FROM curriculum_lessons WHERE uuid = ? AND source_status = 'approved' LIMIT 1", [$lessonUuid]);
|
||||
if (!$lesson) { $response->status(404)->json(['status'=>'error','message'=>'الدرس غير منشور.']); return; }
|
||||
$rows = Database::select(
|
||||
"SELECT vv.id, vv.uuid AS video_version_id, vv.source_lesson_id, ts.uuid AS submission_id,
|
||||
c.grade_level, l.course_id, t.full_name AS teacher_name, COALESCE(pm.weighted_student_rating, 0) AS rating,
|
||||
COALESCE(pm.total_reviews_count, 0) AS rating_count
|
||||
FROM teacher_submissions ts
|
||||
JOIN video_versions vv ON vv.id = ts.current_published_video_version_id AND vv.status = 'published'
|
||||
JOIN lessons l ON l.id = vv.source_lesson_id AND l.encoding_status = 'ready'
|
||||
JOIN courses c ON c.id = l.course_id
|
||||
JOIN teachers t ON t.id = ts.teacher_id
|
||||
LEFT JOIN teacher_performance_metrics pm ON pm.teacher_id = t.id
|
||||
WHERE ts.curriculum_lesson_id = ? AND ts.status = 'published'
|
||||
ORDER BY (rating_count > 0) DESC, rating DESC, vv.id ASC LIMIT 101",
|
||||
[$lesson['id']]
|
||||
);
|
||||
// A list is also protected content: do not disclose a teacher, count,
|
||||
// or version that the student cannot play. A curriculum lesson may
|
||||
// legitimately have versions attached to different courses.
|
||||
$accessible = [];
|
||||
foreach ($rows as $row) {
|
||||
$access = \App\Services\StudentAccessControlService::validateLessonAccess(
|
||||
(int)$request->user_id,
|
||||
$request->getHeader('x-national-id'),
|
||||
\App\Services\StudentAccessControlService::normalizeGrade($row['grade_level'] ?? 'grade_10'),
|
||||
(int)$row['course_id'],
|
||||
(int)$row['source_lesson_id'],
|
||||
false
|
||||
);
|
||||
if (!empty($access['allowed'])) $accessible[] = $row;
|
||||
}
|
||||
// `cursor` is an offset in the stable rating order, rather than a
|
||||
// database id (an id seek would skip records after rating changes).
|
||||
$page = array_slice($accessible, $cursor, $limit);
|
||||
$hasMore = count($accessible) > ($cursor + count($page));
|
||||
$items = array_map(static fn($r) => ['video_version_id'=>$r['video_version_id'],'submission_id'=>$r['submission_id'],'teacher_name'=>$r['teacher_name'],'rating'=>(float)$r['rating'],'rating_count'=>(int)$r['rating_count'],'is_new'=>(int)$r['rating_count']===0], $page);
|
||||
$response->json(['status'=>'success','data'=>['curriculum_lesson_id'=>$lesson['uuid'],'title'=>$lesson['title'],'available_count'=>count($accessible),'items'=>$items,'next_cursor'=>$hasMore ? $cursor + count($page) : null]]);
|
||||
} catch (\Throwable $e) { error_log('Lesson videos list failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر تحميل حصص هذا الدرس.']); }
|
||||
}
|
||||
/** POST /api/teacher/submissions/preflight */
|
||||
public function preflightSubmission(Request $request, Response $response): void
|
||||
{
|
||||
$body = $request->getBody();
|
||||
$lessonId = trim((string)($body['curriculum_lesson_id'] ?? ''));
|
||||
$replacement = !empty($body['replacement']);
|
||||
$submissionId = isset($body['submission_id']) ? trim((string)$body['submission_id']) : null;
|
||||
$key = trim((string)$request->getHeader('idempotency-key', ''));
|
||||
try {
|
||||
$result = TeacherSubmissionService::preflight((int)$request->user_id, $lessonId, $key, $replacement, $submissionId);
|
||||
$response->status((int)$result['http_status'])->json(array_diff_key($result, ['http_status' => true]));
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Teacher submission preflight failed: ' . $e->getMessage());
|
||||
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر إنشاء نسخة الحصة حالياً.']);
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/admin/video-review/publish; super-admin only route. */
|
||||
public function publishReviewedVersion(Request $request, Response $response): void
|
||||
{
|
||||
$body = $request->getBody();
|
||||
$jobId = trim((string)($body['review_job_id'] ?? ''));
|
||||
$expected = isset($body['expected_current_version_id']) && $body['expected_current_version_id'] !== ''
|
||||
? trim((string)$body['expected_current_version_id']) : null;
|
||||
try {
|
||||
$result = VideoReviewService::publishApproved($jobId, $expected);
|
||||
$response->status((int)$result['http_status'])->json(array_diff_key($result, ['http_status' => true]));
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Video publication failed: ' . $e->getMessage());
|
||||
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر نشر نسخة الفيديو حالياً.']);
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/admin/video-review/decision; super-admin only route. */
|
||||
public function recordVideoReviewDecision(Request $request, Response $response): void
|
||||
{
|
||||
$body=$request->getBody();
|
||||
try {
|
||||
$result=VideoReviewService::recordHumanReview(trim((string)($body['review_job_id']??'')),(int)$request->user_id,trim((string)($body['recommendation']??'')),trim((string)($body['decision']??'')),is_array($body['report']??null)?$body['report']:[]);
|
||||
$response->status((int)$result['http_status'])->json(array_diff_key($result,['http_status'=>true]));
|
||||
} catch(\Throwable $e){error_log('Video review decision failed: '.$e->getMessage());$response->status(503)->json(['status'=>'unavailable','message'=>'تعذر حفظ قرار المراجعة.']);}
|
||||
}
|
||||
|
||||
/** POST /api/admin/video-review/evidence; super-admin only route. */
|
||||
public function submitVideoReviewEvidence(Request $request, Response $response): void
|
||||
{
|
||||
$body = $request->getBody();
|
||||
try {
|
||||
$result = VideoReviewService::submitEvidence(
|
||||
trim((string)($body['review_job_id'] ?? '')),
|
||||
(int)$request->user_id,
|
||||
trim((string)($body['transcript_asset_id'] ?? '')),
|
||||
trim((string)($body['visual_evidence_asset_id'] ?? '')),
|
||||
is_array($body['coverage'] ?? null) ? $body['coverage'] : []
|
||||
);
|
||||
$response->status((int)$result['http_status'])->json(array_diff_key($result, ['http_status' => true]));
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Video review evidence failed: ' . $e->getMessage());
|
||||
$response->status(503)->json(['status'=>'unavailable','message'=>'تعذر حفظ أدلة فحص الفيديو.']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* رفع فيديو الشرح مباشرة عبر واجهة البرمجة وتوليد HLS وبدء التحليل السقراطي بالذكاء الاصطناعي
|
||||
* POST /api/teacher/videos/upload-direct
|
||||
@@ -54,6 +214,7 @@ class VideoController
|
||||
$title = trim((string)($request->getBody()['title'] ?? $_POST['title'] ?? ''));
|
||||
$seqOrder = (int)($request->getBody()['sequence_order'] ?? $_POST['sequence_order'] ?? 1);
|
||||
$curriculumKey = trim((string)($request->getBody()['curriculum_key'] ?? $_POST['curriculum_key'] ?? ''));
|
||||
$videoVersionId = trim((string)($request->getBody()['video_version_id'] ?? $_POST['video_version_id'] ?? ''));
|
||||
|
||||
if (empty($title)) {
|
||||
$response->status(400)->json([
|
||||
@@ -62,6 +223,26 @@ class VideoController
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if ($videoVersionId === '') {
|
||||
$response->status(409)->json([
|
||||
'status' => 'submission_preflight_required',
|
||||
'message' => 'أنشئ نسخة حصة عبر preflight قبل رفع الفيديو.',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!TeacherSubmissionService::reserveUpload((int)$request->user_id, $videoVersionId)) {
|
||||
$response->status(409)->json([
|
||||
'status' => 'video_version_unavailable',
|
||||
'message' => 'نسخة الفيديو استُخدمت أو لم تعد متاحة للرفع. أنشئ نسخة مرشحة جديدة.',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Video version reservation failed: ' . $e->getMessage());
|
||||
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر حجز نسخة الفيديو للرفع.']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve a teacher-owned course when the app did not explicitly select one.
|
||||
$course = null;
|
||||
@@ -177,6 +358,12 @@ class VideoController
|
||||
if ($auditId) {
|
||||
Database::query('UPDATE video_upload_audits SET lesson_id = ? WHERE id = ?', [$lessonId, $auditId]);
|
||||
}
|
||||
TeacherSubmissionService::attachUploadedLesson(
|
||||
(int)$request->user_id,
|
||||
$videoVersionId,
|
||||
$lessonId,
|
||||
hash_file('sha256', (string)$_FILES['video']['tmp_name'])
|
||||
);
|
||||
|
||||
// Immediately send HTTP 201 response to Flutter client and close connection
|
||||
$responsePayload = [
|
||||
@@ -210,10 +397,8 @@ class VideoController
|
||||
$uploadResult['mime_type']
|
||||
);
|
||||
|
||||
// 2. Autonomous Zero-Touch AI Analysis & Socratic Checkpoint Generation
|
||||
AiVideoAnalyzerService::processLessonAutonomously($lessonId);
|
||||
|
||||
// 3. Mark encoding as ready
|
||||
// 2. Storage readiness is not publication. The evidence-bound
|
||||
// review job controls any later release of this version.
|
||||
Database::query("UPDATE lessons SET encoding_status = 'ready' WHERE id = ?", [$lessonId]);
|
||||
} catch (\Throwable $bgError) {
|
||||
error_log("Background sync/analysis error for lesson {$lessonId}: " . $bgError->getMessage());
|
||||
@@ -672,17 +857,8 @@ class VideoController
|
||||
}
|
||||
}
|
||||
|
||||
// If lesson has no checkpoints or missing questions, generate them autonomously
|
||||
$existingCount = Database::selectOne("SELECT COUNT(*) as cnt FROM exams WHERE lesson_id = ? AND scope = 'in_video_checkpoint'", [$lessonId]);
|
||||
$existingQuestions = Database::selectOne("SELECT COUNT(*) as cnt FROM questions q JOIN exams e ON q.exam_id = e.id WHERE e.lesson_id = ?", [$lessonId]);
|
||||
if (empty($existingCount['cnt']) || empty($existingQuestions['cnt'])) {
|
||||
try {
|
||||
AiVideoAnalyzerService::processLessonAutonomously($lessonId);
|
||||
$lesson = Database::selectOne("SELECT * FROM lessons WHERE id = ? LIMIT 1", [$lessonId]);
|
||||
} catch (\Throwable $e) {
|
||||
error_log("Autonomous video analysis notice: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
// Playback is read-only. Checkpoints are published only by the review
|
||||
// workflow after it verifies the video transcript and lesson Markdown.
|
||||
|
||||
// Fetch attached in-video Socratic Checkpoints with Questions and Options
|
||||
$exams = Database::select(
|
||||
@@ -696,17 +872,21 @@ class VideoController
|
||||
$checkpoints = [];
|
||||
foreach ($exams as $ex) {
|
||||
$q = Database::selectOne("SELECT id, question_text, explanation_text FROM questions WHERE exam_id = ? LIMIT 1", [$ex['exam_id']]);
|
||||
if (!$q) {
|
||||
continue;
|
||||
}
|
||||
$opts = [];
|
||||
if ($q) {
|
||||
$opts = Database::select("SELECT id, option_text, is_correct, feedback_text FROM question_options WHERE question_id = ?", [$q['id']]);
|
||||
$opts = Database::select("SELECT id, option_text, is_correct, feedback_text FROM question_options WHERE question_id = ?", [$q['id']]);
|
||||
if (count($opts) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$checkpoints[] = [
|
||||
'exam_id' => (int)$ex['exam_id'],
|
||||
'question_id' => $q ? (int)$q['id'] : 0,
|
||||
'question_id' => (int)$q['id'],
|
||||
'timestamp_seconds' => (int)$ex['timestamp_seconds'],
|
||||
'rewind_on_fail_seconds' => (int)$ex['rewind_on_fail_seconds'],
|
||||
'question_text' => $q['question_text'] ?? 'سؤال فحص فهم الفكرة:',
|
||||
'question_text' => $q['question_text'],
|
||||
'explanation' => $q['explanation_text'] ?? '',
|
||||
'options' => array_map(function ($o) {
|
||||
return [
|
||||
|
||||
@@ -201,6 +201,16 @@ class AiVideoAnalyzerService
|
||||
// Perform Gemini AI or Curriculum-grounded Analysis
|
||||
$analysisResult = self::generateAnalysis($lessonTitle, $duration, $curriculum);
|
||||
|
||||
if (($analysisResult['status'] ?? '') === 'needs_evidence_review') {
|
||||
return [
|
||||
'status' => 'needs_evidence_review',
|
||||
'lesson_id' => $lessonId,
|
||||
'message' => 'لا يمكن نشر فصول أو أسئلة للفيديو من دون تفريغ زمني ومراجعة تربوية مرتبطة بالدرس.',
|
||||
'timeline_chapters' => [],
|
||||
'checkpoints_count' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// 1. Save Timeline Chapters to lessons table
|
||||
if (!empty($analysisResult['timeline_chapters'])) {
|
||||
Database::query(
|
||||
@@ -407,6 +417,15 @@ class AiVideoAnalyzerService
|
||||
*/
|
||||
private static function generateAnalysis(string $lessonTitle, int $duration, array $curriculum): array
|
||||
{
|
||||
// This legacy signature receives neither a timestamped transcript nor
|
||||
// the approved Markdown version. It must never create publishable
|
||||
// video claims, even when an AI key happens to be configured.
|
||||
return [
|
||||
'timeline_chapters' => [],
|
||||
'socratic_checkpoints' => [],
|
||||
'status' => 'needs_evidence_review',
|
||||
];
|
||||
|
||||
$geminiKey = self::nextGeminiApiKey();
|
||||
|
||||
if (!empty($geminiKey)) {
|
||||
@@ -482,8 +501,14 @@ class AiVideoAnalyzerService
|
||||
}
|
||||
}
|
||||
|
||||
// High-Precision Curriculum Grounded Engine (Offline / Safe Fallback)
|
||||
return self::buildGroundedCurriculumAnalysis($lessonTitle, $duration, $curriculum);
|
||||
// A title and curriculum outline do not prove what appears at a given
|
||||
// moment in the video. Do not publish chapters or questions without
|
||||
// transcript evidence and a successful review workflow.
|
||||
return [
|
||||
'timeline_chapters' => [],
|
||||
'socratic_checkpoints' => [],
|
||||
'status' => 'needs_evidence_review',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,14 +27,14 @@ class CliqPaymentService
|
||||
public const DEFAULT_BANK_NAME = 'بنك الاتحاد / البنك العربي (الأردن)';
|
||||
|
||||
/**
|
||||
* إنشاء الجداول المخصصة لمدفوعات كليك وطابور السحب تلقائياً إن لم تكن موجودة
|
||||
* @deprecated Schema is installed through migrations only. This no-op
|
||||
* prevents legacy callers from running DDL during an HTTP request.
|
||||
*/
|
||||
public static function ensureSchema(): void
|
||||
{
|
||||
static $ran = false;
|
||||
if ($ran) return;
|
||||
$ran = true;
|
||||
|
||||
/* Runtime DDL removed; apply 20260909_payment_safety_hold.sql instead. */
|
||||
return;
|
||||
/*
|
||||
try {
|
||||
// جدول مدفوعات كليك الواردة من الطلاب
|
||||
Database::query(
|
||||
@@ -79,6 +79,16 @@ class CliqPaymentService
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[CliqPaymentService] ensureSchema warning: ' . $e->getMessage());
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
private static function unavailable(string $operation): array
|
||||
{
|
||||
return [
|
||||
'status' => 'unavailable',
|
||||
'operation' => $operation,
|
||||
'message' => 'هذه العملية المالية غير متاحة حتى يكتمل ربط مزود الدفع والتسوية الموثقة. لم يتم إنشاء حركة مالية أو تفعيل وصول أو سحب رصيد.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,6 +96,8 @@ class CliqPaymentService
|
||||
*/
|
||||
public static function initiatePayment(int $studentId, int $courseId): array
|
||||
{
|
||||
return self::unavailable('initiate_payment');
|
||||
|
||||
self::ensureSchema();
|
||||
|
||||
$course = Database::selectOne("SELECT id, title, price_jod, teacher_id FROM courses WHERE id = ? LIMIT 1", [$courseId]);
|
||||
@@ -130,6 +142,8 @@ class CliqPaymentService
|
||||
string $receiptBase64,
|
||||
?string $manualTxId = null
|
||||
): array {
|
||||
return self::unavailable('submit_receipt');
|
||||
|
||||
self::ensureSchema();
|
||||
|
||||
$payment = Database::selectOne(
|
||||
@@ -225,6 +239,8 @@ class CliqPaymentService
|
||||
*/
|
||||
public static function requestTeacherPayout(int $teacherId, float $amountJod, string $teacherCliqAlias): array
|
||||
{
|
||||
return self::unavailable('request_payout');
|
||||
|
||||
self::ensureSchema();
|
||||
|
||||
if ($amountJod < 20.00) {
|
||||
@@ -259,6 +275,8 @@ class CliqPaymentService
|
||||
*/
|
||||
public static function processPayoutQueue(): array
|
||||
{
|
||||
return self::unavailable('process_payout_queue');
|
||||
|
||||
self::ensureSchema();
|
||||
|
||||
$queuedItems = Database::select(
|
||||
@@ -298,6 +316,8 @@ class CliqPaymentService
|
||||
*/
|
||||
public static function getTeacherPayouts(int $teacherId): array
|
||||
{
|
||||
return [];
|
||||
|
||||
self::ensureSchema();
|
||||
|
||||
$payouts = Database::select(
|
||||
|
||||
@@ -28,56 +28,11 @@ class CurriculumService
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime migration: add is_system_curriculum (and related columns) to `courses`
|
||||
* if they are missing. Safe to call multiple times — uses IF NOT EXISTS / IGNORE.
|
||||
* Compatibility hook. Course schema is managed by migrations, never by requests.
|
||||
*/
|
||||
public static function ensureSystemCurriculumColumn(): void
|
||||
{
|
||||
static $ran = false;
|
||||
if ($ran) return;
|
||||
$ran = true;
|
||||
|
||||
try {
|
||||
// Check whether the column exists to avoid noisy ALTER errors on every request
|
||||
$row = Database::selectOne(
|
||||
"SELECT COUNT(*) as cnt FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'courses'
|
||||
AND COLUMN_NAME = 'is_system_curriculum'
|
||||
LIMIT 1"
|
||||
);
|
||||
|
||||
if (empty($row['cnt'])) {
|
||||
Database::query(
|
||||
"ALTER TABLE `courses`
|
||||
ADD COLUMN `is_system_curriculum` TINYINT(1) NOT NULL DEFAULT 0
|
||||
COMMENT 'الدورة الرئيسية للمنهاج الوزاري'
|
||||
AFTER `is_school_exclusive`"
|
||||
);
|
||||
}
|
||||
|
||||
// Also ensure grade_level + stream columns exist (for student filtering)
|
||||
$gradeRow = Database::selectOne(
|
||||
"SELECT COUNT(*) as cnt FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'courses'
|
||||
AND COLUMN_NAME = 'grade_level'
|
||||
LIMIT 1"
|
||||
);
|
||||
if (empty($gradeRow['cnt'])) {
|
||||
Database::query(
|
||||
"ALTER TABLE `courses`
|
||||
ADD COLUMN `grade_level` VARCHAR(50) DEFAULT NULL
|
||||
COMMENT 'المرحلة الدراسية مثل: tawjihi_2008'
|
||||
AFTER `is_system_curriculum`,
|
||||
ADD COLUMN `stream` ENUM('scientific','literary','vocational','general') DEFAULT NULL
|
||||
COMMENT 'الفرع الدراسي'
|
||||
AFTER `grade_level`"
|
||||
);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[CurriculumService] ensureSystemCurriculumColumn error: ' . $e->getMessage());
|
||||
}
|
||||
// Intentionally empty. Run backend/scripts/run_migrations.php before serving.
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Services;
|
||||
use App\Core\Database;
|
||||
|
||||
/** Reads only the reviewed package attached to the exact published curriculum lesson. */
|
||||
final class LearningPackageService {
|
||||
public static function publishedEnglish(string $lessonUuid,int $studentId,?string $nationalId):array {
|
||||
if(!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',$lessonUuid))return ['http_status'=>400,'status'=>'error','message'=>'هوية الدرس غير صالحة.'];
|
||||
$lesson=Database::selectOne("SELECT cl.id,cl.uuid,cl.grade_key,cl.title FROM curriculum_lessons cl WHERE cl.uuid=? AND cl.source_status='approved' LIMIT 1",[$lessonUuid]);
|
||||
if(!$lesson)return ['http_status'=>404,'status'=>'error','message'=>'الدرس غير منشور.'];
|
||||
$access=StudentAccessControlService::validateLessonAccess($studentId,$nationalId,StudentAccessControlService::normalizeGrade((string)$lesson['grade_key']),null,null,false);
|
||||
if(empty($access['allowed']))return ['http_status'=>403,'status'=>'forbidden','message'=>$access['message']??'غير مصرح بالوصول إلى الدرس.'];
|
||||
$package=Database::selectOne("SELECT lp.id,lp.structure_json,pb.bundle_version FROM lesson_learning_packages lp JOIN publication_bundles pb ON pb.id=lp.bundle_id AND pb.status='published' WHERE lp.curriculum_lesson_id=? AND lp.package_type='english' AND lp.status='published' LIMIT 1",[$lesson['id']]);
|
||||
if(!$package)return ['http_status'=>404,'status'=>'not_ready','message'=>'حزمة الإنجليزية المعتمدة لهذا الدرس لم تُنشر بعد.'];
|
||||
$entries=Database::select("SELECT e.lemma,e.part_of_speech,e.meaning_ar,e.ipa_verified,a.uuid AS audio_asset_id FROM english_lexical_entries e LEFT JOIN content_assets a ON a.id=e.audio_asset_id AND a.review_status='approved' WHERE e.lesson_learning_package_id=? AND e.review_status='approved' ORDER BY e.id",[$package['id']]);
|
||||
return ['http_status'=>200,'status'=>'success','data'=>['curriculum_lesson_id'=>$lesson['uuid'],'title'=>$lesson['title'],'bundle_version'=>$package['bundle_version'],'structure'=>json_decode((string)$package['structure_json'],true)??[],'lexical_entries'=>$entries]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Core\Database;
|
||||
|
||||
/** Resolves only approved assets belonging to a currently published bundle. */
|
||||
final class PublishedContentService
|
||||
{
|
||||
private const STORAGE_ROOT = __DIR__ . '/../../storage/curriculum';
|
||||
|
||||
public static function findPublishedAsset(string $assetUuid): ?array
|
||||
{
|
||||
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $assetUuid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Database::selectOne(
|
||||
"SELECT a.uuid, a.asset_type, a.storage_driver, a.storage_key, a.mime_type,
|
||||
a.byte_size, a.sha256, cl.uuid AS curriculum_lesson_uuid,
|
||||
cl.grade_key, cl.subject_key, cl.semester_key, cl.unit_key,
|
||||
cl.lesson_key, pb.bundle_version
|
||||
FROM content_assets a
|
||||
JOIN publication_bundle_assets pba ON pba.content_asset_id = a.id
|
||||
JOIN publication_bundles pb ON pb.id = pba.publication_bundle_id
|
||||
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
|
||||
WHERE a.uuid = ?
|
||||
AND a.review_status = 'approved'
|
||||
AND pb.status = 'published'
|
||||
AND cl.source_status = 'approved'
|
||||
ORDER BY pb.published_at DESC, pb.id DESC
|
||||
LIMIT 1",
|
||||
[$assetUuid]
|
||||
);
|
||||
}
|
||||
|
||||
public static function readLocalAsset(array $asset): ?string
|
||||
{
|
||||
if (($asset['storage_driver'] ?? '') !== 'local') {
|
||||
return null;
|
||||
}
|
||||
$root = realpath(self::STORAGE_ROOT);
|
||||
$path = $root ? realpath($root . '/' . ltrim((string)$asset['storage_key'], '/')) : false;
|
||||
$prefix = $root ? rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR : '';
|
||||
if (!$path || !$prefix || !str_starts_with($path, $prefix) || !is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,8 @@ class StudentAccessControlService
|
||||
?string $nationalId,
|
||||
string $targetGrade,
|
||||
?int $courseId = null,
|
||||
?int $lessonId = null
|
||||
?int $lessonId = null,
|
||||
bool $allowSideEffects = true
|
||||
): array {
|
||||
// 1. استرجاع بيانات الطالب وسجله الدراسي
|
||||
$student = null;
|
||||
@@ -90,7 +91,7 @@ class StudentAccessControlService
|
||||
|
||||
// Auto-heal obsolete database schema default:
|
||||
// If student was recorded with the old default 'tawjihi_2008' or empty and target is grade_10
|
||||
if (($student['grade_level'] === 'tawjihi_2008' || empty($student['grade_level'])) && $normalizedTargetGrade === 'grade_10') {
|
||||
if ($allowSideEffects && ($student['grade_level'] === 'tawjihi_2008' || empty($student['grade_level'])) && $normalizedTargetGrade === 'grade_10') {
|
||||
Database::query("UPDATE students SET grade_level = 'grade_10', updated_at = NOW() WHERE id = ?", [(int)$student['id']]);
|
||||
$student['grade_level'] = 'grade_10';
|
||||
$activeStudentGrade = 'grade_10';
|
||||
@@ -101,7 +102,7 @@ class StudentAccessControlService
|
||||
// أما الطلبة المستقلون وحسابات التجربة والتعلم الحر، فيتم تحديث صفهم النشط تلقائياً وفق المحتوى المختار
|
||||
$isCohortLocked = !empty($student['is_school_sponsored']) || !empty($student['school_id']);
|
||||
if ($activeStudentGrade !== $normalizedTargetGrade) {
|
||||
if (!$isCohortLocked && !empty($student['id'])) {
|
||||
if (!$isCohortLocked && !empty($student['id']) && $allowSideEffects) {
|
||||
Database::query("UPDATE students SET grade_level = ? WHERE id = ?", [$normalizedTargetGrade, (int)$student['id']]);
|
||||
$activeStudentGrade = $normalizedTargetGrade;
|
||||
} else {
|
||||
@@ -126,7 +127,7 @@ class StudentAccessControlService
|
||||
|
||||
if ($isMilitaryCulture || $isPrivateSponsored) {
|
||||
// الطالب مشمول مجاناً 100% (صفر دينار)
|
||||
if ($courseId && !empty($student['id'])) {
|
||||
if ($allowSideEffects && $courseId && !empty($student['id'])) {
|
||||
self::ensureSchoolIncludedPass((int)$student['id'], $courseId);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Services;
|
||||
use App\Core\Database;
|
||||
|
||||
/** Integer-fils ledger. Credits are never inferred from UI, courses, or watch time. */
|
||||
final class TeacherLedgerService {
|
||||
public static function balance(int $teacherId): array {
|
||||
$account=self::account($teacherId);
|
||||
$row=Database::selectOne('SELECT COALESCE(SUM(CASE WHEN credit_account_id=? THEN amount_fils WHEN debit_account_id=? THEN -amount_fils ELSE 0 END),0) AS balance FROM ledger_entries WHERE credit_account_id=? OR debit_account_id=?',[$account,$account,$account,$account]);
|
||||
$holds=Database::selectOne("SELECT COALESCE(SUM(amount_fils),0) AS held FROM teacher_withdrawal_holds WHERE teacher_id=? AND status='held'",[$teacherId]);
|
||||
$gross=(int)($row['balance']??0);$held=(int)($holds['held']??0);
|
||||
return ['account_id'=>$account,'gross_fils'=>$gross,'held_fils'=>$held,'available_fils'=>max(0,$gross-$held)];
|
||||
}
|
||||
public static function placeWithdrawalHold(int $teacherId,int $amountFils,string $key):array {
|
||||
if($amountFils<=0||$amountFils>100000000||!preg_match('/^[A-Za-z0-9._:-]{16,128}$/',$key))return ['http_status'=>400,'status'=>'error','message'=>'قيمة الحجز أو مفتاح الإعادة غير صالح.'];
|
||||
$pdo=Database::getConnection();$pdo->beginTransaction();try {
|
||||
$existing=Database::selectOne('SELECT uuid,amount_fils,status FROM teacher_withdrawal_holds WHERE teacher_id=? AND idempotency_key=? FOR UPDATE',[$teacherId,$key]);
|
||||
if($existing){if((int)$existing['amount_fils']!==$amountFils)return self::finish($pdo,['http_status'=>409,'status'=>'idempotency_conflict','message'=>'استعمل المفتاح ذاته بمبلغ مختلف.']);return self::finish($pdo,['http_status'=>200,'status'=>$existing['status'],'withdrawal_hold_id'=>$existing['uuid'],'replayed'=>true]);}
|
||||
$balance=self::balance($teacherId);
|
||||
if($amountFils>$balance['available_fils'])return self::finish($pdo,['http_status'=>409,'status'=>'insufficient_available_balance','message'=>'الرصيد المتاح لا يغطي الحجز.','available_fils'=>$balance['available_fils']]);
|
||||
$uuid=self::uuid();Database::insert("INSERT INTO teacher_withdrawal_holds (uuid,teacher_id,amount_fils,status,idempotency_key) VALUES (?,?,?,'held',?)",[$uuid,$teacherId,$amountFils,$key]);
|
||||
return self::finish($pdo,['http_status'=>201,'status'=>'held','withdrawal_hold_id'=>$uuid,'amount_fils'=>$amountFils]);
|
||||
}catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
|
||||
}
|
||||
public static function releaseHold(int $teacherId,string $holdUuid,string $reason):array {
|
||||
if(!self::isUuid($holdUuid)||trim($reason)==='')return ['http_status'=>400,'status'=>'error','message'=>'بيانات إلغاء الحجز غير صالحة.'];
|
||||
$changed=Database::execute("UPDATE teacher_withdrawal_holds SET status='released',released_at=NOW() WHERE uuid=? AND teacher_id=? AND status='held'",[$holdUuid,$teacherId]);
|
||||
return $changed===1?['http_status'=>200,'status'=>'released','reason'=>$reason]:['http_status'=>409,'status'=>'hold_not_releasable','message'=>'الحجز غير موجود أو تمت تسويته.'];
|
||||
}
|
||||
private static function account(int $teacherId):int {
|
||||
$existing=Database::selectOne("SELECT id FROM ledger_accounts WHERE code='teacher_available' AND owner_type='teacher' AND owner_id=? AND currency='JOD' LIMIT 1",[$teacherId]);
|
||||
if($existing)return(int)$existing['id'];
|
||||
Database::insert("INSERT INTO ledger_accounts (code,owner_type,owner_id,currency) VALUES ('teacher_available','teacher',?,'JOD')",[$teacherId]);
|
||||
return(int)(Database::selectOne("SELECT id FROM ledger_accounts WHERE code='teacher_available' AND owner_type='teacher' AND owner_id=? AND currency='JOD' LIMIT 1",[$teacherId])['id']??0);
|
||||
}
|
||||
private static function isUuid(string $v):bool{return(bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',$v);}
|
||||
private static function uuid():string{$b=random_bytes(16);$b[6]=chr((ord($b[6])&15)|64);$b[8]=chr((ord($b[8])&63)|128);return vsprintf('%s%s-%s-%s-%s-%s%s%s',str_split(bin2hex($b),4));}
|
||||
private static function finish(\PDO $pdo,array $result):array{$pdo->commit();return $result;}
|
||||
}
|
||||
@@ -15,8 +15,11 @@ class TeacherRatingService
|
||||
|
||||
public static function ensureSchema(): void
|
||||
{
|
||||
// Schema is installed by migrations. Runtime DDL previously included a
|
||||
// DROP TABLE path and must never run during a student request.
|
||||
return;
|
||||
/*
|
||||
if (self::$schemaChecked) return;
|
||||
|
||||
try {
|
||||
// 1. Ensure teacher_reviews table exists with all columns
|
||||
Database::query("
|
||||
@@ -75,6 +78,7 @@ class TeacherRatingService
|
||||
} catch (\Throwable $e) {
|
||||
error_log("TeacherRatingService schema note: " . $e->getMessage());
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,8 +214,8 @@ class TeacherRatingService
|
||||
);
|
||||
|
||||
$totalReviews = count($reviews);
|
||||
$weightedRating = 5.00;
|
||||
$rawRating = 5.00;
|
||||
$weightedRating = 0.00;
|
||||
$rawRating = 0.00;
|
||||
|
||||
if ($totalReviews > 0) {
|
||||
$sumWeighted = 0;
|
||||
@@ -226,7 +230,7 @@ class TeacherRatingService
|
||||
$sumRaw += $val;
|
||||
}
|
||||
|
||||
$weightedRating = $sumWeights > 0 ? round($sumWeighted / $sumWeights, 2) : 5.00;
|
||||
$weightedRating = $sumWeights > 0 ? round($sumWeighted / $sumWeights, 2) : 0.00;
|
||||
$rawRating = round($sumRaw / $totalReviews, 2);
|
||||
}
|
||||
|
||||
@@ -237,7 +241,9 @@ class TeacherRatingService
|
||||
$masteryScore = self::calculateMasteryImpact($teacherId);
|
||||
|
||||
// 4. AI Socratic Engagement Index
|
||||
$aiEngagementScore = 96.00;
|
||||
// There is no verified AI engagement measurement yet. Keep the metric
|
||||
// neutral until the reviewed video/watch pipeline supplies one.
|
||||
$aiEngagementScore = 0.00;
|
||||
|
||||
// 5. Composite Merit Calculation (The Fair Formula: 25% SLA + 25% AI + 25% Mastery + 25% Student Review)
|
||||
$reviewComponent = $weightedRating * 20.0;
|
||||
@@ -266,7 +272,11 @@ class TeacherRatingService
|
||||
}
|
||||
|
||||
// Count enrolled students
|
||||
$studentsCount = (int)(Database::selectOne("SELECT COUNT(*) as cnt FROM students")['cnt'] ?? 0);
|
||||
$studentsCount = (int)(Database::selectOne(
|
||||
"SELECT COUNT(DISTINCT lp.student_id) AS cnt FROM lesson_progress lp
|
||||
JOIN lessons l ON l.id=lp.lesson_id JOIN courses c ON c.id=l.course_id
|
||||
WHERE c.teacher_id=?", [$teacherId]
|
||||
)['cnt'] ?? 0);
|
||||
|
||||
// Upsert into teacher_performance_metrics
|
||||
Database::query(
|
||||
@@ -295,7 +305,7 @@ class TeacherRatingService
|
||||
$slaData['avg_response_minutes'],
|
||||
$slaData['response_rate_pct'],
|
||||
$slaData['active_queue_count'],
|
||||
max(1, $studentsCount),
|
||||
$studentsCount,
|
||||
$totalReviews,
|
||||
$rawRating,
|
||||
$weightedRating,
|
||||
@@ -364,20 +374,19 @@ class TeacherRatingService
|
||||
$totalReplies = (int)(Database::selectOne("SELECT COUNT(DISTINCT receiver_id) as cnt FROM chat_messages WHERE sender_id = ?", [$teacherId])['cnt'] ?? 0);
|
||||
|
||||
// Calculate Base Response Rate
|
||||
$responseRate = $totalInquiries > 0 ? min(100.0, round(($totalReplies / $totalInquiries) * 100.0, 1)) : 99.0;
|
||||
if ($responseRate < 80.0) $responseRate = 95.0;
|
||||
$responseRate = $totalInquiries > 0 ? min(100.0, round(($totalReplies / $totalInquiries) * 100.0, 1)) : 0.0;
|
||||
|
||||
$baseResponseMinutes = 3;
|
||||
$baseResponseMinutes = 0;
|
||||
|
||||
if ($isActivelySolving && $queueDepth > 0) {
|
||||
$effectiveSlaScore = 99.0;
|
||||
$effectiveSlaScore = 0.0;
|
||||
$effectiveMinutes = $baseResponseMinutes + min(2, (int)($queueDepth * 0.5));
|
||||
} elseif ($queueDepth > 0) {
|
||||
$effectiveMinutes = $baseResponseMinutes + min(5, $queueDepth);
|
||||
$effectiveSlaScore = max(90.0, 98.0 - ($queueDepth * 0.8));
|
||||
$effectiveSlaScore = 0.0;
|
||||
} else {
|
||||
$effectiveMinutes = $baseResponseMinutes;
|
||||
$effectiveSlaScore = 99.5;
|
||||
$effectiveSlaScore = 0.0;
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -393,11 +402,11 @@ class TeacherRatingService
|
||||
}
|
||||
|
||||
return [
|
||||
'avg_response_minutes' => 3,
|
||||
'response_rate_pct' => 99.0,
|
||||
'avg_response_minutes' => 0,
|
||||
'response_rate_pct' => 0.0,
|
||||
'active_queue_count' => 0,
|
||||
'is_active_solving' => true,
|
||||
'sla_score' => 98.0
|
||||
'is_active_solving' => false,
|
||||
'sla_score' => 0.0
|
||||
];
|
||||
}
|
||||
|
||||
@@ -436,7 +445,7 @@ class TeacherRatingService
|
||||
FROM teachers t
|
||||
LEFT JOIN teacher_performance_metrics m ON t.id = m.teacher_id
|
||||
WHERE t.is_marketplace_public = 1
|
||||
ORDER BY " . ($sortBy === 'fastest' ? "COALESCE(m.avg_response_minutes, 10) ASC" : "COALESCE(m.composite_merit_score, 90.00) DESC")
|
||||
ORDER BY " . ($sortBy === 'fastest' ? "CASE WHEN m.avg_response_minutes IS NULL OR m.avg_response_minutes = 0 THEN 1 ELSE 0 END, m.avg_response_minutes ASC" : "COALESCE(m.total_reviews_count, 0) DESC, COALESCE(m.weighted_student_rating, 0) DESC")
|
||||
);
|
||||
|
||||
foreach ($teachers as &$t) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Services;
|
||||
use App\Core\Database;
|
||||
|
||||
final class TeacherSubmissionService {
|
||||
public static function preflight(int $teacherId, string $lessonUuid, string $key, bool $replacement, ?string $submissionUuid = null): array {
|
||||
if (!self::uuidValid($lessonUuid) || !preg_match('/^[A-Za-z0-9._:-]{16,128}$/', $key)) return self::result(400, 'error', 'بيانات هوية الدرس أو مفتاح الإعادة غير صالحة.');
|
||||
$operation = $replacement ? 'replacement_preflight' : 'submission_preflight';
|
||||
$hash = hash('sha256', json_encode([$lessonUuid, $replacement, $submissionUuid]));
|
||||
$pdo = Database::getConnection(); $pdo->beginTransaction();
|
||||
try {
|
||||
$previous = Database::selectOne('SELECT request_sha256,response_json,http_status FROM submission_idempotency_keys WHERE teacher_id=? AND operation=? AND idempotency_key=? FOR UPDATE', [$teacherId,$operation,$key]);
|
||||
if ($previous) {
|
||||
if (!hash_equals((string)$previous['request_sha256'], $hash)) { $pdo->rollBack(); return self::result(409,'idempotency_conflict','استُخدم مفتاح الإعادة مع طلب مختلف.'); }
|
||||
$pdo->commit(); return array_merge(json_decode((string)$previous['response_json'], true) ?: [], ['http_status'=>(int)$previous['http_status'],'replayed'=>true]);
|
||||
}
|
||||
$lesson = Database::selectOne("SELECT id,uuid,title FROM curriculum_lessons WHERE uuid=? AND source_status='approved' LIMIT 1 FOR UPDATE", [$lessonUuid]);
|
||||
if (!$lesson) return self::commit($pdo,$teacherId,$operation,$key,$hash,self::result(404,'error','الدرس المنهجي غير معتمد أو غير موجود.'));
|
||||
$submission = Database::selectOne('SELECT * FROM teacher_submissions WHERE teacher_id=? AND curriculum_lesson_id=? FOR UPDATE',[$teacherId,$lesson['id']]);
|
||||
if ($submission && !$replacement) return self::commit($pdo,$teacherId,$operation,$key,$hash,['http_status'=>409,'status'=>'existing_submission','message'=>'لديك حصة مسجلة لهذا الدرس. استخدم الاستبدال لإنشاء نسخة مرشحة.','submission_id'=>$submission['uuid'],'current_published_video_version_id'=>$submission['current_published_video_version_id'] ?: null]);
|
||||
if (!$submission && $replacement) return self::commit($pdo,$teacherId,$operation,$key,$hash,self::result(409,'replacement_target_missing','لا توجد حصة حالية لاستبدالها.'));
|
||||
if ($submissionUuid && (!$submission || !hash_equals((string)$submission['uuid'],$submissionUuid))) return self::commit($pdo,$teacherId,$operation,$key,$hash,self::result(409,'replacement_target_mismatch','هدف الاستبدال لا يطابق حصة المعلم الحالية.'));
|
||||
if (!$submission) { Database::insert("INSERT INTO teacher_submissions (uuid,teacher_id,curriculum_lesson_id,status) VALUES (?,?,?,'draft')",[self::uuid(),$teacherId,$lesson['id']]); $submission=Database::selectOne('SELECT * FROM teacher_submissions WHERE teacher_id=? AND curriculum_lesson_id=? FOR UPDATE',[$teacherId,$lesson['id']]); }
|
||||
$next=(int)(Database::selectOne('SELECT COALESCE(MAX(version_number),0) n FROM video_versions WHERE teacher_submission_id=? FOR UPDATE',[$submission['id']])['n'] ?? 0)+1;
|
||||
Database::insert("INSERT INTO video_versions (uuid,teacher_submission_id,version_number,status,replaces_video_version_id) VALUES (?,?,?,'draft',?)",[self::uuid(),$submission['id'],$next,$submission['current_published_video_version_id'] ?: null]);
|
||||
$version=Database::selectOne('SELECT uuid FROM video_versions WHERE teacher_submission_id=? AND version_number=?',[$submission['id'],$next]);
|
||||
return self::commit($pdo,$teacherId,$operation,$key,$hash,['http_status'=>201,'status'=>'ready_for_upload','submission_id'=>$submission['uuid'],'video_version_id'=>$version['uuid'],'version_number'=>$next,'replacement'=>$replacement,'lesson'=>['id'=>$lesson['uuid'],'title'=>$lesson['title']]]);
|
||||
} catch (\Throwable $e) { if($pdo->inTransaction())$pdo->rollBack(); throw $e; }
|
||||
}
|
||||
public static function attachUploadedLesson(int $teacherId,string $versionUuid,int $lessonId,string $sha): void {
|
||||
if(!self::uuidValid($versionUuid)||!preg_match('/^[0-9a-f]{64}$/i',$sha)) throw new \RuntimeException('هوية النسخة أو بصمة الملف غير صالحة.');
|
||||
$pdo=Database::getConnection();$pdo->beginTransaction();try { $v=Database::selectOne("SELECT vv.id,vv.status,ts.id submission_id FROM video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id WHERE vv.uuid=? AND ts.teacher_id=? FOR UPDATE",[$versionUuid,$teacherId]); if(!$v||!in_array($v['status'],['draft','uploading'],true)) throw new \RuntimeException('نسخة الفيديو غير متاحة لهذا الرفع.'); Database::query("UPDATE video_versions SET source_lesson_id=?,content_sha256=?,status='review' WHERE id=?",[$lessonId,$sha,$v['id']]);Database::query("UPDATE teacher_submissions SET status='review' WHERE id=?",[$v['submission_id']]);$pdo->commit(); VideoReviewService::queueForVersion((int)$v['id']); } catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
|
||||
}
|
||||
public static function reserveUpload(int $teacherId, string $versionUuid): bool {
|
||||
if (!self::uuidValid($versionUuid)) return false;
|
||||
return Database::execute(
|
||||
"UPDATE video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id
|
||||
SET vv.status='uploading', ts.status='uploading'
|
||||
WHERE vv.uuid=? AND ts.teacher_id=? AND vv.status='draft'",
|
||||
[$versionUuid, $teacherId]
|
||||
) === 1;
|
||||
}
|
||||
private static function commit(\PDO $pdo,int $teacher,string $op,string $key,string $hash,array $r):array {Database::insert('INSERT INTO submission_idempotency_keys (teacher_id,operation,idempotency_key,request_sha256,response_json,http_status) VALUES (?,?,?,?,?,?)',[$teacher,$op,$key,$hash,json_encode($r,JSON_UNESCAPED_UNICODE),$r['http_status']]);$pdo->commit();return $r;}
|
||||
private static function result(int $status,string $code,string $message):array{return ['http_status'=>$status,'status'=>$code,'message'=>$message];}
|
||||
private static function uuidValid(string $v):bool{return(bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',$v);}
|
||||
private static function uuid():string{$b=random_bytes(16);$b[6]=chr((ord($b[6])&15)|64);$b[8]=chr((ord($b[8])&63)|128);return vsprintf('%s%s-%s-%s-%s-%s%s%s',str_split(bin2hex($b),4));}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Core\Database;
|
||||
|
||||
final class VideoReviewService
|
||||
{
|
||||
public static function recordHumanReview(string $jobUuid, int $reviewerId, string $recommendation, string $decision, array $report): array
|
||||
{
|
||||
if (!in_array($recommendation, ['reject','revise','manual_review','ready_for_human_review'], true) || !in_array($decision, ['approved','rejected'], true)) {
|
||||
return ['http_status'=>400,'status'=>'error','message'=>'قرار المراجعة غير صالح.'];
|
||||
}
|
||||
if (empty($report['bindings']) || empty($report['coverage'])) {
|
||||
return ['http_status'=>422,'status'=>'incomplete_report','message'=>'التقرير لا يحتوي bindings وتغطية الأدلة المطلوبة.'];
|
||||
}
|
||||
$pdo=Database::getConnection(); $pdo->beginTransaction();
|
||||
try {
|
||||
$job=Database::selectOne('SELECT j.id,j.status,e.id AS evidence_id FROM video_review_jobs j LEFT JOIN video_review_evidence e ON e.video_review_job_id=j.id WHERE j.uuid=? FOR UPDATE',[$jobUuid]);
|
||||
if(!$job) return self::finish($pdo,['http_status'=>404,'status'=>'error','message'=>'مهمة الفحص غير موجودة.']);
|
||||
if(($job['status'] ?? '') !== 'ready_for_human_review') return self::finish($pdo,['http_status'=>409,'status'=>'job_not_reviewable','message'=>'حالة المهمة لا تسمح بقرار جديد.']);
|
||||
if (empty($job['evidence_id'])) return self::finish($pdo,['http_status'=>422,'status'=>'missing_evidence','message'=>'لا يمكن اعتماد الفحص من دون أدلة فيديو محفوظة.']);
|
||||
$approved=$decision==='approved' && $recommendation==='ready_for_human_review';
|
||||
Database::query("INSERT INTO video_review_reports (video_review_job_id,recommendation,report_json,reviewer_id,human_decision,reviewed_at) VALUES (?,?,?,?,?,NOW()) ON DUPLICATE KEY UPDATE recommendation=VALUES(recommendation),report_json=VALUES(report_json),reviewer_id=VALUES(reviewer_id),human_decision=VALUES(human_decision),reviewed_at=NOW()",[$job['id'],$recommendation,json_encode($report,JSON_UNESCAPED_UNICODE),$reviewerId,$decision]);
|
||||
Database::query('UPDATE video_review_jobs SET status=?, completed_at=NOW() WHERE id=?',[$approved?'approved':'rejected',$job['id']]);
|
||||
return self::finish($pdo,['http_status'=>200,'status'=>$approved?'approved':'rejected']);
|
||||
} catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
|
||||
}
|
||||
/** Attach reviewed, version-bound evidence before human review. */
|
||||
public static function submitEvidence(string $jobUuid, int $reviewerId, string $transcriptAssetUuid, string $visualAssetUuid, array $coverage): array
|
||||
{
|
||||
if (!self::isUuid($jobUuid) || !self::isUuid($transcriptAssetUuid) || !self::isUuid($visualAssetUuid)) {
|
||||
return ['http_status'=>400,'status'=>'error','message'=>'هويات أدلة الفحص غير صالحة.'];
|
||||
}
|
||||
if (empty($coverage['segments']) || !is_array($coverage['segments'])) {
|
||||
return ['http_status'=>422,'status'=>'incomplete_evidence','message'=>'يلزم سجل تغطية زمني للفيديو.'];
|
||||
}
|
||||
$pdo=Database::getConnection(); $pdo->beginTransaction();
|
||||
try {
|
||||
$job=Database::selectOne('SELECT id,video_sha256,status FROM video_review_jobs WHERE uuid=? FOR UPDATE',[$jobUuid]);
|
||||
if (!$job) return self::finish($pdo,['http_status'=>404,'status'=>'error','message'=>'مهمة الفحص غير موجودة.']);
|
||||
if (!in_array($job['status'],['queued','collecting_evidence','needs_evidence'],true)) return self::finish($pdo,['http_status'=>409,'status'=>'job_not_collecting','message'=>'لا تقبل المهمة أدلة جديدة في حالتها الحالية.']);
|
||||
$transcript=Database::selectOne("SELECT id,sha256,asset_type,review_status,source_reference FROM content_assets WHERE uuid=? FOR UPDATE",[$transcriptAssetUuid]);
|
||||
$visual=Database::selectOne("SELECT id,sha256,asset_type,review_status,source_reference FROM content_assets WHERE uuid=? FOR UPDATE",[$visualAssetUuid]);
|
||||
if (!$transcript || !$visual || $transcript['asset_type'] !== 'transcript' || $transcript['review_status'] !== 'approved' || $visual['review_status'] !== 'approved') {
|
||||
return self::finish($pdo,['http_status'=>422,'status'=>'untrusted_evidence','message'=>'يلزم تفريغ زمني ودليل بصري معتمدان.']);
|
||||
}
|
||||
$segments=$coverage['segments']; $lastEnd=0;
|
||||
foreach ($segments as $segment) {
|
||||
if (!is_array($segment) || !isset($segment['start_seconds'],$segment['end_seconds'],$segment['transcript_ref'],$segment['visual_ref']) || !is_numeric($segment['start_seconds']) || !is_numeric($segment['end_seconds']) || (float)$segment['start_seconds'] < $lastEnd || (float)$segment['end_seconds'] <= (float)$segment['start_seconds']) {
|
||||
return self::finish($pdo,['http_status'=>422,'status'=>'invalid_coverage','message'=>'تغطية الفيديو يجب أن تكون متصلة ومربوطة بالتفريغ والدليل البصري.']);
|
||||
}
|
||||
$lastEnd=(float)$segment['end_seconds'];
|
||||
}
|
||||
Database::query('INSERT INTO video_review_evidence (video_review_job_id,transcript_asset_id,visual_evidence_asset_id,coverage_json,submitted_by) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE transcript_asset_id=VALUES(transcript_asset_id),visual_evidence_asset_id=VALUES(visual_evidence_asset_id),coverage_json=VALUES(coverage_json),submitted_by=VALUES(submitted_by),created_at=NOW()',[$job['id'],$transcript['id'],$visual['id'],json_encode($coverage,JSON_UNESCAPED_UNICODE),$reviewerId]);
|
||||
Database::query("UPDATE video_review_jobs SET status='ready_for_human_review' WHERE id=?",[$job['id']]);
|
||||
return self::finish($pdo,['http_status'=>200,'status'=>'ready_for_human_review']);
|
||||
} catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
|
||||
}
|
||||
|
||||
public static function publishApproved(string $jobUuid, ?string $expectedCurrentVersionUuid): array
|
||||
{
|
||||
$pdo = Database::getConnection();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$job = Database::selectOne(
|
||||
"SELECT j.id, j.status, j.video_version_id, vv.uuid AS version_uuid,
|
||||
vv.source_lesson_id, ts.id AS submission_id,
|
||||
currentv.uuid AS current_version_uuid
|
||||
FROM video_review_jobs j
|
||||
JOIN video_versions vv ON vv.id = j.video_version_id
|
||||
JOIN teacher_submissions ts ON ts.id = vv.teacher_submission_id
|
||||
LEFT JOIN video_versions currentv ON currentv.id = ts.current_published_video_version_id
|
||||
WHERE j.uuid = ? FOR UPDATE",
|
||||
[$jobUuid]
|
||||
);
|
||||
if (!$job) return self::finish($pdo, ['status' => 'error', 'http_status' => 404, 'message' => 'مهمة الفحص غير موجودة.']);
|
||||
$report = Database::selectOne('SELECT human_decision FROM video_review_reports WHERE video_review_job_id = ? FOR UPDATE', [$job['id']]);
|
||||
if (($job['status'] ?? '') !== 'approved' || ($report['human_decision'] ?? '') !== 'approved') {
|
||||
return self::finish($pdo, ['status' => 'review_not_approved', 'http_status' => 409, 'message' => 'لا يمكن النشر قبل تقرير مكتمل وموافقة مراجع بشري.']);
|
||||
}
|
||||
if ($expectedCurrentVersionUuid !== null && !hash_equals($expectedCurrentVersionUuid, (string)($job['current_version_uuid'] ?? ''))) {
|
||||
return self::finish($pdo, ['status' => 'current_version_conflict', 'http_status' => 409, 'message' => 'تغيرت النسخة المنشورة منذ فتح المراجعة.']);
|
||||
}
|
||||
if (empty($job['source_lesson_id'])) return self::finish($pdo, ['status' => 'storage_not_ready', 'http_status' => 409, 'message' => 'الفيديو غير مرتبط بتخزين جاهز.']);
|
||||
Database::query("UPDATE video_versions SET status = 'superseded' WHERE teacher_submission_id = ? AND status = 'published'", [$job['submission_id']]);
|
||||
Database::query("UPDATE video_versions SET status = 'published', published_at = NOW() WHERE id = ?", [$job['video_version_id']]);
|
||||
Database::query("UPDATE teacher_submissions SET status = 'published', current_published_video_version_id = ? WHERE id = ?", [$job['video_version_id'], $job['submission_id']]);
|
||||
$pdo->commit();
|
||||
return ['status' => 'published', 'http_status' => 200, 'video_version_id' => $job['version_uuid']];
|
||||
} catch (\Throwable $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public static function queueForVersion(int $versionId): array
|
||||
{
|
||||
$existing = Database::selectOne(
|
||||
"SELECT uuid, status FROM video_review_jobs
|
||||
WHERE video_version_id = ? AND status NOT IN ('failed', 'superseded')
|
||||
ORDER BY id DESC LIMIT 1",
|
||||
[$versionId]
|
||||
);
|
||||
if ($existing) return ['job_id' => $existing['uuid'], 'status' => $existing['status'], 'reused' => true];
|
||||
|
||||
$binding = Database::selectOne(
|
||||
"SELECT vv.content_sha256, ts.curriculum_lesson_id, cl.uuid AS lesson_uuid,
|
||||
cl.curriculum_version, a.uuid AS asset_uuid, a.sha256 AS markdown_sha256
|
||||
FROM video_versions vv
|
||||
JOIN teacher_submissions ts ON ts.id = vv.teacher_submission_id
|
||||
JOIN curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id
|
||||
LEFT JOIN publication_bundles pb ON pb.curriculum_lesson_id = cl.id AND pb.status = 'published'
|
||||
LEFT JOIN publication_bundle_assets pba ON pba.publication_bundle_id = pb.id AND pba.role = 'primary_lesson'
|
||||
LEFT JOIN content_assets a ON a.id = pba.content_asset_id AND a.review_status = 'approved'
|
||||
WHERE vv.id = ? LIMIT 1",
|
||||
[$versionId]
|
||||
);
|
||||
if (!$binding) throw new \RuntimeException('نسخة الفيديو غير مرتبطة بدرس منهجي.');
|
||||
|
||||
$missing = ['timestamped_transcript', 'visual_evidence', 'coverage_manifest'];
|
||||
if (empty($binding['asset_uuid'])) $missing[] = 'approved_markdown_asset';
|
||||
$jobUuid = self::uuid();
|
||||
$manifest = [
|
||||
'curriculum_lesson_id' => $binding['lesson_uuid'],
|
||||
'curriculum_version' => $binding['curriculum_version'],
|
||||
'markdown_asset_id' => $binding['asset_uuid'] ?: null,
|
||||
'missing_inputs' => $missing,
|
||||
];
|
||||
Database::insert(
|
||||
"INSERT INTO video_review_jobs
|
||||
(uuid, video_version_id, curriculum_lesson_id, prompt_version, schema_version, video_sha256, markdown_sha256, status, input_manifest_json)
|
||||
VALUES (?, ?, ?, 'video-review-v1', 'video-review-schema-v1', ?, ?, 'needs_evidence', ?)",
|
||||
[$jobUuid, $versionId, $binding['curriculum_lesson_id'], $binding['content_sha256'] ?: str_repeat('0', 64), $binding['markdown_sha256'] ?: str_repeat('0', 64), json_encode($manifest, JSON_UNESCAPED_UNICODE)]
|
||||
);
|
||||
return ['job_id' => $jobUuid, 'status' => 'needs_evidence', 'missing_inputs' => $missing];
|
||||
}
|
||||
|
||||
private static function isUuid(string $value): bool
|
||||
{
|
||||
return (bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $value);
|
||||
}
|
||||
|
||||
private static function uuid(): string
|
||||
{
|
||||
$bytes = random_bytes(16);
|
||||
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
|
||||
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
|
||||
}
|
||||
|
||||
private static function finish(\PDO $pdo, array $result): array
|
||||
{
|
||||
$pdo->commit();
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,13 @@ class VideoService
|
||||
*/
|
||||
public static function ensureSchema(): void
|
||||
{
|
||||
// Schema is installed by backend/scripts/run_migrations.php.
|
||||
// Legacy request-time DDL remains below only as historical reference.
|
||||
return;
|
||||
/*
|
||||
if (self::$schemaChecked) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if storage_type column exists
|
||||
$cols = Database::select("SHOW COLUMNS FROM lessons LIKE 'storage_type'");
|
||||
@@ -91,6 +94,7 @@ class VideoService
|
||||
} catch (\Throwable $e) {
|
||||
error_log("VideoService schema ensure note: " . $e->getMessage());
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Services;
|
||||
use App\Core\Database;
|
||||
|
||||
/** Server-owned watch evidence. Client positions are hints, never a balance. */
|
||||
final class WatchSessionService {
|
||||
public static function start(int $studentId, string $versionUuid, ?string $nationalId): array {
|
||||
$row=self::authorisedVersion($studentId,$versionUuid,$nationalId);
|
||||
if (!$row['allowed']) return $row;
|
||||
$pdo=Database::getConnection(); $pdo->beginTransaction();
|
||||
try {
|
||||
$active=Database::selectOne("SELECT uuid FROM watch_sessions WHERE student_id=? AND video_version_id=? AND status='active' FOR UPDATE",[$studentId,$row['version_id']]);
|
||||
if ($active) { $pdo->commit(); return ['http_status'=>200,'status'=>'active','watch_session_id'=>$active['uuid'],'reused'=>true]; }
|
||||
$uuid=self::uuid();
|
||||
Database::insert("INSERT INTO watch_sessions (uuid,student_id,video_version_id,funding_source,status) VALUES (?,?,?,?, 'active')",[$uuid,$studentId,$row['version_id'],$row['funding_source']]);
|
||||
$id=(int)(Database::selectOne('SELECT id FROM watch_sessions WHERE uuid=?',[$uuid])['id'] ?? 0);
|
||||
Database::insert("INSERT INTO watch_events (watch_session_id,sequence_no,event_type,client_position_ms,payload_json) VALUES (?,1,'start',0,?)",[$id,json_encode(['server_contract'=>'watch-v1'])]);
|
||||
$pdo->commit(); return ['http_status'=>201,'status'=>'started','watch_session_id'=>$uuid];
|
||||
} catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
|
||||
}
|
||||
public static function event(int $studentId,string $sessionUuid,int $sequence,string $type,int $positionMs,array $payload=[]):array {
|
||||
if(!self::isUuid($sessionUuid)||$sequence<2||!in_array($type,['heartbeat','pause','seek','resume','end','buffer'],true)||$positionMs<0) return ['http_status'=>400,'status'=>'error','message'=>'حدث المشاهدة غير صالح.'];
|
||||
$pdo=Database::getConnection();$pdo->beginTransaction();try {
|
||||
$session=Database::selectOne("SELECT ws.id,ws.status,ws.video_version_id,ws.funding_source FROM watch_sessions ws WHERE ws.uuid=? AND ws.student_id=? FOR UPDATE",[$sessionUuid,$studentId]);
|
||||
if(!$session)return self::finish($pdo,['http_status'=>404,'status'=>'error','message'=>'جلسة المشاهدة غير موجودة.']);
|
||||
if($session['status']!=='active')return self::finish($pdo,['http_status'=>409,'status'=>'ended','message'=>'انتهت جلسة المشاهدة.']);
|
||||
$last=Database::selectOne('SELECT sequence_no,event_type,client_position_ms,server_received_at FROM watch_events WHERE watch_session_id=? ORDER BY sequence_no DESC LIMIT 1 FOR UPDATE',[$session['id']]);
|
||||
if((int)$last['sequence_no'] >= $sequence) return self::finish($pdo,['http_status'=>200,'status'=>'replayed']);
|
||||
if((int)$last['sequence_no']+1 !== $sequence) return self::finish($pdo,['http_status'=>409,'status'=>'sequence_gap','message'=>'تسلسل أحداث المشاهدة غير متصل.']);
|
||||
Database::insert('INSERT INTO watch_events (watch_session_id,sequence_no,event_type,client_position_ms,payload_json) VALUES (?,?,?,?,?)',[$session['id'],$sequence,$type,$positionMs,json_encode($payload,JSON_UNESCAPED_UNICODE)]);
|
||||
$eligible=0;
|
||||
if($type==='heartbeat' && in_array($last['event_type'],['start','heartbeat','resume'],true)) {
|
||||
$clientDelta=$positionMs-(int)$last['client_position_ms'];
|
||||
$serverDelta=(int)(Database::selectOne('SELECT TIMESTAMPDIFF(SECOND, ?, NOW()) AS s',[$last['server_received_at']])['s'] ?? 0);
|
||||
// A contiguous interval is bounded by elapsed server time and 60s;
|
||||
// seeks, background bursts, and client-only minutes earn nothing.
|
||||
if($clientDelta>=1000 && $clientDelta<=90000 && $serverDelta>=1 && $serverDelta<=90) {
|
||||
$eligible=min((int)floor($clientDelta/1000),(int)$serverDelta,60);
|
||||
if($eligible>0) Database::insert("INSERT INTO eligible_watch_intervals (watch_session_id,start_ms,end_ms,eligible_seconds,reason_code,policy_version,status) VALUES (?,?,?,?,?,'watch-v1','measured')",[$session['id'],(int)$last['client_position_ms'],$positionMs,$eligible,'contiguous_heartbeat']);
|
||||
}
|
||||
}
|
||||
if($type==='end'){Database::query("UPDATE watch_sessions SET status='ended',server_ended_at=NOW() WHERE id=?",[$session['id']]);}
|
||||
return self::finish($pdo,['http_status'=>200,'status'=>$type==='end'?'ended':'recorded','eligible_seconds_added'=>$eligible]);
|
||||
}catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
|
||||
}
|
||||
private static function authorisedVersion(int $studentId,string $uuid,?string $national):array {
|
||||
if(!self::isUuid($uuid))return ['http_status'=>400,'status'=>'error','message'=>'هوية نسخة الفيديو غير صالحة.'];
|
||||
$row=Database::selectOne("SELECT vv.id,l.id lesson_id,l.course_id,c.grade_level FROM video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id AND ts.current_published_video_version_id=vv.id AND ts.status='published' JOIN lessons l ON l.id=vv.source_lesson_id AND l.encoding_status='ready' JOIN courses c ON c.id=l.course_id WHERE vv.uuid=? AND vv.status='published' LIMIT 1",[$uuid]);
|
||||
if(!$row)return ['http_status'=>404,'status'=>'error','message'=>'نسخة الفيديو غير منشورة.'];
|
||||
$access=StudentAccessControlService::validateLessonAccess($studentId,$national,StudentAccessControlService::normalizeGrade($row['grade_level']??'grade_10'),(int)$row['course_id'],(int)$row['lesson_id'],false);
|
||||
if(empty($access['allowed']))return ['http_status'=>403,'status'=>'forbidden','message'=>$access['message']??'غير مصرح بالمشاهدة.'];
|
||||
return ['allowed'=>true,'version_id'=>(int)$row['id'],'funding_source'=>!empty($access['is_sponsored'])?'school':(($access['reason']??'')==='paid_pass_active'?'marketplace':'none')];
|
||||
}
|
||||
private static function isUuid(string $v):bool{return(bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',$v);}
|
||||
private static function uuid():string{$b=random_bytes(16);$b[6]=chr((ord($b[6])&15)|64);$b[8]=chr((ord($b[8])&63)|128);return vsprintf('%s%s-%s-%s-%s-%s%s%s',str_split(bin2hex($b),4));}
|
||||
private static function finish(\PDO $pdo,array $r):array{$pdo->commit();return $r;}
|
||||
}
|
||||
@@ -114,15 +114,13 @@ class GuardianPortal
|
||||
<div class="hero-card">
|
||||
<div>
|
||||
<span style="font-size: 12px; font-weight: 800; color: var(--accent-cyan); display: block; margin-bottom: 6px;">متابعة التحصيل الأكاديمي والجاهزية الوزارية 📊</span>
|
||||
<h1 style="font-size: 24px; font-weight: 900; color: #FFF;" id="selected_child_name">الابن: أحمد (الفرع العلمي)</h1>
|
||||
<p style="font-size: 13px; color: var(--text-secondary); margin-top: 4px;">مدرسة الثقافة العسكرية المعتمدة • الرقم الوطني: 2008XXXX12</p>
|
||||
<h1 style="font-size: 24px; font-weight: 900; color: #FFF;" id="selected_child_name">لا توجد بيانات طالب مرتبطة بعد</h1>
|
||||
<p style="font-size: 13px; color: var(--text-secondary); margin-top: 4px;">اربط حساب الطالب المصرح به لعرض بياناته الفعلية.</p>
|
||||
</div>
|
||||
|
||||
<!-- Multi-Child Switching Pill -->
|
||||
<div class="child-selector-pill">
|
||||
<button class="child-btn active" onclick="switchChildProfile(0, 'أحمد (علمي)', '2008XXXX12', '94.2%')">أحمد (توجيهي)</button>
|
||||
<button class="child-btn" onclick="switchChildProfile(1, 'سارة (توجيهي 2008)', '2008XXXX34', '96.8%')">سارة</button>
|
||||
<button class="child-btn" onclick="switchChildProfile(2, 'عمر (الصف العاشر)', '2010XXXX56', '89.5%')">عمر</button>
|
||||
<span style="font-size:12px;color:var(--text-muted);">لا يوجد طلاب مرتبطون بالحساب</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -130,8 +128,8 @@ class GuardianPortal
|
||||
<div class="grid-3">
|
||||
<div class="card">
|
||||
<span style="font-size: 12px; color: var(--text-muted); display: block; margin-bottom: 6px;">مؤشر الجاهزية للوزاري (Socratic Readiness)</span>
|
||||
<div class="metric-val val-cyan" id="metric_readiness">94.2%</div>
|
||||
<span style="font-size: 12px; color: var(--accent-green); display: block; margin-top: 8px;">✓ مستوى متقدم جداً ومؤهل لعلامة كاملة</span>
|
||||
<div class="metric-val val-cyan" id="metric_readiness">غير متاح</div>
|
||||
<span style="font-size: 12px; color: var(--text-muted); display: block; margin-top: 8px;">تظهر القراءة بعد وصول بيانات الطالب الفعلية.</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
||||
@@ -727,8 +727,8 @@ class StudentPortal
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 14px 0;">
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); display: block;">مؤشر الجدارة الكلي</span>
|
||||
<span style="font-size: 15px; font-weight: 900; color: var(--accent-gold);"><?= number_format((float)($t['composite_merit_score'] ?? 96.5), 1) ?>%</span>
|
||||
<span style="font-size: 10px; color: var(--accent-gold);">★ <?= number_format((float)($t['star_equivalent'] ?? 4.9), 1) ?></span>
|
||||
<span style="font-size: 15px; font-weight: 900; color: var(--accent-gold);"><?= isset($t['composite_merit_score']) ? number_format((float)$t['composite_merit_score'], 1) . '%' : 'غير متاح' ?></span>
|
||||
<span style="font-size: 10px; color: var(--accent-gold);"><?= isset($t['star_equivalent']) ? '★ ' . number_format((float)$t['star_equivalent'], 1) : 'لا توجد تقييمات موثقة' ?></span>
|
||||
</div>
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); display: block;">سرعة الرد (Workerman)</span>
|
||||
@@ -2501,8 +2501,8 @@ class StudentPortal
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 14px 0;">
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); display: block;">مؤشر الجدارة الكلي</span>
|
||||
<span style="font-size: 15px; font-weight: 900; color: var(--accent-gold);">${parseFloat(t.composite_merit_score || 96.5).toFixed(1)}%</span>
|
||||
<span style="font-size: 10px; color: var(--accent-gold);">★ ${parseFloat(t.star_equivalent || 4.9).toFixed(1)}</span>
|
||||
<span style="font-size: 15px; font-weight: 900; color: var(--accent-gold);">${t.composite_merit_score == null ? 'غير متاح' : `${parseFloat(t.composite_merit_score).toFixed(1)}%`}</span>
|
||||
<span style="font-size: 10px; color: var(--accent-gold);">${t.star_equivalent == null ? 'لا توجد تقييمات موثقة' : `★ ${parseFloat(t.star_equivalent).toFixed(1)}`}</span>
|
||||
</div>
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); display: block;">سرعة الرد (Workerman)</span>
|
||||
|
||||
@@ -418,15 +418,15 @@ class TeacherPortal
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">الكورسات المنشورة</span>
|
||||
<span class="stat-val val-cyan" id="stat_courses_count"><?= max(1, $coursesCount) ?></span>
|
||||
<span class="stat-val val-cyan" id="stat_courses_count"><?= (int)$coursesCount ?></span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">الطلاب المتفاعلون</span>
|
||||
<span class="stat-val val-purple" id="stat_students_count"><?= max(1, $studentsCount) ?></span>
|
||||
<span class="stat-val val-purple" id="stat_students_count"><?= (int)$studentsCount ?></span>
|
||||
</div>
|
||||
<div class="stat-card gold-glow">
|
||||
<span class="stat-label">متوسط إتقان الطلاب</span>
|
||||
<span class="stat-val val-gold" id="stat_mastery_avg">94.6%</span>
|
||||
<span class="stat-val val-gold" id="stat_mastery_avg">غير متاح</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -494,25 +494,25 @@ class TeacherPortal
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-bottom: 24px;">
|
||||
<div style="background: rgba(11,19,43,0.8); border: 1px solid var(--border); border-radius: 16px; padding: 18px;">
|
||||
<span style="font-size: 11px; color: var(--text-muted); display: block; margin-bottom: 4px;">مؤشر الجدارة الكلي المركب</span>
|
||||
<span style="font-size: 24px; font-weight: 900; color: var(--accent-gold);" id="rep_merit_score">96.8%</span>
|
||||
<span style="font-size: 11px; color: var(--accent-cyan); display: block; margin-top: 4px;" id="rep_tier_badge">معلم نخبوي معتمد 💎</span>
|
||||
<span style="font-size: 24px; font-weight: 900; color: var(--accent-gold);" id="rep_merit_score">غير متاح</span>
|
||||
<span style="font-size: 11px; color: var(--accent-cyan); display: block; margin-top: 4px;" id="rep_tier_badge">قيد بناء سجل التقييم</span>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(11,19,43,0.8); border: 1px solid var(--border); border-radius: 16px; padding: 18px;">
|
||||
<span style="font-size: 11px; color: var(--text-muted); display: block; margin-bottom: 4px;">سرعة الاستجابة (Workerman SLA)</span>
|
||||
<span style="font-size: 24px; font-weight: 900; color: #34D399;" id="rep_response_speed">3 دقائق ⚡</span>
|
||||
<span style="font-size: 11px; color: var(--text-muted); display: block; margin-top: 4px;" id="rep_response_rate">نسبة التجاوب: 99.2%</span>
|
||||
<span style="font-size: 24px; font-weight: 900; color: #34D399;" id="rep_response_speed">غير متاح</span>
|
||||
<span style="font-size: 11px; color: var(--text-muted); display: block; margin-top: 4px;" id="rep_response_rate">نسبة التجاوب: غير متاحة</span>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(11,19,43,0.8); border: 1px solid var(--border); border-radius: 16px; padding: 18px;">
|
||||
<span style="font-size: 11px; color: var(--text-muted); display: block; margin-bottom: 4px;">تقييم الطلاب المعاير والموزون</span>
|
||||
<span style="font-size: 24px; font-weight: 900; color: #A78BFA;" id="rep_weighted_rating">★ 4.90 / 5.0</span>
|
||||
<span style="font-size: 24px; font-weight: 900; color: #A78BFA;" id="rep_weighted_rating">لا توجد تقييمات موثقة</span>
|
||||
<span style="font-size: 11px; color: var(--text-muted); display: block; margin-top: 4px;" id="rep_reviews_count">بناءً على التقييمات الموثقة</span>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(11,19,43,0.8); border: 1px solid var(--border); border-radius: 16px; padding: 18px;">
|
||||
<span style="font-size: 11px; color: var(--text-muted); display: block; margin-bottom: 4px;">معدل رفع إتقان الطلاب (Mastery)</span>
|
||||
<span style="font-size: 24px; font-weight: 900; color: var(--accent-cyan);" id="rep_mastery_gain">+94.6%</span>
|
||||
<span style="font-size: 24px; font-weight: 900; color: var(--accent-cyan);" id="rep_mastery_gain">غير متاح</span>
|
||||
<span style="font-size: 11px; color: var(--text-muted); display: block; margin-top: 4px;">في الكويزات السقراطية والامتحانات</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1517,13 +1517,13 @@ class TeacherPortal
|
||||
const data = await res.json();
|
||||
if (res.ok && data.status === 'success' && data.data) {
|
||||
const m = data.data;
|
||||
document.getElementById('rep_merit_score').textContent = `${parseFloat(m.composite_merit_score || 96.8).toFixed(1)}%`;
|
||||
document.getElementById('rep_tier_badge').textContent = m.reputation_tier || 'معلم نخبوي معتمد 💎';
|
||||
document.getElementById('rep_response_speed').textContent = `${parseInt(m.avg_response_minutes || 3)} دقائق ⚡`;
|
||||
document.getElementById('rep_response_rate').textContent = `نسبة التجاوب: ${parseFloat(m.response_rate_percentage || 99.2).toFixed(1)}%`;
|
||||
document.getElementById('rep_weighted_rating').textContent = `★ ${parseFloat(m.star_equivalent || 4.9).toFixed(2)} / 5.0`;
|
||||
document.getElementById('rep_merit_score').textContent = m.composite_merit_score == null ? 'غير متاح' : `${parseFloat(m.composite_merit_score).toFixed(1)}%`;
|
||||
document.getElementById('rep_tier_badge').textContent = m.reputation_tier || 'قيد بناء سجل التقييم';
|
||||
document.getElementById('rep_response_speed').textContent = m.avg_response_minutes == null ? 'غير متاح' : `${parseInt(m.avg_response_minutes)} دقائق ⚡`;
|
||||
document.getElementById('rep_response_rate').textContent = m.response_rate_percentage == null ? 'نسبة التجاوب: غير متاحة' : `نسبة التجاوب: ${parseFloat(m.response_rate_percentage).toFixed(1)}%`;
|
||||
document.getElementById('rep_weighted_rating').textContent = m.star_equivalent == null ? 'لا توجد تقييمات موثقة' : `★ ${parseFloat(m.star_equivalent).toFixed(2)} / 5.0`;
|
||||
document.getElementById('rep_reviews_count').textContent = `بناءً على ${m.total_reviews_count || 0} تقييم موثق`;
|
||||
document.getElementById('rep_mastery_gain').textContent = `+${parseFloat(m.mastery_impact_score || 94.6).toFixed(1)}%`;
|
||||
document.getElementById('rep_mastery_gain').textContent = m.mastery_impact_score == null ? 'غير متاح' : `+${parseFloat(m.mastery_impact_score).toFixed(1)}%`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Load rep data notice:', e);
|
||||
|
||||
@@ -99,31 +99,8 @@ if ($task === 'all' || $task === 'grades') {
|
||||
// 3. تسوية ومطابقة مدفوعات كليك المعلقة (Reconcile Pending CliQ Slips)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
if ($task === 'all' || $task === 'reconcile') {
|
||||
echo "\n[3/5] Reconciling Pending CliQ Slips (Timeouts > 24 Hours)...\n";
|
||||
try {
|
||||
CliqPaymentService::ensureSchema();
|
||||
$staleCount = (int)Database::selectOne(
|
||||
"SELECT COUNT(*) as cnt FROM cliq_payments
|
||||
WHERE verification_status = 'pending'
|
||||
AND created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)"
|
||||
)['cnt'];
|
||||
|
||||
if (!$isDryRun && $staleCount > 0) {
|
||||
Database::query(
|
||||
"UPDATE cliq_payments
|
||||
SET verification_status = 'rejected'
|
||||
WHERE verification_status = 'pending'
|
||||
AND created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)"
|
||||
);
|
||||
echo " -> Expired {$staleCount} abandoned CliQ payment requests.\n";
|
||||
} else {
|
||||
echo " -> Abandoned pending requests found: {$staleCount}.\n";
|
||||
}
|
||||
$results['reconcile'] = "Expired: {$staleCount}";
|
||||
} catch (\Throwable $e) {
|
||||
echo " -> ERROR: " . $e->getMessage() . "\n";
|
||||
$results['reconcile'] = 'Error: ' . $e->getMessage();
|
||||
}
|
||||
echo "\n[3/5] CliQ reconciliation is disabled until provider integration is verified.\n";
|
||||
$results['reconcile'] = 'Unavailable: provider-backed reconciliation is not configured';
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
+25
-17
@@ -647,16 +647,16 @@ CREATE TABLE IF NOT EXISTS `teacher_reviews` (
|
||||
`student_id` BIGINT UNSIGNED NOT NULL,
|
||||
`course_id` BIGINT UNSIGNED DEFAULT NULL,
|
||||
`lesson_id` BIGINT UNSIGNED DEFAULT NULL,
|
||||
`rating_overall` DECIMAL(3, 2) NOT NULL DEFAULT 5.00,
|
||||
`rating_clarity` INT NOT NULL DEFAULT 5,
|
||||
`rating_response_speed` INT NOT NULL DEFAULT 5,
|
||||
`rating_socratic_interaction` INT NOT NULL DEFAULT 5,
|
||||
`rating_overall` DECIMAL(3, 2) NOT NULL,
|
||||
`rating_clarity` INT NOT NULL,
|
||||
`rating_response_speed` INT NOT NULL,
|
||||
`rating_socratic_interaction` INT NOT NULL,
|
||||
`review_text` TEXT DEFAULT NULL,
|
||||
`review_weight` DECIMAL(4, 3) NOT NULL DEFAULT 1.000,
|
||||
`student_watch_percentage` DECIMAL(5, 2) NOT NULL DEFAULT 100.00,
|
||||
`socratic_accuracy_rate` DECIMAL(5, 2) NOT NULL DEFAULT 100.00,
|
||||
`student_watch_percentage` DECIMAL(5, 2) NOT NULL DEFAULT 0.00,
|
||||
`socratic_accuracy_rate` DECIMAL(5, 2) NOT NULL DEFAULT 0.00,
|
||||
`is_flagged_anomaly` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`is_verified` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`is_verified` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tr_teacher` (`teacher_id`),
|
||||
@@ -670,19 +670,19 @@ CREATE TABLE IF NOT EXISTS `teacher_reviews` (
|
||||
-- ------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `teacher_performance_metrics` (
|
||||
`teacher_id` BIGINT UNSIGNED NOT NULL,
|
||||
`avg_response_minutes` INT NOT NULL DEFAULT 4,
|
||||
`response_rate_percentage` DECIMAL(5, 2) NOT NULL DEFAULT 98.50,
|
||||
`avg_response_minutes` INT NULL DEFAULT NULL,
|
||||
`response_rate_percentage` DECIMAL(5, 2) NULL DEFAULT NULL,
|
||||
`active_queue_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`total_students_enrolled` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`total_reviews_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`raw_avg_rating` DECIMAL(3, 2) NOT NULL DEFAULT 5.00,
|
||||
`weighted_student_rating` DECIMAL(3, 2) NOT NULL DEFAULT 5.00,
|
||||
`ai_engagement_score` DECIMAL(5, 2) NOT NULL DEFAULT 96.00,
|
||||
`sla_speed_score` DECIMAL(5, 2) NOT NULL DEFAULT 98.00,
|
||||
`mastery_impact_score` DECIMAL(5, 2) NOT NULL DEFAULT 94.00,
|
||||
`composite_merit_score` DECIMAL(5, 2) NOT NULL DEFAULT 96.50,
|
||||
`star_equivalent` DECIMAL(3, 2) NOT NULL DEFAULT 4.90,
|
||||
`reputation_tier` VARCHAR(100) NOT NULL DEFAULT 'معلم نخبوي معتمد 💎',
|
||||
`raw_avg_rating` DECIMAL(3, 2) NULL DEFAULT NULL,
|
||||
`weighted_student_rating` DECIMAL(3, 2) NULL DEFAULT NULL,
|
||||
`ai_engagement_score` DECIMAL(5, 2) NULL DEFAULT NULL,
|
||||
`sla_speed_score` DECIMAL(5, 2) NULL DEFAULT NULL,
|
||||
`mastery_impact_score` DECIMAL(5, 2) NULL DEFAULT NULL,
|
||||
`composite_merit_score` DECIMAL(5, 2) NULL DEFAULT NULL,
|
||||
`star_equivalent` DECIMAL(3, 2) NULL DEFAULT NULL,
|
||||
`reputation_tier` VARCHAR(100) NULL DEFAULT NULL,
|
||||
`last_calculated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`teacher_id`),
|
||||
CONSTRAINT `fk_tpm_teacher` FOREIGN KEY (`teacher_id`) REFERENCES `teachers` (`id`) ON DELETE CASCADE
|
||||
@@ -747,3 +747,11 @@ CREATE TABLE IF NOT EXISTS `otp_verifications` (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
-- NEXT-PHASE SCHEMA NOTICE
|
||||
-- The authoritative additive DDL for curriculum identity, submissions, review,
|
||||
-- ledger, watch sessions, revenue, English packages, and remediation lives in
|
||||
-- backend/migrations/20260909_*.sql. Apply it with
|
||||
-- `php backend/scripts/run_migrations.php`; do not copy those statements here,
|
||||
-- because duplicated DDL causes the fresh-schema file and production migrations
|
||||
-- to drift apart.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
-- Curriculum publication identity. Apply after taking a schema backup.
|
||||
-- This migration is additive: it does not rewrite `lessons` or local assets.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `curriculum_lessons` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`uuid` CHAR(36) NOT NULL,
|
||||
`grade_key` VARCHAR(64) NOT NULL,
|
||||
`subject_key` VARCHAR(96) NOT NULL,
|
||||
`semester_key` VARCHAR(64) NOT NULL,
|
||||
`unit_key` VARCHAR(96) NOT NULL,
|
||||
`lesson_key` VARCHAR(128) NOT NULL,
|
||||
`curriculum_version` VARCHAR(64) NOT NULL,
|
||||
`title` VARCHAR(500) NOT NULL,
|
||||
`source_manifest_path` VARCHAR(500) NOT NULL,
|
||||
`source_status` ENUM('unverified','reviewed','approved','withdrawn') NOT NULL DEFAULT 'unverified',
|
||||
`reviewed_at` TIMESTAMP NULL DEFAULT NULL,
|
||||
`reviewed_by` BIGINT UNSIGNED NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_curriculum_lesson_uuid` (`uuid`),
|
||||
UNIQUE KEY `uq_curriculum_lesson_identity` (`grade_key`,`subject_key`,`semester_key`,`unit_key`,`lesson_key`,`curriculum_version`),
|
||||
KEY `idx_curriculum_lesson_manifest` (`source_manifest_path`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `publication_bundles` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`uuid` CHAR(36) NOT NULL,
|
||||
`curriculum_lesson_id` BIGINT UNSIGNED NOT NULL,
|
||||
`bundle_version` VARCHAR(64) NOT NULL,
|
||||
`status` ENUM('draft','review','published','withdrawn') NOT NULL DEFAULT 'draft',
|
||||
`published_at` TIMESTAMP NULL DEFAULT NULL,
|
||||
`withdrawn_at` TIMESTAMP NULL DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_bundle_uuid` (`uuid`),
|
||||
UNIQUE KEY `uq_bundle_lesson_version` (`curriculum_lesson_id`,`bundle_version`),
|
||||
KEY `idx_bundle_publication` (`curriculum_lesson_id`,`status`,`published_at`),
|
||||
CONSTRAINT `fk_bundle_curriculum_lesson` FOREIGN KEY (`curriculum_lesson_id`) REFERENCES `curriculum_lessons` (`id`) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `content_assets` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`uuid` CHAR(36) NOT NULL,
|
||||
`asset_type` ENUM('lesson_markdown','worksheet_markdown','textbook_pdf','lesson_mindmap_image','unit_mindmap_image','audio','transcript','other') NOT NULL,
|
||||
`storage_driver` ENUM('local','r2') NOT NULL DEFAULT 'local',
|
||||
`storage_key` VARCHAR(700) NOT NULL,
|
||||
`mime_type` VARCHAR(127) NOT NULL,
|
||||
`byte_size` BIGINT UNSIGNED NOT NULL,
|
||||
`sha256` CHAR(64) NOT NULL,
|
||||
`source_reference` VARCHAR(700) NULL,
|
||||
`rights_status` ENUM('unknown','review_required','cleared','restricted') NOT NULL DEFAULT 'unknown',
|
||||
`review_status` ENUM('draft','review','approved','withdrawn') NOT NULL DEFAULT 'draft',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_content_asset_uuid` (`uuid`),
|
||||
UNIQUE KEY `uq_content_asset_integrity` (`storage_driver`,`storage_key`,`sha256`),
|
||||
KEY `idx_content_asset_status` (`review_status`,`asset_type`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `publication_bundle_assets` (
|
||||
`publication_bundle_id` BIGINT UNSIGNED NOT NULL,
|
||||
`content_asset_id` BIGINT UNSIGNED NOT NULL,
|
||||
`role` ENUM('primary_lesson','worksheet','textbook','lesson_mindmap','unit_mindmap','audio','transcript','supporting') NOT NULL,
|
||||
`sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`publication_bundle_id`,`content_asset_id`,`role`),
|
||||
KEY `idx_bundle_assets_role` (`publication_bundle_id`,`role`,`sort_order`),
|
||||
CONSTRAINT `fk_bundle_asset_bundle` FOREIGN KEY (`publication_bundle_id`) REFERENCES `publication_bundles` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_bundle_asset_asset` FOREIGN KEY (`content_asset_id`) REFERENCES `content_assets` (`id`) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Rollback: do not drop published records. Mark a bundle withdrawn and preserve
|
||||
-- its assets, then restore the preceding published bundle if one exists.
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Financial ledger foundation. Amounts are integer fils (1 JOD = 1000 fils).
|
||||
CREATE TABLE IF NOT EXISTS `ledger_accounts` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `code` VARCHAR(80) NOT NULL,
|
||||
`owner_type` ENUM('platform','teacher','school') NOT NULL, `owner_id` BIGINT UNSIGNED NULL,
|
||||
`currency` CHAR(3) NOT NULL DEFAULT 'JOD', `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uq_ledger_account` (`code`,`owner_type`,`owner_id`,`currency`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS `ledger_entries` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `uuid` CHAR(36) NOT NULL,
|
||||
`debit_account_id` BIGINT UNSIGNED NOT NULL, `credit_account_id` BIGINT UNSIGNED NOT NULL,
|
||||
`amount_fils` BIGINT UNSIGNED NOT NULL, `currency` CHAR(3) NOT NULL DEFAULT 'JOD',
|
||||
`reference_type` VARCHAR(64) NOT NULL, `reference_id` VARCHAR(128) NOT NULL,
|
||||
`reason` VARCHAR(500) NOT NULL, `policy_version` VARCHAR(64) NOT NULL,
|
||||
`reversal_of_entry_id` BIGINT UNSIGNED NULL, `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uq_ledger_entry_uuid` (`uuid`),
|
||||
UNIQUE KEY `uq_ledger_reference` (`reference_type`,`reference_id`,`debit_account_id`,`credit_account_id`),
|
||||
KEY `idx_ledger_debit` (`debit_account_id`), KEY `idx_ledger_credit` (`credit_account_id`),
|
||||
CONSTRAINT `fk_ledger_debit` FOREIGN KEY (`debit_account_id`) REFERENCES `ledger_accounts` (`id`) ON DELETE RESTRICT,
|
||||
CONSTRAINT `fk_ledger_credit` FOREIGN KEY (`credit_account_id`) REFERENCES `ledger_accounts` (`id`) ON DELETE RESTRICT,
|
||||
CONSTRAINT `fk_ledger_reversal` FOREIGN KEY (`reversal_of_entry_id`) REFERENCES `ledger_entries` (`id`) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS `teacher_withdrawal_holds` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `uuid` CHAR(36) NOT NULL, `teacher_id` BIGINT UNSIGNED NOT NULL,
|
||||
`amount_fils` BIGINT UNSIGNED NOT NULL, `status` ENUM('held','released','settled','cancelled') NOT NULL DEFAULT 'held',
|
||||
`idempotency_key` VARCHAR(128) NOT NULL, `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `released_at` TIMESTAMP NULL,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uq_withdrawal_hold_uuid` (`uuid`), UNIQUE KEY `uq_withdrawal_idempotency` (`teacher_id`,`idempotency_key`),
|
||||
KEY `idx_withdrawal_teacher_status` (`teacher_id`,`status`), CONSTRAINT `fk_withdrawal_teacher` FOREIGN KEY (`teacher_id`) REFERENCES `teachers` (`id`) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,33 @@
|
||||
CREATE TABLE IF NOT EXISTS `lesson_learning_packages` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `curriculum_lesson_id` BIGINT UNSIGNED NOT NULL, `bundle_id` BIGINT UNSIGNED NOT NULL,
|
||||
`package_type` ENUM('general','english') NOT NULL DEFAULT 'general', `status` ENUM('draft','review','published','withdrawn') NOT NULL DEFAULT 'draft',
|
||||
`structure_json` JSON NOT NULL, `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uq_lesson_package` (`curriculum_lesson_id`,`bundle_id`,`package_type`),
|
||||
CONSTRAINT `fk_learning_package_lesson` FOREIGN KEY (`curriculum_lesson_id`) REFERENCES `curriculum_lessons` (`id`) ON DELETE RESTRICT,
|
||||
CONSTRAINT `fk_learning_package_bundle` FOREIGN KEY (`bundle_id`) REFERENCES `publication_bundles` (`id`) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS `english_lexical_entries` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `lesson_learning_package_id` BIGINT UNSIGNED NOT NULL,
|
||||
`lemma` VARCHAR(255) NOT NULL, `part_of_speech` VARCHAR(64) NULL, `meaning_ar` TEXT NOT NULL,
|
||||
`ipa_verified` VARCHAR(255) NULL, `audio_asset_id` BIGINT UNSIGNED NULL, `source_reference` VARCHAR(500) NOT NULL,
|
||||
`review_status` ENUM('draft','approved','withdrawn') NOT NULL DEFAULT 'draft',
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uq_english_lemma_package` (`lesson_learning_package_id`,`lemma`),
|
||||
CONSTRAINT `fk_lexical_package` FOREIGN KEY (`lesson_learning_package_id`) REFERENCES `lesson_learning_packages` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_lexical_audio` FOREIGN KEY (`audio_asset_id`) REFERENCES `content_assets` (`id`) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS `learning_attempts` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `uuid` CHAR(36) NOT NULL, `student_id` BIGINT UNSIGNED NOT NULL,
|
||||
`curriculum_lesson_id` BIGINT UNSIGNED NOT NULL, `activity_type` VARCHAR(64) NOT NULL, `activity_id` VARCHAR(128) NOT NULL,
|
||||
`result_status` ENUM('correct','incorrect','partial','ungraded') NOT NULL, `help_level` TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`submitted_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uq_learning_attempt_uuid` (`uuid`),
|
||||
KEY `idx_attempt_student_lesson` (`student_id`,`curriculum_lesson_id`,`submitted_at`),
|
||||
CONSTRAINT `fk_attempt_student` FOREIGN KEY (`student_id`) REFERENCES `students` (`id`) ON DELETE RESTRICT,
|
||||
CONSTRAINT `fk_attempt_lesson` FOREIGN KEY (`curriculum_lesson_id`) REFERENCES `curriculum_lessons` (`id`) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS `remediation_plans` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `student_id` BIGINT UNSIGNED NOT NULL, `curriculum_lesson_id` BIGINT UNSIGNED NOT NULL,
|
||||
`status` ENUM('active','completed','expired') NOT NULL DEFAULT 'active', `plan_json` JSON NOT NULL, `due_at` TIMESTAMP NULL, `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), KEY `idx_remediation_student` (`student_id`,`status`),
|
||||
CONSTRAINT `fk_remediation_student` FOREIGN KEY (`student_id`) REFERENCES `students` (`id`) ON DELETE RESTRICT,
|
||||
CONSTRAINT `fk_remediation_lesson` FOREIGN KEY (`curriculum_lesson_id`) REFERENCES `curriculum_lessons` (`id`) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Payment safety hold: apply this migration before deploying the P0 payment changes.
|
||||
-- No balance is created or reconciled by this migration. Existing records remain
|
||||
-- historical evidence and must be reconciled against a real provider later.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `cliq_payments` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`uuid` CHAR(36) NOT NULL UNIQUE,
|
||||
`student_id` BIGINT UNSIGNED NOT NULL,
|
||||
`course_id` BIGINT UNSIGNED NOT NULL,
|
||||
`amount_jod` DECIMAL(8,2) NOT NULL,
|
||||
`cliq_alias` VARCHAR(100) NOT NULL,
|
||||
`reference_code` VARCHAR(50) NOT NULL UNIQUE,
|
||||
`bank_transaction_id` VARCHAR(100) DEFAULT NULL UNIQUE,
|
||||
`receipt_image_path` VARCHAR(500) DEFAULT NULL,
|
||||
`verification_status` ENUM('pending','verified','rejected','manual_review') NOT NULL DEFAULT 'pending',
|
||||
`ai_extracted_json` JSON DEFAULT NULL,
|
||||
`verified_at` TIMESTAMP NULL DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_cliq_student` (`student_id`),
|
||||
KEY `idx_cliq_ref` (`reference_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `payout_queue` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`uuid` CHAR(36) NOT NULL UNIQUE,
|
||||
`teacher_id` BIGINT UNSIGNED NOT NULL,
|
||||
`amount_jod` DECIMAL(8,2) NOT NULL,
|
||||
`teacher_cliq_alias` VARCHAR(150) NOT NULL,
|
||||
`status` ENUM('queued','processing','completed','failed','cancelled') NOT NULL DEFAULT 'queued',
|
||||
`transaction_reference` VARCHAR(100) DEFAULT NULL,
|
||||
`failure_reason` TEXT DEFAULT NULL,
|
||||
`processed_at` TIMESTAMP NULL DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_payout_teacher` (`teacher_id`),
|
||||
KEY `idx_payout_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Rollback: do not drop either table. They may contain financial evidence.
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Must run before revenue periods because verified ratings reference watch_sessions.
|
||||
CREATE TABLE IF NOT EXISTS `watch_sessions` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `uuid` CHAR(36) NOT NULL, `student_id` BIGINT UNSIGNED NOT NULL, `video_version_id` BIGINT UNSIGNED NOT NULL,
|
||||
`funding_source` ENUM('school','marketplace','none') NOT NULL, `server_started_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `server_ended_at` TIMESTAMP NULL, `status` ENUM('active','ended','invalidated') NOT NULL DEFAULT 'active',
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uq_watch_session_uuid` (`uuid`), KEY `idx_watch_student_version` (`student_id`,`video_version_id`,`status`),
|
||||
CONSTRAINT `fk_watch_student` FOREIGN KEY (`student_id`) REFERENCES `students` (`id`) ON DELETE RESTRICT, CONSTRAINT `fk_watch_version` FOREIGN KEY (`video_version_id`) REFERENCES `video_versions` (`id`) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS `watch_events` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `watch_session_id` BIGINT UNSIGNED NOT NULL, `sequence_no` INT UNSIGNED NOT NULL, `event_type` ENUM('start','heartbeat','pause','seek','resume','end','buffer') NOT NULL, `client_position_ms` BIGINT UNSIGNED NOT NULL, `server_received_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `payload_json` JSON NULL,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uq_watch_event_sequence` (`watch_session_id`,`sequence_no`), CONSTRAINT `fk_watch_event_session` FOREIGN KEY (`watch_session_id`) REFERENCES `watch_sessions` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS `eligible_watch_intervals` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `watch_session_id` BIGINT UNSIGNED NOT NULL, `start_ms` BIGINT UNSIGNED NOT NULL, `end_ms` BIGINT UNSIGNED NOT NULL, `eligible_seconds` INT UNSIGNED NOT NULL, `reason_code` VARCHAR(80) NOT NULL, `policy_version` VARCHAR(64) NOT NULL, `status` ENUM('measured','excluded','review') NOT NULL DEFAULT 'measured',
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uq_eligible_interval` (`watch_session_id`,`start_ms`,`end_ms`,`policy_version`), CONSTRAINT `fk_eligible_session` FOREIGN KEY (`watch_session_id`) REFERENCES `watch_sessions` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE IF NOT EXISTS `revenue_periods` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `uuid` CHAR(36) NOT NULL,
|
||||
`starts_at` TIMESTAMP NOT NULL, `ends_at` TIMESTAMP NOT NULL,
|
||||
`policy_version` VARCHAR(64) NOT NULL, `status` ENUM('open','closing','closed','reopened') NOT NULL DEFAULT 'open',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uq_revenue_period_uuid` (`uuid`), UNIQUE KEY `uq_revenue_period_dates` (`starts_at`,`ends_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS `teacher_revenue_allocations` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `revenue_period_id` BIGINT UNSIGNED NOT NULL,
|
||||
`teacher_id` BIGINT UNSIGNED NOT NULL, `video_version_id` BIGINT UNSIGNED NOT NULL,
|
||||
`eligible_seconds` BIGINT UNSIGNED NOT NULL, `amount_fils` BIGINT UNSIGNED NOT NULL,
|
||||
`status` ENUM('estimated','held','approved','reversed') NOT NULL DEFAULT 'estimated',
|
||||
`ledger_entry_id` BIGINT UNSIGNED NULL, `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uq_teacher_allocation` (`revenue_period_id`,`teacher_id`,`video_version_id`),
|
||||
CONSTRAINT `fk_allocation_period` FOREIGN KEY (`revenue_period_id`) REFERENCES `revenue_periods` (`id`) ON DELETE RESTRICT,
|
||||
CONSTRAINT `fk_allocation_teacher` FOREIGN KEY (`teacher_id`) REFERENCES `teachers` (`id`) ON DELETE RESTRICT,
|
||||
CONSTRAINT `fk_allocation_version` FOREIGN KEY (`video_version_id`) REFERENCES `video_versions` (`id`) ON DELETE RESTRICT,
|
||||
CONSTRAINT `fk_allocation_ledger` FOREIGN KEY (`ledger_entry_id`) REFERENCES `ledger_entries` (`id`) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS `lesson_video_ratings` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `student_id` BIGINT UNSIGNED NOT NULL,
|
||||
`video_version_id` BIGINT UNSIGNED NOT NULL, `rating` TINYINT UNSIGNED NOT NULL,
|
||||
`watch_session_id` BIGINT UNSIGNED NOT NULL, `status` ENUM('pending','verified','flagged','rejected') NOT NULL DEFAULT 'pending',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uq_student_video_rating` (`student_id`,`video_version_id`),
|
||||
CONSTRAINT `fk_lvr_student` FOREIGN KEY (`student_id`) REFERENCES `students` (`id`) ON DELETE RESTRICT,
|
||||
CONSTRAINT `fk_lvr_version` FOREIGN KEY (`video_version_id`) REFERENCES `video_versions` (`id`) ON DELETE RESTRICT,
|
||||
CONSTRAINT `fk_lvr_session` FOREIGN KEY (`watch_session_id`) REFERENCES `watch_sessions` (`id`) ON DELETE RESTRICT,
|
||||
CONSTRAINT `chk_lvr_rating` CHECK (`rating` BETWEEN 1 AND 5)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,62 @@
|
||||
-- Teacher lesson submissions and immutable video candidates.
|
||||
-- Requires 20260909_curriculum_publication_identity.sql.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `teacher_submissions` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`uuid` CHAR(36) NOT NULL,
|
||||
`teacher_id` BIGINT UNSIGNED NOT NULL,
|
||||
`curriculum_lesson_id` BIGINT UNSIGNED NOT NULL,
|
||||
`status` ENUM('draft','uploading','review','published','withdrawn','archived') NOT NULL DEFAULT 'draft',
|
||||
`current_published_video_version_id` BIGINT UNSIGNED NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_teacher_submission_uuid` (`uuid`),
|
||||
UNIQUE KEY `uq_teacher_curriculum_submission` (`teacher_id`,`curriculum_lesson_id`),
|
||||
KEY `idx_submission_lesson_status` (`curriculum_lesson_id`,`status`),
|
||||
CONSTRAINT `fk_submission_teacher` FOREIGN KEY (`teacher_id`) REFERENCES `teachers` (`id`) ON DELETE RESTRICT,
|
||||
CONSTRAINT `fk_submission_curriculum_lesson` FOREIGN KEY (`curriculum_lesson_id`) REFERENCES `curriculum_lessons` (`id`) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `video_versions` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`uuid` CHAR(36) NOT NULL,
|
||||
`teacher_submission_id` BIGINT UNSIGNED NOT NULL,
|
||||
`version_number` INT UNSIGNED NOT NULL,
|
||||
`status` ENUM('draft','uploading','review','rejected','published','superseded','withdrawn') NOT NULL DEFAULT 'draft',
|
||||
`replaces_video_version_id` BIGINT UNSIGNED NULL,
|
||||
`source_lesson_id` BIGINT UNSIGNED NULL,
|
||||
`upload_audit_id` BIGINT UNSIGNED NULL,
|
||||
`content_sha256` CHAR(64) NULL,
|
||||
`review_report_json` JSON NULL,
|
||||
`published_at` TIMESTAMP NULL DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_video_version_uuid` (`uuid`),
|
||||
UNIQUE KEY `uq_submission_version` (`teacher_submission_id`,`version_number`),
|
||||
KEY `idx_video_version_status` (`teacher_submission_id`,`status`),
|
||||
CONSTRAINT `fk_video_version_submission` FOREIGN KEY (`teacher_submission_id`) REFERENCES `teacher_submissions` (`id`) ON DELETE RESTRICT,
|
||||
CONSTRAINT `fk_video_version_replacement` FOREIGN KEY (`replaces_video_version_id`) REFERENCES `video_versions` (`id`) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE `teacher_submissions`
|
||||
ADD CONSTRAINT `fk_submission_current_version`
|
||||
FOREIGN KEY (`current_published_video_version_id`) REFERENCES `video_versions` (`id`) ON DELETE RESTRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `submission_idempotency_keys` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`teacher_id` BIGINT UNSIGNED NOT NULL,
|
||||
`operation` VARCHAR(64) NOT NULL,
|
||||
`idempotency_key` VARCHAR(128) NOT NULL,
|
||||
`request_sha256` CHAR(64) NOT NULL,
|
||||
`response_json` JSON NULL,
|
||||
`http_status` SMALLINT UNSIGNED NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_submission_idempotency` (`teacher_id`,`operation`,`idempotency_key`),
|
||||
CONSTRAINT `fk_submission_idempotency_teacher` FOREIGN KEY (`teacher_id`) REFERENCES `teachers` (`id`) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Rollback: preserve submissions and versions. Withdraw affected publications;
|
||||
-- do not drop records that may be referenced by student progress or review data.
|
||||
@@ -0,0 +1,31 @@
|
||||
CREATE TABLE IF NOT EXISTS `video_review_jobs` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`uuid` CHAR(36) NOT NULL,
|
||||
`video_version_id` BIGINT UNSIGNED NOT NULL,
|
||||
`curriculum_lesson_id` BIGINT UNSIGNED NOT NULL,
|
||||
`prompt_version` VARCHAR(64) NOT NULL,
|
||||
`schema_version` VARCHAR(64) NOT NULL,
|
||||
`video_sha256` CHAR(64) NOT NULL,
|
||||
`markdown_sha256` CHAR(64) NOT NULL,
|
||||
`status` ENUM('queued','collecting_evidence','needs_evidence','ready_for_human_review','rejected','approved','failed','superseded') NOT NULL DEFAULT 'queued',
|
||||
`input_manifest_json` JSON NOT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`completed_at` TIMESTAMP NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uq_review_job_uuid` (`uuid`),
|
||||
KEY `idx_review_job_version` (`video_version_id`,`status`),
|
||||
CONSTRAINT `fk_review_job_version` FOREIGN KEY (`video_version_id`) REFERENCES `video_versions` (`id`) ON DELETE RESTRICT,
|
||||
CONSTRAINT `fk_review_job_lesson` FOREIGN KEY (`curriculum_lesson_id`) REFERENCES `curriculum_lessons` (`id`) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `video_review_reports` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`video_review_job_id` BIGINT UNSIGNED NOT NULL,
|
||||
`recommendation` ENUM('reject','revise','manual_review','ready_for_human_review') NOT NULL,
|
||||
`report_json` JSON NOT NULL,
|
||||
`reviewer_id` BIGINT UNSIGNED NULL,
|
||||
`human_decision` ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending',
|
||||
`reviewed_at` TIMESTAMP NULL DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uq_review_report_job` (`video_review_job_id`),
|
||||
CONSTRAINT `fk_review_report_job` FOREIGN KEY (`video_review_job_id`) REFERENCES `video_review_jobs` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Removes presentation-grade synthetic reputation defaults. Existing measurements
|
||||
-- are retained; unknown measurements remain NULL until backed by verified data.
|
||||
ALTER TABLE `teacher_performance_metrics`
|
||||
MODIFY `avg_response_minutes` INT NULL DEFAULT NULL,
|
||||
MODIFY `response_rate_percentage` DECIMAL(5,2) NULL DEFAULT NULL,
|
||||
MODIFY `raw_avg_rating` DECIMAL(3,2) NULL DEFAULT NULL,
|
||||
MODIFY `weighted_student_rating` DECIMAL(3,2) NULL DEFAULT NULL,
|
||||
MODIFY `ai_engagement_score` DECIMAL(5,2) NULL DEFAULT NULL,
|
||||
MODIFY `sla_speed_score` DECIMAL(5,2) NULL DEFAULT NULL,
|
||||
MODIFY `mastery_impact_score` DECIMAL(5,2) NULL DEFAULT NULL,
|
||||
MODIFY `composite_merit_score` DECIMAL(5,2) NULL DEFAULT NULL,
|
||||
MODIFY `star_equivalent` DECIMAL(3,2) NULL DEFAULT NULL,
|
||||
MODIFY `reputation_tier` VARCHAR(100) NULL DEFAULT NULL;
|
||||
|
||||
ALTER TABLE `teacher_reviews`
|
||||
ALTER `student_watch_percentage` SET DEFAULT 0.00,
|
||||
ALTER `socratic_accuracy_rate` SET DEFAULT 0.00,
|
||||
ALTER `is_verified` SET DEFAULT 0;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Immutable evidence records are required before a reviewer can approve a video.
|
||||
CREATE TABLE IF NOT EXISTS `video_review_evidence` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`video_review_job_id` BIGINT UNSIGNED NOT NULL,
|
||||
`transcript_asset_id` BIGINT UNSIGNED NOT NULL,
|
||||
`visual_evidence_asset_id` BIGINT UNSIGNED NOT NULL,
|
||||
`coverage_json` JSON NOT NULL,
|
||||
`submitted_by` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_review_evidence_job` (`video_review_job_id`),
|
||||
CONSTRAINT `fk_review_evidence_job` FOREIGN KEY (`video_review_job_id`) REFERENCES `video_review_jobs` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_review_evidence_transcript` FOREIGN KEY (`transcript_asset_id`) REFERENCES `content_assets` (`id`) ON DELETE RESTRICT,
|
||||
CONSTRAINT `fk_review_evidence_visual` FOREIGN KEY (`visual_evidence_asset_id`) REFERENCES `content_assets` (`id`) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -92,6 +92,12 @@ $router->get('/api/curriculum/search', function ($request, $response) {
|
||||
$router->get('/api/curriculum/simulations', [\App\Controllers\CurriculumController::class, 'listSimulations']);
|
||||
$router->get('/api/curriculum/simulations/{subject}/{simName}', [\App\Controllers\CurriculumController::class, 'getSimulation']);
|
||||
$router->get('/api/curriculum/document', [\App\Controllers\CurriculumController::class, 'getDocumentContent']);
|
||||
$router->get('/api/curriculum/assets/{assetId}', [\App\Controllers\CurriculumController::class, 'getPublishedAsset'], $studentMiddleware);
|
||||
$router->get('/api/curriculum/lessons/{lessonId}/videos', [\App\Controllers\VideoController::class, 'listPublishedLessonVideos'], $studentMiddleware);
|
||||
$router->get('/api/curriculum/lessons/{lessonId}/english-package', [\App\Controllers\CurriculumController::class, 'getPublishedEnglishPackage'], $studentMiddleware);
|
||||
$router->get('/api/video-versions/{versionId}/playback', [\App\Controllers\VideoController::class, 'getVideoVersionPlayback'], $studentMiddleware);
|
||||
$router->post('/api/video-versions/{versionId}/watch-sessions', [\App\Controllers\VideoController::class, 'startWatchSession'], $studentMiddleware);
|
||||
$router->post('/api/watch-sessions/{sessionId}/events', [\App\Controllers\VideoController::class, 'recordWatchEvent'], $studentMiddleware);
|
||||
|
||||
// Health & Diagnostic Routes
|
||||
$router->get('/api/health', function ($request, $response) {
|
||||
@@ -157,6 +163,10 @@ $router->post('/api/teacher/broadcast', [\App\Controllers\TeacherController:
|
||||
|
||||
// Dual Video Storage & Cloudflare R2 / HLS Stream Routes (API-Driven)
|
||||
$router->post('/api/teacher/videos/upload-direct', [\App\Controllers\VideoController::class, 'uploadDirect'], $teacherMiddleware);
|
||||
$router->post('/api/teacher/submissions/preflight', [\App\Controllers\VideoController::class, 'preflightSubmission'], $teacherMiddleware);
|
||||
$router->post('/api/admin/video-review/publish', [\App\Controllers\VideoController::class, 'publishReviewedVersion'], $superAdminMiddleware);
|
||||
$router->post('/api/admin/video-review/decision', [\App\Controllers\VideoController::class, 'recordVideoReviewDecision'], $superAdminMiddleware);
|
||||
$router->post('/api/admin/video-review/evidence', [\App\Controllers\VideoController::class, 'submitVideoReviewEvidence'], $superAdminMiddleware);
|
||||
$router->post('/api/teacher/lessons/checkpoints', [\App\Controllers\VideoController::class, 'saveCheckpoint'], $teacherMiddleware);
|
||||
$router->get('/api/student/lessons', [\App\Controllers\VideoController::class, 'getStudentLessons'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
$router->get('/api/videos/stream/{uuid}', [\App\Controllers\VideoController::class, 'streamLocalVideo'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Builds a review queue from manifest.json for migration 20260909.
|
||||
* Default mode is dry-run and never connects to MySQL. --apply creates only
|
||||
* unverified lessons, draft bundles, and draft assets; it never publishes.
|
||||
*
|
||||
* Usage:
|
||||
* php backend/scripts/backfill_curriculum_identity.php
|
||||
* php backend/scripts/backfill_curriculum_identity.php --apply
|
||||
*/
|
||||
|
||||
$apply = in_array('--apply', $argv, true);
|
||||
$manifestPath = dirname(__DIR__) . '/storage/curriculum/manifest.json';
|
||||
$storageRoot = realpath(dirname(__DIR__) . '/storage/curriculum');
|
||||
|
||||
if (!$storageRoot || !is_file($manifestPath)) {
|
||||
fwrite(STDERR, "Manifest or curriculum storage is unavailable.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$manifest = json_decode((string)file_get_contents($manifestPath), true);
|
||||
if (!is_array($manifest)) {
|
||||
fwrite(STDERR, "Manifest is not valid JSON.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($manifest as $gradeKey => $grade) {
|
||||
foreach (($grade['subjects'] ?? []) as $subjectKey => $subject) {
|
||||
foreach (($subject['semesters'] ?? []) as $semesterKey => $semester) {
|
||||
foreach (($semester['units'] ?? []) as $unitKey => $unit) {
|
||||
foreach (($unit['lessons'] ?? []) as $lesson) {
|
||||
$file = (string)($lesson['file'] ?? '');
|
||||
$candidate = realpath($storageRoot . '/' . ltrim($file, '/'));
|
||||
$insideStorage = $candidate && str_starts_with(
|
||||
$candidate,
|
||||
rtrim($storageRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR
|
||||
);
|
||||
$rows[] = [
|
||||
'grade_key' => (string)$gradeKey,
|
||||
'subject_key' => (string)$subjectKey,
|
||||
'semester_key' => (string)$semesterKey,
|
||||
'unit_key' => (string)$unitKey,
|
||||
'lesson_key' => (string)($lesson['id'] ?? ''),
|
||||
'title' => (string)($lesson['title'] ?? ''),
|
||||
'manifest_path' => $file,
|
||||
'path' => $insideStorage ? $candidate : null,
|
||||
'missing' => !$insideStorage,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$identityCounts = [];
|
||||
foreach ($rows as $row) {
|
||||
$identity = implode('|', [$row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key']]);
|
||||
$identityCounts[$identity] = ($identityCounts[$identity] ?? 0) + 1;
|
||||
}
|
||||
$conflicts = array_keys(array_filter($identityCounts, static fn (int $count): bool => $count > 1));
|
||||
$missing = array_values(array_filter($rows, static fn (array $row): bool => $row['missing']));
|
||||
|
||||
$report = [
|
||||
'mode' => $apply ? 'apply' : 'dry_run',
|
||||
'curriculum_version' => 'manifest-2026-09-09',
|
||||
'lessons_found' => count($rows),
|
||||
'missing_assets' => array_map(static fn (array $row): array => [
|
||||
'identity' => implode('/', [$row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key']]),
|
||||
'title' => $row['title'],
|
||||
'manifest_path' => $row['manifest_path'] ?: null,
|
||||
'reason' => $row['manifest_path'] === '' ? 'missing_manifest_file_reference' : 'file_not_found_in_local_storage',
|
||||
], $missing),
|
||||
'identity_conflicts' => $conflicts,
|
||||
'eligible_for_review_queue' => count($rows) - count($missing),
|
||||
];
|
||||
|
||||
if (!$apply) {
|
||||
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
|
||||
exit(($missing || $conflicts) ? 2 : 0);
|
||||
}
|
||||
|
||||
if ($conflicts) {
|
||||
fwrite(STDERR, "Conflicting curriculum identities found. Resolve them before --apply.\n");
|
||||
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
|
||||
exit(2);
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
use App\Core\Database;
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
foreach ($rows as $row) {
|
||||
if ($row['missing']) {
|
||||
continue;
|
||||
}
|
||||
$lessonUuid = selfUuid();
|
||||
Database::query(
|
||||
"INSERT INTO curriculum_lessons
|
||||
(uuid, grade_key, subject_key, semester_key, unit_key, lesson_key, curriculum_version, title, source_manifest_path, source_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'manifest-2026-09-09', ?, ?, 'unverified')
|
||||
ON DUPLICATE KEY UPDATE title = VALUES(title), source_manifest_path = VALUES(source_manifest_path)",
|
||||
[$lessonUuid, $row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key'], $row['title'], $row['manifest_path']]
|
||||
);
|
||||
$lessonId = (int)Database::selectOne(
|
||||
"SELECT id FROM curriculum_lessons WHERE grade_key = ? AND subject_key = ? AND semester_key = ? AND unit_key = ? AND lesson_key = ? AND curriculum_version = 'manifest-2026-09-09' LIMIT 1",
|
||||
[$row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key']]
|
||||
)['id'];
|
||||
Database::query(
|
||||
"INSERT INTO publication_bundles (uuid, curriculum_lesson_id, bundle_version, status)
|
||||
VALUES (?, ?, 'manifest-2026-09-09', 'draft')
|
||||
ON DUPLICATE KEY UPDATE id = id",
|
||||
[selfUuid(), $lessonId]
|
||||
);
|
||||
$bundleId = (int)Database::selectOne(
|
||||
"SELECT id FROM publication_bundles WHERE curriculum_lesson_id = ? AND bundle_version = 'manifest-2026-09-09' LIMIT 1",
|
||||
[$lessonId]
|
||||
)['id'];
|
||||
$sha = hash_file('sha256', $row['path']);
|
||||
$bytes = filesize($row['path']);
|
||||
Database::query(
|
||||
"INSERT INTO content_assets (uuid, asset_type, storage_driver, storage_key, mime_type, byte_size, sha256, source_reference, review_status)
|
||||
VALUES (?, 'lesson_markdown', 'local', ?, 'text/markdown; charset=utf-8', ?, ?, ?, 'draft')
|
||||
ON DUPLICATE KEY UPDATE id = id",
|
||||
[selfUuid(), $row['manifest_path'], $bytes, $sha, 'manifest.json']
|
||||
);
|
||||
$assetId = (int)Database::selectOne(
|
||||
"SELECT id FROM content_assets WHERE storage_driver = 'local' AND storage_key = ? AND sha256 = ? LIMIT 1",
|
||||
[$row['manifest_path'], $sha]
|
||||
)['id'];
|
||||
Database::query(
|
||||
"INSERT IGNORE INTO publication_bundle_assets (publication_bundle_id, content_asset_id, role) VALUES (?, ?, 'primary_lesson')",
|
||||
[$bundleId, $assetId]
|
||||
);
|
||||
}
|
||||
$pdo->commit();
|
||||
$report['created_review_queue'] = true;
|
||||
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
|
||||
} catch (Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
fwrite(STDERR, "Backfill rolled back: {$e->getMessage()}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
function selfUuid(): string
|
||||
{
|
||||
$bytes = random_bytes(16);
|
||||
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
|
||||
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Explicit migration runner. It never drops a database and does not run from
|
||||
* HTTP. Usage: php backend/scripts/run_migrations.php [--dry-run|--status].
|
||||
*/
|
||||
require_once dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
use App\Core\Database;
|
||||
|
||||
$mode = in_array('--status', $argv, true) ? 'status' : (in_array('--dry-run', $argv, true) ? 'dry_run' : 'apply');
|
||||
$dir = dirname(__DIR__) . '/migrations';
|
||||
$files = glob($dir . '/*.sql') ?: [];
|
||||
sort($files, SORT_STRING);
|
||||
$pdo = Database::getConnection();
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS schema_migrations (filename VARCHAR(255) NOT NULL PRIMARY KEY, sha256 CHAR(64) NOT NULL, applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
$applied = Database::select('SELECT filename, sha256, applied_at FROM schema_migrations ORDER BY filename');
|
||||
$known = array_column($applied, null, 'filename');
|
||||
|
||||
foreach ($files as $file) {
|
||||
$name = basename($file); $sha = hash_file('sha256', $file);
|
||||
if (isset($known[$name])) {
|
||||
if (!hash_equals((string)$known[$name]['sha256'], $sha)) { fwrite(STDERR, "REFUSE {$name}: applied migration checksum changed.\n"); exit(2); }
|
||||
echo "APPLIED {$name} {$known[$name]['applied_at']}\n"; continue;
|
||||
}
|
||||
if ($mode === 'status') { echo "PENDING {$name}\n"; continue; }
|
||||
if ($mode === 'dry_run') { echo "WOULD APPLY {$name}\n"; continue; }
|
||||
$sql = file_get_contents($file);
|
||||
if ($sql === false || trim($sql) === '') { fwrite(STDERR, "REFUSE {$name}: unreadable or empty.\n"); exit(2); }
|
||||
echo "APPLYING {$name}\n";
|
||||
try {
|
||||
// MySQL DDL may commit implicitly. The migration is recorded only after
|
||||
// its complete SQL succeeds; repair any partial DDL manually before retry.
|
||||
$pdo->exec($sql);
|
||||
Database::insert('INSERT INTO schema_migrations (filename, sha256) VALUES (?, ?)', [$name, $sha]);
|
||||
echo "APPLIED {$name}\n";
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, "FAILED {$name}: {$e->getMessage()}\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,16 @@ function updateVideoTask(string $file, array $changes): void {
|
||||
if ($taskId === '' || !file_exists($stateFile)) exit(1);
|
||||
$task = json_decode(file_get_contents($stateFile), true) ?: [];
|
||||
|
||||
// Legacy worker has no teacher_submission/video_version binding and therefore
|
||||
// cannot prove which curriculum lesson or candidate it is publishing. Keep the
|
||||
// uploaded file for an operator; do not create or overwrite a lesson by title.
|
||||
updateVideoTask($stateFile, [
|
||||
'status' => 'needs_submission_pipeline',
|
||||
'progress' => 0,
|
||||
'message' => 'هذا العامل موقوف حتى يمرر مسار الإرسال نسخة فيديو مرتبطة بالدرس المنهجي.',
|
||||
]);
|
||||
exit(2);
|
||||
|
||||
try {
|
||||
updateVideoTask($stateFile, ['status' => 'processing', 'progress' => 15, 'message' => 'جاري تحويل الفيديو إلى HLS...']);
|
||||
$file = (string)($task['file'] ?? '');
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# قائمة تنفيذ صَقِل — جلسات Medium قابلة للاستئناف
|
||||
|
||||
هذه القائمة هي سجل التشغيل العملي للمحادثات القادمة. النموذج الافتراضي لكل مهمة هو **GPT-5.6 Terra على Medium**. لا تفتح أكثر من مهمة تنفيذية واحدة في المحادثة الواحدة إلا إذا كانت الثانية لا تمس الملفات أو قاعدة البيانات نفسها. استخدم Sol/Astra للمراجعة النهائية للمال والأمن فقط، بعد اكتمال الاختبارات.
|
||||
|
||||
## طريقة الاستئناف
|
||||
|
||||
في أي جلسة جديدة ابدأ بالنص التالي، ثم نفذ أول مهمة `planned` اكتملت متطلباتها:
|
||||
|
||||
```text
|
||||
اقرأ AGENTS.md وdocs/MEDIUM_EXECUTION_TASKS.md وملفات المهمة المحددة.
|
||||
نفذ المهمة فقط، واحفظ تغييرات المستخدم. لا تنشر ولا تتصل بخدمة دفع حية.
|
||||
حدّث حالة المهمة إلى implemented_unverified أو verified فقط بدليل فعلي.
|
||||
```
|
||||
|
||||
| الترتيب | المهمة | النموذج/الجهد | يعتمد على | الحالة |
|
||||
|---:|---|---|---|---|
|
||||
| 00 | تثبيت بيئة الاختبار والأوامر الفعلية | Medium | — | implemented_unverified |
|
||||
| 01 | إيقاف النجاح المالي والمحتوى البديل الوهمي | Medium | 00 | implemented_unverified |
|
||||
| 02 | هوية المنهج والأصول المنشورة | Medium | 01 | implemented_unverified |
|
||||
| 03 | حصة المعلم والإصدار والاستبدال الذري | Medium | 02 | implemented_unverified |
|
||||
| 04 | تدقيق الفيديو والأسئلة من الأدلة | Medium | 02, 03 | planned |
|
||||
| 05 | API صفر/واحد/متعدد واختيار المعلم | Medium | 02, 04 | planned |
|
||||
| 06 | دفتر الحسابات وحجز الرصيد | Medium ثم Sol review | 00, 01 | planned |
|
||||
| 07 | جلسات المشاهدة والدقائق المؤهلة | Medium | 02, 05, 06 | planned |
|
||||
| 08 | تسوية صندوق المعلمين وكشف الحساب | Medium ثم Sol review | 06, 07 | planned |
|
||||
| 09 | مزود الدفع والبصمة والتحويل | Medium ثم Sol/Astra review | 06 | blocked: مزود دفع/سياسة متجر |
|
||||
| 10 | الإنجليزية: حزمة الدرس والصوت الموحد | Medium | 01, 02 | planned |
|
||||
| 11 | موارد الدرس وحلقة العلاج وولي الأمر | Medium | 02, 04, 05, 10 | planned |
|
||||
| 12 | المؤسسات والتقييمات والتلعيب | Medium | 02, 05, 07, 11 | planned |
|
||||
| 13 | إثبات الصف العاشر وقرار الإطلاق | Medium ثم Astra review | كل ما يلزم | planned |
|
||||
|
||||
## قاعدة تحديث قاعدة البيانات
|
||||
|
||||
شغّل من جذر المشروع فقط: `php backend/scripts/run_migrations.php --status` ثم `--dry-run`، وبعد نسخة احتياطية وstaging نفّذ الأمر دون خيارات. يسجل المشغّل اسم كل migration وSHA-256؛ يرفض تشغيل ملف تغيّر بعد تسجيله. لا تستخدم `migrate.php fresh` لهذا المسار لأنه قد يحذف قاعدة البيانات.
|
||||
|
||||
## بطاقات التسليم
|
||||
|
||||
### 00 — خط الأساس
|
||||
|
||||
اقرأ `backend/public/index.php` و`backend/cron/run_scheduled_tasks.php` و`backend/composer.json` وملفات الاختبار قبل تشغيل أي منها. أنشئ سجلاً لأوامر الفحص الآمنة، ولا تستخدم `artisan`. أنجز harness اختبارات PHP محلياً لا يحتاج قاعدة إنتاج أو Gemini.
|
||||
|
||||
### 01 — الصدق أولاً
|
||||
|
||||
نفذ NP-01 من `NEXT_PHASE_EXECUTION_BACKLOG.md`: لا تفعيل وصول من صورة إيصال، لا إكمال سحب دون مزود، لا تحويل أرقام المنصة إلى دخل معلم، ولا مستند أو IPA أو درس بديل عند فشل المصدر. أضف migration رسمية لما كان ينشأ وقت الطلب. اختبر فشل الخدمات وحالات عدم توفر الدفع.
|
||||
|
||||
### 02 — هوية المحتوى
|
||||
|
||||
نفذ migration لـ`curriculum_lesson`, `content_asset`, وإصدار الحزمة. اعتمد IDs ثابتة ولا تطابق بالعنوان أو LIKE. أنشئ endpoint يطلب asset ID فقط ويعيد الأصل المنشور الموافق أو خطأ. اكتب backfill dry-run مع تقرير تعارضات.
|
||||
|
||||
### 03 — نشر واستبدال الحصة
|
||||
|
||||
نفذ `teacher_submission` و`video_version` وقيد حصة أساسية واحدة لكل معلم/درس. أضف Idempotency-Key وقفل/transaction للنشر. البديلة تبقى مرشحة حتى يجتاز فحصها ولا توقف النسخة القديمة.
|
||||
|
||||
### 04 — الفحص السقراطي
|
||||
|
||||
طبق `VIDEO_REVIEW_AND_SOCRATIC_PROMPTS.md`: لا توليد في GET، ولا fallback أسئلة. job يجمع تفريغاً وأدلة وصوراً وMarkdown بإصدارات، ويخضع لـschema validation ومراجعة بشرية. ابن regression fixtures معزولة لا تستدعي AI.
|
||||
|
||||
### 05 — اختيار المعلم
|
||||
|
||||
اعرض حصصاً منشورة ومصرحاً بها للدرس المحدد فقط: صفر موارد صادقة، واحد تشغيل مباشر، متعدد قائمة paginated مرتبة بتقييم موثق. المشغل يعمل بـvideo_version ID وليس عنواناً.
|
||||
|
||||
### 06–08 — المال بالدقائق
|
||||
|
||||
لا تبدأ قبل تثبيت policy تجارية موقعة. نفذ دفتر الفلس، ثم أحداث مشاهدة غير موثوقة من العميل، ثم توزيع صندوق الفترة وإشعارات معلم تفسيرية. كل تعديل قيد عكسي، وكل سحب حجز ذري. راجع Sol قبل تشغيل أي رصيد حقيقي.
|
||||
|
||||
### 09 — الدفع والبصمة
|
||||
|
||||
مهمة محجوبة حتى يحدد المستخدم مزود تحصيل/تحويل حقيقي وقناة التوزيع وسياسة المتاجر. عندها نبني intents، webhooks موقعة، sandbox، تأكيد بصمة مرتبط بتحدٍ، وتسوية. لا يتم تخزين بصمة.
|
||||
|
||||
### 10–12 — تجربة الطالب والمعلم
|
||||
|
||||
أعد بناء الإنجليزية من الحزمة المنشورة، لا من قوائم ثابتة. وحّد الصوت ولا تولد IPA/ترجمة. أضف PDF وورقة وخريطتي صور، ثم الحلقة العلاجية، ثم تنظيم المؤسسات والتقييمات.
|
||||
|
||||
### 13 — الإطلاق
|
||||
|
||||
اختبر 12 درساً حقيقياً للصف العاشر على staging مع بيانات محكومة. لا توسع قبل دليل مصادر، صحة أسئلة، عودة الطالب بعد أسبوع، ومساهمة تشغيل موجبة. هذه بوابة مراجعة Astra النهائية.
|
||||
|
||||
## قاعدة التحديث
|
||||
|
||||
بعد كل مهمة اكتب في آخر هذه الوثيقة: المهمة، commit/diff، الاختبارات الفعلية، ما لم يختبر، والقرار التالي. لا تحوّل `blocked` إلى `planned` دون القرار أو الخدمة المطلوبة.
|
||||
|
||||
## سجل التنفيذ
|
||||
|
||||
### 2026-09-09 — 00 و01 (implemented_unverified)
|
||||
|
||||
- أضيفت `backend/migrations/20260909_payment_safety_hold.sql` لنقل تعريف جداول كليك وطابور السحب من وقت الطلب إلى migration واضحة لا تحذف أدلة مالية عند التراجع.
|
||||
- واجهات كليك والسحب والتسوية المجدولة تعيد حالة عدم توفر ولا تنشئ إيصالاً أو حركة أو وصولاً أو سحباً. لوحة المعلم لا تستنتج دخلاً أو رصيداً من عدد المشتركين أو أسعار الدورات.
|
||||
- حذف مصدر Markdown التجريبي؛ طلب المستند يحتاج مسار Markdown دقيقاً داخل تخزين المنهج، وفشل المصدر يعرض حالة صادقة. تشغيل الفيديو أصبح للقراءة فقط ولا يولد نقاط فحص من العنوان أو المنهج العام.
|
||||
- فحوص شُغلت: `php -l` للملفات PHP المعدلة، و`git diff --check`، و`flutter analyze` للتطبيقين. لم يظهر خطأ في الملفات المعدلة؛ توجد تحذيرات قديمة غير مانعة في المشروعين.
|
||||
- لم تُنفذ migration على قاعدة بيانات، ولم تُشغّل اختبارات تكامل بقاعدة معزولة أو مزود دفع؛ لا تنشر هذه الدفعة قبل تنفيذ ذلك.
|
||||
- الخطوة التالية: ابدأ 02 بهوية محتوى وأصول منشورة؛ بعدها فقط ابدأ 03 و04.
|
||||
|
||||
### 2026-09-09 — 02 (implemented_unverified)
|
||||
|
||||
- أضيفت migration `20260909_curriculum_publication_identity.sql` لجداول هوية الدرس، bundle النشر، الأصل ذي checksum، وربط الأصول بالحزمة. الاستيراد لا ينشر شيئاً تلقائياً.
|
||||
- أضيف `GET /api/curriculum/assets/{assetId}` للطالب المصرح؛ يقبل UUID فقط ويقرأ أصلاً محلياً approved من bundle منشور فقط. لا يستقبل مسار ملف من المستخدم.
|
||||
- أضيف `backend/scripts/backfill_curriculum_identity.php`: افتراضياً dry-run بلا اتصال DB، و`--apply` ينشئ طابور مراجعة draft/unverified ضمن transaction فقط بعد تطبيق migration وحل التعارضات.
|
||||
- نتيجة dry-run المحلية: 74 سجل manifest، 55 ملفاً محلياً قابلاً لطابور المراجعة، و19 مرجعاً بلا ملف. لا يوجد تعارض هوية تلقائي؛ المراجع الناقصة مدرجة في تقرير الأمر.
|
||||
- فحوص شُغلت: PHP lint للخدمة/المتحكم/route/script و`git diff --check`. لم تطبق migration ولم يشغّل `--apply`.
|
||||
- الخطوة التالية: 03، بناء حصة المعلم ونسخة الفيديو والاستبدال الذري فوق `curriculum_lessons`.
|
||||
|
||||
### 2026-09-09 — 03 (implemented_unverified)
|
||||
|
||||
- أضيفت migration `20260909_teacher_submission_versions.sql`: قيد فريد للمعلم والدرس المنهجي، نسخ فيديو مرشحة مرقمة، مؤشر منفصل للنسخة المنشورة، ومفاتيح idempotency.
|
||||
- أضيف مسار `POST /api/teacher/submissions/preflight` مع `Idempotency-Key`. الحصة الثانية تعيد `409 existing_submission`؛ الاستبدال يتطلب هدف الحصة الحالية وينشئ candidate جديداً فقط.
|
||||
- رفع الفيديو المباشر صار يتطلب `video_version_id` من preflight ويحجز النسخة atomically من draft إلى uploading قبل تخزين الفيديو. بعد الرفع ترتبط بالدرس القديم بوصفها `review`؛ لا تنشر ولا تستبدل النسخة الحالية تلقائياً.
|
||||
- فحوص شُغلت: PHP lint للخدمة والمتحكم والـroute و`git diff --check`.
|
||||
- لم تطبق migration ولم ينفذ اختبار تزامن أو transaction على MySQL. تطبيق المعلم لا يمرر بعد `curriculum_lesson_id`/`video_version_id` لأنه يحتاج تشغيل backfill ومراجعة/نشر هوية الدروس أولاً؛ لذلك الرفع القديم يرفض صراحة بدلاً من تجاوز القيد.
|
||||
- الخطوة التالية: 04، job تدقيق الفيديو من التفريغ وMarkdown وإصدار النسخة، ثم نشر ذري للنسخة التي اجتازت المراجعة.
|
||||
|
||||
### 2026-09-09 — إدارة migrations و04 (in_progress)
|
||||
|
||||
- أضيف `backend/scripts/run_migrations.php` كمشغل موحد للمجلد `backend/migrations` مع أوضاع status وdry-run وapply وسجل checksum.
|
||||
- أضيفت migration `20260909_video_review_jobs.sql` لسجل job وتقرير مراجعة بشرية؛ لا يوجد فيها نشر آلي أو توليد أسئلة تلقائي.
|
||||
|
||||
### 2026-09-09 — 04 و05 (implemented_unverified، تكامل الواجهة مؤجل)
|
||||
|
||||
- أضيفت `video_review_evidence` مع مسار إداري `POST /api/admin/video-review/evidence`. لا ينتقل job إلى مراجعة بشرية قبل تفريغ زمني معتمد ودليل بصري معتمد وسجل تغطية متصل؛ ولا يقبل قرار الموافقة أو النشر من دون هذا السجل.
|
||||
- أضيفت `GET /api/curriculum/lessons/{lessonId}/videos`: يعتمد UUID الدرس، يعرض النسخ المنشورة فقط، ويرشح كل صف بصلاحية الطالب قبل كشف الاسم أو التقييم أو العدد. لا يحدث هذا الاستعلام بيانات الطالب أو تصريحاته أثناء التصفح.
|
||||
- نقطة التشغيل الجديدة `GET /api/video-versions/{versionId}/playback` تتحقق من صلاحية النسخة المنشورة والصلاحية الدراسية قبل توقيع البث. أسئلة الفيديو تبقى فارغة إلى أن تنشر من مسار الأدلة والمراجعة.
|
||||
- أزيل DDL من طلبات المعلم والمنهج والامتحان؛ أصبحت hooks التوافقية no-op وتحتاج البيئة إلى `run_migrations.php` قبل الخدمة. أزيلت قيم السمعة والإنجاز الافتراضية من واجهات المعلم والطالب وولي الأمر ومن HDDL، مع migration تصحح defaults للبيئات الموجودة.
|
||||
- فحوص شُغلت: `php -l` على كل PHP في `backend/app` و`backend/scripts` و`backend/cron` (ناجح)، و`git diff --check` (ناجح). محاولة `php backend/scripts/run_migrations.php --dry-run` لم تبدأ لأن البيئة المحلية بلا `.env` و`ENCRYPTION_KEY`.
|
||||
- لم يكتمل بعد: Flutter ما زال يستدعي `/api/lessons/playback` لأن شجرة المنهج الحالية لا تحمل UUID الدرس المنشور. لا تربطه بالـAPI الجديد إلا بعد تطبيق migrations وbackfill ومراجعة/نشر bundles حقيقية؛ عندها نفذ حالة صفر/واحد/متعدد في `subject_hub_screen.dart` ثم مرر `video_version_id` إلى المشغل.
|
||||
- الخطوة التالية: 06 بعد سياسة دخل موقعة، أو تنفيذ واجهة اختيار المعلم فور توافر UUIDs منشورة على شجرة المنهج.
|
||||
|
||||
### 2026-09-09 — 05 و07 و10 (implemented_unverified)
|
||||
|
||||
- شجرة المنهج تثري الدروس من قاعدة البيانات بعد migrations/backfill فقط بـ`curriculum_lesson_id` و`has_video` المنشور. لا تعيد مساراً بديلاً عندما لا تكون الهوية منشورة.
|
||||
- تطبيق الطالب يستخدم `/api/curriculum/lessons/{lessonId}/videos` ثم `/api/video-versions/{versionId}/playback`: حصة واحدة تفتح مباشرة، وأكثر من حصة يظهر لها اختيار معلم صريح بالتقييم الموثق وعدد التقييمات. أزيل استدعاء `/api/lessons/playback` القائم على العنوان من التطبيق.
|
||||
- أضيفت جلسة المشاهدة: `POST /api/video-versions/{id}/watch-sessions` ثم أحداث متسلسلة. يحتسب الخادم interval متصلاً من heartbeat فقط، بمقارنة زمن الخادم وموضع العميل وحدود صارمة؛ لا ينشئ ذلك رصيداً أو تسوية أو دخل معلم.
|
||||
- أضيف `TeacherLedgerService` لعرض الرصيد بالفلس وحجز السحب idempotently من رصيد دفتر قائم فقط. لا توجد واجهة حجز أو تسوية مفعلة ولا أي سياسة سعر/صندوق مختلقة.
|
||||
- أزيل مختبر الإنجليزية الثابت وما فيه من IPA ومفردات وصوت غير موثقين. أضيف `GET /api/curriculum/lessons/{lessonId}/english-package` الذي يقرأ حزمة إنجليزية منشورة فقط ومفرداتها وصوتها approved؛ الواجهة تعرض حالة انتظار صادقة إلى أن تُزرع الحزم المعتمدة.
|
||||
- فحوص شُغلت: PHP lint كامل لـ`backend/app` و`backend/scripts` و`backend/cron`، و`git diff --check`، وFlutter analyze لملفات الطالب المعدلة؛ كلها ناجحة.
|
||||
- لم تُطبق migrations أو تختبر المعاملات على MySQL لأن `.env` و`ENCRYPTION_KEY` غير متاحين محلياً. كما أن 06–08 لا يجوز تفعيلها أو حساب توزيع مالي قبل سياسة دخل موقعة، و09 ما زالت محجوبة بتحديد مزود دفع/تحويل فعلي.
|
||||
- الخطوة التالية تلقائياً: نفذ migrations على staging مع backup، شغّل backfill dry-run ثم راجع الـ19 مرجعاً الناقصاً، وانشر bundles وحزم إنجليزية حقيقية للصف العاشر. بعدها اختبر مسارات صفر/واحد/متعدد وجلسات المشاهدة بتكامل MySQL قبل أي نشر.
|
||||
Reference in New Issue
Block a user