455 lines
20 KiB
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),
|
|
];
|
|
}
|
|
} |