Skip to content

Commit 6e7cac3

Browse files
Merge pull request #7683 from nextcloud/fix/meeting-proposal-calendar-blockers
fix: meeting proposal calendar blockers
2 parents cdd070d + ccbeef2 commit 6e7cac3

3 files changed

Lines changed: 216 additions & 64 deletions

File tree

lib/Service/Proposal/ProposalService.php

Lines changed: 175 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use OCA\Calendar\Objects\Proposal\ProposalParticipantStatus;
2626
use OCA\Calendar\Objects\Proposal\ProposalResponseObject;
2727
use OCA\Calendar\Objects\Proposal\ProposalVoteCollection;
28+
use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer;
2829
use OCP\Calendar\ICalendar;
2930
use OCP\Calendar\ICalendarIsWritable;
3031
use OCP\Calendar\ICreateFromString;
@@ -200,7 +201,7 @@ public function createProposal(IUser $user, ProposalObject $proposal): ProposalO
200201
$this->generateNotifications($user, $proposal, 'C');
201202

202203
// generate iTip for internal participants
203-
$this->generateIMip($user, $proposal, 'C');
204+
$this->syncCalendarBlockers($user, $proposal, 'C');
204205

205206
return $proposal;
206207
}
@@ -267,7 +268,7 @@ public function modifyProposal(IUser $user, ProposalObject $mutatedProposal): Pr
267268
$this->generateNotifications($user, $proposal, 'M');
268269

269270
// generate iTip for internal participants
270-
$this->generateIMip($user, $proposal, 'M');
271+
$this->syncCalendarBlockers($user, $proposal, 'M');
271272

272273
return $proposal;
273274
}
@@ -291,7 +292,7 @@ public function destroyProposal(IUser $user, int $identifier): void {
291292
$this->generateNotifications($user, $proposal, 'D');
292293

293294
// generate iTip for internal participants
294-
$this->generateIMip($user, $proposal, 'D');
295+
$this->syncCalendarBlockers($user, $proposal, 'D');
295296
}
296297

297298
/**
@@ -308,26 +309,8 @@ public function convertProposal(IUser $user, int $proposalId, int $dateId, array
308309
if ($selectedDate === null) {
309310
throw new \InvalidArgumentException('Date not found for proposal');
310311
}
311-
312-
// retrieve the primary calendar for the user
313-
// this condition is just to make psalm happy
314-
if (method_exists($this->calendarManager, 'getPrimaryCalendar')) {
315-
/** @var ICalendar&ICreateFromString|null $userCalendar */
316-
$userCalendar = $this->calendarManager->getPrimaryCalendar($user->getUID());
317-
}
318-
if ($userCalendar !== null && (!$userCalendar instanceof ICreateFromString || $userCalendar->isDeleted())) {
319-
$userCalendar = null;
320-
}
321-
// if no primary calendar is set, use the first useable calendar
322-
if ($userCalendar === null) {
323-
$userCalendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $user->getUID());
324-
foreach ($userCalendars as $calendar) {
325-
if ($calendar instanceof ICreateFromString && $calendar instanceof ICalendarIsWritable && $calendar->isWritable() && !$calendar->isDeleted()) {
326-
$userCalendar = $calendar;
327-
break;
328-
}
329-
}
330-
}
312+
// locate users primary calendar
313+
$userCalendar = $this->findPrimaryCalendar($user);
331314
if ($userCalendar === null) {
332315
throw new \RuntimeException('Could not find a useable calendar to create a meeting from the selected proposal');
333316
}
@@ -593,58 +576,148 @@ private function sendEmailNotifications(IUser $user, ProposalObject $proposal, P
593576

594577
}
595578

