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",
¬ifications::New {
kind: "run_failed",
repository: "demo",
title: "Run #1 failed",
body: "",
link: "",
},
)
.expect("ok");
notifications::notify(
&state.database,
"bob@example.is",
¬ifications::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");
}
}