Save workspace changes
This commit is contained in:
@@ -0,0 +1,479 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkFeature;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection as SupportCollection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class FeaturedArtworkAdminService
|
||||
{
|
||||
public function __construct(private readonly ArtworkService $artworks)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function pageProps(): array
|
||||
{
|
||||
$now = Carbon::now();
|
||||
$features = ArtworkFeature::query()
|
||||
->with([
|
||||
'artwork' => fn ($query) => $query->withTrashed()->with([
|
||||
'user:id,username,name',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'group:id,name,slug,avatar_path',
|
||||
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
|
||||
]),
|
||||
])
|
||||
->orderByDesc('priority')
|
||||
->orderByDesc('featured_at')
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
|
||||
$duplicateCounts = $features->countBy(fn (ArtworkFeature $feature): int => (int) $feature->artwork_id);
|
||||
$entries = $features
|
||||
->map(fn (ArtworkFeature $feature): array => $this->mapFeature($feature, $duplicateCounts, $now))
|
||||
->sort(function (array $left, array $right): int {
|
||||
$comparisons = [
|
||||
(int) $right['eligibility']['is_eligible'] <=> (int) $left['eligibility']['is_eligible'],
|
||||
(int) $right['is_force_hero'] <=> (int) $left['is_force_hero'],
|
||||
(int) $right['is_active'] <=> (int) $left['is_active'],
|
||||
(int) $left['is_expired'] <=> (int) $right['is_expired'],
|
||||
(int) $right['priority'] <=> (int) $left['priority'],
|
||||
(int) $right['medals']['score_30d'] <=> (int) $left['medals']['score_30d'],
|
||||
$this->timestamp($right['featured_at']) <=> $this->timestamp($left['featured_at']),
|
||||
$this->timestamp($right['artwork']['published_at']) <=> $this->timestamp($left['artwork']['published_at']),
|
||||
(int) $right['id'] <=> (int) $left['id'],
|
||||
];
|
||||
|
||||
foreach ($comparisons as $comparison) {
|
||||
if ($comparison !== 0) {
|
||||
return $comparison;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
})
|
||||
->values();
|
||||
|
||||
$eligibleEntries = $this->sortForHeroSelection(
|
||||
$entries->filter(fn (array $entry): bool => (bool) $entry['eligibility']['is_eligible'])->values()
|
||||
);
|
||||
|
||||
$sharedWinnerArtworkId = $this->artworks->getFeaturedArtworkWinner()?->id;
|
||||
$winner = $sharedWinnerArtworkId
|
||||
? $entries->first(fn (array $entry): bool => (int) $entry['artwork_id'] === (int) $sharedWinnerArtworkId)
|
||||
: null;
|
||||
|
||||
if (! is_array($winner) && $eligibleEntries->isNotEmpty()) {
|
||||
$winner = $eligibleEntries->first();
|
||||
}
|
||||
|
||||
$winnerReason = is_array($winner) ? $this->buildWinnerReason($winner, $eligibleEntries) : null;
|
||||
$winnerId = is_array($winner) ? (int) $winner['id'] : null;
|
||||
|
||||
$entries = $entries
|
||||
->map(function (array $entry) use ($winnerId, $winnerReason): array {
|
||||
$isWinner = $winnerId !== null && (int) $entry['id'] === $winnerId;
|
||||
|
||||
if ($isWinner) {
|
||||
array_unshift($entry['status_badges'], [
|
||||
'label' => 'Winner',
|
||||
'tone' => 'amber',
|
||||
]);
|
||||
}
|
||||
|
||||
$entry['is_winner'] = $isWinner;
|
||||
$entry['winner_reason'] = $isWinner ? $winnerReason : null;
|
||||
|
||||
return $entry;
|
||||
})
|
||||
->values();
|
||||
|
||||
$winner = is_array($winner)
|
||||
? array_merge($winner, ['selection_reason' => $winnerReason])
|
||||
: null;
|
||||
|
||||
return [
|
||||
'entries' => $entries->all(),
|
||||
'winner' => $winner,
|
||||
'stats' => [
|
||||
'total' => $entries->count(),
|
||||
'active' => $entries->where('is_active', true)->count(),
|
||||
'inactive' => $entries->where('is_active', false)->count(),
|
||||
'expired' => $entries->where('is_expired', true)->count(),
|
||||
'eligible' => $entries->filter(fn (array $entry): bool => (bool) $entry['eligibility']['is_eligible'])->count(),
|
||||
'ineligible' => $entries->filter(fn (array $entry): bool => ! $entry['eligibility']['is_eligible'])->count(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function searchArtworks(string $term, int $limit = 12): array
|
||||
{
|
||||
$term = trim($term);
|
||||
if ($term === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$now = Carbon::now();
|
||||
$artworks = Artwork::query()
|
||||
->with([
|
||||
'user:id,username,name',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'group:id,name,slug,avatar_path',
|
||||
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
|
||||
])
|
||||
->where(function ($query) use ($term): void {
|
||||
if (ctype_digit($term)) {
|
||||
$query->where('artworks.id', (int) $term);
|
||||
}
|
||||
|
||||
$query->orWhere('artworks.title', 'like', '%' . $term . '%')
|
||||
->orWhere('artworks.slug', 'like', '%' . $term . '%')
|
||||
->orWhereHas('user', function ($userQuery) use ($term): void {
|
||||
$userQuery->where('username', 'like', '%' . $term . '%')
|
||||
->orWhere('name', 'like', '%' . $term . '%');
|
||||
})
|
||||
->orWhereHas('group', function ($groupQuery) use ($term): void {
|
||||
$groupQuery->where('name', 'like', '%' . $term . '%')
|
||||
->orWhere('slug', 'like', '%' . $term . '%');
|
||||
});
|
||||
});
|
||||
|
||||
if (ctype_digit($term)) {
|
||||
$artworks->orderByRaw('CASE WHEN artworks.id = ? THEN 0 ELSE 1 END', [(int) $term]);
|
||||
}
|
||||
|
||||
$artworks = $artworks
|
||||
->orderByDesc('published_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
$featureCounts = ArtworkFeature::query()
|
||||
->whereNull('deleted_at')
|
||||
->whereIn('artwork_id', $artworks->pluck('id'))
|
||||
->selectRaw('artwork_id, COUNT(*) as aggregate')
|
||||
->groupBy('artwork_id')
|
||||
->pluck('aggregate', 'artwork_id');
|
||||
|
||||
return $artworks
|
||||
->map(fn (Artwork $artwork): array => $this->mapArtworkCandidate($artwork, (int) ($featureCounts[(int) $artwork->id] ?? 0), $now))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SupportCollection<int, int> $duplicateCounts
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapFeature(ArtworkFeature $feature, SupportCollection $duplicateCounts, Carbon $now): array
|
||||
{
|
||||
$context = $this->mapArtworkContext($feature->artwork, $now);
|
||||
$isExpired = $feature->expires_at !== null && $feature->expires_at->lte($now);
|
||||
$isEligible = (bool) $feature->is_active && ! $isExpired && (bool) $context['eligibility']['is_eligible'];
|
||||
$eligibilityReasons = $context['eligibility']['reasons'];
|
||||
|
||||
if (! $feature->is_active) {
|
||||
$eligibilityReasons[] = 'Inactive';
|
||||
}
|
||||
|
||||
if ($isExpired) {
|
||||
$eligibilityReasons[] = 'Expired';
|
||||
}
|
||||
|
||||
$statusBadges = [];
|
||||
|
||||
if ($feature->is_active && ! $isExpired) {
|
||||
$statusBadges[] = ['label' => 'Active', 'tone' => 'emerald'];
|
||||
}
|
||||
|
||||
if ((bool) $feature->force_hero) {
|
||||
$statusBadges[] = ['label' => 'Force Hero', 'tone' => 'amber'];
|
||||
}
|
||||
|
||||
if (! $feature->is_active) {
|
||||
$statusBadges[] = ['label' => 'Inactive', 'tone' => 'slate'];
|
||||
}
|
||||
|
||||
if ($isExpired) {
|
||||
$statusBadges[] = ['label' => 'Expired', 'tone' => 'amber'];
|
||||
}
|
||||
|
||||
$statusBadges[] = $isEligible
|
||||
? ['label' => 'Eligible', 'tone' => 'sky']
|
||||
: ['label' => 'Not eligible', 'tone' => 'rose'];
|
||||
|
||||
if ((bool) $context['flags']['is_private']) {
|
||||
$statusBadges[] = ['label' => 'Private', 'tone' => 'slate'];
|
||||
}
|
||||
|
||||
if ((bool) $context['flags']['is_unpublished']) {
|
||||
$statusBadges[] = ['label' => 'Unpublished', 'tone' => 'slate'];
|
||||
}
|
||||
|
||||
if ((bool) $context['flags']['missing_preview']) {
|
||||
$statusBadges[] = ['label' => 'Missing preview', 'tone' => 'rose'];
|
||||
}
|
||||
|
||||
if ((bool) $context['flags']['is_deleted']) {
|
||||
$statusBadges[] = ['label' => 'Deleted', 'tone' => 'slate'];
|
||||
}
|
||||
|
||||
if ((int) $duplicateCounts->get((int) $feature->artwork_id, 0) > 1) {
|
||||
$statusBadges[] = ['label' => 'Duplicate', 'tone' => 'sky'];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $feature->id,
|
||||
'artwork_id' => (int) $feature->artwork_id,
|
||||
'priority' => (int) $feature->priority,
|
||||
'featured_at' => $feature->featured_at?->toIsoString(),
|
||||
'expires_at' => $feature->expires_at?->toIsoString(),
|
||||
'created_at' => $feature->created_at?->toIsoString(),
|
||||
'updated_at' => $feature->updated_at?->toIsoString(),
|
||||
'is_active' => (bool) $feature->is_active,
|
||||
'is_force_hero' => (bool) $feature->force_hero,
|
||||
'is_expired' => $isExpired,
|
||||
'duplicate_count' => (int) $duplicateCounts->get((int) $feature->artwork_id, 0),
|
||||
'artwork' => $context['artwork'],
|
||||
'medals' => $context['medals'],
|
||||
'eligibility' => [
|
||||
'is_eligible' => $isEligible,
|
||||
'reasons' => array_values(array_unique($eligibilityReasons)),
|
||||
],
|
||||
'status_badges' => $statusBadges,
|
||||
'is_winner' => false,
|
||||
'winner_reason' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapArtworkCandidate(Artwork $artwork, int $existingFeatureCount, Carbon $now): array
|
||||
{
|
||||
$context = $this->mapArtworkContext($artwork, $now);
|
||||
|
||||
return array_merge($context['artwork'], [
|
||||
'medals' => $context['medals'],
|
||||
'eligibility' => $context['eligibility'],
|
||||
'existing_feature_count' => $existingFeatureCount,
|
||||
'already_featured' => $existingFeatureCount > 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapArtworkContext(?Artwork $artwork, Carbon $now): array
|
||||
{
|
||||
if (! $artwork instanceof Artwork) {
|
||||
return [
|
||||
'artwork' => [
|
||||
'id' => null,
|
||||
'title' => 'Missing artwork',
|
||||
'slug' => null,
|
||||
'canonical_url' => null,
|
||||
'thumbnail' => ThumbnailPresenter::present(['id' => null, 'name' => 'Missing artwork'], 'sm'),
|
||||
'published_at' => null,
|
||||
'visibility' => null,
|
||||
'is_public' => false,
|
||||
'is_approved' => false,
|
||||
'has_missing_preview' => true,
|
||||
'is_deleted' => true,
|
||||
'owner' => null,
|
||||
],
|
||||
'medals' => [
|
||||
'score_30d' => 0,
|
||||
],
|
||||
'eligibility' => [
|
||||
'is_eligible' => false,
|
||||
'reasons' => ['Deleted'],
|
||||
],
|
||||
'flags' => [
|
||||
'is_private' => false,
|
||||
'is_unpublished' => true,
|
||||
'missing_preview' => true,
|
||||
'is_deleted' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$isDeleted = $artwork->deleted_at !== null;
|
||||
$isPublic = ! $isDeleted && (bool) $artwork->is_public;
|
||||
$isApproved = ! $isDeleted && (bool) $artwork->is_approved;
|
||||
$isPublished = ! $isDeleted && $artwork->published_at !== null && $artwork->published_at->lte($now);
|
||||
$hasPreview = ! (bool) $artwork->has_missing_thumbnails;
|
||||
$owner = $this->mapOwner($artwork);
|
||||
$reasons = [];
|
||||
|
||||
if ($isDeleted) {
|
||||
$reasons[] = 'Deleted';
|
||||
}
|
||||
|
||||
if (! $isPublic) {
|
||||
$reasons[] = 'Private';
|
||||
}
|
||||
|
||||
if (! $isApproved) {
|
||||
$reasons[] = 'Not approved';
|
||||
}
|
||||
|
||||
if (! $isPublished) {
|
||||
$reasons[] = 'Unpublished';
|
||||
}
|
||||
|
||||
if (! $hasPreview) {
|
||||
$reasons[] = 'Missing preview';
|
||||
}
|
||||
|
||||
return [
|
||||
'artwork' => [
|
||||
'id' => (int) $artwork->id,
|
||||
'title' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'slug' => (string) $artwork->slug,
|
||||
'canonical_url' => route('art.show', ['id' => (int) $artwork->id, 'slug' => $this->artworkSlug($artwork)]),
|
||||
'thumbnail' => ThumbnailPresenter::present($artwork, 'sm'),
|
||||
'published_at' => $artwork->published_at?->toIsoString(),
|
||||
'visibility' => (string) ($artwork->visibility ?? ''),
|
||||
'is_public' => (bool) $artwork->is_public,
|
||||
'is_approved' => (bool) $artwork->is_approved,
|
||||
'has_missing_preview' => (bool) $artwork->has_missing_thumbnails,
|
||||
'is_deleted' => $isDeleted,
|
||||
'owner' => $owner,
|
||||
],
|
||||
'medals' => [
|
||||
'score_30d' => (int) ($artwork->awardStat?->score_30d ?? 0),
|
||||
],
|
||||
'eligibility' => [
|
||||
'is_eligible' => $isPublic && $isApproved && $isPublished && $hasPreview,
|
||||
'reasons' => $reasons,
|
||||
],
|
||||
'flags' => [
|
||||
'is_private' => ! $isPublic,
|
||||
'is_unpublished' => ! $isPublished,
|
||||
'missing_preview' => ! $hasPreview,
|
||||
'is_deleted' => $isDeleted,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SupportCollection<int, array<string, mixed>> $entries
|
||||
* @return SupportCollection<int, array<string, mixed>>
|
||||
*/
|
||||
private function sortForHeroSelection(SupportCollection $entries): SupportCollection
|
||||
{
|
||||
return $entries
|
||||
->sort(function (array $left, array $right): int {
|
||||
$comparisons = [
|
||||
(int) $right['is_force_hero'] <=> (int) $left['is_force_hero'],
|
||||
(int) $right['priority'] <=> (int) $left['priority'],
|
||||
(int) $right['medals']['score_30d'] <=> (int) $left['medals']['score_30d'],
|
||||
$this->timestamp($right['featured_at']) <=> $this->timestamp($left['featured_at']),
|
||||
$this->timestamp($right['artwork']['published_at']) <=> $this->timestamp($left['artwork']['published_at']),
|
||||
(int) $right['id'] <=> (int) $left['id'],
|
||||
];
|
||||
|
||||
foreach ($comparisons as $comparison) {
|
||||
if ($comparison !== 0) {
|
||||
return $comparison;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
})
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $winner
|
||||
* @param SupportCollection<int, array<string, mixed>> $eligibleEntries
|
||||
*/
|
||||
private function buildWinnerReason(array $winner, SupportCollection $eligibleEntries): string
|
||||
{
|
||||
if ((bool) ($winner['is_force_hero'] ?? false)) {
|
||||
return 'Forced hero override is enabled for this featured artwork.';
|
||||
}
|
||||
|
||||
$runnerUp = $eligibleEntries->skip(1)->first();
|
||||
|
||||
if (! is_array($runnerUp)) {
|
||||
return 'Only eligible featured artwork right now.';
|
||||
}
|
||||
|
||||
if ((int) $winner['priority'] > (int) $runnerUp['priority']) {
|
||||
return 'Highest priority among active, eligible featured artworks.';
|
||||
}
|
||||
|
||||
if ((int) $winner['medals']['score_30d'] > (int) $runnerUp['medals']['score_30d']) {
|
||||
return 'Tied on priority, won on higher 30-day medal score.';
|
||||
}
|
||||
|
||||
if ($this->timestamp($winner['featured_at']) > $this->timestamp($runnerUp['featured_at'])) {
|
||||
return 'Tied on priority and medal score, won on newer featured date.';
|
||||
}
|
||||
|
||||
if ($this->timestamp($winner['artwork']['published_at']) > $this->timestamp($runnerUp['artwork']['published_at'])) {
|
||||
return 'Tied on priority, medal score, and featured date, won on newer published date.';
|
||||
}
|
||||
|
||||
return 'Selected by the shared homepage hero ordering.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function mapOwner(Artwork $artwork): ?array
|
||||
{
|
||||
if ($artwork->group) {
|
||||
return [
|
||||
'type' => 'group',
|
||||
'display_name' => (string) $artwork->group->name,
|
||||
'username' => (string) $artwork->group->slug,
|
||||
'profile_url' => $artwork->group->publicUrl(),
|
||||
];
|
||||
}
|
||||
|
||||
if (! $artwork->user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'user',
|
||||
'display_name' => (string) ($artwork->user->name ?: '@' . $artwork->user->username),
|
||||
'username' => (string) $artwork->user->username,
|
||||
'profile_url' => $artwork->user->username !== '' ? '/@' . $artwork->user->username : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function artworkSlug(Artwork $artwork): string
|
||||
{
|
||||
$slug = trim((string) $artwork->slug);
|
||||
if ($slug !== '') {
|
||||
return $slug;
|
||||
}
|
||||
|
||||
$titleSlug = Str::slug((string) $artwork->title);
|
||||
|
||||
return $titleSlug !== '' ? $titleSlug : (string) $artwork->id;
|
||||
}
|
||||
|
||||
private function timestamp(?string $value): int
|
||||
{
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) (strtotime($value) ?: 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user