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