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