309 lines
13 KiB
PHP
309 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Settings;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Requests\Collections\CollectionProgramAssignmentRequest;
|
|
use App\Http\Requests\Collections\CollectionProgrammingCollectionRequest;
|
|
use App\Http\Requests\Collections\CollectionProgrammingMergePairRequest;
|
|
use App\Http\Requests\Collections\CollectionProgrammingMetadataRequest;
|
|
use App\Http\Requests\Collections\CollectionProgrammingPreviewRequest;
|
|
use App\Models\Collection;
|
|
use App\Models\CollectionProgramAssignment;
|
|
use App\Services\CollectionBackgroundJobService;
|
|
use App\Services\CollectionObservabilityService;
|
|
use App\Services\CollectionProgrammingService;
|
|
use App\Services\CollectionService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
class CollectionProgrammingController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly CollectionProgrammingService $programming,
|
|
private readonly CollectionService $collections,
|
|
private readonly CollectionBackgroundJobService $backgroundJobs,
|
|
private readonly CollectionObservabilityService $observability,
|
|
) {
|
|
}
|
|
|
|
public function index(Request $request): Response|JsonResponse
|
|
{
|
|
$this->authorizeStaff($request);
|
|
|
|
$assignments = $this->programming->assignments();
|
|
|
|
if ($request->expectsJson()) {
|
|
return response()->json([
|
|
'ok' => true,
|
|
'assignments' => $assignments->map(fn (CollectionProgramAssignment $assignment): array => $this->mapAssignment($assignment))->values()->all(),
|
|
]);
|
|
}
|
|
|
|
$collectionOptions = Collection::query()
|
|
->with(['user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at'])
|
|
->where(function ($query): void {
|
|
$query->where(function ($public): void {
|
|
$public->where('visibility', Collection::VISIBILITY_PUBLIC)
|
|
->whereIn('lifecycle_state', [Collection::LIFECYCLE_PUBLISHED, Collection::LIFECYCLE_FEATURED])
|
|
->where('moderation_status', Collection::MODERATION_ACTIVE);
|
|
})->orWhereNotNull('program_key');
|
|
})
|
|
->orderByDesc('ranking_score')
|
|
->orderByDesc('updated_at')
|
|
->limit(40)
|
|
->get();
|
|
|
|
$programKeyOptions = $assignments->pluck('program_key')
|
|
->merge($collectionOptions->pluck('program_key'))
|
|
->filter(fn ($value): bool => is_string($value) && $value !== '')
|
|
->unique()
|
|
->sort()
|
|
->values()
|
|
->all();
|
|
|
|
return Inertia::render('Collection/CollectionStaffProgramming', [
|
|
'assignments' => $assignments->map(fn (CollectionProgramAssignment $assignment): array => $this->mapAssignment($assignment))->values()->all(),
|
|
'collectionOptions' => $this->collections->mapCollectionCardPayloads($collectionOptions, true),
|
|
'programKeyOptions' => $programKeyOptions,
|
|
'mergeQueue' => $this->programming->mergeQueue(true),
|
|
'observabilitySummary' => $this->observability->summary(),
|
|
'historyPattern' => route('settings.collections.history', ['collection' => '__COLLECTION__']),
|
|
'viewer' => [
|
|
'isAdmin' => $this->isAdmin($request),
|
|
],
|
|
'endpoints' => [
|
|
'store' => route('staff.collections.programs.store'),
|
|
'updatePattern' => route('staff.collections.programs.update', ['program' => '__PROGRAM__']),
|
|
'publicProgramPattern' => route('collections.program.show', ['programKey' => '__PROGRAM__']),
|
|
'preview' => route('staff.collections.surfaces.preview'),
|
|
'refreshEligibility' => route('staff.collections.eligibility.refresh'),
|
|
'duplicateScan' => route('staff.collections.duplicate-scan'),
|
|
'refreshRecommendations' => route('staff.collections.recommendation-refresh'),
|
|
'metadataUpdate' => route('staff.collections.metadata.update'),
|
|
'canonicalizeCandidate' => route('staff.collections.merge-queue.canonicalize'),
|
|
'mergeCandidate' => route('staff.collections.merge-queue.merge'),
|
|
'rejectCandidate' => route('staff.collections.merge-queue.reject'),
|
|
'managePattern' => route('settings.collections.show', ['collection' => '__COLLECTION__']),
|
|
'surfaces' => route('settings.collections.surfaces.index'),
|
|
],
|
|
'seo' => [
|
|
'title' => 'Collection Programming — Skinbase Nova',
|
|
'description' => 'Staff programming tools for assignments, previews, eligibility diagnostics, and recommendation refreshes.',
|
|
'canonical' => route('staff.collections.programming'),
|
|
'robots' => 'noindex,follow',
|
|
],
|
|
])->rootView('collections');
|
|
}
|
|
|
|
public function storeProgram(CollectionProgramAssignmentRequest $request): JsonResponse
|
|
{
|
|
$this->authorizeStaff($request);
|
|
|
|
$assignment = $this->programming->upsertAssignment($request->validated(), $request->user());
|
|
|
|
return response()->json([
|
|
'ok' => true,
|
|
'assignment' => $this->mapAssignment($assignment),
|
|
]);
|
|
}
|
|
|
|
public function updateProgram(CollectionProgramAssignmentRequest $request, CollectionProgramAssignment $program): JsonResponse
|
|
{
|
|
$this->authorizeStaff($request);
|
|
|
|
$payload = $request->validated();
|
|
$payload['id'] = (int) $program->id;
|
|
|
|
$assignment = $this->programming->upsertAssignment($payload, $request->user());
|
|
|
|
return response()->json([
|
|
'ok' => true,
|
|
'assignment' => $this->mapAssignment($assignment),
|
|
]);
|
|
}
|
|
|
|
public function preview(CollectionProgrammingPreviewRequest $request): JsonResponse
|
|
{
|
|
$this->authorizeStaff($request);
|
|
|
|
$payload = $request->validated();
|
|
|
|
return response()->json([
|
|
'ok' => true,
|
|
'collections' => $this->collections->mapCollectionCardPayloads(
|
|
$this->programming->previewProgram((string) $payload['program_key'], (int) ($payload['limit'] ?? 12)),
|
|
true,
|
|
),
|
|
]);
|
|
}
|
|
|
|
public function refreshEligibility(CollectionProgrammingCollectionRequest $request): JsonResponse
|
|
{
|
|
$this->authorizeStaff($request);
|
|
|
|
$collection = $this->resolveCollection($request);
|
|
|
|
return response()->json([
|
|
'ok' => true,
|
|
'queued' => true,
|
|
'result' => $this->backgroundJobs->dispatchHealthRefresh($collection, $request->user()),
|
|
]);
|
|
}
|
|
|
|
public function duplicateScan(CollectionProgrammingCollectionRequest $request): JsonResponse
|
|
{
|
|
$this->authorizeStaff($request);
|
|
|
|
$collection = $this->resolveCollection($request);
|
|
|
|
return response()->json([
|
|
'ok' => true,
|
|
'queued' => true,
|
|
'result' => $this->backgroundJobs->dispatchDuplicateScan($collection, $request->user()),
|
|
]);
|
|
}
|
|
|
|
public function refreshRecommendations(CollectionProgrammingCollectionRequest $request): JsonResponse
|
|
{
|
|
$this->authorizeStaff($request);
|
|
|
|
$collection = $this->resolveCollection($request);
|
|
|
|
return response()->json([
|
|
'ok' => true,
|
|
'queued' => true,
|
|
'result' => $this->backgroundJobs->dispatchRecommendationRefresh($collection, $request->user()),
|
|
]);
|
|
}
|
|
|
|
public function updateMetadata(CollectionProgrammingMetadataRequest $request): JsonResponse
|
|
{
|
|
$this->authorizeStaff($request);
|
|
|
|
$payload = $request->validated();
|
|
|
|
$collection = Collection::query()->findOrFail((int) $payload['collection_id']);
|
|
$result = $this->programming->syncHooks($collection, $payload, $request->user());
|
|
|
|
return response()->json([
|
|
'ok' => true,
|
|
'message' => 'Experiment and program governance hooks updated.',
|
|
'collection' => $this->collections->mapCollectionCardPayloads([$result['collection']->loadMissing('user')], true)[0],
|
|
'diagnostics' => $result['diagnostics'],
|
|
]);
|
|
}
|
|
|
|
public function canonicalizeCandidate(CollectionProgrammingMergePairRequest $request): JsonResponse
|
|
{
|
|
$this->authorizeStaff($request);
|
|
|
|
[$source, $target] = $this->resolveMergePair($request);
|
|
$payload = $this->programming->canonicalizePair($source, $target, $request->user());
|
|
|
|
return response()->json([
|
|
'ok' => true,
|
|
'message' => 'Canonical target updated from the staff merge queue.',
|
|
'source' => $this->collections->mapCollectionCardPayloads([$payload['source']->loadMissing('user')], true)[0],
|
|
'target' => $this->collections->mapCollectionCardPayloads([$payload['target']->loadMissing('user')], true)[0],
|
|
'mergeQueue' => $payload['mergeQueue'],
|
|
]);
|
|
}
|
|
|
|
public function mergeCandidate(CollectionProgrammingMergePairRequest $request): JsonResponse
|
|
{
|
|
$this->authorizeStaff($request);
|
|
|
|
[$source, $target] = $this->resolveMergePair($request);
|
|
$payload = $this->programming->mergePair($source, $target, $request->user());
|
|
|
|
return response()->json([
|
|
'ok' => true,
|
|
'message' => 'Collections merged from the staff merge queue.',
|
|
'source' => $this->collections->mapCollectionCardPayloads([$payload['source']->loadMissing('user')], true)[0],
|
|
'target' => $this->collections->mapCollectionCardPayloads([$payload['target']->loadMissing('user')], true)[0],
|
|
'attached_artwork_ids' => $payload['attached_artwork_ids'],
|
|
'mergeQueue' => $payload['mergeQueue'],
|
|
]);
|
|
}
|
|
|
|
public function rejectCandidate(CollectionProgrammingMergePairRequest $request): JsonResponse
|
|
{
|
|
$this->authorizeStaff($request);
|
|
|
|
[$source, $target] = $this->resolveMergePair($request);
|
|
$payload = $this->programming->rejectPair($source, $target, $request->user());
|
|
|
|
return response()->json([
|
|
'ok' => true,
|
|
'message' => 'Duplicate candidate dismissed from the staff merge queue.',
|
|
'source' => $this->collections->mapCollectionCardPayloads([$payload['source']->loadMissing('user')], true)[0],
|
|
'target' => $this->collections->mapCollectionCardPayloads([$payload['target']->loadMissing('user')], true)[0],
|
|
'mergeQueue' => $payload['mergeQueue'],
|
|
]);
|
|
}
|
|
|
|
private function resolveCollection(CollectionProgrammingCollectionRequest $request): ?Collection
|
|
{
|
|
$payload = $request->validated();
|
|
|
|
if (! isset($payload['collection_id'])) {
|
|
return null;
|
|
}
|
|
|
|
return Collection::query()->find((int) $payload['collection_id']);
|
|
}
|
|
|
|
/**
|
|
* @return array{0: Collection, 1: Collection}
|
|
*/
|
|
private function resolveMergePair(CollectionProgrammingMergePairRequest $request): array
|
|
{
|
|
$payload = $request->validated();
|
|
|
|
return [
|
|
Collection::query()->findOrFail((int) $payload['source_collection_id']),
|
|
Collection::query()->findOrFail((int) $payload['target_collection_id']),
|
|
];
|
|
}
|
|
|
|
private function mapAssignment(CollectionProgramAssignment $assignment): array
|
|
{
|
|
$assignment->loadMissing(['collection.user', 'creator']);
|
|
|
|
return [
|
|
'id' => (int) $assignment->id,
|
|
'program_key' => (string) $assignment->program_key,
|
|
'campaign_key' => $assignment->campaign_key,
|
|
'placement_scope' => $assignment->placement_scope,
|
|
'starts_at' => optional($assignment->starts_at)?->toISOString(),
|
|
'ends_at' => optional($assignment->ends_at)?->toISOString(),
|
|
'priority' => (int) $assignment->priority,
|
|
'notes' => $assignment->notes,
|
|
'collection' => $this->collections->mapCollectionCardPayloads([$assignment->collection], true)[0],
|
|
'creator' => $assignment->creator ? [
|
|
'id' => (int) $assignment->creator->id,
|
|
'username' => (string) $assignment->creator->username,
|
|
'name' => $assignment->creator->name,
|
|
] : null,
|
|
];
|
|
}
|
|
|
|
private function authorizeStaff(Request $request): void
|
|
{
|
|
$user = $request->user();
|
|
|
|
abort_unless($user && ($user->isAdmin() || $user->isModerator()), 403);
|
|
}
|
|
|
|
private function isAdmin(Request $request): bool
|
|
{
|
|
$user = $request->user();
|
|
|
|
return $user !== null && $user->isAdmin();
|
|
}
|
|
} |