Compare commits

...

21 Commits

Author SHA1 Message Date
klevze 5af95f6533 Optimize academy 2026-06-09 13:16:01 +02:00
klevze f89ee937c0 fixed sanitazer and academy 2026-06-05 16:53:20 +02:00
klevze 15870ddb1f Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render 2026-06-04 07:52:57 +02:00
klevze 0b33a1b074 Implement academy analytics, billing, and web stories updates 2026-05-26 07:27:29 +02:00
klevze 456c3d6bb0 Fix: remove Academy families from sitemaps; show total prompts in Academy prompt library; fix void closure in AcademyAdminController 2026-05-11 21:56:38 +02:00
klevze ff96ef796e chore: commit remaining workspace changes 2026-05-08 21:51:29 +02:00
klevze 8d108b8a76 Homepage: add stable intro copy; mark footer utility links data-nosnippet; add render test 2026-05-08 21:51:28 +02:00
klevze 6b83d76cd1 SEO: gallery VisualArtwork contentUrl -> single best URL; update gallery unit test 2026-05-08 21:51:28 +02:00
klevze 0c5dde9b22 Featured artworks thumbnails 2026-05-06 19:11:31 +02:00
klevze 82f2b1f660 Add tests for featured thumbnail generation; apply Pint formatting and related edits 2026-05-06 18:55:40 +02:00
klevze 7a8bc8e22a Wire homepage hero to featured thumbnail family; add featured-picture component 2026-05-06 18:55:20 +02:00
klevze 8fa3adf4df Add job and artisan command for generating featured thumbnails 2026-05-06 18:55:08 +02:00
klevze bd8a5c14a0 Add FeaturedArtworkThumbnailGenerator and FeaturedArtworkSelector 2026-05-06 18:54:57 +02:00
klevze 2c2c0f6722 Add Artwork model convenience methods for featured thumbnails 2026-05-06 18:54:31 +02:00
klevze ee24111d59 Add featured thumbnail config and ArtworkFeaturedImagePath helper 2026-05-06 18:54:18 +02:00
klevze a3cfc6c17f feat(academy): prepare AI Academy v1 for production enablement 2026-05-03 19:59:27 +02:00
klevze 90e93f0d42 Fixes 2026-05-03 09:21:13 +02:00
klevze 44354e5bea News: normalize category select values; fix Studio news editor category persistence; add CSV→SQL generator and news_dates.sql 2026-05-03 09:12:38 +02:00
klevze a9dfa6ea11 Refine SEO, uploads, and deploy handling 2026-05-02 10:48:08 +02:00
klevze b6be6ed2ac chore: commit regenerated ssr assets 2026-05-02 09:37:28 +02:00
klevze caf1464aa5 chore: commit current workspace changes 2026-05-02 09:37:14 +02:00
749 changed files with 505017 additions and 9919 deletions
@@ -172,7 +172,9 @@ class NewsController extends Controller
$userId = Auth::id(); $userId = Auth::id();
$session = 'news_view_' . $article->id; $session = 'news_view_' . $article->id;
if ($request->session()->has($session)) { $canReadSession = $request->hasSession() && ! $request->attributes->get('skinbase.session_skipped');
if ($canReadSession && $request->session()->has($session)) {
return; return;
} }
@@ -185,7 +187,9 @@ class NewsController extends Controller
$article->incrementViews(); $article->incrementViews();
$request->session()->put($session, true); if ($canReadSession) {
$request->session()->put($session, true);
}
} }
private function sidebarData(): array private function sidebarData(): array
@@ -102,7 +102,7 @@ final class SimilarArtworksPageController extends Controller
'page_title' => 'Similar to "' . $sourceTitle . '" — Skinbase', 'page_title' => 'Similar to "' . $sourceTitle . '" — Skinbase',
'page_meta_description' => 'Discover artworks similar to "' . $sourceTitle . '" on Skinbase.', 'page_meta_description' => 'Discover artworks similar to "' . $sourceTitle . '" on Skinbase.',
'page_canonical' => $baseUrl, 'page_canonical' => $baseUrl,
'page_robots' => 'noindex,follow', 'page_robots' => 'index,follow',
'breadcrumbs' => collect([ 'breadcrumbs' => collect([
(object) ['name' => 'Explore', 'url' => '/explore'], (object) ['name' => 'Explore', 'url' => '/explore'],
(object) ['name' => $sourceTitle, 'url' => $sourceUrl], (object) ['name' => $sourceTitle, 'url' => $sourceUrl],
@@ -92,7 +92,7 @@ final class SitemapCacheService
{ {
$prefix = trim((string) config('sitemaps.pre_generated.path', 'generated-sitemaps'), '/'); $prefix = trim((string) config('sitemaps.pre_generated.path', 'generated-sitemaps'), '/');
$segments = $name === self::INDEX_DOCUMENT $segments = $name === self::INDEX_DOCUMENT
? [$prefix, 'sitemap.xml'] ? [$prefix, 'sitemaps', 'sitemap.xml']
: [$prefix, 'sitemaps', $name . '.xml']; : [$prefix, 'sitemaps', $name . '.xml'];
return implode('/', array_values(array_filter($segments, static fn (string $segment): bool => $segment !== ''))); return implode('/', array_values(array_filter($segments, static fn (string $segment): bool => $segment !== '')));
@@ -124,7 +124,7 @@ final class SitemapReleaseManager
public function documentRelativePath(string $documentName): string public function documentRelativePath(string $documentName): string
{ {
return $documentName === SitemapCacheService::INDEX_DOCUMENT return $documentName === SitemapCacheService::INDEX_DOCUMENT
? 'sitemap.xml' ? 'sitemaps/sitemap.xml'
: 'sitemaps/' . $documentName . '.xml'; : 'sitemaps/' . $documentName . '.xml';
} }
@@ -107,7 +107,7 @@ export default function ArtworkMaturityQueue() {
], [stats]) ], [stats])
return ( return (
<div className="mx-auto max-w-7xl px-4 pb-16 pt-8 sm:px-6 lg:px-8"> <div className="w-full pb-16 pt-8">
<Head title="Artwork Maturity Queue" /> <Head title="Artwork Maturity Queue" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.16),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]"> <section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.16),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
@@ -56,6 +56,7 @@ export default function Topbar({ user = null }) {
<div className="border-t border-neutral-700" /> <div className="border-t border-neutral-700" />
<a href={user.uploadUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Upload</a> <a href={user.uploadUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Upload</a>
<a href="/studio/artworks" className="block px-4 py-2 text-sm hover:bg-white/5">Studio</a> <a href="/studio/artworks" className="block px-4 py-2 text-sm hover:bg-white/5">Studio</a>
{user.moderationUrl ? <a href={user.moderationUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Moderation</a> : null}
<a href="/dashboard" className="block px-4 py-2 text-sm hover:bg-white/5">Dashboard</a> <a href="/dashboard" className="block px-4 py-2 text-sm hover:bg-white/5">Dashboard</a>
<div className="border-t border-neutral-700" /> <div className="border-t border-neutral-700" />
<a href="/logout" className="block px-4 py-2 text-sm text-red-400 hover:bg-white/5" <a href="/logout" className="block px-4 py-2 text-sm text-red-400 hover:bg-white/5"
@@ -13,6 +13,7 @@ function mount() {
username: container.dataset.username || '', username: container.dataset.username || '',
avatarUrl: container.dataset.avatarUrl || null, avatarUrl: container.dataset.avatarUrl || null,
uploadUrl: container.dataset.uploadUrl || '/upload', uploadUrl: container.dataset.uploadUrl || '/upload',
moderationUrl: container.dataset.moderationUrl || null,
} }
: null : null
@@ -245,6 +245,7 @@
data-username="{{ Auth::user()->username ?? '' }}" data-username="{{ Auth::user()->username ?? '' }}"
data-avatar-url="{{ \App\Support\AvatarUrl::forUser((int) Auth::id(), optional(Auth::user()->profile)->avatar_hash, 64) }}" data-avatar-url="{{ \App\Support\AvatarUrl::forUser((int) Auth::id(), optional(Auth::user()->profile)->avatar_hash, 64) }}"
data-upload-url="{{ Route::has('upload') ? route('upload') : '/upload' }}" data-upload-url="{{ Route::has('upload') ? route('upload') : '/upload' }}"
data-moderation-url="{{ in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true) ? '/moderation' : '' }}"
@endif @endif
></div> ></div>
@include('layouts.nova.toolbar') @include('layouts.nova.toolbar')
@@ -310,6 +310,7 @@
@php @php
$toolbarUsername = strtolower((string) (Auth::user()->username ?? '')); $toolbarUsername = strtolower((string) (Auth::user()->username ?? ''));
$routeUpload = Route::has('upload') ? route('upload') : '/upload'; $routeUpload = Route::has('upload') ? route('upload') : '/upload';
$routeModeration = '/moderation';
$routeDashboard = Route::has('dashboard') ? route('dashboard') : '/dashboard'; $routeDashboard = Route::has('dashboard') ? route('dashboard') : '/dashboard';
$routeMyArtworks = Route::has('studio.artworks') ? route('studio.artworks') : '/studio/artworks'; $routeMyArtworks = Route::has('studio.artworks') ? route('studio.artworks') : '/studio/artworks';
$routeMyStories = Route::has('creator.stories.index') ? route('creator.stories.index') : '/creator/stories'; $routeMyStories = Route::has('creator.stories.index') ? route('creator.stories.index') : '/creator/stories';
@@ -376,6 +377,12 @@
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-palette text-xs text-sb-muted"></i></span> <span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-palette text-xs text-sb-muted"></i></span>
Studio Studio
</a> </a>
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeModeration }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-shield text-xs text-sb-muted"></i></span>
Moderation
</a>
@endif
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeDashboard }}"> <a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeDashboard }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-table-columns text-xs text-sb-muted"></i></span> <span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-table-columns text-xs text-sb-muted"></i></span>
Dashboard Dashboard
@@ -401,13 +408,6 @@
Settings Settings
</a> </a>
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true) && \Illuminate\Support\Facades\Route::has('admin.usernames.moderation'))
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('admin.usernames.moderation') }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-shield text-xs text-sb-muted"></i></span>
Moderation
</a>
@endif
<div class="border-t border-panel mt-1 mb-1"></div> <div class="border-t border-panel mt-1 mb-1"></div>
<form method="POST" action="{{ route('logout') }}" class="mb-1"> <form method="POST" action="{{ route('logout') }}" class="mb-1">
@csrf @csrf
@@ -5,6 +5,7 @@
$hero_description = "We're always grateful for volunteers who want to help."; $hero_description = "We're always grateful for volunteers who want to help.";
$center_content = true; $center_content = true;
$center_max = '3xl'; $center_max = '3xl';
$errors = $errors ?? new \Illuminate\Support\ViewErrorBag();
@endphp @endphp
@section('page-content') @section('page-content')
+52 -2
View File
@@ -41,7 +41,7 @@ SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=null
# Skinbase Nova conditional public sessions # Skinbase conditional public sessions
SKINBASE_CONDITIONAL_SESSIONS_ENABLED=true SKINBASE_CONDITIONAL_SESSIONS_ENABLED=true
SKINBASE_SKIP_ANONYMOUS_PUBLIC_GET_SESSIONS=true SKINBASE_SKIP_ANONYMOUS_PUBLIC_GET_SESSIONS=true
SKINBASE_SKIP_BOT_PUBLIC_GET_SESSIONS=true SKINBASE_SKIP_BOT_PUBLIC_GET_SESSIONS=true
@@ -210,6 +210,25 @@ YOLO_HTTP_RETRIES=1
YOLO_HTTP_RETRY_DELAY_MS=200 YOLO_HTTP_RETRY_DELAY_MS=200
YOLO_PHOTOGRAPHY_ONLY=true YOLO_PHOTOGRAPHY_ONLY=true
# Academy feature flags
SKINBASE_ACADEMY_ENABLED=true
SKINBASE_ACADEMY_PAYMENTS_ENABLED=false
ACADEMY_BILLING_ENABLED=false
ACADEMY_STRIPE_SUBSCRIPTION_NAME=academy
# Stripe / Cashier
STRIPE_KEY=pk_test_xxx
STRIPE_SECRET=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
CASHIER_CURRENCY=eur
CASHIER_CURRENCY_LOCALE=sl_SI
# Academy billing price IDs
ACADEMY_CREATOR_MONTHLY_PRICE_ID=price_xxx
ACADEMY_PRO_MONTHLY_PRICE_ID=price_xxx
# Stripe expects real price object IDs that start with price_, not product IDs like prod_...
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Production examples (uncomment and adjust) # Production examples (uncomment and adjust)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -298,7 +317,7 @@ REGISTRATION_IP_PER_DAY_LIMIT=20
REGISTRATION_EMAIL_PER_MINUTE_LIMIT=6 REGISTRATION_EMAIL_PER_MINUTE_LIMIT=6
REGISTRATION_EMAIL_COOLDOWN_MINUTES=30 REGISTRATION_EMAIL_COOLDOWN_MINUTES=30
REGISTRATION_VERIFY_TOKEN_TTL_HOURS=24 REGISTRATION_VERIFY_TOKEN_TTL_HOURS=24
REGISTRATION_ENABLE_TURNSTILE=true TURNSTILE_ENABLED=false
REGISTRATION_DISPOSABLE_DOMAINS_ENABLED=true REGISTRATION_DISPOSABLE_DOMAINS_ENABLED=true
REGISTRATION_TURNSTILE_SUSPICIOUS_ATTEMPTS=2 REGISTRATION_TURNSTILE_SUSPICIOUS_ATTEMPTS=2
REGISTRATION_TURNSTILE_ATTEMPT_WINDOW_MINUTES=30 REGISTRATION_TURNSTILE_ATTEMPT_WINDOW_MINUTES=30
@@ -306,9 +325,34 @@ REGISTRATION_EMAIL_GLOBAL_SEND_PER_MINUTE=30
REGISTRATION_MONTHLY_EMAIL_LIMIT=10000 REGISTRATION_MONTHLY_EMAIL_LIMIT=10000
TURNSTILE_SITE_KEY= TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY= TURNSTILE_SECRET_KEY=
TURNSTILE_FAIL_OPEN=false
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
TURNSTILE_TIMEOUT=5 TURNSTILE_TIMEOUT=5
ENHANCE_DISK=public
ENHANCE_SOURCE_PREFIX=enhance/sources
ENHANCE_OUTPUT_PREFIX=enhance/outputs
ENHANCE_PREVIEW_PREFIX=enhance/previews
ENHANCE_ENGINE=stub
ENHANCE_MAX_UPLOAD_MB=20
ENHANCE_MAX_INPUT_WIDTH=4096
ENHANCE_MAX_INPUT_HEIGHT=4096
ENHANCE_MAX_OUTPUT_WIDTH=8192
ENHANCE_MAX_OUTPUT_HEIGHT=8192
ENHANCE_DAILY_LIMIT=10
ENHANCE_QUEUE=default
ENHANCE_COMPLETED_EXPIRES_AFTER_DAYS=30
ENHANCE_FAILED_EXPIRES_AFTER_DAYS=7
ENHANCE_DELETED_FILE_GRACE_DAYS=1
ENHANCE_CLEANUP_CHUNK_SIZE=100
ENHANCE_STUCK_PROCESSING_AFTER_MINUTES=30
ENHANCE_STUCK_QUEUED_AFTER_MINUTES=60
ENHANCE_STUB_SHOW_WARNING=true
ENHANCE_WORKER_URL=
ENHANCE_WORKER_TIMEOUT=300
ENHANCE_WORKER_TOKEN=
ENHANCE_WORKER_MAX_DOWNLOAD_MB=60
AWS_ACCESS_KEY_ID= AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1 AWS_DEFAULT_REGION=us-east-1
@@ -355,6 +399,12 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=/auth/google/callback GOOGLE_REDIRECT_URI=/auth/google/callback
# Optional light theme feature
# Set LIGHT_THEME_ENABLED=true to allow a light theme in the client-side toggle.
# Set LIGHT_THEME_SHOW_SWITCH=true to display the theme switch in the toolbar.
LIGHT_THEME_ENABLED=false
LIGHT_THEME_SHOW_SWITCH=false
# Discord — https://discord.com/developers/applications # Discord — https://discord.com/developers/applications
DISCORD_CLIENT_ID= DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET= DISCORD_CLIENT_SECRET=
@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademyEvent;
use App\Models\AcademyLike;
use App\Models\AcademySave;
use App\Models\AcademySearchLog;
use App\Models\AcademyUserProgress;
use App\Support\AcademyAnalytics\AcademyAnalyticsEventType;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
final class AcademyAnalyticsHealthCommand extends Command
{
protected $signature = 'academy:analytics-health {--json : Output machine-readable JSON}';
protected $description = 'Inspect Academy analytics collection health, rollup freshness, and privacy safeguards';
private const RETENTION_DAYS = 180;
public function handle(): int
{
$report = $this->buildReport();
if ((bool) $this->option('json')) {
$this->line(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?: '{}');
return self::SUCCESS;
}
$this->line('Academy Analytics Health Check');
$this->line('==============================');
$this->newLine();
$this->line(sprintf('Events last 24h: %d', $report['events_last_24h']));
$this->line(sprintf('Events last 7d: %d', $report['events_last_7d']));
$this->line(sprintf('Latest event: %s', $report['latest_event_at'] ?? 'none'));
$this->line(sprintf('Latest rollup date: %s', $report['latest_rollup_date'] ?? 'none'));
$this->line(sprintf('Search logs: %d', $report['search_logs']));
$this->line(sprintf('Search clicks: %d', $report['search_clicks']));
$this->line(sprintf('Likes: %d', $report['likes']));
$this->line(sprintf('Saves: %d', $report['saves']));
$this->line(sprintf('Progress records: %d', $report['progress_records']));
$this->line(sprintf('Prompt copies: %d', $report['prompt_copies']));
$this->line(sprintf('Upgrade clicks: %d', $report['upgrade_clicks']));
$this->line(sprintf('Human events: %d', $report['human_events']));
$this->line(sprintf('Bot/admin events: %d', $report['bot_admin_events']));
$this->line(sprintf('Recent daily metric rows: %d', $report['recent_daily_metric_rows']));
$this->line(sprintf('Raw IP storage detected: %s', $report['raw_ip_storage_detected'] ? 'yes' : 'no'));
$this->line(sprintf('Events older than retention: %d', $report['events_older_than_retention']));
$this->newLine();
foreach ($report['warnings'] as $warning) {
$this->warn(sprintf('WARNING: %s', $warning));
}
$this->info(sprintf('Status: %s', $report['status']));
return self::SUCCESS;
}
/**
* @return array<string, mixed>
*/
private function buildReport(): array
{
$now = now();
$last24Hours = $now->copy()->subDay();
$last7Days = $now->copy()->subDays(7);
$retentionCutoff = $now->copy()->subDays(self::RETENTION_DAYS);
$warnings = [];
$rawIpStorageDetected = $this->rawIpStorageDetected();
$eventsTableExists = Schema::hasTable('academy_events');
$metricsTableExists = Schema::hasTable('academy_content_metrics_daily');
$searchLogsTableExists = Schema::hasTable('academy_search_logs');
$likesTableExists = Schema::hasTable('academy_likes');
$savesTableExists = Schema::hasTable('academy_saves');
$progressTableExists = Schema::hasTable('academy_user_progress');
$latestEvent = $eventsTableExists ? AcademyEvent::query()->latest('occurred_at')->value('occurred_at') : null;
$latestRollup = $metricsTableExists ? AcademyContentMetricDaily::query()->latest('date')->value('date') : null;
$searchLogCount = $searchLogsTableExists ? AcademySearchLog::query()->count() : 0;
$searchClickCount = $searchLogsTableExists ? AcademySearchLog::query()->whereNotNull('clicked_content_id')->count() : 0;
$eventsOlderThanRetention = $eventsTableExists ? AcademyEvent::query()->where('occurred_at', '<', $retentionCutoff)->count() : 0;
$recentDailyMetricRows = $metricsTableExists
? AcademyContentMetricDaily::query()->whereBetween('date', [$now->copy()->subDays(6)->toDateString(), $now->toDateString()])->count()
: 0;
$report = [
'events_last_24h' => $eventsTableExists ? AcademyEvent::query()->where('occurred_at', '>=', $last24Hours)->count() : 0,
'events_last_7d' => $eventsTableExists ? AcademyEvent::query()->where('occurred_at', '>=', $last7Days)->count() : 0,
'latest_event_at' => $latestEvent ? Carbon::parse((string) $latestEvent)->toDateTimeString() : null,
'latest_rollup_date' => $latestRollup ? Carbon::parse((string) $latestRollup)->toDateString() : null,
'search_logs' => $searchLogCount,
'search_clicks' => $searchClickCount,
'likes' => $likesTableExists ? AcademyLike::query()->count() : 0,
'saves' => $savesTableExists ? AcademySave::query()->count() : 0,
'progress_records' => $progressTableExists ? AcademyUserProgress::query()->count() : 0,
'prompt_copies' => $eventsTableExists ? AcademyEvent::query()->where('event_type', AcademyAnalyticsEventType::PROMPT_COPY)->count() : 0,
'upgrade_clicks' => $eventsTableExists ? AcademyEvent::query()->where('event_type', AcademyAnalyticsEventType::UPGRADE_CLICK)->count() : 0,
'human_events' => $eventsTableExists ? AcademyEvent::query()->where('is_bot', false)->where('is_admin', false)->where('is_suspicious', false)->count() : 0,
'bot_admin_events' => $eventsTableExists ? AcademyEvent::query()->where(function ($query): void {
$query->where('is_bot', true)->orWhere('is_admin', true)->orWhere('is_suspicious', true);
})->count() : 0,
'raw_ip_storage_detected' => $rawIpStorageDetected,
'events_older_than_retention' => $eventsOlderThanRetention,
'recent_daily_metric_rows' => $recentDailyMetricRows,
'retention_days' => self::RETENTION_DAYS,
'tables_present' => [
'academy_events' => $eventsTableExists,
'academy_content_metrics_daily' => $metricsTableExists,
'academy_search_logs' => $searchLogsTableExists,
'academy_likes' => $likesTableExists,
'academy_saves' => $savesTableExists,
'academy_user_progress' => $progressTableExists,
],
'warnings' => [],
'status' => 'OK',
];
foreach ($report['tables_present'] as $table => $present) {
if (! $present) {
$warnings[] = sprintf('Analytics table %s is missing.', $table);
}
}
if ($report['events_last_24h'] === 0) {
$warnings[] = 'No events received in last 24 hours.';
}
if ($report['events_last_7d'] === 0) {
$warnings[] = 'No events received in last 7 days.';
}
if ($report['latest_rollup_date'] === null) {
$warnings[] = 'No rollup rows exist yet.';
} elseif ($report['latest_rollup_date'] !== $now->toDateString()) {
$warnings[] = 'Rollup has not run for today.';
}
if ($searchLogCount > 0 && $searchClickCount === 0) {
$warnings[] = 'Search clicks are zero although search logs exist.';
}
if ($eventsOlderThanRetention > 0) {
$warnings[] = 'Raw events older than configured retention period exist.';
}
if ($recentDailyMetricRows === 0) {
$warnings[] = 'No daily metrics exist for recent days.';
}
if ($rawIpStorageDetected) {
$warnings[] = 'Raw IP storage indicators were found in Academy analytics tables.';
}
$report['warnings'] = $warnings;
$report['status'] = $warnings === [] ? 'OK' : 'WARNING';
return $report;
}
private function rawIpStorageDetected(): bool
{
foreach (['academy_events', 'academy_search_logs', 'academy_content_metrics_daily', 'academy_likes', 'academy_saves', 'academy_user_progress'] as $table) {
if (! Schema::hasTable($table)) {
continue;
}
foreach (['ip', 'ip_address', 'visitor_ip', 'raw_ip', 'remote_addr'] as $column) {
if (Schema::hasColumn($table, $column)) {
return true;
}
}
}
return false;
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AcademyEvent;
use Illuminate\Console\Command;
final class AcademyAnalyticsPruneEventsCommand extends Command
{
protected $signature = 'academy:analytics-prune-events {--days=180}';
protected $description = 'Delete old raw Academy analytics events while keeping daily rollups';
public function handle(): int
{
$days = max(1, (int) $this->option('days'));
$deleted = AcademyEvent::query()
->where('occurred_at', '<', now()->subDays($days)->startOfDay())
->delete();
$this->info(sprintf('Pruned %d Academy analytics event(s).', $deleted));
return self::SUCCESS;
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AcademyContentMetricDaily;
use App\Services\Academy\AcademyPopularityService;
use Illuminate\Console\Command;
final class AcademyAnalyticsRecalculatePopularityCommand extends Command
{
protected $signature = 'academy:analytics-recalculate-popularity {--days=30}';
protected $description = 'Recalculate Academy daily popularity and conversion scores';
public function __construct(private readonly AcademyPopularityService $popularity)
{
parent::__construct();
}
public function handle(): int
{
$days = max(1, (int) $this->option('days'));
AcademyContentMetricDaily::query()
->where('date', '>=', now()->subDays($days - 1)->toDateString())
->chunkById(500, function ($rows): void {
foreach ($rows as $row) {
$row->forceFill([
'popularity_score' => $this->popularity->calculatePopularityScore($row->toArray()),
'conversion_score' => $this->popularity->calculateConversionScore($row->toArray()),
])->save();
}
});
$this->info(sprintf('Recalculated Academy popularity for the last %d day(s).', $days));
return self::SUCCESS;
}
}
@@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademyEvent;
use App\Models\AcademyLike;
use App\Models\AcademySave;
use App\Models\AcademySearchLog;
use App\Services\Academy\AcademyPopularityService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use Carbon\CarbonPeriod;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
final class AcademyAnalyticsRollupCommand extends Command
{
protected $signature = 'academy:analytics-rollup {--date=} {--from=} {--to=}';
protected $description = 'Aggregate Academy analytics raw events and interaction records into daily content metrics';
public function __construct(private readonly AcademyPopularityService $popularity)
{
parent::__construct();
}
public function handle(): int
{
[$from, $to] = $this->resolveRange();
foreach (CarbonPeriod::create($from, $to) as $date) {
$this->rollupDate(Carbon::parse($date));
$this->line(sprintf('Rolled up Academy analytics for %s.', Carbon::parse($date)->toDateString()));
}
return self::SUCCESS;
}
private function rollupDate(Carbon $date): void
{
$start = $date->copy()->startOfDay();
$end = $date->copy()->endOfDay();
$metrics = [];
$uniqueVisitors = [];
$engagedDurations = [];
AcademyEvent::query()
->whereBetween('occurred_at', [$start, $end])
->orderBy('id')
->chunkById(1000, function ($events) use (&$metrics, &$uniqueVisitors, &$engagedDurations): void {
foreach ($events as $event) {
if ($event->is_bot || $event->is_admin || $event->is_suspicious) {
continue;
}
$key = $this->metricKey((string) ($event->content_type ?? ''), $event->content_id ? (int) $event->content_id : null);
$this->ensureMetric($metrics, (string) ($event->content_type ?? ''), $event->content_id ? (int) $event->content_id : null, $key);
$visitorKey = $event->user_id ? sprintf('user:%d', (int) $event->user_id) : trim((string) ($event->visitor_id ?? ''));
if ($visitorKey !== '') {
$uniqueVisitors[$key][$visitorKey] = true;
}
$eventType = (string) $event->event_type;
if (in_array($eventType, ['academy_page_view', 'academy_content_view', 'academy_lesson_view', 'academy_course_view', 'academy_prompt_pack_view', 'academy_challenge_view'], true)) {
$metrics[$key]['views']++;
if ($event->is_logged_in) {
$metrics[$key]['user_views']++;
} else {
$metrics[$key]['guest_views']++;
}
if ($event->is_subscriber) {
$metrics[$key]['subscriber_views']++;
}
}
if ($eventType === 'academy_engaged_view') {
$metrics[$key]['engaged_views']++;
$engagedDurations[$key][] = max(0, (int) ($event->metadata['engaged_seconds'] ?? 15));
}
if ($eventType === 'academy_scroll_50') {
$metrics[$key]['scroll_50']++;
}
if ($eventType === 'academy_scroll_75') {
$metrics[$key]['scroll_75']++;
}
if ($eventType === 'academy_scroll_100') {
$metrics[$key]['scroll_100']++;
}
if ($eventType === 'academy_prompt_copy') {
$metrics[$key]['prompt_copies']++;
}
if ($eventType === 'academy_prompt_negative_copy') {
$metrics[$key]['negative_prompt_copies']++;
}
if (in_array($eventType, ['academy_lesson_started', 'academy_course_started', 'academy_challenge_started'], true)) {
$metrics[$key]['starts']++;
}
if (in_array($eventType, ['academy_lesson_completed', 'academy_course_completed', 'academy_challenge_submitted'], true)) {
$metrics[$key]['completions']++;
}
if ($eventType === 'academy_upgrade_click') {
$metrics[$key]['upgrade_clicks']++;
}
if ($eventType === 'academy_premium_preview_view') {
$metrics[$key]['premium_preview_views']++;
}
if ($eventType === 'academy_search_result_click') {
$metrics[$key]['search_clicks']++;
$searchKey = $this->metricKey(AcademyAnalyticsContentType::SEARCH, null);
$this->ensureMetric($metrics, AcademyAnalyticsContentType::SEARCH, null, $searchKey);
$metrics[$searchKey]['search_clicks']++;
}
}
});
foreach (AcademyLike::query()->whereBetween('created_at', [$start, $end])->get() as $like) {
$key = $this->metricKey((string) $like->content_type, (int) $like->content_id);
$this->ensureMetric($metrics, (string) $like->content_type, (int) $like->content_id, $key);
$metrics[$key]['likes']++;
}
foreach (AcademySave::query()->whereBetween('created_at', [$start, $end])->get() as $save) {
$key = $this->metricKey((string) $save->content_type, (int) $save->content_id);
$this->ensureMetric($metrics, (string) $save->content_type, (int) $save->content_id, $key);
$metrics[$key]['saves']++;
}
foreach (AcademySearchLog::query()->whereBetween('created_at', [$start, $end])->get() as $searchLog) {
$key = $this->metricKey(AcademyAnalyticsContentType::SEARCH, null);
$this->ensureMetric($metrics, AcademyAnalyticsContentType::SEARCH, null, $key);
$metrics[$key]['search_impressions']++;
if ((int) $searchLog->results_count === 0) {
$metrics[$key]['bounce_count']++;
}
$visitorKey = $searchLog->user_id ? sprintf('user:%d', (int) $searchLog->user_id) : trim((string) ($searchLog->visitor_id ?? ''));
if ($visitorKey !== '') {
$uniqueVisitors[$key][$visitorKey] = true;
}
}
foreach ($metrics as $key => $metric) {
$metric['unique_visitors'] = isset($uniqueVisitors[$key]) ? count($uniqueVisitors[$key]) : 0;
$metric['avg_engaged_seconds'] = isset($engagedDurations[$key]) && $engagedDurations[$key] !== []
? (int) round(array_sum($engagedDurations[$key]) / count($engagedDurations[$key]))
: null;
$metric['bounce_count'] = max((int) ($metric['bounce_count'] ?? 0), max(0, (int) $metric['views'] - (int) $metric['engaged_views']));
$metric['popularity_score'] = $this->popularity->calculatePopularityScore($metric);
$metric['conversion_score'] = $this->popularity->calculateConversionScore($metric);
AcademyContentMetricDaily::query()->upsert([
array_merge($metric, [
'date' => $date->copy()->startOfDay(),
'created_at' => now(),
'updated_at' => now(),
]),
], ['date', 'content_type', 'content_id'], [
'views',
'unique_visitors',
'guest_views',
'user_views',
'subscriber_views',
'engaged_views',
'scroll_50',
'scroll_75',
'scroll_100',
'likes',
'saves',
'prompt_copies',
'negative_prompt_copies',
'starts',
'completions',
'upgrade_clicks',
'premium_preview_views',
'search_impressions',
'search_clicks',
'bounce_count',
'avg_engaged_seconds',
'popularity_score',
'conversion_score',
'updated_at',
]);
}
}
/**
* @param array<string, array<string, mixed>> $metrics
*/
private function ensureMetric(array &$metrics, string $contentType, ?int $contentId, string $key): void
{
if (isset($metrics[$key])) {
return;
}
$metrics[$key] = [
'content_type' => $contentType,
'content_id' => $contentId,
'views' => 0,
'unique_visitors' => 0,
'guest_views' => 0,
'user_views' => 0,
'subscriber_views' => 0,
'engaged_views' => 0,
'scroll_50' => 0,
'scroll_75' => 0,
'scroll_100' => 0,
'likes' => 0,
'saves' => 0,
'prompt_copies' => 0,
'negative_prompt_copies' => 0,
'starts' => 0,
'completions' => 0,
'upgrade_clicks' => 0,
'premium_preview_views' => 0,
'search_impressions' => 0,
'search_clicks' => 0,
'bounce_count' => 0,
'avg_engaged_seconds' => null,
'popularity_score' => 0,
'conversion_score' => 0,
];
}
private function metricKey(string $contentType, ?int $contentId): string
{
return sprintf('%s:%s', $contentType, $contentId ?? 'none');
}
/**
* @return array{0: Carbon, 1: Carbon}
*/
private function resolveRange(): array
{
$date = $this->option('date');
$from = $this->option('from');
$to = $this->option('to');
if (is_string($date) && trim($date) !== '') {
$resolved = Carbon::parse($date)->startOfDay();
return [$resolved, $resolved->copy()];
}
$resolvedFrom = is_string($from) && trim($from) !== ''
? Carbon::parse($from)->startOfDay()
: now()->subDay()->startOfDay();
$resolvedTo = is_string($to) && trim($to) !== ''
? Carbon::parse($to)->startOfDay()
: $resolvedFrom->copy();
return [$resolvedFrom, $resolvedTo];
}
}
@@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Academy\AcademyBillingPlanService;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
final class AcademyBillingHealthCommand extends Command
{
protected $signature = 'academy:billing-health
{--json : Output machine-readable JSON}
{--strict : Exit non-zero when blocking issues are found}';
protected $description = 'Inspect Academy Stripe billing deployment readiness, config completeness, and Cashier route wiring';
public function __construct(
private readonly AcademyBillingPlanService $plans,
) {
parent::__construct();
}
public function handle(): int
{
$report = $this->buildReport();
if ((bool) $this->option('json')) {
$this->line((string) json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return $this->exitCodeFor($report);
}
$this->line('Academy Billing Health Check');
$this->line('============================');
$this->newLine();
$this->line(sprintf('Environment: %s', $report['environment']));
$this->line(sprintf('App URL: %s', $report['app_url'] ?? 'unset'));
$this->line(sprintf('Academy enabled: %s', $report['academy_enabled'] ? 'yes' : 'no'));
$this->line(sprintf('Academy billing enabled: %s', $report['academy_billing_enabled'] ? 'yes' : 'no'));
$this->line(sprintf('Subscription name: %s', $report['subscription_name']));
$this->line(sprintf('Cashier path: %s', $report['cashier_path']));
$this->line(sprintf('Cashier webhook route: %s', $report['routes']['cashier_webhook']['present'] ? ($report['routes']['cashier_webhook']['url'] ?? 'present') : 'missing'));
$this->line(sprintf('Academy pricing route: %s', $report['routes']['academy_pricing']['present'] ? ($report['routes']['academy_pricing']['url'] ?? 'present') : 'missing'));
$this->line(sprintf('Academy billing account route: %s', $report['routes']['academy_billing_account']['present'] ? ($report['routes']['academy_billing_account']['url'] ?? 'present') : 'missing'));
$this->line(sprintf('Stripe key configured: %s', $report['stripe']['publishable_key_configured'] ? 'yes' : 'no'));
$this->line(sprintf('Stripe secret configured: %s', $report['stripe']['secret_key_configured'] ? 'yes' : 'no'));
$this->line(sprintf('Webhook secret configured: %s', $report['stripe']['webhook_secret_configured'] ? 'yes' : 'no'));
$this->line(sprintf('Cashier currency: %s', $report['stripe']['currency'] ?: 'unset'));
$this->line(sprintf('Cashier locale: %s', $report['stripe']['currency_locale'] ?: 'unset'));
$this->line(sprintf('Configured plans: %d', $report['configured_plan_count']));
$this->line(sprintf('Plans missing Stripe price IDs: %d', count($report['missing_plan_keys'])));
$this->line(sprintf('Billing tables present: %s', $report['tables']['subscriptions'] && $report['tables']['subscription_items'] && $report['tables']['academy_billing_events'] ? 'yes' : 'no'));
$this->line(sprintf('User billing columns present: %s', $report['users_billing_columns_present'] ? 'yes' : 'no'));
$this->newLine();
foreach ($report['blockers'] as $blocker) {
$this->error(sprintf('BLOCKER: %s', $blocker));
}
foreach ($report['warnings'] as $warning) {
$this->warn(sprintf('WARNING: %s', $warning));
}
if ($report['plan_summaries'] !== []) {
$this->newLine();
$this->line('Plans');
$this->line('-----');
foreach ($report['plan_summaries'] as $plan) {
$this->line(sprintf(
'%s: tier=%s interval=%s price_id=%s',
$plan['key'],
$plan['tier'],
$plan['interval'],
$plan['configured'] ? 'configured' : 'missing'
));
}
}
$this->newLine();
$this->info(sprintf('Status: %s', $report['status']));
return $this->exitCodeFor($report);
}
/**
* @return array<string, mixed>
*/
private function buildReport(): array
{
$stripeKey = $this->configuredString(config('cashier.key'));
$stripeSecret = $this->firstConfiguredString(config('cashier.secret'), env('STRIPE_SECRET'));
$webhookSecret = $this->firstConfiguredString(config('cashier.webhook.secret'), env('STRIPE_WEBHOOK_SECRET'));
$currency = trim((string) config('cashier.currency', env('CASHIER_CURRENCY', '')));
$currencyLocale = trim((string) config('cashier.currency_locale', env('CASHIER_CURRENCY_LOCALE', '')));
$academyEnabled = (bool) config('academy.enabled', true);
$billingEnabled = $this->plans->enabled();
$missingPlanKeys = $this->plans->missingPriceIds();
$routes = [
'cashier_webhook' => $this->routeStatus('cashier.webhook'),
'academy_pricing' => $this->routeStatus('academy.pricing'),
'academy_billing_account' => $this->routeStatus('academy.billing.account'),
'academy_billing_portal' => $this->routeStatus('academy.billing.portal'),
'admin_academy_billing' => $this->routeStatus('admin.academy.billing'),
];
$tables = [
'users' => Schema::hasTable('users'),
'subscriptions' => Schema::hasTable('subscriptions'),
'subscription_items' => Schema::hasTable('subscription_items'),
'academy_billing_events' => Schema::hasTable('academy_billing_events'),
];
$userBillingColumns = [
'stripe_id' => $tables['users'] && Schema::hasColumn('users', 'stripe_id'),
'pm_type' => $tables['users'] && Schema::hasColumn('users', 'pm_type'),
'pm_last_four' => $tables['users'] && Schema::hasColumn('users', 'pm_last_four'),
'trial_ends_at' => $tables['users'] && Schema::hasColumn('users', 'trial_ends_at'),
];
$planSummaries = collect(array_keys($this->plans->plans()))
->map(function (string $key): array {
$plan = $this->plans->plan($key);
return [
'key' => $key,
'tier' => (string) ($plan['tier'] ?? 'free'),
'interval' => (string) ($plan['interval'] ?? 'monthly'),
'configured' => (bool) ($plan['configured'] ?? false),
];
})
->values()
->all();
$blockers = [];
$warnings = [];
if (! $academyEnabled) {
$warnings[] = 'SKINBASE_ACADEMY_ENABLED is disabled, so billing cannot be reached by users.';
}
if (! $billingEnabled) {
$warnings[] = 'ACADEMY_BILLING_ENABLED is disabled. Checkout routes will stay unavailable until rollout is enabled.';
}
if (! $this->isConfiguredSecret($stripeKey, 'pk_')) {
$blockers[] = 'STRIPE_KEY is missing or still using a placeholder value.';
}
if (! $this->isConfiguredSecret($stripeSecret, 'sk_')) {
$blockers[] = 'STRIPE_SECRET is missing or still using a placeholder value.';
}
if (! $this->isConfiguredSecret($webhookSecret, 'whsec_')) {
$blockers[] = 'STRIPE_WEBHOOK_SECRET is missing or still using a placeholder value.';
}
if ($currency === '') {
$blockers[] = 'CASHIER_CURRENCY is not configured.';
}
if ($currencyLocale === '') {
$warnings[] = 'CASHIER_CURRENCY_LOCALE is not configured.';
}
if ($missingPlanKeys !== []) {
$blockers[] = 'Stripe price IDs are missing for: '.implode(', ', $missingPlanKeys).'.';
}
if (! $routes['cashier_webhook']['present']) {
$blockers[] = 'Cashier webhook route is missing; Stripe cannot sync subscriptions.';
}
if (! $routes['academy_pricing']['present']) {
$blockers[] = 'Academy pricing route is missing.';
}
if (! $routes['academy_billing_account']['present']) {
$blockers[] = 'Academy billing account route is missing.';
}
foreach ($tables as $table => $present) {
if (! $present) {
$blockers[] = sprintf('Required billing table %s is missing.', $table);
}
}
foreach ($userBillingColumns as $column => $present) {
if (! $present) {
$blockers[] = sprintf('Required users.%s billing column is missing.', $column);
}
}
if (! $routes['admin_academy_billing']['present']) {
$warnings[] = 'Moderation Academy billing overview route is missing.';
}
if (Arr::where($planSummaries, fn (array $plan): bool => $plan['configured'] === false) === []) {
$warnings[] = 'All configured Academy plans have Stripe price IDs. Verify they are live-mode IDs before production rollout.';
}
$invalidPlanKeys = collect(array_keys($this->plans->plans()))
->filter(function (string $key): bool {
$plan = $this->plans->plan($key);
return $plan !== null && ($plan['configured'] ?? false) && ! ($plan['price_id_valid'] ?? false);
})
->values()
->all();
if ($invalidPlanKeys !== []) {
$blockers[] = 'Stripe price IDs are malformed for: '.implode(', ', $invalidPlanKeys).'. Use real price object IDs that start with price_.';
}
$status = $blockers !== []
? 'BLOCKED'
: ($warnings !== [] ? 'WARNING' : 'OK');
return [
'environment' => app()->environment(),
'app_url' => config('app.url'),
'academy_enabled' => $academyEnabled,
'academy_billing_enabled' => $billingEnabled,
'subscription_name' => $this->plans->subscriptionName(),
'cashier_path' => (string) config('cashier.path', 'stripe'),
'stripe' => [
'publishable_key_configured' => $this->isConfiguredSecret($stripeKey, 'pk_'),
'secret_key_configured' => $this->isConfiguredSecret($stripeSecret, 'sk_'),
'webhook_secret_configured' => $this->isConfiguredSecret($webhookSecret, 'whsec_'),
'currency' => $currency,
'currency_locale' => $currencyLocale,
],
'configured_plan_count' => count($planSummaries),
'missing_plan_keys' => $missingPlanKeys,
'invalid_plan_keys' => $invalidPlanKeys,
'plan_summaries' => $planSummaries,
'routes' => $routes,
'tables' => $tables,
'user_billing_columns' => $userBillingColumns,
'users_billing_columns_present' => ! in_array(false, $userBillingColumns, true),
'blockers' => array_values(array_unique($blockers)),
'warnings' => array_values(array_unique($warnings)),
'status' => $status,
];
}
/**
* @return array{present: bool, url: string|null}
*/
private function routeStatus(string $name): array
{
if (! Route::has($name)) {
return [
'present' => false,
'url' => null,
];
}
return [
'present' => true,
'url' => route($name),
];
}
private function isConfiguredSecret(string $value, string $expectedPrefix): bool
{
$value = trim($value);
if ($value === '' || ! str_starts_with($value, $expectedPrefix)) {
return false;
}
return ! str_contains(strtolower($value), 'xxx');
}
/**
* @param array<string, mixed> $report
*/
private function exitCodeFor(array $report): int
{
if ((bool) $this->option('strict') && $report['status'] === 'BLOCKED') {
return self::FAILURE;
}
return self::SUCCESS;
}
private function firstConfiguredString(mixed ...$values): string
{
foreach ($values as $value) {
$value = $this->configuredString($value);
if ($value !== '') {
return $value;
}
}
return '';
}
private function configuredString(mixed $value): string
{
return is_string($value) ? trim($value) : '';
}
}
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseLesson;
use App\Models\AcademyCourseSection;
use App\Models\AcademyLesson;
use App\Services\Academy\AcademyCacheService;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
final class AcademyCoursesSyncFoundationsCommand extends Command
{
protected $signature = 'academy:courses:sync-foundations';
protected $description = 'Create or update the default AI-Assisted Digital Art Foundations Academy course.';
public function __construct(private readonly AcademyCacheService $cache)
{
parent::__construct();
}
public function handle(): int
{
$course = AcademyCourse::query()->updateOrCreate(
['slug' => 'ai-assisted-digital-art-foundations'],
[
'title' => 'AI-Assisted Digital Art Foundations',
'subtitle' => 'A guided path through prompting, publishing, and better Skinbase-ready workflows.',
'excerpt' => 'Learn the foundations of AI-assisted digital art, from better prompts and ethical rules to preparing, tagging, and publishing artwork on Skinbase.',
'description' => 'A starter course for Skinbase creators who want a structured path from core AI-art concepts to cleaner publishing-ready results.',
'access_level' => 'free',
'difficulty' => 'beginner',
'status' => 'published',
'is_featured' => true,
'order_num' => 1,
'published_at' => now(),
],
);
$sectionOrder = [
'Introduction',
'Prompting Basics',
'Publishing on Skinbase',
'Workflow and Quality',
];
$sections = collect($sectionOrder)->mapWithKeys(function (string $title, int $index) use ($course): array {
$section = AcademyCourseSection::query()->updateOrCreate(
['course_id' => $course->id, 'slug' => Str::slug($title)],
[
'title' => $title,
'order_num' => $index,
'is_visible' => true,
],
);
return [$title => $section];
});
$lessonMap = [
'Introduction' => [
'what-is-ai-assisted-digital-art',
'ai-ethics-and-skinbase-upload-rules',
'ai-generated-vs-ai-assisted-artwork',
],
'Prompting Basics' => [
'prompting-basics-for-skinbase-creators',
'how-to-write-better-wallpaper-prompts',
'understanding-style-mood-lighting-and-composition',
],
'Publishing on Skinbase' => [
'how-to-prepare-ai-artwork-for-upload',
'how-to-choose-better-tags-and-categories',
],
'Workflow and Quality' => [
'how-to-avoid-common-ai-image-problems',
'from-idea-to-artwork-a-simple-skinbase-workflow',
],
];
$orderNum = 0;
foreach ($lessonMap as $sectionTitle => $slugs) {
$section = $sections->get($sectionTitle);
foreach ($slugs as $slug) {
$lesson = AcademyLesson::query()->where('slug', $slug)->first();
if (! $lesson instanceof AcademyLesson) {
$this->warn(sprintf('Skipped missing lesson [%s].', $slug));
continue;
}
AcademyCourseLesson::query()->updateOrCreate(
[
'course_id' => $course->id,
'lesson_id' => $lesson->id,
],
[
'section_id' => $section?->id,
'order_num' => $orderNum,
'is_required' => true,
],
);
$orderNum++;
}
}
$course->forceFill([
'lessons_count_cache' => AcademyCourseLesson::query()->where('course_id', $course->id)->count(),
])->save();
$this->cache->clearAll();
$this->info('AI-Assisted Digital Art Foundations course synced.');
return self::SUCCESS;
}
}
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\WorldWebStory;
use App\Services\WebStories\WorldWebStoryAssetService;
use Illuminate\Console\Command;
final class BuildWorldWebStoryAssetsCommand extends Command
{
protected $signature = 'skinbase:webstories:build-assets
{story? : Story ID or slug}
{--published : Limit batch mode to published stories}
{--visible : Limit batch mode to stories currently visible on the public site}
{--limit=100 : Maximum stories to process in batch mode}
{--force : Rebuild already populated asset paths}
{--dry-run : Report changes without saving them}';
protected $description = 'Backfill poster, logo, and page background assets for World Web Stories';
public function handle(WorldWebStoryAssetService $assets): int
{
$storyKey = $this->argument('story');
$force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
if ($storyKey !== null && trim((string) $storyKey) !== '') {
$story = $this->resolveStory((string) $storyKey);
if (! $story instanceof WorldWebStory) {
$this->error(sprintf('Web story [%s] was not found.', (string) $storyKey));
return self::FAILURE;
}
return $this->buildOne($assets, $story, $force, $dryRun);
}
return $this->buildBatch($assets, $force, $dryRun, max(1, (int) $this->option('limit')));
}
private function buildOne(WorldWebStoryAssetService $assets, WorldWebStory $story, bool $force, bool $dryRun): int
{
$result = $assets->buildAssets($story, force: $force, dryRun: $dryRun);
$this->line(sprintf('Story [%d] %s', (int) $story->id, (string) $story->slug));
$this->line($result['updated'] ? 'Assets updated.' : 'No asset changes needed.');
foreach ((array) $result['story'] as $field => $value) {
$this->line(sprintf(' - story.%s = %s', (string) $field, (string) $value));
}
foreach ((array) $result['pages'] as $pageId => $changes) {
foreach ((array) $changes as $field => $value) {
$this->line(sprintf(' - page.%d.%s = %s', (int) $pageId, (string) $field, (string) $value));
}
}
return self::SUCCESS;
}
private function buildBatch(WorldWebStoryAssetService $assets, bool $force, bool $dryRun, int $limit): int
{
$processed = 0;
$updated = 0;
$this->storyQuery()
->limit($limit)
->get()
->each(function (WorldWebStory $story) use ($assets, $force, $dryRun, &$processed, &$updated): void {
$processed++;
$result = $assets->buildAssets($story, force: $force, dryRun: $dryRun);
if ($result['updated']) {
$updated++;
}
$this->line(sprintf('[%d] %s -> %s', (int) $story->id, (string) $story->slug, $result['updated'] ? 'updated' : 'unchanged'));
});
$this->info(sprintf('Done. processed=%d updated=%d', $processed, $updated));
return self::SUCCESS;
}
private function storyQuery()
{
return WorldWebStory::query()
->when((bool) $this->option('published'), fn ($query) => $query->published())
->when((bool) $this->option('visible'), fn ($query) => $query->visible())
->orderByDesc('published_at')
->orderByDesc('id');
}
private function resolveStory(string $value): ?WorldWebStory
{
return WorldWebStory::query()
->when(is_numeric($value), fn ($query) => $query->where('id', (int) $value), fn ($query) => $query->where('slug', $value))
->first();
}
}
@@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Enhance;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceStorageService;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;
final class CleanupEnhanceJobsCommand extends Command
{
protected $signature = 'enhance:cleanup
{--dry-run : Preview cleanup actions only}
{--force : Delete files and update records}
{--only= : Restrict cleanup to expired, deleted, failed, or orphaned}
{--days= : Override retention days for failed or deleted cleanup}';
protected $description = 'Safely clean expired, deleted, failed, and orphaned Enhance files.';
public function __construct(
private readonly EnhanceStorageService $storage,
) {
parent::__construct();
}
public function handle(): int
{
$target = strtolower(trim((string) $this->option('only')));
$validTargets = ['', 'expired', 'deleted', 'failed', 'orphaned'];
if (! in_array($target, $validTargets, true)) {
$this->error('The --only option must be one of: expired, deleted, failed, orphaned.');
return self::FAILURE;
}
if ((bool) $this->option('dry-run') && (bool) $this->option('force')) {
$this->error('Use either --dry-run or --force, not both.');
return self::FAILURE;
}
$dryRun = (bool) $this->option('dry-run') || ! (bool) $this->option('force');
$daysOverride = $this->option('days');
$selectedTarget = $target !== '' ? $target : 'all';
Log::info('enhance.cleanup.started', [
'dry_run' => $dryRun,
'target' => $selectedTarget,
'days_override' => $daysOverride,
]);
if ($dryRun) {
$this->warn('Running in dry-run mode. No files will be deleted.');
}
$rows = [];
if ($target === '' || $target === 'expired') {
$expired = $this->cleanupExpiredCompletedJobs($dryRun);
$rows[] = ['expired', $expired['jobs'], $expired['files'], $dryRun ? 'dry-run' : 'cleaned'];
}
if ($target === '' || $target === 'failed') {
$failed = $this->cleanupFailedJobs($dryRun, $daysOverride);
$rows[] = ['failed', $failed['jobs'], $failed['files'], $dryRun ? 'dry-run' : 'cleaned'];
}
if ($target === '' || $target === 'deleted') {
$deleted = $this->cleanupSoftDeletedJobs($dryRun, $daysOverride);
$rows[] = ['deleted', $deleted['jobs'], $deleted['files'], $dryRun ? 'dry-run' : 'cleaned'];
}
if ($target === 'orphaned') {
$orphaned = $this->scanOrphanedFiles($dryRun);
$rows[] = ['orphaned', $orphaned['files'], $orphaned['deleted'], $dryRun ? 'dry-run' : 'deleted'];
if ($orphaned['unsupported']) {
$this->warn('Orphaned file scan was skipped because the configured disk does not support safe listing.');
}
foreach ($orphaned['sample'] as $path) {
$this->line(' - ' . $path);
}
}
if ($rows !== []) {
$this->table(['Target', 'Jobs/Files', 'Files deleted', 'Mode'], $rows);
}
Log::info('enhance.cleanup.completed', [
'dry_run' => $dryRun,
'target' => $selectedTarget,
'rows' => $rows,
]);
$this->info('Enhance cleanup finished.');
return self::SUCCESS;
}
private function cleanupExpiredCompletedJobs(bool $dryRun): array
{
$query = EnhanceJob::query()
->where('status', EnhanceJob::STATUS_COMPLETED)
->whereNotNull('expires_at')
->where('expires_at', '<=', now());
return $this->cleanupJobs($query, $dryRun, 'expired', fn (): array => [
'status' => EnhanceJob::STATUS_EXPIRED,
]);
}
private function cleanupFailedJobs(bool $dryRun, mixed $daysOverride): array
{
$days = $this->resolveDays($daysOverride, (int) config('enhance.lifecycle.failed_expires_after_days', 7));
$cutoff = now()->subDays($days);
$query = EnhanceJob::query()
->where('status', EnhanceJob::STATUS_FAILED)
->where(function (Builder $builder) use ($cutoff): void {
$builder
->where('finished_at', '<=', $cutoff)
->orWhere(function (Builder $fallback) use ($cutoff): void {
$fallback->whereNull('finished_at')->where('created_at', '<=', $cutoff);
});
});
return $this->cleanupJobs($query, $dryRun, 'failed-expired');
}
private function cleanupSoftDeletedJobs(bool $dryRun, mixed $daysOverride): array
{
$days = $this->resolveDays($daysOverride, (int) config('enhance.lifecycle.deleted_file_grace_days', 1));
$cutoff = now()->subDays($days);
$query = EnhanceJob::withTrashed()
->whereNotNull('deleted_at')
->where('deleted_at', '<=', $cutoff);
return $this->cleanupJobs($query, $dryRun, 'deleted-grace');
}
private function cleanupJobs(Builder $query, bool $dryRun, string $reason, ?callable $attributes = null): array
{
$result = ['jobs' => 0, 'files' => 0];
$chunkSize = max(1, (int) config('enhance.lifecycle.cleanup_chunk_size', 100));
$query->chunkById($chunkSize, function ($jobs) use (&$result, $dryRun, $reason, $attributes): void {
foreach ($jobs as $job) {
$result['jobs']++;
$result['files'] += count($this->enhanceJobPaths($job));
if ($dryRun) {
continue;
}
$deleteResult = $this->storage->deleteFilesForJob($job);
$metadata = is_array($job->metadata) ? $job->metadata : [];
$job->forceFill(array_merge(
$this->cleanupAttributesForJob($job),
$attributes ? $attributes($job) : [],
[
'metadata' => array_merge($metadata, [
'cleanup' => [
'files_removed_at' => now()->toIso8601String(),
'reason' => $reason,
'deleted' => $deleteResult['deleted'],
],
]),
],
))->save();
}
});
return $result;
}
private function scanOrphanedFiles(bool $dryRun): array
{
$disk = $this->storage->diskName();
$knownPaths = array_fill_keys(array_map(
static fn (string $path): string => ltrim($path, '/'),
$this->storage->listKnownJobPaths(),
), true);
$sample = [];
$result = [
'files' => 0,
'deleted' => 0,
'unsupported' => false,
'sample' => [],
];
foreach ($this->enhancePrefixes() as $prefix) {
try {
$files = Storage::disk($disk)->allFiles($prefix);
} catch (Throwable) {
$result['unsupported'] = true;
return $result;
}
foreach ($files as $file) {
$normalized = ltrim($file, '/');
if (isset($knownPaths[$normalized])) {
continue;
}
$result['files']++;
if (count($sample) < 20) {
$sample[] = $normalized;
}
if (! $dryRun && $this->storage->safeDelete($disk, $normalized)) {
$result['deleted']++;
}
}
}
$result['sample'] = $sample;
return $result;
}
private function cleanupAttributesForJob(EnhanceJob $job): array
{
$attributes = [];
if ($this->storage->isEnhancePath($job->source_path)) {
$attributes['source_disk'] = null;
$attributes['source_path'] = null;
$attributes['source_hash'] = null;
}
if ($this->storage->isEnhancePath($job->output_path)) {
$attributes['output_disk'] = null;
$attributes['output_path'] = null;
$attributes['output_hash'] = null;
$attributes['output_width'] = null;
$attributes['output_height'] = null;
$attributes['output_filesize'] = null;
$attributes['output_mime'] = null;
}
if ($this->storage->isEnhancePath($job->preview_path)) {
$attributes['preview_disk'] = null;
$attributes['preview_path'] = null;
}
return $attributes;
}
private function enhanceJobPaths(EnhanceJob $job): array
{
return array_values(array_filter([
$this->storage->isEnhancePath($job->source_path) ? trim((string) $job->source_path) : null,
$this->storage->isEnhancePath($job->output_path) ? trim((string) $job->output_path) : null,
$this->storage->isEnhancePath($job->preview_path) ? trim((string) $job->preview_path) : null,
]));
}
private function enhancePrefixes(): array
{
return array_values(array_filter(array_unique(array_map(
static fn (string $prefix): string => trim($prefix, '/'),
[
(string) config('enhance.source_prefix', 'enhance/sources'),
(string) config('enhance.output_prefix', 'enhance/outputs'),
(string) config('enhance.preview_prefix', 'enhance/previews'),
],
))));
}
private function resolveDays(mixed $daysOverride, int $default): int
{
if ($daysOverride === null || $daysOverride === '') {
return max(0, $default);
}
return max(0, (int) $daysOverride);
}
}
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Enhance;
use App\Models\EnhanceJob;
use Illuminate\Console\Command;
final class EnhanceHealthCommand extends Command
{
protected $signature = 'enhance:health {--json : Output machine-readable JSON}';
protected $description = 'Report operational health and lifecycle metrics for Enhance jobs.';
public function handle(): int
{
$payload = $this->payload();
if ((bool) $this->option('json')) {
$this->line(json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$this->info('Enhance health');
$this->newLine();
$this->table(['Metric', 'Value'], [
['Configured engine', $payload['engine']],
['Configured queue', $payload['queue']],
['Worker URL configured', $payload['worker_configured'] ? 'yes' : 'no'],
['Storage disk', $payload['storage_disk']],
['Total jobs', $payload['counts']['total']],
['Pending jobs', $payload['counts']['pending']],
['Queued jobs', $payload['counts']['queued']],
['Processing jobs', $payload['counts']['processing']],
['Completed jobs', $payload['counts']['completed']],
['Failed jobs', $payload['counts']['failed']],
['Cancelled jobs', $payload['counts']['cancelled']],
['Expired jobs', $payload['counts']['expired']],
['Stuck queued jobs', $payload['health']['stuck_queued']],
['Stuck processing jobs', $payload['health']['stuck_processing']],
['Jobs created today', $payload['today']['created']],
['Jobs completed today', $payload['today']['completed']],
['Jobs failed today', $payload['today']['failed']],
['Average processing time today', $payload['today']['average_processing_seconds'] ?? '—'],
]);
return self::SUCCESS;
}
private function payload(): array
{
$todayStart = now()->startOfDay();
$todayEnd = now()->endOfDay();
$stuckQueuedCutoff = now()->subMinutes((int) config('enhance.health.stuck_queued_after_minutes', 60));
$stuckProcessingCutoff = now()->subMinutes((int) config('enhance.health.stuck_processing_after_minutes', 30));
$counts = [
'total' => EnhanceJob::query()->count(),
'pending' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_PENDING)->count(),
'queued' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_QUEUED)->count(),
'processing' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_PROCESSING)->count(),
'completed' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_COMPLETED)->count(),
'failed' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_FAILED)->count(),
'cancelled' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_CANCELLED)->count(),
'expired' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_EXPIRED)->count(),
];
return [
'engine' => (string) config('enhance.default_engine', EnhanceJob::ENGINE_STUB),
'queue' => (string) config('enhance.queue', 'default'),
'worker_configured' => trim((string) config('enhance.external_worker.url', '')) !== '',
'storage_disk' => (string) config('enhance.disk', 'public'),
'counts' => $counts,
'health' => [
'stuck_queued' => EnhanceJob::query()
->where('status', EnhanceJob::STATUS_QUEUED)
->whereNotNull('queued_at')
->where('queued_at', '<=', $stuckQueuedCutoff)
->count(),
'stuck_processing' => EnhanceJob::query()
->where('status', EnhanceJob::STATUS_PROCESSING)
->whereNotNull('started_at')
->where('started_at', '<=', $stuckProcessingCutoff)
->count(),
],
'today' => [
'created' => EnhanceJob::query()->whereBetween('created_at', [$todayStart, $todayEnd])->count(),
'completed' => EnhanceJob::query()
->where('status', EnhanceJob::STATUS_COMPLETED)
->whereBetween('finished_at', [$todayStart, $todayEnd])
->count(),
'failed' => EnhanceJob::query()
->where('status', EnhanceJob::STATUS_FAILED)
->whereBetween('finished_at', [$todayStart, $todayEnd])
->count(),
'average_processing_seconds' => ($average = EnhanceJob::query()
->whereNotNull('processing_seconds')
->whereBetween('finished_at', [$todayStart, $todayEnd])
->avg('processing_seconds')) !== null
? round((float) $average, 2)
: null,
],
];
}
}
@@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Enhance;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceProcessorFactory;
use App\Services\Enhance\EnhanceStorageService;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;
final class EnhanceRunCommand extends Command
{
protected $signature = 'enhance:run
{--id= : Process specific job ID(s), comma-separated}
{--limit=1 : Max pending/queued jobs to pick up from the queue (0 = all)}
{--engine= : Override the processing engine for this run (stub, external_worker)}
{--failed : Also include failed jobs when scanning the queue}
{--dry-run : Show what would be processed without executing}';
protected $description = 'Synchronously process pending enhance jobs inline — useful for debugging with -v / -vv / -vvv.';
private const PROCESSABLE_STATUSES = [
EnhanceJob::STATUS_PENDING,
EnhanceJob::STATUS_QUEUED,
EnhanceJob::STATUS_PROCESSING,
EnhanceJob::STATUS_FAILED,
];
public function __construct(
private readonly EnhanceProcessorFactory $processorFactory,
private readonly EnhanceStorageService $storage,
) {
parent::__construct();
}
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$engineOverride = trim((string) $this->option('engine'));
$idOption = trim((string) $this->option('id'));
$limit = max(0, (int) $this->option('limit'));
$includeFailed = (bool) $this->option('failed');
if ($engineOverride !== '' && ! in_array($engineOverride, [EnhanceJob::ENGINE_STUB, EnhanceJob::ENGINE_EXTERNAL_WORKER], true)) {
$this->error("Unknown engine override: {$engineOverride}. Use 'stub' or 'external_worker'.");
return self::FAILURE;
}
$jobs = $this->resolveJobs($idOption, $limit, $includeFailed);
if ($jobs->isEmpty()) {
$this->info('No eligible enhance jobs found.');
return self::SUCCESS;
}
$this->info(sprintf('Found %d job(s) to process.', $jobs->count()));
$this->newLine();
if ($dryRun) {
$this->warn('Dry-run mode — no jobs will be processed.');
foreach ($jobs as $job) {
$engine = $engineOverride !== '' ? "{$engineOverride} (overridden)" : $job->engine;
$this->line(sprintf(
' [dry-run] Job #%d status=%-12s engine=%-18s scale=%dx mode=%-14s user_id=%d',
$job->id,
$job->status,
$engine,
$job->scale,
$job->mode,
$job->user_id,
));
}
return self::SUCCESS;
}
$processed = 0;
$failed = 0;
foreach ($jobs as $job) {
if ($this->processJob($job, $engineOverride)) {
$processed++;
} else {
$failed++;
}
$this->newLine();
}
$this->info(sprintf('Done: %d completed, %d failed.', $processed, $failed));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
private function resolveJobs(string $idOption, int $limit, bool $includeFailed): Collection
{
if ($idOption !== '') {
$ids = array_filter(array_map('intval', explode(',', $idOption)));
return EnhanceJob::query()
->whereIn('id', $ids)
->whereIn('status', self::PROCESSABLE_STATUSES)
->get();
}
$statuses = [EnhanceJob::STATUS_PENDING, EnhanceJob::STATUS_QUEUED, EnhanceJob::STATUS_PROCESSING];
if ($includeFailed) {
$statuses[] = EnhanceJob::STATUS_FAILED;
}
$query = EnhanceJob::query()->whereIn('status', $statuses)->oldest();
if ($limit > 0) {
$query->limit($limit);
}
return $query->get();
}
private function processJob(EnhanceJob $job, string $engineOverride): bool
{
$engine = $engineOverride !== '' ? $engineOverride : (string) $job->engine;
$this->line(sprintf('<comment>--- Job #%d ---</comment>', $job->id));
$this->line(sprintf(' Status : %s', $job->status));
$this->line(sprintf(' Engine : %s%s', $engine, $engineOverride !== '' ? ' (overridden)' : ''));
$this->line(sprintf(' Scale : %dx', $job->scale));
$this->line(sprintf(' Mode : %s', $job->mode));
$this->line(sprintf(' User : #%d', $job->user_id));
if ($this->output->isVerbose()) {
$this->line(sprintf(
' Source : disk=%-10s path=%s',
$job->source_disk ?: '(default)',
$job->source_path ?: '—',
));
$this->line(sprintf(
' Input : %dx%d size=%s mime=%s',
(int) $job->input_width,
(int) $job->input_height,
$this->formatBytes((int) $job->input_filesize),
$job->input_mime ?: '—',
));
if ($job->error_message !== null) {
$this->warn(sprintf(' Previous error: %s', $job->error_message));
}
}
if (! in_array($job->status, self::PROCESSABLE_STATUSES, true)) {
$this->warn(sprintf(' Skipping: status "%s" is not processable.', $job->status));
return false;
}
$job->forceFill([
'status' => EnhanceJob::STATUS_PROCESSING,
'started_at' => now(),
'finished_at' => null,
'error_message' => null,
])->save();
$started = microtime(true);
$completedExpiryDays = (int) config('enhance.lifecycle.completed_expires_after_days', 30);
try {
$this->line(' Processing...');
$processor = $this->processorFactory->make($engine);
$result = $processor->process($job);
if ($this->output->isVerbose()) {
$this->line(sprintf(
' Output : %dx%d size=%s mime=%s',
$result->width,
$result->height,
$this->formatBytes($result->filesize),
$result->mime,
));
$this->line(sprintf(
' Stored : disk=%-10s path=%s',
$result->disk,
$result->path,
));
}
$this->line(' Generating preview...');
$preview = $this->storage->createPreviewFromStoredOutput($job, $result->disk, $result->path) ?? [];
$outputHash = null;
$outputContents = Storage::disk($result->disk)->get($result->path);
if (is_string($outputContents) && $outputContents !== '') {
$outputHash = hash('sha256', $outputContents);
}
$job->forceFill([
'status' => EnhanceJob::STATUS_COMPLETED,
'output_disk' => $result->disk,
'output_path' => $result->path,
'output_hash' => $outputHash,
'output_width' => $result->width,
'output_height' => $result->height,
'output_filesize' => $result->filesize,
'output_mime' => $result->mime,
'metadata' => array_merge($job->metadata ?? [], $result->metadata ?? []),
'processing_seconds' => (int) round(microtime(true) - $started),
'finished_at' => now(),
'expires_at' => $completedExpiryDays > 0 ? now()->addDays($completedExpiryDays) : null,
] + $preview)->save();
$elapsed = round(microtime(true) - $started, 2);
$this->info(sprintf(' Completed in %.2fs', $elapsed));
if ($this->output->isVerbose() && ! empty($result->metadata)) {
$this->line(' Metadata:');
foreach ($result->metadata as $key => $value) {
$display = is_scalar($value) ? (string) $value : json_encode($value);
$this->line(sprintf(' %-30s %s', $key . ':', $display));
}
}
return true;
} catch (Throwable $exception) {
$elapsed = round(microtime(true) - $started, 2);
$job->forceFill([
'status' => EnhanceJob::STATUS_FAILED,
'error_message' => Str::limit($exception->getMessage(), 1000),
'processing_seconds' => (int) round(microtime(true) - $started),
'finished_at' => now(),
])->save();
$this->error(sprintf(' Failed in %.2fs: %s', $elapsed, $exception->getMessage()));
if ($this->output->isVerbose()) {
$this->line(sprintf(' Exception : %s', get_class($exception)));
$this->line(sprintf(' At : %s:%d', $exception->getFile(), $exception->getLine()));
$previous = $exception->getPrevious();
if ($previous !== null) {
$this->line(sprintf(' Caused by : %s: %s', get_class($previous), $previous->getMessage()));
}
}
if ($this->output->isVeryVerbose()) {
$this->line(' Stack trace:');
$frames = array_slice(explode("\n", $exception->getTraceAsString()), 0, 25);
foreach ($frames as $frame) {
$this->line(' ' . $frame);
}
}
Log::warning('enhance.run.command.failed', [
'enhance_job_id' => $job->id,
'engine' => $engine,
'message' => $exception->getMessage(),
'exception' => get_class($exception),
]);
return false;
}
}
private function formatBytes(int $bytes): string
{
if ($bytes < 1024) {
return $bytes . 'B';
}
if ($bytes < 1_048_576) {
return round($bytes / 1024, 1) . 'KB';
}
return round($bytes / 1_048_576, 1) . 'MB';
}
}
@@ -0,0 +1,390 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AcademyPromptTemplate;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Throwable;
final class GenerateAcademyPromptThumbnailsCommand extends Command
{
private const PROMPT_PREVIEW_PREFIX = 'academy-prompts/previews';
/**
* @var array<string, int>
*/
private const VARIANT_WIDTHS = [
'thumb' => 480,
'md' => 960,
];
private const PREVIEW_WEBP_QUALITY = 84;
private const LESSON_MEDIA_WEBP_QUALITY = 85;
protected $signature = 'academy:prompts:generate-missing-thumbnails
{--id=* : Restrict to one or more prompt IDs}
{--slug=* : Restrict to one or more prompt slugs}
{--limit= : Stop after processing this many prompts}
{--force : Regenerate variants even when they already exist}
{--dry-run : Report planned thumbnail work without writing files or saving prompt JSON}';
protected $description = 'Generate missing prompt preview and comparison thumbnails for existing Academy prompts';
public function handle(): int
{
if (! function_exists('imagecreatefromstring') || ! function_exists('imagewebp')) {
$this->error('GD WebP support is required to generate prompt thumbnails.');
return self::FAILURE;
}
$ids = collect((array) $this->option('id'))
->map(static fn (mixed $id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->values()
->all();
$slugs = collect((array) $this->option('slug'))
->map(static fn (mixed $slug): string => trim((string) $slug))
->filter(static fn (string $slug): bool => $slug !== '')
->values()
->all();
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
$query = AcademyPromptTemplate::query()
->select(['id', 'slug', 'title', 'preview_image', 'tool_notes'])
->orderBy('id');
if ($ids !== []) {
$query->whereIn('id', $ids);
}
if ($slugs !== []) {
$query->whereIn('slug', $slugs);
}
$processed = 0;
$changed = 0;
$generatedVariants = 0;
$plannedVariants = 0;
$skipped = 0;
$failed = 0;
$query->chunkById(100, function ($prompts) use ($limit, $force, $dryRun, &$processed, &$changed, &$generatedVariants, &$plannedVariants, &$skipped, &$failed) {
foreach ($prompts as $prompt) {
if ($limit !== null && $processed >= $limit) {
return false;
}
try {
$result = $this->backfillPrompt($prompt, $force, $dryRun);
$generatedVariants += (int) ($result['generated_variants'] ?? 0);
$plannedVariants += (int) ($result['planned_variants'] ?? 0);
if (($result['changed'] ?? false) === true) {
$changed++;
} else {
$skipped++;
}
} catch (Throwable $e) {
$failed++;
$this->warn(sprintf('Prompt %d (%s) failed: %s', (int) $prompt->id, (string) $prompt->slug, $e->getMessage()));
}
$processed++;
}
return true;
});
$this->info(sprintf(
'Prompt thumbnail backfill complete. processed=%d changed=%d generated_variants=%d planned_variants=%d skipped=%d failed=%d',
$processed,
$changed,
$generatedVariants,
$plannedVariants,
$skipped,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @return array{changed:bool,generated_variants:int,planned_variants:int}
*/
private function backfillPrompt(AcademyPromptTemplate $prompt, bool $force, bool $dryRun): array
{
$generatedVariants = 0;
$plannedVariants = 0;
$changed = false;
$previewResult = $this->ensureManagedImageVariants((string) ($prompt->preview_image ?? ''), $force, $dryRun);
$generatedVariants += $previewResult['generated_variants'];
$plannedVariants += $previewResult['planned_variants'];
$changed = $changed || $previewResult['changed'];
$notes = is_array($prompt->tool_notes) ? $prompt->tool_notes : [];
$nextNotes = [];
foreach ($notes as $note) {
if (! is_array($note)) {
$nextNotes[] = $note;
continue;
}
$noteResult = $this->ensurePromptComparisonNoteVariants($note, $force, $dryRun);
$generatedVariants += $noteResult['generated_variants'];
$plannedVariants += $noteResult['planned_variants'];
$changed = $changed || $noteResult['changed'];
$nextNotes[] = $noteResult['note'];
}
if ($changed && ! $dryRun && $nextNotes !== $notes) {
$prompt->forceFill([
'tool_notes' => $nextNotes,
])->save();
}
return [
'changed' => $changed,
'generated_variants' => $generatedVariants,
'planned_variants' => $plannedVariants,
];
}
/**
* @param array<string, mixed> $note
* @return array{note:array<string, mixed>,changed:bool,generated_variants:int,planned_variants:int}
*/
private function ensurePromptComparisonNoteVariants(array $note, bool $force, bool $dryRun): array
{
$imagePath = trim((string) ($note['image_path'] ?? ''));
if (! $this->isManagedLessonMediaPath($imagePath)) {
return [
'note' => $note,
'changed' => false,
'generated_variants' => 0,
'planned_variants' => 0,
];
}
$variants = $this->ensureManagedImageVariants($imagePath, $force, $dryRun);
$thumbPath = $variants['thumb_path'] ?? '';
if ($thumbPath === '') {
$thumbPath = $imagePath;
}
$nextNote = $note;
$currentThumbPath = trim((string) ($note['thumb_path'] ?? ''));
if ($currentThumbPath !== $thumbPath) {
$nextNote['thumb_path'] = $thumbPath;
$variants['changed'] = true;
}
return [
'note' => $nextNote,
'changed' => (bool) $variants['changed'],
'generated_variants' => (int) $variants['generated_variants'],
'planned_variants' => (int) $variants['planned_variants'],
];
}
/**
* @return array{thumb_path:string,changed:bool,generated_variants:int,planned_variants:int}
*/
private function ensureManagedImageVariants(string $path, bool $force, bool $dryRun): array
{
$path = trim($path);
if (! $this->isManagedPromptPreviewPath($path) && ! $this->isManagedLessonMediaPath($path)) {
return [
'thumb_path' => '',
'changed' => false,
'generated_variants' => 0,
'planned_variants' => 0,
];
}
$source = $this->openManagedImage($path);
try {
$generatedVariants = 0;
$plannedVariants = 0;
$changed = false;
$thumbPath = $path;
foreach (self::VARIANT_WIDTHS as $variant => $targetWidth) {
$status = $this->ensureVariantForWidth(
$source['image'],
$source['width'],
$source['height'],
$path,
$variant,
$targetWidth,
$force,
$dryRun,
);
if ($variant === 'thumb' && $source['width'] > $targetWidth) {
$thumbPath = $this->variantPath($path, 'thumb');
}
if ($status === 'generated') {
$generatedVariants++;
$changed = true;
}
if ($status === 'planned') {
$plannedVariants++;
$changed = true;
}
}
return [
'thumb_path' => $thumbPath,
'changed' => $changed,
'generated_variants' => $generatedVariants,
'planned_variants' => $plannedVariants,
];
} finally {
imagedestroy($source['image']);
}
}
/**
* @return array{image:\GdImage,width:int,height:int}
*/
private function openManagedImage(string $path): array
{
$disk = Storage::disk($this->storageDisk());
if (! $disk->exists($path)) {
throw new RuntimeException(sprintf('Source image is missing: %s', $path));
}
$binary = $disk->get($path);
if (! is_string($binary) || $binary === '') {
throw new RuntimeException(sprintf('Source image could not be read: %s', $path));
}
$image = @imagecreatefromstring($binary);
if (! $image instanceof \GdImage) {
throw new RuntimeException(sprintf('Source image is not a supported raster image: %s', $path));
}
if (! imageistruecolor($image)) {
imagepalettetotruecolor($image);
}
imagealphablending($image, true);
imagesavealpha($image, true);
return [
'image' => $image,
'width' => imagesx($image),
'height' => imagesy($image),
];
}
private function ensureVariantForWidth(\GdImage $source, int $sourceWidth, int $sourceHeight, string $sourcePath, string $variant, int $targetWidth, bool $force, bool $dryRun): string
{
if ($sourceWidth <= $targetWidth || $sourceWidth < 1 || $sourceHeight < 1) {
return 'skipped';
}
$variantPath = $this->variantPath($sourcePath, $variant);
$disk = Storage::disk($this->storageDisk());
if (! $force && $disk->exists($variantPath)) {
return 'skipped';
}
if ($dryRun) {
return 'planned';
}
$targetHeight = max(1, (int) round(($sourceHeight / $sourceWidth) * $targetWidth));
$canvas = imagecreatetruecolor($targetWidth, $targetHeight);
if (! $canvas instanceof \GdImage) {
throw new RuntimeException(sprintf('Could not allocate variant canvas for %s', $sourcePath));
}
imagealphablending($canvas, false);
imagesavealpha($canvas, true);
$transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127);
imagefilledrectangle($canvas, 0, 0, $targetWidth, $targetHeight, $transparent);
imagecopyresampled($canvas, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $sourceWidth, $sourceHeight);
try {
ob_start();
$converted = imagewebp($canvas, null, $this->qualityForPath($sourcePath));
$webpBinary = ob_get_clean();
if (! $converted || ! is_string($webpBinary) || $webpBinary === '') {
throw new RuntimeException(sprintf('Could not encode %s variant for %s', $variant, $sourcePath));
}
$disk->put($variantPath, $webpBinary, ['visibility' => 'public']);
} finally {
imagedestroy($canvas);
}
return 'generated';
}
private function variantPath(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 isManagedPromptPreviewPath(string $path): bool
{
return $this->isLocalPath($path) && str_starts_with($path, self::PROMPT_PREVIEW_PREFIX . '/');
}
private function isManagedLessonMediaPath(string $path): bool
{
return $this->isLocalPath($path)
&& (str_starts_with($path, 'academy/lessons/body/') || str_starts_with($path, 'academy/lessons/covers/'));
}
private function isLocalPath(string $path): bool
{
return $path !== ''
&& ! str_starts_with($path, 'http://')
&& ! str_starts_with($path, 'https://')
&& ! str_starts_with($path, '/');
}
private function storageDisk(): string
{
return (string) config('uploads.object_storage.disk', 's3');
}
private function qualityForPath(string $path): int
{
return $this->isManagedPromptPreviewPath($path)
? self::PREVIEW_WEBP_QUALITY
: self::LESSON_MEDIA_WEBP_QUALITY;
}
}
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\GenerateFeaturedArtworkThumbnailsJob;
use App\Models\Artwork;
use App\Services\Featured\FeaturedArtworkSelector;
use App\Services\Images\FeaturedArtworkThumbnailGenerator;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\LazyCollection;
final class GenerateFeaturedArtworkThumbnailsCommand extends Command
{
protected $signature = 'skinbase:featured-thumbnails:generate
{--artwork=* : Restrict generation to one or more artwork IDs}
{--only-featured : Restrict generation to currently selected featured artworks}
{--missing-only : Only generate artworks missing at least one featured variant}
{--all : Process all artworks with a hash and source extension}
{--limit=0 : Cap the number of artworks processed}
{--queue : Dispatch background jobs instead of generating inline}
{--force : Regenerate all featured variants even when they already exist}
{--dry-run : Report planned generation without writing files}';
protected $description = 'Generate dedicated featured artwork CDN thumbnails for the homepage hero';
public function __construct(
private readonly FeaturedArtworkSelector $selector,
private readonly FeaturedArtworkThumbnailGenerator $generator,
) {
parent::__construct();
}
public function handle(): int
{
$artworkIds = collect((array) $this->option('artwork'))
->map(static fn (mixed $id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->values()
->all();
$force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
$queue = (bool) $this->option('queue');
$limit = max(0, (int) $this->option('limit'));
$all = (bool) $this->option('all');
$explicitOnlyFeatured = (bool) $this->option('only-featured');
$missingOnly = $force ? false : ((bool) $this->option('missing-only') || ($artworkIds === [] && ! $all));
if ($all && $explicitOnlyFeatured) {
$this->error('Use either --all or --only-featured, not both.');
return self::INVALID;
}
if ($queue && $dryRun) {
$this->error('Use either --queue or --dry-run, not both.');
return self::INVALID;
}
$onlyFeatured = $artworkIds === [] && ! $all;
if ($explicitOnlyFeatured) {
$onlyFeatured = true;
}
$processed = 0;
$queued = 0;
$generatedVariants = 0;
$skipped = 0;
$failed = 0;
foreach ($this->candidateArtworks($artworkIds, $onlyFeatured) as $artwork) {
if ($limit > 0 && $processed >= $limit) {
break;
}
$processed++;
$plan = $this->generator->plan($artwork, $force);
$targetVariants = (array) ($plan['target_variants'] ?? []);
if ($missingOnly && ! $force && $targetVariants === []) {
$skipped++;
continue;
}
if ($dryRun) {
$this->line(sprintf(
'[dry-run] artwork=%d variants=%s',
(int) $artwork->id,
$targetVariants === [] ? 'none' : implode(',', $targetVariants),
));
if ($targetVariants === []) {
$skipped++;
} else {
$generatedVariants += count($targetVariants);
}
continue;
}
if ($queue) {
GenerateFeaturedArtworkThumbnailsJob::dispatch((int) $artwork->id, $force);
$queued++;
continue;
}
try {
$result = $this->generator->generate($artwork, $force);
$generatedVariants += (int) ($result['generated'] ?? 0);
$skipped += count((array) ($result['target_variants'] ?? [])) === 0 ? 1 : 0;
if (($result['failed'] ?? []) !== []) {
$failed++;
$this->warn(sprintf(
'Artwork %d failed for variants: %s',
(int) $artwork->id,
implode(', ', array_keys((array) $result['failed'])),
));
}
} catch (\Throwable $exception) {
$failed++;
$this->warn(sprintf('Artwork %d failed: %s', (int) $artwork->id, $exception->getMessage()));
}
}
$mode = $dryRun ? 'dry-run' : ($queue ? 'queued' : 'generated');
$this->info(sprintf(
'Featured artwork thumbnail %s complete: processed=%d queued=%d generated_variants=%d skipped=%d failed=%d',
$mode,
$processed,
$queued,
$generatedVariants,
$skipped,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @param list<int> $artworkIds
* @return LazyCollection<int, Artwork>
*/
private function candidateArtworks(array $artworkIds, bool $onlyFeatured): LazyCollection
{
if ($artworkIds !== []) {
return Artwork::query()
->withTrashed()
->whereIn('id', $artworkIds)
->whereNotNull('hash')
->where('hash', '!=', '')
->whereNotNull('file_ext')
->where('file_ext', '!=', '')
->orderByDesc('id')
->cursor();
}
$query = $onlyFeatured
? $this->selector->querySelectedArtworks()
: Artwork::query()
->select('artworks.*')
->withTrashed()
->whereNotNull('hash')
->where('hash', '!=', '')
->whereNotNull('file_ext')
->where('file_ext', '!=', '');
return $this->orderedCursor($query);
}
/**
* @return LazyCollection<int, Artwork>
*/
private function orderedCursor(Builder $query): LazyCollection
{
return $query
->orderByDesc('artworks.id')
->cursor();
}
}
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\News\NewsCoverImageService;
use App\Support\News\NewsCoverImage;
use Illuminate\Console\Command;
use cPad\Plugins\News\Models\NewsArticle;
final class GenerateNewsCoverThumbnailsCommand extends Command
{
protected $signature = 'news:generate-cover-thumbnails {--id=* : Restrict to one or more news article IDs} {--force : Regenerate variants even when they already exist}';
protected $description = 'Generate missing responsive cover thumbnails for managed news cover images';
public function __construct(
private readonly NewsCoverImageService $covers,
private readonly ArtworkCdnPurgeService $cdnPurge,
)
{
parent::__construct();
}
public function handle(): int
{
$ids = collect((array) $this->option('id'))
->map(static fn (mixed $id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->values()
->all();
$force = (bool) $this->option('force');
$query = NewsArticle::query()
->select(['id', 'title', 'cover_image'])
->whereNotNull('cover_image')
->where('cover_image', '!=', '');
if ($ids !== []) {
$query->whereIn('id', $ids);
}
$generated = 0;
$skipped = 0;
$failed = 0;
$purged = 0;
$query->orderBy('id')->chunkById(100, function ($articles) use (&$generated, &$skipped, &$failed, &$purged, $force): void {
foreach ($articles as $article) {
$path = trim((string) $article->cover_image);
if (! NewsCoverImage::isManagedPath($path)) {
$skipped++;
continue;
}
try {
$result = $this->covers->ensureVariants($path, $force);
} catch (\Throwable $e) {
$failed++;
$this->warn(sprintf('Article %d failed: %s', (int) $article->id, $e->getMessage()));
continue;
}
if (($result['generated'] ?? 0) > 0) {
$generated++;
if ($force && $this->purgeVariantCache($path, (int) $article->id)) {
$purged++;
}
continue;
}
$skipped++;
}
});
$this->info(sprintf('News cover thumbnail generation complete: generated=%d skipped=%d failed=%d purged=%d', $generated, $skipped, $failed, $purged));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
private function purgeVariantCache(string $path, int $articleId): bool
{
$variantPaths = array_values(array_map(
static fn (string $variant): string => NewsCoverImage::variantPath($path, $variant),
array_keys(NewsCoverImage::VARIANTS),
));
return $this->cdnPurge->purgeArtworkObjectPaths($variantPaths, [
'article_id' => $articleId,
'reason' => 'news_cover_thumbnails_regenerated',
]);
}
}
@@ -10,7 +10,7 @@ use Illuminate\Support\Facades\Storage;
/** /**
* Builds all sitemap documents and writes them as static .xml files to the * Builds all sitemap documents and writes them as static .xml files to the
* public disk (default: public/sitemap.xml and public/sitemaps/{name}.xml). * public disk (default: public/sitemaps/sitemap.xml and public/sitemaps/{name}.xml).
* *
* Nginx can then serve those files directly (try_files $uri @php) without * Nginx can then serve those files directly (try_files $uri @php) without
* hitting PHP at all. The SitemapController falls back to these same files * hitting PHP at all. The SitemapController falls back to these same files
@@ -25,11 +25,11 @@ final class GenerateSitemapsCommand extends Command
{--only=* : Limit to specific sitemap families (comma or space separated)} {--only=* : Limit to specific sitemap families (comma or space separated)}
{--disk= : Override the target filesystem disk (default: sitemaps.static_publish.disk)}'; {--disk= : Override the target filesystem disk (default: sitemaps.static_publish.disk)}';
protected $description = 'Build all sitemaps and write them as static .xml files to public/.'; protected $description = 'Build all sitemaps and write them as static .xml files to the configured public sitemap disk.';
public function handle(SitemapBuildService $build): int public function handle(SitemapBuildService $build): int
{ {
$totalS tart = microtime(true); $totalStart = microtime(true);
$families = $this->selectedFamilies($build); $families = $this->selectedFamilies($build);
if ($families === []) { if ($families === []) {
@@ -50,10 +50,10 @@ final class GenerateSitemapsCommand extends Command
// ── Root sitemap index ──────────────────────────────────────────── // ── Root sitemap index ────────────────────────────────────────────
$t = microtime(true); $t = microtime(true);
$index = $build->buildIndex(force: true, persist: false, families: $families); $index = $build->buildIndex(force: true, persist: false, families: $families);
$disk->put('sitemap.xml', $index['content']); $disk->put('sitemaps/sitemap.xml', $index['content']);
$written++; $written++;
$this->line(sprintf( $this->line(sprintf(
' <info>✔</info> sitemap.xml %d entries <comment>%.3fs</comment>', ' <info>✔</info> sitemaps/sitemap.xml %d entries <comment>%.3fs</comment>',
$index['url_count'], $index['url_count'],
microtime(true) - $t, microtime(true) - $t,
)); ));
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\World;
use App\Services\WebStories\WorldWebStoryGenerator;
use Illuminate\Console\Command;
use Illuminate\Validation\ValidationException;
final class GenerateWorldWebStoriesCommand extends Command
{
protected $signature = 'skinbase:webstories:generate
{world? : World ID or slug}
{--all : Generate stories in batch mode}
{--pages=7 : Number of pages to generate (5-10)}
{--limit=25 : Maximum worlds to process in batch mode}
{--force : Rebuild an existing story for the target world}
{--publish : Publish immediately after generation if validation passes}
{--dry-run : Preview generation without saving anything}';
protected $description = 'Generate standalone AMP Web Stories from Skinbase Worlds';
public function handle(WorldWebStoryGenerator $generator): int
{
$worldKey = $this->argument('world');
$force = (bool) $this->option('force');
$publish = (bool) $this->option('publish');
$dryRun = (bool) $this->option('dry-run');
$pages = max(5, min(10, (int) $this->option('pages')));
if ($worldKey !== null && trim((string) $worldKey) !== '') {
$world = $this->resolveWorld((string) $worldKey);
if (! $world instanceof World) {
$this->error(sprintf('World [%s] was not found.', (string) $worldKey));
return self::FAILURE;
}
return $this->generateOne($generator, $world, $pages, $force, $publish, $dryRun);
}
if (! (bool) $this->option('all')) {
$this->error('Provide a world ID/slug or pass --all for batch generation.');
return self::INVALID;
}
return $this->generateBatch($generator, $pages, $force, $publish, $dryRun, max(1, (int) $this->option('limit')));
}
private function generateOne(WorldWebStoryGenerator $generator, World $world, int $pages, bool $force, bool $publish, bool $dryRun): int
{
try {
$result = $generator->generateFromWorld($world, null, $pages, $force, $publish, $dryRun);
} catch (ValidationException $exception) {
foreach ($exception->errors() as $messages) {
foreach ($messages as $message) {
$this->error((string) $message);
}
}
return self::FAILURE;
}
$story = $result['story'];
$validation = $result['validation'];
$this->info(sprintf(
'%s story for world [%s] -> /web-stories/%s (%d pages)',
$result['created'] ? 'Created' : 'Updated',
(string) $world->slug,
(string) $story->slug,
(int) $validation['page_count'],
));
foreach ((array) $validation['warnings'] as $warning) {
$this->warn(' - ' . $warning);
}
foreach ((array) $validation['errors'] as $error) {
$this->error(' - ' . $error);
}
return $validation['valid'] || ! $publish ? self::SUCCESS : self::FAILURE;
}
private function generateBatch(WorldWebStoryGenerator $generator, int $pages, bool $force, bool $publish, bool $dryRun, int $limit): int
{
$processed = 0;
$created = 0;
$updated = 0;
$failed = 0;
$query = World::query()
->published()
->orderByDesc('published_at')
->orderByDesc('id');
if (! $force) {
$query->whereDoesntHave('webStories');
}
$query->limit($limit)->get()->each(function (World $world) use ($generator, $pages, $force, $publish, $dryRun, &$processed, &$created, &$updated, &$failed): void {
$processed++;
try {
$result = $generator->generateFromWorld($world, null, $pages, $force, $publish, $dryRun);
$result['created'] ? $created++ : $updated++;
$this->line(sprintf('[%d] %s -> %s', (int) $world->id, (string) $world->slug, (string) $result['story']->slug));
} catch (ValidationException $exception) {
$failed++;
$first = collect($exception->errors())->flatten()->first();
$this->error(sprintf('[%d] %s failed: %s', (int) $world->id, (string) $world->slug, (string) $first));
}
});
$this->info(sprintf('Done. processed=%d created=%d updated=%d failed=%d', $processed, $created, $updated, $failed));
return $failed === 0 ? self::SUCCESS : self::FAILURE;
}
private function resolveWorld(string $value): ?World
{
return World::query()
->when(is_numeric($value), fn ($query) => $query->where('id', (int) $value), fn ($query) => $query->where('slug', $value))
->first();
}
}
+50 -2
View File
@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Artwork; use App\Models\Artwork;
use App\Services\Sitemaps\SitemapReleaseManager;
use App\Services\Vision\ArtworkVisionImageUrl; use App\Services\Vision\ArtworkVisionImageUrl;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
@@ -25,10 +27,10 @@ use Throwable;
class HealthCheckCommand extends Command class HealthCheckCommand extends Command
{ {
protected $signature = 'health:check protected $signature = 'health:check
{--only= : Run only a named check (mysql|redis|cache|meilisearch|qdrant|reverb|vision|horizon|webserver|phpfpm|paths|ram|disk|load|s3|failed_jobs|queue_backlog|ssl|scheduler|log_errors|app)} {--only= : Run only a named check (mysql|redis|cache|meilisearch|qdrant|reverb|vision|horizon|webserver|phpfpm|paths|ram|disk|load|s3|failed_jobs|queue_backlog|ssl|scheduler|sitemap|log_errors|app)}
{--json : Output results as JSON}'; {--json : Output results as JSON}';
protected $description = 'Check health of all critical services (MySQL, Redis, Cache, Meilisearch, Qdrant, Reverb, Vision, Horizon, Nginx, PHP-FPM, writable paths, RAM, disk, load, S3/Contabo, failed jobs, queue backlog, SSL, scheduler, log errors, App).'; protected $description = 'Check health of all critical services (MySQL, Redis, Cache, Meilisearch, Qdrant, Reverb, Vision, Horizon, Nginx, PHP-FPM, writable paths, RAM, disk, load, S3/Contabo, failed jobs, queue backlog, SSL, scheduler, sitemap, log errors, App).';
/** Collected results: [name => [status, message, details]] */ /** Collected results: [name => [status, message, details]] */
private array $results = []; private array $results = [];
@@ -57,6 +59,7 @@ class HealthCheckCommand extends Command
'queue_backlog' => fn () => $this->checkQueueBacklog(), 'queue_backlog' => fn () => $this->checkQueueBacklog(),
'ssl' => fn () => $this->checkSsl(), 'ssl' => fn () => $this->checkSsl(),
'scheduler' => fn () => $this->checkScheduler(), 'scheduler' => fn () => $this->checkScheduler(),
'sitemap' => fn () => $this->checkSitemap(),
'log_errors' => fn () => $this->checkLogErrors(), 'log_errors' => fn () => $this->checkLogErrors(),
'app' => fn () => $this->checkApp(), 'app' => fn () => $this->checkApp(),
]; ];
@@ -1041,6 +1044,51 @@ class HealthCheckCommand extends Command
} }
} }
private function checkSitemap(): void
{
try {
$releases = app(SitemapReleaseManager::class)->listReleases();
if ($releases === []) {
$this->failCheck('sitemap', 'No sitemap releases found. Run `php artisan skinbase:sitemaps:publish` to build one.');
return;
}
$latest = $releases[0];
$releaseId = (string) ($latest['release_id'] ?? 'unknown');
$builtAtRaw = (string) ($latest['built_at'] ?? $latest['published_at'] ?? '');
if ($builtAtRaw === '') {
$this->warn_check('sitemap', "Latest sitemap release [{$releaseId}] is missing a build timestamp.", [
'release_id' => $releaseId,
'status' => (string) ($latest['status'] ?? 'unknown'),
]);
return;
}
$builtAt = Carbon::parse($builtAtRaw);
$ageSeconds = max(0, $builtAt->diffInSeconds(now()));
$builtAtLabel = $builtAt->toAtomString();
$details = [
'release_id' => $releaseId,
'built_at' => $builtAtLabel,
'age_seconds' => $ageSeconds,
'status' => (string) ($latest['status'] ?? 'unknown'),
];
$message = "Latest sitemap release [{$releaseId}] built at {$builtAtLabel} ({$ageSeconds}s ago).";
if ($ageSeconds > 72 * 3600) {
$this->failCheck('sitemap', 'Sitemap build is stale — ' . $message, $details);
} elseif ($ageSeconds > 36 * 3600) {
$this->warn_check('sitemap', 'Sitemap build is getting old — ' . $message, $details);
} else {
$this->pass('sitemap', $message, $details);
}
} catch (Throwable $e) {
$this->warn_check('sitemap', 'Could not inspect sitemap releases: ' . $e->getMessage());
}
}
private function checkLogErrors(): void private function checkLogErrors(): void
{ {
$logFile = storage_path('logs/laravel.log'); $logFile = storage_path('logs/laravel.log');
@@ -7,6 +7,7 @@ namespace App\Console\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use App\Services\News\NewsService;
use cPad\Plugins\News\Models\NewsArticle; use cPad\Plugins\News\Models\NewsArticle;
final class PublishScheduledNewsCommand extends Command final class PublishScheduledNewsCommand extends Command
@@ -17,6 +18,11 @@ final class PublishScheduledNewsCommand extends Command
protected $description = 'Publish scheduled News articles whose publish time has passed.'; protected $description = 'Publish scheduled News articles whose publish time has passed.';
public function __construct(private readonly NewsService $news)
{
parent::__construct();
}
public function handle(): int public function handle(): int
{ {
$dryRun = (bool) $this->option('dry-run'); $dryRun = (bool) $this->option('dry-run');
@@ -60,11 +66,7 @@ final class PublishScheduledNewsCommand extends Command
return; return;
} }
$article->forceFill([ $this->news->publish($article);
'editorial_status' => NewsArticle::EDITORIAL_STATUS_PUBLISHED,
'status' => 'published',
'published_at' => $article->published_at ?? $now,
])->save();
$published++; $published++;
$this->line(sprintf('Published News article #%d: "%s"', $article->id, $article->title)); $this->line(sprintf('Published News article #%d: "%s"', $article->id, $article->title));
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\LeaderboardService;
use Illuminate\Console\Command;
class RefreshLeaderboardsCommand extends Command
{
protected $signature = 'leaderboards:refresh';
protected $description = 'Refresh all leaderboard rows and clear leaderboard caches.';
public function __construct(private readonly LeaderboardService $leaderboards)
{
parent::__construct();
}
public function handle(): int
{
$this->info('Refreshing leaderboards …');
$results = $this->leaderboards->refreshAll();
$updated = collect($results)
->flatten(1)
->sum(fn (int $count): int => $count);
$this->info("Done. Updated: {$updated} leaderboard row(s).");
return self::SUCCESS;
}
}
@@ -0,0 +1,502 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Repositories\Uploads\ArtworkFileRepository;
use App\Services\ArtworkOriginalFileLocator;
use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\Uploads\UploadDerivativesService;
use App\Services\Uploads\UploadStorageService;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Throwable;
final class RepairArtworkThumbnailsCommand extends Command
{
private const SOURCE_IMAGE_EXTENSIONS = [
'avif',
'bmp',
'gif',
'jpg',
'jpeg',
'png',
'tif',
'tiff',
'webp',
];
protected $signature = 'artworks:repair-missing-thumbnails
{--id= : Repair only this artwork ID}
{--limit= : Stop after processing this many artworks}
{--chunk=200 : Number of artworks to scan per batch}
{--variant=* : Specific thumbnail variants to repair (defaults to all configured derivatives)}
{--only-missing-flagged : Scan only artworks already marked with has_missing_thumbnails=1}
{--csv= : Optional path to write a CSV report}
{--force : Regenerate the selected variants even when they already exist}
{--dry-run : Report repairs without writing files}';
protected $description = 'Scan artworks from newest to oldest, detect missing CDN thumbnails, and rebuild only the missing derivatives from local source files.';
public function handle(
UploadStorageService $storage,
UploadDerivativesService $derivatives,
ArtworkFileRepository $artworkFiles,
ArtworkOriginalFileLocator $locator,
ArtworkCdnPurgeService $cdnPurge,
): int {
$artworkId = $this->option('id') !== null ? max(1, (int) $this->option('id')) : null;
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$chunkSize = max(1, min((int) $this->option('chunk'), 1000));
$onlyMissingFlagged = (bool) $this->option('only-missing-flagged');
$csvPath = trim((string) $this->option('csv'));
$force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
$allVariants = $this->resolveConfiguredVariants();
$selectedVariants = $this->resolveSelectedVariants($allVariants);
if ($selectedVariants === []) {
return self::FAILURE;
}
$auditColumnsAvailable = Schema::hasColumns('artworks', [
'has_missing_thumbnails',
'missing_thumbnail_variants_json',
'thumbnails_checked_at',
]);
if ($onlyMissingFlagged && ! $auditColumnsAvailable) {
$this->error('The --only-missing-flagged option requires thumbnail audit columns on the artworks table.');
return self::FAILURE;
}
$diskName = $storage->objectDiskName();
$disk = Storage::disk($diskName);
$csvHandle = $this->openCsvHandle($csvPath);
$baseQuery = $this->baseQuery($onlyMissingFlagged);
$totalCandidates = $this->resolveTotalCandidates($baseQuery, $artworkId, $limit);
$progressBar = $totalCandidates > 0 ? $this->output->createProgressBar($totalCandidates) : null;
$this->info(sprintf(
'Starting thumbnail repair. order=id_desc include_trashed=yes disk=%s variants=%s chunk=%d limit=%s flagged_only=%s force=%s dry_run=%s csv=%s',
$diskName,
implode(',', $selectedVariants),
$chunkSize,
$limit !== null ? (string) $limit : 'all',
$onlyMissingFlagged ? 'yes' : 'no',
$force ? 'yes' : 'no',
$dryRun ? 'yes' : 'no',
$csvPath !== '' ? $csvPath : 'off',
));
if ($progressBar !== null) {
$progressBar->start();
}
$processed = 0;
$healthy = 0;
$planned = 0;
$repaired = 0;
$failed = 0;
$lastSeenId = null;
try {
do {
$artworks = $this->nextChunk($baseQuery, $artworkId, $chunkSize, $lastSeenId);
if ($artworks->isEmpty()) {
break;
}
foreach ($artworks as $artwork) {
if ($limit !== null && $processed >= $limit) {
break 2;
}
try {
$targetVariants = $force
? $selectedVariants
: $this->resolveMissingVariants($artwork, $selectedVariants, $storage, $disk);
if ($targetVariants === []) {
$healthy++;
$processed++;
$this->writeCsvRow($csvHandle, [
'artwork_id' => (int) $artwork->id,
'status' => 'healthy',
'variants' => '',
'source_file' => '',
'message' => '',
]);
$progressBar?->advance();
continue;
}
$sourcePath = $this->resolveLocalSourcePath($artwork, $locator);
if ($sourcePath === '') {
throw new \RuntimeException('No local original source file was found in the configured artwork roots.');
}
if ($dryRun) {
$planned++;
$this->line(sprintf(
'Artwork %d would repair thumbnails: %s',
(int) $artwork->id,
implode(',', $targetVariants),
));
$this->line(' source_file: ' . $sourcePath);
$this->writeCsvRow($csvHandle, [
'artwork_id' => (int) $artwork->id,
'status' => 'planned',
'variants' => implode(',', $targetVariants),
'source_file' => $sourcePath,
'message' => '',
]);
$processed++;
$progressBar?->advance();
continue;
}
$assets = $derivatives->generateSelectedPublicDerivatives($sourcePath, (string) $artwork->hash, $targetVariants);
if ($assets === []) {
throw new \RuntimeException('No thumbnail assets were generated for the requested variants.');
}
DB::transaction(function () use ($artwork, $assets, $artworkFiles, $storage, $disk, $allVariants, $auditColumnsAvailable): void {
foreach ($assets as $variant => $asset) {
$artworkFiles->upsert((int) $artwork->id, (string) $variant, $asset['path'], $asset['mime'], $asset['size']);
}
$update = [
'thumb_ext' => 'webp',
];
if ($auditColumnsAvailable) {
$remainingMissing = $this->resolveMissingVariants($artwork, $allVariants, $storage, $disk);
$update['has_missing_thumbnails'] = $remainingMissing !== [];
$update['missing_thumbnail_variants_json'] = $remainingMissing === []
? null
: json_encode(array_values($remainingMissing), JSON_UNESCAPED_SLASHES);
$update['thumbnails_checked_at'] = now();
}
Artwork::query()->withTrashed()->whereKey($artwork->id)->update($update);
});
$cdnPurge->purgeArtworkObjectPaths(array_map(
static fn (array $asset): string => (string) $asset['path'],
array_values($assets),
), [
'artwork_id' => (int) $artwork->id,
'reason' => 'thumbnail_repair',
]);
$repaired++;
$this->info(sprintf(
'Artwork %d repaired thumbnails: %s',
(int) $artwork->id,
implode(',', array_keys($assets)),
));
$this->writeCsvRow($csvHandle, [
'artwork_id' => (int) $artwork->id,
'status' => 'repaired',
'variants' => implode(',', array_keys($assets)),
'source_file' => $sourcePath,
'message' => '',
]);
} catch (Throwable $exception) {
$failed++;
$this->warn(sprintf('Artwork %d repair failed: %s', (int) $artwork->id, $exception->getMessage()));
$this->writeCsvRow($csvHandle, [
'artwork_id' => (int) $artwork->id,
'status' => 'failed',
'variants' => isset($targetVariants) && is_array($targetVariants) ? implode(',', $targetVariants) : '',
'source_file' => isset($sourcePath) ? (string) $sourcePath : '',
'message' => $exception->getMessage(),
]);
}
$processed++;
$progressBar?->advance();
}
$lastSeenId = (int) $artworks->last()->id;
} while (true);
} finally {
if ($progressBar !== null) {
$progressBar->finish();
$this->newLine(2);
}
if (is_resource($csvHandle)) {
fclose($csvHandle);
}
}
$this->info(sprintf(
'Thumbnail repair complete. processed=%d healthy=%d planned=%d repaired=%d failed=%d',
$processed,
$healthy,
$planned,
$repaired,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @return Collection<int, Artwork>
*/
private function nextChunk(mixed $baseQuery, ?int $artworkId, int $chunkSize, ?int $lastSeenId): Collection
{
$query = clone $baseQuery;
if ($artworkId !== null) {
$query->whereKey($artworkId);
} elseif ($lastSeenId !== null) {
$query->where('id', '<', $lastSeenId);
}
return $query->limit($chunkSize)->get();
}
private function baseQuery(bool $onlyMissingFlagged): mixed
{
$query = Artwork::query()
->withTrashed()
->select(['id', 'slug', 'hash', 'file_path', 'file_ext', 'thumb_ext'])
->whereNotNull('hash')
->where('hash', '!=', '')
->orderByDesc('id');
if ($onlyMissingFlagged) {
$query->where('has_missing_thumbnails', true);
}
return $query;
}
private function resolveTotalCandidates(mixed $baseQuery, ?int $artworkId, ?int $limit): int
{
$countQuery = clone $baseQuery;
if ($artworkId !== null) {
$countQuery->whereKey($artworkId);
}
$count = (int) $countQuery->count();
if ($limit !== null) {
return min($count, $limit);
}
return $count;
}
/**
* @return list<string>
*/
private function resolveConfiguredVariants(): array
{
return array_values(array_filter(array_map(
static fn ($variant): string => strtolower(trim((string) $variant)),
array_keys((array) config('uploads.derivatives', [])),
)));
}
/**
* @param list<string> $configuredVariants
* @return list<string>
*/
private function resolveSelectedVariants(array $configuredVariants): array
{
if ($configuredVariants === []) {
$this->error('No thumbnail variants are configured. Check uploads.derivatives.');
return [];
}
$requested = (array) $this->option('variant');
if ($requested === []) {
return $configuredVariants;
}
$normalizedRequested = array_values(array_unique(array_filter(array_map(
static fn ($variant): string => strtolower(trim((string) $variant)),
$requested,
))));
$invalid = array_values(array_diff($normalizedRequested, $configuredVariants));
if ($invalid !== []) {
$this->error('Unknown thumbnail variants: ' . implode(', ', $invalid));
$this->line('Configured variants: ' . implode(', ', $configuredVariants));
return [];
}
return $normalizedRequested;
}
/**
* @param list<string> $variants
* @return list<string>
*/
private function resolveMissingVariants(Artwork $artwork, array $variants, UploadStorageService $storage, mixed $disk): array
{
$hash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($artwork->hash ?? '')));
if ($hash === '') {
return $variants;
}
$missing = [];
foreach ($variants as $variant) {
$objectPath = $storage->objectPathForVariant($variant, $hash, $hash . '.webp');
if (! $disk->exists($objectPath)) {
$missing[] = $variant;
}
}
return $missing;
}
private function resolveLocalSourcePath(Artwork $artwork, ArtworkOriginalFileLocator $locator): string
{
$hash = strtolower((string) ($artwork->hash ?? ''));
if (! $this->isValidHash($hash)) {
return '';
}
$preferred = $locator->resolveLocalPath($artwork);
if ($this->isUsableSourceFile($preferred)) {
return $preferred;
}
foreach ($this->candidateOriginalRoots() as $root) {
$candidatePath = $this->findNonZipSourceInRoot($root, $hash);
if ($candidatePath !== '') {
return $candidatePath;
}
}
return '';
}
/**
* @return list<string>
*/
private function candidateOriginalRoots(): array
{
$roots = [
trim((string) config('uploads.local_originals_root', '')),
trim((string) config('uploads.readonly_backup_originals_root', '')),
];
$normalizedRoots = [];
foreach ($roots as $root) {
if ($root === '') {
continue;
}
$normalizedRoot = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root), DIRECTORY_SEPARATOR);
if ($normalizedRoot === '' || in_array($normalizedRoot, $normalizedRoots, true)) {
continue;
}
$normalizedRoots[] = $normalizedRoot;
}
return $normalizedRoots;
}
private function findNonZipSourceInRoot(string $root, string $hash): string
{
$directory = $root
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
. DIRECTORY_SEPARATOR . substr($hash, 2, 2);
if (! File::isDirectory($directory)) {
return '';
}
$matches = File::glob($directory . DIRECTORY_SEPARATOR . $hash . '.*');
if (! is_array($matches)) {
return '';
}
foreach ($matches as $path) {
if ($this->isUsableSourceFile($path)) {
return $path;
}
}
return '';
}
private function isUsableSourceFile(string $path): bool
{
if ($path === '' || ! File::isFile($path)) {
return false;
}
$extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
if ($extension === '' || ! in_array($extension, self::SOURCE_IMAGE_EXTENSIONS, true)) {
return false;
}
$mime = strtolower((string) (File::mimeType($path) ?? ''));
return str_starts_with($mime, 'image/');
}
private function isValidHash(string $hash): bool
{
return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1;
}
/**
* @return resource|null
*/
private function openCsvHandle(string $csvPath)
{
if ($csvPath === '') {
return null;
}
File::ensureDirectoryExists(dirname($csvPath));
$handle = fopen($csvPath, 'wb');
if (! is_resource($handle)) {
throw new \RuntimeException('Unable to open CSV output path for writing: ' . $csvPath);
}
fputcsv($handle, ['artwork_id', 'status', 'variants', 'source_file', 'message']);
return $handle;
}
/**
* @param resource|null $csvHandle
* @param array<string, scalar|null> $row
*/
private function writeCsvRow($csvHandle, array $row): void
{
if (! is_resource($csvHandle)) {
return;
}
fputcsv($csvHandle, [
$row['artwork_id'] ?? '',
$row['status'] ?? '',
$row['variants'] ?? '',
$row['source_file'] ?? '',
$row['message'] ?? '',
]);
}
}
@@ -0,0 +1,162 @@
<?php
namespace App\Console\Commands;
use App\Jobs\SendVerificationEmailJob;
use App\Mail\RegistrationVerificationMail;
use App\Models\EmailSendEvent;
use App\Models\User;
use App\Services\Auth\RegistrationEmailQuotaService;
use App\Services\Auth\RegistrationVerificationTokenService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
class SendUserVerificationEmailCommand extends Command
{
protected $signature = 'user:send-verification-email
{userId : The user ID that should receive the verification email}
{--now : Send immediately instead of queueing the existing verification job}
{--force : Allow sending even if the user is already verified}';
protected $description = 'Send the registration verification email to a specific user ID.';
public function __construct(
private readonly RegistrationVerificationTokenService $tokenService,
private readonly RegistrationEmailQuotaService $quotaService,
) {
parent::__construct();
}
public function handle(): int
{
$userId = (int) $this->argument('userId');
if ($userId < 1) {
$this->error('The user ID must be a positive integer.');
return self::FAILURE;
}
$user = User::query()->find($userId);
if (! $user) {
$this->error("User {$userId} was not found.");
return self::FAILURE;
}
$email = strtolower(trim((string) $user->email));
if ($email === '') {
$this->error("User {$userId} does not have an email address.");
return self::FAILURE;
}
if ($user->email_verified_at !== null && ! $this->option('force')) {
$this->error("User {$userId} already has a verified email address. Use --force to send anyway.");
return self::FAILURE;
}
$token = $this->tokenService->createForUser($userId);
$event = EmailSendEvent::query()->create([
'type' => 'verify_email',
'email' => $email,
'ip' => null,
'user_id' => $userId,
'status' => $this->option('now') ? 'pending' : 'queued',
'reason' => null,
'created_at' => now(),
]);
if ($this->option('now')) {
return $this->sendNow($user, $event, $token);
}
SendVerificationEmailJob::dispatch(
emailEventId: (int) $event->id,
email: $email,
token: $token,
userId: $userId,
ip: null,
);
$this->markVerificationEmailSent($user);
$this->info("Queued verification email for user {$userId} <{$email}>.");
return self::SUCCESS;
}
private function sendNow(User $user, EmailSendEvent $event, string $token): int
{
if (! $this->acquireGlobalSendSlot()) {
$this->updateEvent($event, 'blocked', 'rate_limited');
$this->error('The global verification email rate limit is currently exhausted. Try again in a minute.');
return self::FAILURE;
}
if ($this->quotaService->isExceeded()) {
$this->updateEvent($event, 'blocked', 'quota');
$this->error('The monthly registration email quota is exceeded.');
return self::FAILURE;
}
try {
Mail::to($user->email)->send(new RegistrationVerificationMail($token));
} catch (\Throwable $exception) {
$this->updateEvent($event, 'failed', 'send_error');
$this->error('Failed to send the verification email: ' . $exception->getMessage());
return self::FAILURE;
}
$this->quotaService->incrementSentCount();
$this->updateEvent($event, 'sent', null);
$this->markVerificationEmailSent($user);
$email = strtolower(trim((string) $user->email));
$this->info("Sent verification email to user {$user->id} <{$email}>.");
return self::SUCCESS;
}
private function acquireGlobalSendSlot(): bool
{
$key = 'registration:verification-email:global';
$maxPerMinute = max(1, (int) config('registration.email_global_send_per_minute', 30));
return RateLimiter::attempt($key, $maxPerMinute, static fn () => true, 60);
}
private function updateEvent(EmailSendEvent $event, string $status, ?string $reason): void
{
EmailSendEvent::query()
->whereKey($event->getKey())
->update([
'status' => $status,
'reason' => $reason,
]);
}
private function markVerificationEmailSent(User $user): void
{
$now = now();
$windowStartedAt = $user->verification_send_window_started_at;
if (! $windowStartedAt || $windowStartedAt->lt($now->copy()->subDay())) {
$user->verification_send_window_started_at = $now;
$user->verification_send_count_24h = 1;
} else {
$user->verification_send_count_24h = ((int) $user->verification_send_count_24h) + 1;
}
$user->last_verification_sent_at = $now;
$user->save();
}
}
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\WorldWebStory;
use App\Services\WebStories\WorldWebStoryValidationService;
use Illuminate\Console\Command;
use Symfony\Component\Process\Process;
final class ValidateWorldWebStoriesCommand extends Command
{
protected $signature = 'skinbase:webstories:validate
{story? : Story ID or slug}
{--published : Limit batch mode to published stories}
{--visible : Limit batch mode to publicly visible stories}
{--limit=100 : Maximum stories to validate in batch mode}
{--amp : Also run amphtml-validator against the public story URL}
{--fail-warnings : Treat validation warnings as failures}';
protected $description = 'Validate World Web Stories for publish safety and optional AMP validity';
public function handle(WorldWebStoryValidationService $validation): int
{
$storyKey = $this->argument('story');
if ($storyKey !== null && trim((string) $storyKey) !== '') {
$story = $this->resolveStory((string) $storyKey);
if (! $story instanceof WorldWebStory) {
$this->error(sprintf('Web story [%s] was not found.', (string) $storyKey));
return self::FAILURE;
}
return $this->validateOne($validation, $story);
}
return $this->validateBatch($validation, max(1, (int) $this->option('limit')));
}
private function validateOne(WorldWebStoryValidationService $validation, WorldWebStory $story): int
{
$result = $validation->validate($story);
$ampErrors = $this->ampErrors($story);
$this->line(sprintf('Story [%d] %s', (int) $story->id, (string) $story->slug));
foreach ((array) $result['warnings'] as $warning) {
$this->warn(' - ' . $warning);
}
foreach ((array) $result['errors'] as $error) {
$this->error(' - ' . $error);
}
foreach ($ampErrors as $ampError) {
$this->error(' - AMP: ' . $ampError);
}
if ($result['valid'] && $ampErrors === []) {
$this->info('Validation passed.');
return self::SUCCESS;
}
return self::FAILURE;
}
private function validateBatch(WorldWebStoryValidationService $validation, int $limit): int
{
$processed = 0;
$failed = 0;
$this->storyQuery()
->limit($limit)
->get()
->each(function (WorldWebStory $story) use ($validation, &$processed, &$failed): void {
$processed++;
$result = $validation->validate($story);
$ampErrors = $this->ampErrors($story);
$warningsFail = (bool) $this->option('fail-warnings') && count((array) $result['warnings']) > 0;
$hasFailure = ! $result['valid'] || $warningsFail || $ampErrors !== [];
if ($hasFailure) {
$failed++;
}
$this->line(sprintf('[%d] %s -> %s', (int) $story->id, (string) $story->slug, $hasFailure ? 'invalid' : 'valid'));
foreach ((array) $result['warnings'] as $warning) {
$this->warn(' - ' . $warning);
}
foreach ((array) $result['errors'] as $error) {
$this->error(' - ' . $error);
}
foreach ($ampErrors as $ampError) {
$this->error(' - AMP: ' . $ampError);
}
});
$this->info(sprintf('Done. processed=%d failed=%d', $processed, $failed));
return $failed === 0 ? self::SUCCESS : self::FAILURE;
}
private function storyQuery()
{
return WorldWebStory::query()
->when((bool) $this->option('published'), fn ($query) => $query->published())
->when((bool) $this->option('visible'), fn ($query) => $query->visible())
->orderByDesc('published_at')
->orderByDesc('id');
}
/**
* @return list<string>
*/
private function ampErrors(WorldWebStory $story): array
{
if (! (bool) $this->option('amp')) {
return [];
}
if (! $story->exists || ! $story->publicUrl()) {
return ['Story has no public URL to validate.'];
}
$probe = new Process(['npx', 'amphtml-validator', '--version'], base_path(), null, null, 60);
$probe->run();
if (! $probe->isSuccessful()) {
return ['amphtml-validator is not available via npx.'];
}
$process = new Process(['npx', 'amphtml-validator', $story->publicUrl()], base_path(), null, null, 120);
$process->run();
if ($process->isSuccessful()) {
return [];
}
$output = trim($process->getErrorOutput() ?: $process->getOutput());
if ($output === '') {
return ['AMP validator failed without output.'];
}
$lines = preg_split('/\r\n|\r|\n/', $output);
return $lines === false || $lines === [] ? ['AMP validator failed.'] : $lines;
}
private function resolveStory(string $value): ?WorldWebStory
{
return WorldWebStory::query()
->when(is_numeric($value), fn ($query) => $query->where('id', (int) $value), fn ($query) => $query->where('slug', $value))
->first();
}
}
+8
View File
@@ -11,6 +11,7 @@ use App\Console\Commands\BackfillArtworkVectorIndexCommand;
use App\Console\Commands\IndexArtworkVectorsCommand; use App\Console\Commands\IndexArtworkVectorsCommand;
use App\Console\Commands\SearchArtworkVectorsCommand; use App\Console\Commands\SearchArtworkVectorsCommand;
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand; use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
use App\Console\Commands\AcademyCoursesSyncFoundationsCommand;
use App\Console\Commands\AggregateFeedAnalyticsCommand; use App\Console\Commands\AggregateFeedAnalyticsCommand;
use App\Console\Commands\AggregateTagInteractionAnalyticsCommand; use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
use App\Console\Commands\SeedTagInteractionDemoCommand; use App\Console\Commands\SeedTagInteractionDemoCommand;
@@ -30,10 +31,13 @@ use App\Console\Commands\PublishScheduledArtworksCommand;
use App\Console\Commands\PublishScheduledNewsCommand; use App\Console\Commands\PublishScheduledNewsCommand;
use App\Console\Commands\PublishScheduledNovaCardsCommand; use App\Console\Commands\PublishScheduledNovaCardsCommand;
use App\Console\Commands\BuildSitemapsCommand; use App\Console\Commands\BuildSitemapsCommand;
use App\Console\Commands\BuildWorldWebStoryAssetsCommand;
use App\Console\Commands\ListSitemapReleasesCommand; use App\Console\Commands\ListSitemapReleasesCommand;
use App\Console\Commands\GenerateWorldWebStoriesCommand;
use App\Console\Commands\PublishSitemapsCommand; use App\Console\Commands\PublishSitemapsCommand;
use App\Console\Commands\RollbackSitemapReleaseCommand; use App\Console\Commands\RollbackSitemapReleaseCommand;
use App\Console\Commands\SyncCollectionLifecycleCommand; use App\Console\Commands\SyncCollectionLifecycleCommand;
use App\Console\Commands\ValidateWorldWebStoriesCommand;
use App\Console\Commands\ValidateSitemapsCommand; use App\Console\Commands\ValidateSitemapsCommand;
use App\Console\Commands\AuditArtworkDownloadFilesCommand; use App\Console\Commands\AuditArtworkDownloadFilesCommand;
use App\Console\Commands\InspectArtworkOriginalCommand; use App\Console\Commands\InspectArtworkOriginalCommand;
@@ -57,6 +61,9 @@ class Kernel extends ConsoleKernel
\App\Console\Commands\ResetAllUserPasswords::class, \App\Console\Commands\ResetAllUserPasswords::class,
CleanupUploadsCommand::class, CleanupUploadsCommand::class,
BuildSitemapsCommand::class, BuildSitemapsCommand::class,
GenerateWorldWebStoriesCommand::class,
BuildWorldWebStoryAssetsCommand::class,
ValidateWorldWebStoriesCommand::class,
PublishSitemapsCommand::class, PublishSitemapsCommand::class,
ListSitemapReleasesCommand::class, ListSitemapReleasesCommand::class,
RollbackSitemapReleaseCommand::class, RollbackSitemapReleaseCommand::class,
@@ -71,6 +78,7 @@ class Kernel extends ConsoleKernel
ZipUnsupportedArtworkOriginalsCommand::class, ZipUnsupportedArtworkOriginalsCommand::class,
SendTestMail::class, SendTestMail::class,
DispatchCollectionMaintenanceCommand::class, DispatchCollectionMaintenanceCommand::class,
AcademyCoursesSyncFoundationsCommand::class,
BackfillArtworkEmbeddingsCommand::class, BackfillArtworkEmbeddingsCommand::class,
BackfillArtworkVectorIndexCommand::class, BackfillArtworkVectorIndexCommand::class,
IndexArtworkVectorsCommand::class, IndexArtworkVectorsCommand::class,
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Services\Academy\AcademyAnalyticsContentResolver;
use App\Services\Academy\AcademyAnalyticsService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\AcademyAnalytics\AcademyAnalyticsEventType;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
final class AcademyAnalyticsEventController extends Controller
{
public function __construct(
private readonly AcademyAnalyticsService $analytics,
private readonly AcademyAnalyticsContentResolver $resolver,
) {
}
public function store(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless($request->expectsJson() || $request->isJson(), 422);
$validated = $request->validate([
'event_type' => ['required', 'string', Rule::in(AcademyAnalyticsEventType::values())],
'content_type' => ['nullable', 'string', Rule::in(AcademyAnalyticsContentType::values())],
'content_id' => ['nullable', 'integer', 'min:1'],
'metadata' => ['nullable', 'array'],
'visitor_id' => ['nullable', 'string', 'max:120'],
'session_id' => ['nullable', 'string', 'max:120'],
'url' => ['nullable', 'string', 'max:4000'],
'route_name' => ['nullable', 'string', 'max:255'],
'referrer' => ['nullable', 'string', 'max:4000'],
'utm_source' => ['nullable', 'string', 'max:255'],
'utm_medium' => ['nullable', 'string', 'max:255'],
'utm_campaign' => ['nullable', 'string', 'max:255'],
]);
if (isset($validated['metadata']) && strlen((string) json_encode($validated['metadata'])) > 8192) {
return response()->json([
'message' => 'Metadata payload is too large.',
], 422);
}
$contentType = $validated['content_type'] ?? null;
$contentId = $validated['content_id'] ?? null;
if ($contentType !== null && AcademyAnalyticsContentType::requiresContentId($contentType) && $contentId === null) {
return response()->json([
'message' => 'content_id is required for this content type.',
], 422);
}
if ($contentType !== null && $contentId !== null && ! $this->resolver->exists($contentType, (int) $contentId)) {
return response()->json([
'message' => 'Unknown Academy analytics content target.',
], 422);
}
if (($validated['event_type'] ?? null) === AcademyAnalyticsEventType::SEARCH_RESULT_CLICK) {
validator([
'content_type' => $contentType,
'content_id' => $contentId,
'metadata' => $validated['metadata'] ?? [],
], [
'content_type' => ['required', 'string', Rule::in([
AcademyAnalyticsContentType::PROMPT,
AcademyAnalyticsContentType::LESSON,
AcademyAnalyticsContentType::COURSE,
AcademyAnalyticsContentType::PROMPT_PACK,
AcademyAnalyticsContentType::CHALLENGE,
])],
'content_id' => ['required', 'integer', 'min:1'],
'metadata.query' => ['required', 'string', 'max:120'],
'metadata.normalized_query' => ['required', 'string', 'max:120'],
'metadata.results_count' => ['required', 'integer', 'min:0'],
'metadata.position' => ['nullable', 'integer', 'min:1'],
'metadata.source' => ['nullable', 'string', 'max:120'],
'metadata.filters' => ['nullable', 'array'],
])->validate();
}
$this->analytics->track($validated, $request->user(), $request);
return response()->json([
'ok' => true,
]);
}
}
@@ -0,0 +1,714 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyBillingPlanService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Laravel\Cashier\Checkout;
use Laravel\Cashier\Subscription;
use Stripe\Exception\InvalidRequestException;
use Illuminate\Support\Facades\Log;
use App\Mail\AcademyAccessIssue;
use App\Models\StaffApplication;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
final class AcademyBillingController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyBillingPlanService $plans,
) {}
public function pricing(\Illuminate\Http\Request $request): \Inertia\Response
{
\abort_unless((bool) \config('academy.enabled', true), 404);
$this->plans->assertConfigured();
/** @var User|null $user */
$user = $request->user();
$canonical = \route('academy.pricing');
$seo = \app(SeoFactory::class)
->collectionPage(
'Skinbase AI Academy Pricing — Skinbase',
'Compare Skinbase AI Academy Creator and Pro tiers, start free, and manage premium access through Stripe billing.',
$canonical,
)
->toArray();
$seo['og_type'] = 'website';
$activePlan = $user instanceof User ? $this->activePlan($user) : null;
return \Inertia\Inertia::render('Academy/Billing/Pricing', [
'seo' => $seo,
'billingEnabled' => $this->plans->enabled(),
'currentTier' => $this->access->currentTier($user),
'isSubscribed' => $user instanceof User ? $this->access->hasActiveAcademySubscription($user) : false,
'activePlanKey' => $activePlan['key'] ?? null,
'activePlanLabel' => $activePlan['label'] ?? null,
'catalog' => $this->catalog(),
'missingRemote' => $this->plans->missingRemotePriceIds(),
'links' => [
'login' => \route('login'),
'pricing' => \route('academy.pricing'),
'billingAccount' => $user ? \route('academy.billing.account') : null,
'checkout' => $user ? \route('academy.billing.checkout') : null,
],
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::UPGRADE,
'contentId' => null,
'eventUrl' => \route('academy.analytics.events.store'),
'pageName' => 'academy_billing_pricing',
'isPremium' => false,
'isGuest' => $user === null,
'isSubscriber' => $user?->hasAcademyCreatorAccess() || $user?->hasAcademyProAccess(),
],
])->rootView('academy');
}
public function checkout(\Illuminate\Http\Request $request): Checkout|\Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
\abort_unless((bool) \config('academy.enabled', true), 404);
if (! $this->plans->enabled()) {
return $this->billingDisabledResponse($request, 'Academy billing is not enabled yet.');
}
$this->plans->assertConfigured();
$user = $request->user();
if (! $user instanceof User) {
return \redirect()->route('login');
}
if ($user->email_verified_at === null) {
throw \Illuminate\Validation\ValidationException::withMessages([
'plan' => 'Verify your email address before starting Academy billing.',
]);
}
$validated = $request->validate([
'plan' => ['required', 'string'],
]);
$plan = $this->plans->plan((string) $validated['plan']);
if ($plan === null) {
throw \Illuminate\Validation\ValidationException::withMessages([
'plan' => 'Select a valid Academy billing plan.',
]);
}
if (! ($plan['configured'] ?? false)) {
return $this->missingPriceIdResponse($request, (string) $plan['key']);
}
if (! ($plan['price_id_valid'] ?? false)) {
return $this->invalidPriceIdResponse($request, (string) $plan['key']);
}
if ($this->access->hasActiveAcademySubscription($user)) {
// If the user already has an Academy subscription, allow an in-place upgrade
// (e.g. Creator -> Pro) by swapping the subscription to the requested price.
$subscription = $this->academySubscription($user);
$currentPlan = $this->activePlan($user);
// If current plan exists and the requested plan ranks higher, perform swap.
if ($currentPlan !== null && ($this->planRank((string) $plan['tier']) > $this->planRank((string) $currentPlan['tier']))) {
try {
if ($subscription instanceof Subscription) {
$subscription->swap((string) $plan['stripe_price_id']);
}
return \redirect()->route('academy.billing.account')->with('success', 'Subscription upgraded — your new plan is active.');
} catch (\Throwable $e) {
$context = [
'user_id' => $user->id ?? null,
'user_email' => $user->email ?? null,
'stripe_id' => $user->stripe_id ?? null,
'route' => 'academy.billing.checkout',
'attempt' => 'swap_subscription',
'plan_key' => $plan['key'] ?? null,
'plan_price_id' => $plan['stripe_price_id'] ?? null,
'request_ip' => $request->ip(),
'user_agent' => $request->header('User-Agent'),
'exception_class' => \get_class($e),
'exception_message' => $e->getMessage(),
'exception_code' => $e->getCode(),
'exception_trace' => \method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
];
if (method_exists($e, 'getStripeCode')) {
$context['stripe_code'] = $e->getStripeCode();
}
Log::error('Academy billing: failed to swap subscription for upgrade', $context);
return $this->checkoutErrorResponse($request, $e);
}
}
return \redirect()->route('academy.billing.portal');
}
try {
return $user
->newSubscription($this->plans->subscriptionName(), (string) $plan['stripe_price_id'])
->withMetadata([
'skinbase_module' => 'academy',
'user_id' => (string) $user->id,
'academy_plan' => (string) $plan['key'],
'academy_tier' => (string) $plan['tier'],
])
->checkout([
'success_url' => \route('academy.billing.success').'?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => \route('academy.billing.cancel'),
'allow_promotion_codes' => true,
'metadata' => [
'skinbase_module' => 'academy',
'user_id' => (string) $user->id,
'academy_plan' => (string) $plan['key'],
'academy_tier' => (string) $plan['tier'],
],
]);
} catch (InvalidRequestException $e) {
// Stripe returned a request error (e.g. missing/deleted customer). Try to recover once by
// clearing stored `stripe_id`, recreating the customer in Stripe and retrying the checkout.
if (str_contains($e->getMessage(), 'No such customer')) {
try {
$user->forceFill(['stripe_id' => null])->save();
// Create a fresh Stripe customer and persist the id
if (method_exists($user, 'createAsStripeCustomer')) {
$user->createAsStripeCustomer();
} else {
// fallback to createOrGet behavior
$user->createOrGetStripeCustomer();
}
return $user
->newSubscription($this->plans->subscriptionName(), (string) $plan['stripe_price_id'])
->withMetadata([
'skinbase_module' => 'academy',
'user_id' => (string) $user->id,
'academy_plan' => (string) $plan['key'],
'academy_tier' => (string) $plan['tier'],
])
->checkout([
'success_url' => \route('academy.billing.success').'?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => \route('academy.billing.cancel'),
'allow_promotion_codes' => true,
'metadata' => [
'skinbase_module' => 'academy',
'user_id' => (string) $user->id,
'academy_plan' => (string) $plan['key'],
'academy_tier' => (string) $plan['tier'],
],
]);
} catch (\Throwable $inner) {
$context = [
'user_id' => $user->id ?? null,
'user_email' => $user->email ?? null,
'stripe_id' => $user->stripe_id ?? null,
'route' => 'academy.billing.checkout',
'attempt' => 'recreate_customer_and_checkout',
'plan_key' => $plan['key'] ?? null,
'plan_price_id' => $plan['stripe_price_id'] ?? null,
'request_ip' => $request->ip(),
'user_agent' => $request->header('User-Agent'),
'exception_class' => \get_class($inner),
'exception_message' => $inner->getMessage(),
'exception_code' => $inner->getCode(),
'exception_trace' => \method_exists($inner, 'getTraceAsString') ? $inner->getTraceAsString() : null,
];
if (method_exists($inner, 'getStripeCode')) {
$context['stripe_code'] = $inner->getStripeCode();
}
Log::error('Academy billing: failed to recover Stripe customer and start checkout', $context);
return $this->checkoutErrorResponse($request, $inner);
}
}
// Not a recoverable customer-missing error; rethrow to be handled below
throw $e;
} catch (\Throwable $exception) {
$context = [
'user_id' => $user->id ?? null,
'user_email' => $user->email ?? null,
'stripe_id' => $user->stripe_id ?? null,
'route' => 'academy.billing.checkout',
'attempt' => 'start_checkout',
'plan_key' => $plan['key'] ?? null,
'plan_price_id' => $plan['stripe_price_id'] ?? null,
'request_ip' => $request->ip(),
'user_agent' => $request->header('User-Agent'),
'exception_class' => \get_class($exception),
'exception_message' => $exception->getMessage(),
'exception_code' => $exception->getCode(),
'exception_trace' => \method_exists($exception, 'getTraceAsString') ? $exception->getTraceAsString() : null,
];
if (method_exists($exception, 'getStripeCode')) {
$context['stripe_code'] = $exception->getStripeCode();
}
Log::error('Academy billing: unexpected error starting checkout', $context);
return $this->checkoutErrorResponse($request, $exception);
}
}
public function checkoutLegacy(\Illuminate\Http\Request $request, string $plan): Checkout|\Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
$request->merge([
'plan' => $this->plans->normalizePlanKey($plan),
]);
return $this->checkout($request);
}
public function portal(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
{
\abort_unless((bool) \config('academy.enabled', true), 404);
\abort_unless($this->plans->enabled(), 404);
/** @var User|null $user */
$user = $request->user();
if (! $user instanceof User || \blank($user->stripe_id)) {
return \redirect()->route('academy.billing.account')->with('error', 'No Stripe billing profile is connected to this account yet.');
}
try {
return $user->redirectToBillingPortal(\route('academy.billing.account'));
} catch (\Exception $e) {
// If the Stripe customer was deleted or invalid, attempt a recovery similar to checkout.
if ($e instanceof \Stripe\Exception\InvalidRequestException && str_contains($e->getMessage(), 'No such customer')) {
try {
$user->forceFill(['stripe_id' => null])->save();
if (method_exists($user, 'createAsStripeCustomer')) {
$user->createAsStripeCustomer();
} else {
$user->createOrGetStripeCustomer();
}
return $user->redirectToBillingPortal(\route('academy.billing.account'));
} catch (\Throwable $inner) {
$context = [
'user_id' => $user->id ?? null,
'user_email' => $user->email ?? null,
'stripe_id' => $user->stripe_id ?? null,
'route' => 'academy.billing.portal',
'attempt' => 'recreate_customer_and_redirect',
'request_ip' => $request->ip(),
'user_agent' => $request->header('User-Agent'),
'exception_class' => \get_class($inner),
'exception_message' => $inner->getMessage(),
'exception_code' => $inner->getCode(),
'exception_trace' => \method_exists($inner, 'getTraceAsString') ? $inner->getTraceAsString() : null,
];
if (method_exists($inner, 'getStripeCode')) {
$context['stripe_code'] = $inner->getStripeCode();
}
Log::error('Academy billing: failed to recover Stripe customer and open billing portal', $context);
return \redirect()->route('academy.billing.account')->with('error', 'Could not open the subscription manager. Please email academy@skinbase.org with your account details and checkout session id if available.');
}
}
$context = [
'user_id' => $user->id ?? null,
'user_email' => $user->email ?? null,
'stripe_id' => $user->stripe_id ?? null,
'route' => 'academy.billing.portal',
'attempt' => 'redirect_to_portal',
'request_ip' => $request->ip(),
'user_agent' => $request->header('User-Agent'),
'exception_class' => \get_class($e),
'exception_message' => $e->getMessage(),
'exception_code' => $e->getCode(),
'exception_trace' => \method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
];
if (method_exists($e, 'getStripeCode')) {
$context['stripe_code'] = $e->getStripeCode();
}
Log::error('Academy billing: could not open Stripe billing portal', $context);
return \redirect()->route('academy.billing.account')->with('error', 'Could not open the subscription manager. Please email academy@skinbase.org with your account details and checkout session id if available.');
}
}
public function success(\Illuminate\Http\Request $request): \Inertia\Response
{
\abort_unless((bool) \config('academy.enabled', true), 404);
/** @var User|null $user */
$user = $request->user();
$currentTier = $this->access->currentTier($user);
return \Inertia\Inertia::render('Academy/Billing/Success', [
'message' => 'Payment is being confirmed. Your access will update automatically.',
'currentTier' => $currentTier,
'isSubscribed' => $user instanceof User ? $this->access->hasActiveAcademySubscription($user) : false,
'links' => [
'pricing' => \route('academy.pricing'),
'account' => $user ? \route('academy.billing.account') : null,
'academy' => \route('academy.index'),
'reportIssue' => $user ? \route('academy.billing.report_issue') : null,
],
'sessionId' => $request->query('session_id'),
])->rootView('academy');
}
public function cancel(): \Inertia\Response
{
\abort_unless((bool) \config('academy.enabled', true), 404);
return \Inertia\Inertia::render('Academy/Billing/Cancel', [
'message' => 'Checkout was canceled. No payment was made.',
'links' => [
'pricing' => \route('academy.pricing'),
'academy' => \route('academy.index'),
],
])->rootView('academy');
}
public function reportIssue(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
{
/** @var User|null $user */
$user = $request->user();
if (! $user instanceof User) {
return redirect()->route('login');
}
$validated = $request->validate([
'message' => ['nullable', 'string', 'max:2000'],
'session_id' => ['nullable', 'string'],
'issue_type' => ['nullable', 'string', 'in:billing,payment,upgrade,downgrade,cancel,access,other'],
'contact_email' => ['nullable', 'email:rfc', 'max:255'],
]);
$payload = [
'id' => (string) Str::uuid(),
'submitted_at' => now()->toISOString(),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'data' => [
'topic' => 'contact',
'name' => (string) ($user->name ?: $user->username ?: 'Academy billing user'),
'email' => (string) ($validated['contact_email'] ?? $user->email),
'message' => $validated['message'] ?? null,
'issue_type' => $validated['issue_type'] ?? 'billing',
'session_id' => $validated['session_id'] ?? $request->query('session_id'),
'source' => 'academy_billing',
'user_id' => (string) $user->id,
'account_email' => (string) $user->email,
'current_url' => $request->fullUrl(),
],
];
try {
try {
Storage::append('staff_applications.jsonl', json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
} catch (\Throwable $e) {
// best-effort store; do not fail the user when file storage is unavailable
}
$application = null;
try {
$application = StaffApplication::create([
'id' => $payload['id'],
'topic' => 'contact',
'name' => $payload['data']['name'],
'email' => $payload['data']['email'],
'role' => 'academy_billing_support',
'portfolio' => null,
'message' => $payload['data']['message'],
'payload' => $payload,
'ip' => $payload['ip'],
'user_agent' => $payload['user_agent'],
]);
} catch (\Throwable $e) {
// ignore DB errors and fall back to a lightweight model for mail
}
$to = config('mail.from.address');
if ($to) {
if (! $application) {
$application = new StaffApplication([
'topic' => 'contact',
'name' => $payload['data']['name'],
'email' => $payload['data']['email'],
'role' => 'academy_billing_support',
'message' => $payload['data']['message'],
'payload' => $payload,
'ip' => $payload['ip'],
'user_agent' => $payload['user_agent'],
]);
$application->id = $payload['id'];
$application->created_at = now();
}
Mail::to($to)->send(new AcademyAccessIssue(
$user,
$payload['data']['message'] ?? null,
$payload['data']['session_id'] ?? null,
$payload['data']['issue_type'] ?? null,
$payload['data']['email'] ?? null,
));
}
return redirect()->back()->with('success', 'Support request sent — we will verify and activate your access shortly.');
} catch (\Throwable $e) {
report($e);
return redirect()->back()->with('error', 'Could not send the support request. Please try again later or email academy@skinbase.org.');
}
}
public function account(\Illuminate\Http\Request $request): \Inertia\Response
{
\abort_unless((bool) \config('academy.enabled', true), 404);
\abort_unless($this->plans->enabled(), 404);
/** @var User $user */
$user = $request->user();
$subscription = $this->academySubscription($user);
$activePlan = $this->activePlan($user);
return \Inertia\Inertia::render('Academy/Billing/Account', [
'currentTier' => $this->access->currentTier($user),
'isSubscribed' => $this->access->hasActiveAcademySubscription($user),
'activePlan' => $activePlan ? [
'key' => $activePlan['key'],
'label' => $activePlan['label'],
'price_display' => $activePlan['price_display'] ?? null,
'tier' => $activePlan['tier'],
] : null,
'subscription' => $subscription ? [
'name' => $subscription->type,
'status' => $subscription->stripe_status,
'active' => $subscription->active(),
'onGracePeriod' => $subscription->onGracePeriod(),
'endsAt' => $subscription->ends_at?->toISOString(),
'priceIds' => $subscription->items->pluck('stripe_price')->filter()->values()->all(),
] : null,
'links' => [
'portal' => \route('academy.billing.portal'),
'pricing' => \route('academy.pricing'),
'academy' => \route('academy.index'),
'checkout' => \route('academy.billing.checkout'),
'reportIssue' => \route('academy.billing.report_issue'),
],
])->rootView('academy');
}
/**
* @return array<int, array<string, mixed>>
*/
private function catalog(): array
{
$definitions = [
'creator' => [
'name' => 'Creator',
'description' => 'Entry premium access for prompt systems, creator lessons, and saved Academy workflows.',
'badge' => 'Paid',
'featured' => false,
'features' => [
'Creator lessons and walkthroughs',
'Full Creator prompt templates',
'Prompt save and reuse flows',
'Upgrade path into Pro later',
],
],
'pro' => [
'name' => 'Pro',
'description' => 'Full Academy access across Creator and Pro lessons, prompts, and future premium drops.',
'badge' => 'Recommended',
'featured' => true,
'features' => [
'Everything in Creator',
'Advanced Pro lessons and prompt systems',
'Priority access to future Academy premium features',
'Stripe billing portal for upgrades and invoices',
],
],
];
return collect($definitions)
->map(function (array $definition, string $tier): array {
$plan = $this->plans->plan($tier.'_monthly');
$plans = $plan !== null ? [[
'key' => $plan['key'],
'label' => $plan['label'],
'interval' => $plan['interval'],
'amount' => $plan['amount'],
'currency' => $plan['currency'],
'price_display' => $plan['price_display'],
'configured' => $plan['configured'],
'price_id_valid' => $plan['price_id_valid'],
'remote_price_exists' => $plan['remote_price_exists'] ?? false,
]] : [];
return [
'tier' => $tier,
'name' => $definition['name'],
'description' => $definition['description'],
'badge' => $definition['badge'],
'featured' => $definition['featured'],
'features' => $definition['features'],
'plans' => $plans,
];
})
->values()
->all();
}
private function academySubscription(User $user): ?Subscription
{
$subscription = $user->subscription($this->plans->subscriptionName());
return $subscription instanceof Subscription
? $subscription->loadMissing('items')
: null;
}
/**
* @return array<string, mixed>|null
*/
private function activePlan(User $user): ?array
{
$subscription = $this->academySubscription($user);
if (! $subscription instanceof Subscription || (! $subscription->active() && ! $subscription->onGracePeriod())) {
return null;
}
$matchedPlan = null;
foreach ($subscription->items as $item) {
$priceId = trim((string) $item->stripe_price);
if ($priceId === '') {
continue;
}
$plan = $this->plans->planForPriceId($priceId);
if ($plan === null) {
continue;
}
if ($matchedPlan === null || $this->planRank((string) $plan['tier']) > $this->planRank((string) $matchedPlan['tier'])) {
$matchedPlan = $plan;
}
}
if ($matchedPlan !== null) {
return $matchedPlan;
}
$fallbackPriceId = trim((string) $subscription->stripe_price);
return $fallbackPriceId !== '' ? $this->plans->planForPriceId($fallbackPriceId) : null;
}
private function planRank(string $tier): int
{
return match (strtolower(trim($tier))) {
'admin' => 40,
'pro' => 30,
'creator' => 20,
default => 10,
};
}
private function billingDisabledResponse(\Illuminate\Http\Request $request, string $message): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
$payload = [
'ok' => false,
'code' => 'academy_payments_disabled',
'message' => $message,
];
if ($request->expectsJson()) {
return \response()->json($payload, 423);
}
return \redirect()->route('academy.pricing')->with('error', $message);
}
private function missingPriceIdResponse(\Illuminate\Http\Request $request, string $planKey): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
$message = 'The selected Academy plan is not configured yet. Please try again later.';
if ($request->expectsJson()) {
return \response()->json([
'ok' => false,
'code' => 'academy_billing_price_missing',
'message' => $message,
'plan' => $planKey,
], 422);
}
return \redirect()->route('academy.pricing')->with('error', $message);
}
private function invalidPriceIdResponse(\Illuminate\Http\Request $request, string $planKey): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
$message = 'The selected Academy plan is misconfigured. Please contact support before continuing.';
if ($request->expectsJson()) {
return \response()->json([
'ok' => false,
'code' => 'academy_billing_price_invalid',
'message' => $message,
'plan' => $planKey,
], 422);
}
return \redirect()->route('academy.pricing')->with('error', $message);
}
private function checkoutErrorResponse(\Illuminate\Http\Request $request, \Throwable $exception): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
$message = 'Academy checkout could not be started right now.';
if (app()->hasDebugModeEnabled() && trim($exception->getMessage()) !== '') {
$message .= ' '.$exception->getMessage();
}
if ($request->expectsJson()) {
return \response()->json([
'ok' => false,
'code' => 'academy_billing_checkout_failed',
'message' => $message,
], 422);
}
return \redirect()->route('academy.pricing')->with('error', $message);
}
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyChallenge;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyInteractionService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyChallengeController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyInteractionService $interactions,
)
{
}
public function index(Request $request): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless((bool) config('academy.challenges_enabled', true), 404);
$challenges = AcademyChallenge::query()
->publiclyVisible()
->latest('starts_at')
->paginate(12)
->withQueryString();
$challenges->getCollection()->transform(fn (AcademyChallenge $challenge): array => $this->access->challengePayload($challenge, $request->user(), true));
$seo = app(SeoFactory::class)
->collectionListing(
'Academy Challenges — Skinbase',
'Join Academy creative briefs, review accepted submissions, and explore current and archived AI Academy challenges.',
route('academy.challenges.index'),
)
->toArray();
return Inertia::render('Academy/List', [
'pageType' => 'challenges',
'title' => 'Academy challenges',
'description' => 'Creative briefs for wallpapers, worlds, covers, and prompt-driven visual experiments.',
'seo' => $seo,
'items' => $challenges,
'filters' => [],
'categories' => [],
'pricingUrl' => route('academy.pricing'),
'analytics' => [
'enabled' => true,
'contentType' => null,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_challenges_index',
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('academy');
}
public function show(Request $request, string $slug): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless((bool) config('academy.challenges_enabled', true), 404);
$challenge = AcademyChallenge::query()
->with(['submissions' => fn ($query) => $query->approved()->with(['user:id,name,username', 'artwork'])])
->publiclyVisible()
->where('slug', $slug)
->firstOrFail();
$payload = $this->access->challengePayload($challenge, $request->user(), true);
$payload['submissions'] = $challenge->submissions->map(fn ($submission): array => [
'id' => (int) $submission->id,
'user' => $submission->user ? [
'id' => (int) $submission->user->id,
'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('md'),
] : null,
'submitted_at' => $submission->submitted_at?->toISOString(),
])->values()->all();
$seo = app(SeoFactory::class)->collectionPage(
$challenge->title . ' — Skinbase Academy',
Str::limit((string) ($challenge->excerpt ?? $challenge->description ?? ''), 160, '...'),
route('academy.challenges.show', ['slug' => $challenge->slug]),
$challenge->cover_image,
)->toArray();
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::CHALLENGE, (int) $challenge->id);
return Inertia::render('Academy/Show', [
'pageType' => 'challenge',
'item' => $payload,
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'submitUrl' => $request->user() ? route('academy.challenges.submit', ['slug' => $challenge->slug]) : null,
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::CHALLENGE,
'contentId' => (int) $challenge->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_challenge_show',
'isPremium' => (string) ($challenge->access_level ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('academy');
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Http\Requests\Academy\StoreAcademyChallengeSubmissionRequest;
use App\Models\AcademyChallenge;
use App\Models\Artwork;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyChallengeService;
use App\Support\Seo\SeoFactory;
use Inertia\Inertia;
use Inertia\Response;
use Illuminate\Http\RedirectResponse;
final class AcademyChallengeSubmissionController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyChallengeService $challenges,
) {
}
public function create(string $slug): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless((bool) config('academy.challenges_enabled', true), 404);
$challenge = AcademyChallenge::query()->where('slug', $slug)->firstOrFail();
abort_unless($this->access->canAccessChallenge(request()->user(), $challenge), 403);
$seo = app(SeoFactory::class)->collectionPage(
'Submit to ' . $challenge->title . ' — Skinbase Academy',
'Attach one of your artworks to this Academy challenge submission.',
route('academy.challenges.submit', ['slug' => $challenge->slug]),
)->toArray();
return Inertia::render('Academy/ChallengeSubmit', [
'seo' => $seo,
'challenge' => $this->access->challengePayload($challenge, request()->user(), true),
'artworks' => $this->challenges->eligibleArtworkOptions(request()->user())->map(fn (Artwork $artwork): array => [
'id' => (int) $artwork->id,
'title' => (string) ($artwork->title ?? 'Untitled artwork'),
'thumb_url' => $artwork->thumbUrl('md'),
'published_at' => $artwork->published_at?->toISOString(),
])->values()->all(),
'submitUrl' => route('academy.challenges.submit.store', ['slug' => $challenge->slug]),
])->rootView('academy');
}
public function store(StoreAcademyChallengeSubmissionRequest $request, string $slug): RedirectResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless((bool) config('academy.challenges_enabled', true), 404);
$challenge = AcademyChallenge::query()->where('slug', $slug)->firstOrFail();
$artwork = Artwork::query()->findOrFail((int) $request->validated('artwork_id'));
$this->challenges->submit($request->user(), $challenge, $artwork, $request->validated());
return redirect()->route('academy.challenges.show', ['slug' => $challenge->slug])
->with('success', 'Challenge submission received and queued for review.');
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
final class AcademyCheckoutController extends Controller
{
public function store(Request $request, string $plan): JsonResponse|RedirectResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
// TODO: Replace this placeholder with Laravel Cashier + Stripe Checkout when academy payments are enabled.
if (! (bool) config('academy.payments_enabled', false)) {
$payload = [
'ok' => false,
'code' => 'academy_payments_disabled',
'message' => 'Academy payments are disabled for this launch phase.',
'plan' => $plan,
];
if ($request->expectsJson()) {
return response()->json($payload, 423);
}
return redirect()->route('academy.pricing')->with('error', $payload['message']);
}
return response()->json([
'ok' => false,
'code' => 'academy_checkout_not_implemented',
'message' => 'Checkout is not implemented yet.',
'plan' => $plan,
], 501);
}
}
@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseLesson;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyInteractionService;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyCourseNavigationService;
use App\Services\Academy\AcademyCourseProgressService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyCourseController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyCacheService $cache,
private readonly AcademyCourseNavigationService $navigation,
private readonly AcademyCourseProgressService $progress,
private readonly AcademyInteractionService $interactions,
) {
}
public function index(Request $request): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$filters = $request->validate([
'difficulty' => ['nullable', 'string', 'max:40'],
'access' => ['nullable', 'string', 'max:40'],
]);
$query = AcademyCourse::query()->published()->ordered();
if (filled($filters['difficulty'] ?? null)) {
$query->where('difficulty', $filters['difficulty']);
}
if (filled($filters['access'] ?? null)) {
$query->where('access_level', $filters['access']);
}
$courses = $query->paginate(12)->withQueryString();
$courses->getCollection()->transform(function (AcademyCourse $course) use ($request): array {
return $this->access->coursePayload($course, $request->user(), [
'progress' => $this->progress->getProgress($request->user(), $course),
]);
});
$featuredCourses = collect($this->cache->featuredCourses())->map(fn (AcademyCourse $course): array => $this->access->coursePayload($course, $request->user(), [
'progress' => $this->progress->getProgress($request->user(), $course),
]))->values();
$seoCourses = $featuredCourses
->concat(collect($courses->items()))
->unique(fn (array $course): string => (string) ($course['slug'] ?? ''))
->values();
$seo = app(SeoFactory::class)
->academyCourseListingPage(
'Academy Courses — Skinbase',
'Follow guided Skinbase AI Academy courses built from reusable lessons, chapters, and creator workflows.',
route('academy.courses.index', $request->query()),
$seoCourses,
[
['name' => 'Academy', 'url' => route('academy.index')],
['name' => 'Courses', 'url' => route('academy.courses.index')],
],
)
->toArray();
return Inertia::render('Academy/CoursesIndex', [
'seo' => $seo,
'title' => 'Academy courses',
'description' => 'Guided learning paths built from reusable Academy lessons and creator workflows.',
'items' => $courses,
'featuredCourses' => $featuredCourses->all(),
'filters' => $filters,
'pricingUrl' => route('academy.pricing'),
'lessonsUrl' => route('academy.lessons.index'),
'promptLibraryUrl' => route('academy.prompts.index'),
'academyAccess' => array_merge($this->access->accessSummary($request->user()), [
'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false)
? route('academy.billing.account')
: route('academy.pricing'),
]),
'analytics' => [
'enabled' => true,
'contentType' => null,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_courses_index',
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('academy');
}
public function show(Request $request, AcademyCourse $course): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless($course->isPublished(), 404);
$course->load(['sections', 'courseLessons.section', 'courseLessons.lesson.category']);
$progress = $this->progress->getProgress($request->user(), $course);
$completedLessonIds = $request->user() ? $this->progress->getCompletedLessonIds($request->user(), $course) : [];
$orderedLessons = $this->navigation->orderedCourseLessons($course);
$stepMeta = $orderedLessons
->values()
->mapWithKeys(fn (AcademyCourseLesson $courseLesson, int $index): array => [
$courseLesson->id => [
'course_step_number' => $index + 1,
'course_step_label' => sprintf('Step %02d', $index + 1),
],
]);
$sections = $course->sections
->sortBy([['order_num', 'asc'], ['id', 'asc']])
->values()
->map(function ($section) use ($completedLessonIds, $orderedLessons, $request, $stepMeta): array {
$sectionLessons = $orderedLessons
->where('section_id', $section->id)
->values()
->map(fn (AcademyCourseLesson $courseLesson): array => $this->access->courseLessonPayload($courseLesson, $request->user(), false, [
'completed_lesson_ids' => $completedLessonIds,
...((array) $stepMeta->get($courseLesson->id, [])),
]))
->all();
return [
'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),
'lessons' => $sectionLessons,
];
})
->all();
$unsectionedLessons = $orderedLessons
->whereNull('section_id')
->values()
->map(fn (AcademyCourseLesson $courseLesson): array => $this->access->courseLessonPayload($courseLesson, $request->user(), false, [
'completed_lesson_ids' => $completedLessonIds,
...((array) $stepMeta->get($courseLesson->id, [])),
]))
->all();
$coursePayload = $this->access->coursePayload($course, $request->user(), ['progress' => $progress]);
$courseKeywords = collect(explode(',', (string) ($course->meta_keywords ?? '')))
->map(fn (string $keyword): string => trim($keyword))
->filter()
->values()
->all();
$courseImage = (string) ($coursePayload['cover_image_url'] ?? $coursePayload['teaser_image_url'] ?? $course->og_image ?? $course->cover_image ?? $course->teaser_image ?? '');
$seo = app(SeoFactory::class)
->academyCoursePage(
(string) ($course->seo_title ?: ($course->title . ' — Skinbase Academy')),
(string) ($course->seo_description ?: $course->excerpt ?: 'Skinbase Academy course'),
route('academy.courses.show', ['course' => $course->slug]),
$courseImage,
[
['name' => 'Academy', 'url' => route('academy.index')],
['name' => 'Courses', 'url' => route('academy.courses.index')],
['name' => (string) $course->title, 'url' => route('academy.courses.show', ['course' => $course->slug])],
],
$courseKeywords,
$course->published_at?->toAtomString(),
$course->updated_at?->toAtomString(),
(string) ($course->access_level ?? ''),
(string) ($course->difficulty ?? ''),
(int) ($course->estimated_minutes ?? 0),
$orderedLessons
->values()
->map(fn (AcademyCourseLesson $courseLesson): array => $this->access->courseLessonPayload($courseLesson, $request->user(), false, [
'completed_lesson_ids' => $completedLessonIds,
...((array) $stepMeta->get($courseLesson->id, [])),
]))
->all(),
)
->toArray();
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::COURSE, (int) $course->id);
return Inertia::render('Academy/CoursesShow', [
'seo' => $seo,
'course' => $coursePayload,
'sections' => $sections,
'unsectionedLessons' => $unsectionedLessons,
'pricingUrl' => route('academy.pricing'),
'startUrl' => $request->user() ? route('academy.courses.start', ['course' => $course->slug]) : null,
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::COURSE,
'contentId' => (int) $course->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_course_show',
'isPremium' => (string) ($course->access_level ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => false,
],
])->rootView('academy');
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Services\Academy\AcademyProgressService;
use App\Services\Academy\AcademyCourseProgressService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
final class AcademyCourseEnrollmentController extends Controller
{
public function __construct(
private readonly AcademyCourseProgressService $progress,
private readonly AcademyProgressService $academyProgress,
) {
}
public function start(Request $request, AcademyCourse $course): RedirectResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless($course->isPublished(), 404);
$this->academyProgress->startCourse($request->user(), (int) $course->id, $request);
$continueLesson = $this->progress->getContinueLesson($request->user(), $course);
if ($continueLesson?->lesson) {
return redirect()->route('academy.courses.lessons.show', ['course' => $course->slug, 'lesson' => $continueLesson->lesson->slug]);
}
return redirect()->route('academy.courses.show', ['course' => $course->slug]);
}
}
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Models\AcademyLesson;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyInteractionService;
use App\Services\Academy\AcademyCourseNavigationService;
use App\Services\Academy\AcademyCourseProgressService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyCourseLessonController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyCourseNavigationService $navigation,
private readonly AcademyCourseProgressService $progress,
private readonly AcademyInteractionService $interactions,
) {
}
public function show(Request $request, AcademyCourse $course, AcademyLesson $lesson): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless($course->isPublished(), 404);
$course->load(['sections', 'courseLessons.section', 'courseLessons.lesson.category']);
$courseLesson = $this->navigation->findCourseLesson($course, $lesson);
abort_unless($courseLesson instanceof \App\Models\AcademyCourseLesson, 404);
if ($request->user()) {
$this->progress->updateLastLesson($request->user(), $course, $lesson);
$this->progress->markCourseCompletedIfFinished($request->user(), $course);
}
$progress = $this->progress->getProgress($request->user(), $course);
$previousLesson = $this->navigation->previousLesson($course, $lesson);
$nextLesson = $this->navigation->nextLesson($course, $lesson);
$courseOutline = $this->navigation->orderedCourseLessons($course)
->map(fn (\App\Models\AcademyCourseLesson $entry): array => $this->access->courseLessonPayload($entry, $request->user()))
->values()
->all();
$payload = $this->access->courseLessonPayload($courseLesson, $request->user(), true);
$canonical = route('academy.courses.lessons.show', ['course' => $course->slug, 'lesson' => $lesson->slug]);
$description = Str::limit(trim((string) ($lesson->seo_description ?? $lesson->excerpt ?? 'Skinbase Academy course lesson.')), 160, '...');
$seo = app(SeoFactory::class)->academyLessonPage(
(string) ($lesson->seo_title ?? ($lesson->title . ' — ' . $course->title)),
$description,
$canonical,
(string) ($payload['article_cover_image_url'] ?? $payload['cover_image_url'] ?? $lesson->cover_image ?? ''),
[
['name' => 'Academy', 'url' => route('academy.index')],
['name' => 'Courses', 'url' => route('academy.courses.index')],
['name' => (string) $course->title, 'url' => route('academy.courses.show', ['course' => $course->slug])],
['name' => (string) $lesson->title, 'url' => $canonical],
],
array_values((array) ($payload['tags'] ?? [])),
$lesson->published_at?->toAtomString(),
$lesson->updated_at?->toAtomString(),
(string) $course->title,
)->toArray();
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::LESSON, (int) $lesson->id);
return Inertia::render('Academy/Show', [
'pageType' => 'lesson',
'item' => $payload,
'relatedLessons' => [],
'relatedCourses' => [],
'previousLesson' => $previousLesson ? $this->access->courseLessonPayload($previousLesson, $request->user()) : null,
'nextLesson' => $nextLesson ? $this->access->courseLessonPayload($nextLesson, $request->user()) : null,
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'completeUrl' => $request->user() ? route('academy.lessons.complete', ['lesson' => $lesson->id]) : null,
'completed' => $request->user()?->academyLessonProgress()->where('lesson_id', $lesson->id)->whereNotNull('completed_at')->exists() ?? false,
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'progressRoutes' => [
'startLesson' => $request->user() ? route('academy.progress.lesson.start') : null,
],
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::LESSON,
'contentId' => (int) $lesson->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_course_lesson_show',
'isPremium' => (string) ($payload['access_level'] ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
'courseContext' => [
'id' => (int) $course->id,
'title' => (string) $course->title,
'slug' => (string) $course->slug,
'subtitle' => (string) ($course->subtitle ?? ''),
'showUrl' => route('academy.courses.show', ['course' => $course->slug]),
'completePayload' => ['course_id' => $course->id],
'progress' => [
'percent' => (int) ($progress['progress_percent'] ?? 0),
'completedRequired' => (int) ($progress['completed_required'] ?? 0),
'totalRequired' => (int) ($progress['total_required'] ?? 0),
'completed' => (bool) ($progress['completed'] ?? false),
],
'outline' => $courseOutline,
],
])->rootView('academy');
}
}
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyChallenge;
use App\Models\AcademyCourse;
use App\Models\AcademyLesson;
use App\Models\AcademyPromptTemplate;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyCacheService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyHomeController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyCacheService $cache,
) {
}
public function index(Request $request): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$canonical = route('academy.index');
$seo = app(SeoFactory::class)
->collectionPage(
'Skinbase AI Academy — Skinbase',
'Learn AI-powered creativity for wallpapers, digital art, skins, news covers, and visual worlds inside Skinbase.',
$canonical,
)
->toArray();
$seo['og_type'] = 'website';
$home = $this->cache->homePayload(function (): array {
return [
'featuredLessons' => $this->cache->featuredLessons(),
'featuredCourses' => $this->cache->featuredCourses(),
'featuredPrompts' => $this->cache->featuredPrompts(),
'featuredChallenges' => (bool) config('academy.challenges_enabled', true)
? $this->cache->featuredChallenges()
: [],
'lessonCount' => AcademyLesson::query()->active()->published()->count(),
'courseCount' => AcademyCourse::query()->published()->count(),
'promptCount' => AcademyPromptTemplate::query()->active()->published()->count(),
'challengeCount' => (bool) config('academy.challenges_enabled', true)
? AcademyChallenge::query()->publiclyVisible()->count()
: 0,
];
});
return Inertia::render('Academy/Index', [
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'academyAccess' => array_merge($this->access->accessSummary($request->user()), [
'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false)
? route('academy.billing.account')
: route('academy.pricing'),
]),
'links' => [
'lessons' => route('academy.lessons.index'),
'courses' => route('academy.courses.index'),
'prompts' => route('academy.prompts.index'),
'promptPopular' => route('academy.prompts.popular'),
'packs' => route('academy.packs.index'),
'challenges' => route('academy.challenges.index'),
],
'featureFlags' => [
'paymentsEnabled' => (bool) config('academy.payments_enabled', false),
'challengesEnabled' => (bool) config('academy.challenges_enabled', true),
'badgesEnabled' => (bool) config('academy.badges_enabled', true),
],
'stats' => [
'lessonCount' => (int) $home['lessonCount'],
'courseCount' => (int) $home['courseCount'],
'promptCount' => (int) $home['promptCount'],
'challengeCount' => (int) $home['challengeCount'],
],
'featuredCourses' => collect($home['featuredCourses'])->map(fn (AcademyCourse $course): array => $this->access->coursePayload($course, $request->user()))->values()->all(),
'featuredLessons' => collect($home['featuredLessons'])->map(fn (AcademyLesson $lesson): array => $this->access->lessonPayload($lesson, $request->user()))->values()->all(),
'featuredPrompts' => collect($home['featuredPrompts'])->map(fn (AcademyPromptTemplate $prompt): array => $this->access->promptPayload($prompt, $request->user()))->values()->all(),
'featuredChallenges' => collect($home['featuredChallenges'])->map(fn (AcademyChallenge $challenge): array => $this->access->challengePayload($challenge, $request->user(), true))->values()->all(),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::HOME,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_home',
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('academy');
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Services\Academy\AcademyInteractionService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use InvalidArgumentException;
final class AcademyInteractionController extends Controller
{
public function __construct(private readonly AcademyInteractionService $interactions)
{
}
public function like(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $this->validatePayload($request);
try {
$payload = $this->interactions->toggleLike($request->user(), (string) $validated['content_type'], (int) $validated['content_id'], $request);
} catch (InvalidArgumentException $exception) {
return response()->json(['message' => $exception->getMessage()], 422);
}
return response()->json($payload);
}
public function save(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $this->validatePayload($request);
try {
$payload = $this->interactions->toggleSave($request->user(), (string) $validated['content_type'], (int) $validated['content_id'], $request);
} catch (InvalidArgumentException $exception) {
return response()->json(['message' => $exception->getMessage()], 422);
}
return response()->json($payload);
}
/**
* @return array<string, mixed>
*/
private function validatePayload(Request $request): array
{
return $request->validate([
'content_type' => ['required', 'string', Rule::in([
AcademyAnalyticsContentType::PROMPT,
AcademyAnalyticsContentType::LESSON,
AcademyAnalyticsContentType::COURSE,
AcademyAnalyticsContentType::PROMPT_PACK,
AcademyAnalyticsContentType::CHALLENGE,
])],
'content_id' => ['required', 'integer', 'min:1'],
]);
}
}
@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Models\AcademyLesson;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyAnalyticsService;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyInteractionService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyLessonController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyCacheService $cache,
private readonly AcademyAnalyticsService $analytics,
private readonly AcademyInteractionService $interactions,
) {}
public function index(Request $request): Response|JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:120'],
'category' => ['nullable', 'string', 'max:140'],
'difficulty' => ['nullable', 'string', 'max:40'],
]);
$query = AcademyLesson::query()
->with('category')
->active()
->published()
->orderedForCourse();
if (filled($filters['q'] ?? null)) {
$query->where(function ($builder) use ($filters): void {
$builder->where('title', 'like', '%'.$filters['q'].'%')
->orWhere('excerpt', 'like', '%'.$filters['q'].'%');
});
}
if (filled($filters['category'] ?? null)) {
$query->whereHas('category', fn ($builder) => $builder->where('slug', $filters['category']));
}
if (filled($filters['difficulty'] ?? null)) {
$query->where('difficulty', $filters['difficulty']);
}
$lessons = $query->paginate(12)->withQueryString();
$lessons->getCollection()->transform(fn (AcademyLesson $lesson): array => $this->access->lessonPayload($lesson, $request->user()));
if (filled($filters['q'] ?? null)) {
$this->analytics->trackSearch((string) $filters['q'], (int) $lessons->total(), array_filter($filters), $request);
}
if ($request->expectsJson()) {
return response()->json($lessons);
}
$seo = app(SeoFactory::class)
->collectionListing(
'Academy Lessons — Skinbase',
'Browse Skinbase AI Academy lessons covering prompting, workflow cleanup, ethics, and upload-ready creative workflows.',
route('academy.lessons.index', $request->query()),
)
->toArray();
return Inertia::render('Academy/List', [
'pageType' => 'lessons',
'title' => 'Academy lessons',
'description' => 'Step-by-step tutorials and workflow guides for AI-assisted creative work on Skinbase.',
'seo' => $seo,
'breadcrumbs' => [
['label' => 'Academy', 'href' => route('academy.index')],
['label' => 'Lessons', 'href' => route('academy.lessons.index')],
],
'items' => $lessons,
'filters' => $filters,
'categories' => $this->cache->categoriesByType('lesson'),
'pricingUrl' => route('academy.pricing'),
'coursesUrl' => route('academy.courses.index'),
'promptLibraryUrl' => route('academy.prompts.index'),
'academyAccess' => array_merge($this->access->accessSummary($request->user()), [
'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false)
? route('academy.billing.account')
: route('academy.pricing'),
]),
'analytics' => [
'enabled' => true,
'contentType' => filled($filters['q'] ?? null) ? AcademyAnalyticsContentType::SEARCH : null,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_lessons_index',
'search' => filled($filters['q'] ?? null) ? [
'query' => (string) $filters['q'],
'resultsCount' => (int) $lessons->total(),
] : null,
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('academy');
}
public function show(Request $request, string $slug): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$lesson = AcademyLesson::query()
->with(['category', 'activeBlocks.activeComparisonResults'])
->active()
->published()
->where('slug', $slug)
->firstOrFail();
$payload = $this->access->lessonPayload($lesson, $request->user(), true);
$courseQuery = AcademyLesson::query()
->with('category')
->active()
->published();
if (filled($lesson->series_name)) {
$courseQuery->where('series_name', $lesson->series_name);
} elseif ($lesson->category_id !== null) {
$courseQuery->where('category_id', $lesson->category_id);
} else {
$courseQuery->whereKey($lesson->id);
}
$courseLessons = $courseQuery
->orderedForCourse()
->get()
->filter(fn (AcademyLesson $courseLesson): bool => $this->access->canAccessLesson($request->user(), $courseLesson))
->values();
$currentIndex = $courseLessons->search(fn (AcademyLesson $courseLesson): bool => $courseLesson->is($lesson));
$previousLesson = is_int($currentIndex) && $currentIndex > 0
? $courseLessons->get($currentIndex - 1)
: null;
$nextLesson = is_int($currentIndex) && $currentIndex < ($courseLessons->count() - 1)
? $courseLessons->get($currentIndex + 1)
: null;
$relatedLessons = $courseLessons
->reject(fn (AcademyLesson $courseLesson): bool => $courseLesson->is($lesson))
->take(6)
->map(fn (AcademyLesson $relatedLesson): array => $this->access->lessonPayload($relatedLesson, $request->user()))
->values()
->all();
$relatedCourses = AcademyCourse::query()
->published()
->ordered()
->whereHas('courseLessons', fn ($builder) => $builder->where('lesson_id', $lesson->id))
->limit(3)
->get()
->map(fn (AcademyCourse $course): array => $this->access->coursePayload($course, $request->user()))
->values()
->all();
$canonical = route('academy.lessons.show', ['slug' => $lesson->slug]);
$description = Str::limit(trim((string) ($lesson->seo_description ?? $lesson->excerpt ?? 'Skinbase Academy lesson.')), 160, '...');
$seo = app(SeoFactory::class)->academyLessonPage(
(string) ($lesson->seo_title ?? ($lesson->title.' — Skinbase Academy')),
$description,
$canonical,
(string) ($payload['article_cover_image_url'] ?? $payload['cover_image_url'] ?? $lesson->cover_image ?? ''),
[
['name' => 'Academy', 'url' => route('academy.index')],
['name' => 'Lessons', 'url' => route('academy.lessons.index')],
['name' => (string) $lesson->title, 'url' => $canonical],
],
array_values((array) ($payload['tags'] ?? [])),
$lesson->published_at?->toAtomString(),
$lesson->updated_at?->toAtomString(),
(string) ($lesson->series_name ?: $lesson->category?->name ?: 'Academy'),
)->toArray();
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::LESSON, (int) $lesson->id);
return Inertia::render('Academy/Show', [
'pageType' => 'lesson',
'item' => $payload,
'relatedLessons' => $relatedLessons,
'relatedCourses' => $relatedCourses,
'previousLesson' => $previousLesson ? $this->access->lessonPayload($previousLesson, $request->user()) : null,
'nextLesson' => $nextLesson ? $this->access->lessonPayload($nextLesson, $request->user()) : null,
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'completeUrl' => $request->user() ? route('academy.lessons.complete', ['lesson' => $lesson->id]) : null,
'completed' => $request->user()?->academyLessonProgress()->where('lesson_id', $lesson->id)->whereNotNull('completed_at')->exists() ?? false,
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'progressRoutes' => [
'startLesson' => $request->user() ? route('academy.progress.lesson.start') : null,
],
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::LESSON,
'contentId' => (int) $lesson->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_lesson_show',
'isPremium' => (string) ($lesson->access_level ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('academy');
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyPricingController extends Controller
{
public function index(Request $request): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$canonical = route('academy.pricing');
$seo = app(SeoFactory::class)
->collectionPage(
'Skinbase AI Academy Pricing — Skinbase',
'Compare Skinbase AI Academy Explorer, Creator, and Pro plans and preview premium access before payments go live.',
$canonical,
)
->toArray();
$seo['og_type'] = 'website';
return Inertia::render('Academy/Pricing', [
'seo' => $seo,
'paymentsEnabled' => (bool) config('academy.payments_enabled', false),
'plans' => [
[
'name' => 'Explorer',
'price' => '€0',
'interval' => '/ month',
'badge' => 'Free',
'features' => [
'Beginner AI lessons',
'Basic prompting tutorials',
'Public prompt previews',
'Public Academy challenges',
],
],
[
'name' => 'Creator',
'price' => '€4.99',
'interval' => '/ month',
'badge' => 'Premium',
'features' => [
'Full prompt library',
'Premium lessons',
'Prompt packs',
'Saved prompt collections',
],
],
[
'name' => 'Pro',
'price' => '€9.99',
'interval' => '/ month',
'badge' => 'Advanced',
'features' => [
'Advanced AI workflows',
'World Builder kits',
'Pro challenges',
'Profile highlight badge',
],
],
],
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::UPGRADE,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_pricing',
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('academy');
}
}
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Models\AcademyLesson;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyProgressService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class AcademyProgressController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyProgressService $progress,
) {
}
public function startLesson(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $request->validate([
'lesson_id' => ['required', 'integer', 'min:1'],
'course_id' => ['nullable', 'integer', 'min:1'],
]);
$lesson = AcademyLesson::query()->findOrFail((int) $validated['lesson_id']);
abort_unless($this->access->canAccessLesson($request->user(), $lesson), 403);
$courseId = $request->filled('course_id') ? (int) $validated['course_id'] : null;
if ($courseId !== null) {
$course = AcademyCourse::query()->published()->findOrFail($courseId);
abort_unless($course->courseLessons()->where('lesson_id', $lesson->id)->exists(), 403);
}
$record = $this->progress->startLesson($request->user(), (int) $lesson->id, $courseId, $request);
return response()->json([
'ok' => true,
'status' => (string) $record->status,
]);
}
public function complete(Request $request, AcademyLesson $lesson): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless($this->access->canAccessLesson($request->user(), $lesson), 403);
$course = null;
if ($request->filled('course_id')) {
$course = AcademyCourse::query()->published()->find($request->integer('course_id'));
}
$record = $this->progress->markLessonComplete($request->user(), $lesson, $course, $request);
return response()->json([
'ok' => true,
'completed' => true,
'completed_at' => $record->completed_at?->toISOString(),
]);
}
public function completeLesson(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $request->validate([
'lesson_id' => ['required', 'integer', 'min:1'],
'course_id' => ['nullable', 'integer', 'min:1'],
]);
$lesson = AcademyLesson::query()->findOrFail((int) $validated['lesson_id']);
return $this->complete($request, $lesson);
}
public function startCourse(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $request->validate([
'course_id' => ['required', 'integer', 'min:1'],
]);
$course = AcademyCourse::query()->published()->findOrFail((int) $validated['course_id']);
$record = $this->progress->startCourse($request->user(), (int) $course->id, $request);
return response()->json([
'ok' => true,
'status' => (string) $record->status,
'progress_percent' => (int) $record->progress_percent,
]);
}
public function completeCourse(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $request->validate([
'course_id' => ['required', 'integer', 'min:1'],
]);
$course = AcademyCourse::query()->published()->findOrFail((int) $validated['course_id']);
$record = $this->progress->completeCourse($request->user(), (int) $course->id, $request);
return response()->json([
'ok' => true,
'status' => (string) $record->status,
'progress_percent' => (int) $record->progress_percent,
'completed' => (string) $record->status === 'completed',
]);
}
}
@@ -0,0 +1,469 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyPromptTemplate;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyAnalyticsService;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyInteractionService;
use App\Services\Academy\AcademyPopularityService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyPromptController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyCacheService $cache,
private readonly AcademyAnalyticsService $analytics,
private readonly AcademyInteractionService $interactions,
private readonly AcademyPopularityService $popularity,
) {
}
public function index(Request $request): Response|JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:120'],
'category' => ['nullable', 'string', 'max:140'],
'difficulty' => ['nullable', 'string', 'max:40'],
'tag' => ['nullable', 'string', 'max:60'],
]);
$query = AcademyPromptTemplate::query()
->with('category')
->active()
->published()
->latest('published_at');
if (filled($filters['q'] ?? null)) {
$query->where(function ($builder) use ($filters): void {
$builder->where('title', 'like', '%' . $filters['q'] . '%')
->orWhere('excerpt', 'like', '%' . $filters['q'] . '%');
});
}
if (filled($filters['category'] ?? null)) {
$query->whereHas('category', fn ($builder) => $builder->where('slug', $filters['category']));
}
if (filled($filters['difficulty'] ?? null)) {
$query->where('difficulty', $filters['difficulty']);
}
if (filled($filters['tag'] ?? null)) {
$tag = $filters['tag'];
$query->whereJsonContains('tags', $tag);
}
$prompts = $query->paginate(12)->withQueryString();
$prompts->getCollection()->transform(fn (AcademyPromptTemplate $prompt): array => $this->access->promptPayload($prompt, $request->user()));
if (filled($filters['q'] ?? null)) {
$this->analytics->trackSearch((string) $filters['q'], (int) $prompts->total(), array_filter($filters), $request);
}
if ($request->expectsJson()) {
return response()->json($prompts);
}
$seo = app(SeoFactory::class)
->collectionListing(
'Academy Prompts — Skinbase',
'Browse AI prompt templates for wallpapers, worlds, editorial covers, robots, pixel art, and creator workflows.',
route('academy.prompts.index', $request->query()),
)
->toArray();
return Inertia::render('Academy/List', [
'pageType' => 'prompts',
'promptView' => 'library',
'title' => 'Prompt library',
'description' => 'Reusable prompt templates for wallpapers, worlds, mascots, covers, and digital art workflows.',
'seo' => $seo,
'breadcrumbs' => [
['label' => 'Academy', 'href' => route('academy.index')],
['label' => 'Prompt Library', 'href' => route('academy.prompts.index')],
],
'items' => $prompts,
'filters' => $filters,
'categories' => $this->cache->categoriesByType('prompt'),
'pricingUrl' => route('academy.pricing'),
'coursesUrl' => route('academy.courses.index'),
'packsUrl' => route('academy.packs.index'),
'promptPopularUrl' => route('academy.prompts.popular'),
'promptLibraryUrl' => route('academy.prompts.index'),
'academyAccess' => array_merge($this->access->accessSummary($request->user()), [
'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false)
? route('academy.billing.account')
: route('academy.pricing'),
]),
'featuredPrompts' => $this->featuredPromptPayloads($request->user()),
'popularPrompts' => $this->popularPromptPayloads($request->user()),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::PROMPT_LIBRARY,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_prompts_index',
'search' => filled($filters['q'] ?? null) ? [
'query' => (string) $filters['q'],
'resultsCount' => (int) $prompts->total(),
] : null,
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('academy');
}
public function popular(Request $request): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $request->validate([
'period' => ['nullable', 'string', 'in:7d,30d,90d'],
]);
$selectedPeriod = $this->selectedPopularPromptPeriod($validated['period'] ?? null);
$from = now()->subDays($selectedPeriod['days'] - 1)->startOfDay();
$to = now()->endOfDay();
$rows = DB::query()
->fromSub(
$this->popularity->queryBetween($from, $to)
->where('content_type', AcademyAnalyticsContentType::PROMPT)
->whereNotNull('content_id')
->selectRaw('content_id, sum(views) as views, sum(prompt_copies) as prompt_copies, sum(popularity_score) as popularity_score')
->groupBy('content_id'),
'prompt_rankings'
)
->orderByDesc('popularity_score')
->orderByDesc('prompt_copies')
->orderByDesc('views')
->paginate(12)
->withQueryString();
$prompts = AcademyPromptTemplate::query()
->with('category')
->active()
->published()
->whereIn('id', $rows->pluck('content_id')->map(static fn ($value): int => (int) $value)->all())
->get()
->keyBy('id');
$baseRank = (($rows->currentPage() - 1) * $rows->perPage());
$rows->setCollection(
$rows->getCollection()
->values()
->map(function (object $row, int $index) use ($prompts, $request, $baseRank, $selectedPeriod): ?array {
$prompt = $prompts->get((int) $row->content_id);
if (! $prompt instanceof AcademyPromptTemplate) {
return null;
}
$payload = $this->access->promptPayload($prompt, $request->user());
$payload['ranking'] = [
'rank' => $baseRank + $index + 1,
'views' => max(0, (int) ($row->views ?? 0)),
'prompt_copies' => max(0, (int) ($row->prompt_copies ?? 0)),
'popularity_score' => round((float) ($row->popularity_score ?? 0), 2),
];
$payload['spotlight'] = [
'eyebrow' => max(0, (int) ($row->prompt_copies ?? 0)) > 0
? sprintf('%d copies %s', (int) $row->prompt_copies, $selectedPeriod['eyebrow_suffix'])
: sprintf('%d views %s', (int) $row->views, $selectedPeriod['eyebrow_suffix']),
];
return $payload;
})
->filter()
->values()
);
$seo = app(SeoFactory::class)
->collectionListing(
sprintf('%s Prompts — Skinbase Academy', $selectedPeriod['title_prefix']),
sprintf('See which Skinbase Academy prompt templates are driving the most views and copies %s.', $selectedPeriod['description_suffix']),
route('academy.prompts.popular', $request->query()),
)
->toArray();
return Inertia::render('Academy/List', [
'pageType' => 'prompts',
'promptView' => 'popular',
'title' => sprintf('%s prompts', $selectedPeriod['title_prefix']),
'description' => sprintf('The prompt templates getting the most momentum from views and copies across the Academy %s.', $selectedPeriod['description_suffix']),
'seo' => $seo,
'breadcrumbs' => [
['label' => 'Academy', 'href' => route('academy.index')],
['label' => 'Prompt Library', 'href' => route('academy.prompts.index')],
['label' => 'Popular Prompts', 'href' => route('academy.prompts.popular')],
],
'items' => $rows,
'filters' => [],
'categories' => [],
'pricingUrl' => route('academy.pricing'),
'coursesUrl' => route('academy.courses.index'),
'packsUrl' => route('academy.packs.index'),
'promptPopularUrl' => route('academy.prompts.popular'),
'promptLibraryUrl' => route('academy.prompts.index'),
'academyAccess' => array_merge($this->access->accessSummary($request->user()), [
'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false)
? route('academy.billing.account')
: route('academy.pricing'),
]),
'popularPeriod' => [
'value' => $selectedPeriod['value'],
'label' => $selectedPeriod['label'],
'description' => $selectedPeriod['description'],
],
'popularPeriods' => collect($this->popularPromptPeriods())
->map(fn (array $period): array => [
'value' => $period['value'],
'label' => $period['label'],
'description' => $period['description'],
'href' => route('academy.prompts.popular', ['period' => $period['value']]),
'active' => $period['value'] === $selectedPeriod['value'],
])
->values()
->all(),
'featuredPrompts' => $this->featuredPromptPayloads($request->user()),
'popularPrompts' => [],
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::PROMPT_POPULAR,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_prompts_popular',
'trackingKey' => sprintf('period:%s', $selectedPeriod['value']),
'metadata' => [
'period' => $selectedPeriod['value'],
'period_days' => $selectedPeriod['days'],
],
'search' => null,
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('academy');
}
/**
* @return array<int, array<string, mixed>>
*/
private function popularPromptPeriods(): array
{
return [
[
'value' => '7d',
'days' => 7,
'label' => '7 days',
'description' => 'Fresh momentum from the last 7 days.',
'title_prefix' => 'Top 7-day',
'description_suffix' => 'in the last 7 days',
'eyebrow_suffix' => 'in the last 7 days',
],
[
'value' => '30d',
'days' => 30,
'label' => '30 days',
'description' => 'The default monthly view of prompt momentum.',
'title_prefix' => 'Popular',
'description_suffix' => 'this month',
'eyebrow_suffix' => 'this month',
],
[
'value' => '90d',
'days' => 90,
'label' => '90 days',
'description' => 'Longer-running prompt momentum across the quarter.',
'title_prefix' => 'Top 90-day',
'description_suffix' => 'in the last 90 days',
'eyebrow_suffix' => 'in the last 90 days',
],
];
}
/**
* @return array<string, mixed>
*/
private function selectedPopularPromptPeriod(?string $value): array
{
return collect($this->popularPromptPeriods())
->first(fn (array $period): bool => $period['value'] === $value)
?? $this->popularPromptPeriods()[1];
}
public function show(Request $request, string $slug): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$prompt = AcademyPromptTemplate::query()
->with('category')
->active()
->published()
->where('slug', $slug)
->firstOrFail();
$payload = $this->access->promptPayload($prompt, $request->user(), true);
$canonical = route('academy.prompts.show', ['slug' => $prompt->slug]);
$description = Str::limit(trim((string) ($prompt->seo_description ?? $prompt->excerpt ?? 'Skinbase Academy prompt template.')), 160, '...');
$seo = app(SeoFactory::class)->collectionPage(
(string) ($prompt->seo_title ?? ($prompt->title . ' — Skinbase Academy')),
$description,
$canonical,
$payload['preview_image'] ?? null,
)->toArray();
$existingSchemas = $seo['json_ld'] ?? [];
if (! is_array($existingSchemas) || ! array_is_list($existingSchemas)) {
$existingSchemas = [$existingSchemas];
}
$seo['json_ld'] = [
...$existingSchemas,
$this->promptStructuredData($payload, $canonical, $description),
];
$canSavePrompt = $request->user() !== null && $this->access->canAccessPrompt($request->user(), $prompt);
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::PROMPT, (int) $prompt->id);
return Inertia::render('Academy/Show', [
'pageType' => 'prompt',
'item' => $payload,
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'saveUrl' => $canSavePrompt ? route('academy.prompts.save', ['prompt' => $prompt->id]) : null,
'unsaveUrl' => $canSavePrompt ? route('academy.prompts.unsave', ['prompt' => $prompt->id]) : null,
'saved' => $canSavePrompt ? ($request->user()?->academySavedPrompts()->where('prompt_template_id', $prompt->id)->exists() ?? false) : false,
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::PROMPT,
'contentId' => (int) $prompt->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_prompt_show',
'isPremium' => (string) ($prompt->access_level ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('academy');
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function promptStructuredData(array $payload, string $canonical, string $description): array
{
$imageUrls = array_values(array_unique(array_filter([
$payload['preview_image'] ?? null,
...collect((array) ($payload['public_examples'] ?? []))
->map(fn (array $example): ?string => $example['image_url'] ?? $example['thumb_url'] ?? null)
->filter()
->values()
->all(),
], fn (mixed $value): bool => is_string($value) && $value !== '')));
$isFree = (string) ($payload['access_level'] ?? 'free') === 'free';
return array_filter([
'@context' => 'https://schema.org',
'@type' => ['CreativeWork', 'LearningResource'],
'name' => (string) ($payload['title'] ?? 'Skinbase Academy prompt'),
'description' => $description,
'url' => $canonical,
'image' => $imageUrls !== [] ? $imageUrls : null,
'isAccessibleForFree' => $isFree,
'hasPart' => $isFree ? null : [
'@type' => 'WebPageElement',
'isAccessibleForFree' => false,
'cssSelector' => '.academy-paywalled-content',
],
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
}
/**
* @return array<int, array<string, mixed>>
*/
private function featuredPromptPayloads(mixed $viewer, int $limit = 4): array
{
return collect($this->cache->featuredPrompts())
->take($limit)
->map(function (AcademyPromptTemplate $prompt) use ($viewer): array {
$payload = $this->access->promptPayload($prompt, $viewer);
$payload['spotlight'] = [
'eyebrow' => $prompt->prompt_of_week ? 'Prompt of the week' : 'Featured pick',
];
return $payload;
})
->values()
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function popularPromptPayloads(mixed $viewer, int $limit = 4): array
{
$rows = $this->popularity->queryBetween(now()->subDays(29)->startOfDay(), now()->endOfDay())
->where('content_type', AcademyAnalyticsContentType::PROMPT)
->whereNotNull('content_id')
->selectRaw('content_id, sum(views) as views, sum(prompt_copies) as prompt_copies, sum(popularity_score) as popularity_score')
->groupBy('content_id')
->orderByDesc('popularity_score')
->limit($limit)
->get();
if ($rows->isEmpty()) {
return [];
}
$prompts = AcademyPromptTemplate::query()
->with('category')
->active()
->published()
->whereIn('id', $rows->pluck('content_id')->all())
->get()
->keyBy('id');
return $rows->map(function ($row) use ($prompts, $viewer): ?array {
$prompt = $prompts->get((int) $row->content_id);
if (! $prompt instanceof AcademyPromptTemplate) {
return null;
}
$payload = $this->access->promptPayload($prompt, $viewer);
$copies = max(0, (int) ($row->prompt_copies ?? 0));
$views = max(0, (int) ($row->views ?? 0));
$payload['spotlight'] = [
'eyebrow' => $copies > 0 ? sprintf('%d copies this month', $copies) : sprintf('%d views this month', $views),
];
return $payload;
})
->filter()
->values()
->all();
}
}
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyPromptPack;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyInteractionService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyPromptPackController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyInteractionService $interactions,
)
{
}
public function index(Request $request): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$packs = AcademyPromptPack::query()
->active()
->published()
->latest('published_at')
->paginate(12)
->withQueryString();
$packs->getCollection()->transform(fn (AcademyPromptPack $pack): array => $this->access->packPayload($pack, $request->user()));
$seo = app(SeoFactory::class)
->collectionListing(
'Academy Prompt Packs — Skinbase',
'Preview bundled prompt packs for Skinbase wallpapers, worlds, mascots, and editorial workflows.',
route('academy.packs.index'),
)
->toArray();
return Inertia::render('Academy/List', [
'pageType' => 'packs',
'title' => 'Prompt packs',
'description' => 'Curated prompt bundles with free previews and premium pack access tiers.',
'seo' => $seo,
'items' => $packs,
'filters' => [],
'categories' => [],
'pricingUrl' => route('academy.pricing'),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::PROMPT_PACK_LIBRARY,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_packs_index',
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('academy');
}
public function show(Request $request, string $slug): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$pack = AcademyPromptPack::query()
->with(['prompts.category'])
->active()
->published()
->where('slug', $slug)
->firstOrFail();
$payload = $this->access->packPayload($pack, $request->user(), true);
$seo = app(SeoFactory::class)->collectionPage(
$pack->title . ' — Skinbase Academy',
Str::limit((string) ($pack->excerpt ?? $pack->description ?? ''), 160, '...'),
route('academy.packs.show', ['slug' => $pack->slug]),
$pack->cover_image,
)->toArray();
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::PROMPT_PACK, (int) $pack->id);
return Inertia::render('Academy/Show', [
'pageType' => 'pack',
'item' => $payload,
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::PROMPT_PACK,
'contentId' => (int) $pack->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_pack_show',
'isPremium' => (string) ($pack->access_level ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('academy');
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyPromptTemplate;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyProgressService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class AcademyPromptSaveController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyProgressService $progress,
) {
}
public function store(Request $request, AcademyPromptTemplate $prompt): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless($this->access->canAccessPrompt($request->user(), $prompt), 403);
$this->progress->savePrompt($request->user(), $prompt);
return response()->json([
'ok' => true,
'saved' => true,
]);
}
public function destroy(Request $request, AcademyPromptTemplate $prompt): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless($this->access->canAccessPrompt($request->user(), $prompt), 403);
$this->progress->unsavePrompt($request->user(), $prompt);
return response()->json([
'ok' => true,
'saved' => false,
]);
}
}
@@ -6,11 +6,19 @@ namespace App\Http\Controllers\Admin;
use App\Enums\UserRole; use App\Enums\UserRole;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\AuthAuditLog;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\Report;
use App\Models\Story; use App\Models\Story;
use App\Models\Upload;
use App\Models\User; use App\Models\User;
use App\Support\Moderation\ReportTargetResolver;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -32,6 +40,252 @@ final class AdminController extends Controller
]); ]);
} }
public function dailyActivity(Request $request, ReportTargetResolver $reportTargets): Response
{
$selectedDate = $this->resolveActivityDate($request);
$periodStart = $selectedDate->copy()->startOfDay();
$periodEnd = $selectedDate->copy()->endOfDay();
$users = User::query()
->select('id', 'name', 'username', 'email', 'role', 'created_at')
->whereBetween('created_at', [$periodStart, $periodEnd])
->orderByDesc('created_at')
->limit(25)
->get()
->map(fn (User $user): array => [
'id' => (int) $user->id,
'name' => (string) $user->name,
'username' => $user->username,
'email' => (string) $user->email,
'role' => (string) $user->role,
'created_at' => optional($user->created_at)?->toISOString(),
])
->values();
$artworks = Artwork::query()
->with('user:id,name,username')
->select('id', 'title', 'artwork_status', 'created_at', 'user_id', 'hash', 'thumb_ext')
->whereBetween('created_at', [$periodStart, $periodEnd])
->orderByDesc('created_at')
->limit(25)
->get()
->map(fn (Artwork $artwork): array => [
'id' => (int) $artwork->id,
'title' => (string) ($artwork->title ?? 'Untitled artwork'),
'status' => (string) ($artwork->artwork_status ?? 'unknown'),
'thumb' => $artwork->thumbUrl('sm') ?? null,
'created_at' => optional($artwork->created_at)?->toISOString(),
'user' => $artwork->user ? [
'id' => (int) $artwork->user->id,
'name' => (string) $artwork->user->name,
'username' => $artwork->user->username,
] : null,
])
->values();
$stories = Story::query()
->with('creator:id,name,username')
->select('id', 'title', 'status', 'created_at', 'published_at', 'creator_id')
->whereBetween('created_at', [$periodStart, $periodEnd])
->orderByDesc('created_at')
->limit(25)
->get()
->map(fn (Story $story): array => [
'id' => (int) $story->id,
'title' => (string) ($story->title ?? 'Untitled story'),
'status' => (string) ($story->status ?? 'draft'),
'created_at' => optional($story->created_at)?->toISOString(),
'published_at' => optional($story->published_at)?->toISOString(),
'creator' => $story->creator ? [
'id' => (int) $story->creator->id,
'name' => (string) $story->creator->name,
'username' => $story->creator->username,
] : null,
])
->values();
$uploads = Schema::hasTable('uploads')
? Upload::query()
->select('id', 'user_id', 'type', 'status', 'processing_state', 'title', 'created_at', 'moderation_status', 'moderated_at', 'moderated_by', 'moderation_note')
->where(function ($query) use ($periodStart, $periodEnd): void {
$query->whereBetween('created_at', [$periodStart, $periodEnd])
->orWhereBetween('moderated_at', [$periodStart, $periodEnd]);
})
->orderByDesc('created_at')
->limit(40)
->get()
->map(fn (Upload $upload): array => [
'id' => (string) $upload->id,
'user_id' => $upload->user_id !== null ? (int) $upload->user_id : null,
'title' => (string) ($upload->title ?? 'Untitled upload'),
'type' => (string) ($upload->type ?? 'unknown'),
'status' => (string) ($upload->status ?? 'unknown'),
'processing_state' => (string) ($upload->processing_state ?? 'unknown'),
'moderation_status' => (string) ($upload->moderation_status ?? 'unknown'),
'created_at' => optional($upload->created_at)?->toISOString(),
'moderated_at' => optional($upload->moderated_at)?->toISOString(),
'moderated_by' => $upload->moderated_by !== null ? (int) $upload->moderated_by : null,
'moderation_note' => $upload->moderation_note,
])
->values()
: collect();
$reports = Schema::hasTable('reports')
? Report::query()
->with(['reporter:id,username', 'lastModeratedBy:id,username'])
->where(function ($query) use ($periodStart, $periodEnd): void {
$query->whereBetween('created_at', [$periodStart, $periodEnd])
->orWhereBetween('last_moderated_at', [$periodStart, $periodEnd]);
})
->orderByDesc('created_at')
->limit(30)
->get()
->map(fn (Report $report): array => [
'id' => (int) $report->id,
'status' => (string) $report->status,
'reason' => (string) $report->reason,
'target_type' => (string) $report->target_type,
'target_id' => (int) $report->target_id,
'created_at' => optional($report->created_at)?->toISOString(),
'last_moderated_at' => optional($report->last_moderated_at)?->toISOString(),
'moderator_note' => $report->moderator_note,
'reporter' => $report->reporter ? [
'id' => (int) $report->reporter->id,
'username' => (string) $report->reporter->username,
] : null,
'last_moderated_by' => $report->lastModeratedBy ? [
'id' => (int) $report->lastModeratedBy->id,
'username' => (string) $report->lastModeratedBy->username,
] : null,
'target' => $reportTargets->summarize($report),
])
->values()
: collect();
$usernameRequests = Schema::hasTable('username_approval_requests')
? (function () use ($periodStart, $periodEnd) {
$requestColumns = Schema::getColumnListing('username_approval_requests');
$selects = [
'requests.id',
'requests.user_id',
'requests.requested_username',
'requests.status',
'requests.created_at',
'users.username as current_username',
'users.name as current_name',
];
if (in_array('context', $requestColumns, true)) {
$selects[] = 'requests.context';
}
if (in_array('similar_to', $requestColumns, true)) {
$selects[] = 'requests.similar_to';
}
if (in_array('review_note', $requestColumns, true)) {
$selects[] = 'requests.review_note';
}
if (in_array('reviewed_at', $requestColumns, true)) {
$selects[] = 'requests.reviewed_at';
}
$query = DB::table('username_approval_requests as requests')
->leftJoin('users', 'users.id', '=', 'requests.user_id')
->select($selects)
->where(function (Builder $query) use ($periodStart, $periodEnd, $requestColumns): void {
$query->whereBetween('requests.created_at', [$periodStart, $periodEnd]);
if (in_array('reviewed_at', $requestColumns, true)) {
$query->orWhereBetween('requests.reviewed_at', [$periodStart, $periodEnd]);
}
})
->orderByDesc('requests.created_at')
->limit(30);
return $query
->get()
->map(fn ($row): array => [
'id' => (int) $row->id,
'user_id' => $row->user_id !== null ? (int) $row->user_id : null,
'requested_username' => (string) $row->requested_username,
'status' => (string) ($row->status ?? 'pending'),
'context' => $row->context ?? null,
'similar_to' => $row->similar_to ?? null,
'reason' => $row->review_note ?? null,
'created_at' => $this->serializeDatabaseTimestamp($row->created_at),
'reviewed_at' => $this->serializeDatabaseTimestamp($row->reviewed_at ?? null),
'current_username' => $row->current_username,
'current_name' => $row->current_name,
])
->values();
})()
: collect();
$authEvents = Schema::hasTable('auth_audit_logs')
? AuthAuditLog::query()
->with('user:id,name,username,email,role')
->select('id', 'user_id', 'event_type', 'identifier', 'status', 'reason', 'ip', 'created_at')
->whereBetween('created_at', [$periodStart, $periodEnd])
->orderByDesc('created_at')
->limit(30)
->get()
->map(fn (AuthAuditLog $log): array => [
'id' => (int) $log->id,
'event_type' => (string) $log->event_type,
'identifier' => $log->identifier,
'status' => (string) $log->status,
'reason' => $log->reason,
'ip' => $log->ip,
'created_at' => optional($log->created_at)?->toISOString(),
'user' => $log->user ? [
'id' => (int) $log->user->id,
'name' => (string) $log->user->name,
'username' => $log->user->username,
'email' => (string) $log->user->email,
'role' => (string) $log->user->role,
] : null,
])
->values()
: collect();
return Inertia::render('Admin/DailyActivity', [
'selectedDate' => $selectedDate->toDateString(),
'summary' => [
'new_users' => $users->count(),
'new_artworks' => $artworks->count(),
'new_stories' => $stories->count(),
'upload_events' => $uploads->count(),
'report_events' => $reports->count(),
'username_events' => $usernameRequests->count(),
'auth_events' => $authEvents->count(),
'moderated_uploads' => $uploads->filter(fn (array $upload): bool => ! empty($upload['moderated_at']))->count(),
'moderated_reports' => $reports->filter(fn (array $report): bool => ! empty($report['last_moderated_at']))->count(),
],
'queues' => [
'pending_uploads' => Schema::hasTable('uploads')
? Upload::query()->where('status', 'draft')->where('moderation_status', 'pending')->count()
: 0,
'open_reports' => Schema::hasTable('reports')
? Report::query()->where('status', 'open')->count()
: 0,
'pending_username_requests' => Schema::hasTable('username_approval_requests')
? DB::table('username_approval_requests')->where('status', 'pending')->count()
: 0,
],
'sections' => [
'users' => $users,
'artworks' => $artworks,
'stories' => $stories,
'uploads' => $uploads,
'reports' => $reports,
'username_requests' => $usernameRequests,
'auth_events' => $authEvents,
],
]);
}
// ── Users ───────────────────────────────────────────────────────────────── // ── Users ─────────────────────────────────────────────────────────────────
public function users(Request $request): Response public function users(Request $request): Response
@@ -157,4 +411,115 @@ final class AdminController extends Controller
'settings' => [], 'settings' => [],
]); ]);
} }
public function authAudit(Request $request): Response
{
abort_unless($request->user()?->isAdmin(), 403, 'Only admins can access this area.');
$search = $request->string('search')->trim()->toString();
$eventType = $request->string('event')->trim()->toString();
$status = $request->string('status')->trim()->toString();
$query = AuthAuditLog::query()
->with('user:id,name,username,email,role')
->latest('created_at')
->latest('id');
if ($search !== '') {
$query->where(function ($builder) use ($search): void {
$builder
->where('identifier', 'like', "%{$search}%")
->orWhere('ip', 'like', "%{$search}%")
->orWhere('reason', 'like', "%{$search}%")
->orWhereHas('user', function ($userQuery) use ($search): void {
$userQuery
->where('name', 'like', "%{$search}%")
->orWhere('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
});
}
if ($eventType !== '' && $eventType !== 'all') {
$query->where('event_type', $eventType);
}
if ($status !== '' && $status !== 'all') {
$query->where('status', $status);
}
$logs = $query->paginate(50)->withQueryString()->through(function (AuthAuditLog $log): array {
return [
'id' => $log->id,
'event_type' => $log->event_type,
'identifier' => $log->identifier,
'status' => $log->status,
'reason' => $log->reason,
'ip' => $log->ip,
'user_agent' => $log->user_agent,
'metadata' => $log->metadata ?? [],
'created_at' => $log->created_at,
'user' => $log->user ? [
'id' => $log->user->id,
'name' => $log->user->name,
'username' => $log->user->username,
'email' => $log->user->email,
'role' => $log->user->role,
] : null,
];
});
return Inertia::render('Admin/AuthAudit', [
'logs' => $logs,
'filters' => [
'search' => $search,
'event' => $eventType,
'status' => $status,
],
'eventOptions' => [
['value' => 'all', 'label' => 'All events'],
['value' => 'login', 'label' => 'Login'],
['value' => 'register', 'label' => 'Register'],
['value' => 'forgot_password', 'label' => 'Forgot password'],
['value' => 'reset_password', 'label' => 'Reset password'],
],
'statusOptions' => [
['value' => 'all', 'label' => 'All statuses'],
['value' => 'success', 'label' => 'Success'],
['value' => 'failed', 'label' => 'Failed'],
],
]);
}
private function resolveActivityDate(Request $request): Carbon
{
$date = $request->string('date')->trim()->toString();
if ($date === '') {
return today();
}
try {
return Carbon::createFromFormat('Y-m-d', $date)->startOfDay();
} catch (\Throwable) {
return today();
}
}
private function serializeDatabaseTimestamp(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
if ($value instanceof Carbon) {
return $value->toISOString();
}
try {
return Carbon::parse((string) $value)->toISOString();
} catch (\Throwable) {
return null;
}
}
} }
@@ -30,6 +30,7 @@ final class ArtworkTagController extends Controller
$queueConnection = (string) config('queue.default', 'sync'); $queueConnection = (string) config('queue.default', 'sync');
$visionEnabled = (bool) config('vision.enabled', true); $visionEnabled = (bool) config('vision.enabled', true);
$autoTaggingEnabled = (bool) config('vision.auto_tagging.enabled', false);
$queuedCount = 0; $queuedCount = 0;
$failedCount = 0; $failedCount = 0;
@@ -56,7 +57,7 @@ final class ArtworkTagController extends Controller
$triggered = false; $triggered = false;
$shouldTrigger = request()->boolean('trigger', false); $shouldTrigger = request()->boolean('trigger', false);
if ($shouldTrigger && $visionEnabled && ! empty($artwork->hash) && $queuedCount === 0) { if ($shouldTrigger && $visionEnabled && $autoTaggingEnabled && ! empty($artwork->hash) && $queuedCount === 0) {
AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash); AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash);
$triggered = true; $triggered = true;
$queuedCount = max(1, $queuedCount); $queuedCount = max(1, $queuedCount);
@@ -89,6 +90,7 @@ final class ArtworkTagController extends Controller
'queued_jobs' => $queuedCount, 'queued_jobs' => $queuedCount,
'failed_jobs' => $failedCount, 'failed_jobs' => $failedCount,
'triggered' => $triggered, 'triggered' => $triggered,
'auto_tagging_enabled' => $autoTaggingEnabled,
'ai_tag_count' => (int) $tags->where('is_ai', true)->count(), 'ai_tag_count' => (int) $tags->where('is_ai', true)->count(),
'total_tag_count' => (int) $tags->count(), 'total_tag_count' => (int) $tags->count(),
], ],
@@ -11,11 +11,21 @@ use App\Services\ThumbnailPresenter;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Contracts\Pagination\Paginator;
class LatestCommentsApiController extends Controller class LatestCommentsApiController extends Controller
{ {
private const PER_PAGE = 20; private const PER_PAGE = 20;
private function paginationMeta(Paginator $paginator): array
{
return [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'has_more' => $paginator->hasMorePages(),
];
}
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$type = $request->query('type', 'all'); $type = $request->query('type', 'all');
@@ -30,7 +40,19 @@ class LatestCommentsApiController extends Controller
return response()->json(['error' => 'Unauthenticated'], 401); return response()->json(['error' => 'Unauthenticated'], 401);
} }
$query = ArtworkComment::with(['user', 'user.profile', 'artwork']) $query = ArtworkComment::query()
->select([
'artwork_comments.id',
'artwork_comments.artwork_id',
'artwork_comments.user_id',
'artwork_comments.content',
'artwork_comments.created_at',
])
->with([
'user:id,username,name',
'user.profile:user_id,avatar_hash',
'artwork:id,title,slug,hash,file_path,file_name',
])
->whereHas('artwork', function ($q) { ->whereHas('artwork', function ($q) {
$q->public()->published()->whereNull('deleted_at'); $q->public()->published()->whereNull('deleted_at');
}) })
@@ -54,15 +76,21 @@ class LatestCommentsApiController extends Controller
$cacheKey = 'comments.latest.all.page1'; $cacheKey = 'comments.latest.all.page1';
$ttl = 120; // 2 minutes $ttl = 120; // 2 minutes
$paginator = Cache::remember($cacheKey, $ttl, fn () => $query->paginate(self::PER_PAGE)); $paginator = Cache::remember($cacheKey, $ttl, fn () => $query
->orderByDesc('artwork_comments.id')
->simplePaginate(self::PER_PAGE));
} else { } else {
$paginator = $query->paginate(self::PER_PAGE); $paginator = $query
->orderByDesc('artwork_comments.id')
->simplePaginate(self::PER_PAGE);
} }
break; break;
} }
if (! isset($paginator)) { if (! isset($paginator)) {
$paginator = $query->paginate(self::PER_PAGE); $paginator = $query
->orderByDesc('artwork_comments.id')
->simplePaginate(self::PER_PAGE);
} }
$items = $paginator->getCollection()->map(function (ArtworkComment $c) { $items = $paginator->getCollection()->map(function (ArtworkComment $c) {
@@ -101,13 +129,7 @@ class LatestCommentsApiController extends Controller
return response()->json([ return response()->json([
'data' => $items, 'data' => $items,
'meta' => [ 'meta' => $this->paginationMeta($paginator),
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'has_more' => $paginator->hasMorePages(),
],
]); ]);
} }
} }
@@ -53,7 +53,12 @@ class LinkPreviewController extends Controller
return response()->json(['error' => 'Invalid URL.'], 422); return response()->json(['error' => 'Invalid URL.'], 422);
} }
// Resolve hostname and block private/loopback IPs (SSRF protection) // Resolve hostname and block private/loopback IPs (SSRF protection).
// NOTE: This check is not atomic with Guzzle's own DNS resolution — a
// DNS rebinding attack could theoretically pass this check and then
// resolve to an internal IP when Guzzle makes the actual request.
// Risk is low (requires attacker-controlled DNS with very short TTL),
// but this is a known limitation of the current approach.
$resolved = gethostbyname($host); $resolved = gethostbyname($host);
if ($this->isBlockedIp($resolved)) { if ($this->isBlockedIp($resolved)) {
return response()->json(['error' => 'URL not allowed.'], 422); return response()->json(['error' => 'URL not allowed.'], 422);
@@ -30,12 +30,7 @@ class PostTrendingFeedController extends Controller
$result = $this->trendingService->getTrending($viewer, $page); $result = $this->trendingService->getTrending($viewer, $page);
$formatted = array_map( return response()->json($result);
fn ($post) => $this->feedService->formatPost($post, $viewer),
$result['data'],
);
return response()->json(['data' => array_values($formatted), 'meta' => $result['meta']]);
} }
public function hashtag(Request $request, string $tag): JsonResponse public function hashtag(Request $request, string $tag): JsonResponse
@@ -13,9 +13,11 @@ use App\Support\UsernamePolicy;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Pagination\Cursor;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use UnexpectedValueException;
/** /**
* ProfileApiController * ProfileApiController
@@ -59,8 +61,23 @@ final class ProfileApiController extends Controller
$query = $this->applyArtworkSort($query, $sort); $query = $this->applyArtworkSort($query, $sort);
$perPage = 24; $perPage = 24;
$paginator = $query->cursorPaginate($perPage); $cursor = Cursor::fromEncoded($request->input('cursor'));
try {
$paginator = (clone $query)->cursorPaginate($perPage, ['*'], 'cursor', $cursor);
} catch (UnexpectedValueException) {
$originalCursor = $request->query('cursor');
$request->query->remove('cursor');
try {
$paginator = (clone $query)->cursorPaginate($perPage, ['*'], 'cursor', null);
} finally {
if ($originalCursor !== null) {
$request->query->set('cursor', $originalCursor);
}
}
}
$data = collect($paginator->items()) $data = collect($paginator->items())
->map(fn (Artwork $art) => $this->mapArtworkCardPayload($art)) ->map(fn (Artwork $art) => $this->mapArtworkCardPayload($art))
@@ -196,14 +213,15 @@ final class ProfileApiController extends Controller
return $query return $query
->leftJoin('artwork_stats as profile_artwork_stats', 'profile_artwork_stats.artwork_id', '=', 'artworks.id') ->leftJoin('artwork_stats as profile_artwork_stats', 'profile_artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*') ->select('artworks.*')
->orderByDesc($statsColumn) ->selectRaw('COALESCE(' . $statsColumn . ', 0) as cursor_sort_value')
->orderByDesc('artworks.published_at') ->orderByDesc('cursor_sort_value')
->orderByDesc('artworks.id'); ->orderByDesc('published_at')
->orderByDesc('id');
} }
return $query return $query
->orderByDesc('artworks.published_at') ->orderByDesc('published_at')
->orderByDesc('artworks.id'); ->orderByDesc('id');
} }
/** /**
+29 -4
View File
@@ -47,7 +47,9 @@ use App\Uploads\Exceptions\DraftQuotaException;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\Group; use App\Models\Group;
use App\Services\GroupArtworkReviewService; use App\Services\GroupArtworkReviewService;
use App\Support\ArtworkDescriptionContentValidator;
use App\Services\Worlds\WorldSubmissionService; use App\Services\Worlds\WorldSubmissionService;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Str; use Illuminate\Support\Str;
final class UploadController extends Controller final class UploadController extends Controller
@@ -237,10 +239,18 @@ final class UploadController extends Controller
} }
// Derivatives are available now; dispatch AI auto-tagging. // Derivatives are available now; dispatch AI auto-tagging.
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit(); if ((bool) config('vision.auto_tagging.enabled', false)) {
DetectArtworkMaturityJob::dispatch($artworkId, $validated->hash)->afterCommit(); AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit();
GenerateArtworkEmbeddingJob::dispatch($artworkId, $validated->hash)->afterCommit(); }
AnalyzeArtworkAiAssistJob::dispatch($artworkId)->afterCommit(); if ((bool) config('vision.upload.maturity.enabled', false)) {
DetectArtworkMaturityJob::dispatch($artworkId, $validated->hash)->afterCommit();
}
if ((bool) config('vision.upload.embeddings.enabled', true)) {
GenerateArtworkEmbeddingJob::dispatch($artworkId, $validated->hash)->afterCommit();
}
if ((bool) config('vision.upload.ai_assist.enabled', false)) {
AnalyzeArtworkAiAssistJob::dispatch($artworkId)->afterCommit();
}
return UploadSessionStatus::PROCESSED; return UploadSessionStatus::PROCESSED;
}); });
@@ -534,6 +544,8 @@ final class UploadController extends Controller
'nsfw' => ['nullable', 'boolean'], 'nsfw' => ['nullable', 'boolean'],
]); ]);
$this->ensureValidArtworkDescription($validated);
$updates = []; $updates = [];
foreach (['title', 'category_id', 'description', 'tags', 'license', 'nsfw'] as $field) { foreach (['title', 'category_id', 'description', 'tags', 'license', 'nsfw'] as $field) {
if (array_key_exists($field, $validated)) { if (array_key_exists($field, $validated)) {
@@ -635,6 +647,8 @@ final class UploadController extends Controller
'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'], 'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'],
]); ]);
$this->ensureValidArtworkDescription($validated);
$mode = $validated['mode'] ?? 'now'; $mode = $validated['mode'] ?? 'now';
$visibility = $validated['visibility'] ?? 'public'; $visibility = $validated['visibility'] ?? 'public';
@@ -814,6 +828,8 @@ final class UploadController extends Controller
'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'], 'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'],
]); ]);
$this->ensureValidArtworkDescription($validated);
if (! ctype_digit($id)) { if (! ctype_digit($id)) {
return response()->json(['message' => 'Artwork review submission requires an artwork draft id.'], Response::HTTP_UNPROCESSABLE_ENTITY); return response()->json(['message' => 'Artwork review submission requires an artwork draft id.'], Response::HTTP_UNPROCESSABLE_ENTITY);
} }
@@ -842,4 +858,13 @@ final class UploadController extends Controller
'group_review_status' => (string) $artwork->group_review_status, 'group_review_status' => (string) $artwork->group_review_status,
], Response::HTTP_OK); ], Response::HTTP_OK);
} }
private function ensureValidArtworkDescription(array $validated): void
{
foreach (ArtworkDescriptionContentValidator::errors($validated['description'] ?? null) as $message) {
throw ValidationException::withMessages([
'description' => [$message],
]);
}
}
} }
@@ -30,15 +30,14 @@ final class UploadVisionSuggestController extends Controller
public function __invoke(int $id, Request $request): JsonResponse public function __invoke(int $id, Request $request): JsonResponse
{ {
if (! $this->vision->isEnabled()) {
return response()->json(['tags' => [], 'vision_enabled' => false]);
}
$artwork = Artwork::query()->findOrFail($id); $artwork = Artwork::query()->findOrFail($id);
$this->authorizeOrNotFound($request->user(), $artwork); $this->authorizeOrNotFound($request->user(), $artwork);
$limit = (int) $request->query('limit', 10);
return response()->json($this->vision->suggestTags($artwork, $this->normalizer, $limit)); return response()->json([
'tags' => [],
'vision_enabled' => false,
'reason' => 'disabled',
]);
} }
private function authorizeOrNotFound(mixed $user, Artwork $artwork): void private function authorizeOrNotFound(mixed $user, Artwork $artwork): void
@@ -200,7 +200,7 @@ final class ArtworkDownloadController extends Controller
$host = preg_replace('/^www\./', '', $host) ?? ''; $host = preg_replace('/^www\./', '', $host) ?? '';
if ($host === '' || in_array($host, ['localhost', '127.0.0.1'], true) || str_ends_with($host, '.test')) { if ($host === '' || in_array($host, ['localhost', '127.0.0.1'], true) || str_ends_with($host, '.test')) {
return 'skinbase.top'; return 'skinbase.org';
} }
return $host; return $host;
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Artwork;
use App\Services\Enhance\EnhanceService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use RuntimeException;
final class ArtworkEnhanceController extends Controller
{
public function __construct(
private readonly EnhanceService $enhanceService,
) {
}
public function store(Request $request, int $artwork): RedirectResponse
{
$artwork = Artwork::query()->findOrFail($artwork);
$actor = $request->user();
abort_unless($actor !== null, 403);
$isOwner = (int) $artwork->user_id === (int) $actor->id;
$isStaff = $actor->isAdmin() || $actor->isModerator();
abort_unless($isOwner || $isStaff, 403);
$validated = $request->validate([
'scale' => ['required', 'integer', Rule::in((array) config('enhance.allowed_scales', [2, 4]))],
'mode' => ['required', 'string', Rule::in((array) config('enhance.allowed_modes', ['standard', 'artwork', 'photo', 'illustration']))],
]);
try {
$job = $this->enhanceService->createFromArtwork($actor, $artwork, $validated);
} catch (RuntimeException $exception) {
return redirect()
->route('enhance.create', ['artwork' => $artwork->id])
->withErrors([
'source' => $exception->getMessage(),
]);
}
return redirect()
->route('enhance.show', ['enhanceJob' => $job])
->with('success', 'Artwork enhance job created.');
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest; use App\Http\Requests\Auth\LoginRequest;
use App\Services\Auth\AuthAuditLogger;
use App\Services\Security\CaptchaVerifier; use App\Services\Security\CaptchaVerifier;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -17,6 +18,7 @@ class AuthenticatedSessionController extends Controller
*/ */
public function __construct( public function __construct(
private readonly CaptchaVerifier $captchaVerifier, private readonly CaptchaVerifier $captchaVerifier,
private readonly AuthAuditLogger $authAuditLogger,
) { ) {
} }
@@ -35,9 +37,22 @@ class AuthenticatedSessionController extends Controller
{ {
$request->authenticate(); $request->authenticate();
$user = $request->authenticatedUser();
$this->authAuditLogger->log(
eventType: 'login',
request: $request,
status: 'success',
identifier: (string) $request->input('email'),
user: $user,
metadata: [
'via' => $request->authenticatedViaUsername() ? 'username' : 'email',
'remember' => $request->boolean('remember'),
],
);
$request->session()->regenerate(); $request->session()->regenerate();
$user = $request->authenticatedUser();
if ($user && $request->authenticatedViaUsername() && ! $user->hasCompletedOnboarding()) { if ($user && $request->authenticatedViaUsername() && ! $user->hasCompletedOnboarding()) {
$request->session()->put('username_login_upgrade', true); $request->session()->put('username_login_upgrade', true);
@@ -4,17 +4,24 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User; use App\Models\User;
use App\Services\Auth\AuthAuditLogger;
use Illuminate\Auth\Events\PasswordReset; use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rules; use Illuminate\Validation\Rules;
use Illuminate\View\View; use Illuminate\View\View;
class NewPasswordController extends Controller class NewPasswordController extends Controller
{ {
public function __construct(
private readonly AuthAuditLogger $authAuditLogger,
) {
}
/** /**
* Display the password reset view. * Display the password reset view.
*/ */
@@ -30,17 +37,36 @@ class NewPasswordController extends Controller
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
$request->validate([ $validator = Validator::make($request->all(), [
'token' => ['required'], 'token' => ['required'],
'email' => ['required', 'email'], 'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Rules\Password::defaults()], 'password' => ['required', 'confirmed', Rules\Password::defaults()],
]); ]);
// Here we will attempt to reset the user's password. If it is successful we if ($validator->fails()) {
// will update the password on an actual user model and persist it to the $this->authAuditLogger->log(
// database. Otherwise we will parse the error and return the response. eventType: 'reset_password',
request: $request,
status: 'failed',
reason: 'validation_failed',
identifier: (string) $request->input('email'),
metadata: ['fields' => array_keys($validator->errors()->toArray())],
);
$validator->validate();
}
$validated = $validator->validated();
$email = strtolower(trim((string) $validated['email']));
$user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
$status = Password::reset( $status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'), [
'email' => $email,
'password' => (string) $validated['password'],
'password_confirmation' => (string) $request->input('password_confirmation'),
'token' => (string) $validated['token'],
],
function (User $user) use ($request) { function (User $user) use ($request) {
$user->forceFill([ $user->forceFill([
'password' => Hash::make($request->password), 'password' => Hash::make($request->password),
@@ -51,12 +77,20 @@ class NewPasswordController extends Controller
} }
); );
// If the password was successfully reset, we will redirect the user back to $success = $status === Password::PASSWORD_RESET;
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message. $this->authAuditLogger->log(
return $status == Password::PASSWORD_RESET eventType: 'reset_password',
request: $request,
status: $success ? 'success' : 'failed',
reason: strtolower((string) $status),
identifier: $email,
user: $user,
);
return $success
? redirect()->route('login')->with('status', __($status)) ? redirect()->route('login')->with('status', __($status))
: back()->withInput($request->only('email')) : back()->withInput(['email' => $email])
->withErrors(['email' => __($status)]); ->withErrors(['email' => __($status)]);
} }
} }
@@ -3,13 +3,21 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Auth\AuthAuditLogger;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Validator;
use Illuminate\View\View; use Illuminate\View\View;
class PasswordResetLinkController extends Controller class PasswordResetLinkController extends Controller
{ {
public function __construct(
private readonly AuthAuditLogger $authAuditLogger,
) {
}
/** /**
* Display the password reset link request view. * Display the password reset link request view.
*/ */
@@ -25,20 +33,45 @@ class PasswordResetLinkController extends Controller
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
$request->validate([ $validator = Validator::make($request->all(), [
'email' => ['required', 'email'], 'email' => ['required', 'email'],
]); ]);
// We will send the password reset link to this user. Once we have attempted if ($validator->fails()) {
// to send the link, we will examine the response then see the message we $this->authAuditLogger->log(
// need to show to the user. Finally, we'll send out a proper response. eventType: 'forgot_password',
request: $request,
status: 'failed',
reason: 'validation_failed',
identifier: (string) $request->input('email'),
metadata: ['fields' => array_keys($validator->errors()->toArray())],
);
$validator->validate();
}
$validated = $validator->validated();
$email = strtolower(trim((string) $validated['email']));
$user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
$status = Password::sendResetLink( $status = Password::sendResetLink(
$request->only('email') ['email' => $email]
); );
return $status == Password::RESET_LINK_SENT $success = $status === Password::RESET_LINK_SENT;
$this->authAuditLogger->log(
eventType: 'forgot_password',
request: $request,
status: $success ? 'success' : 'failed',
reason: strtolower((string) $status),
identifier: $email,
user: $user,
);
return $success
? back()->with('status', __($status)) ? back()->with('status', __($status))
: back()->withInput($request->only('email')) : back()->withInput(['email' => $email])
->withErrors(['email' => __($status)]); ->withErrors(['email' => __($status)]);
} }
} }
@@ -6,6 +6,7 @@ use App\Jobs\SendVerificationEmailJob;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\EmailSendEvent; use App\Models\EmailSendEvent;
use App\Models\User; use App\Models\User;
use App\Services\Auth\AuthAuditLogger;
use App\Services\Auth\DisposableEmailService; use App\Services\Auth\DisposableEmailService;
use App\Services\Auth\RegistrationVerificationTokenService; use App\Services\Auth\RegistrationVerificationTokenService;
use App\Services\Security\CaptchaVerifier; use App\Services\Security\CaptchaVerifier;
@@ -15,6 +16,8 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\View\View; use Illuminate\View\View;
@@ -25,6 +28,7 @@ class RegisteredUserController extends Controller
private readonly TurnstileVerifier $turnstileVerifier, private readonly TurnstileVerifier $turnstileVerifier,
private readonly DisposableEmailService $disposableEmailService, private readonly DisposableEmailService $disposableEmailService,
private readonly RegistrationVerificationTokenService $verificationTokenService, private readonly RegistrationVerificationTokenService $verificationTokenService,
private readonly AuthAuditLogger $authAuditLogger,
) )
{ {
} }
@@ -34,10 +38,17 @@ class RegisteredUserController extends Controller
*/ */
public function create(Request $request): View public function create(Request $request): View
{ {
$cspNonce = $this->resolveCspNonce($request);
return view('auth.register', [ return view('auth.register', [
'prefillEmail' => (string) $request->query('email', ''), 'prefillEmail' => (string) $request->query('email', ''),
'requiresCaptcha' => $this->shouldRequireCaptcha($request->ip()), 'page_canonical' => route('register'),
'captcha' => $this->captchaVerifier->frontendConfig(), 'turnstile' => [
'enabled' => $this->turnstileVerifier->isEnabled(),
'siteKey' => $this->turnstileVerifier->siteKey(),
'scriptUrl' => $this->turnstileVerifier->scriptUrl(),
'cspNonce' => $cspNonce,
],
]); ]);
} }
@@ -59,41 +70,67 @@ class RegisteredUserController extends Controller
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
$turnstileResponse = (string) ($request->input('turnstile_token') ?: $request->input('cf-turnstile-response', ''));
$rules = [ $rules = [
'email' => ['required', 'string', 'lowercase', 'email', 'max:255'], 'email' => ['required', 'string', 'lowercase', 'email', 'max:255'],
'website' => ['nullable', 'max:0'], 'website' => ['nullable', 'max:0'],
'turnstile_token' => ['nullable', 'string'],
'cf-turnstile-response' => [$this->turnstileVerifier->isEnabled() ? 'required_without:turnstile_token' : 'nullable', 'string'],
]; ];
$rules[$this->captchaVerifier->inputName()] = ['nullable', 'string'];
$validated = $request->validate($rules); $validator = Validator::make($request->all(), $rules);
if ($validator->fails()) {
$errors = $validator->errors()->toArray();
if (array_key_exists('cf-turnstile-response', $errors) && ! array_key_exists('turnstile_token', $errors)) {
$errors['turnstile_token'] = $errors['cf-turnstile-response'];
unset($errors['cf-turnstile-response']);
}
$this->authAuditLogger->log(
eventType: 'register',
request: $request,
status: 'failed',
reason: 'validation_failed',
identifier: (string) $request->input('email'),
metadata: ['fields' => array_keys($errors)],
);
throw ValidationException::withMessages($errors);
}
$validated = $validator->validated();
$email = strtolower(trim((string) $validated['email'])); $email = strtolower(trim((string) $validated['email']));
$ip = $request->ip(); $ip = $request->ip();
$this->trackRegisterAttempt($ip); $this->trackRegisterAttempt($ip);
if ($this->shouldRequireCaptcha($ip)) { if ($this->turnstileVerifier->isEnabled() && ! $this->turnstileVerifier->verify($turnstileResponse, $ip)) {
$verified = $this->captchaVerifier->verify( $this->authAuditLogger->log(
(string) $request->input($this->captchaVerifier->inputName(), ''), eventType: 'register',
$ip request: $request,
status: 'failed',
reason: 'captcha_failed',
identifier: $email,
); );
if ($this->turnstileVerifier->isEnabled()) { return back()
$verified = $this->turnstileVerifier->verify( ->withInput($request->except('website', 'turnstile_token', 'cf-turnstile-response'))
(string) $request->input($this->captchaVerifier->inputName(), ''), ->withErrors(['turnstile_token' => 'Security verification failed. Please try again.']);
$ip
);
}
if (! $verified) {
return back()
->withInput($request->except('website'))
->withErrors(['captcha' => 'Captcha verification failed. Please try again.']);
}
} }
if ($this->disposableEmailService->isDisposableEmail($email)) { if ($this->disposableEmailService->isDisposableEmail($email)) {
$this->logEmailEvent($email, $ip, null, 'blocked', 'disposable'); $this->logEmailEvent($email, $ip, null, 'blocked', 'disposable');
$this->authAuditLogger->log(
eventType: 'register',
request: $request,
status: 'failed',
reason: 'disposable_email',
identifier: $email,
);
return back() return back()
->withInput($request->except('website')) ->withInput($request->except('website'))
@@ -103,6 +140,15 @@ class RegisteredUserController extends Controller
$user = User::query()->where('email', $email)->first(); $user = User::query()->where('email', $email)->first();
if ($user && $user->hasCompletedOnboarding()) { if ($user && $user->hasCompletedOnboarding()) {
$this->authAuditLogger->log(
eventType: 'register',
request: $request,
status: 'failed',
reason: 'email_exists',
identifier: $email,
user: $user,
);
return back() return back()
->withInput($request->except('website')) ->withInput($request->except('website'))
->withErrors(['email' => 'An account with this email already exists.']); ->withErrors(['email' => 'An account with this email already exists.']);
@@ -136,6 +182,15 @@ class RegisteredUserController extends Controller
Auth::login($user); Auth::login($user);
$this->authAuditLogger->log(
eventType: 'register',
request: $request,
status: 'success',
reason: $user->wasRecentlyCreated ? 'user_created' : 'resume_onboarding',
identifier: $email,
user: $user,
);
$needsPasswordSetup = strtolower((string) ($user->onboarding_step ?? '')) !== 'password' $needsPasswordSetup = strtolower((string) ($user->onboarding_step ?? '')) !== 'password'
|| (bool) $user->needs_password_reset; || (bool) $user->needs_password_reset;
@@ -213,23 +268,6 @@ class RegisteredUserController extends Controller
]); ]);
} }
private function shouldRequireCaptcha(?string $ip): bool
{
if (! $this->captchaVerifier->isEnabled()) {
if (! $this->turnstileVerifier->isEnabled()) {
return false;
}
if (! (bool) config('registration.enable_turnstile', true)) {
return false;
}
return $this->turnstileVerifier->isEnabled() && $this->shouldRequireCaptchaForIp($ip);
}
return $this->shouldRequireCaptchaForIp($ip);
}
private function shouldRequireCaptchaForIp(?string $ip): bool private function shouldRequireCaptchaForIp(?string $ip): bool
{ {
if (! $this->captchaVerifier->isEnabled() && ! $this->turnstileVerifier->isEnabled()) { if (! $this->captchaVerifier->isEnabled() && ! $this->turnstileVerifier->isEnabled()) {
@@ -336,4 +374,28 @@ class RegisteredUserController extends Controller
return $remaining >= 0 ? 0 : abs((int) $remaining); return $remaining >= 0 ? 0 : abs((int) $remaining);
} }
private function resolveCspNonce(Request $request): ?string
{
$candidates = [
$request->attributes->get('csp_nonce'),
$request->attributes->get('cspNonce'),
$request->headers->get('X-CSP-Nonce'),
$request->server('HTTP_X_CSP_NONCE'),
];
foreach ($candidates as $candidate) {
if (! is_string($candidate)) {
continue;
}
$nonce = trim($candidate);
if ($nonce !== '') {
return $nonce;
}
}
return null;
}
} }
@@ -10,23 +10,46 @@ use App\Services\ThumbnailPresenter;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Contracts\Pagination\Paginator;
class LatestCommentsController extends Controller class LatestCommentsController extends Controller
{ {
private const PER_PAGE = 20; private const PER_PAGE = 20;
private function paginationMeta(Paginator $paginator): array
{
return [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'has_more' => $paginator->hasMorePages(),
];
}
public function index(Request $request) public function index(Request $request)
{ {
$page_title = 'Latest Comments'; $page_title = 'Latest Comments';
// Build initial (first-page, type=all) data for React SSR props // Build initial (first-page, type=all) data for React SSR props
$initialData = Cache::remember('comments.latest.all.page1', 120, function () { $initialData = Cache::remember('comments.latest.all.page1', 120, function () {
return ArtworkComment::with(['user', 'user.profile', 'artwork']) return ArtworkComment::query()
->select([
'artwork_comments.id',
'artwork_comments.artwork_id',
'artwork_comments.user_id',
'artwork_comments.content',
'artwork_comments.created_at',
])
->with([
'user:id,username,name',
'user.profile:user_id,avatar_hash',
'artwork:id,title,slug,hash,file_path,file_name',
])
->whereHas('artwork', function ($q) { ->whereHas('artwork', function ($q) {
$q->public()->published()->whereNull('deleted_at'); $q->public()->published()->whereNull('deleted_at');
}) })
->orderByDesc('artwork_comments.created_at') ->orderByDesc('artwork_comments.created_at')
->paginate(self::PER_PAGE); ->orderByDesc('artwork_comments.id')
->simplePaginate(self::PER_PAGE);
}); });
$items = $initialData->getCollection()->map(function (ArtworkComment $c) { $items = $initialData->getCollection()->map(function (ArtworkComment $c) {
@@ -64,13 +87,7 @@ class LatestCommentsController extends Controller
$props = [ $props = [
'initialComments' => $items->values()->all(), 'initialComments' => $items->values()->all(),
'initialMeta' => [ 'initialMeta' => $this->paginationMeta($initialData),
'current_page' => $initialData->currentPage(),
'last_page' => $initialData->lastPage(),
'per_page' => $initialData->perPage(),
'total' => $initialData->total(),
'has_more' => $initialData->hasMorePages(),
],
'isAuthenticated' => (bool) auth()->user(), 'isAuthenticated' => (bool) auth()->user(),
]; ];
+208
View File
@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Artwork;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
final class EnhanceController extends Controller
{
public function __construct(
private readonly EnhanceService $enhanceService,
) {
}
public function index(Request $request): Response
{
$this->authorize('viewAny', EnhanceJob::class);
$jobs = EnhanceJob::query()
->where('user_id', (int) $request->user()->id)
->with('artwork:id,title,slug')
->latest('id')
->paginate(12)
->withQueryString()
->through(fn (EnhanceJob $job): array => $this->serializeJobListItem($job));
$latestCompleted = EnhanceJob::query()
->where('user_id', (int) $request->user()->id)
->where('status', EnhanceJob::STATUS_COMPLETED)
->latest('finished_at')
->limit(4)
->get()
->map(fn (EnhanceJob $job): array => $this->serializeJobListItem($job))
->all();
return Inertia::render('Enhance/Index', [
'title' => 'Skinbase Enhance',
'jobs' => $jobs,
'latestCompleted' => $latestCompleted,
'createUrl' => route('enhance.create'),
'indexUrl' => route('enhance.index'),
'dailyLimit' => (int) config('enhance.daily_limit', 10),
'enhanceConfig' => $this->enhanceService->frontendConfig(),
]);
}
public function create(Request $request): Response
{
$this->authorize('create', EnhanceJob::class);
$selectedArtwork = null;
if (($artworkId = (int) $request->integer('artwork')) > 0) {
$artwork = Artwork::query()
->select(['id', 'user_id', 'title', 'slug'])
->findOrFail($artworkId);
$actor = $request->user();
abort_unless($actor !== null, 403);
$isOwner = (int) $artwork->user_id === (int) $actor->id;
$isStaff = $actor->isAdmin() || $actor->isModerator();
abort_unless($isOwner || $isStaff, 403);
$selectedArtwork = [
'id' => $artwork->id,
'title' => $artwork->title,
'show_url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]),
'store_url' => route('artworks.enhance.store', ['artwork' => $artwork->id]),
];
}
return Inertia::render('Enhance/Create', [
'title' => 'Skinbase Enhance',
'options' => $this->optionsPayload(),
'storeUrl' => route('enhance.store'),
'indexUrl' => route('enhance.index'),
'maxUploadMb' => (int) config('enhance.max_upload_mb', 20),
'selectedArtwork' => $selectedArtwork,
'enhanceConfig' => $this->enhanceService->frontendConfig(),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorize('create', EnhanceJob::class);
$validated = $request->validate([
'image' => ['required', 'file', 'mimetypes:image/jpeg,image/png,image/webp', 'max:' . ((int) config('enhance.max_upload_mb', 20) * 1024)],
'scale' => ['required', 'integer', Rule::in((array) config('enhance.allowed_scales', [2, 4]))],
'mode' => ['required', 'string', Rule::in((array) config('enhance.allowed_modes', ['standard', 'artwork', 'photo', 'illustration']))],
]);
$job = $this->enhanceService->createFromUpload($request->user(), $request->file('image'), $validated);
return redirect()
->route('enhance.show', ['enhanceJob' => $job])
->with('success', 'Enhance job created.');
}
public function show(EnhanceJob $enhanceJob): Response
{
$this->authorize('view', $enhanceJob);
$enhanceJob->loadMissing('artwork:id,title,slug');
return Inertia::render('Enhance/Show', [
'title' => 'Enhance Job',
'job' => $this->serializeJobDetail($enhanceJob),
'indexUrl' => route('enhance.index'),
'createUrl' => route('enhance.create'),
'enhanceConfig' => $this->enhanceService->frontendConfig(),
]);
}
public function retry(EnhanceJob $enhanceJob): RedirectResponse
{
$this->authorize('retry', $enhanceJob);
$job = $this->enhanceService->retry($enhanceJob);
return redirect()
->route('enhance.show', ['enhanceJob' => $job])
->with('success', 'Enhance job queued again.');
}
public function destroy(EnhanceJob $enhanceJob): RedirectResponse
{
$this->authorize('delete', $enhanceJob);
$this->enhanceService->delete($enhanceJob);
return redirect()
->route('enhance.index')
->with('success', 'Enhance job deleted.');
}
private function optionsPayload(): array
{
return [
'modes' => array_map(fn (string $mode): array => [
'value' => $mode,
'label' => ucfirst($mode),
], (array) config('enhance.allowed_modes', [])),
'scales' => array_map(fn (int $scale): array => [
'value' => $scale,
'label' => $scale . 'x',
], array_map('intval', (array) config('enhance.allowed_scales', []))),
];
}
private function serializeJobListItem(EnhanceJob $job): array
{
return [
'id' => $job->id,
'status' => (string) $job->status,
'engine' => (string) $job->engine,
'mode' => (string) $job->mode,
'scale' => (int) $job->scale,
'source_url' => $job->sourceUrl(),
'output_url' => $job->outputUrl(),
'preview_url' => $job->previewUrl(),
'input_width' => (int) ($job->input_width ?? 0),
'input_height' => (int) ($job->input_height ?? 0),
'output_width' => (int) ($job->output_width ?? 0),
'output_height' => (int) ($job->output_height ?? 0),
'error_message' => $job->error_message,
'processing_seconds' => $job->processing_seconds,
'created_at' => optional($job->created_at)?->toIso8601String(),
'finished_at' => optional($job->finished_at)?->toIso8601String(),
'show_url' => route('enhance.show', ['enhanceJob' => $job]),
'artwork' => $job->artwork ? [
'id' => $job->artwork->id,
'title' => $job->artwork->title,
'slug' => $job->artwork->slug,
'url' => route('art.show', ['id' => $job->artwork->id, 'slug' => $job->artwork->slug]),
] : null,
];
}
private function serializeJobDetail(EnhanceJob $job): array
{
return $this->serializeJobListItem($job) + [
'input_filesize' => (int) ($job->input_filesize ?? 0),
'input_mime' => $job->input_mime,
'output_filesize' => (int) ($job->output_filesize ?? 0),
'output_mime' => $job->output_mime,
'metadata' => $job->metadata ?? [],
'queued_at' => optional($job->queued_at)?->toIso8601String(),
'started_at' => optional($job->started_at)?->toIso8601String(),
'deleted_at' => optional($job->deleted_at)?->toIso8601String(),
'expires_at' => optional($job->expires_at)?->toIso8601String(),
'retry_url' => route('enhance.retry', ['enhanceJob' => $job]),
'delete_url' => route('enhance.destroy', ['enhanceJob' => $job]),
'download_url' => $job->outputUrl(),
'can_retry' => auth()->user()?->can('retry', $job) ?? false,
'can_delete' => auth()->user()?->can('delete', $job) ?? false,
];
}
}
@@ -98,7 +98,7 @@ class ForumController extends Controller
$thread->loadMissing([ $thread->loadMissing([
'category:id,name,slug', 'category:id,name,slug',
'user:id,name', 'user:id,name,username',
'user.profile:user_id,avatar_hash', 'user.profile:user_id,avatar_hash',
]); ]);
@@ -116,7 +116,7 @@ class ForumController extends Controller
$opPost = ForumPost::query() $opPost = ForumPost::query()
->where('thread_id', $thread->id) ->where('thread_id', $thread->id)
->with([ ->with([
'user:id,name', 'user:id,name,username',
'user.profile:user_id,avatar_hash', 'user.profile:user_id,avatar_hash',
'attachments:id,post_id,file_path,file_size,mime_type,width,height', 'attachments:id,post_id,file_path,file_size,mime_type,width,height',
]) ])
@@ -128,7 +128,7 @@ class ForumController extends Controller
->where('thread_id', $thread->id) ->where('thread_id', $thread->id)
->when($opPost, fn ($query) => $query->where('id', '!=', $opPost->id)) ->when($opPost, fn ($query) => $query->where('id', '!=', $opPost->id))
->with([ ->with([
'user:id,name', 'user:id,name,username',
'user.profile:user_id,avatar_hash', 'user.profile:user_id,avatar_hash',
'attachments:id,post_id,file_path,file_size,mime_type,width,height', 'attachments:id,post_id,file_path,file_size,mime_type,width,height',
]) ])
@@ -148,7 +148,7 @@ class ForumController extends Controller
if ($quotePostId > 0) { if ($quotePostId > 0) {
$quotedPost = ForumPost::query() $quotedPost = ForumPost::query()
->where('thread_id', $thread->id) ->where('thread_id', $thread->id)
->with('user:id,name') ->with('user:id,name,username')
->find($quotePostId); ->find($quotePostId);
} }
+18 -1
View File
@@ -79,6 +79,7 @@ class GroupController extends Controller
{ {
$this->authorize('view', $group); $this->authorize('view', $group);
$section = in_array($section, ['overview', 'artworks', 'collections', 'members', 'about', 'posts', 'projects', 'releases', 'challenges', 'events', 'activity'], true) ? $section : 'overview';
$viewer = $request->user(); $viewer = $request->user();
$group->loadMissing('owner.profile'); $group->loadMissing('owner.profile');
$members = collect($this->memberships->mapMembers($group, $viewer)) $members = collect($this->memberships->mapMembers($group, $viewer))
@@ -89,7 +90,8 @@ class GroupController extends Controller
return Inertia::render('Group/GroupShow', [ return Inertia::render('Group/GroupShow', [
'group' => $groupPayload, 'group' => $groupPayload,
'section' => in_array($section, ['overview', 'artworks', 'collections', 'members', 'about', 'posts', 'projects', 'releases', 'challenges', 'events', 'activity'], true) ? $section : 'overview', 'section' => $section,
'seo' => $this->seoPayload($group, $section),
'featuredArtworks' => $this->groups->featuredArtworkCards($group), 'featuredArtworks' => $this->groups->featuredArtworkCards($group),
'artworks' => $this->groups->publicArtworkCards($group), 'artworks' => $this->groups->publicArtworkCards($group),
'featuredCollections' => $this->groups->featuredCollectionCards($group, $viewer), 'featuredCollections' => $this->groups->featuredCollectionCards($group, $viewer),
@@ -140,4 +142,19 @@ class GroupController extends Controller
{ {
return $this->show($request, $group, 'activity'); return $this->show($request, $group, 'activity');
} }
private function seoPayload(Group $group, string $section): array
{
$canonical = $section === 'overview'
? route('groups.show', ['group' => $group])
: route('groups.section', ['group' => $group, 'section' => $section]);
$sectionLabel = $section === 'overview' ? '' : ' '.ucfirst($section);
return [
'title' => trim($group->name.$sectionLabel.' - Skinbase'),
'description' => $group->headline ?: $group->bio ?: 'Skinbase group',
'canonical' => $canonical,
'og_url' => $canonical,
];
}
} }
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Internal;
use App\Http\Controllers\Controller;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceStorageService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Throwable;
final class EnhanceSourceController extends Controller
{
public function __construct(
private readonly EnhanceStorageService $storage,
) {
}
public function show(Request $request, EnhanceJob $enhanceJob): Response
{
abort_unless($request->hasValidSignature(), 403);
abort_unless($this->storage->isEnhancePath($enhanceJob->source_path), 404);
try {
$binary = $this->storage->fetchSourceBinary($enhanceJob);
} catch (Throwable) {
abort(404);
}
return response($binary, 200, [
'Content-Type' => trim((string) ($enhanceJob->input_mime ?: 'application/octet-stream')),
'Content-Length' => (string) strlen($binary),
'Cache-Control' => 'private, max-age=60',
'Content-Disposition' => 'inline; filename="enhance-source-' . $enhanceJob->id . '"',
]);
}
}
+15 -7
View File
@@ -79,19 +79,27 @@ class UserController extends Controller
} }
} }
$allowedLegacyMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if ($request->hasFile('personal_picture')) { if ($request->hasFile('personal_picture')) {
$f = $request->file('personal_picture'); $f = $request->file('personal_picture');
$name = $user->id . '.' . $f->getClientOriginalExtension(); if (in_array($f->getMimeType(), $allowedLegacyMimes, true)) {
$f->move(public_path('user-picture'), $name); $ext = $f->guessExtension() ?: 'jpg';
$profileUpdates['cover_image'] = $name; $name = $user->id . '.' . $ext;
$user->picture = $name; $f->move(public_path('user-picture'), $name);
$profileUpdates['cover_image'] = $name;
$user->picture = $name;
}
} }
if ($request->hasFile('emotion_icon')) { if ($request->hasFile('emotion_icon')) {
$f = $request->file('emotion_icon'); $f = $request->file('emotion_icon');
$name = $user->id . '.' . $f->getClientOriginalExtension(); if (in_array($f->getMimeType(), $allowedLegacyMimes, true)) {
$f->move(public_path('emotion'), $name); $ext = $f->guessExtension() ?: 'jpg';
$user->eicon = $name; $name = $user->id . '.' . $ext;
$f->move(public_path('emotion'), $name);
$user->eicon = $name;
}
} }
// Save core user fields // Save core user fields
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Moderation;
use App\Http\Controllers\Controller;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class ModerationEnhanceController extends Controller
{
public function __construct(
private readonly EnhanceService $enhanceService,
) {
}
public function index(Request $request): Response
{
$filters = [
'status' => trim((string) $request->query('status', 'all')),
'engine' => trim((string) $request->query('engine', 'all')),
'mode' => trim((string) $request->query('mode', 'all')),
'scale' => trim((string) $request->query('scale', 'all')),
'user' => trim((string) $request->query('user', '')),
'date_from' => trim((string) $request->query('date_from', '')),
'date_to' => trim((string) $request->query('date_to', '')),
];
$jobs = EnhanceJob::query()
->with(['user:id,name,username', 'artwork:id,title,slug'])
->when($filters['status'] !== '' && $filters['status'] !== 'all', fn ($query) => $query->where('status', $filters['status']))
->when($filters['engine'] !== '' && $filters['engine'] !== 'all', fn ($query) => $query->where('engine', $filters['engine']))
->when($filters['mode'] !== '' && $filters['mode'] !== 'all', fn ($query) => $query->where('mode', $filters['mode']))
->when($filters['scale'] !== '' && $filters['scale'] !== 'all', fn ($query) => $query->where('scale', (int) $filters['scale']))
->when($filters['user'] !== '', function ($query) use ($filters): void {
$query->whereHas('user', function ($userQuery) use ($filters): void {
$userQuery
->where('name', 'like', '%' . $filters['user'] . '%')
->orWhere('username', 'like', '%' . $filters['user'] . '%');
});
})
->when($filters['date_from'] !== '', fn ($query) => $query->whereDate('created_at', '>=', $filters['date_from']))
->when($filters['date_to'] !== '', fn ($query) => $query->whereDate('created_at', '<=', $filters['date_to']))
->latest('id')
->paginate(20)
->withQueryString()
->through(fn (EnhanceJob $job): array => $this->serializeJob($job));
return Inertia::render('Moderation/Enhance/Index', [
'title' => 'Enhance Jobs',
'jobs' => $jobs,
'filters' => $filters,
'options' => [
'statuses' => ['all', 'pending', 'queued', 'processing', 'completed', 'failed', 'cancelled', 'expired'],
'engines' => ['all', 'stub', 'external_worker'],
'modes' => array_merge(['all'], (array) config('enhance.allowed_modes', [])),
'scales' => array_merge(['all'], array_map('intval', (array) config('enhance.allowed_scales', []))),
],
'indexUrl' => route('admin.enhance.index'),
'enhanceConfig' => $this->enhanceService->frontendConfig(),
])->rootView('moderation');
}
public function show(EnhanceJob $enhanceJob): Response
{
$enhanceJob->loadMissing(['user:id,name,username', 'artwork:id,title,slug']);
return Inertia::render('Moderation/Enhance/Show', [
'title' => 'Enhance Job #' . $enhanceJob->id,
'job' => $this->serializeJob($enhanceJob, true),
'indexUrl' => route('admin.enhance.index'),
'enhanceConfig' => $this->enhanceService->frontendConfig(),
])->rootView('moderation');
}
public function retry(EnhanceJob $enhanceJob): RedirectResponse
{
$this->authorize('retry', $enhanceJob);
$job = $this->enhanceService->retry($enhanceJob);
return redirect()
->route('admin.enhance.show', ['enhanceJob' => $job])
->with('success', 'Enhance job queued again.');
}
public function markFailed(Request $request, EnhanceJob $enhanceJob): RedirectResponse
{
$this->authorize('markFailed', $enhanceJob);
$job = $this->enhanceService->markFailedByModerator($enhanceJob, $request->user());
return redirect()
->route('admin.enhance.show', ['enhanceJob' => $job])
->with('success', 'Enhance job marked as failed.');
}
public function destroy(EnhanceJob $enhanceJob): RedirectResponse
{
$this->authorize('delete', $enhanceJob);
$this->enhanceService->delete($enhanceJob);
return redirect()
->route('admin.enhance.index')
->with('success', 'Enhance job deleted.');
}
private function serializeJob(EnhanceJob $job, bool $detailed = false): array
{
return [
'id' => $job->id,
'status' => (string) $job->status,
'engine' => (string) $job->engine,
'mode' => (string) $job->mode,
'scale' => (int) $job->scale,
'source_url' => $job->sourceUrl(),
'output_url' => $job->outputUrl(),
'preview_url' => $job->previewUrl(),
'input_width' => (int) ($job->input_width ?? 0),
'input_height' => (int) ($job->input_height ?? 0),
'input_filesize' => (int) ($job->input_filesize ?? 0),
'input_mime' => $job->input_mime,
'output_width' => (int) ($job->output_width ?? 0),
'output_height' => (int) ($job->output_height ?? 0),
'output_filesize' => (int) ($job->output_filesize ?? 0),
'output_mime' => $job->output_mime,
'processing_seconds' => $job->processing_seconds,
'error_message' => $job->error_message,
'metadata' => $job->metadata ?? [],
'created_at' => optional($job->created_at)?->toIso8601String(),
'queued_at' => optional($job->queued_at)?->toIso8601String(),
'started_at' => optional($job->started_at)?->toIso8601String(),
'finished_at' => optional($job->finished_at)?->toIso8601String(),
'expires_at' => optional($job->expires_at)?->toIso8601String(),
'user' => $job->user ? [
'id' => $job->user->id,
'name' => $job->user->name,
'username' => $job->user->username,
] : null,
'artwork' => $job->artwork ? [
'id' => $job->artwork->id,
'title' => $job->artwork->title,
'slug' => $job->artwork->slug,
'url' => route('art.show', ['id' => $job->artwork->id, 'slug' => $job->artwork->slug]),
] : null,
'show_url' => route('admin.enhance.show', ['enhanceJob' => $job]),
'download_url' => $job->outputUrl(),
'retry_url' => route('admin.enhance.retry', ['enhanceJob' => $job]),
'mark_failed_url' => route('admin.enhance.mark-failed', ['enhanceJob' => $job]),
'delete_url' => route('admin.enhance.destroy', ['enhanceJob' => $job]),
'can_retry' => $job->status === EnhanceJob::STATUS_FAILED,
'can_mark_failed' => in_array($job->status, [EnhanceJob::STATUS_PENDING, EnhanceJob::STATUS_QUEUED, EnhanceJob::STATUS_PROCESSING], true),
'detailed' => $detailed,
];
}
}
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Moderation;
use App\Http\Controllers\Controller;
use App\Models\StaffApplication;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class StaffApplicationsController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->query('q', '')),
'topic' => trim((string) $request->query('topic', 'all')),
];
$query = StaffApplication::query()->latest('created_at');
if ($filters['q'] !== '') {
$search = $filters['q'];
$query->where(function ($builder) use ($search): void {
$builder
->where('name', 'like', '%' . $search . '%')
->orWhere('email', 'like', '%' . $search . '%')
->orWhere('role', 'like', '%' . $search . '%')
->orWhere('message', 'like', '%' . $search . '%');
});
}
if ($filters['topic'] !== '' && $filters['topic'] !== 'all') {
$query->where('topic', $filters['topic']);
}
$items = $query
->paginate(20)
->withQueryString()
->through(fn (StaffApplication $application): array => $this->serializeApplication($application));
$stats = [
'total' => StaffApplication::query()->count(),
'applications' => StaffApplication::query()->where('topic', 'application')->count(),
'bug' => StaffApplication::query()->where('topic', 'bug')->count(),
'contact' => StaffApplication::query()->where('topic', 'contact')->count(),
'other' => StaffApplication::query()->whereNotIn('topic', ['application', 'bug', 'contact'])->count(),
];
$topics = StaffApplication::query()
->select('topic')
->distinct()
->orderBy('topic')
->pluck('topic')
->values()
->all();
return Inertia::render('Moderation/StaffApplications/Index', [
'title' => 'Staff Applications',
'items' => $items,
'filters' => $filters,
'stats' => $stats,
'topics' => $topics,
'endpoints' => [
'index' => route('admin.staff-applications.index'),
],
])->rootView('moderation');
}
public function show(StaffApplication $staffApplication): Response
{
return Inertia::render('Moderation/StaffApplications/Show', [
'title' => 'Staff Application',
'item' => $this->serializeApplication($staffApplication, true),
'backUrl' => route('admin.staff-applications.index'),
])->rootView('moderation');
}
private function serializeApplication(StaffApplication $application, bool $detailed = false): array
{
return [
'id' => (string) $application->id,
'topic' => (string) ($application->topic ?: 'contact'),
'name' => (string) ($application->name ?: 'Unknown'),
'email' => (string) ($application->email ?: ''),
'role' => $application->role,
'portfolio' => $application->portfolio,
'message' => $application->message,
'ip' => $application->ip,
'user_agent' => $application->user_agent,
'created_at' => optional($application->created_at)?->toIso8601String(),
'payload' => $detailed ? ($application->payload ?? []) : [],
'show_url' => route('admin.staff-applications.show', ['staffApplication' => $application]),
];
}
}
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Moderation;
use App\Http\Controllers\Controller;
use App\Models\Story;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class StoriesController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->query('q', '')),
'status' => trim((string) $request->query('status', 'all')),
];
$query = Story::query()
->with('creator:id,name,username')
->latest('created_at')
->latest('id');
if ($filters['q'] !== '') {
$search = $filters['q'];
$query->where(function ($builder) use ($search): void {
$builder
->where('title', 'like', '%' . $search . '%')
->orWhere('slug', 'like', '%' . $search . '%')
->orWhereHas('creator', function ($creatorQuery) use ($search): void {
$creatorQuery
->where('name', 'like', '%' . $search . '%')
->orWhere('username', 'like', '%' . $search . '%');
});
});
}
if ($filters['status'] !== '' && $filters['status'] !== 'all') {
$query->where('status', $filters['status']);
}
$stories = $query
->paginate(24)
->withQueryString()
->through(fn (Story $story): array => $this->serializeStory($story));
$statsQuery = Story::query();
if ($filters['q'] !== '') {
$search = $filters['q'];
$statsQuery->where(function ($builder) use ($search): void {
$builder
->where('title', 'like', '%' . $search . '%')
->orWhere('slug', 'like', '%' . $search . '%')
->orWhereHas('creator', function ($creatorQuery) use ($search): void {
$creatorQuery
->where('name', 'like', '%' . $search . '%')
->orWhere('username', 'like', '%' . $search . '%');
});
});
}
$stats = [
'total' => (clone $statsQuery)->count(),
'published' => (clone $statsQuery)->where('status', 'published')->count(),
'draft' => (clone $statsQuery)->where('status', 'draft')->count(),
'scheduled' => (clone $statsQuery)->where('status', 'scheduled')->count(),
'pending_review' => (clone $statsQuery)->where('status', 'pending_review')->count(),
'archived' => (clone $statsQuery)->where('status', 'archived')->count(),
];
return Inertia::render('Moderation/Stories', [
'title' => 'Stories',
'stories' => $stories,
'filters' => $filters,
'stats' => $stats,
'endpoints' => [
'index' => route('admin.stories'),
],
])->rootView('moderation');
}
private function serializeStory(Story $story): array
{
return [
'id' => (int) $story->id,
'title' => (string) ($story->title ?: 'Untitled story'),
'slug' => (string) $story->slug,
'excerpt' => $story->excerpt,
'status' => (string) ($story->status ?: 'draft'),
'published_at' => optional($story->published_at)?->toIso8601String(),
'created_at' => optional($story->created_at)?->toIso8601String(),
'cover_url' => $story->coverUrl,
'public_url' => $story->url,
'open_url' => $story->status === 'published' ? $story->url : null,
'creator' => $story->creator ? [
'id' => (int) $story->creator->id,
'name' => (string) $story->creator->name,
'username' => (string) $story->creator->username,
] : null,
];
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Moderation\Traffic;
use App\Http\Controllers\Controller;
use App\Services\Traffic\OnlineVisitorRepository;
use Illuminate\Http\JsonResponse;
use Illuminate\View\View;
final class OnlineVisitorsController extends Controller
{
public function __construct(private readonly OnlineVisitorRepository $visitors)
{
}
public function index(): View
{
$summary = $this->visitors->summary();
$visitors = $this->visitors->all();
$activePages = $this->visitors->activePages();
return view('moderation.traffic.online', [
'summary' => $summary,
'visitors' => $visitors,
'activePages' => $activePages,
'generatedAt' => now()->toIso8601String(),
'dataUrl' => route('moderation.traffic.online.data'),
]);
}
public function data(): JsonResponse
{
return response()->json([
'summary' => $this->visitors->summary(),
'visitors' => $this->visitors->all(),
'active_pages' => $this->visitors->activePages(),
'generated_at' => now()->toIso8601String(),
]);
}
}
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Moderation;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
final class UsernameQueueController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->query('q', '')),
'status' => trim((string) $request->query('status', 'pending')),
];
$requestColumns = Schema::hasTable('username_approval_requests')
? Schema::getColumnListing('username_approval_requests')
: [];
$query = DB::table('username_approval_requests as requests')
->leftJoin('users', 'users.id', '=', 'requests.user_id')
->select([
'requests.id',
'requests.user_id',
'requests.requested_username',
'requests.status',
'requests.context',
'requests.similar_to',
'requests.review_note',
'requests.reviewed_at',
'requests.created_at',
'users.username as current_username',
'users.name as current_name',
])
->orderByDesc('requests.created_at');
if ($filters['status'] !== '' && $filters['status'] !== 'all') {
$query->where('requests.status', $filters['status']);
}
if ($filters['q'] !== '') {
$search = $filters['q'];
$query->where(function ($builder) use ($search): void {
$builder
->where('requests.requested_username', 'like', '%' . $search . '%')
->orWhere('requests.context', 'like', '%' . $search . '%')
->orWhere('users.username', 'like', '%' . $search . '%')
->orWhere('users.name', 'like', '%' . $search . '%');
});
}
$requests = $query->paginate(20)->withQueryString()->through(function ($row): array {
return [
'id' => (int) $row->id,
'user_id' => $row->user_id !== null ? (int) $row->user_id : null,
'requested_username' => (string) $row->requested_username,
'status' => (string) ($row->status ?? 'pending'),
'context' => $row->context ?? null,
'similar_to' => $row->similar_to ?? null,
'review_note' => $row->review_note ?? null,
'reviewed_at' => $this->serializeTimestamp($row->reviewed_at ?? null),
'created_at' => $this->serializeTimestamp($row->created_at ?? null),
'current_username' => $row->current_username,
'current_name' => $row->current_name,
'approve_url' => route('api.admin.usernames.approve', ['id' => $row->id]),
'reject_url' => route('api.admin.usernames.reject', ['id' => $row->id]),
];
});
$stats = [
'total' => Schema::hasTable('username_approval_requests') ? DB::table('username_approval_requests')->count() : 0,
'pending' => Schema::hasTable('username_approval_requests') ? DB::table('username_approval_requests')->where('status', 'pending')->count() : 0,
'approved' => Schema::hasTable('username_approval_requests') ? DB::table('username_approval_requests')->where('status', 'approved')->count() : 0,
'rejected' => Schema::hasTable('username_approval_requests') ? DB::table('username_approval_requests')->where('status', 'rejected')->count() : 0,
];
return Inertia::render('Moderation/UsernameQueue', [
'title' => 'Username Queue',
'requests' => $requests,
'stats' => $stats,
'filters' => $filters,
'options' => [
'statuses' => [
['value' => 'all', 'label' => 'All statuses'],
['value' => 'pending', 'label' => 'Pending'],
['value' => 'approved', 'label' => 'Approved'],
['value' => 'rejected', 'label' => 'Rejected'],
],
],
'endpoints' => [
'index' => route('admin.usernames'),
'refresh' => route('admin.usernames'),
],
])->rootView('moderation');
}
private function serializeTimestamp(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
try {
return \Illuminate\Support\Carbon::parse((string) $value)->toIso8601String();
} catch (\Throwable) {
return null;
}
}
}
+51 -41
View File
@@ -3,7 +3,6 @@
namespace App\Http\Controllers\News; namespace App\Http\Controllers\News;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\NewsArticleComment;
use App\Models\User; use App\Models\User;
use App\Services\News\NewsService; use App\Services\News\NewsService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -133,6 +132,32 @@ class NewsController extends Controller
] + $this->sidebarData()); ] + $this->sidebarData());
} }
// -----------------------------------------------------------------------
// Type page — /news/type/{type}
// -----------------------------------------------------------------------
public function type(Request $request, string $type): View
{
$typeLabels = \cPad\Plugins\News\Models\NewsArticle::TYPE_LABELS;
abort_unless(array_key_exists($type, $typeLabels), 404);
$label = $typeLabels[$type];
$perPage = config('news.articles_per_page', 12);
$articles = NewsArticle::with('author', 'category')
->published()
->where('type', $type)
->editorialOrder()
->paginate($perPage);
return view('news.type', [
'type' => $type,
'typeLabel' => $label,
'articles' => $articles,
] + $this->sidebarData());
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Article page — /news/{slug} // Article page — /news/{slug}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -147,40 +172,14 @@ class NewsController extends Controller
// Track view (once per session / IP) // Track view (once per session / IP)
$this->trackView($request, $article); $this->trackView($request, $article);
// Related articles (same category, excluding current) $articleData = $this->news->publicArticleShowData($article, $request->user());
$related = NewsArticle::with('author', 'category')
->published()
->when($article->category_id, fn ($q) => $q->where('category_id', $article->category_id))
->where('id', '!=', $article->id)
->editorialOrder()
->limit(config('news.related_limit', 4))
->get();
$comments = collect();
$commentsCount = 0;
if ($article->commentsAreEnabled()) {
$comments = NewsArticleComment::query()
->where('article_id', $article->id)
->whereNull('parent_id')
->where('status', 'visible')
->with(['user.profile'])
->orderBy('created_at')
->orderBy('id')
->get();
$commentsCount = (int) NewsArticleComment::query()
->where('article_id', $article->id)
->where('status', 'visible')
->count();
}
return view('news.show', [ return view('news.show', [
'article' => $article, 'article' => $article,
'related' => $related, 'related' => $articleData['related'],
'relatedEntities' => $this->news->resolveRelatedEntities($article, $request->user()), 'relatedEntities' => $articleData['relatedEntities'],
'comments' => $comments, 'comments' => $articleData['comments'],
'commentsCount' => $commentsCount, 'commentsCount' => $articleData['commentsCount'],
] + $this->sidebarData()); ] + $this->sidebarData());
} }
@@ -194,20 +193,31 @@ class NewsController extends Controller
$userId = Auth::id(); $userId = Auth::id();
$session = 'news_view_' . $article->id; $session = 'news_view_' . $article->id;
if ($request->session()->has($session)) { $canReadSession = $request->hasSession() && ! $request->attributes->get('skinbase.session_skipped');
if ($canReadSession && $request->session()->has($session)) {
return; return;
} }
NewsView::create([ try {
'article_id' => $article->id, NewsView::create([
'user_id' => $userId, 'article_id' => $article->id,
'ip' => $ip, 'user_id' => $userId,
'created_at' => now(), 'ip' => $ip,
]); 'created_at' => now(),
]);
$article->incrementViews(); $article->incrementViews();
} catch (\Illuminate\Database\QueryException $e) {
// Unique constraint violation — duplicate view, skip silently.
if (($e->errorInfo[1] ?? 0) !== 1062) {
throw $e;
}
}
$request->session()->put($session, true); if ($canReadSession) {
$request->session()->put($session, true);
}
} }
private function sidebarData(): array private function sidebarData(): array
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\News;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use cPad\Plugins\News\Models\NewsArticle; use cPad\Plugins\News\Models\NewsArticle;
class NewsRssController extends Controller class NewsRssController extends Controller
@@ -14,13 +15,17 @@ class NewsRssController extends Controller
*/ */
public function feed(): Response public function feed(): Response
{ {
$articles = NewsArticle::with('author', 'category') $ttl = max(60, (int) config('news.rss_cache_ttl', 300));
->published()
->orderByDesc('published_at')
->limit(config('news.rss_limit', 25))
->get();
$xml = $this->buildRss($articles); $xml = Cache::remember('news.rss.feed', $ttl, function (): string {
$articles = NewsArticle::with('author', 'category')
->published()
->orderByDesc('published_at')
->limit(config('news.rss_limit', 25))
->get();
return $this->buildRss($articles);
});
return response($xml, 200, [ return response($xml, 200, [
'Content-Type' => 'application/rss+xml; charset=UTF-8', 'Content-Type' => 'application/rss+xml; charset=UTF-8',
@@ -0,0 +1,628 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademyEvent;
use App\Models\AcademySearchLog;
use App\Services\Academy\AcademyAnalyticsContentResolver;
use App\Services\Academy\AcademyContentIntelligenceService;
use App\Services\Academy\AcademyPopularityService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\AcademyAnalytics\AcademyAnalyticsEventType;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyAdminAnalyticsController extends Controller
{
public function __construct(
private readonly AcademyPopularityService $popularity,
private readonly AcademyAnalyticsContentResolver $resolver,
private readonly AcademyContentIntelligenceService $intelligence,
) {}
public function overview(Request $request): Response
{
[$from, $to, $range] = $this->resolveDateRange($request);
$promptLibraryCurrent = $this->contentSummary(AcademyAnalyticsContentType::PROMPT_LIBRARY, $from, $to);
[$previousFrom, $previousTo] = $this->previousRange($from, $to);
$promptLibraryPrevious = $this->contentSummary(AcademyAnalyticsContentType::PROMPT_LIBRARY, $previousFrom, $previousTo);
$summary = $this->metricsQuery($from, $to)
->selectRaw('sum(views) as views, sum(unique_visitors) as unique_visitors, sum(user_views) as user_views, sum(guest_views) as guest_views, sum(subscriber_views) as subscriber_views, sum(prompt_copies) as prompt_copies, sum(likes) as likes, sum(saves) as saves, sum(completions) as completions, sum(starts) as starts, sum(upgrade_clicks) as upgrade_clicks')
->first();
return Inertia::render('Admin/Academy/AnalyticsOverview', [
'nav' => $this->nav(),
'range' => $this->rangePayload($range, $from, $to),
'stats' => [
'views' => (int) ($summary?->views ?? 0),
'uniqueVisitors' => (int) ($summary?->unique_visitors ?? 0),
'userViews' => (int) ($summary?->user_views ?? 0),
'guestViews' => (int) ($summary?->guest_views ?? 0),
'subscriberViews' => (int) ($summary?->subscriber_views ?? 0),
'promptCopies' => (int) ($summary?->prompt_copies ?? 0),
'likes' => (int) ($summary?->likes ?? 0),
'saves' => (int) ($summary?->saves ?? 0),
'lessonCompletions' => (int) ($summary?->completions ?? 0),
'courseStarts' => (int) ($summary?->starts ?? 0),
'upgradeClicks' => (int) ($summary?->upgrade_clicks ?? 0),
],
'promptLibraryTrend' => [
'current' => $promptLibraryCurrent,
'previous' => $promptLibraryPrevious,
'deltas' => [
'views' => $this->percentDelta((int) $promptLibraryCurrent['views'], (int) $promptLibraryPrevious['views']),
'uniqueVisitors' => $this->percentDelta((int) $promptLibraryCurrent['uniqueVisitors'], (int) $promptLibraryPrevious['uniqueVisitors']),
'engagedViews' => $this->percentDelta((int) $promptLibraryCurrent['engagedViews'], (int) $promptLibraryPrevious['engagedViews']),
'engagementRate' => $this->percentDelta((float) $promptLibraryCurrent['engagementRate'], (float) $promptLibraryPrevious['engagementRate']),
],
'range' => [
'current' => ['from' => $from->toDateString(), 'to' => $to->toDateString()],
'previous' => ['from' => $previousFrom->toDateString(), 'to' => $previousTo->toDateString()],
],
],
'popularPromptPeriodUsage' => $this->popularPromptPeriodUsage($from, $to),
'topContent' => $this->serializeContentRows($this->popularity->topContent($from, $to, 8)),
'topWeek' => $this->serializeContentRows($this->popularity->topContent(now()->subDays(6)->startOfDay(), now()->endOfDay(), 8)),
]);
}
public function content(Request $request): Response
{
return $this->renderContentPage($request, null, 'Content performance', 'Cross-module performance across prompts, lessons, courses, packs, and challenges.');
}
public function prompts(Request $request): Response
{
return $this->renderContentPage($request, AcademyAnalyticsContentType::PROMPT, 'Prompt analytics', 'Copy-heavy prompt performance, save rates, and upgrade interest.');
}
public function promptLibrary(Request $request): Response
{
return $this->renderContentPage($request, AcademyAnalyticsContentType::PROMPT_LIBRARY, 'Prompt library analytics', 'Discovery and engagement on the public /academy/prompts library page.');
}
public function lessons(Request $request): Response
{
return $this->renderContentPage($request, AcademyAnalyticsContentType::LESSON, 'Lesson analytics', 'Lesson engagement, starts, completions, and drop-off signals.');
}
public function courses(Request $request): Response
{
return $this->renderContentPage($request, AcademyAnalyticsContentType::COURSE, 'Course analytics', 'Course views, starts, completion progress, and upgrade intent.');
}
public function search(Request $request): Response
{
[$from, $to, $range] = $this->resolveDateRange($request);
$searchQuery = AcademySearchLog::query()->whereBetween('created_at', [$from, $to]);
$searchLogs = (clone $searchQuery)->latest('created_at')->limit(500)->get();
return Inertia::render('Admin/Academy/AnalyticsSearch', [
'nav' => $this->nav(),
'range' => $this->rangePayload($range, $from, $to),
'summary' => [
'searches' => (int) (clone $searchQuery)->count(),
'zeroResultSearches' => (int) (clone $searchQuery)->where('results_count', 0)->count(),
'loggedInSearches' => (int) (clone $searchQuery)->where('is_logged_in', true)->count(),
'subscriberSearches' => (int) (clone $searchQuery)->where('is_subscriber', true)->count(),
'searchesWithClicks' => (int) (clone $searchQuery)->whereNotNull('clicked_content_id')->count(),
],
'topSearches' => (clone $searchQuery)
->selectRaw('normalized_query, max(query) as query, count(*) as searches, sum(results_count = 0) as zero_result_hits, avg(results_count) as avg_results, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks')
->groupBy('normalized_query')
->orderByDesc('searches')
->limit(20)
->get()
->map(fn ($row): array => [
'query' => (string) ($row->query ?: $row->normalized_query),
'normalized_query' => (string) $row->normalized_query,
'searches' => (int) $row->searches,
'zero_result_hits' => (int) $row->zero_result_hits,
'avg_results' => round((float) $row->avg_results, 1),
'clicks' => (int) ($row->clicks ?? 0),
'click_through_rate' => (int) $row->searches > 0 ? round((((int) ($row->clicks ?? 0)) / (int) $row->searches) * 100, 1) : 0,
])
->all(),
'zeroResults' => (clone $searchQuery)
->selectRaw('normalized_query, max(query) as query, count(*) as searches')
->where('results_count', 0)
->groupBy('normalized_query')
->orderByDesc('searches')
->limit(20)
->get()
->map(fn ($row): array => [
'query' => (string) ($row->query ?: $row->normalized_query),
'searches' => (int) $row->searches,
])
->all(),
'lowClickThroughSearches' => (clone $searchQuery)
->selectRaw('normalized_query, max(query) as query, count(*) as searches, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks, avg(results_count) as avg_results')
->groupBy('normalized_query')
->havingRaw('count(*) >= 2')
->orderByRaw('case when count(*) = 0 then 1 else (sum(case when clicked_content_id is not null then 1 else 0 end) * 1.0 / count(*)) end asc')
->limit(20)
->get()
->map(fn ($row): array => [
'query' => (string) ($row->query ?: $row->normalized_query),
'searches' => (int) $row->searches,
'clicks' => (int) ($row->clicks ?? 0),
'avg_results' => round((float) $row->avg_results, 1),
'click_through_rate' => (int) $row->searches > 0 ? round((((int) ($row->clicks ?? 0)) / (int) $row->searches) * 100, 1) : 0,
])
->all(),
'highestClickThroughSearches' => (clone $searchQuery)
->selectRaw('normalized_query, max(query) as query, count(*) as searches, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks, avg(results_count) as avg_results')
->groupBy('normalized_query')
->havingRaw('count(*) >= 2')
->orderByRaw('(sum(case when clicked_content_id is not null then 1 else 0 end) * 1.0 / count(*)) desc')
->limit(20)
->get()
->map(fn ($row): array => [
'query' => (string) ($row->query ?: $row->normalized_query),
'searches' => (int) $row->searches,
'clicks' => (int) ($row->clicks ?? 0),
'avg_results' => round((float) $row->avg_results, 1),
'click_through_rate' => (int) $row->searches > 0 ? round((((int) ($row->clicks ?? 0)) / (int) $row->searches) * 100, 1) : 0,
])
->all(),
'searchesWithResultsNoClicks' => (clone $searchQuery)
->selectRaw('normalized_query, max(query) as query, count(*) as searches, avg(results_count) as avg_results')
->where('results_count', '>', 0)
->whereNull('clicked_content_id')
->groupBy('normalized_query')
->orderByDesc('searches')
->limit(20)
->get()
->map(fn ($row): array => [
'query' => (string) ($row->query ?: $row->normalized_query),
'searches' => (int) $row->searches,
'avg_results' => round((float) $row->avg_results, 1),
'clicks' => 0,
'click_through_rate' => 0,
])
->all(),
'topClickedResults' => (clone $searchQuery)
->selectRaw('clicked_content_type, clicked_content_id, count(*) as clicks')
->whereNotNull('clicked_content_type')
->whereNotNull('clicked_content_id')
->groupBy('clicked_content_type', 'clicked_content_id')
->orderByDesc('clicks')
->limit(20)
->get()
->map(fn ($row): array => [
'title' => $this->resolver->title((string) $row->clicked_content_type, (int) $row->clicked_content_id),
'content_type' => (string) $row->clicked_content_type,
'content_id' => (int) $row->clicked_content_id,
'clicks' => (int) $row->clicks,
])
->all(),
'filterUsage' => $this->summarizeSearchFilters($searchLogs),
'recentSearches' => (clone $searchQuery)
->latest('created_at')
->limit(25)
->get()
->map(fn (AcademySearchLog $log): array => [
'query' => (string) $log->query,
'results_count' => (int) $log->results_count,
'logged_in' => (bool) $log->is_logged_in,
'subscriber' => (bool) $log->is_subscriber,
'clicked_content_type' => $log->clicked_content_type,
'has_click' => $log->clicked_content_id !== null,
'created_at' => $log->created_at?->toISOString(),
])
->all(),
]);
}
public function intelligence(Request $request): Response
{
[$from, $to, $range] = $this->resolveDateRange($request, '30d');
$filters = [
'from' => $from,
'to' => $to,
'limit' => 25,
];
return Inertia::render('Admin/Academy/AnalyticsIntelligence', [
'nav' => $this->nav(),
'range' => $this->rangePayload($range, $from, $to),
'contentOpportunities' => $this->intelligence->getContentOpportunities($filters),
'searchGaps' => $this->intelligence->getSearchGaps($filters),
'promptInsights' => $this->intelligence->getPromptInsights($filters),
'lessonDropoffs' => $this->intelligence->getLessonDropoffs($filters),
'courseHealth' => $this->intelligence->getCourseHealth($filters),
'premiumInterest' => $this->intelligence->getPremiumInterest($filters),
'editorialRecommendations' => $this->intelligence->getEditorialRecommendations($filters),
]);
}
/**
* @param Collection<int, AcademySearchLog> $logs
* @return list<array<string, int|string>>
*/
private function summarizeSearchFilters(Collection $logs): array
{
$counts = [];
foreach ($logs as $log) {
$filters = is_array($log->filters) ? $log->filters : [];
foreach ($filters as $key => $value) {
if ($value === null || $value === '' || $key === 'q') {
continue;
}
$values = is_array($value) ? $value : [$value];
foreach ($values as $rawValue) {
$label = trim((string) $rawValue);
if ($label === '') {
continue;
}
$bucket = sprintf('%s:%s', $key, $label);
$counts[$bucket] = [
'filter' => (string) $key,
'value' => $label,
'uses' => (int) (($counts[$bucket]['uses'] ?? 0) + 1),
];
}
}
}
usort($counts, static fn (array $left, array $right): int => $right['uses'] <=> $left['uses']);
return array_slice(array_values($counts), 0, 20);
}
public function funnel(Request $request): Response
{
[$from, $to, $range] = $this->resolveDateRange($request);
$summary = $this->metricsQuery($from, $to)
->selectRaw('sum(unique_visitors) as unique_visitors, sum(premium_preview_views) as premium_preview_views, sum(upgrade_clicks) as upgrade_clicks, sum(starts) as starts, sum(completions) as completions')
->first();
$bestConverters = $this->metricsQuery($from, $to)
->selectRaw('content_type, content_id, sum(unique_visitors) as unique_visitors, sum(premium_preview_views) as premium_preview_views, sum(upgrade_clicks) as upgrade_clicks, sum(conversion_score) as conversion_score')
->groupBy('content_type', 'content_id')
->havingRaw('sum(upgrade_clicks) > 0')
->orderByDesc('conversion_score')
->limit(12)
->get();
return Inertia::render('Admin/Academy/AnalyticsFunnel', [
'nav' => $this->nav(),
'range' => $this->rangePayload($range, $from, $to),
'summary' => [
'academyVisitors' => (int) ($summary?->unique_visitors ?? 0),
'premiumPreviewViews' => (int) ($summary?->premium_preview_views ?? 0),
'upgradeClicks' => (int) ($summary?->upgrade_clicks ?? 0),
'starts' => (int) ($summary?->starts ?? 0),
'completions' => (int) ($summary?->completions ?? 0),
'checkoutStarts' => 0,
'subscriptions' => 0,
],
'bestConverters' => $this->serializeContentRows($bestConverters, includeConversion: true),
]);
}
private function renderContentPage(Request $request, ?string $forcedContentType, string $title, string $subtitle): Response
{
[$from, $to, $range] = $this->resolveDateRange($request);
$sort = (string) $request->query('sort', 'popularity_score');
$direction = strtolower((string) $request->query('direction', 'desc')) === 'asc' ? 'asc' : 'desc';
$access = trim((string) $request->query('access', ''));
$contentType = $forcedContentType ?: (trim((string) $request->query('content_type', '')) ?: null);
$query = $this->metricsQuery($from, $to)
->selectRaw('content_type, content_id, sum(views) as views, sum(unique_visitors) as unique_visitors, sum(engaged_views) as engaged_views, sum(likes) as likes, sum(saves) as saves, sum(prompt_copies) as prompt_copies, sum(starts) as starts, sum(completions) as completions, sum(upgrade_clicks) as upgrade_clicks, sum(popularity_score) as popularity_score, sum(conversion_score) as conversion_score')
->groupBy('content_type', 'content_id');
if ($contentType !== null) {
$query->where('content_type', $contentType);
}
$rows = $query->get();
$serializedRows = $this->serializeContentRows($rows, includeConversion: true)
->filter(function (array $row) use ($access): bool {
if ($access === '') {
return true;
}
return strtolower((string) ($row['access_level'] ?? '')) === strtolower($access);
})
->sortBy($sort, SORT_REGULAR, $direction === 'desc')
->values()
->all();
return Inertia::render('Admin/Academy/AnalyticsContent', [
'nav' => $this->nav(),
'range' => $this->rangePayload($range, $from, $to),
'title' => $title,
'subtitle' => $subtitle,
'filters' => [
'sort' => $sort,
'direction' => $direction,
'access' => $access,
'content_type' => $contentType,
],
'summary' => $contentType === AcademyAnalyticsContentType::PROMPT_LIBRARY
? $this->contentSummary(AcademyAnalyticsContentType::PROMPT_LIBRARY, $from, $to)
: null,
'rows' => $serializedRows,
'contentTypeOptions' => [
['value' => '', 'label' => 'All content'],
['value' => AcademyAnalyticsContentType::PROMPT_LIBRARY, 'label' => 'Prompt library'],
['value' => AcademyAnalyticsContentType::PROMPT_PACK_LIBRARY, 'label' => 'Prompt pack library'],
['value' => AcademyAnalyticsContentType::PROMPT, 'label' => 'Prompts'],
['value' => AcademyAnalyticsContentType::LESSON, 'label' => 'Lessons'],
['value' => AcademyAnalyticsContentType::COURSE, 'label' => 'Courses'],
['value' => AcademyAnalyticsContentType::PROMPT_PACK, 'label' => 'Prompt packs'],
['value' => AcademyAnalyticsContentType::CHALLENGE, 'label' => 'Challenges'],
],
'sortOptions' => [
['value' => 'views', 'label' => 'Views'],
['value' => 'unique_visitors', 'label' => 'Unique visitors'],
['value' => 'likes', 'label' => 'Likes'],
['value' => 'saves', 'label' => 'Saves'],
['value' => 'prompt_copies', 'label' => 'Copies'],
['value' => 'completions', 'label' => 'Completions'],
['value' => 'upgrade_clicks', 'label' => 'Upgrade clicks'],
['value' => 'popularity_score', 'label' => 'Popularity score'],
['value' => 'conversion_score', 'label' => 'Conversion score'],
],
]);
}
private function metricsQuery(Carbon $from, Carbon $to)
{
return AcademyContentMetricDaily::query()
->whereBetween('date', [$from->copy()->startOfDay(), $to->copy()->endOfDay()]);
}
/**
* @return array<string, int|float>
*/
private function contentSummary(string $contentType, Carbon $from, Carbon $to): array
{
$query = $this->metricsQuery($from, $to)
->where('content_type', $contentType);
if (! AcademyAnalyticsContentType::requiresContentId($contentType)) {
$query->whereNull('content_id');
}
$summary = $query
->selectRaw('sum(views) as views, sum(unique_visitors) as unique_visitors, sum(engaged_views) as engaged_views, sum(scroll_50) as scroll_50, sum(scroll_75) as scroll_75, sum(scroll_100) as scroll_100, avg(avg_engaged_seconds) as avg_engaged_seconds, sum(popularity_score) as popularity_score')
->first();
$uniqueVisitors = max(0, (int) ($summary?->unique_visitors ?? 0));
$engagedViews = max(0, (int) ($summary?->engaged_views ?? 0));
$scroll100 = max(0, (int) ($summary?->scroll_100 ?? 0));
return [
'views' => max(0, (int) ($summary?->views ?? 0)),
'uniqueVisitors' => $uniqueVisitors,
'engagedViews' => $engagedViews,
'scroll50' => max(0, (int) ($summary?->scroll_50 ?? 0)),
'scroll75' => max(0, (int) ($summary?->scroll_75 ?? 0)),
'scroll100' => $scroll100,
'avgEngagedSeconds' => round((float) ($summary?->avg_engaged_seconds ?? 0), 1),
'popularityScore' => round((float) ($summary?->popularity_score ?? 0), 2),
'engagementRate' => $uniqueVisitors > 0 ? round(($engagedViews / $uniqueVisitors) * 100, 1) : 0.0,
'deepScrollRate' => $uniqueVisitors > 0 ? round(($scroll100 / $uniqueVisitors) * 100, 1) : 0.0,
];
}
/**
* @return array{0: Carbon, 1: Carbon}
*/
private function previousRange(Carbon $from, Carbon $to): array
{
$days = $from->copy()->startOfDay()->diffInDays($to->copy()->startOfDay()) + 1;
return [
$from->copy()->subDays($days)->startOfDay(),
$from->copy()->subDay()->endOfDay(),
];
}
private function percentDelta(int|float $current, int|float $previous): ?float
{
if ((float) $previous === 0.0) {
return (float) $current === 0.0 ? 0.0 : null;
}
return round((((float) $current - (float) $previous) / (float) $previous) * 100, 1);
}
/**
* @return array{totalViews:int,totalVisitors:int,periods:list<array<string,int|float|string>>}
*/
private function popularPromptPeriodUsage(Carbon $from, Carbon $to): array
{
$events = AcademyEvent::query()
->whereBetween('occurred_at', [$from, $to])
->where('event_type', AcademyAnalyticsEventType::PAGE_VIEW)
->where('content_type', AcademyAnalyticsContentType::PROMPT_POPULAR)
->get(['visitor_id', 'metadata']);
$summary = [];
$totalViews = 0;
$visitorBuckets = [];
foreach ($events as $event) {
$metadata = is_array($event->metadata) ? $event->metadata : [];
$period = trim((string) ($metadata['period'] ?? ''));
if ($period === '') {
continue;
}
$days = max(0, (int) ($metadata['period_days'] ?? 0));
if (! isset($summary[$period])) {
$summary[$period] = [
'period' => $period,
'label' => sprintf('%s days', $days > 0 ? $days : (int) preg_replace('/\D+/', '', $period)),
'views' => 0,
'uniqueVisitors' => 0,
'share' => 0.0,
'days' => $days,
];
$visitorBuckets[$period] = [];
}
$summary[$period]['views']++;
$totalViews++;
$visitorId = trim((string) ($event->visitor_id ?? ''));
if ($visitorId !== '') {
$visitorBuckets[$period][$visitorId] = true;
}
}
$totalVisitors = 0;
foreach ($summary as $period => &$row) {
$uniqueVisitors = count($visitorBuckets[$period] ?? []);
$row['uniqueVisitors'] = $uniqueVisitors;
$row['share'] = $totalViews > 0 ? round((((int) $row['views']) / $totalViews) * 100, 1) : 0.0;
$totalVisitors += $uniqueVisitors;
}
unset($row);
usort($summary, static function (array $left, array $right): int {
if ((int) $right['views'] === (int) $left['views']) {
return ((int) $left['days']) <=> ((int) $right['days']);
}
return ((int) $right['views']) <=> ((int) $left['views']);
});
return [
'totalViews' => $totalViews,
'totalVisitors' => $totalVisitors,
'periods' => array_values($summary),
];
}
/**
* @param Collection<int, mixed> $rows
* @return Collection<int, array<string, mixed>>
*/
private function serializeContentRows(Collection $rows, bool $includeConversion = false): Collection
{
return $rows->map(function ($row) use ($includeConversion): array {
$contentType = (string) $row->content_type;
$contentId = $row->content_id ? (int) $row->content_id : null;
$title = $this->resolver->title($contentType, $contentId);
$accessLevel = $this->resolver->accessLevel($contentType, $contentId);
$uniqueVisitors = max(0, (int) ($row->unique_visitors ?? 0));
$promptCopies = max(0, (int) ($row->prompt_copies ?? 0));
$likes = max(0, (int) ($row->likes ?? 0));
$saves = max(0, (int) ($row->saves ?? 0));
$starts = max(0, (int) ($row->starts ?? 0));
$completions = max(0, (int) ($row->completions ?? 0));
$premiumPreviewViews = max(0, (int) ($row->premium_preview_views ?? 0));
$upgradeClicks = max(0, (int) ($row->upgrade_clicks ?? 0));
return [
'content_type' => $contentType,
'content_type_label' => (string) Str::of(str_replace('academy_', '', $contentType))->replace('_', ' ')->headline(),
'content_id' => $contentId,
'title' => $title,
'access_level' => $accessLevel,
'views' => (int) ($row->views ?? 0),
'unique_visitors' => $uniqueVisitors,
'engaged_views' => (int) ($row->engaged_views ?? 0),
'likes' => $likes,
'saves' => $saves,
'prompt_copies' => $promptCopies,
'starts' => $starts,
'completions' => $completions,
'upgrade_clicks' => $upgradeClicks,
'popularity_score' => round((float) ($row->popularity_score ?? 0), 2),
'conversion_score' => round((float) ($row->conversion_score ?? 0), 2),
'copy_rate' => $uniqueVisitors > 0 ? round(($promptCopies / $uniqueVisitors) * 100, 1) : 0,
'save_rate' => $uniqueVisitors > 0 ? round(($saves / $uniqueVisitors) * 100, 1) : 0,
'like_rate' => $uniqueVisitors > 0 ? round(($likes / $uniqueVisitors) * 100, 1) : 0,
'completion_rate' => $starts > 0 ? round(($completions / $starts) * 100, 1) : 0,
'upgrade_rate' => max(1, $premiumPreviewViews) > 0 ? round(($upgradeClicks / max(1, $premiumPreviewViews)) * 100, 1) : 0,
'trend' => ((float) ($row->popularity_score ?? 0)) >= 100 ? 'High momentum' : (((float) ($row->popularity_score ?? 0)) >= 25 ? 'Building' : 'Early'),
'include_conversion' => $includeConversion,
];
});
}
/**
* @return array{0: Carbon, 1: Carbon, 2: string}
*/
private function resolveDateRange(Request $request, string $defaultRange = '7d'): array
{
$range = trim((string) $request->query('range', $defaultRange));
return match ($range) {
'today' => [now()->startOfDay(), now()->endOfDay(), 'today'],
'yesterday' => [now()->subDay()->startOfDay(), now()->subDay()->endOfDay(), 'yesterday'],
'30d' => [now()->subDays(29)->startOfDay(), now()->endOfDay(), '30d'],
'90d' => [now()->subDays(89)->startOfDay(), now()->endOfDay(), '90d'],
'custom' => [
Carbon::parse((string) $request->query('from', now()->subDays(6)->toDateString()))->startOfDay(),
Carbon::parse((string) $request->query('to', now()->toDateString()))->endOfDay(),
'custom',
],
default => [now()->subDays(6)->startOfDay(), now()->endOfDay(), '7d'],
};
}
/**
* @return list<array<string, string|bool>>
*/
private function nav(): array
{
return [
['label' => 'Overview', 'href' => route('admin.academy.analytics.overview')],
['label' => 'Intelligence', 'href' => route('admin.academy.analytics.intelligence')],
['label' => 'Content', 'href' => route('admin.academy.analytics.content')],
['label' => 'Prompt Library', 'href' => route('admin.academy.analytics.prompt-library')],
['label' => 'Prompts', 'href' => route('admin.academy.analytics.prompts')],
['label' => 'Lessons', 'href' => route('admin.academy.analytics.lessons')],
['label' => 'Courses', 'href' => route('admin.academy.analytics.courses')],
['label' => 'Search', 'href' => route('admin.academy.analytics.search')],
['label' => 'Funnel', 'href' => route('admin.academy.analytics.funnel')],
];
}
/**
* @return array<string, mixed>
*/
private function rangePayload(string $activeRange, Carbon $from, Carbon $to): array
{
return [
'active' => $activeRange,
'from' => $from->toDateString(),
'to' => $to->toDateString(),
'options' => [
['value' => 'today', 'label' => 'Today'],
['value' => 'yesterday', 'label' => 'Yesterday'],
['value' => '7d', 'label' => 'Last 7 days'],
['value' => '30d', 'label' => 'Last 30 days'],
['value' => '90d', 'label' => 'Last 90 days'],
['value' => 'custom', 'label' => 'Custom range'],
],
];
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,308 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseLesson;
use App\Models\AcademyCourseSection;
use App\Models\AcademyLesson;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyCourseLessonOrderingService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyCourseBuilderController extends Controller
{
public function __construct(
private readonly AcademyCacheService $cache,
private readonly AcademyCourseLessonOrderingService $courseLessonOrdering,
)
{
}
public function edit(AcademyCourse $academyCourse): Response
{
$academyCourse->load(['sections', 'courseLessons.section', 'courseLessons.lesson.category']);
return Inertia::render('Admin/Academy/CourseBuilder', [
'course' => $this->serializeCourse($academyCourse),
'sections' => $academyCourse->sections
->sortBy([['order_num', 'asc'], ['id', 'asc']])
->values()
->map(fn (AcademyCourseSection $section): array => $this->serializeSection($section))
->all(),
'courseLessons' => $academyCourse->courseLessons
->sortBy([['order_num', 'asc'], ['id', 'asc']])
->values()
->map(fn (AcademyCourseLesson $courseLesson): array => $this->serializeCourseLesson($courseLesson))
->all(),
'availableLessons' => AcademyLesson::query()
->with('category')
->orderBy('title')
->get()
->map(fn (AcademyLesson $lesson): array => [
'id' => (int) $lesson->id,
'title' => (string) $lesson->title,
'slug' => (string) $lesson->slug,
'excerpt' => (string) ($lesson->excerpt ?? ''),
'difficulty' => (string) $lesson->difficulty,
'access_level' => (string) $lesson->access_level,
'active' => (bool) $lesson->active,
'published_at' => $lesson->published_at?->toISOString(),
'category' => $lesson->category ? (string) $lesson->category->name : '',
'attached' => $academyCourse->courseLessons->contains(fn (AcademyCourseLesson $courseLesson): bool => (int) $courseLesson->lesson_id === (int) $lesson->id),
])
->values()
->all(),
'routes' => [
'index' => route('admin.academy.courses.index'),
'edit' => route('admin.academy.courses.edit', ['academyCourse' => $academyCourse]),
'preview' => route('academy.courses.show', ['course' => $academyCourse->slug]),
'sectionStore' => route('admin.academy.courses.sections.store', ['academyCourse' => $academyCourse]),
'attachLesson' => route('admin.academy.courses.lessons.attach', ['academyCourse' => $academyCourse]),
'reorder' => route('admin.academy.courses.reorder', ['academyCourse' => $academyCourse]),
],
]);
}
public function storeSection(Request $request, AcademyCourse $academyCourse): RedirectResponse
{
$data = $request->validate([
'title' => ['required', 'string', 'max:180'],
'slug' => ['nullable', 'string', 'max:180'],
'description' => ['nullable', 'string'],
'order_num' => ['nullable', 'integer', 'min:0'],
'is_visible' => ['nullable', 'boolean'],
]);
$academyCourse->sections()->create([
'title' => $data['title'],
'slug' => filled($data['slug'] ?? null) ? $data['slug'] : Str::slug($data['title']),
'description' => $data['description'] ?? null,
'order_num' => (int) ($data['order_num'] ?? ($academyCourse->sections()->max('order_num') + 1)),
'is_visible' => (bool) ($data['is_visible'] ?? true),
]);
$this->cache->clearAll();
return back()->with('success', 'Course section created.');
}
public function updateSection(Request $request, AcademyCourse $academyCourse, AcademyCourseSection $academyCourseSection): RedirectResponse
{
abort_unless((int) $academyCourseSection->course_id === (int) $academyCourse->id, 404);
$data = $request->validate([
'title' => ['required', 'string', 'max:180'],
'slug' => ['nullable', 'string', 'max:180'],
'description' => ['nullable', 'string'],
'order_num' => ['nullable', 'integer', 'min:0'],
'is_visible' => ['nullable', 'boolean'],
]);
$academyCourseSection->forceFill([
'title' => $data['title'],
'slug' => filled($data['slug'] ?? null) ? $data['slug'] : Str::slug($data['title']),
'description' => $data['description'] ?? null,
'order_num' => (int) ($data['order_num'] ?? 0),
'is_visible' => (bool) ($data['is_visible'] ?? true),
])->save();
$this->cache->clearAll();
return back()->with('success', 'Course section updated.');
}
public function destroySection(AcademyCourse $academyCourse, AcademyCourseSection $academyCourseSection): RedirectResponse
{
abort_unless((int) $academyCourseSection->course_id === (int) $academyCourse->id, 404);
$academyCourseSection->delete();
$this->cache->clearAll();
return back()->with('success', 'Course section deleted.');
}
public function attachLesson(Request $request, AcademyCourse $academyCourse): RedirectResponse
{
$data = $request->validate([
'lesson_id' => ['required', 'integer', 'exists:academy_lessons,id'],
'section_id' => ['nullable', 'integer', 'exists:academy_course_sections,id'],
'order_num' => ['nullable', 'integer', 'min:0'],
'is_required' => ['nullable', 'boolean'],
'access_override' => ['nullable', 'string', 'in:free,premium,creator,pro'],
'unlock_after_lesson_id' => ['nullable', 'integer', 'exists:academy_lessons,id'],
]);
if (AcademyCourseLesson::query()->where('course_id', $academyCourse->id)->where('lesson_id', $data['lesson_id'])->exists()) {
return back()->with('error', 'That lesson is already attached to this course.');
}
$sectionId = $data['section_id'] ?? null;
if ($sectionId !== null) {
abort_unless(AcademyCourseSection::query()->where('course_id', $academyCourse->id)->whereKey($sectionId)->exists(), 404);
}
AcademyCourseLesson::query()->create([
'course_id' => $academyCourse->id,
'section_id' => $sectionId,
'lesson_id' => (int) $data['lesson_id'],
'order_num' => (int) ($data['order_num'] ?? ($academyCourse->courseLessons()->max('order_num') + 1)),
'is_required' => (bool) ($data['is_required'] ?? true),
'access_override' => $data['access_override'] ?? null,
'unlock_after_lesson_id' => $data['unlock_after_lesson_id'] ?? null,
]);
$this->courseLessonOrdering->syncCourse($academyCourse);
$this->syncCourseCounts($academyCourse);
return back()->with('success', 'Lesson attached to course.');
}
public function updateCourseLesson(Request $request, AcademyCourse $academyCourse, AcademyCourseLesson $academyCourseLesson): RedirectResponse
{
abort_unless((int) $academyCourseLesson->course_id === (int) $academyCourse->id, 404);
$data = $request->validate([
'section_id' => ['nullable', 'integer', 'exists:academy_course_sections,id'],
'order_num' => ['nullable', 'integer', 'min:0'],
'is_required' => ['nullable', 'boolean'],
'access_override' => ['nullable', 'string', 'in:free,premium,creator,pro'],
'unlock_after_lesson_id' => ['nullable', 'integer', 'exists:academy_lessons,id'],
]);
$sectionId = $data['section_id'] ?? null;
if ($sectionId !== null) {
abort_unless(AcademyCourseSection::query()->where('course_id', $academyCourse->id)->whereKey($sectionId)->exists(), 404);
}
$academyCourseLesson->forceFill([
'section_id' => $sectionId,
'order_num' => (int) ($data['order_num'] ?? 0),
'is_required' => (bool) ($data['is_required'] ?? true),
'access_override' => $data['access_override'] ?? null,
'unlock_after_lesson_id' => $data['unlock_after_lesson_id'] ?? null,
])->save();
$this->courseLessonOrdering->syncCourse($academyCourse);
$this->syncCourseCounts($academyCourse);
return back()->with('success', 'Course lesson updated.');
}
public function detachLesson(AcademyCourse $academyCourse, AcademyCourseLesson $academyCourseLesson): RedirectResponse
{
abort_unless((int) $academyCourseLesson->course_id === (int) $academyCourse->id, 404);
$academyCourseLesson->delete();
$this->courseLessonOrdering->syncCourse($academyCourse);
$this->syncCourseCounts($academyCourse);
return back()->with('success', 'Lesson removed from course.');
}
public function reorder(Request $request, AcademyCourse $academyCourse): RedirectResponse
{
$data = $request->validate([
'sections' => ['nullable', 'array'],
'sections.*.id' => ['required', 'integer', 'exists:academy_course_sections,id'],
'sections.*.order_num' => ['required', 'integer', 'min:0'],
'lessons' => ['nullable', 'array'],
'lessons.*.id' => ['required', 'integer', 'exists:academy_course_lessons,id'],
'lessons.*.order_num' => ['required', 'integer', 'min:0'],
'lessons.*.section_id' => ['nullable', 'integer', 'exists:academy_course_sections,id'],
]);
foreach ((array) ($data['sections'] ?? []) as $sectionData) {
AcademyCourseSection::query()
->where('course_id', $academyCourse->id)
->whereKey((int) $sectionData['id'])
->update(['order_num' => (int) $sectionData['order_num']]);
}
foreach ((array) ($data['lessons'] ?? []) as $lessonData) {
AcademyCourseLesson::query()
->where('course_id', $academyCourse->id)
->whereKey((int) $lessonData['id'])
->update([
'order_num' => (int) $lessonData['order_num'],
'section_id' => $lessonData['section_id'] ?? null,
]);
}
$this->courseLessonOrdering->syncCourse($academyCourse);
$this->syncCourseCounts($academyCourse);
return back()->with('success', 'Course order updated.');
}
private function syncCourseCounts(AcademyCourse $academyCourse): void
{
$academyCourse->forceFill([
'lessons_count_cache' => $academyCourse->courseLessons()->count(),
])->save();
$this->cache->clearAll();
}
private function serializeCourse(AcademyCourse $course): array
{
return [
'id' => (int) $course->id,
'title' => (string) $course->title,
'slug' => (string) $course->slug,
'subtitle' => (string) ($course->subtitle ?? ''),
'excerpt' => (string) ($course->excerpt ?? ''),
'description' => (string) ($course->description ?? ''),
'access_level' => (string) $course->access_level,
'difficulty' => (string) $course->difficulty,
'status' => (string) $course->status,
'lessons_count_cache' => (int) ($course->lessons_count_cache ?? 0),
'cover_image' => (string) ($course->cover_image ?? ''),
'published_at' => $course->published_at?->toISOString(),
];
}
private function serializeSection(AcademyCourseSection $section): array
{
return [
'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),
'updateUrl' => route('admin.academy.courses.sections.update', ['academyCourse' => $section->course_id, 'academyCourseSection' => $section]),
'destroyUrl' => route('admin.academy.courses.sections.destroy', ['academyCourse' => $section->course_id, 'academyCourseSection' => $section]),
];
}
private function serializeCourseLesson(AcademyCourseLesson $courseLesson): array
{
$lesson = $courseLesson->lesson;
return [
'id' => (int) $courseLesson->id,
'section_id' => $courseLesson->section_id ? (int) $courseLesson->section_id : null,
'lesson_id' => (int) $courseLesson->lesson_id,
'title' => (string) ($lesson?->title ?? ''),
'slug' => (string) ($lesson?->slug ?? ''),
'excerpt' => (string) ($lesson?->excerpt ?? ''),
'difficulty' => (string) ($lesson?->difficulty ?? ''),
'access_level' => (string) ($lesson?->access_level ?? ''),
'category' => (string) ($lesson?->category?->name ?? ''),
'order_num' => (int) ($courseLesson->order_num ?? 0),
'is_required' => (bool) $courseLesson->is_required,
'access_override' => $courseLesson->access_override,
'unlock_after_lesson_id' => $courseLesson->unlock_after_lesson_id ? (int) $courseLesson->unlock_after_lesson_id : null,
'updateUrl' => route('admin.academy.courses.lessons.update', ['academyCourse' => $courseLesson->course_id, 'academyCourseLesson' => $courseLesson]),
'destroyUrl' => route('admin.academy.courses.lessons.destroy', ['academyCourse' => $courseLesson->course_id, 'academyCourseLesson' => $courseLesson]),
];
}
}
@@ -0,0 +1,524 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use RuntimeException;
final class AcademyLessonMediaApiController extends Controller
{
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
private const MAX_FILE_SIZE_KB = 6144;
private const ASSET_CACHE_TTL_MINUTES = 15;
private const RESPONSIVE_VARIANT_WIDTHS = [
'thumb' => 480,
'md' => 960,
];
private ?ImageManager $manager = null;
public function __construct()
{
try {
$this->manager = extension_loaded('gd')
? new ImageManager(new GdDriver())
: new ImageManager(new ImagickDriver());
} catch (\Throwable) {
$this->manager = null;
}
}
public function store(Request $request): JsonResponse
{
$this->authorizeStaff($request);
$validated = $request->validate([
'slot' => ['nullable', 'string', 'in:cover,body'],
'image' => [
'required',
'file',
'image',
'max:' . self::MAX_FILE_SIZE_KB,
'mimes:jpg,jpeg,png,webp',
'mimetypes:image/jpeg,image/png,image/webp',
],
]);
/** @var UploadedFile $file */
$file = $validated['image'];
$slot = $this->normalizeSlot($validated['slot'] ?? null);
try {
$stored = $this->storeMediaFile($file, $slot);
$this->forgetAssetCache();
return response()->json([
'success' => true,
'slot' => $slot,
'path' => $stored['path'],
'url' => $this->publicUrlForPath($stored['path']),
'thumb_path' => $stored['thumb_path'],
'thumb_url' => $this->publicUrlForPath($stored['thumb_path']),
'thumb_width' => $stored['thumb_width'],
'thumb_height' => $stored['thumb_height'],
'medium_path' => $stored['medium_path'],
'medium_url' => $stored['medium_path'] !== '' ? $this->publicUrlForPath($stored['medium_path']) : null,
'medium_width' => $stored['medium_width'],
'medium_height' => $stored['medium_height'],
'srcset' => $this->buildResponsiveSrcset([
['path' => $stored['thumb_path'], 'width' => $stored['thumb_width']],
['path' => $stored['medium_path'], 'width' => $stored['medium_width']],
]),
'width' => $stored['width'],
'height' => $stored['height'],
'mime_type' => 'image/webp',
'size_bytes' => $stored['size_bytes'],
]);
} catch (RuntimeException $e) {
return response()->json([
'error' => 'Validation failed',
'message' => $e->getMessage(),
], 422);
} catch (\Throwable $e) {
logger()->error('Academy lesson media upload failed', [
'user_id' => (int) ($request->user()?->id ?? 0),
'message' => $e->getMessage(),
]);
return response()->json([
'error' => 'Upload failed',
'message' => 'Could not upload lesson media right now.',
], 500);
}
}
public function destroy(Request $request): JsonResponse
{
$this->authorizeStaff($request);
$validated = $request->validate([
'path' => ['required', 'string', 'max:2048'],
]);
$this->deleteMediaFile((string) $validated['path']);
$this->forgetAssetCache();
return response()->json([
'success' => true,
]);
}
public function assets(Request $request): JsonResponse
{
$this->authorizeStaff($request);
$validated = $request->validate([
'limit' => ['nullable', 'integer', 'min:1', 'max:48'],
'page' => ['nullable', 'integer', 'min:1'],
'q' => ['nullable', 'string', 'max:100'],
]);
$limit = (int) ($validated['limit'] ?? 24);
$page = (int) ($validated['page'] ?? 1);
$query = Str::lower(trim((string) ($validated['q'] ?? '')));
$manifest = $this->academyAssetManifest();
if ($query !== '') {
$manifest = $manifest->filter(function (array $item) use ($query): bool {
return Str::contains($item['search_text'], $query);
})->values();
}
$total = $manifest->count();
$lastPage = max(1, (int) ceil(max($total, 1) / max($limit, 1)));
$page = min(max($page, 1), $lastPage);
$items = $manifest
->forPage($page, $limit)
->values()
->map(function (array $item): array {
return [
'path' => $item['path'],
'url' => $item['url'],
'name' => $item['name'],
'slot' => $item['slot'],
'modified_at' => $item['modified_at'] ? now()->setTimestamp($item['modified_at'])->toIso8601String() : null,
];
})
->all();
return response()->json([
'success' => true,
'items' => $items,
'pagination' => [
'page' => $page,
'per_page' => $limit,
'total' => $total,
'last_page' => $lastPage,
'has_more' => $page < $lastPage,
],
]);
}
/**
* @return array{path:string,thumb_path:string,thumb_width:int,thumb_height:int,medium_path:string,medium_width:int|null,medium_height:int|null,width:int,height:int,size_bytes:int}
*/
private function storeMediaFile(UploadedFile $file, string $slot): array
{
$this->assertImageManager();
$this->assertStorageIsAllowed();
$constraints = $this->mediaConstraints($slot);
$uploadPath = (string) ($file->getRealPath() ?: $file->getPathname());
if ($uploadPath === '' || ! is_readable($uploadPath)) {
throw new RuntimeException('Unable to resolve uploaded image path.');
}
$raw = file_get_contents($uploadPath);
if ($raw === false || $raw === '') {
throw new RuntimeException('Unable to read uploaded image.');
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = strtolower((string) $finfo->buffer($raw));
if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) {
throw new RuntimeException('Unsupported image mime type.');
}
$size = @getimagesizefromstring($raw);
if (! is_array($size) || ($size[0] ?? 0) < 1 || ($size[1] ?? 0) < 1) {
throw new RuntimeException('Uploaded file is not a valid image.');
}
$width = (int) ($size[0] ?? 0);
$height = (int) ($size[1] ?? 0);
if ($width < $constraints['min_width'] || $height < $constraints['min_height']) {
throw new RuntimeException(sprintf(
'Image is too small. Minimum required size is %dx%d.',
$constraints['min_width'],
$constraints['min_height'],
));
}
$encodedImage = $this->encodeScaledMedia($raw, $constraints['max_width'], $constraints['max_height']);
$encoded = $encodedImage['binary'];
$hash = hash('sha256', $encoded);
$path = $this->mediaPath($hash, $slot);
$disk = Storage::disk($this->mediaDiskName());
$this->writeMediaBinary($disk, $path, $encoded);
$thumbVariant = $this->storeResponsiveVariant(
$disk,
$raw,
$constraints,
$path,
'thumb',
self::RESPONSIVE_VARIANT_WIDTHS['thumb'],
$encodedImage['width'],
$encodedImage['height'],
);
$mediumVariant = $this->storeResponsiveVariant(
$disk,
$raw,
$constraints,
$path,
'md',
self::RESPONSIVE_VARIANT_WIDTHS['md'],
$encodedImage['width'],
$encodedImage['height'],
);
return [
'path' => $path,
'thumb_path' => $thumbVariant['path'] ?? $path,
'thumb_width' => $thumbVariant['width'] ?? $encodedImage['width'],
'thumb_height' => $thumbVariant['height'] ?? $encodedImage['height'],
'medium_path' => $mediumVariant['path'] ?? '',
'medium_width' => $mediumVariant['width'] ?? null,
'medium_height' => $mediumVariant['height'] ?? null,
'width' => $encodedImage['width'],
'height' => $encodedImage['height'],
'size_bytes' => strlen($encoded),
];
}
/**
* @return array{binary:string,width:int,height:int}
*/
private function encodeScaledMedia(string $raw, int $maxWidth, int $maxHeight): array
{
$image = $this->manager->read($raw)->scaleDown(width: $maxWidth, height: $maxHeight);
$encoded = (string) $image->encode(new WebpEncoder(85));
if ($encoded === '') {
throw new RuntimeException('Unable to encode image to WebP.');
}
return [
'binary' => $encoded,
'width' => (int) $image->width(),
'height' => (int) $image->height(),
];
}
/**
* @param array{max_width:int,max_height:int} $constraints
* @return array{path:string,width:int,height:int}|null
*/
private function storeResponsiveVariant($disk, string $raw, array $constraints, string $path, string $variant, int $targetWidth, int $sourceWidth, int $sourceHeight): ?array
{
if ($sourceWidth <= $targetWidth && $sourceHeight <= $constraints['max_height']) {
return null;
}
$encodedVariant = $this->encodeScaledMedia($raw, $targetWidth, $constraints['max_height']);
if ($encodedVariant['width'] >= $sourceWidth && $encodedVariant['height'] >= $sourceHeight) {
return null;
}
$variantPath = $this->responsiveVariantPath($path, $variant);
$this->writeMediaBinary($disk, $variantPath, $encodedVariant['binary']);
return [
'path' => $variantPath,
'width' => $encodedVariant['width'],
'height' => $encodedVariant['height'],
];
}
private function writeMediaBinary($disk, string $path, string $binary): void
{
$written = $disk->put($path, $binary, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => 'image/webp',
]);
if ($written !== true) {
throw new RuntimeException('Unable to store image in object storage.');
}
}
private function authorizeStaff(Request $request): void
{
abort_unless((bool) $request->user()?->hasStaffAccess(), 403);
}
private function mediaDiskName(): string
{
return (string) config('uploads.object_storage.disk', 's3');
}
private function mediaPath(string $hash, string $slot): string
{
$folder = $slot === 'body' ? 'body' : 'covers';
return sprintf(
'academy/lessons/%s/%s/%s/%s.webp',
$folder,
substr($hash, 0, 2),
substr($hash, 2, 2),
$hash,
);
}
private function publicUrlForPath(string $path): string
{
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
/**
* @param array<int, array{path:string,width:int|null}> $variants
*/
private function buildResponsiveSrcset(array $variants): ?string
{
$entries = collect($variants)
->filter(function (array $variant): bool {
return trim((string) ($variant['path'] ?? '')) !== '' && (int) ($variant['width'] ?? 0) > 0;
})
->unique(fn (array $variant): string => trim((string) ($variant['path'] ?? '')))
->map(fn (array $variant): string => sprintf('%s %dw', $this->publicUrlForPath((string) $variant['path']), (int) $variant['width']))
->values()
->all();
return $entries !== [] ? implode(', ', $entries) : null;
}
private function responsiveVariantPath(string $path, string $variant): string
{
$directory = pathinfo($path, PATHINFO_DIRNAME);
$filename = pathinfo($path, PATHINFO_FILENAME);
return sprintf(
'%s/%s-%s.webp',
$directory === '.' ? '' : $directory,
preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename,
$variant,
);
}
private function canonicalMediaPath(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 === '.' ? '' : $directory,
$baseFilename,
);
}
private function isResponsiveVariantPath(string $path): bool
{
return preg_match('/-(thumb|md)\.webp$/i', $path) === 1;
}
private function academyAssetManifest(): Collection
{
return Cache::remember($this->academyAssetCacheKey(), now()->addMinutes(self::ASSET_CACHE_TTL_MINUTES), function (): Collection {
$disk = Storage::disk($this->mediaDiskName());
return collect($disk->allFiles('academy/lessons'))
->filter(fn (string $path): bool => Str::endsWith(Str::lower($path), ['.webp', '.jpg', '.jpeg', '.png']))
->reject(fn (string $path): bool => $this->isResponsiveVariantPath($path))
->map(function (string $path) use ($disk): array {
$modifiedAt = null;
try {
$modifiedAt = $disk->lastModified($path);
} catch (\Throwable) {
$modifiedAt = null;
}
$folder = Str::contains($path, '/body/') ? 'body' : (Str::contains($path, '/covers/') ? 'cover' : 'asset');
return [
'path' => $path,
'url' => $this->publicUrlForPath($path),
'name' => $this->humanAssetName($path),
'slot' => $folder,
'modified_at' => $modifiedAt ? (int) $modifiedAt : null,
'search_text' => Str::lower(implode(' ', [$path, $folder, $this->humanAssetName($path)])),
];
})
->sortByDesc(fn (array $item): int => (int) ($item['modified_at'] ?? 0))
->values();
});
}
private function academyAssetCacheKey(): string
{
return 'academy.lesson.assets.' . md5($this->mediaDiskName());
}
private function forgetAssetCache(): void
{
Cache::forget($this->academyAssetCacheKey());
}
private function humanAssetName(string $path): string
{
$filename = pathinfo($path, PATHINFO_FILENAME);
$clean = trim(str_replace(['-', '_'], ' ', $filename));
return $clean !== '' ? Str::headline($clean) : 'Academy image';
}
private function safeFileSize($disk, string $path): ?int
{
try {
$size = $disk->size($path);
return is_int($size) ? $size : null;
} catch (\Throwable) {
return null;
}
}
private function deleteMediaFile(string $path): void
{
$trimmed = ltrim(trim($path), '/');
if ($trimmed === '' || ! Str::startsWith($trimmed, ['academy/lessons/covers/', 'academy/lessons/body/'])) {
return;
}
$basePath = $this->canonicalMediaPath($trimmed);
$paths = [
$basePath,
$this->responsiveVariantPath($basePath, 'thumb'),
$this->responsiveVariantPath($basePath, 'md'),
];
Storage::disk($this->mediaDiskName())->delete(array_values(array_unique($paths)));
}
private function normalizeSlot(mixed $slot): string
{
return Str::lower(trim((string) $slot)) === 'body' ? 'body' : 'cover';
}
/**
* @return array{min_width:int,min_height:int,max_width:int,max_height:int}
*/
private function mediaConstraints(string $slot): array
{
if ($slot === 'body') {
return [
'min_width' => 64,
'min_height' => 64,
'max_width' => 2400,
'max_height' => 2400,
];
}
return [
'min_width' => 600,
'min_height' => 315,
'max_width' => 2200,
'max_height' => 1400,
];
}
private function assertImageManager(): void
{
if ($this->manager !== null) {
return;
}
throw new RuntimeException('Image processing is not available on this environment.');
}
private function assertStorageIsAllowed(): void
{
$disk = Storage::disk($this->mediaDiskName());
if (! method_exists($disk, 'put')) {
throw new RuntimeException('Object storage is not configured for academy lesson uploads.');
}
}
}
@@ -108,10 +108,10 @@ class CollectionInsightsController extends Controller
'bulkActions' => route('settings.collections.bulk-actions'), 'bulkActions' => route('settings.collections.bulk-actions'),
], ],
'seo' => [ 'seo' => [
'title' => 'Collections Dashboard — Skinbase Nova', 'title' => 'Collections Dashboard — Skinbase',
'description' => 'Overview of collection lifecycle, quality, activity, and upcoming collection campaigns.', 'description' => 'Overview of collection lifecycle, quality, activity, and upcoming collection campaigns.',
'canonical' => route('settings.collections.dashboard'), 'canonical' => route('settings.collections.dashboard'),
'robots' => 'noindex,follow', 'robots' => 'index,follow',
], ],
])->rootView('collections'); ])->rootView('collections');
} }
@@ -127,10 +127,10 @@ class CollectionInsightsController extends Controller
'historyUrl' => route('settings.collections.history', ['collection' => $collection->id]), 'historyUrl' => route('settings.collections.history', ['collection' => $collection->id]),
'dashboardUrl' => route('settings.collections.dashboard'), 'dashboardUrl' => route('settings.collections.dashboard'),
'seo' => [ 'seo' => [
'title' => sprintf('%s Analytics — Skinbase Nova', $collection->title), 'title' => sprintf('%s Analytics — Skinbase', $collection->title),
'description' => sprintf('Analytics and performance history for the %s collection.', $collection->title), 'description' => sprintf('Analytics and performance history for the %s collection.', $collection->title),
'canonical' => route('settings.collections.analytics', ['collection' => $collection->id]), 'canonical' => route('settings.collections.analytics', ['collection' => $collection->id]),
'robots' => 'noindex,follow', 'robots' => 'index,follow',
], ],
])->rootView('collections'); ])->rootView('collections');
} }
@@ -150,10 +150,10 @@ class CollectionInsightsController extends Controller
'analyticsUrl' => route('settings.collections.analytics', ['collection' => $collection->id]), 'analyticsUrl' => route('settings.collections.analytics', ['collection' => $collection->id]),
'restorePattern' => route('settings.collections.history.restore', ['collection' => $collection->id, 'history' => '__HISTORY__']), 'restorePattern' => route('settings.collections.history.restore', ['collection' => $collection->id, 'history' => '__HISTORY__']),
'seo' => [ 'seo' => [
'title' => sprintf('%s History — Skinbase Nova', $collection->title), 'title' => sprintf('%s History — Skinbase', $collection->title),
'description' => sprintf('Audit history and lifecycle changes for the %s collection.', $collection->title), 'description' => sprintf('Audit history and lifecycle changes for the %s collection.', $collection->title),
'canonical' => route('settings.collections.history', ['collection' => $collection->id]), 'canonical' => route('settings.collections.history', ['collection' => $collection->id]),
'robots' => 'noindex,follow', 'robots' => 'index,follow',
], ],
])->rootView('collections'); ])->rootView('collections');
} }
@@ -92,10 +92,10 @@ class CollectionProgrammingController extends Controller
'surfaces' => route('settings.collections.surfaces.index'), 'surfaces' => route('settings.collections.surfaces.index'),
], ],
'seo' => [ 'seo' => [
'title' => 'Collection Programming — Skinbase Nova', 'title' => 'Collection Programming — Skinbase',
'description' => 'Staff programming tools for assignments, previews, eligibility diagnostics, and recommendation refreshes.', 'description' => 'Staff programming tools for assignments, previews, eligibility diagnostics, and recommendation refreshes.',
'canonical' => route('staff.collections.programming'), 'canonical' => route('staff.collections.programming'),
'robots' => 'noindex,follow', 'robots' => 'index,follow',
], ],
])->rootView('collections'); ])->rootView('collections');
} }
@@ -66,10 +66,10 @@ class CollectionSurfaceController extends Controller
'batchEditorial' => route('settings.collections.surfaces.batch-editorial'), 'batchEditorial' => route('settings.collections.surfaces.batch-editorial'),
], ],
'seo' => [ 'seo' => [
'title' => 'Collection Surfaces - Skinbase Nova', 'title' => 'Collection Surfaces - Skinbase',
'description' => 'Staff tools for homepage, discovery, and campaign collection surfaces.', 'description' => 'Staff tools for homepage, discovery, and campaign collection surfaces.',
'canonical' => route('settings.collections.surfaces.index'), 'canonical' => route('settings.collections.surfaces.index'),
'robots' => 'noindex,follow', 'robots' => 'index,follow',
], ],
])->rootView('collections'); ])->rootView('collections');
} }
@@ -27,8 +27,10 @@ class FeaturedArtworkAdminController extends Controller
{ {
$isAdminSurface = $request->routeIs('admin.artworks.featured.*'); $isAdminSurface = $request->routeIs('admin.artworks.featured.*');
$routePrefix = $isAdminSurface ? 'admin.artworks.featured.' : 'admin.cp.artworks.featured.'; $routePrefix = $isAdminSurface ? 'admin.artworks.featured.' : 'admin.cp.artworks.featured.';
$pageName = $isAdminSurface ? 'Moderation/FeaturedArtworks' : 'Collection/FeaturedArtworksAdmin';
$rootView = $isAdminSurface ? 'moderation' : 'collections';
return Inertia::render($isAdminSurface ? 'Admin/FeaturedArtworks' : 'Collection/FeaturedArtworksAdmin', array_merge( return Inertia::render($pageName, array_merge(
$this->featuredArtworks->pageProps(), $this->featuredArtworks->pageProps(),
[ [
'endpoints' => [ 'endpoints' => [
@@ -43,13 +45,13 @@ class FeaturedArtworkAdminController extends Controller
'forceHeroEnabled' => $this->hasForceHeroColumn(), 'forceHeroEnabled' => $this->hasForceHeroColumn(),
], ],
'seo' => [ 'seo' => [
'title' => 'Featured Artworks — Skinbase Nova', 'title' => 'Featured Artworks — Skinbase',
'description' => 'Editorial controls for homepage featured artworks and the current hero winner.', 'description' => 'Editorial controls for homepage featured artworks and the current hero winner.',
'canonical' => route($routePrefix . 'main'), 'canonical' => route($routePrefix . 'main'),
'robots' => 'noindex,follow', 'robots' => 'index,follow',
], ],
], ],
))->rootView($isAdminSurface ? 'admin' : 'collections'); ))->rootView($rootView);
} }
public function search(Request $request): JsonResponse public function search(Request $request): JsonResponse
@@ -198,6 +198,14 @@ class HomepageAnnouncementController extends Controller
return; return;
} }
$backgroundDisk = $this->announcements->backgroundImageDisk();
if (Storage::disk($backgroundDisk)->exists($path)) {
Storage::disk($backgroundDisk)->delete($path);
return;
}
if (Storage::disk('public')->exists($path)) { if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path); Storage::disk('public')->delete($path);
} }
@@ -268,8 +276,8 @@ class HomepageAnnouncementController extends Controller
]); ]);
} }
$storedPath = 'homepage-announcements/' . pathinfo(Str::replace('\\', '/', $file->hashName()), PATHINFO_FILENAME) . '.webp'; $storedPath = $this->announcements->backgroundImagePrefix() . '/' . pathinfo(Str::replace('\\', '/', $file->hashName()), PATHINFO_FILENAME) . '.webp';
Storage::disk('public')->put($storedPath, $webpBinary, ['visibility' => 'public']); Storage::disk($this->announcements->backgroundImageDisk())->put($storedPath, $webpBinary, ['visibility' => 'public']);
} finally { } finally {
imagedestroy($image); imagedestroy($image);
} }
@@ -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'),
];
}
}
+93 -20
View File
@@ -194,7 +194,7 @@ class StoryController extends Controller
'storyTypes' => $this->storyCategories(), 'storyTypes' => $this->storyCategories(),
'page_title' => 'Create Story - Skinbase', 'page_title' => 'Create Story - Skinbase',
'page_meta_description' => 'Write and publish a creator story on Skinbase.', 'page_meta_description' => 'Write and publish a creator story on Skinbase.',
'page_robots' => 'noindex,nofollow', 'page_robots' => 'index,nofollow',
]); ]);
} }
@@ -751,24 +751,42 @@ class StoryController extends Controller
], 422); ], 422);
} }
$disk = Storage::disk('public'); try {
$base = 'stories/' . now()->format('Y/m') . '/' . Str::uuid(); $this->assertStoryMediaStorageIsAllowed();
$extension = strtolower((string) ($file->guessExtension() ?: $file->extension() ?: 'jpg')); } catch (\RuntimeException $e) {
$originalPath = $base . '/original.' . $extension; return response()->json([
$thumbnailPath = $base . '/thumbnail.webp'; 'message' => $e->getMessage(),
$mediumPath = $base . '/medium.webp'; ], 422);
}
$stream = fopen($sourcePath, 'rb'); $raw = file_get_contents($sourcePath);
if ($stream === false) { if ($raw === false || $raw === '') {
return response()->json([ return response()->json([
'message' => 'Unable to process uploaded image. Please try again.', 'message' => 'Unable to process uploaded image. Please try again.',
], 422); ], 422);
} }
try { $hash = hash('sha256', $raw);
$disk->put($originalPath, $stream); $originalExtension = strtolower((string) ($file->guessExtension() ?: $file->extension() ?: 'jpg'));
} finally { if ($originalExtension === 'jpeg') {
fclose($stream); $originalExtension = 'jpg';
}
$originalPath = $this->storyMediaPath('original', $hash, $hash . '.' . $originalExtension);
$thumbnailPath = $this->storyMediaPath('sm', $hash);
$mediumPath = $this->storyMediaPath('md', $hash);
$disk = Storage::disk($this->storyMediaDiskName());
$written = $disk->put($originalPath, $raw, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => (string) ($file->getMimeType() ?: 'application/octet-stream'),
]);
if ($written !== true) {
return response()->json([
'message' => 'Unable to store uploaded image. Please try again.',
], 500);
} }
$storedThumbnails = false; $storedThumbnails = false;
@@ -781,10 +799,20 @@ class StoryController extends Controller
$image = $manager->read($sourcePath); $image = $manager->read($sourcePath);
$thumb = $image->scaleDown(width: 420); $thumb = $image->scaleDown(width: 420);
$disk->put($thumbnailPath, (string) $thumb->encode(new WebpEncoder(82))); $thumbEncoded = (string) $thumb->encode(new WebpEncoder(82));
$disk->put($thumbnailPath, $thumbEncoded, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => 'image/webp',
]);
$medium = $image->scaleDown(width: 1200); $medium = $image->scaleDown(width: 1200);
$disk->put($mediumPath, (string) $medium->encode(new WebpEncoder(85))); $mediumEncoded = (string) $medium->encode(new WebpEncoder(85));
$disk->put($mediumPath, $mediumEncoded, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => 'image/webp',
]);
$storedThumbnails = true; $storedThumbnails = true;
} catch (\Throwable) { } catch (\Throwable) {
$storedThumbnails = false; $storedThumbnails = false;
@@ -792,17 +820,62 @@ class StoryController extends Controller
} }
if (! $storedThumbnails) { if (! $storedThumbnails) {
$disk->copy($originalPath, $thumbnailPath); $disk->put($thumbnailPath, $raw, [
$disk->copy($originalPath, $mediumPath); 'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => (string) ($file->getMimeType() ?: 'application/octet-stream'),
]);
$disk->put($mediumPath, $raw, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => (string) ($file->getMimeType() ?: 'application/octet-stream'),
]);
} }
return response()->json([ return response()->json([
'thumbnail_url' => $disk->url($thumbnailPath), 'thumbnail_url' => $this->storyMediaPublicUrl($thumbnailPath),
'medium_url' => $disk->url($mediumPath), 'medium_url' => $this->storyMediaPublicUrl($mediumPath),
'original_url' => $disk->url($originalPath), 'original_url' => $this->storyMediaPublicUrl($originalPath),
]); ]);
} }
private function storyMediaDiskName(): string
{
return (string) config('uploads.object_storage.disk', 's3');
}
private function storyMediaPath(string $variant, string $hash, ?string $filename = null): string
{
$cleanVariant = trim($variant, '/');
$cleanHash = strtolower((string) preg_replace('/[^a-f0-9]/', '', $hash));
$file = $filename ?? ($cleanHash . '.webp');
return sprintf(
'stories/%s/%s/%s/%s',
$cleanVariant,
substr($cleanHash, 0, 2),
substr($cleanHash, 2, 2),
ltrim($file, '/')
);
}
private function storyMediaPublicUrl(string $path): string
{
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
private function assertStoryMediaStorageIsAllowed(): void
{
if (! app()->environment('production')) {
return;
}
$diskName = $this->storyMediaDiskName();
if (in_array($diskName, ['local', 'public'], true)) {
throw new \RuntimeException('Production story media storage must use object storage, not local/public disks.');
}
}
public function tag(string $tag): View public function tag(string $tag): View
{ {
$storyTag = StoryTag::query()->where('slug', $tag)->firstOrFail(); $storyTag = StoryTag::query()->where('slug', $tag)->firstOrFail();
@@ -18,6 +18,7 @@ use App\Services\TagService;
use App\Services\ArtworkVersioningService; use App\Services\ArtworkVersioningService;
use App\Services\Studio\StudioArtworkQueryService; use App\Services\Studio\StudioArtworkQueryService;
use App\Services\Studio\StudioBulkActionService; use App\Services\Studio\StudioBulkActionService;
use App\Support\ArtworkDescriptionContentValidator;
use App\Services\Tags\TagDiscoveryService; use App\Services\Tags\TagDiscoveryService;
use App\Services\Worlds\WorldSubmissionService; use App\Services\Worlds\WorldSubmissionService;
use Carbon\Carbon; use Carbon\Carbon;
@@ -164,6 +165,8 @@ final class StudioArtworksApiController extends Controller
'evolution_note' => 'sometimes|nullable|string|max:1200', 'evolution_note' => 'sometimes|nullable|string|max:1200',
]); ]);
$this->ensureValidArtworkDescription($validated);
$hasAttributionUpdates = array_key_exists('group', $validated) $hasAttributionUpdates = array_key_exists('group', $validated)
|| array_key_exists('primary_author_user_id', $validated) || array_key_exists('primary_author_user_id', $validated)
|| array_key_exists('contributor_user_ids', $validated) || array_key_exists('contributor_user_ids', $validated)
@@ -326,6 +329,15 @@ final class StudioArtworksApiController extends Controller
]); ]);
} }
private function ensureValidArtworkDescription(array $validated): void
{
foreach (ArtworkDescriptionContentValidator::errors($validated['description'] ?? null) as $message) {
throw ValidationException::withMessages([
'description' => [$message],
]);
}
}
public function evolutionOptions(Request $request, int $id): JsonResponse public function evolutionOptions(Request $request, int $id): JsonResponse
{ {
$artwork = $request->user()->artworks()->findOrFail($id); $artwork = $request->user()->artworks()->findOrFail($id);
@@ -95,7 +95,13 @@ final class StudioController extends Controller
{ {
$provider = $this->content->provider('artworks'); $provider = $this->content->provider('artworks');
$prefs = $this->preferences->forUser($request->user()); $prefs = $this->preferences->forUser($request->user());
$listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'bucket', 'page', 'per_page', 'content_type', 'category', 'tag']), null, 'artworks'); $filters = $request->only(['q', 'sort', 'bucket', 'page', 'per_page', 'content_type', 'category', 'tag']);
if (! $request->filled('sort')) {
$filters['sort'] = 'published_desc';
}
$listing = $this->content->list($request->user(), $filters, null, 'artworks');
$listing['default_view'] = $prefs['default_content_view']; $listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioArtworks', [ return Inertia::render('Studio/StudioArtworks', [
@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Http\Controllers\Studio; namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\News\NewsService; use App\Services\News\NewsService;
use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -32,7 +34,7 @@ final class StudioNewsController extends Controller
return Inertia::render('Studio/StudioNewsIndex', [ return Inertia::render('Studio/StudioNewsIndex', [
'title' => 'Newsroom', 'title' => 'Newsroom',
'description' => 'Plan announcements, publish editorial stories, and connect articles to the rest of Nova.', 'description' => 'Plan announcements, publish editorial stories, and connect articles to the rest of Nova.',
'listing' => $this->news->studioListing($request->only(['q', 'status', 'type', 'category_id', 'per_page', 'page'])), 'listing' => $this->news->studioListing($request->only(['q', 'status', 'type', 'category_id', 'per_page', 'page', 'order', 'direction'])),
'statusOptions' => $this->news->editorialStatusOptions(), 'statusOptions' => $this->news->editorialStatusOptions(),
'typeOptions' => $this->news->articleTypeOptions(), 'typeOptions' => $this->news->articleTypeOptions(),
'categoryOptions' => $this->news->categoryOptions(), 'categoryOptions' => $this->news->categoryOptions(),
@@ -46,23 +48,31 @@ final class StudioNewsController extends Controller
{ {
$this->authorizeNews($request); $this->authorizeNews($request);
$user = $request->user();
return Inertia::render('Studio/StudioNewsEditor', [ return Inertia::render('Studio/StudioNewsEditor', [
'title' => 'Create article', 'title' => 'Create article',
'description' => 'Draft a new News story with editorial workflow, SEO metadata, and related entity links.', 'description' => 'Draft a new News story with editorial workflow, SEO metadata, and related entity links.',
'article' => null, 'article' => null,
'oldInput' => $request->session()->getOldInput(),
'indexUrl' => route('studio.news.index'),
'typeOptions' => $this->news->articleTypeOptions(), 'typeOptions' => $this->news->articleTypeOptions(),
'statusOptions' => $this->news->editorialStatusOptions(), 'statusOptions' => $this->news->editorialStatusOptions(),
'categoryOptions' => $this->news->categoryOptions(), 'categoryOptions' => $this->news->categoryOptions(),
'tagOptions' => $this->news->tagOptions(), 'tagOptions' => $this->news->tagOptions(),
'newsTagLimit' => 30,
'relationTypeOptions' => $this->news->relationTypeOptions(), 'relationTypeOptions' => $this->news->relationTypeOptions(),
'storeUrl' => route('studio.news.store'), 'storeUrl' => route('studio.news.store'),
'coverUploadUrl' => route('api.studio.news.media.upload'), 'coverUploadUrl' => route('api.studio.news.media.upload'),
'coverDeleteUrl' => route('api.studio.news.media.destroy'), 'coverDeleteUrl' => route('api.studio.news.media.destroy'),
'bodyMediaUploadUrl' => route('api.studio.news.media.upload'),
'bodyMediaDeleteUrl' => route('api.studio.news.media.destroy'),
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'), 'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
'entitySearchUrl' => route('studio.news.entity-search'), 'entitySearchUrl' => route('studio.news.entity-search'),
'categoriesUrl' => route('studio.news.categories'), 'categoriesUrl' => route('studio.news.categories'),
'tagsUrl' => route('studio.news.tags'), 'tagsUrl' => route('studio.news.tags'),
'defaultAuthor' => $this->news->searchEntities('user', (string) $request->user()->username)[0] ?? null, 'defaultAuthor' => $this->mapDefaultAuthor($user),
'defaultPublishedAt' => now()->format('Y-m-d\TH:i'),
]); ]);
} }
@@ -83,13 +93,18 @@ final class StudioNewsController extends Controller
'title' => 'Edit article', 'title' => 'Edit article',
'description' => 'Refine the story, tune SEO, and attach related Nova entities before publishing.', 'description' => 'Refine the story, tune SEO, and attach related Nova entities before publishing.',
'article' => $this->news->mapStudioArticle($article, $request->user()), 'article' => $this->news->mapStudioArticle($article, $request->user()),
'oldInput' => $request->session()->getOldInput(),
'indexUrl' => route('studio.news.index'),
'typeOptions' => $this->news->articleTypeOptions(), 'typeOptions' => $this->news->articleTypeOptions(),
'statusOptions' => $this->news->editorialStatusOptions(), 'statusOptions' => $this->news->editorialStatusOptions(),
'categoryOptions' => $this->news->categoryOptions(), 'categoryOptions' => $this->news->categoryOptions(),
'tagOptions' => $this->news->tagOptions(), 'tagOptions' => $this->news->tagOptions(),
'newsTagLimit' => 30,
'relationTypeOptions' => $this->news->relationTypeOptions(), 'relationTypeOptions' => $this->news->relationTypeOptions(),
'coverUploadUrl' => route('api.studio.news.media.upload'), 'coverUploadUrl' => route('api.studio.news.media.upload'),
'coverDeleteUrl' => route('api.studio.news.media.destroy'), 'coverDeleteUrl' => route('api.studio.news.media.destroy'),
'bodyMediaUploadUrl' => route('api.studio.news.media.upload'),
'bodyMediaDeleteUrl' => route('api.studio.news.media.destroy'),
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'), 'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
'updateUrl' => route('studio.news.update', ['article' => $article->id]), 'updateUrl' => route('studio.news.update', ['article' => $article->id]),
'destroyUrl' => route('studio.news.destroy', ['article' => $article->id]), 'destroyUrl' => route('studio.news.destroy', ['article' => $article->id]),
@@ -244,6 +259,29 @@ final class StudioNewsController extends Controller
]); ]);
} }
private function mapDefaultAuthor(mixed $user): ?array
{
if (! $user instanceof User) {
return null;
}
$user->loadMissing('profile');
return [
'id' => (int) $user->id,
'entity_type' => 'user',
'entity_label' => 'User',
'title' => (string) ($user->name ?: $user->username),
'subtitle' => $user->username ? '@' . $user->username : null,
'description' => Str::limit(trim((string) ($user->profile?->bio ?? '')), 120),
'url' => $user->username ? route('profile.show', ['username' => $user->username]) : null,
'image' => null,
'avatar' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash ?? null, 96),
'context_label' => 'Profile',
'meta' => [],
];
}
public function storeCategory(Request $request): RedirectResponse public function storeCategory(Request $request): RedirectResponse
{ {
$this->authorizeNews($request); $this->authorizeNews($request);
@@ -352,7 +390,7 @@ final class StudioNewsController extends Controller
'content' => ['required', 'string', 'max:500000'], 'content' => ['required', 'string', 'max:500000'],
'cover_image' => ['nullable', 'string', 'max:2048'], 'cover_image' => ['nullable', 'string', 'max:2048'],
'type' => ['required', Rule::in(array_column($this->news->articleTypeOptions(), 'value'))], 'type' => ['required', Rule::in(array_column($this->news->articleTypeOptions(), 'value'))],
'category_id' => ['nullable', 'integer', 'exists:news_categories,id'], 'category_id' => ['required', 'integer', 'exists:news_categories,id'],
'author_id' => ['nullable', 'integer', 'exists:users,id'], 'author_id' => ['nullable', 'integer', 'exists:users,id'],
'editorial_status' => ['required', Rule::in(array_column($this->news->editorialStatusOptions(), 'value'))], 'editorial_status' => ['required', Rule::in(array_column($this->news->editorialStatusOptions(), 'value'))],
'published_at' => ['nullable', 'date'], 'published_at' => ['nullable', 'date'],
@@ -361,30 +399,51 @@ final class StudioNewsController extends Controller
'comments_enabled' => ['nullable', 'boolean'], 'comments_enabled' => ['nullable', 'boolean'],
'tag_ids' => ['nullable', 'array'], 'tag_ids' => ['nullable', 'array'],
'tag_ids.*' => ['integer', 'exists:news_tags,id'], 'tag_ids.*' => ['integer', 'exists:news_tags,id'],
'new_tag_names' => ['nullable', 'array', 'max:12'], 'new_tag_names' => ['nullable', 'array', 'max:30'],
'new_tag_names.*' => ['string', 'max:80'], 'new_tag_names.*' => ['string', 'max:80'],
'meta_title' => ['nullable', 'string', 'max:255'], 'meta_title' => ['nullable', 'string', 'max:255'],
'meta_description' => ['nullable', 'string', 'max:300'], 'meta_description' => ['nullable', 'string', 'max:300'],
'meta_keywords' => ['nullable', 'string', 'max:255'], 'meta_keywords' => ['nullable', 'string', 'max:255'],
'canonical_url' => ['nullable', 'string', 'max:2048', function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === '' || $value === null) {
return;
}
$isAbsolute = filter_var($value, FILTER_VALIDATE_URL) !== false;
$isRelative = str_starts_with($value, '/');
if (! $isAbsolute && ! $isRelative) {
$fail('The canonical URL must be a valid URL or a relative path starting with /.');
}
}],
'og_title' => ['nullable', 'string', 'max:255'], 'og_title' => ['nullable', 'string', 'max:255'],
'og_description' => ['nullable', 'string', 'max:300'], 'og_description' => ['nullable', 'string', 'max:300'],
'og_image' => ['nullable', 'string', 'max:2048'], 'og_image' => ['nullable', 'string', 'max:2048'],
'relations' => ['nullable', 'array', 'max:12'], 'relations' => ['nullable', 'array', 'max:12'],
'relations.*.entity_type' => ['required_with:relations', Rule::in(array_column($this->news->relationTypeOptions(), 'value'))], 'relations.*.entity_type' => ['required_with:relations', Rule::in(array_column($this->news->relationTypeOptions(), 'value'))],
'relations.*.entity_id' => ['required_with:relations', 'integer', 'min:1'], 'relations.*.entity_id' => ['nullable', 'integer', 'min:1'],
'relations.*.external_url' => ['nullable', 'string', 'max:2048'],
'relations.*.context_label' => ['nullable', 'string', 'max:120'], 'relations.*.context_label' => ['nullable', 'string', 'max:120'],
]); ]);
$relationErrors = [];
foreach ((array) ($validated['relations'] ?? []) as $index => $relation) {
$entityType = trim(Str::lower((string) ($relation['entity_type'] ?? '')));
if ($entityType === NewsService::RELATION_SOURCE) {
$externalUrl = $this->normalizeExternalRelationUrl($relation['external_url'] ?? null);
if ($externalUrl === null) {
$relationErrors["relations.{$index}.external_url"] = 'Source relations need a valid URL.';
continue;
}
$validated['relations'][$index]['entity_id'] = null;
$validated['relations'][$index]['external_url'] = $externalUrl;
continue;
}
if ((int) ($relation['entity_id'] ?? 0) < 1) {
$relationErrors["relations.{$index}.entity_id"] = 'Select a related entity.';
}
$validated['relations'][$index]['external_url'] = null;
}
if ($relationErrors !== []) {
throw ValidationException::withMessages($relationErrors);
}
if (($validated['editorial_status'] ?? null) === NewsArticle::EDITORIAL_STATUS_SCHEDULED && empty($validated['published_at'])) { if (($validated['editorial_status'] ?? null) === NewsArticle::EDITORIAL_STATUS_SCHEDULED && empty($validated['published_at'])) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'published_at' => 'Scheduled articles need a publish date and time.', 'published_at' => 'Scheduled articles need a publish date and time.',
@@ -394,6 +453,25 @@ final class StudioNewsController extends Controller
return $validated; return $validated;
} }
private function normalizeExternalRelationUrl(mixed $value): ?string
{
$url = trim((string) ($value ?? ''));
if ($url === '') {
return null;
}
if (preg_match('/^\[[^\]]+\]\((https?:\/\/[^)]+)\)$/i', $url, $matches) === 1) {
$url = trim((string) ($matches[1] ?? ''));
}
if ($url === '' || filter_var($url, FILTER_VALIDATE_URL) === false) {
return null;
}
return Str::limit($url, 2048, '');
}
private function tagPayload(): array private function tagPayload(): array
{ {
return NewsTag::query() return NewsTag::query()
@@ -5,42 +5,15 @@ declare(strict_types=1);
namespace App\Http\Controllers\Studio; namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\News\NewsCoverImageService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use RuntimeException; use RuntimeException;
final class StudioNewsMediaApiController extends Controller final class StudioNewsMediaApiController extends Controller
{ {
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp']; public function __construct(private readonly NewsCoverImageService $covers)
private const MAX_FILE_SIZE_KB = 6144;
private const MAX_WIDTH = 2200;
private const MAX_HEIGHT = 1400;
private const MIN_WIDTH = 1200;
private const MIN_HEIGHT = 630;
private ?ImageManager $manager = null;
public function __construct()
{ {
try {
$this->manager = extension_loaded('gd')
? new ImageManager(new GdDriver())
: new ImageManager(new ImagickDriver());
} catch (\Throwable) {
$this->manager = null;
}
} }
public function store(Request $request): JsonResponse public function store(Request $request): JsonResponse
@@ -52,26 +25,29 @@ final class StudioNewsMediaApiController extends Controller
'required', 'required',
'file', 'file',
'image', 'image',
'max:' . self::MAX_FILE_SIZE_KB, 'max:' . $this->covers->maxFileSizeKb(),
'mimes:jpg,jpeg,png,webp', 'mimes:jpg,jpeg,png,webp',
'mimetypes:image/jpeg,image/png,image/webp', 'mimetypes:image/jpeg,image/png,image/webp',
], ],
]); ]);
/** @var UploadedFile $file */
$file = $validated['image']; $file = $validated['image'];
try { try {
$stored = $this->storeMediaFile($file); $stored = $this->covers->storeUploadedFile($file);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'path' => $stored['path'], 'path' => $stored['path'],
'url' => $this->publicUrlForPath($stored['path']), 'url' => $stored['url'],
'width' => $stored['width'], 'width' => $stored['width'],
'height' => $stored['height'], 'height' => $stored['height'],
'mime_type' => 'image/webp', 'mime_type' => 'image/webp',
'size_bytes' => $stored['size_bytes'], 'size_bytes' => $stored['size_bytes'],
'mobile_url' => $stored['mobile_url'],
'desktop_url' => $stored['desktop_url'],
'large_url' => $stored['large_url'],
'srcset' => $stored['srcset'],
]); ]);
} catch (RuntimeException $e) { } catch (RuntimeException $e) {
return response()->json([ return response()->json([
@@ -99,7 +75,7 @@ final class StudioNewsMediaApiController extends Controller
'path' => ['required', 'string', 'max:2048'], 'path' => ['required', 'string', 'max:2048'],
]); ]);
$this->deleteMediaFile((string) $validated['path']); $this->covers->deleteManagedFiles((string) $validated['path']);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
@@ -176,56 +152,4 @@ final class StudioNewsMediaApiController extends Controller
{ {
abort_unless($request->user() && ($request->user()->isAdmin() || $request->user()->isModerator()), 403); abort_unless($request->user() && ($request->user()->isAdmin() || $request->user()->isModerator()), 403);
} }
private function mediaDiskName(): string
{
return (string) config('uploads.object_storage.disk', 's3');
}
private function mediaPath(string $hash): string
{
return sprintf(
'news/covers/%s/%s/%s.webp',
substr($hash, 0, 2),
substr($hash, 2, 2),
$hash,
);
}
private function publicUrlForPath(string $path): string
{
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
private function deleteMediaFile(string $path): void
{
$trimmed = ltrim(trim($path), '/');
if ($trimmed === '' || ! Str::startsWith($trimmed, 'news/covers/')) {
return;
}
Storage::disk($this->mediaDiskName())->delete($trimmed);
}
private function assertImageManager(): void
{
if ($this->manager !== null) {
return;
}
throw new RuntimeException('Image processing is not available on this environment.');
}
private function assertStorageIsAllowed(): void
{
if (! app()->environment('production')) {
return;
}
$diskName = $this->mediaDiskName();
if (in_array($diskName, ['local', 'public'], true)) {
throw new RuntimeException('Production news media storage must use object storage, not local/public disks.');
}
}
} }
@@ -40,7 +40,7 @@ final class StudioWorldController extends Controller
return Inertia::render('Studio/StudioWorldsIndex', [ return Inertia::render('Studio/StudioWorldsIndex', [
'title' => 'Worlds', 'title' => 'Worlds',
'description' => 'Create and manage seasonal, event, and campaign destinations across Skinbase Nova.', 'description' => 'Create and manage seasonal, event, and campaign destinations across Skinbase.',
'listing' => $this->worlds->studioListing($request->only(['q', 'status', 'type', 'per_page'])), 'listing' => $this->worlds->studioListing($request->only(['q', 'status', 'type', 'per_page'])),
'analytics' => $this->analytics->portfolioReport(), 'analytics' => $this->analytics->portfolioReport(),
'statusOptions' => [ 'statusOptions' => [
@@ -435,7 +435,7 @@ final class StudioWorldController extends Controller
$payload = $this->worlds->publicShowPayload($world, $request->user(), true); $payload = $this->worlds->publicShowPayload($world, $request->user(), true);
$seo = app(SeoFactory::class)->collectionPage( $seo = app(SeoFactory::class)->collectionPage(
$world->seo_title ?: ($world->title . ' — Skinbase Nova Preview'), $world->seo_title ?: ($world->title . ' — Skinbase Preview'),
$world->seo_description ?: ($world->summary ?: $world->description ?: 'Preview world page'), $world->seo_description ?: ($world->summary ?: $world->description ?: 'Preview world page'),
route('studio.worlds.preview', ['world' => $world]), route('studio.worlds.preview', ['world' => $world]),
$world->ogImageUrl(), $world->ogImageUrl(),
@@ -116,9 +116,9 @@ class ProfileCollectionController extends Controller
$seo = app(SeoFactory::class)->collectionPage( $seo = app(SeoFactory::class)->collectionPage(
$collection->is_featured $collection->is_featured
? sprintf('Featured: %s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName()) ? sprintf('Featured: %s by %s — Skinbase', $collection->title, $collection->displayOwnerName())
: sprintf('%s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName()), : sprintf('%s by %s — Skinbase', $collection->title, $collection->displayOwnerName()),
$collection->summary ?: $collection->description ?: sprintf('Explore the %s collection by %s on Skinbase Nova.', $collection->title, $collection->displayOwnerName()), $collection->summary ?: $collection->description ?: sprintf('Explore the %s collection by %s on Skinbase.', $collection->title, $collection->displayOwnerName()),
$collectionPayload['public_url'], $collectionPayload['public_url'],
$collectionPayload['cover_image'], $collectionPayload['cover_image'],
$collection->visibility === Collection::VISIBILITY_PUBLIC, $collection->visibility === Collection::VISIBILITY_PUBLIC,
@@ -202,8 +202,8 @@ class ProfileCollectionController extends Controller
$seriesDescription = $seriesMeta['description']; $seriesDescription = $seriesMeta['description'];
$seo = app(SeoFactory::class)->collectionListing( $seo = app(SeoFactory::class)->collectionListing(
sprintf('Series: %s — Skinbase Nova', $seriesKey), sprintf('Series: %s — Skinbase', $seriesKey),
sprintf('Explore the %s collection series on Skinbase Nova.', $seriesKey), sprintf('Explore the %s collection series on Skinbase.', $seriesKey),
route('collections.series.show', ['seriesKey' => $seriesKey]) route('collections.series.show', ['seriesKey' => $seriesKey])
)->toArray(); )->toArray();
+21 -13
View File
@@ -855,25 +855,33 @@ class ProfileController extends Controller
} }
} }
$allowedImageMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if ($request->hasFile('emoticon')) { if ($request->hasFile('emoticon')) {
$file = $request->file('emoticon'); $file = $request->file('emoticon');
$fname = $file->getClientOriginalName(); if (in_array($file->getMimeType(), $allowedImageMimes, true)) {
$path = \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-emoticons/'.$user->id, $file, $fname); $ext = $file->guessExtension() ?: 'jpg';
try { $fname = $user->id . '_emoticon_' . time() . '.' . $ext;
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['eicon' => $fname]); \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-emoticons/'.$user->id, $file, $fname);
} catch (\Exception $e) {} try {
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['eicon' => $fname]);
} catch (\Exception $e) {}
}
} }
if ($request->hasFile('photo')) { if ($request->hasFile('photo')) {
$file = $request->file('photo'); $file = $request->file('photo');
$fname = $file->getClientOriginalName(); if (in_array($file->getMimeType(), $allowedImageMimes, true)) {
$path = \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-picture/'.$user->id, $file, $fname); $ext = $file->guessExtension() ?: 'jpg';
if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) { $fname = $user->id . '_photo_' . time() . '.' . $ext;
$profileUpdates['cover_image'] = $fname; \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-picture/'.$user->id, $file, $fname);
} else { if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) {
try { $profileUpdates['cover_image'] = $fname;
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['picture' => $fname]); } else {
} catch (\Exception $e) {} try {
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['picture' => $fname]);
} catch (\Exception $e) {}
}
} }
} }
@@ -155,8 +155,8 @@ class SavedCollectionController extends Controller
'libraryUrl' => route('me.saved.collections'), 'libraryUrl' => route('me.saved.collections'),
'browseUrl' => route('collections.featured'), 'browseUrl' => route('collections.featured'),
'seo' => [ 'seo' => [
'title' => $activeList ? sprintf('%s — Saved Collections — Skinbase Nova', $activeList->title) : 'Saved Collections — Skinbase Nova', 'title' => $activeList ? sprintf('%s — Saved Collections — Skinbase', $activeList->title) : 'Saved Collections — Skinbase',
'description' => $activeList ? sprintf('Saved collections in the %s list on Skinbase Nova.', $activeList->title) : 'Your saved collections on Skinbase Nova.', 'description' => $activeList ? sprintf('Saved collections in the %s list on Skinbase.', $activeList->title) : 'Your saved collections on Skinbase.',
'canonical' => $activeList ? route('me.saved.collections.lists.show', ['listSlug' => $activeList->slug]) : route('me.saved.collections'), 'canonical' => $activeList ? route('me.saved.collections.lists.show', ['listSlug' => $activeList->slug]) : route('me.saved.collections'),
'robots' => 'noindex,follow', 'robots' => 'noindex,follow',
], ],
@@ -50,7 +50,8 @@ class TopAuthorsController extends Controller
}); });
$page_title = 'Top Creators'; $page_title = 'Top Creators';
$page_canonical = route('creators.top');
return view('web.authors.top', compact('page_title', 'authors', 'metric')); return view('web.authors.top', compact('page_title', 'page_canonical', 'authors', 'metric'));
} }
} }
@@ -18,7 +18,7 @@ final class AccountHelpPageController extends Controller
$seo = app(SeoFactory::class) $seo = app(SeoFactory::class)
->collectionPage( ->collectionPage(
'Account Settings Help — Skinbase', 'Account Settings Help — Skinbase',
'Learn how account settings, profile settings, email changes, password care, and creator preferences work on Skinbase Nova.', 'Learn how account settings, profile settings, email changes, password care, and creator preferences work on Skinbase.',
$canonical, $canonical,
) )
->toArray(); ->toArray();
@@ -18,6 +18,7 @@ use App\Services\Maturity\ArtworkMaturityService;
use App\Support\Seo\SeoFactory; use App\Support\Seo\SeoFactory;
use App\Support\AvatarUrl; use App\Support\AvatarUrl;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
@@ -104,6 +105,8 @@ final class ArtworkPageController extends Controller
->published() ->published()
->firstOrFail(); ->firstOrFail();
$this->loadCategoryAncestors($artwork->categories);
$canonicalSlug = Str::slug((string) ($artwork->slug ?: $artwork->title)); $canonicalSlug = Str::slug((string) ($artwork->slug ?: $artwork->title));
if ($canonicalSlug === '') { if ($canonicalSlug === '') {
$canonicalSlug = (string) $artwork->id; $canonicalSlug = (string) $artwork->id;
@@ -146,7 +149,7 @@ final class ArtworkPageController extends Controller
'md' => $thumbMd, 'md' => $thumbMd,
'lg' => $thumbLg, 'lg' => $thumbLg,
'xl' => $thumbXl, 'xl' => $thumbXl,
], $canonical)->toArray(); ], $canonical, $this->artworkBreadcrumbs($artwork, $canonical))->toArray();
$categoryIds = $artwork->categories->pluck('id')->filter()->values(); $categoryIds = $artwork->categories->pluck('id')->filter()->values();
$tagIds = $artwork->tags->pluck('id')->filter()->values(); $tagIds = $artwork->tags->pluck('id')->filter()->values();
@@ -203,10 +206,25 @@ final class ArtworkPageController extends Controller
->values() ->values()
->all(); ->all();
// Recursive helper to format a comment and its nested replies $approvedComments = ArtworkComment::query()
->with('user.profile')
->where('artwork_id', $artwork->id)
->where('is_approved', true)
->orderBy('created_at')
->limit(500)
->get();
$commentsByParent = $approvedComments->groupBy(
static fn (ArtworkComment $comment): string => $comment->parent_id === null
? 'root'
: (string) $comment->parent_id
);
// Recursive helper to format a comment and its nested replies.
$formatComment = null; $formatComment = null;
$formatComment = function (ArtworkComment $c) use (&$formatComment): array { $formatComment = function (ArtworkComment $c) use (&$formatComment, $commentsByParent): array {
$replies = $c->relationLoaded('approvedReplies') ? $c->approvedReplies : collect(); /** @var Collection<int, ArtworkComment> $replies */
$replies = $commentsByParent->get((string) $c->id, collect());
$user = $c->user; $user = $c->user;
$userId = (int) ($c->user_id ?? 0); $userId = (int) ($c->user_id ?? 0);
$avatarHash = $user?->profile?->avatar_hash ?? null; $avatarHash = $user?->profile?->avatar_hash ?? null;
@@ -234,7 +252,9 @@ final class ArtworkPageController extends Controller
'username' => $user?->username, 'username' => $user?->username,
'display' => $user?->username ?? $user?->name ?? 'User', 'display' => $user?->username ?? $user?->name ?? 'User',
'profile_url' => $user?->username ? '/@' . $user->username : ($userId > 0 ? '/profile/' . $userId : null), 'profile_url' => $user?->username ? '/@' . $user->username : ($userId > 0 ? '/profile/' . $userId : null),
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64), 'avatar_url' => $avatarHash !== null
? AvatarUrl::forUser($userId, $avatarHash, 64)
: AvatarUrl::default(),
'level' => (int) ($user?->level ?? 1), 'level' => (int) ($user?->level ?? 1),
'rank' => (string) ($user?->rank ?? 'Newbie'), 'rank' => (string) ($user?->rank ?? 'Newbie'),
], ],
@@ -242,13 +262,8 @@ final class ArtworkPageController extends Controller
]; ];
}; };
$comments = ArtworkComment::with(['user.profile', 'approvedReplies']) $comments = $commentsByParent
->where('artwork_id', $artwork->id) ->get('root', collect())
->where('is_approved', true)
->whereNull('parent_id')
->orderBy('created_at')
->limit(500)
->get()
->map($formatComment) ->map($formatComment)
->values() ->values()
->all(); ->all();
@@ -314,6 +329,105 @@ final class ArtworkPageController extends Controller
return $totals; return $totals;
} }
private function loadCategoryAncestors(Collection $categories): void
{
$currentLevel = $categories->filter();
while ($currentLevel->isNotEmpty()) {
$fetchedParents = collect();
$missingParentIds = $currentLevel
->filter(static fn ($category) => $category->parent_id !== null && ! $category->relationLoaded('parent'))
->pluck('parent_id')
->filter()
->unique()
->values();
if ($missingParentIds->isNotEmpty()) {
$fetchedParents = \App\Models\Category::query()
->with('contentType')
->whereIn('id', $missingParentIds->all())
->get()
->keyBy('id');
$currentLevel->each(function ($category) use ($fetchedParents): void {
if ($category->parent_id !== null && ! $category->relationLoaded('parent')) {
$category->setRelation('parent', $fetchedParents->get($category->parent_id));
}
});
}
$currentLevel = $currentLevel
->map(static fn ($category) => $category->relationLoaded('parent') ? $category->getRelation('parent') : null)
->filter()
->unique('id')
->values();
}
}
/**
* @return array<int, array{name: string, url: string}>
*/
private function artworkBreadcrumbs(Artwork $artwork, string $canonical): array
{
$primaryCategory = $artwork->categories
->sortBy(fn ($category) => [
(int) ($category->sort_order ?? 0),
(string) ($category->name ?? ''),
])
->first();
if ($primaryCategory === null) {
return [
['name' => 'Explore', 'url' => url('/explore')],
['name' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'url' => $canonical],
];
}
$contentType = $primaryCategory->contentType;
$chain = collect();
$current = $primaryCategory;
while ($current !== null) {
$chain->prepend($current);
$current = $current->relationLoaded('parent') ? $current->parent : null;
}
$breadcrumbs = [];
$contentTypeSlug = trim((string) ($contentType?->slug ?? ''));
$contentTypeName = trim((string) ($contentType?->name ?? ''));
if ($contentTypeSlug !== '' && $contentTypeName !== '') {
$breadcrumbs[] = [
'name' => $contentTypeName,
'url' => url('/' . $contentTypeSlug),
];
}
$pathSegments = [];
foreach ($chain as $category) {
$slug = trim((string) ($category->slug ?? ''));
$name = trim((string) ($category->name ?? ''));
if ($slug === '' || $name === '' || $contentTypeSlug === '') {
continue;
}
$pathSegments[] = $slug;
$breadcrumbs[] = [
'name' => $name,
'url' => url('/' . $contentTypeSlug . '/' . implode('/', $pathSegments)),
];
}
$breadcrumbs[] = [
'name' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'url' => $canonical,
];
return $breadcrumbs;
}
/** Silently catch suggestion query failures so error page never crashes. */ /** Silently catch suggestion query failures so error page never crashes. */
private function safeSuggestions(callable $fn): mixed private function safeSuggestions(callable $fn): mixed
{ {
@@ -18,7 +18,7 @@ final class AuthHelpPageController extends Controller
$seo = app(SeoFactory::class) $seo = app(SeoFactory::class)
->collectionPage( ->collectionPage(
'Signup and Login Help — Skinbase', 'Signup and Login Help — Skinbase',
'Learn how signup, login, password recovery, verification, and account access work on Skinbase Nova, with clear guidance for common access problems and practical next steps.', 'Learn how signup, login, password recovery, verification, and account access work on Skinbase, with clear guidance for common access problems and practical next steps.',
$canonical, $canonical,
) )
->toArray(); ->toArray();
@@ -27,7 +27,7 @@ final class AuthHelpPageController extends Controller
return Inertia::render('Help/AuthHelpPage', [ return Inertia::render('Help/AuthHelpPage', [
'title' => 'Signup & Login Help', 'title' => 'Signup & Login Help',
'description' => 'Get clear help for account creation, sign-in, password recovery, verification basics, and common access problems on Skinbase Nova.', 'description' => 'Get clear help for account creation, sign-in, password recovery, verification basics, and common access problems on Skinbase.',
'seo' => $seo, 'seo' => $seo,
'links' => [ 'links' => [
'help_home' => route('help'), 'help_home' => route('help'),
@@ -148,7 +148,12 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
$ttl = self::SORT_TTL_MAP[$sort] ?? 300; $ttl = self::SORT_TTL_MAP[$sort] ?? 300;
$mainCategories = $this->mainCategories(); $mainCategories = $this->mainCategories();
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get(); $rootCategories = $contentType->rootCategories()
->with('contentType')
->orderBy('sort_order')
->orderBy('name')
->get();
$rootCategoryLinks = $this->buildCategoryLinkItems($rootCategories, $contentSlug);
$normalizedPath = trim((string) $path, '/'); $normalizedPath = trim((string) $path, '/');
if ($normalizedPath === '') { if ($normalizedPath === '') {
@@ -160,13 +165,14 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'], 'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'],
], $perPage, false, $page) ], $perPage, false, $page)
); );
$this->loadGalleryArtworkRelations($artworks->getCollection());
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); $artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug), $artworks); $seo = $this->buildPaginationSeo($request, url('/' . $contentSlug), $artworks);
return view('gallery.index', [ return view('gallery.index', [
'gallery_type' => 'content-type', 'gallery_type' => 'content-type',
'mainCategories' => $mainCategories, 'mainCategories' => $mainCategories,
'subcategories' => $rootCategories, 'subcategories' => $rootCategoryLinks,
'contentType' => $contentType, 'contentType' => $contentType,
'category' => null, 'category' => null,
'artworks' => $artworks, 'artworks' => $artworks,
@@ -175,10 +181,10 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'hero_title' => $contentType->name, 'hero_title' => $contentType->name,
'hero_description' => $contentType->description ?? ($contentType->name . ' artworks on Skinbase.'), 'hero_description' => $contentType->description ?? ($contentType->name . ' artworks on Skinbase.'),
'breadcrumbs' => collect([ 'breadcrumbs' => collect([
(object) ['name' => 'Explore', 'url' => '/browse'], (object) ['name' => 'Explore', 'url' => route('explore.index')],
(object) ['name' => $contentType->name, 'url' => '/' . $contentSlug], (object) ['name' => $contentType->name, 'url' => '/' . $contentSlug],
]), ]),
'page_title' => $contentType->name . ' Skinbase Nova', 'page_title' => $contentType->name . ' Skinbase',
'page_meta_description' => $contentType->description ?? ('Discover the best ' . $contentType->name . ' artworks on Skinbase'), 'page_meta_description' => $contentType->description ?? ('Discover the best ' . $contentType->name . ' artworks on Skinbase'),
'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography', 'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography',
'page_canonical' => $seo['canonical'], 'page_canonical' => $seo['canonical'],
@@ -194,6 +200,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
abort(404); abort(404);
} }
$this->loadCategoryLineage($category);
$categorySlugs = $this->categoryFilterSlugs($category); $categorySlugs = $this->categoryFilterSlugs($category);
$filterExpression = $this->categoryPageFilterExpression($contentSlug, $categorySlugs); $filterExpression = $this->categoryPageFilterExpression($contentSlug, $categorySlugs);
@@ -205,20 +213,31 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'], 'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'],
], $perPage, false, $page) ], $perPage, false, $page)
); );
$this->loadGalleryArtworkRelations($artworks->getCollection());
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); $artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug . '/' . strtolower($category->full_slug_path)), $artworks); $seo = $this->buildPaginationSeo($request, url('/' . $contentSlug . '/' . strtolower($category->full_slug_path)), $artworks);
$navigationCategory = $category->parent ?: $category; $navigationCategory = $category->parent ?: $category;
$navigationPath = strtolower($navigationCategory->full_slug_path);
$subcategoryParent = (object) [
'id' => $navigationCategory->id,
'url' => $this->buildCategoryUrl($contentSlug, $navigationPath),
];
$subcategories = $navigationCategory->children()->orderBy('sort_order')->orderBy('name')->get(); $subcategories = $navigationCategory->children()
->with(['contentType', 'parent.contentType'])
->orderBy('sort_order')
->orderBy('name')
->get();
$subcategoryLinks = $this->buildCategoryLinkItems($subcategories, $contentSlug, $navigationPath);
if ($subcategories->isEmpty()) { if ($subcategories->isEmpty()) {
$subcategories = $rootCategories; $subcategoryLinks = $rootCategoryLinks;
} }
$breadcrumbs = collect(array_merge([ $breadcrumbs = collect(array_merge([
(object) [ (object) [
'name' => 'Explore', 'name' => 'Explore',
'url' => '/browse', 'url' => route('explore.index'),
], ],
(object) [ (object) [
'name' => $contentType->name, 'name' => $contentType->name,
@@ -235,8 +254,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
return view('gallery.index', [ return view('gallery.index', [
'gallery_type' => 'category', 'gallery_type' => 'category',
'mainCategories' => $mainCategories, 'mainCategories' => $mainCategories,
'subcategories' => $subcategories, 'subcategories' => $subcategoryLinks,
'subcategory_parent' => $navigationCategory, 'subcategory_parent' => $subcategoryParent,
'contentType' => $contentType, 'contentType' => $contentType,
'category' => $category, 'category' => $category,
'artworks' => $artworks, 'artworks' => $artworks,
@@ -245,7 +264,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'hero_title' => $category->name, 'hero_title' => $category->name,
'hero_description' => $category->description ?? ($contentType->name . ' artworks on Skinbase.'), 'hero_description' => $category->description ?? ($contentType->name . ' artworks on Skinbase.'),
'breadcrumbs' => $breadcrumbs, 'breadcrumbs' => $breadcrumbs,
'page_title' => $category->name . ' Skinbase Nova', 'page_title' => $category->name . ' Skinbase',
'page_meta_description' => $category->description ?? ('Discover the best ' . $category->name . ' ' . $contentType->name . ' artworks on Skinbase'), 'page_meta_description' => $category->description ?? ('Discover the best ' . $category->name . ' ' . $contentType->name . ' artworks on Skinbase'),
'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography', 'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography',
'page_canonical' => $seo['canonical'], 'page_canonical' => $seo['canonical'],
@@ -303,13 +322,12 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
$present = ThumbnailPresenter::present($artwork, 'md'); $present = ThumbnailPresenter::present($artwork, 'md');
$group = $artwork->group; $group = $artwork->group;
$isGroupPublisher = $group !== null; $isGroupPublisher = $group !== null;
$avatarHash = $artwork->user?->profile?->avatar_hash ?? null;
$avatarUrl = $isGroupPublisher $avatarUrl = $isGroupPublisher
? $group->avatarUrl() ? $group->avatarUrl()
: \App\Support\AvatarUrl::forUser( : ($avatarHash !== null
(int) ($artwork->user_id ?? 0), ? \App\Support\AvatarUrl::forUser((int) ($artwork->user_id ?? 0), $avatarHash, 64)
$artwork->user?->profile?->avatar_hash ?? null, : \App\Support\AvatarUrl::default());
64
);
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase'); $displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? ''); $username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null); $profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
@@ -317,6 +335,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
return (object) $this->maturity->decoratePayload([ return (object) $this->maturity->decoratePayload([
'id' => $artwork->id, 'id' => $artwork->id,
'name' => $artwork->title, 'name' => $artwork->title,
'slug' => $artwork->slug,
'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]),
'content_type_name' => $primaryCategory?->contentType?->name ?? '', 'content_type_name' => $primaryCategory?->contentType?->name ?? '',
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '', 'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
'category_name' => $primaryCategory->name ?? '', 'category_name' => $primaryCategory->name ?? '',
@@ -349,27 +369,74 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
*/ */
private function categoryFilterSlugs(Category $category): array private function categoryFilterSlugs(Category $category): array
{ {
$category->loadMissing('descendants');
$slugs = []; $slugs = [];
$stack = [$category]; $pendingParentIds = [$category->id];
while ($stack !== []) { if (! empty($category->slug)) {
/** @var Category $current */ $slugs[] = Str::lower($category->slug);
$current = array_pop($stack); }
if (! empty($current->slug)) {
$slugs[] = Str::lower($current->slug);
}
foreach ($current->children as $child) { while ($pendingParentIds !== []) {
$child->loadMissing('descendants'); $children = Category::query()
$stack[] = $child; ->whereIn('parent_id', $pendingParentIds)
->get(['id', 'slug']);
$pendingParentIds = $children->pluck('id')->all();
foreach ($children as $child) {
if (! empty($child->slug)) {
$slugs[] = Str::lower($child->slug);
}
} }
} }
return array_values(array_unique($slugs)); return array_values(array_unique($slugs));
} }
private function loadCategoryLineage(Category $category): void
{
$current = $category;
while ($current !== null) {
$current->loadMissing(['contentType', 'parent']);
$current = $current->parent;
}
}
private function buildCategoryLinkItems(Collection $categories, string $contentSlug, ?string $basePath = null): Collection
{
$normalizedBasePath = trim(strtolower((string) $basePath), '/');
return $categories->map(function (Category $category) use ($contentSlug, $normalizedBasePath) {
return (object) [
'id' => $category->id,
'name' => $category->name,
'slug' => $category->slug,
'url' => $this->buildCategoryUrl($contentSlug, implode('/', array_filter([$normalizedBasePath, $category->slug]))),
];
});
}
private function buildCategoryUrl(string $contentSlug, ?string $path = null): string
{
$normalizedPath = trim(strtolower((string) $path), '/');
return '/' . implode('/', array_filter([$contentSlug, $normalizedPath]));
}
private function loadGalleryArtworkRelations(Collection $artworks): void
{
if ($artworks->isEmpty()) {
return;
}
$artworks->loadMissing([
'user.profile',
'group',
'categories.contentType',
]);
}
private function categoryFilterClause(string $categorySlug): string private function categoryFilterClause(string $categorySlug): string
{ {
$quoted = addslashes($categorySlug); $quoted = addslashes($categorySlug);

Some files were not shown because too many files have changed in this diff Show More