diff --git a/app/Enums/RoomStateChange.php b/app/Enums/RoomStateChange.php index 53de42e..bb0f354 100644 --- a/app/Enums/RoomStateChange.php +++ b/app/Enums/RoomStateChange.php @@ -9,6 +9,7 @@ enum RoomStateChange: string case CardsRevealed = 'cards_revealed'; case VotesReset = 'votes_reset'; case NextRound = 'next_round'; + case PermissionsUpdated = 'permissions_updated'; case RoomReopened = 'room_reopened'; case RoomClosed = 'room_closed'; case RoomDeleted = 'room_deleted'; diff --git a/app/Http/Controllers/RoomPermissionController.php b/app/Http/Controllers/RoomPermissionController.php new file mode 100644 index 0000000..149009b --- /dev/null +++ b/app/Http/Controllers/RoomPermissionController.php @@ -0,0 +1,25 @@ +roomPermissions->updateParticipantControls( + $room, + (bool) $request->validated('allow_participant_controls'), + ); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/RoundController.php b/app/Http/Controllers/RoundController.php index 674e080..4751960 100644 --- a/app/Http/Controllers/RoundController.php +++ b/app/Http/Controllers/RoundController.php @@ -16,7 +16,7 @@ class RoundController extends Controller public function reset(Request $request, Room $room): Response { - $this->ensureModerator($request, $room); + $this->ensureCanControlRound($request, $room); $this->rounds->reset($room); return response()->noContent(); @@ -24,7 +24,7 @@ class RoundController extends Controller public function reveal(Request $request, Room $room): Response { - $this->ensureModerator($request, $room); + $this->ensureCanControlRound($request, $room); $this->rounds->reveal($room); return response()->noContent(); @@ -32,20 +32,20 @@ class RoundController extends Controller public function nextRound(Request $request, Room $room): Response { - $this->ensureModerator($request, $room); + $this->ensureCanControlRound($request, $room); $this->rounds->startNext($room); return response()->noContent(); } - private function ensureModerator(Request $request, Room $room): void + private function ensureCanControlRound(Request $request, Room $room): void { $player = $request->attributes->get('currentPlayer'); abort_unless( $player instanceof Player && $player->room_id === $room->id - && $player->is_moderator, + && ($player->is_moderator || $room->allow_participant_controls), 403, ); } diff --git a/app/Http/Requests/UpdateRoomPermissionsRequest.php b/app/Http/Requests/UpdateRoomPermissionsRequest.php new file mode 100644 index 0000000..e1b91ee --- /dev/null +++ b/app/Http/Requests/UpdateRoomPermissionsRequest.php @@ -0,0 +1,37 @@ +route('room'); + $player = $this->attributes->get('currentPlayer'); + + return $room instanceof Room + && $player instanceof Player + && $player->room_id === $room->id + && $player->is_moderator; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'allow_participant_controls' => ['required', 'boolean:strict'], + ]; + } +} diff --git a/app/Http/Resources/RoomResource.php b/app/Http/Resources/RoomResource.php index e2a99c7..20bf52c 100644 --- a/app/Http/Resources/RoomResource.php +++ b/app/Http/Resources/RoomResource.php @@ -26,6 +26,7 @@ class RoomResource extends JsonResource 'current_round' => $room->current_round, 'revealed' => $room->revealed_at !== null, 'state_version' => $room->state_version, + 'allow_participant_controls' => $room->allow_participant_controls, 'statistics' => $pageData->statistics, 'deck' => (new RoomDeckResource($room->deck_config))->resolve($request), 'players' => $room->players diff --git a/app/Models/Room.php b/app/Models/Room.php index be345ad..d3177bc 100644 --- a/app/Models/Room.php +++ b/app/Models/Room.php @@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property int $current_round * @property CarbonImmutable|null $revealed_at * @property int $state_version + * @property bool $allow_participant_controls * @property CarbonImmutable|null $last_activity_at * @property CarbonImmutable|null $closed_at * @property CarbonImmutable|null $deleted_at @@ -35,6 +36,7 @@ class Room extends Model 'deck_config', 'revealed_at', 'state_version', + 'allow_participant_controls', 'last_activity_at', 'closed_at', ]; @@ -44,6 +46,7 @@ class Room extends Model return [ 'current_round' => 'integer', 'state_version' => 'integer', + 'allow_participant_controls' => 'boolean', 'revealed_at' => 'immutable_datetime', 'last_activity_at' => 'immutable_datetime', 'closed_at' => 'immutable_datetime', @@ -51,6 +54,10 @@ class Room extends Model ]; } + protected $attributes = [ + 'allow_participant_controls' => false, + ]; + protected static function booted(): void { static::creating(function (Room $room) { diff --git a/app/Services/Rooms/RoomPermissionService.php b/app/Services/Rooms/RoomPermissionService.php new file mode 100644 index 0000000..eb9d9a2 --- /dev/null +++ b/app/Services/Rooms/RoomPermissionService.php @@ -0,0 +1,47 @@ +lockForUpdate() + ->findOrFail($room->id); + + if ($lockedRoom->allow_participant_controls === $allowParticipantControls) { + return [$lockedRoom->state_version, false]; + } + + $lockedRoom->update([ + 'allow_participant_controls' => $allowParticipantControls, + 'last_activity_at' => now(), + 'state_version' => $lockedRoom->state_version + 1, + ]); + + return [$lockedRoom->state_version, true]; + }); + + if (! $wasChanged) { + return; + } + + RoomStateChanged::dispatch( + $room->id, + $stateVersion, + RoomStateChange::PermissionsUpdated, + $this->realtimeState->for($room), + ); + } +} diff --git a/app/Services/Rooms/RoomRealtimeStateService.php b/app/Services/Rooms/RoomRealtimeStateService.php index 7871845..49b32e2 100644 --- a/app/Services/Rooms/RoomRealtimeStateService.php +++ b/app/Services/Rooms/RoomRealtimeStateService.php @@ -15,6 +15,7 @@ class RoomRealtimeStateService * current_round: int, * revealed: bool, * state_version: int, + * allow_participant_controls: bool, * statistics: array{average: string|null, median: string|null}|null, * players: array $room->current_round, 'revealed' => $room->revealed_at !== null, 'state_version' => $room->state_version, + 'allow_participant_controls' => $room->allow_participant_controls, 'statistics' => $this->statistics->currentRound($room), 'players' => $room->players ->map(function ($player) use ($room): array { diff --git a/database/migrations/2026_08_27_133231_add_allow_participant_controls_to_rooms_table.php b/database/migrations/2026_08_27_133231_add_allow_participant_controls_to_rooms_table.php new file mode 100644 index 0000000..6774c4d --- /dev/null +++ b/database/migrations/2026_08_27_133231_add_allow_participant_controls_to_rooms_table.php @@ -0,0 +1,30 @@ +boolean('allow_participant_controls') + ->default(false) + ->after('state_version'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('rooms', function (Blueprint $table) { + $table->dropColumn('allow_participant_controls'); + }); + } +}; diff --git a/resources/js/components/rooms/RoomHeader.vue b/resources/js/components/rooms/RoomHeader.vue index 5dea09c..127f2a5 100644 --- a/resources/js/components/rooms/RoomHeader.vue +++ b/resources/js/components/rooms/RoomHeader.vue @@ -1,16 +1,32 @@