EscolaLMS/Courses

View on GitHub
src/Services/ProgressService.php

Summary

Maintainability
A
2 hrs
Test Coverage
A
92%
<?php

namespace EscolaLms\Courses\Services;

use EscolaLms\Core\Dtos\OrderDto;
use EscolaLms\Core\Models\User;
use EscolaLms\Courses\Enum\CourseStatusEnum;
use EscolaLms\Courses\Enum\ProgressFilterEnum;
use EscolaLms\Courses\Enum\ProgressStatus;
use EscolaLms\Courses\Events\CourseAccessFinished;
use EscolaLms\Courses\Events\CourseAccessStarted;
use EscolaLms\Courses\Events\CourseFinished;
use EscolaLms\Courses\Events\CourseStarted;
use EscolaLms\Courses\Models\Course;
use EscolaLms\Courses\Models\Group;
use EscolaLms\Courses\Models\H5PUserProgress;
use EscolaLms\Courses\Models\Topic;
use EscolaLms\Courses\Models\User as CoursesUser;
use EscolaLms\Courses\Repositories\Contracts\CourseH5PProgressRepositoryContract;
use EscolaLms\Courses\Services\Contracts\ProgressServiceContract;
use EscolaLms\Courses\ValueObjects\CourseProgressCollection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

class ProgressService implements ProgressServiceContract
{
    private CourseH5PProgressRepositoryContract $courseH5PProgressContract;

    public function __construct(
        CourseH5PProgressRepositoryContract $courseH5PProgressContract
    )
    {
        $this->courseH5PProgressContract = $courseH5PProgressContract;
    }

    public function getByUser(User $user): Collection
    {
        $progresses = new Collection();
        if (!$user instanceof CoursesUser) {
            /** @var CoursesUser $user */
            $user = CoursesUser::find($user->getKey());
        }

        $courses = $user->courses()->where('status', '=', CourseStatusEnum::PUBLISHED)->get();
        /** @var Course $course */
        foreach ($courses as $course) {
            $progresses->push(CourseProgressCollection::make($user, $course));
        }

        $groups = $user->groups->merge($this->getParentGroups($user->groups));
        foreach ($groups as $group) {
            if (!$group instanceof Group) {
                $group = Group::find($group->getKey());
            }

            $courses = $group->courses()->where('status', '=', CourseStatusEnum::PUBLISHED)->get();
            foreach ($courses as $course) {
                if (!$progresses->contains(fn(CourseProgressCollection $collection) => $collection->getCourse()->getKey() === $course->getKey())) {
                    $progresses->push(CourseProgressCollection::make($user, $course));
                }
            }
        }

        return $progresses
            ->sortByDesc(fn(CourseProgressCollection $collection) => $collection->getCourse()->pivot->created_at)
            ->values();
    }

    public function getByUserPaginated(User $user, ?OrderDto $orderDto = null, ?int $perPage = 20, ?string $filter = null): LengthAwarePaginator
    {
        $userId = $user->getKey();
        $progresses = new Collection();

        $query = $this->getBaseQuery($userId);
        $query = $this->applyFilters($query, $userId, $filter);
        $query = $this->orderQuery($query, $orderDto);

        $courses = $query->paginate($perPage);

        foreach ($courses as $course) {
            $progresses->push(CourseProgressCollection::make($user, $course));
        }

        return new LengthAwarePaginator(
            $progresses->values(),
            $courses->total(),
            $courses->perPage(),
            $courses->currentPage(),
            ['path' => $courses->path()]
        );
    }

    public function update(Course $course, User $user, array $progress): CourseProgressCollection
    {
        $courseProgressCollection = CourseProgressCollection::make($user, $course);

        if ($courseProgressCollection->courseCanBeProgressed()) {
            if ($courseProgressCollection->getProgress()->count() === 0) {
                event(new CourseAccessStarted($user, $course));
                event(new CourseStarted($user, $course));
            }
            if (!empty($progress)) {
                $courseProgressCollection->setProgress($progress);
            }

            if (!$user instanceof CoursesUser) {
                $user = CoursesUser::find($user->getKey());
            }

            assert($user instanceof CoursesUser);

            $courseIsFinished = $courseProgressCollection->isFinished();
            $userHasCourseMarkedAsFinished = $user->finishedCourse($course->getKey());

            if ($courseIsFinished && !$userHasCourseMarkedAsFinished) {
                $user->courses()->updateExistingPivot($course->getKey(), ['finished' => true]);
                event(new CourseAccessFinished($user, $courseProgressCollection->getCourse()));
                event(new CourseFinished($user, $courseProgressCollection->getCourse()));
            } elseif (!$courseIsFinished && $userHasCourseMarkedAsFinished) {
                $user->courses()->updateExistingPivot($course->getKey(), ['finished' => false]);
            }
        }

        return $courseProgressCollection;
    }

    public function ping(User $user, Topic $topic): CourseProgressCollection
    {
        $course = $topic->course;

        $courseProgressCollection = CourseProgressCollection::make($user, $course);

        if ($courseProgressCollection->topicCanBeProgressed($topic)) {
            $courseProgressCollection->ping($topic);

            if (!$user instanceof CoursesUser) {
                /** @var CoursesUser $user */
                $user = CoursesUser::find($user->getKey());
            }
            if (!$courseProgressCollection->isFinished() && $user->finishedCourse($course->getKey())) {
                $user->courses()->updateExistingPivot($course->getKey(), ['finished' => false]);
            }
        }

        return $courseProgressCollection;
    }

