diff --git a/cronback-api-model/src/admin/mod.rs b/cronback-api-model/src/admin/mod.rs index fbdcb71..1c68099 100644 --- a/cronback-api-model/src/admin/mod.rs +++ b/cronback-api-model/src/admin/mod.rs @@ -1,5 +1,7 @@ mod api_keys; +mod notifications; mod projects; pub use api_keys::*; +pub use notifications::*; pub use projects::*; diff --git a/cronback-api-model/src/admin/notifications.rs b/cronback-api-model/src/admin/notifications.rs new file mode 100644 index 0000000..9e6b780 --- /dev/null +++ b/cronback-api-model/src/admin/notifications.rs @@ -0,0 +1,240 @@ +use std::collections::HashMap; + +#[cfg(feature = "dto")] +use dto::{FromProto, IntoProto}; +use monostate::MustBe; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "validation")] +use validator::Validate; + +#[cfg(feature = "validation")] +use crate::validation_util::validation_error; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr( + feature = "dto", + derive(IntoProto, FromProto), + proto(target = "proto::notifications::ProjectNotificationSettings") +)] +#[cfg_attr( + feature = "validation", + derive(Validate), + validate(schema( + function = "validate_settings", + skip_on_field_errors = false + )) +)] +#[serde(deny_unknown_fields)] +pub struct NotificationSettings { + #[cfg_attr(feature = "validation", validate)] + pub default_subscriptions: Vec, + // The key of the hashmap is the channel name + #[cfg_attr(feature = "validation", validate)] + pub channels: HashMap, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr( + feature = "dto", + derive(IntoProto, FromProto), + proto(target = "proto::notifications::NotificationSubscription") +)] +#[cfg_attr(feature = "validation", derive(Validate))] +#[serde(deny_unknown_fields)] +pub struct NotificationSubscription { + #[cfg_attr(feature = "validation", validate(length(max = 20)))] + pub channel_names: Vec, + #[cfg_attr(feature = "dto", proto(required))] + #[cfg_attr(feature = "validation", validate)] + pub event: NotificationEvent, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr( + feature = "dto", + derive(IntoProto, FromProto), + proto( + target = "proto::notifications::NotificationChannel", + oneof = "channel" + ) +)] +#[serde(rename_all = "snake_case")] +#[serde(untagged)] +pub enum NotificationChannel { + Email(EmailNotification), +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr( + feature = "dto", + derive(IntoProto, FromProto), + proto(target = "proto::notifications::NotificationEvent", oneof = "event") +)] +#[serde(rename_all = "snake_case")] +#[serde(untagged)] +pub enum NotificationEvent { + OnRunFailure(OnRunFailure), +} + +// Channel configs + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr( + feature = "dto", + derive(IntoProto, FromProto), + proto(target = "proto::notifications::Email") +)] +#[serde(deny_unknown_fields)] +#[cfg_attr(feature = "validation", derive(Validate))] +pub struct EmailNotification { + #[serde(rename = "type")] + _kind: MustBe!("email"), + #[cfg_attr(feature = "validation", validate(email))] + pub address: String, + pub verified: bool, +} + +// Subscription configs + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr( + feature = "dto", + derive(IntoProto, FromProto), + proto(target = "proto::notifications::OnRunFailure") +)] +#[cfg_attr(feature = "validation", derive(Validate))] +#[serde(deny_unknown_fields)] +pub struct OnRunFailure { + #[serde(rename = "type")] + _kind: MustBe!("on_run_failure"), +} + +#[cfg(feature = "validation")] +impl Validate for NotificationEvent { + fn validate(&self) -> Result<(), validator::ValidationErrors> { + match self { + | NotificationEvent::OnRunFailure(o) => o.validate(), + } + } +} + +#[cfg(feature = "validation")] +impl Validate for NotificationChannel { + fn validate(&self) -> Result<(), validator::ValidationErrors> { + match self { + | NotificationChannel::Email(e) => e.validate(), + } + } +} + +#[cfg(feature = "validation")] +fn validate_settings( + settings: &NotificationSettings, +) -> Result<(), validator::ValidationError> { + // Validate that any channel referenced in a subscription actually exists. + + for sub in &settings.default_subscriptions { + for channel in &sub.channel_names { + if !settings.channels.contains_key(channel) { + return Err(validation_error( + "invalid_channel_name", + format!( + "Channel name '{}' is not configured in channel list", + channel + ), + )); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + + #[test] + fn test_valid_settings() -> anyhow::Result<()> { + let email = EmailNotification { + _kind: Default::default(), + address: "test@gmail.com".to_string(), + verified: true, + }; + let mut channels = HashMap::new(); + channels.insert("email".to_string(), NotificationChannel::Email(email)); + let setting = NotificationSettings { + channels, + default_subscriptions: vec![NotificationSubscription { + channel_names: vec!["email".to_string()], + event: NotificationEvent::OnRunFailure(OnRunFailure { + _kind: Default::default(), + }), + }], + }; + + setting.validate()?; + Ok(()) + } + + #[test] + fn test_invalid_email() -> anyhow::Result<()> { + let email = EmailNotification { + _kind: Default::default(), + address: "wrong_email".to_string(), + verified: false, + }; + let mut channels = HashMap::new(); + channels.insert("email".to_string(), NotificationChannel::Email(email)); + let setting = NotificationSettings { + channels, + default_subscriptions: vec![], + }; + + let validated = setting.validate(); + + assert!(validated.is_err()); + assert_eq!( + validated.unwrap_err().to_string(), + "channels[0].address: Validation error: email [{\"value\": \ + String(\"wrong_email\")}]" + .to_string() + ); + + Ok(()) + } + + #[test] + fn test_invalid_channel() { + let email = EmailNotification { + _kind: Default::default(), + address: "test@gmail.com".to_string(), + verified: false, + }; + let mut channels = HashMap::new(); + channels.insert("email".to_string(), NotificationChannel::Email(email)); + let setting = NotificationSettings { + channels, + default_subscriptions: vec![NotificationSubscription { + channel_names: vec![ + "email".to_string(), + "wrong_channel".to_string(), + ], + event: NotificationEvent::OnRunFailure(OnRunFailure { + _kind: Default::default(), + }), + }], + }; + + let validated = setting.validate(); + + assert!(validated.is_err()); + assert_eq!( + validated.unwrap_err().to_string(), + "__all__: Channel name 'wrong_channel' is not configured in \ + channel list" + .to_string() + ); + } +} diff --git a/cronback-dto-core/src/struct_codegen.rs b/cronback-dto-core/src/struct_codegen.rs index 5fe4935..51993e2 100644 --- a/cronback-dto-core/src/struct_codegen.rs +++ b/cronback-dto-core/src/struct_codegen.rs @@ -12,6 +12,7 @@ use crate::attributes::{ }; use crate::utils::{ extract_inner_type_from_container, + map_segment, option_segment, vec_segment, }; @@ -59,6 +60,12 @@ impl ProtoFieldInfo { // - IntoProto + required: .into() should handle it. // - FromProto + required: our_name: incoming.unwrap() // + // - HashMap: + // - Protobuf's map keys only support scaler types, we only need to map + // the values + // - IntoProto + required: .into() should handle it. + // - FromProto + required: our_name: incoming.unwrap() + // // - always add .into() after mapping. // Primary cases we need to take care of: @@ -84,6 +91,7 @@ impl ProtoFieldInfo { let option_type = extract_inner_type_from_container(&self.ty, option_segment); let vec_type = extract_inner_type_from_container(&self.ty, vec_segment); + let map_type = extract_inner_type_from_container(&self.ty, map_segment); // 1. Do we need to unwrap the input before processing? We do that if // the field is `required` and our local type is not `Option` when @@ -163,6 +171,25 @@ impl ProtoFieldInfo { rhs_value_tok = quote_spanned! { span => #rhs_value_tok.into_iter().map(#mapper).collect::<::std::vec::Vec<_>>() }; + } else if let Some(_inner_ty) = map_type { + // A HashMap + let mapper = self + .wrap_with_mapper(direction, quote! { v }) + .map(|mapper| { + quote_spanned! { span => + |(k, v)| (k, #mapper) + } + }) + // If there is no mapper, we just map the inner value with any + // existing Into impl. + .unwrap_or_else(|| { + quote_spanned! { span => + |(k, v)| (k, v.into()) + } + }); + rhs_value_tok = quote_spanned! { span => + #rhs_value_tok.into_iter().map(#mapper).collect::<::std::collections::HashMap<_, _>>() + }; } else { // Bare type rhs_value_tok = self diff --git a/cronback-dto-core/src/utils.rs b/cronback-dto-core/src/utils.rs index 834ba28..a62346c 100644 --- a/cronback-dto-core/src/utils.rs +++ b/cronback-dto-core/src/utils.rs @@ -29,6 +29,11 @@ pub(crate) fn vec_segment(path: &syn::Path) -> Option<&syn::PathSegment> { extract_generic_type_segment(path, VECTOR) } +pub(crate) fn map_segment(path: &syn::Path) -> Option<&syn::PathSegment> { + static MAP: &[&str] = &["HashMap|", "std|collections|HashMap|"]; + extract_generic_type_segment(path, MAP) +} + fn extract_type_path(ty: &syn::Type) -> Option<&syn::Path> { match *ty { | syn::Type::Path(ref typepath) if typepath.qself.is_none() => { diff --git a/cronback-proto/build.rs b/cronback-proto/build.rs index 14d0ca2..b452457 100644 --- a/cronback-proto/build.rs +++ b/cronback-proto/build.rs @@ -22,6 +22,7 @@ fn main() -> Result<(), Box> { "./runs.proto", "./scheduler_svc.proto", "./triggers.proto", + "./notifications.proto", ], &["../proto"], )?; @@ -36,6 +37,7 @@ fn main() -> Result<(), Box> { ".events", ".projects", ".triggers", + ".notifications", ])?; Ok(()) diff --git a/cronback-proto/lib.rs b/cronback-proto/lib.rs index 5d31061..37dd567 100644 --- a/cronback-proto/lib.rs +++ b/cronback-proto/lib.rs @@ -80,5 +80,9 @@ pub mod projects { include!(concat!(env!("OUT_DIR"), "/projects.serde.rs")); } +pub mod notifications { + tonic::include_proto!("notifications"); +} + pub const FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_set!("file_descriptor"); diff --git a/cronback-proto/metadata_svc.proto b/cronback-proto/metadata_svc.proto index 08bc709..ca92a3c 100644 --- a/cronback-proto/metadata_svc.proto +++ b/cronback-proto/metadata_svc.proto @@ -2,6 +2,7 @@ syntax = "proto3"; import "common.proto"; import "projects.proto"; +import "notifications.proto"; package metadata_svc; @@ -10,6 +11,8 @@ service MetadataSvc { rpc GetProjectStatus(GetProjectStatusRequest) returns (GetProjectStatusResponse); rpc SetProjectStatus(SetProjectStatusRequest) returns (SetProjectStatusResponse); rpc ProjectExists(ProjectExistsRequest) returns (ProjectExistsResponse); + rpc GetNotificationSettings(GetNotificationSettingsRequest) returns (GetNotificationSettingsResponse); + rpc SetNotificationSettings(SetNotificationSettingsRequest) returns (SetNotificationSettingsResponse); } message CreateProjectRequest { @@ -45,3 +48,21 @@ message ProjectExistsResponse { bool exists = 1; } +message GetNotificationSettingsRequest { + common.ProjectId id = 1; +} + +message GetNotificationSettingsResponse { + notifications.ProjectNotificationSettings settings = 1; +} + + +message SetNotificationSettingsRequest { + common.ProjectId id = 1; + notifications.ProjectNotificationSettings settings = 2; +} + +message SetNotificationSettingsResponse { + notifications.ProjectNotificationSettings old_settings = 1; +} + diff --git a/cronback-proto/notifications.proto b/cronback-proto/notifications.proto new file mode 100644 index 0000000..d228f28 --- /dev/null +++ b/cronback-proto/notifications.proto @@ -0,0 +1,51 @@ +syntax = "proto3"; + +package notifications; + +// This struct represents the notification settings of a single project. +// Every project has different kind of notification channels (e.g. email, slack, etc). +// A project then subscribes to certain events, and specify which channels should the +// notification be sent on if the event fires. +message ProjectNotificationSettings { + repeated NotificationSubscription default_subscriptions = 1; + + // The list of configured channels. The map key is the channel name and the value + // is the channel configuration. + map channels = 2; +} + +message NotificationSubscription { + // The list of channel names to send notifications to. Items in this list must + // refer to channels configured in this project. + repeated string channel_names = 1; + + // The event type that this subscription will fire on. + NotificationEvent event = 2; +} + +message NotificationChannel { + oneof channel { + Email email = 1; + } +} + +message NotificationEvent { + oneof event { + OnRunFailure on_run_failure = 1; + } +} + +//////////////// Channels + +// Sends an email to the address specified if and only if its a verified address. +message Email { + string address = 1; + bool verified = 2; +} + + +//////////////// Events + +// Trigger the subscription if a run in this project fails. +message OnRunFailure { +} \ No newline at end of file diff --git a/cronback-services/src/api/handlers/admin/mod.rs b/cronback-services/src/api/handlers/admin/mod.rs index 1bc59ba..debcd8f 100644 --- a/cronback-services/src/api/handlers/admin/mod.rs +++ b/cronback-services/src/api/handlers/admin/mod.rs @@ -25,6 +25,14 @@ pub(crate) fn routes(shared_state: Arc) -> Router { .route("/", axum::routing::post(projects::create)) .route("/:id/disable", axum::routing::post(projects::disable)) .route("/:id/enable", axum::routing::post(projects::enable)) + .route( + "/:id/notification_settings", + axum::routing::post(projects::set_notification_settings), + ) + .route( + "/:id/notification_settings", + axum::routing::get(projects::get_notification_settings), + ) .with_state(Arc::clone(&shared_state)) .route_layer(middleware::from_fn(ensure_admin)), ) diff --git a/cronback-services/src/api/handlers/admin/projects.rs b/cronback-services/src/api/handlers/admin/projects.rs index 9c60742..3815a97 100644 --- a/cronback-services/src/api/handlers/admin/projects.rs +++ b/cronback-services/src/api/handlers/admin/projects.rs @@ -3,13 +3,22 @@ use std::sync::Arc; use axum::extract::{Path, State}; use axum::response::IntoResponse; use axum::{Extension, Json}; -use cronback_api_model::admin::CreateProjectResponse as CreateProjectHttpResponse; +use cronback_api_model::admin::{ + CreateProjectResponse as CreateProjectHttpResponse, + NotificationSettings, +}; use hyper::StatusCode; use lib::prelude::*; -use proto::metadata_svc::{CreateProjectRequest, SetProjectStatusRequest}; +use proto::metadata_svc::{ + CreateProjectRequest, + GetNotificationSettingsRequest, + SetNotificationSettingsRequest, + SetProjectStatusRequest, +}; use proto::projects::ProjectStatus; use crate::api::errors::ApiError; +use crate::api::extractors::ValidatedJson; use crate::api::AppState; #[tracing::instrument(skip(state))] @@ -22,12 +31,12 @@ pub(crate) async fn create( .metadata_svc_clients .get_client(&request_id, &id) .await?; - let (_, resp, _) = metadata + let resp = metadata .create_project(CreateProjectRequest { id: Some(id.clone().into()), }) .await? - .into_parts(); + .into_inner(); let response = CreateProjectHttpResponse { id: resp.id.unwrap().value, }; @@ -86,3 +95,53 @@ pub(crate) async fn disable( ) .await } + +#[tracing::instrument(skip(state))] +pub(crate) async fn get_notification_settings( + state: State>, + Path(project_id_str): Path, + Extension(request_id): Extension, +) -> Result { + let project_id = ProjectId::from(project_id_str.clone()) + .validated() + .map_err(move |_| ApiError::NotFound(project_id_str))?; + + let mut metadata = state + .metadata_svc_clients + .get_client(&request_id, &project_id) + .await?; + let resp = metadata + .get_notification_settings(GetNotificationSettingsRequest { + id: Some(project_id.into()), + }) + .await? + .into_inner(); + + let settings: NotificationSettings = resp.settings.unwrap().into(); + + Ok(Json(settings)) +} + +#[tracing::instrument(skip(state))] +pub(crate) async fn set_notification_settings( + state: State>, + Path(project_id_str): Path, + Extension(request_id): Extension, + ValidatedJson(settings): ValidatedJson, +) -> Result { + let project_id = ProjectId::from(project_id_str.clone()) + .validated() + .map_err(move |_| ApiError::NotFound(project_id_str))?; + + let mut metadata = state + .metadata_svc_clients + .get_client(&request_id, &project_id) + .await?; + metadata + .set_notification_settings(SetNotificationSettingsRequest { + id: Some(project_id.into()), + settings: Some(settings.into()), + }) + .await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/cronback-services/src/metadata/db_model/mod.rs b/cronback-services/src/metadata/db_model/mod.rs index fff998f..e6813fe 100644 --- a/cronback-services/src/metadata/db_model/mod.rs +++ b/cronback-services/src/metadata/db_model/mod.rs @@ -1,3 +1,4 @@ +pub mod notifications; pub mod projects; pub use projects::{Entity as Projects, Model as Project, ProjectStatus}; diff --git a/cronback-services/src/metadata/db_model/notifications.rs b/cronback-services/src/metadata/db_model/notifications.rs new file mode 100644 index 0000000..e13d9de --- /dev/null +++ b/cronback-services/src/metadata/db_model/notifications.rs @@ -0,0 +1,71 @@ +use std::collections::HashMap; + +use dto::{FromProto, IntoProto}; +use sea_orm::FromJsonQueryResult; +use serde::{Deserialize, Serialize}; + +#[derive( + Clone, + Default, + Debug, + Serialize, + Deserialize, + PartialEq, + Eq, + FromJsonQueryResult, + FromProto, + IntoProto, +)] +#[proto(target = "proto::notifications::ProjectNotificationSettings")] +pub struct NotificationSettings { + pub default_subscriptions: Vec, + pub channels: HashMap, +} + +#[derive( + Clone, Debug, Serialize, Deserialize, PartialEq, Eq, FromProto, IntoProto, +)] +#[proto(target = "proto::notifications::NotificationSubscription")] +pub struct NotificationSubscription { + pub channel_names: Vec, + #[proto(required)] + pub event: NotificationEvent, +} + +#[derive( + Clone, Debug, Serialize, Deserialize, PartialEq, Eq, FromProto, IntoProto, +)] +#[proto( + target = "proto::notifications::NotificationChannel", + oneof = "channel" +)] +pub enum NotificationChannel { + Email(EmailNotification), +} + +#[derive( + Clone, Debug, Serialize, Deserialize, PartialEq, Eq, FromProto, IntoProto, +)] +#[proto(target = "proto::notifications::NotificationEvent", oneof = "event")] +pub enum NotificationEvent { + OnRunFailure(OnRunFailure), +} + +// Channel configs + +#[derive( + Clone, Debug, Serialize, Deserialize, PartialEq, Eq, FromProto, IntoProto, +)] +#[proto(target = "proto::notifications::Email")] +pub struct EmailNotification { + pub address: String, + pub verified: bool, +} + +// Subscription configs + +#[derive( + Clone, Debug, Serialize, Deserialize, PartialEq, Eq, FromProto, IntoProto, +)] +#[proto(target = "proto::notifications::OnRunFailure")] +pub struct OnRunFailure {} diff --git a/cronback-services/src/metadata/db_model/projects.rs b/cronback-services/src/metadata/db_model/projects.rs index dc42776..5758bf2 100644 --- a/cronback-services/src/metadata/db_model/projects.rs +++ b/cronback-services/src/metadata/db_model/projects.rs @@ -5,6 +5,8 @@ use dto::{FromProto, IntoProto}; use lib::prelude::*; use sea_orm::entity::prelude::*; +use super::notifications::NotificationSettings; + #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(table_name = "projects")] pub struct Model { @@ -13,6 +15,7 @@ pub struct Model { pub created_at: DateTime, pub changed_at: DateTime, pub status: ProjectStatus, + pub notification_settings: NotificationSettings, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/cronback-services/src/metadata/handler.rs b/cronback-services/src/metadata/handler.rs index c7b2885..4bde0e4 100644 --- a/cronback-services/src/metadata/handler.rs +++ b/cronback-services/src/metadata/handler.rs @@ -5,10 +5,14 @@ use proto::metadata_svc::metadata_svc_server::MetadataSvc; use proto::metadata_svc::{ CreateProjectRequest, CreateProjectResponse, + GetNotificationSettingsRequest, + GetNotificationSettingsResponse, GetProjectStatusRequest, GetProjectStatusResponse, ProjectExistsRequest, ProjectExistsResponse, + SetNotificationSettingsRequest, + SetNotificationSettingsResponse, SetProjectStatusRequest, SetProjectStatusResponse, }; @@ -43,7 +47,7 @@ impl MetadataSvc for MetadataSvcHandler { &self, request: Request, ) -> Result, Status> { - let (_, _, req) = request.into_parts(); + let req = request.into_inner(); let id: ProjectId = req.id.unwrap().into(); let id = id .validated() @@ -53,6 +57,7 @@ impl MetadataSvc for MetadataSvcHandler { created_at: Utc::now(), changed_at: Utc::now(), status: ProjectStatus::Enabled, + notification_settings: Default::default(), }; self.project_store @@ -71,7 +76,7 @@ impl MetadataSvc for MetadataSvcHandler { &self, request: Request, ) -> Result, Status> { - let (_, _, req) = request.into_parts(); + let req = request.into_inner(); let project_id: ProjectId = req.id.unwrap().into(); let project_id = project_id .validated() @@ -100,7 +105,7 @@ impl MetadataSvc for MetadataSvcHandler { &self, request: Request, ) -> Result, Status> { - let (_, _, req) = request.into_parts(); + let req = request.into_inner(); let project_id: ProjectId = req.id.unwrap().into(); let project_id = project_id .validated() @@ -139,11 +144,76 @@ impl MetadataSvc for MetadataSvcHandler { })) } + async fn get_notification_settings( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let project_id: ProjectId = req.id.unwrap().into(); + let project_id = project_id + .validated() + .map_err(|e| Status::invalid_argument(e.to_string()))?; + let status = self + .project_store + .get_notification_settings(&project_id) + .await + .map_err(ProjectStoreHandlerError::Store)?; + match status { + | Some(st) => { + Ok(Response::new(GetNotificationSettingsResponse { + settings: Some(st.into()), + })) + } + | None => { + Err(ProjectStoreHandlerError::NotFound(format!( + "{}", + project_id + )))? + } + } + } + + async fn set_notification_settings( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let project_id: ProjectId = req.id.unwrap().into(); + let project_id = project_id + .validated() + .map_err(|e| Status::invalid_argument(e.to_string()))?; + + let old_settings = self + .project_store + .get_notification_settings(&project_id) + .await + .map_err(ProjectStoreHandlerError::Store)?; + + let Some(old_settings) = old_settings else { + return Err(ProjectStoreHandlerError::NotFound( + project_id.to_string(), + ) + .into()); + }; + + self.project_store + .set_notification_settings( + &project_id, + req.settings.unwrap().into(), + ) + .await + .map_err(ProjectStoreHandlerError::Store)?; + + Ok(Response::new(SetNotificationSettingsResponse { + old_settings: Some(old_settings.into()), + })) + } + async fn project_exists( &self, request: Request, ) -> Result, Status> { - let (_, _, req) = request.into_parts(); + let req = request.into_inner(); let project_id: ProjectId = req.id.unwrap().into(); let project_id = project_id .validated() diff --git a/cronback-services/src/metadata/metadata_store.rs b/cronback-services/src/metadata/metadata_store.rs index 951d0b5..fb43d40 100644 --- a/cronback-services/src/metadata/metadata_store.rs +++ b/cronback-services/src/metadata/metadata_store.rs @@ -2,6 +2,7 @@ use chrono::Utc; use lib::prelude::*; use sea_orm::{ActiveModelTrait, EntityTrait, Set}; +use super::db_model::notifications::NotificationSettings; use super::db_model::{projects, Project, ProjectStatus, Projects}; pub type MetadataStoreError = DatabaseError; @@ -51,6 +52,32 @@ impl MetadataStore { .map(|p| p.status)) } + pub async fn set_notification_settings( + &self, + id: &ValidShardedId, + settings: NotificationSettings, + ) -> Result<(), MetadataStoreError> { + let active_model = projects::ActiveModel { + id: Set(id.clone()), + notification_settings: Set(settings), + changed_at: Set(Utc::now()), + ..Default::default() + }; + + active_model.update(&self.db.orm).await?; + Ok(()) + } + + pub async fn get_notification_settings( + &self, + id: &ValidShardedId, + ) -> Result, MetadataStoreError> { + Ok(Projects::find_by_id(id.clone()) + .one(&self.db.orm) + .await? + .map(|p| p.notification_settings)) + } + pub async fn exists( &self, id: &ValidShardedId, @@ -62,7 +89,16 @@ impl MetadataStore { #[cfg(test)] mod tests { + use std::collections::HashMap; + use super::*; + use crate::metadata::db_model::notifications::{ + EmailNotification, + NotificationChannel, + NotificationEvent, + NotificationSubscription, + OnRunFailure, + }; use crate::metadata::MetadataService; fn build_project(status: ProjectStatus) -> Project { @@ -73,6 +109,7 @@ mod tests { created_at: now, changed_at: now, status, + notification_settings: Default::default(), } } @@ -124,6 +161,30 @@ mod tests { Err(DatabaseError::DB(sea_orm::DbErr::RecordNotUpdated)) )); + // Test notification setters / getters + { + let email = EmailNotification { + address: "test@gmail.com".to_string(), + verified: true, + }; + let mut channels = HashMap::new(); + channels + .insert("email".to_string(), NotificationChannel::Email(email)); + let setting = NotificationSettings { + channels, + default_subscriptions: vec![NotificationSubscription { + channel_names: vec!["email".to_string()], + event: NotificationEvent::OnRunFailure(OnRunFailure {}), + }], + }; + store + .set_notification_settings(&project2.id, setting.clone()) + .await?; + + let found = store.get_notification_settings(&project2.id).await?; + assert_eq!(found, Some(setting)); + } + Ok(()) } } diff --git a/cronback-services/src/metadata/migration/m20230726_115454_add_notification_settings.rs b/cronback-services/src/metadata/migration/m20230726_115454_add_notification_settings.rs new file mode 100644 index 0000000..86c3753 --- /dev/null +++ b/cronback-services/src/metadata/migration/m20230726_115454_add_notification_settings.rs @@ -0,0 +1,37 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Projects::Table) + .add_column( + ColumnDef::new(Projects::NotificationSettings).json(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Projects::Table) + .drop_column(Projects::NotificationSettings) + .to_owned(), + ) + .await + } +} + +#[derive(Iden)] +enum Projects { + Table, + NotificationSettings, +} diff --git a/cronback-services/src/metadata/migration/mod.rs b/cronback-services/src/metadata/migration/mod.rs index 011bb34..f879c5d 100644 --- a/cronback-services/src/metadata/migration/mod.rs +++ b/cronback-services/src/metadata/migration/mod.rs @@ -1,12 +1,16 @@ pub use sea_orm_migration::prelude::*; mod m20230712_205649_add_projects_model; +mod m20230726_115454_add_notification_settings; pub struct Migrator; #[async_trait::async_trait] impl MigratorTrait for Migrator { fn migrations() -> Vec> { - vec![Box::new(m20230712_205649_add_projects_model::Migration)] + vec![ + Box::new(m20230712_205649_add_projects_model::Migration), + Box::new(m20230726_115454_add_notification_settings::Migration), + ] } } diff --git a/examples/notifications.json b/examples/notifications.json new file mode 100644 index 0000000..9601937 --- /dev/null +++ b/examples/notifications.json @@ -0,0 +1,19 @@ +{ + "channels": { + "email": { + "type": "email", + "address": "test@gmail.com", + "verified": false + } + }, + "subscriptions": [ + { + "channel_names": [ + "email" + ], + "event": { + "type": "on_run_failure" + } + } + ] +} \ No newline at end of file