Files
SkinbaseNova/app/Http/Controllers/Settings/CollectionInsightsController.php
2026-03-28 19:15:39 +01:00

343 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Collections\CollectionBulkActionsRequest;
use App\Http\Requests\Collections\CollectionOwnerSearchRequest;
use App\Http\Requests\Collections\CollectionTargetActionRequest;
use App\Http\Requests\Collections\UpdateCollectionWorkflowRequest;
use App\Models\Collection;
use App\Models\CollectionHistory;
use App\Services\CollectionAiOperationsService;
use App\Services\CollectionAnalyticsService;
use App\Services\CollectionBackgroundJobService;
use App\Services\CollectionBulkActionService;
use App\Services\CollectionCanonicalService;
use App\Services\CollectionDashboardService;
use App\Services\CollectionHistoryService;
use App\Services\CollectionHealthService;
use App\Services\CollectionMergeService;
use App\Services\CollectionSearchService;
use App\Services\CollectionService;
use App\Services\CollectionWorkflowService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class CollectionInsightsController extends Controller
{
public function __construct(
private readonly CollectionDashboardService $dashboard,
private readonly CollectionAnalyticsService $analytics,
private readonly CollectionHistoryService $history,
private readonly CollectionAiOperationsService $aiOperations,
private readonly CollectionBackgroundJobService $backgroundJobs,
private readonly CollectionBulkActionService $bulkActions,
private readonly CollectionService $collections,
private readonly CollectionSearchService $search,
private readonly CollectionHealthService $health,
private readonly CollectionWorkflowService $workflow,
private readonly CollectionCanonicalService $canonical,
private readonly CollectionMergeService $merge,
) {
}
public function dashboard(Request $request): Response
{
$payload = $this->dashboard->build($request->user());
return Inertia::render('Collection/CollectionDashboard', [
'summary' => $payload['summary'],
'topPerforming' => $this->collections->mapCollectionCardPayloads($payload['top_performing'], true),
'needsAttention' => $this->collections->mapCollectionCardPayloads($payload['needs_attention'], true),
'expiringCampaigns' => $this->collections->mapCollectionCardPayloads($payload['expiring_campaigns'], true),
'healthWarnings' => $payload['health_warnings'],
'filterOptions' => [
'types' => [
Collection::TYPE_PERSONAL,
Collection::TYPE_COMMUNITY,
Collection::TYPE_EDITORIAL,
],
'visibilities' => [
Collection::VISIBILITY_PUBLIC,
Collection::VISIBILITY_UNLISTED,
Collection::VISIBILITY_PRIVATE,
],
'lifecycleStates' => [
Collection::LIFECYCLE_DRAFT,
Collection::LIFECYCLE_SCHEDULED,
Collection::LIFECYCLE_PUBLISHED,
Collection::LIFECYCLE_FEATURED,
Collection::LIFECYCLE_ARCHIVED,
Collection::LIFECYCLE_HIDDEN,
Collection::LIFECYCLE_RESTRICTED,
Collection::LIFECYCLE_UNDER_REVIEW,
Collection::LIFECYCLE_EXPIRED,
],
'workflowStates' => [
Collection::WORKFLOW_DRAFT,
Collection::WORKFLOW_IN_REVIEW,
Collection::WORKFLOW_APPROVED,
Collection::WORKFLOW_PROGRAMMED,
Collection::WORKFLOW_ARCHIVED,
],
'healthStates' => [
Collection::HEALTH_HEALTHY,
Collection::HEALTH_NEEDS_METADATA,
Collection::HEALTH_STALE,
Collection::HEALTH_LOW_CONTENT,
Collection::HEALTH_BROKEN_ITEMS,
Collection::HEALTH_WEAK_COVER,
Collection::HEALTH_LOW_ENGAGEMENT,
Collection::HEALTH_ATTRIBUTION_INCOMPLETE,
Collection::HEALTH_NEEDS_REVIEW,
Collection::HEALTH_DUPLICATE_RISK,
Collection::HEALTH_MERGE_CANDIDATE,
],
],
'endpoints' => [
'managePattern' => route('settings.collections.show', ['collection' => '__COLLECTION__']),
'analyticsPattern' => route('settings.collections.analytics', ['collection' => '__COLLECTION__']),
'historyPattern' => route('settings.collections.history', ['collection' => '__COLLECTION__']),
'healthPattern' => route('settings.collections.health', ['collection' => '__COLLECTION__']),
'search' => route('settings.collections.search'),
'bulkActions' => route('settings.collections.bulk-actions'),
],
'seo' => [
'title' => 'Collections Dashboard — Skinbase Nova',
'description' => 'Overview of collection lifecycle, quality, activity, and upcoming collection campaigns.',
'canonical' => route('settings.collections.dashboard'),
'robots' => 'noindex,follow',
],
])->rootView('collections');
}
public function analytics(Request $request, Collection $collection): Response
{
$this->authorize('update', $collection);
$collection->loadMissing(['user.profile', 'coverArtwork']);
return Inertia::render('Collection/CollectionAnalytics', [
'collection' => $this->collections->mapCollectionDetailPayload($collection, true),
'analytics' => $this->analytics->overview($collection, (int) $request->integer('days', 30)),
'historyUrl' => route('settings.collections.history', ['collection' => $collection->id]),
'dashboardUrl' => route('settings.collections.dashboard'),
'seo' => [
'title' => sprintf('%s Analytics — Skinbase Nova', $collection->title),
'description' => sprintf('Analytics and performance history for the %s collection.', $collection->title),
'canonical' => route('settings.collections.analytics', ['collection' => $collection->id]),
'robots' => 'noindex,follow',
],
])->rootView('collections');
}
public function history(Request $request, Collection $collection): Response
{
$this->authorize('update', $collection);
$collection->loadMissing(['user.profile', 'coverArtwork']);
$history = $this->history->historyFor($collection, (int) $request->integer('per_page', 40));
return Inertia::render('Collection/CollectionHistory', [
'collection' => $this->collections->mapCollectionDetailPayload($collection, true),
'history' => $this->history->mapPaginator($history),
'canRestoreHistory' => $this->isStaff($request),
'dashboardUrl' => route('settings.collections.dashboard'),
'analyticsUrl' => route('settings.collections.analytics', ['collection' => $collection->id]),
'restorePattern' => route('settings.collections.history.restore', ['collection' => $collection->id, 'history' => '__HISTORY__']),
'seo' => [
'title' => sprintf('%s History — Skinbase Nova', $collection->title),
'description' => sprintf('Audit history and lifecycle changes for the %s collection.', $collection->title),
'canonical' => route('settings.collections.history', ['collection' => $collection->id]),
'robots' => 'noindex,follow',
],
])->rootView('collections');
}
public function restoreHistory(Request $request, Collection $collection, CollectionHistory $history): JsonResponse
{
$this->authorize('update', $collection);
abort_unless($this->isStaff($request), 403);
$collection = $this->history->restore($collection->loadMissing('user'), $history, $request->user());
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'health' => $this->health->summary($collection),
'restored_history_entry_id' => (int) $history->id,
]);
}
public function qualityReview(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
return response()->json([
'ok' => true,
'review' => $this->aiOperations->qualityReview($collection->loadMissing(['user.profile', 'coverArtwork'])),
]);
}
public function search(CollectionOwnerSearchRequest $request): JsonResponse
{
$filters = $request->validated();
$results = $this->search->ownerSearch($request->user(), $filters, (int) config('collections.v5.search.owner_per_page', 20));
return response()->json([
'ok' => true,
'collections' => $this->collections->mapCollectionCardPayloads($results->items(), true),
'filters' => $filters,
'meta' => [
'current_page' => $results->currentPage(),
'last_page' => $results->lastPage(),
'per_page' => $results->perPage(),
'total' => $results->total(),
],
]);
}
public function bulkActions(CollectionBulkActionsRequest $request): JsonResponse
{
$payload = $request->validated();
$result = $this->bulkActions->apply($request->user(), $payload);
$dashboard = $this->dashboard->build($request->user());
return response()->json([
'ok' => true,
'action' => $result['action'],
'count' => $result['count'],
'message' => $result['message'],
'collections' => $this->collections->mapCollectionCardPayloads($result['collections'], true),
'items' => $result['items'],
'summary' => $dashboard['summary'],
'healthWarnings' => $dashboard['health_warnings'],
]);
}
public function health(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
$collection->loadMissing(['user.profile', 'coverArtwork']);
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection, true),
'health' => $this->health->summary($collection),
'duplicate_candidates' => $this->collections->mapCollectionCardPayloads($this->merge->duplicateCandidates($collection), true),
'history_url' => route('settings.collections.history', ['collection' => $collection->id]),
]);
}
public function workflowUpdate(UpdateCollectionWorkflowRequest $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
$payload = $request->validated();
if (! $this->isStaff($request)) {
unset($payload['program_key'], $payload['partner_key'], $payload['experiment_key'], $payload['placement_eligibility']);
}
$collection = $this->workflow->update($collection->loadMissing('user'), $payload, $request->user());
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'health' => $this->health->summary($collection),
]);
}
public function qualityRefresh(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
return response()->json([
'ok' => true,
'queued' => true,
'result' => $this->backgroundJobs->dispatchQualityRefresh($collection->loadMissing('user'), $request->user()),
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'health' => $this->health->summary($collection),
]);
}
public function canonicalize(CollectionTargetActionRequest $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
$payload = $request->validated();
$target = Collection::query()->findOrFail((int) $payload['target_collection_id']);
$this->authorize('update', $target);
$collection = $this->canonical->designate($collection->loadMissing('user'), $target->loadMissing('user'), $request->user());
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'target' => $this->collections->mapCollectionCardPayloads([$target->fresh()->loadMissing('user')], true)[0],
'canonical_target' => $this->collections->mapCollectionCardPayloads([$target->fresh()->loadMissing('user')], true)[0],
'duplicate_candidates' => $this->merge->reviewCandidates($collection->fresh()->loadMissing('user'), true),
]);
}
public function merge(CollectionTargetActionRequest $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
$payload = $request->validated();
$target = Collection::query()->findOrFail((int) $payload['target_collection_id']);
$this->authorize('update', $target);
$result = $this->merge->mergeInto($collection->loadMissing('user'), $target->loadMissing('user'), $request->user());
return response()->json([
'ok' => true,
'source' => $this->collections->mapCollectionDetailPayload($result['source']->loadMissing('user'), true),
'target' => $this->collections->mapCollectionDetailPayload($result['target']->loadMissing('user'), true),
'attached_artwork_ids' => $result['attached_artwork_ids'],
'canonical_target' => $this->collections->mapCollectionCardPayloads([$result['target']->loadMissing('user')], true)[0],
'duplicate_candidates' => $this->merge->reviewCandidates($result['source']->fresh()->loadMissing('user'), true),
]);
}
public function rejectDuplicate(CollectionTargetActionRequest $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
$payload = $request->validated();
$target = Collection::query()->findOrFail((int) $payload['target_collection_id']);
$this->authorize('update', $target);
$collection = $this->merge->rejectCandidate($collection->loadMissing('user'), $target->loadMissing('user'), $request->user());
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'duplicate_candidates' => $this->merge->reviewCandidates($collection->fresh()->loadMissing('user'), true),
'canonical_target' => $collection->canonical_collection_id
? $this->collections->mapCollectionCardPayloads([
Collection::query()->findOrFail((int) $collection->canonical_collection_id)->loadMissing('user'),
], true)[0]
: null,
]);
}
private function isStaff(Request $request): bool
{
$user = $request->user();
return $user !== null && ($user->isAdmin() || $user->isModerator());
}
}