Revert "Revert "Create Talk rooms for appointments""

Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
This commit is contained in:
Christoph Wurst 2023-06-14 12:04:20 +02:00 committed by Christoph Wurst
parent 2ec5e44395
commit faa1c72e23
No known key found for this signature in database
GPG Key ID: CC42AC2A7F0E56D8
19 changed files with 246 additions and 40 deletions

View File

@ -15,7 +15,7 @@
* ☑️ Tasks! See tasks with a due date directly in the calendar
* 🙈 **Were not reinventing the wheel!** Based on the great [c-dav library](https://github.com/nextcloud/cdav-library), [ical.js](https://github.com/mozilla-comm/ical.js) and [fullcalendar](https://github.com/fullcalendar/fullcalendar) libraries.
]]></description>
<version>4.5.0-beta.1</version>
<version>4.5.0-beta.2</version>
<licence>agpl</licence>
<author>Anna Larch</author>
<author homepage="https://github.com/nextcloud/groupware">Nextcloud Groupware Team</author>

View File

@ -24,6 +24,8 @@ declare(strict_types=1);
namespace OCA\Calendar\AppInfo;
use OCA\Calendar\Dashboard\CalendarWidget;
use OCA\Calendar\Events\BeforeAppointmentBookedEvent;
use OCA\Calendar\Listener\AppointmentBookedListener;
use OCA\Calendar\Listener\UserDeletedListener;
use OCA\Calendar\Notification\Notifier;
use OCA\Calendar\Profile\AppointmentsAction;
@ -56,6 +58,7 @@ class Application extends App implements IBootstrap {
$context->registerProfileLinkAction(AppointmentsAction::class);
}
$context->registerEventListener(BeforeAppointmentBookedEvent::class, AppointmentBookedListener::class);
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
$context->registerNotifierService(Notifier::class);

View File

