'integer', 'started_at' => 'datetime', 'completed_at' => 'datetime', ]; public function __construct(array $attributes = []) { parent::__construct($attributes); $this->table = acadlix()->helper()->acadlix_table_prefix('course_enrollments'); } public function scopeOfActive($query) { return $query->where('status', self::STATUS_ACTIVE); } public function scopeOfSuspended($query) { return $query->where('status', self::STATUS_SUSPENDED); } public function scopeOfCompleted($query) { return $query->where('status', self::STATUS_COMPLETED); } public function scopeOfExpired($query) { return $query->where('status', self::STATUS_EXPIRED); } public function scopeOfStatus($query, $status) { return $query->where('status', $status); } /* ========================================================================= * BOOLEAN ACCESS HELPERS * ========================================================================= */ public function isActive(): bool { return $this->status === self::STATUS_ACTIVE; } public function isCompleted(): bool { return $this->status === self::STATUS_COMPLETED; } public function isSuspended(): bool { return $this->status === self::STATUS_SUSPENDED; } public function isExpired(): bool { return $this->status === self::STATUS_EXPIRED; } public function user() { return $this->belongsTo(acadlix()->model()->wpUsers(), 'user_id', 'ID'); } public function course() { return $this->belongsTo(acadlix()->model()->course(), 'course_id', 'ID'); } public function enrollmentSources() { return $this->hasMany(acadlix()->model()->enrollmentSource(), 'enrollment_id', 'id'); } /** * Toggle enrollment status between suspended and active/completed. * * @param int $userId Optional. Defaults to $this->user_id * @param int $courseId Optional. Defaults to $this->course_id * @return self|null */ public function toggleEnrollment(int $userId = 0, int $courseId = 0): ?self { // 1. Resolve target User ID and Course ID $targetUserId = $userId ?: $this->user_id; $targetCourseId = $courseId ?: $this->course_id; if (!$targetUserId || !$targetCourseId) { return null; } // 2. Resolve the enrollment record instance $enrollment = ($this->exists && $this->user_id == $targetUserId && $this->course_id == $targetCourseId) ? $this : self::where('user_id', $targetUserId)->where('course_id', $targetCourseId)->first(); if (!$enrollment) { return null; } $wpNow = Carbon::parse(current_time('mysql')); // 3. Toggle Logic if (in_array($enrollment->status, [self::STATUS_ACTIVE, self::STATUS_COMPLETED])) { // Active/Completed -> Suspend $newStatus = self::STATUS_SUSPENDED; } else { // Suspended/Expired -> Restore based on progress $newStatus = ($enrollment->progress_percent >= 100) ? self::STATUS_COMPLETED : self::STATUS_ACTIVE; } // 4. Update the DB record $updateData = [ 'status' => $newStatus, ]; // If restoring to completed and completed_at is not set, timestamp it if ($newStatus === self::STATUS_COMPLETED && !$enrollment->completed_at) { $updateData['completed_at'] = $wpNow; } $enrollment->update($updateData); // Sync instance state if called on an instantiated object $this->fill($enrollment->toArray()); $this->exists = true; return $this; } public function syncCompletion(int $userId = 0, int $courseId = 0): void { // 1. Resolve target User ID and Course ID from instance if omitted $targetUserId = $userId ?: $this->user_id; $targetCourseId = $courseId ?: $this->course_id; if (!$targetUserId || !$targetCourseId) { return; } // 2. Fetch or assign the target enrollment record $enrollment = ($this->exists && $this->user_id == $targetUserId && $this->course_id == $targetCourseId) ? $this : self::where('user_id', $targetUserId)->where('course_id', $targetCourseId)->first(); if (!$enrollment) { return; } // 3. Find course entity $course = acadlix()->model()->course()->ofCourse()->ofPublish()->find($targetCourseId); if (!$course) { return; } // 4. Calculate progress percentage $progressPercent = (int) $course->getCourseCompletionPercentage($targetUserId); // 5. Determine calculated status $newStatus = ($progressPercent >= 100) ? self::STATUS_COMPLETED : self::STATUS_ACTIVE; // Preserve suspended or expired status states $finalStatus = in_array($enrollment->status, [self::STATUS_ACTIVE, self::STATUS_COMPLETED]) ? $newStatus : $enrollment->status; // 6. Build payload $wpNow = Carbon::parse(current_time('mysql')); $updateData = [ 'progress_percent' => $progressPercent, 'status' => $finalStatus, ]; // If completing for the first time, record timestamp if ($finalStatus === self::STATUS_COMPLETED && !$enrollment->completed_at) { $updateData['completed_at'] = $wpNow; } // 7. Update database record $enrollment->update($updateData); // Sync instance attributes if operating on $this if ($this->id === $enrollment->id) { $this->fill($updateData); } } public function hasAccess(int $userId, int $courseId): bool { $wpNow = Carbon::parse(current_time('mysql')); $enrollmentTable = $this->getTable(); $enrollmentSourceTable = acadlix()->model()->enrollmentSource()->getTable(); // Check if a valid, unexpired enrollment source join exists return DB::table($enrollmentTable . ' as e') ->join($enrollmentSourceTable . ' as es', 'es.enrollment_id', '=', 'e.id') ->where('e.user_id', $userId) ->where('e.course_id', $courseId) ->whereIn('e.status', [self::STATUS_ACTIVE, self::STATUS_COMPLETED]) ->where(function ($query) use ($wpNow) { $query->whereNull('es.starts_at') ->orWhere('es.starts_at', '<=', $wpNow); }) ->where(function ($query) use ($wpNow) { $query->whereNull('es.expires_at') ->orWhere('es.expires_at', '>=', $wpNow); }) ->exists(); } public function enroll(int $userId, int $courseId, array $args = []): self|null { // 1. Fetch current WP localized time for strict data integrity writes $wpNow = Carbon::parse(current_time('mysql')); $course = acadlix()->model()->course()->find($courseId); $user = acadlix()->model()->wpUsers()->find($userId); if (!$course || !$user) { return null; } $convertToCarbon = function ($input) { if (empty($input)) return null; // If the string contains only numbers and is exactly 10 digits, treat it as a Unix timestamp if (is_numeric($input) && strlen((string) $input) === 10) { return Carbon::createFromTimestamp((int) $input); } // Otherwise, parse it as a standard datetime string return Carbon::parse($input); }; // 2. Extract operational arguments $sourceType = $args['source_type'] ?? 'order'; $sourceId = $args['source_id'] ?? null; $type = $args['type'] ?? 'course'; $itemId = $args['item_id'] ?? $courseId; // Default to the course itself if not specified $via = $args['via'] ?? "user"; $viaId = $args['via_id'] ?? $userId; // Default to the user themselves if not specified $startsAt = isset($args['starts_at']) ? $convertToCarbon($args['starts_at']) : null; $expiresAt = isset($args['expires_at']) ? $convertToCarbon($args['expires_at']) : null; $courseSectionContentId = $args['course_section_content_id'] ?? null; $startedAt = isset($args['started_at']) ? $convertToCarbon($args['started_at']) : null; $completedAt = isset($args['completed_at']) ? $convertToCarbon($args['completed_at']) : null; // 3. Look for an existing lifecycle row to perform the Scenario A update $enrollment = self::where('user_id', $userId) ->where('course_id', $courseId) ->first(); if ($enrollment) { $progress = isset($args['progress_percent']) ? (int) $args['progress_percent'] : $enrollment->progress_percent; } else { $progress = isset($args['progress_percent']) ? (int) $args['progress_percent'] : 0; } // 4. Determine Status based on Progress Percentage $calculatedStatus = ($progress >= 100) ? self::STATUS_COMPLETED : self::STATUS_ACTIVE; if ($enrollment) { $enrollment->update([ 'course_section_content_id' => $courseSectionContentId ?? $enrollment->course_section_content_id, 'progress_percent' => $progress, 'status' => $calculatedStatus, 'updated_at' => $wpNow ]); $enrollmentSource = acadlix()->model()->enrollmentSource() ->where('enrollment_id', $enrollment->id) ->where('source_type', $sourceType) ->where('source_id', $sourceId) ->first(); if ($enrollmentSource) { $enrollmentSource->update([ 'type' => $type, 'item_id' => $itemId, 'via' => $via, 'via_id' => $viaId, 'starts_at' => $startsAt, 'expires_at' => $expiresAt ]); } else { acadlix()->model()->enrollmentSource()->create([ 'enrollment_id' => $enrollment->id, 'source_type' => $sourceType, 'source_id' => $sourceId, 'type' => $type, 'item_id' => $itemId, 'via' => $via, 'via_id' => $viaId, 'starts_at' => $startsAt, 'expires_at' => $expiresAt ]); } // Sync the current instance with the updated database data $this->fill($enrollment->toArray()); $this->exists = true; return $this; } // 5. Fresh Enrollment Creation Flow $newEnrollment = self::create([ 'user_id' => $userId, 'course_id' => $courseId, 'course_section_content_id' => $courseSectionContentId, 'progress_percent' => $progress, 'status' => $calculatedStatus, 'started_at' => $startedAt, 'completed_at' => $completedAt, ]); // 6. Create the associated EnrollmentSource record acadlix()->model()->enrollmentSource()->create([ 'enrollment_id' => $newEnrollment->id, 'source_type' => $sourceType, 'source_id' => $sourceId, 'type' => $type, 'item_id' => $itemId, 'via' => $via, 'via_id' => $viaId, 'starts_at' => $startsAt, 'expires_at' => $expiresAt ]); // Sync instance state $this->fill($newEnrollment->toArray()); $this->exists = true; return $this; } public function unenroll(int $userId, int $courseId, array $args = []): ?self { // Find the active lifecycle row $enrollment = self::where('user_id', $userId) ->where('course_id', $courseId) ->first(); if (!$enrollment) { return null; } $enrollment->update([ 'status' => self::STATUS_SUSPENDED, ]); $this->fill($enrollment->toArray()); return $this; } public function getEnrolledCourseQuery(int $userId) { $wpNow = Carbon::parse(current_time('mysql')); return $this->where('user_id', $userId) ->whereIn('status', [self::STATUS_ACTIVE, self::STATUS_COMPLETED]) ->whereHas('enrollmentSources', function ($query) use ($wpNow) { $query->where(function ($query) use ($wpNow) { $query->whereNull('starts_at') ->orWhere('starts_at', '<=', $wpNow); }) ->where(function ($query) use ($wpNow) { $query->whereNull('expires_at') ->orWhere('expires_at', '>=', $wpNow); }); }) ->orderByDesc('created_at') ->orderByDesc('id'); } public function getEnrolledCourses( int $userId, $search = null, $skip = 0, $take = 10, $with = [], $categoryId = null ): array { if (empty($userId)) { return [ 'total' => 0, 'courses' => [], 'category_ids' => [] ]; } // 1. Initialize the pristine base query for this user $baseQuery = $this->getEnrolledCourseQuery($userId); // 2. Fetch ALL enrolled course IDs for this user to get their total category footprint // Cloning ensures we don't mutate the query instance we need for pagination later $allEnrolledCourseIds = (clone $baseQuery)->pluck('course_id')->unique()->toArray(); // 3. Fetch ALL category IDs based on the complete list of enrolled courses $categoryIds = []; if (!empty($allEnrolledCourseIds)) { $termRelationshipsTable = acadlix()->model()->wpTermRelationship()->getTable(); $categoryIds = DB::table($termRelationshipsTable) ->whereIn('object_id', $allEnrolledCourseIds) ->pluck('term_taxonomy_id') ->unique() ->values() ->toArray(); } // 4. Now handle the main query filters (Search & Category) $coursesQuery = $baseQuery; if (!empty($search)) { $coursesQuery->whereHas('course', function ($q) use ($search) { $q->where('post_title', 'like', '%' . $search . '%'); }); } if (!empty($categoryId)) { $coursesQuery->whereHas('course', function ($q) use ($categoryId) { $q->whereHas('course_categories', function ($catQ) use ($categoryId) { $catQ->where('term_id', $categoryId); }); }); } // 5. Get the filtered total count for pagination $total = $coursesQuery->count(); // 6. Fetch the paginated, filtered slice of courses if ($take > 0) { $coursesQuery->skip($skip)->take($take); } $courses = $coursesQuery->with($with)->get(); return [ 'total' => $total, 'courses' => $courses, 'category_ids' => $categoryIds ]; } public function getEnrolledUserQuery(int $courseId) { $wpNow = Carbon::parse(current_time('mysql')); return $this->where('course_id', $courseId) ->whereIn('status', [self::STATUS_ACTIVE, self::STATUS_COMPLETED]) ->whereHas('enrollmentSources', function ($query) use ($wpNow) { $query->where(function ($query) use ($wpNow) { $query->whereNull('starts_at') ->orWhere('starts_at', '<=', $wpNow); }) ->where(function ($query) use ($wpNow) { $query->whereNull('expires_at') ->orWhere('expires_at', '>=', $wpNow); }); }) ->orderByDesc('created_at') ->orderByDesc('id'); } public function getEnrolledUsers( int $courseId, $search = null, $skip = 0, $take = 10, $with = [], ): array { if (empty($courseId)) { return [ 'total' => 0, 'users' => [], ]; } // 1. Initialize the pristine base query for this user $userQuery = $this->getEnrolledUserQuery($courseId); if (!empty($search)) { $userQuery->whereHas('user', function ($q) use ($search) { $q->where('display_name', 'like', '%' . $search . '%'); }); } $total = $userQuery->count(); $users = $userQuery->with($with)->get(); if ($take > 0) { $users = $userQuery->with($with)->skip($skip)->take($take)->get(); } return [ 'total' => $total, 'users' => $users, ]; } public function getPopularCourses($limit = 5, $with = []): array|null|object { $wpNow = Carbon::parse(current_time('mysql')); $popularCoursesQuery = $this->whereIn('status', [self::STATUS_ACTIVE, self::STATUS_COMPLETED]) ->whereHas('enrollmentSources', function ($query) use ($wpNow) { $query->where(function ($query) use ($wpNow) { $query->whereNull('starts_at') ->orWhere('starts_at', '<=', $wpNow); }) ->where(function ($query) use ($wpNow) { $query->whereNull('expires_at') ->orWhere('expires_at', '>=', $wpNow); }); }) ->select('course_id', DB::raw('COUNT(*) as enrollment_count')) ->groupBy('course_id') ->orderByDesc('enrollment_count'); if ($limit > 0) { $popularCoursesQuery->with($with)->take($limit); } return $popularCoursesQuery->get(); } public function delete() { // Delete associated enrollment sources first $this->enrollmentSources()->delete(); // Then delete the enrollment itself return parent::delete(); } } }