Implement academy analytics, billing, and web stories updates

This commit is contained in:
2026-05-26 07:27:29 +02:00
parent 456c3d6bb0
commit 0b33a1b074
177 changed files with 27360 additions and 2685 deletions

View File

@@ -19,6 +19,7 @@ use App\Models\AcademyChallenge;
use App\Models\AcademyChallengeSubmission;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseLesson;
use App\Models\AcademyCourseSection;
use App\Models\AcademyLesson;
use App\Models\AcademyLessonBlock;
use App\Models\AcademyLessonRevision;
@@ -26,6 +27,7 @@ use App\Models\AcademyPromptPack;
use App\Models\AcademyPromptPackItem;
use App\Models\AcademyPromptTemplate;
use App\Models\User;
use App\Services\Academy\AcademyAdminBillingOverviewService;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyCourseLessonOrderingService;
use App\Services\Academy\AcademyLessonMarkdownRenderer;
@@ -38,6 +40,7 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@@ -48,7 +51,13 @@ final class AcademyAdminController extends Controller
private const PROMPT_PREVIEW_PREFIX = 'academy-prompts/previews';
private const PROMPT_PREVIEW_VARIANT_WIDTHS = [
'thumb' => 480,
'md' => 960,
];
public function __construct(
private readonly AcademyAdminBillingOverviewService $billingOverview,
private readonly AcademyCacheService $cache,
private readonly AcademyCourseLessonOrderingService $courseLessonOrdering,
private readonly AcademyLessonMarkdownRenderer $lessonMarkdownRenderer,
@@ -56,6 +65,8 @@ final class AcademyAdminController extends Controller
public function dashboard(): Response
{
$billingSummary = $this->billingOverview->summary();
return Inertia::render('Admin/Academy/Dashboard', [
'stats' => [
'courses' => AcademyCourse::query()->count(),
@@ -65,11 +76,13 @@ final class AcademyAdminController extends Controller
'challenges' => AcademyChallenge::query()->count(),
'submissions' => AcademyChallengeSubmission::query()->count(),
'badges' => AcademyBadge::query()->count(),
'creator_subscribers' => 0,
'pro_subscribers' => 0,
'mrr' => 0,
'active_subscribers' => (int) ($billingSummary['active_subscribers'] ?? 0),
'creator_subscribers' => (int) ($billingSummary['creator_subscribers'] ?? 0),
'pro_subscribers' => (int) ($billingSummary['pro_subscribers'] ?? 0),
'grace_period_subscribers' => (int) ($billingSummary['grace_period_subscribers'] ?? 0),
],
'links' => [
'billing' => route('admin.academy.billing'),
'courses' => route('admin.academy.courses.index'),
'categories' => route('admin.academy.categories.index'),
'lessons' => route('admin.academy.lessons.index'),
@@ -83,6 +96,22 @@ final class AcademyAdminController extends Controller
]);
}
public function billing(): Response
{
$summary = $this->billingOverview->summary();
return Inertia::render('Admin/Academy/Billing', [
'summary' => $summary,
'planBreakdown' => $summary['plan_breakdown'] ?? [],
'recentEvents' => $this->billingOverview->recentEvents(),
'links' => [
'dashboard' => route('admin.academy.dashboard'),
'pricing' => route('academy.pricing'),
'account' => route('academy.billing.account'),
],
]);
}
public function categoriesIndex(): Response
{
return $this->renderIndex('categories');
@@ -100,13 +129,20 @@ final class AcademyAdminController extends Controller
public function coursesStore(UpsertAcademyCourseRequest $request): RedirectResponse
{
$course = new AcademyCourse;
$course->fill($this->persistCourseAttributes($request))->save();
$course = $this->saveCourseFromRequest($request);
$this->cache->clearAll();
return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $course])->with('success', 'Academy course created.');
}
public function coursesStoreJson(UpsertAcademyCourseRequest $request): RedirectResponse
{
$course = $this->saveCourseFromRequest($request);
$this->cache->clearAll();
return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $course])->with('success', 'Academy course created from JSON.');
}
public function coursesEdit(AcademyCourse $academyCourse): Response
{
return $this->renderForm('courses', $academyCourse);
@@ -114,12 +150,94 @@ final class AcademyAdminController extends Controller
public function coursesUpdate(UpsertAcademyCourseRequest $request, AcademyCourse $academyCourse): RedirectResponse
{
$academyCourse->fill($this->persistCourseAttributes($request, $academyCourse))->save();
$this->saveCourseFromRequest($request, $academyCourse);
$this->cache->clearAll();
return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $academyCourse])->with('success', 'Academy course updated.');
}
public function coursesImportLessons(Request $request, AcademyCourse $academyCourse): RedirectResponse
{
$difficultyLevels = array_values(array_filter(array_map('strval', (array) config('academy.difficulty_levels', []))));
$validated = $request->validate([
'defaults' => ['nullable', 'array'],
'defaults.category_id' => ['nullable', 'integer', 'exists:academy_categories,id'],
'defaults.category_slug' => ['nullable', 'string', 'max:180'],
'defaults.category' => ['nullable', 'string', 'max:180'],
'defaults.difficulty' => ['nullable', 'string', Rule::in($difficultyLevels)],
'defaults.access_level' => ['nullable', 'string', Rule::in(['free', 'creator', 'pro'])],
'defaults.lesson_type' => ['nullable', 'string', 'max:80'],
'defaults.active' => ['nullable', 'boolean'],
'defaults.series_name' => ['nullable', 'string', 'max:120'],
'lessons' => ['required', 'array', 'min:1', 'max:250'],
'lessons.*.title' => ['required', 'string', 'max:180'],
'lessons.*.slug' => ['nullable', 'string', 'max:180'],
'lessons.*.goal' => ['nullable', 'string'],
'lessons.*.excerpt' => ['nullable', 'string'],
'lessons.*.category_id' => ['nullable', 'integer', 'exists:academy_categories,id'],
'lessons.*.category_slug' => ['nullable', 'string', 'max:180'],
'lessons.*.category' => ['nullable', 'string', 'max:180'],
'lessons.*.difficulty' => ['nullable', 'string', Rule::in($difficultyLevels)],
'lessons.*.access_level' => ['nullable', 'string', Rule::in(['free', 'creator', 'pro'])],
'lessons.*.lesson_type' => ['nullable', 'string', 'max:80'],
'lessons.*.active' => ['nullable', 'boolean'],
'lessons.*.series_name' => ['nullable', 'string', 'max:120'],
'lessons.*.tags' => ['nullable', 'array'],
'lessons.*.tags.*' => ['string', 'max:60'],
]);
$defaults = (array) ($validated['defaults'] ?? []);
$lessons = array_values((array) ($validated['lessons'] ?? []));
if ($lessons === []) {
throw ValidationException::withMessages([
'lessons' => 'Provide at least one lesson row to import.',
]);
}
DB::transaction(function () use ($academyCourse, $defaults, $lessons): void {
$reservedSlugs = AcademyLesson::query()
->pluck('slug')
->filter(fn ($slug): bool => is_string($slug) && trim($slug) !== '')
->map(fn ($slug): string => trim((string) $slug))
->values()
->all();
$nextOrder = (int) ((AcademyCourseLesson::query()->where('course_id', $academyCourse->id)->max('order_num') ?? -1) + 1);
foreach ($lessons as $lessonData) {
$attributes = $this->buildImportedCourseLessonAttributes($academyCourse, (array) $lessonData, $defaults, $reservedSlugs);
$lesson = new AcademyLesson;
$lesson->fill($attributes)->save();
AcademyCourseLesson::query()->create([
'course_id' => $academyCourse->id,
'lesson_id' => $lesson->id,
'section_id' => null,
'order_num' => $nextOrder,
'is_required' => true,
'access_override' => null,
'unlock_after_lesson_id' => null,
]);
$nextOrder++;
}
$this->courseLessonOrdering->syncCourse($academyCourse);
$academyCourse->forceFill([
'lessons_count_cache' => (int) AcademyCourseLesson::query()->where('course_id', $academyCourse->id)->count(),
])->save();
});
$this->cache->clearAll();
return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $academyCourse])
->with('success', sprintf('%d lesson%s imported into the course.', count($lessons), count($lessons) === 1 ? '' : 's'));
}
public function coursesDestroy(AcademyCourse $academyCourse): RedirectResponse
{
$this->deleteStoredLessonCoverIfLocal((string) $academyCourse->cover_image);
@@ -484,12 +602,40 @@ final class AcademyAdminController extends Controller
private function renderIndex(string $resource): Response
{
$meta = $this->resourceMeta($resource);
$query = $meta['model']::query()->latest('updated_at');
$search = trim((string) request()->query('search', ''));
$query = $meta['model']::query();
if ($resource === 'courses') {
$query->withCount('courseLessons');
if ($search !== '') {
$query->where(function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
}
$query->orderByDesc('is_featured')
->orderBy('order_num')
->orderByDesc('updated_at')
->orderByDesc('id');
} else {
$query->latest('updated_at');
}
if ($resource === 'prompts') {
$query->with('category');
}
if ($resource === 'lessons') {
$query->with('courses:id,title');
}
$items = $query->paginate(25)->withQueryString();
$items->getCollection()->transform(fn (Model $model): array => $this->serializeIndexItem($resource, $model));
@@ -500,6 +646,45 @@ final class AcademyAdminController extends Controller
'items' => $items,
'columns' => $meta['columns'],
'createUrl' => route($meta['route_base'].'.create'),
'filters' => [
'search' => $search,
],
'summary' => $resource === 'courses' ? [
'total' => (int) $items->total(),
'published' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where(function ($inner) use ($like): void {
$inner->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
})->where('status', AcademyCourse::STATUS_PUBLISHED)->count(),
'featured' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where(function ($inner) use ($like): void {
$inner->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
})->where('is_featured', true)->count(),
'drafts' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where(function ($inner) use ($like): void {
$inner->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
})->where('status', AcademyCourse::STATUS_DRAFT)->count(),
] : null,
]);
}
@@ -538,6 +723,9 @@ final class AcademyAdminController extends Controller
'outlineSummary' => $record instanceof AcademyCourse && $record->exists
? $this->serializeCourseOutlineSummary($record)
: null,
'courseSections' => $record instanceof AcademyCourse && $record->exists
? $this->serializeCourseEditorSections($record)
: [],
'courseLessons' => $record instanceof AcademyCourse && $record->exists
? $this->serializeCourseEditorLessons($record)
: [],
@@ -547,9 +735,19 @@ final class AcademyAdminController extends Controller
'attachLessonUrl' => $record instanceof AcademyCourse && $record->exists
? route('admin.academy.courses.lessons.attach', ['academyCourse' => $record])
: null,
'importLessonsUrl' => $record instanceof AcademyCourse && $record->exists
? route('admin.academy.courses.lessons.import', ['academyCourse' => $record])
: null,
'sectionStoreUrl' => $record instanceof AcademyCourse && $record->exists
? route('admin.academy.courses.sections.store', ['academyCourse' => $record])
: null,
'reorderUrl' => $record instanceof AcademyCourse && $record->exists
? route('admin.academy.courses.reorder', ['academyCourse' => $record])
: null,
'courseImportUrl' => $record instanceof AcademyCourse && ! $record->exists
? route('admin.academy.courses.import-json')
: null,
'lessonCategoryOptions' => $this->categoriesForEditor('lesson'),
];
}
@@ -656,7 +854,7 @@ final class AcademyAdminController extends Controller
'singular' => 'lesson',
'subtitle' => 'Create and publish Academy lessons.',
'route_base' => 'admin.academy.lessons',
'columns' => ['title', 'difficulty', 'access_level', 'featured', 'active'],
'columns' => ['title', 'course_names', 'course_order', 'difficulty', 'access_level', 'active'],
'fields' => [
['name' => 'category_id', 'label' => 'Category', 'type' => 'select', 'options' => $this->categoryOptions('lesson')],
['name' => 'course_ids', 'label' => 'Courses', 'type' => 'multiselect', 'options' => $this->courseOptions()],
@@ -694,6 +892,10 @@ final class AcademyAdminController extends Controller
['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' => 'documentation', 'label' => 'Documentation JSON', 'type' => 'json'],
['name' => 'placeholders', 'label' => 'Placeholders JSON', 'type' => 'json'],
['name' => 'helper_prompts', 'label' => 'Helper Prompts JSON', 'type' => 'json'],
['name' => 'prompt_variants', 'label' => 'Prompt Variants JSON', 'type' => 'json'],
['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'],
@@ -785,10 +987,17 @@ final class AcademyAdminController extends Controller
'courses' => [
'id' => (int) $model->id,
'title' => (string) $model->title,
'slug' => (string) $model->slug,
'subtitle' => (string) ($model->subtitle ?? ''),
'excerpt' => (string) ($model->excerpt ?? ''),
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($model->cover_image ?? '')),
'lessons_count' => (int) ($model->lessons_count_cache ?? $model->course_lessons_count ?? 0),
'difficulty' => (string) $model->difficulty,
'access_level' => (string) $model->access_level,
'status' => (string) $model->status,
'is_featured' => (bool) $model->is_featured,
'published_at' => optional($model->published_at)->toIso8601String(),
'updated_at' => optional($model->updated_at)->toIso8601String(),
'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]),
@@ -805,6 +1014,8 @@ final class AcademyAdminController extends Controller
'lessons' => [
'id' => (int) $model->id,
'title' => (string) $model->title,
'course_names' => $model->courses->pluck('title')->filter()->values()->all(),
'course_order' => $model->course_order,
'difficulty' => (string) $model->difficulty,
'access_level' => (string) $model->access_level,
'featured' => (bool) $model->featured,
@@ -941,6 +1152,10 @@ final class AcademyAdminController extends Controller
'negative_prompt' => (string) ($record->negative_prompt ?? ''),
'usage_notes' => (string) ($record->usage_notes ?? ''),
'workflow_notes' => (string) ($record->workflow_notes ?? ''),
'documentation' => $this->encodePrettyJsonForForm($record->documentation),
'placeholders' => $this->encodePrettyJsonForForm($record->placeholders),
'helper_prompts' => $this->encodePrettyJsonForForm($record->helper_prompts),
'prompt_variants' => $this->encodePrettyJsonForForm($record->prompt_variants),
'difficulty' => (string) ($record->difficulty ?? 'beginner'),
'access_level' => (string) ($record->access_level ?? 'free'),
'aspect_ratio' => (string) ($record->aspect_ratio ?? ''),
@@ -1464,9 +1679,46 @@ final class AcademyAdminController extends Controller
return $validated;
}
private function saveCourseFromRequest(UpsertAcademyCourseRequest $request, ?AcademyCourse $course = null): AcademyCourse
{
$course ??= new AcademyCourse;
$course->fill($this->persistCourseAttributes($request, $course))->save();
return $course;
}
/**
* @return array<string, mixed>
*/
/**
* @return array<int, array<string, mixed>>
*/
private function serializeCourseEditorSections(AcademyCourse $course): array
{
$course->loadMissing(['sections']);
return $course->sections
->sortBy([['order_num', 'asc'], ['id', 'asc']])
->values()
->map(fn (AcademyCourseSection $section): array => [
'id' => (int) $section->id,
'title' => (string) $section->title,
'slug' => (string) ($section->slug ?? ''),
'description' => (string) ($section->description ?? ''),
'order_num' => (int) ($section->order_num ?? 0),
'is_visible' => (bool) ($section->is_visible ?? true),
'update_url' => route('admin.academy.courses.sections.update', [
'academyCourse' => $course,
'academyCourseSection' => $section,
]),
'destroy_url' => route('admin.academy.courses.sections.destroy', [
'academyCourse' => $course,
'academyCourseSection' => $section,
]),
])
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
@@ -1479,12 +1731,17 @@ final class AcademyAdminController extends Controller
->values()
->map(function (AcademyCourseLesson $courseLesson) use ($course): array {
$lesson = $courseLesson->lesson;
$publicationMeta = $this->serializeLessonPublicationMeta($lesson instanceof AcademyLesson ? $lesson : null);
return [
return array_merge([
'id' => (int) $courseLesson->id,
'lesson_id' => (int) $courseLesson->lesson_id,
'title' => (string) ($lesson?->title ?? 'Untitled lesson'),
'slug' => (string) ($lesson?->slug ?? ''),
'cover_image' => (string) ($lesson?->cover_image ?? ''),
'cover_image_url' => $lesson instanceof AcademyLesson
? $this->resolveLessonCoverImageUrl((string) ($lesson->cover_image ?: $lesson->article_cover_image ?? ''))
: null,
'section_id' => $courseLesson->section_id ? (int) $courseLesson->section_id : null,
'section_title' => (string) ($courseLesson->section?->title ?? ''),
'order_num' => (int) ($courseLesson->order_num ?? 0),
@@ -1493,6 +1750,7 @@ final class AcademyAdminController extends Controller
'is_required' => (bool) $courseLesson->is_required,
'difficulty' => (string) ($lesson?->difficulty ?? ''),
'access_level' => (string) ($lesson?->access_level ?? ''),
'active' => (bool) ($lesson?->active ?? false),
'destroy_url' => route('admin.academy.courses.lessons.destroy', [
'academyCourse' => $course,
'academyCourseLesson' => $courseLesson,
@@ -1500,42 +1758,208 @@ final class AcademyAdminController extends Controller
'edit_url' => $lesson instanceof AcademyLesson
? route('admin.academy.lessons.edit', ['academyLesson' => $lesson])
: null,
];
], $publicationMeta);
})
->all();
}
/**
* @param array<string, mixed> $lessonData
* @param array<string, mixed> $defaults
* @param array<int, string> $reservedSlugs
* @return array<string, mixed>
*/
private function buildImportedCourseLessonAttributes(AcademyCourse $course, array $lessonData, array $defaults, array &$reservedSlugs): array
{
$title = trim((string) ($lessonData['title'] ?? ''));
$slugSource = $this->nullableTrimmedString($lessonData['slug'] ?? null) ?? $title;
$excerpt = $this->nullableTrimmedString($lessonData['excerpt'] ?? null)
?? $this->nullableTrimmedString($lessonData['goal'] ?? null);
$difficulty = $this->nullableTrimmedString($lessonData['difficulty'] ?? null)
?? $this->nullableTrimmedString($defaults['difficulty'] ?? null)
?? $this->nullableTrimmedString($course->difficulty)
?? 'beginner';
$accessLevel = $this->nullableTrimmedString($lessonData['access_level'] ?? null)
?? $this->nullableTrimmedString($defaults['access_level'] ?? null)
?? 'free';
$lessonType = $this->nullableTrimmedString($lessonData['lesson_type'] ?? null)
?? $this->nullableTrimmedString($defaults['lesson_type'] ?? null)
?? 'article';
$seriesName = $this->nullableTrimmedString($lessonData['series_name'] ?? null)
?? $this->nullableTrimmedString($defaults['series_name'] ?? null)
?? $this->nullableTrimmedString($course->title);
$active = array_key_exists('active', $lessonData)
? (bool) $lessonData['active']
: (array_key_exists('active', $defaults) ? (bool) $defaults['active'] : false);
return [
'category_id' => $this->resolveImportedLessonCategoryId($lessonData, $defaults),
'title' => $title,
'slug' => $this->reserveImportedLessonSlug($slugSource, $reservedSlugs),
'lesson_number' => null,
'course_order' => null,
'series_name' => $seriesName,
'excerpt' => $excerpt,
'content' => null,
'content_markdown' => null,
'difficulty' => $difficulty,
'access_level' => $accessLevel,
'lesson_type' => $lessonType,
'cover_image' => null,
'article_cover_image' => null,
'tags' => collect((array) ($lessonData['tags'] ?? []))
->map(fn ($tag): string => trim((string) $tag))
->filter(fn (string $tag): bool => $tag !== '')
->values()
->all(),
'video_url' => null,
'reading_minutes' => 5,
'featured' => false,
'active' => $active,
'published_at' => null,
'seo_title' => null,
'seo_description' => $excerpt,
];
}
/**
* @param array<string, mixed> $lessonData
* @param array<string, mixed> $defaults
*/
private function resolveImportedLessonCategoryId(array $lessonData, array $defaults): ?int
{
foreach ([$lessonData, $defaults] as $source) {
if ($source === []) {
continue;
}
$categoryId = $source['category_id'] ?? null;
if ($categoryId !== null && AcademyCategory::query()->where('type', 'lesson')->whereKey((int) $categoryId)->exists()) {
return (int) $categoryId;
}
$categorySlug = $this->nullableTrimmedString($source['category_slug'] ?? null);
if ($categorySlug !== null) {
$category = AcademyCategory::query()->where('type', 'lesson')->where('slug', $categorySlug)->first();
if ($category instanceof AcademyCategory) {
return (int) $category->id;
}
}
$categoryName = $this->nullableTrimmedString($source['category'] ?? null);
if ($categoryName !== null) {
$category = AcademyCategory::query()->where('type', 'lesson')->whereRaw('lower(name) = ?', [Str::lower($categoryName)])->first();
if ($category instanceof AcademyCategory) {
return (int) $category->id;
}
}
}
return null;
}
/**
* @param array<int, string> $reservedSlugs
*/
private function reserveImportedLessonSlug(string $source, array &$reservedSlugs): string
{
$base = Str::slug($source);
if ($base === '') {
$base = 'academy-lesson';
}
$candidate = $base;
$suffix = 2;
while (in_array($candidate, $reservedSlugs, true)) {
$candidate = $base.'-'.$suffix;
$suffix++;
}
$reservedSlugs[] = $candidate;
return $candidate;
}
/**
* @return array<int, array<string, mixed>>
*/
private function categoriesForEditor(string $type): array
{
return AcademyCategory::query()
->where('type', $type)
->orderBy('order_num')
->orderBy('name')
->get()
->map(fn (AcademyCategory $category): array => $this->serializeCategoryOption($category))
->values()
->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()
->whereDoesntHave('courseLessons')
->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]),
])
->map(function (AcademyLesson $lesson): array {
$publicationMeta = $this->serializeLessonPublicationMeta($lesson);
return array_merge([
'id' => (int) $lesson->id,
'title' => (string) $lesson->title,
'slug' => (string) $lesson->slug,
'cover_image' => (string) ($lesson->cover_image ?? ''),
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($lesson->cover_image ?: $lesson->article_cover_image ?? '')),
'difficulty' => (string) $lesson->difficulty,
'access_level' => (string) $lesson->access_level,
'active' => (bool) $lesson->active,
'category' => $lesson->category ? (string) $lesson->category->name : '',
'edit_url' => route('admin.academy.lessons.edit', ['academyLesson' => $lesson]),
'attached' => false,
], $publicationMeta);
})
->values()
->all();
}
/**
* @return array<string, string|null>
*/
private function serializeLessonPublicationMeta(?AcademyLesson $lesson): array
{
$publishedAt = $lesson?->published_at instanceof Carbon
? $lesson->published_at->copy()
: null;
if (! $publishedAt) {
return [
'published_at' => null,
'publication_state' => 'draft',
'publication_label' => 'Unscheduled',
];
}
if ($publishedAt->isFuture()) {
return [
'published_at' => $publishedAt->toIso8601String(),
'publication_state' => 'scheduled',
'publication_label' => 'Publishes '.$publishedAt->format('Y-m-d H:i'),
];
}
return [
'published_at' => $publishedAt->toIso8601String(),
'publication_state' => 'published',
'publication_label' => 'Published',
];
}
private function serializeCourseOutlineSummary(AcademyCourse $course): array
{
$course->loadMissing(['sections', 'courseLessons']);
@@ -1734,6 +2158,10 @@ final class AcademyAdminController extends Controller
$validated['category_id'] = $this->resolveOrCreatePromptCategoryId($newCategoryName);
}
$validated['documentation'] = $this->normalizePromptDocumentation($validated['documentation'] ?? null);
$validated['placeholders'] = $this->normalizePromptPlaceholders($validated['placeholders'] ?? null);
$validated['helper_prompts'] = $this->normalizePromptHelperPrompts($validated['helper_prompts'] ?? null);
$validated['prompt_variants'] = $this->normalizePromptVariants($validated['prompt_variants'] ?? null);
$validated['tool_notes'] = $this->normalizePromptToolNotes((array) ($validated['tool_notes'] ?? []));
$previousToolNotes = $this->normalizePromptToolNotes((array) ($prompt?->tool_notes ?? []));
@@ -1803,6 +2231,172 @@ final class AcademyAdminController extends Controller
->all();
}
private function encodePrettyJsonForForm(mixed $value): string
{
if ($value === null || $value === [] || $value === '') {
return '';
}
return (string) json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* @return array<string, mixed>|null
*/
private function normalizePromptDocumentation(mixed $documentation): ?array
{
if (! is_array($documentation)) {
return null;
}
$listFields = ['best_for', 'how_to_use', 'required_inputs', 'workflow', 'tips', 'common_mistakes', 'data_accuracy_notes'];
$normalized = [
'summary' => $this->nullableTrimmedString($documentation['summary'] ?? null),
'display_notes' => $this->nullableTrimmedString($documentation['display_notes'] ?? null),
];
foreach ($listFields as $field) {
$normalized[$field] = $this->normalizePromptStringList($documentation[$field] ?? []);
}
$hasContent = $normalized['summary'] !== null
|| $normalized['display_notes'] !== null
|| collect($listFields)->contains(fn (string $field): bool => $normalized[$field] !== []);
return $hasContent ? $normalized : null;
}
/**
* @return array<int, array<string, mixed>>
*/
private function normalizePromptPlaceholders(mixed $placeholders): array
{
if (! is_array($placeholders)) {
return [];
}
return collect($placeholders)
->filter(static fn ($placeholder): bool => is_array($placeholder))
->map(function (array $placeholder): array {
return [
'key' => $this->nullableTrimmedString($placeholder['key'] ?? null),
'label' => $this->nullableTrimmedString($placeholder['label'] ?? null),
'description' => $this->nullableTrimmedString($placeholder['description'] ?? null),
'required' => filter_var($placeholder['required'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false,
'example' => $this->normalizePromptJsonValue($placeholder['example'] ?? null),
'default' => $this->normalizePromptJsonValue($placeholder['default'] ?? null),
'type' => $this->nullableTrimmedString($placeholder['type'] ?? null),
];
})
->filter(function (array $placeholder): bool {
return collect([
$placeholder['key'] ?? null,
$placeholder['label'] ?? null,
$placeholder['description'] ?? null,
$placeholder['example'] ?? null,
$placeholder['default'] ?? null,
$placeholder['type'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []);
})
->values()
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function normalizePromptHelperPrompts(mixed $helperPrompts): array
{
if (! is_array($helperPrompts)) {
return [];
}
return collect($helperPrompts)
->filter(static fn ($helperPrompt): bool => is_array($helperPrompt))
->map(function (array $helperPrompt): array {
return [
'title' => $this->nullableTrimmedString($helperPrompt['title'] ?? null),
'type' => $this->nullableTrimmedString($helperPrompt['type'] ?? null) ?? 'other',
'description' => $this->nullableTrimmedString($helperPrompt['description'] ?? null),
'prompt' => $this->nullableTrimmedString($helperPrompt['prompt'] ?? null),
'expected_output' => $this->nullableTrimmedString($helperPrompt['expected_output'] ?? null) ?? 'text',
'active' => filter_var($helperPrompt['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
];
})
->filter(function (array $helperPrompt): bool {
return collect([
$helperPrompt['title'] ?? null,
$helperPrompt['description'] ?? null,
$helperPrompt['prompt'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '');
})
->values()
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function normalizePromptVariants(mixed $variants): array
{
if (! is_array($variants)) {
return [];
}
return collect($variants)
->filter(static fn ($variant): bool => is_array($variant))
->map(function (array $variant): array {
return [
'title' => $this->nullableTrimmedString($variant['title'] ?? null),
'slug' => $this->nullableTrimmedString($variant['slug'] ?? null),
'description' => $this->nullableTrimmedString($variant['description'] ?? null),
'prompt' => $this->nullableTrimmedString($variant['prompt'] ?? null),
'negative_prompt' => $this->nullableTrimmedString($variant['negative_prompt'] ?? null),
'recommended' => filter_var($variant['recommended'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false,
'recommended_for' => $this->normalizePromptStringList($variant['recommended_for'] ?? []),
'risk_notes' => $this->normalizePromptStringList($variant['risk_notes'] ?? []),
'active' => filter_var($variant['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
];
})
->filter(function (array $variant): bool {
return collect([
$variant['title'] ?? null,
$variant['description'] ?? null,
$variant['prompt'] ?? null,
$variant['negative_prompt'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '');
})
->values()
->all();
}
/**
* @return array<int, string>
*/
private function normalizePromptStringList(mixed $value): array
{
if (! is_array($value)) {
$value = $value === null ? [] : [$value];
}
return collect($value)
->map(fn ($item): string => trim((string) $item))
->filter(static fn (string $item): bool => $item !== '')
->values()
->all();
}
private function normalizePromptJsonValue(mixed $value): mixed
{
if (! is_string($value)) {
return $value;
}
$trimmed = trim($value);
return $trimmed !== '' ? $trimmed : null;
}
/**
* @param array<int, mixed> $notes
* @return array<int, array<string, string>>
@@ -1966,6 +2560,23 @@ final class AcademyAdminController extends Controller
$storedPath = self::PROMPT_PREVIEW_PREFIX.'/'.pathinfo(Str::replace('\\', '/', $file->hashName()), PATHINFO_FILENAME).'.webp';
Storage::disk($this->promptPreviewImageDisk())->put($storedPath, $webpBinary, ['visibility' => 'public']);
$sourceWidth = imagesx($image);
$sourceHeight = imagesy($image);
foreach (self::PROMPT_PREVIEW_VARIANT_WIDTHS as $variant => $targetWidth) {
$variantBinary = $this->encodePromptPreviewVariant($image, $targetWidth, $sourceWidth, $sourceHeight);
if ($variantBinary === null) {
continue;
}
Storage::disk($this->promptPreviewImageDisk())->put(
$this->promptPreviewVariantPath($storedPath, $variant),
$variantBinary,
['visibility' => 'public']
);
}
} finally {
imagedestroy($image);
}
@@ -1973,6 +2584,62 @@ final class AcademyAdminController extends Controller
return $storedPath;
}
private function encodePromptPreviewVariant(\GdImage $source, int $targetWidth, int $sourceWidth, int $sourceHeight): ?string
{
if ($sourceWidth <= $targetWidth || $sourceWidth < 1 || $sourceHeight < 1) {
return null;
}
$targetHeight = max(1, (int) round(($sourceHeight / $sourceWidth) * $targetWidth));
$variant = imagecreatetruecolor($targetWidth, $targetHeight);
if (! $variant instanceof \GdImage) {
throw ValidationException::withMessages([
'preview_image_file' => 'The uploaded preview image could not be resized. Please try a different image.',
]);
}
imagealphablending($variant, false);
imagesavealpha($variant, true);
$transparent = imagecolorallocatealpha($variant, 0, 0, 0, 127);
imagefilledrectangle($variant, 0, 0, $targetWidth, $targetHeight, $transparent);
imagecopyresampled($variant, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $sourceWidth, $sourceHeight);
try {
ob_start();
$converted = imagewebp($variant, 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.',
]);
}
return $webpBinary;
} finally {
imagedestroy($variant);
}
}
private function promptPreviewVariantPath(string $path, string $variant): string
{
$directory = pathinfo($path, PATHINFO_DIRNAME);
$filename = pathinfo($path, PATHINFO_FILENAME);
$baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename;
return sprintf('%s/%s-%s.webp', $directory, $baseFilename, $variant);
}
private function canonicalPromptPreviewPath(string $path): string
{
$directory = pathinfo($path, PATHINFO_DIRNAME);
$filename = pathinfo($path, PATHINFO_FILENAME);
$baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename;
return sprintf('%s/%s.webp', $directory, $baseFilename);
}
private function deleteStoredPromptPreviewIfLocal(?string $path): void
{
$path = trim((string) $path);
@@ -1985,10 +2652,14 @@ final class AcademyAdminController extends Controller
}
$disk = $this->promptPreviewImageDisk();
$basePath = $this->canonicalPromptPreviewPath($path);
$paths = [
$basePath,
$this->promptPreviewVariantPath($basePath, 'thumb'),
$this->promptPreviewVariantPath($basePath, 'md'),
];
if (Storage::disk($disk)->exists($path)) {
Storage::disk($disk)->delete($path);
}
Storage::disk($disk)->delete(array_values(array_unique($paths)));
}
private function promptPreviewImageUploadErrorMessage(UploadedFile $file): string