mirror of
https://github.com/ericasch6/scrum-poker-internal.git
synced 2026-10-13 19:12:10 +00:00
fix: refactored the codebase
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Rooms;
|
||||
|
||||
use App\Models\Room;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class DashboardRoomService
|
||||
{
|
||||
/**
|
||||
* @return array{owned: Collection<int, Room>, favorites: Collection<int, Room>}
|
||||
*/
|
||||
public function forUser(User $user): array
|
||||
{
|
||||
$ownedRooms = $user
|
||||
->ownedRooms()
|
||||
->withCount('players')
|
||||
->withCount(['activePlayers as active_player_count'])
|
||||
->orderByRaw('closed_at IS NOT NULL')
|
||||
->orderByDesc('last_activity_at')
|
||||
->get();
|
||||
|
||||
$favoriteRooms = $user
|
||||
->favoriteRooms()
|
||||
->withCount('players')
|
||||
->withCount(['activePlayers as active_player_count'])
|
||||
->orderByRaw('closed_at IS NOT NULL')
|
||||
->orderByDesc('last_activity_at')
|
||||
->get();
|
||||
|
||||
return [
|
||||
'owned' => $ownedRooms,
|
||||
'favorites' => $favoriteRooms,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Rooms;
|
||||
|
||||
use App\Models\Room;
|
||||
use App\Models\User;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
|
||||
class FavoriteRoomService
|
||||
{
|
||||
public function toggle(User $user, Room $room): void
|
||||
{
|
||||
if ($room->owner_id === $user->id) {
|
||||
throw new ConflictHttpException('Lobby is already yours', null, 409);
|
||||
}
|
||||
|
||||
$user->favoriteRooms()->toggle([$room->id]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Rooms;
|
||||
|
||||
use App\Models\Deck;
|
||||
use App\Models\Player;
|
||||
use App\Models\Room;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RoomCreationService
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, Deck>
|
||||
*/
|
||||
public function availableDecks(?User $user): Collection
|
||||
{
|
||||
return Deck::query()
|
||||
->where(function ($query) use ($user) {
|
||||
$query->where('is_system', true);
|
||||
|
||||
if ($user !== null) {
|
||||
$query->orWhere('user_id', $user->id);
|
||||
}
|
||||
})
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'cards']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{deck_id: int, player_name: string, title?: string|null} $data
|
||||
* @return array{Room, Player}
|
||||
*/
|
||||
public function create(array $data, ?User $user): array
|
||||
{
|
||||
$deck = Deck::query()
|
||||
->whereKey($data['deck_id'])
|
||||
->where(function ($query) use ($user) {
|
||||
$query->where('is_system', true);
|
||||
|
||||
if ($user !== null) {
|
||||
$query->orWhere('user_id', $user->id);
|
||||
}
|
||||
})
|
||||
->firstOrFail();
|
||||
|
||||
return DB::transaction(function () use ($data, $deck, $user): array {
|
||||
$room = Room::create([
|
||||
'owner_id' => $user?->id,
|
||||
'title' => $data['title'] ?? null,
|
||||
'last_activity_at' => now(),
|
||||
'deck_config' => [
|
||||
'id' => $deck->id,
|
||||
'name' => $deck->name,
|
||||
'cards' => $deck->cards,
|
||||
],
|
||||
]);
|
||||
|
||||
$player = $room->players()->create([
|
||||
'user_id' => $user?->id,
|
||||
'name' => $data['player_name'],
|
||||
'is_moderator' => true,
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
return [$room, $player];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Rooms;
|
||||
|
||||
use App\Events\PaperBallThrown;
|
||||
use App\Models\Player;
|
||||
use App\Models\Room;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class RoomEmoteService
|
||||
{
|
||||
public function throwPaperBall(Room $room, Player $player, int $targetPlayerId): void
|
||||
{
|
||||
if ($targetPlayerId === $player->id) {
|
||||
throw ValidationException::withMessages([
|
||||
'target_player_id' => 'Du kannst dich nicht selbst abwerfen.',
|
||||
]);
|
||||
}
|
||||
|
||||
$targetExists = $room->players()
|
||||
->whereKey($targetPlayerId)
|
||||
->exists();
|
||||
|
||||
if (! $targetExists) {
|
||||
throw ValidationException::withMessages([
|
||||
'target_player_id' => 'Dieser Spieler sitzt nicht an diesem Tisch.',
|
||||
]);
|
||||
}
|
||||
|
||||
$rateLimitKey = "room-emote:{$room->id}:{$player->id}";
|
||||
|
||||
abort_if(
|
||||
RateLimiter::tooManyAttempts($rateLimitKey, 10),
|
||||
429,
|
||||
'Bitte warte kurz, bevor du erneut wirfst.',
|
||||
);
|
||||
|
||||
RateLimiter::hit($rateLimitKey, 10);
|
||||
|
||||
PaperBallThrown::dispatch(
|
||||
Str::uuid()->toString(),
|
||||
$room->id,
|
||||
$player->id,
|
||||
$targetPlayerId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Rooms;
|
||||
|
||||
use App\Enums\RoomStateChange;
|
||||
use App\Events\RoomStateChanged;
|
||||
use App\Models\Player;
|
||||
use App\Models\Room;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RoomLifecycleService
|
||||
{
|
||||
/**
|
||||
* @return array{player: Player, was_reopened: bool}
|
||||
*/
|
||||
public function reopen(Room $room, User $user): array
|
||||
{
|
||||
[$player, $stateVersion, $wasReopened] = DB::transaction(function () use ($room, $user): array {
|
||||
$lockedRoom = Room::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($room->id);
|
||||
|
||||
abort_unless($user->id === $lockedRoom->owner_id, 403);
|
||||
|
||||
$wasReopened = $lockedRoom->isClosed();
|
||||
|
||||
if ($wasReopened) {
|
||||
$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(),
|
||||
$lockedRoom->state_version,
|
||||
$wasReopened,
|
||||
];
|
||||
});
|
||||
|
||||
if ($wasReopened) {
|
||||
RoomStateChanged::dispatch(
|
||||
$room->id,
|
||||
$stateVersion,
|
||||
RoomStateChange::RoomReopened,
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'player' => $player,
|
||||
'was_reopened' => $wasReopened,
|
||||
];
|
||||
}
|
||||
|
||||
public function delete(Room $room, User $user): void
|
||||
{
|
||||
$stateVersion = DB::transaction(function () use ($room, $user): int {
|
||||
$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();
|
||||
|
||||
return $lockedRoom->state_version;
|
||||
});
|
||||
|
||||
RoomStateChanged::dispatch(
|
||||
$room->id,
|
||||
$stateVersion,
|
||||
RoomStateChange::RoomDeleted,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Rooms;
|
||||
|
||||
use App\Data\Rooms\RoomPageData;
|
||||
use App\Models\Player;
|
||||
use App\Models\Room;
|
||||
use App\Models\User;
|
||||
|
||||
class RoomPageService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RoomStatisticsService $statistics,
|
||||
) {}
|
||||
|
||||
public function prepare(Room $room, Player $currentPlayer, ?User $user): RoomPageData
|
||||
{
|
||||
$isFavorite = $user !== null
|
||||
&& $user->favoriteRooms()->whereKey($room->id)->exists();
|
||||
|
||||
$room->load([
|
||||
'players' => fn ($query) => $query
|
||||
->active()
|
||||
->select(['id', 'room_id', 'user_id', 'name', 'is_moderator', 'created_at'])
|
||||
->orderByDesc('is_moderator')
|
||||
->orderBy('created_at'),
|
||||
'players.votes' => fn ($query) => $query
|
||||
->where('room_id', $room->id)
|
||||
->where('round', $room->current_round),
|
||||
]);
|
||||
|
||||
$currentVote = $room->players
|
||||
->firstWhere('id', $currentPlayer->id)
|
||||
?->votes
|
||||
->first()
|
||||
?->value;
|
||||
|
||||
return new RoomPageData(
|
||||
room: $room,
|
||||
currentPlayer: $currentPlayer,
|
||||
isFavorite: $isFavorite,
|
||||
currentVote: $currentVote,
|
||||
statistics: $this->statistics->currentRound($room),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Rooms;
|
||||
|
||||
use App\Enums\RoomStateChange;
|
||||
use App\Events\RoomStateChanged;
|
||||
use App\Models\Player;
|
||||
use App\Models\Room;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class RoomParticipationService
|
||||
{
|
||||
public function findByJoinCode(string $joinCode): ?Room
|
||||
{
|
||||
if ($joinCode === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Room::query()
|
||||
->where('join_code', $joinCode)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function findExistingPlayer(
|
||||
Room $room,
|
||||
?User $user,
|
||||
mixed $sessionPlayerId,
|
||||
): ?Player {
|
||||
if ($user !== null) {
|
||||
return $room->players()
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! is_numeric($sessionPlayerId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $room->players()->find((int) $sessionPlayerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{join_code: string, player_name: string} $data
|
||||
* @param array<string, mixed> $sessionPlayerIds
|
||||
* @return array{Room, Player}
|
||||
*/
|
||||
public function join(array $data, ?User $user, array $sessionPlayerIds): array
|
||||
{
|
||||
[$room, $player] = DB::transaction(function () use ($data, $user, $sessionPlayerIds): array {
|
||||
$room = Room::query()
|
||||
->where('join_code', $data['join_code'])
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
if ($room->isClosed()) {
|
||||
throw ValidationException::withMessages([
|
||||
'join_code' => 'Dieser Raum ist geschlossen.',
|
||||
]);
|
||||
}
|
||||
|
||||
$player = $this->findExistingPlayer(
|
||||
$room,
|
||||
$user,
|
||||
$sessionPlayerIds[$room->id] ?? null,
|
||||
);
|
||||
|
||||
if ($player === null) {
|
||||
$player = $room->players()->create([
|
||||
'user_id' => $user?->id,
|
||||
'name' => $data['player_name'],
|
||||
'is_moderator' => false,
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
} else {
|
||||
$player->update([
|
||||
'last_seen_at' => now(),
|
||||
'inactive_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$room->update([
|
||||
'last_activity_at' => now(),
|
||||
'state_version' => $room->state_version + 1,
|
||||
]);
|
||||
|
||||
return [$room, $player];
|
||||
});
|
||||
|
||||
RoomStateChanged::dispatch(
|
||||
$room->id,
|
||||
$room->state_version,
|
||||
RoomStateChange::PlayerJoined,
|
||||
);
|
||||
|
||||
return [$room, $player];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Rooms;
|
||||
|
||||
use App\Models\Room;
|
||||
|
||||
class RoomStatisticsService
|
||||
{
|
||||
/**
|
||||
* @return array{average: string|null, median: string|null}|null
|
||||
*/
|
||||
public function currentRound(Room $room): ?array
|
||||
{
|
||||
if ($room->revealed_at === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$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, ',', ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Rooms;
|
||||
|
||||
use App\Enums\RoomStateChange;
|
||||
use App\Events\RoomStateChanged;
|
||||
use App\Models\Room;
|
||||
use App\Models\Vote;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class RoundService
|
||||
{
|
||||
public function reset(Room $room): void
|
||||
{
|
||||
$stateVersion = DB::transaction(function () use ($room): int {
|
||||
$lockedRoom = Room::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($room->id);
|
||||
|
||||
$this->ensureRoomIsOpen($lockedRoom);
|
||||
|
||||
Vote::query()
|
||||
->where('room_id', $lockedRoom->id)
|
||||
->where('round', $lockedRoom->current_round)
|
||||
->delete();
|
||||
|
||||
$lockedRoom->update([
|
||||
'revealed_at' => null,
|
||||
'last_activity_at' => now(),
|
||||
'state_version' => $lockedRoom->state_version + 1,
|
||||
]);
|
||||
|
||||
return $lockedRoom->state_version;
|
||||
});
|
||||
|
||||
RoomStateChanged::dispatch(
|
||||
$room->id,
|
||||
$stateVersion,
|
||||
RoomStateChange::VotesReset,
|
||||
);
|
||||
}
|
||||
|
||||
public function reveal(Room $room): void
|
||||
{
|
||||
$stateVersion = DB::transaction(function () use ($room): int {
|
||||
$lockedRoom = Room::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($room->id);
|
||||
|
||||
$this->ensureRoomIsOpen($lockedRoom);
|
||||
|
||||
if ($lockedRoom->revealed_at !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'room' => 'Die Karten sind bereits aufgedeckt.',
|
||||
]);
|
||||
}
|
||||
|
||||
$lockedRoom->update([
|
||||
'revealed_at' => now(),
|
||||
'last_activity_at' => now(),
|
||||
'state_version' => $lockedRoom->state_version + 1,
|
||||
]);
|
||||
|
||||
return $lockedRoom->state_version;
|
||||
});
|
||||
|
||||
RoomStateChanged::dispatch(
|
||||
$room->id,
|
||||
$stateVersion,
|
||||
RoomStateChange::CardsRevealed,
|
||||
);
|
||||
}
|
||||
|
||||
public function startNext(Room $room): void
|
||||
{
|
||||
$stateVersion = DB::transaction(function () use ($room): int {
|
||||
$lockedRoom = Room::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($room->id);
|
||||
|
||||
$this->ensureRoomIsOpen($lockedRoom);
|
||||
|
||||
if ($lockedRoom->revealed_at === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'room' => 'Die aktuelle Runde muss zuerst aufgedeckt werden.',
|
||||
]);
|
||||
}
|
||||
|
||||
$lockedRoom->update([
|
||||
'current_round' => $lockedRoom->current_round + 1,
|
||||
'revealed_at' => null,
|
||||
'last_activity_at' => now(),
|
||||
'state_version' => $lockedRoom->state_version + 1,
|
||||
]);
|
||||
|
||||
return $lockedRoom->state_version;
|
||||
});
|
||||
|
||||
RoomStateChanged::dispatch(
|
||||
$room->id,
|
||||
$stateVersion,
|
||||
RoomStateChange::NextRound,
|
||||
);
|
||||
}
|
||||
|
||||
private function ensureRoomIsOpen(Room $room): void
|
||||
{
|
||||
if ($room->isClosed()) {
|
||||
throw ValidationException::withMessages([
|
||||
'room' => 'Dieser Raum ist geschlossen.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Rooms;
|
||||
|
||||
use App\Enums\RoomStateChange;
|
||||
use App\Events\RoomStateChanged;
|
||||
use App\Models\Player;
|
||||
use App\Models\Room;
|
||||
use App\Models\Vote;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class VoteService
|
||||
{
|
||||
public function submit(Room $room, Player $player, string $value): void
|
||||
{
|
||||
$stateVersion = DB::transaction(function () use ($room, $player, $value): int {
|
||||
$lockedRoom = Room::query()
|
||||
->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.',
|
||||
]);
|
||||
}
|
||||
|
||||
Vote::updateOrCreate(
|
||||
[
|
||||
'room_id' => $lockedRoom->id,
|
||||
'player_id' => $player->id,
|
||||
'round' => $lockedRoom->current_round,
|
||||
],
|
||||
['value' => $value],
|
||||
);
|
||||
|
||||
$lockedRoom->update([
|
||||
'last_activity_at' => now(),
|
||||
'state_version' => $lockedRoom->state_version + 1,
|
||||
]);
|
||||
|
||||
return $lockedRoom->state_version;
|
||||
});
|
||||
|
||||
RoomStateChanged::dispatch(
|
||||
$room->id,
|
||||
$stateVersion,
|
||||
RoomStateChange::VoteChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Settings;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Laravel\Passkeys\Passkey;
|
||||
|
||||
class SecuritySettingsService
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, Passkey>
|
||||
*/
|
||||
public function passkeys(User $user, bool $enabled): Collection
|
||||
{
|
||||
if (! $enabled) {
|
||||
return new Collection;
|
||||
}
|
||||
|
||||
return $user->passkeys()
|
||||
->select(['id', 'user_id', 'name', 'credential', 'created_at', 'last_used_at'])
|
||||
->latest()
|
||||
->get();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user