use db::sqlx; use model::users::UserProfileModel; use serde::{Deserialize, Serialize}; use session::Session; use storage::{ObjectStorage, PutObjectOptions}; use uuid::Uuid; use crate::{AppService, error::AppError, session_user}; /// Allowed image MIME types for avatars. const ALLOWED_AVATAR_TYPES: &[&str] = &["image/png", "image/jpeg", "image/webp", "image/gif"]; /// Maximum avatar file size: 5 MB. const MAX_AVATAR_SIZE: usize = 5 * 1024 * 1024; #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct UserProfileConfig { pub language: String, pub theme: String, pub timezone: String, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct UpdateUserProfileConfig { pub language: Option, pub theme: Option, pub timezone: Option, pub avatar_url: Option, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct AvatarUploadResponse { pub avatar_url: String, } /// Derive file extension from MIME content type. fn extension_from_content_type(content_type: &str) -> &str { match content_type { "image/png" => "png", "image/jpeg" => "jpg", "image/webp" => "webp", "image/gif" => "gif", _ => "bin", } } impl AppService { pub async fn user_update_profile_config( &self, ctx: &Session, params: UpdateUserProfileConfig, ) -> Result { let user_uid = session_user(ctx)?; let mut config = self.user_profile_config(user_uid).await?; if let Some(language) = params.language { config.language = language; } if let Some(theme) = params.theme { config.theme = theme; } if let Some(timezone) = params.timezone { config.timezone = timezone; } if let Some(avatar_url) = params.avatar_url { sqlx::query("UPDATE users SET avatar_url = $1 WHERE id = $2") .bind(&avatar_url) .bind(user_uid) .execute(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; } let now = chrono::Utc::now(); sqlx::query( "INSERT INTO user_profile (\"user\", language, theme, timezone, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $5) \ ON CONFLICT (\"user\") DO UPDATE SET \ language = EXCLUDED.language, theme = EXCLUDED.theme, timezone = EXCLUDED.timezone, updated_at = EXCLUDED.updated_at", ) .bind(user_uid) .bind(&config.language) .bind(&config.theme) .bind(&config.timezone) .bind(now) .execute(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(config) } /// Upload a user avatar image, store it, and update the user's avatar_url. pub async fn user_upload_avatar( &self, ctx: &Session, bytes: Vec, content_type: &str, ) -> Result { let user_uid = session_user(ctx)?; if bytes.len() > MAX_AVATAR_SIZE { return Err(AppError::AvatarUploadError( "file size exceeds 5 MB limit".to_string(), )); } if !ALLOWED_AVATAR_TYPES.contains(&content_type) { return Err(AppError::AvatarUploadError(format!( "unsupported image type: {content_type}. Allowed: png, jpeg, webp, gif" ))); } let ext = extension_from_content_type(content_type); let key = format!("avatars/users/{user_uid}-{}.{ext}", uuid::Uuid::now_v7()); let stored = self .storage .put_bytes( &key, bytes, PutObjectOptions { content_type: Some(content_type.to_string()), ..PutObjectOptions::default() }, ) .await .map_err(|e| { AppError::AvatarUploadError(format!("storage error: {e}")) })?; sqlx::query("UPDATE users SET avatar_url = $1 WHERE id = $2") .bind(&stored.url) .bind(user_uid) .execute(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(AvatarUploadResponse { avatar_url: stored.url, }) } pub async fn user_profile_config( &self, user_uid: Uuid, ) -> Result { let row = sqlx::query_as::<_, UserProfileModel>( "SELECT \"user\", language, theme, timezone, created_at, updated_at \ FROM user_profile WHERE \"user\" = $1", ) .bind(user_uid) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(row.map(Into::into).unwrap_or_default()) } } impl Default for UserProfileConfig { fn default() -> Self { Self { language: "en".to_string(), theme: "system".to_string(), timezone: "UTC".to_string(), } } } impl From for UserProfileConfig { fn from(value: UserProfileModel) -> Self { Self { language: value.language, theme: value.theme, timezone: value.timezone, } } }