feat: Initial commit for Saqel Platform architecture, backend, Docker, and documentation

This commit is contained in:
Hamza-Ayed
2026-08-26 15:45:55 +03:00
commit d53b64500a
177 changed files with 19909 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Contracts;
interface AiService
{
/**
* Analyze a student's performance data and return a structured remediation plan.
*
* @param array<string, mixed> $performance
* @return array<string, mixed>
*/
public function analyzeStudentPerformance(array $performance): array;
/**
* Generate a parent-friendly weekly summary from the student's activity data.
*
* @param array<string, mixed> $activity
* @return array<string, mixed>
*/
public function generateParentWeeklyReport(array $activity): array;
}
@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class GuardianSummaryController extends Controller
{
public function show(Request $request, User $student): JsonResponse
{
/** @var User $guardian */
$guardian = $request->user();
abort_unless($guardian->students()->whereKey($student->id)->exists(), 403);
$progress = $student->lessonProgress()
->with('lesson:id,title,course_id')
->latest('updated_at')
->limit(10)
->get()
->map(fn ($row): array => [
'lesson_id' => $row->lesson_id,
'lesson_title' => $row->lesson?->title,
'watched_percentage' => $row->watched_percentage,
'completed' => $row->completed_at !== null,
'last_active_at' => $row->updated_at->toIso8601String(),
]);
$weakestConcepts = $student->conceptMastery()
->with('concept:id,name')
->orderBy('mastery_score')
->limit(5)
->get()
->map(fn ($row): array => [
'concept' => $row->concept?->name,
'mastery_score' => (float) $row->mastery_score,
]);
$latestReport = $student->aiReports()
->where('type', 'weakness_analysis')
->latest()
->first();
return response()->json([
'student' => [
'id' => $student->id,
'name' => $student->name,
],
'lessons_progress' => $progress,
'weakest_concepts' => $weakestConcepts,
'latest_ai_summary' => $latestReport?->summary,
]);
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Lesson;
use App\Services\ProgressTrackingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ProgressController extends Controller
{
public function __construct(private readonly ProgressTrackingService $tracking) {}
public function heartbeat(Request $request): JsonResponse
{
$validated = $request->validate([
'lesson_id' => ['required', 'integer', 'exists:lessons,id'],
'position_seconds' => ['required', 'integer', 'min:0'],
]);
/** @var Lesson $lesson */
$lesson = Lesson::query()->findOrFail($validated['lesson_id']);
$progress = $this->tracking->heartbeat(
student: $request->user(),
lesson: $lesson,
positionSeconds: (int) $validated['position_seconds'],
);
return response()->json([
'lesson_id' => $lesson->id,
'watched_percentage' => $progress->watched_percentage,
'last_position_seconds' => $progress->last_position_seconds,
'completed' => $progress->completed_at !== null,
]);
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Lesson;
use App\Models\Quiz;
use App\Services\ProgressTrackingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class QuizAttemptController extends Controller
{
public function __construct(private readonly ProgressTrackingService $tracking) {}
public function store(Request $request, Lesson $lesson, Quiz $quiz): JsonResponse
{
abort_unless($quiz->lesson_id === $lesson->id, 404);
$validated = $request->validate([
'answers' => ['required', 'array', 'min:1'],
'answers.*.question_id' => ['required', 'integer'],
'answers.*.selected_option_id' => ['nullable', 'integer'],
'answers.*.time_spent_seconds' => ['nullable', 'integer', 'min:0', 'max:7200'],
]);
$result = $this->tracking->recordQuizAttempt(
student: $request->user(),
quiz: $quiz,
submittedAnswers: $validated['answers'],
);
return response()->json($result);
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Jobs\AnalyzeStudentPerformance;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class StudentAnalysisController extends Controller
{
public function store(Request $request): JsonResponse
{
/** @var User $student */
$student = $request->user();
AnalyzeStudentPerformance::dispatch($student->id);
return response()->json(['queued' => true, 'message' => 'Analysis queued.'], 202);
}
}
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class ApiTokenAuth
{
public function handle(Request $request, Closure $next): Response
{
$token = $request->bearerToken();
if ($token === null || Str::length($token) < 32) {
return response()->json(['message' => 'Unauthenticated.'], 401);
}
/** @var User|null $user */
$user = User::query()->where('api_token', $token)->first();
if ($user === null) {
return response()->json(['message' => 'Unauthenticated.'], 401);
}
$request->setUserResolver(fn () => $user);
return $next($request);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Jobs;
use App\Contracts\AiService;
use App\Models\User;
use App\Services\ProgressTrackingService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
class AnalyzeStudentPerformance implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public int $studentId) {}
public function handle(ProgressTrackingService $tracking, AiService $ai): void
{
/** @var User|null $student */
$student = User::query()->find($this->studentId);
if ($student === null) {
return;
}
try {
$payload = $ai->analyzeStudentPerformance($tracking->buildPerformanceSnapshot($student));
} catch (Throwable $exception) {
report($exception);
return;
}
$student->aiReports()->create([
'type' => 'weakness_analysis',
'payload' => $payload,
'summary' => is_string($payload['summary'] ?? null) ? $payload['summary'] : null,
]);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AiReport extends Model
{
protected $fillable = ['user_id', 'type', 'payload', 'summary'];
protected function casts(): array
{
return [
'payload' => 'array',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Answer extends Model
{
protected $fillable = [
'quiz_attempt_id',
'question_id',
'selected_option_id',
'is_correct',
'time_spent_seconds',
];
protected $casts = [
'is_correct' => 'boolean',
];
public function attempt(): BelongsTo
{
return $this->belongsTo(QuizAttempt::class, 'quiz_attempt_id');
}
public function question(): BelongsTo
{
return $this->belongsTo(Question::class);
}
public function selectedOption(): BelongsTo
{
return $this->belongsTo(QuestionOption::class, 'selected_option_id');
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Database\Factories\ConceptFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Concept extends Model
{
/** @use HasFactory<ConceptFactory> */
use HasFactory;
protected $fillable = ['subject_id', 'name', 'remedial_lesson_id'];
public function subject(): BelongsTo
{
return $this->belongsTo(Subject::class);
}
public function remedialLesson(): BelongsTo
{
return $this->belongsTo(Lesson::class, 'remedial_lesson_id');
}
public function questions(): HasMany
{
return $this->hasMany(Question::class);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ConceptMastery extends Model
{
protected $table = 'concept_mastery';
protected $fillable = [
'user_id',
'concept_id',
'correct_count',
'wrong_count',
'mastery_score',
];
public function concept(): BelongsTo
{
return $this->belongsTo(Concept::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Database\Factories\CourseFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Course extends Model
{
/** @use HasFactory<CourseFactory> */
use HasFactory;
protected $fillable = ['subject_id', 'grade', 'track', 'title', 'description'];
public function subject(): BelongsTo
{
return $this->belongsTo(Subject::class);
}
public function lessons(): HasMany
{
return $this->hasMany(Lesson::class)->orderBy('order_index');
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Database\Factories\LessonFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Lesson extends Model
{
/** @use HasFactory<LessonFactory> */
use HasFactory;
protected $fillable = ['course_id', 'title', 'order_index', 'video_id', 'duration_seconds', 'transcript'];
public function course(): BelongsTo
{
return $this->belongsTo(Course::class);
}
public function concepts(): BelongsToMany
{
return $this->belongsToMany(Concept::class);
}
public function quizzes(): HasMany
{
return $this->hasMany(Quiz::class);
}
public function progressFor(int $userId): ?LessonProgress
{
/** @var LessonProgress|null $progress */
$progress = $this->hasOne(LessonProgress::class)
->where('user_id', $userId)
->first();
return $progress;
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LessonProgress extends Model
{
protected $table = 'lesson_progress';
protected $fillable = [
'user_id',
'lesson_id',
'last_position_seconds',
'watched_percentage',
'completed_at',
];
protected function casts(): array
{
return [
'completed_at' => 'datetime',
];
}
public function lesson(): BelongsTo
{
return $this->belongsTo(Lesson::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Database\Factories\QuestionFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Question extends Model
{
/** @use HasFactory<QuestionFactory> */
use HasFactory;
protected $fillable = ['quiz_id', 'concept_id', 'body', 'explanation'];
public function quiz(): BelongsTo
{
return $this->belongsTo(Quiz::class);
}
public function concept(): BelongsTo
{
return $this->belongsTo(Concept::class);
}
public function options(): HasMany
{
return $this->hasMany(QuestionOption::class);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class QuestionOption extends Model
{
protected $fillable = ['question_id', 'body', 'is_correct'];
protected $casts = [
'is_correct' => 'boolean',
];
public function question(): BelongsTo
{
return $this->belongsTo(Question::class);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Database\Factories\QuizFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Quiz extends Model
{
/** @use HasFactory<QuizFactory> */
use HasFactory;
protected $fillable = ['lesson_id', 'title', 'trigger_seconds'];
public function lesson(): BelongsTo
{
return $this->belongsTo(Lesson::class);
}
public function questions(): HasMany
{
return $this->hasMany(Question::class);
}
public function attempts(): HasMany
{
return $this->hasMany(QuizAttempt::class);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class QuizAttempt extends Model
{
protected $fillable = ['user_id', 'quiz_id', 'score'];
public function quiz(): BelongsTo
{
return $this->belongsTo(Quiz::class);
}
public function answers(): HasMany
{
return $this->hasMany(Answer::class);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Database\Factories\SubjectFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Subject extends Model
{
/** @use HasFactory<SubjectFactory> */
use HasFactory;
protected $fillable = ['name', 'slug'];
public function courses(): HasMany
{
return $this->hasMany(Course::class);
}
public function concepts(): HasMany
{
return $this->hasMany(Concept::class);
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password', 'role', 'api_token', 'grade', 'track'])]
#[Hidden(['password', 'remember_token', 'api_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function isGuardian(): bool
{
return $this->role === 'guardian';
}
public function students(): BelongsToMany
{
return $this->belongsToMany(User::class, 'guardian_student', 'guardian_id', 'student_id');
}
public function guardians(): BelongsToMany
{
return $this->belongsToMany(User::class, 'guardian_student', 'student_id', 'guardian_id');
}
public function lessonProgress(): HasMany
{
return $this->hasMany(LessonProgress::class);
}
public function conceptMastery(): HasMany
{
return $this->hasMany(ConceptMastery::class);
}
public function aiReports(): HasMany
{
return $this->hasMany(AiReport::class);
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Providers;
use App\Contracts\AiService;
use App\Services\GeminiService;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
$this->app->bind(AiService::class, GeminiService::class);
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace App\Services;
use App\Contracts\AiService;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GeminiService implements AiService
{
private const ANALYSIS_SCHEMA_HINT = <<<'PROMPT'
أنت معلم خبير خاص بمنهج الثانوية العامة الأردني (التوجيهي). حلل بيانات أداء الطالب التالية
وأعد استجابة JSON فقط بالحقول التالية بالضبط:
{
"weak_concepts": [{"name": "...", "reason": "...", "severity": "high|medium|low"}],
"remedial_lesson_ids": [أرقام صحيحة],
"recommended_actions": ["..."],
"motivation_note": "رسالة تحفيزية قصيرة بالعربية",
"summary": "ملخص من سطرين بالعربية"
}
لا تكتب أي شيء خارج JSON.
PROMPT;
/**
* @param array<string, mixed> $performance
* @return array<string, mixed>
*
* @throws ConnectionException
*/
public function analyzeStudentPerformance(array $performance): array
{
$text = $this->generate(self::ANALYSIS_SCHEMA_HINT."\n\nبيانات الطالب:\n".json_encode($performance, JSON_UNESCAPED_UNICODE));
return $this->decodeJson($text);
}
/**
* @param array<string, mixed> $activity
* @return array<string, mixed>
*
* @throws ConnectionException
*/
public function generateParentWeeklyReport(array $activity): array
{
$prompt = <<<'PROMPT'
أنت مستشار تعليمي. اكتب تقريراً أسبوعياً لولي أمر طالب توجيهي بناءً على البيانات التالية.
أعد JSON فقط بهذا الشكل:
{"headline": "...", "progress_note": "...", "weak_points": ["..."], "home_advice": ["..."]}
استخدم العربية الواضحة دون مصطلحات تقنية.
PROMPT;
$text = $this->generate($prompt."\n\nبيانات النشاط:\n".json_encode($activity, JSON_UNESCAPED_UNICODE));
return $this->decodeJson($text);
}
/**
* @throws ConnectionException
*/
private function generate(string $prompt): string
{
$config = config('services.gemini');
$model = is_array($config) ? ($config['model'] ?? 'gemini-2.5-flash-lite') : 'gemini-2.5-flash-lite';
$response = Http::withHeaders([
'x-goog-api-key' => is_array($config) ? (string) $config['key'] : '',
])
->timeout(is_array($config) ? (int) ($config['timeout'] ?? 30) : 30)
->retry(2, 500)
->post(rtrim((string) (is_array($config) ? $config['base_url'] : ''), '/')."/models/{$model}:generateContent", [
'contents' => [
['parts' => [['text' => $prompt]]],
],
'generationConfig' => [
'temperature' => 0.4,
'responseMimeType' => 'application/json',
],
]);
if ($response->failed()) {
Log::warning('Gemini request failed', ['status' => $response->status(), 'body' => mb_substr($response->body(), 0, 500)]);
throw new ConnectionException('Gemini API request failed.');
}
/** @var array{candidates?: list<array{content?: array{parts?: list<array{text?: string}}>}}> $payload */
$payload = $response->json();
return $payload['candidates'][0]['content']['parts'][0]['text'] ?? '{}';
}
/**
* @return array<string, mixed>
*/
private function decodeJson(string $text): array
{
/** @var array<string, mixed> $decoded */
$decoded = json_decode(trim($text), true);
if (! is_array($decoded)) {
throw new \RuntimeException('Gemini returned invalid JSON payload.');
}
return $decoded;
}
}
+167
View File
@@ -0,0 +1,167 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Exception;
class NabehOtpService
{
protected string $authUrl;
protected string $sendUrl;
protected ?string $email;
protected ?string $password;
public function __construct()
{
$this->authUrl = config('services.nabeh.auth_url', 'https://nabeh.intaleqapp.com/api/auth/login');
$this->sendUrl = config('services.nabeh.send_url', 'https://nabeh.intaleqapp.com/api/otp/send');
$this->email = config('services.nabeh.email', env('NABEH_EMAIL'));
$this->password = config('services.nabeh.password', env('NABEH_PASSWORD'));
}
/**
* Retrieve Nabeh JWT Bearer Token, caching it in Redis for 24 hours.
*/
public function getBearerToken(): ?string
{
// 1. Try fetching from Redis first
try {
$cachedToken = Redis::get('nabeh_bearer_token');
if ($cachedToken) {
return (string)$cachedToken;
}
} catch (Exception $e) {
Log::warning("⚠️ [Nabeh Auth Redis] Error reading token: " . $e->getMessage());
}
// 2. Token not cached, authenticate via Nabeh Login API
if (!$this->email || !$this->password) {
Log::error("❌ [Nabeh Auth] Missing NABEH_EMAIL or NABEH_PASSWORD environment variables.");
return null;
}
try {
$response = Http::timeout(10)->post($this->authUrl, [
'email' => $this->email,
'password' => $this->password,
]);
if ($response->successful()) {
$decoded = $response->json();
$token = $decoded['token'] ?? $decoded['message']['token'] ?? $decoded['jwt'] ?? $decoded['access_token'] ?? null;
if ($token) {
// 3. Cache token in Redis for 24h
try {
Redis::setex('nabeh_bearer_token', 86400, (string)$token);
Log::info("[Nabeh Auth] Token cached in Redis successfully.");
} catch (Exception $e) {
Log::warning("⚠️ [Nabeh Auth Redis Cache Save] Error saving token: " . $e->getMessage());
}
return (string)$token;
}
Log::error("❌ [Nabeh Auth Login Failed] Response without token: " . $response->body());
} else {
Log::error("❌ [Nabeh Auth Login Failed] Status: " . $response->status() . " | Body: " . $response->body());
}
} catch (Exception $e) {
Log::error("❌ [Nabeh Auth Exception] " . $e->getMessage());
}
return null;
}
/**
* Send OTP via Nabeh JWT Auth Gateway (WhatsApp Image/Text OTP)
*
* @param string $receiver Recipient phone number
* @param string $otp 4-6 digit verification code
* @param string $method image | text | voice
* @param string $appName Application name to appear in the message
* @return bool
*/
public function sendOtp(string $receiver, string $otp, string $method = 'image', string $appName = 'منصة صَقِل'): bool
{
$bearerToken = $this->getBearerToken();
if (!$bearerToken) {
Log::error("⚠️ [Nabeh OTP] Failed to obtain dynamic JWT Bearer token.");
return false;
}
// Strip symbols from phone
$phoneRaw = preg_replace('/\D+/', '', $receiver);
// Type mapping (Image OTP card is default for Nabeh)
$type = in_array($method, ['text', 'voice', 'image'], true) ? $method : 'image';
// First attempt with the chosen type
$success = $this->attemptSend($phoneRaw, $type, $otp, $appName, $bearerToken);
if ($success) {
return true;
}
// Fallback: if image failed, retry with text
if ($type === 'image') {
Log::info("ℹ️ [Nabeh OTP Fallback] Image failed, retrying with text type...");
return $this->attemptSend($phoneRaw, 'text', $otp, $appName, $bearerToken);
}
return false;
}
/**
* Internal helper: single OTP send attempt to Nabeh
*/
protected function attemptSend(string $phone, string $type, string $otp, string $appName, string $bearerToken): bool
{
$payload = [
'phone' => $phone,
'type' => $type,
'code' => $otp,
'message' => "رمز التحقق الخاص بك لمنصة {$appName} هو: *{code}* \n الرجاء عدم مشاركته مع أي شخص لحماية حسابك.",
];
try {
$response = Http::withToken($bearerToken)
->timeout(10)
->post($this->sendUrl, $payload);
Log::info("ℹ️ [Nabeh OTP Response type={$type}] " . $response->body());
if ($response->successful()) {
$decoded = $response->json();
if ($decoded) {
$statusStr = strtolower((string)($decoded['status'] ?? ''));
$msgStr = strtolower((string)($decoded['message'] ?? ''));
$errStr = strtolower((string)($decoded['error'] ?? ''));
if (
!empty($decoded['success']) ||
in_array($statusStr, ['success', 'ok', 'true', '200', 'sent', 'queued', '1'], true) ||
($decoded['status'] ?? false) === true ||
($decoded['code'] ?? 0) === 200 ||
!empty($decoded['message_id']) ||
!empty($decoded['id']) ||
!empty($decoded['token']) ||
str_contains($msgStr, 'success') ||
str_contains($msgStr, 'sent') ||
str_contains($msgStr, 'تم') ||
str_contains($errStr, 'via gateway')
) {
return true;
}
}
Log::warning("⚠️ [Nabeh OTP Response not matching success] " . $response->body());
} else {
Log::error("❌ [Nabeh OTP Request Failed] Status: " . $response->status() . " | Body: " . $response->body());
}
} catch (Exception $e) {
Log::error("❌ [Nabeh OTP Exception] " . $e->getMessage());
}
return false;
}
}
@@ -0,0 +1,182 @@
<?php
namespace App\Services;
use App\Models\Answer;
use App\Models\ConceptMastery;
use App\Models\Lesson;
use App\Models\LessonProgress;
use App\Models\Question;
use App\Models\QuestionOption;
use App\Models\Quiz;
use App\Models\QuizAttempt;
use App\Models\User;
use Illuminate\Support\Collection;
class ProgressTrackingService
{
private const COMPLETION_THRESHOLD = 95.0;
private const MASTERY_DECAY = 0.7;
public function heartbeat(User $student, Lesson $lesson, int $positionSeconds): LessonProgress
{
$duration = max(1, $lesson->duration_seconds);
$position = max(0, min($positionSeconds, $lesson->duration_seconds));
$watched = round(($position / $duration) * 100, 2);
/** @var LessonProgress $progress */
$progress = LessonProgress::updateOrCreate(
['user_id' => $student->id, 'lesson_id' => $lesson->id],
[
'last_position_seconds' => $position,
'watched_percentage' => (int) min(100, $watched),
'completed_at' => $watched >= self::COMPLETION_THRESHOLD ? now() : null,
]
);
return $progress;
}
/**
* @param array<int, array{question_id: int, selected_option_id?: ?int, time_spent_seconds?: int}> $submittedAnswers
* @return array<string, mixed>
*/
public function recordQuizAttempt(User $student, Quiz $quiz, array $submittedAnswers): array
{
/** @var Collection<int, Question> $questions */
$questions = Question::with('options')
->where('quiz_id', $quiz->id)
->whereIn('id', collect($submittedAnswers)->pluck('question_id'))
->get()
->keyBy('id');
/** @var QuizAttempt $attempt */
$attempt = $quiz->attempts()->create([
'user_id' => $student->id,
'score' => 0,
]);
$correctCount = 0;
$results = [];
foreach ($submittedAnswers as $entry) {
/** @var Question|null $question */
$question = $questions->get($entry['question_id']);
if ($question === null) {
continue;
}
$selectedOptionId = $entry['selected_option_id'] ?? null;
/** @var QuestionOption|null $option */
$option = $selectedOptionId !== null
? $question->options->firstWhere('id', $selectedOptionId)
: null;
$isCorrect = $option !== null && $option->is_correct;
if ($isCorrect) {
$correctCount++;
}
$attempt->answers()->create([
'question_id' => $question->id,
'selected_option_id' => $option?->id,
'is_correct' => $isCorrect,
'time_spent_seconds' => $entry['time_spent_seconds'] ?? 0,
]);
if ($question->concept_id !== null) {
$this->updateMastery($student->id, (int) $question->concept_id, $isCorrect);
}
$results[] = [
'question_id' => $question->id,
'is_correct' => $isCorrect,
'explanation' => $question->explanation,
];
}
$total = count($results);
$score = $total > 0 ? (int) round(($correctCount / $total) * 100) : 0;
$attempt->update(['score' => $score]);
return [
'attempt_id' => $attempt->id,
'score' => $score,
'total_questions' => $total,
'correct_answers' => $correctCount,
'results' => $results,
];
}
private function updateMastery(int $userId, int $conceptId, bool $isCorrect): ConceptMastery
{
/** @var ConceptMastery $mastery */
$mastery = ConceptMastery::firstOrNew(['user_id' => $userId, 'concept_id' => $conceptId]);
if (! $mastery->exists) {
$mastery->correct_count = 0;
$mastery->wrong_count = 0;
$mastery->mastery_score = 0;
}
if ($mastery->correct_count + $mastery->wrong_count === 0) {
$mastery->mastery_score = $isCorrect ? 100 : 0;
} else {
$previous = (float) $mastery->mastery_score / 100;
$outcome = $isCorrect ? 1.0 : 0.0;
$mastery->mastery_score = round((($previous * self::MASTERY_DECAY) + ($outcome * (1 - self::MASTERY_DECAY))) * 100, 2);
}
$isCorrect ? $mastery->correct_count++ : $mastery->wrong_count++;
$mastery->save();
return $mastery;
}
/**
* Collect the student's aggregated performance snapshot for AI analysis.
*
* @return array<string, mixed>
*/
public function buildPerformanceSnapshot(User $student): array
{
$masteryRows = $student->conceptMastery()
->with('concept:id,name,remedial_lesson_id')
->orderBy('mastery_score')
->limit(15)
->get();
$recentWrongAnswers = Answer::query()
->where('is_correct', false)
->whereHas('attempt', fn ($query) => $query->where('user_id', $student->id))
->with('question:id,body,concept_id')
->latest()
->limit(10)
->get();
$progressStats = $student->lessonProgress()
->selectRaw('COUNT(*) as lessons_started, COALESCE(AVG(watched_percentage), 0) as avg_watched')
->first();
return [
'student_grade' => $student->grade,
'track' => $student->track,
'lessons_started' => (int) ($progressStats?->lessons_started ?? 0),
'average_watch_percentage' => round((float) ($progressStats?->avg_watched ?? 0), 1),
'concept_mastery' => $masteryRows->map(fn (ConceptMastery $row): array => [
'concept_id' => $row->concept_id,
'name' => $row->concept?->name,
'mastery_score' => (float) $row->mastery_score,
'correct' => $row->correct_count,
'wrong' => $row->wrong_count,
])->all(),
'recent_wrong_answers' => $recentWrongAnswers->map(fn (Answer $answer): array => [
'question_body' => $answer->question?->body,
])->all(),
];
}
}