*/ private static array $touchedSessions = []; public function __construct() { system_session_activity_schema_bootstrap::ensureTables(); } public function touchUser(users_o $user, string $token): void { $customerNumber = null; if (isset($user->customer_number)) { try { $customerNumber = (int)$user->customer_number->value(); } catch (Exception) { $customerNumber = null; } } $this->touch('user', (int)$user->id, $token, $customerNumber); } public function touchSubuser(subusers_o $subuser, string $token, ?int $customerNumberContext = null): void { $this->touch('subuser', (int)$subuser->id, $token, $customerNumberContext); } public function touch(string $sessionKind, int $principalId, string $token, ?int $customerNumberContext = null): void { global $db; $token = trim($token); if ($token === '' || $principalId <= 0) { return; } $sessionHash = hash('sha256', $token); if (isset(self::$touchedSessions[$sessionHash])) { return; } self::$touchedSessions[$sessionHash] = true; $headers = function_exists('getallheaders') ? (getallheaders() ?: []) : []; $userAgent = trim((string)($headers['User-Agent'] ?? $headers['user-agent'] ?? '')); $lastRoute = trim((string)($_SERVER['REQUEST_URI'] ?? '')); if ($lastRoute !== '') { $lastRoute = explode('?', $lastRoute)[0] ?? $lastRoute; } $sessionHashEscaped = $db->escape_string($sessionHash); $sessionKindEscaped = $db->escape_string($sessionKind); $deviceTypeEscaped = $db->escape_string(self::detectDeviceType($userAgent)); $userAgentEscaped = $db->escape_string(substr($userAgent, 0, 1024)); $lastRouteEscaped = $db->escape_string(substr($lastRoute, 0, 255)); $customerNumberSql = $customerNumberContext === null ? 'NULL' : (string)(int)$customerNumberContext; $currentUtcDateTime = self::utcSqlDateTime(); $sql = "INSERT INTO system_session_activity ( session_hash, session_kind, principal_id, customer_number_context, device_type, user_agent, last_route, first_seen_at, last_seen_at ) VALUES ( '$sessionHashEscaped', '$sessionKindEscaped', " . (int)$principalId . ", $customerNumberSql, '$deviceTypeEscaped', '$userAgentEscaped', '$lastRouteEscaped', '$currentUtcDateTime', '$currentUtcDateTime' ) ON DUPLICATE KEY UPDATE customer_number_context = VALUES(customer_number_context), device_type = VALUES(device_type), user_agent = VALUES(user_agent), last_route = VALUES(last_route), last_seen_at = '$currentUtcDateTime'"; $db->query($sql); } public function getSnapshot(int $limit = 50): array { global $db; system_session_activity_schema_bootstrap::ensureTables(); $limit = max(1, min(200, $limit)); $cutoff = self::utcSqlDateTime(time() - (self::ACTIVE_WINDOW_MINUTES * 60)); $cutoffEscaped = $db->escape_string($cutoff); $activeUsersResult = $db->query( "SELECT COUNT(DISTINCT CONCAT(session_kind, ':', principal_id)) AS c FROM system_session_activity WHERE last_seen_at >= '$cutoffEscaped'" ); $activeSessionsResult = $db->query( "SELECT COUNT(*) AS c FROM system_session_activity WHERE last_seen_at >= '$cutoffEscaped'" ); $recentRows = $db->query( "SELECT s.session_kind, s.principal_id, s.customer_number_context, s.device_type, s.user_agent, s.last_route, s.first_seen_at, s.last_seen_at, u.display_name AS user_display_name, u.customer_number AS user_customer_number, su.name AS subuser_name, su.username AS subuser_username FROM system_session_activity s LEFT JOIN users u ON s.session_kind = 'user' AND u.id = s.principal_id LEFT JOIN subusers su ON s.session_kind = 'subuser' AND su.id = s.principal_id ORDER BY s.last_seen_at DESC LIMIT $limit" ); $recentSessions = []; while ($row = $recentRows->fetch_assoc()) { $isUser = ($row['session_kind'] ?? '') === 'user'; $displayName = $isUser ? trim((string)($row['user_display_name'] ?? '')) : trim((string)($row['subuser_name'] ?? '')); $displayNameKey = null; $displayNameParams = []; if ($displayName === '') { if ($isUser && !empty($row['user_customer_number'])) { $displayName = 'Customer ' . $row['user_customer_number']; $displayNameKey = 'customer_number'; $displayNameParams = ['number' => (int)$row['user_customer_number']]; } elseif (!$isUser && !empty($row['subuser_username'])) { $displayName = $row['subuser_username']; } elseif ($isUser) { $displayName = 'User #' . (int)($row['principal_id'] ?? 0); $displayNameKey = 'user_with_id'; $displayNameParams = ['id' => (int)($row['principal_id'] ?? 0)]; } elseif (($row['session_kind'] ?? '') === 'subuser') { $displayName = 'Subuser #' . (int)($row['principal_id'] ?? 0); $displayNameKey = 'subuser_with_id'; $displayNameParams = ['id' => (int)($row['principal_id'] ?? 0)]; } else { $displayName = 'Session #' . (int)($row['principal_id'] ?? 0); $displayNameKey = 'session_with_id'; $displayNameParams = ['id' => (int)($row['principal_id'] ?? 0)]; } } $contextLabel = null; $contextLabelKey = null; $contextLabelParams = []; if ($isUser && !empty($row['user_customer_number'])) { $contextLabel = 'Customer ' . $row['user_customer_number']; $contextLabelKey = 'customer_number'; $contextLabelParams = ['number' => (int)$row['user_customer_number']]; } elseif (!empty($row['customer_number_context'])) { $contextLabel = 'Customer ' . $row['customer_number_context']; $contextLabelKey = 'customer_number'; $contextLabelParams = ['number' => (int)$row['customer_number_context']]; } $firstSeenAt = self::databaseDateTimeToIso8601($row['first_seen_at'] ?? null); $lastSeenAt = self::databaseDateTimeToIso8601($row['last_seen_at'] ?? null); $recentSessions[] = [ 'session_kind' => (string)($row['session_kind'] ?? 'unknown'), 'principal_id' => (int)($row['principal_id'] ?? 0), 'display_name' => $displayName, 'display_name_key' => $displayNameKey, 'display_name_params' => $displayNameParams, 'context_label' => $contextLabel, 'context_label_key' => $contextLabelKey, 'context_label_params' => $contextLabelParams, 'customer_number_context' => isset($row['customer_number_context']) ? (int)$row['customer_number_context'] : null, 'device_type' => (string)($row['device_type'] ?? 'unknown'), 'user_agent' => (string)($row['user_agent'] ?? ''), 'last_route' => (string)($row['last_route'] ?? ''), 'first_seen_at' => $firstSeenAt, 'last_seen_at' => $lastSeenAt, 'active' => self::isActive($lastSeenAt), ]; } $activeUsers = (int)(($activeUsersResult?->fetch_assoc()['c']) ?? 0); $activeSessions = (int)(($activeSessionsResult?->fetch_assoc()['c']) ?? 0); return [ 'active_window_minutes' => self::ACTIVE_WINDOW_MINUTES, 'active_users' => $activeUsers, 'active_sessions' => $activeSessions, 'recent_sessions' => $recentSessions, ]; } public function pruneOlderThanDays(int $days = self::PRUNE_AFTER_DAYS): int { global $db; system_session_activity_schema_bootstrap::ensureTables(); $days = max(1, $days); $cutoff = self::utcSqlDateTime(time() - ($days * 86400)); $cutoffEscaped = $db->escape_string($cutoff); $db->query("DELETE FROM system_session_activity WHERE last_seen_at < '$cutoffEscaped'"); return (int)$db->conn()->affected_rows; } public static function detectDeviceType(string $userAgent): string { $userAgent = strtolower(trim($userAgent)); if ($userAgent === '') { return 'unknown'; } if (preg_match('/bot|crawler|spider|slurp|curl|wget|postman|insomnia/', $userAgent) === 1) { return 'bot'; } if (preg_match('/ipad|tablet|kindle|playbook|silk/', $userAgent) === 1) { return 'tablet'; } if (preg_match('/iphone|ipod|android.+mobile|windows phone|mobile/', $userAgent) === 1) { return 'mobile'; } if (preg_match('/macintosh|windows nt|linux|x11|cros/', $userAgent) === 1) { return 'desktop'; } return 'unknown'; } public static function isActive(?string $lastSeenAt, int $windowMinutes = self::ACTIVE_WINDOW_MINUTES, ?int $referenceTimestamp = null): bool { if ($lastSeenAt === null || trim($lastSeenAt) === '') { return false; } $lastSeenTimestamp = strtotime($lastSeenAt); if ($lastSeenTimestamp === false) { return false; } $referenceTimestamp = $referenceTimestamp ?? time(); return $lastSeenTimestamp >= ($referenceTimestamp - (max(1, $windowMinutes) * 60)); } public static function databaseDateTimeToIso8601(?string $value): ?string { if ($value === null || trim($value) === '') { return null; } $dateTime = DateTimeImmutable::createFromFormat( 'Y-m-d H:i:s', trim($value), new DateTimeZone(self::DATABASE_TIMEZONE) ); if (!$dateTime instanceof DateTimeImmutable) { return null; } return $dateTime->format(DATE_ATOM); } private static function utcSqlDateTime(?int $timestamp = null): string { return gmdate('Y-m-d H:i:s', $timestamp ?? time()); } }