1use 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#[derive(Debug, Deserialize, ToSchema)]
29pub struct NotificationsListQuery {
30 pub wallet: String,
32 pub before_id: Option<i64>,
35 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
69fn 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
105fn 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#[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}