Limit how far in the future appointments can be booked

Signed-off-by: Richard Steinmetz <richard@steinmetz.cloud>
This commit is contained in:
Richard Steinmetz 2022-01-11 15:36:49 +01:00
parent 988a018001
commit 1d6abdb7bd
No known key found for this signature in database
GPG Key ID: 27137D9E7D273FB2
9 changed files with 200 additions and 27 deletions

View File

@ -8,6 +8,7 @@ declare(strict_types=1);
* @copyright 2021 Anna Larch <anna.larch@gmx.net>
*
* @author Anna Larch <anna.larch@gmx.net>
* @author Richard Steinmetz <richard@steinmetz.cloud>
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
@ -145,6 +146,7 @@ class AppointmentConfigController extends Controller {
* @param string[]|null $calendarFreeBusyUris
* @param int|null $start
* @param int|null $end
* @param int|null $futureLimit
* @return JsonResponse
*/
public function create(
@ -162,7 +164,8 @@ class AppointmentConfigController extends Controller {
?int $dailyMax = null,
?array $calendarFreeBusyUris = null,
?int $start = null,
?int $end = null): JsonResponse {
?int $end = null,
?int $futureLimit = null): JsonResponse {
if ($this->userId === null) {
return JsonResponse::fail();
}
@ -189,7 +192,8 @@ class AppointmentConfigController extends Controller {
$dailyMax,
$calendarFreeBusyUris,
$start,
$end
$end,
$futureLimit
);
return JsonResponse::success($appointmentConfig);
} catch (ServiceException $e) {
@ -214,9 +218,10 @@ class AppointmentConfigController extends Controller {
* @param int $followupDuration
* @param int $timeBeforeNextSlot
* @param int|null $dailyMax
* @param string[] $freebusyUris
* @param string[] $calendarFreeBusyUris
* @param int|null $start
* @param int|null $end
* @param int|null $futureLimit
* @return JsonResponse
*/
public function update(
@ -235,7 +240,8 @@ class AppointmentConfigController extends Controller {
?int $dailyMax = null,
?array $calendarFreeBusyUris = null,
?int $start = null,
?int $end = null): JsonResponse {
?int $end = null,
?int $futureLimit = null): JsonResponse {
if ($this->userId === null) {
return JsonResponse::fail(null, Http::STATUS_NOT_FOUND);
}
@ -268,6 +274,7 @@ class AppointmentConfigController extends Controller {
$appointmentConfig->setCalendarFreeBusyUrisAsArray($calendarFreeBusyUris ?? []);
$appointmentConfig->setStart($start);
$appointmentConfig->setEnd($end);
$appointmentConfig->setFutureLimit($futureLimit);
try {
$appointmentConfig = $this->appointmentConfigService->update($appointmentConfig);

View File

@ -6,6 +6,7 @@ declare(strict_types=1);
* @copyright 2021 Anna Larch <anna.larch@gmx.net>
*
* @author Anna Larch <anna.larch@gmx.net>
* @author Richard Steinmetz <richard@steinmetz.cloud>
*
* @license GNU AGPL version 3 or any later version
*
@ -67,6 +68,8 @@ use function json_encode;
* @method void setTimeBeforeNextSlot(int $buffer)
* @method int|null getDailyMax()
* @method void setDailyMax(?int $max)
* @method int|null getFutureLimit()
* @method void setFutureLimit(?int $limit)
*/
class AppointmentConfig extends Entity implements JsonSerializable {
@ -121,6 +124,9 @@ class AppointmentConfig extends Entity implements JsonSerializable {
/** @var int|null */
protected $dailyMax;
/** @var int|null */
protected $futureLimit;
/** @var string */
public const VISIBILITY_PUBLIC = 'PUBLIC';
@ -136,6 +142,7 @@ class AppointmentConfig extends Entity implements JsonSerializable {
$this->addType('followupDuration', 'int');
$this->addType('timeBeforeNextSlot', 'int');
$this->addType('dailyMax', 'int');
$this->addType('futureLimit', 'int');
}
/**
@ -196,7 +203,8 @@ class AppointmentConfig extends Entity implements JsonSerializable {
'followUpDuration' => $this->getFollowupDuration(),
'totalLength' => $this->getTotalLength(),
'timeBeforeNextSlot' => $this->getTimeBeforeNextSlot(),
'dailyMax' => $this->getDailyMax()
'dailyMax' => $this->getDailyMax(),
'futureLimit' => $this->getFutureLimit()
];
}
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/**
* @copyright 2022 Richard Steinmetz <richard@steinmetz.cloud>
*
* @author Richard Steinmetz <richard@steinmetz.cloud>
*
* @license GNU AGPL version 3 or any later version
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* 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, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\Calendar\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version3010Date20220111090252 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
$schema = $schemaClosure();
$table = $schema->getTable('calendar_appt_configs');
$table->addColumn('future_limit', 'integer', [
'notnull' => false,
]);
return $schema;
}
}

View File

@ -8,6 +8,7 @@ declare(strict_types=1);
* @copyright 2021 Anna Larch <anna.larch@gmx.net>
*
* @author Anna Larch <anna.larch@gmx.net>
* @author Richard Steinmetz <richard@steinmetz.cloud>
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
@ -158,6 +159,7 @@ class AppointmentConfigService {
* @param string[] $calendarFreeBusyUris
* @param int|null $start
* @param int|null $end
* @param int|null $futureLimit
* @return AppointmentConfig
* @throws ServiceException
*/
@ -176,7 +178,8 @@ class AppointmentConfigService {
?int $dailyMax,
?array $calendarFreeBusyUris = [],
?int $start = null,
?int $end = null): AppointmentConfig {
?int $end = null,
?int $futureLimit = null): AppointmentConfig {
try {
$appointmentConfig = new AppointmentConfig();
$appointmentConfig->setToken($this->random->generate(12, ISecureRandom::CHAR_HUMAN_READABLE));
@ -196,6 +199,7 @@ class AppointmentConfigService {
$appointmentConfig->setCalendarFreeBusyUrisAsArray($calendarFreeBusyUris);
$appointmentConfig->setStart($start);
$appointmentConfig->setEnd($end);
$appointmentConfig->setFutureLimit($futureLimit);
return $this->mapper->insert($appointmentConfig);
} catch (DbException $e) {

View File

@ -7,6 +7,7 @@ declare(strict_types=1);
* @copyright 2021 Anna Larch <anna.larch@gmx.net>
*
* @author Anna Larch <anna.larch@gmx.net>
* @author Richard Steinmetz <richard@steinmetz.cloud>
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
@ -163,6 +164,17 @@ class BookingService {
* @return Interval[]
*/
public function getAvailableSlots(AppointmentConfig $config, int $startTime, int $endTime): array {
if ($config->getFutureLimit() !== null) {
/** @var int $maxEndTime */
$maxEndTime = time() + $config->getFutureLimit();
if ($startTime > $maxEndTime) {
return [];
}
if ($endTime > $maxEndTime) {
$endTime = $maxEndTime;
}
}
// 1. Build intervals at which slots may be booked
$availabilityIntervals = $this->availabilityGenerator->generate($config, $startTime, $endTime);
// 2. Generate all possible slots

View File

@ -130,6 +130,16 @@
:value.sync="editing.dailyMax"
:allow-empty="true" />
</div>
<div class="appointment-config-modal__form__row appointment-config-modal__form__row--wrapped">
<CheckedDurationSelect
:label="t('calendar', 'Limit how far in the future appointments can be booked')"
:enabled.sync="enableFutureLimit"
:value.sync="editing.futureLimit"
:default-value="defaultConfig.futureLimit"
:min="7 * 24 * 60 * 60"
:max="null" />
</div>
</fieldset>
</div>
<button
@ -189,6 +199,7 @@ export default {
editing: undefined,
enablePreparationDuration: false,
enableFollowupDuration: false,
enableFutureLimit: false,
showConfirmation: false,
}
},
@ -250,6 +261,8 @@ export default {
this.enablePreparationDuration = !!this.editing.preparationDuration
this.enableFollowupDuration = !!this.editing.followupDuration
this.enableFutureLimit = !!this.editing.futureLimit
this.showConfirmation = false
},
calendarUrlToUri(url) {
@ -277,6 +290,10 @@ export default {
this.editing.followupDuration = this.defaultConfig.followupDuration
}
if (!this.enableFutureLimit) {
this.editing.futureLimit = null
}
this.editing.targetCalendarUri ??= this.defaultConfig.targetCalendarUri
const config = this.editing

View File

@ -34,9 +34,11 @@
</div>
<DurationSelect
class="checked-duration-select__duration"
:allow-zero="true"
:allow-zero="defaultValue === 0"
:disabled="!enabled"
:value="value"
:value="valueOrDefault"
:min="min"
:max="max"
@update:value="$emit('update:value', $event)" />
</div>
</template>
@ -56,6 +58,10 @@ export default {
required: true,
},
value: {
type: [Number, null, undefined],
required: true,
},
defaultValue: {
type: Number,
default: 0,
},
@ -63,12 +69,25 @@ export default {
type: Boolean,
required: true,
},
min: {
type: Number,
default: 0,
},
max: {
type: [Number, null, undefined],
default: 60 * 60,
},
},
data() {
return {
id: randomId(),
}
},
computed: {
valueOrDefault() {
return this.value ?? this.defaultValue
},
},
}
</script>

View File

@ -54,33 +54,82 @@ export default {
type: Boolean,
default: false,
},
max: {
min: {
type: Number,
default: 0,
},
max: {
type: [Number, null, undefined],
default: 60 * 60,
},
},
computed: {
options() {
// TODO: shouldn't this use the translatePlural (n) function?
const options = [
{ value: 0, label: this.t('calendar', '0 minutes') },
{ value: 5 * 60, label: this.t('calendar', '5 minutes') },
{ value: 10 * 60, label: this.t('calendar', '10 minutes') },
{ value: 15 * 60, label: this.t('calendar', '15 minutes') },
{ value: 30 * 60, label: this.t('calendar', '30 minutes') },
{ value: 45 * 60, label: this.t('calendar', '45 minutes') },
{ value: 60 * 60, label: this.t('calendar', '1 hour') },
{ value: 2 * 60 * 60, label: this.t('calendar', '2 hours') },
{ value: 6 * 60 * 60, label: this.t('calendar', '6 hours') },
{ value: 24 * 60 * 60, label: this.t('calendar', '1 day') },
{ value: 2 * 24 * 60 * 60, label: this.t('calendar', '2 days') },
{ value: 7 * 24 * 60 * 60, label: this.t('calendar', '1 week') },
]
if (!this.allowZero) {
options.splice(0, 1)
let options = []
if (this.allowZero) {
options.push({ value: 0, label: this.t('calendar', '0 minutes') })
}
return options.filter(option => option.value <= this.max)
options.push(...[
// Minutes
...[5, 10, 15, 30, 45].map(duration => {
const label = this.n('calendar', '{duration} minute', '{duration} minutes', duration, {
duration,
})
return { value: duration * 60, label }
}),
// Hours
...[1, 2, 6].map(duration => {
const label = this.n('calendar', '{duration} hour', '{duration} hours', duration, {
duration,
})
return { value: duration * 60 * 60, label }
}),
// Days
...[1, 2].map(duration => {
const label = this.n('calendar', '{duration} day', '{duration} days', duration, {
duration,
})
return { value: duration * 60 * 60 * 24, label }
}),
// Weeks
...[1, 2, 4, 6].map(duration => {
const label = this.n('calendar', '{duration} week', '{duration} weeks', duration, {
duration,
})
return { value: duration * 60 * 60 * 24 * 7, label }
}),
// Months
...[1, 2, 3, 6, 9].map(duration => {
const label = this.n('calendar', '{duration} month', '{duration} months', duration, {
duration,
})
return { value: duration * 60 * 60 * 24 * 30, label }
}),
// Years
...[1].map(duration => {
const label = this.n('calendar', '{duration} year', '{duration} years', duration, {
duration,
})
return { value: duration * 60 * 60 * 24 * 365, label }
}),
])
if (this.min) {
options = options.filter(option => {
return option.value >= this.min || (this.allowZero && option.value === 0)
})
}
if (this.max) {
options = options.filter(option => option.value <= this.max)
}
return options
},
},
}

View File

@ -71,6 +71,9 @@ export default class AppointmentConfig {
/** @member {?number} */
dailyMax
/** @member {?number} */
futureLimit
/** @member {?string[]} */
calendarFreeBusyUris
@ -92,6 +95,7 @@ export default class AppointmentConfig {
* @param {number} data.followupDuration Followup duration in seconds
* @param {number} data.timeBeforeNextSlot Time before next slot in seconds
* @param {?number} data.dailyMax Max daily slots
* @param {?number} data.futureLimit Limits how far in the future appointments can be booked
* @param {?string[]} data.calendarFreeBusyUris URIs of calendars to check for conflicts
*/
constructor(data) {
@ -110,6 +114,7 @@ export default class AppointmentConfig {
this.followupDuration = tryParseInt(data.followupDuration)
this.timeBeforeNextSlot = tryParseInt(data.timeBeforeNextSlot)
this.dailyMax = tryParseInt(data.dailyMax)
this.futureLimit = tryParseInt(data.futureLimit)
this.calendarFreeBusyUris = data.calendarFreeBusyUris
}
@ -152,6 +157,7 @@ export default class AppointmentConfig {
followupDuration: 0,
timeBeforeNextSlot: 0,
calendarFreeBusyUris: [],
futureLimit: 2 * 30 * 24 * 60 * 60, // 2 months
})
}