Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.78% |
132 / 135 |
|
80.00% |
8 / 10 |
CRAP | |
0.00% |
0 / 1 |
| CalendarIcsGenerator | |
97.76% |
131 / 134 |
|
80.00% |
8 / 10 |
35 | |
0.00% |
0 / 1 |
| generateRequest | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| generateCancel | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| generateReply | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
5 | |||
| createBaseCalendar | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| appendTimezone | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
1 | |||
| createEventComponent | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
4 | |||
| appendOrganizer | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
2.01 | |||
| appendRequestAttendees | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
13 | |||
| appendDetails | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
5 | |||
| parseDate | |
33.33% |
1 / 3 |
|
0.00% |
0 / 1 |
3.19 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Modules\Calendar\Application\Service; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use DateTimeImmutable; |
| 12 | use DateTimeZone; |
| 13 | use Exception; |
| 14 | use Sabre\VObject\Component\VCalendar; |
| 15 | use Sabre\VObject\Component\VEvent; |
| 16 | |
| 17 | /** |
| 18 | * Generates RFC 5545 and RFC 6047 (iTIP) iCalendar (.ics) payloads for event invitations. |
| 19 | * |
| 20 | * Produces METHOD:REQUEST, METHOD:REPLY, and METHOD:CANCEL iCalendar documents compatible with |
| 21 | * Google Calendar, Microsoft Outlook, Apple Mail, and standard CalDAV/iTIP servers. |
| 22 | * |
| 23 | * @package App\Modules\Calendar\Application\Service |
| 24 | */ |
| 25 | final readonly class CalendarIcsGenerator |
| 26 | { |
| 27 | private const string PRODID = '-//Ammonly//Ammonly Calendar 1.0//EN'; |
| 28 | private const string DEFAULT_TIMEZONE = 'Europe/Warsaw'; |
| 29 | private const string METHOD_REQUEST = 'REQUEST'; |
| 30 | private const string METHOD_REPLY = 'REPLY'; |
| 31 | private const string METHOD_CANCEL = 'CANCEL'; |
| 32 | private const string STATUS_CONFIRMED = 'CONFIRMED'; |
| 33 | private const string STATUS_CANCELLED = 'CANCELLED'; |
| 34 | private const string PARTSTAT_NEEDS_ACTION = 'NEEDS-ACTION'; |
| 35 | private const string PARTSTAT_ACCEPTED = 'ACCEPTED'; |
| 36 | private const string PARTSTAT_DECLINED = 'DECLINED'; |
| 37 | private const string PARTSTAT_TENTATIVE = 'TENTATIVE'; |
| 38 | |
| 39 | /** |
| 40 | * Generates an iTIP METHOD:REQUEST iCalendar invitation for an event. |
| 41 | * |
| 42 | * @param array<string, mixed> $record Event attributes. |
| 43 | * @param list<array{name?: string, email: string, status?: string}> $attendees List of attendees. |
| 44 | * @param array{name?: string, email: string} $organizer Organizer details. |
| 45 | * @param string|null $targetEmail Specific attendee receiving the invite. |
| 46 | * @return string Serialized VCALENDAR payload. |
| 47 | */ |
| 48 | public function generateRequest( |
| 49 | array $record, |
| 50 | array $attendees, |
| 51 | array $organizer, |
| 52 | ?string $targetEmail = null |
| 53 | ): string { |
| 54 | $vcal = $this->createBaseCalendar(self::METHOD_REQUEST); |
| 55 | $this->appendTimezone($vcal, self::DEFAULT_TIMEZONE); |
| 56 | |
| 57 | $event = $this->createEventComponent($vcal, $record, self::STATUS_CONFIRMED); |
| 58 | $this->appendOrganizer($event, $organizer); |
| 59 | $this->appendRequestAttendees($event, $attendees, $organizer, $targetEmail); |
| 60 | $this->appendDetails($event, $record); |
| 61 | |
| 62 | return $vcal->serialize(); |
| 63 | } |
| 64 | |
| 65 | /** |
| 66 | * Generates an iTIP METHOD:CANCEL iCalendar notification for a cancelled event. |
| 67 | * |
| 68 | * @param array<string, mixed> $record Event attributes. |
| 69 | * @param list<array{name?: string, email: string, status?: string}> $attendees List of attendees. |
| 70 | * @param array{name?: string, email: string} $organizer Organizer details. |
| 71 | * @return string Serialized VCALENDAR payload. |
| 72 | */ |
| 73 | public function generateCancel( |
| 74 | array $record, |
| 75 | array $attendees, |
| 76 | array $organizer |
| 77 | ): string { |
| 78 | $vcal = $this->createBaseCalendar(self::METHOD_CANCEL); |
| 79 | $this->appendTimezone($vcal, self::DEFAULT_TIMEZONE); |
| 80 | |
| 81 | $event = $this->createEventComponent($vcal, $record, self::STATUS_CANCELLED); |
| 82 | $this->appendOrganizer($event, $organizer); |
| 83 | $this->appendRequestAttendees($event, $attendees, $organizer, null); |
| 84 | $this->appendDetails($event, $record); |
| 85 | |
| 86 | return $vcal->serialize(); |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * Generates an iTIP METHOD:REPLY iCalendar response sent to the organizer. |
| 91 | * |
| 92 | * @param array<string, mixed> $record Event attributes. |
| 93 | * @param array{name?: string, email: string} $organizer Organizer details. |
| 94 | * @param string $attendeeEmail Responding attendee email. |
| 95 | * @param string $attendeeName Responding attendee display name. |
| 96 | * @param string $partStat ACCEPTED, DECLINED, or TENTATIVE. |
| 97 | * @return string Serialized VCALENDAR payload. |
| 98 | */ |
| 99 | public function generateReply( |
| 100 | array $record, |
| 101 | array $organizer, |
| 102 | string $attendeeEmail, |
| 103 | string $attendeeName, |
| 104 | string $partStat |
| 105 | ): string { |
| 106 | $vcal = $this->createBaseCalendar(self::METHOD_REPLY); |
| 107 | $this->appendTimezone($vcal, self::DEFAULT_TIMEZONE); |
| 108 | |
| 109 | $normalizedPartStat = match (strtoupper($partStat)) { |
| 110 | 'DECLINED', 'NO', 'DECLINE' => self::PARTSTAT_DECLINED, |
| 111 | 'TENTATIVE', 'MAYBE' => self::PARTSTAT_TENTATIVE, |
| 112 | default => self::PARTSTAT_ACCEPTED, |
| 113 | }; |
| 114 | |
| 115 | $event = $this->createEventComponent($vcal, $record, self::STATUS_CONFIRMED); |
| 116 | $this->appendOrganizer($event, $organizer); |
| 117 | |
| 118 | $displayName = $attendeeName !== '' ? $attendeeName : $attendeeEmail; |
| 119 | $event->add('ATTENDEE', 'mailto:' . $attendeeEmail, [ |
| 120 | 'CUTYPE' => 'INDIVIDUAL', |
| 121 | 'ROLE' => 'REQ-PARTICIPANT', |
| 122 | 'PARTSTAT' => $normalizedPartStat, |
| 123 | 'CN' => $displayName, |
| 124 | ]); |
| 125 | |
| 126 | return $vcal->serialize(); |
| 127 | } |
| 128 | |
| 129 | /** |
| 130 | * Initializes base VCalendar component with required header properties. |
| 131 | */ |
| 132 | private function createBaseCalendar(string $method): VCalendar |
| 133 | { |
| 134 | return new VCalendar([ |
| 135 | 'PRODID' => self::PRODID, |
| 136 | 'VERSION' => '2.0', |
| 137 | 'CALSCALE' => 'GREGORIAN', |
| 138 | 'METHOD' => $method, |
| 139 | ]); |
| 140 | } |
| 141 | |
| 142 | /** |
| 143 | * Appends standard European timezone definition to the calendar. |
| 144 | */ |
| 145 | private function appendTimezone(VCalendar $vcal, string $tzid): void |
| 146 | { |
| 147 | $vtz = $vcal->add('VTIMEZONE', [ |
| 148 | 'TZID' => $tzid, |
| 149 | 'X-LIC-LOCATION' => $tzid, |
| 150 | ]); |
| 151 | |
| 152 | $vtz->add('DAYLIGHT', [ |
| 153 | 'TZOFFSETFROM' => '+0100', |
| 154 | 'TZOFFSETTO' => '+0200', |
| 155 | 'TZNAME' => 'CEST', |
| 156 | 'DTSTART' => '19700329T020000', |
| 157 | 'RRULE' => 'FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU', |
| 158 | ]); |
| 159 | |
| 160 | $vtz->add('STANDARD', [ |
| 161 | 'TZOFFSETFROM' => '+0200', |
| 162 | 'TZOFFSETTO' => '+0100', |
| 163 | 'TZNAME' => 'CET', |
| 164 | 'DTSTART' => '19701025T030000', |
| 165 | 'RRULE' => 'FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', |
| 166 | ]); |
| 167 | } |
| 168 | |
| 169 | /** |
| 170 | * Creates and populates core VEVENT component. |
| 171 | * |
| 172 | * @param VCalendar $vcal Parent calendar. |
| 173 | * @param array<string, mixed> $record Event data. |
| 174 | * @param string $status Event status (CONFIRMED or CANCELLED). |
| 175 | * @return VEvent Populated VEVENT component. |
| 176 | */ |
| 177 | private function createEventComponent(VCalendar $vcal, array $record, string $status): VEvent |
| 178 | { |
| 179 | $uid = (string) ($record['c_uid'] ?? ''); |
| 180 | if ($uid === '') { |
| 181 | $recordId = (int) ($record['id'] ?? 0); |
| 182 | $uid = sprintf('cal-event-%d-%s@ammonly.com', $recordId, bin2hex(random_bytes(8))); |
| 183 | } |
| 184 | |
| 185 | $summary = (string) ($record['subject'] ?? 'Calendar Event'); |
| 186 | $sequence = (int) ($record['sequence'] ?? 0); |
| 187 | $isAllDay = ((int) ($record['is_all_day'] ?? 0)) === 1; |
| 188 | |
| 189 | $tz = new DateTimeZone(self::DEFAULT_TIMEZONE); |
| 190 | $startStr = (string) ($record['start_date'] ?? 'now'); |
| 191 | $endStr = (string) ($record['end_date'] ?? '+1 hour'); |
| 192 | |
| 193 | $dtStart = $this->parseDate($startStr, $tz); |
| 194 | $dtEnd = $this->parseDate($endStr, $tz); |
| 195 | |
| 196 | /** @var VEvent $event */ |
| 197 | $event = $vcal->add('VEVENT', [ |
| 198 | 'UID' => $uid, |
| 199 | 'SEQUENCE' => $sequence, |
| 200 | 'SUMMARY' => $summary, |
| 201 | 'STATUS' => $status, |
| 202 | 'TRANSP' => ((int) ($record['c_isopaque'] ?? 1)) === 1 ? 'OPAQUE' : 'TRANSPARENT', |
| 203 | 'DTSTAMP' => new DateTimeImmutable('now', new DateTimeZone('UTC')), |
| 204 | ]); |
| 205 | |
| 206 | if ($isAllDay) { |
| 207 | $event->add('DTSTART', $dtStart->format('Ymd'), ['VALUE' => 'DATE']); |
| 208 | $event->add('DTEND', $dtEnd->format('Ymd'), ['VALUE' => 'DATE']); |
| 209 | } else { |
| 210 | $event->add('DTSTART', $dtStart); |
| 211 | $event->add('DTEND', $dtEnd); |
| 212 | } |
| 213 | |
| 214 | return $event; |
| 215 | } |
| 216 | |
| 217 | /** |
| 218 | * Appends ORGANIZER property to VEVENT. |
| 219 | * |
| 220 | * @param VEvent $event Target event. |
| 221 | * @param array{name?: string, email: string} $organizer Organizer attributes. |
| 222 | */ |
| 223 | private function appendOrganizer(VEvent $event, array $organizer): void |
| 224 | { |
| 225 | $orgEmail = trim($organizer['email'] ?? ''); |
| 226 | if ($orgEmail === '') { |
| 227 | return; |
| 228 | } |
| 229 | |
| 230 | $orgName = trim($organizer['name'] ?? $orgEmail); |
| 231 | $event->add('ORGANIZER', 'mailto:' . $orgEmail, [ |
| 232 | 'CN' => $orgName, |
| 233 | ]); |
| 234 | } |
| 235 | |
| 236 | /** |
| 237 | * Appends attendee components with RSVP and role flags. |
| 238 | * |
| 239 | * @param VEvent $event Target event. |
| 240 | * @param list<array{name?: string, email: string, status?: string}> $attendees Attendees. |
| 241 | * @param array{name?: string, email: string} $organizer Organizer. |
| 242 | * @param string|null $targetEmail Target recipient email. |
| 243 | */ |
| 244 | private function appendRequestAttendees( |
| 245 | VEvent $event, |
| 246 | array $attendees, |
| 247 | array $organizer, |
| 248 | ?string $targetEmail |
| 249 | ): void { |
| 250 | $orgEmail = strtolower(trim($organizer['email'] ?? '')); |
| 251 | |
| 252 | foreach ($attendees as $att) { |
| 253 | $email = trim($att['email'] ?? ''); |
| 254 | if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { |
| 255 | continue; |
| 256 | } |
| 257 | |
| 258 | $name = trim($att['name'] ?? $email); |
| 259 | $rawStatus = strtoupper(trim((string) ($att['status'] ?? self::PARTSTAT_NEEDS_ACTION))); |
| 260 | $isTarget = $targetEmail !== null && strtolower($email) === strtolower($targetEmail); |
| 261 | |
| 262 | $partStat = match ($rawStatus) { |
| 263 | 'ACCEPTED' => self::PARTSTAT_ACCEPTED, |
| 264 | 'DECLINED' => self::PARTSTAT_DECLINED, |
| 265 | 'TENTATIVE' => self::PARTSTAT_TENTATIVE, |
| 266 | default => self::PARTSTAT_NEEDS_ACTION, |
| 267 | }; |
| 268 | |
| 269 | $params = [ |
| 270 | 'CUTYPE' => 'INDIVIDUAL', |
| 271 | 'ROLE' => 'REQ-PARTICIPANT', |
| 272 | 'PARTSTAT' => $partStat, |
| 273 | 'RSVP' => ($isTarget || $partStat === self::PARTSTAT_NEEDS_ACTION) ? 'TRUE' : 'FALSE', |
| 274 | 'CN' => $name, |
| 275 | ]; |
| 276 | |
| 277 | if ($orgEmail !== '' && strtolower($email) === $orgEmail) { |
| 278 | $params['ROLE'] = 'CHAIR'; |
| 279 | $params['PARTSTAT'] = self::PARTSTAT_ACCEPTED; |
| 280 | $params['RSVP'] = 'FALSE'; |
| 281 | } |
| 282 | |
| 283 | $event->add('ATTENDEE', 'mailto:' . $email, $params); |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | /** |
| 288 | * Appends additional metadata (description, location, meeting URL, priority, alarms). |
| 289 | * |
| 290 | * @param VEvent $event Target event. |
| 291 | * @param array<string, mixed> $record Calendar record. |
| 292 | */ |
| 293 | private function appendDetails(VEvent $event, array $record): void |
| 294 | { |
| 295 | $description = (string) ($record['description'] ?? ''); |
| 296 | if ($description !== '') { |
| 297 | $event->add('DESCRIPTION', $description); |
| 298 | } |
| 299 | |
| 300 | $location = (string) ($record['location'] ?? ''); |
| 301 | if ($location !== '') { |
| 302 | $event->add('LOCATION', $location); |
| 303 | } |
| 304 | |
| 305 | $meetingUrl = (string) ($record['meeting_url'] ?? ''); |
| 306 | if ($meetingUrl !== '') { |
| 307 | $event->add('URL', $meetingUrl); |
| 308 | $event->add('X-GOOGLE-CONFERENCE', $meetingUrl); |
| 309 | $event->add('X-CONFERENCE-URL', $meetingUrl); |
| 310 | } |
| 311 | |
| 312 | $reminderMinutes = (int) ($record['reminder_minutes'] ?? 0); |
| 313 | if ($reminderMinutes > 0) { |
| 314 | $event->add('VALARM', [ |
| 315 | 'ACTION' => 'DISPLAY', |
| 316 | 'TRIGGER' => sprintf('-PT%dM', $reminderMinutes), |
| 317 | 'DESCRIPTION' => (string) ($record['subject'] ?? 'Event Reminder'), |
| 318 | ]); |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | /** |
| 323 | * Safely parses date string into DateTimeImmutable with given timezone. |
| 324 | */ |
| 325 | private function parseDate(string $dateStr, DateTimeZone $tz): DateTimeImmutable |
| 326 | { |
| 327 | try { |
| 328 | return new DateTimeImmutable($dateStr, $tz); |
| 329 | } catch (Exception) { |
| 330 | return new DateTimeImmutable('now', $tz); |
| 331 | } |
| 332 | } |
| 333 | } |