Follow-up to commits144d841(Embed user lifecycle audit into settings page) +8c07c2c(Revert "fix(user-lifecycle-panel): restore filter-options population"). The audit page move turned the user-lifecycle list-page into a slot template included by pages/admin/settings/user-lifecycle. Slot templates run during the view phase, where MintyPHP forbids DB calls ("Database can only be used in MintyPHP action", DB.php). The populated actor / action / status / trigger filter dropdowns — previously fed by UserLifecycleAuditService::filterOptions() during the action phase of the now-deleted index().php — therefore had to go away and were not replaced. The service method and its underlying UserLifecycleAuditRepository::listFilterOptions() became dead code in the same commit but were kept; this cleans them up. Removed: * UserLifecycleAuditService::filterOptions(int) — single caller was the deleted index() action. * UserLifecycleAuditRepository::listFilterOptions(int) — single caller was the service method above. * Stale phpstan-baseline.neon entry for the service method. * Now-unused RepositoryArrayHelper import in the repository. Sister services (Api / System / Import) still use their own filterOptions() — their list-pages remain action-driven and can populate dropdowns from the DB. The constant FILTER_OPTIONS_LIMIT_MAX in UserLifecycleAuditRepository is kept; listPaged() reuses it as a generic upper bound for multi-value filter inputs. The reduced-fidelity actor filter in the settings panel (URL-pinned IDs only, no DB-side enumeration) is the architecturally correct trade-off for the slot-based isolation. A future provider mechanism on the slot manifest could restore richer filter population without breaking module isolation, but that is out of scope here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
254 lines
7.8 KiB
PHP
254 lines
7.8 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Module\Audit\Service;
|
|
|
|
use MintyPHP\Module\Audit\Domain\UserLifecycleAction;
|
|
use MintyPHP\Module\Audit\Domain\UserLifecycleStatus;
|
|
use MintyPHP\Module\Audit\Domain\UserLifecycleTriggerType;
|
|
use MintyPHP\Module\Audit\Repository\UserLifecycleAuditRepository;
|
|
use MintyPHP\Service\Audit\UserLifecycleAuditInterface;
|
|
use MintyPHP\Support\Crypto;
|
|
|
|
class UserLifecycleAuditService implements UserLifecycleAuditInterface
|
|
{
|
|
private const RETENTION_DAYS = 365;
|
|
private const SNAPSHOT_VERSION = 1;
|
|
|
|
private const SNAPSHOT_FIELDS = [
|
|
'uuid',
|
|
'first_name',
|
|
'last_name',
|
|
'display_name',
|
|
'email',
|
|
'profile_description',
|
|
'job_title',
|
|
'phone',
|
|
'mobile',
|
|
'short_dial',
|
|
'address',
|
|
'postal_code',
|
|
'city',
|
|
'region',
|
|
'country',
|
|
'hire_date',
|
|
'locale',
|
|
'theme',
|
|
'email_verified_at',
|
|
'last_login_at',
|
|
'last_login_provider',
|
|
'primary_tenant_id',
|
|
'current_tenant_id',
|
|
'created',
|
|
'modified',
|
|
'active_changed_at',
|
|
];
|
|
|
|
public function __construct(private readonly UserLifecycleAuditRepository $userLifecycleAuditRepository)
|
|
{
|
|
}
|
|
|
|
public function logDeactivate(
|
|
string $runUuid,
|
|
string $triggerType,
|
|
array $policy,
|
|
?int $actorUserId,
|
|
array $targetUser,
|
|
string $status = UserLifecycleStatus::Success->value,
|
|
?string $reasonCode = null
|
|
): bool {
|
|
try {
|
|
return $this->userLifecycleAuditRepository->create($this->buildBaseRow(
|
|
$runUuid,
|
|
UserLifecycleAction::Deactivate->value,
|
|
$triggerType,
|
|
$status,
|
|
$reasonCode,
|
|
$policy,
|
|
$actorUserId,
|
|
$targetUser
|
|
)) !== false;
|
|
} catch (\Throwable $exception) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function logDeleteWithSnapshot(
|
|
string $runUuid,
|
|
string $triggerType,
|
|
array $policy,
|
|
?int $actorUserId,
|
|
array $targetUser
|
|
): int|false {
|
|
try {
|
|
$snapshot = $this->buildSnapshot($targetUser);
|
|
$json = json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
if (!is_string($json)) {
|
|
return false;
|
|
}
|
|
$row = $this->buildBaseRow(
|
|
$runUuid,
|
|
UserLifecycleAction::Delete->value,
|
|
$triggerType,
|
|
UserLifecycleStatus::Success->value,
|
|
null,
|
|
$policy,
|
|
$actorUserId,
|
|
$targetUser
|
|
);
|
|
$row['snapshot_enc'] = Crypto::encryptString($json);
|
|
$row['snapshot_version'] = self::SNAPSHOT_VERSION;
|
|
return $this->userLifecycleAuditRepository->create($row);
|
|
} catch (\Throwable $exception) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function logDeleteFailure(
|
|
string $runUuid,
|
|
string $triggerType,
|
|
array $policy,
|
|
?int $actorUserId,
|
|
array $targetUser,
|
|
string $reasonCode
|
|
): bool {
|
|
try {
|
|
return $this->userLifecycleAuditRepository->create($this->buildBaseRow(
|
|
$runUuid,
|
|
UserLifecycleAction::Delete->value,
|
|
$triggerType,
|
|
UserLifecycleStatus::Failed->value,
|
|
$reasonCode,
|
|
$policy,
|
|
$actorUserId,
|
|
$targetUser
|
|
)) !== false;
|
|
} catch (\Throwable $exception) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function logRestore(
|
|
string $runUuid,
|
|
string $triggerType,
|
|
array $policy,
|
|
?int $actorUserId,
|
|
array $targetUser,
|
|
string $status = UserLifecycleStatus::Success->value,
|
|
?string $reasonCode = null
|
|
): bool {
|
|
try {
|
|
return $this->userLifecycleAuditRepository->create($this->buildBaseRow(
|
|
$runUuid,
|
|
UserLifecycleAction::Restore->value,
|
|
$triggerType,
|
|
$status,
|
|
$reasonCode,
|
|
$policy,
|
|
$actorUserId,
|
|
$targetUser
|
|
)) !== false;
|
|
} catch (\Throwable $exception) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function markDeleteEventRestored(int $auditId, int $restoredByUserId, int $restoredUserId): bool
|
|
{
|
|
try {
|
|
return $this->userLifecycleAuditRepository->markRestored($auditId, $restoredByUserId, $restoredUserId);
|
|
} catch (\Throwable $exception) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function listPaged(array $filters): array
|
|
{
|
|
return $this->userLifecycleAuditRepository->listPaged($filters);
|
|
}
|
|
|
|
public function find(int $id): ?array
|
|
{
|
|
return $this->userLifecycleAuditRepository->find($id);
|
|
}
|
|
|
|
public function findDeleteEventForRestore(int $id, bool $forUpdate = false): ?array
|
|
{
|
|
return $this->userLifecycleAuditRepository->findDeleteEventForRestore($id, $forUpdate);
|
|
}
|
|
|
|
public function decryptSnapshot(array $event): ?array
|
|
{
|
|
$enc = trim((string) ($event['snapshot_enc'] ?? ''));
|
|
if ($enc === '') {
|
|
return null;
|
|
}
|
|
try {
|
|
$json = Crypto::decryptString($enc);
|
|
$decoded = json_decode($json, true);
|
|
return is_array($decoded) ? $decoded : null;
|
|
} catch (\Throwable $exception) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public function updateStatus(int $id, string $status, ?string $reasonCode = null): bool
|
|
{
|
|
try {
|
|
return $this->userLifecycleAuditRepository->updateStatus($id, $status, $reasonCode);
|
|
} catch (\Throwable $exception) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function purgeExpired(): int
|
|
{
|
|
return $this->userLifecycleAuditRepository->purgeOlderThanDays(self::RETENTION_DAYS);
|
|
}
|
|
|
|
private function buildBaseRow(
|
|
string $runUuid,
|
|
string $action,
|
|
string $triggerType,
|
|
string $status,
|
|
?string $reasonCode,
|
|
array $policy,
|
|
?int $actorUserId,
|
|
array $targetUser
|
|
): array {
|
|
$action = UserLifecycleAction::normalizeOr($action, UserLifecycleAction::Deactivate)->value;
|
|
$triggerType = UserLifecycleTriggerType::normalizeOr($triggerType, UserLifecycleTriggerType::System)->value;
|
|
$status = UserLifecycleStatus::normalizeOr($status, UserLifecycleStatus::Failed)->value;
|
|
|
|
return [
|
|
'run_uuid' => trim($runUuid),
|
|
'action' => $action,
|
|
'trigger_type' => $triggerType,
|
|
'status' => $status,
|
|
'reason_code' => $reasonCode !== null ? trim($reasonCode) : null,
|
|
'policy_deactivate_days' => max(0, (int) ($policy['deactivate_days'] ?? 0)),
|
|
'policy_delete_days' => max(0, (int) ($policy['delete_days'] ?? 0)),
|
|
'actor_user_id' => ($actorUserId ?? 0) > 0 ? (int) $actorUserId : null,
|
|
'target_user_id' => ((int) ($targetUser['id'] ?? 0)) > 0 ? (int) $targetUser['id'] : null,
|
|
'target_user_uuid' => $this->stringOrNull($targetUser['uuid'] ?? null),
|
|
'target_user_email' => $this->stringOrNull($targetUser['email'] ?? null),
|
|
'snapshot_enc' => null,
|
|
'snapshot_version' => self::SNAPSHOT_VERSION,
|
|
];
|
|
}
|
|
|
|
private function buildSnapshot(array $targetUser): array
|
|
{
|
|
$snapshot = [];
|
|
foreach (self::SNAPSHOT_FIELDS as $field) {
|
|
$snapshot[$field] = $targetUser[$field] ?? null;
|
|
}
|
|
return $snapshot;
|
|
}
|
|
|
|
private function stringOrNull(mixed $value): ?string
|
|
{
|
|
$value = trim((string) $value);
|
|
return $value !== '' ? $value : null;
|
|
}
|
|
}
|