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