Skip to content

Commit 7940edb

Browse files
committed
Reporting
1 parent a3da7af commit 7940edb

File tree

65 files changed

+4419
-3
lines changed

Some content is hidden

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

65 files changed

+4419
-3
lines changed
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+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SpeedPuzzling\Web\Controller\Admin;
6+
7+
use Auth0\Symfony\Models\User;
8+
use SpeedPuzzling\Web\Message\ApprovePuzzleChangeRequest;
9+
use SpeedPuzzling\Web\Security\AdminAccessVoter;
10+
use SpeedPuzzling\Web\Services\RetrieveLoggedUserProfile;
11+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
12+
use Symfony\Component\HttpFoundation\Response;
13+
use Symfony\Component\Messenger\MessageBusInterface;
14+
use Symfony\Component\Routing\Attribute\Route;
15+
use Symfony\Component\Security\Http\Attribute\CurrentUser;
16+
use Symfony\Component\Security\Http\Attribute\IsGranted;
17+
18+
final class ApprovePuzzleChangeRequestController extends AbstractController
19+
{
20+
public function __construct(
21+
private readonly MessageBusInterface $messageBus,
22+
private readonly RetrieveLoggedUserProfile $retrieveLoggedUserProfile,
23+
) {
24+
}
25+
26+
#[Route(
27+
path: '/admin/puzzle-change-requests/{id}/approve',
28+
name: 'admin_approve_puzzle_change_request',
29+
methods: ['POST'],
30+
)]
31+
#[IsGranted(AdminAccessVoter::ADMIN_ACCESS)]
32+
public function __invoke(
33+
#[CurrentUser] User $user,
34+
string $id,
35+
): Response {
36+
$player = $this->retrieveLoggedUserProfile->getProfile();
37+
38+
if ($player === null) {
39+
throw $this->createAccessDeniedException();
40+
}
41+
42+
$this->messageBus->dispatch(
43+
new ApprovePuzzleChangeRequest(
44+
changeRequestId: $id,
45+
reviewerId: $player->playerId,
46+
),
47+
);
48+
49+
$this->addFlash('success', 'Change request has been approved and puzzle updated.');
50+
51+
return $this->redirectToRoute('admin_puzzle_change_requests');
52+
}
53+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SpeedPuzzling\Web\Controller\Admin;
6+
7+
use Auth0\Symfony\Models\User;
8+
use SpeedPuzzling\Web\Message\ApprovePuzzleMergeRequest;
9+
use SpeedPuzzling\Web\Security\AdminAccessVoter;
10+
use SpeedPuzzling\Web\Services\RetrieveLoggedUserProfile;
11+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
12+
use Symfony\Component\HttpFoundation\Request;
13+
use Symfony\Component\HttpFoundation\Response;
14+
use Symfony\Component\Messenger\MessageBusInterface;
15+
use Symfony\Component\Routing\Attribute\Route;
16+
use Symfony\Component\Security\Http\Attribute\CurrentUser;
17+
use Symfony\Component\Security\Http\Attribute\IsGranted;
18+
19+
final class ApprovePuzzleMergeRequestController extends AbstractController
20+
{
21+
public function __construct(
22+
private readonly MessageBusInterface $messageBus,
23+
private readonly RetrieveLoggedUserProfile $retrieveLoggedUserProfile,
24+
) {
25+
}
26+
27+
#[Route(
28+
path: '/admin/puzzle-merge-requests/{id}/approve',
29+
name: 'admin_approve_puzzle_merge_request',
30+
methods: ['POST'],
31+
)]
32+
#[IsGranted(AdminAccessVoter::ADMIN_ACCESS)]
33+
public function __invoke(
34+
#[CurrentUser] User $user,
35+
Request $request,
36+
string $id,
37+
): Response {
38+
$player = $this->retrieveLoggedUserProfile->getProfile();
39+
40+
if ($player === null) {
41+
throw $this->createAccessDeniedException();
42+
}
43+
44+
$survivorPuzzleId = $request->request->getString('survivor_puzzle_id');
45+
$mergedName = $request->request->getString('merged_name');
46+
$mergedEan = $request->request->getString('merged_ean');
47+
$mergedIdentificationNumber = $request->request->getString('merged_identification_number');
48+
$mergedPiecesCount = $request->request->getInt('merged_pieces_count');
49+
$mergedManufacturerId = $request->request->getString('merged_manufacturer_id');
50+
$selectedImagePuzzleId = $request->request->getString('selected_image_puzzle_id');
51+
52+
if ($survivorPuzzleId === '' || $mergedName === '' || $mergedPiecesCount === 0) {
53+
$this->addFlash('error', 'Survivor puzzle, name, and pieces count are required.');
54+
return $this->redirectToRoute('admin_puzzle_merge_request_detail', ['id' => $id]);
55+
}
56+
57+
$this->messageBus->dispatch(
58+
new ApprovePuzzleMergeRequest(
59+
mergeRequestId: $id,
60+
reviewerId: $player->playerId,
61+
survivorPuzzleId: $survivorPuzzleId,
62+
mergedName: $mergedName,
63+
mergedEan: $mergedEan !== '' ? $mergedEan : null,
64+
mergedIdentificationNumber: $mergedIdentificationNumber !== '' ? $mergedIdentificationNumber : null,
65+
mergedPiecesCount: $mergedPiecesCount,
66+
mergedManufacturerId: $mergedManufacturerId !== '' ? $mergedManufacturerId : null,
67+
selectedImagePuzzleId: $selectedImagePuzzleId !== '' ? $selectedImagePuzzleId : null,
68+
),
69+
);
70+
71+
$this->addFlash('success', 'Merge request has been approved. Puzzles have been merged.');
72+
73+
return $this->redirectToRoute('admin_puzzle_merge_requests');
74+
}
75+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SpeedPuzzling\Web\Controller\Admin;
6+
7+
use Auth0\Symfony\Models\User;
8+
use SpeedPuzzling\Web\Exceptions\PuzzleChangeRequestNotFound;
9+
use SpeedPuzzling\Web\Query\GetPuzzleChangeRequests;
10+
use SpeedPuzzling\Web\Security\AdminAccessVoter;
11+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
12+
use Symfony\Component\HttpFoundation\Response;
13+
use Symfony\Component\Routing\Attribute\Route;
14+
use Symfony\Component\Security\Http\Attribute\CurrentUser;
15+
use Symfony\Component\Security\Http\Attribute\IsGranted;
16+
17+
final class PuzzleChangeRequestDetailController extends AbstractController
18+
{
19+
public function __construct(
20+
private readonly GetPuzzleChangeRequests $getPuzzleChangeRequests,
21+
) {
22+
}
23+
24+
#[Route(
25+
path: '/admin/puzzle-change-requests/{id}',
26+
name: 'admin_puzzle_change_request_detail',
27+
)]
28+
#[IsGranted(AdminAccessVoter::ADMIN_ACCESS)]
29+
public function __invoke(
30+
#[CurrentUser] User $user,
31+
string $id,
32+
): Response {
33+
$request = $this->getPuzzleChangeRequests->byId($id);
34+
35+
if ($request === null) {
36+
throw new PuzzleChangeRequestNotFound();
37+
}
38+
39+
return $this->render('admin/puzzle_change_request_detail.html.twig', [
40+
'request' => $request,
41+
]);
42+
}
43+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SpeedPuzzling\Web\Controller\Admin;
6+
7+
use Auth0\Symfony\Models\User;
8+
use SpeedPuzzling\Web\Query\GetPuzzleChangeRequests;
9+
use SpeedPuzzling\Web\Security\AdminAccessVoter;
10+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
11+
use Symfony\Component\HttpFoundation\Request;
12+
use Symfony\Component\HttpFoundation\Response;
13+
use Symfony\Component\Routing\Attribute\Route;
14+
use Symfony\Component\Security\Http\Attribute\CurrentUser;
15+
use Symfony\Component\Security\Http\Attribute\IsGranted;
16+
17+
final class PuzzleChangeRequestsController extends AbstractController
18+
{
19+
public function __construct(
20+
private readonly GetPuzzleChangeRequests $getPuzzleChangeRequests,
21+
) {
22+
}
23+
24+
#[Route(
25+
path: '/admin/puzzle-change-requests',
26+
name: 'admin_puzzle_change_requests',
27+
)]
28+
#[IsGranted(AdminAccessVoter::ADMIN_ACCESS)]
29+
public function __invoke(
30+
#[CurrentUser] User $user,
31+
Request $request,
32+
): Response {
33+
$tab = $request->query->getString('tab', 'pending');
34+
35+
$requests = match ($tab) {
36+
'approved' => $this->getPuzzleChangeRequests->allApproved(),
37+
'rejected' => $this->getPuzzleChangeRequests->allRejected(),
38+
default => $this->getPuzzleChangeRequests->allPending(),
39+
};
40+
41+
return $this->render('admin/puzzle_change_requests.html.twig', [
42+
'requests' => $requests,
43+
'active_tab' => $tab,
44+
'pending_count' => count($this->getPuzzleChangeRequests->allPending()),
45+
]);
46+
}
47+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SpeedPuzzling\Web\Controller\Admin;
6+
7+
use Auth0\Symfony\Models\User;
8+
use SpeedPuzzling\Web\Exceptions\PuzzleMergeRequestNotFound;
9+
use SpeedPuzzling\Web\Query\GetPuzzleMergeRequests;
10+
use SpeedPuzzling\Web\Query\GetPuzzleOverview;
11+
use SpeedPuzzling\Web\Query\GetManufacturers;
12+
use SpeedPuzzling\Web\Results\PuzzleOverview;
13+
use SpeedPuzzling\Web\Security\AdminAccessVoter;
14+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
15+
use Symfony\Component\HttpFoundation\Response;
16+
use Symfony\Component\Routing\Attribute\Route;
17+
use Symfony\Component\Security\Http\Attribute\CurrentUser;
18+
use Symfony\Component\Security\Http\Attribute\IsGranted;
19+
20+
final class PuzzleMergeRequestDetailController extends AbstractController
21+
{
22+
public function __construct(
23+
private readonly GetPuzzleMergeRequests $getPuzzleMergeRequests,
24+
private readonly GetPuzzleOverview $getPuzzleOverview,
25+
private readonly GetManufacturers $getManufacturers,
26+
) {
27+
}
28+
29+
#[Route(
30+
path: '/admin/puzzle-merge-requests/{id}',
31+
name: 'admin_puzzle_merge_request_detail',
32+
)]
33+
#[IsGranted(AdminAccessVoter::ADMIN_ACCESS)]
34+
public function __invoke(
35+
#[CurrentUser] User $user,
36+
string $id,
37+
): Response {
38+
$request = $this->getPuzzleMergeRequests->byId($id);
39+
40+
if ($request === null) {
41+
throw new PuzzleMergeRequestNotFound();
42+
}
43+
44+
// Fetch all duplicate puzzle details
45+
$puzzles = [];
46+
foreach ($request->reportedDuplicatePuzzleIds as $puzzleId) {
47+
try {
48+
$puzzle = $this->getPuzzleOverview->byId($puzzleId);
49+
$puzzles[] = $puzzle;
50+
} catch (\Throwable) {
51+
// Puzzle might have been deleted, skip it
52+
}
53+
}
54+
55+
// Collect merged values for the form
56+
$mergedData = $this->collectMergedData($puzzles);
57+
58+
return $this->render('admin/puzzle_merge_request_detail.html.twig', [
59+
'request' => $request,
60+
'puzzles' => $puzzles,
61+
'merged_data' => $mergedData,
62+
'manufacturers' => $this->getManufacturers->onlyApprovedOrAddedByPlayer(),
63+
]);
64+
}
65+
66+
/**
67+
* @param array<PuzzleOverview> $puzzles
68+
* @return array<string, mixed>
69+
*/
70+
private function collectMergedData(array $puzzles): array
71+
{
72+
$names = [];
73+
$eans = [];
74+
$identificationNumbers = [];
75+
$pieceCounts = [];
76+
$images = [];
77+
$manufacturers = [];
78+
79+
foreach ($puzzles as $puzzle) {
80+
if ($puzzle->puzzleName !== '') {
81+
$names[] = $puzzle->puzzleName;
82+
}
83+
if ($puzzle->puzzleEan !== null && $puzzle->puzzleEan !== '') {
84+
$eans[] = $puzzle->puzzleEan;
85+
}
86+
if ($puzzle->puzzleIdentificationNumber !== null && $puzzle->puzzleIdentificationNumber !== '') {
87+
$identificationNumbers[] = $puzzle->puzzleIdentificationNumber;
88+
}
89+
$pieceCounts[$puzzle->piecesCount] = $puzzle->piecesCount;
90+
if ($puzzle->puzzleImage !== null) {
91+
$images[$puzzle->puzzleId] = $puzzle->puzzleImage;
92+
}
93+
$manufacturers[$puzzle->manufacturerId] = $puzzle->manufacturerName;
94+
}
95+
96+
return [
97+
'name' => $names[0] ?? '',
98+
'ean' => implode(', ', array_unique($eans)),
99+
'identification_number' => implode(', ', array_unique($identificationNumbers)),
100+
'pieces_counts' => array_values($pieceCounts),
101+
'pieces_count' => count($pieceCounts) === 1 ? array_values($pieceCounts)[0] : null,
102+
'images' => $images,
103+
'manufacturers' => $manufacturers,
104+
'manufacturer_id' => count($manufacturers) === 1 ? array_key_first($manufacturers) : null,
105+
];
106+
}
107+
}

0 commit comments

Comments
 (0)