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