Add physical escape room map, reviews, and website admin

Public:
- /escape-rooms: Leaflet + OpenStreetMap map with marker clustering and a
  searchable list, both built from a cached slim JSON feed
- /escape-rooms/{id}-{slug}: room detail with a mini-map and user reviews
  (rating + title + body, login required)
- /escape-rooms/suggest: visitor submission form with click-to-drop-pin map
  and reCAPTCHA v3; new rooms land as pending

Data:
- EscapeRoom / EscapeRoomReview entities (soft-delete), migration
- app:escaperooms:import-osm console command pulls leisure=escape_game from
  the Overpass API, upserts by (osmType, osmId), skips locked rows;
  --tiled / --area / --bbox options

Admin (new "Website Admin" area, separate from Game Admin):
- src/Website/Controller/AdminEscapeRoomController.php at /admin/escape-rooms:
  status-filtered list, new/edit, publish/hide/reject, delete, review removal
- game admin sidebar extracted into a block and cross-linked

Assets: leaflet + leaflet.markercluster; escaperoom-map and escaperoom-detail
Encore entries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Frank
2026-08-31 00:24:03 +02:00
co-authored by Claude Sonnet 5
parent 0edaa9d8cc
commit 008887ab60
29 changed files with 2532 additions and 0 deletions
@@ -0,0 +1,334 @@
<?php
declare(strict_types=1);
namespace App\Website\Command;
use App\Website\Entity\EscapeRoom;
use App\Website\Enum\EscapeRoomSource;
use App\Website\Enum\EscapeRoomStatus;
use App\Website\Repository\EscapeRoomRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[AsCommand(
name: 'app:escaperooms:import-osm',
description: 'Import physical escape rooms (leisure=escape_game) from OpenStreetMap via the Overpass API',
)]
final class ImportEscapeRoomsFromOsmCommand extends Command
{
private const DEFAULT_ENDPOINT = 'https://overpass-api.de/api/interpreter';
/**
* Continental-ish tiles covering inhabited land. Used with --tiled so no single
* Overpass response has to carry the whole planet at once.
*
* @var array<string, array{float, float, float, float}> name => [south, west, north, east]
*/
private const WORLD_TILES = [
'Europe' => [34.0, -25.0, 72.0, 45.0],
'Africa' => [-36.0, -20.0, 38.0, 52.0],
'Middle East' => [12.0, 26.0, 43.0, 63.0],
'North Asia' => [40.0, 45.0, 78.0, 180.0],
'South Asia' => [5.0, 63.0, 40.0, 93.0],
'East Asia' => [18.0, 93.0, 54.0, 146.0],
'SE Asia/Oceania' => [-48.0, 93.0, 18.0, 180.0],
'North America' => [10.0, -168.0, 72.0, -52.0],
'Central America' => [7.0, -92.0, 33.0, -58.0],
'South America' => [-56.0, -82.0, 13.0, -34.0],
];
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly EntityManagerInterface $em,
private readonly EscapeRoomRepository $rooms,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('area', null, InputOption::VALUE_REQUIRED, 'Restrict to one country by ISO 3166-1 alpha-2 code, e.g. --area=NL')
->addOption('bbox', null, InputOption::VALUE_REQUIRED, 'Restrict to a bounding box: --bbox=south,west,north,east')
->addOption('tiled', null, InputOption::VALUE_NONE, 'Split a worldwide import into continental tiles (recommended for the first run)')
->addOption('endpoint', null, InputOption::VALUE_REQUIRED, 'Overpass API endpoint', self::DEFAULT_ENDPOINT)
->addOption('timeout', null, InputOption::VALUE_REQUIRED, 'Overpass server-side timeout in seconds', '600')
->addOption('sleep', null, InputOption::VALUE_REQUIRED, 'Seconds to wait between requests', '5')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Fetch and parse but write nothing to the database');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$endpoint = (string) $input->getOption('endpoint');
$timeout = max(30, (int) $input->getOption('timeout'));
$sleep = max(0, (int) $input->getOption('sleep'));
$dryRun = (bool) $input->getOption('dry-run');
try {
$queries = $this->buildQueries($input, $timeout);
} catch (\InvalidArgumentException $e) {
$io->error($e->getMessage());
return Command::INVALID;
}
$io->title('Importing escape rooms from OpenStreetMap');
$io->writeln(sprintf('Endpoint: <info>%s</info> · batches: <info>%d</info>%s', $endpoint, \count($queries), $dryRun ? ' · <comment>dry run</comment>' : ''));
$totals = ['created' => 0, 'updated' => 0, 'locked' => 0, 'noname' => 0, 'nocoords' => 0];
$seen = 0;
$first = true;
foreach ($queries as $label => $ql) {
if (!$first && $sleep > 0) {
$io->writeln(sprintf(' <comment>sleeping %ds…</comment>', $sleep));
sleep($sleep);
}
$first = false;
$io->section((string) $label);
$elements = $this->fetch($endpoint, $ql, $timeout, $io);
if ($elements === null) {
$io->warning('Skipping this batch after a failed request.');
continue;
}
$io->writeln(sprintf(' %d element(s) returned', \count($elements)));
$batch = 0;
foreach ($elements as $element) {
++$seen;
$outcome = $this->upsert($element, $dryRun);
++$totals[$outcome];
if (!$dryRun && $outcome !== 'noname' && $outcome !== 'nocoords' && ++$batch % 400 === 0) {
$this->em->flush();
$this->em->clear();
}
}
if (!$dryRun) {
$this->em->flush();
$this->em->clear();
}
}
$io->newLine();
$io->success(sprintf(
"%d element(s) processed\n created: %d\n updated: %d\n skipped (locked): %d\n skipped (no name): %d\n skipped (no coords): %d",
$seen,
$totals['created'],
$totals['updated'],
$totals['locked'],
$totals['noname'],
$totals['nocoords'],
));
return Command::SUCCESS;
}
/**
* @return array<string, string> label => Overpass QL
*/
private function buildQueries(InputInterface $input, int $timeout): array
{
$area = $input->getOption('area');
$bbox = $input->getOption('bbox');
$tiled = (bool) $input->getOption('tiled');
$header = sprintf('[out:json][timeout:%d];', $timeout);
$footer = 'out center tags;';
if ($area !== null) {
$code = strtoupper(trim((string) $area));
if (!preg_match('/^[A-Z]{2}$/', $code)) {
throw new \InvalidArgumentException('--area expects a two-letter ISO country code, e.g. NL');
}
return [
'Country '.$code => sprintf('%s area["ISO3166-1"="%s"][admin_level=2]->.a; nwr["leisure"="escape_game"](area.a); %s', $header, $code, $footer),
];
}
if ($bbox !== null) {
$box = $this->parseBbox((string) $bbox);
return [
'Bounding box' => sprintf('%s nwr["leisure"="escape_game"](%F,%F,%F,%F); %s', $header, $box[0], $box[1], $box[2], $box[3], $footer),
];
}
if ($tiled) {
$queries = [];
foreach (self::WORLD_TILES as $name => $box) {
$queries[$name] = sprintf('%s nwr["leisure"="escape_game"](%F,%F,%F,%F); %s', $header, $box[0], $box[1], $box[2], $box[3], $footer);
}
return $queries;
}
return [
'Worldwide' => sprintf('%s nwr["leisure"="escape_game"]; %s', $header, $footer),
];
}
/**
* @return array{float, float, float, float} south, west, north, east
*/
private function parseBbox(string $raw): array
{
$parts = array_map('trim', explode(',', $raw));
if (\count($parts) !== 4 || array_filter($parts, static fn ($p) => !is_numeric($p))) {
throw new \InvalidArgumentException('--bbox expects four comma-separated numbers: south,west,north,east');
}
return [(float) $parts[0], (float) $parts[1], (float) $parts[2], (float) $parts[3]];
}
/**
* @return array<int, array<string, mixed>>|null
*/
private function fetch(string $endpoint, string $ql, int $timeout, SymfonyStyle $io): ?array
{
for ($attempt = 1; $attempt <= 2; ++$attempt) {
try {
$response = $this->httpClient->request('POST', $endpoint, [
'body' => ['data' => $ql],
'headers' => ['Accept' => 'application/json'],
'timeout' => $timeout + 60,
]);
$data = $response->toArray();
if (isset($data['remark']) && str_contains((string) $data['remark'], 'error')) {
$io->warning('Overpass reported: '.$data['remark']);
return null;
}
return $data['elements'] ?? [];
} catch (\Throwable $e) {
$io->writeln(sprintf(' <comment>request failed (attempt %d/2): %s</comment>', $attempt, $e->getMessage()));
if ($attempt < 2) {
sleep(15);
}
}
}
return null;
}
/**
* @param array<string, mixed> $element
*
* @return 'created'|'updated'|'locked'|'noname'|'nocoords'
*/
private function upsert(array $element, bool $dryRun): string
{
$tags = $element['tags'] ?? [];
$name = trim((string) ($tags['name'] ?? ''));
if ($name === '') {
return 'noname';
}
[$lat, $lng] = $this->coords($element);
if ($lat === null || $lng === null) {
return 'nocoords';
}
$osmType = (string) ($element['type'] ?? 'node');
$osmId = (string) ($element['id'] ?? '');
$room = $this->rooms->findOneByOsm($osmType, $osmId);
$isNew = $room === null;
if (!$isNew && $room->isLocked()) {
return 'locked';
}
if ($isNew) {
$room = (new EscapeRoom())
->setSource(EscapeRoomSource::OSM)
->setStatus(EscapeRoomStatus::PUBLISHED)
->setOsmType($osmType)
->setOsmId($osmId);
}
$room
->setName($name)
->setVenue($this->firstTag($tags, ['brand', 'operator']))
->setStreet($this->firstTag($tags, ['addr:street']))
->setHouseNumber($this->firstTag($tags, ['addr:housenumber']))
->setPostcode($this->firstTag($tags, ['addr:postcode']))
->setCity($this->firstTag($tags, ['addr:city', 'addr:town', 'addr:suburb']))
->setCountry($this->normaliseCountry($this->firstTag($tags, ['addr:country'])))
->setWebsite($this->firstTag($tags, ['website', 'contact:website', 'url']))
->setPhone($this->firstTag($tags, ['phone', 'contact:phone']))
->setLatitude($lat)
->setLongitude($lng);
if (!$isNew) {
$room->touch();
}
if (!$dryRun) {
$this->em->persist($room);
}
return $isNew ? 'created' : 'updated';
}
/**
* @param array<string, mixed> $element
*
* @return array{?float, ?float}
*/
private function coords(array $element): array
{
if (isset($element['lat'], $element['lon'])) {
return [(float) $element['lat'], (float) $element['lon']];
}
if (isset($element['center']['lat'], $element['center']['lon'])) {
return [(float) $element['center']['lat'], (float) $element['center']['lon']];
}
return [null, null];
}
/**
* @param array<string, mixed> $tags
* @param string[] $keys
*/
private function firstTag(array $tags, array $keys): ?string
{
foreach ($keys as $key) {
$value = trim((string) ($tags[$key] ?? ''));
if ($value !== '') {
return mb_substr($value, 0, 500);
}
}
return null;
}
private function normaliseCountry(?string $value): ?string
{
if ($value === null) {
return null;
}
$value = strtoupper(trim($value));
return preg_match('/^[A-Z]{2}$/', $value) === 1 ? $value : null;
}
}
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Website\Controller;
use App\Website\Entity\EscapeRoom;
use App\Website\Entity\EscapeRoomReview;
use App\Website\Enum\EscapeRoomStatus;
use App\Website\Form\AdminEscapeRoomType;
use App\Website\Repository\EscapeRoomRepository;
use App\Website\Repository\EscapeRoomReviewRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin/escape-rooms')]
#[IsGranted('ROLE_ADMIN')]
final class AdminEscapeRoomController extends AbstractController
{
#[Route('', name: 'website_admin_escaperooms', methods: ['GET'])]
public function index(Request $request, EscapeRoomRepository $rooms): Response
{
$filter = EscapeRoomStatus::tryFrom((string) $request->query->get('status'));
return $this->render('website/admin/escape_room/index.html.twig', [
'rooms' => $rooms->findForAdmin($filter),
'reviewCounts' => $rooms->reviewCounts(),
'filter' => $filter,
'counts' => [
'all' => \count($rooms->findForAdmin()),
EscapeRoomStatus::PENDING->value => $rooms->countByStatus(EscapeRoomStatus::PENDING),
EscapeRoomStatus::PUBLISHED->value => $rooms->countByStatus(EscapeRoomStatus::PUBLISHED),
EscapeRoomStatus::HIDDEN->value => $rooms->countByStatus(EscapeRoomStatus::HIDDEN),
EscapeRoomStatus::REJECTED->value => $rooms->countByStatus(EscapeRoomStatus::REJECTED),
],
]);
}
#[Route('/new', name: 'website_admin_escaperoom_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $em): Response
{
$room = new EscapeRoom();
$form = $this->createForm(AdminEscapeRoomType::class, $room);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->persist($room);
$em->flush();
$this->addFlash('success', sprintf('"%s" added.', $room->getName()));
return $this->redirectToRoute('website_admin_escaperooms');
}
return $this->render('website/admin/escape_room/form.html.twig', [
'form' => $form,
'room' => $room,
'heading' => 'New escape room',
]);
}
#[Route('/{id}/edit', name: 'website_admin_escaperoom_edit', methods: ['GET', 'POST'], requirements: ['id' => '\d+'])]
public function edit(EscapeRoom $room, Request $request, EntityManagerInterface $em): Response
{
$form = $this->createForm(AdminEscapeRoomType::class, $room);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$room->touch();
$em->flush();
$this->addFlash('success', 'Changes saved.');
return $this->redirectToRoute('website_admin_escaperooms');
}
return $this->render('website/admin/escape_room/form.html.twig', [
'form' => $form,
'room' => $room,
'heading' => 'Edit: '.$room->getName(),
]);
}
#[Route('/{id}/status/{status}', name: 'website_admin_escaperoom_moderate', methods: ['POST'], requirements: ['id' => '\d+'])]
public function moderate(EscapeRoom $room, string $status, Request $request, EntityManagerInterface $em): Response
{
if (!$this->isCsrfTokenValid('moderate_room_'.$room->getId(), (string) $request->request->get('_token'))) {
$this->addFlash('danger', 'Invalid CSRF token.');
return $this->redirectToRoute('website_admin_escaperooms');
}
$target = EscapeRoomStatus::tryFrom($status);
if ($target === null) {
throw $this->createNotFoundException();
}
$room->setStatus($target)->touch();
$em->flush();
$this->addFlash('success', sprintf('"%s" is now %s.', $room->getName(), $target->label()));
return $this->redirectToRoute('website_admin_escaperooms', ['status' => $request->request->get('return_to')]);
}
#[Route('/{id}/delete', name: 'website_admin_escaperoom_delete', methods: ['POST'], requirements: ['id' => '\d+'])]
public function delete(EscapeRoom $room, Request $request, EntityManagerInterface $em): Response
{
if (!$this->isCsrfTokenValid('delete_room_'.$room->getId(), (string) $request->request->get('_token'))) {
$this->addFlash('danger', 'Invalid CSRF token.');
return $this->redirectToRoute('website_admin_escaperooms');
}
if (!$room->isDeleted()) {
$room->setDeletedAt(new \DateTimeImmutable());
$em->flush();
}
$this->addFlash('success', sprintf('"%s" deleted.', $room->getName()));
return $this->redirectToRoute('website_admin_escaperooms');
}
#[Route('/reviews', name: 'website_admin_escaperoom_reviews', methods: ['GET'])]
public function reviews(EscapeRoomReviewRepository $reviews): Response
{
return $this->render('website/admin/review/index.html.twig', [
'reviews' => $reviews->findRecent(),
]);
}
#[Route('/reviews/{id}/delete', name: 'website_admin_escaperoom_review_delete', methods: ['POST'], requirements: ['id' => '\d+'])]
public function deleteReview(EscapeRoomReview $review, Request $request, EntityManagerInterface $em): Response
{
if (!$this->isCsrfTokenValid('delete_review_'.$review->getId(), (string) $request->request->get('_token'))) {
$this->addFlash('danger', 'Invalid CSRF token.');
return $this->redirectToRoute('website_admin_escaperoom_reviews');
}
if (!$review->isDeleted()) {
$review->setDeletedAt(new \DateTimeImmutable());
$em->flush();
}
$this->addFlash('success', 'Review removed.');
return $this->redirectToRoute('website_admin_escaperoom_reviews');
}
}
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Website\Controller;
use App\Tech\Entity\User;
use App\Website\Entity\EscapeRoom;
use App\Website\Entity\EscapeRoomReview;
use App\Website\Enum\EscapeRoomSource;
use App\Website\Enum\EscapeRoomStatus;
use App\Website\Form\EscapeRoomReviewType;
use App\Website\Form\SuggestEscapeRoomType;
use App\Website\Repository\EscapeRoomRepository;
use App\Website\Repository\EscapeRoomReviewRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
final class EscapeRoomController extends AbstractController
{
#[Route(path: '/escape-rooms', name: 'website_escaperooms', methods: ['GET'])]
public function index(EscapeRoomRepository $rooms): Response
{
return $this->render('website/escape_room/index.html.twig', [
'publishedCount' => $rooms->countByStatus(EscapeRoomStatus::PUBLISHED),
]);
}
/**
* Slim feed the map and the list are both built from. Cached at the edge for
* a few minutes; the dataset only changes when the importer runs or an admin
* edits a room.
*/
#[Route(path: '/escape-rooms/data.json', name: 'website_escaperooms_data', methods: ['GET'])]
public function data(EscapeRoomRepository $rooms): JsonResponse
{
$response = new JsonResponse([
'generatedAt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
'rooms' => $rooms->findMapData(),
]);
$response->setPublic();
$response->setMaxAge(600);
$response->setSharedMaxAge(600);
return $response;
}
#[Route(path: '/escape-rooms/suggest', name: 'website_escaperoom_suggest', methods: ['GET', 'POST'])]
public function suggest(Request $request, EntityManagerInterface $em): Response
{
$room = new EscapeRoom();
$form = $this->createForm(SuggestEscapeRoomType::class, $room);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$room->setSource(EscapeRoomSource::USER)
->setStatus(EscapeRoomStatus::PENDING);
if ($this->getUser() instanceof User) {
$room->setSubmittedBy($this->getUser());
}
$em->persist($room);
$em->flush();
$this->addFlash('success', 'Thanks! Your suggestion has been sent for review and will appear on the map once approved.');
return $this->redirectToRoute('website_escaperooms');
}
return $this->render('website/escape_room/suggest.html.twig', [
'form' => $form,
]);
}
#[Route(path: '/escape-rooms/{id}-{slug}', name: 'website_escaperoom_show', methods: ['GET'], requirements: ['id' => '\d+', 'slug' => '[a-z0-9-]*'])]
public function show(EscapeRoom $room, EscapeRoomReviewRepository $reviews): Response
{
$this->guardVisible($room);
return $this->renderRoom($room, $reviews, $this->createForm(EscapeRoomReviewType::class));
}
#[Route(path: '/escape-rooms/{id}/reviews', name: 'website_escaperoom_review', methods: ['POST'], requirements: ['id' => '\d+'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function review(
EscapeRoom $room,
Request $request,
EntityManagerInterface $em,
EscapeRoomReviewRepository $reviews,
): Response {
$this->guardVisible($room);
$review = new EscapeRoomReview();
$form = $this->createForm(EscapeRoomReviewType::class, $review);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var User $user */
$user = $this->getUser();
$review->setEscapeRoom($room)->setAuthor($user);
$em->persist($review);
$em->flush();
$this->addFlash('success', 'Thanks for your review!');
return $this->redirectToRoute('website_escaperoom_show', [
'id' => $room->getId(),
'slug' => $room->getSlug(),
]);
}
return $this->renderRoom($room, $reviews, $form);
}
private function guardVisible(EscapeRoom $room): void
{
if ($room->isDeleted() || !$room->getStatus()->isPubliclyVisible()) {
throw $this->createNotFoundException();
}
}
private function renderRoom(EscapeRoom $room, EscapeRoomReviewRepository $reviews, FormInterface $reviewForm): Response
{
return $this->render('website/escape_room/show.html.twig', [
'room' => $room,
'reviews' => $reviews->findVisibleForRoom($room),
'rating' => $reviews->ratingSummary($room),
'reviewForm' => $reviewForm,
]);
}
}
+378
View File
@@ -0,0 +1,378 @@
<?php
namespace App\Website\Entity;
use App\Tech\Entity\User;
use App\Website\Enum\EscapeRoomSource;
use App\Website\Enum\EscapeRoomStatus;
use App\Website\Repository\EscapeRoomRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: EscapeRoomRepository::class)]
#[ORM\Table(name: 'escape_room')]
#[ORM\UniqueConstraint(name: 'uniq_escape_room_osm', columns: ['osm_type', 'osm_id'])]
#[ORM\Index(name: 'idx_escape_room_status', columns: ['status'])]
#[ORM\Index(name: 'idx_escape_room_bbox', columns: ['latitude', 'longitude'])]
#[ORM\Index(name: 'idx_escape_room_submitted_by', columns: ['submitted_by_id'])]
class EscapeRoom
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
/** Operating company / brand, when it differs from the room name. */
#[ORM\Column(length: 255, nullable: true)]
private ?string $venue = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $street = null;
#[ORM\Column(length: 32, nullable: true)]
private ?string $houseNumber = null;
#[ORM\Column(length: 32, nullable: true)]
private ?string $postcode = null;
#[ORM\Column(length: 128, nullable: true)]
private ?string $city = null;
/** ISO 3166-1 alpha-2, upper case. */
#[ORM\Column(length: 2, nullable: true)]
private ?string $country = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 7)]
private ?string $latitude = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 7)]
private ?string $longitude = null;
#[ORM\Column(length: 511, nullable: true)]
private ?string $website = null;
#[ORM\Column(length: 64, nullable: true)]
private ?string $phone = null;
#[ORM\Column(length: 16, enumType: EscapeRoomSource::class)]
private EscapeRoomSource $source = EscapeRoomSource::ADMIN;
/** OSM element type: node | way | relation. Set together with osmId for imported rooms. */
#[ORM\Column(length: 8, nullable: true)]
private ?string $osmType = null;
#[ORM\Column(type: Types::BIGINT, nullable: true)]
private ?string $osmId = null;
#[ORM\Column(length: 16, enumType: EscapeRoomStatus::class)]
private EscapeRoomStatus $status = EscapeRoomStatus::PENDING;
/** Kept for imported rooms so a re-import never clobbers manual corrections. */
#[ORM\Column(options: ['default' => false])]
private bool $locked = false;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?User $submittedBy = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $deletedAt = null;
/** @var Collection<int, EscapeRoomReview> */
#[ORM\OneToMany(mappedBy: 'escapeRoom', targetEntity: EscapeRoomReview::class, cascade: ['remove'])]
private Collection $reviews;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
$this->reviews = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getVenue(): ?string
{
return $this->venue;
}
public function setVenue(?string $venue): static
{
$this->venue = $venue;
return $this;
}
public function getStreet(): ?string
{
return $this->street;
}
public function setStreet(?string $street): static
{
$this->street = $street;
return $this;
}
public function getHouseNumber(): ?string
{
return $this->houseNumber;
}
public function setHouseNumber(?string $houseNumber): static
{
$this->houseNumber = $houseNumber;
return $this;
}
public function getPostcode(): ?string
{
return $this->postcode;
}
public function setPostcode(?string $postcode): static
{
$this->postcode = $postcode;
return $this;
}
public function getCity(): ?string
{
return $this->city;
}
public function setCity(?string $city): static
{
$this->city = $city;
return $this;
}
public function getCountry(): ?string
{
return $this->country;
}
public function setCountry(?string $country): static
{
$this->country = $country ? strtoupper($country) : null;
return $this;
}
public function getLatitude(): ?string
{
return $this->latitude;
}
public function setLatitude(string|float|null $latitude): static
{
$this->latitude = $latitude === null ? null : (string) $latitude;
return $this;
}
public function getLongitude(): ?string
{
return $this->longitude;
}
public function setLongitude(string|float|null $longitude): static
{
$this->longitude = $longitude === null ? null : (string) $longitude;
return $this;
}
public function getWebsite(): ?string
{
return $this->website;
}
public function setWebsite(?string $website): static
{
$this->website = $website;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): static
{
$this->phone = $phone;
return $this;
}
public function getSource(): EscapeRoomSource
{
return $this->source;
}
public function setSource(EscapeRoomSource $source): static
{
$this->source = $source;
return $this;
}
public function getOsmType(): ?string
{
return $this->osmType;
}
public function setOsmType(?string $osmType): static
{
$this->osmType = $osmType;
return $this;
}
public function getOsmId(): ?string
{
return $this->osmId;
}
public function setOsmId(int|string|null $osmId): static
{
$this->osmId = $osmId === null ? null : (string) $osmId;
return $this;
}
public function getStatus(): EscapeRoomStatus
{
return $this->status;
}
public function setStatus(EscapeRoomStatus $status): static
{
$this->status = $status;
return $this;
}
public function isLocked(): bool
{
return $this->locked;
}
public function setLocked(bool $locked): static
{
$this->locked = $locked;
return $this;
}
public function getSubmittedBy(): ?User
{
return $this->submittedBy;
}
public function setSubmittedBy(?User $submittedBy): static
{
$this->submittedBy = $submittedBy;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
public function touch(): static
{
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getDeletedAt(): ?\DateTimeImmutable
{
return $this->deletedAt;
}
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
public function isDeleted(): bool
{
return $this->deletedAt !== null;
}
/** @return Collection<int, EscapeRoomReview> */
public function getReviews(): Collection
{
return $this->reviews;
}
/** @return EscapeRoomReview[] */
public function getVisibleReviews(): array
{
return $this->reviews
->filter(static fn (EscapeRoomReview $r) => !$r->isDeleted())
->toArray();
}
public function getSlug(): string
{
$slug = strtolower((string) $this->name);
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
return trim($slug, '-') ?: 'room';
}
public function getDisplayAddress(): string
{
$line = trim(sprintf('%s %s', (string) $this->street, (string) $this->houseNumber));
$parts = array_filter([
$line !== '' ? $line : null,
trim(sprintf('%s %s', (string) $this->postcode, (string) $this->city)) ?: null,
$this->country,
]);
return implode(', ', $parts);
}
}
+141
View File
@@ -0,0 +1,141 @@
<?php
namespace App\Website\Entity;
use App\Tech\Entity\User;
use App\Website\Repository\EscapeRoomReviewRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: EscapeRoomReviewRepository::class)]
#[ORM\Table(name: 'escape_room_review')]
#[ORM\Index(name: 'idx_escape_room_review_room', columns: ['escape_room_id'])]
#[ORM\Index(name: 'idx_escape_room_review_author', columns: ['author_id'])]
class EscapeRoomReview
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: EscapeRoom::class, inversedBy: 'reviews')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?EscapeRoom $escapeRoom = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?User $author = null;
#[ORM\Column(type: Types::SMALLINT)]
#[Assert\Range(min: 1, max: 5)]
private int $rating = 0;
#[ORM\Column(length: 150)]
#[Assert\NotBlank(message: 'Give your review a short title')]
#[Assert\Length(max: 150)]
private ?string $title = null;
#[ORM\Column(type: Types::TEXT)]
#[Assert\NotBlank(message: 'Write a few words about your experience')]
#[Assert\Length(max: 5000)]
private ?string $body = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $deletedAt = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getEscapeRoom(): ?EscapeRoom
{
return $this->escapeRoom;
}
public function setEscapeRoom(?EscapeRoom $escapeRoom): static
{
$this->escapeRoom = $escapeRoom;
return $this;
}
public function getAuthor(): ?User
{
return $this->author;
}
public function setAuthor(?User $author): static
{
$this->author = $author;
return $this;
}
public function getRating(): int
{
return $this->rating;
}
public function setRating(int $rating): static
{
$this->rating = $rating;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(?string $title): static
{
$this->title = $title;
return $this;
}
public function getBody(): ?string
{
return $this->body;
}
public function setBody(?string $body): static
{
$this->body = $body;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function getDeletedAt(): ?\DateTimeImmutable
{
return $this->deletedAt;
}
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
public function isDeleted(): bool
{
return $this->deletedAt !== null;
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Website\Enum;
enum EscapeRoomSource: string
{
case OSM = 'osm';
case USER = 'user';
case ADMIN = 'admin';
public function label(): string
{
return match ($this) {
self::OSM => 'OpenStreetMap',
self::USER => 'Visitor submission',
self::ADMIN => 'Added by admin',
};
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Website\Enum;
enum EscapeRoomStatus: string
{
/** Submitted by a visitor, awaiting admin moderation. */
case PENDING = 'pending';
/** Live: shown on the public map and list. */
case PUBLISHED = 'published';
/** Reviewed by an admin and turned down; kept for de-duplication. */
case REJECTED = 'rejected';
/** Was public, hidden again by an admin (e.g. room closed down). */
case HIDDEN = 'hidden';
public function label(): string
{
return match ($this) {
self::PENDING => 'Pending review',
self::PUBLISHED => 'Published',
self::REJECTED => 'Rejected',
self::HIDDEN => 'Hidden',
};
}
public function isPubliclyVisible(): bool
{
return $this === self::PUBLISHED;
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Website\Form;
use App\Website\Entity\EscapeRoom;
use App\Website\Enum\EscapeRoomSource;
use App\Website\Enum\EscapeRoomStatus;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TelType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Range;
class AdminEscapeRoomType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, ['constraints' => [new NotBlank()]])
->add('venue', TextType::class, ['required' => false])
->add('street', TextType::class, ['required' => false])
->add('houseNumber', TextType::class, ['label' => 'House number', 'required' => false])
->add('postcode', TextType::class, ['required' => false])
->add('city', TextType::class, ['required' => false])
->add('country', CountryType::class, ['placeholder' => '—', 'required' => false])
->add('latitude', NumberType::class, [
'scale' => 7,
'html5' => true,
'constraints' => [new NotBlank(), new Range(min: -90, max: 90)],
])
->add('longitude', NumberType::class, [
'scale' => 7,
'html5' => true,
'constraints' => [new NotBlank(), new Range(min: -180, max: 180)],
])
->add('website', UrlType::class, ['required' => false, 'default_protocol' => 'https'])
->add('phone', TelType::class, ['required' => false])
->add('status', EnumType::class, [
'class' => EscapeRoomStatus::class,
'choice_label' => fn (EscapeRoomStatus $s) => $s->label(),
])
->add('source', EnumType::class, [
'class' => EscapeRoomSource::class,
'choice_label' => fn (EscapeRoomSource $s) => $s->label(),
])
->add('locked', CheckboxType::class, [
'required' => false,
'label' => 'Locked (protect from OSM re-import)',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => EscapeRoom::class,
]);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Website\Form;
use App\Website\Entity\EscapeRoomReview;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
class EscapeRoomReviewType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('rating', ChoiceType::class, [
'placeholder' => 'Choose a rating',
'choices' => [
'★★★★★ — Outstanding' => 5,
'★★★★ — Very good' => 4,
'★★★ — Decent' => 3,
'★★ — Poor' => 2,
'★ — Terrible' => 1,
],
'constraints' => [new NotBlank(message: 'Pick a rating')],
])
->add('title', TextType::class, [
'attr' => ['maxlength' => 150, 'placeholder' => 'Sum it up in a few words'],
])
->add('body', TextareaType::class, [
'attr' => ['rows' => 5, 'maxlength' => 5000, 'placeholder' => 'What worked, what didn\'t, would you recommend it?'],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => EscapeRoomReview::class,
]);
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Website\Form;
use App\Website\Entity\EscapeRoom;
use Karser\Recaptcha3Bundle\Form\Recaptcha3Type;
use Karser\Recaptcha3Bundle\Validator\Constraints\Recaptcha3;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\TelType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Range;
use Symfony\Component\Validator\Constraints\Regex;
class SuggestEscapeRoomType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'label' => 'Room / venue name',
'constraints' => [new NotBlank(message: 'Enter the room name'), new Length(max: 255)],
])
->add('venue', TextType::class, [
'label' => 'Operating company (optional)',
'required' => false,
'constraints' => [new Length(max: 255)],
])
->add('street', TextType::class, ['required' => false, 'constraints' => [new Length(max: 255)]])
->add('houseNumber', TextType::class, ['label' => 'No.', 'required' => false, 'constraints' => [new Length(max: 32)]])
->add('postcode', TextType::class, ['required' => false, 'constraints' => [new Length(max: 32)]])
->add('city', TextType::class, ['required' => false, 'constraints' => [new Length(max: 128)]])
->add('country', CountryType::class, [
'placeholder' => 'Select a country',
'required' => false,
])
->add('website', UrlType::class, [
'required' => false,
'default_protocol' => 'https',
'constraints' => [new Length(max: 511)],
])
->add('phone', TelType::class, ['required' => false, 'constraints' => [new Length(max: 64)]])
->add('latitude', HiddenType::class, [
'constraints' => [
new NotBlank(message: 'Drop a pin on the map to set the location'),
new Regex(pattern: '/^-?\d{1,3}(\.\d+)?$/', message: 'Invalid latitude'),
new Range(min: -90, max: 90),
],
])
->add('longitude', HiddenType::class, [
'constraints' => [
new NotBlank(message: 'Drop a pin on the map to set the location'),
new Regex(pattern: '/^-?\d{1,3}(\.\d+)?$/', message: 'Invalid longitude'),
new Range(min: -180, max: 180),
],
])
->add('captcha', Recaptcha3Type::class, [
'mapped' => false,
'constraints' => new Recaptcha3(),
'action_name' => 'escaperoom_suggest',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => EscapeRoom::class,
]);
}
}
@@ -0,0 +1,134 @@
<?php
namespace App\Website\Repository;
use App\Website\Entity\EscapeRoom;
use App\Website\Enum\EscapeRoomStatus;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EscapeRoom>
*/
class EscapeRoomRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EscapeRoom::class);
}
public function save(EscapeRoom $room, bool $flush = true): void
{
$this->getEntityManager()->persist($room);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function findOneByOsm(string $osmType, string $osmId): ?EscapeRoom
{
return $this->findOneBy(['osmType' => $osmType, 'osmId' => $osmId]);
}
/**
* Lightweight rows for the public map / list. One row per published room with
* its aggregated rating. Optionally clipped to a lat/lng bounding box.
*
* @param array{south: float, west: float, north: float, east: float}|null $bbox
*
* @return list<array{
* id: int, name: string, city: ?string, country: ?string,
* lat: float, lng: float, avgRating: ?float, reviewCount: int
* }>
*/
public function findMapData(?array $bbox = null, int $limit = 20000): array
{
$qb = $this->createQueryBuilder('r')
->select('r.id, r.name, r.city, r.country, r.latitude, r.longitude')
->addSelect('AVG(rev.rating) AS avgRating')
->addSelect('COUNT(rev.id) AS reviewCount')
->leftJoin('r.reviews', 'rev', 'WITH', 'rev.deletedAt IS NULL')
->where('r.deletedAt IS NULL')
->andWhere('r.status = :published')
->setParameter('published', EscapeRoomStatus::PUBLISHED)
->groupBy('r.id')
->setMaxResults($limit);
if ($bbox !== null) {
$qb->andWhere('r.latitude BETWEEN :south AND :north')
->andWhere('r.longitude BETWEEN :west AND :east')
->setParameter('south', $bbox['south'])
->setParameter('north', $bbox['north'])
->setParameter('west', $bbox['west'])
->setParameter('east', $bbox['east']);
}
$rows = $qb->getQuery()->getArrayResult();
return array_map(static function (array $row): array {
return [
'id' => (int) $row['id'],
'name' => (string) $row['name'],
'city' => $row['city'],
'country' => $row['country'],
'lat' => (float) $row['latitude'],
'lng' => (float) $row['longitude'],
'avgRating' => $row['reviewCount'] > 0 ? round((float) $row['avgRating'], 1) : null,
'reviewCount' => (int) $row['reviewCount'],
];
}, $rows);
}
public function countByStatus(EscapeRoomStatus $status): int
{
return (int) $this->createQueryBuilder('r')
->select('COUNT(r.id)')
->where('r.deletedAt IS NULL')
->andWhere('r.status = :status')
->setParameter('status', $status)
->getQuery()
->getSingleScalarResult();
}
/**
* All non-deleted rooms, newest first. For the admin list.
*
* @return EscapeRoom[]
*/
public function findForAdmin(?EscapeRoomStatus $status = null): array
{
$qb = $this->createQueryBuilder('r')
->where('r.deletedAt IS NULL')
->orderBy('r.createdAt', 'DESC');
if ($status !== null) {
$qb->andWhere('r.status = :status')->setParameter('status', $status);
}
return $qb->getQuery()->getResult();
}
/**
* Visible review count per room, in one query, for the admin list.
*
* @return array<int, int> roomId => count
*/
public function reviewCounts(): array
{
$rows = $this->getEntityManager()
->createQuery(
'SELECT IDENTITY(rev.escapeRoom) AS rid, COUNT(rev.id) AS c '
.'FROM App\Website\Entity\EscapeRoomReview rev '
.'WHERE rev.deletedAt IS NULL GROUP BY rid'
)
->getScalarResult();
$map = [];
foreach ($rows as $row) {
$map[(int) $row['rid']] = (int) $row['c'];
}
return $map;
}
}
@@ -0,0 +1,80 @@
<?php
namespace App\Website\Repository;
use App\Website\Entity\EscapeRoom;
use App\Website\Entity\EscapeRoomReview;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EscapeRoomReview>
*/
class EscapeRoomReviewRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EscapeRoomReview::class);
}
public function save(EscapeRoomReview $review, bool $flush = true): void
{
$this->getEntityManager()->persist($review);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/**
* @return EscapeRoomReview[]
*/
public function findVisibleForRoom(EscapeRoom $room): array
{
return $this->createQueryBuilder('rev')
->where('rev.escapeRoom = :room')
->andWhere('rev.deletedAt IS NULL')
->setParameter('room', $room)
->orderBy('rev.createdAt', 'DESC')
->getQuery()
->getResult();
}
/**
* @return array{avg: ?float, count: int}
*/
public function ratingSummary(EscapeRoom $room): array
{
$row = $this->createQueryBuilder('rev')
->select('AVG(rev.rating) AS avg, COUNT(rev.id) AS count')
->where('rev.escapeRoom = :room')
->andWhere('rev.deletedAt IS NULL')
->setParameter('room', $room)
->getQuery()
->getSingleResult();
$count = (int) $row['count'];
return [
'avg' => $count > 0 ? round((float) $row['avg'], 1) : null,
'count' => $count,
];
}
/**
* Recent reviews across all rooms, for admin moderation.
*
* @return EscapeRoomReview[]
*/
public function findRecent(int $limit = 200): array
{
return $this->createQueryBuilder('rev')
->addSelect('room')
->join('rev.escapeRoom', 'room')
->where('rev.deletedAt IS NULL')
->orderBy('rev.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
}