authorizeStaff($request); $conflicts = $this->surfaces->placementConflicts(); $conflictPlacementIds = $conflicts->pluck('placement_ids')->flatten()->unique()->map(fn ($id) => (int) $id)->all(); $definitions = $this->surfaces->definitions()->map(function ($definition): array { return $this->mapDefinition($definition); })->values()->all(); $placements = $this->surfaces->placements()->map(function ($placement) use ($conflictPlacementIds): array { return array_merge($this->mapPlacement($placement), [ 'has_conflict' => in_array((int) $placement->id, $conflictPlacementIds, true), ]); })->values()->all(); return Inertia::render('Collection/CollectionStaffSurfaces', [ 'definitions' => $definitions, 'placements' => $placements, 'conflicts' => $conflicts->all(), 'surfaceKeyOptions' => collect($definitions)->pluck('surface_key')->values()->all(), 'collectionOptions' => $this->collections->mapCollectionCardPayloads( Collection::query()->public()->orderByDesc('ranking_score')->limit(30)->get(), true, ), 'endpoints' => [ 'definitionsStore' => route('settings.collections.surfaces.definitions.store'), 'definitionsUpdatePattern' => route('settings.collections.surfaces.definitions.update', ['definition' => '__DEFINITION__']), 'definitionsDeletePattern' => route('settings.collections.surfaces.definitions.destroy', ['definition' => '__DEFINITION__']), 'placementsStore' => route('settings.collections.surfaces.placements.store'), 'placementsUpdatePattern' => route('settings.collections.surfaces.placements.update', ['placement' => '__PLACEMENT__']), 'placementsDeletePattern' => route('settings.collections.surfaces.placements.destroy', ['placement' => '__PLACEMENT__']), 'previewPattern' => route('settings.collections.surfaces.preview', ['definition' => '__DEFINITION__']), 'batchEditorial' => route('settings.collections.surfaces.batch-editorial'), ], 'seo' => [ 'title' => 'Collection Surfaces - Skinbase Nova', 'description' => 'Staff tools for homepage, discovery, and campaign collection surfaces.', 'canonical' => route('settings.collections.surfaces.index'), 'robots' => 'noindex,follow', ], ])->rootView('collections'); } public function storeDefinition(Request $request): JsonResponse { $this->authorizeStaff($request); $definition = $this->surfaces->upsertDefinition($this->validateDefinition($request)); return response()->json([ 'ok' => true, 'definition' => $this->mapDefinition($definition->fresh()), ]); } public function updateDefinition(Request $request, CollectionSurfaceDefinition $definition): JsonResponse { $this->authorizeStaff($request); $payload = $this->validateDefinition($request); $payload['surface_key'] = $definition->surface_key; $updatedDefinition = $this->surfaces->upsertDefinition($payload); return response()->json([ 'ok' => true, 'definition' => $this->mapDefinition($updatedDefinition->fresh()), ]); } public function destroyDefinition(Request $request, CollectionSurfaceDefinition $definition): JsonResponse { $this->authorizeStaff($request); abort_if( CollectionSurfacePlacement::query()->where('surface_key', $definition->surface_key)->exists(), 422, 'Remove all placements from this surface before deleting the definition.' ); $deletedId = (int) $definition->id; $definition->delete(); return response()->json([ 'ok' => true, 'deleted_definition_id' => $deletedId, ]); } public function storePlacement(Request $request): JsonResponse { $this->authorizeStaff($request); $payload = $this->validatePlacement($request); $payload['created_by_user_id'] = $request->user()?->id; $collection = Collection::query()->findOrFail((int) $payload['collection_id']); abort_unless($collection->isFeatureablePublicly(), 422, 'Only public, active collections can be placed on public surfaces.'); $placement = $this->surfaces->upsertPlacement($payload)->loadMissing([ 'collection.user:id,username,name', 'collection.coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at', ]); $this->history->record( $collection->fresh(), $request->user(), 'placement_assigned', 'Collection assigned to a staff surface.', null, $this->placementHistoryPayload($placement) ); return response()->json([ 'ok' => true, 'placement' => $this->mapPlacement($placement), 'conflicts' => $this->surfaces->placementConflicts()->all(), ]); } public function updatePlacement(Request $request, CollectionSurfacePlacement $placement): JsonResponse { $this->authorizeStaff($request); $before = $this->placementHistoryPayload($placement); $originalCollectionId = (int) $placement->collection_id; $payload = $this->validatePlacement($request); $payload['id'] = (int) $placement->id; $payload['created_by_user_id'] = $placement->created_by_user_id ?: $request->user()?->id; $collection = Collection::query()->findOrFail((int) $payload['collection_id']); abort_unless($collection->isFeatureablePublicly(), 422, 'Only public, active collections can be placed on public surfaces.'); $updatedPlacement = $this->surfaces->upsertPlacement($payload)->loadMissing([ 'collection.user:id,username,name', 'collection.coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at', ]); if ($originalCollectionId !== (int) $updatedPlacement->collection_id) { $originalCollection = Collection::query()->find($originalCollectionId); if ($originalCollection) { $this->history->record( $originalCollection->fresh(), $request->user(), 'placement_removed', 'Collection removed from a staff surface assignment.', $before, null ); } $this->history->record( $collection->fresh(), $request->user(), 'placement_assigned', 'Collection assigned to a staff surface.', null, $this->placementHistoryPayload($updatedPlacement) ); } else { $this->history->record( $collection->fresh(), $request->user(), 'placement_updated', 'Collection staff surface assignment updated.', $before, $this->placementHistoryPayload($updatedPlacement) ); } return response()->json([ 'ok' => true, 'placement' => $this->mapPlacement($updatedPlacement), 'conflicts' => $this->surfaces->placementConflicts()->all(), ]); } public function destroyPlacement(Request $request, CollectionSurfacePlacement $placement): JsonResponse { $this->authorizeStaff($request); $before = $this->placementHistoryPayload($placement); $collection = $placement->collection()->first(); $deletedId = (int) $placement->id; $this->surfaces->deletePlacement($placement); if ($collection) { $this->history->record( $collection->fresh(), $request->user(), 'placement_removed', 'Collection removed from a staff surface assignment.', $before, null ); } return response()->json([ 'ok' => true, 'deleted_placement_id' => $deletedId, 'conflicts' => $this->surfaces->placementConflicts()->all(), ]); } public function preview(Request $request, CollectionSurfaceDefinition $definition): JsonResponse { $this->authorizeStaff($request); $items = $this->surfaces->resolveSurfaceItems( $definition->surface_key, (int) $request->integer('limit', (int) $definition->max_items) ); return response()->json([ 'ok' => true, 'collections' => $this->collections->mapCollectionCardPayloads($items, true), ]); } public function batchEditorial(Request $request): JsonResponse { $this->authorizeStaff($request); $payload = $this->validateBatchEditorial($request); $collectionIds = collect($payload['collection_ids'] ?? [])->map(fn ($id) => (int) $id)->filter()->values()->all(); abort_if(count($collectionIds) === 0, 422, 'Choose at least one collection for the batch editorial run.'); if (! ($payload['apply'] ?? false)) { return response()->json([ 'ok' => true, 'mode' => 'preview', 'plan' => $this->campaigns->batchEditorialPlan($collectionIds, $payload), ]); } $result = DB::transaction(function () use ($collectionIds, $payload, $request): array { $actor = $request->user(); $applied = $this->campaigns->applyBatchEditorialPlan($collectionIds, $payload, $actor); $resultsByCollection = collect($applied['results'] ?? [])->keyBy('collection_id'); foreach ($applied['plan']['items'] as $item) { $collectionId = (int) Arr::get($item, 'collection.id'); $collection = Collection::query()->find($collectionId); $itemResult = $resultsByCollection->get($collectionId, []); if (! $collection) { continue; } $summaryParts = []; if (count($item['campaign_updates'] ?? []) > 0) { $summaryParts[] = 'campaign metadata refreshed'; } if (is_array($item['placement'] ?? null)) { $summaryParts[] = ($item['placement']['eligible'] ?? false) ? sprintf('placement planned for %s', (string) $item['placement']['surface_key']) : 'placement skipped'; } if (($itemResult['placement']['status'] ?? null) === 'created') { $this->history->record( $collection->fresh(), $actor, 'placement_assigned', 'Collection assigned to a staff surface via batch editorial tools.', null, $item['placement'] ?? null ); } if (($itemResult['placement']['status'] ?? null) === 'updated') { $this->history->record( $collection->fresh(), $actor, 'placement_updated', 'Collection staff surface assignment updated via batch editorial tools.', null, $item['placement'] ?? null ); } $this->history->record( $collection->fresh(), $actor, 'batch_editorial_updated', 'Staff batch editorial tools updated campaign planning.', null, [ 'summary' => $summaryParts, 'campaign_updates' => $item['campaign_updates'] ?? [], 'placement' => $item['placement'] ?? null, ] ); } return $applied; }); return response()->json([ 'ok' => true, 'mode' => 'apply', 'plan' => $result['plan'], 'results' => $result['results'], 'placements' => $this->surfaces->placements()->map(fn ($placement): array => $this->mapPlacement($placement))->values()->all(), 'conflicts' => $this->surfaces->placementConflicts()->all(), ]); } private function authorizeStaff(Request $request): void { $user = $request->user(); abort_unless($user && ($user->isAdmin() || $user->isModerator()), 403); } private function validateDefinition(Request $request): array { return $request->validate([ 'surface_key' => ['required', 'string', 'max:120'], 'title' => ['required', 'string', 'max:160'], 'description' => ['nullable', 'string', 'max:400'], 'mode' => ['required', 'in:manual,automatic,hybrid'], 'ranking_mode' => ['required', 'in:ranking_score,recent_activity,quality_score'], 'max_items' => ['nullable', 'integer', 'min:1', 'max:24'], 'is_active' => ['nullable', 'boolean'], 'starts_at' => ['nullable', 'date'], 'ends_at' => ['nullable', 'date', 'after:starts_at'], 'fallback_surface_key' => ['nullable', 'string', 'max:120', 'different:surface_key'], 'rules_json' => ['nullable', 'array'], ]); } private function validatePlacement(Request $request): array { return $request->validate([ 'id' => ['nullable', 'integer', 'exists:collection_surface_placements,id'], 'surface_key' => ['required', 'string', 'max:120'], 'collection_id' => ['required', 'integer', 'exists:collections,id'], 'placement_type' => ['required', 'in:manual,campaign,scheduled_override'], 'priority' => ['nullable', 'integer', 'min:-100', 'max:100'], 'starts_at' => ['nullable', 'date'], 'ends_at' => ['nullable', 'date', 'after:starts_at'], 'is_active' => ['nullable', 'boolean'], 'campaign_key' => ['nullable', 'string', 'max:80'], 'notes' => ['nullable', 'string', 'max:1000'], ]); } private function validateBatchEditorial(Request $request): array { return $request->validate([ 'collection_ids' => ['required', 'array', 'min:1', 'max:24'], 'collection_ids.*' => ['integer', 'distinct', 'exists:collections,id'], 'campaign_key' => ['nullable', 'string', 'max:80'], 'campaign_label' => ['nullable', 'string', 'max:120'], 'event_key' => ['nullable', 'string', 'max:80'], 'event_label' => ['nullable', 'string', 'max:120'], 'season_key' => ['nullable', 'string', 'max:80'], 'banner_text' => ['nullable', 'string', 'max:160'], 'badge_label' => ['nullable', 'string', 'max:80'], 'spotlight_style' => ['nullable', 'string', 'max:60'], 'editorial_notes' => ['nullable', 'string', 'max:4000'], 'surface_key' => ['nullable', 'string', 'max:120'], 'placement_type' => ['nullable', 'string', 'in:manual,campaign,scheduled_override'], 'priority' => ['nullable', 'integer', 'min:-100', 'max:100'], 'starts_at' => ['nullable', 'date'], 'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'], 'is_active' => ['nullable', 'boolean'], 'notes' => ['nullable', 'string', 'max:1000'], 'apply' => ['nullable', 'boolean'], ]); } private function mapDefinition(CollectionSurfaceDefinition $definition): array { return [ 'id' => (int) $definition->id, 'surface_key' => $definition->surface_key, 'title' => $definition->title, 'description' => $definition->description, 'mode' => $definition->mode, 'ranking_mode' => $definition->ranking_mode, 'max_items' => (int) $definition->max_items, 'is_active' => (bool) $definition->is_active, 'starts_at' => $definition->starts_at?->toISOString(), 'ends_at' => $definition->ends_at?->toISOString(), 'fallback_surface_key' => $definition->fallback_surface_key, 'rules_json' => $definition->rules_json, ]; } private function mapPlacement(CollectionSurfacePlacement $placement): array { return [ 'id' => (int) $placement->id, 'surface_key' => $placement->surface_key, 'placement_type' => $placement->placement_type, 'priority' => (int) $placement->priority, 'starts_at' => $placement->starts_at?->toISOString(), 'ends_at' => $placement->ends_at?->toISOString(), 'is_active' => (bool) $placement->is_active, 'campaign_key' => $placement->campaign_key, 'notes' => $placement->notes, 'collection' => $placement->collection ? ($this->collections->mapCollectionCardPayloads(collect([$placement->collection]), true)[0] ?? null) : null, ]; } private function placementHistoryPayload(CollectionSurfacePlacement $placement): array { return [ 'placement_id' => (int) $placement->id, 'surface_key' => (string) $placement->surface_key, 'collection_id' => (int) $placement->collection_id, 'placement_type' => (string) $placement->placement_type, 'priority' => (int) $placement->priority, 'starts_at' => $placement->starts_at?->toISOString(), 'ends_at' => $placement->ends_at?->toISOString(), 'is_active' => (bool) $placement->is_active, 'campaign_key' => $placement->campaign_key, 'notes' => $placement->notes, ]; } }