404 lines
18 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|