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