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