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

@@ -0,0 +1,512 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\World;
use App\Models\WorldWebStory;
use App\Models\WorldWebStoryPage;
use App\Services\WebStories\WorldWebStoryAssetService;
use App\Services\WebStories\WorldWebStoryGenerator;
use App\Services\WebStories\WorldWebStoryValidationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
final class WorldWebStoryAdminController extends Controller
{
private const PER_PAGE = 20;
public function __construct(
private readonly WorldWebStoryGenerator $generator,
private readonly WorldWebStoryAssetService $assets,
private readonly WorldWebStoryValidationService $validation,
) {
}
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->query('q', '')),
'status' => trim((string) $request->query('status', 'all')),
];
$stories = WorldWebStory::query()
->with('world')
->when($filters['q'] !== '', function ($query) use ($filters): void {
$query->where(function ($nested) use ($filters): void {
$nested->where('title', 'like', '%' . $filters['q'] . '%')
->orWhere('slug', 'like', '%' . $filters['q'] . '%')
->orWhereHas('world', fn ($worldQuery) => $worldQuery->where('title', 'like', '%' . $filters['q'] . '%')->orWhere('slug', 'like', '%' . $filters['q'] . '%'));
});
})
->when($filters['status'] !== 'all', fn ($query) => $query->where('status', $filters['status']))
->orderByDesc('published_at')
->orderByDesc('updated_at')
->paginate(self::PER_PAGE)
->withQueryString()
->through(fn (WorldWebStory $story): array => $this->mapStoryListItem($story));
return Inertia::render('Moderation/WorldWebStoriesIndex', [
'title' => 'World Web Stories',
'stories' => $stories,
'filters' => $filters,
'stats' => [
'total' => WorldWebStory::query()->count(),
'published' => WorldWebStory::query()->where('status', WorldWebStory::STATUS_PUBLISHED)->count(),
'draft' => WorldWebStory::query()->where('status', WorldWebStory::STATUS_DRAFT)->count(),
'hidden' => WorldWebStory::query()->where('noindex', true)->orWhere('active', false)->count(),
],
'worldOptions' => $this->worldOptions(),
'endpoints' => [
'index' => route('admin.web-stories.index'),
'create' => route('admin.web-stories.create'),
'editPattern' => route('admin.web-stories.edit', ['story' => '__STORY__']),
'destroyPattern' => route('admin.web-stories.destroy', ['story' => '__STORY__']),
'publishPattern' => route('admin.web-stories.publish', ['story' => '__STORY__']),
'unpublishPattern' => route('admin.web-stories.unpublish', ['story' => '__STORY__']),
'generatePattern' => route('admin.web-stories.generate', ['world' => '__WORLD__']),
],
])->rootView('moderation');
}
public function create(): Response
{
return Inertia::render('Moderation/WorldWebStoryEditor', [
'story' => $this->blankStoryPayload(),
'worldOptions' => $this->worldOptions(),
'endpoints' => $this->editorEndpoints(),
'isNew' => true,
])->rootView('moderation');
}
public function store(Request $request): RedirectResponse
{
$attributes = $this->validatedStoryAttributes($request);
$story = new WorldWebStory();
$story->fill($attributes + [
'created_by' => (int) $request->user()->id,
'updated_by' => (int) $request->user()->id,
]);
$this->normalizeStatusTimestamps($story);
$this->assertPublishedStateIsValid($story);
$story->save();
return redirect()->route('admin.web-stories.edit', ['story' => $story])->with('success', 'Web story created.');
}
public function edit(WorldWebStory $story): Response
{
$story->load(['world', 'orderedPages.artwork']);
return Inertia::render('Moderation/WorldWebStoryEditor', [
'story' => $this->mapStoryEditorPayload($story),
'worldOptions' => $this->worldOptions(),
'endpoints' => $this->editorEndpoints($story),
'isNew' => false,
])->rootView('moderation');
}
public function update(Request $request, WorldWebStory $story): RedirectResponse
{
$story->fill($this->validatedStoryAttributes($request) + [
'updated_by' => (int) $request->user()->id,
]);
$this->normalizeStatusTimestamps($story);
$this->assertPublishedStateIsValid($story);
$story->save();
return back()->with('success', 'Web story updated.');
}
public function destroy(WorldWebStory $story): JsonResponse
{
$story->delete();
return response()->json([
'ok' => true,
'message' => 'Web story deleted.',
]);
}
public function storePage(Request $request, WorldWebStory $story): JsonResponse
{
$attributes = $this->validatedPageAttributes($request, $story, null);
$page = $story->pages()->create($attributes);
return response()->json([
'ok' => true,
'message' => 'Page created.',
'page' => $this->mapPage($page->fresh('artwork')),
]);
}
public function updatePage(Request $request, WorldWebStory $story, WorldWebStoryPage $page): JsonResponse
{
abort_unless((int) $page->story_id === (int) $story->id, 404);
$page->fill($this->validatedPageAttributes($request, $story, $page));
$page->save();
return response()->json([
'ok' => true,
'message' => 'Page updated.',
'page' => $this->mapPage($page->fresh('artwork')),
]);
}
public function destroyPage(WorldWebStory $story, WorldWebStoryPage $page): JsonResponse
{
abort_unless((int) $page->story_id === (int) $story->id, 404);
$page->delete();
return response()->json([
'ok' => true,
'message' => 'Page deleted.',
]);
}
public function reorderPages(Request $request, WorldWebStory $story): JsonResponse
{
$validated = $request->validate([
'page_ids' => ['required', 'array', 'min:1'],
'page_ids.*' => ['integer'],
]);
$ids = collect($validated['page_ids'])->map(fn ($id): int => (int) $id)->values();
$pages = $story->orderedPages()->whereIn('id', $ids)->get()->keyBy('id');
abort_unless($pages->count() === $ids->count(), 422);
foreach ($ids as $index => $id) {
$pages[$id]->forceFill(['position' => $index + 1])->save();
}
return response()->json([
'ok' => true,
'message' => 'Page order updated.',
]);
}
public function generateFromWorld(Request $request, World $world): JsonResponse
{
$validated = $request->validate([
'force' => ['nullable', 'boolean'],
'publish' => ['nullable', 'boolean'],
'dry_run' => ['nullable', 'boolean'],
'pages' => ['nullable', 'integer', 'min:5', 'max:10'],
]);
$result = $this->generator->generateFromWorld(
$world,
$request->user(),
(int) ($validated['pages'] ?? 7),
(bool) ($validated['force'] ?? false),
(bool) ($validated['publish'] ?? false),
(bool) ($validated['dry_run'] ?? false),
);
return response()->json([
'ok' => true,
'message' => $result['created'] ? 'Web story draft generated.' : 'Web story draft regenerated.',
'story' => [
'id' => $result['story']->id,
'slug' => $result['story']->slug,
'edit_url' => $result['story']->exists ? route('admin.web-stories.edit', ['story' => $result['story']->id]) : null,
],
'validation' => $result['validation'],
]);
}
public function publish(WorldWebStory $story): JsonResponse
{
$this->assets->buildAssets($story, force: false);
$story->refresh()->load('orderedPages');
$this->validation->assertPublishable($story);
$story->forceFill([
'status' => WorldWebStory::STATUS_PUBLISHED,
'published_at' => $story->published_at ?: now(),
])->save();
return response()->json([
'ok' => true,
'message' => 'Web story published.',
]);
}
public function unpublish(WorldWebStory $story): JsonResponse
{
$story->forceFill([
'status' => WorldWebStory::STATUS_DRAFT,
'published_at' => null,
])->save();
return response()->json([
'ok' => true,
'message' => 'Web story reverted to draft.',
]);
}
/**
* @return array<string, mixed>
*/
private function validatedStoryAttributes(Request $request, ?WorldWebStory $story = null): array
{
$validated = $request->validate([
'world_id' => ['nullable', 'integer', Rule::exists('worlds', 'id')],
'slug' => ['required', 'string', 'max:120', Rule::unique('world_web_stories', 'slug')->ignore($story?->id)],
'title' => ['required', 'string', 'max:255'],
'subtitle' => ['nullable', 'string', 'max:255'],
'excerpt' => ['nullable', 'string', 'max:400'],
'description' => ['nullable', 'string', 'max:2000'],
'seo_title' => ['nullable', 'string', 'max:255'],
'seo_description' => ['nullable', 'string', 'max:400'],
'poster_portrait_path' => ['nullable', 'string', 'max:2048'],
'poster_square_path' => ['nullable', 'string', 'max:2048'],
'publisher_logo_path' => ['nullable', 'string', 'max:2048'],
'status' => ['required', Rule::in([WorldWebStory::STATUS_DRAFT, WorldWebStory::STATUS_PUBLISHED, WorldWebStory::STATUS_ARCHIVED])],
'featured' => ['required', 'boolean'],
'active' => ['required', 'boolean'],
'noindex' => ['required', 'boolean'],
'published_at' => ['nullable', 'date'],
'starts_at' => ['nullable', 'date'],
'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'],
]);
return $validated;
}
/**
* @return array<string, mixed>
*/
private function validatedPageAttributes(Request $request, WorldWebStory $story, ?WorldWebStoryPage $page): array
{
$validated = $request->validate([
'artwork_id' => ['nullable', 'integer', Rule::exists('artworks', 'id')],
'position' => ['nullable', 'integer', 'min:1'],
'layout' => ['required', Rule::in([
WorldWebStoryPage::LAYOUT_COVER,
WorldWebStoryPage::LAYOUT_ARTWORK,
WorldWebStoryPage::LAYOUT_CREATOR,
WorldWebStoryPage::LAYOUT_MOOD,
WorldWebStoryPage::LAYOUT_COLLECTION,
WorldWebStoryPage::LAYOUT_CTA,
])],
'background_type' => ['required', Rule::in([
WorldWebStoryPage::BACKGROUND_IMAGE,
WorldWebStoryPage::BACKGROUND_VIDEO,
WorldWebStoryPage::BACKGROUND_GRADIENT,
])],
'background_path' => ['nullable', 'string', 'max:2048'],
'background_mobile_path' => ['nullable', 'string', 'max:2048'],
'headline' => ['nullable', 'string', 'max:255'],
'body' => ['nullable', 'string', 'max:180'],
'cta_label' => ['nullable', 'string', 'max:120'],
'cta_url' => ['nullable', 'string', 'max:2048'],
'alt_text' => ['required', 'string', 'max:255'],
'caption' => ['nullable', 'string', 'max:120'],
'credit_text' => ['nullable', 'string', 'max:255'],
'text_position' => ['required', Rule::in(['top', 'center', 'bottom'])],
'overlay_strength' => ['required', 'integer', 'min:0', 'max:100'],
'animation' => ['nullable', Rule::in(['fade-in', 'fly-in-bottom', 'pulse', 'pan-left', 'pan-right'])],
'active' => ['required', 'boolean'],
]);
$validated['position'] = (int) ($validated['position'] ?? ($story->orderedPages()->max('position') + ($page ? 0 : 1) ?: 1));
$pageErrors = $this->validation->validatePagePayload($validated);
if ($pageErrors !== []) {
throw ValidationException::withMessages($pageErrors);
}
return $validated;
}
private function normalizeStatusTimestamps(WorldWebStory $story): void
{
if ((string) $story->status === WorldWebStory::STATUS_PUBLISHED && $story->published_at === null) {
$story->published_at = now();
}
if ((string) $story->status === WorldWebStory::STATUS_DRAFT) {
$story->published_at = null;
}
}
private function assertPublishedStateIsValid(WorldWebStory $story): void
{
if ((string) $story->status !== WorldWebStory::STATUS_PUBLISHED) {
return;
}
$story->loadMissing('orderedPages');
$this->validation->assertPublishable($story);
}
/**
* @return array<int, array{value:int,label:string,description:string}>
*/
private function worldOptions(): array
{
return World::query()
->orderByDesc('published_at')
->orderBy('title')
->limit(200)
->get(['id', 'title', 'slug'])
->map(fn (World $world): array => [
'value' => (int) $world->id,
'label' => (string) $world->title,
'description' => (string) $world->slug,
])
->all();
}
/**
* @return array<string, mixed>
*/
private function blankStoryPayload(): array
{
return [
'id' => null,
'world_id' => null,
'slug' => '',
'title' => '',
'subtitle' => '',
'excerpt' => '',
'description' => '',
'seo_title' => '',
'seo_description' => '',
'poster_portrait_path' => '',
'poster_square_path' => '',
'publisher_logo_path' => $this->assets->defaultPublisherLogoPath(),
'status' => WorldWebStory::STATUS_DRAFT,
'featured' => false,
'active' => true,
'noindex' => false,
'published_at' => null,
'starts_at' => null,
'ends_at' => null,
'world' => null,
'pages' => [],
'public_url' => null,
'validation' => ['valid' => false, 'errors' => [], 'warnings' => [], 'page_count' => 0],
];
}
/**
* @return array<string, mixed>
*/
private function mapStoryEditorPayload(WorldWebStory $story): array
{
return [
'id' => (int) $story->id,
'world_id' => $story->world_id ? (int) $story->world_id : null,
'slug' => (string) $story->slug,
'title' => (string) $story->title,
'subtitle' => (string) ($story->subtitle ?? ''),
'excerpt' => (string) ($story->excerpt ?? ''),
'description' => (string) ($story->description ?? ''),
'seo_title' => (string) ($story->seo_title ?? ''),
'seo_description' => (string) ($story->seo_description ?? ''),
'poster_portrait_path' => (string) ($story->poster_portrait_path ?? ''),
'poster_square_path' => (string) ($story->poster_square_path ?? ''),
'publisher_logo_path' => (string) ($story->publisher_logo_path ?? ''),
'status' => (string) $story->status,
'featured' => (bool) $story->featured,
'active' => (bool) $story->active,
'noindex' => (bool) $story->noindex,
'published_at' => optional($story->published_at)?->toIso8601String(),
'starts_at' => optional($story->starts_at)?->toIso8601String(),
'ends_at' => optional($story->ends_at)?->toIso8601String(),
'world' => $story->world ? [
'id' => (int) $story->world->id,
'title' => (string) $story->world->title,
'slug' => (string) $story->world->slug,
] : null,
'pages' => $story->orderedPages->map(fn (WorldWebStoryPage $page): array => $this->mapPage($page))->all(),
'public_url' => route('web-stories.show', ['slug' => $story->slug]),
'validation' => $this->validation->validate($story),
];
}
/**
* @return array<string, mixed>
*/
private function mapStoryListItem(WorldWebStory $story): array
{
return [
'id' => (int) $story->id,
'slug' => (string) $story->slug,
'title' => (string) $story->title,
'excerpt' => (string) ($story->excerpt ?? ''),
'status' => (string) $story->status,
'active' => (bool) $story->active,
'noindex' => (bool) $story->noindex,
'featured' => (bool) $story->featured,
'page_count' => (int) ($story->pages()->count()),
'published_at' => optional($story->published_at)?->toIso8601String(),
'poster_portrait_url' => $story->posterPortraitUrl(),
'world' => $story->world ? [
'id' => (int) $story->world->id,
'title' => (string) $story->world->title,
'slug' => (string) $story->world->slug,
] : null,
'public_url' => route('web-stories.show', ['slug' => $story->slug]),
];
}
/**
* @return array<string, mixed>
*/
private function mapPage(WorldWebStoryPage $page): array
{
return [
'id' => (int) $page->id,
'artwork_id' => $page->artwork_id ? (int) $page->artwork_id : null,
'position' => (int) $page->position,
'layout' => (string) $page->layout,
'background_type' => (string) $page->background_type,
'background_path' => (string) ($page->background_path ?? ''),
'background_mobile_path' => (string) ($page->background_mobile_path ?? ''),
'headline' => (string) ($page->headline ?? ''),
'body' => (string) ($page->body ?? ''),
'cta_label' => (string) ($page->cta_label ?? ''),
'cta_url' => (string) ($page->cta_url ?? ''),
'alt_text' => (string) ($page->alt_text ?? ''),
'caption' => (string) ($page->caption ?? ''),
'credit_text' => (string) ($page->credit_text ?? ''),
'text_position' => (string) ($page->text_position ?? 'bottom'),
'overlay_strength' => (int) ($page->overlay_strength ?? 35),
'animation' => (string) ($page->animation ?? ''),
'active' => (bool) $page->active,
'background_url' => $page->backgroundUrl(),
];
}
/**
* @return array<string, string>
*/
private function editorEndpoints(?WorldWebStory $story = null): array
{
return [
'store' => route('admin.web-stories.store'),
'update' => $story ? route('admin.web-stories.update', ['story' => $story]) : '',
'destroy' => $story ? route('admin.web-stories.destroy', ['story' => $story]) : '',
'pagesStore' => $story ? route('admin.web-stories.pages.store', ['story' => $story]) : '',
'pagesUpdatePattern' => $story ? route('admin.web-stories.pages.update', ['story' => $story, 'page' => '__PAGE__']) : '',
'pagesDestroyPattern' => $story ? route('admin.web-stories.pages.destroy', ['story' => $story, 'page' => '__PAGE__']) : '',
'pagesReorder' => $story ? route('admin.web-stories.pages.reorder', ['story' => $story]) : '',
'publish' => $story ? route('admin.web-stories.publish', ['story' => $story]) : '',
'unpublish' => $story ? route('admin.web-stories.unpublish', ['story' => $story]) : '',
'generateFromWorldPattern' => route('admin.web-stories.generate', ['world' => '__WORLD__']),
'index' => route('admin.web-stories.index'),
];
}
}