nxtgauge-backend-rust/apps/users/src/handlers/settings.rs

265 lines
7.4 KiB
Rust

use crate::AppState;
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, patch},
Json, Router,
};
use contracts::auth_middleware::AuthUser;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(get_settings))
.route("/notifications", patch(update_notifications))
.route("/delete-account-request", get(get_delete_account_request).post(create_delete_account_request))
}
#[derive(Serialize)]
struct SettingsResponse {
email_notifications: bool,
in_app_notifications: bool,
sms_notifications: bool,
}
#[derive(Deserialize)]
struct UpdateNotificationsPayload {
email_notifications: bool,
in_app_notifications: bool,
sms_notifications: bool,
}
#[derive(Deserialize)]
struct CreateDeleteAccountPayload {
reason: Option<String>,
}
#[derive(Serialize)]
struct DeleteRequestResponse {
status: String,
deleted_at: Option<String>,
}
async fn ensure_settings_row(pool: &sqlx::PgPool, user_id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
INSERT INTO user_settings (user_id)
VALUES ($1)
ON CONFLICT (user_id) DO NOTHING
"#,
)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
async fn get_settings(
auth: AuthUser,
State(state): State<AppState>,
) -> impl IntoResponse {
if let Err(e) = ensure_settings_row(&state.pool, auth.user_id).await {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
}
let row = sqlx::query_as::<_, (bool, bool, bool)>(
r#"
SELECT email_notifications, in_app_notifications, sms_notifications
FROM user_settings
WHERE user_id = $1
"#,
)
.bind(auth.user_id)
.fetch_one(&state.pool)
.await;
match row {
Ok((email_notifications, in_app_notifications, sms_notifications)) => (
StatusCode::OK,
Json(SettingsResponse {
email_notifications,
in_app_notifications,
sms_notifications,
}),
)
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_notifications(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<UpdateNotificationsPayload>,
) -> impl IntoResponse {
let result = sqlx::query(
r#"
INSERT INTO user_settings (user_id, email_notifications, in_app_notifications, sms_notifications, updated_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (user_id)
DO UPDATE SET
email_notifications = EXCLUDED.email_notifications,
in_app_notifications = EXCLUDED.in_app_notifications,
sms_notifications = EXCLUDED.sms_notifications,
updated_at = NOW()
"#,
)
.bind(auth.user_id)
.bind(payload.email_notifications)
.bind(payload.in_app_notifications)
.bind(payload.sms_notifications)
.execute(&state.pool)
.await;
match result {
Ok(_) => (
StatusCode::OK,
Json(serde_json::json!({
"message": "Notification preferences updated"
})),
)
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn get_delete_account_request(
auth: AuthUser,
State(state): State<AppState>,
) -> impl IntoResponse {
let row = sqlx::query_as::<_, (Option<chrono::DateTime<chrono::Utc>>, )>(
r#"
SELECT deleted_at
FROM users
WHERE id = $1
"#,
)
.bind(auth.user_id)
.fetch_optional(&state.pool)
.await;
match row {
Ok(Some((deleted_at,))) if deleted_at.is_some() => (
StatusCode::OK,
Json(DeleteRequestResponse {
status: "DELETED".to_string(),
deleted_at: deleted_at.map(|v| v.to_rfc3339()),
}),
)
.into_response(),
Ok(Some((_deleted_at,))) => (
StatusCode::OK,
Json(DeleteRequestResponse {
status: "NONE".to_string(),
deleted_at: None,
}),
)
.into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "User not found" })),
)
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn create_delete_account_request(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<CreateDeleteAccountPayload>,
) -> impl IntoResponse {
let user = match db::models::user::UserRepository::get_by_id(&state.pool, auth.user_id).await {
Ok(u) => u,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "error": "User not found" })),
)
.into_response()
}
};
let already_deleted = sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)::BIGINT
FROM users
WHERE id = $1 AND deleted_at IS NOT NULL
"#,
)
.bind(auth.user_id)
.fetch_one(&state.pool)
.await;
if let Ok(count) = already_deleted {
if count > 0 {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "Account is already deleted."
})),
)
.into_response();
}
}
let deleted_at = chrono::Utc::now();
let soft_deleted = sqlx::query(
r#"
UPDATE users
SET deleted_at = $2, status = 'SUSPENDED', updated_at = NOW()
WHERE id = $1 AND deleted_at IS NULL
"#,
)
.bind(auth.user_id)
.bind(deleted_at)
.execute(&state.pool)
.await;
match soft_deleted {
Ok(result) if result.rows_affected() > 0 => {
let _ = db::models::user::UserRepository::revoke_all_for_user(&state.pool, auth.user_id).await;
let _ = state
.mail
.send_account_deleted_email(
&user.email,
&format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()),
)
.await;
let _ = sqlx::query(
r#"INSERT INTO notifications (user_id, title, body, type)
VALUES ($1, $2, $3, $4)"#,
)
.bind(auth.user_id)
.bind("Account Deleted")
.bind(format!(
"Your account was deleted{}.",
payload
.reason
.as_deref()
.map(|r| format!(" Reason: {r}"))
.unwrap_or_default()
))
.bind("ACCOUNT")
.execute(&state.pool)
.await;
(
StatusCode::OK,
Json(DeleteRequestResponse {
status: "DELETED".to_string(),
deleted_at: Some(deleted_at.to_rfc3339()),
}),
)
.into_response()
}
Ok(_) => (
StatusCode::CONFLICT,
Json(serde_json::json!({ "error": "Account is already deleted." })),
)
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}