1
0
Fork 0
mirror of https://github.com/nextcloud/calendar.git synced 2024-10-07 16:40:09 +02:00

Add an appointment booking page

Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
This commit is contained in:
Christoph Wurst 2021-10-27 10:28:52 +02:00
parent e1e97a7dcf
commit a902e1aa46
No known key found for this signature in database
GPG key ID: CC42AC2A7F0E56D8
33 changed files with 1621 additions and 688 deletions

View file

@ -36,6 +36,9 @@ return [
['name' => 'view#index', 'url' => '/{view}/{timeRange}/edit/{mode}/{objectId}/{recurrenceId}', 'verb' => 'GET', 'requirements' => ['view' => 'timeGridDay|timeGridWeek|dayGridMonth|listMonth'], 'postfix' => 'view.timerange.edit'],
// Appointments
['name' => 'appointment#index', 'url' => '/appointments/{userId}', 'verb' => 'GET'],
['name' => 'appointment#show', 'url' => '/appointment/{token}', 'verb' => 'GET'],
['name' => 'booking#getBookableSlots', 'url' => '/appointment/{appointmentConfigId}/slots', 'verb' => 'GET'],
['name' => 'booking#bookSlot', 'url' => '/appointment/{appointmentConfigId}/book', 'verb' => 'POST'],
// Public views
['name' => 'publicView#public_index_with_branding', 'url' => '/p/{token}', 'verb' => 'GET'],
['name' => 'publicView#public_index_with_branding', 'url' => '/p/{token}/{view}/{timeRange}', 'verb' => 'GET', 'postfix' => 'publicview.timerange'],

View file

@ -27,13 +27,17 @@ namespace OCA\Calendar\Controller;
use OCA\Calendar\AppInfo\Application;
use OCA\Calendar\Db\AppointmentConfig;
use OCA\Calendar\Exception\ClientException;
use OCA\Calendar\Exception\ServiceException;
use OCA\Calendar\Service\Appointments\AppointmentConfigService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IRequest;
use OCP\IUserManager;
use RuntimeException;
use function array_filter;
class AppointmentController extends Controller {
@ -97,4 +101,49 @@ class AppointmentController extends Controller {
TemplateResponse::RENDER_AS_PUBLIC
);
}
/**
* @PublicPage
* @NoAdminRequired
* @NoCSRFRequired
*
* @return Response
*/
public function show(string $token): Response {
try {
$config = $this->configService->findByToken($token);
} catch (ClientException $e) {
if ($e->getHttpCode() === Http::STATUS_NOT_FOUND) {
return new TemplateResponse(
Application::APP_ID,
'appointments/404-booking',
[],
TemplateResponse::RENDER_AS_GUEST
);
}
}
$user = $this->userManager->get($config->getUserId());
if ($user === null) {
throw new ServiceException("Appointment config $token does not belong to a valid user");
}
$this->initialState->provideInitialState(
'userInfo',
[
'uid' => $user->getUID(),
'displayName' => $user->getDisplayName(),
],
);
$this->initialState->provideInitialState(
'config',
$config
);
return new TemplateResponse(
Application::APP_ID,
'appointments/booking',
[],
TemplateResponse::RENDER_AS_PUBLIC
);
}
}

View file

