Files
SkinbaseNova/app/Services/Worlds/WorldRewardService.php

569 lines
22 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Worlds;
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 App\Notifications\WorldRewardGrantedNotification;
use App\Services\Activity\UserActivityService;
use App\Services\XPService;
use App\Support\AvatarUrl;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
final class WorldRewardService
{
private const CHALLENGE_GRANT_SOURCE = 'challenge';
public function __construct(
private readonly UserActivityService $activities,
private readonly XPService $xp,
private readonly WorldAnalyticsService $analytics,
) {
}
public function syncAutomaticRewardsForSubmission(WorldSubmission $submission): void
{
$submission->loadMissing(['world', 'artwork.user.profile']);
$world = $submission->world;
$creator = $submission->artwork?->user;
if (! $world || ! $creator) {
return;
}
$this->syncAutomaticReward($world, $creator, WorldRewardType::Participant);
$this->syncAutomaticReward($world, $creator, WorldRewardType::Featured);
}
public function grantManualReward(WorldSubmission $submission, User $editor, WorldRewardType $rewardType, ?string $note = null): WorldRewardGrant
{
if ($rewardType->isAutomatic()) {
throw new \InvalidArgumentException('Automatic world rewards cannot be granted manually.');
}
if ((string) $submission->status !== WorldSubmission::STATUS_LIVE) {
throw ValidationException::withMessages([
'submission' => 'Only live world submissions can receive manual rewards.',
]);
}
$submission->loadMissing(['world', 'artwork.user.profile']);
$world = $submission->world;
$artwork = $submission->artwork;
$creator = $artwork?->user;
if (! $world || ! $artwork || ! $creator) {
throw new \RuntimeException('Submission is missing world, artwork, or creator context.');
}
$grant = WorldRewardGrant::query()->firstOrNew([
'user_id' => (int) $creator->id,
'world_id' => (int) $world->id,
'reward_type' => $rewardType->value,
]);
$wasRecentlyCreated = ! $grant->exists;
$grant->forceFill([
'artwork_id' => (int) $artwork->id,
'world_submission_id' => (int) $submission->id,
'granted_by_user_id' => (int) $editor->id,
'grant_source' => $rewardType->source(),
'note' => $this->nullableText($note),
'granted_at' => $grant->granted_at ?? now(),
])->save();
$grant->loadMissing(['world', 'artwork', 'user.profile']);
if ($wasRecentlyCreated) {
$this->dispatchGrantSideEffects($grant);
}
return $grant;
}
public function revokeManualReward(WorldSubmission $submission, WorldRewardType $rewardType): void
{
if ($rewardType->isAutomatic()) {
throw new \InvalidArgumentException('Automatic world rewards are revoked through submission state changes.');
}
$submission->loadMissing(['world', 'artwork.user']);
$world = $submission->world;
$creator = $submission->artwork?->user;
if (! $world || ! $creator) {
return;
}
WorldRewardGrant::query()
->where('user_id', (int) $creator->id)
->where('world_id', (int) $world->id)
->where('reward_type', $rewardType->value)
->where('grant_source', $rewardType->source())
->delete();
$this->activities->invalidateUserFeed((int) $creator->id);
}
public function summaryForUser(User $user, int $limit = 12): array
{
$recentGrants = WorldRewardGrant::query()
->with(['world', 'artwork', 'user.profile'])
->where('user_id', (int) $user->id)
->orderByDesc('granted_at')
->orderByDesc('id')
->get();
$grants = $recentGrants->sortBy([
fn (WorldRewardGrant $grant): int => $this->sortPriority($grant->reward_type),
fn (WorldRewardGrant $grant): int => -1 * ($grant->granted_at?->getTimestamp() ?? 0),
fn (WorldRewardGrant $grant): int => -1 * (int) $grant->id,
])->values();
return [
'count' => $grants->count(),
'counts' => [
'participant' => $grants->where('reward_type', WorldRewardType::Participant)->count(),
'featured' => $grants->where('reward_type', WorldRewardType::Featured)->count(),
'finalist' => $grants->where('reward_type', WorldRewardType::Finalist)->count(),
'winner' => $grants->where('reward_type', WorldRewardType::Winner)->count(),
'spotlight' => $grants->where('reward_type', WorldRewardType::Spotlight)->count(),
],
'recent' => $recentGrants->take($limit)->map(fn (WorldRewardGrant $grant): array => $this->mapGrant($grant))->all(),
'items' => $grants->map(fn (WorldRewardGrant $grant): array => $this->mapGrant($grant))->all(),
];
}
public function rewardedContributorsForWorld(World $world, int $limit = 24): array
{
$baseQuery = WorldRewardGrant::query()
->where('world_id', (int) $world->id);
$allGrants = (clone $baseQuery)
->get(['user_id', 'reward_type']);
$grants = (clone $baseQuery)
->with(['user.profile', 'artwork'])
->orderByRaw($this->sortCaseSql())
->orderByDesc('granted_at')
->orderByDesc('id')
->limit($limit)
->get();
return [
'count' => $allGrants->count(),
'creator_count' => $allGrants->pluck('user_id')->filter()->unique()->count(),
'counts' => [
'participant' => $allGrants->where('reward_type', WorldRewardType::Participant->value)->count(),
'featured' => $allGrants->where('reward_type', WorldRewardType::Featured->value)->count(),
'finalist' => $allGrants->where('reward_type', WorldRewardType::Finalist->value)->count(),
'winner' => $allGrants->where('reward_type', WorldRewardType::Winner->value)->count(),
'spotlight' => $allGrants->where('reward_type', WorldRewardType::Spotlight->value)->count(),
],
'items' => $grants->map(fn (WorldRewardGrant $grant): array => $this->mapGrant($grant))->all(),
];
}
public function syncLinkedChallengeRewardsForWorld(World $world): void
{
$world->loadMissing(['worldRelations', 'linkedChallenge.group', 'linkedChallenge.outcomes']);
if (! (bool) ($world->auto_grant_challenge_world_rewards ?? true)) {
$this->deleteChallengeOutcomeGrantsForWorld($world);
return;
}
$challengeIds = $world->worldRelations
->where('related_type', WorldRelation::TYPE_CHALLENGE)
->pluck('related_id')
->map(fn ($id): int => (int) $id)
->filter(fn (int $id): bool => $id > 0)
->prepend((int) ($world->linked_challenge_id ?? 0))
->unique()
->values();
if ($challengeIds->isEmpty()) {
$this->deleteChallengeOutcomeGrantsForWorld($world);
return;
}
$challenges = GroupChallenge::query()
->with(['group', 'featuredArtwork.user.profile', 'outcomes.artwork.user.profile'])
->whereIn('id', $challengeIds->all())
->get()
->filter(fn (GroupChallenge $challenge): bool => $this->challengeCanGrantWorldOutcomeReward($challenge))
->values();
$this->syncChallengeOutcomeRewardTypeForWorld($world, $challenges, WorldRewardType::Winner);
$this->syncChallengeOutcomeRewardTypeForWorld($world, $challenges, WorldRewardType::Finalist);
}
private function syncChallengeOutcomeRewardTypeForWorld(World $world, Collection $challenges, WorldRewardType $rewardType): void
{
$artworkIds = $challenges
->flatMap(fn (GroupChallenge $challenge): array => $this->challengeOutcomeArtworkIds($challenge, $rewardType)->all())
->unique()
->values();
$submissionsByArtwork = $artworkIds->isEmpty()
? collect()
: WorldSubmission::query()
->with(['artwork.user.profile'])
->where('world_id', (int) $world->id)
->where('status', WorldSubmission::STATUS_LIVE)
->whereIn('artwork_id', $artworkIds->all())
->get()
->keyBy(fn (WorldSubmission $submission): int => (int) $submission->artwork_id);
$expected = collect();
foreach ($challenges as $challenge) {
foreach ($this->challengeOutcomeArtworkIds($challenge, $rewardType) as $artworkId) {
$submission = $submissionsByArtwork->get((int) $artworkId);
$creator = $submission?->artwork?->user;
if (! $submission || ! $creator || $expected->has((int) $creator->id)) {
continue;
}
$expected->put((int) $creator->id, [
'submission' => $submission,
'challenge' => $challenge,
]);
}
}
$existing = WorldRewardGrant::query()
->where('world_id', (int) $world->id)
->where('reward_type', $rewardType->value)
->get()
->keyBy(fn (WorldRewardGrant $grant): int => (int) $grant->user_id);
foreach ($expected as $userId => $payload) {
/** @var WorldSubmission $submission */
$submission = $payload['submission'];
/** @var GroupChallenge $challenge */
$challenge = $payload['challenge'];
$current = $existing->get((int) $userId);
if ($current && (string) $current->grant_source !== self::CHALLENGE_GRANT_SOURCE) {
continue;
}
$grant = $current ?? new WorldRewardGrant();
$wasRecentlyCreated = ! $grant->exists;
$grant->forceFill([
'user_id' => (int) $userId,
'world_id' => (int) $world->id,
'artwork_id' => (int) $submission->artwork_id,
'world_submission_id' => (int) $submission->id,
'granted_by_user_id' => null,
'reward_type' => $rewardType->value,
'grant_source' => self::CHALLENGE_GRANT_SOURCE,
'note' => sprintf('Synced from linked challenge %s: %s.', $rewardType->label(), $challenge->title),
'granted_at' => $grant->granted_at ?? now(),
])->save();
$grant->loadMissing(['world', 'artwork', 'user.profile']);
if ($wasRecentlyCreated) {
$this->dispatchGrantSideEffects($grant);
}
}
$expectedUserIds = $expected->keys()->map(fn ($id): int => (int) $id)->all();
$existing
->filter(fn (WorldRewardGrant $grant): bool => (string) $grant->grant_source === self::CHALLENGE_GRANT_SOURCE)
->reject(fn (WorldRewardGrant $grant): bool => in_array((int) $grant->user_id, $expectedUserIds, true))
->each(function (WorldRewardGrant $grant): void {
$grant->delete();
$this->activities->invalidateUserFeed((int) $grant->user_id);
});
}
public function syncLinkedChallengeRewardsForChallenge(GroupChallenge $challenge): void
{
$worldIds = WorldRelation::query()
->where('related_type', WorldRelation::TYPE_CHALLENGE)
->where('related_id', (int) $challenge->id)
->pluck('world_id')
->map(fn ($id): int => (int) $id)
->filter(fn (int $id): bool => $id > 0)
->merge(
World::query()
->where('linked_challenge_id', (int) $challenge->id)
->pluck('id')
->map(fn ($id): int => (int) $id)
)
->unique()
->all();
if ($worldIds === []) {
return;
}
World::query()
->with('worldRelations')
->whereIn('id', $worldIds)
->get()
->each(fn (World $world): bool => tap(true, fn () => $this->syncLinkedChallengeRewardsForWorld($world)));
}
public function creatorRewardMapForWorld(World $world): Collection
{
return WorldRewardGrant::query()
->with(['artwork'])
->where('world_id', (int) $world->id)
->orderByRaw($this->sortCaseSql())
->orderByDesc('granted_at')
->get()
->groupBy('user_id')
->map(fn (Collection $items): array => $items->map(fn (WorldRewardGrant $grant): array => $this->mapGrant($grant, false))->all());
}
public function artworkRewardBadges(Artwork $artwork): array
{
return WorldRewardGrant::query()
->with('world')
->where('artwork_id', (int) $artwork->id)
->orderByRaw($this->sortCaseSql())
->orderByDesc('granted_at')
->get()
->map(function (WorldRewardGrant $grant): array {
$world = $grant->world;
$rewardType = $grant->reward_type;
return [
'world_id' => (int) ($world?->id ?? 0),
'world_title' => (string) ($world?->title ?? 'World'),
'world_slug' => (string) ($world?->slug ?? ''),
'world_url' => $world?->publicUrl(),
'badge_label' => $this->worldRewardLabel($world, $rewardType),
'status' => $rewardType->value,
'status_label' => $rewardType->label(),
'tone' => $rewardType->tone(),
'sort_priority' => $this->sortPriority($rewardType),
];
})
->all();
}
private function syncAutomaticReward(World $world, User $creator, WorldRewardType $rewardType): void
{
$qualifyingSubmission = $this->qualifyingSubmission($world, $creator, $rewardType);
$existing = WorldRewardGrant::query()
->where('user_id', (int) $creator->id)
->where('world_id', (int) $world->id)
->where('reward_type', $rewardType->value)
->first();
if (! $qualifyingSubmission) {
if ($rewardType === WorldRewardType::Participant) {
return;
}
if ($existing && (string) $existing->grant_source === $rewardType->source()) {
$existing->delete();
$this->activities->invalidateUserFeed((int) $creator->id);
}
return;
}
if ($existing) {
$existing->forceFill([
'artwork_id' => (int) $qualifyingSubmission->artwork_id,
'world_submission_id' => (int) $qualifyingSubmission->id,
'grant_source' => $rewardType->source(),
])->save();
return;
}
$grant = WorldRewardGrant::query()->create([
'user_id' => (int) $creator->id,
'world_id' => (int) $world->id,
'artwork_id' => (int) $qualifyingSubmission->artwork_id,
'world_submission_id' => (int) $qualifyingSubmission->id,
'reward_type' => $rewardType->value,
'grant_source' => $rewardType->source(),
'granted_at' => now(),
]);
$grant->loadMissing(['world', 'artwork', 'user.profile']);
$this->dispatchGrantSideEffects($grant);
}
private function qualifyingSubmission(World $world, User $creator, WorldRewardType $rewardType): ?WorldSubmission
{
$query = WorldSubmission::query()
->with(['world', 'artwork.user.profile'])
->where('world_id', (int) $world->id)
->where('status', WorldSubmission::STATUS_LIVE)
->whereHas('artwork', fn (Builder $builder) => $builder->where('user_id', (int) $creator->id));
if ($rewardType === WorldRewardType::Featured) {
$query->where('is_featured', true)->orderByDesc('featured_at');
} else {
$query->orderByDesc('reviewed_at');
}
return $query->orderByDesc('id')->first();
}
private function dispatchGrantSideEffects(WorldRewardGrant $grant): void
{
$this->analytics->recordRewardGrant($grant);
$grant->user?->notify(new WorldRewardGrantedNotification($grant));
$this->activities->logWorldReward((int) $grant->user_id, (int) $grant->id, [
'reward_type' => $grant->reward_type->value,
'world_id' => (int) $grant->world_id,
]);
$this->xp->awardWorldReward((int) $grant->user_id, $grant->reward_type, (int) $grant->world_id);
}
private function mapGrant(WorldRewardGrant $grant, bool $includeCreator = true): array
{
$grant->loadMissing(['world', 'artwork', 'user.profile']);
$payload = [
'id' => (int) $grant->id,
'reward_type' => $grant->reward_type->value,
'reward_label' => $grant->reward_type->label(),
'badge_label' => $this->worldRewardLabel($grant->world, $grant->reward_type),
'tone' => $grant->reward_type->tone(),
'grant_source' => (string) $grant->grant_source,
'note' => (string) ($grant->note ?? ''),
'granted_at' => $grant->granted_at?->toIso8601String(),
'world' => $grant->world ? [
'id' => (int) $grant->world->id,
'title' => (string) $grant->world->title,
'slug' => (string) $grant->world->slug,
'url' => $grant->world->publicUrl(),
'edition_year' => $grant->world->edition_year,
] : null,
'artwork' => $grant->artwork ? [
'id' => (int) $grant->artwork->id,
'title' => (string) ($grant->artwork->title ?: 'Untitled artwork'),
'url' => route('art.show', ['id' => (int) $grant->artwork->id, 'slug' => $grant->artwork->slug ?: Str::slug((string) $grant->artwork->title)]),
] : null,
];
if (! $includeCreator) {
return $payload;
}
return [
...$payload,
'creator' => $grant->user ? [
'id' => (int) $grant->user->id,
'name' => (string) ($grant->user->name ?: $grant->user->username ?: 'Creator'),
'username' => (string) ($grant->user->username ?? ''),
'profile_url' => $grant->user->username ? route('profile.show', ['username' => strtolower((string) $grant->user->username)]) : null,
'avatar_url' => AvatarUrl::forUser((int) $grant->user->id, $grant->user->profile?->avatar_hash, 96),
] : null,
];
}
private function worldRewardLabel(?World $world, WorldRewardType $rewardType): string
{
return trim(($world?->title ?? 'World') . ' ' . $rewardType->label());
}
private function sortCaseSql(): string
{
return "CASE reward_type WHEN 'winner' THEN 0 WHEN 'finalist' THEN 1 WHEN 'spotlight' THEN 2 WHEN 'featured' THEN 3 ELSE 4 END";
}
private function challengeCanGrantWorldOutcomeReward(GroupChallenge $challenge): bool
{
if ((string) $challenge->status === GroupChallenge::STATUS_DRAFT) {
return false;
}
return $challenge->canBeViewedBy(null);
}
private function challengeOutcomeArtworkIds(GroupChallenge $challenge, WorldRewardType $rewardType): Collection
{
$challenge->loadMissing('outcomes');
$type = match ($rewardType) {
WorldRewardType::Winner => GroupChallengeOutcome::TYPE_WINNER,
WorldRewardType::Finalist => GroupChallengeOutcome::TYPE_FINALIST,
default => null,
};
if ($type === null) {
return collect();
}
$ids = $challenge->outcomes
->where('outcome_type', $type)
->pluck('artwork_id')
->map(fn ($id): int => (int) $id)
->filter(fn (int $id): bool => $id > 0)
->values();
if ($rewardType === WorldRewardType::Winner && $ids->isEmpty() && (int) ($challenge->featured_artwork_id ?? 0) > 0) {
return collect([(int) $challenge->featured_artwork_id]);
}
return $ids;
}
private function deleteChallengeOutcomeGrantsForWorld(World $world, ?WorldRewardType $rewardType = null): void
{
$query = WorldRewardGrant::query()
->where('world_id', (int) $world->id)
->where('grant_source', self::CHALLENGE_GRANT_SOURCE)
->when($rewardType !== null, fn ($builder) => $builder->where('reward_type', $rewardType->value));
$query
->get()
->each(function (WorldRewardGrant $grant): void {
$grant->delete();
$this->activities->invalidateUserFeed((int) $grant->user_id);
});
}
private function sortPriority(WorldRewardType $rewardType): int
{
return match ($rewardType) {
WorldRewardType::Winner => 0,
WorldRewardType::Finalist => 1,
WorldRewardType::Spotlight => 2,
WorldRewardType::Featured => 3,
WorldRewardType::Participant => 4,
};
}
private function nullableText(?string $value): ?string
{
$trimmed = trim((string) $value);
return $trimmed !== '' ? $trimmed : null;
}
}