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

+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]);
}
}