@ -24,24 +24,17 @@ declare(strict_types=1);
*/
namespace OCA\Calendar\Controller;
use DateTimeImmutable;
use DateTimeZone;
use OCA\Calendar\Exception\ServiceException;
use OCA\Calendar\Http\JsonResponse;
use OCA\Calendar\Service\Appointments\AppointmentConfigService;
use OCA\Calendar\Service\Appointments\Booking;
use OCA\Calendar\Service\Appointments\BookingService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Calendar\IManager;
use OCP\IConfig;
use OCP\IInitialStateService;
use OCP\IRequest;
use OCP\IURLGenerator;
/**
* Class PublicViewController
*
* @package OCA\Calendar\Controller
*/
class BookingController extends Controller {
/** @var BookingService */
@ -53,22 +46,11 @@ class BookingController extends Controller {
/** @var AppointmentConfigService */
private $appointmentConfigService;
/** @var IManager */
private $manager;
/**
* @param string $appName
* @param IRequest $request an instance of the request
* @param IConfig $config
* @param IInitialStateService $initialStateService
* @param IURLGenerator $urlGenerator
*/
public function __construct(string $appName,
IRequest $request,
ITimeFactory $timeFactory,
BookingService $bookingService,
AppointmentConfigService $appointmentConfigService,
IManager $manager) {
AppointmentConfigService $appointmentConfigService) {
parent::__construct($appName, $request);
$this->bookingService = $bookingService;
@ -77,30 +59,63 @@ class BookingController extends Controller {
}
/**
* @throws ServiceException
* @throws \JsonException
* @NoAdminRequired
* @PublicPage
*
* @param int $appointmentConfigId
* @param int $startTime UNIX time stamp for the start time in UTC
* @param int $endTime UNIX time stamp for the start time in UTC
* @param string $timeZone
*
* @return JsonResponse
*/
public function getBookableSlots(int $appointmentConfigId, int $unixStartTime, int $unixEndTime) {
public function getBookableSlots(int $appointmentConfigId,
int $startTime,
int $endTime,
string $timeZone): JsonResponse {
// Convert the timestamps to the beginning and end of the respective day in the specified timezone
$tz = new DateTimeZone($timeZone);
$startTimeInTz = (new DateTimeImmutable())
->setTimestamp($startTime)
->setTimezone($tz)
->setTime(0, 0)
->getTimestamp();
$endTimeInTz = (new DateTimeImmutable())
->setTimestamp($endTime)
->setTimezone($tz)
->setTime(23, 59, 59)
->getTimestamp();
if ($startTimeInTz > $endTimeInTz) {
return JsonResponse::fail('Invalid time range', Http::STATUS_UNPROCESSABLE_ENTITY);
}
// rate limit this to only allow ranges between 0 to 7 days
if (ceil(($unixEndTime - $unixStartTime) / 86400) > 7) {
return JsonResponse::error('Date Range too large.', 403);
if (ceil(($endTimeInTz - $startTimeInTz) / 86400) > 7) {
return JsonResponse::fail('Date Range too large.', Http::STATUS_UNPROCESSABLE_ENTITY);
}
$now = $this->timeFactory->getTime();
if ($now > $endTimeInTz) {
return JsonResponse::fail('Slot time range must be in the future', Http::STATUS_UNPROCESSABLE_ENTITY);
}
if ($this->timeFactory->getTime() > $unixStartTime || $this->timeFactory->getTime() > $unixEndTime) {
throw new ServiceException('Booking time must be in the future', 403);
try {
$config = $this->appointmentConfigService->findById($appointmentConfigId);
} catch (ServiceException $e) {
return JsonResponse::fail(null, Http::STATUS_NOT_FOUND);
}
$appointmentConfig = $this->appointmentConfigService->findById($appointmentConfigId);
$booking = new Booking($appointmentConfig, $unixStartTime, $unixEndTime);
$data = $this->bookingService->getSlots($booking);
return JsonResponse::success($data);
return JsonResponse::success(
$this->bookingService->getAvailableSlots($config, $startTimeInTz, $endTimeInTz)
);
}
/**
* @param string $calendarData
*/
public function bookSlot(string $calendarData) {
$this->bookingService->book($calendarData);
public function bookSlot(int $appointmentConfigId,
int $start,
string $name,
string $email,
string $description): JsonResponse {
return JsonResponse::success();
//$this->bookingService->book($calendarData);
return JsonResponse::success();
}
}

View file

@ -29,6 +29,7 @@ namespace OCA\Calendar\Db;
use JsonSerializable;
use OCP\AppFramework\Db\Entity;
use function json_decode;
/**
* @method int getId()
@ -50,7 +51,7 @@ use OCP\AppFramework\Db\Entity;
* @method string|null getCalendarFreebusyUris()
* @method void setCalendarFreebusyUris(?string $freebusyUris)
* @method string getAvailability()
* @method void setAvailability(string $availability)
* @method void setAvailability(?string $availability)
* @method int getLength()
* @method void setLength(int $length)
* @method int getIncrement()
@ -90,7 +91,7 @@ class AppointmentConfig extends Entity implements JsonSerializable {
/** @var string|null */
protected $calendarFreebusyUris;
/** @var string */
/** @var string|null */
protected $availability;
/** @var int */
@ -132,10 +133,14 @@ class AppointmentConfig extends Entity implements JsonSerializable {
*
* @return string
*/
public function getPrincipalUri() : string {
public function getPrincipalUri(): string {
return 'principals/users/' . $this->userId;
}
public function getCalendarFreebusyUrisAsArray(): array {
return json_decode($this->getCalendarFreebusyUris(), true, 512, JSON_THROW_ON_ERROR);
}
public function jsonSerialize() {
return [
'id' => $this->id,

View file

@ -75,9 +75,7 @@ class AppointmentConfigMapper extends QBMapper {
/**
* @param string $token
* @return AppointmentConfig
* @throws DbException
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
*/
public function findByToken(string $token) : AppointmentConfig {
$qb = $this->db->getQueryBuilder();

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/*
* @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @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 <http://www.gnu.org/licenses/>.
*/
namespace OCA\Calendar\Exception;
use Exception;
use Throwable;
class ClientException extends Exception {
/** @var int|null */
private $httpCode;
public function __construct($message = "",
$code = 0,
Throwable $previous = null,
int $httpCode = null) {
parent::__construct($message, $code, $previous);
$this->httpCode = $httpCode;
}
public function getHttpCode(): ?int {
return $this->httpCode;
}
}

View file

@ -1,6 +1,7 @@
<?php
declare(strict_types=1);
/**
* Calendar App
*
@ -22,13 +23,16 @@ declare(strict_types=1);
* License along with this library. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Calendar\Service\Appointments;
use OCA\Calendar\Db\AppointmentConfig;
use OCA\Calendar\Db\AppointmentConfigMapper;
use OCA\Calendar\Exception\ClientException;
use OCA\Calendar\Exception\ServiceException;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\AppFramework\Http;
use OCP\DB\Exception as DbException;
class AppointmentConfigService {
@ -60,6 +64,7 @@ class AppointmentConfigService {
/**
* @param int $id
* @param string $userId
*
* @throws ServiceException
*/
public function delete(int $id, string $userId): void {
@ -72,6 +77,7 @@ class AppointmentConfigService {
/**
* @param AppointmentConfig $appointmentConfig
*
* @return AppointmentConfig
* @throws ServiceException
*/
@ -85,32 +91,53 @@ class AppointmentConfigService {
/**
* @param int $id
*
* @return AppointmentConfig
* @throws ServiceException
*/
public function findById(int $id): AppointmentConfig {
try {
return $this->mapper->findById($id);
} catch (DbException |DoesNotExistException|MultipleObjectsReturnedException $e) {
throw new ServiceException('Could not find a record for id', $e->getCode(), $e);
} catch (DbException | DoesNotExistException | MultipleObjectsReturnedException $e) {
throw new ClientException(
'Could not find a record for id',
$e->getCode(),
$e,
Http::STATUS_NOT_FOUND
);
}
}
public function findByToken(string $token): AppointmentConfig {
try {
return $this->mapper->findByToken($token);
} catch (DoesNotExistException $e) {
throw new ClientException(
"Appointment config $token does not exist",
0,
$e,
Http::STATUS_NOT_FOUND
);
}
}
/**
* @param int $id
*
* @return AppointmentConfig
* @throws ServiceException
*/
public function findByIdAndUser(int $id, string $userId): AppointmentConfig {
try {
return $this->mapper->findByIdForUser($id, $userId);
} catch (DbException |DoesNotExistException|MultipleObjectsReturnedException $e) {
} catch (DbException | DoesNotExistException | MultipleObjectsReturnedException $e) {
throw new ServiceException('Could not find a record for id', $e->getCode(), $e);
}
}
/**
* @param AppointmentConfig $appointmentConfig
*
* @return AppointmentConfig
* @throws ServiceException
*/
@ -126,6 +153,7 @@ class AppointmentConfigService {
* @param int $id ?? maybe pass the appointment
* @param int $unixStartTime
* @param int $unixEndTime
*
* @return array
* @throws ServiceException
*/
@ -141,7 +169,7 @@ class AppointmentConfigService {
// move this to controller
try {
$appointment = $this->mapper->findByIdForUser($id);
} catch (DbException |DoesNotExistException|MultipleObjectsReturnedException $e) {
} catch (DbException | DoesNotExistException | MultipleObjectsReturnedException $e) {
throw new ServiceException('Appointment not found', 404, $e);
}
@ -194,6 +222,7 @@ class AppointmentConfigService {
* @param int $time
* @param string $outboxUri
* @param array $freeBusyUris
*
* @return [][]
*
* Check if slot is conflicting with existing appointments

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
/*
* @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @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 <http://www.gnu.org/licenses/>.
*/
namespace OCA\Calendar\Service\Appointments;
use OCA\Calendar\Db\AppointmentConfig;
class AvailabilityGenerator {
/**
* Generate intervals at which the user is generally available
*
* @param AppointmentConfig $config
* @param int $start
* @param int $end
*
* @return Interval[]
*/
public function generate(AppointmentConfig $config,
int $start,
int $end): array {
if ($config->getAvailability() === null) {
// No availability -> full time range is available
return [
new Interval($start, $end),
];
}
// TODO: derive intervals from RRULE
return [
new Interval($start, $end),
];
}
}

View file

@ -1,183 +0,0 @@
<?php
declare(strict_types=1);
/**
* Calendar App
*
* @copyright 2021 Anna Larch <anna.larch@gmx.net>
*
* @author Anna Larch <anna.larch@gmx.net>
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
* License as published by the Free Software Foundation; either
* version 3 of the License, or any later version.
*
* This library 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 library. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Calendar\Service\Appointments;
use DateTimeImmutable;
use OCA\Calendar\Db\AppointmentConfig;
use Recurr\Exception\InvalidRRule;
use Recurr\Exception\InvalidWeekday;
use Recurr\Recurrence;
use Recurr\Rule;
use Recurr\Transformer\ArrayTransformer;
use Recurr\Transformer\ArrayTransformerConfig;
class Booking {
/** @var AppointmentConfig */
private $appointmentConfig;
/** @var int */
private $startTime;
/** @var int */
private $endTime;
/** @var Slot[] */
private $slots;
public function __construct(AppointmentConfig $appointmentConfig, int $startTime, int $endTime, array $slots = []) {
$this->appointmentConfig = $appointmentConfig;
$this->startTime = $startTime;
$this->endTime = $endTime;
$this->slots = $slots;
}
/**
* @return int
*/
public function getStartTime(): int {
return $this->startTime;
}
// Trait maybe?
public function getStartTimeDTObj() : DateTimeImmutable {
return (new DateTimeImmutable())->setTimestamp($this->startTime);
}
public function getEndTimeDTObj() : DateTimeImmutable {
return (new DateTimeImmutable())->setTimestamp($this->endTime);
}
/**
* @param int $startTime
*/
public function setStartTime(int $startTime): void {
$this->startTime = $startTime;
}
/**
* @return int
*/
public function getEndTime(): int {
return $this->endTime;
}
/**
* @param int $endTime
*/
public function setEndTime(int $endTime): void {
$this->endTime = $endTime;
}
/**
* @return Slot[]
*/
public function getSlots(): array {
return $this->slots;
}
/**
* @param Slot[] $slots
*/
public function setSlots(array $slots): void {
$this->slots = $slots;
}
/**
* @return AppointmentConfig
*/
public function getAppointmentConfig(): AppointmentConfig {
return $this->appointmentConfig;
}
/**
* @param AppointmentConfig $appointmentConfig
*/
public function setAppointmentConfig(AppointmentConfig $appointmentConfig): void {
$this->appointmentConfig = $appointmentConfig;
}
public function generateSlots(): array {
$slots = [];
$unixStartTime = $this->getStartTime();
$length = $this->getAppointmentConfig()->getTotalLength()*60;
while(($unixStartTime + $length) <= $this->getEndTime() ) {
$slots[] = new Slot($unixStartTime, $unixStartTime+$this->getAppointmentConfig()->getTotalLength()*60);
$unixStartTime += $this->getAppointmentConfig()->getIncrement()*60;
}
$this->slots = $slots;
return $slots;
}
/**
* @param int $booked
* @return int
*/
public function getAvailableSlotsAmount(int $booked): int {
return ($this->appointmentConfig->getDailyMax() !== null) ? $this->appointmentConfig->getDailyMax() - $booked : 99999;
}
/**
* @return self;
*/
public function parseRRule(): self {
try {
// RRule Array Transformer does not work with constraints atm
// so this is (kind of) superfluous
$startDT = $this->getStartTimeDTObj();
$endDT = $this->getEndTimeDTObj();
// force UTC
$startDT->setTimezone(new \DateTimeZone('UTC'));
$endDT->setTimezone(new \DateTimeZone('UTC'));
$rule = new Rule($this->appointmentConfig->getAvailability());
} catch (InvalidRRule $e) {
$this->slots = [];
return $this;
}
$config = new ArrayTransformerConfig();
$config->enableLastDayOfMonthFix();
$transformer = new ArrayTransformer();
$transformer->setConfig($config);
try {
$collection = $transformer->transform($rule);
} catch (InvalidWeekday $e) {
// throw an error here?
$this->slots = [];
return $this;
}
$this->slots = $collection->map(function (Recurrence $slot) {
$start = $slot->getStart()->getTimestamp();
$end = $start + ($this->appointmentConfig->getTotalLength() * 60);
return new Slot($start, $end);
})->toArray();
return $this;
}
}

View file

@ -22,24 +22,36 @@ declare(strict_types=1);
* License along with this library. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Calendar\Service\Appointments;
use OC\Calendar\CalendarQuery;
use OCA\Calendar\Db\AppointmentConfig;
use OCA\Calendar\Db\AppointmentConfigMapper;
use OCP\Calendar\IManager;
class BookingService {
/** @var IManager */
private $manager;
/** @var AvailabilityGenerator */
private $availabilityGenerator;
/** @var AppointmentConfigMapper */
private $mapper;
/** @var SlotExtrapolator */
private $extrapolator;
public function __construct(IManager $manager,
AppointmentConfigMapper $mapper) {
$this->manager = $manager;
$this->mapper = $mapper;
/** @var DailyLimitFilter */
private $dailyLimitFilter;
/** @var EventConflictFilter */
private $eventConflictFilter;
public function __construct(AvailabilityGenerator $availabilityGenerator,
SlotExtrapolator $extrapolator,
DailyLimitFilter $dailyLimitFilter,
EventConflictFilter $eventConflictFilter) {
$this->availabilityGenerator = $availabilityGenerator;
$this->extrapolator = $extrapolator;
$this->dailyLimitFilter = $dailyLimitFilter;
$this->eventConflictFilter = $eventConflictFilter;
}
// CREATE
@ -47,74 +59,20 @@ class BookingService {
// use new ICreateFromString::create method
}
public function getBookingInformation(string $token) {
// unmarshal token for ID would be an option too
// this also returns all bookings for this token
// needs a unique identifier - X-NC-USERID or something?
$config = $this->mapper->findByToken($token);
$query = $this->manager->newQuery($config->getPrincipalUri());
$query->addSearchCalendar($config->getTargetCalendarUri());
return $this->manager->searchForPrincipal($query);
}
public function getSlots(Booking $booking): array {
$bookedSlots = $this->findBookedSlotsAmount($booking);
// negotiate available slots
if ($booking->getAvailableSlotsAmount($bookedSlots) <= 0) {
return [];
}
// decide if we want to use the complete 24 hour period to intersect via:
// $booking->generateSlots();
// Remove unavailable slots via comparing with RRule
$booking->parseRRule();
// remove conflicting slots via calendar free busy
$booking = $this->getCalendarFreeTimeblocks($booking);
return $booking->getSlots();
}
/**
* @param Booking $booking
* @return Booking
*
* Check if slot is conflicting with existing appointments
* @return Interval[]
*/
public function getCalendarFreeTimeblocks(Booking $booking): Booking {
$query = $this->manager->newQuery($booking->getAppointmentConfig()->getPrincipalUri());
$query->addSearchCalendar($booking->getAppointmentConfig()->getTargetCalendarUri());
public function getAvailableSlots(AppointmentConfig $config, int $startTime, int $endTime): array {
// 1. Build intervals at which slots may be booked
$availabilityIntervals = $this->availabilityGenerator->generate($config, $startTime, $endTime);
// 2. Generate all possible slots
$allPossibleSlots = $this->extrapolator->extrapolate($config, $availabilityIntervals);
// 3. Filter out the daily limits
$filteredByDailyLimit = $this->dailyLimitFilter->filter($config, $allPossibleSlots);
// 4. Filter out booking conflicts
$filteredByConflict = $this->eventConflictFilter->filter($config, $filteredByDailyLimit);
if (!empty($booking->getAppointmentConfig()->getCalendarFreebusyUris())) {
foreach ($booking->getAppointmentConfig()->getCalendarFreebusyUris() as $uri) {
$query->addSearchCalendar($uri);
}
}
$slots = $booking->getSlots();
foreach ($slots as $k => $slot) {
$query->setTimerangeStart($slot->getStartTimeDTObj());
$query->setTimerangeEnd($slot->getEndTimeDTObj());
// cache the query maybe? or maybe run everything at once?
$events = $this->manager->searchForPrincipal($query);
if (!empty($events)) {
unset($slots[$k]);
}
}
$booking->setSlots($slots);
return $booking;
}
public function findBookedSlotsAmount(Booking $booking): int {
/** @var CalendarQuery $query */
$query = $this->manager->newQuery($booking->getAppointmentConfig()->getPrincipalUri());
$query->addSearchCalendar($booking->getAppointmentConfig()->getTargetCalendarUri());
$query->addSearchProperty('X-NC-APPOINTMENT');
$query->setSearchPattern($booking->getAppointmentConfig()->getToken());
$query->setTimerangeStart($booking->getStartTimeDTObj());
$query->setTimerangeEnd($booking->getEndTimeDTObj());
$events = $this->manager->searchForPrincipal($query);
return count($events);
return $filteredByConflict;
}
// Update

View file

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/*
* @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @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 <http://www.gnu.org/licenses/>.
*/
namespace OCA\Calendar\Service\Appointments;
use OC\Calendar\CalendarQuery;
use OCA\Calendar\Db\AppointmentConfig;
use OCP\Calendar\IManager;
use function array_filter;
use function array_values;
use function count;
class DailyLimitFilter {
/** @var IManager */
private $calendarManger;
public function __construct(IManager $calendarManger) {
$this->calendarManger = $calendarManger;
}
/**
* @param AppointmentConfig $config
* @param Interval[] $slots
*
* @return Interval[]
*/
public function filter(AppointmentConfig $config, array $slots): array {
// 0. If there is no limit then we don't have to filter anything
if ($config->getDailyMax() === 0) {
return $slots;
}
// 1. Find all days
$days = [];
foreach ($slots as $slot) {
$startOfDay = $slot->getStartAsObject()->setTime(0, 0, 0, 0);
$ts = $startOfDay->getTimestamp();
$days[$ts] = $startOfDay;
}
// 2. Check what days are bookable
/** @var CalendarQuery $query */
$query = $this->calendarManger->newQuery($config->getPrincipalUri());
$query->addSearchCalendar($config->getTargetCalendarUri());
$query->addSearchProperty('X-NC-APPOINTMENT');
$query->setSearchPattern($config->getToken());
$available = [];
foreach ($days as $ts => $day) {
$nextDay = $day->modify('+1 day');
$query->setTimerangeStart($day);
$query->setTimerangeEnd($nextDay);
$events = $this->calendarManger->searchForPrincipal($query);
// Only days with less than the max number are still available
$available[$ts] = count($events) < $config->getDailyMax();
}
// 3. Filter out the slots that are on an unavailable day
return array_values(array_filter($slots, function(Interval $slot) use ($available): bool {
$startOfDay = $slot->getStartAsObject()->setTime(0, 0, 0, 0);
$ts = $startOfDay->getTimestamp();
return $available[$ts];
}));
}
}

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
/*
* @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @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 <http://www.gnu.org/licenses/>.
*/
namespace OCA\Calendar\Service\Appointments;
use DateInterval;
use OCA\Calendar\Db\AppointmentConfig;
use OCP\Calendar\IManager;
use function array_filter;
class EventConflictFilter {
/** @var IManager */
private $calendarManager;
public function __construct(IManager $calendarManager) {
$this->calendarManager = $calendarManager;
}
/**
* Filter appointment slots to those that do not conflict with existing calendar events
*
* @todo try to combine slots a bit to lower the number of calendar queries
*
* @param AppointmentConfig $config
* @param Interval[] $slots
*
* @return Interval[]
*/
public function filter(AppointmentConfig $config, array $slots): array {
$query = $this->calendarManager->newQuery($config->getPrincipalUri());
foreach ($config->getCalendarFreebusyUrisAsArray() as $uri) {
$query->addSearchCalendar($uri);
}
// Always check the target calendar for conflicts
$query->addSearchCalendar($config->getTargetCalendarUri());
$preparationDuration = DateInterval::createFromDateString($config->getPreparationDuration() . ' minutes');
$followUpDuration = DateInterval::createFromDateString($config->getFollowupDuration() . ' minutes');
return array_filter($slots, function(Interval $slot) use ($followUpDuration, $preparationDuration, $query): bool {
$query->setTimerangeStart($slot->getStartAsObject()->sub($preparationDuration));
$query->setTimerangeEnd($slot->getEndAsObject()->add($followUpDuration));
$objects = $this->calendarManager->searchForPrincipal($query);
// If there is at least one event at this time then the slot is taken
return empty($objects);
});
}
}

View file

@ -1,6 +1,7 @@
<?php
declare(strict_types=1);
/**
* Calendar App
*
@ -22,12 +23,21 @@ declare(strict_types=1);
* License along with this library. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Calendar\Service\Appointments;
use DateTimeImmutable;
use JsonSerializable;
class Slot {
/**
* @psalm-immutable
*/
class Interval implements JsonSerializable {
/** @var int */
private $start;
/** @var int */
private $end;
public function __construct(int $start, int $end) {
@ -35,31 +45,26 @@ class Slot {
$this->end = $end;
}
public function getStartTime(): int {
public function getStart(): int {
return $this->start;
}
public function setStartTime(int $start): void {
$this->start = $start;
}
public function getEndTime(): int {
public function getEnd(): int {
return $this->end;
}
public function setEndTime(int $end): void {
$this->end = $end;
}
public function getStartTimeDTObj() : DateTimeImmutable {
public function getStartAsObject(): DateTimeImmutable {
return (new DateTimeImmutable())->setTimestamp($this->start);
}
public function getEndTimeDTObj() : DateTimeImmutable {
public function getEndAsObject(): DateTimeImmutable {
return (new DateTimeImmutable())->setTimestamp($this->end);
}
public function isViable(int $start, int $end): bool {
return !($this->start > $start || $this->end < $end);
public function jsonSerialize(): array {
return [
'start' => $this->start,
'end' => $this->end,
];
}
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/*
* @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @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 <http://www.gnu.org/licenses/>.
*/
namespace OCA\Calendar\Service\Appointments;
use OCA\Calendar\Db\AppointmentConfig;
class SlotExtrapolator {
/**
* @param AppointmentConfig $config
* @param Interval[] $availabilityIntervals
* @param int $to
*
* @return Interval[]
*/
public function extrapolate(AppointmentConfig $config,
array $availabilityIntervals): array {
$increment = $config->getIncrement() * 60;
$length = $config->getLength() * 60;
$slots = [];
foreach ($availabilityIntervals as $available) {
$from = $available->getStart();
$to = $available->getEnd();
for ($t = $from; ($t + $length) <= $to; $t += $increment) {
$slots[] = new Interval($t, $t + $length);
}
}
return $slots;
}
}

View file

@ -0,0 +1,53 @@
/**
* @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at>
* @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at>
* @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 <http://www.gnu.org/licenses/>.
*/
import { getRequestToken } from '@nextcloud/auth'
import { loadState } from '@nextcloud/initial-state'
import { linkTo } from '@nextcloud/router'
import { translate, translatePlural } from '@nextcloud/l10n'
import Vue from 'vue'
import Booking from '../views/Appointments/Booking'
// CSP config for webpack dynamic chunk loading
// eslint-disable-next-line
__webpack_nonce__ = btoa(getRequestToken())
// Correct the root of the app for chunk loading
// OC.linkTo matches the apps folders
// OC.generateUrl ensure the index.php (or not)
// We do not want the index.php since we're loading files
// eslint-disable-next-line
__webpack_public_path__ = linkTo('calendar', 'js/')
Vue.prototype.$t = translate
Vue.prototype.$n = translatePlural
const config = loadState('calendar', 'config')
const userInfo = loadState('calendar', 'userInfo')
export default new Vue({
el: '#appointment-booking',
render: h => h(Booking, {
props: {
config,
userInfo,
},
}),
})

View file

@ -52,7 +52,7 @@
v-if="index2 !== (shortcut.keys.length - 1)"
:key="`${category.categoryId}-${index}-${index2}`"
class="shortcut-section-item__spacer">
{{ t('calendar', 'or') }}
{{ $t('calendar', 'or') }}
</span>
</template>
</span>

View file

@ -0,0 +1,166 @@
<template>
<Modal
size="large"
@close="$emit('close')">
<div class="booking-appointment-details">
<div class="booking-details">
<Avatar
:user="userInfo.uid"
:display-name="userInfo.displayName"
:disable-tooltip="true"
:disable-menu="true"
:size="64" />
<div class="booking__display-name">
<strong>{{ userInfo.displayName }}</strong>
</div>
<h2 class="booking__name">
{{ config.name }}
</h2>
<span class="booking__description">{{ config.description }}</span>
<span class="booking__time">{{ startTime }} - {{ endTime }}</span>
</div>
<div class="appointment-details">
<h2>{{ $t('calendar', 'Enter details') }}</h2>
<div>
{{ $t('calendar', 'Name') }}
</div>
<input id="name"
v-model="name"
type="text"
class="no-close"
required>
<div>
{{ $t('calendar', 'Email') }}
</div>
<input ref="email"
v-model="email"
type="email"
autocapitalize="none"
autocomplete="on"
autocorrect="off"
required>
<div class="meeting-info">
{{ $t('calendar', 'Please share anything that will help prepare for our meeting') }}
<div class="meeting-text">
<textarea
id="biography"
v-model="description"
rows="8"
autocapitalize="none"
autocomplete="off" />
</div>
</div>
<button class="button primary"
@click="save">
{{ $t('calendar', 'Book the appointment') }}
</button>
</div>
</div>
</Modal>
</template>
<script>
import Avatar from '@nextcloud/vue/dist/Components/Avatar'
import Modal from '@nextcloud/vue/dist/Components/Modal'
import { timeStampToLocaleTime } from '../../utils/localeTime'
export default {
name: 'AppointmentDetails',
components: {
Avatar,
Modal,
},
props: {
config: {
required: true,
type: Object,
},
timeSlot: {
required: true,
type: Object,
},
userInfo: {
required: true,
type: Object,
},
},
data() {
return {
description: '',
email: '',
name: '',
}
},
computed: {
startTime() {
return timeStampToLocaleTime(this.timeSlot.start, this.timeZoneId)
},
endTime() {
return timeStampToLocaleTime(this.timeSlot.end, this.timeZoneId)
},
},
methods: {
save() {
this.$emit('save', {
slot: this.timeSlot,
description: this.description,
email: this.email,
name: this.name,
})
},
},
}
</script>
<style lang="scss" scoped>
::v-deep .modal-container {
width: calc(100vw - 120px) !important;
height: calc(100vh - 120px) !important;
max-width: 600px !important;
max-height: 500px !important;
}
.booking-appointment-details {
display: flex;
flex-direction: row;
}
.booking-details {
padding-left: 30px;
padding-top: 80px;
white-space: nowrap;
}
.appointment-details {
padding-left: 120px;
padding-top: 40px;
}
.add-guest {
display: block;
color: var(--color-primary);
background-color: transparent;
}
.meeting-info {
padding-right: 10px;
}
.meeting-text {
display: grid;
align-items: center;
textarea {
resize: vertical;
grid-area: 1 / 1;
width: 100%;
margin: 3px 3px 3px 0;
padding: 7px 6px;
color: var(--color-main-text);
border: 1px solid var(--color-border-dark);
border-radius: var(--border-radius);
background-color: var(--color-main-background);
cursor: text;
&:hover {
border-color: var(--color-primary-element) !important;
outline: none !important;
}
}
}
</style>

View file

@ -0,0 +1,63 @@
<!--
- @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at>
-
- @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at>
-
- @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 <http://www.gnu.org/licenses/>.
-->
<template>
<button class="appointment-slot" @click="$emit('click', $event)">
{{ startTime }} - {{ endTime }}
</button>
</template>
<script>
import { timeStampToLocaleTime } from '../../utils/localeTime'
export default {
name: 'AppointmentSlot',
props: {
start: {
required: true,
type: Number,
},
end: {
required: true,
type: Number,
},
timeZoneId: {
required: true,
type: String,
},
},
computed: {
dateTimeFormatter() {
return Intl.DateTimeFormat(undefined, {
timeZone: this.timeZoneId,
timeStyle: 'full',
dateStyle: 'short',
})
},
startTime() {
return timeStampToLocaleTime(this.start, this.timeZoneId)
},
endTime() {
return timeStampToLocaleTime(this.end, this.timeZoneId)
},
},
}
</script>