Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.71% covered (warning)
89.71%
157 / 175
88.24% covered (warning)
88.24%
30 / 34
CRAP
0.00% covered (danger)
0.00%
0 / 1
ValidationService
89.71% covered (warning)
89.71%
157 / 175
88.24% covered (warning)
88.24%
30 / 34
154.83
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
 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%
2 / 2
100.00% covered (success)
100.00%
1 / 1
5
 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%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 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
 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\ThinnedScope;
9use BO\Zmsentities\Helper\ProcessPlainText;
10use BO\Zmscitizenapi\Services\Core\ZmsApiFacadeService;
11use BO\Zmscitizenapi\Services\Captcha\TokenValidationService;
12use BO\Zmsentities\Process;
13use BO\Zmsentities\Collection\ScopeList;
14use DateTime;
15use Psr\Http\Message\ServerRequestInterface;
16
17/**
18 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
19 * @SuppressWarnings(PHPMD.TooManyMethods)
20 * @TODO: Split this service into domain-specific validation services
21 */
22class ValidationService
23{
24    private static array $officeServicesCache = [];
25
26    public static function clearOfficeServicesCacheForTesting(): void
27    {
28        self::$officeServicesCache = [];
29    }
30    private const DATE_FORMAT = 'Y-m-d';
31    private const MIN_PROCESS_ID = 1;
32    private const PHONE_PATTERN = '/^\+?[0-9]\d{6,14}$/';
33    private const SERVICE_COUNT_PATTERN = '/^\d+$/';
34    private const EMAIL_PATTERN = '/^(?!.*\.\.)(?!\.)(?!.*\.$)[^\s@+]+(?<!\.)@(?!\.)[^\s@+]+\.[^\s@]{2,}$/';
35    private const MAX_FUTURE_DAYS = 365;
36    // Maximum days in the future for appointments
37    /** Must match {@see \BO\Zmsdb\Slot::MAX_SLOTS} */
38    private const MAX_SERVICE_COUNT = 25;
39    private const AUTH_KEY_LEGACY_HEX_LENGTH = 4;
40    private const AUTH_KEY_NEW_HEX_LENGTH = 64;
41
42    private static function getError(string $key): array
43    {
44        return ErrorMessages::get($key);
45    }
46
47    public static function validateServerGetRequest(?ServerRequestInterface $request): array
48    {
49        if (!$request instanceof ServerRequestInterface) {
50            return ['errors' => [self::getError('invalidRequest')]];
51        }
52
53        if ($request->getMethod() !== "GET") {
54            return ['errors' => [self::getError('invalidRequest')]];
55        }
56
57        return [];
58    }
59
60    public static function validateServerPostRequest(?ServerRequestInterface $request): array
61    {
62        if (!$request instanceof ServerRequestInterface) {
63            return ['errors' => [self::getError('invalidRequest')]];
64        }
65
66        if ($request->getMethod() !== "POST") {
67            return ['errors' => [self::getError('invalidRequest')]];
68        }
69
70        if ($request->getParsedBody() === null) {
71            return ['errors' => [self::getError('invalidRequest')]];
72        }
73
74        return [];
75    }
76
77    public static function validateServiceLocationCombination(int $officeId, array $serviceIds, bool $showUnpublished = false): array
78    {
79        if ($officeId <= 0) {
80            return ['errors' => [self::getError('invalidOfficeId')]];
81        }
82
83        if (empty($serviceIds) || !self::isValidNumericArray($serviceIds)) {
84            return ['errors' => [self::getError('invalidServiceId')]];
85        }
86
87        $cacheKey = $officeId . '|' . ($showUnpublished ? '1' : '0');
88        if (!isset(self::$officeServicesCache[$cacheKey])) {
89            $serviceList = ZmsApiFacadeService::getServicesByOfficeId($officeId, $showUnpublished);
90            $ids = [];
91            if (is_array($serviceList) && isset($serviceList['errors'])) {
92                self::$officeServicesCache[$cacheKey] = [];
93            } else {
94                foreach ($serviceList->services as $service) {
95                    $ids[] = (string)$service->id;
96                }
97                self::$officeServicesCache[$cacheKey] = $ids;
98            }
99        }
100        $availableServiceIds = self::$officeServicesCache[$cacheKey];
101
102        $serviceIdsStr = array_map('strval', $serviceIds);
103        $invalidServiceIds = array_diff($serviceIdsStr, $availableServiceIds);
104        return empty($invalidServiceIds)
105            ? []
106            : ['errors' => [self::getError('invalidLocationAndServiceCombination')]];
107    }
108
109    private static function validateCaptcha(bool $captchaRequired, ?string $captchaToken, ?TokenValidationService $tokenValidator): array
110    {
111        $errors = [];
112
113        if ($captchaRequired) {
114            if (!$tokenValidator) {
115                $status = TokenValidationService::TOKEN_MISSING;
116            } else {
117                $status = $tokenValidator->validateCaptchaToken($captchaToken);
118            }
119
120            if ($status !== TokenValidationService::TOKEN_VALID) {
121                switch ($status) {
122                    case TokenValidationService::TOKEN_MISSING:
123                        $errors[] = self::getError('captchaMissing');
124                        break;
125                    case TokenValidationService::TOKEN_EXPIRED:
126                        $errors[] = self::getError('captchaExpired');
127                        break;
128                    default:
129                        $errors[] = self::getError('captchaInvalid');
130                }
131            }
132        }
133
134        return $errors;
135    }
136
137    /**
138     * @SuppressWarnings(PHPMD.NPathComplexity)
139     * @TODO: Extract validation rules into separate rule objects using the specification pattern
140     */
141    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
142    {
143        $errors = [];
144        if (!self::isValidOfficeIds($officeIds)) {
145            $errors[] = self::getError('invalidOfficeId');
146        }
147
148        if (!self::isValidServiceIds($serviceIds)) {
149            $errors[] = self::getError('invalidServiceId');
150        }
151
152        if (!$startDate || !self::isValidDate($startDate)) {
153            $errors[] = self::getError('invalidStartDate');
154        }
155
156        if (!$endDate || !self::isValidDate($endDate)) {
157            $errors[] = self::getError('invalidEndDate');
158        }
159
160        if ($startDate && $endDate && self::isValidDate($startDate) && self::isValidDate($endDate)) {
161            if (new DateTime($startDate) > new DateTime($endDate)) {
162                $errors[] = self::getError('startDateAfterEndDate');
163            }
164
165            if (!self::isDateRangeValid($startDate, $endDate)) {
166                $errors[] = self::getError('dateRangeTooLarge');
167            }
168        }
169
170        if ($slotsStartDate !== null && !self::isValidDate($slotsStartDate)) {
171            $errors[] = self::getError('invalidSlotsStartDate');
172        }
173
174        if ($slotsEndDate !== null && !self::isValidDate($slotsEndDate)) {
175            $errors[] = self::getError('invalidSlotsEndDate');
176        }
177
178        if (
179            $slotsStartDate !== null
180            && $slotsEndDate !== null
181            && self::isValidDate($slotsStartDate)
182            && self::isValidDate($slotsEndDate)
183            && new DateTime($slotsStartDate) > new DateTime($slotsEndDate)
184        ) {
185            $errors[] = self::getError('slotsStartDateAfterEndDate');
186        }
187
188        if (!self::isValidServiceCounts($serviceCounts)) {
189            $errors[] = self::getError('invalidServiceCount');
190        }
191
192        $errors = array_merge($errors, self::validateCaptcha($captchaRequired, $captchaToken, $tokenValidator));
193
194        return ['errors' => $errors];
195    }
196
197    public static function validateGetProcessById(?int $processId, ?string $authKey): array
198    {
199        $errors = [];
200        if (!self::isValidProcessId($processId)) {
201            $errors[] = self::getError('invalidProcessId');
202        }
203
204        if (!self::isValidAuthKey($authKey)) {
205            $errors[] = self::getError('invalidAuthKey');
206        }
207
208        return ['errors' => $errors];
209    }
210
211    public static function validatePostAppointmentReserve(?int $officeId, ?array $serviceIds, ?array $serviceCounts, ?int $timestamp, ?bool $captchaRequired = false, ?string $captchaToken = null, ?TokenValidationService $tokenValidator = null): array
212    {
213        $errors = [];
214        if (!self::isValidOfficeId($officeId)) {
215            $errors[] = self::getError('invalidOfficeId');
216        }
217
218        if (!self::isValidServiceIds($serviceIds)) {
219            $errors[] = self::getError('invalidServiceId');
220        }
221
222        if (!self::isValidTimestamp($timestamp)) {
223            $errors[] = self::getError('invalidTimestamp');
224        }
225
226        if (!self::isValidServiceCounts($serviceCounts)) {
227            $errors[] = self::getError('invalidServiceCount');
228        }
229
230        $errors = array_merge($errors, self::validateCaptcha($captchaRequired, $captchaToken, $tokenValidator));
231
232        return ['errors' => $errors];
233    }
234
235    public static function validateAppointmentUpdateFields(
236        ?string $familyName,
237        ?string $email,
238        ?string $telephone,
239        ?string $customTextfield,
240        ?string $customTextfield2,
241        ?ThinnedScope $scope
242    ): array {
243        $errors = [];
244
245        self::validateFamilyNameField($familyName, $errors);
246        self::validateEmailField($email, $scope, $errors);
247        self::validateTelephoneField($telephone, $scope, $errors);
248        self::validateCustomTextField($customTextfield, $scope?->customTextfieldActivated, $scope?->customTextfieldRequired, 'invalidCustomTextfield', $errors);
249        self::validateCustomTextField($customTextfield2, $scope?->customTextfield2Activated, $scope?->customTextfield2Required, 'invalidCustomTextfield2', $errors);
250
251        return ['errors' => $errors];
252    }
253
254    private static function validateFamilyNameField(?string $familyName, array &$errors): void
255    {
256        if (!self::isValidFamilyName($familyName)) {
257            $errors[] = self::getError('invalidFamilyName');
258        }
259    }
260
261    private static function validateEmailField(?string $email, ?ThinnedScope $scope, array &$errors): void
262    {
263        if ($scope && $scope->emailRequired && ($email === "" || !self::isValidEmail($email))) {
264            $errors[] = self::getError('invalidEmail');
265        }
266    }
267
268    private static function validateTelephoneField(?string $telephone, ?ThinnedScope $scope, array &$errors): void
269    {
270        if (!$scope || !$scope->telephoneActivated) {
271            return;
272        }
273
274        if (
275            ($scope->telephoneRequired && ($telephone === "" || !self::isValidTelephone($telephone))) ||
276            ($telephone !== null && $telephone !== "" && !self::isValidTelephone($telephone))
277        ) {
278            $errors[] = self::getError('invalidTelephone');
279        }
280    }
281
282    private static function validateCustomTextField(?string $fieldValue, ?bool $fieldActivated, ?bool $fieldRequired, string $errorKey, array &$errors): void
283    {
284        if (!$fieldActivated) {
285            return;
286        }
287
288        $normalized = ProcessPlainText::normalize($fieldValue);
289        if ($fieldRequired && trim($normalized) === '') {
290            $errors[] = self::getError($errorKey);
291            return;
292        }
293        if ($fieldValue !== null && $fieldValue !== '' && mb_strlen($normalized, 'UTF-8') > ProcessPlainText::MAX_CUSTOM_TEXTFIELD_CHARS) {
294            $errors[] = self::getError($errorKey);
295        }
296    }
297
298    public static function validateGetProcessNotFound(?Process $process): array
299    {
300        return !$process
301            ? ['errors' => [self::getError('appointmentNotAvailable')]]
302            : [];
303    }
304
305    public static function validateScopesNotFound(?ScopeList $scopes): array
306    {
307        return empty($scopes) || $scopes->count() === 0
308            ? ['errors' => [self::getError('scopesNotFound')]]
309            : [];
310    }
311
312    public static function validateServicesNotFound(?array $services): array
313    {
314        return empty($services)
315            ? ['errors' => [self::getError('requestNotFound')]]
316            : [];
317    }
318
319    public static function validateOfficesNotFound(?array $offices): array
320    {
321        return empty($offices)
322            ? ['errors' => [self::getError('providerNotFound')]]
323            : [];
324    }
325
326    public static function validatenoAppointmentForThisScope(): array
327    {
328        return ['errors' => [self::getError('noAppointmentForThisScope')]];
329    }
330
331    public static function validateServiceArrays(array $serviceIds, array $serviceCounts): array
332    {
333        $errors = [];
334        if (empty($serviceIds) || empty($serviceCounts)) {
335            $errors[] = self::getError('emptyServiceArrays');
336        }
337
338        if (count($serviceIds) !== count($serviceCounts)) {
339            $errors[] = self::getError('mismatchedArrays');
340        }
341
342        foreach ($serviceIds as $id) {
343            if (!is_numeric($id)) {
344                $errors[] = self::getError('invalidServiceId');
345                break;
346            }
347        }
348
349        foreach ($serviceCounts as $count) {
350            if (!self::isValidServiceCount($count)) {
351                $errors[] = self::getError('invalidServiceCount');
352                break;
353            }
354        }
355
356        return $errors;
357    }
358
359    /*  Helper methods for validation */
360    private static function isValidDate(string $date): bool
361    {
362        $dateTime = DateTime::createFromFormat(self::DATE_FORMAT, $date);
363        return $dateTime && $dateTime->format(self::DATE_FORMAT) === $date;
364    }
365
366    private static function isDateRangeValid(string $startDate, string $endDate): bool
367    {
368        $start = new DateTime($startDate);
369        $end = new DateTime($endDate);
370        $diff = $start->diff($end);
371        return $diff->days <= self::MAX_FUTURE_DAYS;
372    }
373
374    private static function isValidNumericArray(array $array): bool
375    {
376        return !empty($array) && array_filter($array, 'is_numeric') === $array;
377    }
378
379    private static function isValidOfficeIds(?array $officeIds): bool
380    {
381        return !empty($officeIds) && self::isValidNumericArray($officeIds);
382    }
383
384    private static function isValidProcessId(?int $processId): bool
385    {
386        return !empty($processId) && $processId >= self::MIN_PROCESS_ID;
387    }
388
389    private static function isValidAuthKey(?string $authKey): bool
390    {
391        if ($authKey === null) {
392            return false;
393        }
394        $authKey = trim($authKey);
395        $len = strlen($authKey);
396        if ($len !== self::AUTH_KEY_LEGACY_HEX_LENGTH && $len !== self::AUTH_KEY_NEW_HEX_LENGTH) {
397            return false;
398        }
399
400        return ctype_xdigit($authKey);
401    }
402
403    private static function isValidServiceIds(?array $serviceIds): bool
404    {
405        return !empty($serviceIds) && self::isValidNumericArray($serviceIds);
406    }
407
408    private static function isValidServiceCount(mixed $count): bool
409    {
410        return is_numeric($count)
411            && (int) $count >= 1
412            && (int) $count <= self::MAX_SERVICE_COUNT
413            && preg_match(self::SERVICE_COUNT_PATTERN, (string) $count) === 1;
414    }
415
416    private static function isValidServiceCounts(?array $serviceCounts): bool
417    {
418        if (empty($serviceCounts) || !is_array($serviceCounts)) {
419            return false;
420        }
421
422        foreach ($serviceCounts as $count) {
423            if (!self::isValidServiceCount($count)) {
424                return false;
425            }
426        }
427
428        return true;
429    }
430
431    private static function isValidTimestamp(?int $timestamp): bool
432    {
433        return !empty($timestamp) && is_numeric($timestamp) && $timestamp > time();
434    }
435
436    private static function isValidEmail(?string $email): bool
437    {
438        return !empty($email) && preg_match(self::EMAIL_PATTERN, $email) === 1;
439    }
440
441    private static function isValidTelephone(?string $telephone): bool
442    {
443        return $telephone === null || preg_match(self::PHONE_PATTERN, $telephone);
444    }
445
446    private static function isValidFamilyName(?string $familyName): bool
447    {
448        return !empty($familyName) && is_string($familyName) && strlen(trim($familyName)) > 0;
449    }
450
451
452    private static function isValidOfficeId(?int $officeId): bool
453    {
454        return !empty($officeId) && $officeId > 0;
455    }
456}