feat: add the booking domain layer

This commit is contained in:
fromme committed 2026-09-13 14:54:42 +02:00
1 parent ef8a88ed2a
commit cb407477bf
13 files changed
+693 -6

No files matched your search

+4 -4
View File
@@ -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:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
DATABASE_URL="postgresql://app:[email protected]: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 ###
-2
View File
@@ -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:
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Initial schema: users mirrored from myep, bookable resources, and bookings.
*/
final class Version20260913125102 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create user, resource and booking tables';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\BookingRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* One person holding one resource for one whole day.
*
* The unique constraint is the actual guard against double-booking -- checking first and then
* inserting is racy, so callers insert and catch UniqueConstraintViolationException instead.
*/
#[ORM\Entity(repositoryClass: BookingRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_booking_resource_date', columns: ['resource_id', 'date'])]
class Booking
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private User $user;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
private Resource $resource;
#[ORM\Column(type: 'date_immutable')]
private \DateTimeImmutable $date;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
public function __construct(User $user, Resource $resource, \DateTimeImmutable $date)
{
$this->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();
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\ResourceRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* A bookable thing: a desk or a parking spot.
*/
#[ORM\Entity(repositoryClass: ResourceRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_resource_label', columns: ['type', 'label'])]
class Resource
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(enumType: ResourceType::class)]
private ResourceType $type;
#[ORM\Column(length: 50)]
#[Assert\NotBlank]
private string $label;
/**
* Id of the matching element in the hand-authored floor plan SVG. Null for anything that is
* bookable but not drawn on the plan.
*/
#[ORM\Column(length: 50, nullable: true)]
private ?string $svgElementId = null;
/**
* Retired resources stay in the table so historical bookings keep resolving; they are just
* not offered any more.
*/
#[ORM\Column]
private bool $active = true;
public function __construct(ResourceType $type, string $label, ?string $svgElementId = null)
{
$this->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;
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Entity;
enum ResourceType: string
{
case DESK = 'desk';
case PARKING = 'parking';
public function label(): string
{
return match ($this) {
self::DESK => 'Schreibtisch',
self::PARKING => 'Parkplatz',
};
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;
/**
* A local mirror of an identity held by myep.
*
* There is no password: authentication happens against myep over OAuth2, and ephub reads
* /api/userinfo once at login and then relies on its own session.
*/
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
#[ORM\UniqueConstraint(name: 'uniq_user_email', columns: ['email'])]
class User implements UserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180)]
#[Assert\NotBlank]
#[Assert\Email]
private string $email;
#[ORM\Column(length: 100)]
private string $name;
/**
* Always filtered through a local whitelist before being stored -- the identity provider's
* claim is never trusted verbatim.
*
* @var list<string>
*/
#[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<string>
*/
public function getRoles(): array
{
return array_values(array_unique([...$this->roles, 'ROLE_USER']));
}
/**
* @param list<string> $roles
*/
public function setRoles(array $roles): void
{
$this->roles = $roles;
}
public function eraseCredentials(): void
{
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Booking;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Booking>
*/
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<Booking>
*/
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<Booking>
*/
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<string, int>
*/
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)]);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Resource;
use App\Entity\ResourceType;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Resource>
*/
class ResourceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Resource::class);
}
/**
* @return list<Resource>
*/
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<Resource>
*/
public function findAllActive(): array
{
return $this->createQueryBuilder('r')
->andWhere('r.active = true')
->orderBy('r.type', 'ASC')
->addOrderBy('r.label', 'ASC')
->getQuery()
->getResult();
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<User>
*/
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]);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Tests\Entity;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Entity\User;
use PHPUnit\Framework\TestCase;
class BookingTest extends TestCase
{
public function testTheDateIsNormalisedToMidnightSoTheUniqueConstraintCannotBeSidestepped(): void
{
$booking = new Booking(
new User('[email protected]', 'A'),
new Resource(ResourceType::DESK, 'D1'),
new \DateTimeImmutable('2026-09-13 14:37:11'),
);
self::assertSame('2026-09-13 00:00:00', $booking->getDate()->format('Y-m-d H:i:s'));
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Tests\Entity;
use App\Entity\User;
use PHPUnit\Framework\TestCase;
class UserTest extends TestCase
{
public function testEveryUserGetsRoleUserWithoutItBeingStored(): void
{
self::assertSame(['ROLE_USER'], (new User('[email protected]', 'A'))->getRoles());
}
public function testRoleUserIsNotDuplicatedWhenAlreadyGranted(): void
{
$user = new User('[email protected]', 'A');
$user->setRoles(['ROLE_USER', 'ROLE_EMPLOYEE']);
self::assertSame(['ROLE_USER', 'ROLE_EMPLOYEE'], $user->getRoles());
}
public function testTheEmailIsTheSecurityIdentifier(): void
{
self::assertSame('[email protected]', (new User('[email protected]', 'A'))->getUserIdentifier());
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Tests\Repository;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Entity\User;
use App\Repository\BookingRepository;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Hits the real database, because the behaviour under test is the database's: the unique
* constraint is what actually prevents double-booking.
*
* Each test runs inside a transaction that is rolled back afterwards.
*/
class BookingRepositoryTest extends KernelTestCase
{
private EntityManagerInterface $em;
private BookingRepository $bookings;
protected function setUp(): void
{
self::bootKernel();
$this->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('[email protected]'), $desk, $date));
$this->em->flush();
$this->persist(new Booking($this->user('[email protected]'), $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('[email protected]');
$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('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01 08:00')));
$this->em->flush();
$this->persist(new Booking($this->user('[email protected]'), $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('[email protected]', '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('[email protected]');
$this->persist(new Booking($user, $this->resource('D1'), new \DateTimeImmutable('2026-10-01')));
$this->persist(new Booking($this->user('[email protected]'), $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('[email protected]'), $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('[email protected]');
$this->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01')));
$this->persist(new Booking($this->user('[email protected]'), $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;
}
}