Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.18% covered (success)
95.18%
79 / 83
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
SqlUserImpersonationRepository
95.12% covered (success)
95.12%
78 / 82
85.71% covered (warning)
85.71%
6 / 7
23
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canImpersonate
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 getGrantedTargetUserIds
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 getAllGrantsMap
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 getGrantedActorUserIds
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 saveGrants
85.19% covered (warning)
85.19%
23 / 27
0.00% covered (danger)
0.00%
0 / 1
7.16
 ensureTable
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\User\Infrastructure\Repository;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\User\Domain\Repository\UserImpersonationRepositoryInterface;
12use PDO;
13
14/**
15 * Concrete PDO implementation of user impersonation grants repository.
16 *
17 * @package App\Modules\User\Infrastructure\Repository
18 */
19final readonly class SqlUserImpersonationRepository implements UserImpersonationRepositoryInterface
20{
21    private const string PARAM_USER_ID = ':user_id';
22
23    /**
24     * SqlUserImpersonationRepository constructor.
25     *
26     * @param PDO $pdo Active PDO database connection.
27     * @param string $tablePrefix Database table prefix.
28     */
29    public function __construct(
30        private PDO $pdo,
31        private string $tablePrefix = 'a_'
32    ) {
33    }
34
35    /**
36     * {@inheritdoc}
37     */
38    public function canImpersonate(int $userId, int $targetUserId): bool
39    {
40        if ($userId <= 0 || $targetUserId <= 0) {
41            return false;
42        }
43
44        $tableName = $this->tablePrefix . 'mod_user_impersonation_grants';
45        $sql = "SELECT 1 FROM `{$tableName}`
46                WHERE `user_id` = :user_id AND `target_user_id` = :target_id
47                LIMIT 1";
48
49        try {
50            $stmt = $this->pdo->prepare($sql);
51            $stmt->bindValue(self::PARAM_USER_ID, $userId, PDO::PARAM_INT);
52            $stmt->bindValue(':target_id', $targetUserId, PDO::PARAM_INT);
53            $stmt->execute();
54
55            return (bool) $stmt->fetchColumn();
56        } catch (\PDOException) {
57            return false;
58        }
59    }
60
61    /**
62     * {@inheritdoc}
63     */
64    public function getGrantedTargetUserIds(int $userId): array
65    {
66        if ($userId <= 0) {
67            return [];
68        }
69
70        $tableName = $this->tablePrefix . 'mod_user_impersonation_grants';
71        $sql = "SELECT `target_user_id` FROM `{$tableName}`
72                WHERE `user_id` = :user_id
73                ORDER BY `target_user_id` ASC";
74
75        try {
76            $stmt = $this->pdo->prepare($sql);
77            $stmt->bindValue(self::PARAM_USER_ID, $userId, PDO::PARAM_INT);
78            $stmt->execute();
79
80            /** @var array<int, int|string> $ids */
81            $ids = $stmt->fetchAll(PDO::FETCH_COLUMN);
82
83            return array_map('intval', $ids);
84        } catch (\PDOException) {
85            return [];
86        }
87    }
88
89    /**
90     * {@inheritdoc}
91     */
92    public function getAllGrantsMap(): array
93    {
94        $tableName = $this->tablePrefix . 'mod_user_impersonation_grants';
95        $sql = "SELECT `user_id`, `target_user_id` FROM `{$tableName}`
96                ORDER BY `user_id` ASC, `target_user_id` ASC";
97
98        try {
99            $stmt = $this->pdo->query($sql);
100            /** @var array<int, array{user_id: int|string, target_user_id: int|string}> $rows */
101            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
102
103            $map = [];
104            foreach ($rows as $row) {
105                $uId = (int) $row['user_id'];
106                $tId = (int) $row['target_user_id'];
107                $map[$uId][] = $tId;
108            }
109
110            return $map;
111        } catch (\PDOException) {
112            return [];
113        }
114    }
115
116    /**
117     * {@inheritdoc}
118     */
119    public function getGrantedActorUserIds(int $targetUserId): array
120    {
121        if ($targetUserId <= 0) {
122            return [];
123        }
124
125        $tableName = $this->tablePrefix . 'mod_user_impersonation_grants';
126        $sql = "SELECT `user_id` FROM `{$tableName}`
127                WHERE `target_user_id` = :target_id
128                ORDER BY `user_id` ASC";
129
130        try {
131            $stmt = $this->pdo->prepare($sql);
132            $stmt->bindValue(':target_id', $targetUserId, PDO::PARAM_INT);
133            $stmt->execute();
134
135            /** @var array<int, int|string> $ids */
136            $ids = $stmt->fetchAll(PDO::FETCH_COLUMN);
137
138            return array_map('intval', $ids);
139        } catch (\PDOException) {
140            return [];
141        }
142    }
143
144    /**
145     * {@inheritdoc}
146     */
147    public function saveGrants(int $userId, array $targetUserIds, int $operatorId): void
148    {
149        if ($userId <= 0) {
150            return;
151        }
152
153        $this->ensureTable();
154
155        $tableName = $this->tablePrefix . 'mod_user_impersonation_grants';
156        $cleanTargets = array_values(array_unique(array_filter(
157            array_map('intval', $targetUserIds),
158            static fn(int $id): bool => $id > 0 && $id !== $userId
159        )));
160
161        $this->pdo->beginTransaction();
162        try {
163            $deleteSql = "DELETE FROM `{$tableName}` WHERE `user_id` = :user_id";
164            $delStmt = $this->pdo->prepare($deleteSql);
165            $delStmt->bindValue(self::PARAM_USER_ID, $userId, PDO::PARAM_INT);
166            $delStmt->execute();
167
168            if (!empty($cleanTargets)) {
169                $insertSql = "INSERT INTO `{$tableName}`
170                              (`user_id`, `target_user_id`, `created_at`, `created_by`)
171                              VALUES (:user_id, :target_user_id, NOW(6), :created_by)";
172                $insStmt = $this->pdo->prepare($insertSql);
173
174                foreach ($cleanTargets as $targetId) {
175                    $insStmt->bindValue(self::PARAM_USER_ID, $userId, PDO::PARAM_INT);
176                    $insStmt->bindValue(':target_user_id', $targetId, PDO::PARAM_INT);
177                    $insStmt->bindValue(':created_by', max(1, $operatorId), PDO::PARAM_INT);
178                    $insStmt->execute();
179                }
180            }
181
182            $this->pdo->commit();
183        } catch (\Throwable $e) {
184            if ($this->pdo->inTransaction()) {
185                $this->pdo->rollBack();
186            }
187            throw $e;
188        }
189    }
190
191    /**
192     * Ensures target grants table exists in current database connection.
193     *
194     * @return void
195     */
196    private function ensureTable(): void
197    {
198        $tableName = $this->tablePrefix . 'mod_user_impersonation_grants';
199        $sql = "CREATE TABLE IF NOT EXISTS `{$tableName}` (
200            `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
201            `user_id` int(10) unsigned NOT NULL,
202            `target_user_id` int(10) unsigned NOT NULL,
203            `created_by` int(10) unsigned DEFAULT 1,
204            `created_at` timestamp NOT NULL DEFAULT current_timestamp(),
205            PRIMARY KEY (`id`),
206            UNIQUE KEY `uniq_user_target` (`user_id`,`target_user_id`),
207            KEY `idx_target_user` (`target_user_id`)
208        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
209
210        try {
211            $this->pdo->exec($sql);
212        } catch (\Throwable) {
213            // Ignore if concurrent creation or permission issue
214        }
215    }
216}