feat: add the booking domain layer
This commit is contained in:
1 parent
ef8a88ed2a
commit
cb407477bf
13 files changed
+693
-6
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user