diff --git a/apps/dav/lib/Controller/OutOfOfficeController.php b/apps/dav/lib/Controller/OutOfOfficeController.php index 8fdbf00c5627f..fe4200ee1b59d 100644 --- a/apps/dav/lib/Controller/OutOfOfficeController.php +++ b/apps/dav/lib/Controller/OutOfOfficeController.php @@ -35,7 +35,7 @@ use OCP\IRequest; /** - * @psalm-import-type DavOutOfOfficeData from ResponseDefinitions + * @psalm-import-type DAVOutOfOfficeData from ResponseDefinitions */ class OutOfOfficeController extends OCSController { @@ -54,7 +54,7 @@ public function __construct( * @NoCSRFRequired * * @param string $userId The user id to get out-of-office data for. - * @return DataResponse + * @return DataResponse * * 200: Out-of-office data * 404: No out-of-office data was found diff --git a/apps/dav/lib/Db/Absence.php b/apps/dav/lib/Db/Absence.php index f705b99ef3058..e9ce1d2ea6496 100644 --- a/apps/dav/lib/Db/Absence.php +++ b/apps/dav/lib/Db/Absence.php @@ -26,8 +26,13 @@ namespace OCA\DAV\Db; +use DateTimeImmutable; +use InvalidArgumentException; use JsonSerializable; +use OC\User\OutOfOfficeData; use OCP\AppFramework\Db\Entity; +use OCP\IUser; +use OCP\User\IOutOfOfficeData; /** * @method string getUserId() @@ -43,8 +48,13 @@ */ class Absence extends Entity implements JsonSerializable { protected string $userId = ''; + + /** Inclusive, formatted as YYYY-MM-DD */ protected string $firstDay = ''; + + /** Inclusive, formatted as YYYY-MM-DD */ protected string $lastDay = ''; + protected string $status = ''; protected string $message = ''; @@ -56,6 +66,24 @@ public function __construct() { $this->addType('message', 'string'); } + public function toOutOufOfficeData(IUser $user): IOutOfOfficeData { + if ($user->getUID() !== $this->getUserId()) { + throw new InvalidArgumentException("The user doesn't match the user id of this absence! Expected " . $this->getUserId() . ", got " . $user->getUID()); + } + + //$user = $userManager->get($this->getUserId()); + $startDate = new DateTimeImmutable($this->getFirstDay()); + $endDate = new DateTimeImmutable($this->getLastDay()); + return new OutOfOfficeData( + (string)$this->getId(), + $user, + $startDate->getTimestamp(), + $endDate->getTimestamp(), + $this->getStatus(), + $this->getMessage(), + ); + } + public function jsonSerialize(): array { return [ 'userId' => $this->userId, diff --git a/apps/dav/lib/ResponseDefinitions.php b/apps/dav/lib/ResponseDefinitions.php index 9681945e7e804..97bd8e9efe907 100644 --- a/apps/dav/lib/ResponseDefinitions.php +++ b/apps/dav/lib/ResponseDefinitions.php @@ -27,7 +27,7 @@ namespace OCA\DAV; /** - * @psalm-type DavOutOfOfficeData = array{ + * @psalm-type DAVOutOfOfficeData = array{ * id: int, * userId: string, * firstDay: string, diff --git a/apps/dav/lib/Service/AbsenceService.php b/apps/dav/lib/Service/AbsenceService.php index 228007b3af114..69dee1bd8cc36 100644 --- a/apps/dav/lib/Service/AbsenceService.php +++ b/apps/dav/lib/Service/AbsenceService.php @@ -26,18 +26,30 @@ namespace OCA\DAV\Service; +use InvalidArgumentException; use OCA\DAV\Db\Absence; use OCA\DAV\Db\AbsenceMapper; use OCP\AppFramework\Db\DoesNotExistException; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IUserManager; +use OCP\User\Events\OutOfOfficeChangedEvent; +use OCP\User\Events\OutOfOfficeClearedEvent; +use OCP\User\Events\OutOfOfficeScheduledEvent; class AbsenceService { public function __construct( private AbsenceMapper $absenceMapper, + private IEventDispatcher $eventDispatcher, + private IUserManager $userManager, ) { } /** + * @param string $firstDay The first day (inclusive) of the absence formatted as YYYY-MM-DD. + * @param string $lastDay The last day (inclusive) of the absence formatted as YYYY-MM-DD. + * * @throws \OCP\DB\Exception + * @throws InvalidArgumentException If no user with the given user id exists. */ public function createOrUpdateAbsence( string $userId, @@ -58,9 +70,19 @@ public function createOrUpdateAbsence( $absence->setStatus($status); $absence->setMessage($message); + // TODO: this method should probably just take a IUser instance + $user = $this->userManager->get($userId); + if ($user === null) { + throw new InvalidArgumentException("User $userId does not exist"); + } + $eventData = $absence->toOutOufOfficeData($user); + if ($absence->getId() === null) { + $this->eventDispatcher->dispatchTyped(new OutOfOfficeScheduledEvent($eventData)); return $this->absenceMapper->insert($absence); } + + $this->eventDispatcher->dispatchTyped(new OutOfOfficeChangedEvent($eventData)); return $this->absenceMapper->update($absence); } @@ -68,7 +90,20 @@ public function createOrUpdateAbsence( * @throws \OCP\DB\Exception */ public function clearAbsence(string $userId): void { - $this->absenceMapper->deleteByUserId($userId); + try { + $absence = $this->absenceMapper->findByUserId($userId); + } catch (DoesNotExistException $e) { + // Nothing to clear + return; + } + $this->absenceMapper->delete($absence); + // TODO: this method should probably just take a IUser instance + $user = $this->userManager->get($userId); + if ($user === null) { + throw new InvalidArgumentException("User $userId does not exist"); + } + $eventData = $absence->toOutOufOfficeData($user); + $this->eventDispatcher->dispatchTyped(new OutOfOfficeClearedEvent($eventData)); } } diff --git a/lib/private/User/AvailabilityCoordinator.php b/lib/private/User/AvailabilityCoordinator.php index 12ad9c8d49d2d..fe0db92fd0f0c 100644 --- a/lib/private/User/AvailabilityCoordinator.php +++ b/lib/private/User/AvailabilityCoordinator.php @@ -6,6 +6,7 @@ * @copyright 2023 Christoph Wurst * * @author 2023 Christoph Wurst + * @author Richard Steinmetz * * @license GNU AGPL version 3 or any later version * @@ -25,14 +26,86 @@ namespace OC\User; +use JsonException; +use OCA\DAV\Db\AbsenceMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\ICache; +use OCP\ICacheFactory; use OCP\IUser; use OCP\User\IAvailabilityCoordinator; use OCP\User\IOutOfOfficeData; +use Psr\Log\LoggerInterface; class AvailabilityCoordinator implements IAvailabilityCoordinator { + private ICache $cache; - public function getCurrentOutOfOfficeData(IUser $user): ?IOutOfOfficeData { - return null; + public function __construct( + ICacheFactory $cacheFactory, + private AbsenceMapper $absenceMapper, + private LoggerInterface $logger, + ) { + $this->cache = $cacheFactory->createLocal('OutOfOfficeData'); + } + + private function getCachedOutOfOfficeData(IUser $user): ?OutOfOfficeData { + $cachedString = $this->cache->get($user->getUID()); + if ($cachedString === null) { + return null; + } + + try { + $cachedData = json_decode($cachedString, true, 10, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + $this->logger->error('Failed to deserialize cached out-of-office data: ' . $e->getMessage(), [ + 'exception' => $e, + 'json' => $cachedString, + ]); + return null; + } + + return new OutOfOfficeData( + $cachedData['id'], + $user, + $cachedData['startDate'], + $cachedData['endDate'], + $cachedData['shortMessage'], + $cachedData['message'], + ); + } + + private function setCachedOutOfOfficeData(IOutOfOfficeData $data): void { + try { + $cachedString = json_encode([ + 'id' => $data->getId(), + 'startDate' => $data->getStartDate(), + 'endDate' => $data->getEndDate(), + 'shortMessage' => $data->getShortMessage(), + 'message' => $data->getMessage(), + ], JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + $this->logger->error('Failed to serialize out-of-office data: ' . $e->getMessage(), [ + 'exception' => $e, + ]); + return; + } + + $this->cache->set($data->getUser()->getUID(), $cachedString, 300); } + public function getCurrentOutOfOfficeData(IUser $user): ?IOutOfOfficeData { + $cachedData = $this->getCachedOutOfOfficeData($user); + if ($cachedData !== null) { + return $cachedData; + } + + try { + $absenceData = $this->absenceMapper->findByUserId($user->getUID()); + } catch (DoesNotExistException $e) { + return null; + } + + $data = $absenceData->toOutOufOfficeData($user); + $this->setCachedOutOfOfficeData($data); + return $data; + } } diff --git a/lib/private/User/OutOfOfficeData.php b/lib/private/User/OutOfOfficeData.php index d5625422fbd0c..12b7e03a0ae84 100644 --- a/lib/private/User/OutOfOfficeData.php +++ b/lib/private/User/OutOfOfficeData.php @@ -29,7 +29,6 @@ use OCP\User\IOutOfOfficeData; class OutOfOfficeData implements IOutOfOfficeData { - public function __construct(private string $id, private IUser $user, private int $startDate, diff --git a/lib/public/User/Events/OutOfOfficeChangedEvent.php b/lib/public/User/Events/OutOfOfficeChangedEvent.php index 4c7c90a8d216b..5e5753b72026d 100644 --- a/lib/public/User/Events/OutOfOfficeChangedEvent.php +++ b/lib/public/User/Events/OutOfOfficeChangedEvent.php @@ -34,7 +34,6 @@ * @since 28.0.0 */ class OutOfOfficeChangedEvent extends Event { - /** * @since 28.0.0 */ @@ -48,5 +47,4 @@ public function __construct(private IOutOfOfficeData $data) { public function getData(): IOutOfOfficeData { return $this->data; } - } diff --git a/lib/public/User/Events/OutOfOfficeClearedEvent.php b/lib/public/User/Events/OutOfOfficeClearedEvent.php new file mode 100644 index 0000000000000..48a77c77023aa --- /dev/null +++ b/lib/public/User/Events/OutOfOfficeClearedEvent.php @@ -0,0 +1,50 @@ + + * + * @author 2023 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCP\User\Events; + +use OCP\EventDispatcher\Event; +use OCP\User\IOutOfOfficeData; + +/** + * Emitted when a user's out-of-office period is cleared + * + * @since 28.0.0 + */ +class OutOfOfficeClearedEvent extends Event { + /** + * @since 28.0.0 + */ + public function __construct(private IOutOfOfficeData $data) { + parent::__construct(); + } + + /** + * @since 28.0.0 + */ + public function getData(): IOutOfOfficeData { + return $this->data; + } +} diff --git a/lib/public/User/Events/OutOfOfficeEndedEvent.php b/lib/public/User/Events/OutOfOfficeEndedEvent.php index 351ab82af7a3c..912d3e6b4d4b3 100644 --- a/lib/public/User/Events/OutOfOfficeEndedEvent.php +++ b/lib/public/User/Events/OutOfOfficeEndedEvent.php @@ -34,7 +34,6 @@ * @since 28.0.0 */ class OutOfOfficeEndedEvent extends Event { - /** * @since 28.0.0 */ @@ -48,5 +47,4 @@ public function __construct(private IOutOfOfficeData $data) { public function getData(): IOutOfOfficeData { return $this->data; } - } diff --git a/lib/public/User/Events/OutOfOfficeScheduledEvent.php b/lib/public/User/Events/OutOfOfficeScheduledEvent.php index 9522e6bf60447..2bcbec6347852 100644 --- a/lib/public/User/Events/OutOfOfficeScheduledEvent.php +++ b/lib/public/User/Events/OutOfOfficeScheduledEvent.php @@ -34,7 +34,6 @@ * @since 28.0.0 */ class OutOfOfficeScheduledEvent extends Event { - /** * @since 28.0.0 */ @@ -48,5 +47,4 @@ public function __construct(private IOutOfOfficeData $data) { public function getData(): IOutOfOfficeData { return $this->data; } - } diff --git a/lib/public/User/Events/OutOfOfficeStartedEvent.php b/lib/public/User/Events/OutOfOfficeStartedEvent.php index ab05eae37f04f..a8660ef41e21b 100644 --- a/lib/public/User/Events/OutOfOfficeStartedEvent.php +++ b/lib/public/User/Events/OutOfOfficeStartedEvent.php @@ -34,7 +34,6 @@ * @since 28.0.0 */ class OutOfOfficeStartedEvent extends Event { - /** * @since 28.0.0 */ @@ -48,5 +47,4 @@ public function __construct(private IOutOfOfficeData $data) { public function getData(): IOutOfOfficeData { return $this->data; } - } diff --git a/lib/public/User/IAvailabilityCoordinator.php b/lib/public/User/IAvailabilityCoordinator.php index c188f4f03760d..113e349171491 100644 --- a/lib/public/User/IAvailabilityCoordinator.php +++ b/lib/public/User/IAvailabilityCoordinator.php @@ -33,12 +33,10 @@ * @since 28.0.0 */ interface IAvailabilityCoordinator { - /** * Get the user's out-of-office message, if any * * @since 28.0.0 */ public function getCurrentOutOfOfficeData(IUser $user): ?IOutOfOfficeData; - } diff --git a/lib/public/User/IOutOfOfficeData.php b/lib/public/User/IOutOfOfficeData.php index d1d03cc969c16..03444449d58f9 100644 --- a/lib/public/User/IOutOfOfficeData.php +++ b/lib/public/User/IOutOfOfficeData.php @@ -33,7 +33,6 @@ * @since 28.0.0 */ interface IOutOfOfficeData { - /** * Get the unique token assigned to the current out-of-office event * @@ -75,5 +74,4 @@ public function getShortMessage(): string; * @since 28.0.0 */ public function getMessage(): string; - } diff --git a/tests/lib/User/AvailabilityCoordinatorTest.php b/tests/lib/User/AvailabilityCoordinatorTest.php new file mode 100644 index 0000000000000..8e847f7e5d57a --- /dev/null +++ b/tests/lib/User/AvailabilityCoordinatorTest.php @@ -0,0 +1,164 @@ + + * + * @author Richard Steinmetz + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +namespace Test\User; + +use OC\User\AvailabilityCoordinator; +use OC\User\OutOfOfficeData; +use OCA\DAV\Db\Absence; +use OCA\DAV\Db\AbsenceMapper; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IUser; +use Psr\Log\LoggerInterface; +use Test\TestCase; + +class AvailabilityCoordinatorTest extends TestCase { + private AvailabilityCoordinator $availabilityCoordinator; + private ICacheFactory $cacheFactory; + private ICache $cache; + private AbsenceMapper $absenceMapper; + private LoggerInterface $logger; + + protected function setUp(): void { + parent::setUp(); + + $this->cacheFactory = $this->createMock(ICacheFactory::class); + $this->cache = $this->createMock(ICache::class); + $this->absenceMapper = $this->createMock(AbsenceMapper::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->cacheFactory->expects(self::once()) + ->method('createLocal') + ->willReturn($this->cache); + + $this->availabilityCoordinator = new AvailabilityCoordinator( + $this->cacheFactory, + $this->absenceMapper, + $this->logger, + ); + } + + public function testGetOutOfOfficeData(): void { + $absence = new Absence(); + $absence->setId(420); + $absence->setUserId('user'); + $absence->setFirstDay('2023-10-01'); + $absence->setLastDay('2023-10-08'); + $absence->setStatus('Vacation'); + $absence->setMessage('On vacation'); + + $user = $this->createMock(IUser::class); + $user->method('getUID') + ->willReturn('user'); + + $this->cache->expects(self::once()) + ->method('get') + ->with('user') + ->willReturn(null); + $this->absenceMapper->expects(self::once()) + ->method('findByUserId') + ->with('user') + ->willReturn($absence); + $this->cache->expects(self::once()) + ->method('set') + ->with('user', '{"id":"420","startDate":1696118400,"endDate":1696723200,"shortMessage":"Vacation","message":"On vacation"}', 300); + + $expected = new OutOfOfficeData( + '420', + $user, + 1696118400, + 1696723200, + 'Vacation', + 'On vacation', + ); + $actual = $this->availabilityCoordinator->getCurrentOutOfOfficeData($user); + self::assertEquals($expected, $actual); + } + + public function testGetOutOfOfficeDataWithCachedData(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID') + ->willReturn('user'); + + $this->cache->expects(self::once()) + ->method('get') + ->with('user') + ->willReturn('{"id":"420","startDate":1696118400,"endDate":1696723200,"shortMessage":"Vacation","message":"On vacation"}'); + $this->absenceMapper->expects(self::never()) + ->method('findByUserId'); + $this->cache->expects(self::never()) + ->method('set'); + + $expected = new OutOfOfficeData( + '420', + $user, + 1696118400, + 1696723200, + 'Vacation', + 'On vacation', + ); + $actual = $this->availabilityCoordinator->getCurrentOutOfOfficeData($user); + self::assertEquals($expected, $actual); + } + + public function testGetOutOfOfficeDataWithInvalidCachedData(): void { + $absence = new Absence(); + $absence->setId(420); + $absence->setUserId('user'); + $absence->setFirstDay('2023-10-01'); + $absence->setLastDay('2023-10-08'); + $absence->setStatus('Vacation'); + $absence->setMessage('On vacation'); + + $user = $this->createMock(IUser::class); + $user->method('getUID') + ->willReturn('user'); + + $this->cache->expects(self::once()) + ->method('get') + ->with('user') + ->willReturn('{"id":"420",}'); + $this->absenceMapper->expects(self::once()) + ->method('findByUserId') + ->with('user') + ->willReturn($absence); + $this->cache->expects(self::once()) + ->method('set') + ->with('user', '{"id":"420","startDate":1696118400,"endDate":1696723200,"shortMessage":"Vacation","message":"On vacation"}', 300); + + $expected = new OutOfOfficeData( + '420', + $user, + 1696118400, + 1696723200, + 'Vacation', + 'On vacation', + ); + $actual = $this->availabilityCoordinator->getCurrentOutOfOfficeData($user); + self::assertEquals($expected, $actual); + } +}