chore: commit remaining workspace changes
This commit is contained in:
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Academy;
|
||||
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyCourseLesson;
|
||||
use App\Models\AcademyAiComparisonResult;
|
||||
use App\Models\AcademyChallenge;
|
||||
use App\Models\AcademyLesson;
|
||||
@@ -53,15 +55,31 @@ final class AcademyAccessService
|
||||
return $this->canAccessContent($user, (string) $challenge->access_level);
|
||||
}
|
||||
|
||||
public function lessonPayload(AcademyLesson $lesson, ?User $viewer, bool $includeFull = false): array
|
||||
public function canAccessCourseLesson(?User $user, AcademyCourseLesson $courseLesson): bool
|
||||
{
|
||||
$authorized = $this->canAccessLesson($viewer, $lesson);
|
||||
$fullContent = (string) ($lesson->content ?? '');
|
||||
$accessLevel = trim((string) ($courseLesson->access_override ?: $courseLesson->lesson?->access_level ?: 'free'));
|
||||
|
||||
if ($accessLevel === 'premium') {
|
||||
return $user?->isAdmin() ?? false;
|
||||
}
|
||||
|
||||
return $this->canAccessContent($user, $accessLevel === 'mixed' ? 'free' : $accessLevel);
|
||||
}
|
||||
|
||||
public function lessonPayload(AcademyLesson $lesson, ?User $viewer, bool $includeFull = false, ?bool $authorizedOverride = null): array
|
||||
{
|
||||
$authorized = $authorizedOverride ?? $this->canAccessLesson($viewer, $lesson);
|
||||
$fullContent = $this->injectHeadingIds((string) ($lesson->content ?? ''));
|
||||
|
||||
return [
|
||||
'id' => (int) $lesson->id,
|
||||
'title' => (string) $lesson->title,
|
||||
'slug' => (string) $lesson->slug,
|
||||
'lesson_number' => $lesson->lesson_number,
|
||||
'formatted_lesson_number' => $lesson->formatted_lesson_number,
|
||||
'course_order' => $lesson->course_order,
|
||||
'series_name' => (string) ($lesson->series_name ?? ''),
|
||||
'lesson_label' => $lesson->lesson_label,
|
||||
'excerpt' => (string) ($lesson->excerpt ?? ''),
|
||||
'content' => ($authorized && $includeFull) ? $fullContent : null,
|
||||
'content_preview' => $authorized ? null : $this->previewText($fullContent, 360),
|
||||
@@ -70,6 +88,9 @@ final class AcademyAccessService
|
||||
'lesson_type' => (string) $lesson->lesson_type,
|
||||
'cover_image' => $lesson->cover_image,
|
||||
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($lesson->cover_image ?? '')),
|
||||
'article_cover_image' => $lesson->article_cover_image,
|
||||
'article_cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($lesson->article_cover_image ?? '')),
|
||||
'tags' => array_values((array) ($lesson->tags ?? [])),
|
||||
'video_url' => $authorized ? $lesson->video_url : null,
|
||||
'reading_minutes' => (int) $lesson->reading_minutes,
|
||||
'featured' => (bool) $lesson->featured,
|
||||
@@ -88,6 +109,66 @@ final class AcademyAccessService
|
||||
];
|
||||
}
|
||||
|
||||
public function coursePayload(AcademyCourse $course, ?User $viewer, array $options = []): array
|
||||
{
|
||||
$progress = is_array($options['progress'] ?? null) ? $options['progress'] : null;
|
||||
$lessonCount = (int) ($course->lessons_count_cache ?: $course->courseLessons()->count());
|
||||
|
||||
return [
|
||||
'id' => (int) $course->id,
|
||||
'title' => (string) $course->title,
|
||||
'slug' => (string) $course->slug,
|
||||
'subtitle' => (string) ($course->subtitle ?? ''),
|
||||
'excerpt' => (string) ($course->excerpt ?? ''),
|
||||
'description' => (string) ($course->description ?? ''),
|
||||
'cover_image' => (string) ($course->cover_image ?? ''),
|
||||
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($course->cover_image ?? '')),
|
||||
'teaser_image' => (string) ($course->teaser_image ?? ''),
|
||||
'teaser_image_url' => $this->resolveLessonCoverImageUrl((string) ($course->teaser_image ?? '')),
|
||||
'access_level' => (string) $course->access_level,
|
||||
'difficulty' => (string) $course->difficulty,
|
||||
'status' => (string) $course->status,
|
||||
'is_featured' => (bool) $course->is_featured,
|
||||
'order_num' => (int) ($course->order_num ?? 0),
|
||||
'estimated_minutes' => (int) ($course->estimated_minutes ?? 0),
|
||||
'lessons_count' => $lessonCount,
|
||||
'published_at' => $course->published_at?->toISOString(),
|
||||
'public_url' => route('academy.courses.show', ['course' => $course->slug]),
|
||||
'progress' => $progress ? [
|
||||
'completedRequired' => (int) ($progress['completed_required'] ?? 0),
|
||||
'totalRequired' => (int) ($progress['total_required'] ?? 0),
|
||||
'percent' => (int) ($progress['progress_percent'] ?? 0),
|
||||
'completed' => (bool) ($progress['completed'] ?? false),
|
||||
] : null,
|
||||
'continue_url' => $viewer ? $course->getContinueUrl($viewer) : route('academy.courses.show', ['course' => $course->slug]),
|
||||
];
|
||||
}
|
||||
|
||||
public function courseLessonPayload(AcademyCourseLesson $courseLesson, ?User $viewer, bool $includeFull = false, array $options = []): array
|
||||
{
|
||||
$lesson = $courseLesson->lesson;
|
||||
|
||||
if (! $lesson instanceof AcademyLesson) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$authorized = $this->canAccessCourseLesson($viewer, $courseLesson);
|
||||
$payload = $this->lessonPayload($lesson, $viewer, $includeFull, $authorized);
|
||||
|
||||
$payload['course_lesson_id'] = (int) $courseLesson->id;
|
||||
$payload['course_id'] = (int) $courseLesson->course_id;
|
||||
$payload['section_id'] = $courseLesson->section_id ? (int) $courseLesson->section_id : null;
|
||||
$payload['order_num'] = (int) ($courseLesson->order_num ?? 0);
|
||||
$payload['is_required'] = (bool) $courseLesson->is_required;
|
||||
$payload['access_override'] = $courseLesson->access_override;
|
||||
$payload['course_step_number'] = (int) ($options['course_step_number'] ?? 0);
|
||||
$payload['course_step_label'] = (string) ($options['course_step_label'] ?? '');
|
||||
$payload['completed'] = in_array((int) $courseLesson->lesson_id, array_map('intval', (array) ($options['completed_lesson_ids'] ?? [])), true);
|
||||
$payload['course_url'] = route('academy.courses.lessons.show', ['course' => $courseLesson->course->slug, 'lesson' => $lesson->slug]);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public function promptPayload(AcademyPromptTemplate $prompt, ?User $viewer, bool $includeFull = false): array
|
||||
{
|
||||
$authorized = $this->canAccessPrompt($viewer, $prompt);
|
||||
@@ -106,7 +187,7 @@ final class AcademyAccessService
|
||||
'access_level' => (string) $prompt->access_level,
|
||||
'aspect_ratio' => $prompt->aspect_ratio,
|
||||
'tags' => array_values((array) ($prompt->tags ?? [])),
|
||||
'tool_notes' => $authorized ? (array) ($prompt->tool_notes ?? []) : [],
|
||||
'tool_notes' => $authorized ? $this->promptToolNotesPayload((array) ($prompt->tool_notes ?? [])) : [],
|
||||
'preview_image' => $this->resolvePreviewImageUrl((string) ($prompt->preview_image ?? '')),
|
||||
'featured' => (bool) $prompt->featured,
|
||||
'prompt_of_week' => (bool) $prompt->prompt_of_week,
|
||||
@@ -121,6 +202,48 @@ final class AcademyAccessService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $notes
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function promptToolNotesPayload(array $notes): array
|
||||
{
|
||||
return collect($notes)
|
||||
->filter(static fn ($note): bool => is_array($note))
|
||||
->map(function (array $note): array {
|
||||
return [
|
||||
'provider' => trim((string) ($note['provider'] ?? '')),
|
||||
'model_name' => trim((string) ($note['model_name'] ?? '')),
|
||||
'notes' => trim((string) ($note['notes'] ?? '')),
|
||||
'strengths' => trim((string) ($note['strengths'] ?? '')),
|
||||
'weaknesses' => trim((string) ($note['weaknesses'] ?? '')),
|
||||
'best_for' => trim((string) ($note['best_for'] ?? '')),
|
||||
'image_path' => trim((string) ($note['image_path'] ?? '')),
|
||||
'image_url' => $this->resolveLessonMediaUrl((string) ($note['image_path'] ?? '')),
|
||||
'thumb_path' => trim((string) ($note['thumb_path'] ?? '')),
|
||||
'thumb_url' => $this->resolveLessonMediaUrl((string) ($note['thumb_path'] ?? '')),
|
||||
'settings' => trim((string) ($note['settings'] ?? '')),
|
||||
'score' => filled($note['score'] ?? null) ? (int) $note['score'] : null,
|
||||
'active' => filter_var($note['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
|
||||
];
|
||||
})
|
||||
->filter(function (array $note): bool {
|
||||
return collect([
|
||||
$note['provider'],
|
||||
$note['model_name'],
|
||||
$note['notes'],
|
||||
$note['strengths'],
|
||||
$note['weaknesses'],
|
||||
$note['best_for'],
|
||||
$note['image_path'],
|
||||
$note['thumb_path'],
|
||||
$note['settings'],
|
||||
])->contains(fn (string $value): bool => $value !== '') || $note['score'] !== null;
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function packPayload(AcademyPromptPack $pack, ?User $viewer, bool $includePrompts = false): array
|
||||
{
|
||||
$authorized = $this->canAccessPack($viewer, $pack);
|
||||
@@ -190,6 +313,7 @@ final class AcademyAccessService
|
||||
{
|
||||
return match (Str::lower(trim($accessLevel))) {
|
||||
'admin' => 99,
|
||||
'premium' => 40,
|
||||
'pro' => 30,
|
||||
'creator' => 20,
|
||||
default => 10,
|
||||
@@ -322,4 +446,44 @@ final class AcademyAccessService
|
||||
'comparison_results' => $results,
|
||||
];
|
||||
}
|
||||
|
||||
private function injectHeadingIds(string $html): string
|
||||
{
|
||||
if ($html === '') {
|
||||
return $html;
|
||||
}
|
||||
|
||||
$seenIds = [];
|
||||
|
||||
return preg_replace_callback(
|
||||
'/<(h[23])([^>]*)>(.*?)<\/\1>/si',
|
||||
function (array $m) use (&$seenIds): string {
|
||||
[, $tag, $attrs, $inner] = $m;
|
||||
|
||||
if (preg_match('/\bid\s*=/i', $attrs)) {
|
||||
return $m[0];
|
||||
}
|
||||
|
||||
$textContent = strip_tags($inner);
|
||||
$baseId = $this->slugifyHeading($textContent);
|
||||
$count = $seenIds[$baseId] ?? 0;
|
||||
$id = $count > 0 ? $baseId.'-'.($count + 1) : $baseId;
|
||||
$seenIds[$baseId] = $count + 1;
|
||||
|
||||
return "<{$tag} id=\"{$id}\"{$attrs}>{$inner}</{$tag}>";
|
||||
},
|
||||
$html
|
||||
) ?? $html;
|
||||
}
|
||||
|
||||
private function slugifyHeading(string $text): string
|
||||
{
|
||||
$slug = mb_strtolower($text);
|
||||
$slug = preg_replace('/[^a-z0-9_\s\-]/', '', $slug) ?? '';
|
||||
$slug = preg_replace('/\s+/', '-', trim($slug)) ?? '';
|
||||
$slug = preg_replace('/-+/', '-', $slug) ?? '';
|
||||
$slug = trim($slug, '-');
|
||||
|
||||
return $slug !== '' ? $slug : 'section';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Services\Academy;
|
||||
|
||||
use App\Models\AcademyCategory;
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyChallenge;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Models\AcademyPromptTemplate;
|
||||
@@ -14,6 +15,8 @@ final class AcademyCacheService
|
||||
{
|
||||
private const HOME_KEY = 'academy.home';
|
||||
private const FEATURED_LESSONS_KEY = 'academy.featured_lessons';
|
||||
private const FEATURED_COURSES_KEY = 'academy.featured_courses';
|
||||
private const PUBLISHED_COURSES_KEY = 'academy.published_courses';
|
||||
private const FEATURED_PROMPTS_KEY = 'academy.featured_prompts';
|
||||
private const FEATURED_CHALLENGES_KEY = 'academy.featured_challenges';
|
||||
private const CATEGORIES_KEY = 'academy.categories';
|
||||
@@ -30,12 +33,32 @@ final class AcademyCacheService
|
||||
->active()
|
||||
->published()
|
||||
->where('featured', true)
|
||||
->latest('published_at')
|
||||
->orderedForCourse()
|
||||
->limit(6)
|
||||
->get()
|
||||
->all());
|
||||
}
|
||||
|
||||
public function featuredCourses(): array
|
||||
{
|
||||
return Cache::remember(self::FEATURED_COURSES_KEY, $this->ttl(), static fn (): array => AcademyCourse::query()
|
||||
->published()
|
||||
->featured()
|
||||
->ordered()
|
||||
->limit(6)
|
||||
->get()
|
||||
->all());
|
||||
}
|
||||
|
||||
public function publishedCourses(): array
|
||||
{
|
||||
return Cache::remember(self::PUBLISHED_COURSES_KEY, $this->ttl(), static fn (): array => AcademyCourse::query()
|
||||
->published()
|
||||
->ordered()
|
||||
->get()
|
||||
->all());
|
||||
}
|
||||
|
||||
public function featuredPrompts(): array
|
||||
{
|
||||
return Cache::remember(self::FEATURED_PROMPTS_KEY, $this->ttl(), static fn (): array => AcademyPromptTemplate::query()
|
||||
@@ -79,6 +102,8 @@ final class AcademyCacheService
|
||||
{
|
||||
Cache::forget(self::HOME_KEY);
|
||||
Cache::forget(self::FEATURED_LESSONS_KEY);
|
||||
Cache::forget(self::FEATURED_COURSES_KEY);
|
||||
Cache::forget(self::PUBLISHED_COURSES_KEY);
|
||||
Cache::forget(self::FEATURED_PROMPTS_KEY);
|
||||
Cache::forget(self::FEATURED_CHALLENGES_KEY);
|
||||
|
||||
|
||||
52
app/Services/Academy/AcademyCourseLessonOrderingService.php
Normal file
52
app/Services/Academy/AcademyCourseLessonOrderingService.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Academy;
|
||||
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyCourseLesson;
|
||||
|
||||
final class AcademyCourseLessonOrderingService
|
||||
{
|
||||
public function syncCourse(AcademyCourse|int $course): void
|
||||
{
|
||||
$courseId = $course instanceof AcademyCourse ? (int) $course->id : (int) $course;
|
||||
|
||||
if ($courseId < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
AcademyCourseLesson::query()
|
||||
->with('lesson:id,lesson_number,course_order')
|
||||
->where('course_id', $courseId)
|
||||
->orderBy('order_num')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->values()
|
||||
->each(function (AcademyCourseLesson $courseLesson, int $index): void {
|
||||
$pivotOrder = $index;
|
||||
$lessonOrder = $index + 1;
|
||||
|
||||
if ((int) ($courseLesson->order_num ?? 0) !== $pivotOrder) {
|
||||
$courseLesson->forceFill([
|
||||
'order_num' => $pivotOrder,
|
||||
])->save();
|
||||
}
|
||||
|
||||
if ($courseLesson->lesson === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) ($courseLesson->lesson->course_order ?? 0) === $lessonOrder
|
||||
&& (int) ($courseLesson->lesson->lesson_number ?? 0) === $lessonOrder) {
|
||||
return;
|
||||
}
|
||||
|
||||
$courseLesson->lesson->forceFill([
|
||||
'course_order' => $lessonOrder,
|
||||
'lesson_number' => $lessonOrder,
|
||||
])->save();
|
||||
});
|
||||
}
|
||||
}
|
||||
72
app/Services/Academy/AcademyCourseNavigationService.php
Normal file
72
app/Services/Academy/AcademyCourseNavigationService.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Academy;
|
||||
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyCourseLesson;
|
||||
use App\Models\AcademyLesson;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class AcademyCourseNavigationService
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, AcademyCourseLesson>
|
||||
*/
|
||||
public function orderedCourseLessons(AcademyCourse $course): Collection
|
||||
{
|
||||
return $course->courseLessons()
|
||||
->with(['section', 'lesson.category'])
|
||||
->get()
|
||||
->filter(fn (AcademyCourseLesson $courseLesson): bool => $courseLesson->lesson instanceof AcademyLesson
|
||||
&& (bool) $courseLesson->lesson->active
|
||||
&& $courseLesson->lesson->published_at !== null
|
||||
&& $courseLesson->lesson->published_at->lte(now()))
|
||||
->sort(fn (AcademyCourseLesson $left, AcademyCourseLesson $right): int => $this->courseLessonSortKey($left) <=> $this->courseLessonSortKey($right))
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function courseLessonSortKey(AcademyCourseLesson $courseLesson): array
|
||||
{
|
||||
$section = $courseLesson->section;
|
||||
|
||||
return [
|
||||
$courseLesson->section_id === null ? 0 : 1,
|
||||
(int) ($section?->order_num ?? 0),
|
||||
(int) ($section?->id ?? 0),
|
||||
(int) ($courseLesson->order_num ?? 0),
|
||||
(int) $courseLesson->id,
|
||||
];
|
||||
}
|
||||
|
||||
public function firstPublishedLesson(AcademyCourse $course): ?AcademyCourseLesson
|
||||
{
|
||||
return $this->orderedCourseLessons($course)->first();
|
||||
}
|
||||
|
||||
public function findCourseLesson(AcademyCourse $course, AcademyLesson $lesson): ?AcademyCourseLesson
|
||||
{
|
||||
return $this->orderedCourseLessons($course)
|
||||
->first(fn (AcademyCourseLesson $courseLesson): bool => $courseLesson->lesson?->is($lesson) ?? false);
|
||||
}
|
||||
|
||||
public function nextLesson(AcademyCourse $course, AcademyLesson $lesson): ?AcademyCourseLesson
|
||||
{
|
||||
$ordered = $this->orderedCourseLessons($course);
|
||||
$index = $ordered->search(fn (AcademyCourseLesson $courseLesson): bool => $courseLesson->lesson?->is($lesson) ?? false);
|
||||
|
||||
return is_int($index) ? $ordered->get($index + 1) : null;
|
||||
}
|
||||
|
||||
public function previousLesson(AcademyCourse $course, AcademyLesson $lesson): ?AcademyCourseLesson
|
||||
{
|
||||
$ordered = $this->orderedCourseLessons($course);
|
||||
$index = $ordered->search(fn (AcademyCourseLesson $courseLesson): bool => $courseLesson->lesson?->is($lesson) ?? false);
|
||||
|
||||
return is_int($index) && $index > 0 ? $ordered->get($index - 1) : null;
|
||||
}
|
||||
}
|
||||
173
app/Services/Academy/AcademyCourseProgressService.php
Normal file
173
app/Services/Academy/AcademyCourseProgressService.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Academy;
|
||||
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyCourseEnrollment;
|
||||
use App\Models\AcademyCourseLesson;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Models\AcademyLessonProgress;
|
||||
use App\Models\User;
|
||||
|
||||
final class AcademyCourseProgressService
|
||||
{
|
||||
public function __construct(private readonly AcademyCourseNavigationService $navigation)
|
||||
{
|
||||
}
|
||||
|
||||
public function getProgress(?User $user, AcademyCourse $course): array
|
||||
{
|
||||
$totalRequired = $this->getTotalRequiredLessonsCount($course);
|
||||
$completedRequired = $user ? $this->getCompletedRequiredLessonsCount($user, $course) : 0;
|
||||
$enrollment = $user
|
||||
? AcademyCourseEnrollment::query()->with('lastLesson')->where('user_id', $user->id)->where('course_id', $course->id)->first()
|
||||
: null;
|
||||
|
||||
return [
|
||||
'total_required' => $totalRequired,
|
||||
'completed_required' => $completedRequired,
|
||||
'progress_percent' => $this->getProgressPercent($user, $course),
|
||||
'enrollment' => $enrollment,
|
||||
'next_lesson' => $user ? $this->getNextLesson($user, $course) : null,
|
||||
'continue_lesson' => $user ? $this->getContinueLesson($user, $course) : null,
|
||||
'completed' => $enrollment?->status === AcademyCourseEnrollment::STATUS_COMPLETED,
|
||||
];
|
||||
}
|
||||
|
||||
public function getCompletedRequiredLessonsCount(User $user, AcademyCourse $course): int
|
||||
{
|
||||
$lessonIds = $course->courseLessons()
|
||||
->where('is_required', true)
|
||||
->pluck('lesson_id')
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($lessonIds === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return AcademyLessonProgress::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereIn('lesson_id', $lessonIds)
|
||||
->whereNotNull('completed_at')
|
||||
->count();
|
||||
}
|
||||
|
||||
public function getCompletedLessonIds(User $user, AcademyCourse $course): array
|
||||
{
|
||||
$lessonIds = $course->courseLessons()
|
||||
->pluck('lesson_id')
|
||||
->filter()
|
||||
->map(fn ($lessonId): int => (int) $lessonId)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($lessonIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return AcademyLessonProgress::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereIn('lesson_id', $lessonIds)
|
||||
->whereNotNull('completed_at')
|
||||
->pluck('lesson_id')
|
||||
->map(fn ($lessonId): int => (int) $lessonId)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function getTotalRequiredLessonsCount(AcademyCourse $course): int
|
||||
{
|
||||
return $course->courseLessons()->where('is_required', true)->count();
|
||||
}
|
||||
|
||||
public function getProgressPercent(?User $user, AcademyCourse $course): int
|
||||
{
|
||||
if (! $user) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$totalRequired = $this->getTotalRequiredLessonsCount($course);
|
||||
|
||||
if ($totalRequired < 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) floor(($this->getCompletedRequiredLessonsCount($user, $course) / $totalRequired) * 100);
|
||||
}
|
||||
|
||||
public function getNextLesson(User $user, AcademyCourse $course): ?AcademyCourseLesson
|
||||
{
|
||||
$completedLessonIds = $this->getCompletedLessonIds($user, $course);
|
||||
|
||||
return $this->navigation->orderedCourseLessons($course)
|
||||
->first(fn (AcademyCourseLesson $courseLesson): bool => ! in_array((int) $courseLesson->lesson_id, $completedLessonIds, true));
|
||||
}
|
||||
|
||||
public function getPreviousLesson(AcademyCourse $course, AcademyLesson $lesson): ?AcademyCourseLesson
|
||||
{
|
||||
return $this->navigation->previousLesson($course, $lesson);
|
||||
}
|
||||
|
||||
public function getContinueLesson(User $user, AcademyCourse $course): ?AcademyCourseLesson
|
||||
{
|
||||
$enrollment = AcademyCourseEnrollment::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('course_id', $course->id)
|
||||
->first();
|
||||
|
||||
if ($enrollment?->last_lesson_id) {
|
||||
$lastLesson = AcademyLesson::query()->find($enrollment->last_lesson_id);
|
||||
|
||||
if ($lastLesson instanceof AcademyLesson) {
|
||||
$nextLesson = $this->navigation->nextLesson($course, $lastLesson);
|
||||
|
||||
return $nextLesson ?? $this->navigation->findCourseLesson($course, $lastLesson);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->getNextLesson($user, $course) ?? $this->navigation->firstPublishedLesson($course);
|
||||
}
|
||||
|
||||
public function markEnrollmentStarted(User $user, AcademyCourse $course): AcademyCourseEnrollment
|
||||
{
|
||||
return AcademyCourseEnrollment::query()->updateOrCreate(
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'course_id' => $course->id,
|
||||
],
|
||||
[
|
||||
'status' => AcademyCourseEnrollment::STATUS_ACTIVE,
|
||||
'started_at' => now(),
|
||||
'completed_at' => null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function updateLastLesson(User $user, AcademyCourse $course, AcademyLesson $lesson): AcademyCourseEnrollment
|
||||
{
|
||||
$enrollment = $this->markEnrollmentStarted($user, $course);
|
||||
$enrollment->forceFill([
|
||||
'last_lesson_id' => $lesson->id,
|
||||
])->save();
|
||||
|
||||
return $enrollment;
|
||||
}
|
||||
|
||||
public function markCourseCompletedIfFinished(User $user, AcademyCourse $course): AcademyCourseEnrollment
|
||||
{
|
||||
$enrollment = $this->markEnrollmentStarted($user, $course);
|
||||
$progressPercent = $this->getProgressPercent($user, $course);
|
||||
$isComplete = $this->getTotalRequiredLessonsCount($course) > 0 && $progressPercent >= 100;
|
||||
|
||||
$enrollment->forceFill([
|
||||
'status' => $isComplete ? AcademyCourseEnrollment::STATUS_COMPLETED : AcademyCourseEnrollment::STATUS_ACTIVE,
|
||||
'completed_at' => $isComplete ? ($enrollment->completed_at ?? now()) : null,
|
||||
])->save();
|
||||
|
||||
return $enrollment;
|
||||
}
|
||||
}
|
||||
42
app/Services/Academy/AcademyLessonMarkdownRenderer.php
Normal file
42
app/Services/Academy/AcademyLessonMarkdownRenderer.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Academy;
|
||||
|
||||
use League\CommonMark\Environment\Environment;
|
||||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
|
||||
use League\CommonMark\MarkdownConverter;
|
||||
|
||||
final class AcademyLessonMarkdownRenderer
|
||||
{
|
||||
private ?MarkdownConverter $converter = null;
|
||||
|
||||
public function render(?string $markdown): string
|
||||
{
|
||||
$trimmed = trim((string) $markdown);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trim((string) $this->converter()->convert($trimmed)->getContent());
|
||||
}
|
||||
|
||||
private function converter(): MarkdownConverter
|
||||
{
|
||||
if ($this->converter instanceof MarkdownConverter) {
|
||||
return $this->converter;
|
||||
}
|
||||
|
||||
$environment = new Environment([
|
||||
'html_input' => 'strip',
|
||||
'allow_unsafe_links' => false,
|
||||
]);
|
||||
$environment->addExtension(new CommonMarkCoreExtension());
|
||||
$environment->addExtension(new GithubFlavoredMarkdownExtension());
|
||||
|
||||
return $this->converter = new MarkdownConverter($environment);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Academy;
|
||||
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Models\AcademyLessonProgress;
|
||||
use App\Models\AcademyPromptTemplate;
|
||||
@@ -12,11 +13,13 @@ use App\Models\User;
|
||||
|
||||
final class AcademyProgressService
|
||||
{
|
||||
public function __construct(private readonly AcademyBadgeService $badges)
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AcademyBadgeService $badges,
|
||||
private readonly AcademyCourseProgressService $courses,
|
||||
) {
|
||||
}
|
||||
|
||||
public function markLessonComplete(User $user, AcademyLesson $lesson): AcademyLessonProgress
|
||||
public function markLessonComplete(User $user, AcademyLesson $lesson, ?AcademyCourse $course = null): AcademyLessonProgress
|
||||
{
|
||||
$progress = AcademyLessonProgress::query()->updateOrCreate(
|
||||
[
|
||||
@@ -28,6 +31,11 @@ final class AcademyProgressService
|
||||
],
|
||||
);
|
||||
|
||||
if ($course instanceof AcademyCourse) {
|
||||
$this->courses->updateLastLesson($user, $course, $lesson);
|
||||
$this->courses->markCourseCompletedIfFinished($user, $course);
|
||||
}
|
||||
|
||||
$this->badges->syncForUser($user);
|
||||
|
||||
return $progress;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Services\Sitemaps\AbstractSitemapBuilder;
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use DateTimeInterface;
|
||||
|
||||
final class AcademyCoursesSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'academy-courses';
|
||||
}
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
if (! (bool) config('academy.enabled', true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = [$this->urls->staticRoute('/academy/courses')];
|
||||
|
||||
$details = AcademyCourse::query()
|
||||
->published()
|
||||
->orderBy('id')
|
||||
->cursor()
|
||||
->map(fn (AcademyCourse $course): SitemapUrl => $this->urls->staticRoute('/academy/courses/' . $course->slug, $course->updated_at ?? $course->published_at))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return array_merge($items, $details);
|
||||
}
|
||||
|
||||
public function lastModified(): ?DateTimeInterface
|
||||
{
|
||||
return $this->dateTime(AcademyCourse::query()->published()->max('updated_at'));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Services\Sitemaps;
|
||||
|
||||
use App\Services\Sitemaps\Builders\ArtworksSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyChallengesSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyCoursesSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyLessonsSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyPacksSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyPromptsSitemapBuilder;
|
||||
@@ -31,6 +32,7 @@ final class SitemapRegistry
|
||||
|
||||
public function __construct(
|
||||
ArtworksSitemapBuilder $artworks,
|
||||
AcademyCoursesSitemapBuilder $academyCourses,
|
||||
AcademyLessonsSitemapBuilder $academyLessons,
|
||||
AcademyPromptsSitemapBuilder $academyPrompts,
|
||||
AcademyPacksSitemapBuilder $academyPacks,
|
||||
@@ -50,6 +52,7 @@ final class SitemapRegistry
|
||||
) {
|
||||
$this->builders = [
|
||||
$artworks->name() => $artworks,
|
||||
$academyCourses->name() => $academyCourses,
|
||||
$academyLessons->name() => $academyLessons,
|
||||
$academyPrompts->name() => $academyPrompts,
|
||||
$academyPacks->name() => $academyPacks,
|
||||
|
||||
Reference in New Issue
Block a user