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