From 158ccdbac73fe2c16eaa610cc69c44fa8f32a85f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Wed, 4 Mar 2026 14:54:56 +0100 Subject: [PATCH 1/8] Competitions management --- migrations/Version20260304133125.php | 95 ++++++++++ src/Controller/AddCompetitionController.php | 82 +++++++++ .../AddCompetitionRoundController.php | 74 ++++++++ src/Controller/AddPuzzleToRoundController.php | 83 +++++++++ .../Admin/ApproveCompetitionController.php | 46 +++++ .../Admin/CompetitionApprovalsController.php | 34 ++++ .../DeleteCompetitionRoundController.php | 47 +++++ src/Controller/EditCompetitionController.php | 80 +++++++++ .../EditCompetitionRoundController.php | 77 ++++++++ src/Controller/EventsController.php | 10 ++ ...anageCompetitionParticipantsController.php | 39 +++++ .../ManageCompetitionRoundsController.php | 43 +++++ .../ManageRoundPuzzlesController.php | 48 +++++ .../PlayerSearchAutocompleteController.php | 52 ++++++ .../RemovePuzzleFromRoundController.php | 48 +++++ src/Entity/Competition.php | 64 +++++++ src/Entity/CompetitionRound.php | 22 ++- src/Entity/CompetitionRoundPuzzle.php | 34 ++++ src/Entity/Puzzle.php | 3 + src/Exceptions/CompetitionRoundNotFound.php | 11 ++ src/FormData/CompetitionFormData.php | 60 +++++++ src/FormData/CompetitionRoundFormData.php | 36 ++++ src/FormData/RoundPuzzleFormData.php | 24 +++ src/FormType/CompetitionFormType.php | 165 ++++++++++++++++++ src/FormType/CompetitionRoundFormType.php | 53 ++++++ src/FormType/RoundPuzzleFormType.php | 121 +++++++++++++ src/Message/AddCompetition.php | 34 ++++ src/Message/AddCompetitionRound.php | 22 +++ src/Message/AddPuzzleToCompetitionRound.php | 25 +++ src/Message/ApproveCompetition.php | 14 ++ src/Message/DeleteCompetitionRound.php | 13 ++ src/Message/EditCompetition.php | 32 ++++ src/Message/EditCompetitionRound.php | 20 +++ .../RemovePuzzleFromCompetitionRound.php | 13 ++ src/MessageHandler/AddCompetitionHandler.php | 100 +++++++++++ .../AddCompetitionRoundHandler.php | 38 ++++ .../AddPuzzleToCompetitionRoundHandler.php | 118 +++++++++++++ .../ApproveCompetitionHandler.php | 30 ++++ .../DeleteCompetitionRoundHandler.php | 24 +++ src/MessageHandler/EditCompetitionHandler.php | 99 +++++++++++ .../EditCompetitionRoundHandler.php | 31 ++++ ...emovePuzzleFromCompetitionRoundHandler.php | 24 +++ src/Query/GetCompetitionEvents.php | 61 ++++++- .../GetCompetitionRoundsForManagement.php | 69 ++++++++ src/Query/GetPuzzleIdsForSitemap.php | 15 +- src/Query/GetPuzzlesOverview.php | 4 +- src/Query/GetRoundPuzzlesForManagement.php | 73 ++++++++ src/Query/IsCompetitionMaintainer.php | 36 ++++ src/Query/SearchPuzzle.php | 6 +- .../CompetitionRoundPuzzleRepository.php | 42 +++++ src/Repository/CompetitionRoundRepository.php | 42 +++++ src/Results/CompetitionEvent.php | 17 ++ src/Results/CompetitionRoundForManagement.php | 21 +++ src/Results/RoundPuzzleForManagement.php | 19 ++ src/Security/CompetitionEditVoter.php | 45 +++++ templates/_competition_event.html.twig | 7 +- templates/add_competition.html.twig | 46 +++++ templates/add_competition_round.html.twig | 34 ++++ templates/add_puzzle_to_round.html.twig | 36 ++++ .../admin/competition_approvals.html.twig | 73 ++++++++ templates/base.html.twig | 5 + templates/edit_competition.html.twig | 59 +++++++ templates/edit_competition_round.html.twig | 46 +++++ templates/events.html.twig | 42 ++++- .../manage_competition_participants.html.twig | 21 +++ templates/manage_competition_rounds.html.twig | 77 ++++++++ templates/manage_round_puzzles.html.twig | 68 ++++++++ tests/DataFixtures/CompetitionFixture.php | 31 +++- .../DataFixtures/CompetitionRoundFixture.php | 33 ++-- translations/messages.en.yml | 75 ++++++++ 70 files changed, 3157 insertions(+), 34 deletions(-) create mode 100644 migrations/Version20260304133125.php create mode 100644 src/Controller/AddCompetitionController.php create mode 100644 src/Controller/AddCompetitionRoundController.php create mode 100644 src/Controller/AddPuzzleToRoundController.php create mode 100644 src/Controller/Admin/ApproveCompetitionController.php create mode 100644 src/Controller/Admin/CompetitionApprovalsController.php create mode 100644 src/Controller/DeleteCompetitionRoundController.php create mode 100644 src/Controller/EditCompetitionController.php create mode 100644 src/Controller/EditCompetitionRoundController.php create mode 100644 src/Controller/ManageCompetitionParticipantsController.php create mode 100644 src/Controller/ManageCompetitionRoundsController.php create mode 100644 src/Controller/ManageRoundPuzzlesController.php create mode 100644 src/Controller/PlayerSearchAutocompleteController.php create mode 100644 src/Controller/RemovePuzzleFromRoundController.php create mode 100644 src/Entity/CompetitionRoundPuzzle.php create mode 100644 src/Exceptions/CompetitionRoundNotFound.php create mode 100644 src/FormData/CompetitionFormData.php create mode 100644 src/FormData/CompetitionRoundFormData.php create mode 100644 src/FormData/RoundPuzzleFormData.php create mode 100644 src/FormType/CompetitionFormType.php create mode 100644 src/FormType/CompetitionRoundFormType.php create mode 100644 src/FormType/RoundPuzzleFormType.php create mode 100644 src/Message/AddCompetition.php create mode 100644 src/Message/AddCompetitionRound.php create mode 100644 src/Message/AddPuzzleToCompetitionRound.php create mode 100644 src/Message/ApproveCompetition.php create mode 100644 src/Message/DeleteCompetitionRound.php create mode 100644 src/Message/EditCompetition.php create mode 100644 src/Message/EditCompetitionRound.php create mode 100644 src/Message/RemovePuzzleFromCompetitionRound.php create mode 100644 src/MessageHandler/AddCompetitionHandler.php create mode 100644 src/MessageHandler/AddCompetitionRoundHandler.php create mode 100644 src/MessageHandler/AddPuzzleToCompetitionRoundHandler.php create mode 100644 src/MessageHandler/ApproveCompetitionHandler.php create mode 100644 src/MessageHandler/DeleteCompetitionRoundHandler.php create mode 100644 src/MessageHandler/EditCompetitionHandler.php create mode 100644 src/MessageHandler/EditCompetitionRoundHandler.php create mode 100644 src/MessageHandler/RemovePuzzleFromCompetitionRoundHandler.php create mode 100644 src/Query/GetCompetitionRoundsForManagement.php create mode 100644 src/Query/GetRoundPuzzlesForManagement.php create mode 100644 src/Query/IsCompetitionMaintainer.php create mode 100644 src/Repository/CompetitionRoundPuzzleRepository.php create mode 100644 src/Repository/CompetitionRoundRepository.php create mode 100644 src/Results/CompetitionRoundForManagement.php create mode 100644 src/Results/RoundPuzzleForManagement.php create mode 100644 src/Security/CompetitionEditVoter.php create mode 100644 templates/add_competition.html.twig create mode 100644 templates/add_competition_round.html.twig create mode 100644 templates/add_puzzle_to_round.html.twig create mode 100644 templates/admin/competition_approvals.html.twig create mode 100644 templates/edit_competition.html.twig create mode 100644 templates/edit_competition_round.html.twig create mode 100644 templates/manage_competition_participants.html.twig create mode 100644 templates/manage_competition_rounds.html.twig create mode 100644 templates/manage_round_puzzles.html.twig diff --git a/migrations/Version20260304133125.php b/migrations/Version20260304133125.php new file mode 100644 index 00000000..75b604e8 --- /dev/null +++ b/migrations/Version20260304133125.php @@ -0,0 +1,95 @@ +addSql('CREATE TABLE competition_maintainer (competition_id UUID NOT NULL, player_id UUID NOT NULL, PRIMARY KEY (competition_id, player_id))'); + $this->addSql('CREATE INDEX IDX_ADB6DE3A7B39D312 ON competition_maintainer (competition_id)'); + $this->addSql('CREATE INDEX IDX_ADB6DE3A99E6F5DF ON competition_maintainer (player_id)'); + $this->addSql('ALTER TABLE competition_maintainer ADD CONSTRAINT FK_ADB6DE3A7B39D312 FOREIGN KEY (competition_id) REFERENCES competition (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE competition_maintainer ADD CONSTRAINT FK_ADB6DE3A99E6F5DF FOREIGN KEY (player_id) REFERENCES player (id) ON DELETE CASCADE'); + + // Competition approval fields + $this->addSql('ALTER TABLE competition ADD approved_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE competition ADD created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE competition ADD added_by_player_id UUID DEFAULT NULL'); + $this->addSql('ALTER TABLE competition ADD approved_by_player_id UUID DEFAULT NULL'); + $this->addSql('ALTER TABLE competition ADD CONSTRAINT FK_B50A2CB13EDBBB76 FOREIGN KEY (added_by_player_id) REFERENCES player (id)'); + $this->addSql('ALTER TABLE competition ADD CONSTRAINT FK_B50A2CB143132E94 FOREIGN KEY (approved_by_player_id) REFERENCES player (id)'); + $this->addSql('CREATE INDEX IDX_B50A2CB13EDBBB76 ON competition (added_by_player_id)'); + $this->addSql('CREATE INDEX IDX_B50A2CB143132E94 ON competition (approved_by_player_id)'); + + // Set all existing competitions as approved + $this->addSql('UPDATE competition SET approved_at = NOW() WHERE approved_at IS NULL'); + + // Convert competition_round_puzzle from ManyToMany join table to proper entity + // First, save existing data + $this->addSql('CREATE TEMP TABLE _crp_backup AS SELECT competition_round_id, puzzle_id FROM competition_round_puzzle'); + + // Drop old table constraints and table + $this->addSql('ALTER TABLE competition_round_puzzle DROP CONSTRAINT IF EXISTS fk_51841be7d9816812'); + $this->addSql('ALTER TABLE competition_round_puzzle DROP CONSTRAINT IF EXISTS fk_51841be79771678f'); + $this->addSql('DROP TABLE competition_round_puzzle'); + + // Create new entity table + $this->addSql('CREATE TABLE competition_round_puzzle (id UUID NOT NULL, round_id UUID NOT NULL, puzzle_id UUID NOT NULL, hide_until_round_starts BOOLEAN DEFAULT false NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_51841BE7A6005CA0 ON competition_round_puzzle (round_id)'); + $this->addSql('CREATE INDEX IDX_51841BE7D9816812 ON competition_round_puzzle (puzzle_id)'); + $this->addSql('ALTER TABLE competition_round_puzzle ADD CONSTRAINT FK_51841BE7A6005CA0 FOREIGN KEY (round_id) REFERENCES competition_round (id) NOT DEFERRABLE'); + $this->addSql('ALTER TABLE competition_round_puzzle ADD CONSTRAINT FK_51841BE7D9816812 FOREIGN KEY (puzzle_id) REFERENCES puzzle (id) NOT DEFERRABLE'); + + // Restore data with generated UUIDs + $this->addSql('INSERT INTO competition_round_puzzle (id, round_id, puzzle_id, hide_until_round_starts) SELECT gen_random_uuid(), competition_round_id, puzzle_id, false FROM _crp_backup'); + $this->addSql('DROP TABLE _crp_backup'); + + // Puzzle hide_until field + $this->addSql('ALTER TABLE puzzle ADD hide_until TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE competition_maintainer DROP CONSTRAINT FK_ADB6DE3A7B39D312'); + $this->addSql('ALTER TABLE competition_maintainer DROP CONSTRAINT FK_ADB6DE3A99E6F5DF'); + $this->addSql('DROP TABLE competition_maintainer'); + + $this->addSql('ALTER TABLE competition DROP CONSTRAINT FK_B50A2CB13EDBBB76'); + $this->addSql('ALTER TABLE competition DROP CONSTRAINT FK_B50A2CB143132E94'); + $this->addSql('DROP INDEX IDX_B50A2CB13EDBBB76'); + $this->addSql('DROP INDEX IDX_B50A2CB143132E94'); + $this->addSql('ALTER TABLE competition DROP approved_at'); + $this->addSql('ALTER TABLE competition DROP created_at'); + $this->addSql('ALTER TABLE competition DROP added_by_player_id'); + $this->addSql('ALTER TABLE competition DROP approved_by_player_id'); + + // Restore old ManyToMany join table + $this->addSql('CREATE TEMP TABLE _crp_backup AS SELECT round_id, puzzle_id FROM competition_round_puzzle'); + $this->addSql('ALTER TABLE competition_round_puzzle DROP CONSTRAINT FK_51841BE7A6005CA0'); + $this->addSql('ALTER TABLE competition_round_puzzle DROP CONSTRAINT FK_51841BE7D9816812'); + $this->addSql('DROP TABLE competition_round_puzzle'); + + $this->addSql('CREATE TABLE competition_round_puzzle (competition_round_id UUID NOT NULL, puzzle_id UUID NOT NULL, PRIMARY KEY (competition_round_id, puzzle_id))'); + $this->addSql('CREATE INDEX idx_51841be79771678f ON competition_round_puzzle (competition_round_id)'); + $this->addSql('CREATE INDEX idx_51841be7d9816812 ON competition_round_puzzle (puzzle_id)'); + $this->addSql('ALTER TABLE competition_round_puzzle ADD CONSTRAINT fk_51841be79771678f FOREIGN KEY (competition_round_id) REFERENCES competition_round (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE competition_round_puzzle ADD CONSTRAINT fk_51841be7d9816812 FOREIGN KEY (puzzle_id) REFERENCES puzzle (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + + $this->addSql('INSERT INTO competition_round_puzzle (competition_round_id, puzzle_id) SELECT round_id, puzzle_id FROM _crp_backup'); + $this->addSql('DROP TABLE _crp_backup'); + + $this->addSql('ALTER TABLE puzzle DROP hide_until'); + } +} diff --git a/src/Controller/AddCompetitionController.php b/src/Controller/AddCompetitionController.php new file mode 100644 index 00000000..195c3482 --- /dev/null +++ b/src/Controller/AddCompetitionController.php @@ -0,0 +1,82 @@ + '/pridat-udalost', + 'en' => '/en/add-event', + ], + name: 'add_competition', + )] + public function __invoke(Request $request, #[CurrentUser] User $user): Response + { + $player = $this->retrieveLoggedUserProfile->getProfile(); + + if ($player === null) { + return $this->redirectToRoute('events'); + } + + $formData = new CompetitionFormData(); + $form = $this->createForm(CompetitionFormType::class, $formData); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $data = $form->getData(); + $competitionId = Uuid::uuid7(); + + $this->messageBus->dispatch(new AddCompetition( + competitionId: $competitionId, + playerId: $player->playerId, + name: $data->name ?? '', + shortcut: $data->shortcut, + description: $data->description, + link: $data->link, + registrationLink: $data->registrationLink, + resultsLink: $data->resultsLink, + location: $data->location ?? '', + locationCountryCode: $data->locationCountryCode, + dateFrom: $data->dateFrom, + dateTo: $data->dateTo, + isOnline: $data->isOnline, + logo: $data->logo, + maintainerIds: $data->maintainers, + )); + + $this->addFlash('success', $this->translator->trans('competition.flash.created')); + + return $this->redirectToRoute('edit_competition', ['competitionId' => $competitionId->toString()]); + } + + return $this->render('add_competition.html.twig', [ + 'form' => $form, + ]); + } +} diff --git a/src/Controller/AddCompetitionRoundController.php b/src/Controller/AddCompetitionRoundController.php new file mode 100644 index 00000000..843647bf --- /dev/null +++ b/src/Controller/AddCompetitionRoundController.php @@ -0,0 +1,74 @@ + '/pridat-kolo-udalosti/{competitionId}', + 'en' => '/en/add-event-round/{competitionId}', + ], + name: 'add_competition_round', + )] + public function __invoke(Request $request, string $competitionId): Response + { + $this->denyAccessUnlessGranted(CompetitionEditVoter::COMPETITION_EDIT, $competitionId); + + $competition = $this->getCompetitionEvents->byId($competitionId); + + $formData = new CompetitionRoundFormData(); + $form = $this->createForm(CompetitionRoundFormType::class, $formData); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $data = $form->getData(); + assert($data->name !== null); + assert($data->minutesLimit !== null); + assert($data->startsAt !== null); + + $this->messageBus->dispatch(new AddCompetitionRound( + roundId: Uuid::uuid7(), + competitionId: $competitionId, + name: $data->name, + minutesLimit: $data->minutesLimit, + startsAt: $data->startsAt, + badgeBackgroundColor: $data->badgeBackgroundColor, + badgeTextColor: $data->badgeTextColor, + )); + + $this->addFlash('success', $this->translator->trans('competition.flash.round_added')); + + return $this->redirectToRoute('manage_competition_rounds', ['competitionId' => $competitionId]); + } + + return $this->render('add_competition_round.html.twig', [ + 'form' => $form, + 'competition' => $competition, + ]); + } +} diff --git a/src/Controller/AddPuzzleToRoundController.php b/src/Controller/AddPuzzleToRoundController.php new file mode 100644 index 00000000..24ca6d4b --- /dev/null +++ b/src/Controller/AddPuzzleToRoundController.php @@ -0,0 +1,83 @@ + '/pridat-puzzle-do-kola/{roundId}', + 'en' => '/en/add-puzzle-to-round/{roundId}', + ], + name: 'add_puzzle_to_round', + )] + public function __invoke(Request $request, string $roundId, #[CurrentUser] User $user): Response + { + $round = $this->competitionRoundRepository->get($roundId); + $competitionId = $round->competition->id->toString(); + $this->denyAccessUnlessGranted(CompetitionEditVoter::COMPETITION_EDIT, $competitionId); + + $competition = $this->getCompetitionEvents->byId($competitionId); + + $formData = new RoundPuzzleFormData(); + $form = $this->createForm(RoundPuzzleFormType::class, $formData); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $data = $form->getData(); + assert($data->puzzle !== null); + assert($data->brand !== null); + + $this->messageBus->dispatch(new AddPuzzleToCompetitionRound( + roundPuzzleId: Uuid::uuid7(), + roundId: $roundId, + userId: $user->getUserIdentifier(), + brand: $data->brand, + puzzle: $data->puzzle, + piecesCount: $data->piecesCount, + puzzlePhoto: $data->puzzlePhoto, + puzzleEan: $data->puzzleEan, + puzzleIdentificationNumber: $data->puzzleIdentificationNumber, + hideUntilRoundStarts: $data->hideUntilRoundStarts, + )); + + $this->addFlash('success', $this->translator->trans('competition.flash.puzzle_added')); + + return $this->redirectToRoute('manage_round_puzzles', ['roundId' => $roundId]); + } + + return $this->render('add_puzzle_to_round.html.twig', [ + 'form' => $form, + 'competition' => $competition, + 'round' => $round, + ]); + } +} diff --git a/src/Controller/Admin/ApproveCompetitionController.php b/src/Controller/Admin/ApproveCompetitionController.php new file mode 100644 index 00000000..6d8a9499 --- /dev/null +++ b/src/Controller/Admin/ApproveCompetitionController.php @@ -0,0 +1,46 @@ +retrieveLoggedUserProfile->getProfile(); + assert($profile !== null); + + $this->messageBus->dispatch(new ApproveCompetition( + competitionId: $competitionId, + approvedByPlayerId: $profile->playerId, + )); + + $this->addFlash('success', $this->translator->trans('competition.flash.approved')); + + return $this->redirectToRoute('admin_competition_approvals'); + } +} diff --git a/src/Controller/Admin/CompetitionApprovalsController.php b/src/Controller/Admin/CompetitionApprovalsController.php new file mode 100644 index 00000000..46d66884 --- /dev/null +++ b/src/Controller/Admin/CompetitionApprovalsController.php @@ -0,0 +1,34 @@ +getCompetitionEvents->allUnapproved(); + + return $this->render('admin/competition_approvals.html.twig', [ + 'competitions' => $unapprovedCompetitions, + ]); + } +} diff --git a/src/Controller/DeleteCompetitionRoundController.php b/src/Controller/DeleteCompetitionRoundController.php new file mode 100644 index 00000000..ce869f86 --- /dev/null +++ b/src/Controller/DeleteCompetitionRoundController.php @@ -0,0 +1,47 @@ + '/smazat-kolo-udalosti/{roundId}', + 'en' => '/en/delete-event-round/{roundId}', + ], + name: 'delete_competition_round', + methods: ['POST'], + )] + public function __invoke(string $roundId): Response + { + $round = $this->competitionRoundRepository->get($roundId); + $competitionId = $round->competition->id->toString(); + $this->denyAccessUnlessGranted(CompetitionEditVoter::COMPETITION_EDIT, $competitionId); + + $this->messageBus->dispatch(new DeleteCompetitionRound(roundId: $roundId)); + + $this->addFlash('success', $this->translator->trans('competition.flash.round_deleted')); + + return $this->redirectToRoute('manage_competition_rounds', ['competitionId' => $competitionId]); + } +} diff --git a/src/Controller/EditCompetitionController.php b/src/Controller/EditCompetitionController.php new file mode 100644 index 00000000..ff9b733e --- /dev/null +++ b/src/Controller/EditCompetitionController.php @@ -0,0 +1,80 @@ + '/upravit-udalost/{competitionId}', + 'en' => '/en/edit-event/{competitionId}', + ], + name: 'edit_competition', + )] + public function __invoke(Request $request, string $competitionId): Response + { + $this->denyAccessUnlessGranted(CompetitionEditVoter::COMPETITION_EDIT, $competitionId); + + $competition = $this->competitionRepository->get($competitionId); + $competitionEvent = $this->getCompetitionEvents->byId($competitionId); + + $formData = CompetitionFormData::fromCompetition($competition); + $form = $this->createForm(CompetitionFormType::class, $formData); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $data = $form->getData(); + + $this->messageBus->dispatch(new EditCompetition( + competitionId: $competitionId, + name: $data->name ?? '', + shortcut: $data->shortcut, + description: $data->description, + link: $data->link, + registrationLink: $data->registrationLink, + resultsLink: $data->resultsLink, + location: $data->location ?? '', + locationCountryCode: $data->locationCountryCode, + dateFrom: $data->dateFrom, + dateTo: $data->dateTo, + isOnline: $data->isOnline, + logo: $data->logo, + maintainerIds: $data->maintainers, + )); + + $this->addFlash('success', $this->translator->trans('competition.flash.updated')); + + return $this->redirectToRoute('edit_competition', ['competitionId' => $competitionId]); + } + + return $this->render('edit_competition.html.twig', [ + 'form' => $form, + 'competition' => $competitionEvent, + ]); + } +} diff --git a/src/Controller/EditCompetitionRoundController.php b/src/Controller/EditCompetitionRoundController.php new file mode 100644 index 00000000..c9d43ce4 --- /dev/null +++ b/src/Controller/EditCompetitionRoundController.php @@ -0,0 +1,77 @@ + '/upravit-kolo-udalosti/{roundId}', + 'en' => '/en/edit-event-round/{roundId}', + ], + name: 'edit_competition_round', + )] + public function __invoke(Request $request, string $roundId): Response + { + $round = $this->competitionRoundRepository->get($roundId); + $competitionId = $round->competition->id->toString(); + $this->denyAccessUnlessGranted(CompetitionEditVoter::COMPETITION_EDIT, $competitionId); + + $competition = $this->getCompetitionEvents->byId($competitionId); + + $formData = CompetitionRoundFormData::fromCompetitionRound($round); + $form = $this->createForm(CompetitionRoundFormType::class, $formData); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $data = $form->getData(); + assert($data->name !== null); + assert($data->minutesLimit !== null); + assert($data->startsAt !== null); + + $this->messageBus->dispatch(new EditCompetitionRound( + roundId: $roundId, + name: $data->name, + minutesLimit: $data->minutesLimit, + startsAt: $data->startsAt, + badgeBackgroundColor: $data->badgeBackgroundColor, + badgeTextColor: $data->badgeTextColor, + )); + + $this->addFlash('success', $this->translator->trans('competition.flash.round_updated')); + + return $this->redirectToRoute('manage_competition_rounds', ['competitionId' => $competitionId]); + } + + return $this->render('edit_competition_round.html.twig', [ + 'form' => $form, + 'competition' => $competition, + 'round' => $round, + ]); + } +} diff --git a/src/Controller/EventsController.php b/src/Controller/EventsController.php index fc6d34bd..c9651865 100644 --- a/src/Controller/EventsController.php +++ b/src/Controller/EventsController.php @@ -5,6 +5,7 @@ namespace SpeedPuzzling\Web\Controller; use SpeedPuzzling\Web\Query\GetCompetitionEvents; +use SpeedPuzzling\Web\Services\RetrieveLoggedUserProfile; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -13,6 +14,7 @@ final class EventsController extends AbstractController { public function __construct( readonly private GetCompetitionEvents $getCompetitionEvents, + readonly private RetrieveLoggedUserProfile $retrieveLoggedUserProfile, ) { } @@ -29,10 +31,18 @@ public function __construct( )] public function __invoke(): Response { + $playerCompetitions = []; + $profile = $this->retrieveLoggedUserProfile->getProfile(); + + if ($profile !== null) { + $playerCompetitions = $this->getCompetitionEvents->allForPlayer($profile->playerId); + } + return $this->render('events.html.twig', [ 'live_events' => $this->getCompetitionEvents->allLive(), 'upcoming_events' => $this->getCompetitionEvents->allUpcoming(), 'past_events' => $this->getCompetitionEvents->allPast(), + 'player_competitions' => $playerCompetitions, ]); } } diff --git a/src/Controller/ManageCompetitionParticipantsController.php b/src/Controller/ManageCompetitionParticipantsController.php new file mode 100644 index 00000000..908bf925 --- /dev/null +++ b/src/Controller/ManageCompetitionParticipantsController.php @@ -0,0 +1,39 @@ + '/sprava-ucastniku-udalosti/{competitionId}', + 'en' => '/en/manage-event-participants/{competitionId}', + ], + name: 'manage_competition_participants', + )] + public function __invoke(string $competitionId): Response + { + $this->denyAccessUnlessGranted(CompetitionEditVoter::COMPETITION_EDIT, $competitionId); + + $competition = $this->getCompetitionEvents->byId($competitionId); + + return $this->render('manage_competition_participants.html.twig', [ + 'competition' => $competition, + ]); + } +} diff --git a/src/Controller/ManageCompetitionRoundsController.php b/src/Controller/ManageCompetitionRoundsController.php new file mode 100644 index 00000000..1f093aa5 --- /dev/null +++ b/src/Controller/ManageCompetitionRoundsController.php @@ -0,0 +1,43 @@ + '/sprava-kol-udalosti/{competitionId}', + 'en' => '/en/manage-event-rounds/{competitionId}', + ], + name: 'manage_competition_rounds', + )] + public function __invoke(string $competitionId): Response + { + $this->denyAccessUnlessGranted(CompetitionEditVoter::COMPETITION_EDIT, $competitionId); + + $competition = $this->getCompetitionEvents->byId($competitionId); + $rounds = $this->getCompetitionRoundsForManagement->ofCompetition($competitionId); + + return $this->render('manage_competition_rounds.html.twig', [ + 'competition' => $competition, + 'rounds' => $rounds, + ]); + } +} diff --git a/src/Controller/ManageRoundPuzzlesController.php b/src/Controller/ManageRoundPuzzlesController.php new file mode 100644 index 00000000..3f35307e --- /dev/null +++ b/src/Controller/ManageRoundPuzzlesController.php @@ -0,0 +1,48 @@ + '/sprava-puzzli-kola/{roundId}', + 'en' => '/en/manage-round-puzzles/{roundId}', + ], + name: 'manage_round_puzzles', + )] + public function __invoke(string $roundId): Response + { + $round = $this->competitionRoundRepository->get($roundId); + $competitionId = $round->competition->id->toString(); + $this->denyAccessUnlessGranted(CompetitionEditVoter::COMPETITION_EDIT, $competitionId); + + $competition = $this->getCompetitionEvents->byId($competitionId); + $puzzles = $this->getRoundPuzzlesForManagement->ofRound($roundId); + + return $this->render('manage_round_puzzles.html.twig', [ + 'competition' => $competition, + 'round' => $round, + 'puzzles' => $puzzles, + ]); + } +} diff --git a/src/Controller/PlayerSearchAutocompleteController.php b/src/Controller/PlayerSearchAutocompleteController.php new file mode 100644 index 00000000..ce101483 --- /dev/null +++ b/src/Controller/PlayerSearchAutocompleteController.php @@ -0,0 +1,52 @@ +query->getString('query', ''); + + if (strlen($search) < 2) { + return new JsonResponse([]); + } + + $players = $this->searchPlayers->fulltext($search, 15); + + $results = []; + foreach ($players as $player) { + $label = $player->playerName ?? $player->playerCode; + + if ($player->playerCountry !== null) { + $label .= " ({$player->playerCountry->name})"; + } + + $results[] = [ + 'value' => $player->playerId, + 'text' => $label, + ]; + } + + return new JsonResponse($results); + } +} diff --git a/src/Controller/RemovePuzzleFromRoundController.php b/src/Controller/RemovePuzzleFromRoundController.php new file mode 100644 index 00000000..b0a1b441 --- /dev/null +++ b/src/Controller/RemovePuzzleFromRoundController.php @@ -0,0 +1,48 @@ + '/odebrat-puzzle-z-kola/{roundPuzzleId}', + 'en' => '/en/remove-puzzle-from-round/{roundPuzzleId}', + ], + name: 'remove_puzzle_from_round', + methods: ['POST'], + )] + public function __invoke(string $roundPuzzleId): Response + { + $roundPuzzle = $this->competitionRoundPuzzleRepository->get($roundPuzzleId); + $round = $roundPuzzle->round; + $competitionId = $round->competition->id->toString(); + $this->denyAccessUnlessGranted(CompetitionEditVoter::COMPETITION_EDIT, $competitionId); + + $this->messageBus->dispatch(new RemovePuzzleFromCompetitionRound(roundPuzzleId: $roundPuzzleId)); + + $this->addFlash('success', $this->translator->trans('competition.flash.puzzle_removed')); + + return $this->redirectToRoute('manage_round_puzzles', ['roundId' => $round->id->toString()]); + } +} diff --git a/src/Entity/Competition.php b/src/Entity/Competition.php index 3874487c..8f2a67b7 100644 --- a/src/Entity/Competition.php +++ b/src/Entity/Competition.php @@ -5,10 +5,14 @@ namespace SpeedPuzzling\Web\Entity; use DateTimeImmutable; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\Id; +use Doctrine\ORM\Mapping\JoinTable; +use Doctrine\ORM\Mapping\ManyToMany; use Doctrine\ORM\Mapping\ManyToOne; use JetBrains\PhpStorm\Immutable; use Ramsey\Uuid\Doctrine\UuidType; @@ -17,6 +21,9 @@ #[Entity] class Competition { + /** + * @param Collection $maintainers + */ public function __construct( #[Id] #[Immutable] @@ -50,6 +57,63 @@ public function __construct( public null|Tag $tag, #[Column(options: ['default' => false])] public bool $isOnline = false, + #[Immutable] + #[ManyToOne] + public null|Player $addedByPlayer = null, + #[Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + public null|DateTimeImmutable $approvedAt = null, + #[ManyToOne] + public null|Player $approvedByPlayer = null, + #[Immutable] + #[Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + public null|DateTimeImmutable $createdAt = null, + /** + * @var Collection + */ + #[ManyToMany(targetEntity: Player::class)] + #[JoinTable(name: 'competition_maintainer')] + public Collection $maintainers = new ArrayCollection(), ) { } + + public function approve(Player $approvedBy, DateTimeImmutable $approvedAt): void + { + $this->approvedAt = $approvedAt; + $this->approvedByPlayer = $approvedBy; + } + + public function isApproved(): bool + { + return $this->approvedAt !== null; + } + + public function edit( + string $name, + null|string $slug, + null|string $shortcut, + null|string $logo, + null|string $description, + null|string $link, + null|string $registrationLink, + null|string $resultsLink, + string $location, + null|string $locationCountryCode, + null|DateTimeImmutable $dateFrom, + null|DateTimeImmutable $dateTo, + bool $isOnline, + ): void { + $this->name = $name; + $this->slug = $slug; + $this->shortcut = $shortcut; + $this->logo = $logo; + $this->description = $description; + $this->link = $link; + $this->registrationLink = $registrationLink; + $this->resultsLink = $resultsLink; + $this->location = $location; + $this->locationCountryCode = $locationCountryCode; + $this->dateFrom = $dateFrom; + $this->dateTo = $dateTo; + $this->isOnline = $isOnline; + } } diff --git a/src/Entity/CompetitionRound.php b/src/Entity/CompetitionRound.php index c36e899a..a8ee770d 100644 --- a/src/Entity/CompetitionRound.php +++ b/src/Entity/CompetitionRound.php @@ -12,8 +12,8 @@ use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\Id; use Doctrine\ORM\Mapping\JoinColumn; -use Doctrine\ORM\Mapping\ManyToMany; use Doctrine\ORM\Mapping\ManyToOne; +use Doctrine\ORM\Mapping\OneToMany; use JetBrains\PhpStorm\Immutable; use Ramsey\Uuid\Doctrine\UuidType; use Ramsey\Uuid\UuidInterface; @@ -36,14 +36,28 @@ public function __construct( #[Column(type: Types::DATETIME_IMMUTABLE)] public DateTimeImmutable $startsAt, /** - * @var Collection + * @var Collection */ - #[ManyToMany(targetEntity: Puzzle::class)] - public Collection $puzzles = new ArrayCollection(), + #[OneToMany(targetEntity: CompetitionRoundPuzzle::class, mappedBy: 'round')] + public Collection $roundPuzzles = new ArrayCollection(), #[Column(nullable: true)] public null|string $badgeBackgroundColor = null, #[Column(nullable: true)] public null|string $badgeTextColor = null, ) { } + + public function edit( + string $name, + int $minutesLimit, + DateTimeImmutable $startsAt, + null|string $badgeBackgroundColor, + null|string $badgeTextColor, + ): void { + $this->name = $name; + $this->minutesLimit = $minutesLimit; + $this->startsAt = $startsAt; + $this->badgeBackgroundColor = $badgeBackgroundColor; + $this->badgeTextColor = $badgeTextColor; + } } diff --git a/src/Entity/CompetitionRoundPuzzle.php b/src/Entity/CompetitionRoundPuzzle.php new file mode 100644 index 00000000..c38c5a80 --- /dev/null +++ b/src/Entity/CompetitionRoundPuzzle.php @@ -0,0 +1,34 @@ + false])] + public bool $hideUntilRoundStarts = false, + ) { + } +} diff --git a/src/Entity/Puzzle.php b/src/Entity/Puzzle.php index a65c83e3..debe7657 100644 --- a/src/Entity/Puzzle.php +++ b/src/Entity/Puzzle.php @@ -5,6 +5,7 @@ namespace SpeedPuzzling\Web\Entity; use DateTimeImmutable; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\Id; @@ -54,6 +55,8 @@ public function __construct( public bool $isAvailable = false, #[Column(nullable: true)] public null|DateTimeImmutable $hideImageUntil = null, + #[Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + public null|DateTimeImmutable $hideUntil = null, ) { } diff --git a/src/Exceptions/CompetitionRoundNotFound.php b/src/Exceptions/CompetitionRoundNotFound.php new file mode 100644 index 00000000..f6657c2f --- /dev/null +++ b/src/Exceptions/CompetitionRoundNotFound.php @@ -0,0 +1,11 @@ + */ + public array $maintainers = [], + ) { + } + + public static function fromCompetition(Competition $competition): self + { + $data = new self(); + $data->name = $competition->name; + $data->shortcut = $competition->shortcut; + $data->description = $competition->description; + $data->link = $competition->link; + $data->registrationLink = $competition->registrationLink; + $data->resultsLink = $competition->resultsLink; + $data->location = $competition->location; + $data->locationCountryCode = $competition->locationCountryCode; + $data->dateFrom = $competition->dateFrom; + $data->dateTo = $competition->dateTo; + $data->isOnline = $competition->isOnline; + + $maintainerIds = []; + foreach ($competition->maintainers as $maintainer) { + $maintainerIds[] = $maintainer->id->toString(); + } + $data->maintainers = $maintainerIds; + + return $data; + } +} diff --git a/src/FormData/CompetitionRoundFormData.php b/src/FormData/CompetitionRoundFormData.php new file mode 100644 index 00000000..4de76ed6 --- /dev/null +++ b/src/FormData/CompetitionRoundFormData.php @@ -0,0 +1,36 @@ +name = $round->name; + $data->minutesLimit = $round->minutesLimit; + $data->startsAt = $round->startsAt; + $data->badgeBackgroundColor = $round->badgeBackgroundColor; + $data->badgeTextColor = $round->badgeTextColor; + + return $data; + } +} diff --git a/src/FormData/RoundPuzzleFormData.php b/src/FormData/RoundPuzzleFormData.php new file mode 100644 index 00000000..7d298f60 --- /dev/null +++ b/src/FormData/RoundPuzzleFormData.php @@ -0,0 +1,24 @@ + + */ +final class CompetitionFormType extends AbstractType +{ + public function __construct( + private readonly TranslatorInterface $translator, + private readonly UrlGeneratorInterface $urlGenerator, + ) { + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('name', TextType::class, [ + 'label' => 'competition.form.name', + ]); + + $builder->add('shortcut', TextType::class, [ + 'label' => 'competition.form.shortcut', + 'help' => 'competition.form.shortcut_help', + 'required' => false, + ]); + + $builder->add('description', TextareaType::class, [ + 'label' => 'competition.form.description', + 'required' => false, + 'attr' => ['rows' => 4], + ]); + + $builder->add('link', TextType::class, [ + 'label' => 'competition.form.link', + 'required' => false, + ]); + + $builder->add('registrationLink', TextType::class, [ + 'label' => 'competition.form.registration_link', + 'required' => false, + ]); + + $builder->add('resultsLink', TextType::class, [ + 'label' => 'competition.form.results_link', + 'required' => false, + ]); + + $builder->add('location', TextType::class, [ + 'label' => 'competition.form.location', + ]); + + $allCountries = []; + foreach (CountryCode::cases() as $country) { + $allCountries[$country->value] = $country->name; + } + + $countries = [ + $this->translator->trans('forms.country_most_common') => [ + CountryCode::cz->value => CountryCode::cz->name, + CountryCode::sk->value => CountryCode::sk->name, + CountryCode::pl->value => CountryCode::pl->name, + CountryCode::de->value => CountryCode::de->name, + CountryCode::at->value => CountryCode::at->name, + CountryCode::no->value => CountryCode::no->name, + CountryCode::fi->value => CountryCode::fi->name, + CountryCode::us->value => CountryCode::us->name, + CountryCode::ca->value => CountryCode::ca->name, + CountryCode::fr->value => CountryCode::fr->name, + CountryCode::nz->value => CountryCode::nz->name, + CountryCode::es->value => CountryCode::es->name, + CountryCode::nl->value => CountryCode::nl->name, + CountryCode::pt->value => CountryCode::pt->name, + CountryCode::gb->value => CountryCode::gb->name, + ], + $this->translator->trans('forms.country_all') => $allCountries, + ]; + + $builder->add('locationCountryCode', ChoiceType::class, [ + 'label' => 'competition.form.country', + 'choices' => $countries, + 'required' => false, + 'placeholder' => 'competition.form.country_placeholder', + ]); + + $builder->add('dateFrom', DateType::class, [ + 'label' => 'competition.form.date_from', + 'widget' => 'single_text', + 'required' => false, + 'help' => 'competition.form.dates_help', + ]); + + $builder->add('dateTo', DateType::class, [ + 'label' => 'competition.form.date_to', + 'widget' => 'single_text', + 'required' => false, + ]); + + $builder->add('isOnline', CheckboxType::class, [ + 'label' => 'competition.form.is_online', + 'required' => false, + ]); + + $builder->add('logo', FileType::class, [ + 'label' => 'competition.form.logo', + 'required' => false, + 'constraints' => [ + new Image( + maxSize: '5M', + mimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], + ), + ], + ]); + + /** @var CompetitionFormData $data */ + $data = $builder->getData(); + + $maintainerChoices = []; + foreach ($data->maintainers as $playerId) { + $maintainerChoices[$playerId] = $playerId; + } + + $builder->add('maintainers', TextType::class, [ + 'label' => 'competition.form.maintainers', + 'help' => 'competition.form.maintainers_help', + 'required' => false, + 'autocomplete' => true, + 'options_as_html' => true, + 'tom_select_options' => [ + 'create' => false, + 'persist' => false, + 'maxItems' => 10, + 'options' => $maintainerChoices, + 'closeAfterSelect' => true, + ], + 'attr' => [ + 'data-fetch-url' => $this->urlGenerator->generate('player_search_autocomplete', ['_locale' => 'en']), + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => CompetitionFormData::class, + ]); + } +} diff --git a/src/FormType/CompetitionRoundFormType.php b/src/FormType/CompetitionRoundFormType.php new file mode 100644 index 00000000..d1c2cdb8 --- /dev/null +++ b/src/FormType/CompetitionRoundFormType.php @@ -0,0 +1,53 @@ + + */ +final class CompetitionRoundFormType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('name', TextType::class, [ + 'label' => 'competition.round.form.name', + ]); + + $builder->add('minutesLimit', NumberType::class, [ + 'label' => 'competition.round.form.minutes_limit', + ]); + + $builder->add('startsAt', DateTimeType::class, [ + 'label' => 'competition.round.form.starts_at', + 'widget' => 'single_text', + ]); + + $builder->add('badgeBackgroundColor', ColorType::class, [ + 'label' => 'competition.round.form.badge_background_color', + 'required' => false, + ]); + + $builder->add('badgeTextColor', ColorType::class, [ + 'label' => 'competition.round.form.badge_text_color', + 'required' => false, + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => CompetitionRoundFormData::class, + ]); + } +} diff --git a/src/FormType/RoundPuzzleFormType.php b/src/FormType/RoundPuzzleFormType.php new file mode 100644 index 00000000..a98cae55 --- /dev/null +++ b/src/FormType/RoundPuzzleFormType.php @@ -0,0 +1,121 @@ + + */ +final class RoundPuzzleFormType extends AbstractType +{ + public function __construct( + private readonly BrandChoicesBuilder $brandChoicesBuilder, + private readonly RetrieveLoggedUserProfile $retrieveLoggedUserProfile, + private readonly TranslatorInterface $translator, + private readonly UrlGeneratorInterface $urlGenerator, + ) { + } + + /** + * @param mixed[] $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $profile = $this->retrieveLoggedUserProfile->getProfile(); + assert($profile !== null); + $brandChoices = $this->brandChoicesBuilder->build($profile->playerId, null); + + $builder->add('brand', TextType::class, [ + 'label' => 'forms.brand', + 'help' => 'forms.brand_help', + 'required' => true, + 'autocomplete' => true, + 'empty_data' => '', + 'options_as_html' => true, + 'tom_select_options' => [ + 'create' => true, + 'persist' => false, + 'maxItems' => 1, + 'options' => $brandChoices, + 'closeAfterSelect' => true, + 'createOnBlur' => true, + 'searchField' => ['text', 'eanPrefix'], + ], + 'attr' => [ + 'data-fetch-url' => $this->urlGenerator->generate('puzzle_by_brand_autocomplete'), + ], + ]); + + $builder->add('puzzle', TextType::class, [ + 'label' => 'forms.puzzle', + 'help' => 'forms.puzzle_help', + 'required' => true, + 'autocomplete' => true, + 'options_as_html' => true, + 'tom_select_options' => [ + 'create' => true, + 'persist' => false, + 'maxItems' => 1, + 'closeAfterSelect' => true, + 'createOnBlur' => true, + ], + 'attr' => [ + 'data-choose-brand-placeholder' => $this->translator->trans('forms.puzzle_choose_brand_placeholder'), + 'data-choose-puzzle-placeholder' => $this->translator->trans('forms.puzzle_choose_placeholder'), + ], + ]); + + $builder->add('piecesCount', NumberType::class, [ + 'label' => 'forms.pieces_count', + 'required' => false, + ]); + + $builder->add('puzzlePhoto', FileType::class, [ + 'label' => 'forms.puzzle_photo', + 'required' => false, + 'constraints' => [ + new Image( + maxSize: '10M', + mimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], + ), + ], + ]); + + $builder->add('puzzleEan', TextType::class, [ + 'label' => 'forms.ean', + 'required' => false, + ]); + + $builder->add('puzzleIdentificationNumber', TextType::class, [ + 'label' => 'forms.identification_number', + 'required' => false, + ]); + + $builder->add('hideUntilRoundStarts', CheckboxType::class, [ + 'label' => 'competition.round_puzzle.form.hide_until_round_starts', + 'required' => false, + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => RoundPuzzleFormData::class, + ]); + } +} diff --git a/src/Message/AddCompetition.php b/src/Message/AddCompetition.php new file mode 100644 index 00000000..9b419e5e --- /dev/null +++ b/src/Message/AddCompetition.php @@ -0,0 +1,34 @@ + $maintainerIds + */ + public function __construct( + public UuidInterface $competitionId, + public string $playerId, + public string $name, + public null|string $shortcut, + public null|string $description, + public null|string $link, + public null|string $registrationLink, + public null|string $resultsLink, + public string $location, + public null|string $locationCountryCode, + public null|DateTimeImmutable $dateFrom, + public null|DateTimeImmutable $dateTo, + public bool $isOnline, + public null|UploadedFile $logo, + public array $maintainerIds, + ) { + } +} diff --git a/src/Message/AddCompetitionRound.php b/src/Message/AddCompetitionRound.php new file mode 100644 index 00000000..73d5c153 --- /dev/null +++ b/src/Message/AddCompetitionRound.php @@ -0,0 +1,22 @@ + $maintainerIds + */ + public function __construct( + public string $competitionId, + public string $name, + public null|string $shortcut, + public null|string $description, + public null|string $link, + public null|string $registrationLink, + public null|string $resultsLink, + public string $location, + public null|string $locationCountryCode, + public null|DateTimeImmutable $dateFrom, + public null|DateTimeImmutable $dateTo, + public bool $isOnline, + public null|UploadedFile $logo, + public array $maintainerIds, + ) { + } +} diff --git a/src/Message/EditCompetitionRound.php b/src/Message/EditCompetitionRound.php new file mode 100644 index 00000000..b23dbe6a --- /dev/null +++ b/src/Message/EditCompetitionRound.php @@ -0,0 +1,20 @@ +playerRepository->get($message->playerId); + $now = $this->clock->now(); + + $slug = $this->generateUniqueSlug($message->name); + + $logoPath = null; + if ($message->logo !== null) { + $extension = $message->logo->guessExtension(); + $timestamp = $now->getTimestamp(); + $logoPath = "competitions/{$message->competitionId}-{$timestamp}.{$extension}"; + + $this->imageOptimizer->optimize($message->logo->getPathname()); + + $stream = fopen($message->logo->getPathname(), 'rb'); + $this->filesystem->writeStream($logoPath, $stream); + + if (is_resource($stream)) { + fclose($stream); + } + } + + $competition = new Competition( + id: $message->competitionId, + name: $message->name, + slug: $slug, + shortcut: $message->shortcut, + logo: $logoPath, + description: $message->description, + link: $message->link, + registrationLink: $message->registrationLink, + resultsLink: $message->resultsLink, + location: $message->location, + locationCountryCode: $message->locationCountryCode, + dateFrom: $message->dateFrom, + dateTo: $message->dateTo, + tag: null, + isOnline: $message->isOnline, + addedByPlayer: $player, + createdAt: $now, + ); + + foreach ($message->maintainerIds as $maintainerId) { + $maintainer = $this->playerRepository->get($maintainerId); + $competition->maintainers->add($maintainer); + } + + $this->entityManager->persist($competition); + } + + private function generateUniqueSlug(string $name): string + { + $slug = (string) $this->slugger->slug(strtolower($name)); + + /** @var int|string $existingCount */ + $existingCount = $this->entityManager->getConnection() + ->executeQuery( + 'SELECT COUNT(*) FROM competition WHERE slug = :slug', + ['slug' => $slug], + ) + ->fetchOne(); + $existingCount = (int) $existingCount; + + if ($existingCount > 0) { + $slug .= '-' . substr(md5(uniqid()), 0, 6); + } + + return $slug; + } +} diff --git a/src/MessageHandler/AddCompetitionRoundHandler.php b/src/MessageHandler/AddCompetitionRoundHandler.php new file mode 100644 index 00000000..1ead9753 --- /dev/null +++ b/src/MessageHandler/AddCompetitionRoundHandler.php @@ -0,0 +1,38 @@ +competitionRepository->get($message->competitionId); + + $round = new CompetitionRound( + id: $message->roundId, + competition: $competition, + name: $message->name, + minutesLimit: $message->minutesLimit, + startsAt: $message->startsAt, + badgeBackgroundColor: $message->badgeBackgroundColor, + badgeTextColor: $message->badgeTextColor, + ); + + $this->competitionRoundRepository->save($round); + } +} diff --git a/src/MessageHandler/AddPuzzleToCompetitionRoundHandler.php b/src/MessageHandler/AddPuzzleToCompetitionRoundHandler.php new file mode 100644 index 00000000..ff3bad9e --- /dev/null +++ b/src/MessageHandler/AddPuzzleToCompetitionRoundHandler.php @@ -0,0 +1,118 @@ +competitionRoundRepository->get($message->roundId); + + if (Uuid::isValid($message->puzzle)) { + // Existing puzzle + $puzzle = $this->puzzleRepository->get($message->puzzle); + } else { + // New puzzle + $puzzle = $this->createNewPuzzle($message); + } + + if ($message->hideUntilRoundStarts) { + $puzzle->hideUntil = $round->startsAt; + } + + $roundPuzzle = new CompetitionRoundPuzzle( + id: $message->roundPuzzleId, + round: $round, + puzzle: $puzzle, + hideUntilRoundStarts: $message->hideUntilRoundStarts, + ); + + $this->competitionRoundPuzzleRepository->save($roundPuzzle); + } + + private function createNewPuzzle(AddPuzzleToCompetitionRound $message): Puzzle + { + $player = $this->playerRepository->getByUserIdCreateIfNotExists($message->userId); + $now = $this->clock->now(); + + if (Uuid::isValid($message->brand)) { + $manufacturer = $this->manufacturerRepository->get($message->brand); + } else { + $manufacturer = new Manufacturer( + Uuid::uuid7(), + $message->brand, + false, + $player, + $now, + ); + $this->entityManager->persist($manufacturer); + } + + $puzzleId = Uuid::uuid7(); + $puzzlePhotoPath = null; + + if ($message->puzzlePhoto !== null) { + $extension = $message->puzzlePhoto->guessExtension(); + $timestamp = $now->getTimestamp(); + $puzzlePhotoPath = "$puzzleId-$timestamp.$extension"; + + $this->imageOptimizer->optimize($message->puzzlePhoto->getPathname()); + + $stream = fopen($message->puzzlePhoto->getPathname(), 'rb'); + $this->filesystem->writeStream($puzzlePhotoPath, $stream); + + if (is_resource($stream)) { + fclose($stream); + } + } + + $puzzle = new Puzzle( + $puzzleId, + $message->piecesCount ?? 0, + $message->puzzle, + approved: false, + image: $puzzlePhotoPath, + manufacturer: $manufacturer, + addedByUser: $player, + addedAt: $now, + identificationNumber: $message->puzzleIdentificationNumber, + ean: $message->puzzleEan !== null ? (ltrim($message->puzzleEan, '0') ?: null) : null, + ); + + $this->entityManager->persist($puzzle); + + return $puzzle; + } +} diff --git a/src/MessageHandler/ApproveCompetitionHandler.php b/src/MessageHandler/ApproveCompetitionHandler.php new file mode 100644 index 00000000..8ffc89e2 --- /dev/null +++ b/src/MessageHandler/ApproveCompetitionHandler.php @@ -0,0 +1,30 @@ +competitionRepository->get($message->competitionId); + $approvedBy = $this->playerRepository->get($message->approvedByPlayerId); + + $competition->approve($approvedBy, $this->clock->now()); + } +} diff --git a/src/MessageHandler/DeleteCompetitionRoundHandler.php b/src/MessageHandler/DeleteCompetitionRoundHandler.php new file mode 100644 index 00000000..6bfde69b --- /dev/null +++ b/src/MessageHandler/DeleteCompetitionRoundHandler.php @@ -0,0 +1,24 @@ +competitionRoundRepository->get($message->roundId); + $this->competitionRoundRepository->delete($round); + } +} diff --git a/src/MessageHandler/EditCompetitionHandler.php b/src/MessageHandler/EditCompetitionHandler.php new file mode 100644 index 00000000..66e444f0 --- /dev/null +++ b/src/MessageHandler/EditCompetitionHandler.php @@ -0,0 +1,99 @@ +competitionRepository->get($message->competitionId); + + $logoPath = $competition->logo; + if ($message->logo !== null) { + $extension = $message->logo->guessExtension(); + $timestamp = $this->clock->now()->getTimestamp(); + $logoPath = "competitions/{$message->competitionId}-{$timestamp}.{$extension}"; + + $this->imageOptimizer->optimize($message->logo->getPathname()); + + $stream = fopen($message->logo->getPathname(), 'rb'); + $this->filesystem->writeStream($logoPath, $stream); + + if (is_resource($stream)) { + fclose($stream); + } + } + + $slug = $competition->slug; + if ($competition->name !== $message->name) { + $slug = $this->generateUniqueSlug($message->name, $message->competitionId); + } + + $competition->edit( + name: $message->name, + slug: $slug, + shortcut: $message->shortcut, + logo: $logoPath, + description: $message->description, + link: $message->link, + registrationLink: $message->registrationLink, + resultsLink: $message->resultsLink, + location: $message->location, + locationCountryCode: $message->locationCountryCode, + dateFrom: $message->dateFrom, + dateTo: $message->dateTo, + isOnline: $message->isOnline, + ); + + // Sync maintainers + $competition->maintainers->clear(); + foreach ($message->maintainerIds as $maintainerId) { + $maintainer = $this->playerRepository->get($maintainerId); + $competition->maintainers->add($maintainer); + } + } + + private function generateUniqueSlug(string $name, string $competitionId): string + { + $slug = (string) $this->slugger->slug(strtolower($name)); + + /** @var int|string $existingCount */ + $existingCount = $this->entityManager->getConnection() + ->executeQuery( + 'SELECT COUNT(*) FROM competition WHERE slug = :slug AND id != :id', + ['slug' => $slug, 'id' => $competitionId], + ) + ->fetchOne(); + $existingCount = (int) $existingCount; + + if ($existingCount > 0) { + $slug .= '-' . substr(md5(uniqid()), 0, 6); + } + + return $slug; + } +} diff --git a/src/MessageHandler/EditCompetitionRoundHandler.php b/src/MessageHandler/EditCompetitionRoundHandler.php new file mode 100644 index 00000000..f7f4a8bc --- /dev/null +++ b/src/MessageHandler/EditCompetitionRoundHandler.php @@ -0,0 +1,31 @@ +competitionRoundRepository->get($message->roundId); + + $round->edit( + name: $message->name, + minutesLimit: $message->minutesLimit, + startsAt: $message->startsAt, + badgeBackgroundColor: $message->badgeBackgroundColor, + badgeTextColor: $message->badgeTextColor, + ); + } +} diff --git a/src/MessageHandler/RemovePuzzleFromCompetitionRoundHandler.php b/src/MessageHandler/RemovePuzzleFromCompetitionRoundHandler.php new file mode 100644 index 00000000..001bc203 --- /dev/null +++ b/src/MessageHandler/RemovePuzzleFromCompetitionRoundHandler.php @@ -0,0 +1,24 @@ +competitionRoundPuzzleRepository->get($message->roundPuzzleId); + $this->competitionRoundPuzzleRepository->delete($roundPuzzle); + } +} diff --git a/src/Query/GetCompetitionEvents.php b/src/Query/GetCompetitionEvents.php index 2cc63e82..7957bc8a 100644 --- a/src/Query/GetCompetitionEvents.php +++ b/src/Query/GetCompetitionEvents.php @@ -51,8 +51,9 @@ public function allPast(): array $query = <<clock->now(); @@ -77,7 +78,8 @@ public function allUpcoming(): array $query = << :date::date +WHERE approved_at IS NOT NULL + AND COALESCE(date_from, date_to)::date > :date::date ORDER BY date_from; SQL; $now = $this->clock->now(); @@ -102,8 +104,9 @@ public function allLive(): array $query = <<clock->now(); @@ -130,7 +133,6 @@ public function all(): array FROM competition ORDER BY date_from DESC; SQL; - $now = $this->clock->now(); $data = $this->database ->executeQuery($query) @@ -141,4 +143,51 @@ public function all(): array return CompetitionEvent::fromDatabaseRow($row); }, $data); } + + /** + * @return array + */ + public function allUnapproved(): array + { + $query = <<database + ->executeQuery($query) + ->fetchAllAssociative(); + + return array_map(static function (array $row): CompetitionEvent { + /** @var CompetitionEventDatabaseRow $row */ + return CompetitionEvent::fromDatabaseRow($row); + }, $data); + } + + /** + * @return array + */ + public function allForPlayer(string $playerId): array + { + $query = <<database + ->executeQuery($query, [ + 'playerId' => $playerId, + ]) + ->fetchAllAssociative(); + + return array_map(static function (array $row): CompetitionEvent { + /** @var CompetitionEventDatabaseRow $row */ + return CompetitionEvent::fromDatabaseRow($row); + }, $data); + } } diff --git a/src/Query/GetCompetitionRoundsForManagement.php b/src/Query/GetCompetitionRoundsForManagement.php new file mode 100644 index 00000000..2514cea7 --- /dev/null +++ b/src/Query/GetCompetitionRoundsForManagement.php @@ -0,0 +1,69 @@ + + */ + public function ofCompetition(string $competitionId): array + { + $query = <<database + ->executeQuery($query, [ + 'competitionId' => $competitionId, + ]) + ->fetchAllAssociative(); + + return array_map(static function (array $row): CompetitionRoundForManagement { + /** + * @var array{ + * id: string, + * name: string, + * minutes_limit: int|string, + * starts_at: string, + * badge_background_color: null|string, + * badge_text_color: null|string, + * puzzle_count: int|string, + * } $row + */ + + return new CompetitionRoundForManagement( + id: $row['id'], + name: $row['name'], + minutesLimit: (int) $row['minutes_limit'], + startsAt: new DateTimeImmutable($row['starts_at']), + badgeBackgroundColor: $row['badge_background_color'], + badgeTextColor: $row['badge_text_color'], + puzzleCount: (int) $row['puzzle_count'], + ); + }, $data); + } +} diff --git a/src/Query/GetPuzzleIdsForSitemap.php b/src/Query/GetPuzzleIdsForSitemap.php index 1d654eac..285d2de9 100644 --- a/src/Query/GetPuzzleIdsForSitemap.php +++ b/src/Query/GetPuzzleIdsForSitemap.php @@ -5,11 +5,13 @@ namespace SpeedPuzzling\Web\Query; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; readonly final class GetPuzzleIdsForSitemap { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -22,12 +24,15 @@ public function allApproved(): array SELECT puzzle.id FROM puzzle WHERE puzzle.approved = true -AND (puzzle.hide_image_until IS NULL OR puzzle.hide_image_until <= NOW()) + AND (puzzle.hide_image_until IS NULL OR puzzle.hide_image_until <= :now) + AND (puzzle.hide_until IS NULL OR puzzle.hide_until <= :now) SQL; /** @var array $puzzleIds */ $puzzleIds = $this->database - ->executeQuery($query) + ->executeQuery($query, [ + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), + ]) ->fetchFirstColumn(); return $puzzleIds; @@ -43,12 +48,14 @@ public function withMarketplaceOffers(): array FROM puzzle p JOIN sell_swap_list_item ssli ON ssli.puzzle_id = p.id WHERE ssli.published_on_marketplace = true -AND (p.hide_image_until IS NULL OR p.hide_image_until <= NOW()) +AND (p.hide_image_until IS NULL OR p.hide_image_until <= :now) SQL; /** @var array $puzzleIds */ $puzzleIds = $this->database - ->executeQuery($query) + ->executeQuery($query, [ + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), + ]) ->fetchFirstColumn(); return $puzzleIds; diff --git a/src/Query/GetPuzzlesOverview.php b/src/Query/GetPuzzlesOverview.php index 95e4f1af..64b48013 100644 --- a/src/Query/GetPuzzlesOverview.php +++ b/src/Query/GetPuzzlesOverview.php @@ -44,8 +44,8 @@ public function allApprovedOrAddedByPlayer(null|string $playerId): array LEFT JOIN puzzle_statistics ON puzzle_statistics.puzzle_id = puzzle.id INNER JOIN manufacturer ON puzzle.manufacturer_id = manufacturer.id WHERE - puzzle.approved = true - OR puzzle.added_by_user_id = :playerId + (puzzle.hide_until IS NULL OR puzzle.hide_until <= NOW()) + AND (puzzle.approved = true OR puzzle.added_by_user_id = :playerId) ORDER BY COALESCE(puzzle.alternative_name, puzzle.name) ASC, manufacturer_name ASC, pieces_count ASC SQL; diff --git a/src/Query/GetRoundPuzzlesForManagement.php b/src/Query/GetRoundPuzzlesForManagement.php new file mode 100644 index 00000000..87fb195d --- /dev/null +++ b/src/Query/GetRoundPuzzlesForManagement.php @@ -0,0 +1,73 @@ + + */ + public function ofRound(string $roundId): array + { + $query = <<database + ->executeQuery($query, [ + 'roundId' => $roundId, + ]) + ->fetchAllAssociative(); + + return array_map(static function (array $row): RoundPuzzleForManagement { + /** + * @var array{ + * round_puzzle_id: string, + * hide_until_round_starts: bool|string, + * puzzle_id: string, + * puzzle_name: string, + * pieces_count: int|string, + * puzzle_image: null|string, + * manufacturer_name: null|string, + * } $row + */ + + $hideUntilRoundStarts = $row['hide_until_round_starts']; + if (is_string($hideUntilRoundStarts)) { + $hideUntilRoundStarts = $hideUntilRoundStarts === 't' || $hideUntilRoundStarts === '1' || $hideUntilRoundStarts === 'true'; + } + + return new RoundPuzzleForManagement( + roundPuzzleId: $row['round_puzzle_id'], + puzzleId: $row['puzzle_id'], + puzzleName: $row['puzzle_name'], + piecesCount: (int) $row['pieces_count'], + puzzleImage: $row['puzzle_image'], + manufacturerName: $row['manufacturer_name'], + hideUntilRoundStarts: $hideUntilRoundStarts, + ); + }, $data); + } +} diff --git a/src/Query/IsCompetitionMaintainer.php b/src/Query/IsCompetitionMaintainer.php new file mode 100644 index 00000000..0c2a8133 --- /dev/null +++ b/src/Query/IsCompetitionMaintainer.php @@ -0,0 +1,36 @@ +database + ->executeQuery($query, [ + 'competitionId' => $competitionId, + 'playerId' => $playerId, + ]) + ->fetchOne(); + + return $result !== false; + } +} diff --git a/src/Query/SearchPuzzle.php b/src/Query/SearchPuzzle.php index 15e675fa..84f4829a 100644 --- a/src/Query/SearchPuzzle.php +++ b/src/Query/SearchPuzzle.php @@ -51,7 +51,8 @@ public function countByUserInput( LEFT JOIN tag_puzzle ON tag_puzzle.puzzle_id = puzzle.id {$difficultyJoin} WHERE - (:brandId::uuid IS NULL OR manufacturer_id = :brandId) + (puzzle.hide_until IS NULL OR puzzle.hide_until <= NOW()) + AND (:brandId::uuid IS NULL OR manufacturer_id = :brandId) AND (:minPieces::int IS NULL OR pieces_count >= :minPieces) AND (:maxPieces::int IS NULL OR pieces_count <= :maxPieces) AND ( @@ -171,7 +172,8 @@ public function byUserInput( LEFT JOIN tag_puzzle ON tag_puzzle.puzzle_id = puzzle.id {$difficultyJoin} WHERE - (:brandId::uuid IS NULL OR manufacturer_id = :brandId) + (puzzle.hide_until IS NULL OR puzzle.hide_until <= NOW()) + AND (:brandId::uuid IS NULL OR manufacturer_id = :brandId) AND (:minPieces::int IS NULL OR pieces_count >= :minPieces) AND (:maxPieces::int IS NULL OR pieces_count <= :maxPieces) AND ( diff --git a/src/Repository/CompetitionRoundPuzzleRepository.php b/src/Repository/CompetitionRoundPuzzleRepository.php new file mode 100644 index 00000000..40732f47 --- /dev/null +++ b/src/Repository/CompetitionRoundPuzzleRepository.php @@ -0,0 +1,42 @@ +entityManager->find(CompetitionRoundPuzzle::class, $id); + + if ($roundPuzzle === null) { + throw new \InvalidArgumentException('Competition round puzzle not found'); + } + + return $roundPuzzle; + } + + public function save(CompetitionRoundPuzzle $roundPuzzle): void + { + $this->entityManager->persist($roundPuzzle); + } + + public function delete(CompetitionRoundPuzzle $roundPuzzle): void + { + $this->entityManager->remove($roundPuzzle); + } +} diff --git a/src/Repository/CompetitionRoundRepository.php b/src/Repository/CompetitionRoundRepository.php new file mode 100644 index 00000000..9c9d112b --- /dev/null +++ b/src/Repository/CompetitionRoundRepository.php @@ -0,0 +1,42 @@ +entityManager->find(CompetitionRound::class, $roundId); + + return $round ?? throw new CompetitionRoundNotFound(); + } + + public function save(CompetitionRound $round): void + { + $this->entityManager->persist($round); + } + + public function delete(CompetitionRound $round): void + { + $this->entityManager->remove($round); + } +} diff --git a/src/Results/CompetitionEvent.php b/src/Results/CompetitionEvent.php index 6165dea8..5f7af8df 100644 --- a/src/Results/CompetitionEvent.php +++ b/src/Results/CompetitionEvent.php @@ -23,6 +23,10 @@ * results_link: null|string, * slug: null|string, * tag_id: null|string, + * is_online: bool|string, + * added_by_player_id: null|string, + * approved_at: null|string, + * created_at: null|string, * } */ readonly final class CompetitionEvent @@ -46,6 +50,10 @@ public function __construct( public null|DateTimeImmutable $dateTo, public null|string $slug, public null|string $tagId, + public bool $isOnline, + public null|string $addedByPlayerId, + public null|DateTimeImmutable $approvedAt, + public null|DateTimeImmutable $createdAt, ) { $this->link = $this->appendUtm($link); $this->registrationLink = $this->appendUtm($registrationLink); @@ -57,6 +65,11 @@ public function __construct( */ public static function fromDatabaseRow(array $row): self { + $isOnline = $row['is_online']; + if (is_string($isOnline)) { + $isOnline = $isOnline === 't' || $isOnline === '1' || $isOnline === 'true'; + } + return new self( id: $row['id'], name: $row['name'], @@ -72,6 +85,10 @@ public static function fromDatabaseRow(array $row): self dateTo: $row['date_to'] !== null ? new DateTimeImmutable($row['date_to']) : null, slug: $row['slug'], tagId: $row['tag_id'], + isOnline: $isOnline, + addedByPlayerId: $row['added_by_player_id'], + approvedAt: $row['approved_at'] !== null ? new DateTimeImmutable($row['approved_at']) : null, + createdAt: $row['created_at'] !== null ? new DateTimeImmutable($row['created_at']) : null, ); } diff --git a/src/Results/CompetitionRoundForManagement.php b/src/Results/CompetitionRoundForManagement.php new file mode 100644 index 00000000..cf8a1f8b --- /dev/null +++ b/src/Results/CompetitionRoundForManagement.php @@ -0,0 +1,21 @@ + + */ +final class CompetitionEditVoter extends Voter +{ + public const string COMPETITION_EDIT = 'COMPETITION_EDIT'; + + public function __construct( + private readonly RetrieveLoggedUserProfile $retrieveLoggedUserProfile, + private readonly IsCompetitionMaintainer $isCompetitionMaintainer, + ) { + } + + protected function supports(string $attribute, mixed $subject): bool + { + return $attribute === self::COMPETITION_EDIT && is_string($subject); + } + + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, null|Vote $vote = null): bool + { + $profile = $this->retrieveLoggedUserProfile->getProfile(); + + if ($profile === null) { + return false; + } + + if ($profile->isAdmin === true) { + return true; + } + + return $this->isCompetitionMaintainer->check($subject, $profile->playerId); + } +} diff --git a/templates/_competition_event.html.twig b/templates/_competition_event.html.twig index eea07664..2f6631f9 100644 --- a/templates/_competition_event.html.twig +++ b/templates/_competition_event.html.twig @@ -1,5 +1,10 @@
-
+
+ {% if is_granted('COMPETITION_EDIT', event.id) %} + + + + {% endif %}

diff --git a/templates/add_competition.html.twig b/templates/add_competition.html.twig new file mode 100644 index 00000000..e346526c --- /dev/null +++ b/templates/add_competition.html.twig @@ -0,0 +1,46 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'competition.add_event'|trans }}{% endblock %} + +{% block content %} +
+
+
+

{{ 'competition.add_event'|trans }}

+ +

{{ 'competition.add_event_info'|trans }}

+ + {% for flash_message in app.flashes('success') %} +
+ + {{ flash_message }} +
+ {% endfor %} + +
+
+ {{ form_start(form) }} + {{ form_row(form.name) }} + {{ form_row(form.shortcut) }} + {{ form_row(form.description) }} + {{ form_row(form.location) }} + {{ form_row(form.locationCountryCode) }} + {{ form_row(form.isOnline) }} + {{ form_row(form.dateFrom) }} + {{ form_row(form.dateTo) }} + {{ form_row(form.link) }} + {{ form_row(form.registrationLink) }} + {{ form_row(form.resultsLink) }} + {{ form_row(form.logo) }} + {{ form_row(form.maintainers) }} + + + {{ form_end(form) }} +
+
+
+
+
+{% endblock %} diff --git a/templates/add_competition_round.html.twig b/templates/add_competition_round.html.twig new file mode 100644 index 00000000..2201e76f --- /dev/null +++ b/templates/add_competition_round.html.twig @@ -0,0 +1,34 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'competition.add_round'|trans }} - {{ competition.name }}{% endblock %} + +{% block content %} +
+
+
+

{{ 'competition.add_round'|trans }}

+

{{ competition.name }}

+ + + {{ 'competition.back_to_rounds'|trans }} + + +
+
+ {{ form_start(form) }} + {{ form_row(form.name) }} + {{ form_row(form.minutesLimit) }} + {{ form_row(form.startsAt) }} + {{ form_row(form.badgeBackgroundColor) }} + {{ form_row(form.badgeTextColor) }} + + + {{ form_end(form) }} +
+
+
+
+
+{% endblock %} diff --git a/templates/add_puzzle_to_round.html.twig b/templates/add_puzzle_to_round.html.twig new file mode 100644 index 00000000..77b86b60 --- /dev/null +++ b/templates/add_puzzle_to_round.html.twig @@ -0,0 +1,36 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'competition.add_puzzle'|trans }} - {{ round.name }}{% endblock %} + +{% block content %} +
+
+
+

{{ 'competition.add_puzzle'|trans }}

+

{{ competition.name }} - {{ round.name }}

+ + + {{ 'competition.back_to_puzzles'|trans }} + + +
+
+ {{ form_start(form) }} + {{ form_row(form.brand) }} + {{ form_row(form.puzzle) }} + {{ form_row(form.piecesCount) }} + {{ form_row(form.puzzlePhoto) }} + {{ form_row(form.puzzleEan) }} + {{ form_row(form.puzzleIdentificationNumber) }} + {{ form_row(form.hideUntilRoundStarts) }} + + + {{ form_end(form) }} +
+
+
+
+
+{% endblock %} diff --git a/templates/admin/competition_approvals.html.twig b/templates/admin/competition_approvals.html.twig new file mode 100644 index 00000000..57f38d16 --- /dev/null +++ b/templates/admin/competition_approvals.html.twig @@ -0,0 +1,73 @@ +{% extends 'base.html.twig' %} + +{% block title %}Competition Approvals{% endblock %} + +{% block content %} +
+

Competition Approvals

+ + {% for flash_message in app.flashes('success') %} +
+ + {{ flash_message }} +
+ {% endfor %} + + {% if competitions|length > 0 %} +
+ + + + + + + + + + + + {% for competition in competitions %} + + + + + + + + {% endfor %} + +
NameLocationDatesCreated
{{ competition.name }} + {% if competition.locationCountryCode %} + + {% endif %} + {{ competition.location }} + + {% if competition.dateFrom %} + {{ competition.dateFrom|date('d.m.Y') }} + {% if competition.dateTo %} + - {{ competition.dateTo|date('d.m.Y') }} + {% endif %} + {% else %} + - + {% endif %} + + {% if competition.createdAt %} + {{ competition.createdAt|date('d.m.Y H:i') }} + {% endif %} + + + View + +
+ + +
+
+
+ {% else %} +

No competitions pending approval.

+ {% endif %} +
+{% endblock %} diff --git a/templates/base.html.twig b/templates/base.html.twig index 97f1eef2..6cbbede3 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -147,6 +147,11 @@ Vouchers +
  • + + Competition Approvals + +
  • diff --git a/templates/edit_competition.html.twig b/templates/edit_competition.html.twig new file mode 100644 index 00000000..6e370421 --- /dev/null +++ b/templates/edit_competition.html.twig @@ -0,0 +1,59 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'competition.edit_event'|trans }} - {{ competition.name }}{% endblock %} + +{% block content %} +
    +
    +
    +

    {{ 'competition.edit_event'|trans }}: {{ competition.name }}

    + + {% if competition.approvedAt is null %} +
    + {{ 'competition.pending_approval_info'|trans }} +
    + {% endif %} + +
    + + {% for flash_message in app.flashes('success') %} +
    + + {{ flash_message }} +
    + {% endfor %} + +
    +
    + {{ form_start(form) }} + {{ form_row(form.name) }} + {{ form_row(form.shortcut) }} + {{ form_row(form.description) }} + {{ form_row(form.location) }} + {{ form_row(form.locationCountryCode) }} + {{ form_row(form.isOnline) }} + {{ form_row(form.dateFrom) }} + {{ form_row(form.dateTo) }} + {{ form_row(form.link) }} + {{ form_row(form.registrationLink) }} + {{ form_row(form.resultsLink) }} + {{ form_row(form.logo) }} + {{ form_row(form.maintainers) }} + + + {{ form_end(form) }} +
    +
    +
    +
    +
    +{% endblock %} diff --git a/templates/edit_competition_round.html.twig b/templates/edit_competition_round.html.twig new file mode 100644 index 00000000..6013394b --- /dev/null +++ b/templates/edit_competition_round.html.twig @@ -0,0 +1,46 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'competition.edit_round'|trans }} - {{ competition.name }}{% endblock %} + +{% block content %} +
    +
    +
    +

    {{ 'competition.edit_round'|trans }}: {{ round.name }}

    +

    {{ competition.name }}

    + + + + {% for flash_message in app.flashes('success') %} +
    + + {{ flash_message }} +
    + {% endfor %} + +
    +
    + {{ form_start(form) }} + {{ form_row(form.name) }} + {{ form_row(form.minutesLimit) }} + {{ form_row(form.startsAt) }} + {{ form_row(form.badgeBackgroundColor) }} + {{ form_row(form.badgeTextColor) }} + + + {{ form_end(form) }} +
    +
    +
    +
    +
    +{% endblock %} diff --git a/templates/events.html.twig b/templates/events.html.twig index db5a4430..3201395a 100644 --- a/templates/events.html.twig +++ b/templates/events.html.twig @@ -25,7 +25,47 @@ {% endblock %} {% block content %} -

    {{ 'events.title'|trans }}

    +
    +

    {{ 'events.title'|trans }}

    + {% if is_granted('IS_AUTHENTICATED_FULLY') %} + + {{ 'competition.add_event'|trans }} + + {% endif %} +
    + + {% if player_competitions|length > 0 %} +

    {{ 'competition.my_competitions'|trans }}

    +
    + {% for event in player_competitions %} +
    +
    +
    +
    +

    + {{ event.name }} + {% if event.approvedAt is null %} + {{ 'competition.pending_approval'|trans }} + {% endif %} +

    +
    + {% if event.locationCountryCode %} + + {% endif %} + {{ event.location }} +
    +
    + +
    +
    +
    + {% endfor %} +
    + {% endif %} {% if live_events|length > 0 %}
    diff --git a/templates/manage_competition_participants.html.twig b/templates/manage_competition_participants.html.twig new file mode 100644 index 00000000..281a4817 --- /dev/null +++ b/templates/manage_competition_participants.html.twig @@ -0,0 +1,21 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'competition.manage_participants'|trans }} - {{ competition.name }}{% endblock %} + +{% block content %} +
    +

    {{ competition.name }}

    + + + +

    {{ 'competition.manage_participants'|trans }}

    + +
    + {{ 'competition.participants_coming_soon'|trans }} +
    +
    +{% endblock %} diff --git a/templates/manage_competition_rounds.html.twig b/templates/manage_competition_rounds.html.twig new file mode 100644 index 00000000..3801d3a0 --- /dev/null +++ b/templates/manage_competition_rounds.html.twig @@ -0,0 +1,77 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'competition.manage_rounds'|trans }} - {{ competition.name }}{% endblock %} + +{% block content %} +
    +

    {{ competition.name }}

    + + + + {% for flash_message in app.flashes('success') %} +
    + + {{ flash_message }} +
    + {% endfor %} + +

    {{ 'competition.rounds'|trans }}

    + + {% if rounds|length > 0 %} +
    + + + + + + + + + + + + {% for round in rounds %} + + + + + + + + {% endfor %} + +
    {{ 'competition.round.name'|trans }}{{ 'competition.round.starts_at'|trans }}{{ 'competition.round.minutes_limit'|trans }}{{ 'competition.round.puzzles'|trans }}
    + {% if round.badgeBackgroundColor %} + + {{ round.name }} + + {% else %} + {{ round.name }} + {% endif %} + {{ round.startsAt|date('d.m.Y H:i') }}{{ round.minutesLimit }} min{{ round.puzzleCount }} + + {{ 'competition.puzzles'|trans }} + + + + +
    + + +
    +
    +
    + {% else %} +

    {{ 'competition.no_rounds'|trans }}

    + {% endif %} +
    +{% endblock %} diff --git a/templates/manage_round_puzzles.html.twig b/templates/manage_round_puzzles.html.twig new file mode 100644 index 00000000..71713fb5 --- /dev/null +++ b/templates/manage_round_puzzles.html.twig @@ -0,0 +1,68 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'competition.manage_puzzles'|trans }} - {{ round.name }}{% endblock %} + +{% block content %} +
    +

    {{ round.name }}

    +

    {{ competition.name }}

    + + + + {% for flash_message in app.flashes('success') %} +
    + + {{ flash_message }} +
    + {% endfor %} + +

    {{ 'competition.puzzles'|trans }}

    + + {% if puzzles|length > 0 %} +
    + + + + + + + + + + + + {% for puzzle in puzzles %} + + + + + + + + {% endfor %} + +
    {{ 'competition.puzzle.name'|trans }}{{ 'competition.puzzle.brand'|trans }}{{ 'competition.puzzle.pieces'|trans }}{{ 'competition.puzzle.hidden'|trans }}
    {{ puzzle.puzzleName }}{{ puzzle.manufacturerName ?? '-' }}{{ puzzle.piecesCount }} + {% if puzzle.hideUntilRoundStarts %} + {{ 'competition.hidden_until_start'|trans }} + {% endif %} + +
    + + +
    +
    +
    + {% else %} +

    {{ 'competition.no_puzzles'|trans }}

    + {% endif %} +
    +{% endblock %} diff --git a/tests/DataFixtures/CompetitionFixture.php b/tests/DataFixtures/CompetitionFixture.php index bb6e17ce..89ba8957 100644 --- a/tests/DataFixtures/CompetitionFixture.php +++ b/tests/DataFixtures/CompetitionFixture.php @@ -4,18 +4,21 @@ namespace SpeedPuzzling\Web\Tests\DataFixtures; +use DateTimeImmutable; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Persistence\ObjectManager; use Psr\Clock\ClockInterface; use Ramsey\Uuid\Uuid; use SpeedPuzzling\Web\Entity\Competition; +use SpeedPuzzling\Web\Entity\Player; use SpeedPuzzling\Web\Entity\Tag; final class CompetitionFixture extends Fixture implements DependentFixtureInterface { public const string COMPETITION_WJPC_2024 = '018d0004-0000-0000-0000-000000000001'; public const string COMPETITION_CZECH_NATIONALS_2024 = '018d0004-0000-0000-0000-000000000002'; + public const string COMPETITION_UNAPPROVED = '018d0004-0000-0000-0000-000000000003'; public function __construct( private readonly ClockInterface $clock, @@ -26,6 +29,8 @@ public function load(ObjectManager $manager): void { $wjpcTag = $this->getReference(TagFixture::TAG_WJPC, Tag::class); $nationalTag = $this->getReference(TagFixture::TAG_NATIONAL, Tag::class); + $regularPlayer = $this->getReference(PlayerFixture::PLAYER_REGULAR, Player::class); + $adminPlayer = $this->getReference(PlayerFixture::PLAYER_ADMIN, Player::class); $wjpcCompetition = $this->createCompetition( id: self::COMPETITION_WJPC_2024, @@ -40,6 +45,7 @@ public function load(ObjectManager $manager): void link: 'https://wjpc2024.com', registrationLink: 'https://wjpc2024.com/register', resultsLink: 'https://wjpc2024.com/results', + approvedAt: $this->clock->now(), ); $manager->persist($wjpcCompetition); $this->addReference(self::COMPETITION_WJPC_2024, $wjpcCompetition); @@ -54,10 +60,26 @@ public function load(ObjectManager $manager): void slug: 'czech-nationals-2024', shortcut: 'CZE24', description: 'Czech National Jigsaw Puzzle Championship 2024', + approvedAt: $this->clock->now(), ); $manager->persist($czechNationalsCompetition); $this->addReference(self::COMPETITION_CZECH_NATIONALS_2024, $czechNationalsCompetition); + $unapprovedCompetition = $this->createCompetition( + id: self::COMPETITION_UNAPPROVED, + name: 'Unapproved Puzzle Event', + location: 'Vienna', + locationCountryCode: 'at', + tag: null, + daysFromNow: 90, + slug: 'unapproved-puzzle-event', + addedByPlayer: $regularPlayer, + createdAt: $this->clock->now(), + ); + $unapprovedCompetition->maintainers->add($regularPlayer); + $manager->persist($unapprovedCompetition); + $this->addReference(self::COMPETITION_UNAPPROVED, $unapprovedCompetition); + $manager->flush(); } @@ -65,6 +87,7 @@ public function getDependencies(): array { return [ TagFixture::class, + PlayerFixture::class, ]; } @@ -73,7 +96,7 @@ private function createCompetition( string $name, string $location, string $locationCountryCode, - Tag $tag, + null|Tag $tag, int $daysFromNow, null|string $slug = null, null|string $shortcut = null, @@ -81,6 +104,9 @@ private function createCompetition( null|string $link = null, null|string $registrationLink = null, null|string $resultsLink = null, + null|DateTimeImmutable $approvedAt = null, + null|Player $addedByPlayer = null, + null|DateTimeImmutable $createdAt = null, ): Competition { $dateFrom = $this->clock->now()->modify("+{$daysFromNow} days"); $dateTo = $dateFrom->modify('+2 days'); @@ -100,6 +126,9 @@ private function createCompetition( dateFrom: $dateFrom, dateTo: $dateTo, tag: $tag, + approvedAt: $approvedAt, + addedByPlayer: $addedByPlayer, + createdAt: $createdAt, ); } } diff --git a/tests/DataFixtures/CompetitionRoundFixture.php b/tests/DataFixtures/CompetitionRoundFixture.php index e00c565b..9dd0b9c2 100644 --- a/tests/DataFixtures/CompetitionRoundFixture.php +++ b/tests/DataFixtures/CompetitionRoundFixture.php @@ -11,6 +11,7 @@ use Ramsey\Uuid\Uuid; use SpeedPuzzling\Web\Entity\Competition; use SpeedPuzzling\Web\Entity\CompetitionRound; +use SpeedPuzzling\Web\Entity\CompetitionRoundPuzzle; use SpeedPuzzling\Web\Entity\Puzzle; final class CompetitionRoundFixture extends Fixture implements DependentFixtureInterface @@ -40,37 +41,42 @@ public function load(ObjectManager $manager): void name: 'Qualification Round', minutesLimit: 60, daysFromNow: 30, - puzzles: [$puzzle500_01, $puzzle500_02], badgeBackgroundColor: '#007bff', badgeTextColor: '#ffffff', ); $manager->persist($wjpcQualificationRound); $this->addReference(self::ROUND_WJPC_QUALIFICATION, $wjpcQualificationRound); + $this->addPuzzleToRound($manager, $wjpcQualificationRound, $puzzle500_01); + $this->addPuzzleToRound($manager, $wjpcQualificationRound, $puzzle500_02); + $wjpcFinalRound = $this->createCompetitionRound( id: self::ROUND_WJPC_FINAL, competition: $wjpcCompetition, name: 'Final Round', minutesLimit: 120, daysFromNow: 32, - puzzles: [$puzzle1000_01, $puzzle1000_02], badgeBackgroundColor: '#ffc107', badgeTextColor: '#000000', ); $manager->persist($wjpcFinalRound); $this->addReference(self::ROUND_WJPC_FINAL, $wjpcFinalRound); + $this->addPuzzleToRound($manager, $wjpcFinalRound, $puzzle1000_01); + $this->addPuzzleToRound($manager, $wjpcFinalRound, $puzzle1000_02); + $czechFinalRound = $this->createCompetitionRound( id: self::ROUND_CZECH_FINAL, competition: $czechCompetition, name: 'Final Round', minutesLimit: 90, daysFromNow: 60, - puzzles: [$puzzle500_01], ); $manager->persist($czechFinalRound); $this->addReference(self::ROUND_CZECH_FINAL, $czechFinalRound); + $this->addPuzzleToRound($manager, $czechFinalRound, $puzzle500_01); + $manager->flush(); } @@ -82,22 +88,18 @@ public function getDependencies(): array ]; } - /** - * @param array $puzzles - */ private function createCompetitionRound( string $id, Competition $competition, string $name, int $minutesLimit, int $daysFromNow, - array $puzzles = [], null|string $badgeBackgroundColor = null, null|string $badgeTextColor = null, ): CompetitionRound { $startsAt = $this->clock->now()->modify("+{$daysFromNow} days"); - $round = new CompetitionRound( + return new CompetitionRound( id: Uuid::fromString($id), competition: $competition, name: $name, @@ -106,11 +108,16 @@ private function createCompetitionRound( badgeBackgroundColor: $badgeBackgroundColor, badgeTextColor: $badgeTextColor, ); + } - foreach ($puzzles as $puzzle) { - $round->puzzles->add($puzzle); - } - - return $round; + private function addPuzzleToRound(ObjectManager $manager, CompetitionRound $round, Puzzle $puzzle, bool $hideUntilRoundStarts = false): void + { + $roundPuzzle = new CompetitionRoundPuzzle( + id: Uuid::uuid7(), + round: $round, + puzzle: $puzzle, + hideUntilRoundStarts: $hideUntilRoundStarts, + ); + $manager->persist($roundPuzzle); } } diff --git a/translations/messages.en.yml b/translations/messages.en.yml index 89b8e481..b47a1f1f 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -2396,3 +2396,78 @@ qr_code: title: "QR Code" save: "Save" share: "Share" + +competition: + add_event: "Add Event" + edit_event: "Edit Event" + edit: "Edit" + my_competitions: "My Competitions" + pending_approval: "Pending approval" + pending_approval_info: "This event is pending admin approval and is not yet visible to other users." + add_event_info: "Add a new competition or event. It will be reviewed by an admin before being published." + submit_for_approval: "Submit for Approval" + save_changes: "Save Changes" + manage_rounds: "Manage Rounds" + manage_participants: "Manage Participants" + manage_puzzles: "Manage Puzzles" + back_to_edit: "Back to Event" + back_to_rounds: "Back to Rounds" + back_to_puzzles: "Back to Puzzles" + add_round: "Add Round" + edit_round: "Edit Round" + rounds: "Rounds" + puzzles: "Puzzles" + add_puzzle: "Add Puzzle" + remove: "Remove" + no_rounds: "No rounds yet. Add a round to get started." + no_puzzles: "No puzzles in this round yet." + confirm_delete_round: "Are you sure you want to delete this round?" + confirm_remove_puzzle: "Are you sure you want to remove this puzzle from this round?" + hidden_until_start: "Hidden until start" + participants_coming_soon: "Participant management will be added soon." + round: + name: "Name" + starts_at: "Starts at" + minutes_limit: "Time limit" + puzzles: "Puzzles" + form: + name: "Round name" + minutes_limit: "Time limit (minutes)" + starts_at: "Start date and time" + badge_background_color: "Badge color" + badge_text_color: "Badge text color" + puzzle: + name: "Name" + brand: "Brand" + pieces: "Pieces" + hidden: "Visibility" + form: + name: "Event name" + shortcut: "Shortcut" + shortcut_help: "Displayed as a shortcut, e.g. WJPC for World Jigsaw Puzzle Championship" + description: "Description" + link: "Website URL" + registration_link: "Registration URL" + results_link: "Results URL" + location: "Location" + country: "Country" + country_placeholder: "Select country..." + date_from: "Start date" + date_to: "End date" + dates_help: "For regular online events, leave dates empty" + is_online: "This is an online event" + logo: "Logo" + maintainers: "Maintainers" + maintainers_help: "Other users who can manage this event" + round_puzzle: + form: + hide_until_round_starts: "Do not reveal until round starts" + flash: + created: "Event submitted for approval." + updated: "Event updated." + approved: "Competition approved." + round_added: "Round added." + round_updated: "Round updated." + round_deleted: "Round deleted." + puzzle_added: "Puzzle added to round." + puzzle_removed: "Puzzle removed from round." From ae24826946bf7d7da4a9f9b2f85923a4e9386c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Wed, 4 Mar 2026 18:34:16 +0100 Subject: [PATCH 2/8] Table management --- migrations/Version20260304170035.php | 47 +++++ src/Component/RoundTableManager.php | 170 +++++++++++++++ .../GenerateTableLayoutController.php | 80 +++++++ .../ManageRoundTablesController.php | 49 +++++ src/Controller/PrintRoundTablesController.php | 53 +++++ src/Entity/RoundTable.php | 43 ++++ src/Entity/TableRow.php | 43 ++++ src/Entity/TableSpot.php | 54 +++++ src/Exceptions/RoundTableNotFound.php | 11 + src/Exceptions/TableRowNotFound.php | 11 + src/Exceptions/TableSpotNotFound.php | 11 + src/FormData/GenerateTableLayoutFormData.php | 26 +++ src/FormType/GenerateTableLayoutFormType.php | 39 ++++ src/Message/AddRoundTable.php | 16 ++ src/Message/AddTableRow.php | 16 ++ src/Message/AddTableSpot.php | 16 ++ src/Message/AssignPlayerToSpot.php | 15 ++ src/Message/ClearTableLayout.php | 13 ++ src/Message/DeleteRoundTable.php | 13 ++ src/Message/DeleteTableRow.php | 13 ++ src/Message/DeleteTableSpot.php | 13 ++ src/Message/GenerateTableLayout.php | 16 ++ src/MessageHandler/AddRoundTableHandler.php | 63 ++++++ src/MessageHandler/AddTableRowHandler.php | 44 ++++ src/MessageHandler/AddTableSpotHandler.php | 43 ++++ .../AssignPlayerToSpotHandler.php | 34 +++ .../ClearTableLayoutHandler.php | 26 +++ .../DeleteRoundTableHandler.php | 24 +++ src/MessageHandler/DeleteTableRowHandler.php | 24 +++ src/MessageHandler/DeleteTableSpotHandler.php | 24 +++ .../GenerateTableLayoutHandler.php | 73 +++++++ src/Query/GetTableLayoutForRound.php | 146 +++++++++++++ src/Repository/RoundTableRepository.php | 42 ++++ src/Repository/TableRowRepository.php | 42 ++++ src/Repository/TableSpotRepository.php | 42 ++++ src/Results/TableLayoutRow.php | 19 ++ src/Results/TableLayoutSpot.php | 25 +++ src/Results/TableLayoutTable.php | 19 ++ .../components/RoundTableManager.html.twig | 197 ++++++++++++++++++ templates/generate_table_layout.html.twig | 34 +++ templates/manage_competition_rounds.html.twig | 5 + templates/manage_round_tables.html.twig | 34 +++ templates/print_round_tables.html.twig | 68 ++++++ .../GenerateTableLayoutControllerTest.php | 41 ++++ .../ManageRoundTablesControllerTest.php | 41 ++++ .../PrintRoundTablesControllerTest.php | 23 ++ tests/DataFixtures/TableLayoutFixture.php | 168 +++++++++++++++ tests/Entity/TableSpotTest.php | 115 ++++++++++ .../AddRoundTableHandlerTest.php | 49 +++++ .../MessageHandler/AddTableRowHandlerTest.php | 42 ++++ .../AddTableSpotHandlerTest.php | 42 ++++ .../AssignPlayerToSpotHandlerTest.php | 73 +++++++ .../ClearTableLayoutHandlerTest.php | 40 ++++ .../DeleteRoundTableHandlerTest.php | 45 ++++ .../DeleteTableRowHandlerTest.php | 52 +++++ .../DeleteTableSpotHandlerTest.php | 38 ++++ .../GenerateTableLayoutHandlerTest.php | 96 +++++++++ tests/Query/GetTableLayoutForRoundTest.php | 60 ++++++ translations/messages.en.yml | 25 +++ 59 files changed, 2746 insertions(+) create mode 100644 migrations/Version20260304170035.php create mode 100644 src/Component/RoundTableManager.php create mode 100644 src/Controller/GenerateTableLayoutController.php create mode 100644 src/Controller/ManageRoundTablesController.php create mode 100644 src/Controller/PrintRoundTablesController.php create mode 100644 src/Entity/RoundTable.php create mode 100644 src/Entity/TableRow.php create mode 100644 src/Entity/TableSpot.php create mode 100644 src/Exceptions/RoundTableNotFound.php create mode 100644 src/Exceptions/TableRowNotFound.php create mode 100644 src/Exceptions/TableSpotNotFound.php create mode 100644 src/FormData/GenerateTableLayoutFormData.php create mode 100644 src/FormType/GenerateTableLayoutFormType.php create mode 100644 src/Message/AddRoundTable.php create mode 100644 src/Message/AddTableRow.php create mode 100644 src/Message/AddTableSpot.php create mode 100644 src/Message/AssignPlayerToSpot.php create mode 100644 src/Message/ClearTableLayout.php create mode 100644 src/Message/DeleteRoundTable.php create mode 100644 src/Message/DeleteTableRow.php create mode 100644 src/Message/DeleteTableSpot.php create mode 100644 src/Message/GenerateTableLayout.php create mode 100644 src/MessageHandler/AddRoundTableHandler.php create mode 100644 src/MessageHandler/AddTableRowHandler.php create mode 100644 src/MessageHandler/AddTableSpotHandler.php create mode 100644 src/MessageHandler/AssignPlayerToSpotHandler.php create mode 100644 src/MessageHandler/ClearTableLayoutHandler.php create mode 100644 src/MessageHandler/DeleteRoundTableHandler.php create mode 100644 src/MessageHandler/DeleteTableRowHandler.php create mode 100644 src/MessageHandler/DeleteTableSpotHandler.php create mode 100644 src/MessageHandler/GenerateTableLayoutHandler.php create mode 100644 src/Query/GetTableLayoutForRound.php create mode 100644 src/Repository/RoundTableRepository.php create mode 100644 src/Repository/TableRowRepository.php create mode 100644 src/Repository/TableSpotRepository.php create mode 100644 src/Results/TableLayoutRow.php create mode 100644 src/Results/TableLayoutSpot.php create mode 100644 src/Results/TableLayoutTable.php create mode 100644 templates/components/RoundTableManager.html.twig create mode 100644 templates/generate_table_layout.html.twig create mode 100644 templates/manage_round_tables.html.twig create mode 100644 templates/print_round_tables.html.twig create mode 100644 tests/Controller/GenerateTableLayoutControllerTest.php create mode 100644 tests/Controller/ManageRoundTablesControllerTest.php create mode 100644 tests/Controller/PrintRoundTablesControllerTest.php create mode 100644 tests/DataFixtures/TableLayoutFixture.php create mode 100644 tests/Entity/TableSpotTest.php create mode 100644 tests/MessageHandler/AddRoundTableHandlerTest.php create mode 100644 tests/MessageHandler/AddTableRowHandlerTest.php create mode 100644 tests/MessageHandler/AddTableSpotHandlerTest.php create mode 100644 tests/MessageHandler/AssignPlayerToSpotHandlerTest.php create mode 100644 tests/MessageHandler/ClearTableLayoutHandlerTest.php create mode 100644 tests/MessageHandler/DeleteRoundTableHandlerTest.php create mode 100644 tests/MessageHandler/DeleteTableRowHandlerTest.php create mode 100644 tests/MessageHandler/DeleteTableSpotHandlerTest.php create mode 100644 tests/MessageHandler/GenerateTableLayoutHandlerTest.php create mode 100644 tests/Query/GetTableLayoutForRoundTest.php diff --git a/migrations/Version20260304170035.php b/migrations/Version20260304170035.php new file mode 100644 index 00000000..31b188e1 --- /dev/null +++ b/migrations/Version20260304170035.php @@ -0,0 +1,47 @@ +addSql('CREATE TABLE round_table (id UUID NOT NULL, position INT NOT NULL, label VARCHAR(255) NOT NULL, row_id UUID NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_205A9BBF83A269F2 ON round_table (row_id)'); + $this->addSql('CREATE TABLE table_row (id UUID NOT NULL, position INT NOT NULL, label VARCHAR(255) DEFAULT NULL, round_id UUID NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_8DD57CBFA6005CA0 ON table_row (round_id)'); + $this->addSql('CREATE TABLE table_spot (id UUID NOT NULL, position INT NOT NULL, player_name VARCHAR(255) DEFAULT NULL, table_id UUID NOT NULL, player_id UUID DEFAULT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_F3E43AB8ECFF285C ON table_spot (table_id)'); + $this->addSql('CREATE INDEX IDX_F3E43AB899E6F5DF ON table_spot (player_id)'); + $this->addSql('ALTER TABLE round_table ADD CONSTRAINT FK_205A9BBF83A269F2 FOREIGN KEY (row_id) REFERENCES table_row (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE table_row ADD CONSTRAINT FK_8DD57CBFA6005CA0 FOREIGN KEY (round_id) REFERENCES competition_round (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE table_spot ADD CONSTRAINT FK_F3E43AB8ECFF285C FOREIGN KEY (table_id) REFERENCES round_table (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE table_spot ADD CONSTRAINT FK_F3E43AB899E6F5DF FOREIGN KEY (player_id) REFERENCES player (id) ON DELETE SET NULL NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE round_table DROP CONSTRAINT FK_205A9BBF83A269F2'); + $this->addSql('ALTER TABLE table_row DROP CONSTRAINT FK_8DD57CBFA6005CA0'); + $this->addSql('ALTER TABLE table_spot DROP CONSTRAINT FK_F3E43AB8ECFF285C'); + $this->addSql('ALTER TABLE table_spot DROP CONSTRAINT FK_F3E43AB899E6F5DF'); + $this->addSql('DROP TABLE round_table'); + $this->addSql('DROP TABLE table_row'); + $this->addSql('DROP TABLE table_spot'); + } +} diff --git a/src/Component/RoundTableManager.php b/src/Component/RoundTableManager.php new file mode 100644 index 00000000..190d9613 --- /dev/null +++ b/src/Component/RoundTableManager.php @@ -0,0 +1,170 @@ + */ + public array $rows = []; + + public function __construct( + private readonly GetTableLayoutForRound $getTableLayoutForRound, + private readonly SearchPlayers $searchPlayers, + private readonly MessageBusInterface $messageBus, + ) { + } + + #[PostMount] + #[PreReRender] + public function loadLayout(): void + { + $this->rows = $this->getTableLayoutForRound->byRoundId($this->roundId); + } + + /** + * @return list + */ + public function getSearchResults(): array + { + $query = trim($this->searchQuery); + + if (strlen($query) < 2) { + return []; + } + + return $this->searchPlayers->fulltext($query, limit: 10); + } + + #[LiveAction] + public function addRow(): void + { + $this->messageBus->dispatch(new AddTableRow( + rowId: Uuid::uuid7(), + roundId: $this->roundId, + )); + } + + #[LiveAction] + public function deleteRow(#[LiveArg] string $rowId): void + { + $this->messageBus->dispatch(new DeleteTableRow( + rowId: $rowId, + )); + } + + #[LiveAction] + public function addTable(#[LiveArg] string $rowId): void + { + $this->messageBus->dispatch(new AddRoundTable( + tableId: Uuid::uuid7(), + rowId: $rowId, + )); + } + + #[LiveAction] + public function deleteTable(#[LiveArg] string $tableId): void + { + $this->messageBus->dispatch(new DeleteRoundTable( + tableId: $tableId, + )); + } + + #[LiveAction] + public function addSpot(#[LiveArg] string $tableId): void + { + $this->messageBus->dispatch(new AddTableSpot( + spotId: Uuid::uuid7(), + tableId: $tableId, + )); + } + + #[LiveAction] + public function deleteSpot(#[LiveArg] string $spotId): void + { + $this->messageBus->dispatch(new DeleteTableSpot( + spotId: $spotId, + )); + } + + #[LiveAction] + public function startEditSpot(#[LiveArg] string $spotId): void + { + $this->editingSpotId = $spotId; + $this->searchQuery = ''; + } + + #[LiveAction] + public function assignPlayer(#[LiveArg] string $spotId, #[LiveArg] string $playerId): void + { + $this->messageBus->dispatch(new AssignPlayerToSpot( + spotId: $spotId, + playerId: $playerId, + )); + $this->editingSpotId = null; + $this->searchQuery = ''; + } + + #[LiveAction] + public function assignManualName(#[LiveArg] string $spotId, #[LiveArg] string $playerName): void + { + $this->messageBus->dispatch(new AssignPlayerToSpot( + spotId: $spotId, + playerName: $playerName, + )); + $this->editingSpotId = null; + $this->searchQuery = ''; + } + + #[LiveAction] + public function clearSpot(#[LiveArg] string $spotId): void + { + $this->messageBus->dispatch(new AssignPlayerToSpot( + spotId: $spotId, + )); + } + + #[LiveAction] + public function cancelEdit(): void + { + $this->editingSpotId = null; + $this->searchQuery = ''; + } +} diff --git a/src/Controller/GenerateTableLayoutController.php b/src/Controller/GenerateTableLayoutController.php new file mode 100644 index 00000000..cce2e0ac --- /dev/null +++ b/src/Controller/GenerateTableLayoutController.php @@ -0,0 +1,80 @@ + '/generovat-rozlozeni-stolu/{roundId}', + 'en' => '/en/generate-table-layout/{roundId}', + 'es' => '/es/generate-table-layout/{roundId}', + 'ja' => '/ja/generate-table-layout/{roundId}', + 'fr' => '/fr/generate-table-layout/{roundId}', + 'de' => '/de/generate-table-layout/{roundId}', + ], + name: 'generate_table_layout', + )] + public function __invoke(Request $request, string $roundId): Response + { + $round = $this->competitionRoundRepository->get($roundId); + $competitionId = $round->competition->id->toString(); + + $this->denyAccessUnlessGranted(CompetitionEditVoter::COMPETITION_EDIT, $competitionId); + + $competition = $this->getCompetitionEvents->byId($competitionId); + + $formData = new GenerateTableLayoutFormData(); + $form = $this->createForm(GenerateTableLayoutFormType::class, $formData); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $data = $form->getData(); + assert($data->numberOfRows !== null); + assert($data->tablesPerRow !== null); + assert($data->spotsPerTable !== null); + + $this->messageBus->dispatch(new GenerateTableLayout( + roundId: $roundId, + numberOfRows: $data->numberOfRows, + tablesPerRow: $data->tablesPerRow, + spotsPerTable: $data->spotsPerTable, + )); + + $this->addFlash('success', $this->translator->trans('competition.tables.flash.layout_generated')); + + return $this->redirectToRoute('manage_round_tables', ['roundId' => $roundId]); + } + + return $this->render('generate_table_layout.html.twig', [ + 'form' => $form, + 'competition' => $competition, + 'round' => $round, + ]); + } +} diff --git a/src/Controller/ManageRoundTablesController.php b/src/Controller/ManageRoundTablesController.php new file mode 100644 index 00000000..179cb872 --- /dev/null +++ b/src/Controller/ManageRoundTablesController.php @@ -0,0 +1,49 @@ + '/sprava-stolu-kola/{roundId}', + 'en' => '/en/manage-round-tables/{roundId}', + 'es' => '/es/manage-round-tables/{roundId}', + 'ja' => '/ja/manage-round-tables/{roundId}', + 'fr' => '/fr/manage-round-tables/{roundId}', + 'de' => '/de/manage-round-tables/{roundId}', + ], + name: 'manage_round_tables', + )] + public function __invoke(string $roundId): Response + { + $round = $this->competitionRoundRepository->get($roundId); + $competitionId = $round->competition->id->toString(); + + $this->denyAccessUnlessGranted(CompetitionEditVoter::COMPETITION_EDIT, $competitionId); + + $competition = $this->getCompetitionEvents->byId($competitionId); + + return $this->render('manage_round_tables.html.twig', [ + 'competition' => $competition, + 'round' => $round, + ]); + } +} diff --git a/src/Controller/PrintRoundTablesController.php b/src/Controller/PrintRoundTablesController.php new file mode 100644 index 00000000..ec5f7619 --- /dev/null +++ b/src/Controller/PrintRoundTablesController.php @@ -0,0 +1,53 @@ + '/tisk-stolu-kola/{roundId}', + 'en' => '/en/print-round-tables/{roundId}', + 'es' => '/es/print-round-tables/{roundId}', + 'ja' => '/ja/print-round-tables/{roundId}', + 'fr' => '/fr/print-round-tables/{roundId}', + 'de' => '/de/print-round-tables/{roundId}', + ], + name: 'print_round_tables', + )] + public function __invoke(string $roundId): Response + { + $round = $this->competitionRoundRepository->get($roundId); + $competitionId = $round->competition->id->toString(); + + $this->denyAccessUnlessGranted(CompetitionEditVoter::COMPETITION_EDIT, $competitionId); + + $competition = $this->getCompetitionEvents->byId($competitionId); + $rows = $this->getTableLayoutForRound->byRoundId($roundId); + + return $this->render('print_round_tables.html.twig', [ + 'competition' => $competition, + 'round' => $round, + 'rows' => $rows, + ]); + } +} diff --git a/src/Entity/RoundTable.php b/src/Entity/RoundTable.php new file mode 100644 index 00000000..d477cf13 --- /dev/null +++ b/src/Entity/RoundTable.php @@ -0,0 +1,43 @@ + + */ + #[OneToMany(targetEntity: TableSpot::class, mappedBy: 'table')] + #[OrderBy(['position' => 'ASC'])] + public Collection $spots = new ArrayCollection(), + ) { + } +} diff --git a/src/Entity/TableRow.php b/src/Entity/TableRow.php new file mode 100644 index 00000000..243c6165 --- /dev/null +++ b/src/Entity/TableRow.php @@ -0,0 +1,43 @@ + + */ + #[OneToMany(targetEntity: RoundTable::class, mappedBy: 'row')] + #[OrderBy(['position' => 'ASC'])] + public Collection $tables = new ArrayCollection(), + ) { + } +} diff --git a/src/Entity/TableSpot.php b/src/Entity/TableSpot.php new file mode 100644 index 00000000..32d9907b --- /dev/null +++ b/src/Entity/TableSpot.php @@ -0,0 +1,54 @@ +player = $player; + $this->playerName = null; + } + + public function assignManualName(string $name): void + { + $this->playerName = $name; + $this->player = null; + } + + public function clearAssignment(): void + { + $this->player = null; + $this->playerName = null; + } +} diff --git a/src/Exceptions/RoundTableNotFound.php b/src/Exceptions/RoundTableNotFound.php new file mode 100644 index 00000000..ad024a2e --- /dev/null +++ b/src/Exceptions/RoundTableNotFound.php @@ -0,0 +1,11 @@ + + */ +final class GenerateTableLayoutFormType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('numberOfRows', NumberType::class, [ + 'label' => 'competition.tables.form.number_of_rows', + ]); + + $builder->add('tablesPerRow', NumberType::class, [ + 'label' => 'competition.tables.form.tables_per_row', + ]); + + $builder->add('spotsPerTable', NumberType::class, [ + 'label' => 'competition.tables.form.spots_per_table', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => GenerateTableLayoutFormData::class, + ]); + } +} diff --git a/src/Message/AddRoundTable.php b/src/Message/AddRoundTable.php new file mode 100644 index 00000000..156da29a --- /dev/null +++ b/src/Message/AddRoundTable.php @@ -0,0 +1,16 @@ +tableRowRepository->get($message->rowId); + + /** @var int|string|false $maxPositionResult */ + $maxPositionResult = $this->database->fetchOne( + 'SELECT COALESCE(MAX(position), 0) FROM round_table WHERE row_id = :rowId', + ['rowId' => $message->rowId], + ); + $maxPosition = (int) $maxPositionResult; + + /** @var int|string|false $totalResult */ + $totalResult = $this->database->fetchOne( + 'SELECT COUNT(*) FROM round_table rt INNER JOIN table_row tr ON rt.row_id = tr.id WHERE tr.round_id = :roundId', + ['roundId' => $row->round->id->toString()], + ); + $totalTablesInRound = (int) $totalResult; + + $table = new RoundTable( + id: $message->tableId, + row: $row, + position: $maxPosition + 1, + label: 'Table ' . ($totalTablesInRound + 1), + ); + + $this->roundTableRepository->save($table); + + $spot = new TableSpot( + id: Uuid::uuid7(), + table: $table, + position: 1, + ); + + $this->tableSpotRepository->save($spot); + } +} diff --git a/src/MessageHandler/AddTableRowHandler.php b/src/MessageHandler/AddTableRowHandler.php new file mode 100644 index 00000000..88a28d70 --- /dev/null +++ b/src/MessageHandler/AddTableRowHandler.php @@ -0,0 +1,44 @@ +competitionRoundRepository->get($message->roundId); + + /** @var int|string|false $result */ + $result = $this->database->fetchOne( + 'SELECT COALESCE(MAX(position), 0) FROM table_row WHERE round_id = :roundId', + ['roundId' => $message->roundId], + ); + $maxPosition = (int) $result; + + $row = new TableRow( + id: $message->rowId, + round: $round, + position: $maxPosition + 1, + label: 'Row ' . ($maxPosition + 1), + ); + + $this->tableRowRepository->save($row); + } +} diff --git a/src/MessageHandler/AddTableSpotHandler.php b/src/MessageHandler/AddTableSpotHandler.php new file mode 100644 index 00000000..d439097a --- /dev/null +++ b/src/MessageHandler/AddTableSpotHandler.php @@ -0,0 +1,43 @@ +roundTableRepository->get($message->tableId); + + /** @var int|string|false $result */ + $result = $this->database->fetchOne( + 'SELECT COALESCE(MAX(position), 0) FROM table_spot WHERE table_id = :tableId', + ['tableId' => $message->tableId], + ); + $maxPosition = (int) $result; + + $spot = new TableSpot( + id: $message->spotId, + table: $table, + position: $maxPosition + 1, + ); + + $this->tableSpotRepository->save($spot); + } +} diff --git a/src/MessageHandler/AssignPlayerToSpotHandler.php b/src/MessageHandler/AssignPlayerToSpotHandler.php new file mode 100644 index 00000000..34c25b78 --- /dev/null +++ b/src/MessageHandler/AssignPlayerToSpotHandler.php @@ -0,0 +1,34 @@ +tableSpotRepository->get($message->spotId); + + if ($message->playerId !== null) { + $player = $this->playerRepository->get($message->playerId); + $spot->assignPlayer($player); + } elseif ($message->playerName !== null) { + $spot->assignManualName($message->playerName); + } else { + $spot->clearAssignment(); + } + } +} diff --git a/src/MessageHandler/ClearTableLayoutHandler.php b/src/MessageHandler/ClearTableLayoutHandler.php new file mode 100644 index 00000000..27d73c39 --- /dev/null +++ b/src/MessageHandler/ClearTableLayoutHandler.php @@ -0,0 +1,26 @@ +database->executeStatement( + 'DELETE FROM table_row WHERE round_id = :roundId', + ['roundId' => $message->roundId], + ); + } +} diff --git a/src/MessageHandler/DeleteRoundTableHandler.php b/src/MessageHandler/DeleteRoundTableHandler.php new file mode 100644 index 00000000..cea3d0f2 --- /dev/null +++ b/src/MessageHandler/DeleteRoundTableHandler.php @@ -0,0 +1,24 @@ +roundTableRepository->get($message->tableId); + $this->roundTableRepository->delete($table); + } +} diff --git a/src/MessageHandler/DeleteTableRowHandler.php b/src/MessageHandler/DeleteTableRowHandler.php new file mode 100644 index 00000000..bb09c0e0 --- /dev/null +++ b/src/MessageHandler/DeleteTableRowHandler.php @@ -0,0 +1,24 @@ +tableRowRepository->get($message->rowId); + $this->tableRowRepository->delete($row); + } +} diff --git a/src/MessageHandler/DeleteTableSpotHandler.php b/src/MessageHandler/DeleteTableSpotHandler.php new file mode 100644 index 00000000..eadaa5a4 --- /dev/null +++ b/src/MessageHandler/DeleteTableSpotHandler.php @@ -0,0 +1,24 @@ +tableSpotRepository->get($message->spotId); + $this->tableSpotRepository->delete($spot); + } +} diff --git a/src/MessageHandler/GenerateTableLayoutHandler.php b/src/MessageHandler/GenerateTableLayoutHandler.php new file mode 100644 index 00000000..34faa943 --- /dev/null +++ b/src/MessageHandler/GenerateTableLayoutHandler.php @@ -0,0 +1,73 @@ +competitionRoundRepository->get($message->roundId); + + // Clear existing layout + $this->database->executeStatement( + 'DELETE FROM table_row WHERE round_id = :roundId', + ['roundId' => $message->roundId], + ); + + $tableNumber = 1; + + for ($rowIndex = 0; $rowIndex < $message->numberOfRows; $rowIndex++) { + $row = new TableRow( + id: Uuid::uuid7(), + round: $round, + position: $rowIndex + 1, + label: 'Row ' . ($rowIndex + 1), + ); + $this->tableRowRepository->save($row); + + for ($tableIndex = 0; $tableIndex < $message->tablesPerRow; $tableIndex++) { + $table = new RoundTable( + id: Uuid::uuid7(), + row: $row, + position: $tableIndex + 1, + label: 'Table ' . $tableNumber, + ); + $this->roundTableRepository->save($table); + $tableNumber++; + + for ($spotIndex = 0; $spotIndex < $message->spotsPerTable; $spotIndex++) { + $spot = new TableSpot( + id: Uuid::uuid7(), + table: $table, + position: $spotIndex + 1, + ); + $this->tableSpotRepository->save($spot); + } + } + } + } +} diff --git a/src/Query/GetTableLayoutForRound.php b/src/Query/GetTableLayoutForRound.php new file mode 100644 index 00000000..71e03472 --- /dev/null +++ b/src/Query/GetTableLayoutForRound.php @@ -0,0 +1,146 @@ + + */ + public function byRoundId(string $roundId): array + { + $query = <<database + ->executeQuery($query, [ + 'roundId' => $roundId, + ]) + ->fetchAllAssociative(); + + /** + * @var array, + * }>, + * }> $rows + */ + $rows = []; + + foreach ($data as $row) { + /** @var array{ + * row_id: string, + * row_position: int|string, + * row_label: null|string, + * table_id: null|string, + * table_position: null|int|string, + * table_label: null|string, + * spot_id: null|string, + * spot_position: null|int|string, + * spot_player_name: null|string, + * player_id: null|string, + * player_name: null|string, + * player_code: null|string, + * player_country: null|string, + * } $row + */ + $rowId = $row['row_id']; + + if (!isset($rows[$rowId])) { + $rows[$rowId] = [ + 'row_id' => $rowId, + 'row_position' => $row['row_position'], + 'row_label' => $row['row_label'], + 'tables' => [], + ]; + } + + $tableId = $row['table_id']; + if ($tableId === null) { + continue; + } + + if (!isset($rows[$rowId]['tables'][$tableId])) { + $rows[$rowId]['tables'][$tableId] = [ + 'table_id' => $tableId, + 'table_position' => $row['table_position'], + 'table_label' => $row['table_label'] ?? '', + 'spots' => [], + ]; + } + + $spotId = $row['spot_id']; + if ($spotId === null) { + continue; + } + + $playerName = $row['player_name'] ?? $row['spot_player_name']; + + $rows[$rowId]['tables'][$tableId]['spots'][] = new TableLayoutSpot( + id: $spotId, + position: (int) $row['spot_position'], + playerId: $row['player_id'], + playerName: $playerName, + playerCode: $row['player_code'], + playerCountry: $row['player_country'] !== null ? CountryCode::fromCode($row['player_country']) : null, + ); + } + + return array_values(array_map( + static fn (array $rowData): TableLayoutRow => new TableLayoutRow( + id: $rowData['row_id'], + position: (int) $rowData['row_position'], + label: $rowData['row_label'], + tables: array_values(array_map( + static fn (array $tableData): TableLayoutTable => new TableLayoutTable( + id: $tableData['table_id'], + position: (int) $tableData['table_position'], + label: $tableData['table_label'], + spots: $tableData['spots'], + ), + $rowData['tables'], + )), + ), + $rows, + )); + } +} diff --git a/src/Repository/RoundTableRepository.php b/src/Repository/RoundTableRepository.php new file mode 100644 index 00000000..d5f92790 --- /dev/null +++ b/src/Repository/RoundTableRepository.php @@ -0,0 +1,42 @@ +entityManager->find(RoundTable::class, $tableId); + + return $table ?? throw new RoundTableNotFound(); + } + + public function save(RoundTable $table): void + { + $this->entityManager->persist($table); + } + + public function delete(RoundTable $table): void + { + $this->entityManager->remove($table); + } +} diff --git a/src/Repository/TableRowRepository.php b/src/Repository/TableRowRepository.php new file mode 100644 index 00000000..c982a636 --- /dev/null +++ b/src/Repository/TableRowRepository.php @@ -0,0 +1,42 @@ +entityManager->find(TableRow::class, $rowId); + + return $row ?? throw new TableRowNotFound(); + } + + public function save(TableRow $row): void + { + $this->entityManager->persist($row); + } + + public function delete(TableRow $row): void + { + $this->entityManager->remove($row); + } +} diff --git a/src/Repository/TableSpotRepository.php b/src/Repository/TableSpotRepository.php new file mode 100644 index 00000000..be5cd7e2 --- /dev/null +++ b/src/Repository/TableSpotRepository.php @@ -0,0 +1,42 @@ +entityManager->find(TableSpot::class, $spotId); + + return $spot ?? throw new TableSpotNotFound(); + } + + public function save(TableSpot $spot): void + { + $this->entityManager->persist($spot); + } + + public function delete(TableSpot $spot): void + { + $this->entityManager->remove($spot); + } +} diff --git a/src/Results/TableLayoutRow.php b/src/Results/TableLayoutRow.php new file mode 100644 index 00000000..206e3246 --- /dev/null +++ b/src/Results/TableLayoutRow.php @@ -0,0 +1,19 @@ + $tables + */ + public function __construct( + public string $id, + public int $position, + public null|string $label, + public array $tables, + ) { + } +} diff --git a/src/Results/TableLayoutSpot.php b/src/Results/TableLayoutSpot.php new file mode 100644 index 00000000..9fe6be25 --- /dev/null +++ b/src/Results/TableLayoutSpot.php @@ -0,0 +1,25 @@ +playerId !== null || $this->playerName !== null; + } +} diff --git a/src/Results/TableLayoutTable.php b/src/Results/TableLayoutTable.php new file mode 100644 index 00000000..4f4c7737 --- /dev/null +++ b/src/Results/TableLayoutTable.php @@ -0,0 +1,19 @@ + $spots + */ + public function __construct( + public string $id, + public int $position, + public string $label, + public array $spots, + ) { + } +} diff --git a/templates/components/RoundTableManager.html.twig b/templates/components/RoundTableManager.html.twig new file mode 100644 index 00000000..d1d5e614 --- /dev/null +++ b/templates/components/RoundTableManager.html.twig @@ -0,0 +1,197 @@ + + {% if this.rows|length == 0 %} +
    +

    {{ 'competition.tables.no_layout'|trans }}

    + + {{ 'competition.tables.generate_layout'|trans }} + +
    + {% else %} + {% for row in this.rows %} +
    +
    + {{ row.label ?? 'Row ' ~ row.position }} +
    + + +
    +
    +
    +
    + {% for table in row.tables %} +
    +
    + {{ table.label }} + +
    +
      + {% for spot in table.spots %} +
    • + {% if this.editingSpotId == spot.id %} +
      +
      + + +
      + + {% if this.searchQuery|length >= 2 %} + {% set results = computed.searchResults %} + {% if results|length > 0 %} +
      + {% for player in results %} + + {% endfor %} +
      + {% else %} +

      {{ 'competition.tables.no_player_found'|trans }}

      + {% endif %} + + + {% endif %} +
      + {% elseif spot.isAssigned %} +
      + + {% if spot.playerId %} + {{ spot.playerName }} + {% if spot.playerCode %} + #{{ spot.playerCode|upper }} + {% endif %} + {% if spot.playerCountry is not null %} + + {% endif %} + {% else %} + {{ spot.playerName }} + {{ 'competition.tables.manual'|trans }} + {% endif %} + +
      + + +
      +
      + {% else %} +
      + {{ 'competition.tables.spot'|trans }} {{ spot.position }} +
      + + +
      +
      + {% endif %} +
    • + {% endfor %} +
    • + +
    • +
    +
    + {% endfor %} +
    +
    +
    + {% endfor %} + + + {% endif %} +
    diff --git a/templates/generate_table_layout.html.twig b/templates/generate_table_layout.html.twig new file mode 100644 index 00000000..dbb76eff --- /dev/null +++ b/templates/generate_table_layout.html.twig @@ -0,0 +1,34 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'competition.tables.generate_layout'|trans }} - {{ round.name }}{% endblock %} + +{% block content %} +
    +

    {{ 'competition.tables.generate_layout'|trans }}

    +

    {{ competition.name }} - {{ round.name }}

    + + + +
    + {{ 'competition.tables.generate_info'|trans }} +
    + +
    +
    + {{ form_start(form) }} + {{ form_row(form.numberOfRows) }} + {{ form_row(form.tablesPerRow) }} + {{ form_row(form.spotsPerTable) }} + + + {{ form_end(form) }} +
    +
    +
    +{% endblock %} diff --git a/templates/manage_competition_rounds.html.twig b/templates/manage_competition_rounds.html.twig index 3801d3a0..3b5d9f61 100644 --- a/templates/manage_competition_rounds.html.twig +++ b/templates/manage_competition_rounds.html.twig @@ -55,6 +55,11 @@ {{ 'competition.puzzles'|trans }} + {% if not competition.isOnline %} + + {{ 'competition.tables.tables'|trans }} + + {% endif %} diff --git a/templates/manage_round_tables.html.twig b/templates/manage_round_tables.html.twig new file mode 100644 index 00000000..3439f1e6 --- /dev/null +++ b/templates/manage_round_tables.html.twig @@ -0,0 +1,34 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'competition.tables.manage_tables'|trans }} - {{ round.name }}{% endblock %} + +{% block content %} +
    +

    {{ round.name }}

    +

    {{ competition.name }}

    + + + + {% for flash_message in app.flashes('success') %} +
    + + {{ flash_message }} +
    + {% endfor %} + + {{ component('RoundTableManager', { + roundId: round.id, + competitionId: competition.id, + }) }} +
    +{% endblock %} diff --git a/templates/print_round_tables.html.twig b/templates/print_round_tables.html.twig new file mode 100644 index 00000000..8cd7435e --- /dev/null +++ b/templates/print_round_tables.html.twig @@ -0,0 +1,68 @@ + + + + + + {{ competition.name }} - {{ round.name }} + + + + + +

    {{ competition.name }}

    +

    {{ round.name }}

    + + {% for row in rows %} +
    +
    {{ row.label ?? 'Row ' ~ row.position }}
    +
    + {% for table in row.tables %} +
    +
    {{ table.label }}
    + {% for spot in table.spots %} +
    + {{ spot.position }}. + + {% if spot.playerId %} + {{ spot.playerName }} + {% if spot.playerCode %}(#{{ spot.playerCode|upper }}){% endif %} + {% elseif spot.playerName %} + {{ spot.playerName }} + {% else %} + ________________ + {% endif %} + +
    + {% endfor %} +
    + {% endfor %} +
    +
    + {% endfor %} + + diff --git a/tests/Controller/GenerateTableLayoutControllerTest.php b/tests/Controller/GenerateTableLayoutControllerTest.php new file mode 100644 index 00000000..af5355bc --- /dev/null +++ b/tests/Controller/GenerateTableLayoutControllerTest.php @@ -0,0 +1,41 @@ +request('GET', '/en/generate-table-layout/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseIsSuccessful(); + } + + public function testFormSubmissionGeneratesLayout(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_ADMIN); + + $crawler = $browser->request('GET', '/en/generate-table-layout/' . CompetitionRoundFixture::ROUND_CZECH_FINAL); + $this->assertResponseIsSuccessful(); + + $form = $crawler->filter('form')->form([ + 'generate_table_layout_form[numberOfRows]' => '2', + 'generate_table_layout_form[tablesPerRow]' => '3', + 'generate_table_layout_form[spotsPerTable]' => '2', + ]); + $browser->submit($form); + + $this->assertResponseRedirects('/en/manage-round-tables/' . CompetitionRoundFixture::ROUND_CZECH_FINAL); + } +} diff --git a/tests/Controller/ManageRoundTablesControllerTest.php b/tests/Controller/ManageRoundTablesControllerTest.php new file mode 100644 index 00000000..ecee8e20 --- /dev/null +++ b/tests/Controller/ManageRoundTablesControllerTest.php @@ -0,0 +1,41 @@ +request('GET', '/en/manage-round-tables/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseRedirects(); + } + + public function testMaintainerCanAccessPage(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_ADMIN); + + $browser->request('GET', '/en/manage-round-tables/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseIsSuccessful(); + } + + public function testNonMaintainerDenied(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', '/en/manage-round-tables/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseStatusCodeSame(403); + } +} diff --git a/tests/Controller/PrintRoundTablesControllerTest.php b/tests/Controller/PrintRoundTablesControllerTest.php new file mode 100644 index 00000000..553cdcb5 --- /dev/null +++ b/tests/Controller/PrintRoundTablesControllerTest.php @@ -0,0 +1,23 @@ +request('GET', '/en/print-round-tables/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseIsSuccessful(); + } +} diff --git a/tests/DataFixtures/TableLayoutFixture.php b/tests/DataFixtures/TableLayoutFixture.php new file mode 100644 index 00000000..6933cee7 --- /dev/null +++ b/tests/DataFixtures/TableLayoutFixture.php @@ -0,0 +1,168 @@ +getReference(CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION, CompetitionRound::class); + $player = $this->getReference(PlayerFixture::PLAYER_REGULAR, Player::class); + + // Row 1 + $row1 = new TableRow( + id: Uuid::fromString(self::TABLE_ROW_1), + round: $round, + position: 1, + label: 'Row 1', + ); + $manager->persist($row1); + $this->addReference(self::TABLE_ROW_1, $row1); + + // Row 2 + $row2 = new TableRow( + id: Uuid::fromString(self::TABLE_ROW_2), + round: $round, + position: 2, + label: 'Row 2', + ); + $manager->persist($row2); + $this->addReference(self::TABLE_ROW_2, $row2); + + // Table 1 in Row 1 + $table1 = new RoundTable( + id: Uuid::fromString(self::ROUND_TABLE_1), + row: $row1, + position: 1, + label: 'Table 1', + ); + $manager->persist($table1); + $this->addReference(self::ROUND_TABLE_1, $table1); + + // Table 2 in Row 1 + $table2 = new RoundTable( + id: Uuid::fromString(self::ROUND_TABLE_2), + row: $row1, + position: 2, + label: 'Table 2', + ); + $manager->persist($table2); + $this->addReference(self::ROUND_TABLE_2, $table2); + + // Table 3 in Row 2 + $table3 = new RoundTable( + id: Uuid::fromString(self::ROUND_TABLE_3), + row: $row2, + position: 1, + label: 'Table 3', + ); + $manager->persist($table3); + $this->addReference(self::ROUND_TABLE_3, $table3); + + // Table 4 in Row 2 + $table4 = new RoundTable( + id: Uuid::fromString(self::ROUND_TABLE_4), + row: $row2, + position: 2, + label: 'Table 4', + ); + $manager->persist($table4); + $this->addReference(self::ROUND_TABLE_4, $table4); + + // Spot with assigned player (Table 1) + $spotAssigned = new TableSpot( + id: Uuid::fromString(self::SPOT_ASSIGNED_PLAYER), + table: $table1, + position: 1, + player: $player, + ); + $manager->persist($spotAssigned); + + // Spot with manual name (Table 1) + $spotManual = new TableSpot( + id: Uuid::fromString(self::SPOT_MANUAL_NAME), + table: $table1, + position: 2, + playerName: 'Manual Player', + ); + $manager->persist($spotManual); + + // Empty spot (Table 2) + $spotEmpty = new TableSpot( + id: Uuid::fromString(self::SPOT_EMPTY), + table: $table2, + position: 1, + ); + $manager->persist($spotEmpty); + + // Spots for Table 2 (second spot) + $spot4 = new TableSpot( + id: Uuid::uuid7(), + table: $table2, + position: 2, + ); + $manager->persist($spot4); + + // Spots for Table 3 + $spot5 = new TableSpot( + id: Uuid::uuid7(), + table: $table3, + position: 1, + ); + $manager->persist($spot5); + + $spot6 = new TableSpot( + id: Uuid::uuid7(), + table: $table3, + position: 2, + ); + $manager->persist($spot6); + + // Spots for Table 4 + $spot7 = new TableSpot( + id: Uuid::uuid7(), + table: $table4, + position: 1, + ); + $manager->persist($spot7); + + $spot8 = new TableSpot( + id: Uuid::uuid7(), + table: $table4, + position: 2, + ); + $manager->persist($spot8); + + $manager->flush(); + } + + public function getDependencies(): array + { + return [ + CompetitionRoundFixture::class, + PlayerFixture::class, + ]; + } +} diff --git a/tests/Entity/TableSpotTest.php b/tests/Entity/TableSpotTest.php new file mode 100644 index 00000000..033e0169 --- /dev/null +++ b/tests/Entity/TableSpotTest.php @@ -0,0 +1,115 @@ +createSpot(); + $spot->assignManualName('Manual Name'); + self::assertSame('Manual Name', $spot->playerName); + + $player = $this->createPlayer(); + $spot->assignPlayer($player); + + self::assertSame($player, $spot->player); + self::assertNull($spot->playerName); + } + + public function testAssignManualNameClearsPlayer(): void + { + $spot = $this->createSpot(); + $player = $this->createPlayer(); + $spot->assignPlayer($player); + self::assertSame($player, $spot->player); + + $spot->assignManualName('Manual Name'); + + self::assertSame('Manual Name', $spot->playerName); + self::assertNull($spot->player); + } + + public function testClearAssignment(): void + { + $spot = $this->createSpot(); + $player = $this->createPlayer(); + $spot->assignPlayer($player); + + $spot->clearAssignment(); + + self::assertNull($spot->player); + self::assertNull($spot->playerName); + } + + private function createSpot(): TableSpot + { + $competition = new Competition( + id: Uuid::uuid7(), + name: 'Test Competition', + slug: null, + shortcut: null, + logo: null, + description: null, + link: null, + registrationLink: null, + resultsLink: null, + location: 'Test', + locationCountryCode: null, + dateFrom: null, + dateTo: null, + tag: null, + ); + + $round = new CompetitionRound( + id: Uuid::uuid7(), + competition: $competition, + name: 'Test Round', + minutesLimit: 60, + startsAt: new DateTimeImmutable(), + ); + + $row = new TableRow( + id: Uuid::uuid7(), + round: $round, + position: 1, + ); + + $table = new RoundTable( + id: Uuid::uuid7(), + row: $row, + position: 1, + label: 'Table 1', + ); + + return new TableSpot( + id: Uuid::uuid7(), + table: $table, + position: 1, + ); + } + + private function createPlayer(): Player + { + return new Player( + id: Uuid::uuid7(), + code: 'test', + userId: 'auth0|test', + email: 'test@test.com', + name: 'Test Player', + registeredAt: new DateTimeImmutable(), + ); + } +} diff --git a/tests/MessageHandler/AddRoundTableHandlerTest.php b/tests/MessageHandler/AddRoundTableHandlerTest.php new file mode 100644 index 00000000..75e08c9f --- /dev/null +++ b/tests/MessageHandler/AddRoundTableHandlerTest.php @@ -0,0 +1,49 @@ +messageBus = self::getContainer()->get(MessageBusInterface::class); + $this->database = self::getContainer()->get(Connection::class); + } + + public function testAddTableToRow(): void + { + $tableId = Uuid::uuid7(); + + $this->messageBus->dispatch(new AddRoundTable( + tableId: $tableId, + rowId: TableLayoutFixture::TABLE_ROW_1, + )); + + /** @var int|string|false $tableExists */ + $tableExists = $this->database->fetchOne( + 'SELECT COUNT(*) FROM round_table WHERE id = :id', + ['id' => $tableId->toString()], + ); + self::assertSame(1, (int) $tableExists); + + /** @var int|string|false $spotCount */ + $spotCount = $this->database->fetchOne( + 'SELECT COUNT(*) FROM table_spot WHERE table_id = :tableId', + ['tableId' => $tableId->toString()], + ); + self::assertSame(1, (int) $spotCount); + } +} diff --git a/tests/MessageHandler/AddTableRowHandlerTest.php b/tests/MessageHandler/AddTableRowHandlerTest.php new file mode 100644 index 00000000..911759ff --- /dev/null +++ b/tests/MessageHandler/AddTableRowHandlerTest.php @@ -0,0 +1,42 @@ +messageBus = self::getContainer()->get(MessageBusInterface::class); + $this->database = self::getContainer()->get(Connection::class); + } + + public function testAddRowIncrementsPosition(): void + { + $roundId = CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION; + + $this->messageBus->dispatch(new AddTableRow( + rowId: Uuid::uuid7(), + roundId: $roundId, + )); + + /** @var int|string|false $maxPosition */ + $maxPosition = $this->database->fetchOne( + 'SELECT MAX(position) FROM table_row WHERE round_id = :roundId', + ['roundId' => $roundId], + ); + self::assertSame(3, (int) $maxPosition); + } +} diff --git a/tests/MessageHandler/AddTableSpotHandlerTest.php b/tests/MessageHandler/AddTableSpotHandlerTest.php new file mode 100644 index 00000000..57f6a63e --- /dev/null +++ b/tests/MessageHandler/AddTableSpotHandlerTest.php @@ -0,0 +1,42 @@ +messageBus = self::getContainer()->get(MessageBusInterface::class); + $this->database = self::getContainer()->get(Connection::class); + } + + public function testAddSpotToTable(): void + { + $spotId = Uuid::uuid7(); + + $this->messageBus->dispatch(new AddTableSpot( + spotId: $spotId, + tableId: TableLayoutFixture::ROUND_TABLE_1, + )); + + /** @var int|string|false $position */ + $position = $this->database->fetchOne( + 'SELECT position FROM table_spot WHERE id = :id', + ['id' => $spotId->toString()], + ); + self::assertSame(3, (int) $position); + } +} diff --git a/tests/MessageHandler/AssignPlayerToSpotHandlerTest.php b/tests/MessageHandler/AssignPlayerToSpotHandlerTest.php new file mode 100644 index 00000000..e768c699 --- /dev/null +++ b/tests/MessageHandler/AssignPlayerToSpotHandlerTest.php @@ -0,0 +1,73 @@ +messageBus = self::getContainer()->get(MessageBusInterface::class); + $this->tableSpotRepository = self::getContainer()->get(TableSpotRepository::class); + } + + public function testAssignPlayerFromDatabase(): void + { + $this->messageBus->dispatch(new AssignPlayerToSpot( + spotId: TableLayoutFixture::SPOT_EMPTY, + playerId: PlayerFixture::PLAYER_ADMIN, + )); + + $spot = $this->tableSpotRepository->get(TableLayoutFixture::SPOT_EMPTY); + self::assertNotNull($spot->player); + self::assertSame(PlayerFixture::PLAYER_ADMIN, $spot->player->id->toString()); + self::assertNull($spot->playerName); + } + + public function testAssignManualName(): void + { + $this->messageBus->dispatch(new AssignPlayerToSpot( + spotId: TableLayoutFixture::SPOT_EMPTY, + playerName: 'Jane Smith', + )); + + $spot = $this->tableSpotRepository->get(TableLayoutFixture::SPOT_EMPTY); + self::assertSame('Jane Smith', $spot->playerName); + self::assertNull($spot->player); + } + + public function testClearAssignment(): void + { + $this->messageBus->dispatch(new AssignPlayerToSpot( + spotId: TableLayoutFixture::SPOT_ASSIGNED_PLAYER, + )); + + $spot = $this->tableSpotRepository->get(TableLayoutFixture::SPOT_ASSIGNED_PLAYER); + self::assertNull($spot->player); + self::assertNull($spot->playerName); + } + + public function testReassignOverwritesPrevious(): void + { + $this->messageBus->dispatch(new AssignPlayerToSpot( + spotId: TableLayoutFixture::SPOT_ASSIGNED_PLAYER, + playerName: 'New Manual Name', + )); + + $spot = $this->tableSpotRepository->get(TableLayoutFixture::SPOT_ASSIGNED_PLAYER); + self::assertNull($spot->player); + self::assertSame('New Manual Name', $spot->playerName); + } +} diff --git a/tests/MessageHandler/ClearTableLayoutHandlerTest.php b/tests/MessageHandler/ClearTableLayoutHandlerTest.php new file mode 100644 index 00000000..e2413f65 --- /dev/null +++ b/tests/MessageHandler/ClearTableLayoutHandlerTest.php @@ -0,0 +1,40 @@ +messageBus = self::getContainer()->get(MessageBusInterface::class); + $this->database = self::getContainer()->get(Connection::class); + } + + public function testClearRemovesEverything(): void + { + $roundId = CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION; + + $this->messageBus->dispatch(new ClearTableLayout( + roundId: $roundId, + )); + + /** @var int|string|false $rowCount */ + $rowCount = $this->database->fetchOne( + 'SELECT COUNT(*) FROM table_row WHERE round_id = :roundId', + ['roundId' => $roundId], + ); + self::assertSame(0, (int) $rowCount); + } +} diff --git a/tests/MessageHandler/DeleteRoundTableHandlerTest.php b/tests/MessageHandler/DeleteRoundTableHandlerTest.php new file mode 100644 index 00000000..09e83138 --- /dev/null +++ b/tests/MessageHandler/DeleteRoundTableHandlerTest.php @@ -0,0 +1,45 @@ +messageBus = self::getContainer()->get(MessageBusInterface::class); + $this->database = self::getContainer()->get(Connection::class); + } + + public function testDeleteTableRemovesSpots(): void + { + $this->messageBus->dispatch(new DeleteRoundTable( + tableId: TableLayoutFixture::ROUND_TABLE_1, + )); + + /** @var int|string|false $tableCount */ + $tableCount = $this->database->fetchOne( + 'SELECT COUNT(*) FROM round_table WHERE id = :id', + ['id' => TableLayoutFixture::ROUND_TABLE_1], + ); + self::assertSame(0, (int) $tableCount); + + /** @var int|string|false $spotCount */ + $spotCount = $this->database->fetchOne( + 'SELECT COUNT(*) FROM table_spot WHERE table_id = :tableId', + ['tableId' => TableLayoutFixture::ROUND_TABLE_1], + ); + self::assertSame(0, (int) $spotCount); + } +} diff --git a/tests/MessageHandler/DeleteTableRowHandlerTest.php b/tests/MessageHandler/DeleteTableRowHandlerTest.php new file mode 100644 index 00000000..cba30b49 --- /dev/null +++ b/tests/MessageHandler/DeleteTableRowHandlerTest.php @@ -0,0 +1,52 @@ +messageBus = self::getContainer()->get(MessageBusInterface::class); + $this->database = self::getContainer()->get(Connection::class); + } + + public function testDeleteRowRemovesTablesAndSpots(): void + { + $this->messageBus->dispatch(new DeleteTableRow( + rowId: TableLayoutFixture::TABLE_ROW_1, + )); + + /** @var int|string|false $rowCount */ + $rowCount = $this->database->fetchOne( + 'SELECT COUNT(*) FROM table_row WHERE id = :id', + ['id' => TableLayoutFixture::TABLE_ROW_1], + ); + self::assertSame(0, (int) $rowCount); + + /** @var int|string|false $tableCount */ + $tableCount = $this->database->fetchOne( + 'SELECT COUNT(*) FROM round_table WHERE row_id = :rowId', + ['rowId' => TableLayoutFixture::TABLE_ROW_1], + ); + self::assertSame(0, (int) $tableCount); + + /** @var int|string|false $spotCount */ + $spotCount = $this->database->fetchOne( + 'SELECT COUNT(*) FROM table_spot WHERE table_id = :tableId', + ['tableId' => TableLayoutFixture::ROUND_TABLE_1], + ); + self::assertSame(0, (int) $spotCount); + } +} diff --git a/tests/MessageHandler/DeleteTableSpotHandlerTest.php b/tests/MessageHandler/DeleteTableSpotHandlerTest.php new file mode 100644 index 00000000..59074f1b --- /dev/null +++ b/tests/MessageHandler/DeleteTableSpotHandlerTest.php @@ -0,0 +1,38 @@ +messageBus = self::getContainer()->get(MessageBusInterface::class); + $this->database = self::getContainer()->get(Connection::class); + } + + public function testDeleteSpot(): void + { + $this->messageBus->dispatch(new DeleteTableSpot( + spotId: TableLayoutFixture::SPOT_EMPTY, + )); + + /** @var int|string|false $count */ + $count = $this->database->fetchOne( + 'SELECT COUNT(*) FROM table_spot WHERE id = :id', + ['id' => TableLayoutFixture::SPOT_EMPTY], + ); + self::assertSame(0, (int) $count); + } +} diff --git a/tests/MessageHandler/GenerateTableLayoutHandlerTest.php b/tests/MessageHandler/GenerateTableLayoutHandlerTest.php new file mode 100644 index 00000000..4db51667 --- /dev/null +++ b/tests/MessageHandler/GenerateTableLayoutHandlerTest.php @@ -0,0 +1,96 @@ +messageBus = self::getContainer()->get(MessageBusInterface::class); + $this->database = self::getContainer()->get(Connection::class); + } + + public function testGenerateCreatesCorrectStructure(): void + { + $roundId = CompetitionRoundFixture::ROUND_CZECH_FINAL; + + $this->messageBus->dispatch(new GenerateTableLayout( + roundId: $roundId, + numberOfRows: 2, + tablesPerRow: 3, + spotsPerTable: 2, + )); + + self::assertSame(2, $this->countRows($roundId)); + self::assertSame(6, $this->countTablesForRound($roundId)); + self::assertSame(12, $this->countSpotsForRound($roundId)); + + /** @var list $labels */ + $labels = $this->database->fetchFirstColumn( + 'SELECT rt.label FROM round_table rt INNER JOIN table_row tr ON rt.row_id = tr.id WHERE tr.round_id = :roundId ORDER BY tr.position, rt.position', + ['roundId' => $roundId], + ); + self::assertSame(['Table 1', 'Table 2', 'Table 3', 'Table 4', 'Table 5', 'Table 6'], $labels); + } + + public function testRegenerateReplacesExistingLayout(): void + { + $roundId = CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION; + + $this->messageBus->dispatch(new GenerateTableLayout( + roundId: $roundId, + numberOfRows: 1, + tablesPerRow: 2, + spotsPerTable: 3, + )); + + self::assertSame(1, $this->countRows($roundId)); + self::assertSame(2, $this->countTablesForRound($roundId)); + self::assertSame(6, $this->countSpotsForRound($roundId)); + } + + private function countRows(string $roundId): int + { + /** @var int|string|false $result */ + $result = $this->database->fetchOne( + 'SELECT COUNT(*) FROM table_row WHERE round_id = :roundId', + ['roundId' => $roundId], + ); + + return (int) $result; + } + + private function countTablesForRound(string $roundId): int + { + /** @var int|string|false $result */ + $result = $this->database->fetchOne( + 'SELECT COUNT(*) FROM round_table rt INNER JOIN table_row tr ON rt.row_id = tr.id WHERE tr.round_id = :roundId', + ['roundId' => $roundId], + ); + + return (int) $result; + } + + private function countSpotsForRound(string $roundId): int + { + /** @var int|string|false $result */ + $result = $this->database->fetchOne( + 'SELECT COUNT(*) FROM table_spot ts INNER JOIN round_table rt ON ts.table_id = rt.id INNER JOIN table_row tr ON rt.row_id = tr.id WHERE tr.round_id = :roundId', + ['roundId' => $roundId], + ); + + return (int) $result; + } +} diff --git a/tests/Query/GetTableLayoutForRoundTest.php b/tests/Query/GetTableLayoutForRoundTest.php new file mode 100644 index 00000000..afe1cab9 --- /dev/null +++ b/tests/Query/GetTableLayoutForRoundTest.php @@ -0,0 +1,60 @@ +query = self::getContainer()->get(GetTableLayoutForRound::class); + } + + public function testReturnsNestedStructure(): void + { + $rows = $this->query->byRoundId(CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + self::assertCount(2, $rows); + self::assertCount(2, $rows[0]->tables); + self::assertCount(2, $rows[1]->tables); + self::assertCount(2, $rows[0]->tables[0]->spots); + } + + public function testSpotsShowPlayerData(): void + { + $rows = $this->query->byRoundId(CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $assignedSpot = $rows[0]->tables[0]->spots[0]; + self::assertSame(PlayerFixture::PLAYER_REGULAR, $assignedSpot->playerId); + self::assertNotNull($assignedSpot->playerName); + self::assertTrue($assignedSpot->isAssigned()); + } + + public function testEmptyRoundReturnsEmptyArray(): void + { + $rows = $this->query->byRoundId(CompetitionRoundFixture::ROUND_CZECH_FINAL); + + self::assertSame([], $rows); + } + + public function testOrderingIsCorrect(): void + { + $rows = $this->query->byRoundId(CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + self::assertSame(1, $rows[0]->position); + self::assertSame(2, $rows[1]->position); + self::assertSame(1, $rows[0]->tables[0]->position); + self::assertSame(2, $rows[0]->tables[1]->position); + self::assertSame(1, $rows[0]->tables[0]->spots[0]->position); + self::assertSame(2, $rows[0]->tables[0]->spots[1]->position); + } +} diff --git a/translations/messages.en.yml b/translations/messages.en.yml index b47a1f1f..da31b80a 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -2462,6 +2462,31 @@ competition: round_puzzle: form: hide_until_round_starts: "Do not reveal until round starts" + tables: + tables: "Tables" + manage_tables: "Manage Tables" + generate_layout: "Generate Layout" + generate: "Generate" + generate_info: "This will generate a table layout for the round. You can modify it afterwards by adding or removing rows, tables, and spots." + back_to_tables: "Back to Tables" + print: "Print" + no_layout: "No table layout yet. Generate one to get started." + add_row: "Add Row" + add_table: "Add Table" + add_spot: "Add Spot" + spot: "Spot" + search_player: "Search player..." + no_player_found: "No player found." + use_as_name: "Use \"%name%\" as name" + manual: "manual" + confirm_delete_row: "Are you sure you want to delete this row and all its tables?" + confirm_delete_table: "Are you sure you want to delete this table and all its spots?" + form: + number_of_rows: "Number of rows" + tables_per_row: "Tables per row" + spots_per_table: "Spots per table" + flash: + layout_generated: "Table layout has been generated." flash: created: "Event submitted for approval." updated: "Event updated." From d7b9f47aca575644fabda0011cbadb2f08018ed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Wed, 4 Mar 2026 20:28:51 +0100 Subject: [PATCH 3/8] Competition timewatch --- .../controllers/round_stopwatch_controller.js | 162 ++++++++++++++++++ migrations/Version20260304185605.php | 33 ++++ .../ManageRoundStopwatchController.php | 44 +++++ .../ResetRoundStopwatchController.php | 46 +++++ src/Controller/RoundStopwatchController.php | 45 +++++ .../StartRoundStopwatchController.php | 46 +++++ .../StopRoundStopwatchController.php | 46 +++++ src/Entity/CompetitionRound.php | 21 +++ src/Message/ResetRoundStopwatch.php | 13 ++ src/Message/StartRoundStopwatch.php | 13 ++ src/Message/StopRoundStopwatch.php | 13 ++ .../ResetRoundStopwatchHandler.php | 36 ++++ .../StartRoundStopwatchHandler.php | 41 +++++ .../StopRoundStopwatchHandler.php | 36 ++++ templates/manage_competition_rounds.html.twig | 3 + templates/manage_round_stopwatch.html.twig | 63 +++++++ templates/round_stopwatch.html.twig | 56 ++++++ .../ManageRoundStopwatchControllerTest.php | 42 +++++ .../ResetRoundStopwatchControllerTest.php | 23 +++ .../RoundStopwatchControllerTest.php | 30 ++++ .../StartRoundStopwatchControllerTest.php | 32 ++++ .../StopRoundStopwatchControllerTest.php | 23 +++ .../ResetRoundStopwatchHandlerTest.php | 41 +++++ .../StartRoundStopwatchHandlerTest.php | 36 ++++ .../StopRoundStopwatchHandlerTest.php | 41 +++++ translations/messages.en.yml | 14 ++ 26 files changed, 999 insertions(+) create mode 100644 assets/controllers/round_stopwatch_controller.js create mode 100644 migrations/Version20260304185605.php create mode 100644 src/Controller/ManageRoundStopwatchController.php create mode 100644 src/Controller/ResetRoundStopwatchController.php create mode 100644 src/Controller/RoundStopwatchController.php create mode 100644 src/Controller/StartRoundStopwatchController.php create mode 100644 src/Controller/StopRoundStopwatchController.php create mode 100644 src/Message/ResetRoundStopwatch.php create mode 100644 src/Message/StartRoundStopwatch.php create mode 100644 src/Message/StopRoundStopwatch.php create mode 100644 src/MessageHandler/ResetRoundStopwatchHandler.php create mode 100644 src/MessageHandler/StartRoundStopwatchHandler.php create mode 100644 src/MessageHandler/StopRoundStopwatchHandler.php create mode 100644 templates/manage_round_stopwatch.html.twig create mode 100644 templates/round_stopwatch.html.twig create mode 100644 tests/Controller/ManageRoundStopwatchControllerTest.php create mode 100644 tests/Controller/ResetRoundStopwatchControllerTest.php create mode 100644 tests/Controller/RoundStopwatchControllerTest.php create mode 100644 tests/Controller/StartRoundStopwatchControllerTest.php create mode 100644 tests/Controller/StopRoundStopwatchControllerTest.php create mode 100644 tests/MessageHandler/ResetRoundStopwatchHandlerTest.php create mode 100644 tests/MessageHandler/StartRoundStopwatchHandlerTest.php create mode 100644 tests/MessageHandler/StopRoundStopwatchHandlerTest.php diff --git a/assets/controllers/round_stopwatch_controller.js b/assets/controllers/round_stopwatch_controller.js new file mode 100644 index 00000000..0ef7fd46 --- /dev/null +++ b/assets/controllers/round_stopwatch_controller.js @@ -0,0 +1,162 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['hours', 'minutes', 'seconds', 'status']; + + static values = { + status: String, + startedAt: String, + minutesLimit: Number, + serverNow: String, + mercureUrl: String, + topic: String, + }; + + connect() { + this._serverOffset = this._calculateServerOffset(); + this._rafId = null; + this._startTimestamp = null; + this._stopped = false; + + if (this.statusValue === 'running' && this.startedAtValue) { + this._startTimestamp = new Date(this.startedAtValue).getTime() + this._serverOffset; + this._startRaf(); + } else if (this.statusValue === 'stopped' && this.startedAtValue) { + this._startTimestamp = new Date(this.startedAtValue).getTime() + this._serverOffset; + this._stopped = true; + this._renderTime(Date.now() - this._startTimestamp); + } else { + this._renderTime(0); + } + + this._connectMercure(); + } + + disconnect() { + this._stopRaf(); + if (this._eventSource) { + this._eventSource.close(); + this._eventSource = null; + } + } + + _connectMercure() { + if (!this.mercureUrlValue || !this.topicValue) return; + + const url = new URL(this.mercureUrlValue); + url.searchParams.append('topic', this.topicValue); + + this._eventSource = new EventSource(url); + + this._eventSource.addEventListener('message', (event) => { + try { + const data = JSON.parse(event.data); + this._handleMercureMessage(data); + } catch { /* ignore non-JSON */ } + }); + } + + _handleMercureMessage(data) { + switch (data.action) { + case 'start': + this._stopped = false; + this._startTimestamp = new Date(data.startedAt).getTime() + this._serverOffset; + if (data.minutesLimit) { + this.minutesLimitValue = data.minutesLimit; + } + this._updateStatus('running'); + this._startRaf(); + break; + + case 'stop': + this._stopped = true; + this._stopRaf(); + this._updateStatus('stopped'); + break; + + case 'reset': + this._stopped = false; + this._stopRaf(); + this._startTimestamp = null; + this._renderTime(0); + this._updateStatus(''); + break; + } + } + + _startRaf() { + this._stopRaf(); + const tick = () => { + if (this._startTimestamp === null) return; + let elapsed = Date.now() - this._startTimestamp; + const limitMs = this.minutesLimitValue * 60 * 1000; + + if (elapsed >= limitMs) { + elapsed = limitMs; + this._renderTime(elapsed); + this._updateStatus('times_up'); + this._stopRaf(); + return; + } + + this._renderTime(elapsed); + this._rafId = requestAnimationFrame(tick); + }; + this._rafId = requestAnimationFrame(tick); + } + + _stopRaf() { + if (this._rafId !== null) { + cancelAnimationFrame(this._rafId); + this._rafId = null; + } + } + + _renderTime(ms) { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + this.hoursTarget.textContent = this._pad(hours); + this.minutesTarget.textContent = this._pad(minutes); + this.secondsTarget.textContent = this._pad(seconds); + } + + _pad(n) { + return n < 10 ? '0' + n : String(n); + } + + _calculateServerOffset() { + if (!this.serverNowValue) return 0; + return Date.now() - new Date(this.serverNowValue).getTime(); + } + + _updateStatus(status) { + if (!this.hasStatusTarget) return; + + const labels = { + '': this.statusTarget.dataset.labelNotStarted || 'Not started', + 'running': this.statusTarget.dataset.labelRunning || 'Running', + 'stopped': this.statusTarget.dataset.labelStopped || 'Stopped', + 'times_up': this.statusTarget.dataset.labelTimesUp || "Time's up!", + }; + + this.statusTarget.textContent = labels[status] || labels['']; + + this.statusTarget.classList.remove('text-muted', 'text-success', 'text-warning', 'text-danger'); + switch (status) { + case 'running': + this.statusTarget.classList.add('text-success'); + break; + case 'stopped': + this.statusTarget.classList.add('text-warning'); + break; + case 'times_up': + this.statusTarget.classList.add('text-danger'); + break; + default: + this.statusTarget.classList.add('text-muted'); + } + } +} diff --git a/migrations/Version20260304185605.php b/migrations/Version20260304185605.php new file mode 100644 index 00000000..10bcce8e --- /dev/null +++ b/migrations/Version20260304185605.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE competition_round ADD stopwatch_started_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE competition_round ADD stopwatch_status VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE competition_round DROP stopwatch_started_at'); + $this->addSql('ALTER TABLE competition_round DROP stopwatch_status'); + } +} diff --git a/src/Controller/ManageRoundStopwatchController.php b/src/Controller/ManageRoundStopwatchController.php new file mode 100644 index 00000000..ac9feb04 --- /dev/null +++ b/src/Controller/ManageRoundStopwatchController.php @@ -0,0 +1,44 @@ + '/sprava-stopek-kola/{roundId}', + 'en' => '/en/manage-round-stopwatch/{roundId}', + 'es' => '/es/manage-round-stopwatch/{roundId}', + 'ja' => '/ja/manage-round-stopwatch/{roundId}', + 'fr' => '/fr/manage-round-stopwatch/{roundId}', + 'de' => '/de/manage-round-stopwatch/{roundId}', + ], + name: 'manage_round_stopwatch', + )] + public function __invoke(string $roundId): Response + { + $round = $this->competitionRoundRepository->get($roundId); + $competitionId = $round->competition->id->toString(); + $this->denyAccessUnlessGranted(CompetitionEditVoter::COMPETITION_EDIT, $competitionId); + + return $this->render('manage_round_stopwatch.html.twig', [ + 'round' => $round, + 'competition' => $round->competition, + ]); + } +} diff --git a/src/Controller/ResetRoundStopwatchController.php b/src/Controller/ResetRoundStopwatchController.php new file mode 100644 index 00000000..47486542 --- /dev/null +++ b/src/Controller/ResetRoundStopwatchController.php @@ -0,0 +1,46 @@ + '/resetovat-stopky-kola/{roundId}', + 'en' => '/en/reset-round-stopwatch/{roundId}', + 'es' => '/es/reset-round-stopwatch/{roundId}', + 'ja' => '/ja/reset-round-stopwatch/{roundId}', + 'fr' => '/fr/reset-round-stopwatch/{roundId}', + 'de' => '/de/reset-round-stopwatch/{roundId}', + ], + name: 'reset_round_stopwatch', + methods: ['POST'], + )] + public function __invoke(string $roundId): Response + { + $round = $this->competitionRoundRepository->get($roundId); + $this->denyAccessUnlessGranted(CompetitionEditVoter::COMPETITION_EDIT, $round->competition->id->toString()); + + $this->messageBus->dispatch(new ResetRoundStopwatch(roundId: $roundId)); + + return $this->redirectToRoute('manage_round_stopwatch', ['roundId' => $roundId]); + } +} diff --git a/src/Controller/RoundStopwatchController.php b/src/Controller/RoundStopwatchController.php new file mode 100644 index 00000000..2909cef7 --- /dev/null +++ b/src/Controller/RoundStopwatchController.php @@ -0,0 +1,45 @@ + '/stopky-kola/{roundId}', + 'en' => '/en/round-stopwatch/{roundId}', + 'es' => '/es/round-stopwatch/{roundId}', + 'ja' => '/ja/round-stopwatch/{roundId}', + 'fr' => '/fr/round-stopwatch/{roundId}', + 'de' => '/de/round-stopwatch/{roundId}', + ], + name: 'round_stopwatch', + )] + public function __invoke(string $roundId): Response + { + $round = $this->competitionRoundRepository->get($roundId); + $canManage = $this->isGranted(CompetitionEditVoter::COMPETITION_EDIT, $round->competition->id->toString()); + + return $this->render('round_stopwatch.html.twig', [ + 'round' => $round, + 'competition' => $round->competition, + 'canManage' => $canManage, + 'serverNow' => $this->clock->now()->format(\DateTimeInterface::ATOM), + ]); + } +} diff --git a/src/Controller/StartRoundStopwatchController.php b/src/Controller/StartRoundStopwatchController.php new file mode 100644 index 00000000..13dd14ff --- /dev/null +++ b/src/Controller/StartRoundStopwatchController.php @@ -0,0 +1,46 @@ + '/spustit-stopky-kola/{roundId}', + 'en' => '/en/start-round-stopwatch/{roundId}', + 'es' => '/es/start-round-stopwatch/{roundId}', + 'ja' => '/ja/start-round-stopwatch/{roundId}', + 'fr' => '/fr/start-round-stopwatch/{roundId}', + 'de' => '/de/start-round-stopwatch/{roundId}', + ], + name: 'start_round_stopwatch', + methods: ['POST'], + )] + public function __invoke(string $roundId): Response + { + $round = $this->competitionRoundRepository->get($roundId); + $this->denyAccessUnlessGranted(CompetitionEditVoter::COMPETITION_EDIT, $round->competition->id->toString()); + + $this->messageBus->dispatch(new StartRoundStopwatch(roundId: $roundId)); + + return $this->redirectToRoute('round_stopwatch', ['roundId' => $roundId]); + } +} diff --git a/src/Controller/StopRoundStopwatchController.php b/src/Controller/StopRoundStopwatchController.php new file mode 100644 index 00000000..c80cdc32 --- /dev/null +++ b/src/Controller/StopRoundStopwatchController.php @@ -0,0 +1,46 @@ + '/zastavit-stopky-kola/{roundId}', + 'en' => '/en/stop-round-stopwatch/{roundId}', + 'es' => '/es/stop-round-stopwatch/{roundId}', + 'ja' => '/ja/stop-round-stopwatch/{roundId}', + 'fr' => '/fr/stop-round-stopwatch/{roundId}', + 'de' => '/de/stop-round-stopwatch/{roundId}', + ], + name: 'stop_round_stopwatch', + methods: ['POST'], + )] + public function __invoke(string $roundId): Response + { + $round = $this->competitionRoundRepository->get($roundId); + $this->denyAccessUnlessGranted(CompetitionEditVoter::COMPETITION_EDIT, $round->competition->id->toString()); + + $this->messageBus->dispatch(new StopRoundStopwatch(roundId: $roundId)); + + return $this->redirectToRoute('manage_round_stopwatch', ['roundId' => $roundId]); + } +} diff --git a/src/Entity/CompetitionRound.php b/src/Entity/CompetitionRound.php index a8ee770d..e89e3cbd 100644 --- a/src/Entity/CompetitionRound.php +++ b/src/Entity/CompetitionRound.php @@ -44,6 +44,10 @@ public function __construct( public null|string $badgeBackgroundColor = null, #[Column(nullable: true)] public null|string $badgeTextColor = null, + #[Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + public null|DateTimeImmutable $stopwatchStartedAt = null, + #[Column(nullable: true)] + public null|string $stopwatchStatus = null, ) { } @@ -60,4 +64,21 @@ public function edit( $this->badgeBackgroundColor = $badgeBackgroundColor; $this->badgeTextColor = $badgeTextColor; } + + public function startStopwatch(DateTimeImmutable $startedAt): void + { + $this->stopwatchStartedAt = $startedAt; + $this->stopwatchStatus = 'running'; + } + + public function stopStopwatch(): void + { + $this->stopwatchStatus = 'stopped'; + } + + public function resetStopwatch(): void + { + $this->stopwatchStartedAt = null; + $this->stopwatchStatus = null; + } } diff --git a/src/Message/ResetRoundStopwatch.php b/src/Message/ResetRoundStopwatch.php new file mode 100644 index 00000000..fa357625 --- /dev/null +++ b/src/Message/ResetRoundStopwatch.php @@ -0,0 +1,13 @@ +competitionRoundRepository->get($message->roundId); + + $round->resetStopwatch(); + + $this->hub->publish(new Update( + '/round-stopwatch/' . $round->id->toString(), + json_encode([ + 'action' => 'reset', + ], JSON_THROW_ON_ERROR), + private: false, + )); + } +} diff --git a/src/MessageHandler/StartRoundStopwatchHandler.php b/src/MessageHandler/StartRoundStopwatchHandler.php new file mode 100644 index 00000000..0ac5e873 --- /dev/null +++ b/src/MessageHandler/StartRoundStopwatchHandler.php @@ -0,0 +1,41 @@ +competitionRoundRepository->get($message->roundId); + $now = $this->clock->now(); + + $round->startStopwatch($now); + + $this->hub->publish(new Update( + '/round-stopwatch/' . $round->id->toString(), + json_encode([ + 'action' => 'start', + 'startedAt' => $now->format(\DateTimeInterface::ATOM), + 'minutesLimit' => $round->minutesLimit, + ], JSON_THROW_ON_ERROR), + private: false, + )); + } +} diff --git a/src/MessageHandler/StopRoundStopwatchHandler.php b/src/MessageHandler/StopRoundStopwatchHandler.php new file mode 100644 index 00000000..69e8a5d7 --- /dev/null +++ b/src/MessageHandler/StopRoundStopwatchHandler.php @@ -0,0 +1,36 @@ +competitionRoundRepository->get($message->roundId); + + $round->stopStopwatch(); + + $this->hub->publish(new Update( + '/round-stopwatch/' . $round->id->toString(), + json_encode([ + 'action' => 'stop', + ], JSON_THROW_ON_ERROR), + private: false, + )); + } +} diff --git a/templates/manage_competition_rounds.html.twig b/templates/manage_competition_rounds.html.twig index 3b5d9f61..00bc0050 100644 --- a/templates/manage_competition_rounds.html.twig +++ b/templates/manage_competition_rounds.html.twig @@ -60,6 +60,9 @@ {{ 'competition.tables.tables'|trans }} {% endif %} + + {{ 'competition.stopwatch.title'|trans }} + diff --git a/templates/manage_round_stopwatch.html.twig b/templates/manage_round_stopwatch.html.twig new file mode 100644 index 00000000..493009fd --- /dev/null +++ b/templates/manage_round_stopwatch.html.twig @@ -0,0 +1,63 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'competition.stopwatch.manage'|trans }} - {{ round.name }}{% endblock %} + +{% block content %} +
    +

    {{ 'competition.stopwatch.manage'|trans }}

    +

    {{ competition.name }} — {{ round.name }}

    + + + +
    +
    +

    + {{ 'competition.round.minutes_limit'|trans }}: {{ round.minutesLimit }} min +

    +

    + Status: + {% if round.stopwatchStatus == 'running' %} + {{ 'competition.stopwatch.running'|trans }} + {% elseif round.stopwatchStatus == 'stopped' %} + {{ 'competition.stopwatch.stopped'|trans }} + {% else %} + {{ 'competition.stopwatch.not_started'|trans }} + {% endif %} +

    + +
    + {% if round.stopwatchStatus != 'running' %} +
    + +
    + {% endif %} + + {% if round.stopwatchStatus == 'running' %} +
    + +
    + {% endif %} + + {% if round.stopwatchStatus is not null %} +
    + +
    + {% endif %} +
    +
    +
    +
    +{% endblock %} diff --git a/templates/round_stopwatch.html.twig b/templates/round_stopwatch.html.twig new file mode 100644 index 00000000..3ae2357a --- /dev/null +++ b/templates/round_stopwatch.html.twig @@ -0,0 +1,56 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'competition.stopwatch.title'|trans }} - {{ round.name }} - {{ competition.name }}{% endblock %} + +{% block full_content %} +
    +

    {{ competition.name }}

    +

    {{ round.name }}

    + +
    + 00 + : + 00 + : + 00 +
    + +
    + {% if round.stopwatchStatus == 'running' %} + {{ 'competition.stopwatch.running'|trans }} + {% elseif round.stopwatchStatus == 'stopped' %} + {{ 'competition.stopwatch.stopped'|trans }} + {% else %} + {{ 'competition.stopwatch.not_started'|trans }} + {% endif %} +
    + + {% if canManage and round.stopwatchStatus != 'running' %} +
    + +
    + {% endif %} + + {% if canManage %} + + {{ 'competition.stopwatch.manage'|trans }} + + {% endif %} +
    +{% endblock %} diff --git a/tests/Controller/ManageRoundStopwatchControllerTest.php b/tests/Controller/ManageRoundStopwatchControllerTest.php new file mode 100644 index 00000000..33ee3cd1 --- /dev/null +++ b/tests/Controller/ManageRoundStopwatchControllerTest.php @@ -0,0 +1,42 @@ +request('GET', '/en/manage-round-stopwatch/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseRedirects(); + } + + public function testMaintainerCanAccessPage(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_ADMIN); + + $browser->request('GET', '/en/manage-round-stopwatch/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseIsSuccessful(); + } + + public function testNonMaintainerDenied(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', '/en/manage-round-stopwatch/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseStatusCodeSame(403); + } +} diff --git a/tests/Controller/ResetRoundStopwatchControllerTest.php b/tests/Controller/ResetRoundStopwatchControllerTest.php new file mode 100644 index 00000000..b0cabbda --- /dev/null +++ b/tests/Controller/ResetRoundStopwatchControllerTest.php @@ -0,0 +1,23 @@ +request('POST', '/en/reset-round-stopwatch/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseRedirects(); + } +} diff --git a/tests/Controller/RoundStopwatchControllerTest.php b/tests/Controller/RoundStopwatchControllerTest.php new file mode 100644 index 00000000..c6a92afb --- /dev/null +++ b/tests/Controller/RoundStopwatchControllerTest.php @@ -0,0 +1,30 @@ +request('GET', '/en/round-stopwatch/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseIsSuccessful(); + } + + public function testPublicPageShowsRoundName(): void + { + $browser = self::createClient(); + + $browser->request('GET', '/en/round-stopwatch/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorTextContains('h2', 'Qualification Round'); + } +} diff --git a/tests/Controller/StartRoundStopwatchControllerTest.php b/tests/Controller/StartRoundStopwatchControllerTest.php new file mode 100644 index 00000000..a8d02149 --- /dev/null +++ b/tests/Controller/StartRoundStopwatchControllerTest.php @@ -0,0 +1,32 @@ +request('POST', '/en/start-round-stopwatch/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseRedirects(); + } + + public function testAnonymousCannotStart(): void + { + $browser = self::createClient(); + + $browser->request('POST', '/en/start-round-stopwatch/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseRedirects(); + } +} diff --git a/tests/Controller/StopRoundStopwatchControllerTest.php b/tests/Controller/StopRoundStopwatchControllerTest.php new file mode 100644 index 00000000..43dee75b --- /dev/null +++ b/tests/Controller/StopRoundStopwatchControllerTest.php @@ -0,0 +1,23 @@ +request('POST', '/en/stop-round-stopwatch/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseRedirects(); + } +} diff --git a/tests/MessageHandler/ResetRoundStopwatchHandlerTest.php b/tests/MessageHandler/ResetRoundStopwatchHandlerTest.php new file mode 100644 index 00000000..b3a04d9b --- /dev/null +++ b/tests/MessageHandler/ResetRoundStopwatchHandlerTest.php @@ -0,0 +1,41 @@ +messageBus = self::getContainer()->get(MessageBusInterface::class); + $this->competitionRoundRepository = self::getContainer()->get(CompetitionRoundRepository::class); + } + + public function testResetClearsEverything(): void + { + $this->messageBus->dispatch(new StartRoundStopwatch( + roundId: CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION, + )); + + $this->messageBus->dispatch(new ResetRoundStopwatch( + roundId: CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION, + )); + + $round = $this->competitionRoundRepository->get(CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + self::assertNull($round->stopwatchStartedAt); + self::assertNull($round->stopwatchStatus); + } +} diff --git a/tests/MessageHandler/StartRoundStopwatchHandlerTest.php b/tests/MessageHandler/StartRoundStopwatchHandlerTest.php new file mode 100644 index 00000000..d9f15f3a --- /dev/null +++ b/tests/MessageHandler/StartRoundStopwatchHandlerTest.php @@ -0,0 +1,36 @@ +messageBus = self::getContainer()->get(MessageBusInterface::class); + $this->competitionRoundRepository = self::getContainer()->get(CompetitionRoundRepository::class); + } + + public function testStartSetsTimestampAndStatus(): void + { + $this->messageBus->dispatch(new StartRoundStopwatch( + roundId: CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION, + )); + + $round = $this->competitionRoundRepository->get(CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + self::assertNotNull($round->stopwatchStartedAt); + self::assertSame('running', $round->stopwatchStatus); + } +} diff --git a/tests/MessageHandler/StopRoundStopwatchHandlerTest.php b/tests/MessageHandler/StopRoundStopwatchHandlerTest.php new file mode 100644 index 00000000..e385a3f4 --- /dev/null +++ b/tests/MessageHandler/StopRoundStopwatchHandlerTest.php @@ -0,0 +1,41 @@ +messageBus = self::getContainer()->get(MessageBusInterface::class); + $this->competitionRoundRepository = self::getContainer()->get(CompetitionRoundRepository::class); + } + + public function testStopPreservesStartedAtAndSetsStatus(): void + { + $this->messageBus->dispatch(new StartRoundStopwatch( + roundId: CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION, + )); + + $this->messageBus->dispatch(new StopRoundStopwatch( + roundId: CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION, + )); + + $round = $this->competitionRoundRepository->get(CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + self::assertNotNull($round->stopwatchStartedAt); + self::assertSame('stopped', $round->stopwatchStatus); + } +} diff --git a/translations/messages.en.yml b/translations/messages.en.yml index da31b80a..04477933 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -2487,6 +2487,20 @@ competition: spots_per_table: "Spots per table" flash: layout_generated: "Table layout has been generated." + stopwatch: + title: "Round Stopwatch" + not_started: "Not started" + running: "Running" + stopped: "Stopped" + times_up: "Time's up!" + start: "Start Stopwatch" + stop: "Stop Stopwatch" + reset: "Reset Stopwatch" + manage: "Manage Stopwatch" + view_public: "View Public Page" + back_to_rounds: "Back to Rounds" + confirm_stop: "Are you sure you want to stop the stopwatch?" + confirm_reset: "Are you sure you want to reset the stopwatch?" flash: created: "Event submitted for approval." updated: "Event updated." From 16c4c2c6233b1df43f828131d889e9f056cafca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Thu, 2 Apr 2026 11:23:09 +0200 Subject: [PATCH 4/8] Replace NOW() with ClockInterface in all query classes for testability Injected Psr\Clock\ClockInterface into 31 query classes and replaced ~70 SQL NOW() calls with :now named parameters, making all time-dependent queries testable with a frozen clock. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Query/GetBorrowedPuzzles.php | 10 ++++++---- src/Query/GetCollectionItems.php | 10 ++++++---- src/Query/GetCompetitionParticipants.php | 9 ++++++--- src/Query/GetConversations.php | 12 +++++++---- src/Query/GetFastestGroups.php | 5 ++++- src/Query/GetFastestPairs.php | 5 ++++- src/Query/GetFastestPlayers.php | 5 ++++- src/Query/GetLastSolvedPuzzle.php | 11 ++++++++--- src/Query/GetLendBorrowHistory.php | 6 ++++-- src/Query/GetLentPuzzles.php | 10 ++++++---- src/Query/GetMarketplaceListings.php | 9 ++++++--- src/Query/GetModerationActions.php | 5 ++++- src/Query/GetMostSolvedPuzzles.php | 8 ++++++-- src/Query/GetNotifications.php | 15 ++++++++------ src/Query/GetPendingPuzzleProposals.php | 6 ++++-- src/Query/GetPlayerSolvedPuzzles.php | 23 +++++++++++++++------- src/Query/GetPlayersWithUnreadMessages.php | 18 +++++++++++------ src/Query/GetPuzzleChangeRequests.php | 8 ++++++-- src/Query/GetPuzzleMergeRequests.php | 12 +++++++---- src/Query/GetPuzzleOverview.php | 11 ++++++++--- src/Query/GetPuzzleTracking.php | 5 ++++- src/Query/GetPuzzlesOverview.php | 7 +++++-- src/Query/GetRanking.php | 8 ++++++-- src/Query/GetRecentActivity.php | 11 ++++++++--- src/Query/GetSellSwapListItems.php | 10 ++++++---- src/Query/GetSoldSwappedHistory.php | 6 ++++-- src/Query/GetTransactionRatings.php | 6 ++++-- src/Query/GetUnsolvedPuzzles.php | 10 ++++++---- src/Query/GetWishListItems.php | 6 ++++-- src/Query/SearchPuzzle.php | 16 ++++++++++----- 30 files changed, 193 insertions(+), 90 deletions(-) diff --git a/src/Query/GetBorrowedPuzzles.php b/src/Query/GetBorrowedPuzzles.php index 46fcb9d4..7b6e0d17 100644 --- a/src/Query/GetBorrowedPuzzles.php +++ b/src/Query/GetBorrowedPuzzles.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\BorrowedPuzzleOverview; use SpeedPuzzling\Web\Results\UnsolvedPuzzleItem; @@ -13,6 +14,7 @@ { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -31,7 +33,7 @@ public function byHolderId(string $holderId): array p.name as puzzle_name, p.alternative_name as puzzle_alternative_name, p.pieces_count, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS image, m.name as manufacturer_name, owner.id as owner_id, owner.name as owner_name, @@ -46,7 +48,7 @@ public function byHolderId(string $holderId): array SQL; $data = $this->database - ->executeQuery($query, ['holderId' => $holderId]) + ->executeQuery($query, ['holderId' => $holderId, 'now' => $this->clock->now()->format('Y-m-d H:i:s')]) ->fetchAllAssociative(); return array_map(static function (array $row): BorrowedPuzzleOverview { @@ -130,7 +132,7 @@ public function unsolvedByHolderId(string $holderId): array p.identification_number as puzzle_identification_number, p.ean, p.pieces_count, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS image, m.name as manufacturer_name, lp.lent_at as added_at, lp.owner_name as owner_text_name, @@ -151,7 +153,7 @@ public function unsolvedByHolderId(string $holderId): array SQL; $data = $this->database - ->executeQuery($query, ['holderId' => $holderId]) + ->executeQuery($query, ['holderId' => $holderId, 'now' => $this->clock->now()->format('Y-m-d H:i:s')]) ->fetchAllAssociative(); return array_map(static function (array $row): UnsolvedPuzzleItem { diff --git a/src/Query/GetCollectionItems.php b/src/Query/GetCollectionItems.php index d4c3bf26..9a126e49 100644 --- a/src/Query/GetCollectionItems.php +++ b/src/Query/GetCollectionItems.php @@ -6,12 +6,14 @@ use DateTimeImmutable; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\CollectionItemOverview; readonly final class GetCollectionItems { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -24,7 +26,7 @@ public function byCollectionAndPlayer(null|string $collectionId, string $playerI ? 'ci.collection_id IS NULL' : 'ci.collection_id = :collectionId'; - $params = ['playerId' => $playerId]; + $params = ['playerId' => $playerId, 'now' => $this->clock->now()->format('Y-m-d H:i:s')]; if ($collectionId !== null) { $params['collectionId'] = $collectionId; } @@ -40,7 +42,7 @@ public function byCollectionAndPlayer(null|string $collectionId, string $playerI p.identification_number as puzzle_identification_number, p.ean, p.pieces_count, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS image, m.name as manufacturer_name FROM collection_item ci JOIN puzzle p ON ci.puzzle_id = p.id @@ -115,7 +117,7 @@ public function getByPuzzleIdAndPlayerId(string $puzzleId, string $playerId, nul ? 'ci.collection_id IS NULL' : 'ci.collection_id = :collectionId'; - $params = ['playerId' => $playerId, 'puzzleId' => $puzzleId]; + $params = ['playerId' => $playerId, 'puzzleId' => $puzzleId, 'now' => $this->clock->now()->format('Y-m-d H:i:s')]; if ($collectionId !== null) { $params['collectionId'] = $collectionId; } @@ -131,7 +133,7 @@ public function getByPuzzleIdAndPlayerId(string $puzzleId, string $playerId, nul p.identification_number as puzzle_identification_number, p.ean, p.pieces_count, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS image, m.name as manufacturer_name FROM collection_item ci JOIN puzzle p ON ci.puzzle_id = p.id diff --git a/src/Query/GetCompetitionParticipants.php b/src/Query/GetCompetitionParticipants.php index 9ace891f..78ebbaeb 100644 --- a/src/Query/GetCompetitionParticipants.php +++ b/src/Query/GetCompetitionParticipants.php @@ -6,6 +6,7 @@ use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\ConnectedCompetitionParticipant; use SpeedPuzzling\Web\Results\NotConnectedCompetitionParticipant; use SpeedPuzzling\Web\Results\CompetitionParticipantInfo; @@ -15,6 +16,7 @@ { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -138,15 +140,15 @@ public function getConnectedParticipants(string $competitionId, array $roundsFil SELECT puzzle_solving_time.player_id, AVG(CASE - WHEN puzzle_solving_time.finished_at >= NOW() - INTERVAL '3 months' + WHEN puzzle_solving_time.finished_at >= :now::timestamp - INTERVAL '3 months' THEN puzzle_solving_time.seconds_to_solve END) AS average_time, MIN(CASE - WHEN puzzle_solving_time.finished_at >= NOW() - INTERVAL '3 months' + WHEN puzzle_solving_time.finished_at >= :now::timestamp - INTERVAL '3 months' THEN puzzle_solving_time.seconds_to_solve END) AS fastest_time, COUNT(CASE - WHEN puzzle_solving_time.finished_at >= NOW() - INTERVAL '3 months' + WHEN puzzle_solving_time.finished_at >= :now::timestamp - INTERVAL '3 months' THEN puzzle_solving_time.seconds_to_solve END) AS solved_puzzle_count FROM @@ -172,6 +174,7 @@ public function getConnectedParticipants(string $competitionId, array $roundsFil $times = $this->database ->executeQuery($query2, [ 'playerIds' => $playerIds, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ], [ 'playerIds' => ArrayParameterType::STRING, ]) diff --git a/src/Query/GetConversations.php b/src/Query/GetConversations.php index 956b61c5..b4ed8fd2 100644 --- a/src/Query/GetConversations.php +++ b/src/Query/GetConversations.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\ConversationOverview; use SpeedPuzzling\Web\Value\ConversationStatus; @@ -13,6 +14,7 @@ { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -22,7 +24,7 @@ public function __construct( public function forPlayer(string $playerId, null|ConversationStatus $status = null): array { $statusFilter = ''; - $params = ['playerId' => $playerId]; + $params = ['playerId' => $playerId, 'now' => $this->clock->now()->format('Y-m-d H:i:s')]; if ($status !== null) { $statusFilter = 'AND c.status = :status'; @@ -85,7 +87,7 @@ public function forPlayer(string $playerId, null|ConversationStatus $status = nu -- Puzzle context p.id AS puzzle_id, p.name AS puzzle_name, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS puzzle_image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS puzzle_image, sli.listing_type AS listing_type, sli.price AS listing_price FROM conversation c @@ -172,7 +174,7 @@ public function pendingRequestsForPlayer(string $playerId): array ip.country AS other_player_country, p.id AS puzzle_id, p.name AS puzzle_name, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS puzzle_image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS puzzle_image, sli.listing_type AS listing_type, sli.price AS listing_price FROM conversation c @@ -193,6 +195,7 @@ public function pendingRequestsForPlayer(string $playerId): array ->executeQuery($query, [ 'playerId' => $playerId, 'status' => ConversationStatus::Pending->value, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAllAssociative(); @@ -277,7 +280,7 @@ public function ignoredForPlayer(string $playerId): array ) AS last_message_system_type, p.id AS puzzle_id, p.name AS puzzle_name, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS puzzle_image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS puzzle_image, sli.listing_type AS listing_type, sli.price AS listing_price FROM conversation c @@ -298,6 +301,7 @@ public function ignoredForPlayer(string $playerId): array ->executeQuery($query, [ 'playerId' => $playerId, 'status' => ConversationStatus::Ignored->value, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAllAssociative(); diff --git a/src/Query/GetFastestGroups.php b/src/Query/GetFastestGroups.php index 98b1c976..357c41cb 100644 --- a/src/Query/GetFastestGroups.php +++ b/src/Query/GetFastestGroups.php @@ -5,6 +5,7 @@ namespace SpeedPuzzling\Web\Query; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\SolvedPuzzle; use SpeedPuzzling\Web\Value\CountryCode; use SpeedPuzzling\Web\Value\SkillTier; @@ -13,6 +14,7 @@ { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -38,7 +40,7 @@ public function perPiecesCount(int $piecesCount, int $howManyPlayers, null|Count puzzle.id AS puzzle_id, puzzle.name AS puzzle_name, puzzle.alternative_name AS puzzle_alternative_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, puzzle.pieces_count, pst.comment, pst.tracked_at, @@ -109,6 +111,7 @@ public function perPiecesCount(int $piecesCount, int $howManyPlayers, null|Count 'piecesCount' => $piecesCount, 'countryCode' => $countryCode?->name, 'howManyPlayers' => $howManyPlayers, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAllAssociative(); diff --git a/src/Query/GetFastestPairs.php b/src/Query/GetFastestPairs.php index 4832ed66..16532a1f 100644 --- a/src/Query/GetFastestPairs.php +++ b/src/Query/GetFastestPairs.php @@ -5,6 +5,7 @@ namespace SpeedPuzzling\Web\Query; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\SolvedPuzzle; use SpeedPuzzling\Web\Value\CountryCode; use SpeedPuzzling\Web\Value\SkillTier; @@ -13,6 +14,7 @@ { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -38,7 +40,7 @@ public function perPiecesCount(int $piecesCount, int $howManyPlayers, null|Count puzzle.id AS puzzle_id, puzzle.name AS puzzle_name, puzzle.alternative_name AS puzzle_alternative_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, puzzle.pieces_count, pst.comment, pst.tracked_at, @@ -109,6 +111,7 @@ public function perPiecesCount(int $piecesCount, int $howManyPlayers, null|Count 'piecesCount' => $piecesCount, 'countryCode' => $countryCode?->name, 'howManyPlayers' => $howManyPlayers, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAllAssociative(); diff --git a/src/Query/GetFastestPlayers.php b/src/Query/GetFastestPlayers.php index f81fb2d5..5faed110 100644 --- a/src/Query/GetFastestPlayers.php +++ b/src/Query/GetFastestPlayers.php @@ -5,6 +5,7 @@ namespace SpeedPuzzling\Web\Query; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\SolvedPuzzle; use SpeedPuzzling\Web\Value\CountryCode; use SpeedPuzzling\Web\Value\SkillTier; @@ -13,6 +14,7 @@ { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -54,7 +56,7 @@ public function perPiecesCount(int $piecesCount, int $limit, null|CountryCode $c puzzle.id AS puzzle_id, puzzle.name AS puzzle_name, puzzle.alternative_name AS puzzle_alternative_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, puzzle.pieces_count, puzzle_solving_time.comment, puzzle_solving_time.tracked_at, @@ -94,6 +96,7 @@ public function perPiecesCount(int $piecesCount, int $limit, null|CountryCode $c 'piecesCount' => $piecesCount, 'limit' => $limit, 'countryCode' => $countryCode?->name, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAllAssociative(); diff --git a/src/Query/GetLastSolvedPuzzle.php b/src/Query/GetLastSolvedPuzzle.php index f4b1e741..7a539168 100644 --- a/src/Query/GetLastSolvedPuzzle.php +++ b/src/Query/GetLastSolvedPuzzle.php @@ -5,6 +5,7 @@ namespace SpeedPuzzling\Web\Query; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use Ramsey\Uuid\Uuid; use SpeedPuzzling\Web\Exceptions\PlayerNotFound; use SpeedPuzzling\Web\Results\SolvedPuzzle; @@ -13,6 +14,7 @@ { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -32,7 +34,7 @@ public function forPlayer(string $playerId, int $limit): array puzzle.id AS puzzle_id, puzzle.name AS puzzle_name, puzzle.alternative_name AS puzzle_alternative_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, puzzle_solving_time.seconds_to_solve AS time, puzzle_solving_time.player_id AS player_id, player.name AS player_name, @@ -79,6 +81,7 @@ public function forPlayer(string $playerId, int $limit): array ->executeQuery($query, [ 'limit' => $limit, 'playerId' => $playerId, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAllAssociative(); @@ -129,7 +132,7 @@ public function limit(int $limit): array puzzle.id AS puzzle_id, puzzle.name AS puzzle_name, puzzle.alternative_name AS puzzle_alternative_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, puzzle_solving_time.seconds_to_solve AS time, puzzle_solving_time.player_id AS player_id, player.name AS player_name, @@ -174,6 +177,7 @@ public function limit(int $limit): array $data = $this->database ->executeQuery($query, [ 'limit' => $limit, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAllAssociative(); @@ -248,7 +252,7 @@ public function ofPlayerFavorites(int $limit, string $playerId): array puzzle.id AS puzzle_id, puzzle.name AS puzzle_name, puzzle.alternative_name AS puzzle_alternative_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, pst.seconds_to_solve AS time, pst.player_id AS player_id, player.name AS player_name, @@ -295,6 +299,7 @@ public function ofPlayerFavorites(int $limit, string $playerId): array ->executeQuery($query, [ 'limit' => $limit, 'playerId' => $playerId, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAllAssociative(); diff --git a/src/Query/GetLendBorrowHistory.php b/src/Query/GetLendBorrowHistory.php index cd4ef1aa..6d633c84 100644 --- a/src/Query/GetLendBorrowHistory.php +++ b/src/Query/GetLendBorrowHistory.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\LendBorrowHistoryItem; use SpeedPuzzling\Web\Value\TransferType; @@ -13,6 +14,7 @@ { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -39,7 +41,7 @@ public function byPlayerId(string $playerId): array p.name as puzzle_name, p.alternative_name as puzzle_alternative_name, p.pieces_count, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS image, m.name as manufacturer_name, lpt.lent_puzzle_id IS NOT NULL as is_active FROM lent_puzzle_transfer lpt @@ -55,7 +57,7 @@ public function byPlayerId(string $playerId): array SQL; $data = $this->database - ->executeQuery($query, ['playerId' => $playerId]) + ->executeQuery($query, ['playerId' => $playerId, 'now' => $this->clock->now()->format('Y-m-d H:i:s')]) ->fetchAllAssociative(); return array_map(static function (array $row): LendBorrowHistoryItem { diff --git a/src/Query/GetLentPuzzles.php b/src/Query/GetLentPuzzles.php index 78f587ed..9e63dd95 100644 --- a/src/Query/GetLentPuzzles.php +++ b/src/Query/GetLentPuzzles.php @@ -6,12 +6,14 @@ use DateTimeImmutable; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\LentPuzzleOverview; readonly final class GetLentPuzzles { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -30,7 +32,7 @@ public function byOwnerId(string $ownerId): array p.name as puzzle_name, p.alternative_name as puzzle_alternative_name, p.pieces_count, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS image, m.name as manufacturer_name, holder.id as current_holder_id, holder.name as current_holder_name, @@ -44,7 +46,7 @@ public function byOwnerId(string $ownerId): array SQL; $data = $this->database - ->executeQuery($query, ['ownerId' => $ownerId]) + ->executeQuery($query, ['ownerId' => $ownerId, 'now' => $this->clock->now()->format('Y-m-d H:i:s')]) ->fetchAllAssociative(); return array_map(static function (array $row): LentPuzzleOverview { @@ -124,7 +126,7 @@ public function getByPuzzleIdAndOwnerId(string $puzzleId, string $ownerId): null p.name as puzzle_name, p.alternative_name as puzzle_alternative_name, p.pieces_count, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS image, m.name as manufacturer_name, holder.id as current_holder_id, holder.name as current_holder_name, @@ -153,7 +155,7 @@ public function getByPuzzleIdAndOwnerId(string $puzzleId, string $ownerId): null * }|false $row */ $row = $this->database - ->executeQuery($query, ['ownerId' => $ownerId, 'puzzleId' => $puzzleId]) + ->executeQuery($query, ['ownerId' => $ownerId, 'puzzleId' => $puzzleId, 'now' => $this->clock->now()->format('Y-m-d H:i:s')]) ->fetchAssociative(); if ($row === false) { diff --git a/src/Query/GetMarketplaceListings.php b/src/Query/GetMarketplaceListings.php index a46c1f53..b2c25ef4 100644 --- a/src/Query/GetMarketplaceListings.php +++ b/src/Query/GetMarketplaceListings.php @@ -5,6 +5,7 @@ namespace SpeedPuzzling\Web\Query; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\MarketplaceListingItem; use SpeedPuzzling\Web\Value\ListingType; use SpeedPuzzling\Web\Value\PuzzleCondition; @@ -13,6 +14,7 @@ { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -45,7 +47,7 @@ public function search( p.name AS puzzle_name, p.alternative_name AS puzzle_alternative_name, p.pieces_count, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS puzzle_image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS puzzle_image, m.name AS manufacturer_name, ssli.listing_type, ssli.price, @@ -214,6 +216,7 @@ public function search( LIMIT :limit OFFSET :offset'; $params['limit'] = $limit; $params['offset'] = $offset; + $params['now'] = $this->clock->now()->format('Y-m-d H:i:s'); $data = $this->database ->executeQuery($query, $params) @@ -302,7 +305,7 @@ public function byItemId(string $itemId): MarketplaceListingItem p.name AS puzzle_name, p.alternative_name AS puzzle_alternative_name, p.pieces_count, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS puzzle_image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS puzzle_image, m.name AS manufacturer_name, ssli.listing_type, ssli.price, @@ -329,7 +332,7 @@ public function byItemId(string $itemId): MarketplaceListingItem SQL; $row = $this->database - ->executeQuery($query, ['itemId' => $itemId]) + ->executeQuery($query, ['itemId' => $itemId, 'now' => $this->clock->now()->format('Y-m-d H:i:s')]) ->fetchAssociative(); if ($row === false) { diff --git a/src/Query/GetModerationActions.php b/src/Query/GetModerationActions.php index 30e39e04..0de70bc2 100644 --- a/src/Query/GetModerationActions.php +++ b/src/Query/GetModerationActions.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\ModerationActionView; use SpeedPuzzling\Web\Value\ModerationActionType; @@ -13,6 +14,7 @@ { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -62,7 +64,7 @@ public function activeMute(string $playerId): null|ModerationActionView JOIN player ap ON ma.admin_id = ap.id WHERE ma.target_player_id = :playerId AND ma.action_type = :actionType - AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) + AND (ma.expires_at IS NULL OR ma.expires_at > :now::timestamp) ORDER BY ma.performed_at DESC LIMIT 1 SQL; @@ -71,6 +73,7 @@ public function activeMute(string $playerId): null|ModerationActionView ->executeQuery($query, [ 'playerId' => $playerId, 'actionType' => ModerationActionType::TemporaryMute->value, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAssociative(); diff --git a/src/Query/GetMostSolvedPuzzles.php b/src/Query/GetMostSolvedPuzzles.php index 77744649..ad3103ec 100644 --- a/src/Query/GetMostSolvedPuzzles.php +++ b/src/Query/GetMostSolvedPuzzles.php @@ -5,12 +5,14 @@ namespace SpeedPuzzling\Web\Query; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\MostSolvedPuzzle; readonly final class GetMostSolvedPuzzles { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -22,7 +24,7 @@ public function top(int $howManyPuzzles): array $query = << NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, puzzle.name AS puzzle_name, puzzle.alternative_name AS puzzle_alternative_name, puzzle_statistics.solved_times_count AS solved_times, @@ -41,6 +43,7 @@ public function top(int $howManyPuzzles): array $data = $this->database ->executeQuery($query, [ 'howManyPuzzles' => $howManyPuzzles, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAllAssociative(); @@ -76,7 +79,7 @@ public function topInMonth(int $limit, int $month, int $year): array $query = << NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, puzzle.name AS puzzle_name, puzzle.alternative_name AS puzzle_alternative_name, count(puzzle_solving_time.puzzle_id) AS solved_times, @@ -99,6 +102,7 @@ public function topInMonth(int $limit, int $month, int $year): array 'howManyPuzzles' => $limit, 'startDate' => $startDate, 'endDate' => $endDate, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAllAssociative(); diff --git a/src/Query/GetNotifications.php b/src/Query/GetNotifications.php index caf3166f..b188fb7c 100644 --- a/src/Query/GetNotifications.php +++ b/src/Query/GetNotifications.php @@ -5,12 +5,14 @@ namespace SpeedPuzzling\Web\Query; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\PlayerNotification; readonly final class GetNotifications { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -81,7 +83,7 @@ public function forPlayer(string $playerId, int $limit, int $offset = 0): array puzzle_solving_time.seconds_to_solve AS time, puzzle_solving_time.first_attempt, puzzle_solving_time.unboxed, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, puzzle_solving_time.team ->> 'team_id' AS team_id, CASE WHEN puzzle_solving_time.team IS NOT NULL THEN JSON_AGG( @@ -183,7 +185,7 @@ public function forPlayer(string $playerId, int $limit, int $offset = 0): array COALESCE(owner_player.name, owner_player.code, lpt.owner_name, lp.owner_name) AS owner_player_name, puzzle.id AS lending_puzzle_id, puzzle.name AS lending_puzzle_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS lending_puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS lending_puzzle_image, manufacturer.name AS lending_manufacturer_name, puzzle.pieces_count AS lending_pieces_count, -- Puzzle report fields (NULL for lending notifications) @@ -266,7 +268,7 @@ public function forPlayer(string $playerId, int $limit, int $offset = 0): array pcr.id AS change_request_id, puzzle.id AS change_request_puzzle_id, puzzle.name AS change_request_puzzle_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS change_request_puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS change_request_puzzle_image, pcr.rejection_reason AS change_request_rejection_reason, NULL::uuid AS merge_request_id, NULL::uuid AS merge_request_puzzle_id, @@ -342,7 +344,7 @@ public function forPlayer(string $playerId, int $limit, int $offset = 0): array pmr.id AS merge_request_id, source_puzzle.id AS merge_request_puzzle_id, source_puzzle.name AS merge_request_puzzle_name, - CASE WHEN source_puzzle.hide_image_until IS NOT NULL AND source_puzzle.hide_image_until > NOW() THEN NULL ELSE source_puzzle.image END AS merge_request_puzzle_image, + CASE WHEN source_puzzle.hide_image_until IS NOT NULL AND source_puzzle.hide_image_until > :now::timestamp THEN NULL ELSE source_puzzle.image END AS merge_request_puzzle_image, pmr.rejection_reason AS merge_request_rejection_reason, -- Rating notification fields (NULL for merge request notifications) NULL::uuid AS sold_swapped_item_id, @@ -418,7 +420,7 @@ public function forPlayer(string $playerId, int $limit, int $offset = 0): array -- Rating notification fields ssi.id AS sold_swapped_item_id, puzzle.name AS rating_puzzle_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS rating_puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS rating_puzzle_image, CASE WHEN ssi.seller_id = notification.player_id THEN COALESCE(buyer.name, buyer.code) ELSE COALESCE(seller.name, seller.code) @@ -507,7 +509,7 @@ public function forPlayer(string $playerId, int $limit, int $offset = 0): array initiator.avatar AS conversation_initiator_avatar, (conv.sell_swap_list_item_id IS NOT NULL) AS conversation_is_marketplace, conv_puzzle.name AS conversation_puzzle_name, - CASE WHEN conv_puzzle.hide_image_until IS NOT NULL AND conv_puzzle.hide_image_until > NOW() THEN NULL ELSE conv_puzzle.image END AS conversation_puzzle_image + CASE WHEN conv_puzzle.hide_image_until IS NOT NULL AND conv_puzzle.hide_image_until > :now::timestamp THEN NULL ELSE conv_puzzle.image END AS conversation_puzzle_image FROM notification INNER JOIN conversation conv ON notification.target_conversation_id = conv.id INNER JOIN player initiator ON conv.initiator_id = initiator.id @@ -525,6 +527,7 @@ public function forPlayer(string $playerId, int $limit, int $offset = 0): array 'playerId' => $playerId, 'limit' => $limit, 'offset' => $offset, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAllAssociative(); diff --git a/src/Query/GetPendingPuzzleProposals.php b/src/Query/GetPendingPuzzleProposals.php index 849afd33..0e381358 100644 --- a/src/Query/GetPendingPuzzleProposals.php +++ b/src/Query/GetPendingPuzzleProposals.php @@ -5,6 +5,7 @@ namespace SpeedPuzzling\Web\Query; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\MergePuzzleInfo; use SpeedPuzzling\Web\Results\PendingPuzzleProposal; @@ -12,6 +13,7 @@ { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -133,7 +135,7 @@ private function fetchPuzzleDetails(array $puzzleIds): array p.id, p.name, p.pieces_count, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > ?::timestamp THEN NULL ELSE p.image END AS image, m.name as manufacturer_name, (SELECT COUNT(*) FROM puzzle_solving_time pst WHERE pst.puzzle_id = p.id) as times_count FROM puzzle p @@ -141,7 +143,7 @@ private function fetchPuzzleDetails(array $puzzleIds): array WHERE p.id IN ({$placeholders}) SQL; - $rows = $this->database->fetchAllAssociative($query, array_values($puzzleIds)); + $rows = $this->database->fetchAllAssociative($query, [...array_values($puzzleIds), $this->clock->now()->format('Y-m-d H:i:s')]); $result = []; foreach ($rows as $row) { diff --git a/src/Query/GetPlayerSolvedPuzzles.php b/src/Query/GetPlayerSolvedPuzzles.php index 5cb2bed6..dc1944b5 100644 --- a/src/Query/GetPlayerSolvedPuzzles.php +++ b/src/Query/GetPlayerSolvedPuzzles.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use Ramsey\Uuid\Uuid; use SpeedPuzzling\Web\Exceptions\PlayerNotFound; use SpeedPuzzling\Web\Exceptions\PuzzleSolvingTimeNotFound; @@ -18,6 +19,7 @@ public function __construct( private Connection $database, private GetTeamPlayers $getTeamPlayers, + private ClockInterface $clock, ) { } @@ -67,7 +69,7 @@ public function byTimeId(string $timeId): SolvedPuzzleDetail puzzle_solving_time.team ->> 'team_id' AS team_id, puzzle.name AS puzzle_name, puzzle.alternative_name AS puzzle_alternative_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, puzzle_solving_time.seconds_to_solve AS time, puzzle_solving_time.player_id AS player_id, pieces_count, @@ -129,6 +131,7 @@ public function byTimeId(string $timeId): SolvedPuzzleDetail $row = $this->database ->executeQuery($query, [ 'timeId' => $timeId, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAssociative(); @@ -170,7 +173,7 @@ public function soloByPlayerId( puzzle.id AS puzzle_id, puzzle.name AS puzzle_name, puzzle.alternative_name AS puzzle_alternative_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, puzzle_solving_time.seconds_to_solve AS time, puzzle_solving_time.player_id AS player_id, pieces_count, @@ -235,6 +238,7 @@ public function soloByPlayerId( 'playerId' => $playerId, 'dateFrom' => $dateFrom?->format('Y-m-d H:i:s'), 'dateTo' => $dateTo?->format('Y-m-d H:i:s'), + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAllAssociative(); @@ -299,7 +303,7 @@ public function soloByPlayerIdAndPuzzleId(string $playerId, string $puzzleId): a puzzle.id AS puzzle_id, puzzle.name AS puzzle_name, puzzle.alternative_name AS puzzle_alternative_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, puzzle_solving_time.seconds_to_solve AS time, puzzle_solving_time.player_id AS player_id, pieces_count, @@ -337,6 +341,7 @@ public function soloByPlayerIdAndPuzzleId(string $playerId, string $puzzleId): a ->executeQuery($query, [ 'playerId' => $playerId, 'puzzleId' => $puzzleId, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAllAssociative(); @@ -431,7 +436,7 @@ public function duoByPlayerId( puzzle.id AS puzzle_id, puzzle.name AS puzzle_name, puzzle.alternative_name AS puzzle_alternative_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, pst.seconds_to_solve AS time, pst.player_id AS player_id, pieces_count, @@ -462,6 +467,7 @@ public function duoByPlayerId( 'playerId' => $playerId, 'dateFrom' => $dateFrom?->format('Y-m-d H:i:s'), 'dateTo' => $dateTo?->format('Y-m-d H:i:s'), + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAllAssociative(); @@ -568,7 +574,7 @@ public function teamByPlayerId( puzzle.id AS puzzle_id, puzzle.name AS puzzle_name, puzzle.alternative_name AS puzzle_alternative_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, pst.seconds_to_solve AS time, pst.player_id AS player_id, pieces_count, @@ -599,6 +605,7 @@ public function teamByPlayerId( 'playerId' => $playerId, 'dateFrom' => $dateFrom?->format('Y-m-d H:i:s'), 'dateTo' => $dateTo?->format('Y-m-d H:i:s'), + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAllAssociative(); @@ -696,7 +703,7 @@ public function byPuzzleIdAndPlayerId(string $puzzleId, string $playerId): null| puzzle.ean AS ean, puzzle.pieces_count, manufacturer.name AS manufacturer_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS image, puzzle_solving_time.finished_at FROM puzzle_solving_time INNER JOIN puzzle ON puzzle.id = puzzle_solving_time.puzzle_id @@ -714,6 +721,7 @@ public function byPuzzleIdAndPlayerId(string $puzzleId, string $playerId): null| ->executeQuery($query, [ 'puzzleId' => $puzzleId, 'playerId' => $playerId, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAssociative(); @@ -769,7 +777,7 @@ public function allByPlayerId(string $playerId): array puzzle.ean AS ean, puzzle.pieces_count, manufacturer.name AS manufacturer_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS image, puzzle_solving_time.finished_at FROM puzzle_solving_time INNER JOIN puzzle ON puzzle.id = puzzle_solving_time.puzzle_id @@ -783,6 +791,7 @@ public function allByPlayerId(string $playerId): array $data = $this->database ->executeQuery($query, [ 'playerId' => $playerId, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAllAssociative(); diff --git a/src/Query/GetPlayersWithUnreadMessages.php b/src/Query/GetPlayersWithUnreadMessages.php index f94d2c20..3d96af16 100644 --- a/src/Query/GetPlayersWithUnreadMessages.php +++ b/src/Query/GetPlayersWithUnreadMessages.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\PendingRequestNotification; use SpeedPuzzling\Web\Results\UnreadMessageNotification; use SpeedPuzzling\Web\Results\UnreadMessageSummary; @@ -25,6 +26,7 @@ public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -51,7 +53,7 @@ public function findPlayersToNotify(int $limit): array AND c.status = 'accepted' AND cm.sender_id != p.id AND cm.read_at IS NULL - AND cm.sent_at < NOW() - {$frequencyInterval} + AND cm.sent_at < :now::timestamp - {$frequencyInterval} AND NOT EXISTS ( SELECT 1 FROM user_block ub WHERE ub.blocker_id = p.id @@ -60,7 +62,7 @@ public function findPlayersToNotify(int $limit): array AND NOT EXISTS ( SELECT 1 FROM digest_email_log del WHERE del.player_id = p.id - AND del.sent_at > NOW() - {$frequencyInterval} + AND del.sent_at > :now::timestamp - {$frequencyInterval} ) GROUP BY p.id, p.email, p.name, p.locale HAVING MIN(cm.sent_at) > COALESCE( @@ -73,7 +75,9 @@ public function findPlayersToNotify(int $limit): array SQL; $rows = $this->database - ->executeQuery($query) + ->executeQuery($query, [ + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), + ]) ->fetchAllAssociative(); return array_map(static function (array $row): UnreadMessageNotification { @@ -118,7 +122,7 @@ public function findPlayersWithPendingRequestsToNotify(int $limit): array WHERE p.email IS NOT NULL AND p.email_notifications_enabled = true AND c.status = 'pending' - AND c.created_at < NOW() - {$frequencyInterval} + AND c.created_at < :now::timestamp - {$frequencyInterval} AND NOT EXISTS ( SELECT 1 FROM user_block ub WHERE ub.blocker_id = p.id @@ -127,7 +131,7 @@ public function findPlayersWithPendingRequestsToNotify(int $limit): array AND NOT EXISTS ( SELECT 1 FROM digest_email_log del WHERE del.player_id = p.id - AND del.sent_at > NOW() - {$frequencyInterval} + AND del.sent_at > :now::timestamp - {$frequencyInterval} ) GROUP BY p.id, p.email, p.name, p.locale HAVING MIN(c.created_at) > COALESCE( @@ -140,7 +144,9 @@ public function findPlayersWithPendingRequestsToNotify(int $limit): array SQL; $rows = $this->database - ->executeQuery($query) + ->executeQuery($query, [ + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), + ]) ->fetchAllAssociative(); return array_map(static function (array $row): PendingRequestNotification { diff --git a/src/Query/GetPuzzleChangeRequests.php b/src/Query/GetPuzzleChangeRequests.php index 801ccc68..ca59c021 100644 --- a/src/Query/GetPuzzleChangeRequests.php +++ b/src/Query/GetPuzzleChangeRequests.php @@ -5,6 +5,7 @@ namespace SpeedPuzzling\Web\Query; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\PuzzleChangeRequestOverview; use SpeedPuzzling\Web\Value\PuzzleReportStatus; @@ -12,6 +13,7 @@ { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -94,7 +96,7 @@ public function byId(string $id): null|PuzzleChangeRequestOverview p.id as puzzle_id, p.name as puzzle_name, p.pieces_count as puzzle_pieces_count, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS puzzle_image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS puzzle_image, pm.name as puzzle_manufacturer_name, reporter.id as reporter_id, reporter.name as reporter_name, @@ -117,6 +119,7 @@ public function byId(string $id): null|PuzzleChangeRequestOverview $row = $this->database->fetchAssociative($query, [ 'id' => $id, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]); if ($row === false) { @@ -151,7 +154,7 @@ private function byStatus(PuzzleReportStatus $status, string $orderBy): array p.id as puzzle_id, p.name as puzzle_name, p.pieces_count as puzzle_pieces_count, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS puzzle_image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS puzzle_image, pm.name as puzzle_manufacturer_name, reporter.id as reporter_id, reporter.name as reporter_name, @@ -175,6 +178,7 @@ private function byStatus(PuzzleReportStatus $status, string $orderBy): array $rows = $this->database->fetchAllAssociative($query, [ 'status' => $status->value, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]); return array_map( diff --git a/src/Query/GetPuzzleMergeRequests.php b/src/Query/GetPuzzleMergeRequests.php index 81e37f18..ddb8e63f 100644 --- a/src/Query/GetPuzzleMergeRequests.php +++ b/src/Query/GetPuzzleMergeRequests.php @@ -5,6 +5,7 @@ namespace SpeedPuzzling\Web\Query; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\PuzzleMergeRequestOverview; use SpeedPuzzling\Web\Value\PuzzleReportStatus; @@ -12,6 +13,7 @@ { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -88,11 +90,11 @@ public function byId(string $id): null|PuzzleMergeRequestOverview source_p.id as source_puzzle_id, source_p.name as source_puzzle_name, source_p.pieces_count as source_puzzle_pieces_count, - CASE WHEN source_p.hide_image_until IS NOT NULL AND source_p.hide_image_until > NOW() THEN NULL ELSE source_p.image END AS source_puzzle_image, + CASE WHEN source_p.hide_image_until IS NOT NULL AND source_p.hide_image_until > :now::timestamp THEN NULL ELSE source_p.image END AS source_puzzle_image, source_m.name as source_puzzle_manufacturer_name, survivor_p.name as survivor_puzzle_name, survivor_p.pieces_count as survivor_puzzle_pieces_count, - CASE WHEN survivor_p.hide_image_until IS NOT NULL AND survivor_p.hide_image_until > NOW() THEN NULL ELSE survivor_p.image END AS survivor_puzzle_image, + CASE WHEN survivor_p.hide_image_until IS NOT NULL AND survivor_p.hide_image_until > :now::timestamp THEN NULL ELSE survivor_p.image END AS survivor_puzzle_image, survivor_m.name as survivor_puzzle_manufacturer_name, reporter.id as reporter_id, reporter.name as reporter_name, @@ -111,6 +113,7 @@ public function byId(string $id): null|PuzzleMergeRequestOverview $row = $this->database->fetchAssociative($query, [ 'id' => $id, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]); if ($row === false) { @@ -139,11 +142,11 @@ private function byStatus(PuzzleReportStatus $status, string $orderBy): array source_p.id as source_puzzle_id, source_p.name as source_puzzle_name, source_p.pieces_count as source_puzzle_pieces_count, - CASE WHEN source_p.hide_image_until IS NOT NULL AND source_p.hide_image_until > NOW() THEN NULL ELSE source_p.image END AS source_puzzle_image, + CASE WHEN source_p.hide_image_until IS NOT NULL AND source_p.hide_image_until > :now::timestamp THEN NULL ELSE source_p.image END AS source_puzzle_image, source_m.name as source_puzzle_manufacturer_name, survivor_p.name as survivor_puzzle_name, survivor_p.pieces_count as survivor_puzzle_pieces_count, - CASE WHEN survivor_p.hide_image_until IS NOT NULL AND survivor_p.hide_image_until > NOW() THEN NULL ELSE survivor_p.image END AS survivor_puzzle_image, + CASE WHEN survivor_p.hide_image_until IS NOT NULL AND survivor_p.hide_image_until > :now::timestamp THEN NULL ELSE survivor_p.image END AS survivor_puzzle_image, survivor_m.name as survivor_puzzle_manufacturer_name, reporter.id as reporter_id, reporter.name as reporter_name, @@ -163,6 +166,7 @@ private function byStatus(PuzzleReportStatus $status, string $orderBy): array $rows = $this->database->fetchAllAssociative($query, [ 'status' => $status->value, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]); return array_map( diff --git a/src/Query/GetPuzzleOverview.php b/src/Query/GetPuzzleOverview.php index a3a92e37..d79db69d 100644 --- a/src/Query/GetPuzzleOverview.php +++ b/src/Query/GetPuzzleOverview.php @@ -5,6 +5,7 @@ namespace SpeedPuzzling\Web\Query; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use Ramsey\Uuid\Uuid; use SpeedPuzzling\Web\Exceptions\PuzzleNotFound; use SpeedPuzzling\Web\Exceptions\TagNotFound; @@ -14,6 +15,7 @@ { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -33,7 +35,7 @@ public function byEan(string $ean): PuzzleOverview SELECT puzzle.id AS puzzle_id, puzzle.name AS puzzle_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, puzzle.hide_image_until, puzzle.alternative_name AS puzzle_alternative_name, puzzle.pieces_count, @@ -82,6 +84,7 @@ public function byEan(string $ean): PuzzleOverview $row = $this->database ->executeQuery($query, [ 'ean' => '%' . $ean, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAssociative(); @@ -105,7 +108,7 @@ public function byId(string $puzzleId): PuzzleOverview SELECT puzzle.id AS puzzle_id, puzzle.name AS puzzle_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, puzzle.hide_image_until, puzzle.alternative_name AS puzzle_alternative_name, puzzle.pieces_count, @@ -154,6 +157,7 @@ public function byId(string $puzzleId): PuzzleOverview $row = $this->database ->executeQuery($query, [ 'puzzleId' => $puzzleId, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAssociative(); @@ -183,7 +187,7 @@ public function byTagId(string $tagId): array SELECT puzzle.id AS puzzle_id, puzzle.name AS puzzle_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, puzzle.hide_image_until, puzzle.alternative_name AS puzzle_alternative_name, puzzle.pieces_count, @@ -210,6 +214,7 @@ public function byTagId(string $tagId): array $data = $this->database ->executeQuery($query, [ 'tagId' => $tagId, + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), ]) ->fetchAllAssociative(); diff --git a/src/Query/GetPuzzleTracking.php b/src/Query/GetPuzzleTracking.php index 5e141755..265473f8 100644 --- a/src/Query/GetPuzzleTracking.php +++ b/src/Query/GetPuzzleTracking.php @@ -5,6 +5,7 @@ namespace SpeedPuzzling\Web\Query; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use Ramsey\Uuid\Uuid; use SpeedPuzzling\Web\Exceptions\PuzzleTrackingNotFound; use SpeedPuzzling\Web\Results\TrackedPuzzleDetail; @@ -13,6 +14,7 @@ { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -32,7 +34,7 @@ public function byId(string $trackingId): TrackedPuzzleDetail puzzle_tracking.team ->> 'team_id' AS team_id, puzzle.name AS puzzle_name, puzzle.alternative_name AS puzzle_alternative_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, puzzle_tracking.player_id AS player_id, pieces_count, player.name AS player_name, @@ -84,6 +86,7 @@ public function byId(string $trackingId): TrackedPuzzleDetail */ $row = $this->database ->executeQuery($query, [ + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), 'trackingId' => $trackingId, ]) ->fetchAssociative(); diff --git a/src/Query/GetPuzzlesOverview.php b/src/Query/GetPuzzlesOverview.php index 64b48013..d4141f69 100644 --- a/src/Query/GetPuzzlesOverview.php +++ b/src/Query/GetPuzzlesOverview.php @@ -5,12 +5,14 @@ namespace SpeedPuzzling\Web\Query; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\PuzzleOverview; readonly final class GetPuzzlesOverview { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -23,7 +25,7 @@ public function allApprovedOrAddedByPlayer(null|string $playerId): array SELECT puzzle.id AS puzzle_id, puzzle.name AS puzzle_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, puzzle.alternative_name AS puzzle_alternative_name, puzzle.pieces_count, puzzle.is_available, @@ -44,13 +46,14 @@ public function allApprovedOrAddedByPlayer(null|string $playerId): array LEFT JOIN puzzle_statistics ON puzzle_statistics.puzzle_id = puzzle.id INNER JOIN manufacturer ON puzzle.manufacturer_id = manufacturer.id WHERE - (puzzle.hide_until IS NULL OR puzzle.hide_until <= NOW()) + (puzzle.hide_until IS NULL OR puzzle.hide_until <= :now::timestamp) AND (puzzle.approved = true OR puzzle.added_by_user_id = :playerId) ORDER BY COALESCE(puzzle.alternative_name, puzzle.name) ASC, manufacturer_name ASC, pieces_count ASC SQL; $data = $this->database ->executeQuery($query, [ + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), 'playerId' => $playerId, ]) ->fetchAllAssociative(); diff --git a/src/Query/GetRanking.php b/src/Query/GetRanking.php index bc302106..3f3f1ea5 100644 --- a/src/Query/GetRanking.php +++ b/src/Query/GetRanking.php @@ -5,6 +5,7 @@ namespace SpeedPuzzling\Web\Query; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use Ramsey\Uuid\Uuid; use SpeedPuzzling\Web\Exceptions\PlayerNotFound; use SpeedPuzzling\Web\Exceptions\PuzzleNotFound; @@ -21,6 +22,7 @@ final class GetRanking implements ResetInterface public function __construct( private readonly Connection $database, + private readonly ClockInterface $clock, ) { } @@ -82,7 +84,7 @@ public function allForPlayer(string $playerId): array p.name AS puzzle_name, p.alternative_name AS puzzle_alternative_name, p.pieces_count, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS puzzle_image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS puzzle_image, m.name AS manufacturer_name FROM RankedTimes rt @@ -96,6 +98,7 @@ public function allForPlayer(string $playerId): array $data = $this->database ->executeQuery($query, [ + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), 'playerId' => $playerId, ]) ->fetchAllAssociative(); @@ -179,7 +182,7 @@ public function ofPuzzleForPlayer(string $puzzleId, string $playerId): null|Play puzzle.name AS puzzle_name, puzzle.alternative_name AS puzzle_alternative_name, puzzle.pieces_count, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, manufacturer.name AS manufacturer_name FROM RankedTimes @@ -206,6 +209,7 @@ public function ofPuzzleForPlayer(string $puzzleId, string $playerId): null|Play */ $row = $this->database ->executeQuery($query, [ + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), 'playerId' => $playerId, 'puzzleId' => $puzzleId, ]) diff --git a/src/Query/GetRecentActivity.php b/src/Query/GetRecentActivity.php index c395bc73..c6038ef4 100644 --- a/src/Query/GetRecentActivity.php +++ b/src/Query/GetRecentActivity.php @@ -5,6 +5,7 @@ namespace SpeedPuzzling\Web\Query; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use Ramsey\Uuid\Uuid; use SpeedPuzzling\Web\Exceptions\PlayerNotFound; use SpeedPuzzling\Web\Results\RecentActivityItem; @@ -14,6 +15,7 @@ { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -33,7 +35,7 @@ public function forPlayer(string $playerId, int $limit): array puzzle.id AS puzzle_id, puzzle.name AS puzzle_name, puzzle.alternative_name AS puzzle_alternative_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, puzzle_solving_time.seconds_to_solve AS time, puzzle_solving_time.player_id AS player_id, player.name AS player_name, @@ -84,6 +86,7 @@ public function forPlayer(string $playerId, int $limit): array $data = $this->database ->executeQuery($query, [ + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), 'limit' => $limit, 'playerId' => $playerId, ]) @@ -142,7 +145,7 @@ public function latest(int $limit): array puzzle.id AS puzzle_id, puzzle.name AS puzzle_name, puzzle.alternative_name AS puzzle_alternative_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, puzzle_solving_time.seconds_to_solve AS time, puzzle_solving_time.player_id AS player_id, player.name AS player_name, @@ -192,6 +195,7 @@ public function latest(int $limit): array $data = $this->database ->executeQuery($query, [ + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), 'limit' => $limit, ]) ->fetchAllAssociative(); @@ -273,7 +277,7 @@ public function ofPlayerFavorites(int $limit, string $playerId): array puzzle.id AS puzzle_id, puzzle.name AS puzzle_name, puzzle.alternative_name AS puzzle_alternative_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, pst.seconds_to_solve AS time, pst.player_id AS player_id, player.name AS player_name, @@ -324,6 +328,7 @@ public function ofPlayerFavorites(int $limit, string $playerId): array $data = $this->database ->executeQuery($query, [ + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), 'limit' => $limit, 'playerId' => $playerId, ]) diff --git a/src/Query/GetSellSwapListItems.php b/src/Query/GetSellSwapListItems.php index ad519203..8f8ec040 100644 --- a/src/Query/GetSellSwapListItems.php +++ b/src/Query/GetSellSwapListItems.php @@ -7,6 +7,7 @@ use DateTimeImmutable; use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\SellSwapListItemOverview; use SpeedPuzzling\Web\Value\ListingType; use SpeedPuzzling\Web\Value\PuzzleCondition; @@ -15,6 +16,7 @@ { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -41,7 +43,7 @@ public function byPlayerId(string $playerId): array p.identification_number as puzzle_identification_number, p.ean, p.pieces_count, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS image, m.name as manufacturer_name FROM sell_swap_list_item ssli JOIN puzzle p ON ssli.puzzle_id = p.id @@ -52,7 +54,7 @@ public function byPlayerId(string $playerId): array SQL; $data = $this->database - ->executeQuery($query, ['playerId' => $playerId]) + ->executeQuery($query, ['now' => $this->clock->now()->format('Y-m-d H:i:s'), 'playerId' => $playerId]) ->fetchAllAssociative(); return array_map(static function (array $row): SellSwapListItemOverview { @@ -121,7 +123,7 @@ public function byItemId(string $itemId): SellSwapListItemOverview p.identification_number as puzzle_identification_number, p.ean, p.pieces_count, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS image, m.name as manufacturer_name FROM sell_swap_list_item ssli JOIN puzzle p ON ssli.puzzle_id = p.id @@ -131,7 +133,7 @@ public function byItemId(string $itemId): SellSwapListItemOverview SQL; $row = $this->database - ->executeQuery($query, ['itemId' => $itemId]) + ->executeQuery($query, ['now' => $this->clock->now()->format('Y-m-d H:i:s'), 'itemId' => $itemId]) ->fetchAssociative(); if ($row === false) { diff --git a/src/Query/GetSoldSwappedHistory.php b/src/Query/GetSoldSwappedHistory.php index d68d9c46..4096b9e6 100644 --- a/src/Query/GetSoldSwappedHistory.php +++ b/src/Query/GetSoldSwappedHistory.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\SoldSwappedItemOverview; use SpeedPuzzling\Web\Value\ListingType; @@ -13,6 +14,7 @@ { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -33,7 +35,7 @@ public function byPlayerId(string $playerId): array p.alternative_name as puzzle_alternative_name, p.identification_number as puzzle_identification_number, p.pieces_count, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS image, m.name as manufacturer_name, bp.id as buyer_player_id, bp.name as buyer_player_name, @@ -47,7 +49,7 @@ public function byPlayerId(string $playerId): array SQL; $data = $this->database - ->executeQuery($query, ['playerId' => $playerId]) + ->executeQuery($query, ['now' => $this->clock->now()->format('Y-m-d H:i:s'), 'playerId' => $playerId]) ->fetchAllAssociative(); return array_map(static function (array $row): SoldSwappedItemOverview { diff --git a/src/Query/GetTransactionRatings.php b/src/Query/GetTransactionRatings.php index d767b03e..b4adad70 100644 --- a/src/Query/GetTransactionRatings.php +++ b/src/Query/GetTransactionRatings.php @@ -39,7 +39,7 @@ public function forPlayer(string $playerId, int $limit = 20, int $offset = 0): a p.name AS puzzle_name, p.pieces_count AS puzzle_pieces_count, ssi.listing_type AS transaction_type, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS puzzle_image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS puzzle_image, p.id AS puzzle_id FROM transaction_rating tr JOIN player reviewer ON tr.reviewer_id = reviewer.id @@ -53,6 +53,7 @@ public function forPlayer(string $playerId, int $limit = 20, int $offset = 0): a $data = $this->database ->executeQuery($query, [ + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), 'playerId' => $playerId, 'limit' => $limit, 'offset' => $offset, @@ -158,7 +159,7 @@ public function pendingRatings(string $playerId): array SELECT ssi.id AS sold_swapped_item_id, p.name AS puzzle_name, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS puzzle_image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS puzzle_image, p.pieces_count, CASE WHEN ssi.seller_id = :playerId THEN COALESCE(buyer.name, buyer.code) @@ -191,6 +192,7 @@ public function pendingRatings(string $playerId): array $data = $this->database ->executeQuery($query, [ + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), 'playerId' => $playerId, 'cutoffDate' => $this->clock->now()->modify('-30 days')->format('Y-m-d H:i:s'), ]) diff --git a/src/Query/GetUnsolvedPuzzles.php b/src/Query/GetUnsolvedPuzzles.php index 5fa7018a..e7c0d307 100644 --- a/src/Query/GetUnsolvedPuzzles.php +++ b/src/Query/GetUnsolvedPuzzles.php @@ -6,12 +6,14 @@ use DateTimeImmutable; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\UnsolvedPuzzleItem; readonly final class GetUnsolvedPuzzles { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -28,7 +30,7 @@ public function byPlayerId(string $playerId): array p.identification_number as puzzle_identification_number, p.ean, p.pieces_count, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS image, m.name as manufacturer_name, MIN(ci.added_at) as added_at FROM collection_item ci @@ -51,7 +53,7 @@ public function byPlayerId(string $playerId): array SQL; $data = $this->database - ->executeQuery($query, ['playerId' => $playerId]) + ->executeQuery($query, ['now' => $this->clock->now()->format('Y-m-d H:i:s'), 'playerId' => $playerId]) ->fetchAllAssociative(); return array_map(static function (array $row): UnsolvedPuzzleItem { @@ -122,7 +124,7 @@ public function byPuzzleIdAndPlayerId(string $puzzleId, string $playerId): null| p.identification_number as puzzle_identification_number, p.ean, p.pieces_count, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS image, m.name as manufacturer_name, MIN(ci.added_at) as added_at FROM collection_item ci @@ -145,7 +147,7 @@ public function byPuzzleIdAndPlayerId(string $puzzleId, string $playerId): null| SQL; $data = $this->database - ->executeQuery($query, ['playerId' => $playerId, 'puzzleId' => $puzzleId]) + ->executeQuery($query, ['now' => $this->clock->now()->format('Y-m-d H:i:s'), 'playerId' => $playerId, 'puzzleId' => $puzzleId]) ->fetchAssociative(); if ($data === false) { diff --git a/src/Query/GetWishListItems.php b/src/Query/GetWishListItems.php index f52747cc..3f8a5662 100644 --- a/src/Query/GetWishListItems.php +++ b/src/Query/GetWishListItems.php @@ -6,12 +6,14 @@ use DateTimeImmutable; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Results\WishListItemOverview; readonly final class GetWishListItems { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -31,7 +33,7 @@ public function byPlayerId(string $playerId): array p.identification_number as puzzle_identification_number, p.ean, p.pieces_count, - CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > NOW() THEN NULL ELSE p.image END AS image, + CASE WHEN p.hide_image_until IS NOT NULL AND p.hide_image_until > :now::timestamp THEN NULL ELSE p.image END AS image, m.name as manufacturer_name FROM wish_list_item wli JOIN puzzle p ON wli.puzzle_id = p.id @@ -41,7 +43,7 @@ public function byPlayerId(string $playerId): array SQL; $data = $this->database - ->executeQuery($query, ['playerId' => $playerId]) + ->executeQuery($query, ['now' => $this->clock->now()->format('Y-m-d H:i:s'), 'playerId' => $playerId]) ->fetchAllAssociative(); return array_map(static function (array $row): WishListItemOverview { diff --git a/src/Query/SearchPuzzle.php b/src/Query/SearchPuzzle.php index 84f4829a..a6512232 100644 --- a/src/Query/SearchPuzzle.php +++ b/src/Query/SearchPuzzle.php @@ -6,6 +6,7 @@ use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; +use Psr\Clock\ClockInterface; use Ramsey\Uuid\Uuid; use SpeedPuzzling\Web\Exceptions\ManufacturerNotFound; use SpeedPuzzling\Web\Results\AutocompletePuzzle; @@ -16,6 +17,7 @@ { public function __construct( private Connection $database, + private ClockInterface $clock, ) { } @@ -51,7 +53,7 @@ public function countByUserInput( LEFT JOIN tag_puzzle ON tag_puzzle.puzzle_id = puzzle.id {$difficultyJoin} WHERE - (puzzle.hide_until IS NULL OR puzzle.hide_until <= NOW()) + (puzzle.hide_until IS NULL OR puzzle.hide_until <= :now::timestamp) AND (:brandId::uuid IS NULL OR manufacturer_id = :brandId) AND (:minPieces::int IS NULL OR pieces_count >= :minPieces) AND (:maxPieces::int IS NULL OR pieces_count <= :maxPieces) @@ -70,6 +72,7 @@ public function countByUserInput( $eanSearch = trim($search ?? '', '0'); $params = [ + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), 'searchFullLikeQuery' => "%$search%", 'eanSearchFullLikeQuery' => "%$eanSearch%", 'brandId' => $brandId, @@ -134,7 +137,7 @@ public function byUserInput( SELECT DISTINCT ON (puzzle.id) puzzle.id AS puzzle_id, puzzle.name AS puzzle_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, puzzle.alternative_name AS puzzle_alternative_name, puzzle.pieces_count, puzzle.is_available, @@ -172,7 +175,7 @@ public function byUserInput( LEFT JOIN tag_puzzle ON tag_puzzle.puzzle_id = puzzle.id {$difficultyJoin} WHERE - (puzzle.hide_until IS NULL OR puzzle.hide_until <= NOW()) + (puzzle.hide_until IS NULL OR puzzle.hide_until <= :now::timestamp) AND (:brandId::uuid IS NULL OR manufacturer_id = :brandId) AND (:minPieces::int IS NULL OR pieces_count >= :minPieces) AND (:maxPieces::int IS NULL OR pieces_count <= :maxPieces) @@ -241,6 +244,7 @@ public function byUserInput( $eanSearch = trim($search ?? '', '0'); $params = [ + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), 'searchQuery' => $search, 'searchStartLikeQuery' => "%$search", 'searchEndLikeQuery' => "$search%", @@ -319,7 +323,7 @@ public function allByEan(string $ean): array SELECT puzzle.id AS puzzle_id, puzzle.name AS puzzle_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, puzzle.ean AS puzzle_ean, puzzle.pieces_count, manufacturer.id AS manufacturer_id, @@ -332,6 +336,7 @@ public function allByEan(string $ean): array /** @var array $rows */ $rows = $this->database ->executeQuery($query, [ + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), 'eanPattern' => '%' . $eanSearch . '%', ]) ->fetchAllAssociative(); @@ -348,7 +353,7 @@ public function byBrandId(string $brandId): array SELECT puzzle.id AS puzzle_id, puzzle.name AS puzzle_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > NOW() THEN NULL ELSE puzzle.image END AS puzzle_image, + CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, puzzle.alternative_name AS puzzle_alternative_name, puzzle.pieces_count, puzzle.approved AS puzzle_approved, @@ -364,6 +369,7 @@ public function byBrandId(string $brandId): array $data = $this->database ->executeQuery($query, [ + 'now' => $this->clock->now()->format('Y-m-d H:i:s'), 'manufacturerId' => $brandId, ]) ->fetchAllAssociative(); From 31b31c5061414ecccb2ad7cc2990382c649d93ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Fri, 3 Apr 2026 15:00:13 +0200 Subject: [PATCH 5/8] Add competition rejection, email notifications, return navigation, and multi-locale routes - Add reject competition flow with reason (admin panel, handler, email notification) - Send email notifications on competition submit, approve, and reject - Add return_url/return_title navigation to all event links on /events page - Add return back button to add/edit competition and event detail pages - Add es/ja/fr/de locale routes for all competition management controllers - Filter rejected competitions from pending approvals list - Add rejection state display on edit competition page Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 1 + .../competitions-management/README.md | 150 ++++++++++++++++++ migrations/Version20260403124723.php | 39 +++++ src/Controller/AddCompetitionController.php | 4 + .../AddCompetitionRoundController.php | 4 + src/Controller/AddPuzzleToRoundController.php | 4 + .../Admin/RejectCompetitionController.php | 56 +++++++ .../DeleteCompetitionRoundController.php | 4 + src/Controller/EditCompetitionController.php | 4 + .../EditCompetitionRoundController.php | 4 + ...anageCompetitionParticipantsController.php | 4 + .../ManageCompetitionRoundsController.php | 4 + .../ManageRoundPuzzlesController.php | 4 + .../RemovePuzzleFromRoundController.php | 4 + src/Entity/Competition.php | 18 +++ src/Message/RejectCompetition.php | 15 ++ src/MessageHandler/AddCompetitionHandler.php | 30 ++++ .../ApproveCompetitionHandler.php | 36 +++++ .../RejectCompetitionHandler.php | 60 +++++++ src/Query/GetCompetitionEvents.php | 1 + src/Results/CompetitionEvent.php | 6 + templates/_competition_event.html.twig | 4 +- templates/add_competition.html.twig | 2 + .../admin/competition_approvals.html.twig | 25 +++ templates/edit_competition.html.twig | 9 +- .../emails/competition_approved.html.twig | 27 ++++ .../emails/competition_rejected.html.twig | 27 ++++ .../emails/competition_submitted.html.twig | 29 ++++ templates/event_detail.html.twig | 2 + templates/events.html.twig | 4 +- .../AddCompetitionControllerTest.php | 30 ++++ .../AddCompetitionRoundControllerTest.php | 41 +++++ .../AddPuzzleToRoundControllerTest.php | 41 +++++ .../ApproveCompetitionControllerTest.php | 19 +++ .../CompetitionApprovalsControllerTest.php | 18 +++ .../DeleteCompetitionRoundControllerTest.php | 31 ++++ .../EditCompetitionControllerTest.php | 41 +++++ .../EditCompetitionRoundControllerTest.php | 41 +++++ ...eCompetitionParticipantsControllerTest.php | 41 +++++ .../ManageCompetitionRoundsControllerTest.php | 41 +++++ .../ManageRoundPuzzlesControllerTest.php | 41 +++++ ...PlayerSearchAutocompleteControllerTest.php | 30 ++++ .../RemovePuzzleFromRoundControllerTest.php | 18 +++ .../ApproveCompetitionHandlerTest.php | 39 +++++ .../RejectCompetitionHandlerTest.php | 42 +++++ tests/Query/GetCompetitionEventsTest.php | 47 ++++++ translations/emails.en.yml | 23 +++ translations/messages.en.yml | 4 + 48 files changed, 1164 insertions(+), 5 deletions(-) create mode 100644 docs/features/competitions-management/README.md create mode 100644 migrations/Version20260403124723.php create mode 100644 src/Controller/Admin/RejectCompetitionController.php create mode 100644 src/Message/RejectCompetition.php create mode 100644 src/MessageHandler/RejectCompetitionHandler.php create mode 100644 templates/emails/competition_approved.html.twig create mode 100644 templates/emails/competition_rejected.html.twig create mode 100644 templates/emails/competition_submitted.html.twig create mode 100644 tests/Controller/AddCompetitionControllerTest.php create mode 100644 tests/Controller/AddCompetitionRoundControllerTest.php create mode 100644 tests/Controller/AddPuzzleToRoundControllerTest.php create mode 100644 tests/Controller/Admin/ApproveCompetitionControllerTest.php create mode 100644 tests/Controller/Admin/CompetitionApprovalsControllerTest.php create mode 100644 tests/Controller/DeleteCompetitionRoundControllerTest.php create mode 100644 tests/Controller/EditCompetitionControllerTest.php create mode 100644 tests/Controller/EditCompetitionRoundControllerTest.php create mode 100644 tests/Controller/ManageCompetitionParticipantsControllerTest.php create mode 100644 tests/Controller/ManageCompetitionRoundsControllerTest.php create mode 100644 tests/Controller/ManageRoundPuzzlesControllerTest.php create mode 100644 tests/Controller/PlayerSearchAutocompleteControllerTest.php create mode 100644 tests/Controller/RemovePuzzleFromRoundControllerTest.php create mode 100644 tests/MessageHandler/ApproveCompetitionHandlerTest.php create mode 100644 tests/MessageHandler/RejectCompetitionHandlerTest.php create mode 100644 tests/Query/GetCompetitionEventsTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 87f1ea27..2a01ba87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,6 +138,7 @@ Feature design documents and implementation plans are in `docs/features/`. Each - **API & OAuth2**: `docs/features/api/` — Public REST API (V1), OAuth2 server, Swagger docs, internal APIs, deprecated V0 - **Stripe Payments**: `docs/features/stripe.md` — Stripe integration for premium membership - **Opt-Out Features**: `docs/features/opt-out.md` — Streak and ranking opt-out for players +- **Competitions Management**: `docs/features/competitions-management/` — Community-driven event creation with admin approval, round management, puzzle assignment, table layout planning, and live stopwatch ### Feature Flags Active feature flags are documented in `docs/features/feature_flags.md`. **Always read and update this file** when adding, modifying, or removing feature flags. It tracks which files are gated, what feature each flag belongs to, and when it can be removed. diff --git a/docs/features/competitions-management/README.md b/docs/features/competitions-management/README.md new file mode 100644 index 00000000..02f8736b --- /dev/null +++ b/docs/features/competitions-management/README.md @@ -0,0 +1,150 @@ +# Competitions Management + +Community-driven competition and event management. Any logged-in player can submit a competition; it becomes publicly visible after admin approval. Maintainers (the creator + named co-maintainers) can then manage rounds, assign puzzles, plan table layouts, and run a live stopwatch during the event. + +## Competition Lifecycle + +### 1. Submission + +Any authenticated player can submit a new competition with: +- **Required:** name, location +- **Optional:** shortcut (e.g. "WJPC"), description, website/registration/results links, country, date range, online flag, logo image +- **Maintainers:** other players who should have edit access (searchable autocomplete) + +A URL slug is auto-generated from the name (with a random suffix if collisions exist). The competition is stored with `approvedAt = null` (pending state) and is **not visible** in the public listing. + +### 2. Admin Review (Approve or Reject) + +Admins see all pending competitions in a dedicated approval queue (`/admin/competition-approvals`). + +- **Approve:** Sets `approvedAt`, makes the competition publicly visible. The creator receives an email notification with a link to the public event page. +- **Reject:** Admin must provide a reason. Sets `rejectedAt` and `rejectionReason`. The creator receives an email notification with the rejection reason. Rejected competitions are removed from the approval queue and remain invisible in public listings. The rejection reason is displayed on the edit page. + +An admin notification email is sent automatically when a new competition is submitted, linking to the approval queue. + +### 3. Editing + +Maintainers and admins can edit all competition fields. While unapproved, a warning banner is shown on the edit page. If rejected, a danger banner with the rejection reason is shown instead. The edit page provides navigation to round management and participant management. + +Changing the name regenerates the slug. Maintainer lists are fully replaced on each save (clear + re-add). + +### 4. Public Listing + +The events page shows three sections: **Live** (date range includes today), **Upcoming** (starts in the future), and **Past** (already ended). All sections only show approved competitions. External links (website, registration, results) automatically get `utm_source=myspeedpuzzling` appended. + +Each competition also appears in "My Competitions" for its creator/maintainers regardless of approval status. + +## Access Control + +| Action | Who | +|--------|-----| +| Browse public events listing | Everyone | +| Submit a new competition | Any authenticated player | +| Edit competition & manage rounds/tables/stopwatch | Admin, original creator, or named maintainer | +| View public stopwatch page | Everyone (no auth required) | +| Approve or reject a competition | Admin only | + +Access is enforced via a `CompetitionEditVoter` that checks whether the player is admin, the creator, or in the maintainers list. All management controllers use this same voter, including round-level controllers (which resolve the competition from the round). + +## Round Management + +A competition has multiple **rounds**, each with: +- **Name** and **start time** +- **Minutes limit** — the time limit for solving (drives the stopwatch countdown) +- **Badge colors** — optional background/text hex colors for visual distinction in round lists + +Rounds are displayed sorted by start time. Each round can be edited or deleted. The round list shows action buttons for: Puzzles, Tables (only for in-person events), Stopwatch, Edit, Delete. + +## Puzzle Assignment + +Puzzles are assigned to rounds via a `CompetitionRoundPuzzle` join. When adding a puzzle to a round: + +- **Existing puzzle:** select by UUID +- **New puzzle on the fly:** provide name, piece count, manufacturer (existing or new), optional photo, EAN, identification number. The new puzzle is created with `approved = false` + +### Hide Until Round Starts + +Each puzzle assignment has a `hideUntilRoundStarts` flag. When enabled, the puzzle's own `hideUntil` field is set to the round's start time, making it invisible in the general puzzle database until the round begins. This prevents spoiling which puzzles will be used. + +## Table Layout System + +For **in-person events only**, organizers can plan the physical seating layout. The hierarchy is: + +``` +Round + -> Table Rows (e.g. "Row 1", "Row 2") + -> Tables (e.g. "Table 1", "Table 2", numbered globally) + -> Spots (individual seats, assignable to players) +``` + +### Generation + +A form lets the organizer specify rows count (1-20), tables per row (1-20), and spots per table (1-10). Generating a layout **replaces the entire existing layout** for that round (destructive, no confirmation). + +### Manual Editing + +A Symfony Live Component provides real-time inline editing: +- Add/remove rows, tables, spots +- Assign a player to a spot via inline search (min 2 characters, up to 10 results) +- Assign a manual name (for participants not registered on the platform) +- Clear spot assignments +- Player and manual name are mutually exclusive on a spot + +### Print View + +A standalone, minimal HTML page (no base layout, print-optimized CSS) showing the full table grid. Empty spots show a blank line for handwriting. Opens in a new browser tab. + +## Round Stopwatch + +A real-time countdown/count-up timer for running competition rounds live. + +### Server State + +Each round tracks `stopwatchStartedAt` (UTC timestamp) and `stopwatchStatus` (`null` / `running` / `stopped`): + +- **Not started** (`null`): only "Start" available +- **Running**: only "Stop" available +- **Stopped**: "Start" (resume) and "Reset" available + +### Real-Time Sync via Mercure + +Every state change (start, stop, reset) publishes an SSE event on topic `/round-stopwatch/{roundId}`. All connected browsers receive the update instantly. + +### Client-Side Timer + +A Stimulus controller handles the display: +- Computes server/client clock offset on page load for accurate timing +- Uses `requestAnimationFrame` for smooth `HH:MM:SS` rendering +- When elapsed time reaches the round's `minutesLimit`, the display shows "Time's up" (client-side only, no server event) +- Subscribes to Mercure SSE for real-time start/stop/reset events + +### Two Views + +- **Public view** (`/en/round-stopwatch/{roundId}`): large timer display, accessible to everyone — useful for projecting at events +- **Management view** (`/en/manage-round-stopwatch/{roundId}`): shows status + control buttons, requires edit permission + +## Participant Management + +Currently a placeholder — the management page shows a "coming soon" message. The underlying database tables and queries exist from prior functionality but are not wired up in this feature yet. + +## Email Notifications + +Three email notifications are sent during the competition lifecycle: + +1. **New submission (to admin):** When a player submits a new competition, an email is sent to `jan.mikes@myspeedpuzzling.com` with the event name, location, submitter name, and a link to the admin approval queue. +2. **Approved (to creator):** When an admin approves a competition, the creator receives an email with a link to their public event page. Sent in the creator's locale. +3. **Rejected (to creator):** When an admin rejects a competition, the creator receives an email with the rejection reason. Sent in the creator's locale. + +All emails use the `transactional` mailer transport and follow the standard Inky email template structure. + +## Key Business Rules + +1. **Unapproved competitions are invisible** in public listings but accessible to their maintainers +2. **Table layout is only for in-person events** — the tables button is hidden when `isOnline = true` +3. **Layout generation is destructive** — it wipes the entire existing layout before creating a new grid +4. **`times_up` is client-only** — the server does not track when time expires; it's purely a display state +5. **New puzzles created via round assignment need separate approval** — they are created with `approved = false` +6. **Removing a puzzle from a round does not clear the puzzle's `hideUntil` field** +7. **External links get automatic UTM tracking** — `utm_source=myspeedpuzzling` is appended +8. **Rejected competitions are excluded from the approval queue** — they no longer appear as "pending" +9. **Email notifications require creator to have an email** — if the creator has no email on their profile, no notification is sent (no error) diff --git a/migrations/Version20260403124723.php b/migrations/Version20260403124723.php new file mode 100644 index 00000000..03327375 --- /dev/null +++ b/migrations/Version20260403124723.php @@ -0,0 +1,39 @@ +addSql('ALTER TABLE competition ADD rejected_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE competition ADD rejection_reason TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE competition ADD rejected_by_player_id UUID DEFAULT NULL'); + $this->addSql('ALTER TABLE competition ADD CONSTRAINT FK_B50A2CB1BC7FE91 FOREIGN KEY (rejected_by_player_id) REFERENCES player (id)'); + $this->addSql('CREATE INDEX IDX_B50A2CB1BC7FE91 ON competition (rejected_by_player_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE competition DROP CONSTRAINT FK_B50A2CB1BC7FE91'); + $this->addSql('DROP INDEX IDX_B50A2CB1BC7FE91'); + $this->addSql('ALTER TABLE competition DROP rejected_at'); + $this->addSql('ALTER TABLE competition DROP rejection_reason'); + $this->addSql('ALTER TABLE competition DROP rejected_by_player_id'); + } +} diff --git a/src/Controller/AddCompetitionController.php b/src/Controller/AddCompetitionController.php index 195c3482..f3475e0a 100644 --- a/src/Controller/AddCompetitionController.php +++ b/src/Controller/AddCompetitionController.php @@ -33,6 +33,10 @@ public function __construct( path: [ 'cs' => '/pridat-udalost', 'en' => '/en/add-event', + 'es' => '/es/add-event', + 'ja' => '/ja/add-event', + 'fr' => '/fr/add-event', + 'de' => '/de/add-event', ], name: 'add_competition', )] diff --git a/src/Controller/AddCompetitionRoundController.php b/src/Controller/AddCompetitionRoundController.php index 843647bf..ece39895 100644 --- a/src/Controller/AddCompetitionRoundController.php +++ b/src/Controller/AddCompetitionRoundController.php @@ -32,6 +32,10 @@ public function __construct( path: [ 'cs' => '/pridat-kolo-udalosti/{competitionId}', 'en' => '/en/add-event-round/{competitionId}', + 'es' => '/es/add-event-round/{competitionId}', + 'ja' => '/ja/add-event-round/{competitionId}', + 'fr' => '/fr/add-event-round/{competitionId}', + 'de' => '/de/add-event-round/{competitionId}', ], name: 'add_competition_round', )] diff --git a/src/Controller/AddPuzzleToRoundController.php b/src/Controller/AddPuzzleToRoundController.php index 24ca6d4b..aaf855f6 100644 --- a/src/Controller/AddPuzzleToRoundController.php +++ b/src/Controller/AddPuzzleToRoundController.php @@ -36,6 +36,10 @@ public function __construct( path: [ 'cs' => '/pridat-puzzle-do-kola/{roundId}', 'en' => '/en/add-puzzle-to-round/{roundId}', + 'es' => '/es/add-puzzle-to-round/{roundId}', + 'ja' => '/ja/add-puzzle-to-round/{roundId}', + 'fr' => '/fr/add-puzzle-to-round/{roundId}', + 'de' => '/de/add-puzzle-to-round/{roundId}', ], name: 'add_puzzle_to_round', )] diff --git a/src/Controller/Admin/RejectCompetitionController.php b/src/Controller/Admin/RejectCompetitionController.php new file mode 100644 index 00000000..c312c26b --- /dev/null +++ b/src/Controller/Admin/RejectCompetitionController.php @@ -0,0 +1,56 @@ +retrieveLoggedUserProfile->getProfile(); + assert($profile !== null); + + $reason = trim((string) $request->request->get('reason', '')); + + if ($reason === '') { + $this->addFlash('danger', $this->translator->trans('competition.flash.rejection_reason_required')); + + return $this->redirectToRoute('admin_competition_approvals'); + } + + $this->messageBus->dispatch(new RejectCompetition( + competitionId: $competitionId, + rejectedByPlayerId: $profile->playerId, + reason: $reason, + )); + + $this->addFlash('success', $this->translator->trans('competition.flash.rejected')); + + return $this->redirectToRoute('admin_competition_approvals'); + } +} diff --git a/src/Controller/DeleteCompetitionRoundController.php b/src/Controller/DeleteCompetitionRoundController.php index ce869f86..6db37693 100644 --- a/src/Controller/DeleteCompetitionRoundController.php +++ b/src/Controller/DeleteCompetitionRoundController.php @@ -28,6 +28,10 @@ public function __construct( path: [ 'cs' => '/smazat-kolo-udalosti/{roundId}', 'en' => '/en/delete-event-round/{roundId}', + 'es' => '/es/delete-event-round/{roundId}', + 'ja' => '/ja/delete-event-round/{roundId}', + 'fr' => '/fr/delete-event-round/{roundId}', + 'de' => '/de/delete-event-round/{roundId}', ], name: 'delete_competition_round', methods: ['POST'], diff --git a/src/Controller/EditCompetitionController.php b/src/Controller/EditCompetitionController.php index ff9b733e..968dc237 100644 --- a/src/Controller/EditCompetitionController.php +++ b/src/Controller/EditCompetitionController.php @@ -33,6 +33,10 @@ public function __construct( path: [ 'cs' => '/upravit-udalost/{competitionId}', 'en' => '/en/edit-event/{competitionId}', + 'es' => '/es/edit-event/{competitionId}', + 'ja' => '/ja/edit-event/{competitionId}', + 'fr' => '/fr/edit-event/{competitionId}', + 'de' => '/de/edit-event/{competitionId}', ], name: 'edit_competition', )] diff --git a/src/Controller/EditCompetitionRoundController.php b/src/Controller/EditCompetitionRoundController.php index c9d43ce4..bba92b3c 100644 --- a/src/Controller/EditCompetitionRoundController.php +++ b/src/Controller/EditCompetitionRoundController.php @@ -33,6 +33,10 @@ public function __construct( path: [ 'cs' => '/upravit-kolo-udalosti/{roundId}', 'en' => '/en/edit-event-round/{roundId}', + 'es' => '/es/edit-event-round/{roundId}', + 'ja' => '/ja/edit-event-round/{roundId}', + 'fr' => '/fr/edit-event-round/{roundId}', + 'de' => '/de/edit-event-round/{roundId}', ], name: 'edit_competition_round', )] diff --git a/src/Controller/ManageCompetitionParticipantsController.php b/src/Controller/ManageCompetitionParticipantsController.php index 908bf925..286bf36c 100644 --- a/src/Controller/ManageCompetitionParticipantsController.php +++ b/src/Controller/ManageCompetitionParticipantsController.php @@ -23,6 +23,10 @@ public function __construct( path: [ 'cs' => '/sprava-ucastniku-udalosti/{competitionId}', 'en' => '/en/manage-event-participants/{competitionId}', + 'es' => '/es/manage-event-participants/{competitionId}', + 'ja' => '/ja/manage-event-participants/{competitionId}', + 'fr' => '/fr/manage-event-participants/{competitionId}', + 'de' => '/de/manage-event-participants/{competitionId}', ], name: 'manage_competition_participants', )] diff --git a/src/Controller/ManageCompetitionRoundsController.php b/src/Controller/ManageCompetitionRoundsController.php index 1f093aa5..c6221fc7 100644 --- a/src/Controller/ManageCompetitionRoundsController.php +++ b/src/Controller/ManageCompetitionRoundsController.php @@ -25,6 +25,10 @@ public function __construct( path: [ 'cs' => '/sprava-kol-udalosti/{competitionId}', 'en' => '/en/manage-event-rounds/{competitionId}', + 'es' => '/es/manage-event-rounds/{competitionId}', + 'ja' => '/ja/manage-event-rounds/{competitionId}', + 'fr' => '/fr/manage-event-rounds/{competitionId}', + 'de' => '/de/manage-event-rounds/{competitionId}', ], name: 'manage_competition_rounds', )] diff --git a/src/Controller/ManageRoundPuzzlesController.php b/src/Controller/ManageRoundPuzzlesController.php index 3f35307e..7a0b7df6 100644 --- a/src/Controller/ManageRoundPuzzlesController.php +++ b/src/Controller/ManageRoundPuzzlesController.php @@ -27,6 +27,10 @@ public function __construct( path: [ 'cs' => '/sprava-puzzli-kola/{roundId}', 'en' => '/en/manage-round-puzzles/{roundId}', + 'es' => '/es/manage-round-puzzles/{roundId}', + 'ja' => '/ja/manage-round-puzzles/{roundId}', + 'fr' => '/fr/manage-round-puzzles/{roundId}', + 'de' => '/de/manage-round-puzzles/{roundId}', ], name: 'manage_round_puzzles', )] diff --git a/src/Controller/RemovePuzzleFromRoundController.php b/src/Controller/RemovePuzzleFromRoundController.php index b0a1b441..123a3f53 100644 --- a/src/Controller/RemovePuzzleFromRoundController.php +++ b/src/Controller/RemovePuzzleFromRoundController.php @@ -28,6 +28,10 @@ public function __construct( path: [ 'cs' => '/odebrat-puzzle-z-kola/{roundPuzzleId}', 'en' => '/en/remove-puzzle-from-round/{roundPuzzleId}', + 'es' => '/es/remove-puzzle-from-round/{roundPuzzleId}', + 'ja' => '/ja/remove-puzzle-from-round/{roundPuzzleId}', + 'fr' => '/fr/remove-puzzle-from-round/{roundPuzzleId}', + 'de' => '/de/remove-puzzle-from-round/{roundPuzzleId}', ], name: 'remove_puzzle_from_round', methods: ['POST'], diff --git a/src/Entity/Competition.php b/src/Entity/Competition.php index 8f2a67b7..7231c359 100644 --- a/src/Entity/Competition.php +++ b/src/Entity/Competition.php @@ -64,6 +64,12 @@ public function __construct( public null|DateTimeImmutable $approvedAt = null, #[ManyToOne] public null|Player $approvedByPlayer = null, + #[Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + public null|DateTimeImmutable $rejectedAt = null, + #[ManyToOne] + public null|Player $rejectedByPlayer = null, + #[Column(type: Types::TEXT, nullable: true)] + public null|string $rejectionReason = null, #[Immutable] #[Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] public null|DateTimeImmutable $createdAt = null, @@ -82,11 +88,23 @@ public function approve(Player $approvedBy, DateTimeImmutable $approvedAt): void $this->approvedByPlayer = $approvedBy; } + public function reject(Player $rejectedBy, DateTimeImmutable $rejectedAt, string $reason): void + { + $this->rejectedAt = $rejectedAt; + $this->rejectedByPlayer = $rejectedBy; + $this->rejectionReason = $reason; + } + public function isApproved(): bool { return $this->approvedAt !== null; } + public function isRejected(): bool + { + return $this->rejectedAt !== null; + } + public function edit( string $name, null|string $slug, diff --git a/src/Message/RejectCompetition.php b/src/Message/RejectCompetition.php new file mode 100644 index 00000000..4cbcc996 --- /dev/null +++ b/src/Message/RejectCompetition.php @@ -0,0 +1,15 @@ +entityManager->persist($competition); + $this->entityManager->flush(); + + $adminUrl = $this->urlGenerator->generate('admin_competition_approvals', [], UrlGeneratorInterface::ABSOLUTE_URL); + + $subject = $this->translator->trans( + 'competition_submitted.subject', + ['%competitionName%' => $message->name], + domain: 'emails', + ); + + $email = (new TemplatedEmail()) + ->to('jan.mikes@myspeedpuzzling.com') + ->subject($subject) + ->htmlTemplate('emails/competition_submitted.html.twig') + ->context([ + 'playerName' => $player->name ?? 'Unknown', + 'competitionName' => $message->name, + 'location' => $message->location, + 'adminUrl' => $adminUrl, + ]); + $email->getHeaders()->addTextHeader('X-Transport', 'transactional'); + + $this->mailer->send($email); } private function generateUniqueSlug(string $name): string diff --git a/src/MessageHandler/ApproveCompetitionHandler.php b/src/MessageHandler/ApproveCompetitionHandler.php index 8ffc89e2..2ff5cd0a 100644 --- a/src/MessageHandler/ApproveCompetitionHandler.php +++ b/src/MessageHandler/ApproveCompetitionHandler.php @@ -8,7 +8,11 @@ use SpeedPuzzling\Web\Message\ApproveCompetition; use SpeedPuzzling\Web\Repository\CompetitionRepository; use SpeedPuzzling\Web\Repository\PlayerRepository; +use Symfony\Bridge\Twig\Mime\TemplatedEmail; +use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Contracts\Translation\TranslatorInterface; #[AsMessageHandler] readonly final class ApproveCompetitionHandler @@ -17,6 +21,9 @@ public function __construct( private CompetitionRepository $competitionRepository, private PlayerRepository $playerRepository, private ClockInterface $clock, + private MailerInterface $mailer, + private UrlGeneratorInterface $urlGenerator, + private TranslatorInterface $translator, ) { } @@ -26,5 +33,34 @@ public function __invoke(ApproveCompetition $message): void $approvedBy = $this->playerRepository->get($message->approvedByPlayerId); $competition->approve($approvedBy, $this->clock->now()); + + $creator = $competition->addedByPlayer; + + if ($creator?->email !== null) { + $playerLocale = $creator->locale ?? 'en'; + + $eventUrl = $this->urlGenerator->generate('event_detail', [ + 'slug' => $competition->slug, + ], UrlGeneratorInterface::ABSOLUTE_URL); + + $subject = $this->translator->trans( + 'competition_approved.subject', + domain: 'emails', + locale: $playerLocale, + ); + + $email = (new TemplatedEmail()) + ->to($creator->email) + ->locale($playerLocale) + ->subject($subject) + ->htmlTemplate('emails/competition_approved.html.twig') + ->context([ + 'competitionName' => $competition->name, + 'eventUrl' => $eventUrl, + ]); + $email->getHeaders()->addTextHeader('X-Transport', 'transactional'); + + $this->mailer->send($email); + } } } diff --git a/src/MessageHandler/RejectCompetitionHandler.php b/src/MessageHandler/RejectCompetitionHandler.php new file mode 100644 index 00000000..c0c02221 --- /dev/null +++ b/src/MessageHandler/RejectCompetitionHandler.php @@ -0,0 +1,60 @@ +competitionRepository->get($message->competitionId); + $rejectedBy = $this->playerRepository->get($message->rejectedByPlayerId); + + $competition->reject($rejectedBy, $this->clock->now(), $message->reason); + + $creator = $competition->addedByPlayer; + + if ($creator?->email !== null) { + $playerLocale = $creator->locale ?? 'en'; + + $subject = $this->translator->trans( + 'competition_rejected.subject', + domain: 'emails', + locale: $playerLocale, + ); + + $email = (new TemplatedEmail()) + ->to($creator->email) + ->locale($playerLocale) + ->subject($subject) + ->htmlTemplate('emails/competition_rejected.html.twig') + ->context([ + 'competitionName' => $competition->name, + 'reason' => $message->reason, + ]); + $email->getHeaders()->addTextHeader('X-Transport', 'transactional'); + + $this->mailer->send($email); + } + } +} diff --git a/src/Query/GetCompetitionEvents.php b/src/Query/GetCompetitionEvents.php index 7957bc8a..a274a06d 100644 --- a/src/Query/GetCompetitionEvents.php +++ b/src/Query/GetCompetitionEvents.php @@ -153,6 +153,7 @@ public function allUnapproved(): array SELECT * FROM competition WHERE approved_at IS NULL + AND rejected_at IS NULL ORDER BY created_at DESC; SQL; diff --git a/src/Results/CompetitionEvent.php b/src/Results/CompetitionEvent.php index 5f7af8df..ba122b26 100644 --- a/src/Results/CompetitionEvent.php +++ b/src/Results/CompetitionEvent.php @@ -26,6 +26,8 @@ * is_online: bool|string, * added_by_player_id: null|string, * approved_at: null|string, + * rejected_at: null|string, + * rejection_reason: null|string, * created_at: null|string, * } */ @@ -53,6 +55,8 @@ public function __construct( public bool $isOnline, public null|string $addedByPlayerId, public null|DateTimeImmutable $approvedAt, + public null|DateTimeImmutable $rejectedAt, + public null|string $rejectionReason, public null|DateTimeImmutable $createdAt, ) { $this->link = $this->appendUtm($link); @@ -88,6 +92,8 @@ public static function fromDatabaseRow(array $row): self isOnline: $isOnline, addedByPlayerId: $row['added_by_player_id'], approvedAt: $row['approved_at'] !== null ? new DateTimeImmutable($row['approved_at']) : null, + rejectedAt: $row['rejected_at'] !== null ? new DateTimeImmutable($row['rejected_at']) : null, + rejectionReason: $row['rejection_reason'], createdAt: $row['created_at'] !== null ? new DateTimeImmutable($row['created_at']) : null, ); } diff --git a/templates/_competition_event.html.twig b/templates/_competition_event.html.twig index 2f6631f9..f47329a0 100644 --- a/templates/_competition_event.html.twig +++ b/templates/_competition_event.html.twig @@ -1,7 +1,7 @@
    {% if is_granted('COMPETITION_EDIT', event.id) %} - + {% endif %} @@ -9,7 +9,7 @@

    {% if event.slug and event.tagId %} - + {{ event.name }} {% else %} diff --git a/templates/add_competition.html.twig b/templates/add_competition.html.twig index e346526c..21abfe56 100644 --- a/templates/add_competition.html.twig +++ b/templates/add_competition.html.twig @@ -3,6 +3,8 @@ {% block title %}{{ 'competition.add_event'|trans }}{% endblock %} {% block content %} + {{ include('_return_back_button.html.twig', {fallbackUrl: path('events'), fallbackTitle: 'events.title'|trans}) }} +
    diff --git a/templates/admin/competition_approvals.html.twig b/templates/admin/competition_approvals.html.twig index 57f38d16..2e8217b1 100644 --- a/templates/admin/competition_approvals.html.twig +++ b/templates/admin/competition_approvals.html.twig @@ -13,6 +13,13 @@
    {% endfor %} + {% for flash_message in app.flashes('danger') %} +
    + + {{ flash_message }} +
    + {% endfor %} + {% if competitions|length > 0 %}
    @@ -60,6 +67,24 @@ Approve + + + + + {% endfor %} diff --git a/templates/edit_competition.html.twig b/templates/edit_competition.html.twig index 6e370421..be18997a 100644 --- a/templates/edit_competition.html.twig +++ b/templates/edit_competition.html.twig @@ -3,12 +3,19 @@ {% block title %}{{ 'competition.edit_event'|trans }} - {{ competition.name }}{% endblock %} {% block content %} + {{ include('_return_back_button.html.twig', {fallbackUrl: path('events'), fallbackTitle: 'events.title'|trans}) }} +

    {{ 'competition.edit_event'|trans }}: {{ competition.name }}

    - {% if competition.approvedAt is null %} + {% if competition.rejectedAt is not null %} +
    + {{ 'competition.rejected_info'|trans }} +
    {{ 'competition.rejection_reason'|trans }}: {{ competition.rejectionReason }} +
    + {% elseif competition.approvedAt is null %}
    {{ 'competition.pending_approval_info'|trans }}
    diff --git a/templates/emails/competition_approved.html.twig b/templates/emails/competition_approved.html.twig new file mode 100644 index 00000000..500f3c10 --- /dev/null +++ b/templates/emails/competition_approved.html.twig @@ -0,0 +1,27 @@ +{% trans_default_domain 'emails' %} +{% apply inky_to_html|inline_css(source('@styles/foundation-emails.css')) %} + + + {{ include('emails/_header.html.twig') }} + + + + +

    {{ 'competition_approved.title'|trans }}

    + +
    +
    + + + + {{ 'competition_approved.content'|trans({ + '%competitionName%': competitionName, + '%eventUrl%': eventUrl, + })|raw }} + + + + {{ include('emails/_footer.html.twig') }} +
    +
    +{% endapply %} diff --git a/templates/emails/competition_rejected.html.twig b/templates/emails/competition_rejected.html.twig new file mode 100644 index 00000000..a89e830d --- /dev/null +++ b/templates/emails/competition_rejected.html.twig @@ -0,0 +1,27 @@ +{% trans_default_domain 'emails' %} +{% apply inky_to_html|inline_css(source('@styles/foundation-emails.css')) %} + + + {{ include('emails/_header.html.twig') }} + + + + +

    {{ 'competition_rejected.title'|trans }}

    + +
    +
    + + + + {{ 'competition_rejected.content'|trans({ + '%competitionName%': competitionName, + '%reason%': reason, + })|raw }} + + + + {{ include('emails/_footer.html.twig') }} +
    +
    +{% endapply %} diff --git a/templates/emails/competition_submitted.html.twig b/templates/emails/competition_submitted.html.twig new file mode 100644 index 00000000..80c0ea5d --- /dev/null +++ b/templates/emails/competition_submitted.html.twig @@ -0,0 +1,29 @@ +{% trans_default_domain 'emails' %} +{% apply inky_to_html|inline_css(source('@styles/foundation-emails.css')) %} + + + {{ include('emails/_header.html.twig') }} + + + + +

    {{ 'competition_submitted.title'|trans }}

    + +
    +
    + + + + {{ 'competition_submitted.content'|trans({ + '%playerName%': playerName, + '%competitionName%': competitionName, + '%location%': location, + '%adminUrl%': adminUrl, + })|raw }} + + + + {{ include('emails/_footer.html.twig') }} +
    +
    +{% endapply %} diff --git a/templates/event_detail.html.twig b/templates/event_detail.html.twig index 8c742b94..d6dbaef3 100644 --- a/templates/event_detail.html.twig +++ b/templates/event_detail.html.twig @@ -3,6 +3,8 @@ {% block title %}{{ event.name }}{% endblock %} {% block content %} + {{ include('_return_back_button.html.twig', {fallbackUrl: path('events'), fallbackTitle: 'events.title'|trans}) }} +

    diff --git a/templates/events.html.twig b/templates/events.html.twig index 3201395a..098b32bd 100644 --- a/templates/events.html.twig +++ b/templates/events.html.twig @@ -28,7 +28,7 @@

    {{ 'events.title'|trans }}

    {% if is_granted('IS_AUTHENTICATED_FULLY') %} - + {{ 'competition.add_event'|trans }} {% endif %} @@ -56,7 +56,7 @@

    diff --git a/tests/Controller/AddCompetitionControllerTest.php b/tests/Controller/AddCompetitionControllerTest.php new file mode 100644 index 00000000..c20f13b6 --- /dev/null +++ b/tests/Controller/AddCompetitionControllerTest.php @@ -0,0 +1,30 @@ +request('GET', '/en/add-event'); + + $this->assertResponseRedirects(); + } + + public function testLoggedInUserCanAccessPage(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', '/en/add-event'); + + $this->assertResponseIsSuccessful(); + } +} diff --git a/tests/Controller/AddCompetitionRoundControllerTest.php b/tests/Controller/AddCompetitionRoundControllerTest.php new file mode 100644 index 00000000..2053b1ed --- /dev/null +++ b/tests/Controller/AddCompetitionRoundControllerTest.php @@ -0,0 +1,41 @@ +request('GET', '/en/add-event-round/' . CompetitionFixture::COMPETITION_UNAPPROVED); + + $this->assertResponseRedirects(); + } + + public function testMaintainerCanAccessPage(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', '/en/add-event-round/' . CompetitionFixture::COMPETITION_UNAPPROVED); + + $this->assertResponseIsSuccessful(); + } + + public function testNonMaintainerDenied(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', '/en/add-event-round/' . CompetitionFixture::COMPETITION_WJPC_2024); + + $this->assertResponseStatusCodeSame(403); + } +} diff --git a/tests/Controller/AddPuzzleToRoundControllerTest.php b/tests/Controller/AddPuzzleToRoundControllerTest.php new file mode 100644 index 00000000..a6c5c6a9 --- /dev/null +++ b/tests/Controller/AddPuzzleToRoundControllerTest.php @@ -0,0 +1,41 @@ +request('GET', '/en/add-puzzle-to-round/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseRedirects(); + } + + public function testAdminCanAccessPage(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_ADMIN); + + $browser->request('GET', '/en/add-puzzle-to-round/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseIsSuccessful(); + } + + public function testNonMaintainerDenied(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', '/en/add-puzzle-to-round/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseStatusCodeSame(403); + } +} diff --git a/tests/Controller/Admin/ApproveCompetitionControllerTest.php b/tests/Controller/Admin/ApproveCompetitionControllerTest.php new file mode 100644 index 00000000..3de56a96 --- /dev/null +++ b/tests/Controller/Admin/ApproveCompetitionControllerTest.php @@ -0,0 +1,19 @@ +request('POST', '/admin/competitions/' . CompetitionFixture::COMPETITION_UNAPPROVED . '/approve'); + + $this->assertResponseRedirects('/login'); + } +} diff --git a/tests/Controller/Admin/CompetitionApprovalsControllerTest.php b/tests/Controller/Admin/CompetitionApprovalsControllerTest.php new file mode 100644 index 00000000..fa36b283 --- /dev/null +++ b/tests/Controller/Admin/CompetitionApprovalsControllerTest.php @@ -0,0 +1,18 @@ +request('GET', '/admin/competition-approvals'); + + $this->assertResponseRedirects('/login'); + } +} diff --git a/tests/Controller/DeleteCompetitionRoundControllerTest.php b/tests/Controller/DeleteCompetitionRoundControllerTest.php new file mode 100644 index 00000000..8cd57607 --- /dev/null +++ b/tests/Controller/DeleteCompetitionRoundControllerTest.php @@ -0,0 +1,31 @@ +request('POST', '/en/delete-event-round/' . CompetitionRoundFixture::ROUND_CZECH_FINAL); + + $this->assertResponseRedirects(); + } + + public function testNonMaintainerDenied(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('POST', '/en/delete-event-round/' . CompetitionRoundFixture::ROUND_CZECH_FINAL); + + $this->assertResponseStatusCodeSame(403); + } +} diff --git a/tests/Controller/EditCompetitionControllerTest.php b/tests/Controller/EditCompetitionControllerTest.php new file mode 100644 index 00000000..31768049 --- /dev/null +++ b/tests/Controller/EditCompetitionControllerTest.php @@ -0,0 +1,41 @@ +request('GET', '/en/edit-event/' . CompetitionFixture::COMPETITION_UNAPPROVED); + + $this->assertResponseRedirects(); + } + + public function testMaintainerCanAccessPage(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', '/en/edit-event/' . CompetitionFixture::COMPETITION_UNAPPROVED); + + $this->assertResponseIsSuccessful(); + } + + public function testNonMaintainerDenied(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', '/en/edit-event/' . CompetitionFixture::COMPETITION_WJPC_2024); + + $this->assertResponseStatusCodeSame(403); + } +} diff --git a/tests/Controller/EditCompetitionRoundControllerTest.php b/tests/Controller/EditCompetitionRoundControllerTest.php new file mode 100644 index 00000000..f40f77bc --- /dev/null +++ b/tests/Controller/EditCompetitionRoundControllerTest.php @@ -0,0 +1,41 @@ +request('GET', '/en/edit-event-round/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseRedirects(); + } + + public function testAdminCanAccessPage(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_ADMIN); + + $browser->request('GET', '/en/edit-event-round/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseIsSuccessful(); + } + + public function testNonMaintainerDenied(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', '/en/edit-event-round/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseStatusCodeSame(403); + } +} diff --git a/tests/Controller/ManageCompetitionParticipantsControllerTest.php b/tests/Controller/ManageCompetitionParticipantsControllerTest.php new file mode 100644 index 00000000..4c70314e --- /dev/null +++ b/tests/Controller/ManageCompetitionParticipantsControllerTest.php @@ -0,0 +1,41 @@ +request('GET', '/en/manage-event-participants/' . CompetitionFixture::COMPETITION_UNAPPROVED); + + $this->assertResponseRedirects(); + } + + public function testMaintainerCanAccessPage(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', '/en/manage-event-participants/' . CompetitionFixture::COMPETITION_UNAPPROVED); + + $this->assertResponseIsSuccessful(); + } + + public function testNonMaintainerDenied(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', '/en/manage-event-participants/' . CompetitionFixture::COMPETITION_WJPC_2024); + + $this->assertResponseStatusCodeSame(403); + } +} diff --git a/tests/Controller/ManageCompetitionRoundsControllerTest.php b/tests/Controller/ManageCompetitionRoundsControllerTest.php new file mode 100644 index 00000000..2dcdf1bc --- /dev/null +++ b/tests/Controller/ManageCompetitionRoundsControllerTest.php @@ -0,0 +1,41 @@ +request('GET', '/en/manage-event-rounds/' . CompetitionFixture::COMPETITION_UNAPPROVED); + + $this->assertResponseRedirects(); + } + + public function testMaintainerCanAccessPage(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', '/en/manage-event-rounds/' . CompetitionFixture::COMPETITION_UNAPPROVED); + + $this->assertResponseIsSuccessful(); + } + + public function testNonMaintainerDenied(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', '/en/manage-event-rounds/' . CompetitionFixture::COMPETITION_WJPC_2024); + + $this->assertResponseStatusCodeSame(403); + } +} diff --git a/tests/Controller/ManageRoundPuzzlesControllerTest.php b/tests/Controller/ManageRoundPuzzlesControllerTest.php new file mode 100644 index 00000000..fde558f4 --- /dev/null +++ b/tests/Controller/ManageRoundPuzzlesControllerTest.php @@ -0,0 +1,41 @@ +request('GET', '/en/manage-round-puzzles/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseRedirects(); + } + + public function testAdminCanAccessPage(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_ADMIN); + + $browser->request('GET', '/en/manage-round-puzzles/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseIsSuccessful(); + } + + public function testNonMaintainerDenied(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', '/en/manage-round-puzzles/' . CompetitionRoundFixture::ROUND_WJPC_QUALIFICATION); + + $this->assertResponseStatusCodeSame(403); + } +} diff --git a/tests/Controller/PlayerSearchAutocompleteControllerTest.php b/tests/Controller/PlayerSearchAutocompleteControllerTest.php new file mode 100644 index 00000000..de2a2326 --- /dev/null +++ b/tests/Controller/PlayerSearchAutocompleteControllerTest.php @@ -0,0 +1,30 @@ +request('GET', '/en/player-search-autocomplete/?query=test'); + + $this->assertResponseRedirects(); + } + + public function testLoggedInUserCanAccessEndpoint(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', '/en/player-search-autocomplete/?query=test'); + + $this->assertResponseIsSuccessful(); + } +} diff --git a/tests/Controller/RemovePuzzleFromRoundControllerTest.php b/tests/Controller/RemovePuzzleFromRoundControllerTest.php new file mode 100644 index 00000000..efbcd82d --- /dev/null +++ b/tests/Controller/RemovePuzzleFromRoundControllerTest.php @@ -0,0 +1,18 @@ +request('POST', '/en/remove-puzzle-from-round/00000000-0000-0000-0000-000000000000'); + + $this->assertResponseRedirects(); + } +} diff --git a/tests/MessageHandler/ApproveCompetitionHandlerTest.php b/tests/MessageHandler/ApproveCompetitionHandlerTest.php new file mode 100644 index 00000000..558b9a32 --- /dev/null +++ b/tests/MessageHandler/ApproveCompetitionHandlerTest.php @@ -0,0 +1,39 @@ +messageBus = self::getContainer()->get(MessageBusInterface::class); + $this->competitionRepository = self::getContainer()->get(CompetitionRepository::class); + } + + public function testApproveSetsFieldsOnCompetition(): void + { + $this->messageBus->dispatch(new ApproveCompetition( + competitionId: CompetitionFixture::COMPETITION_UNAPPROVED, + approvedByPlayerId: PlayerFixture::PLAYER_ADMIN, + )); + + $competition = $this->competitionRepository->get(CompetitionFixture::COMPETITION_UNAPPROVED); + + self::assertNotNull($competition->approvedAt); + self::assertNotNull($competition->approvedByPlayer); + self::assertSame(PlayerFixture::PLAYER_ADMIN, $competition->approvedByPlayer->id->toString()); + } +} diff --git a/tests/MessageHandler/RejectCompetitionHandlerTest.php b/tests/MessageHandler/RejectCompetitionHandlerTest.php new file mode 100644 index 00000000..16eadc42 --- /dev/null +++ b/tests/MessageHandler/RejectCompetitionHandlerTest.php @@ -0,0 +1,42 @@ +messageBus = self::getContainer()->get(MessageBusInterface::class); + $this->competitionRepository = self::getContainer()->get(CompetitionRepository::class); + } + + public function testRejectSetsFieldsOnCompetition(): void + { + $this->messageBus->dispatch(new RejectCompetition( + competitionId: CompetitionFixture::COMPETITION_UNAPPROVED, + rejectedByPlayerId: PlayerFixture::PLAYER_ADMIN, + reason: 'Duplicate event', + )); + + $competition = $this->competitionRepository->get(CompetitionFixture::COMPETITION_UNAPPROVED); + + self::assertNotNull($competition->rejectedAt); + self::assertNotNull($competition->rejectedByPlayer); + self::assertSame(PlayerFixture::PLAYER_ADMIN, $competition->rejectedByPlayer->id->toString()); + self::assertSame('Duplicate event', $competition->rejectionReason); + self::assertNull($competition->approvedAt); + } +} diff --git a/tests/Query/GetCompetitionEventsTest.php b/tests/Query/GetCompetitionEventsTest.php new file mode 100644 index 00000000..bca4b2e4 --- /dev/null +++ b/tests/Query/GetCompetitionEventsTest.php @@ -0,0 +1,47 @@ +query = self::getContainer()->get(GetCompetitionEvents::class); + $this->messageBus = self::getContainer()->get(MessageBusInterface::class); + } + + public function testUnapprovedListContainsUnapprovedCompetition(): void + { + $unapproved = $this->query->allUnapproved(); + + $ids = array_map(static fn($c) => $c->id, $unapproved); + self::assertContains(CompetitionFixture::COMPETITION_UNAPPROVED, $ids); + } + + public function testRejectedCompetitionExcludedFromUnapprovedList(): void + { + $this->messageBus->dispatch(new RejectCompetition( + competitionId: CompetitionFixture::COMPETITION_UNAPPROVED, + rejectedByPlayerId: PlayerFixture::PLAYER_ADMIN, + reason: 'Not a real event', + )); + + $unapproved = $this->query->allUnapproved(); + + $ids = array_map(static fn($c) => $c->id, $unapproved); + self::assertNotContains(CompetitionFixture::COMPETITION_UNAPPROVED, $ids); + } +} diff --git a/translations/emails.en.yml b/translations/emails.en.yml index 7f7d71be..c448070e 100644 --- a/translations/emails.en.yml +++ b/translations/emails.en.yml @@ -87,3 +87,26 @@ oauth2_client_rejected:

    Unfortunately, your OAuth2 application request for %clientName% was not approved.

    Reason: %reason%

    If you have questions or would like to discuss this further, please contact us at jan@myspeedpuzzling.com.

    + +competition_submitted: + subject: "New event submitted: %competitionName%" + title: "New Event Submission" + content: | +

    A new event has been submitted for approval.

    +

    Submitted by: %playerName%
    Event: %competitionName%
    Location: %location%

    +

    Review pending events in admin

    + +competition_approved: + subject: "Your event has been approved!" + title: "Your event has been approved!" + content: | +

    Great news! Your event %competitionName% has been approved and is now publicly visible on MySpeedPuzzling.

    +

    View your event page

    + +competition_rejected: + subject: "Your event submission was not approved" + title: "Event not approved" + content: | +

    Unfortunately, your event %competitionName% was not approved.

    +

    Reason: %reason%

    +

    If you have questions or would like to discuss this further, please contact us at jan@myspeedpuzzling.com.

    diff --git a/translations/messages.en.yml b/translations/messages.en.yml index 04477933..f3ecd289 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -2404,6 +2404,8 @@ competition: my_competitions: "My Competitions" pending_approval: "Pending approval" pending_approval_info: "This event is pending admin approval and is not yet visible to other users." + rejected_info: "This event was not approved." + rejection_reason: "Reason" add_event_info: "Add a new competition or event. It will be reviewed by an admin before being published." submit_for_approval: "Submit for Approval" save_changes: "Save Changes" @@ -2510,3 +2512,5 @@ competition: round_deleted: "Round deleted." puzzle_added: "Puzzle added to round." puzzle_removed: "Puzzle removed from round." + rejected: "Competition rejected. Creator has been notified." + rejection_reason_required: "Please provide a rejection reason." From 1af235265fe58cb3aaa834b09ba4d081776bbede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Fri, 3 Apr 2026 20:44:21 +0200 Subject: [PATCH 6/8] Competition events --- .../competitions-management/README.md | 43 ++++++- ...03124723.php => Version20260403132113.php} | 4 +- src/Controller/AddCompetitionController.php | 1 + src/Controller/EditCompetitionController.php | 1 + src/Controller/EventDetailController.php | 12 ++ src/Controller/EventsController.php | 1 + src/Entity/Competition.php | 4 + src/FormData/CompetitionFormData.php | 2 + src/FormType/CompetitionFormType.php | 6 + src/Message/AddCompetition.php | 1 + src/Message/EditCompetition.php | 1 + src/MessageHandler/AddCompetitionHandler.php | 1 + src/MessageHandler/EditCompetitionHandler.php | 1 + src/Query/GetCompetitionEditions.php | 107 ++++++++++++++++++ src/Query/GetCompetitionEvents.php | 26 +++++ src/Results/CompetitionEdition.php | 21 ++++ src/Results/CompetitionEvent.php | 8 ++ templates/_competition_event.html.twig | 6 + templates/edit_competition.html.twig | 2 +- templates/event_detail.html.twig | 96 ++++++++++++++-- templates/events.html.twig | 20 +++- templates/manage_competition_rounds.html.twig | 6 +- tests/DataFixtures/CompetitionFixture.php | 21 ++++ tests/Query/GetCompetitionEventsTest.php | 20 ++++ translations/messages.en.yml | 14 +++ 25 files changed, 406 insertions(+), 19 deletions(-) rename migrations/{Version20260403124723.php => Version20260403132113.php} (86%) create mode 100644 src/Query/GetCompetitionEditions.php create mode 100644 src/Results/CompetitionEdition.php diff --git a/docs/features/competitions-management/README.md b/docs/features/competitions-management/README.md index 02f8736b..179f9fe6 100644 --- a/docs/features/competitions-management/README.md +++ b/docs/features/competitions-management/README.md @@ -8,7 +8,7 @@ Community-driven competition and event management. Any logged-in player can subm Any authenticated player can submit a new competition with: - **Required:** name, location -- **Optional:** shortcut (e.g. "WJPC"), description, website/registration/results links, country, date range, online flag, logo image +- **Optional:** shortcut (e.g. "WJPC"), description, website/registration/results links, country, date range, online flag, recurring flag, logo image - **Maintainers:** other players who should have edit access (searchable autocomplete) A URL slug is auto-generated from the name (with a random suffix if collisions exist). The competition is stored with `approvedAt = null` (pending state) and is **not visible** in the public listing. @@ -30,7 +30,13 @@ Changing the name regenerates the slug. Maintainer lists are fully replaced on e ### 4. Public Listing -The events page shows three sections: **Live** (date range includes today), **Upcoming** (starts in the future), and **Past** (already ended). All sections only show approved competitions. External links (website, registration, results) automatically get `utm_source=myspeedpuzzling` appended. +The events page shows four sections: +- **Live** — one-time events where today's date falls within the event date range +- **Upcoming** — one-time events starting in the future +- **Recurring** — all approved recurring events (sorted alphabetically) +- **Past** — one-time events that have ended + +Recurring events are excluded from Live/Upcoming/Past sections. All sections only show approved competitions. External links (website, registration, results) automatically get `utm_source=myspeedpuzzling` appended. Online and recurring badges are displayed on event cards. Each competition also appears in "My Competitions" for its creator/maintainers regardless of approval status. @@ -46,6 +52,37 @@ Each competition also appears in "My Competitions" for its creator/maintainers r Access is enforced via a `CompetitionEditVoter` that checks whether the player is admin, the creator, or in the maintainers list. All management controllers use this same voter, including round-level controllers (which resolve the competition from the round). +## Event Types + +Every competition has two independent flags: `isOnline` and `isRecurring`. This creates four combinations: + +| Type | Online | Recurring | Example | Dates | Tables | +|------|--------|-----------|---------|-------|--------| +| One-time offline | No | No | WJPC 2024 | dateFrom/dateTo | Yes | +| One-time online | Yes | No | Online Challenge 2024 | dateFrom/dateTo | No | +| Recurring offline | No | Yes | Monthly Puzzle Meetup | Optional | Yes | +| Recurring online | Yes | Yes | Euro Jigsaw Jam | Optional | No | + +**Online and offline are never combined** — a competition is either fully online or fully offline. Users must create separate competitions for each format. + +### Recurring Events + +Recurring events (e.g. "Euro Jigsaw Jam" with 70+ monthly editions) use **rounds as editions**. Instead of appearing 70 times in the events listing, the event appears once in the "Recurring" section. + +**How it works:** +- The competition itself represents the series (e.g. "Euro Jigsaw Jam") +- Each round represents one edition (e.g. "EJJ #68 — March 2026") +- Each edition has its own date, puzzles, stopwatch, and (for offline) table layout +- The event detail page shows upcoming and past editions instead of a flat puzzle list +- The management UI labels rounds as "Editions" for recurring events + +**Public event detail page for recurring events:** +- Competition header with name, description, logo, links +- "Upcoming editions" table: name, date, time limit, puzzle count (sorted by date ascending) +- "Past editions" table: same columns (sorted by date descending, most recent first) + +**For non-recurring events:** the detail page shows the traditional puzzle grid and participants section. + ## Round Management A competition has multiple **rounds**, each with: @@ -148,3 +185,5 @@ All emails use the `transactional` mailer transport and follow the standard Inky 7. **External links get automatic UTM tracking** — `utm_source=myspeedpuzzling` is appended 8. **Rejected competitions are excluded from the approval queue** — they no longer appear as "pending" 9. **Email notifications require creator to have an email** — if the creator has no email on their profile, no notification is sent (no error) +10. **Recurring events get their own listing section** — they're excluded from Live/Upcoming/Past and shown in a dedicated "Recurring" section +11. **Online and offline are mutually exclusive** — one competition cannot be both; users create separate events diff --git a/migrations/Version20260403124723.php b/migrations/Version20260403132113.php similarity index 86% rename from migrations/Version20260403124723.php rename to migrations/Version20260403132113.php index 03327375..f60c611a 100644 --- a/migrations/Version20260403124723.php +++ b/migrations/Version20260403132113.php @@ -10,7 +10,7 @@ /** * Auto-generated Migration: Please modify to your needs! */ -final class Version20260403124723 extends AbstractMigration +final class Version20260403132113 extends AbstractMigration { public function getDescription(): string { @@ -20,6 +20,7 @@ public function getDescription(): string public function up(Schema $schema): void { // this up() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE competition ADD is_recurring BOOLEAN DEFAULT false NOT NULL'); $this->addSql('ALTER TABLE competition ADD rejected_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); $this->addSql('ALTER TABLE competition ADD rejection_reason TEXT DEFAULT NULL'); $this->addSql('ALTER TABLE competition ADD rejected_by_player_id UUID DEFAULT NULL'); @@ -32,6 +33,7 @@ public function down(Schema $schema): void // this down() migration is auto-generated, please modify it to your needs $this->addSql('ALTER TABLE competition DROP CONSTRAINT FK_B50A2CB1BC7FE91'); $this->addSql('DROP INDEX IDX_B50A2CB1BC7FE91'); + $this->addSql('ALTER TABLE competition DROP is_recurring'); $this->addSql('ALTER TABLE competition DROP rejected_at'); $this->addSql('ALTER TABLE competition DROP rejection_reason'); $this->addSql('ALTER TABLE competition DROP rejected_by_player_id'); diff --git a/src/Controller/AddCompetitionController.php b/src/Controller/AddCompetitionController.php index f3475e0a..df218971 100644 --- a/src/Controller/AddCompetitionController.php +++ b/src/Controller/AddCompetitionController.php @@ -70,6 +70,7 @@ public function __invoke(Request $request, #[CurrentUser] User $user): Response dateFrom: $data->dateFrom, dateTo: $data->dateTo, isOnline: $data->isOnline, + isRecurring: $data->isRecurring, logo: $data->logo, maintainerIds: $data->maintainers, )); diff --git a/src/Controller/EditCompetitionController.php b/src/Controller/EditCompetitionController.php index 968dc237..b2785196 100644 --- a/src/Controller/EditCompetitionController.php +++ b/src/Controller/EditCompetitionController.php @@ -67,6 +67,7 @@ public function __invoke(Request $request, string $competitionId): Response dateFrom: $data->dateFrom, dateTo: $data->dateTo, isOnline: $data->isOnline, + isRecurring: $data->isRecurring, logo: $data->logo, maintainerIds: $data->maintainers, )); diff --git a/src/Controller/EventDetailController.php b/src/Controller/EventDetailController.php index a048e806..5c46eacc 100644 --- a/src/Controller/EventDetailController.php +++ b/src/Controller/EventDetailController.php @@ -5,6 +5,7 @@ namespace SpeedPuzzling\Web\Controller; use SpeedPuzzling\Web\Entity\Competition; +use SpeedPuzzling\Web\Query\GetCompetitionEditions; use SpeedPuzzling\Web\Query\GetCompetitionEvents; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use SpeedPuzzling\Web\Query\GetPuzzleOverview; @@ -20,6 +21,7 @@ final class EventDetailController extends AbstractController { public function __construct( readonly private GetCompetitionEvents $getCompetitionEvents, + readonly private GetCompetitionEditions $getCompetitionEditions, readonly private GetPuzzleOverview $getPuzzleOverview, readonly private GetUserPuzzleStatuses $getUserPuzzleStatuses, readonly private RetrieveLoggedUserProfile $retrieveLoggedUserProfile, @@ -52,10 +54,20 @@ public function __invoke( $puzzleStatuses = $this->getUserPuzzleStatuses->byPlayerId($loggedPlayer?->playerId); + $upcomingEditions = []; + $pastEditions = []; + if ($competitionEvent->isRecurring) { + $competitionId = $competition->id->toString(); + $upcomingEditions = $this->getCompetitionEditions->upcomingForCompetition($competitionId); + $pastEditions = $this->getCompetitionEditions->pastForCompetition($competitionId); + } + return $this->render('event_detail.html.twig', [ 'event' => $competitionEvent, 'puzzles' => $puzzles, 'puzzle_statuses' => $puzzleStatuses, + 'upcoming_editions' => $upcomingEditions, + 'past_editions' => $pastEditions, ]); } } diff --git a/src/Controller/EventsController.php b/src/Controller/EventsController.php index c9651865..1f8ce998 100644 --- a/src/Controller/EventsController.php +++ b/src/Controller/EventsController.php @@ -41,6 +41,7 @@ public function __invoke(): Response return $this->render('events.html.twig', [ 'live_events' => $this->getCompetitionEvents->allLive(), 'upcoming_events' => $this->getCompetitionEvents->allUpcoming(), + 'recurring_events' => $this->getCompetitionEvents->allRecurring(), 'past_events' => $this->getCompetitionEvents->allPast(), 'player_competitions' => $playerCompetitions, ]); diff --git a/src/Entity/Competition.php b/src/Entity/Competition.php index 7231c359..de9e66df 100644 --- a/src/Entity/Competition.php +++ b/src/Entity/Competition.php @@ -57,6 +57,8 @@ public function __construct( public null|Tag $tag, #[Column(options: ['default' => false])] public bool $isOnline = false, + #[Column(options: ['default' => false])] + public bool $isRecurring = false, #[Immutable] #[ManyToOne] public null|Player $addedByPlayer = null, @@ -119,6 +121,7 @@ public function edit( null|DateTimeImmutable $dateFrom, null|DateTimeImmutable $dateTo, bool $isOnline, + bool $isRecurring, ): void { $this->name = $name; $this->slug = $slug; @@ -133,5 +136,6 @@ public function edit( $this->dateFrom = $dateFrom; $this->dateTo = $dateTo; $this->isOnline = $isOnline; + $this->isRecurring = $isRecurring; } } diff --git a/src/FormData/CompetitionFormData.php b/src/FormData/CompetitionFormData.php index 4ec4451f..185a3267 100644 --- a/src/FormData/CompetitionFormData.php +++ b/src/FormData/CompetitionFormData.php @@ -28,6 +28,7 @@ public function __construct( public null|DateTimeImmutable $dateFrom = null, public null|DateTimeImmutable $dateTo = null, public bool $isOnline = false, + public bool $isRecurring = false, public null|UploadedFile $logo = null, /** @var array */ public array $maintainers = [], @@ -48,6 +49,7 @@ public static function fromCompetition(Competition $competition): self $data->dateFrom = $competition->dateFrom; $data->dateTo = $competition->dateTo; $data->isOnline = $competition->isOnline; + $data->isRecurring = $competition->isRecurring; $maintainerIds = []; foreach ($competition->maintainers as $maintainer) { diff --git a/src/FormType/CompetitionFormType.php b/src/FormType/CompetitionFormType.php index b7da85f2..78a28d31 100644 --- a/src/FormType/CompetitionFormType.php +++ b/src/FormType/CompetitionFormType.php @@ -118,6 +118,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, ]); + $builder->add('isRecurring', CheckboxType::class, [ + 'label' => 'competition.form.is_recurring', + 'help' => 'competition.form.is_recurring_help', + 'required' => false, + ]); + $builder->add('logo', FileType::class, [ 'label' => 'competition.form.logo', 'required' => false, diff --git a/src/Message/AddCompetition.php b/src/Message/AddCompetition.php index 9b419e5e..57131c72 100644 --- a/src/Message/AddCompetition.php +++ b/src/Message/AddCompetition.php @@ -27,6 +27,7 @@ public function __construct( public null|DateTimeImmutable $dateFrom, public null|DateTimeImmutable $dateTo, public bool $isOnline, + public bool $isRecurring, public null|UploadedFile $logo, public array $maintainerIds, ) { diff --git a/src/Message/EditCompetition.php b/src/Message/EditCompetition.php index 15854571..d647b3d1 100644 --- a/src/Message/EditCompetition.php +++ b/src/Message/EditCompetition.php @@ -25,6 +25,7 @@ public function __construct( public null|DateTimeImmutable $dateFrom, public null|DateTimeImmutable $dateTo, public bool $isOnline, + public bool $isRecurring, public null|UploadedFile $logo, public array $maintainerIds, ) { diff --git a/src/MessageHandler/AddCompetitionHandler.php b/src/MessageHandler/AddCompetitionHandler.php index 688d24f8..ff8fa8a2 100644 --- a/src/MessageHandler/AddCompetitionHandler.php +++ b/src/MessageHandler/AddCompetitionHandler.php @@ -73,6 +73,7 @@ public function __invoke(AddCompetition $message): void dateTo: $message->dateTo, tag: null, isOnline: $message->isOnline, + isRecurring: $message->isRecurring, addedByPlayer: $player, createdAt: $now, ); diff --git a/src/MessageHandler/EditCompetitionHandler.php b/src/MessageHandler/EditCompetitionHandler.php index 66e444f0..d75d1ba7 100644 --- a/src/MessageHandler/EditCompetitionHandler.php +++ b/src/MessageHandler/EditCompetitionHandler.php @@ -67,6 +67,7 @@ public function __invoke(EditCompetition $message): void dateFrom: $message->dateFrom, dateTo: $message->dateTo, isOnline: $message->isOnline, + isRecurring: $message->isRecurring, ); // Sync maintainers diff --git a/src/Query/GetCompetitionEditions.php b/src/Query/GetCompetitionEditions.php new file mode 100644 index 00000000..c8c048ec --- /dev/null +++ b/src/Query/GetCompetitionEditions.php @@ -0,0 +1,107 @@ + + */ + public function upcomingForCompetition(string $competitionId): array + { + $query = <<= :now +GROUP BY cr.id +ORDER BY cr.starts_at +SQL; + + return $this->fetchEditions($query, $competitionId); + } + + /** + * @return array + */ + public function pastForCompetition(string $competitionId): array + { + $query = <<fetchEditions($query, $competitionId); + } + + /** + * @return array + */ + private function fetchEditions(string $query, string $competitionId): array + { + $now = $this->clock->now(); + + $data = $this->database + ->executeQuery($query, [ + 'competitionId' => $competitionId, + 'now' => $now->format('Y-m-d H:i:s'), + ]) + ->fetchAllAssociative(); + + return array_map(static function (array $row): CompetitionEdition { + /** + * @var array{ + * id: string, + * name: string, + * starts_at: string, + * minutes_limit: int|string, + * badge_background_color: null|string, + * badge_text_color: null|string, + * puzzle_count: int|string, + * } $row + */ + return new CompetitionEdition( + id: $row['id'], + name: $row['name'], + startsAt: new DateTimeImmutable($row['starts_at']), + minutesLimit: (int) $row['minutes_limit'], + badgeBackgroundColor: $row['badge_background_color'], + badgeTextColor: $row['badge_text_color'], + puzzleCount: (int) $row['puzzle_count'], + ); + }, $data); + } +} diff --git a/src/Query/GetCompetitionEvents.php b/src/Query/GetCompetitionEvents.php index a274a06d..9ff80dfe 100644 --- a/src/Query/GetCompetitionEvents.php +++ b/src/Query/GetCompetitionEvents.php @@ -52,6 +52,7 @@ public function allPast(): array SELECT * FROM competition WHERE approved_at IS NOT NULL + AND NOT is_recurring AND (COALESCE(date_to, date_from)::date < :date::date OR date_from IS NULL) ORDER BY date_from DESC; @@ -79,6 +80,7 @@ public function allUpcoming(): array SELECT * FROM competition WHERE approved_at IS NOT NULL + AND NOT is_recurring AND COALESCE(date_from, date_to)::date > :date::date ORDER BY date_from; SQL; @@ -105,6 +107,7 @@ public function allLive(): array SELECT * FROM competition WHERE approved_at IS NOT NULL + AND NOT is_recurring AND :date::date BETWEEN COALESCE(date_from, date_to)::date AND COALESCE(date_to, date_from)::date; @@ -144,6 +147,29 @@ public function all(): array }, $data); } + /** + * @return array + */ + public function allRecurring(): array + { + $query = <<database + ->executeQuery($query) + ->fetchAllAssociative(); + + return array_map(static function (array $row): CompetitionEvent { + /** @var CompetitionEventDatabaseRow $row */ + return CompetitionEvent::fromDatabaseRow($row); + }, $data); + } + /** * @return array */ diff --git a/src/Results/CompetitionEdition.php b/src/Results/CompetitionEdition.php new file mode 100644 index 00000000..227b388b --- /dev/null +++ b/src/Results/CompetitionEdition.php @@ -0,0 +1,21 @@ +{{ 'competition.online'|trans }} + {% endif %} + {% if event.isRecurring %} + {{ 'competition.recurring'|trans }} + {% endif %} -

    {{ 'events.competition_puzzles'|trans }}

    - - {% if puzzles|length > 0 %} -
    - {% for puzzle in puzzles %} - {{ include('_puzzle_item.html.twig', { - 'search': '', - }) }} - {% endfor %} -
    + {% if event.isRecurring %} + {% if upcoming_editions|length > 0 %} +

    {{ 'events.upcoming_editions'|trans }}

    +
    +
    +
    +
    +
    + + +
    + +
    +
    + + + + + + + + + + {% for edition in upcoming_editions %} + + + + + + + {% endfor %} + +
    {{ 'competition.round.name'|trans }}{{ 'events.edition_date'|trans }}{{ 'competition.round.minutes_limit'|trans }}{{ 'competition.round.puzzles'|trans }}
    + {% if edition.badgeBackgroundColor %} + + {{ edition.name }} + + {% else %} + {{ edition.name }} + {% endif %} + {{ edition.startsAt|date('d.m.Y H:i') }}{{ edition.minutesLimit }} min{{ edition.puzzleCount }}
    +
    + {% endif %} + + {% if past_editions|length > 0 %} +

    {{ 'events.past_editions'|trans }}

    +
    + + + + + + + + + + + {% for edition in past_editions %} + + + + + + + {% endfor %} + +
    {{ 'competition.round.name'|trans }}{{ 'events.edition_date'|trans }}{{ 'competition.round.minutes_limit'|trans }}{{ 'competition.round.puzzles'|trans }}
    + {% if edition.badgeBackgroundColor %} + + {{ edition.name }} + + {% else %} + {{ edition.name }} + {% endif %} + {{ edition.startsAt|date('d.m.Y H:i') }}{{ edition.minutesLimit }} min{{ edition.puzzleCount }}
    +
    + {% endif %} + + {% if upcoming_editions|length == 0 and past_editions|length == 0 %} +

    {{ 'events.no_editions_yet'|trans }}

    + {% endif %} {% else %} -

    {{ 'events.no_puzzle_text'|trans|raw }}

    +

    {{ 'events.competition_puzzles'|trans }}

    + + {% if puzzles|length > 0 %} +
    + {% for puzzle in puzzles %} + {{ include('_puzzle_item.html.twig', { + 'search': '', + }) }} + {% endfor %} +
    + {% else %} +

    {{ 'events.no_puzzle_text'|trans|raw }}

    + {% endif %} {% endif %} diff --git a/templates/events.html.twig b/templates/events.html.twig index 098b32bd..e76e2c80 100644 --- a/templates/events.html.twig +++ b/templates/events.html.twig @@ -44,9 +44,17 @@
    @@ -22,7 +22,7 @@
    {% endfor %} -

    {{ 'competition.rounds'|trans }}

    +

    {{ competition.isRecurring ? 'competition.editions'|trans : 'competition.rounds'|trans }}

    {% if rounds|length > 0 %}
    @@ -79,7 +79,7 @@
    {% else %} -

    {{ 'competition.no_rounds'|trans }}

    +

    {{ competition.isRecurring ? 'competition.no_editions'|trans : 'competition.no_rounds'|trans }}

    {% endif %}
    {% endblock %} diff --git a/tests/DataFixtures/CompetitionFixture.php b/tests/DataFixtures/CompetitionFixture.php index 89ba8957..fec8aaaa 100644 --- a/tests/DataFixtures/CompetitionFixture.php +++ b/tests/DataFixtures/CompetitionFixture.php @@ -19,6 +19,7 @@ final class CompetitionFixture extends Fixture implements DependentFixtureInterf public const string COMPETITION_WJPC_2024 = '018d0004-0000-0000-0000-000000000001'; public const string COMPETITION_CZECH_NATIONALS_2024 = '018d0004-0000-0000-0000-000000000002'; public const string COMPETITION_UNAPPROVED = '018d0004-0000-0000-0000-000000000003'; + public const string COMPETITION_RECURRING_ONLINE = '018d0004-0000-0000-0000-000000000004'; public function __construct( private readonly ClockInterface $clock, @@ -80,6 +81,22 @@ public function load(ObjectManager $manager): void $manager->persist($unapprovedCompetition); $this->addReference(self::COMPETITION_UNAPPROVED, $unapprovedCompetition); + $recurringOnlineCompetition = $this->createCompetition( + id: self::COMPETITION_RECURRING_ONLINE, + name: 'Euro Jigsaw Jam', + location: 'Online', + locationCountryCode: 'eu', + tag: null, + daysFromNow: 0, + slug: 'euro-jigsaw-jam', + description: 'Monthly online jigsaw puzzle competition', + isOnline: true, + isRecurring: true, + approvedAt: $this->clock->now(), + ); + $manager->persist($recurringOnlineCompetition); + $this->addReference(self::COMPETITION_RECURRING_ONLINE, $recurringOnlineCompetition); + $manager->flush(); } @@ -104,6 +121,8 @@ private function createCompetition( null|string $link = null, null|string $registrationLink = null, null|string $resultsLink = null, + bool $isOnline = false, + bool $isRecurring = false, null|DateTimeImmutable $approvedAt = null, null|Player $addedByPlayer = null, null|DateTimeImmutable $createdAt = null, @@ -126,6 +145,8 @@ private function createCompetition( dateFrom: $dateFrom, dateTo: $dateTo, tag: $tag, + isOnline: $isOnline, + isRecurring: $isRecurring, approvedAt: $approvedAt, addedByPlayer: $addedByPlayer, createdAt: $createdAt, diff --git a/tests/Query/GetCompetitionEventsTest.php b/tests/Query/GetCompetitionEventsTest.php index bca4b2e4..128bfb99 100644 --- a/tests/Query/GetCompetitionEventsTest.php +++ b/tests/Query/GetCompetitionEventsTest.php @@ -44,4 +44,24 @@ public function testRejectedCompetitionExcludedFromUnapprovedList(): void $ids = array_map(static fn($c) => $c->id, $unapproved); self::assertNotContains(CompetitionFixture::COMPETITION_UNAPPROVED, $ids); } + + public function testRecurringEventsExcludedFromUpcomingAndPast(): void + { + $upcoming = $this->query->allUpcoming(); + $past = $this->query->allPast(); + + $upcomingIds = array_map(static fn($c) => $c->id, $upcoming); + $pastIds = array_map(static fn($c) => $c->id, $past); + + self::assertNotContains(CompetitionFixture::COMPETITION_RECURRING_ONLINE, $upcomingIds); + self::assertNotContains(CompetitionFixture::COMPETITION_RECURRING_ONLINE, $pastIds); + } + + public function testRecurringEventsReturnedByAllRecurring(): void + { + $recurring = $this->query->allRecurring(); + + $ids = array_map(static fn($c) => $c->id, $recurring); + self::assertContains(CompetitionFixture::COMPETITION_RECURRING_ONLINE, $ids); + } } diff --git a/translations/messages.en.yml b/translations/messages.en.yml index f3ecd289..e91722ce 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -433,8 +433,13 @@ events: website_link: "Info" results_link: "Results" registration_link: "Register" + recurring: "Recurring events" competition_puzzles: "Competition puzzles" no_puzzle_text: No puzzles here… yet! Stay tuned + upcoming_editions: "Upcoming editions" + past_editions: "Past editions" + edition_date: "Date" + no_editions_yet: "No editions yet." for_developers: @@ -2404,24 +2409,31 @@ competition: my_competitions: "My Competitions" pending_approval: "Pending approval" pending_approval_info: "This event is pending admin approval and is not yet visible to other users." + rejected: "Rejected" rejected_info: "This event was not approved." rejection_reason: "Reason" + online: "Online" + recurring: "Recurring" add_event_info: "Add a new competition or event. It will be reviewed by an admin before being published." submit_for_approval: "Submit for Approval" save_changes: "Save Changes" manage_rounds: "Manage Rounds" + manage_editions: "Manage Editions" manage_participants: "Manage Participants" manage_puzzles: "Manage Puzzles" back_to_edit: "Back to Event" back_to_rounds: "Back to Rounds" back_to_puzzles: "Back to Puzzles" add_round: "Add Round" + add_edition: "Add Edition" edit_round: "Edit Round" rounds: "Rounds" + editions: "Editions" puzzles: "Puzzles" add_puzzle: "Add Puzzle" remove: "Remove" no_rounds: "No rounds yet. Add a round to get started." + no_editions: "No editions yet. Add an edition to get started." no_puzzles: "No puzzles in this round yet." confirm_delete_round: "Are you sure you want to delete this round?" confirm_remove_puzzle: "Are you sure you want to remove this puzzle from this round?" @@ -2458,6 +2470,8 @@ competition: date_to: "End date" dates_help: "For regular online events, leave dates empty" is_online: "This is an online event" + is_recurring: "This is a recurring event" + is_recurring_help: "Check this for events that happen regularly (e.g. monthly, weekly). Each occurrence will be managed as a separate edition." logo: "Logo" maintainers: "Maintainers" maintainers_help: "Other users who can manage this event" From 4be6ce8aafba51b130f6fd4151fcee244c37e6d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Sat, 4 Apr 2026 01:45:07 +0200 Subject: [PATCH 7/8] Competition management UX improvements - Add required validation for location and dates on offline events - Add feature flags (admin-only) for table layout and stopwatch - Add participant management date notice (expected 7.4.2026) - Improve round form: single-day events show date + time-only picker - Add timezone select with country-based defaults and UTC conversion - Add Coloris color picker plugin replacing native color inputs - Add country-to-timezone mapping on CountryCode enum Co-Authored-By: Claude Opus 4.6 (1M context) --- assets/controllers/colorpicker_controller.js | 27 ++++ .../competition_form_controller.js | 29 ++++ ...roller.js => country_select_controller.js} | 0 .../player_search_autocomplete_controller.js | 40 ++++++ assets/styles/app.scss | 2 +- assets/styles/components/_forms.scss | 8 ++ docs/features/feature_flags.md | 16 ++- migrations/Version20260403185938.php | 31 +++++ package-lock.json | 7 + package.json | 1 + src/Component/EventsListing.php | 128 ++++++++++++++++++ src/Controller/AddCompetitionController.php | 10 +- .../AddCompetitionRoundController.php | 36 ++++- .../CompetitionAutocompleteController.php | 2 +- src/Controller/EditCompetitionController.php | 10 +- .../EditCompetitionRoundController.php | 41 +++++- src/Controller/EventsController.php | 4 - .../PlayerSearchAutocompleteController.php | 24 +++- src/Entity/Competition.php | 6 +- src/FormData/CompetitionFormData.php | 31 ++++- src/FormData/CompetitionRoundFormData.php | 4 +- src/FormType/CompetitionFormType.php | 107 +++++++++++++-- src/FormType/CompetitionRoundFormType.php | 70 ++++++++-- .../EditPuzzleSolvingTimeFormType.php | 2 +- src/FormType/PuzzleAddFormType.php | 2 +- src/FormType/PuzzleSolvingTimeFormType.php | 2 +- src/Message/AddCompetition.php | 2 +- src/Message/EditCompetition.php | 2 +- src/Query/GetCompetitionEvents.php | 85 ++++++++++++ src/Query/SearchPlayers.php | 1 + src/Results/CompetitionEvent.php | 4 +- src/Results/PlayerIdentification.php | 3 + src/Value/CountryCode.php | 77 +++++++++++ templates/add_competition.html.twig | 53 +++++++- templates/add_competition_round.html.twig | 47 ++++++- templates/add_puzzle_to_round.html.twig | 6 +- templates/components/EventsListing.html.twig | 62 +++++++++ .../components/MarketplaceListing.html.twig | 4 +- .../components/MspRatingLadder.html.twig | 4 +- templates/edit_competition.html.twig | 51 +++++-- templates/edit_competition_round.html.twig | 48 ++++++- templates/events.html.twig | 118 ++++++++-------- templates/generate_table_layout.html.twig | 8 +- .../manage_competition_participants.html.twig | 10 +- templates/manage_competition_rounds.html.twig | 28 ++-- templates/manage_round_puzzles.html.twig | 7 +- templates/manage_round_stopwatch.html.twig | 5 +- templates/manage_round_tables.html.twig | 7 +- .../AddCompetitionControllerTest.php | 48 +++++++ .../EditCompetitionControllerTest.php | 36 +++++ translations/messages.en.yml | 24 +++- 51 files changed, 1186 insertions(+), 194 deletions(-) create mode 100644 assets/controllers/colorpicker_controller.js create mode 100644 assets/controllers/competition_form_controller.js rename assets/controllers/{marketplace_country_filter_controller.js => country_select_controller.js} (100%) create mode 100644 assets/controllers/player_search_autocomplete_controller.js create mode 100644 migrations/Version20260403185938.php create mode 100644 src/Component/EventsListing.php create mode 100644 templates/components/EventsListing.html.twig diff --git a/assets/controllers/colorpicker_controller.js b/assets/controllers/colorpicker_controller.js new file mode 100644 index 00000000..8ed67680 --- /dev/null +++ b/assets/controllers/colorpicker_controller.js @@ -0,0 +1,27 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + async connect() { + const [{ default: Coloris }] = await Promise.all([ + import('@melloware/coloris'), + import('@melloware/coloris/dist/coloris.min.css'), + ]); + + Coloris.init(); + Coloris({ + el: '#' + this.element.id, + format: 'hex', + alpha: false, + swatches: [ + '#fe696a', + '#ffffff', + '#000000', + '#0d6efd', + '#198754', + '#ffc107', + '#dc3545', + '#6c757d', + ], + }); + } +} diff --git a/assets/controllers/competition_form_controller.js b/assets/controllers/competition_form_controller.js new file mode 100644 index 00000000..fb3588ed --- /dev/null +++ b/assets/controllers/competition_form_controller.js @@ -0,0 +1,29 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['offlineFields', 'recurringField', 'typeSelectedFields']; + + connect() { + this._toggle(); + } + + toggle() { + this._toggle(); + } + + _toggle() { + const checkedRadio = this.element.querySelector('input[type="radio"]:checked'); + const hasSelection = checkedRadio !== null; + const isOnline = checkedRadio !== null && checkedRadio.value === '1'; + + this.offlineFieldsTargets.forEach(el => { + el.style.display = hasSelection && !isOnline ? '' : 'none'; + }); + + this.recurringFieldTarget.style.display = hasSelection && isOnline ? '' : 'none'; + + this.typeSelectedFieldsTargets.forEach(el => { + el.style.display = hasSelection ? '' : 'none'; + }); + } +} diff --git a/assets/controllers/marketplace_country_filter_controller.js b/assets/controllers/country_select_controller.js similarity index 100% rename from assets/controllers/marketplace_country_filter_controller.js rename to assets/controllers/country_select_controller.js diff --git a/assets/controllers/player_search_autocomplete_controller.js b/assets/controllers/player_search_autocomplete_controller.js new file mode 100644 index 00000000..f057cac4 --- /dev/null +++ b/assets/controllers/player_search_autocomplete_controller.js @@ -0,0 +1,40 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static values = { + url: String, + }; + + initialize() { + this._onPreConnect = this._onPreConnect.bind(this); + } + + connect() { + this.element.addEventListener('autocomplete:pre-connect', this._onPreConnect); + } + + disconnect() { + this.element.removeEventListener('autocomplete:pre-connect', this._onPreConnect); + } + + _onPreConnect(event) { + const url = this.urlValue; + + event.detail.options.shouldLoad = (query) => query.length >= 2; + + event.detail.options.score = () => () => 1; + + event.detail.options.render = { + ...event.detail.options.render, + option: (item) => `
    ${item.text}
    `, + item: (item) => `
    ${item.text}
    `, + }; + + event.detail.options.load = function (query, callback) { + fetch(`${url}?query=${encodeURIComponent(query)}`) + .then(response => response.json()) + .then(data => callback(data)) + .catch(() => callback()); + }; + } +} diff --git a/assets/styles/app.scss b/assets/styles/app.scss index e7420d27..79611731 100755 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -540,7 +540,7 @@ button[data-submit-prevention-target] { } } -.marketplace-country-filter-wrapper { +.country-select-wrapper { .ts-wrapper { .ts-control { min-height: auto; diff --git a/assets/styles/components/_forms.scss b/assets/styles/components/_forms.scss index 715c7f56..cc6b061f 100644 --- a/assets/styles/components/_forms.scss +++ b/assets/styles/components/_forms.scss @@ -3,6 +3,14 @@ // -------------------------------------------------- +// Required field indicator + +.required > label::after, +.required > .form-label::after { + content: ' *'; + color: $danger; +} + // Label for use with horizontal and inline forms .col-form-label { diff --git a/docs/features/feature_flags.md b/docs/features/feature_flags.md index 5c1cfb2b..608f0d60 100644 --- a/docs/features/feature_flags.md +++ b/docs/features/feature_flags.md @@ -2,4 +2,18 @@ This file documents all active feature flags in the codebase — where they are, what feature they gate, and when they can be removed. -No active feature flags. +## Competition Table Layout (admin-only) + +- **Feature:** Table layout management for competition rounds +- **Flag:** `is_granted('ADMIN_ACCESS')` check in template +- **Gated files:** + - `templates/manage_competition_rounds.html.twig` — Tables button visibility +- **Remove when:** Table layout feature is ready for all competition organizers + +## Competition Stopwatch (admin-only) + +- **Feature:** Stopwatch/timewatch for competition rounds +- **Flag:** `is_granted('ADMIN_ACCESS')` check in template +- **Gated files:** + - `templates/manage_competition_rounds.html.twig` — Stopwatch button visibility +- **Remove when:** Stopwatch feature is ready for all competition organizers diff --git a/migrations/Version20260403185938.php b/migrations/Version20260403185938.php new file mode 100644 index 00000000..9eafbf41 --- /dev/null +++ b/migrations/Version20260403185938.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE competition ALTER location DROP NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE competition ALTER location SET NOT NULL'); + } +} diff --git a/package-lock.json b/package-lock.json index 745ad3c7..7c87d1e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "dependencies": { "@hotwired/stimulus": "^3.0.0", "@hotwired/turbo": "^8.0", + "@melloware/coloris": "^0.25.0", "@symfony/stimulus-bridge": "^3.2.1", "@symfony/stimulus-bundle": "file:vendor/symfony/stimulus-bundle/assets", "@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets", @@ -2629,6 +2630,12 @@ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", "dev": true }, + "node_modules/@melloware/coloris": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@melloware/coloris/-/coloris-0.25.0.tgz", + "integrity": "sha512-RBWVFLjWbup7GRkOXb9g3+ZtR9AevFtJinrRz2cYPLjZ3TCkNRGMWuNbmQWbZ5cF3VU7aQDZwUsYgIY/bGrh2g==", + "license": "MIT" + }, "node_modules/@nuxt/friendly-errors-webpack-plugin": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@nuxt/friendly-errors-webpack-plugin/-/friendly-errors-webpack-plugin-2.6.0.tgz", diff --git a/package.json b/package.json index a1727e32..072e70f8 100755 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "dependencies": { "@hotwired/stimulus": "^3.0.0", "@hotwired/turbo": "^8.0", + "@melloware/coloris": "^0.25.0", "@symfony/stimulus-bridge": "^3.2.1", "@symfony/stimulus-bundle": "file:vendor/symfony/stimulus-bundle/assets", "@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets", diff --git a/src/Component/EventsListing.php b/src/Component/EventsListing.php new file mode 100644 index 00000000..0ada13ec --- /dev/null +++ b/src/Component/EventsListing.php @@ -0,0 +1,128 @@ + */ + private null|array $cachedItems = null; + + public function __construct( + readonly private GetCompetitionEvents $getCompetitionEvents, + readonly private TranslatorInterface $translator, + ) { + } + + /** + * @return array + */ + public function getItems(): array + { + if ($this->cachedItems !== null) { + return $this->cachedItems; + } + + $allowedPeriods = ['all', 'live', 'upcoming', 'past']; + $timePeriod = in_array($this->timePeriod, $allowedPeriods, true) ? $this->timePeriod : 'all'; + + $this->cachedItems = $this->getCompetitionEvents->search( + timePeriod: $timePeriod, + onlineOnly: $this->onlineOnly, + country: $this->country !== '' ? $this->country : null, + ); + + return $this->cachedItems; + } + + /** + * @return array> + */ + public function getCountryChoicesGroupedByRegion(): array + { + $centralEurope = [ + CountryCode::cz, CountryCode::sk, CountryCode::pl, CountryCode::hu, + CountryCode::at, CountryCode::si, CountryCode::ch, CountryCode::li, + ]; + + $westernEurope = [ + CountryCode::de, CountryCode::fr, CountryCode::nl, CountryCode::be, + CountryCode::lu, CountryCode::ie, CountryCode::gb, CountryCode::mc, + ]; + + $southernEurope = [ + CountryCode::es, CountryCode::pt, CountryCode::it, CountryCode::gr, + CountryCode::hr, CountryCode::ba, CountryCode::rs, CountryCode::me, + CountryCode::mk, CountryCode::al, CountryCode::mt, CountryCode::cy, + ]; + + $northernEurope = [ + CountryCode::se, CountryCode::no, CountryCode::dk, CountryCode::fi, + CountryCode::is, CountryCode::ee, CountryCode::lv, CountryCode::lt, + ]; + + $easternEurope = [ + CountryCode::ro, CountryCode::bg, CountryCode::ua, CountryCode::md, + CountryCode::by, + ]; + + $northAmerica = [ + CountryCode::us, CountryCode::ca, CountryCode::mx, + ]; + + $groups = [ + $this->translator->trans('sell_swap_list.settings.region.central_europe') => $centralEurope, + $this->translator->trans('sell_swap_list.settings.region.western_europe') => $westernEurope, + $this->translator->trans('sell_swap_list.settings.region.southern_europe') => $southernEurope, + $this->translator->trans('sell_swap_list.settings.region.northern_europe') => $northernEurope, + $this->translator->trans('sell_swap_list.settings.region.eastern_europe') => $easternEurope, + $this->translator->trans('sell_swap_list.settings.region.north_america') => $northAmerica, + ]; + + $usedCodes = []; + foreach ($groups as $countries) { + foreach ($countries as $country) { + $usedCodes[] = $country->name; + } + } + + $restOfWorld = []; + foreach (CountryCode::cases() as $country) { + if (!in_array($country->name, $usedCodes, true)) { + $restOfWorld[] = $country; + } + } + + $groups[$this->translator->trans('sell_swap_list.settings.region.rest_of_world')] = $restOfWorld; + + $choices = []; + foreach ($groups as $groupName => $countries) { + foreach ($countries as $country) { + $choices[$groupName][$country->name] = $country->value; + } + } + + return $choices; + } +} diff --git a/src/Controller/AddCompetitionController.php b/src/Controller/AddCompetitionController.php index df218971..5b5f464a 100644 --- a/src/Controller/AddCompetitionController.php +++ b/src/Controller/AddCompetitionController.php @@ -65,12 +65,12 @@ public function __invoke(Request $request, #[CurrentUser] User $user): Response link: $data->link, registrationLink: $data->registrationLink, resultsLink: $data->resultsLink, - location: $data->location ?? '', + location: $data->isOnline === true ? null : $data->location, locationCountryCode: $data->locationCountryCode, - dateFrom: $data->dateFrom, - dateTo: $data->dateTo, - isOnline: $data->isOnline, - isRecurring: $data->isRecurring, + dateFrom: $data->isOnline === true ? null : $data->dateFrom, + dateTo: $data->isOnline === true ? null : $data->dateTo, + isOnline: $data->isOnline === true, + isRecurring: $data->isOnline === true ? $data->isRecurring : false, logo: $data->logo, maintainerIds: $data->maintainers, )); diff --git a/src/Controller/AddCompetitionRoundController.php b/src/Controller/AddCompetitionRoundController.php index ece39895..df23bde2 100644 --- a/src/Controller/AddCompetitionRoundController.php +++ b/src/Controller/AddCompetitionRoundController.php @@ -4,6 +4,8 @@ namespace SpeedPuzzling\Web\Controller; +use DateTimeImmutable; +use DateTimeZone; use Ramsey\Uuid\Uuid; use SpeedPuzzling\Web\FormData\CompetitionRoundFormData; use SpeedPuzzling\Web\FormType\CompetitionRoundFormType; @@ -45,22 +47,50 @@ public function __invoke(Request $request, string $competitionId): Response $competition = $this->getCompetitionEvents->byId($competitionId); + $isSingleDay = $competition->dateFrom !== null + && $competition->dateTo !== null + && $competition->dateFrom->format('Y-m-d') === $competition->dateTo->format('Y-m-d'); + $formData = new CompetitionRoundFormData(); - $form = $this->createForm(CompetitionRoundFormType::class, $formData); + $form = $this->createForm(CompetitionRoundFormType::class, $formData, [ + 'date_from' => $competition->dateFrom, + 'date_to' => $competition->dateTo, + 'country_code' => $competition->locationCountryCode?->name, + ]); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $data = $form->getData(); assert($data->name !== null); assert($data->minutesLimit !== null); - assert($data->startsAt !== null); + + /** @var string $timezone */ + $timezone = $form->get('timezone')->getData(); + $tz = new DateTimeZone($timezone); + + if ($isSingleDay) { + /** @var string $time */ + $time = $form->get('startsAtTime')->getData(); + $localDateTime = new DateTimeImmutable( + $competition->dateFrom->format('Y-m-d') . ' ' . $time, + $tz, + ); + $startsAt = $localDateTime->setTimezone(new DateTimeZone('UTC')); + } else { + assert($data->startsAt !== null); + $localDateTime = new DateTimeImmutable( + $data->startsAt->format('Y-m-d H:i:s'), + $tz, + ); + $startsAt = $localDateTime->setTimezone(new DateTimeZone('UTC')); + } $this->messageBus->dispatch(new AddCompetitionRound( roundId: Uuid::uuid7(), competitionId: $competitionId, name: $data->name, minutesLimit: $data->minutesLimit, - startsAt: $data->startsAt, + startsAt: $startsAt, badgeBackgroundColor: $data->badgeBackgroundColor, badgeTextColor: $data->badgeTextColor, )); diff --git a/src/Controller/CompetitionAutocompleteController.php b/src/Controller/CompetitionAutocompleteController.php index b424411f..2b879bd3 100644 --- a/src/Controller/CompetitionAutocompleteController.php +++ b/src/Controller/CompetitionAutocompleteController.php @@ -56,7 +56,7 @@ public function __invoke(Request $request): Response $location = ''; } - $location .= $competition->location; + $location .= $competition->location ?? ''; $html = << diff --git a/src/Controller/EditCompetitionController.php b/src/Controller/EditCompetitionController.php index b2785196..a2ce8359 100644 --- a/src/Controller/EditCompetitionController.php +++ b/src/Controller/EditCompetitionController.php @@ -62,12 +62,12 @@ public function __invoke(Request $request, string $competitionId): Response link: $data->link, registrationLink: $data->registrationLink, resultsLink: $data->resultsLink, - location: $data->location ?? '', + location: $data->isOnline === true ? null : $data->location, locationCountryCode: $data->locationCountryCode, - dateFrom: $data->dateFrom, - dateTo: $data->dateTo, - isOnline: $data->isOnline, - isRecurring: $data->isRecurring, + dateFrom: $data->isOnline === true ? null : $data->dateFrom, + dateTo: $data->isOnline === true ? null : $data->dateTo, + isOnline: $data->isOnline === true, + isRecurring: $data->isOnline === true ? $data->isRecurring : false, logo: $data->logo, maintainerIds: $data->maintainers, )); diff --git a/src/Controller/EditCompetitionRoundController.php b/src/Controller/EditCompetitionRoundController.php index bba92b3c..bd8251f8 100644 --- a/src/Controller/EditCompetitionRoundController.php +++ b/src/Controller/EditCompetitionRoundController.php @@ -4,6 +4,8 @@ namespace SpeedPuzzling\Web\Controller; +use DateTimeImmutable; +use DateTimeZone; use SpeedPuzzling\Web\FormData\CompetitionRoundFormData; use SpeedPuzzling\Web\FormType\CompetitionRoundFormType; use SpeedPuzzling\Web\Message\EditCompetitionRound; @@ -48,21 +50,54 @@ public function __invoke(Request $request, string $roundId): Response $competition = $this->getCompetitionEvents->byId($competitionId); + $isSingleDay = $competition->dateFrom !== null + && $competition->dateTo !== null + && $competition->dateFrom->format('Y-m-d') === $competition->dateTo->format('Y-m-d'); + $formData = CompetitionRoundFormData::fromCompetitionRound($round); - $form = $this->createForm(CompetitionRoundFormType::class, $formData); + $form = $this->createForm(CompetitionRoundFormType::class, $formData, [ + 'date_from' => $competition->dateFrom, + 'date_to' => $competition->dateTo, + 'country_code' => $competition->locationCountryCode?->name, + ]); + + if ($isSingleDay) { + $form->get('startsAtTime')->setData($round->startsAt->format('H:i')); + } + $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $data = $form->getData(); assert($data->name !== null); assert($data->minutesLimit !== null); - assert($data->startsAt !== null); + + /** @var string $timezone */ + $timezone = $form->get('timezone')->getData(); + $tz = new DateTimeZone($timezone); + + if ($isSingleDay) { + /** @var string $time */ + $time = $form->get('startsAtTime')->getData(); + $localDateTime = new DateTimeImmutable( + $competition->dateFrom->format('Y-m-d') . ' ' . $time, + $tz, + ); + $startsAt = $localDateTime->setTimezone(new DateTimeZone('UTC')); + } else { + assert($data->startsAt !== null); + $localDateTime = new DateTimeImmutable( + $data->startsAt->format('Y-m-d H:i:s'), + $tz, + ); + $startsAt = $localDateTime->setTimezone(new DateTimeZone('UTC')); + } $this->messageBus->dispatch(new EditCompetitionRound( roundId: $roundId, name: $data->name, minutesLimit: $data->minutesLimit, - startsAt: $data->startsAt, + startsAt: $startsAt, badgeBackgroundColor: $data->badgeBackgroundColor, badgeTextColor: $data->badgeTextColor, )); diff --git a/src/Controller/EventsController.php b/src/Controller/EventsController.php index 1f8ce998..65637952 100644 --- a/src/Controller/EventsController.php +++ b/src/Controller/EventsController.php @@ -39,10 +39,6 @@ public function __invoke(): Response } return $this->render('events.html.twig', [ - 'live_events' => $this->getCompetitionEvents->allLive(), - 'upcoming_events' => $this->getCompetitionEvents->allUpcoming(), - 'recurring_events' => $this->getCompetitionEvents->allRecurring(), - 'past_events' => $this->getCompetitionEvents->allPast(), 'player_competitions' => $playerCompetitions, ]); } diff --git a/src/Controller/PlayerSearchAutocompleteController.php b/src/Controller/PlayerSearchAutocompleteController.php index ce101483..222a0ee1 100644 --- a/src/Controller/PlayerSearchAutocompleteController.php +++ b/src/Controller/PlayerSearchAutocompleteController.php @@ -5,6 +5,7 @@ namespace SpeedPuzzling\Web\Controller; use SpeedPuzzling\Web\Query\SearchPlayers; +use SpeedPuzzling\Web\Twig\ImageThumbnailTwigExtension; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -16,6 +17,7 @@ final class PlayerSearchAutocompleteController extends AbstractController { public function __construct( private readonly SearchPlayers $searchPlayers, + private readonly ImageThumbnailTwigExtension $imageThumbnail, ) { } @@ -35,15 +37,31 @@ public function __invoke(Request $request): JsonResponse $results = []; foreach ($players as $player) { - $label = $player->playerName ?? $player->playerCode; + $name = htmlspecialchars($player->playerName ?? $player->playerCode); + $code = htmlspecialchars($player->playerCode); + $avatar = ''; + if ($player->playerAvatar !== null) { + $avatarUrl = htmlspecialchars($this->imageThumbnail->thumbnailUrl($player->playerAvatar, 'puzzle_small')); + $avatar = << +HTML; + } else { + $avatar = ''; + } + + $flag = ''; if ($player->playerCountry !== null) { - $label .= " ({$player->playerCountry->name})"; + $flag = ' '; } + $html = <<{$avatar}{$flag}{$name}#{$code}

    +HTML; + $results[] = [ 'value' => $player->playerId, - 'text' => $label, + 'text' => $html, ]; } diff --git a/src/Entity/Competition.php b/src/Entity/Competition.php index de9e66df..7f952f97 100644 --- a/src/Entity/Competition.php +++ b/src/Entity/Competition.php @@ -45,8 +45,8 @@ public function __construct( public null|string $registrationLink, #[Column(nullable: true)] public null|string $resultsLink, - #[Column] - public string $location, + #[Column(nullable: true)] + public null|string $location, #[Column(nullable: true)] public null|string $locationCountryCode, #[Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] @@ -116,7 +116,7 @@ public function edit( null|string $link, null|string $registrationLink, null|string $resultsLink, - string $location, + null|string $location, null|string $locationCountryCode, null|DateTimeImmutable $dateFrom, null|DateTimeImmutable $dateTo, diff --git a/src/FormData/CompetitionFormData.php b/src/FormData/CompetitionFormData.php index 185a3267..4c50ceb7 100644 --- a/src/FormData/CompetitionFormData.php +++ b/src/FormData/CompetitionFormData.php @@ -8,7 +8,9 @@ use SpeedPuzzling\Web\Entity\Competition; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Context\ExecutionContextInterface; +#[Assert\Callback('validateOfflineFields')] final class CompetitionFormData { public function __construct( @@ -22,12 +24,12 @@ public function __construct( public null|string $registrationLink = null, #[Assert\Url] public null|string $resultsLink = null, - #[Assert\NotBlank] public null|string $location = null, public null|string $locationCountryCode = null, public null|DateTimeImmutable $dateFrom = null, public null|DateTimeImmutable $dateTo = null, - public bool $isOnline = false, + #[Assert\NotNull] + public null|bool $isOnline = null, public bool $isRecurring = false, public null|UploadedFile $logo = null, /** @var array */ @@ -35,6 +37,31 @@ public function __construct( ) { } + public function validateOfflineFields(ExecutionContextInterface $context): void + { + if ($this->isOnline !== false) { + return; + } + + if ($this->location === null || $this->location === '') { + $context->buildViolation('This value should not be blank.') + ->atPath('location') + ->addViolation(); + } + + if ($this->dateFrom === null) { + $context->buildViolation('This value should not be blank.') + ->atPath('dateFrom') + ->addViolation(); + } + + if ($this->dateTo === null) { + $context->buildViolation('This value should not be blank.') + ->atPath('dateTo') + ->addViolation(); + } + } + public static function fromCompetition(Competition $competition): self { $data = new self(); diff --git a/src/FormData/CompetitionRoundFormData.php b/src/FormData/CompetitionRoundFormData.php index 4de76ed6..1712ff75 100644 --- a/src/FormData/CompetitionRoundFormData.php +++ b/src/FormData/CompetitionRoundFormData.php @@ -17,8 +17,8 @@ public function __construct( public null|int $minutesLimit = null, #[Assert\NotNull] public null|DateTimeImmutable $startsAt = null, - public null|string $badgeBackgroundColor = null, - public null|string $badgeTextColor = null, + public null|string $badgeBackgroundColor = '#fe696a', + public null|string $badgeTextColor = '#ffffff', ) { } diff --git a/src/FormType/CompetitionFormType.php b/src/FormType/CompetitionFormType.php index 78a28d31..38f15801 100644 --- a/src/FormType/CompetitionFormType.php +++ b/src/FormType/CompetitionFormType.php @@ -4,9 +4,13 @@ namespace SpeedPuzzling\Web\FormType; +use Doctrine\DBAL\ArrayParameterType; +use Doctrine\DBAL\Connection; use SpeedPuzzling\Web\FormData\CompetitionFormData; +use SpeedPuzzling\Web\Twig\ImageThumbnailTwigExtension; use SpeedPuzzling\Web\Value\CountryCode; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\CallbackTransformer; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\DateType; @@ -15,7 +19,6 @@ use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; -use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Validator\Constraints\Image; use Symfony\Contracts\Translation\TranslatorInterface; @@ -26,7 +29,8 @@ final class CompetitionFormType extends AbstractType { public function __construct( private readonly TranslatorInterface $translator, - private readonly UrlGeneratorInterface $urlGenerator, + private readonly Connection $database, + private readonly ImageThumbnailTwigExtension $imageThumbnail, ) { } @@ -65,6 +69,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $builder->add('location', TextType::class, [ 'label' => 'competition.form.location', + 'required' => false, ]); $allCountries = []; @@ -98,24 +103,41 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'choices' => $countries, 'required' => false, 'placeholder' => 'competition.form.country_placeholder', + 'autocomplete' => true, + 'choice_attr' => static function (string $countryCode): array { + return ['data-icon' => 'fi fi-' . $countryCode]; + }, ]); $builder->add('dateFrom', DateType::class, [ 'label' => 'competition.form.date_from', - 'widget' => 'single_text', 'required' => false, + 'widget' => 'single_text', + 'format' => 'dd.MM.yyyy', + 'html5' => false, + 'input' => 'datetime_immutable', + 'input_format' => 'd.m.Y', 'help' => 'competition.form.dates_help', ]); $builder->add('dateTo', DateType::class, [ 'label' => 'competition.form.date_to', - 'widget' => 'single_text', 'required' => false, + 'widget' => 'single_text', + 'format' => 'dd.MM.yyyy', + 'html5' => false, + 'input' => 'datetime_immutable', + 'input_format' => 'd.m.Y', ]); - $builder->add('isOnline', CheckboxType::class, [ - 'label' => 'competition.form.is_online', - 'required' => false, + $builder->add('isOnline', ChoiceType::class, [ + 'label' => 'competition.form.event_type', + 'choices' => [ + 'competition.form.event_type_offline' => false, + 'competition.form.event_type_online' => true, + ], + 'expanded' => true, + 'placeholder' => false, ]); $builder->add('isRecurring', CheckboxType::class, [ @@ -139,8 +161,25 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $data = $builder->getData(); $maintainerChoices = []; - foreach ($data->maintainers as $playerId) { - $maintainerChoices[$playerId] = $playerId; + if ($data->maintainers !== []) { + $rows = $this->database->executeQuery( + 'SELECT id, name, code, country, avatar FROM player WHERE id IN (?)', + [$data->maintainers], + [ArrayParameterType::STRING], + )->fetchAllAssociative(); + + foreach ($rows as $row) { + /** @var array{id: string, name: null|string, code: string, country: null|string, avatar: null|string} $row */ + $maintainerChoices[] = [ + 'value' => $row['id'], + 'text' => $this->buildPlayerOptionHtml( + $row['name'] ?? $row['code'], + $row['code'], + $row['country'], + $row['avatar'], + ), + ]; + } } $builder->add('maintainers', TextType::class, [ @@ -148,7 +187,6 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'help' => 'competition.form.maintainers_help', 'required' => false, 'autocomplete' => true, - 'options_as_html' => true, 'tom_select_options' => [ 'create' => false, 'persist' => false, @@ -156,10 +194,24 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'options' => $maintainerChoices, 'closeAfterSelect' => true, ], - 'attr' => [ - 'data-fetch-url' => $this->urlGenerator->generate('player_search_autocomplete', ['_locale' => 'en']), - ], ]); + + $builder->get('maintainers')->addModelTransformer(new CallbackTransformer( + function (null|array $value): string { + if ($value === null || $value === []) { + return ''; + } + + return implode(',', $value); + }, + function (null|string $value): array { + if ($value === null || $value === '') { + return []; + } + + return array_filter(explode(',', $value)); + }, + )); } public function configureOptions(OptionsResolver $resolver): void @@ -168,4 +220,33 @@ public function configureOptions(OptionsResolver $resolver): void 'data_class' => CompetitionFormData::class, ]); } + + private function buildPlayerOptionHtml( + string $name, + string $code, + null|string $country, + null|string $avatar, + ): string { + $name = htmlspecialchars($name); + $code = htmlspecialchars($code); + + if ($avatar !== null) { + $avatarUrl = htmlspecialchars($this->imageThumbnail->thumbnailUrl($avatar, 'puzzle_small')); + $avatarHtml = << +HTML; + } else { + $avatarHtml = ''; + } + + $flagHtml = ''; + $countryCode = CountryCode::fromCode($country); + if ($countryCode !== null) { + $flagHtml = ' '; + } + + return <<{$avatarHtml}{$flagHtml}{$name}#{$code}
    +HTML; + } } diff --git a/src/FormType/CompetitionRoundFormType.php b/src/FormType/CompetitionRoundFormType.php index d1c2cdb8..abf009d0 100644 --- a/src/FormType/CompetitionRoundFormType.php +++ b/src/FormType/CompetitionRoundFormType.php @@ -4,12 +4,14 @@ namespace SpeedPuzzling\Web\FormType; +use DateTimeImmutable; use SpeedPuzzling\Web\FormData\CompetitionRoundFormData; +use SpeedPuzzling\Web\Value\CountryCode; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\ColorType; use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\Extension\Core\Type\NumberType; use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\Extension\Core\Type\TimezoneType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -20,6 +22,17 @@ final class CompetitionRoundFormType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { + /** @var null|DateTimeImmutable $dateFrom */ + $dateFrom = $options['date_from']; + /** @var null|DateTimeImmutable $dateTo */ + $dateTo = $options['date_to']; + /** @var null|string $countryCode */ + $countryCode = $options['country_code']; + + $isSingleDay = $dateFrom !== null + && $dateTo !== null + && $dateFrom->format('Y-m-d') === $dateTo->format('Y-m-d'); + $builder->add('name', TextType::class, [ 'label' => 'competition.round.form.name', ]); @@ -28,19 +41,53 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'label' => 'competition.round.form.minutes_limit', ]); - $builder->add('startsAt', DateTimeType::class, [ - 'label' => 'competition.round.form.starts_at', - 'widget' => 'single_text', + if ($isSingleDay) { + $builder->add('startsAtTime', TextType::class, [ + 'label' => 'competition.round.form.starts_at_time', + 'mapped' => false, + ]); + } else { + $builder->add('startsAt', DateTimeType::class, [ + 'label' => 'competition.round.form.starts_at', + 'widget' => 'single_text', + 'html5' => false, + 'format' => 'dd.MM.yyyy HH:mm', + 'input' => 'datetime_immutable', + 'input_format' => 'd.m.Y H:i', + ]); + } + + $builder->add('timezone', TimezoneType::class, [ + 'label' => 'competition.round.form.timezone', + 'help' => 'competition.round.form.timezone_help', + 'mapped' => false, + 'data' => CountryCode::fromCode($countryCode)?->defaultTimezone() ?? 'Europe/Prague', + 'autocomplete' => true, + 'choice_label' => static function (string $timezone): string { + $tz = new \DateTimeZone($timezone); + $offset = $tz->getOffset(new \DateTimeImmutable('now', $tz)); + $hours = intdiv($offset, 3600); + $minutes = abs(intdiv($offset % 3600, 60)); + $utcOffset = sprintf('UTC%+d', $hours) . ($minutes > 0 ? sprintf(':%02d', $minutes) : ''); + + return $timezone . ' (' . $utcOffset . ')'; + }, ]); - $builder->add('badgeBackgroundColor', ColorType::class, [ + $builder->add('badgeBackgroundColor', TextType::class, [ 'label' => 'competition.round.form.badge_background_color', - 'required' => false, + 'empty_data' => '#fe696a', + 'attr' => [ + 'data-controller' => 'colorpicker', + ], ]); - $builder->add('badgeTextColor', ColorType::class, [ + $builder->add('badgeTextColor', TextType::class, [ 'label' => 'competition.round.form.badge_text_color', - 'required' => false, + 'empty_data' => '#ffffff', + 'attr' => [ + 'data-controller' => 'colorpicker', + ], ]); } @@ -48,6 +95,13 @@ public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'data_class' => CompetitionRoundFormData::class, + 'date_from' => null, + 'date_to' => null, + 'country_code' => null, ]); + + $resolver->setAllowedTypes('date_from', ['null', DateTimeImmutable::class]); + $resolver->setAllowedTypes('date_to', ['null', DateTimeImmutable::class]); + $resolver->setAllowedTypes('country_code', ['null', 'string']); } } diff --git a/src/FormType/EditPuzzleSolvingTimeFormType.php b/src/FormType/EditPuzzleSolvingTimeFormType.php index a234aca5..22911d84 100644 --- a/src/FormType/EditPuzzleSolvingTimeFormType.php +++ b/src/FormType/EditPuzzleSolvingTimeFormType.php @@ -335,7 +335,7 @@ public function getCompetitionsAutocompleteData(): array $location = ''; } - $location .= $competition->location; + $location .= $competition->location ?? ''; $html = << diff --git a/src/FormType/PuzzleAddFormType.php b/src/FormType/PuzzleAddFormType.php index 268e1282..9b468cf6 100644 --- a/src/FormType/PuzzleAddFormType.php +++ b/src/FormType/PuzzleAddFormType.php @@ -426,7 +426,7 @@ public function getCompetitionsAutocompleteData(): array $location = ''; } - $location .= $competition->location; + $location .= $competition->location ?? ''; $html = << diff --git a/src/FormType/PuzzleSolvingTimeFormType.php b/src/FormType/PuzzleSolvingTimeFormType.php index 0bdcc8f0..0827b446 100644 --- a/src/FormType/PuzzleSolvingTimeFormType.php +++ b/src/FormType/PuzzleSolvingTimeFormType.php @@ -283,7 +283,7 @@ public function getCompetitionsAutocompleteData(): array $location = ''; } - $location .= $competition->location; + $location .= $competition->location ?? ''; $html = << diff --git a/src/Message/AddCompetition.php b/src/Message/AddCompetition.php index 57131c72..0aa753ef 100644 --- a/src/Message/AddCompetition.php +++ b/src/Message/AddCompetition.php @@ -22,7 +22,7 @@ public function __construct( public null|string $link, public null|string $registrationLink, public null|string $resultsLink, - public string $location, + public null|string $location, public null|string $locationCountryCode, public null|DateTimeImmutable $dateFrom, public null|DateTimeImmutable $dateTo, diff --git a/src/Message/EditCompetition.php b/src/Message/EditCompetition.php index d647b3d1..83d4c78f 100644 --- a/src/Message/EditCompetition.php +++ b/src/Message/EditCompetition.php @@ -20,7 +20,7 @@ public function __construct( public null|string $link, public null|string $registrationLink, public null|string $resultsLink, - public string $location, + public null|string $location, public null|string $locationCountryCode, public null|DateTimeImmutable $dateFrom, public null|DateTimeImmutable $dateTo, diff --git a/src/Query/GetCompetitionEvents.php b/src/Query/GetCompetitionEvents.php index 9ff80dfe..2078507f 100644 --- a/src/Query/GetCompetitionEvents.php +++ b/src/Query/GetCompetitionEvents.php @@ -43,6 +43,91 @@ public function byId(string $competitionId): CompetitionEvent return CompetitionEvent::fromDatabaseRow($data); } + /** + * @return array + */ + public function search( + string $timePeriod = 'all', + bool $onlineOnly = false, + null|string $country = null, + ): array { + $date = $this->clock->now()->format('Y-m-d'); + $params = ['date' => $date]; + + $cte = <<<'SQL' + WITH event_classified AS ( + SELECT c.*, + CASE + WHEN NOT c.is_recurring + AND c.date_from IS NOT NULL + AND :date::date BETWEEN COALESCE(c.date_from, c.date_to)::date AND COALESCE(c.date_to, c.date_from)::date + THEN 'live' + WHEN NOT c.is_recurring + AND COALESCE(c.date_from, c.date_to)::date > :date::date + THEN 'upcoming' + WHEN NOT c.is_recurring + THEN 'past' + WHEN c.is_recurring + AND EXISTS (SELECT 1 FROM competition_round cr WHERE cr.competition_id = c.id AND cr.starts_at::date = :date::date) + THEN 'live' + WHEN c.is_recurring + AND EXISTS (SELECT 1 FROM competition_round cr WHERE cr.competition_id = c.id AND cr.starts_at::date > :date::date) + THEN 'upcoming' + ELSE 'past' + END AS event_status, + CASE + WHEN NOT c.is_recurring THEN COALESCE(c.date_from, c.date_to) + ELSE COALESCE( + (SELECT MIN(cr.starts_at) FROM competition_round cr WHERE cr.competition_id = c.id AND cr.starts_at::date >= :date::date), + (SELECT MAX(cr.starts_at) FROM competition_round cr WHERE cr.competition_id = c.id) + ) + END AS sort_date + FROM competition c + WHERE c.approved_at IS NOT NULL + ) + SQL; + + $whereClauses = []; + + if (in_array($timePeriod, ['live', 'upcoming', 'past'], true)) { + $whereClauses[] = 'event_status = :status'; + $params['status'] = $timePeriod; + } + + if ($onlineOnly) { + $whereClauses[] = 'is_online = true'; + } + + if ($country !== null) { + $whereClauses[] = 'location_country_code = :country'; + $params['country'] = $country; + } + + $where = count($whereClauses) > 0 ? 'WHERE ' . implode(' AND ', $whereClauses) : ''; + + $orderBy = match ($timePeriod) { + 'live' => 'sort_date ASC NULLS LAST', + 'upcoming' => 'sort_date ASC NULLS LAST', + 'past' => 'sort_date DESC NULLS LAST', + default => <<<'SQL' + CASE event_status WHEN 'live' THEN 1 WHEN 'upcoming' THEN 2 WHEN 'past' THEN 3 END ASC, + CASE WHEN event_status != 'past' THEN sort_date END ASC NULLS LAST, + CASE WHEN event_status = 'past' THEN sort_date END DESC NULLS LAST + SQL, + }; + + $query = "{$cte} SELECT * FROM event_classified {$where} ORDER BY {$orderBy}"; + + $data = $this->database + ->executeQuery($query, $params) + ->fetchAllAssociative(); + + return array_map(static function (array $row): CompetitionEvent { + /** @var CompetitionEventDatabaseRow $row */ + return CompetitionEvent::fromDatabaseRow($row); + }, $data); + } + /** * @return array */ diff --git a/src/Query/SearchPlayers.php b/src/Query/SearchPlayers.php index bb28fc0e..8098e0ea 100644 --- a/src/Query/SearchPlayers.php +++ b/src/Query/SearchPlayers.php @@ -25,6 +25,7 @@ public function fulltext(string $search, null|int $limit = null): array name AS player_name, country AS player_country, code AS player_code, + avatar AS player_avatar, ( CASE WHEN LOWER(code) = LOWER(:searchQuery) THEN 6 -- Exact match on code with diacritics diff --git a/src/Results/CompetitionEvent.php b/src/Results/CompetitionEvent.php index dfe3c4a7..0c2d556e 100644 --- a/src/Results/CompetitionEvent.php +++ b/src/Results/CompetitionEvent.php @@ -12,7 +12,7 @@ * id: string, * name: string, * shortcut: null|string, - * location: string, + * location: null|string, * location_country_code: null|string, * date_from: null|string, * date_to: null|string, @@ -47,7 +47,7 @@ public function __construct( null|string $link, null|string $registrationLink, null|string $resultsLink, - public string $location, + public null|string $location, public null|CountryCode $locationCountryCode, public null|DateTimeImmutable $dateFrom, public null|DateTimeImmutable $dateTo, diff --git a/src/Results/PlayerIdentification.php b/src/Results/PlayerIdentification.php index 3a9b278c..71355ca1 100644 --- a/src/Results/PlayerIdentification.php +++ b/src/Results/PlayerIdentification.php @@ -13,6 +13,7 @@ public function __construct( public string $playerCode, public null|string $playerName, public null|CountryCode $playerCountry, + public null|string $playerAvatar = null, ) { } @@ -22,6 +23,7 @@ public function __construct( * player_code: string, * player_name: null|string, * player_country: null|string, + * player_avatar?: null|string, * } $row */ public static function fromDatabaseRow(array $row): self @@ -31,6 +33,7 @@ public static function fromDatabaseRow(array $row): self playerCode: $row['player_code'], playerName: $row['player_name'], playerCountry: CountryCode::fromCode($row['player_country']), + playerAvatar: $row['player_avatar'] ?? null, ); } } diff --git a/src/Value/CountryCode.php b/src/Value/CountryCode.php index 59533d95..5044a3d0 100644 --- a/src/Value/CountryCode.php +++ b/src/Value/CountryCode.php @@ -270,4 +270,81 @@ public static function fromCode(null|string $code): null|self return null; } + + public function defaultTimezone(): string + { + return match ($this) { + self::cz, self::sk => 'Europe/Prague', + self::de => 'Europe/Berlin', + self::at => 'Europe/Vienna', + self::pl => 'Europe/Warsaw', + self::fr, self::mc => 'Europe/Paris', + self::es => 'Europe/Madrid', + self::it, self::sm, self::va => 'Europe/Rome', + self::nl => 'Europe/Amsterdam', + self::be => 'Europe/Brussels', + self::lu => 'Europe/Luxembourg', + self::ch, self::li => 'Europe/Zurich', + self::gb, self::im, self::je, self::gg => 'Europe/London', + self::ie => 'Europe/Dublin', + self::pt => 'Europe/Lisbon', + self::no, self::sj => 'Europe/Oslo', + self::se => 'Europe/Stockholm', + self::dk, self::fo => 'Europe/Copenhagen', + self::fi, self::ax => 'Europe/Helsinki', + self::ee => 'Europe/Tallinn', + self::lv => 'Europe/Riga', + self::lt => 'Europe/Vilnius', + self::hu => 'Europe/Budapest', + self::ro => 'Europe/Bucharest', + self::bg => 'Europe/Sofia', + self::gr, self::cy => 'Europe/Athens', + self::hr => 'Europe/Zagreb', + self::si => 'Europe/Ljubljana', + self::rs => 'Europe/Belgrade', + self::me => 'Europe/Podgorica', + self::ba => 'Europe/Sarajevo', + self::mk => 'Europe/Skopje', + self::al => 'Europe/Tirane', + self::mt => 'Europe/Malta', + self::is => 'Atlantic/Reykjavik', + self::ua => 'Europe/Kyiv', + self::by => 'Europe/Minsk', + self::md => 'Europe/Chisinau', + self::ru => 'Europe/Moscow', + self::tr => 'Europe/Istanbul', + self::il => 'Asia/Jerusalem', + self::us, self::um => 'America/New_York', + self::ca => 'America/Toronto', + self::mx => 'America/Mexico_City', + self::br => 'America/Sao_Paulo', + self::ar => 'America/Argentina/Buenos_Aires', + self::cl => 'America/Santiago', + self::co => 'America/Bogota', + self::pe => 'America/Lima', + self::au, self::nf => 'Australia/Sydney', + self::nz, self::ck => 'Pacific/Auckland', + self::jp => 'Asia/Tokyo', + self::kr => 'Asia/Seoul', + self::cn, self::hk, self::mo => 'Asia/Shanghai', + self::tw => 'Asia/Taipei', + self::sg => 'Asia/Singapore', + self::my => 'Asia/Kuala_Lumpur', + self::th => 'Asia/Bangkok', + self::vn => 'Asia/Ho_Chi_Minh', + self::ph => 'Asia/Manila', + self::id => 'Asia/Jakarta', + self::in => 'Asia/Kolkata', + self::pk => 'Asia/Karachi', + self::bd => 'Asia/Dhaka', + self::ae => 'Asia/Dubai', + self::sa, self::qa, self::bh, self::kw => 'Asia/Riyadh', + self::za => 'Africa/Johannesburg', + self::eg => 'Africa/Cairo', + self::ke => 'Africa/Nairobi', + self::ng => 'Africa/Lagos', + self::ma => 'Africa/Casablanca', + default => 'Europe/Prague', + }; + } } diff --git a/templates/add_competition.html.twig b/templates/add_competition.html.twig index 21abfe56..b95ac576 100644 --- a/templates/add_competition.html.twig +++ b/templates/add_competition.html.twig @@ -22,26 +22,65 @@
    {{ form_start(form) }} - {{ form_row(form.name) }} +
    +
    {{ form_row(form.name) }}
    {{ form_row(form.shortcut) }} {{ form_row(form.description) }} - {{ form_row(form.location) }} - {{ form_row(form.locationCountryCode) }} - {{ form_row(form.isOnline) }} - {{ form_row(form.dateFrom) }} - {{ form_row(form.dateTo) }} +
    + {{ form_row(form.isOnline) }} +
    +
    + {{ form_row(form.isRecurring) }} +
    +
    + {{ form_label(form.location, null, {label_attr: {class: 'form-label required'}}) }} + {{ form_widget(form.location) }} + {{ form_errors(form.location) }} +
    +
    + {{ form_row(form.locationCountryCode) }} +
    + +
    + {{ form_label(form.dateFrom, null, {label_attr: {class: 'form-label required'}}) }} +
    + {{ form_widget(form.dateFrom, {'attr': {'class': 'form-control rounded date-picker pe-5', 'data-datepicker-options': '{"altInput": true, "altFormat": "d.m.Y", "dateFormat": "d.m.Y"}'}}) }} + +
    + {{ form_help(form.dateFrom) }} + {{ form_errors(form.dateFrom) }} +
    + +
    + {{ form_label(form.dateTo, null, {label_attr: {class: 'form-label required'}}) }} +
    + {{ form_widget(form.dateTo, {'attr': {'class': 'form-control rounded date-picker pe-5', 'data-datepicker-options': '{"altInput": true, "altFormat": "d.m.Y", "dateFormat": "d.m.Y"}'}}) }} + +
    + {{ form_errors(form.dateTo) }} +
    + {{ form_row(form.link) }} {{ form_row(form.registrationLink) }} {{ form_row(form.resultsLink) }} {{ form_row(form.logo) }} - {{ form_row(form.maintainers) }} +
    + {{ form_row(form.maintainers) }} +
    + + +
    {{ form_end(form) }}
    +
  • diff --git a/templates/add_competition_round.html.twig b/templates/add_competition_round.html.twig index 2201e76f..8c1302bc 100644 --- a/templates/add_competition_round.html.twig +++ b/templates/add_competition_round.html.twig @@ -3,24 +3,57 @@ {% block title %}{{ 'competition.add_round'|trans }} - {{ competition.name }}{% endblock %} {% block content %} + {{ include('_return_back_button.html.twig', {fallbackUrl: path('manage_competition_rounds', {competitionId: competition.id}), fallbackTitle: competition.isRecurring ? 'competition.manage_editions'|trans : 'competition.manage_rounds'|trans}) }} +

    {{ 'competition.add_round'|trans }}

    {{ competition.name }}

    - - {{ 'competition.back_to_rounds'|trans }} - -
    {{ form_start(form) }} {{ form_row(form.name) }} {{ form_row(form.minutesLimit) }} - {{ form_row(form.startsAt) }} - {{ form_row(form.badgeBackgroundColor) }} - {{ form_row(form.badgeTextColor) }} + + {% set isSingleDay = competition.dateFrom is not null and competition.dateTo is not null and competition.dateFrom|date('Y-m-d') == competition.dateTo|date('Y-m-d') %} + + {% if isSingleDay %} +
    + +
    + {{ competition.dateFrom|date('d.m.Y') }} +
    +
    +
    +
    + {{ form_label(form.startsAtTime) }} +
    + {{ form_widget(form.startsAtTime, {'attr': {'class': 'form-control rounded date-picker pe-5', 'data-datepicker-options': '{"noCalendar": true, "enableTime": true, "time_24hr": true, "dateFormat": "H:i"}'}}) }} + +
    + {{ form_errors(form.startsAtTime) }} +
    +
    + {{ form_row(form.timezone) }} +
    +
    + {% else %} +
    + {{ form_label(form.startsAt) }} +
    + {{ form_widget(form.startsAt, {'attr': {'class': 'form-control rounded date-picker pe-5', 'data-datepicker-options': '{"altInput": true, "altFormat": "d.m.Y H:i", "dateFormat": "d.m.Y H:i", "enableTime": true, "time_24hr": true}'}}) }} + +
    + {{ form_errors(form.startsAt) }} +
    + {{ form_row(form.timezone) }} + {% endif %} +
    +
    {{ form_row(form.badgeBackgroundColor) }}
    +
    {{ form_row(form.badgeTextColor) }}
    +
    +
    {{ form_end(form) }}
    diff --git a/templates/edit_competition_round.html.twig b/templates/edit_competition_round.html.twig index 6013394b..8d5f914d 100644 --- a/templates/edit_competition_round.html.twig +++ b/templates/edit_competition_round.html.twig @@ -3,6 +3,8 @@ {% block title %}{{ 'competition.edit_round'|trans }} - {{ competition.name }}{% endblock %} {% block content %} + {{ include('_return_back_button.html.twig', {fallbackUrl: path('manage_competition_rounds', {competitionId: competition.id}), fallbackTitle: competition.isRecurring ? 'competition.manage_editions'|trans : 'competition.manage_rounds'|trans}) }} +
    @@ -10,10 +12,7 @@

    {{ competition.name }}

    @@ -30,9 +29,44 @@ {{ form_start(form) }} {{ form_row(form.name) }} {{ form_row(form.minutesLimit) }} - {{ form_row(form.startsAt) }} - {{ form_row(form.badgeBackgroundColor) }} - {{ form_row(form.badgeTextColor) }} + + {% set isSingleDay = competition.dateFrom is not null and competition.dateTo is not null and competition.dateFrom|date('Y-m-d') == competition.dateTo|date('Y-m-d') %} + + {% if isSingleDay %} +
    + +
    + {{ competition.dateFrom|date('d.m.Y') }} +
    +
    +
    +
    + {{ form_label(form.startsAtTime) }} +
    + {{ form_widget(form.startsAtTime, {'attr': {'class': 'form-control rounded date-picker pe-5', 'data-datepicker-options': '{"noCalendar": true, "enableTime": true, "time_24hr": true, "dateFormat": "H:i"}'}}) }} + +
    + {{ form_errors(form.startsAtTime) }} +
    +
    + {{ form_row(form.timezone) }} +
    +
    + {% else %} +
    + {{ form_label(form.startsAt) }} +
    + {{ form_widget(form.startsAt, {'attr': {'class': 'form-control rounded date-picker pe-5', 'data-datepicker-options': '{"altInput": true, "altFormat": "d.m.Y H:i", "dateFormat": "d.m.Y H:i", "enableTime": true, "time_24hr": true}'}}) }} + +
    + {{ form_errors(form.startsAt) }} +
    + {{ form_row(form.timezone) }} + {% endif %} +
    +
    {{ form_row(form.badgeBackgroundColor) }}
    +
    {{ form_row(form.badgeTextColor) }}
    +