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

218 lines
6.3 KiB
Rust

use crate::AppState;
use axum::{
extract::{Path, Query, 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(list_notifications))
.route("/unread-count", get(unread_count))
.route("/{id}/read", patch(mark_read))
.route("/read-all", patch(mark_all_read))
}
// ── Query params ──────────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct ListQuery {
page: Option<i64>,
limit: Option<i64>,
}
// ── Response types ────────────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct NotificationDto {
pub id: Uuid,
pub title: String,
pub body: Option<String>,
#[serde(rename = "type")]
pub notification_type: Option<String>,
pub reference_id: Option<Uuid>,
pub is_read: bool,
pub created_at: String,
}
#[derive(Serialize)]
pub struct PaginatedResponse<T> {
pub data: Vec<T>,
pub pagination: Pagination,
}
#[derive(Serialize)]
pub struct Pagination {
pub page: i64,
pub limit: i64,
pub total: i64,
pub total_pages: i64,
}
// ── FromRow structs ──────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct NotificationRow {
id: Uuid,
title: String,
body: Option<String>,
notification_type: Option<String>,
reference_id: Option<Uuid>,
is_read: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
// ── Handlers ──────────────────────────────────────────────────────────────────
async fn list_notifications(
auth: AuthUser,
State(state): State<AppState>,
Query(params): Query<ListQuery>,
) -> impl IntoResponse {
let page = params.page.unwrap_or(1).max(1);
let limit = params.limit.unwrap_or(20).clamp(1, 100);
let offset = (page - 1) * limit;
let rows = sqlx::query_as::<_, NotificationRow>(
r#"
SELECT id, title, body, type AS notification_type,
reference_id, is_read, created_at
FROM notifications
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
)
.bind(auth.user_id)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
.await;
let total: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM notifications WHERE user_id = $1",
)
.bind(auth.user_id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
match rows {
Ok(rows) => {
let data = rows
.into_iter()
.map(|r| NotificationDto {
id: r.id,
title: r.title,
body: r.body,
notification_type: r.notification_type,
reference_id: r.reference_id,
is_read: r.is_read,
created_at: r.created_at.to_rfc3339(),
})
.collect();
let total_pages = if total == 0 { 1 } else { (total + limit - 1) / limit };
(
StatusCode::OK,
Json(PaginatedResponse {
data,
pagination: Pagination { page, limit, total, total_pages },
}),
)
.into_response()
}
Err(e) => {
tracing::error!("Failed to fetch notifications: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to fetch notifications" })),
)
.into_response()
}
}
}
async fn unread_count(
auth: AuthUser,
State(state): State<AppState>,
) -> impl IntoResponse {
let count: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND is_read = false",
)
.bind(auth.user_id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
(StatusCode::OK, Json(serde_json::json!({ "unread_count": count })))
}
async fn mark_read(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let result = sqlx::query(
"UPDATE notifications SET is_read = true WHERE id = $1 AND user_id = $2",
)
.bind(id)
.bind(auth.user_id)
.execute(&state.pool)
.await;
match result {
Ok(r) if r.rows_affected() == 0 => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Notification not found" })),
)
.into_response(),
Ok(_) => (
StatusCode::OK,
Json(serde_json::json!({ "id": id, "is_read": true })),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to mark notification as read: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to update notification" })),
)
.into_response()
}
}
}
async fn mark_all_read(
auth: AuthUser,
State(state): State<AppState>,
) -> impl IntoResponse {
let result = sqlx::query(
"UPDATE notifications SET is_read = true WHERE user_id = $1 AND is_read = false",
)
.bind(auth.user_id)
.execute(&state.pool)
.await;
match result {
Ok(r) => (
StatusCode::OK,
Json(serde_json::json!({ "updated": r.rows_affected() })),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to mark all notifications as read: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to update notifications" })),
)
.into_response()
}
}
}