diff --git a/app/Enums/RoomStateChange.php b/app/Enums/RoomStateChange.php index 78b6001..f5bc9a4 100644 --- a/app/Enums/RoomStateChange.php +++ b/app/Enums/RoomStateChange.php @@ -6,6 +6,7 @@ enum RoomStateChange: string { case PlayerJoined = 'player_joined'; case PlayerUpdated = 'player_updated'; + case PlayerKicked = 'player_kicked'; case VoteChanged = 'vote_changed'; case CardsRevealed = 'cards_revealed'; case VotesReset = 'votes_reset'; diff --git a/app/Events/RoomStateChanged.php b/app/Events/RoomStateChanged.php index 2c9a8da..11a851c 100644 --- a/app/Events/RoomStateChanged.php +++ b/app/Events/RoomStateChanged.php @@ -21,6 +21,7 @@ class RoomStateChanged implements ShouldBroadcastNow public readonly int $version, public readonly RoomStateChange $change, public readonly ?array $state = null, + public readonly ?int $affectedPlayerId = null, ) {} public function broadcastOn(): Channel @@ -34,14 +35,20 @@ class RoomStateChanged implements ShouldBroadcastNow } /** - * @return array{version: int, change: string, state: array|null} + * @return array{version: int, change: string, state: array|null, affected_player_id?: int} */ public function broadcastWith(): array { - return [ + $payload = [ 'version' => $this->version, 'change' => $this->change->value, 'state' => $this->state, ]; + + if ($this->affectedPlayerId !== null) { + $payload['affected_player_id'] = $this->affectedPlayerId; + } + + return $payload; } } diff --git a/app/Http/Controllers/RoomPlayerKickController.php b/app/Http/Controllers/RoomPlayerKickController.php new file mode 100644 index 0000000..ea790a7 --- /dev/null +++ b/app/Http/Controllers/RoomPlayerKickController.php @@ -0,0 +1,34 @@ +user(); + + abort_unless($user instanceof User, 403); + + $this->roomParticipation->kick($room, $player, $user); + + Inertia::flash('toast', [ + 'type' => 'success', + 'message' => "{$player->name} wurde aus dem Raum entfernt.", + ]); + + return back(); + } +} diff --git a/app/Http/Requests/KickRoomPlayerRequest.php b/app/Http/Requests/KickRoomPlayerRequest.php new file mode 100644 index 0000000..3c68847 --- /dev/null +++ b/app/Http/Requests/KickRoomPlayerRequest.php @@ -0,0 +1,35 @@ +route('room'); + $player = $this->route('player'); + + return $room instanceof Room + && $player instanceof Player + && $this->user()?->id === $room->owner_id + && $player->room_id === $room->id + && $player->user_id !== $room->owner_id; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array> + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Resources/DashboardRoomResource.php b/app/Http/Resources/DashboardRoomResource.php index 55b89f1..373d405 100644 --- a/app/Http/Resources/DashboardRoomResource.php +++ b/app/Http/Resources/DashboardRoomResource.php @@ -19,6 +19,17 @@ class DashboardRoomResource extends JsonResource 'current_round' => $this->resource->current_round, 'players_count' => $this->resource->players_count, 'active_player_count' => $this->resource->active_player_count, + 'players' => $this->resource->relationLoaded('activePlayers') + ? $this->resource->activePlayers + ->map(fn ($player): array => [ + 'id' => $player->id, + 'name' => $player->name, + 'is_moderator' => $player->is_moderator, + 'is_owner' => $player->user_id === $this->resource->owner_id, + ]) + ->values() + ->all() + : [], 'last_activity_at' => $this->resource->last_activity_at?->toIso8601String(), 'closed_at' => $this->resource->closed_at?->toIso8601String(), ]; diff --git a/app/Services/Rooms/DashboardRoomService.php b/app/Services/Rooms/DashboardRoomService.php index 66a644d..d5a3319 100644 --- a/app/Services/Rooms/DashboardRoomService.php +++ b/app/Services/Rooms/DashboardRoomService.php @@ -15,6 +15,12 @@ class DashboardRoomService { $ownedRooms = $user ->ownedRooms() + ->with([ + 'activePlayers' => fn ($query) => $query + ->select(['id', 'room_id', 'user_id', 'name', 'is_moderator', 'created_at']) + ->orderByDesc('is_moderator') + ->orderBy('created_at'), + ]) ->withCount('players') ->withCount(['activePlayers as active_player_count']) ->orderByRaw('closed_at IS NOT NULL') diff --git a/app/Services/Rooms/RoomParticipationService.php b/app/Services/Rooms/RoomParticipationService.php index 81958ca..91a4e4a 100644 --- a/app/Services/Rooms/RoomParticipationService.php +++ b/app/Services/Rooms/RoomParticipationService.php @@ -179,4 +179,47 @@ class RoomParticipationService return $player; } + + public function kick(Room $room, Player $player, User $user): void + { + $stateVersion = DB::transaction(function () use ($room, $player, $user): ?int { + $lockedRoom = Room::query() + ->lockForUpdate() + ->findOrFail($room->id); + + abort_unless($lockedRoom->owner_id === $user->id, 403); + + $lockedPlayer = $lockedRoom->players() + ->lockForUpdate() + ->findOrFail($player->id); + + abort_if($lockedPlayer->user_id === $lockedRoom->owner_id, 403); + + if ($lockedPlayer->inactive_at !== null) { + return null; + } + + $lockedPlayer->update([ + 'inactive_at' => now(), + ]); + $lockedRoom->update([ + 'last_activity_at' => now(), + 'state_version' => $lockedRoom->state_version + 1, + ]); + + return $lockedRoom->state_version; + }); + + if ($stateVersion === null) { + return; + } + + RoomStateChanged::dispatch( + $room->id, + $stateVersion, + RoomStateChange::PlayerKicked, + $this->realtimeState->for($room), + $player->id, + ); + } } diff --git a/resources/js/components/MyRooms.vue b/resources/js/components/MyRooms.vue index 74c5786..d62c977 100644 --- a/resources/js/components/MyRooms.vue +++ b/resources/js/components/MyRooms.vue @@ -1,6 +1,12 @@ @@ -173,5 +264,42 @@ function deleteRoom(): void { + + + + + Teilnehmer wirklich kicken? + + {{ selectedPlayer?.player.name }} wird aus „{{ + selectedPlayer?.room.title ?? 'Scrum Poker' + }}“ entfernt und zur Startseite weitergeleitet. + + + + + + + + + diff --git a/resources/js/components/rooms/RoomSeat.vue b/resources/js/components/rooms/RoomSeat.vue index 2c4a227..0e4d871 100644 --- a/resources/js/components/rooms/RoomSeat.vue +++ b/resources/js/components/rooms/RoomSeat.vue @@ -1,5 +1,5 @@