AkurAI Build
Menu

Public repository / 8d41f1e10aceed387d425b43038f2070e399a349

AkurAI-Build / src/server/notifications_api.rs

Latest change b3f9a218e1ff51ec87a0dd7c07ff6b98fdb47905 - OLI-275: notifications feed, page and mail for reviews, merges and failed runs by Ólafur Búi Ólafsson

//! The per-user notification feed (OLI-275): API and page.

use axum::{
    Json,
    extract::{Path, Query, State},
    http::HeaderMap,
    response::{Html, IntoResponse, Redirect, Response},
};
use minijinja::context;
use serde::Deserialize;

use super::access_api::{Identity, identify};
use super::{ApiError, AppState, Envelope, ok, unix_time, valid_session};
use crate::notifications::{self, Notification};

/// The signed-in person's address (sessions and PATs; the controller token has no feed).
fn subject_of(headers: &HeaderMap, state: &AppState) -> Result<String, ApiError> {
    match identify(headers, state)? {
        Identity::Session { email, .. } => Ok(email),
        Identity::Token(principal) => principal
            .subject
            .ok_or_else(|| ApiError::forbidden("deploy keys have no notification feed")),
        Identity::Master => Err(ApiError::forbidden(
            "the controller token has no notification feed; sign in or use a personal access token",
        )),
    }
}

#[derive(Deserialize)]
pub(super) struct FeedQuery {
    #[serde(default)]
    unread: Option<bool>,
}

pub(super) async fn feed(
    State(state): State<AppState>,
    headers: HeaderMap,
    Query(query): Query<FeedQuery>,
) -> Result<Json<Envelope<Vec<Notification>>>, ApiError> {
    let subject = subject_of(&headers, &state)?;
    let database = state.database.clone();
    let unread = query.unread.unwrap_or(false);
    let list =
        tokio::task::spawn_blocking(move || notifications::feed(&database, &subject, unread))
            .await
            .map_err(ApiError::internal)?
            .map_err(ApiError::internal)?;
    Ok(ok(list))
}

pub(super) async fn read_one(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(id): Path<i64>,
) -> Result<Json<Envelope<bool>>, ApiError> {
    let subject = subject_of(&headers, &state)?;
    let now = unix_time()? as i64;
    let database = state.database.clone();
    let done =
        tokio::task::spawn_blocking(move || notifications::mark_read(&database, &subject, id, now))
            .await
            .map_err(ApiError::internal)?
            .map_err(ApiError::internal)?;
    if !done {
        return Err(ApiError::not_found("notification not found"));
    }
    Ok(ok(true))
}

pub(super) async fn read_all(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> Result<Json<Envelope<usize>>, ApiError> {
    let subject = subject_of(&headers, &state)?;
    let now = unix_time()? as i64;
    let database = state.database.clone();
    let count =
        tokio::task::spawn_blocking(move || notifications::mark_all_read(&database, &subject, now))
            .await
            .map_err(ApiError::internal)?
            .map_err(ApiError::internal)?;
    Ok(ok(count))
}

/// `/app/notifications`: the signed-in person's feed.
pub(super) async fn page(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> Result<Response, ApiError> {
    if !valid_session(&headers, &state) {
        return Ok(Redirect::to("/login").into_response());
    }
    let subject = subject_of(&headers, &state)?;
    let database = state.database.clone();
    let (items, unread) = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
        Ok((
            notifications::feed(&database, &subject, false)?,
            notifications::unread_count(&database, &subject)?,
        ))
    })
    .await
    .map_err(ApiError::internal)?
    .map_err(ApiError::internal)?;
    let rendered = state
        .templates
        .get_template("notifications.html")
        .map_err(ApiError::internal)?
        .render(context! { version => env!("CARGO_PKG_VERSION"), items => items, unread => unread })
        .map_err(ApiError::internal)?;
    Ok(Html(rendered).into_response())
}

/// Form posts from the page: mark one or all read, then back to the feed.
pub(super) async fn page_read_one(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(id): Path<i64>,
) -> Result<Response, ApiError> {
    if !valid_session(&headers, &state) {
        return Ok(Redirect::to("/login").into_response());
    }
    let subject = subject_of(&headers, &state)?;
    let now = unix_time()? as i64;
    let _ = notifications::mark_read(&state.database, &subject, id, now);
    Ok(Redirect::to("/app/notifications").into_response())
}

