diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 8918c296..9f80abae 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1638,6 +1638,7 @@ dependencies = [ "graphql_datasource", "graphql_general", "graphql_notification_config", + "graphql_notification_event", "graphql_notification_query", "graphql_recipient", "graphql_recipient_list", @@ -1726,6 +1727,26 @@ dependencies = [ "util", ] +[[package]] +name = "graphql_notification_event" +version = "0.1.0" +dependencies = [ + "actix-rt", + "actix-web", + "assert-json-diff", + "async-graphql", + "async-graphql-actix-web", + "async-trait", + "chrono", + "graphql_core", + "graphql_types", + "repository", + "serde", + "serde_json", + "service", + "util", +] + [[package]] name = "graphql_notification_query" version = "0.1.0" diff --git a/backend/graphql/Cargo.toml b/backend/graphql/Cargo.toml index d3aae893..5b8c9031 100644 --- a/backend/graphql/Cargo.toml +++ b/backend/graphql/Cargo.toml @@ -16,6 +16,7 @@ graphql_types = { path = "types" } graphql_general = { path = "general" } graphql_notification_config = { path = "notification_config" } graphql_notification_query = { path = "notification_query" } +graphql_notification_event = { path = "notification_event" } graphql_user_account = { path = "user_account" } graphql_recipient = { path = "recipient" } graphql_recipient_list = { path = "recipient_list" } diff --git a/backend/graphql/core/src/loader/loader_registry.rs b/backend/graphql/core/src/loader/loader_registry.rs index 15d00c17..24ccbeb9 100644 --- a/backend/graphql/core/src/loader/loader_registry.rs +++ b/backend/graphql/core/src/loader/loader_registry.rs @@ -5,7 +5,10 @@ use async_graphql::dataloader::DataLoader; use repository::StorageConnectionManager; use service::service_provider::ServiceProvider; -use super::{user_permission::UserPermissionLoader, AuditLogLoader, RecipientsLoader}; +use super::{ + user_permission::UserPermissionLoader, AuditLogLoader, NotificationConfigLoader, + RecipientsLoader, +}; pub type LoaderMap = Map; pub type AnyLoader = dyn Any + Send + Sync; @@ -61,5 +64,13 @@ pub async fn get_loaders( ); loaders.insert(audit_log_loader); + let notification_config_loader = DataLoader::new( + NotificationConfigLoader { + connection_manager: connection_manager.clone(), + }, + async_std::task::spawn, + ); + loaders.insert(notification_config_loader); + loaders } diff --git a/backend/graphql/core/src/loader/mod.rs b/backend/graphql/core/src/loader/mod.rs index 3802f593..11121950 100644 --- a/backend/graphql/core/src/loader/mod.rs +++ b/backend/graphql/core/src/loader/mod.rs @@ -1,11 +1,13 @@ mod audit_log; mod loader_registry; +mod notification_config; mod recipient; mod user; mod user_permission; pub use audit_log::*; pub use loader_registry::{get_loaders, LoaderMap, LoaderRegistry}; +pub use notification_config::*; pub use recipient::*; pub use user::*; pub use user_permission::*; diff --git a/backend/graphql/core/src/loader/notification_config.rs b/backend/graphql/core/src/loader/notification_config.rs new file mode 100644 index 00000000..1ee89ee9 --- /dev/null +++ b/backend/graphql/core/src/loader/notification_config.rs @@ -0,0 +1,35 @@ +use repository::{EqualFilter, NotificationConfigFilter, NotificationConfigRepository, Pagination}; +use repository::{NotificationConfigRow, StorageConnectionManager}; + +use async_graphql::dataloader::*; +use async_graphql::*; +use std::collections::HashMap; + +pub struct NotificationConfigLoader { + pub connection_manager: StorageConnectionManager, +} + +#[async_trait::async_trait] +impl Loader for NotificationConfigLoader { + type Value = NotificationConfigRow; + type Error = async_graphql::Error; + + async fn load( + &self, + config_ids: &[String], + ) -> Result, Self::Error> { + let connection = self.connection_manager.connection()?; + let repo = NotificationConfigRepository::new(&connection); + Ok(repo + .query( + Pagination::all(), + Some( + NotificationConfigFilter::new().id(EqualFilter::equal_any(config_ids.to_vec())), + ), + None, + )? + .into_iter() + .map(|config| (config.id.clone(), config)) + .collect()) + } +} diff --git a/backend/graphql/lib.rs b/backend/graphql/lib.rs index 1a586e4e..88a1a455 100644 --- a/backend/graphql/lib.rs +++ b/backend/graphql/lib.rs @@ -13,6 +13,7 @@ use graphql_datasource::DatasourceQueries; use graphql_general::GeneralQueries; use graphql_notification_config::{NotificationConfigMutations, NotificationConfigQueries}; +use graphql_notification_event::NotificationEventQueries; use graphql_notification_query::{NotificationQueryMutations, NotificationQueryQueries}; use graphql_recipient::{RecipientMutations, RecipientQueries}; use graphql_recipient_list::{RecipientListMutations, RecipientListQueries}; @@ -36,6 +37,7 @@ pub struct FullQuery( pub TelegramQueries, pub NotificationConfigQueries, pub NotificationQueryQueries, + pub NotificationEventQueries, pub DatasourceQueries, ); @@ -61,6 +63,7 @@ pub fn full_query() -> FullQuery { TelegramQueries, NotificationConfigQueries, NotificationQueryQueries, + NotificationEventQueries, DatasourceQueries, ) } diff --git a/backend/graphql/notification_event/Cargo.toml b/backend/graphql/notification_event/Cargo.toml new file mode 100644 index 00000000..f52546af --- /dev/null +++ b/backend/graphql/notification_event/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "graphql_notification_event" +version = "0.1.0" +edition = "2018" + +[lib] +path = "src/lib.rs" +doctest = false + +[dependencies] + +repository = { path = "../../repository" } +service = { path = "../../service" } +util = { path = "../../util" } +graphql_core = { path = "../core" } +graphql_types = { path = "../types" } + +actix-web = { version = "4.0.1", default-features = false, features = [ + "macros", +] } +async-graphql = { version = "3.0.35", features = ["dataloader", "chrono"] } +async-graphql-actix-web = "3.0.35" +async-trait = "0.1.30" +serde = "1.0.126" +serde_json = "1.0.66" +chrono = { version = "0.4", features = ["serde"] } + +[dev-dependencies] +actix-rt = "2.6.0" +assert-json-diff = "2.0.1" diff --git a/backend/graphql/notification_event/src/lib.rs b/backend/graphql/notification_event/src/lib.rs new file mode 100644 index 00000000..cb0d0f3a --- /dev/null +++ b/backend/graphql/notification_event/src/lib.rs @@ -0,0 +1,54 @@ +mod types; +use self::types::*; + +use async_graphql::*; +use graphql_core::{ + pagination::PaginationInput, + standard_graphql_error::{validate_auth, StandardGraphqlError}, + ContextExt, +}; + +use repository::NotificationEventFilter; +use repository::PaginationOption; +use service::auth::{Resource, ResourceAccessRequest}; + +#[derive(Default, Clone)] +pub struct NotificationEventQueries; + +#[Object] +impl NotificationEventQueries { + pub async fn notification_events( + &self, + ctx: &Context<'_>, + #[graphql(desc = "Pagination option (first and offset)")] page: Option, + #[graphql(desc = "Filter option")] filter: Option, + #[graphql(desc = "Sort options (only first sort input is evaluated for this endpoint)")] + sort: Option>, + ) -> Result { + let user = validate_auth( + ctx, + &ResourceAccessRequest { + resource: Resource::ServerAdmin, + }, + )?; + + let service_context = ctx.service_context(Some(&user))?; + + let configs = service_context + .service_provider + .notification_event_service + .get_notification_events( + &service_context, + page.map(PaginationOption::from), + filter.map(NotificationEventFilter::from), + // Currently only one sort option is supported, use the first from the list. + sort.and_then(|mut sort_list| sort_list.pop()) + .map(|sort| sort.to_domain()), + ) + .map_err(StandardGraphqlError::from_list_error)?; + + Ok(NotificationEventsResponse::Response( + NotificationEventConnector::from_domain(configs), + )) + } +} diff --git a/backend/graphql/notification_event/src/types/event_status.rs b/backend/graphql/notification_event/src/types/event_status.rs new file mode 100644 index 00000000..70d1cdeb --- /dev/null +++ b/backend/graphql/notification_event/src/types/event_status.rs @@ -0,0 +1,32 @@ +use async_graphql::Enum; +use repository::NotificationEventStatus; +use serde::Serialize; + +#[derive(Enum, Copy, Clone, PartialEq, Eq, Debug, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum EventStatus { + Queued, + Sent, + Errored, // Errored will be re-tried + Failed, // Failed will not be re-tried +} + +impl EventStatus { + pub fn to_domain(self) -> NotificationEventStatus { + match self { + EventStatus::Queued => NotificationEventStatus::Queued, + EventStatus::Sent => NotificationEventStatus::Sent, + EventStatus::Errored => NotificationEventStatus::Errored, + EventStatus::Failed => NotificationEventStatus::Failed, + } + } + + pub fn from_domain(status: &NotificationEventStatus) -> EventStatus { + match status { + NotificationEventStatus::Queued => EventStatus::Queued, + NotificationEventStatus::Sent => EventStatus::Sent, + NotificationEventStatus::Errored => EventStatus::Errored, + NotificationEventStatus::Failed => EventStatus::Failed, + } + } +} diff --git a/backend/graphql/notification_event/src/types/inputs.rs b/backend/graphql/notification_event/src/types/inputs.rs new file mode 100644 index 00000000..f2c59da4 --- /dev/null +++ b/backend/graphql/notification_event/src/types/inputs.rs @@ -0,0 +1,78 @@ +use async_graphql::{Enum, InputObject}; +use graphql_core::{ + generic_filters::{DatetimeFilterInput, EqualFilterStringInput}, + map_filter, +}; +use repository::{ + DatetimeFilter, EqualFilter, NotificationEventFilter, NotificationEventSort, + NotificationEventSortField, +}; + +use super::EventStatus; + +#[derive(Enum, Copy, Clone, PartialEq, Eq)] +#[graphql(rename_items = "camelCase")] +pub enum NotificationEventSortFieldInput { + Title, + CreatedAt, + ToAddress, + Message, + NotificationType, + Status, + ErrorMessage, +} + +#[derive(InputObject, Clone)] +pub struct EqualFilterEventStatusInput { + pub equal_to: Option, + pub equal_any: Option>, + pub not_equal_to: Option, +} + +#[derive(InputObject)] +pub struct NotificationEventSortInput { + /// Sort query result by `key` + key: NotificationEventSortFieldInput, + /// Sort query result is sorted descending or ascending (if not provided the default is + /// ascending) + desc: Option, +} +impl NotificationEventSortInput { + pub fn to_domain(self) -> NotificationEventSort { + use NotificationEventSortField as to; + use NotificationEventSortFieldInput as from; + let key = match self.key { + from::Title => to::Title, + from::CreatedAt => to::CreatedAt, + from::ToAddress => to::ToAddress, + from::Message => to::Message, + from::NotificationType => to::NotificationType, + from::Status => to::Status, + from::ErrorMessage => to::ErrorMessage, + }; + + NotificationEventSort { + key, + desc: self.desc, + } + } +} + +#[derive(Clone, InputObject)] +pub struct NotificationEventFilterInput { + pub id: Option, + pub search: Option, + pub status: Option, + pub created_at: Option, +} + +impl From for NotificationEventFilter { + fn from(f: NotificationEventFilterInput) -> Self { + NotificationEventFilter { + id: f.id.map(EqualFilter::from), + search: f.search, + status: f.status.map(|t| map_filter!(t, EventStatus::to_domain)), + created_at: f.created_at.map(DatetimeFilter::from), + } + } +} diff --git a/backend/graphql/notification_event/src/types/mod.rs b/backend/graphql/notification_event/src/types/mod.rs new file mode 100644 index 00000000..b5d78987 --- /dev/null +++ b/backend/graphql/notification_event/src/types/mod.rs @@ -0,0 +1,6 @@ +mod inputs; +pub use inputs::*; +mod event_status; +pub use event_status::*; +mod notification_event; +pub use notification_event::*; diff --git a/backend/graphql/notification_event/src/types/notification_event.rs b/backend/graphql/notification_event/src/types/notification_event.rs new file mode 100644 index 00000000..02ec8c56 --- /dev/null +++ b/backend/graphql/notification_event/src/types/notification_event.rs @@ -0,0 +1,134 @@ +use async_graphql::{dataloader::DataLoader, *}; +use chrono::{DateTime, Utc}; +use graphql_core::simple_generic_errors::NodeError; +use graphql_core::{loader::NotificationConfigLoader, ContextExt}; + +use graphql_types::types::{NotificationConfigNode, NotificationTypeNode}; +use repository::NotificationEvent; +use service::ListResult; +use util::usize_to_u32; + +use super::EventStatus; + +#[derive(Union)] +pub enum NotificationEventsResponse { + Response(NotificationEventConnector), +} + +#[derive(Union)] +pub enum NotificationEventResponse { + Error(NodeError), + Response(NotificationEventNode), +} + +#[derive(PartialEq, Debug, Clone)] +pub struct NotificationEventNode { + pub notification_event: NotificationEvent, +} + +#[Object] +impl NotificationEventNode { + pub async fn id(&self) -> &str { + &self.row().id + } + + pub async fn notification_config_id(&self) -> Option { + self.row().notification_config_id.to_owned() + } + + pub async fn title(&self) -> String { + self.row().title.to_owned().unwrap_or_default() + } + pub async fn message(&self) -> &str { + &self.row().message + } + pub async fn to_address(&self) -> &str { + &self.row().to_address + } + pub async fn notification_type(&self) -> NotificationTypeNode { + NotificationTypeNode::from_domain(&self.row().notification_type) + } + + pub async fn error_message(&self) -> Option { + self.row().error_message.to_owned() + } + + pub async fn created_at(&self) -> DateTime { + DateTime::::from_utc(self.row().created_at, Utc) + } + pub async fn updated_at(&self) -> DateTime { + DateTime::::from_utc(self.row().updated_at, Utc) + } + pub async fn sent_at(&self) -> Option> { + match self.row().sent_at { + Some(sent_at) => Some(DateTime::::from_utc(sent_at, Utc)), + None => None, + } + } + + pub async fn status(&self) -> EventStatus { + EventStatus::from_domain(&self.row().status) + } + + pub async fn send_attempts(&self) -> i32 { + self.row().send_attempts + } + + pub async fn notification_config( + &self, + ctx: &Context<'_>, + ) -> Result> { + let loader = ctx.get_loader::>(); + + let config_id = match &self.row().notification_config_id { + Some(config_id) => config_id, + None => return Ok(None), + }; + + match loader.load_one(config_id.clone()).await? { + Some(config) => Ok(Some(NotificationConfigNode::from_domain(config.into()))), + None => Ok(None), + } + } +} + +impl NotificationEventNode { + pub fn from_domain(notification_event: NotificationEvent) -> NotificationEventNode { + NotificationEventNode { notification_event } + } + + pub fn row(&self) -> &NotificationEvent { + &self.notification_event + } +} + +#[derive(SimpleObject)] +pub struct NotificationEventConnector { + total_count: u32, + nodes: Vec, +} + +impl NotificationEventConnector { + pub fn from_domain( + notification_events: ListResult, + ) -> NotificationEventConnector { + NotificationEventConnector { + total_count: notification_events.count, + nodes: notification_events + .rows + .into_iter() + .map(NotificationEventNode::from_domain) + .collect(), + } + } + + pub fn from_vec(notification_events: Vec) -> NotificationEventConnector { + NotificationEventConnector { + total_count: usize_to_u32(notification_events.len()), + nodes: notification_events + .into_iter() + .map(NotificationEventNode::from_domain) + .collect(), + } + } +} diff --git a/backend/repository/src/db_diesel/mod.rs b/backend/repository/src/db_diesel/mod.rs index 7dbafebc..2c32f176 100644 --- a/backend/repository/src/db_diesel/mod.rs +++ b/backend/repository/src/db_diesel/mod.rs @@ -11,6 +11,7 @@ mod filter_sort_pagination; pub mod key_value_store; pub mod notification_config; mod notification_config_row; +pub mod notification_event; pub mod notification_event_row; pub mod notification_query; pub mod notification_query_row; @@ -35,6 +36,7 @@ pub use filter_sort_pagination::*; pub use key_value_store::*; pub use notification_config::*; pub use notification_config_row::*; +pub use notification_event::*; pub use notification_event_row::*; pub use notification_query::*; pub use notification_query_row::*; diff --git a/backend/repository/src/db_diesel/notification_event.rs b/backend/repository/src/db_diesel/notification_event.rs new file mode 100644 index 00000000..0aa47348 --- /dev/null +++ b/backend/repository/src/db_diesel/notification_event.rs @@ -0,0 +1,168 @@ +use super::{ + notification_event_row::{ + notification_event, notification_event::dsl as notification_event_dsl, + }, + DBType, NotificationEventRow, StorageConnection, +}; +use crate::{ + diesel_macros::{apply_date_time_filter, apply_equal_filter, apply_sort_no_case}, + repository_error::RepositoryError, + DatetimeFilter, EqualFilter, NotificationEventStatus, Pagination, Sort, +}; + +use diesel::{dsl::IntoBoxed, prelude::*}; + +pub type NotificationEvent = NotificationEventRow; + +#[derive(Clone, Default, Debug, PartialEq)] +pub struct NotificationEventFilter { + pub id: Option>, + pub search: Option, + pub status: Option>, + pub created_at: Option, +} + +impl NotificationEventFilter { + pub fn new() -> NotificationEventFilter { + NotificationEventFilter::default() + } + + pub fn id(mut self, filter: EqualFilter) -> Self { + self.id = Some(filter); + self + } + + pub fn search(mut self, filter: String) -> Self { + self.search = Some(filter); + self + } +} + +#[derive(PartialEq, Debug)] +pub enum NotificationEventSortField { + Title, + Id, + CreatedAt, + ToAddress, + Message, + NotificationType, + Status, + ErrorMessage, +} + +pub type NotificationEventSort = Sort; + +pub struct NotificationEventRepository<'a> { + connection: &'a StorageConnection, +} + +impl<'a> NotificationEventRepository<'a> { + pub fn new(connection: &'a StorageConnection) -> Self { + NotificationEventRepository { connection } + } + + pub fn count(&self, filter: Option) -> Result { + let query = create_filtered_query(filter); + + Ok(query.count().get_result(&self.connection.connection)?) + } + + pub fn query_by_filter( + &self, + filter: NotificationEventFilter, + ) -> Result, RepositoryError> { + self.query(Pagination::new(), Some(filter), None) + } + + pub fn query_one( + &self, + filter: NotificationEventFilter, + ) -> Result, RepositoryError> { + Ok(self.query_by_filter(filter)?.pop()) + } + + pub fn query( + &self, + pagination: Pagination, + filter: Option, + sort: Option, + ) -> Result, RepositoryError> { + let mut query = create_filtered_query(filter); + + if let Some(sort) = sort { + match sort.key { + NotificationEventSortField::Title => { + apply_sort_no_case!(query, sort, notification_event_dsl::title); + } + NotificationEventSortField::Id => { + apply_sort_no_case!(query, sort, notification_event_dsl::id); + } + NotificationEventSortField::CreatedAt => { + apply_sort_no_case!(query, sort, notification_event_dsl::created_at); + } + NotificationEventSortField::ToAddress => { + apply_sort_no_case!(query, sort, notification_event_dsl::to_address); + } + NotificationEventSortField::Message => { + apply_sort_no_case!(query, sort, notification_event_dsl::message); + } + NotificationEventSortField::NotificationType => { + apply_sort_no_case!(query, sort, notification_event_dsl::notification_type); + } + NotificationEventSortField::Status => { + apply_sort_no_case!(query, sort, notification_event_dsl::status); + } + NotificationEventSortField::ErrorMessage => { + apply_sort_no_case!(query, sort, notification_event_dsl::error_message); + } + } + } else { + query = query.order(notification_event_dsl::id.asc()) + } + + let final_query = query + .offset(pagination.offset as i64) + .limit(pagination.limit as i64); + + // // Debug diesel query + // println!( + // "{}", + // diesel::debug_query::(&final_query).to_string() + // ); + + let result = final_query.load::(&self.connection.connection)?; + Ok(result) + } +} + +type BoxedQuery = IntoBoxed<'static, notification_event::table, DBType>; + +fn create_filtered_query(filter: Option) -> BoxedQuery { + let mut query = notification_event_dsl::notification_event.into_boxed(); + + if let Some(f) = filter { + let NotificationEventFilter { + id, + search, + status, + created_at, + } = f; + + apply_equal_filter!(query, id, notification_event_dsl::id); + apply_equal_filter!(query, status, notification_event_dsl::status); + apply_date_time_filter!(query, created_at, notification_event_dsl::created_at); + + if let Some(search) = search { + let search_term = format!("%{}%", search); + query = query.filter( + notification_event_dsl::title + .like(search_term.clone()) + .or(notification_event_dsl::message.like(search_term.clone())) + .or(notification_event_dsl::to_address.like(search_term.clone())) + .or(notification_event_dsl::error_message.like(search_term.clone())), + ); + } + } + + query +} diff --git a/backend/service/src/lib.rs b/backend/service/src/lib.rs index a9ea4f4f..6d8a6fb6 100644 --- a/backend/service/src/lib.rs +++ b/backend/service/src/lib.rs @@ -10,6 +10,7 @@ pub mod log_service; pub mod login; pub mod notification; pub mod notification_config; +pub mod notification_event; pub mod notification_query; pub mod plugin; pub mod plugin_store; diff --git a/backend/service/src/notification_event/mod.rs b/backend/service/src/notification_event/mod.rs new file mode 100644 index 00000000..70d485e9 --- /dev/null +++ b/backend/service/src/notification_event/mod.rs @@ -0,0 +1,34 @@ +pub mod query; +mod tests; + +use self::query::{get_notification_event, get_notification_events}; + +use super::{ListError, ListResult}; +use crate::{service_provider::ServiceContext, SingleRecordError}; + +use repository::{ + NotificationEvent, NotificationEventFilter, NotificationEventSort, PaginationOption, +}; + +pub trait NotificationEventServiceTrait: Sync + Send { + fn get_notification_events( + &self, + ctx: &ServiceContext, + pagination: Option, + filter: Option, + sort: Option, + ) -> Result, ListError> { + get_notification_events(ctx, pagination, filter, sort) + } + + fn get_notification_event( + &self, + ctx: &ServiceContext, + notification_event_id: String, + ) -> Result { + get_notification_event(ctx, notification_event_id) + } +} + +pub struct NotificationEventService {} +impl NotificationEventServiceTrait for NotificationEventService {} diff --git a/backend/service/src/notification_event/query.rs b/backend/service/src/notification_event/query.rs new file mode 100644 index 00000000..5ded5700 --- /dev/null +++ b/backend/service/src/notification_event/query.rs @@ -0,0 +1,44 @@ +use repository::{ + EqualFilter, NotificationEvent, NotificationEventFilter, NotificationEventRepository, + NotificationEventSort, PaginationOption, +}; +use util::number_conversions::i64_to_u32; + +use crate::{ + get_default_pagination, service_provider::ServiceContext, ListError, ListResult, + SingleRecordError, +}; + +pub const MAX_LIMIT: u32 = 1000; +pub const MIN_LIMIT: u32 = 1; + +pub fn get_notification_events( + ctx: &ServiceContext, + pagination: Option, + filter: Option, + sort: Option, +) -> Result, ListError> { + let pagination = get_default_pagination(pagination, MAX_LIMIT, MIN_LIMIT)?; + let repository = NotificationEventRepository::new(&ctx.connection); + + Ok(ListResult { + rows: repository.query(pagination, filter.clone(), sort)?, + count: i64_to_u32(repository.count(filter)?), + }) +} + +pub fn get_notification_event( + ctx: &ServiceContext, + id: String, +) -> Result { + let repository = NotificationEventRepository::new(&ctx.connection); + + let mut result = repository + .query_by_filter(NotificationEventFilter::new().id(EqualFilter::equal_to(&id)))?; + + if let Some(record) = result.pop() { + Ok(record) + } else { + Err(SingleRecordError::NotFound(id)) + } +} diff --git a/backend/service/src/notification_event/tests/mod.rs b/backend/service/src/notification_event/tests/mod.rs new file mode 100644 index 00000000..7a5c5969 --- /dev/null +++ b/backend/service/src/notification_event/tests/mod.rs @@ -0,0 +1,2 @@ +#[cfg(test)] +mod query; diff --git a/backend/service/src/notification_event/tests/query.rs b/backend/service/src/notification_event/tests/query.rs new file mode 100644 index 00000000..17ac60b3 --- /dev/null +++ b/backend/service/src/notification_event/tests/query.rs @@ -0,0 +1,134 @@ +#[cfg(test)] +mod notification_event_query_test { + use std::sync::Arc; + + use repository::{ + mock::MockDataInserts, test_db::setup_all, NotificationEventFilter, + NotificationEventSortField, + }; + use repository::{NotificationEventRow, NotificationEventRowRepository, Sort}; + + use crate::service_provider::ServiceContext; + use crate::test_utils::get_test_settings; + use crate::{service_provider::ServiceProvider, SingleRecordError}; + + #[actix_rt::test] + async fn notification_event_service_single_record() { + let (_, _, connection_manager, _) = setup_all( + "test_notification_event_single_record", + MockDataInserts::none(), + ) + .await; + + let service_provider = Arc::new(ServiceProvider::new( + connection_manager, + get_test_settings(""), + )); + let context = ServiceContext::new(service_provider).unwrap(); + let service = &context.service_provider.notification_event_service; + + assert_eq!( + service.get_notification_event(&context, "invalid_id".to_owned()), + Err(SingleRecordError::NotFound("invalid_id".to_owned())) + ); + + let repo = NotificationEventRowRepository::new(&context.connection); + + let id = "some-id".to_string(); + let notification_event = NotificationEventRow { + id: id.clone(), + ..Default::default() + }; + repo.insert_one(¬ification_event).unwrap(); + + let db_notification_event = service + .get_notification_event(&context, id.clone()) + .unwrap(); + + assert_eq!(db_notification_event.id, id); + } + + #[actix_rt::test] + async fn notification_event_service_filter_search() { + let (_, _, connection_manager, _) = setup_all( + "test_notification_event_filter_search", + MockDataInserts::none(), + ) + .await; + + let service_provider = Arc::new(ServiceProvider::new( + connection_manager, + get_test_settings(""), + )); + let context = ServiceContext::new(service_provider).unwrap(); + let service = &context.service_provider.notification_event_service; + + /* A search filter, should match strings in the title, message,to_address and error_message */ + + // Setup test records + let repo = NotificationEventRowRepository::new(&context.connection); + + let searched_string = "searched_string".to_string(); + + let id0 = "id0-no-match".to_string(); + let notification_event = NotificationEventRow { + id: id0.clone(), + + ..Default::default() + }; + repo.insert_one(¬ification_event).unwrap(); + + let id1 = "id1-title".to_string(); + let notification_event = NotificationEventRow { + id: id1.clone(), + title: Some(searched_string.clone()), + ..Default::default() + }; + repo.insert_one(¬ification_event).unwrap(); + + let id2 = "id2-message".to_string(); + let notification_event = NotificationEventRow { + id: id2.clone(), + message: searched_string.clone(), + ..Default::default() + }; + repo.insert_one(¬ification_event).unwrap(); + + let id3 = "id3-to_address".to_string(); + let notification_event = NotificationEventRow { + id: id3.clone(), + to_address: searched_string.clone(), + ..Default::default() + }; + repo.insert_one(¬ification_event).unwrap(); + + let id4 = "id4-error-message".to_string(); + let notification_event = NotificationEventRow { + id: id4.clone(), + error_message: Some(searched_string.clone()), + ..Default::default() + }; + repo.insert_one(¬ification_event).unwrap(); + + // Query to find the new records + let db_notification_events = service + .get_notification_events( + &context, + None, + Some(NotificationEventFilter::new().search(searched_string)), + Some(Sort { + key: NotificationEventSortField::Id, + desc: Some(false), + }), + ) + .unwrap(); + + // We should see all the records with the searched string in the fields we're searching + // We should not see any of the records that don't have that searched string + assert_eq!(db_notification_events.count, 4); + assert_eq!(db_notification_events.rows[0].id, id1); + assert_eq!(db_notification_events.rows[1].id, id2); + assert_eq!(db_notification_events.rows[2].id, id3); + assert_eq!(db_notification_events.rows[3].id, id4); + } +} diff --git a/backend/service/src/service_provider.rs b/backend/service/src/service_provider.rs index 9b89e69f..1b011120 100644 --- a/backend/service/src/service_provider.rs +++ b/backend/service/src/service_provider.rs @@ -10,6 +10,7 @@ use crate::{ log_service::{LogService, LogServiceTrait}, notification::{NotificationService, NotificationServiceTrait}, notification_config::{NotificationConfigService, NotificationConfigServiceTrait}, + notification_event::{NotificationEventService, NotificationEventServiceTrait}, notification_query::{NotificationQueryService, NotificationQueryServiceTrait}, plugin_store::{PluginService, PluginServiceTrait}, recipient::{RecipientService, RecipientServiceTrait}, @@ -30,6 +31,7 @@ pub struct ServiceProvider { pub recipient_list_service: Box, pub sql_recipient_list_service: Box, pub notification_query_service: Box, + pub notification_event_service: Box, pub notification_service: Box, pub plugin_service: Box, pub settings: Settings, @@ -95,6 +97,7 @@ impl ServiceProvider { recipient_list_service: Box::new(RecipientListService {}), sql_recipient_list_service: Box::new(SqlRecipientListService {}), notification_query_service: Box::new(NotificationQueryService {}), + notification_event_service: Box::new(NotificationEventService {}), notification_service: Box::new(NotificationService::new(settings.clone())), plugin_service: Box::new(PluginService {}), settings, diff --git a/frontend/packages/common/src/intl/locales/en/common.json b/frontend/packages/common/src/intl/locales/en/common.json index a295f612..9eefdf2d 100644 --- a/frontend/packages/common/src/intl/locales/en/common.json +++ b/frontend/packages/common/src/intl/locales/en/common.json @@ -191,5 +191,6 @@ "label.subject-template": "Subject template", "label.body-template": "Body template", "label.parameters": "Parameters", - "label.reference-name": "Reference Name" + "label.reference-name": "Reference Name", + "label.notification-type": "Notification Type" } \ No newline at end of file diff --git a/frontend/packages/common/src/intl/locales/en/host.json b/frontend/packages/common/src/intl/locales/en/host.json index c88ba54d..5e5e91e6 100644 --- a/frontend/packages/common/src/intl/locales/en/host.json +++ b/frontend/packages/common/src/intl/locales/en/host.json @@ -48,5 +48,6 @@ "set-up-account": "Set up your account", "username-reset.explanation": "Enter your new log in username", "users": "Users", - "queries": "Queries" + "queries": "Queries", + "notification-events": "Events" } \ No newline at end of file diff --git a/frontend/packages/common/src/intl/locales/en/system.json b/frontend/packages/common/src/intl/locales/en/system.json index ad850660..10db4a5f 100644 --- a/frontend/packages/common/src/intl/locales/en/system.json +++ b/frontend/packages/common/src/intl/locales/en/system.json @@ -98,5 +98,7 @@ "label.degrees-celsius": "°C", "label.select-queries": "Select Queries", "message.no-queries-selected": "No queries selected", - "label.schedule": "Schedule" + "label.schedule": "Schedule", + "button.edit-notification-config": "Edit Notification Config", + "messages.no-events-matching-status": "No events found" } \ No newline at end of file diff --git a/frontend/packages/common/src/types/schema.ts b/frontend/packages/common/src/types/schema.ts index aa38d9f2..559387ae 100644 --- a/frontend/packages/common/src/types/schema.ts +++ b/frontend/packages/common/src/types/schema.ts @@ -140,6 +140,12 @@ export type EqualFilterConfigStatusInput = { notEqualTo?: InputMaybe; }; +export type EqualFilterEventStatusInput = { + equalAny?: InputMaybe>; + equalTo?: InputMaybe; + notEqualTo?: InputMaybe; +}; + export type EqualFilterLogTypeInput = { equalAny?: InputMaybe>; equalTo?: InputMaybe; @@ -158,6 +164,13 @@ export type EqualFilterStringInput = { notEqualTo?: InputMaybe; }; +export enum EventStatus { + Errored = 'ERRORED', + Failed = 'FAILED', + Queued = 'QUEUED', + Sent = 'SENT' +} + export type FullMutation = { __typename: 'FullMutation'; /** Updates user account based on a token and their information (Response to initiate_user_invite) */ @@ -340,6 +353,7 @@ export type FullQuery = { logs: LogResponse; me: UserResponse; notificationConfigs: NotificationConfigsResponse; + notificationEvents: NotificationEventsResponse; notificationQueries: NotificationQueriesResponse; /** Query "recipient_list" entries */ recipientLists: RecipientListsResponse; @@ -381,6 +395,13 @@ export type FullQueryNotificationConfigsArgs = { }; +export type FullQueryNotificationEventsArgs = { + filter?: InputMaybe; + page?: InputMaybe; + sort?: InputMaybe>; +}; + + export type FullQueryNotificationQueriesArgs = { filter?: InputMaybe; page?: InputMaybe; @@ -619,6 +640,53 @@ export type NotificationConfigSortInput = { export type NotificationConfigsResponse = NotificationConfigConnector; +export type NotificationEventConnector = { + __typename: 'NotificationEventConnector'; + nodes: Array; + totalCount: Scalars['Int']['output']; +}; + +export type NotificationEventFilterInput = { + id?: InputMaybe; + search?: InputMaybe; + status?: InputMaybe; + title?: InputMaybe; +}; + +export type NotificationEventNode = { + __typename: 'NotificationEventNode'; + createdAt: Scalars['DateTime']['output']; + errorMessage?: Maybe; + id: Scalars['String']['output']; + message: Scalars['String']['output']; + notificationConfig?: Maybe; + notificationConfigId?: Maybe; + notificationType: NotificationTypeNode; + sendAttempts: Scalars['Int']['output']; + sentAt?: Maybe; + status: EventStatus; + title: Scalars['String']['output']; + toAddress: Scalars['String']['output']; + updatedAt: Scalars['DateTime']['output']; +}; + +export enum NotificationEventSortFieldInput { + CreatedAt = 'createdAt', + Title = 'title' +} + +export type NotificationEventSortInput = { + /** + * Sort query result is sorted descending or ascending (if not provided the default is + * ascending) + */ + desc?: InputMaybe; + /** Sort query result by `key` */ + key: NotificationEventSortFieldInput; +}; + +export type NotificationEventsResponse = NotificationEventConnector; + export type NotificationQueriesResponse = NotificationQueryConnector; export type NotificationQueryConnector = { diff --git a/frontend/packages/common/src/ui/components/display/RelativeTimeDate.tsx b/frontend/packages/common/src/ui/components/display/RelativeTimeDate.tsx new file mode 100644 index 00000000..e8320d94 --- /dev/null +++ b/frontend/packages/common/src/ui/components/display/RelativeTimeDate.tsx @@ -0,0 +1,25 @@ +import React, { FC } from 'react'; +import { useFormatDateTime } from '@common/intl/utils/DateUtils'; + +export interface RelativeTimeDateProps { + d: string | number | Date | null | undefined; +} + +export const RelativeTimeDate: FC = ({ d }) => { + const { dayMonthYearHourMinute, localisedDistanceToNow } = + useFormatDateTime(); + + if (!d) { + return null; + } + + if (typeof d === 'string') { + d = new Date(d); + } + + return ( + <> + {dayMonthYearHourMinute(d)} ({localisedDistanceToNow(d)} ago) + + ); +}; diff --git a/frontend/packages/common/src/ui/components/display/index.ts b/frontend/packages/common/src/ui/components/display/index.ts new file mode 100644 index 00000000..76b39367 --- /dev/null +++ b/frontend/packages/common/src/ui/components/display/index.ts @@ -0,0 +1 @@ +export * from './RelativeTimeDate'; diff --git a/frontend/packages/common/src/ui/components/index.ts b/frontend/packages/common/src/ui/components/index.ts index a8e4fa4b..7672a95c 100644 --- a/frontend/packages/common/src/ui/components/index.ts +++ b/frontend/packages/common/src/ui/components/index.ts @@ -34,6 +34,7 @@ export * from './charts'; export * from './steppers'; export * from './text'; export * from './toolbars'; +export * from './display'; export { Accordion, diff --git a/frontend/packages/config/src/routes.ts b/frontend/packages/config/src/routes.ts index c86b330c..5062ad8f 100644 --- a/frontend/packages/config/src/routes.ts +++ b/frontend/packages/config/src/routes.ts @@ -7,6 +7,7 @@ export enum AppRoute { UserAccounts = 'users', Notifications = 'notifications', + NotificationEvents = 'notification-events', ColdChain = 'cold-chain', Scheduled = 'scheduled', diff --git a/frontend/packages/host/src/Site.tsx b/frontend/packages/host/src/Site.tsx index de69d6a4..a3e0adee 100644 --- a/frontend/packages/host/src/Site.tsx +++ b/frontend/packages/host/src/Site.tsx @@ -23,6 +23,7 @@ import { RequireAuthentication } from './components/Navigation/RequireAuthentica import { QueryErrorHandler } from './QueryErrorHandler'; import { RecipientsRouter } from './routers/RecipientsRouter'; import { NotificationsRouter } from './routers/NotificationsRouter'; +import { NotificationEventsRouter } from './routers/NotificationEventsRouter'; import { QueriesRouter } from './routers/QueryRouter'; export const Site: FC = () => { @@ -67,6 +68,12 @@ export const Site: FC = () => { .build()} element={} /> + } + /> { icon={} text={t('notifications')} /> + } + text={t('notification-events')} + /> } diff --git a/frontend/packages/host/src/routers/NotificationEventsRouter.tsx b/frontend/packages/host/src/routers/NotificationEventsRouter.tsx new file mode 100644 index 00000000..e4424c23 --- /dev/null +++ b/frontend/packages/host/src/routers/NotificationEventsRouter.tsx @@ -0,0 +1,22 @@ +import React, { FC } from 'react'; +import { RouteBuilder, Navigate, useMatch } from '@notify-frontend/common'; +import { AppRoute } from '@notify-frontend/config'; + +const NotificationEventService = React.lazy( + () => import('@notify-frontend/system/src/NotificationEvents/Service/Service') +); + +const fullNotificationsPath = RouteBuilder.create(AppRoute.NotificationEvents) + .addWildCard() + .build(); + +export const NotificationEventsRouter: FC = () => { + const goToNotifications = useMatch(fullNotificationsPath); + + if (goToNotifications) { + return ; + } + + const notFoundRoute = RouteBuilder.create(AppRoute.PageNotFound).build(); + return ; +}; diff --git a/frontend/packages/system/src/NotificationEvents/DetailView/DetailView.tsx b/frontend/packages/system/src/NotificationEvents/DetailView/DetailView.tsx new file mode 100644 index 00000000..4ee860ef --- /dev/null +++ b/frontend/packages/system/src/NotificationEvents/DetailView/DetailView.tsx @@ -0,0 +1,152 @@ +import React, { useEffect } from 'react'; +import { useBreadcrumbs, useQueryParamsState } from '@common/hooks'; +import { + AppBarButtonsPortal, + AppBarContentPortal, + BasicSpinner, + Box, + EditIcon, + BaseButton, + RelativeTimeDate, + Stack, + TextArea, + Typography, + Tooltip, +} from '@common/ui'; +import { useTranslation } from '@common/intl'; +import { useNotificationEvents } from '../api'; +import { + ConfigKind, + EventStatus, + useNavigate, + useParams, +} from 'packages/common/src'; +import { configRoute } from '../../Notifications/navigate'; +import { NotificationStatusChip } from '../components/NotificationStatusChip'; + +export const DetailView = () => { + const t = useTranslation('system'); + const urlParams = useParams(); + const { suffix, setSuffix } = useBreadcrumbs(); + const navigate = useNavigate(); + + const { queryParams } = useQueryParamsState({ + initialFilter: { id: { equalTo: urlParams['id'] } }, + }); + + const { data, isLoading } = useNotificationEvents(queryParams); + const entity = data?.nodes[0]; + + useEffect(() => { + const listName = entity?.title; + if (!suffix && listName) { + setSuffix(listName); + } + }, [suffix, entity]); + + return ( + <> + + {/* if we have a config_id, create a link to edit the config */} + {entity?.notificationConfigId && ( + + { + navigate( + configRoute( + entity.notificationConfig?.kind ?? ConfigKind.Scheduled, + entity.notificationConfigId ?? '' + ) + ); + }} + variant="outlined" + endIcon={} + > + {t('button.edit-notification-config')} + + + )} + + + + + + + + + + + {entity?.errorMessage ? ( +