@ -164,7 +164,8 @@ class AppointmentConfigController extends Controller {
?array $calendarFreeBusyUris = null,
?int $start = null,
?int $end = null,
?int $futureLimit = null): JsonResponse {
?int $futureLimit = null,
?bool $createTalkRoom = false): JsonResponse {
if ($this->userId === null) {
return JsonResponse::fail();
}
@ -192,7 +193,8 @@ class AppointmentConfigController extends Controller {
$calendarFreeBusyUris,
$start,
$end,
$futureLimit
$futureLimit,
$createTalkRoom
);
return JsonResponse::success($appointmentConfig);
} catch (ServiceException $e) {
@ -240,7 +242,8 @@ class AppointmentConfigController extends Controller {
?array $calendarFreeBusyUris = null,
?int $start = null,
?int $end = null,
?int $futureLimit = null): JsonResponse {
?int $futureLimit = null,
?bool $createTalkRoom = false): JsonResponse {
if ($this->userId === null) {
return JsonResponse::fail(null, Http::STATUS_NOT_FOUND);
}
@ -274,6 +277,7 @@ class AppointmentConfigController extends Controller {
$appointmentConfig->setStart($start);
$appointmentConfig->setEnd($end);
$appointmentConfig->setFutureLimit($futureLimit);
$appointmentConfig->setCreateTalkRoom($createTalkRoom === true);
try {
$appointmentConfig = $this->appointmentConfigService->update($appointmentConfig);

View File

@ -71,6 +71,8 @@ use function json_encode;
* @method void setDailyMax(?int $max)
* @method int|null getFutureLimit()
* @method void setFutureLimit(?int $limit)
* @method int|null getCreateTalkRoom()
* @method void setCreateTalkRoom(bool $create)
*/
class AppointmentConfig extends Entity implements JsonSerializable {
/** @var string */
@ -127,6 +129,9 @@ class AppointmentConfig extends Entity implements JsonSerializable {
/** @var int|null */
protected $futureLimit;
/** @var bool */
protected $createTalkRoom;
/** @var string */
public const VISIBILITY_PUBLIC = 'PUBLIC';
@ -143,6 +148,7 @@ class AppointmentConfig extends Entity implements JsonSerializable {
$this->addType('timeBeforeNextSlot', 'int');
$this->addType('dailyMax', 'int');
$this->addType('futureLimit', 'int');
$this->addType('createTalkRoom', 'boolean');
}
/**
@ -205,7 +211,8 @@ class AppointmentConfig extends Entity implements JsonSerializable {
'totalLength' => $this->getTotalLength(),
'timeBeforeNextSlot' => $this->getTimeBeforeNextSlot(),
'dailyMax' => $this->getDailyMax(),
'futureLimit' => $this->getFutureLimit()
'futureLimit' => $this->getFutureLimit(),
'createTalkRoom' => $this->getCreateTalkRoom(),
];
}
}

View File

@ -48,7 +48,7 @@ class AppointmentConfigMapper extends QBMapper {
*/
public function findByIdForUser(int $id, string $userId) : AppointmentConfig {
$qb = $this->db->getQueryBuilder();
$qb->select('id', 'token', 'name', 'description', 'location', 'visibility', 'user_id', 'target_calendar_uri', 'calendar_freebusy_uris', 'availability', 'start', 'end', 'length', 'increment', 'preparation_duration', 'followup_duration', 'time_before_next_slot', 'daily_max', 'future_limit')
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR));
@ -64,7 +64,7 @@ class AppointmentConfigMapper extends QBMapper {
*/
public function findById(int $id) : AppointmentConfig {
$qb = $this->db->getQueryBuilder();
$qb->select('id', 'token', 'name', 'description', 'location', 'visibility', 'user_id', 'target_calendar_uri', 'calendar_freebusy_uris', 'availability', 'start', 'end', 'length', 'increment', 'preparation_duration', 'followup_duration', 'time_before_next_slot', 'daily_max', 'future_limit')
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT));
return $this->findEntity($qb);
@ -92,7 +92,7 @@ class AppointmentConfigMapper extends QBMapper {
*/
public function findAllForUser(string $userId, string $visibility = null): array {
$qb = $this->db->getQueryBuilder();
$qb->select('id', 'token', 'name', 'description', 'location', 'visibility', 'user_id', 'target_calendar_uri', 'calendar_freebusy_uris', 'availability', 'start', 'end', 'length', 'increment', 'preparation_duration', 'followup_duration', 'time_before_next_slot', 'daily_max', 'future_limit')
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR));
if ($visibility !== null) {

View File

@ -85,6 +85,13 @@ class Booking extends Entity implements JsonSerializable {
/** @var bool */
protected $confirmed;
/**
* Transient talk URL
*
* @var string|null
*/
private $talkUrl;
public function __construct() {
$this->addType('id', 'integer');
$this->addType('apptConfigId', 'integer');
@ -110,4 +117,12 @@ class Booking extends Entity implements JsonSerializable {
'confirmed' => $this->isConfirmed(),
];
}
public function getTalkUrl(): ?string {
return $this->talkUrl;
}
public function setTalkUrl(string $talkUrl): void {
$this->talkUrl = $talkUrl;
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/*
* @copyright 2022 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2022 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\Events;
use OCA\Calendar\Db\AppointmentConfig;
use OCA\Calendar\Db\Booking;
use OCP\EventDispatcher\Event;
class BeforeAppointmentBookedEvent extends Event {
/** @var Booking */
private $booking;
/** @var AppointmentConfig */
private $config;
public function __construct(Booking $booking, AppointmentConfig $config) {
parent::__construct();
$this->booking = $booking;
$this->config = $config;
}
public function getBooking(): Booking {
return $this->booking;
}
public function getConfig(): AppointmentConfig {
return $this->config;
}
}

View File

@ -25,7 +25,7 @@ declare(strict_types=1);
namespace OCA\Calendar\Listener;
use OCA\Calendar\Events\AppointmentBookedEvent;
use OCA\Calendar\Events\BeforeAppointmentBookedEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\IL10N;
@ -57,7 +57,7 @@ class AppointmentBookedListener implements IEventListener {
}
public function handle(Event $event): void {
if (!($event instanceof AppointmentBookedEvent)) {
if (!($event instanceof BeforeAppointmentBookedEvent)) {
// Don't care
return;
}

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
/*
* @copyright 2022 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2022 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\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version4050Date20230614163505 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 {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$table = $schema->getTable('calendar_appt_configs');
if (!$table->hasColumn('create_talk_room')) {
$table->addColumn('create_talk_room', Types::BOOLEAN, [
'notnull' => false,
'default' => false,
]);
}
return $schema;
}
}

View File

@ -142,24 +142,6 @@ class AppointmentConfigService {
}
/**
* @param string $name
* @param string $description
* @param string|null $location
* @param string $visibility
* @param string $userId
* @param string $targetCalendarUri
* @param array $availability
* @param int $length
* @param int $increment
* @param int $preparationDuration
* @param int $followupDuration
* @param int $buffer
* @param int|null $dailyMax
* @param string[] $calendarFreeBusyUris
* @param int|null $start
* @param int|null $end
* @param int|null $futureLimit
* @return AppointmentConfig
* @throws ServiceException
*/
public function create(string $name,
@ -178,7 +160,8 @@ class AppointmentConfigService {
?array $calendarFreeBusyUris = [],
?int $start = null,
?int $end = null,
?int $futureLimit = null): AppointmentConfig {
?int $futureLimit = null,
?bool $createTalkRoom = false): AppointmentConfig {
try {
$appointmentConfig = new AppointmentConfig();
$appointmentConfig->setToken($this->random->generate(12, ISecureRandom::CHAR_HUMAN_READABLE));
@ -199,6 +182,7 @@ class AppointmentConfigService {
$appointmentConfig->setStart($start);
$appointmentConfig->setEnd($end);
$appointmentConfig->setFutureLimit($futureLimit);
$appointmentConfig->setCreateTalkRoom($createTalkRoom === true);
return $this->mapper->insert($appointmentConfig);
} catch (DbException $e) {

View File

@ -93,7 +93,12 @@ class BookingCalendarWriter {
* @return string
* @throws RuntimeException
*/
public function write(AppointmentConfig $config, DateTimeImmutable $start, string $displayName, string $email, ?string $description = null) : string {
public function write(AppointmentConfig $config,
DateTimeImmutable $start,
string $displayName,
string $email,
?string $description = null,
?string $location = null) : string {
$calendar = current($this->manager->getCalendarsForPrincipal($config->getPrincipalUri(), [$config->getTargetCalendarUri()]));
if (!($calendar instanceof ICreateFromString)) {
throw new RuntimeException('Could not find a public writable calendar for this principal');
@ -166,6 +171,7 @@ class BookingCalendarWriter {
$vcalendar->VEVENT->add($alarm);
}
if ($config->getLocation() !== null) {
$vcalendar->VEVENT->add('LOCATION', $config->getLocation());
}

View File

@ -32,6 +32,7 @@ use InvalidArgumentException;
use OCA\Calendar\Db\AppointmentConfig;
use OCA\Calendar\Db\Booking;
use OCA\Calendar\Db\BookingMapper;
use OCA\Calendar\Events\BeforeAppointmentBookedEvent;
use OCA\Calendar\Exception\ClientException;
use OCA\Calendar\Exception\NoSlotFoundException;
use OCA\Calendar\Exception\ServiceException;
@ -39,6 +40,7 @@ use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\DB\Exception;
use OCP\DB\Exception as DbException;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IUser;
use OCP\Security\ISecureRandom;
use Psr\Log\LoggerInterface;
@ -72,6 +74,9 @@ class BookingService {
/** @var MailService */
private $mailService;
/** @var IEventDispatcher */
private $eventDispatcher;
/** @var LoggerInterface */
private $logger;
@ -83,6 +88,7 @@ class BookingService {
BookingCalendarWriter $calendarWriter,
ISecureRandom $random,
MailService $mailService,
IEventDispatcher $eventDispatcher,
LoggerInterface $logger) {
$this->availabilityGenerator = $availabilityGenerator;
$this->extrapolator = $extrapolator;
@ -92,6 +98,7 @@ class BookingService {
$this->bookingMapper = $bookingMapper;
$this->random = $random;
$this->mailService = $mailService;
$this->eventDispatcher = $eventDispatcher;
$this->logger = $logger;
}
@ -111,10 +118,26 @@ class BookingService {
throw new ClientException('Could not make sense of booking times');
}
$calendar = $this->calendarWriter->write($config, $startObj, $booking->getDisplayName(), $booking->getEmail(), $booking->getDescription());
// TODO: inject broker here and remove indirection
$this->eventDispatcher->dispatchTyped(
new BeforeAppointmentBookedEvent(
$booking,
$config,
)
);
$calendar = $this->calendarWriter->write(
$config,
$startObj,
$booking->getDisplayName(),
$booking->getEmail(),
$booking->getDescription(),
$config->getCreateTalkRoom() ? $booking->getTalkUrl() : $config->getLocation(),
);
$booking->setConfirmed(true);
$this->bookingMapper->update($booking);
try {
$this->mailService->sendBookingInformationEmail($booking, $config, $calendar);
$this->mailService->sendOrganizerBookingInformationEmail($booking, $config, $calendar);

View File

@ -41,6 +41,7 @@ use OCP\Mail\IEMailTemplate;
use OCP\Mail\IMailer;
use OCP\Notification\IManager;
use Psr\Log\LoggerInterface;
use function htmlspecialchars;
use function implode;
class MailService {
@ -128,7 +129,7 @@ class MailService {
}
// Create Booking overview
$this->addBulletList($template, $this->l10n, $booking, $config->getLocation());
$this->addBulletList($template, $this->l10n, $booking, $config);
$bodyText = $this->l10n->t('This confirmation link expires in %s hours.', [(BookingService::EXPIRY / 3600)]);
$template->addBodyText($bodyText);
@ -198,7 +199,7 @@ class MailService {
}
// Create Booking overview
$this->addBulletList($template, $this->l10n, $booking, $config->getLocation());
$this->addBulletList($template, $this->l10n, $booking, $config);
$bodyText = $this->l10n->t('If you wish to cancel the appointment after all, please contact your organizer by replying to this email or by visiting their profile page.');
$template->addBodyText($bodyText);
@ -228,7 +229,7 @@ class MailService {
private function addBulletList(IEMailTemplate $template,
IL10N $l10n,
Booking $booking,
?string $location = null):void {
AppointmentConfig $config):void {
$template->addBodyListItem($booking->getDisplayName(), $l10n->t('Appointment for:'));
$l = $this->lFactory->findGenericLanguage();
@ -242,8 +243,19 @@ class MailService {
$template->addBodyListItem($relativeDateTime, $l10n->t('Date:'));
if (!empty($location)) {
$template->addBodyListItem($location, $l10n->t('Where:'));
if (!$booking->isConfirmed() && $config->getCreateTalkRoom()) {
$template->addBodyListItem($l10n->t('You will receive a link with the confirmation email'), $l10n->t('Where:'));
} elseif (!$booking->isConfirmed() && !empty($config->getLocation())) {
$template->addBodyListItem($config->getLocation(), $l10n->t('Where:'));
} elseif ($booking->isConfirmed() && $booking->getTalkUrl() !== null) {
$template->addBodyListItem(
'<a href="' . htmlspecialchars($booking->getTalkUrl()) . '">' . $booking->getTalkUrl() . '</a>',
$l10n->t('Where:'),
'',
$booking->getTalkUrl(),
);
} elseif ($booking->isConfirmed() && !empty($config->getLocation())) {
$template->addBodyListItem($config->getLocation(), $l10n->t('Where:'));
}
if (!empty($booking->getDescription())) {
@ -296,7 +308,7 @@ class MailService {
}
// Create Booking overview
$this->addBulletList($template, $this->l10n, $booking, $config->getLocation());
$this->addBulletList($template, $this->l10n, $booking, $config);
$template->addFooter();
$attachment = $this->mailer->createAttachment($calendar, 'appointment.ics', 'text/calendar');

View File

@ -38,7 +38,14 @@
:value.sync="editing.name" />
<TextInput class="appointment-config-modal__form__row"
:label="t('calendar', 'Location')"
:value.sync="editing.location" />
:value.sync="editing.location"
:disabled="isTalkEnabled && editing.createTalkRoom" />
<div v-if="isTalkEnabled" class="appointment-config-modal__form__row">
<NcCheckboxRadioSwitch :checked.sync="editing.createTalkRoom">
{{ t('calendar', 'Create a Talk room') }}
</NcCheckboxRadioSwitch>
<span class="appointment-config-modal__talk-room-description">{{ t('calendar', 'A unique link will be generated for every booked appointment and sent via the confirmation email') }}</span>
</div>
<TextArea class="appointment-config-modal__form__row"
:label="t('calendar', 'Description')"
:value.sync="editing.description" />
@ -141,6 +148,7 @@
<script>
import { CalendarAvailability } from '@nextcloud/calendar-availability-vue'
import Modal from '@nextcloud/vue/dist/Components/NcModal.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import TextInput from './AppointmentConfigModal/TextInput.vue'
import TextArea from './AppointmentConfigModal/TextArea.vue'
import AppointmentConfig from '../models/appointmentConfig.js'
@ -170,6 +178,7 @@ export default {
VisibilitySelect,
Confirmation,
NcButton,
NcCheckboxRadioSwitch,
},
props: {
config: {
@ -193,6 +202,7 @@ export default {
computed: {
...mapGetters([
'ownSortedCalendars',
'isTalkEnabled',
]),
formTitle() {
if (this.isNew) {
@ -252,6 +262,10 @@ export default {
this.enableFutureLimit = !!this.editing.futureLimit
this.showConfirmation = false
// Disable Talk integration if Talk is no longer available
if (!this.isTalkEnabled) {
this.editing.createTalkRoom = false
}
},
calendarUrlToUri(url) {
// Trim trailing slash and split into URL parts
@ -301,3 +315,11 @@ export default {
},
}
</script>
<style lang="scss" scoped>
.appointment-config-modal {
&__talk-room-description {
color: var(--color-text-maxcontrast);
}
}
</style>

View File

@ -56,8 +56,8 @@ export default {
required: true,
},
value: {
type: [Number, null, undefined],
required: true,
type: Number,
default: 0,
},
defaultValue: {
type: Number,

View File

@ -26,6 +26,7 @@
<input :id="id"
type="text"
:value="value"
:disabled="disabled"
@input="change">
</div>
</template>
@ -44,6 +45,10 @@ export default {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
},
data() {
return {

View File

@ -77,6 +77,9 @@ export default class AppointmentConfig {
/** @member {?string[]} */
calendarFreeBusyUris
/** @member {bool} */
createTalkRoom
/**
* Create a new AppointmentConfig from the given plain object data
*
@ -97,6 +100,7 @@ export default class AppointmentConfig {
* @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
* @param {bool} data.createTalkRoom Whether a Talk room should be created
*/
constructor(data) {
data ??= {}
@ -116,6 +120,7 @@ export default class AppointmentConfig {
this.dailyMax = tryParseInt(data.dailyMax)
this.futureLimit = tryParseInt(data.futureLimit)
this.calendarFreeBusyUris = data.calendarFreeBusyUris
this.createTalkRoom = data.createTalkRoom
}
/**

View File

@ -227,6 +227,8 @@ Initial settings:
const getters = {
isTalkEnabled: (state) => state.talkEnabled,
/**
* Gets the resolved timezone.
* If the timezone is set to automatic, it returns the user's current timezone

View File

@ -42,6 +42,7 @@ use OCA\Calendar\Service\Appointments\MailService;
use OCA\Calendar\Service\Appointments\SlotExtrapolator;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\Calendar\ICalendarQuery;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IUser;
use OCP\Security\ISecureRandom;
use PHPUnit\Framework\MockObject\MockObject;
@ -73,6 +74,9 @@ class BookingServiceTest extends TestCase {
/** @var MailService|MockObject */
private $mailService;
/** @var IEventDispatcher|MockObject */
private $eventDispatcher;
/** @var MockObject|LoggerInterface */
private $logger;
@ -94,7 +98,9 @@ class BookingServiceTest extends TestCase {
$this->bookingCalendarWriter = $this->createMock(BookingCalendarWriter::class);
$this->random = $this->createMock(ISecureRandom::class);
$this->mailService = $this->createMock(MailService::class);
$this->eventDispatcher = $this->createMock(IEventDispatcher::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->service = new BookingService(
$this->availabilityGenerator,
$this->extrapolator,
@ -104,6 +110,7 @@ class BookingServiceTest extends TestCase {
$this->bookingCalendarWriter,
$this->random,
$this->mailService,
$this->eventDispatcher,
$this->logger,
);
}