From a77f74911d97fc57d57dc2292f0286892a9f77e4 Mon Sep 17 00:00:00 2001 From: ericasch6 Date: Thu, 20 Aug 2026 23:00:47 +0200 Subject: [PATCH] fix: refactored the codebase --- app/Data/Rooms/RoomPageData.php | 20 + app/Http/Controllers/DashboardController.php | 33 +- .../Controllers/FavoriteRoomController.php | 29 + app/Http/Controllers/RoomController.php | 280 +----- app/Http/Controllers/RoomEmoteController.php | 53 +- app/Http/Controllers/RoomJoinController.php | 50 + .../Controllers/RoomLifecycleController.php | 52 + app/Http/Controllers/RoomPlayerController.php | 72 +- app/Http/Controllers/RoundController.php | 179 +--- .../Settings/SecurityController.php | 40 +- app/Http/Controllers/VoteController.php | 56 +- app/Http/Middleware/EnsureRoomParticipant.php | 11 +- app/Http/Requests/ThrowPaperBallRequest.php | 24 + app/Http/Resources/ClosedRoomResource.php | 20 + app/Http/Resources/CurrentPlayerResource.php | 27 + app/Http/Resources/DashboardRoomResource.php | 26 + app/Http/Resources/DeckResource.php | 21 + app/Http/Resources/PasskeyResource.php | 23 + app/Http/Resources/RoomDeckResource.php | 20 + app/Http/Resources/RoomPlayerResource.php | 33 + app/Http/Resources/RoomResource.php | 36 + app/Jobs/MarkInactivePlayers.php | 22 + app/Models/Player.php | 14 + app/Models/Room.php | 13 + app/Models/User.php | 7 + app/Services/Rooms/DashboardRoomService.php | 37 + app/Services/Rooms/FavoriteRoomService.php | 19 + app/Services/Rooms/RoomCreationService.php | 70 ++ app/Services/Rooms/RoomEmoteService.php | 49 + app/Services/Rooms/RoomLifecycleService.php | 85 ++ app/Services/Rooms/RoomPageService.php | 46 + .../Rooms/RoomParticipationService.php | 99 ++ app/Services/Rooms/RoomStatisticsService.php | 45 + app/Services/Rooms/RoundService.php | 115 +++ app/Services/Rooms/VoteService.php | 57 ++ .../Settings/SecuritySettingsService.php | 25 + ...8_20_161538_add_inactive_to_user_table.php | 28 + .../2026_08_20_165833_user_room_favorite.php | 29 + package-lock.json | 45 + package.json | 1 + resources/js/app.ts | 1 + resources/js/components/FavoriteRooms.vue | 40 + resources/js/components/MyRooms.vue | 168 ++++ .../dashboard/DashboardRoomCard.vue | 64 ++ resources/js/components/rooms/RoomHeader.vue | 65 ++ .../rooms/RoomModeratorControls.vue | 109 ++ .../components/rooms/RoomRetentionNotice.vue | 47 + resources/js/components/rooms/RoomSeat.vue | 325 ++++++ resources/js/components/rooms/RoomTable.vue | 252 +++++ .../js/components/rooms/RoomVoteDeck.vue | 130 +++ resources/js/pages/Dashboard.vue | 230 +---- resources/js/pages/rooms/Create.vue | 16 +- resources/js/pages/rooms/Join.vue | 11 +- resources/js/pages/rooms/Show.vue | 927 +----------------- resources/js/pages/settings/Security.vue | 10 +- resources/js/types/dashboard.ts | 28 + resources/js/types/global.d.ts | 6 +- resources/js/types/index.ts | 2 + resources/js/types/room.ts | 116 ++- resources/js/types/settings.ts | 10 + routes/console.php | 5 + routes/room.php | 11 +- tests/Feature/RoomFavoritesTest.php | 77 ++ tests/Feature/RoomLifecycleTest.php | 42 + 64 files changed, 2864 insertions(+), 1739 deletions(-) create mode 100644 app/Data/Rooms/RoomPageData.php create mode 100644 app/Http/Controllers/FavoriteRoomController.php create mode 100644 app/Http/Controllers/RoomJoinController.php create mode 100644 app/Http/Controllers/RoomLifecycleController.php create mode 100644 app/Http/Requests/ThrowPaperBallRequest.php create mode 100644 app/Http/Resources/ClosedRoomResource.php create mode 100644 app/Http/Resources/CurrentPlayerResource.php create mode 100644 app/Http/Resources/DashboardRoomResource.php create mode 100644 app/Http/Resources/DeckResource.php create mode 100644 app/Http/Resources/PasskeyResource.php create mode 100644 app/Http/Resources/RoomDeckResource.php create mode 100644 app/Http/Resources/RoomPlayerResource.php create mode 100644 app/Http/Resources/RoomResource.php create mode 100644 app/Jobs/MarkInactivePlayers.php create mode 100644 app/Services/Rooms/DashboardRoomService.php create mode 100644 app/Services/Rooms/FavoriteRoomService.php create mode 100644 app/Services/Rooms/RoomCreationService.php create mode 100644 app/Services/Rooms/RoomEmoteService.php create mode 100644 app/Services/Rooms/RoomLifecycleService.php create mode 100644 app/Services/Rooms/RoomPageService.php create mode 100644 app/Services/Rooms/RoomParticipationService.php create mode 100644 app/Services/Rooms/RoomStatisticsService.php create mode 100644 app/Services/Rooms/RoundService.php create mode 100644 app/Services/Rooms/VoteService.php create mode 100644 app/Services/Settings/SecuritySettingsService.php create mode 100644 database/migrations/2026_08_20_161538_add_inactive_to_user_table.php create mode 100644 database/migrations/2026_08_20_165833_user_room_favorite.php create mode 100644 resources/js/components/FavoriteRooms.vue create mode 100644 resources/js/components/MyRooms.vue create mode 100644 resources/js/components/dashboard/DashboardRoomCard.vue create mode 100644 resources/js/components/rooms/RoomHeader.vue create mode 100644 resources/js/components/rooms/RoomModeratorControls.vue create mode 100644 resources/js/components/rooms/RoomRetentionNotice.vue create mode 100644 resources/js/components/rooms/RoomSeat.vue create mode 100644 resources/js/components/rooms/RoomTable.vue create mode 100644 resources/js/components/rooms/RoomVoteDeck.vue create mode 100644 resources/js/types/dashboard.ts create mode 100644 resources/js/types/settings.ts create mode 100644 tests/Feature/RoomFavoritesTest.php diff --git a/app/Data/Rooms/RoomPageData.php b/app/Data/Rooms/RoomPageData.php new file mode 100644 index 0000000..037b359 --- /dev/null +++ b/app/Data/Rooms/RoomPageData.php @@ -0,0 +1,20 @@ +user() - ->ownedRooms() - ->withCount('players') - ->orderByRaw('closed_at IS NOT NULL') - ->orderByDesc('last_activity_at') - ->get() - ->map(fn (Room $room) => [ - 'id' => $room->id, - 'title' => $room->title, - 'join_code' => $room->join_code, - 'current_round' => $room->current_round, - 'players_count' => $room->players_count, - 'last_activity_at' => $room->last_activity_at?->toIso8601String(), - 'closed_at' => $room->closed_at?->toIso8601String(), - ]); + $user = $request->user(); + + abort_unless($user instanceof User, 403); + + $rooms = $this->dashboardRooms->forUser($user); return Inertia::render('Dashboard', [ - 'rooms' => $rooms, + 'rooms' => DashboardRoomResource::collection($rooms['owned']) + ->resolve($request), + 'favoriteRooms' => DashboardRoomResource::collection($rooms['favorites']) + ->resolve($request), ]); } } diff --git a/app/Http/Controllers/FavoriteRoomController.php b/app/Http/Controllers/FavoriteRoomController.php new file mode 100644 index 0000000..53959cf --- /dev/null +++ b/app/Http/Controllers/FavoriteRoomController.php @@ -0,0 +1,29 @@ +trashed(), 404); + + $user = $request->user(); + + abort_unless($user instanceof User, 401); + + $this->favoriteRooms->toggle($user, $room); + + return back(); + } +} diff --git a/app/Http/Controllers/RoomController.php b/app/Http/Controllers/RoomController.php index a6a2e31..6a61835 100644 --- a/app/Http/Controllers/RoomController.php +++ b/app/Http/Controllers/RoomController.php @@ -2,86 +2,44 @@ namespace App\Http\Controllers; -use App\Enums\RoomStateChange; -use App\Events\RoomStateChanged; use App\Http\Requests\StoreRoomRequest; -use App\Models\Deck; +use App\Http\Resources\CurrentPlayerResource; +use App\Http\Resources\DeckResource; +use App\Http\Resources\RoomResource; use App\Models\Player; use App\Models\Room; +use App\Services\Rooms\RoomCreationService; +use App\Services\Rooms\RoomPageService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; use Inertia\Inertia; use Inertia\Response; class RoomController extends Controller { + public function __construct( + private readonly RoomCreationService $roomCreation, + private readonly RoomPageService $roomPage, + ) {} + public function create(Request $request): Response { - $user = $request->user(); - - $decks = Deck::query() - ->where(function ($query) use ($user) { - $query->where('is_system', true); - - if ($user !== null) { - $query->orWhere('user_id', $user->id); - } - }) - ->orderBy('name') - ->get([ - 'id', - 'name', - 'cards', - ]); - return Inertia::render('rooms/Create', [ - 'decks' => $decks, - 'defaultPlayerName' => $user?->name, + 'decks' => DeckResource::collection( + $this->roomCreation->availableDecks($request->user()), + )->resolve($request), + 'defaultPlayerName' => $request->user()?->name, ]); } - public function store(Request $request, StoreRoomRequest $roomStoreRequest): RedirectResponse + public function store(StoreRoomRequest $request): RedirectResponse { - $validated = $roomStoreRequest->validated(); - $user = $request->user(); - - $deck = Deck::query() - ->whereKey($validated['deck_id']) - ->where(function ($query) use ($user) { - $query->where('is_system', true); - - if ($user !== null) { - $query->orWhere('user_id', $user->id); - } - }) - ->firstOrFail(); - - [$room, $player] = DB::transaction(function () use ( + /** @var array{deck_id: int, player_name: string, title?: string|null} $validated */ + $validated = $request->validated(); + [$room, $player] = $this->roomCreation->create( $validated, - $deck, - $user, - ) { - $room = Room::create([ - 'owner_id' => $user?->id, - 'title' => $validated['title'] ?? null, - 'last_activity_at' => now(), - 'deck_config' => [ - 'id' => $deck->id, - 'name' => $deck->name, - 'cards' => $deck->cards, - ], - ]); - - $player = $room->players()->create([ - 'user_id' => $user?->id, - 'name' => $validated['player_name'], - 'is_moderator' => true, - 'last_seen_at' => now(), - ]); - - return [$room, $player]; - }); + $request->user(), + ); $request->session()->put( "room_players.{$room->id}", @@ -90,7 +48,7 @@ class RoomController extends Controller $redirect = to_route('room.show', $room); - if ($user === null) { + if ($request->user() === null) { $redirect->with('room_notice', [ 'guest_retention_days' => config('rooms.guest_retention_days'), 'account_retention_days' => config('rooms.account_retention_days'), @@ -106,197 +64,15 @@ class RoomController extends Controller abort_unless($currentPlayer instanceof Player, 403); - $room->load([ - 'players' => fn ($query) => $query - ->select(['id', 'room_id', 'name', 'is_moderator', 'created_at']) - ->orderByDesc('is_moderator') - ->orderBy('created_at'), - 'players.votes' => fn ($query) => $query - ->where('room_id', $room->id) - ->where('round', $room->current_round), - ]); - - $players = $room->players->map(function (Player $player) use ($room) { - $vote = $player->votes->first(); - - return [ - 'id' => $player->id, - 'name' => $player->name, - 'is_moderator' => $player->is_moderator, - 'has_voted' => $vote !== null, - 'vote' => $room->revealed_at !== null ? $vote?->value : null, - ]; - }); - - $currentVote = $room->players - ->firstWhere('id', $currentPlayer->id) - ?->votes - ->first(); - $statistics = $room->revealed_at !== null - ? $this->currentRoundStatistics($room) - : null; - - return Inertia::render('rooms/Show', [ - 'room' => [ - 'id' => $room->id, - 'title' => $room->title, - 'join_code' => $room->join_code, - 'current_round' => $room->current_round, - 'revealed' => $room->revealed_at !== null, - 'state_version' => $room->state_version, - 'statistics' => $statistics, - 'deck' => [ - 'name' => $room->deck_config['name'], - 'cards' => $room->deck_config['cards'], - ], - 'players' => $players, - ], - 'currentPlayer' => [ - 'id' => $currentPlayer->id, - 'name' => $currentPlayer->name, - 'is_moderator' => $currentPlayer->is_moderator, - 'current_vote' => $currentVote?->value, - ], - ]); - } - - public function join(Request $request): Response - { - $joinCode = strtoupper(trim((string) $request->query('join_code'))); - $room = $joinCode !== '' - ? Room::query()->where('join_code', $joinCode)->first() - : null; - $existingPlayer = $room !== null && $request->user() !== null - ? $room->players()->where('user_id', $request->user()->id)->first() - : null; - - return Inertia::render('rooms/Join', [ - 'join_code' => $joinCode, - 'defaultPlayerName' => $existingPlayer instanceof Player - ? $existingPlayer->name - : $request->user()?->name, - 'closedRoom' => $room?->isClosed() ? [ - 'title' => $room->title, - 'join_code' => $room->join_code, - ] : null, - ]); - } - - public function reopen(Room $room, Request $request): RedirectResponse - { - $user = $request->user(); - - abort_if($room->trashed(), 404); - abort_unless($user !== null && $user->id === $room->owner_id, 403); - - [$player, $stateVersion, $wasReopened] = DB::transaction(function () use ($room, $user): array { - $lockedRoom = Room::query()->lockForUpdate()->findOrFail($room->id); - - abort_unless($user->id === $lockedRoom->owner_id, 403); - - $wasReopened = $lockedRoom->isClosed(); - - if ($lockedRoom->isClosed()) { - $lockedRoom->update([ - 'current_round' => $lockedRoom->current_round + 1, - 'revealed_at' => null, - 'closed_at' => null, - 'last_activity_at' => now(), - 'state_version' => $lockedRoom->state_version + 1, - ]); - } - - return [ - $lockedRoom->players() - ->where('user_id', $user->id) - ->firstOrFail(), - $lockedRoom->state_version, - $wasReopened, - ]; - }); - - $request->session()->put("room_players.{$room->id}", $player->id); - - if ($wasReopened) { - RoomStateChanged::dispatch( - $room->id, - $stateVersion, - RoomStateChange::RoomReopened, - ); - } - - return to_route('room.show', $room); - } - - public function destroy(Room $room, Request $request): RedirectResponse - { - $user = $request->user(); - - abort_if($room->trashed(), 404); - abort_unless($user !== null && $user->id === $room->owner_id, 403); - - $stateVersion = DB::transaction(function () use ($room, $user): int { - $lockedRoom = Room::withTrashed() - ->lockForUpdate() - ->findOrFail($room->id); - - abort_if($lockedRoom->trashed(), 404); - abort_unless($user->id === $lockedRoom->owner_id, 403); - - $lockedRoom->update([ - 'state_version' => $lockedRoom->state_version + 1, - ]); - $lockedRoom->delete(); - - return $lockedRoom->state_version; - }); - - $request->session()->forget("room_players.{$room->id}"); - - RoomStateChanged::dispatch( - $room->id, - $stateVersion, - RoomStateChange::RoomDeleted, + $pageData = $this->roomPage->prepare( + $room, + $currentPlayer, + $request->user(), ); - Inertia::flash('toast', [ - 'type' => 'success', - 'message' => 'Der Raum wurde gelöscht.', + return Inertia::render('rooms/Show', [ + 'room' => (new RoomResource($pageData))->resolve($request), + 'currentPlayer' => (new CurrentPlayerResource($pageData))->resolve($request), ]); - - return to_route('dashboard'); - } - - /** - * @return array{average: string|null, median: string|null} - */ - private function currentRoundStatistics(Room $room): array - { - $values = []; - - foreach ($room->players as $player) { - $vote = $player->votes->first(); - - if ($vote !== null && is_numeric($vote->value)) { - $values[] = (float) $vote->value; - } - } - - sort($values, SORT_NUMERIC); - $count = count($values); - - if ($count === 0) { - return ['average' => null, 'median' => null]; - } - - $middle = intdiv($count, 2); - $median = $count % 2 === 1 - ? $values[$middle] - : ($values[$middle - 1] + $values[$middle]) / 2; - - return [ - 'average' => number_format(array_sum($values) / $count, 2, ',', ''), - 'median' => number_format($median, 2, ',', ''), - ]; } } diff --git a/app/Http/Controllers/RoomEmoteController.php b/app/Http/Controllers/RoomEmoteController.php index 1578838..ec53897 100644 --- a/app/Http/Controllers/RoomEmoteController.php +++ b/app/Http/Controllers/RoomEmoteController.php @@ -2,18 +2,19 @@ namespace App\Http\Controllers; -use App\Events\PaperBallThrown; +use App\Http\Requests\ThrowPaperBallRequest; use App\Models\Player; use App\Models\Room; -use Illuminate\Http\Request; +use App\Services\Rooms\RoomEmoteService; use Illuminate\Http\Response; -use Illuminate\Support\Facades\RateLimiter; -use Illuminate\Support\Str; -use Illuminate\Validation\ValidationException; class RoomEmoteController extends Controller { - public function store(Request $request, Room $room): Response + public function __construct( + private readonly RoomEmoteService $roomEmotes, + ) {} + + public function store(ThrowPaperBallRequest $request, Room $room): Response { $player = $request->attributes->get('currentPlayer'); @@ -22,42 +23,10 @@ class RoomEmoteController extends Controller 403, ); - $validated = $request->validate([ - 'target_player_id' => ['required', 'integer'], - ]); - $targetPlayerId = (int) $validated['target_player_id']; - - if ($targetPlayerId === $player->id) { - throw ValidationException::withMessages([ - 'target_player_id' => 'Du kannst dich nicht selbst abwerfen.', - ]); - } - - $targetExists = $room->players() - ->whereKey($targetPlayerId) - ->exists(); - - if (! $targetExists) { - throw ValidationException::withMessages([ - 'target_player_id' => 'Dieser Spieler sitzt nicht an diesem Tisch.', - ]); - } - - $rateLimitKey = "room-emote:{$room->id}:{$player->id}"; - - abort_if( - RateLimiter::tooManyAttempts($rateLimitKey, 10), - 429, - 'Bitte warte kurz, bevor du erneut wirfst.', - ); - - RateLimiter::hit($rateLimitKey, 10); - - PaperBallThrown::dispatch( - Str::uuid()->toString(), - $room->id, - $player->id, - $targetPlayerId, + $this->roomEmotes->throwPaperBall( + $room, + $player, + (int) $request->validated('target_player_id'), ); return response()->noContent(); diff --git a/app/Http/Controllers/RoomJoinController.php b/app/Http/Controllers/RoomJoinController.php new file mode 100644 index 0000000..b70167f --- /dev/null +++ b/app/Http/Controllers/RoomJoinController.php @@ -0,0 +1,50 @@ +query('join_code'))); + $room = $this->roomParticipation->findByJoinCode($joinCode); + $existingPlayer = $room !== null + ? $this->roomParticipation->findExistingPlayer( + $room, + $request->user(), + $request->session()->get("room_players.{$room->id}"), + ) + : null; + + if ( + $room !== null + && ! $room->isClosed() + && $existingPlayer instanceof Player + && $existingPlayer->inactive_at === null + ) { + return to_route('room.show', $room); + } + + return Inertia::render('rooms/Join', [ + 'join_code' => $joinCode, + 'defaultPlayerName' => $existingPlayer instanceof Player + ? $existingPlayer->name + : $request->user()?->name, + 'closedRoom' => $room?->isClosed() + ? (new ClosedRoomResource($room))->resolve($request) + : null, + ]); + } +} diff --git a/app/Http/Controllers/RoomLifecycleController.php b/app/Http/Controllers/RoomLifecycleController.php new file mode 100644 index 0000000..93dd047 --- /dev/null +++ b/app/Http/Controllers/RoomLifecycleController.php @@ -0,0 +1,52 @@ +user(); + + abort_if($room->trashed(), 404); + abort_unless($user instanceof User && $user->id === $room->owner_id, 403); + + $result = $this->roomLifecycle->reopen($room, $user); + + $request->session()->put( + "room_players.{$room->id}", + $result['player']->id, + ); + + return to_route('room.show', $room); + } + + public function destroy(Room $room, Request $request): RedirectResponse + { + $user = $request->user(); + + abort_if($room->trashed(), 404); + abort_unless($user instanceof User && $user->id === $room->owner_id, 403); + + $this->roomLifecycle->delete($room, $user); + $request->session()->forget("room_players.{$room->id}"); + + Inertia::flash('toast', [ + 'type' => 'success', + 'message' => 'Der Raum wurde gelöscht.', + ]); + + return to_route('dashboard'); + } +} diff --git a/app/Http/Controllers/RoomPlayerController.php b/app/Http/Controllers/RoomPlayerController.php index e664698..d810782 100644 --- a/app/Http/Controllers/RoomPlayerController.php +++ b/app/Http/Controllers/RoomPlayerController.php @@ -2,81 +2,33 @@ namespace App\Http\Controllers; -use App\Enums\RoomStateChange; -use App\Events\RoomStateChanged; use App\Http\Requests\JoinRoomRequest; -use App\Models\Player; -use App\Models\Room; +use App\Services\Rooms\RoomParticipationService; use Illuminate\Http\RedirectResponse; -use Illuminate\Support\Facades\DB; -use Illuminate\Validation\ValidationException; class RoomPlayerController extends Controller { + public function __construct( + private readonly RoomParticipationService $roomParticipation, + ) {} + public function join(JoinRoomRequest $request): RedirectResponse { + /** @var array{join_code: string, player_name: string} $validated */ $validated = $request->validated(); - $user = $request->user(); + $sessionPlayerIds = $request->session()->get('room_players', []); - [$room, $player] = DB::transaction(function () use ($request, $validated, $user): array { - $room = Room::query() - ->where('join_code', $validated['join_code']) - ->lockForUpdate() - ->firstOrFail(); - - if ($room->isClosed()) { - throw ValidationException::withMessages([ - 'join_code' => 'Dieser Raum ist geschlossen.', - ]); - } - - $player = $this->existingPlayer($request, $room); - - if ($player === null) { - $player = $room->players()->create([ - 'user_id' => $user?->id, - 'name' => $validated['player_name'], - 'is_moderator' => false, - 'last_seen_at' => now(), - ]); - } else { - $player->update(['last_seen_at' => now()]); - } - - $room->update([ - 'last_activity_at' => now(), - 'state_version' => $room->state_version + 1, - ]); - - return [$room, $player]; - }); + [$room, $player] = $this->roomParticipation->join( + $validated, + $request->user(), + is_array($sessionPlayerIds) ? $sessionPlayerIds : [], + ); $request->session()->put( "room_players.{$room->id}", $player->id, ); - RoomStateChanged::dispatch( - $room->id, - $room->state_version, - RoomStateChange::PlayerJoined, - ); - return to_route('room.show', $room); } - - private function existingPlayer(JoinRoomRequest $request, Room $room): ?Player - { - if ($request->user() !== null) { - return $room->players() - ->where('user_id', $request->user()->id) - ->first(); - } - - $playerId = $request->session()->get("room_players.{$room->id}"); - - return is_numeric($playerId) - ? $room->players()->find((int) $playerId) - : null; - } } diff --git a/app/Http/Controllers/RoundController.php b/app/Http/Controllers/RoundController.php index ca0d9b3..2939832 100644 --- a/app/Http/Controllers/RoundController.php +++ b/app/Http/Controllers/RoundController.php @@ -2,156 +2,51 @@ namespace App\Http\Controllers; -use App\Enums\RoomStateChange; -use App\Events\RoomStateChanged; use App\Models\Player; use App\Models\Room; -use App\Models\Vote; +use App\Services\Rooms\RoundService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; -use Illuminate\Validation\ValidationException; class RoundController extends Controller { - public function reset( - Request $request, - Room $room, - ): RedirectResponse { - $player = $request->attributes->get('currentPlayer'); + public function __construct( + private readonly RoundService $rounds, + ) {} - abort_unless( - $player instanceof Player - && $player->room_id === $room->id - && $player->is_moderator, - 403, - ); - - $stateVersion = DB::transaction(function () use ($room): int { - $lockedRoom = Room::query() - ->lockForUpdate() - ->findOrFail($room->id); - - $this->ensureRoomIsOpen($lockedRoom); - - Vote::query() - ->where('room_id', $lockedRoom->id) - ->where('round', $lockedRoom->current_round) - ->delete(); - - $lockedRoom->update([ - 'revealed_at' => null, - 'last_activity_at' => now(), - 'state_version' => $lockedRoom->state_version + 1, - ]); - - return $lockedRoom->state_version; - }); - - RoomStateChanged::dispatch( - $room->id, - $stateVersion, - RoomStateChange::VotesReset, - ); - - return back(); - } - - public function reveal( - Request $request, - Room $room, - ): RedirectResponse { - $player = $request->attributes->get('currentPlayer'); - - abort_unless( - $player instanceof Player - && $player->room_id === $room->id - && $player->is_moderator, - 403, - ); - - $stateVersion = DB::transaction(function () use ($room): int { - $lockedRoom = Room::query() - ->lockForUpdate() - ->findOrFail($room->id); - - $this->ensureRoomIsOpen($lockedRoom); - - if ($lockedRoom->revealed_at !== null) { - throw ValidationException::withMessages([ - 'room' => 'Die Karten sind bereits aufgedeckt.', - ]); - } - - $lockedRoom->update([ - 'revealed_at' => now(), - 'last_activity_at' => now(), - 'state_version' => $lockedRoom->state_version + 1, - ]); - - return $lockedRoom->state_version; - }); - - RoomStateChanged::dispatch( - $room->id, - $stateVersion, - RoomStateChange::CardsRevealed, - ); - - return back(); - } - - public function nextRound( - Request $request, - Room $room, - ): RedirectResponse { - $player = $request->attributes->get('currentPlayer'); - - abort_unless( - $player instanceof Player - && $player->room_id === $room->id - && $player->is_moderator, - 403, - ); - - $stateVersion = DB::transaction(function () use ($room): int { - $lockedRoom = Room::query() - ->lockForUpdate() - ->findOrFail($room->id); - - $this->ensureRoomIsOpen($lockedRoom); - - if ($lockedRoom->revealed_at === null) { - throw ValidationException::withMessages([ - 'room' => 'Die aktuelle Runde muss zuerst aufgedeckt werden.', - ]); - } - - $lockedRoom->update([ - 'current_round' => $lockedRoom->current_round + 1, - 'revealed_at' => null, - 'last_activity_at' => now(), - 'state_version' => $lockedRoom->state_version + 1, - ]); - - return $lockedRoom->state_version; - }); - - RoomStateChanged::dispatch( - $room->id, - $stateVersion, - RoomStateChange::NextRound, - ); - - return back(); - } - - private function ensureRoomIsOpen(Room $room): void + public function reset(Request $request, Room $room): RedirectResponse { - if ($room->isClosed()) { - throw ValidationException::withMessages([ - 'room' => 'Dieser Raum ist geschlossen.', - ]); - } + $this->ensureModerator($request, $room); + $this->rounds->reset($room); + + return back(); + } + + public function reveal(Request $request, Room $room): RedirectResponse + { + $this->ensureModerator($request, $room); + $this->rounds->reveal($room); + + return back(); + } + + public function nextRound(Request $request, Room $room): RedirectResponse + { + $this->ensureModerator($request, $room); + $this->rounds->startNext($room); + + return back(); + } + + private function ensureModerator(Request $request, Room $room): void + { + $player = $request->attributes->get('currentPlayer'); + + abort_unless( + $player instanceof Player + && $player->room_id === $room->id + && $player->is_moderator, + 403, + ); } } diff --git a/app/Http/Controllers/Settings/SecurityController.php b/app/Http/Controllers/Settings/SecurityController.php index cdbc640..6d78167 100644 --- a/app/Http/Controllers/Settings/SecurityController.php +++ b/app/Http/Controllers/Settings/SecurityController.php @@ -5,6 +5,9 @@ namespace App\Http\Controllers\Settings; use App\Http\Controllers\Controller; use App\Http\Requests\Settings\PasswordUpdateRequest; use App\Http\Requests\Settings\TwoFactorAuthenticationRequest; +use App\Http\Resources\PasskeyResource; +use App\Models\User; +use App\Services\Settings\SecuritySettingsService; use Illuminate\Http\RedirectResponse; use Illuminate\Validation\Rules\Password; use Inertia\Inertia; @@ -13,37 +16,34 @@ use Laravel\Fortify\Features; class SecurityController extends Controller { + public function __construct( + private readonly SecuritySettingsService $securitySettings, + ) {} + /** * Show the user's security settings page. */ public function edit(TwoFactorAuthenticationRequest $request): Response { + $user = $request->user(); + + abort_unless($user instanceof User, 403); + + $canManageTwoFactor = Features::canManageTwoFactorAuthentication(); + $canManagePasskeys = Features::canManagePasskeys(); $props = [ - 'canManageTwoFactor' => Features::canManageTwoFactorAuthentication(), - 'canManagePasskeys' => Features::canManagePasskeys(), - 'passkeys' => Features::canManagePasskeys() - ? $request->user() - ->passkeys() - ->select(['id', 'name', 'credential', 'created_at', 'last_used_at']) - ->latest() - ->get() - ->map(fn ($passkey) => [ - 'id' => $passkey->id, - 'name' => $passkey->name, - 'authenticator' => $passkey->authenticator, - 'created_at_diff' => $passkey->created_at->diffForHumans(), - 'last_used_at_diff' => $passkey->last_used_at?->diffForHumans(), - ]) - ->values() - ->all() - : [], + 'canManageTwoFactor' => $canManageTwoFactor, + 'canManagePasskeys' => $canManagePasskeys, + 'passkeys' => PasskeyResource::collection( + $this->securitySettings->passkeys($user, $canManagePasskeys), + )->resolve($request), 'passwordRules' => Password::defaults()->toPasswordRulesString(), ]; - if (Features::canManageTwoFactorAuthentication()) { + if ($canManageTwoFactor) { $request->ensureStateIsValid(); - $props['twoFactorEnabled'] = $request->user()->hasEnabledTwoFactorAuthentication(); + $props['twoFactorEnabled'] = $user->hasEnabledTwoFactorAuthentication(); $props['requiresConfirmation'] = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'); } diff --git a/app/Http/Controllers/VoteController.php b/app/Http/Controllers/VoteController.php index 3b2d70e..8e78257 100644 --- a/app/Http/Controllers/VoteController.php +++ b/app/Http/Controllers/VoteController.php @@ -2,18 +2,18 @@ namespace App\Http\Controllers; -use App\Enums\RoomStateChange; -use App\Events\RoomStateChanged; use App\Http\Requests\SubmitVoteRequest; use App\Models\Player; use App\Models\Room; -use App\Models\Vote; +use App\Services\Rooms\VoteService; use Illuminate\Http\RedirectResponse; -use Illuminate\Support\Facades\DB; -use Illuminate\Validation\ValidationException; class VoteController extends Controller { + public function __construct( + private readonly VoteService $votes, + ) {} + public function store( SubmitVoteRequest $request, Room $room, @@ -26,48 +26,10 @@ class VoteController extends Controller 403, ); - $value = $request->validated('value'); - - $stateVersion = DB::transaction(function () use ($room, $player, $value): int { - $lockedRoom = Room::query() - ->lockForUpdate() - ->findOrFail($room->id); - - if ($lockedRoom->isClosed()) { - throw ValidationException::withMessages([ - 'value' => 'Dieser Raum ist geschlossen.', - ]); - } - - if ($lockedRoom->revealed_at !== null) { - throw ValidationException::withMessages([ - 'value' => 'Die Karten wurden bereits aufgedeckt.', - ]); - } - - Vote::updateOrCreate( - [ - 'room_id' => $lockedRoom->id, - 'player_id' => $player->id, - 'round' => $lockedRoom->current_round, - ], - [ - 'value' => $value, - ], - ); - - $lockedRoom->update([ - 'last_activity_at' => now(), - 'state_version' => $lockedRoom->state_version + 1, - ]); - - return $lockedRoom->state_version; - }); - - RoomStateChanged::dispatch( - $room->id, - $stateVersion, - RoomStateChange::VoteChanged, + $this->votes->submit( + $room, + $player, + $request->validated('value'), ); return back(); diff --git a/app/Http/Middleware/EnsureRoomParticipant.php b/app/Http/Middleware/EnsureRoomParticipant.php index 93338f5..7cdbdd2 100644 --- a/app/Http/Middleware/EnsureRoomParticipant.php +++ b/app/Http/Middleware/EnsureRoomParticipant.php @@ -45,7 +45,9 @@ class EnsureRoomParticipant $playerId = $request->session()->get($sessionKey); - $player = $playerId ? $room->players()->find($playerId) : null; + $player = is_numeric($playerId) + ? $room->players()->find((int) $playerId) + : null; if ($player === null && $playerId) { $request->session()->forget($sessionKey); @@ -69,6 +71,13 @@ class EnsureRoomParticipant ]); } + if ($player->inactive_at !== null) { + return to_route('room.join', [ + 'join_code' => $room->join_code, + ]); + } + + $player->markAsSeen(); $request->attributes->set('currentPlayer', $player); return $next($request); diff --git a/app/Http/Requests/ThrowPaperBallRequest.php b/app/Http/Requests/ThrowPaperBallRequest.php new file mode 100644 index 0000000..0405772 --- /dev/null +++ b/app/Http/Requests/ThrowPaperBallRequest.php @@ -0,0 +1,24 @@ +|string> + */ + public function rules(): array + { + return [ + 'target_player_id' => ['required', 'integer'], + ]; + } +} diff --git a/app/Http/Resources/ClosedRoomResource.php b/app/Http/Resources/ClosedRoomResource.php new file mode 100644 index 0000000..9cd1476 --- /dev/null +++ b/app/Http/Resources/ClosedRoomResource.php @@ -0,0 +1,20 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'title' => $this->resource->title, + 'join_code' => $this->resource->join_code, + ]; + } +} diff --git a/app/Http/Resources/CurrentPlayerResource.php b/app/Http/Resources/CurrentPlayerResource.php new file mode 100644 index 0000000..d23a471 --- /dev/null +++ b/app/Http/Resources/CurrentPlayerResource.php @@ -0,0 +1,27 @@ + + */ + public function toArray(Request $request): array + { + /** @var RoomPageData $pageData */ + $pageData = $this->resource; + $player = $pageData->currentPlayer; + + return [ + 'id' => $player->id, + 'name' => $player->name, + 'is_moderator' => $player->is_moderator, + 'current_vote' => $pageData->currentVote, + ]; + } +} diff --git a/app/Http/Resources/DashboardRoomResource.php b/app/Http/Resources/DashboardRoomResource.php new file mode 100644 index 0000000..55b89f1 --- /dev/null +++ b/app/Http/Resources/DashboardRoomResource.php @@ -0,0 +1,26 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->resource->id, + 'title' => $this->resource->title, + 'join_code' => $this->resource->join_code, + 'current_round' => $this->resource->current_round, + 'players_count' => $this->resource->players_count, + 'active_player_count' => $this->resource->active_player_count, + 'last_activity_at' => $this->resource->last_activity_at?->toIso8601String(), + 'closed_at' => $this->resource->closed_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/DeckResource.php b/app/Http/Resources/DeckResource.php new file mode 100644 index 0000000..2b8f0c5 --- /dev/null +++ b/app/Http/Resources/DeckResource.php @@ -0,0 +1,21 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->resource->id, + 'name' => $this->resource->name, + 'cards' => $this->resource->cards, + ]; + } +} diff --git a/app/Http/Resources/PasskeyResource.php b/app/Http/Resources/PasskeyResource.php new file mode 100644 index 0000000..703e374 --- /dev/null +++ b/app/Http/Resources/PasskeyResource.php @@ -0,0 +1,23 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->resource->id, + 'name' => $this->resource->name, + 'authenticator' => $this->resource->authenticator, + 'created_at_diff' => $this->resource->created_at->diffForHumans(), + 'last_used_at_diff' => $this->resource->last_used_at?->diffForHumans(), + ]; + } +} diff --git a/app/Http/Resources/RoomDeckResource.php b/app/Http/Resources/RoomDeckResource.php new file mode 100644 index 0000000..3d3df51 --- /dev/null +++ b/app/Http/Resources/RoomDeckResource.php @@ -0,0 +1,20 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'name' => $this->resource['name'], + 'cards' => $this->resource['cards'], + ]; + } +} diff --git a/app/Http/Resources/RoomPlayerResource.php b/app/Http/Resources/RoomPlayerResource.php new file mode 100644 index 0000000..92dd593 --- /dev/null +++ b/app/Http/Resources/RoomPlayerResource.php @@ -0,0 +1,33 @@ + + */ + public function toArray(Request $request): array + { + $vote = $this->resource->votes->first(); + + return [ + 'id' => $this->resource->id, + 'name' => $this->resource->name, + 'is_moderator' => $this->resource->is_moderator, + 'is_owner' => $this->resource->user_id === $this->room->owner_id, + 'has_voted' => $vote !== null, + 'vote' => $this->room->revealed_at !== null ? $vote?->value : null, + ]; + } +} diff --git a/app/Http/Resources/RoomResource.php b/app/Http/Resources/RoomResource.php new file mode 100644 index 0000000..e2a99c7 --- /dev/null +++ b/app/Http/Resources/RoomResource.php @@ -0,0 +1,36 @@ + + */ + public function toArray(Request $request): array + { + /** @var RoomPageData $pageData */ + $pageData = $this->resource; + $room = $pageData->room; + + return [ + 'id' => $room->id, + 'title' => $room->title, + 'join_code' => $room->join_code, + 'owner_id' => $room->owner_id, + 'is_favorite' => $pageData->isFavorite, + 'current_round' => $room->current_round, + 'revealed' => $room->revealed_at !== null, + 'state_version' => $room->state_version, + 'statistics' => $pageData->statistics, + 'deck' => (new RoomDeckResource($room->deck_config))->resolve($request), + 'players' => $room->players + ->map(fn ($player) => (new RoomPlayerResource($player, $room))->resolve($request)) + ->all(), + ]; + } +} diff --git a/app/Jobs/MarkInactivePlayers.php b/app/Jobs/MarkInactivePlayers.php new file mode 100644 index 0000000..81b6881 --- /dev/null +++ b/app/Jobs/MarkInactivePlayers.php @@ -0,0 +1,22 @@ +whereNull('inactive_at') + ->where('last_seen_at', '<=', now()->subHour()) + ->update([ + 'inactive_at' => now(), + ]); + } +} diff --git a/app/Models/Player.php b/app/Models/Player.php index 4e51c91..910dd0b 100644 --- a/app/Models/Player.php +++ b/app/Models/Player.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -14,6 +15,7 @@ use Illuminate\Support\Carbon; * @property string $name * @property bool $is_moderator * @property Carbon|null $last_seen_at + * @property Carbon|null $inactive_at * @property Carbon|null $created_at * @property Carbon|null $updated_at * @property-read Room $room @@ -26,6 +28,7 @@ class Player extends Model 'name', 'is_moderator', 'last_seen_at', + 'inactive_at', ]; protected function casts(): array @@ -33,6 +36,7 @@ class Player extends Model return [ 'is_moderator' => 'boolean', 'last_seen_at' => 'datetime', + 'inactive_at' => 'datetime', ]; } @@ -79,4 +83,14 @@ class Player extends Model 'last_seen_at' => now(), ]); } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeActive(Builder $query): Builder + { + return $query->whereNull('inactive_at'); + + } } diff --git a/app/Models/Room.php b/app/Models/Room.php index 31c5ffd..be345ad 100644 --- a/app/Models/Room.php +++ b/app/Models/Room.php @@ -6,6 +6,7 @@ use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; @@ -81,6 +82,12 @@ class Room extends Model } + /** @return HasMany */ + public function activePlayers(): HasMany + { + return $this->hasMany(Player::class)->whereNull('inactive_at'); + } + /** @return BelongsTo */ public function owner(): BelongsTo { @@ -97,4 +104,10 @@ class Room extends Model { return $this->closed_at !== null; } + + /** @return BelongsToMany */ + public function favoritedBy(): BelongsToMany + { + return $this->belongsToMany(User::class, 'user_room_favorites')->withTimestamps(); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 894d585..4947eab 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,7 @@ use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -66,4 +67,10 @@ class User extends Authenticatable implements PasskeyUser { return $this->hasMany(Room::class, 'owner_id'); } + + /** @return BelongsToMany */ + public function favoriteRooms(): BelongsToMany + { + return $this->belongsToMany(Room::class, 'user_room_favorites')->withTimestamps(); + } } diff --git a/app/Services/Rooms/DashboardRoomService.php b/app/Services/Rooms/DashboardRoomService.php new file mode 100644 index 0000000..66a644d --- /dev/null +++ b/app/Services/Rooms/DashboardRoomService.php @@ -0,0 +1,37 @@ +, favorites: Collection} + */ + public function forUser(User $user): array + { + $ownedRooms = $user + ->ownedRooms() + ->withCount('players') + ->withCount(['activePlayers as active_player_count']) + ->orderByRaw('closed_at IS NOT NULL') + ->orderByDesc('last_activity_at') + ->get(); + + $favoriteRooms = $user + ->favoriteRooms() + ->withCount('players') + ->withCount(['activePlayers as active_player_count']) + ->orderByRaw('closed_at IS NOT NULL') + ->orderByDesc('last_activity_at') + ->get(); + + return [ + 'owned' => $ownedRooms, + 'favorites' => $favoriteRooms, + ]; + } +} diff --git a/app/Services/Rooms/FavoriteRoomService.php b/app/Services/Rooms/FavoriteRoomService.php new file mode 100644 index 0000000..0f4d1e6 --- /dev/null +++ b/app/Services/Rooms/FavoriteRoomService.php @@ -0,0 +1,19 @@ +owner_id === $user->id) { + throw new ConflictHttpException('Lobby is already yours', null, 409); + } + + $user->favoriteRooms()->toggle([$room->id]); + } +} diff --git a/app/Services/Rooms/RoomCreationService.php b/app/Services/Rooms/RoomCreationService.php new file mode 100644 index 0000000..5fbd1af --- /dev/null +++ b/app/Services/Rooms/RoomCreationService.php @@ -0,0 +1,70 @@ + + */ + public function availableDecks(?User $user): Collection + { + return Deck::query() + ->where(function ($query) use ($user) { + $query->where('is_system', true); + + if ($user !== null) { + $query->orWhere('user_id', $user->id); + } + }) + ->orderBy('name') + ->get(['id', 'name', 'cards']); + } + + /** + * @param array{deck_id: int, player_name: string, title?: string|null} $data + * @return array{Room, Player} + */ + public function create(array $data, ?User $user): array + { + $deck = Deck::query() + ->whereKey($data['deck_id']) + ->where(function ($query) use ($user) { + $query->where('is_system', true); + + if ($user !== null) { + $query->orWhere('user_id', $user->id); + } + }) + ->firstOrFail(); + + return DB::transaction(function () use ($data, $deck, $user): array { + $room = Room::create([ + 'owner_id' => $user?->id, + 'title' => $data['title'] ?? null, + 'last_activity_at' => now(), + 'deck_config' => [ + 'id' => $deck->id, + 'name' => $deck->name, + 'cards' => $deck->cards, + ], + ]); + + $player = $room->players()->create([ + 'user_id' => $user?->id, + 'name' => $data['player_name'], + 'is_moderator' => true, + 'last_seen_at' => now(), + ]); + + return [$room, $player]; + }); + } +} diff --git a/app/Services/Rooms/RoomEmoteService.php b/app/Services/Rooms/RoomEmoteService.php new file mode 100644 index 0000000..d23818c --- /dev/null +++ b/app/Services/Rooms/RoomEmoteService.php @@ -0,0 +1,49 @@ +id) { + throw ValidationException::withMessages([ + 'target_player_id' => 'Du kannst dich nicht selbst abwerfen.', + ]); + } + + $targetExists = $room->players() + ->whereKey($targetPlayerId) + ->exists(); + + if (! $targetExists) { + throw ValidationException::withMessages([ + 'target_player_id' => 'Dieser Spieler sitzt nicht an diesem Tisch.', + ]); + } + + $rateLimitKey = "room-emote:{$room->id}:{$player->id}"; + + abort_if( + RateLimiter::tooManyAttempts($rateLimitKey, 10), + 429, + 'Bitte warte kurz, bevor du erneut wirfst.', + ); + + RateLimiter::hit($rateLimitKey, 10); + + PaperBallThrown::dispatch( + Str::uuid()->toString(), + $room->id, + $player->id, + $targetPlayerId, + ); + } +} diff --git a/app/Services/Rooms/RoomLifecycleService.php b/app/Services/Rooms/RoomLifecycleService.php new file mode 100644 index 0000000..62dd384 --- /dev/null +++ b/app/Services/Rooms/RoomLifecycleService.php @@ -0,0 +1,85 @@ +lockForUpdate() + ->findOrFail($room->id); + + abort_unless($user->id === $lockedRoom->owner_id, 403); + + $wasReopened = $lockedRoom->isClosed(); + + if ($wasReopened) { + $lockedRoom->update([ + 'current_round' => $lockedRoom->current_round + 1, + 'revealed_at' => null, + 'closed_at' => null, + 'last_activity_at' => now(), + 'state_version' => $lockedRoom->state_version + 1, + ]); + } + + return [ + $lockedRoom->players() + ->where('user_id', $user->id) + ->firstOrFail(), + $lockedRoom->state_version, + $wasReopened, + ]; + }); + + if ($wasReopened) { + RoomStateChanged::dispatch( + $room->id, + $stateVersion, + RoomStateChange::RoomReopened, + ); + } + + return [ + 'player' => $player, + 'was_reopened' => $wasReopened, + ]; + } + + public function delete(Room $room, User $user): void + { + $stateVersion = DB::transaction(function () use ($room, $user): int { + $lockedRoom = Room::withTrashed() + ->lockForUpdate() + ->findOrFail($room->id); + + abort_if($lockedRoom->trashed(), 404); + abort_unless($user->id === $lockedRoom->owner_id, 403); + + $lockedRoom->update([ + 'state_version' => $lockedRoom->state_version + 1, + ]); + $lockedRoom->delete(); + + return $lockedRoom->state_version; + }); + + RoomStateChanged::dispatch( + $room->id, + $stateVersion, + RoomStateChange::RoomDeleted, + ); + } +} diff --git a/app/Services/Rooms/RoomPageService.php b/app/Services/Rooms/RoomPageService.php new file mode 100644 index 0000000..5ee61ee --- /dev/null +++ b/app/Services/Rooms/RoomPageService.php @@ -0,0 +1,46 @@ +favoriteRooms()->whereKey($room->id)->exists(); + + $room->load([ + 'players' => fn ($query) => $query + ->active() + ->select(['id', 'room_id', 'user_id', 'name', 'is_moderator', 'created_at']) + ->orderByDesc('is_moderator') + ->orderBy('created_at'), + 'players.votes' => fn ($query) => $query + ->where('room_id', $room->id) + ->where('round', $room->current_round), + ]); + + $currentVote = $room->players + ->firstWhere('id', $currentPlayer->id) + ?->votes + ->first() + ?->value; + + return new RoomPageData( + room: $room, + currentPlayer: $currentPlayer, + isFavorite: $isFavorite, + currentVote: $currentVote, + statistics: $this->statistics->currentRound($room), + ); + } +} diff --git a/app/Services/Rooms/RoomParticipationService.php b/app/Services/Rooms/RoomParticipationService.php new file mode 100644 index 0000000..f880db7 --- /dev/null +++ b/app/Services/Rooms/RoomParticipationService.php @@ -0,0 +1,99 @@ +where('join_code', $joinCode) + ->first(); + } + + public function findExistingPlayer( + Room $room, + ?User $user, + mixed $sessionPlayerId, + ): ?Player { + if ($user !== null) { + return $room->players() + ->where('user_id', $user->id) + ->first(); + } + + if (! is_numeric($sessionPlayerId)) { + return null; + } + + return $room->players()->find((int) $sessionPlayerId); + } + + /** + * @param array{join_code: string, player_name: string} $data + * @param array $sessionPlayerIds + * @return array{Room, Player} + */ + public function join(array $data, ?User $user, array $sessionPlayerIds): array + { + [$room, $player] = DB::transaction(function () use ($data, $user, $sessionPlayerIds): array { + $room = Room::query() + ->where('join_code', $data['join_code']) + ->lockForUpdate() + ->firstOrFail(); + + if ($room->isClosed()) { + throw ValidationException::withMessages([ + 'join_code' => 'Dieser Raum ist geschlossen.', + ]); + } + + $player = $this->findExistingPlayer( + $room, + $user, + $sessionPlayerIds[$room->id] ?? null, + ); + + if ($player === null) { + $player = $room->players()->create([ + 'user_id' => $user?->id, + 'name' => $data['player_name'], + 'is_moderator' => false, + 'last_seen_at' => now(), + ]); + } else { + $player->update([ + 'last_seen_at' => now(), + 'inactive_at' => null, + ]); + } + + $room->update([ + 'last_activity_at' => now(), + 'state_version' => $room->state_version + 1, + ]); + + return [$room, $player]; + }); + + RoomStateChanged::dispatch( + $room->id, + $room->state_version, + RoomStateChange::PlayerJoined, + ); + + return [$room, $player]; + } +} diff --git a/app/Services/Rooms/RoomStatisticsService.php b/app/Services/Rooms/RoomStatisticsService.php new file mode 100644 index 0000000..e580c14 --- /dev/null +++ b/app/Services/Rooms/RoomStatisticsService.php @@ -0,0 +1,45 @@ +revealed_at === null) { + return null; + } + + $values = []; + + foreach ($room->players as $player) { + $vote = $player->votes->first(); + + if ($vote !== null && is_numeric($vote->value)) { + $values[] = (float) $vote->value; + } + } + + sort($values, SORT_NUMERIC); + $count = count($values); + + if ($count === 0) { + return ['average' => null, 'median' => null]; + } + + $middle = intdiv($count, 2); + $median = $count % 2 === 1 + ? $values[$middle] + : ($values[$middle - 1] + $values[$middle]) / 2; + + return [ + 'average' => number_format(array_sum($values) / $count, 2, ',', ''), + 'median' => number_format($median, 2, ',', ''), + ]; + } +} diff --git a/app/Services/Rooms/RoundService.php b/app/Services/Rooms/RoundService.php new file mode 100644 index 0000000..2e3a1b2 --- /dev/null +++ b/app/Services/Rooms/RoundService.php @@ -0,0 +1,115 @@ +lockForUpdate() + ->findOrFail($room->id); + + $this->ensureRoomIsOpen($lockedRoom); + + Vote::query() + ->where('room_id', $lockedRoom->id) + ->where('round', $lockedRoom->current_round) + ->delete(); + + $lockedRoom->update([ + 'revealed_at' => null, + 'last_activity_at' => now(), + 'state_version' => $lockedRoom->state_version + 1, + ]); + + return $lockedRoom->state_version; + }); + + RoomStateChanged::dispatch( + $room->id, + $stateVersion, + RoomStateChange::VotesReset, + ); + } + + public function reveal(Room $room): void + { + $stateVersion = DB::transaction(function () use ($room): int { + $lockedRoom = Room::query() + ->lockForUpdate() + ->findOrFail($room->id); + + $this->ensureRoomIsOpen($lockedRoom); + + if ($lockedRoom->revealed_at !== null) { + throw ValidationException::withMessages([ + 'room' => 'Die Karten sind bereits aufgedeckt.', + ]); + } + + $lockedRoom->update([ + 'revealed_at' => now(), + 'last_activity_at' => now(), + 'state_version' => $lockedRoom->state_version + 1, + ]); + + return $lockedRoom->state_version; + }); + + RoomStateChanged::dispatch( + $room->id, + $stateVersion, + RoomStateChange::CardsRevealed, + ); + } + + public function startNext(Room $room): void + { + $stateVersion = DB::transaction(function () use ($room): int { + $lockedRoom = Room::query() + ->lockForUpdate() + ->findOrFail($room->id); + + $this->ensureRoomIsOpen($lockedRoom); + + if ($lockedRoom->revealed_at === null) { + throw ValidationException::withMessages([ + 'room' => 'Die aktuelle Runde muss zuerst aufgedeckt werden.', + ]); + } + + $lockedRoom->update([ + 'current_round' => $lockedRoom->current_round + 1, + 'revealed_at' => null, + 'last_activity_at' => now(), + 'state_version' => $lockedRoom->state_version + 1, + ]); + + return $lockedRoom->state_version; + }); + + RoomStateChanged::dispatch( + $room->id, + $stateVersion, + RoomStateChange::NextRound, + ); + } + + private function ensureRoomIsOpen(Room $room): void + { + if ($room->isClosed()) { + throw ValidationException::withMessages([ + 'room' => 'Dieser Raum ist geschlossen.', + ]); + } + } +} diff --git a/app/Services/Rooms/VoteService.php b/app/Services/Rooms/VoteService.php new file mode 100644 index 0000000..207e613 --- /dev/null +++ b/app/Services/Rooms/VoteService.php @@ -0,0 +1,57 @@ +lockForUpdate() + ->findOrFail($room->id); + + if ($lockedRoom->isClosed()) { + throw ValidationException::withMessages([ + 'value' => 'Dieser Raum ist geschlossen.', + ]); + } + + if ($lockedRoom->revealed_at !== null) { + throw ValidationException::withMessages([ + 'value' => 'Die Karten wurden bereits aufgedeckt.', + ]); + } + + Vote::updateOrCreate( + [ + 'room_id' => $lockedRoom->id, + 'player_id' => $player->id, + 'round' => $lockedRoom->current_round, + ], + ['value' => $value], + ); + + $lockedRoom->update([ + 'last_activity_at' => now(), + 'state_version' => $lockedRoom->state_version + 1, + ]); + + return $lockedRoom->state_version; + }); + + RoomStateChanged::dispatch( + $room->id, + $stateVersion, + RoomStateChange::VoteChanged, + ); + } +} diff --git a/app/Services/Settings/SecuritySettingsService.php b/app/Services/Settings/SecuritySettingsService.php new file mode 100644 index 0000000..c255f5a --- /dev/null +++ b/app/Services/Settings/SecuritySettingsService.php @@ -0,0 +1,25 @@ + + */ + public function passkeys(User $user, bool $enabled): Collection + { + if (! $enabled) { + return new Collection; + } + + return $user->passkeys() + ->select(['id', 'user_id', 'name', 'credential', 'created_at', 'last_used_at']) + ->latest() + ->get(); + } +} diff --git a/database/migrations/2026_08_20_161538_add_inactive_to_user_table.php b/database/migrations/2026_08_20_161538_add_inactive_to_user_table.php new file mode 100644 index 0000000..2b8e2bb --- /dev/null +++ b/database/migrations/2026_08_20_161538_add_inactive_to_user_table.php @@ -0,0 +1,28 @@ +timestamp('inactive_at')->nullable()->after('last_seen_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('players', function (Blueprint $table) { + $table->dropColumn('inactive_at'); + }); + } +}; diff --git a/database/migrations/2026_08_20_165833_user_room_favorite.php b/database/migrations/2026_08_20_165833_user_room_favorite.php new file mode 100644 index 0000000..5d0141c --- /dev/null +++ b/database/migrations/2026_08_20_165833_user_room_favorite.php @@ -0,0 +1,29 @@ +foreignUuid('room_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + $table->unique(['room_id', 'user_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_room_favorites'); + } +}; diff --git a/package-lock.json b/package-lock.json index 513df6d..21fc6c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,10 @@ "@vueuse/core": "^12.8.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "laravel-echo": "^2.4.0", "laravel-vite-plugin": "^3.0.0", + "material-icons": "^1.13.14", + "pusher-js": "^8.6.0", "reka-ui": "^2.9.8", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.1", @@ -5022,6 +5025,27 @@ "json-buffer": "3.0.1" } }, + "node_modules/laravel-echo": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.4.0.tgz", + "integrity": "sha512-8w0fAGSNt6THfbNyqdKc29bhfeNpJg13CGx2fcLgoX0/f0mTJm/AIkYTTakmcr9pc42ZB68cSoE00j4/xNaFGQ==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "pusher-js": "*", + "socket.io-client": "*" + }, + "peerDependenciesMeta": { + "pusher-js": { + "optional": true + }, + "socket.io-client": { + "optional": true + } + } + }, "node_modules/laravel-precognition": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-2.0.0.tgz", @@ -5434,6 +5458,12 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/material-icons": { + "version": "1.13.14", + "resolved": "https://registry.npmjs.org/material-icons/-/material-icons-1.13.14.tgz", + "integrity": "sha512-kZOfc7xCC0rAT8Q3DQixYAeT+tBqZnxkseQtp2bxBxz7q5pMAC+wmit7vJn1g/l7wRU+HEPq23gER4iPjGs5Cg==", + "license": "Apache-2.0" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6068,6 +6098,15 @@ "node": ">=6" } }, + "node_modules/pusher-js": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.6.0.tgz", + "integrity": "sha512-wShJPfCS/kYkCBVzVW67wa9cnQIgHTszEK2XHNrFkOgGruuGw081aERAxfRjfdFU+WcIt8x6dvbwkTW4iZuQ8Q==", + "license": "MIT", + "dependencies": { + "tweetnacl": "^1.0.3" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6954,6 +6993,12 @@ "url": "https://github.com/sponsors/Wombosvideo" } }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 1984af2..673ede7 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "clsx": "^2.1.1", "laravel-echo": "^2.4.0", "laravel-vite-plugin": "^3.0.0", + "material-icons": "^1.13.14", "pusher-js": "^8.6.0", "reka-ui": "^2.9.8", "tailwind-merge": "^3.2.0", diff --git a/resources/js/app.ts b/resources/js/app.ts index d255648..0dfec4b 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -2,6 +2,7 @@ import { createInertiaApp } from '@inertiajs/vue3'; import { initializeTheme } from '@/composables/useAppearance'; import { initializeFlashToast } from '@/lib/flashToast'; import PublicLayout from './layouts/PublicLayout.vue'; +import 'material-icons/iconfont/material-icons.css'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; diff --git a/resources/js/components/FavoriteRooms.vue b/resources/js/components/FavoriteRooms.vue new file mode 100644 index 0000000..19cd3bf --- /dev/null +++ b/resources/js/components/FavoriteRooms.vue @@ -0,0 +1,40 @@ + + + diff --git a/resources/js/components/MyRooms.vue b/resources/js/components/MyRooms.vue new file mode 100644 index 0000000..98243ce --- /dev/null +++ b/resources/js/components/MyRooms.vue @@ -0,0 +1,168 @@ + + + diff --git a/resources/js/components/dashboard/DashboardRoomCard.vue b/resources/js/components/dashboard/DashboardRoomCard.vue new file mode 100644 index 0000000..602611d --- /dev/null +++ b/resources/js/components/dashboard/DashboardRoomCard.vue @@ -0,0 +1,64 @@ + + + diff --git a/resources/js/components/rooms/RoomHeader.vue b/resources/js/components/rooms/RoomHeader.vue new file mode 100644 index 0000000..67f5d38 --- /dev/null +++ b/resources/js/components/rooms/RoomHeader.vue @@ -0,0 +1,65 @@ + + + diff --git a/resources/js/components/rooms/RoomModeratorControls.vue b/resources/js/components/rooms/RoomModeratorControls.vue new file mode 100644 index 0000000..406477f --- /dev/null +++ b/resources/js/components/rooms/RoomModeratorControls.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/resources/js/components/rooms/RoomRetentionNotice.vue b/resources/js/components/rooms/RoomRetentionNotice.vue new file mode 100644 index 0000000..84ba27d --- /dev/null +++ b/resources/js/components/rooms/RoomRetentionNotice.vue @@ -0,0 +1,47 @@ + + + diff --git a/resources/js/components/rooms/RoomSeat.vue b/resources/js/components/rooms/RoomSeat.vue new file mode 100644 index 0000000..7c6e27f --- /dev/null +++ b/resources/js/components/rooms/RoomSeat.vue @@ -0,0 +1,325 @@ + + + + + diff --git a/resources/js/components/rooms/RoomTable.vue b/resources/js/components/rooms/RoomTable.vue new file mode 100644 index 0000000..6f325a8 --- /dev/null +++ b/resources/js/components/rooms/RoomTable.vue @@ -0,0 +1,252 @@ + + + + + diff --git a/resources/js/components/rooms/RoomVoteDeck.vue b/resources/js/components/rooms/RoomVoteDeck.vue new file mode 100644 index 0000000..6070b85 --- /dev/null +++ b/resources/js/components/rooms/RoomVoteDeck.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/resources/js/pages/Dashboard.vue b/resources/js/pages/Dashboard.vue index e839281..f2a232e 100644 --- a/resources/js/pages/Dashboard.vue +++ b/resources/js/pages/Dashboard.vue @@ -1,27 +1,10 @@ diff --git a/resources/js/pages/rooms/Create.vue b/resources/js/pages/rooms/Create.vue index 753ccb6..365685b 100644 --- a/resources/js/pages/rooms/Create.vue +++ b/resources/js/pages/rooms/Create.vue @@ -1,20 +1,10 @@ - - diff --git a/resources/js/pages/settings/Security.vue b/resources/js/pages/settings/Security.vue index 784e17b..e8955cc 100644 --- a/resources/js/pages/settings/Security.vue +++ b/resources/js/pages/settings/Security.vue @@ -3,21 +3,15 @@ import { Form, Head } from '@inertiajs/vue3'; import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController'; import Heading from '@/components/Heading.vue'; import InputError from '@/components/InputError.vue'; -import type { Props as ManagePasskeysProps } from '@/components/ManagePasskeys.vue'; import ManagePasskeys from '@/components/ManagePasskeys.vue'; -import type { Props as ManageTwoFactorProps } from '@/components/ManageTwoFactor.vue'; import ManageTwoFactor from '@/components/ManageTwoFactor.vue'; import PasswordInput from '@/components/PasswordInput.vue'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { edit } from '@/routes/security'; +import type { SecurityPageProps } from '@/types/settings'; -type Props = { - passwordRules: string; -} & ManagePasskeysProps & - ManageTwoFactorProps; - -const props = defineProps(); +const props = defineProps(); defineOptions({ layout: { diff --git a/resources/js/types/dashboard.ts b/resources/js/types/dashboard.ts new file mode 100644 index 0000000..8d4d4c7 --- /dev/null +++ b/resources/js/types/dashboard.ts @@ -0,0 +1,28 @@ +export type DashboardRoom = { + id: string; + title: string | null; + join_code: string; + current_round: number; + players_count: number; + active_player_count: number; + last_activity_at: string | null; + closed_at: string | null; +}; + +export type DashboardPageProps = { + rooms: DashboardRoom[]; + favoriteRooms: DashboardRoom[]; +}; + +export type DashboardRoomCardProps = { + room: DashboardRoom; + showActions?: boolean; +}; + +export type OwnedRoomListProps = { + rooms: DashboardRoom[]; +}; + +export type FavoriteRoomListProps = { + favoriteRooms: DashboardRoom[]; +}; diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts index 3f51b2b..a51e6f7 100644 --- a/resources/js/types/global.d.ts +++ b/resources/js/types/global.d.ts @@ -1,4 +1,5 @@ import type { Auth } from '@/types/auth'; +import type { RoomNotice } from '@/types/room'; // Extend ImportMeta interface for Vite... declare module 'vite/client' { @@ -18,10 +19,7 @@ declare module '@inertiajs/core' { sharedPageProps: { name: string; auth: Auth; - roomNotice: { - guest_retention_days: number; - account_retention_days: number; - } | null; + roomNotice: RoomNotice | null; sidebarOpen: boolean; [key: string]: unknown; }; diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts index c2b7a0c..778fcaa 100644 --- a/resources/js/types/index.ts +++ b/resources/js/types/index.ts @@ -1,4 +1,6 @@ export * from './auth'; +export * from './dashboard'; export * from './navigation'; export * from './room'; +export * from './settings'; export * from './ui'; diff --git a/resources/js/types/room.ts b/resources/js/types/room.ts index a66db39..58921fe 100644 --- a/resources/js/types/room.ts +++ b/resources/js/types/room.ts @@ -5,14 +5,125 @@ export type PokerCard = { label: string; }; +export type RoomDeck = { + name: string; + cards: PokerCard[]; +}; + +export type SelectableDeck = RoomDeck & { + id: number; +}; + +export type RoomStatistics = { + average: string | null; + median: string | null; +}; + export type RoomPlayer = { id: number; name: string; is_moderator: boolean; + is_owner: boolean; has_voted: boolean; vote: string | null; }; +export type CurrentRoomPlayer = { + id: number; + name: string; + is_moderator: boolean; + current_vote: string | null; +}; + +export type PokerRoom = { + id: string; + title: string | null; + join_code: string; + owner_id: number | null; + is_favorite: boolean; + current_round: number; + revealed: boolean; + state_version: number; + statistics: RoomStatistics | null; + deck: RoomDeck; + players: RoomPlayer[]; +}; + +export type ClosedRoom = { + title: string | null; + join_code: string; +}; + +export type RoomNotice = { + guest_retention_days: number; + account_retention_days: number; +}; + +export type RoomPageProps = { + room: PokerRoom; + currentPlayer: CurrentRoomPlayer; +}; + +export type CreateRoomPageProps = { + decks: SelectableDeck[]; + defaultPlayerName: string | null; +}; + +export type JoinRoomPageProps = { + join_code?: string; + defaultPlayerName: string | null; + closedRoom: ClosedRoom | null; +}; + +export type SeatStyle = CSSProperties & { + '--card-x': string; + '--card-y': string; +}; + +export type PositionedRoomPlayer = RoomPlayer & { + isTopSeat: boolean; + seatStyle: SeatStyle; +}; + +export type RoomRetentionNoticeProps = { + notice: RoomNotice; +}; + +export type RoomHeaderProps = Pick< + PokerRoom, + 'id' | 'title' | 'join_code' | 'is_favorite' +> & { + canFavorite: boolean; +}; + +export type RoomTableProps = { + room: PokerRoom; + currentPlayer: CurrentRoomPlayer; +}; + +export type RoomSeatProps = { + player: PositionedRoomPlayer; + currentPlayerId: number; + currentVote: string | null; + cards: PokerCard[]; + revealed: boolean; + isPaperBallHit: boolean; + throwDisabled: boolean; +}; + +export type RoomModeratorControlsProps = { + roomId: string; + revealed: boolean; + hasVotes: boolean; +}; + +export type RoomVoteDeckProps = { + roomId: string; + revealed: boolean; + cards: PokerCard[]; + currentVote: string | null; +}; + export type RoomStateChange = | 'player_joined' | 'vote_changed' @@ -27,8 +138,3 @@ export type RoomStateChangedPayload = { version: number; change: RoomStateChange; }; - -export type SeatStyle = CSSProperties & { - '--card-x': string; - '--card-y': string; -}; diff --git a/resources/js/types/settings.ts b/resources/js/types/settings.ts new file mode 100644 index 0000000..61f11c6 --- /dev/null +++ b/resources/js/types/settings.ts @@ -0,0 +1,10 @@ +import type { Passkey } from '@/types/auth'; + +export type SecurityPageProps = { + canManageTwoFactor?: boolean; + requiresConfirmation?: boolean; + twoFactorEnabled?: boolean; + canManagePasskeys?: boolean; + passkeys?: Passkey[]; + passwordRules: string; +}; diff --git a/routes/console.php b/routes/console.php index 20a9cc1..f45062d 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,6 +1,7 @@ weeklyOn(1, '02:00') ->withoutOverlapping(); + +Schedule::job(new MarkInactivePlayers) + ->everyFiveMinutes() + ->withoutOverlapping(); diff --git a/routes/room.php b/routes/room.php index 8ecafe6..48ef144 100644 --- a/routes/room.php +++ b/routes/room.php @@ -1,7 +1,10 @@ name('room.create'); -Route::get('/room/join', [RoomController::class, 'join'])->name('room.join'); +Route::get('/room/join', RoomJoinController::class)->name('room.join'); Route::post('room/join', [RoomPlayerController::class, 'join'])->name('room.code'); Route::post('/room', [RoomController::class, 'store'])->name('room.store'); Route::middleware(['auth', 'verified'])->group(function () { - Route::post('/rooms/{room}/reopen', [RoomController::class, 'reopen']) + Route::post('/rooms/{room}/reopen', [RoomLifecycleController::class, 'reopen']) ->name('room.reopen'); - Route::delete('/rooms/{room}', [RoomController::class, 'destroy']) + Route::delete('/rooms/{room}', [RoomLifecycleController::class, 'destroy']) ->name('room.destroy'); + Route::post('/rooms/{room}/favorite', FavoriteRoomController::class) + ->name('room.favorite'); }); Route::middleware('room.player')->group(function () { diff --git a/tests/Feature/RoomFavoritesTest.php b/tests/Feature/RoomFavoritesTest.php new file mode 100644 index 0000000..6ade311 --- /dev/null +++ b/tests/Feature/RoomFavoritesTest.php @@ -0,0 +1,77 @@ +create(); + $user = User::factory()->create(); + $room = Room::create([ + 'owner_id' => $owner->id, + 'title' => 'Favorite planning', + 'deck_config' => [ + 'name' => 'Fibonacci', + 'cards' => [['key' => '3', 'label' => '3']], + ], + ]); + + $room->players()->create([ + 'name' => 'Active player', + 'last_seen_at' => now(), + ]); + $room->players()->create([ + 'name' => 'Inactive player', + 'last_seen_at' => now()->subHour(), + 'inactive_at' => now(), + ]); + + $this->actingAs($user) + ->post(route('room.favorite', $room)) + ->assertRedirect(); + + $this->assertDatabaseHas('user_room_favorites', [ + 'user_id' => $user->id, + 'room_id' => $room->id, + ]); + + $this->get(route('dashboard')) + ->assertInertia(fn (Assert $page) => $page + ->has('favoriteRooms', 1) + ->where('favoriteRooms.0.id', $room->id) + ->where('favoriteRooms.0.players_count', 2) + ->where('favoriteRooms.0.active_player_count', 1)); +}); + +test('the room resource exposes favorite and owner state', function () { + $owner = User::factory()->create(); + $user = User::factory()->create(); + $room = Room::create([ + 'owner_id' => $owner->id, + 'title' => 'Resource planning', + 'deck_config' => [ + 'name' => 'Fibonacci', + 'cards' => [['key' => '3', 'label' => '3']], + ], + ]); + $ownerPlayer = $room->players()->create([ + 'user_id' => $owner->id, + 'name' => 'Owner', + 'is_moderator' => true, + ]); + $currentPlayer = $room->players()->create([ + 'user_id' => $user->id, + 'name' => 'Participant', + ]); + $user->favoriteRooms()->attach($room); + + $this->actingAs($user) + ->withSession(["room_players.{$room->id}" => $currentPlayer->id]) + ->get(route('room.show', $room)) + ->assertInertia(fn (Assert $page) => $page + ->where('room.is_favorite', true) + ->where('room.owner_id', $owner->id) + ->where('room.players.0.id', $ownerPlayer->id) + ->where('room.players.0.is_owner', true) + ->where('currentPlayer.id', $currentPlayer->id)); +}); diff --git a/tests/Feature/RoomLifecycleTest.php b/tests/Feature/RoomLifecycleTest.php index 64bf4bd..b42187c 100644 --- a/tests/Feature/RoomLifecycleTest.php +++ b/tests/Feature/RoomLifecycleTest.php @@ -171,6 +171,48 @@ test('an authenticated player can restore room access without the previous sessi ->assertSessionHas("room_players.{$room->id}", $moderator->id); }); +test('an active guest with a room session skips the join form', function () { + $room = createLifecycleRoom(); + $player = $room->players()->create([ + 'name' => 'Guest', + 'last_seen_at' => now(), + ]); + + $this->withSession(["room_players.{$room->id}" => $player->id]) + ->get(route('room.join', ['join_code' => $room->join_code])) + ->assertRedirect(route('room.show', $room)); +}); + +test('an inactive guest is sent through join and reuses the existing player', function () { + $room = createLifecycleRoom(); + $player = $room->players()->create([ + 'name' => 'Returning guest', + 'last_seen_at' => now()->subHours(2), + 'inactive_at' => now()->subHour(), + ]); + $session = ["room_players.{$room->id}" => $player->id]; + + $this->withSession($session) + ->get(route('room.show', $room)) + ->assertRedirect(route('room.join', ['join_code' => $room->join_code])); + + $this->withSession($session) + ->get(route('room.join', ['join_code' => $room->join_code])) + ->assertInertia(fn (Assert $page) => $page + ->component('rooms/Join') + ->where('defaultPlayerName', 'Returning guest')); + + $this->withSession($session) + ->post(route('room.code'), [ + 'join_code' => $room->join_code, + 'player_name' => 'Returning guest', + ]) + ->assertRedirect(route('room.show', $room)); + + expect($room->players()->count())->toBe(1) + ->and($player->fresh()->inactive_at)->toBeNull(); +}); + test('showing or polling a room does not count as room activity', function () { $room = createLifecycleRoom(['last_activity_at' => now()->subDays(5)]); $moderator = $room->players()->create([