Files
SkinbaseNova/app/Services/CollectionCampaignService.php
2026-03-28 19:15:39 +01:00

404 lines
18 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\CollectionSurfacePlacement;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Str;
class CollectionCampaignService
{
public function __construct(
private readonly CollectionService $collections,
private readonly CollectionDiscoveryService $discovery,
private readonly CollectionSurfaceService $surfaces,
) {
}
public function updateCampaign(Collection $collection, array $attributes, ?User $actor = null): Collection
{
return $this->collections->updateCollection(
$collection->loadMissing('user'),
$this->normalizeAttributes($collection, $attributes),
$actor,
);
}
public function campaignSummary(Collection $collection): array
{
return [
'campaign_key' => $collection->campaign_key,
'campaign_label' => $collection->campaign_label,
'event_key' => $collection->event_key,
'event_label' => $collection->event_label,
'season_key' => $collection->season_key,
'spotlight_style' => $collection->spotlight_style,
'schedule' => [
'published_at' => $collection->published_at?->toIso8601String(),
'unpublished_at' => $collection->unpublished_at?->toIso8601String(),
'expired_at' => $collection->expired_at?->toIso8601String(),
'is_scheduled' => $collection->published_at?->isFuture() ?? false,
'is_expiring_soon' => $collection->unpublished_at?->between(now(), now()->addDays(14)) ?? false,
],
'eligibility' => $this->eligibility($collection),
'surface_assignments' => $this->surfaceAssignments($collection),
'recommended_surfaces' => $this->suggestedSurfaceAssignments($collection),
'editorial_notes' => $collection->editorial_notes,
'staff_commercial_notes' => $collection->staff_commercial_notes,
];
}
public function eligibility(Collection $collection): array
{
$reasons = [];
if ($collection->visibility !== Collection::VISIBILITY_PUBLIC) {
$reasons[] = 'Collection must be public before it can drive public campaign surfaces.';
}
if ($collection->moderation_status !== Collection::MODERATION_ACTIVE) {
$reasons[] = 'Only moderation-approved collections are eligible for campaign promotion.';
}
if (in_array($collection->lifecycle_state, [
Collection::LIFECYCLE_DRAFT,
Collection::LIFECYCLE_SCHEDULED,
Collection::LIFECYCLE_EXPIRED,
Collection::LIFECYCLE_HIDDEN,
Collection::LIFECYCLE_RESTRICTED,
Collection::LIFECYCLE_UNDER_REVIEW,
], true)) {
$reasons[] = 'Collection lifecycle must be published, featured, or archived before campaign placement.';
}
if ($collection->published_at?->isFuture()) {
$reasons[] = 'Collection publish window has not opened yet.';
}
if ($collection->unpublished_at?->lte(now())) {
$reasons[] = 'Collection campaign window has already ended.';
}
return [
'is_campaign_ready' => count($reasons) === 0,
'is_publicly_featureable' => $collection->isFeatureablePublicly(),
'has_campaign_context' => filled($collection->campaign_key) || filled($collection->event_key) || filled($collection->season_key),
'reasons' => $reasons,
];
}
public function surfaceAssignments(Collection $collection): array
{
return $collection->placements()
->orderBy('surface_key')
->orderByDesc('priority')
->orderBy('starts_at')
->get()
->map(function ($placement): array {
return [
'id' => (int) $placement->id,
'surface_key' => (string) $placement->surface_key,
'placement_type' => (string) $placement->placement_type,
'priority' => (int) $placement->priority,
'campaign_key' => $placement->campaign_key,
'starts_at' => $placement->starts_at?->toIso8601String(),
'ends_at' => $placement->ends_at?->toIso8601String(),
'is_active' => (bool) $placement->is_active,
'notes' => $placement->notes,
];
})
->values()
->all();
}
public function suggestedSurfaceAssignments(Collection $collection): array
{
$suggestions = [];
if (filled($collection->campaign_key) || filled($collection->event_key) || filled($collection->season_key)) {
$suggestions[] = $this->surfaceSuggestion('homepage.featured_collections', 'campaign', 'Campaign-aware collection suitable for the homepage featured rail.', 90);
$suggestions[] = $this->surfaceSuggestion('discover.featured_collections', 'campaign', 'Campaign metadata makes this collection a strong discover spotlight candidate.', 85);
}
if ($collection->type === Collection::TYPE_EDITORIAL) {
$suggestions[] = $this->surfaceSuggestion('homepage.editorial_collections', 'editorial', 'Editorial ownership makes this collection a homepage editorial fit.', 88);
}
if ($collection->type === Collection::TYPE_COMMUNITY) {
$suggestions[] = $this->surfaceSuggestion('homepage.community_collections', 'community', 'Community curation makes this collection suitable for the community row.', 82);
}
if ((float) ($collection->ranking_score ?? 0) >= 60 || (bool) $collection->is_featured) {
$suggestions[] = $this->surfaceSuggestion('homepage.trending_collections', 'algorithmic', 'Strong ranking signals make this collection a trending candidate.', 76);
}
return collect($suggestions)
->unique('surface_key')
->values()
->all();
}
public function expiringCampaignsForOwner(User $user, int $days = 14, int $limit = 6): EloquentCollection
{
return Collection::query()
->with(['user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at'])
->ownedBy((int) $user->id)
->whereNotNull('unpublished_at')
->whereBetween('unpublished_at', [now(), now()->addDays(max(1, $days))])
->orderBy('unpublished_at')
->limit(max(1, $limit))
->get();
}
public function publicLanding(string $campaignKey, int $limit = 18): array
{
$normalizedKey = trim($campaignKey);
$surfaceItems = $this->surfaces->resolveSurfaceItems(sprintf('campaign.%s.featured_collections', $normalizedKey), $limit);
$collections = $surfaceItems->isNotEmpty()
? $surfaceItems
: $this->discovery->publicCampaignCollections($normalizedKey, $limit);
$editorialCollections = $this->discovery->publicCampaignCollectionsByType($normalizedKey, Collection::TYPE_EDITORIAL, 6);
$communityCollections = $this->discovery->publicCampaignCollectionsByType($normalizedKey, Collection::TYPE_COMMUNITY, 6);
$trendingCollections = $this->discovery->publicTrendingCampaignCollections($normalizedKey, 6);
$recentCollections = $this->discovery->publicRecentCampaignCollections($normalizedKey, 6);
$leadCollection = $collections->first();
$placementSurfaces = CollectionSurfacePlacement::query()
->where('campaign_key', $normalizedKey)
->where('is_active', true)
->where(function ($query): void {
$query->whereNull('starts_at')->orWhere('starts_at', '<=', now());
})
->where(function ($query): void {
$query->whereNull('ends_at')->orWhere('ends_at', '>', now());
})
->orderBy('surface_key')
->pluck('surface_key')
->unique()
->values()
->all();
return [
'campaign' => [
'key' => $normalizedKey,
'label' => $leadCollection?->campaign_label ?: Str::headline(str_replace(['_', '-'], ' ', $normalizedKey)),
'description' => $leadCollection?->banner_text
?: sprintf('Public collections grouped under the %s campaign, including editorial, community, and discovery-ready showcases.', Str::headline(str_replace(['_', '-'], ' ', $normalizedKey))),
'badge_label' => $leadCollection?->badge_label,
'event_key' => $leadCollection?->event_key,
'event_label' => $leadCollection?->event_label,
'season_key' => $leadCollection?->season_key,
'active_surface_keys' => $placementSurfaces,
'collections_count' => $collections->count(),
],
'collections' => $collections,
'editorial_collections' => $editorialCollections,
'community_collections' => $communityCollections,
'trending_collections' => $trendingCollections,
'recent_collections' => $recentCollections,
];
}
public function batchEditorialPlan(array $collectionIds, array $attributes): array
{
$collections = Collection::query()
->with(['user:id,username,name'])
->whereIn('id', collect($collectionIds)->map(fn ($id) => (int) $id)->filter()->values()->all())
->orderBy('title')
->get();
$campaignAttributes = $this->batchCampaignAttributes($attributes);
$placementAttributes = $this->batchPlacementAttributes($attributes);
$surfaceKey = $placementAttributes['surface_key'] ?? null;
$items = $collections->map(function (Collection $collection) use ($campaignAttributes, $placementAttributes, $surfaceKey): array {
$campaignPreview = $this->normalizeAttributes($collection, $campaignAttributes);
$placementEligible = $surfaceKey ? $collection->isFeatureablePublicly() : null;
$placementReasons = [];
if ($surfaceKey && ! $collection->isFeatureablePublicly()) {
$placementReasons[] = 'Collection is not publicly featureable for staff surface placement.';
}
return [
'collection' => [
'id' => (int) $collection->id,
'title' => (string) $collection->title,
'slug' => (string) $collection->slug,
'visibility' => (string) $collection->visibility,
'lifecycle_state' => (string) $collection->lifecycle_state,
'moderation_status' => (string) $collection->moderation_status,
'owner' => $collection->user ? [
'id' => (int) $collection->user->id,
'username' => $collection->user->username,
'name' => $collection->user->name,
] : null,
],
'campaign_updates' => $campaignPreview,
'placement' => $surfaceKey ? [
'surface_key' => $surfaceKey,
'placement_type' => $placementAttributes['placement_type'] ?? 'campaign',
'priority' => (int) ($placementAttributes['priority'] ?? 0),
'starts_at' => $placementAttributes['starts_at'] ?? null,
'ends_at' => $placementAttributes['ends_at'] ?? null,
'is_active' => array_key_exists('is_active', $placementAttributes) ? (bool) $placementAttributes['is_active'] : true,
'campaign_key' => $placementAttributes['campaign_key'] ?? ($campaignPreview['campaign_key'] ?? $collection->campaign_key),
'notes' => $placementAttributes['notes'] ?? null,
'eligible' => $placementEligible,
'reasons' => $placementReasons,
] : null,
'existing_assignments' => $this->surfaceAssignments($collection),
'eligibility' => $this->eligibility($collection),
];
})->values();
return [
'collections_count' => $collections->count(),
'campaign_updates_count' => $items->filter(fn (array $item): bool => count($item['campaign_updates']) > 0)->count(),
'placement_candidates_count' => $items->filter(fn (array $item): bool => is_array($item['placement']))->count(),
'placement_eligible_count' => $items->filter(fn (array $item): bool => ($item['placement']['eligible'] ?? false) === true)->count(),
'items' => $items->all(),
];
}
public function applyBatchEditorialPlan(array $collectionIds, array $attributes, ?User $actor = null): array
{
$plan = $this->batchEditorialPlan($collectionIds, $attributes);
$campaignAttributes = $this->batchCampaignAttributes($attributes);
$placementAttributes = $this->batchPlacementAttributes($attributes);
$results = [];
foreach ($plan['items'] as $item) {
$collection = Collection::query()->find((int) Arr::get($item, 'collection.id'));
if (! $collection) {
continue;
}
$updatedCollection = count($campaignAttributes) > 0
? $this->updateCampaign($collection, $campaignAttributes, $actor)
: $collection->fresh();
$placementResult = null;
if (is_array($item['placement'])) {
if (($item['placement']['eligible'] ?? false) === true) {
$existingPlacement = CollectionSurfacePlacement::query()
->where('surface_key', $item['placement']['surface_key'])
->where('collection_id', $updatedCollection->id)
->first();
$placementPayload = array_merge($placementAttributes, [
'id' => $existingPlacement?->id,
'surface_key' => $item['placement']['surface_key'],
'collection_id' => $updatedCollection->id,
'campaign_key' => $item['placement']['campaign_key'],
'created_by_user_id' => $existingPlacement?->created_by_user_id ?: $actor?->id,
]);
$placement = $this->surfaces->upsertPlacement($placementPayload);
$placementResult = [
'status' => $existingPlacement ? 'updated' : 'created',
'placement_id' => (int) $placement->id,
'surface_key' => (string) $placement->surface_key,
];
} else {
$placementResult = [
'status' => 'skipped',
'reasons' => $item['placement']['reasons'] ?? [],
];
}
}
$results[] = [
'collection_id' => (int) $updatedCollection->id,
'campaign_updated' => count($campaignAttributes) > 0,
'placement' => $placementResult,
];
}
return [
'plan' => $plan,
'results' => $results,
];
}
private function normalizeAttributes(Collection $collection, array $attributes): array
{
if (array_key_exists('campaign_key', $attributes) && blank($attributes['campaign_key']) && ! array_key_exists('campaign_label', $attributes)) {
$attributes['campaign_label'] = null;
}
if (array_key_exists('event_key', $attributes) && blank($attributes['event_key']) && ! array_key_exists('event_label', $attributes)) {
$attributes['event_label'] = null;
}
if (
filled($attributes['campaign_key'] ?? $collection->campaign_key)
&& blank($attributes['campaign_label'] ?? $collection->campaign_label)
&& filled($attributes['event_label'] ?? $collection->event_label)
) {
$attributes['campaign_label'] = $attributes['event_label'] ?? $collection->event_label;
}
return $attributes;
}
private function batchCampaignAttributes(array $attributes): array
{
return collect([
'campaign_key',
'campaign_label',
'event_key',
'event_label',
'season_key',
'banner_text',
'badge_label',
'spotlight_style',
'editorial_notes',
])->reduce(function (array $carry, string $key) use ($attributes): array {
if (array_key_exists($key, $attributes)) {
$carry[$key] = $attributes[$key];
}
return $carry;
}, []);
}
private function batchPlacementAttributes(array $attributes): array
{
return collect([
'surface_key',
'placement_type',
'priority',
'starts_at',
'ends_at',
'is_active',
'campaign_key',
'notes',
])->reduce(function (array $carry, string $key) use ($attributes): array {
if (array_key_exists($key, $attributes)) {
$carry[$key] = $attributes[$key];
}
return $carry;
}, []);
}
private function surfaceSuggestion(string $surfaceKey, string $placementType, string $reason, int $priority): array
{
return [
'surface_key' => $surfaceKey,
'placement_type' => $placementType,
'reason' => $reason,
'priority' => $priority,
];
}
}