579 lines
22 KiB
PHP
579 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
|
|
{
|
|
$grants = WorldRewardGrant::query()
|
|
->with('world')
|
|
->where('artwork_id', (int) $artwork->id)
|
|
->orderByRaw($this->sortCaseSql())
|
|
->orderByDesc('granted_at')
|
|
->get()
|
|
->values();
|
|
|
|
World::primeCanonicalEditionIds(
|
|
$grants->pluck('world')
|
|
->filter()
|
|
->pluck('recurrence_key')
|
|
->all()
|
|
);
|
|
|
|
return $grants
|
|
->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;
|
|
}
|
|
} |