Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
72 / 72
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
FeedController
100.00% covered (success)
100.00%
72 / 72
100.00% covered (success)
100.00%
8 / 8
11
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 atom
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 rss
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 ical
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 shifts
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
2
 withEtag
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getNews
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getShifts
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Engelsystem\Controllers;
6
7use Carbon\CarbonTimeZone;
8use Engelsystem\Helpers\Authenticator;
9use Engelsystem\Helpers\Carbon;
10use Engelsystem\Http\Request;
11use Engelsystem\Http\Response;
12use Engelsystem\Http\UrlGenerator;
13use Engelsystem\Models\News;
14use Engelsystem\Models\Shifts\ShiftEntry;
15use Illuminate\Support\Collection;
16
17class FeedController extends BaseController
18{
19    /** @var array<string, string> */
20    protected array $permissions = [
21        'atom'   => 'atom',
22        'rss'    => 'atom',
23        'ical'   => 'ical',
24        'shifts' => 'shifts_json_export',
25    ];
26
27    public function __construct(
28        protected Authenticator $auth,
29        protected Request $request,
30        protected Response $response,
31        protected UrlGenerator $url,
32    ) {
33    }
34
35    public function atom(): Response
36    {
37        $news = $this->getNews();
38
39        return $this->withEtag($news)
40            ->withHeader('content-type', 'application/atom+xml; charset=utf-8')
41            ->withView('api/atom', ['news' => $news]);
42    }
43
44    public function rss(): Response
45    {
46        $news = $this->getNews();
47
48        return $this->withEtag($news)
49            ->withHeader('content-type', 'application/rss+xml; charset=utf-8')
50            ->withView('api/rss', ['news' => $news]);
51    }
52
53    public function ical(): Response
54    {
55        $user = $this->auth->userFromApi();
56        $shifts = $this->getShifts();
57
58        /* @var string $timezoneTransitionStart */
59        if ($shifts->isNotEmpty()) {
60            $timezoneTransitionStart = $shifts[0]->shift->start->utc()->isoFormat('YYYYMMDDTHHmmss');
61        } else {
62            $timezoneTransitionStart = Carbon::now()->startOfDay()->utc()->isoFormat('YYYYMMDDTHHmmss');
63        }
64
65        // Build filename: AppName-username-shifts.ics (sanitized for safe filenames)
66        $appName = preg_replace('/[^a-zA-Z0-9-_]/', '', config('app_name'));
67        $username = preg_replace('/[^a-zA-Z0-9-_]/', '', $user->name);
68        $filename = sprintf('%s-%s-shifts.ics', $appName, $username);
69
70        $response = $this->withEtag($shifts)
71            ->withHeader('content-type', 'text/calendar; charset=utf-8')
72            ->withHeader('content-disposition', 'inline; filename=' . $filename);
73
74        return $response->withView(
75            'api/ical',
76            ['shiftEntries' => $shifts, 'timezoneTransitionStart' => $timezoneTransitionStart]
77        );
78    }
79
80    public function shifts(): Response
81    {
82        /** @var Collection|ShiftEntry[] $shiftEntries */
83        $shiftEntries = $this->getShifts();
84        $timeZone = CarbonTimeZone::create(config('timezone'));
85
86        $response = [];
87        foreach ($shiftEntries as $entry) {
88            $shift = $entry->shift;
89            // Data required for the Fahrplan app integration https://github.com/johnjohndoe/engelsystem
90            // See engelsystem-base/src/main/kotlin/info/metadude/kotlin/library/engelsystem/models/Shift.kt
91            // Explicitly typecasts used to stay consistent
92            // ! All attributes not defined in $data might change at any time !
93            $data = [
94                // Name of the shift (type)
95                /** @deprecated, use shifttype_name instead */
96                'name'           => (string) $shift->shiftType->name,
97                // Shift / Talk title
98                'title'          => (string) $shift->title,
99                // Shift description, should be shown after shifttype_description, markdown formatted
100                'description'    => (string) $shift->description,
101
102                'link'           => (string) $this->url->to('/shifts', ['action' => 'view', 'shift_id' => $shift->id]),
103
104                // Users comment, might be empty
105                'Comment'        => (string) $entry->user_comment,
106
107                // Shift id
108                'SID'            => (int) $shift->id,
109
110                // Shift type
111                'shifttype_id'   => (int) $shift->shiftType->id,
112                // General type of the task
113                'shifttype_name' => (string) $shift->shiftType->name,
114                // General description, markdown formatted, might be empty
115                'shifttype_description' => (string) $shift->shiftType->description,
116
117                // Talk URL, mostly empty
118                'URL'            => (string) $shift->url,
119
120                // Location (room) id
121                'RID'            => (int) $shift->location->id,
122                // Location (room) name
123                'Name'           => (string) $shift->location->name,
124                // Location map url, can be empty
125                'map_url'        => (string) $shift->location->map_url,
126
127                // Start timestamp
128                /** @deprecated start_date should be used */
129                'start'          => (int) $shift->start->timestamp,
130                // Start date
131                'start_date'     => (string) $shift->start->toRfc3339String(),
132                // End timestamp
133                /** @deprecated end_date should be used */
134                'end'            => (int) $shift->end->timestamp,
135                // End date
136                'end_date'       => (string) $shift->end->toRfc3339String(),
137
138                // Timezone offset like "+01:00"
139                /** @deprecated should be retrieved from start_date or end_date */
140                'timezone'       => (string) $timeZone->toOffsetName(),
141                // The events timezone like "Europe/Berlin"
142                'event_timezone' => (string) $timeZone->getName(),
143            ];
144
145            $response[] = [
146                // Model data
147                ...$entry->toArray(),
148
149                // Fahrplan app required data
150                ...$data,
151            ];
152        }
153
154        return $this->withEtag($response)
155            ->withAddedHeader('content-type', 'application/json; charset=utf-8')
156            ->withContent(json_encode($response));
157    }
158
159    protected function withEtag(mixed $value): Response
160    {
161        $hash = md5(json_encode($value));
162
163        return $this->response->setEtag($hash);
164    }
165
166    protected function getNews(): Collection
167    {
168        $news = $this->request->has('meetings')
169            ? News::whereIsMeeting((bool) $this->request->get('meetings', false))
170            : News::query();
171        $news
172            ->limit((int) config('display_news'))
173            ->orderByDesc('updated_at');
174
175        return $news->get();
176    }
177
178    protected function getShifts(): Collection
179    {
180        return $this->auth->userFromApi()
181            ->shiftEntries()
182            ->leftJoin('shifts', 'shifts.id', 'shift_entries.shift_id')
183            ->orderBy('shifts.start')
184            ->with(['shift', 'shift.location', 'shift.shiftType'])
185            ->get(['*', 'shift_entries.id']);
186    }
187}