1: <?php
2: /**
3: * This code is licensed under AGPLv3 license or Afterlogic Software License
4: * if commercial version of the product was purchased.
5: * For full statements of the licenses see LICENSE-AFTERLOGIC and LICENSE-AGPL3 files.
6: */
7:
8: namespace Aurora\Modules\CalendarMeetingsPlugin;
9:
10: use Aurora\Modules\Core\Models\User;
11: use Aurora\Modules\Mail\Models\MailAccount;
12: use Aurora\System\Api;
13: use Aurora\Modules\Core\Module as CoreModule;
14: use Aurora\System\Enums\LogLevel;
15:
16: /**
17: * @license https://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
18: * @license https://afterlogic.com/products/common-licensing Afterlogic Software License
19: * @copyright Copyright (c) 2023, Afterlogic Corp.
20: */
21: class Manager extends \Aurora\Modules\Calendar\Manager
22: {
23: /**
24: * Processing response to event invitation. [Aurora only.](http://dev.afterlogic.com/aurora)
25: *
26: * @param string $sUserPublicId
27: * @param string $sCalendarId Calendar ID
28: * @param string $sEventId Event ID
29: * @param string $sAttendee Attendee identified by email address
30: * @param string $sAction Appointment actions. Accepted values:
31: * - "ACCEPTED"
32: * - "DECLINED"
33: * - "TENTATIVE"
34: *
35: * @return bool
36: */
37: public function updateAppointment($sUserPublicId, $sCalendarId, $sEventId, $sAttendee, $sAction)
38: {
39: $oResult = null;
40:
41: $aData = $this->oStorage->getEvent($sUserPublicId, $sCalendarId, $sEventId);
42: if ($aData !== false) {
43: $oVCal = $aData['vcal'];
44: $oVCal->METHOD = 'REQUEST';
45: return $this->appointmentAction($sUserPublicId, $sAttendee, $sAction, $sCalendarId, $oVCal->serialize());
46: }
47:
48: return $oResult;
49: }
50:
51: /**
52: * @param string $sPartstat
53: * @param string $sSummary
54: */
55: protected function getMessageSubjectFromPartstat($sPartstat, $sSummary)
56: {
57: $sSubject = $sSummary;
58: switch ($sPartstat) {
59: case 'ACCEPTED':
60: $sSubject = $this->GetModule()->i18N('SUBJECT_PREFFIX_ACCEPTED') . ': ' . $sSummary;
61: break;
62: case 'DECLINED':
63: $sSubject = $this->GetModule()->i18N('SUBJECT_PREFFIX_DECLINED') . ': ' . $sSummary;
64: break;
65: case 'TENTATIVE':
66: $sSubject = $this->GetModule()->i18N('SUBJECT_PREFFIX_TENTATIVE') . ': ' . $sSummary;
67: break;
68: }
69:
70: return $sSubject;
71: }
72:
73: /**
74: * @param User $oUser
75: * @param string $sAttendee
76: */
77: protected function getFromAccount($oUser, $sAttendee)
78: {
79: $oFromAccount = null;
80:
81: if ($oUser && $oUser->PublicId !== $sAttendee) {
82: $oMailModule = Api::GetModule('Mail');
83: /** @var \Aurora\Modules\Mail\Module $oMailModule */
84: if ($oMailModule) {
85: $aAccounts = $oMailModule->getAccountsManager()->getUserAccounts($oUser->Id);
86: foreach ($aAccounts as $oAccount) {
87: if ($oAccount instanceof MailAccount && $oAccount->Email === $sAttendee) {
88: $oFromAccount = $oAccount;
89: break;
90: }
91: }
92: }
93: }
94:
95: return $oFromAccount;
96: }
97:
98: /**
99: * Allows for responding to event invitation (accept / decline / tentative). [Aurora only.](http://dev.afterlogic.com/aurora)
100: *
101: * @param int|string $sUserPublicId Account object
102: * @param string $sAttendee Attendee identified by email address
103: * @param string $sAction Appointment actions. Accepted values:
104: * - "ACCEPTED"
105: * - "DECLINED"
106: * - "TENTATIVE"
107: * @param string $sCalendarId Calendar ID
108: * @param string $sData ICS data of the response
109: * @param bool $bIsLinkAction If **true**, the attendee's action on the link is assumed
110: * @param bool $bIsExternalAttendee
111: *
112: * @return bool
113: */
114: public function appointmentAction($sUserPublicId, $sAttendee, $sAction, $sCalendarId, $sData, $AllEvents = 2, $RecurrenceId = null, $bIsLinkAction = false, $bIsExternalAttendee = false)
115: {
116: $oUser = null;
117: $bResult = false;
118: $sEventId = null;
119: $sTo = $sSubject = '';
120:
121: $oUser = CoreModule::Decorator()->GetUserByPublicId($sUserPublicId);
122:
123: if (!($oUser instanceof User)) {
124: throw new Exceptions\Exception(
125: Enums\ErrorCodes::CannotSendAppointmentMessage,
126: null,
127: 'User not found'
128: );
129: }
130:
131: if ($bIsLinkAction && !$bIsExternalAttendee) {
132: // getting default calendar for attendee
133: $oCalendar = $this->getDefaultCalendar($oUser->PublicId);
134: if ($oCalendar) {
135: $sCalendarId = $oCalendar->Id;
136: }
137: }
138:
139: /** @var \Sabre\VObject\Component\VCalendar $oVCal */
140: $oVCal = \Sabre\VObject\Reader::read($sData);
141:
142: if ($oVCal) {
143: $sMethod = strtoupper((string) $oVCal->METHOD);
144: $sPartstat = strtoupper($sAction);
145: $sCN = '';
146: if ($sAttendee === $oUser->PublicId) {
147: $sCN = !empty($oUser->Name) ? $oUser->Name : $sAttendee;
148: }
149: $bNeedsToUpdateEvent = false;
150:
151: // Now we need to loop through the original organizer event, to find
152: // all the instances where we have a reply for.
153: $masterEvent = $oVCal->getBaseComponent('VEVENT');
154:
155: if (!$masterEvent) {
156: // No master event, we can't add new instances.
157: return false;
158: }
159:
160: $sEventId = (string) $masterEvent->UID;
161: if ($AllEvents === 2) {
162: if ($sPartstat === 'DECLINED' || $sMethod === 'CANCEL') {
163: if ($sCalendarId !== false) {
164: $this->deleteEvent($sAttendee, $sCalendarId, $sEventId);
165: }
166: } else {
167: $bNeedsToUpdateEvent = true;
168: }
169:
170: $attendeeFound = false;
171: if (isset($masterEvent->ATTENDEE)) {
172: foreach ($masterEvent->ATTENDEE as $attendee) {
173: $sEmail = str_replace('mailto:', '', strtolower($attendee->getValue()));
174: if (strtolower($sEmail) === strtolower($sAttendee)) {
175: $attendeeFound = true;
176: $attendee['PARTSTAT'] = $sPartstat;
177: $attendee['RESPONDED-AT'] = gmdate("Ymd\THis\Z");
178: // Un-setting the RSVP status, because we now know
179: // that the attendee already replied.
180: unset($attendee['RSVP']);
181: break;
182: }
183: }
184: }
185:
186: if (!$attendeeFound) {
187: // Adding a new attendee. The iTip documentation calls this
188: // a party crasher.
189: $attendee = $masterEvent->add('ATTENDEE', $sAttendee, [
190: 'PARTSTAT' => $sPartstat,
191: 'CN' => $sCN,
192: 'RESPONDED-AT' => gmdate("Ymd\THis\Z")
193: ]);
194: }
195: }
196:
197: $masterEvent->{'LAST-MODIFIED'} = new \DateTime('now', new \DateTimeZone('UTC'));
198:
199: if ($AllEvents === 1 && $RecurrenceId !== null) {
200: if ($sPartstat === 'DECLINED' || $sMethod === 'CANCEL') {
201: if ($sCalendarId !== false) {
202: $oEvent = new \Aurora\Modules\Calendar\Classes\Event();
203: $oEvent->IdCalendar = $sCalendarId;
204: $oEvent->Id = $sEventId;
205: $this->updateExclusion($sAttendee, $oEvent, $RecurrenceId, true);
206: }
207: } else {
208: $bNeedsToUpdateEvent = true;
209: }
210:
211: $vevent = null;
212: $index = \Aurora\Modules\Calendar\Classes\Helper::isRecurrenceExists($oVCal->VEVENT, $RecurrenceId);
213: if ($index === false) {
214: // If we got replies to instances that did not exist in the
215: // original list, it means that new exceptions must be created.
216: $recurrenceIterator = new \Sabre\VObject\Recur\EventIterator($oVCal, $masterEvent->UID);
217: $found = false;
218: $iterations = 1000;
219: do {
220: $newObject = $recurrenceIterator->getEventObject();
221: $recurrenceIterator->next();
222:
223: if (isset($newObject->{'RECURRENCE-ID'})) {
224: $iRecurrenceId = \Aurora\Modules\Calendar\Classes\Helper::getTimestamp($newObject->{'RECURRENCE-ID'}, $oUser->DefaultTimeZone);
225: if ((int) $iRecurrenceId === (int) $RecurrenceId) {
226: $found = true;
227: }
228: }
229: --$iterations;
230: } while ($recurrenceIterator->valid() && !$found && $iterations);
231:
232: if ($found) {
233: unset(
234: $newObject->RRULE,
235: $newObject->EXDATE,
236: $newObject->RDATE
237: );
238: $vevent = $oVCal->add($newObject);
239: }
240: } else {
241: $vevent = $oVCal->VEVENT[$index];
242: }
243:
244: $attendeeFound = false;
245: if (isset($vevent->ATTENDEE)) {
246: foreach ($vevent->ATTENDEE as $attendee) {
247: $sEmail = str_replace('mailto:', '', strtolower($attendee->getValue()));
248: if (strtolower($sEmail) === strtolower($sAttendee)) {
249: $attendeeFound = true;
250: $attendee['PARTSTAT'] = $sPartstat;
251: $attendee['RESPONDED-AT'] = gmdate("Ymd\THis\Z");
252: break;
253: }
254: }
255: }
256: if ($vevent && !$attendeeFound) {
257: // Adding a new attendee
258: $attendee = $vevent->add('ATTENDEE', $sAttendee, [
259: 'PARTSTAT' => $sPartstat,
260: 'CN' => $sCN,
261: 'RESPONDED-AT' => gmdate("Ymd\THis\Z")
262: ]);
263: }
264: }
265:
266: if ($sMethod === 'REQUEST') {
267: $sMethod = 'REPLY';
268: }
269:
270: $oVCalForSend = clone $oVCal;
271: $oVCalForSend->METHOD = $sMethod;
272:
273: $sTo = isset($masterEvent->ORGANIZER) ? str_replace(['mailto:', 'principals/'], '', strtolower((string) $masterEvent->ORGANIZER)) : '';
274: $sSummary = isset($masterEvent->SUMMARY) ? (string) $masterEvent->SUMMARY : '';
275: $sSubject = $this->getMessageSubjectFromPartstat($sPartstat, $sSummary);
276:
277: if ($bNeedsToUpdateEvent) { // update event on server
278: unset($oVCal->METHOD);
279: $this->oStorage->updateEventRaw(
280: $oUser->PublicId,
281: $sCalendarId,
282: $sEventId,
283: $oVCal->serialize()
284: );
285: }
286:
287: if ($oVCalForSend) { //send message to organizer
288: if (empty($sTo)) {
289: throw new Exceptions\Exception(
290: Enums\ErrorCodes::CannotSendAppointmentMessageNoOrganizer
291: );
292: }
293: $oFromAccount = $this->getFromAccount($oUser, $sAttendee);
294: $bResult = Classes\Helper::sendAppointmentMessage(
295: $oUser->PublicId,
296: $sTo,
297: $sSubject,
298: $oVCalForSend,
299: $sMethod,
300: '',
301: $oFromAccount,
302: $sAttendee
303: );
304: }
305: }
306:
307: if (!$bResult) {
308: Api::Log('Ics Appointment Action FALSE result!', LogLevel::Error);
309: if ($sUserPublicId) {
310: Api::Log('Email: ' . $oUser->PublicId . ', Action: ' . $sAction . ', Data:', LogLevel::Error);
311: }
312: Api::Log($sData, LogLevel::Error);
313: } else {
314: $bResult = $sEventId;
315: }
316:
317: return $bResult;
318: }
319: }
320: