#[cfg(feature = "portal")]
use axum::{
extract::{Json, Multipart, State},
http::StatusCode,
response::IntoResponse,
};
#[cfg(feature = "portal")]
use chrono::Utc;
#[cfg(feature = "portal")]
use serde::{Deserialize, Serialize};
#[cfg(feature = "portal")]
use sqlx::PgPool;
#[cfg(feature = "portal")]
use uuid::Uuid;
#[cfg(feature = "portal")]
use crate::portal::auth_db::PortalState;
#[cfg(feature = "portal")]
use crate::portal::db::DbError;
#[cfg(feature = "portal")]
use crate::portal::middleware::AuthClaims;
#[cfg(feature = "portal")]
#[derive(Debug, Serialize)]
pub struct ProfileResponse {
pub user_id: String,
pub email: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub timezone: String,
pub locale: String,
pub preferences: serde_json::Value,
pub email_verified: bool,
pub created_at: String,
pub updated_at: String,
}
#[cfg(feature = "portal")]
#[derive(Debug, Deserialize)]
pub struct UpdateProfileRequest {
pub display_name: Option<String>,
pub timezone: Option<String>,
pub locale: Option<String>,
pub preferences: Option<serde_json::Value>,
}
#[cfg(feature = "portal")]
pub struct ProfileRepository<'a> {
pool: &'a PgPool,
}
#[cfg(feature = "portal")]
impl<'a> ProfileRepository<'a> {
pub fn new(pool: &'a PgPool) -> Self {
Self { pool }
}
pub async fn get_or_create(&self, user_id: Uuid) -> Result<ProfileData, DbError> {
let existing =
sqlx::query_as::<_, ProfileData>("SELECT * FROM profiles WHERE user_id = $1")
.bind(user_id)
.fetch_optional(self.pool)
.await?;
if let Some(profile) = existing {
return Ok(profile);
}
let profile = sqlx::query_as::<_, ProfileData>(
r#"
INSERT INTO profiles (user_id, timezone, locale, preferences)
VALUES ($1, 'UTC', 'en-US', '{}')
RETURNING *
"#,
)
.bind(user_id)
.fetch_one(self.pool)
.await?;
Ok(profile)
}
pub async fn update(
&self,
user_id: Uuid,
update: &UpdateProfileRequest,
) -> Result<ProfileData, DbError> {
let profile = sqlx::query_as::<_, ProfileData>(
r#"
UPDATE profiles SET
display_name = COALESCE($2, display_name),
timezone = COALESCE($3, timezone),
locale = COALESCE($4, locale),
preferences = COALESCE($5, preferences),
updated_at = NOW()
WHERE user_id = $1
RETURNING *
"#,
)
.bind(user_id)
.bind(&update.display_name)
.bind(&update.timezone)
.bind(&update.locale)
.bind(&update.preferences)
.fetch_one(self.pool)
.await?;
Ok(profile)
}
pub async fn update_avatar(&self, user_id: Uuid, avatar_url: &str) -> Result<(), DbError> {
sqlx::query("UPDATE profiles SET avatar_url = $2, updated_at = NOW() WHERE user_id = $1")
.bind(user_id)
.bind(avatar_url)
.execute(self.pool)
.await?;
Ok(())
}
pub async fn delete(&self, user_id: Uuid) -> Result<(), DbError> {
sqlx::query("DELETE FROM profiles WHERE user_id = $1")
.bind(user_id)
.execute(self.pool)
.await?;
Ok(())
}
}
#[cfg(feature = "portal")]
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct ProfileData {
pub user_id: Uuid,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub timezone: String,
pub locale: String,
pub preferences: serde_json::Value,
pub updated_at: chrono::DateTime<Utc>,
}
#[cfg(feature = "portal")]
pub async fn get_profile(
State(state): State<PortalState>,
AuthClaims(claims): AuthClaims,
) -> impl IntoResponse {
let user_id = match Uuid::parse_str(&claims.sub) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid user ID"})),
);
}
};
let user_repo = crate::portal::db::queries::UserRepository::new(state.db.pool());
let user = match user_repo.find_by_id(user_id).await {
Ok(user) => user,
Err(DbError::NotFound) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "User not found"})),
);
}
Err(e) => {
tracing::error!("Database error: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Failed to fetch profile"})),
);
}
};
let profile_repo = ProfileRepository::new(state.db.pool());
let profile = match profile_repo.get_or_create(user_id).await {
Ok(p) => p,
Err(e) => {
tracing::error!("Database error: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Failed to fetch profile"})),
);
}
};
let response = ProfileResponse {
user_id: user.id.to_string(),
email: user.email,
display_name: profile.display_name,
avatar_url: profile.avatar_url,
timezone: profile.timezone,
locale: profile.locale,
preferences: profile.preferences,
email_verified: user.email_verified_at.is_some(),
created_at: user.created_at.to_rfc3339(),
updated_at: profile.updated_at.to_rfc3339(),
};
(
StatusCode::OK,
Json(serde_json::to_value(response).unwrap()),
)
}
#[cfg(feature = "portal")]
pub async fn update_profile(
State(state): State<PortalState>,
AuthClaims(claims): AuthClaims,
Json(req): Json<UpdateProfileRequest>,
) -> impl IntoResponse {
let user_id = match Uuid::parse_str(&claims.sub) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid user ID"})),
);
}
};
let profile_repo = ProfileRepository::new(state.db.pool());
if let Err(e) = profile_repo.get_or_create(user_id).await {
tracing::error!("Database error: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Failed to update profile"})),
);
}
match profile_repo.update(user_id, &req).await {
Ok(profile) => {
tracing::info!("Profile updated for user: {}", user_id);
(
StatusCode::OK,
Json(serde_json::json!({
"success": true,
"display_name": profile.display_name,
"timezone": profile.timezone,
"locale": profile.locale,
"updated_at": profile.updated_at.to_rfc3339()
})),
)
}
Err(e) => {
tracing::error!("Database error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Failed to update profile"})),
)
}
}
}
#[cfg(feature = "portal")]
pub async fn upload_avatar(
State(state): State<PortalState>,
AuthClaims(claims): AuthClaims,
mut multipart: Multipart,
) -> impl IntoResponse {
let user_id = match Uuid::parse_str(&claims.sub) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid user ID"})),
);
}
};
let mut avatar_data: Option<Vec<u8>> = None;
let mut content_type: Option<String> = None;
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
if name == "avatar" {
content_type = field.content_type().map(|s| s.to_string());
if let Ok(data) = field.bytes().await {
avatar_data = Some(data.to_vec());
}
}
}
let (data, ct) = match (avatar_data, content_type) {
(Some(d), Some(c)) => (d, c),
_ => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "No avatar file provided"})),
);
}
};
if !["image/jpeg", "image/png", "image/gif", "image/webp"].contains(&ct.as_str()) {
return (
StatusCode::BAD_REQUEST,
Json(
serde_json::json!({"error": "Invalid image format. Allowed: JPEG, PNG, GIF, WebP"}),
),
);
}
if data.len() > 5 * 1024 * 1024 {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Avatar too large. Maximum size is 5MB"})),
);
}
let avatar_url = format!(
"https://avatars.reasonkit.sh/{}/{}",
user_id,
Uuid::new_v4()
);
let profile_repo = ProfileRepository::new(state.db.pool());
if let Err(e) = profile_repo.update_avatar(user_id, &avatar_url).await {
tracing::error!("Database error: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Failed to update avatar"})),
);
}
tracing::info!("Avatar uploaded for user: {}", user_id);
(
StatusCode::OK,
Json(serde_json::json!({
"success": true,
"avatar_url": avatar_url
})),
)
}
#[cfg(feature = "portal")]
pub async fn delete_account(
State(state): State<PortalState>,
AuthClaims(claims): AuthClaims,
) -> impl IntoResponse {
let user_id = match Uuid::parse_str(&claims.sub) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid user ID"})),
);
}
};
let user_repo = crate::portal::db::queries::UserRepository::new(state.db.pool());
if let Err(e) = user_repo.soft_delete(user_id).await {
tracing::error!("Database error: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Failed to delete account"})),
);
}
let session_repo = crate::portal::db::queries::SessionRepository::new(state.db.pool());
if let Err(e) = session_repo.revoke_all_for_user(user_id).await {
tracing::error!("Failed to revoke sessions: {}", e);
}
tracing::info!("Account deletion initiated for user: {}", user_id);
(
StatusCode::OK,
Json(serde_json::json!({
"success": true,
"message": "Account scheduled for deletion. You have 30 days to reactivate."
})),
)
}