From 2d1a1ef720e3fa0545e816ef304ea2f9919a712a Mon Sep 17 00:00:00 2001 From: James Brunskill Date: Fri, 3 Nov 2023 11:47:56 +1300 Subject: [PATCH 01/13] Notification Event Service, Repo, Graphql --- backend/Cargo.lock | 20 +++ backend/graphql/Cargo.toml | 1 + backend/graphql/lib.rs | 3 + backend/graphql/notification_event/Cargo.toml | 29 ++++ backend/graphql/notification_event/README.md | 37 +++++ backend/graphql/notification_event/src/lib.rs | 54 +++++++ .../src/types/event_status.rs | 32 +++++ .../notification_event/src/types/inputs.rs | 64 +++++++++ .../notification_event/src/types/mod.rs | 6 + .../src/types/notification_event.rs | 73 ++++++++++ backend/repository/src/db_diesel/mod.rs | 2 + .../src/db_diesel/notification_event.rs | 135 ++++++++++++++++++ backend/service/src/lib.rs | 1 + backend/service/src/notification_event/mod.rs | 34 +++++ .../service/src/notification_event/query.rs | 44 ++++++ .../src/notification_event/tests/mod.rs | 2 + .../src/notification_event/tests/query.rs | 132 +++++++++++++++++ backend/service/src/service_provider.rs | 3 + 18 files changed, 672 insertions(+) create mode 100644 backend/graphql/notification_event/Cargo.toml create mode 100644 backend/graphql/notification_event/README.md create mode 100644 backend/graphql/notification_event/src/lib.rs create mode 100644 backend/graphql/notification_event/src/types/event_status.rs create mode 100644 backend/graphql/notification_event/src/types/inputs.rs create mode 100644 backend/graphql/notification_event/src/types/mod.rs create mode 100644 backend/graphql/notification_event/src/types/notification_event.rs create mode 100644 backend/repository/src/db_diesel/notification_event.rs create mode 100644 backend/service/src/notification_event/mod.rs create mode 100644 backend/service/src/notification_event/query.rs create mode 100644 backend/service/src/notification_event/tests/mod.rs create mode 100644 backend/service/src/notification_event/tests/query.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 55fdec69..4973d9cf 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1623,6 +1623,7 @@ dependencies = [ "graphql_datasource", "graphql_general", "graphql_notification_config", + "graphql_notification_event", "graphql_notification_query", "graphql_recipient", "graphql_recipient_list", @@ -1711,6 +1712,25 @@ 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", + "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/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..dc04eabc --- /dev/null +++ b/backend/graphql/notification_event/Cargo.toml @@ -0,0 +1,29 @@ +[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" + +[dev-dependencies] +actix-rt = "2.6.0" +assert-json-diff = "2.0.1" diff --git a/backend/graphql/notification_event/README.md b/backend/graphql/notification_event/README.md new file mode 100644 index 00000000..0ee7e8bd --- /dev/null +++ b/backend/graphql/notification_event/README.md @@ -0,0 +1,37 @@ +# Notification Event GraphQL API + +This crate provides a GraphQL API for managing notification events. It is built with Rust and uses the Juniper library for GraphQL server implementation. + +## Project Structure + +The project is structured as follows: + +- `src/lib.rs`: Main library file. +- `src/schema.rs`: GraphQL schema definition for the `NotificationEvent` model. +- `src/models/notification_event.rs`: `NotificationEvent` model definition. +- `src/resolvers/mutation.rs`: Mutation resolvers for the GraphQL schema. +- `src/resolvers/query.rs`: Query resolvers for the GraphQL schema. +- `src/resolvers/notification_event.rs`: Resolvers for the `NotificationEvent` type. +- `tests/notification_event.rs`: Tests for the `NotificationEvent` model and its resolvers. + +## Setup + +To set up the project, follow these steps: + +1. Install Rust: Follow the instructions on the [official Rust website](https://www.rust-lang.org/tools/install) to install Rust on your machine. + +2. Clone the repository: Clone this repository to your local machine using `git clone`. + +3. Build the project: Navigate to the project directory and run `cargo build` to build the project. + +4. Run the tests: Run `cargo test` to run the tests and ensure everything is set up correctly. + +## Usage + +To start the GraphQL server, run `cargo run` in the project directory. This will start the server on `localhost:8000`. + +You can then send GraphQL queries and mutations to `localhost:8000/graphql`. + +## Documentation + +For more detailed documentation, see the comments in the source code. Each module, function, and type has a comment explaining what it does and how to use it. \ No newline at end of file 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..58a48493 --- /dev/null +++ b/backend/graphql/notification_event/src/types/inputs.rs @@ -0,0 +1,64 @@ +use async_graphql::{Enum, InputObject}; +use graphql_core::generic_filters::{EqualFilterStringInput, StringFilterInput}; +use repository::{ + EqualFilter, NotificationEventFilter, NotificationEventSort, NotificationEventSortField, +}; + +use super::EventStatus; + +#[derive(Enum, Copy, Clone, PartialEq, Eq)] +#[graphql(rename_items = "camelCase")] +pub enum NotificationEventSortFieldInput { + Title, +} + +#[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, + }; + + NotificationEventSort { + key, + desc: self.desc, + } + } +} + +#[derive(Clone, InputObject)] +pub struct NotificationEventFilterInput { + pub id: Option, + pub title: Option, + pub search: Option, + pub status: Option, +} + +impl From for NotificationEventFilter { + fn from(f: NotificationEventFilterInput) -> Self { + NotificationEventFilter { + id: f.id.map(EqualFilter::from), + // title: f.title.map(StringFilter::from), + search: f.search, + // status: f + // .status + // .map(|t| map_filter!(t, NotificationEventStatus::to_domain)), + } + } +} 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..6a273cfb --- /dev/null +++ b/backend/graphql/notification_event/src/types/notification_event.rs @@ -0,0 +1,73 @@ +use async_graphql::{Object, SimpleObject, Union}; +use graphql_core::simple_generic_errors::NodeError; + +use repository::NotificationEvent; +use service::ListResult; +use util::usize_to_u32; + +#[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 title(&self) -> String { + self.row().title.to_owned().unwrap_or_default() + } +} + +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..a51f0f33 --- /dev/null +++ b/backend/repository/src/db_diesel/notification_event.rs @@ -0,0 +1,135 @@ +use super::{ + notification_event_row::{ + notification_event, notification_event::dsl as notification_event_dsl, + }, + DBType, NotificationEventRow, StorageConnection, +}; +use crate::{ + diesel_macros::{apply_equal_filter, apply_sort_no_case}, + repository_error::RepositoryError, + EqualFilter, 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, +} + +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, +} + +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); + } + } + } 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 } = f; + + apply_equal_filter!(query, id, notification_event_dsl::id); + + 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..bad96578 --- /dev/null +++ b/backend/service/src/notification_event/tests/query.rs @@ -0,0 +1,132 @@ +#[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(); + + 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, From 61b0ccfa86966f66630579820d62f3d7d4b44c98 Mon Sep 17 00:00:00 2001 From: James Brunskill Date: Fri, 3 Nov 2023 12:00:13 +1300 Subject: [PATCH 02/13] Add fields to notification event graphql response --- backend/Cargo.lock | 1 + backend/graphql/notification_event/Cargo.toml | 1 + .../src/types/notification_event.rs | 39 +++++++++++++++++++ .../src/notification_event/tests/query.rs | 2 + 4 files changed, 43 insertions(+) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 4973d9cf..9d78d164 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1722,6 +1722,7 @@ dependencies = [ "async-graphql", "async-graphql-actix-web", "async-trait", + "chrono", "graphql_core", "graphql_types", "repository", diff --git a/backend/graphql/notification_event/Cargo.toml b/backend/graphql/notification_event/Cargo.toml index dc04eabc..f52546af 100644 --- a/backend/graphql/notification_event/Cargo.toml +++ b/backend/graphql/notification_event/Cargo.toml @@ -23,6 +23,7 @@ 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" diff --git a/backend/graphql/notification_event/src/types/notification_event.rs b/backend/graphql/notification_event/src/types/notification_event.rs index 6a273cfb..8f38f384 100644 --- a/backend/graphql/notification_event/src/types/notification_event.rs +++ b/backend/graphql/notification_event/src/types/notification_event.rs @@ -1,10 +1,14 @@ use async_graphql::{Object, SimpleObject, Union}; +use chrono::{DateTime, Utc}; use graphql_core::simple_generic_errors::NodeError; +use graphql_types::types::NotificationTypeNode; use repository::NotificationEvent; use service::ListResult; use util::usize_to_u32; +use super::EventStatus; + #[derive(Union)] pub enum NotificationEventsResponse { Response(NotificationEventConnector), @@ -26,9 +30,44 @@ 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) + } } impl NotificationEventNode { diff --git a/backend/service/src/notification_event/tests/query.rs b/backend/service/src/notification_event/tests/query.rs index bad96578..17ac60b3 100644 --- a/backend/service/src/notification_event/tests/query.rs +++ b/backend/service/src/notification_event/tests/query.rs @@ -123,6 +123,8 @@ mod notification_event_query_test { ) .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); From ea2e1f1601458d9171b51b70abc492ce60870893 Mon Sep 17 00:00:00 2001 From: James Brunskill Date: Fri, 3 Nov 2023 13:22:47 +1300 Subject: [PATCH 03/13] Basic Frontend to View Events --- .../notification_event/src/types/inputs.rs | 2 + .../src/db_diesel/notification_event.rs | 4 + frontend/packages/common/src/types/schema.ts | 66 ++++++++++ frontend/packages/config/src/routes.ts | 1 + frontend/packages/host/src/Site.tsx | 7 ++ .../src/routers/NotificationEventsRouter.tsx | 22 ++++ .../NotificationEvents/ListView/ListView.tsx | 116 ++++++++++++++++++ .../NotificationEvents/Service/Service.tsx | 13 ++ .../src/NotificationEvents/api/hooks/index.ts | 1 + .../api/hooks/useNotificationEvents.ts | 43 +++++++ .../src/NotificationEvents/api/index.ts | 2 + .../api/operations.generated.ts | 57 +++++++++ .../NotificationEvents/api/operations.graphql | 28 +++++ 13 files changed, 362 insertions(+) create mode 100644 frontend/packages/host/src/routers/NotificationEventsRouter.tsx create mode 100644 frontend/packages/system/src/NotificationEvents/ListView/ListView.tsx create mode 100644 frontend/packages/system/src/NotificationEvents/Service/Service.tsx create mode 100644 frontend/packages/system/src/NotificationEvents/api/hooks/index.ts create mode 100644 frontend/packages/system/src/NotificationEvents/api/hooks/useNotificationEvents.ts create mode 100644 frontend/packages/system/src/NotificationEvents/api/index.ts create mode 100644 frontend/packages/system/src/NotificationEvents/api/operations.generated.ts create mode 100644 frontend/packages/system/src/NotificationEvents/api/operations.graphql diff --git a/backend/graphql/notification_event/src/types/inputs.rs b/backend/graphql/notification_event/src/types/inputs.rs index 58a48493..61870602 100644 --- a/backend/graphql/notification_event/src/types/inputs.rs +++ b/backend/graphql/notification_event/src/types/inputs.rs @@ -10,6 +10,7 @@ use super::EventStatus; #[graphql(rename_items = "camelCase")] pub enum NotificationEventSortFieldInput { Title, + CreatedAt, } #[derive(InputObject, Clone)] @@ -33,6 +34,7 @@ impl NotificationEventSortInput { use NotificationEventSortFieldInput as from; let key = match self.key { from::Title => to::Title, + from::CreatedAt => to::CreatedAt, }; NotificationEventSort { diff --git a/backend/repository/src/db_diesel/notification_event.rs b/backend/repository/src/db_diesel/notification_event.rs index a51f0f33..4b0c5906 100644 --- a/backend/repository/src/db_diesel/notification_event.rs +++ b/backend/repository/src/db_diesel/notification_event.rs @@ -40,6 +40,7 @@ impl NotificationEventFilter { pub enum NotificationEventSortField { Title, Id, + CreatedAt, } pub type NotificationEventSort = Sort; @@ -89,6 +90,9 @@ impl<'a> NotificationEventRepository<'a> { 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); + } } } else { query = query.order(notification_event_dsl::id.asc()) diff --git a/frontend/packages/common/src/types/schema.ts b/frontend/packages/common/src/types/schema.ts index f3734673..dcf4a9eb 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; @@ -609,6 +630,51 @@ 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']; + notificationConfigId?: Maybe; + notificationType: NotificationTypeNode; + 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/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={} /> + } + /> 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/ListView/ListView.tsx b/frontend/packages/system/src/NotificationEvents/ListView/ListView.tsx new file mode 100644 index 00000000..2ce9efcc --- /dev/null +++ b/frontend/packages/system/src/NotificationEvents/ListView/ListView.tsx @@ -0,0 +1,116 @@ +import React, { useEffect } from 'react'; +import { useTranslation } from '@common/intl'; +import { + AppBarButtonsPortal, + AppBarContentPortal, + DataTable, + NothingHere, + SearchToolbar, + TableProvider, + Tooltip, + Typography, + createTableStore, + useColumns, +} from '@common/ui'; +import { useQueryParamsState } from '@common/hooks'; +import { NotificationEventRowFragment, useNotificationEvents } from '../api'; + +import { ConfigKind, StringUtils } from '@notify-frontend/common'; + +type ListViewProps = { + kind: ConfigKind | null; +}; + +export const ListView = ({}: ListViewProps) => { + const t = useTranslation('system'); + // const navigate = useNavigate(); // TODO: Navigate to config from row? + + const { filter, queryParams, updatePaginationQuery, updateSortQuery } = + useQueryParamsState({ + initialSort: { + key: 'createdAt', + dir: 'desc', + }, + }); + + const columns = useColumns( + [ + { key: 'title', label: 'label.title' }, + { + key: 'message', + label: 'label.message', + sortable: false, + Cell: props => ( + + + {StringUtils.ellipsis(props.rowData.message, 50)} + + + ), + }, + { key: 'createdAt', label: 'label.date' }, + { + key: 'kind', + label: 'label.kind', + sortable: false, + Cell: props => ( + {props.rowData.notificationType} + ), + }, + { + key: 'status', + label: 'label.status', + sortable: false, + Cell: props => {props.rowData.status}, + }, + { + key: 'errorMessage', + label: 'error', + sortable: false, + Cell: props => ( + + + {StringUtils.ellipsis(props.rowData.errorMessage ?? '', 50)} + + + ), + }, + ], + { sortBy: queryParams.sortBy, onChangeSortBy: updateSortQuery }, + [queryParams.sortBy, updateSortQuery] + ); + + const { data, isError, isLoading } = useNotificationEvents(queryParams); + const notificationEvents = data?.nodes ?? []; + + // const onClick = (entity: NotificationConfigRowFragment) => { + // navigate(configRoute(entity.kind, entity.id)); + // }; + + const pagination = { + page: queryParams.page, + offset: queryParams.offset, + first: queryParams.first, + }; + + return ( + <> + + + + + + } + pagination={pagination} + onChangePage={updatePaginationQuery} + /> + + + ); +}; diff --git a/frontend/packages/system/src/NotificationEvents/Service/Service.tsx b/frontend/packages/system/src/NotificationEvents/Service/Service.tsx new file mode 100644 index 00000000..5494df75 --- /dev/null +++ b/frontend/packages/system/src/NotificationEvents/Service/Service.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { Routes, Route } from '@notify-frontend/common'; +import { ListView } from '../ListView/ListView'; + +const NotificationEventService = () => { + return ( + + } /> + + ); +}; + +export default NotificationEventService; diff --git a/frontend/packages/system/src/NotificationEvents/api/hooks/index.ts b/frontend/packages/system/src/NotificationEvents/api/hooks/index.ts new file mode 100644 index 00000000..949fe660 --- /dev/null +++ b/frontend/packages/system/src/NotificationEvents/api/hooks/index.ts @@ -0,0 +1 @@ +export * from './useNotificationEvents'; diff --git a/frontend/packages/system/src/NotificationEvents/api/hooks/useNotificationEvents.ts b/frontend/packages/system/src/NotificationEvents/api/hooks/useNotificationEvents.ts new file mode 100644 index 00000000..fbfa3ce8 --- /dev/null +++ b/frontend/packages/system/src/NotificationEvents/api/hooks/useNotificationEvents.ts @@ -0,0 +1,43 @@ +import { + FilterBy, + NotificationEventSortFieldInput, + SortBy, + useGql, + useQuery, +} from '@notify-frontend/common'; +import { NotificationEventRowFragment, getSdk } from '../operations.generated'; +import { NOTIFICATION_CONFIGS } from '../../../cacheKeys'; + +export const useNotificationEvents = ({ + filterBy, + sortBy, + first, + offset, +}: { + filterBy?: FilterBy | null; + sortBy?: SortBy; + first?: number; + offset?: number; +} = {}) => { + const { client } = useGql(); + const sdk = getSdk(client); + + const cacheKeys = [NOTIFICATION_CONFIGS, first, offset, filterBy, sortBy]; + + return useQuery(cacheKeys, async () => { + const response = await sdk.NotificationEvents({ + filter: filterBy, + sort: sortBy?.key + ? { + desc: sortBy.isDesc ?? false, + key: sortBy.key as NotificationEventSortFieldInput, + } + : undefined, + page: { + first, + offset, + }, + }); + return response?.notificationEvents; + }); +}; diff --git a/frontend/packages/system/src/NotificationEvents/api/index.ts b/frontend/packages/system/src/NotificationEvents/api/index.ts new file mode 100644 index 00000000..3965d812 --- /dev/null +++ b/frontend/packages/system/src/NotificationEvents/api/index.ts @@ -0,0 +1,2 @@ +export * from './hooks'; +export { NotificationEventRowFragment } from './operations.generated'; diff --git a/frontend/packages/system/src/NotificationEvents/api/operations.generated.ts b/frontend/packages/system/src/NotificationEvents/api/operations.generated.ts new file mode 100644 index 00000000..7230b101 --- /dev/null +++ b/frontend/packages/system/src/NotificationEvents/api/operations.generated.ts @@ -0,0 +1,57 @@ +import * as Types from '@notify-frontend/common'; + +import { GraphQLClient } from 'graphql-request'; +import * as Dom from 'graphql-request/dist/types.dom'; +import gql from 'graphql-tag'; +export type NotificationEventRowFragment = { __typename: 'NotificationEventNode', id: string, title: string, sentAt?: string | null, message: string, errorMessage?: string | null, createdAt: string, status: Types.EventStatus, toAddress: string, updatedAt: string, notificationType: Types.NotificationTypeNode, notificationConfigId?: string | null }; + +export type NotificationEventsQueryVariables = Types.Exact<{ + filter?: Types.InputMaybe; + page?: Types.InputMaybe; + sort?: Types.InputMaybe | Types.NotificationEventSortInput>; +}>; + + +export type NotificationEventsQuery = { __typename: 'FullQuery', notificationEvents: { __typename: 'NotificationEventConnector', totalCount: number, nodes: Array<{ __typename: 'NotificationEventNode', id: string, title: string, sentAt?: string | null, message: string, errorMessage?: string | null, createdAt: string, status: Types.EventStatus, toAddress: string, updatedAt: string, notificationType: Types.NotificationTypeNode, notificationConfigId?: string | null }> } }; + +export const NotificationEventRowFragmentDoc = gql` + fragment NotificationEventRow on NotificationEventNode { + id + title + sentAt + message + errorMessage + createdAt + status + toAddress + updatedAt + notificationType + notificationConfigId +} + `; +export const NotificationEventsDocument = gql` + query NotificationEvents($filter: NotificationEventFilterInput, $page: PaginationInput, $sort: [NotificationEventSortInput!]) { + notificationEvents(filter: $filter, page: $page, sort: $sort) { + ... on NotificationEventConnector { + totalCount + nodes { + ...NotificationEventRow + } + } + } +} + ${NotificationEventRowFragmentDoc}`; + +export type SdkFunctionWrapper = (action: (requestHeaders?:Record) => Promise, operationName: string, operationType?: string) => Promise; + + +const defaultWrapper: SdkFunctionWrapper = (action, _operationName, _operationType) => action(); + +export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = defaultWrapper) { + return { + NotificationEvents(variables?: NotificationEventsQueryVariables, requestHeaders?: Dom.RequestInit["headers"]): Promise { + return withWrapper((wrappedRequestHeaders) => client.request(NotificationEventsDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'NotificationEvents', 'query'); + } + }; +} +export type Sdk = ReturnType; \ No newline at end of file diff --git a/frontend/packages/system/src/NotificationEvents/api/operations.graphql b/frontend/packages/system/src/NotificationEvents/api/operations.graphql new file mode 100644 index 00000000..9f744cee --- /dev/null +++ b/frontend/packages/system/src/NotificationEvents/api/operations.graphql @@ -0,0 +1,28 @@ +fragment NotificationEventRow on NotificationEventNode { + id + title + sentAt + message + errorMessage + createdAt + status + toAddress + updatedAt + notificationType + notificationConfigId +} + +query NotificationEvents( + $filter: NotificationEventFilterInput + $page: PaginationInput + $sort: [NotificationEventSortInput!] +) { + notificationEvents(filter: $filter, page: $page, sort: $sort) { + ... on NotificationEventConnector { + totalCount + nodes { + ...NotificationEventRow + } + } + } +} From ae2987f901fbcaeacb9442b0c7df3d2a939ff447 Mon Sep 17 00:00:00 2001 From: James Brunskill Date: Fri, 3 Nov 2023 17:32:00 +1300 Subject: [PATCH 04/13] Display notification event log --- .../core/src/loader/loader_registry.rs | 13 +++- backend/graphql/core/src/loader/mod.rs | 2 + .../core/src/loader/notification_config.rs | 35 +++++++++ .../src/types/notification_event.rs | 26 ++++++- .../common/src/intl/locales/en/host.json | 3 +- .../common/src/intl/locales/en/system.json | 3 +- frontend/packages/common/src/types/schema.ts | 2 + .../src/components/AppDrawer/AppDrawer.tsx | 6 ++ .../DetailView/DetailView.tsx | 75 +++++++++++++++++++ .../NotificationEvents/ListView/ListView.tsx | 13 ++-- .../NotificationEvents/Service/Service.tsx | 2 + .../api/operations.generated.ts | 8 +- .../NotificationEvents/api/operations.graphql | 4 + 13 files changed, 179 insertions(+), 13 deletions(-) create mode 100644 backend/graphql/core/src/loader/notification_config.rs create mode 100644 frontend/packages/system/src/NotificationEvents/DetailView/DetailView.tsx 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/notification_event/src/types/notification_event.rs b/backend/graphql/notification_event/src/types/notification_event.rs index 8f38f384..02ec8c56 100644 --- a/backend/graphql/notification_event/src/types/notification_event.rs +++ b/backend/graphql/notification_event/src/types/notification_event.rs @@ -1,8 +1,9 @@ -use async_graphql::{Object, SimpleObject, Union}; +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::NotificationTypeNode; +use graphql_types::types::{NotificationConfigNode, NotificationTypeNode}; use repository::NotificationEvent; use service::ListResult; use util::usize_to_u32; @@ -68,6 +69,27 @@ impl NotificationEventNode { 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 { 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 9435b3e6..ea8e91ee 100644 --- a/frontend/packages/common/src/intl/locales/en/system.json +++ b/frontend/packages/common/src/intl/locales/en/system.json @@ -95,5 +95,6 @@ "label.degrees-celsius": "°C", "label.select-queries": "Select Queries", "message.no-queries-selected": "No queries selected", - "label.schedule": "Schedule" + "label.schedule": "Schedule", + "button.edit-config": "Edit Config" } \ 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 dcf4a9eb..c2132fc1 100644 --- a/frontend/packages/common/src/types/schema.ts +++ b/frontend/packages/common/src/types/schema.ts @@ -649,8 +649,10 @@ export type NotificationEventNode = { 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']; diff --git a/frontend/packages/host/src/components/AppDrawer/AppDrawer.tsx b/frontend/packages/host/src/components/AppDrawer/AppDrawer.tsx index b76806fd..46869515 100644 --- a/frontend/packages/host/src/components/AppDrawer/AppDrawer.tsx +++ b/frontend/packages/host/src/components/AppDrawer/AppDrawer.tsx @@ -25,6 +25,7 @@ import { MessagesIcon, PersonSearchIcon, SearchIcon, + ListIcon, } from '@notify-frontend/common'; import { AppRoute, ExternalURL } from '@notify-frontend/config'; import { AppDrawerIcon } from './AppDrawerIcon'; @@ -192,6 +193,11 @@ export const AppDrawer: React.FC = () => { icon={} text={t('notifications')} /> + } + text={t('notification-events')} + /> } 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..8b558b03 --- /dev/null +++ b/frontend/packages/system/src/NotificationEvents/DetailView/DetailView.tsx @@ -0,0 +1,75 @@ +import React, { useEffect } from 'react'; +import { useBreadcrumbs, useQueryParamsState } from '@common/hooks'; +import { + AppBarButtonsPortal, + BasicSpinner, + Box, + EditIcon, + LoadingButton, + TextArea, + Typography, +} from '@common/ui'; +import { useTranslation } from '@common/intl'; +import { useNotificationEvents } from '../api'; +import { ConfigKind, useNavigate, useParams } from 'packages/common/src'; +import { configRoute } from '../../Notifications/navigate'; + +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-config')} + + )} + + {/* Description/Details section */} + + + {isLoading ? ( + + ) : ( + <> + {entity?.title} +