    public function h5p(User $user, Topic $topic, string $event, $json): ?H5PUserProgress
    {
        $courseProgressCollection = CourseProgressCollection::make($user, $topic->course);

        if ($courseProgressCollection->topicCanBeProgressed($topic)) {
            return $this->courseH5PProgressContract->store($topic, $user, $event, $json);
        }
        return null;
    }

    private function getBaseQuery(int $userId): Builder
    {
        return Course::dontCache()
            ->leftJoinSub('SELECT course_id, MAX(created_at) as user_pivot_created_at FROM course_user GROUP BY course_id', 'course_user', function ($join) {
                $join->on('courses.id', '=', 'course_user.course_id');
            })
            ->leftJoinSub('SELECT course_id, MAX(created_at) as group_pivot_created_at FROM course_group GROUP BY course_id', 'course_group', function ($join) {
                $join->on('courses.id', '=', 'course_group.course_id');
            })
            ->where(function (Builder $query) use ($userId) {
                $query
                    ->whereHas('users', function (Builder $query) use ($userId) {
                        $query->where('users.id', $userId);
                    })
                    ->orWhereHas('groups', function (Builder $query) use ($userId) {
                        $query->whereHas('users', function (Builder $query) use ($userId) {
                            $query->where('users.id', $userId);
                        });
                    });
            });
    }

    private function orderQuery(Builder $query, ?OrderDto $orderDto = null): Builder
    {
        $order = $orderDto->getOrder() ?? 'desc';

        if ($orderDto->getOrderBy() && $orderDto->getOrderBy() !== 'obtained') {
            return $query->orderBy($orderDto->getOrderBy(), $order);
        } else {
            if (DB::connection()->getDriverName() === 'pgsql') {
                $order = $order === 'desc' ? $order . ' NULLS LAST' : $order . ' NULLS FIRST';
            }
            return $query->orderByRaw("LEAST(COALESCE(user_pivot_created_at, group_pivot_created_at), COALESCE(group_pivot_created_at, user_pivot_created_at)) $order");
        }
    }

    private function applyFilters(Builder $query, int $userId, ?string $filter = null): Builder
    {
        return match ($filter) {
            ProgressFilterEnum::STARTED => $this->filterForStartedCourses($query, $userId),
            ProgressFilterEnum::FINISHED => $this->filterForFinishedCourses($query, $userId),
            ProgressFilterEnum::PLANNED => $this->filterForPlannedCourses($query, $userId),
            default => $query,
        };
    }

    private function filterForPlannedCourses(Builder $query, int $userId): Builder
    {
        return $query
            ->where(function (Builder $query) use ($userId) {
                $query
                    ->whereDoesntHave('topics.progress')
                    ->orWhereNotExists(function (QueryBuilder $query) use ($userId) {
                        $query->select(DB::raw(1))
                            ->from('topics')
                            ->join('lessons', 'lessons.id', '=', 'topics.lesson_id')
                            ->join('course_progress', 'topics.id', '=', 'course_progress.topic_id')
                            ->whereColumn('lessons.course_id', 'courses.id')
                            ->where('course_progress.user_id', $userId)
                            ->whereIn('course_progress.status', [ProgressStatus::COMPLETE, ProgressStatus::IN_PROGRESS]);
                    });
            });
    }

    private function filterForStartedCourses(Builder $query, int $userId): Builder
    {
        return $query
            ->whereExists(function (QueryBuilder $query) use ($userId) {
                $query->select(DB::raw(1))
                    ->from('topics')
                    ->join('lessons', 'lessons.id', '=', 'topics.lesson_id')
                    ->leftJoin('course_progress', function (JoinClause $join) use ($userId) {
                        $join->on('topics.id', '=', 'course_progress.topic_id')
                            ->where('course_progress.user_id', $userId);
                    })
                    ->whereColumn('lessons.course_id', 'courses.id')
                    ->where('course_progress.status', ProgressStatus::IN_PROGRESS)
                    ->orWhere(function (QueryBuilder $query) {
                        $query->groupBy('topic.id')
                            ->selectRaw('COUNT(CASE WHEN status = ? THEN 1 END) as completed_count', [ProgressStatus::COMPLETE])
                            ->selectRaw('COUNT(CASE WHEN status = ? THEN 1 END) as in_completed_count', [ProgressStatus::INCOMPLETE])
                            ->havingRaw('completed_count > 0 AND in_completed_count > 0');
                    });
            });
    }

    private function filterForFinishedCourses(Builder $query, int $userId): Builder
    {
        return $query
            ->whereNotExists(function (QueryBuilder $query) use ($userId) {
                $query->select(DB::raw(1))
                    ->from('topics')
                    ->join('lessons', 'lessons.id', '=', 'topics.lesson_id')
                    ->where('lessons.active', true)
                    ->where('topics.active', true)
                    ->whereColumn('courses.id', 'lessons.course_id')
                    ->join('course_progress', 'topics.id', '=', 'course_progress.topic_id')
                    ->where('course_progress.user_id', $userId)
                    ->whereNull('course_progress.finished_at');
            });
    }

    private function getParentGroups(Collection $groups): Collection
    {
        $childGroups = Group::query()->whereIn('id', $groups->pluck('parent_id')->unique())->get();
        if ($childGroups->count() > 0) {
            $childGroups = $childGroups->merge($this->getParentGroups($childGroups));
        }

        return $childGroups;
    }
}