value), $data['error_code'] ?? null, $data['actor_user_id'] !== null ? (string) ((int) $data['actor_user_id']) : null, $data['actor_tenant_id'] !== null ? (string) ((int) $data['actor_tenant_id']) : null, $data['target_type'] ?? null, $data['target_id'] !== null ? (string) ((int) $data['target_id']) : null, $data['target_uuid'] ?? null, $data['method'] ?? null, $data['path'] ?? null, $data['ip_hash'] ?? null, $data['user_agent_hash'] ?? null, $data['metadata_json'] ?? null ); return $id ? (int) $id : false; } public function listPaged(array $filters): array { $search = trim((string) ($filters['search'] ?? '')); $eventTypes = RepoQuery::normalizeStringList( $filters['event_types'] ?? '', self::FILTER_OPTIONS_LIMIT_MAX, static function (string $value): string { $value = strtolower(trim($value)); if ($value === '' || strlen($value) > 64) { return ''; } return preg_match('/^[a-z0-9._-]+$/', $value) === 1 ? $value : ''; } ); $outcome = strtolower(trim((string) ($filters['outcome'] ?? ''))); $channel = strtolower(trim((string) ($filters['channel'] ?? ''))); $createdFrom = trim((string) ($filters['created_from'] ?? '')); $createdTo = trim((string) ($filters['created_to'] ?? '')); $actorUserIds = array_slice( RepoQuery::normalizeIdList($filters['actor_user_ids'] ?? ''), 0, self::FILTER_OPTIONS_LIMIT_MAX ); $targetType = trim((string) ($filters['target_type'] ?? '')); $requestId = trim((string) ($filters['request_id'] ?? '')); [$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0); [$order, $dir] = RepoQuery::sanitizeOrder( $filters, ['id', 'created_at', 'event_type', 'outcome', 'channel', 'actor_user_id'], 'created_at', 'desc' ); $where = []; $params = []; RepoQuery::addLikeFilter( $where, $params, ['system_audit_log.event_type', 'system_audit_log.path', 'system_audit_log.error_code', 'system_audit_log.request_id'], $search ); if ($eventTypes !== []) { $where[] = 'system_audit_log.event_type in (???)'; $params[] = $eventTypes; } $normalizedOutcome = SystemAuditOutcome::tryNormalize($outcome); if ($normalizedOutcome !== null) { $where[] = 'system_audit_log.outcome = ?'; $params[] = $normalizedOutcome->value; } $normalizedChannel = SystemAuditChannel::tryNormalize($channel); if ($normalizedChannel !== null) { $where[] = 'system_audit_log.channel = ?'; $params[] = $normalizedChannel->value; } if ($actorUserIds !== []) { $where[] = 'system_audit_log.actor_user_id in (???)'; $params[] = $actorUserIds; } if ($targetType !== '') { $where[] = 'system_audit_log.target_type = ?'; $params[] = $targetType; } if ($requestId !== '') { if (preg_match('/^[a-f0-9-]{36}$/i', $requestId)) { $where[] = 'system_audit_log.request_id = ?'; $params[] = strtolower($requestId); } else { RepoQuery::addLikeFilter($where, $params, ['system_audit_log.request_id'], $requestId); } } if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) { $where[] = 'system_audit_log.created_at >= ?'; $params[] = $createdFrom . ' 00:00:00'; } if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) { $where[] = 'system_audit_log.created_at <= ?'; $params[] = $createdTo . ' 23:59:59'; } $whereSql = $where ? (' where ' . implode(' and ', $where)) : ''; $fromSql = ' from system_audit_log ' . 'left join users actor_user on actor_user.id = system_audit_log.actor_user_id ' . 'left join tenants actor_tenant on actor_tenant.id = system_audit_log.actor_tenant_id '; $total = (int) (DB::selectValue('select count(*)' . $fromSql . $whereSql, ...$params) ?? 0); $rows = DB::select( 'select system_audit_log.id, system_audit_log.event_uuid, system_audit_log.request_id, system_audit_log.channel, system_audit_log.event_type, system_audit_log.outcome, system_audit_log.error_code, system_audit_log.actor_user_id, system_audit_log.actor_tenant_id, system_audit_log.target_type, system_audit_log.target_id, system_audit_log.target_uuid, system_audit_log.method, system_audit_log.path, system_audit_log.ip_hash, system_audit_log.user_agent_hash, system_audit_log.metadata_json, system_audit_log.created_at, actor_user.id, actor_user.uuid, actor_user.display_name, actor_user.email, actor_tenant.id, actor_tenant.uuid, actor_tenant.description ' . $fromSql . $whereSql . sprintf(' order by system_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); 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)); $eventRows = DB::select( 'select system_audit_log.event_type, max(system_audit_log.created_at) as last_used_at from system_audit_log where system_audit_log.event_type is not null and system_audit_log.event_type <> \'\' group by system_audit_log.event_type order by last_used_at desc limit ?', (string) $limit ); $eventTypes = []; if (is_array($eventRows)) { foreach ($eventRows as $row) { $flat = self::flattenRow($row); $eventType = strtolower(trim((string) ($flat['event_type'] ?? ''))); if ($eventType === '' || strlen($eventType) > 64) { continue; } if (preg_match('/^[a-z0-9._-]+$/', $eventType) !== 1) { continue; } $eventTypes[] = $eventType; } } $eventTypes = array_values(array_unique($eventTypes)); $actorRows = DB::select( 'select system_audit_log.actor_user_id, max(system_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 system_audit_log left join users actor_user on actor_user.id = system_audit_log.actor_user_id where system_audit_log.actor_user_id is not null and system_audit_log.actor_user_id > 0 group by system_audit_log.actor_user_id order by last_used_at desc limit ?', (string) $limit ); $actors = []; if (is_array($actorRows)) { foreach ($actorRows as $row) { $flat = self::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 [ 'event_types' => $eventTypes, 'actors' => $actors, ]; } public function find(int $id): ?array { $row = DB::selectOne( 'select system_audit_log.id, system_audit_log.event_uuid, system_audit_log.request_id, system_audit_log.channel, system_audit_log.event_type, system_audit_log.outcome, system_audit_log.error_code, system_audit_log.actor_user_id, system_audit_log.actor_tenant_id, system_audit_log.target_type, system_audit_log.target_id, system_audit_log.target_uuid, system_audit_log.method, system_audit_log.path, system_audit_log.ip_hash, system_audit_log.user_agent_hash, system_audit_log.metadata_json, system_audit_log.created_at, actor_user.id, actor_user.uuid, actor_user.display_name, actor_user.email, actor_tenant.id, actor_tenant.uuid, actor_tenant.description from system_audit_log left join users actor_user on actor_user.id = system_audit_log.actor_user_id left join tenants actor_tenant on actor_tenant.id = system_audit_log.actor_tenant_id where system_audit_log.id = ? limit 1', (string) $id ); return $this->normalizeRow($row); } 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 system_audit_log where created_at < ?', $cutoff); return is_int($deleted) ? $deleted : 0; } private function normalizeRow(mixed $row): ?array { if (!is_array($row)) { return null; } $log = $row['system_audit_log'] ?? []; if (!is_array($log) || !isset($log['id'])) { return null; } $user = is_array($row['actor_user'] ?? null) ? $row['actor_user'] : []; $tenant = is_array($row['actor_tenant'] ?? null) ? $row['actor_tenant'] : []; $log['actor_user_uuid'] = (string) ($user['uuid'] ?? ''); $log['actor_user_display_name'] = (string) ($user['display_name'] ?? ''); $log['actor_user_email'] = (string) ($user['email'] ?? ''); $log['actor_tenant_uuid'] = (string) ($tenant['uuid'] ?? ''); $log['actor_tenant_description'] = (string) ($tenant['description'] ?? ''); return $log; } /** * @return array */ private static function flattenRow(mixed $row): array { if (!is_array($row)) { return []; } $flat = []; foreach ($row as $key => $value) { if (is_array($value)) { $flat = array_merge($flat, $value); } elseif (is_string($key)) { $flat[$key] = $value; } } return $flat; } }