Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
54.82% covered (warning)
54.82%
347 / 633
39.58% covered (danger)
39.58%
19 / 48
CRAP
0.00% covered (danger)
0.00%
0 / 1
Useraccount
54.82% covered (warning)
54.82%
347 / 633
39.58% covered (danger)
39.58%
19 / 48
5781.29
0.00% covered (danger)
0.00%
0 / 1
 getUseraccountCacheVersion
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 invalidateAllCaches
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 registerCacheKeyForDepartments
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
7.02
 deleteCacheKey
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 invalidateDepartmentCaches
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
9.09
 getCacheIndexKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 extractDepartmentIdsFromEntity
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
10.37
 readDepartmentIdsForLoginName
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 collectDepartmentIdsForInvalidation
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 collectUseraccountIdentifiers
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 applyWorkstationAccessFilter
16.67% covered (danger)
16.67%
2 / 12
0.00% covered (danger)
0.00%
0 / 1
13.26
 sanitizeCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 readIsUserExisting
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 readEntity
63.33% covered (warning)
63.33%
19 / 30
0.00% covered (danger)
0.00%
0 / 1
14.93
 readResolvedReferences
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 readList
65.12% covered (warning)
65.12%
28 / 43
0.00% covered (danger)
0.00%
0 / 1
29.27
 readListStatement
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 readAssignedDepartmentList
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 readAssignedDepartmentListsForAll
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 separateSuperusersFromRegularUsers
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 loadSuperuserDepartments
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 loadRegularUserDepartments
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 groupAssignmentsByUser
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 buildDepartmentListsForUsers
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
10
 buildDepartmentList
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 readEntityByAuthKey
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 readEntityByUserId
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 readCollectionByDepartmentIds
56.52% covered (warning)
56.52%
26 / 46
0.00% covered (danger)
0.00%
0 / 1
44.63
 writeEntity
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 assignTemporaryRoleForNewUser
47.37% covered (danger)
47.37%
9 / 19
0.00% covered (danger)
0.00%
0 / 1
6.33
 readRoleIdByName
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 extractRequestedRoleNames
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
7.77
 replaceUserRoles
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 writeUpdatedEntity
65.00% covered (warning)
65.00%
13 / 20
0.00% covered (danger)
0.00%
0 / 1
4.69
 deleteEntity
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 updateAssignedDepartments
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 readEntityIdByLoginName
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 deleteAssignedDepartments
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 buildSearchCacheKey
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 getCachedResult
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 setCachedResult
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 executeSearchQuery
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
90
 executeSearchByDepartmentIdsQuery
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
110
 readSearch
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 readSearchByDepartmentIds
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 readListRole
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
156
 readListByRoleAndDepartmentIds
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
240
 removeCache
