f7cloud_client/apps/calendar/lib/Service/Proposal/ProposalService.php
root 8b6a0139db f7cloud_client
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 22:59:26 +00:00

787 lines
30 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 F7cloud GmbH and F7cloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Calendar\Service\Proposal;
use DateTimeZone;
use Exception;
use OCA\Calendar\Db\ProposalDateMapper;
use OCA\Calendar\Db\ProposalMapper;
use OCA\Calendar\Db\ProposalParticipantMapper;
use OCA\Calendar\Db\ProposalVoteMapper;
use OCA\Calendar\Objects\Proposal\ProposalDateCollection;
use OCA\Calendar\Objects\Proposal\ProposalDateObject;
use OCA\Calendar\Objects\Proposal\ProposalDateVote;
use OCA\Calendar\Objects\Proposal\ProposalObject;
use OCA\Calendar\Objects\Proposal\ProposalParticipantCollection;
use OCA\Calendar\Objects\Proposal\ProposalParticipantObject;
use OCA\Calendar\Objects\Proposal\ProposalParticipantRealm;
use OCA\Calendar\Objects\Proposal\ProposalParticipantStatus;
use OCA\Calendar\Objects\Proposal\ProposalResponseObject;
use OCA\Calendar\Objects\Proposal\ProposalVoteCollection;
use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer;
use OCP\Calendar\ICalendar;
use OCP\Calendar\ICalendarIsWritable;
use OCP\Calendar\ICreateFromString;
use OCP\Calendar\IManager;
use OCP\IAppConfig;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Mail\IMailer;
use OCP\Mail\Provider\Address;
use OCP\Mail\Provider\IManager as IMailManager;
use OCP\Mail\Provider\IMessageSend;
use Psr\Log\LoggerInterface;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;
use Symfony\Component\Uid\Uuid;
class ProposalService {
public function __construct(
private IAppConfig $appConfig,
private LoggerInterface $logger,
private ProposalMapper $proposalMapper,
private ProposalParticipantMapper $proposalParticipantMapper,
private ProposalDateMapper $proposalDateMapper,
private ProposalVoteMapper $proposalVoteMapper,
private IL10N $l10n,
private IURLGenerator $urlGenerator,
private IUserManager $userManager,
private IMailer $systemMailManager,
private IMailManager $userMailManager,
private IManager $calendarManager,
) {
}
public function listProposals(IUser $user): array {
// retrieve all proposals, participants, dates, and votes for the user
$proposalEntries = $this->proposalMapper->fetchByUserId($user->getUID());
$proposalParticipantEntries = $this->proposalParticipantMapper->fetchByUserId($user->getUID());
$proposalDateEntries = $this->proposalDateMapper->fetchByUserId($user->getUID());
$proposalVoteEntries = $this->proposalVoteMapper->fetchByUserId($user->getUID());
// organize the participant entries by proposal ID ['pid' => [participant, ...]]
$proposalParticipantEntries = array_reduce(
$proposalParticipantEntries,
function ($carry, $entry) {
$pid = $entry->getPid();
$carry[$pid][] = $entry;
return $carry;
},
[]
);
// organize the date entries by proposal ID ['pid' => [date, ...]]
$proposalDateEntries = array_reduce(
$proposalDateEntries,
function ($carry, $entry) {
$pid = $entry->getPid();
$carry[$pid][] = $entry;
return $carry;
},
[]
);
// organize the vote entries by proposal ID ['pid' => [vote, ...]]
$proposalVoteEntries = array_reduce(
$proposalVoteEntries,
function ($carry, $entry) {
$pid = $entry->getPid();
$carry[$pid][] = $entry;
return $carry;
},
[]
);
// convert the store entries to objects
$proposals = [];
foreach ($proposalEntries as $proposalEntry) {
// convert the store format to a proposal object
$proposal = new ProposalObject();
$proposal->fromStore($proposalEntry);
$proposalParticipants = $proposal->getParticipants();
if (isset($proposalParticipantEntries[$proposalEntry->getId()])) {
$proposalParticipants->fromStore($proposalParticipantEntries[$proposalEntry->getId()]);
}
$proposalDates = $proposal->getDates();
if (isset($proposalDateEntries[$proposalEntry->getId()])) {
$proposalDates->fromStore($proposalDateEntries[$proposalEntry->getId()]);
}
$proposalVotes = $proposal->getVotes();
if (isset($proposalVoteEntries[$proposalEntry->getId()])) {
$proposalVotes->fromStore($proposalVoteEntries[$proposalEntry->getId()]);
}
// add the proposal to the list
$proposals[] = $proposal;
}
return $proposals;
}
public function fetchProposal(IUser $user, int $id): ?ProposalObject {
// fetch the proposal entry by id
$proposalEntry = $this->proposalMapper->fetchById($user->getUID(), $id);
if ($proposalEntry === null) {
return null;
}
// retrieve the participants, dates, and votes for this proposal
$proposalParticipantEntries = $this->proposalParticipantMapper->fetchByProposalId($user->getUID(), $id);
$proposalDateEntries = $this->proposalDateMapper->fetchByProposalId($user->getUID(), $id);
$proposalVoteEntries = $this->proposalVoteMapper->fetchByProposalId($user->getUID(), $id);
// convert the store entries to objects
$proposalParticipants = new ProposalParticipantCollection();
$proposalParticipants->fromStore($proposalParticipantEntries);
$proposalDates = new ProposalDateCollection();
$proposalDates->fromStore($proposalDateEntries);
$proposalVotes = new ProposalVoteCollection();
$proposalVotes->fromStore($proposalVoteEntries);
// convert the proposal entry to a proposal object
$proposal = new ProposalObject();
$proposal->fromStore($proposalEntry);
$proposal->setParticipants($proposalParticipants);
$proposal->setDates($proposalDates);
$proposal->setVotes($proposalVotes);
return $proposal;
}
/**
* Fetch a proposal by a participant's unique token
*/
public function fetchByToken(string $token): ?ProposalObject {
// Fetch the participant by token
$participantEntry = $this->proposalParticipantMapper->fetchByToken($token);
if ($participantEntry === null) {
return null;
}
$user = $this->userManager->get($participantEntry->getUid());
// retrieve full proposal with participants, dates, and votes
$proposal = $this->fetchProposal($user, $participantEntry->getPid());
return $proposal;
}
/**
* Create a new proposal
*/
public function createProposal(IUser $user, ProposalObject $proposal): ProposalObject {
// determine if the proposal ID is null, as it should be for a new proposal
if ($proposal->getId() !== null) {
throw new \InvalidArgumentException('Proposal identifier must be null for a new proposal');
}
// store proposal
$proposalEntry = $proposal->toStore();
$proposalEntry->setUid($user->getUID());
$proposalEntry->setUuid(Uuid::v4()->toRfc4122());
$proposalEntry = $this->proposalMapper->insert($proposalEntry);
// store participants
foreach ($proposal->getParticipants() as $participant) {
$entry = $participant->toStore();
$entry->setUid($user->getUID());
$entry->setPid($proposalEntry->getId());
$entry->setToken(md5(random_bytes(32)));
$this->proposalParticipantMapper->insert($entry);
}
// store dates
foreach ($proposal->getDates() as $date) {
$entry = $date->toStore();
$entry->setUid($user->getUID());
$entry->setPid($proposalEntry->getId());
$this->proposalDateMapper->insert($entry);
}
// retrieve full proposal with participants, dates, and votes
$proposal = $this->fetchProposal($user, $proposalEntry->getId());
// generate notifications for internal and external participants
$this->generateNotifications($user, $proposal, 'C');
// generate iTip for internal participants
$this->syncCalendarBlockers($user, $proposal, 'C');
return $proposal;
}
/**
* Modify an existing proposal
*/
public function modifyProposal(IUser $user, ProposalObject $mutatedProposal): ProposalObject {
// determine if the proposal ID is set, as it should be for a modification
if ($mutatedProposal->getId() === null) {
throw new \InvalidArgumentException('Proposal identifier cannot be null for modification');
}
// retrieve the existing proposal
$currentProposal = $this->fetchProposal($user, $mutatedProposal->getId());
if ($currentProposal === null) {
throw new \InvalidArgumentException('Proposal not found for identifier: ' . ($mutatedProposal->getId() ?? 'null'));
}
// convert and store proposal object
$mutatedProposalEntry = $mutatedProposal->toStore();
$mutatedProposalEntry->setId($currentProposal->getId());
$mutatedProposalEntry->setUid($user->getUID());
$this->proposalMapper->update($mutatedProposalEntry);
// compare, convert and store participants objects
$participantDelta = $currentProposal->getParticipants()->compare($mutatedProposal->getParticipants());
foreach (['added', 'modified'] as $mutation) {
foreach ($participantDelta[$mutation] as $participant) {
$mutatedParticipantEntry = $participant->toStore();
$mutatedParticipantEntry->setUid($user->getUID());
$mutatedParticipantEntry->setPid($mutatedProposalEntry->getId());
if ($mutation === 'modified') {
$this->proposalParticipantMapper->update($mutatedParticipantEntry);
} else {
$mutatedParticipantEntry->setToken(md5(random_bytes(32)));
$this->proposalParticipantMapper->insert($mutatedParticipantEntry);
}
}
}
foreach ($participantDelta['deleted'] as $participant) {
$this->proposalParticipantMapper->deleteById($user->getUID(), $participant->getId());
}
// compare, convert and store dates objects
$dateDelta = $currentProposal->getDates()->compare($mutatedProposal->getDates());
foreach (['added', 'modified'] as $mutation) {
foreach ($dateDelta[$mutation] as $date) {
$mutatedDateEntry = $date->toStore();
$mutatedDateEntry->setUid($user->getUID());
$mutatedDateEntry->setPid($mutatedProposalEntry->getId());
if ($mutation === 'modified') {
$this->proposalDateMapper->update($mutatedDateEntry);
$this->proposalVoteMapper->deleteByDateId($user->getUID(), $date->getId());
} else {
$this->proposalDateMapper->insert($mutatedDateEntry);
}
}
}
foreach ($dateDelta['deleted'] as $date) {
$this->proposalVoteMapper->deleteByDateId($user->getUID(), $date->getId());
$this->proposalDateMapper->deleteById($user->getUID(), $date->getId());
}
// retrieve full proposal with participants, dates, and votes
$proposal = $this->fetchProposal($user, $mutatedProposalEntry->getId());
// generate notifications for internal and external participants
$this->generateNotifications($user, $proposal, 'M');
// generate iTip for internal participants
$this->syncCalendarBlockers($user, $proposal, 'M');
return $proposal;
}
/**
* Destroy a proposal
*/
public function destroyProposal(IUser $user, int $identifier): void {
// retrieve full proposal with participants, dates, and votes
$proposal = $this->fetchProposal($user, $identifier);
if ($proposal === null) {
throw new \InvalidArgumentException('Proposal not found');
}
// destroy the proposal entry
$this->proposalVoteMapper->deleteByProposalId($user->getUID(), $proposal->getId());
$this->proposalParticipantMapper->deleteByProposalId($user->getUID(), $proposal->getId());
$this->proposalDateMapper->deleteByProposalId($user->getUID(), $proposal->getId());
$this->proposalMapper->deleteById($user->getUID(), $proposal->getId());
// generate notifications for internal and external participants
$this->generateNotifications($user, $proposal, 'D');
// generate iTip for internal participants
$this->syncCalendarBlockers($user, $proposal, 'D');
}
/**
* Convert a selected proposal date into a calendar meeting.
*/
public function convertProposal(IUser $user, int $proposalId, int $dateId, array $options = []): void {
// retrieve full proposal with participants, dates, and votes
$proposal = $this->fetchProposal($user, $proposalId);
if ($proposal === null) {
throw new \InvalidArgumentException('Proposal not found');
}
// locate selected date
$selectedDate = $proposal->getDates()->findById($dateId);
if ($selectedDate === null) {
throw new \InvalidArgumentException('Date not found for proposal');
}
// locate users primary calendar
$userCalendar = $this->findPrimaryCalendar($user);
if ($userCalendar === null) {
throw new \RuntimeException('Could not find a useable calendar to create a meeting from the selected proposal');
}
// extract options
// timezone option
$eventTimezone = null;
if (isset($options['timezone']) && is_string($options['timezone']) && in_array($options['timezone'], DateTimeZone::listIdentifiers(), true)) {
if (!empty($options['timezone'])) {
$eventTimezone = new DateTimeZone($options['timezone']);
}
}
// participant attendance option
$eventAttendancePreset = false;
if (isset($options['attendancePreset']) && is_bool($options['attendancePreset'])) {
$eventAttendancePreset = $options['attendancePreset'];
}
// talk room option
$talkRoomUri = null;
if (isset($options['talkRoomUri']) && is_string($options['talkRoomUri'])) {
$talkRoomUri = $options['talkRoomUri'];
}
// build a VCalendar with single definitive event
$vObject = new VCalendar();
/** @var \Sabre\VObject\Component\VEvent $vEvent */
$vEvent = $vObject->add('VEVENT', []);
$vEvent->UID->setValue($proposal->getUuid() ?? Uuid::v4()->toRfc4122());
$vEvent->add('DTSTART', $eventTimezone ? $selectedDate->getDate()->setTimezone($eventTimezone) : $selectedDate->getDate());
$vEvent->add('DTEND', (clone $vEvent->DTSTART->getDateTime())->add(new \DateInterval("PT{$proposal->getDuration()}M")));
$vEvent->add('SEQUENCE', 1);
$vEvent->add('SUMMARY', $proposal->getTitle());
$vEvent->add('DESCRIPTION', $proposal->getDescription());
if ($talkRoomUri !== null) {
$vEvent->add('LOCATION', $talkRoomUri);
} elseif (!empty($proposal->getLocation())) {
$vEvent->add('LOCATION', $proposal->getLocation());
}
$vEvent->add('ORGANIZER', 'mailto:' . $user->getEMailAddress(), ['CN' => $user->getDisplayName()]);
foreach ($proposal->getParticipants() as $participant) {
if ($participant->getAddress() === null) {
continue;
}
$vEvent->add('ATTENDEE', 'mailto:' . $participant->getAddress(), [
'CN' => $participant->getName(),
'CUTYPE' => 'INDIVIDUAL',
'PARTSTAT' => $eventAttendancePreset ? $this->convertProposalAttendeeAttendance($selectedDate, $participant, $proposal->getVotes()) : 'NEEDS-ACTION',
'ROLE' => 'REQ-PARTICIPANT'
]);
}
// delete any existing calendar blocker event
$result = $this->findCalendarBlocker($user, $proposal);
if ($result !== null) {
$this->deleteCalendarBlockersOrganizer($user, $result['calendarUri'], $result['eventUri'], $proposal);
}
// store the calendar object
$userCalendar->createFromString(
Uuid::v4()->toRfc4122() . '.ics',
$vObject->serialize()
);
// destroy the proposal entry
$this->proposalVoteMapper->deleteByProposalId($user->getUID(), $proposal->getId());
$this->proposalParticipantMapper->deleteByProposalId($user->getUID(), $proposal->getId());
$this->proposalDateMapper->deleteByProposalId($user->getUID(), $proposal->getId());
$this->proposalMapper->deleteById($user->getUID(), $proposal->getId());
}
public function convertProposalAttendeeAttendance(ProposalDateObject $date, ProposalParticipantObject $participant, ProposalVoteCollection $votes): string {
// find the vote for the given date and participant
$vote = $votes->findByDateAndParticipant($date->getId(), $participant->getId());
// convert the vote to an iCal PARTSTAT value
if ($vote === null) {
return 'NEEDS-ACTION';
}
return match ($vote->getVote()) {
ProposalDateVote::Yes => 'ACCEPTED',
ProposalDateVote::No => 'DECLINED',
ProposalDateVote::Maybe => 'TENTATIVE',
default => 'NEEDS-ACTION',
};
}
public function deleteProposalsByUser(string $user): void {
$this->proposalVoteMapper->deleteByUserId($user);
$this->proposalParticipantMapper->deleteByUserId($user);
$this->proposalDateMapper->deleteByUserId($user);
$this->proposalMapper->deleteByUserId($user);
}
public function storeResponse(ProposalResponseObject $response): void {
// retrieve the participant entry
$participantEntry = $this->proposalParticipantMapper->fetchByToken($response->getToken());
if ($participantEntry === null) {
throw new \InvalidArgumentException('Participant not found for token: ' . $response->getToken());
}
// retrieve the proposal entry
$proposalEntry = $this->proposalMapper->fetchById($participantEntry->getUid(), $participantEntry->getPid());
if ($proposalEntry === null) {
throw new \InvalidArgumentException('Proposal not found for identifier: ' . ($participantEntry->getPid() ?? 'null'));
}
// retrieve proposal dates
$proposalDateEntries = $this->proposalDateMapper->fetchByProposalId($participantEntry->getUid(), $participantEntry->getPid());
// first, delete any existing votes for this participant
$this->proposalVoteMapper->deleteByParticipantId($participantEntry->getUid(), $participantEntry->getId());
// find response dates that match the proposal dates and store votes
$responseDates = $response->getDates();
foreach ($responseDates as $responseDate) {
$dateId = $responseDate->getId();
$foundDateEntry = null;
foreach ($proposalDateEntries as $proposalDateEntry) {
if ($proposalDateEntry->getId() === $dateId) {
$foundDateEntry = $proposalDateEntry;
break;
}
}
if ($foundDateEntry !== null) {
// create and save vote entry directly
$voteEntry = new \OCA\Calendar\Db\ProposalVoteEntry();
$voteEntry->setUid($participantEntry->getUid());
$voteEntry->setPid($participantEntry->getPid());
$voteEntry->setParticipantId($participantEntry->getId());
$voteEntry->setDateId($foundDateEntry->getId());
$voteEntry->setVote($responseDate->getVote());
$this->proposalVoteMapper->insert($voteEntry);
}
}
// update participant status to responded
$participantEntry->setStatus(ProposalParticipantStatus::Responded->value);
$this->proposalParticipantMapper->update($participantEntry);
}
private function generateNotifications(IUser $user, ProposalObject $proposal, string $reason): void {
// if the proposal has no dates or participants, we cannot generate any notifications
if ($proposal->getDates()->count() === 0 || $proposal->getParticipants()->count() === 0) {
return;
}
foreach ($proposal->getParticipants() as $participant) {
$this->sendEmailNotifications($user, $proposal, $participant, $reason);
}
}
private function sendEmailNotifications(IUser $user, ProposalObject $proposal, ProposalParticipantObject $participant, string $reason): void {
// if the user has no configured email address, we cannot send notifications
if (empty($user->getEMailAddress())) {
return;
}
// if the participant has no email address, we cannot send notifications
if (empty($participant->getAddress())) {
return;
}
$senderAddress = $user->getEMailAddress();
$senderName = $user->getDisplayName();
$recipientAddress = $participant->getAddress();
$recipientName = $participant->getName();
$recipientToken = $participant->getToken();
// Ensure we have valid string values for array keys
if ($senderAddress === null || $recipientAddress === null) {
return;
}
$template = $this->systemMailManager->createEMailTemplate('calendar.proposal.notification');
$template->addHeader();
// subject
match ($reason) {
'C' => $template->setSubject(
$this->l10n->t('%s has proposed a meeting', [$senderName])
),
'M' => $template->setSubject(
$this->l10n->t('%s has updated a proposed meeting', [$senderName])
),
'D' => $template->setSubject(
$this->l10n->t('%s has canceled a proposed meeting', [$senderName])
)
};
// heading
match ($reason) {
'C' => $template->addHeading(
$this->l10n->t('Dear %s, a new meeting has been proposed', [$recipientName])
),
'M' => $template->addHeading(
$this->l10n->t('Dear %s, a proposed meeting has been updated', [$recipientName])
),
'D' => $template->addHeading(
$this->l10n->t('Dear %s, a proposed meeting has been cancelled', [$recipientName])
)
};
// description
if (!empty($proposal->getDescription())) {
$template->addBodyListItem($proposal->getDescription(), $this->l10n->t('Description:'));
}
// location
if (!empty($proposal->getLocation())) {
$template->addBodyListItem($proposal->getLocation(), $this->l10n->t('Location:'));
}
// duration
if ($proposal->getDuration() > 0) {
$template->addBodyListItem($this->l10n->t('%1$s minutes', [(string)$proposal->getDuration()]), $this->l10n->t('Duration:'));
}
// dates
$temporaryText = '';
foreach ($proposal->getDates()->sortByDate() as $date) {
$dtStart = \DateTime::createFromImmutable($date->getDate());
$dtEnd = (clone $dtStart)->add(new \DateInterval("PT{$proposal->getDuration()}M"));
$textDate = $this->l10n->l('date', $dtStart, ['width' => 'long']);
$textStart = $this->l10n->l('time', $dtStart, ['width' => 'short']);
$textEnd = $this->l10n->l('time', $dtEnd, ['width' => 'short']);
$temporaryText .= $this->l10n->t('%1$s from %2$s to %3$s', [$textDate, $textStart, $textEnd]) . "\n";
}
$template->addBodyListItem($temporaryText, $this->l10n->t('Dates:'));
$template->addBodyButton(
$this->l10n->t('Respond'),
$this->urlGenerator->linkToRouteAbsolute('Calendar.ProposalPublic.index', ['token' => $recipientToken])
);
$template->addFooter();
try {
if ($this->appConfig->getValueBool('core', 'mail_providers_enabled', true)) {
// retrieve appropriate service with the same address as sender
$mailService = $this->userMailManager->findServiceByAddress($user->getUID(), $senderAddress);
}
// evaluate if a mail service was found and has sending capabilities
if (isset($mailService) && $mailService instanceof IMessageSend) {
// construct mail message and set required parameters
// this condition is just to make psalm happy
if (!method_exists($mailService, 'initiateMessage')) {
throw new \RuntimeException('Mail service does not support message initiation');
}
$message = $mailService->initiateMessage();
$message->setFrom(
(new Address($senderAddress, $senderName))
);
$message->setTo(
(new Address($recipientAddress, $recipientName))
);
$message->setSubject($template->renderSubject());
$message->setBodyPlain($template->renderText());
$message->setBodyHtml($template->renderHtml());
// send message
$mailService->sendMessage($message);
} else {
$fromAddress = \OCP\Util::getDefaultEmailAddress('proposal-noreply');
// construct symfony mailer message and set required parameters
$message = $this->systemMailManager->createMessage();
$message->setFrom([$fromAddress => $senderName ?? '']);
$message->setTo(
$recipientName !== null ? [$recipientAddress => $recipientName] : [$recipientAddress]
);
$message->setReplyTo(
$senderName !== null ? [$senderAddress => $senderName] : [$senderAddress]
);
$message->useTemplate($template);
$failed = $this->systemMailManager->send($message);
}
} catch (Exception $e) {
$this->logger->error($e->getMessage(), ['app' => 'calendar', 'exception' => $e]);
}
}
/**
* Create, update, or delete calendar blocker event
*/
private function syncCalendarBlockers(IUser $user, ProposalObject $proposal, string $reason): void {
// if the proposal has no dates or participants, time blockers are not needed
if ($proposal->getDates()->count() === 0 || $proposal->getParticipants()->count() === 0) {
return;
}
$userCalendarUri = null;
$userEventUri = null;
// if the reason is deletion, remove existing calendar blockers
if ($reason === 'D') {
$result = $this->findCalendarBlocker($user, $proposal);
if ($result === null) {
return;
}
$this->deleteCalendarBlockersOrganizer($user, $result['calendarUri'], $result['eventUri'], $proposal);
return;
}
// if the reason is modification, try to locate existing calendar with blocker
if ($reason === 'M') {
$result = $this->findCalendarBlocker($user, $proposal);
if ($result !== null) {
$userCalendarUri = $result['calendarUri'];
$userEventUri = $result['eventUri'];
}
}
// if reason is creation, or no existing calendar blocker found, locate primary calendar
if (!isset($userCalendarUri) || $userCalendarUri === null) {
$result = $this->findPrimaryCalendar($user);
if ($result !== null) {
$userCalendarUri = $result->getUri();
}
}
if ($userCalendarUri === null) {
throw new \RuntimeException('Could not find a useable calendar to create a meeting from the selected proposal');
}
$vObject = $this->constructCalendarBlocker($user, $proposal);
$this->applyCalendarBlockersOrganizer($user, $userCalendarUri, $userEventUri, $vObject);
$this->applyCalendarBlockersParticipant($user, $proposal, $reason, $userCalendarUri, $userEventUri, $vObject);
}
/**
* Construct calendar blocker event
*/
private function constructCalendarBlocker(IUser $user, ProposalObject $proposal): VCalendar {
// construct calendar object with events
$proposalDates = [];
$firstProposalDate = null;
foreach ($proposal->getDates()->sortByDate() as $proposalDate) {
$date = $proposalDate->getDate();
if ($firstProposalDate === null) {
$firstProposalDate = $date;
}
$proposalDates[] = $date->format('Ymd\THis\Z');
}
if ($firstProposalDate === null) {
throw new \InvalidArgumentException('Cannot construct calendar blocker without at least one proposal date');
}
$vObject = new VCalendar();
/** @var VEvent $vEvent */
$vEvent = $vObject->add('VEVENT', []);
$vEvent->UID->setValue($proposal->getUuid());
$vEvent->add('STATUS', 'TENTATIVE');
$vEvent->add('SEQUENCE', 1);
$vEvent->add('DTSTART', $firstProposalDate);
$vEvent->add('DURATION', "PT{$proposal->getDuration()}M");
$vEvent->add('RDATE', $proposalDates);
$vEvent->add('SUMMARY', $this->l10n->t('[Proposed] %1$s', [$proposal->getTitle()]));
if (!empty($proposal->getDescription())) {
$vEvent->add('DESCRIPTION', $proposal->getDescription());
}
if (!empty($proposal->getLocation())) {
$vEvent->add('LOCATION', $proposal->getLocation());
}
$vEvent->add('ORGANIZER', 'mailto:' . $user->getEMailAddress(), ['CN' => $user->getDisplayName()]);
// add the participant to the event
foreach ($proposal->getParticipants() as $participant) {
$vEvent->add('ATTENDEE', 'mailto:' . $participant->getAddress(), [
'CN' => $participant->getName(),
'CUTYPE' => 'INDIVIDUAL',
'PARTSTAT' => 'NEEDS-ACTION',
'ROLE' => 'REQ-PARTICIPANT'
]);
}
return $vObject;
}
/**
* Create or update calendar blocker event(s) for organizer
*/
private function applyCalendarBlockersOrganizer(IUser $user, string $calendarUri, ?string $eventUri, VCalendar $vObject): void {
/** @var \OCA\DAV\CalDAV\CalendarHome $calendarHome */
$calendarHome = (new InvitationResponseServer(false))->getServer()->tree->getNodeForPath('/calendars/' . $user->getUID());
/** @var \OCA\DAV\CalDAV\Calendar $calendar */
$calendar = $calendarHome->getChild($calendarUri);
if ($eventUri === null) {
$calendar->createFile(
Uuid::v4()->toRfc4122() . '.ics',
$vObject->serialize()
);
} else {
$event = $calendar->getChild($eventUri);
$event->put($vObject->serialize());
}
}
/**
* Delete existing calendar blocker event
*/
private function deleteCalendarBlockersOrganizer(IUser $user, string $calendarUri, string $eventUri, ProposalObject $proposal): void {
/** @var \OCA\DAV\CalDAV\CalendarHome $calendarHome */
$calendarHome = (new InvitationResponseServer(false))->getServer()->tree->getNodeForPath('/calendars/' . $user->getUID());
/** @var \OCA\DAV\CalDAV\Calendar $calendar */
$calendar = $calendarHome->getChild($calendarUri);
$event = $calendar->getChild($eventUri);
$event->delete($event->getName());
}
/**
* Create or update calendar blocker event(s) for participant(s)
*/
private function applyCalendarBlockersParticipant(IUser $user, ProposalObject $proposal, string $reason, string $calendarUri, ?string $eventUri, VCalendar $vObject): void {
// if the calendar manager does not have a handleIMip method, we cannot generate iTip messages
if (!method_exists($this->calendarManager, 'handleIMip')) {
return;
}
// TODO: change REQUEST to PUBLISH
$vObject->add('METHOD', $reason !== 'D' ? 'REQUEST' : 'CANCEL');
foreach ($proposal->getParticipants()->filterByRealm(ProposalParticipantRealm::Internal) as $participant) {
$participantAddress = $participant->getAddress();
if ($participantAddress === null) {
continue;
}
$participantUsers = $this->userManager->getByEmail($participantAddress);
if ($participantUsers === []) {
continue;
}
$participantUser = $participantUsers[0];
try {
$this->calendarManager->handleIMip(
$participantUser->getUID(),
$vObject->serialize(),
$reason !== 'D' ? ['absent' => 'create'] : []
);
} catch (Exception $e) {
$this->logger->error($e->getMessage(), ['app' => 'calendar', 'exception' => $e]);
}
}
}
/**
* Find the primary calendar for a user, or the first useable calendar
*/
private function findPrimaryCalendar(IUser $user): ?ICreateFromString {
// retrieve the primary calendar for the user
// this condition is just to make psalm happy
if (method_exists($this->calendarManager, 'getPrimaryCalendar')) {
/** @var ICalendar&ICreateFromString|null $userCalendar */
$userCalendar = $this->calendarManager->getPrimaryCalendar($user->getUID());
}
if ($userCalendar !== null && (!$userCalendar instanceof ICreateFromString || $userCalendar->isDeleted())) {
$userCalendar = null;
}
// if no primary calendar is set, use the first useable calendar
if ($userCalendar === null) {
$userCalendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $user->getUID());
foreach ($userCalendars as $calendar) {
if ($calendar instanceof ICreateFromString && $calendar instanceof ICalendarIsWritable && $calendar->isWritable() && !$calendar->isDeleted()) {
$userCalendar = $calendar;
break;
}
}
}
return $userCalendar;
}
/**
* Find existing calendar blocker event
*
* @return array{calendarUri: string, eventUri: string}|null
*/
private function findCalendarBlocker(IUser $user, ProposalObject $proposal): ?array {
$userCalendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $user->getUID());
foreach ($userCalendars as $calendar) {
$result = $calendar->search('', [], ['uid' => (string)$proposal->getUuid()]);
if (isset($result[0])) {
return ['calendarUri' => $calendar->getUri(), 'eventUri' => $result[0]['uri']];
}
}
return null;
}
}