Skip to main content

hypercall_api/handlers/
notifications.rs

1//! API handlers for the persisted notification feed.
2//!
3//! - `GET  /notifications`                - list notifications (keyset pagination, wallet query)
4//! - `POST /notifications/mark-read`      - mark specific ids read (signed)
5//! - `POST /notifications/mark-all-read`  - mark every unread notification read (signed)
6//!
7//! Reads follow the same unsigned-with-wallet-query pattern as `/fills` and
8//! `/profile` so the frontend bell doesn't need to sign on every page load.
9//! Writes stay behind EIP-712 signature auth; the wallet is extracted from
10//! the verified signature, not the body.
11
12use axum::extract::{Query, State};
13use hypercall_types::WalletAddress;
14use serde::{Deserialize, Serialize};
15use std::str::FromStr;
16use utoipa::ToSchema;
17
18use super::AppState;
19use crate::error::ApiError;
20use crate::middleware::SignerContext;
21use crate::sonic_json::SonicJson;
22use hypercall_db::NotificationRecord;
23
24// ---------------------------------------------------------------------------
25// Request / response types
26// ---------------------------------------------------------------------------
27
28#[derive(Debug, Deserialize, ToSchema)]
29pub struct NotificationsListQuery {
30    /// Wallet to list notifications for.
31    pub wallet: String,
32    /// Return rows with id strictly less than this value. Use the smallest
33    /// id from the previous page to fetch the next page.
34    pub before_id: Option<i64>,
35    /// Page size. Clamped to [1, 100]. Default 50.
36    pub limit: Option<i64>,
37}
38
39#[derive(Debug, Serialize, ToSchema)]
40pub struct NotificationDto {
41    pub id: i64,
42    pub notification_type: String,
43    pub created_at_ms: i64,
44    pub read: bool,
45    pub payload: serde_json::Value,
46}
47
48#[derive(Debug, Serialize, ToSchema)]
49pub struct NotificationsListResponse {
50    pub items: Vec<NotificationDto>,
51    pub unread_count: i64,
52}
53
54#[derive(Debug, Deserialize, ToSchema)]
55pub struct MarkReadRequest {
56    pub ids: Vec<i64>,
57}
58
59#[derive(Debug, Serialize, ToSchema)]
60pub struct MarkReadResponse {
61    pub updated: usize,
62}
63
64#[derive(Debug, Serialize, ToSchema)]
65pub struct MarkAllReadResponse {
66    pub updated: usize,
67}
68
69// ---------------------------------------------------------------------------
70// Helpers
71// ---------------------------------------------------------------------------
72
73/// Decode a stored msgpack payload. A decode failure indicates persisted
74/// data corruption (schema mismatch, truncation, etc.), so we surface it
75/// loudly (log::error) so operators see it, but return `null` for the
76/// payload instead of failing the whole list — a single poisoned row
77/// shouldn't take down the bell for the wallet's entire history. The
78/// fail-loud log + null payload is the compromise between AGENTS.md's
79/// fail-fast-on-invariant rule and read-path availability.
80fn decode_payload(id: i64, raw: &[u8]) -> serde_json::Value {
81    match rmp_serde::from_slice::<serde_json::Value>(raw) {
82        Ok(v) => v,
83        Err(e) => {
84            tracing::error!(
85                notification_id = id,
86                error = %e,
87                "notifications: failed to decode stored msgpack payload; returning null for this row"
88            );
89            serde_json::Value::Null
90        }
91    }
92}
93
94fn to_dto(row: NotificationRecord) -> NotificationDto {
95    let payload = decode_payload(row.id, &row.payload);
96    NotificationDto {
97        id: row.id,
98        notification_type: row.notification_type,
99        created_at_ms: row.created_at.timestamp_millis(),
100        read: row.read_at.is_some(),
101        payload,
102    }
103}
104
105/// Parse+lowercase the wallet query string, rejecting anything that isn't a
106/// valid 0x-prefixed address. Parsing at the handler boundary keeps the
107/// rate limiter's wallet-based path reachable (it only kicks in when the
108/// query parses as `WalletAddress`) and blocks a trivially-crafted 404-ish
109/// DB-load vector against the notifications endpoint.
110fn parse_wallet(raw: &str) -> Result<String, ApiError> {
111    WalletAddress::from_str(raw.trim())
112        .map(|w| format!("{:#x}", w.0))
113        .map_err(|_| ApiError::bad_request("Invalid wallet address"))
114}
115
116// ---------------------------------------------------------------------------
117// Handlers
118// ---------------------------------------------------------------------------
119
120#[utoipa::path(
121    get,
122    path = "/notifications",
123    params(
124        ("wallet"    = String,      Query, description = "Wallet address"),
125        ("before_id" = Option<i64>, Query, description = "Keyset cursor: return rows with id < this"),
126        ("limit"     = Option<i64>, Query, description = "Page size (clamped to 1..=100, default 50)"),
127    ),
128    responses(
129        (status = 200, description = "Notifications page", body = NotificationsListResponse),
130        (status = 400, description = "Invalid wallet address"),
131        (status = 500, description = "Internal server error"),
132    ),
133    security(("wallet_query" = [])),
134    tag = "Notifications"
135)]
136pub async fn list_notifications(
137    State(state): State<AppState>,
138    Query(query): Query<NotificationsListQuery>,
139) -> Result<SonicJson<NotificationsListResponse>, ApiError> {
140    let wallet = parse_wallet(&query.wallet)?;
141
142    let Some(ref service) = state.notification_service else {
143        return Err(ApiError::internal_error(
144            "Notifications service not configured",
145        ));
146    };
147
148    let rows = service
149        .list(&wallet, query.before_id, query.limit)
150        .await
151        .map_err(|e| {
152            tracing::error!("notifications list failed: {e}");
153            ApiError::internal_error("Failed to list notifications")
154        })?;
155
156    let unread_count = service.count_unread(&wallet).await.map_err(|e| {
157        tracing::error!("notifications count_unread failed: {e}");
158        ApiError::internal_error("Failed to count unread notifications")
159    })?;
160
161    let items: Vec<NotificationDto> = rows.into_iter().map(to_dto).collect();
162
163    Ok(SonicJson(NotificationsListResponse {
164        items,
165        unread_count,
166    }))
167}
168
169#[utoipa::path(
170    post,
171    path = "/notifications/mark-read",
172    request_body = MarkReadRequest,
173    responses(
174        (status = 200, description = "Marked read", body = MarkReadResponse),
175        (status = 401, description = "Unauthorized"),
176        (status = 500, description = "Internal server error"),
177    ),
178    security(("eip712_signature" = [])),
179    tag = "Notifications"
180)]
181pub async fn mark_read(
182    State(state): State<AppState>,
183    signer_ctx: SignerContext,
184    SonicJson(request): SonicJson<MarkReadRequest>,
185) -> Result<SonicJson<MarkReadResponse>, ApiError> {
186    let wallet = format!("{:#x}", signer_ctx.wallet_address.0);
187
188    let Some(ref service) = state.notification_service else {
189        return Err(ApiError::internal_error(
190            "Notifications service not configured",
191        ));
192    };
193
194    let updated = service
195        .mark_read(&wallet, &request.ids)
196        .await
197        .map_err(|e| {
198            tracing::error!("notifications mark_read failed: {e}");
199            ApiError::internal_error("Failed to mark notifications read")
200        })?;
201
202    Ok(SonicJson(MarkReadResponse { updated }))
203}
204
205#[utoipa::path(
206    post,
207    path = "/notifications/mark-all-read",
208    responses(
209        (status = 200, description = "All notifications marked read", body = MarkAllReadResponse),
210        (status = 401, description = "Unauthorized"),
211        (status = 500, description = "Internal server error"),
212    ),
213    security(("eip712_signature" = [])),
214    tag = "Notifications"
215)]
216pub async fn mark_all_read(
217    State(state): State<AppState>,
218    signer_ctx: SignerContext,
219) -> Result<SonicJson<MarkAllReadResponse>, ApiError> {
220    let wallet = format!("{:#x}", signer_ctx.wallet_address.0);
221
222    let Some(ref service) = state.notification_service else {
223        return Err(ApiError::internal_error(
224            "Notifications service not configured",
225        ));
226    };
227
228    let updated = service.mark_all_read(&wallet).await.map_err(|e| {
229        tracing::error!("notifications mark_all_read failed: {e}");
230        ApiError::internal_error("Failed to mark all notifications read")
231    })?;
232
233    Ok(SonicJson(MarkAllReadResponse { updated }))
234}