Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
74.65% covered (warning)
74.65%
162 / 217
60.00% covered (warning)
60.00%
12 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
TermineUtils
74.65% covered (warning)
74.65%
162 / 217
60.00% covered (warning)
60.00%
12 / 20
156.41
0.00% covered (danger)
0.00%
0 / 1
 loadTypeOptions
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
6
 serialize
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 deserialize
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultFilter
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 isValidFilter
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
6
 getValidFilter
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getAllValidFiltersForSitemap
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getUiTypeFilterOptions
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 getUiDateRangeFilterOptions
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 hasArchiveAccess
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDateRangeOptions
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getSqlDateRangeFilter
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 getSqlTypeFilter
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
7.04
 getTitleFromFilter
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
9.01
 getDateFilterTitleYearSuffix
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getIsNotArchivedCriteria
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getIsNotArchivedSql
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getUrl
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 formatNotificationRecipients
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
72
 updateTerminFromSolvEvent
90.70% covered (success)
90.70%
39 / 43
0.00% covered (danger)
0.00%
0 / 1
13.14
1<?php
2
3namespace Olz\Termine\Utils;
4
5use Doctrine\Common\Collections\Criteria;
6use Doctrine\Common\Collections\Expr\Comparison;
7use Olz\Entity\SolvEvent;
8use Olz\Entity\Termine\Termin;
9use Olz\Entity\Termine\TerminLabel;
10use Olz\Entity\Termine\TerminNotification;
11use Olz\Entity\Termine\TerminNotificationTemplate;
12use Olz\Utils\DateUtils;
13use Olz\Utils\WithUtilsTrait;
14
15/**
16 * @phpstan-type Option array{ident: string, name: string, icon?: string}
17 * @phpstan-type UiOption array{selected: bool, new_filter: FullFilter, name: string, icon: ?string, ident: string}
18 * @phpstan-type FullFilter array{typ: string, datum: string}
19 * @phpstan-type PartialFilter array{typ?: string, datum?: string}
20 */
21class TermineUtils {
22    use WithUtilsTrait;
23
24    /** @var array<Option> */
25    public array $allTypeOptions = [];
26
27    public string $allDescription = "Orientierungslauf-Wettkämpfe, OL-Wochen, OL-Weekends, Trainings und Vereinsanlässe";
28
29    public function loadTypeOptions(): self {
30        $code_href = $this->envUtils()->getCodeHref();
31        $termin_label_repo = $this->entityManager()->getRepository(TerminLabel::class);
32        $termin_labels = $termin_label_repo->findBy(['on_off' => 1], ['position' => 'ASC']);
33        $this->allTypeOptions = [
34            [
35                'ident' => 'alle',
36                'name' => "Alle Termine",
37            ],
38            ...array_map(function ($label) use ($code_href) {
39                $ident = "{$label->getIdent()}";
40                $fallback_href = "{$code_href}assets/icns/termine_type_{$ident}_20.svg";
41                return [
42                    'ident' => $ident,
43                    'name' => "{$label->getName()}",
44                    'icon' => $label->getIcon() ? $label->getFileHref($label->getIcon()) : $fallback_href,
45                ];
46            }, $termin_labels),
47            [
48                'ident' => 'meldeschluss',
49                'name' => "Meldeschlüsse",
50                'icon' => "{$code_href}assets/icns/termine_type_meldeschluss_20.svg",
51            ],
52        ];
53        return $this;
54    }
55
56    /** @param FullFilter $filter */
57    public function serialize(array $filter): string {
58        $json = json_encode($filter) ?: '';
59        return str_replace(['{"', '":"', '","', '"}'], ['', '-', '---', ''], $json);
60    }
61
62    /** @return ?array<string, string> */
63    public function deserialize(string $input): ?array {
64        $json = '{"'.str_replace(['---', '-'], ['","', '":"'], $input).'"}';
65        return json_decode($json, true) ?? json_decode($input, true);
66    }
67
68    /** @return FullFilter */
69    public function getDefaultFilter(): array {
70        return [
71            'typ' => 'alle',
72            'datum' => 'bevorstehend',
73        ];
74    }
75
76    /** @param ?array<string, string> $filter */
77    public function isValidFilter(?array $filter): bool {
78        $has_correct_type = (
79            isset($filter['typ'])
80            && array_filter(
81                $this->allTypeOptions,
82                function ($type_option) use ($filter) {
83                    return $type_option['ident'] === $filter['typ'];
84                }
85            )
86        );
87        $has_correct_date_range = (
88            isset($filter['datum'])
89            && array_filter(
90                $this->getDateRangeOptions(),
91                function ($type_option) use ($filter) {
92                    return $type_option['ident'] === $filter['datum'];
93                }
94            )
95        );
96        $has_no_other_keys = !array_filter(
97            array_keys($filter ?? []),
98            fn ($key) => $key !== 'typ' && $key !== 'datum',
99        );
100        return $has_correct_type && $has_correct_date_range && $has_no_other_keys;
101    }
102
103    /**
104     * @param ?PartialFilter $filter
105     *
106     * @return FullFilter
107     */
108    public function getValidFilter(?array $filter): array {
109        $default_filter = $this->getDefaultFilter();
110        if (!$filter) {
111            return $default_filter;
112        }
113        $merged_filter = [
114            'typ' => $filter['typ'] ?? $default_filter['typ'],
115            'datum' => $filter['datum'] ?? $default_filter['datum'],
116        ];
117        return $this->isValidFilter($merged_filter) ? $merged_filter : $default_filter;
118    }
119
120    /** @return array<FullFilter> */
121    public function getAllValidFiltersForSitemap(): array {
122        $all_valid_filters = [];
123        foreach ($this->allTypeOptions as $type_option) {
124            $date_range_options = $this->getDateRangeOptions();
125            foreach ($date_range_options as $date_range_option) {
126                $all_valid_filters[] = [
127                    'typ' => $type_option['ident'],
128                    'datum' => $date_range_option['ident'],
129                ];
130            }
131        }
132        return $all_valid_filters;
133    }
134
135    /**
136     * @param FullFilter $filter
137     *
138     * @return array<UiOption>
139     */
140    public function getUiTypeFilterOptions(array $filter): array {
141        return array_map(function ($type_option) use ($filter) {
142            $new_filter = $filter;
143            $new_filter['typ'] = $type_option['ident'];
144            return [
145                'selected' => $type_option['ident'] === $filter['typ'],
146                'new_filter' => $new_filter,
147                'name' => $type_option['name'],
148                'icon' => $type_option['icon'] ?? null,
149                'ident' => $type_option['ident'],
150            ];
151        }, $this->allTypeOptions);
152    }
153
154    /**
155     * @param FullFilter $filter
156     *
157     * @return array<UiOption>
158     */
159    public function getUiDateRangeFilterOptions(array $filter): array {
160        return array_map(function ($date_range_option) use ($filter) {
161            $new_filter = $filter;
162            $new_filter['datum'] = $date_range_option['ident'];
163            return [
164                'selected' => $date_range_option['ident'] === $filter['datum'],
165                'new_filter' => $new_filter,
166                'name' => $date_range_option['name'],
167                'icon' => null,
168                'ident' => $date_range_option['ident'],
169            ];
170        }, $this->getDateRangeOptions());
171    }
172
173    public function hasArchiveAccess(): bool {
174        return $this->authUtils()->hasPermission('verified_email');
175    }
176
177    /**
178     * @return array<Option>
179     */
180    public function getDateRangeOptions(): array {
181        $include_archive = $this->hasArchiveAccess();
182        $current_year = intval($this->dateUtils()->getCurrentDateInFormat('Y'));
183        $first_year = $include_archive ? 2006 : $current_year - DateUtils::ARCHIVE_YEARS_THRESHOLD;
184        $options = [
185            ['ident' => 'bevorstehend', 'name' => "Bevorstehende"],
186        ];
187        for ($year = $current_year + 1; $year >= $first_year; $year--) {
188            $year_ident = strval($year);
189            $options[] = ['ident' => $year_ident, 'name' => $year_ident];
190        }
191        return $options;
192    }
193
194    /** @param PartialFilter $filter_arg */
195    public function getSqlDateRangeFilter(array $filter_arg, string $tbl = 't'): string {
196        if (!$this->isValidFilter($filter_arg)) {
197            return "'1'='0'";
198        }
199        $filter = $this->getValidFilter($filter_arg);
200        $today = $this->dateUtils()->getIsoToday();
201        if ($filter['datum'] === 'bevorstehend') {
202            return "({$tbl}.start_date >= '{$today}') OR ({$tbl}.end_date >= '{$today}')";
203        }
204        if (intval($filter['datum']) > 2000) {
205            $sane_year = strval(intval($filter['datum']));
206            return "YEAR({$tbl}.start_date) = '{$sane_year}'";
207        }
208        // @codeCoverageIgnoreStart
209        // Reason: Should not be reached.
210        return "'1' = '0'"; // invalid => show nothing
211        // @codeCoverageIgnoreEnd
212    }
213
214    /** @param PartialFilter $filter_arg */
215    public function getSqlTypeFilter(array $filter_arg, string $tbl = 't'): string {
216        if (!$this->isValidFilter($filter_arg)) {
217            return "'1'='0'";
218        }
219        $filter = $this->getValidFilter($filter_arg);
220        if ($filter['typ'] === 'alle') {
221            return "'1' = '1'";
222        }
223        if ($filter['typ'] === 'meldeschluss') {
224            return "{$tbl}.typ LIKE '%meldeschluss%'";
225        }
226        foreach ($this->allTypeOptions as $type_option) {
227            $ident = $type_option['ident'];
228            if ($filter['typ'] === $ident && preg_match('/^[a-zA-Z0-9_]+$/', $ident)) {
229                return "{$tbl}.typ LIKE '%{$ident}%'";
230            }
231        }
232        // @codeCoverageIgnoreStart
233        // Reason: Should not be reached.
234        return "'1' = '0'"; // invalid => show nothing
235        // @codeCoverageIgnoreEnd
236    }
237
238    /** @param FullFilter $filter */
239    public function getTitleFromFilter(array $filter): string {
240        if (!$this->isValidFilter($filter)) {
241            return "Termine";
242        }
243        $year_suffix = $this->getDateFilterTitleYearSuffix($filter);
244        $is_upcoming = $filter['datum'] == 'bevorstehend';
245        if ($filter['typ'] === 'alle') {
246            if ($is_upcoming) {
247                return "Bevorstehende Termine";
248            }
249            return "Termine{$year_suffix}";
250        }
251        if ($filter['typ'] === 'meldeschluss') {
252            if ($is_upcoming) {
253                return "Bevorstehende Meldeschlüsse";
254            }
255            return "Meldeschlüsse{$year_suffix}";
256        }
257        foreach ($this->allTypeOptions as $type_option) {
258            if ($filter['typ'] === $type_option['ident']) {
259                $name = $type_option['name'];
260                if ($is_upcoming) {
261                    return "{$name} (bevorstehend)";
262                }
263                return "{$name}{$year_suffix}";
264            }
265        }
266        // @codeCoverageIgnoreStart
267        // Reason: Should not be reached.
268        return "Termine";
269        // @codeCoverageIgnoreEnd
270    }
271
272    /** @param FullFilter $filter */
273    private function getDateFilterTitleYearSuffix(array $filter): string {
274        if ($filter['datum'] == 'bevorstehend') {
275            return "";
276        }
277        if (intval($filter['datum']) < 2000) {
278            // @codeCoverageIgnoreStart
279            // Reason: Should not be reached.
280            // TODO: Logging
281            return "";
282            // @codeCoverageIgnoreEnd
283        }
284        $year = $filter['datum'];
285        return " {$year}";
286    }
287
288    public function getIsNotArchivedCriteria(): Comparison {
289        $archive_threshold = $this->dateUtils()->getIsoArchiveThreshold();
290        return Criteria::expr()->gte('start_date', new \DateTime($archive_threshold));
291    }
292
293    public function getIsNotArchivedSql(?string $tbl = null): string {
294        $tbl_sql = $tbl === null ? '' : "{$tbl}.";
295        $archive_threshold = $this->dateUtils()->getIsoArchiveThreshold();
296        return "{$tbl_sql}start_date >= '{$archive_threshold}'";
297    }
298
299    /** @param PartialFilter $filter */
300    public function getUrl(array $filter = []): string {
301        $code_href = $this->envUtils()->getCodeHref();
302        $serialized_filter = $this->serialize($this->getValidFilter($filter));
303        return "{$code_href}termine?filter={$serialized_filter}";
304    }
305
306    /**
307     * @param TerminNotification|TerminNotificationTemplate $notification
308     *
309     * @return array<string>
310     */
311    public function formatNotificationRecipients(mixed $notification): array {
312        $recipients = [];
313        $recipient_user = $notification->getRecipientUser();
314        if ($recipient_user !== null) {
315            $recipients[] = $recipient_user->getFullName();
316        }
317        $recipient_role = $notification->getRecipientRole();
318        if ($recipient_role !== null) {
319            $recipients[] = "Ressort {$recipient_role->getName()}";
320        }
321        if ($notification->getRecipientTerminOwnerUser()) {
322            $recipients[] = "Termin-Verantwortlicher";
323        }
324        if ($notification->getRecipientTerminOwnerRole()) {
325            $recipients[] = "Termin-Ressort";
326        }
327        if ($notification->getRecipientTerminOrganizer()) {
328            $recipients[] = "Termin-Organisator";
329        }
330        if ($notification->getRecipientTerminVolunteers()) {
331            $recipients[] = "Termin-Helfer";
332        }
333        if ($notification->getRecipientTerminParticipants()) {
334            $recipients[] = "Termin-Teilnehmer";
335        }
336        return $recipients;
337    }
338
339    public function updateTerminFromSolvEvent(Termin $termin, ?SolvEvent $solv_event_arg = null): void {
340        $solv_id = $termin->getSolvId();
341        if (!$solv_id) {
342            $this->log()->warning("Update termin {$termin->getId()} from SOLV: no SOLV ID.");
343            return;
344        }
345        $solv_event = $solv_event_arg;
346        if ($solv_event_arg === null) {
347            $solv_event_repo = $this->entityManager()->getRepository(SolvEvent::class);
348            $solv_event = $solv_event_repo->findOneBy(['solv_uid' => $solv_id]);
349        } else {
350            if ($solv_id !== $solv_event_arg->getSolvUid()) {
351                $this->log()->warning("Update termin {$termin->getId()} from SOLV: SOLV ID mismatch ({$solv_id} vs. {$solv_event_arg->getSolvUid()}).");
352                return;
353            }
354        }
355        $this->generalUtils()->checkNotNull($solv_event, "No SolvEvent for termin update");
356
357        $duration_days = $solv_event->getDuration() - 1;
358        $duration = \DateInterval::createFromDateString("{$duration_days} days");
359        $end_date = (clone $solv_event->getDate())->add($duration);
360        $deadline = $solv_event->getDeadline()
361            ? (clone $solv_event->getDeadline())->setTime(23, 59, 59) : null;
362        $link = $solv_event->getLink() ?: '-';
363        $club = $solv_event->getClub() ?: '-';
364        $map = $solv_event->getMap() ?: '-';
365        $location = $solv_event->getLocation() ?: '-';
366        $coord_x = $solv_event->getCoordX();
367        $coord_y = $solv_event->getCoordY();
368        $lat = ($coord_x && $coord_y) ? $this->mapUtils()->CHtoWGSlat($coord_x, $coord_y) : null;
369        $lng = ($coord_x && $coord_y) ? $this->mapUtils()->CHtoWGSlng($coord_x, $coord_y) : null;
370        $text = <<<ZZZZZZZZZZ
371            Link: {$link}
372
373            Organisator: {$club}
374
375            Karte: {$map}
376
377            Ort: {$location}
378            ZZZZZZZZZZ;
379
380        $termin->setStartDate($solv_event->getDate());
381        $termin->setStartTime(null);
382        $termin->setEndDate($end_date);
383        $termin->setEndTime(null);
384        $termin->setDeadline($deadline);
385        $termin->setTitle($solv_event->getName());
386        $termin->setText($text);
387        $termin->setNewsletter(false); // TODO: Enable Newsletter for SOLV Termine
388        $termin->setLocation(null);
389        $termin->setLatitude($lat);
390        $termin->setLongitude($lng);
391        $this->log()->info("Termin {$termin->getId()} updated from SOLV.");
392    }
393}