mirror of
https://github.com/ericasch6/scrum-poker-internal.git
synced 2026-10-13 15:32:09 +00:00
Add room participant moderation
This commit is contained in:
@@ -6,6 +6,7 @@ enum RoomStateChange: string
|
|||||||
{
|
{
|
||||||
case PlayerJoined = 'player_joined';
|
case PlayerJoined = 'player_joined';
|
||||||
case PlayerUpdated = 'player_updated';
|
case PlayerUpdated = 'player_updated';
|
||||||
|
case PlayerKicked = 'player_kicked';
|
||||||
case VoteChanged = 'vote_changed';
|
case VoteChanged = 'vote_changed';
|
||||||
case CardsRevealed = 'cards_revealed';
|
case CardsRevealed = 'cards_revealed';
|
||||||
case VotesReset = 'votes_reset';
|
case VotesReset = 'votes_reset';
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class RoomStateChanged implements ShouldBroadcastNow
|
|||||||
public readonly int $version,
|
public readonly int $version,
|
||||||
public readonly RoomStateChange $change,
|
public readonly RoomStateChange $change,
|
||||||
public readonly ?array $state = null,
|
public readonly ?array $state = null,
|
||||||
|
public readonly ?int $affectedPlayerId = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function broadcastOn(): Channel
|
public function broadcastOn(): Channel
|
||||||
@@ -34,14 +35,20 @@ class RoomStateChanged implements ShouldBroadcastNow
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{version: int, change: string, state: array<string, mixed>|null}
|
* @return array{version: int, change: string, state: array<string, mixed>|null, affected_player_id?: int}
|
||||||
*/
|
*/
|
||||||
public function broadcastWith(): array
|
public function broadcastWith(): array
|
||||||
{
|
{
|
||||||
return [
|
$payload = [
|
||||||
'version' => $this->version,
|
'version' => $this->version,
|
||||||
'change' => $this->change->value,
|
'change' => $this->change->value,
|
||||||
'state' => $this->state,
|
'state' => $this->state,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if ($this->affectedPlayerId !== null) {
|
||||||
|
$payload['affected_player_id'] = $this->affectedPlayerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\KickRoomPlayerRequest;
|
||||||
|
use App\Models\Player;
|
||||||
|
use App\Models\Room;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Rooms\RoomParticipationService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class RoomPlayerKickController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly RoomParticipationService $roomParticipation,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(KickRoomPlayerRequest $request, Room $room, Player $player): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Player;
|
||||||
|
use App\Models\Room;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class KickRoomPlayerRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
$room = $this->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<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,17 @@ class DashboardRoomResource extends JsonResource
|
|||||||
'current_round' => $this->resource->current_round,
|
'current_round' => $this->resource->current_round,
|
||||||
'players_count' => $this->resource->players_count,
|
'players_count' => $this->resource->players_count,
|
||||||
'active_player_count' => $this->resource->active_player_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(),
|
'last_activity_at' => $this->resource->last_activity_at?->toIso8601String(),
|
||||||
'closed_at' => $this->resource->closed_at?->toIso8601String(),
|
'closed_at' => $this->resource->closed_at?->toIso8601String(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ class DashboardRoomService
|
|||||||
{
|
{
|
||||||
$ownedRooms = $user
|
$ownedRooms = $user
|
||||||
->ownedRooms()
|
->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('players')
|
||||||
->withCount(['activePlayers as active_player_count'])
|
->withCount(['activePlayers as active_player_count'])
|
||||||
->orderByRaw('closed_at IS NOT NULL')
|
->orderByRaw('closed_at IS NOT NULL')
|
||||||
|
|||||||
@@ -179,4 +179,47 @@ class RoomParticipationService
|
|||||||
|
|
||||||
return $player;
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, useForm } from '@inertiajs/vue3';
|
import { Link, useForm } from '@inertiajs/vue3';
|
||||||
import { DoorOpen, Plus, Settings as SettingsIcon, Trash2 } from '@lucide/vue';
|
import {
|
||||||
|
DoorOpen,
|
||||||
|
Plus,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Trash2,
|
||||||
|
UserMinus,
|
||||||
|
} from '@lucide/vue';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
import DashboardRoomCard from '@/components/dashboard/DashboardRoomCard.vue';
|
import DashboardRoomCard from '@/components/dashboard/DashboardRoomCard.vue';
|
||||||
@@ -13,13 +19,23 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { create, destroy, reopen, show } from '@/routes/room';
|
import { create, destroy, reopen, show } from '@/routes/room';
|
||||||
|
import { destroy as kickRoomPlayer } from '@/routes/room/player';
|
||||||
import { edit as roomSettings } from '@/routes/room/settings';
|
import { edit as roomSettings } from '@/routes/room/settings';
|
||||||
import type { DashboardRoom, OwnedRoomListProps } from '@/types/dashboard';
|
import type {
|
||||||
|
DashboardRoom,
|
||||||
|
DashboardRoomPlayer,
|
||||||
|
OwnedRoomListProps,
|
||||||
|
} from '@/types/dashboard';
|
||||||
|
|
||||||
defineProps<OwnedRoomListProps>();
|
defineProps<OwnedRoomListProps>();
|
||||||
|
|
||||||
const selectedRoom = ref<DashboardRoom | null>(null);
|
const selectedRoom = ref<DashboardRoom | null>(null);
|
||||||
|
const selectedPlayer = ref<{
|
||||||
|
room: DashboardRoom;
|
||||||
|
player: DashboardRoomPlayer;
|
||||||
|
} | null>(null);
|
||||||
const deleteForm = useForm({});
|
const deleteForm = useForm({});
|
||||||
|
const kickForm = useForm({});
|
||||||
|
|
||||||
function setDeleteDialogOpen(open: boolean): void {
|
function setDeleteDialogOpen(open: boolean): void {
|
||||||
if (!open && !deleteForm.processing) {
|
if (!open && !deleteForm.processing) {
|
||||||
@@ -27,6 +43,12 @@ function setDeleteDialogOpen(open: boolean): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setKickDialogOpen(open: boolean): void {
|
||||||
|
if (!open && !kickForm.processing) {
|
||||||
|
selectedPlayer.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function deleteRoom(): void {
|
function deleteRoom(): void {
|
||||||
if (selectedRoom.value === null || deleteForm.processing) {
|
if (selectedRoom.value === null || deleteForm.processing) {
|
||||||
return;
|
return;
|
||||||
@@ -39,6 +61,25 @@ function deleteRoom(): void {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function kickPlayer(): void {
|
||||||
|
if (selectedPlayer.value === null || kickForm.processing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
kickForm.delete(
|
||||||
|
kickRoomPlayer({
|
||||||
|
room: selectedPlayer.value.room.id,
|
||||||
|
player: selectedPlayer.value.player.id,
|
||||||
|
}).url,
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
selectedPlayer.value = null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -108,6 +149,56 @@ function deleteRoom(): void {
|
|||||||
<Trash2 class="size-4" />
|
<Trash2 class="size-4" />
|
||||||
Raum löschen
|
Raum löschen
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-2 w-full rounded-md border border-slate-700 bg-slate-900/40 p-3"
|
||||||
|
>
|
||||||
|
<h3 class="text-sm font-semibold">Teilnehmer</h3>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
v-if="room.players.length > 0"
|
||||||
|
class="mt-2 flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="player in room.players"
|
||||||
|
:key="player.id"
|
||||||
|
class="flex items-center justify-between gap-3 rounded-md bg-slate-800 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span class="min-w-0">
|
||||||
|
<span
|
||||||
|
class="block truncate text-sm"
|
||||||
|
:class="
|
||||||
|
player.is_moderator
|
||||||
|
? 'text-amber-300'
|
||||||
|
: 'text-slate-100'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ player.name }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="player.is_owner"
|
||||||
|
class="shrink-0 text-xs text-slate-400"
|
||||||
|
>
|
||||||
|
Du
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-semibold text-rose-300 transition hover:bg-rose-500/20 hover:text-rose-200"
|
||||||
|
@click="selectedPlayer = { room, player }"
|
||||||
|
>
|
||||||
|
<UserMinus class="size-3.5" />
|
||||||
|
Kicken
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p v-else class="mt-2 text-sm text-slate-400">
|
||||||
|
Aktuell sind keine Teilnehmer im Raum.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</DashboardRoomCard>
|
</DashboardRoomCard>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,5 +264,42 @@ function deleteRoom(): void {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
:open="selectedPlayer !== null"
|
||||||
|
@update:open="setKickDialogOpen"
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
class="border-slate-700 bg-slate-800 text-slate-100 sm:max-w-md"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Teilnehmer wirklich kicken?</DialogTitle>
|
||||||
|
<DialogDescription class="text-slate-300">
|
||||||
|
{{ selectedPlayer?.player.name }} wird aus „{{
|
||||||
|
selectedPlayer?.room.title ?? 'Scrum Poker'
|
||||||
|
}}“ entfernt und zur Startseite weitergeleitet.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter class="gap-6 space-x-4 sm:gap-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-slate-600 px-4 py-2 text-sm font-semibold text-slate-200 transition hover:bg-slate-700"
|
||||||
|
:disabled="kickForm.processing"
|
||||||
|
@click="selectedPlayer = null"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-rose-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-rose-500 disabled:opacity-50"
|
||||||
|
:disabled="kickForm.processing"
|
||||||
|
@click="kickPlayer"
|
||||||
|
>
|
||||||
|
{{ kickForm.processing ? 'Wird entfernt …' : 'Kicken' }}
|
||||||
|
</button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CircleUserRound, Crown } from '@lucide/vue';
|
import { CircleUserRound } from '@lucide/vue';
|
||||||
|
|
||||||
import type { RoomSeatProps } from '@/types/room';
|
import type { RoomSeatProps } from '@/types/room';
|
||||||
|
|
||||||
@@ -75,22 +75,18 @@ function playerVote(): string | null {
|
|||||||
:class="{ 'player-details-above': player.isTopSeat }"
|
:class="{ 'player-details-above': player.isTopSeat }"
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
class="player-name max-w-32 text-center text-sm leading-tight font-semibold text-slate-100"
|
class="player-name max-w-32 text-center text-sm leading-tight font-semibold"
|
||||||
|
:class="
|
||||||
|
player.is_moderator ? 'text-amber-300' : 'text-slate-100'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ player.name }}
|
{{ player.name }}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
v-if="player.is_moderator || isCurrentPlayer()"
|
v-if="isCurrentPlayer()"
|
||||||
class="flex flex-wrap items-center justify-center gap-x-2 gap-y-0.5 text-[11px]"
|
class="flex flex-wrap items-center justify-center gap-x-2 gap-y-0.5 text-[11px]"
|
||||||
>
|
>
|
||||||
<span
|
<span class="text-cyan-300">Du</span>
|
||||||
v-if="player.is_moderator"
|
|
||||||
class="inline-flex items-center gap-1 text-amber-300"
|
|
||||||
>
|
|
||||||
<Crown class="size-3 fill-amber-300" aria-hidden="true" />
|
|
||||||
Moderator
|
|
||||||
</span>
|
|
||||||
<span v-if="isCurrentPlayer()" class="text-cyan-300">Du</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ export function useRoomRealtime(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
payload.change === 'player_kicked' &&
|
||||||
|
payload.affected_player_id === currentPlayer.value.id
|
||||||
|
) {
|
||||||
|
router.visit(home().url, { replace: true });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (payload.change === 'room_closed') {
|
if (payload.change === 'room_closed') {
|
||||||
router.visit(
|
router.visit(
|
||||||
join({ query: { join_code: room.value.join_code } }).url,
|
join({ query: { join_code: room.value.join_code } }).url,
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
export type DashboardRoomPlayer = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
is_moderator: boolean;
|
||||||
|
is_owner: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type DashboardRoom = {
|
export type DashboardRoom = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
@@ -5,6 +12,7 @@ export type DashboardRoom = {
|
|||||||
current_round: number;
|
current_round: number;
|
||||||
players_count: number;
|
players_count: number;
|
||||||
active_player_count: number;
|
active_player_count: number;
|
||||||
|
players: DashboardRoomPlayer[];
|
||||||
last_activity_at: string | null;
|
last_activity_at: string | null;
|
||||||
closed_at: string | null;
|
closed_at: string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ export type RoomVoteDeckEmits = {
|
|||||||
export type RoomStateChange =
|
export type RoomStateChange =
|
||||||
| 'player_joined'
|
| 'player_joined'
|
||||||
| 'player_updated'
|
| 'player_updated'
|
||||||
|
| 'player_kicked'
|
||||||
| 'vote_changed'
|
| 'vote_changed'
|
||||||
| 'cards_revealed'
|
| 'cards_revealed'
|
||||||
| 'votes_reset'
|
| 'votes_reset'
|
||||||
@@ -173,6 +174,7 @@ export type RoomStateChangedPayload = {
|
|||||||
version: number;
|
version: number;
|
||||||
change: RoomStateChange;
|
change: RoomStateChange;
|
||||||
state: RoomRealtimeState | null;
|
state: RoomRealtimeState | null;
|
||||||
|
affected_player_id?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RoomRealtimeState = Pick<
|
export type RoomRealtimeState = Pick<
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\RoomEmoteController;
|
|||||||
use App\Http\Controllers\RoomJoinController;
|
use App\Http\Controllers\RoomJoinController;
|
||||||
use App\Http\Controllers\RoomLifecycleController;
|
use App\Http\Controllers\RoomLifecycleController;
|
||||||
use App\Http\Controllers\RoomPlayerController;
|
use App\Http\Controllers\RoomPlayerController;
|
||||||
|
use App\Http\Controllers\RoomPlayerKickController;
|
||||||
use App\Http\Controllers\RoomSettingsController;
|
use App\Http\Controllers\RoomSettingsController;
|
||||||
use App\Http\Controllers\RoundController;
|
use App\Http\Controllers\RoundController;
|
||||||
use App\Http\Controllers\VoteController;
|
use App\Http\Controllers\VoteController;
|
||||||
@@ -44,6 +45,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
->name('room.destroy');
|
->name('room.destroy');
|
||||||
Route::post('/rooms/{room}/favorite', FavoriteRoomController::class)
|
Route::post('/rooms/{room}/favorite', FavoriteRoomController::class)
|
||||||
->name('room.favorite');
|
->name('room.favorite');
|
||||||
|
Route::delete('/rooms/{room}/players/{player}', RoomPlayerKickController::class)
|
||||||
|
->name('room.player.destroy');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware('room.player')->group(function () {
|
Route::middleware('room.player')->group(function () {
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\RoomStateChange;
|
||||||
|
use App\Events\RoomStateChanged;
|
||||||
|
use App\Models\Player;
|
||||||
|
use App\Models\Room;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
|
||||||
|
function createRoomForPlayerKick(User $owner, array $attributes = []): Room
|
||||||
|
{
|
||||||
|
return Room::create([
|
||||||
|
'owner_id' => $owner->id,
|
||||||
|
'title' => 'Team Planning',
|
||||||
|
'current_round' => 1,
|
||||||
|
'deck_config' => [
|
||||||
|
'name' => 'Fibonacci',
|
||||||
|
'cards' => [
|
||||||
|
['key' => '3', 'label' => '3'],
|
||||||
|
['key' => '5', 'label' => '5'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
...$attributes,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPlayerToKickRoom(
|
||||||
|
Room $room,
|
||||||
|
string $name,
|
||||||
|
?User $user = null,
|
||||||
|
bool $isModerator = false,
|
||||||
|
): Player {
|
||||||
|
return $room->players()->create([
|
||||||
|
'user_id' => $user?->id,
|
||||||
|
'name' => $name,
|
||||||
|
'is_moderator' => $isModerator,
|
||||||
|
'last_seen_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the room owner sees active room players on the dashboard', function () {
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
$room = createRoomForPlayerKick($owner);
|
||||||
|
$moderator = addPlayerToKickRoom($room, 'Moderator', $owner, true);
|
||||||
|
$participant = addPlayerToKickRoom($room, 'Anna');
|
||||||
|
addPlayerToKickRoom($room, 'Inactive')->update(['inactive_at' => now()]);
|
||||||
|
|
||||||
|
$this->actingAs($owner)
|
||||||
|
->get(route('dashboard'))
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->has('rooms.0.players', 2)
|
||||||
|
->where('rooms.0.players.0.id', $moderator->id)
|
||||||
|
->where('rooms.0.players.0.is_moderator', true)
|
||||||
|
->where('rooms.0.players.0.is_owner', true)
|
||||||
|
->where('rooms.0.players.1.id', $participant->id)
|
||||||
|
->where('rooms.0.players.1.is_moderator', false)
|
||||||
|
->where('rooms.0.players.1.is_owner', false));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the room owner can kick an active participant', function () {
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
$room = createRoomForPlayerKick($owner);
|
||||||
|
addPlayerToKickRoom($room, 'Moderator', $owner, true);
|
||||||
|
$participant = addPlayerToKickRoom($room, 'Anna');
|
||||||
|
$remainingPlayer = addPlayerToKickRoom($room, 'Ben');
|
||||||
|
$remainingPlayer->votes()->create([
|
||||||
|
'room_id' => $room->id,
|
||||||
|
'round' => $room->current_round,
|
||||||
|
'value' => '5',
|
||||||
|
]);
|
||||||
|
$previousVersion = $room->fresh()->state_version;
|
||||||
|
|
||||||
|
Event::fake([RoomStateChanged::class]);
|
||||||
|
|
||||||
|
$this->actingAs($owner)
|
||||||
|
->delete(route('room.player.destroy', [$room, $participant]))
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect($participant->fresh()->inactive_at)->not->toBeNull()
|
||||||
|
->and($room->fresh()->state_version)->toBe($previousVersion + 1);
|
||||||
|
|
||||||
|
Event::assertDispatched(
|
||||||
|
RoomStateChanged::class,
|
||||||
|
function (RoomStateChanged $event) use ($participant, $remainingPlayer, $room): bool {
|
||||||
|
if (
|
||||||
|
$event->roomId !== $room->id
|
||||||
|
|| $event->change !== RoomStateChange::PlayerKicked
|
||||||
|
|| $event->affectedPlayerId !== $participant->id
|
||||||
|
|| $event->state === null
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remainingPlayerState = collect($event->state['players'])
|
||||||
|
->firstWhere('id', $remainingPlayer->id);
|
||||||
|
|
||||||
|
return collect($event->state['players'])->doesntContain('id', $participant->id)
|
||||||
|
&& $remainingPlayerState['has_voted'] === true
|
||||||
|
&& $remainingPlayerState['vote'] === null
|
||||||
|
&& $event->broadcastWith()['affected_player_id'] === $participant->id;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Event::assertDispatchedTimes(RoomStateChanged::class, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a participant cannot kick another player', function () {
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
$participantUser = User::factory()->create();
|
||||||
|
$room = createRoomForPlayerKick($owner);
|
||||||
|
addPlayerToKickRoom($room, 'Moderator', $owner, true);
|
||||||
|
addPlayerToKickRoom($room, 'Anna', $participantUser);
|
||||||
|
$target = addPlayerToKickRoom($room, 'Ben');
|
||||||
|
|
||||||
|
Event::fake([RoomStateChanged::class]);
|
||||||
|
|
||||||
|
$this->actingAs($participantUser)
|
||||||
|
->delete(route('room.player.destroy', [$room, $target]))
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
expect($target->fresh()->inactive_at)->toBeNull();
|
||||||
|
Event::assertNotDispatched(RoomStateChanged::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the room owner cannot kick their own player', function () {
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
$room = createRoomForPlayerKick($owner);
|
||||||
|
$moderator = addPlayerToKickRoom($room, 'Moderator', $owner, true);
|
||||||
|
|
||||||
|
Event::fake([RoomStateChanged::class]);
|
||||||
|
|
||||||
|
$this->actingAs($owner)
|
||||||
|
->delete(route('room.player.destroy', [$room, $moderator]))
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
expect($moderator->fresh()->inactive_at)->toBeNull();
|
||||||
|
Event::assertNotDispatched(RoomStateChanged::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the room owner cannot kick a player from another room', function () {
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
$otherOwner = User::factory()->create();
|
||||||
|
$room = createRoomForPlayerKick($owner);
|
||||||
|
$otherRoom = createRoomForPlayerKick($otherOwner, ['title' => 'Other room']);
|
||||||
|
$target = addPlayerToKickRoom($otherRoom, 'Anna');
|
||||||
|
|
||||||
|
Event::fake([RoomStateChanged::class]);
|
||||||
|
|
||||||
|
$this->actingAs($owner)
|
||||||
|
->delete(route('room.player.destroy', [$room, $target]))
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
expect($target->fresh()->inactive_at)->toBeNull();
|
||||||
|
Event::assertNotDispatched(RoomStateChanged::class);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user