diff --git a/.claude/fixtures.md b/.claude/fixtures.md index f4e5ffba..dc2b8abc 100644 --- a/.claude/fixtures.md +++ b/.claude/fixtures.md @@ -35,6 +35,9 @@ Most lent puzzles are **owned by `PLAYER_WITH_STRIPE`**: | `LENT_03` | PUZZLE_1000_01 (1000 pcs) | PLAYER_WITH_STRIPE | - | **Returned** | "Returned in good condition" | | `LENT_04` | PUZZLE_500_03 (500 pcs) | PLAYER_WITH_STRIPE | PLAYER_WITH_FAVORITES | Active (passed) | "For testing purposes" | | `LENT_05` | PUZZLE_1500_02 (1500 pcs) | PLAYER_REGULAR | PLAYER_WITH_STRIPE | Active | - | +| `LENT_06` | PUZZLE_3000 (3000 pcs) | PLAYER_REGULAR | PLAYER_WITH_STRIPE | Active | - | +| `LENT_07` | PUZZLE_500_05 (500 pcs) | PLAYER_REGULAR | PLAYER_ADMIN | Active | "Merge test puzzle" | +| `LENT_08` | PUZZLE_500_04 (500 pcs) | PLAYER_REGULAR | PLAYER_WITH_FAVORITES | Active | "Deduplication test puzzle" | ### Transfer History @@ -65,7 +68,7 @@ Most lent puzzles are **owned by `PLAYER_WITH_STRIPE`**: ### Collection Items Distribution -**COLLECTION_PUBLIC** (PLAYER_WITH_STRIPE): PUZZLE_500_01, PUZZLE_500_02, PUZZLE_1000_01, PUZZLE_1000_03, PUZZLE_1000_05, PUZZLE_300, PUZZLE_500_04 +**COLLECTION_PUBLIC** (PLAYER_WITH_STRIPE): PUZZLE_500_01, PUZZLE_500_02, PUZZLE_1000_01, PUZZLE_1000_03, PUZZLE_1000_05, PUZZLE_300, PUZZLE_500_04, PUZZLE_500_05 **COLLECTION_PRIVATE** (PLAYER_REGULAR): PUZZLE_1500_01, PUZZLE_2000, PUZZLE_3000, PUZZLE_1500_02 @@ -105,13 +108,15 @@ Most lent puzzles are **owned by `PLAYER_WITH_STRIPE`**: | `SELLSWAP_05` | PUZZLE_1000_02 | Swap | - | LikeNew | | `SELLSWAP_06` | PUZZLE_1500_01 | Both | 60.00 | MissingPieces | | `SELLSWAP_07` | PUZZLE_1000_03 | Sell | 35.00 | Normal | +| `SELLSWAP_08` | PUZZLE_500_05 | Sell | 20.00 | Normal | (PLAYER_ADMIN) +| `SELLSWAP_09` | PUZZLE_500_04 | Swap | - | LikeNew | (PLAYER_ADMIN) ## Wishlists | Player | Puzzles | |--------|---------| -| PLAYER_REGULAR | PUZZLE_4000, PUZZLE_5000, PUZZLE_6000 | -| PLAYER_WITH_STRIPE | PUZZLE_9000, PUZZLE_3000 | +| PLAYER_REGULAR | PUZZLE_4000, PUZZLE_5000, PUZZLE_6000, PUZZLE_500_05, PUZZLE_500_04 | +| PLAYER_WITH_STRIPE | PUZZLE_9000, PUZZLE_3000, PUZZLE_500_01 | | PLAYER_PRIVATE | PUZZLE_4000 | ## Puzzles (20 total) @@ -214,3 +219,29 @@ Most lent puzzles are **owned by `PLAYER_WITH_STRIPE`**: | Multiple collections | PLAYER_WITH_STRIPE (2), PLAYER_REGULAR (2) | | Puzzle in 3 collections | PLAYER_WITH_STRIPE: PUZZLE_500_02 (system + PUBLIC + STRIPE_TREFL) | | Borrowed + in collection | PLAYER_WITH_STRIPE: PUZZLE_1500_02 (borrowed + in system collection) | + +## Puzzle Merge Testing + +PUZZLE_500_04 (survivor) and PUZZLE_500_05 (duplicate) are set up for puzzle merge testing: + +### Deduplication Scenarios +These scenarios test that when a player has BOTH puzzles, only the survivor entry is kept: + +| Entity Type | Player | Survivor Entry | Duplicate Entry | After Merge | +|-------------|--------|----------------|-----------------|-------------| +| CollectionItem | PLAYER_WITH_STRIPE | ITEM_21 (PUBLIC) | ITEM_27 (PUBLIC) | ITEM_27 removed | +| WishListItem | PLAYER_REGULAR | WISHLIST_09 | WISHLIST_08 | WISHLIST_08 removed | +| SellSwapListItem | PLAYER_ADMIN | SELLSWAP_09 | SELLSWAP_08 | SELLSWAP_08 removed | +| LentPuzzle | PLAYER_REGULAR | LENT_08 | LENT_07 | LENT_07 removed | + +### Migration Scenarios +These entries migrate from duplicate to survivor (no deduplication needed): + +| Entity Type | Entry | Player | +|-------------|-------|--------| +| CollectionItem | ITEM_25 | PLAYER_ADMIN | +| CollectionItem | ITEM_26 | PLAYER_PRIVATE | +| PuzzleSolvingTime | TIME_43 | (all solving times migrate) | +| PuzzleSolvingTime | TIME_44 | (all solving times migrate) | +| SoldSwappedItem | SOLD_01 | (all historical records migrate) | +| SoldSwappedItem | SOLD_02 | (all historical records migrate) | diff --git a/assets/controllers/dynamic_modal_controller.js b/assets/controllers/dynamic_modal_controller.js index 93216732..2fa919ba 100644 --- a/assets/controllers/dynamic_modal_controller.js +++ b/assets/controllers/dynamic_modal_controller.js @@ -5,7 +5,7 @@ import { Modal } from 'bootstrap'; * Dynamic Modal Controller * * Handles a global modal that loads content dynamically via Turbo Frames. - * Opens automatically when the turbo-frame starts fetching content, + * Opens automatically when frame content is loaded (turbo:frame-load event), * closes automatically when the frame becomes empty. * * Usage: @@ -18,7 +18,7 @@ export default class extends Controller { modal = null; observer = null; - openTimeout = null; + pendingOpen = false; connect() { // Disable focus trap to allow interaction with tom-select dropdowns @@ -27,9 +27,12 @@ export default class extends Controller { focus: false }); - // Open modal when frame starts fetching content + // Track when a fetch starts (we want to open modal when content arrives) this.frameTarget.addEventListener('turbo:before-fetch-request', this.handleBeforeFetch); + // Open modal when content arrives + this.frameTarget.addEventListener('turbo:frame-load', this.handleFrameLoad); + // Watch for frame becoming empty (close trigger) this.observer = new MutationObserver(this.handleMutation); this.observer.observe(this.frameTarget, { childList: true, subtree: true }); @@ -43,18 +46,23 @@ export default class extends Controller { disconnect() { this.frameTarget.removeEventListener('turbo:before-fetch-request', this.handleBeforeFetch); + this.frameTarget.removeEventListener('turbo:frame-load', this.handleFrameLoad); this.observer?.disconnect(); document.removeEventListener('keydown', this.handleKeydown); document.removeEventListener('modal:close', this.handleClose); - clearTimeout(this.openTimeout); } handleBeforeFetch = () => { - // Delay modal opening to allow content to load first - clearTimeout(this.openTimeout); - this.openTimeout = setTimeout(() => { + // Mark that we want to open the modal when content arrives + this.pendingOpen = true; + }; + + handleFrameLoad = () => { + // Content has arrived - open modal if we were waiting for it + if (this.pendingOpen) { + this.pendingOpen = false; this.open(); - }, 150); + } }; handleMutation = () => { @@ -75,12 +83,16 @@ export default class extends Controller { }; open() { + // Don't open modal if frame is empty (safety check for edge cases) + if (this.frameTarget.innerHTML.trim() === '') { + return; + } this.modal.show(); document.body.classList.add('modal-open'); } close() { - clearTimeout(this.openTimeout); + this.pendingOpen = false; this.modal.hide(); document.body.classList.remove('modal-open'); this.frameTarget.innerHTML = ''; diff --git a/assets/controllers/report_duplicate_form_controller.js b/assets/controllers/report_duplicate_form_controller.js new file mode 100644 index 00000000..3b1c467e --- /dev/null +++ b/assets/controllers/report_duplicate_form_controller.js @@ -0,0 +1,123 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['manufacturer', 'puzzle']; + + static values = { + currentPuzzleId: String, + }; + + uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + initialize() { + this._onManufacturerConnect = this._onManufacturerConnect.bind(this); + this._onPuzzleConnect = this._onPuzzleConnect.bind(this); + } + + connect() { + this.manufacturerTarget.addEventListener('autocomplete:pre-connect', this._onManufacturerConnect); + this.puzzleTarget.addEventListener('autocomplete:pre-connect', this._onPuzzleConnect); + } + + disconnect() { + this.manufacturerTarget.removeEventListener('autocomplete:pre-connect', this._onManufacturerConnect); + this.puzzleTarget.removeEventListener('autocomplete:pre-connect', this._onPuzzleConnect); + } + + _onManufacturerConnect(event) { + event.detail.options.onChange = (value) => { + this.onManufacturerValueChanged(value); + }; + } + + _onPuzzleConnect(event) { + event.detail.options.onChange = (value) => { + this.onPuzzleValueChanged(value); + }; + + // Initialize puzzle field state after TomSelect is ready + event.detail.options.onInitialize = () => { + this.handleInitialState(); + }; + } + + onManufacturerValueChanged(value) { + const puzzleTom = this.puzzleTarget.tomselect; + if (!puzzleTom) return; + + puzzleTom.clear(); + puzzleTom.clearOptions(); + + if (value && this.uuidRegex.test(value)) { + puzzleTom.enable(); + puzzleTom.settings.placeholder = this.puzzleTarget.dataset.choosePuzzlePlaceholder; + puzzleTom.inputState(); + + this.fetchPuzzleOptions(value); + } else { + this.disablePuzzleField(); + } + } + + onPuzzleValueChanged(value) { + const puzzleTom = this.puzzleTarget.tomselect; + if (value && puzzleTom) { + puzzleTom.blur(); + } + } + + fetchPuzzleOptions(manufacturerId) { + const fetchUrl = this.manufacturerTarget.getAttribute('data-fetch-url'); + const currentPuzzleId = this.currentPuzzleIdValue; + + fetch(`${fetchUrl}?brand=${manufacturerId}`) + .then(response => { + if (!response.ok) { + console.error('Network response was not ok'); + return null; + } + return response.json(); + }) + .then(data => { + if (data && data.results) { + // Filter out current puzzle from options + const filteredResults = data.results.filter( + puzzle => puzzle.value !== currentPuzzleId + ); + this.updatePuzzleSelectValues(filteredResults); + } + }) + .catch(error => { + console.error('Error fetching puzzle options:', error); + }); + } + + updatePuzzleSelectValues(data) { + const puzzleTomSelect = this.puzzleTarget.tomselect; + if (!puzzleTomSelect) return; + + puzzleTomSelect.clearOptions(); + puzzleTomSelect.addOptions(data); + puzzleTomSelect.refreshOptions(true); + } + + handleInitialState() { + // Check if manufacturer already has a value (shouldn't normally happen on fresh form) + const manufacturerValue = this.manufacturerTarget.value; + if (manufacturerValue && this.uuidRegex.test(manufacturerValue)) { + this.fetchPuzzleOptions(manufacturerValue); + } else { + this.disablePuzzleField(); + } + } + + disablePuzzleField() { + const puzzleTomSelect = this.puzzleTarget.tomselect; + if (!puzzleTomSelect) return; + + puzzleTomSelect.clearOptions(); + puzzleTomSelect.disable(); + puzzleTomSelect.settings.placeholder = this.puzzleTarget.dataset.chooseManufacturerPlaceholder; + puzzleTomSelect.inputState(); + } +} diff --git a/config/packages/messenger.php b/config/packages/messenger.php index f76de9c4..34a2fbc4 100644 --- a/config/packages/messenger.php +++ b/config/packages/messenger.php @@ -12,7 +12,10 @@ 'messenger' => [ 'buses' => [ 'command_bus' => [ - 'middleware' => ['doctrine_transaction'], + 'middleware' => [ + 'SpeedPuzzling\Web\Services\MessengerMiddleware\ClearEntityManagerMiddleware', + 'doctrine_transaction', + ], ], ], 'failure_transport' => 'failed', @@ -34,6 +37,12 @@ 'SpeedPuzzling\Web\Events\PuzzleBorrowed' => 'sync', 'SpeedPuzzling\Web\Events\PuzzleAddedToCollection' => 'sync', 'SpeedPuzzling\Web\Events\LendingTransferCompleted' => 'sync', + // Events that must run synchronously for statistics recalculation + 'SpeedPuzzling\Web\Events\PuzzleSolved' => 'sync', + 'SpeedPuzzling\Web\Events\PuzzleSolvingTimeModified' => 'sync', + 'SpeedPuzzling\Web\Events\PuzzleSolvingTimeDeleted' => 'sync', + // Events that must run synchronously for proper transaction ordering + 'SpeedPuzzling\Web\Events\PuzzleMergeApproved' => 'sync', // All other events can run asynchronously 'SpeedPuzzling\Web\Events\*' => 'async', ], diff --git a/migrations/Version20251218233751.php b/migrations/Version20251218233751.php new file mode 100644 index 00000000..a4147bd0 --- /dev/null +++ b/migrations/Version20251218233751.php @@ -0,0 +1,66 @@ +addSql('CREATE TABLE puzzle_change_request (status VARCHAR(255) NOT NULL, reviewed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, rejection_reason TEXT DEFAULT NULL, id UUID NOT NULL, submitted_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, proposed_name VARCHAR(255) DEFAULT NULL, proposed_pieces_count INT DEFAULT NULL, proposed_ean VARCHAR(255) DEFAULT NULL, proposed_identification_number VARCHAR(255) DEFAULT NULL, proposed_image VARCHAR(255) DEFAULT NULL, original_name VARCHAR(255) NOT NULL, original_manufacturer_id UUID DEFAULT NULL, original_pieces_count INT NOT NULL, original_ean VARCHAR(255) DEFAULT NULL, original_identification_number VARCHAR(255) DEFAULT NULL, original_image VARCHAR(255) DEFAULT NULL, reviewed_by_id UUID DEFAULT NULL, puzzle_id UUID NOT NULL, reporter_id UUID NOT NULL, proposed_manufacturer_id UUID DEFAULT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_3668C37CFC6B21F1 ON puzzle_change_request (reviewed_by_id)'); + $this->addSql('CREATE INDEX IDX_3668C37CD9816812 ON puzzle_change_request (puzzle_id)'); + $this->addSql('CREATE INDEX IDX_3668C37CE1CFE6F5 ON puzzle_change_request (reporter_id)'); + $this->addSql('CREATE INDEX IDX_3668C37C4971441F ON puzzle_change_request (proposed_manufacturer_id)'); + $this->addSql('CREATE TABLE puzzle_merge_request (status VARCHAR(255) NOT NULL, reviewed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, rejection_reason TEXT DEFAULT NULL, survivor_puzzle_id UUID DEFAULT NULL, merged_puzzle_ids JSON DEFAULT \'[]\' NOT NULL, id UUID NOT NULL, submitted_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, reported_duplicate_puzzle_ids JSON NOT NULL, reviewed_by_id UUID DEFAULT NULL, source_puzzle_id UUID NOT NULL, reporter_id UUID NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_C53156FBFC6B21F1 ON puzzle_merge_request (reviewed_by_id)'); + $this->addSql('CREATE INDEX IDX_C53156FBB11FFBDC ON puzzle_merge_request (source_puzzle_id)'); + $this->addSql('CREATE INDEX IDX_C53156FBE1CFE6F5 ON puzzle_merge_request (reporter_id)'); + $this->addSql('ALTER TABLE puzzle_change_request ADD CONSTRAINT FK_3668C37CFC6B21F1 FOREIGN KEY (reviewed_by_id) REFERENCES player (id) ON DELETE SET NULL NOT DEFERRABLE'); + $this->addSql('ALTER TABLE puzzle_change_request ADD CONSTRAINT FK_3668C37CD9816812 FOREIGN KEY (puzzle_id) REFERENCES puzzle (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE puzzle_change_request ADD CONSTRAINT FK_3668C37CE1CFE6F5 FOREIGN KEY (reporter_id) REFERENCES player (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE puzzle_change_request ADD CONSTRAINT FK_3668C37C4971441F FOREIGN KEY (proposed_manufacturer_id) REFERENCES manufacturer (id) ON DELETE SET NULL NOT DEFERRABLE'); + $this->addSql('ALTER TABLE puzzle_merge_request ADD CONSTRAINT FK_C53156FBFC6B21F1 FOREIGN KEY (reviewed_by_id) REFERENCES player (id) ON DELETE SET NULL NOT DEFERRABLE'); + $this->addSql('ALTER TABLE puzzle_merge_request ADD CONSTRAINT FK_C53156FBB11FFBDC FOREIGN KEY (source_puzzle_id) REFERENCES puzzle (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE puzzle_merge_request ADD CONSTRAINT FK_C53156FBE1CFE6F5 FOREIGN KEY (reporter_id) REFERENCES player (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE notification ADD target_change_request_id UUID DEFAULT NULL'); + $this->addSql('ALTER TABLE notification ADD target_merge_request_id UUID DEFAULT NULL'); + $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA96566C4C FOREIGN KEY (target_change_request_id) REFERENCES puzzle_change_request (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CAFB320824 FOREIGN KEY (target_merge_request_id) REFERENCES puzzle_merge_request (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('CREATE INDEX IDX_BF5476CA96566C4C ON notification (target_change_request_id)'); + $this->addSql('CREATE INDEX IDX_BF5476CAFB320824 ON notification (target_merge_request_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE puzzle_change_request DROP CONSTRAINT FK_3668C37CFC6B21F1'); + $this->addSql('ALTER TABLE puzzle_change_request DROP CONSTRAINT FK_3668C37CD9816812'); + $this->addSql('ALTER TABLE puzzle_change_request DROP CONSTRAINT FK_3668C37CE1CFE6F5'); + $this->addSql('ALTER TABLE puzzle_change_request DROP CONSTRAINT FK_3668C37C4971441F'); + $this->addSql('ALTER TABLE puzzle_merge_request DROP CONSTRAINT FK_C53156FBFC6B21F1'); + $this->addSql('ALTER TABLE puzzle_merge_request DROP CONSTRAINT FK_C53156FBB11FFBDC'); + $this->addSql('ALTER TABLE puzzle_merge_request DROP CONSTRAINT FK_C53156FBE1CFE6F5'); + $this->addSql('DROP TABLE puzzle_change_request'); + $this->addSql('DROP TABLE puzzle_merge_request'); + $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA96566C4C'); + $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CAFB320824'); + $this->addSql('DROP INDEX IDX_BF5476CA96566C4C'); + $this->addSql('DROP INDEX IDX_BF5476CAFB320824'); + $this->addSql('ALTER TABLE notification DROP target_change_request_id'); + $this->addSql('ALTER TABLE notification DROP target_merge_request_id'); + } +} diff --git a/migrations/Version20260104003437.php b/migrations/Version20260104003437.php new file mode 100644 index 00000000..4efe77f6 --- /dev/null +++ b/migrations/Version20260104003437.php @@ -0,0 +1,52 @@ +addSql('ALTER TABLE puzzle_merge_request ADD source_puzzle_name VARCHAR(255) DEFAULT NULL'); + + // Populate source_puzzle_name from current puzzle data before changing constraints + $this->addSql('UPDATE puzzle_merge_request SET source_puzzle_name = (SELECT p.name FROM puzzle p WHERE p.id = puzzle_merge_request.source_puzzle_id)'); + + // Drop old CASCADE constraints + $this->addSql('ALTER TABLE puzzle_merge_request DROP CONSTRAINT fk_c53156fbb11ffbdc'); + $this->addSql('ALTER TABLE puzzle_merge_request DROP CONSTRAINT fk_c53156fbe1cfe6f5'); + + // Make columns nullable + $this->addSql('ALTER TABLE puzzle_merge_request ALTER source_puzzle_id DROP NOT NULL'); + $this->addSql('ALTER TABLE puzzle_merge_request ALTER reporter_id DROP NOT NULL'); + + // Add new SET NULL constraints + $this->addSql('ALTER TABLE puzzle_merge_request ADD CONSTRAINT FK_C53156FBB11FFBDC FOREIGN KEY (source_puzzle_id) REFERENCES puzzle (id) ON DELETE SET NULL NOT DEFERRABLE'); + $this->addSql('ALTER TABLE puzzle_merge_request ADD CONSTRAINT FK_C53156FBE1CFE6F5 FOREIGN KEY (reporter_id) REFERENCES player (id) ON DELETE SET NULL NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + // This down migration is not safe if there are NULL values in the columns + $this->addSql('ALTER TABLE puzzle_merge_request DROP CONSTRAINT FK_C53156FBB11FFBDC'); + $this->addSql('ALTER TABLE puzzle_merge_request DROP CONSTRAINT FK_C53156FBE1CFE6F5'); + $this->addSql('ALTER TABLE puzzle_merge_request DROP source_puzzle_name'); + $this->addSql('ALTER TABLE puzzle_merge_request ALTER source_puzzle_id SET NOT NULL'); + $this->addSql('ALTER TABLE puzzle_merge_request ALTER reporter_id SET NOT NULL'); + $this->addSql('ALTER TABLE puzzle_merge_request ADD CONSTRAINT fk_c53156fbb11ffbdc FOREIGN KEY (source_puzzle_id) REFERENCES puzzle (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE puzzle_merge_request ADD CONSTRAINT fk_c53156fbe1cfe6f5 FOREIGN KEY (reporter_id) REFERENCES player (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } +} diff --git a/public/img/example-image-not-ok.jpg b/public/img/example-image-not-ok.jpg new file mode 100644 index 00000000..bf8283e8 Binary files /dev/null and b/public/img/example-image-not-ok.jpg differ diff --git a/public/img/example-image-ok.jpg b/public/img/example-image-ok.jpg new file mode 100644 index 00000000..a9528204 Binary files /dev/null and b/public/img/example-image-ok.jpg differ diff --git a/public/img/example-image-perfect.jpg b/public/img/example-image-perfect.jpg new file mode 100644 index 00000000..1ec715c9 Binary files /dev/null and b/public/img/example-image-perfect.jpg differ diff --git a/src/Controller/Admin/ApprovePuzzleChangeRequestController.php b/src/Controller/Admin/ApprovePuzzleChangeRequestController.php new file mode 100644 index 00000000..21c5d1b3 --- /dev/null +++ b/src/Controller/Admin/ApprovePuzzleChangeRequestController.php @@ -0,0 +1,51 @@ +retrieveLoggedUserProfile->getProfile(); + + if ($player === null) { + throw $this->createAccessDeniedException(); + } + + $this->messageBus->dispatch( + new ApprovePuzzleChangeRequest( + changeRequestId: $id, + reviewerId: $player->playerId, + ), + ); + + $this->addFlash('success', $this->translator->trans('admin.puzzle_change_request.approved')); + + return $this->redirectToRoute('admin_puzzle_change_requests'); + } +} diff --git a/src/Controller/Admin/ApprovePuzzleMergeRequestController.php b/src/Controller/Admin/ApprovePuzzleMergeRequestController.php new file mode 100644 index 00000000..44194b0d --- /dev/null +++ b/src/Controller/Admin/ApprovePuzzleMergeRequestController.php @@ -0,0 +1,74 @@ +retrieveLoggedUserProfile->getProfile(); + + if ($player === null) { + throw $this->createAccessDeniedException(); + } + + $survivorPuzzleId = $request->request->getString('survivor_puzzle_id'); + $mergedName = $request->request->getString('merged_name'); + $mergedEan = $request->request->getString('merged_ean'); + $mergedIdentificationNumber = $request->request->getString('merged_identification_number'); + $mergedPiecesCount = $request->request->getInt('merged_pieces_count'); + $mergedManufacturerId = $request->request->getString('merged_manufacturer_id'); + $selectedImagePuzzleId = $request->request->getString('selected_image_puzzle_id'); + + if ($survivorPuzzleId === '' || $mergedName === '' || $mergedPiecesCount === 0) { + $this->addFlash('error', $this->translator->trans('admin.puzzle_merge_request.validation_failed')); + return $this->redirectToRoute('admin_puzzle_merge_request_detail', ['id' => $id]); + } + + $this->messageBus->dispatch( + new ApprovePuzzleMergeRequest( + mergeRequestId: $id, + reviewerId: $player->playerId, + survivorPuzzleId: $survivorPuzzleId, + mergedName: $mergedName, + mergedEan: $mergedEan !== '' ? $mergedEan : null, + mergedIdentificationNumber: $mergedIdentificationNumber !== '' ? $mergedIdentificationNumber : null, + mergedPiecesCount: $mergedPiecesCount, + mergedManufacturerId: $mergedManufacturerId !== '' ? $mergedManufacturerId : null, + selectedImagePuzzleId: $selectedImagePuzzleId !== '' ? $selectedImagePuzzleId : null, + ), + ); + + $this->addFlash('success', $this->translator->trans('admin.puzzle_merge_request.approved')); + + return $this->redirectToRoute('admin_puzzle_merge_requests'); + } +} diff --git a/src/Controller/Admin/PuzzleChangeRequestDetailController.php b/src/Controller/Admin/PuzzleChangeRequestDetailController.php new file mode 100644 index 00000000..39aab089 --- /dev/null +++ b/src/Controller/Admin/PuzzleChangeRequestDetailController.php @@ -0,0 +1,43 @@ +getPuzzleChangeRequests->byId($id); + + if ($request === null) { + throw new PuzzleChangeRequestNotFound(); + } + + return $this->render('admin/puzzle_change_request_detail.html.twig', [ + 'request' => $request, + ]); + } +} diff --git a/src/Controller/Admin/PuzzleChangeRequestsController.php b/src/Controller/Admin/PuzzleChangeRequestsController.php new file mode 100644 index 00000000..8a0fbb7e --- /dev/null +++ b/src/Controller/Admin/PuzzleChangeRequestsController.php @@ -0,0 +1,47 @@ +query->getString('tab', 'pending'); + + $requests = match ($tab) { + 'approved' => $this->getPuzzleChangeRequests->allApproved(), + 'rejected' => $this->getPuzzleChangeRequests->allRejected(), + default => $this->getPuzzleChangeRequests->allPending(), + }; + + return $this->render('admin/puzzle_change_requests.html.twig', [ + 'requests' => $requests, + 'active_tab' => $tab, + 'counts' => $this->getPuzzleChangeRequests->countByStatus(), + ]); + } +} diff --git a/src/Controller/Admin/PuzzleMergeRequestDetailController.php b/src/Controller/Admin/PuzzleMergeRequestDetailController.php new file mode 100644 index 00000000..130cafc4 --- /dev/null +++ b/src/Controller/Admin/PuzzleMergeRequestDetailController.php @@ -0,0 +1,116 @@ +getPuzzleMergeRequests->byId($id); + + if ($request === null) { + throw new PuzzleMergeRequestNotFound(); + } + + // Fetch all duplicate puzzle details + $puzzles = []; + foreach ($request->reportedDuplicatePuzzleIds as $puzzleId) { + try { + $puzzle = $this->getPuzzleOverview->byId($puzzleId); + $puzzles[] = $puzzle; + } catch (\Throwable) { + // Puzzle might have been deleted, skip it + } + } + + // Collect merged values for the form + $mergedData = $this->collectMergedData($puzzles); + + return $this->render('admin/puzzle_merge_request_detail.html.twig', [ + 'request' => $request, + 'puzzles' => $puzzles, + 'merged_data' => $mergedData, + 'manufacturers' => $this->getManufacturers->onlyApprovedOrAddedByPlayer(), + ]); + } + + /** + * @param array $puzzles + * @return array + */ + private function collectMergedData(array $puzzles): array + { + $eans = []; + $identificationNumbers = []; + $pieceCounts = []; + $images = []; + $manufacturers = []; + + // First pass: find the survivor puzzle (the one with most solving times) + $survivorPuzzle = null; + $maxSolvedTimes = -1; + + foreach ($puzzles as $puzzle) { + if ($puzzle->solvedTimes > $maxSolvedTimes) { + $maxSolvedTimes = $puzzle->solvedTimes; + $survivorPuzzle = $puzzle; + } + } + + // Second pass: collect all values for merging + foreach ($puzzles as $puzzle) { + if ($puzzle->puzzleEan !== null && $puzzle->puzzleEan !== '') { + $eans[] = $puzzle->puzzleEan; + } + if ($puzzle->puzzleIdentificationNumber !== null && $puzzle->puzzleIdentificationNumber !== '') { + $identificationNumbers[] = $puzzle->puzzleIdentificationNumber; + } + $pieceCounts[$puzzle->piecesCount] = $puzzle->piecesCount; + if ($puzzle->puzzleImage !== null) { + $images[$puzzle->puzzleId] = $puzzle->puzzleImage; + } + $manufacturers[$puzzle->manufacturerId] = $puzzle->manufacturerName; + } + + return [ + 'name' => $survivorPuzzle !== null ? $survivorPuzzle->puzzleName : '', + 'ean' => implode(', ', array_unique($eans)), + 'identification_number' => implode(', ', array_unique($identificationNumbers)), + 'pieces_counts' => array_values($pieceCounts), + 'pieces_count' => $survivorPuzzle?->piecesCount, + 'images' => $images, + 'manufacturers' => $manufacturers, + 'manufacturer_id' => $survivorPuzzle?->manufacturerId, + 'survivor_puzzle_id' => $survivorPuzzle?->puzzleId, + ]; + } +} diff --git a/src/Controller/Admin/PuzzleMergeRequestsController.php b/src/Controller/Admin/PuzzleMergeRequestsController.php new file mode 100644 index 00000000..91da327a --- /dev/null +++ b/src/Controller/Admin/PuzzleMergeRequestsController.php @@ -0,0 +1,47 @@ +query->getString('tab', 'pending'); + + $requests = match ($tab) { + 'approved' => $this->getPuzzleMergeRequests->allApproved(), + 'rejected' => $this->getPuzzleMergeRequests->allRejected(), + default => $this->getPuzzleMergeRequests->allPending(), + }; + + return $this->render('admin/puzzle_merge_requests.html.twig', [ + 'requests' => $requests, + 'active_tab' => $tab, + 'counts' => $this->getPuzzleMergeRequests->countByStatus(), + ]); + } +} diff --git a/src/Controller/Admin/RejectPuzzleChangeRequestController.php b/src/Controller/Admin/RejectPuzzleChangeRequestController.php new file mode 100644 index 00000000..31739e73 --- /dev/null +++ b/src/Controller/Admin/RejectPuzzleChangeRequestController.php @@ -0,0 +1,62 @@ +retrieveLoggedUserProfile->getProfile(); + + if ($player === null) { + throw $this->createAccessDeniedException(); + } + + $rejectionReason = $request->request->getString('rejection_reason'); + + if ($rejectionReason === '') { + $this->addFlash('error', $this->translator->trans('admin.puzzle_change_request.rejection_reason_required')); + return $this->redirectToRoute('admin_puzzle_change_request_detail', ['id' => $id]); + } + + $this->messageBus->dispatch( + new RejectPuzzleChangeRequest( + changeRequestId: $id, + reviewerId: $player->playerId, + rejectionReason: $rejectionReason, + ), + ); + + $this->addFlash('success', $this->translator->trans('admin.puzzle_change_request.rejected')); + + return $this->redirectToRoute('admin_puzzle_change_requests'); + } +} diff --git a/src/Controller/Admin/RejectPuzzleMergeRequestController.php b/src/Controller/Admin/RejectPuzzleMergeRequestController.php new file mode 100644 index 00000000..9f6f238c --- /dev/null +++ b/src/Controller/Admin/RejectPuzzleMergeRequestController.php @@ -0,0 +1,62 @@ +retrieveLoggedUserProfile->getProfile(); + + if ($player === null) { + throw $this->createAccessDeniedException(); + } + + $rejectionReason = $request->request->getString('rejection_reason'); + + if ($rejectionReason === '') { + $this->addFlash('error', $this->translator->trans('admin.puzzle_merge_request.rejection_reason_required')); + return $this->redirectToRoute('admin_puzzle_merge_request_detail', ['id' => $id]); + } + + $this->messageBus->dispatch( + new RejectPuzzleMergeRequest( + mergeRequestId: $id, + reviewerId: $player->playerId, + rejectionReason: $rejectionReason, + ), + ); + + $this->addFlash('success', $this->translator->trans('admin.puzzle_merge_request.rejected')); + + return $this->redirectToRoute('admin_puzzle_merge_requests'); + } +} diff --git a/src/Controller/PuzzleDetailController.php b/src/Controller/PuzzleDetailController.php index 7979b55f..f37c6b24 100644 --- a/src/Controller/PuzzleDetailController.php +++ b/src/Controller/PuzzleDetailController.php @@ -5,6 +5,7 @@ namespace SpeedPuzzling\Web\Controller; use SpeedPuzzling\Web\Exceptions\PuzzleNotFound; +use SpeedPuzzling\Web\Query\GetPendingPuzzleProposals; use SpeedPuzzling\Web\Query\GetPuzzleCollections; use SpeedPuzzling\Web\Query\GetPuzzleOverview; use SpeedPuzzling\Web\Query\GetSellSwapListItems; @@ -29,6 +30,7 @@ public function __construct( readonly private GetPuzzleCollections $getPuzzleCollections, readonly private RetrieveLoggedUserProfile $retrieveLoggedUserProfile, readonly private GetSellSwapListItems $getSellSwapListItems, + readonly private GetPendingPuzzleProposals $getPendingPuzzleProposals, ) { } @@ -79,6 +81,7 @@ public function __invoke(string $puzzleId, #[CurrentUser] null|UserInterface $us 'puzzle_collections' => $puzzleCollections, 'logged_player' => $loggedPlayer, 'offers_count' => $this->getSellSwapListItems->countByPuzzleId($puzzleId), + 'has_pending_proposals' => $this->getPendingPuzzleProposals->hasPendingForPuzzle($puzzleId), ]); } } diff --git a/src/Controller/PuzzleReport/PendingProposalsModalController.php b/src/Controller/PuzzleReport/PendingProposalsModalController.php new file mode 100644 index 00000000..9dde538c --- /dev/null +++ b/src/Controller/PuzzleReport/PendingProposalsModalController.php @@ -0,0 +1,50 @@ + '/puzzle/{puzzleId}/cekajici-navrhy', + 'en' => '/en/puzzle/{puzzleId}/pending-proposals', + ], + name: 'puzzle_pending_proposals', + methods: ['GET'], + )] + public function __invoke( + Request $request, + string $puzzleId, + ): Response { + $puzzle = $this->getPuzzleOverview->byId($puzzleId); + $proposals = $this->getPendingPuzzleProposals->forPuzzle($puzzleId); + + $templateParams = [ + 'puzzle' => $puzzle, + 'proposals' => $proposals, + ]; + + // Turbo Frame request - return frame content only + if ($request->headers->get('Turbo-Frame') === 'modal-frame') { + return $this->render('puzzle-report/pending_proposals_modal.html.twig', $templateParams); + } + + // Non-Turbo request: return full page for progressive enhancement + return $this->render('puzzle-report/pending_proposals.html.twig', $templateParams); + } +} diff --git a/src/Controller/PuzzleReport/ProposeChangesController.php b/src/Controller/PuzzleReport/ProposeChangesController.php new file mode 100644 index 00000000..89a31b0b --- /dev/null +++ b/src/Controller/PuzzleReport/ProposeChangesController.php @@ -0,0 +1,142 @@ + '/puzzle/{puzzleId}/navrhnout-zmeny', + 'en' => '/en/puzzle/{puzzleId}/propose-changes', + ], + name: 'puzzle_propose_changes', + methods: ['GET', 'POST'], + )] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function __invoke( + Request $request, + string $puzzleId, + ): Response { + $loggedPlayer = $this->retrieveLoggedUserProfile->getProfile(); + assert($loggedPlayer !== null); + + $puzzle = $this->getPuzzleOverview->byId($puzzleId); + + // Check for existing pending proposals - show them instead of the form + if ($this->getPendingPuzzleProposals->hasPendingForPuzzle($puzzleId)) { + $proposals = $this->getPendingPuzzleProposals->forPuzzle($puzzleId); + + // Handle Turbo Frame request - show pending proposals modal + if ($request->headers->get('Turbo-Frame') === 'modal-frame') { + return $this->render('puzzle-report/pending_proposals_modal.html.twig', [ + 'puzzle' => $puzzle, + 'proposals' => $proposals, + ]); + } + + $this->addFlash('warning', $this->translator->trans('puzzle_report.flash.pending_proposal_exists')); + + return $this->redirectToRoute('puzzle_detail', ['puzzleId' => $puzzleId]); + } + + // Pre-populate propose changes form with existing values + $proposeFormData = new ProposePuzzleChangesFormData(); + $proposeFormData->name = $puzzle->puzzleName; + $proposeFormData->manufacturerId = $puzzle->manufacturerId; + $proposeFormData->piecesCount = $puzzle->piecesCount; + $proposeFormData->ean = $puzzle->puzzleEan; + $proposeFormData->identificationNumber = $puzzle->puzzleIdentificationNumber; + + $proposeForm = $this->createForm(ProposePuzzleChangesFormType::class, $proposeFormData); + + // Create report form for display (handled by ReportDuplicatePuzzleController on POST) + $reportForm = $this->createForm(ReportDuplicatePuzzleFormType::class, new ReportDuplicatePuzzleFormData()); + + $activeTab = $request->query->getString('tab', 'propose'); + + $proposeForm->handleRequest($request); + + // Handle propose changes submission + if ($proposeForm->isSubmitted() && $proposeForm->isValid()) { + /** @var ProposePuzzleChangesFormData $formData */ + $formData = $proposeForm->getData(); + + $changeRequestId = Uuid::uuid7()->toString(); + + $this->messageBus->dispatch(new SubmitPuzzleChangeRequest( + changeRequestId: $changeRequestId, + puzzleId: $puzzleId, + reporterId: $loggedPlayer->playerId, + proposedName: $formData->name, + proposedManufacturerId: $formData->manufacturerId, + proposedPiecesCount: $formData->piecesCount, + proposedEan: $formData->ean, + proposedIdentificationNumber: $formData->identificationNumber, + proposedPhoto: $formData->photo, + )); + + if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) { + $request->setRequestFormat(TurboBundle::STREAM_FORMAT); + + return $this->render('puzzle-report/_stream.html.twig', [ + 'puzzle_id' => $puzzleId, + 'message' => $this->translator->trans('puzzle_report.flash.changes_submitted'), + ]); + } + + $this->addFlash('success', $this->translator->trans('puzzle_report.flash.changes_submitted')); + + return $this->redirectToRoute('puzzle_detail', ['puzzleId' => $puzzleId]); + } + + $templateParams = [ + 'puzzle' => $puzzle, + 'propose_form' => $proposeForm, + 'report_form' => $reportForm, + 'puzzle_id' => $puzzleId, + 'active_tab' => $activeTab, + ]; + + // Determine if form has validation errors (for proper Turbo handling) + $hasErrors = $proposeForm->isSubmitted() && !$proposeForm->isValid(); + + $statusCode = $hasErrors ? Response::HTTP_UNPROCESSABLE_ENTITY : Response::HTTP_OK; + + // Turbo Frame request - return frame content only + if ($request->headers->get('Turbo-Frame') === 'modal-frame') { + return $this->render('puzzle-report/modal.html.twig', $templateParams, new Response('', $statusCode)); + } + + // Non-Turbo request: return full page for progressive enhancement + return $this->render('puzzle-report/propose_changes.html.twig', $templateParams, new Response('', $statusCode)); + } +} diff --git a/src/Controller/PuzzleReport/ReportDuplicatePuzzleController.php b/src/Controller/PuzzleReport/ReportDuplicatePuzzleController.php new file mode 100644 index 00000000..9eb3497f --- /dev/null +++ b/src/Controller/PuzzleReport/ReportDuplicatePuzzleController.php @@ -0,0 +1,138 @@ + '/puzzle/{puzzleId}/nahlasit-duplikat', + 'en' => '/en/puzzle/{puzzleId}/report-duplicate', + ], + name: 'puzzle_report_duplicate', + methods: ['POST'], + )] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function __invoke( + Request $request, + string $puzzleId, + ): Response { + $loggedPlayer = $this->retrieveLoggedUserProfile->getProfile(); + assert($loggedPlayer !== null); + + $puzzle = $this->getPuzzleOverview->byId($puzzleId); + + // Check for existing pending proposals + if ($this->getPendingPuzzleProposals->hasPendingForPuzzle($puzzleId)) { + $this->addFlash('warning', $this->translator->trans('puzzle_report.flash.pending_proposal_exists')); + + return $this->redirectToRoute('puzzle_detail', ['puzzleId' => $puzzleId]); + } + + $reportForm = $this->createForm(ReportDuplicatePuzzleFormType::class, new ReportDuplicatePuzzleFormData()); + $reportForm->handleRequest($request); + + if ($reportForm->isSubmitted() && $reportForm->isValid()) { + /** @var ReportDuplicatePuzzleFormData $formData */ + $formData = $reportForm->getData(); + + // Parse URLs to extract puzzle IDs and filter out self-duplicates + $duplicateIds = $this->parseDuplicatePuzzleIds($formData, $puzzleId); + + if (count($duplicateIds) > 0) { + $mergeRequestId = Uuid::uuid7()->toString(); + + $this->messageBus->dispatch(new SubmitPuzzleMergeRequest( + mergeRequestId: $mergeRequestId, + sourcePuzzleId: $puzzleId, + reporterId: $loggedPlayer->playerId, + duplicatePuzzleIds: $duplicateIds, + )); + + if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) { + $request->setRequestFormat(TurboBundle::STREAM_FORMAT); + + return $this->render('puzzle-report/_stream.html.twig', [ + 'puzzle_id' => $puzzleId, + 'message' => $this->translator->trans('puzzle_report.flash.duplicate_reported'), + ]); + } + + $this->addFlash('success', $this->translator->trans('puzzle_report.flash.duplicate_reported')); + + return $this->redirectToRoute('puzzle_detail', ['puzzleId' => $puzzleId]); + } + + // No valid duplicates after filtering - show error + $this->addFlash('error', $this->translator->trans('puzzle_report.flash.no_valid_duplicates')); + } + + // On validation error, redirect back to the propose changes page with report tab active + return $this->redirectToRoute('puzzle_propose_changes', [ + 'puzzleId' => $puzzleId, + 'tab' => 'report', + ]); + } + + /** + * Parse duplicate puzzle IDs from form data and filter out self-duplicates. + * + * @return array + */ + private function parseDuplicatePuzzleIds(ReportDuplicatePuzzleFormData $formData, string $sourcePuzzleId): array + { + $ids = $formData->duplicatePuzzleIds; + + // Add puzzle ID from dropdown selection + if ($formData->selectedPuzzleId !== null && $formData->selectedPuzzleId !== '') { + if (Uuid::isValid($formData->selectedPuzzleId)) { + $ids[] = $formData->selectedPuzzleId; + } + } + + // Parse URL from single text input + if ($formData->duplicatePuzzleUrl !== null && $formData->duplicatePuzzleUrl !== '') { + $line = trim($formData->duplicatePuzzleUrl); + + // Try to extract puzzle ID from URL + if (preg_match('/puzzle\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i', $line, $matches)) { + $ids[] = $matches[1]; + } elseif (Uuid::isValid($line)) { + // Direct UUID + $ids[] = $line; + } + } + + // Remove source puzzle ID (prevent self-duplicate) + $ids = array_filter($ids, static fn(string $id): bool => $id !== $sourcePuzzleId); + + return array_values(array_unique($ids)); + } +} diff --git a/src/Entity/Notification.php b/src/Entity/Notification.php index b9417916..21434875 100644 --- a/src/Entity/Notification.php +++ b/src/Entity/Notification.php @@ -43,6 +43,14 @@ public function __construct( #[JoinColumn(onDelete: 'CASCADE')] #[Immutable] public null|LentPuzzleTransfer $targetTransfer = null, + #[ManyToOne] + #[JoinColumn(onDelete: 'CASCADE')] + #[Immutable] + public null|PuzzleChangeRequest $targetChangeRequest = null, + #[ManyToOne] + #[JoinColumn(onDelete: 'CASCADE')] + #[Immutable] + public null|PuzzleMergeRequest $targetMergeRequest = null, ) { } } diff --git a/src/Entity/Puzzle.php b/src/Entity/Puzzle.php index 48d872b3..64295d1b 100644 --- a/src/Entity/Puzzle.php +++ b/src/Entity/Puzzle.php @@ -54,4 +54,10 @@ public function __construct( public bool $isAvailable = false, ) { } + + public function updateProductIdentifiers(null|string $ean, null|string $identificationNumber): void + { + $this->ean = $ean; + $this->identificationNumber = $identificationNumber; + } } diff --git a/src/Entity/PuzzleChangeRequest.php b/src/Entity/PuzzleChangeRequest.php new file mode 100644 index 00000000..f3320727 --- /dev/null +++ b/src/Entity/PuzzleChangeRequest.php @@ -0,0 +1,120 @@ +status = PuzzleReportStatus::Approved; + $this->reviewedBy = $reviewedBy; + $this->reviewedAt = $reviewedAt; + } + + public function reject(Player $reviewedBy, DateTimeImmutable $reviewedAt, string $reason): void + { + $this->status = PuzzleReportStatus::Rejected; + $this->reviewedBy = $reviewedBy; + $this->reviewedAt = $reviewedAt; + $this->rejectionReason = $reason; + } + + public function hasProposedChanges(): bool + { + return $this->proposedName !== null + || $this->proposedManufacturer !== null + || $this->proposedPiecesCount !== null + || $this->proposedEan !== null + || $this->proposedIdentificationNumber !== null + || $this->proposedImage !== null; + } +} diff --git a/src/Entity/PuzzleMergeRequest.php b/src/Entity/PuzzleMergeRequest.php new file mode 100644 index 00000000..9628fa7a --- /dev/null +++ b/src/Entity/PuzzleMergeRequest.php @@ -0,0 +1,124 @@ + + */ + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] + #[Column(type: Types::JSON, options: ['default' => '[]'])] + public array $mergedPuzzleIds = []; + + // Store source puzzle name for display even after puzzle is deleted + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] + #[Column(type: 'string', length: 255, nullable: true)] + public null|string $sourcePuzzleName = null; + + public function __construct( + #[Id] + #[Immutable] + #[Column(type: UuidType::NAME, unique: true)] + public UuidInterface $id, + // The puzzle from which the report was initiated (nullable for audit trail - SET NULL when puzzle deleted) + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] + #[ManyToOne] + #[JoinColumn(nullable: true, onDelete: 'SET NULL')] + public null|Puzzle $sourcePuzzle, + // Reporter player (nullable for audit trail - SET NULL when player deleted) + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] + #[ManyToOne] + #[JoinColumn(nullable: true, onDelete: 'SET NULL')] + public null|Player $reporter, + #[Immutable] + #[Column] + public DateTimeImmutable $submittedAt, + // All puzzle IDs reported as duplicates (including source, max 5) + /** + * @var array + */ + #[Immutable] + #[Column(type: Types::JSON)] + public array $reportedDuplicatePuzzleIds = [], + ) { + // Store puzzle name for display even after puzzle is deleted + $this->sourcePuzzleName = $sourcePuzzle?->name; + } + + /** + * @param array $mergedPuzzleIds + */ + public function approve( + Player $reviewedBy, + DateTimeImmutable $reviewedAt, + UuidInterface $survivorPuzzleId, + array $mergedPuzzleIds, + ): void { + $this->status = PuzzleReportStatus::Approved; + $this->reviewedBy = $reviewedBy; + $this->reviewedAt = $reviewedAt; + $this->survivorPuzzleId = $survivorPuzzleId; + $this->mergedPuzzleIds = $mergedPuzzleIds; + + $this->recordThat(new PuzzleMergeApproved( + mergeRequestId: $this->id, + survivorPuzzleId: $survivorPuzzleId, + puzzleIdsToDelete: $mergedPuzzleIds, + )); + } + + public function reject(Player $reviewedBy, DateTimeImmutable $reviewedAt, string $reason): void + { + $this->status = PuzzleReportStatus::Rejected; + $this->reviewedBy = $reviewedBy; + $this->reviewedAt = $reviewedAt; + $this->rejectionReason = $reason; + } + + public function getDuplicateCount(): int + { + return count($this->reportedDuplicatePuzzleIds); + } +} diff --git a/src/Entity/PuzzleSolvingTime.php b/src/Entity/PuzzleSolvingTime.php index 7ed80cd5..8d1c10b4 100644 --- a/src/Entity/PuzzleSolvingTime.php +++ b/src/Entity/PuzzleSolvingTime.php @@ -109,6 +109,15 @@ public function modify( ); } + public function migrateToPuzzle(Puzzle $newPuzzle): void + { + $this->puzzle = $newPuzzle; + + $this->recordThat( + new PuzzleSolvingTimeModified($this->id, $newPuzzle->id), + ); + } + private function calculatePuzzlersCount(): int { if ($this->team === null) { diff --git a/src/Events/PuzzleMergeApproved.php b/src/Events/PuzzleMergeApproved.php new file mode 100644 index 00000000..2d89e348 --- /dev/null +++ b/src/Events/PuzzleMergeApproved.php @@ -0,0 +1,21 @@ + $puzzleIdsToDelete + */ + public function __construct( + public UuidInterface $mergeRequestId, + public UuidInterface $survivorPuzzleId, + public array $puzzleIdsToDelete, + ) { + } +} diff --git a/src/Exceptions/PuzzleChangeRequestNotFound.php b/src/Exceptions/PuzzleChangeRequestNotFound.php new file mode 100644 index 00000000..332b22f3 --- /dev/null +++ b/src/Exceptions/PuzzleChangeRequestNotFound.php @@ -0,0 +1,11 @@ + + */ + public array $duplicatePuzzleIds = []; + + #[Length(max: 500)] + public null|string $duplicatePuzzleUrl = null; + + public null|string $selectedManufacturerId = null; + + public null|string $selectedPuzzleId = null; + + public function validateHasDuplicate(ExecutionContextInterface $context): void + { + $hasSelectedPuzzle = $this->selectedPuzzleId !== null && $this->selectedPuzzleId !== ''; + $hasUrl = $this->duplicatePuzzleUrl !== null && $this->duplicatePuzzleUrl !== ''; + + if (!$hasSelectedPuzzle && !$hasUrl) { + $context->buildViolation('Please select a duplicate puzzle or enter a puzzle URL.') + ->atPath('selectedPuzzleId') + ->addViolation(); + } + } +} diff --git a/src/FormType/ProposePuzzleChangesFormType.php b/src/FormType/ProposePuzzleChangesFormType.php new file mode 100644 index 00000000..0ec219fe --- /dev/null +++ b/src/FormType/ProposePuzzleChangesFormType.php @@ -0,0 +1,91 @@ + + */ +final class ProposePuzzleChangesFormType extends AbstractType +{ + public function __construct( + private readonly GetManufacturers $getManufacturers, + ) { + } + + /** + * @param mixed[] $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $manufacturerChoices = []; + foreach ($this->getManufacturers->onlyApprovedOrAddedByPlayer() as $manufacturer) { + $manufacturerChoices["{$manufacturer->manufacturerName} ({$manufacturer->puzzlesCount})"] = $manufacturer->manufacturerId; + } + + $builder + ->add('name', TextType::class, [ + 'label' => 'puzzle_report.form.name', + 'help' => 'puzzle_report.form.name_help', + 'attr' => [ + 'placeholder' => 'puzzle_report.form.name_placeholder', + ], + ]) + ->add('manufacturerId', ChoiceType::class, [ + 'label' => 'puzzle_report.form.manufacturer', + 'required' => false, + 'autocomplete' => true, + 'choices' => $manufacturerChoices, + 'placeholder' => 'puzzle_report.form.manufacturer_placeholder', + 'choice_translation_domain' => false, + ]) + ->add('piecesCount', IntegerType::class, [ + 'label' => 'puzzle_report.form.pieces_count', + 'attr' => [ + 'min' => 10, + 'max' => 25000, + ], + ]) + ->add('ean', TextType::class, [ + 'label' => 'puzzle_report.form.ean', + 'required' => false, + 'help' => 'puzzle_report.form.ean_help', + 'attr' => [ + 'placeholder' => 'puzzle_report.form.ean_placeholder', + ], + ]) + ->add('identificationNumber', TextType::class, [ + 'label' => 'puzzle_report.form.identification_number', + 'required' => false, + 'help' => 'puzzle_report.form.identification_number_help', + 'attr' => [ + 'placeholder' => 'puzzle_report.form.identification_number_placeholder', + ], + ]) + ->add('photo', FileType::class, [ + 'label' => 'puzzle_report.form.photo', + 'required' => false, + 'attr' => [ + 'accept' => 'image/jpeg,image/png,image/webp', + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => ProposePuzzleChangesFormData::class, + ]); + } +} diff --git a/src/FormType/ReportDuplicatePuzzleFormType.php b/src/FormType/ReportDuplicatePuzzleFormType.php new file mode 100644 index 00000000..fac61562 --- /dev/null +++ b/src/FormType/ReportDuplicatePuzzleFormType.php @@ -0,0 +1,85 @@ + + */ +final class ReportDuplicatePuzzleFormType extends AbstractType +{ + public function __construct( + private readonly GetManufacturers $getManufacturers, + private readonly UrlGeneratorInterface $urlGenerator, + private readonly TranslatorInterface $translator, + ) { + } + + /** + * @param mixed[] $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $manufacturerChoices = []; + foreach ($this->getManufacturers->onlyApprovedOrAddedByPlayer() as $manufacturer) { + $manufacturerChoices["{$manufacturer->manufacturerName} ({$manufacturer->puzzlesCount})"] = $manufacturer->manufacturerId; + } + + $builder + ->add('duplicatePuzzleUrl', TextType::class, [ + 'label' => 'puzzle_report.form.duplicate_url', + 'required' => false, + 'help' => 'puzzle_report.form.duplicate_url_help', + 'attr' => [ + 'placeholder' => 'puzzle_report.form.duplicate_url_placeholder', + ], + ]) + ->add('selectedManufacturerId', ChoiceType::class, [ + 'label' => 'puzzle_report.form.search_manufacturer', + 'required' => false, + 'autocomplete' => true, + 'choices' => $manufacturerChoices, + 'placeholder' => 'puzzle_report.form.search_manufacturer_placeholder', + 'choice_translation_domain' => false, + 'attr' => [ + 'data-fetch-url' => $this->urlGenerator->generate('puzzle_by_brand_autocomplete'), + 'data-report-duplicate-form-target' => 'manufacturer', + ], + ]) + ->add('selectedPuzzleId', TextType::class, [ + 'label' => 'puzzle_report.form.select_puzzle', + 'required' => false, + 'autocomplete' => true, + 'options_as_html' => true, + 'tom_select_options' => [ + 'create' => false, + 'persist' => false, + 'maxItems' => 1, + 'closeAfterSelect' => true, + ], + 'attr' => [ + 'data-report-duplicate-form-target' => 'puzzle', + 'data-choose-manufacturer-placeholder' => $this->translator->trans('puzzle_report.form.select_manufacturer_first'), + 'data-choose-puzzle-placeholder' => $this->translator->trans('puzzle_report.form.select_puzzle_placeholder'), + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => ReportDuplicatePuzzleFormData::class, + ]); + } +} diff --git a/src/Message/ApprovePuzzleChangeRequest.php b/src/Message/ApprovePuzzleChangeRequest.php new file mode 100644 index 00000000..06b4e34e --- /dev/null +++ b/src/Message/ApprovePuzzleChangeRequest.php @@ -0,0 +1,14 @@ + $duplicatePuzzleIds + */ + public function __construct( + public string $mergeRequestId, + public string $sourcePuzzleId, + public string $reporterId, + public array $duplicatePuzzleIds, + ) { + } +} diff --git a/src/MessageHandler/ApprovePuzzleChangeRequestHandler.php b/src/MessageHandler/ApprovePuzzleChangeRequestHandler.php new file mode 100644 index 00000000..9468edcb --- /dev/null +++ b/src/MessageHandler/ApprovePuzzleChangeRequestHandler.php @@ -0,0 +1,79 @@ +puzzleChangeRequestRepository->get($message->changeRequestId); + $puzzle = $this->puzzleRepository->get($changeRequest->puzzle->id->toString()); + $reviewer = $this->playerRepository->get($message->reviewerId); + + // Apply proposed changes to puzzle + if ($changeRequest->proposedName !== null) { + $puzzle->name = $changeRequest->proposedName; + } + + if ($changeRequest->proposedManufacturer !== null) { + $puzzle->manufacturer = $changeRequest->proposedManufacturer; + } + + if ($changeRequest->proposedPiecesCount !== null) { + $puzzle->piecesCount = $changeRequest->proposedPiecesCount; + } + + $puzzle->updateProductIdentifiers( + ean: $changeRequest->proposedEan ?? $puzzle->ean, + identificationNumber: $changeRequest->proposedIdentificationNumber ?? $puzzle->identificationNumber, + ); + + if ($changeRequest->proposedImage !== null) { + $puzzle->image = $changeRequest->proposedImage; + } + + // Mark request as approved + $changeRequest->approve($reviewer, $this->clock->now()); + + // Create notification for reporter + $notification = new Notification( + id: Uuid::uuid7(), + player: $changeRequest->reporter, + type: NotificationType::PuzzleChangeRequestApproved, + notifiedAt: $this->clock->now(), + targetChangeRequest: $changeRequest, + ); + $this->entityManager->persist($notification); + } +} diff --git a/src/MessageHandler/ApprovePuzzleMergeRequestHandler.php b/src/MessageHandler/ApprovePuzzleMergeRequestHandler.php new file mode 100644 index 00000000..050b95c5 --- /dev/null +++ b/src/MessageHandler/ApprovePuzzleMergeRequestHandler.php @@ -0,0 +1,225 @@ +puzzleMergeRequestRepository->get($message->mergeRequestId); + $reviewer = $this->playerRepository->get($message->reviewerId); + $survivorPuzzle = $this->puzzleRepository->get($message->survivorPuzzleId); + + // Collect all puzzle IDs to merge (including source puzzle, excluding survivor) + $allPuzzleIds = $mergeRequest->reportedDuplicatePuzzleIds; + $puzzlesToMerge = []; + + foreach ($allPuzzleIds as $puzzleId) { + if ($puzzleId === $message->survivorPuzzleId) { + continue; + } + + try { + $puzzlesToMerge[] = $this->puzzleRepository->get($puzzleId); + } catch (PuzzleNotFound) { + $this->logger->debug('Puzzle {puzzleId} not found during merge, already deleted', [ + 'puzzleId' => $puzzleId, + 'mergeRequestId' => $message->mergeRequestId, + ]); + } + } + + // Update survivor puzzle with merged data + $survivorPuzzle->name = $message->mergedName; + $survivorPuzzle->piecesCount = $message->mergedPiecesCount; + + $survivorPuzzle->updateProductIdentifiers( + ean: ($message->mergedEan !== null && $message->mergedEan !== '') ? $message->mergedEan : $survivorPuzzle->ean, + identificationNumber: ($message->mergedIdentificationNumber !== null && $message->mergedIdentificationNumber !== '') ? $message->mergedIdentificationNumber : $survivorPuzzle->identificationNumber, + ); + + if ($message->mergedManufacturerId !== null) { + $manufacturer = $this->manufacturerRepository->get($message->mergedManufacturerId); + $survivorPuzzle->manufacturer = $manufacturer; + } + + // Copy image from selected puzzle if different from survivor + if ($message->selectedImagePuzzleId !== null && $message->selectedImagePuzzleId !== $message->survivorPuzzleId) { + try { + $imagePuzzle = $this->puzzleRepository->get($message->selectedImagePuzzleId); + if ($imagePuzzle->image !== null) { + $survivorPuzzle->image = $imagePuzzle->image; + } + } catch (PuzzleNotFound) { + $this->logger->debug('Image puzzle {puzzleId} not found, keeping survivor image', [ + 'puzzleId' => $message->selectedImagePuzzleId, + 'survivorPuzzleId' => $message->survivorPuzzleId, + ]); + } + } + + // Migrate all puzzle-related records from merged puzzles to survivor + $this->migrateRecordsToSurvivor($puzzlesToMerge, $survivorPuzzle); + + // Mark merge request as approved (this records PuzzleMergeApproved event for puzzle deletion) + $mergeRequest->approve( + reviewedBy: $reviewer, + reviewedAt: $this->clock->now(), + survivorPuzzleId: $survivorPuzzle->id, + mergedPuzzleIds: array_map( + static fn($puzzle) => $puzzle->id->toString(), + $puzzlesToMerge, + ), + ); + + // Create notification for reporter (if reporter still exists) + if ($mergeRequest->reporter !== null) { + $notification = new Notification( + id: Uuid::uuid7(), + player: $mergeRequest->reporter, + type: NotificationType::PuzzleMergeRequestApproved, + notifiedAt: $this->clock->now(), + targetMergeRequest: $mergeRequest, + ); + $this->entityManager->persist($notification); + } + + // Puzzle deletions are handled by PuzzleMergeApproved event (recorded in approve() method) + // This ensures migrations are flushed first, then deletions happen in a separate transaction + + $this->logger->info('Puzzle merge approved: {mergedCount} puzzles will be merged into survivor', [ + 'mergeRequestId' => $message->mergeRequestId, + 'survivorPuzzleId' => $message->survivorPuzzleId, + 'mergedCount' => count($puzzlesToMerge), + 'mergedPuzzleIds' => array_map( + static fn($puzzle) => $puzzle->id->toString(), + $puzzlesToMerge, + ), + ]); + } + + /** + * @param array $puzzlesToMerge + */ + private function migrateRecordsToSurvivor(array $puzzlesToMerge, Puzzle $survivorPuzzle): void + { + foreach ($puzzlesToMerge as $puzzleToMerge) { + // Migrate solving times (records PuzzleSolvingTimeModified event which triggers statistics recalculation) + $solvingTimes = $this->entityManager->getRepository(PuzzleSolvingTime::class)->findBy(['puzzle' => $puzzleToMerge]); + foreach ($solvingTimes as $solvingTime) { + $solvingTime->migrateToPuzzle($survivorPuzzle); + } + + // Migrate collection items (unique on collection_id + player_id + puzzle_id) + $collectionItems = $this->entityManager->getRepository(CollectionItem::class)->findBy(['puzzle' => $puzzleToMerge]); + foreach ($collectionItems as $item) { + // Check if player already has survivor puzzle in the same collection + $existingItem = $this->entityManager->getRepository(CollectionItem::class)->findOneBy([ + 'collection' => $item->collection, + 'player' => $item->player, + 'puzzle' => $survivorPuzzle, + ]); + if ($existingItem !== null) { + $this->entityManager->remove($item); + } else { + $item->puzzle = $survivorPuzzle; + } + } + + // Migrate wish list items (unique on player_id + puzzle_id) + $wishListItems = $this->entityManager->getRepository(WishListItem::class)->findBy(['puzzle' => $puzzleToMerge]); + foreach ($wishListItems as $item) { + // Check if player already has survivor puzzle in their wish list + $existingItem = $this->entityManager->getRepository(WishListItem::class)->findOneBy([ + 'player' => $item->player, + 'puzzle' => $survivorPuzzle, + ]); + if ($existingItem !== null) { + $this->entityManager->remove($item); + } else { + $item->puzzle = $survivorPuzzle; + } + } + + // Migrate sell/swap list items (unique on player_id + puzzle_id) + $sellSwapItems = $this->entityManager->getRepository(SellSwapListItem::class)->findBy(['puzzle' => $puzzleToMerge]); + foreach ($sellSwapItems as $item) { + // Check if player already has survivor puzzle in their sell/swap list + $existingItem = $this->entityManager->getRepository(SellSwapListItem::class)->findOneBy([ + 'player' => $item->player, + 'puzzle' => $survivorPuzzle, + ]); + if ($existingItem !== null) { + $this->entityManager->remove($item); + } else { + $item->puzzle = $survivorPuzzle; + } + } + + // Migrate lent puzzles (unique on owner_player_id + puzzle_id) + $lentPuzzles = $this->entityManager->getRepository(LentPuzzle::class)->findBy(['puzzle' => $puzzleToMerge]); + foreach ($lentPuzzles as $item) { + // Check if owner already has survivor puzzle in lent puzzles + $existingItem = $this->entityManager->getRepository(LentPuzzle::class)->findOneBy([ + 'ownerPlayer' => $item->ownerPlayer, + 'puzzle' => $survivorPuzzle, + ]); + if ($existingItem !== null) { + $this->entityManager->remove($item); + } else { + $item->puzzle = $survivorPuzzle; + } + } + + // Migrate sold/swapped items (historical records - no unique constraint) + $soldSwappedItems = $this->entityManager->getRepository(SoldSwappedItem::class)->findBy(['puzzle' => $puzzleToMerge]); + foreach ($soldSwappedItems as $item) { + $item->puzzle = $survivorPuzzle; + } + } + } +} diff --git a/src/MessageHandler/DeleteMergedPuzzlesOnMergeApproved.php b/src/MessageHandler/DeleteMergedPuzzlesOnMergeApproved.php new file mode 100644 index 00000000..9a124ffc --- /dev/null +++ b/src/MessageHandler/DeleteMergedPuzzlesOnMergeApproved.php @@ -0,0 +1,47 @@ +puzzleIdsToDelete as $puzzleId) { + try { + $puzzle = $this->puzzleRepository->get($puzzleId); + $this->entityManager->remove($puzzle); + $deletedCount++; + } catch (PuzzleNotFound) { + $this->logger->debug('Puzzle {puzzleId} already deleted during merge', [ + 'puzzleId' => $puzzleId, + 'mergeRequestId' => $event->mergeRequestId->toString(), + ]); + } + } + + $this->logger->info('Deleted {deletedCount} merged puzzles', [ + 'mergeRequestId' => $event->mergeRequestId->toString(), + 'survivorPuzzleId' => $event->survivorPuzzleId->toString(), + 'deletedCount' => $deletedCount, + ]); + } +} diff --git a/src/MessageHandler/RejectPuzzleChangeRequestHandler.php b/src/MessageHandler/RejectPuzzleChangeRequestHandler.php new file mode 100644 index 00000000..77f14df8 --- /dev/null +++ b/src/MessageHandler/RejectPuzzleChangeRequestHandler.php @@ -0,0 +1,51 @@ +puzzleChangeRequestRepository->get($message->changeRequestId); + $reviewer = $this->playerRepository->get($message->reviewerId); + + $changeRequest->reject($reviewer, $this->clock->now(), $message->rejectionReason); + + // Create notification for reporter + $notification = new Notification( + id: Uuid::uuid7(), + player: $changeRequest->reporter, + type: NotificationType::PuzzleChangeRequestRejected, + notifiedAt: $this->clock->now(), + targetChangeRequest: $changeRequest, + ); + $this->entityManager->persist($notification); + } +} diff --git a/src/MessageHandler/RejectPuzzleMergeRequestHandler.php b/src/MessageHandler/RejectPuzzleMergeRequestHandler.php new file mode 100644 index 00000000..f903ea9e --- /dev/null +++ b/src/MessageHandler/RejectPuzzleMergeRequestHandler.php @@ -0,0 +1,53 @@ +puzzleMergeRequestRepository->get($message->mergeRequestId); + $reviewer = $this->playerRepository->get($message->reviewerId); + + $mergeRequest->reject($reviewer, $this->clock->now(), $message->rejectionReason); + + // Create notification for reporter (if reporter still exists) + if ($mergeRequest->reporter !== null) { + $notification = new Notification( + id: Uuid::uuid7(), + player: $mergeRequest->reporter, + type: NotificationType::PuzzleMergeRequestRejected, + notifiedAt: $this->clock->now(), + targetMergeRequest: $mergeRequest, + ); + $this->entityManager->persist($notification); + } + } +} diff --git a/src/MessageHandler/SubmitPuzzleChangeRequestHandler.php b/src/MessageHandler/SubmitPuzzleChangeRequestHandler.php new file mode 100644 index 00000000..f98daa11 --- /dev/null +++ b/src/MessageHandler/SubmitPuzzleChangeRequestHandler.php @@ -0,0 +1,92 @@ +puzzleRepository->get($message->puzzleId); + $reporter = $this->playerRepository->get($message->reporterId); + $now = $this->clock->now(); + + $proposedManufacturer = null; + if ($message->proposedManufacturerId !== null && Uuid::isValid($message->proposedManufacturerId)) { + $proposedManufacturer = $this->manufacturerRepository->get($message->proposedManufacturerId); + } + + // Handle image upload with SEO-friendly naming + $proposedImagePath = null; + if ($message->proposedPhoto !== null) { + $extension = $message->proposedPhoto->guessExtension() ?? 'jpg'; + $brandName = $proposedManufacturer !== null + ? $proposedManufacturer->name + : ($puzzle->manufacturer !== null ? $puzzle->manufacturer->name : 'puzzle'); + $proposedImagePath = $this->puzzleImageNamer->generateFilename( + $brandName, + $message->proposedName, + $message->proposedPiecesCount, + $extension, + ); + + $stream = fopen($message->proposedPhoto->getPathname(), 'rb'); + $this->filesystem->writeStream($proposedImagePath, $stream); + + if (is_resource($stream)) { + fclose($stream); + } + + $this->messageBus->dispatch(new WarmupCache($proposedImagePath)); + } + + $changeRequest = new PuzzleChangeRequest( + id: Uuid::fromString($message->changeRequestId), + puzzle: $puzzle, + reporter: $reporter, + submittedAt: $now, + proposedName: $message->proposedName, + proposedManufacturer: $proposedManufacturer, + proposedPiecesCount: $message->proposedPiecesCount, + proposedEan: $message->proposedEan, + proposedIdentificationNumber: $message->proposedIdentificationNumber, + proposedImage: $proposedImagePath, + originalName: $puzzle->name, + originalManufacturerId: $puzzle->manufacturer?->id, + originalPiecesCount: $puzzle->piecesCount, + originalEan: $puzzle->ean, + originalIdentificationNumber: $puzzle->identificationNumber, + originalImage: $puzzle->image, + ); + + $this->entityManager->persist($changeRequest); + } +} diff --git a/src/MessageHandler/SubmitPuzzleMergeRequestHandler.php b/src/MessageHandler/SubmitPuzzleMergeRequestHandler.php new file mode 100644 index 00000000..6aa8bfca --- /dev/null +++ b/src/MessageHandler/SubmitPuzzleMergeRequestHandler.php @@ -0,0 +1,59 @@ +puzzleRepository->get($message->sourcePuzzleId); + $reporter = $this->playerRepository->get($message->reporterId); + $now = $this->clock->now(); + + // Validate all duplicate puzzle IDs exist + foreach ($message->duplicatePuzzleIds as $puzzleId) { + $this->puzzleRepository->get($puzzleId); + } + + // Ensure source puzzle is included in the list + $allPuzzleIds = array_unique(array_merge( + [$message->sourcePuzzleId], + $message->duplicatePuzzleIds, + )); + + // Validate that there's at least one actual duplicate (not just source itself) + if (count($allPuzzleIds) < 2) { + throw new \InvalidArgumentException('At least one duplicate puzzle different from the source is required.'); + } + + $mergeRequest = new PuzzleMergeRequest( + id: Uuid::fromString($message->mergeRequestId), + sourcePuzzle: $sourcePuzzle, + reporter: $reporter, + submittedAt: $now, + reportedDuplicatePuzzleIds: $allPuzzleIds, + ); + + $this->entityManager->persist($mergeRequest); + } +} diff --git a/src/Query/GetNotifications.php b/src/Query/GetNotifications.php index d00ec591..98194d56 100644 --- a/src/Query/GetNotifications.php +++ b/src/Query/GetNotifications.php @@ -85,7 +85,18 @@ public function forPlayer(string $playerId, int $limit, int $offset = 0): array NULL::varchar AS lending_puzzle_name, NULL::varchar AS lending_puzzle_image, NULL::varchar AS lending_manufacturer_name, - NULL::int AS lending_pieces_count + NULL::int AS lending_pieces_count, + -- Puzzle report fields (NULL for puzzle solving notifications) + NULL::uuid AS change_request_id, + NULL::uuid AS change_request_puzzle_id, + NULL::varchar AS change_request_puzzle_name, + NULL::varchar AS change_request_puzzle_image, + NULL::varchar AS change_request_rejection_reason, + NULL::uuid AS merge_request_id, + NULL::uuid AS merge_request_puzzle_id, + NULL::varchar AS merge_request_puzzle_name, + NULL::varchar AS merge_request_puzzle_image, + NULL::varchar AS merge_request_rejection_reason FROM notification LEFT JOIN puzzle_solving_time ON notification.target_solving_time_id = puzzle_solving_time.id INNER JOIN puzzle ON puzzle.id = puzzle_solving_time.puzzle_id @@ -134,7 +145,18 @@ public function forPlayer(string $playerId, int $limit, int $offset = 0): array puzzle.name AS lending_puzzle_name, puzzle.image AS lending_puzzle_image, manufacturer.name AS lending_manufacturer_name, - puzzle.pieces_count AS lending_pieces_count + puzzle.pieces_count AS lending_pieces_count, + -- Puzzle report fields (NULL for lending notifications) + NULL::uuid AS change_request_id, + NULL::uuid AS change_request_puzzle_id, + NULL::varchar AS change_request_puzzle_name, + NULL::varchar AS change_request_puzzle_image, + NULL::varchar AS change_request_rejection_reason, + NULL::uuid AS merge_request_id, + NULL::uuid AS merge_request_puzzle_id, + NULL::varchar AS merge_request_puzzle_name, + NULL::varchar AS merge_request_puzzle_image, + NULL::varchar AS merge_request_rejection_reason FROM notification INNER JOIN lent_puzzle_transfer lpt ON notification.target_transfer_id = lpt.id INNER JOIN lent_puzzle lp ON lpt.lent_puzzle_id = lp.id @@ -145,6 +167,116 @@ public function forPlayer(string $playerId, int $limit, int $offset = 0): array LEFT JOIN player owner_player ON lp.owner_player_id = owner_player.id WHERE notification.player_id = :playerId AND notification.target_transfer_id IS NOT NULL + + UNION ALL + + -- Puzzle change request notifications + SELECT + notification.notified_at, + notification.read_at, + notification.type AS notification_type, + -- Puzzle solving fields (NULL) + NULL::uuid AS target_player_id, + NULL::varchar AS target_player_name, + NULL::varchar AS target_player_code, + NULL::varchar AS target_player_country, + NULL::varchar AS target_player_avatar, + NULL::uuid AS puzzle_id, + NULL::varchar AS puzzle_name, + NULL::varchar AS puzzle_alternative_name, + NULL::varchar AS manufacturer_name, + NULL::int AS pieces_count, + NULL::int AS time, + NULL::varchar AS puzzle_image, + NULL::varchar AS team_id, + NULL::json AS players, + -- Lending fields (NULL) + NULL::uuid AS transfer_id, + NULL::varchar AS transfer_type, + NULL::uuid AS from_player_id, + NULL::varchar AS from_player_name, + NULL::varchar AS from_player_avatar, + NULL::uuid AS to_player_id, + NULL::varchar AS to_player_name, + NULL::varchar AS to_player_avatar, + NULL::uuid AS owner_player_id, + NULL::varchar AS owner_player_name, + NULL::uuid AS lending_puzzle_id, + NULL::varchar AS lending_puzzle_name, + NULL::varchar AS lending_puzzle_image, + NULL::varchar AS lending_manufacturer_name, + NULL::int AS lending_pieces_count, + -- Puzzle change request fields + pcr.id AS change_request_id, + puzzle.id AS change_request_puzzle_id, + puzzle.name AS change_request_puzzle_name, + puzzle.image 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, + NULL::varchar AS merge_request_puzzle_name, + NULL::varchar AS merge_request_puzzle_image, + NULL::varchar AS merge_request_rejection_reason + FROM notification + INNER JOIN puzzle_change_request pcr ON notification.target_change_request_id = pcr.id + INNER JOIN puzzle ON pcr.puzzle_id = puzzle.id + WHERE notification.player_id = :playerId + AND notification.target_change_request_id IS NOT NULL + + UNION ALL + + -- Puzzle merge request notifications + SELECT + notification.notified_at, + notification.read_at, + notification.type AS notification_type, + -- Puzzle solving fields (NULL) + NULL::uuid AS target_player_id, + NULL::varchar AS target_player_name, + NULL::varchar AS target_player_code, + NULL::varchar AS target_player_country, + NULL::varchar AS target_player_avatar, + NULL::uuid AS puzzle_id, + NULL::varchar AS puzzle_name, + NULL::varchar AS puzzle_alternative_name, + NULL::varchar AS manufacturer_name, + NULL::int AS pieces_count, + NULL::int AS time, + NULL::varchar AS puzzle_image, + NULL::varchar AS team_id, + NULL::json AS players, + -- Lending fields (NULL) + NULL::uuid AS transfer_id, + NULL::varchar AS transfer_type, + NULL::uuid AS from_player_id, + NULL::varchar AS from_player_name, + NULL::varchar AS from_player_avatar, + NULL::uuid AS to_player_id, + NULL::varchar AS to_player_name, + NULL::varchar AS to_player_avatar, + NULL::uuid AS owner_player_id, + NULL::varchar AS owner_player_name, + NULL::uuid AS lending_puzzle_id, + NULL::varchar AS lending_puzzle_name, + NULL::varchar AS lending_puzzle_image, + NULL::varchar AS lending_manufacturer_name, + NULL::int AS lending_pieces_count, + -- Puzzle merge request fields + NULL::uuid AS change_request_id, + NULL::uuid AS change_request_puzzle_id, + NULL::varchar AS change_request_puzzle_name, + NULL::varchar AS change_request_puzzle_image, + NULL::varchar AS change_request_rejection_reason, + pmr.id AS merge_request_id, + source_puzzle.id AS merge_request_puzzle_id, + source_puzzle.name AS merge_request_puzzle_name, + source_puzzle.image AS merge_request_puzzle_image, + pmr.rejection_reason AS merge_request_rejection_reason + FROM notification + INNER JOIN puzzle_merge_request pmr ON notification.target_merge_request_id = pmr.id + INNER JOIN puzzle source_puzzle ON pmr.source_puzzle_id = source_puzzle.id + WHERE notification.player_id = :playerId + AND notification.target_merge_request_id IS NOT NULL ) AS combined_notifications ORDER BY notified_at DESC LIMIT :limit diff --git a/src/Query/GetPendingPuzzleProposals.php b/src/Query/GetPendingPuzzleProposals.php new file mode 100644 index 00000000..9d2089a8 --- /dev/null +++ b/src/Query/GetPendingPuzzleProposals.php @@ -0,0 +1,168 @@ + :puzzleIdJson::jsonb) +) as has_pending +SQL; + + $result = $this->database->fetchOne($query, [ + 'puzzleId' => $puzzleId, + 'puzzleIdJson' => json_encode([$puzzleId]), + ]); + + return $result === true; + } + + /** + * @return array + */ + public function forPuzzle(string $puzzleId): array + { + $query = << :puzzleIdJson::jsonb) + +ORDER BY submitted_at DESC +SQL; + + $rows = $this->database->fetchAllAssociative($query, [ + 'puzzleId' => $puzzleId, + 'puzzleIdJson' => json_encode([$puzzleId]), + ]); + + // Collect all puzzle IDs from merge requests to fetch in one query + $allPuzzleIds = []; + foreach ($rows as $row) { + if ($row['type'] === 'merge_request' && is_string($row['reported_duplicate_puzzle_ids'])) { + /** @var array $puzzleIds */ + $puzzleIds = json_decode($row['reported_duplicate_puzzle_ids'], true) ?? []; + $allPuzzleIds = array_merge($allPuzzleIds, $puzzleIds); + } + } + $allPuzzleIds = array_unique($allPuzzleIds); + + // Fetch puzzle details if we have any merge requests + $puzzleDetails = []; + if (count($allPuzzleIds) > 0) { + $puzzleDetails = $this->fetchPuzzleDetails($allPuzzleIds); + } + + return array_map( + static function (array $row) use ($puzzleDetails): PendingPuzzleProposal { + $mergePuzzles = []; + if ($row['type'] === 'merge_request' && is_string($row['reported_duplicate_puzzle_ids'])) { + /** @var array $puzzleIds */ + $puzzleIds = json_decode($row['reported_duplicate_puzzle_ids'], true) ?? []; + foreach ($puzzleIds as $pid) { + if (isset($puzzleDetails[$pid])) { + $mergePuzzles[] = $puzzleDetails[$pid]; + } + } + } + + return PendingPuzzleProposal::fromDatabaseRow($row, $mergePuzzles); + }, + $rows, + ); + } + + /** + * @param array $puzzleIds + * @return array + */ + private function fetchPuzzleDetails(array $puzzleIds): array + { + $placeholders = implode(',', array_fill(0, count($puzzleIds), '?')); + + $query = <<database->fetchAllAssociative($query, array_values($puzzleIds)); + + $result = []; + foreach ($rows as $row) { + $id = $row['id']; + assert(is_string($id)); + $name = $row['name']; + assert(is_string($name)); + + $timesCount = $row['times_count']; + assert(is_numeric($timesCount)); + + $result[$id] = new MergePuzzleInfo( + id: $id, + name: $name, + piecesCount: is_int($row['pieces_count']) ? $row['pieces_count'] : null, + image: is_string($row['image']) ? $row['image'] : null, + manufacturerName: is_string($row['manufacturer_name']) ? $row['manufacturer_name'] : null, + timesCount: (int) $timesCount, + ); + } + + return $result; + } +} diff --git a/src/Query/GetPuzzleChangeRequests.php b/src/Query/GetPuzzleChangeRequests.php new file mode 100644 index 00000000..44e38134 --- /dev/null +++ b/src/Query/GetPuzzleChangeRequests.php @@ -0,0 +1,185 @@ +database->fetchAssociative($query); + + if ($row === false) { + return ['pending' => 0, 'approved' => 0, 'rejected' => 0]; + } + + /** @var int $pending */ + $pending = $row['pending']; + /** @var int $approved */ + $approved = $row['approved']; + /** @var int $rejected */ + $rejected = $row['rejected']; + + return [ + 'pending' => $pending, + 'approved' => $approved, + 'rejected' => $rejected, + ]; + } + + /** + * @return array + */ + public function allPending(): array + { + return $this->byStatus(PuzzleReportStatus::Pending); + } + + /** + * @return array + */ + public function allApproved(): array + { + return $this->byStatus(PuzzleReportStatus::Approved); + } + + /** + * @return array + */ + public function allRejected(): array + { + return $this->byStatus(PuzzleReportStatus::Rejected); + } + + public function byId(string $id): null|PuzzleChangeRequestOverview + { + $query = <<database->fetchAssociative($query, [ + 'id' => $id, + ]); + + if ($row === false) { + return null; + } + + return PuzzleChangeRequestOverview::fromDatabaseRow($row); + } + + /** + * @return array + */ + private function byStatus(PuzzleReportStatus $status): array + { + $query = <<database->fetchAllAssociative($query, [ + 'status' => $status->value, + ]); + + return array_map( + static fn(array $row): PuzzleChangeRequestOverview => PuzzleChangeRequestOverview::fromDatabaseRow($row), + $rows, + ); + } +} diff --git a/src/Query/GetPuzzleMergeRequests.php b/src/Query/GetPuzzleMergeRequests.php new file mode 100644 index 00000000..f6233f68 --- /dev/null +++ b/src/Query/GetPuzzleMergeRequests.php @@ -0,0 +1,173 @@ +database->fetchAssociative($query); + + if ($row === false) { + return ['pending' => 0, 'approved' => 0, 'rejected' => 0]; + } + + /** @var int $pending */ + $pending = $row['pending']; + /** @var int $approved */ + $approved = $row['approved']; + /** @var int $rejected */ + $rejected = $row['rejected']; + + return [ + 'pending' => $pending, + 'approved' => $approved, + 'rejected' => $rejected, + ]; + } + + /** + * @return array + */ + public function allPending(): array + { + return $this->byStatus(PuzzleReportStatus::Pending); + } + + /** + * @return array + */ + public function allApproved(): array + { + return $this->byStatus(PuzzleReportStatus::Approved); + } + + /** + * @return array + */ + public function allRejected(): array + { + return $this->byStatus(PuzzleReportStatus::Rejected); + } + + public function byId(string $id): null|PuzzleMergeRequestOverview + { + $query = <<database->fetchAssociative($query, [ + 'id' => $id, + ]); + + if ($row === false) { + return null; + } + + return PuzzleMergeRequestOverview::fromDatabaseRow($row); + } + + /** + * @return array + */ + private function byStatus(PuzzleReportStatus $status): array + { + $query = <<database->fetchAllAssociative($query, [ + 'status' => $status->value, + ]); + + return array_map( + static fn(array $row): PuzzleMergeRequestOverview => PuzzleMergeRequestOverview::fromDatabaseRow($row), + $rows, + ); + } +} diff --git a/src/Repository/PuzzleChangeRequestRepository.php b/src/Repository/PuzzleChangeRequestRepository.php new file mode 100644 index 00000000..11b64599 --- /dev/null +++ b/src/Repository/PuzzleChangeRequestRepository.php @@ -0,0 +1,32 @@ +entityManager->find(PuzzleChangeRequest::class, $changeRequestId); + + return $request ?? throw new PuzzleChangeRequestNotFound(); + } +} diff --git a/src/Repository/PuzzleMergeRequestRepository.php b/src/Repository/PuzzleMergeRequestRepository.php new file mode 100644 index 00000000..88f2cf9f --- /dev/null +++ b/src/Repository/PuzzleMergeRequestRepository.php @@ -0,0 +1,32 @@ +entityManager->find(PuzzleMergeRequest::class, $mergeRequestId); + + return $request ?? throw new PuzzleMergeRequestNotFound(); + } +} diff --git a/src/Results/MergePuzzleInfo.php b/src/Results/MergePuzzleInfo.php new file mode 100644 index 00000000..f992d4f2 --- /dev/null +++ b/src/Results/MergePuzzleInfo.php @@ -0,0 +1,18 @@ + $mergePuzzles + */ + public function __construct( + public string $id, + public string $type, + public DateTimeImmutable $submittedAt, + public null|string $reporterName, + public null|string $reporterCode, + public string $summary, + public array $mergePuzzles = [], + ) { + } + + /** + * @param array $row + * @param array $mergePuzzles + */ + public static function fromDatabaseRow(array $row, array $mergePuzzles = []): self + { + $id = $row['id']; + assert(is_string($id)); + $type = $row['type']; + assert(is_string($type)); + $submittedAt = $row['submitted_at']; + assert(is_string($submittedAt)); + $summary = $row['summary']; + assert(is_string($summary)); + + return new self( + id: $id, + type: $type, + submittedAt: new DateTimeImmutable($submittedAt), + reporterName: is_string($row['reporter_name']) ? $row['reporter_name'] : null, + reporterCode: is_string($row['reporter_code']) ? $row['reporter_code'] : null, + summary: $summary, + mergePuzzles: $mergePuzzles, + ); + } +} diff --git a/src/Results/PlayerNotification.php b/src/Results/PlayerNotification.php index 0d220f24..5a955446 100644 --- a/src/Results/PlayerNotification.php +++ b/src/Results/PlayerNotification.php @@ -47,6 +47,17 @@ public function __construct( public null|string $lendingPuzzleImage = null, public null|string $lendingManufacturerName = null, public null|int $lendingPiecesCount = null, + // Puzzle report notification fields + public null|string $changeRequestId = null, + public null|string $changeRequestPuzzleId = null, + public null|string $changeRequestPuzzleName = null, + public null|string $changeRequestPuzzleImage = null, + public null|string $changeRequestRejectionReason = null, + public null|string $mergeRequestId = null, + public null|string $mergeRequestPuzzleId = null, + public null|string $mergeRequestPuzzleName = null, + public null|string $mergeRequestPuzzleImage = null, + public null|string $mergeRequestRejectionReason = null, ) { } @@ -132,6 +143,17 @@ public static function fromDatabaseRow(array $row): self lendingPuzzleImage: is_string($lendingPuzzleImage) ? $lendingPuzzleImage : null, lendingManufacturerName: is_string($lendingManufacturerName) ? $lendingManufacturerName : null, lendingPiecesCount: isset($row['lending_pieces_count']) && is_numeric($row['lending_pieces_count']) ? (int) $row['lending_pieces_count'] : null, + // Puzzle report fields + changeRequestId: is_string($row['change_request_id'] ?? null) ? $row['change_request_id'] : null, + changeRequestPuzzleId: is_string($row['change_request_puzzle_id'] ?? null) ? $row['change_request_puzzle_id'] : null, + changeRequestPuzzleName: is_string($row['change_request_puzzle_name'] ?? null) ? $row['change_request_puzzle_name'] : null, + changeRequestPuzzleImage: is_string($row['change_request_puzzle_image'] ?? null) ? $row['change_request_puzzle_image'] : null, + changeRequestRejectionReason: is_string($row['change_request_rejection_reason'] ?? null) ? $row['change_request_rejection_reason'] : null, + mergeRequestId: is_string($row['merge_request_id'] ?? null) ? $row['merge_request_id'] : null, + mergeRequestPuzzleId: is_string($row['merge_request_puzzle_id'] ?? null) ? $row['merge_request_puzzle_id'] : null, + mergeRequestPuzzleName: is_string($row['merge_request_puzzle_name'] ?? null) ? $row['merge_request_puzzle_name'] : null, + mergeRequestPuzzleImage: is_string($row['merge_request_puzzle_image'] ?? null) ? $row['merge_request_puzzle_image'] : null, + mergeRequestRejectionReason: is_string($row['merge_request_rejection_reason'] ?? null) ? $row['merge_request_rejection_reason'] : null, ); } @@ -144,4 +166,14 @@ public function isPuzzleSolvingNotification(): bool { return $this->targetPlayerId !== null; } + + public function isChangeRequestNotification(): bool + { + return $this->changeRequestId !== null; + } + + public function isMergeRequestNotification(): bool + { + return $this->mergeRequestId !== null; + } } diff --git a/src/Results/PuzzleChangeRequestOverview.php b/src/Results/PuzzleChangeRequestOverview.php new file mode 100644 index 00000000..5bc1030c --- /dev/null +++ b/src/Results/PuzzleChangeRequestOverview.php @@ -0,0 +1,139 @@ + $row + */ + public static function fromDatabaseRow(array $row): self + { + $id = $row['id']; + assert(is_string($id)); + $status = $row['status']; + assert(is_string($status)); + $submittedAt = $row['submitted_at']; + assert(is_string($submittedAt)); + $reviewedAt = $row['reviewed_at']; + assert(is_string($reviewedAt) || $reviewedAt === null); + $puzzleId = $row['puzzle_id']; + assert(is_string($puzzleId)); + $puzzleName = $row['puzzle_name']; + assert(is_string($puzzleName)); + $puzzlePiecesCount = $row['puzzle_pieces_count']; + assert(is_int($puzzlePiecesCount)); + $reporterId = $row['reporter_id']; + assert(is_string($reporterId)); + + return new self( + id: $id, + status: PuzzleReportStatus::from($status), + submittedAt: new DateTimeImmutable($submittedAt), + reviewedAt: $reviewedAt !== null ? new DateTimeImmutable($reviewedAt) : null, + rejectionReason: is_string($row['rejection_reason']) ? $row['rejection_reason'] : null, + puzzleId: $puzzleId, + puzzleName: $puzzleName, + puzzlePiecesCount: $puzzlePiecesCount, + puzzleImage: is_string($row['puzzle_image']) ? $row['puzzle_image'] : null, + puzzleManufacturerName: is_string($row['puzzle_manufacturer_name']) ? $row['puzzle_manufacturer_name'] : null, + reporterId: $reporterId, + reporterName: is_string($row['reporter_name']) ? $row['reporter_name'] : null, + reporterCode: is_string($row['reporter_code']) ? $row['reporter_code'] : null, + reviewerId: is_string($row['reviewer_id']) ? $row['reviewer_id'] : null, + reviewerName: is_string($row['reviewer_name']) ? $row['reviewer_name'] : null, + proposedName: is_string($row['proposed_name']) ? $row['proposed_name'] : null, + proposedManufacturerId: is_string($row['proposed_manufacturer_id']) ? $row['proposed_manufacturer_id'] : null, + proposedManufacturerName: is_string($row['proposed_manufacturer_name']) ? $row['proposed_manufacturer_name'] : null, + proposedPiecesCount: is_int($row['proposed_pieces_count']) ? $row['proposed_pieces_count'] : null, + proposedEan: is_string($row['proposed_ean']) ? $row['proposed_ean'] : null, + proposedIdentificationNumber: is_string($row['proposed_identification_number']) ? $row['proposed_identification_number'] : null, + proposedImage: is_string($row['proposed_image']) ? $row['proposed_image'] : null, + originalName: is_string($row['original_name']) ? $row['original_name'] : null, + originalManufacturerId: is_string($row['original_manufacturer_id']) ? $row['original_manufacturer_id'] : null, + originalManufacturerName: is_string($row['original_manufacturer_name']) ? $row['original_manufacturer_name'] : null, + originalPiecesCount: is_int($row['original_pieces_count']) ? $row['original_pieces_count'] : null, + originalEan: is_string($row['original_ean']) ? $row['original_ean'] : null, + originalIdentificationNumber: is_string($row['original_identification_number']) ? $row['original_identification_number'] : null, + originalImage: is_string($row['original_image']) ? $row['original_image'] : null, + ); + } + + public function hasNameChange(): bool + { + return $this->proposedName !== null && $this->proposedName !== $this->originalName; + } + + public function hasManufacturerChange(): bool + { + return $this->proposedManufacturerId !== null && $this->proposedManufacturerId !== $this->originalManufacturerId; + } + + public function hasPiecesCountChange(): bool + { + return $this->proposedPiecesCount !== null && $this->proposedPiecesCount !== $this->originalPiecesCount; + } + + public function hasEanChange(): bool + { + return $this->proposedEan !== null && $this->proposedEan !== $this->originalEan; + } + + public function hasIdentificationNumberChange(): bool + { + return $this->proposedIdentificationNumber !== null && $this->proposedIdentificationNumber !== $this->originalIdentificationNumber; + } + + public function hasImageChange(): bool + { + return $this->proposedImage !== null; + } + + public function hasAnyChange(): bool + { + return $this->hasNameChange() + || $this->hasManufacturerChange() + || $this->hasPiecesCountChange() + || $this->hasEanChange() + || $this->hasIdentificationNumberChange() + || $this->hasImageChange(); + } +} diff --git a/src/Results/PuzzleMergeRequestOverview.php b/src/Results/PuzzleMergeRequestOverview.php new file mode 100644 index 00000000..3c8cf701 --- /dev/null +++ b/src/Results/PuzzleMergeRequestOverview.php @@ -0,0 +1,102 @@ + $reportedDuplicatePuzzleIds + * @param array $mergedPuzzleIds + */ + public function __construct( + public string $id, + public PuzzleReportStatus $status, + public DateTimeImmutable $submittedAt, + public null|DateTimeImmutable $reviewedAt, + public null|string $rejectionReason, + public array $reportedDuplicatePuzzleIds, + public null|string $finalMergedPuzzleId, + public array $mergedPuzzleIds, + public null|string $sourcePuzzleId, + public string $sourcePuzzleName, + public null|int $sourcePuzzlePiecesCount, + public null|string $sourcePuzzleImage, + public null|string $sourcePuzzleManufacturerName, + public null|string $survivorPuzzleName, + public null|int $survivorPuzzlePiecesCount, + public null|string $survivorPuzzleImage, + public null|string $survivorPuzzleManufacturerName, + public null|string $reporterId, + public null|string $reporterName, + public null|string $reporterCode, + public null|string $reviewerId, + public null|string $reviewerName, + ) { + } + + /** + * @param array $row + */ + public static function fromDatabaseRow(array $row): self + { + $id = $row['id']; + assert(is_string($id)); + $status = $row['status']; + assert(is_string($status)); + $submittedAt = $row['submitted_at']; + assert(is_string($submittedAt)); + $reviewedAt = $row['reviewed_at']; + assert(is_string($reviewedAt) || $reviewedAt === null); + + // Use stored puzzle name as fallback when puzzle is deleted + $sourcePuzzleName = is_string($row['source_puzzle_name']) + ? $row['source_puzzle_name'] + : (is_string($row['stored_source_puzzle_name']) ? $row['stored_source_puzzle_name'] : 'Deleted puzzle'); + + $reportedDuplicateIdsJson = $row['reported_duplicate_puzzle_ids']; + assert(is_string($reportedDuplicateIdsJson)); + /** @var array $reportedDuplicateIds */ + $reportedDuplicateIds = json_decode($reportedDuplicateIdsJson, true) ?? []; + + $mergedIdsJson = $row['merged_puzzle_ids'] ?? null; + /** @var array $mergedIds */ + $mergedIds = is_string($mergedIdsJson) + ? (json_decode($mergedIdsJson, true) ?? []) + : []; + + return new self( + id: $id, + status: PuzzleReportStatus::from($status), + submittedAt: new DateTimeImmutable($submittedAt), + reviewedAt: $reviewedAt !== null ? new DateTimeImmutable($reviewedAt) : null, + rejectionReason: is_string($row['rejection_reason']) ? $row['rejection_reason'] : null, + reportedDuplicatePuzzleIds: $reportedDuplicateIds, + finalMergedPuzzleId: is_string($row['survivor_puzzle_id']) ? $row['survivor_puzzle_id'] : null, + mergedPuzzleIds: $mergedIds, + sourcePuzzleId: is_string($row['source_puzzle_id']) ? $row['source_puzzle_id'] : null, + sourcePuzzleName: $sourcePuzzleName, + sourcePuzzlePiecesCount: is_int($row['source_puzzle_pieces_count']) ? $row['source_puzzle_pieces_count'] : null, + sourcePuzzleImage: is_string($row['source_puzzle_image']) ? $row['source_puzzle_image'] : null, + sourcePuzzleManufacturerName: is_string($row['source_puzzle_manufacturer_name']) ? $row['source_puzzle_manufacturer_name'] : null, + survivorPuzzleName: is_string($row['survivor_puzzle_name']) ? $row['survivor_puzzle_name'] : null, + survivorPuzzlePiecesCount: is_int($row['survivor_puzzle_pieces_count']) ? $row['survivor_puzzle_pieces_count'] : null, + survivorPuzzleImage: is_string($row['survivor_puzzle_image']) ? $row['survivor_puzzle_image'] : null, + survivorPuzzleManufacturerName: is_string($row['survivor_puzzle_manufacturer_name']) ? $row['survivor_puzzle_manufacturer_name'] : null, + reporterId: is_string($row['reporter_id']) ? $row['reporter_id'] : null, + reporterName: is_string($row['reporter_name']) ? $row['reporter_name'] : null, + reporterCode: is_string($row['reporter_code']) ? $row['reporter_code'] : null, + reviewerId: is_string($row['reviewer_id']) ? $row['reviewer_id'] : null, + reviewerName: is_string($row['reviewer_name']) ? $row['reviewer_name'] : null, + ); + } + + public function getDuplicateCount(): int + { + return count($this->reportedDuplicatePuzzleIds); + } +} diff --git a/src/Services/MessengerMiddleware/ClearEntityManagerMiddleware.php b/src/Services/MessengerMiddleware/ClearEntityManagerMiddleware.php new file mode 100644 index 00000000..2acd45e1 --- /dev/null +++ b/src/Services/MessengerMiddleware/ClearEntityManagerMiddleware.php @@ -0,0 +1,41 @@ +getMessage(); + + if ($message instanceof RequiresFreshEntityManagerState) { + // Only clear if no transaction is active + // This prevents clearing during nested dispatches (which would lose parent's pending changes) + // Events from postFlush are safe to clear because the parent transaction has already committed + if (!$this->entityManager->getConnection()->isTransactionActive()) { + $this->entityManager->clear(); + } + } + + return $stack->next()->handle($envelope, $stack); + } +} diff --git a/src/Services/MessengerMiddleware/RequiresFreshEntityManagerState.php b/src/Services/MessengerMiddleware/RequiresFreshEntityManagerState.php new file mode 100644 index 00000000..bd5f0a5e --- /dev/null +++ b/src/Services/MessengerMiddleware/RequiresFreshEntityManagerState.php @@ -0,0 +1,16 @@ +slugger->slug(strtolower("$brandName-$puzzleName-$piecesCount")); + $baseName = "$slug.$extension"; + + // Check for duplicates and add UUID suffix if needed + if ($this->filesystem->fileExists($baseName)) { + $uuid = substr(Uuid::uuid7()->toString(), 0, 8); + $baseName = "$slug-$uuid.$extension"; + } + + return $baseName; + } +} diff --git a/src/Services/SentryScopeResetListener.php b/src/Services/SentryScopeResetListener.php new file mode 100644 index 00000000..faccfcc9 --- /dev/null +++ b/src/Services/SentryScopeResetListener.php @@ -0,0 +1,43 @@ +isMainRequest() === false) { + return; + } + + $this->hub->configureScope(static function (Scope $scope): void { + // Clear all accumulated state: breadcrumbs, tags, contexts, extra, user, fingerprint, level, span, flags + $scope->clear(); + + // Reset propagation context to get fresh trace/span IDs for this request + $scope->setPropagationContext(PropagationContext::fromDefaults()); + }); + } +} diff --git a/src/Value/NotificationType.php b/src/Value/NotificationType.php index 02a61e60..ccb91101 100644 --- a/src/Value/NotificationType.php +++ b/src/Value/NotificationType.php @@ -16,4 +16,10 @@ enum NotificationType: string case PuzzlePassedToYou = 'PuzzlePassedToYou'; case PuzzlePassedFromYou = 'PuzzlePassedFromYou'; case YourPuzzleWasPassed = 'YourPuzzleWasPassed'; + + // Puzzle report notifications + case PuzzleChangeRequestApproved = 'PuzzleChangeRequestApproved'; + case PuzzleChangeRequestRejected = 'PuzzleChangeRequestRejected'; + case PuzzleMergeRequestApproved = 'PuzzleMergeRequestApproved'; + case PuzzleMergeRequestRejected = 'PuzzleMergeRequestRejected'; } diff --git a/src/Value/PuzzleReportStatus.php b/src/Value/PuzzleReportStatus.php new file mode 100644 index 00000000..ca8f138c --- /dev/null +++ b/src/Value/PuzzleReportStatus.php @@ -0,0 +1,12 @@ +{{ 'forms.puzzle_photo_help'|trans }} + + + +
+
+ {{ 'puzzle_report.form.photo_help_title'|trans }} +
    +
  • {{ 'puzzle_report.form.photo_help_1'|trans }}
  • +
  • {{ 'puzzle_report.form.photo_help_2'|trans }}
  • +
  • {{ 'puzzle_report.form.photo_help_3'|trans }}
  • +
  • {{ 'puzzle_report.form.photo_help_4'|trans }}
  • +
