refactor: rename lib/ to core/ for clearer core-module separation
Rename the top-level lib/ directory to core/ so the project root immediately communicates which code is core platform and which lives in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the Composer PSR-4 path mapping moves from lib/ to core/. Module-internal lib/ directories (modules/*/lib/) are untouched. Updated across the full stack: - composer.json PSR-4 mapping - bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php) - tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer) - 26 architecture contract tests - enforcement-policy, guard-catalog, quality-gates - all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/) - bin/qa-extended.sh search paths - .claude/settings.local.json permission paths Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner → Executor → Code Review (4 findings fixed) → Security Review → Acceptance Test → Finalizer) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
189
core/Service/User/UserLifecycleService.php
Normal file
189
core/Service/User/UserLifecycleService.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\User;
|
||||
|
||||
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\Audit\UserLifecycleAuditInterface;
|
||||
|
||||
class UserLifecycleService
|
||||
{
|
||||
private const LOCK_NAME = 'user_lifecycle_maintenance';
|
||||
private const BATCH_SIZE = 500;
|
||||
|
||||
public function __construct(
|
||||
private readonly UserReadRepositoryInterface $userReadRepository,
|
||||
private readonly UserWriteRepositoryInterface $userWriteRepository,
|
||||
private readonly UserSettingsGateway $settingsGateway,
|
||||
private readonly UserLifecycleAuditInterface $userLifecycleAuditService,
|
||||
private readonly DatabaseSessionRepository $databaseSessionRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function run(?int $actorUserId = null): array
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
$runUuid = RepoQuery::uuidV4();
|
||||
$triggerType = ($actorUserId ?? 0) > 0 ? 'manual' : 'cron';
|
||||
$deactivateDays = $this->settingsGateway->getUserInactivityDeactivateDays();
|
||||
$deleteDays = $this->settingsGateway->getUserInactivityDeleteDays();
|
||||
if ($deactivateDays <= 0) {
|
||||
$deleteDays = 0;
|
||||
}
|
||||
|
||||
// Exclude admins with settings/tenant management rights — they must be deactivated manually.
|
||||
$privilegedUserIds = $this->userReadRepository->listPrivilegedUserIdsByPermissionKeys([
|
||||
PermissionService::SETTINGS_UPDATE,
|
||||
PermissionService::TENANTS_UPDATE,
|
||||
]);
|
||||
|
||||
$result = [
|
||||
'ok' => true,
|
||||
'locked' => false,
|
||||
'error' => null,
|
||||
'run_uuid' => $runUuid,
|
||||
'deactivated_count' => 0,
|
||||
'deleted_count' => 0,
|
||||
'skipped_count' => 0,
|
||||
'skipped_privileged_count' => count($privilegedUserIds),
|
||||
'policy' => [
|
||||
'deactivate_days' => $deactivateDays,
|
||||
'delete_days' => $deleteDays,
|
||||
],
|
||||
'duration_ms' => 0,
|
||||
];
|
||||
|
||||
// Advisory lock prevents concurrent runs (e.g. cron and a manual trigger at the same time).
|
||||
if (!$this->acquireLock()) {
|
||||
$result['ok'] = false;
|
||||
$result['locked'] = true;
|
||||
$result['error'] = 'lock_not_acquired';
|
||||
$result['duration_ms'] = $this->durationMs($startedAt);
|
||||
return $result;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($deactivateDays > 0) {
|
||||
while (true) {
|
||||
$ids = $this->userReadRepository->listIdsForAutoDeactivate($deactivateDays, $privilegedUserIds, self::BATCH_SIZE);
|
||||
if (!$ids) {
|
||||
break;
|
||||
}
|
||||
|
||||
$usersById = [];
|
||||
foreach ($ids as $id) {
|
||||
$user = $this->userReadRepository->find((int) $id);
|
||||
if ($user) {
|
||||
$usersById[(int) $id] = $user;
|
||||
}
|
||||
}
|
||||
$updated = $this->userWriteRepository->setInactiveByIds(
|
||||
$ids,
|
||||
$actorUserId && $actorUserId > 0 ? $actorUserId : null
|
||||
);
|
||||
if ($updated <= 0) {
|
||||
$result['ok'] = false;
|
||||
$result['error'] = 'deactivate_failed';
|
||||
break;
|
||||
}
|
||||
$result['deactivated_count'] += $updated;
|
||||
$this->userWriteRepository->bumpAuthzVersionByUserIds($ids);
|
||||
foreach ($ids as $id) {
|
||||
if (!isset($usersById[(int) $id])) {
|
||||
continue;
|
||||
}
|
||||
$this->userLifecycleAuditService->logDeactivate(
|
||||
$runUuid,
|
||||
$triggerType,
|
||||
$result['policy'],
|
||||
$actorUserId,
|
||||
$usersById[(int) $id],
|
||||
'success',
|
||||
null
|
||||
);
|
||||
}
|
||||
if (count($ids) < self::BATCH_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($deleteDays > 0) {
|
||||
while (true) {
|
||||
$ids = $this->userReadRepository->listIdsForAutoDelete($deleteDays, $privilegedUserIds, self::BATCH_SIZE);
|
||||
if (!$ids) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$user = $this->userReadRepository->find((int) $id);
|
||||
if (!$user) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$auditId = $this->userLifecycleAuditService->logDeleteWithSnapshot(
|
||||
$runUuid,
|
||||
$triggerType,
|
||||
$result['policy'],
|
||||
$actorUserId,
|
||||
$user
|
||||
);
|
||||
if (!$auditId) {
|
||||
$result['skipped_count']++;
|
||||
$this->userLifecycleAuditService->logDeleteFailure(
|
||||
$runUuid,
|
||||
$triggerType,
|
||||
$result['policy'],
|
||||
$actorUserId,
|
||||
$user,
|
||||
'audit_snapshot_failed'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
$deleted = $this->userWriteRepository->deleteByIds([(int) $id]);
|
||||
if ($deleted <= 0) {
|
||||
$result['skipped_count']++;
|
||||
$this->userLifecycleAuditService->updateStatus((int) $auditId, 'failed', 'delete_failed');
|
||||
continue;
|
||||
}
|
||||
$result['deleted_count'] += $deleted;
|
||||
}
|
||||
if (count($ids) < self::BATCH_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$result['ok'] = false;
|
||||
$result['error'] = 'unexpected_error';
|
||||
} finally {
|
||||
$this->releaseLock();
|
||||
$result['duration_ms'] = $this->durationMs($startedAt);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function acquireLock(): bool
|
||||
{
|
||||
return $this->databaseSessionRepository->acquireAdvisoryLock(self::LOCK_NAME, 0);
|
||||
}
|
||||
|
||||
private function releaseLock(): void
|
||||
{
|
||||
try {
|
||||
$this->databaseSessionRepository->releaseAdvisoryLock(self::LOCK_NAME);
|
||||
} catch (\Throwable $exception) {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
private function durationMs(float $startedAt): int
|
||||
{
|
||||
return (int) max(0, round((microtime(true) - $startedAt) * 1000));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user