Skip to main content

hypercall_api/handlers/
push.rs

1//! API handlers for Web Push subscription management.
2//!
3//! - `POST /push/subscribe`      - register a push subscription (authenticated)
4//! - `POST /push/unsubscribe`    - remove a push subscription (authenticated)
5//! - `POST /push/preferences`    - update notification preferences (authenticated)
6//!
7//! All endpoints require EIP-712 signature auth. The wallet is extracted
8//! from the verified signature, not from the request body.
9
10use axum::extract::State;
11use serde::{Deserialize, Serialize};
12use utoipa::ToSchema;
13
14use super::AppState;
15use crate::error::ApiError;
16use crate::middleware::SignerContext;
17use crate::sonic_json::SonicJson;
18
19// ---------------------------------------------------------------------------
20// Request / response types
21// ---------------------------------------------------------------------------
22
23#[derive(Debug, Deserialize, ToSchema)]
24pub struct PushSubscribeRequest {
25    pub endpoint: String,
26    pub auth_key: String,
27    pub p256dh_key: String,
28    /// Optional notification preferences. Defaults to all enabled.
29    pub preferences: Option<serde_json::Value>,
30}
31
32#[derive(Debug, Serialize, ToSchema)]
33pub struct PushSubscribeResponse {
34    pub ok: bool,
35}
36
37#[derive(Debug, Deserialize, ToSchema)]
38pub struct PushUnsubscribeRequest {
39    pub endpoint: String,
40}
41
42#[derive(Debug, Serialize, ToSchema)]
43pub struct PushUnsubscribeResponse {
44    pub deleted: bool,
45}
46
47#[derive(Debug, Deserialize, ToSchema)]
48pub struct PushPreferencesRequest {
49    pub endpoint: String,
50    /// Example: {"fills": true, "liquidations": true, "settlements": false}
51    pub preferences: serde_json::Value,
52}
53
54#[derive(Debug, Serialize, ToSchema)]
55pub struct PushPreferencesResponse {
56    pub updated: bool,
57}
58
59// ---------------------------------------------------------------------------
60// Handlers
61// ---------------------------------------------------------------------------
62
63/// Register a Web Push subscription for the authenticated wallet.
64#[utoipa::path(
65    post,
66    path = "/push/subscribe",
67    request_body = PushSubscribeRequest,
68    responses(
69        (status = 200, description = "Subscription registered", body = PushSubscribeResponse),
70        (status = 400, description = "Invalid request"),
71        (status = 500, description = "Internal server error"),
72    ),
73    security(("eip712_signature" = [])),
74    tag = "Push Notifications"
75)]
76pub async fn push_subscribe(
77    State(state): State<AppState>,
78    signer_ctx: SignerContext,
79    SonicJson(request): SonicJson<PushSubscribeRequest>,
80) -> Result<SonicJson<PushSubscribeResponse>, ApiError> {
81    let wallet = format!("{:#x}", signer_ctx.wallet_address.0);
82
83    let Some(ref push_service) = state.push_service else {
84        return Err(ApiError::internal_error(
85            "Push notifications not configured",
86        ));
87    };
88
89    push_service
90        .subscribe(
91            &wallet,
92            &request.endpoint,
93            &request.auth_key,
94            &request.p256dh_key,
95            request.preferences,
96        )
97        .await
98        .map_err(|e| {
99            let msg = e.to_string();
100            // Validation errors (endpoint URL, subscription cap) -> 400
101            if msg.contains("not from a recognized push service")
102                || msg.contains("Maximum push subscriptions")
103            {
104                return ApiError::bad_request(msg);
105            }
106            tracing::error!("push_subscribe failed: {e}");
107            ApiError::internal_error("Failed to register push subscription")
108        })?;
109
110    Ok(SonicJson(PushSubscribeResponse { ok: true }))
111}
112
113/// Remove a Web Push subscription for the authenticated wallet.
114#[utoipa::path(
115    post,
116    path = "/push/unsubscribe",
117    request_body = PushUnsubscribeRequest,
118    responses(
119        (status = 200, description = "Subscription removed", body = PushUnsubscribeResponse),
120        (status = 500, description = "Internal server error"),
121    ),
122    security(("eip712_signature" = [])),
123    tag = "Push Notifications"
124)]
125pub async fn push_unsubscribe(
126    State(state): State<AppState>,
127    signer_ctx: SignerContext,
128    SonicJson(request): SonicJson<PushUnsubscribeRequest>,
129) -> Result<SonicJson<PushUnsubscribeResponse>, ApiError> {
130    let wallet = format!("{:#x}", signer_ctx.wallet_address.0);
131
132    let Some(ref push_service) = state.push_service else {
133        return Err(ApiError::internal_error(
134            "Push notifications not configured",
135        ));
136    };
137
138    let deleted = push_service
139        .unsubscribe(&wallet, &request.endpoint)
140        .await
141        .map_err(|e| {
142            tracing::error!("push_unsubscribe failed: {e}");
143            ApiError::internal_error("Failed to remove push subscription")
144        })?;
145
146    Ok(SonicJson(PushUnsubscribeResponse { deleted }))
147}
148
149/// Update notification preferences for an existing push subscription.
150#[utoipa::path(
151    post,
152    path = "/push/preferences",
153    request_body = PushPreferencesRequest,
154    responses(
155        (status = 200, description = "Preferences updated", body = PushPreferencesResponse),
156        (status = 500, description = "Internal server error"),
157    ),
158    security(("eip712_signature" = [])),
159    tag = "Push Notifications"
160)]
161pub async fn push_preferences(
162    State(state): State<AppState>,
163    signer_ctx: SignerContext,
164    SonicJson(request): SonicJson<PushPreferencesRequest>,
165) -> Result<SonicJson<PushPreferencesResponse>, ApiError> {
166    let wallet = format!("{:#x}", signer_ctx.wallet_address.0);
167
168    let Some(ref push_service) = state.push_service else {
169        return Err(ApiError::internal_error(
170            "Push notifications not configured",
171        ));
172    };
173
174    let updated = push_service
175        .update_preferences(&wallet, &request.endpoint, request.preferences)
176        .await
177        .map_err(|e| {
178            tracing::error!("push_preferences failed: {e}");
179            ApiError::internal_error("Failed to update notification preferences")
180        })?;
181
182    Ok(SonicJson(PushPreferencesResponse { updated }))
183}