use crate::error::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use sqlx::{PgPool, Row};
use uuid::Uuid;
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize)]
pub struct UserSession {
pub id: Uuid,
pub user_id: Uuid,
pub session_token: String,
pub device_type: Option<String>,
pub device_name: Option<String>,
pub os: Option<String>,
pub browser: Option<String>,
pub ip_address: String,
pub country: Option<String>,
pub city: Option<String>,
pub logged_in_at: DateTime<Utc>,
pub last_activity_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub logged_out_at: Option<DateTime<Utc>>,
pub is_active: bool,
pub logout_reason: Option<String>,
pub security_flags: Option<JsonValue>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateSession {
pub user_id: Uuid,
pub session_token: String,
pub device_type: Option<String>,
pub device_name: Option<String>,
pub os: Option<String>,
pub browser: Option<String>,
pub ip_address: String,
pub country: Option<String>,
pub city: Option<String>,
pub expires_at: DateTime<Utc>,
pub security_flags: Option<JsonValue>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionStats {
pub total_sessions: i64,
pub active_sessions: i64,
pub unique_devices: i64,
pub unique_ips: i64,
pub last_login: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize)]
pub struct ActiveSessionSummary {
pub id: Uuid,
pub device_type: Option<String>,
pub device_name: Option<String>,
pub ip_address: String,
pub logged_in_at: DateTime<Utc>,
pub last_activity_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
pub struct SessionRepository {
pool: PgPool,
}
impl SessionRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
pub async fn create(&self, params: CreateSession) -> Result<UserSession> {
let session = sqlx::query_as::<_, UserSession>(
r#"
INSERT INTO user_sessions (
user_id, session_token, device_type, device_name, os, browser,
ip_address, country, city, expires_at, security_flags
)
VALUES ($1, $2, $3, $4, $5, $6, $7::inet, $8, $9, $10, $11)
RETURNING *
"#,
)
.bind(params.user_id)
.bind(¶ms.session_token)
.bind(¶ms.device_type)
.bind(¶ms.device_name)
.bind(¶ms.os)
.bind(¶ms.browser)
.bind(¶ms.ip_address)
.bind(¶ms.country)
.bind(¶ms.city)
.bind(params.expires_at)
.bind(¶ms.security_flags)
.fetch_one(&self.pool)
.await?;
Ok(session)
}
pub async fn find_by_id(&self, id: Uuid) -> Result<Option<UserSession>> {
let session = sqlx::query_as::<_, UserSession>("SELECT * FROM user_sessions WHERE id = $1")
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(session)
}
pub async fn find_by_token(&self, token: &str) -> Result<Option<UserSession>> {
let session = sqlx::query_as::<_, UserSession>(
r#"
SELECT * FROM user_sessions
WHERE session_token = $1
AND is_active = true
AND logged_out_at IS NULL
AND expires_at > NOW()
"#,
)
.bind(token)
.fetch_optional(&self.pool)
.await?;
Ok(session)
}
pub async fn get_active_sessions(&self, user_id: Uuid) -> Result<Vec<UserSession>> {
let sessions = sqlx::query_as::<_, UserSession>(
r#"
SELECT * FROM user_sessions
WHERE user_id = $1
AND is_active = true
AND logged_out_at IS NULL
ORDER BY last_activity_at DESC
"#,
)
.bind(user_id)
.fetch_all(&self.pool)
.await?;
Ok(sessions)
}
pub async fn get_active_session_summaries(
&self,
user_id: Uuid,
) -> Result<Vec<ActiveSessionSummary>> {
let summaries = sqlx::query_as::<_, ActiveSessionSummary>(
r#"
SELECT id, device_type, device_name, ip_address, logged_in_at, last_activity_at, expires_at
FROM user_sessions
WHERE user_id = $1
AND is_active = true
AND logged_out_at IS NULL
ORDER BY last_activity_at DESC
"#,
)
.bind(user_id)
.fetch_all(&self.pool)
.await?;
Ok(summaries)
}
pub async fn get_session_history(
&self,
user_id: Uuid,
limit: i64,
page: i64,
) -> Result<Vec<UserSession>> {
let offset = crate::helpers::calculate_offset(page as u32, limit as u32);
let sessions = sqlx::query_as::<_, UserSession>(
r#"
SELECT * FROM user_sessions
WHERE user_id = $1
ORDER BY logged_in_at DESC
LIMIT $2 OFFSET $3
"#,
)
.bind(user_id)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await?;
Ok(sessions)
}
pub async fn update_activity(&self, session_id: Uuid) -> Result<UserSession> {
let session = sqlx::query_as::<_, UserSession>(
r#"
UPDATE user_sessions
SET last_activity_at = NOW(), updated_at = NOW()
WHERE id = $1
RETURNING *
"#,
)
.bind(session_id)
.fetch_one(&self.pool)
.await?;
Ok(session)
}
pub async fn logout(&self, session_id: Uuid, reason: &str) -> Result<UserSession> {
let session = sqlx::query_as::<_, UserSession>(
r#"
UPDATE user_sessions
SET is_active = false,
logged_out_at = NOW(),
logout_reason = $2,
updated_at = NOW()
WHERE id = $1
RETURNING *
"#,
)
.bind(session_id)
.bind(reason)
.fetch_one(&self.pool)
.await?;
Ok(session)
}
pub async fn logout_all_sessions(&self, user_id: Uuid, reason: &str) -> Result<i64> {
let result = sqlx::query(
r#"
UPDATE user_sessions
SET is_active = false,
logged_out_at = NOW(),
logout_reason = $2,
updated_at = NOW()
WHERE user_id = $1
AND is_active = true
AND logged_out_at IS NULL
"#,
)
.bind(user_id)
.bind(reason)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() as i64)
}
pub async fn logout_other_sessions(
&self,
user_id: Uuid,
current_session_id: Uuid,
reason: &str,
) -> Result<i64> {
let result = sqlx::query(
r#"
UPDATE user_sessions
SET is_active = false,
logged_out_at = NOW(),
logout_reason = $3,
updated_at = NOW()
WHERE user_id = $1
AND id != $2
AND is_active = true
AND logged_out_at IS NULL
"#,
)
.bind(user_id)
.bind(current_session_id)
.bind(reason)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() as i64)
}
pub async fn expire_old_sessions(&self) -> Result<i64> {
let result = sqlx::query(
r#"
UPDATE user_sessions
SET is_active = false,
logged_out_at = NOW(),
logout_reason = 'expired',
updated_at = NOW()
WHERE is_active = true
AND logged_out_at IS NULL
AND expires_at < NOW()
"#,
)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() as i64)
}
pub async fn cleanup_old_sessions(&self, days: i64) -> Result<i64> {
let result = sqlx::query(
r#"
DELETE FROM user_sessions
WHERE is_active = false
AND logged_out_at < NOW() - ($1 || ' days')::INTERVAL
"#,
)
.bind(days)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() as i64)
}
pub async fn get_user_stats(&self, user_id: Uuid) -> Result<SessionStats> {
let row = sqlx::query(
r#"
SELECT
COUNT(*) as total_sessions,
COUNT(*) FILTER (WHERE is_active = true AND logged_out_at IS NULL) as active_sessions,
COUNT(DISTINCT device_type) as unique_devices,
COUNT(DISTINCT ip_address) as unique_ips,
MAX(logged_in_at) as last_login
FROM user_sessions
WHERE user_id = $1
"#,
)
.bind(user_id)
.fetch_one(&self.pool)
.await?;
Ok(SessionStats {
total_sessions: row.get("total_sessions"),
active_sessions: row.get("active_sessions"),
unique_devices: row.get("unique_devices"),
unique_ips: row.get("unique_ips"),
last_login: row.get("last_login"),
})
}
pub async fn count_active_sessions(&self, user_id: Uuid) -> Result<i64> {
let row = sqlx::query(
r#"
SELECT COUNT(*) as count
FROM user_sessions
WHERE user_id = $1
AND is_active = true
AND logged_out_at IS NULL
"#,
)
.bind(user_id)
.fetch_one(&self.pool)
.await?;
Ok(row.get("count"))
}
pub async fn get_sessions_by_ip(
&self,
ip_address: &str,
limit: i64,
) -> Result<Vec<UserSession>> {
let sessions = sqlx::query_as::<_, UserSession>(
r#"
SELECT * FROM user_sessions
WHERE ip_address = $1::inet
ORDER BY logged_in_at DESC
LIMIT $2
"#,
)
.bind(ip_address)
.bind(limit)
.fetch_all(&self.pool)
.await?;
Ok(sessions)
}
pub async fn get_sessions_by_device(
&self,
user_id: Uuid,
device_type: &str,
) -> Result<Vec<UserSession>> {
let sessions = sqlx::query_as::<_, UserSession>(
r#"
SELECT * FROM user_sessions
WHERE user_id = $1
AND device_type = $2
ORDER BY logged_in_at DESC
"#,
)
.bind(user_id)
.bind(device_type)
.fetch_all(&self.pool)
.await?;
Ok(sessions)
}
pub async fn detect_suspicious_logins(
&self,
user_id: Uuid,
time_window_minutes: i64,
) -> Result<bool> {
let row = sqlx::query(
r#"
SELECT COUNT(DISTINCT ip_address) as distinct_ips
FROM user_sessions
WHERE user_id = $1
AND logged_in_at > NOW() - ($2 || ' minutes')::INTERVAL
"#,
)
.bind(user_id)
.bind(time_window_minutes)
.fetch_one(&self.pool)
.await?;
let distinct_ips: i64 = row.get("distinct_ips");
Ok(distinct_ips > 1) }
pub async fn revoke_session(&self, session_id: Uuid) -> Result<UserSession> {
self.logout(session_id, "security").await
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_create_session_params() {
let params = CreateSession {
user_id: Uuid::new_v4(),
session_token: "token123".to_string(),
device_type: Some("desktop".to_string()),
device_name: Some("Chrome on Windows".to_string()),
os: Some("Windows 11".to_string()),
browser: Some("Chrome 120".to_string()),
ip_address: "192.168.1.1".to_string(),
country: Some("US".to_string()),
city: Some("San Francisco".to_string()),
expires_at: Utc::now() + chrono::Duration::hours(24),
security_flags: Some(json!({"2fa_verified": true})),
};
assert_eq!(params.device_type, Some("desktop".to_string()));
assert_eq!(params.country, Some("US".to_string()));
}
#[test]
fn test_session_stats_structure() {
let stats = SessionStats {
total_sessions: 10,
active_sessions: 2,
unique_devices: 3,
unique_ips: 5,
last_login: Some(Utc::now()),
};
assert_eq!(stats.total_sessions, 10);
assert_eq!(stats.active_sessions, 2);
}
#[test]
fn test_session_serialization() {
let stats = SessionStats {
total_sessions: 5,
active_sessions: 1,
unique_devices: 2,
unique_ips: 3,
last_login: None,
};
let json = serde_json::to_string(&stats).unwrap();
assert!(json.contains("total_sessions"));
let deserialized: SessionStats = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.total_sessions, 5);
}
#[test]
fn test_ip_address_format() {
let ip_str = "192.168.1.1";
assert!(ip_str.contains('.'));
}
}