Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.94% covered (success)
97.94%
95 / 97
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
CalendarRsvpWebController
97.92% covered (success)
97.92%
94 / 96
66.67% covered (warning)
66.67%
4 / 6
17
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
 handle
98.00% covered (success)
98.00%
49 / 50
0.00% covered (danger)
0.00%
0 / 1
6
 downloadIcs
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 normalizeResponse
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 buildChangeUrls
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 renderErrorResponse
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
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\Presentation\Web;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Calendar\Application\Service\CalendarIcsGenerator;
12use App\Modules\Calendar\Application\Service\CalendarInvitationServiceInterface;
13use App\Modules\Calendar\Application\Service\CalendarRsvpTokenService;
14use Psr\Http\Message\ResponseFactoryInterface;
15use Psr\Http\Message\ResponseInterface;
16use Psr\Http\Message\ServerRequestInterface;
17use Twig\Environment as TwigEnvironment;
18
19/**
20 * Public Web Controller for Calendar Event RSVP Actions and .ics Calendar File Downloads.
21 *
22 * Handles invitee decisions (Accepted, Declined, Tentative) from email invitation links,
23 * updates attendance status in CRM, dispatches iTIP METHOD:REPLY to organizer, and renders confirmation.
24 *
25 * @package App\Modules\Calendar\Presentation\Web
26 */
27final readonly class CalendarRsvpWebController
28{
29    private const string STATUS_ACCEPTED = 'accepted';
30    private const string STATUS_DECLINED = 'declined';
31    private const string STATUS_TENTATIVE = 'tentative';
32    private const string DEFAULT_LANG = 'pl';
33
34    public function __construct(
35        private ResponseFactoryInterface $responseFactory,
36        private CalendarRsvpTokenService $tokenService,
37        private CalendarInvitationServiceInterface $invitationService,
38        private CalendarIcsGenerator $icsGenerator,
39        private TwigEnvironment $twig,
40        private string $baseUrl = 'https://app-client.ammonly.com'
41    ) {
42    }
43
44    /**
45     * Handles RSVP click request: GET /calendar/rsvp?token=...&response=...&lang=...
46     *
47     * @param ServerRequestInterface $request Incoming HTTP request.
48     * @return ResponseInterface Rendered confirmation page or error response.
49     */
50    public function handle(ServerRequestInterface $request): ResponseInterface
51    {
52        $queryParams = $request->getQueryParams();
53        $token = trim((string) ($queryParams['token'] ?? ''));
54        $rawResponse = trim((string) ($queryParams['response'] ?? ''));
55        $lang = strtolower(trim((string) ($queryParams['lang'] ?? self::DEFAULT_LANG)));
56        if (!in_array($lang, ['pl', 'en'], true)) {
57            $lang = self::DEFAULT_LANG;
58        }
59
60        $verified = $this->tokenService->verifyToken($token);
61        if ($verified === null) {
62            return $this->renderErrorResponse(
63                400,
64                $lang === 'pl'
65                    ? 'Nieprawidłowy lub wygasły link do zaproszenia.'
66                    : 'Invalid or expired invitation link.',
67                $lang
68            );
69        }
70
71        $calendarId = $verified['calendar_id'];
72        $attendeeEmail = $verified['attendee_email'];
73
74        $event = $this->invitationService->findCalendarRecord($calendarId);
75        if ($event === null) {
76            return $this->renderErrorResponse(
77                404,
78                $lang === 'pl' ? 'Wydarzenie nie zostało znalezione.' : 'Event not found.',
79                $lang
80            );
81        }
82
83        $normalizedResponse = $this->normalizeResponse($rawResponse);
84        $this->invitationService->updateAttendeeStatus($calendarId, $attendeeEmail, $normalizedResponse);
85        $this->invitationService->sendOrganizerRsvpNotification($event, $attendeeEmail, $normalizedResponse);
86
87        $organizer = $this->invitationService->resolveOrganizer($event);
88        $startDate = (string) ($event['start_date'] ?? 'now');
89        $endDate = (string) ($event['end_date'] ?? '+1 hour');
90        $isAllDay = ((int) ($event['is_all_day'] ?? 0)) === 1;
91        $formattedDate = $this->invitationService->formatEventDate($startDate, $endDate, $isAllDay, $lang);
92
93        $changeUrls = $this->buildChangeUrls($token, $lang);
94        $downloadIcsUrl = "{$this->baseUrl}/calendar/rsvp/download?token=" . urlencode($token);
95
96        $html = $this->twig->render('web/calendar/rsvp_confirmation.twig', [
97            'event'            => $event,
98            'organizer'        => $organizer,
99            'attendee_email'   => $attendeeEmail,
100            'response'         => $normalizedResponse,
101            'lang'             => $lang,
102            'formatted_date'   => $formattedDate,
103            'token'            => $token,
104            'change_urls'      => $changeUrls,
105            'download_ics_url' => $downloadIcsUrl,
106        ]);
107
108        $response = $this->responseFactory->createResponse(200)
109            ->withHeader('Content-Type', 'text/html; charset=UTF-8')
110            ->withHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
111        $response->getBody()->write($html);
112
113        return $response;
114    }
115
116    /**
117     * Handles .ics file download request: GET /calendar/rsvp/download?token=...
118     *
119     * @param ServerRequestInterface $request Incoming HTTP request.
120     * @return ResponseInterface File download response.
121     */
122    public function downloadIcs(ServerRequestInterface $request): ResponseInterface
123    {
124        $queryParams = $request->getQueryParams();
125        $token = trim((string) ($queryParams['token'] ?? ''));
126
127        $verified = $this->tokenService->verifyToken($token);
128        if ($verified === null) {
129            return $this->renderErrorResponse(400, 'Invalid or expired token.', 'en');
130        }
131
132        $event = $this->invitationService->findCalendarRecord($verified['calendar_id']);
133        if ($event === null) {
134            return $this->renderErrorResponse(404, 'Event not found.', 'en');
135        }
136
137        $attendees = $this->invitationService->extractAttendees($event['attendees'] ?? null);
138        $organizer = $this->invitationService->resolveOrganizer($event);
139        $icsContent = $this->icsGenerator->generateRequest($event, $attendees, $organizer, $verified['attendee_email']);
140
141        $response = $this->responseFactory->createResponse(200)
142            ->withHeader('Content-Type', 'text/calendar; charset=UTF-8; method=REQUEST')
143            ->withHeader('Content-Disposition', 'attachment; filename="event.ics"')
144            ->withHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
145        $response->getBody()->write($icsContent);
146
147        return $response;
148    }
149
150    /**
151     * Normalizes user query parameter to standard status.
152     */
153    private function normalizeResponse(string $raw): string
154    {
155        return match (strtolower($raw)) {
156            'accepted', 'yes', 'tak', '1' => self::STATUS_ACCEPTED,
157            'declined', 'no', 'nie', '2'   => self::STATUS_DECLINED,
158            default                       => self::STATUS_TENTATIVE,
159        };
160    }
161
162    /**
163     * Builds change response URLs for the landing page.
164     *
165     * @return array{yes: string, maybe: string, no: string}
166     */
167    private function buildChangeUrls(string $token, string $lang): array
168    {
169        $base = "{$this->baseUrl}/calendar/rsvp?token=" . urlencode($token) . "&lang={$lang}";
170        return [
171            'yes'   => "{$base}&response=" . self::STATUS_ACCEPTED,
172            'maybe' => "{$base}&response=" . self::STATUS_TENTATIVE,
173            'no'    => "{$base}&response=" . self::STATUS_DECLINED,
174        ];
175    }
176
177    /**
178     * Generates a simple clean error response.
179     */
180    private function renderErrorResponse(int $statusCode, string $message, string $lang): ResponseInterface
181    {
182        $title = $lang === 'pl' ? 'Błąd zaproszenia' : 'Invitation Error';
183        $html = sprintf(
184            '<!DOCTYPE html><html lang="%s"><head><meta charset="UTF-8">'
185            . '<meta name="viewport" content="width=device-width, initial-scale=1.0">'
186            . '<title>%s</title><link rel="stylesheet" href="/assets/vendor/tabler/tabler.min.css"></head>'
187            . '<body class="bg-light min-vh-100 d-flex align-items-center justify-content-center">'
188            . '<div class="card shadow-sm p-4 text-center" style="max-width: 480px;">'
189            . '<h2 class="h4 text-danger mb-2">%s</h2><p class="text-muted mb-0">%s</p></div></body></html>',
190            htmlspecialchars($lang, ENT_QUOTES, 'UTF-8'),
191            htmlspecialchars($title, ENT_QUOTES, 'UTF-8'),
192            htmlspecialchars($title, ENT_QUOTES, 'UTF-8'),
193            htmlspecialchars($message, ENT_QUOTES, 'UTF-8')
194        );
195
196        $response = $this->responseFactory->createResponse($statusCode)
197            ->withHeader('Content-Type', 'text/html; charset=UTF-8');
198        $response->getBody()->write($html);
199
200        return $response;
201    }
202}