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