Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.59% covered (success)
92.59%
250 / 270
87.23% covered (warning)
87.23%
41 / 47
CRAP
0.00% covered (danger)
0.00%
0 / 1
ValidationService
92.59% covered (success)
92.59%
250 / 270
87.23% covered (warning)
87.23%
41 / 47
198.91
0.00% covered (danger)
0.00%
0 / 1
 clearOfficeServicesCacheForTesting
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getError
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateServerGetRequest
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 validateServerPostRequest
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 validateServiceLocationCombination
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
10.39
 validateCaptcha
21.43% covered (danger)
21.43%
3 / 14
0.00% covered (danger)
0.00%
0 / 1
30.77
 validateGetBookableFreeDays
89.29% covered (warning)
89.29%
25 / 28
0.00% covered (danger)
0.00%
0 / 1
23.65
 validateGetProcessById
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 validatePostAppointmentReserve
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 validateAppointmentUpdateFields
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 isFilledContactValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isFilledEmail
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isSameOrCompletedFamilyName
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 validateUnchangedStoredContact
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 normalizedCustomText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 rejectChangedStoredFamilyName
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 rejectChangedStoredField
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 validateFamilyNameField
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 validateEmailField
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
6
 validateTelephoneField
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
9
 validateCustomTextField
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 validateGetProcessNotFound
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validateScopesNotFound
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 validateServicesNotFound
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validateOfficesNotFound
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validatenoAppointmentForThisScope
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateServiceArrays
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
8
 isValidDate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isDateRangeValid
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 isValidNumericArray
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isValidOfficeIds
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isValidProcessId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isValidAuthKey
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 isValidServiceIds
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isValidServiceCount
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 isValidServiceCounts
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 isValidTimestamp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 isValidEmail
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 isValidTelephone
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isValidFamilyName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 isPlaceholderEmail
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 isMissingClientContactData
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 validateAppointmentReservedStatus
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 validateAppointmentPreconfirm
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 validateAppointmentConfirm
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
8
 isConfirmedRebookingSource
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 isValidOfficeId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace BO\Zmscitizenapi\Services\Core;
6
7use BO\Zmscitizenapi\Utils\ErrorMessages;
8use BO\Zmscitizenapi\Models\ThinnedProcess;
9use BO\Zmscitizenapi\Models\ThinnedScope;
10use BO\Zmsentities\Helper\ProcessPlainText;
11use BO\Zmscitizenapi\Services\Core\ZmsApiFacadeService;
12use BO\Zmscitizenapi\Services\Captcha\TokenValidationService;
13use BO\Zmsentities\Process;
14use BO\Zmsentities\Collection\ScopeList;
15use DateTime;
16use Psr\Http\Message\ServerRequestInterface;
17
18/**
19 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
20 * @SuppressWarnings(PHPMD.TooManyMethods)
21 * @TODO: Split this service into domain-specific validation services
22 */
23class ValidationService
24{
25    private static array $officeServicesCache = [];
26
27    public static function clearOfficeServicesCacheForTesting(): void
28    {
29        self::$officeServicesCache = [];
30    }
31    private const string DATE_FORMAT = 'Y-m-d';
32    private const int MIN_PROCESS_ID = 1;
33    private const string PHONE_PATTERN = '/^\+?[0-9]\d{6,14}$/';
34    private const string SERVICE_COUNT_PATTERN = '/^\d+$/';
35    private const string EMAIL_PATTERN = '/^(?!.*\.\.)(?!\.)(?!.*\.$)[^\s@+]+(?<!\.)@(?!\.)[^\s@+]+\.[^\s@]{2,}$/';
36    private const int MAX_FUTURE_DAYS = 365;
37    // Maximum days in the future for appointments
38    /** Must match {@see \BO\Zmsdb\Slot::MAX_SLOTS} */
39    private const int MAX_SERVICE_COUNT = 25;
40    private const int AUTH_KEY_LEGACY_HEX_LENGTH = 4;
41    private const int AUTH_KEY_NEW_HEX_LENGTH = 64;
42
43    private static function getError(string $key): array
44    {
45        return ErrorMessages::get($key);
46    }
47
48    public static function validateServerGetRequest(?ServerRequestInterface $request): array
49    {
50        if (!$request instanceof ServerRequestInterface) {
51            return ['errors' => [self::getError('invalidRequest')]];
52        }
53
54        if ($request->getMethod() !== "GET") {
55            return ['errors' => [self::getError('invalidRequest')]];
56        }
57
58        return [];
59    }
60
61    public static function validateServerPostRequest(?ServerRequestInterface $request): array
62    {
63        if (!$request instanceof ServerRequestInterface) {
64            return ['errors' => [self::getError('invalidRequest')]];
65        }
66
67        if ($request->getMethod() !== "POST") {
68            return ['errors' => [self::getError('invalidRequest')]];
69        }
70
71        if ($request->getParsedBody() === null) {
72            return ['errors' => [self::getError('invalidRequest')]];
73        }
74
75        return [];
76    }
77
78    public static function validateServiceLocationCombination(int $officeId, array $serviceIds, bool $showUnpublished = false): array
79    {
80        if ($officeId <= 0) {
81            return ['errors' => [self::getError('invalidOfficeId')]];
82        }
83
84        if (empty($serviceIds) || !self::isValidNumericArray($serviceIds)) {
85            return ['errors' => [self::getError('invalidServiceId')]];
86        }
87
88        $cacheKey = $officeId . '|' . ($showUnpublished ? '1' : '0');
89        if (!isset(self::$officeServicesCache[$cacheKey])) {
90            $serviceList = ZmsApiFacadeService::getServicesByOfficeId($officeId, $showUnpublished);
91            $ids = [];
92            if (is_array($serviceList) && isset($serviceList['errors'])) {
93                self::$officeServicesCache[$cacheKey] = [];
94            } else {
95                foreach ($serviceList->services as $service) {
96                    $ids[] = (string)$service->id;
97                }
98                self::$officeServicesCache[$cacheKey] = $ids;
99            }
100        }
101        $availableServiceIds = self::$officeServicesCache[$cacheKey];
102
103        $serviceIdsStr = array_map('strval', $serviceIds);
104        $invalidServiceIds = array_diff($serviceIdsStr, $availableServiceIds);
105        return empty($invalidServiceIds)
106            ? []
107            : ['errors' => [self::getError('invalidLocationAndServiceCombination')]];
108    }
109
110    private static function validateCaptcha(bool $captchaRequired, ?string $captchaToken, ?TokenValidationService $tokenValidator): array
111    {
112        $errors = [];
113
114        if ($captchaRequired) {
115            if (!$tokenValidator) {
116                $status = TokenValidationService::TOKEN_MISSING;
117            } else {
118                $status = $tokenValidator->validateCaptchaToken($captchaToken);
119            }
120
121            if ($status !== TokenValidationService::TOKEN_VALID) {
122                switch ($status) {
123                    case TokenValidationService::TOKEN_MISSING:
124                        $errors[] = self::getError('captchaMissing');
125                        break;
126                    case TokenValidationService::TOKEN_EXPIRED:
127                        $errors[] = self::getError('captchaExpired');
128                        break;
129                    default:
130                        $errors[] = self::getError('captchaInvalid');
131                }
132            }
133        }
134
135        return $errors;
136    }
137
138    /**
139     * @SuppressWarnings(PHPMD.NPathComplexity)
140     * @TODO: Extract validation rules into separate rule objects using the specification pattern
141     */
142    public static function validateGetBookableFreeDays(?array $officeIds, ?array $serviceIds, ?string $startDate, ?string $endDate, ?array $serviceCounts, ?bool $captchaRequired = false, ?string $captchaToken = null, ?TokenValidationService $tokenValidator = null, ?string $slotsStartDate = null, ?string $slotsEndDate = null): array
143    {
144        $errors = [];
145        if (!self::isValidOfficeIds($officeIds)) {
146            $errors[] = self::getError('invalidOfficeId');
147        }
148
149        if (!self::isValidServiceIds($serviceIds)) {
150            $errors[] = self::getError('invalidServiceId');
151        }
152
153        if (!$startDate || !self::isValidDate($startDate)) {
154            $errors[] = self::getError('invalidStartDate');
155        }
156
157        if (!$endDate || !self::isValidDate($endDate)) {
158            $errors[] = self::getError('invalidEndDate');
159        }
160
161        if ($startDate && $endDate && self::isValidDate($startDate) && self::isValidDate($endDate)) {
162            if (new DateTime($startDate) > new DateTime($endDate)) {
163                $errors[] = self::getError('startDateAfterEndDate');
164            }
165
166            if (!self::isDateRangeValid($startDate, $endDate)) {
167                $errors[] = self::getError('dateRangeTooLarge');
168            }
169        }
170
171        if ($slotsStartDate !== null && !self::isValidDate($slotsStartDate)) {
172            $errors[] = self::getError('invalidSlotsStartDate');
173        }
174
175        if ($slotsEndDate !== null && !self::isValidDate($slotsEndDate)) {
176            $errors[] = self::getError('invalidSlotsEndDate');
177        }
178
179        if (
180            $slotsStartDate !== null
181            && $slotsEndDate !== null
182            && self::isValidDate($slotsStartDate)
183            && self::isValidDate($slotsEndDate)
184            && new DateTime($slotsStartDate) > new DateTime($slotsEndDate)
185        ) {
186            $errors[] = self::getError('slotsStartDateAfterEndDate');
187        }
188
189        if (!self::isValidServiceCounts($serviceCounts)) {
190            $errors[] = self::getError('invalidServiceCount');
191        }
192
193        $errors = array_merge($errors, self::validateCaptcha($captchaRequired, $captchaToken, $tokenValidator));
194
195        return ['errors' => $errors];
196    }
197
198    public static function validateGetProcessById(?int $processId, ?string $authKey): array
199    {
200        $errors = [];
201        if (!self::isValidProcessId($processId)) {
202            $errors[] = self::getError('invalidProcessId');
203        }
204
205        if (!self::isValidAuthKey($authKey)) {
206            $errors[] = self::getError('invalidAuthKey');
207        }
208
209        return ['errors' => $errors];
210    }
211
212    public static function validatePostAppointmentReserve(?int $officeId, ?array $serviceIds, ?array $serviceCounts, ?int $timestamp, ?bool $captchaRequired = false, ?string $captchaToken = null, ?TokenValidationService $tokenValidator = null): array
213    {
214        $errors = [];
215        if (!self::isValidOfficeId($officeId)) {
216            $errors[] = self::getError('invalidOfficeId');
217        }
218
219        if (!self::isValidServiceIds($serviceIds)) {
220            $errors[] = self::getError('invalidServiceId');
221        }
222
223        if (!self::isValidTimestamp($timestamp)) {
224            $errors[] = self::getError('invalidTimestamp');
225        }
226
227        if (!self::isValidServiceCounts($serviceCounts)) {
228            $errors[] = self::getError('invalidServiceCount');
229        }
230
231        $errors = array_merge($errors, self::validateCaptcha($captchaRequired, $captchaToken, $tokenValidator));
232
233        return ['errors' => $errors];
234    }
235
236    public static function validateAppointmentUpdateFields(
237        ?string $familyName,
238        ?string $email,
239        ?string $telephone,
240        ?string $customTextfield,
241        ?string $customTextfield2,
242        ?ThinnedScope $scope
243    ): array {
244        $errors = [];
245
246        self::validateFamilyNameField($familyName, $errors);
247        self::validateEmailField($email, $scope, $errors);
248        self::validateTelephoneField($telephone, $scope, $errors);
249        self::validateCustomTextField($customTextfield, $scope?->customTextfieldActivated, $scope?->customTextfieldRequired, 'invalidCustomTextfield', $errors);
250        self::validateCustomTextField($customTextfield2, $scope?->customTextfield2Activated, $scope?->customTextfield2Required, 'invalidCustomTextfield2', $errors);
251
252        return ['errors' => $errors];
253    }
254
255    public static function isFilledContactValue(?string $value): bool
256    {
257        return trim((string) $value) !== '';
258    }
259
260    public static function isFilledEmail(?string $email): bool
261    {
262        return self::isFilledContactValue($email) && !self::isPlaceholderEmail($email);
263    }
264
265    /**
266     * True when incoming equals stored, or completes it with further name parts
267     * ("Max" -> "Max Mustermann"). The citizen form splits familyName on the first
268     * space and can leave lastName editable when the stored value is a single word.
269     */
270    public static function isSameOrCompletedFamilyName(?string $stored, ?string $incoming): bool
271    {
272        $storedTrimmed = trim((string) $stored);
273        $incomingTrimmed = trim((string) $incoming);
274        if ($storedTrimmed === '' || $incomingTrimmed === '') {
275            return false;
276        }
277        return $incomingTrimmed === $storedTrimmed
278            || (
279                !str_contains($storedTrimmed, ' ')
280                && str_starts_with($incomingTrimmed, $storedTrimmed . ' ')
281            );
282    }
283
284    public static function validateUnchangedStoredContact(
285        ThinnedProcess $stored,
286        ?string $familyName,
287        ?string $email,
288        ?string $telephone,
289        ?string $customTextfield,
290        ?string $customTextfield2
291    ): array {
292        $errors = [];
293        self::rejectChangedStoredFamilyName($stored->familyName, $familyName, $errors);
294        self::rejectChangedStoredField($stored->email, $email, 'emailCannotBeChanged', $errors, true);
295        self::rejectChangedStoredField($stored->telephone, $telephone, 'telephoneCannotBeChanged', $errors);
296        self::rejectChangedStoredField(
297            self::normalizedCustomText($stored->customTextfield),
298            self::normalizedCustomText($customTextfield),
299            'customTextfieldCannotBeChanged',
300            $errors
301        );
302        self::rejectChangedStoredField(
303            self::normalizedCustomText($stored->customTextfield2),
304            self::normalizedCustomText($customTextfield2),
305            'customTextfield2CannotBeChanged',
306            $errors
307        );
308
309        return ['errors' => $errors];
310    }
311
312    private static function normalizedCustomText(?string $value): ?string
313    {
314        return $value === null ? null : ProcessPlainText::normalize($value);
315    }
316
317    private static function rejectChangedStoredFamilyName(?string $stored, ?string $incoming, array &$errors): void
318    {
319        if (!self::isFilledContactValue($stored) || !self::isFilledContactValue($incoming)) {
320            return;
321        }
322        if (!self::isSameOrCompletedFamilyName($stored, $incoming)) {
323            $errors[] = self::getError('familyNameCannotBeChanged');
324        }
325    }
326
327    private static function rejectChangedStoredField(
328        ?string $stored,
329        ?string $incoming,
330        string $errorKey,
331        array &$errors,
332        bool $email = false
333    ): void {
334        $storedFilled = $email
335            ? self::isFilledEmail($stored)
336            : self::isFilledContactValue($stored);
337        $incomingFilled = $email
338            ? self::isFilledEmail($incoming)
339            : self::isFilledContactValue($incoming);
340        if (!$storedFilled || !$incomingFilled) {
341            return;
342        }
343        $storedTrimmed = trim($stored);
344        $incomingTrimmed = trim($incoming);
345        $same = $email
346            ? strcasecmp($storedTrimmed, $incomingTrimmed) === 0
347            : $storedTrimmed === $incomingTrimmed;
348        if (!$same) {
349            $errors[] = self::getError($errorKey);
350        }
351    }
352
353    private static function validateFamilyNameField(?string $familyName, array &$errors): void
354    {
355        if (!self::isValidFamilyName($familyName)) {
356            $errors[] = self::getError('invalidFamilyName');
357        }
358    }
359
360    private static function validateEmailField(?string $email, ?ThinnedScope $scope, array &$errors): void
361    {
362        if (self::isPlaceholderEmail($email)) {
363            $errors[] = self::getError('invalidEmail');
364            return;
365        }
366
367        if (
368            $scope && $scope->emailRequired
369            && ($email === "" || !self::isValidEmail($email))
370        ) {
371            $errors[] = self::getError('invalidEmail');
372        }
373    }
374
375    private static function validateTelephoneField(?string $telephone, ?ThinnedScope $scope, array &$errors): void
376    {
377        if (!$scope || !$scope->telephoneActivated) {
378            return;
379        }
380
381        if (
382            ($scope->telephoneRequired && ($telephone === "" || !self::isValidTelephone($telephone))) ||
383            ($telephone !== null && $telephone !== "" && !self::isValidTelephone($telephone))
384        ) {
385            $errors[] = self::getError('invalidTelephone');
386        }
387    }
388
389    private static function validateCustomTextField(?string $fieldValue, ?bool $fieldActivated, ?bool $fieldRequired, string $errorKey, array &$errors): void
390    {
391        if (!$fieldActivated) {
392            return;
393        }
394
395        $normalized = ProcessPlainText::normalize($fieldValue);
396        if ($fieldRequired && trim($normalized) === '') {
397            $errors[] = self::getError($errorKey);
398            return;
399        }
400        if ($fieldValue !== null && $fieldValue !== '' && mb_strlen($normalized, 'UTF-8') > ProcessPlainText::MAX_CUSTOM_TEXTFIELD_CHARS) {
401            $errors[] = self::getError($errorKey);
402        }
403    }
404
405    public static function validateGetProcessNotFound(?Process $process): array
406    {
407        return !$process
408            ? ['errors' => [self::getError('appointmentNotAvailable')]]
409            : [];
410    }
411
412    public static function validateScopesNotFound(?ScopeList $scopes): array
413    {
414        return empty($scopes) || $scopes->count() === 0
415            ? ['errors' => [self::getError('scopesNotFound')]]
416            : [];
417    }
418
419    public static function validateServicesNotFound(?array $services): array
420    {
421        return empty($services)
422            ? ['errors' => [self::getError('requestNotFound')]]
423            : [];
424    }
425
426    public static function validateOfficesNotFound(?array $offices): array
427    {
428        return empty($offices)
429            ? ['errors' => [self::getError('providerNotFound')]]
430            : [];
431    }
432
433    public static function validatenoAppointmentForThisScope(): array
434    {
435        return ['errors' => [self::getError('noAppointmentForThisScope')]];
436    }
437
438    public static function validateServiceArrays(array $serviceIds, array $serviceCounts): array
439    {
440        $errors = [];
441        if (empty($serviceIds) || empty($serviceCounts)) {
442            $errors[] = self::getError('emptyServiceArrays');
443        }
444
445        if (count($serviceIds) !== count($serviceCounts)) {
446            $errors[] = self::getError('mismatchedArrays');
447        }
448
449        foreach ($serviceIds as $id) {
450            if (!is_numeric($id)) {
451                $errors[] = self::getError('invalidServiceId');
452                break;
453            }
454        }
455
456        foreach ($serviceCounts as $count) {
457            if (!self::isValidServiceCount($count)) {
458                $errors[] = self::getError('invalidServiceCount');
459                break;
460            }
461        }
462
463        return $errors;
464    }
465
466    /*  Helper methods for validation */
467    private static function isValidDate(string $date): bool
468    {
469        $dateTime = DateTime::createFromFormat(self::DATE_FORMAT, $date);
470        return $dateTime && $dateTime->format(self::DATE_FORMAT) === $date;
471    }
472
473    private static function isDateRangeValid(string $startDate, string $endDate): bool
474    {
475        $start = new DateTime($startDate);
476        $end = new DateTime($endDate);
477        $diff = $start->diff($end);
478        return $diff->days <= self::MAX_FUTURE_DAYS;
479    }
480
481    private static function isValidNumericArray(array $array): bool
482    {
483        return !empty($array) && array_filter($array, 'is_numeric') === $array;
484    }
485
486    private static function isValidOfficeIds(?array $officeIds): bool
487    {
488        return !empty($officeIds) && self::isValidNumericArray($officeIds);
489    }
490
491    private static function isValidProcessId(?int $processId): bool
492    {
493        return !empty($processId) && $processId >= self::MIN_PROCESS_ID;
494    }
495
496    private static function isValidAuthKey(?string $authKey): bool
497    {
498        if ($authKey === null) {
499            return false;
500        }
501        $authKey = trim($authKey);
502        $len = strlen($authKey);
503        if ($len !== self::AUTH_KEY_LEGACY_HEX_LENGTH && $len !== self::AUTH_KEY_NEW_HEX_LENGTH) {
504            return false;
505        }
506
507        return ctype_xdigit($authKey);
508    }
509
510    private static function isValidServiceIds(?array $serviceIds): bool
511    {
512        return !empty($serviceIds) && self::isValidNumericArray($serviceIds);
513    }
514
515    private static function isValidServiceCount(mixed $count): bool
516    {
517        return is_numeric($count)
518            && (int) $count >= 1
519            && (int) $count <= self::MAX_SERVICE_COUNT
520            && preg_match(self::SERVICE_COUNT_PATTERN, (string) $count) === 1;
521    }
522
523    private static function isValidServiceCounts(?array $serviceCounts): bool
524    {
525        if (empty($serviceCounts) || !is_array($serviceCounts)) {
526            return false;
527        }
528
529        foreach ($serviceCounts as $count) {
530            if (!self::isValidServiceCount($count)) {
531                return false;
532            }
533        }
534
535        return true;
536    }
537
538    private static function isValidTimestamp(?int $timestamp): bool
539    {
540        return !empty($timestamp) && is_numeric($timestamp) && $timestamp > time();
541    }
542
543    private static function isValidEmail(?string $email): bool
544    {
545        return !empty($email)
546            && !self::isPlaceholderEmail($email)
547            && preg_match(self::EMAIL_PATTERN, $email) === 1;
548    }
549
550    private static function isValidTelephone(?string $telephone): bool
551    {
552        return $telephone === null || preg_match(self::PHONE_PATTERN, $telephone);
553    }
554
555    private static function isValidFamilyName(?string $familyName): bool
556    {
557        return !empty($familyName) && is_string($familyName) && strlen(trim($familyName)) > 0;
558    }
559
560    public static function isPlaceholderEmail(?string $email): bool
561    {
562        if ($email === null || trim($email) === '') {
563            return false;
564        }
565
566        return strcasecmp(trim($email), \App::getPlaceholderEmail()) === 0;
567    }
568
569    private static function isMissingClientContactData(ThinnedProcess $process): bool
570    {
571        return self::isPlaceholderEmail($process->email)
572            || !self::isValidFamilyName($process->familyName)
573            || !self::isValidEmail($process->email);
574    }
575
576    public static function validateAppointmentReservedStatus(?ThinnedProcess $process): array
577    {
578        if ($process === null || $process->status !== 'reserved') {
579            return ['errors' => [self::getError('processNotReservedAnymore')]];
580        }
581
582        return ['errors' => []];
583    }
584
585    public static function validateAppointmentPreconfirm(ThinnedProcess $process): array
586    {
587        $reservedErrors = self::validateAppointmentReservedStatus($process);
588        if ($reservedErrors['errors'] !== []) {
589            return $reservedErrors;
590        }
591
592        if (self::isMissingClientContactData($process)) {
593            return ['errors' => [self::getError('placeholderEmailNotAllowed')]];
594        }
595
596        return ['errors' => []];
597    }
598
599    public static function validateAppointmentConfirm(
600        ThinnedProcess $process,
601        mixed $externalUserId,
602        ?ThinnedProcess $sourceProcess = null
603    ): array {
604        if (self::isMissingClientContactData($process)) {
605            return ['errors' => [self::getError('placeholderEmailNotAllowed')]];
606        }
607
608        if ($process->status === 'preconfirmed') {
609            return ['errors' => []];
610        }
611
612        $hasExternalUserId = is_string($externalUserId) && trim($externalUserId) !== '';
613        if ($process->status === 'reserved' && $hasExternalUserId) {
614            return ['errors' => []];
615        }
616
617        if ($process->status === 'reserved' && self::isConfirmedRebookingSource($process, $sourceProcess)) {
618            return ['errors' => []];
619        }
620
621        return ['errors' => [self::getError('processNotPreconfirmedAnymore')]];
622    }
623
624    /**
625     * Guest rebooking may skip email activation only when the original appointment
626     * is already confirmed, proven with processId + authKey, and has the same
627     * permitted contact data as the reserved process.
628     */
629    private static function isConfirmedRebookingSource(
630        ThinnedProcess $process,
631        ?ThinnedProcess $sourceProcess
632    ): bool {
633        if (!$sourceProcess instanceof ThinnedProcess) {
634            return false;
635        }
636        if ($sourceProcess->processId === null || $sourceProcess->processId === $process->processId) {
637            return false;
638        }
639        if ($sourceProcess->status !== Process::STATUS_CONFIRMED) {
640            return false;
641        }
642        if (self::isMissingClientContactData($sourceProcess)) {
643            return false;
644        }
645
646        return self::validateUnchangedStoredContact(
647            $sourceProcess,
648            $process->familyName,
649            $process->email,
650            $process->telephone,
651            $process->customTextfield,
652            $process->customTextfield2
653        )['errors'] === [];
654    }
655
656    private static function isValidOfficeId(?int $officeId): bool
657    {
658        return !empty($officeId) && $officeId > 0;
659    }
660}