mirror of
https://github.com/ericasch6/scrum-poker-internal.git
synced 2026-10-13 21:12:10 +00:00
feat: some fixes to the ui, imlemented user and guest user, ans used the dahboard
This commit is contained in:
@@ -4,6 +4,9 @@ APP_KEY=
|
|||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost:8000
|
APP_URL=http://localhost:8000
|
||||||
|
|
||||||
|
ROOM_GUEST_RETENTION_DAYS=14
|
||||||
|
ROOM_ACCOUNT_RETENTION_DAYS=30
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
APP_FAKER_LOCALE=en_US
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Rooms;
|
||||||
|
|
||||||
|
use App\Models\Player;
|
||||||
|
use App\Models\Room;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Vote;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class ClaimGuestRooms
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<array-key, mixed> $roomPlayers
|
||||||
|
*/
|
||||||
|
public function handle(User $user, array $roomPlayers): void
|
||||||
|
{
|
||||||
|
foreach ($roomPlayers as $roomId => $playerId) {
|
||||||
|
if (! is_string($roomId) || ! is_numeric($playerId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($user, $roomId, $playerId): void {
|
||||||
|
$room = Room::query()->lockForUpdate()->find($roomId);
|
||||||
|
|
||||||
|
if ($room === null || $room->owner_id !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$moderator = $room->players()
|
||||||
|
->whereKey((int) $playerId)
|
||||||
|
->where('is_moderator', true)
|
||||||
|
->where(function ($query) use ($user): void {
|
||||||
|
$query->whereNull('user_id')->orWhere('user_id', $user->id);
|
||||||
|
})
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($moderator === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingPlayer = $room->players()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->whereKeyNot($moderator->id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existingPlayer !== null) {
|
||||||
|
$this->mergePlayerIntoModerator($existingPlayer, $moderator);
|
||||||
|
}
|
||||||
|
|
||||||
|
$moderator->update(['user_id' => $user->id]);
|
||||||
|
$room->update(['owner_id' => $user->id]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mergePlayerIntoModerator(Player $source, Player $moderator): void
|
||||||
|
{
|
||||||
|
$source->votes()
|
||||||
|
->orderBy('updated_at')
|
||||||
|
->each(function (Vote $vote) use ($moderator): void {
|
||||||
|
$targetVote = $moderator->votes()
|
||||||
|
->where('room_id', $vote->room_id)
|
||||||
|
->where('round', $vote->round)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($targetVote === null || $vote->updated_at->greaterThanOrEqualTo($targetVote->updated_at)) {
|
||||||
|
Vote::updateOrCreate(
|
||||||
|
[
|
||||||
|
'player_id' => $moderator->id,
|
||||||
|
'room_id' => $vote->room_id,
|
||||||
|
'round' => $vote->round,
|
||||||
|
],
|
||||||
|
['value' => $vote->value],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$source->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Room;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request): Response
|
||||||
|
{
|
||||||
|
$rooms = $request->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(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Inertia::render('Dashboard', [
|
||||||
|
'rooms' => $rooms,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,13 +6,15 @@ use App\Http\Requests\StoreRoomRequest;
|
|||||||
use App\Models\Deck;
|
use App\Models\Deck;
|
||||||
use App\Models\Player;
|
use App\Models\Player;
|
||||||
use App\Models\Room;
|
use App\Models\Room;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
class RoomController extends Controller
|
class RoomController extends Controller
|
||||||
{
|
{
|
||||||
public function create(Request $request)
|
public function create(Request $request): Response
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ class RoomController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request, StoreRoomRequest $roomStoreRequest)
|
public function store(Request $request, StoreRoomRequest $roomStoreRequest): RedirectResponse
|
||||||
{
|
{
|
||||||
$validated = $roomStoreRequest->validated();
|
$validated = $roomStoreRequest->validated();
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
@@ -59,7 +61,9 @@ class RoomController extends Controller
|
|||||||
$user,
|
$user,
|
||||||
) {
|
) {
|
||||||
$room = Room::create([
|
$room = Room::create([
|
||||||
|
'owner_id' => $user?->id,
|
||||||
'title' => $validated['title'] ?? null,
|
'title' => $validated['title'] ?? null,
|
||||||
|
'last_activity_at' => now(),
|
||||||
'deck_config' => [
|
'deck_config' => [
|
||||||
'id' => $deck->id,
|
'id' => $deck->id,
|
||||||
'name' => $deck->name,
|
'name' => $deck->name,
|
||||||
@@ -82,10 +86,19 @@ class RoomController extends Controller
|
|||||||
$player->id,
|
$player->id,
|
||||||
);
|
);
|
||||||
|
|
||||||
return to_route('room.show', $room);
|
$redirect = to_route('room.show', $room);
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
$redirect->with('room_notice', [
|
||||||
|
'guest_retention_days' => config('rooms.guest_retention_days'),
|
||||||
|
'account_retention_days' => config('rooms.account_retention_days'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $redirect;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Room $room, Request $request)
|
public function show(Room $room, Request $request): Response
|
||||||
{
|
{
|
||||||
$currentPlayer = $request->attributes->get('currentPlayer');
|
$currentPlayer = $request->attributes->get('currentPlayer');
|
||||||
|
|
||||||
@@ -117,6 +130,9 @@ class RoomController extends Controller
|
|||||||
->firstWhere('id', $currentPlayer->id)
|
->firstWhere('id', $currentPlayer->id)
|
||||||
?->votes
|
?->votes
|
||||||
->first();
|
->first();
|
||||||
|
$statistics = $room->revealed_at !== null
|
||||||
|
? $this->currentRoundStatistics($room)
|
||||||
|
: null;
|
||||||
|
|
||||||
return Inertia::render('rooms/Show', [
|
return Inertia::render('rooms/Show', [
|
||||||
'room' => [
|
'room' => [
|
||||||
@@ -126,6 +142,7 @@ class RoomController extends Controller
|
|||||||
'current_round' => $room->current_round,
|
'current_round' => $room->current_round,
|
||||||
'revealed' => $room->revealed_at !== null,
|
'revealed' => $room->revealed_at !== null,
|
||||||
'state_version' => $room->state_version,
|
'state_version' => $room->state_version,
|
||||||
|
'statistics' => $statistics,
|
||||||
'deck' => [
|
'deck' => [
|
||||||
'name' => $room->deck_config['name'],
|
'name' => $room->deck_config['name'],
|
||||||
'cards' => $room->deck_config['cards'],
|
'cards' => $room->deck_config['cards'],
|
||||||
@@ -141,10 +158,120 @@ class RoomController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function join(Request $request)
|
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', [
|
return Inertia::render('rooms/Join', [
|
||||||
'join_code' => $request->query('join_code'),
|
'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 = DB::transaction(function () use ($room, $user) {
|
||||||
|
$lockedRoom = Room::query()->lockForUpdate()->findOrFail($room->id);
|
||||||
|
|
||||||
|
abort_unless($user->id === $lockedRoom->owner_id, 403);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
$request->session()->put("room_players.{$room->id}", $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 !== null && $user->id === $room->owner_id, 403);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($room, $user): void {
|
||||||
|
$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();
|
||||||
|
});
|
||||||
|
|
||||||
|
$request->session()->forget("room_players.{$room->id}");
|
||||||
|
Inertia::flash('toast', [
|
||||||
|
'type' => 'success',
|
||||||
|
'message' => 'Der Raum wurde gelöscht.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
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, ',', ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,23 +3,51 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Requests\JoinRoomRequest;
|
use App\Http\Requests\JoinRoomRequest;
|
||||||
|
use App\Models\Player;
|
||||||
use App\Models\Room;
|
use App\Models\Room;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class RoomPlayerController extends Controller
|
class RoomPlayerController extends Controller
|
||||||
{
|
{
|
||||||
public function join(JoinRoomRequest $request)
|
public function join(JoinRoomRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
$room = Room::where('join_code', '=', $validated['join_code'])->firstOrFail();
|
[$room, $player] = DB::transaction(function () use ($request, $validated, $user): array {
|
||||||
|
$room = Room::query()
|
||||||
|
->where('join_code', $validated['join_code'])
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
$player = $room->players()->create([
|
if ($room->isClosed()) {
|
||||||
'user_id' => $user?->id,
|
throw ValidationException::withMessages([
|
||||||
'name' => $validated['player_name'],
|
'join_code' => 'Dieser Raum ist geschlossen.',
|
||||||
'is_moderator' => false,
|
]);
|
||||||
'last_seen_at' => now(),
|
}
|
||||||
]);
|
|
||||||
|
$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];
|
||||||
|
});
|
||||||
|
|
||||||
$request->session()->put(
|
$request->session()->put(
|
||||||
"room_players.{$room->id}",
|
"room_players.{$room->id}",
|
||||||
@@ -27,6 +55,20 @@ class RoomPlayerController extends Controller
|
|||||||
);
|
);
|
||||||
|
|
||||||
return to_route('room.show', $room);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ class RoundController extends Controller
|
|||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->findOrFail($room->id);
|
->findOrFail($room->id);
|
||||||
|
|
||||||
|
$this->ensureRoomIsOpen($lockedRoom);
|
||||||
|
|
||||||
Vote::query()
|
Vote::query()
|
||||||
->where('room_id', $lockedRoom->id)
|
->where('room_id', $lockedRoom->id)
|
||||||
->where('round', $lockedRoom->current_round)
|
->where('round', $lockedRoom->current_round)
|
||||||
@@ -37,6 +39,7 @@ class RoundController extends Controller
|
|||||||
|
|
||||||
$lockedRoom->update([
|
$lockedRoom->update([
|
||||||
'revealed_at' => null,
|
'revealed_at' => null,
|
||||||
|
'last_activity_at' => now(),
|
||||||
'state_version' => $lockedRoom->state_version + 1,
|
'state_version' => $lockedRoom->state_version + 1,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -62,6 +65,8 @@ class RoundController extends Controller
|
|||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->findOrFail($room->id);
|
->findOrFail($room->id);
|
||||||
|
|
||||||
|
$this->ensureRoomIsOpen($lockedRoom);
|
||||||
|
|
||||||
if ($lockedRoom->revealed_at !== null) {
|
if ($lockedRoom->revealed_at !== null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'room' => 'Die Karten sind bereits aufgedeckt.',
|
'room' => 'Die Karten sind bereits aufgedeckt.',
|
||||||
@@ -70,6 +75,7 @@ class RoundController extends Controller
|
|||||||
|
|
||||||
$lockedRoom->update([
|
$lockedRoom->update([
|
||||||
'revealed_at' => now(),
|
'revealed_at' => now(),
|
||||||
|
'last_activity_at' => now(),
|
||||||
'state_version' => $lockedRoom->state_version + 1,
|
'state_version' => $lockedRoom->state_version + 1,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -95,6 +101,8 @@ class RoundController extends Controller
|
|||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->findOrFail($room->id);
|
->findOrFail($room->id);
|
||||||
|
|
||||||
|
$this->ensureRoomIsOpen($lockedRoom);
|
||||||
|
|
||||||
if ($lockedRoom->revealed_at === null) {
|
if ($lockedRoom->revealed_at === null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'room' => 'Die aktuelle Runde muss zuerst aufgedeckt werden.',
|
'room' => 'Die aktuelle Runde muss zuerst aufgedeckt werden.',
|
||||||
@@ -104,10 +112,20 @@ class RoundController extends Controller
|
|||||||
$lockedRoom->update([
|
$lockedRoom->update([
|
||||||
'current_round' => $lockedRoom->current_round + 1,
|
'current_round' => $lockedRoom->current_round + 1,
|
||||||
'revealed_at' => null,
|
'revealed_at' => null,
|
||||||
|
'last_activity_at' => now(),
|
||||||
'state_version' => $lockedRoom->state_version + 1,
|
'state_version' => $lockedRoom->state_version + 1,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
return back();
|
return back();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function ensureRoomIsOpen(Room $room): void
|
||||||
|
{
|
||||||
|
if ($room->isClosed()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'room' => 'Dieser Raum ist geschlossen.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ class VoteController extends Controller
|
|||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->findOrFail($room->id);
|
->findOrFail($room->id);
|
||||||
|
|
||||||
|
if ($lockedRoom->isClosed()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'value' => 'Dieser Raum ist geschlossen.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
if ($lockedRoom->revealed_at !== null) {
|
if ($lockedRoom->revealed_at !== null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'value' => 'Die Karten wurden bereits aufgedeckt.',
|
'value' => 'Die Karten wurden bereits aufgedeckt.',
|
||||||
@@ -48,7 +54,10 @@ class VoteController extends Controller
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$lockedRoom->increment('state_version');
|
$lockedRoom->update([
|
||||||
|
'last_activity_at' => now(),
|
||||||
|
'state_version' => $lockedRoom->state_version + 1,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
return back();
|
return back();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Http\Middleware;
|
|||||||
use App\Models\Room;
|
use App\Models\Room;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class EnsureRoomParticipant
|
class EnsureRoomParticipant
|
||||||
@@ -23,23 +24,48 @@ class EnsureRoomParticipant
|
|||||||
}
|
}
|
||||||
|
|
||||||
$sessionKey = "room_players.{$room->id}";
|
$sessionKey = "room_players.{$room->id}";
|
||||||
$playerId = $request->session()->get($sessionKey);
|
|
||||||
|
|
||||||
if (! $playerId) {
|
if ($room->trashed()) {
|
||||||
|
$request->session()->forget($sessionKey);
|
||||||
|
Inertia::flash('toast', [
|
||||||
|
'type' => 'info',
|
||||||
|
'message' => 'Dieser Raum wurde gelöscht.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return to_route('home');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($room->isClosed()) {
|
||||||
return to_route('room.join', [
|
return to_route('room.join', [
|
||||||
'join_code' => $room->join_code,
|
'join_code' => $room->join_code,
|
||||||
])->withErrors([
|
])->withErrors([
|
||||||
'join_code' => 'Du musst diesen Raum erst beitreten.',
|
'join_code' => 'Dieser Raum ist geschlossen.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$player = $room->players()->find($playerId);
|
$playerId = $request->session()->get($sessionKey);
|
||||||
|
|
||||||
if (! $player) {
|
$player = $playerId ? $room->players()->find($playerId) : null;
|
||||||
|
|
||||||
|
if ($player === null && $playerId) {
|
||||||
$request->session()->forget($sessionKey);
|
$request->session()->forget($sessionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($player === null && $request->user() !== null) {
|
||||||
|
$player = $room->players()
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($player !== null) {
|
||||||
|
$request->session()->put($sessionKey, $player->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($player === null) {
|
||||||
return to_route('room.join', [
|
return to_route('room.join', [
|
||||||
'join_code' => $room->join_code,
|
'join_code' => $room->join_code,
|
||||||
|
])->withErrors([
|
||||||
|
'join_code' => 'Du musst diesem Raum erst beitreten.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'auth' => [
|
'auth' => [
|
||||||
'user' => $request->user(),
|
'user' => $request->user(),
|
||||||
],
|
],
|
||||||
|
'roomNotice' => fn () => $request->session()->get('room_notice'),
|
||||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Models\Room;
|
|||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
class JoinRoomRequest extends FormRequest
|
class JoinRoomRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -37,9 +38,54 @@ class JoinRoomRequest extends FormRequest
|
|||||||
->where('join_code', $this->input('join_code'))
|
->where('join_code', $this->input('join_code'))
|
||||||
->value('id');
|
->value('id');
|
||||||
|
|
||||||
|
$existingPlayerId = null;
|
||||||
|
|
||||||
|
if ($roomId !== null && $this->user() !== null) {
|
||||||
|
$existingPlayerId = Player::query()
|
||||||
|
->where('room_id', $roomId)
|
||||||
|
->where('user_id', $this->user()->id)
|
||||||
|
->value('id');
|
||||||
|
} elseif ($roomId !== null) {
|
||||||
|
$sessionPlayerId = $this->session()->get("room_players.{$roomId}");
|
||||||
|
$existingPlayerId = is_numeric($sessionPlayerId)
|
||||||
|
? Player::query()
|
||||||
|
->where('room_id', $roomId)
|
||||||
|
->whereKey((int) $sessionPlayerId)
|
||||||
|
->value('id')
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$playerNameRules = ['required', 'string', 'max:50'];
|
||||||
|
|
||||||
|
if ($existingPlayerId === null) {
|
||||||
|
$playerNameRules[] = Rule::unique(Player::class, 'name')->where('room_id', $roomId);
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'join_code' => ['bail', 'string', 'required', 'size:6', Rule::exists(Room::class, 'join_code')],
|
'join_code' => [
|
||||||
'player_name' => ['required', 'string', 'max:50', Rule::unique(Player::class, 'name')->where('room_id', $roomId)],
|
'bail',
|
||||||
|
'string',
|
||||||
|
'required',
|
||||||
|
'size:6',
|
||||||
|
Rule::exists(Room::class, 'join_code')->whereNull('deleted_at'),
|
||||||
|
],
|
||||||
|
'player_name' => $playerNameRules,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<callable(Validator): void> */
|
||||||
|
public function after(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
function (Validator $validator): void {
|
||||||
|
$room = Room::query()
|
||||||
|
->where('join_code', $this->input('join_code'))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($room?->isClosed()) {
|
||||||
|
$validator->errors()->add('join_code', 'Dieser Raum ist geschlossen.');
|
||||||
|
}
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Room;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
@@ -25,6 +26,10 @@ class SubmitVoteRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
$room = $this->route('room');
|
$room = $this->route('room');
|
||||||
|
|
||||||
|
if (! $room instanceof Room) {
|
||||||
|
return ['value' => ['prohibited']];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'value' => ['bail', 'required', 'string', Rule::in(array_column($room->deck_config['cards'], 'key'))],
|
'value' => ['bail', 'required', 'string', Rule::in(array_column($room->deck_config['cards'], 'key'))],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Room;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class CloseInactiveRooms implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$closedAt = now();
|
||||||
|
$guestCutoff = $closedAt->subDays(config('rooms.guest_retention_days'));
|
||||||
|
$accountCutoff = $closedAt->subDays(config('rooms.account_retention_days'));
|
||||||
|
|
||||||
|
Room::query()
|
||||||
|
->whereNull('closed_at')
|
||||||
|
->where(function ($query) use ($guestCutoff, $accountCutoff): void {
|
||||||
|
$query
|
||||||
|
->where(function ($query) use ($guestCutoff): void {
|
||||||
|
$query->whereNull('owner_id')
|
||||||
|
->where('last_activity_at', '<=', $guestCutoff);
|
||||||
|
})
|
||||||
|
->orWhere(function ($query) use ($accountCutoff): void {
|
||||||
|
$query->whereNotNull('owner_id')
|
||||||
|
->where('last_activity_at', '<=', $accountCutoff);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->select('id')
|
||||||
|
->chunkById(100, function ($rooms) use ($closedAt, $guestCutoff, $accountCutoff): void {
|
||||||
|
Room::query()
|
||||||
|
->whereKey($rooms->pluck('id'))
|
||||||
|
->whereNull('closed_at')
|
||||||
|
->where(function ($query) use ($guestCutoff, $accountCutoff): void {
|
||||||
|
$query
|
||||||
|
->where(function ($query) use ($guestCutoff): void {
|
||||||
|
$query->whereNull('owner_id')
|
||||||
|
->where('last_activity_at', '<=', $guestCutoff);
|
||||||
|
})
|
||||||
|
->orWhere(function ($query) use ($accountCutoff): void {
|
||||||
|
$query->whereNotNull('owner_id')
|
||||||
|
->where('last_activity_at', '<=', $accountCutoff);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->update([
|
||||||
|
'closed_at' => $closedAt,
|
||||||
|
'state_version' => DB::raw('state_version + 1'),
|
||||||
|
'updated_at' => $closedAt,
|
||||||
|
]);
|
||||||
|
}, 'id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Actions\Rooms\ClaimGuestRooms;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Events\Login;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ClaimGuestRoomsAfterLogin
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ClaimGuestRooms $claimGuestRooms,
|
||||||
|
private readonly Request $request,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(Login $event): void
|
||||||
|
{
|
||||||
|
if (! $event->user instanceof User || ! $this->request->hasSession()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roomPlayers = $this->request->session()->get('room_players', []);
|
||||||
|
|
||||||
|
if (! is_array($roomPlayers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->claimGuestRooms->handle($event->user, $roomPlayers);
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-1
@@ -5,6 +5,13 @@ namespace App\Models;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property int|null $user_id
|
||||||
|
* @property string $name
|
||||||
|
* @property array<int, array{key: string, label: string}> $cards
|
||||||
|
* @property bool $is_system
|
||||||
|
*/
|
||||||
class Deck extends Model
|
class Deck extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
@@ -12,7 +19,7 @@ class Deck extends Model
|
|||||||
'cards',
|
'cards',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts()
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'cards' => 'array',
|
'cards' => 'array',
|
||||||
@@ -20,6 +27,7 @@ class Deck extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<User, $this> */
|
||||||
public function user(): BelongsTo
|
public function user(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
|
|||||||
@@ -5,7 +5,19 @@ namespace App\Models;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property string $room_id
|
||||||
|
* @property int|null $user_id
|
||||||
|
* @property string $name
|
||||||
|
* @property bool $is_moderator
|
||||||
|
* @property Carbon|null $last_seen_at
|
||||||
|
* @property Carbon|null $created_at
|
||||||
|
* @property Carbon|null $updated_at
|
||||||
|
* @property-read Room $room
|
||||||
|
*/
|
||||||
class Player extends Model
|
class Player extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
@@ -24,16 +36,19 @@ class Player extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Room, $this> */
|
||||||
public function room(): BelongsTo
|
public function room(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Room::class);
|
return $this->belongsTo(Room::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Vote, $this> */
|
||||||
public function votes(): HasMany
|
public function votes(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Vote::class);
|
return $this->hasMany(Vote::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<User, $this> */
|
||||||
public function user(): BelongsTo
|
public function user(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
|
|||||||
+40
-4
@@ -2,20 +2,40 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property string $id
|
||||||
|
* @property int|null $owner_id
|
||||||
|
* @property string|null $title
|
||||||
|
* @property array{name: string, cards: array<int, array{key: string, label: string}>, id?: int} $deck_config
|
||||||
|
* @property string $join_code
|
||||||
|
* @property int $current_round
|
||||||
|
* @property CarbonImmutable|null $revealed_at
|
||||||
|
* @property int $state_version
|
||||||
|
* @property CarbonImmutable|null $last_activity_at
|
||||||
|
* @property CarbonImmutable|null $closed_at
|
||||||
|
* @property CarbonImmutable|null $deleted_at
|
||||||
|
* @property int $players_count
|
||||||
|
*/
|
||||||
class Room extends Model
|
class Room extends Model
|
||||||
{
|
{
|
||||||
use HasUuids;
|
use HasUuids, SoftDeletes;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
'owner_id',
|
||||||
'title',
|
'title',
|
||||||
'current_round',
|
'current_round',
|
||||||
'deck_config',
|
'deck_config',
|
||||||
'revealed_at',
|
'revealed_at',
|
||||||
'state_version',
|
'state_version',
|
||||||
|
'last_activity_at',
|
||||||
|
'closed_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
@@ -24,18 +44,21 @@ class Room extends Model
|
|||||||
'current_round' => 'integer',
|
'current_round' => 'integer',
|
||||||
'state_version' => 'integer',
|
'state_version' => 'integer',
|
||||||
'revealed_at' => 'immutable_datetime',
|
'revealed_at' => 'immutable_datetime',
|
||||||
|
'last_activity_at' => 'immutable_datetime',
|
||||||
|
'closed_at' => 'immutable_datetime',
|
||||||
'deck_config' => 'array',
|
'deck_config' => 'array',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static function booted()
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
static::creating(function (Room $room) {
|
static::creating(function (Room $room) {
|
||||||
$room->join_code ??= static::generateJoinCode();
|
$room->join_code ??= static::generateJoinCode();
|
||||||
|
$room->last_activity_at ??= now();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function generateJoinCode(): string
|
protected static function generateJoinCode(): string
|
||||||
{
|
{
|
||||||
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
|
|
||||||
@@ -45,20 +68,33 @@ class Room extends Model
|
|||||||
for ($i = 0; $i < 6; $i++) {
|
for ($i = 0; $i < 6; $i++) {
|
||||||
$code .= $alphabet[random_int(0, strlen($alphabet) - 1)];
|
$code .= $alphabet[random_int(0, strlen($alphabet) - 1)];
|
||||||
}
|
}
|
||||||
} while (static::where('join_code', $code)->exists());
|
} while (static::withTrashed()->where('join_code', $code)->exists());
|
||||||
|
|
||||||
return $code;
|
return $code;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Player, $this> */
|
||||||
public function players(): HasMany
|
public function players(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Player::class);
|
return $this->hasMany(Player::class);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<User, $this> */
|
||||||
|
public function owner(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'owner_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Vote, $this> */
|
||||||
public function votes(): HasMany
|
public function votes(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Vote::class);
|
return $this->hasMany(Vote::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isClosed(): bool
|
||||||
|
{
|
||||||
|
return $this->closed_at !== null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,13 +49,21 @@ class User extends Authenticatable implements PasskeyUser
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Player, $this> */
|
||||||
public function players(): HasMany
|
public function players(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Player::class);
|
return $this->hasMany(Player::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Deck, $this> */
|
||||||
public function decks(): HasMany
|
public function decks(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Deck::class);
|
return $this->hasMany(Deck::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Room, $this> */
|
||||||
|
public function ownedRooms(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Room::class, 'owner_id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,17 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property int $player_id
|
||||||
|
* @property string $room_id
|
||||||
|
* @property int $round
|
||||||
|
* @property string $value
|
||||||
|
* @property Carbon|null $created_at
|
||||||
|
* @property Carbon|null $updated_at
|
||||||
|
*/
|
||||||
class Vote extends Model
|
class Vote extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
@@ -21,11 +31,13 @@ class Vote extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Room, $this> */
|
||||||
public function room(): BelongsTo
|
public function room(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Room::class);
|
return $this->belongsTo(Room::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Player, $this> */
|
||||||
public function player(): BelongsTo
|
public function player(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Player::class);
|
return $this->belongsTo(Player::class);
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'guest_retention_days' => (int) env('ROOM_GUEST_RETENTION_DAYS', 14),
|
||||||
|
'account_retention_days' => (int) env('ROOM_ACCOUNT_RETENTION_DAYS', 30),
|
||||||
|
];
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('rooms', function (Blueprint $table) {
|
||||||
|
$table->foreignId('owner_id')
|
||||||
|
->nullable()
|
||||||
|
->after('id')
|
||||||
|
->constrained('users')
|
||||||
|
->nullOnDelete();
|
||||||
|
$table->timestamp('last_activity_at')->nullable()->after('state_version')->index();
|
||||||
|
$table->timestamp('closed_at')->nullable()->after('last_activity_at')->index();
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('rooms')
|
||||||
|
->whereNull('last_activity_at')
|
||||||
|
->update(['last_activity_at' => DB::raw('updated_at')]);
|
||||||
|
|
||||||
|
$moderators = DB::table('players')
|
||||||
|
->where('is_moderator', true)
|
||||||
|
->whereNotNull('user_id')
|
||||||
|
->orderBy('id')
|
||||||
|
->get(['room_id', 'user_id'])
|
||||||
|
->unique('room_id');
|
||||||
|
|
||||||
|
foreach ($moderators as $moderator) {
|
||||||
|
DB::table('rooms')
|
||||||
|
->where('id', $moderator->room_id)
|
||||||
|
->whereNull('owner_id')
|
||||||
|
->update(['owner_id' => $moderator->user_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$duplicateMemberships = DB::table('players')
|
||||||
|
->select(['room_id', 'user_id'])
|
||||||
|
->whereNotNull('user_id')
|
||||||
|
->groupBy('room_id', 'user_id')
|
||||||
|
->havingRaw('COUNT(*) > 1')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($duplicateMemberships as $membership) {
|
||||||
|
$players = DB::table('players')
|
||||||
|
->where('room_id', $membership->room_id)
|
||||||
|
->where('user_id', $membership->user_id)
|
||||||
|
->orderByDesc('is_moderator')
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$canonicalPlayer = $players->first();
|
||||||
|
|
||||||
|
if ($canonicalPlayer === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$playerIds = $players->pluck('id');
|
||||||
|
|
||||||
|
DB::table('votes')
|
||||||
|
->whereIn('player_id', $playerIds)
|
||||||
|
->orderBy('updated_at')
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
->each(function (object $vote) use ($canonicalPlayer): void {
|
||||||
|
DB::table('votes')->updateOrInsert(
|
||||||
|
[
|
||||||
|
'player_id' => $canonicalPlayer->id,
|
||||||
|
'room_id' => $vote->room_id,
|
||||||
|
'round' => $vote->round,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'value' => $vote->value,
|
||||||
|
'created_at' => $vote->created_at,
|
||||||
|
'updated_at' => $vote->updated_at,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$duplicatePlayerIds = $playerIds
|
||||||
|
->reject(fn (int $playerId): bool => $playerId === $canonicalPlayer->id)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($duplicatePlayerIds->isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('votes')->whereIn('player_id', $duplicatePlayerIds)->delete();
|
||||||
|
DB::table('players')->whereIn('id', $duplicatePlayerIds)->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('players', function (Blueprint $table) {
|
||||||
|
$table->unique(['room_id', 'user_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('players', function (Blueprint $table) {
|
||||||
|
$table->dropUnique(['room_id', 'user_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('rooms', function (Blueprint $table) {
|
||||||
|
$table->dropConstrainedForeignId('owner_id');
|
||||||
|
$table->dropColumn(['last_activity_at', 'closed_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('rooms', function (Blueprint $table) {
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('rooms', function (Blueprint $table) {
|
||||||
|
$table->dropSoftDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+1
-2
@@ -1,11 +1,10 @@
|
|||||||
import { createInertiaApp } from '@inertiajs/vue3';
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
import { initializeTheme } from '@/composables/useAppearance';
|
import { initializeTheme } from '@/composables/useAppearance';
|
||||||
import PublicLayout from './layouts/PublicLayout.vue';
|
|
||||||
import { initializeFlashToast } from '@/lib/flashToast';
|
import { initializeFlashToast } from '@/lib/flashToast';
|
||||||
|
import PublicLayout from './layouts/PublicLayout.vue';
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||||
|
|
||||||
|
|
||||||
createInertiaApp({
|
createInertiaApp({
|
||||||
title: (title) => (title ? `${title} - ${appName}` : appName),
|
title: (title) => (title ? `${title} - ${appName}` : appName),
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
|||||||
import { getInitials } from '@/composables/useInitials';
|
import { getInitials } from '@/composables/useInitials';
|
||||||
import { toUrl } from '@/lib/utils';
|
import { toUrl } from '@/lib/utils';
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
import type { BreadcrumbItem, NavItem } from '@/types';
|
import type { BreadcrumbItem, NavItem, User } from '@/types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
breadcrumbs?: BreadcrumbItem[];
|
breadcrumbs?: BreadcrumbItem[];
|
||||||
@@ -47,7 +47,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
const auth = computed(() => page.props.auth);
|
const auth = computed(() => page.props.auth as { user: User });
|
||||||
const { isCurrentUrl, whenCurrentUrl } = useCurrentUrl();
|
const { isCurrentUrl, whenCurrentUrl } = useCurrentUrl();
|
||||||
|
|
||||||
const activeItemStyles =
|
const activeItemStyles =
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import {
|
|||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
import UserInfo from '@/components/UserInfo.vue';
|
import UserInfo from '@/components/UserInfo.vue';
|
||||||
import UserMenuContent from '@/components/UserMenuContent.vue';
|
import UserMenuContent from '@/components/UserMenuContent.vue';
|
||||||
|
import type { User } from '@/types';
|
||||||
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
const user = computed(() => page.props.auth.user);
|
const user = computed(() => page.props.auth.user as User);
|
||||||
const { isMobile, state } = useSidebar();
|
const { isMobile, state } = useSidebar();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,56 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { Link, usePage } from '@inertiajs/vue3';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { dashboard, home, login, logout, register } from '@/routes';
|
||||||
|
|
||||||
const navigation = [
|
const page = usePage();
|
||||||
]
|
const user = computed(() => page.props.auth.user);
|
||||||
|
|
||||||
const mobileMenuOpen = ref(false)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-svh bg-gray-900">
|
<div class="min-h-svh bg-gray-900 text-white">
|
||||||
<header class="border-b border-white/10">
|
<header class="border-b border-white/10">
|
||||||
<nav class="mx-auto flex h-16 max-w-7xl items-center justify-between px-6" aria-label="Global">
|
<nav
|
||||||
<nav class="flex items-center w-full justify-between p-6 lg:px-8" aria-label="Global">
|
class="mx-auto flex h-16 max-w-7xl items-center justify-between px-6"
|
||||||
<div class="flex lg:flex-1">
|
aria-label="Hauptnavigation"
|
||||||
<a href="#" class="-m-1.5 p-1.5">
|
>
|
||||||
<span class="sr-only">Your Company</span> </a>
|
<Link :href="home()" class="text-xl font-extrabold">
|
||||||
</div>
|
Scrum Poker
|
||||||
<div class="flex lg:hidden">
|
</Link>
|
||||||
<button type="button"
|
|
||||||
class="-m-2.5 inline-flex items-center justify-center rounded-md p-2.5 text-gray-200"
|
<div class="flex items-center gap-4 text-sm font-semibold">
|
||||||
@click="mobileMenuOpen = true">
|
<template v-if="user">
|
||||||
<span class="sr-only">Open main menu</span>
|
<Link
|
||||||
</button>
|
:href="dashboard()"
|
||||||
</div>
|
class="text-gray-200 transition hover:text-white"
|
||||||
<div class="hidden lg:flex lg:gap-x-12">
|
>
|
||||||
<h1 class="font-extrabold text-2xl">Scrum Poker</h1>
|
Meine Räume
|
||||||
</div>
|
</Link>
|
||||||
<div class="hidden lg:flex lg:flex-1 lg:justify-end">
|
<Link
|
||||||
<a href="#" class="text-sm/6 font-semibold text-white">Log in <span
|
:href="logout()"
|
||||||
aria-hidden="true">→</span></a>
|
method="post"
|
||||||
</div>
|
as="button"
|
||||||
</nav>
|
class="rounded-lg border border-white/15 px-3 py-2 text-gray-200 transition hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</Link>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<Link
|
||||||
|
:href="login()"
|
||||||
|
class="text-gray-200 transition hover:text-white"
|
||||||
|
>
|
||||||
|
Anmelden
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
:href="register()"
|
||||||
|
class="rounded-lg bg-white px-3 py-2 text-gray-900 transition hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
Registrieren
|
||||||
|
</Link>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -38,4 +59,3 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,47 +1,238 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head } from '@inertiajs/vue3';
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
|
import { CalendarClock, DoorOpen, Plus, Trash2, Users } from '@lucide/vue';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
|
import { create, destroy, reopen, show } from '@/routes/room';
|
||||||
|
|
||||||
|
type OwnedRoom = {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
join_code: string;
|
||||||
|
current_round: number;
|
||||||
|
players_count: number;
|
||||||
|
last_activity_at: string | null;
|
||||||
|
closed_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
layout: {
|
layout: {
|
||||||
breadcrumbs: [
|
breadcrumbs: [
|
||||||
{
|
{
|
||||||
title: 'Dashboard',
|
title: 'Meine Räume',
|
||||||
href: dashboard(),
|
href: dashboard(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
rooms: OwnedRoom[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const selectedRoom = ref<OwnedRoom | null>(null);
|
||||||
|
const deleteForm = useForm({});
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDate(value: string | null): string {
|
||||||
|
return value ? dateFormatter.format(new Date(value)) : 'Keine Aktivität';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDeleteDialogOpen(open: boolean): void {
|
||||||
|
if (!open && !deleteForm.processing) {
|
||||||
|
selectedRoom.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRoom(): void {
|
||||||
|
if (selectedRoom.value === null || deleteForm.processing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteForm.delete(destroy(selectedRoom.value.id).url, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
selectedRoom.value = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Head title="Dashboard" />
|
<Head title="Meine Räume" />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
|
class="flex min-h-[calc(100svh-4rem)] flex-1 flex-col gap-6 overflow-x-auto bg-slate-900 p-4 text-slate-100"
|
||||||
>
|
>
|
||||||
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
|
|
||||||
<div
|
|
||||||
class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
|
|
||||||
>
|
|
||||||
<PlaceholderPattern />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
|
|
||||||
>
|
|
||||||
<PlaceholderPattern />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
|
|
||||||
>
|
|
||||||
<PlaceholderPattern />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="relative min-h-[100vh] flex-1 rounded-xl border border-sidebar-border/70 md:min-h-min dark:border-sidebar-border"
|
class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
>
|
>
|
||||||
<PlaceholderPattern />
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight">Meine Räume</h1>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Deine erstellten Scrum-Poker-Räume auf einen Blick.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
:href="create()"
|
||||||
|
class="inline-flex w-fit items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
<Plus class="size-4" />
|
||||||
|
Raum erstellen
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="rooms.length > 0"
|
||||||
|
class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
|
||||||
|
>
|
||||||
|
<article
|
||||||
|
v-for="room in rooms"
|
||||||
|
:key="room.id"
|
||||||
|
class="flex flex-col rounded-md border border-slate-700 bg-slate-800 p-5 text-slate-100"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h2 class="text-lg font-semibold break-words">
|
||||||
|
{{ room.title ?? 'Scrum Poker' }}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
class="mt-1 font-mono text-sm tracking-[0.18em] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ room.join_code }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="shrink-0 text-sm font-semibold normal-case"
|
||||||
|
:class="
|
||||||
|
room.closed_at
|
||||||
|
? 'text-muted-foreground'
|
||||||
|
: 'text-emerald-600 dark:text-emerald-400'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ room.closed_at ? 'Geschlossen' : 'Aktiv' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-5 grid grid-cols-2 gap-3 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<Users class="size-4" />
|
||||||
|
{{ room.players_count }} Spieler
|
||||||
|
</span>
|
||||||
|
<span>Runde {{ room.current_round }}</span>
|
||||||
|
<span class="col-span-2 inline-flex items-center gap-2">
|
||||||
|
<CalendarClock class="size-4" />
|
||||||
|
{{ formatDate(room.last_activity_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-wrap items-center gap-3">
|
||||||
|
<Link
|
||||||
|
v-if="!room.closed_at"
|
||||||
|
:href="show(room.id)"
|
||||||
|
class="inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-semibold transition hover:bg-blue-500/80"
|
||||||
|
>
|
||||||
|
<DoorOpen class="size-4" />
|
||||||
|
Raum beitreten
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
v-else
|
||||||
|
:href="reopen(room.id)"
|
||||||
|
method="post"
|
||||||
|
as="button"
|
||||||
|
class="inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-semibold text-primary-foreground transition hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
<DoorOpen class="size-4" />
|
||||||
|
Raum öffnen
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-semibold text-rose-300 transition hover:bg-rose-500/80"
|
||||||
|
@click="selectedRoom = room"
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4" />
|
||||||
|
Raum löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="grid min-h-72 place-items-center rounded-md border border-dashed border-sidebar-border p-8 text-center"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Noch keine eigenen Räume</h2>
|
||||||
|
<p class="mt-2 max-w-md text-sm text-muted-foreground">
|
||||||
|
Erstelle deinen ersten Raum. Er wird automatisch in diesem
|
||||||
|
Dashboard gespeichert.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
:href="create()"
|
||||||
|
class="mt-5 inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground"
|
||||||
|
>
|
||||||
|
<Plus class="size-4" />
|
||||||
|
Ersten Raum erstellen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
:open="selectedRoom !== null"
|
||||||
|
@update:open="setDeleteDialogOpen"
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
class="border-slate-700 bg-slate-800 text-slate-100 sm:max-w-md"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Raum wirklich löschen?</DialogTitle>
|
||||||
|
<DialogDescription class="text-slate-300">
|
||||||
|
„{{ selectedRoom?.title ?? 'Scrum Poker' }}“ wird aus
|
||||||
|
deinem Dashboard entfernt. Alle Spieler, die den Raum
|
||||||
|
gerade geöffnet haben, werden automatisch
|
||||||
|
hinausgeleitet.
|
||||||
|
</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="deleteForm.processing"
|
||||||
|
@click="selectedRoom = 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="deleteForm.processing"
|
||||||
|
@click="deleteRoom"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
deleteForm.processing
|
||||||
|
? 'Wird gelöscht …'
|
||||||
|
: 'Raum löschen'
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,35 +1,36 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { create, join } from '@/routes/room';
|
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import { create, join } from '@/routes/room';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="p-6">
|
<main class="p-6">
|
||||||
<div class="flex flex-col gap-6 justify-start items-center min-h-screen">
|
<div
|
||||||
|
class="flex min-h-screen flex-col items-center justify-start gap-6"
|
||||||
|
>
|
||||||
|
<h1 class="mt-10 text-4xl font-extrabold">Welcome to ScrumPoker</h1>
|
||||||
|
|
||||||
<h1 class="text-4xl font-extrabold mt-10"> Welcome to ScrumPoker</h1>
|
<div
|
||||||
|
class="mt-[6%] flex h-full flex-col items-center justify-center space-y-8"
|
||||||
<div class="h-full mt-[6%] space-y-8 flex justify-center flex-col items-center">
|
>
|
||||||
<p class="font-bold"> Create or Join a Room? </p>
|
<p class="font-bold">Create or Join a Room?</p>
|
||||||
<div class=" flex justify-between gap-6 font-extrabold mt-6">
|
<div class="mt-6 flex justify-between gap-6 font-extrabold">
|
||||||
<Link :href="join()">
|
<Link :href="join()">
|
||||||
<p class="bg-gray-600 rounded-2xl min-w-30 px-4 py-2 text-center"> Join</p>
|
<p
|
||||||
|
class="min-w-30 rounded-2xl bg-gray-600 px-4 py-2 text-center"
|
||||||
|
>
|
||||||
|
Join
|
||||||
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
<Link :href="create()">
|
<Link :href="create()">
|
||||||
<p class="bg-gray-600 rounded-2xl min-w-30 px-4 py-2 text-center"> Create</p>
|
<p
|
||||||
|
class="min-w-30 rounded-2xl bg-gray-600 px-4 py-2 text-center"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { store } from '@/routes/room';
|
|
||||||
import { Head, useForm } from '@inertiajs/vue3';
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
|
import { store } from '@/routes/room';
|
||||||
|
|
||||||
type Deck = {
|
type Deck = {
|
||||||
id: number,
|
id: number;
|
||||||
name: string,
|
name: string;
|
||||||
cards: Array<{
|
cards: Array<{
|
||||||
key: string,
|
key: string;
|
||||||
label: string,
|
label: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
decks: Deck[];
|
decks: Deck[];
|
||||||
defaultPlayerName: string | null;
|
defaultPlayerName: string | null;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
title: '',
|
title: '',
|
||||||
@@ -29,26 +28,31 @@ function submit(): void {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<Head title="Raum erstellen" />
|
<Head title="Raum erstellen" />
|
||||||
<main class="mx-auto max-w-lg p-6">
|
<main class="mx-auto max-w-lg p-6">
|
||||||
<h1 class="mb-6 text-2xl font-bold">
|
<h1 class="mb-6 text-2xl font-bold">Scrum Poker Raum erstellen</h1>
|
||||||
Scrum Poker Raum erstellen
|
|
||||||
</h1>
|
|
||||||
<form class="space-y-5" @submit.prevent="submit">
|
<form class="space-y-5" @submit.prevent="submit">
|
||||||
<div>
|
<div>
|
||||||
<label for="player_name" class="mb-1 block font-medium">
|
<label for="player_name" class="mb-1 block font-medium">
|
||||||
Dein Name
|
Dein Name
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input id="player_name" v-model="form.player_name" type="text" maxlength="50" required
|
<input
|
||||||
class="w-full rounded border p-2" placeholder="Eric">
|
id="player_name"
|
||||||
|
v-model="form.player_name"
|
||||||
<p v-if="form.errors.player_name" class="mt-1 text-sm text-red-600">
|
type="text"
|
||||||
|
maxlength="50"
|
||||||
|
required
|
||||||
|
class="w-full rounded border p-2"
|
||||||
|
placeholder="Eric"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="form.errors.player_name"
|
||||||
|
class="mt-1 text-sm text-red-600"
|
||||||
|
>
|
||||||
{{ form.errors.player_name }}
|
{{ form.errors.player_name }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -56,14 +60,18 @@ function submit(): void {
|
|||||||
Raumtitle
|
Raumtitle
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input id="title" v-model="form.title" type="text" maxlength="100" class="w-full rounded border p-2"
|
<input
|
||||||
placeholder="Sprint Planning" />
|
id="title"
|
||||||
|
v-model="form.title"
|
||||||
|
type="text"
|
||||||
|
maxlength="100"
|
||||||
|
class="w-full rounded border p-2"
|
||||||
|
placeholder="Sprint Planning"
|
||||||
|
/>
|
||||||
|
|
||||||
<p v-if="form.errors.title" class="mt-1 text-sm text-red-600">
|
<p v-if="form.errors.title" class="mt-1 text-sm text-red-600">
|
||||||
|
|
||||||
{{ form.errors.title }}
|
{{ form.errors.title }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -71,45 +79,51 @@ function submit(): void {
|
|||||||
Kartendeck
|
Kartendeck
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<select id="deck_id" v-model.number="form.deck_id" required class="w-full rounded border p-2">
|
<select
|
||||||
<option v-for="deck in decks" :key="deck.id" :value="deck.id">
|
id="deck_id"
|
||||||
|
v-model.number="form.deck_id"
|
||||||
|
required
|
||||||
|
class="w-full rounded border p-2"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="deck in decks"
|
||||||
|
:key="deck.id"
|
||||||
|
:value="deck.id"
|
||||||
|
>
|
||||||
{{ deck.name }}
|
{{ deck.name }}
|
||||||
|
|
||||||
</option>
|
</option>
|
||||||
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<p v-if="form.errors.deck_id" class="mt-1 text-sm text-red-600">
|
<p v-if="form.errors.deck_id" class="mt-1 text-sm text-red-600">
|
||||||
|
|
||||||
{{ form.errors.deck_id }}
|
{{ form.errors.deck_id }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="form.deck_id" class="rounded border p-3">
|
<div v-if="form.deck_id" class="rounded border p-3">
|
||||||
<p class="mb-2 text-sm font-medium">
|
<p class="mb-2 text-sm font-medium">Karten:</p>
|
||||||
Karten:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<template v-for="deck in decks" :key="deck.id">
|
<template v-for="deck in decks" :key="deck.id">
|
||||||
<span v-for="card in deck.id === form.deck_id ? deck.cards : []" :key="card.key"
|
<span
|
||||||
class="rounded border px-3 py-1">
|
v-for="card in deck.id === form.deck_id
|
||||||
|
? deck.cards
|
||||||
|
: []"
|
||||||
|
:key="card.key"
|
||||||
|
class="rounded border px-3 py-1"
|
||||||
|
>
|
||||||
{{ card.label }}
|
{{ card.label }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" :disabled="form.processing || decks.length === 0"
|
<button
|
||||||
class="w-full rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50">
|
type="submit"
|
||||||
|
:disabled="form.processing || decks.length === 0"
|
||||||
|
class="w-full rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
{{ form.processing ? 'Raum wird erstellt' : 'Raum erstellen' }}
|
{{ form.processing ? 'Raum wird erstellt' : 'Raum erstellen' }}
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p v-if="decks.length === 0" class="mt-4 text-red-600">
|
<p v-if="decks.length === 0" class="mt-4 text-red-600">
|
||||||
|
|||||||
@@ -1,69 +1,124 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { code} from '@/routes/room';
|
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { computed } from 'vue';
|
||||||
|
import { dashboard } from '@/routes';
|
||||||
|
import { code } from '@/routes/room';
|
||||||
|
|
||||||
const props = defineProps <{
|
const props = defineProps<{
|
||||||
join_code?: string,
|
join_code?: string;
|
||||||
}>()
|
defaultPlayerName: string | null;
|
||||||
|
closedRoom: {
|
||||||
|
title: string | null;
|
||||||
|
join_code: string;
|
||||||
|
} | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const page = usePage();
|
||||||
|
const isAuthenticated = computed(() => page.props.auth.user !== null);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
player_name: '',
|
player_name: props.defaultPlayerName ?? '',
|
||||||
join_code: props.join_code ?? '',
|
join_code: props.join_code ?? '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
function submit(): void {
|
function submit(): void {
|
||||||
form.submit(code());
|
form.submit(code());
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<Head title="Raum beitreten" />
|
||||||
<div class="flex flex-col gap-6 justify-start items-center min-h-screen">
|
|
||||||
|
|
||||||
<h1 class="text-4xl font-extrabold mt-10"> Tritt einer Runde bei </h1>
|
<main
|
||||||
|
class="mx-auto flex min-h-[calc(100svh-8rem)] max-w-lg items-start justify-center pt-10"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
class="w-full rounded-xl border border-white/10 bg-gray-800 p-6"
|
||||||
|
>
|
||||||
|
<template v-if="closedRoom">
|
||||||
|
<p class="text-sm font-semibold text-amber-300">
|
||||||
|
Raum geschlossen
|
||||||
|
</p>
|
||||||
|
<h1 class="mt-2 text-2xl font-bold">
|
||||||
|
{{ closedRoom.title ?? 'Scrum Poker' }}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-3 text-gray-300">
|
||||||
|
Der Raum mit dem Code
|
||||||
|
<strong class="font-mono tracking-wider">{{
|
||||||
|
closedRoom.join_code
|
||||||
|
}}</strong>
|
||||||
|
ist geschlossen und kann nicht mehr betreten werden.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
v-if="isAuthenticated"
|
||||||
|
:href="dashboard()"
|
||||||
|
class="mt-6 inline-flex rounded-lg bg-white px-4 py-2 font-semibold text-gray-900"
|
||||||
|
>
|
||||||
|
Zu meinen Räumen
|
||||||
|
</Link>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="p-6 flex justify-center items-center flex-col gap-8">
|
<template v-else>
|
||||||
|
<h1 class="text-2xl font-bold">Einem Raum beitreten</h1>
|
||||||
|
<p class="mt-2 text-sm text-gray-400">
|
||||||
|
Gib deinen Namen und den sechsstelligen Raumcode ein.
|
||||||
|
</p>
|
||||||
|
|
||||||
<form class="space-y-5" @submit.prevent="submit">
|
<form class="mt-6 space-y-5" @submit.prevent="submit">
|
||||||
<div>
|
<div>
|
||||||
<label for="player_name" class="mb-1 block font-medium">
|
<label for="player_name" class="mb-1 block font-medium"
|
||||||
Player Name
|
>Dein Name</label
|
||||||
</label>
|
>
|
||||||
|
<input
|
||||||
<input id="title" v-model="form.player_name" type="text" maxlength="100"
|
id="player_name"
|
||||||
class="w-full rounded border p-2" placeholder="Max Mustermann" />
|
v-model="form.player_name"
|
||||||
|
type="text"
|
||||||
<p v-if="form.errors.player_name" class="mt-1 text-sm text-red-600">
|
maxlength="50"
|
||||||
|
required
|
||||||
|
class="w-full rounded-lg border border-gray-600 bg-gray-900 p-2.5 text-white"
|
||||||
|
placeholder="Max Mustermann"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="form.errors.player_name"
|
||||||
|
class="mt-1 text-sm text-red-400"
|
||||||
|
>
|
||||||
{{ form.errors.player_name }}
|
{{ form.errors.player_name }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="join_code" class="mb-1 block font-medium">
|
<label for="join_code" class="mb-1 block font-medium"
|
||||||
Raum Code
|
>Raumcode</label
|
||||||
</label>
|
>
|
||||||
|
<input
|
||||||
<input id="title" v-model="form.join_code" type="text" maxlength="100"
|
id="join_code"
|
||||||
class="w-full rounded border p-2" />
|
v-model="form.join_code"
|
||||||
|
type="text"
|
||||||
<p v-if="form.errors.join_code" class="mt-1 text-sm text-red-600">
|
maxlength="6"
|
||||||
|
required
|
||||||
|
class="w-full rounded-lg border border-gray-600 bg-gray-900 p-2.5 font-mono tracking-[0.2em] text-white uppercase"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="form.errors.join_code"
|
||||||
|
class="mt-1 text-sm text-red-400"
|
||||||
|
>
|
||||||
{{ form.errors.join_code }}
|
{{ form.errors.join_code }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" :disabled="form.processing"
|
|
||||||
class="w-full rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50">
|
|
||||||
{{ form.processing ? 'Raum wird beigetreten' : 'Raum beitreten' }}
|
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="w-full rounded-lg bg-blue-600 px-4 py-2.5 font-semibold text-white transition hover:bg-blue-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
form.processing
|
||||||
|
? 'Raum wird geöffnet'
|
||||||
|
: 'Raum beitreten'
|
||||||
|
}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, useForm, usePoll } from '@inertiajs/vue3';
|
import { Head, Link, useForm, usePage, usePoll } from '@inertiajs/vue3';
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
CircleUserRound,
|
CircleUserRound,
|
||||||
@@ -8,9 +8,11 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
X,
|
||||||
} from '@lucide/vue';
|
} from '@lucide/vue';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { login, register } from '@/routes';
|
||||||
import { nextRound, reset as resetRound, reveal } from '@/routes/room';
|
import { nextRound, reset as resetRound, reveal } from '@/routes/room';
|
||||||
import { store as storeVote } from '@/routes/room/vote';
|
import { store as storeVote } from '@/routes/room/vote';
|
||||||
import type { PokerCard, RoomPlayer, SeatStyle } from '@/types/room';
|
import type { PokerCard, RoomPlayer, SeatStyle } from '@/types/room';
|
||||||
@@ -23,6 +25,10 @@ const props = defineProps<{
|
|||||||
current_round: number;
|
current_round: number;
|
||||||
revealed: boolean;
|
revealed: boolean;
|
||||||
state_version: number;
|
state_version: number;
|
||||||
|
statistics: {
|
||||||
|
average: string | null;
|
||||||
|
median: string | null;
|
||||||
|
} | null;
|
||||||
deck: {
|
deck: {
|
||||||
name: string;
|
name: string;
|
||||||
cards: PokerCard[];
|
cards: PokerCard[];
|
||||||
@@ -37,6 +43,15 @@ const props = defineProps<{
|
|||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const page = usePage();
|
||||||
|
const roomNotice = computed(
|
||||||
|
() =>
|
||||||
|
page.props.roomNotice as {
|
||||||
|
guest_retention_days: number;
|
||||||
|
account_retention_days: number;
|
||||||
|
} | null,
|
||||||
|
);
|
||||||
|
|
||||||
const voteForm = useForm({
|
const voteForm = useForm({
|
||||||
value: props.currentPlayer.current_vote ?? '',
|
value: props.currentPlayer.current_vote ?? '',
|
||||||
});
|
});
|
||||||
@@ -44,6 +59,7 @@ const revealForm = useForm<{ room?: string }>({});
|
|||||||
const nextRoundForm = useForm<{ room?: string }>({});
|
const nextRoundForm = useForm<{ room?: string }>({});
|
||||||
const resetForm = useForm<{ room?: string }>({});
|
const resetForm = useForm<{ room?: string }>({});
|
||||||
const copied = ref(false);
|
const copied = ref(false);
|
||||||
|
const roomNoticeDismissed = ref(false);
|
||||||
|
|
||||||
usePoll(2_000, {
|
usePoll(2_000, {
|
||||||
only: ['room', 'currentPlayer'],
|
only: ['room', 'currentPlayer'],
|
||||||
@@ -168,6 +184,44 @@ function playerVote(player: RoomPlayer): string | null {
|
|||||||
<main
|
<main
|
||||||
class="-m-6 min-h-[calc(100svh-4rem)] bg-slate-900 px-3 py-6 text-white sm:px-6 lg:px-10"
|
class="-m-6 min-h-[calc(100svh-4rem)] bg-slate-900 px-3 py-6 text-white sm:px-6 lg:px-10"
|
||||||
>
|
>
|
||||||
|
<aside
|
||||||
|
v-if="roomNotice && !roomNoticeDismissed"
|
||||||
|
class="mx-auto mb-5 flex max-w-6xl flex-col gap-4 rounded-lg border border-dashed border-slate-500 bg-slate-800 p-4 text-sm text-slate-100 sm:flex-row sm:items-center"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="font-semibold">
|
||||||
|
Speichere diesen Raum in deinem Konto
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-slate-300">
|
||||||
|
Als Gast wird der Raum nach
|
||||||
|
{{ roomNotice.guest_retention_days }} Tagen Inaktivität
|
||||||
|
geschlossen. Nach der Anmeldung bleibt er in deinem
|
||||||
|
Dashboard gespeichert und die Frist beträgt
|
||||||
|
{{ roomNotice.account_retention_days }} Tage.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 items-center gap-3 font-semibold sm:ml-auto"
|
||||||
|
>
|
||||||
|
<Link :href="login()" class="underline underline-offset-4">
|
||||||
|
Anmelden
|
||||||
|
</Link>
|
||||||
|
<Link :href="register()" class="underline underline-offset-4">
|
||||||
|
Registrieren
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ml-1 grid size-8 place-items-center rounded-md text-slate-300 transition hover:bg-slate-700 hover:text-white"
|
||||||
|
aria-label="Hinweis schließen"
|
||||||
|
@click="roomNoticeDismissed = true"
|
||||||
|
>
|
||||||
|
<X class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<header
|
<header
|
||||||
class="mx-auto flex max-w-6xl flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
|
class="mx-auto flex max-w-6xl flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
>
|
>
|
||||||
@@ -213,12 +267,27 @@ function playerVote(player: RoomPlayer): string | null {
|
|||||||
}}
|
}}
|
||||||
von {{ room.players.length }} haben gewählt
|
von {{ room.players.length }} haben gewählt
|
||||||
</span>
|
</span>
|
||||||
<span
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="text-sm font-semibold text-amber-200"
|
class="grid grid-cols-2 gap-8 text-emerald-50"
|
||||||
>
|
>
|
||||||
Karten aufgedeckt
|
<div class="flex flex-col gap-1">
|
||||||
</span>
|
<span class="text-xs text-emerald-100/65">
|
||||||
|
Durchschnitt
|
||||||
|
</span>
|
||||||
|
<strong class="text-xl">
|
||||||
|
{{ room.statistics?.average ?? '–' }}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs text-emerald-100/65">
|
||||||
|
Median
|
||||||
|
</span>
|
||||||
|
<strong class="text-xl">
|
||||||
|
{{ room.statistics?.median ?? '–' }}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { edit } from '@/routes/profile';
|
import { edit } from '@/routes/profile';
|
||||||
import { send } from '@/routes/verification';
|
import { send } from '@/routes/verification';
|
||||||
|
import type { User } from '@/types';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
layout: {
|
layout: {
|
||||||
@@ -24,7 +25,7 @@ defineOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
const user = computed(() => page.props.auth.user);
|
const user = computed(() => page.props.auth.user as User);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
Vendored
+4
@@ -18,6 +18,10 @@ declare module '@inertiajs/core' {
|
|||||||
sharedPageProps: {
|
sharedPageProps: {
|
||||||
name: string;
|
name: string;
|
||||||
auth: Auth;
|
auth: Auth;
|
||||||
|
roomNotice: {
|
||||||
|
guest_retention_days: number;
|
||||||
|
account_retention_days: number;
|
||||||
|
} | null;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\CloseInactiveRooms;
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\Schedule;
|
||||||
|
|
||||||
Artisan::command('inspire', function () {
|
Artisan::command('inspire', function () {
|
||||||
$this->comment(Inspiring::quote());
|
$this->comment(Inspiring::quote());
|
||||||
})->purpose('Display an inspiring quote');
|
})->purpose('Display an inspiring quote');
|
||||||
|
|
||||||
|
Schedule::job(new CloseInactiveRooms)
|
||||||
|
->weeklyOn(1, '02:00')
|
||||||
|
->withoutOverlapping();
|
||||||
|
|||||||
@@ -4,13 +4,26 @@ use App\Http\Controllers\RoomController;
|
|||||||
use App\Http\Controllers\RoomPlayerController;
|
use App\Http\Controllers\RoomPlayerController;
|
||||||
use App\Http\Controllers\RoundController;
|
use App\Http\Controllers\RoundController;
|
||||||
use App\Http\Controllers\VoteController;
|
use App\Http\Controllers\VoteController;
|
||||||
|
use App\Models\Room;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::bind(
|
||||||
|
'room',
|
||||||
|
fn (string $value): Room => Room::withTrashed()->findOrFail($value),
|
||||||
|
);
|
||||||
|
|
||||||
Route::get('/room', [RoomController::class, 'create'])->name('room.create');
|
Route::get('/room', [RoomController::class, 'create'])->name('room.create');
|
||||||
Route::get('/room/join', [RoomController::class, 'join'])->name('room.join');
|
Route::get('/room/join', [RoomController::class, 'join'])->name('room.join');
|
||||||
Route::post('room/join', [RoomPlayerController::class, 'join'])->name('room.code');
|
Route::post('room/join', [RoomPlayerController::class, 'join'])->name('room.code');
|
||||||
Route::post('/room', [RoomController::class, 'store'])->name('room.store');
|
Route::post('/room', [RoomController::class, 'store'])->name('room.store');
|
||||||
|
|
||||||
|
Route::middleware(['auth', 'verified'])->group(function () {
|
||||||
|
Route::post('/rooms/{room}/reopen', [RoomController::class, 'reopen'])
|
||||||
|
->name('room.reopen');
|
||||||
|
Route::delete('/rooms/{room}', [RoomController::class, 'destroy'])
|
||||||
|
->name('room.destroy');
|
||||||
|
});
|
||||||
|
|
||||||
Route::middleware('room.player')->group(function () {
|
Route::middleware('room.player')->group(function () {
|
||||||
Route::get('/rooms/{room}', [RoomController::class, 'show'])->name('room.show');
|
Route::get('/rooms/{room}', [RoomController::class, 'show'])->name('room.show');
|
||||||
Route::post('/rooms/{room}/votes', [VoteController::class, 'store'])
|
Route::post('/rooms/{room}/votes', [VoteController::class, 'store'])
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\DashboardController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
require __DIR__.'/settings.php';
|
require __DIR__.'/settings.php';
|
||||||
@@ -7,5 +8,5 @@ require __DIR__.'/room.php';
|
|||||||
|
|
||||||
Route::inertia('/', 'Landingpage')->name('home');
|
Route::inertia('/', 'Landingpage')->name('home');
|
||||||
Route::middleware(['auth', 'verified'])->group(function () {
|
Route::middleware(['auth', 'verified'])->group(function () {
|
||||||
Route::inertia('dashboard', 'Dashboard')->name('dashboard');
|
Route::get('dashboard', DashboardController::class)->name('dashboard');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\CloseInactiveRooms;
|
||||||
|
use App\Models\Deck;
|
||||||
|
use App\Models\Room;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Vote;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
|
||||||
|
function createLifecycleRoom(array $attributes = []): Room
|
||||||
|
{
|
||||||
|
return Room::create([
|
||||||
|
'title' => 'Sprint Planning',
|
||||||
|
'deck_config' => [
|
||||||
|
'name' => 'Fibonacci',
|
||||||
|
'cards' => [
|
||||||
|
['key' => '3', 'label' => '3'],
|
||||||
|
['key' => '5', 'label' => '5'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
...$attributes,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSystemDeck(): Deck
|
||||||
|
{
|
||||||
|
return Deck::query()->forceCreate([
|
||||||
|
'name' => 'Fibonacci',
|
||||||
|
'cards' => [
|
||||||
|
['key' => '3', 'label' => '3'],
|
||||||
|
['key' => '5', 'label' => '5'],
|
||||||
|
],
|
||||||
|
'is_system' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('an authenticated room creator owns the room and sees it on the dashboard', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$deck = createSystemDeck();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('room.store'), [
|
||||||
|
'title' => 'Team Alpha',
|
||||||
|
'player_name' => 'Eric',
|
||||||
|
'deck_id' => $deck->id,
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$room = Room::query()->sole();
|
||||||
|
|
||||||
|
expect($room->owner_id)->toBe($user->id)
|
||||||
|
->and($room->last_activity_at)->not->toBeNull();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('players', [
|
||||||
|
'room_id' => $room->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'is_moderator' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get(route('dashboard'))
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('Dashboard')
|
||||||
|
->has('rooms', 1)
|
||||||
|
->where('rooms.0.id', $room->id)
|
||||||
|
->where('rooms.0.title', 'Team Alpha'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a guest creator receives the one-time account notice', function () {
|
||||||
|
$deck = createSystemDeck();
|
||||||
|
|
||||||
|
$this->post(route('room.store'), [
|
||||||
|
'title' => 'Guest room',
|
||||||
|
'player_name' => 'Guest',
|
||||||
|
'deck_id' => $deck->id,
|
||||||
|
])->assertRedirect();
|
||||||
|
|
||||||
|
$room = Room::query()->sole();
|
||||||
|
|
||||||
|
$this->get(route('room.show', $room))
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->where('roomNotice.guest_retention_days', 14)
|
||||||
|
->where('roomNotice.account_retention_days', 30));
|
||||||
|
|
||||||
|
$this->get(route('room.show', $room))
|
||||||
|
->assertInertia(fn (Assert $page) => $page->where('roomNotice', null));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('guest rooms from the current browser are claimed after login', function () {
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'eric@example.com',
|
||||||
|
]);
|
||||||
|
$room = createLifecycleRoom();
|
||||||
|
$moderator = $room->players()->create([
|
||||||
|
'name' => 'Eric als Gast',
|
||||||
|
'is_moderator' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->withSession(["room_players.{$room->id}" => $moderator->id])
|
||||||
|
->post(route('login.store'), [
|
||||||
|
'email' => 'eric@example.com',
|
||||||
|
'password' => 'password',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect($room->fresh()->owner_id)->toBe($user->id)
|
||||||
|
->and($moderator->fresh()->user_id)->toBe($user->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('joined rooms are not listed as owned rooms', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
$ownedRoom = createLifecycleRoom(['owner_id' => $user->id, 'title' => 'Owned']);
|
||||||
|
$joinedRoom = createLifecycleRoom(['owner_id' => $owner->id, 'title' => 'Joined']);
|
||||||
|
|
||||||
|
$ownedRoom->players()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => 'Owner',
|
||||||
|
'is_moderator' => true,
|
||||||
|
]);
|
||||||
|
$joinedRoom->players()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => 'Participant',
|
||||||
|
'is_moderator' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('dashboard'))
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->has('rooms', 1)
|
||||||
|
->where('rooms.0.id', $ownedRoom->id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an authenticated player rejoins with the existing room identity', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$room = createLifecycleRoom();
|
||||||
|
$player = $room->players()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => 'Room nickname',
|
||||||
|
'is_moderator' => false,
|
||||||
|
]);
|
||||||
|
$room->update(['last_activity_at' => now()->subDay()]);
|
||||||
|
$previousActivity = $room->last_activity_at;
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)
|
||||||
|
->post(route('room.code'), [
|
||||||
|
'join_code' => $room->join_code,
|
||||||
|
'player_name' => 'Ignored new name',
|
||||||
|
])
|
||||||
|
->assertRedirect(route('room.show', $room));
|
||||||
|
|
||||||
|
expect($room->players()->count())->toBe(1)
|
||||||
|
->and($player->fresh()->name)->toBe('Room nickname')
|
||||||
|
->and($room->fresh()->last_activity_at->greaterThan($previousActivity))->toBeTrue();
|
||||||
|
|
||||||
|
$response->assertSessionHas("room_players.{$room->id}", $player->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an authenticated player can restore room access without the previous session', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$room = createLifecycleRoom(['owner_id' => $user->id]);
|
||||||
|
$moderator = $room->players()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => 'Eric',
|
||||||
|
'is_moderator' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('room.show', $room))
|
||||||
|
->assertOk()
|
||||||
|
->assertSessionHas("room_players.{$room->id}", $moderator->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
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([
|
||||||
|
'name' => 'Eric',
|
||||||
|
'is_moderator' => true,
|
||||||
|
]);
|
||||||
|
$activity = $room->last_activity_at;
|
||||||
|
|
||||||
|
$this->withSession(["room_players.{$room->id}" => $moderator->id])
|
||||||
|
->get(route('room.show', $room))
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
expect($room->fresh()->last_activity_at->equalTo($activity))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('meaningful game actions update the explicit activity timestamp', function () {
|
||||||
|
Carbon::setTestNow('2026-08-16 12:00:00');
|
||||||
|
$room = createLifecycleRoom(['last_activity_at' => now()->subDay()]);
|
||||||
|
$moderator = $room->players()->create([
|
||||||
|
'name' => 'Eric',
|
||||||
|
'is_moderator' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->withSession(["room_players.{$room->id}" => $moderator->id])
|
||||||
|
->post(route('room.vote.store', $room), ['value' => '5'])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect($room->fresh()->last_activity_at->equalTo(now()))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the cleanup job closes expired guest and account rooms only once', function () {
|
||||||
|
Carbon::setTestNow('2026-08-16 12:00:00');
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
|
||||||
|
$expiredGuest = createLifecycleRoom(['last_activity_at' => now()->subDays(14)]);
|
||||||
|
$freshGuest = createLifecycleRoom(['last_activity_at' => now()->subDays(14)->addSecond()]);
|
||||||
|
$expiredAccount = createLifecycleRoom([
|
||||||
|
'owner_id' => $owner->id,
|
||||||
|
'last_activity_at' => now()->subDays(30),
|
||||||
|
]);
|
||||||
|
$freshAccount = createLifecycleRoom([
|
||||||
|
'owner_id' => $owner->id,
|
||||||
|
'last_activity_at' => now()->subDays(30)->addSecond(),
|
||||||
|
]);
|
||||||
|
$alreadyClosed = createLifecycleRoom([
|
||||||
|
'last_activity_at' => now()->subDays(40),
|
||||||
|
'closed_at' => now()->subDay(),
|
||||||
|
'state_version' => 7,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$job = new CloseInactiveRooms;
|
||||||
|
$job->handle();
|
||||||
|
$job->handle();
|
||||||
|
|
||||||
|
expect($expiredGuest->fresh()->closed_at)->not->toBeNull()
|
||||||
|
->and($expiredGuest->fresh()->state_version)->toBe(2)
|
||||||
|
->and($expiredAccount->fresh()->closed_at)->not->toBeNull()
|
||||||
|
->and($freshGuest->fresh()->closed_at)->toBeNull()
|
||||||
|
->and($freshAccount->fresh()->closed_at)->toBeNull()
|
||||||
|
->and($alreadyClosed->fresh()->state_version)->toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closed rooms reject joins and participant actions', function () {
|
||||||
|
$room = createLifecycleRoom(['closed_at' => now()]);
|
||||||
|
$moderator = $room->players()->create([
|
||||||
|
'name' => 'Eric',
|
||||||
|
'is_moderator' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->post(route('room.code'), [
|
||||||
|
'join_code' => $room->join_code,
|
||||||
|
'player_name' => 'Anna',
|
||||||
|
])->assertSessionHasErrors('join_code');
|
||||||
|
|
||||||
|
$this->get(route('room.join', ['join_code' => $room->join_code]))
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('rooms/Join')
|
||||||
|
->where('closedRoom.join_code', $room->join_code));
|
||||||
|
|
||||||
|
$this->withSession(["room_players.{$room->id}" => $moderator->id])
|
||||||
|
->post(route('room.vote.store', $room), ['value' => '5'])
|
||||||
|
->assertRedirect(route('room.join', ['join_code' => $room->join_code]));
|
||||||
|
|
||||||
|
expect(Vote::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only the owner can reopen a room into a clean new round', function () {
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$room = createLifecycleRoom([
|
||||||
|
'owner_id' => $owner->id,
|
||||||
|
'current_round' => 3,
|
||||||
|
'revealed_at' => now()->subDay(),
|
||||||
|
'closed_at' => now(),
|
||||||
|
'last_activity_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
$moderator = $room->players()->create([
|
||||||
|
'user_id' => $owner->id,
|
||||||
|
'name' => 'Owner',
|
||||||
|
'is_moderator' => true,
|
||||||
|
]);
|
||||||
|
$vote = $moderator->votes()->create([
|
||||||
|
'room_id' => $room->id,
|
||||||
|
'round' => 3,
|
||||||
|
'value' => '5',
|
||||||
|
]);
|
||||||
|
$joinCode = $room->join_code;
|
||||||
|
|
||||||
|
$this->actingAs($otherUser)
|
||||||
|
->post(route('room.reopen', $room))
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$this->actingAs($owner)
|
||||||
|
->post(route('room.reopen', $room))
|
||||||
|
->assertRedirect(route('room.show', $room));
|
||||||
|
|
||||||
|
$room->refresh();
|
||||||
|
|
||||||
|
expect($room->current_round)->toBe(4)
|
||||||
|
->and($room->revealed_at)->toBeNull()
|
||||||
|
->and($room->closed_at)->toBeNull()
|
||||||
|
->and($room->last_activity_at->isToday())->toBeTrue()
|
||||||
|
->and($room->join_code)->toBe($joinCode)
|
||||||
|
->and($vote->fresh())->not->toBeNull()
|
||||||
|
->and($room->votes()->where('round', 4)->exists())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only the owner can delete a room', function () {
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$room = createLifecycleRoom(['owner_id' => $owner->id]);
|
||||||
|
$room->players()->create([
|
||||||
|
'user_id' => $owner->id,
|
||||||
|
'name' => 'Owner',
|
||||||
|
'is_moderator' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($otherUser)
|
||||||
|
->delete(route('room.destroy', $room))
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$this->actingAs($owner)
|
||||||
|
->delete(route('room.destroy', $room))
|
||||||
|
->assertRedirect(route('dashboard'));
|
||||||
|
|
||||||
|
$this->assertSoftDeleted('rooms', ['id' => $room->id]);
|
||||||
|
|
||||||
|
$this->get(route('dashboard'))
|
||||||
|
->assertInertia(fn (Assert $page) => $page->has('rooms', 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('participants are redirected when an open room is deleted', function () {
|
||||||
|
$room = createLifecycleRoom();
|
||||||
|
$player = $room->players()->create([
|
||||||
|
'name' => 'Participant',
|
||||||
|
'is_moderator' => false,
|
||||||
|
]);
|
||||||
|
$sessionKey = "room_players.{$room->id}";
|
||||||
|
|
||||||
|
$room->delete();
|
||||||
|
|
||||||
|
$this->withSession([$sessionKey => $player->id])
|
||||||
|
->get(route('room.show', $room))
|
||||||
|
->assertRedirect(route('home'))
|
||||||
|
->assertSessionMissing($sessionKey);
|
||||||
|
});
|
||||||
@@ -33,6 +33,8 @@ function createVotingRoom(): array
|
|||||||
|
|
||||||
test('a participant can vote and change their vote in the current round', function () {
|
test('a participant can vote and change their vote in the current round', function () {
|
||||||
[$room, $moderator] = createVotingRoom();
|
[$room, $moderator] = createVotingRoom();
|
||||||
|
$room->update(['last_activity_at' => now()->subDay()]);
|
||||||
|
$previousActivity = $room->last_activity_at;
|
||||||
|
|
||||||
$session = ["room_players.{$room->id}" => $moderator->id];
|
$session = ["room_players.{$room->id}" => $moderator->id];
|
||||||
|
|
||||||
@@ -52,7 +54,8 @@ test('a participant can vote and change their vote in the current round', functi
|
|||||||
->assertRedirect();
|
->assertRedirect();
|
||||||
|
|
||||||
expect(Vote::query()->count())->toBe(1);
|
expect(Vote::query()->count())->toBe(1);
|
||||||
expect(Vote::query()->value('value'))->toBe('8');
|
expect(Vote::query()->value('value'))->toBe('8')
|
||||||
|
->and($room->fresh()->last_activity_at->greaterThan($previousActivity))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a participant cannot vote with a card outside the room deck', function () {
|
test('a participant cannot vote with a card outside the room deck', function () {
|
||||||
@@ -67,6 +70,8 @@ test('a participant cannot vote with a card outside the room deck', function ()
|
|||||||
|
|
||||||
test('other votes stay hidden until the moderator reveals the round', function () {
|
test('other votes stay hidden until the moderator reveals the round', function () {
|
||||||
[$room, $moderator, $player] = createVotingRoom();
|
[$room, $moderator, $player] = createVotingRoom();
|
||||||
|
$room->update(['last_activity_at' => now()->subDay()]);
|
||||||
|
$previousActivity = $room->last_activity_at;
|
||||||
|
|
||||||
$player->votes()->create([
|
$player->votes()->create([
|
||||||
'room_id' => $room->id,
|
'room_id' => $room->id,
|
||||||
@@ -88,6 +93,8 @@ test('other votes stay hidden until the moderator reveals the round', function (
|
|||||||
->post(route('room.reveal', $room))
|
->post(route('room.reveal', $room))
|
||||||
->assertRedirect();
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect($room->fresh()->last_activity_at->greaterThan($previousActivity))->toBeTrue();
|
||||||
|
|
||||||
$this->withSession($session)
|
$this->withSession($session)
|
||||||
->get(route('room.show', $room))
|
->get(route('room.show', $room))
|
||||||
->assertInertia(fn (Assert $page) => $page
|
->assertInertia(fn (Assert $page) => $page
|
||||||
@@ -108,7 +115,11 @@ test('only the moderator may reveal cards', function () {
|
|||||||
test('starting the next round clears the revealed state', function () {
|
test('starting the next round clears the revealed state', function () {
|
||||||
[$room, $moderator] = createVotingRoom();
|
[$room, $moderator] = createVotingRoom();
|
||||||
|
|
||||||
$room->update(['revealed_at' => now()]);
|
$room->update([
|
||||||
|
'revealed_at' => now(),
|
||||||
|
'last_activity_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
$previousActivity = $room->last_activity_at;
|
||||||
|
|
||||||
$this->withSession(["room_players.{$room->id}" => $moderator->id])
|
$this->withSession(["room_players.{$room->id}" => $moderator->id])
|
||||||
->post(route('room.next-round', $room))
|
->post(route('room.next-round', $room))
|
||||||
@@ -117,7 +128,8 @@ test('starting the next round clears the revealed state', function () {
|
|||||||
$room->refresh();
|
$room->refresh();
|
||||||
|
|
||||||
expect($room->current_round)->toBe(2)
|
expect($room->current_round)->toBe(2)
|
||||||
->and($room->revealed_at)->toBeNull();
|
->and($room->revealed_at)->toBeNull()
|
||||||
|
->and($room->last_activity_at->greaterThan($previousActivity))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the moderator can reset only the votes from the current round', function () {
|
test('the moderator can reset only the votes from the current round', function () {
|
||||||
@@ -126,7 +138,9 @@ test('the moderator can reset only the votes from the current round', function (
|
|||||||
$room->update([
|
$room->update([
|
||||||
'current_round' => 2,
|
'current_round' => 2,
|
||||||
'revealed_at' => now(),
|
'revealed_at' => now(),
|
||||||
|
'last_activity_at' => now()->subDay(),
|
||||||
]);
|
]);
|
||||||
|
$previousActivity = $room->last_activity_at;
|
||||||
|
|
||||||
$player->votes()->create([
|
$player->votes()->create([
|
||||||
'room_id' => $room->id,
|
'room_id' => $room->id,
|
||||||
@@ -156,5 +170,41 @@ test('the moderator can reset only the votes from the current round', function (
|
|||||||
'round' => 2,
|
'round' => 2,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect($room->fresh()->revealed_at)->toBeNull();
|
$room->refresh();
|
||||||
|
|
||||||
|
expect($room->revealed_at)->toBeNull()
|
||||||
|
->and($room->last_activity_at->greaterThan($previousActivity))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revealed rounds show the average and median of numeric votes', function () {
|
||||||
|
[$room, $moderator, $player] = createVotingRoom();
|
||||||
|
$thirdPlayer = $room->players()->create([
|
||||||
|
'name' => 'Nina',
|
||||||
|
'is_moderator' => false,
|
||||||
|
]);
|
||||||
|
$nonNumericPlayer = $room->players()->create([
|
||||||
|
'name' => 'Coffee',
|
||||||
|
'is_moderator' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
[$moderator, '3'],
|
||||||
|
[$player, '5'],
|
||||||
|
[$thirdPlayer, '8'],
|
||||||
|
[$nonNumericPlayer, 'coffee'],
|
||||||
|
] as [$votingPlayer, $value]) {
|
||||||
|
$votingPlayer->votes()->create([
|
||||||
|
'room_id' => $room->id,
|
||||||
|
'round' => 1,
|
||||||
|
'value' => $value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$room->update(['revealed_at' => now()]);
|
||||||
|
|
||||||
|
$this->withSession(["room_players.{$room->id}" => $moderator->id])
|
||||||
|
->get(route('room.show', $room))
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->where('room.statistics.average', '5,33')
|
||||||
|
->where('room.statistics.median', '5,00'));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user