596-
private function generateIMip(IUser $user, ProposalObject $proposal, string $reason): void {
597-
// if the calendar manager does not have a handleIMip method, we cannot generate iTip messages
598-
if (!method_exists($this->calendarManager, 'handleIMip')) {
579+
/**
580+
* Create, update, or delete calendar blocker event
581+
*/
582+
private function syncCalendarBlockers(IUser $user, ProposalObject $proposal, string $reason): void {
583+
584+
// if the proposal has no dates or participants, time blockers are not needed
585+
if ($proposal->getDates()->count() === 0 || $proposal->getParticipants()->count() === 0) {
599586
return;
600587
}
601-
// if the proposal has no dates or participants, we cannot generate any iTip messages
602-
if ($proposal->getDates()->count() === 0 || $proposal->getParticipants()->count() === 0) {
588+
$userCalendarUri = null;
589+
$userEventUri = null;
590+
// if the reason is deletion, remove existing calendar blockers
591+
if ($reason === 'D') {
592+
$result = $this->findCalendarBlocker($user, $proposal);
593+
if ($result === null) {
594+
return;
595+
}
596+
$this->deleteCalendarBlockersOrganizer($user, $result['calendarUri'], $result['eventUri'], $proposal);
603597
return;
604598
}
599+
// if the reason is modification, try to locate existing calendar with blocker
600+
if ($reason === 'M') {
601+
$result = $this->findCalendarBlocker($user, $proposal);
602+
if ($result !== null) {
603+
$userCalendarUri = $result['calendarUri'];
604+
$userEventUri = $result['eventUri'];
605+
}
606+
}
607+
// if reason is creation, or no existing calendar blocker found, locate primary calendar
608+
if (!isset($userCalendarUri) || $userCalendarUri === null) {
609+
$result = $this->findPrimaryCalendar($user);
610+
if ($result !== null) {
611+
$userCalendarUri = $result->getUri();
612+
}
613+
}
614+
if ($userCalendarUri === null) {
615+
throw new \RuntimeException('Could not find a useable calendar to create a meeting from the selected proposal');
616+
}
617+
618+
$vObject = $this->constructCalendarBlocker($user, $proposal);
619+
620+
$this->applyCalendarBlockersOrganizer($user, $userCalendarUri, $userEventUri, $vObject);
621+
$this->applyCalendarBlockersParticipant($user, $proposal, $reason, $userCalendarUri, $userEventUri, $vObject);
622+
623+
}
624+
625+
/**
626+
* Construct calendar blocker event
627+
*/
628+
private function constructCalendarBlocker(IUser $user, ProposalObject $proposal): VCalendar {
605629
// construct calendar object with events
606-
$template = new VCalendar();
607-
// TODO: change REQUEST to PUBLISH
608-
$template->add('METHOD', $reason !== 'D' ? 'REQUEST' : 'CANCEL');
609-
// create a event for each date in the proposal
610-
// TODO: should we create a new instance for each date or use a recurrence rule? Like RDATE:19970714T083000Z,19970715T083000Z
630+
$proposalDates = [];
631+
$firstProposalDate = null;
611632
foreach ($proposal->getDates()->sortByDate() as $proposalDate) {
612-
/** @var VEvent $vEvent */
613-
$vEvent = $template->add('VEVENT', []);
614-
if (isset($baseDate)) {
615-
$vEvent->add('RECURRENCE-ID', $baseDate->getDate()->format('Ymd\THis\Z'));
616-
} else {
617-
$baseDate = $proposalDate;
633+
$date = $proposalDate->getDate();
634+
if ($firstProposalDate === null) {
635+
$firstProposalDate = $date;
618636
}
619-
$vEvent->UID->setValue($proposal->getUuid());
620-
$vEvent->add('STATUS', 'TENTATIVE');
621-
$vEvent->add('SEQUENCE', 1);
622-
$vEvent->add('DTSTART', $proposalDate->getDate());
623-
$vEvent->add('DURATION', "PT{$proposal->getDuration()}M");
624-
$vEvent->add('SUMMARY', $proposal->getTitle());
637+
$proposalDates[] = $date->format('Ymd\THis\Z');
638+
}
639+
if ($firstProposalDate === null) {
640+
throw new \InvalidArgumentException('Cannot construct calendar blocker without at least one proposal date');
641+
}
642+
$vObject = new VCalendar();
643+
/** @var VEvent $vEvent */
644+
$vEvent = $vObject->add('VEVENT', []);
645+
$vEvent->UID->setValue($proposal->getUuid());
646+
$vEvent->add('STATUS', 'TENTATIVE');
647+
$vEvent->add('SEQUENCE', 1);
648+
$vEvent->add('DTSTART', $firstProposalDate);
649+
$vEvent->add('DURATION', "PT{$proposal->getDuration()}M");
650+
$vEvent->add('RDATE', $proposalDates);
651+
$vEvent->add('SUMMARY', $this->l10n->t('[Proposed] ') . $proposal->getTitle());
652+
if (!empty($proposal->getDescription())) {
625653
$vEvent->add('DESCRIPTION', $proposal->getDescription());
626-
$vEvent->add('ORGANIZER', 'mailto:' . $user->getEMailAddress(), ['CN' => $user->getDisplayName()]);
627-
// add the participant to the event
628-
foreach ($proposal->getParticipants() as $participant) {
629-
$vEvent->add('ATTENDEE', 'mailto:' . $participant->getAddress(), [
630-
'CN' => $participant->getName(),
631-
'CUTYPE' => 'INDIVIDUAL',
632-
'PARTSTAT' => 'NEEDS-ACTION',
633-
'ROLE' => 'REQ-PARTICIPANT'
634-
]);
635-
}
636654
}
655+
if (!empty($proposal->getLocation())) {
656+
$vEvent->add('LOCATION', $proposal->getLocation());
657+
}
658+
$vEvent->add('ORGANIZER', 'mailto:' . $user->getEMailAddress(), ['CN' => $user->getDisplayName()]);
659+
// add the participant to the event
660+
foreach ($proposal->getParticipants() as $participant) {
661+
$vEvent->add('ATTENDEE', 'mailto:' . $participant->getAddress(), [
662+
'CN' => $participant->getName(),
663+
'CUTYPE' => 'INDIVIDUAL',
664+
'PARTSTAT' => 'NEEDS-ACTION',
665+
'ROLE' => 'REQ-PARTICIPANT'
666+
]);
667+
}
668+
669+
return $vObject;
670+
}
671+
672+
/**
673+
* Create or update calendar blocker event(s) for organizer
674+
*/
675+
private function applyCalendarBlockersOrganizer(IUser $user, string $calendarUri, ?string $eventUri, VCalendar $vObject): void {
676+
/** @var \OCA\DAV\CalDAV\CalendarHome $calendarHome */
677+
$calendarHome = (new InvitationResponseServer(false))->getServer()->tree->getNodeForPath('/calendars/' . $user->getUID());
678+
/** @var \OCA\DAV\CalDAV\Calendar $calendar */
679+
$calendar = $calendarHome->getChild($calendarUri);
680+
681+
if ($eventUri === null) {
682+
$calendar->createFile(
683+
Uuid::v4()->toRfc4122() . '.ics',
684+
$vObject->serialize()
685+
);
686+
} else {
687+
$event = $calendar->getChild($eventUri);
688+
$event->put($vObject->serialize());
689+
}
690+
}
691+
692+
/**
693+
* Delete existing calendar blocker event
694+
*/
695+
private function deleteCalendarBlockersOrganizer(IUser $user, string $calendarUri, string $eventUri, ProposalObject $proposal): void {
696+
/** @var \OCA\DAV\CalDAV\CalendarHome $calendarHome */
697+
$calendarHome = (new InvitationResponseServer(false))->getServer()->tree->getNodeForPath('/calendars/' . $user->getUID());
698+
/** @var \OCA\DAV\CalDAV\Calendar $calendar */
699+
$calendar = $calendarHome->getChild($calendarUri);
700+
701+
$event = $calendar->getChild($eventUri);
702+
$event->delete($event->getName());
703+
}
704+
705+
/**
706+
* Create or update calendar blocker event(s) for participant(s)
707+
*/
708+
private function applyCalendarBlockersParticipant(IUser $user, ProposalObject $proposal, string $reason, string $calendarUri, ?string $eventUri, VCalendar $vObject): void {
709+
// if the calendar manager does not have a handleIMip method, we cannot generate iTip messages
710+
if (!method_exists($this->calendarManager, 'handleIMip')) {
711+
return;
712+
}
713+
// TODO: change REQUEST to PUBLISH
714+
$vObject->add('METHOD', $reason !== 'D' ? 'REQUEST' : 'CANCEL');
637715

638716
foreach ($proposal->getParticipants()->filterByRealm(ProposalParticipantRealm::Internal) as $participant) {
639717
$participantAddress = $participant->getAddress();
640718
if ($participantAddress === null) {
641719
continue;
642720
}
643-
644-
// TODO: this is stupid, we send the internal users email address from the UI then convert it back to a user name
645-
// should probably be sent from the UI as a user name, or send and store both the user name and email address
646-
// maybe send the address as a special schema "local:{user name}/{email address}", this would allow us to later extend this to federated users
647-
// with a different special schema like "federated:{user name}@{server}/{email address}"
648721
$participantUsers = $this->userManager->getByEmail($participantAddress);
649722
if ($participantUsers === []) {
650723
continue;
@@ -653,14 +726,55 @@ private function generateIMip(IUser $user, ProposalObject $proposal, string $rea
653726
try {
654727
$this->calendarManager->handleIMip(
655728
$participantUser->getUID(),
656-
$template->serialize(),
729+
$vObject->serialize(),
657730
$reason !== 'D' ? ['absent' => 'create'] : []
658731
);
659732
} catch (Exception $e) {
660733
$this->logger->error($e->getMessage(), ['app' => 'calendar', 'exception' => $e]);
661734
}
662735
}
736+
}
737+
738+
/**
739+
* Find the primary calendar for a user, or the first useable calendar
740+
*/
741+
private function findPrimaryCalendar(IUser $user): ?ICreateFromString {
742+
// retrieve the primary calendar for the user
743+
// this condition is just to make psalm happy
744+
if (method_exists($this->calendarManager, 'getPrimaryCalendar')) {
745+
/** @var ICalendar&ICreateFromString|null $userCalendar */
746+
$userCalendar = $this->calendarManager->getPrimaryCalendar($user->getUID());
747+
}
748+
if ($userCalendar !== null && (!$userCalendar instanceof ICreateFromString || $userCalendar->isDeleted())) {
749+
$userCalendar = null;
750+
}
751+
// if no primary calendar is set, use the first useable calendar
752+
if ($userCalendar === null) {
753+
$userCalendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $user->getUID());
754+
foreach ($userCalendars as $calendar) {
755+
if ($calendar instanceof ICreateFromString && $calendar instanceof ICalendarIsWritable && $calendar->isWritable() && !$calendar->isDeleted()) {
756+
$userCalendar = $calendar;
757+
break;
758+
}
759+
}
760+
}
761+
return $userCalendar;
762+
}
663763

764+
/**
765+
* Find existing calendar blocker event
766+
*
767+
* @return array{calendarUri: string, eventUri: string}|null
768+
*/
769+
private function findCalendarBlocker(IUser $user, ProposalObject $proposal): ?array {
770+
$userCalendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $user->getUID());
771+
foreach ($userCalendars as $calendar) {
772+
$result = $calendar->search('', [], ['uid' => (string)$proposal->getUuid()]);
773+
if (isset($result[0])) {
774+
return ['calendarUri' => $calendar->getUri(), 'eventUri' => $result[0]['uri']];
775+
}
776+
}
777+
return null;
664778
}
665779

666780
}

psalm.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
<referencedClass name="OCA\Circles\Api\v1\Circles" />
3838
<referencedClass name="OCA\Circles\Exceptions\CircleNotFoundException" />
3939
<referencedClass name="OCA\Calendar\Controller\Exception" />
40+
<referencedClass name="OCA\DAV\CalDAV\Calendar" />
41+
<referencedClass name="OCA\DAV\CalDAV\CalendarHome" />
42+
<referencedClass name="OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer" />
4043
<referencedClass name="OCA\NotifyPush\Queue\IQueue" />
4144
<referencedClass name="Psr\Http\Client\ClientExceptionInterface" />
4245
<referencedClass name="Sabre\VObject\Component\VCalendar" />
@@ -65,6 +68,9 @@
6568
<referencedClass name="OC\App\CompareVersion" />
6669
<referencedClass name="OCA\Calendar\Controller\Exception" />
6770
<referencedClass name="OCA\Circles\Api\v1\Circles" />
71+
<referencedClass name="OCA\DAV\CalDAV\Calendar" />
72+
<referencedClass name="OCA\DAV\CalDAV\CalendarHome" />
73+
<referencedClass name="OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer" />
6874
<referencedClass name="Symfony\Component\Console\Output\OutputInterface" />
6975
</errorLevel>
7076
</UndefinedDocblockClass>

0 commit comments

Comments
 (0)