218 lines
6.3 KiB
Rust
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()
|
|
}
|
|
}
|
|
}
|