chore: commit remaining workspace changes

This commit is contained in:
2026-05-08 21:51:29 +02:00
parent 8d108b8a76
commit ff96ef796e
97 changed files with 18020 additions and 2196 deletions

View File

@@ -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';
}
}