optimizations
This commit is contained in:
403
app/Services/CollectionCampaignService.php
Normal file
403
app/Services/CollectionCampaignService.php
Normal file
@@ -0,0 +1,403 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user