+ +
+
+ Wrong example + {{ 'puzzle_report.form.photo_example_wrong'|trans }} +
+
+ OK example + {{ 'puzzle_report.form.photo_example_ok'|trans }} +
+
+ Perfect example + {{ 'puzzle_report.form.photo_example_perfect'|trans }} +
+
+
+
+
{{ 'forms.drop_file'|trans }} diff --git a/templates/admin/puzzle_change_request_detail.html.twig b/templates/admin/puzzle_change_request_detail.html.twig new file mode 100644 index 00000000..03d3a34d --- /dev/null +++ b/templates/admin/puzzle_change_request_detail.html.twig @@ -0,0 +1,232 @@ +{% extends 'base.html.twig' %} + +{% block title %}Change Request - {{ request.puzzleName }}{% endblock %} + +{% block content %} + + +
+
+
+
+

Change Request

+ {% if request.status.value == 'pending' %} + Pending + {% elseif request.status.value == 'approved' %} + Approved + {% else %} + Rejected + {% endif %} +
+ +
+ {# Puzzle info header #} +
+ {% if request.puzzleImage %} + {{ request.puzzleName }} + {% endif %} +
+
+ + {{ request.puzzleName }} + +
+

+ {{ request.puzzleManufacturerName }} - {{ request.puzzlePiecesCount }} pieces +

+
+
+ + {# Changes comparison #} +
Proposed Changes
+
+ + + + + + + + + + {% if request.hasNameChange() %} + + + + + + {% endif %} + + {% if request.hasManufacturerChange() %} + + + + + + {% endif %} + + {% if request.hasPiecesCountChange() %} + + + + + + {% endif %} + + {% if request.hasEanChange() %} + + + + + + {% endif %} + + {% if request.hasIdentificationNumberChange() %} + + + + + + {% endif %} + + {% if request.hasImageChange() %} + + + + + + {% endif %} + +
FieldOriginalProposed
Name{{ request.originalName }}{{ request.proposedName }}
Manufacturer{{ request.originalManufacturerName|default('Not set') }}{{ request.proposedManufacturerName }}
Pieces{{ request.originalPiecesCount }}{{ request.proposedPiecesCount }}
EAN{{ request.originalEan|default('Not set') }}{{ request.proposedEan }}
Brand Code{{ request.originalIdentificationNumber|default('Not set') }}{{ request.proposedIdentificationNumber }}
Image + {% if request.originalImage %} + Original + {% else %} + No image + {% endif %} + + Proposed +
+
+ + {% if not request.hasAnyChange() %} +
+ No changes were proposed. The user may have submitted the form without modifications. +
+ {% endif %} + + {# Rejection reason if rejected #} + {% if request.status.value == 'rejected' and request.rejectionReason %} +
+ Rejection Reason:
+ {{ request.rejectionReason }} +
+ {% endif %} + + {# Action buttons for pending requests #} + {% if request.status.value == 'pending' %} +
+
+
+ +
+ + +
+ {% endif %} +
+
+
+ +
+ {# Reporter info #} +
+
+
Reporter
+
+
+ {% if request.reporterName %} + + {{ request.reporterName }} + + {% if request.reporterCode %} + #{{ request.reporterCode }} + {% endif %} + {% else %} + Unknown user + {% endif %} +
+ Submitted: {{ request.submittedAt|ago }} +
+
+ + {# Review info if reviewed #} + {% if request.reviewedAt %} +
+
+
Review
+
+
+ {% if request.reviewerName %} + Reviewed by: {{ request.reviewerName }} + {% endif %} +
+ {{ request.reviewedAt|ago }} +
+
+ {% endif %} +
+
+ +{# Reject Modal #} +{% if request.status.value == 'pending' %} + +{% endif %} +{% endblock %} diff --git a/templates/admin/puzzle_change_requests.html.twig b/templates/admin/puzzle_change_requests.html.twig new file mode 100644 index 00000000..cdd9e28b --- /dev/null +++ b/templates/admin/puzzle_change_requests.html.twig @@ -0,0 +1,159 @@ +{% extends 'base.html.twig' %} + +{% block title %}Puzzle Change Requests{% endblock %} + +{% block content %} +

Puzzle Change Requests

+ + + + {% if requests is empty %} +
+ No {{ active_tab }} change requests. +
+ {% else %} +
+
+ + + + + + + + {% if active_tab != 'pending' %} + + {% endif %} + + + + + {% for request in requests %} + + + + + + {% if active_tab != 'pending' %} + + {% endif %} + + + {% endfor %} + +
PuzzleReporterSubmittedChangesReviewed
+
+ {% if request.puzzleImage %} + {{ request.puzzleName }} + {% endif %} +
+ + {{ request.puzzleName }} + +
+ + {{ request.puzzleManufacturerName }} - {{ request.puzzlePiecesCount }} pcs + +
+
+
+ + {{ request.submittedAt|ago }} + {% if request.reporterName %} + • {{ request.reporterName }} + {% endif %} + +
+ {% if active_tab != 'pending' and request.reviewedAt %} +
+ + Reviewed {{ request.reviewedAt|ago }}{% if request.reviewerName %} by {{ request.reviewerName }}{% endif %} + +
+ {% endif %} +
+
+ {% if request.hasNameChange() %}Name{% endif %} + {% if request.hasManufacturerChange() %}Manufacturer{% endif %} + {% if request.hasPiecesCountChange() %}Pieces{% endif %} + {% if request.hasEanChange() %}EAN{% endif %} + {% if request.hasIdentificationNumberChange() %}Brand Code{% endif %} + {% if request.hasImageChange() %}Image{% endif %} +
+ + View + +
+
+ {% if request.reporterName %} + + {{ request.reporterName }} + + {% if request.reporterCode %} +
#{{ request.reporterCode }} + {% endif %} + {% else %} + Unknown + {% endif %} +
+ {{ request.submittedAt|ago }} + +
+ {% if request.hasNameChange() %}Name{% endif %} + {% if request.hasManufacturerChange() %}Manufacturer{% endif %} + {% if request.hasPiecesCountChange() %}Pieces{% endif %} + {% if request.hasEanChange() %}EAN{% endif %} + {% if request.hasIdentificationNumberChange() %}Brand Code{% endif %} + {% if request.hasImageChange() %}Image{% endif %} +
+
+ {% if request.reviewedAt %} + {{ request.reviewedAt|ago }} + {% if request.reviewerName %} +
by {{ request.reviewerName }} + {% endif %} + {% endif %} +
+ + View + +
+
+
+ {% endif %} +{% endblock %} diff --git a/templates/admin/puzzle_merge_request_detail.html.twig b/templates/admin/puzzle_merge_request_detail.html.twig new file mode 100644 index 00000000..3da7f608 --- /dev/null +++ b/templates/admin/puzzle_merge_request_detail.html.twig @@ -0,0 +1,282 @@ +{% extends 'base.html.twig' %} + +{% block title %}Merge Request - {{ request.sourcePuzzleName }}{% endblock %} + +{% block content %} + + +
+
+

Merge Request

+ {% if request.status.value == 'pending' %} + Pending + {% elseif request.status.value == 'approved' %} + Approved + {% else %} + Rejected + {% endif %} +
+ +
+ {# Reporter info #} +
+ Reported by: + {% if request.reporterName %} + + {{ request.reporterName }} + + {% if request.reporterCode %} + #{{ request.reporterCode }} + {% endif %} + {% else %} + Unknown user + {% endif %} + {{ request.submittedAt|ago }} +
+ + {# Rejection reason if rejected #} + {% if request.status.value == 'rejected' and request.rejectionReason %} +
+ Rejection Reason:
+ {{ request.rejectionReason }} +
+ {% endif %} + + {% if puzzles is empty %} +
+ All reported puzzles have been deleted. +
+ {% else %} + {# Puzzles grid #} +
Reported Duplicate Puzzles ({{ puzzles|length }})
+
+ {% for puzzle in puzzles %} +
+
+ {% if puzzle.puzzleImage %} + {{ puzzle.puzzleName }} + {% else %} +
+ +
+ {% endif %} +
+
+ + {{ puzzle.puzzleName }} + +
+
    +
  • Manufacturer: {{ puzzle.manufacturerName|default('Not set') }}
  • +
  • Pieces: {{ puzzle.piecesCount }}
  • +
  • EAN: {{ puzzle.puzzleEan|default('Not set') }}
  • +
  • Brand Code: {{ puzzle.puzzleIdentificationNumber|default('Not set') }}
  • +
  • Solving Times: {{ puzzle.solvedTimes }}
  • +
+
+
+
+ {% endfor %} +
+ + {% if request.status.value == 'pending' %} + {# Merge form #} +
+
Merge Configuration
+ +
+ + All puzzles will be merged together and all solving times will be preserved. Nothing will be lost! +
+ +

+ Configure the merged puzzle data below. Values are auto-filled from the reported puzzles. + The puzzle with the most solving times will automatically become the survivor (keeping the id and URL address). +

+ +
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {% if merged_data.pieces_counts|length == 1 %} + + {% else %} + + Puzzles have different piece counts! + {% endif %} +
+ +
+ + + {% if merged_data.manufacturers|length > 1 %} + Puzzles have different manufacturers! + {% endif %} +
+
+ +
+ {# Hidden survivor puzzle id - automatically selected based on most solving times #} + + + {# Image selection #} + {% if merged_data.images|length > 1 %} + {% set default_image_id = merged_data.survivor_puzzle_id in merged_data.images|keys ? merged_data.survivor_puzzle_id : merged_data.images|keys|first %} +
+
+ Select Image +
+
+
+ {% for puzzleId, image in merged_data.images %} +
+ + +
+ {% endfor %} +
+
+
+ {% endif %} +
+
+ +
+
+ + + +
+
+ {% endif %} + {% endif %} + + {# Review info if reviewed #} + {% if request.reviewedAt %} +
+
+ Reviewed: + {{ request.reviewedAt|ago }} + {% if request.reviewerName %} + by {{ request.reviewerName }} + {% endif %} + {% if request.finalMergedPuzzleId %} +
+ Final Puzzle: + + View merged puzzle + + {% endif %} +
+ {% endif %} +
+
+ +{# Reject Modal #} +{% if request.status.value == 'pending' %} + +{% endif %} +{% endblock %} diff --git a/templates/admin/puzzle_merge_requests.html.twig b/templates/admin/puzzle_merge_requests.html.twig new file mode 100644 index 00000000..452d3bb8 --- /dev/null +++ b/templates/admin/puzzle_merge_requests.html.twig @@ -0,0 +1,158 @@ +{% extends 'base.html.twig' %} + +{% block title %}Puzzle Merge Requests{% endblock %} + +{% block content %} +

Puzzle Merge Requests

+ + + + {% if requests is empty %} +
+ No {{ active_tab }} merge requests. +
+ {% else %} +
+
+ + + + + + + + {% if active_tab != 'pending' %} + + {% endif %} + + + + + {% for request in requests %} + + + + + + {% if active_tab != 'pending' %} + + {% endif %} + + + {% endfor %} + +
Source PuzzleReporterSubmittedDuplicatesReviewed
+ {% set puzzleImage = request.survivorPuzzleImage ?? request.sourcePuzzleImage %} + {% set puzzleName = request.survivorPuzzleName ?? request.sourcePuzzleName %} + {% set puzzlePiecesCount = request.survivorPuzzlePiecesCount ?? request.sourcePuzzlePiecesCount %} + {% set puzzleManufacturerName = request.survivorPuzzleManufacturerName ?? request.sourcePuzzleManufacturerName %} +
+ {% if puzzleImage %} + {{ puzzleName }} + {% endif %} +
+ {% if request.finalMergedPuzzleId %} + + {{ puzzleName }} + + {% elseif request.sourcePuzzleId %} + + {{ puzzleName }} + + {% else %} + {{ puzzleName }} + {% endif %} +
+ + {{ puzzleManufacturerName }} - {{ puzzlePiecesCount }} pcs + +
+
+
+ + {{ request.submittedAt|ago }} + {% if request.reporterName %} + • {{ request.reporterName }} + {% endif %} + +
+ {% if active_tab != 'pending' and request.reviewedAt %} +
+ + Reviewed {{ request.reviewedAt|ago }}{% if request.reviewerName %} by {{ request.reviewerName }}{% endif %} + +
+ {% endif %} +
+ {{ request.duplicateCount }} puzzles + + View + +
+
+ {% if request.reporterName %} + + {{ request.reporterName }} + + {% if request.reporterCode %} +
#{{ request.reporterCode }} + {% endif %} + {% else %} + Unknown + {% endif %} +
+ {{ request.submittedAt|ago }} + + {{ request.duplicateCount }} puzzles + + {% if request.reviewedAt %} + {{ request.reviewedAt|ago }} + {% if request.reviewerName %} +
by {{ request.reviewerName }} + {% endif %} + {% endif %} +
+ + View + +
+
+
+ {% endif %} +{% endblock %} diff --git a/templates/base.html.twig b/templates/base.html.twig index ab47f7a4..1a1d932f 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -136,6 +136,17 @@
diff --git a/templates/puzzle_detail.html.twig b/templates/puzzle_detail.html.twig index 8e80c324..0be37894 100644 --- a/templates/puzzle_detail.html.twig +++ b/templates/puzzle_detail.html.twig @@ -34,6 +34,15 @@ {{ 'puzzler_offers.badge'|trans({'%count%': offers_count}) }} {% endif %} + {% if has_pending_proposals %} + + + {{ 'pending_proposals.badge'|trans }} + + {% endif %} {{ 'pieces_count'|trans({ '%count%': puzzle.piecesCount })|raw }} diff --git a/tests/Controller/Admin/PuzzleChangeRequestControllerTest.php b/tests/Controller/Admin/PuzzleChangeRequestControllerTest.php new file mode 100644 index 00000000..85e06f04 --- /dev/null +++ b/tests/Controller/Admin/PuzzleChangeRequestControllerTest.php @@ -0,0 +1,34 @@ +request('GET', '/admin/puzzle-change-requests'); + + $this->assertResponseRedirects('/login'); + } + + public function testApproveIsNotAccessibleByAnonymous(): void + { + $browser = self::createClient(); + $browser->request('POST', '/admin/puzzle-change-requests/00000000-0000-0000-0000-000000000000/approve'); + + $this->assertResponseRedirects('/login'); + } + + public function testRejectIsNotAccessibleByAnonymous(): void + { + $browser = self::createClient(); + $browser->request('POST', '/admin/puzzle-change-requests/00000000-0000-0000-0000-000000000000/reject'); + + $this->assertResponseRedirects('/login'); + } +} diff --git a/tests/Controller/Admin/PuzzleMergeRequestControllerTest.php b/tests/Controller/Admin/PuzzleMergeRequestControllerTest.php new file mode 100644 index 00000000..ecc118c2 --- /dev/null +++ b/tests/Controller/Admin/PuzzleMergeRequestControllerTest.php @@ -0,0 +1,34 @@ +request('GET', '/admin/puzzle-merge-requests'); + + $this->assertResponseRedirects('/login'); + } + + public function testApproveIsNotAccessibleByAnonymous(): void + { + $browser = self::createClient(); + $browser->request('POST', '/admin/puzzle-merge-requests/00000000-0000-0000-0000-000000000000/approve'); + + $this->assertResponseRedirects('/login'); + } + + public function testRejectIsNotAccessibleByAnonymous(): void + { + $browser = self::createClient(); + $browser->request('POST', '/admin/puzzle-merge-requests/00000000-0000-0000-0000-000000000000/reject'); + + $this->assertResponseRedirects('/login'); + } +} diff --git a/tests/DataFixtures/CollectionItemFixture.php b/tests/DataFixtures/CollectionItemFixture.php index a07d1db5..585824a0 100644 --- a/tests/DataFixtures/CollectionItemFixture.php +++ b/tests/DataFixtures/CollectionItemFixture.php @@ -40,6 +40,11 @@ final class CollectionItemFixture extends Fixture implements DependentFixtureInt public const string ITEM_22 = '018d0009-0000-0000-0000-000000000022'; public const string ITEM_23 = '018d0009-0000-0000-0000-000000000023'; public const string ITEM_24 = '018d0009-0000-0000-0000-000000000024'; + public const string ITEM_25 = '018d0009-0000-0000-0000-000000000025'; + public const string ITEM_26 = '018d0009-0000-0000-0000-000000000026'; + public const string ITEM_27 = '018d0009-0000-0000-0000-000000000027'; + public const string ITEM_28 = '018d0009-0000-0000-0000-000000000028'; + public const string ITEM_29 = '018d0009-0000-0000-0000-000000000029'; public function __construct( private readonly ClockInterface $clock, @@ -50,6 +55,7 @@ public function load(ObjectManager $manager): void { $player1 = $this->getReference(PlayerFixture::PLAYER_REGULAR, Player::class); $player2 = $this->getReference(PlayerFixture::PLAYER_PRIVATE, Player::class); + $player3 = $this->getReference(PlayerFixture::PLAYER_ADMIN, Player::class); $player5 = $this->getReference(PlayerFixture::PLAYER_WITH_STRIPE, Player::class); $publicCollection = $this->getReference(CollectionFixture::COLLECTION_PUBLIC, Collection::class); @@ -61,6 +67,7 @@ public function load(ObjectManager $manager): void $puzzle500_02 = $this->getReference(PuzzleFixture::PUZZLE_500_02, Puzzle::class); $puzzle500_03 = $this->getReference(PuzzleFixture::PUZZLE_500_03, Puzzle::class); $puzzle500_04 = $this->getReference(PuzzleFixture::PUZZLE_500_04, Puzzle::class); + $puzzle500_05 = $this->getReference(PuzzleFixture::PUZZLE_500_05, Puzzle::class); $puzzle1000_01 = $this->getReference(PuzzleFixture::PUZZLE_1000_01, Puzzle::class); $puzzle1000_02 = $this->getReference(PuzzleFixture::PUZZLE_1000_02, Puzzle::class); $puzzle1000_03 = $this->getReference(PuzzleFixture::PUZZLE_1000_03, Puzzle::class); @@ -339,6 +346,67 @@ public function load(ObjectManager $manager): void $manager->persist($item24); $this->addReference(self::ITEM_24, $item24); + // ITEM_25 & ITEM_26: PUZZLE_500_05 for merge testing + // These items should be migrated to survivor puzzle during merge + $item25 = $this->createCollectionItem( + id: self::ITEM_25, + player: $player3, // PLAYER_ADMIN + puzzle: $puzzle500_05, + collection: null, + daysAgo: 8, + ); + $manager->persist($item25); + $this->addReference(self::ITEM_25, $item25); + + $item26 = $this->createCollectionItem( + id: self::ITEM_26, + player: $player2, // PLAYER_PRIVATE + puzzle: $puzzle500_05, + collection: null, + daysAgo: 6, + ); + $manager->persist($item26); + $this->addReference(self::ITEM_26, $item26); + + // ITEM_27: PLAYER_WITH_STRIPE + PUZZLE_500_05 + COLLECTION_PUBLIC + // Creates deduplication scenario with ITEM_21 (same player + same collection + survivor puzzle) + // When merging PUZZLE_500_05 into PUZZLE_500_04, this item should be REMOVED (not migrated) + $item27 = $this->createCollectionItem( + id: self::ITEM_27, + player: $player5, // PLAYER_WITH_STRIPE + puzzle: $puzzle500_05, + collection: $publicCollection, + daysAgo: 4, + ); + $manager->persist($item27); + $this->addReference(self::ITEM_27, $item27); + + // ITEM_28: PLAYER_ADMIN + PUZZLE_500_04 + null (system collection) + // Creates deduplication scenario for null collection with ITEM_25 + // When merging PUZZLE_500_05 into PUZZLE_500_04, ITEM_25 should be REMOVED (not migrated) + $item28 = $this->createCollectionItem( + id: self::ITEM_28, + player: $player3, // PLAYER_ADMIN + puzzle: $puzzle500_04, + collection: null, + daysAgo: 3, + ); + $manager->persist($item28); + $this->addReference(self::ITEM_28, $item28); + + // ITEM_29: PLAYER_PRIVATE + PUZZLE_500_04 + null (system collection) + // Creates deduplication scenario for null collection with ITEM_26 + // When merging PUZZLE_500_05 into PUZZLE_500_04, ITEM_26 should be REMOVED (not migrated) + $item29 = $this->createCollectionItem( + id: self::ITEM_29, + player: $player2, // PLAYER_PRIVATE + puzzle: $puzzle500_04, + collection: null, + daysAgo: 2, + ); + $manager->persist($item29); + $this->addReference(self::ITEM_29, $item29); + $manager->flush(); } diff --git a/tests/DataFixtures/LentPuzzleFixture.php b/tests/DataFixtures/LentPuzzleFixture.php index 90884154..3827c85c 100644 --- a/tests/DataFixtures/LentPuzzleFixture.php +++ b/tests/DataFixtures/LentPuzzleFixture.php @@ -21,6 +21,8 @@ final class LentPuzzleFixture extends Fixture implements DependentFixtureInterfa public const string LENT_04 = '018d000c-0000-0000-0000-000000000004'; public const string LENT_05 = '018d000c-0000-0000-0000-000000000005'; public const string LENT_06 = '018d000c-0000-0000-0000-000000000006'; + public const string LENT_07 = '018d000c-0000-0000-0000-000000000007'; + public const string LENT_08 = '018d000c-0000-0000-0000-000000000008'; public function __construct( private readonly ClockInterface $clock, @@ -30,11 +32,14 @@ public function __construct( public function load(ObjectManager $manager): void { $player1 = $this->getReference(PlayerFixture::PLAYER_REGULAR, Player::class); + $player3 = $this->getReference(PlayerFixture::PLAYER_ADMIN, Player::class); $player4 = $this->getReference(PlayerFixture::PLAYER_WITH_FAVORITES, Player::class); $player5 = $this->getReference(PlayerFixture::PLAYER_WITH_STRIPE, Player::class); // Puzzles from player5's collection $puzzle500_03 = $this->getReference(PuzzleFixture::PUZZLE_500_03, Puzzle::class); + $puzzle500_04 = $this->getReference(PuzzleFixture::PUZZLE_500_04, Puzzle::class); + $puzzle500_05 = $this->getReference(PuzzleFixture::PUZZLE_500_05, Puzzle::class); $puzzle1000_01 = $this->getReference(PuzzleFixture::PUZZLE_1000_01, Puzzle::class); $puzzle1500_01 = $this->getReference(PuzzleFixture::PUZZLE_1500_01, Puzzle::class); $puzzle1500_02 = $this->getReference(PuzzleFixture::PUZZLE_1500_02, Puzzle::class); @@ -126,6 +131,37 @@ public function load(ObjectManager $manager): void $manager->persist($lent06); $this->addReference(self::LENT_06, $lent06); + // LENT_07: PLAYER_REGULAR lends PUZZLE_500_05 to PLAYER_ADMIN (for merge testing) + // This record should be migrated to survivor puzzle during merge + $lent07 = $this->createLentPuzzle( + id: self::LENT_07, + puzzle: $puzzle500_05, + ownerPlayer: $player1, + ownerName: null, + currentHolderPlayer: $player3, + currentHolderName: null, + daysAgo: 8, + notes: 'Merge test puzzle', + ); + $manager->persist($lent07); + $this->addReference(self::LENT_07, $lent07); + + // LENT_08: PLAYER_REGULAR lends PUZZLE_500_04 to PLAYER_WITH_FAVORITES (for merge deduplication testing) + // Creates deduplication scenario with LENT_07 (same owner has both puzzles lent out) + // When merging PUZZLE_500_05 into PUZZLE_500_04, LENT_07 should be REMOVED (not migrated) + $lent08 = $this->createLentPuzzle( + id: self::LENT_08, + puzzle: $puzzle500_04, + ownerPlayer: $player1, + ownerName: null, + currentHolderPlayer: $player4, + currentHolderName: null, + daysAgo: 6, + notes: 'Deduplication test puzzle', + ); + $manager->persist($lent08); + $this->addReference(self::LENT_08, $lent08); + $manager->flush(); } diff --git a/tests/DataFixtures/MembershipFixture.php b/tests/DataFixtures/MembershipFixture.php index df971248..b0112d18 100644 --- a/tests/DataFixtures/MembershipFixture.php +++ b/tests/DataFixtures/MembershipFixture.php @@ -15,6 +15,7 @@ final class MembershipFixture extends Fixture implements DependentFixtureInterface { public const string MEMBERSHIP_ACTIVE = '018d0007-0000-0000-0000-000000000001'; + public const string MEMBERSHIP_ADMIN = '018d0007-0000-0000-0000-000000000002'; public function __construct( private readonly ClockInterface $clock, @@ -24,6 +25,7 @@ public function __construct( public function load(ObjectManager $manager): void { $playerWithStripe = $this->getReference(PlayerFixture::PLAYER_WITH_STRIPE, Player::class); + $playerAdmin = $this->getReference(PlayerFixture::PLAYER_ADMIN, Player::class); $activeMembership = $this->createMembership( id: self::MEMBERSHIP_ACTIVE, @@ -34,6 +36,15 @@ public function load(ObjectManager $manager): void $manager->persist($activeMembership); $this->addReference(self::MEMBERSHIP_ACTIVE, $activeMembership); + $adminMembership = $this->createMembership( + id: self::MEMBERSHIP_ADMIN, + player: $playerAdmin, + stripeSubscriptionId: 'sub_admin_123456789', + daysFromNow: 30, + ); + $manager->persist($adminMembership); + $this->addReference(self::MEMBERSHIP_ADMIN, $adminMembership); + $manager->flush(); } diff --git a/tests/DataFixtures/PuzzleReportFixture.php b/tests/DataFixtures/PuzzleReportFixture.php new file mode 100644 index 00000000..e309f9ac --- /dev/null +++ b/tests/DataFixtures/PuzzleReportFixture.php @@ -0,0 +1,137 @@ +getReference(PlayerFixture::PLAYER_REGULAR, Player::class); + $adminPlayer = $this->getReference(PlayerFixture::PLAYER_ADMIN, Player::class); + $puzzle500_01 = $this->getReference(PuzzleFixture::PUZZLE_500_01, Puzzle::class); + $puzzle500_02 = $this->getReference(PuzzleFixture::PUZZLE_500_02, Puzzle::class); + $puzzle500_03 = $this->getReference(PuzzleFixture::PUZZLE_500_03, Puzzle::class); + $ravensburger = $this->getReference(ManufacturerFixture::MANUFACTURER_RAVENSBURGER, Manufacturer::class); + + $now = $this->clock->now(); + + // Pending change request + $pendingChange = new PuzzleChangeRequest( + id: Uuid::fromString(self::CHANGE_REQUEST_PENDING), + puzzle: $puzzle500_01, + reporter: $regularPlayer, + submittedAt: $now, + proposedName: 'Updated Puzzle Name', + proposedManufacturer: null, + proposedPiecesCount: null, + proposedEan: '1234567890123', + proposedIdentificationNumber: null, + proposedImage: null, + originalName: $puzzle500_01->name, + originalManufacturerId: $puzzle500_01->manufacturer?->id, + originalPiecesCount: $puzzle500_01->piecesCount, + originalEan: $puzzle500_01->ean, + originalIdentificationNumber: $puzzle500_01->identificationNumber, + originalImage: $puzzle500_01->image, + ); + $manager->persist($pendingChange); + $this->addReference(self::CHANGE_REQUEST_PENDING, $pendingChange); + + // Approved change request + $approvedChange = new PuzzleChangeRequest( + id: Uuid::fromString(self::CHANGE_REQUEST_APPROVED), + puzzle: $puzzle500_02, + reporter: $regularPlayer, + submittedAt: $now->modify('-1 day'), + proposedName: 'Already Approved Name', + proposedManufacturer: null, + proposedPiecesCount: 600, + proposedEan: null, + proposedIdentificationNumber: null, + proposedImage: null, + originalName: $puzzle500_02->name, + originalManufacturerId: $puzzle500_02->manufacturer?->id, + originalPiecesCount: $puzzle500_02->piecesCount, + originalEan: $puzzle500_02->ean, + originalIdentificationNumber: $puzzle500_02->identificationNumber, + originalImage: $puzzle500_02->image, + ); + $approvedChange->approve($adminPlayer, $now); + $manager->persist($approvedChange); + $this->addReference(self::CHANGE_REQUEST_APPROVED, $approvedChange); + + // Rejected change request + $rejectedChange = new PuzzleChangeRequest( + id: Uuid::fromString(self::CHANGE_REQUEST_REJECTED), + puzzle: $puzzle500_03, + reporter: $regularPlayer, + submittedAt: $now->modify('-2 days'), + proposedName: 'Rejected Name', + proposedManufacturer: null, + proposedPiecesCount: null, + proposedEan: null, + proposedIdentificationNumber: null, + proposedImage: null, + originalName: $puzzle500_03->name, + originalManufacturerId: $puzzle500_03->manufacturer?->id, + originalPiecesCount: $puzzle500_03->piecesCount, + originalEan: $puzzle500_03->ean, + originalIdentificationNumber: $puzzle500_03->identificationNumber, + originalImage: $puzzle500_03->image, + ); + $rejectedChange->reject($adminPlayer, $now, 'Not a valid change'); + $manager->persist($rejectedChange); + $this->addReference(self::CHANGE_REQUEST_REJECTED, $rejectedChange); + + // Pending merge request + $pendingMerge = new PuzzleMergeRequest( + id: Uuid::fromString(self::MERGE_REQUEST_PENDING), + sourcePuzzle: $puzzle500_01, + reporter: $regularPlayer, + submittedAt: $now, + reportedDuplicatePuzzleIds: [ + PuzzleFixture::PUZZLE_500_01, + PuzzleFixture::PUZZLE_500_02, + ], + ); + $manager->persist($pendingMerge); + $this->addReference(self::MERGE_REQUEST_PENDING, $pendingMerge); + + $manager->flush(); + } + + public function getDependencies(): array + { + return [ + PlayerFixture::class, + ManufacturerFixture::class, + PuzzleFixture::class, + ]; + } +} diff --git a/tests/DataFixtures/PuzzleSolvingTimeFixture.php b/tests/DataFixtures/PuzzleSolvingTimeFixture.php index 4616545e..1c155a10 100644 --- a/tests/DataFixtures/PuzzleSolvingTimeFixture.php +++ b/tests/DataFixtures/PuzzleSolvingTimeFixture.php @@ -61,6 +61,8 @@ final class PuzzleSolvingTimeFixture extends Fixture implements DependentFixture public const string TIME_40 = '018d0006-0000-0000-0000-000000000040'; public const string TIME_41 = '018d0006-0000-0000-0000-000000000041'; public const string TIME_42 = '018d0006-0000-0000-0000-000000000042'; + public const string TIME_43 = '018d0006-0000-0000-0000-000000000043'; + public const string TIME_44 = '018d0006-0000-0000-0000-000000000044'; public function __construct( private readonly ClockInterface $clock, @@ -78,6 +80,7 @@ public function load(ObjectManager $manager): void $puzzle500_01 = $this->getReference(PuzzleFixture::PUZZLE_500_01, Puzzle::class); $puzzle500_02 = $this->getReference(PuzzleFixture::PUZZLE_500_02, Puzzle::class); $puzzle500_03 = $this->getReference(PuzzleFixture::PUZZLE_500_03, Puzzle::class); + $puzzle500_05 = $this->getReference(PuzzleFixture::PUZZLE_500_05, Puzzle::class); $puzzle1000_01 = $this->getReference(PuzzleFixture::PUZZLE_1000_01, Puzzle::class); $puzzle1000_02 = $this->getReference(PuzzleFixture::PUZZLE_1000_02, Puzzle::class); $puzzle1000_03 = $this->getReference(PuzzleFixture::PUZZLE_1000_03, Puzzle::class); @@ -303,6 +306,32 @@ public function load(ObjectManager $manager): void $manager->persist($teamTime2); $this->addReference(self::TIME_41, $teamTime2); + // PUZZLE_500_05: Solving times for merge testing + // These records should be migrated to survivor puzzle during merge + $time43 = $this->createPuzzleSolvingTime( + id: self::TIME_43, + player: $player3, // PLAYER_ADMIN + puzzle: $puzzle500_05, + secondsToSolve: 2200, + daysAgo: 14, + verified: true, + firstAttempt: true, + ); + $manager->persist($time43); + $this->addReference(self::TIME_43, $time43); + + $time44 = $this->createPuzzleSolvingTime( + id: self::TIME_44, + player: $player2, // PLAYER_PRIVATE + puzzle: $puzzle500_05, + secondsToSolve: 2500, + daysAgo: 12, + verified: true, + firstAttempt: true, + ); + $manager->persist($time44); + $this->addReference(self::TIME_44, $time44); + $manager->flush(); } diff --git a/tests/DataFixtures/SellSwapListItemFixture.php b/tests/DataFixtures/SellSwapListItemFixture.php index f51d219d..e819c1ef 100644 --- a/tests/DataFixtures/SellSwapListItemFixture.php +++ b/tests/DataFixtures/SellSwapListItemFixture.php @@ -24,6 +24,8 @@ final class SellSwapListItemFixture extends Fixture implements DependentFixtureI public const string SELLSWAP_05 = '018d000b-0000-0000-0000-000000000005'; public const string SELLSWAP_06 = '018d000b-0000-0000-0000-000000000006'; public const string SELLSWAP_07 = '018d000b-0000-0000-0000-000000000007'; + public const string SELLSWAP_08 = '018d000b-0000-0000-0000-000000000008'; + public const string SELLSWAP_09 = '018d000b-0000-0000-0000-000000000009'; public function __construct( private readonly ClockInterface $clock, @@ -32,11 +34,14 @@ public function __construct( public function load(ObjectManager $manager): void { - // Only player5 has membership and can use sell/swap feature + // Players with membership who can use sell/swap feature + $player3 = $this->getReference(PlayerFixture::PLAYER_ADMIN, Player::class); $player5 = $this->getReference(PlayerFixture::PLAYER_WITH_STRIPE, Player::class); // Puzzles from player5's collection $puzzle500_01 = $this->getReference(PuzzleFixture::PUZZLE_500_01, Puzzle::class); + $puzzle500_04 = $this->getReference(PuzzleFixture::PUZZLE_500_04, Puzzle::class); + $puzzle500_05 = $this->getReference(PuzzleFixture::PUZZLE_500_05, Puzzle::class); $puzzle500_02 = $this->getReference(PuzzleFixture::PUZZLE_500_02, Puzzle::class); $puzzle500_03 = $this->getReference(PuzzleFixture::PUZZLE_500_03, Puzzle::class); $puzzle1000_01 = $this->getReference(PuzzleFixture::PUZZLE_1000_01, Puzzle::class); @@ -142,6 +147,37 @@ public function load(ObjectManager $manager): void $manager->persist($item07); $this->addReference(self::SELLSWAP_07, $item07); + // SELLSWAP_08: PLAYER_ADMIN selling PUZZLE_500_05 (for merge testing) + // This item should be migrated to survivor puzzle during merge + $item08 = $this->createSellSwapListItem( + id: self::SELLSWAP_08, + player: $player3, + puzzle: $puzzle500_05, + listingType: ListingType::Sell, + price: 20.00, + condition: PuzzleCondition::Normal, + comment: null, + daysAgo: 9, + ); + $manager->persist($item08); + $this->addReference(self::SELLSWAP_08, $item08); + + // SELLSWAP_09: PLAYER_ADMIN selling PUZZLE_500_04 (for merge deduplication testing) + // Creates deduplication scenario with SELLSWAP_08 (same player has both puzzles on sell/swap) + // When merging PUZZLE_500_05 into PUZZLE_500_04, SELLSWAP_08 should be REMOVED (not migrated) + $item09 = $this->createSellSwapListItem( + id: self::SELLSWAP_09, + player: $player3, + puzzle: $puzzle500_04, + listingType: ListingType::Swap, + price: null, + condition: PuzzleCondition::LikeNew, + comment: null, + daysAgo: 7, + ); + $manager->persist($item09); + $this->addReference(self::SELLSWAP_09, $item09); + $manager->flush(); } diff --git a/tests/DataFixtures/SoldSwappedItemFixture.php b/tests/DataFixtures/SoldSwappedItemFixture.php new file mode 100644 index 00000000..0a769d5a --- /dev/null +++ b/tests/DataFixtures/SoldSwappedItemFixture.php @@ -0,0 +1,75 @@ +getReference(PlayerFixture::PLAYER_ADMIN, Player::class); + $playerPrivate = $this->getReference(PlayerFixture::PLAYER_PRIVATE, Player::class); + $playerRegular = $this->getReference(PlayerFixture::PLAYER_REGULAR, Player::class); + $puzzle500_05 = $this->getReference(PuzzleFixture::PUZZLE_500_05, Puzzle::class); + + // PLAYER_ADMIN sold PUZZLE_500_05 to PLAYER_REGULAR + $sold01 = new SoldSwappedItem( + id: Uuid::fromString(self::SOLD_01), + seller: $playerAdmin, + puzzle: $puzzle500_05, + buyerPlayer: $playerRegular, + buyerName: null, + listingType: ListingType::Sell, + price: 25.00, + soldAt: $this->clock->now()->modify('-30 days'), + ); + $manager->persist($sold01); + $this->addReference(self::SOLD_01, $sold01); + + // PLAYER_PRIVATE sold PUZZLE_500_05 to external buyer + $sold02 = new SoldSwappedItem( + id: Uuid::fromString(self::SOLD_02), + seller: $playerPrivate, + puzzle: $puzzle500_05, + buyerPlayer: null, + buyerName: 'Jane External', + listingType: ListingType::Swap, + price: null, + soldAt: $this->clock->now()->modify('-20 days'), + ); + $manager->persist($sold02); + $this->addReference(self::SOLD_02, $sold02); + + $manager->flush(); + } + + /** + * @return array> + */ + public function getDependencies(): array + { + return [ + PlayerFixture::class, + PuzzleFixture::class, + ]; + } +} diff --git a/tests/DataFixtures/WishListItemFixture.php b/tests/DataFixtures/WishListItemFixture.php index 2d5cf5f2..b3d0aafa 100644 --- a/tests/DataFixtures/WishListItemFixture.php +++ b/tests/DataFixtures/WishListItemFixture.php @@ -22,6 +22,8 @@ final class WishListItemFixture extends Fixture implements DependentFixtureInter public const string WISHLIST_05 = '018d000a-0000-0000-0000-000000000005'; public const string WISHLIST_06 = '018d000a-0000-0000-0000-000000000006'; public const string WISHLIST_07 = '018d000a-0000-0000-0000-000000000007'; + public const string WISHLIST_08 = '018d000a-0000-0000-0000-000000000008'; + public const string WISHLIST_09 = '018d000a-0000-0000-0000-000000000009'; public function __construct( private readonly ClockInterface $clock, @@ -32,9 +34,12 @@ public function load(ObjectManager $manager): void { $player1 = $this->getReference(PlayerFixture::PLAYER_REGULAR, Player::class); $player2 = $this->getReference(PlayerFixture::PLAYER_PRIVATE, Player::class); + $player3 = $this->getReference(PlayerFixture::PLAYER_ADMIN, Player::class); $player5 = $this->getReference(PlayerFixture::PLAYER_WITH_STRIPE, Player::class); $puzzle500_01 = $this->getReference(PuzzleFixture::PUZZLE_500_01, Puzzle::class); + $puzzle500_04 = $this->getReference(PuzzleFixture::PUZZLE_500_04, Puzzle::class); + $puzzle500_05 = $this->getReference(PuzzleFixture::PUZZLE_500_05, Puzzle::class); $puzzle3000 = $this->getReference(PuzzleFixture::PUZZLE_3000, Puzzle::class); $puzzle4000 = $this->getReference(PuzzleFixture::PUZZLE_4000, Puzzle::class); $puzzle5000 = $this->getReference(PuzzleFixture::PUZZLE_5000, Puzzle::class); @@ -115,6 +120,32 @@ public function load(ObjectManager $manager): void $manager->persist($item06); $this->addReference(self::WISHLIST_06, $item06); + // WISHLIST_08: PLAYER_REGULAR + PUZZLE_500_05 (for merge testing) + // Using PLAYER_REGULAR who doesn't have PUZZLE_500_05 in collection (avoids auto-removal) + // This item should be migrated to survivor puzzle during merge + $item08 = $this->createWishListItem( + id: self::WISHLIST_08, + player: $player1, + puzzle: $puzzle500_05, + removeOnCollectionAdd: true, + daysAgo: 7, + ); + $manager->persist($item08); + $this->addReference(self::WISHLIST_08, $item08); + + // WISHLIST_09: PLAYER_REGULAR + PUZZLE_500_04 (for merge deduplication testing) + // Creates deduplication scenario with WISHLIST_08 (same player has both puzzles on wishlist) + // When merging PUZZLE_500_05 into PUZZLE_500_04, WISHLIST_08 should be REMOVED (not migrated) + $item09 = $this->createWishListItem( + id: self::WISHLIST_09, + player: $player1, + puzzle: $puzzle500_04, + removeOnCollectionAdd: true, + daysAgo: 5, + ); + $manager->persist($item09); + $this->addReference(self::WISHLIST_09, $item09); + $manager->flush(); } diff --git a/tests/MessageHandler/ApprovePuzzleChangeRequestHandlerTest.php b/tests/MessageHandler/ApprovePuzzleChangeRequestHandlerTest.php new file mode 100644 index 00000000..08763b2b --- /dev/null +++ b/tests/MessageHandler/ApprovePuzzleChangeRequestHandlerTest.php @@ -0,0 +1,62 @@ +messageBus = $container->get(MessageBusInterface::class); + $this->changeRequestRepository = $container->get(PuzzleChangeRequestRepository::class); + $this->puzzleRepository = $container->get(PuzzleRepository::class); + } + + public function testApprovingChangeRequestUpdatesPuzzle(): void + { + $changeRequest = $this->changeRequestRepository->get(PuzzleReportFixture::CHANGE_REQUEST_PENDING); + $puzzle = $this->puzzleRepository->get(PuzzleFixture::PUZZLE_500_01); + + // Verify initial state + self::assertSame(PuzzleReportStatus::Pending, $changeRequest->status); + self::assertNotSame('Updated Puzzle Name', $puzzle->name); + + // Dispatch the approve message + $this->messageBus->dispatch( + new ApprovePuzzleChangeRequest( + changeRequestId: PuzzleReportFixture::CHANGE_REQUEST_PENDING, + reviewerId: PlayerFixture::PLAYER_ADMIN, + ), + ); + + // Refresh entities from database + $changeRequest = $this->changeRequestRepository->get(PuzzleReportFixture::CHANGE_REQUEST_PENDING); + $puzzle = $this->puzzleRepository->get(PuzzleFixture::PUZZLE_500_01); + + // Verify the change request is now approved + self::assertSame(PuzzleReportStatus::Approved, $changeRequest->status); + self::assertNotNull($changeRequest->reviewedAt); + self::assertNotNull($changeRequest->reviewedBy); + + // Verify the puzzle was updated with proposed changes + self::assertSame('Updated Puzzle Name', $puzzle->name); + self::assertSame('1234567890123', $puzzle->ean); + } +} diff --git a/tests/MessageHandler/ApprovePuzzleMergeRequestHandlerTest.php b/tests/MessageHandler/ApprovePuzzleMergeRequestHandlerTest.php new file mode 100644 index 00000000..9bf2cffd --- /dev/null +++ b/tests/MessageHandler/ApprovePuzzleMergeRequestHandlerTest.php @@ -0,0 +1,379 @@ +messageBus = $container->get(MessageBusInterface::class); + $this->mergeRequestRepository = $container->get(PuzzleMergeRequestRepository::class); + $this->puzzleRepository = $container->get(PuzzleRepository::class); + $this->entityManager = $container->get(EntityManagerInterface::class); + $this->statisticsRepository = $container->get(PuzzleStatisticsRepository::class); + } + + public function testApprovingMergeRequestUpdatesSurvivorPuzzle(): void + { + // First create a merge request + $mergeRequestId = Uuid::uuid7()->toString(); + + $this->messageBus->dispatch( + new SubmitPuzzleMergeRequest( + mergeRequestId: $mergeRequestId, + sourcePuzzleId: PuzzleFixture::PUZZLE_500_04, + reporterId: PlayerFixture::PLAYER_REGULAR, + duplicatePuzzleIds: [ + PuzzleFixture::PUZZLE_500_05, + ], + ), + ); + + // Now approve it + $this->messageBus->dispatch( + new ApprovePuzzleMergeRequest( + mergeRequestId: $mergeRequestId, + reviewerId: PlayerFixture::PLAYER_ADMIN, + survivorPuzzleId: PuzzleFixture::PUZZLE_500_04, + mergedName: 'Merged Puzzle Name', + mergedEan: '9999999999999', + mergedIdentificationNumber: 'MERGED-001', + mergedPiecesCount: 500, + mergedManufacturerId: ManufacturerFixture::MANUFACTURER_RAVENSBURGER, + selectedImagePuzzleId: null, + ), + ); + + // Verify merge request is approved + $mergeRequest = $this->mergeRequestRepository->get($mergeRequestId); + self::assertSame(PuzzleReportStatus::Approved, $mergeRequest->status); + self::assertNotNull($mergeRequest->reviewedAt); + self::assertNotNull($mergeRequest->reviewedBy); + self::assertNotNull($mergeRequest->survivorPuzzleId); + self::assertSame(PuzzleFixture::PUZZLE_500_04, $mergeRequest->survivorPuzzleId->toString()); + + // Verify survivor puzzle was updated + $survivorPuzzle = $this->puzzleRepository->get(PuzzleFixture::PUZZLE_500_04); + self::assertSame('Merged Puzzle Name', $survivorPuzzle->name); + self::assertSame('9999999999999', $survivorPuzzle->ean); + self::assertSame('MERGED-001', $survivorPuzzle->identificationNumber); + self::assertSame(500, $survivorPuzzle->piecesCount); + self::assertNotNull($survivorPuzzle->manufacturer); + self::assertSame(ManufacturerFixture::MANUFACTURER_RAVENSBURGER, $survivorPuzzle->manufacturer->id->toString()); + } + + public function testApprovingMergeRequestMigratesAllRelatedRecords(): void + { + // Get puzzle entities for querying + $survivorPuzzle = $this->puzzleRepository->get(PuzzleFixture::PUZZLE_500_04); + $duplicatePuzzle = $this->puzzleRepository->get(PuzzleFixture::PUZZLE_500_05); + + // --- BEFORE MERGE: Assert records exist for duplicate puzzle --- + + // Solving times - expect 2 for duplicate (TIME_43, TIME_44) + $duplicateSolvingTimes = $this->entityManager->getRepository(PuzzleSolvingTime::class) + ->findBy(['puzzle' => $duplicatePuzzle]); + self::assertCount(2, $duplicateSolvingTimes, 'Expected 2 solving times for duplicate puzzle'); + + // Collection items - expect 3 for duplicate (ITEM_25, ITEM_26, ITEM_27) + // Note: ITEM_25 + ITEM_26 are in null collection, ITEM_27 is in COLLECTION_PUBLIC + $duplicateCollectionItems = $this->entityManager->getRepository(CollectionItem::class) + ->findBy(['puzzle' => $duplicatePuzzle]); + self::assertCount(3, $duplicateCollectionItems, 'Expected 3 collection items for duplicate puzzle'); + + // Wishlist items - expect 1 for duplicate (WISHLIST_08) + $duplicateWishlistItems = $this->entityManager->getRepository(WishListItem::class) + ->findBy(['puzzle' => $duplicatePuzzle]); + self::assertCount(1, $duplicateWishlistItems, 'Expected 1 wishlist item for duplicate puzzle'); + + // Sell/swap items - expect 1 for duplicate (SELLSWAP_08) + $duplicateSellSwapItems = $this->entityManager->getRepository(SellSwapListItem::class) + ->findBy(['puzzle' => $duplicatePuzzle]); + self::assertCount(1, $duplicateSellSwapItems, 'Expected 1 sell/swap item for duplicate puzzle'); + + // Lent puzzles - expect 1 for duplicate (LENT_07) + $duplicateLentPuzzles = $this->entityManager->getRepository(LentPuzzle::class) + ->findBy(['puzzle' => $duplicatePuzzle]); + self::assertCount(1, $duplicateLentPuzzles, 'Expected 1 lent puzzle for duplicate puzzle'); + + // Sold/swapped items - expect 2 for duplicate (SOLD_01, SOLD_02) + $duplicateSoldSwappedItems = $this->entityManager->getRepository(SoldSwappedItem::class) + ->findBy(['puzzle' => $duplicatePuzzle]); + self::assertCount(2, $duplicateSoldSwappedItems, 'Expected 2 sold/swapped items for duplicate puzzle'); + + // Initial survivor counts + $initialSurvivorSolvingTimes = $this->entityManager->getRepository(PuzzleSolvingTime::class) + ->findBy(['puzzle' => $survivorPuzzle]); + $initialSurvivorSolvingTimesCount = count($initialSurvivorSolvingTimes); + + $initialSurvivorCollectionItems = $this->entityManager->getRepository(CollectionItem::class) + ->findBy(['puzzle' => $survivorPuzzle]); + self::assertCount(3, $initialSurvivorCollectionItems, 'Expected 3 collection items for survivor puzzle initially (ITEM_21, ITEM_28, ITEM_29)'); + + // --- PERFORM MERGE --- + $mergeRequestId = Uuid::uuid7()->toString(); + + $this->messageBus->dispatch( + new SubmitPuzzleMergeRequest( + mergeRequestId: $mergeRequestId, + sourcePuzzleId: PuzzleFixture::PUZZLE_500_04, + reporterId: PlayerFixture::PLAYER_REGULAR, + duplicatePuzzleIds: [PuzzleFixture::PUZZLE_500_05], + ), + ); + + $this->messageBus->dispatch( + new ApprovePuzzleMergeRequest( + mergeRequestId: $mergeRequestId, + reviewerId: PlayerFixture::PLAYER_ADMIN, + survivorPuzzleId: PuzzleFixture::PUZZLE_500_04, + mergedName: 'Merged Puzzle with All Data', + mergedEan: null, + mergedIdentificationNumber: null, + mergedPiecesCount: 500, + mergedManufacturerId: ManufacturerFixture::MANUFACTURER_RAVENSBURGER, + selectedImagePuzzleId: null, + ), + ); + + // Clear entity manager to ensure fresh data + $this->entityManager->clear(); + + // --- AFTER MERGE: Assert duplicate puzzle is deleted --- + try { + $this->puzzleRepository->get(PuzzleFixture::PUZZLE_500_05); + self::fail('Expected PuzzleNotFound exception - duplicate puzzle should be deleted'); + } catch (PuzzleNotFound) { + // Expected behavior + } + + // Reload survivor puzzle + $survivorPuzzle = $this->puzzleRepository->get(PuzzleFixture::PUZZLE_500_04); + + // --- AFTER MERGE: Assert all records migrated to survivor --- + + // Solving times - survivor should have 2 more (migrated from duplicate) + $survivorSolvingTimes = $this->entityManager->getRepository(PuzzleSolvingTime::class) + ->findBy(['puzzle' => $survivorPuzzle]); + self::assertCount( + $initialSurvivorSolvingTimesCount + 2, + $survivorSolvingTimes, + 'Expected 2 solving times to be migrated to survivor puzzle', + ); + + // Collection items - survivor should have 3 total + // All 3 original survivor items stay, all 3 duplicates are deduplicated (removed) + // ITEM_21 stays, ITEM_28 stays, ITEM_29 stays + // ITEM_27 deduplicated with ITEM_21, ITEM_25 deduplicated with ITEM_28, ITEM_26 deduplicated with ITEM_29 + $survivorCollectionItems = $this->entityManager->getRepository(CollectionItem::class) + ->findBy(['puzzle' => $survivorPuzzle]); + self::assertCount(3, $survivorCollectionItems, 'Expected 3 collection items after merge (all deduplicated)'); + + // Wishlist items - survivor should have 1 (migrated) + $survivorWishlistItems = $this->entityManager->getRepository(WishListItem::class) + ->findBy(['puzzle' => $survivorPuzzle]); + self::assertCount(1, $survivorWishlistItems, 'Expected 1 wishlist item migrated to survivor puzzle'); + + // Sell/swap items - survivor should have 1 (migrated) + $survivorSellSwapItems = $this->entityManager->getRepository(SellSwapListItem::class) + ->findBy(['puzzle' => $survivorPuzzle]); + self::assertCount(1, $survivorSellSwapItems, 'Expected 1 sell/swap item migrated to survivor puzzle'); + + // Lent puzzles - survivor should have 1 (migrated) + $survivorLentPuzzles = $this->entityManager->getRepository(LentPuzzle::class) + ->findBy(['puzzle' => $survivorPuzzle]); + self::assertCount(1, $survivorLentPuzzles, 'Expected 1 lent puzzle migrated to survivor puzzle'); + + // Sold/swapped items - survivor should have 2 (migrated) + $survivorSoldSwappedItems = $this->entityManager->getRepository(SoldSwappedItem::class) + ->findBy(['puzzle' => $survivorPuzzle]); + self::assertCount(2, $survivorSoldSwappedItems, 'Expected 2 sold/swapped items migrated to survivor puzzle'); + + // Statistics should be recalculated + $statistics = $this->statisticsRepository->findByPuzzleId($survivorPuzzle->id); + self::assertNotNull($statistics, 'Expected statistics to exist for survivor puzzle'); + self::assertSame(2, $statistics->solvedTimesCount, 'Expected 2 solving times in statistics (migrated from duplicate)'); + } + + public function testApprovingMergeRequestDeduplicatesPlayerRecords(): void + { + // Get puzzle entities + $survivorPuzzle = $this->puzzleRepository->get(PuzzleFixture::PUZZLE_500_04); + $duplicatePuzzle = $this->puzzleRepository->get(PuzzleFixture::PUZZLE_500_05); + + // --- BEFORE MERGE: Assert deduplication scenarios exist --- + + // CollectionItem: PLAYER_WITH_STRIPE has BOTH puzzles in COLLECTION_PUBLIC (ITEM_21 + ITEM_27) + $stripePlayerReference = $this->entityManager + ->getRepository(\SpeedPuzzling\Web\Entity\Player::class) + ->find(PlayerFixture::PLAYER_WITH_STRIPE); + $publicCollectionReference = $this->entityManager + ->getRepository(\SpeedPuzzling\Web\Entity\Collection::class) + ->find(CollectionFixture::COLLECTION_PUBLIC); + + $stripeCollectionItemsForSurvivor = $this->entityManager->getRepository(CollectionItem::class) + ->findBy(['player' => $stripePlayerReference, 'puzzle' => $survivorPuzzle, 'collection' => $publicCollectionReference]); + self::assertCount(1, $stripeCollectionItemsForSurvivor, 'PLAYER_WITH_STRIPE should have survivor puzzle in COLLECTION_PUBLIC'); + + $stripeCollectionItemsForDuplicate = $this->entityManager->getRepository(CollectionItem::class) + ->findBy(['player' => $stripePlayerReference, 'puzzle' => $duplicatePuzzle, 'collection' => $publicCollectionReference]); + self::assertCount(1, $stripeCollectionItemsForDuplicate, 'PLAYER_WITH_STRIPE should have duplicate puzzle in COLLECTION_PUBLIC'); + + // CollectionItem: PLAYER_ADMIN has BOTH puzzles in null collection (ITEM_28 + ITEM_25) + $adminPlayerReference = $this->entityManager + ->getRepository(\SpeedPuzzling\Web\Entity\Player::class) + ->find(PlayerFixture::PLAYER_ADMIN); + + $adminCollectionItemsForSurvivor = $this->entityManager->getRepository(CollectionItem::class) + ->findBy(['player' => $adminPlayerReference, 'puzzle' => $survivorPuzzle, 'collection' => null]); + self::assertCount(1, $adminCollectionItemsForSurvivor, 'PLAYER_ADMIN should have survivor puzzle in null collection'); + + $adminCollectionItemsForDuplicate = $this->entityManager->getRepository(CollectionItem::class) + ->findBy(['player' => $adminPlayerReference, 'puzzle' => $duplicatePuzzle, 'collection' => null]); + self::assertCount(1, $adminCollectionItemsForDuplicate, 'PLAYER_ADMIN should have duplicate puzzle in null collection'); + + // WishListItem: PLAYER_REGULAR has BOTH puzzles on wishlist (WISHLIST_08 + WISHLIST_09) + $regularPlayerReference = $this->entityManager + ->getRepository(\SpeedPuzzling\Web\Entity\Player::class) + ->find(PlayerFixture::PLAYER_REGULAR); + + $regularWishlistForSurvivor = $this->entityManager->getRepository(WishListItem::class) + ->findBy(['player' => $regularPlayerReference, 'puzzle' => $survivorPuzzle]); + self::assertCount(1, $regularWishlistForSurvivor, 'PLAYER_REGULAR should have survivor puzzle on wishlist'); + + $regularWishlistForDuplicate = $this->entityManager->getRepository(WishListItem::class) + ->findBy(['player' => $regularPlayerReference, 'puzzle' => $duplicatePuzzle]); + self::assertCount(1, $regularWishlistForDuplicate, 'PLAYER_REGULAR should have duplicate puzzle on wishlist'); + + // SellSwapListItem: PLAYER_ADMIN has BOTH puzzles on sell/swap list (SELLSWAP_08 + SELLSWAP_09) + $adminPlayerReference = $this->entityManager + ->getRepository(\SpeedPuzzling\Web\Entity\Player::class) + ->find(PlayerFixture::PLAYER_ADMIN); + + $adminSellSwapForSurvivor = $this->entityManager->getRepository(SellSwapListItem::class) + ->findBy(['player' => $adminPlayerReference, 'puzzle' => $survivorPuzzle]); + self::assertCount(1, $adminSellSwapForSurvivor, 'PLAYER_ADMIN should have survivor puzzle on sell/swap list'); + + $adminSellSwapForDuplicate = $this->entityManager->getRepository(SellSwapListItem::class) + ->findBy(['player' => $adminPlayerReference, 'puzzle' => $duplicatePuzzle]); + self::assertCount(1, $adminSellSwapForDuplicate, 'PLAYER_ADMIN should have duplicate puzzle on sell/swap list'); + + // LentPuzzle: PLAYER_REGULAR owns BOTH puzzles (LENT_07 + LENT_08) + $regularLentForSurvivor = $this->entityManager->getRepository(LentPuzzle::class) + ->findBy(['ownerPlayer' => $regularPlayerReference, 'puzzle' => $survivorPuzzle]); + self::assertCount(1, $regularLentForSurvivor, 'PLAYER_REGULAR should own survivor puzzle in lent puzzles'); + + $regularLentForDuplicate = $this->entityManager->getRepository(LentPuzzle::class) + ->findBy(['ownerPlayer' => $regularPlayerReference, 'puzzle' => $duplicatePuzzle]); + self::assertCount(1, $regularLentForDuplicate, 'PLAYER_REGULAR should own duplicate puzzle in lent puzzles'); + + // --- PERFORM MERGE --- + $mergeRequestId = Uuid::uuid7()->toString(); + + $this->messageBus->dispatch( + new SubmitPuzzleMergeRequest( + mergeRequestId: $mergeRequestId, + sourcePuzzleId: PuzzleFixture::PUZZLE_500_04, + reporterId: PlayerFixture::PLAYER_REGULAR, + duplicatePuzzleIds: [PuzzleFixture::PUZZLE_500_05], + ), + ); + + $this->messageBus->dispatch( + new ApprovePuzzleMergeRequest( + mergeRequestId: $mergeRequestId, + reviewerId: PlayerFixture::PLAYER_ADMIN, + survivorPuzzleId: PuzzleFixture::PUZZLE_500_04, + mergedName: 'Deduplicated Puzzle', + mergedEan: null, + mergedIdentificationNumber: null, + mergedPiecesCount: 500, + mergedManufacturerId: ManufacturerFixture::MANUFACTURER_TREFL, + selectedImagePuzzleId: null, + ), + ); + + // Clear entity manager to ensure fresh data + $this->entityManager->clear(); + + // Reload survivor puzzle and player references + $survivorPuzzle = $this->puzzleRepository->get(PuzzleFixture::PUZZLE_500_04); + $stripePlayerReference = $this->entityManager + ->getRepository(\SpeedPuzzling\Web\Entity\Player::class) + ->find(PlayerFixture::PLAYER_WITH_STRIPE); + $regularPlayerReference = $this->entityManager + ->getRepository(\SpeedPuzzling\Web\Entity\Player::class) + ->find(PlayerFixture::PLAYER_REGULAR); + $adminPlayerReference = $this->entityManager + ->getRepository(\SpeedPuzzling\Web\Entity\Player::class) + ->find(PlayerFixture::PLAYER_ADMIN); + $publicCollectionReference = $this->entityManager + ->getRepository(\SpeedPuzzling\Web\Entity\Collection::class) + ->find(CollectionFixture::COLLECTION_PUBLIC); + + // --- AFTER MERGE: Assert deduplication happened --- + + // CollectionItem (named collection): PLAYER_WITH_STRIPE should have ONLY 1 item for survivor in COLLECTION_PUBLIC (not 2) + $stripeCollectionItemsAfter = $this->entityManager->getRepository(CollectionItem::class) + ->findBy(['player' => $stripePlayerReference, 'puzzle' => $survivorPuzzle, 'collection' => $publicCollectionReference]); + self::assertCount(1, $stripeCollectionItemsAfter, 'PLAYER_WITH_STRIPE should have exactly 1 collection item for survivor puzzle in named collection (deduplication)'); + + // CollectionItem (null/system collection): PLAYER_ADMIN should have ONLY 1 item for survivor in null collection (not 2) + $adminCollectionItemsAfter = $this->entityManager->getRepository(CollectionItem::class) + ->findBy(['player' => $adminPlayerReference, 'puzzle' => $survivorPuzzle, 'collection' => null]); + self::assertCount(1, $adminCollectionItemsAfter, 'PLAYER_ADMIN should have exactly 1 collection item for survivor puzzle in null collection (deduplication)'); + + // WishListItem: PLAYER_REGULAR should have ONLY 1 item for survivor (not 2) + $regularWishlistAfter = $this->entityManager->getRepository(WishListItem::class) + ->findBy(['player' => $regularPlayerReference, 'puzzle' => $survivorPuzzle]); + self::assertCount(1, $regularWishlistAfter, 'PLAYER_REGULAR should have exactly 1 wishlist item for survivor puzzle (deduplication)'); + + // SellSwapListItem: PLAYER_ADMIN should have ONLY 1 item for survivor (not 2) + $adminSellSwapAfter = $this->entityManager->getRepository(SellSwapListItem::class) + ->findBy(['player' => $adminPlayerReference, 'puzzle' => $survivorPuzzle]); + self::assertCount(1, $adminSellSwapAfter, 'PLAYER_ADMIN should have exactly 1 sell/swap item for survivor puzzle (deduplication)'); + + // LentPuzzle: PLAYER_REGULAR should own ONLY 1 lent puzzle record for survivor (not 2) + $regularLentAfter = $this->entityManager->getRepository(LentPuzzle::class) + ->findBy(['ownerPlayer' => $regularPlayerReference, 'puzzle' => $survivorPuzzle]); + self::assertCount(1, $regularLentAfter, 'PLAYER_REGULAR should have exactly 1 lent puzzle for survivor puzzle (deduplication)'); + + // PuzzleSolvingTime: ALL records should be kept (no deduplication for solving times) + $survivorSolvingTimes = $this->entityManager->getRepository(PuzzleSolvingTime::class) + ->findBy(['puzzle' => $survivorPuzzle]); + // Original survivor had 0 solving times, duplicate had 2 (TIME_43, TIME_44) + self::assertCount(2, $survivorSolvingTimes, 'All solving times should be migrated (no deduplication for solving times)'); + } +} diff --git a/tests/MessageHandler/RejectPuzzleChangeRequestHandlerTest.php b/tests/MessageHandler/RejectPuzzleChangeRequestHandlerTest.php new file mode 100644 index 00000000..7378c0a3 --- /dev/null +++ b/tests/MessageHandler/RejectPuzzleChangeRequestHandlerTest.php @@ -0,0 +1,54 @@ +messageBus = $container->get(MessageBusInterface::class); + $this->changeRequestRepository = $container->get(PuzzleChangeRequestRepository::class); + } + + public function testRejectingChangeRequestSetsRejectedStatus(): void + { + $changeRequest = $this->changeRequestRepository->get(PuzzleReportFixture::CHANGE_REQUEST_PENDING); + + // Verify initial state + self::assertSame(PuzzleReportStatus::Pending, $changeRequest->status); + self::assertNull($changeRequest->rejectionReason); + + // Dispatch the reject message + $this->messageBus->dispatch( + new RejectPuzzleChangeRequest( + changeRequestId: PuzzleReportFixture::CHANGE_REQUEST_PENDING, + reviewerId: PlayerFixture::PLAYER_ADMIN, + rejectionReason: 'This change is not accurate', + ), + ); + + // Refresh entity from database + $changeRequest = $this->changeRequestRepository->get(PuzzleReportFixture::CHANGE_REQUEST_PENDING); + + // Verify the change request is now rejected + self::assertSame(PuzzleReportStatus::Rejected, $changeRequest->status); + self::assertNotNull($changeRequest->reviewedAt); + self::assertNotNull($changeRequest->reviewedBy); + self::assertSame('This change is not accurate', $changeRequest->rejectionReason); + } +} diff --git a/tests/MessageHandler/RejectPuzzleMergeRequestHandlerTest.php b/tests/MessageHandler/RejectPuzzleMergeRequestHandlerTest.php new file mode 100644 index 00000000..abd4cfec --- /dev/null +++ b/tests/MessageHandler/RejectPuzzleMergeRequestHandlerTest.php @@ -0,0 +1,54 @@ +messageBus = $container->get(MessageBusInterface::class); + $this->mergeRequestRepository = $container->get(PuzzleMergeRequestRepository::class); + } + + public function testRejectingMergeRequestSetsRejectedStatus(): void + { + $mergeRequest = $this->mergeRequestRepository->get(PuzzleReportFixture::MERGE_REQUEST_PENDING); + + // Verify initial state + self::assertSame(PuzzleReportStatus::Pending, $mergeRequest->status); + self::assertNull($mergeRequest->rejectionReason); + + // Dispatch the reject message + $this->messageBus->dispatch( + new RejectPuzzleMergeRequest( + mergeRequestId: PuzzleReportFixture::MERGE_REQUEST_PENDING, + reviewerId: PlayerFixture::PLAYER_ADMIN, + rejectionReason: 'These puzzles are not duplicates', + ), + ); + + // Refresh entity from database + $mergeRequest = $this->mergeRequestRepository->get(PuzzleReportFixture::MERGE_REQUEST_PENDING); + + // Verify the merge request is now rejected + self::assertSame(PuzzleReportStatus::Rejected, $mergeRequest->status); + self::assertNotNull($mergeRequest->reviewedAt); + self::assertNotNull($mergeRequest->reviewedBy); + self::assertSame('These puzzles are not duplicates', $mergeRequest->rejectionReason); + } +} diff --git a/tests/MessageHandler/SubmitPuzzleChangeRequestHandlerTest.php b/tests/MessageHandler/SubmitPuzzleChangeRequestHandlerTest.php new file mode 100644 index 00000000..e6316be3 --- /dev/null +++ b/tests/MessageHandler/SubmitPuzzleChangeRequestHandlerTest.php @@ -0,0 +1,92 @@ +messageBus = $container->get(MessageBusInterface::class); + $this->changeRequestRepository = $container->get(PuzzleChangeRequestRepository::class); + } + + public function testSubmittingChangeRequestCreatesEntity(): void + { + $changeRequestId = Uuid::uuid7()->toString(); + + $this->messageBus->dispatch( + new SubmitPuzzleChangeRequest( + changeRequestId: $changeRequestId, + puzzleId: PuzzleFixture::PUZZLE_500_01, + reporterId: PlayerFixture::PLAYER_REGULAR, + proposedName: 'New Puzzle Name', + proposedManufacturerId: ManufacturerFixture::MANUFACTURER_TREFL, + proposedPiecesCount: 600, + proposedEan: '1234567890123', + proposedIdentificationNumber: 'NEW-001', + proposedPhoto: null, + ), + ); + + $changeRequest = $this->changeRequestRepository->get($changeRequestId); + + // Verify the change request was created with correct values + self::assertSame(PuzzleReportStatus::Pending, $changeRequest->status); + self::assertSame('New Puzzle Name', $changeRequest->proposedName); + self::assertSame(600, $changeRequest->proposedPiecesCount); + self::assertSame('1234567890123', $changeRequest->proposedEan); + self::assertSame('NEW-001', $changeRequest->proposedIdentificationNumber); + self::assertNotNull($changeRequest->proposedManufacturer); + self::assertSame(ManufacturerFixture::MANUFACTURER_TREFL, $changeRequest->proposedManufacturer->id->toString()); + + // Verify original values were captured + self::assertSame('Puzzle 1', $changeRequest->originalName); + self::assertSame(500, $changeRequest->originalPiecesCount); + self::assertNull($changeRequest->reviewedAt); + self::assertNull($changeRequest->reviewedBy); + } + + public function testSubmittingChangeRequestWithoutManufacturerChange(): void + { + $changeRequestId = Uuid::uuid7()->toString(); + + $this->messageBus->dispatch( + new SubmitPuzzleChangeRequest( + changeRequestId: $changeRequestId, + puzzleId: PuzzleFixture::PUZZLE_500_02, + reporterId: PlayerFixture::PLAYER_REGULAR, + proposedName: 'Updated Name Only', + proposedManufacturerId: null, + proposedPiecesCount: 500, + proposedEan: null, + proposedIdentificationNumber: null, + proposedPhoto: null, + ), + ); + + $changeRequest = $this->changeRequestRepository->get($changeRequestId); + + self::assertSame(PuzzleReportStatus::Pending, $changeRequest->status); + self::assertSame('Updated Name Only', $changeRequest->proposedName); + self::assertNull($changeRequest->proposedManufacturer); + self::assertNull($changeRequest->proposedEan); + self::assertNull($changeRequest->proposedIdentificationNumber); + } +} diff --git a/tests/MessageHandler/SubmitPuzzleMergeRequestHandlerTest.php b/tests/MessageHandler/SubmitPuzzleMergeRequestHandlerTest.php new file mode 100644 index 00000000..24cfa21b --- /dev/null +++ b/tests/MessageHandler/SubmitPuzzleMergeRequestHandlerTest.php @@ -0,0 +1,89 @@ +messageBus = $container->get(MessageBusInterface::class); + $this->mergeRequestRepository = $container->get(PuzzleMergeRequestRepository::class); + } + + public function testSubmittingMergeRequestCreatesEntity(): void + { + $mergeRequestId = Uuid::uuid7()->toString(); + + $this->messageBus->dispatch( + new SubmitPuzzleMergeRequest( + mergeRequestId: $mergeRequestId, + sourcePuzzleId: PuzzleFixture::PUZZLE_1000_01, + reporterId: PlayerFixture::PLAYER_REGULAR, + duplicatePuzzleIds: [ + PuzzleFixture::PUZZLE_1000_02, + PuzzleFixture::PUZZLE_1000_03, + ], + ), + ); + + $mergeRequest = $this->mergeRequestRepository->get($mergeRequestId); + + // Verify the merge request was created with correct values + self::assertSame(PuzzleReportStatus::Pending, $mergeRequest->status); + self::assertNotNull($mergeRequest->sourcePuzzle); + self::assertSame(PuzzleFixture::PUZZLE_1000_01, $mergeRequest->sourcePuzzle->id->toString()); + self::assertNull($mergeRequest->reviewedAt); + self::assertNull($mergeRequest->reviewedBy); + + // Verify all puzzle IDs are included (source + duplicates) + $expectedPuzzleIds = [ + PuzzleFixture::PUZZLE_1000_01, + PuzzleFixture::PUZZLE_1000_02, + PuzzleFixture::PUZZLE_1000_03, + ]; + self::assertCount(3, $mergeRequest->reportedDuplicatePuzzleIds); + foreach ($expectedPuzzleIds as $expectedId) { + self::assertContains($expectedId, $mergeRequest->reportedDuplicatePuzzleIds); + } + } + + public function testSubmittingMergeRequestIncludesSourcePuzzleAutomatically(): void + { + $mergeRequestId = Uuid::uuid7()->toString(); + + // Submit without source puzzle in duplicatePuzzleIds + $this->messageBus->dispatch( + new SubmitPuzzleMergeRequest( + mergeRequestId: $mergeRequestId, + sourcePuzzleId: PuzzleFixture::PUZZLE_1000_04, + reporterId: PlayerFixture::PLAYER_REGULAR, + duplicatePuzzleIds: [ + PuzzleFixture::PUZZLE_1000_05, + ], + ), + ); + + $mergeRequest = $this->mergeRequestRepository->get($mergeRequestId); + + // Source puzzle should be included automatically + self::assertContains(PuzzleFixture::PUZZLE_1000_04, $mergeRequest->reportedDuplicatePuzzleIds); + self::assertContains(PuzzleFixture::PUZZLE_1000_05, $mergeRequest->reportedDuplicatePuzzleIds); + self::assertCount(2, $mergeRequest->reportedDuplicatePuzzleIds); + } +} diff --git a/translations/messages.cs.yml b/translations/messages.cs.yml index b46f99ff..ebe3f9cb 100644 --- a/translations/messages.cs.yml +++ b/translations/messages.cs.yml @@ -629,6 +629,11 @@ notifications: passed_to_you: "%from% vám předal puzzle" passed_from_you: "Puzzle bylo předáno hráči %to%" your_puzzle_passed: "Vaše puzzle bylo předáno od %from% hráči %to%" + puzzle_report: + change_approved: "Vaše navrhované změny byly schváleny" + change_rejected: "Vaše navrhované změny byly zamítnuty" + merge_approved: "Váš report duplikátu byl schválen a puzzle byla sloučena" + merge_rejected: "Váš report duplikátu byl zamítnut" badges: title: "Odznaky" @@ -1327,3 +1332,79 @@ image_editor: rotate_left: "Otočit doleva" rotate_right: "Otočit doprava" apply: "Použít" + +admin: + puzzle_change_request: + approved: "Žádost o změnu byla schválena a puzzle aktualizováno." + rejected: "Žádost o změnu byla zamítnuta." + rejection_reason_required: "Důvod zamítnutí je povinný." + puzzle_merge_request: + approved: "Žádost o sloučení byla schválena. Puzzle byla sloučena." + rejected: "Žádost o sloučení byla zamítnuta." + rejection_reason_required: "Důvod zamítnutí je povinný." + validation_failed: "Je vyžadováno cílové puzzle, název a počet dílků." + +pending_proposals: + badge: "Čekající návrh" + go_to_admin: "Přejít do administrace" + no_proposals: "Žádné čekající návrhy pro toto puzzle." + reported_by: "Nahlásil" + title: "Čekající návrhy" + type: + change_request: "Žádost o změnu" + merge_request: "Žádost o sloučení" + +puzzle_report: + back_to_puzzle: "Zpět na puzzle" + dropdown: + propose_changes: "Navrhnout změny" + flash: + changes_submitted: "Vaše navrhované změny byly odeslány ke kontrole. Děkujeme!" + duplicate_reported: "Váš report duplikátu byl odeslán ke kontrole. Děkujeme!" + login_required: "Pro návrh změn se prosím přihlaste." + no_valid_duplicates: "Nemůžete nahlásit puzzle jako duplikát samo sebe. Vyberte prosím jiné puzzle." + pending_proposal_exists: "Návrh pro toto puzzle již čeká na kontrolu." + validation_error: "Opravte prosím chyby a zkuste to znovu." + form: + current_image: "Aktuální obrázek" + duplicate_url: "URL duplikovaného puzzle" + duplicate_url_help: "Vložte URL duplikovaného puzzle." + duplicate_url_placeholder: "https://www.myspeedpuzzling.com/puzzle/..." + ean: "EAN / Čárový kód" + ean_help: "Číslo čárového kódu na krabici puzzle (obvykle 13 číslic)." + identification_number: "Kód značky / SKU" + identification_number_help: "Kód produktu výrobce (např. 19432 pro puzzle Ravensburger)." + manufacturer: "Výrobce" + manufacturer_placeholder: "Hledat výrobce..." + name: "Název puzzle" + name_help: "Zadejte správný název podle krabice puzzle. Pro více názvů použijte čárku." + or: "nebo" + photo: "Nová fotka" + photo_example_ok: "OK" + photo_example_perfect: "Perfektní" + photo_example_wrong: "Špatně" + photo_help_1: "Použijte obrázek z krabice puzzle, ne složené puzzle." + photo_help_2: "Zajistěte dobré osvětlení a ostrost." + photo_help_3: "Ořízněte pouze na obrázek puzzle, ne celou krabici." + photo_help_4: "U multipacků vyberte pouze obrázek jednoho puzzle." + photo_help_title: "Pokyny pro fotografii:" + photo_help_toggle: "Tipy pro dobrou fotografii" + pieces_count: "Počet dílků" + pieces_count_help: "Zadejte správný počet dílků." + search_manufacturer: "Hledat podle výrobce" + search_manufacturer_placeholder: "Vyberte výrobce pro procházení puzzle..." + select_duplicate_error: "Vyberte prosím duplikované puzzle nebo zadejte URL puzzle." + select_manufacturer_first: "Nejprve vyberte výrobce" + select_puzzle: "Vybrat puzzle" + select_puzzle_placeholder: "Hledat puzzle..." + submit_changes: "Odeslat změny" + submit_duplicate: "Nahlásit jako duplikát" + propose_changes: + description: "Navrhněte opravy nebo vylepšení informací o tomto puzzle. Všechny změny budou zkontrolovány administrátorem." + report_duplicate: + description: "Nahlaste, že toto puzzle je duplikátem jiného puzzle. Administrátor zkontroluje a sloučí záznamy, pokud se potvrdí." + max_puzzles_info: "Můžete najednou nahlásit až %max% puzzle jako duplikáty." + tab: + propose_changes: "Navrhnout změny" + report_duplicate: "Nahlásit duplikát" + title: "Navrhnout změny puzzle" diff --git a/translations/messages.de.yml b/translations/messages.de.yml index e1c1a5ab..815888f9 100644 --- a/translations/messages.de.yml +++ b/translations/messages.de.yml @@ -612,6 +612,11 @@ notifications: returned_to_you: "%holder% hat dein Puzzle zurückgegeben" taken_back: "%owner% hat das Puzzle zurückgenommen" your_puzzle_passed: "Dein Puzzle wurde von %from% an %to% weitergegeben" + puzzle_report: + change_approved: "Deine vorgeschlagenen Änderungen wurden genehmigt" + change_rejected: "Deine vorgeschlagenen Änderungen wurden abgelehnt" + merge_approved: "Deine Duplikat-Meldung wurde genehmigt und die Puzzles wurden zusammengeführt" + merge_rejected: "Deine Duplikat-Meldung wurde abgelehnt" badges: title: "Abzeichen" @@ -1327,3 +1332,79 @@ image_editor: rotate_left: "Nach links drehen" rotate_right: "Nach rechts drehen" apply: "Anwenden" + +admin: + puzzle_change_request: + approved: "Änderungsanfrage wurde genehmigt und das Puzzle aktualisiert." + rejected: "Änderungsanfrage wurde abgelehnt." + rejection_reason_required: "Ablehnungsgrund ist erforderlich." + puzzle_merge_request: + approved: "Zusammenführungsanfrage wurde genehmigt. Die Puzzles wurden zusammengeführt." + rejected: "Zusammenführungsanfrage wurde abgelehnt." + rejection_reason_required: "Ablehnungsgrund ist erforderlich." + validation_failed: "Ziel-Puzzle, Name und Teileanzahl sind erforderlich." + +pending_proposals: + badge: "Ausstehender Vorschlag" + go_to_admin: "Zur Verwaltung" + no_proposals: "Keine ausstehenden Vorschläge für dieses Puzzle." + reported_by: "Gemeldet von" + title: "Ausstehende Vorschläge" + type: + change_request: "Änderungsanfrage" + merge_request: "Zusammenführungsanfrage" + +puzzle_report: + back_to_puzzle: "Zurück zum Puzzle" + dropdown: + propose_changes: "Änderungen vorschlagen" + flash: + changes_submitted: "Deine vorgeschlagenen Änderungen wurden zur Überprüfung eingereicht. Danke!" + duplicate_reported: "Deine Duplikat-Meldung wurde zur Überprüfung eingereicht. Danke!" + login_required: "Bitte melde dich an, um Änderungen vorzuschlagen." + no_valid_duplicates: "Du kannst ein Puzzle nicht als Duplikat von sich selbst melden. Bitte wähle ein anderes Puzzle." + pending_proposal_exists: "Ein Vorschlag für dieses Puzzle wartet bereits auf Überprüfung." + validation_error: "Bitte behebe die Fehler und versuche es erneut." + form: + current_image: "Aktuelles Bild" + duplicate_url: "Duplikat-Puzzle-URL" + duplicate_url_help: "Füge die URL des Duplikat-Puzzles ein." + duplicate_url_placeholder: "https://www.myspeedpuzzling.com/puzzle/..." + ean: "EAN / Barcode" + ean_help: "Die Barcode-Nummer auf der Puzzle-Schachtel (normalerweise 13 Ziffern)." + identification_number: "Markencode / SKU" + identification_number_help: "Der Produktcode des Herstellers (z.B. 19432 für Ravensburger-Puzzles)." + manufacturer: "Hersteller" + manufacturer_placeholder: "Hersteller suchen..." + name: "Puzzle-Name" + name_help: "Gib den korrekten Namen wie auf der Puzzle-Schachtel ein. Verwende Kommas für mehrere Namen." + or: "oder" + photo: "Neues Foto" + photo_example_ok: "OK" + photo_example_perfect: "Perfekt" + photo_example_wrong: "Falsch" + photo_help_1: "Verwende das Bild von der Puzzle-Schachtel, nicht das fertige Puzzle." + photo_help_2: "Achte auf gute Beleuchtung und Schärfe." + photo_help_3: "Schneide das Bild zu, um nur das Puzzle-Bild zu zeigen, nicht die ganze Schachtel." + photo_help_4: "Bei Multipacks wähle nur das Bild eines Puzzles." + photo_help_title: "Foto-Richtlinien:" + photo_help_toggle: "Tipps für ein gutes Foto" + pieces_count: "Teileanzahl" + pieces_count_help: "Gib die korrekte Teileanzahl ein." + search_manufacturer: "Nach Hersteller suchen" + search_manufacturer_placeholder: "Hersteller auswählen, um Puzzles zu durchsuchen..." + select_duplicate_error: "Bitte wähle ein Duplikat-Puzzle oder gib eine Puzzle-URL ein." + select_manufacturer_first: "Zuerst Hersteller auswählen" + select_puzzle: "Puzzle auswählen" + select_puzzle_placeholder: "Puzzle suchen..." + submit_changes: "Änderungen einreichen" + submit_duplicate: "Als Duplikat melden" + propose_changes: + description: "Schlage Korrekturen oder Verbesserungen für die Informationen dieses Puzzles vor. Alle Änderungen werden von einem Administrator überprüft." + report_duplicate: + description: "Melde, dass dieses Puzzle ein Duplikat eines anderen Puzzles ist. Ein Administrator wird prüfen und die Einträge zusammenführen, falls bestätigt." + max_puzzles_info: "Du kannst bis zu %max% Puzzles gleichzeitig als Duplikate melden." + tab: + propose_changes: "Änderungen vorschlagen" + report_duplicate: "Duplikat melden" + title: "Puzzle-Änderungen vorschlagen" diff --git a/translations/messages.en.yml b/translations/messages.en.yml index d806878c..450b56de 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -630,6 +630,11 @@ notifications: passed_to_you: "%from% passed you a puzzle" passed_from_you: "Puzzle was passed to %to%" your_puzzle_passed: "Your puzzle was passed from %from% to %to%" + puzzle_report: + change_approved: "Your proposed changes were approved" + change_rejected: "Your proposed changes were rejected" + merge_approved: "Your duplicate report was approved and puzzles were merged" + merge_rejected: "Your duplicate report was rejected" badges: title: "Badges" @@ -1326,3 +1331,79 @@ image_editor: rotate_left: "Rotate Left" rotate_right: "Rotate Right" apply: "Apply" + +puzzle_report: + title: "Propose Puzzle Changes" + back_to_puzzle: "Back to puzzle" + dropdown: + propose_changes: "Propose changes" + tab: + propose_changes: "Propose Changes" + report_duplicate: "Report Duplicate" + propose_changes: + description: "Suggest corrections or improvements to this puzzle's information. All changes will be reviewed by an admin." + report_duplicate: + description: "Report that this puzzle is a duplicate of another puzzle. An admin will review and merge the records if confirmed." + max_puzzles_info: "You can report up to %max% puzzles as duplicates at once." + form: + name: "Puzzle Name" + name_help: "Enter the correct name as shown on the puzzle box. Use comma to separate multiple names." + manufacturer: "Manufacturer" + manufacturer_placeholder: "Search for manufacturer..." + pieces_count: "Pieces Count" + pieces_count_help: "Enter the correct number of pieces." + ean: "EAN / Barcode" + ean_help: "The barcode number on the puzzle box (usually 13 digits)." + identification_number: "Brand Code / SKU" + identification_number_help: "The manufacturer's product code (e.g., 19432 for Ravensburger puzzles)." + photo: "New Photo" + photo_help_toggle: "Tips for a good photo" + photo_help_title: "Photo Guidelines:" + photo_help_1: "Use the image from the puzzle box, not the completed puzzle." + photo_help_2: "Ensure good lighting and focus." + photo_help_3: "Crop to show just the puzzle image, not the entire box." + photo_help_4: "For multipacks, choose only the image of the one puzzle." + photo_example_wrong: "Wrong" + photo_example_ok: "OK" + photo_example_perfect: "Perfect" + current_image: "Current Image" + submit_changes: "Submit Changes" + submit_duplicate: "Report as Duplicate" + duplicate_url: "Duplicate Puzzle URL" + duplicate_url_help: "Paste the URL of the duplicate puzzle." + duplicate_url_placeholder: "https://www.myspeedpuzzling.com/puzzle/..." + or: "or" + search_manufacturer: "Search by Manufacturer" + search_manufacturer_placeholder: "Select manufacturer to browse puzzles..." + select_puzzle: "Select Puzzle" + select_puzzle_placeholder: "Search for puzzle..." + select_manufacturer_first: "Select manufacturer first" + select_duplicate_error: "Please select a duplicate puzzle or enter a puzzle URL." + flash: + changes_submitted: "Your proposed changes have been submitted for review. Thank you!" + duplicate_reported: "Your duplicate report has been submitted for review. Thank you!" + validation_error: "Please fix the errors and try again." + login_required: "Please log in to propose changes." + pending_proposal_exists: "A proposal for this puzzle is already pending review." + no_valid_duplicates: "You cannot report a puzzle as a duplicate of itself. Please select a different puzzle." + +admin: + puzzle_change_request: + approved: "Change request has been approved and puzzle updated." + rejected: "Change request has been rejected." + rejection_reason_required: "Rejection reason is required." + puzzle_merge_request: + approved: "Merge request has been approved. Puzzles have been merged." + rejected: "Merge request has been rejected." + rejection_reason_required: "Rejection reason is required." + validation_failed: "Survivor puzzle, name, and pieces count are required." + +pending_proposals: + badge: "Pending Proposal" + title: "Pending Proposals" + no_proposals: "No pending proposals for this puzzle." + type: + change_request: "Change Request" + merge_request: "Merge Request" + reported_by: "Reported by" + go_to_admin: "Go to admin" diff --git a/translations/messages.es.yml b/translations/messages.es.yml index 8a718015..98649b8d 100644 --- a/translations/messages.es.yml +++ b/translations/messages.es.yml @@ -634,6 +634,11 @@ notifications: returned_to_you: "%holder% devolvió tu puzzle" taken_back: "%owner% recuperó el puzzle" your_puzzle_passed: "Tu puzzle fue pasado de %from% a %to%" + puzzle_report: + change_approved: "Tus cambios propuestos fueron aprobados" + change_rejected: "Tus cambios propuestos fueron rechazados" + merge_approved: "Tu reporte de duplicado fue aprobado y los puzzles fueron fusionados" + merge_rejected: "Tu reporte de duplicado fue rechazado" badges: title: "Insignias" @@ -1327,3 +1332,79 @@ image_editor: rotate_left: "Girar a la izquierda" rotate_right: "Girar a la derecha" apply: "Aplicar" + +admin: + puzzle_change_request: + approved: "La solicitud de cambio ha sido aprobada y el puzzle actualizado." + rejected: "La solicitud de cambio ha sido rechazada." + rejection_reason_required: "Se requiere un motivo de rechazo." + puzzle_merge_request: + approved: "La solicitud de fusión ha sido aprobada. Los puzzles han sido fusionados." + rejected: "La solicitud de fusión ha sido rechazada." + rejection_reason_required: "Se requiere un motivo de rechazo." + validation_failed: "Se requiere el puzzle objetivo, nombre y cantidad de piezas." + +pending_proposals: + badge: "Propuesta Pendiente" + go_to_admin: "Ir a administración" + no_proposals: "No hay propuestas pendientes para este puzzle." + reported_by: "Reportado por" + title: "Propuestas Pendientes" + type: + change_request: "Solicitud de Cambio" + merge_request: "Solicitud de Fusión" + +puzzle_report: + back_to_puzzle: "Volver al puzzle" + dropdown: + propose_changes: "Proponer cambios" + flash: + changes_submitted: "Tus cambios propuestos han sido enviados para revisión. ¡Gracias!" + duplicate_reported: "Tu reporte de duplicado ha sido enviado para revisión. ¡Gracias!" + login_required: "Por favor inicia sesión para proponer cambios." + no_valid_duplicates: "No puedes reportar un puzzle como duplicado de sí mismo. Por favor selecciona un puzzle diferente." + pending_proposal_exists: "Ya existe una propuesta pendiente de revisión para este puzzle." + validation_error: "Por favor corrige los errores e intenta de nuevo." + form: + current_image: "Imagen Actual" + duplicate_url: "URL del Puzzle Duplicado" + duplicate_url_help: "Pega la URL del puzzle duplicado." + duplicate_url_placeholder: "https://www.myspeedpuzzling.com/puzzle/..." + ean: "EAN / Código de barras" + ean_help: "El número del código de barras en la caja del puzzle (normalmente 13 dígitos)." + identification_number: "Código de Marca / SKU" + identification_number_help: "El código de producto del fabricante (ej. 19432 para puzzles Ravensburger)." + manufacturer: "Fabricante" + manufacturer_placeholder: "Buscar fabricante..." + name: "Nombre del Puzzle" + name_help: "Introduce el nombre correcto como aparece en la caja del puzzle. Usa coma para separar múltiples nombres." + or: "o" + photo: "Nueva Foto" + photo_example_ok: "OK" + photo_example_perfect: "Perfecto" + photo_example_wrong: "Incorrecto" + photo_help_1: "Usa la imagen de la caja del puzzle, no el puzzle completado." + photo_help_2: "Asegura buena iluminación y enfoque." + photo_help_3: "Recorta para mostrar solo la imagen del puzzle, no la caja completa." + photo_help_4: "Para multipacks, elige solo la imagen de un puzzle." + photo_help_title: "Guía para la foto:" + photo_help_toggle: "Consejos para una buena foto" + pieces_count: "Cantidad de Piezas" + pieces_count_help: "Introduce el número correcto de piezas." + search_manufacturer: "Buscar por Fabricante" + search_manufacturer_placeholder: "Selecciona fabricante para explorar puzzles..." + select_duplicate_error: "Por favor selecciona un puzzle duplicado o introduce una URL de puzzle." + select_manufacturer_first: "Primero selecciona un fabricante" + select_puzzle: "Seleccionar Puzzle" + select_puzzle_placeholder: "Buscar puzzle..." + submit_changes: "Enviar Cambios" + submit_duplicate: "Reportar como Duplicado" + propose_changes: + description: "Sugiere correcciones o mejoras a la información de este puzzle. Todos los cambios serán revisados por un administrador." + report_duplicate: + description: "Reporta que este puzzle es un duplicado de otro puzzle. Un administrador revisará y fusionará los registros si se confirma." + max_puzzles_info: "Puedes reportar hasta %max% puzzles como duplicados a la vez." + tab: + propose_changes: "Proponer Cambios" + report_duplicate: "Reportar Duplicado" + title: "Proponer Cambios al Puzzle" diff --git a/translations/messages.fr.yml b/translations/messages.fr.yml index 6622eb8e..6ce13e4e 100644 --- a/translations/messages.fr.yml +++ b/translations/messages.fr.yml @@ -634,6 +634,11 @@ notifications: returned_to_you: "%holder% a rendu votre puzzle" taken_back: "%owner% a repris le puzzle" your_puzzle_passed: "Votre puzzle a été transmis de %from% à %to%" + puzzle_report: + change_approved: "Vos modifications proposées ont été approuvées" + change_rejected: "Vos modifications proposées ont été rejetées" + merge_approved: "Votre signalement de doublon a été approuvé et les puzzles ont été fusionnés" + merge_rejected: "Votre signalement de doublon a été rejeté" badges: title: "Badges" @@ -1327,3 +1332,79 @@ image_editor: rotate_left: "Pivoter à gauche" rotate_right: "Pivoter à droite" apply: "Appliquer" + +admin: + puzzle_change_request: + approved: "La demande de modification a été approuvée et le puzzle mis à jour." + rejected: "La demande de modification a été rejetée." + rejection_reason_required: "Le motif de rejet est requis." + puzzle_merge_request: + approved: "La demande de fusion a été approuvée. Les puzzles ont été fusionnés." + rejected: "La demande de fusion a été rejetée." + rejection_reason_required: "Le motif de rejet est requis." + validation_failed: "Le puzzle cible, le nom et le nombre de pièces sont requis." + +pending_proposals: + badge: "Proposition en attente" + go_to_admin: "Aller à l'administration" + no_proposals: "Aucune proposition en attente pour ce puzzle." + reported_by: "Signalé par" + title: "Propositions en attente" + type: + change_request: "Demande de modification" + merge_request: "Demande de fusion" + +puzzle_report: + back_to_puzzle: "Retour au puzzle" + dropdown: + propose_changes: "Proposer des modifications" + flash: + changes_submitted: "Vos modifications proposées ont été soumises pour examen. Merci !" + duplicate_reported: "Votre signalement de doublon a été soumis pour examen. Merci !" + login_required: "Veuillez vous connecter pour proposer des modifications." + no_valid_duplicates: "Vous ne pouvez pas signaler un puzzle comme doublon de lui-même. Veuillez sélectionner un puzzle différent." + pending_proposal_exists: "Une proposition pour ce puzzle est déjà en attente d'examen." + validation_error: "Veuillez corriger les erreurs et réessayer." + form: + current_image: "Image actuelle" + duplicate_url: "URL du puzzle en doublon" + duplicate_url_help: "Collez l'URL du puzzle en doublon." + duplicate_url_placeholder: "https://www.myspeedpuzzling.com/puzzle/..." + ean: "EAN / Code-barres" + ean_help: "Le numéro du code-barres sur la boîte du puzzle (généralement 13 chiffres)." + identification_number: "Code marque / SKU" + identification_number_help: "Le code produit du fabricant (ex. 19432 pour les puzzles Ravensburger)." + manufacturer: "Fabricant" + manufacturer_placeholder: "Rechercher un fabricant..." + name: "Nom du puzzle" + name_help: "Entrez le nom correct tel qu'affiché sur la boîte du puzzle. Utilisez une virgule pour séparer plusieurs noms." + or: "ou" + photo: "Nouvelle photo" + photo_example_ok: "OK" + photo_example_perfect: "Parfait" + photo_example_wrong: "Incorrect" + photo_help_1: "Utilisez l'image de la boîte du puzzle, pas le puzzle terminé." + photo_help_2: "Assurez un bon éclairage et une bonne mise au point." + photo_help_3: "Recadrez pour montrer uniquement l'image du puzzle, pas la boîte entière." + photo_help_4: "Pour les multipacks, choisissez uniquement l'image d'un puzzle." + photo_help_title: "Conseils photo :" + photo_help_toggle: "Conseils pour une bonne photo" + pieces_count: "Nombre de pièces" + pieces_count_help: "Entrez le nombre correct de pièces." + search_manufacturer: "Rechercher par fabricant" + search_manufacturer_placeholder: "Sélectionnez un fabricant pour parcourir les puzzles..." + select_duplicate_error: "Veuillez sélectionner un puzzle en doublon ou entrer une URL de puzzle." + select_manufacturer_first: "Sélectionnez d'abord un fabricant" + select_puzzle: "Sélectionner un puzzle" + select_puzzle_placeholder: "Rechercher un puzzle..." + submit_changes: "Soumettre les modifications" + submit_duplicate: "Signaler comme doublon" + propose_changes: + description: "Suggérez des corrections ou des améliorations aux informations de ce puzzle. Toutes les modifications seront examinées par un administrateur." + report_duplicate: + description: "Signalez que ce puzzle est un doublon d'un autre puzzle. Un administrateur examinera et fusionnera les entrées si confirmé." + max_puzzles_info: "Vous pouvez signaler jusqu'à %max% puzzles comme doublons à la fois." + tab: + propose_changes: "Proposer des modifications" + report_duplicate: "Signaler un doublon" + title: "Proposer des modifications au puzzle" diff --git a/translations/messages.ja.yml b/translations/messages.ja.yml index 1cb5dac6..1362e876 100644 --- a/translations/messages.ja.yml +++ b/translations/messages.ja.yml @@ -364,6 +364,11 @@ notifications: returned_to_you: "%holder%があなたのパズルを返却しました" taken_back: "%owner%がパズルを取り戻しました" your_puzzle_passed: "あなたのパズルが%from%から%to%に渡されました" + puzzle_report: + change_approved: "提案された変更が承認されました" + change_rejected: "提案された変更が却下されました" + merge_approved: "重複報告が承認され、パズルが統合されました" + merge_rejected: "重複報告が却下されました" membership: meta: @@ -1325,3 +1330,79 @@ image_editor: rotate_left: "左に回転" rotate_right: "右に回転" apply: "適用" + +admin: + puzzle_change_request: + approved: "変更リクエストが承認され、パズルが更新されました。" + rejected: "変更リクエストが却下されました。" + rejection_reason_required: "却下理由は必須です。" + puzzle_merge_request: + approved: "統合リクエストが承認されました。パズルが統合されました。" + rejected: "統合リクエストが却下されました。" + rejection_reason_required: "却下理由は必須です。" + validation_failed: "統合先パズル、名前、ピース数は必須です。" + +pending_proposals: + badge: "保留中の提案" + go_to_admin: "管理画面へ" + no_proposals: "このパズルに対する保留中の提案はありません。" + reported_by: "報告者" + title: "保留中の提案" + type: + change_request: "変更リクエスト" + merge_request: "統合リクエスト" + +puzzle_report: + back_to_puzzle: "パズルに戻る" + dropdown: + propose_changes: "変更を提案" + flash: + changes_submitted: "提案された変更がレビューのために送信されました。ありがとうございます!" + duplicate_reported: "重複報告がレビューのために送信されました。ありがとうございます!" + login_required: "変更を提案するにはログインしてください。" + no_valid_duplicates: "パズルを自身の重複として報告することはできません。別のパズルを選択してください。" + pending_proposal_exists: "このパズルに対する提案はすでにレビュー待ちです。" + validation_error: "エラーを修正して再試行してください。" + form: + current_image: "現在の画像" + duplicate_url: "重複パズルのURL" + duplicate_url_help: "重複パズルのURLを貼り付けてください。" + duplicate_url_placeholder: "https://www.myspeedpuzzling.com/puzzle/..." + ean: "EAN / バーコード" + ean_help: "パズルボックスのバーコード番号(通常13桁)。" + identification_number: "ブランドコード / SKU" + identification_number_help: "メーカーの製品コード(例:Ravensburgerパズルの19432)。" + manufacturer: "メーカー" + manufacturer_placeholder: "メーカーを検索..." + name: "パズル名" + name_help: "パズルボックスに記載されている正しい名前を入力してください。複数の名前はカンマで区切ってください。" + or: "または" + photo: "新しい写真" + photo_example_ok: "OK" + photo_example_perfect: "完璧" + photo_example_wrong: "不可" + photo_help_1: "完成したパズルではなく、パズルボックスの画像を使用してください。" + photo_help_2: "良好な照明とピントを確保してください。" + photo_help_3: "ボックス全体ではなく、パズルの画像のみを切り抜いてください。" + photo_help_4: "マルチパックの場合、1つのパズルの画像のみを選択してください。" + photo_help_title: "写真ガイドライン:" + photo_help_toggle: "良い写真のヒント" + pieces_count: "ピース数" + pieces_count_help: "正しいピース数を入力してください。" + search_manufacturer: "メーカーで検索" + search_manufacturer_placeholder: "メーカーを選択してパズルを閲覧..." + select_duplicate_error: "重複パズルを選択するか、パズルのURLを入力してください。" + select_manufacturer_first: "最初にメーカーを選択" + select_puzzle: "パズルを選択" + select_puzzle_placeholder: "パズルを検索..." + submit_changes: "変更を送信" + submit_duplicate: "重複として報告" + propose_changes: + description: "このパズルの情報の修正や改善を提案してください。すべての変更は管理者によってレビューされます。" + report_duplicate: + description: "このパズルが別のパズルの重複であることを報告してください。管理者が確認し、確認された場合はレコードを統合します。" + max_puzzles_info: "一度に最大%max%件のパズルを重複として報告できます。" + tab: + propose_changes: "変更を提案" + report_duplicate: "重複を報告" + title: "パズルの変更を提案"