pub(super) async fn page_read_all(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> Result<Response, ApiError> {
    if !valid_session(&headers, &state) {
        return Ok(Redirect::to("/login").into_response());
    }
    let subject = subject_of(&headers, &state)?;
    let now = unix_time()? as i64;
    let _ = notifications::mark_all_read(&state.database, &subject, now);
    Ok(Redirect::to("/app/notifications").into_response())
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{
        Router,
        body::{Body, to_bytes},
        http::{Request, StatusCode, header},
        routing::{get, post},
    };
    use tower::ServiceExt;

    #[tokio::test]
    async fn the_feed_belongs_to_the_token_holder_and_marks_read() {
        let state = crate::server::tests::test_state("api-token-123", "webhook-secret");
        state
            .database
            .add_repository("demo", "https://example.com/demo.git", "main")
            .expect("repo");
        crate::access::set_collaborator(
            &state.database,
            "demo",
            "ada@example.is",
            crate::access::Role::Read,
        )
        .expect("ok");
        let (_, pat) = crate::access::create_token(
            &state.database,
            &crate::access::NewToken {
                kind: crate::access::Kind::Pat,
                name: "t",
                subject: Some("ada@example.is"),
                repository: None,
                role: crate::access::Role::Read,
                ttl_secs: None,
            },
            1,
        )
        .expect("pat");
        notifications::notify(
            &state.database,
            "ada@example.is",
            &notifications::New {
                kind: "run_failed",
                repository: "demo",
                title: "Run #1 failed",
                body: "",
                link: "",
            },
        )
        .expect("ok");
        notifications::notify(
            &state.database,
            "bob@example.is",
            &notifications::New {
                kind: "run_failed",
                repository: "demo",
                title: "Run #2 failed",
                body: "",
                link: "",
            },
        )
        .expect("ok");
        let app = Router::new()
            .route("/api/notifications", get(feed))
            .route("/api/notifications/{id}/read", post(read_one))
            .route("/api/notifications/read-all", post(read_all))
            .with_state(state);
        let send = |method: &'static str, uri: String, auth: String| {
            let app = app.clone();
            async move {
                let response = app
                    .oneshot(
                        Request::builder()
                            .method(method)
                            .uri(uri)
                            .header(header::AUTHORIZATION, auth)
                            .body(Body::empty())
                            .expect("request"),
                    )
                    .await
                    .expect("response");
                let status = response.status();
                let bytes = to_bytes(response.into_body(), usize::MAX)
                    .await
                    .expect("body");
                (
                    status,
                    serde_json::from_slice::<serde_json::Value>(&bytes)
                        .unwrap_or(serde_json::Value::Null),
                )
            }
        };
        let bearer = format!("Bearer {pat}");
        let (status, body) = send("GET", "/api/notifications".into(), bearer.clone()).await;
        assert_eq!(status, StatusCode::OK, "{body}");
        assert_eq!(
            body["data"].as_array().expect("array").len(),
            1,
            "only ada's"
        );
        let id = body["data"][0]["id"].as_i64().expect("id");
        let (status, _) = send(
            "GET",
            "/api/notifications".into(),
            "Bearer api-token-123".into(),
        )
        .await;
        assert_eq!(
            status,
            StatusCode::FORBIDDEN,
            "the controller token has no feed"
        );
        let (status, _) = send(
            "POST",
            format!("/api/notifications/{id}/read"),
            bearer.clone(),
        )
        .await;
        assert_eq!(status, StatusCode::OK);
        let (_, unread) = send(
            "GET",
            "/api/notifications?unread=true".into(),
            bearer.clone(),
        )
        .await;
        assert!(unread["data"].as_array().expect("array").is_empty());
        let (status, _) = send(
            "POST",
            format!("/api/notifications/{}/read", id + 1),
            bearer,
        )
        .await;
        assert_eq!(status, StatusCode::NOT_FOUND, "bob's is not ada's");
    }
}