From 4c5fdb0f8c701de6cec23cd7cbef46a2a2d0e432 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Thu, 21 May 2026 20:19:35 -0400 Subject: [PATCH 1/3] chore: prepare polymorphic ticketable primitives --- .../Actions/CreateGameClaimAction.php | 2 +- app/Community/Enums/TicketType.php | 27 +++++++- app/Helpers/database/user.php | 2 +- app/Models/Achievement.php | 38 ++++++++++- app/Models/Leaderboard.php | 64 ++++++++++++++++- app/Models/Ticket.php | 68 +++++++++++++++---- .../Actions/BuildGamePageClaimDataAction.php | 2 +- .../Actions/BuildGameShowPagePropsAction.php | 4 +- app/Platform/Contracts/Ticketable.php | 25 +++++++ app/Platform/Requests/StoreTicketRequest.php | 6 +- app/Platform/Services/TicketListService.php | 5 +- database/factories/TicketFactory.php | 3 +- public/API/API_GetTicketData.php | 9 +-- resources/js/types/generated.d.ts | 2 +- .../game/link-buttons/index.blade.php | 4 +- .../most-reported-games-table.blade.php | 10 ++- .../pages/user/[User]/tickets/index.blade.php | 2 +- .../Controllers/GameControllerShowTest.php | 47 +++++++++++++ 18 files changed, 281 insertions(+), 39 deletions(-) create mode 100644 app/Platform/Contracts/Ticketable.php diff --git a/app/Community/Actions/CreateGameClaimAction.php b/app/Community/Actions/CreateGameClaimAction.php index 134a9079fb..a2599692cb 100644 --- a/app/Community/Actions/CreateGameClaimAction.php +++ b/app/Community/Actions/CreateGameClaimAction.php @@ -117,7 +117,7 @@ private function maybeSendClaimWithUnresolvedTicketsAlert(User $currentUser, Gam return; } - $ticketCount = Ticket::forDeveloper($currentUser)->awaitingDeveloper()->count(); + $ticketCount = Ticket::forAssignee($currentUser)->awaitingDeveloper()->count(); if ($ticketCount < 2) { // two or more suggests the developer may be ignoring tickets return; } diff --git a/app/Community/Enums/TicketType.php b/app/Community/Enums/TicketType.php index 686610b537..bb4e74ad25 100644 --- a/app/Community/Enums/TicketType.php +++ b/app/Community/Enums/TicketType.php @@ -4,20 +4,38 @@ namespace App\Community\Enums; +use App\Platform\Enums\TicketableType; use InvalidArgumentException; +use LogicException; use Spatie\TypeScriptTransformer\Attributes\TypeScript; #[TypeScript] enum TicketType: string { - case TriggeredAtWrongTime = 'triggered_at_wrong_time'; + case DidNotCancel = 'did_not_cancel'; + case DidNotStart = 'did_not_start'; + case DidNotSubmit = 'did_not_submit'; case DidNotTrigger = 'did_not_trigger'; + case SubmittedWrongValue = 'submitted_wrong_value'; + case TriggeredAtWrongTime = 'triggered_at_wrong_time'; + + public function appliesTo(TicketableType $type): bool + { + return match ($this) { + self::TriggeredAtWrongTime, self::DidNotTrigger => $type === TicketableType::Achievement, + self::DidNotStart, self::DidNotCancel, self::DidNotSubmit, self::SubmittedWrongValue => $type === TicketableType::Leaderboard, + }; + } public function label(): string { return match ($this) { - self::TriggeredAtWrongTime => 'Triggered at the wrong time', + self::DidNotCancel => 'Did not cancel', + self::DidNotStart => 'Did not start', + self::DidNotSubmit => 'Did not submit', self::DidNotTrigger => 'Did not trigger', + self::SubmittedWrongValue => 'Submitted wrong value', + self::TriggeredAtWrongTime => 'Triggered at the wrong time', }; } @@ -31,6 +49,11 @@ public function toLegacyInteger(): int return match ($this) { self::TriggeredAtWrongTime => 1, self::DidNotTrigger => 2, + + self::DidNotStart, + self::DidNotCancel, + self::DidNotSubmit, + self::SubmittedWrongValue => throw new LogicException("TicketType {$this->value} has no legacy integer mapping."), }; } diff --git a/app/Helpers/database/user.php b/app/Helpers/database/user.php index 9cb64ed6b9..8319c8ff26 100644 --- a/app/Helpers/database/user.php +++ b/app/Helpers/database/user.php @@ -258,7 +258,7 @@ function GetDeveloperStatsFull(int $count, int $offset = 0, int $sortBy = 0, int } elseif ($sortBy == 4) { // TicketsResolvedForOthers DESC $query = "SELECT ua.id, SUM(!ISNULL(ach.id)) as total FROM users as ua - LEFT JOIN tickets tick ON tick.resolver_id = ua.id AND tick.state = 'resolved' AND tick.resolver_id != tick.reporter_id + LEFT JOIN tickets tick ON tick.resolver_id = ua.id AND tick.state = 'resolved' AND tick.resolver_id != tick.reporter_id AND tick.ticketable_type = 'achievement' LEFT JOIN achievements as ach ON ach.id = tick.ticketable_id AND ach.is_promoted = 1 AND ach.user_id != ua.id WHERE $stateCond GROUP BY ua.id diff --git a/app/Models/Achievement.php b/app/Models/Achievement.php index 6a1e096d19..b20db10ba2 100644 --- a/app/Models/Achievement.php +++ b/app/Models/Achievement.php @@ -9,6 +9,7 @@ use App\Community\Enums\CommentableType; use App\Platform\Contracts\HasPermalink; use App\Platform\Contracts\HasVersionedTrigger; +use App\Platform\Contracts\Ticketable; use App\Platform\Enums\AchievementAuthorTask; use App\Platform\Enums\AchievementSetType; use App\Platform\Enums\AchievementType; @@ -22,6 +23,7 @@ use App\Platform\Events\AchievementTypeChanged; use App\Platform\Events\AchievementUnpromoted; use App\Support\Database\Eloquent\BaseModel; +use Carbon\CarbonInterface; use Database\Factories\AchievementFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -51,7 +53,7 @@ /** * @implements HasVersionedTrigger */ -class Achievement extends BaseModel implements HasPermalink, HasVersionedTrigger +class Achievement extends BaseModel implements HasPermalink, HasVersionedTrigger, Ticketable { /* * Community Traits @@ -260,6 +262,38 @@ public function shouldBeSearchable(): bool return $this->is_promoted; } + // == ticketable + + public function getTicketableType(): TicketableType + { + return TicketableType::Achievement; + } + + public function getTicketableGame(): Game + { + return $this->game; + } + + public function getTicketableAssignee(?CarbonInterface $at = null): ?User + { + return $this->getMaintainerAt($at ?? now()); + } + + public function getTicketableTitle(): string + { + return $this->title; + } + + public function getTicketableUrl(): string + { + return $this->getCanonicalUrlAttribute(); + } + + public function getTicketableBadgeUrl(): ?string + { + return $this->getBadgeUrlAttribute(); + } + // == helpers public function ensureAuthorshipCredit(User $user, AchievementAuthorTask $task, ?Carbon $backdate = null): AchievementAuthor @@ -270,7 +304,7 @@ public function ensureAuthorshipCredit(User $user, AchievementAuthorTask $task, ); } - public function getMaintainerAt(Carbon $timestamp): ?User + public function getMaintainerAt(CarbonInterface $timestamp): ?User { $maintainer = $this->maintainers() ->where('effective_from', '<=', $timestamp) diff --git a/app/Models/Leaderboard.php b/app/Models/Leaderboard.php index cc4c6b47c7..d79d30f3a8 100644 --- a/app/Models/Leaderboard.php +++ b/app/Models/Leaderboard.php @@ -8,9 +8,12 @@ use App\Platform\Actions\RecalculateLeaderboardTopEntryAction; use App\Platform\Contracts\HasPermalink; use App\Platform\Contracts\HasVersionedTrigger; +use App\Platform\Contracts\Ticketable; use App\Platform\Enums\LeaderboardState; +use App\Platform\Enums\TicketableType; use App\Platform\Enums\ValueFormat; use App\Support\Database\Eloquent\BaseModel; +use Carbon\CarbonInterface; use Database\Factories\LeaderboardFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -30,7 +33,7 @@ /** * @implements HasVersionedTrigger */ -class Leaderboard extends BaseModel implements HasPermalink, HasVersionedTrigger +class Leaderboard extends BaseModel implements HasPermalink, HasVersionedTrigger, Ticketable { /* * Shared Traits @@ -105,6 +108,39 @@ public function getActivitylogOptions(): LogOptions ->dontSubmitEmptyLogs(); } + // == ticketable + + public function getTicketableType(): TicketableType + { + return TicketableType::Leaderboard; + } + + public function getTicketableGame(): Game + { + return $this->game; + } + + public function getTicketableAssignee(?CarbonInterface $at = null): ?User + { + // leaderboards don't have a "maintainer" concept - the assignee is always the author. + return $this->developer; + } + + public function getTicketableTitle(): string + { + return $this->title; + } + + public function getTicketableUrl(): string + { + return $this->getCanonicalUrlAttribute(); + } + + public function getTicketableBadgeUrl(): ?string + { + return null; + } + // == accessors public function getCanonicalUrlAttribute(): string @@ -245,6 +281,14 @@ public function triggers(): MorphMany ->orderBy('version'); } + /** + * @return MorphMany + */ + public function tickets(): MorphMany + { + return $this->morphMany(Ticket::class, 'ticketable'); + } + // == scopes /** @@ -256,6 +300,24 @@ public function scopeVisible(Builder $query): Builder return $query->where('order_column', '>=', 0); } + /** + * @param Builder $query + * @return Builder + */ + public function scopePromoted(Builder $query): Builder + { + return $query->where('state', '!=', LeaderboardState::Unpromoted->value); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeUnpromoted(Builder $query): Builder + { + return $query->where('state', LeaderboardState::Unpromoted->value); + } + /** * @param Builder $query * @return Builder diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index 7a3e6255fa..74995790dd 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -6,6 +6,8 @@ use App\Community\Enums\TicketState; use App\Community\Enums\TicketType; +use App\Platform\Enums\LeaderboardState; +use App\Platform\Enums\TicketableType; use App\Support\Database\Eloquent\BaseModel; use Database\Factories\TicketFactory; use Illuminate\Database\Eloquent\Builder; @@ -14,6 +16,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\SoftDeletes; +use LogicException; class Ticket extends BaseModel { @@ -64,6 +67,9 @@ public function ticketable(): MorphTo } /** + * Unsafe without a `ticketable_type = achievement` filter. + * Use `ticketable()` or pair with `scopeForTicketableType`. + * * @return BelongsTo */ public function achievement(): BelongsTo @@ -71,6 +77,17 @@ public function achievement(): BelongsTo return $this->belongsTo(Achievement::class, 'ticketable_id'); } + /** + * Unsafe without a `ticketable_type = leaderboard` filter. + * Use `ticketable()` or pair with `scopeForTicketableType`. + * + * @return BelongsTo + */ + public function leaderboard(): BelongsTo + { + return $this->belongsTo(Leaderboard::class, 'ticketable_id'); + } + /** * @return BelongsTo */ @@ -158,9 +175,11 @@ public function scopeQuarantined(Builder $query): Builder */ public function scopeForGame(Builder $query, Game $game): Builder { - return $query->whereHas('achievement', function ($query) use ($game) { - $query->where('game_id', $game->id); - }); + return $query->whereHasMorph( + 'ticketable', + [Achievement::class, Leaderboard::class], + fn (Builder $q) => $q->where('game_id', $game->id), + ); } /** @@ -170,37 +189,58 @@ public function scopeForGame(Builder $query, Game $game): Builder public function scopeForAchievement(Builder $query, Achievement $achievement): Builder { return $query->where('ticketable_id', $achievement->id) - ->where('ticketable_type', 'achievement'); + ->where('ticketable_type', TicketableType::Achievement->value); } /** * @param Builder $query * @return Builder */ - public function scopeForDeveloper(Builder $query, User $developer): Builder + public function scopeForAssignee(Builder $query, User $user): Builder { - return $query->where('ticketable_author_id', $developer->id); + return $query->where('ticketable_author_id', $user->id); } /** * @param Builder $query * @return Builder */ - public function scopeOfficialCore(Builder $query): Builder + public function scopeForTicketableType(Builder $query, TicketableType $type): Builder { - return $query->whereHas('achievement', function ($query) { - $query->where('is_promoted', true); - }); + return $query->where('ticketable_type', $type->value); } /** * @param Builder $query * @return Builder */ - public function scopeUnofficial(Builder $query): Builder + public function scopePromoted(Builder $query): Builder { - return $query->whereHas('achievement', function ($query) { - $query->where('is_promoted', false); - }); + return $query->whereHasMorph( + 'ticketable', + [Achievement::class, Leaderboard::class], + fn (Builder $q, string $type) => match ($type) { + Achievement::class => $q->where('is_promoted', true), + Leaderboard::class => $q->where('state', '!=', LeaderboardState::Unpromoted->value), + default => throw new LogicException("Unexpected ticketable type: {$type}"), + }, + ); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeUnpromoted(Builder $query): Builder + { + return $query->whereHasMorph( + 'ticketable', + [Achievement::class, Leaderboard::class], + fn (Builder $q, string $type) => match ($type) { + Achievement::class => $q->where('is_promoted', false), + Leaderboard::class => $q->where('state', LeaderboardState::Unpromoted->value), + default => throw new LogicException("Unexpected ticketable type: {$type}"), + }, + ); } } diff --git a/app/Platform/Actions/BuildGamePageClaimDataAction.php b/app/Platform/Actions/BuildGamePageClaimDataAction.php index a59bdbbb1c..03ebae72ca 100644 --- a/app/Platform/Actions/BuildGamePageClaimDataAction.php +++ b/app/Platform/Actions/BuildGamePageClaimDataAction.php @@ -73,7 +73,7 @@ public function execute(Game $game, ?User $user, Collection $achievementSetClaim isSoleAuthor: $isSoleAuthor, maxClaimCount: $maxClaimCount, numClaimsRemaining: $this->calculateNumClaimsRemaining($user, $maxClaimCount), - numUnresolvedTickets: Ticket::forDeveloper($user)->awaitingDeveloper()->count(), + numUnresolvedTickets: Ticket::forAssignee($user)->awaitingDeveloper()->count(), wouldBeCollaboration: $wouldBeCollaboration, wouldBeRevision: $wouldBeRevision, ); diff --git a/app/Platform/Actions/BuildGameShowPagePropsAction.php b/app/Platform/Actions/BuildGameShowPagePropsAction.php index ba405ec868..f932b97c54 100644 --- a/app/Platform/Actions/BuildGameShowPagePropsAction.php +++ b/app/Platform/Actions/BuildGameShowPagePropsAction.php @@ -352,8 +352,8 @@ public function execute( numLeaderboards: $this->gameLeaderboardService->getCount($backingGame, $isPromoted), numMasters: $numMasters, numOpenTickets: $isPromoted - ? Ticket::forGame($backingGame)->open()->officialCore()->count() - : Ticket::forGame($backingGame)->open()->unofficial()->count(), + ? Ticket::forGame($backingGame)->open()->promoted()->count() + : Ticket::forGame($backingGame)->open()->unpromoted()->count(), numScreenshots: $game->gameScreenshots()->approved()->count(), screenshots: Lazy::inertiaDeferred(fn () => $game->gameScreenshots() diff --git a/app/Platform/Contracts/Ticketable.php b/app/Platform/Contracts/Ticketable.php new file mode 100644 index 0000000000..b15eb96f6d --- /dev/null +++ b/app/Platform/Contracts/Ticketable.php @@ -0,0 +1,25 @@ + 'required|string|in:achievement', // TODO or in:leaderboard 'ticketableId' => 'required|integer|exists:achievements,id', // TODO could also be a leaderboard id 'mode' => 'required|string|in:hardcore,softcore', - 'issue' => ['required', new Enum(TicketType::class)], + // TODO expand or compute this list via TicketType::appliesTo() + 'issue' => ['required', (new Enum(TicketType::class))->only([ + TicketType::TriggeredAtWrongTime, + TicketType::DidNotTrigger, + ])], 'description' => 'required|string|max:2000', 'emulator' => 'required|string', 'emulatorVersion' => 'nullable|string', diff --git a/app/Platform/Services/TicketListService.php b/app/Platform/Services/TicketListService.php index c656a67176..17f279b55f 100644 --- a/app/Platform/Services/TicketListService.php +++ b/app/Platform/Services/TicketListService.php @@ -8,6 +8,7 @@ use App\Enums\Permissions; use App\Models\Emulator; use App\Models\Ticket; +use App\Platform\Enums\TicketableType; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\Request; @@ -217,11 +218,11 @@ public function buildQuery(array $filterOptions, ?Builder $tickets = null): Buil switch ($filterOptions['achievement']) { case 'core': - $tickets->officialCore(); + $tickets->forTicketableType(TicketableType::Achievement)->promoted(); break; case 'unofficial': - $tickets->unofficial(); + $tickets->forTicketableType(TicketableType::Achievement)->unpromoted(); break; } diff --git a/database/factories/TicketFactory.php b/database/factories/TicketFactory.php index e5ed7523bf..f878fa8329 100644 --- a/database/factories/TicketFactory.php +++ b/database/factories/TicketFactory.php @@ -7,6 +7,7 @@ use App\Community\Enums\TicketState; use App\Community\Enums\TicketType; use App\Models\Ticket; +use App\Platform\Enums\TicketableType; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Carbon; @@ -20,7 +21,7 @@ class TicketFactory extends Factory public function definition(): array { return [ - 'ticketable_type' => 'achievement', + 'ticketable_type' => TicketableType::Achievement->value, 'body' => $this->faker->sentence(), 'type' => TicketType::DidNotTrigger, 'state' => TicketState::Open, diff --git a/public/API/API_GetTicketData.php b/public/API/API_GetTicketData.php index 6a4847aa05..325930ae34 100644 --- a/public/API/API_GetTicketData.php +++ b/public/API/API_GetTicketData.php @@ -152,6 +152,7 @@ use App\Models\Game; use App\Models\Ticket; use App\Models\User; +use App\Platform\Enums\TicketableType; use Illuminate\Database\Eloquent\Builder; $count = min((int) request()->query('c', '10'), 100); @@ -285,11 +286,11 @@ if ($gameIDGiven > 0) { $game = Game::where('id', $gameIDGiven)->with('system')->first(); if ($game) { - $tickets = Ticket::forGame($game); + $tickets = Ticket::forGame($game)->forTicketableType(TicketableType::Achievement); if ($gamesTableFlag === Achievement::FLAG_UNPROMOTED) { - $tickets->unofficial(); + $tickets->unpromoted(); } else { - $tickets->officialCore(); + $tickets->promoted(); } $ticketData['GameID'] = $game->id; @@ -327,7 +328,7 @@ } // getting the 10 most recent tickets -$tickets = Ticket::officialCore()->open(); +$tickets = Ticket::forTicketableType(TicketableType::Achievement)->promoted()->open(); $ticketData['OpenTickets'] = $tickets->count(); $ticketData['URL'] = route('tickets.index'); $ticketData['RecentTickets'] = $getTicketsInfo($tickets, $offset, $count); diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts index e68b0bba26..da5adeca0b 100644 --- a/resources/js/types/generated.d.ts +++ b/resources/js/types/generated.d.ts @@ -202,7 +202,7 @@ export type ModerationReportableType = 'Comment' | 'DirectMessage' | 'ForumTopic export type NewsCategory = 'achievement-set' | 'community' | 'events' | 'guide' | 'media' | 'site-release-notes' | 'technical'; export type SubscriptionSubjectType = 'ForumTopic' | 'UserWall' | 'GameWall' | 'Achievement' | 'Leaderboard' | 'GameTickets' | 'GameAchievements' | 'AchievementTicket' | 'GameScreenshotDecision'; export type TicketState = 'closed' | 'open' | 'resolved' | 'request' | 'quarantined'; -export type TicketType = 'triggered_at_wrong_time' | 'did_not_trigger'; +export type TicketType = 'did_not_cancel' | 'did_not_start' | 'did_not_submit' | 'did_not_trigger' | 'submitted_wrong_value' | 'triggered_at_wrong_time'; export type TrendingReason = 'new-set' | 'revised-set' | 'gaining-traction' | 'renewed-interest' | 'many-more-players' | 'more-players'; export type UserGameListType = 'achievement_set_request' | 'play' | 'develop'; export type UserRelationStatus = 'blocked' | 'not_following' | 'following'; diff --git a/resources/views/components/game/link-buttons/index.blade.php b/resources/views/components/game/link-buttons/index.blade.php index 48d5b7a2c7..2535e15f5b 100644 --- a/resources/views/components/game/link-buttons/index.blade.php +++ b/resources/views/components/game/link-buttons/index.blade.php @@ -27,9 +27,9 @@ if ($canSeeOpenTickets) { $gameTickets = Ticket::forGame($game)->open(); if ($isViewingOfficial) { - $gameTickets->officialCore(); + $gameTickets->promoted(); } else { - $gameTickets->unofficial(); + $gameTickets->unpromoted(); } $numOpenTickets = $gameTickets->count(); } diff --git a/resources/views/livewire/administrative-tools/most-reported-games-table.blade.php b/resources/views/livewire/administrative-tools/most-reported-games-table.blade.php index 8e29774d89..e7e8b116c8 100644 --- a/resources/views/livewire/administrative-tools/most-reported-games-table.blade.php +++ b/resources/views/livewire/administrative-tools/most-reported-games-table.blade.php @@ -2,6 +2,7 @@ use App\Models\Role; use App\Models\Ticket; +use App\Platform\Enums\TicketableType; use Filament\Actions\Concerns\InteractsWithActions; use Filament\Actions\Contracts\HasActions; use Filament\Forms\Concerns\InteractsWithForms; @@ -72,18 +73,21 @@ public function table(Table $table): Table private function buildMostTicketedSetsQuery(): Builder { $oldestTicketSubquery = Ticket::open() - ->officialCore() + ->forTicketableType(TicketableType::Achievement) + ->promoted() ->select('ticketable_id', DB::raw('MIN(created_at) as OldestTicketDate')) ->groupBy('ticketable_id'); $newestTicketSubquery = Ticket::open() - ->officialCore() + ->forTicketableType(TicketableType::Achievement) + ->promoted() ->select('ticketable_id', DB::raw('MAX(created_at) as NewestTicketDate')) ->groupBy('ticketable_id'); return ( Ticket::open() - ->officialCore() + ->forTicketableType(TicketableType::Achievement) + ->promoted() ->join('achievements', 'achievements.id', '=', 'tickets.ticketable_id') ->join('games', 'games.id', '=', 'achievements.game_id') ->join('systems', 'systems.id', '=', 'games.system_id') diff --git a/resources/views/pages/user/[User]/tickets/index.blade.php b/resources/views/pages/user/[User]/tickets/index.blade.php index 9a145ce2db..00f777a9e3 100644 --- a/resources/views/pages/user/[User]/tickets/index.blade.php +++ b/resources/views/pages/user/[User]/tickets/index.blade.php @@ -16,7 +16,7 @@ $ticketListService->perPage = 50; $selectFilters = $ticketListService->getSelectFilters(showDevType: false); $filterOptions = $ticketListService->getFilterOptions(request()); - $tickets = $ticketListService->getTickets($filterOptions, Ticket::forDeveloper($user)); + $tickets = $ticketListService->getTickets($filterOptions, Ticket::forAssignee($user)); return $view->with([ 'user' => $user, diff --git a/tests/Feature/Platform/Controllers/GameControllerShowTest.php b/tests/Feature/Platform/Controllers/GameControllerShowTest.php index 4e94246d61..3296a1e127 100644 --- a/tests/Feature/Platform/Controllers/GameControllerShowTest.php +++ b/tests/Feature/Platform/Controllers/GameControllerShowTest.php @@ -39,6 +39,7 @@ use App\Platform\Enums\GameSetRolePermission; use App\Platform\Enums\GameSetType; use App\Platform\Enums\LeaderboardState; +use App\Platform\Enums\TicketableType; use App\Platform\Services\EventHubIdCacheService; use Database\Seeders\RolesTableSeeder; use Illuminate\Foundation\Testing\LazilyRefreshDatabase; @@ -1803,6 +1804,52 @@ function createLeaderboardWithEntries( ->where('numOpenTickets', 1) ); }); + + it('sums open ticket counts across achievement and leaderboard tickets for the game', function () { + // ARRANGE + $system = System::factory()->create(); + $developer = User::factory()->create(); + $game = Game::factory()->create(['system_id' => $system->id]); + + $achievement = Achievement::factory()->promoted()->create([ + 'game_id' => $game->id, + 'user_id' => $developer->id, + ]); + $leaderboard = Leaderboard::factory()->create([ + 'game_id' => $game->id, + 'author_id' => $developer->id, + 'state' => LeaderboardState::Active, + ]); + + (new UpsertGameCoreAchievementSetFromLegacyFlagsAction())->execute($game); + + $reporter = User::factory()->create(); + + // ... an open achievement ticket ... + Ticket::factory()->create([ + 'ticketable_id' => $achievement->id, + 'reporter_id' => $reporter->id, + 'ticketable_author_id' => $developer->id, + 'state' => TicketState::Open, + ]); + + // ... and an open leaderboard ticket on the same game ... + Ticket::factory()->create([ + 'ticketable_type' => TicketableType::Leaderboard->value, + 'ticketable_id' => $leaderboard->id, + 'ticketable_author_id' => $developer->id, + 'reporter_id' => $reporter->id, + 'state' => TicketState::Open, + ]); + + // ACT + $response = get(route('game.show', ['game' => $game])); + + // ASSERT + $response->assertInertia(fn (Assert $page) => $page + ->where('numOpenTickets', 2) + ); + }); }); describe('Completion Stats Props', function () { From 71504a4744b00edafa6b24a608e9f3f281549996 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Fri, 22 May 2026 12:46:58 -0400 Subject: [PATCH 2/3] feat(ticket): make it safe to render leaderboard tickets --- .../FetchDynamicShortcodeContentAction.php | 2 +- .../Actions/SendDailyDigestAction.php | 4 +- .../Services/SubscriptionService.php | 51 +++++--- app/Helpers/database/ticket.php | 7 +- app/Helpers/render/ticket.php | 12 +- app/Helpers/util/mail.php | 7 +- app/Models/Achievement.php | 18 ++- app/Models/Leaderboard.php | 46 ++++++- app/Models/Ticket.php | 17 ++- .../CommunityActivityNotification.php | 11 +- .../TicketStatusUpdatedNotification.php | 11 +- app/Platform/Contracts/Ticketable.php | 4 +- app/Platform/Data/TicketData.php | 11 +- app/Platform/Services/TicketListService.php | 15 +-- app/Platform/Services/TicketViewService.php | 69 ++++++---- .../ShortcodeTicket/ShortcodeTicket.test.tsx | 12 +- .../ShortcodeTicket/ShortcodeTicket.tsx | 17 ++- .../leaderboard/trigger-viewer.blade.php | 30 +++++ .../components/ticket/ticket-list.blade.php | 15 ++- .../views/pages/ticket/[Ticket].blade.php | 122 ++++++++++++------ 20 files changed, 344 insertions(+), 137 deletions(-) create mode 100644 resources/views/components/leaderboard/trigger-viewer.blade.php diff --git a/app/Community/Actions/FetchDynamicShortcodeContentAction.php b/app/Community/Actions/FetchDynamicShortcodeContentAction.php index 802c1c3081..051276cc24 100644 --- a/app/Community/Actions/FetchDynamicShortcodeContentAction.php +++ b/app/Community/Actions/FetchDynamicShortcodeContentAction.php @@ -71,7 +71,7 @@ private function fetchTickets(array $ticketIds): Collection return collect(); } - return Ticket::with('achievement') + return Ticket::with('ticketable') ->whereIn('id', $ticketIds) ->get() ->map(fn (Ticket $ticket) => TicketData::fromTicket($ticket)->include('state', 'ticketable')); diff --git a/app/Community/Actions/SendDailyDigestAction.php b/app/Community/Actions/SendDailyDigestAction.php index ae94541d9d..e8983e04f8 100644 --- a/app/Community/Actions/SendDailyDigestAction.php +++ b/app/Community/Actions/SendDailyDigestAction.php @@ -250,9 +250,9 @@ private function buildTicketTitles(array $ids): array { $result = []; - $tickets = Ticket::whereIn('id', $ids)->with('achievement')->get(); + $tickets = Ticket::whereIn('id', $ids)->with('ticketable')->get(); foreach ($tickets as $ticket) { - $result[$ticket->id] = "{$ticket->achievement->title}"; + $result[$ticket->id] = $ticket->getTicketableModel()->getTicketableTitle(); } return $result; diff --git a/app/Community/Services/SubscriptionService.php b/app/Community/Services/SubscriptionService.php index fe35252697..2c86a4c789 100644 --- a/app/Community/Services/SubscriptionService.php +++ b/app/Community/Services/SubscriptionService.php @@ -16,6 +16,7 @@ use App\Models\Subscription; use App\Models\Ticket; use App\Models\User; +use App\Platform\Enums\TicketableType; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; @@ -259,7 +260,7 @@ private function getHandler(SubscriptionSubjectType $subjectType): BaseSubscript { return match ($subjectType) { SubscriptionSubjectType::Achievement => new AchievementWallSubscriptionHandler(), - SubscriptionSubjectType::AchievementTicket => new AchievementTicketSubscriptionHandler(), + SubscriptionSubjectType::AchievementTicket => new TicketSubscriptionHandler(), SubscriptionSubjectType::ForumTopic => new ForumTopicSubscriptionHandler(), SubscriptionSubjectType::GameAchievements => new GameAchievementsSubscriptionHandler(), SubscriptionSubjectType::GameTickets => new GameTicketsSubscriptionHandler(), @@ -442,7 +443,7 @@ public function getImplicitSubscriptionQuery(?int $subjectId, ?int $forUserId, ? } } -class AchievementTicketSubscriptionHandler extends CommentSubscriptionHandler +class TicketSubscriptionHandler extends CommentSubscriptionHandler { protected function getCommentableType(): CommentableType { @@ -454,17 +455,25 @@ protected function getCommentableType(): CommentableType */ public function getSubjectQuery(array $subjectIds): Builder { - /** @var Builder $query */ - $query = Ticket::whereIn(DB::raw('tickets.id'), $subjectIds) + /** @var Builder $achievementQuery */ + $achievementQuery = Ticket::whereIn(DB::raw('tickets.id'), $subjectIds) ->join('achievements', DB::raw('tickets.ticketable_id'), '=', 'achievements.id') - ->where(DB::raw('tickets.ticketable_type'), 'achievement') + ->where(DB::raw('tickets.ticketable_type'), TicketableType::Achievement->value) ->select([ DB::raw('tickets.id as subject_id'), DB::raw('achievements.title as title'), - ]) - ->orderBy('title'); + ]); - return $query; + /** @var Builder $leaderboardQuery */ + $leaderboardQuery = Ticket::whereIn(DB::raw('tickets.id'), $subjectIds) + ->join('leaderboards', DB::raw('tickets.ticketable_id'), '=', 'leaderboards.id') + ->where(DB::raw('tickets.ticketable_type'), TicketableType::Leaderboard->value) + ->select([ + DB::raw('tickets.id as subject_id'), + DB::raw('leaderboards.title as title'), + ]); + + return $achievementQuery->union($leaderboardQuery)->orderBy('title'); } /** @@ -476,13 +485,16 @@ public function getImplicitSubscriptionQuery(?int $subjectId, ?int $forUserId, ? $query = parent::getImplicitSubscriptionQuery($subjectId, $forUserId, $ignoreSubjectIds, $ignoreUserIds); if ($subjectId !== null) { - $ticket = Ticket::with('achievement')->find($subjectId); + $ticket = Ticket::with('ticketable')->find($subjectId); if ($ticket) { - // find any users subscribed to GameTickets for the game owning the ticketed achievement + $ticketable = $ticket->getTicketableModel(); + $ticketableGame = $ticketable->getTicketableGame(); + + // find any users subscribed to GameTickets for the game owning the ticketed achievement/leaderboard /** @var Builder $query2 */ $query2 = Subscription::query() ->where('subject_type', SubscriptionSubjectType::GameTickets) - ->where('subject_id', $ticket->achievement->game_id) + ->where('subject_id', $ticketableGame->id) ->where('state', true) ->select(['user_id', 'subject_id']); @@ -514,23 +526,24 @@ public function getImplicitSubscriptionQuery(?int $subjectId, ?int $forUserId, ? $query->union($query3); } - // achievement maintainer should also be implicitly subscribed if they still have a development role - $includeMaintainer = false; - $maintainer = $ticket->achievement->getMaintainerAt(now()); - if ($maintainer && $maintainer->hasAnyRole([Role::DEVELOPER, Role::DEVELOPER_JUNIOR])) { + // ticketable assignee (achievement maintainer or leaderboard author) should + // also be implicitly subscribed if they still have a development role + $includeAssignee = false; + $assignee = $ticketable->getTicketableAssignee(now()); + if ($assignee && $assignee->hasAnyRole([Role::DEVELOPER, Role::DEVELOPER_JUNIOR])) { if ($forUserId !== null) { - $includeMaintainer = $maintainer->id === $forUserId; + $includeAssignee = $assignee->id === $forUserId; } else { - $includeMaintainer = !$ignoreUserIds || !in_array($maintainer->id, $ignoreUserIds); + $includeAssignee = !$ignoreUserIds || !in_array($assignee->id, $ignoreUserIds); } } - if ($includeMaintainer) { + if ($includeAssignee) { /** @var Builder $query4 */ $query4 = Ticket::query() ->where('id', $ticket->id) ->select([ - DB::raw($maintainer->id . ' as user_id'), + DB::raw($assignee->id . ' as user_id'), DB::raw('id as subject_id'), ]); diff --git a/app/Helpers/database/ticket.php b/app/Helpers/database/ticket.php index c4a372677f..be10d21327 100644 --- a/app/Helpers/database/ticket.php +++ b/app/Helpers/database/ticket.php @@ -135,7 +135,7 @@ function getTicket(int $ticketID): ?array function updateTicket(User $userModel, int $ticketID, TicketState $ticketVal, ?string $reason = null): bool { - $ticket = Ticket::with(['reporter', 'author', 'achievement.game.system'])->find($ticketID); + $ticket = Ticket::with(['reporter', 'author', 'ticketable.game.system'])->find($ticketID); if (!$ticket) { return false; @@ -160,9 +160,8 @@ function updateTicket(User $userModel, int $ticketID, TicketState $ticketVal, ?s switch ($ticketVal) { case TicketState::Closed: - if ($reason == TicketState::REASON_DEMOTED && $ticket->achievement?->is_promoted) { - updateAchievementPromotedStatus($ticket->achievement->id, false); - addArticleComment("Server", CommentableType::Achievement, $ticket->achievement->id, "{$userModel->display_name} demoted this achievement to Unofficial.", $userModel->display_name); + if ($reason === TicketState::REASON_DEMOTED) { + $ticket->getTicketableModel()->demoteForTicket($userModel); } $comment = "Ticket closed by {$userModel->display_name}. Reason: \"$reason\"."; break; diff --git a/app/Helpers/render/ticket.php b/app/Helpers/render/ticket.php index 1362ce781d..4a4d0c1a89 100644 --- a/app/Helpers/render/ticket.php +++ b/app/Helpers/render/ticket.php @@ -28,6 +28,8 @@ function ticketAvatar( TicketState::Closed, TicketState::Resolved, TicketState::Quarantined => 'closed', }; + $badgeUrl = $safeTicket->getTicketableModel()->getTicketableIconUrl(); + return avatar( resource: 'ticket', id: $safeTicket->id, @@ -35,7 +37,7 @@ function ticketAvatar( link: route('ticket.show', ['ticket' => $safeTicket->id]), tooltip: is_array($tooltip) ? renderAchievementCard($tooltip) : $tooltip, class: "ticket-avatar $ticketStateClass", - iconUrl: media_asset("/Badge/" . $safeTicket->achievement->image_name . ".png"), + iconUrl: $badgeUrl, iconSize: $iconSize, iconClass: $iconClass, context: $context, @@ -57,10 +59,14 @@ function renderTicketCard(int|Ticket $ticket): string TicketState::Closed, TicketState::Resolved, TicketState::Quarantined => 'closed', }; + $ticketable = $ticket->getTicketableModel(); + $game = $ticketable->getTicketableGame(); + $badgeUrl = $ticketable->getTicketableIconUrl(); + return "
" . - "achievement->image_name . '.png') . "' width='64' height='64' />" . + "" . "
" . - "
" . $ticket->achievement->title . " (" . $ticket->achievement->game->title . ")
" . + "
" . $ticketable->getTicketableTitle() . " (" . $game->title . ")
" . "
Reported by {$ticket->reporter->display_name}
" . "
Issue: " . $ticket->type->label() . "
" . ($ticket->resolver ? "
Closed by {$ticket->resolver->display_name}, " . getNiceDate(strtotime($ticket->resolved_at)) . "
" : "") . diff --git a/app/Helpers/util/mail.php b/app/Helpers/util/mail.php index 034c36372c..ae00891584 100644 --- a/app/Helpers/util/mail.php +++ b/app/Helpers/util/mail.php @@ -230,12 +230,13 @@ function informAllSubscribersAboutActivity( break; case CommentableType::AchievementTicket: // Ticket - $ticket = Ticket::with(['achievement.game', 'reporter'])->find($commentableId); - if (!$ticket) { + $ticket = Ticket::with(['ticketable.game', 'reporter'])->find($commentableId); + if (!$ticket || !$ticket->ticketable) { return; } - $articleTitle = "{$ticket->achievement->title} ({$ticket->achievement->game->title})"; + $ticketable = $ticket->getTicketableModel(); + $articleTitle = "{$ticketable->getTicketableTitle()} ({$ticketable->getTicketableGame()->title})"; $urlTarget = route('ticket.show', ['ticket' => $ticket->id]); $subjectAuthor = $ticket->reporter; $articleEmailPreference = UserPreference::EmailOn_TicketActivity; diff --git a/app/Models/Achievement.php b/app/Models/Achievement.php index b20db10ba2..c24218bbb2 100644 --- a/app/Models/Achievement.php +++ b/app/Models/Achievement.php @@ -289,11 +289,27 @@ public function getTicketableUrl(): string return $this->getCanonicalUrlAttribute(); } - public function getTicketableBadgeUrl(): ?string + public function getTicketableIconUrl(): string { return $this->getBadgeUrlAttribute(); } + public function demoteForTicket(User $byUser): void + { + if (!$this->is_promoted) { + return; + } + + updateAchievementPromotedStatus($this->id, false); + addArticleComment( + 'Server', + CommentableType::Achievement, + $this->id, + "{$byUser->display_name} demoted this achievement to Unofficial.", + $byUser->display_name, + ); + } + // == helpers public function ensureAuthorshipCredit(User $user, AchievementAuthorTask $task, ?Carbon $backdate = null): AchievementAuthor diff --git a/app/Models/Leaderboard.php b/app/Models/Leaderboard.php index d79d30f3a8..94d503590e 100644 --- a/app/Models/Leaderboard.php +++ b/app/Models/Leaderboard.php @@ -136,9 +136,28 @@ public function getTicketableUrl(): string return $this->getCanonicalUrlAttribute(); } - public function getTicketableBadgeUrl(): ?string + public function getTicketableIconUrl(): string { - return null; + // Leaderboards don't have a dedicated badge, so display the game's icon. + return media_asset($this->game->image_icon_asset_path); + } + + public function demoteForTicket(User $byUser): void + { + if ($this->state === LeaderboardState::Unpromoted) { + return; + } + + $this->state = LeaderboardState::Unpromoted; + $this->save(); + + addArticleComment( + 'Server', + CommentableType::Leaderboard, + $this->id, + "{$byUser->display_name} demoted this leaderboard to Unpromoted.", + $byUser->display_name, + ); } // == accessors @@ -148,6 +167,29 @@ public function getCanonicalUrlAttribute(): string return route('leaderboard.show', [$this, $this->getSlugAttribute()]); } + /** + * Decompose `trigger_definition` into its four sections. + * A leaderboard trigger string looks like: + * `STA:::CAN:::SUB:::VAL:`. + * This returns those sections keyed by lowercase name. + * + * @return array{start: string, cancel: string, submit: string, value: string} + */ + public function getTriggerPartsAttribute(): array + { + $parts = ['start' => '', 'cancel' => '', 'submit' => '', 'value' => '']; + $map = ['STA:' => 'start', 'CAN:' => 'cancel', 'SUB:' => 'submit', 'VAL:' => 'value']; + + foreach (explode('::', $this->trigger_definition) as $chunk) { + $prefix = substr($chunk, 0, 4); + if (isset($map[$prefix])) { + $parts[$map[$prefix]] = substr($chunk, 4); + } + } + + return $parts; + } + /** * Get the games associated with this leaderboard. * TODO replace with proper relationship through achievement_set_leaderboards diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index 74995790dd..bf72ed3bf2 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -47,6 +47,19 @@ protected static function newFactory(): TicketFactory return TicketFactory::new(); } + // == helpers + + public function getTicketableModel(): Achievement|Leaderboard + { + return match ($this->ticketable_type) { + TicketableType::Achievement->value => $this->achievement + ?? throw new LogicException("Ticket {$this->id} has no resolvable achievement."), + TicketableType::Leaderboard->value => $this->leaderboard + ?? throw new LogicException("Ticket {$this->id} has no resolvable leaderboard."), + default => throw new LogicException("Unsupported ticketable_type: {$this->ticketable_type}"), + }; + } + // == accessors public function getIsOpenAttribute(): bool @@ -68,7 +81,7 @@ public function ticketable(): MorphTo /** * Unsafe without a `ticketable_type = achievement` filter. - * Use `ticketable()` or pair with `scopeForTicketableType`. + * Use `getTicketableModel()` or pair with `scopeForTicketableType`. * * @return BelongsTo */ @@ -79,7 +92,7 @@ public function achievement(): BelongsTo /** * Unsafe without a `ticketable_type = leaderboard` filter. - * Use `ticketable()` or pair with `scopeForTicketableType`. + * Use `getTicketableModel()` or pair with `scopeForTicketableType`. * * @return BelongsTo */ diff --git a/app/Notifications/Community/CommunityActivityNotification.php b/app/Notifications/Community/CommunityActivityNotification.php index 6e62e61670..83d0413189 100644 --- a/app/Notifications/Community/CommunityActivityNotification.php +++ b/app/Notifications/Community/CommunityActivityNotification.php @@ -8,10 +8,10 @@ use App\Community\Enums\SubscriptionSubjectType; use App\Enums\UserPreference; use App\Mail\Services\UnsubscribeService; -use App\Models\Achievement; use App\Models\Game; use App\Models\Ticket; use App\Models\User; +use App\Platform\Contracts\Ticketable; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; @@ -21,7 +21,7 @@ class CommunityActivityNotification extends Notification implements ShouldQueue { use Queueable; - private ?Achievement $ticketable = null; + private ?Ticketable $ticketable = null; private ?Game $game = null; public function __construct( @@ -35,10 +35,11 @@ public function __construct( ) { // If this is a ticket comment, fetch the ticket data in constructor. if ($this->commentableType === CommentableType::AchievementTicket) { - $ticket = Ticket::with(['achievement.game.system'])->find($this->activityId); + $ticket = Ticket::with(['ticketable.game.system'])->find($this->activityId); if ($ticket) { - $this->ticketable = $ticket->achievement; - $this->game = $ticket->achievement?->game; + $ticketable = $ticket->getTicketableModel(); + $this->ticketable = $ticketable; + $this->game = $ticketable->getTicketableGame(); } } } diff --git a/app/Notifications/Ticket/TicketStatusUpdatedNotification.php b/app/Notifications/Ticket/TicketStatusUpdatedNotification.php index 227cb55d99..430caf8b72 100644 --- a/app/Notifications/Ticket/TicketStatusUpdatedNotification.php +++ b/app/Notifications/Ticket/TicketStatusUpdatedNotification.php @@ -6,8 +6,6 @@ use App\Enums\UserPreference; use App\Mail\Services\UnsubscribeService; -use App\Models\Achievement; -use App\Models\Leaderboard; use App\Models\Ticket; use App\Models\User; use Illuminate\Bus\Queueable; @@ -43,12 +41,7 @@ public function toMail(User $notifiable): MailMessage UserPreference::EmailOn_TicketActivity ); - /** - * TODO For now, we only support achievement tickets. - * When leaderboard tickets are implemented, this will need to be updated. - */ - /** @var Achievement|Leaderboard|null $ticketable */ - $ticketable = $this->ticket->achievement; + $ticketable = $this->ticket->getTicketableModel(); return (new MailMessage()) ->subject('Ticket status changed') @@ -59,7 +52,7 @@ public function toMail(User $notifiable): MailMessage 'comment' => $this->comment, 'ticketUrl' => route('ticket.show', ['ticket' => $this->ticket->id]), 'ticketable' => $ticketable, - 'game' => $ticketable?->game, + 'game' => $ticketable->getTicketableGame(), 'categoryUrl' => $categoryUrl, 'categoryText' => 'Unsubscribe from all ticket emails', ]) diff --git a/app/Platform/Contracts/Ticketable.php b/app/Platform/Contracts/Ticketable.php index b15eb96f6d..932c981d9b 100644 --- a/app/Platform/Contracts/Ticketable.php +++ b/app/Platform/Contracts/Ticketable.php @@ -21,5 +21,7 @@ public function getTicketableTitle(): string; public function getTicketableUrl(): string; - public function getTicketableBadgeUrl(): ?string; + public function getTicketableIconUrl(): string; + + public function demoteForTicket(User $byUser): void; } diff --git a/app/Platform/Data/TicketData.php b/app/Platform/Data/TicketData.php index 4b9117cd94..c4122bf83c 100644 --- a/app/Platform/Data/TicketData.php +++ b/app/Platform/Data/TicketData.php @@ -7,6 +7,7 @@ use App\Community\Enums\TicketState; use App\Models\Ticket; use App\Platform\Enums\TicketableType; +use LogicException; use Spatie\LaravelData\Data; use Spatie\LaravelData\Lazy; use Spatie\TypeScriptTransformer\Attributes\TypeScript; @@ -24,11 +25,17 @@ public function __construct( public static function fromTicket(Ticket $ticket): self { + $ticketableType = TicketableType::from($ticket->ticketable_type); + return new self( id: $ticket->id, - ticketableType: TicketableType::Achievement, + ticketableType: $ticketableType, state: Lazy::create(fn () => $ticket->state), - ticketable: Lazy::create(fn () => AchievementData::fromAchievement($ticket->achievement)) + ticketable: Lazy::create(fn () => match ($ticketableType) { + TicketableType::Achievement => AchievementData::fromAchievement($ticket->achievement), + TicketableType::Leaderboard => LeaderboardData::fromLeaderboard($ticket->leaderboard), + TicketableType::RichPresence => throw new LogicException('Rich presence tickets are not currently supported.'), + }), ); } } diff --git a/app/Platform/Services/TicketListService.php b/app/Platform/Services/TicketListService.php index 17f279b55f..d1c75b8a3a 100644 --- a/app/Platform/Services/TicketListService.php +++ b/app/Platform/Services/TicketListService.php @@ -6,7 +6,9 @@ use App\Community\Enums\TicketType; use App\Enums\Permissions; +use App\Models\Achievement; use App\Models\Emulator; +use App\Models\Leaderboard; use App\Models\Ticket; use App\Platform\Enums\TicketableType; use Illuminate\Database\Eloquent\Builder; @@ -193,7 +195,8 @@ public function buildQuery(array $filterOptions, ?Builder $tickets = null): Buil $tickets = Ticket::query(); } - $tickets->whereHas('achievement'); // don't include tickets where the achievement is hard deleted + // Don't include tickets where the ticketable is hard deleted. + $tickets->whereHasMorph('ticketable', [Achievement::class, Leaderboard::class]); $this->totalTickets = $tickets->count(); @@ -274,15 +277,11 @@ public function buildQuery(array $filterOptions, ?Builder $tickets = null): Buil break; case 'self': - $tickets->whereHas('achievement', function ($query) use ($filterOptions) { - $query->where('user_id', '=', $filterOptions['userId']); - }); + $tickets->where('ticketable_author_id', '=', $filterOptions['userId']); break; case 'others': - $tickets->whereHas('achievement', function ($query) use ($filterOptions) { - $query->where('user_id', '!=', $filterOptions['userId']); - }); + $tickets->where('ticketable_author_id', '!=', $filterOptions['userId']); break; } @@ -323,6 +322,6 @@ public function buildQuery(array $filterOptions, ?Builder $tickets = null): Buil $tickets->offset(($this->pageNumber - 1) * $this->perPage)->take($this->perPage); } - return $tickets->with(['achievement', 'author', 'reporter', 'resolver']); + return $tickets->with(['ticketable', 'author', 'reporter', 'resolver']); } } diff --git a/app/Platform/Services/TicketViewService.php b/app/Platform/Services/TicketViewService.php index a420bbc53d..69c4a659b6 100644 --- a/app/Platform/Services/TicketViewService.php +++ b/app/Platform/Services/TicketViewService.php @@ -6,9 +6,11 @@ use App\Enums\PlayerGameActivityEventType; use App\Enums\PlayerGameActivitySessionType; +use App\Models\LeaderboardEntry; use App\Models\PlayerAchievement; use App\Models\Ticket; use App\Models\User; +use App\Platform\Enums\TicketableType; class TicketViewService { @@ -17,44 +19,53 @@ class TicketViewService public array $closedTickets = []; public string $contactReporterUrl = ''; public ?PlayerAchievement $existingUnlock = null; + public ?LeaderboardEntry $reporterLeaderboardEntry = null; public string $ticketNotes = ''; public function __construct( protected PlayerGameActivityService $activity = new PlayerGameActivityService(), ) { - } public function load(Ticket $ticket): void { + $ticketable = $ticket->getTicketableModel(); + $isAchievement = $ticket->ticketable_type === TicketableType::Achievement->value; + if ($ticket->reporter) { - $msgTitle = rawurlencode("Bug Report ({$ticket->achievement->game->title})"); + $msgTitle = rawurlencode("Bug Report ({$ticketable->getTicketableGame()->title})"); $msgPayload = "Hi [user={$ticket->reporter->display_name}], I'm contacting you about [ticket={$ticket->id}]"; $msgPayload = rawurlencode($msgPayload); $this->contactReporterUrl = route('message-thread.create') . "?to={$ticket->reporter->display_name}&subject=$msgTitle&message=$msgPayload"; - $this->existingUnlock = PlayerAchievement::where('user_id', $ticket->reporter->id) - ->where('achievement_id', $ticket->achievement->id) - ->first(); + if ($isAchievement) { + $this->existingUnlock = PlayerAchievement::where('user_id', $ticket->reporter->id) + ->where('achievement_id', $ticketable->id) + ->first(); + } else { + $this->reporterLeaderboardEntry = LeaderboardEntry::where('leaderboard_id', $ticketable->id) + ->where('user_id', $ticket->reporter->id) + ->first(); + } $this->openTickets = []; $this->closedTickets = []; - $achievementTickets = Ticket::where('ticketable_id', $ticket->achievement->id) - ->where('ticketable_type', 'achievement'); - foreach ($achievementTickets->get() as $otherTicket) { - if ($otherTicket->id !== $ticket->id) { - if ($otherTicket->state->isOpen()) { - $this->openTickets[] = $otherTicket->id; - } else { - $this->closedTickets[] = $otherTicket->id; - } + $relatedTickets = Ticket::where('ticketable_id', $ticket->ticketable_id) + ->where('ticketable_type', $ticket->ticketable_type) + ->where('id', '!=', $ticket->id) + ->get(); + foreach ($relatedTickets as $otherTicket) { + if ($otherTicket->state->isOpen()) { + $this->openTickets[] = $otherTicket->id; + } else { + $this->closedTickets[] = $otherTicket->id; } } } $this->unlocksSinceReported = 0; - if ($ticket->state->isOpen()) { - $this->unlocksSinceReported = PlayerAchievement::where('achievement_id', $ticket->achievement->id) + if ($isAchievement && $ticket->state->isOpen()) { + $this->unlocksSinceReported = PlayerAchievement::where('achievement_id', $ticketable->id) ->where(function ($query) use ($ticket) { $query->where('unlocked_at', '>', $ticket->created_at) ->orWhere('unlocked_hardcore_at', '>', $ticket->created_at); @@ -62,9 +73,10 @@ public function load(Ticket $ticket): void } $this->ticketNotes = nl2br($ticket->body); - foreach ($ticket->achievement->game->hashes as $hash) { + $game = $ticketable->getTicketableGame(); + foreach ($game->hashes as $hash) { if (stripos($this->ticketNotes, $hash->md5) !== false) { - $hashesRoute = route('game.hashes.index', ['game' => $ticket->achievement->game]); + $hashesRoute = route('game.hashes.index', ['game' => $game]); $escapedHashName = attributeEscape($hash->name); $replacement = "{$hash->md5}"; @@ -75,23 +87,24 @@ public function load(Ticket $ticket): void public function buildHistory(Ticket $ticket, User $actingUser): array { - $this->clients = []; - $canViewHistory = $actingUser->canany(['manage', 'viewHistory'], Ticket::class); if (!$canViewHistory || !$ticket->reporter) { return []; } - $this->activity->initialize($ticket->reporter, $ticket->achievement->game); + $ticketable = $ticket->getTicketableModel(); + $this->activity->initialize($ticket->reporter, $ticketable->getTicketableGame()); $this->activity->addCustomEvent($ticket->created_at, PlayerGameActivitySessionType::TicketCreated, - "Ticket created - " . $ticket->type->label() . ": {$ticket->achievement->title}"); - - foreach ($this->activity->sessions as &$session) { - foreach ($session['events'] as &$event) { - if ($event['type'] === PlayerGameActivityEventType::Unlock - && $event['id'] === $ticket->achievement->id) { - $event['note'] = 'reported achievement'; + "Ticket created - " . $ticket->type->label() . ": {$ticketable->getTicketableTitle()}"); + + if ($ticket->ticketable_type === TicketableType::Achievement->value) { + foreach ($this->activity->sessions as &$session) { + foreach ($session['events'] as &$event) { + if ($event['type'] === PlayerGameActivityEventType::Unlock + && $event['id'] === $ticketable->id) { + $event['note'] = 'reported achievement'; + } } } } diff --git a/resources/js/common/components/ShortcodeRenderer/ShortcodeTicket/ShortcodeTicket.test.tsx b/resources/js/common/components/ShortcodeRenderer/ShortcodeTicket/ShortcodeTicket.test.tsx index 95585af0f9..352664f22b 100644 --- a/resources/js/common/components/ShortcodeRenderer/ShortcodeTicket/ShortcodeTicket.test.tsx +++ b/resources/js/common/components/ShortcodeRenderer/ShortcodeTicket/ShortcodeTicket.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@/test'; -import { createAchievement, createTicket } from '@/test/factories'; +import { createAchievement, createLeaderboard, createTicket } from '@/test/factories'; import { persistedTicketsAtom } from '../../../state/shortcode.atoms'; import { ShortcodeTicket } from './ShortcodeTicket'; @@ -72,13 +72,13 @@ describe('Component: ShortcodeTicket', () => { expect(screen.getByText(/ticket #123/i)).toBeVisible(); }); - it('given a non-achievement ticket is found, renders nothing', () => { + it('given a leaderboard ticket is found, renders a text embed with the state border', () => { // ARRANGE const ticket = createTicket({ id: 123, ticketableType: 'leaderboard', state: 'open', - ticketable: createAchievement({ badgeUnlockedUrl: 'test-badge.png' }), + ticketable: createLeaderboard(), }); render(, { @@ -90,7 +90,11 @@ describe('Component: ShortcodeTicket', () => { // ASSERT expect(screen.queryByTestId('achievement-ticket-embed')).not.toBeInTheDocument(); - expect(screen.queryByTestId('leaderboard-ticket-embed')).not.toBeInTheDocument(); + const linkEl = screen.getByTestId('leaderboard-ticket-embed'); + expect(linkEl).toBeVisible(); + expect(linkEl).toHaveAttribute('href', expect.stringContaining('ticket.show')); + expect(linkEl).toHaveClass('border-green-600'); + expect(screen.getByText(/ticket #123/i)).toBeVisible(); }); it('given an open ticket, applies green border styling', () => { diff --git a/resources/js/common/components/ShortcodeRenderer/ShortcodeTicket/ShortcodeTicket.tsx b/resources/js/common/components/ShortcodeRenderer/ShortcodeTicket/ShortcodeTicket.tsx index 0309f3c644..d9d1a6da28 100644 --- a/resources/js/common/components/ShortcodeRenderer/ShortcodeTicket/ShortcodeTicket.tsx +++ b/resources/js/common/components/ShortcodeRenderer/ShortcodeTicket/ShortcodeTicket.tsx @@ -51,7 +51,22 @@ export const ShortcodeTicket: FC = ({ ticketId }) => { ); } - // Other ticketable types aren't supported yet. + if (foundTicket.ticketableType === 'leaderboard') { + return ( + + {t('Ticket #{{ticketId}}', { ticketId })} + + ); + } + return null; }; diff --git a/resources/views/components/leaderboard/trigger-viewer.blade.php b/resources/views/components/leaderboard/trigger-viewer.blade.php new file mode 100644 index 0000000000..42ecb3f391 --- /dev/null +++ b/resources/views/components/leaderboard/trigger-viewer.blade.php @@ -0,0 +1,30 @@ +@props([ + 'leaderboard' => null, // Leaderboard +]) + +@php + +use App\Platform\Services\TriggerDecoderService; + +$triggerDecoderService = new TriggerDecoderService(); +$gameId = $leaderboard->game_id; +$parts = $leaderboard->trigger_parts; + +$startGroups = $triggerDecoderService->decode($parts['start']); +$triggerDecoderService->addCodeNotes($startGroups, $gameId); + +$cancelGroups = $triggerDecoderService->decode($parts['cancel']); +$triggerDecoderService->addCodeNotes($cancelGroups, $gameId); + +$submitGroups = $triggerDecoderService->decode($parts['submit']); +$triggerDecoderService->addCodeNotes($submitGroups, $gameId); + +$valueGroups = $triggerDecoderService->decodeValue($parts['value']); +$triggerDecoderService->addCodeNotes($valueGroups, $gameId); + +@endphp + + + + + diff --git a/resources/views/components/ticket/ticket-list.blade.php b/resources/views/components/ticket/ticket-list.blade.php index 69590af467..f5576147c3 100644 --- a/resources/views/components/ticket/ticket-list.blade.php +++ b/resources/views/components/ticket/ticket-list.blade.php @@ -10,6 +10,7 @@ @php use App\Models\Game; +use App\Platform\Enums\TicketableType; $gameCache = []; @@ -41,7 +42,7 @@ ID State - Achievement + Issue with Game Developer Reporter @@ -59,11 +60,17 @@ {{ $ticket->id }} {{ $ticket->state->label() }} - {!! achievementAvatar($ticket->achievement) !!} + + @if ($ticket->ticketable_type === TicketableType::Achievement->value) + {!! achievementAvatar($ticket->ticketable) !!} + @else + {{ $ticket->ticketable->getTicketableTitle() }} + @endif + @php - $game = $gameCache[$ticket->achievement->game_id] ??= - Game::where('id', $ticket->achievement->game_id)->with('system')->first(); + $game = $gameCache[$ticket->ticketable->game_id] ??= + Game::where('id', $ticket->ticketable->game_id)->with('system')->first(); @endphp achievement, 404); + abort_if(!$ticket->ticketable, 404); $userAgentService = new UserAgentService(); $ticketService = new TicketViewService(); @@ -29,6 +29,7 @@ 'userAgentService' => $userAgentService, 'contactReporterUrl' => $ticketService->contactReporterUrl, 'existingUnlock' => $ticketService->existingUnlock, + 'reporterLeaderboardEntry' => $ticketService->reporterLeaderboardEntry, 'ticketNotes' => $ticketService->ticketNotes, ]); }); @@ -45,6 +46,7 @@ 'userAgentService' => null, // ?UserAgentService 'contactReporterUrl' => '', 'existingUnlock' => null, // ?PlayerAchievement + 'reporterLeaderboardEntry' => null, // ?LeaderboardEntry 'ticketNotes' => '', ]) @@ -57,6 +59,8 @@ use App\Enums\Permissions; use App\Enums\PlayerGameActivityEventType; use App\Models\User; +use App\Platform\Enums\TicketableType; +use App\Platform\Enums\ValueFormat; use App\Platform\Services\TriggerDecoderService; use Illuminate\Support\Carbon; @@ -74,25 +78,33 @@ default => 'Open Tickets', }; +$ticketable = $ticket->getTicketableModel(); +$isAchievementTicket = $ticket->ticketable_type === TicketableType::Achievement->value; +$ticketableGame = $ticketable->getTicketableGame(); +$ticketableTitle = $ticketable->getTicketableTitle(); +$ticketableAssignee = $ticketable->getTicketableAssignee(); + @endphp
- {!! achievementAvatar($ticket->achievement, label: false, iconSize: 48, iconClass: 'rounded-sm') !!} -

{{ $ticket->achievement->title }} ({{ $ticket->type->label() }})

+ @if ($isAchievementTicket) + {!! achievementAvatar($ticketable, label: false, iconSize: 48, iconClass: 'rounded-sm') !!} + @endif +

{{ $ticketableTitle }} ({{ $ticket->type->label() }})

@@ -112,7 +124,7 @@ {{ $ticket->emulator_core }} @endif @if ($ticket->gameHash) - {{ $ticket->gameHash->md5 }} + {{ $ticket->gameHash->md5 }} @else Unknown @endif @@ -124,27 +136,50 @@ Unknown @endif {{ getNiceDate($ticket->resolved_at->unix()) }} - @else - {{ $unlocksSinceReported }} + @elseif ($isAchievementTicket) + {{ $unlocksSinceReported }} @endif
-

Achievement Information

+ @if ($isAchievementTicket) +

Achievement Information

- {!! achievementAvatar($ticket->achievement, iconSize: 16) !!} - {!! gameAvatar($ticket->achievement->game, iconSize: 16) !!} + {!! achievementAvatar($ticketable, iconSize: 16) !!} + {!! gameAvatar($ticketableGame, iconSize: 16) !!} @php $authorLabel = 'Author'; - if (!$ticket->author->is($ticket->achievement->developer)) { + if (!$ticket->author?->is($ticketable->developer)) { $authorLabel = 'Maintainer'; } @endphp {!! userAvatar($ticket->author ?? 'Deleted User', iconSize: 16) !!} - - @if ($ticket->achievement->type) - {{ __('achievement-type.' . $ticket->achievement->type) }} + + @if ($ticketable->type) + {{ __('achievement-type.' . $ticketable->type) }} + @endif + @else +

Leaderboard Information

+
+ {{ $ticketableTitle }} + {!! gameAvatar($ticketableGame, iconSize: 16) !!} + {!! userAvatar($ticketableAssignee ?? 'Deleted User', iconSize: 16) !!} + @if ($ticketable->format) + {{ ValueFormat::toString($ticketable->format) }} @endif + {{ $ticketable->rank_asc ? 'Low score wins' : 'High score wins' }} + @if ($reporterLeaderboardEntry) + @php + $reporterRank = $ticketable->getRank($reporterLeaderboardEntry->score); + $reporterScoreLabel = $ticketable->format + ? ValueFormat::format($reporterLeaderboardEntry->score, $ticketable->format) + : (string) $reporterLeaderboardEntry->score; + @endphp + #{{ $reporterRank }} — {{ $reporterScoreLabel }} ({{ getNiceDate($reporterLeaderboardEntry->updated_at->unix()) }}) + @else + No entry yet + @endif + @endif @php $label = $ticket->state->isOpen() ? 'Other open tickets' : 'Open tickets' @endphp @if (empty($openTickets)) @@ -156,7 +191,7 @@ @endforeach @endif - + @php $label = $ticket->state->isOpen() ? 'Closed tickets' : 'Other closed tickets' @endphp @if (empty($closedTickets)) None @@ -168,11 +203,10 @@ @endif
-

- @if ($ticket->reporter) + @if ($isAchievementTicket && $ticket->reporter) @canany(['manage', 'viewHistory'], Ticket::class)
@@ -201,7 +235,7 @@ unlocker_id) Manually unlocked by {!! userAvatar(User::firstWhere('id', $existingUnlock->unlocker_id), icon:false) !!} at {{ getNiceDate($unlockedAt->unix()) }} @else - {{ $ticket->reporter->display_name }} earned this achievement at + {{ $ticket->reporter->display_name }} earned this achievement at {{ getNiceDate($unlockedAt->unix()) }} @if ($unlockedAt > $ticket->created_at) (after the report) @@ -233,7 +267,7 @@ function AwardManually(hardcore) { $.post('/request/user/award-achievement.php', { user: '{{ $ticket->reporter->display_name }}', - achievement: {{ $ticket->achievement->id }}, + achievement: {{ $ticketable->id }}, hardcore: hardcore }) .done(function () { @@ -333,20 +367,24 @@ function AwardManually(hardcore) { @php $lastComment = null; foreach ($commentData as $comment) { - if ($comment['User'] != 'Server') { + if ($comment['User'] !== 'Server') { $lastComment = $comment; } } + + $assigneeName = $ticketableAssignee?->display_name ?? $ticket->author?->display_name; @endphp - @if ($lastComment != null && ($lastComment['User'] === $user->username || $lastComment['User'] === $ticket->achievement->developer->display_name) && !$ticket->reporter->trashed()) + @if ($lastComment !== null && ($lastComment['User'] === $user->username || ($assigneeName && $lastComment['User'] === $assigneeName)) && !$ticket->reporter->trashed()) @endif @elseif ($ticket->state === TicketState::Request) - + @endif @if ($ticket->state !== TicketState::Quarantined) - + @endif @@ -388,28 +426,36 @@ function AwardManually(hardcore) { @canany(['manage', 'viewLogic',], Ticket::class) + @php + $logicLabel = $isAchievementTicket ? 'Achievement Logic' : 'Leaderboard Logic'; + $logicIdLabel = $isAchievementTicket ? 'Achievement ID' : 'Leaderboard ID'; + @endphp
- +
-