diff --git a/.env.example b/.env.example index 40bb13b..73d6512 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,9 @@ APP_KEY= APP_DEBUG=true APP_URL=http://localhost:8000 +ROOM_GUEST_RETENTION_DAYS=14 +ROOM_ACCOUNT_RETENTION_DAYS=30 + APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US diff --git a/app/Actions/Rooms/ClaimGuestRooms.php b/app/Actions/Rooms/ClaimGuestRooms.php new file mode 100644 index 0000000..d8a468f --- /dev/null +++ b/app/Actions/Rooms/ClaimGuestRooms.php @@ -0,0 +1,83 @@ + $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(); + } +} diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php new file mode 100644 index 0000000..862d59c --- /dev/null +++ b/app/Http/Controllers/DashboardController.php @@ -0,0 +1,34 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/RoomController.php b/app/Http/Controllers/RoomController.php index aa28c24..d8b0c65 100644 --- a/app/Http/Controllers/RoomController.php +++ b/app/Http/Controllers/RoomController.php @@ -6,13 +6,15 @@ use App\Http\Requests\StoreRoomRequest; use App\Models\Deck; use App\Models\Player; use App\Models\Room; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Inertia\Inertia; +use Inertia\Response; class RoomController extends Controller { - public function create(Request $request) + public function create(Request $request): Response { $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(); $user = $request->user(); @@ -59,7 +61,9 @@ class RoomController extends Controller $user, ) { $room = Room::create([ + 'owner_id' => $user?->id, 'title' => $validated['title'] ?? null, + 'last_activity_at' => now(), 'deck_config' => [ 'id' => $deck->id, 'name' => $deck->name, @@ -82,10 +86,19 @@ class RoomController extends Controller $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'); @@ -117,6 +130,9 @@ class RoomController extends Controller ->firstWhere('id', $currentPlayer->id) ?->votes ->first(); + $statistics = $room->revealed_at !== null + ? $this->currentRoundStatistics($room) + : null; return Inertia::render('rooms/Show', [ 'room' => [ @@ -126,6 +142,7 @@ class RoomController extends Controller 'current_round' => $room->current_round, 'revealed' => $room->revealed_at !== null, 'state_version' => $room->state_version, + 'statistics' => $statistics, 'deck' => [ 'name' => $room->deck_config['name'], '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', [ - '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, ',', ''), + ]; + } } diff --git a/app/Http/Controllers/RoomPlayerController.php b/app/Http/Controllers/RoomPlayerController.php index 5861208..fbbdd4c 100644 --- a/app/Http/Controllers/RoomPlayerController.php +++ b/app/Http/Controllers/RoomPlayerController.php @@ -3,23 +3,51 @@ namespace App\Http\Controllers; use App\Http\Requests\JoinRoomRequest; +use App\Models\Player; use App\Models\Room; +use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; class RoomPlayerController extends Controller { - public function join(JoinRoomRequest $request) + public function join(JoinRoomRequest $request): RedirectResponse { $validated = $request->validated(); $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([ - 'user_id' => $user?->id, - 'name' => $validated['player_name'], - 'is_moderator' => false, - 'last_seen_at' => now(), - ]); + if ($room->isClosed()) { + throw ValidationException::withMessages([ + 'join_code' => 'Dieser Raum ist geschlossen.', + ]); + } + + $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( "room_players.{$room->id}", @@ -27,6 +55,20 @@ class RoomPlayerController extends Controller ); 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; } } diff --git a/app/Http/Controllers/RoundController.php b/app/Http/Controllers/RoundController.php index 93d8280..04e6324 100644 --- a/app/Http/Controllers/RoundController.php +++ b/app/Http/Controllers/RoundController.php @@ -30,6 +30,8 @@ class RoundController extends Controller ->lockForUpdate() ->findOrFail($room->id); + $this->ensureRoomIsOpen($lockedRoom); + Vote::query() ->where('room_id', $lockedRoom->id) ->where('round', $lockedRoom->current_round) @@ -37,6 +39,7 @@ class RoundController extends Controller $lockedRoom->update([ 'revealed_at' => null, + 'last_activity_at' => now(), 'state_version' => $lockedRoom->state_version + 1, ]); }); @@ -62,6 +65,8 @@ class RoundController extends Controller ->lockForUpdate() ->findOrFail($room->id); + $this->ensureRoomIsOpen($lockedRoom); + if ($lockedRoom->revealed_at !== null) { throw ValidationException::withMessages([ 'room' => 'Die Karten sind bereits aufgedeckt.', @@ -70,6 +75,7 @@ class RoundController extends Controller $lockedRoom->update([ 'revealed_at' => now(), + 'last_activity_at' => now(), 'state_version' => $lockedRoom->state_version + 1, ]); }); @@ -95,6 +101,8 @@ class RoundController extends Controller ->lockForUpdate() ->findOrFail($room->id); + $this->ensureRoomIsOpen($lockedRoom); + if ($lockedRoom->revealed_at === null) { throw ValidationException::withMessages([ 'room' => 'Die aktuelle Runde muss zuerst aufgedeckt werden.', @@ -104,10 +112,20 @@ class RoundController extends Controller $lockedRoom->update([ 'current_round' => $lockedRoom->current_round + 1, 'revealed_at' => null, + 'last_activity_at' => now(), 'state_version' => $lockedRoom->state_version + 1, ]); }); return back(); } + + private function ensureRoomIsOpen(Room $room): void + { + if ($room->isClosed()) { + throw ValidationException::withMessages([ + 'room' => 'Dieser Raum ist geschlossen.', + ]); + } + } } diff --git a/app/Http/Controllers/VoteController.php b/app/Http/Controllers/VoteController.php index f059ddb..a0fc93c 100644 --- a/app/Http/Controllers/VoteController.php +++ b/app/Http/Controllers/VoteController.php @@ -31,6 +31,12 @@ class VoteController extends Controller ->lockForUpdate() ->findOrFail($room->id); + if ($lockedRoom->isClosed()) { + throw ValidationException::withMessages([ + 'value' => 'Dieser Raum ist geschlossen.', + ]); + } + if ($lockedRoom->revealed_at !== null) { throw ValidationException::withMessages([ '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(); diff --git a/app/Http/Middleware/EnsureRoomParticipant.php b/app/Http/Middleware/EnsureRoomParticipant.php index 4c7c952..93338f5 100644 --- a/app/Http/Middleware/EnsureRoomParticipant.php +++ b/app/Http/Middleware/EnsureRoomParticipant.php @@ -5,6 +5,7 @@ namespace App\Http\Middleware; use App\Models\Room; use Closure; use Illuminate\Http\Request; +use Inertia\Inertia; use Symfony\Component\HttpFoundation\Response; class EnsureRoomParticipant @@ -23,23 +24,48 @@ class EnsureRoomParticipant } $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', [ 'join_code' => $room->join_code, ])->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); + } + 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', [ 'join_code' => $room->join_code, + ])->withErrors([ + 'join_code' => 'Du musst diesem Raum erst beitreten.', ]); } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index f4cc770..e2f5701 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -41,6 +41,7 @@ class HandleInertiaRequests extends Middleware 'auth' => [ 'user' => $request->user(), ], + 'roomNotice' => fn () => $request->session()->get('room_notice'), 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', ]; } diff --git a/app/Http/Requests/JoinRoomRequest.php b/app/Http/Requests/JoinRoomRequest.php index 4e5b317..0a7b65c 100644 --- a/app/Http/Requests/JoinRoomRequest.php +++ b/app/Http/Requests/JoinRoomRequest.php @@ -7,6 +7,7 @@ use App\Models\Room; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class JoinRoomRequest extends FormRequest { @@ -37,9 +38,54 @@ class JoinRoomRequest extends FormRequest ->where('join_code', $this->input('join_code')) ->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 [ - 'join_code' => ['bail', 'string', 'required', 'size:6', Rule::exists(Room::class, 'join_code')], - 'player_name' => ['required', 'string', 'max:50', Rule::unique(Player::class, 'name')->where('room_id', $roomId)], + 'join_code' => [ + 'bail', + 'string', + 'required', + 'size:6', + Rule::exists(Room::class, 'join_code')->whereNull('deleted_at'), + ], + 'player_name' => $playerNameRules, + ]; + } + + /** @return array */ + 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.'); + } + }, ]; } diff --git a/app/Http/Requests/SubmitVoteRequest.php b/app/Http/Requests/SubmitVoteRequest.php index f1dba08..c85fcc4 100644 --- a/app/Http/Requests/SubmitVoteRequest.php +++ b/app/Http/Requests/SubmitVoteRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Models\Room; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -25,6 +26,10 @@ class SubmitVoteRequest extends FormRequest { $room = $this->route('room'); + if (! $room instanceof Room) { + return ['value' => ['prohibited']]; + } + return [ 'value' => ['bail', 'required', 'string', Rule::in(array_column($room->deck_config['cards'], 'key'))], ]; diff --git a/app/Jobs/CloseInactiveRooms.php b/app/Jobs/CloseInactiveRooms.php new file mode 100644 index 0000000..812fb99 --- /dev/null +++ b/app/Jobs/CloseInactiveRooms.php @@ -0,0 +1,56 @@ +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'); + } +} diff --git a/app/Listeners/ClaimGuestRoomsAfterLogin.php b/app/Listeners/ClaimGuestRoomsAfterLogin.php new file mode 100644 index 0000000..5f6b7f9 --- /dev/null +++ b/app/Listeners/ClaimGuestRoomsAfterLogin.php @@ -0,0 +1,31 @@ +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); + } +} diff --git a/app/Models/Deck.php b/app/Models/Deck.php index 893fbc5..2905576 100644 --- a/app/Models/Deck.php +++ b/app/Models/Deck.php @@ -5,6 +5,13 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +/** + * @property int $id + * @property int|null $user_id + * @property string $name + * @property array $cards + * @property bool $is_system + */ class Deck extends Model { protected $fillable = [ @@ -12,7 +19,7 @@ class Deck extends Model 'cards', ]; - protected function casts() + protected function casts(): array { return [ 'cards' => 'array', @@ -20,6 +27,7 @@ class Deck extends Model ]; } + /** @return BelongsTo */ public function user(): BelongsTo { return $this->belongsTo(User::class); diff --git a/app/Models/Player.php b/app/Models/Player.php index 9a0a7ff..4e51c91 100644 --- a/app/Models/Player.php +++ b/app/Models/Player.php @@ -5,7 +5,19 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; 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 { protected $fillable = [ @@ -24,16 +36,19 @@ class Player extends Model ]; } + /** @return BelongsTo */ public function room(): BelongsTo { return $this->belongsTo(Room::class); } + /** @return HasMany */ public function votes(): HasMany { return $this->hasMany(Vote::class); } + /** @return BelongsTo */ public function user(): BelongsTo { return $this->belongsTo(User::class); diff --git a/app/Models/Room.php b/app/Models/Room.php index c8afc44..31c5ffd 100644 --- a/app/Models/Room.php +++ b/app/Models/Room.php @@ -2,20 +2,40 @@ namespace App\Models; +use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; 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, 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 { - use HasUuids; + use HasUuids, SoftDeletes; protected $fillable = [ + 'owner_id', 'title', 'current_round', 'deck_config', 'revealed_at', 'state_version', + 'last_activity_at', + 'closed_at', ]; protected function casts(): array @@ -24,18 +44,21 @@ class Room extends Model 'current_round' => 'integer', 'state_version' => 'integer', 'revealed_at' => 'immutable_datetime', + 'last_activity_at' => 'immutable_datetime', + 'closed_at' => 'immutable_datetime', 'deck_config' => 'array', ]; } - protected static function booted() + protected static function booted(): void { static::creating(function (Room $room) { $room->join_code ??= static::generateJoinCode(); + $room->last_activity_at ??= now(); }); } - private static function generateJoinCode(): string + protected static function generateJoinCode(): string { $alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; @@ -45,20 +68,33 @@ class Room extends Model for ($i = 0; $i < 6; $i++) { $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 HasMany */ public function players(): HasMany { return $this->hasMany(Player::class); } + /** @return BelongsTo */ + public function owner(): BelongsTo + { + return $this->belongsTo(User::class, 'owner_id'); + } + + /** @return HasMany */ public function votes(): HasMany { return $this->hasMany(Vote::class); } + + public function isClosed(): bool + { + return $this->closed_at !== null; + } } diff --git a/app/Models/User.php b/app/Models/User.php index 29f88ae..894d585 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -49,13 +49,21 @@ class User extends Authenticatable implements PasskeyUser ]; } + /** @return HasMany */ public function players(): HasMany { return $this->hasMany(Player::class); } + /** @return HasMany */ public function decks(): HasMany { return $this->hasMany(Deck::class); } + + /** @return HasMany */ + public function ownedRooms(): HasMany + { + return $this->hasMany(Room::class, 'owner_id'); + } } diff --git a/app/Models/Vote.php b/app/Models/Vote.php index bb082e0..e14d653 100644 --- a/app/Models/Vote.php +++ b/app/Models/Vote.php @@ -4,7 +4,17 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; 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 { protected $fillable = [ @@ -21,11 +31,13 @@ class Vote extends Model ]; } + /** @return BelongsTo */ public function room(): BelongsTo { return $this->belongsTo(Room::class); } + /** @return BelongsTo */ public function player(): BelongsTo { return $this->belongsTo(Player::class); diff --git a/config/rooms.php b/config/rooms.php new file mode 100644 index 0000000..cc63b1f --- /dev/null +++ b/config/rooms.php @@ -0,0 +1,6 @@ + (int) env('ROOM_GUEST_RETENTION_DAYS', 14), + 'account_retention_days' => (int) env('ROOM_ACCOUNT_RETENTION_DAYS', 30), +]; diff --git a/database/migrations/2026_08_16_000000_add_ownership_and_activity_to_rooms.php b/database/migrations/2026_08_16_000000_add_ownership_and_activity_to_rooms.php new file mode 100644 index 0000000..4a3b006 --- /dev/null +++ b/database/migrations/2026_08_16_000000_add_ownership_and_activity_to_rooms.php @@ -0,0 +1,111 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_08_16_010000_add_deleted_at_to_rooms.php b/database/migrations/2026_08_16_010000_add_deleted_at_to_rooms.php new file mode 100644 index 0000000..15974d6 --- /dev/null +++ b/database/migrations/2026_08_16_010000_add_deleted_at_to_rooms.php @@ -0,0 +1,22 @@ +softDeletes(); + }); + } + + public function down(): void + { + Schema::table('rooms', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/resources/js/app.ts b/resources/js/app.ts index 97c2594..d255648 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -1,11 +1,10 @@ import { createInertiaApp } from '@inertiajs/vue3'; import { initializeTheme } from '@/composables/useAppearance'; -import PublicLayout from './layouts/PublicLayout.vue'; import { initializeFlashToast } from '@/lib/flashToast'; +import PublicLayout from './layouts/PublicLayout.vue'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; - createInertiaApp({ title: (title) => (title ? `${title} - ${appName}` : appName), diff --git a/resources/js/components/AppHeader.vue b/resources/js/components/AppHeader.vue index ea0df0e..0113056 100644 --- a/resources/js/components/AppHeader.vue +++ b/resources/js/components/AppHeader.vue @@ -36,7 +36,7 @@ import { useCurrentUrl } from '@/composables/useCurrentUrl'; import { getInitials } from '@/composables/useInitials'; import { toUrl } from '@/lib/utils'; import { dashboard } from '@/routes'; -import type { BreadcrumbItem, NavItem } from '@/types'; +import type { BreadcrumbItem, NavItem, User } from '@/types'; type Props = { breadcrumbs?: BreadcrumbItem[]; @@ -47,7 +47,7 @@ const props = withDefaults(defineProps(), { }); const page = usePage(); -const auth = computed(() => page.props.auth); +const auth = computed(() => page.props.auth as { user: User }); const { isCurrentUrl, whenCurrentUrl } = useCurrentUrl(); const activeItemStyles = diff --git a/resources/js/components/NavUser.vue b/resources/js/components/NavUser.vue index 6ab6723..4584496 100644 --- a/resources/js/components/NavUser.vue +++ b/resources/js/components/NavUser.vue @@ -15,9 +15,10 @@ import { } from '@/components/ui/sidebar'; import UserInfo from '@/components/UserInfo.vue'; import UserMenuContent from '@/components/UserMenuContent.vue'; +import type { User } from '@/types'; const page = usePage(); -const user = computed(() => page.props.auth.user); +const user = computed(() => page.props.auth.user as User); const { isMobile, state } = useSidebar(); diff --git a/resources/js/layouts/PublicLayout.vue b/resources/js/layouts/PublicLayout.vue index e553193..c252db1 100644 --- a/resources/js/layouts/PublicLayout.vue +++ b/resources/js/layouts/PublicLayout.vue @@ -1,35 +1,56 @@ - + - diff --git a/resources/js/pages/Dashboard.vue b/resources/js/pages/Dashboard.vue index 449cfcf..e839281 100644 --- a/resources/js/pages/Dashboard.vue +++ b/resources/js/pages/Dashboard.vue @@ -1,47 +1,238 @@ diff --git a/resources/js/pages/Landingpage.vue b/resources/js/pages/Landingpage.vue index 2aa64d4..352db95 100644 --- a/resources/js/pages/Landingpage.vue +++ b/resources/js/pages/Landingpage.vue @@ -1,35 +1,36 @@ diff --git a/resources/js/pages/rooms/Create.vue b/resources/js/pages/rooms/Create.vue index 1b942c6..753ccb6 100644 --- a/resources/js/pages/rooms/Create.vue +++ b/resources/js/pages/rooms/Create.vue @@ -1,21 +1,20 @@ \ No newline at end of file + diff --git a/resources/js/pages/rooms/Join.vue b/resources/js/pages/rooms/Join.vue index a420d5a..48cfdba 100644 --- a/resources/js/pages/rooms/Join.vue +++ b/resources/js/pages/rooms/Join.vue @@ -1,69 +1,124 @@ \ No newline at end of file + diff --git a/resources/js/pages/rooms/Show.vue b/resources/js/pages/rooms/Show.vue index 8a89ad0..4d0d8ca 100644 --- a/resources/js/pages/rooms/Show.vue +++ b/resources/js/pages/rooms/Show.vue @@ -1,5 +1,5 @@