diff --git a/core/App/Container/Registrars/UserRegistrar.php b/core/App/Container/Registrars/UserRegistrar.php index e727b10..74321d9 100644 --- a/core/App/Container/Registrars/UserRegistrar.php +++ b/core/App/Container/Registrars/UserRegistrar.php @@ -28,6 +28,7 @@ use MintyPHP\Service\User\UserPasswordService; use MintyPHP\Service\User\UserProfileViewService; use MintyPHP\Service\User\UserRepositoryFactory; use MintyPHP\Service\User\UserServicesFactory; +use MintyPHP\Service\User\UserSettingsGateway; use MintyPHP\Service\User\UserTenantContextService; final class UserRegistrar implements ContainerRegistrar @@ -65,7 +66,8 @@ final class UserRegistrar implements ContainerRegistrar $container->set(UserDepartmentRepository::class, static fn (AppContainer $c): UserDepartmentRepository => $c->get(UserRepositoryFactory::class)->createUserDepartmentRepository()); $container->set(UserLifecyclePolicyDashboardRepository::class, static fn (AppContainer $c): UserLifecyclePolicyDashboardRepository => $c->get(UserRepositoryFactory::class)->createUserLifecyclePolicyDashboardRepository()); $container->set(UserLifecyclePolicyDashboardService::class, static fn (AppContainer $c): UserLifecyclePolicyDashboardService => new UserLifecyclePolicyDashboardService( - $c->get(UserLifecyclePolicyDashboardRepository::class) + $c->get(UserLifecyclePolicyDashboardRepository::class), + $c->get(UserSettingsGateway::class) )); $container->set(UserProfileViewService::class, static fn (AppContainer $c): UserProfileViewService => new UserProfileViewService( $c->get(UserAccountService::class), diff --git a/core/Service/Audit/NullUserLifecycleAuditDashboard.php b/core/Service/Audit/NullUserLifecycleAuditDashboard.php index 8cee7f9..f744ccf 100644 --- a/core/Service/Audit/NullUserLifecycleAuditDashboard.php +++ b/core/Service/Audit/NullUserLifecycleAuditDashboard.php @@ -7,11 +7,6 @@ namespace MintyPHP\Service\Audit; */ final class NullUserLifecycleAuditDashboard implements UserLifecycleAuditDashboardInterface { - public function lastRun(): ?array - { - return null; - } - public function actionCountInWindow(string $action, int $days, string $status = 'success'): int { return 0; diff --git a/core/Service/Audit/UserLifecycleAuditDashboardInterface.php b/core/Service/Audit/UserLifecycleAuditDashboardInterface.php index 76d8a70..71674cb 100644 --- a/core/Service/Audit/UserLifecycleAuditDashboardInterface.php +++ b/core/Service/Audit/UserLifecycleAuditDashboardInterface.php @@ -10,13 +10,6 @@ namespace MintyPHP\Service\Audit; */ interface UserLifecycleAuditDashboardInterface { - /** - * Latest automatic (system-triggered) lifecycle run, or null when none recorded. - * - * @return array{created_at:string,status:string,action:string}|null - */ - public function lastRun(): ?array; - /** * Count successful (or status-filtered) audit-log events for a single action within the last $days. */ diff --git a/core/Service/Settings/SettingKeys.php b/core/Service/Settings/SettingKeys.php index 52e4ef2..6950ff2 100644 --- a/core/Service/Settings/SettingKeys.php +++ b/core/Service/Settings/SettingKeys.php @@ -25,6 +25,11 @@ final class SettingKeys public const SMTP_FROM_NAME_KEY = 'smtp_from_name'; public const USER_INACTIVITY_DEACTIVATE_DAYS_KEY = 'user_inactivity_deactivate_days'; public const USER_INACTIVITY_DELETE_DAYS_KEY = 'user_inactivity_delete_days'; + // Internal state keys: written by UserLifecycleService::run() and read by + // the dashboard. Not intended to appear in the admin settings UI as + // user-configurable values — they record run-trigger state. + public const USER_LIFECYCLE_LAST_RUN_AT_KEY = 'user_lifecycle_last_run_at'; + public const USER_LIFECYCLE_LAST_RUN_STATUS_KEY = 'user_lifecycle_last_run_status'; public const SYSTEM_AUDIT_ENABLED_KEY = 'system_audit_enabled'; public const SYSTEM_AUDIT_RETENTION_DAYS_KEY = 'system_audit_retention_days'; public const FRONTEND_TELEMETRY_ENABLED_KEY = 'frontend_telemetry_enabled'; diff --git a/core/Service/Settings/SettingsUserLifecycleGateway.php b/core/Service/Settings/SettingsUserLifecycleGateway.php index ff55bd1..dfdac9d 100644 --- a/core/Service/Settings/SettingsUserLifecycleGateway.php +++ b/core/Service/Settings/SettingsUserLifecycleGateway.php @@ -77,4 +77,42 @@ class SettingsUserLifecycleGateway { return $days >= self::USER_INACTIVITY_DAYS_MIN && $days <= self::USER_INACTIVITY_DAYS_MAX; } + + /** + * Record the completion of a UserLifecycleService::run() invocation. + * + * The two state keys (created_at, status) are written even when no users + * were processed (no-op runs) so that the lifecycle cockpit can show the + * tile correctly in every case. Audit-log entries cover what was done; + * these settings cover that the run itself happened. + */ + public function recordLastRun(string $createdAtUtc, string $status): bool + { + $atOk = $this->settingsMetadataGateway->set( + SettingKeys::USER_LIFECYCLE_LAST_RUN_AT_KEY, + $createdAtUtc + ); + $statusOk = $this->settingsMetadataGateway->set( + SettingKeys::USER_LIFECYCLE_LAST_RUN_STATUS_KEY, + $status + ); + return $atOk && $statusOk; + } + + /** + * @return array{created_at: string, status: string}|null + * null when no run has been recorded yet. + */ + public function getLastRun(): ?array + { + $createdAt = $this->settingsMetadataGateway->getValue(SettingKeys::USER_LIFECYCLE_LAST_RUN_AT_KEY); + if ($createdAt === null || $createdAt === '') { + return null; + } + $status = (string) ($this->settingsMetadataGateway->getValue(SettingKeys::USER_LIFECYCLE_LAST_RUN_STATUS_KEY) ?? ''); + return [ + 'created_at' => $createdAt, + 'status' => $status, + ]; + } } diff --git a/core/Service/User/UserLifecyclePolicyDashboardService.php b/core/Service/User/UserLifecyclePolicyDashboardService.php index f2a5715..6838545 100644 --- a/core/Service/User/UserLifecyclePolicyDashboardService.php +++ b/core/Service/User/UserLifecyclePolicyDashboardService.php @@ -5,17 +5,20 @@ namespace MintyPHP\Service\User; use MintyPHP\Repository\User\UserLifecyclePolicyDashboardRepositoryInterface; /** - * Computes the "Pending deletion" KPI for the user lifecycle settings dashboard. + * Computes the core-domain KPIs for the user lifecycle settings dashboard. * - * If either policy threshold (deactivate / delete days) is disabled, the service short-circuits - * to 0 without issuing a database query. + * "Pending deletion" reads the users table directly. "Last run" reads the + * settings table where UserLifecycleService::run() records every invocation + * (success, failed, no-op alike) — independent of the audit module, so the + * tile works even when audit is disabled. * * @api */ final class UserLifecyclePolicyDashboardService { public function __construct( - private readonly UserLifecyclePolicyDashboardRepositoryInterface $userLifecyclePolicyDashboardRepository + private readonly UserLifecyclePolicyDashboardRepositoryInterface $userLifecyclePolicyDashboardRepository, + private readonly UserSettingsGateway $userSettingsGateway ) { } @@ -26,4 +29,13 @@ final class UserLifecyclePolicyDashboardService } return $this->userLifecyclePolicyDashboardRepository->countPendingDeletion($deleteDays, $windowDays); } + + /** + * @return array{created_at: string, status: string}|null + * null when no run has been recorded yet. + */ + public function lastRun(): ?array + { + return $this->userSettingsGateway->getLifecycleLastRun(); + } } diff --git a/core/Service/User/UserLifecycleService.php b/core/Service/User/UserLifecycleService.php index 20bf0fc..b157280 100644 --- a/core/Service/User/UserLifecycleService.php +++ b/core/Service/User/UserLifecycleService.php @@ -163,6 +163,13 @@ class UserLifecycleService } finally { $this->releaseLock(); $result['duration_ms'] = $this->durationMs($startedAt); + // Record run-trigger state every time the lock was acquired — + // including no-op runs (0 users processed) — so the cockpit + // KPI tile reflects every actual execution. + $this->settingsGateway->recordLifecycleLastRun( + gmdate('Y-m-d H:i:s'), + $result['ok'] ? 'success' : 'failed' + ); } return $result; diff --git a/core/Service/User/UserSettingsGateway.php b/core/Service/User/UserSettingsGateway.php index 805a33b..0c408f9 100644 --- a/core/Service/User/UserSettingsGateway.php +++ b/core/Service/User/UserSettingsGateway.php @@ -57,4 +57,17 @@ class UserSettingsGateway { return $this->settingsUserLifecycleGateway->getUserInactivityDeleteDays(); } + + public function recordLifecycleLastRun(string $createdAtUtc, string $status): bool + { + return $this->settingsUserLifecycleGateway->recordLastRun($createdAtUtc, $status); + } + + /** + * @return array{created_at: string, status: string}|null + */ + public function getLifecycleLastRun(): ?array + { + return $this->settingsUserLifecycleGateway->getLastRun(); + } } diff --git a/modules/audit/lib/Module/Audit/Repository/UserLifecycleAuditRepository.php b/modules/audit/lib/Module/Audit/Repository/UserLifecycleAuditRepository.php index be9e5fb..e3d322b 100644 --- a/modules/audit/lib/Module/Audit/Repository/UserLifecycleAuditRepository.php +++ b/modules/audit/lib/Module/Audit/Repository/UserLifecycleAuditRepository.php @@ -278,35 +278,6 @@ class UserLifecycleAuditRepository return (int) $updated > 0; } - /** - * Most recent system-triggered lifecycle run (any status), used by the policy dashboard tile. - * - * @return array{created_at:string,status:string,action:string}|null - */ - public function latestSystemRun(): ?array - { - $row = DB::selectOne( - 'select created_at, status, action - from user_lifecycle_audit_log - where trigger_type = ? - order by created_at desc - limit 1', - UserLifecycleTriggerType::System->value - ); - if (!is_array($row)) { - return null; - } - $item = $row['user_lifecycle_audit_log'] ?? $row; - if (!is_array($item) || !isset($item['created_at'])) { - return null; - } - return [ - 'created_at' => (string) $item['created_at'], - 'status' => (string) ($item['status'] ?? ''), - 'action' => (string) ($item['action'] ?? ''), - ]; - } - /** * Count audit-log events for a single action+status within the last $days days (UTC). */ diff --git a/modules/audit/lib/Module/Audit/Service/UserLifecycleAuditDashboardService.php b/modules/audit/lib/Module/Audit/Service/UserLifecycleAuditDashboardService.php index 67bd239..591799e 100644 --- a/modules/audit/lib/Module/Audit/Service/UserLifecycleAuditDashboardService.php +++ b/modules/audit/lib/Module/Audit/Service/UserLifecycleAuditDashboardService.php @@ -17,11 +17,6 @@ final class UserLifecycleAuditDashboardService implements UserLifecycleAuditDash { } - public function lastRun(): ?array - { - return $this->userLifecycleAuditRepository->latestSystemRun(); - } - public function actionCountInWindow(string $action, int $days, string $status = 'success'): int { $normalizedAction = UserLifecycleAction::tryNormalize($action); diff --git a/modules/audit/tests/Module/Audit/Service/UserLifecycleAuditDashboardServiceTest.php b/modules/audit/tests/Module/Audit/Service/UserLifecycleAuditDashboardServiceTest.php index c999728..27c2965 100644 --- a/modules/audit/tests/Module/Audit/Service/UserLifecycleAuditDashboardServiceTest.php +++ b/modules/audit/tests/Module/Audit/Service/UserLifecycleAuditDashboardServiceTest.php @@ -8,34 +8,6 @@ use PHPUnit\Framework\TestCase; class UserLifecycleAuditDashboardServiceTest extends TestCase { - public function testLastRunReturnsNullWhenRepositoryHasNoSystemRun(): void - { - $repository = $this->createMock(UserLifecycleAuditRepository::class); - $repository->expects($this->once())->method('latestSystemRun')->willReturn(null); - - $service = new UserLifecycleAuditDashboardService($repository); - - $this->assertNull($service->lastRun()); - } - - public function testLastRunNormalizesRowToContractShape(): void - { - $repository = $this->createMock(UserLifecycleAuditRepository::class); - $repository->method('latestSystemRun')->willReturn([ - 'created_at' => '2026-04-25 12:00:00', - 'status' => 'success', - 'action' => 'deactivate', - ]); - - $service = new UserLifecycleAuditDashboardService($repository); - $row = $service->lastRun(); - - $this->assertIsArray($row); - $this->assertSame('2026-04-25 12:00:00', $row['created_at']); - $this->assertSame('success', $row['status']); - $this->assertSame('deactivate', $row['action']); - } - public function testActionCountInWindowReturnsZeroForUnknownAction(): void { $repository = $this->createMock(UserLifecycleAuditRepository::class); diff --git a/pages/admin/settings/user-lifecycle().php b/pages/admin/settings/user-lifecycle().php index 9d56bca..266251b 100644 --- a/pages/admin/settings/user-lifecycle().php +++ b/pages/admin/settings/user-lifecycle().php @@ -87,7 +87,10 @@ $dashboardService = app(UserLifecyclePolicyDashboardService::class); $auditDashboard = app(UserLifecycleAuditDashboardInterface::class); $auditDashboardActive = !($auditDashboard instanceof NullUserLifecycleAuditDashboard); -$lastRun = $auditDashboard->lastRun(); +// "Last run" comes from the core settings gateway — recorded by +// UserLifecycleService::run() on every invocation, including no-op runs +// and runs while the audit module is disabled. +$lastRun = $dashboardService->lastRun(); $summary = $auditDashboard->summaryByAction(30); $pendingCount = $dashboardService->pendingDeletionCount($deactivateDays, $deleteDays, 7); diff --git a/tests/Service/User/UserLifecyclePolicyDashboardServiceTest.php b/tests/Service/User/UserLifecyclePolicyDashboardServiceTest.php index 4551796..931a89a 100644 --- a/tests/Service/User/UserLifecyclePolicyDashboardServiceTest.php +++ b/tests/Service/User/UserLifecyclePolicyDashboardServiceTest.php @@ -4,18 +4,27 @@ namespace MintyPHP\Tests\Service\User; use MintyPHP\Repository\User\UserLifecyclePolicyDashboardRepositoryInterface; use MintyPHP\Service\User\UserLifecyclePolicyDashboardService; +use MintyPHP\Service\User\UserSettingsGateway; use PHPUnit\Framework\TestCase; class UserLifecyclePolicyDashboardServiceTest extends TestCase { + private function service( + ?UserLifecyclePolicyDashboardRepositoryInterface $repository = null, + ?UserSettingsGateway $settingsGateway = null + ): UserLifecyclePolicyDashboardService { + return new UserLifecyclePolicyDashboardService( + $repository ?? $this->createMock(UserLifecyclePolicyDashboardRepositoryInterface::class), + $settingsGateway ?? $this->createMock(UserSettingsGateway::class) + ); + } + public function testReturnsZeroWhenDeactivateDaysIsZero(): void { $repository = $this->createMock(UserLifecyclePolicyDashboardRepositoryInterface::class); $repository->expects($this->never())->method('countPendingDeletion'); - $service = new UserLifecyclePolicyDashboardService($repository); - - $this->assertSame(0, $service->pendingDeletionCount(0, 365, 7)); + $this->assertSame(0, $this->service($repository)->pendingDeletionCount(0, 365, 7)); } public function testReturnsZeroWhenDeleteDaysIsZero(): void @@ -23,9 +32,7 @@ class UserLifecyclePolicyDashboardServiceTest extends TestCase $repository = $this->createMock(UserLifecyclePolicyDashboardRepositoryInterface::class); $repository->expects($this->never())->method('countPendingDeletion'); - $service = new UserLifecyclePolicyDashboardService($repository); - - $this->assertSame(0, $service->pendingDeletionCount(180, 0, 7)); + $this->assertSame(0, $this->service($repository)->pendingDeletionCount(180, 0, 7)); } public function testReturnsZeroWhenWindowDaysIsZeroOrNegative(): void @@ -33,7 +40,7 @@ class UserLifecyclePolicyDashboardServiceTest extends TestCase $repository = $this->createMock(UserLifecyclePolicyDashboardRepositoryInterface::class); $repository->expects($this->never())->method('countPendingDeletion'); - $service = new UserLifecyclePolicyDashboardService($repository); + $service = $this->service($repository); $this->assertSame(0, $service->pendingDeletionCount(180, 365, 0)); $this->assertSame(0, $service->pendingDeletionCount(180, 365, -1)); @@ -47,9 +54,7 @@ class UserLifecyclePolicyDashboardServiceTest extends TestCase ->with(365, 7) ->willReturn(42); - $service = new UserLifecyclePolicyDashboardService($repository); - - $this->assertSame(42, $service->pendingDeletionCount(180, 365, 7)); + $this->assertSame(42, $this->service($repository)->pendingDeletionCount(180, 365, 7)); } public function testReturnsRepositoryCountVerbatim(): void @@ -57,8 +62,31 @@ class UserLifecyclePolicyDashboardServiceTest extends TestCase $repository = $this->createMock(UserLifecyclePolicyDashboardRepositoryInterface::class); $repository->method('countPendingDeletion')->willReturn(0); - $service = new UserLifecyclePolicyDashboardService($repository); + $this->assertSame(0, $this->service($repository)->pendingDeletionCount(180, 365, 7)); + } - $this->assertSame(0, $service->pendingDeletionCount(180, 365, 7)); + public function testLastRunReturnsNullWhenSettingsHaveNoRecord(): void + { + $settingsGateway = $this->createMock(UserSettingsGateway::class); + $settingsGateway->expects($this->once()) + ->method('getLifecycleLastRun') + ->willReturn(null); + + $this->assertNull($this->service(null, $settingsGateway)->lastRun()); + } + + public function testLastRunReturnsRecordedSettingsVerbatim(): void + { + $settingsGateway = $this->createMock(UserSettingsGateway::class); + $settingsGateway->method('getLifecycleLastRun')->willReturn([ + 'created_at' => '2026-04-26 17:30:00', + 'status' => 'success', + ]); + + $row = $this->service(null, $settingsGateway)->lastRun(); + + $this->assertIsArray($row); + $this->assertSame('2026-04-26 17:30:00', $row['created_at']); + $this->assertSame('success', $row['status']); } }