Files
SkinbaseNova/app/Http/Controllers/Settings/AcademyAdminController.php

2202 lines
103 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Academy\UpsertAcademyBadgeRequest;
use App\Http\Requests\Academy\UpsertAcademyCategoryRequest;
use App\Http\Requests\Academy\UpsertAcademyChallengeRequest;
use App\Http\Requests\Academy\UpsertAcademyCourseRequest;
use App\Http\Requests\Academy\UpsertAcademyLessonRequest;
use App\Http\Requests\Academy\UpsertAcademyPromptPackRequest;
use App\Http\Requests\Academy\UpsertAcademyPromptTemplateRequest;
use App\Models\AcademyAiComparisonResult;
use App\Models\AcademyBadge;
use App\Models\AcademyCategory;
use App\Models\AcademyChallenge;
use App\Models\AcademyChallengeSubmission;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseLesson;
use App\Models\AcademyLesson;
use App\Models\AcademyLessonBlock;
use App\Models\AcademyLessonRevision;
use App\Models\AcademyPromptPack;
use App\Models\AcademyPromptPackItem;
use App\Models\AcademyPromptTemplate;
use App\Models\User;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyCourseLessonOrderingService;
use App\Services\Academy\AcademyLessonMarkdownRenderer;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyAdminController extends Controller
{
private const PROMPT_PREVIEW_WEBP_QUALITY = 84;
private const PROMPT_PREVIEW_PREFIX = 'academy-prompts/previews';
public function __construct(
private readonly AcademyCacheService $cache,
private readonly AcademyCourseLessonOrderingService $courseLessonOrdering,
private readonly AcademyLessonMarkdownRenderer $lessonMarkdownRenderer,
) {}
public function dashboard(): Response
{
return Inertia::render('Admin/Academy/Dashboard', [
'stats' => [
'courses' => AcademyCourse::query()->count(),
'lessons' => AcademyLesson::query()->count(),
'prompts' => AcademyPromptTemplate::query()->count(),
'packs' => AcademyPromptPack::query()->count(),
'challenges' => AcademyChallenge::query()->count(),
'submissions' => AcademyChallengeSubmission::query()->count(),
'badges' => AcademyBadge::query()->count(),
'creator_subscribers' => 0,
'pro_subscribers' => 0,
'mrr' => 0,
],
'links' => [
'courses' => route('admin.academy.courses.index'),
'categories' => route('admin.academy.categories.index'),
'lessons' => route('admin.academy.lessons.index'),
'prompts' => route('admin.academy.prompts.index'),
'packs' => route('admin.academy.packs.index'),
'challenges' => route('admin.academy.challenges.index'),
'submissions' => route('admin.academy.submissions.index'),
'badges' => route('admin.academy.badges.index'),
],
]);
}
public function categoriesIndex(): Response
{
return $this->renderIndex('categories');
}
public function coursesIndex(): Response
{
return $this->renderIndex('courses');
}
public function coursesCreate(): Response
{
return $this->renderForm('courses', new AcademyCourse);
}
public function coursesStore(UpsertAcademyCourseRequest $request): RedirectResponse
{
$course = new AcademyCourse;
$course->fill($this->persistCourseAttributes($request))->save();
$this->cache->clearAll();
return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $course])->with('success', 'Academy course created.');
}
public function coursesEdit(AcademyCourse $academyCourse): Response
{
return $this->renderForm('courses', $academyCourse);
}
public function coursesUpdate(UpsertAcademyCourseRequest $request, AcademyCourse $academyCourse): RedirectResponse
{
$academyCourse->fill($this->persistCourseAttributes($request, $academyCourse))->save();
$this->cache->clearAll();
return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $academyCourse])->with('success', 'Academy course updated.');
}
public function coursesDestroy(AcademyCourse $academyCourse): RedirectResponse
{
$this->deleteStoredLessonCoverIfLocal((string) $academyCourse->cover_image);
$this->deleteStoredLessonCoverIfLocal((string) $academyCourse->teaser_image);
$academyCourse->delete();
$this->cache->clearAll();
return redirect()->route('admin.academy.courses.index')->with('success', 'Academy course deleted.');
}
public function categoriesCreate(): Response
{
return $this->renderForm('categories', new AcademyCategory);
}
public function categoriesStore(UpsertAcademyCategoryRequest $request): RedirectResponse
{
$category = new AcademyCategory;
$category->fill($request->validated())->save();
$this->cache->clearAll();
return redirect()->route('admin.academy.categories.edit', ['academyCategory' => $category])->with('success', 'Academy category created.');
}
public function categoriesStoreJson(UpsertAcademyCategoryRequest $request): JsonResponse
{
$category = new AcademyCategory;
$category->fill($request->validated())->save();
$this->cache->clearAll();
return response()->json([
'success' => true,
'category' => $this->serializeCategoryOption($category),
]);
}
public function categoriesEdit(AcademyCategory $academyCategory): Response
{
return $this->renderForm('categories', $academyCategory);
}
public function categoriesUpdate(UpsertAcademyCategoryRequest $request, AcademyCategory $academyCategory): RedirectResponse
{
$academyCategory->fill($request->validated())->save();
$this->cache->clearAll();
return redirect()->route('admin.academy.categories.edit', ['academyCategory' => $academyCategory])->with('success', 'Academy category updated.');
}
public function categoriesDestroy(AcademyCategory $academyCategory): RedirectResponse
{
$academyCategory->delete();
$this->cache->clearAll();
return redirect()->route('admin.academy.categories.index')->with('success', 'Academy category deleted.');
}
public function lessonsIndex(): Response
{
return $this->renderIndex('lessons');
}
public function lessonsCreate(): Response
{
return $this->renderForm('lessons', new AcademyLesson);
}
public function lessonsStore(UpsertAcademyLessonRequest $request): RedirectResponse
{
$lesson = DB::transaction(function () use ($request): AcademyLesson {
$lesson = new AcademyLesson;
$lesson->fill($this->persistLessonAttributes($request))->save();
$this->syncLessonCourses($lesson, $request->validated('course_ids', []));
$this->syncLessonBlocks($lesson, $request->validated('blocks', []));
return $lesson;
});
$this->cache->clearAll();
return redirect()->route('admin.academy.lessons.edit', ['academyLesson' => $lesson])->with('success', 'Academy lesson created.');
}
public function lessonsEdit(AcademyLesson $academyLesson): Response
{
$academyLesson->load(['blocks.comparisonResults']);
return $this->renderForm('lessons', $academyLesson);
}
public function lessonsUpdate(UpsertAcademyLessonRequest $request, AcademyLesson $academyLesson): RedirectResponse
{
DB::transaction(function () use ($request, $academyLesson): void {
$this->createLessonRevision($academyLesson, $request->user(), 'Before lesson update');
$academyLesson->fill($this->persistLessonAttributes($request, $academyLesson))->save();
$this->syncLessonCourses($academyLesson, $request->validated('course_ids', []));
$this->syncLessonBlocks($academyLesson, $request->validated('blocks', []));
});
$this->cache->clearAll();
return redirect()->route('admin.academy.lessons.edit', ['academyLesson' => $academyLesson])->with('success', 'Academy lesson updated.');
}
public function lessonsRestoreRevision(Request $request, AcademyLesson $academyLesson, AcademyLessonRevision $academyLessonRevision): RedirectResponse
{
abort_unless((int) $academyLessonRevision->lesson_id === (int) $academyLesson->id, 404);
$validated = $request->validate([
'field' => ['nullable', 'string', 'in:category_id,title,slug,lesson_number,course_order,series_name,excerpt,content,difficulty,access_level,lesson_type,cover_image,article_cover_image,tags,video_url,reading_minutes,featured,active,published_at,seo_title,seo_description,course_ids,blocks'],
]);
$field = filled($validated['field'] ?? null) ? (string) $validated['field'] : null;
DB::transaction(function () use ($academyLesson, $academyLessonRevision, $field, $request): void {
$note = $field
? sprintf('Before restoring %s from revision #%d', $field, $academyLessonRevision->id)
: sprintf('Before restoring revision #%d', $academyLessonRevision->id);
$this->createLessonRevision($academyLesson, $request->user(), $note);
$this->applyLessonRevisionSnapshot($academyLesson, $academyLessonRevision, $field);
});
$this->cache->clearAll();
return redirect()->route('admin.academy.lessons.edit', ['academyLesson' => $academyLesson])->with('success', $field
? sprintf('Lesson field "%s" restored from revision.', $field)
: 'Lesson restored from revision.');
}
public function lessonsDestroy(AcademyLesson $academyLesson): RedirectResponse
{
$academyLesson->load(['blocks.comparisonResults']);
foreach ($academyLesson->blocks as $block) {
foreach ($block->comparisonResults as $result) {
$this->deleteStoredLessonMediaIfLocal($result->image_path);
$this->deleteStoredLessonMediaIfLocal($result->thumb_path);
}
}
$this->deleteStoredLessonCoverIfLocal((string) $academyLesson->cover_image);
$this->deleteStoredLessonCoverIfLocal((string) $academyLesson->article_cover_image);
$academyLesson->delete();
$this->cache->clearAll();
return redirect()->route('admin.academy.lessons.index')->with('success', 'Academy lesson deleted.');
}
public function promptsIndex(): Response
{
return $this->renderIndex('prompts');
}
public function promptsCreate(): Response
{
return $this->renderForm('prompts', new AcademyPromptTemplate);
}
public function promptsStore(UpsertAcademyPromptTemplateRequest $request): RedirectResponse
{
$prompt = new AcademyPromptTemplate;
$prompt->forceFill($this->persistPromptAttributes($request, $prompt))->save();
$this->cache->clearAll();
return redirect()->route('admin.academy.prompts.edit', ['academyPromptTemplate' => $prompt])->with('success', 'Academy prompt created.');
}
public function promptsEdit(AcademyPromptTemplate $academyPromptTemplate): Response
{
return $this->renderForm('prompts', $academyPromptTemplate);
}
public function promptsUpdate(UpsertAcademyPromptTemplateRequest $request, AcademyPromptTemplate $academyPromptTemplate): RedirectResponse
{
$academyPromptTemplate->forceFill($this->persistPromptAttributes($request, $academyPromptTemplate))->save();
$this->cache->clearAll();
return redirect()->route('admin.academy.prompts.edit', ['academyPromptTemplate' => $academyPromptTemplate])->with('success', 'Academy prompt updated.');
}
public function promptsDestroy(AcademyPromptTemplate $academyPromptTemplate): RedirectResponse
{
$academyPromptTemplate->delete();
$this->cache->clearAll();
return redirect()->route('admin.academy.prompts.index')->with('success', 'Academy prompt deleted.');
}
public function packsIndex(): Response
{
return $this->renderIndex('packs');
}
public function packsCreate(): Response
{
return $this->renderForm('packs', new AcademyPromptPack);
}
public function packsStore(UpsertAcademyPromptPackRequest $request): RedirectResponse
{
$pack = new AcademyPromptPack;
$pack->fill(collect($request->validated())->except('prompt_ids')->all())->save();
$this->syncPackItems($pack, $request->validated('prompt_ids', []));
$this->cache->clearAll();
return redirect()->route('admin.academy.packs.edit', ['academyPromptPack' => $pack])->with('success', 'Academy prompt pack created.');
}
public function packsEdit(AcademyPromptPack $academyPromptPack): Response
{
$academyPromptPack->load('prompts');
return $this->renderForm('packs', $academyPromptPack);
}
public function packsUpdate(UpsertAcademyPromptPackRequest $request, AcademyPromptPack $academyPromptPack): RedirectResponse
{
$academyPromptPack->fill(collect($request->validated())->except('prompt_ids')->all())->save();
$this->syncPackItems($academyPromptPack, $request->validated('prompt_ids', []));
$this->cache->clearAll();
return redirect()->route('admin.academy.packs.edit', ['academyPromptPack' => $academyPromptPack])->with('success', 'Academy prompt pack updated.');
}
public function packsDestroy(AcademyPromptPack $academyPromptPack): RedirectResponse
{
$academyPromptPack->delete();
$this->cache->clearAll();
return redirect()->route('admin.academy.packs.index')->with('success', 'Academy prompt pack deleted.');
}
public function challengesIndex(): Response
{
return $this->renderIndex('challenges');
}
public function challengesCreate(): Response
{
return $this->renderForm('challenges', new AcademyChallenge);
}
public function challengesStore(UpsertAcademyChallengeRequest $request): RedirectResponse
{
$challenge = new AcademyChallenge;
$challenge->fill($request->validated())->save();
$this->cache->clearAll();
return redirect()->route('admin.academy.challenges.edit', ['academyChallenge' => $challenge])->with('success', 'Academy challenge created.');
}
public function challengesEdit(AcademyChallenge $academyChallenge): Response
{
return $this->renderForm('challenges', $academyChallenge);
}
public function challengesUpdate(UpsertAcademyChallengeRequest $request, AcademyChallenge $academyChallenge): RedirectResponse
{
$academyChallenge->fill($request->validated())->save();
$this->cache->clearAll();
return redirect()->route('admin.academy.challenges.edit', ['academyChallenge' => $academyChallenge])->with('success', 'Academy challenge updated.');
}
public function challengesDestroy(AcademyChallenge $academyChallenge): RedirectResponse
{
$academyChallenge->delete();
$this->cache->clearAll();
return redirect()->route('admin.academy.challenges.index')->with('success', 'Academy challenge deleted.');
}
public function badgesIndex(): Response
{
return $this->renderIndex('badges');
}
public function badgesCreate(): Response
{
return $this->renderForm('badges', new AcademyBadge);
}
public function badgesStore(UpsertAcademyBadgeRequest $request): RedirectResponse
{
$badge = new AcademyBadge;
$badge->fill($request->validated())->save();
return redirect()->route('admin.academy.badges.edit', ['academyBadge' => $badge])->with('success', 'Academy badge created.');
}
public function badgesEdit(AcademyBadge $academyBadge): Response
{
return $this->renderForm('badges', $academyBadge);
}
public function badgesUpdate(UpsertAcademyBadgeRequest $request, AcademyBadge $academyBadge): RedirectResponse
{
$academyBadge->fill($request->validated())->save();
return redirect()->route('admin.academy.badges.edit', ['academyBadge' => $academyBadge])->with('success', 'Academy badge updated.');
}
public function badgesDestroy(AcademyBadge $academyBadge): RedirectResponse
{
$academyBadge->delete();
return redirect()->route('admin.academy.badges.index')->with('success', 'Academy badge deleted.');
}
public function submissionsIndex(Request $request): Response
{
$submissions = AcademyChallengeSubmission::query()
->with(['challenge', 'artwork', 'user'])
->latest('submitted_at')
->paginate(25)
->withQueryString();
$submissions->getCollection()->transform(fn (AcademyChallengeSubmission $submission): array => [
'id' => (int) $submission->id,
'moderation_status' => (string) $submission->moderation_status,
'submitted_at' => $submission->submitted_at?->toISOString(),
'ai_tool_used' => (string) ($submission->ai_tool_used ?? ''),
'prompt_used' => (string) ($submission->prompt_used ?? ''),
'workflow_notes' => (string) ($submission->workflow_notes ?? ''),
'challenge' => $submission->challenge ? [
'title' => (string) $submission->challenge->title,
'slug' => (string) $submission->challenge->slug,
] : null,
'user' => $submission->user ? [
'name' => (string) $submission->user->name,
'username' => (string) ($submission->user->username ?? ''),
] : null,
'artwork' => $submission->artwork ? [
'id' => (int) $submission->artwork->id,
'title' => (string) ($submission->artwork->title ?? 'Untitled artwork'),
'thumb_url' => $submission->artwork->thumbUrl('sm'),
] : null,
'approve_url' => route('admin.academy.submissions.approve', ['academyChallengeSubmission' => $submission]),
'reject_url' => route('admin.academy.submissions.reject', ['academyChallengeSubmission' => $submission]),
]);
return Inertia::render('Admin/Academy/Submissions', [
'submissions' => $submissions,
]);
}
public function approveSubmission(AcademyChallengeSubmission $academyChallengeSubmission): RedirectResponse
{
$academyChallengeSubmission->forceFill(['moderation_status' => 'approved'])->save();
return back()->with('success', 'Challenge submission approved.');
}
public function rejectSubmission(AcademyChallengeSubmission $academyChallengeSubmission): RedirectResponse
{
$academyChallengeSubmission->forceFill(['moderation_status' => 'rejected'])->save();
return back()->with('success', 'Challenge submission rejected.');
}
private function renderIndex(string $resource): Response
{
$meta = $this->resourceMeta($resource);
$query = $meta['model']::query()->latest('updated_at');
if ($resource === 'prompts') {
$query->with('category');
}
$items = $query->paginate(25)->withQueryString();
$items->getCollection()->transform(fn (Model $model): array => $this->serializeIndexItem($resource, $model));
return Inertia::render('Admin/Academy/CrudIndex', [
'resource' => $resource,
'title' => $meta['title'],
'subtitle' => $meta['subtitle'],
'items' => $items,
'columns' => $meta['columns'],
'createUrl' => route($meta['route_base'].'.create'),
]);
}
private function renderForm(string $resource, Model $record): Response
{
$meta = $this->resourceMeta($resource);
return Inertia::render('Admin/Academy/CrudForm', [
'resource' => $resource,
'title' => $record->exists ? 'Edit '.$meta['singular'] : 'Create '.$meta['singular'],
'subtitle' => $meta['subtitle'],
'fields' => $meta['fields'],
'record' => $this->serializeFormRecord($resource, $record),
'submitUrl' => $record->exists ? route($meta['route_base'].'.update', $this->routeParams($resource, $record)) : route($meta['route_base'].'.store'),
'indexUrl' => route($meta['route_base'].'.index'),
'destroyUrl' => $record->exists ? route($meta['route_base'].'.destroy', $this->routeParams($resource, $record)) : null,
'method' => $record->exists ? 'patch' : 'post',
'editorContext' => $this->formEditorContext($resource, $record),
]);
}
private function formEditorContext(string $resource, Model $record): array
{
if ($resource === 'courses') {
return [
'links' => array_filter([
'builder' => $record->exists ? route('admin.academy.courses.builder.edit', ['academyCourse' => $record]) : null,
'preview' => $record->exists ? route('academy.courses.show', ['course' => $record->slug]) : null,
]),
'coverUploadUrl' => route('api.studio.academy.lessons.media.upload'),
'coverDeleteUrl' => route('api.studio.academy.lessons.media.destroy'),
'bodyMediaUploadUrl' => route('api.studio.academy.lessons.media.upload'),
'bodyMediaDeleteUrl' => route('api.studio.academy.lessons.media.destroy'),
'bodyMediaAssetsUrl' => route('api.studio.academy.lessons.media.assets'),
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
'outlineSummary' => $record instanceof AcademyCourse && $record->exists
? $this->serializeCourseOutlineSummary($record)
: null,
'courseLessons' => $record instanceof AcademyCourse && $record->exists
? $this->serializeCourseEditorLessons($record)
: [],
'availableLessons' => $record instanceof AcademyCourse && $record->exists
? $this->serializeCourseAvailableLessons($record)
: [],
'attachLessonUrl' => $record instanceof AcademyCourse && $record->exists
? route('admin.academy.courses.lessons.attach', ['academyCourse' => $record])
: null,
'reorderUrl' => $record instanceof AcademyCourse && $record->exists
? route('admin.academy.courses.reorder', ['academyCourse' => $record])
: null,
];
}
if ($resource === 'prompts') {
return [
'links' => array_filter([
'preview' => $record->exists ? route('academy.prompts.show', ['slug' => $record->slug]) : null,
]),
'comparisonMediaUploadUrl' => route('api.studio.academy.lessons.media.upload'),
'comparisonMediaDeleteUrl' => route('api.studio.academy.lessons.media.destroy'),
'comparisonMediaAssetsUrl' => route('api.studio.academy.lessons.media.assets'),
'comparisonMediaBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
'comparisonCodeLists' => [
'providers' => array_values(array_filter(array_map('strval', (array) config('academy.prompt_comparison.providers', [])))),
'models' => array_values(array_filter(array_map('strval', (array) config('academy.prompt_comparison.models', [])))),
],
];
}
if ($resource !== 'lessons') {
return [];
}
return [
'currentLessonId' => $record instanceof AcademyLesson && $record->exists ? (int) $record->id : null,
'currentLessonTitle' => $record instanceof AcademyLesson && $record->exists ? (string) $record->title : '',
'coverUploadUrl' => route('api.studio.academy.lessons.media.upload'),
'coverDeleteUrl' => route('api.studio.academy.lessons.media.destroy'),
'bodyMediaUploadUrl' => route('api.studio.academy.lessons.media.upload'),
'bodyMediaDeleteUrl' => route('api.studio.academy.lessons.media.destroy'),
'bodyMediaAssetsUrl' => route('api.studio.academy.lessons.media.assets'),
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
'numbering' => $this->serializeLessonNumberingContext($record instanceof AcademyLesson ? $record : null),
'revisions' => $record instanceof AcademyLesson && $record->exists
? $this->serializeLessonRevisions($record)
: [],
'categories' => AcademyCategory::query()
->where('type', 'lesson')
->orderBy('order_num')
->orderBy('name')
->get()
->map(fn (AcademyCategory $category): array => $this->serializeCategoryOption($category))
->values()
->all(),
'courses' => $this->serializeLessonEditorCourses($record instanceof AcademyLesson ? $record : null),
'categoryStoreUrl' => route('api.academy.categories.store'),
'categoryManageUrl' => route('admin.academy.categories.index'),
];
}
private function resourceMeta(string $resource): array
{
return match ($resource) {
'courses' => [
'model' => AcademyCourse::class,
'title' => 'Academy Courses',
'singular' => 'course',
'subtitle' => 'Build structured learning paths from reusable Academy lessons.',
'route_base' => 'admin.academy.courses',
'columns' => ['title', 'difficulty', 'access_level', 'status', 'is_featured'],
'fields' => [
['name' => 'title', 'label' => 'Title', 'type' => 'text'],
['name' => 'slug', 'label' => 'Slug', 'type' => 'text'],
['name' => 'subtitle', 'label' => 'Subtitle', 'type' => 'text'],
['name' => 'excerpt', 'label' => 'Excerpt', 'type' => 'textarea', 'rows' => 4],
['name' => 'description', 'label' => 'Description', 'type' => 'textarea', 'rows' => 8],
['name' => 'cover_image', 'label' => 'Cover Image', 'type' => 'text'],
['name' => 'teaser_image', 'label' => 'Teaser Image', 'type' => 'text'],
['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->courseAccessOptions()],
['name' => 'difficulty', 'label' => 'Difficulty', 'type' => 'select', 'options' => $this->courseDifficultyOptions()],
['name' => 'status', 'label' => 'Status', 'type' => 'select', 'options' => $this->courseStatusOptions()],
['name' => 'order_num', 'label' => 'Order', 'type' => 'number'],
['name' => 'estimated_minutes', 'label' => 'Estimated Minutes', 'type' => 'number'],
['name' => 'published_at', 'label' => 'Published At', 'type' => 'datetime-local'],
['name' => 'seo_title', 'label' => 'SEO Title', 'type' => 'text'],
['name' => 'seo_description', 'label' => 'SEO Description', 'type' => 'textarea', 'rows' => 4],
['name' => 'meta_keywords', 'label' => 'Meta Keywords', 'type' => 'textarea', 'rows' => 3],
['name' => 'og_title', 'label' => 'OpenGraph Title', 'type' => 'text'],
['name' => 'og_description', 'label' => 'OpenGraph Description', 'type' => 'textarea', 'rows' => 4],
['name' => 'og_image', 'label' => 'OpenGraph Image', 'type' => 'text'],
['name' => 'is_featured', 'label' => 'Featured', 'type' => 'checkbox'],
],
],
'categories' => [
'model' => AcademyCategory::class,
'title' => 'Academy Categories',
'singular' => 'category',
'subtitle' => 'Manage lesson, prompt, pack, and challenge categories.',
'route_base' => 'admin.academy.categories',
'columns' => ['name', 'type', 'slug', 'active'],
'fields' => [
['name' => 'type', 'label' => 'Type', 'type' => 'select', 'options' => [['value' => 'lesson', 'label' => 'Lesson'], ['value' => 'prompt', 'label' => 'Prompt'], ['value' => 'challenge', 'label' => 'Challenge'], ['value' => 'pack', 'label' => 'Pack']]],
['name' => 'name', 'label' => 'Name', 'type' => 'text'],
['name' => 'slug', 'label' => 'Slug', 'type' => 'text'],
['name' => 'description', 'label' => 'Description', 'type' => 'textarea'],
['name' => 'icon', 'label' => 'Icon', 'type' => 'text'],
['name' => 'order_num', 'label' => 'Order', 'type' => 'number'],
['name' => 'active', 'label' => 'Active', 'type' => 'checkbox'],
],
],
'lessons' => [
'model' => AcademyLesson::class,
'title' => 'Academy Lessons',
'singular' => 'lesson',
'subtitle' => 'Create and publish Academy lessons.',
'route_base' => 'admin.academy.lessons',
'columns' => ['title', 'difficulty', 'access_level', 'featured', 'active'],
'fields' => [
['name' => 'category_id', 'label' => 'Category', 'type' => 'select', 'options' => $this->categoryOptions('lesson')],
['name' => 'course_ids', 'label' => 'Courses', 'type' => 'multiselect', 'options' => $this->courseOptions()],
['name' => 'title', 'label' => 'Title', 'type' => 'text'],
['name' => 'slug', 'label' => 'Slug', 'type' => 'text'],
['name' => 'excerpt', 'label' => 'Excerpt', 'type' => 'textarea'],
['name' => 'content', 'label' => 'Content', 'type' => 'textarea'],
['name' => 'difficulty', 'label' => 'Difficulty', 'type' => 'select', 'options' => $this->difficultyOptions()],
['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->accessOptions()],
['name' => 'lesson_type', 'label' => 'Lesson Type', 'type' => 'text'],
['name' => 'cover_image', 'label' => 'Cover Image', 'type' => 'text'],
['name' => 'tags', 'label' => 'Tags', 'type' => 'csv'],
['name' => 'video_url', 'label' => 'Video URL', 'type' => 'text'],
['name' => 'reading_minutes', 'label' => 'Reading Minutes', 'type' => 'number'],
['name' => 'published_at', 'label' => 'Published At', 'type' => 'datetime-local'],
['name' => 'seo_title', 'label' => 'SEO Title', 'type' => 'text'],
['name' => 'seo_description', 'label' => 'SEO Description', 'type' => 'textarea'],
['name' => 'featured', 'label' => 'Featured', 'type' => 'checkbox'],
['name' => 'active', 'label' => 'Active', 'type' => 'checkbox'],
],
],
'prompts' => [
'model' => AcademyPromptTemplate::class,
'title' => 'Academy Prompt Templates',
'singular' => 'prompt template',
'subtitle' => 'Manage prompt previews, premium prompts, and prompt of the week.',
'route_base' => 'admin.academy.prompts',
'columns' => ['title', 'difficulty', 'access_level', 'prompt_of_week', 'active'],
'fields' => [
['name' => 'category_id', 'label' => 'Category', 'type' => 'select', 'options' => $this->categoryOptions('prompt')],
['name' => 'title', 'label' => 'Title', 'type' => 'text'],
['name' => 'slug', 'label' => 'Slug', 'type' => 'text'],
['name' => 'excerpt', 'label' => 'Excerpt', 'type' => 'textarea'],
['name' => 'prompt', 'label' => 'Prompt', 'type' => 'textarea'],
['name' => 'negative_prompt', 'label' => 'Negative Prompt', 'type' => 'textarea'],
['name' => 'usage_notes', 'label' => 'Usage Notes', 'type' => 'textarea'],
['name' => 'workflow_notes', 'label' => 'Workflow Notes', 'type' => 'textarea'],
['name' => 'difficulty', 'label' => 'Difficulty', 'type' => 'select', 'options' => $this->difficultyOptions()],
['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->accessOptions()],
['name' => 'aspect_ratio', 'label' => 'Aspect Ratio', 'type' => 'text'],
['name' => 'tags', 'label' => 'Tags', 'type' => 'csv'],
['name' => 'preview_image', 'label' => 'Preview Image URL', 'type' => 'text'],
['name' => 'published_at', 'label' => 'Published At', 'type' => 'datetime-local'],
['name' => 'seo_title', 'label' => 'SEO Title', 'type' => 'text'],
['name' => 'seo_description', 'label' => 'SEO Description', 'type' => 'textarea'],
['name' => 'featured', 'label' => 'Featured', 'type' => 'checkbox'],
['name' => 'prompt_of_week', 'label' => 'Prompt Of Week', 'type' => 'checkbox'],
['name' => 'active', 'label' => 'Active', 'type' => 'checkbox'],
],
],
'packs' => [
'model' => AcademyPromptPack::class,
'title' => 'Academy Prompt Packs',
'singular' => 'prompt pack',
'subtitle' => 'Bundle Academy prompts into reusable packs.',
'route_base' => 'admin.academy.packs',
'columns' => ['title', 'access_level', 'featured', 'active'],
'fields' => [
['name' => 'title', 'label' => 'Title', 'type' => 'text'],
['name' => 'slug', 'label' => 'Slug', 'type' => 'text'],
['name' => 'excerpt', 'label' => 'Excerpt', 'type' => 'textarea'],
['name' => 'description', 'label' => 'Description', 'type' => 'textarea'],
['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->accessOptions()],
['name' => 'one_time_price_cents', 'label' => 'One-time Price (cents)', 'type' => 'number'],
['name' => 'currency', 'label' => 'Currency', 'type' => 'text'],
['name' => 'cover_image', 'label' => 'Cover Image', 'type' => 'text'],
['name' => 'tags', 'label' => 'Tags', 'type' => 'csv'],
['name' => 'prompt_ids', 'label' => 'Prompt Templates', 'type' => 'multiselect', 'options' => $this->promptOptions()],
['name' => 'published_at', 'label' => 'Published At', 'type' => 'datetime-local'],
['name' => 'featured', 'label' => 'Featured', 'type' => 'checkbox'],
['name' => 'active', 'label' => 'Active', 'type' => 'checkbox'],
],
],
'challenges' => [
'model' => AcademyChallenge::class,
'title' => 'Academy Challenges',
'singular' => 'challenge',
'subtitle' => 'Create and moderate Academy challenge briefs.',
'route_base' => 'admin.academy.challenges',
'columns' => ['title', 'status', 'access_level', 'featured', 'active'],
'fields' => [
['name' => 'title', 'label' => 'Title', 'type' => 'text'],
['name' => 'slug', 'label' => 'Slug', 'type' => 'text'],
['name' => 'excerpt', 'label' => 'Excerpt', 'type' => 'textarea'],
['name' => 'description', 'label' => 'Description', 'type' => 'textarea'],
['name' => 'brief', 'label' => 'Brief', 'type' => 'textarea'],
['name' => 'rules', 'label' => 'Rules', 'type' => 'textarea'],
['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->accessOptions()],
['name' => 'status', 'label' => 'Status', 'type' => 'select', 'options' => [['value' => 'draft', 'label' => 'Draft'], ['value' => 'scheduled', 'label' => 'Scheduled'], ['value' => 'active', 'label' => 'Active'], ['value' => 'voting', 'label' => 'Voting'], ['value' => 'completed', 'label' => 'Completed'], ['value' => 'archived', 'label' => 'Archived']]],
['name' => 'starts_at', 'label' => 'Starts At', 'type' => 'datetime-local'],
['name' => 'ends_at', 'label' => 'Ends At', 'type' => 'datetime-local'],
['name' => 'voting_starts_at', 'label' => 'Voting Starts At', 'type' => 'datetime-local'],
['name' => 'voting_ends_at', 'label' => 'Voting Ends At', 'type' => 'datetime-local'],
['name' => 'cover_image', 'label' => 'Cover Image', 'type' => 'text'],
['name' => 'prize_text', 'label' => 'Prize Text', 'type' => 'text'],
['name' => 'required_tags', 'label' => 'Required Tags', 'type' => 'csv'],
['name' => 'allowed_categories', 'label' => 'Allowed Categories', 'type' => 'csv'],
['name' => 'featured', 'label' => 'Featured', 'type' => 'checkbox'],
['name' => 'active', 'label' => 'Active', 'type' => 'checkbox'],
],
],
'badges' => [
'model' => AcademyBadge::class,
'title' => 'Academy Badges',
'singular' => 'badge',
'subtitle' => 'Define Academy plan and achievement badges.',
'route_base' => 'admin.academy.badges',
'columns' => ['name', 'badge_type', 'slug', 'active'],
'fields' => [
['name' => 'name', 'label' => 'Name', 'type' => 'text'],
['name' => 'slug', 'label' => 'Slug', 'type' => 'text'],
['name' => 'description', 'label' => 'Description', 'type' => 'textarea'],
['name' => 'icon', 'label' => 'Icon', 'type' => 'text'],
['name' => 'badge_type', 'label' => 'Badge Type', 'type' => 'text'],
['name' => 'rules', 'label' => 'Rules JSON', 'type' => 'json'],
['name' => 'active', 'label' => 'Active', 'type' => 'checkbox'],
],
],
default => throw new \InvalidArgumentException('Unknown Academy resource ['.$resource.'].'),
};
}
private function serializeIndexItem(string $resource, Model $model): array
{
return match ($resource) {
'courses' => [
'id' => (int) $model->id,
'title' => (string) $model->title,
'difficulty' => (string) $model->difficulty,
'access_level' => (string) $model->access_level,
'status' => (string) $model->status,
'is_featured' => (bool) $model->is_featured,
'edit_url' => route('admin.academy.courses.edit', ['academyCourse' => $model]),
'destroy_url' => route('admin.academy.courses.destroy', ['academyCourse' => $model]),
'builder_url' => route('admin.academy.courses.builder.edit', ['academyCourse' => $model]),
],
'categories' => [
'id' => (int) $model->id,
'name' => (string) $model->name,
'type' => (string) $model->type,
'slug' => (string) $model->slug,
'active' => (bool) $model->active,
'edit_url' => route('admin.academy.categories.edit', ['academyCategory' => $model]),
'destroy_url' => route('admin.academy.categories.destroy', ['academyCategory' => $model]),
],
'lessons' => [
'id' => (int) $model->id,
'title' => (string) $model->title,
'difficulty' => (string) $model->difficulty,
'access_level' => (string) $model->access_level,
'featured' => (bool) $model->featured,
'active' => (bool) $model->active,
'edit_url' => route('admin.academy.lessons.edit', ['academyLesson' => $model]),
'destroy_url' => route('admin.academy.lessons.destroy', ['academyLesson' => $model]),
],
'prompts' => [
'id' => (int) $model->id,
'title' => (string) $model->title,
'slug' => (string) $model->slug,
'excerpt' => (string) ($model->excerpt ?? ''),
'difficulty' => (string) $model->difficulty,
'access_level' => (string) $model->access_level,
'category_name' => (string) ($model->category?->name ?? ''),
'aspect_ratio' => (string) ($model->aspect_ratio ?? ''),
'featured' => (bool) $model->featured,
'prompt_of_week' => (bool) $model->prompt_of_week,
'active' => (bool) $model->active,
'preview_image_url' => $this->resolvePromptPreviewImageUrl((string) ($model->preview_image ?? '')),
'comparisons_count' => count($this->serializePromptToolNotes((array) ($model->tool_notes ?? []))),
'tags' => array_values(array_filter(array_map(static fn ($tag): string => trim((string) $tag), (array) ($model->tags ?? [])))),
'updated_at' => optional($model->updated_at)->toIso8601String(),
'preview_url' => route('academy.prompts.show', ['slug' => $model->slug]),
'edit_url' => route('admin.academy.prompts.edit', ['academyPromptTemplate' => $model]),
'destroy_url' => route('admin.academy.prompts.destroy', ['academyPromptTemplate' => $model]),
],
'packs' => [
'id' => (int) $model->id,
'title' => (string) $model->title,
'access_level' => (string) $model->access_level,
'featured' => (bool) $model->featured,
'active' => (bool) $model->active,
'edit_url' => route('admin.academy.packs.edit', ['academyPromptPack' => $model]),
'destroy_url' => route('admin.academy.packs.destroy', ['academyPromptPack' => $model]),
],
'challenges' => [
'id' => (int) $model->id,
'title' => (string) $model->title,
'status' => (string) $model->status,
'access_level' => (string) $model->access_level,
'featured' => (bool) $model->featured,
'active' => (bool) $model->active,
'edit_url' => route('admin.academy.challenges.edit', ['academyChallenge' => $model]),
'destroy_url' => route('admin.academy.challenges.destroy', ['academyChallenge' => $model]),
],
'badges' => [
'id' => (int) $model->id,
'name' => (string) $model->name,
'badge_type' => (string) $model->badge_type,
'slug' => (string) $model->slug,
'active' => (bool) $model->active,
'edit_url' => route('admin.academy.badges.edit', ['academyBadge' => $model]),
'destroy_url' => route('admin.academy.badges.destroy', ['academyBadge' => $model]),
],
default => [],
};
}
private function serializeFormRecord(string $resource, Model $record): array
{
return match ($resource) {
'courses' => [
'title' => (string) ($record->title ?? ''),
'slug' => (string) ($record->slug ?? ''),
'subtitle' => (string) ($record->subtitle ?? ''),
'excerpt' => (string) ($record->excerpt ?? ''),
'description' => (string) ($record->description ?? ''),
'cover_image' => (string) ($record->cover_image ?? ''),
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($record->cover_image ?? '')),
'teaser_image' => (string) ($record->teaser_image ?? ''),
'teaser_image_url' => $this->resolveLessonCoverImageUrl((string) ($record->teaser_image ?? '')),
'access_level' => (string) ($record->access_level ?? 'free'),
'difficulty' => (string) ($record->difficulty ?? 'beginner'),
'status' => (string) ($record->status ?? 'draft'),
'order_num' => (int) ($record->order_num ?? 0),
'estimated_minutes' => $record->estimated_minutes,
'published_at' => optional($record->published_at)?->format('Y-m-d\TH:i'),
'seo_title' => (string) ($record->seo_title ?? ''),
'seo_description' => (string) ($record->seo_description ?? ''),
'meta_keywords' => (string) ($record->meta_keywords ?? ''),
'og_title' => (string) ($record->og_title ?? ''),
'og_description' => (string) ($record->og_description ?? ''),
'og_image' => (string) ($record->og_image ?? ''),
'is_featured' => (bool) ($record->is_featured ?? false),
],
'categories' => [
'type' => (string) ($record->type ?? 'lesson'),
'name' => (string) ($record->name ?? ''),
'slug' => (string) ($record->slug ?? ''),
'description' => (string) ($record->description ?? ''),
'icon' => (string) ($record->icon ?? ''),
'order_num' => (int) ($record->order_num ?? 0),
'active' => (bool) ($record->active ?? true),
],
'lessons' => [
'category_id' => $record->category_id,
'course_ids' => $record instanceof AcademyLesson
? $record->courses()->pluck('academy_courses.id')->map(static fn ($id): string => (string) $id)->values()->all()
: [],
'title' => (string) ($record->title ?? ''),
'slug' => (string) ($record->slug ?? ''),
'lesson_number' => $record->lesson_number,
'course_order' => $record->course_order,
'series_name' => (string) ($record->series_name ?? ''),
'excerpt' => (string) ($record->excerpt ?? ''),
'content' => (string) ($record->content ?? ''),
'content_markdown' => (string) ($record->content_markdown ?? ''),
'difficulty' => (string) ($record->difficulty ?? 'beginner'),
'access_level' => (string) ($record->access_level ?? 'free'),
'lesson_type' => (string) ($record->lesson_type ?? 'article'),
'cover_image' => (string) ($record->cover_image ?? ''),
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($record->cover_image ?? '')),
'article_cover_image' => (string) ($record->article_cover_image ?? ''),
'article_cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($record->article_cover_image ?? '')),
'tags' => implode(', ', (array) ($record->tags ?? [])),
'video_url' => (string) ($record->video_url ?? ''),
'reading_minutes' => (int) ($record->reading_minutes ?? 5),
'published_at' => optional($record->published_at)?->format('Y-m-d\TH:i'),
'seo_title' => (string) ($record->seo_title ?? ''),
'seo_description' => (string) ($record->seo_description ?? ''),
'featured' => (bool) ($record->featured ?? false),
'active' => (bool) ($record->active ?? true),
'blocks' => $record instanceof AcademyLesson
? $record->blocks->map(fn (AcademyLessonBlock $block): array => $this->serializeLessonBlock($block))->values()->all()
: [],
],
'prompts' => [
'category_id' => $record->category_id,
'title' => (string) ($record->title ?? ''),
'slug' => (string) ($record->slug ?? ''),
'excerpt' => (string) ($record->excerpt ?? ''),
'prompt' => (string) ($record->prompt ?? ''),
'negative_prompt' => (string) ($record->negative_prompt ?? ''),
'usage_notes' => (string) ($record->usage_notes ?? ''),
'workflow_notes' => (string) ($record->workflow_notes ?? ''),
'difficulty' => (string) ($record->difficulty ?? 'beginner'),
'access_level' => (string) ($record->access_level ?? 'free'),
'aspect_ratio' => (string) ($record->aspect_ratio ?? ''),
'tags' => implode(', ', (array) ($record->tags ?? [])),
'tool_notes' => $this->serializePromptToolNotes((array) ($record->tool_notes ?? [])),
'preview_image' => (string) ($record->preview_image ?? ''),
'preview_image_url' => $this->resolvePromptPreviewImageUrl((string) ($record->preview_image ?? '')),
'preview_image_file' => null,
'published_at' => optional($record->published_at)?->format('Y-m-d\TH:i'),
'seo_title' => (string) ($record->seo_title ?? ''),
'seo_description' => (string) ($record->seo_description ?? ''),
'featured' => (bool) ($record->featured ?? false),
'prompt_of_week' => (bool) ($record->prompt_of_week ?? false),
'active' => (bool) ($record->active ?? true),
],
'packs' => [
'title' => (string) ($record->title ?? ''),
'slug' => (string) ($record->slug ?? ''),
'excerpt' => (string) ($record->excerpt ?? ''),
'description' => (string) ($record->description ?? ''),
'access_level' => (string) ($record->access_level ?? 'creator'),
'one_time_price_cents' => $record->one_time_price_cents,
'currency' => (string) ($record->currency ?? 'EUR'),
'cover_image' => (string) ($record->cover_image ?? ''),
'tags' => implode(', ', (array) ($record->tags ?? [])),
'prompt_ids' => $record instanceof AcademyPromptPack ? $record->prompts->pluck('id')->all() : [],
'published_at' => optional($record->published_at)?->format('Y-m-d\TH:i'),
'featured' => (bool) ($record->featured ?? false),
'active' => (bool) ($record->active ?? true),
],
'challenges' => [
'title' => (string) ($record->title ?? ''),
'slug' => (string) ($record->slug ?? ''),
'excerpt' => (string) ($record->excerpt ?? ''),
'description' => (string) ($record->description ?? ''),
'brief' => (string) ($record->brief ?? ''),
'rules' => (string) ($record->rules ?? ''),
'access_level' => (string) ($record->access_level ?? 'free'),
'status' => (string) ($record->status ?? 'draft'),
'starts_at' => optional($record->starts_at)?->format('Y-m-d\TH:i'),
'ends_at' => optional($record->ends_at)?->format('Y-m-d\TH:i'),
'voting_starts_at' => optional($record->voting_starts_at)?->format('Y-m-d\TH:i'),
'voting_ends_at' => optional($record->voting_ends_at)?->format('Y-m-d\TH:i'),
'cover_image' => (string) ($record->cover_image ?? ''),
'prize_text' => (string) ($record->prize_text ?? ''),
'required_tags' => implode(', ', (array) ($record->required_tags ?? [])),
'allowed_categories' => implode(', ', (array) ($record->allowed_categories ?? [])),
'featured' => (bool) ($record->featured ?? false),
'active' => (bool) ($record->active ?? true),
],
'badges' => [
'name' => (string) ($record->name ?? ''),
'slug' => (string) ($record->slug ?? ''),
'description' => (string) ($record->description ?? ''),
'icon' => (string) ($record->icon ?? ''),
'badge_type' => (string) ($record->badge_type ?? 'achievement'),
'rules' => json_encode((array) ($record->rules ?? []), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
'active' => (bool) ($record->active ?? true),
],
default => [],
};
}
/**
* @return array<string, mixed>
*/
private function persistLessonAttributes(UpsertAcademyLessonRequest $request, ?AcademyLesson $lesson = null): array
{
$validated = $request->validated();
unset($validated['course_ids']);
unset($validated['blocks']);
$contentSource = in_array((string) ($validated['content_source'] ?? ''), ['html', 'markdown'], true)
? (string) $validated['content_source']
: ((string) ($lesson?->content_markdown ?? '') !== '' ? 'markdown' : 'html');
$contentMarkdown = $this->nullableTrimmedString($validated['content_markdown'] ?? null);
$contentHtml = $this->nullableTrimmedString($validated['content'] ?? null);
$currentCoverImage = trim((string) ($lesson?->cover_image ?? ''));
$currentArticleCoverImage = trim((string) ($lesson?->article_cover_image ?? ''));
$nextCoverImage = filled($validated['cover_image'] ?? null)
? trim((string) $validated['cover_image'])
: null;
$nextArticleCoverImage = filled($validated['article_cover_image'] ?? null)
? trim((string) $validated['article_cover_image'])
: null;
if ($currentCoverImage !== '' && $currentCoverImage !== (string) $nextCoverImage) {
$this->deleteStoredLessonCoverIfLocal($currentCoverImage);
}
if ($currentArticleCoverImage !== '' && $currentArticleCoverImage !== (string) $nextArticleCoverImage) {
$this->deleteStoredLessonCoverIfLocal($currentArticleCoverImage);
}
$validated['cover_image'] = $nextCoverImage;
$validated['article_cover_image'] = $nextArticleCoverImage;
$validated['content_markdown'] = $contentSource === 'markdown' ? $contentMarkdown : null;
$validated['content'] = $contentSource === 'markdown' ? $contentHtml : $contentHtml;
unset($validated['content_source']);
if ($contentSource === 'markdown' && $contentMarkdown !== null) {
$validated['content'] = $this->lessonMarkdownRenderer->render($contentMarkdown);
}
$validated['reading_minutes'] = $this->estimateLessonReadingMinutes(
(string) ($validated['content'] ?? ''),
(string) ($validated['content_markdown'] ?? ''),
);
// Auto-publish: if marked active but no published_at set, default to now.
if (! empty($validated['active']) && empty($validated['published_at'])) {
$validated['published_at'] = now();
}
return $validated;
}
/**
* @param array<int, int> $courseIds
*/
private function syncLessonCourses(AcademyLesson $lesson, array $courseIds): void
{
$selectedCourseIds = collect($courseIds)
->map(static fn ($courseId): int => (int) $courseId)
->filter(static fn (int $courseId): bool => $courseId > 0)
->unique()
->values();
$existingCourseLessons = $lesson->courseLessons()->get()->keyBy(fn (AcademyCourseLesson $courseLesson): int => (int) $courseLesson->course_id);
$affectedCourseIds = $existingCourseLessons->keys()->map(static fn ($courseId): int => (int) $courseId)
->merge($selectedCourseIds)
->unique()
->values();
foreach ($selectedCourseIds as $courseId) {
if ($existingCourseLessons->has($courseId)) {
continue;
}
AcademyCourseLesson::query()->create([
'course_id' => $courseId,
'lesson_id' => $lesson->id,
'section_id' => null,
'order_num' => (int) ((AcademyCourseLesson::query()->where('course_id', $courseId)->max('order_num') ?? -1) + 1),
'is_required' => true,
'access_override' => null,
'unlock_after_lesson_id' => null,
]);
}
$existingCourseLessons
->reject(fn (AcademyCourseLesson $courseLesson, int $courseId): bool => $selectedCourseIds->contains($courseId))
->each(fn (AcademyCourseLesson $courseLesson): bool|null => $courseLesson->delete());
if ($affectedCourseIds->isEmpty()) {
return;
}
$affectedCourseIds->each(fn (int $courseId): bool => tap(true, fn () => $this->courseLessonOrdering->syncCourse($courseId)));
$counts = AcademyCourseLesson::query()
->selectRaw('course_id, count(*) as aggregate')
->whereIn('course_id', $affectedCourseIds->all())
->groupBy('course_id')
->pluck('aggregate', 'course_id');
AcademyCourse::query()
->whereIn('id', $affectedCourseIds->all())
->get()
->each(function (AcademyCourse $course) use ($counts): void {
$course->forceFill([
'lessons_count_cache' => (int) ($counts[$course->id] ?? 0),
])->save();
});
}
/**
* @return array<int, array<string, mixed>>
*/
private function courseOptions(): array
{
return AcademyCourse::query()
->orderByDesc('is_featured')
->orderBy('order_num')
->orderBy('title')
->get()
->map(fn (AcademyCourse $course): array => [
'value' => (string) $course->id,
'label' => (string) $course->title,
])
->values()
->all();
}
/**
* @return array<string, array<string, int|array<int, int>>>
*/
private function serializeLessonNumberingContext(?AcademyLesson $currentLesson): array
{
$otherLessons = AcademyLesson::query()
->when($currentLesson?->exists, fn ($query) => $query->whereKeyNot($currentLesson->id))
->get(['lesson_number', 'course_order']);
return [
'lesson_number' => $this->buildSequentialNumberSummary($otherLessons->pluck('lesson_number')->all()),
'course_order' => $this->buildSequentialNumberSummary($otherLessons->pluck('course_order')->all()),
];
}
/**
* @return array<int, array<string, mixed>>
*/
private function serializeLessonEditorCourses(?AcademyLesson $currentLesson): array
{
return AcademyCourse::query()
->with([
'courseLessons' => fn ($query) => $query
->with(['lesson:id,title,slug,lesson_number,series_name', 'section:id,title'])
->orderBy('order_num')
->orderBy('id'),
])
->orderByDesc('is_featured')
->orderBy('order_num')
->orderBy('title')
->get()
->map(function (AcademyCourse $course) use ($currentLesson): array {
$courseLessons = $course->courseLessons
->sortBy([['order_num', 'asc'], ['id', 'asc']])
->values();
return [
'value' => (string) $course->id,
'label' => (string) $course->title,
'description' => sprintf('%s · %s', (string) $course->difficulty, (string) $course->status),
'status' => (string) $course->status,
'difficulty' => (string) $course->difficulty,
'access_level' => (string) $course->access_level,
'lesson_count' => $courseLessons->count(),
'next_order_num' => (int) (($courseLessons->max('order_num') ?? -1) + 1),
'attach_url' => route('admin.academy.courses.lessons.attach', ['academyCourse' => $course]),
'reorder_url' => route('admin.academy.courses.reorder', ['academyCourse' => $course]),
'builder_url' => route('admin.academy.courses.builder.edit', ['academyCourse' => $course]),
'edit_url' => route('admin.academy.courses.edit', ['academyCourse' => $course]),
'preview_url' => route('academy.courses.show', ['course' => $course->slug]),
'lessons' => $courseLessons->map(function (AcademyCourseLesson $courseLesson) use ($currentLesson, $course): array {
$lesson = $courseLesson->lesson;
return [
'id' => (int) $courseLesson->id,
'lesson_id' => (int) $courseLesson->lesson_id,
'title' => (string) ($lesson?->title ?? 'Untitled lesson'),
'slug' => (string) ($lesson?->slug ?? ''),
'section_id' => $courseLesson->section_id ? (int) $courseLesson->section_id : null,
'order_num' => (int) ($courseLesson->order_num ?? 0),
'display_order' => (int) ($courseLesson->order_num ?? 0) + 1,
'formatted_lesson_number' => $lesson?->formatted_lesson_number,
'series_name' => (string) ($lesson?->series_name ?? ''),
'section_title' => (string) ($courseLesson->section?->title ?? ''),
'is_required' => (bool) $courseLesson->is_required,
'is_current' => $currentLesson?->exists
? (int) $courseLesson->lesson_id === (int) $currentLesson->id
: false,
'destroy_url' => route('admin.academy.courses.lessons.destroy', ['academyCourse' => $course, 'academyCourseLesson' => $courseLesson]),
'edit_url' => $lesson instanceof AcademyLesson
? route('admin.academy.lessons.edit', ['academyLesson' => $lesson])
: null,
];
})->all(),
];
})
->values()
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function serializeLessonRevisions(AcademyLesson $lesson): array
{
return AcademyLessonRevision::query()
->with('user')
->where('lesson_id', $lesson->id)
->latest('id')
->limit(12)
->get()
->map(function (AcademyLessonRevision $revision) use ($lesson): array {
$snapshot = is_array($revision->snapshot_json) ? $revision->snapshot_json : [];
$fields = is_array($snapshot['fields'] ?? null) ? $snapshot['fields'] : [];
return [
'id' => (int) $revision->id,
'created_at' => $revision->created_at?->toISOString(),
'created_label' => $revision->created_at?->diffForHumans(),
'actor_name' => (string) ($revision->user?->name ?? 'Staff'),
'change_note' => (string) ($revision->change_note ?? ''),
'restore_url' => route('admin.academy.lessons.revisions.restore', [
'academyLesson' => $lesson,
'academyLessonRevision' => $revision,
]),
'snapshot' => [
'title' => (string) ($fields['title'] ?? ''),
'excerpt' => (string) ($fields['excerpt'] ?? ''),
'content_preview' => Str::limit(strip_tags((string) ($fields['content'] ?? '')), 180),
'course_count' => count((array) ($snapshot['course_ids'] ?? [])),
'block_count' => count((array) ($snapshot['blocks'] ?? [])),
],
];
})
->values()
->all();
}
/**
* @param array<int, mixed> $values
* @return array<string, int|array<int, int>>
*/
private function buildSequentialNumberSummary(array $values): array
{
$numbers = collect($values)
->map(static fn ($value): int => (int) $value)
->filter(static fn (int $value): bool => $value > 0)
->unique()
->sort()
->values();
$missing = [];
$expected = 1;
foreach ($numbers as $number) {
while ($expected < $number) {
$missing[] = $expected;
$expected += 1;
}
$expected = $number + 1;
}
return [
'suggested' => (int) ($missing[0] ?? (($numbers->last() ?? 0) + 1)),
'highest' => (int) ($numbers->last() ?? 0),
'used_count' => $numbers->count(),
'missing' => array_slice($missing, 0, 8),
];
}
private function createLessonRevision(AcademyLesson $lesson, ?User $user, ?string $changeNote = null): AcademyLessonRevision
{
$lesson->loadMissing(['blocks.comparisonResults', 'courses']);
return AcademyLessonRevision::query()->create([
'lesson_id' => $lesson->id,
'user_id' => $user?->id,
'change_note' => $this->nullableTrimmedString($changeNote),
'snapshot_json' => $this->serializeLessonRevisionSnapshot($lesson),
]);
}
/**
* @return array<string, mixed>
*/
private function serializeLessonRevisionSnapshot(AcademyLesson $lesson): array
{
return [
'content_source' => (string) ($lesson->content_markdown ? 'markdown' : 'html'),
'fields' => [
'category_id' => $lesson->category_id,
'title' => (string) $lesson->title,
'slug' => (string) $lesson->slug,
'lesson_number' => $lesson->lesson_number,
'course_order' => $lesson->course_order,
'series_name' => (string) ($lesson->series_name ?? ''),
'excerpt' => (string) ($lesson->excerpt ?? ''),
'content' => (string) ($lesson->content ?? ''),
'content_markdown' => (string) ($lesson->content_markdown ?? ''),
'difficulty' => (string) $lesson->difficulty,
'access_level' => (string) $lesson->access_level,
'lesson_type' => (string) $lesson->lesson_type,
'cover_image' => (string) ($lesson->cover_image ?? ''),
'article_cover_image' => (string) ($lesson->article_cover_image ?? ''),
'tags' => array_values((array) ($lesson->tags ?? [])),
'video_url' => (string) ($lesson->video_url ?? ''),
'reading_minutes' => (int) ($lesson->reading_minutes ?? 5),
'featured' => (bool) ($lesson->featured ?? false),
'active' => (bool) ($lesson->active ?? true),
'published_at' => $lesson->published_at?->toISOString(),
'seo_title' => (string) ($lesson->seo_title ?? ''),
'seo_description' => (string) ($lesson->seo_description ?? ''),
],
'course_ids' => $lesson->courses->pluck('id')->map(static fn ($id): int => (int) $id)->values()->all(),
'blocks' => $lesson->blocks->map(fn (AcademyLessonBlock $block): array => $this->serializeLessonBlock($block))->values()->all(),
];
}
private function applyLessonRevisionSnapshot(AcademyLesson $lesson, AcademyLessonRevision $revision, ?string $field = null): void
{
$snapshot = is_array($revision->snapshot_json) ? $revision->snapshot_json : [];
$fields = is_array($snapshot['fields'] ?? null) ? $snapshot['fields'] : [];
if ($field === 'course_ids') {
$this->syncLessonCourses($lesson, array_values((array) ($snapshot['course_ids'] ?? [])));
return;
}
if ($field === 'blocks') {
$this->syncLessonBlocks($lesson, array_values((array) ($snapshot['blocks'] ?? [])));
return;
}
if ($field === 'content') {
$contentSource = (string) ($snapshot['content_source'] ?? 'html');
$lesson->forceFill([
'content' => (string) ($fields['content'] ?? ''),
'content_markdown' => $contentSource === 'markdown'
? $this->nullableTrimmedString($fields['content_markdown'] ?? null)
: null,
])->save();
return;
}
if (is_string($field) && $field !== '') {
$lesson->forceFill([$field => $this->normalizeLessonRevisionFieldValue($field, $fields[$field] ?? null)])->save();
return;
}
$lesson->forceFill([
'category_id' => $fields['category_id'] ?? null,
'title' => (string) ($fields['title'] ?? ''),
'slug' => (string) ($fields['slug'] ?? ''),
'lesson_number' => $fields['lesson_number'] ?? null,
'course_order' => $fields['course_order'] ?? null,
'series_name' => (string) ($fields['series_name'] ?? ''),
'excerpt' => (string) ($fields['excerpt'] ?? ''),
'content' => (string) ($fields['content'] ?? ''),
'content_markdown' => (string) ($snapshot['content_source'] ?? 'html') === 'markdown'
? $this->nullableTrimmedString($fields['content_markdown'] ?? null)
: null,
'difficulty' => (string) ($fields['difficulty'] ?? 'beginner'),
'access_level' => (string) ($fields['access_level'] ?? 'free'),
'lesson_type' => (string) ($fields['lesson_type'] ?? 'article'),
'cover_image' => $this->nullableTrimmedString($fields['cover_image'] ?? null),
'article_cover_image' => $this->nullableTrimmedString($fields['article_cover_image'] ?? null),
'tags' => array_values((array) ($fields['tags'] ?? [])),
'video_url' => $this->nullableTrimmedString($fields['video_url'] ?? null),
'reading_minutes' => (int) ($fields['reading_minutes'] ?? 5),
'featured' => (bool) ($fields['featured'] ?? false),
'active' => (bool) ($fields['active'] ?? true),
'published_at' => filled($fields['published_at'] ?? null) ? Carbon::parse((string) $fields['published_at']) : null,
'seo_title' => $this->nullableTrimmedString($fields['seo_title'] ?? null),
'seo_description' => $this->nullableTrimmedString($fields['seo_description'] ?? null),
])->save();
$this->syncLessonCourses($lesson, array_values((array) ($snapshot['course_ids'] ?? [])));
$this->syncLessonBlocks($lesson, array_values((array) ($snapshot['blocks'] ?? [])));
}
private function normalizeLessonRevisionFieldValue(string $field, mixed $value): mixed
{
return match ($field) {
'category_id', 'lesson_number', 'course_order' => filled($value) ? (int) $value : null,
'reading_minutes' => max(1, (int) ($value ?? 5)),
'featured', 'active' => (bool) $value,
'published_at' => filled($value) ? Carbon::parse((string) $value) : null,
'cover_image', 'article_cover_image', 'video_url', 'seo_title', 'seo_description' => $this->nullableTrimmedString($value),
'tags' => array_values((array) ($value ?? [])),
default => is_string($value) ? $value : $value,
};
}
private function estimateLessonReadingMinutes(?string $contentHtml, ?string $contentMarkdown): int
{
$source = trim((string) ($contentMarkdown ?: ''));
if ($source === '') {
$source = html_entity_decode(strip_tags((string) ($contentHtml ?? '')), ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
$normalized = preg_replace('/\s+/u', ' ', trim((string) $source));
if (! is_string($normalized) || $normalized === '') {
return 1;
}
$words = preg_split('/\s+/u', $normalized, -1, PREG_SPLIT_NO_EMPTY);
$wordCount = is_array($words) ? count($words) : 0;
return max(1, (int) ceil($wordCount / 180));
}
private function persistCourseAttributes(UpsertAcademyCourseRequest $request, ?AcademyCourse $course = null): array
{
$validated = $request->validated();
$currentCoverImage = trim((string) ($course?->cover_image ?? ''));
$currentTeaserImage = trim((string) ($course?->teaser_image ?? ''));
$nextCoverImage = $this->nullableTrimmedString($validated['cover_image'] ?? null);
$nextTeaserImage = $this->nullableTrimmedString($validated['teaser_image'] ?? null);
if ($currentCoverImage !== '' && $currentCoverImage !== (string) $nextCoverImage) {
$this->deleteStoredLessonCoverIfLocal($currentCoverImage);
}
if ($currentTeaserImage !== '' && $currentTeaserImage !== (string) $nextTeaserImage) {
$this->deleteStoredLessonCoverIfLocal($currentTeaserImage);
}
$validated['cover_image'] = $nextCoverImage;
$validated['teaser_image'] = $nextTeaserImage;
$validated['seo_title'] = $this->nullableTrimmedString($validated['seo_title'] ?? null);
$validated['seo_description'] = $this->nullableTrimmedString($validated['seo_description'] ?? null);
$validated['meta_keywords'] = $this->nullableTrimmedString($validated['meta_keywords'] ?? null);
$validated['og_title'] = $this->nullableTrimmedString($validated['og_title'] ?? null);
$validated['og_description'] = $this->nullableTrimmedString($validated['og_description'] ?? null);
$validated['og_image'] = $this->nullableTrimmedString($validated['og_image'] ?? null);
if ((string) ($validated['status'] ?? '') === 'published' && empty($validated['published_at'])) {
$validated['published_at'] = now();
}
return $validated;
}
/**
* @return array<string, mixed>
*/
/**
* @return array<int, array<string, mixed>>
*/
private function serializeCourseEditorLessons(AcademyCourse $course): array
{
$course->loadMissing(['courseLessons.lesson', 'courseLessons.section']);
return $course->courseLessons
->sortBy([['order_num', 'asc'], ['id', 'asc']])
->values()
->map(function (AcademyCourseLesson $courseLesson) use ($course): array {
$lesson = $courseLesson->lesson;
return [
'id' => (int) $courseLesson->id,
'lesson_id' => (int) $courseLesson->lesson_id,
'title' => (string) ($lesson?->title ?? 'Untitled lesson'),
'slug' => (string) ($lesson?->slug ?? ''),
'section_id' => $courseLesson->section_id ? (int) $courseLesson->section_id : null,
'section_title' => (string) ($courseLesson->section?->title ?? ''),
'order_num' => (int) ($courseLesson->order_num ?? 0),
'display_order' => (int) ($courseLesson->order_num ?? 0) + 1,
'formatted_lesson_number' => $lesson instanceof AcademyLesson ? $lesson->formatted_lesson_number : null,
'is_required' => (bool) $courseLesson->is_required,
'difficulty' => (string) ($lesson?->difficulty ?? ''),
'access_level' => (string) ($lesson?->access_level ?? ''),
'destroy_url' => route('admin.academy.courses.lessons.destroy', [
'academyCourse' => $course,
'academyCourseLesson' => $courseLesson,
]),
'edit_url' => $lesson instanceof AcademyLesson
? route('admin.academy.lessons.edit', ['academyLesson' => $lesson])
: null,
];
})
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function serializeCourseAvailableLessons(AcademyCourse $course): array
{
$course->loadMissing(['courseLessons']);
$attachedLessonIds = $course->courseLessons
->pluck('lesson_id')
->map(fn ($id): int => (int) $id)
->flip()
->all();
return AcademyLesson::query()
->with('category')
->orderBy('title')
->get()
->map(fn (AcademyLesson $lesson): array => [
'id' => (int) $lesson->id,
'title' => (string) $lesson->title,
'slug' => (string) $lesson->slug,
'difficulty' => (string) $lesson->difficulty,
'access_level' => (string) $lesson->access_level,
'active' => (bool) $lesson->active,
'category' => $lesson->category ? (string) $lesson->category->name : '',
'attached' => isset($attachedLessonIds[(int) $lesson->id]),
])
->values()
->all();
}
private function serializeCourseOutlineSummary(AcademyCourse $course): array
{
$course->loadMissing(['sections', 'courseLessons']);
$sections = $course->sections
->sortBy([['order_num', 'asc'], ['id', 'asc']])
->values();
$courseLessons = $course->courseLessons;
return [
'section_count' => $sections->count(),
'visible_section_count' => $sections->where('is_visible', true)->count(),
'lesson_count' => $courseLessons->count(),
'required_lesson_count' => $courseLessons->where('is_required', true)->count(),
'unsectioned_lesson_count' => $courseLessons->whereNull('section_id')->count(),
'sections' => $sections->map(function ($section) use ($courseLessons): array {
return [
'id' => (int) $section->id,
'title' => (string) $section->title,
'is_visible' => (bool) $section->is_visible,
'lesson_count' => $courseLessons->where('section_id', $section->id)->count(),
];
})->all(),
];
}
/**
* @param array<int, array<string, mixed>> $blocks
*/
private function syncLessonBlocks(AcademyLesson $lesson, array $blocks): void
{
$lesson->loadMissing(['blocks.comparisonResults']);
$existingBlocks = $lesson->blocks->keyBy(fn (AcademyLessonBlock $block): int => (int) $block->id);
$retainedBlockIds = [];
foreach ($blocks as $index => $blockData) {
$blockId = isset($blockData['id']) ? (int) $blockData['id'] : null;
$block = $blockId !== null ? $existingBlocks->get($blockId) : null;
if (! $block instanceof AcademyLessonBlock) {
$block = new AcademyLessonBlock;
$block->lesson()->associate($lesson);
}
$payload = is_array($blockData['payload'] ?? null) ? $blockData['payload'] : [];
$block->fill([
'type' => (string) ($blockData['type'] ?? 'ai_comparison'),
'title' => $this->nullableTrimmedString($blockData['title'] ?? null) ?? $this->nullableTrimmedString($payload['title'] ?? null),
'payload' => $this->normalizeLessonBlockPayload($payload),
'sort_order' => (int) ($blockData['sort_order'] ?? $index),
'active' => (bool) ($blockData['active'] ?? true),
]);
$block->save();
$retainedBlockIds[] = (int) $block->id;
$this->syncLessonBlockComparisonResults($block, is_array($blockData['comparison_results'] ?? null) ? $blockData['comparison_results'] : []);
}
$lesson->blocks
->filter(fn (AcademyLessonBlock $block): bool => ! in_array((int) $block->id, $retainedBlockIds, true))
->each(function (AcademyLessonBlock $block): void {
foreach ($block->comparisonResults as $result) {
$this->deleteStoredLessonMediaIfLocal($result->image_path);
$this->deleteStoredLessonMediaIfLocal($result->thumb_path);
}
$block->delete();
});
}
/**
* @param array<int, array<string, mixed>> $results
*/
private function syncLessonBlockComparisonResults(AcademyLessonBlock $block, array $results): void
{
$existingResults = $block->comparisonResults->keyBy(fn (AcademyAiComparisonResult $result): int => (int) $result->id);
$retainedResultIds = [];
foreach ($results as $index => $resultData) {
$resultId = isset($resultData['id']) ? (int) $resultData['id'] : null;
$result = $resultId !== null ? $existingResults->get($resultId) : null;
if (! $result instanceof AcademyAiComparisonResult) {
$result = new AcademyAiComparisonResult;
$result->block()->associate($block);
}
$previousImagePath = (string) ($result->image_path ?? '');
$previousThumbPath = (string) ($result->thumb_path ?? '');
$nextImagePath = $this->nullableTrimmedString($resultData['image_path'] ?? null);
$nextThumbPath = $this->nullableTrimmedString($resultData['thumb_path'] ?? null);
$result->fill([
'provider' => $this->nullableTrimmedString($resultData['provider'] ?? null),
'model_name' => $this->nullableTrimmedString($resultData['model_name'] ?? null),
'image_path' => $nextImagePath,
'thumb_path' => $nextThumbPath,
'settings' => $this->nullableTrimmedString($resultData['settings'] ?? null),
'strengths' => $this->nullableTrimmedString($resultData['strengths'] ?? null),
'weaknesses' => $this->nullableTrimmedString($resultData['weaknesses'] ?? null),
'best_for' => $this->nullableTrimmedString($resultData['best_for'] ?? null),
'score' => $resultData['score'] ?? null,
'sort_order' => (int) ($resultData['sort_order'] ?? $index),
'active' => (bool) ($resultData['active'] ?? true),
]);
$result->save();
if ($previousImagePath !== '' && $previousImagePath !== (string) $nextImagePath) {
$this->deleteStoredLessonMediaIfLocal($previousImagePath);
}
if ($previousThumbPath !== '' && $previousThumbPath !== (string) $nextThumbPath) {
$this->deleteStoredLessonMediaIfLocal($previousThumbPath);
}
$retainedResultIds[] = (int) $result->id;
}
$block->comparisonResults
->filter(fn (AcademyAiComparisonResult $result): bool => ! in_array((int) $result->id, $retainedResultIds, true))
->each(function (AcademyAiComparisonResult $result): void {
$this->deleteStoredLessonMediaIfLocal($result->image_path);
$this->deleteStoredLessonMediaIfLocal($result->thumb_path);
$result->delete();
});
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function normalizeLessonBlockPayload(array $payload): array
{
$criteria = collect($payload['criteria'] ?? [])
->map(fn ($criterion): string => trim((string) $criterion))
->filter(static fn (string $criterion): bool => $criterion !== '')
->values()
->all();
return [
'title' => $this->nullableTrimmedString($payload['title'] ?? null),
'intro' => $this->nullableTrimmedString($payload['intro'] ?? null),
'prompt' => $this->nullableTrimmedString($payload['prompt'] ?? null),
'negative_prompt' => $this->nullableTrimmedString($payload['negative_prompt'] ?? null),
'aspect_ratio' => $this->nullableTrimmedString($payload['aspect_ratio'] ?? null),
'criteria' => $criteria,
];
}
/**
* @return array<string, mixed>
*/
private function serializeLessonBlock(AcademyLessonBlock $block): array
{
$payload = is_array($block->payload) ? $block->payload : [];
return [
'id' => (int) $block->id,
'type' => (string) $block->type,
'title' => (string) ($block->title ?? ''),
'payload' => $this->normalizeLessonBlockPayload($payload),
'sort_order' => (int) $block->sort_order,
'active' => (bool) $block->active,
'comparison_results' => $block->comparisonResults->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(),
];
}
/**
* @return array<string, mixed>
*/
private function persistPromptAttributes(UpsertAcademyPromptTemplateRequest $request, ?AcademyPromptTemplate $prompt = null): array
{
$validated = $request->validated();
unset($validated['preview_image_file']);
$newCategoryName = trim((string) ($validated['new_category_name'] ?? ''));
unset($validated['new_category_name']);
if ($newCategoryName !== '') {
$validated['category_id'] = $this->resolveOrCreatePromptCategoryId($newCategoryName);
}
$validated['tool_notes'] = $this->normalizePromptToolNotes((array) ($validated['tool_notes'] ?? []));
$previousToolNotes = $this->normalizePromptToolNotes((array) ($prompt?->tool_notes ?? []));
$currentPreviewImage = (string) ($prompt?->preview_image ?? '');
$previewImageFile = $this->promptPreviewImageUpload($request);
if ($previewImageFile instanceof UploadedFile) {
$this->deleteStoredPromptPreviewIfLocal($currentPreviewImage);
$validated['preview_image'] = $this->storePromptPreviewImage($previewImageFile);
} else {
$validated['preview_image'] = filled($validated['preview_image'] ?? null)
? trim((string) $validated['preview_image'])
: null;
}
// Auto-publish: if marked active but no published_at set, default to now.
if (! empty($validated['active']) && empty($validated['published_at'])) {
$validated['published_at'] = now();
}
$this->deleteRemovedPromptComparisonMedia($previousToolNotes, (array) ($validated['tool_notes'] ?? []));
return $validated;
}
private function resolveOrCreatePromptCategoryId(string $name): int
{
$normalizedName = trim($name);
$existing = AcademyCategory::query()
->where('type', 'prompt')
->whereRaw('LOWER(name) = ?', [mb_strtolower($normalizedName)])
->first();
if ($existing instanceof AcademyCategory) {
return (int) $existing->id;
}
$maxOrder = (int) (AcademyCategory::query()->where('type', 'prompt')->max('order_num') ?? 0);
$category = new AcademyCategory();
$category->forceFill([
'type' => 'prompt',
'name' => $normalizedName,
'slug' => Str::slug($normalizedName),
'description' => null,
'order_num' => $maxOrder + 1,
'active' => true,
])->save();
return (int) $category->id;
}
/**
* @param array<int, mixed> $notes
* @return array<int, array<string, mixed>>
*/
private function serializePromptToolNotes(array $notes): array
{
return collect($this->normalizePromptToolNotes($notes))
->map(fn (array $note): array => [
...$note,
'image_url' => $this->resolveLessonMediaUrl($note['image_path'] ?? null),
'thumb_url' => $this->resolveLessonMediaUrl($note['thumb_path'] ?? null),
])
->values()
->all();
}
/**
* @param array<int, mixed> $notes
* @return array<int, array<string, string>>
*/
private function normalizePromptToolNotes(array $notes): array
{
return collect($notes)
->map(function ($note): ?array {
if (is_string($note)) {
$normalized = [
'provider' => '',
'model_name' => '',
'notes' => trim($note),
'strengths' => '',
'weaknesses' => '',
'best_for' => '',
'image_path' => '',
'thumb_path' => '',
'settings' => '',
'score' => null,
'active' => true,
];
return $normalized['notes'] !== '' ? $normalized : null;
}
if (! is_array($note)) {
return null;
}
$normalized = [
'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'] ?? '')),
'thumb_path' => trim((string) ($note['thumb_path'] ?? '')),
'settings' => trim((string) ($note['settings'] ?? '')),
'score' => filled($note['score'] ?? null) ? max(1, min(10, (int) $note['score'])) : null,
'active' => filter_var($note['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
];
$hasContent = collect([
$normalized['provider'],
$normalized['model_name'],
$normalized['notes'],
$normalized['strengths'],
$normalized['weaknesses'],
$normalized['best_for'],
$normalized['image_path'],
$normalized['thumb_path'],
$normalized['settings'],
])->contains(fn (string $value): bool => $value !== '');
return $hasContent || $normalized['score'] !== null ? $normalized : null;
})
->filter()
->values()
->all();
}
/**
* @param array<int, array<string, mixed>> $previousNotes
* @param array<int, array<string, mixed>> $nextNotes
*/
private function deleteRemovedPromptComparisonMedia(array $previousNotes, array $nextNotes): void
{
$previousPaths = collect($previousNotes)
->flatMap(fn (array $note): array => [
trim((string) ($note['image_path'] ?? '')),
trim((string) ($note['thumb_path'] ?? '')),
])
->filter()
->unique();
$nextPaths = collect($nextNotes)
->flatMap(fn (array $note): array => [
trim((string) ($note['image_path'] ?? '')),
trim((string) ($note['thumb_path'] ?? '')),
])
->filter()
->unique()
->all();
$previousPaths
->reject(fn (string $path): bool => in_array($path, $nextPaths, true))
->each(fn (string $path): bool => $this->deleteStoredLessonMediaIfLocal($path));
}
private function promptPreviewImageUpload(UpsertAcademyPromptTemplateRequest $request): ?UploadedFile
{
$file = $request->file('preview_image_file');
if (! $file instanceof UploadedFile) {
return null;
}
$pathName = trim((string) $file->getPathname());
if ($file->isValid() && $pathName !== '' && is_file($pathName) && is_readable($pathName)) {
return $file;
}
throw ValidationException::withMessages([
'preview_image_file' => $this->promptPreviewImageUploadErrorMessage($file),
]);
}
private function storePromptPreviewImage(UploadedFile $file): string
{
$pathName = trim((string) $file->getPathname());
if ($pathName === '' || ! is_file($pathName) || ! is_readable($pathName)) {
throw ValidationException::withMessages([
'preview_image_file' => $this->promptPreviewImageUploadErrorMessage($file),
]);
}
if (! function_exists('imagecreatefromstring') || ! function_exists('imagewebp')) {
throw ValidationException::withMessages([
'preview_image_file' => 'The server is missing WebP image support. Enable the GD WebP extension to upload prompt preview images.',
]);
}
$binary = @file_get_contents($pathName);
if ($binary === false) {
throw ValidationException::withMessages([
'preview_image_file' => 'The uploaded preview image could not be opened for conversion. Please choose the file again and retry.',
]);
}
$image = @imagecreatefromstring($binary);
if (! $image instanceof \GdImage) {
throw ValidationException::withMessages([
'preview_image_file' => 'The uploaded preview image format could not be converted. Please use JPG, PNG, or WEBP.',
]);
}
try {
if (! imageistruecolor($image)) {
imagepalettetotruecolor($image);
}
imagealphablending($image, true);
imagesavealpha($image, true);
ob_start();
$converted = imagewebp($image, null, self::PROMPT_PREVIEW_WEBP_QUALITY);
$webpBinary = ob_get_clean();
if (! $converted || ! is_string($webpBinary) || $webpBinary === '') {
throw ValidationException::withMessages([
'preview_image_file' => 'The uploaded preview image could not be converted to WebP. Please try a different image.',
]);
}
$storedPath = self::PROMPT_PREVIEW_PREFIX.'/'.pathinfo(Str::replace('\\', '/', $file->hashName()), PATHINFO_FILENAME).'.webp';
Storage::disk($this->promptPreviewImageDisk())->put($storedPath, $webpBinary, ['visibility' => 'public']);
} finally {
imagedestroy($image);
}
return $storedPath;
}
private function deleteStoredPromptPreviewIfLocal(?string $path): void
{
$path = trim((string) $path);
if ($path === '' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
return;
}
if (! str_starts_with($path, self::PROMPT_PREVIEW_PREFIX.'/')) {
return;
}
$disk = $this->promptPreviewImageDisk();
if (Storage::disk($disk)->exists($path)) {
Storage::disk($disk)->delete($path);
}
}
private function promptPreviewImageUploadErrorMessage(UploadedFile $file): string
{
return match ($file->getError()) {
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The uploaded preview image exceeds the server upload limit.',
UPLOAD_ERR_PARTIAL => 'The uploaded preview image was only partially received. Please retry the upload.',
UPLOAD_ERR_NO_TMP_DIR => 'The server upload temp directory is unavailable. Check PHP upload temp configuration.',
UPLOAD_ERR_CANT_WRITE => 'The server could not write the uploaded preview image to temporary storage.',
UPLOAD_ERR_EXTENSION => 'A PHP extension blocked the preview image upload.',
default => 'The uploaded preview image could not be read. Please choose the file again and retry.',
};
}
private function promptPreviewImageDisk(): string
{
return (string) config('uploads.object_storage.disk', 's3');
}
private function resolvePromptPreviewImageUrl(?string $previewImage): ?string
{
$previewImage = trim((string) $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($this->promptPreviewImageDisk())->url($previewImage);
}
private function resolveLessonMediaUrl(?string $path): ?string
{
$path = trim((string) $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($this->promptPreviewImageDisk())->url($path);
}
private function deleteStoredLessonCoverIfLocal(?string $path): void
{
$path = trim((string) $path);
if ($path === '' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
return;
}
if (! str_starts_with($path, 'academy/lessons/covers/')) {
return;
}
$disk = $this->promptPreviewImageDisk();
if (Storage::disk($disk)->exists($path)) {
Storage::disk($disk)->delete($path);
}
}
private function deleteStoredLessonMediaIfLocal(?string $path): void
{
$path = trim((string) $path);
if ($path === '' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
return;
}
if (! str_starts_with($path, 'academy/lessons/body/') && ! str_starts_with($path, 'academy/lessons/covers/')) {
return;
}
$disk = $this->promptPreviewImageDisk();
if (Storage::disk($disk)->exists($path)) {
Storage::disk($disk)->delete($path);
}
}
private function nullableTrimmedString(mixed $value): ?string
{
$trimmed = trim((string) $value);
return $trimmed === '' ? null : $trimmed;
}
private function resolveLessonCoverImageUrl(?string $coverImage): ?string
{
$coverImage = trim((string) $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($this->promptPreviewImageDisk())->url($coverImage);
}
private function serializeCategoryOption(AcademyCategory $category): array
{
return [
'id' => (int) $category->id,
'value' => (int) $category->id,
'label' => (string) $category->name,
'name' => (string) $category->name,
'slug' => (string) $category->slug,
'description' => (string) ($category->description ?? ''),
'order_num' => (int) ($category->order_num ?? 0),
'active' => (bool) ($category->active ?? true),
'edit_url' => route('admin.academy.categories.edit', ['academyCategory' => $category]),
];
}
private function routeParams(string $resource, Model $record): array
{
return match ($resource) {
'courses' => ['academyCourse' => $record],
'categories' => ['academyCategory' => $record],
'lessons' => ['academyLesson' => $record],
'prompts' => ['academyPromptTemplate' => $record],
'packs' => ['academyPromptPack' => $record],
'challenges' => ['academyChallenge' => $record],
'badges' => ['academyBadge' => $record],
default => [],
};
}
private function categoryOptions(string $type): array
{
return AcademyCategory::query()
->where('type', $type)
->orderBy('order_num')
->orderBy('name')
->get()
->map(fn (AcademyCategory $category): array => ['value' => $category->id, 'label' => $category->name])
->prepend(['value' => '', 'label' => 'No category'])
->values()
->all();
}
private function promptOptions(): array
{
return AcademyPromptTemplate::query()
->orderBy('title')
->get()
->map(fn (AcademyPromptTemplate $prompt): array => ['value' => $prompt->id, 'label' => $prompt->title])
->values()
->all();
}
private function difficultyOptions(): array
{
return collect((array) config('academy.difficulty_levels', []))
->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])
->values()
->all();
}
private function courseDifficultyOptions(): array
{
return collect(['beginner', 'intermediate', 'advanced'])
->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])
->values()
->all();
}
private function accessOptions(): array
{
return [
['value' => 'free', 'label' => 'Free'],
['value' => 'creator', 'label' => 'Creator'],
['value' => 'pro', 'label' => 'Pro'],
];
}
private function courseAccessOptions(): array
{
return [
['value' => 'free', 'label' => 'Free'],
['value' => 'premium', 'label' => 'Premium'],
['value' => 'mixed', 'label' => 'Mixed'],
];
}
private function courseStatusOptions(): array
{
return [
['value' => 'draft', 'label' => 'Draft'],
['value' => 'review', 'label' => 'Review'],
['value' => 'published', 'label' => 'Published'],
['value' => 'archived', 'label' => 'Archived'],
];
}
private function syncPackItems(AcademyPromptPack $pack, array $promptIds): void
{
AcademyPromptPackItem::query()->where('pack_id', $pack->id)->delete();
foreach (array_values($promptIds) as $index => $promptId) {
AcademyPromptPackItem::query()->create([
'pack_id' => $pack->id,
'prompt_template_id' => (int) $promptId,
'order_num' => $index,
]);
}
}
}