Skip to content

Commit 0ec7eab

Browse files
authored
Propose puzzle changes / duplicates (#73)
* Reporting * Proposals improvements * Code review * Translations, description, fixes * New badge * Fixes * Fix duplicates * Improvements * Improvements * Improvements
1 parent 6a5f5e3 commit 0ec7eab

File tree

102 files changed

+6722
-17
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

102 files changed

+6722
-17
lines changed

.claude/fixtures.md

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ Most lent puzzles are **owned by `PLAYER_WITH_STRIPE`**:
3535
| `LENT_03` | PUZZLE_1000_01 (1000 pcs) | PLAYER_WITH_STRIPE | - | **Returned** | "Returned in good condition" |
3636
| `LENT_04` | PUZZLE_500_03 (500 pcs) | PLAYER_WITH_STRIPE | PLAYER_WITH_FAVORITES | Active (passed) | "For testing purposes" |
3737
| `LENT_05` | PUZZLE_1500_02 (1500 pcs) | PLAYER_REGULAR | PLAYER_WITH_STRIPE | Active | - |
38+
| `LENT_06` | PUZZLE_3000 (3000 pcs) | PLAYER_REGULAR | PLAYER_WITH_STRIPE | Active | - |
39+
| `LENT_07` | PUZZLE_500_05 (500 pcs) | PLAYER_REGULAR | PLAYER_ADMIN | Active | "Merge test puzzle" |
40+
| `LENT_08` | PUZZLE_500_04 (500 pcs) | PLAYER_REGULAR | PLAYER_WITH_FAVORITES | Active | "Deduplication test puzzle" |
3841

3942
### Transfer History
4043

@@ -65,7 +68,7 @@ Most lent puzzles are **owned by `PLAYER_WITH_STRIPE`**:
6568

6669
### Collection Items Distribution
6770

68-
**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
71+
**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
6972

7073
**COLLECTION_PRIVATE** (PLAYER_REGULAR): PUZZLE_1500_01, PUZZLE_2000, PUZZLE_3000, PUZZLE_1500_02
7174

@@ -105,13 +108,15 @@ Most lent puzzles are **owned by `PLAYER_WITH_STRIPE`**:
105108
| `SELLSWAP_05` | PUZZLE_1000_02 | Swap | - | LikeNew |
106109
| `SELLSWAP_06` | PUZZLE_1500_01 | Both | 60.00 | MissingPieces |
107110
| `SELLSWAP_07` | PUZZLE_1000_03 | Sell | 35.00 | Normal |
111+
| `SELLSWAP_08` | PUZZLE_500_05 | Sell | 20.00 | Normal | (PLAYER_ADMIN)
112+
| `SELLSWAP_09` | PUZZLE_500_04 | Swap | - | LikeNew | (PLAYER_ADMIN)
108113

109114
## Wishlists
110115

111116
| Player | Puzzles |
112117
|--------|---------|
113-
| PLAYER_REGULAR | PUZZLE_4000, PUZZLE_5000, PUZZLE_6000 |
114-
| PLAYER_WITH_STRIPE | PUZZLE_9000, PUZZLE_3000 |
118+
| PLAYER_REGULAR | PUZZLE_4000, PUZZLE_5000, PUZZLE_6000, PUZZLE_500_05, PUZZLE_500_04 |
119+
| PLAYER_WITH_STRIPE | PUZZLE_9000, PUZZLE_3000, PUZZLE_500_01 |
115120
| PLAYER_PRIVATE | PUZZLE_4000 |
116121

117122
## Puzzles (20 total)
@@ -214,3 +219,29 @@ Most lent puzzles are **owned by `PLAYER_WITH_STRIPE`**:
214219
| Multiple collections | PLAYER_WITH_STRIPE (2), PLAYER_REGULAR (2) |
215220
| Puzzle in 3 collections | PLAYER_WITH_STRIPE: PUZZLE_500_02 (system + PUBLIC + STRIPE_TREFL) |
216221
| Borrowed + in collection | PLAYER_WITH_STRIPE: PUZZLE_1500_02 (borrowed + in system collection) |
222+
223+
## Puzzle Merge Testing
224+
225+
PUZZLE_500_04 (survivor) and PUZZLE_500_05 (duplicate) are set up for puzzle merge testing:
226+
227+
### Deduplication Scenarios
228+
These scenarios test that when a player has BOTH puzzles, only the survivor entry is kept:
229+
230+
| Entity Type | Player | Survivor Entry | Duplicate Entry | After Merge |
231+
|-------------|--------|----------------|-----------------|-------------|
232+
| CollectionItem | PLAYER_WITH_STRIPE | ITEM_21 (PUBLIC) | ITEM_27 (PUBLIC) | ITEM_27 removed |
233+
| WishListItem | PLAYER_REGULAR | WISHLIST_09 | WISHLIST_08 | WISHLIST_08 removed |
234+
| SellSwapListItem | PLAYER_ADMIN | SELLSWAP_09 | SELLSWAP_08 | SELLSWAP_08 removed |
235+
| LentPuzzle | PLAYER_REGULAR | LENT_08 | LENT_07 | LENT_07 removed |
236+
237+
### Migration Scenarios
238+
These entries migrate from duplicate to survivor (no deduplication needed):
239+
240+
| Entity Type | Entry | Player |
241+
|-------------|-------|--------|
242+
| CollectionItem | ITEM_25 | PLAYER_ADMIN |
243+
| CollectionItem | ITEM_26 | PLAYER_PRIVATE |
244+
| PuzzleSolvingTime | TIME_43 | (all solving times migrate) |
245+
| PuzzleSolvingTime | TIME_44 | (all solving times migrate) |
246+
| SoldSwappedItem | SOLD_01 | (all historical records migrate) |
247+
| SoldSwappedItem | SOLD_02 | (all historical records migrate) |

assets/controllers/dynamic_modal_controller.js

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Modal } from 'bootstrap';
55
* Dynamic Modal Controller
66
*
77
* Handles a global modal that loads content dynamically via Turbo Frames.
8-
* Opens automatically when the turbo-frame starts fetching content,
8+
* Opens automatically when frame content is loaded (turbo:frame-load event),
99
* closes automatically when the frame becomes empty.
1010
*
1111
* Usage:
@@ -18,7 +18,7 @@ export default class extends Controller {
1818

1919
modal = null;
2020
observer = null;
21-
openTimeout = null;
21+
pendingOpen = false;
2222

2323
connect() {
2424
// Disable focus trap to allow interaction with tom-select dropdowns
@@ -27,9 +27,12 @@ export default class extends Controller {
2727
focus: false
2828
});
2929

30-
// Open modal when frame starts fetching content
30+
// Track when a fetch starts (we want to open modal when content arrives)
3131
this.frameTarget.addEventListener('turbo:before-fetch-request', this.handleBeforeFetch);
3232

33+
// Open modal when content arrives
34+
this.frameTarget.addEventListener('turbo:frame-load', this.handleFrameLoad);
35+
3336
// Watch for frame becoming empty (close trigger)
3437
this.observer = new MutationObserver(this.handleMutation);
3538
this.observer.observe(this.frameTarget, { childList: true, subtree: true });
@@ -43,18 +46,23 @@ export default class extends Controller {
4346

4447
disconnect() {
4548
this.frameTarget.removeEventListener('turbo:before-fetch-request', this.handleBeforeFetch);
49+
this.frameTarget.removeEventListener('turbo:frame-load', this.handleFrameLoad);
4650
this.observer?.disconnect();
4751
document.removeEventListener('keydown', this.handleKeydown);
4852
document.removeEventListener('modal:close', this.handleClose);
49-
clearTimeout(this.openTimeout);
5053
}
5154

5255
handleBeforeFetch = () => {
53-
// Delay modal opening to allow content to load first
54-
clearTimeout(this.openTimeout);
55-
this.openTimeout = setTimeout(() => {
56+
// Mark that we want to open the modal when content arrives
57+
this.pendingOpen = true;
58+
};
59+
60+
handleFrameLoad = () => {
61+
// Content has arrived - open modal if we were waiting for it
62+
if (this.pendingOpen) {
63+
this.pendingOpen = false;
5664
this.open();
57-
}, 150);
65+
}
5866
};
5967

6068
handleMutation = () => {
@@ -75,12 +83,16 @@ export default class extends Controller {
7583
};
7684

7785
open() {
86+
// Don't open modal if frame is empty (safety check for edge cases)
87+
if (this.frameTarget.innerHTML.trim() === '') {
88+
return;
89+
}
7890
this.modal.show();
7991
document.body.classList.add('modal-open');
8092
}
8193

8294
close() {
83-
clearTimeout(this.openTimeout);
95+
this.pendingOpen = false;
8496
this.modal.hide();
8597
document.body.classList.remove('modal-open');
8698
this.frameTarget.innerHTML = '';
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { Controller } from '@hotwired/stimulus';
2+
3+
export default class extends Controller {
4+
static targets = ['manufacturer', 'puzzle'];
5+
6+
static values = {
7+
currentPuzzleId: String,
8+
};
9+
10+
uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11+
12+
initialize() {
13+
this._onManufacturerConnect = this._onManufacturerConnect.bind(this);
14+
this._onPuzzleConnect = this._onPuzzleConnect.bind(this);
15+
}
16+
17+
connect() {
18+
this.manufacturerTarget.addEventListener('autocomplete:pre-connect', this._onManufacturerConnect);
19+
this.puzzleTarget.addEventListener('autocomplete:pre-connect', this._onPuzzleConnect);
20+
}
21+
22+
disconnect() {
23+
this.manufacturerTarget.removeEventListener('autocomplete:pre-connect', this._onManufacturerConnect);
24+
this.puzzleTarget.removeEventListener('autocomplete:pre-connect', this._onPuzzleConnect);
25+
}
26+
27+
_onManufacturerConnect(event) {
28+
event.detail.options.onChange = (value) => {
29+
this.onManufacturerValueChanged(value);
30+
};
31+
}
32+
33+
_onPuzzleConnect(event) {
34+
event.detail.options.onChange = (value) => {
35+
this.onPuzzleValueChanged(value);
36+
};
37+
38+
// Initialize puzzle field state after TomSelect is ready
39+
event.detail.options.onInitialize = () => {
40+
this.handleInitialState();
41+
};
42+
}
43+
44+
onManufacturerValueChanged(value) {
45+
const puzzleTom = this.puzzleTarget.tomselect;
46+
if (!puzzleTom) return;
47+
48+
puzzleTom.clear();
49+
puzzleTom.clearOptions();
50+
51+
if (value && this.uuidRegex.test(value)) {
52+
puzzleTom.enable();
53+
puzzleTom.settings.placeholder = this.puzzleTarget.dataset.choosePuzzlePlaceholder;
54+
puzzleTom.inputState();
55+
56+
this.fetchPuzzleOptions(value);
57+
} else {
58+
this.disablePuzzleField();
59+
}
60+
}
61+
62+
onPuzzleValueChanged(value) {
63+
const puzzleTom = this.puzzleTarget.tomselect;
64+
if (value && puzzleTom) {
65+
puzzleTom.blur();
66+
}
67+
}
68+
69+
fetchPuzzleOptions(manufacturerId) {
70+
const fetchUrl = this.manufacturerTarget.getAttribute('data-fetch-url');
71+
const currentPuzzleId = this.currentPuzzleIdValue;
72+
73+
fetch(`${fetchUrl}?brand=${manufacturerId}`)
74+
.then(response => {
75+
if (!response.ok) {
76+
console.error('Network response was not ok');
77+
return null;
78+
}
79+
return response.json();
80+
})
81+
.then(data => {
82+
if (data && data.results) {
83+
// Filter out current puzzle from options
84+
const filteredResults = data.results.filter(
85+
puzzle => puzzle.value !== currentPuzzleId
86+
);
87+
this.updatePuzzleSelectValues(filteredResults);
88+
}
89+
})
90+
.catch(error => {
91+
console.error('Error fetching puzzle options:', error);
92+
});
93+
}
94+
95+
updatePuzzleSelectValues(data) {
96+
const puzzleTomSelect = this.puzzleTarget.tomselect;
97+
if (!puzzleTomSelect) return;
98+
99+
puzzleTomSelect.clearOptions();
100+
puzzleTomSelect.addOptions(data);
101+
puzzleTomSelect.refreshOptions(true);
102+
}
103+
104+
handleInitialState() {
105+
// Check if manufacturer already has a value (shouldn't normally happen on fresh form)
106+
const manufacturerValue = this.manufacturerTarget.value;
107+
if (manufacturerValue && this.uuidRegex.test(manufacturerValue)) {
108+
this.fetchPuzzleOptions(manufacturerValue);
109+
} else {
110+
this.disablePuzzleField();
111+
}
112+
}
113+
114+
disablePuzzleField() {
115+
const puzzleTomSelect = this.puzzleTarget.tomselect;
116+
if (!puzzleTomSelect) return;
117+
118+
puzzleTomSelect.clearOptions();
119+
puzzleTomSelect.disable();
120+
puzzleTomSelect.settings.placeholder = this.puzzleTarget.dataset.chooseManufacturerPlaceholder;
121+
puzzleTomSelect.inputState();
122+
}
123+
}

config/packages/messenger.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
'messenger' => [
1313
'buses' => [
1414
'command_bus' => [
15-
'middleware' => ['doctrine_transaction'],
15+
'middleware' => [
16+
'SpeedPuzzling\Web\Services\MessengerMiddleware\ClearEntityManagerMiddleware',
17+
'doctrine_transaction',
18+
],
1619
],
1720
],
1821
'failure_transport' => 'failed',
@@ -34,6 +37,12 @@
3437
'SpeedPuzzling\Web\Events\PuzzleBorrowed' => 'sync',
3538
'SpeedPuzzling\Web\Events\PuzzleAddedToCollection' => 'sync',
3639
'SpeedPuzzling\Web\Events\LendingTransferCompleted' => 'sync',
40+
// Events that must run synchronously for statistics recalculation
41+
'SpeedPuzzling\Web\Events\PuzzleSolved' => 'sync',
42+
'SpeedPuzzling\Web\Events\PuzzleSolvingTimeModified' => 'sync',
43+
'SpeedPuzzling\Web\Events\PuzzleSolvingTimeDeleted' => 'sync',
44+
// Events that must run synchronously for proper transaction ordering
45+
'SpeedPuzzling\Web\Events\PuzzleMergeApproved' => 'sync',
3746
// All other events can run asynchronously
3847
'SpeedPuzzling\Web\Events\*' => 'async',
3948
],
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SpeedPuzzling\Web\Migrations;
6+
7+
use Doctrine\DBAL\Schema\Schema;
8+
use Doctrine\Migrations\AbstractMigration;
9+
10+
/**
11+
* Auto-generated Migration: Please modify to your needs!
12+
*/
13+
final class Version20251218233751 extends AbstractMigration
14+
{
15+
public function getDescription(): string
16+
{
17+
return 'Add puzzle change request and merge request tables for community feedback system';
18+
}
19+
20+
public function up(Schema $schema): void
21+
{
22+
// this up() migration is auto-generated, please modify it to your needs
23+
$this->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))');
24+
$this->addSql('CREATE INDEX IDX_3668C37CFC6B21F1 ON puzzle_change_request (reviewed_by_id)');
25+
$this->addSql('CREATE INDEX IDX_3668C37CD9816812 ON puzzle_change_request (puzzle_id)');
26+
$this->addSql('CREATE INDEX IDX_3668C37CE1CFE6F5 ON puzzle_change_request (reporter_id)');
27+
$this->addSql('CREATE INDEX IDX_3668C37C4971441F ON puzzle_change_request (proposed_manufacturer_id)');
28+
$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))');
29+
$this->addSql('CREATE INDEX IDX_C53156FBFC6B21F1 ON puzzle_merge_request (reviewed_by_id)');
30+
$this->addSql('CREATE INDEX IDX_C53156FBB11FFBDC ON puzzle_merge_request (source_puzzle_id)');
31+
$this->addSql('CREATE INDEX IDX_C53156FBE1CFE6F5 ON puzzle_merge_request (reporter_id)');
32+
$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');
33+
$this->addSql('ALTER TABLE puzzle_change_request ADD CONSTRAINT FK_3668C37CD9816812 FOREIGN KEY (puzzle_id) REFERENCES puzzle (id) ON DELETE CASCADE NOT DEFERRABLE');
34+
$this->addSql('ALTER TABLE puzzle_change_request ADD CONSTRAINT FK_3668C37CE1CFE6F5 FOREIGN KEY (reporter_id) REFERENCES player (id) ON DELETE CASCADE NOT DEFERRABLE');
35+
$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');
36+
$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');
37+
$this->addSql('ALTER TABLE puzzle_merge_request ADD CONSTRAINT FK_C53156FBB11FFBDC FOREIGN KEY (source_puzzle_id) REFERENCES puzzle (id) ON DELETE CASCADE NOT DEFERRABLE');
38+
$this->addSql('ALTER TABLE puzzle_merge_request ADD CONSTRAINT FK_C53156FBE1CFE6F5 FOREIGN KEY (reporter_id) REFERENCES player (id) ON DELETE CASCADE NOT DEFERRABLE');
39+
$this->addSql('ALTER TABLE notification ADD target_change_request_id UUID DEFAULT NULL');
40+
$this->addSql('ALTER TABLE notification ADD target_merge_request_id UUID DEFAULT NULL');
41+
$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');
42+
$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');
43+
$this->addSql('CREATE INDEX IDX_BF5476CA96566C4C ON notification (target_change_request_id)');
44+
$this->addSql('CREATE INDEX IDX_BF5476CAFB320824 ON notification (target_merge_request_id)');
45+
}
46+
47+
public function down(Schema $schema): void
48+
{
49+
// this down() migration is auto-generated, please modify it to your needs
50+
$this->addSql('ALTER TABLE puzzle_change_request DROP CONSTRAINT FK_3668C37CFC6B21F1');
51+
$this->addSql('ALTER TABLE puzzle_change_request DROP CONSTRAINT FK_3668C37CD9816812');
52+
$this->addSql('ALTER TABLE puzzle_change_request DROP CONSTRAINT FK_3668C37CE1CFE6F5');
53+
$this->addSql('ALTER TABLE puzzle_change_request DROP CONSTRAINT FK_3668C37C4971441F');
54+
$this->addSql('ALTER TABLE puzzle_merge_request DROP CONSTRAINT FK_C53156FBFC6B21F1');
55+
$this->addSql('ALTER TABLE puzzle_merge_request DROP CONSTRAINT FK_C53156FBB11FFBDC');
56+
$this->addSql('ALTER TABLE puzzle_merge_request DROP CONSTRAINT FK_C53156FBE1CFE6F5');
57+
$this->addSql('DROP TABLE puzzle_change_request');
58+
$this->addSql('DROP TABLE puzzle_merge_request');
59+
$this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA96566C4C');
60+
$this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CAFB320824');
61+
$this->addSql('DROP INDEX IDX_BF5476CA96566C4C');
62+
$this->addSql('DROP INDEX IDX_BF5476CAFB320824');
63+
$this->addSql('ALTER TABLE notification DROP target_change_request_id');
64+
$this->addSql('ALTER TABLE notification DROP target_merge_request_id');
65+
}
66+
}

0 commit comments

Comments
 (0)