Files
SkinbaseNova/app/Services/Profile/WorldProfileHistoryService.php

455 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Profile;
use App\Enums\WorldRewardType;
use App\Models\Artwork;
use App\Models\GroupChallenge;
use App\Models\GroupChallengeOutcome;
use App\Models\User;
use App\Models\World;
use App\Models\WorldRelation;
use App\Models\WorldRewardGrant;
use App\Models\WorldSubmission;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
final class WorldProfileHistoryService
{
public function publicPayloadForUser(User $user): array
{
return $this->buildPayload($user, false);
}
public function ownerPayloadForUser(User $user): array
{
return $this->buildPayload($user, true);
}
private function buildPayload(User $user, bool $includeOwnerContext): array
{
$submissions = $this->submissionsForUser($user);
$rewardGrants = $this->rewardGrantsForUser($user);
$challengeOutcomes = $this->challengeOutcomesForUser($user);
$challengeWorldMap = $this->challengeWorldMap($challengeOutcomes->pluck('group_challenge_id')->unique()->values());
$entries = [];
$hiddenPublicEntries = 0;
foreach ($rewardGrants as $grant) {
if (! $this->grantQualifiesForPublicHistory($grant, $submissions)) {
$hiddenPublicEntries++;
continue;
}
$this->addRecognition(
$entries,
$grant->world,
$grant->reward_type->value,
$grant->artwork,
$grant->granted_at,
$grant->grant_source === 'challenge' ? $this->challengeContextForWorld($grant->world) : null,
'reward'
);
}
$liveSubmissions = $submissions
->filter(fn (WorldSubmission $submission): bool => $this->submissionQualifiesForPublicHistory($submission));
foreach ($liveSubmissions as $submission) {
$recognitionKey = $submission->is_featured ? WorldRewardType::Featured->value : WorldRewardType::Participant->value;
$this->addRecognition(
$entries,
$submission->world,
$recognitionKey,
$submission->artwork,
$submission->featured_at ?? $submission->reviewed_at ?? $submission->created_at,
$this->challengeContextForWorld($submission->world),
'participation'
);
}
foreach ($challengeOutcomes as $outcome) {
$recognitionKey = $this->recognitionKeyForOutcome($outcome->outcome_type);
if ($recognitionKey === null || ! $this->artworkIsPubliclyVisible($outcome->artwork)) {
$hiddenPublicEntries++;
continue;
}
$worlds = $challengeWorldMap->get((int) $outcome->group_challenge_id, Collection::make());
if ($worlds->isEmpty()) {
continue;
}
foreach ($worlds as $world) {
$this->addRecognition(
$entries,
$world,
$recognitionKey,
$outcome->artwork,
$outcome->awarded_at ?? $outcome->created_at,
$this->challengeContextFromChallenge($outcome->challenge),
'challenge_outcome'
);
}
}
$normalizedEntries = Collection::make($entries)
->map(fn (array $entry): array => $this->normalizeEntry($entry))
->sort(function (array $left, array $right): int {
if ($left['occurred_at'] !== $right['occurred_at']) {
return strcmp((string) $right['occurred_at'], (string) $left['occurred_at']);
}
if ($left['primary_recognition']['priority'] !== $right['primary_recognition']['priority']) {
return $left['primary_recognition']['priority'] <=> $right['primary_recognition']['priority'];
}
return strcmp((string) $left['world']['title'], (string) $right['world']['title']);
})
->values();
$yearValues = $normalizedEntries
->pluck('world.edition_year')
->filter(fn ($year): bool => is_int($year) || ctype_digit((string) $year))
->map(fn ($year): int => (int) $year)
->values();
$worldAppearances = $normalizedEntries->count();
$highlights = $normalizedEntries->take(3)->values();
$mostRecent = $normalizedEntries->first();
return [
'summary' => [
'available' => $normalizedEntries->isNotEmpty(),
'world_appearances' => $worldAppearances,
'worlds_joined' => $worldAppearances,
'featured_appearances' => $normalizedEntries->filter(fn (array $entry): bool => in_array('featured', $entry['recognition_keys'], true))->count(),
'winner_appearances' => $normalizedEntries->filter(fn (array $entry): bool => in_array('winner', $entry['recognition_keys'], true))->count(),
'finalist_appearances' => $normalizedEntries->filter(fn (array $entry): bool => in_array('finalist', $entry['recognition_keys'], true))->count(),
'spotlight_appearances' => $normalizedEntries->filter(fn (array $entry): bool => in_array('spotlight', $entry['recognition_keys'], true))->count(),
'finalist_winner_appearances' => $normalizedEntries->filter(fn (array $entry): bool => in_array('winner', $entry['recognition_keys'], true) || in_array('finalist', $entry['recognition_keys'], true))->count(),
'active_year_span' => $this->yearSpanPayload($yearValues),
'most_recent_world_activity' => $mostRecent ? [
'world_title' => $mostRecent['world']['title'],
'primary_recognition' => $mostRecent['primary_recognition'],
'recognition_label' => $mostRecent['primary_recognition']['label'],
'world_url' => $mostRecent['world']['url'],
'occurred_at' => $mostRecent['occurred_at'],
] : null,
],
'highlights' => $highlights->all(),
'entries' => $normalizedEntries->all(),
'owner_context' => $includeOwnerContext ? [
'pending_submissions' => $submissions->where('status', WorldSubmission::STATUS_PENDING)->count(),
'removed_or_blocked_submissions' => $submissions->filter(fn (WorldSubmission $submission): bool => in_array((string) $submission->status, [WorldSubmission::STATUS_REMOVED, WorldSubmission::STATUS_BLOCKED], true))->count(),
'hidden_public_entries' => $hiddenPublicEntries,
] : null,
'filters' => [
'default_order' => 'recent_first',
'groupable_by' => ['year', 'world_family', 'recognition_type'],
],
];
}
private function submissionsForUser(User $user): Collection
{
return WorldSubmission::query()
->with([
'world:id,title,slug,type,recurrence_key,edition_year,linked_challenge_id,status,published_at,deleted_at',
'world.linkedChallenge.group:id,name,slug,visibility,status',
'artwork:id,user_id,title,slug,hash,thumb_ext,is_public,visibility,is_approved,published_at,deleted_at',
])
->where('submitted_by_user_id', (int) $user->id)
->whereHas('world', fn ($builder) => $builder->publiclyVisible())
->whereHas('artwork', fn ($builder) => $builder->where('user_id', (int) $user->id))
->get();
}
private function rewardGrantsForUser(User $user): Collection
{
return WorldRewardGrant::query()
->with([
'world:id,title,slug,type,recurrence_key,edition_year,linked_challenge_id,status,published_at,deleted_at',
'world.linkedChallenge.group:id,name,slug,visibility,status',
'artwork:id,user_id,title,slug,hash,thumb_ext,is_public,visibility,is_approved,published_at,deleted_at',
'worldSubmission:id,world_id,artwork_id,status,is_featured,featured_at,reviewed_at,created_at',
])
->where('user_id', (int) $user->id)
->whereHas('world', fn ($builder) => $builder->publiclyVisible())
->orderByDesc('granted_at')
->orderByDesc('id')
->get();
}
private function challengeOutcomesForUser(User $user): Collection
{
return GroupChallengeOutcome::query()
->with([
'challenge.group:id,name,slug,visibility,status',
'artwork:id,user_id,title,slug,hash,thumb_ext,is_public,visibility,is_approved,published_at,deleted_at',
])
->where('user_id', (int) $user->id)
->whereHas('artwork', fn ($builder) => $builder->where('user_id', (int) $user->id))
->get();
}
private function challengeWorldMap(Collection $challengeIds): Collection
{
if ($challengeIds->isEmpty()) {
return Collection::make();
}
$map = Collection::make();
World::query()
->with('linkedChallenge.group')
->publiclyVisible()
->whereIn('linked_challenge_id', $challengeIds->all())
->get()
->each(function (World $world) use (&$map): void {
$challengeId = (int) ($world->linked_challenge_id ?? 0);
if ($challengeId <= 0) {
return;
}
$items = $map->get($challengeId, Collection::make());
$map->put($challengeId, $items->push($world)->unique('id')->values());
});
WorldRelation::query()
->with(['world.linkedChallenge.group'])
->where('related_type', WorldRelation::TYPE_CHALLENGE)
->whereIn('related_id', $challengeIds->all())
->whereHas('world', fn ($builder) => $builder->publiclyVisible())
->get()
->each(function (WorldRelation $relation) use (&$map): void {
$challengeId = (int) $relation->related_id;
$world = $relation->world;
if (! $world) {
return;
}
$items = $map->get($challengeId, Collection::make());
$map->put($challengeId, $items->push($world)->unique('id')->values());
});
return $map;
}
private function grantQualifiesForPublicHistory(WorldRewardGrant $grant, Collection $submissions): bool
{
if (! $grant->world || ! $this->artworkIsPubliclyVisible($grant->artwork)) {
return false;
}
return match ($grant->reward_type) {
WorldRewardType::Participant => $submissions->contains(fn (WorldSubmission $submission): bool => (int) $submission->world_id === (int) $grant->world_id && $this->submissionQualifiesForPublicHistory($submission)),
WorldRewardType::Featured => $submissions->contains(fn (WorldSubmission $submission): bool => (int) $submission->world_id === (int) $grant->world_id && $this->submissionQualifiesForPublicHistory($submission) && (bool) $submission->is_featured),
default => true,
};
}
private function submissionQualifiesForPublicHistory(WorldSubmission $submission): bool
{
return $submission->world !== null
&& (string) $submission->status === WorldSubmission::STATUS_LIVE
&& $this->artworkIsPubliclyVisible($submission->artwork);
}
private function artworkIsPubliclyVisible(?Artwork $artwork): bool
{
if (! $artwork) {
return false;
}
return $artwork->deleted_at === null
&& (bool) $artwork->is_approved
&& (bool) $artwork->is_public
&& $artwork->published_at !== null
&& $artwork->published_at->isPast()
&& in_array((string) ($artwork->visibility ?? Artwork::VISIBILITY_PUBLIC), ['', Artwork::VISIBILITY_PUBLIC], true);
}
private function recognitionKeyForOutcome(string $outcomeType): ?string
{
return match ($outcomeType) {
GroupChallengeOutcome::TYPE_WINNER => 'winner',
GroupChallengeOutcome::TYPE_FINALIST => 'finalist',
GroupChallengeOutcome::TYPE_FEATURED => 'featured',
GroupChallengeOutcome::TYPE_RUNNER_UP => 'runner_up',
GroupChallengeOutcome::TYPE_HONORABLE_MENTION => 'honorable_mention',
default => null,
};
}
private function addRecognition(array &$entries, World $world, string $recognitionKey, ?Artwork $artwork, ?\DateTimeInterface $occurredAt, ?array $challengeContext, string $sourceType): void
{
$worldId = (int) $world->id;
$timestamp = $occurredAt?->format(DATE_ATOM) ?? date(DATE_ATOM);
if (! array_key_exists($worldId, $entries)) {
$entries[$worldId] = [
'world' => $world,
'recognitions' => [],
'occurred_at' => $timestamp,
];
}
if (! array_key_exists($recognitionKey, $entries[$worldId]['recognitions'])) {
$entries[$worldId]['recognitions'][$recognitionKey] = [
'recognition' => $this->recognitionPayload($recognitionKey),
'linked_artwork' => $this->artworkPayload($artwork),
'challenge' => $challengeContext,
'occurred_at' => $timestamp,
'source_types' => [$sourceType],
];
} else {
$current = $entries[$worldId]['recognitions'][$recognitionKey];
$entries[$worldId]['recognitions'][$recognitionKey] = [
'recognition' => $current['recognition'],
'linked_artwork' => $current['linked_artwork'] ?? $this->artworkPayload($artwork),
'challenge' => $current['challenge'] ?? $challengeContext,
'occurred_at' => max((string) $current['occurred_at'], $timestamp),
'source_types' => array_values(array_unique([...$current['source_types'], $sourceType])),
];
}
if ($timestamp > (string) $entries[$worldId]['occurred_at']) {
$entries[$worldId]['occurred_at'] = $timestamp;
}
}
private function normalizeEntry(array $entry): array
{
/** @var World $world */
$world = $entry['world'];
$recognitions = Collection::make($entry['recognitions'])
->sort(function (array $left, array $right): int {
if ($left['recognition']['priority'] !== $right['recognition']['priority']) {
return $left['recognition']['priority'] <=> $right['recognition']['priority'];
}
return strcmp((string) $right['occurred_at'], (string) $left['occurred_at']);
})
->values();
$primary = $recognitions->first();
$linkedArtwork = $primary['linked_artwork'] ?? $recognitions->pluck('linked_artwork')->first(fn ($item) => $item !== null);
$challenge = $primary['challenge'] ?? $recognitions->pluck('challenge')->first(fn ($item) => $item !== null);
return [
'id' => 'world-history-' . (int) $world->id,
'world' => [
'id' => (int) $world->id,
'title' => (string) $world->title,
'slug' => (string) $world->slug,
'url' => $world->publicUrl(),
'type' => (string) $world->type,
'type_label' => Str::headline((string) $world->type),
'edition_year' => $world->edition_year ? (int) $world->edition_year : null,
'family_key' => (string) ($world->recurrence_key ?: 'world-' . $world->id),
'family_label' => $this->familyLabelForWorld($world),
],
'primary_recognition' => $primary['recognition'],
'recognitions' => $recognitions->map(fn (array $recognition): array => [
...$recognition['recognition'],
'source_types' => $recognition['source_types'],
])->all(),
'recognition_keys' => $recognitions->map(fn (array $recognition): string => (string) $recognition['recognition']['key'])->values()->all(),
'linked_artwork' => $linkedArtwork,
'challenge' => $challenge,
'occurred_at' => (string) $entry['occurred_at'],
'source_types' => $recognitions->flatMap(fn (array $recognition): array => $recognition['source_types'])->unique()->values()->all(),
];
}
private function artworkPayload(?Artwork $artwork): ?array
{
if (! $artwork || ! $this->artworkIsPubliclyVisible($artwork)) {
return null;
}
return [
'id' => (int) $artwork->id,
'title' => (string) ($artwork->title ?: 'Untitled artwork'),
'url' => route('art.show', ['id' => (int) $artwork->id, 'slug' => $artwork->slug ?: Str::slug((string) $artwork->title)]),
'thumbnail_url' => $artwork->thumb_url,
];
}
private function challengeContextForWorld(?World $world): ?array
{
if (! $world || ! $world->linkedChallenge || ! $world->linkedChallenge->canBeViewedBy(null)) {
return null;
}
return $this->challengeContextFromChallenge($world->linkedChallenge);
}
private function challengeContextFromChallenge(?GroupChallenge $challenge): ?array
{
if (! $challenge || ! $challenge->group || ! $challenge->canBeViewedBy(null)) {
return null;
}
return [
'id' => (int) $challenge->id,
'title' => (string) $challenge->title,
'url' => route('groups.challenges.show', ['group' => $challenge->group, 'challenge' => $challenge]),
'group_name' => (string) $challenge->group->name,
];
}
private function recognitionPayload(string $recognitionKey): array
{
return match ($recognitionKey) {
'winner' => ['key' => 'winner', 'label' => 'Winner', 'tone' => 'emerald', 'priority' => 0],
'finalist' => ['key' => 'finalist', 'label' => 'Finalist', 'tone' => 'violet', 'priority' => 1],
'featured' => ['key' => 'featured', 'label' => 'Featured', 'tone' => 'amber', 'priority' => 2],
'spotlight' => ['key' => 'spotlight', 'label' => 'Spotlight', 'tone' => 'rose', 'priority' => 3],
'participant' => ['key' => 'participant', 'label' => 'Participant', 'tone' => 'sky', 'priority' => 4],
'runner_up' => ['key' => 'runner_up', 'label' => 'Runner-up', 'tone' => 'slate', 'priority' => 5],
'honorable_mention' => ['key' => 'honorable_mention', 'label' => 'Honorable Mention', 'tone' => 'slate', 'priority' => 6],
default => ['key' => $recognitionKey, 'label' => Str::headline(str_replace('_', ' ', $recognitionKey)), 'tone' => 'slate', 'priority' => 7],
};
}
private function familyLabelForWorld(World $world): string
{
if ($world->recurrence_key) {
return Str::headline(str_replace('-', ' ', (string) $world->recurrence_key));
}
if ($world->edition_year) {
return trim((string) preg_replace('/\s+' . preg_quote((string) $world->edition_year, '/') . '$/', '', (string) $world->title));
}
return (string) $world->title;
}
private function yearSpanPayload(Collection $years): ?array
{
if ($years->isEmpty()) {
return null;
}
$start = (int) $years->min();
$end = (int) $years->max();
return [
'start' => $start,
'end' => $end,
'label' => $start === $end ? (string) $start : sprintf('%d-%d', $start, $end),
];
}
}