Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.28% covered (warning)
84.28%
134 / 159
37.50% covered (danger)
37.50%
3 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
StructureReassignmentService
84.18% covered (warning)
84.18%
133 / 158
37.50% covered (danger)
37.50%
3 / 8
47.66
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
 canDeleteStructure
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 reassignAndRemoveStructure
81.82% covered (warning)
81.82%
36 / 44
0.00% covered (danger)
0.00%
0 / 1
12.87
 canDeleteUser
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 reassignAndRemoveUser
77.14% covered (warning)
77.14%
27 / 35
0.00% covered (danger)
0.00%
0 / 1
11.19
 reassignModuleRecordsOwner
66.67% covered (warning)
66.67%
10 / 15
0.00% covered (danger)
0.00%
0 / 1
4.59
 countUserOwnedRecords
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
5.01
 reassignUserRecordsOwner
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
5.02
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\Structure\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Database\Security\SqlIdentifierValidator;
12use App\Modules\Structure\Domain\Exception\StructureReassignRequiredException;
13use App\Modules\Structure\Domain\Repository\StructureMembershipRepositoryInterface;
14use App\Modules\Structure\Domain\Repository\StructureRepositoryInterface;
15use InvalidArgumentException;
16use PDO;
17
18/**
19 * Structure Reassignment Application Service.
20 *
21 * Implements Rule 2 Reassign Flow: prevents deletion of structure nodes or users with
22 * active dependencies and handles atomic reassignment of records, memberships, and subtrees.
23 *
24 * @package App\Modules\Structure\Application\Service
25 */
26final readonly class StructureReassignmentService implements StructureReassignmentServiceInterface
27{
28    /**
29     * StructureReassignmentService constructor.
30     *
31     * @param PDO $pdo Database connection.
32     * @param StructureRepositoryInterface $structureRepo Structure repository.
33     * @param StructureMembershipRepositoryInterface $membershipRepo Membership repository.
34     * @param string $tablePrefix Database table prefix.
35     */
36    public function __construct(
37        private PDO $pdo,
38        private StructureRepositoryInterface $structureRepo,
39        private StructureMembershipRepositoryInterface $membershipRepo,
40        private string $tablePrefix = 'a_'
41    ) {
42    }
43
44    private const string SQL_ACTIVE_MODULE_TABLES =
45        'SELECT table_name FROM a_core_module_records WHERE is_active = 1 AND table_name IS NOT NULL';
46    private const string SQL_COUNT_INFOSCHEMA_COLS =
47        'SELECT COUNT(*) FROM information_schema.columns ';
48    private const string SQL_WHERE_INFOSCHEMA_OWNER_TYPE =
49        "WHERE table_schema = DATABASE() AND table_name = :tname AND column_name = 'owner_type'";
50    private const string SQL_WHERE_INFOSCHEMA_OWNER =
51        "WHERE table_schema = DATABASE() AND table_name = :tname AND column_name = 'owner'";
52
53    /**
54     * Checks whether a structure node can be safely deleted without reassignment.
55     *
56     * @param int $structureId Structure node ID.
57     * @return array{can_delete: bool, record_count: int, user_count: int, child_count: int}
58     */
59    public function canDeleteStructure(int $structureId): array
60    {
61        $records = $this->structureRepo->countAssignedRecords($structureId);
62        $users = $this->membershipRepo->countUsersInStructure($structureId);
63        $children = $this->structureRepo->countChildNodes($structureId);
64
65        return [
66            'can_delete'   => ($records === 0 && $users === 0 && $children === 0),
67            'record_count' => $records,
68            'user_count'   => $users,
69            'child_count'  => $children,
70        ];
71    }
72
73    /**
74     * Reassigns all dependencies of a structure node to target node and deletes the source.
75     *
76     * @param int $sourceStructureId Source structure node ID to delete.
77     * @param int $targetStructureId Target structure node ID to receive dependencies.
78     * @return void
79     * @throws InvalidArgumentException When source and target IDs match or target does not exist.
80     * @throws StructureReassignRequiredException When target has no users.
81     */
82    public function reassignAndRemoveStructure(int $sourceStructureId, int $targetStructureId): void
83    {
84        if ($sourceStructureId <= 0 || $targetStructureId <= 0 || $sourceStructureId === $targetStructureId) {
85            throw new InvalidArgumentException('Invalid source or target structure node ID for reassignment.');
86        }
87
88        $targetNode = $this->structureRepo->findById($targetStructureId);
89        if ($targetNode === null) {
90            throw new InvalidArgumentException(sprintf('Target structure ID %d does not exist.', $targetStructureId));
91        }
92
93        $ownsTransaction = false;
94        if (!$this->pdo->inTransaction()) {
95            $this->pdo->beginTransaction();
96            $ownsTransaction = true;
97        }
98        try {
99            // 1. Move child structure nodes to target parent
100            $structTable = $this->tablePrefix . 'mod_structure_records';
101            $stmtChildren = $this->pdo->prepare(
102                "UPDATE `{$structTable}` SET parent_id = :target WHERE parent_id = :source"
103            );
104            $stmtChildren->execute([
105                ':target' => $targetStructureId,
106                ':source' => $sourceStructureId,
107            ]);
108
109            // 2. Reassign user memberships
110            $relTable = $this->tablePrefix . 'rel_users_structure';
111            $userIds = $this->membershipRepo->getStructureUserIds($sourceStructureId);
112            foreach ($userIds as $uid) {
113                $this->membershipRepo->assignUser($uid, $targetStructureId);
114            }
115            $stmtDelRel = $this->pdo->prepare("DELETE FROM `{$relTable}` WHERE structure_id = :source");
116            $stmtDelRel->execute([':source' => $sourceStructureId]);
117
118            // 3. Reassign co-ownership table
119            $coOwnersTable = $this->tablePrefix . 'core_record_co_owners';
120            $stmtCo = $this->pdo->prepare(
121                "UPDATE IGNORE `{$coOwnersTable}` SET structure_id = :target "
122                . "WHERE owner_type = 'structure' AND structure_id = :source"
123            );
124            $stmtCo->execute([
125                ':target' => $targetStructureId,
126                ':source' => $sourceStructureId,
127            ]);
128            $stmtCleanCo = $this->pdo->prepare(
129                "DELETE FROM `{$coOwnersTable}` WHERE owner_type = 'structure' AND structure_id = :source"
130            );
131            $stmtCleanCo->execute([':source' => $sourceStructureId]);
132
133            // 4. Reassign module records ownership
134            $this->reassignModuleRecordsOwner($sourceStructureId, $targetStructureId);
135
136            // 5. Delete source node
137            $this->structureRepo->delete($sourceStructureId);
138
139            if ($ownsTransaction && $this->pdo->inTransaction()) {
140                $this->pdo->commit();
141            }
142        } catch (\Throwable $e) {
143            if ($ownsTransaction && $this->pdo->inTransaction()) {
144                $this->pdo->rollBack();
145            }
146            throw $e;
147        }
148    }
149
150    /**
151     * Checks whether a user can be safely deleted without reassignment.
152     *
153     * @param int $userId Target user ID.
154     * @return array{can_delete: bool, record_count: int}
155     */
156    public function canDeleteUser(int $userId): array
157    {
158        $records = $this->countUserOwnedRecords($userId);
159
160        return [
161            'can_delete'   => ($records === 0),
162            'record_count' => $records,
163        ];
164    }
165
166    /**
167     * Reassigns all records owned by user to target user and removes user assignments.
168     *
169     * @param int $sourceUserId Source user ID.
170     * @param int $targetUserId Target user ID to receive records.
171     * @return void
172     */
173    public function reassignAndRemoveUser(int $sourceUserId, int $targetUserId): void
174    {
175        if ($sourceUserId <= 0 || $targetUserId <= 0 || $sourceUserId === $targetUserId) {
176            throw new InvalidArgumentException('Invalid source or target user ID for reassignment.');
177        }
178
179        $ownsTransaction = false;
180        if (!$this->pdo->inTransaction()) {
181            $this->pdo->beginTransaction();
182            $ownsTransaction = true;
183        }
184        try {
185            // 1. Reassign module records owned by user
186            $this->reassignUserRecordsOwner($sourceUserId, $targetUserId);
187
188            // 2. Reassign co-owners
189            $coOwnersTable = $this->tablePrefix . 'core_record_co_owners';
190            $stmtCo = $this->pdo->prepare(
191                "UPDATE IGNORE `{$coOwnersTable}` SET user_id = :target "
192                . "WHERE (owner_type = 'user' OR owner_type IS NULL) AND user_id = :source"
193            );
194            $stmtCo->execute([
195                ':target' => $targetUserId,
196                ':source' => $sourceUserId,
197            ]);
198            $stmtCleanCo = $this->pdo->prepare(
199                "DELETE FROM `{$coOwnersTable}` WHERE (owner_type = 'user' OR owner_type IS NULL) "
200                . "AND user_id = :source"
201            );
202            $stmtCleanCo->execute([':source' => $sourceUserId]);
203
204            // 3. Remove structure memberships
205            $relTable = $this->tablePrefix . 'rel_users_structure';
206            $stmtRel = $this->pdo->prepare("DELETE FROM `{$relTable}` WHERE user_id = :source");
207            $stmtRel->execute([':source' => $sourceUserId]);
208
209            // 4. Mark user deleted in a_mod_users_records
210            $usersTable = $this->tablePrefix . 'mod_users_records';
211            $stmtUser = $this->pdo->prepare(
212                "UPDATE `{$usersTable}` SET special_access = 3, status = 'inactive' WHERE id = :source"
213            );
214            $stmtUser->execute([':source' => $sourceUserId]);
215
216            if ($ownsTransaction && $this->pdo->inTransaction()) {
217                $this->pdo->commit();
218            }
219        } catch (\Throwable $e) {
220            if ($ownsTransaction && $this->pdo->inTransaction()) {
221                $this->pdo->rollBack();
222            }
223            throw $e;
224        }
225    }
226
227    /**
228     * Reassigns module records owned by structure.
229     *
230     * @param int $sourceId Source structure ID.
231     * @param int $targetId Target structure ID.
232     * @return void
233     */
234    private function reassignModuleRecordsOwner(int $sourceId, int $targetId): void
235    {
236        $stmtMod = $this->pdo->query(self::SQL_ACTIVE_MODULE_TABLES);
237        if ($stmtMod === false) {
238            return;
239        }
240
241        /** @var array<int, array<string, mixed>> $modules */
242        $modules = $stmtMod->fetchAll(PDO::FETCH_ASSOC);
243        foreach ($modules as $mod) {
244            $tableName = (string)$mod['table_name'];
245            $chk = $this->pdo->prepare(
246                self::SQL_COUNT_INFOSCHEMA_COLS . self::SQL_WHERE_INFOSCHEMA_OWNER_TYPE
247            );
248            $chk->execute([':tname' => $tableName]);
249            if ((int)$chk->fetchColumn() > 0) {
250                $q = $this->pdo->prepare(
251                    "UPDATE `{$tableName}` SET owner = :target WHERE owner_type = 'structure' AND owner = :source"
252                );
253                $q->execute([':target' => $targetId, ':source' => $sourceId]);
254            }
255        }
256    }
257
258    /**
259     * Counts all records owned by user across active modules.
260     *
261     * @param int $userId User ID.
262     * @return int
263     */
264    private function countUserOwnedRecords(int $userId): int
265    {
266        $stmtMod = $this->pdo->query(self::SQL_ACTIVE_MODULE_TABLES);
267        if ($stmtMod === false) {
268            return 0;
269        }
270
271        $count = 0;
272        /** @var array<int, array<string, mixed>> $modules */
273        $modules = $stmtMod->fetchAll(PDO::FETCH_ASSOC);
274        foreach ($modules as $mod) {
275            $tableName = (string)$mod['table_name'];
276            $chk = $this->pdo->prepare(
277                self::SQL_COUNT_INFOSCHEMA_COLS . self::SQL_WHERE_INFOSCHEMA_OWNER
278            );
279            $chk->execute([':tname' => $tableName]);
280            if ((int)$chk->fetchColumn() > 0) {
281                $hasOwnerType = $this->pdo->prepare(
282                    self::SQL_COUNT_INFOSCHEMA_COLS . self::SQL_WHERE_INFOSCHEMA_OWNER_TYPE
283                );
284                $hasOwnerType->execute([':tname' => $tableName]);
285                $ownerTypeClause = ((int)$hasOwnerType->fetchColumn() > 0)
286                    ? " AND (owner_type = 'user' OR owner_type IS NULL)"
287                    : "";
288
289                $safeTable = SqlIdentifierValidator::quote($tableName);
290                $q = $this->pdo->prepare(
291                    "SELECT COUNT(*) FROM {$safeTable} WHERE owner = :uid" . $ownerTypeClause
292                );
293                $q->execute([':uid' => $userId]);
294                $count += (int)$q->fetchColumn();
295            }
296        }
297
298        return $count;
299    }
300
301    /**
302     * Reassigns module records owned by user to new user.
303     *
304     * @param int $sourceUserId Source user ID.
305     * @param int $targetUserId Target user ID.
306     * @return void
307     */
308    private function reassignUserRecordsOwner(int $sourceUserId, int $targetUserId): void
309    {
310        $stmtMod = $this->pdo->query(self::SQL_ACTIVE_MODULE_TABLES);
311        if ($stmtMod === false) {
312            return;
313        }
314
315        /** @var array<int, array<string, mixed>> $modules */
316        $modules = $stmtMod->fetchAll(PDO::FETCH_ASSOC);
317        foreach ($modules as $mod) {
318            $tableName = (string)$mod['table_name'];
319            $chk = $this->pdo->prepare(
320                self::SQL_COUNT_INFOSCHEMA_COLS . self::SQL_WHERE_INFOSCHEMA_OWNER
321            );
322            $chk->execute([':tname' => $tableName]);
323            if ((int)$chk->fetchColumn() > 0) {
324                $hasOwnerType = $this->pdo->prepare(
325                    self::SQL_COUNT_INFOSCHEMA_COLS . self::SQL_WHERE_INFOSCHEMA_OWNER_TYPE
326                );
327                $hasOwnerType->execute([':tname' => $tableName]);
328                $ownerTypeClause = ((int)$hasOwnerType->fetchColumn() > 0)
329                    ? " AND (owner_type = 'user' OR owner_type IS NULL)"
330                    : "";
331
332                $safeTable = SqlIdentifierValidator::quote($tableName);
333                $q = $this->pdo->prepare(
334                    "UPDATE {$safeTable} SET owner = :target WHERE owner = :source" . $ownerTypeClause
335                );
336                $q->execute([':target' => $targetUserId, ':source' => $sourceUserId]);
337            }
338        }
339    }
340}