use async_trait::async_trait; use chrono::{DateTime, Utc}; use sqlx::query_as; use crate::domain::RepositoryError; use crate::domain::ids::{TokenId, UserId}; use crate::domain::repositories::TokenRepository; use crate::domain::tokens::{NewToken, Token}; use crate::infrastructure::database::DatabasePool; #[derive(Clone)] pub struct SqlTokenRepository { pool: DatabasePool, } impl SqlTokenRepository { pub fn new(pool: DatabasePool) -> Self { Self { pool } } fn to_domain(record: TokenRecord) -> Token { let TokenRecord { id, user_id, token_hash, name, created_at, last_used_at, revoked_at, } = record; Token::new( TokenId::from(id), UserId::from(user_id), token_hash, name, created_at, last_used_at, revoked_at, ) } } #[async_trait] impl TokenRepository for SqlTokenRepository { async fn insert(&self, token: NewToken) -> Result { let query = "INSERT INTO tokens (user_id, token_hash, name) VALUES (?, ?, ?) RETURNING id, user_id, token_hash, name, created_at, last_used_at, revoked_at"; let NewToken { user_id, token_hash, name, } = token; let record = query_as::<_, TokenRecord>(query) .bind(i64::from(user_id)) .bind(&token_hash) .bind(&name) .fetch_one(&self.pool) .await .map_err(|err| { if let sqlx::Error::Database(db_err) = &err && db_err.is_unique_violation() { return RepositoryError::conflict("token already exists"); } RepositoryError::unexpected(err.to_string()) })?; Ok(Self::to_domain(record)) } async fn get(&self, id: TokenId) -> Result { let query = "SELECT id, user_id, token_hash, name, created_at, last_used_at, revoked_at FROM tokens WHERE id = ?"; let record = query_as::<_, TokenRecord>(query) .bind(i64::from(id)) .fetch_optional(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))? .ok_or(RepositoryError::NotFound)?; Ok(Self::to_domain(record)) } async fn get_by_token_hash(&self, token_hash: &str) -> Result { let query = "SELECT id, user_id, token_hash, name, created_at, last_used_at, revoked_at FROM tokens WHERE token_hash = ?"; let record = query_as::<_, TokenRecord>(query) .bind(token_hash) .fetch_optional(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))? .ok_or(RepositoryError::NotFound)?; Ok(Self::to_domain(record)) } async fn list_by_user(&self, user_id: UserId) -> Result, RepositoryError> { let query = "SELECT id, user_id, token_hash, name, created_at, last_used_at, revoked_at FROM tokens WHERE user_id = ? ORDER BY created_at DESC"; let records = query_as::<_, TokenRecord>(query) .bind(i64::from(user_id)) .fetch_all(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; Ok(records.into_iter().map(Self::to_domain).collect()) } async fn revoke(&self, id: TokenId) -> Result { let query = "UPDATE tokens SET revoked_at = ? WHERE id = ? RETURNING id, user_id, token_hash, name, created_at, last_used_at, revoked_at"; let now = Utc::now(); let record = query_as::<_, TokenRecord>(query) .bind(now) .bind(i64::from(id)) .fetch_optional(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; match record { Some(record) => Ok(Self::to_domain(record)), None => Err(RepositoryError::NotFound), } } async fn update_last_used(&self, id: TokenId) -> Result<(), RepositoryError> { let now = Utc::now(); sqlx::query("UPDATE tokens SET last_used_at = ? WHERE id = ?") .bind(now) .bind(i64::from(id)) .execute(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; Ok(()) } } #[derive(sqlx::FromRow)] struct TokenRecord { id: i64, user_id: i64, token_hash: String, name: String, created_at: DateTime, last_used_at: Option>, revoked_at: Option>, }