63.33% covered (warning)
63.33%
19 / 30
0.00% covered (danger)
0.00%
0 / 1
12.99
1<?php
2
3namespace BO\Zmsdb;
4
5use BO\Zmsdb\Application as App;
6use BO\Zmsentities\Useraccount as Entity;
7use BO\Zmsentities\Collection\UseraccountList as Collection;
8
9/**
10 * @SuppressWarnings(Public)
11 * @SuppressWarnings(TooManyMethods)
12 * @SuppressWarnings(ExcessiveClassComplexity)
13 *
14 */
15class Useraccount extends Base
16{
17    private const CACHE_VERSION_KEY = 'useraccountCacheVersion';
18    private const CACHE_INDEX_PREFIX = 'useraccountCacheIndex-';
19    private const CACHE_INDEX_GLOBAL = 'all';
20    private const TEMP_ROLE_MAP_BY_BERECHTIGUNG = [
21        90 => 'system_admin',
22        40 => 'user_admin',
23        30 => 'appointment_admin',
24        5  => 'audit_viewer',
25        0  => 'agent_queue',
26    ];
27
28    /**
29     * Read or initialize the cache version for all useraccount-related cache entries.
30     */
31    protected function getUseraccountCacheVersion(): int
32    {
33        if (!App::$cache) {
34            return 1;
35        }
36
37        $version = App::$cache->get(self::CACHE_VERSION_KEY);
38        if (!is_int($version) || $version < 1) {
39            $version = 1;
40            App::$cache->set(self::CACHE_VERSION_KEY, $version);
41        }
42
43        return $version;
44    }
45
46    public function invalidateAllCaches(): void
47    {
48        if (!App::$cache) {
49            return;
50        }
51        App::$cache->set(self::CACHE_VERSION_KEY, $this->getUseraccountCacheVersion() + 1);
52    }
53
54    /**
55     * Register a cache key for one or more department IDs so we can invalidate
56     * only the affected department-based caches later on.
57     *
58     * Note: This method uses a non-atomic read-modify-write pattern which may
59     * experience race conditions under high concurrency. Lost index entries are
60     * handled by the version-bump fallback in removeCache(), ensuring eventual
61     * consistency at the cost of a full cache invalidation.
62     *
63     * @see https://github.com/it-at-m/eappointment/issues/1804
64     *      Migration to Redis for atomic operations is tracked in issue #1804.
65     *
66     * @param array $departmentIds Department IDs to associate with the cache key
67     * @param string $cacheKey The cache key to register
68     */
69    protected function registerCacheKeyForDepartments(array $departmentIds, string $cacheKey): void
70    {
71        if (!App::$cache || empty($departmentIds)) {
72            return;
73        }
74
75        $departmentIds = array_values(array_unique(array_filter($departmentIds, function ($id) {
76            return $id !== null && $id !== '';
77        })));
78
79        foreach ($departmentIds as $departmentId) {
80            $indexKey = $this->getCacheIndexKey($departmentId);
81            $existing = App::$cache->get($indexKey);
82            if (!is_array($existing)) {
83                $existing = [];
84            }
85            if (!in_array($cacheKey, $existing, true)) {
86                $existing[] = $cacheKey;
87                App::$cache->set($indexKey, $existing);
88            }
89        }
90    }
91
92    protected function deleteCacheKey(string $cacheKey): bool
93    {
94        if (!App::$cache) {
95            return false;
96        }
97
98        if (App::$cache->has($cacheKey)) {
99            App::$cache->delete($cacheKey);
100            return true;
101        }
102
103        return false;
104    }
105
106    protected function invalidateDepartmentCaches(array $departmentIds): bool
107    {
108        if (!App::$cache) {
109            return false;
110        }
111
112        $departmentIds = array_values(array_unique(array_filter($departmentIds, function ($id) {
113            return $id !== null && $id !== '';
114        })));
115
116        $foundAny = false;
117
118        foreach ($departmentIds as $departmentId) {
119            $indexKey = $this->getCacheIndexKey($departmentId);
120            $indexExists = App::$cache->has($indexKey);
121            $cacheKeys = $indexExists ? App::$cache->get($indexKey) : [];
122            if (!is_array($cacheKeys)) {
123                $cacheKeys = [];
124            }
125
126            if ($indexExists) {
127                $foundAny = true;
128            }
129
130            foreach ($cacheKeys as $cacheKey) {
131                if ($this->deleteCacheKey($cacheKey)) {
132                    $foundAny = true;
133                }
134            }
135
136            App::$cache->delete($indexKey);
137        }
138
139        return $foundAny;
140    }
141
142    protected function getCacheIndexKey($departmentId): string
143    {
144        return self::CACHE_INDEX_PREFIX . $departmentId;
145    }
146
147    protected function extractDepartmentIdsFromEntity($useraccount): array
148    {
149        if (!isset($useraccount->departments) || empty($useraccount->departments)) {
150            return [];
151        }
152
153        $ids = [];
154        foreach ($useraccount->departments as $department) {
155            if (is_object($department) && isset($department->id)) {
156                $ids[] = $department->id;
157            } elseif (is_array($department) && isset($department['id'])) {
158                $ids[] = $department['id'];
159            }
160        }
161
162        return array_values(array_unique(array_filter($ids)));
163    }
164
165    protected function readDepartmentIdsForLoginName($loginName): array
166    {
167        if (!$loginName) {
168            return [];
169        }
170
171        $query = Query\Useraccount::QUERY_READ_ASSIGNED_DEPARTMENTS;
172        $departmentData = $this->getReader()->fetchAll($query, ['useraccountName' => $loginName]);
173
174        $ids = [];
175        foreach ($departmentData as $row) {
176            if (isset($row['id'])) {
177                $ids[] = $row['id'];
178            }
179        }
180
181        return array_values(array_unique($ids));
182    }
183
184    protected function collectDepartmentIdsForInvalidation($useraccount, array $previousDepartmentIds = []): array
185    {
186        $currentFromEntity = $this->extractDepartmentIdsFromEntity($useraccount);
187        $currentFromDatabase = $this->readDepartmentIdsForLoginName($useraccount->id ?? null);
188
189        $departmentIds = array_merge(
190            $previousDepartmentIds,
191            $currentFromEntity,
192            $currentFromDatabase
193        );
194
195        $departmentIds[] = self::CACHE_INDEX_GLOBAL;
196
197        return array_values(array_unique(array_filter($departmentIds, function ($id) {
198            return $id !== null && $id !== '';
199        })));
200    }
201
202    protected function collectUseraccountIdentifiers($useraccount): array
203    {
204        $identifiers = [];
205
206        if (isset($useraccount->id)) {
207            $identifiers[] = $useraccount->id;
208        }
209
210        if (isset($useraccount->loginname) && $useraccount->loginname !== $useraccount->id) {
211            $identifiers[] = $useraccount->loginname;
212        }
213
214        return array_values(array_unique(array_filter($identifiers, function ($identifier) {
215            return $identifier !== null && $identifier !== '';
216        })));
217    }
218
219    protected function applyWorkstationAccessFilter(Query\Useraccount $query, $workstation): bool
220    {
221        if (!$workstation || $workstation->getUseraccount()->isSuperUser()) {
222            return true; // No filtering needed for superusers
223        }
224
225        $workstationUserId = $this->readEntityIdByLoginName($workstation->getUseraccount()->id);
226        $workstationDepartmentIds = $workstation->getDepartmentList()->getIds();
227
228        // If no departments loaded, return empty result for security
229        if (empty($workstationDepartmentIds)) {
230            return false; // Signal to return empty collection
231        }
232
233        $query->addConditionWorkstationAccess(
234            $workstationUserId,
235            $workstationDepartmentIds,
236            false // We already checked isSuperUser above
237        );
238
239        return true;
240    }
241    /**
242     * Sanitize cache key by replacing reserved characters
243     * Reserved characters: {}()/\@:
244     */
245    protected function sanitizeCacheKey($key)
246    {
247        return str_replace(['{', '}', '(', ')', '/', '\\', '@', ':'], '_', $key);
248    }
249
250    public function readIsUserExisting($loginName, $password = false)
251    {
252        $query = new Query\Useraccount(Query\Base::SELECT);
253        $query->addEntityMapping()
254            ->setResolveLevel(0)
255            ->addConditionLoginName($loginName);
256        if ($password) {
257            $query->addConditionPassword($password);
258        }
259        $useraccount = $this->fetchOne($query, new Entity());
260        return ($useraccount->hasId()) ? true : false;
261    }
262
263    public function readEntity($loginname, $resolveReferences = 1, $disableCache = false)
264    {
265        $version = $this->getUseraccountCacheVersion();
266        $cacheKey = $this->sanitizeCacheKey("useraccount-v{$version}-$loginname-$resolveReferences");
267        $useraccount = null;
268
269        if (!$disableCache && App::$cache && App::$cache->has($cacheKey)) {
270            $useraccount = App::$cache->get($cacheKey);
271            if ($useraccount && App::$log) {
272                App::$log->info('Useraccount cache hit', [
273                    'cache_key' => $cacheKey,
274                    'loginname' => $loginname,
275                    'resolveReferences' => $resolveReferences,
276                ]);
277            }
278        }
279
280        if (empty($useraccount)) {
281            $query = new Query\Useraccount(Query\Base::SELECT);
282            $query->addEntityMapping()
283            ->addResolvedReferences($resolveReferences)
284            ->addConditionLoginName($loginname);
285            $useraccount = $this->fetchOne($query, new Entity());
286            if (!$useraccount->hasId()) {
287                return null;
288            }
289
290            $useraccount = $this->readResolvedReferences($useraccount, $resolveReferences);
291
292            if (App::$cache) {
293                App::$cache->set($cacheKey, $useraccount);
294                if (App::$log) {
295                    App::$log->info('Useraccount cache set', [
296                        'cache_key' => $cacheKey,
297                        'loginname' => $loginname,
298                        'resolveReferences' => $resolveReferences,
299                        'useraccount_id' => $useraccount->id ?? null
300                    ]);
301                }
302            }
303        }
304
305        return $useraccount;
306    }
307
308    #[\Override]
309    public function readResolvedReferences(\BO\Zmsentities\Schema\Entity $entity, $resolveReferences)
310    {
311        if (0 < $resolveReferences && $entity->toProperty()->id->get()) {
312            $entity->departments = $this->readAssignedDepartmentList($entity, $resolveReferences);
313        }
314        return $entity;
315    }
316
317    /**
318     * @SuppressWarnings(NPathComplexity)
319     */
320    public function readList($resolveReferences = 0, $disableCache = false, $workstation = null)
321    {
322        $version = $this->getUseraccountCacheVersion();
323        $workstationKey = '';
324        if ($workstation && !$workstation->getUseraccount()->isSuperUser()) {
325            $workstationKey = '-workstation-' . $workstation->getUseraccount()->id;
326        }
327        $cacheKey = "useraccountReadList-v{$version}-$resolveReferences$workstationKey";
328        $result = null;
329
330        if (!$disableCache && App::$cache && App::$cache->has($cacheKey)) {
331            $result = App::$cache->get($cacheKey);
332            if ($result && App::$log) {
333                App::$log->info('Useraccount list cache hit', [
334                    'cache_key' => $cacheKey,
335                    'resolveReferences' => $resolveReferences,
336                    'count' => $result->count()
337                ]);
338            }
339        }
340
341        if (empty($result)) {
342            $collection = new Collection();
343            $query = new Query\Useraccount(Query\Base::SELECT);
344            $query->addResolvedReferences($resolveReferences)
345            ->addEntityMapping()
346            ->addOrderByName();
347
348            // Apply workstation access filtering if provided
349            if (!$this->applyWorkstationAccessFilter($query, $workstation)) {
350                $result = new Collection();
351                return $result;
352            }
353
354            $result = $this->fetchList($query, new Entity());
355            if (count($result)) {
356                foreach ($result as $entity) {
357                    $collection->addEntity($entity);
358                }
359                if (0 < $resolveReferences) {
360                    $departmentMap = $this->readAssignedDepartmentListsForAll($collection, $resolveReferences - 1);
361                    foreach ($collection as $entity) {
362                        if (isset($departmentMap[$entity->id])) {
363                            $entity->departments = $departmentMap[$entity->id];
364                        }
365                    }
366                }
367            }
368            $result = $collection;
369
370            if (App::$cache) {
371                App::$cache->set($cacheKey, $result);
372                $this->registerCacheKeyForDepartments([self::CACHE_INDEX_GLOBAL], $cacheKey);
373                if (App::$log) {
374                    App::$log->info('Useraccount list cache set', [
375                        'cache_key' => $cacheKey,
376                        'resolveReferences' => $resolveReferences,
377                        'count' => $result->count()
378                    ]);
379                }
380            }
381        }
382
383        return $result;
384    }
385
386    protected function readListStatement($statement, $resolveReferences)
387    {
388        $query = new Query\Useraccount(Query\Base::SELECT);
389        $collection = new Collection();
390        while ($userAccountData = $statement->fetch(\PDO::FETCH_ASSOC)) {
391            $entity = new Entity($query->postProcessJoins($userAccountData));
392            $collection->addEntity($entity);
393        }
394        if (0 < $resolveReferences && count($collection) > 0) {
395            $departmentMap = $this->readAssignedDepartmentListsForAll($collection, $resolveReferences - 1);
396            foreach ($collection as $entity) {
397                if (isset($departmentMap[$entity->id])) {
398                    $entity->departments = $departmentMap[$entity->id];
399                }
400            }
401        }
402        return $collection;
403    }
404
405    public function readAssignedDepartmentList($useraccount, $resolveReferences = 0)
406    {
407        if ($useraccount->isSuperUser()) {
408            $query = Query\Useraccount::QUERY_READ_SUPERUSER_DEPARTMENTS;
409            $departmentData = $this->getReader()->fetchAll($query);
410        } else {
411            $query = Query\Useraccount::QUERY_READ_ASSIGNED_DEPARTMENTS;
412            $departmentData = $this->getReader()->fetchAll($query, ['useraccountName' => $useraccount->id]);
413        }
414        return $this->buildDepartmentList($departmentData, $resolveReferences);
415    }
416
417    protected function readAssignedDepartmentListsForAll(Collection $useraccounts, $resolveReferences = 0)
418    {
419        if (count($useraccounts) === 0) {
420            return [];
421        }
422
423        list($superusers, $regularUsers) = $this->separateSuperusersFromRegularUsers($useraccounts);
424        $result = [];
425
426        if (count($superusers) > 0) {
427            $result = array_merge($result, $this->loadSuperuserDepartments($superusers, $resolveReferences));
428        }
429
430        if (count($regularUsers) > 0) {
431            $result = array_merge($result, $this->loadRegularUserDepartments($regularUsers, $resolveReferences));
432        }
433
434        return $result;
435    }
436
437    protected function separateSuperusersFromRegularUsers(Collection $useraccounts)
438    {
439        $superusers = [];
440        $regularUsers = [];
441        foreach ($useraccounts as $useraccount) {
442            if ($useraccount->isSuperUser()) {
443                $superusers[] = $useraccount->id;
444            } else {
445                $regularUsers[] = $useraccount->id;
446            }
447        }
448        return [$superusers, $regularUsers];
449    }
450
451    protected function loadSuperuserDepartments(array $superusers, $resolveReferences = 0)
452    {
453        // Load all departments once - all superusers have access to all departments
454            $query = Query\Useraccount::QUERY_READ_SUPERUSER_DEPARTMENTS;
455            $departmentIds = $this->getReader()->fetchAll($query);
456        $departmentList = $this->buildDepartmentList($departmentIds, $resolveReferences);
457
458        // Reuse the same list for all superusers - no need to clone since they all get the same departments
459        $result = [];
460        foreach ($superusers as $useraccountName) {
461            $result[$useraccountName] = $departmentList;
462        }
463        return $result;
464    }
465
466    protected function loadRegularUserDepartments(array $regularUsers, $resolveReferences = 0)
467    {
468        $placeholders = str_repeat('?,', count($regularUsers) - 1) . '?';
469        $query = str_replace(':useraccountNames', $placeholders, Query\Useraccount::QUERY_READ_ASSIGNED_DEPARTMENTS_FOR_ALL);
470        $allAssignments = $this->getReader()->fetchAll($query, $regularUsers);
471
472        $assignmentsByUser = $this->groupAssignmentsByUser($allAssignments);
473        return $this->buildDepartmentListsForUsers($regularUsers, $assignmentsByUser, $resolveReferences);
474    }
475
476    protected function groupAssignmentsByUser(array $allAssignments)
477    {
478        $assignmentsByUser = [];
479        foreach ($allAssignments as $assignment) {
480            $useraccountName = $assignment['useraccountName'];
481            if (!isset($assignmentsByUser[$useraccountName])) {
482                $assignmentsByUser[$useraccountName] = [];
483            }
484            $assignmentsByUser[$useraccountName][] = $assignment;
485        }
486        return $assignmentsByUser;
487    }
488
489    protected function buildDepartmentListsForUsers(array $useraccountNames, array $assignmentsByUser, $resolveReferences = 0)
490    {
491        // Collect ALL unique department IDs from all useraccounts
492        $allDepartmentIds = [];
493        $departmentIdsByUser = [];
494
495        foreach ($useraccountNames as $useraccountName) {
496            $departmentIdsByUser[$useraccountName] = [];
497            if (isset($assignmentsByUser[$useraccountName])) {
498                foreach ($assignmentsByUser[$useraccountName] as $item) {
499                    $departmentId = $item['id'];
500                    if (!isset($allDepartmentIds[$departmentId])) {
501                        $allDepartmentIds[$departmentId] = true;
502                    }
503                    $departmentIdsByUser[$useraccountName][] = $departmentId;
504                }
505            }
506        }
507
508        // Load ALL departments in ONE query
509        $allDepartments = [];
510        if (!empty($allDepartmentIds)) {
511            $uniqueDepartmentIds = array_keys($allDepartmentIds);
512            $allDepartments = (new \BO\Zmsdb\Department())->readEntitiesByIds($uniqueDepartmentIds, $resolveReferences);
513        }
514
515        // Build department lists for each useraccount from the pre-loaded departments
516        $result = [];
517        foreach ($useraccountNames as $useraccountName) {
518            $departmentList = new \BO\Zmsentities\Collection\DepartmentList();
519            if (isset($departmentIdsByUser[$useraccountName])) {
520                foreach ($departmentIdsByUser[$useraccountName] as $departmentId) {
521                    if (isset($allDepartments[$departmentId])) {
522                        // Clone department so each useraccount gets its own instance
523                        $departmentList->addEntity(clone $allDepartments[$departmentId]);
524                    }
525                }
526            }
527            $result[$useraccountName] = $departmentList;
528        }
529
530        return $result;
531    }
532
533    protected function buildDepartmentList(array $items, $resolveReferences = 0)
534    {
535        $departmentList = new \BO\Zmsentities\Collection\DepartmentList();
536
537        if (empty($items)) {
538            return $departmentList;
539        }
540
541        $departmentIds = [];
542        foreach ($items as $item) {
543            $departmentIds[] = $item['id'];
544        }
545
546        $departments = (new \BO\Zmsdb\Department())->readEntitiesByIds($departmentIds, $resolveReferences);
547
548        foreach ($departmentIds as $id) {
549            if (isset($departments[$id])) {
550                // Clone department so the list has its own instances
551                $departmentList->addEntity(clone $departments[$id]);
552            }
553        }
554
555        return $departmentList;
556    }
557
558    public function readEntityByAuthKey($xAuthKey, $resolveReferences = 0)
559    {
560        $hashedAuthKey = hash('sha256', $xAuthKey);
561        $query = new Query\Useraccount(Query\Base::SELECT);
562        $query->addEntityMapping()
563            ->addResolvedReferences($resolveReferences)
564            ->addConditionXauthKey($hashedAuthKey);
565        $entity = ($hashedAuthKey) ? $this->fetchOne($query, new Entity()) : new Entity();
566        return $this->readResolvedReferences($entity, $resolveReferences);
567    }
568
569    public function readEntityByUserId($userId, $resolveReferences = 0)
570    {
571        $query = new Query\Useraccount(Query\Base::SELECT);
572        $query->addEntityMapping()
573            ->addResolvedReferences($resolveReferences)
574            ->addConditionUserId($userId);
575        $entity = ($userId) ? $this->fetchOne($query, new Entity()) : new Entity();
576        return $this->readResolvedReferences($entity, $resolveReferences);
577    }
578
579    /**
580     * @SuppressWarnings(NPathComplexity)
581     */
582    public function readCollectionByDepartmentIds($departmentIds, $resolveReferences = 0, $disableCache = false, $workstation = null)
583    {
584        sort($departmentIds);
585        $version = $this->getUseraccountCacheVersion();
586        $workstationKey = '';
587        if ($workstation && !$workstation->getUseraccount()->isSuperUser()) {
588            $workstationKey = '-workstation-' . $workstation->getUseraccount()->id;
589        }
590        $cacheKey = "useraccountReadByDepartmentIds-v{$version}-" . implode(',', $departmentIds) . "-$resolveReferences$workstationKey";
591        $result = null;
592
593        if (!$disableCache && App::$cache && App::$cache->has($cacheKey)) {
594            $result = App::$cache->get($cacheKey);
595            if ($result && App::$log) {
596                App::$log->info('Useraccount department list cache hit', [
597                    'cache_key' => $cacheKey,
598                    'department_ids' => $departmentIds,
599                    'resolveReferences' => $resolveReferences,
600                    'count' => $result->count()
601                ]);
602            }
603        }
604
605        if (empty($result)) {
606            $collection = new Collection();
607            $query = new Query\Useraccount(Query\Base::SELECT);
608            $query->addResolvedReferences($resolveReferences)
609            ->addConditionDepartmentIds($departmentIds)
610            ->addEntityMapping()
611            ->addOrderByName();
612
613            // Exclude superusers if workstation user is not superuser
614            if ($workstation && !$workstation->getUseraccount()->isSuperUser()) {
615                $query->addConditionExcludeSuperusers();
616            }
617
618            $result = $this->fetchList($query, new Entity());
619            if (count($result)) {
620                foreach ($result as $entity) {
621                    $collection->addEntity($entity);
622                }
623                if (0 < $resolveReferences) {
624                    $departmentMap = $this->readAssignedDepartmentListsForAll($collection, $resolveReferences - 1);
625                    foreach ($collection as $entity) {
626                        if (isset($departmentMap[$entity->id])) {
627                            $entity->departments = $departmentMap[$entity->id];
628                        }
629                    }
630                }
631            }
632            $result = $collection;
633
634            if (App::$cache) {
635                App::$cache->set($cacheKey, $result);
636                $this->registerCacheKeyForDepartments($departmentIds, $cacheKey);
637                if (App::$log) {
638                    App::$log->info('Useraccount department list cache set', [
639                        'cache_key' => $cacheKey,
640                        'department_ids' => $departmentIds,
641                        'resolveReferences' => $resolveReferences,
642                        'count' => $result->count()
643                    ]);
644                }
645            }
646        }
647
648        return $result;
649    }
650
651    public function writeEntity(\BO\Zmsentities\Useraccount $entity, $resolveReferences = 0)
652    {
653        if ($this->readIsUserExisting($entity->id)) {
654            throw new Exception\Useraccount\DuplicateEntry();
655        }
656        $query = new Query\Useraccount(Query\Base::INSERT);
657        $values = $query->reverseEntityMapping($entity);
658        $query->addValues($values);
659        $this->writeItem($query);
660        $this->assignTemporaryRoleForNewUser($entity);
661        $this->updateAssignedDepartments($entity);
662
663        $this->removeCache($entity);
664
665        return $this->readEntity($entity->getId(), $resolveReferences, true);
666    }
667
668    /**
669     * Temporary bridge while roles/permissions UI is missing.
670     * Mirrors the logic from migration 91771576480-migrate-users-to-new-roles.sql
671     * so newly created users get a role entry in user_role.
672     *
673     * @todo Remove legacy Berechtigung->role mapping after full roles/permissions rollout.
674     */
675    protected function assignTemporaryRoleForNewUser(\BO\Zmsentities\Useraccount $entity): void
676    {
677        $berechtigung = (int) $entity->getRightsLevel();
678        $roleName = self::TEMP_ROLE_MAP_BY_BERECHTIGUNG[$berechtigung] ?? null;
679        if (!$roleName) {
680            return;
681        }
682
683        try {
684            $userId = (int) $this->readEntityIdByLoginName($entity->id);
685            $roleId = $this->readRoleIdByName($roleName);
686            $this->perform(
687                'INSERT IGNORE INTO user_role (user_id, role_id) VALUES (?, ?)',
688                [$userId, $roleId]
689            );
690        } catch (\Throwable $e) {
691            if (App::$log) {
692                App::$log->warning('Temporary user_role assignment failed', [
693                    'useraccount' => $entity->id ?? null,
694                    'berechtigung' => $berechtigung,
695                    'role_name' => $roleName,
696                    'exception' => get_class($e),
697                    'message' => $e->getMessage(),
698                ]);
699            }
700        }
701    }
702
703    protected function readRoleIdByName(string $roleName): int
704    {
705        $row = $this->getReader()->fetchOne(
706            'SELECT id FROM role WHERE name = ?',
707            [$roleName]
708        );
709        if (!$row || !isset($row['id'])) {
710            throw new \RuntimeException('Role not found: ' . $roleName);
711        }
712        return (int) $row['id'];
713    }
714
715    protected function extractRequestedRoleNames(Entity $entity): array
716    {
717        $roles = $entity->offsetExists('roles') ? $entity['roles'] : [];
718        if (!is_array($roles)) {
719            return [];
720        }
721
722        $requestedRoles = [];
723        foreach ($roles as $roleName) {
724            if (!is_string($roleName)) {
725                continue;
726            }
727
728            $roleName = trim($roleName);
729            if ($roleName === '' || isset($requestedRoles[$roleName])) {
730                continue;
731            }
732
733            $requestedRoles[$roleName] = true;
734        }
735
736        return array_keys($requestedRoles);
737    }
738
739    protected function replaceUserRoles(int $userId, array $roleNames): void
740    {
741        $this->perform(Query\Useraccount::QUERY_DELETE_USER_ROLES, [$userId]);
742        if (empty($roleNames)) {
743            return;
744        }
745
746        $placeholders = implode(', ', array_fill(0, count($roleNames), '?'));
747        $query = str_replace(':roleNames', $placeholders, Query\Useraccount::QUERY_INSERT_USER_ROLES_BY_NAME);
748        $this->perform($query, array_merge([$userId], $roleNames));
749    }
750
751    public function writeUpdatedEntity($loginName, Entity $entity, $resolveReferences = 0)
752    {
753        $previousDepartmentIds = $this->readDepartmentIdsForLoginName($loginName);
754        $query = new Query\Useraccount(Query\Base::UPDATE);
755        $query->addConditionLoginName($loginName);
756        $values = $query->reverseEntityMapping($entity);
757        $query->addValues($values);
758        $this->writeItem($query);
759        $this->updateAssignedDepartments($entity);
760
761        if ($entity->offsetExists('roles')) {
762            try {
763                $userId = (int) $this->readEntityIdByLoginName($loginName);
764                $roleNames = $this->extractRequestedRoleNames($entity);
765                $this->replaceUserRoles($userId, $roleNames);
766            } catch (\Throwable $e) {
767                if (App::$log) {
768                    App::$log->warning('user_role update on useraccount save failed', [
769                        'login' => $loginName,
770                        'message' => $e->getMessage(),
771                    ]);
772                }
773                throw $e;
774            }
775        }
776
777        $this->removeCache($entity, $previousDepartmentIds, $loginName);
778
779        return $this->readEntity($entity->getId(), $resolveReferences, true);
780    }
781
782    public function deleteEntity($loginName)
783    {
784        // Read entity before deletion to get cache info
785        $entity = $this->readEntity($loginName, 0, true);
786        $previousDepartmentIds = $this->readDepartmentIdsForLoginName($loginName);
787
788        $query = new Query\Useraccount(Query\Base::DELETE);
789        $query->addConditionLoginName($loginName);
790        $this->deleteAssignedDepartments($loginName);
791        $result = $this->deleteItem($query);
792
793        if ($entity && $entity->hasId()) {
794            $this->removeCache($entity, $previousDepartmentIds, $loginName);
795        }
796
797        return $result;
798    }
799
800    protected function updateAssignedDepartments($entity)
801    {
802        $loginName = $entity->id;
803        if (!$entity->isSuperUser()) {
804            $this->deleteAssignedDepartments($loginName);
805            $userId = $this->readEntityIdByLoginName($loginName);
806            foreach ($entity->departments as $department) {
807                $this->perform(
808                    Query\Useraccount::QUERY_WRITE_ASSIGNED_DEPARTMENTS,
809                    array(
810                        $userId,
811                        $department['id']
812                    )
813                );
814            }
815        }
816    }
817
818    protected function readEntityIdByLoginName($loginName)
819    {
820        $query = Query\Useraccount::QUERY_READ_ID_BY_USERNAME;
821        $result = $this->getReader()->fetchOne($query, [$loginName]);
822        return $result['id'];
823    }
824
825    protected function deleteAssignedDepartments($loginName)
826    {
827        $query = Query\Useraccount::QUERY_DELETE_ASSIGNED_DEPARTMENTS;
828        $userId = $this->readEntityIdByLoginName($loginName);
829        return $this->perform($query, [$userId]);
830    }
831
832    protected function buildSearchCacheKey($prefix, $resolveReferences, $workstation, $queryString, array $departmentIds = [])
833    {
834        $version = $this->getUseraccountCacheVersion();
835        $workstationKey = '';
836        if ($workstation && !$workstation->getUseraccount()->isSuperUser()) {
837            $workstationKey = '-workstation-' . $workstation->getUseraccount()->id;
838        }
839        $departmentKey = empty($departmentIds) ? '' : '-' . implode(',', $departmentIds);
840        return "{$prefix}-v{$version}{$departmentKey}-$resolveReferences$workstationKey-query-" . md5($queryString);
841    }
842
843    protected function getCachedResult($cacheKey, $disableCache, $logMessage, array $logContext = [])
844    {
845        $result = null;
846        if (!$disableCache && App::$cache && App::$cache->has($cacheKey)) {
847            $result = App::$cache->get($cacheKey);
848            if ($result && App::$log) {
849                $logContext['cache_key'] = $cacheKey;
850                $logContext['count'] = $result->count();
851                App::$log->info($logMessage, $logContext);
852            }
853        }
854        return $result;
855    }
856
857    protected function setCachedResult($cacheKey, $result, array $departmentIds, $logMessage, array $logContext = [])
858    {
859        if (App::$cache) {
860            App::$cache->set($cacheKey, $result);
861            $this->registerCacheKeyForDepartments($departmentIds, $cacheKey);
862            if (App::$log) {
863                $logContext['cache_key'] = $cacheKey;
864                $logContext['count'] = $result->count();
865                App::$log->info($logMessage, $logContext);
866            }
867        }
868    }
869
870    protected function executeSearchQuery(array $parameter, $resolveReferences, $workstation)
871    {
872        $query = new Query\Useraccount(Query\Base::SELECT);
873        $query
874            ->addResolvedReferences($resolveReferences)
875            ->addEntityMapping()
876            ->addOrderByName();
877
878        // For superusers: select all users without department filtering
879        // For non-superusers: apply department-based access filtering
880        if (!$this->applyWorkstationAccessFilter($query, $workstation)) {
881            return new Collection();
882        }
883
884        if (isset($parameter['query'])) {
885            if (preg_match('#^\d+$#', $parameter['query'])) {
886                $query->addConditionUserId($parameter['query']);
887                $query->addConditionSearch($parameter['query'], true);
888            } else {
889                $query->addConditionSearch($parameter['query']);
890            }
891        }
892
893        $collection = new Collection();
894        $result = $this->fetchList($query, new Entity());
895        if (count($result)) {
896            foreach ($result as $entity) {
897                $collection->addEntity($entity);
898            }
899            if (0 < $resolveReferences) {
900                $departmentMap = $this->readAssignedDepartmentListsForAll($collection, $resolveReferences - 1);
901                foreach ($collection as $entity) {
902                    if (isset($departmentMap[$entity->id])) {
903                        $entity->departments = $departmentMap[$entity->id];
904                    }
905                }
906            }
907        }
908        return $collection;
909    }
910
911    protected function executeSearchByDepartmentIdsQuery(array $departmentIds, array $parameter, $resolveReferences, $workstation)
912    {
913        $query = new Query\Useraccount(Query\Base::SELECT);
914        $query->addResolvedReferences($resolveReferences)
915            ->addEntityMapping()
916            ->addOrderByName();
917
918        if (isset($parameter['query'])) {
919            if (preg_match('#^\d+$#', $parameter['query'])) {
920                $query->addConditionUserId($parameter['query']);
921                $query->addConditionDepartmentIdsAndSearch($departmentIds, $parameter['query'], true);
922            } else {
923                $query->addConditionDepartmentIdsAndSearch($departmentIds, $parameter['query']);
924            }
925        } else {
926            $query->addConditionDepartmentIds($departmentIds);
927        }
928
929        // Exclude superusers if workstation user is not superuser
930        if ($workstation && !$workstation->getUseraccount()->isSuperUser()) {
931            $query->addConditionExcludeSuperusers();
932        }
933
934        $collection = new Collection();
935        $result = $this->fetchList($query, new Entity());
936        if (count($result)) {
937            foreach ($result as $entity) {
938                $collection->addEntity($entity);
939            }
940            if (0 < $resolveReferences) {
941                $departmentMap = $this->readAssignedDepartmentListsForAll($collection, $resolveReferences - 1);
942                foreach ($collection as $entity) {
943                    if (isset($departmentMap[$entity->id])) {
944                        $entity->departments = $departmentMap[$entity->id];
945                    }
946                }
947            }
948        }
949        return $collection;
950    }
951
952    /**
953     * @SuppressWarnings(NPathComplexity)
954     */
955    public function readSearch(array $parameter, $resolveReferences = 0, $workstation = null, $disableCache = false)
956    {
957        $queryString = isset($parameter['query']) ? $parameter['query'] : '';
958        $cacheKey = $this->buildSearchCacheKey('useraccountReadSearch', $resolveReferences, $workstation, $queryString);
959        $result = $this->getCachedResult($cacheKey, $disableCache, 'Useraccount search cache hit', [
960            'query' => $queryString,
961            'resolveReferences' => $resolveReferences
962        ]);
963
964        if (empty($result)) {
965            $result = $this->executeSearchQuery($parameter, $resolveReferences, $workstation);
966            $this->setCachedResult($cacheKey, $result, [self::CACHE_INDEX_GLOBAL], 'Useraccount search cache set', [
967                'query' => $queryString,
968                'resolveReferences' => $resolveReferences
969            ]);
970        }
971
972        return $result;
973    }
974
975    /**
976     * @SuppressWarnings(NPathComplexity)
977     */
978    public function readSearchByDepartmentIds(array $departmentIds, array $parameter, $resolveReferences = 0, $workstation = null, $disableCache = false)
979    {
980        sort($departmentIds);
981        $queryString = isset($parameter['query']) ? $parameter['query'] : '';
982        $cacheKey = $this->buildSearchCacheKey('useraccountReadSearchByDepartmentIds', $resolveReferences, $workstation, $queryString, $departmentIds);
983        $result = $this->getCachedResult($cacheKey, $disableCache, 'Useraccount search by department cache hit', [
984            'department_ids' => $departmentIds,
985            'query' => $queryString,
986            'resolveReferences' => $resolveReferences
987        ]);
988
989        if (empty($result)) {
990            $result = $this->executeSearchByDepartmentIdsQuery($departmentIds, $parameter, $resolveReferences, $workstation);
991            $this->setCachedResult($cacheKey, $result, $departmentIds, 'Useraccount search by department cache set', [
992                'department_ids' => $departmentIds,
993                'query' => $queryString,
994                'resolveReferences' => $resolveReferences
995            ]);
996        }
997
998        return $result;
999    }
1000
1001    public function readListRole($roleLevel, $resolveReferences = 0, $workstation = null)
1002    {
1003        $version = $this->getUseraccountCacheVersion();
1004        $workstationKey = '';
1005        if ($workstation && !$workstation->getUseraccount()->isSuperUser()) {
1006            $workstationKey = '-workstation-' . $workstation->getUseraccount()->id;
1007        }
1008        $cacheKey = "useraccountReadByRole-v{$version}-" . ($roleLevel ?? 'null') . "-$resolveReferences$workstationKey";
1009        $result = null;
1010
1011        if (App::$cache && App::$cache->has($cacheKey)) {
1012            $result = App::$cache->get($cacheKey);
1013            if ($result && App::$log) {
1014                App::$log->info('Useraccount role list cache hit', [
1015                    'cache_key' => $cacheKey,
1016                    'role_level' => $roleLevel,
1017                    'resolveReferences' => $resolveReferences,
1018                    'count' => $result->count()
1019                ]);
1020            }
1021        }
1022
1023        if (empty($result)) {
1024            $query = new Query\Useraccount(Query\Base::SELECT);
1025            $query->addResolvedReferences($resolveReferences)
1026            ->addEntityMapping()
1027            ->addOrderByName();
1028
1029            if (isset($roleLevel)) {
1030                $query->addConditionRoleLevel($roleLevel);
1031            }
1032
1033            // Apply workstation access filtering if provided
1034            if (!$this->applyWorkstationAccessFilter($query, $workstation)) {
1035                return new Collection();
1036            }
1037
1038            $statement = $this->fetchStatement($query);
1039            $result = $this->readListStatement($statement, $resolveReferences);
1040
1041            if (App::$cache) {
1042                App::$cache->set($cacheKey, $result);
1043                $this->registerCacheKeyForDepartments([self::CACHE_INDEX_GLOBAL], $cacheKey);
1044                if (App::$log) {
1045                    App::$log->info('Useraccount role list cache set', [
1046                        'cache_key' => $cacheKey,
1047                        'role_level' => $roleLevel,
1048                        'resolveReferences' => $resolveReferences,
1049                        'count' => $result->count()
1050                    ]);
1051                }
1052            }
1053        }
1054
1055        return $result;
1056    }
1057
1058    /**
1059     * @SuppressWarnings(NPathComplexity)
1060     */
1061    public function readListByRoleAndDepartmentIds($roleLevel, array $departmentIds, $resolveReferences = 0, $disableCache = false, $workstation = null)
1062    {
1063        sort($departmentIds);
1064        $version = $this->getUseraccountCacheVersion();
1065        $workstationKey = '';
1066        if ($workstation && !$workstation->getUseraccount()->isSuperUser()) {
1067            $workstationKey = '-workstation-' . $workstation->getUseraccount()->id;
1068        }
1069        $cacheKey = "useraccountReadByRoleAndDepartmentIds-v{$version}-$roleLevel-" . implode(',', $departmentIds) . "-$resolveReferences$workstationKey";
1070        $result = null;
1071
1072        if (!$disableCache && App::$cache && App::$cache->has($cacheKey)) {
1073            $result = App::$cache->get($cacheKey);
1074            if ($result && App::$log) {
1075                App::$log->info('Useraccount role and department list cache hit', [
1076                    'cache_key' => $cacheKey,
1077                    'role_level' => $roleLevel,
1078                    'department_ids' => $departmentIds,
1079                    'resolveReferences' => $resolveReferences,
1080                    'count' => $result->count()
1081                ]);
1082            }
1083        }
1084
1085        if (empty($result)) {
1086            $query = new Query\Useraccount(Query\Base::SELECT);
1087            $query->addResolvedReferences($resolveReferences)
1088              ->addEntityMapping()
1089              ->addOrderByName();
1090
1091            if (isset($roleLevel) && !empty($departmentIds)) {
1092                $query->addConditionRoleLevel($roleLevel);
1093                $query->addConditionDepartmentIds($departmentIds);
1094            }
1095
1096            // Exclude superusers if workstation user is not superuser
1097            if ($workstation && !$workstation->getUseraccount()->isSuperUser()) {
1098                $query->addConditionExcludeSuperusers();
1099            }
1100
1101            $statement = $this->fetchStatement($query);
1102            $result = $this->readListStatement($statement, $resolveReferences);
1103
1104            if (App::$cache) {
1105                App::$cache->set($cacheKey, $result);
1106                $this->registerCacheKeyForDepartments($departmentIds, $cacheKey);
1107                if (App::$log) {
1108                    App::$log->info('Useraccount role and department list cache set', [
1109                        'cache_key' => $cacheKey,
1110                        'role_level' => $roleLevel,
1111                        'department_ids' => $departmentIds,
1112                        'resolveReferences' => $resolveReferences,
1113                        'count' => $result->count()
1114                    ]);
1115                }
1116            }
1117        }
1118
1119        return $result;
1120    }
1121
1122    public function removeCache($useraccount, array $previousDepartmentIds = [], ?string $oldLoginName = null)
1123    {
1124        if (!App::$cache) {
1125            return;
1126        }
1127
1128        $currentVersion = $this->getUseraccountCacheVersion();
1129        $identifiers = $this->collectUseraccountIdentifiers($useraccount);
1130        // Add old loginname to identifiers if provided and not already in the list
1131        // This ensures cache keys for the old loginname are invalidated when loginname changes
1132        if ($oldLoginName !== null && !in_array($oldLoginName, $identifiers, true)) {
1133            $identifiers[] = $oldLoginName;
1134        }
1135        $removedEntityKeys = [];
1136
1137        foreach ($identifiers as $identifier) {
1138            for ($i = 0; $i <= 2; $i++) {
1139                $cacheKey = $this->sanitizeCacheKey("useraccount-v{$currentVersion}-{$identifier}-$i");
1140                if ($this->deleteCacheKey($cacheKey)) {
1141                    $removedEntityKeys[] = $cacheKey;
1142                }
1143            }
1144        }
1145
1146        $departmentIds = $this->collectDepartmentIdsForInvalidation($useraccount, $previousDepartmentIds);
1147        $removedDepartmentCaches = $this->invalidateDepartmentCaches($departmentIds);
1148
1149        $versionBumped = false;
1150        $newVersion = $currentVersion;
1151
1152        if (!$removedDepartmentCaches) {
1153            $newVersion = $currentVersion + 1;
1154            App::$cache->set(self::CACHE_VERSION_KEY, $newVersion);
1155            $versionBumped = true;
1156        }
1157
1158        if (App::$log) {
1159            App::$log->info('Useraccount caches invalidated after mutation', [
1160                'useraccount_id' => $useraccount->id ?? null,
1161                'identifiers' => $identifiers,
1162                'removed_entity_cache_keys' => $removedEntityKeys,
1163                'department_ids' => $departmentIds,
1164                'version_bumped' => $versionBumped,
1165                'old_version' => $currentVersion,
1166                'new_version' => $newVersion,
1167            ]);
1168        }
1169    }
1170}