From cb407477bfc5ab5ef0b904ec2c539185ede01e9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Sun, 13 Sep 2026 14:54:42 +0200 Subject: [PATCH] feat: add the booking domain layer --- .env | 8 +- config/packages/doctrine.yaml | 2 - migrations/Version20260913125102.php | 39 ++++++ src/Entity/Booking.php | 77 +++++++++++ src/Entity/Resource.php | 90 +++++++++++++ src/Entity/ResourceType.php | 19 +++ src/Entity/User.php | 95 +++++++++++++ src/Repository/BookingRepository.php | 93 +++++++++++++ src/Repository/ResourceRepository.php | 48 +++++++ src/Repository/UserRepository.php | 25 ++++ tests/Entity/BookingTest.php | 25 ++++ tests/Entity/UserTest.php | 29 ++++ tests/Repository/BookingRepositoryTest.php | 149 +++++++++++++++++++++ 13 files changed, 693 insertions(+), 6 deletions(-) create mode 100644 migrations/Version20260913125102.php create mode 100644 src/Entity/Booking.php create mode 100644 src/Entity/Resource.php create mode 100644 src/Entity/ResourceType.php create mode 100644 src/Entity/User.php create mode 100644 src/Repository/BookingRepository.php create mode 100644 src/Repository/ResourceRepository.php create mode 100644 src/Repository/UserRepository.php create mode 100644 tests/Entity/BookingTest.php create mode 100644 tests/Entity/UserTest.php create mode 100644 tests/Repository/BookingRepositoryTest.php diff --git a/.env b/.env index 5450e90..cd76e70 100644 --- a/.env +++ b/.env @@ -30,10 +30,10 @@ DEFAULT_URI=http://localhost # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url # IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml # -# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db" -# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" -# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" -DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8" +# MariaDB 11.x, as provided by DDEV. The mysql:// scheme is just DBAL's pdo_mysql alias -- +# serverVersion is what selects MariaDBPlatform over MySQLPlatform, so keep it in sync with +# the container. DDEV writes the same value into the gitignored .env.local. +DATABASE_URL="mysql://db:db@db:3306/db?charset=utf8mb4&serverVersion=11.8.0-mariadb" ###< doctrine/doctrine-bundle ### ###> symfony/messenger ### diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 290611c..ffb8ee9 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -10,8 +10,6 @@ doctrine: orm: validate_xml_mapping: true naming_strategy: doctrine.orm.naming_strategy.underscore - identity_generation_preferences: - Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity auto_mapping: true mappings: App: diff --git a/migrations/Version20260913125102.php b/migrations/Version20260913125102.php new file mode 100644 index 0000000..e926e17 --- /dev/null +++ b/migrations/Version20260913125102.php @@ -0,0 +1,39 @@ +addSql('CREATE TABLE booking (id INT AUTO_INCREMENT NOT NULL, date DATE NOT NULL, created_at DATETIME NOT NULL, user_id INT NOT NULL, resource_id INT NOT NULL, UNIQUE INDEX uniq_booking_resource_date (resource_id, date), INDEX IDX_E00CEDDEA76ED395 (user_id), INDEX IDX_E00CEDDE89329D25 (resource_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE resource (id INT AUTO_INCREMENT NOT NULL, type VARCHAR(255) NOT NULL, label VARCHAR(50) NOT NULL, svg_element_id VARCHAR(50) DEFAULT NULL, active TINYINT NOT NULL, UNIQUE INDEX uniq_resource_label (type, label), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE `user` (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(180) NOT NULL, name VARCHAR(100) NOT NULL, roles JSON NOT NULL, UNIQUE INDEX uniq_user_email (email), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE messenger_messages (id BIGINT AUTO_INCREMENT NOT NULL, body LONGTEXT NOT NULL, headers LONGTEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at DATETIME NOT NULL, available_at DATETIME NOT NULL, delivered_at DATETIME DEFAULT NULL, INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 (queue_name, available_at, delivered_at, id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE booking ADD CONSTRAINT FK_E00CEDDEA76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE booking ADD CONSTRAINT FK_E00CEDDE89329D25 FOREIGN KEY (resource_id) REFERENCES resource (id) ON DELETE RESTRICT'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE booking DROP FOREIGN KEY FK_E00CEDDEA76ED395'); + $this->addSql('ALTER TABLE booking DROP FOREIGN KEY FK_E00CEDDE89329D25'); + $this->addSql('DROP TABLE booking'); + $this->addSql('DROP TABLE resource'); + $this->addSql('DROP TABLE `user`'); + $this->addSql('DROP TABLE messenger_messages'); + } +} diff --git a/src/Entity/Booking.php b/src/Entity/Booking.php new file mode 100644 index 0000000..3ff0628 --- /dev/null +++ b/src/Entity/Booking.php @@ -0,0 +1,77 @@ +user = $user; + $this->resource = $resource; + // Normalised to midnight so the unique constraint cannot be sidestepped by a time part. + $this->date = $date->setTime(0, 0); + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUser(): User + { + return $this->user; + } + + public function getResource(): Resource + { + return $this->resource; + } + + public function getDate(): \DateTimeImmutable + { + return $this->date; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function belongsTo(User $user): bool + { + return $this->user->getId() === $user->getId(); + } +} diff --git a/src/Entity/Resource.php b/src/Entity/Resource.php new file mode 100644 index 0000000..6d4e1b0 --- /dev/null +++ b/src/Entity/Resource.php @@ -0,0 +1,90 @@ +type = $type; + $this->label = $label; + $this->svgElementId = $svgElementId; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getType(): ResourceType + { + return $this->type; + } + + public function getLabel(): string + { + return $this->label; + } + + public function setLabel(string $label): void + { + $this->label = $label; + } + + public function getSvgElementId(): ?string + { + return $this->svgElementId; + } + + public function setSvgElementId(?string $svgElementId): void + { + $this->svgElementId = $svgElementId; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(bool $active): void + { + $this->active = $active; + } +} diff --git a/src/Entity/ResourceType.php b/src/Entity/ResourceType.php new file mode 100644 index 0000000..310856e --- /dev/null +++ b/src/Entity/ResourceType.php @@ -0,0 +1,19 @@ + 'Schreibtisch', + self::PARKING => 'Parkplatz', + }; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php new file mode 100644 index 0000000..10af1d1 --- /dev/null +++ b/src/Entity/User.php @@ -0,0 +1,95 @@ + + */ + #[ORM\Column] + private array $roles = []; + + public function __construct(string $email, string $name) + { + $this->email = $email; + $this->name = $name; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getEmail(): string + { + return $this->email; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getUserIdentifier(): string + { + return $this->email; + } + + /** + * @return list + */ + public function getRoles(): array + { + return array_values(array_unique([...$this->roles, 'ROLE_USER'])); + } + + /** + * @param list $roles + */ + public function setRoles(array $roles): void + { + $this->roles = $roles; + } + + public function eraseCredentials(): void + { + } +} diff --git a/src/Repository/BookingRepository.php b/src/Repository/BookingRepository.php new file mode 100644 index 0000000..91e988b --- /dev/null +++ b/src/Repository/BookingRepository.php @@ -0,0 +1,93 @@ + + */ +class BookingRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Booking::class); + } + + /** + * Everything booked on one day, for colouring the floor plan. + * + * Team view is in scope, so the booking holder's name is shown on occupied resources -- + * hence the joins are eager, or the plan would fire a query per drawn element. + * + * @return list + */ + public function findForDate(\DateTimeImmutable $date): array + { + return $this->createQueryBuilder('b') + ->addSelect('u', 'r') + ->join('b.user', 'u') + ->join('b.resource', 'r') + ->andWhere('b.date = :date') + ->setParameter('date', $date->setTime(0, 0)) + ->orderBy('r.label', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * One person's bookings over a window, for the "my bookings" list. + * + * @return list + */ + public function findForUserInRange(User $user, \DateTimeImmutable $from, \DateTimeImmutable $to): array + { + return $this->createQueryBuilder('b') + ->addSelect('r') + ->join('b.resource', 'r') + ->andWhere('b.user = :user') + ->andWhere('b.date BETWEEN :from AND :to') + ->setParameter('user', $user) + ->setParameter('from', $from->setTime(0, 0)) + ->setParameter('to', $to->setTime(0, 0)) + ->orderBy('b.date', 'ASC') + ->addOrderBy('r.label', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * Bookings per day across a window, keyed by Y-m-d -- what the calendar grid renders as + * occupancy, and the same query team view reads. + * + * @return array + */ + public function countPerDateInRange(\DateTimeImmutable $from, \DateTimeImmutable $to): array + { + $rows = $this->createQueryBuilder('b') + ->select('b.date AS date', 'COUNT(b.id) AS total') + ->andWhere('b.date BETWEEN :from AND :to') + ->setParameter('from', $from->setTime(0, 0)) + ->setParameter('to', $to->setTime(0, 0)) + ->groupBy('b.date') + ->getQuery() + ->getResult(); + + $counts = []; + foreach ($rows as $row) { + $counts[$row['date']->format('Y-m-d')] = (int) $row['total']; + } + + return $counts; + } + + public function findOneForUserAndDate(User $user, \DateTimeImmutable $date): ?Booking + { + return $this->findOneBy(['user' => $user, 'date' => $date->setTime(0, 0)]); + } +} diff --git a/src/Repository/ResourceRepository.php b/src/Repository/ResourceRepository.php new file mode 100644 index 0000000..910e27f --- /dev/null +++ b/src/Repository/ResourceRepository.php @@ -0,0 +1,48 @@ + + */ +class ResourceRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Resource::class); + } + + /** + * @return list + */ + public function findActiveByType(ResourceType $type): array + { + return $this->createQueryBuilder('r') + ->andWhere('r.active = true') + ->andWhere('r.type = :type') + ->setParameter('type', $type) + ->orderBy('r.label', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * @return list + */ + public function findAllActive(): array + { + return $this->createQueryBuilder('r') + ->andWhere('r.active = true') + ->orderBy('r.type', 'ASC') + ->addOrderBy('r.label', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100644 index 0000000..ec1c4b6 --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,25 @@ + + */ +class UserRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, User::class); + } + + public function findOneByEmail(string $email): ?User + { + return $this->findOneBy(['email' => $email]); + } +} diff --git a/tests/Entity/BookingTest.php b/tests/Entity/BookingTest.php new file mode 100644 index 0000000..9a4ecc8 --- /dev/null +++ b/tests/Entity/BookingTest.php @@ -0,0 +1,25 @@ +getDate()->format('Y-m-d H:i:s')); + } +} diff --git a/tests/Entity/UserTest.php b/tests/Entity/UserTest.php new file mode 100644 index 0000000..2284fad --- /dev/null +++ b/tests/Entity/UserTest.php @@ -0,0 +1,29 @@ +getRoles()); + } + + public function testRoleUserIsNotDuplicatedWhenAlreadyGranted(): void + { + $user = new User('a@ep-reisen.de', 'A'); + $user->setRoles(['ROLE_USER', 'ROLE_EMPLOYEE']); + + self::assertSame(['ROLE_USER', 'ROLE_EMPLOYEE'], $user->getRoles()); + } + + public function testTheEmailIsTheSecurityIdentifier(): void + { + self::assertSame('a@ep-reisen.de', (new User('a@ep-reisen.de', 'A'))->getUserIdentifier()); + } +} diff --git a/tests/Repository/BookingRepositoryTest.php b/tests/Repository/BookingRepositoryTest.php new file mode 100644 index 0000000..d04e6fb --- /dev/null +++ b/tests/Repository/BookingRepositoryTest.php @@ -0,0 +1,149 @@ +em = self::getContainer()->get(EntityManagerInterface::class); + $this->bookings = self::getContainer()->get(BookingRepository::class); + $this->em->getConnection()->beginTransaction(); + } + + protected function tearDown(): void + { + $this->em->getConnection()->rollBack(); + parent::tearDown(); + } + + public function testTheSameResourceCannotBeBookedTwiceOnOneDay(): void + { + $desk = $this->resource('D1'); + $date = new \DateTimeImmutable('2026-10-01'); + + $this->persist(new Booking($this->user('first@ep-reisen.de'), $desk, $date)); + $this->em->flush(); + + $this->persist(new Booking($this->user('second@ep-reisen.de'), $desk, $date)); + + // The controller catches exactly this and re-renders the fragment with an inline error, + // rather than pre-checking, which would be racy. + $this->expectException(UniqueConstraintViolationException::class); + $this->em->flush(); + } + + public function testTheSameResourceOnAnotherDayIsFine(): void + { + $desk = $this->resource('D1'); + $user = $this->user('first@ep-reisen.de'); + + $this->persist(new Booking($user, $desk, new \DateTimeImmutable('2026-10-01'))); + $this->persist(new Booking($user, $desk, new \DateTimeImmutable('2026-10-02'))); + $this->em->flush(); + + self::assertCount(1, $this->bookings->findForDate(new \DateTimeImmutable('2026-10-01'))); + } + + public function testATimeComponentDoesNotCreateASecondSlotForTheSameDay(): void + { + $desk = $this->resource('D1'); + + $this->persist(new Booking($this->user('first@ep-reisen.de'), $desk, new \DateTimeImmutable('2026-10-01 08:00'))); + $this->em->flush(); + + $this->persist(new Booking($this->user('second@ep-reisen.de'), $desk, new \DateTimeImmutable('2026-10-01 17:30'))); + + $this->expectException(UniqueConstraintViolationException::class); + $this->em->flush(); + } + + public function testFindForDateCarriesTheBookingHolderForTheTeamView(): void + { + $this->persist(new Booking($this->user('anna@ep-reisen.de', 'Anna'), $this->resource('D1'), new \DateTimeImmutable('2026-10-01'))); + $this->em->flush(); + $this->em->clear(); + + $bookings = $this->bookings->findForDate(new \DateTimeImmutable('2026-10-01')); + + self::assertCount(1, $bookings); + self::assertSame('Anna', $bookings[0]->getUser()->getName()); + self::assertSame('D1', $bookings[0]->getResource()->getLabel()); + } + + public function testCountPerDateInRangeIsKeyedByDay(): void + { + $user = $this->user('anna@ep-reisen.de'); + $this->persist(new Booking($user, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'))); + $this->persist(new Booking($this->user('bea@ep-reisen.de'), $this->resource('D2'), new \DateTimeImmutable('2026-10-01'))); + $this->persist(new Booking($user, $this->resource('P1', ResourceType::PARKING), new \DateTimeImmutable('2026-10-03'))); + $this->em->flush(); + + $counts = $this->bookings->countPerDateInRange(new \DateTimeImmutable('2026-10-01'), new \DateTimeImmutable('2026-10-31')); + + self::assertSame(['2026-10-01' => 2, '2026-10-03' => 1], $counts); + } + + public function testCountPerDateIgnoresDaysOutsideTheWindow(): void + { + $this->persist(new Booking($this->user('anna@ep-reisen.de'), $this->resource('D1'), new \DateTimeImmutable('2026-11-05'))); + $this->em->flush(); + + self::assertSame([], $this->bookings->countPerDateInRange(new \DateTimeImmutable('2026-10-01'), new \DateTimeImmutable('2026-10-31'))); + } + + public function testFindForUserInRangeOnlyReturnsThatPersonsBookings(): void + { + $anna = $this->user('anna@ep-reisen.de'); + $this->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'))); + $this->persist(new Booking($this->user('bea@ep-reisen.de'), $this->resource('D2'), new \DateTimeImmutable('2026-10-01'))); + $this->em->flush(); + + $mine = $this->bookings->findForUserInRange($anna, new \DateTimeImmutable('2026-10-01'), new \DateTimeImmutable('2026-10-31')); + + self::assertCount(1, $mine); + self::assertSame('D1', $mine[0]->getResource()->getLabel()); + } + + private function persist(object $entity): void + { + $this->em->persist($entity); + } + + private function user(string $email, string $name = 'Test'): User + { + $user = new User($email, $name); + $this->em->persist($user); + + return $user; + } + + private function resource(string $label, ResourceType $type = ResourceType::DESK): Resource + { + $resource = new Resource($type, $label); + $this->em->persist($resource); + + return $resource; + } +}