Compare commits

..
9 Commits
Author SHA1 Message Date
Twyco 2ddba0c8b3 formatted
CI/CD / test_and_check_app (push) Successful in 10s
CI/CD / Publish immutable image (push) Successful in 2m26s
2026-10-22 00:30:50 +02:00
Twyco 8c55af6573 change to pnpm
CI/CD / test_and_check_app (push) Failing after 1m45s
CI/CD / Publish immutable image (push) Skipped
2026-10-22 00:20:39 +02:00
Twyco 71208f9b44 formatting & security fixes
CI/CD / test_and_check_app (push) Failing after 22s
CI/CD / Publish immutable image (push) Skipped
2026-10-22 00:11:26 +02:00
Twyco e8103d4182 pnpm security fix
CI/CD / test_and_check_app (push) Failing after 25s
CI/CD / Publish immutable image (push) Skipped
2026-10-21 23:49:51 +02:00
Twyco a0ea0769c7 fix dockerignore
CI/CD / test_and_check_app (push) Failing after 14s
CI/CD / Publish immutable image (push) Skipped
2026-10-21 23:40:27 +02:00
Twyco bd22399736 change audit level to critical
CI/CD / test_and_check_app (push) Failing after 1m0s
CI/CD / Publish immutable image (push) Skipped
2026-10-21 23:31:24 +02:00
Twyco edb6fd92ef security-fix 2026-10-21 23:26:45 +02:00
Twyco 33df9e49ab Keep guest rooms moderated and restore owner moderation
CI/CD / test_and_check_app (push) Successful in 2m22s
CI/CD / Publish immutable image (push) Successful in 1m16s
2026-09-24 15:02:05 +02:00
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
35 changed files with 591 additions and 8073 deletions
@@ -1,6 +1,6 @@
--- ---
name: wayfinder-development name: wayfinder-development
description: "Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task" description: "Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only tasks"
license: MIT license: MIT
metadata: metadata:
author: laravel author: laravel
+1 -1
View File
@@ -33,7 +33,7 @@
vendor vendor
**/.DS_Store **/.DS_Store
.editorconfig .editorconfig
.env.example !.env.example
.env.mariadb .env.mariadb
.env.mariadb.example .env.mariadb.example
.env.production .env.production
+2 -2
View File
@@ -34,7 +34,7 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
## Frontend Bundling ## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. - If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `pnpm run build`, `pnpm run dev`, or `composer run dev`. Ask them.
## Documentation Files ## Documentation Files
@@ -162,7 +162,7 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
## Vite Error ## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. - If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `pnpm run build` or ask the user to run `pnpm run dev` or `composer run dev`.
=== wayfinder/core rules === === wayfinder/core rules ===
+6 -5
View File
@@ -1,6 +1,6 @@
ARG COMPOSER_IMAGE=composer:2.9.5@sha256:698d3801b2a622ace460c4743c781282fcbcb733a4cbf8b31c44731e846585e8 ARG COMPOSER_IMAGE=composer:2.9.5@sha256:698d3801b2a622ace460c4743c781282fcbcb733a4cbf8b31c44731e846585e8
ARG NODE_IMAGE=node:22-bookworm-slim@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436 ARG NODE_IMAGE=node:22-bookworm-slim@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436
ARG PHP_IMAGE=php:8.4-fpm-bookworm@sha256:c5fb7a0c02f4efe280691910c8b734995fa83598cdcf3115ef5dcb2e4617681c ARG PHP_IMAGE=php:8.4.27-fpm-bookworm@sha256:6692783d126388861ec4477dbc32e4d22cc6237673ddf17c87703384627d25a7
FROM ${COMPOSER_IMAGE} AS composer_binary FROM ${COMPOSER_IMAGE} AS composer_binary
FROM ${NODE_IMAGE} AS node_binary FROM ${NODE_IMAGE} AS node_binary
@@ -67,13 +67,14 @@ COPY --from=node_binary /usr/local/ /usr/local/
COPY . /var/www COPY . /var/www
RUN composer install --prefer-dist --no-interaction --no-progress \ RUN composer install --prefer-dist --no-interaction --no-progress \
&& npm ci \ && corepack enable pnpm \
&& npm run build && pnpm install --frozen-lockfile \
&& pnpm run build
FROM build AS test FROM build AS test
RUN touch .env \ RUN cp .env.example .env \
&& composer audit --locked --no-interaction \ && php artisan key:generate \
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check && COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
FROM build AS production_builder FROM build AS production_builder
@@ -32,7 +32,6 @@ class RoomJoinController extends Controller
$room !== null $room !== null
&& ! $room->isClosed() && ! $room->isClosed()
&& $existingPlayer instanceof Player && $existingPlayer instanceof Player
&& $existingPlayer->inactive_at === null
) { ) {
return to_route('room.show', $room); 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(); $player->markAsSeen();
$request->attributes->set('currentPlayer', $player); $request->attributes->set('currentPlayer', $player);
+1 -1
View File
@@ -48,7 +48,7 @@ class KickRoomPlayerRequest extends FormRequest
return null; 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, 'join_code' => $this->resource->join_code,
'current_round' => $this->resource->current_round, 'current_round' => $this->resource->current_round,
'players_count' => $this->resource->players_count, 'players_count' => $this->resource->players_count,
'active_player_count' => $this->resource->active_player_count, 'players' => $this->resource->relationLoaded('players')
'players' => $this->resource->relationLoaded('activePlayers') ? $this->resource->players
? $this->resource->activePlayers
->map(fn ($player): array => [ ->map(fn ($player): array => [
'id' => $player->id, 'id' => $player->id,
'name' => $player->name, '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(),
]);
}
}
+111
View File
@@ -0,0 +1,111 @@
<?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) {
$result = DB::transaction(function () use ($player, $cutoff): ?array {
$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();
$roomDeleted = false;
if ($room->owner_id === null && ! $room->trashed()) {
if (! $room->players()->exists()) {
$room->delete();
$roomDeleted = true;
} elseif (! $room->players()->where('is_moderator', true)->exists()) {
$successor = $room->players()
->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);
});
})
->orderBy('created_at')
->orderBy('id')
->first();
$successor?->update(['is_moderator' => true]);
}
}
$room->update([
'state_version' => $room->state_version + 1,
]);
return [$room, $roomDeleted];
});
if ($result === null) {
continue;
}
[$room, $roomDeleted] = $result;
if ($roomDeleted) {
RoomStateChanged::dispatch(
$room->id,
$room->state_version,
RoomStateChange::RoomDeleted,
);
} elseif (! $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; namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
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;
@@ -16,7 +15,6 @@ use Illuminate\Support\Carbon;
* @property string $name * @property string $name
* @property bool $is_moderator * @property bool $is_moderator
* @property Carbon|null $last_seen_at * @property Carbon|null $last_seen_at
* @property Carbon|null $inactive_at
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property-read Room $room * @property-read Room $room
@@ -30,7 +28,6 @@ class Player extends Model
'name', 'name',
'is_moderator', 'is_moderator',
'last_seen_at', 'last_seen_at',
'inactive_at',
]; ];
protected function casts(): array protected function casts(): array
@@ -38,7 +35,6 @@ class Player extends Model
return [ return [
'is_moderator' => 'boolean', 'is_moderator' => 'boolean',
'last_seen_at' => 'datetime', 'last_seen_at' => 'datetime',
'inactive_at' => 'datetime',
]; ];
} }
@@ -85,14 +81,4 @@ class Player extends Model
'last_seen_at' => now(), '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> */ /** @return BelongsTo<User, $this> */
public function owner(): BelongsTo public function owner(): BelongsTo
{ {
+1 -3
View File
@@ -16,13 +16,12 @@ class DashboardRoomService
$ownedRooms = $user $ownedRooms = $user
->ownedRooms() ->ownedRooms()
->with([ ->with([
'activePlayers' => fn ($query) => $query 'players' => fn ($query) => $query
->select(['id', 'room_id', 'user_id', 'name', 'is_moderator', 'created_at']) ->select(['id', 'room_id', 'user_id', 'name', 'is_moderator', 'created_at'])
->orderByDesc('is_moderator') ->orderByDesc('is_moderator')
->orderBy('created_at'), ->orderBy('created_at'),
]) ])
->withCount('players') ->withCount('players')
->withCount(['activePlayers as active_player_count'])
->orderByRaw('closed_at IS NOT NULL') ->orderByRaw('closed_at IS NOT NULL')
->orderByDesc('last_activity_at') ->orderByDesc('last_activity_at')
->get(); ->get();
@@ -30,7 +29,6 @@ class DashboardRoomService
$favoriteRooms = $user $favoriteRooms = $user
->favoriteRooms() ->favoriteRooms()
->withCount('players') ->withCount('players')
->withCount(['activePlayers as active_player_count'])
->orderByRaw('closed_at IS NOT NULL') ->orderByRaw('closed_at IS NOT NULL')
->orderByDesc('last_activity_at') ->orderByDesc('last_activity_at')
->get(); ->get();
-1
View File
@@ -20,7 +20,6 @@ class RoomPageService
$room->load([ $room->load([
'players' => fn ($query) => $query 'players' => fn ($query) => $query
->active()
->select(['id', 'room_id', 'user_id', 'name', 'is_moderator', 'created_at']) ->select(['id', 'room_id', 'user_id', 'name', 'is_moderator', 'created_at'])
->orderByDesc('is_moderator') ->orderByDesc('is_moderator')
->orderBy('created_at'), ->orderBy('created_at'),
+24 -21
View File
@@ -69,18 +69,19 @@ class RoomParticipationService
$user, $user,
$sessionPlayerIds[$room->id] ?? null, $sessionPlayerIds[$room->id] ?? null,
); );
$shouldModerate = $this->shouldModerate($room, $user);
if ($player === null) { if ($player === null) {
$player = $room->players()->create([ $player = $room->players()->create([
'user_id' => $user?->id, 'user_id' => $user?->id,
'name' => $data['player_name'], 'name' => $data['player_name'],
'is_moderator' => false, 'is_moderator' => $shouldModerate,
'last_seen_at' => now(), 'last_seen_at' => now(),
]); ]);
} else { } else {
$player->update([ $player->update([
'last_seen_at' => now(), 'last_seen_at' => now(),
'inactive_at' => null, 'is_moderator' => $player->is_moderator || $shouldModerate,
]); ]);
} }
@@ -124,6 +125,7 @@ class RoomParticipationService
$user, $user,
$sessionPlayerId, $sessionPlayerId,
); );
$shouldModerate = $this->shouldModerate($lockedRoom, $user);
$nameIsTaken = $lockedRoom->players() $nameIsTaken = $lockedRoom->players()
->where('name', $playerName) ->where('name', $playerName)
@@ -145,21 +147,19 @@ class RoomParticipationService
$player = $lockedRoom->players()->create([ $player = $lockedRoom->players()->create([
'user_id' => $user?->id, 'user_id' => $user?->id,
'name' => $playerName, 'name' => $playerName,
'is_moderator' => false, 'is_moderator' => $shouldModerate,
'last_seen_at' => now(), 'last_seen_at' => now(),
]); ]);
$change = RoomStateChange::PlayerJoined; $change = RoomStateChange::PlayerJoined;
} else { } else {
if ($player->inactive_at !== null) { if ($player->name !== $playerName || ($shouldModerate && ! $player->is_moderator)) {
$change = RoomStateChange::PlayerJoined;
} elseif ($player->name !== $playerName) {
$change = RoomStateChange::PlayerUpdated; $change = RoomStateChange::PlayerUpdated;
} }
$updates = [ $updates = [
'name' => $playerName, 'name' => $playerName,
'last_seen_at' => now(), 'last_seen_at' => now(),
'inactive_at' => null, 'is_moderator' => $player->is_moderator || $shouldModerate,
]; ];
if ( if (
@@ -200,6 +200,16 @@ class RoomParticipationService
return $player; return $player;
} }
private function shouldModerate(Room $room, ?User $user): bool
{
if ($user !== null && $room->owner_id === $user->id) {
return true;
}
return $room->owner_id === null
&& ! $room->players()->where('is_moderator', true)->exists();
}
private function findExistingEmbeddedPlayer( private function findExistingEmbeddedPlayer(
Room $room, Room $room,
?User $user, ?User $user,
@@ -222,7 +232,10 @@ class RoomParticipationService
->lockForUpdate() ->lockForUpdate()
->first(); ->first();
if ($sessionPlayer !== null) { if (
$sessionPlayer !== null
&& ($user === null || $sessionPlayer->user_id === null || $sessionPlayer->user_id === $user->id)
) {
return $sessionPlayer; return $sessionPlayer;
} }
} }
@@ -282,14 +295,14 @@ class RoomParticipationService
?User $user, ?User $user,
?Player $currentPlayer, ?Player $currentPlayer,
): void { ): void {
$stateVersion = DB::transaction(function () use ($room, $player, $user, $currentPlayer): ?int { $stateVersion = DB::transaction(function () use ($room, $player, $user, $currentPlayer): int {
$lockedRoom = Room::query() $lockedRoom = Room::query()
->lockForUpdate() ->lockForUpdate()
->findOrFail($room->id); ->findOrFail($room->id);
$lockedCurrentPlayer = $currentPlayer === null $lockedCurrentPlayer = $currentPlayer === null
? null ? null
: $lockedRoom->activePlayers() : $lockedRoom->players()
->lockForUpdate() ->lockForUpdate()
->find($currentPlayer->id); ->find($currentPlayer->id);
$isOwner = $user !== null && $lockedRoom->owner_id === $user->id; $isOwner = $user !== null && $lockedRoom->owner_id === $user->id;
@@ -312,13 +325,7 @@ class RoomParticipationService
403, 403,
); );
if ($lockedPlayer->inactive_at !== null) { $lockedPlayer->delete();
return null;
}
$lockedPlayer->update([
'inactive_at' => now(),
]);
$lockedRoom->update([ $lockedRoom->update([
'last_activity_at' => now(), 'last_activity_at' => now(),
'state_version' => $lockedRoom->state_version + 1, 'state_version' => $lockedRoom->state_version + 1,
@@ -327,10 +334,6 @@ class RoomParticipationService
return $lockedRoom->state_version; return $lockedRoom->state_version;
}); });
if ($stateVersion === null) {
return;
}
RoomStateChanged::dispatch( RoomStateChanged::dispatch(
$room->id, $room->id,
$stateVersion, $stateVersion,
@@ -32,7 +32,6 @@ class RoomRealtimeStateService
$room->refresh(); $room->refresh();
$room->load([ $room->load([
'players' => fn ($query) => $query 'players' => fn ($query) => $query
->active()
->select(['id', 'room_id', 'user_id', 'name', 'is_moderator', 'created_at']) ->select(['id', 'room_id', 'user_id', 'name', 'is_moderator', 'created_at'])
->orderByDesc('is_moderator') ->orderByDesc('is_moderator')
->orderBy('created_at'), ->orderBy('created_at'),
+5 -5
View File
@@ -50,8 +50,8 @@
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"", "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate", "@php artisan key:generate",
"@php artisan migrate --force", "@php artisan migrate --force",
"npm install", "pnpm install",
"npm run build" "pnpm run build"
], ],
"dev": [ "dev": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
@@ -65,9 +65,9 @@
], ],
"ci:check": [ "ci:check": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"npm audit --audit-level=high", "pnpm audit --audit-level=critical",
"npm run lint:check", "pnpm run lint:check",
"npm run types:check", "pnpm run types:check",
"@test" "@test"
], ],
"types:check": [ "types:check": [
Generated
+5 -5
View File
@@ -2234,16 +2234,16 @@
}, },
{ {
"name": "laravel/wayfinder", "name": "laravel/wayfinder",
"version": "v0.1.21", "version": "v0.1.23",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/wayfinder.git", "url": "https://github.com/laravel/wayfinder.git",
"reference": "a85a996cea189f59cac14854f8b13319a29007f3" "reference": "e7b0914d194201e54ba320ef38137b47a7daca98"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/wayfinder/zipball/a85a996cea189f59cac14854f8b13319a29007f3", "url": "https://api.github.com/repos/laravel/wayfinder/zipball/e7b0914d194201e54ba320ef38137b47a7daca98",
"reference": "a85a996cea189f59cac14854f8b13319a29007f3", "reference": "e7b0914d194201e54ba320ef38137b47a7daca98",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2296,7 +2296,7 @@
"issues": "https://github.com/laravel/wayfinder/issues", "issues": "https://github.com/laravel/wayfinder/issues",
"source": "https://github.com/laravel/wayfinder" "source": "https://github.com/laravel/wayfinder"
}, },
"time": "2026-08-04T21:55:43+00:00" "time": "2026-10-16T14:24:32+00:00"
}, },
{ {
"name": "league/commonmark", "name": "league/commonmark",
@@ -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');
});
}
};
@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('rooms')
->whereNull('owner_id')
->whereNull('deleted_at')
->whereNotIn('id', DB::table('players')->select('room_id'))
->update(['deleted_at' => now()]);
DB::table('rooms')
->whereNull('owner_id')
->whereNull('deleted_at')
->whereNotIn('id', DB::table('players')->where('is_moderator', true)->select('room_id'))
->select('id')
->chunkById(100, function ($rooms): void {
foreach ($rooms as $room) {
$playerId = DB::table('players')
->where('room_id', $room->id)
->orderBy('created_at')
->orderBy('id')
->value('id');
if ($playerId !== null) {
DB::table('players')
->where('id', $playerId)
->update(['is_moderator' => true]);
}
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Deleted rooms and former moderator assignments cannot be reconstructed.
}
};
-7908
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -10,7 +10,7 @@
"format:check": "prettier --check resources/", "format:check": "prettier --check resources/",
"lint": "eslint . --fix", "lint": "eslint . --fix",
"lint:check": "eslint .", "lint:check": "eslint .",
"types:check": "vue-tsc --noEmit" "types:check": "php artisan wayfinder:generate --with-form --no-interaction && vue-tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
-1
View File
@@ -19,7 +19,6 @@
</source> </source>
<php> <php>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:4Ptb91NBsGWQmvFU/5KD+BsS9+JYbpjRG+I6SRQNDtM="/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/> <env name="BROADCAST_CONNECTION" value="null"/>
+35 -25
View File
@@ -4,6 +4,9 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
overrides:
shell-quote@>=1.8.4 <1.11.0: ^1.11.0
importers: importers:
.: .:
@@ -113,7 +116,7 @@ importers:
version: 5.9.3 version: 5.9.3
typescript-eslint: typescript-eslint:
specifier: ^8.23.0 specifier: ^8.23.0
version: 8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(typescript@5.9.3) version: 8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
vite: vite:
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.2.1(@types/node@22.20.1)(jiti@2.7.0) version: 8.2.1(@types/node@22.20.1)(jiti@2.7.0)
@@ -772,6 +775,11 @@ packages:
'@volar/typescript@2.4.15': '@volar/typescript@2.4.15':
resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
'@vue/compiler-core@3.5.41': '@vue/compiler-core@3.5.41':
resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==} resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==}
@@ -2144,8 +2152,8 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'} engines: {node: '>=8'}
shell-quote@1.9.0: shell-quote@1.12.0:
resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} resolution: {integrity: sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
side-channel-list@1.0.1: side-channel-list@1.0.1:
@@ -2892,13 +2900,13 @@ snapshots:
'@types/web-bluetooth@0.0.21': {} '@types/web-bluetooth@0.0.21': {}
'@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/parser': 8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.67.0 '@typescript-eslint/scope-manager': 8.67.0
'@typescript-eslint/type-utils': 8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(typescript@5.9.3) '@typescript-eslint/type-utils': 8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/utils': 8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(typescript@5.9.3) '@typescript-eslint/utils': 8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.67.0 '@typescript-eslint/visitor-keys': 8.67.0
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
ignore: 7.0.6 ignore: 7.0.6
@@ -2912,7 +2920,7 @@ snapshots:
dependencies: dependencies:
'@typescript-eslint/scope-manager': 8.67.0 '@typescript-eslint/scope-manager': 8.67.0
'@typescript-eslint/types': 8.67.0 '@typescript-eslint/types': 8.67.0
'@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.67.0(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.67.0 '@typescript-eslint/visitor-keys': 8.67.0
debug: 4.4.3(supports-color@10.2.2) debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
@@ -2920,7 +2928,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/project-service@8.67.0(typescript@5.9.3)': '@typescript-eslint/project-service@8.67.0(supports-color@10.2.2)(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3)
'@typescript-eslint/types': 8.67.0 '@typescript-eslint/types': 8.67.0
@@ -2938,11 +2946,11 @@ snapshots:
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
'@typescript-eslint/type-utils@8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(typescript@5.9.3)': '@typescript-eslint/type-utils@8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/types': 8.67.0 '@typescript-eslint/types': 8.67.0
'@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.67.0(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/utils': 8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(typescript@5.9.3) '@typescript-eslint/utils': 8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
debug: 4.4.3(supports-color@10.2.2) debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
ts-api-utils: 2.5.0(typescript@5.9.3) ts-api-utils: 2.5.0(typescript@5.9.3)
@@ -2952,9 +2960,9 @@ snapshots:
'@typescript-eslint/types@8.67.0': {} '@typescript-eslint/types@8.67.0': {}
'@typescript-eslint/typescript-estree@8.67.0(typescript@5.9.3)': '@typescript-eslint/typescript-estree@8.67.0(supports-color@10.2.2)(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/project-service': 8.67.0(typescript@5.9.3) '@typescript-eslint/project-service': 8.67.0(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3)
'@typescript-eslint/types': 8.67.0 '@typescript-eslint/types': 8.67.0
'@typescript-eslint/visitor-keys': 8.67.0 '@typescript-eslint/visitor-keys': 8.67.0
@@ -2967,12 +2975,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/utils@8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(typescript@5.9.3)': '@typescript-eslint/utils@8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)) '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))
'@typescript-eslint/scope-manager': 8.67.0 '@typescript-eslint/scope-manager': 8.67.0
'@typescript-eslint/types': 8.67.0 '@typescript-eslint/types': 8.67.0
'@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.67.0(supports-color@10.2.2)(typescript@5.9.3)
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -3065,11 +3073,13 @@ snapshots:
'@volar/source-map@2.4.15': {} '@volar/source-map@2.4.15': {}
'@volar/typescript@2.4.15': '@volar/typescript@2.4.15(typescript@5.9.3)':
dependencies: dependencies:
'@volar/language-core': 2.4.15 '@volar/language-core': 2.4.15
path-browserify: 1.0.1 path-browserify: 1.0.1
vscode-uri: 3.1.0 vscode-uri: 3.1.0
optionalDependencies:
typescript: 5.9.3
'@vue/compiler-core@3.5.41': '@vue/compiler-core@3.5.41':
dependencies: dependencies:
@@ -3108,11 +3118,11 @@ snapshots:
'@vue/eslint-config-typescript@14.9.0(eslint-plugin-vue@9.33.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2))(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': '@vue/eslint-config-typescript@14.9.0(eslint-plugin-vue@9.33.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2))(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/utils': 8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(typescript@5.9.3) '@typescript-eslint/utils': 8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
eslint-plugin-vue: 9.33.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) eslint-plugin-vue: 9.33.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)
fast-glob: 3.3.3 fast-glob: 3.3.3
typescript-eslint: 8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(typescript@5.9.3) typescript-eslint: 8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
vue-eslint-parser: 10.4.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) vue-eslint-parser: 10.4.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
@@ -3374,7 +3384,7 @@ snapshots:
dependencies: dependencies:
chalk: 5.6.2 chalk: 5.6.2
rxjs: 7.8.2 rxjs: 7.8.2
shell-quote: 1.9.0 shell-quote: 1.12.0
supports-color: 10.2.2 supports-color: 10.2.2
tree-kill: 1.2.2 tree-kill: 1.2.2
yargs: 18.0.0 yargs: 18.0.0
@@ -4544,7 +4554,7 @@ snapshots:
shebang-regex@3.0.0: {} shebang-regex@3.0.0: {}
shell-quote@1.9.0: {} shell-quote@1.12.0: {}
side-channel-list@1.0.1: side-channel-list@1.0.1:
dependencies: dependencies:
@@ -4733,12 +4743,12 @@ snapshots:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10 reflect.getprototypeof: 1.0.10
typescript-eslint@8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(typescript@5.9.3): typescript-eslint@8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(typescript@5.9.3) '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/parser': 8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/parser': 8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.67.0(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/utils': 8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(typescript@5.9.3) '@typescript-eslint/utils': 8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -4849,7 +4859,7 @@ snapshots:
vue-tsc@2.2.12(typescript@5.9.3): vue-tsc@2.2.12(typescript@5.9.3):
dependencies: dependencies:
'@volar/typescript': 2.4.15 '@volar/typescript': 2.4.15(typescript@5.9.3)
'@vue/language-core': 2.2.12(typescript@5.9.3) '@vue/language-core': 2.2.12(typescript@5.9.3)
typescript: 5.9.3 typescript: 5.9.3
+2
View File
@@ -10,3 +10,5 @@ minimumReleaseAgeIgnoreMissingTime: false
allowBuilds: allowBuilds:
unrs-resolver: false unrs-resolver: false
vue-demi: true vue-demi: true
overrides:
shell-quote@>=1.8.4 <1.11.0: ^1.11.0
@@ -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"> <div class="mt-5 grid grid-cols-2 gap-3 text-sm text-muted-foreground">
<span class="inline-flex items-center gap-2"> <span class="inline-flex items-center gap-2">
<Users class="size-4" /> <Users class="size-4" />
{{ room.players_count }} Spieler / {{ room.players_count }} Spieler
{{ room.active_player_count }} aktiv
</span> </span>
<span>Runde {{ room.current_round }}</span> <span>Runde {{ room.current_round }}</span>
<span class="col-span-2 inline-flex items-center gap-2"> <span class="col-span-2 inline-flex items-center gap-2">
@@ -70,6 +70,17 @@ export function useRoomRealtime(
...payload.state, ...payload.state,
}; };
const currentRoomPlayer = room.value.players.find(
(player) => player.id === currentPlayer.value.id,
);
if (currentRoomPlayer !== undefined) {
currentPlayer.value = {
...currentPlayer.value,
is_moderator: currentRoomPlayer.is_moderator,
};
}
if ( if (
payload.change === 'votes_reset' || payload.change === 'votes_reset' ||
payload.change === 'next_round' payload.change === 'next_round'
+1 -5
View File
@@ -85,11 +85,7 @@ defineProps<{
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Label for="remember" class="flex items-center space-x-3"> <Label for="remember" class="flex items-center space-x-3">
<Checkbox <Checkbox id="remember" name="remember" :tabindex="3" />
id="remember"
name="remember"
:tabindex="3"
/>
<span>Remember me</span> <span>Remember me</span>
</Label> </Label>
</div> </div>
-1
View File
@@ -11,7 +11,6 @@ export type DashboardRoom = {
join_code: string; join_code: string;
current_round: number; current_round: number;
players_count: number; players_count: number;
active_player_count: number;
players: DashboardRoomPlayer[]; players: DashboardRoomPlayer[];
last_activity_at: string | null; last_activity_at: string | null;
closed_at: string | null; closed_at: string | null;
+2 -2
View File
@@ -1,7 +1,7 @@
<?php <?php
use App\Jobs\CloseInactiveRooms; use App\Jobs\CloseInactiveRooms;
use App\Jobs\MarkInactivePlayers; use App\Jobs\RemoveAbsentPlayers;
use Illuminate\Foundation\DevCommands; use Illuminate\Foundation\DevCommands;
use Illuminate\Support\Facades\Schedule; use Illuminate\Support\Facades\Schedule;
@@ -11,6 +11,6 @@ Schedule::job(new CloseInactiveRooms)
->weeklyOn(1, '02:00') ->weeklyOn(1, '02:00')
->withoutOverlapping(); ->withoutOverlapping();
Schedule::job(new MarkInactivePlayers) Schedule::job(new RemoveAbsentPlayers)
->everyFiveMinutes() ->everyFiveMinutes()
->withoutOverlapping(); ->withoutOverlapping();
+184
View File
@@ -0,0 +1,184 @@
<?php
use App\Enums\RoomStateChange;
use App\Events\RoomStateChanged;
use App\Jobs\RemoveAbsentPlayers;
use App\Models\Room;
use App\Models\User;
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('migrations remove absent players and reconcile guest rooms', 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(),
]);
$emptyGuestRoom = Room::create([
'title' => 'Empty guest room',
'deck_config' => ['name' => 'Fibonacci', 'cards' => []],
]);
$owner = User::factory()->create();
$emptyAccountRoom = Room::create([
'owner_id' => $owner->id,
'is_persistent' => true,
'title' => 'Persistent account room',
'deck_config' => ['name' => 'Fibonacci', 'cards' => []],
]);
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();
$guestRoomMigration = require database_path('migrations/2026_09_24_125529_reconcile_guest_rooms_after_player_cleanup.php');
$guestRoomMigration->up();
$this->assertModelMissing($previouslyInactive);
$this->assertModelMissing($absent);
$this->assertModelExists($present);
$this->assertSoftDeleted($emptyGuestRoom);
$this->assertModelExists($emptyAccountRoom);
expect($present->fresh()->is_moderator)->toBeTrue();
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',
'is_moderator' => true,
'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);
expect($recentPlayer->fresh()->is_moderator)->toBeTrue();
Event::assertDispatched(
RoomStateChanged::class,
fn (RoomStateChanged $event): bool => $event->change === RoomStateChange::PlayerKicked
&& $event->affectedPlayerId === $absentPlayer->id
&& collect($event->state['players'])->doesntContain('id', $absentPlayer->id)
&& collect($event->state['players'])->firstWhere('id', $recentPlayer->id)['is_moderator'] === true,
);
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);
});
test('the timeout deletes a guest room when its last player is removed', function () {
$room = Room::create([
'title' => 'Guest planning',
'deck_config' => ['name' => 'Fibonacci', 'cards' => []],
]);
$moderator = $room->players()->create([
'name' => 'Nico',
'is_moderator' => true,
'last_seen_at' => now()->subHour(),
]);
Event::fake([RoomStateChanged::class]);
app()->call([new RemoveAbsentPlayers, 'handle']);
app()->call([new RemoveAbsentPlayers, 'handle']);
$this->assertModelMissing($moderator);
$this->assertSoftDeleted($room);
Event::assertDispatched(
RoomStateChanged::class,
fn (RoomStateChanged $event): bool => $event->roomId === $room->id
&& $event->change === RoomStateChange::RoomDeleted,
);
Event::assertDispatchedTimes(RoomStateChanged::class, 1);
$this->post(route('room.code'), [
'join_code' => $room->join_code,
'player_name' => 'Nico',
])->assertSessionHasErrors('join_code');
});
test('the owner regains moderation when joining a persistent room after a timeout', function () {
$owner = User::factory()->create();
$room = Room::create([
'owner_id' => $owner->id,
'is_persistent' => true,
'title' => 'Permanent planning',
'deck_config' => ['name' => 'Fibonacci', 'cards' => []],
]);
$oldModerator = $room->players()->create([
'user_id' => $owner->id,
'name' => 'Nico',
'is_moderator' => true,
'last_seen_at' => now()->subHour(),
]);
$guest = $room->players()->create([
'name' => 'Anna',
'last_seen_at' => now(),
]);
app()->call([new RemoveAbsentPlayers, 'handle']);
$this->assertModelMissing($oldModerator);
$this->assertModelExists($room);
expect($guest->fresh()->is_moderator)->toBeFalse();
$this->actingAs($owner)
->withSession(["room_players.{$room->id}" => $oldModerator->id])
->post(route('room.code'), [
'join_code' => $room->join_code,
'player_name' => 'Nico',
])
->assertRedirect(route('room.show', $room));
$newModerator = $room->players()->where('user_id', $owner->id)->sole();
expect($newModerator->id)->not->toBe($oldModerator->id)
->and($newModerator->is_moderator)->toBeTrue();
});
+59 -8
View File
@@ -2,6 +2,7 @@
use App\Enums\RoomStateChange; use App\Enums\RoomStateChange;
use App\Events\RoomStateChanged; use App\Events\RoomStateChanged;
use App\Jobs\RemoveAbsentPlayers;
use App\Models\Room; use App\Models\Room;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
@@ -37,7 +38,7 @@ test('a guest automatically joins the embedded room', function () {
$player = $room->players()->sole(); $player = $room->players()->sole();
expect($player->user_id)->toBeNull() expect($player->user_id)->toBeNull()
->and($player->is_moderator)->toBeFalse(); ->and($player->is_moderator)->toBeTrue();
$response->assertSessionHas("room_players.{$room->id}", $player->id); $response->assertSessionHas("room_players.{$room->id}", $player->id);
}); });
@@ -46,7 +47,6 @@ test('embedded guest access reuses the session player and applies a changed disp
$room = createEmbeddedRoom(); $room = createEmbeddedRoom();
$player = $room->players()->create([ $player = $room->players()->create([
'name' => 'Old name', 'name' => 'Old name',
'inactive_at' => now(),
]); ]);
$this->withSession(["room_players.{$room->id}" => $player->id]) $this->withSession(["room_players.{$room->id}" => $player->id])
@@ -57,8 +57,7 @@ test('embedded guest access reuses the session player and applies a changed disp
->assertOk(); ->assertOk();
expect($room->players()->count())->toBe(1) expect($room->players()->count())->toBe(1)
->and($player->fresh()->name)->toBe('New name') ->and($player->fresh()->name)->toBe('New name');
->and($player->fresh()->inactive_at)->toBeNull();
}); });
test('an authenticated user automatically joins the embedded room', function () { test('an authenticated user automatically joins the embedded room', function () {
@@ -82,17 +81,70 @@ test('an authenticated user automatically joins the embedded room', function ()
'room_id' => $room->id, 'room_id' => $room->id,
'user_id' => $user->id, 'user_id' => $user->id,
'name' => 'Nico', 'name' => 'Nico',
'is_moderator' => false, 'is_moderator' => true,
]); ]);
}); });
test('a persistent room owner regains moderation through the embedded room', function () {
$owner = User::factory()->create();
$room = createEmbeddedRoom([
'owner_id' => $owner->id,
'is_persistent' => true,
]);
$oldModerator = $room->players()->create([
'user_id' => $owner->id,
'name' => 'Nico',
'is_moderator' => true,
'last_seen_at' => now()->subHour(),
]);
app()->call([new RemoveAbsentPlayers, 'handle']);
$this->actingAs($owner)
->withSession(["room_players.{$room->id}" => $oldModerator->id])
->get(route('embedded.room', [
'join_code' => $room->join_code,
'player_name' => 'Nico',
]))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->where('currentPlayer.is_moderator', true));
expect($room->players()->where('user_id', $owner->id)->sole()->id)->not->toBe($oldModerator->id);
});
test('a persistent room owner does not take over another account session when becoming moderator', function () {
$owner = User::factory()->create();
$otherUser = User::factory()->create();
$room = createEmbeddedRoom([
'owner_id' => $owner->id,
'is_persistent' => true,
]);
$otherPlayer = $room->players()->create([
'user_id' => $otherUser->id,
'name' => 'Anna',
]);
$this->actingAs($owner)
->withSession(["room_players.{$room->id}" => $otherPlayer->id])
->get(route('embedded.room', [
'join_code' => $room->join_code,
'player_name' => 'Nico',
]))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->where('currentPlayer.is_moderator', true));
expect($otherPlayer->fresh()->is_moderator)->toBeFalse()
->and($room->players()->where('user_id', $owner->id)->sole()->is_moderator)->toBeTrue();
});
test('embedded access reuses the user player and applies a changed display name', function () { test('embedded access reuses the user player and applies a changed display name', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$room = createEmbeddedRoom(); $room = createEmbeddedRoom();
$player = $room->players()->create([ $player = $room->players()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'name' => 'Old name', 'name' => 'Old name',
'inactive_at' => now(),
]); ]);
$this->actingAs($user) $this->actingAs($user)
@@ -103,8 +155,7 @@ test('embedded access reuses the user player and applies a changed display name'
->assertOk(); ->assertOk();
expect($room->players()->count())->toBe(1) expect($room->players()->count())->toBe(1)
->and($player->fresh()->name)->toBe('New name') ->and($player->fresh()->name)->toBe('New name');
->and($player->fresh()->inactive_at)->toBeNull();
}); });
test('an embedded guest keeps the same player after logging in separately', function () { 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', 'name' => 'Active player',
'last_seen_at' => now(), 'last_seen_at' => now(),
]); ]);
$room->players()->create([
'name' => 'Inactive player',
'last_seen_at' => now()->subHour(),
'inactive_at' => now(),
]);
$this->actingAs($user) $this->actingAs($user)
->post(route('room.favorite', $room)) ->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 ->assertInertia(fn (Assert $page) => $page
->has('favoriteRooms', 1) ->has('favoriteRooms', 1)
->where('favoriteRooms.0.id', $room->id) ->where('favoriteRooms.0.id', $room->id)
->where('favoriteRooms.0.players_count', 2) ->where('favoriteRooms.0.players_count', 1));
->where('favoriteRooms.0.active_player_count', 1));
}); });
test('the room resource exposes favorite and owner state', function () { 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)); ->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(); $room = createLifecycleRoom();
$player = $room->players()->create([ $player = $room->players()->create([
'name' => 'Returning guest', 'name' => 'Returning guest',
'last_seen_at' => now()->subHours(2), 'last_seen_at' => now()->subHours(2),
'inactive_at' => now()->subHour(),
]); ]);
$session = ["room_players.{$room->id}" => $player->id]; $session = ["room_players.{$room->id}" => $player->id];
$player->delete();
$this->withSession($session) $this->withSession($session)
->get(route('room.show', $room)) ->get(route('room.show', $room))
->assertRedirect(route('room.join', ['join_code' => $room->join_code])); ->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])) ->get(route('room.join', ['join_code' => $room->join_code]))
->assertInertia(fn (Assert $page) => $page ->assertInertia(fn (Assert $page) => $page
->component('rooms/Join') ->component('rooms/Join')
->where('defaultPlayerName', 'Returning guest')); ->where('defaultPlayerName', null));
$this->withSession($session) $this->withSession($session)
->post(route('room.code'), [ ->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)); ->assertRedirect(route('room.show', $room));
expect($room->players()->count())->toBe(1) 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 () { 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); $room = createRoomForPlayerKick($owner);
$moderator = addPlayerToKickRoom($room, 'Moderator', $owner, true); $moderator = addPlayerToKickRoom($room, 'Moderator', $owner, true);
$participant = addPlayerToKickRoom($room, 'Anna'); $participant = addPlayerToKickRoom($room, 'Anna');
addPlayerToKickRoom($room, 'Inactive')->update(['inactive_at' => now()]);
$this->actingAs($owner) $this->actingAs($owner)
->get(route('dashboard')) ->get(route('dashboard'))
@@ -77,8 +76,8 @@ test('the room owner can kick an active participant', function () {
->delete(route('room.player.destroy', [$room, $participant])) ->delete(route('room.player.destroy', [$room, $participant]))
->assertRedirect(); ->assertRedirect();
expect($participant->fresh()->inactive_at)->not->toBeNull() $this->assertModelMissing($participant);
->and($room->fresh()->state_version)->toBe($previousVersion + 1); expect($room->fresh()->state_version)->toBe($previousVersion + 1);
Event::assertDispatched( Event::assertDispatched(
RoomStateChanged::class, RoomStateChanged::class,
@@ -104,6 +103,36 @@ test('the room owner can kick an active participant', function () {
Event::assertDispatchedTimes(RoomStateChanged::class, 1); 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 () { test('the room moderator can kick an active participant without shared management', function () {
$owner = User::factory()->create(); $owner = User::factory()->create();
$room = createRoomForPlayerKick($owner, ['owner_id' => null]); $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])) ->delete(route('room.player.destroy', [$room, $target]))
->assertRedirect(); ->assertRedirect();
expect($target->fresh()->inactive_at)->not->toBeNull(); $this->assertModelMissing($target);
Event::assertDispatchedTimes(RoomStateChanged::class, 1); 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])) ->delete(route('room.player.destroy', [$room, $target]))
->assertForbidden(); ->assertForbidden();
expect($target->fresh()->inactive_at)->toBeNull(); $this->assertModelExists($target);
Event::assertNotDispatched(RoomStateChanged::class); 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])) ->delete(route('room.player.destroy', [$room, $target]))
->assertRedirect(); ->assertRedirect();
expect($target->fresh()->inactive_at)->not->toBeNull(); $this->assertModelMissing($target);
Event::assertDispatched(RoomStateChanged::class); 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])) ->delete(route('room.player.destroy', [$room, $participant]))
->assertForbidden(); ->assertForbidden();
expect($participant->fresh()->inactive_at)->toBeNull(); $this->assertModelExists($participant);
Event::assertNotDispatched(RoomStateChanged::class); 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])) $this->delete(route('room.player.destroy', [$room, $target]))
->assertForbidden(); ->assertForbidden();
expect($target->fresh()->inactive_at)->toBeNull(); $this->assertModelExists($target);
Event::assertNotDispatched(RoomStateChanged::class); 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])) ->delete(route('room.player.destroy', [$room, $moderator]))
->assertForbidden(); ->assertForbidden();
expect($moderator->fresh()->inactive_at)->toBeNull(); $this->assertModelExists($moderator);
Event::assertNotDispatched(RoomStateChanged::class); 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])) ->delete(route('room.player.destroy', [$room, $target]))
->assertForbidden(); ->assertForbidden();
expect($target->fresh()->inactive_at)->toBeNull(); $this->assertModelExists($target);
Event::assertNotDispatched(RoomStateChanged::class); Event::assertNotDispatched(RoomStateChanged::class);
}); });