Compare commits

...
Author SHA1 Message Date
Twyco 4dfe79ce5c kick forever
CI/CD / test_and_check_app (push) Successful in 2m19s
CI/CD / Publish immutable image (push) Successful in 1m16s
2026-09-24 14:38:18 +02:00
Twyco b190ff719e audit fix
CI/CD / Publish immutable image (push) Successful in 2m28s
CI/CD / test_and_check_app (push) Successful in 3m43s
2026-09-10 19:16:00 +02:00
Twyco 54d23c3660 Merge pull request #10 from ericasch6/codex/guest-play
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 17s
2026-09-10 17:09:18 +02:00
23 changed files with 275 additions and 116 deletions
@@ -32,7 +32,6 @@ class RoomJoinController extends Controller
$room !== null
&& ! $room->isClosed()
&& $existingPlayer instanceof Player
&& $existingPlayer->inactive_at === null
) {
return to_route('room.show', $room);
}
@@ -113,12 +113,6 @@ class EnsureRoomParticipant
]);
}
if ($player->inactive_at !== null) {
return to_route('room.join', [
'join_code' => $room->join_code,
]);
}
$player->markAsSeen();
$request->attributes->set('currentPlayer', $player);
+1 -1
View File
@@ -48,7 +48,7 @@ class KickRoomPlayerRequest extends FormRequest
return null;
}
return $room->activePlayers()->find((int) $playerId);
return $room->players()->find((int) $playerId);
}
/**
+2 -3
View File
@@ -18,9 +18,8 @@ class DashboardRoomResource extends JsonResource
'join_code' => $this->resource->join_code,
'current_round' => $this->resource->current_round,
'players_count' => $this->resource->players_count,
'active_player_count' => $this->resource->active_player_count,
'players' => $this->resource->relationLoaded('activePlayers')
? $this->resource->activePlayers
'players' => $this->resource->relationLoaded('players')
? $this->resource->players
->map(fn ($player): array => [
'id' => $player->id,
'name' => $player->name,
-22
View File
@@ -1,22 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\Player;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class MarkInactivePlayers implements ShouldQueue
{
use Queueable;
public function handle(): void
{
Player::query()
->whereNull('inactive_at')
->where('last_seen_at', '<=', now()->subHour())
->update([
'inactive_at' => now(),
]);
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace App\Jobs;
use App\Enums\RoomStateChange;
use App\Events\RoomStateChanged;
use App\Models\Player;
use App\Models\Room;
use App\Services\Rooms\RoomRealtimeStateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
class RemoveAbsentPlayers implements ShouldQueue
{
use Queueable;
public function handle(RoomRealtimeStateService $realtimeState): void
{
$cutoff = now()->subHour();
Player::query()
->where(function ($query) use ($cutoff): void {
$query->where('last_seen_at', '<=', $cutoff)
->orWhere(function ($query) use ($cutoff): void {
$query->whereNull('last_seen_at')
->where('created_at', '<=', $cutoff);
});
})
->select(['id', 'room_id'])
->chunkById(100, function ($players) use ($cutoff, $realtimeState): void {
foreach ($players as $player) {
$room = DB::transaction(function () use ($player, $cutoff): ?Room {
$room = Room::withTrashed()
->lockForUpdate()
->find($player->room_id);
if ($room === null) {
return null;
}
$currentPlayer = $room->players()
->lockForUpdate()
->find($player->id);
if ($currentPlayer === null) {
return null;
}
$lastActivity = $currentPlayer->last_seen_at ?? $currentPlayer->created_at;
if ($lastActivity === null || $lastActivity->greaterThan($cutoff)) {
return null;
}
$currentPlayer->delete();
$room->update([
'state_version' => $room->state_version + 1,
]);
return $room;
});
if ($room !== null && ! $room->trashed()) {
RoomStateChanged::dispatch(
$room->id,
$room->state_version,
RoomStateChange::PlayerKicked,
$realtimeState->for($room),
$player->id,
);
}
}
});
}
}
-14
View File
@@ -2,7 +2,6 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -16,7 +15,6 @@ use Illuminate\Support\Carbon;
* @property string $name
* @property bool $is_moderator
* @property Carbon|null $last_seen_at
* @property Carbon|null $inactive_at
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Room $room
@@ -30,7 +28,6 @@ class Player extends Model
'name',
'is_moderator',
'last_seen_at',
'inactive_at',
];
protected function casts(): array
@@ -38,7 +35,6 @@ class Player extends Model
return [
'is_moderator' => 'boolean',
'last_seen_at' => 'datetime',
'inactive_at' => 'datetime',
];
}
@@ -85,14 +81,4 @@ class Player extends Model
'last_seen_at' => now(),
]);
}
/**
* @param Builder<Player> $query
* @return Builder<Player>
*/
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('inactive_at');
}
}
-6
View File
@@ -93,12 +93,6 @@ class Room extends Model
}
/** @return HasMany<Player, $this> */
public function activePlayers(): HasMany
{
return $this->hasMany(Player::class)->whereNull('inactive_at');
}
/** @return BelongsTo<User, $this> */
public function owner(): BelongsTo
{
+1 -3
View File
@@ -16,13 +16,12 @@ class DashboardRoomService
$ownedRooms = $user
->ownedRooms()
->with([
'activePlayers' => fn ($query) => $query
'players' => fn ($query) => $query
->select(['id', 'room_id', 'user_id', 'name', 'is_moderator', 'created_at'])
->orderByDesc('is_moderator')
->orderBy('created_at'),
])
->withCount('players')
->withCount(['activePlayers as active_player_count'])
->orderByRaw('closed_at IS NOT NULL')
->orderByDesc('last_activity_at')
->get();
@@ -30,7 +29,6 @@ class DashboardRoomService
$favoriteRooms = $user
->favoriteRooms()
->withCount('players')
->withCount(['activePlayers as active_player_count'])
->orderByRaw('closed_at IS NOT NULL')
->orderByDesc('last_activity_at')
->get();
-1
View File
@@ -20,7 +20,6 @@ class RoomPageService
$room->load([
'players' => fn ($query) => $query
->active()
->select(['id', 'room_id', 'user_id', 'name', 'is_moderator', 'created_at'])
->orderByDesc('is_moderator')
->orderBy('created_at'),
@@ -80,7 +80,6 @@ class RoomParticipationService
} else {
$player->update([
'last_seen_at' => now(),
'inactive_at' => null,
]);
}
@@ -150,16 +149,13 @@ class RoomParticipationService
]);
$change = RoomStateChange::PlayerJoined;
} else {
if ($player->inactive_at !== null) {
$change = RoomStateChange::PlayerJoined;
} elseif ($player->name !== $playerName) {
if ($player->name !== $playerName) {
$change = RoomStateChange::PlayerUpdated;
}
$updates = [
'name' => $playerName,
'last_seen_at' => now(),
'inactive_at' => null,
];
if (
@@ -282,14 +278,14 @@ class RoomParticipationService
?User $user,
?Player $currentPlayer,
): void {
$stateVersion = DB::transaction(function () use ($room, $player, $user, $currentPlayer): ?int {
$stateVersion = DB::transaction(function () use ($room, $player, $user, $currentPlayer): int {
$lockedRoom = Room::query()
->lockForUpdate()
->findOrFail($room->id);
$lockedCurrentPlayer = $currentPlayer === null
? null
: $lockedRoom->activePlayers()
: $lockedRoom->players()
->lockForUpdate()
->find($currentPlayer->id);
$isOwner = $user !== null && $lockedRoom->owner_id === $user->id;
@@ -312,13 +308,7 @@ class RoomParticipationService
403,
);
if ($lockedPlayer->inactive_at !== null) {
return null;
}
$lockedPlayer->update([
'inactive_at' => now(),
]);
$lockedPlayer->delete();
$lockedRoom->update([
'last_activity_at' => now(),
'state_version' => $lockedRoom->state_version + 1,
@@ -327,10 +317,6 @@ class RoomParticipationService
return $lockedRoom->state_version;
});
if ($stateVersion === null) {
return;
}
RoomStateChanged::dispatch(
$room->id,
$stateVersion,
@@ -32,7 +32,6 @@ class RoomRealtimeStateService
$room->refresh();
$room->load([
'players' => fn ($query) => $query
->active()
->select(['id', 'room_id', 'user_id', 'name', 'is_moderator', 'created_at'])
->orderByDesc('is_moderator')
->orderBy('created_at'),
@@ -0,0 +1,42 @@
<?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
{
/**
* Run the migrations.
*/
public function up(): void
{
$cutoff = now()->subHour();
DB::table('players')
->where(function ($query) use ($cutoff): void {
$query->whereNotNull('inactive_at')
->orWhere('last_seen_at', '<=', $cutoff)
->orWhere(function ($query) use ($cutoff): void {
$query->whereNull('last_seen_at')
->where('created_at', '<=', $cutoff);
});
})
->delete();
Schema::table('players', function (Blueprint $table): void {
$table->dropColumn('inactive_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('players', function (Blueprint $table): void {
$table->timestamp('inactive_at')->nullable()->after('last_seen_at');
});
}
};
+4 -3
View File
@@ -4,6 +4,7 @@
"requires": true,
"packages": {
"": {
"name": "scrum-poker-internal",
"dependencies": {
"@inertiajs/vite": "^3.0.0",
"@inertiajs/vue3": "^3.0.0",
@@ -4959,9 +4960,9 @@
}
},
"node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{
@@ -47,8 +47,7 @@ function formatDate(value: string | null): string {
<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 /
{{ room.active_player_count }} aktiv
{{ room.players_count }} Spieler
</span>
<span>Runde {{ room.current_round }}</span>
<span class="col-span-2 inline-flex items-center gap-2">
+1 -5
View File
@@ -85,11 +85,7 @@ defineProps<{
<div class="flex items-center justify-between">
<Label for="remember" class="flex items-center space-x-3">
<Checkbox
id="remember"
name="remember"
:tabindex="3"
/>
<Checkbox id="remember" name="remember" :tabindex="3" />
<span>Remember me</span>
</Label>
</div>
-1
View File
@@ -11,7 +11,6 @@ export type DashboardRoom = {
join_code: string;
current_round: number;
players_count: number;
active_player_count: number;
players: DashboardRoomPlayer[];
last_activity_at: string | null;
closed_at: string | null;
+2 -2
View File
@@ -1,7 +1,7 @@
<?php
use App\Jobs\CloseInactiveRooms;
use App\Jobs\MarkInactivePlayers;
use App\Jobs\RemoveAbsentPlayers;
use Illuminate\Foundation\DevCommands;
use Illuminate\Support\Facades\Schedule;
@@ -11,6 +11,6 @@ Schedule::job(new CloseInactiveRooms)
->weeklyOn(1, '02:00')
->withoutOverlapping();
Schedule::job(new MarkInactivePlayers)
Schedule::job(new RemoveAbsentPlayers)
->everyFiveMinutes()
->withoutOverlapping();
+94
View File
@@ -0,0 +1,94 @@
<?php
use App\Enums\RoomStateChange;
use App\Events\RoomStateChanged;
use App\Jobs\RemoveAbsentPlayers;
use App\Models\Room;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
test('the player table no longer stores an inactive status', function () {
expect(Schema::hasColumn('players', 'inactive_at'))->toBeFalse();
});
test('the migration removes old inactive and absent players while keeping present players', function () {
Schema::table('players', function (Blueprint $table): void {
$table->timestamp('inactive_at')->nullable();
});
$room = Room::create([
'title' => 'Planning',
'deck_config' => ['name' => 'Fibonacci', 'cards' => []],
]);
$previouslyInactive = $room->players()->create([
'name' => 'Inactive',
'last_seen_at' => now(),
]);
$absent = $room->players()->create([
'name' => 'Nico',
'last_seen_at' => now()->subHours(2),
]);
$present = $room->players()->create([
'name' => 'Anna',
'last_seen_at' => now(),
]);
DB::table('players')->where('id', $previouslyInactive->id)->update(['inactive_at' => now()]);
$migration = require database_path('migrations/2026_09_24_123048_remove_previously_inactive_players.php');
$migration->up();
$this->assertModelMissing($previouslyInactive);
$this->assertModelMissing($absent);
$this->assertModelExists($present);
expect(Schema::hasColumn('players', 'inactive_at'))->toBeFalse();
});
test('the timeout removes absent players and their votes but keeps recent players', function () {
$room = Room::create([
'title' => 'Planning',
'current_round' => 1,
'deck_config' => ['name' => 'Fibonacci', 'cards' => []],
]);
$absentPlayer = $room->players()->create([
'name' => 'Nico',
'last_seen_at' => now()->subHour(),
]);
$recentPlayer = $room->players()->create([
'name' => 'Anna',
'last_seen_at' => now()->subHour()->addSecond(),
]);
$vote = $absentPlayer->votes()->create([
'room_id' => $room->id,
'round' => $room->current_round,
'value' => '3',
]);
$previousVersion = $room->fresh()->state_version;
Event::fake([RoomStateChanged::class]);
app()->call([new RemoveAbsentPlayers, 'handle']);
app()->call([new RemoveAbsentPlayers, 'handle']);
$this->assertModelMissing($absentPlayer);
$this->assertModelMissing($vote);
$this->assertModelExists($recentPlayer);
expect($room->fresh()->state_version)->toBe($previousVersion + 1);
Event::assertDispatched(
RoomStateChanged::class,
fn (RoomStateChanged $event): bool => $event->change === RoomStateChange::PlayerKicked
&& $event->affectedPlayerId === $absentPlayer->id
&& collect($event->state['players'])->doesntContain('id', $absentPlayer->id),
);
Event::assertDispatchedTimes(RoomStateChanged::class, 1);
$this->post(route('room.code'), [
'join_code' => $room->join_code,
'player_name' => 'Nico',
])->assertRedirect(route('room.show', $room));
expect($room->players()->where('name', 'Nico')->sole()->id)->not->toBe($absentPlayer->id);
});
+2 -6
View File
@@ -46,7 +46,6 @@ test('embedded guest access reuses the session player and applies a changed disp
$room = createEmbeddedRoom();
$player = $room->players()->create([
'name' => 'Old name',
'inactive_at' => now(),
]);
$this->withSession(["room_players.{$room->id}" => $player->id])
@@ -57,8 +56,7 @@ test('embedded guest access reuses the session player and applies a changed disp
->assertOk();
expect($room->players()->count())->toBe(1)
->and($player->fresh()->name)->toBe('New name')
->and($player->fresh()->inactive_at)->toBeNull();
->and($player->fresh()->name)->toBe('New name');
});
test('an authenticated user automatically joins the embedded room', function () {
@@ -92,7 +90,6 @@ test('embedded access reuses the user player and applies a changed display name'
$player = $room->players()->create([
'user_id' => $user->id,
'name' => 'Old name',
'inactive_at' => now(),
]);
$this->actingAs($user)
@@ -103,8 +100,7 @@ test('embedded access reuses the user player and applies a changed display name'
->assertOk();
expect($room->players()->count())->toBe(1)
->and($player->fresh()->name)->toBe('New name')
->and($player->fresh()->inactive_at)->toBeNull();
->and($player->fresh()->name)->toBe('New name');
});
test('an embedded guest keeps the same player after logging in separately', function () {
+1 -7
View File
@@ -20,11 +20,6 @@ test('favorited rooms are presented on the dashboard with player counts', functi
'name' => 'Active player',
'last_seen_at' => now(),
]);
$room->players()->create([
'name' => 'Inactive player',
'last_seen_at' => now()->subHour(),
'inactive_at' => now(),
]);
$this->actingAs($user)
->post(route('room.favorite', $room))
@@ -39,8 +34,7 @@ test('favorited rooms are presented on the dashboard with player counts', functi
->assertInertia(fn (Assert $page) => $page
->has('favoriteRooms', 1)
->where('favoriteRooms.0.id', $room->id)
->where('favoriteRooms.0.players_count', 2)
->where('favoriteRooms.0.active_player_count', 1));
->where('favoriteRooms.0.players_count', 1));
});
test('the room resource exposes favorite and owner state', function () {
+5 -4
View File
@@ -216,15 +216,16 @@ test('an active guest with a room session skips the join form', function () {
->assertRedirect(route('room.show', $room));
});
test('an inactive guest is sent through join and reuses the existing player', function () {
test('a removed guest joins again with a new player identity', function () {
$room = createLifecycleRoom();
$player = $room->players()->create([
'name' => 'Returning guest',
'last_seen_at' => now()->subHours(2),
'inactive_at' => now()->subHour(),
]);
$session = ["room_players.{$room->id}" => $player->id];
$player->delete();
$this->withSession($session)
->get(route('room.show', $room))
->assertRedirect(route('room.join', ['join_code' => $room->join_code]));
@@ -233,7 +234,7 @@ test('an inactive guest is sent through join and reuses the existing player', fu
->get(route('room.join', ['join_code' => $room->join_code]))
->assertInertia(fn (Assert $page) => $page
->component('rooms/Join')
->where('defaultPlayerName', 'Returning guest'));
->where('defaultPlayerName', null));
$this->withSession($session)
->post(route('room.code'), [
@@ -243,7 +244,7 @@ test('an inactive guest is sent through join and reuses the existing player', fu
->assertRedirect(route('room.show', $room));
expect($room->players()->count())->toBe(1)
->and($player->fresh()->inactive_at)->toBeNull();
->and($room->players()->sole()->id)->not->toBe($player->id);
});
test('showing or polling a room does not count as room activity', function () {
+39 -10
View File
@@ -44,7 +44,6 @@ test('the room owner sees active room players on the dashboard', function () {
$room = createRoomForPlayerKick($owner);
$moderator = addPlayerToKickRoom($room, 'Moderator', $owner, true);
$participant = addPlayerToKickRoom($room, 'Anna');
addPlayerToKickRoom($room, 'Inactive')->update(['inactive_at' => now()]);
$this->actingAs($owner)
->get(route('dashboard'))
@@ -77,8 +76,8 @@ test('the room owner can kick an active participant', function () {
->delete(route('room.player.destroy', [$room, $participant]))
->assertRedirect();
expect($participant->fresh()->inactive_at)->not->toBeNull()
->and($room->fresh()->state_version)->toBe($previousVersion + 1);
$this->assertModelMissing($participant);
expect($room->fresh()->state_version)->toBe($previousVersion + 1);
Event::assertDispatched(
RoomStateChanged::class,
@@ -104,6 +103,36 @@ test('the room owner can kick an active participant', function () {
Event::assertDispatchedTimes(RoomStateChanged::class, 1);
});
test('a kicked guest loses their votes and their name becomes available', function () {
$owner = User::factory()->create();
$room = createRoomForPlayerKick($owner);
addPlayerToKickRoom($room, 'Moderator', $owner, true);
$guest = addPlayerToKickRoom($room, 'Nico');
$vote = $guest->votes()->create([
'room_id' => $room->id,
'round' => $room->current_round,
'value' => '3',
]);
$this->actingAs($owner)
->delete(route('room.player.destroy', [$room, $guest]))
->assertRedirect();
$this->assertModelMissing($guest);
$this->assertModelMissing($vote);
auth()->logout();
$this->withSession(["room_players.{$room->id}" => $guest->id])
->post(route('room.code'), [
'join_code' => $room->join_code,
'player_name' => 'Nico',
])
->assertRedirect(route('room.show', $room));
expect($room->players()->where('name', 'Nico')->sole()->id)->not->toBe($guest->id);
});
test('the room moderator can kick an active participant without shared management', function () {
$owner = User::factory()->create();
$room = createRoomForPlayerKick($owner, ['owner_id' => null]);
@@ -116,7 +145,7 @@ test('the room moderator can kick an active participant without shared managemen
->delete(route('room.player.destroy', [$room, $target]))
->assertRedirect();
expect($target->fresh()->inactive_at)->not->toBeNull();
$this->assertModelMissing($target);
Event::assertDispatchedTimes(RoomStateChanged::class, 1);
});
@@ -135,7 +164,7 @@ test('a participant cannot kick another player when participant management is di
->delete(route('room.player.destroy', [$room, $target]))
->assertForbidden();
expect($target->fresh()->inactive_at)->toBeNull();
$this->assertModelExists($target);
Event::assertNotDispatched(RoomStateChanged::class);
});
@@ -154,7 +183,7 @@ test('a participant can kick another player when participant management is enabl
->delete(route('room.player.destroy', [$room, $target]))
->assertRedirect();
expect($target->fresh()->inactive_at)->not->toBeNull();
$this->assertModelMissing($target);
Event::assertDispatched(RoomStateChanged::class);
});
@@ -171,7 +200,7 @@ test('a participant cannot kick themselves when participant management is enable
->delete(route('room.player.destroy', [$room, $participant]))
->assertForbidden();
expect($participant->fresh()->inactive_at)->toBeNull();
$this->assertModelExists($participant);
Event::assertNotDispatched(RoomStateChanged::class);
});
@@ -189,7 +218,7 @@ test('an anonymous visitor without a room session cannot kick from an ownerless
$this->delete(route('room.player.destroy', [$room, $target]))
->assertForbidden();
expect($target->fresh()->inactive_at)->toBeNull();
$this->assertModelExists($target);
Event::assertNotDispatched(RoomStateChanged::class);
});
@@ -204,7 +233,7 @@ test('the room owner cannot kick their own player', function () {
->delete(route('room.player.destroy', [$room, $moderator]))
->assertForbidden();
expect($moderator->fresh()->inactive_at)->toBeNull();
$this->assertModelExists($moderator);
Event::assertNotDispatched(RoomStateChanged::class);
});
@@ -221,6 +250,6 @@ test('the room owner cannot kick a player from another room', function () {
->delete(route('room.player.destroy', [$room, $target]))
->assertForbidden();
expect($target->fresh()->inactive_at)->toBeNull();
$this->assertModelExists($target);
Event::assertNotDispatched(RoomStateChanged::class);
});