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