Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.94% covered (warning)
79.94%
259 / 324
50.00% covered (danger)
50.00%
12 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
ZmsApiClientService
79.94% covered (warning)
79.94%
259 / 324
50.00% covered (danger)
50.00%
12 / 24
216.10
0.00% covered (danger)
0.00%
0 / 1
 getMergedMailTemplates
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
156
 getIcsContent
50.00% covered (danger)
50.00%
6 / 12
0.00% covered (danger)
0.00%
0 / 1
4.12
 getOffices
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
5.01
 getServices
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
5.01
 getRequestRelationList
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
5.01
 getScopes
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
7
 getFreeTimeslots
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 getCalendarAvailability
47.37% covered (danger)
47.37%
9 / 19
0.00% covered (danger)
0.00%
0 / 1
6.33
 reserveTimeslot
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
8
 submitClientData
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 preconfirmProcess
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 confirmProcess
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 cancelAppointment
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 sendConfirmationEmail
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 sendPreconfirmationEmail
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 sendCancellationEmail
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getProcessById
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getProcessByIdAuthenticated
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 getScopesByProviderId
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 forEachAvailableSource
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 fetchSourceDataFor
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
8
 isSourceNotFoundException
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 getSourceNames
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
7.23
 getProcessesByExternalUserId
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
5.20
1<?php
2
3declare(strict_types=1);
4
5namespace BO\Zmscitizenapi\Services\Core;
6
7use BO\Slim\LoggerService;
8use BO\Zmscitizenapi\Utils\ClientIpHelper;
9use BO\Zmsentities\Calendar;
10use BO\Zmsentities\Process;
11use BO\Zmsentities\Source;
12use BO\Zmsentities\Collection\ProcessList;
13use BO\Zmsentities\Collection\ProviderList;
14use BO\Zmsentities\Collection\RequestList;
15use BO\Zmsentities\Collection\RequestRelationList;
16use BO\Zmsentities\Collection\ScopeList;
17
18/**
19 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
20 */
21class ZmsApiClientService
22{
23    public static function getMergedMailTemplates(int $providerId): array
24    {
25        try {
26            $cacheKey = 'merged_mailtemplates_' . $providerId;
27            if (\App::$cache && ($cached = \App::$cache->get($cacheKey))) {
28                return is_array($cached) ? $cached : [];
29            }
30            $result = \App::$http->readGetResult('/merged-mailtemplates/' . $providerId . '/');
31            $templates = $result?->getCollection();
32            if (!is_iterable($templates)) {
33                return [];
34            }
35            $out = [];
36            foreach ($templates as $template) {
37                $name = is_array($template) ? ($template['name'] ?? null) : ($template->name ?? null);
38                $value = is_array($template) ? ($template['value'] ?? null) : ($template->value ?? null);
39                if ($name !== null && $value !== null) {
40                    $out[(string)$name] = (string)$value;
41                }
42            }
43            if (\App::$cache) {
44                \App::$cache->set($cacheKey, $out, \App::$SOURCE_CACHE_TTL);
45                LoggerService::logInfo('Cache set', [
46                    'key' => $cacheKey,
47                    'ttl' => \App::$SOURCE_CACHE_TTL,
48                    'entity_type' => 'merged_mail_templates'
49                ]);
50            }
51            return $out;
52        } catch (\Exception $e) {
53            ExceptionService::handleException($e);
54        }
55    }
56
57    public static function getIcsContent(int $processId, string $authKey): ?string
58    {
59        try {
60            $url = "/process/{$processId}/{$authKey}/ics/";
61            $result = \App::$http->readGetResult($url);
62            $entity = $result?->getEntity();
63            if ($entity instanceof \BO\Zmsentities\Ics) {
64                return $entity->getContent() ?? null;
65            }
66            return null;
67        } catch (\Exception $e) {
68            // Do not fail the user flow if ICS is unavailable; just log and return null
69            LoggerService::logError($e, null, null, [
70                'processId' => $processId,
71                'context' => 'ICS fetch via API'
72            ]);
73            return null;
74        }
75    }
76    public static function getOffices(): ProviderList
77    {
78        try {
79            $combined = new ProviderList();
80            $seen = [];
81
82            self::forEachAvailableSource(function (Source $src) use ($combined, &$seen): void {
83                $list = $src->getProviderList();
84                if (!$list instanceof ProviderList) {
85                    return;
86                }
87                foreach ($list as $provider) {
88                    $key = (($provider->source ?? '') . '_' . $provider->id);
89                    if (!isset($seen[$key])) {
90                        $combined->addEntity($provider);
91                        $seen[$key] = true;
92                    }
93                }
94            });
95
96            return $combined;
97        } catch (\Exception $e) {
98            ExceptionService::handleException($e);
99        }
100    }
101
102    public static function getServices(): RequestList
103    {
104        try {
105            $combined = new RequestList();
106            $seen = [];
107
108            self::forEachAvailableSource(function (Source $src) use ($combined, &$seen): void {
109                $list = $src->getRequestList();
110                if (!$list instanceof RequestList) {
111                    return;
112                }
113                foreach ($list as $request) {
114                    $key = (($request->source ?? '') . '_' . $request->id);
115                    if (!isset($seen[$key])) {
116                        $combined->addEntity($request);
117                        $seen[$key] = true;
118                    }
119                }
120            });
121
122            return $combined;
123        } catch (\Exception $e) {
124            ExceptionService::handleException($e);
125        }
126    }
127
128    public static function getRequestRelationList(): RequestRelationList
129    {
130        try {
131            $combined = new RequestRelationList();
132            $seen = [];
133
134            self::forEachAvailableSource(function (Source $src) use ($combined, &$seen): void {
135                $list = $src->getRequestRelationList();
136                if (!$list instanceof RequestRelationList) {
137                    return;
138                }
139                foreach ($list as $rel) {
140                    $r = $rel->request ?? null;
141                    $p = $rel->provider ?? null;
142
143                    $key = (($r->source ?? '') . '_' . $r->id) . '|' . (($p->source ?? '') . '_' . $p->id);
144                    if (!isset($seen[$key])) {
145                        $combined->addEntity($rel);
146                        $seen[$key] = true;
147                    }
148                }
149            });
150
151            return $combined;
152        } catch (\Exception $e) {
153            ExceptionService::handleException($e);
154        }
155    }
156
157    public static function getScopes(): ScopeList
158    {
159        try {
160            $combined = new ScopeList();
161            $seen = [];
162
163            self::forEachAvailableSource(function (Source $src) use ($combined, &$seen): void {
164                $providerMap = [];
165                foreach ($src->getProviderList() as $provider) {
166                    $providerMap[($provider->source ?? '') . '_' . $provider->id] = $provider;
167                }
168                $list = $src->getScopeList();
169                if (!$list instanceof ScopeList) {
170                    return;
171                }
172                foreach ($list as $scope) {
173                    $prov = $scope->getProvider();
174                    $key = (($prov->source ?? '') . '_' . $prov->id);
175                    if (isset($providerMap[$key])) {
176                        $scope->provider = $providerMap[$key];
177                    }
178                    if (!isset($seen[$key])) {
179                        $combined->addEntity($scope);
180                        $seen[$key] = true;
181                    }
182                }
183            });
184
185            return $combined;
186        } catch (\Exception $e) {
187            ExceptionService::handleException($e);
188        }
189    }
190
191    public static function getFreeTimeslots(ProviderList $providers, RequestList $requests, array $firstDay, array $lastDay): ProcessList
192    {
193        try {
194            $calendar = new Calendar();
195            $calendar->firstDay = $firstDay;
196            $calendar->lastDay = $lastDay;
197            $calendar->providers = $providers;
198            $calendar->requests = $requests;
199            $result = \App::$http->readPostResult('/process/status/free/unique/', $calendar);
200            $collection = $result?->getCollection();
201            if (!$collection instanceof ProcessList) {
202                return new ProcessList();
203            }
204
205            return $collection;
206        } catch (\Exception $e) {
207            ExceptionService::handleException($e);
208        }
209    }
210
211    /**
212     * @return array{startDate: string, endDate: string, days: array<int, array<string, mixed>>}
213     */
214    public static function getCalendarAvailability(array $params): array
215    {
216        try {
217            $result = \App::$http->readGetResult('/calendar/availability/', $params);
218            if ($result === null) {
219                return [
220                    'startDate' => '',
221                    'endDate' => '',
222                    'days' => [],
223                ];
224            }
225            $rawBody = (string) $result->getResponse()->getBody();
226            $body = json_decode($rawBody, true);
227            $data = $body['data'] ?? null;
228
229            if (!is_array($data)) {
230                return [
231                    'startDate' => '',
232                    'endDate' => '',
233                    'days' => [],
234                ];
235            }
236
237            return $data;
238        } catch (\Exception $e) {
239            ExceptionService::handleException($e);
240        }
241    }
242
243    public static function reserveTimeslot(Process $appointmentProcess, array $serviceIds, array $serviceCounts): Process
244    {
245        try {
246            $requestList = self::getServices();
247            $requestSource = [];
248            foreach ($requestList as $r) {
249                $requestSource[(string)$r->id] = (string)($r->source ?? '');
250            }
251
252            $requests = [];
253            foreach ($serviceIds as $index => $serviceId) {
254                $sid = (string)$serviceId;
255                $src = $requestSource[$sid] ?? null;
256                if (!$src) {
257                    return new Process();
258                }
259                $count = (int)($serviceCounts[$index] ?? 1);
260                for ($i = 0; $i < $count; $i++) {
261                    $requests[] = ['id' => $serviceId, 'source' => $src];
262                }
263            }
264
265            $processEntity = new Process();
266            $processEntity->appointments = $appointmentProcess->appointments ?? [];
267            $processEntity->authKey = $appointmentProcess->authKey ?? null;
268            $processEntity->clients = $appointmentProcess->clients ?? [];
269            $processEntity->scope = $appointmentProcess->scope ?? null;
270            $processEntity->requests = $requests;
271            $processEntity->lastChange = $appointmentProcess->lastChange ?? time();
272            $processEntity->createIP = ClientIpHelper::getClientIp();
273            $processEntity->createTimestamp = time();
274            if (isset($appointmentProcess->queue)) {
275                $processEntity->queue = $appointmentProcess->queue;
276            }
277
278            $result = \App::$http->readPostResult('/process/status/reserved/', $processEntity, [
279                'resolveReferences' => 2,
280            ]);
281            $entity = $result?->getEntity();
282            return $entity instanceof Process ? $entity : new Process();
283        } catch (\Exception $e) {
284            ExceptionService::handleException($e);
285        }
286    }
287
288    public static function submitClientData(Process $process): Process
289    {
290        try {
291            $url = '/process/' . $process->getId() . '/' . $process->getAuthKey() . '/';
292            $result = \App::$http->readPostResult($url, $process);
293            $entity = $result?->getEntity();
294            if (!$entity instanceof Process) {
295                return new Process();
296            }
297            return $entity;
298        } catch (\Exception $e) {
299            ExceptionService::handleException($e);
300        }
301    }
302
303    public static function preconfirmProcess(Process $process): Process
304    {
305        try {
306            $url = '/process/status/preconfirmed/';
307            $result = \App::$http->readPostResult($url, $process);
308            $entity = $result?->getEntity();
309            if (!$entity instanceof Process) {
310                return new Process();
311            }
312            return $entity;
313        } catch (\Exception $e) {
314            ExceptionService::handleException($e);
315        }
316    }
317
318    public static function confirmProcess(Process $process): Process
319    {
320        try {
321            $url = '/process/status/confirmed/';
322            $result = \App::$http->readPostResult($url, $process);
323            $entity = $result?->getEntity();
324            if (!$entity instanceof Process) {
325                return new Process();
326            }
327            return $entity;
328        } catch (\Exception $e) {
329            ExceptionService::handleException($e);
330        }
331    }
332
333    public static function cancelAppointment(Process $process): Process
334    {
335        try {
336            $url = '/process/' . $process->getId() . '/' . $process->getAuthKey() . '/';
337            $result = \App::$http->readDeleteResult($url, []);
338            $entity = $result?->getEntity();
339            if (!$entity instanceof Process) {
340                return new Process();
341            }
342            return $entity;
343        } catch (\Exception $e) {
344            ExceptionService::handleException($e);
345        }
346    }
347
348    public static function sendConfirmationEmail(Process $process): Process
349    {
350        try {
351            $url = '/process/' . $process->getId() . '/' . $process->getAuthKey() . '/confirmation/mail/';
352            $result = \App::$http->readPostResult($url, $process);
353            $entity = $result?->getEntity();
354            if (!$entity instanceof Process) {
355                return new Process();
356            }
357            return $entity;
358        } catch (\Exception $e) {
359            ExceptionService::handleException($e);
360        }
361    }
362
363    public static function sendPreconfirmationEmail(Process $process): Process
364    {
365        try {
366            $url = '/process/' . $process->getId() . '/' . $process->getAuthKey() . '/preconfirmation/mail/';
367            $result = \App::$http->readPostResult($url, $process);
368            $entity = $result?->getEntity();
369            if (!$entity instanceof Process) {
370                return new Process();
371            }
372            return $entity;
373        } catch (\Exception $e) {
374            ExceptionService::handleException($e);
375        }
376    }
377
378    public static function sendCancellationEmail(Process $process): Process
379    {
380        try {
381            $url = '/process/' . $process->getId() . '/' . $process->getAuthKey() . '/delete/mail/';
382            $result = \App::$http->readPostResult($url, $process);
383            $entity = $result?->getEntity();
384            if (!$entity instanceof Process) {
385                return new Process();
386            }
387            return $entity;
388        } catch (\Exception $e) {
389            ExceptionService::handleException($e);
390        }
391    }
392
393    public static function getProcessById(int $processId, string $authKey): Process
394    {
395        try {
396            $resolveReferences = 2;
397            $result = \App::$http->readGetResult("/process/{$processId}/{$authKey}/", [
398                'resolveReferences' => $resolveReferences
399            ]);
400            $entity = $result?->getEntity();
401            if (!$entity instanceof Process) {
402                return new Process();
403            }
404            return $entity;
405        } catch (\Exception $e) {
406            ExceptionService::handleException($e);
407        }
408    }
409
410    /**
411     * Load a process for a citizen authenticated via JWT (validated in zmscitizenapi).
412     * Calls zmsbackend ProcessGetByExternalUserId â€” not WorkstationProcessGet â€” so access
413     * is limited to processes owned by the given external user id (GH-1582).
414     */
415    public static function getProcessByIdAuthenticated(int $processId, string $externalUserId): Process
416    {
417        try {
418            $resolveReferences = 2;
419            $externalUserIdUrlEncoded = rawurlencode($externalUserId);
420            $result = \App::$http->readGetResult(
421                "/process/{$processId}/externaluserid/{$externalUserIdUrlEncoded}/",
422                [
423                    'resolveReferences' => $resolveReferences,
424                ]
425            );
426            $entity = $result?->getEntity();
427            if (!$entity instanceof Process) {
428                return new Process();
429            }
430            return $entity;
431        } catch (\Exception $e) {
432            ExceptionService::handleException($e);
433        }
434    }
435
436    public static function getScopesByProviderId(string $source, string|int $providerId): ScopeList
437    {
438        try {
439            $scopeList = self::getScopes();
440            if (!$scopeList instanceof ScopeList) {
441                return new ScopeList();
442            }
443            $result = $scopeList->withProviderID($source, (string)$providerId);
444            if (!$result instanceof ScopeList) {
445                return new ScopeList();
446            }
447            return $result;
448        } catch (\Exception $e) {
449            ExceptionService::handleException($e);
450        }
451    }
452
453    /**
454     * Iterate configured sources; skip individual SourceNotFound and only fail when none load.
455     *
456     * @param callable(Source):void $callback
457     */
458    private static function forEachAvailableSource(callable $callback): void
459    {
460        $loaded = 0;
461        $notFound = 0;
462
463        foreach (self::getSourceNames() as $name) {
464            $src = self::fetchSourceDataFor($name);
465            if ($src === null) {
466                $notFound++;
467                continue;
468            }
469            $loaded++;
470            $callback($src);
471        }
472
473        if ($loaded === 0 && $notFound > 0) {
474            $exception = new \BO\Zmsclient\Exception('Source not found');
475            $exception->template = 'BO\\Zmsbackend\\Source\\Exception\\SourceNotFound';
476            throw $exception;
477        }
478    }
479
480    private static function fetchSourceDataFor(string $sourceName): ?Source
481    {
482        $cacheKey = 'source_' . $sourceName;
483        if (\App::$cache && ($data = \App::$cache->get($cacheKey))) {
484            return $data instanceof Source ? $data : null;
485        }
486
487        try {
488            $result = \App::$http->readGetResult('/source/' . $sourceName . '/', [
489                'resolveReferences' => 2,
490            ]);
491        } catch (\Exception $e) {
492            if (self::isSourceNotFoundException($e)) {
493                LoggerService::logWarning('Configured source is unavailable; continuing with remaining sources', [
494                    'source' => $sourceName,
495                    'exception' => $e->getMessage(),
496                ]);
497                return null;
498            }
499            throw $e;
500        }
501
502        $entity = $result?->getEntity();
503        if (!$entity instanceof Source) {
504            return new Source();
505        }
506
507        if (\App::$cache) {
508            \App::$cache->set($cacheKey, $entity, \App::$SOURCE_CACHE_TTL);
509            LoggerService::logInfo('Cache set', [
510                'key' => $cacheKey,
511                'ttl' => \App::$SOURCE_CACHE_TTL,
512                'entity_type' => get_class($entity)
513            ]);
514        }
515
516        return $entity;
517    }
518
519    private static function isSourceNotFoundException(\Throwable $e): bool
520    {
521        if ($e instanceof \BO\Zmsclient\Exception && is_string($e->template ?? null)) {
522            if (str_contains($e->template, 'SourceNotFound')) {
523                return true;
524            }
525        }
526
527        $previous = $e->getPrevious();
528        return $previous instanceof \Throwable ? self::isSourceNotFoundException($previous) : false;
529    }
530
531    /**
532     * Accepts comma/semicolon/pipe/whitespace separated source names, e.g.
533     * "dldb", "dldb,zms", "dldb; zms", "dldb zms", "dldb|zms".
534     */
535    private static function getSourceNames(): array
536    {
537        $raw = \App::$source_name;
538        if ($raw === '') {
539            $raw = 'dldb';
540        }
541        $names = preg_split('/[,\;\|\s]+/', $raw, -1, PREG_SPLIT_NO_EMPTY);
542        if ($names === false) {
543            $names = [];
544        }
545
546        $out = [];
547        foreach ($names as $n) {
548            $n = trim($n);
549            if ($n !== '' && !in_array($n, $out, true)) {
550                $out[] = $n;
551            }
552        }
553
554        return $out !== [] ? $out : ['dldb'];
555    }
556
557    public static function getProcessesByExternalUserId(string $externalUserId, ?int $filterId = null, ?string $status = null): ProcessList
558    {
559        try {
560            $params = [
561                'resolveReferences' => 2,
562            ];
563            if (!is_null($filterId)) {
564                $params['filterId'] = $filterId;
565            }
566            if (!is_null($status)) {
567                $params['status'] = $status;
568            }
569            $externalUserIdUrlEncoded = rawurlencode($externalUserId);
570            $result = \App::$http->readGetResult("/process/externaluserid/{$externalUserIdUrlEncoded}/", $params);
571            $collection = $result?->getCollection();
572            if (!$collection instanceof ProcessList) {
573                return new ProcessList();
574            }
575            return $collection;
576        } catch (\Exception $e) {
577            ExceptionService::handleException($e);
578        }
579    }
580}