Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.15% covered (success)
93.15%
272 / 292
41.18% covered (danger)
41.18%
7 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
CalendarInvitationService
93.13% covered (success)
93.13%
271 / 291
41.18% covered (danger)
41.18%
7 / 17
83.13
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sendEventInvitations
98.15% covered (success)
98.15%
53 / 54
0.00% covered (danger)
0.00%
0 / 1
10
 sendOrganizerRsvpNotification
96.30% covered (success)
96.30%
52 / 54
0.00% covered (danger)
0.00%
0 / 1
6
 updateAttendeeStatus
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 parseAttendeesPayload
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
8.06
 updateAttendeeInList
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 findCalendarRecord
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 extractAttendees
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
11
 resolveOrganizer
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 formatEventDate
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
 formatEventTimeRange
60.00% covered (warning)
60.00%
9 / 15
0.00% covered (danger)
0.00%
0 / 1
3.58
 lookupUser
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
4.05
 resolveAttendeeName
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 buildTemplateContext
89.66% covered (warning)
89.66%
26 / 29
0.00% covered (danger)
0.00%
0 / 1
3.01
 buildSubject
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
7.39
 resolveAttendeeLanguage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 writeTempIcsFile
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
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 App\Modules\Mail\Domain\Model\MailQueueItem;
12use App\Modules\Mail\Domain\Repository\MailRepositoryInterface;
13use DateTimeImmutable;
14use DateTimeZone;
15use Exception;
16use PDO;
17use Twig\Environment as TwigEnvironment;
18
19/**
20 * Orchestrates event invitation lifecycle, email dispatching, iTIP generation, and RSVP updates.
21 *
22 * Coordinates bilingual email templates, RFC 5545/6047 .ics attachments, secure HMAC tokens,
23 * and automatic iTIP METHOD:REPLY notifications to organizers.
24 *
25 * @package App\Modules\Calendar\Application\Service
26 */
27final readonly class CalendarInvitationService implements CalendarInvitationServiceInterface
28{
29    private const string DEFAULT_TIMEZONE = 'Europe/Warsaw';
30    private const string LANG_PL = 'pl';
31    private const string DEFAULT_EVENT_SUBJECT = 'Calendar Event';
32    private const string DATE_FORMAT_YMD_HIS = 'Y-m-d H:i:s';
33
34    /**
35     * CalendarInvitationService constructor.
36     *
37     * @param CalendarIcsGenerator     $icsGenerator iTIP RFC 5545 generator.
38     * @param CalendarRsvpTokenService $tokenService Cryptographic HMAC token service.
39     * @param TwigEnvironment          $twig         Twig template engine.
40     * @param MailRepositoryInterface  $mailRepo     Mail queue repository.
41     * @param PDO                      $pdo          Database handle.
42     * @param string                   $tablePrefix  Database table prefix ('c_' or 'a_').
43     * @param string                   $baseUrl      Base application URL for RSVP links.
44     */
45    public function __construct(
46        private CalendarIcsGenerator $icsGenerator,
47        private CalendarRsvpTokenService $tokenService,
48        private TwigEnvironment $twig,
49        private MailRepositoryInterface $mailRepo,
50        private PDO $pdo,
51        private string $tablePrefix = 'c_',
52        private string $baseUrl = 'https://app-client.ammonly.com'
53    ) {
54    }
55
56    /**
57     * Sends or queues invitation emails for all attendees of an event.
58     *
59     * @param array<string, mixed> $record Calendar record data.
60     * @param string               $action Action type: 'create', 'update', or 'cancel'.
61     * @return int Number of invitations enqueued.
62     */
63    public function sendEventInvitations(array $record, string $action = 'create'): int
64    {
65        $calendarId = (int) ($record['id'] ?? 0);
66        if ($calendarId <= 0) {
67            return 0;
68        }
69
70        $attendees = $this->extractAttendees($record['attendees'] ?? null);
71        if (empty($attendees)) {
72            return 0;
73        }
74
75        $organizer = $this->resolveOrganizer($record);
76        $defaultSmtp = $this->mailRepo->findDefaultSmtp();
77        $smtpId = $defaultSmtp->id;
78
79        $count = 0;
80        foreach ($attendees as $att) {
81            $email = trim($att['email'] ?? '');
82            if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
83                continue;
84            }
85
86            // Do not send an invitation to the organizer if listed as attendee
87            if (strtolower($email) === strtolower($organizer['email'])) {
88                continue;
89            }
90
91            $lang = $this->resolveAttendeeLanguage($att);
92            $token = $this->tokenService->generateToken($calendarId, $email);
93
94            $icsContent = $action === 'cancel'
95                ? $this->icsGenerator->generateCancel($record, $attendees, $organizer)
96                : $this->icsGenerator->generateRequest($record, $attendees, $organizer, $email);
97
98            $icsFilePath = $this->writeTempIcsFile($calendarId, $email, $icsContent);
99
100            $context = $this->buildTemplateContext(
101                $record,
102                $attendees,
103                $organizer,
104                $action,
105                $lang,
106                $token
107            );
108
109            $template = $lang === self::LANG_PL
110                ? 'mail/calendar/invitation_google_style_pl.twig'
111                : 'mail/calendar/invitation_google_style_en.twig';
112            $htmlBody = $this->twig->render($template, $context);
113            $textBody = $this->twig->render('mail/calendar/invitation_text.twig', $context);
114
115            $subject = $this->buildSubject(
116                $record['subject'] ?? self::DEFAULT_EVENT_SUBJECT,
117                $action,
118                $lang
119            );
120
121            $item = new MailQueueItem(
122                id: 0,
123                templateId: null,
124                smtpId: $smtpId,
125                recipientEmail: $email,
126                recipientName: $att['name'] ?? null,
127                subject: $subject,
128                bodyHtml: $htmlBody,
129                bodyText: $textBody,
130                attachments: [$icsFilePath],
131                priority: $action === 'cancel' ? 'urgent' : 'high',
132                dispatchMode: 'automatic'
133            );
134
135            $this->mailRepo->enqueue($item);
136            $count++;
137        }
138
139        return $count;
140    }
141
142    /**
143     * Sends an iTIP METHOD:REPLY notification email to the organizer when an attendee RSVPs.
144     *
145     * @param array<string, mixed> $record        Calendar event record.
146     * @param string               $attendeeEmail Responding attendee email.
147     * @param string               $response      'accepted', 'declined', or 'tentative'.
148     * @return bool True if enqueued.
149     */
150    public function sendOrganizerRsvpNotification(
151        array $record,
152        string $attendeeEmail,
153        string $response
154    ): bool {
155        $organizer = $this->resolveOrganizer($record);
156        if ($organizer['email'] === '' || !filter_var($organizer['email'], FILTER_VALIDATE_EMAIL)) {
157            return false;
158        }
159
160        $attendeeName = $this->resolveAttendeeName($record['attendees'] ?? null, $attendeeEmail);
161        $icsReply = $this->icsGenerator->generateReply(
162            $record,
163            $organizer,
164            $attendeeEmail,
165            $attendeeName,
166            $response
167        );
168
169        $calendarId = (int) ($record['id'] ?? 0);
170        $icsFilePath = $this->writeTempIcsFile($calendarId, 'reply_' . $attendeeEmail, $icsReply);
171
172        $defaultSmtp = $this->mailRepo->findDefaultSmtp();
173        $smtpId = $defaultSmtp->id;
174
175        $statusText = match ($response) {
176            'accepted' => 'Zaakceptowano / Accepted',
177            'declined' => 'Odrzucono / Declined',
178            default    => 'Być może / Tentative',
179        };
180
181        $eventSubject = (string) ($record['subject'] ?? self::DEFAULT_EVENT_SUBJECT);
182        $subject = sprintf('RSVP: %s - %s (%s)', $statusText, $eventSubject, $attendeeEmail);
183
184        $bodyHtml = sprintf(
185            '<p>Uczestnik <strong>%s</strong> (%s) odpowiedział na zaproszenie do wydarzenia: '
186            . '<strong>%s</strong>.</p><p>Status odpowiedzi: <strong>%s</strong>.</p>'
187            . '<p>Załączony plik iTIP METHOD:REPLY automatycznie aktualizuje status w Twoim kalendarzu.</p>',
188            htmlspecialchars($attendeeName, ENT_QUOTES, 'UTF-8'),
189            htmlspecialchars($attendeeEmail, ENT_QUOTES, 'UTF-8'),
190            htmlspecialchars($eventSubject, ENT_QUOTES, 'UTF-8'),
191            htmlspecialchars($statusText, ENT_QUOTES, 'UTF-8')
192        );
193
194        $bodyText = sprintf(
195            "RSVP Odpowiedź: %s\nWydarzenie: %s\nUczestnik: %s <%s>\nStatus: %s\n",
196            $statusText,
197            $eventSubject,
198            $attendeeName,
199            $attendeeEmail,
200            $statusText
201        );
202
203        $item = new MailQueueItem(
204            id: 0,
205            templateId: null,
206            smtpId: $smtpId,
207            recipientEmail: $organizer['email'],
208            recipientName: $organizer['name'],
209            subject: $subject,
210            bodyHtml: $bodyHtml,
211            bodyText: $bodyText,
212            attachments: [$icsFilePath],
213            priority: 'high',
214            dispatchMode: 'automatic'
215        );
216
217        $this->mailRepo->enqueue($item);
218        return true;
219    }
220
221    /**
222     * Updates an attendee's RSVP status in the database JSON column.
223     *
224     * @param int    $calendarId    Event ID.
225     * @param string $attendeeEmail Attendee email.
226     * @param string $newStatus     'accepted', 'declined', or 'tentative'.
227     * @return bool True if updated successfully.
228     */
229    public function updateAttendeeStatus(int $calendarId, string $attendeeEmail, string $newStatus): bool
230    {
231        $table = $this->tablePrefix . 'mod_calendar_records';
232        $selectSql = "SELECT `id`, `attendees` FROM `{$table}` WHERE `id` = :id LIMIT 1";
233        $stmt = $this->pdo->prepare($selectSql);
234        $stmt->execute([':id' => $calendarId]);
235        $row = $stmt->fetch(PDO::FETCH_ASSOC);
236
237        if ($row === false) {
238            return false;
239        }
240
241        [$attendeesList, $permissions] = $this->parseAttendeesPayload($row['attendees'] ?? null);
242        $attendeesList = $this->updateAttendeeInList($attendeesList, $attendeeEmail, $newStatus);
243
244        $payload = !empty($permissions)
245            ? ['attendees' => $attendeesList, 'permissions' => $permissions]
246            : ['attendees' => $attendeesList];
247
248        $jsonEncoded = json_encode($payload, JSON_UNESCAPED_UNICODE);
249        $updateSql = "UPDATE `{$table}` SET `attendees` = :attendees, `updated_at` = :updated_at WHERE `id` = :id";
250        $updateStmt = $this->pdo->prepare($updateSql);
251        return $updateStmt->execute([
252            ':attendees'  => $jsonEncoded,
253            ':updated_at' => date(self::DATE_FORMAT_YMD_HIS),
254            ':id'         => $calendarId,
255        ]);
256    }
257
258    /**
259     * @param string|null $rawAttendees
260     * @return array{0: list<array<string, mixed>>, 1: array<string, mixed>}
261     */
262    private function parseAttendeesPayload(?string $rawAttendees): array
263    {
264        $decoded = is_string($rawAttendees) && $rawAttendees !== '' ? json_decode($rawAttendees, true) : null;
265        if (!is_array($decoded)) {
266            return [[], []];
267        }
268
269        $attendeesList = isset($decoded['attendees']) && is_array($decoded['attendees'])
270            ? $decoded['attendees']
271            : $decoded;
272        $permissions = isset($decoded['permissions']) && is_array($decoded['permissions'])
273            ? $decoded['permissions']
274            : [];
275
276        return [$attendeesList, $permissions];
277    }
278
279    /**
280     * @param list<array<string, mixed>> $attendeesList
281     * @return list<array<string, mixed>>
282     */
283    private function updateAttendeeInList(array $attendeesList, string $attendeeEmail, string $newStatus): array
284    {
285        $matched = false;
286        $normalizedEmail = strtolower(trim($attendeeEmail));
287
288        foreach ($attendeesList as &$att) {
289            if (is_array($att) && strtolower(trim((string) ($att['email'] ?? ''))) === $normalizedEmail) {
290                $att['status'] = strtolower(trim($newStatus));
291                $att['responded_at'] = date(self::DATE_FORMAT_YMD_HIS);
292                $matched = true;
293            }
294        }
295        unset($att);
296
297        if (!$matched) {
298            $attendeesList[] = [
299                'name'         => $attendeeEmail,
300                'email'        => $attendeeEmail,
301                'status'       => strtolower(trim($newStatus)),
302                'responded_at' => date(self::DATE_FORMAT_YMD_HIS),
303            ];
304        }
305
306        return $attendeesList;
307    }
308
309    /**
310     * Resolves event record by ID.
311     *
312     * @param int $calendarId Event ID.
313     * @return array<string, mixed>|null Event row or null.
314     */
315    public function findCalendarRecord(int $calendarId): ?array
316    {
317        $table = $this->tablePrefix . 'mod_calendar_records';
318        $sql = "SELECT `id`, `c_uid`, `sequence`, `subject`, `start_date`, `end_date`, `is_all_day`, "
319            . "`location`, `meeting_url`, `status`, `description`, `owner`, `created_by`, `attendees` "
320            . "FROM `{$table}` WHERE `id` = :id LIMIT 1";
321
322        $stmt = $this->pdo->prepare($sql);
323        $stmt->execute([':id' => $calendarId]);
324        $row = $stmt->fetch(PDO::FETCH_ASSOC);
325
326        return $row !== false ? $row : null;
327    }
328
329    /**
330     * Normalizes attendees data into a clean array.
331     *
332     * @param mixed $raw Raw attendees data from DB.
333     * @return list<array{name: string, email: string, status: string}>
334     */
335    public function extractAttendees(mixed $raw): array
336    {
337        if (is_string($raw) && trim($raw) !== '') {
338            $raw = json_decode($raw, true);
339        }
340
341        $candidates = [];
342        if (is_array($raw)) {
343            $candidates = isset($raw['attendees']) && is_array($raw['attendees'])
344                ? $raw['attendees']
345                : $raw;
346        }
347
348        $list = [];
349        foreach ($candidates as $item) {
350            if (!is_array($item)) {
351                continue;
352            }
353            $email = trim((string) ($item['email'] ?? ''));
354            if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
355                continue;
356            }
357            $name = trim((string) ($item['name'] ?? ''));
358            $status = strtolower(trim((string) ($item['status'] ?? 'needs-action')));
359
360            $list[] = [
361                'name'   => $name !== '' ? $name : $email,
362                'email'  => $email,
363                'status' => $status,
364            ];
365        }
366
367        return $list;
368    }
369
370    /**
371     * Resolves organizer details from event record owner or fallback.
372     *
373     * @param array<string, mixed> $record Event data.
374     * @return array{name: string, email: string}
375     */
376    public function resolveOrganizer(array $record): array
377    {
378        $userId = (int) ($record['owner'] ?? ($record['created_by'] ?? 1));
379        if ($userId > 0) {
380            $user = $this->lookupUser($userId);
381            if ($user !== null && filter_var($user['email'], FILTER_VALIDATE_EMAIL)) {
382                return $user;
383            }
384        }
385
386        $defaultSmtp = $this->mailRepo->findDefaultSmtp();
387        if ($defaultSmtp !== null && filter_var($defaultSmtp->fromEmail, FILTER_VALIDATE_EMAIL)) {
388            return [
389                'name'  => $defaultSmtp->fromName !== '' ? $defaultSmtp->fromName : 'Ammonly Calendar',
390                'email' => $defaultSmtp->fromEmail,
391            ];
392        }
393
394        return [
395            'name'  => 'Ammonly Calendar',
396            'email' => '',
397        ];
398    }
399
400    /**
401     * Formats start and end dates into localized human-readable string.
402     */
403    public function formatEventDate(
404        string $startDate,
405        string $endDate,
406        bool $isAllDay,
407        string $lang = self::LANG_PL
408    ): string {
409        try {
410            $tz = new DateTimeZone(self::DEFAULT_TIMEZONE);
411            $start = new DateTimeImmutable($startDate, $tz);
412            $end = new DateTimeImmutable($endDate, $tz);
413
414            if ($isAllDay) {
415                return $lang === self::LANG_PL
416                    ? $start->format('d.m.Y') . ' (Cały dzień)'
417                    : $start->format('M d, Y') . ' (All day)';
418            }
419
420            return $this->formatEventTimeRange($start, $end, $lang);
421        } catch (Exception) {
422            return "{$startDate} - {$endDate}";
423        }
424    }
425
426    private function formatEventTimeRange(DateTimeImmutable $start, DateTimeImmutable $end, string $lang): string
427    {
428        $sameDay = $start->format('Y-m-d') === $end->format('Y-m-d');
429        if ($sameDay) {
430            $fmt = $lang === self::LANG_PL ? 'd.m.Y' : 'M d, Y';
431            return sprintf(
432                '%s, %s – %s (CEST)',
433                $start->format($fmt),
434                $start->format('H:i'),
435                $end->format('H:i')
436            );
437        }
438
439        return sprintf(
440            '%s – %s (%s)',
441            $start->format('d.m.Y H:i'),
442            $end->format('d.m.Y H:i'),
443            'CEST'
444        );
445    }
446
447    /**
448     * Looks up user information from database.
449     *
450     * @return array{name: string, email: string}|null
451     */
452    private function lookupUser(int $userId): ?array
453    {
454        $table = $this->tablePrefix . 'mod_users_records';
455        $sql = "SELECT `first_name`, `last_name`, `email` FROM `{$table}` WHERE `id` = :id LIMIT 1";
456
457        try {
458            $stmt = $this->pdo->prepare($sql);
459            $stmt->execute([':id' => $userId]);
460            $row = $stmt->fetch(PDO::FETCH_ASSOC);
461
462            if ($row === false) {
463                return null;
464            }
465
466            $fullName = trim(($row['first_name'] ?? '') . ' ' . ($row['last_name'] ?? ''));
467            return [
468                'name'  => $fullName !== '' ? $fullName : (string) ($row['email'] ?? ''),
469                'email' => (string) ($row['email'] ?? ''),
470            ];
471        } catch (Exception) {
472            return null;
473        }
474    }
475
476    /**
477     * Finds attendee display name by email.
478     */
479    private function resolveAttendeeName(mixed $rawAttendees, string $email): string
480    {
481        $attendees = $this->extractAttendees($rawAttendees);
482        $search = strtolower(trim($email));
483        foreach ($attendees as $att) {
484            if (strtolower($att['email']) === $search && $att['name'] !== '') {
485                return $att['name'];
486            }
487        }
488        return $email;
489    }
490
491    /**
492     * Builds template replacement context.
493     *
494     * @param array<string, mixed> $record
495     * @param list<array{name: string, email: string, status: string}> $attendees
496     * @param array{name: string, email: string} $organizer
497     * @return array<string, mixed>
498     */
499    private function buildTemplateContext(
500        array $record,
501        array $attendees,
502        array $organizer,
503        string $action,
504        string $lang,
505        string $token
506    ): array {
507        $startDate = (string) ($record['start_date'] ?? 'now');
508        $endDate = (string) ($record['end_date'] ?? '+1 hour');
509        $isAllDay = ((int) ($record['is_all_day'] ?? 0)) === 1;
510
511        $baseUrl = rtrim($this->baseUrl, '/');
512        $rsvpBase = "{$baseUrl}/calendar/rsvp?token={$token}&lang={$lang}";
513
514        $updateNotice = null;
515        if ($action === 'update') {
516            $updateNotice = $lang === self::LANG_PL
517                ? 'Zmieniono termin lub szczegóły'
518                : 'Schedule or details updated';
519        }
520
521        return [
522            'subject'           => (string) ($record['subject'] ?? self::DEFAULT_EVENT_SUBJECT),
523            'location'          => (string) ($record['location'] ?? ''),
524            'meeting_url'       => (string) ($record['meeting_url'] ?? ''),
525            'description'       => (string) ($record['description'] ?? ''),
526            'organizer'         => $organizer,
527            'attendees'         => $attendees,
528            'lang'              => $lang,
529            'is_cancelled'      => $action === 'cancel',
530            'is_updated'        => $action === 'update',
531            'update_notice'     => $updateNotice,
532            'start_iso'         => date('c', strtotime($startDate)),
533            'end_iso'           => date('c', strtotime($endDate)),
534            'formatted_date'    => $this->formatEventDate($startDate, $endDate, $isAllDay, $lang),
535            'old_formatted_date'=> null,
536            'rsvp_yes_url'      => "{$rsvpBase}&response=accepted",
537            'rsvp_maybe_url'    => "{$rsvpBase}&response=tentative",
538            'rsvp_no_url'       => "{$rsvpBase}&response=declined",
539        ];
540    }
541
542    /**
543     * Constructs email subject line.
544     */
545    private function buildSubject(string $subject, string $action, string $lang): string
546    {
547        return match ($action) {
548            'cancel' => $lang === self::LANG_PL ? "[Odwołano] {$subject}" : "[Cancelled] {$subject}",
549            'update' => $lang === self::LANG_PL ? "[Zaktualizowano] {$subject}" : "[Updated] {$subject}",
550            default  => $lang === self::LANG_PL ? "Zaproszenie: {$subject}" : "Invitation: {$subject}",
551        };
552    }
553
554    /**
555     * Resolves attendee language preference (defaults to 'pl').
556     *
557     * @param array{name?: string, email: string, status?: string} $attendee
558     */
559    private function resolveAttendeeLanguage(array $attendee): string
560    {
561        $email = strtolower($attendee['email']);
562        if (str_ends_with($email, '.pl')) {
563            return self::LANG_PL;
564        }
565        return self::LANG_PL;
566    }
567
568    /**
569     * Writes generated iCalendar payload to a temporary file on disk for email attachment.
570     */
571    private function writeTempIcsFile(int $calendarId, string $email, string $content): string
572    {
573        $safePrefix = preg_replace('/[^a-zA-Z0-9_-]/', '_', $email);
574        $path = sprintf(
575            '%s/invite_%d_%s_%s.ics',
576            sys_get_temp_dir(),
577            $calendarId,
578            $safePrefix,
579            bin2hex(random_bytes(4))
580        );
581
582        file_put_contents($path, $content);
583        return $path;
584    }
585}