Compare commits

..
8 Commits
68 changed files with 2920 additions and 783 deletions

No files matched your search

+1 -6
View File
@@ -87,10 +87,5 @@ EP_OAUTH2_SCOPES=email,profile,roles
###> app/features ###
# Feature flags, read by config/packages/flagception.yaml. Off here and switched on per
# deployment in .env.local, so a feature reaches production when its environment says so rather
# than when it is merged.
#
# Door, lift and barrier codes under /access-codes and their admin table. Off until the codes
# have actually been entered: an empty list of codes is worse than no menu entry, because it
# reads as "there are none" to somebody standing at the door.
FEATURE_ACCESS_CODES=false
# than when it is merged. None at the moment.
###< app/features ###
-3
View File
@@ -8,6 +8,3 @@ APP_SECRET=44660e8fcd421376ebe0f7a7d55e179c
# ddev serves the application under this name; anything else is refused.
SYMFONY_TRUSTED_HOSTS='^ephub\.ddev\.site$'
# On for local development, so a fresh checkout sees the feature the branch is about.
FEATURE_ACCESS_CODES=true
-4
View File
@@ -3,7 +3,3 @@ KERNEL_CLASS='App\Kernel'
# Fixed and committed so the test suite is reproducible, and never served: .env.test is excluded
# from the deploy rsync and production takes APP_SECRET from its own uncommitted .env.local.
APP_SECRET='$ecretf0rt3st'
# The access-code suite tests the feature, not the gate in front of it, so it runs with the flag
# on.
FEATURE_ACCESS_CODES=true
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 12a9 9 0 1 0 9-9a9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5m4-1v5l4 2"/></g></svg>

After

Width:  |  Height:  |  Size: 262 B

