Limit how far in the future appointments can be booked
Signed-off-by: Richard Steinmetz <richard@steinmetz.cloud>
This commit is contained in:
parent
988a018001
commit
1d6abdb7bd
|
@ -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);
|
||||
|
|
|
@ -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()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue