490 lines
21 KiB
PHP
490 lines
21 KiB
PHP
<?php
|
|
|
|
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;
|
|
use App\Models\AcademyLessonBlock;
|
|
use App\Models\AcademyPromptPack;
|
|
use App\Models\AcademyPromptTemplate;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
|
|
final class AcademyAccessService
|
|
{
|
|
public function canAccessContent(?User $user, string $accessLevel): bool
|
|
{
|
|
if ($accessLevel === 'free') {
|
|
return true;
|
|
}
|
|
|
|
if ($user === null) {
|
|
return false;
|
|
}
|
|
|
|
if ($user->isAdmin()) {
|
|
return true;
|
|
}
|
|
|
|
return $this->rankForUser($user) >= $this->rankForLevel($accessLevel);
|
|
}
|
|
|
|
public function canAccessLesson(?User $user, AcademyLesson $lesson): bool
|
|
{
|
|
return $this->canAccessContent($user, (string) $lesson->access_level);
|
|
}
|
|
|
|
public function canAccessPrompt(?User $user, AcademyPromptTemplate $prompt): bool
|
|
{
|
|
return $this->canAccessContent($user, (string) $prompt->access_level);
|
|
}
|
|
|
|
public function canAccessPack(?User $user, AcademyPromptPack $pack): bool
|
|
{
|
|
return $this->canAccessContent($user, (string) $pack->access_level);
|
|
}
|
|
|
|
public function canAccessChallenge(?User $user, AcademyChallenge $challenge): bool
|
|
{
|
|
return $this->canAccessContent($user, (string) $challenge->access_level);
|
|
}
|
|
|
|
public function canAccessCourseLesson(?User $user, AcademyCourseLesson $courseLesson): bool
|
|
{
|
|
$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),
|
|
'difficulty' => (string) $lesson->difficulty,
|
|
'access_level' => (string) $lesson->access_level,
|
|
'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,
|
|
'active' => (bool) $lesson->active,
|
|
'published_at' => $lesson->published_at?->toISOString(),
|
|
'category' => $lesson->category ? [
|
|
'id' => (int) $lesson->category->id,
|
|
'name' => (string) $lesson->category->name,
|
|
'slug' => (string) $lesson->category->slug,
|
|
] : null,
|
|
'blocks' => ($authorized && $includeFull)
|
|
? $lesson->activeBlocks->map(fn (AcademyLessonBlock $block): ?array => $this->lessonBlockPayload($block))->filter()->values()->all()
|
|
: [],
|
|
'locked' => ! $authorized,
|
|
'can_access' => $authorized,
|
|
];
|
|
}
|
|
|
|
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);
|
|
|
|
return [
|
|
'id' => (int) $prompt->id,
|
|
'title' => (string) $prompt->title,
|
|
'slug' => (string) $prompt->slug,
|
|
'excerpt' => (string) ($prompt->excerpt ?? ''),
|
|
'prompt' => ($authorized && $includeFull) ? (string) $prompt->prompt : null,
|
|
'negative_prompt' => ($authorized && $includeFull) ? (string) ($prompt->negative_prompt ?? '') : null,
|
|
'usage_notes' => ($authorized && $includeFull) ? (string) ($prompt->usage_notes ?? '') : null,
|
|
'workflow_notes' => ($authorized && $includeFull) ? (string) ($prompt->workflow_notes ?? '') : null,
|
|
'prompt_preview' => $authorized ? null : $this->previewText((string) $prompt->prompt, 220),
|
|
'difficulty' => (string) $prompt->difficulty,
|
|
'access_level' => (string) $prompt->access_level,
|
|
'aspect_ratio' => $prompt->aspect_ratio,
|
|
'tags' => array_values((array) ($prompt->tags ?? [])),
|
|
'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,
|
|
'published_at' => $prompt->published_at?->toISOString(),
|
|
'category' => $prompt->category ? [
|
|
'id' => (int) $prompt->category->id,
|
|
'name' => (string) $prompt->category->name,
|
|
'slug' => (string) $prompt->category->slug,
|
|
] : null,
|
|
'locked' => ! $authorized,
|
|
'can_access' => $authorized,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @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);
|
|
|
|
return [
|
|
'id' => (int) $pack->id,
|
|
'title' => (string) $pack->title,
|
|
'slug' => (string) $pack->slug,
|
|
'excerpt' => (string) ($pack->excerpt ?? ''),
|
|
'description' => (string) ($pack->description ?? ''),
|
|
'access_level' => (string) $pack->access_level,
|
|
'cover_image' => $pack->cover_image,
|
|
'tags' => array_values((array) ($pack->tags ?? [])),
|
|
'featured' => (bool) $pack->featured,
|
|
'published_at' => $pack->published_at?->toISOString(),
|
|
'locked' => ! $authorized,
|
|
'can_access' => $authorized,
|
|
'prompts' => $includePrompts
|
|
? $pack->prompts->map(fn (AcademyPromptTemplate $prompt): array => $this->promptPayload($prompt, $viewer, $authorized))->values()->all()
|
|
: [],
|
|
];
|
|
}
|
|
|
|
public function challengePayload(AcademyChallenge $challenge, ?User $viewer, bool $includeSubmissions = false): array
|
|
{
|
|
$authorized = $this->canAccessChallenge($viewer, $challenge);
|
|
|
|
return [
|
|
'id' => (int) $challenge->id,
|
|
'title' => (string) $challenge->title,
|
|
'slug' => (string) $challenge->slug,
|
|
'excerpt' => (string) ($challenge->excerpt ?? ''),
|
|
'description' => (string) ($challenge->description ?? ''),
|
|
'brief' => (string) ($challenge->brief ?? ''),
|
|
'rules' => (string) ($challenge->rules ?? ''),
|
|
'access_level' => (string) $challenge->access_level,
|
|
'status' => (string) $challenge->status,
|
|
'starts_at' => $challenge->starts_at?->toISOString(),
|
|
'ends_at' => $challenge->ends_at?->toISOString(),
|
|
'voting_starts_at' => $challenge->voting_starts_at?->toISOString(),
|
|
'voting_ends_at' => $challenge->voting_ends_at?->toISOString(),
|
|
'cover_image' => $challenge->cover_image,
|
|
'prize_text' => $challenge->prize_text,
|
|
'required_tags' => array_values((array) ($challenge->required_tags ?? [])),
|
|
'allowed_categories' => array_values((array) ($challenge->allowed_categories ?? [])),
|
|
'featured' => (bool) $challenge->featured,
|
|
'locked' => ! $authorized,
|
|
'can_access' => $authorized,
|
|
'submission_count' => $includeSubmissions ? (int) $challenge->submissions()->approved()->count() : null,
|
|
];
|
|
}
|
|
|
|
private function rankForUser(User $user): int
|
|
{
|
|
if (method_exists($user, 'hasAcademyProAccess') && $user->hasAcademyProAccess()) {
|
|
return $this->rankForLevel('pro');
|
|
}
|
|
|
|
if (method_exists($user, 'hasAcademyCreatorAccess') && $user->hasAcademyCreatorAccess()) {
|
|
return $this->rankForLevel('creator');
|
|
}
|
|
|
|
return $this->rankForLevel('free');
|
|
}
|
|
|
|
private function rankForLevel(string $accessLevel): int
|
|
{
|
|
return match (Str::lower(trim($accessLevel))) {
|
|
'admin' => 99,
|
|
'premium' => 40,
|
|
'pro' => 30,
|
|
'creator' => 20,
|
|
default => 10,
|
|
};
|
|
}
|
|
|
|
private function previewText(string $value, int $limit): string
|
|
{
|
|
$plain = trim(strip_tags($value));
|
|
|
|
if ($plain === '') {
|
|
return '';
|
|
}
|
|
|
|
$length = mb_strlen($plain);
|
|
$previewLength = min($limit, max(12, (int) ceil($length * 0.55)));
|
|
|
|
if ($previewLength >= $length) {
|
|
$previewLength = max(1, $length - 1);
|
|
}
|
|
|
|
return rtrim(mb_substr($plain, 0, $previewLength)).'...';
|
|
}
|
|
|
|
private function resolvePreviewImageUrl(string $previewImage): ?string
|
|
{
|
|
$previewImage = trim($previewImage);
|
|
|
|
if ($previewImage === '') {
|
|
return null;
|
|
}
|
|
|
|
if (str_starts_with($previewImage, 'http://') || str_starts_with($previewImage, 'https://') || str_starts_with($previewImage, '/')) {
|
|
return $previewImage;
|
|
}
|
|
|
|
return Storage::disk((string) config('uploads.object_storage.disk', 's3'))->url($previewImage);
|
|
}
|
|
|
|
private function resolveLessonCoverImageUrl(string $coverImage): ?string
|
|
{
|
|
$coverImage = trim($coverImage);
|
|
|
|
if ($coverImage === '') {
|
|
return null;
|
|
}
|
|
|
|
if (str_starts_with($coverImage, 'http://') || str_starts_with($coverImage, 'https://') || str_starts_with($coverImage, '/')) {
|
|
return $coverImage;
|
|
}
|
|
|
|
return Storage::disk((string) config('uploads.object_storage.disk', 's3'))->url($coverImage);
|
|
}
|
|
|
|
private function resolveLessonMediaUrl(string $path): ?string
|
|
{
|
|
$path = trim($path);
|
|
|
|
if ($path === '') {
|
|
return null;
|
|
}
|
|
|
|
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
|
|
return $path;
|
|
}
|
|
|
|
return Storage::disk((string) config('uploads.object_storage.disk', 's3'))->url($path);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private function lessonBlockPayload(AcademyLessonBlock $block): ?array
|
|
{
|
|
if ($block->type !== 'ai_comparison') {
|
|
return null;
|
|
}
|
|
|
|
$payload = is_array($block->payload) ? $block->payload : [];
|
|
$criteria = collect($payload['criteria'] ?? [])
|
|
->map(static fn ($criterion): string => trim((string) $criterion))
|
|
->filter(static fn (string $criterion): bool => $criterion !== '')
|
|
->values()
|
|
->all();
|
|
$results = $block->activeComparisonResults
|
|
->map(fn (AcademyAiComparisonResult $result): array => [
|
|
'id' => (int) $result->id,
|
|
'provider' => (string) ($result->provider ?? ''),
|
|
'model_name' => (string) ($result->model_name ?? ''),
|
|
'image_path' => (string) $result->image_path,
|
|
'image_url' => $this->resolveLessonMediaUrl((string) $result->image_path),
|
|
'thumb_path' => (string) ($result->thumb_path ?? ''),
|
|
'thumb_url' => $this->resolveLessonMediaUrl((string) ($result->thumb_path ?? '')),
|
|
'settings' => (string) ($result->settings ?? ''),
|
|
'strengths' => (string) ($result->strengths ?? ''),
|
|
'weaknesses' => (string) ($result->weaknesses ?? ''),
|
|
'best_for' => (string) ($result->best_for ?? ''),
|
|
'score' => $result->score,
|
|
'sort_order' => (int) $result->sort_order,
|
|
'active' => (bool) $result->active,
|
|
])
|
|
->values()
|
|
->all();
|
|
|
|
$hasPromptData = filled($payload['prompt'] ?? null)
|
|
|| filled($payload['negative_prompt'] ?? null)
|
|
|| filled($payload['intro'] ?? null)
|
|
|| filled($payload['title'] ?? null)
|
|
|| filled($payload['aspect_ratio'] ?? null)
|
|
|| ! empty($criteria);
|
|
|
|
if (! $hasPromptData && $results === []) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'id' => (int) $block->id,
|
|
'type' => (string) $block->type,
|
|
'title' => (string) ($block->title ?? ($payload['title'] ?? '')),
|
|
'payload' => [
|
|
'title' => (string) ($payload['title'] ?? ''),
|
|
'intro' => (string) ($payload['intro'] ?? ''),
|
|
'prompt' => (string) ($payload['prompt'] ?? ''),
|
|
'negative_prompt' => (string) ($payload['negative_prompt'] ?? ''),
|
|
'aspect_ratio' => (string) ($payload['aspect_ratio'] ?? ''),
|
|
'criteria' => $criteria,
|
|
],
|
|
'sort_order' => (int) $block->sort_order,
|
|
'active' => (bool) $block->active,
|
|
'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';
|
|
}
|
|
}
|