Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.78% covered (success)
97.78%
132 / 135
80.00% covered (warning)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
CalendarIcsGenerator
97.76% covered (success)
97.76%
131 / 134
80.00% covered (warning)
80.00%
8 / 10
35
0.00% covered (danger)
0.00%
0 / 1
 generateRequest
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 generateCancel
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 generateReply
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 createBaseCalendar
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 appendTimezone
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 createEventComponent
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
4
 appendOrganizer
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 appendRequestAttendees
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
13
 appendDetails
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 parseDate
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
3.19
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Calendar\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use DateTimeImmutable;
12use DateTimeZone;
13use Exception;
14use Sabre\VObject\Component\VCalendar;
15use 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 */
25final 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}