Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.84% covered (success)
91.84%
315 / 343
21.74% covered (danger)
21.74%
5 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
ZmsApiFacadeService
91.84% covered (success)
91.84%
315 / 343
21.74% covered (danger)
21.74%
5 / 23
132.36
0.00% covered (danger)
0.00%
0 / 1
 warmOfficesAndServicesCache
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 setMappedCache
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getOffices
96.23% covered (success)
96.23%
51 / 53
0.00% covered (danger)
0.00%
0 / 1
18
 getScopes
97.67% covered (success)
97.67%
42 / 43
0.00% covered (danger)
0.00%
0 / 1
14
 getServices
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
8.15
 getServicesAndOffices
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 rebuildServicesAndOfficesCache
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 isCaptchaRequiredForAnyOffice
87.50% covered (warning)
87.50%
21 / 24
0.00% covered (danger)
0.00%
0 / 1
11.24
 getScopeById
94.87% covered (success)
94.87%
37 / 39
0.00% covered (danger)
0.00%
0 / 1
12.02
 getServicesByOfficeId
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
13.08
 getCalendarAvailability
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
10
 getFreeAppointments
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
7.02
 reserveTimeslot
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 getProcessById
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
5.67
 getThinnedProcessById
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 updateClientData
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 preconfirmAppointment
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 confirmAppointment
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 cancelAppointment
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 sendPreconfirmationEmail
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 sendConfirmationEmail
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 sendCancellationEmail
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getAppointmentsByExternalUserId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace BO\Zmscitizenapi\Services\Core;
6
7use BO\Slim\LoggerService;
8use BO\Zmscitizenapi\Exceptions\UnauthorizedException;
9use BO\Zmscitizenapi\Models\AuthenticatedUser;
10use BO\Zmscitizenapi\Models\AvailableCalendar;
11use BO\Zmscitizenapi\Models\Office;
12use BO\Zmscitizenapi\Models\Service;
13use BO\Zmscitizenapi\Models\ThinnedProcess;
14use BO\Zmscitizenapi\Models\ThinnedScope;
15use BO\Zmscitizenapi\Models\Collections\OfficeList;
16use BO\Zmscitizenapi\Models\Collections\OfficeServiceRelationList;
17use BO\Zmscitizenapi\Models\Collections\OfficeServiceAndRelationList;
18use BO\Zmscitizenapi\Models\Collections\ServiceList;
19use BO\Zmscitizenapi\Models\Collections\ThinnedScopeList;
20use BO\Zmsentities\Collection\RequestRelationList;
21use BO\Zmsentities\Process;
22use BO\Zmsentities\Scope;
23use BO\Zmsentities\Collection\ScopeList;
24use BO\Zmsentities\Collection\ProviderList;
25use BO\Zmsentities\Collection\RequestList;
26use BO\Zmsentities\Collection\ProcessList;
27
28/**
29 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
30 * @TODO: Break down this facade into smaller domain-specific facades or use the Command pattern
31 */
32class ZmsApiFacadeService
33{
34    private const string CACHE_KEY_OFFICES = 'processed_offices';
35    private const string CACHE_KEY_SCOPES = 'processed_scopes';
36    private const string CACHE_KEY_SERVICES = 'processed_services';
37    private const string CACHE_KEY_OFFICES_AND_SERVICES = 'processed_offices_and_services';
38    private const string CACHE_KEY_SERVICES_BY_OFFICE_PREFIX = 'processed_services_by_office_';
39
40    /**
41     * Fetch fresh source data and overwrite the public offices-and-services cache in place.
42     * Existing entries stay readable until the new values are written.
43     *
44     * @return array{result: OfficeServiceAndRelationList|array, refreshedKeys: list<string>}
45     */
46    public static function warmOfficesAndServicesCache(): array
47    {
48        $refreshedKeys = ZmsApiClientService::refreshSourceCaches();
49        $cacheKey = self::CACHE_KEY_OFFICES_AND_SERVICES;
50        $result = self::rebuildServicesAndOfficesCache(false, $cacheKey);
51        $refreshedKeys[] = $cacheKey;
52
53        $refreshedKeys = array_values(array_unique($refreshedKeys));
54        LoggerService::logInfo('Offices-and-services cache warmed', [
55            'refreshedKeys' => $refreshedKeys,
56        ]);
57
58        return [
59            'result' => $result,
60            'refreshedKeys' => $refreshedKeys,
61        ];
62    }
63
64    /**
65     * @param OfficeList|OfficeServiceAndRelationList|ServiceList|ThinnedScopeList $data
66     */
67    private static function setMappedCache(string $cacheKey, OfficeList|ThinnedScopeList|ServiceList|OfficeServiceAndRelationList $data): void
68    {
69        if (\App::$cache) {
70            \App::$cache->set($cacheKey, $data, \App::$SOURCE_CACHE_TTL);
71            LoggerService::logInfo('Second-level cache set', [
72                'key' => $cacheKey,
73                'ttl' => \App::$SOURCE_CACHE_TTL
74            ]);
75        }
76    }
77
78    /**
79     * @SuppressWarnings(PHPMD.NPathComplexity)
80     */
81    public static function getOffices(bool $showUnpublished = false): OfficeList
82    {
83        $cacheKey = self::CACHE_KEY_OFFICES . ($showUnpublished ? '_unpublished' : '');
84
85        if (\App::$cache && ($cachedData = \App::$cache->get($cacheKey))) {
86            return $cachedData;
87        }
88
89        $providerList = ZmsApiClientService::getOffices();
90        $scopeList = ZmsApiClientService::getScopes();
91        $offices = [];
92        $scopeMap = [];
93        foreach ($scopeList as $scope) {
94            if ($scope->getProvider()) {
95                $scopeMap[$scope->getProvider()->source . '_' . $scope->getProvider()->id] = $scope;
96            }
97        }
98
99        foreach ($providerList as $provider) {
100            if (!$showUnpublished && isset($provider->data['public']) && !(bool) $provider->data['public']) {
101                continue;
102            }
103
104            $matchingScope = $scopeMap[$provider->source . '_' . $provider->id] ?? null;
105            $offices[] = new Office(
106                id: (int) $provider->id,
107                name: $provider->displayName ?? $provider->name,
108                address: $provider->data['address'] ?? null,
109                showAlternativeLocations: $provider->data['showAlternativeLocations'] ?? null,
110                displayNameAlternatives: $provider->data['displayNameAlternatives'] ?? [],
111                organization: $provider->data['organization'] ?? null,
112                organizationUnit: $provider->data['organizationUnit'] ?? null,
113                slotTimeInMinutes: $provider->data['slotTimeInMinutes'] ?? null,
114                geo: $provider->data['geo'] ?? null,
115                scope: $matchingScope ? new ThinnedScope(
116                    id: (int) $matchingScope->id,
117                    provider: MapperService::providerToThinnedProvider($provider),
118                    shortName: (string) $matchingScope->getShortName(),
119                    emailFrom: (string) $matchingScope->getEmailFrom(),
120                    emailRequired: (bool) $matchingScope->getEmailRequired(),
121                    telephoneActivated: (bool) $matchingScope->getTelephoneActivated(),
122                    telephoneRequired: (bool) $matchingScope->getTelephoneRequired(),
123                    customTextfieldActivated: (bool) $matchingScope->getCustomTextfieldActivated(),
124                    customTextfieldRequired: (bool) $matchingScope->getCustomTextfieldRequired(),
125                    customTextfieldLabel: $matchingScope->getCustomTextfieldLabel(),
126                    customTextfield2Activated: (bool) $matchingScope->getCustomTextfield2Activated(),
127                    customTextfield2Required: (bool) $matchingScope->getCustomTextfield2Required(),
128                    customTextfield2Label: $matchingScope->getCustomTextfield2Label(),
129                    captchaActivatedRequired: (bool) $matchingScope->getCaptchaActivatedRequired(),
130                    infoForAppointment: $matchingScope->getInfoForAppointment(),
131                    infoForAllAppointments: $matchingScope->getInfoForAllAppointments(),
132                    slotsPerAppointment: ((string) $matchingScope->getSlotsPerAppointment() === '' ? null : (string) $matchingScope->getSlotsPerAppointment()),
133                    appointmentsPerMail: ((string) $matchingScope->getAppointmentsPerMail() === '' ? null : (string) $matchingScope->getAppointmentsPerMail()),
134                    whitelistedMails: ((string) $matchingScope->getWhitelistedMails() === '' ? null : (string) $matchingScope->getWhitelistedMails()),
135                    activationDuration: MapperService::extractActivationDuration($matchingScope),
136                    reservationDuration: (int) MapperService::extractReservationDuration($matchingScope),
137                    hint: ($matchingScope && trim((string) $matchingScope->getScopeHint()) !== '')  ? (string) $matchingScope->getScopeHint() : null
138                ) : null,
139                slotsPerAppointment: $matchingScope ? ((string) $matchingScope->getSlotsPerAppointment() === '' ? null : (string) $matchingScope->getSlotsPerAppointment()) : null
140            );
141        }
142
143        $result = new OfficeList($offices);
144
145        self::setMappedCache($cacheKey, $result);
146
147        return $result;
148    }
149
150    /**
151     * @SuppressWarnings(PHPMD.NPathComplexity)
152     */
153    public static function getScopes(): ThinnedScopeList|array
154    {
155        $cacheKey = self::CACHE_KEY_SCOPES;
156
157        if (\App::$cache && ($cachedData = \App::$cache->get($cacheKey))) {
158            return $cachedData;
159        }
160
161        $providerList = ZmsApiClientService::getOffices();
162        $scopeList = ZmsApiClientService::getScopes();
163        $scopeMap = [];
164        foreach ($scopeList as $scope) {
165            $scopeProvider = $scope->getProvider();
166            if ($scopeProvider && $scopeProvider->id && $scopeProvider->source) {
167                $key = $scopeProvider->source . '_' . $scopeProvider->id;
168                $scopeMap[$key] = $scope;
169            }
170        }
171
172        $scopesProjectionList = [];
173        foreach ($providerList as $provider) {
174            $key = $provider->source . '_' . $provider->id;
175            if (isset($scopeMap[$key])) {
176                $matchingScope = $scopeMap[$key];
177                $scopesProjectionList[] = new ThinnedScope(
178                    id: (int) $matchingScope->id,
179                    provider: MapperService::providerToThinnedProvider($provider),
180                    shortName: (string) $matchingScope->getShortName(),
181                    emailFrom: (string) $matchingScope->getEmailFrom(),
182                    emailRequired: (bool) $matchingScope->getEmailRequired(),
183                    telephoneActivated: (bool) $matchingScope->getTelephoneActivated(),
184                    telephoneRequired: (bool) $matchingScope->getTelephoneRequired(),
185                    customTextfieldActivated: (bool) $matchingScope->getCustomTextfieldActivated(),
186                    customTextfieldRequired: (bool) $matchingScope->getCustomTextfieldRequired(),
187                    customTextfieldLabel: $matchingScope->getCustomTextfieldLabel(),
188                    customTextfield2Activated: (bool) $matchingScope->getCustomTextfield2Activated(),
189                    customTextfield2Required: (bool) $matchingScope->getCustomTextfield2Required(),
190                    customTextfield2Label: $matchingScope->getCustomTextfield2Label(),
191                    captchaActivatedRequired: (bool) $matchingScope->getCaptchaActivatedRequired(),
192                    infoForAppointment: $matchingScope->getInfoForAppointment(),
193                    infoForAllAppointments: $matchingScope->getInfoForAllAppointments(),
194                    slotsPerAppointment: ((string) $matchingScope->getSlotsPerAppointment() === '' ? null : (string) $matchingScope->getSlotsPerAppointment()),
195                    appointmentsPerMail: ((string) $matchingScope->getAppointmentsPerMail() === '' ? null : (string) $matchingScope->getAppointmentsPerMail()),
196                    whitelistedMails: ((string) $matchingScope->getWhitelistedMails() === '' ? null : (string) $matchingScope->getWhitelistedMails()),
197                    reservationDuration: (int) MapperService::extractReservationDuration($matchingScope),
198                    activationDuration: MapperService::extractActivationDuration($matchingScope),
199                    hint: ($matchingScope && trim((string) $matchingScope->getScopeHint()) !== '') ? (string) $matchingScope->getScopeHint() : null
200                );
201            }
202        }
203
204        $result = new ThinnedScopeList($scopesProjectionList);
205
206        self::setMappedCache($cacheKey, $result);
207
208        return $result;
209    }
210
211    public static function getServices(bool $showUnpublished = false): ServiceList|array
212    {
213        $cacheKey = self::CACHE_KEY_SERVICES . ($showUnpublished ? '_unpublished' : '');
214
215        if (\App::$cache && ($cachedData = \App::$cache->get($cacheKey))) {
216            return $cachedData;
217        }
218
219        $requestList = ZmsApiClientService::getServices();
220        $services = [];
221        foreach ($requestList as $request) {
222            $additionalData = $request->getAdditionalData();
223            if (
224                !$showUnpublished
225                && isset($additionalData['public'])
226                && !$additionalData['public']
227            ) {
228                continue;
229            }
230
231            $services[] = new Service(id: (int) $request->getId(), name: $request->getName(), maxQuantity: $additionalData['maxQuantity'] ?? 1);
232        }
233
234        $result = new ServiceList($services);
235
236        self::setMappedCache($cacheKey, $result);
237
238        return $result;
239    }
240
241    public static function getServicesAndOffices(bool $showUnpublished = false): OfficeServiceAndRelationList|array
242    {
243        $cacheKey = self::CACHE_KEY_OFFICES_AND_SERVICES . ($showUnpublished ? '_unpublished' : '');
244
245        if (\App::$cache && ($cachedData = \App::$cache->get($cacheKey))) {
246            return $cachedData;
247        }
248
249        return self::rebuildServicesAndOfficesCache($showUnpublished, $cacheKey);
250    }
251
252    private static function rebuildServicesAndOfficesCache(
253        bool $showUnpublished,
254        string $cacheKey
255    ): OfficeServiceAndRelationList|array {
256        $providerList = ZmsApiClientService::getOffices();
257        $requestList = ZmsApiClientService::getServices();
258        $relationList = ZmsApiClientService::getRequestRelationList();
259
260        $offices = MapperService::mapOfficesWithScope($providerList, $showUnpublished);
261        $services = MapperService::mapServicesWithCombinations(
262            $requestList,
263            $relationList,
264            $showUnpublished
265        );
266        $relations = MapperService::mapRelations($relationList, $showUnpublished);
267
268        $result = new OfficeServiceAndRelationList($offices, $services, $relations);
269
270        self::setMappedCache($cacheKey, $result);
271
272        return $result;
273    }
274
275    /**
276     * One offices + scopes load, then in-memory captcha checks for all office IDs.
277     * Avoids per-office scope reloads that each rebuild a full ThinnedScope.
278     */
279    public static function isCaptchaRequiredForAnyOffice(array $officeIds): bool
280    {
281        $wanted = [];
282        foreach ($officeIds as $officeIdRaw) {
283            $officeId = (int) $officeIdRaw;
284            if ($officeId > 0) {
285                $wanted[$officeId] = true;
286            }
287        }
288        if ($wanted === []) {
289            return false;
290        }
291
292        $providerById = [];
293        foreach (ZmsApiClientService::getOffices() as $provider) {
294            $providerById[(int) $provider->id] = $provider;
295        }
296
297        $scopes = ZmsApiClientService::getScopes();
298        if (!$scopes instanceof ScopeList) {
299            return false;
300        }
301
302        foreach (array_keys($wanted) as $officeId) {
303            $provider = $providerById[$officeId] ?? null;
304            if ($provider === null) {
305                continue;
306            }
307            $source = (string) ($provider->source ?? '');
308            if ($source === '') {
309                continue;
310            }
311
312            $matchingScope = $scopes->withProviderID($source, (string) $officeId)->getIterator()->current();
313            if ($matchingScope instanceof Scope && $matchingScope->getCaptchaActivatedRequired()) {
314                return true;
315            }
316        }
317
318        return false;
319    }
320
321    /**
322     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
323     * @SuppressWarnings(PHPMD.NPathComplexity)
324     */
325    public static function getScopeById(?int $scopeId): ThinnedScope|array
326    {
327        $scopeList = ZmsApiClientService::getScopes();
328        $matchingScope = null;
329        foreach ($scopeList as $scope) {
330            if ((int) $scope->id === (int) $scopeId) {
331                $matchingScope = $scope;
332                break;
333            }
334        }
335
336        $tempScopeList = new ScopeList();
337        if ($matchingScope !== null) {
338            $tempScopeList->addEntity($matchingScope);
339        }
340        $errors = ValidationService::validateScopesNotFound($tempScopeList);
341        if (is_array($errors) && !empty($errors['errors'])) {
342            return $errors;
343        }
344
345        if ($matchingScope === null) {
346            return ValidationService::validateScopesNotFound(new ScopeList());
347        }
348
349        $scopeProvider = $matchingScope->getProvider();
350        return new ThinnedScope(
351            id: (int) $matchingScope->id,
352            provider: $scopeProvider ? MapperService::providerToThinnedProvider($scopeProvider) : null,
353            shortName: (string) $matchingScope->getShortName(),
354            emailFrom: (string) $matchingScope->getEmailFrom(),
355            emailRequired: (bool) $matchingScope->getEmailRequired(),
356            telephoneActivated: (bool) $matchingScope->getTelephoneActivated(),
357            telephoneRequired: (bool) $matchingScope->getTelephoneRequired(),
358            customTextfieldActivated: (bool) $matchingScope->getCustomTextfieldActivated(),
359            customTextfieldRequired: (bool) $matchingScope->getCustomTextfieldRequired(),
360            customTextfieldLabel: $matchingScope->getCustomTextfieldLabel() ?? null,
361            customTextfield2Activated: (bool) $matchingScope->getCustomTextfield2Activated(),
362            customTextfield2Required: (bool) $matchingScope->getCustomTextfield2Required(),
363            customTextfield2Label: $matchingScope->getCustomTextfield2Label() ?? null,
364            captchaActivatedRequired: (bool) $matchingScope->getCaptchaActivatedRequired(),
365            infoForAppointment: $matchingScope->getInfoForAppointment() ?? null,
366            infoForAllAppointments: $matchingScope->getInfoForAllAppointments() ?? null,
367            slotsPerAppointment: ((string) $matchingScope->getSlotsPerAppointment() === '' ? null : (string) $matchingScope->getSlotsPerAppointment()),
368            appointmentsPerMail: ((string) $matchingScope->getAppointmentsPerMail() === '' ? null : (string) $matchingScope->getAppointmentsPerMail()),
369            whitelistedMails: ((string) $matchingScope->getWhitelistedMails() === '' ? null : (string) $matchingScope->getWhitelistedMails()),
370            reservationDuration: MapperService::extractReservationDuration($matchingScope),
371            activationDuration: MapperService::extractActivationDuration($matchingScope),
372            hint: ((string) $matchingScope->getScopeHint() === '' ? null : (string) $matchingScope->getScopeHint())
373        );
374    }
375
376    public static function getServicesByOfficeId(int $officeId, bool $showUnpublished = false): ServiceList|array
377    {
378        $cacheKey = self::CACHE_KEY_SERVICES_BY_OFFICE_PREFIX . $officeId . ($showUnpublished ? '_unpublished' : '');
379
380        if (\App::$cache && ($cachedData = \App::$cache->get($cacheKey))) {
381            return $cachedData;
382        }
383
384        $requestList = ZmsApiClientService::getServices();
385        $requestRelationList = ZmsApiClientService::getRequestRelationList();
386        $requestMap = [];
387        foreach ($requestList as $request) {
388            $additionalData = $request->getAdditionalData();
389            if (
390                !$showUnpublished
391                && isset($additionalData['public'])
392                && !$additionalData['public']
393            ) {
394                continue;
395            }
396
397            $requestMap[$request->id] = $request;
398        }
399
400        $services = [];
401        foreach ($requestRelationList as $relation) {
402            if ((int) $relation->provider->id === $officeId) {
403                $requestId = $relation->request->id;
404                if (isset($requestMap[$requestId])) {
405                    $request = $requestMap[$requestId];
406                    $services[] = new Service(id: (int) $request->id, name: $request->name, maxQuantity: $request->getAdditionalData()['maxQuantity'] ?? 1);
407                }
408            }
409        }
410
411        $errors = ValidationService::validateServicesNotFound($services);
412        if (is_array($errors) && !empty($errors['errors'])) {
413            return $errors;
414        }
415
416        $result = new ServiceList($services);
417
418        self::setMappedCache($cacheKey, $result);
419
420        return $result;
421    }
422
423    public static function getCalendarAvailability(
424        array $officeIds,
425        array $serviceIds,
426        array $serviceCounts,
427        string $startDate,
428        string $endDate,
429        ?string $slotsStartDate = null,
430        ?string $slotsEndDate = null
431    ): AvailableCalendar|array {
432        $params = [
433            'startDate' => $startDate,
434            'endDate' => $endDate,
435            'officeIds' => implode(',', $officeIds),
436            'serviceIds' => implode(',', $serviceIds),
437        ];
438        if ($serviceCounts !== []) {
439            $params['serviceCounts'] = implode(',', $serviceCounts);
440        }
441        if ($slotsStartDate !== null && $slotsStartDate !== '') {
442            $params['slotsStartDate'] = $slotsStartDate;
443        }
444        if ($slotsEndDate !== null && $slotsEndDate !== '') {
445            $params['slotsEndDate'] = $slotsEndDate;
446        }
447
448        $availability = ZmsApiClientService::getCalendarAvailability($params);
449
450        $formattedDays = [];
451        foreach ($availability['days'] ?? [] as $day) {
452            $offices = [];
453            foreach ($day['appointments'] ?? [] as $officeId => $timestamps) {
454                $offices[] = [
455                    'officeId' => (string) $officeId,
456                    'appointments' => array_map('intval', array_values((array) $timestamps)),
457                ];
458            }
459
460            $formattedDays[] = [
461                'date' => (string) ($day['date'] ?? ''),
462                'providerIDs' => (string) ($day['providerIDs'] ?? ''),
463                'offices' => $offices,
464            ];
465        }
466
467        return new AvailableCalendar(
468            (string) ($availability['startDate'] ?? $startDate),
469            (string) ($availability['endDate'] ?? $endDate),
470            $formattedDays,
471            (string) ($availability['slotsStartDate'] ?? $slotsStartDate ?? $startDate),
472            (string) ($availability['slotsEndDate'] ?? $slotsEndDate ?? $endDate),
473            isset($availability['prevBookableDate']) ? (string) $availability['prevBookableDate'] : null,
474            isset($availability['nextBookableDate']) ? (string) $availability['nextBookableDate'] : null
475        );
476    }
477
478    public static function getFreeAppointments(int $officeId, array $serviceIds, array $serviceCounts, array $date): ProcessList|array
479    {
480        $providerList = ZmsApiClientService::getOffices();
481        $requestList  = ZmsApiClientService::getServices();
482
483        $providerSource = [];
484        foreach ($providerList as $p) {
485            $providerSource[(string)$p->id] = (string)($p->source ?? '');
486        }
487        $requestSource  = [];
488        foreach ($requestList as $r) {
489            $requestSource[(string)$r->id] = (string)($r->source ?? '');
490        }
491
492        $oid = (string)$officeId;
493        $provSrc = $providerSource[$oid] ?? null;
494        if (!$provSrc) {
495            return ['errors' => [['message' => 'Unknown provider source for ID ' . $oid]]];
496        }
497
498        $office = ['id' => $officeId, 'source' => $provSrc];
499
500        $requests = [];
501        foreach ($serviceIds as $id => $serviceId) {
502            $sid   = (string)$serviceId;
503            $reqSrc = $requestSource[$sid] ?? null;
504            if (!$reqSrc) {
505                return ['errors' => [['message' => 'Unknown service source for ID ' . $sid]]];
506            }
507            $count = (int)($serviceCounts[$id] ?? 1);
508            for ($k = 0; $k < $count; $k++) {
509                $requests[] = ['id' => $serviceId, 'source' => $reqSrc];
510            }
511        }
512
513        return ZmsApiClientService::getFreeTimeslots(
514            new ProviderList([$office]),
515            new RequestList($requests),
516            $date,
517            $date
518        );
519    }
520
521    public static function reserveTimeslot(Process $appointmentProcess, array $serviceIds, array $serviceCounts): ThinnedProcess|array
522    {
523        $errors = ValidationService::validateServiceArrays($serviceIds, $serviceCounts);
524        if (!empty($errors)) {
525            return $errors;
526        }
527        $process = ZmsApiClientService::reserveTimeslot($appointmentProcess, $serviceIds, $serviceCounts);
528        return MapperService::processToThinnedProcess($process);
529    }
530
531    public static function getProcessById(?int $processId, ?string $authKey, ?AuthenticatedUser $user): Process
532    {
533        // AuthKey check needs to be first
534        if (!is_null($authKey)) {
535            return ZmsApiClientService::getProcessById($processId, $authKey);
536        } elseif (!is_null($user)) {
537            $externalUserId = $user->getExternalUserId();
538            return ZmsApiClientService::getProcessByIdAuthenticated($processId, $externalUserId);
539        } else {
540            throw new UnauthorizedException();
541        }
542    }
543
544    /**
545     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
546     * @SuppressWarnings(PHPMD.NPathComplexity)
547     */
548    public static function getThinnedProcessById(int $processId, ?string $authKey, ?AuthenticatedUser $user): ThinnedProcess|array
549    {
550        $process = self::getProcessById($processId, $authKey, $user);
551        $errors = ValidationService::validateGetProcessNotFound($process);
552        if (is_array($errors) && !empty($errors['errors'])) {
553            return $errors;
554        }
555        return MapperService::processToThinnedProcess($process);
556    }
557
558    public static function updateClientData(Process $reservedProcess): Process|array
559    {
560        $clientUpdateResult = ZmsApiClientService::submitClientData($reservedProcess);
561        if (isset($clientUpdateResult['error'])) {
562            return $clientUpdateResult;
563        }
564        return $clientUpdateResult;
565    }
566
567    public static function preconfirmAppointment(Process $reservedProcess): Process|array
568    {
569        $clientUpdateResult = ZmsApiClientService::preconfirmProcess($reservedProcess);
570        if (isset($clientUpdateResult['error'])) {
571            return $clientUpdateResult;
572        }
573        return $clientUpdateResult;
574    }
575
576    public static function confirmAppointment(Process $preconfirmedProcess): Process|array
577    {
578        $clientUpdateResult = ZmsApiClientService::confirmProcess($preconfirmedProcess);
579        if (isset($clientUpdateResult['error'])) {
580            return $clientUpdateResult;
581        }
582        return $clientUpdateResult;
583    }
584
585    public static function cancelAppointment(Process $confirmedProcess): Process|array
586    {
587        $clientUpdateResult = ZmsApiClientService::cancelAppointment($confirmedProcess);
588        if (isset($clientUpdateResult['error'])) {
589            return $clientUpdateResult;
590        }
591        return $clientUpdateResult;
592    }
593
594    public static function sendPreconfirmationEmail(Process $reservedProcess): Process|array
595    {
596        $clientUpdateResult = ZmsApiClientService::sendPreconfirmationEmail($reservedProcess);
597        if (isset($clientUpdateResult['error'])) {
598            return $clientUpdateResult;
599        }
600        return $clientUpdateResult;
601    }
602
603    public static function sendConfirmationEmail(Process $preconfirmedProcess): Process|array
604    {
605        $clientUpdateResult = ZmsApiClientService::sendConfirmationEmail($preconfirmedProcess);
606        if (isset($clientUpdateResult['error'])) {
607            return $clientUpdateResult;
608        }
609        return $clientUpdateResult;
610    }
611
612    public static function sendCancellationEmail(Process $confirmedProcess): Process|array
613    {
614        $clientUpdateResult = ZmsApiClientService::sendCancellationEmail($confirmedProcess);
615        if (isset($clientUpdateResult['error'])) {
616            return $clientUpdateResult;
617        }
618        return $clientUpdateResult;
619    }
620
621    public static function getAppointmentsByExternalUserId(string $externalUserId, ?int $filterId = null, ?string $status = null): ProcessList
622    {
623        return ZmsApiClientService::getProcessesByExternalUserId($externalUserId, $filterId, $status);
624    }
625}