pipey 0.2.1

A lightweight HTTP-to-WebSocket event delivery service.
Documentation
use crate::{
    models::{AppState, PublishPayload},
    utils::ApiResponse,
};
use actix_web::{Responder, http::StatusCode, post, web};
use arifa::prelude::{MessageKind, MessageScope, WsMessage};
use tracing::error;

/// Publish an event to a single recipient's private WebSocket channel.
///
/// This is the HTTP-facing entry point for server-to-client push messaging:
/// any internal service (or external caller with access to this route) can
/// use it to deliver a real-time event to a specific user, provided that
/// user currently has an open WebSocket connection to *some* node in the
/// cluster.
///
/// # Flow
/// 1. Look up which node (if any) the recipient is connected to via `arifa`.
///    A missing node mapping means the user is offline — there is no
///    separate "online" flag to check, since node presence *is* the
///    online signal (a connected session always has a node ID).
/// 2. Build a [`WsMessage`] scoped as [`MessageScope::Private`] so it is
///    only delivered to the target user's own channel, not broadcast.
/// 3. Publish to the user's channel (`User::{user_id}`). `arifa` handles
///    routing the message to the correct node/socket from there.
///
/// # Delivery guarantees
/// This endpoint confirms the message was **published to the recipient's
/// channel**, not that the client actually received and processed it.
/// There is an inherent (small) race between the online check and the
/// publish call: the recipient could disconnect in between, in which case
/// the publish is effectively dropped. This endpoint does not retry or
/// queue for later delivery — for guaranteed eventual delivery to
/// currently-offline users, that needs to be handled by a separate
/// mechanism (e.g. an offline message queue), not this route.
///
/// # Responses
/// - `200 OK` — message was published to the recipient's channel.
/// - `404 Not Found` — recipient is not currently connected to any node.
/// - `500 Internal Server Error` — Redis/`arifa` lookup or publish failed;
///   see server logs for the underlying error.
#[post("/pipey/publish")]
pub async fn publish_to_pipe(
    state: web::Data<AppState>,
    payload: web::Json<PublishPayload>,
) -> impl Responder {
    let user_id = payload.recipient.to_string();

    // Resolve which node currently owns this user's WebSocket connection.
    // `None` means offline; we deliberately avoid a second "is_user_online"
    // call here since node presence already implies online status.
    let node_id = match state.arifa.get_user_node_id(&user_id).await {
        Ok(Some(node_id)) => node_id,
        Ok(None) => {
            return ApiResponse::error("Recipient is offline", StatusCode::NOT_FOUND);
        }
        Err(err) => {
            error!(%user_id, %err, "failed to lookup recipient node");
            return ApiResponse::error(
                "Failed to lookup recipient status",
                StatusCode::INTERNAL_SERVER_ERROR,
            );
        }
    };

    // Take ownership of the payload body instead of cloning `payload.payload`,
    // since we no longer need `payload` (the Json wrapper) after this point.
    let payload = payload.into_inner();

    let channel = format!("User::{user_id}");
    let message = WsMessage {
        scope: MessageScope::Private, // deliver only to this user's channel
        kind: MessageKind::Event,
        node_id: Some(node_id), // tells arifa which node should handle delivery
        payload: payload.payload,
    };

    match state.arifa.publish(&channel, &message).await {
        Ok(_) => ApiResponse::ok_empty("Event published", StatusCode::OK),
        Err(err) => {
            error!(%user_id, %err, "failed to publish event");
            ApiResponse::error("Failed to publish event", StatusCode::INTERNAL_SERVER_ERROR)
        }
    }
}