From a79624b0457b6c63f4d38aa0b3170716f00819bc Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Tue, 9 Jan 2024 16:25:55 +0100 Subject: [PATCH 01/27] #366: Started work on quick book --- config/api_platform/slide.yaml | 18 +++++++++++++ .../InteractiveSlideActionController.php | 26 +++++++++++++++++++ src/Dto/InteractiveSlideActionInput.php | 8 ++++++ src/Service/InteractiveSlideService.php | 13 ++++++++++ 4 files changed, 65 insertions(+) create mode 100644 src/Controller/InteractiveSlideActionController.php create mode 100644 src/Dto/InteractiveSlideActionInput.php create mode 100644 src/Service/InteractiveSlideService.php diff --git a/config/api_platform/slide.yaml b/config/api_platform/slide.yaml index 5a78b85a..7c609c9a 100644 --- a/config/api_platform/slide.yaml +++ b/config/api_platform/slide.yaml @@ -108,6 +108,24 @@ resources: tags: - Slides + _api_Slide_perform_action: + input: App\Dto\InteractiveSlideActionInput + class: ApiPlatform\Metadata\POST + method: POST + uriTemplate: '/slides/{id}/action' + controller: App\Controller\InteractiveSlideActionController + openapiContext: + tags: + - Slides + parameters: + - schema: + type: string + format: ulid + pattern: "^[A-Za-z0-9]{26}$" + name: id + in: path + required: true + # Our DTO must be a resource to get a proper URL # @see https://stackoverflow.com/a/75705084 # @see https://github.com/api-platform/core/issues/5451 diff --git a/src/Controller/InteractiveSlideActionController.php b/src/Controller/InteractiveSlideActionController.php new file mode 100644 index 00000000..e1f86282 --- /dev/null +++ b/src/Controller/InteractiveSlideActionController.php @@ -0,0 +1,26 @@ +toArray(); + + return new JsonResponse($this->interactiveSlideService->performAction($slide, $requestBody)); + } +} diff --git a/src/Dto/InteractiveSlideActionInput.php b/src/Dto/InteractiveSlideActionInput.php new file mode 100644 index 00000000..d9e7d60a --- /dev/null +++ b/src/Dto/InteractiveSlideActionInput.php @@ -0,0 +1,8 @@ + Date: Wed, 24 Jan 2024 09:58:59 +0100 Subject: [PATCH 02/27] #366: Started interactive support --- config/api_platform/slide.yaml | 2 +- config/services.yaml | 7 ++++ src/Command/Tenant/ConfigureTenantCommand.php | 31 ++++++++++++++--- ...ntroller.php => InteractiveController.php} | 6 ++-- src/Interactive/InteractiveInterface.php | 9 +++++ src/Interactive/QuickBook.php | 17 ++++++++++ src/Service/InteractiveService.php | 33 +++++++++++++++++++ src/Service/InteractiveSlideService.php | 13 -------- 8 files changed, 97 insertions(+), 21 deletions(-) rename src/Controller/{InteractiveSlideActionController.php => InteractiveController.php} (77%) create mode 100644 src/Interactive/InteractiveInterface.php create mode 100644 src/Interactive/QuickBook.php create mode 100644 src/Service/InteractiveService.php delete mode 100644 src/Service/InteractiveSlideService.php diff --git a/config/api_platform/slide.yaml b/config/api_platform/slide.yaml index 7c609c9a..489bf2a0 100644 --- a/config/api_platform/slide.yaml +++ b/config/api_platform/slide.yaml @@ -113,7 +113,7 @@ resources: class: ApiPlatform\Metadata\POST method: POST uriTemplate: '/slides/{id}/action' - controller: App\Controller\InteractiveSlideActionController + controller: App\Controller\InteractiveController openapiContext: tags: - Slides diff --git a/config/services.yaml b/config/services.yaml index 92392ffb..1b7504e0 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -30,6 +30,9 @@ services: - { name: api_platform.doctrine.orm.query_extension.collection } - { name: api_platform.doctrine.orm.query_extension.item } + App\Interactive\InteractiveInterface: + tags: [app.interactive.interactive] + # Specify primary UserProviderInterface Symfony\Component\Security\Core\User\UserProviderInterface: '@security.user.provider.concrete.app_user_provider' @@ -72,6 +75,10 @@ services: arguments: - !tagged_iterator app.feed.feed_type + App\Service\InteractiveService: + arguments: + - !tagged_iterator app.interactive.interactive + App\Security\ScreenAuthenticator: arguments: $jwtScreenRefreshTokenTtl: '%env(int:JWT_SCREEN_REFRESH_TOKEN_TTL)%' diff --git a/src/Command/Tenant/ConfigureTenantCommand.php b/src/Command/Tenant/ConfigureTenantCommand.php index 97a608d2..42601023 100644 --- a/src/Command/Tenant/ConfigureTenantCommand.php +++ b/src/Command/Tenant/ConfigureTenantCommand.php @@ -6,11 +6,13 @@ use App\Entity\Tenant; use App\Repository\TenantRepository; +use App\Service\InteractiveService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Style\SymfonyStyle; @@ -22,7 +24,8 @@ class ConfigureTenantCommand extends Command { public function __construct( private readonly EntityManagerInterface $entityManager, - private readonly TenantRepository $tenantRepository + private readonly TenantRepository $tenantRepository, + private readonly InteractiveService $interactiveService, ) { parent::__construct(); } @@ -35,6 +38,8 @@ final protected function execute(InputInterface $input, OutputInterface $output) { $io = new SymfonyStyle($input, $output); + $helper = $this->getHelper('question'); + $tenants = $this->tenantRepository->findAll(); $question = new Question('Which tenant should be configured?'); @@ -59,13 +64,31 @@ final protected function execute(InputInterface $input, OutputInterface $output) return Command::INVALID; } - $fallbackImageUrl = $io->ask('Enter fallback image url (fallbackImageUrl). Defaults to null.:'); + $question = new ConfirmationQuestion('Configure fallback image url (y/n)?', false); + + if ($helper->ask($input, $output, $question)) { + $fallbackImageUrl = $io->ask('Enter fallback image url (fallbackImageUrl). Defaults to null.:'); + + $tenant->setFallbackImageUrl($fallbackImageUrl); + } + + $question = new ConfirmationQuestion('Configure interactive slides (y/n)?', false); + + if ($helper->ask($input, $output, $question)) { + // Get available configurable interactive slide types. + $configurables = $this->interactiveService->getConfigurables(); + + foreach ($configurables as $configurable) { + $io->info(json_encode($configurable)); + } + + // Set required config (json) in tenant.interactiveSlidesConfig. + // Ask for configuring other interactiveSlide config. + } - $tenant->setFallbackImageUrl($fallbackImageUrl); $this->entityManager->flush(); $tenantKey = $tenant->getTenantKey(); - $io->success("Tenant $tenantKey has been configured."); return Command::SUCCESS; diff --git a/src/Controller/InteractiveSlideActionController.php b/src/Controller/InteractiveController.php similarity index 77% rename from src/Controller/InteractiveSlideActionController.php rename to src/Controller/InteractiveController.php index e1f86282..cb923f15 100644 --- a/src/Controller/InteractiveSlideActionController.php +++ b/src/Controller/InteractiveController.php @@ -5,16 +5,16 @@ namespace App\Controller; use App\Entity\Tenant\Slide; -use App\Service\InteractiveSlideService; +use App\Service\InteractiveService; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\AsController; #[AsController] -final readonly class InteractiveSlideActionController +final readonly class InteractiveController { public function __construct( - private InteractiveSlideService $interactiveSlideService + private InteractiveService $interactiveSlideService ) {} public function __invoke(Request $request, Slide $slide): JsonResponse diff --git a/src/Interactive/InteractiveInterface.php b/src/Interactive/InteractiveInterface.php new file mode 100644 index 00000000..578d54b5 --- /dev/null +++ b/src/Interactive/InteractiveInterface.php @@ -0,0 +1,9 @@ + 'dej']; + } + + public function performAction(): array + { + return []; + } +} diff --git a/src/Service/InteractiveService.php b/src/Service/InteractiveService.php new file mode 100644 index 00000000..83be4e95 --- /dev/null +++ b/src/Service/InteractiveService.php @@ -0,0 +1,33 @@ + $interactives */ + private readonly iterable $interactives, + ) {} + + public function performAction(Slide $slide, array $requestBody): array + { + return []; + } + + /** + * Get configurable interactive. + */ + public function getConfigurables(): array + { + $result = []; + + foreach ($this->interactives as $interactive) { + $result[$interactive::class] = $interactive->getConfigOptions(); + } + + return $result; + } +} diff --git a/src/Service/InteractiveSlideService.php b/src/Service/InteractiveSlideService.php deleted file mode 100644 index 7a972a84..00000000 --- a/src/Service/InteractiveSlideService.php +++ /dev/null @@ -1,13 +0,0 @@ - Date: Wed, 24 Jan 2024 10:51:40 +0100 Subject: [PATCH 03/27] 366: Fixed configuration of MicrosoftGraphQuickBook --- migrations/Version20240124092925.php | 32 +++++++++++++ src/Command/Tenant/ConfigureTenantCommand.php | 21 +++++--- src/Entity/Tenant/Interactive.php | 40 ++++++++++++++++ src/Interactive/MicrosoftGraphQuickBook.php | 30 ++++++++++++ src/Interactive/QuickBook.php | 17 ------- src/Repository/InteractiveRepository.php | 48 +++++++++++++++++++ src/Service/InteractiveService.php | 40 ++++++++++++++++ 7 files changed, 205 insertions(+), 23 deletions(-) create mode 100644 migrations/Version20240124092925.php create mode 100644 src/Entity/Tenant/Interactive.php create mode 100644 src/Interactive/MicrosoftGraphQuickBook.php delete mode 100644 src/Interactive/QuickBook.php create mode 100644 src/Repository/InteractiveRepository.php diff --git a/migrations/Version20240124092925.php b/migrations/Version20240124092925.php new file mode 100644 index 00000000..b1bac85b --- /dev/null +++ b/migrations/Version20240124092925.php @@ -0,0 +1,32 @@ +addSql('CREATE TABLE interactive (id BINARY(16) NOT NULL COMMENT \'(DC2Type:ulid)\', tenant_id BINARY(16) NOT NULL COMMENT \'(DC2Type:ulid)\', created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', modified_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', created_by VARCHAR(255) DEFAULT \'\' NOT NULL, modified_by VARCHAR(255) DEFAULT \'\' NOT NULL, configuration JSON DEFAULT NULL COMMENT \'(DC2Type:json)\', implementation_class VARCHAR(255) NOT NULL, INDEX IDX_3B5F8D379033212A (tenant_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE interactive ADD CONSTRAINT FK_3B5F8D379033212A FOREIGN KEY (tenant_id) REFERENCES tenant (id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE interactive DROP FOREIGN KEY FK_3B5F8D379033212A'); + $this->addSql('DROP TABLE interactive'); + } +} diff --git a/src/Command/Tenant/ConfigureTenantCommand.php b/src/Command/Tenant/ConfigureTenantCommand.php index 42601023..56d89f96 100644 --- a/src/Command/Tenant/ConfigureTenantCommand.php +++ b/src/Command/Tenant/ConfigureTenantCommand.php @@ -75,15 +75,24 @@ final protected function execute(InputInterface $input, OutputInterface $output) $question = new ConfirmationQuestion('Configure interactive slides (y/n)?', false); if ($helper->ask($input, $output, $question)) { - // Get available configurable interactive slide types. $configurables = $this->interactiveService->getConfigurables(); - foreach ($configurables as $configurable) { - $io->info(json_encode($configurable)); - } + foreach ($configurables as $interactiveClass => $configurable) { + $question = new ConfirmationQuestion('Configure '.$interactiveClass.' (y/n)?', false); + if ($helper->ask($input, $output, $question)) { + $io->info("Configuring ".$interactiveClass); + + $configuration = []; + + foreach ($configurable as $key => $data) { + $value = $io->ask($key . ' (' . $data['description'] . ')'); - // Set required config (json) in tenant.interactiveSlidesConfig. - // Ask for configuring other interactiveSlide config. + $configuration[$key] = $value; + } + + $this->interactiveService->saveConfiguration($tenant, (string)$interactiveClass, $configuration); + } + } } $this->entityManager->flush(); diff --git a/src/Entity/Tenant/Interactive.php b/src/Entity/Tenant/Interactive.php new file mode 100644 index 00000000..d8610605 --- /dev/null +++ b/src/Entity/Tenant/Interactive.php @@ -0,0 +1,40 @@ +configuration; + } + + public function setConfiguration(?array $configuration): static + { + $this->configuration = $configuration; + + return $this; + } + + public function getImplementationClass(): ?string + { + return $this->implementationClass; + } + + public function setImplementationClass(string $implementationClass): static + { + $this->implementationClass = $implementationClass; + + return $this; + } +} diff --git a/src/Interactive/MicrosoftGraphQuickBook.php b/src/Interactive/MicrosoftGraphQuickBook.php new file mode 100644 index 00000000..05dde109 --- /dev/null +++ b/src/Interactive/MicrosoftGraphQuickBook.php @@ -0,0 +1,30 @@ + [ + 'required' => true, + 'description' => 'The Microsoft Graph username that should perform the action.', + ], + 'password' => [ + 'required' => true, + 'description' => 'The password of the user.', + ], + ]; + } + + public function performAction(): array + { + return []; + } +} diff --git a/src/Interactive/QuickBook.php b/src/Interactive/QuickBook.php deleted file mode 100644 index 7d3d689f..00000000 --- a/src/Interactive/QuickBook.php +++ /dev/null @@ -1,17 +0,0 @@ - 'dej']; - } - - public function performAction(): array - { - return []; - } -} diff --git a/src/Repository/InteractiveRepository.php b/src/Repository/InteractiveRepository.php new file mode 100644 index 00000000..42809495 --- /dev/null +++ b/src/Repository/InteractiveRepository.php @@ -0,0 +1,48 @@ + + * + * @method Interactive|null find($id, $lockMode = null, $lockVersion = null) + * @method Interactive|null findOneBy(array $criteria, array $orderBy = null) + * @method Interactive[] findAll() + * @method Interactive[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class InteractiveRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Interactive::class); + } + +// /** +// * @return Interactive[] Returns an array of Interactive objects +// */ +// public function findByExampleField($value): array +// { +// return $this->createQueryBuilder('i') +// ->andWhere('i.exampleField = :val') +// ->setParameter('val', $value) +// ->orderBy('i.id', 'ASC') +// ->setMaxResults(10) +// ->getQuery() +// ->getResult() +// ; +// } + +// public function findOneBySomeField($value): ?Interactive +// { +// return $this->createQueryBuilder('i') +// ->andWhere('i.exampleField = :val') +// ->setParameter('val', $value) +// ->getQuery() +// ->getOneOrNullResult() +// ; +// } +} diff --git a/src/Service/InteractiveService.php b/src/Service/InteractiveService.php index 83be4e95..ed8f4b85 100644 --- a/src/Service/InteractiveService.php +++ b/src/Service/InteractiveService.php @@ -2,14 +2,26 @@ namespace App\Service; +use App\Entity\Tenant; +use App\Entity\Tenant\Interactive; use App\Entity\Tenant\Slide; +use App\Entity\User; use App\Interactive\InteractiveInterface; +use App\Repository\InteractiveRepository; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Bundle\SecurityBundle\Security; +/** + * Service for handling Slide interactions. + */ class InteractiveService { public function __construct( /** @var array $interactives */ private readonly iterable $interactives, + private readonly InteractiveRepository $interactiveRepository, + private readonly Security $security, + private readonly EntityManagerInterface $entityManager, ) {} public function performAction(Slide $slide, array $requestBody): array @@ -30,4 +42,32 @@ public function getConfigurables(): array return $result; } + + public function getInteractive(Tenant $tenant, string $implementationClass): ?Interactive + { + return $this->interactiveRepository->findOneBy([ + 'implementationClass' => $implementationClass, + 'tenant' => $tenant, + ]); + } + + public function saveConfiguration(Tenant $tenant, string $implementationClass, array $configuration): void + { + $entry = $this->interactiveRepository->findOneBy([ + 'implementationClass' => $implementationClass, + 'tenant' => $tenant, + ]); + + if ($entry === null) { + $entry = new Interactive(); + $entry->setTenant($tenant); + $entry->setImplementationClass($implementationClass); + + $this->entityManager->persist($entry); + } + + $entry->setConfiguration($configuration); + + $this->entityManager->flush(); + } } From 57f54181746afa89a16250763cc07a9d2db5a435 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 19 Feb 2024 14:02:45 +0100 Subject: [PATCH 04/27] #366: Added logic for interacting from a slide --- config/api_platform/slide.yaml | 2 + config/packages/dev/monolog.yaml | 2 +- src/Controller/InteractiveController.php | 4 +- src/Dto/InteractiveSlideActionInput.php | 1 + src/Exceptions/InteractiveException.php | 8 +++ src/Feed/KobaFeedType.php | 2 +- src/Interactive/Interaction.php | 17 ++++++ src/Interactive/InteractiveInterface.php | 4 +- src/Interactive/MicrosoftGraphQuickBook.php | 27 ++++++++- src/Service/InteractiveService.php | 64 +++++++++++++++++---- 10 files changed, 115 insertions(+), 16 deletions(-) create mode 100644 src/Exceptions/InteractiveException.php create mode 100644 src/Interactive/Interaction.php diff --git a/config/api_platform/slide.yaml b/config/api_platform/slide.yaml index 489bf2a0..4475f522 100644 --- a/config/api_platform/slide.yaml +++ b/config/api_platform/slide.yaml @@ -115,6 +115,8 @@ resources: uriTemplate: '/slides/{id}/action' controller: App\Controller\InteractiveController openapiContext: + description: Perform an action for a slide. + summary: Performs an action for a slide. tags: - Slides parameters: diff --git a/config/packages/dev/monolog.yaml b/config/packages/dev/monolog.yaml index b1998da1..b45b2d02 100644 --- a/config/packages/dev/monolog.yaml +++ b/config/packages/dev/monolog.yaml @@ -4,7 +4,7 @@ monolog: type: stream path: "%kernel.logs_dir%/%kernel.environment%.log" level: debug - channels: ["!event"] + channels: ["!event", "!doctrine"] # uncomment to get logging in your browser # you may have to allow bigger header sizes in your Web server configuration #firephp: diff --git a/src/Controller/InteractiveController.php b/src/Controller/InteractiveController.php index cb923f15..b082a89b 100644 --- a/src/Controller/InteractiveController.php +++ b/src/Controller/InteractiveController.php @@ -21,6 +21,8 @@ public function __invoke(Request $request, Slide $slide): JsonResponse { $requestBody = $request->toArray(); - return new JsonResponse($this->interactiveSlideService->performAction($slide, $requestBody)); + $interaction = $this->interactiveSlideService->parseRequestBody($requestBody); + + return new JsonResponse($this->interactiveSlideService->performAction($slide, $interaction)); } } diff --git a/src/Dto/InteractiveSlideActionInput.php b/src/Dto/InteractiveSlideActionInput.php index d9e7d60a..288f3351 100644 --- a/src/Dto/InteractiveSlideActionInput.php +++ b/src/Dto/InteractiveSlideActionInput.php @@ -5,4 +5,5 @@ class InteractiveSlideActionInput { public string $action; + public array $data; } diff --git a/src/Exceptions/InteractiveException.php b/src/Exceptions/InteractiveException.php new file mode 100644 index 00000000..95254329 --- /dev/null +++ b/src/Exceptions/InteractiveException.php @@ -0,0 +1,8 @@ +implementationClass = $implementationClass; + $this->action = $action; + $this->data = $data; + } +} diff --git a/src/Interactive/InteractiveInterface.php b/src/Interactive/InteractiveInterface.php index 578d54b5..6a8b9983 100644 --- a/src/Interactive/InteractiveInterface.php +++ b/src/Interactive/InteractiveInterface.php @@ -2,8 +2,10 @@ namespace App\Interactive; +use App\Entity\Tenant\Slide; + interface InteractiveInterface { public function getConfigOptions(): array; - public function performAction(): array; + public function performAction(Slide $slide, Interaction $interaction): array; } diff --git a/src/Interactive/MicrosoftGraphQuickBook.php b/src/Interactive/MicrosoftGraphQuickBook.php index 05dde109..a8618b15 100644 --- a/src/Interactive/MicrosoftGraphQuickBook.php +++ b/src/Interactive/MicrosoftGraphQuickBook.php @@ -2,6 +2,9 @@ namespace App\Interactive; +use App\Entity\Tenant\Slide; +use App\Exceptions\InteractiveException; + /** * Interactive slide that allows for performing quick bookings of resources. * @@ -9,6 +12,9 @@ */ class MicrosoftGraphQuickBook implements InteractiveInterface { + private const ACTION_GET_QUICK_BOOK_OPTIONS = 'ACTION_GET_QUICK_BOOK_OPTIONS'; + private const ACTION_QUICK_BOOK = 'ACTION_QUICK_BOOK'; + public function getConfigOptions(): array { return [ @@ -23,8 +29,25 @@ public function getConfigOptions(): array ]; } - public function performAction(): array + /** + * @throws InteractiveException + */ + public function performAction(Slide $slide, Interaction $interaction): array + { + return match ($interaction->action) { + self::ACTION_GET_QUICK_BOOK_OPTIONS => $this->getQuickBookOptions($slide, $interaction), + self::ACTION_QUICK_BOOK => $this->quickBook($slide, $interaction), + default => throw new InteractiveException("Action not allowed"), + }; + } + + private function getQuickBookOptions(Slide $slide, Interaction $interaction): array + { + return ["test1" => "test2"]; + } + + private function quickBook(Slide $slide, Interaction $interaction): array { - return []; + return ["test3" => "test4"]; } } diff --git a/src/Service/InteractiveService.php b/src/Service/InteractiveService.php index ed8f4b85..0dc6afdb 100644 --- a/src/Service/InteractiveService.php +++ b/src/Service/InteractiveService.php @@ -2,10 +2,13 @@ namespace App\Service; +use App\Entity\ScreenUser; use App\Entity\Tenant; use App\Entity\Tenant\Interactive; use App\Entity\Tenant\Slide; use App\Entity\User; +use App\Exceptions\InteractiveException; +use App\Interactive\Interaction; use App\Interactive\InteractiveInterface; use App\Repository\InteractiveRepository; use Doctrine\ORM\EntityManagerInterface; @@ -14,19 +17,60 @@ /** * Service for handling Slide interactions. */ -class InteractiveService +readonly class InteractiveService { public function __construct( /** @var array $interactives */ - private readonly iterable $interactives, - private readonly InteractiveRepository $interactiveRepository, - private readonly Security $security, - private readonly EntityManagerInterface $entityManager, - ) {} + private iterable $interactiveImplementations, + private InteractiveRepository $interactiveRepository, + private EntityManagerInterface $entityManager, + private Security $security, + ) { + } + + public function parseRequestBody(array $requestBody): Interaction + { + $implementationClass = $requestBody['implementationClass']; + $action = $requestBody['action']; + $data = $requestBody['data']; + + // TODO: Test for required. + + return new Interaction($implementationClass, $action, $data); + } - public function performAction(Slide $slide, array $requestBody): array + /** + * @throws InteractiveException + */ + public function performAction(Slide $slide, Interaction $interaction): array { - return []; + $implementationClass = $interaction->implementationClass; + + $currentUser = $this->security->getUser(); + + // TODO: Remove standard user from check. + if (!$currentUser instanceof ScreenUser && !$currentUser instanceof User) { + throw new InteractiveException("User is not supported"); + } + + $tenant = $currentUser->getActiveTenant(); + + $interactive = $this->getInteractive($tenant, $implementationClass); + + if ($interactive === null) { + throw new InteractiveException("Interactive not found"); + } + + $asArray = [...$this->interactiveImplementations]; + $interactiveImplementations = array_filter($asArray, fn($implementation) => $implementation::class === $implementationClass); + + if (count($interactiveImplementations) === 0) { + throw new InteractiveException("Interactive implementation class not found"); + } + + $interactiveImplementation = $interactiveImplementations[0]; + + return $interactiveImplementation->performAction($slide, $interaction); } /** @@ -36,8 +80,8 @@ public function getConfigurables(): array { $result = []; - foreach ($this->interactives as $interactive) { - $result[$interactive::class] = $interactive->getConfigOptions(); + foreach ($this->interactiveImplementations as $interactiveImplementation) { + $result[$interactiveImplementation::class] = $interactiveImplementation->getConfigOptions(); } return $result; From 29da38548b2209eea73da719680e1ac8262acab2 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Fri, 23 Feb 2024 10:09:59 +0100 Subject: [PATCH 05/27] #366: Continued work on interactive slides --- src/Entity/Tenant/Interactive.php | 2 + ...Interaction.php => InteractionRequest.php} | 2 +- src/Interactive/InteractiveInterface.php | 2 +- src/Interactive/MicrosoftGraphQuickBook.php | 140 +++++++++++++++++- src/Repository/InteractiveRepository.php | 25 ---- src/Service/InteractiveService.php | 44 ++++-- .../MicrosoftGraphQuickBookTest.php | 10 ++ 7 files changed, 177 insertions(+), 48 deletions(-) rename src/Interactive/{Interaction.php => InteractionRequest.php} (91%) create mode 100644 tests/Interactive/MicrosoftGraphQuickBookTest.php diff --git a/src/Entity/Tenant/Interactive.php b/src/Entity/Tenant/Interactive.php index d8610605..4b33c218 100644 --- a/src/Entity/Tenant/Interactive.php +++ b/src/Entity/Tenant/Interactive.php @@ -4,10 +4,12 @@ use App\Repository\InteractiveRepository; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Ignore; #[ORM\Entity(repositoryClass: InteractiveRepository::class)] class Interactive extends AbstractTenantScopedEntity { + #[Ignore] #[ORM\Column(nullable: true)] private ?array $configuration = null; diff --git a/src/Interactive/Interaction.php b/src/Interactive/InteractionRequest.php similarity index 91% rename from src/Interactive/Interaction.php rename to src/Interactive/InteractionRequest.php index e6db42cc..5c167d8f 100644 --- a/src/Interactive/Interaction.php +++ b/src/Interactive/InteractionRequest.php @@ -2,7 +2,7 @@ namespace App\Interactive; -readonly class Interaction +readonly class InteractionRequest { public string $implementationClass; public string $action; diff --git a/src/Interactive/InteractiveInterface.php b/src/Interactive/InteractiveInterface.php index 6a8b9983..6991a18e 100644 --- a/src/Interactive/InteractiveInterface.php +++ b/src/Interactive/InteractiveInterface.php @@ -7,5 +7,5 @@ interface InteractiveInterface { public function getConfigOptions(): array; - public function performAction(Slide $slide, Interaction $interaction): array; + public function performAction(Slide $slide, InteractionRequest $interactionRequest): array; } diff --git a/src/Interactive/MicrosoftGraphQuickBook.php b/src/Interactive/MicrosoftGraphQuickBook.php index a8618b15..0bb24229 100644 --- a/src/Interactive/MicrosoftGraphQuickBook.php +++ b/src/Interactive/MicrosoftGraphQuickBook.php @@ -3,7 +3,11 @@ namespace App\Interactive; use App\Entity\Tenant\Slide; +use App\Entity\User; use App\Exceptions\InteractiveException; +use App\Service\InteractiveService; +use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Contracts\HttpClient\HttpClientInterface; /** * Interactive slide that allows for performing quick bookings of resources. @@ -14,10 +18,31 @@ class MicrosoftGraphQuickBook implements InteractiveInterface { private const ACTION_GET_QUICK_BOOK_OPTIONS = 'ACTION_GET_QUICK_BOOK_OPTIONS'; private const ACTION_QUICK_BOOK = 'ACTION_QUICK_BOOK'; + private const MICROSOFT_GRAPH_ENDPOINT = 'https://graph.microsoft.com/v1.0'; + + // see https://docs.microsoft.com/en-us/graph/api/resources/datetimetimezone?view=graph-rest-1.0 + // example 2019-03-15T09:00:00 + public const GRAPH_DATE_FORMAT = 'Y-m-d\TH:i:s'; + + public function __construct( + private readonly InteractiveService $interactiveService, + private readonly Security $security, + private readonly HttpClientInterface $client, + ) + { + } public function getConfigOptions(): array { return [ + 'tenantId' => [ + 'required' => true, + 'description' => 'The tenant id of the App' + ], + 'clientId' => [ + 'required' => true, + 'description' => 'The client id of the App' + ], 'username' => [ 'required' => true, 'description' => 'The Microsoft Graph username that should perform the action.', @@ -32,22 +57,125 @@ public function getConfigOptions(): array /** * @throws InteractiveException */ - public function performAction(Slide $slide, Interaction $interaction): array + public function performAction(Slide $slide, InteractionRequest $interactionRequest): array { - return match ($interaction->action) { - self::ACTION_GET_QUICK_BOOK_OPTIONS => $this->getQuickBookOptions($slide, $interaction), - self::ACTION_QUICK_BOOK => $this->quickBook($slide, $interaction), + return match ($interactionRequest->action) { + self::ACTION_GET_QUICK_BOOK_OPTIONS => $this->getQuickBookOptions($slide, $interactionRequest), + self::ACTION_QUICK_BOOK => $this->quickBook($slide, $interactionRequest), default => throw new InteractiveException("Action not allowed"), }; } - private function getQuickBookOptions(Slide $slide, Interaction $interaction): array + /** + * @throws \Throwable + */ + private function authenticate(array $configuration): string + { + $url = 'https://login.microsoftonline.com/'.$configuration['tenantId'].'/oauth2/v2.0/token'; + + $response = $this->client->request("POST", $url, [ + 'body' => [ + 'client_id' => $configuration['clientId'], + 'scope' => 'https://graph.microsoft.com/.default', + 'username' => $configuration['username'], + 'password' => $configuration['password'], + 'grant_type' => 'password', + ], + ]); + + $data = $response->toArray(); + + // TODO: cache response. + + return $data['access_token']; + } + + /** + * @throws \Throwable + */ + private function getQuickBookOptions(Slide $slide, InteractionRequest $interactionRequest): array { + /** @var User $user */ + $user = $this->security->getUser(); + $tenant = $user->getActiveTenant(); + + $interactive = $this->interactiveService->getInteractive($tenant, $interactionRequest->implementationClass); + + // TODO: Custom exceptions. + + if ($interactive === null) { + throw new \Exception("InteractiveNotFound"); + } + + $configuration = $interactive->getConfiguration(); + + if ($configuration === null) { + throw new \Exception("InteractiveNoConfiguration"); + } + + $token = $this->authenticate($configuration); + + $now = new \DateTime(); + $nowPlusOneHour = (new \DateTime())->add(new \DateInterval('PT1H')); + + $schedule = $this->getBusyIntervals($token, $interactionRequest->data['resource'], $now, $nowPlusOneHour); + + print_r($schedule);die(); + return ["test1" => "test2"]; } - private function quickBook(Slide $slide, Interaction $interaction): array + private function quickBook(Slide $slide, InteractionRequest $interaction): array { return ["test3" => "test4"]; } + + /** + * @see https://docs.microsoft.com/en-us/graph/api/calendar-getschedule?view=graph-rest-1.0&tabs=http + */ + public function getBusyIntervals(string $token, string $resource, \DateTime $startTime, \DateTime $endTime): array + { + $body = [ + 'schedules' => [$resource], + 'availabilityViewInterval' => '15', + 'startTime' => [ + 'dateTime' => $startTime->setTimezone(new \DateTimeZone('UTC'))->format(self::GRAPH_DATE_FORMAT), + 'timeZone' => 'UTC', + ], + 'endTime' => [ + 'dateTime' => $endTime->setTimezone(new \DateTimeZone('UTC'))->format(self::GRAPH_DATE_FORMAT), + 'timeZone' => 'UTC', + ], + ]; + + $response = $this->client->request("POST", self::MICROSOFT_GRAPH_ENDPOINT."/me/calendar/getSchedule", [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $token, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ], + 'body' => json_encode($body), + ]); + + $data = $response->toArray(); + + $scheduleData = $data['value']; + + $result = []; + + foreach ($scheduleData as $schedule) { + $scheduleResult = []; + + foreach ($schedule['scheduleItems'] as $scheduleItem) { + $scheduleResult[] = [ + 'startTime' => $scheduleItem['start'], + 'endTime' => $scheduleItem['end'], + ]; + } + + $result[$schedule['scheduleId']] = $scheduleResult; + } + + return $result; + } } diff --git a/src/Repository/InteractiveRepository.php b/src/Repository/InteractiveRepository.php index 42809495..e9e2fb2b 100644 --- a/src/Repository/InteractiveRepository.php +++ b/src/Repository/InteractiveRepository.php @@ -20,29 +20,4 @@ public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Interactive::class); } - -// /** -// * @return Interactive[] Returns an array of Interactive objects -// */ -// public function findByExampleField($value): array -// { -// return $this->createQueryBuilder('i') -// ->andWhere('i.exampleField = :val') -// ->setParameter('val', $value) -// ->orderBy('i.id', 'ASC') -// ->setMaxResults(10) -// ->getQuery() -// ->getResult() -// ; -// } - -// public function findOneBySomeField($value): ?Interactive -// { -// return $this->createQueryBuilder('i') -// ->andWhere('i.exampleField = :val') -// ->setParameter('val', $value) -// ->getQuery() -// ->getOneOrNullResult() -// ; -// } } diff --git a/src/Service/InteractiveService.php b/src/Service/InteractiveService.php index 0dc6afdb..550e6a7a 100644 --- a/src/Service/InteractiveService.php +++ b/src/Service/InteractiveService.php @@ -8,7 +8,7 @@ use App\Entity\Tenant\Slide; use App\Entity\User; use App\Exceptions\InteractiveException; -use App\Interactive\Interaction; +use App\Interactive\InteractionRequest; use App\Interactive\InteractiveInterface; use App\Repository\InteractiveRepository; use Doctrine\ORM\EntityManagerInterface; @@ -28,7 +28,12 @@ public function __construct( ) { } - public function parseRequestBody(array $requestBody): Interaction + /** + * Create InteractionRequest from the request body. + * + * @param array $requestBody The request body from the http request. + */ + public function parseRequestBody(array $requestBody): InteractionRequest { $implementationClass = $requestBody['implementationClass']; $action = $requestBody['action']; @@ -36,19 +41,20 @@ public function parseRequestBody(array $requestBody): Interaction // TODO: Test for required. - return new Interaction($implementationClass, $action, $data); + return new InteractionRequest($implementationClass, $action, $data); } /** + * Perform the given InteractionRequest with the given Slide. + * * @throws InteractiveException */ - public function performAction(Slide $slide, Interaction $interaction): array + public function performAction(Slide $slide, InteractionRequest $interactionRequest): array { - $implementationClass = $interaction->implementationClass; + $implementationClass = $interactionRequest->implementationClass; $currentUser = $this->security->getUser(); - // TODO: Remove standard user from check. if (!$currentUser instanceof ScreenUser && !$currentUser instanceof User) { throw new InteractiveException("User is not supported"); } @@ -61,16 +67,9 @@ public function performAction(Slide $slide, Interaction $interaction): array throw new InteractiveException("Interactive not found"); } - $asArray = [...$this->interactiveImplementations]; - $interactiveImplementations = array_filter($asArray, fn($implementation) => $implementation::class === $implementationClass); + $interactiveImplementation = $this->getImplementation($interactive->getImplementationClass()); - if (count($interactiveImplementations) === 0) { - throw new InteractiveException("Interactive implementation class not found"); - } - - $interactiveImplementation = $interactiveImplementations[0]; - - return $interactiveImplementation->performAction($slide, $interaction); + return $interactiveImplementation->performAction($slide, $interactionRequest); } /** @@ -87,6 +86,21 @@ public function getConfigurables(): array return $result; } + /** + * @throws InteractiveException + */ + public function getImplementation(?string $implementationClass): InteractiveInterface + { + $asArray = [...$this->interactiveImplementations]; + $interactiveImplementations = array_filter($asArray, fn($implementation) => $implementation::class === $implementationClass); + + if (count($interactiveImplementations) === 0) { + throw new InteractiveException("Interactive implementation class not found"); + } + + return $interactiveImplementations[0]; + } + public function getInteractive(Tenant $tenant, string $implementationClass): ?Interactive { return $this->interactiveRepository->findOneBy([ diff --git a/tests/Interactive/MicrosoftGraphQuickBookTest.php b/tests/Interactive/MicrosoftGraphQuickBookTest.php new file mode 100644 index 00000000..0ca28aac --- /dev/null +++ b/tests/Interactive/MicrosoftGraphQuickBookTest.php @@ -0,0 +1,10 @@ + Date: Mon, 4 Mar 2024 12:23:06 +0100 Subject: [PATCH 06/27] #366: Continued work on implementing calls to get available intervals --- .env | 16 ++- .env.test | 8 +- config/services.yaml | 5 + migrations/Version20240227095949.php | 31 +++++ src/Command/Tenant/ConfigureTenantCommand.php | 6 +- src/Controller/AuthOidcController.php | 1 + src/Controller/InteractiveController.php | 8 +- src/Dto/InteractiveSlideActionInput.php | 2 + src/Entity/Tenant/Interactive.php | 2 + src/Exceptions/InteractiveException.php | 3 +- src/Interactive/InteractionRequest.php | 2 + src/Interactive/InteractiveInterface.php | 12 +- src/Interactive/MicrosoftGraphQuickBook.php | 118 ++++++++++++------ src/Repository/InteractiveRepository.php | 2 + src/Service/InteractiveService.php | 73 +++++++---- src/Service/KeyVaultService.php | 43 +++++++ .../MicrosoftGraphQuickBookTest.php | 30 +++++ tests/Service/InteractiveServiceTest.php | 111 ++++++++++++++++ 18 files changed, 394 insertions(+), 79 deletions(-) create mode 100644 migrations/Version20240227095949.php create mode 100644 src/Service/KeyVaultService.php create mode 100644 tests/Service/InteractiveServiceTest.php diff --git a/.env b/.env index 1cb563d6..c62c7b33 100644 --- a/.env +++ b/.env @@ -41,19 +41,25 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' ###> App ### APP_DEFAULT_DATE_FORMAT='Y-m-d\TH:i:s.v\Z' APP_ACTIVATION_CODE_EXPIRE_INTERNAL=P2D +APP_KEY_VAULT_SOURCE=ENVIRONMENT +APP_KEY_VAULT_JSON="{}" ###< App ### ###> lexik/jwt-authentication-bundle ### JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem JWT_PASSPHRASE=APP_JWT_PASSPHRASE -JWT_TOKEN_TTL=3600 # 1 hour -JWT_SCREEN_TOKEN_TTL=1296000 # 15 days +# 1 hour +JWT_TOKEN_TTL=3600 +# 15 days +JWT_SCREEN_TOKEN_TTL=1296000 ###< lexik/jwt-authentication-bundle ### ###> gesdinet/jwt-refresh-token-bundle ### -JWT_REFRESH_TOKEN_TTL=7200 # 2 hours -JWT_SCREEN_REFRESH_TOKEN_TTL=2592000 # 30 days +# 2 hours +JWT_REFRESH_TOKEN_TTL=7200 +# 30 days +JWT_SCREEN_REFRESH_TOKEN_TTL=2592000 ###< gesdinet/jwt-refresh-token-bundle ### ###> itk-dev/openid-connect-bundle ### @@ -75,6 +81,7 @@ EXTERNAL_OIDC_REDIRECT_URI=EXTERNAL_OIDC_REDIRECT_URI EXTERNAL_OIDC_LEEWAY=30 EXTERNAL_OIDC_HASH_SALT= EXTERNAL_OIDC_CLAIM_ID=signinname +###< itk-dev/openid-connect-bundle ### # cli redirect url OIDC_CLI_REDIRECT=APP_CLI_REDIRECT_URI @@ -84,3 +91,4 @@ OIDC_CLI_REDIRECT=APP_CLI_REDIRECT_URI REDIS_CACHE_PREFIX=DisplayApiService REDIS_CACHE_DSN=redis://redis:6379/0 ###< redis ### + diff --git a/.env.test b/.env.test index 20477d67..d1803367 100644 --- a/.env.test +++ b/.env.test @@ -11,11 +11,11 @@ DATABASE_URL="mysql://root:password@mariadb:3306/db_test?serverVersion=mariadb-1 JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem JWT_PASSPHRASE=APP_JWT_PASSPHRASE -JWT_TOKEN_TTL=1800 # 30 min -JWT_SCREEN_TOKEN_TTL=43200 # 12 hours +JWT_TOKEN_TTL=1800 +JWT_SCREEN_TOKEN_TTL=43200 ###< lexik/jwt-authentication-bundle ### ###> gesdinet/jwt-refresh-token-bundle ### -JWT_REFRESH_TOKEN_TTL=3600 # 1 hour -JWT_SCREEN_REFRESH_TOKEN_TTL=86400 # 24 hours +JWT_REFRESH_TOKEN_TTL=3600 +JWT_SCREEN_REFRESH_TOKEN_TTL=86400 ###< gesdinet/jwt-refresh-token-bundle ### diff --git a/config/services.yaml b/config/services.yaml index 95418739..2a87ff5c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -51,6 +51,11 @@ services: Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface: '@Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationFailureHandler' Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface: '@Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationSuccessHandler' + App\Service\KeyVaultService: + arguments: + $keyVaultSource: '%env(string:APP_KEY_VAULT_SOURCE)%' + $keyVaultArray: '%env(json:APP_KEY_VAULT_JSON)%' + App\Service\UserService: arguments: $hashSalt: '%env(EXTERNAL_OIDC_HASH_SALT)%' diff --git a/migrations/Version20240227095949.php b/migrations/Version20240227095949.php new file mode 100644 index 00000000..ee34c0fc --- /dev/null +++ b/migrations/Version20240227095949.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE interactive ADD version INT DEFAULT 1 NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE interactive DROP version'); + } +} diff --git a/src/Command/Tenant/ConfigureTenantCommand.php b/src/Command/Tenant/ConfigureTenantCommand.php index 56d89f96..ff594b41 100644 --- a/src/Command/Tenant/ConfigureTenantCommand.php +++ b/src/Command/Tenant/ConfigureTenantCommand.php @@ -80,17 +80,17 @@ final protected function execute(InputInterface $input, OutputInterface $output) foreach ($configurables as $interactiveClass => $configurable) { $question = new ConfirmationQuestion('Configure '.$interactiveClass.' (y/n)?', false); if ($helper->ask($input, $output, $question)) { - $io->info("Configuring ".$interactiveClass); + $io->info('Configuring '.$interactiveClass); $configuration = []; foreach ($configurable as $key => $data) { - $value = $io->ask($key . ' (' . $data['description'] . ')'); + $value = $io->ask($key.' ('.$data['description'].')'); $configuration[$key] = $value; } - $this->interactiveService->saveConfiguration($tenant, (string)$interactiveClass, $configuration); + $this->interactiveService->saveConfiguration($tenant, (string) $interactiveClass, $configuration); } } } diff --git a/src/Controller/AuthOidcController.php b/src/Controller/AuthOidcController.php index c1d46001..0a6bd8de 100644 --- a/src/Controller/AuthOidcController.php +++ b/src/Controller/AuthOidcController.php @@ -12,6 +12,7 @@ use Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationSuccessHandler; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Session\SessionInterface; diff --git a/src/Controller/InteractiveController.php b/src/Controller/InteractiveController.php index b082a89b..7961e402 100644 --- a/src/Controller/InteractiveController.php +++ b/src/Controller/InteractiveController.php @@ -6,6 +6,7 @@ use App\Entity\Tenant\Slide; use App\Service\InteractiveService; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\AsController; @@ -14,7 +15,8 @@ final readonly class InteractiveController { public function __construct( - private InteractiveService $interactiveSlideService + private InteractiveService $interactiveSlideService, + private Security $security, ) {} public function __invoke(Request $request, Slide $slide): JsonResponse @@ -23,6 +25,8 @@ public function __invoke(Request $request, Slide $slide): JsonResponse $interaction = $this->interactiveSlideService->parseRequestBody($requestBody); - return new JsonResponse($this->interactiveSlideService->performAction($slide, $interaction)); + $user = $this->security->getUser(); + + return new JsonResponse($this->interactiveSlideService->performAction($user, $slide, $interaction)); } } diff --git a/src/Dto/InteractiveSlideActionInput.php b/src/Dto/InteractiveSlideActionInput.php index 288f3351..c0a9c460 100644 --- a/src/Dto/InteractiveSlideActionInput.php +++ b/src/Dto/InteractiveSlideActionInput.php @@ -1,5 +1,7 @@ [ 'required' => true, - 'description' => 'The tenant id of the App' + 'description' => 'The key in the KeyVault for the tenant id of the App', ], 'clientId' => [ 'required' => true, - 'description' => 'The client id of the App' + 'description' => 'The key in the KeyVault for the client id of the App', ], 'username' => [ 'required' => true, - 'description' => 'The Microsoft Graph username that should perform the action.', + 'description' => 'The key in the KeyVault for the Microsoft Graph username that should perform the action.', ], 'password' => [ 'required' => true, - 'description' => 'The password of the user.', + 'description' => 'The key in the KeyVault for the password of the user.', ], ]; } - /** - * @throws InteractiveException - */ - public function performAction(Slide $slide, InteractionRequest $interactionRequest): array + public function performAction(UserInterface $user, Slide $slide, InteractionRequest $interactionRequest): array { return match ($interactionRequest->action) { self::ACTION_GET_QUICK_BOOK_OPTIONS => $this->getQuickBookOptions($slide, $interactionRequest), self::ACTION_QUICK_BOOK => $this->quickBook($slide, $interactionRequest), - default => throw new InteractiveException("Action not allowed"), + default => throw new InteractiveException('Action not allowed'), }; } @@ -71,15 +76,24 @@ public function performAction(Slide $slide, InteractionRequest $interactionReque */ private function authenticate(array $configuration): string { - $url = 'https://login.microsoftonline.com/'.$configuration['tenantId'].'/oauth2/v2.0/token'; + $tenantId = $this->keyValueService->getValue($configuration['tenantId']); + $clientId = $this->keyValueService->getValue($configuration['clientId']); + $username = $this->keyValueService->getValue($configuration['username']); + $password = $this->keyValueService->getValue($configuration['password']); - $response = $this->client->request("POST", $url, [ + if (4 !== count(array_filter([$tenantId, $clientId, $username, $password]))) { + throw new \Exception('tenantId, clientId, username, password must all be set.'); + } + + $url = self::LOGIN_ENDPOINT.$tenantId.self::OAUTH_PATH; + + $response = $this->client->request('POST', $url, [ 'body' => [ - 'client_id' => $configuration['clientId'], - 'scope' => 'https://graph.microsoft.com/.default', - 'username' => $configuration['username'], - 'password' => $configuration['password'], - 'grant_type' => 'password', + 'client_id' => $clientId, + 'scope' => self::SCOPE, + 'username' => $username, + 'password' => $password, + 'grant_type' => self::GRANT_TYPE, ], ]); @@ -103,31 +117,55 @@ private function getQuickBookOptions(Slide $slide, InteractionRequest $interacti // TODO: Custom exceptions. - if ($interactive === null) { - throw new \Exception("InteractiveNotFound"); + if (null === $interactive) { + throw new \Exception('InteractiveNotFound'); } $configuration = $interactive->getConfiguration(); - if ($configuration === null) { - throw new \Exception("InteractiveNoConfiguration"); + if (null === $configuration) { + throw new \Exception('InteractiveNoConfiguration'); } $token = $this->authenticate($configuration); - $now = new \DateTime(); - $nowPlusOneHour = (new \DateTime())->add(new \DateInterval('PT1H')); + $start = (new \DateTime())->add(new \DateInterval('PT1M'))->setTimezone(new \DateTimeZone('UTC')); + $startPlus15Minutes = (clone $start)->add(new \DateInterval('PT15M'))->setTimezone(new \DateTimeZone('UTC')); + $startPlus30Minutes = (clone $start)->add(new \DateInterval('PT30M'))->setTimezone(new \DateTimeZone('UTC')); + $startPlus1Hour = (clone $start)->add(new \DateInterval('PT1H'))->setTimezone(new \DateTimeZone('UTC')); - $schedule = $this->getBusyIntervals($token, $interactionRequest->data['resource'], $now, $nowPlusOneHour); + $schedule = $this->getBusyIntervals($token, $interactionRequest->data['resource'], $start, $startPlus1Hour); - print_r($schedule);die(); + $startFormatted = $start->format('c'); + $startPlus15MinutesFormatted = $startPlus15Minutes->format('c'); + $startPlus30MinutesFormatted = $startPlus30Minutes->format('c'); + $startPlus1HourFormatted = $startPlus1Hour->format('c'); - return ["test1" => "test2"]; + return [ + [ + 'title' => '15 min', + 'from' => $startFormatted, + 'to' => $startPlus15MinutesFormatted, + 'available' => $this->intervalFree($schedule, $start, $startPlus15Minutes), + ], + [ + 'title' => '30 min', + 'from' => $startFormatted, + 'to' => $startPlus30MinutesFormatted, + 'available' => $this->intervalFree($schedule, $start, $startPlus30Minutes), + ], + [ + 'title' => '60 min', + 'from' => $startFormatted, + 'to' => $startPlus1HourFormatted, + 'available' => $this->intervalFree($schedule, $start, $startPlus1Hour), + ], + ]; } private function quickBook(Slide $slide, InteractionRequest $interaction): array { - return ["test3" => "test4"]; + return ['test3' => 'test4']; } /** @@ -148,9 +186,9 @@ public function getBusyIntervals(string $token, string $resource, \DateTime $sta ], ]; - $response = $this->client->request("POST", self::MICROSOFT_GRAPH_ENDPOINT."/me/calendar/getSchedule", [ + $response = $this->client->request('POST', self::ENDPOINT.'/me/calendar/getSchedule', [ 'headers' => [ - 'Authorization' => 'Bearer ' . $token, + 'Authorization' => 'Bearer '.$token, 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], @@ -164,18 +202,24 @@ public function getBusyIntervals(string $token, string $resource, \DateTime $sta $result = []; foreach ($scheduleData as $schedule) { - $scheduleResult = []; - foreach ($schedule['scheduleItems'] as $scheduleItem) { - $scheduleResult[] = [ + $eventStartArray = $scheduleItem['start']; + $eventEndArray = $scheduleItem['end']; + + $p = 1; + + $result[] = [ 'startTime' => $scheduleItem['start'], 'endTime' => $scheduleItem['end'], ]; } - - $result[$schedule['scheduleId']] = $scheduleResult; } return $result; } + + private function intervalFree(array $schedule, \DateTime $from , \DateTime $to): bool + { + return false; + } } diff --git a/src/Repository/InteractiveRepository.php b/src/Repository/InteractiveRepository.php index e9e2fb2b..5a7dd61d 100644 --- a/src/Repository/InteractiveRepository.php +++ b/src/Repository/InteractiveRepository.php @@ -1,5 +1,7 @@ $interactives */ - private iterable $interactiveImplementations, - private InteractiveRepository $interactiveRepository, + private iterable $interactiveImplementations, + private InteractiveRepository $interactiveRepository, private EntityManagerInterface $entityManager, - private Security $security, - ) { - } + ) {} /** * Create InteractionRequest from the request body. * - * @param array $requestBody The request body from the http request. + * @param array $requestBody the request body from the http request + * + * @throws InteractiveException */ public function parseRequestBody(array $requestBody): InteractionRequest { - $implementationClass = $requestBody['implementationClass']; - $action = $requestBody['action']; - $data = $requestBody['data']; + $implementationClass = $requestBody['implementationClass'] ?? null; + $action = $requestBody['action'] ?? null; + $data = $requestBody['data'] ?? null; - // TODO: Test for required. + if (null === $implementationClass || null === $action || null === $data) { + throw new InteractiveException('implementationClass, action and/or data not set.'); + } return new InteractionRequest($implementationClass, $action, $data); } /** - * Perform the given InteractionRequest with the given Slide. + * @TODO: Describe. * * @throws InteractiveException */ - public function performAction(Slide $slide, InteractionRequest $interactionRequest): array + public function performAction(UserInterface $user, Slide $slide, InteractionRequest $interactionRequest): array { - $implementationClass = $interactionRequest->implementationClass; - - $currentUser = $this->security->getUser(); - - if (!$currentUser instanceof ScreenUser && !$currentUser instanceof User) { - throw new InteractiveException("User is not supported"); + if (!$user instanceof ScreenUser && !$user instanceof User) { + throw new InteractiveException('User is not supported'); } - $tenant = $currentUser->getActiveTenant(); + $tenant = $user->getActiveTenant(); + + $implementationClass = $interactionRequest->implementationClass; $interactive = $this->getInteractive($tenant, $implementationClass); - if ($interactive === null) { - throw new InteractiveException("Interactive not found"); + if (null === $interactive) { + throw new InteractiveException('Interactive not found'); } $interactiveImplementation = $this->getImplementation($interactive->getImplementationClass()); - return $interactiveImplementation->performAction($slide, $interactionRequest); + return $interactiveImplementation->performAction($user, $slide, $interactionRequest); } /** @@ -87,20 +89,25 @@ public function getConfigurables(): array } /** + * @TODO: Describe. + * * @throws InteractiveException */ public function getImplementation(?string $implementationClass): InteractiveInterface { $asArray = [...$this->interactiveImplementations]; - $interactiveImplementations = array_filter($asArray, fn($implementation) => $implementation::class === $implementationClass); + $interactiveImplementations = array_filter($asArray, fn ($implementation) => $implementation::class === $implementationClass); - if (count($interactiveImplementations) === 0) { - throw new InteractiveException("Interactive implementation class not found"); + if (0 === count($interactiveImplementations)) { + throw new InteractiveException('Interactive implementation class not found'); } return $interactiveImplementations[0]; } + /** + * @TODO: Describe. + */ public function getInteractive(Tenant $tenant, string $implementationClass): ?Interactive { return $this->interactiveRepository->findOneBy([ @@ -109,6 +116,9 @@ public function getInteractive(Tenant $tenant, string $implementationClass): ?In ]); } + /** + * @TODO: Describe. + */ public function saveConfiguration(Tenant $tenant, string $implementationClass, array $configuration): void { $entry = $this->interactiveRepository->findOneBy([ @@ -116,7 +126,7 @@ public function saveConfiguration(Tenant $tenant, string $implementationClass, a 'tenant' => $tenant, ]); - if ($entry === null) { + if (null === $entry) { $entry = new Interactive(); $entry->setTenant($tenant); $entry->setImplementationClass($implementationClass); @@ -128,4 +138,13 @@ public function saveConfiguration(Tenant $tenant, string $implementationClass, a $this->entityManager->flush(); } + + /** + * @TODO: Describe. + */ + public function getConfigOptions(): array + { + // TODO: Implement getConfigOptions() method. + return []; + } } diff --git a/src/Service/KeyVaultService.php b/src/Service/KeyVaultService.php new file mode 100644 index 00000000..f7fe9e8a --- /dev/null +++ b/src/Service/KeyVaultService.php @@ -0,0 +1,43 @@ +keyVaultSource) { + self::ENVIRONMENT => $this->getValueFromEnvironment($key), + self::AZURE_KEY_VAULT => $this->getValueFromAzureKeyVault($key), + }; + } + + private function getValueFromEnvironment(string $key): ?string + { + return $this->keyVaultArray[$key] ?? null; + } + + private function getValueFromAzureKeyVault(string $key): ?string + { + // TODO: Add support for Azure KeyVault. + // https://github.com/itk-dev/AzureKeyVaultPhp + + throw new \Exception('Not implemented'); + } +} diff --git a/tests/Interactive/MicrosoftGraphQuickBookTest.php b/tests/Interactive/MicrosoftGraphQuickBookTest.php index 0ca28aac..d5fb5730 100644 --- a/tests/Interactive/MicrosoftGraphQuickBookTest.php +++ b/tests/Interactive/MicrosoftGraphQuickBookTest.php @@ -1,10 +1,40 @@ entityManager = static::getContainer()->get('doctrine')->getManager(); + } + + public function testGetBookingOptions(): void + { + $this->assertEquals(1, 1); + } + public function testCreateBooking(): void + { + $this->assertEquals(1, 1); + } } diff --git a/tests/Service/InteractiveServiceTest.php b/tests/Service/InteractiveServiceTest.php new file mode 100644 index 00000000..c21ed8ef --- /dev/null +++ b/tests/Service/InteractiveServiceTest.php @@ -0,0 +1,111 @@ +container = static::getContainer(); + $this->entityManager = $this->container->get('doctrine')->getManager(); + } + + public function testParseRequestBody(): void + { + $interactiveService = $this->container->get(InteractiveService::class); + + $this->expectException(InteractiveException::class); + + $interactiveService->parseRequestBody([ + 'test' => 'test', + ]); + + $interactionRequest = $interactiveService->parseRequestBody([ + 'implementationClass' => MicrosoftGraphQuickBook::class, + 'action' => 'test', + 'data' => [], + ]); + + $correctReturnType = $interactionRequest instanceof InteractionRequest; + + $this->assertTrue($correctReturnType); + } + + /** + * @throws \Exception + */ + public function testPerformAction(): void + { + $interactiveService = $this->container->get(InteractiveService::class); + $user = $this->container->get(UserRepository::class)->findOneBy(['email' => 'admin@example.com']); + + $this->assertNotNull($user); + + $slide = new Slide(); + + $interactionRequest = $interactiveService->parseRequestBody([ + 'implementationClass' => MicrosoftGraphQuickBook::class, + 'action' => 'ACTION_NOT_EXIST', + 'data' => [], + ]); + + $this->expectException(InteractiveException::class); + $this->expectExceptionMessage('Interactive not found'); + + $tenant = $user->getActiveTenant(); + + $interactiveService->performAction($user, $slide, $interactionRequest); + + $interactiveService->saveConfiguration($tenant, MicrosoftGraphQuickBook::class, []); + + $this->expectException(InteractiveException::class); + $this->expectExceptionMessage('Action not allowed'); + + $interactiveService->performAction($user, $slide, $interactionRequest); + } + + public function testGetConfigurables(): void + { + $interactiveService = $this->container->get(InteractiveService::class); + + $this->assertCount(1, $interactiveService->getConfigurables()); + } + + public function testGetImplementation(): void + { + $interactiveService = $this->container->get(InteractiveService::class); + + $service = $interactiveService->getImplementation(MicrosoftGraphQuickBook::class); + + $instanceOf = $service instanceof MicrosoftGraphQuickBook; + + $this->assertTrue($instanceOf); + } +} From 7ea3874d79147dc248542bd405a25d312d23153c Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 4 Mar 2024 13:32:13 +0100 Subject: [PATCH 07/27] #366: Fixed interval calculations. Added caching to ms graph token caching --- src/Interactive/MicrosoftGraphQuickBook.php | 59 +++++++++++++------ .../MicrosoftGraphQuickBookTest.php | 28 ++++++++- 2 files changed, 67 insertions(+), 20 deletions(-) diff --git a/src/Interactive/MicrosoftGraphQuickBook.php b/src/Interactive/MicrosoftGraphQuickBook.php index f3677ea5..95478ba7 100644 --- a/src/Interactive/MicrosoftGraphQuickBook.php +++ b/src/Interactive/MicrosoftGraphQuickBook.php @@ -4,14 +4,19 @@ namespace App\Interactive; +use App\Entity\Tenant; +use App\Entity\Tenant\Interactive; use App\Entity\Tenant\Slide; use App\Entity\User; use App\Exceptions\InteractiveException; use App\Service\InteractiveService; use App\Service\KeyVaultService; +use Psr\Cache\InvalidArgumentException; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Validator\Constraints\Timezone; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; /** @@ -38,6 +43,7 @@ public function __construct( private readonly Security $security, private readonly HttpClientInterface $client, private readonly KeyVaultService $keyValueService, + private readonly CacheInterface $cache, ) {} public function getConfigOptions(): array @@ -74,7 +80,7 @@ public function performAction(UserInterface $user, Slide $slide, InteractionRequ /** * @throws \Throwable */ - private function authenticate(array $configuration): string + private function authenticate(array $configuration): array { $tenantId = $this->keyValueService->getValue($configuration['tenantId']); $clientId = $this->keyValueService->getValue($configuration['clientId']); @@ -97,11 +103,30 @@ private function authenticate(array $configuration): string ], ]); - $data = $response->toArray(); + return $response->toArray(); + } + + /** + * @throws InvalidArgumentException + */ + private function getToken(Tenant $tenant, Interactive $interactive): string + { + $configuration = $interactive->getConfiguration(); + + if (null === $configuration) { + throw new \Exception('InteractiveNoConfiguration'); + } - // TODO: cache response. + return $this->cache->get( + "MSGraphToken-".$tenant->getTenantKey(), + function (ItemInterface $item) use ($configuration) { + $arr = $this->authenticate($configuration); - return $data['access_token']; + $item->expiresAfter($arr["expires_in"]); + + return $arr['access_token']; + }, + ); } /** @@ -115,19 +140,11 @@ private function getQuickBookOptions(Slide $slide, InteractionRequest $interacti $interactive = $this->interactiveService->getInteractive($tenant, $interactionRequest->implementationClass); - // TODO: Custom exceptions. - if (null === $interactive) { throw new \Exception('InteractiveNotFound'); } - $configuration = $interactive->getConfiguration(); - - if (null === $configuration) { - throw new \Exception('InteractiveNoConfiguration'); - } - - $token = $this->authenticate($configuration); + $token = $this->getToken($tenant, $interactive); $start = (new \DateTime())->add(new \DateInterval('PT1M'))->setTimezone(new \DateTimeZone('UTC')); $startPlus15Minutes = (clone $start)->add(new \DateInterval('PT15M'))->setTimezone(new \DateTimeZone('UTC')); @@ -206,11 +223,12 @@ public function getBusyIntervals(string $token, string $resource, \DateTime $sta $eventStartArray = $scheduleItem['start']; $eventEndArray = $scheduleItem['end']; - $p = 1; + $start = new \DateTime($eventStartArray['dateTime'], new \DateTimeZone($eventStartArray['timeZone'])); + $end = new \DateTime($eventEndArray['dateTime'], new \DateTimeZone($eventStartArray['timeZone'])); $result[] = [ - 'startTime' => $scheduleItem['start'], - 'endTime' => $scheduleItem['end'], + 'startTime' => $start, + 'endTime' => $end, ]; } } @@ -218,8 +236,13 @@ public function getBusyIntervals(string $token, string $resource, \DateTime $sta return $result; } - private function intervalFree(array $schedule, \DateTime $from , \DateTime $to): bool + public function intervalFree(array $schedule, \DateTime $from , \DateTime $to): bool { - return false; + foreach ($schedule as $scheduleEntry) { + if (!($scheduleEntry['startTime'] > $to || $scheduleEntry['endTime'] < $from)) { + return false; + } + } + return true; } } diff --git a/tests/Interactive/MicrosoftGraphQuickBookTest.php b/tests/Interactive/MicrosoftGraphQuickBookTest.php index d5fb5730..35b400f1 100644 --- a/tests/Interactive/MicrosoftGraphQuickBookTest.php +++ b/tests/Interactive/MicrosoftGraphQuickBookTest.php @@ -4,15 +4,18 @@ namespace App\Tests\Interactive; +use App\Interactive\MicrosoftGraphQuickBook; use Doctrine\ORM\EntityManager; use Hautelook\AliceBundle\PhpUnit\BaseDatabaseTrait; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\DependencyInjection\ContainerInterface; class MicrosoftGraphQuickBookTest extends KernelTestCase { use BaseDatabaseTrait; private EntityManager $entityManager; + private ContainerInterface $container; public static function setUpBeforeClass(): void { @@ -25,16 +28,37 @@ public static function setUpBeforeClass(): void public function setUp(): void { - $this->entityManager = static::getContainer()->get('doctrine')->getManager(); + $this->container = static::getContainer(); + $this->entityManager = $this->container->get('doctrine')->getManager(); } - +/* public function testGetBookingOptions(): void { + // TODO: Add tests. $this->assertEquals(1, 1); } public function testCreateBooking(): void { + // TODO: Add tests. $this->assertEquals(1, 1); } +*/ + public function testIntervalFree(): void + { + $service = $this->container->get(MicrosoftGraphQuickBook::class); + + $schedules = [ + [ + 'startTime' => (new \DateTime())->add(new \DateInterval('PT30M')), + 'endTime' => (new \DateTime())->add(new \DateInterval('PT1H')), + ] + ]; + + $intervalFree = $service->intervalFree($schedules, new \DateTime(), (new \DateTime())->add(new \DateInterval('PT15M'))); + $this->assertTrue($intervalFree); + + $intervalFree = $service->intervalFree($schedules, (new \DateTime())->add(new \DateInterval('PT15M')), (new \DateTime())->add(new \DateInterval('PT45M'))); + $this->assertFalse($intervalFree); + } } From c427fc12faa5576e804ccc9b2350218fa9cc47cb Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 4 Mar 2024 14:14:12 +0100 Subject: [PATCH 08/27] #366: Added requirement that resource should be in feed configuration resources field --- src/Feed/KobaFeedType.php | 2 +- src/Interactive/MicrosoftGraphQuickBook.php | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Feed/KobaFeedType.php b/src/Feed/KobaFeedType.php index 6487f2c0..8ee70573 100644 --- a/src/Feed/KobaFeedType.php +++ b/src/Feed/KobaFeedType.php @@ -80,7 +80,7 @@ public function getData(Feed $feed): array if (!is_string($title)) { $this->logger->error('KobaFeedType: event_name is not string.'); - throw new MissingFeedConfigurationException('Koba event_name is not string'); + throw new \Exception('Koba event_name is not string'); } // Apply list filter. If enabled it removes all events that do not have (liste) in title. diff --git a/src/Interactive/MicrosoftGraphQuickBook.php b/src/Interactive/MicrosoftGraphQuickBook.php index 95478ba7..f1e446b3 100644 --- a/src/Interactive/MicrosoftGraphQuickBook.php +++ b/src/Interactive/MicrosoftGraphQuickBook.php @@ -11,6 +11,8 @@ use App\Exceptions\InteractiveException; use App\Service\InteractiveService; use App\Service\KeyVaultService; +use PHPUnit\Util\Exception; +use Psr\Cache\CacheItemInterface; use Psr\Cache\InvalidArgumentException; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Security\Core\User\UserInterface; @@ -119,7 +121,7 @@ private function getToken(Tenant $tenant, Interactive $interactive): string return $this->cache->get( "MSGraphToken-".$tenant->getTenantKey(), - function (ItemInterface $item) use ($configuration) { + function (CacheItemInterface $item) use ($configuration): mixed { $arr = $this->authenticate($configuration); $item->expiresAfter($arr["expires_in"]); @@ -144,6 +146,12 @@ private function getQuickBookOptions(Slide $slide, InteractionRequest $interacti throw new \Exception('InteractiveNotFound'); } + $feed = $slide->getFeed(); + + if (!in_array($interactionRequest->data['resource'] ?? '', $feed->getConfiguration()['resources'] ?? [])) { + throw new \Exception("Resource not in feed resources"); + } + $token = $this->getToken($tenant, $interactive); $start = (new \DateTime())->add(new \DateInterval('PT1M'))->setTimezone(new \DateTimeZone('UTC')); From 2b7e4fccf16c25070b0af1f211c6c09035874d40 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sat, 9 Mar 2024 07:41:01 +0100 Subject: [PATCH 09/27] #369: Added quick booking --- .gitignore | 3 - ...errides.yml => docker-compose.override.yml | 0 src/Interactive/MicrosoftGraphQuickBook.php | 80 ++++++++++++++++++- 3 files changed, 78 insertions(+), 5 deletions(-) rename docker-compose.overrides.yml => docker-compose.override.yml (100%) diff --git a/.gitignore b/.gitignore index fdd8f9f3..2d3adbc3 100644 --- a/.gitignore +++ b/.gitignore @@ -31,9 +31,6 @@ public/media/* xdebug.ini launch.json -# Allow local docker-compose overrides -docker-compose.override.yml - ###> liip/imagine-bundle ### /public/media/cache/ ###< liip/imagine-bundle ### diff --git a/docker-compose.overrides.yml b/docker-compose.override.yml similarity index 100% rename from docker-compose.overrides.yml rename to docker-compose.override.yml diff --git a/src/Interactive/MicrosoftGraphQuickBook.php b/src/Interactive/MicrosoftGraphQuickBook.php index f1e446b3..872055e2 100644 --- a/src/Interactive/MicrosoftGraphQuickBook.php +++ b/src/Interactive/MicrosoftGraphQuickBook.php @@ -136,6 +136,8 @@ function (CacheItemInterface $item) use ($configuration): mixed { */ private function getQuickBookOptions(Slide $slide, InteractionRequest $interactionRequest): array { + // TODO: Add caching to avoid spamming microsoft graph. + /** @var User $user */ $user = $this->security->getUser(); $tenant = $user->getActiveTenant(); @@ -169,18 +171,21 @@ private function getQuickBookOptions(Slide $slide, InteractionRequest $interacti return [ [ 'title' => '15 min', + 'resource' => $interactionRequest->data['resource'], 'from' => $startFormatted, 'to' => $startPlus15MinutesFormatted, 'available' => $this->intervalFree($schedule, $start, $startPlus15Minutes), ], [ 'title' => '30 min', + 'resource' => $interactionRequest->data['resource'], 'from' => $startFormatted, 'to' => $startPlus30MinutesFormatted, 'available' => $this->intervalFree($schedule, $start, $startPlus30Minutes), ], [ 'title' => '60 min', + 'resource' => $interactionRequest->data['resource'], 'from' => $startFormatted, 'to' => $startPlus1HourFormatted, 'available' => $this->intervalFree($schedule, $start, $startPlus1Hour), @@ -188,9 +193,80 @@ private function getQuickBookOptions(Slide $slide, InteractionRequest $interacti ]; } - private function quickBook(Slide $slide, InteractionRequest $interaction): array + private function quickBook(Slide $slide, InteractionRequest $interactionRequest): array { - return ['test3' => 'test4']; + /** @var User $user */ + $user = $this->security->getUser(); + $tenant = $user->getActiveTenant(); + + $interactive = $this->interactiveService->getInteractive($tenant, $interactionRequest->implementationClass); + + if (null === $interactive) { + throw new \Exception('InteractiveNotFound'); + } + + $feed = $slide->getFeed(); + + $interval = $interactionRequest->data['interval']; + + if (!in_array($interval['resource'] ?? '', $feed->getConfiguration()['resources'] ?? [])) { + throw new \Exception("Resource not in feed resources"); + } + + $token = $this->getToken($tenant, $interactive); + + // TODO: Make sure interval is free. + + $configuration = $interactive->getConfiguration(); + + if (null === $configuration) { + throw new \Exception('InteractiveNoConfiguration'); + } + + $username = $this->keyValueService->getValue($configuration['username']); + + $requestBody = [ + 'subject' => "Hurtig booking", + 'start' => [ + 'dateTime' => (new \DateTime($interval['from']))->format(self::GRAPH_DATE_FORMAT), + 'timeZone' => 'UTC', + ], + 'end' => [ + 'dateTime' => (new \DateTime($interval['to']))->format(self::GRAPH_DATE_FORMAT), + 'timeZone' => 'UTC', + ], + 'allowNewTimeProposals' => false, + 'showAs' => 'busy', + 'isOrganizer' => false, + 'location' => [ + 'locationEmailAddress' => $interval['resource'], + ], + 'attendees' => [ + [ + 'emailAddress' => [ + 'address' => $username, + ], + 'type' => 'optional', + ], + ], + ]; + + $response = $this->client->request('POST', self::ENDPOINT.'/users/'.$interval['resource'].'/events', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ], + 'body' => json_encode($requestBody), + ]); + + $status = $response->getStatusCode(); + + if (201 !== $status) { + return ['status' => $status, 'interval' => $interval, 'message' => 'booking not successful']; + } + + return ['status' => $status, 'interval' => $interval, 'message' => 'booking successful']; } /** From 1b47b95dbfb92c9013563879038eeaa0ed324088 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Tue, 12 Mar 2024 12:55:15 +0100 Subject: [PATCH 10/27] #366: Changed title of events to Straksbooking --- src/Interactive/MicrosoftGraphQuickBook.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Interactive/MicrosoftGraphQuickBook.php b/src/Interactive/MicrosoftGraphQuickBook.php index 872055e2..3b5380fb 100644 --- a/src/Interactive/MicrosoftGraphQuickBook.php +++ b/src/Interactive/MicrosoftGraphQuickBook.php @@ -226,7 +226,7 @@ private function quickBook(Slide $slide, InteractionRequest $interactionRequest) $username = $this->keyValueService->getValue($configuration['username']); $requestBody = [ - 'subject' => "Hurtig booking", + 'subject' => "Straksbooking", 'start' => [ 'dateTime' => (new \DateTime($interval['from']))->format(self::GRAPH_DATE_FORMAT), 'timeZone' => 'UTC', From 285477e6a8d39df4fb02d6cc47d441dff5d5656c Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 18 Mar 2024 11:35:33 +0100 Subject: [PATCH 11/27] 366: Updated api spec --- public/api-spec-v1.json | 112 ++++++++++++++++++++++++++++++++++++++++ public/api-spec-v1.yaml | 77 +++++++++++++++++++++++++++ 2 files changed, 189 insertions(+) diff --git a/public/api-spec-v1.json b/public/api-spec-v1.json index fdfe5590..16a9d899 100755 --- a/public/api-spec-v1.json +++ b/public/api-spec-v1.json @@ -5039,6 +5039,86 @@ }, "parameters": [] }, + "/v1/slides/{id}/action": { + "post": { + "operationId": "api_Slide_perform_action", + "tags": [ + "Slides" + ], + "responses": { + "201": { + "description": "Slide resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Slide.Slide.jsonld" + } + }, + "text/html": { + "schema": { + "$ref": "#/components/schemas/Slide.Slide" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Slide.Slide" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input" + }, + "422": { + "description": "Unprocessable entity" + } + }, + "summary": "Performs an action for a slide.", + "description": "Perform an action for a slide.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "", + "required": true, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string", + "format": "ulid", + "pattern": "^[A-Za-z0-9]{26}$" + }, + "style": "simple", + "explode": false, + "allowReserved": false + } + ], + "requestBody": { + "description": "The new Slide resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Slide.InteractiveSlideActionInput.jsonld" + } + }, + "text/html": { + "schema": { + "$ref": "#/components/schemas/Slide.InteractiveSlideActionInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Slide.InteractiveSlideActionInput" + } + } + }, + "required": true + }, + "deprecated": false + }, + "parameters": [] + }, "/v1/slides/{id}/playlists": { "get": { "operationId": "put-v1-slide-playlist-id", @@ -13102,6 +13182,38 @@ } } }, + "Slide.InteractiveSlideActionInput": { + "type": "object", + "description": "", + "deprecated": false, + "properties": { + "action": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Slide.InteractiveSlideActionInput.jsonld": { + "type": "object", + "description": "", + "deprecated": false, + "properties": { + "action": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "Slide.Slide": { "type": "object", "description": "", diff --git a/public/api-spec-v1.yaml b/public/api-spec-v1.yaml index c35a0ce3..9c7963ae 100755 --- a/public/api-spec-v1.yaml +++ b/public/api-spec-v1.yaml @@ -3525,6 +3525,61 @@ paths: allowReserved: false deprecated: false parameters: [] + '/v1/slides/{id}/action': + post: + operationId: api_Slide_perform_action + tags: + - Slides + responses: + 201: + description: 'Slide resource created' + content: + application/ld+json: + schema: + $ref: '#/components/schemas/Slide.Slide.jsonld' + text/html: + schema: + $ref: '#/components/schemas/Slide.Slide' + multipart/form-data: + schema: + $ref: '#/components/schemas/Slide.Slide' + links: { } + 400: + description: 'Invalid input' + 422: + description: 'Unprocessable entity' + summary: 'Performs an action for a slide.' + description: 'Perform an action for a slide.' + parameters: + - + name: id + in: path + description: '' + required: true + deprecated: false + allowEmptyValue: false + schema: + type: string + format: ulid + pattern: '^[A-Za-z0-9]{26}$' + style: simple + explode: false + allowReserved: false + requestBody: + description: 'The new Slide resource' + content: + application/ld+json: + schema: + $ref: '#/components/schemas/Slide.InteractiveSlideActionInput.jsonld' + text/html: + schema: + $ref: '#/components/schemas/Slide.InteractiveSlideActionInput' + multipart/form-data: + schema: + $ref: '#/components/schemas/Slide.InteractiveSlideActionInput' + required: true + deprecated: false + parameters: [] '/v1/slides/{id}/playlists': get: operationId: put-v1-slide-playlist-id @@ -9109,6 +9164,28 @@ components: properties: relationsChecksum: type: object + Slide.InteractiveSlideActionInput: + type: object + description: '' + deprecated: false + properties: + action: + type: string + data: + type: array + items: + type: string + Slide.InteractiveSlideActionInput.jsonld: + type: object + description: '' + deprecated: false + properties: + action: + type: string + data: + type: array + items: + type: string Slide.Slide: type: object description: '' From 79b5e0ebc7906ba034b47ce9c6907eba935dc347 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 18 Mar 2024 12:14:57 +0100 Subject: [PATCH 12/27] 367: Applied coding standards --- psalm-baseline.xml | 6 ----- src/Command/Tenant/ConfigureTenantCommand.php | 2 ++ src/Controller/AuthOidcController.php | 1 - src/Controller/InteractiveController.php | 11 ++++++-- src/Dto/InteractiveSlideActionInput.php | 4 +-- src/Interactive/InteractionRequest.php | 15 ++++------- src/Interactive/MicrosoftGraphQuickBook.php | 24 ++++++++++------- .../MicrosoftGraphQuickBookTest.php | 27 ++++++++++--------- 8 files changed, 47 insertions(+), 43 deletions(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index a98c5bdd..4cdc2101 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -495,16 +495,10 @@ - - - - - - diff --git a/src/Command/Tenant/ConfigureTenantCommand.php b/src/Command/Tenant/ConfigureTenantCommand.php index ff594b41..0042ce49 100644 --- a/src/Command/Tenant/ConfigureTenantCommand.php +++ b/src/Command/Tenant/ConfigureTenantCommand.php @@ -10,6 +10,7 @@ use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ConfirmationQuestion; @@ -38,6 +39,7 @@ final protected function execute(InputInterface $input, OutputInterface $output) { $io = new SymfonyStyle($input, $output); + /** @var QuestionHelper $helper */ $helper = $this->getHelper('question'); $tenants = $this->tenantRepository->findAll(); diff --git a/src/Controller/AuthOidcController.php b/src/Controller/AuthOidcController.php index 0a6bd8de..c1d46001 100644 --- a/src/Controller/AuthOidcController.php +++ b/src/Controller/AuthOidcController.php @@ -12,7 +12,6 @@ use Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationSuccessHandler; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; -use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Session\SessionInterface; diff --git a/src/Controller/InteractiveController.php b/src/Controller/InteractiveController.php index 7961e402..024cc0ce 100644 --- a/src/Controller/InteractiveController.php +++ b/src/Controller/InteractiveController.php @@ -4,7 +4,10 @@ namespace App\Controller; +use App\Entity\ScreenUser; use App\Entity\Tenant\Slide; +use App\Entity\User; +use App\Exceptions\NotFoundException; use App\Service\InteractiveService; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\JsonResponse; @@ -23,10 +26,14 @@ public function __invoke(Request $request, Slide $slide): JsonResponse { $requestBody = $request->toArray(); - $interaction = $this->interactiveSlideService->parseRequestBody($requestBody); + $interactionRequest = $this->interactiveSlideService->parseRequestBody($requestBody); $user = $this->security->getUser(); - return new JsonResponse($this->interactiveSlideService->performAction($user, $slide, $interaction)); + if (!($user instanceof User || $user instanceof ScreenUser)) { + throw new NotFoundException('User not found'); + } + + return new JsonResponse($this->interactiveSlideService->performAction($user, $slide, $interactionRequest)); } } diff --git a/src/Dto/InteractiveSlideActionInput.php b/src/Dto/InteractiveSlideActionInput.php index c0a9c460..25ef131e 100644 --- a/src/Dto/InteractiveSlideActionInput.php +++ b/src/Dto/InteractiveSlideActionInput.php @@ -6,6 +6,6 @@ class InteractiveSlideActionInput { - public string $action; - public array $data; + public ?string $action = null; + public array $data = []; } diff --git a/src/Interactive/InteractionRequest.php b/src/Interactive/InteractionRequest.php index 2cc541d7..f5b756f9 100644 --- a/src/Interactive/InteractionRequest.php +++ b/src/Interactive/InteractionRequest.php @@ -6,14 +6,9 @@ readonly class InteractionRequest { - public string $implementationClass; - public string $action; - public array $data; - - public function __construct(string $implementationClass, string $action, array $data) - { - $this->implementationClass = $implementationClass; - $this->action = $action; - $this->data = $data; - } + public function __construct( + public string $implementationClass, + public string $action, + public array $data + ) {} } diff --git a/src/Interactive/MicrosoftGraphQuickBook.php b/src/Interactive/MicrosoftGraphQuickBook.php index 3b5380fb..6b2fa377 100644 --- a/src/Interactive/MicrosoftGraphQuickBook.php +++ b/src/Interactive/MicrosoftGraphQuickBook.php @@ -11,14 +11,11 @@ use App\Exceptions\InteractiveException; use App\Service\InteractiveService; use App\Service\KeyVaultService; -use PHPUnit\Util\Exception; use Psr\Cache\CacheItemInterface; use Psr\Cache\InvalidArgumentException; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Security\Core\User\UserInterface; -use Symfony\Component\Validator\Constraints\Timezone; use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; /** @@ -120,11 +117,11 @@ private function getToken(Tenant $tenant, Interactive $interactive): string } return $this->cache->get( - "MSGraphToken-".$tenant->getTenantKey(), + 'MSGraphToken-'.$tenant->getTenantKey(), function (CacheItemInterface $item) use ($configuration): mixed { $arr = $this->authenticate($configuration); - $item->expiresAfter($arr["expires_in"]); + $item->expiresAfter($arr['expires_in']); return $arr['access_token']; }, @@ -150,8 +147,12 @@ private function getQuickBookOptions(Slide $slide, InteractionRequest $interacti $feed = $slide->getFeed(); + if (null === $feed) { + throw new \Exception('Slide.feed not set.'); + } + if (!in_array($interactionRequest->data['resource'] ?? '', $feed->getConfiguration()['resources'] ?? [])) { - throw new \Exception("Resource not in feed resources"); + throw new \Exception('Resource not in feed resources'); } $token = $this->getToken($tenant, $interactive); @@ -207,10 +208,14 @@ private function quickBook(Slide $slide, InteractionRequest $interactionRequest) $feed = $slide->getFeed(); + if (null === $feed) { + throw new \Exception('Slide.feed not set.'); + } + $interval = $interactionRequest->data['interval']; if (!in_array($interval['resource'] ?? '', $feed->getConfiguration()['resources'] ?? [])) { - throw new \Exception("Resource not in feed resources"); + throw new \Exception('Resource not in feed resources'); } $token = $this->getToken($tenant, $interactive); @@ -226,7 +231,7 @@ private function quickBook(Slide $slide, InteractionRequest $interactionRequest) $username = $this->keyValueService->getValue($configuration['username']); $requestBody = [ - 'subject' => "Straksbooking", + 'subject' => 'Straksbooking', 'start' => [ 'dateTime' => (new \DateTime($interval['from']))->format(self::GRAPH_DATE_FORMAT), 'timeZone' => 'UTC', @@ -320,13 +325,14 @@ public function getBusyIntervals(string $token, string $resource, \DateTime $sta return $result; } - public function intervalFree(array $schedule, \DateTime $from , \DateTime $to): bool + public function intervalFree(array $schedule, \DateTime $from, \DateTime $to): bool { foreach ($schedule as $scheduleEntry) { if (!($scheduleEntry['startTime'] > $to || $scheduleEntry['endTime'] < $from)) { return false; } } + return true; } } diff --git a/tests/Interactive/MicrosoftGraphQuickBookTest.php b/tests/Interactive/MicrosoftGraphQuickBookTest.php index 35b400f1..4fedb024 100644 --- a/tests/Interactive/MicrosoftGraphQuickBookTest.php +++ b/tests/Interactive/MicrosoftGraphQuickBookTest.php @@ -31,19 +31,20 @@ public function setUp(): void $this->container = static::getContainer(); $this->entityManager = $this->container->get('doctrine')->getManager(); } -/* - public function testGetBookingOptions(): void - { - // TODO: Add tests. - $this->assertEquals(1, 1); - } - public function testCreateBooking(): void - { - // TODO: Add tests. - $this->assertEquals(1, 1); - } -*/ + /* + public function testGetBookingOptions(): void + { + // TODO: Add tests. + $this->assertEquals(1, 1); + } + + public function testCreateBooking(): void + { + // TODO: Add tests. + $this->assertEquals(1, 1); + } + */ public function testIntervalFree(): void { $service = $this->container->get(MicrosoftGraphQuickBook::class); @@ -52,7 +53,7 @@ public function testIntervalFree(): void [ 'startTime' => (new \DateTime())->add(new \DateInterval('PT30M')), 'endTime' => (new \DateTime())->add(new \DateInterval('PT1H')), - ] + ], ]; $intervalFree = $service->intervalFree($schedules, new \DateTime(), (new \DateTime())->add(new \DateInterval('PT15M'))); From 63ca50932a185939a0ccd698eaefde32396eb76d Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 18 Mar 2024 13:38:05 +0100 Subject: [PATCH 13/27] 367: Added comments --- src/Interactive/MicrosoftGraphQuickBook.php | 22 ++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/Interactive/MicrosoftGraphQuickBook.php b/src/Interactive/MicrosoftGraphQuickBook.php index 6b2fa377..4ff685d8 100644 --- a/src/Interactive/MicrosoftGraphQuickBook.php +++ b/src/Interactive/MicrosoftGraphQuickBook.php @@ -25,17 +25,17 @@ */ class MicrosoftGraphQuickBook implements InteractiveInterface { - private const ACTION_GET_QUICK_BOOK_OPTIONS = 'ACTION_GET_QUICK_BOOK_OPTIONS'; - private const ACTION_QUICK_BOOK = 'ACTION_QUICK_BOOK'; - private const ENDPOINT = 'https://graph.microsoft.com/v1.0'; - private const LOGIN_ENDPOINT = 'https://login.microsoftonline.com/'; - private const OAUTH_PATH = '/oauth2/v2.0/token'; - private const SCOPE = 'https://graph.microsoft.com/.default'; - private const GRANT_TYPE = 'password'; + private const string ACTION_GET_QUICK_BOOK_OPTIONS = 'ACTION_GET_QUICK_BOOK_OPTIONS'; + private const string ACTION_QUICK_BOOK = 'ACTION_QUICK_BOOK'; + private const string ENDPOINT = 'https://graph.microsoft.com/v1.0'; + private const string LOGIN_ENDPOINT = 'https://login.microsoftonline.com/'; + private const string OAUTH_PATH = '/oauth2/v2.0/token'; + private const string SCOPE = 'https://graph.microsoft.com/.default'; + private const string GRANT_TYPE = 'password'; // see https://docs.microsoft.com/en-us/graph/api/resources/datetimetimezone?view=graph-rest-1.0 // example 2019-03-15T09:00:00 - public const GRAPH_DATE_FORMAT = 'Y-m-d\TH:i:s'; + public const string GRAPH_DATE_FORMAT = 'Y-m-d\TH:i:s'; public function __construct( private readonly InteractiveService $interactiveService, @@ -133,7 +133,7 @@ function (CacheItemInterface $item) use ($configuration): mixed { */ private function getQuickBookOptions(Slide $slide, InteractionRequest $interactionRequest): array { - // TODO: Add caching to avoid spamming microsoft graph. + // TODO: Add caching to avoid spamming Microsoft Graph. /** @var User $user */ $user = $this->security->getUser(); @@ -196,6 +196,8 @@ private function getQuickBookOptions(Slide $slide, InteractionRequest $interacti private function quickBook(Slide $slide, InteractionRequest $interactionRequest): array { + // Make sure that booking requests are not spammed. + /** @var User $user */ $user = $this->security->getUser(); $tenant = $user->getActiveTenant(); @@ -230,6 +232,8 @@ private function quickBook(Slide $slide, InteractionRequest $interactionRequest) $username = $this->keyValueService->getValue($configuration['username']); + // Make sure interval is from now instead of interval['from'] -> interval['to'] + $requestBody = [ 'subject' => 'Straksbooking', 'start' => [ From e109216214400d0c52a7a2c3510b806b2b67c352 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Tue, 26 Mar 2024 14:43:41 +0100 Subject: [PATCH 14/27] 964: Renamed Interactive to InteractiveSlide --- config/services.yaml | 4 +- migrations/Version20240326133850.php | 37 +++++++++++++++++++ src/Command/Tenant/ConfigureTenantCommand.php | 4 +- src/Controller/InteractiveController.php | 4 +- .../{Interactive.php => InteractiveSlide.php} | 2 +- .../InteractionSlideRequest.php} | 4 +- .../InteractiveSlideInterface.php} | 6 +-- .../MicrosoftGraphQuickBook.php | 18 ++++----- src/Repository/InteractiveRepository.php | 14 +++---- ...ervice.php => InteractiveSlideService.php} | 22 +++++------ .../MicrosoftGraphQuickBookTest.php | 2 +- tests/Service/InteractiveServiceTest.php | 16 ++++---- 12 files changed, 85 insertions(+), 48 deletions(-) create mode 100644 migrations/Version20240326133850.php rename src/Entity/Tenant/{Interactive.php => InteractiveSlide.php} (94%) rename src/{Interactive/InteractionRequest.php => InteractiveSlide/InteractionSlideRequest.php} (71%) rename src/{Interactive/InteractiveInterface.php => InteractiveSlide/InteractiveSlideInterface.php} (76%) rename src/{Interactive => InteractiveSlide}/MicrosoftGraphQuickBook.php (95%) rename src/Service/{InteractiveService.php => InteractiveSlideService.php} (88%) diff --git a/config/services.yaml b/config/services.yaml index c288f5f3..2b381d94 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -30,7 +30,7 @@ services: - { name: api_platform.doctrine.orm.query_extension.collection } - { name: api_platform.doctrine.orm.query_extension.item } - App\Interactive\InteractiveInterface: + App\InteractiveSlide\InteractiveSlideInterface: tags: [app.interactive.interactive] # Specify primary UserProviderInterface @@ -89,7 +89,7 @@ services: arguments: - !tagged_iterator app.feed.feed_type - App\Service\InteractiveService: + App\Service\InteractiveSlideService: arguments: - !tagged_iterator app.interactive.interactive diff --git a/migrations/Version20240326133850.php b/migrations/Version20240326133850.php new file mode 100644 index 00000000..6d47f754 --- /dev/null +++ b/migrations/Version20240326133850.php @@ -0,0 +1,37 @@ +addSql('CREATE TABLE interactive_slide (id BINARY(16) NOT NULL COMMENT \'(DC2Type:ulid)\', tenant_id BINARY(16) NOT NULL COMMENT \'(DC2Type:ulid)\', version INT DEFAULT 1 NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', modified_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', created_by VARCHAR(255) DEFAULT \'\' NOT NULL, modified_by VARCHAR(255) DEFAULT \'\' NOT NULL, configuration JSON DEFAULT NULL COMMENT \'(DC2Type:json)\', implementation_class VARCHAR(255) NOT NULL, INDEX IDX_138E544D9033212A (tenant_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE interactive_slide ADD CONSTRAINT FK_138E544D9033212A FOREIGN KEY (tenant_id) REFERENCES tenant (id)'); + $this->addSql('ALTER TABLE interactive DROP FOREIGN KEY FK_3B5F8D379033212A'); + $this->addSql('DROP TABLE interactive'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE TABLE interactive (id BINARY(16) NOT NULL COMMENT \'(DC2Type:ulid)\', tenant_id BINARY(16) NOT NULL COMMENT \'(DC2Type:ulid)\', created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', modified_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', created_by VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT \'\' NOT NULL COLLATE `utf8mb4_unicode_ci`, modified_by VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT \'\' NOT NULL COLLATE `utf8mb4_unicode_ci`, configuration JSON DEFAULT NULL COMMENT \'(DC2Type:json)\', implementation_class VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, version INT DEFAULT 1 NOT NULL, INDEX IDX_3B5F8D379033212A (tenant_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'\' '); + $this->addSql('ALTER TABLE interactive ADD CONSTRAINT FK_3B5F8D379033212A FOREIGN KEY (tenant_id) REFERENCES tenant (id)'); + $this->addSql('ALTER TABLE interactive_slide DROP FOREIGN KEY FK_138E544D9033212A'); + $this->addSql('DROP TABLE interactive_slide'); + } +} diff --git a/src/Command/Tenant/ConfigureTenantCommand.php b/src/Command/Tenant/ConfigureTenantCommand.php index 0042ce49..cf3dcf8c 100644 --- a/src/Command/Tenant/ConfigureTenantCommand.php +++ b/src/Command/Tenant/ConfigureTenantCommand.php @@ -6,7 +6,7 @@ use App\Entity\Tenant; use App\Repository\TenantRepository; -use App\Service\InteractiveService; +use App\Service\InteractiveSlideService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -26,7 +26,7 @@ class ConfigureTenantCommand extends Command public function __construct( private readonly EntityManagerInterface $entityManager, private readonly TenantRepository $tenantRepository, - private readonly InteractiveService $interactiveService, + private readonly InteractiveSlideService $interactiveService, ) { parent::__construct(); } diff --git a/src/Controller/InteractiveController.php b/src/Controller/InteractiveController.php index 024cc0ce..16ebab47 100644 --- a/src/Controller/InteractiveController.php +++ b/src/Controller/InteractiveController.php @@ -8,7 +8,7 @@ use App\Entity\Tenant\Slide; use App\Entity\User; use App\Exceptions\NotFoundException; -use App\Service\InteractiveService; +use App\Service\InteractiveSlideService; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -18,7 +18,7 @@ final readonly class InteractiveController { public function __construct( - private InteractiveService $interactiveSlideService, + private InteractiveSlideService $interactiveSlideService, private Security $security, ) {} diff --git a/src/Entity/Tenant/Interactive.php b/src/Entity/Tenant/InteractiveSlide.php similarity index 94% rename from src/Entity/Tenant/Interactive.php rename to src/Entity/Tenant/InteractiveSlide.php index 6fdb7dc4..cb183f66 100644 --- a/src/Entity/Tenant/Interactive.php +++ b/src/Entity/Tenant/InteractiveSlide.php @@ -9,7 +9,7 @@ use Symfony\Component\Serializer\Annotation\Ignore; #[ORM\Entity(repositoryClass: InteractiveRepository::class)] -class Interactive extends AbstractTenantScopedEntity +class InteractiveSlide extends AbstractTenantScopedEntity { #[Ignore] #[ORM\Column(nullable: true)] diff --git a/src/Interactive/InteractionRequest.php b/src/InteractiveSlide/InteractionSlideRequest.php similarity index 71% rename from src/Interactive/InteractionRequest.php rename to src/InteractiveSlide/InteractionSlideRequest.php index f5b756f9..d30ea512 100644 --- a/src/Interactive/InteractionRequest.php +++ b/src/InteractiveSlide/InteractionSlideRequest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace App\Interactive; +namespace App\InteractiveSlide; -readonly class InteractionRequest +readonly class InteractionSlideRequest { public function __construct( public string $implementationClass, diff --git a/src/Interactive/InteractiveInterface.php b/src/InteractiveSlide/InteractiveSlideInterface.php similarity index 76% rename from src/Interactive/InteractiveInterface.php rename to src/InteractiveSlide/InteractiveSlideInterface.php index d41ab443..52a6eae3 100644 --- a/src/Interactive/InteractiveInterface.php +++ b/src/InteractiveSlide/InteractiveSlideInterface.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace App\Interactive; +namespace App\InteractiveSlide; use App\Entity\Tenant\Slide; use App\Exceptions\InteractiveException; use Symfony\Component\Security\Core\User\UserInterface; -interface InteractiveInterface +interface InteractiveSlideInterface { public function getConfigOptions(): array; @@ -17,5 +17,5 @@ public function getConfigOptions(): array; * * @throws InteractiveException */ - public function performAction(UserInterface $user, Slide $slide, InteractionRequest $interactionRequest): array; + public function performAction(UserInterface $user, Slide $slide, InteractionSlideRequest $interactionRequest): array; } diff --git a/src/Interactive/MicrosoftGraphQuickBook.php b/src/InteractiveSlide/MicrosoftGraphQuickBook.php similarity index 95% rename from src/Interactive/MicrosoftGraphQuickBook.php rename to src/InteractiveSlide/MicrosoftGraphQuickBook.php index 4ff685d8..374841ea 100644 --- a/src/Interactive/MicrosoftGraphQuickBook.php +++ b/src/InteractiveSlide/MicrosoftGraphQuickBook.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace App\Interactive; +namespace App\InteractiveSlide; use App\Entity\Tenant; -use App\Entity\Tenant\Interactive; +use App\Entity\Tenant\InteractiveSlide; use App\Entity\Tenant\Slide; use App\Entity\User; use App\Exceptions\InteractiveException; -use App\Service\InteractiveService; +use App\Service\InteractiveSlideService; use App\Service\KeyVaultService; use Psr\Cache\CacheItemInterface; use Psr\Cache\InvalidArgumentException; @@ -23,7 +23,7 @@ * * Only resources attached to the slide through slide.feed.configuration.resources can be booked from the slide. */ -class MicrosoftGraphQuickBook implements InteractiveInterface +class MicrosoftGraphQuickBook implements InteractiveSlideInterface { private const string ACTION_GET_QUICK_BOOK_OPTIONS = 'ACTION_GET_QUICK_BOOK_OPTIONS'; private const string ACTION_QUICK_BOOK = 'ACTION_QUICK_BOOK'; @@ -38,7 +38,7 @@ class MicrosoftGraphQuickBook implements InteractiveInterface public const string GRAPH_DATE_FORMAT = 'Y-m-d\TH:i:s'; public function __construct( - private readonly InteractiveService $interactiveService, + private readonly InteractiveSlideService $interactiveService, private readonly Security $security, private readonly HttpClientInterface $client, private readonly KeyVaultService $keyValueService, @@ -67,7 +67,7 @@ public function getConfigOptions(): array ]; } - public function performAction(UserInterface $user, Slide $slide, InteractionRequest $interactionRequest): array + public function performAction(UserInterface $user, Slide $slide, InteractionSlideRequest $interactionRequest): array { return match ($interactionRequest->action) { self::ACTION_GET_QUICK_BOOK_OPTIONS => $this->getQuickBookOptions($slide, $interactionRequest), @@ -108,7 +108,7 @@ private function authenticate(array $configuration): array /** * @throws InvalidArgumentException */ - private function getToken(Tenant $tenant, Interactive $interactive): string + private function getToken(Tenant $tenant, InteractiveSlide $interactive): string { $configuration = $interactive->getConfiguration(); @@ -131,7 +131,7 @@ function (CacheItemInterface $item) use ($configuration): mixed { /** * @throws \Throwable */ - private function getQuickBookOptions(Slide $slide, InteractionRequest $interactionRequest): array + private function getQuickBookOptions(Slide $slide, InteractionSlideRequest $interactionRequest): array { // TODO: Add caching to avoid spamming Microsoft Graph. @@ -194,7 +194,7 @@ private function getQuickBookOptions(Slide $slide, InteractionRequest $interacti ]; } - private function quickBook(Slide $slide, InteractionRequest $interactionRequest): array + private function quickBook(Slide $slide, InteractionSlideRequest $interactionRequest): array { // Make sure that booking requests are not spammed. diff --git a/src/Repository/InteractiveRepository.php b/src/Repository/InteractiveRepository.php index 5a7dd61d..6229a973 100644 --- a/src/Repository/InteractiveRepository.php +++ b/src/Repository/InteractiveRepository.php @@ -4,22 +4,22 @@ namespace App\Repository; -use App\Entity\Tenant\Interactive; +use App\Entity\Tenant\InteractiveSlide; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; /** - * @extends ServiceEntityRepository + * @extends ServiceEntityRepository * - * @method Interactive|null find($id, $lockMode = null, $lockVersion = null) - * @method Interactive|null findOneBy(array $criteria, array $orderBy = null) - * @method Interactive[] findAll() - * @method Interactive[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + * @method InteractiveSlide|null find($id, $lockMode = null, $lockVersion = null) + * @method InteractiveSlide|null findOneBy(array $criteria, array $orderBy = null) + * @method InteractiveSlide[] findAll() + * @method InteractiveSlide[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class InteractiveRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { - parent::__construct($registry, Interactive::class); + parent::__construct($registry, InteractiveSlide::class); } } diff --git a/src/Service/InteractiveService.php b/src/Service/InteractiveSlideService.php similarity index 88% rename from src/Service/InteractiveService.php rename to src/Service/InteractiveSlideService.php index 47420f88..aec1ad1f 100644 --- a/src/Service/InteractiveService.php +++ b/src/Service/InteractiveSlideService.php @@ -6,12 +6,12 @@ use App\Entity\ScreenUser; use App\Entity\Tenant; -use App\Entity\Tenant\Interactive; +use App\Entity\Tenant\InteractiveSlide; use App\Entity\Tenant\Slide; use App\Entity\User; use App\Exceptions\InteractiveException; -use App\Interactive\InteractionRequest; -use App\Interactive\InteractiveInterface; +use App\InteractiveSlide\InteractionSlideRequest; +use App\InteractiveSlide\InteractiveSlideInterface; use App\Repository\InteractiveRepository; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Security\Core\User\UserInterface; @@ -19,10 +19,10 @@ /** * Service for handling Slide interactions. */ -readonly class InteractiveService +readonly class InteractiveSlideService { public function __construct( - /** @var array $interactives */ + /** @var array $interactives */ private iterable $interactiveImplementations, private InteractiveRepository $interactiveRepository, private EntityManagerInterface $entityManager, @@ -35,7 +35,7 @@ public function __construct( * * @throws InteractiveException */ - public function parseRequestBody(array $requestBody): InteractionRequest + public function parseRequestBody(array $requestBody): InteractionSlideRequest { $implementationClass = $requestBody['implementationClass'] ?? null; $action = $requestBody['action'] ?? null; @@ -45,7 +45,7 @@ public function parseRequestBody(array $requestBody): InteractionRequest throw new InteractiveException('implementationClass, action and/or data not set.'); } - return new InteractionRequest($implementationClass, $action, $data); + return new InteractionSlideRequest($implementationClass, $action, $data); } /** @@ -53,7 +53,7 @@ public function parseRequestBody(array $requestBody): InteractionRequest * * @throws InteractiveException */ - public function performAction(UserInterface $user, Slide $slide, InteractionRequest $interactionRequest): array + public function performAction(UserInterface $user, Slide $slide, InteractionSlideRequest $interactionRequest): array { if (!$user instanceof ScreenUser && !$user instanceof User) { throw new InteractiveException('User is not supported'); @@ -93,7 +93,7 @@ public function getConfigurables(): array * * @throws InteractiveException */ - public function getImplementation(?string $implementationClass): InteractiveInterface + public function getImplementation(?string $implementationClass): InteractiveSlideInterface { $asArray = [...$this->interactiveImplementations]; $interactiveImplementations = array_filter($asArray, fn ($implementation) => $implementation::class === $implementationClass); @@ -108,7 +108,7 @@ public function getImplementation(?string $implementationClass): InteractiveInte /** * @TODO: Describe. */ - public function getInteractive(Tenant $tenant, string $implementationClass): ?Interactive + public function getInteractive(Tenant $tenant, string $implementationClass): ?InteractiveSlide { return $this->interactiveRepository->findOneBy([ 'implementationClass' => $implementationClass, @@ -127,7 +127,7 @@ public function saveConfiguration(Tenant $tenant, string $implementationClass, a ]); if (null === $entry) { - $entry = new Interactive(); + $entry = new InteractiveSlide(); $entry->setTenant($tenant); $entry->setImplementationClass($implementationClass); diff --git a/tests/Interactive/MicrosoftGraphQuickBookTest.php b/tests/Interactive/MicrosoftGraphQuickBookTest.php index 4fedb024..c3ecf39a 100644 --- a/tests/Interactive/MicrosoftGraphQuickBookTest.php +++ b/tests/Interactive/MicrosoftGraphQuickBookTest.php @@ -4,7 +4,7 @@ namespace App\Tests\Interactive; -use App\Interactive\MicrosoftGraphQuickBook; +use App\InteractiveSlide\MicrosoftGraphQuickBook; use Doctrine\ORM\EntityManager; use Hautelook\AliceBundle\PhpUnit\BaseDatabaseTrait; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; diff --git a/tests/Service/InteractiveServiceTest.php b/tests/Service/InteractiveServiceTest.php index c21ed8ef..01af6174 100644 --- a/tests/Service/InteractiveServiceTest.php +++ b/tests/Service/InteractiveServiceTest.php @@ -6,10 +6,10 @@ use App\Entity\Tenant\Slide; use App\Exceptions\InteractiveException; -use App\Interactive\InteractionRequest; -use App\Interactive\MicrosoftGraphQuickBook; +use App\InteractiveSlide\InteractionSlideRequest; +use App\InteractiveSlide\MicrosoftGraphQuickBook; use App\Repository\UserRepository; -use App\Service\InteractiveService; +use App\Service\InteractiveSlideService; use Doctrine\ORM\EntityManager; use Hautelook\AliceBundle\PhpUnit\BaseDatabaseTrait; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -39,7 +39,7 @@ public function setUp(): void public function testParseRequestBody(): void { - $interactiveService = $this->container->get(InteractiveService::class); + $interactiveService = $this->container->get(InteractiveSlideService::class); $this->expectException(InteractiveException::class); @@ -53,7 +53,7 @@ public function testParseRequestBody(): void 'data' => [], ]); - $correctReturnType = $interactionRequest instanceof InteractionRequest; + $correctReturnType = $interactionRequest instanceof InteractionSlideRequest; $this->assertTrue($correctReturnType); } @@ -63,7 +63,7 @@ public function testParseRequestBody(): void */ public function testPerformAction(): void { - $interactiveService = $this->container->get(InteractiveService::class); + $interactiveService = $this->container->get(InteractiveSlideService::class); $user = $this->container->get(UserRepository::class)->findOneBy(['email' => 'admin@example.com']); $this->assertNotNull($user); @@ -93,14 +93,14 @@ public function testPerformAction(): void public function testGetConfigurables(): void { - $interactiveService = $this->container->get(InteractiveService::class); + $interactiveService = $this->container->get(InteractiveSlideService::class); $this->assertCount(1, $interactiveService->getConfigurables()); } public function testGetImplementation(): void { - $interactiveService = $this->container->get(InteractiveService::class); + $interactiveService = $this->container->get(InteractiveSlideService::class); $service = $interactiveService->getImplementation(MicrosoftGraphQuickBook::class); From 7bbd41af5e315e54c50a01a24148a5cc95bdc6a1 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Tue, 26 Mar 2024 14:59:47 +0100 Subject: [PATCH 15/27] 964: Added dedicated cache for interactive slides --- config/packages/cache.yaml | 6 ++++++ src/InteractiveSlide/MicrosoftGraphQuickBook.php | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index 1ebaec98..c3f359eb 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -22,3 +22,9 @@ framework: adapter: cache.adapter.redis # Default expire set to 1 day default_lifetime: 86400 + + # Creates a "interactive_slide.cache" service + interactive_slide.cache: + adapter: cache.adapter.redis + # Default expire set to 12 hours + default_lifetime: 43200 diff --git a/src/InteractiveSlide/MicrosoftGraphQuickBook.php b/src/InteractiveSlide/MicrosoftGraphQuickBook.php index 374841ea..b0947e57 100644 --- a/src/InteractiveSlide/MicrosoftGraphQuickBook.php +++ b/src/InteractiveSlide/MicrosoftGraphQuickBook.php @@ -42,7 +42,7 @@ public function __construct( private readonly Security $security, private readonly HttpClientInterface $client, private readonly KeyVaultService $keyValueService, - private readonly CacheInterface $cache, + private readonly CacheInterface $interactiveSlideCache, ) {} public function getConfigOptions(): array @@ -116,7 +116,7 @@ private function getToken(Tenant $tenant, InteractiveSlide $interactive): string throw new \Exception('InteractiveNoConfiguration'); } - return $this->cache->get( + return $this->interactiveSlideCache->get( 'MSGraphToken-'.$tenant->getTenantKey(), function (CacheItemInterface $item) use ($configuration): mixed { $arr = $this->authenticate($configuration); From 4ea65a37ab40cef79dd12ac7d1ba83562455b6c0 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Wed, 27 Mar 2024 12:35:03 +0100 Subject: [PATCH 16/27] 946: Added spam protection and interval free checks --- ...tion.php => InteractiveSlideException.php} | 2 +- .../InteractiveSlideInterface.php | 4 +- .../MicrosoftGraphQuickBook.php | 146 +++++++++++------- src/Service/InteractiveSlideService.php | 16 +- tests/Service/InteractiveServiceTest.php | 8 +- 5 files changed, 103 insertions(+), 73 deletions(-) rename src/Exceptions/{InteractiveException.php => InteractiveSlideException.php} (55%) diff --git a/src/Exceptions/InteractiveException.php b/src/Exceptions/InteractiveSlideException.php similarity index 55% rename from src/Exceptions/InteractiveException.php rename to src/Exceptions/InteractiveSlideException.php index d2d6aa6e..cd9a844c 100644 --- a/src/Exceptions/InteractiveException.php +++ b/src/Exceptions/InteractiveSlideException.php @@ -4,6 +4,6 @@ namespace App\Exceptions; -class InteractiveException extends \Exception +class InteractiveSlideException extends \Exception { } diff --git a/src/InteractiveSlide/InteractiveSlideInterface.php b/src/InteractiveSlide/InteractiveSlideInterface.php index 52a6eae3..5ba3343d 100644 --- a/src/InteractiveSlide/InteractiveSlideInterface.php +++ b/src/InteractiveSlide/InteractiveSlideInterface.php @@ -5,7 +5,7 @@ namespace App\InteractiveSlide; use App\Entity\Tenant\Slide; -use App\Exceptions\InteractiveException; +use App\Exceptions\InteractiveSlideException; use Symfony\Component\Security\Core\User\UserInterface; interface InteractiveSlideInterface @@ -15,7 +15,7 @@ public function getConfigOptions(): array; /** * Perform the given InteractionRequest with the given Slide. * - * @throws InteractiveException + * @throws InteractiveSlideException */ public function performAction(UserInterface $user, Slide $slide, InteractionSlideRequest $interactionRequest): array; } diff --git a/src/InteractiveSlide/MicrosoftGraphQuickBook.php b/src/InteractiveSlide/MicrosoftGraphQuickBook.php index b0947e57..e6e6749e 100644 --- a/src/InteractiveSlide/MicrosoftGraphQuickBook.php +++ b/src/InteractiveSlide/MicrosoftGraphQuickBook.php @@ -8,14 +8,17 @@ use App\Entity\Tenant\InteractiveSlide; use App\Entity\Tenant\Slide; use App\Entity\User; -use App\Exceptions\InteractiveException; +use App\Exceptions\InteractiveSlideException; use App\Service\InteractiveSlideService; use App\Service\KeyVaultService; use Psr\Cache\CacheItemInterface; use Psr\Cache\InvalidArgumentException; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\HttpKernel\Exception\ConflictHttpException; +use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; /** @@ -32,6 +35,8 @@ class MicrosoftGraphQuickBook implements InteractiveSlideInterface private const string OAUTH_PATH = '/oauth2/v2.0/token'; private const string SCOPE = 'https://graph.microsoft.com/.default'; private const string GRANT_TYPE = 'password'; + private const string CACHE_PREFIX = 'MSGraphQuickBook'; + private const string BOOKING_TITLE = 'Straksbooking'; // see https://docs.microsoft.com/en-us/graph/api/resources/datetimetimezone?view=graph-rest-1.0 // example 2019-03-15T09:00:00 @@ -72,7 +77,7 @@ public function performAction(UserInterface $user, Slide $slide, InteractionSlid return match ($interactionRequest->action) { self::ACTION_GET_QUICK_BOOK_OPTIONS => $this->getQuickBookOptions($slide, $interactionRequest), self::ACTION_QUICK_BOOK => $this->quickBook($slide, $interactionRequest), - default => throw new InteractiveException('Action not allowed'), + default => throw new InteractiveSlideException('Action not allowed'), }; } @@ -117,7 +122,7 @@ private function getToken(Tenant $tenant, InteractiveSlide $interactive): string } return $this->interactiveSlideCache->get( - 'MSGraphToken-'.$tenant->getTenantKey(), + self::CACHE_PREFIX . '-token-'.$tenant->getTenantKey(), function (CacheItemInterface $item) use ($configuration): mixed { $arr = $this->authenticate($configuration); @@ -158,45 +163,53 @@ private function getQuickBookOptions(Slide $slide, InteractionSlideRequest $inte $token = $this->getToken($tenant, $interactive); $start = (new \DateTime())->add(new \DateInterval('PT1M'))->setTimezone(new \DateTimeZone('UTC')); - $startPlus15Minutes = (clone $start)->add(new \DateInterval('PT15M'))->setTimezone(new \DateTimeZone('UTC')); - $startPlus30Minutes = (clone $start)->add(new \DateInterval('PT30M'))->setTimezone(new \DateTimeZone('UTC')); + $startFormatted = $start->format('c'); + $startPlus1Hour = (clone $start)->add(new \DateInterval('PT1H'))->setTimezone(new \DateTimeZone('UTC')); $schedule = $this->getBusyIntervals($token, $interactionRequest->data['resource'], $start, $startPlus1Hour); - $startFormatted = $start->format('c'); - $startPlus15MinutesFormatted = $startPlus15Minutes->format('c'); - $startPlus30MinutesFormatted = $startPlus30Minutes->format('c'); - $startPlus1HourFormatted = $startPlus1Hour->format('c'); + $result = []; - return [ - [ - 'title' => '15 min', - 'resource' => $interactionRequest->data['resource'], - 'from' => $startFormatted, - 'to' => $startPlus15MinutesFormatted, - 'available' => $this->intervalFree($schedule, $start, $startPlus15Minutes), - ], - [ - 'title' => '30 min', - 'resource' => $interactionRequest->data['resource'], - 'from' => $startFormatted, - 'to' => $startPlus30MinutesFormatted, - 'available' => $this->intervalFree($schedule, $start, $startPlus30Minutes), - ], - [ - 'title' => '60 min', - 'resource' => $interactionRequest->data['resource'], - 'from' => $startFormatted, - 'to' => $startPlus1HourFormatted, - 'available' => $this->intervalFree($schedule, $start, $startPlus1Hour), - ], - ]; + foreach ([15,30,60] as $durationMinutes) { + $startPlus = (clone $start)->add(new \DateInterval('PT'.$durationMinutes.'M'))->setTimezone(new \DateTimeZone('UTC')); + $startPlusFormatted = $startPlus->format('c'); + + if ($this->intervalFree($schedule, $start, $startPlus)) { + $result[] = [ + 'durationMinutes' => $durationMinutes, + 'resource' => $interactionRequest->data['resource'], + 'from' => $startFormatted, + 'to' => $startPlusFormatted, + ]; + } + } + + return $result; } + /** + * @throws \Throwable + */ private function quickBook(Slide $slide, InteractionSlideRequest $interactionRequest): array { + $resource = $this->getValueFromInterval('resource', $interactionRequest); + $durationMinutes = $this->getValueFromInterval('durationMinutes', $interactionRequest); + + $now = new \DateTime(); + // Make sure that booking requests are not spammed. + $lastRequestDateTime = $this->interactiveSlideCache->get( + self::CACHE_PREFIX . "-sp-".$slide->getId(), + function (CacheItemInterface $item) use ($now): \DateTime { + $item->expiresAfter(new \DateInterval('PT1M')); + return $now; + } + ); + + if (($lastRequestDateTime)->add(new \DateInterval('PT1M')) > $now) { + throw new ServiceUnavailableHttpException(60); + } /** @var User $user */ $user = $this->security->getUser(); @@ -214,16 +227,12 @@ private function quickBook(Slide $slide, InteractionSlideRequest $interactionReq throw new \Exception('Slide.feed not set.'); } - $interval = $interactionRequest->data['interval']; - - if (!in_array($interval['resource'] ?? '', $feed->getConfiguration()['resources'] ?? [])) { + if (!in_array($resource, $feed->getConfiguration()['resources'] ?? [])) { throw new \Exception('Resource not in feed resources'); } $token = $this->getToken($tenant, $interactive); - // TODO: Make sure interval is free. - $configuration = $interactive->getConfiguration(); if (null === $configuration) { @@ -232,23 +241,29 @@ private function quickBook(Slide $slide, InteractionSlideRequest $interactionReq $username = $this->keyValueService->getValue($configuration['username']); - // Make sure interval is from now instead of interval['from'] -> interval['to'] + $start = (new \DateTime())->setTimezone(new \DateTimeZone('UTC'));; + $startPlusDuration = (clone $start)->add(new \DateInterval('PT'.$durationMinutes.'M'))->setTimezone(new \DateTimeZone('UTC'));; + + // Make sure interval is free. + if (count($this->getBusyIntervals($token, $resource, $start, $startPlusDuration)) > 0) { + throw new ConflictHttpException("Interval booked already"); + } $requestBody = [ - 'subject' => 'Straksbooking', + 'subject' => self::BOOKING_TITLE, 'start' => [ - 'dateTime' => (new \DateTime($interval['from']))->format(self::GRAPH_DATE_FORMAT), + 'dateTime' => $start->format(self::GRAPH_DATE_FORMAT), 'timeZone' => 'UTC', ], 'end' => [ - 'dateTime' => (new \DateTime($interval['to']))->format(self::GRAPH_DATE_FORMAT), + 'dateTime' => $startPlusDuration->format(self::GRAPH_DATE_FORMAT), 'timeZone' => 'UTC', ], 'allowNewTimeProposals' => false, 'showAs' => 'busy', 'isOrganizer' => false, 'location' => [ - 'locationEmailAddress' => $interval['resource'], + 'locationEmailAddress' => $resource, ], 'attendees' => [ [ @@ -260,26 +275,19 @@ private function quickBook(Slide $slide, InteractionSlideRequest $interactionReq ], ]; - $response = $this->client->request('POST', self::ENDPOINT.'/users/'.$interval['resource'].'/events', [ - 'headers' => [ - 'Authorization' => 'Bearer '.$token, - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - ], + $response = $this->client->request('POST', self::ENDPOINT.'/users/'.$resource.'/events', [ + 'headers' => $this->getHeaders($token), 'body' => json_encode($requestBody), ]); $status = $response->getStatusCode(); - if (201 !== $status) { - return ['status' => $status, 'interval' => $interval, 'message' => 'booking not successful']; - } - - return ['status' => $status, 'interval' => $interval, 'message' => 'booking successful']; + return ['status' => $status, 'interval' => $interactionRequest->data]; } /** * @see https://docs.microsoft.com/en-us/graph/api/calendar-getschedule?view=graph-rest-1.0&tabs=http + * @throws \Throwable */ public function getBusyIntervals(string $token, string $resource, \DateTime $startTime, \DateTime $endTime): array { @@ -297,11 +305,7 @@ public function getBusyIntervals(string $token, string $resource, \DateTime $sta ]; $response = $this->client->request('POST', self::ENDPOINT.'/me/calendar/getSchedule', [ - 'headers' => [ - 'Authorization' => 'Bearer '.$token, - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - ], + 'headers' => $this->getHeaders($token), 'body' => json_encode($body), ]); @@ -339,4 +343,30 @@ public function intervalFree(array $schedule, \DateTime $from, \DateTime $to): b return true; } + + private function getValueFromInterval(string $key, InteractionSlideRequest $interactionRequest): string|int + { + $interval = $interactionRequest->data['interval'] ?? null; + + if ($interval === null) { + throw new \Exception("interval not set."); + } + + $value = $interval[$key] ?? null; + + if ($value === null) { + throw new \Exception("interval.'.$key.' not set."); + } + + return $value; + } + + private function getHeaders(string $token): array + { + return [ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; + } } diff --git a/src/Service/InteractiveSlideService.php b/src/Service/InteractiveSlideService.php index aec1ad1f..56470ddd 100644 --- a/src/Service/InteractiveSlideService.php +++ b/src/Service/InteractiveSlideService.php @@ -9,7 +9,7 @@ use App\Entity\Tenant\InteractiveSlide; use App\Entity\Tenant\Slide; use App\Entity\User; -use App\Exceptions\InteractiveException; +use App\Exceptions\InteractiveSlideException; use App\InteractiveSlide\InteractionSlideRequest; use App\InteractiveSlide\InteractiveSlideInterface; use App\Repository\InteractiveRepository; @@ -33,7 +33,7 @@ public function __construct( * * @param array $requestBody the request body from the http request * - * @throws InteractiveException + * @throws InteractiveSlideException */ public function parseRequestBody(array $requestBody): InteractionSlideRequest { @@ -42,7 +42,7 @@ public function parseRequestBody(array $requestBody): InteractionSlideRequest $data = $requestBody['data'] ?? null; if (null === $implementationClass || null === $action || null === $data) { - throw new InteractiveException('implementationClass, action and/or data not set.'); + throw new InteractiveSlideException('implementationClass, action and/or data not set.'); } return new InteractionSlideRequest($implementationClass, $action, $data); @@ -51,12 +51,12 @@ public function parseRequestBody(array $requestBody): InteractionSlideRequest /** * @TODO: Describe. * - * @throws InteractiveException + * @throws InteractiveSlideException */ public function performAction(UserInterface $user, Slide $slide, InteractionSlideRequest $interactionRequest): array { if (!$user instanceof ScreenUser && !$user instanceof User) { - throw new InteractiveException('User is not supported'); + throw new InteractiveSlideException('User is not supported'); } $tenant = $user->getActiveTenant(); @@ -66,7 +66,7 @@ public function performAction(UserInterface $user, Slide $slide, InteractionSlid $interactive = $this->getInteractive($tenant, $implementationClass); if (null === $interactive) { - throw new InteractiveException('Interactive not found'); + throw new InteractiveSlideException('Interactive slide not found'); } $interactiveImplementation = $this->getImplementation($interactive->getImplementationClass()); @@ -91,7 +91,7 @@ public function getConfigurables(): array /** * @TODO: Describe. * - * @throws InteractiveException + * @throws InteractiveSlideException */ public function getImplementation(?string $implementationClass): InteractiveSlideInterface { @@ -99,7 +99,7 @@ public function getImplementation(?string $implementationClass): InteractiveSlid $interactiveImplementations = array_filter($asArray, fn ($implementation) => $implementation::class === $implementationClass); if (0 === count($interactiveImplementations)) { - throw new InteractiveException('Interactive implementation class not found'); + throw new InteractiveSlideException('Interactive implementation class not found'); } return $interactiveImplementations[0]; diff --git a/tests/Service/InteractiveServiceTest.php b/tests/Service/InteractiveServiceTest.php index 01af6174..fac2456c 100644 --- a/tests/Service/InteractiveServiceTest.php +++ b/tests/Service/InteractiveServiceTest.php @@ -5,7 +5,7 @@ namespace App\Tests\Service; use App\Entity\Tenant\Slide; -use App\Exceptions\InteractiveException; +use App\Exceptions\InteractiveSlideException; use App\InteractiveSlide\InteractionSlideRequest; use App\InteractiveSlide\MicrosoftGraphQuickBook; use App\Repository\UserRepository; @@ -41,7 +41,7 @@ public function testParseRequestBody(): void { $interactiveService = $this->container->get(InteractiveSlideService::class); - $this->expectException(InteractiveException::class); + $this->expectException(InteractiveSlideException::class); $interactiveService->parseRequestBody([ 'test' => 'test', @@ -76,7 +76,7 @@ public function testPerformAction(): void 'data' => [], ]); - $this->expectException(InteractiveException::class); + $this->expectException(InteractiveSlideException::class); $this->expectExceptionMessage('Interactive not found'); $tenant = $user->getActiveTenant(); @@ -85,7 +85,7 @@ public function testPerformAction(): void $interactiveService->saveConfiguration($tenant, MicrosoftGraphQuickBook::class, []); - $this->expectException(InteractiveException::class); + $this->expectException(InteractiveSlideException::class); $this->expectExceptionMessage('Action not allowed'); $interactiveService->performAction($user, $slide, $interactionRequest); From df0bce86c5b610d004b1d925d6ab02bb97c460de Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sat, 30 Mar 2024 07:18:50 +0100 Subject: [PATCH 17/27] 964: Changed response data --- src/InteractiveSlide/MicrosoftGraphQuickBook.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/InteractiveSlide/MicrosoftGraphQuickBook.php b/src/InteractiveSlide/MicrosoftGraphQuickBook.php index e6e6749e..92b051b4 100644 --- a/src/InteractiveSlide/MicrosoftGraphQuickBook.php +++ b/src/InteractiveSlide/MicrosoftGraphQuickBook.php @@ -282,7 +282,10 @@ function (CacheItemInterface $item) use ($now): \DateTime { $status = $response->getStatusCode(); - return ['status' => $status, 'interval' => $interactionRequest->data]; + return ['status' => $status, 'interval' => [ + 'from' => $start->format('c'), + 'to' => $startPlusDuration->format('c'), + ]]; } /** From 3a6cdad19a5820d2b2f5ed2d64a8a1622b6c9ee0 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sat, 30 Mar 2024 08:03:08 +0100 Subject: [PATCH 18/27] 964: Added caching --- .../MicrosoftGraphQuickBook.php | 97 +++++++++++-------- 1 file changed, 58 insertions(+), 39 deletions(-) diff --git a/src/InteractiveSlide/MicrosoftGraphQuickBook.php b/src/InteractiveSlide/MicrosoftGraphQuickBook.php index 92b051b4..d4c1bdbd 100644 --- a/src/InteractiveSlide/MicrosoftGraphQuickBook.php +++ b/src/InteractiveSlide/MicrosoftGraphQuickBook.php @@ -37,6 +37,9 @@ class MicrosoftGraphQuickBook implements InteractiveSlideInterface private const string GRANT_TYPE = 'password'; private const string CACHE_PREFIX = 'MSGraphQuickBook'; private const string BOOKING_TITLE = 'Straksbooking'; + private const array DURATIONS = [15, 30, 60]; + private const string CACHE_LIFETIME_QUICK_BOOK_OPTIONS = 'PT5M'; + private const string CACHE_LIFETIME_QUICK_BOOK_SPAM_PROTECT = 'PT1M'; // see https://docs.microsoft.com/en-us/graph/api/resources/datetimetimezone?view=graph-rest-1.0 // example 2019-03-15T09:00:00 @@ -122,7 +125,7 @@ private function getToken(Tenant $tenant, InteractiveSlide $interactive): string } return $this->interactiveSlideCache->get( - self::CACHE_PREFIX . '-token-'.$tenant->getTenantKey(), + self::CACHE_PREFIX . '-TOKEN-'.$tenant->getTenantKey(), function (CacheItemInterface $item) use ($configuration): mixed { $arr = $this->authenticate($configuration); @@ -138,54 +141,72 @@ function (CacheItemInterface $item) use ($configuration): mixed { */ private function getQuickBookOptions(Slide $slide, InteractionSlideRequest $interactionRequest): array { - // TODO: Add caching to avoid spamming Microsoft Graph. + $resource = $interactionRequest->data['resource'] ?? null; - /** @var User $user */ - $user = $this->security->getUser(); - $tenant = $user->getActiveTenant(); + if ($resource === null) { + throw new \Exception('Resource not set.'); + } - $interactive = $this->interactiveService->getInteractive($tenant, $interactionRequest->implementationClass); + // Add resource to watchedResources, if not in list. + $cacheKey = self::CACHE_PREFIX."-RESOURCES"; + $watchedResources = $this->interactiveSlideCache->get($cacheKey, fn () => []); + if (!in_array($resource, $watchedResources)) { + $this->interactiveSlideCache->delete($cacheKey); - if (null === $interactive) { - throw new \Exception('InteractiveNotFound'); + $watchedResources[] = $resource; + $this->interactiveSlideCache->get($cacheKey, fn () => $watchedResources); } - $feed = $slide->getFeed(); + return $this->interactiveSlideCache->get(self::CACHE_PREFIX.'-QUICK_BOOK_OPTIONS-'.$resource, + function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest) { + $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS)); - if (null === $feed) { - throw new \Exception('Slide.feed not set.'); - } + $tenant = $this->security->getUser()->getActiveTenant(); - if (!in_array($interactionRequest->data['resource'] ?? '', $feed->getConfiguration()['resources'] ?? [])) { - throw new \Exception('Resource not in feed resources'); - } + $interactive = $this->interactiveService->getInteractive($tenant, $interactionRequest->implementationClass); - $token = $this->getToken($tenant, $interactive); + if (null === $interactive) { + throw new \Exception('InteractiveNotFound'); + } - $start = (new \DateTime())->add(new \DateInterval('PT1M'))->setTimezone(new \DateTimeZone('UTC')); - $startFormatted = $start->format('c'); + $feed = $slide->getFeed(); - $startPlus1Hour = (clone $start)->add(new \DateInterval('PT1H'))->setTimezone(new \DateTimeZone('UTC')); + if (null === $feed) { + throw new \Exception('Slide.feed not set.'); + } - $schedule = $this->getBusyIntervals($token, $interactionRequest->data['resource'], $start, $startPlus1Hour); + if (!in_array($resource, $feed->getConfiguration()['resources'] ?? [])) { + throw new \Exception('Resource not in feed resources'); + } - $result = []; + $token = $this->getToken($tenant, $interactive); - foreach ([15,30,60] as $durationMinutes) { - $startPlus = (clone $start)->add(new \DateInterval('PT'.$durationMinutes.'M'))->setTimezone(new \DateTimeZone('UTC')); - $startPlusFormatted = $startPlus->format('c'); + $start = (new \DateTime())->add(new \DateInterval('PT1M'))->setTimezone(new \DateTimeZone('UTC')); + $startFormatted = $start->format('c'); - if ($this->intervalFree($schedule, $start, $startPlus)) { - $result[] = [ - 'durationMinutes' => $durationMinutes, - 'resource' => $interactionRequest->data['resource'], - 'from' => $startFormatted, - 'to' => $startPlusFormatted, - ]; - } - } + $startPlus1Hour = (clone $start)->add(new \DateInterval('PT1H'))->setTimezone(new \DateTimeZone('UTC')); - return $result; + $schedule = $this->getBusyIntervals($token, $interactionRequest->data['resource'], $start, $startPlus1Hour); + + $result = []; + + foreach (self::DURATIONS as $durationMinutes) { + $startPlus = (clone $start)->add(new \DateInterval('PT'.$durationMinutes.'M'))->setTimezone(new \DateTimeZone('UTC')); + $startPlusFormatted = $startPlus->format('c'); + + if ($this->intervalFree($schedule, $start, $startPlus)) { + $result[] = [ + 'durationMinutes' => $durationMinutes, + 'resource' => $interactionRequest->data['resource'], + 'from' => $startFormatted, + 'to' => $startPlusFormatted, + ]; + } + } + + return $result; + } + ); } /** @@ -200,9 +221,9 @@ private function quickBook(Slide $slide, InteractionSlideRequest $interactionReq // Make sure that booking requests are not spammed. $lastRequestDateTime = $this->interactiveSlideCache->get( - self::CACHE_PREFIX . "-sp-".$slide->getId(), + self::CACHE_PREFIX."-SPAM-PROTECT-".$slide->getId(), function (CacheItemInterface $item) use ($now): \DateTime { - $item->expiresAfter(new \DateInterval('PT1M')); + $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_SPAM_PROTECT)); return $now; } ); @@ -211,9 +232,7 @@ function (CacheItemInterface $item) use ($now): \DateTime { throw new ServiceUnavailableHttpException(60); } - /** @var User $user */ - $user = $this->security->getUser(); - $tenant = $user->getActiveTenant(); + $tenant = $this->security->getUser()->getActiveTenant(); $interactive = $this->interactiveService->getInteractive($tenant, $interactionRequest->implementationClass); From 07eda2050058b8f837864b36eebe051db7146f98 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Wed, 3 Apr 2024 06:36:07 +0200 Subject: [PATCH 19/27] 964: Cleaned up migrations --- migrations/Version20240124092925.php | 32 ------------------- migrations/Version20240227095949.php | 31 ------------------ ...26133850.php => Version20240403043527.php} | 6 +--- 3 files changed, 1 insertion(+), 68 deletions(-) delete mode 100644 migrations/Version20240124092925.php delete mode 100644 migrations/Version20240227095949.php rename migrations/{Version20240326133850.php => Version20240403043527.php} (56%) diff --git a/migrations/Version20240124092925.php b/migrations/Version20240124092925.php deleted file mode 100644 index b1bac85b..00000000 --- a/migrations/Version20240124092925.php +++ /dev/null @@ -1,32 +0,0 @@ -addSql('CREATE TABLE interactive (id BINARY(16) NOT NULL COMMENT \'(DC2Type:ulid)\', tenant_id BINARY(16) NOT NULL COMMENT \'(DC2Type:ulid)\', created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', modified_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', created_by VARCHAR(255) DEFAULT \'\' NOT NULL, modified_by VARCHAR(255) DEFAULT \'\' NOT NULL, configuration JSON DEFAULT NULL COMMENT \'(DC2Type:json)\', implementation_class VARCHAR(255) NOT NULL, INDEX IDX_3B5F8D379033212A (tenant_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); - $this->addSql('ALTER TABLE interactive ADD CONSTRAINT FK_3B5F8D379033212A FOREIGN KEY (tenant_id) REFERENCES tenant (id)'); - } - - public function down(Schema $schema): void - { - $this->addSql('ALTER TABLE interactive DROP FOREIGN KEY FK_3B5F8D379033212A'); - $this->addSql('DROP TABLE interactive'); - } -} diff --git a/migrations/Version20240227095949.php b/migrations/Version20240227095949.php deleted file mode 100644 index ee34c0fc..00000000 --- a/migrations/Version20240227095949.php +++ /dev/null @@ -1,31 +0,0 @@ -addSql('ALTER TABLE interactive ADD version INT DEFAULT 1 NOT NULL'); - } - - public function down(Schema $schema): void - { - // this down() migration is auto-generated, please modify it to your needs - $this->addSql('ALTER TABLE interactive DROP version'); - } -} diff --git a/migrations/Version20240326133850.php b/migrations/Version20240403043527.php similarity index 56% rename from migrations/Version20240326133850.php rename to migrations/Version20240403043527.php index 6d47f754..b93cd0d7 100644 --- a/migrations/Version20240326133850.php +++ b/migrations/Version20240403043527.php @@ -10,7 +10,7 @@ /** * Auto-generated Migration: Please modify to your needs! */ -final class Version20240326133850 extends AbstractMigration +final class Version20240403043527 extends AbstractMigration { public function getDescription(): string { @@ -22,15 +22,11 @@ public function up(Schema $schema): void // this up() migration is auto-generated, please modify it to your needs $this->addSql('CREATE TABLE interactive_slide (id BINARY(16) NOT NULL COMMENT \'(DC2Type:ulid)\', tenant_id BINARY(16) NOT NULL COMMENT \'(DC2Type:ulid)\', version INT DEFAULT 1 NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', modified_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', created_by VARCHAR(255) DEFAULT \'\' NOT NULL, modified_by VARCHAR(255) DEFAULT \'\' NOT NULL, configuration JSON DEFAULT NULL COMMENT \'(DC2Type:json)\', implementation_class VARCHAR(255) NOT NULL, INDEX IDX_138E544D9033212A (tenant_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('ALTER TABLE interactive_slide ADD CONSTRAINT FK_138E544D9033212A FOREIGN KEY (tenant_id) REFERENCES tenant (id)'); - $this->addSql('ALTER TABLE interactive DROP FOREIGN KEY FK_3B5F8D379033212A'); - $this->addSql('DROP TABLE interactive'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs - $this->addSql('CREATE TABLE interactive (id BINARY(16) NOT NULL COMMENT \'(DC2Type:ulid)\', tenant_id BINARY(16) NOT NULL COMMENT \'(DC2Type:ulid)\', created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', modified_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', created_by VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT \'\' NOT NULL COLLATE `utf8mb4_unicode_ci`, modified_by VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT \'\' NOT NULL COLLATE `utf8mb4_unicode_ci`, configuration JSON DEFAULT NULL COMMENT \'(DC2Type:json)\', implementation_class VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, version INT DEFAULT 1 NOT NULL, INDEX IDX_3B5F8D379033212A (tenant_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'\' '); - $this->addSql('ALTER TABLE interactive ADD CONSTRAINT FK_3B5F8D379033212A FOREIGN KEY (tenant_id) REFERENCES tenant (id)'); $this->addSql('ALTER TABLE interactive_slide DROP FOREIGN KEY FK_138E544D9033212A'); $this->addSql('DROP TABLE interactive_slide'); } From 366d1d9a4fc800015b8025521404cd877772c151 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Wed, 3 Apr 2024 07:26:30 +0200 Subject: [PATCH 20/27] 964: Applied coding standards --- .../MicrosoftGraphQuickBook.php | 125 ++++++++++++------ src/Service/InteractiveSlideService.php | 9 -- 2 files changed, 83 insertions(+), 51 deletions(-) diff --git a/src/InteractiveSlide/MicrosoftGraphQuickBook.php b/src/InteractiveSlide/MicrosoftGraphQuickBook.php index d4c1bdbd..a12d1149 100644 --- a/src/InteractiveSlide/MicrosoftGraphQuickBook.php +++ b/src/InteractiveSlide/MicrosoftGraphQuickBook.php @@ -4,6 +4,7 @@ namespace App\InteractiveSlide; +use App\Entity\ScreenUser; use App\Entity\Tenant; use App\Entity\Tenant\InteractiveSlide; use App\Entity\Tenant\Slide; @@ -18,7 +19,6 @@ use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; /** @@ -35,12 +35,15 @@ class MicrosoftGraphQuickBook implements InteractiveSlideInterface private const string OAUTH_PATH = '/oauth2/v2.0/token'; private const string SCOPE = 'https://graph.microsoft.com/.default'; private const string GRANT_TYPE = 'password'; - private const string CACHE_PREFIX = 'MSGraphQuickBook'; + private const string CACHE_PREFIX = 'MS-INSTANT-BOOK'; + private const string CACHE_KEY_TOKEN_PREFIX = self::CACHE_PREFIX.'-TOKEN-'; + private const string CACHE_KEY_OPTIONS_PREFIX = self::CACHE_PREFIX.'-OPTIONS-'; + private const string CACHE_PREFIX_SPAM_PROTECT_PREFIX = self::CACHE_PREFIX.'-SPAM-PROTECT-'; + private const string CACHE_KEY_RESOURCES = self::CACHE_PREFIX.'-RESOURCES'; private const string BOOKING_TITLE = 'Straksbooking'; private const array DURATIONS = [15, 30, 60]; private const string CACHE_LIFETIME_QUICK_BOOK_OPTIONS = 'PT5M'; private const string CACHE_LIFETIME_QUICK_BOOK_SPAM_PROTECT = 'PT1M'; - // see https://docs.microsoft.com/en-us/graph/api/resources/datetimetimezone?view=graph-rest-1.0 // example 2019-03-15T09:00:00 public const string GRAPH_DATE_FORMAT = 'Y-m-d\TH:i:s'; @@ -125,7 +128,7 @@ private function getToken(Tenant $tenant, InteractiveSlide $interactive): string } return $this->interactiveSlideCache->get( - self::CACHE_PREFIX . '-TOKEN-'.$tenant->getTenantKey(), + self::CACHE_KEY_TOKEN_PREFIX.$tenant->getTenantKey(), function (CacheItemInterface $item) use ($configuration): mixed { $arr = $this->authenticate($configuration); @@ -143,25 +146,17 @@ private function getQuickBookOptions(Slide $slide, InteractionSlideRequest $inte { $resource = $interactionRequest->data['resource'] ?? null; - if ($resource === null) { + if (null === $resource) { throw new \Exception('Resource not set.'); } - // Add resource to watchedResources, if not in list. - $cacheKey = self::CACHE_PREFIX."-RESOURCES"; - $watchedResources = $this->interactiveSlideCache->get($cacheKey, fn () => []); - if (!in_array($resource, $watchedResources)) { - $this->interactiveSlideCache->delete($cacheKey); - - $watchedResources[] = $resource; - $this->interactiveSlideCache->get($cacheKey, fn () => $watchedResources); - } - - return $this->interactiveSlideCache->get(self::CACHE_PREFIX.'-QUICK_BOOK_OPTIONS-'.$resource, + return $this->interactiveSlideCache->get(self::CACHE_KEY_OPTIONS_PREFIX.$resource, function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest) { $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS)); - $tenant = $this->security->getUser()->getActiveTenant(); + /** @var User|ScreenUser $activeUser */ + $activeUser = $this->security->getUser(); + $tenant = $activeUser->getActiveTenant(); $interactive = $this->interactiveService->getInteractive($tenant, $interactionRequest->implementationClass); @@ -181,26 +176,43 @@ function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest) $token = $this->getToken($tenant, $interactive); + // Set start to 1 minute into the future, to allow for a bit of working room. $start = (new \DateTime())->add(new \DateInterval('PT1M'))->setTimezone(new \DateTimeZone('UTC')); $startFormatted = $start->format('c'); $startPlus1Hour = (clone $start)->add(new \DateInterval('PT1H'))->setTimezone(new \DateTimeZone('UTC')); - $schedule = $this->getBusyIntervals($token, $interactionRequest->data['resource'], $start, $startPlus1Hour); + // Get resources that are watched for availability. + $watchedResources = $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, fn () => []); + + // Add resource to watchedResources, if not in list. + if (!in_array($resource, $watchedResources)) { + $this->interactiveSlideCache->delete(self::CACHE_KEY_RESOURCES); + + $watchedResources[] = $resource; + $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, fn () => $watchedResources); + } + + $schedules = $this->getBusyIntervals($token, $watchedResources, $start, $startPlus1Hour); $result = []; - foreach (self::DURATIONS as $durationMinutes) { - $startPlus = (clone $start)->add(new \DateInterval('PT'.$durationMinutes.'M'))->setTimezone(new \DateTimeZone('UTC')); - $startPlusFormatted = $startPlus->format('c'); - - if ($this->intervalFree($schedule, $start, $startPlus)) { - $result[] = [ - 'durationMinutes' => $durationMinutes, - 'resource' => $interactionRequest->data['resource'], - 'from' => $startFormatted, - 'to' => $startPlusFormatted, - ]; + // Refresh entries for all watched resources. + foreach ($watchedResources as $watchResource) { + $entry = $this->createEntry($watchResource, $schedules[$watchResource], $startFormatted, $start); + + if ($watchResource == $resource) { + $result = $entry; + } else { + // Refresh cache entry for resources in watch list that are not handled in current request. + $this->interactiveSlideCache->delete(self::CACHE_KEY_OPTIONS_PREFIX.$watchResource); + $this->interactiveSlideCache->get(self::CACHE_KEY_OPTIONS_PREFIX.$watchResource, + function (CacheItemInterface $item) use ($entry) { + $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS)); + + return $entry; + } + ); } } @@ -209,6 +221,28 @@ function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest) ); } + private function createEntry(string $resource, array $schedules, string $startFormatted, \DateTime $start): array + { + $entry = [ + 'resource' => $resource, + 'from' => $startFormatted, + 'options' => [], + ]; + + foreach (self::DURATIONS as $durationMinutes) { + $startPlus = (clone $start)->add(new \DateInterval('PT'.$durationMinutes.'M'))->setTimezone(new \DateTimeZone('UTC')); + + if ($this->intervalFree($schedules, $start, $startPlus)) { + $entry['options'][] = [ + 'durationMinutes' => $durationMinutes, + 'to' => $startPlus->format('c'), + ]; + } + } + + return $entry; + } + /** * @throws \Throwable */ @@ -221,18 +255,21 @@ private function quickBook(Slide $slide, InteractionSlideRequest $interactionReq // Make sure that booking requests are not spammed. $lastRequestDateTime = $this->interactiveSlideCache->get( - self::CACHE_PREFIX."-SPAM-PROTECT-".$slide->getId(), + self::CACHE_PREFIX_SPAM_PROTECT_PREFIX.$slide->getId(), function (CacheItemInterface $item) use ($now): \DateTime { $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_SPAM_PROTECT)); + return $now; } ); - if (($lastRequestDateTime)->add(new \DateInterval('PT1M')) > $now) { + if ($lastRequestDateTime->add(new \DateInterval('PT1M')) > $now) { throw new ServiceUnavailableHttpException(60); } - $tenant = $this->security->getUser()->getActiveTenant(); + /** @var User|ScreenUser $activeUser */ + $activeUser = $this->security->getUser(); + $tenant = $activeUser->getActiveTenant(); $interactive = $this->interactiveService->getInteractive($tenant, $interactionRequest->implementationClass); @@ -260,12 +297,13 @@ function (CacheItemInterface $item) use ($now): \DateTime { $username = $this->keyValueService->getValue($configuration['username']); - $start = (new \DateTime())->setTimezone(new \DateTimeZone('UTC'));; - $startPlusDuration = (clone $start)->add(new \DateInterval('PT'.$durationMinutes.'M'))->setTimezone(new \DateTimeZone('UTC'));; + $start = (new \DateTime())->setTimezone(new \DateTimeZone('UTC')); + $startPlusDuration = (clone $start)->add(new \DateInterval('PT'.$durationMinutes.'M'))->setTimezone(new \DateTimeZone('UTC')); // Make sure interval is free. - if (count($this->getBusyIntervals($token, $resource, $start, $startPlusDuration)) > 0) { - throw new ConflictHttpException("Interval booked already"); + $busyIntervals = $this->getBusyIntervals($token, [$resource], $start, $startPlusDuration); + if (count($busyIntervals[$resource]) > 0) { + throw new ConflictHttpException('Interval booked already'); } $requestBody = [ @@ -309,12 +347,13 @@ function (CacheItemInterface $item) use ($now): \DateTime { /** * @see https://docs.microsoft.com/en-us/graph/api/calendar-getschedule?view=graph-rest-1.0&tabs=http + * * @throws \Throwable */ - public function getBusyIntervals(string $token, string $resource, \DateTime $startTime, \DateTime $endTime): array + public function getBusyIntervals(string $token, array $resources, \DateTime $startTime, \DateTime $endTime): array { $body = [ - 'schedules' => [$resource], + 'schedules' => $resources, 'availabilityViewInterval' => '15', 'startTime' => [ 'dateTime' => $startTime->setTimezone(new \DateTimeZone('UTC'))->format(self::GRAPH_DATE_FORMAT), @@ -338,6 +377,8 @@ public function getBusyIntervals(string $token, string $resource, \DateTime $sta $result = []; foreach ($scheduleData as $schedule) { + $scheduleId = $schedule['scheduleId']; + $result[$scheduleId] = []; foreach ($schedule['scheduleItems'] as $scheduleItem) { $eventStartArray = $scheduleItem['start']; $eventEndArray = $scheduleItem['end']; @@ -345,7 +386,7 @@ public function getBusyIntervals(string $token, string $resource, \DateTime $sta $start = new \DateTime($eventStartArray['dateTime'], new \DateTimeZone($eventStartArray['timeZone'])); $end = new \DateTime($eventEndArray['dateTime'], new \DateTimeZone($eventStartArray['timeZone'])); - $result[] = [ + $result[$scheduleId][] = [ 'startTime' => $start, 'endTime' => $end, ]; @@ -370,13 +411,13 @@ private function getValueFromInterval(string $key, InteractionSlideRequest $inte { $interval = $interactionRequest->data['interval'] ?? null; - if ($interval === null) { - throw new \Exception("interval not set."); + if (null === $interval) { + throw new \Exception('interval not set.'); } $value = $interval[$key] ?? null; - if ($value === null) { + if (null === $value) { throw new \Exception("interval.'.$key.' not set."); } diff --git a/src/Service/InteractiveSlideService.php b/src/Service/InteractiveSlideService.php index 56470ddd..f285afc3 100644 --- a/src/Service/InteractiveSlideService.php +++ b/src/Service/InteractiveSlideService.php @@ -138,13 +138,4 @@ public function saveConfiguration(Tenant $tenant, string $implementationClass, a $this->entityManager->flush(); } - - /** - * @TODO: Describe. - */ - public function getConfigOptions(): array - { - // TODO: Implement getConfigOptions() method. - return []; - } } From 46ad32e58c7b12bbbcf9ea595007e6373035a439 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Wed, 3 Apr 2024 07:34:02 +0200 Subject: [PATCH 21/27] 964: Updated api spec --- public/api-spec-v1.json | 10 ++++++++-- public/api-spec-v1.yaml | 8 ++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/public/api-spec-v1.json b/public/api-spec-v1.json index 16a9d899..2ef3df55 100755 --- a/public/api-spec-v1.json +++ b/public/api-spec-v1.json @@ -13188,7 +13188,10 @@ "deprecated": false, "properties": { "action": { - "type": "string" + "type": [ + "string", + "null" + ] }, "data": { "type": "array", @@ -13204,7 +13207,10 @@ "deprecated": false, "properties": { "action": { - "type": "string" + "type": [ + "string", + "null" + ] }, "data": { "type": "array", diff --git a/public/api-spec-v1.yaml b/public/api-spec-v1.yaml index 9c7963ae..f2e11eeb 100755 --- a/public/api-spec-v1.yaml +++ b/public/api-spec-v1.yaml @@ -9170,7 +9170,9 @@ components: deprecated: false properties: action: - type: string + type: + - string + - 'null' data: type: array items: @@ -9181,7 +9183,9 @@ components: deprecated: false properties: action: - type: string + type: + - string + - 'null' data: type: array items: From be7db40e89b2dc7038c35446df96a8497a7a4e63 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 4 Apr 2024 14:54:38 +0200 Subject: [PATCH 22/27] 964: Fixed test --- tests/Service/InteractiveServiceTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Service/InteractiveServiceTest.php b/tests/Service/InteractiveServiceTest.php index fac2456c..d2d6bd95 100644 --- a/tests/Service/InteractiveServiceTest.php +++ b/tests/Service/InteractiveServiceTest.php @@ -77,7 +77,7 @@ public function testPerformAction(): void ]); $this->expectException(InteractiveSlideException::class); - $this->expectExceptionMessage('Interactive not found'); + $this->expectExceptionMessage('Interactive slide not found'); $tenant = $user->getActiveTenant(); From bd796beb9bed5c5f4f93f43b9d95afdec9a7bac4 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 8 Apr 2024 12:19:30 +0200 Subject: [PATCH 23/27] 366: Renamed classes --- src/Entity/Tenant/InteractiveSlide.php | 4 ++-- ...softGraphQuickBook.php => InstantBook.php} | 6 +++--- ...ory.php => InteractiveSlideRepository.php} | 2 +- src/Service/InteractiveSlideService.php | 20 +++++++++---------- ...hQuickBookTest.php => InstantBookTest.php} | 19 +++--------------- tests/Service/InteractiveServiceTest.php | 12 +++++------ 6 files changed, 25 insertions(+), 38 deletions(-) rename src/InteractiveSlide/{MicrosoftGraphQuickBook.php => InstantBook.php} (98%) rename src/Repository/{InteractiveRepository.php => InteractiveSlideRepository.php} (91%) rename tests/Interactive/{MicrosoftGraphQuickBookTest.php => InstantBookTest.php} (75%) diff --git a/src/Entity/Tenant/InteractiveSlide.php b/src/Entity/Tenant/InteractiveSlide.php index cb183f66..faf36d67 100644 --- a/src/Entity/Tenant/InteractiveSlide.php +++ b/src/Entity/Tenant/InteractiveSlide.php @@ -4,11 +4,11 @@ namespace App\Entity\Tenant; -use App\Repository\InteractiveRepository; +use App\Repository\InteractiveSlideRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation\Ignore; -#[ORM\Entity(repositoryClass: InteractiveRepository::class)] +#[ORM\Entity(repositoryClass: InteractiveSlideRepository::class)] class InteractiveSlide extends AbstractTenantScopedEntity { #[Ignore] diff --git a/src/InteractiveSlide/MicrosoftGraphQuickBook.php b/src/InteractiveSlide/InstantBook.php similarity index 98% rename from src/InteractiveSlide/MicrosoftGraphQuickBook.php rename to src/InteractiveSlide/InstantBook.php index a12d1149..c0bec69c 100644 --- a/src/InteractiveSlide/MicrosoftGraphQuickBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -26,7 +26,7 @@ * * Only resources attached to the slide through slide.feed.configuration.resources can be booked from the slide. */ -class MicrosoftGraphQuickBook implements InteractiveSlideInterface +class InstantBook implements InteractiveSlideInterface { private const string ACTION_GET_QUICK_BOOK_OPTIONS = 'ACTION_GET_QUICK_BOOK_OPTIONS'; private const string ACTION_QUICK_BOOK = 'ACTION_QUICK_BOOK'; @@ -158,7 +158,7 @@ function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest) $activeUser = $this->security->getUser(); $tenant = $activeUser->getActiveTenant(); - $interactive = $this->interactiveService->getInteractive($tenant, $interactionRequest->implementationClass); + $interactive = $this->interactiveService->getInteractiveSlide($tenant, $interactionRequest->implementationClass); if (null === $interactive) { throw new \Exception('InteractiveNotFound'); @@ -271,7 +271,7 @@ function (CacheItemInterface $item) use ($now): \DateTime { $activeUser = $this->security->getUser(); $tenant = $activeUser->getActiveTenant(); - $interactive = $this->interactiveService->getInteractive($tenant, $interactionRequest->implementationClass); + $interactive = $this->interactiveService->getInteractiveSlide($tenant, $interactionRequest->implementationClass); if (null === $interactive) { throw new \Exception('InteractiveNotFound'); diff --git a/src/Repository/InteractiveRepository.php b/src/Repository/InteractiveSlideRepository.php similarity index 91% rename from src/Repository/InteractiveRepository.php rename to src/Repository/InteractiveSlideRepository.php index 6229a973..a9d52ab9 100644 --- a/src/Repository/InteractiveRepository.php +++ b/src/Repository/InteractiveSlideRepository.php @@ -16,7 +16,7 @@ * @method InteractiveSlide[] findAll() * @method InteractiveSlide[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ -class InteractiveRepository extends ServiceEntityRepository +class InteractiveSlideRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { diff --git a/src/Service/InteractiveSlideService.php b/src/Service/InteractiveSlideService.php index f285afc3..e4c8a680 100644 --- a/src/Service/InteractiveSlideService.php +++ b/src/Service/InteractiveSlideService.php @@ -12,7 +12,7 @@ use App\Exceptions\InteractiveSlideException; use App\InteractiveSlide\InteractionSlideRequest; use App\InteractiveSlide\InteractiveSlideInterface; -use App\Repository\InteractiveRepository; +use App\Repository\InteractiveSlideRepository; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Security\Core\User\UserInterface; @@ -24,7 +24,7 @@ public function __construct( /** @var array $interactives */ private iterable $interactiveImplementations, - private InteractiveRepository $interactiveRepository, + private InteractiveSlideRepository $interactiveSlideRepository, private EntityManagerInterface $entityManager, ) {} @@ -49,7 +49,7 @@ public function parseRequestBody(array $requestBody): InteractionSlideRequest } /** - * @TODO: Describe. + * Perform an action for an interactive slide. * * @throws InteractiveSlideException */ @@ -63,7 +63,7 @@ public function performAction(UserInterface $user, Slide $slide, InteractionSlid $implementationClass = $interactionRequest->implementationClass; - $interactive = $this->getInteractive($tenant, $implementationClass); + $interactive = $this->getInteractiveSlide($tenant, $implementationClass); if (null === $interactive) { throw new InteractiveSlideException('Interactive slide not found'); @@ -89,7 +89,7 @@ public function getConfigurables(): array } /** - * @TODO: Describe. + * Find the implementation class. * * @throws InteractiveSlideException */ @@ -106,22 +106,22 @@ public function getImplementation(?string $implementationClass): InteractiveSlid } /** - * @TODO: Describe. + * Get the interactive slide. */ - public function getInteractive(Tenant $tenant, string $implementationClass): ?InteractiveSlide + public function getInteractiveSlide(Tenant $tenant, string $implementationClass): ?InteractiveSlide { - return $this->interactiveRepository->findOneBy([ + return $this->interactiveSlideRepository->findOneBy([ 'implementationClass' => $implementationClass, 'tenant' => $tenant, ]); } /** - * @TODO: Describe. + * Save configuration for a interactive slide. */ public function saveConfiguration(Tenant $tenant, string $implementationClass, array $configuration): void { - $entry = $this->interactiveRepository->findOneBy([ + $entry = $this->interactiveSlideRepository->findOneBy([ 'implementationClass' => $implementationClass, 'tenant' => $tenant, ]); diff --git a/tests/Interactive/MicrosoftGraphQuickBookTest.php b/tests/Interactive/InstantBookTest.php similarity index 75% rename from tests/Interactive/MicrosoftGraphQuickBookTest.php rename to tests/Interactive/InstantBookTest.php index c3ecf39a..f1fb4b1a 100644 --- a/tests/Interactive/MicrosoftGraphQuickBookTest.php +++ b/tests/Interactive/InstantBookTest.php @@ -4,13 +4,13 @@ namespace App\Tests\Interactive; -use App\InteractiveSlide\MicrosoftGraphQuickBook; +use App\InteractiveSlide\InstantBook; use Doctrine\ORM\EntityManager; use Hautelook\AliceBundle\PhpUnit\BaseDatabaseTrait; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\DependencyInjection\ContainerInterface; -class MicrosoftGraphQuickBookTest extends KernelTestCase +class InstantBookTest extends KernelTestCase { use BaseDatabaseTrait; @@ -32,22 +32,9 @@ public function setUp(): void $this->entityManager = $this->container->get('doctrine')->getManager(); } - /* - public function testGetBookingOptions(): void - { - // TODO: Add tests. - $this->assertEquals(1, 1); - } - - public function testCreateBooking(): void - { - // TODO: Add tests. - $this->assertEquals(1, 1); - } - */ public function testIntervalFree(): void { - $service = $this->container->get(MicrosoftGraphQuickBook::class); + $service = $this->container->get(InstantBook::class); $schedules = [ [ diff --git a/tests/Service/InteractiveServiceTest.php b/tests/Service/InteractiveServiceTest.php index d2d6bd95..eea83c7c 100644 --- a/tests/Service/InteractiveServiceTest.php +++ b/tests/Service/InteractiveServiceTest.php @@ -6,8 +6,8 @@ use App\Entity\Tenant\Slide; use App\Exceptions\InteractiveSlideException; +use App\InteractiveSlide\InstantBook; use App\InteractiveSlide\InteractionSlideRequest; -use App\InteractiveSlide\MicrosoftGraphQuickBook; use App\Repository\UserRepository; use App\Service\InteractiveSlideService; use Doctrine\ORM\EntityManager; @@ -48,7 +48,7 @@ public function testParseRequestBody(): void ]); $interactionRequest = $interactiveService->parseRequestBody([ - 'implementationClass' => MicrosoftGraphQuickBook::class, + 'implementationClass' => InstantBook::class, 'action' => 'test', 'data' => [], ]); @@ -71,7 +71,7 @@ public function testPerformAction(): void $slide = new Slide(); $interactionRequest = $interactiveService->parseRequestBody([ - 'implementationClass' => MicrosoftGraphQuickBook::class, + 'implementationClass' => InstantBook::class, 'action' => 'ACTION_NOT_EXIST', 'data' => [], ]); @@ -83,7 +83,7 @@ public function testPerformAction(): void $interactiveService->performAction($user, $slide, $interactionRequest); - $interactiveService->saveConfiguration($tenant, MicrosoftGraphQuickBook::class, []); + $interactiveService->saveConfiguration($tenant, InstantBook::class, []); $this->expectException(InteractiveSlideException::class); $this->expectExceptionMessage('Action not allowed'); @@ -102,9 +102,9 @@ public function testGetImplementation(): void { $interactiveService = $this->container->get(InteractiveSlideService::class); - $service = $interactiveService->getImplementation(MicrosoftGraphQuickBook::class); + $service = $interactiveService->getImplementation(InstantBook::class); - $instanceOf = $service instanceof MicrosoftGraphQuickBook; + $instanceOf = $service instanceof InstantBook; $this->assertTrue($instanceOf); } From f7ff802817edb8832f8950cc23ba7ceb8a1849e9 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 8 Apr 2024 12:28:20 +0200 Subject: [PATCH 24/27] 964: Removed azure key vault references --- src/Service/KeyVaultService.php | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/Service/KeyVaultService.php b/src/Service/KeyVaultService.php index f7fe9e8a..dcd4bd7f 100644 --- a/src/Service/KeyVaultService.php +++ b/src/Service/KeyVaultService.php @@ -8,7 +8,6 @@ { // APP_KEY_VAULT_SOURCE (set in environment/.env) options: public const ENVIRONMENT = 'ENVIRONMENT'; - public const AZURE_KEY_VAULT = 'AZURE_KEY_VAULT'; public function __construct( private string $keyVaultSource, @@ -24,7 +23,6 @@ public function getValue(string $key): ?string { return match ($this->keyVaultSource) { self::ENVIRONMENT => $this->getValueFromEnvironment($key), - self::AZURE_KEY_VAULT => $this->getValueFromAzureKeyVault($key), }; } @@ -32,12 +30,4 @@ private function getValueFromEnvironment(string $key): ?string { return $this->keyVaultArray[$key] ?? null; } - - private function getValueFromAzureKeyVault(string $key): ?string - { - // TODO: Add support for Azure KeyVault. - // https://github.com/itk-dev/AzureKeyVaultPhp - - throw new \Exception('Not implemented'); - } } From d5c0231575e6ca0dd143e93432fd72615fc005cf Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 11 Apr 2024 09:34:38 +0200 Subject: [PATCH 25/27] Update tests/Interactive/InstantBookTest.php Co-authored-by: Mikkel Ricky --- tests/Interactive/InstantBookTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Interactive/InstantBookTest.php b/tests/Interactive/InstantBookTest.php index f1fb4b1a..53111163 100644 --- a/tests/Interactive/InstantBookTest.php +++ b/tests/Interactive/InstantBookTest.php @@ -38,7 +38,7 @@ public function testIntervalFree(): void $schedules = [ [ - 'startTime' => (new \DateTime())->add(new \DateInterval('PT30M')), + 'startTime' => new \DateTime('+30 min'), 'endTime' => (new \DateTime())->add(new \DateInterval('PT1H')), ], ]; From ca564039d594d124918c155afb0b2fdfc26210b4 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 11 Apr 2024 09:39:16 +0200 Subject: [PATCH 26/27] 964: Changed exception type --- src/Feed/KobaFeedType.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Feed/KobaFeedType.php b/src/Feed/KobaFeedType.php index 8ee70573..96fc0405 100644 --- a/src/Feed/KobaFeedType.php +++ b/src/Feed/KobaFeedType.php @@ -80,7 +80,7 @@ public function getData(Feed $feed): array if (!is_string($title)) { $this->logger->error('KobaFeedType: event_name is not string.'); - throw new \Exception('Koba event_name is not string'); + throw new \InvalidArgumentException('Koba event_name is not string'); } // Apply list filter. If enabled it removes all events that do not have (liste) in title. From 8bb416cade998e9e9811de04a29b84cb4feaa069 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Fri, 12 Apr 2024 12:54:50 +0200 Subject: [PATCH 27/27] 964: Fixed issues raised in code review --- psalm-baseline.xml | 2 +- src/InteractiveSlide/InstantBook.php | 5 ++--- tests/Interactive/InstantBookTest.php | 8 ++++---- tests/Service/InteractiveServiceTest.php | 3 --- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 4cdc2101..d3120752 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -745,8 +745,8 @@ - + diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index c0bec69c..0ec19002 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -176,8 +176,7 @@ function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest) $token = $this->getToken($tenant, $interactive); - // Set start to 1 minute into the future, to allow for a bit of working room. - $start = (new \DateTime())->add(new \DateInterval('PT1M'))->setTimezone(new \DateTimeZone('UTC')); + $start = (new \DateTime())->setTimezone(new \DateTimeZone('UTC')); $startFormatted = $start->format('c'); $startPlus1Hour = (clone $start)->add(new \DateInterval('PT1H'))->setTimezone(new \DateTimeZone('UTC')); @@ -263,7 +262,7 @@ function (CacheItemInterface $item) use ($now): \DateTime { } ); - if ($lastRequestDateTime->add(new \DateInterval('PT1M')) > $now) { + if ($lastRequestDateTime->add(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_SPAM_PROTECT)) > $now) { throw new ServiceUnavailableHttpException(60); } diff --git a/tests/Interactive/InstantBookTest.php b/tests/Interactive/InstantBookTest.php index 53111163..17b405b1 100644 --- a/tests/Interactive/InstantBookTest.php +++ b/tests/Interactive/InstantBookTest.php @@ -38,15 +38,15 @@ public function testIntervalFree(): void $schedules = [ [ - 'startTime' => new \DateTime('+30 min'), - 'endTime' => (new \DateTime())->add(new \DateInterval('PT1H')), + 'startTime' => new \DateTime('+30 minutes'), + 'endTime' => (new \DateTime('+1 hour')), ], ]; - $intervalFree = $service->intervalFree($schedules, new \DateTime(), (new \DateTime())->add(new \DateInterval('PT15M'))); + $intervalFree = $service->intervalFree($schedules, new \DateTime(), new \DateTime('+15 minutes')); $this->assertTrue($intervalFree); - $intervalFree = $service->intervalFree($schedules, (new \DateTime())->add(new \DateInterval('PT15M')), (new \DateTime())->add(new \DateInterval('PT45M'))); + $intervalFree = $service->intervalFree($schedules, new \DateTime('+15 minutes'), new \DateTime('+45 minutes')); $this->assertFalse($intervalFree); } } diff --git a/tests/Service/InteractiveServiceTest.php b/tests/Service/InteractiveServiceTest.php index eea83c7c..5f5fee8b 100644 --- a/tests/Service/InteractiveServiceTest.php +++ b/tests/Service/InteractiveServiceTest.php @@ -58,9 +58,6 @@ public function testParseRequestBody(): void $this->assertTrue($correctReturnType); } - /** - * @throws \Exception - */ public function testPerformAction(): void { $interactiveService = $this->container->get(InteractiveSlideService::class);