Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.22% covered (success)
90.22%
166 / 184
88.37% covered (warning)
88.37%
38 / 43
CRAP
0.00% covered (danger)
0.00%
0 / 1
ValidationService
90.22% covered (success)
90.22%
166 / 184
88.37% covered (warning)
88.37%
38 / 43
172.35
0.00% covered (danger)
0.00%
0 / 1
 setLanguageContext
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
9.32
 validateCaptcha
21.43% covered (danger)
21.43%
3 / 14
0.00% covered (danger)
0.00%
0 / 1
30.77
 validateGetBookableFreeDays
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
14.27
 validateGetProcessById
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 validateGetAvailableAppointments
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 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%
5 / 5
100.00% covered (success)
100.00%
1 / 1
8
 validateGetScopeById
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validateGetServicesByOfficeId
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validateGetOfficeListByServiceId
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validateGetProcessFreeSlots
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 validateGetProcessByIdTimestamps
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 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
4
 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
 validateAppointmentDaysNotFound
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
9
 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
 isValidScopeId
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%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 isValidServiceIds
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isValidServiceCounts
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
7
 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
 isValidCustomTextfield
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
 isValidServiceId
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\Localization\ErrorMessages;
8use BO\Zmscitizenapi\Models\ThinnedScope;
9use BO\Zmscitizenapi\Services\Core\ZmsApiFacadeService;
10use BO\Zmscitizenapi\Services\Captcha\TokenValidationService;
11use BO\Zmsentities\Process;
12use BO\Zmsentities\Collection\ProcessList;
13use BO\Zmsentities\Collection\ScopeList;
14use DateTime;
15use Psr\Http\Message\ServerRequestInterface;
16
17/**
18 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
19 * @TODO: Split this service into domain-specific validation services
20 */
21class ValidationService
22{
23    private static ?string $currentLanguage = null;
24    private const DATE_FORMAT = 'Y-m-d';
25    private const MIN_PROCESS_ID = 1;
26    private const PHONE_PATTERN = '/^\+?[0-9]\d{6,14}$/';
27    private const SERVICE_COUNT_PATTERN = '/^\d+$/';
28    private const MAX_FUTURE_DAYS = 365;
29    // Maximum days in the future for appointments
30
31    public static function setLanguageContext(?string $language): void
32    {
33        self::$currentLanguage = $language;
34    }
35
36    private static function getError(string $key): array
37    {
38        return ErrorMessages::get($key, self::$currentLanguage);
39    }
40
41    public static function validateServerGetRequest(?ServerRequestInterface $request): array
42    {
43        if (!$request instanceof ServerRequestInterface) {
44            return ['errors' => [self::getError('invalidRequest')]];
45        }
46
47        if ($request->getMethod() !== "GET") {
48            return ['errors' => [self::getError('invalidRequest')]];
49        }
50
51        return [];
52    }
53
54    public static function validateServerPostRequest(?ServerRequestInterface $request): array
55    {
56        if (!$request instanceof ServerRequestInterface) {
57            return ['errors' => [self::getError('invalidRequest')]];
58        }
59
60        if ($request->getMethod() !== "POST") {
61            return ['errors' => [self::getError('invalidRequest')]];
62        }
63
64        if ($request->getParsedBody() === null) {
65            return ['errors' => [self::getError('invalidRequest')]];
66        }
67
68        return [];
69    }
70
71    public static function validateServiceLocationCombination(int $officeId, array $serviceIds): array
72    {
73        static $officeServicesCache = [];
74
75        if ($officeId <= 0) {
76            return ['errors' => [self::getError('invalidOfficeId')]];
77        }
78
79        if (empty($serviceIds) || !self::isValidNumericArray($serviceIds)) {
80            return ['errors' => [self::getError('invalidServiceId')]];
81        }
82
83        if (!isset($officeServicesCache[$officeId])) {
84            $serviceList = ZmsApiFacadeService::getServicesByOfficeId($officeId);
85            $ids = [];
86            if (is_array($serviceList) && isset($serviceList['errors'])) {
87                $officeServicesCache[$officeId] = [];
88            } else {
89                foreach ($serviceList->services as $service) {
90                    $ids[] = (string)$service->id;
91                }
92                $officeServicesCache[$officeId] = $ids;
93            }
94        }
95        $availableServiceIds = $officeServicesCache[$officeId];
96
97        $serviceIdsStr = array_map('strval', $serviceIds);
98        $invalidServiceIds = array_diff($serviceIdsStr, $availableServiceIds);
99        return empty($invalidServiceIds)
100            ? []
101            : ['errors' => [self::getError('invalidLocationAndServiceCombination')]];
102    }
103
104    private static function validateCaptcha(bool $captchaRequired, ?string $captchaToken, ?TokenValidationService $tokenValidator): array
105    {
106        $errors = [];
107
108        if ($captchaRequired) {
109            if (!$tokenValidator) {
110                $status = TokenValidationService::TOKEN_MISSING;
111            } else {
112                $status = $tokenValidator->validateCaptchaToken($captchaToken);
113            }
114
115            if ($status !== TokenValidationService::TOKEN_VALID) {
116                switch ($status) {
117                    case TokenValidationService::TOKEN_MISSING:
118                        $errors[] = self::getError('captchaMissing');
119                        break;
120                    case TokenValidationService::TOKEN_EXPIRED:
121                        $errors[] = self::getError('captchaExpired');
122                        break;
123                    default:
124                        $errors[] = self::getError('captchaInvalid');
125                }
126            }
127        }
128
129        return $errors;
130    }
131
132    /**
133     * @SuppressWarnings(PHPMD.NPathComplexity)
134     * @TODO: Extract validation rules into separate rule objects using the specification pattern
135     */
136    public static function validateGetBookableFreeDays(?array $officeIds, ?array $serviceIds, ?string $startDate, ?string $endDate, ?array $serviceCounts, ?bool $captchaRequired = false, ?string $captchaToken = null, ?TokenValidationService $tokenValidator = null): array
137    {
138        $errors = [];
139        if (!self::isValidOfficeIds($officeIds)) {
140            $errors[] = self::getError('invalidOfficeId');
141        }
142
143        if (!self::isValidServiceIds($serviceIds)) {
144            $errors[] = self::getError('invalidServiceId');
145        }
146
147        if (!$startDate || !self::isValidDate($startDate)) {
148            $errors[] = self::getError('invalidStartDate');
149        }
150
151        if (!$endDate || !self::isValidDate($endDate)) {
152            $errors[] = self::getError('invalidEndDate');
153        }
154
155        if ($startDate && $endDate && self::isValidDate($startDate) && self::isValidDate($endDate)) {
156            if (new DateTime($startDate) > new DateTime($endDate)) {
157                $errors[] = self::getError('startDateAfterEndDate');
158            }
159
160            if (!self::isDateRangeValid($startDate, $endDate)) {
161                $errors[] = self::getError('dateRangeTooLarge');
162            }
163        }
164
165        if (!self::isValidServiceCounts($serviceCounts)) {
166            $errors[] = self::getError('invalidServiceCount');
167        }
168
169        $errors = array_merge($errors, self::validateCaptcha($captchaRequired, $captchaToken, $tokenValidator));
170
171        return ['errors' => $errors];
172    }
173
174    public static function validateGetProcessById(?int $processId, ?string $authKey): array
175    {
176        $errors = [];
177        if (!self::isValidProcessId($processId)) {
178            $errors[] = self::getError('invalidProcessId');
179        }
180
181        if (!self::isValidAuthKey($authKey)) {
182            $errors[] = self::getError('invalidAuthKey');
183        }
184
185        return ['errors' => $errors];
186    }
187
188    public static function validateGetAvailableAppointments(?string $date, ?array $officeIds, ?array $serviceIds, ?array $serviceCounts, ?bool $captchaRequired = false, ?string $captchaToken = null, ?TokenValidationService $tokenValidator = null): array
189    {
190        $errors = [];
191        if (!$date || !self::isValidDate($date)) {
192            $errors[] = self::getError('invalidDate');
193        }
194
195        if (!self::isValidOfficeIds($officeIds)) {
196            $errors[] = self::getError('invalidOfficeId');
197        }
198
199        if (!self::isValidServiceIds($serviceIds)) {
200            $errors[] = self::getError('invalidServiceId');
201        }
202
203        if (!self::isValidServiceCounts($serviceCounts)) {
204            $errors[] = self::getError('invalidServiceCount');
205        }
206
207        $errors = array_merge($errors, self::validateCaptcha($captchaRequired, $captchaToken, $tokenValidator));
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    private static function validateFamilyNameField(?string $familyName, array &$errors): void
256    {
257        if (!self::isValidFamilyName($familyName)) {
258            $errors[] = self::getError('invalidFamilyName');
259        }
260    }
261
262    private static function validateEmailField(?string $email, ?ThinnedScope $scope, array &$errors): void
263    {
264        if ($scope && $scope->emailRequired && ($email === "" || !self::isValidEmail($email))) {
265            $errors[] = self::getError('invalidEmail');
266        }
267    }
268
269    private static function validateTelephoneField(?string $telephone, ?ThinnedScope $scope, array &$errors): void
270    {
271        if (!$scope || !$scope->telephoneActivated) {
272            return;
273        }
274
275        if (
276            ($scope->telephoneRequired && ($telephone === "" || !self::isValidTelephone($telephone))) ||
277            ($telephone !== null && $telephone !== "" && !self::isValidTelephone($telephone))
278        ) {
279            $errors[] = self::getError('invalidTelephone');
280        }
281    }
282
283    private static function validateCustomTextField(?string $fieldValue, ?bool $fieldActivated, ?bool $fieldRequired, string $errorKey, array &$errors): void
284    {
285        if (!$fieldActivated) {
286            return;
287        }
288
289        if (
290            ($fieldRequired && ($fieldValue === "" || !self::isValidCustomTextfield($fieldValue))) ||
291            ($fieldValue !== null && $fieldValue !== "" && !self::isValidCustomTextfield($fieldValue))
292        ) {
293            $errors[] = self::getError($errorKey);
294        }
295    }
296
297    public static function validateGetScopeById(?int $scopeId): array
298    {
299        return !self::isValidScopeId($scopeId)
300            ? ['errors' => [self::getError('invalidScopeId')]]
301            : [];
302    }
303
304    public static function validateGetServicesByOfficeId(?int $officeId): array
305    {
306        return !self::isValidOfficeId($officeId)
307            ? ['errors' => [self::getError('invalidOfficeId')]]
308            : [];
309    }
310
311    public static function validateGetOfficeListByServiceId(?int $serviceId): array
312    {
313        return !self::isValidServiceId($serviceId)
314            ? ['errors' => [self::getError('invalidServiceId')]]
315            : [];
316    }
317
318    public static function validateGetProcessFreeSlots(?ProcessList $freeSlots): array
319    {
320        return empty($freeSlots) || !is_iterable($freeSlots)
321            ? ['errors' => [self::getError('appointmentNotAvailable')]]
322            : [];
323    }
324
325    public static function validateGetProcessByIdTimestamps(?array $appointmentTimestamps): array
326    {
327        return empty($appointmentTimestamps)
328            ? ['errors' => [self::getError('appointmentNotAvailable')]]
329            : [];
330    }
331
332    public static function validateGetProcessNotFound(?Process $process): array
333    {
334        return !$process
335            ? ['errors' => [self::getError('appointmentNotAvailable')]]
336            : [];
337    }
338
339    public static function validateScopesNotFound(?ScopeList $scopes): array
340    {
341        return empty($scopes) || $scopes === null || $scopes->count() === 0
342            ? ['errors' => [self::getError('scopesNotFound')]]
343            : [];
344    }
345
346    public static function validateServicesNotFound(?array $services): array
347    {
348        return empty($services)
349            ? ['errors' => [self::getError('requestNotFound')]]
350            : [];
351    }
352
353    public static function validateOfficesNotFound(?array $offices): array
354    {
355        return empty($offices)
356            ? ['errors' => [self::getError('providerNotFound')]]
357            : [];
358    }
359
360    public static function validateAppointmentDaysNotFound(?array $formattedDays): array
361    {
362        return empty($formattedDays)
363            ? ['errors' => [self::getError('noAppointmentForThisDay')]]
364            : [];
365    }
366
367    public static function validatenoAppointmentForThisScope(): array
368    {
369        return ['errors' => [self::getError('noAppointmentForThisScope')]];
370    }
371
372    public static function validateServiceArrays(array $serviceIds, array $serviceCounts): array
373    {
374        $errors = [];
375        if (empty($serviceIds) || empty($serviceCounts)) {
376            $errors[] = self::getError('emptyServiceArrays');
377        }
378
379        if (count($serviceIds) !== count($serviceCounts)) {
380            $errors[] = self::getError('mismatchedArrays');
381        }
382
383        foreach ($serviceIds as $id) {
384            if (!is_numeric($id)) {
385                $errors[] = self::getError('invalidServiceId');
386                break;
387            }
388        }
389
390        foreach ($serviceCounts as $count) {
391            if (!is_numeric($count) || $count < 0) {
392                $errors[] = self::getError('invalidServiceCount');
393                break;
394            }
395        }
396
397        return $errors;
398    }
399
400    /*  Helper methods for validation */
401    private static function isValidDate(string $date): bool
402    {
403        $dateTime = DateTime::createFromFormat(self::DATE_FORMAT, $date);
404        return $dateTime && $dateTime->format(self::DATE_FORMAT) === $date;
405    }
406
407    private static function isDateRangeValid(string $startDate, string $endDate): bool
408    {
409        $start = new DateTime($startDate);
410        $end = new DateTime($endDate);
411        $diff = $start->diff($end);
412        return $diff->days <= self::MAX_FUTURE_DAYS;
413    }
414
415    private static function isValidNumericArray(array $array): bool
416    {
417        return !empty($array) && array_filter($array, 'is_numeric') === $array;
418    }
419
420    private static function isValidOfficeIds(?array $officeIds): bool
421    {
422        return !empty($officeIds) && self::isValidNumericArray($officeIds);
423    }
424
425    private static function isValidScopeId(?int $scopeId): bool
426    {
427        return !empty($scopeId) && $scopeId > 0;
428    }
429
430    private static function isValidProcessId(?int $processId): bool
431    {
432        return !empty($processId) && $processId >= self::MIN_PROCESS_ID;
433    }
434
435    private static function isValidAuthKey(?string $authKey): bool
436    {
437        return !empty($authKey) && is_string($authKey) && strlen(trim($authKey)) > 0;
438    }
439
440    private static function isValidServiceIds(?array $serviceIds): bool
441    {
442        return !empty($serviceIds) && self::isValidNumericArray($serviceIds);
443    }
444
445    private static function isValidServiceCounts(?array $serviceCounts): bool
446    {
447        if (empty($serviceCounts) || !is_array($serviceCounts)) {
448            return false;
449        }
450
451        foreach ($serviceCounts as $count) {
452            if (!is_numeric($count) || $count < 0 || !preg_match(self::SERVICE_COUNT_PATTERN, (string) $count)) {
453                return false;
454            }
455        }
456
457        return true;
458    }
459
460    private static function isValidTimestamp(?int $timestamp): bool
461    {
462        return !empty($timestamp) && is_numeric($timestamp) && $timestamp > time();
463    }
464
465    private static function isValidEmail(?string $email): bool
466    {
467        return !empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
468    }
469
470    private static function isValidTelephone(?string $telephone): bool
471    {
472        return $telephone === null || preg_match(self::PHONE_PATTERN, $telephone);
473    }
474
475    private static function isValidFamilyName(?string $familyName): bool
476    {
477        return !empty($familyName) && is_string($familyName) && strlen(trim($familyName)) > 0;
478    }
479
480    private static function isValidCustomTextfield(?string $customTextfield): bool
481    {
482        return $customTextfield === null || (is_string($customTextfield) && strlen(trim($customTextfield)) > 0);
483    }
484
485    private static function isValidOfficeId(?int $officeId): bool
486    {
487        return !empty($officeId) && $officeId > 0;
488    }
489
490    private static function isValidServiceId(?int $serviceId): bool
491    {
492        return !empty($serviceId) && $serviceId > 0;
493    }
494}