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 { 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, limit: Option, } // ── Response types ──────────────────────────────────────────────────────────── #[derive(Serialize)] pub struct NotificationDto { pub id: Uuid, pub title: String, pub body: Option, #[serde(rename = "type")] pub notification_type: Option, pub reference_id: Option, pub is_read: bool, pub created_at: String, } #[derive(Serialize)] pub struct PaginatedResponse { pub data: Vec, 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, notification_type: Option, reference_id: Option, is_read: bool, created_at: chrono::DateTime, } // ── Handlers ────────────────────────────────────────────────────────────────── async fn list_notifications( auth: AuthUser, State(state): State, Query(params): Query, ) -> 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, ) -> 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, Path(id): Path, ) -> 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, ) -> 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() } } }