use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::error::AppError; use crate::models::common::{EventType, JsonValue, Provider, Scope}; use crate::models::users::{UserDevice, UserSecurityLog}; use crate::service::UserService; use crate::session::Session; use super::util::ensure_affected; #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct UserSessionInfo { pub id: Uuid, pub ip_address: Option, pub user_agent: Option, pub last_active_at: DateTime, pub expires_at: DateTime, pub revoked_at: Option>, pub created_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct UserOAuthInfo { pub id: Uuid, pub provider: Provider, pub provider_user_id: String, pub provider_username: Option, pub provider_email: Option, pub token_expires_at: Option>, pub linked_at: DateTime, pub last_used_at: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct UserPersonalAccessTokenInfo { pub id: Uuid, pub name: String, pub scopes: Vec, pub last_used_at: Option>, pub expires_at: Option>, pub revoked_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] struct UserSessionRow { id: Uuid, ip_address: Option, user_agent: Option, last_active_at: DateTime, expires_at: DateTime, revoked_at: Option>, created_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] struct UserOAuthRow { id: Uuid, provider: Provider, provider_user_id: String, provider_username: Option, provider_email: Option, token_expires_at: Option>, linked_at: DateTime, last_used_at: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] struct UserPersonalAccessTokenRow { id: Uuid, name: String, scopes: Vec, last_used_at: Option>, expires_at: Option>, revoked_at: Option>, created_at: DateTime, updated_at: DateTime, } impl UserService { pub async fn user_devices(&self, ctx: &Session) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; sqlx::query_as::<_, UserDevice>( "SELECT id, user_id, device_name, device_type, fingerprint, ip_address, user_agent, \ trusted, last_seen_at, created_at, updated_at FROM user_device \ WHERE user_id = $1 ORDER BY last_seen_at DESC NULLS LAST, created_at DESC", ) .bind(user_uid) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database) } pub async fn user_delete_device( &self, ctx: &Session, device_uid: Uuid, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let result = sqlx::query("DELETE FROM user_device WHERE id = $1 AND user_id = $2") .bind(device_uid) .bind(user_uid) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "device not found") } pub async fn user_sessions( &self, ctx: &Session, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let limit = limit.clamp(1, 100); let offset = offset.max(0); let rows = sqlx::query_as::<_, UserSessionRow>( "SELECT id, ip_address, user_agent, last_active_at, expires_at, revoked_at, created_at \ FROM user_session WHERE user_id = $1 ORDER BY last_active_at DESC LIMIT $2 OFFSET $3", ) .bind(user_uid) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database)?; Ok(rows.into_iter().map(Into::into).collect()) } pub async fn user_revoke_session( &self, ctx: &Session, session_uid: Uuid, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let result = sqlx::query( "UPDATE user_session SET revoked_at = $1 \ WHERE id = $2 AND user_id = $3 AND revoked_at IS NULL", ) .bind(chrono::Utc::now()) .bind(session_uid) .bind(user_uid) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "session not found") } pub async fn user_oauth_accounts(&self, ctx: &Session) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let rows = sqlx::query_as::<_, UserOAuthRow>( "SELECT id, provider, provider_user_id, provider_username, provider_email, \ token_expires_at, linked_at, last_used_at FROM user_oauth \ WHERE user_id = $1 ORDER BY linked_at DESC", ) .bind(user_uid) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database)?; Ok(rows.into_iter().map(Into::into).collect()) } pub async fn user_unlink_oauth(&self, ctx: &Session, oauth_uid: Uuid) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let has_password: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM user_password WHERE user_id = $1)") .bind(user_uid) .fetch_one(self.ctx.db.reader()) .await .map_err(AppError::Database)?; let oauth_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_oauth WHERE user_id = $1") .bind(user_uid) .fetch_one(self.ctx.db.reader()) .await .map_err(AppError::Database)?; if !has_password && oauth_count <= 1 { return Err(AppError::BadRequest( "cannot unlink the last login method; please set a password first".into(), )); } let result = sqlx::query("DELETE FROM user_oauth WHERE id = $1 AND user_id = $2") .bind(oauth_uid) .bind(user_uid) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "oauth account not found") } pub async fn user_security_logs( &self, ctx: &Session, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let limit = limit.clamp(1, 100); let offset = offset.max(0); sqlx::query_as::<_, UserSecurityLog>( "SELECT id, user_id, event_type, description, ip_address, user_agent, metadata, created_at \ FROM user_security_log WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", ) .bind(user_uid) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database) } pub async fn user_log_security_event( &self, user_uid: Uuid, event_type: EventType, description: Option, ip_address: Option, user_agent: Option, metadata: Option, ) -> Result<(), AppError> { sqlx::query( "INSERT INTO user_security_log (id, user_id, event_type, description, ip_address, user_agent, metadata, created_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", ) .bind(Uuid::now_v7()) .bind(user_uid) .bind(event_type) .bind(description) .bind(ip_address) .bind(user_agent) .bind(metadata) .bind(chrono::Utc::now()) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; Ok(()) } pub async fn user_personal_access_tokens( &self, ctx: &Session, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let limit = limit.clamp(1, 100); let offset = offset.max(0); let rows = sqlx::query_as::<_, UserPersonalAccessTokenRow>( "SELECT id, name, scopes, last_used_at, expires_at, revoked_at, created_at, updated_at \ FROM user_personal_access_token WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", ) .bind(user_uid) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database)?; Ok(rows.into_iter().map(Into::into).collect()) } pub async fn user_revoke_personal_access_token( &self, ctx: &Session, token_uid: Uuid, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let result = sqlx::query( "UPDATE user_personal_access_token SET revoked_at = $1, updated_at = $1 \ WHERE id = $2 AND user_id = $3 AND revoked_at IS NULL", ) .bind(chrono::Utc::now()) .bind(token_uid) .bind(user_uid) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "token not found") } } impl From for UserSessionInfo { fn from(row: UserSessionRow) -> Self { Self { id: row.id, ip_address: row.ip_address, user_agent: row.user_agent, last_active_at: row.last_active_at, expires_at: row.expires_at, revoked_at: row.revoked_at, created_at: row.created_at, } } } impl From for UserOAuthInfo { fn from(row: UserOAuthRow) -> Self { Self { id: row.id, provider: row.provider, provider_user_id: row.provider_user_id, provider_username: row.provider_username, provider_email: row.provider_email, token_expires_at: row.token_expires_at, linked_at: row.linked_at, last_used_at: row.last_used_at, } } } impl From for UserPersonalAccessTokenInfo { fn from(row: UserPersonalAccessTokenRow) -> Self { Self { id: row.id, name: row.name, scopes: row.scopes, last_used_at: row.last_used_at, expires_at: row.expires_at, revoked_at: row.revoked_at, created_at: row.created_at, updated_at: row.updated_at, } } }