use chrono::{DateTime, Utc}; use sqlx::PgPool; use uuid::Uuid; // Row shape for `session`, populated by sqlx from `RETURNING *`. Session validation is done in SQL // (expiry/last-seen predicates), so no field is read in Rust — the struct is the row's shape. #[allow(dead_code)] #[derive(Debug, sqlx::FromRow)] pub struct Session { pub id: Uuid, pub user_id: Uuid, pub token_hash: String, pub expires_at: DateTime, pub last_seen_at: DateTime, pub created_at: DateTime, } impl Session { pub async fn create( pool: &PgPool, user_id: Uuid, token_hash: &str, expires_at: DateTime, ) -> Result { sqlx::query_as::<_, Self>( "INSERT INTO session (user_id, token_hash, expires_at) VALUES ($1, $2, $3) RETURNING *", ) .bind(user_id) .bind(token_hash) .bind(expires_at) .fetch_one(pool) .await } pub async fn find_by_token_hash( pool: &PgPool, token_hash: &str, ) -> Result, sqlx::Error> { sqlx::query_as::<_, Self>( "SELECT * FROM session WHERE token_hash = $1 AND expires_at > NOW()", ) .bind(token_hash) .fetch_optional(pool) .await } /// Resolve a session token straight to its live user row in one round-trip. /// /// The auth extractor runs on every authenticated request and used to do two /// sequential queries (session lookup, then user lookup); this collapses them into /// a single `session JOIN "user"`. The `expires_at` guard mirrors /// [`Self::find_by_token_hash`], and the user row is read live (role/ban are never /// trusted from the JWT). Returns `None` when the session is missing/expired. pub async fn find_user_by_token_hash( pool: &PgPool, token_hash: &str, ) -> Result, sqlx::Error> { sqlx::query_as::<_, crate::models::user::User>( "SELECT u.* FROM session s JOIN \"user\" u ON u.id = s.user_id WHERE s.token_hash = $1 AND s.expires_at > NOW()", ) .bind(token_hash) .fetch_optional(pool) .await } /// Touch `last_seen_at` AND slide `expires_at` forward by `expiry_days` from now, so /// an actively-used session never hits the fixed 30-day cliff (it renews on every /// authenticated request). An idle session still expires `expiry_days` after its last /// activity. Keyed by token hash so the auth extractor needs no prior id lookup. pub async fn touch_and_renew( pool: &PgPool, token_hash: &str, expiry_days: i64, ) -> Result<(), sqlx::Error> { sqlx::query( "UPDATE session SET last_seen_at = NOW(), expires_at = NOW() + ($2 || ' days')::interval WHERE token_hash = $1", ) .bind(token_hash) .bind(expiry_days.to_string()) .execute(pool) .await?; Ok(()) } pub async fn delete_by_token_hash(pool: &PgPool, token_hash: &str) -> Result<(), sqlx::Error> { sqlx::query("DELETE FROM session WHERE token_hash = $1") .bind(token_hash) .execute(pool) .await?; Ok(()) } /// Revoke every session for a user. Backs "sign out everywhere" and the forced /// re-auth after a host PIN-reset or a ban. Returns the number of sessions cleared. pub async fn delete_all_for_user(pool: &PgPool, user_id: Uuid) -> Result { let r = sqlx::query("DELETE FROM session WHERE user_id = $1") .bind(user_id) .execute(pool) .await?; Ok(r.rows_affected()) } }