+6 -6
View File
@@ -7,14 +7,14 @@
"php": ">=8.5",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/doctrine-bundle": "^3.3",
"doctrine/doctrine-bundle": "^3.3.4",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.7.2",
"doctrine/orm": "^3.7.5",
"flagception/flagception-bundle": "^6.0",
"knplabs/knp-paginator-bundle": "^6.10",
"league/oauth2-client": "^2.9.1",
"phpdocumentor/reflection-docblock": "^6.0",
"phpstan/phpdoc-parser": "^2.3",
"phpstan/phpdoc-parser": "^2.4.0",
"symfony/apache-pack": "*",
"symfony/asset": "7.4.*",
"symfony/asset-mapper": "7.4.*",
@@ -37,11 +37,11 @@
"symfony/runtime": "7.4.*",
"symfony/security-bundle": "7.4.*",
"symfony/serializer": "7.4.*",
"symfony/stimulus-bundle": "^3.5.1",
"symfony/stimulus-bundle": "^3.6.0",
"symfony/string": "7.4.*",
"symfony/translation": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/ux-icons": "^3.5.1",
"symfony/ux-icons": "^3.6.0",
"symfony/validator": "7.4.*",
"symfony/web-link": "7.4.*",
"symfony/yaml": "7.4.*",
@@ -104,7 +104,7 @@
}
},
"require-dev": {
"phpunit/phpunit": "^13.3.5",
"phpunit/phpunit": "^13.4.5",
"symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.4.*",
"symfony/debug-bundle": "7.4.*",
Generated
+519 -509
View File
File diff suppressed because it is too large. Load diff
+3 -4
View File
@@ -4,7 +4,6 @@
# flag gets the state the feature had before it existed. Switching one on is an .env change, not
# a release.
flagception:
features:
access_codes:
default: false
env: FEATURE_ACCESS_CODES
# None at the moment. A new one is an entry here, with `default: false` and an `env` variable
# declared in .env.
features: {}
+1 -1
View File
@@ -21,7 +21,7 @@ framework:
# Mail is deliberately NOT routed to async. Nothing starts a worker on the server --
# deploy.php only stops them -- so a queued SendEmailMessage would be written to
# messenger_messages and stay there, with no error anywhere and nobody notified.
# Sending happens in the request instead; BookingNotifier catches a transport
# Sending happens in the request instead; App\Email\Mailer catches a transport
# failure so it can never fail the booking that triggered it. Route it back here
# once a `messenger:consume async` worker actually runs.
# Symfony\Component\Mailer\Messenger\SendEmailMessage: async
+14 -4
View File
@@ -7,6 +7,13 @@ security:
# No password_hashers: there are no passwords here. Authentication is OAuth2 against myep,
# which owns the credentials; ephub only ever sees a userinfo response.
# An administrator can do everything a supervisor can, so the "may act on other people's
# bookings" checks ask for ROLE_SUPERVISOR alone. Only isGranted() sees this:
# User::getRoles() still returns the stored roles, which is why CodeVisibility names
# ROLE_ADMIN explicitly.
role_hierarchy:
ROLE_ADMIN: [ROLE_SUPERVISOR]
providers:
app_user_provider:
entity:
@@ -46,8 +53,11 @@ security:
# Before the catch-all, or ROLE_EMPLOYEE would match ^/ first and the admin area would be
# open to everybody. Order is the whole guard here.
- { path: ^/admin, roles: ROLE_ADMIN, requires_channel: https }
# Either role opens the app -- access_control treats a list of roles as an OR. Not
# Everybody's bookings, for supervisors and (through role_hierarchy) administrators. Kept
# out of ^/admin so that everything under it stays administrator-only.
- { path: ^/manage, roles: ROLE_SUPERVISOR, requires_channel: https }
# Any of these opens the app -- access_control treats a list of roles as an OR. Not
# ROLE_USER: myep authenticates far more people than work here, and someone who holds
# neither of these is refused at login with an explanation rather than let in to an
# empty screen.
- { path: ^/, roles: [ROLE_EMPLOYEE, ROLE_ADMIN], requires_channel: https }
# none of these is refused at login with an explanation rather than let in to an empty
# screen. Keep in step with Role::ELIGIBLE.
- { path: ^/, roles: [ROLE_EMPLOYEE, ROLE_SUPERVISOR, ROLE_ADMIN], requires_channel: https }
+4 -4
View File
@@ -680,7 +680,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus"
* routing?: array<string, array{ // Default: []
* service?: scalar|Param|null,
* secret?: scalar|Param|null, // The secret used to verify incoming request signatures. It must be set in production: with an empty value, requests from any sender are accepted. // Default: ""
* secret?: scalar|Param|null, // The secret used to verify incoming request signatures. It must be set in production: with an empty value, depending on the parser, requests from any sender are accepted or every request is rejected. // Default: ""
* }>,
* },
* remote_event?: bool|array{ // RemoteEvent configuration
@@ -1224,7 +1224,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* token_handler?: Param|string|array{
* id?: scalar|Param|null,
* oidc_user_info?: Param|string|array{
* base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured).
* base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured). The latter must end with a slash when it has a path, as the discovery path is resolved against it as a relative URL.
* discovery?: array{ // Enable the OIDC discovery.
* cache?: array{
* id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
@@ -1293,9 +1293,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* lifetime?: int|Param, // Default: 31536000
* path?: scalar|Param|null, // Default: "/"
* domain?: scalar|Param|null, // Default: null
* secure?: true|false|"auto"|Param, // Default: true
* secure?: true|false|"auto"|Param, // Defaults to the value of "framework.session.cookie_secure", or to "auto".
* httponly?: bool|Param, // Default: true
* samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax"
* samesite?: null|"lax"|"strict"|"none"|Param, // Defaults to the value of "framework.session.cookie_samesite", or to "lax".
* always_remember_me?: bool|Param, // Default: false
* remember_me_parameter?: scalar|Param|null, // Default: "_remember_me"
* },
+2 -3
View File
@@ -26,9 +26,8 @@ services:
arguments:
$logger: '@monolog.logger.audit'
# Booking notifications. The sender is also the To of every notification -- the actual
# recipients are Bcc'd -- so it has to be an address that may both send and receive.
App\Service\BookingNotifier:
# Every mail goes out from this address, one per recipient -- see App\Email\Mailer.
App\Email\Mailer:
arguments:
$sender: '%env(string:MAILER_FROM)%'
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* The event history behind /admin/history. Starts empty: the audit log file covers the time
* before it, and is not imported.
*/
final class Version20261016135717 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds the booking event history';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE booking_event (id INT AUTO_INCREMENT NOT NULL, occurred_at DATETIME NOT NULL, type VARCHAR(32) NOT NULL, actor_name VARCHAR(100) NOT NULL, holder_name VARCHAR(100) DEFAULT NULL, resource_id INT DEFAULT NULL, resource_label VARCHAR(50) NOT NULL, resource_type VARCHAR(32) NOT NULL, date DATE DEFAULT NULL, end_date DATE DEFAULT NULL, start_minute INT DEFAULT NULL, end_minute INT DEFAULT NULL, previous_start_minute INT DEFAULT NULL, previous_end_minute INT DEFAULT NULL, booking_id INT DEFAULT NULL, series_id INT DEFAULT NULL, details JSON NOT NULL, actor_id INT DEFAULT NULL, holder_id INT DEFAULT NULL, INDEX idx_booking_event_occurred_at (occurred_at), INDEX idx_booking_event_actor_occurred_at (actor_id, occurred_at), INDEX IDX_AAD2F71210DAF24A (actor_id), INDEX IDX_AAD2F712DEEE62D0 (holder_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE booking_event ADD CONSTRAINT FK_AAD2F71210DAF24A FOREIGN KEY (actor_id) REFERENCES `user` (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE booking_event ADD CONSTRAINT FK_AAD2F712DEEE62D0 FOREIGN KEY (holder_id) REFERENCES `user` (id) ON DELETE SET NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE booking_event DROP FOREIGN KEY FK_AAD2F71210DAF24A');
$this->addSql('ALTER TABLE booking_event DROP FOREIGN KEY FK_AAD2F712DEEE62D0');
$this->addSql('DROP TABLE booking_event');
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\BookingEvent;
use App\Repository\BookingEventRepository;
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;
/**
* Drops event history older than the retention period. Meant to run daily from cron.
*
* The history names who booked what for a year; keeping it longer than that would turn it into a
* record of people's attendance that nobody asked for. The audit log file is rotated separately
* by Monolog and is not touched here.
*/
#[AsCommand(name: 'app:booking-events:prune', description: 'Deletes booking events older than the retention period')]
final class PruneBookingEventsCommand extends Command
{
public function __construct(private readonly BookingEventRepository $events)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption(
'months',
null,
InputOption::VALUE_REQUIRED,
'How many months of history to keep',
(string) BookingEvent::RETENTION_MONTHS,
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$months = filter_var($input->getOption('months'), \FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if (false === $months) {
$io->error('--months must be a positive whole number.');
return Command::INVALID;
}
$cutoff = new \DateTimeImmutable(\sprintf('today -%d months', $months));
$deleted = $this->events->deleteOlderThan($cutoff);
$io->success(\sprintf('Deleted %d booking events from before %s.', $deleted, $cutoff->format('Y-m-d')));
return Command::SUCCESS;
}
}
@@ -7,7 +7,6 @@ namespace App\Controller\AccessCode;
use App\Controller\Traits\CurrentUserTrait;
use App\Entity\AccessCode;
use App\Repository\AccessCodeRepository;
use Flagception\Bundle\FlagceptionBundle\Attribute\Feature;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -24,7 +23,6 @@ use Symfony\Component\Routing\Attribute\Route;
* has been replaced is gone the day its successor takes over. Both would only be one more number
* on the screen for somebody to mistype.
*/
#[Feature('access_codes')]
final class IndexController extends AbstractController
{
use CurrentUserTrait;
@@ -11,14 +11,12 @@ use App\Htmx\HxRedirectResponse;
use App\Security\Role;
use App\Service\AuditLogger;
use Doctrine\ORM\EntityManagerInterface;
use Flagception\Bundle\FlagceptionBundle\Attribute\Feature;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Feature('access_codes')]
#[IsGranted(Role::ADMIN)]
final class CreateController extends AbstractController
{
@@ -10,7 +10,6 @@ use App\Htmx\HxRedirectResponse;
use App\Security\Role;
use App\Service\AuditLogger;
use Doctrine\ORM\EntityManagerInterface;
use Flagception\Bundle\FlagceptionBundle\Attribute\Feature;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -22,7 +21,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
* confirmation, which is there because a deleted code is not recoverable from the screen that
* deleted it.
*/
#[Feature('access_codes')]
#[IsGranted(Role::ADMIN)]
final class DeleteController extends AbstractController
{
@@ -10,14 +10,12 @@ use App\Htmx\HxRedirectResponse;
use App\Security\Role;
use App\Service\AuditLogger;
use Doctrine\ORM\EntityManagerInterface;
use Flagception\Bundle\FlagceptionBundle\Attribute\Feature;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Feature('access_codes')]
#[IsGranted(Role::ADMIN)]
final class EditController extends AbstractController
{
@@ -8,7 +8,6 @@ use App\Controller\Traits\CurrentUserTrait;
use App\Entity\AccessCode;
use App\Repository\AccessCodeRepository;
use App\Security\Role;
use Flagception\Bundle\FlagceptionBundle\Attribute\Feature;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -21,7 +20,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
* Which is which is a question about today rather than a stored flag, and it is answered here
* because it is a question about the whole list: a template cannot look back over its own rows.
*/
#[Feature('access_codes')]
#[IsGranted(Role::ADMIN)]
final class IndexController extends AbstractController
{
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccessCode;
use App\Controller\Traits\ConfirmedActionTrait;
use App\Htmx\HxRedirectResponse;
use App\Repository\UserRepository;
use App\Security\Role;
use App\Service\AccessCodeNotifier;
use App\Service\AuditLogger;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Tells the whole staff that the codes changed. Confirmed first, because a mail to everybody
* cannot be taken back -- and the confirmation says how many people that is.
*/
#[IsGranted(Role::ADMIN)]
final class NotifyController extends AbstractController
{
use ConfirmedActionTrait;
private const CSRF_TOKEN_ID = 'notify-access-codes';
public function __construct(
private readonly AccessCodeNotifier $notifier,
private readonly UserRepository $users,
private readonly AuditLogger $audit,
) {
}
#[Route('/admin/access-codes/notify', name: 'app_admin_access_code_notify', methods: ['GET', 'POST'])]
public function index(Request $request): Response
{
if (!$request->isMethod(Request::METHOD_POST)) {
return $this->render('admin/_modal_access_code_notify.html.twig', [
'recipients' => $this->users->countActive(),
'csrf_token_id' => self::CSRF_TOKEN_ID,
]);
}
$this->denyUnlessCsrfValid($request, self::CSRF_TOKEN_ID);
$report = $this->notifier->codesUpdated();
$this->audit->accessCodesAnnounced($report->sent, $report->failed);
if (0 === $report->failed) {
$this->addFlash('success', \sprintf('Benachrichtigung an %d Mitarbeitende versendet.', $report->sent));
} else {
$this->addFlash('warning', \sprintf(
'Benachrichtigung an %d Mitarbeitende versendet, %d konnten nicht erreicht werden.',
$report->sent,
$report->failed,
));
}
return new HxRedirectResponse($this->generateUrl('app_admin_access_code'));
}
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\BookingEvent;
use App\Form\Admin\BookingEventFilter;
use App\Form\Admin\BookingEventFilterType;
use App\Repository\BookingEventRepository;
use App\Security\Role;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The event history: who booked, moved or freed what, and for whom.
*
* Administrators only, under /admin, although supervisors can free bookings themselves: the
* history names who did what to whom across the whole staff, which is more than a supervisor
* needs to do their part.
*
* Read-only. The rows are written by BookingEventRecorder and removed after a year by
* app:booking-events:prune.
*/
#[IsGranted(Role::ADMIN)]
final class IndexController extends AbstractController
{
private const PER_PAGE = 50;
/**
* The columns a header may sort by. Spelled out because the field name comes from the query
* string and the paginator would otherwise accept any path into the entity.
*/
private const SORTABLE = [
'e.occurredAt',
'e.type',
'e.resourceLabel',
'e.date',
'e.holderName',
'e.actorName',
];
public function __construct(
private readonly BookingEventRepository $events,
private readonly PaginatorInterface $paginator,
) {
}
#[Route('/admin/history', name: 'app_admin_booking_event', methods: ['GET'])]
public function index(Request $request): Response
{
$filter = new BookingEventFilter();
$form = $this->createForm(BookingEventFilterType::class, $filter);
$form->handleRequest($request);
$pagination = $this->paginator->paginate(
$this->events->createFilteredQueryBuilder($filter),
$request->query->getInt('page', 1),
self::PER_PAGE,
[
'defaultSortFieldName' => 'e.occurredAt',
'defaultSortDirection' => 'desc',
'sortFieldAllowList' => self::SORTABLE,
],
);
return $this->render('admin/booking_events.html.twig', [
'form' => $form,
'filter' => $filter,
'pagination' => $pagination,
]);
}
}
@@ -13,6 +13,7 @@ use App\Repository\BookingRepository;
use App\Security\Role;
use Doctrine\DBAL\LockMode;
use App\Service\AuditLogger;
use App\Service\BookingEventRecorder;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -39,6 +40,7 @@ final class DeleteController extends AbstractController
private readonly EntityManagerInterface $em,
private readonly BookingRepository $bookings,
private readonly AuditLogger $audit,
private readonly BookingEventRecorder $events,
) {
}
@@ -66,7 +68,7 @@ final class DeleteController extends AbstractController
$label = $resource->getLabel();
$type = $resource->getType();
$deleted = $this->em->wrapInTransaction(function () use ($resource): int {
$deleted = $this->em->wrapInTransaction(function () use ($resource, $id, $label, $type): int {
$this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
$deleted = $this->em->createQueryBuilder()
@@ -84,6 +86,7 @@ final class DeleteController extends AbstractController
->execute();
$this->em->remove($resource);
$this->events->resourceDeleted($id, $label, $type, (int) $deleted);
$this->em->flush();
return (int) $deleted;
@@ -23,7 +23,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
* Retiring is a checkbox here rather than a confirmation modal of its own: it deletes nothing and
* is undone by ticking the box again. What it does leave behind -- bookings that still point at
* the resource -- is named in the flash, so the administrator can release them from
* /admin/bookings if they should not stand.
* /manage/bookings if they should not stand.
*/
#[IsGranted(Role::ADMIN)]
final class EditController extends AbstractController
+5 -5
View File
@@ -56,7 +56,7 @@ final class ReleaseController extends AbstractController
$onBehalf = !$booking->belongsTo($this->currentUser());
// The release itself is audited from BookingManager, which is also where the row still
// exists to be described. An administrator freeing somebody else's desk needs nothing
// exists to be described. A supervisor freeing somebody else's desk needs nothing
// extra here: the record carries the holder, and the actor comes from the token.
$this->manager->release($booking);
@@ -78,13 +78,13 @@ final class ReleaseController extends AbstractController
}
/**
* Releasing is the one thing an administrator may do to a booking that is not theirs -- a
* colleague calls in sick and their desk has to go back into the pool.
* Releasing is the one thing a supervisor may do to a booking that is not theirs -- a colleague
* calls in sick and their desk has to go back into the pool. Administrators inherit it.
*/
private function denyUnlessMayManage(Booking $booking): void
{
if (!$booking->belongsTo($this->currentUser()) && !$this->isGranted(Role::ADMIN)) {
throw $this->createAccessDeniedException('A booking may only be released by the person holding it or by an administrator.');
if (!$booking->belongsTo($this->currentUser()) && !$this->isGranted(Role::SUPERVISOR)) {
throw $this->createAccessDeniedException('A booking may only be released by the person holding it or by a supervisor.');
}
}
}
+2 -2
View File
@@ -14,8 +14,8 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* The landing screen: one tile per bookable kind, plus tiles for the bookings list and, for an
* administrator, the admin screens.
* The landing screen: one tile per bookable kind, plus tiles for the bookings list, for a
* supervisor the overview of everybody's bookings and, for an administrator, the admin screens.
*
* It replaces the sidebar navigation, so it is also the only route to the booking boards and the
* bookings list -- which is why the tiles are generated from ResourceType rather than written
@@ -2,10 +2,10 @@
declare(strict_types=1);
namespace App\Controller\Admin\Booking;
namespace App\Controller\Manage\Booking;
use App\Form\Admin\BookingFilter;
use App\Form\Admin\BookingFilterType;
use App\Form\Manage\BookingFilter;
use App\Form\Manage\BookingFilterType;
use App\Repository\BookingRepository;
use App\Repository\BookingSeriesRepository;
use App\Security\Role;
@@ -17,18 +17,22 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* What ROLE_ADMIN can do that ROLE_EMPLOYEE cannot: see everybody's bookings and free a resource
* on somebody's behalf -- a colleague calls in sick and their desk should go back into the pool.
* What a supervisor can do that ROLE_EMPLOYEE cannot: see everybody's bookings and free a
* resource on somebody's behalf -- a colleague calls in sick and their desk should go back into
* the pool. Administrators reach it through role_hierarchy.
*
* The admin controllers are read-only. The two actions offered here post to the ordinary
* app_booking_release / app_series_end routes, whose ownership checks were widened to admit an
* administrator, so there is exactly one code path that removes a booking and it still goes
* Not under /admin, which is administrator-only throughout: supervisors may free bookings but not
* configure resources or codes.
*
* The manage controllers are read-only. The two actions offered here post to the ordinary
* app_booking_release / app_series_end routes, whose ownership checks were widened to admit
* supervisors, so there is exactly one code path that removes a booking and it still goes
* through BookingManager.
*
* The access_control rule on ^/admin is the real gate; the attribute here is so the guard
* The access_control rule on ^/manage is the real gate; the attribute here is so the guard
* survives a change to the route path.
*/
#[IsGranted(Role::ADMIN)]
#[IsGranted(Role::SUPERVISOR)]
final class IndexController extends AbstractController
{
/**
@@ -44,7 +48,7 @@ final class IndexController extends AbstractController
) {
}
#[Route('/admin/bookings', name: 'app_admin_booking', methods: ['GET'])]
#[Route('/manage/bookings', name: 'app_manage_booking', methods: ['GET'])]
public function index(Request $request): Response
{
$filter = new BookingFilter();
@@ -62,7 +66,7 @@ final class IndexController extends AbstractController
self::PER_PAGE,
);
return $this->render('admin/bookings.html.twig', [
return $this->render('manage/bookings.html.twig', [
'form' => $form,
'filter' => $filter,
'pagination' => $pagination,
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Controller\Admin\Booking;
namespace App\Controller\Manage\Booking;
use App\Controller\Traits\BoardResponseTrait;
use App\Entity\Resource;
@@ -16,13 +16,13 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The modal behind an occupied shape on the floor plan, for an administrator.
* The modal behind an occupied shape on the floor plan, for supervisors and administrators.
*
* Not app_booking_edit: a shape can hold several bookings on one day once part-day spans exist,
* so "the booking" has no single referent -- and editing reschedules, which an administrator
* so "the booking" has no single referent -- and editing reschedules, which a supervisor
* deliberately may not do to somebody else's booking.
*/
#[IsGranted(Role::ADMIN)]
#[IsGranted(Role::SUPERVISOR)]
final class SpotController extends AbstractController
{
use BoardResponseTrait;
@@ -31,12 +31,12 @@ final class SpotController extends AbstractController
{
}
#[Route('/admin/spot/{id}/{date}', name: 'app_admin_spot', requirements: ['date' => DateParam::PATTERN], methods: ['GET'])]
#[Route('/manage/spot/{id}/{date}', name: 'app_manage_spot', requirements: ['date' => DateParam::PATTERN], methods: ['GET'])]
public function index(Request $request, Resource $resource, string $date): Response
{
$selected = DateParam::parseOrToday($date);
return $this->render('admin/_modal_spot.html.twig', [
return $this->render('manage/_modal_spot.html.twig', [
'resource' => $resource,
'date' => $selected,
// The board's span, so the modal lists exactly what the shape the user clicked shows.
+2 -2
View File
@@ -59,8 +59,8 @@ final class EndController extends AbstractController
private function denyUnlessMayManageSeries(BookingSeries $series): void
{
if ($series->getUser()->getId() !== $this->currentUser()->getId() && !$this->isGranted(Role::ADMIN)) {
throw $this->createAccessDeniedException('A series may only be ended by the person who created it or by an administrator.');
if ($series->getUser()->getId() !== $this->currentUser()->getId() && !$this->isGranted(Role::SUPERVISOR)) {
throw $this->createAccessDeniedException('A series may only be ended by the person who created it or by a supervisor.');
}
}
}
@@ -13,14 +13,14 @@ trait ConfirmedActionTrait
{
/**
* Where to navigate after a confirmed action, or null when the caller is the board and wants
* its fragments swapped instead. The opener says which -- the admin screen sends
* ?return=admin so freeing somebody else's desk does not drop the administrator on their own
* bookings.
* its fragments swapped instead. The opener says which -- the overview of everybody's
* bookings sends ?return=manage so freeing somebody else's desk does not drop a supervisor on
* their own bookings.
*/
private function returnRoute(Request $request): ?string
{
return match ($request->query->get('return')) {
'admin' => 'app_admin_booking',
'manage' => 'app_manage_booking',
'bookings' => 'app_booking',
default => null,
};
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Email;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
/**
* Every mail this application sends goes through here.
*
* The counterpart of myep's App\Email\Mailer, with three deliberate differences. Arguments are
* typed rather than an OptionsResolver array, since there are only four of them and all are
* required. Every mail is multipart, with a hand-written text part beside the MyE&P layout. And a
* failure is logged and counted rather than rethrown -- see below.
*
* **Sending never throws.** Mail here is always the side effect of something that has already
* happened -- a booking committed, a code rotated -- and the person who caused it should not be
* shown an error about a mail server. The caller gets a SendReport instead and decides whether
* the failures are worth mentioning.
*
* Every recipient gets their own mail, addressed only to them. Nobody sees who else is on the
* list, and a single bad address loses its own mail rather than everybody's.
*
* Sending is synchronous, inside the request: no messenger worker runs on the server (see
* messenger.yaml). At the fifty-odd accounts this application has, that is a few seconds at
* most. Routing SendEmailMessage to `async` once a worker runs needs no change here.
*/
final readonly class Mailer
{
public function __construct(
private MailerInterface $mailer,
private LoggerInterface $logger,
private string $sender,
) {
}
/**
* @param iterable<Address|string> $recipients
* @param string $template the template pair's basename; both .html.twig
* and .txt.twig of it are rendered
* @param array<string, mixed> $context
*/
public function send(iterable $recipients, string $subject, string $template, array $context = []): SendReport
{
$sent = 0;
$failed = 0;
foreach ($recipients as $recipient) {
$email = (new TemplatedEmail())
->from(new Address($this->sender, 'E&P Platz'))
->to($recipient)
->subject($subject)
->htmlTemplate($template.'.html.twig')
->textTemplate($template.'.txt.twig')
->context($context);
try {
$this->mailer->send($email);
++$sent;
} catch (\Throwable $e) {
// Caught per recipient, so one unreachable address does not cost the others theirs.
// \Throwable rather than TransportExceptionInterface because the templates are
// rendered inside send(): a typo in one of them would otherwise surface as an
// error in whatever action triggered the mail.
++$failed;
$this->logger->error('Email could not be sent.', [
'subject' => $subject,
'recipient' => $recipient instanceof Address ? $recipient->getAddress() : $recipient,
'exception' => $e,
]);
}
}
return new SendReport($sent, $failed);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Email;
/**
* What came of a Mailer::send(): how many recipients the mail reached the transport for, and how
* many it did not. The failures themselves are in the log.
*/
final readonly class SendReport
{
public function __construct(
public int $sent,
public int $failed,
) {
}
}
+295
View File
@@ -0,0 +1,295 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Enum\BookingEventType;
use App\Enum\RecurrenceRule;
use App\Enum\ResourceType;
use App\Model\TimeRange;
use App\Repository\BookingEventRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* One line of the event history: something that happened to a booking, a series, or the bookings
* on a deleted resource.
*
* **A snapshot, not a reference.** Releasing a booking deletes its row, ending a series can delete
* the series, and deleting a resource deletes both -- so a foreign key to any of them would point
* at nothing on exactly the records worth reading. What the history shows is therefore copied in
* at the time: labels, names, dates and times. The ids are kept as plain integers, so a record can
* still be matched to the audit log file, which carries the same ones.
*
* The two people are the exception: they are kept as nullable foreign keys *and* as names. The key
* is what the filter matches on, so a renamed account still finds its own history; the name is
* what is shown, and survives the account itself going away (ON DELETE SET NULL).
*
* Written by BookingEventRecorder alone, inside the same transaction as the change it describes.
*/
#[ORM\Entity(repositoryClass: BookingEventRepository::class)]
#[ORM\Index(name: 'idx_booking_event_occurred_at', columns: ['occurred_at'])]
#[ORM\Index(name: 'idx_booking_event_actor_occurred_at', columns: ['actor_id', 'occurred_at'])]
class BookingEvent
{
/** How long the history reaches back -- see app:booking-events:prune. */
public const RETENTION_MONTHS = 12;
/** The actor name when nobody was signed in -- a console command, the worker. */
public const SYSTEM_ACTOR = 'System';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $occurredAt;
#[ORM\Column(length: 32, enumType: BookingEventType::class)]
private BookingEventType $type;
/** Who did it; null for a console command or the message worker, which have no user. */
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?User $actor;
#[ORM\Column(length: 100)]
private string $actorName;
/** Whose booking it was; null only for a resource deletion, which affects many people. */
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?User $holder = null;
#[ORM\Column(length: 100, nullable: true)]
private ?string $holderName = null;
#[ORM\Column(nullable: true)]
private ?int $resourceId;
#[ORM\Column(length: 50)]
private string $resourceLabel;
#[ORM\Column(length: 32, enumType: ResourceType::class)]
private ResourceType $resourceType;
/**
* The booked day; for a series its first day, and for an ended series the day it ends from.
*/
#[ORM\Column(type: 'date_immutable', nullable: true)]
private ?\DateTimeImmutable $date = null;
/** A series' last day. */
#[ORM\Column(type: 'date_immutable', nullable: true)]
private ?\DateTimeImmutable $endDate = null;
#[ORM\Column(nullable: true)]
private ?int $startMinute = null;
#[ORM\Column(nullable: true)]
private ?int $endMinute = null;
/** The times a rescheduled booking had before. */
#[ORM\Column(nullable: true)]
private ?int $previousStartMinute = null;
#[ORM\Column(nullable: true)]
private ?int $previousEndMinute = null;
#[ORM\Column(nullable: true)]
private ?int $bookingId = null;
#[ORM\Column(nullable: true)]
private ?int $seriesId = null;
/**
* What only one kind of event has: a series' rule and its created/skipped counts, the number
* of bookings a series end or a resource deletion removed.
*
* @var array<string, mixed>
*/
#[ORM\Column(type: Types::JSON)]
private array $details = [];
public function __construct(
BookingEventType $type,
?User $actor,
string $actorName,
?int $resourceId,
string $resourceLabel,
ResourceType $resourceType,
) {
$this->occurredAt = new \DateTimeImmutable();
$this->type = $type;
$this->actor = $actor;
$this->actorName = $actorName;
$this->resourceId = $resourceId;
$this->resourceLabel = $resourceLabel;
$this->resourceType = $resourceType;
}
public function getId(): ?int
{
return $this->id;
}
public function getOccurredAt(): \DateTimeImmutable
{
return $this->occurredAt;
}
public function getType(): BookingEventType
{
return $this->type;
}
public function getActor(): ?User
{
return $this->actor;
}
public function getActorName(): string
{
return $this->actorName;
}
public function getHolder(): ?User
{
return $this->holder;
}
public function getHolderName(): ?string
{
return $this->holderName;
}
public function setHolder(User $holder): self
{
$this->holder = $holder;
$this->holderName = $holder->getName();
return $this;
}
/**
* Somebody acting on another person's booking -- a supervisor freeing a desk, say. Compared by
* id while both accounts exist and by name once one has gone, which is the best left to go on.
* The system acts for nobody: it has no booking of its own to tell apart from someone else's.
*/
public function isOnBehalf(): bool
{
if (null === $this->holderName) {
return false;
}
if (null === $this->actor && self::SYSTEM_ACTOR === $this->actorName) {
return false;
}
if (null !== $this->actor && null !== $this->holder) {
return $this->actor->getId() !== $this->holder->getId();
}
return $this->actorName !== $this->holderName;
}
public function getResourceId(): ?int
{
return $this->resourceId;
}
public function getResourceLabel(): string
{
return $this->resourceLabel;
}
public function getResourceType(): ResourceType
{
return $this->resourceType;
}
public function getDate(): ?\DateTimeImmutable
{
return $this->date;
}
public function getEndDate(): ?\DateTimeImmutable
{
return $this->endDate;
}
public function setDates(\DateTimeImmutable $date, ?\DateTimeImmutable $endDate = null): self
{
$this->date = $date;
$this->endDate = $endDate;
return $this;
}
public function getTimeRange(): ?TimeRange
{
return null === $this->startMinute || null === $this->endMinute
? null
: TimeRange::fromMinutes($this->startMinute, $this->endMinute);
}
public function getPreviousTimeRange(): ?TimeRange
{
return null === $this->previousStartMinute || null === $this->previousEndMinute
? null
: TimeRange::fromMinutes($this->previousStartMinute, $this->previousEndMinute);
}
public function setTimeRange(TimeRange $time, ?TimeRange $previous = null): self
{
$this->startMinute = $time->startMinute;
$this->endMinute = $time->endMinute;
$this->previousStartMinute = $previous?->startMinute;
$this->previousEndMinute = $previous?->endMinute;
return $this;
}
public function getBookingId(): ?int
{
return $this->bookingId;
}
public function getSeriesId(): ?int
{
return $this->seriesId;
}
public function setIds(?int $bookingId, ?int $seriesId): self
{
$this->bookingId = $bookingId;
$this->seriesId = $seriesId;
return $this;
}
/**
* @return array<string, mixed>
*/
public function getDetails(): array
{
return $this->details;
}
/**
* @param array<string, mixed> $details
*/
public function setDetails(array $details): self
{
$this->details = $details;
return $this;
}
public function getRule(): ?RecurrenceRule
{
return isset($this->details['rule']) ? RecurrenceRule::tryFrom($this->details['rule']) : null;
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Enum;
/**
* What can happen to a booking, as the event history at /admin/history lists it.
*
* Narrower than the audit trail on purpose: sign-ins, resources and access codes stay in the log
* file, and this is the part of it an administrator wants to search by hand -- who booked, moved
* or freed what, and for whom.
*
* The backing value is what is stored and what the filter puts in the URL. The label and the
* badge are the only things anyone sees.
*/
enum BookingEventType: string
{
case BOOKING_CREATED = 'booking_created';
case BOOKING_RESCHEDULED = 'booking_rescheduled';
case BOOKING_RELEASED = 'booking_released';
case SERIES_CREATED = 'series_created';
case SERIES_ENDED = 'series_ended';
/**
* Deleting a resource takes every booking on it along, without going through BookingManager.
* One event for the lot rather than one per booking, like a series: it is a single act.
*/
case RESOURCE_DELETED = 'resource_deleted';
public function label(): string
{
return match ($this) {
self::BOOKING_CREATED => 'Buchung angelegt',
self::BOOKING_RESCHEDULED => 'Buchung geändert',
self::BOOKING_RELEASED => 'Buchung freigegeben',
self::SERIES_CREATED => 'Serie angelegt',
self::SERIES_ENDED => 'Serie beendet',
self::RESOURCE_DELETED => 'Ressource gelöscht',
};
}
/**
* Badge colours, spelled out in full so Tailwind finds them in src/: green for something
* coming into existence, amber for a change, red for something going away.
*/
public function badgeClass(): string
{
return match ($this) {
self::BOOKING_CREATED, self::SERIES_CREATED => 'bg-green-100 text-green-800',
self::BOOKING_RESCHEDULED => 'bg-amber-100 text-amber-800',
self::BOOKING_RELEASED, self::SERIES_ENDED, self::RESOURCE_DELETED => 'bg-red-100 text-red-800',
};
}
}
+12 -4
View File
@@ -9,10 +9,10 @@ use App\Security\Role;
/**
* Who may read an access code.
*
* Deliberately an enum with two cases rather than a boolean column: "admin only" is one policy
* among several plausible ones -- a code for the reception desk or for the cleaning company is
* the obvious next case -- and a third case here costs a case in the match below, where a third
* value of a boolean costs a migration.
* Deliberately an enum rather than a boolean column: "admin only" was one policy among several
* plausible ones -- supervisors were the next, a code for the reception desk or for the cleaning
* company the obvious ones after that -- and another case here costs a case in the match below,
* where another value of a boolean costs a migration.
*
* This is also what the list shows under "Für wen": the access decision and the caption are one
* value, so there is no second, prose field describing an audience that the filter does not
@@ -23,6 +23,9 @@ enum CodeVisibility: string
/** Every signed-in user, which in this application means everyone who works here. */
case EVERYONE = 'everyone';
/** Supervisors, and administrators with them -- e.g. the office of the management. */
case SUPERVISOR = 'supervisor';
/** Administrators only -- a code the whole staff has no business reading. */
case ADMIN = 'admin';
@@ -30,6 +33,7 @@ enum CodeVisibility: string
{
return match ($this) {
self::EVERYONE => 'Alle Mitarbeitenden',
self::SUPERVISOR => 'Leitung',
self::ADMIN => 'Administration',
};
}
@@ -44,12 +48,16 @@ enum CodeVisibility: string
* The roles this application knows are the ones in Role::ELIGIBLE, so "everyone" needs no
* role of its own: reaching the page at all already required one.
*
* These are the stored roles, which role_hierarchy does not expand -- so an administrator's
* claim to the supervisor codes is spelt out here rather than inherited.
*
* @param list<string> $roles
*/
public function isReadableBy(array $roles): bool
{
return match ($this) {
self::EVERYONE => true,
self::SUPERVISOR => \in_array(Role::SUPERVISOR, $roles, true) || \in_array(Role::ADMIN, $roles, true),
self::ADMIN => \in_array(Role::ADMIN, $roles, true),
};
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin;
use App\Entity\User;
use App\Enum\BookingEventType;
use App\Enum\ResourceType;
/**
* What the event history is narrowed by. A plain DTO bound to a GET form, like the manage
* overview's BookingFilter, so a filtered history can be linked to.
*
* The window is about when something *happened*, not about the day that was booked: the
* question this screen answers is "what went on recently", and a booking for next month made
* this morning belongs to this morning.
*/
final class BookingEventFilter
{
public ?\DateTimeImmutable $from = null;
public ?\DateTimeImmutable $to = null;
/** @var list<BookingEventType> empty means every kind */
public array $types = [];
/** Who carried the change out -- not whose booking it was. */
public ?User $actor = null;
public ?ResourceType $resourceType = null;
public function __construct()
{
$this->from = new \DateTimeImmutable('today -30 days');
$this->to = new \DateTimeImmutable('today');
}
public function fromDate(): \DateTimeImmutable
{
return ($this->from ?? new \DateTimeImmutable('today -30 days'))->setTime(0, 0);
}
/**
* The last day shown, inclusive -- the query compares against the midnight after it.
*/
public function toDate(): \DateTimeImmutable
{
return ($this->to ?? new \DateTimeImmutable('today'))->setTime(0, 0);
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin;
use App\Entity\User;
use App\Enum\BookingEventType;
use App\Enum\ResourceType;
use Doctrine\ORM\EntityRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* A GET form, like the manage overview's BookingFilterType: navigation, not a fragment swap, and
* no CSRF token since nothing is written.
*/
final class BookingEventFilterType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('from', DateType::class, [
'label' => 'Von',
'widget' => 'single_text',
'input' => 'datetime_immutable',
'required' => false,
])
->add('to', DateType::class, [
'label' => 'Bis',
'widget' => 'single_text',
'input' => 'datetime_immutable',
'required' => false,
])
->add('actor', EntityType::class, [
'label' => 'Durchgeführt von',
'class' => User::class,
'required' => false,
'placeholder' => 'Alle Personen',
'choice_label' => 'name',
'query_builder' => static fn (EntityRepository $repository) => $repository
->createQueryBuilder('u')
->orderBy('u.name', 'ASC'),
])
->add('resourceType', EnumType::class, [
'label' => 'Art',
'class' => ResourceType::class,
'required' => false,
'placeholder' => 'Alle Arten',
'choice_label' => static fn (ResourceType $type): string => $type->plural(),
])
// Checkboxes rather than a multi-select: there are few kinds, and a multi-select needs
// a modifier key nobody remembers.
->add('types', EnumType::class, [
'label' => 'Ereignis',
'class' => BookingEventType::class,
'multiple' => true,
'expanded' => true,
'required' => false,
'choice_label' => static fn (BookingEventType $type): string => $type->label(),
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => BookingEventFilter::class,
'method' => 'GET',
'csrf_protection' => false,
// page/sort/direction share the query string; see BookingFilterType.
'allow_extra_fields' => true,
]);
}
/**
* No prefix, so the query string reads ?from=&types[]=&actor= rather than nested.
*/
public function getBlockPrefix(): string
{
return '';
}
}
@@ -2,14 +2,14 @@
declare(strict_types=1);
namespace App\Form\Admin;
namespace App\Form\Manage;
use App\Config\BookingHorizon;
use App\Entity\User;
use App\Enum\ResourceType;
/**
* What the admin booking list is narrowed by. A plain DTO bound to a GET form, so the filters
* What the overview of all bookings is narrowed by. A plain DTO bound to a GET form, so the filters
* live in the URL and a filtered view can be linked to or bookmarked.
*/
final class BookingFilter
@@ -24,7 +24,7 @@ final class BookingFilter
public function __construct()
{
// Today through the booking horizon: what an administrator can still do something about.
// Today through the booking horizon: what a supervisor can still do something about.
// Past bookings are history and are not actionable, so they are not the default view.
$this->from = new \DateTimeImmutable('today');
$this->to = BookingHorizon::latest();
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Form\Admin;
namespace App\Form\Manage;
use App\Entity\User;
use App\Enum\ResourceType;
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\BookingEvent;
use App\Form\Admin\BookingEventFilter;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<BookingEvent>
*/
class BookingEventRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, BookingEvent::class);
}
/**
* Newest first. The id breaks ties, since a series and the booking released right after it can
* share a second; the paginator puts a clicked column in front of both.
*/
public function createFilteredQueryBuilder(BookingEventFilter $filter): QueryBuilder
{
$qb = $this->createQueryBuilder('e')
->andWhere('e.occurredAt >= :from')
->andWhere('e.occurredAt < :until')
->setParameter('from', $filter->fromDate())
->setParameter('until', $filter->toDate()->modify('+1 day'))
->orderBy('e.occurredAt', 'DESC')
->addOrderBy('e.id', 'DESC');
if ([] !== $filter->types) {
$qb->andWhere('e.type IN (:types)')->setParameter('types', $filter->types);
}
if (null !== $filter->actor) {
$qb->andWhere('e.actor = :actor')->setParameter('actor', $filter->actor);
}
if (null !== $filter->resourceType) {
$qb->andWhere('e.resourceType = :resourceType')->setParameter('resourceType', $filter->resourceType);
}
return $qb;
}
/**
* @return int the number of events removed
*/
public function deleteOlderThan(\DateTimeImmutable $cutoff): int
{
return (int) $this->createQueryBuilder('e')
->delete()
->andWhere('e.occurredAt < :cutoff')
->setParameter('cutoff', $cutoff)
->getQuery()
->execute();
}
}
+3 -3
View File
@@ -9,7 +9,7 @@ use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Entity\User;
use App\Enum\ResourceType;
use App\Form\Admin\BookingFilter;
use App\Form\Manage\BookingFilter;
use App\Model\TimeRange;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
@@ -210,10 +210,10 @@ class BookingRepository extends ServiceEntityRepository
}
/**
* Every booking in a window, across everybody -- what the admin list renders.
* Every booking in a window, across everybody -- what /manage/bookings renders.
*
* findForUserInRange() is the same query with a mandatory user; this one keeps it optional
* and eager-selects the holder as well, because the admin list names a different person on
* and eager-selects the holder as well, because the overview names a different person on
* every row and would otherwise fire a query per booking.
*
* Unexecuted, because the paginator has to add the LIMIT and the ORDER BY of a clicked column
+2 -2
View File
@@ -30,9 +30,9 @@ class BookingSeriesRepository extends ServiceEntityRepository
/**
* Every series still running on the given day, optionally narrowed to one person or one kind
* of resource. The admin list is the caller that needs it across everybody.
* of resource. The overview at /manage/bookings is the caller that needs it across everybody.
*
* The user is eager-selected because the admin list names the holder of every row -- without
* The user is eager-selected because the overview names the holder of every row -- without
* it the page fires a query per series.
*
* @return list<BookingSeries>
+16
View File
@@ -30,4 +30,20 @@ class UserRepository extends ServiceEntityRepository
{
return $this->findOneBy(['sub' => $sub]);
}
/**
* Everybody who can currently sign in -- the audience of a mail meant for the whole staff.
* A locked account is left out: its holder could not follow the link in it anyway.
*
* @return list<User>
*/
public function findActive(): array
{
return $this->findBy(['disabledAt' => null], ['name' => 'ASC']);
}
public function countActive(): int
{
return $this->count(['disabledAt' => null]);
}
}
+12 -3
View File
@@ -20,13 +20,21 @@ final class Role
/** Derived on myep from the account's email domain: "works here". */
public const EMPLOYEE = 'ROLE_EMPLOYEE';
/**
* Frees other people's bookings and reads the codes meant for supervisors, but does not
* configure anything -- the admin area stays ROLE_ADMIN's. Nominated by the CRM and approved
* on myep like ROLE_ADMIN. ROLE_ADMIN inherits it through role_hierarchy, so a check for this
* role is a check for "supervisor or above".
*/
public const SUPERVISOR = 'ROLE_SUPERVISOR';
/** Administrative on myep -- nominated by the CRM, but only exported once approved there. */
public const ADMIN = 'ROLE_ADMIN';
/**
* The roles that entitle someone to use ephub at all. Someone who holds neither is refused
* at login rather than let in without access, so they get an explanation instead of an
* empty screen they cannot act on.
* The roles that entitle someone to use ephub at all. Someone who holds none of them is
* refused at login rather than let in without access, so they get an explanation instead of
* an empty screen they cannot act on.
*
* **Keep this in step with `required_roles` on ephub's `oauth2_client` row on myep.** myep
* gates its own authorization endpoint on the same list and shows an explanation page
@@ -39,6 +47,7 @@ final class Role
*/
public const ELIGIBLE = [
self::EMPLOYEE,
self::SUPERVISOR,
self::ADMIN,
];
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Email\Mailer;
use App\Email\SendReport;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Component\Mime\Address;
/**
* Tells the whole staff that the access codes have changed -- and nothing more.
*
* **The mail carries no codes, and no door names either.** A mailbox is not a place a door code
* should end up: it is synced to phones, forwarded, and kept long after the code is rotated away.
* The door names are left out too, because some of them belong to codes only supervisors or
* administrators may read, and naming them to everybody would leak what this application
* otherwise keeps from them. So every recipient gets the same mail, and the link in it leads to
* /access-codes, which shows each of them exactly what they may see.
*
* Sent on an administrator's explicit request rather than on every save: a rotation is usually
* several edits in a row, and a corrected description or an admin-only code is no news to the
* staff.
*/
final readonly class AccessCodeNotifier
{
public function __construct(
private Mailer $mailer,
private UserRepository $users,
) {
}
public function codesUpdated(): SendReport
{
return $this->mailer->send(
array_map(
static fn (User $user): Address => new Address($user->getEmail(), $user->getName()),
$this->users->findActive(),
),
'Neue Zugangscodes bei E&P',
'email/access_codes_updated',
);
}
}
+13 -1
View File
@@ -32,7 +32,7 @@ use Psr\Log\LoggerInterface;
* The acting user is NOT passed in. AuditActorProcessor takes it from the security token and
* puts it in `extra.actor`, so no call site can log the wrong one. What the context carries is
* the subject -- `holder` for a booking -- and a record whose holder is not its actor is an
* administrator having acted for somebody else.
* supervisor or administrator having acted for somebody else.
*
* Level is info throughout: an audit record is a fact, not a problem. Something that *is* a
* problem belongs in the framework log, which is what the default channel is for -- a refused
@@ -295,6 +295,18 @@ final readonly class AuditLogger
]);
}
/**
* The staff told that the codes changed. Counts only: the recipients are everybody, and
* which addresses failed is in the framework log, where a failure belongs.
*/
public function accessCodesAnnounced(int $sent, int $failed): void
{
$this->logger->info('Access code update announced.', [
'sent' => $sent,
'failed' => $failed,
]);
}
/**
* @return array<string, mixed>
*/
+151
View File
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Booking;
use App\Entity\BookingEvent;
use App\Entity\BookingSeries;
use App\Entity\User;
use App\Enum\BookingEventType;
use App\Enum\ResourceType;
use App\Model\SeriesResult;
use App\Model\TimeRange;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
* Writes the event history that /admin/history lists.
*
* The sibling of AuditLogger, and deliberately not merged into it: the log file is the complete
* trail and is written after a commit, while these rows are a queryable subset that has to agree
* with the bookings table. So every method here only persists -- the caller flushes, inside the
* same transaction as the change itself. A booking that was rolled back leaves no event, and an
* event that failed to write takes the booking with it.
*
* The actor is read from the token, as AuditActorProcessor does, so no call site can name the
* wrong one.
*/
final readonly class BookingEventRecorder
{
public function __construct(
private EntityManagerInterface $em,
private Security $security,
) {
}
/**
* Needs the booking's id, so call it after the booking has been flushed.
*/
public function bookingCreated(Booking $booking): void
{
$this->persist($this->forBooking(BookingEventType::BOOKING_CREATED, $booking));
}
public function bookingRescheduled(Booking $booking, TimeRange $previous): void
{
$event = $this->forBooking(BookingEventType::BOOKING_RESCHEDULED, $booking)
->setTimeRange($booking->getTimeRange(), $previous);
$this->persist($event);
}
/**
* Before the row is removed, while the booking still knows its own id.
*/
public function bookingReleased(Booking $booking): void
{
$this->persist($this->forBooking(BookingEventType::BOOKING_RELEASED, $booking));
}
/**
* One event for the whole series, as in the audit log. A series that booked nothing was never
* stored and has no id, but the attempt is still recorded with its count of zero.
*/
public function seriesCreated(SeriesResult $result): void
{
$series = $result->series;
$event = $this->forSeries(BookingEventType::SERIES_CREATED, $series, $series->getId())
->setDates($series->getStartDate(), $series->getEndDate())
->setDetails([
'rule' => $series->getRule()->value,
'created' => $result->createdCount(),
'skipped' => array_keys($result->skipped),
]);
$this->persist($event);
}
/**
* @param ?int $seriesId read by the caller before the transaction, since ending a series can
* delete it and a deleted entity no longer has an id
*/
public function seriesEnded(BookingSeries $series, ?int $seriesId, \DateTimeImmutable $from, int $removed): void
{
$event = $this->forSeries(BookingEventType::SERIES_ENDED, $series, $seriesId)
->setDates($from)
->setDetails([
'rule' => $series->getRule()->value,
'removed' => $removed,
]);
$this->persist($event);
}
/**
* Only worth a line when bookings went with it -- an unused resource deleted is configuration,
* not booking history, and stays in the audit log alone.
*/
public function resourceDeleted(int $id, string $label, ResourceType $type, int $bookingsDeleted): void
{
if ($bookingsDeleted < 1) {
return;
}
$this->persist($this->event(BookingEventType::RESOURCE_DELETED, $id, $label, $type)
->setDetails(['removed' => $bookingsDeleted]));
}
private function forBooking(BookingEventType $type, Booking $booking): BookingEvent
{
$resource = $booking->getResource();
return $this->event($type, $resource->getId(), $resource->getLabel(), $resource->getType())
->setHolder($booking->getUser())
->setDates($booking->getDate())
->setTimeRange($booking->getTimeRange())
->setIds($booking->getId(), $booking->getSeries()?->getId());
}
private function forSeries(BookingEventType $type, BookingSeries $series, ?int $seriesId): BookingEvent
{
$resource = $series->getResource();
return $this->event($type, $resource->getId(), $resource->getLabel(), $resource->getType())
->setHolder($series->getUser())
->setTimeRange($series->getTimeRange())
->setIds(null, $seriesId);
}
private function event(BookingEventType $type, ?int $resourceId, string $resourceLabel, ResourceType $resourceType): BookingEvent
{
$user = $this->security->getUser();
$actor = $user instanceof User ? $user : null;
return new BookingEvent(
$type,
$actor,
$actor?->getName() ?? $user?->getUserIdentifier() ?? BookingEvent::SYSTEM_ACTOR,
$resourceId,
$resourceLabel,
$resourceType,
);
}
private function persist(BookingEvent $event): void
{
$this->em->persist($event);
}
}
+19 -5
View File
@@ -44,6 +44,7 @@ final readonly class BookingManager
private EntityManagerInterface $em,
private BookingRepository $bookings,
private AuditLogger $audit,
private BookingEventRecorder $events,
private BookingNotifier $notifier,
) {
}
@@ -65,6 +66,10 @@ final readonly class BookingManager
$this->em->persist($booking);
$this->em->flush();
// A second flush, because the event wants the id the first one assigned.
$this->events->bookingCreated($booking);
$this->em->flush();
return $booking;
});
@@ -118,7 +123,11 @@ final readonly class BookingManager
$this->em->flush();
return new SeriesResult($series, $created, $skipped);
$result = new SeriesResult($series, $created, $skipped);
$this->events->seriesCreated($result);
$this->em->flush();
return $result;
});
$this->audit->seriesCreated($result);
@@ -134,13 +143,14 @@ final readonly class BookingManager
{
$previous = $booking->getTimeRange();
$this->em->wrapInTransaction(function () use ($booking, $time): void {
$this->em->wrapInTransaction(function () use ($booking, $time, $previous): void {
$resource = $booking->getResource();
$this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
$this->assertFree($resource, $booking->getDate(), $time, $booking);
$booking->changeTime($time);
$this->events->bookingRescheduled($booking, $previous);
$this->em->flush();
});
@@ -152,8 +162,11 @@ final readonly class BookingManager
// Before the flush, while the booking still knows its own id.
$this->audit->bookingReleased($booking);
$this->em->remove($booking);
$this->em->flush();
$this->em->wrapInTransaction(function () use ($booking): void {
$this->events->bookingReleased($booking);
$this->em->remove($booking);
$this->em->flush();
});
}
/**
@@ -168,7 +181,7 @@ final readonly class BookingManager
// longer knows its own id.
$seriesId = $series->getId();
$removed = $this->em->wrapInTransaction(function () use ($series, $from): int {
$removed = $this->em->wrapInTransaction(function () use ($series, $seriesId, $from): int {
$from = $from->setTime(0, 0);
$removed = 0;
@@ -183,6 +196,7 @@ final readonly class BookingManager
$series->setEndDate($from->modify('-1 day'));
}
$this->events->seriesEnded($series, $seriesId, $from, $removed);
$this->em->flush();
return $removed;
+5 -51
View File
@@ -4,14 +4,11 @@ declare(strict_types=1);
namespace App\Service;
use App\Email\Mailer;
use App\Entity\Booking;
use App\Enum\ResourceType;
use App\Model\SeriesResult;
use App\Repository\ResourceTypeSettingsRepository;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
/**
* Tells whoever an administrator named that something was booked.
@@ -22,25 +19,14 @@ use Symfony\Component\Mime\Address;
* would otherwise never notify anybody.
*
* **Nothing here may break a booking.** It runs after the transaction has committed, so a
* failure cannot roll anything back, and a transport that is down is logged rather than thrown:
* the booking is made, and the person who made it should not be shown an error about somebody
* else's mail server.
*
* Every recipient gets their own mail, addressed only to them. Nobody sees who else is on the
* list, and a single bad address loses its own mail rather than everybody's.
*
* Each mail is multipart: the MyE&P layout for clients that render HTML, and a hand-written text
* part for those that do not. The text part is written rather than generated because there is no
* html-to-markdown converter installed, and three facts and a name read better as plain lines
* than as a flattened table anyway.
* failure cannot roll anything back -- and Mailer never throws, so the person who booked is not
* shown an error about somebody else's mail server.
*/
final readonly class BookingNotifier
{
public function __construct(
private MailerInterface $mailer,
private Mailer $mailer,
private ResourceTypeSettingsRepository $settings,
private LoggerInterface $logger,
private string $sender,
) {
}
@@ -80,42 +66,10 @@ final readonly class BookingNotifier
}
/**
* @param string $template the template pair's basename; both .html.twig and
* .txt.twig of it are rendered
* @param array<string, mixed> $context
*/
private function send(ResourceType $type, string $subject, string $template, array $context): void
{
$recipients = $this->settings->recipientsFor($type);
if ([] === $recipients) {
return;
}
foreach ($recipients as $recipient) {
// A fresh message per recipient: ->to() on a reused one appends rather than replaces,
// which would put the whole list back on everybody's copy.
$email = (new TemplatedEmail())
->from(new Address($this->sender, 'E&P Platz'))
->to($recipient)
->subject($subject)
->htmlTemplate($template.'.html.twig')
->textTemplate($template.'.txt.twig')
->context($context);
try {
$this->mailer->send($email);
} catch (\Throwable $e) {
// Caught per recipient, so one unreachable address does not cost the others theirs.
// \Throwable rather than TransportExceptionInterface because the templates are
// rendered inside send(): a typo in one of them would otherwise surface as a failed
// booking, which is exactly what this class promises never to do.
$this->logger->error('Booking notification could not be sent.', [
'subject' => $subject,
'recipient' => $recipient,
'exception' => $e,
]);
}
}
$this->mailer->send($this->settings->recipientsFor($type), $subject, $template, $context);
}
}
@@ -0,0 +1,15 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block title %}Mitarbeitende benachrichtigen?{% endblock %}
{% block content %}
<p>
{{ recipients }} Mitarbeitende erhalten eine E-Mail, dass sich die Zugangscodes geändert
haben, mit einem Link zu dieser Anwendung.
</p>
<p class="pt-2 text-sm text-gray-500">
Die Codes selbst stehen aus Sicherheitsgründen nicht in der E-Mail.
</p>
{% endblock %}
{% block button_confirm %}Senden{% endblock %}
+10 -4
View File
@@ -20,10 +20,16 @@
<div>
<h1>Zugangscodes verwalten</h1>
</div>
<button type="button" class="button button--small button--primary"
hx-get="{{ path('app_admin_access_code_new') }}"
hx-target="body"
hx-swap="beforeend">Code hinzufügen</button>
<div class="flex flex-wrap gap-2">
<button type="button" class="button button--small button--neutral"
hx-get="{{ path('app_admin_access_code_notify') }}"
hx-target="body"
hx-swap="beforeend">Mitarbeitende benachrichtigen</button>
<button type="button" class="button button--small button--primary"
hx-get="{{ path('app_admin_access_code_new') }}"
hx-target="body"
hx-swap="beforeend">Code hinzufügen</button>
</div>
</div>
{% if rows is empty %}
+119
View File
@@ -0,0 +1,119 @@
{#
The event history. One row per thing that happened -- a series is one row, not one per day --
so a year of standing bookings does not drown out this morning.
"Für" and "Durchgeführt von" are both shown on every row; when they differ, somebody acted on
another person's booking, and the row says so with a badge rather than leaving it to be
noticed.
#}
{% extends 'layout.html.twig' %}
{% block title %}Ereignishistorie · E&P Platz{% endblock %}
{% block content %}
<a class="text-sm text-gray-500 hover:text-gray-900" href="{{ path('app_dashboard') }}">← Übersicht</a>
<h1 class="pt-2">Ereignishistorie</h1>
<p class="subline">Angelegte, geänderte und freigegebene Buchungen der letzten zwölf Monate.</p>
{{ form_start(form, { attr: { class: 'mt-6 rounded-md border border-gray-200 p-4' } }) }}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{{ form_row(form.from) }}
{{ form_row(form.to) }}
{{ form_row(form.actor) }}
{{ form_row(form.resourceType) }}
</div>
<fieldset class="pt-4">
<legend class="block font-bold pb-2">{{ form.types.vars.label }}</legend>
<div class="flex flex-wrap gap-x-6 gap-y-2 text-sm">
{% for choice in form.types %}
<label class="inline-flex items-center gap-2">
{{ form_widget(choice) }}
{{ choice.vars.label }}
</label>
{% endfor %}
</div>
</fieldset>
<div class="flex justify-end gap-2 pt-2">
<a class="button button--small button--neutral" href="{{ path('app_admin_booking_event') }}">Zurücksetzen</a>
<button type="submit" class="button button--small button--primary">Filtern</button>
</div>
{{ form_end(form) }}
<p class="subline pt-8 pb-4">
{{ filter.fromDate|format_datetime(pattern: 'd. MMMM y') }} bis
{{ filter.toDate|format_datetime(pattern: 'd. MMMM y') }}
· {{ pagination.totalItemCount }} {{ pagination.totalItemCount == 1 ? 'Ereignis' : 'Ereignisse' }}
</p>
<div class="data-table-wrapper">
<div class="data-table-wrapper__inner text-sm">
<table class="data-table">
<thead>
<tr>
<th>{{ knp_pagination_sortable(pagination, 'Zeitpunkt', 'e.occurredAt') }}</th>
<th>{{ knp_pagination_sortable(pagination, 'Ereignis', 'e.type') }}</th>
<th>{{ knp_pagination_sortable(pagination, 'Ressource', 'e.resourceLabel') }}</th>
<th>{{ knp_pagination_sortable(pagination, 'Buchung', 'e.date') }}</th>
<th>{{ knp_pagination_sortable(pagination, 'Für', 'e.holderName') }}</th>
<th>{{ knp_pagination_sortable(pagination, 'Durchgeführt von', 'e.actorName') }}</th>
</tr>
</thead>
<tbody>
{% for event in pagination %}
{% set type = event.type.value %}
<tr>
<td class="whitespace-nowrap">{{ event.occurredAt|format_datetime(pattern: 'dd.MM.y, HH:mm') }}</td>
<td class="whitespace-nowrap">
<span class="badge {{ event.type.badgeClass }}">{{ event.type.label }}</span>
</td>
<td>
{{ event.resourceLabel }}
<span class="block text-xs text-gray-500">{{ event.resourceType.label }}</span>
</td>
<td>
{% if type in ['series_created', 'series_ended'] %}
{{ event.rule ? event.rule.label }}{% if event.timeRange %}, {{ event.timeRange }}{% endif %}
<span class="block text-xs text-gray-500">
{% if type == 'series_created' %}
{{ event.date|format_datetime(pattern: 'd. MMM y') }} – {{ event.endDate|format_datetime(pattern: 'd. MMM y') }}
· {{ event.details.created }} {{ event.details.created == 1 ? 'Termin' : 'Termine' }}
{%- if event.details.skipped is not empty %}, {{ event.details.skipped|length }} übersprungen{% endif %}
{% else %}
ab {{ event.date|format_datetime(pattern: 'd. MMM y') }}
· {{ event.details.removed }} {{ event.details.removed == 1 ? 'Termin' : 'Termine' }} entfernt
{% endif %}
</span>
{% elseif type == 'resource_deleted' %}
{{ event.details.removed }} {{ event.details.removed == 1 ? 'Buchung' : 'Buchungen' }} gelöscht
{% else %}
<span class="whitespace-nowrap">{{ event.date|format_datetime(pattern: 'EEE, d. MMM y') }}</span>
<span class="block text-xs text-gray-500">
{% if event.previousTimeRange %}
{{ event.previousTimeRange }} → {{ event.timeRange }}
{% else %}
{{ event.timeRange }}
{% endif %}
{%- if event.seriesId %} · wiederkehrend{% endif %}
</span>
{% endif %}
</td>
<td>{{ event.holderName ?? '–' }}</td>
<td>
{{ event.actorName }}
{% if event.onBehalf %}
<span class="badge bg-gray-100 text-gray-600">stellvertretend</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-gray-500">Keine Ereignisse in diesem Zeitraum.</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ knp_pagination_render(pagination) }}
</div>
</div>
{% endblock %}
+5 -3
View File
@@ -40,10 +40,12 @@
<section>
{% if spot_group.group %}
{% set free = spot_group.freeCount %}
<h3 class="!mb-0 pb-2 text-sm font-semibold text-gray-500 md:text-sm">
{# Prominent on purpose: desk labels repeat from room to room, so the room is
the only thing telling one "Schreibtisch 1" from the next. #}
<h3 class="!mb-0 pb-2 text-base font-semibold text-gray-900 md:text-lg">
{{- spot_group.group.label -}}
{%- if free > 0 %} <span class="text-emerald-600">· {{ free }} frei</span>
{%- else %} <span>· belegt</span>{% endif -%}
{%- if free > 0 %} <span class="text-sm font-normal text-emerald-600">· {{ free }} frei</span>
{%- else %} <span class="text-sm font-normal text-gray-500">· belegt</span>{% endif -%}
</h3>
{% endif %}
+9 -8
View File
@@ -29,14 +29,15 @@
{% endif %}
{% set interactive = not is_past and spot.actionable %}
{# An administrator may free somebody else's desk -- a colleague calls in sick and the spot
should go back into the pool. Colours are untouched: MINE still means mine, and only
clickability widens. Past days stay inert for admins too; there is no past-write path.
{# A supervisor (and so an administrator) may free somebody else's desk -- a colleague calls in
sick and the spot should go back into the pool. Colours are untouched: MINE still means
mine, and only clickability widens. Past days stay inert for them too; there is no
past-write path.
Only a card that is not already actionable: a partly booked one still opens the create
modal, because taking the free window is the more useful act there and an admin can reach
that booking from /admin/bookings anyway. #}
{% set manageable = not is_past and not interactive and is_granted('ROLE_ADMIN') and spot.bookings is not empty %}
modal, because taking the free window is the more useful act there and a supervisor can
reach that booking from /manage/bookings anyway. #}
{% set manageable = not is_past and not interactive and is_granted('ROLE_SUPERVISOR') and spot.bookings is not empty %}
{# One line per booking, then the window still free if there is one -- that last line is the
reason a partly booked card is worth clicking, so it earns its place. #}
@@ -78,9 +79,9 @@
hx-target="body"
hx-swap="beforeend"
{%- elseif manageable %}
{# Not app_booking_edit: a card can hold several bookings on one day, and an admin
{# Not app_booking_edit: a card can hold several bookings on one day, and a supervisor
releases rather than reschedules. The modal lists them and frees one. #}
hx-get="{{ path('app_admin_spot', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
hx-get="{{ path('app_manage_spot', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
hx-target="body"
hx-swap="beforeend"
{%- endif %}>
+26 -15
View File
@@ -49,24 +49,22 @@
<p class="text-sm text-gray-500 pb-4">Alle Buchungen verwalten.</p>
</a>
{# The route 404s while the feature is off, so the card has to go with it. #}
{% if feature('access_codes') %}
<a href="{{ path('app_access_code') }}"
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
<div class="flex items-center gap-3 pb-2">
{{ ux_icon('lucide:key-round', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
<span class="text-lg font-bold">Zugangscodes</span>
</div>
<p class="text-sm text-gray-500 pb-4">Codes für Türen, Aufzug und Schranke.</p>
</a>
{% endif %}
<a href="{{ path('app_access_code') }}"
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
<div class="flex items-center gap-3 pb-2">
{{ ux_icon('lucide:key-round', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
<span class="text-lg font-bold">Zugangscodes</span>
</div>
<p class="text-sm text-gray-500 pb-4">Codes für Türen, Aufzug und Schranke.</p>
</a>
{#
The header is deliberately not navigation, so the dashboard is the only place an
admin screen can be reached from.
The header is deliberately not navigation, so the dashboard is the only place the
overview and the admin screens can be reached from. ROLE_SUPERVISOR includes
administrators through role_hierarchy.
#}
{% if is_granted('ROLE_ADMIN') %}
<a href="{{ path('app_admin_booking') }}"
{% if is_granted('ROLE_SUPERVISOR') %}
<a href="{{ path('app_manage_booking') }}"
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
<div class="flex items-center gap-3 pb-2">
{{ ux_icon('lucide:shield-check', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
@@ -76,7 +74,9 @@
Buchungen aller Mitarbeitenden einsehen und ggf. freigeben.
</p>
</a>
{% endif %}
{% if is_granted('ROLE_ADMIN') %}
<a href="{{ path('app_admin_resource') }}"
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
<div class="flex items-center gap-3 pb-2">
@@ -87,6 +87,17 @@
Buchbare Ressourcen und ihre Standorte verwalten.
</p>
</a>
<a href="{{ path('app_admin_booking_event') }}"
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
<div class="flex items-center gap-3 pb-2">
{{ ux_icon('lucide:history', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
<span class="text-lg font-bold">Ereignishistorie</span>
</div>
<p class="text-sm text-gray-500 pb-4">
Nachvollziehen, wer welche Buchung angelegt, geändert oder freigegeben hat.
</p>
</a>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,14 @@
{# No codes and no door names -- see AccessCodeNotifier for why. #}
{% extends 'email/layout.html.twig' %}
{% block body %}
<h1>Neue Zugangscodes</h1>
<p>
Die Zugangscodes wurden aktualisiert. Die aktuell gültigen Codes findest du in E&amp;P Platz.
</p>
<p>
<a href="{{ url('app_access_code') }}" class="button">Zugangscodes ansehen</a>
</p>
{% endblock %}
@@ -0,0 +1,7 @@
{# No codes and no door names -- see AccessCodeNotifier for why. #}
Die Zugangscodes wurden aktualisiert. Die aktuell gültigen Codes findest du in E&P Platz:
{{ url('app_access_code') }}
--
E&P Platz
@@ -1,10 +1,10 @@
{#
What an administrator sees when clicking an occupied shape on the floor plan.
What a supervisor (or an administrator) sees when clicking an occupied shape on the floor plan.
Not the edit modal: a shape can hold several bookings on one day once part-day spans exist,
so "the booking" has no single referent -- and an administrator releases rather than
reschedules. Each row posts to the ordinary app_booking_release route, whose ownership check
admits an admin, so there is still one code path that removes a booking.
so "the booking" has no single referent -- and a supervisor releases rather than reschedules.
Each row posts to the ordinary app_booking_release route, whose ownership check admits
supervisors, so there is still one code path that removes a booking.
#}
{% extends 'htmx_modal.html.twig' %}
@@ -4,7 +4,7 @@
Filtering is a plain GET form -- the house rule hands navigation to the browser. The two
destructive actions open the shared confirmation modal via htmx and then navigate back here,
which is what ?return=admin says; without it a confirmed release swaps board fragments this
which is what ?return=manage says; without it a confirmed release swaps board fragments this
page does not have.
The appointment table is paginated; the series table is not, because a company has a handful of
@@ -28,7 +28,7 @@
{{ form_row(form.user) }}
</div>
<div class="flex justify-end gap-2 pt-2">
<a class="button button--small button--neutral" href="{{ path('app_admin_booking') }}">Zurücksetzen</a>
<a class="button button--small button--neutral" href="{{ path('app_manage_booking') }}">Zurücksetzen</a>
<button type="submit" class="button button--small button--primary">Filtern</button>
</div>
{{ form_end(form) }}
@@ -58,7 +58,7 @@
<td>{{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }}</td>
<td class="text-right">
<button type="button" class="button button--small button--warning"
hx-get="{{ path('app_series_end', { id: entry.id, return: 'admin' }) }}"
hx-get="{{ path('app_series_end', { id: entry.id, return: 'manage' }) }}"
hx-target="body"
hx-swap="beforeend">
Wiederholung beenden
@@ -115,7 +115,7 @@
<a class="button button--small button--neutral"
href="{{ path('app_board', { type: booking.resource.type.value, date: booking.date|date('Y-m-d') }) }}">Im Plan</a>
<button type="button" class="button button--small button--warning"
hx-get="{{ path('app_booking_release', { id: booking.id, return: 'admin' }) }}"
hx-get="{{ path('app_booking_release', { id: booking.id, return: 'manage' }) }}"
hx-target="body"
hx-swap="beforeend">
Freigeben
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Tests\Command;
use App\Entity\BookingEvent;
use App\Enum\BookingEventType;
use App\Enum\ResourceType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
/**
* The retention period of the event history, enforced from cron.
*/
class PruneBookingEventsCommandTest extends KernelTestCase
{
private EntityManagerInterface $em;
private CommandTester $command;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->em->getConnection()->beginTransaction();
$this->command = new CommandTester((new Application(self::$kernel))->find('app:booking-events:prune'));
}
protected function tearDown(): void
{
$this->em->getConnection()->rollBack();
parent::tearDown();
}
public function testEventsOlderThanAYearAreDeleted(): void
{
$old = $this->event('-13 months');
$recent = $this->event('-11 months');
$this->command->execute([]);
$this->command->assertCommandIsSuccessful();
self::assertStringContainsString('Deleted 1 booking events', $this->command->getDisplay());
self::assertNull($this->em->find(BookingEvent::class, $old));
self::assertNotNull($this->em->find(BookingEvent::class, $recent));
}
public function testTheRetentionCanBeShortened(): void
{
$event = $this->event('-2 months');
$this->command->execute(['--months' => '1']);
$this->command->assertCommandIsSuccessful();
self::assertNull($this->em->find(BookingEvent::class, $event));
}
public function testANonsensicalRetentionIsRefused(): void
{
$event = $this->event('-2 months');
self::assertSame(Command::INVALID, $this->command->execute(['--months' => '0']));
self::assertNotNull($this->em->find(BookingEvent::class, $event));
}
private function event(string $when): int
{
$event = new BookingEvent(BookingEventType::BOOKING_CREATED, null, BookingEvent::SYSTEM_ACTOR, null, 'Tisch 1', ResourceType::DESK);
$this->em->persist($event);
$this->em->flush();
$this->em->getConnection()->executeStatement(
'UPDATE booking_event SET occurred_at = ? WHERE id = ?',
[(new \DateTimeImmutable($when))->format('Y-m-d H:i:s'), $event->getId()],
);
$this->em->clear();
return (int) $event->getId();
}
}
@@ -82,6 +82,46 @@ class AccessCodeControllerTest extends WebTestCase
self::assertStringContainsString('Administration', $card->text());
}
/**
* A supervisor code is read by supervisors and by administrators, who hold no
* ROLE_SUPERVISOR of their own -- role_hierarchy does not reach the stored roles the query
* filters on. And a supervisor is not an administrator: the admin-only code stays out of
* their response.
*/
public function testASupervisorCodeIsReadBySupervisorsAndAdminsOnly(): void
{
$this->code('Management office', '5555', visibility: CodeVisibility::SUPERVISOR);
$this->code('Serverraum', '9999', visibility: CodeVisibility::ADMIN);
$this->em->flush();
$this->client->loginUser($this->user('[email protected]'));
$this->client->request('GET', '/access-codes');
self::assertStringNotContainsString('5555', (string) $this->client->getResponse()->getContent());
$this->client->loginUser($this->user('[email protected]', ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR']));
$card = $this->client->request('GET', '/access-codes')->filter('[data-access-code]');
self::assertCount(1, $card);
self::assertSame('5555', $card->filter('[data-code]')->text());
// The visibility caption the page shows, which is German like all user-facing text.
self::assertStringContainsString('Leitung', $card->text());
self::assertStringNotContainsString('9999', (string) $this->client->getResponse()->getContent());
$this->client->loginUser($this->user('[email protected]', ['ROLE_ADMIN']));
self::assertCount(2, $this->client->request('GET', '/access-codes')->filter('[data-access-code]'));
}
/**
* A supervisor reads codes but does not manage them.
*/
public function testASupervisorIsNotOfferedTheTable(): void
{
$this->client->loginUser($this->user('[email protected]', ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR']));
$crawler = $this->client->request('GET', '/access-codes');
self::assertCount(0, $crawler->filter('a[href="/admin/access-codes"]'));
}
/**
* Rotating a code. Only the one in force is shown: its predecessor is history, and its
* successor does not open anything yet -- both would only be another number to mistype.
@@ -56,6 +56,10 @@ class AccessCodeAdminControllerTest extends WebTestCase
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
$this->em->clear();
self::assertNotNull($this->em->find(AccessCode::class, $code->getId()));
$this->client->request('POST', '/admin/access-codes/notify', ['_token' => 'whatever'], server: ['HTTP_HX-Request' => 'true']);
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
self::assertEmailCount(0);
}
/**
@@ -219,6 +223,59 @@ class AccessCodeAdminControllerTest extends WebTestCase
self::assertNotNull($this->em->find(AccessCode::class, $code->getId()));
}
/**
* Everybody who can sign in gets a mail of their own, and none of them contains a code -- not
* even one only the recipient's own role could read.
*/
public function testNotifyingTheStaffMailsEveryActiveUserWithoutTheCodes(): void
{
$this->code('Aufzug', '4711');
$this->code('Tresor', '9876');
$admin = $this->admin();
$this->user('[email protected]');
$locked = $this->user('[email protected]');
$locked->disable('ausgeschieden');
$this->em->flush();
$expected = $this->em->getRepository(User::class)->count(['disabledAt' => null]);
$this->client->loginUser($admin);
$uri = '/admin/access-codes/notify';
$modal = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']);
self::assertStringContainsString(\sprintf('%d Mitarbeitende', $expected), $modal->text());
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
self::assertSame('/admin/access-codes', $this->client->getResponse()->headers->get('HX-Redirect'));
self::assertEmailCount($expected);
$recipients = [];
foreach (self::getMailerMessages() as $email) {
self::assertCount(1, $email->getTo());
$recipients[] = $email->getTo()[0]->getAddress();
foreach ([$email->getTextBody(), $email->getHtmlBody()] as $body) {
self::assertStringContainsString('https://localhost/access-codes', $body);
self::assertStringNotContainsString('4711', $body);
self::assertStringNotContainsString('9876', $body);
self::assertStringNotContainsString('Aufzug', $body);
}
}
self::assertContains('[email protected]', $recipients);
self::assertContains('[email protected]', $recipients);
self::assertNotContains('[email protected]', $recipients);
}
public function testNotifyingTheStaffNeedsAValidToken(): void
{
$this->client->loginUser($this->admin());
$this->client->request('POST', '/admin/access-codes/notify', ['_token' => 'whatever'], server: ['HTTP_HX-Request' => 'true']);
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
self::assertEmailCount(0);
}
/**
* Opens the modal, then posts the given fields with its CSRF token -- the round trip a
* browser makes.
@@ -0,0 +1,331 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin;
use App\Entity\Booking;
use App\Entity\BookingEvent;
use App\Entity\Resource;
use App\Entity\User;
use App\Enum\BookingEventType;
use App\Enum\ResourceType;
use App\Model\TimeRange;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* The event history at /admin/history: who may see it, what it shows, and that its filters and
* sort links narrow what they say they do. What gets *recorded* is tested in BookingManagerTest;
* the one end-to-end test here goes through a real release, because "acting for somebody else"
* is only meaningful with a signed-in actor.
*/
class BookingEventControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $em;
protected function setUp(): void
{
$this->client = self::createClient(server: ['HTTPS' => true]);
$this->client->disableReboot();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->em->getConnection()->beginTransaction();
}
protected function tearDown(): void
{
$this->em->getConnection()->rollBack();
parent::tearDown();
}
/**
* Supervisors free bookings themselves, but the history across the whole staff is the
* administrators' alone.
*/
public function testOnlyAnAdminCanReachTheHistory(): void
{
$this->client->loginUser($this->user('[email protected]'));
$this->client->request('GET', '/admin/history');
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
$this->client->loginUser($this->user('[email protected]', 'Sam Supervisor', ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR']));
$this->client->request('GET', '/admin/history');
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
$this->client->loginUser($this->admin());
$this->client->request('GET', '/admin/history');
self::assertResponseIsSuccessful();
}
public function testTheDashboardTileIsForAdminsOnly(): void
{
$this->client->loginUser($this->user('[email protected]', 'Sam Supervisor', ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR']));
self::assertCount(0, $this->client->request('GET', '/')->filter('a[href="/admin/history"]'));
$this->client->loginUser($this->admin());
self::assertCount(1, $this->client->request('GET', '/')->filter('a[href="/admin/history"]'));
}
public function testAReleaseOnSomebodysBehalfShowsBothPeople(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$booking = new Booking($bea, $this->resource('Tisch 12'), new \DateTimeImmutable('today'));
$this->em->persist($booking);
$this->em->flush();
$this->client->loginUser($this->admin());
$uri = \sprintf('/bookings/%d/release', $booking->getId());
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
$crawler = $this->client->request('GET', '/admin/history');
self::assertResponseIsSuccessful();
$row = $crawler->filter('tbody tr')->first()->text();
self::assertStringContainsString('Buchung freigegeben', $row);
self::assertStringContainsString('Tisch 12', $row);
self::assertStringContainsString('Bea Berger', $row);
self::assertStringContainsString('Chef Chefin', $row);
self::assertStringContainsString('stellvertretend', $row);
}
public function testSomebodyActingOnTheirOwnBookingIsNotMarked(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea);
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/history');
self::assertStringNotContainsString('stellvertretend', $crawler->filter('tbody')->text());
}
/**
* A console command or the worker has no booking of its own, so it acts for nobody.
*/
public function testTheSystemActingIsNotMarked(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$event = (new BookingEvent(BookingEventType::BOOKING_RELEASED, null, BookingEvent::SYSTEM_ACTOR, null, 'Tisch 1', ResourceType::DESK))
->setHolder($bea);
$this->em->persist($event);
$this->em->flush();
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/history');
self::assertStringContainsString('Bea Berger', $crawler->filter('tbody')->text());
self::assertStringNotContainsString('stellvertretend', $crawler->filter('tbody')->text());
}
/**
* The person filter is about who carried the change out: Carl's booking freed by Bea is
* Bea's line, not Carl's.
*/
public function testThePersonFilterMatchesWhoActed(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$carl = $this->user('[email protected]', 'Carl Conrad');
$this->event(BookingEventType::BOOKING_RELEASED, $bea, $carl, 'Tisch 1');
$this->event(BookingEventType::BOOKING_CREATED, $carl, $carl, 'Tisch 2');
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/history?actor='.$bea->getId());
self::assertResponseIsSuccessful();
$text = $crawler->filter('tbody')->text();
self::assertStringContainsString('Tisch 1', $text);
self::assertStringNotContainsString('Tisch 2', $text);
}
public function testTheTypeFilterAcceptsSeveralKinds(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch 1');
$this->event(BookingEventType::BOOKING_RESCHEDULED, $bea, $bea, 'Tisch 2');
$this->event(BookingEventType::BOOKING_RELEASED, $bea, $bea, 'Tisch 3');
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/history?types[]=booking_created&types[]=booking_released');
self::assertResponseIsSuccessful();
$text = $crawler->filter('tbody')->text();
self::assertStringContainsString('Tisch 1', $text);
self::assertStringNotContainsString('Tisch 2', $text);
self::assertStringContainsString('Tisch 3', $text);
}
public function testTheResourceTypeFilter(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch 1');
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'P1', ResourceType::PARKING);
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/history?resourceType=parking');
$text = $crawler->filter('tbody')->text();
self::assertStringContainsString('P1', $text);
self::assertStringNotContainsString('Tisch 1', $text);
}
/**
* The window is about when it happened. The default reaches back thirty days, and the "to"
* day is included in full, not cut off at its midnight.
*/
public function testTheDateWindowIsAboutWhenItHappened(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$this->occurredAt($this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch alt'), '-40 days');
$this->occurredAt($this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch gestern'), 'yesterday 23:30');
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch heute');
$this->em->clear();
$this->client->loginUser($this->admin());
$text = $this->client->request('GET', '/admin/history')->filter('tbody')->text();
self::assertStringNotContainsString('Tisch alt', $text);
self::assertStringContainsString('Tisch gestern', $text);
self::assertStringContainsString('Tisch heute', $text);
$yesterday = (new \DateTimeImmutable('yesterday'))->format('Y-m-d');
$text = $this->client->request('GET', '/admin/history?from='.$yesterday.'&to='.$yesterday)->filter('tbody')->text();
self::assertStringContainsString('Tisch gestern', $text);
self::assertStringNotContainsString('Tisch heute', $text);
}
public function testTheNewestEventComesFirst(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$this->occurredAt($this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch früher'), '-2 hours');
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch später');
$this->em->clear();
$this->client->loginUser($this->admin());
$rows = $this->client->request('GET', '/admin/history')->filter('tbody tr');
self::assertStringContainsString('Tisch später', $rows->eq(0)->text());
self::assertStringContainsString('Tisch früher', $rows->eq(1)->text());
$rows = $this->client->request('GET', '/admin/history?sort=e.occurredAt&direction=asc')->filter('tbody tr');
self::assertStringContainsString('Tisch früher', $rows->eq(0)->text());
}
public function testSortingByWhoActedKeepsTheFilter(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$carl = $this->user('[email protected]', 'Carl Conrad');
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch 1');
$this->event(BookingEventType::BOOKING_CREATED, $carl, $carl, 'Tisch 2');
$this->event(BookingEventType::BOOKING_RELEASED, $carl, $carl, 'Tisch 3');
$this->client->loginUser($this->admin());
$rows = $this->client
->request('GET', '/admin/history?types[]=booking_created&sort=e.actorName&direction=desc')
->filter('tbody tr');
self::assertCount(2, $rows);
self::assertStringContainsString('Carl Conrad', $rows->eq(0)->text());
self::assertStringContainsString('Bea Berger', $rows->eq(1)->text());
}
/**
* Every kind has its own branch in the "Buchung" column; each is rendered once here so a typo
* in one of them cannot hide until somebody filters for it.
*/
public function testEveryKindOfEventRenders(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea);
$this->event(BookingEventType::BOOKING_RESCHEDULED, $bea, $bea)
->setTimeRange(TimeRange::fromMinutes(600, 720), TimeRange::fromMinutes(540, 780));
$this->event(BookingEventType::BOOKING_RELEASED, $bea, $bea);
$this->event(BookingEventType::SERIES_CREATED, $bea, $bea)
->setDates(new \DateTimeImmutable('today'), new \DateTimeImmutable('+4 weeks'))
->setDetails(['rule' => 'weekly', 'created' => 4, 'skipped' => ['2026-10-20']]);
$this->event(BookingEventType::SERIES_ENDED, $bea, $bea)
->setDetails(['rule' => 'weekly', 'removed' => 3]);
$deleted = new BookingEvent(BookingEventType::RESOURCE_DELETED, $bea, 'Bea Berger', 7, 'Tisch 9', ResourceType::DESK);
$this->em->persist($deleted->setDetails(['removed' => 5]));
$this->em->flush();
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/history');
self::assertResponseIsSuccessful();
$text = $crawler->filter('tbody')->text();
self::assertStringContainsString('09:00–13:00 → 10:00–12:00', $text);
self::assertStringContainsString('Wöchentlich', $text);
self::assertStringContainsString('4 Termine, 1 übersprungen', $text);
self::assertStringContainsString('3 Termine entfernt', $text);
self::assertStringContainsString('5 Buchungen gelöscht', $text);
self::assertStringContainsString('6 Ereignisse', $crawler->filter('body')->text());
}
private function event(
BookingEventType $type,
User $actor,
User $holder,
string $label = 'Tisch 1',
ResourceType $resourceType = ResourceType::DESK,
): BookingEvent {
$event = (new BookingEvent($type, $actor, $actor->getName(), null, $label, $resourceType))
->setHolder($holder)
->setDates(new \DateTimeImmutable('today'))
->setTimeRange(TimeRange::wholeDay());
$this->em->persist($event);
$this->em->flush();
return $event;
}
/**
* The constructor stamps the current time and nothing should be able to change it afterwards,
* so a test that needs an older event rewrites the row. Call clear() once the setup is done,
* or the page renders the stale time still held in the identity map.
*/
private function occurredAt(BookingEvent $event, string $when): void
{
$this->em->getConnection()->executeStatement(
'UPDATE booking_event SET occurred_at = ? WHERE id = ?',
[(new \DateTimeImmutable($when))->format('Y-m-d H:i:s'), $event->getId()],
);
}
private function confirmationToken(string $uri): string
{
$crawler = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']);
self::assertResponseIsSuccessful();
$vals = $crawler->filter('#htmx-confirmation-modal [hx-post]')->attr('hx-vals');
return json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token'];
}
private function admin(): User
{
return $this->user('[email protected]', 'Chef Chefin', ['ROLE_ADMIN']);
}
/**
* @param list<string> $roles
*/
private function user(string $email, string $name = 'Test', array $roles = ['ROLE_EMPLOYEE']): User
{
$user = new User($email, $name);
$user->setRoles($roles);
$this->em->persist($user);
$this->em->flush();
return $user;
}
private function resource(string $label): Resource
{
$resource = new Resource(ResourceType::DESK, $label);
$this->em->persist($resource);
$this->em->flush();
return $resource;
}
}
@@ -5,11 +5,13 @@ declare(strict_types=1);
namespace App\Tests\Controller\Admin;
use App\Entity\Booking;
use App\Entity\BookingEvent;
use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Entity\ResourceGroup;
use App\Entity\ResourceTypeSettings;
use App\Entity\User;
use App\Enum\BookingEventType;
use App\Enum\RecurrenceRule;
use App\Enum\ResourceType;
use App\Model\TimeRange;
@@ -268,6 +270,29 @@ class ResourceAdminControllerTest extends WebTestCase
$remaining = $this->em->getRepository(Booking::class)->findAll();
self::assertCount(1, $remaining);
self::assertSame($other->getId(), $remaining[0]->getResource()->getId());
// The bookings went without passing through BookingManager, so the history gets one line
// for the lot here.
$events = $this->em->getRepository(BookingEvent::class)->findAll();
self::assertCount(1, $events);
self::assertSame(BookingEventType::RESOURCE_DELETED, $events[0]->getType());
self::assertSame('Schreibtisch 01', $events[0]->getResourceLabel());
self::assertSame(3, $events[0]->getDetails()['removed']);
}
/**
* An unused resource is configuration, not booking history.
*/
public function testDeletingAResourceWithoutBookingsRecordsNoEvent(): void
{
$desk = $this->resource('Schreibtisch 01');
$this->client->loginUser($this->admin());
$uri = \sprintf('/admin/resources/%d/delete', $desk->getId());
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
self::assertSame('/admin/resources', $this->client->getResponse()->headers->get('HX-Redirect'));
self::assertSame(0, $this->em->getRepository(BookingEvent::class)->count([]));
}
public function testDeletingAResourceNeedsAValidToken(): void
+68 -55
View File
@@ -26,6 +26,7 @@ class BookingControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $em;
private \DateTimeImmutable $monday;
protected function setUp(): void
{
@@ -33,6 +34,10 @@ class BookingControllerTest extends WebTestCase
$this->client->disableReboot();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->em->getConnection()->beginTransaction();
// Every day these tests book on is counted from here. Fixed dates fell into the past and
// started being refused; the next Monday is always ahead, always inside the booking
// horizon, and keeps the weekday the series tests count on.
$this->monday = new \DateTimeImmutable('next monday');
}
protected function tearDown(): void
@@ -45,11 +50,11 @@ class BookingControllerTest extends WebTestCase
{
$this->client->loginUser($this->user('[email protected]'));
$this->client->request('GET', '/book/desk/day/2026-10-01', server: ['HTTP_HX-Request' => 'true']);
$this->client->request('GET', '/book/desk/day/'.$this->day(), server: ['HTTP_HX-Request' => 'true']);
$response = $this->client->getResponse();
self::assertResponseIsSuccessful();
self::assertSame('/book/desk?date=2026-10-01', $response->headers->get('HX-Push-Url'));
self::assertSame('/book/desk?date='.$this->day(), $response->headers->get('HX-Push-Url'));
self::assertSame(2, substr_count((string) $response->getContent(), 'hx-swap-oob="true"'));
self::assertStringContainsString('id="board-calendar"', (string) $response->getContent());
self::assertStringContainsString('id="board-floorplan"', (string) $response->getContent());
@@ -59,9 +64,9 @@ class BookingControllerTest extends WebTestCase
{
$this->client->loginUser($this->user('[email protected]'));
$this->client->request('GET', '/book/desk/day/2026-10-01');
$this->client->request('GET', '/book/desk/day/'.$this->day());
self::assertResponseRedirects('/book/desk?date=2026-10-01');
self::assertResponseRedirects('/book/desk?date='.$this->day());
}
/**
@@ -73,7 +78,7 @@ class BookingControllerTest extends WebTestCase
$this->client->loginUser($this->user('[email protected]'));
$desk = $this->resource('D1');
$response = $this->submitBookingModal($desk, '2026-10-01');
$response = $this->submitBookingModal($desk, $this->day());
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
// The modal is gone from the response, which is what closes it: an out-of-band-only
@@ -91,7 +96,7 @@ class BookingControllerTest extends WebTestCase
{
$this->client->loginUser($this->user('[email protected]'));
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-01', ['allDay' => '1', 'date' => '2020-01-01']);
$response = $this->submitBookingModal($this->resource('D1'), $this->day(), ['allDay' => '1', 'date' => '2020-01-01']);
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
self::assertStringContainsString('id="htmx-modal"', (string) $response->getContent());
@@ -104,15 +109,15 @@ class BookingControllerTest extends WebTestCase
public function testADeskAlreadyTakenIsReportedInsideTheModal(): void
{
$desk = $this->resource('D1');
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01')));
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable($this->day())));
$this->em->flush();
$this->client->loginUser($this->user('[email protected]'));
$response = $this->submitBookingModal($desk, '2026-10-01');
$response = $this->submitBookingModal($desk, $this->day());
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
self::assertStringContainsString('ist am 01.10.2026 bereits belegt', (string) $response->getContent());
self::assertStringContainsString(\sprintf('ist am %s bereits belegt', $this->day(0, 'd.m.Y')), (string) $response->getContent());
}
/**
@@ -123,12 +128,12 @@ class BookingControllerTest extends WebTestCase
public function testASecondDeskAtTheSameTimeIsBookedAndWarnedAbout(): void
{
$anna = $this->user('[email protected]');
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01')));
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day())));
$this->em->flush();
$this->client->loginUser($anna);
$response = $this->submitBookingModal($this->resource('D2'), '2026-10-01');
$response = $this->submitBookingModal($this->resource('D2'), $this->day());
$content = (string) $response->getContent();
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
@@ -139,13 +144,13 @@ class BookingControllerTest extends WebTestCase
public function testEveryClashingDeskIsNamedInTheWarning(): void
{
$anna = $this->user('[email protected]');
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01')));
$this->em->persist(new Booking($anna, $this->resource('D2'), new \DateTimeImmutable('2026-10-01')));
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day())));
$this->em->persist(new Booking($anna, $this->resource('D2'), new \DateTimeImmutable($this->day())));
$this->em->flush();
$this->client->loginUser($anna);
$response = $this->submitBookingModal($this->resource('D3'), '2026-10-01');
$response = $this->submitBookingModal($this->resource('D3'), $this->day());
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertStringContainsString('bereits 2 weitere Schreibtische gebucht: D1, D2', (string) $response->getContent());
@@ -154,12 +159,12 @@ class BookingControllerTest extends WebTestCase
public function testTwoDesksAtDifferentTimesAreNotWarnedAbout(): void
{
$anna = $this->user('[email protected]');
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 720)));
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day()), TimeRange::fromMinutes(540, 720)));
$this->em->flush();
$this->client->loginUser($anna);
$response = $this->submitBookingModal($this->resource('D2'), '2026-10-01', [
$response = $this->submitBookingModal($this->resource('D2'), $this->day(), [
'startTime' => '13:00',
'endTime' => '17:00',
]);
@@ -175,12 +180,12 @@ class BookingControllerTest extends WebTestCase
public function testADeskAndAParkingSpotOnTheSameDayAreBothAllowedWithoutAWarning(): void
{
$anna = $this->user('[email protected]');
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01')));
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day())));
$this->em->flush();
$this->client->loginUser($anna);
$response = $this->submitBookingModal($this->resource('P1', ResourceType::PARKING), '2026-10-01');
$response = $this->submitBookingModal($this->resource('P1', ResourceType::PARKING), $this->day());
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertStringNotContainsString('Hinweis', (string) $response->getContent());
@@ -191,33 +196,33 @@ class BookingControllerTest extends WebTestCase
$anna = $this->user('[email protected]');
$other = $this->resource('D2');
// Two of the four Mondays the series lands on, and one Tuesday it does not.
$this->em->persist(new Booking($anna, $other, new \DateTimeImmutable('2026-10-05'), TimeRange::fromMinutes(600, 660)));
$this->em->persist(new Booking($anna, $other, new \DateTimeImmutable('2026-10-19'), TimeRange::fromMinutes(600, 660)));
$this->em->persist(new Booking($anna, $other, new \DateTimeImmutable('2026-10-20'), TimeRange::fromMinutes(600, 660)));
$this->em->persist(new Booking($anna, $other, new \DateTimeImmutable($this->day()), TimeRange::fromMinutes(600, 660)));
$this->em->persist(new Booking($anna, $other, new \DateTimeImmutable($this->day(14)), TimeRange::fromMinutes(600, 660)));
$this->em->persist(new Booking($anna, $other, new \DateTimeImmutable($this->day(15)), TimeRange::fromMinutes(600, 660)));
$this->em->flush();
$this->client->loginUser($anna);
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [
$response = $this->submitBookingModal($this->resource('D1'), $this->day(), [
'startTime' => '09:00',
'endTime' => '12:00',
'repeat' => 'weekly',
'until' => '2026-10-30',
'until' => $this->day(25),
]);
$content = (string) $response->getContent();
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame(1, substr_count($content, 'Hinweis:'), 'one flash for the whole series');
self::assertStringContainsString('An 2 von 4 Terminen', $content);
self::assertStringContainsString('(5.10., 19.10.)', $content);
self::assertStringContainsString(\sprintf('(%s, %s)', $this->day(0, 'j.n.'), $this->day(14, 'j.n.')), $content);
}
public function testMovingABookingOntoAnotherOneWarns(): void
{
$anna = $this->user('[email protected]');
$mine = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(780, 1020));
$mine = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day()), TimeRange::fromMinutes(780, 1020));
$this->em->persist($mine);
$this->em->persist(new Booking($anna, $this->resource('D2'), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 720)));
$this->em->persist(new Booking($anna, $this->resource('D2'), new \DateTimeImmutable($this->day()), TimeRange::fromMinutes(540, 720)));
$this->em->flush();
$this->client->loginUser($anna);
@@ -240,8 +245,8 @@ class BookingControllerTest extends WebTestCase
$this->client->loginUser($this->user('[email protected]'));
$desk = $this->resource('D1');
$this->client->request('POST', \sprintf('/bookings/new/%d/2026-10-01', $desk->getId()), [
'booking' => ['date' => '2026-10-01', '_token' => 'nope'],
$this->client->request('POST', \sprintf('/bookings/new/%d/%s', $desk->getId(), $this->day()), [
'booking' => ['date' => $this->day(), '_token' => 'nope'],
], server: ['HTTP_HX-Request' => 'true']);
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $this->client->getResponse()->getStatusCode());
@@ -250,7 +255,7 @@ class BookingControllerTest extends WebTestCase
public function testReleasingAnotherPersonsBookingIsRefused(): void
{
$booking = new Booking($this->user('[email protected]'), $this->resource('D1'), new \DateTimeImmutable('2026-10-01'));
$booking = new Booking($this->user('[email protected]'), $this->resource('D1'), new \DateTimeImmutable($this->day()));
$this->em->persist($booking);
$this->em->flush();
@@ -264,7 +269,7 @@ class BookingControllerTest extends WebTestCase
public function testReleasingOwnBookingSwapsTheBoard(): void
{
$anna = $this->user('[email protected]');
$booking = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'));
$booking = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day()));
$this->em->persist($booking);
$this->em->flush();
$id = $booking->getId();
@@ -291,12 +296,12 @@ class BookingControllerTest extends WebTestCase
public function testTheFloorPlanOpensTheEditModalForYourOwnBooking(): void
{
$anna = $this->user('[email protected]');
$booking = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'));
$booking = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day()));
$this->em->persist($booking);
$this->em->flush();
$this->client->loginUser($anna);
$crawler = $this->client->request('GET', '/book/desk?date=2026-10-01');
$crawler = $this->client->request('GET', '/book/desk?date='.$this->day());
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/edit"]', $booking->getId())));
}
@@ -312,13 +317,13 @@ class BookingControllerTest extends WebTestCase
public function testASecondPersonCanBookTheRestOfADayFromTheDefaultBoard(): void
{
$desk = $this->resource('D1');
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 780)));
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable($this->day()), TimeRange::fromMinutes(540, 780)));
$this->em->flush();
$this->client->loginUser($this->user('[email protected]'));
// 1. The shape is clickable on the all-day board.
$crawler = $this->client->request('GET', '/book/desk?date=2026-10-01');
$crawler = $this->client->request('GET', '/book/desk?date='.$this->day());
$opener = $crawler->filter(\sprintf('[data-spot][hx-get*="/bookings/new/%d/"]', $desk->getId()));
self::assertCount(1, $opener, 'a partly booked desk must still open the booking modal');
@@ -330,7 +335,7 @@ class BookingControllerTest extends WebTestCase
self::assertNull($modal->filter('#booking_endTime')->attr('value'));
// 3. Submitting it works.
$response = $this->submitBookingModal($desk, '2026-10-01', [
$response = $this->submitBookingModal($desk, $this->day(), [
'startTime' => '13:00',
'endTime' => '17:00',
]);
@@ -342,11 +347,11 @@ class BookingControllerTest extends WebTestCase
public function testAFullyBookedDeskIsNotClickable(): void
{
$desk = $this->resource('D1');
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01')));
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable($this->day())));
$this->em->flush();
$this->client->loginUser($this->user('[email protected]'));
$crawler = $this->client->request('GET', '/book/desk?date=2026-10-01');
$crawler = $this->client->request('GET', '/book/desk?date='.$this->day());
self::assertCount(0, $crawler->filter(\sprintf('[data-spot][hx-get*="/bookings/new/%d/"]', $desk->getId())));
}
@@ -356,7 +361,7 @@ class BookingControllerTest extends WebTestCase
$this->client->loginUser($this->user('[email protected]'));
$desk = $this->resource('D1');
$response = $this->submitBookingModal($desk, '2026-10-01', [
$response = $this->submitBookingModal($desk, $this->day(), [
'startTime' => '09:00',
'endTime' => '13:00',
]);
@@ -372,7 +377,7 @@ class BookingControllerTest extends WebTestCase
$this->client->loginUser($this->user('[email protected]'));
$desk = $this->resource('D1');
$this->submitBookingModal($desk, '2026-10-01');
$this->submitBookingModal($desk, $this->day());
$booking = $this->em->getRepository(Booking::class)->findOneBy(['resource' => $desk]);
self::assertNotNull($booking);
@@ -383,7 +388,7 @@ class BookingControllerTest extends WebTestCase
{
$this->client->loginUser($this->user('[email protected]'));
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-01', [
$response = $this->submitBookingModal($this->resource('D1'), $this->day(), [
'startTime' => '13:00',
'endTime' => '09:00',
]);
@@ -395,12 +400,12 @@ class BookingControllerTest extends WebTestCase
public function testAPartDayBookingLeavesTheRestOfTheDayBookable(): void
{
$desk = $this->resource('D1');
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 780)));
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable($this->day()), TimeRange::fromMinutes(540, 780)));
$this->em->flush();
$this->client->loginUser($this->user('[email protected]'));
$response = $this->submitBookingModal($desk, '2026-10-01', [
$response = $this->submitBookingModal($desk, $this->day(), [
'startTime' => '13:00',
'endTime' => '17:00',
]);
@@ -413,13 +418,13 @@ class BookingControllerTest extends WebTestCase
$this->client->loginUser($this->user('[email protected]'));
$desk = $this->resource('D1');
// 2026-10-05 is a Monday, and the weekday is taken from it rather than asked for --
// the Mondays up to the 30th are the 5th, 12th, 19th and 26th.
$response = $this->submitBookingModal($desk, '2026-10-05', [
// day() is a Monday, and the weekday is taken from it rather than asked for -- the
// Mondays up to the Friday three weeks on are four.
$response = $this->submitBookingModal($desk, $this->day(), [
'startTime' => '09:00',
'endTime' => '12:00',
'repeat' => 'weekly',
'until' => '2026-10-30',
'until' => $this->day(25),
]);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
@@ -436,8 +441,8 @@ class BookingControllerTest extends WebTestCase
$this->client->loginUser($this->user('[email protected]'));
$desk = $this->resource('D1');
// Relative to today, unlike the rest of this file: the three-month horizon moves with the
// clock, so a fixed end date here would start failing the day it fell outside it.
// The three-month horizon moves with the clock, so a fixed end date here would start
// failing the day it fell outside it.
$start = (new \DateTimeImmutable('today'))->modify('next monday');
$until = $start->modify('+56 days');
@@ -459,7 +464,7 @@ class BookingControllerTest extends WebTestCase
{
$this->client->loginUser($this->user('[email protected]'));
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [
$response = $this->submitBookingModal($this->resource('D1'), $this->day(), [
'allDay' => '1',
'repeat' => 'daily',
]);
@@ -472,7 +477,7 @@ class BookingControllerTest extends WebTestCase
{
$this->client->loginUser($this->user('[email protected]'));
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [
$response = $this->submitBookingModal($this->resource('D1'), $this->day(), [
'allDay' => '1',
'repeat' => 'daily',
'until' => '2030-01-01',
@@ -585,7 +590,7 @@ class BookingControllerTest extends WebTestCase
$crawler = $this->client->request(
'GET',
\sprintf('/bookings/new/%d/2026-10-01?from=09:00&to=13:00', $desk->getId()),
\sprintf('/bookings/new/%d/%s?from=09:00&to=13:00', $desk->getId(), $this->day()),
server: ['HTTP_HX-Request' => 'true'],
);
@@ -598,10 +603,10 @@ class BookingControllerTest extends WebTestCase
{
$this->client->loginUser($this->user('[email protected]'));
$this->client->request('GET', '/book/desk/day/2026-10-01?from=09:00&to=13:00', server: ['HTTP_HX-Request' => 'true']);
$this->client->request('GET', '/book/desk/day/'.$this->day().'?from=09:00&to=13:00', server: ['HTTP_HX-Request' => 'true']);
self::assertResponseIsSuccessful();
self::assertSame('/book/desk?date=2026-10-01&from=09:00&to=13:00', $this->client->getResponse()->headers->get('HX-Push-Url'));
self::assertSame('/book/desk?date='.$this->day().'&from=09:00&to=13:00', $this->client->getResponse()->headers->get('HX-Push-Url'));
}
public function testOverridingOneOccurrenceLeavesTheSeriesAlone(): void
@@ -610,11 +615,11 @@ class BookingControllerTest extends WebTestCase
$this->client->loginUser($anna);
$desk = $this->resource('D1');
$this->submitBookingModal($desk, '2026-10-05', [
$this->submitBookingModal($desk, $this->day(), [
'startTime' => '09:00',
'endTime' => '12:00',
'repeat' => 'weekly',
'until' => '2026-10-26',
'until' => $this->day(21),
]);
$occurrences = $this->em->getRepository(Booking::class)->findBy(['resource' => $desk], ['date' => 'ASC']);
@@ -671,6 +676,14 @@ class BookingControllerTest extends WebTestCase
return $this->client->getResponse();
}
/**
* A day counted from the next Monday, as the routes and form fields spell it.
*/
private function day(int $offset = 0, string $format = 'Y-m-d'): string
{
return $this->monday->modify(\sprintf('+%d days', $offset))->format($format);
}
/**
* Opens a confirmation modal and reads its CSRF token off the confirm button's hx-vals --
* the same round trip a browser makes, rather than reaching into the token manager (which
@@ -2,24 +2,29 @@
declare(strict_types=1);
namespace App\Tests\Controller\Admin;
namespace App\Tests\Controller\Manage;
use App\Entity\Booking;
use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Entity\User;
use App\Enum\RecurrenceRule;
use App\Enum\ResourceType;
use App\Model\TimeRange;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* The admin area, and the widened ownership checks it leans on.
* The overview of everybody's bookings, and the widened ownership checks it leans on. Most tests
* sign in as an administrator, who reaches it through role_hierarchy; the supervisor ones below
* pin down that the role itself is enough, and that it reaches no further than this.
*
* The test that matters most here is the negative one: releasing somebody else's booking must
* still be refused for an ordinary employee. Everything else on this screen is a list.
*/
class BookingAdminControllerTest extends WebTestCase
class BookingManageControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $em;
@@ -38,21 +43,21 @@ class BookingAdminControllerTest extends WebTestCase
parent::tearDown();
}
public function testAnEmployeeCannotReachTheAdminList(): void
public function testAnEmployeeCannotReachTheOverview(): void
{
$this->client->loginUser($this->user('[email protected]'));
$this->client->request('GET', '/admin/bookings');
$this->client->request('GET', '/manage/bookings');
self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
}
public function testTheAdminListShowsOtherPeoplesBookings(): void
public function testTheOverviewShowsOtherPeoplesBookings(): void
{
$this->booking($this->user('[email protected]', 'Bea Berger'), $this->resource('D1'));
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/bookings');
$crawler = $this->client->request('GET', '/manage/bookings');
self::assertResponseIsSuccessful();
self::assertStringContainsString('Bea Berger', $crawler->filter('body')->text());
@@ -66,15 +71,15 @@ class BookingAdminControllerTest extends WebTestCase
// The two-step flow a browser walks: the list opens the confirmation modal, the modal
// carries the token and posts back to the same URI.
$crawler = $this->client->request('GET', '/admin/bookings');
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/release?return=admin"]', $id)));
$crawler = $this->client->request('GET', '/manage/bookings');
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/release?return=manage"]', $id)));
$uri = \sprintf('/bookings/%d/release?return=admin', $id);
$uri = \sprintf('/bookings/%d/release?return=manage', $id);
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
// ?return= means a genuine navigation rather than a board swap: this page has no board
// fragments, so HX-Redirect is the honest answer.
self::assertSame('/admin/bookings', $this->client->getResponse()->headers->get('HX-Redirect'));
self::assertSame('/manage/bookings', $this->client->getResponse()->headers->get('HX-Redirect'));
$this->em->clear();
self::assertNull($this->em->getRepository(Booking::class)->find($id));
}
@@ -95,7 +100,7 @@ class BookingAdminControllerTest extends WebTestCase
// The ownership guard runs before the CSRF one, so asserting on its message is what
// distinguishes "refused because it is not yours" from "refused because of the token".
self::assertStringContainsString(
'released by the person holding it or by an administrator',
'released by the person holding it or by a supervisor',
(string) $this->client->getResponse()->getContent(),
);
self::assertNotNull($this->em->getRepository(Booking::class)->find($id));
@@ -122,7 +127,7 @@ class BookingAdminControllerTest extends WebTestCase
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2'));
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/bookings?user='.$bea->getId());
$crawler = $this->client->request('GET', '/manage/bookings?user='.$bea->getId());
self::assertResponseIsSuccessful();
$text = $crawler->filter('tbody')->text();
@@ -136,7 +141,7 @@ class BookingAdminControllerTest extends WebTestCase
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('P1', ResourceType::PARKING));
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/bookings?type=parking');
$crawler = $this->client->request('GET', '/manage/bookings?type=parking');
self::assertResponseIsSuccessful();
$text = $crawler->filter('tbody')->text();
@@ -156,11 +161,11 @@ class BookingAdminControllerTest extends WebTestCase
$this->client->loginUser($this->user('[email protected]'));
$crawler = $this->client->request('GET', '/book/desk?date='.$date);
self::assertCount(0, $crawler->filter('[data-spot][hx-get*="/admin/spot/"]'));
self::assertCount(0, $crawler->filter('[data-spot][hx-get*="/manage/spot/"]'));
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/book/desk?date='.$date);
self::assertCount(1, $crawler->filter(\sprintf('[data-spot][hx-get="/admin/spot/%d/%s"]', $desk->getId(), $date)));
self::assertCount(1, $crawler->filter(\sprintf('[data-spot][hx-get="/manage/spot/%d/%s"]', $desk->getId(), $date)));
}
public function testTheSpotModalListsTheHolderAndReleasesTheBooking(): void
@@ -171,7 +176,7 @@ class BookingAdminControllerTest extends WebTestCase
$crawler = $this->client->request(
'GET',
\sprintf('/admin/spot/%d/%s', $desk->getId(), $booking->getDate()->format('Y-m-d')),
\sprintf('/manage/spot/%d/%s', $desk->getId(), $booking->getDate()->format('Y-m-d')),
server: ['HTTP_HX-Request' => 'true'],
);
@@ -192,7 +197,7 @@ class BookingAdminControllerTest extends WebTestCase
$crawler = $this->client->request(
'GET',
\sprintf('/admin/spot/%d/%s', $desk->getId(), (new \DateTimeImmutable('today'))->format('Y-m-d')),
\sprintf('/manage/spot/%d/%s', $desk->getId(), (new \DateTimeImmutable('today'))->format('Y-m-d')),
server: ['HTTP_HX-Request' => 'true'],
);
@@ -201,13 +206,90 @@ class BookingAdminControllerTest extends WebTestCase
self::assertStringNotContainsString('Carl Conrad', $text);
}
public function testTheDashboardTileIsAdminOnly(): void
public function testTheDashboardTileIsForSupervisorsAndAdminsOnly(): void
{
$this->client->loginUser($this->user('[email protected]'));
self::assertCount(0, $this->client->request('GET', '/')->filter('a[href="/admin/bookings"]'));
self::assertCount(0, $this->client->request('GET', '/')->filter('a[href="/manage/bookings"]'));
$this->client->loginUser($this->supervisor());
$crawler = $this->client->request('GET', '/');
self::assertCount(1, $crawler->filter('a[href="/manage/bookings"]'));
// The tile beside it leads into /admin, which a supervisor cannot enter.
self::assertCount(0, $crawler->filter('a[href="/admin/resources"]'));
$this->client->loginUser($this->admin());
self::assertCount(1, $this->client->request('GET', '/')->filter('a[href="/admin/bookings"]'));
self::assertCount(1, $this->client->request('GET', '/')->filter('a[href="/manage/bookings"]'));
}
public function testASupervisorSeesTheOverview(): void
{
$this->booking($this->user('[email protected]', 'Bea Berger'), $this->resource('D1'));
$this->client->loginUser($this->supervisor());
$crawler = $this->client->request('GET', '/manage/bookings');
self::assertResponseIsSuccessful();
self::assertStringContainsString('Bea Berger', $crawler->filter('body')->text());
}
public function testASupervisorReleasesABookingOnBehalfOfSomebodyElse(): void
{
$booking = $this->booking($this->user('[email protected]', 'Bea Berger'), $this->resource('D1'));
$id = $booking->getId();
$this->client->loginUser($this->supervisor());
$uri = \sprintf('/bookings/%d/release?return=manage', $id);
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
self::assertSame('/manage/bookings', $this->client->getResponse()->headers->get('HX-Redirect'));
$this->em->clear();
self::assertNull($this->em->getRepository(Booking::class)->find($id));
}
public function testASupervisorEndsSomebodyElsesSeries(): void
{
$series = new BookingSeries(
$this->user('[email protected]', 'Bea Berger'),
$this->resource('D1'),
RecurrenceRule::DAILY,
new \DateTimeImmutable('+1 day'),
new \DateTimeImmutable('+2 days'),
TimeRange::wholeDay(),
);
$this->em->persist($series);
$this->em->flush();
$this->client->loginUser($this->supervisor());
$uri = \sprintf('/series/%d/end?return=manage', $series->getId());
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
self::assertSame('/manage/bookings', $this->client->getResponse()->headers->get('HX-Redirect'));
}
public function testAnOccupiedDeskIsClickableForASupervisor(): void
{
$desk = $this->resource('D1');
$this->booking($this->user('[email protected]', 'Bea Berger'), $desk);
$date = (new \DateTimeImmutable('today'))->format('Y-m-d');
$this->client->loginUser($this->supervisor());
$crawler = $this->client->request('GET', '/book/desk?date='.$date);
self::assertCount(1, $crawler->filter(\sprintf('[data-spot][hx-get="/manage/spot/%d/%s"]', $desk->getId(), $date)));
}
/**
* A supervisor frees bookings; configuring what can be booked and who reads which code stays
* with the administrators.
*/
public function testASupervisorCannotEnterTheAdminArea(): void
{
$this->client->loginUser($this->supervisor());
foreach (['/admin/resources', '/admin/access-codes'] as $path) {
$this->client->request('GET', $path);
self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode(), $path);
}
}
/**
@@ -222,16 +304,16 @@ class BookingAdminControllerTest extends WebTestCase
$crawler = $this->client->request(
'GET',
\sprintf('/bookings/%d/release?return=admin', $booking->getId()),
\sprintf('/bookings/%d/release?return=manage', $booking->getId()),
server: ['HTTP_HX-Request' => 'true'],
);
self::assertResponseIsSuccessful();
$modal = $crawler->filter('#htmx-confirmation-modal');
self::assertStringContainsString('Bea Berger', $modal->text());
// Posts back to the same URI, which is what carries ?return=admin through the round trip.
// Posts back to the same URI, which is what carries ?return=manage through the round trip.
self::assertSame(
\sprintf('https://localhost/bookings/%d/release?return=admin', $booking->getId()),
\sprintf('https://localhost/bookings/%d/release?return=manage', $booking->getId()),
$modal->filter('[hx-post]')->attr('hx-post'),
);
}
@@ -250,12 +332,12 @@ class BookingAdminControllerTest extends WebTestCase
}
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/bookings');
$crawler = $this->client->request('GET', '/manage/bookings');
self::assertResponseIsSuccessful();
self::assertCount(25, $crawler->filter('tbody tr'));
$crawler = $this->client->request('GET', '/admin/bookings?page=2');
$crawler = $this->client->request('GET', '/manage/bookings?page=2');
self::assertResponseIsSuccessful();
self::assertCount(5, $crawler->filter('tbody tr'));
@@ -272,7 +354,7 @@ class BookingAdminControllerTest extends WebTestCase
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2'));
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/bookings?user='.$bea->getId().'&sort=u.name&direction=desc');
$crawler = $this->client->request('GET', '/manage/bookings?user='.$bea->getId().'&sort=u.name&direction=desc');
self::assertResponseIsSuccessful();
$text = $crawler->filter('tbody')->text();
@@ -299,6 +381,11 @@ class BookingAdminControllerTest extends WebTestCase
return $booking;
}
private function supervisor(string $email = '[email protected]', string $name = 'Sam Supervisor'): User
{
return $this->user($email, $name, ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR']);
}
private function admin(string $email = '[email protected]', string $name = 'Chef Chefin'): User
{
return $this->user($email, $name, ['ROLE_ADMIN']);
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Tests\Email;
use App\Email\Mailer;
use Monolog\Handler\TestHandler;
use Monolog\Logger;
use PHPUnit\Framework\TestCase;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\Exception\TransportException;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\RawMessage;
/**
* The two promises every caller relies on: one mail per recipient, addressed to them alone, and
* a failure that costs only its own recipient and is never thrown.
*/
class MailerTest extends TestCase
{
public function testEveryRecipientGetsTheirOwnMail(): void
{
$transport = $this->transport();
$mailer = new Mailer($transport, new Logger('test'), '[email protected]');
$report = $mailer->send(
['[email protected]', new Address('[email protected]', 'B')],
'Betreff',
'email/some',
['key' => 'value'],
);
self::assertSame(2, $report->sent);
self::assertSame(0, $report->failed);
self::assertCount(2, $transport->sent);
foreach ($transport->sent as $i => $email) {
self::assertCount(1, $email->getTo());
self::assertSame(['[email protected]', '[email protected]'][$i], $email->getTo()[0]->getAddress());
self::assertSame('[email protected]', $email->getFrom()[0]->getAddress());
self::assertSame('E&P Platz', $email->getFrom()[0]->getName());
self::assertSame('Betreff', $email->getSubject());
self::assertSame('email/some.html.twig', $email->getHtmlTemplate());
self::assertSame('email/some.txt.twig', $email->getTextTemplate());
self::assertSame(['key' => 'value'], $email->getContext());
}
}
public function testAFailedRecipientIsLoggedAndTheOthersStillGetTheirs(): void
{
$transport = $this->transport(failFor: '[email protected]');
$log = new TestHandler();
$mailer = new Mailer($transport, new Logger('test', [$log]), '[email protected]');
$report = $mailer->send(['[email protected]', '[email protected]', '[email protected]'], 'Betreff', 'email/some');
self::assertSame(2, $report->sent);
self::assertSame(1, $report->failed);
self::assertCount(2, $transport->sent);
self::assertTrue($log->hasErrorThatContains('Email could not be sent.'));
self::assertSame('[email protected]', $log->getRecords()[0]->context['recipient']);
}
public function testNoRecipientsSendsNothing(): void
{
$transport = $this->transport();
$mailer = new Mailer($transport, new Logger('test'), '[email protected]');
$report = $mailer->send([], 'Betreff', 'email/some');
self::assertSame(0, $report->sent);
self::assertSame([], $transport->sent);
}
/**
* @return MailerInterface&object{sent: list<TemplatedEmail>}
*/
private function transport(?string $failFor = null): MailerInterface
{
return new class($failFor) implements MailerInterface {
/** @var list<TemplatedEmail> */
public array $sent = [];
public function __construct(private readonly ?string $failFor)
{
}
public function send(RawMessage $message, ?Envelope $envelope = null): void
{
\assert($message instanceof TemplatedEmail);
if ($message->getTo()[0]->getAddress() === $this->failFor) {
throw new TransportException('550 mailbox unavailable');
}
$this->sent[] = $message;
}
};
}
}
+8 -3
View File
@@ -9,14 +9,18 @@ use PHPUnit\Framework\TestCase;
class RoleTest extends TestCase
{
public function testBothEligibleRolesAreRecognised(): void
public function testAllEligibleRolesAreRecognised(): void
{
self::assertSame([Role::EMPLOYEE, Role::ADMIN], Role::eligibleOnly(['ROLE_ADMIN', 'ROLE_EMPLOYEE']));
self::assertSame(
[Role::EMPLOYEE, Role::SUPERVISOR, Role::ADMIN],
Role::eligibleOnly(['ROLE_ADMIN', 'ROLE_SUPERVISOR', 'ROLE_EMPLOYEE']),
);
}
public function testEitherRoleOnItsOwnIsEnough(): void
public function testEachRoleOnItsOwnIsEnough(): void
{
self::assertSame([Role::EMPLOYEE], Role::eligibleOnly(['ROLE_EMPLOYEE']));
self::assertSame([Role::SUPERVISOR], Role::eligibleOnly(['ROLE_SUPERVISOR']));
self::assertSame([Role::ADMIN], Role::eligibleOnly(['ROLE_ADMIN']));
}
@@ -35,6 +39,7 @@ class RoleTest extends TestCase
{
// myep strips these before exporting, but that is its policy and not our guarantee.
self::assertSame([], Role::eligibleOnly(['ROLE_ADMIN_PENDING']));
self::assertSame([], Role::eligibleOnly(['ROLE_SUPERVISOR_PENDING']));
}
public function testTheOrderIsStableRegardlessOfWhatMyepSends(): void
+8
View File
@@ -180,6 +180,14 @@ class AuditLoggerTest extends TestCase
self::assertSame(7, $records[2]->context['access_code_id']);
}
public function testAnAnnouncementIsRecordedWithItsCounts(): void
{
$this->audit->accessCodesAnnounced(50, 1);
self::assertSame('Access code update announced.', $this->record()->message);
self::assertSame(['sent' => 50, 'failed' => 1], $this->record()->context);
}
private function record(): LogRecord
{
return $this->handler->getRecords()[0];
+112
View File
@@ -5,10 +5,12 @@ declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\Booking;
use App\Entity\BookingEvent;
use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Entity\ResourceTypeSettings;
use App\Entity\User;
use App\Enum\BookingEventType;
use App\Enum\RecurrenceRule;
use App\Enum\ResourceType;
use App\Exception\ResourceAlreadyBookedException;
@@ -368,6 +370,99 @@ class BookingManagerTest extends KernelTestCase
self::assertEmailCount(1);
}
public function testABookingRecordsAnEvent(): void
{
$booking = $this->manager->book($this->user('[email protected]'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
$event = $this->onlyEvent();
self::assertSame(BookingEventType::BOOKING_CREATED, $event->getType());
self::assertSame($booking->getId(), $event->getBookingId());
self::assertSame('D1', $event->getResourceLabel());
self::assertSame('Test', $event->getHolderName());
self::assertSame('2026-10-01', $event->getDate()?->format('Y-m-d'));
self::assertSame('09:00–13:00', (string) $event->getTimeRange());
// No token in a kernel test, as for a console command.
self::assertNull($event->getActor());
self::assertSame(BookingEvent::SYSTEM_ACTOR, $event->getActorName());
}
/**
* The event is written in the booking's transaction, so a refusal leaves nothing behind.
*/
public function testARefusedBookingRecordsNoEvent(): void
{
$desk = $this->resource('D1');
$this->manager->book($this->user('[email protected]'), $desk, $this->date());
try {
$this->manager->book($this->user('[email protected]'), $desk, $this->date());
self::fail('The second booking should have been refused.');
} catch (ResourceAlreadyBookedException) {
}
self::assertSame(1, $this->em->getRepository(BookingEvent::class)->count([]));
}
public function testReschedulingRecordsTheOldAndTheNewTimes(): void
{
$booking = $this->manager->book($this->user('[email protected]'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
$this->manager->reschedule($booking, TimeRange::fromMinutes(600, 720));
$event = $this->latestEvent();
self::assertSame(BookingEventType::BOOKING_RESCHEDULED, $event->getType());
self::assertSame('09:00–13:00', (string) $event->getPreviousTimeRange());
self::assertSame('10:00–12:00', (string) $event->getTimeRange());
}
public function testReleasingRecordsTheBookingThatWent(): void
{
$booking = $this->manager->book($this->user('[email protected]'), $this->resource('D1'), $this->date());
$id = $booking->getId();
$this->manager->release($booking);
$event = $this->latestEvent();
self::assertSame(BookingEventType::BOOKING_RELEASED, $event->getType());
self::assertSame($id, $event->getBookingId());
self::assertSame('D1', $event->getResourceLabel());
}
/**
* One event for the series, not one per day -- the same rule as the audit log.
*/
public function testASeriesRecordsOneEvent(): void
{
$series = $this->weeklySeries();
$this->manager->bookSeries($series);
$event = $this->onlyEvent();
self::assertSame(BookingEventType::SERIES_CREATED, $event->getType());
self::assertSame($series->getId(), $event->getSeriesId());
self::assertSame(RecurrenceRule::WEEKLY, $event->getRule());
self::assertSame(4, $event->getDetails()['created']);
self::assertSame('2026-10-30', $event->getEndDate()?->format('Y-m-d'));
}
/**
* Ending a series that has nothing left deletes it, so the id has to survive from before.
*/
public function testEndingASeriesRecordsItsIdEvenWhenTheSeriesIsGone(): void
{
$series = $this->weeklySeries();
$this->manager->bookSeries($series);
$id = $series->getId();
$this->manager->endSeries($series, new \DateTimeImmutable('2026-10-05'));
$event = $this->latestEvent();
self::assertSame(BookingEventType::SERIES_ENDED, $event->getType());
self::assertSame($id, $event->getSeriesId());
self::assertSame(4, $event->getDetails()['removed']);
self::assertSame('2026-10-05', $event->getDate()?->format('Y-m-d'));
}
/**
* @param list<string> $recipients
*/
@@ -415,9 +510,26 @@ class BookingManagerTest extends KernelTestCase
return $resource;
}
private function onlyEvent(): BookingEvent
{
$events = $this->em->getRepository(BookingEvent::class)->findAll();
self::assertCount(1, $events);
return $events[0];
}
private function latestEvent(): BookingEvent
{
$event = $this->em->getRepository(BookingEvent::class)->findOneBy([], ['id' => 'DESC']);
self::assertInstanceOf(BookingEvent::class, $event);
return $event;
}
private function wipe(): void
{
$connection = $this->em->getConnection();
$connection->executeStatement('DELETE FROM booking_event');
$connection->executeStatement('DELETE FROM booking');
$connection->executeStatement('DELETE FROM booking_series');
$connection->executeStatement('DELETE FROM resource');