value, $reasonCode, (string) $id ); return $updated !== false; } public function listPaged(array $filters): array { $search = trim((string) ($filters['search'] ?? '')); $actions = RepoQuery::normalizeStringList( $filters['actions'] ?? '', self::FILTER_OPTIONS_LIMIT_MAX, static fn (string $value): string => UserLifecycleAction::tryNormalize($value)?->value ?? '' ); $statuses = RepoQuery::normalizeStringList( $filters['statuses'] ?? '', self::FILTER_OPTIONS_LIMIT_MAX, static fn (string $value): string => UserLifecycleStatus::tryNormalize($value)?->value ?? '' ); $triggerTypes = RepoQuery::normalizeStringList( $filters['trigger_types'] ?? '', self::FILTER_OPTIONS_LIMIT_MAX, static fn (string $value): string => UserLifecycleTriggerType::tryNormalize($value)?->value ?? '' ); $actorUserIds = array_slice( RepoQuery::normalizeIdList($filters['actor_user_ids'] ?? ''), 0, self::FILTER_OPTIONS_LIMIT_MAX ); $createdFrom = trim((string) ($filters['created_from'] ?? '')); $createdTo = trim((string) ($filters['created_to'] ?? '')); [$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0); [$order, $dir] = RepoQuery::sanitizeOrder( $filters, ['id', 'created_at', 'action', 'trigger_type', 'status'], 'created_at', 'desc' ); $where = []; $params = []; RepoQuery::addLikeFilter( $where, $params, [ 'user_lifecycle_audit_log.run_uuid', 'user_lifecycle_audit_log.target_user_uuid', 'user_lifecycle_audit_log.target_user_email', 'user_lifecycle_audit_log.reason_code', ], $search ); if ($actions !== []) { $where[] = 'user_lifecycle_audit_log.action in (???)'; $params[] = $actions; } if ($statuses !== []) { $where[] = 'user_lifecycle_audit_log.status in (???)'; $params[] = $statuses; } if ($triggerTypes !== []) { $where[] = 'user_lifecycle_audit_log.trigger_type in (???)'; $params[] = $triggerTypes; } if ($actorUserIds !== []) { $where[] = 'user_lifecycle_audit_log.actor_user_id in (???)'; $params[] = $actorUserIds; } if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) { $where[] = 'user_lifecycle_audit_log.created_at >= ?'; $params[] = $createdFrom . ' 00:00:00'; } if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) { $where[] = 'user_lifecycle_audit_log.created_at <= ?'; $params[] = $createdTo . ' 23:59:59'; } $whereSql = $where ? (' where ' . implode(' and ', $where)) : ''; $fromSql = ' from user_lifecycle_audit_log ' . 'left join users actor_user on actor_user.id = user_lifecycle_audit_log.actor_user_id ' . 'left join users restored_by_user on restored_by_user.id = user_lifecycle_audit_log.restored_by_user_id ' . 'left join users restored_user on restored_user.id = user_lifecycle_audit_log.restored_user_id '; $total = (int) (DB::selectValue('select count(*)' . $fromSql . $whereSql, ...$params) ?? 0); $rows = DB::select( 'select user_lifecycle_audit_log.id, user_lifecycle_audit_log.run_uuid, user_lifecycle_audit_log.action, user_lifecycle_audit_log.trigger_type, user_lifecycle_audit_log.status, user_lifecycle_audit_log.reason_code, user_lifecycle_audit_log.policy_deactivate_days, user_lifecycle_audit_log.policy_delete_days, user_lifecycle_audit_log.actor_user_id, user_lifecycle_audit_log.target_user_id, user_lifecycle_audit_log.target_user_uuid, user_lifecycle_audit_log.target_user_email, user_lifecycle_audit_log.snapshot_version, user_lifecycle_audit_log.restored_at, user_lifecycle_audit_log.restored_by_user_id, user_lifecycle_audit_log.restored_user_id, user_lifecycle_audit_log.created_at, actor_user.uuid, actor_user.display_name, actor_user.email, restored_by_user.uuid, restored_by_user.display_name, restored_by_user.email, restored_user.uuid, restored_user.display_name, restored_user.email ' . $fromSql . $whereSql . sprintf(' order by user_lifecycle_audit_log.`%s` %s limit ? offset ?', $order, $dir), ...array_merge($params, [(string) $limit, (string) $offset]) ); $normalized = []; if (is_array($rows)) { foreach ($rows as $row) { $item = $this->normalizeRow($row, false); if ($item !== null) { $normalized[] = $item; } } } return ['total' => $total, 'rows' => $normalized]; } public function listFilterOptions(int $limit = self::FILTER_OPTIONS_LIMIT_MAX): array { $limit = max(1, min(self::FILTER_OPTIONS_LIMIT_MAX, $limit)); $actionRows = DB::select( 'select user_lifecycle_audit_log.action, max(user_lifecycle_audit_log.created_at) as last_used_at from user_lifecycle_audit_log where user_lifecycle_audit_log.action is not null and user_lifecycle_audit_log.action <> \'\' group by user_lifecycle_audit_log.action order by last_used_at desc limit ?', (string) $limit ); $actions = []; if (is_array($actionRows)) { foreach ($actionRows as $row) { $flat = RepositoryArrayHelper::flattenRow($row); $action = strtolower(trim((string) ($flat['action'] ?? ''))); if (UserLifecycleAction::tryNormalize($action) === null) { continue; } $actions[] = $action; } } $actions = array_values(array_unique($actions)); $statusRows = DB::select( 'select user_lifecycle_audit_log.status, max(user_lifecycle_audit_log.created_at) as last_used_at from user_lifecycle_audit_log where user_lifecycle_audit_log.status is not null and user_lifecycle_audit_log.status <> \'\' group by user_lifecycle_audit_log.status order by last_used_at desc limit ?', (string) $limit ); $statuses = []; if (is_array($statusRows)) { foreach ($statusRows as $row) { $flat = RepositoryArrayHelper::flattenRow($row); $status = strtolower(trim((string) ($flat['status'] ?? ''))); if (UserLifecycleStatus::tryNormalize($status) === null) { continue; } $statuses[] = $status; } } $statuses = array_values(array_unique($statuses)); $triggerRows = DB::select( 'select user_lifecycle_audit_log.trigger_type, max(user_lifecycle_audit_log.created_at) as last_used_at from user_lifecycle_audit_log where user_lifecycle_audit_log.trigger_type is not null and user_lifecycle_audit_log.trigger_type <> \'\' group by user_lifecycle_audit_log.trigger_type order by last_used_at desc limit ?', (string) $limit ); $triggerTypes = []; if (is_array($triggerRows)) { foreach ($triggerRows as $row) { $flat = RepositoryArrayHelper::flattenRow($row); $triggerType = strtolower(trim((string) ($flat['trigger_type'] ?? ''))); if (UserLifecycleTriggerType::tryNormalize($triggerType) === null) { continue; } $triggerTypes[] = $triggerType; } } $triggerTypes = array_values(array_unique($triggerTypes)); $actorRows = DB::select( 'select user_lifecycle_audit_log.actor_user_id, max(user_lifecycle_audit_log.created_at) as last_used_at, max(actor_user.id) as actor_user_exists_id, max(actor_user.display_name) as actor_user_display_name, max(actor_user.email) as actor_user_email from user_lifecycle_audit_log left join users actor_user on actor_user.id = user_lifecycle_audit_log.actor_user_id where user_lifecycle_audit_log.actor_user_id is not null and user_lifecycle_audit_log.actor_user_id > 0 group by user_lifecycle_audit_log.actor_user_id order by last_used_at desc limit ?', (string) $limit ); $actors = []; if (is_array($actorRows)) { foreach ($actorRows as $row) { $flat = RepositoryArrayHelper::flattenRow($row); $id = (int) ($flat['actor_user_id'] ?? 0); if ($id <= 0) { continue; } $actors[] = [ 'id' => $id, 'display_name' => trim((string) ($flat['actor_user_display_name'] ?? '')), 'email' => trim((string) ($flat['actor_user_email'] ?? '')), 'exists' => (int) ($flat['actor_user_exists_id'] ?? 0) > 0, ]; } } return [ 'actions' => $actions, 'statuses' => $statuses, 'trigger_types' => $triggerTypes, 'actors' => $actors, ]; } public function find(int $id): ?array { if ($id <= 0) { return null; } $row = DB::selectOne( 'select user_lifecycle_audit_log.id, user_lifecycle_audit_log.run_uuid, user_lifecycle_audit_log.action, user_lifecycle_audit_log.trigger_type, user_lifecycle_audit_log.status, user_lifecycle_audit_log.reason_code, user_lifecycle_audit_log.policy_deactivate_days, user_lifecycle_audit_log.policy_delete_days, user_lifecycle_audit_log.actor_user_id, user_lifecycle_audit_log.target_user_id, user_lifecycle_audit_log.target_user_uuid, user_lifecycle_audit_log.target_user_email, user_lifecycle_audit_log.snapshot_enc, user_lifecycle_audit_log.snapshot_version, user_lifecycle_audit_log.restored_at, user_lifecycle_audit_log.restored_by_user_id, user_lifecycle_audit_log.restored_user_id, user_lifecycle_audit_log.created_at, actor_user.uuid, actor_user.display_name, actor_user.email, restored_by_user.uuid, restored_by_user.display_name, restored_by_user.email, restored_user.uuid, restored_user.display_name, restored_user.email from user_lifecycle_audit_log left join users actor_user on actor_user.id = user_lifecycle_audit_log.actor_user_id left join users restored_by_user on restored_by_user.id = user_lifecycle_audit_log.restored_by_user_id left join users restored_user on restored_user.id = user_lifecycle_audit_log.restored_user_id where user_lifecycle_audit_log.id = ? limit 1', (string) $id ); return $this->normalizeRow($row, true); } public function findDeleteEventForRestore(int $id, bool $forUpdate = false): ?array { if ($id <= 0) { return null; } $query = 'select id, run_uuid, action, trigger_type, status, reason_code, policy_deactivate_days, policy_delete_days, actor_user_id, target_user_id, target_user_uuid, target_user_email, snapshot_enc, snapshot_version, restored_at, restored_by_user_id, restored_user_id, created_at from user_lifecycle_audit_log where id = ? and action = \'delete\' and status = \'success\' limit 1'; if ($forUpdate) { $query .= ' for update'; } $row = DB::selectOne($query, (string) $id); if (!is_array($row)) { return null; } $item = $row['user_lifecycle_audit_log'] ?? $row; return is_array($item) ? $item : null; } public function markRestored(int $id, int $restoredBy, int $restoredUserId): bool { if ($id <= 0 || $restoredBy <= 0 || $restoredUserId <= 0) { return false; } $updated = DB::update( 'update user_lifecycle_audit_log set restored_at = NOW(), restored_by_user_id = ?, restored_user_id = ? where id = ? and restored_at is null', (string) $restoredBy, (string) $restoredUserId, (string) $id ); return (int) $updated > 0; } public function purgeOlderThanDays(int $days): int { if ($days <= 0) { return 0; } $cutoff = (new \DateTimeImmutable('now', new \DateTimeZone('UTC'))) ->modify('-' . $days . ' days') ->format('Y-m-d H:i:s'); $deleted = DB::delete('delete from user_lifecycle_audit_log where created_at < ?', $cutoff); return is_int($deleted) ? $deleted : 0; } private function normalizeRow(mixed $row, bool $includeSnapshot): ?array { if (!is_array($row)) { return null; } $item = $row['user_lifecycle_audit_log'] ?? []; if (!is_array($item) || !isset($item['id'])) { return null; } $actor = is_array($row['actor_user'] ?? null) ? $row['actor_user'] : []; $restoredBy = is_array($row['restored_by_user'] ?? null) ? $row['restored_by_user'] : []; $restoredUser = is_array($row['restored_user'] ?? null) ? $row['restored_user'] : []; $item['actor_user_uuid'] = (string) ($actor['uuid'] ?? ''); $item['actor_user_display_name'] = (string) ($actor['display_name'] ?? ''); $item['actor_user_email'] = (string) ($actor['email'] ?? ''); $item['restored_by_user_uuid'] = (string) ($restoredBy['uuid'] ?? ''); $item['restored_by_user_display_name'] = (string) ($restoredBy['display_name'] ?? ''); $item['restored_by_user_email'] = (string) ($restoredBy['email'] ?? ''); $item['restored_user_uuid'] = (string) ($restoredUser['uuid'] ?? ''); $item['restored_user_display_name'] = (string) ($restoredUser['display_name'] ?? ''); $item['restored_user_email'] = (string) ($restoredUser['email'] ?? ''); if (!$includeSnapshot) { unset($item['snapshot_enc']); } return $item; } }