Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.80% covered (warning)
82.80%
260 / 314
54.17% covered (warning)
54.17%
13 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
ZmsApiClientService
82.80% covered (warning)
82.80%
260 / 314
54.17% covered (warning)
54.17%
13 / 24
177.95
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
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
5.01
 getFreeDays
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
6
 getFreeTimeslots
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 reserveTimeslot
96.55% covered (success)
96.55%
28 / 29
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
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
7.04
 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\DayList;
13use BO\Zmsentities\Collection\ProcessList;
14use BO\Zmsentities\Collection\ProviderList;
15use BO\Zmsentities\Collection\RequestList;
16use BO\Zmsentities\Collection\RequestRelationList;
17use BO\Zmsentities\Collection\ScopeList;
18
19/**
20 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
21 */
22class ZmsApiClientService
23{
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                $list = $src->getScopeList();
166                if (!$list instanceof ScopeList) {
167                    return;
168                }
169                foreach ($list as $scope) {
170                    $prov = $scope->getProvider();
171                    $key = (($prov->source ?? '') . '_' . $prov->id);
172                    if (!isset($seen[$key])) {
173                        $combined->addEntity($scope);
174                        $seen[$key] = true;
175                    }
176                }
177            });
178
179            return $combined;
180        } catch (\Exception $e) {
181            ExceptionService::handleException($e);
182        }
183    }
184
185    public static function getFreeDays(ProviderList $providers, RequestList $requests, array $firstDay, array $lastDay): Calendar
186    {
187        try {
188            $calendar = new Calendar();
189            $calendar->firstDay = $firstDay;
190            $calendar->lastDay = $lastDay;
191            $calendar->providers = $providers;
192            $calendar->requests = $requests;
193            $result = \App::$http->readPostResult('/calendar/', $calendar);
194            $entity = $result?->getEntity();
195
196            if (!$entity instanceof Calendar) {
197                return new Calendar();
198            }
199            $bookableDays = new DayList();
200            foreach ($entity->days as $day) {
201                if (isset($day['status']) && $day['status'] === 'bookable') {
202                    $bookableDays->addEntity($day);
203                }
204            }
205            $entity->days = $bookableDays;
206
207            return $entity;
208        } catch (\Exception $e) {
209            ExceptionService::handleException($e);
210        }
211    }
212
213    public static function getFreeTimeslots(ProviderList $providers, RequestList $requests, array $firstDay, array $lastDay): ProcessList
214    {
215        try {
216            $calendar = new Calendar();
217            $calendar->firstDay = $firstDay;
218            $calendar->lastDay = $lastDay;
219            $calendar->providers = $providers;
220            $calendar->requests = $requests;
221            $result = \App::$http->readPostResult('/process/status/free/unique/', $calendar);
222            $collection = $result?->getCollection();
223            if (!$collection instanceof ProcessList) {
224                return new ProcessList();
225            }
226
227            return $collection;
228        } catch (\Exception $e) {
229            ExceptionService::handleException($e);
230        }
231    }
232
233    public static function reserveTimeslot(Process $appointmentProcess, array $serviceIds, array $serviceCounts): Process
234    {
235        try {
236            $requestList = self::getServices();
237            $requestSource = [];
238            foreach ($requestList as $r) {
239                $requestSource[(string)$r->id] = (string)($r->source ?? '');
240            }
241
242            $requests = [];
243            foreach ($serviceIds as $index => $serviceId) {
244                $sid = (string)$serviceId;
245                $src = $requestSource[$sid] ?? null;
246                if (!$src) {
247                    return new Process();
248                }
249                $count = (int)($serviceCounts[$index] ?? 1);
250                for ($i = 0; $i < $count; $i++) {
251                    $requests[] = ['id' => $serviceId, 'source' => $src];
252                }
253            }
254
255            $processEntity = new Process();
256            $processEntity->appointments = $appointmentProcess->appointments ?? [];
257            $processEntity->authKey = $appointmentProcess->authKey ?? null;
258            $processEntity->clients = $appointmentProcess->clients ?? [];
259            $processEntity->scope = $appointmentProcess->scope ?? null;
260            $processEntity->requests = $requests;
261            $processEntity->lastChange = $appointmentProcess->lastChange ?? time();
262            $processEntity->createIP = ClientIpHelper::getClientIp();
263            $processEntity->createTimestamp = time();
264            if (isset($appointmentProcess->queue)) {
265                $processEntity->queue = $appointmentProcess->queue;
266            }
267
268            $result = \App::$http->readPostResult('/process/status/reserved/', $processEntity);
269            $entity = $result?->getEntity();
270            return $entity instanceof Process ? $entity : new Process();
271        } catch (\Exception $e) {
272            ExceptionService::handleException($e);
273        }
274    }
275
276    public static function submitClientData(Process $process): Process
277    {
278        try {
279            $url = '/process/' . $process->getId() . '/' . $process->getAuthKey() . '/';
280            $result = \App::$http->readPostResult($url, $process);
281            $entity = $result?->getEntity();
282            if (!$entity instanceof Process) {
283                return new Process();
284            }
285            return $entity;
286        } catch (\Exception $e) {
287            ExceptionService::handleException($e);
288        }
289    }
290
291    public static function preconfirmProcess(Process $process): Process
292    {
293        try {
294            $url = '/process/status/preconfirmed/';
295            $result = \App::$http->readPostResult($url, $process);
296            $entity = $result?->getEntity();
297            if (!$entity instanceof Process) {
298                return new Process();
299            }
300            return $entity;
301        } catch (\Exception $e) {
302            ExceptionService::handleException($e);
303        }
304    }
305
306    public static function confirmProcess(Process $process): Process
307    {
308        try {
309            $url = '/process/status/confirmed/';
310            $result = \App::$http->readPostResult($url, $process);
311            $entity = $result?->getEntity();
312            if (!$entity instanceof Process) {
313                return new Process();
314            }
315            return $entity;
316        } catch (\Exception $e) {
317            ExceptionService::handleException($e);
318        }
319    }
320
321    public static function cancelAppointment(Process $process): Process
322    {
323        try {
324            $url = '/process/' . $process->getId() . '/' . $process->getAuthKey() . '/';
325            $result = \App::$http->readDeleteResult($url, []);
326            $entity = $result?->getEntity();
327            if (!$entity instanceof Process) {
328                return new Process();
329            }
330            return $entity;
331        } catch (\Exception $e) {
332            ExceptionService::handleException($e);
333        }
334    }
335
336    public static function sendConfirmationEmail(Process $process): Process
337    {
338        try {
339            $url = '/process/' . $process->getId() . '/' . $process->getAuthKey() . '/confirmation/mail/';
340            $result = \App::$http->readPostResult($url, $process);
341            $entity = $result?->getEntity();
342            if (!$entity instanceof Process) {
343                return new Process();
344            }
345            return $entity;
346        } catch (\Exception $e) {
347            ExceptionService::handleException($e);
348        }
349    }
350
351    public static function sendPreconfirmationEmail(Process $process): Process
352    {
353        try {
354            $url = '/process/' . $process->getId() . '/' . $process->getAuthKey() . '/preconfirmation/mail/';
355            $result = \App::$http->readPostResult($url, $process);
356            $entity = $result?->getEntity();
357            if (!$entity instanceof Process) {
358                return new Process();
359            }
360            return $entity;
361        } catch (\Exception $e) {
362            ExceptionService::handleException($e);
363        }
364    }
365
366    public static function sendCancellationEmail(Process $process): Process
367    {
368        try {
369            $url = '/process/' . $process->getId() . '/' . $process->getAuthKey() . '/delete/mail/';
370            $result = \App::$http->readPostResult($url, $process);
371            $entity = $result?->getEntity();
372            if (!$entity instanceof Process) {
373                return new Process();
374            }
375            return $entity;
376        } catch (\Exception $e) {
377            ExceptionService::handleException($e);
378        }
379    }
380
381    public static function getProcessById(int $processId, string $authKey): Process
382    {
383        try {
384            $resolveReferences = 2;
385            $result = \App::$http->readGetResult("/process/{$processId}/{$authKey}/", [
386                'resolveReferences' => $resolveReferences
387            ]);
388            $entity = $result?->getEntity();
389            if (!$entity instanceof Process) {
390                return new Process();
391            }
392            return $entity;
393        } catch (\Exception $e) {
394            ExceptionService::handleException($e);
395        }
396    }
397
398    /**
399     * Load a process for a citizen authenticated via JWT (validated in zmscitizenapi).
400     * Calls zmsbackend ProcessGetByExternalUserId â€” not WorkstationProcessGet â€” so access
401     * is limited to processes owned by the given external user id (GH-1582).
402     */
403    public static function getProcessByIdAuthenticated(int $processId, string $externalUserId): Process
404    {
405        try {
406            $resolveReferences = 2;
407            $externalUserIdUrlEncoded = rawurlencode($externalUserId);
408            $result = \App::$http->readGetResult(
409                "/process/{$processId}/externaluserid/{$externalUserIdUrlEncoded}/",
410                [
411                    'resolveReferences' => $resolveReferences,
412                ]
413            );
414            $entity = $result?->getEntity();
415            if (!$entity instanceof Process) {
416                return new Process();
417            }
418            return $entity;
419        } catch (\Exception $e) {
420            ExceptionService::handleException($e);
421        }
422    }
423
424    public static function getScopesByProviderId(string $source, string|int $providerId): ScopeList
425    {
426        try {
427            $scopeList = self::getScopes();
428            if (!$scopeList instanceof ScopeList) {
429                return new ScopeList();
430            }
431            $result = $scopeList->withProviderID($source, (string)$providerId);
432            if (!$result instanceof ScopeList) {
433                return new ScopeList();
434            }
435            return $result;
436        } catch (\Exception $e) {
437            ExceptionService::handleException($e);
438        }
439    }
440
441    /**
442     * Iterate configured sources; skip individual SourceNotFound and only fail when none load.
443     *
444     * @param callable(Source):void $callback
445     */
446    private static function forEachAvailableSource(callable $callback): void
447    {
448        $loaded = 0;
449        $notFound = 0;
450
451        foreach (self::getSourceNames() as $name) {
452            $src = self::fetchSourceDataFor($name);
453            if ($src === null) {
454                $notFound++;
455                continue;
456            }
457            $loaded++;
458            $callback($src);
459        }
460
461        if ($loaded === 0 && $notFound > 0) {
462            $exception = new \BO\Zmsclient\Exception('Source not found');
463            $exception->template = 'BO\\Zmsbackend\\Source\\Exception\\SourceNotFound';
464            throw $exception;
465        }
466    }
467
468    private static function fetchSourceDataFor(string $sourceName): ?Source
469    {
470        $cacheKey = 'source_' . $sourceName;
471        if (\App::$cache && ($data = \App::$cache->get($cacheKey))) {
472            return $data instanceof Source ? $data : null;
473        }
474
475        try {
476            $result = \App::$http->readGetResult('/source/' . $sourceName . '/', [
477                'resolveReferences' => 2,
478            ]);
479        } catch (\Exception $e) {
480            if (self::isSourceNotFoundException($e)) {
481                LoggerService::logWarning('Configured source is unavailable; continuing with remaining sources', [
482                    'source' => $sourceName,
483                    'exception' => $e->getMessage(),
484                ]);
485                return null;
486            }
487            throw $e;
488        }
489
490        $entity = $result?->getEntity();
491        if (!$entity instanceof Source) {
492            return new Source();
493        }
494
495        if (\App::$cache) {
496            \App::$cache->set($cacheKey, $entity, \App::$SOURCE_CACHE_TTL);
497            LoggerService::logInfo('Cache set', [
498                'key' => $cacheKey,
499                'ttl' => \App::$SOURCE_CACHE_TTL,
500                'entity_type' => get_class($entity)
501            ]);
502        }
503
504        return $entity;
505    }
506
507    private static function isSourceNotFoundException(\Throwable $e): bool
508    {
509        if ($e instanceof \BO\Zmsclient\Exception && is_string($e->template ?? null)) {
510            if (str_contains($e->template, 'SourceNotFound')) {
511                return true;
512            }
513        }
514
515        $previous = $e->getPrevious();
516        return $previous instanceof \Throwable ? self::isSourceNotFoundException($previous) : false;
517    }
518
519    /**
520     * Akzeptiert sowohl:
521     * - String: "dldb", "dldb,zms", "dldb; zms", "dldb zms", "dldb|zms"
522     * - Array:  ["dldb","zms"]
523     */
524    private static function getSourceNames(): array
525    {
526        $raw = \App::$source_name ?? 'dldb';
527
528        if (is_array($raw)) {
529            $names = array_values(array_filter(array_map('strval', $raw)));
530        } else {
531            $s = (string)$raw;
532            $names = preg_split('/[,\;\|\s]+/', $s, -1, PREG_SPLIT_NO_EMPTY) ?: [];
533        }
534
535        $out = [];
536        foreach ($names as $n) {
537            $n = trim($n);
538            if ($n !== '' && !in_array($n, $out, true)) {
539                $out[] = $n;
540            }
541        }
542
543        return $out ?: ['dldb'];
544    }
545
546    public static function getProcessesByExternalUserId(string $externalUserId, ?int $filterId = null, ?string $status = null): ProcessList
547    {
548        try {
549            $params = [
550                'resolveReferences' => 2,
551            ];
552            if (!is_null($filterId)) {
553                $params['filterId'] = $filterId;
554            }
555            if (!is_null($status)) {
556                $params['status'] = $status;
557            }
558            $externalUserIdUrlEncoded = rawurlencode($externalUserId);
559            $result = \App::$http->readGetResult("/process/externaluserid/{$externalUserIdUrlEncoded}/", $params);
560            $collection = $result?->getCollection();
561            if (!$collection instanceof ProcessList) {
562                return new ProcessList();
563            }
564            return $collection;
565        } catch (\Exception $e) {
566            ExceptionService::handleException($e);
567        }
568    }
569}