feat: ship creator journey v2 and profile updates
This commit is contained in:
310
app/Http/Controllers/Settings/ArtworkMaturityAdminController.php
Normal file
310
app/Http/Controllers/Settings/ArtworkMaturityAdminController.php
Normal file
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkMaturityAuditFinding;
|
||||
use App\Models\User;
|
||||
use App\Services\Maturity\ArtworkMaturityAuditService;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class ArtworkMaturityAdminController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtworkMaturityService $maturity,
|
||||
private readonly ArtworkMaturityAuditService $audit,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$stats = $this->queueStats();
|
||||
$status = $this->initialStatus($request, $stats);
|
||||
$routes = $this->routeNamesForRequest($request);
|
||||
|
||||
return Inertia::render('Moderation/ArtworkMaturityQueue', [
|
||||
'title' => 'Artwork Maturity Queue',
|
||||
'initialItems' => $this->queueItems($status),
|
||||
'initialFilters' => [
|
||||
'status' => $status,
|
||||
'ai_action' => 'all',
|
||||
'ai_status' => 'all',
|
||||
],
|
||||
'stats' => $stats,
|
||||
'endpoints' => [
|
||||
'list' => route($routes['list']),
|
||||
'reviewPattern' => route($routes['review'], ['artwork' => '__ARTWORK__']),
|
||||
],
|
||||
'filterOptions' => [
|
||||
'aiAction' => [
|
||||
['value' => 'all', 'label' => 'All actions'],
|
||||
['value' => ArtworkMaturityService::AI_ACTION_SAFE, 'label' => 'Safe'],
|
||||
['value' => ArtworkMaturityService::AI_ACTION_REVIEW, 'label' => 'Review'],
|
||||
['value' => ArtworkMaturityService::AI_ACTION_FLAG_HIGH, 'label' => 'Flag high'],
|
||||
],
|
||||
'aiStatus' => [
|
||||
['value' => 'all', 'label' => 'All statuses'],
|
||||
['value' => ArtworkMaturityService::AI_STATUS_SUCCEEDED, 'label' => 'Succeeded'],
|
||||
['value' => ArtworkMaturityService::AI_STATUS_PENDING, 'label' => 'Pending'],
|
||||
['value' => ArtworkMaturityService::AI_STATUS_FAILED, 'label' => 'Failed'],
|
||||
['value' => ArtworkMaturityService::AI_STATUS_SKIPPED, 'label' => 'Skipped'],
|
||||
],
|
||||
],
|
||||
'reviewActions' => [
|
||||
['value' => 'mark_safe', 'label' => 'Mark safe'],
|
||||
['value' => 'mark_mature', 'label' => 'Mark mature'],
|
||||
['value' => 'confirm_current', 'label' => 'Confirm current state'],
|
||||
],
|
||||
])->rootView('moderation');
|
||||
}
|
||||
|
||||
public function list(Request $request): JsonResponse
|
||||
{
|
||||
$status = $this->normalizeStatus((string) $request->query('status', 'suspected'));
|
||||
$aiAction = strtolower((string) $request->query('ai_action', 'all'));
|
||||
$aiStatus = strtolower((string) $request->query('ai_status', 'all'));
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->queueItems($status, $aiAction, $aiStatus),
|
||||
'meta' => [
|
||||
'stats' => $this->queueStats(),
|
||||
'status' => $status,
|
||||
'filters' => [
|
||||
'ai_action' => $aiAction,
|
||||
'ai_status' => $aiStatus,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function review(Request $request, Artwork $artwork): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'action' => ['required', 'in:mark_safe,mark_mature,confirm_current'],
|
||||
'note' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
/** @var User $moderator */
|
||||
$moderator = $request->user('controlpanel') ?? $request->user() ?? abort(403, 'Admin access required.');
|
||||
$artwork = $this->maturity->review($artwork, (string) $validated['action'], $moderator, $validated['note'] ?? null);
|
||||
$this->audit->resolveFindingForReview($artwork, $moderator, (string) $validated['action'], $validated['note'] ?? null);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'artwork' => $this->mapQueueItem($artwork->loadMissing(['user.profile', 'group', 'categories.contentType'])),
|
||||
'stats' => $this->queueStats(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function queueItems(string $status, string $aiAction = 'all', string $aiStatus = 'all'): array
|
||||
{
|
||||
if ($status === 'audit') {
|
||||
return $this->auditQueueItems($aiAction, $aiStatus);
|
||||
}
|
||||
|
||||
$query = Artwork::query()
|
||||
->with(['user.profile', 'group', 'categories.contentType'])
|
||||
->where(function ($builder): void {
|
||||
$builder->where('maturity_status', ArtworkMaturityService::STATUS_SUSPECTED)
|
||||
->orWhere(function ($reviewed): void {
|
||||
$reviewed->where('maturity_status', ArtworkMaturityService::STATUS_REVIEWED)
|
||||
->whereNotNull('maturity_reviewed_at');
|
||||
});
|
||||
})
|
||||
->latest('maturity_flagged_at')
|
||||
->latest('published_at')
|
||||
->limit(100);
|
||||
|
||||
if ($status === 'reviewed') {
|
||||
$query->where('maturity_status', ArtworkMaturityService::STATUS_REVIEWED);
|
||||
} else {
|
||||
$query->where('maturity_status', ArtworkMaturityService::STATUS_SUSPECTED);
|
||||
}
|
||||
|
||||
if (in_array($aiAction, [
|
||||
ArtworkMaturityService::AI_ACTION_SAFE,
|
||||
ArtworkMaturityService::AI_ACTION_REVIEW,
|
||||
ArtworkMaturityService::AI_ACTION_FLAG_HIGH,
|
||||
], true)) {
|
||||
$query->where('maturity_ai_action_hint', $aiAction);
|
||||
}
|
||||
|
||||
if (in_array($aiStatus, [
|
||||
ArtworkMaturityService::AI_STATUS_SUCCEEDED,
|
||||
ArtworkMaturityService::AI_STATUS_PENDING,
|
||||
ArtworkMaturityService::AI_STATUS_FAILED,
|
||||
ArtworkMaturityService::AI_STATUS_SKIPPED,
|
||||
], true)) {
|
||||
$query->where('maturity_ai_status', $aiStatus);
|
||||
}
|
||||
|
||||
return $query->get()->map(fn (Artwork $artwork): array => $this->mapQueueItem($artwork))->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function auditQueueItems(string $aiAction = 'all', string $aiStatus = 'all'): array
|
||||
{
|
||||
$query = $this->audit->openFindingsQuery()
|
||||
->latest('detected_at')
|
||||
->latest('updated_at')
|
||||
->limit(100);
|
||||
|
||||
if (in_array($aiAction, [
|
||||
ArtworkMaturityService::AI_ACTION_SAFE,
|
||||
ArtworkMaturityService::AI_ACTION_REVIEW,
|
||||
ArtworkMaturityService::AI_ACTION_FLAG_HIGH,
|
||||
], true)) {
|
||||
$query->where('ai_action_hint', $aiAction);
|
||||
}
|
||||
|
||||
if (in_array($aiStatus, [
|
||||
ArtworkMaturityService::AI_STATUS_SUCCEEDED,
|
||||
ArtworkMaturityService::AI_STATUS_PENDING,
|
||||
ArtworkMaturityService::AI_STATUS_FAILED,
|
||||
ArtworkMaturityService::AI_STATUS_SKIPPED,
|
||||
ArtworkMaturityService::AI_STATUS_NOT_REQUESTED,
|
||||
], true)) {
|
||||
$query->where('ai_status', $aiStatus);
|
||||
}
|
||||
|
||||
return $query->get()->map(fn (ArtworkMaturityAuditFinding $finding): array => $this->mapAuditQueueItem($finding))->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function queueStats(): array
|
||||
{
|
||||
return [
|
||||
'suspected' => (int) Artwork::query()->where('maturity_status', ArtworkMaturityService::STATUS_SUSPECTED)->count(),
|
||||
'audit' => $this->audit->openFindingsCount(),
|
||||
'reviewed' => (int) Artwork::query()->where('maturity_status', ArtworkMaturityService::STATUS_REVIEWED)->count(),
|
||||
'mature' => (int) Artwork::query()->where('is_mature', true)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapAuditQueueItem(ArtworkMaturityAuditFinding $finding): array
|
||||
{
|
||||
$artwork = $finding->artwork;
|
||||
|
||||
return $this->mapQueueItem($artwork, [
|
||||
'status' => (string) $finding->status,
|
||||
'thumbnail_variant' => $finding->thumbnail_variant,
|
||||
'detected_at' => optional($finding->detected_at)->toIsoString(),
|
||||
'last_scanned_at' => optional($finding->last_scanned_at)->toIsoString(),
|
||||
'ai_label' => $finding->ai_label,
|
||||
'ai_confidence' => $finding->ai_confidence,
|
||||
'ai_score' => $finding->ai_score,
|
||||
'ai_labels' => $finding->ai_labels,
|
||||
'ai_model' => $finding->ai_model,
|
||||
'ai_threshold_used' => $finding->ai_threshold_used,
|
||||
'ai_analysis_time_ms' => $finding->ai_analysis_time_ms,
|
||||
'ai_action_hint' => $finding->ai_action_hint,
|
||||
'ai_status' => $finding->ai_status,
|
||||
'ai_advisory' => $finding->ai_advisory,
|
||||
'legacy_unset' => $this->audit->isArtworkEligible($artwork),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapQueueItem(Artwork $artwork, ?array $audit = null): array
|
||||
{
|
||||
$category = $artwork->categories->sortBy('sort_order')->first();
|
||||
$publisherName = $artwork->group?->name ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist';
|
||||
$thumb = ThumbnailPresenter::present($artwork, 'md');
|
||||
$preview = ThumbnailPresenter::present($artwork, 'xl');
|
||||
|
||||
return [
|
||||
'id' => (int) $artwork->id,
|
||||
'title' => (string) $artwork->title,
|
||||
'url' => route('art.show', ['id' => $artwork->id, 'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id]),
|
||||
'admin_url' => route('admin.cp.artworks.edit', ['id' => $artwork->id]),
|
||||
'thumbnail' => $thumb['url'] ?? null,
|
||||
'preview_image' => $preview['url'] ?? ($thumb['url'] ?? null),
|
||||
'publisher' => $publisherName,
|
||||
'published_at' => optional($artwork->published_at)->toIsoString(),
|
||||
'content_type' => $category?->contentType?->name,
|
||||
'category' => $category?->name,
|
||||
'maturity' => $this->maturity->presentation($artwork, null),
|
||||
'audit' => $audit,
|
||||
'review' => [
|
||||
'reviewed_at' => optional($artwork->maturity_reviewed_at)->toIsoString(),
|
||||
'reviewed_by' => $artwork->maturity_reviewed_by,
|
||||
'reviewer_note' => $artwork->maturity_reviewer_note,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeStatus(string $status): string
|
||||
{
|
||||
$normalized = Str::lower(trim($status));
|
||||
|
||||
return in_array($normalized, ['suspected', 'reviewed', 'audit'], true)
|
||||
? $normalized
|
||||
: 'suspected';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $stats
|
||||
*/
|
||||
private function initialStatus(Request $request, array $stats): string
|
||||
{
|
||||
if ($request->query->has('status')) {
|
||||
return $this->normalizeStatus((string) $request->query('status'));
|
||||
}
|
||||
|
||||
if (($stats['suspected'] ?? 0) > 0) {
|
||||
return 'suspected';
|
||||
}
|
||||
|
||||
if (($stats['audit'] ?? 0) > 0) {
|
||||
return 'audit';
|
||||
}
|
||||
|
||||
if (($stats['reviewed'] ?? 0) > 0) {
|
||||
return 'reviewed';
|
||||
}
|
||||
|
||||
return 'suspected';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{list: string, review: string}
|
||||
*/
|
||||
private function routeNamesForRequest(Request $request): array
|
||||
{
|
||||
$routeName = (string) $request->route()?->getName();
|
||||
|
||||
if (Str::startsWith($routeName, 'admin.cp.artworks.maturity.')) {
|
||||
return [
|
||||
'list' => 'admin.cp.artworks.maturity.queue',
|
||||
'review' => 'admin.cp.artworks.maturity.review',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'list' => 'cp.maturity.list',
|
||||
'review' => 'cp.maturity.review',
|
||||
];
|
||||
}
|
||||
}
|
||||
215
app/Http/Controllers/Settings/FeaturedArtworkAdminController.php
Normal file
215
app/Http/Controllers/Settings/FeaturedArtworkAdminController.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ArtworkFeature;
|
||||
use App\Services\FeaturedArtworkAdminService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class FeaturedArtworkAdminController extends Controller
|
||||
{
|
||||
public function __construct(private readonly FeaturedArtworkAdminService $featuredArtworks)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(): Response
|
||||
{
|
||||
return Inertia::render('Collection/FeaturedArtworksAdmin', array_merge(
|
||||
$this->featuredArtworks->pageProps(),
|
||||
[
|
||||
'endpoints' => [
|
||||
'search' => route('admin.cp.artworks.featured.search'),
|
||||
'store' => route('admin.cp.artworks.featured.store'),
|
||||
'updatePattern' => route('admin.cp.artworks.featured.update', ['feature' => '__FEATURE__']),
|
||||
'togglePattern' => route('admin.cp.artworks.featured.toggle', ['feature' => '__FEATURE__']),
|
||||
'forceHeroPattern' => route('admin.cp.artworks.featured.force-hero', ['feature' => '__FEATURE__']),
|
||||
'destroyPattern' => route('admin.cp.artworks.featured.delete', ['feature' => '__FEATURE__']),
|
||||
],
|
||||
'capabilities' => [
|
||||
'forceHeroEnabled' => $this->hasForceHeroColumn(),
|
||||
],
|
||||
'seo' => [
|
||||
'title' => 'Featured Artworks — Skinbase Nova',
|
||||
'description' => 'Editorial controls for homepage featured artworks and the current hero winner.',
|
||||
'canonical' => route('admin.cp.artworks.featured.main'),
|
||||
'robots' => 'noindex,follow',
|
||||
],
|
||||
],
|
||||
))->rootView('collections');
|
||||
}
|
||||
|
||||
public function search(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'q' => ['required', 'string', 'min:1', 'max:120'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'results' => $this->featuredArtworks->searchArtworks((string) $validated['q']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $this->validateStore($request);
|
||||
$actor = $this->currentActor($request);
|
||||
|
||||
ArtworkFeature::query()->create([
|
||||
'artwork_id' => (int) $validated['artwork_id'],
|
||||
'priority' => (int) $validated['priority'],
|
||||
'featured_at' => Carbon::parse((string) $validated['featured_at']),
|
||||
'expires_at' => filled($validated['expires_at'] ?? null) ? Carbon::parse((string) $validated['expires_at']) : null,
|
||||
'is_active' => (bool) $validated['is_active'],
|
||||
'created_by' => (int) $actor->id,
|
||||
]);
|
||||
|
||||
return $this->mutationResponse('Featured artwork added.');
|
||||
}
|
||||
|
||||
public function update(Request $request, ArtworkFeature $feature): JsonResponse
|
||||
{
|
||||
$validated = $this->validateUpdate($request);
|
||||
$this->ensureStateAvailable($feature, (bool) $validated['is_active']);
|
||||
|
||||
$feature->fill([
|
||||
'priority' => (int) $validated['priority'],
|
||||
'featured_at' => Carbon::parse((string) $validated['featured_at']),
|
||||
'expires_at' => filled($validated['expires_at'] ?? null) ? Carbon::parse((string) $validated['expires_at']) : null,
|
||||
'is_active' => (bool) $validated['is_active'],
|
||||
]);
|
||||
$feature->save();
|
||||
|
||||
return $this->mutationResponse('Featured artwork updated.');
|
||||
}
|
||||
|
||||
public function toggle(ArtworkFeature $feature): JsonResponse
|
||||
{
|
||||
$nextState = ! (bool) $feature->is_active;
|
||||
$this->ensureStateAvailable($feature, $nextState);
|
||||
|
||||
$feature->forceFill([
|
||||
'is_active' => $nextState,
|
||||
])->save();
|
||||
|
||||
return $this->mutationResponse($nextState ? 'Featured artwork activated.' : 'Featured artwork deactivated.');
|
||||
}
|
||||
|
||||
public function toggleForceHero(ArtworkFeature $feature): JsonResponse
|
||||
{
|
||||
$this->ensureForceHeroAvailable();
|
||||
|
||||
$nextState = ! (bool) $feature->force_hero;
|
||||
|
||||
DB::transaction(function () use ($feature, $nextState): void {
|
||||
if ($nextState) {
|
||||
ArtworkFeature::query()
|
||||
->where('force_hero', true)
|
||||
->whereNull('deleted_at')
|
||||
->whereKeyNot($feature->id)
|
||||
->update(['force_hero' => false]);
|
||||
}
|
||||
|
||||
$feature->forceFill([
|
||||
'force_hero' => $nextState,
|
||||
])->save();
|
||||
});
|
||||
|
||||
return $this->mutationResponse($nextState ? 'Force hero enabled.' : 'Force hero disabled.');
|
||||
}
|
||||
|
||||
public function destroy(ArtworkFeature $feature): JsonResponse
|
||||
{
|
||||
$feature->delete();
|
||||
|
||||
return $this->mutationResponse('Featured artwork entry deleted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function validateStore(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'artwork_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('artworks', 'id'),
|
||||
Rule::unique('artwork_features', 'artwork_id')->where(fn ($query) => $query->whereNull('deleted_at')),
|
||||
],
|
||||
'priority' => ['required', 'integer', 'min:0', 'max:65535'],
|
||||
'featured_at' => ['required', 'date'],
|
||||
'expires_at' => ['nullable', 'date', 'after:featured_at'],
|
||||
'is_active' => ['required', 'boolean'],
|
||||
], [
|
||||
'artwork_id.unique' => 'This artwork already has a featured entry. Edit the existing row instead.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function validateUpdate(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'priority' => ['required', 'integer', 'min:0', 'max:65535'],
|
||||
'featured_at' => ['required', 'date'],
|
||||
'expires_at' => ['nullable', 'date', 'after:featured_at'],
|
||||
'is_active' => ['required', 'boolean'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function ensureStateAvailable(ArtworkFeature $feature, bool $isActive): void
|
||||
{
|
||||
$conflictExists = ArtworkFeature::query()
|
||||
->where('artwork_id', $feature->artwork_id)
|
||||
->where('is_active', $isActive)
|
||||
->whereNull('deleted_at')
|
||||
->whereKeyNot($feature->id)
|
||||
->exists();
|
||||
|
||||
if ($conflictExists) {
|
||||
throw ValidationException::withMessages([
|
||||
'is_active' => 'Another featured entry for this artwork already uses that active state.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function mutationResponse(string $message): JsonResponse
|
||||
{
|
||||
return response()->json(array_merge([
|
||||
'ok' => true,
|
||||
'message' => $message,
|
||||
], $this->featuredArtworks->pageProps()));
|
||||
}
|
||||
|
||||
private function currentActor(Request $request): object
|
||||
{
|
||||
return $request->user('controlpanel') ?? $request->user() ?? abort(403, 'Admin access required.');
|
||||
}
|
||||
|
||||
private function ensureForceHeroAvailable(): void
|
||||
{
|
||||
if (! $this->hasForceHeroColumn()) {
|
||||
throw ValidationException::withMessages([
|
||||
'force_hero' => 'Run php artisan migrate to enable force hero controls.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function hasForceHeroColumn(): bool
|
||||
{
|
||||
return Schema::hasColumn('artwork_features', 'force_hero');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user