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