Optimize academy

This commit is contained in:
2026-06-09 13:16:01 +02:00
parent f89ee937c0
commit 5af95f6533
109 changed files with 6862 additions and 719 deletions

View File

@@ -12,7 +12,14 @@ 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(
@@ -48,6 +55,7 @@ final class AcademyBillingController extends Controller
'activePlanKey' => $activePlan['key'] ?? null,
'activePlanLabel' => $activePlan['label'] ?? null,
'catalog' => $this->catalog(),
'missingRemote' => $this->plans->missingRemotePriceIds(),
'links' => [
'login' => \route('login'),
'pricing' => \route('academy.pricing'),
@@ -64,7 +72,7 @@ final class AcademyBillingController extends Controller
'isGuest' => $user === null,
'isSubscriber' => $user?->hasAcademyCreatorAccess() || $user?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
public function checkout(\Illuminate\Http\Request $request): Checkout|\Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
@@ -110,6 +118,46 @@ final class AcademyBillingController extends Controller
}
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');
}
@@ -133,8 +181,91 @@ final class AcademyBillingController extends Controller
'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) {
\report($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);
}
@@ -161,7 +292,68 @@ final class AcademyBillingController extends Controller
return \redirect()->route('academy.billing.account')->with('error', 'No Stripe billing profile is connected to this account yet.');
}
return $user->redirectToBillingPortal(\route('academy.billing.account'));
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
@@ -180,9 +372,10 @@ final class AcademyBillingController extends Controller
'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('collections');
])->rootView('academy');
}
public function cancel(): \Inertia\Response
@@ -195,8 +388,104 @@ final class AcademyBillingController extends Controller
'pricing' => \route('academy.pricing'),
'academy' => \route('academy.index'),
],
])->rootView('collections');
])->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
{
@@ -230,8 +519,10 @@ final class AcademyBillingController extends Controller
'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('collections');
])->rootView('academy');
}
/**
@@ -279,6 +570,7 @@ final class AcademyBillingController extends Controller
'price_display' => $plan['price_display'],
'configured' => $plan['configured'],
'price_id_valid' => $plan['price_id_valid'],
'remote_price_exists' => $plan['remote_price_exists'] ?? false,
]] : [];
return [
@@ -419,4 +711,4 @@ final class AcademyBillingController extends Controller
return \redirect()->route('academy.pricing')->with('error', $message);
}
}
}

View File

@@ -64,7 +64,7 @@ final class AcademyChallengeController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
public function show(Request $request, string $slug): Response
@@ -126,6 +126,6 @@ final class AcademyChallengeController extends Controller
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('collections');
])->rootView('academy');
}
}
}

View File

@@ -47,7 +47,7 @@ final class AcademyChallengeSubmissionController extends Controller
'published_at' => $artwork->published_at?->toISOString(),
])->values()->all(),
'submitUrl' => route('academy.challenges.submit.store', ['slug' => $challenge->slug]),
])->rootView('collections');
])->rootView('academy');
}
public function store(StoreAcademyChallengeSubmissionRequest $request, string $slug): RedirectResponse
@@ -63,4 +63,4 @@ final class AcademyChallengeSubmissionController extends Controller
return redirect()->route('academy.challenges.show', ['slug' => $challenge->slug])
->with('success', 'Challenge submission received and queued for review.');
}
}
}

View File

@@ -102,7 +102,7 @@ final class AcademyCourseController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
public function show(Request $request, AcademyCourse $course): Response
@@ -218,6 +218,6 @@ final class AcademyCourseController extends Controller
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => false,
],
])->rootView('collections');
])->rootView('academy');
}
}
}

View File

@@ -119,6 +119,6 @@ final class AcademyCourseLessonController extends Controller
],
'outline' => $courseOutline,
],
])->rootView('collections');
])->rootView('academy');
}
}
}

View File

@@ -98,6 +98,6 @@ final class AcademyHomeController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
}
}

View File

@@ -112,7 +112,7 @@ final class AcademyLessonController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
public function show(Request $request, string $slug): Response
@@ -220,6 +220,6 @@ final class AcademyLessonController extends Controller
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('collections');
])->rootView('academy');
}
}

View File

@@ -79,6 +79,6 @@ final class AcademyPricingController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
}
}

View File

@@ -126,7 +126,7 @@ final class AcademyPromptController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
public function popular(Request $request): Response
@@ -260,7 +260,7 @@ final class AcademyPromptController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
/**
@@ -366,7 +366,7 @@ final class AcademyPromptController extends Controller
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('collections');
])->rootView('academy');
}
/**
@@ -466,4 +466,4 @@ final class AcademyPromptController extends Controller
->values()
->all();
}
}
}

View File

@@ -64,7 +64,7 @@ final class AcademyPromptPackController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
public function show(Request $request, string $slug): Response
@@ -110,6 +110,6 @@ final class AcademyPromptPackController extends Controller
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('collections');
])->rootView('academy');
}
}
}

View File

@@ -30,6 +30,7 @@ final class ArtworkTagController extends Controller
$queueConnection = (string) config('queue.default', 'sync');
$visionEnabled = (bool) config('vision.enabled', true);
$autoTaggingEnabled = (bool) config('vision.auto_tagging.enabled', false);
$queuedCount = 0;
$failedCount = 0;
@@ -56,7 +57,7 @@ final class ArtworkTagController extends Controller
$triggered = 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);
$triggered = true;
$queuedCount = max(1, $queuedCount);
@@ -89,6 +90,7 @@ final class ArtworkTagController extends Controller
'queued_jobs' => $queuedCount,
'failed_jobs' => $failedCount,
'triggered' => $triggered,
'auto_tagging_enabled' => $autoTaggingEnabled,
'ai_tag_count' => (int) $tags->where('is_ai', true)->count(),
'total_tag_count' => (int) $tags->count(),
],

View File

@@ -239,10 +239,18 @@ final class UploadController extends Controller
}
// Derivatives are available now; dispatch AI auto-tagging.
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit();
DetectArtworkMaturityJob::dispatch($artworkId, $validated->hash)->afterCommit();
GenerateArtworkEmbeddingJob::dispatch($artworkId, $validated->hash)->afterCommit();
AnalyzeArtworkAiAssistJob::dispatch($artworkId)->afterCommit();
if ((bool) config('vision.auto_tagging.enabled', false)) {
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->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;
});

View File

@@ -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]),
];
}
}

View File

@@ -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,
];
}
}

View File

@@ -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;
}
}
}

View File

@@ -17,6 +17,7 @@ use App\Models\AcademyBadge;
use App\Models\AcademyCategory;
use App\Models\AcademyChallenge;
use App\Models\AcademyChallengeSubmission;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseLesson;
use App\Models\AcademyCourseSection;
@@ -31,6 +32,7 @@ use App\Services\Academy\AcademyAdminBillingOverviewService;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyCourseLessonOrderingService;
use App\Services\Academy\AcademyLessonMarkdownRenderer;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -604,34 +606,48 @@ final class AcademyAdminController extends Controller
$meta = $this->resourceMeta($resource);
$search = trim((string) request()->query('search', ''));
$query = $meta['model']::query();
$filters = [
'search' => $search,
];
$summary = null;
if ($resource === 'courses') {
$query->withCount('courseLessons');
if ($search !== '') {
$query->where(function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
$this->applyCourseAdminSearch($query, $search);
}
$query->orderByDesc('is_featured')
->orderBy('order_num')
->orderByDesc('updated_at')
->orderByDesc('id');
} elseif ($resource === 'prompts') {
$query->with('category');
$query->withSum(['metrics as total_views' => function ($builder): void {
$builder->where('content_type', AcademyAnalyticsContentType::PROMPT);
}], 'views');
$promptFilters = [
'category' => (string) request()->query('category', 'all'),
'featured' => (string) request()->query('featured', 'all'),
'prompt_of_week' => (string) request()->query('prompt_of_week', 'all'),
'active' => (string) request()->query('active', 'all'),
'access_level' => (string) request()->query('access_level', 'all'),
'difficulty' => (string) request()->query('difficulty', 'all'),
'order' => (string) request()->query('order', 'updated_desc'),
];
$filters = array_merge($filters, $promptFilters);
$this->applyPromptAdminSearch($query, $search);
$this->applyPromptAdminFilters($query, $promptFilters);
$this->applyPromptAdminOrdering($query, $promptFilters['order']);
$summary = $this->promptAdminSummary($search, $promptFilters);
} else {
$query->latest('updated_at');
}
if ($resource === 'prompts') {
$query->with('category');
}
if ($resource === 'lessons') {
$query->with('courses:id,title');
}
@@ -646,48 +662,191 @@ final class AcademyAdminController extends Controller
'items' => $items,
'columns' => $meta['columns'],
'createUrl' => route($meta['route_base'].'.create'),
'filters' => [
'search' => $search,
],
'filters' => $filters,
'filterOptions' => $resource === 'prompts' ? [
'categories' => $this->promptAdminCategoryFilterOptions(),
'difficulty' => $this->filterOptionsWithAll($this->difficultyOptions(), 'All difficulties'),
'access' => $this->filterOptionsWithAll($this->accessOptions(), 'All access levels'),
'featured' => [
['value' => 'all', 'label' => 'Any featured state'],
['value' => 'yes', 'label' => 'Featured only'],
['value' => 'no', 'label' => 'Not featured'],
],
'promptOfWeek' => [
['value' => 'all', 'label' => 'Any weekly state'],
['value' => 'yes', 'label' => 'Prompt of the week'],
['value' => 'no', 'label' => 'Not prompt of the week'],
],
'active' => [
['value' => 'all', 'label' => 'Any visibility state'],
['value' => 'active', 'label' => 'Active only'],
['value' => 'inactive', 'label' => 'Inactive only'],
],
'order' => [
['value' => 'updated_desc', 'label' => 'Updated newest'],
['value' => 'updated_asc', 'label' => 'Updated oldest'],
['value' => 'views_desc', 'label' => 'Most viewed'],
['value' => 'title_asc', 'label' => 'Title A-Z'],
['value' => 'title_desc', 'label' => 'Title Z-A'],
['value' => 'access_asc', 'label' => 'Access'],
['value' => 'difficulty_asc', 'label' => 'Difficulty'],
['value' => 'featured_desc', 'label' => 'Featured first'],
],
] : null,
'summary' => $resource === 'courses' ? [
'total' => (int) $items->total(),
'published' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where(function ($inner) use ($like): void {
$inner->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
})->where('status', AcademyCourse::STATUS_PUBLISHED)->count(),
'featured' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where(function ($inner) use ($like): void {
$inner->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
})->where('is_featured', true)->count(),
'drafts' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where(function ($inner) use ($like): void {
$inner->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
})->where('status', AcademyCourse::STATUS_DRAFT)->count(),
] : null,
'published' => (int) (clone $meta['model']::query())->tap(fn ($builder) => $this->applyCourseAdminSearch($builder, $search))->where('status', AcademyCourse::STATUS_PUBLISHED)->count(),
'featured' => (int) (clone $meta['model']::query())->tap(fn ($builder) => $this->applyCourseAdminSearch($builder, $search))->where('is_featured', true)->count(),
'drafts' => (int) (clone $meta['model']::query())->tap(fn ($builder) => $this->applyCourseAdminSearch($builder, $search))->where('status', AcademyCourse::STATUS_DRAFT)->count(),
] : $summary,
]);
}
private function applyCourseAdminSearch($query, string $search): void
{
if ($search === '') {
return;
}
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$query->where(function ($builder) use ($like): void {
$builder->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
}
private function applyPromptAdminSearch($query, string $search): void
{
if ($search === '') {
return;
}
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$query->where(function ($builder) use ($like): void {
$builder->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('prompt', 'like', $like)
->orWhere('negative_prompt', 'like', $like)
->orWhere('usage_notes', 'like', $like)
->orWhere('workflow_notes', 'like', $like)
->orWhereHas('category', function ($categoryQuery) use ($like): void {
$categoryQuery->where('name', 'like', $like);
});
});
}
private function applyPromptAdminFilters($query, array $filters, bool $includeAccessFilter = true): void
{
$category = (string) ($filters['category'] ?? 'all');
$featured = (string) ($filters['featured'] ?? 'all');
$promptOfWeek = (string) ($filters['prompt_of_week'] ?? 'all');
$active = (string) ($filters['active'] ?? 'all');
$accessLevel = (string) ($filters['access_level'] ?? 'all');
$difficulty = (string) ($filters['difficulty'] ?? 'all');
if ($category === 'uncategorized') {
$query->whereNull('category_id');
} elseif ($category !== '' && $category !== 'all' && ctype_digit($category)) {
$query->where('category_id', (int) $category);
}
if ($featured === 'yes') {
$query->where('featured', true);
} elseif ($featured === 'no') {
$query->where('featured', false);
}
if ($promptOfWeek === 'yes') {
$query->where('prompt_of_week', true);
} elseif ($promptOfWeek === 'no') {
$query->where('prompt_of_week', false);
}
if ($active === 'active') {
$query->where('active', true);
} elseif ($active === 'inactive') {
$query->where('active', false);
}
if ($includeAccessFilter && in_array($accessLevel, ['free', 'creator', 'pro'], true)) {
$query->where('access_level', $accessLevel);
}
if (in_array($difficulty, array_column($this->difficultyOptions(), 'value'), true)) {
$query->where('difficulty', $difficulty);
}
}
private function applyPromptAdminOrdering($query, string $order): void
{
match ($order) {
'updated_asc' => $query->orderBy('updated_at')->orderBy('id'),
'views_desc' => $query->orderByDesc('total_views')->orderByDesc('updated_at')->orderByDesc('id'),
'title_asc' => $query->orderBy('title')->orderByDesc('updated_at'),
'title_desc' => $query->orderByDesc('title')->orderByDesc('updated_at'),
'access_asc' => $query->orderByRaw("FIELD(access_level, 'free', 'creator', 'pro')")->orderBy('title'),
'difficulty_asc' => $query->orderBy('difficulty')->orderBy('title'),
'featured_desc' => $query->orderByDesc('featured')->orderByDesc('prompt_of_week')->orderBy('title'),
default => $query->orderByDesc('updated_at')->orderByDesc('id'),
};
}
private function promptAdminSummary(string $search, array $filters): array
{
$summaryQuery = AcademyPromptTemplate::query();
$accessSummaryQuery = AcademyPromptTemplate::query();
$this->applyPromptAdminSearch($summaryQuery, $search);
$this->applyPromptAdminFilters($summaryQuery, $filters);
$this->applyPromptAdminSearch($accessSummaryQuery, $search);
$this->applyPromptAdminFilters($accessSummaryQuery, $filters, false);
return [
'total' => (int) $summaryQuery->count(),
'active' => (int) (clone $summaryQuery)->where('active', true)->count(),
'featured' => (int) (clone $summaryQuery)->where('featured', true)->count(),
'promptOfWeek' => (int) (clone $summaryQuery)->where('prompt_of_week', true)->count(),
'access' => [
'free' => (int) (clone $accessSummaryQuery)->where('access_level', 'free')->count(),
'creator' => (int) (clone $accessSummaryQuery)->where('access_level', 'creator')->count(),
'pro' => (int) (clone $accessSummaryQuery)->where('access_level', 'pro')->count(),
],
];
}
private function promptAdminCategoryFilterOptions(): array
{
return AcademyCategory::query()
->where('type', 'prompt')
->orderBy('order_num')
->orderBy('name')
->get()
->map(fn (AcademyCategory $category): array => ['value' => (string) $category->id, 'label' => $category->name])
->prepend(['value' => 'uncategorized', 'label' => 'Uncategorized'])
->prepend(['value' => 'all', 'label' => 'All categories'])
->values()
->all();
}
private function filterOptionsWithAll(array $options, string $allLabel): array
{
return collect($options)
->map(fn (array $option): array => [
'value' => (string) ($option['value'] ?? ''),
'label' => (string) ($option['label'] ?? $option['value'] ?? ''),
])
->prepend(['value' => 'all', 'label' => $allLabel])
->values()
->all();
}
private function renderForm(string $resource, Model $record): Response
{
$meta = $this->resourceMeta($resource);
@@ -893,7 +1052,7 @@ final class AcademyAdminController extends Controller
'singular' => 'prompt template',
'subtitle' => 'Manage prompt previews, premium prompts, and prompt of the week.',
'route_base' => 'admin.academy.prompts',
'columns' => ['title', 'difficulty', 'access_level', 'prompt_of_week', 'active'],
'columns' => ['title', 'category_name', 'difficulty', 'access_level', 'prompt_of_week', 'active'],
'fields' => [
['name' => 'category_id', 'label' => 'Category', 'type' => 'select', 'options' => $this->categoryOptions('prompt')],
['name' => 'title', 'label' => 'Title', 'type' => 'text'],
@@ -907,6 +1066,7 @@ final class AcademyAdminController extends Controller
['name' => 'placeholders', 'label' => 'Placeholders JSON', 'type' => 'json'],
['name' => 'helper_prompts', 'label' => 'Helper Prompts JSON', 'type' => 'json'],
['name' => 'prompt_variants', 'label' => 'Prompt Variants JSON', 'type' => 'json'],
['name' => 'filled_examples', 'label' => 'Filled Examples JSON', 'type' => 'json'],
['name' => 'difficulty', 'label' => 'Difficulty', 'type' => 'select', 'options' => $this->difficultyOptions()],
['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->accessOptions()],
['name' => 'aspect_ratio', 'label' => 'Aspect Ratio', 'type' => 'text'],
@@ -1048,6 +1208,7 @@ final class AcademyAdminController extends Controller
'active' => (bool) $model->active,
'preview_image_url' => $this->resolvePromptPreviewImageUrl((string) ($model->preview_image ?? '')),
'comparisons_count' => count($this->serializePromptToolNotes((array) ($model->tool_notes ?? []))),
'views_count' => (int) ($model->total_views ?? 0),
'tags' => array_values(array_filter(array_map(static fn ($tag): string => trim((string) $tag), (array) ($model->tags ?? [])))),
'updated_at' => optional($model->updated_at)->toIso8601String(),
'preview_url' => route('academy.prompts.show', ['slug' => $model->slug]),
@@ -1167,6 +1328,7 @@ final class AcademyAdminController extends Controller
'placeholders' => $this->encodePrettyJsonForForm($record->placeholders),
'helper_prompts' => $this->encodePrettyJsonForForm($record->helper_prompts),
'prompt_variants' => $this->encodePrettyJsonForForm($record->prompt_variants),
'filled_examples' => $this->encodePrettyJsonForForm($record->filled_examples),
'difficulty' => (string) ($record->difficulty ?? 'beginner'),
'access_level' => (string) ($record->access_level ?? 'free'),
'aspect_ratio' => (string) ($record->aspect_ratio ?? ''),
@@ -2174,6 +2336,7 @@ final class AcademyAdminController extends Controller
$validated['placeholders'] = $this->normalizePromptPlaceholders($validated['placeholders'] ?? null);
$validated['helper_prompts'] = $this->normalizePromptHelperPrompts($validated['helper_prompts'] ?? null);
$validated['prompt_variants'] = $this->normalizePromptVariants($validated['prompt_variants'] ?? null);
$validated['filled_examples'] = $this->normalizePromptFilledExamples($validated['filled_examples'] ?? null);
$validated['tool_notes'] = $this->normalizePromptToolNotes((array) ($validated['tool_notes'] ?? []));
$previousToolNotes = $this->normalizePromptToolNotes((array) ($prompt?->tool_notes ?? []));
@@ -2382,6 +2545,56 @@ final class AcademyAdminController extends Controller
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function normalizePromptFilledExamples(mixed $filledExamples): array
{
if (! is_array($filledExamples)) {
return [];
}
return collect($filledExamples)
->filter(static fn ($example): bool => is_array($example))
->map(function (array $example): array {
return [
'title' => $this->nullableTrimmedString($example['title'] ?? null),
'description' => $this->nullableTrimmedString($example['description'] ?? null),
'placeholder_values' => collect(is_array($example['placeholder_values'] ?? null) ? $example['placeholder_values'] : [])
->mapWithKeys(function ($value, $key): array {
$normalizedKey = trim((string) $key);
if ($normalizedKey === '') {
return [];
}
$normalizedValue = $this->normalizePromptJsonValue($value);
if ($normalizedValue === null || $normalizedValue === '' || $normalizedValue === []) {
return [];
}
return [$normalizedKey => $normalizedValue];
})
->all(),
'prompt' => $this->nullableTrimmedString($example['prompt'] ?? null),
'negative_prompt' => $this->nullableTrimmedString($example['negative_prompt'] ?? null),
];
})
->filter(function (array $example): bool {
return collect([
$example['title'] ?? null,
$example['description'] ?? null,
$example['prompt'] ?? null,
$example['negative_prompt'] ?? null,
$example['placeholder_values'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []);
})
->take(5)
->values()
->all();
}
/**
* @return array<int, string>
*/

View File

@@ -27,8 +27,10 @@ class FeaturedArtworkAdminController extends Controller
{
$isAdminSurface = $request->routeIs('admin.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(),
[
'endpoints' => [
@@ -49,7 +51,7 @@ class FeaturedArtworkAdminController extends Controller
'robots' => 'index,follow',
],
],
))->rootView($isAdminSurface ? 'admin' : 'collections');
))->rootView($rootView);
}
public function search(Request $request): JsonResponse
@@ -215,4 +217,4 @@ class FeaturedArtworkAdminController extends Controller
{
return Schema::hasColumn('artwork_features', 'force_hero');
}
}
}