diff --git a/services/reports/src/email/template/mod.rs b/services/reports/src/email/template/mod.rs index ad9825957..adf8e6c90 100644 --- a/services/reports/src/email/template/mod.rs +++ b/services/reports/src/email/template/mod.rs @@ -1,172 +1,209 @@ -use maud::{html, Markup, Render}; +use maud::{html, Markup, PreEscaped, Render}; use tracing::error; -use crate::{config::SERVICE_PUBLIC_URL, report_types::*}; +use crate::{config::SERVICE_PUBLIC_URL, report_types::*, report_utils}; mod html_layout; const MAX_JSON_LINES: usize = 100; pub fn render_email_for_report( report_input: &ReportInput, report_id: &ReportID, user_id: Option<&str>, ) -> String { html_layout::render_page(message_body_for_report( report_input, report_id, user_id, )) } // Examples: // [Android] Error report for User(ID = foo) // Media mission failed for User(ID = foo) // Thread inconsistency report for User(ID = foo) pub fn subject_for_report( report_input: &ReportInput, user_id: Option<&str>, ) -> String { let kind = title_for_report_type(&report_input.report_type); let user = format!("User(ID = {})", user_id.unwrap_or("[unknown]")); let object = if report_input.report_type.is_media_mission() { media_mission_status(report_input) } else { "report" }; let platform_prefix = if report_input.report_type.is_error() { format!("[{}] ", report_input.platform_details.platform) } else { "".into() }; format!("{platform_prefix}{kind} {object} for {user}") } fn message_body_for_report( report: &ReportInput, report_id: &ReportID, user_id: Option<&str>, ) -> Markup { let user = user_id.unwrap_or("N/A"); let platform = &report.platform_details; let time = report .time .unwrap_or_default() .format("%d/%m/%y %H:%M:%S") .to_string(); html! { h2 { (title_for_report_type(&report.report_type)) " report" } p { (intro_text(report, user_id)) } ul { li { "User ID: " b { (user) } } li { "Platform: " (platform) } li { "Time: " b { (time) } } li { "Report ID: " b { (report_id) } } } + + @if report.report_type.is_inconsistency() { + (inconsistency_details(report)) + } (further_actions(report_id, report.report_type.is_error())) (display_contents(report, report_id)) } } fn title_for_report_type(report_type: &ReportType) -> &str { match report_type { ReportType::ErrorReport => "Error", ReportType::ThreadInconsistency => "Thread inconsistency", ReportType::EntryInconsistency => "Entry inconsistency", ReportType::UserInconsistency => "User inconsistency", ReportType::MediaMission => "Media mission", } } fn intro_text(report: &ReportInput, user_id: Option<&str>) -> String { let user = format!("User (ID = {})", user_id.unwrap_or("[unknown]")); match &report.report_type { ReportType::ErrorReport => format!("{user} encountered an error :("), ReportType::ThreadInconsistency | ReportType::EntryInconsistency | ReportType::UserInconsistency => { format!("System detected inconsistency for {user}") } ReportType::MediaMission => { let status = media_mission_status(report); format!("Media mission {status} for {user}") } } } /// returns "success" or "failed" based on media mission status /// falls back to "completed" if couldn't determine fn media_mission_status(report: &ReportInput) -> &str { report .report_content .get("mediaMission") .and_then(|obj| obj["result"]["success"].as_bool()) .map(|success| if success { "success" } else { "failed" }) .unwrap_or("completed") } +fn inconsistency_details(report: &ReportInput) -> Markup { + let (subjects, ids) = match report.report_type { + ReportType::ThreadInconsistency => ( + "Thread IDs", + report_utils::inconsistent_thread_ids(&report.report_content), + ), + ReportType::UserInconsistency => ( + "User IDs", + report_utils::inconsistent_user_ids(&report.report_content), + ), + ReportType::EntryInconsistency => ( + "Entries", + report_utils::inconsistent_entry_ids(&report.report_content), + ), + _ => return html!(), + }; + + let formatted_ids = if ids.is_empty() { + html! { em { "[None found]" } }.into_string() + } else { + ids + .into_iter() + .map(|id| html! { code { (id) } }.into_string()) + .collect::>() + .join(", ") + }; + + html! { + h3 { "Inconsistency details" } + p { (subjects) " that are inconsistent: " (PreEscaped(formatted_ids))} + } +} + fn further_actions( report_id: &ReportID, display_download_link: bool, ) -> Markup { html! { h3 { "Further actions" } ul { li { a href=(report_link(report_id)) { "Open raw report JSON" } } @if display_download_link { li { a href={ (report_link(report_id)) "/redux-devtools.json" } { "Redux Devtools import" } } } } } } fn display_contents(report: &ReportInput, report_id: &ReportID) -> Markup { let pretty = match serde_json::to_string_pretty(&report.report_content) { Ok(string) => string, Err(err) => { error!("Failed to render report JSON: {err}"); return html! { pre { "ERROR: Failed to render JSON" } }; } }; let content: String = pretty .split('\n') .take(MAX_JSON_LINES) .collect::>() .join("\n"); html! { h3 { "Report contents" } em { "The content is truncated to " (MAX_JSON_LINES) " lines. To view more, " a href=(report_link(report_id)) { "open full report" } "." } pre { (content) } } } fn report_link(id: &ReportID) -> String { let base_url = SERVICE_PUBLIC_URL.as_str(); format!("{base_url}/reports/{}", id.as_str()) } impl Render for PlatformDetails { fn render(&self) -> Markup { let code_version = self .code_version .map(|it| it.to_string()) .unwrap_or("N/A".into()); html! { b { (self.platform) } ", code version: " b { (code_version) } } } } impl Render for ReportID { fn render(&self) -> Markup { html! { (self.as_str()) } } } diff --git a/services/reports/src/main.rs b/services/reports/src/main.rs index 558232a40..fefdb80f2 100644 --- a/services/reports/src/main.rs +++ b/services/reports/src/main.rs @@ -1,41 +1,42 @@ pub mod config; pub mod constants; pub mod database; pub mod email; pub mod http; pub mod report_types; +pub mod report_utils; pub mod service; use anyhow::Result; use comm_services_lib::blob::client::BlobServiceClient; use service::ReportsService; use tracing_subscriber::filter::{EnvFilter, LevelFilter}; fn configure_logging() -> Result<()> { let filter = EnvFilter::builder() .with_default_directive(LevelFilter::INFO.into()) .with_env_var(EnvFilter::DEFAULT_ENV) .from_env_lossy(); // init HTTP logger - it relies on 'log' instead of 'tracing' // so we have to initialize a polyfill tracing_log::LogTracer::init()?; let subscriber = tracing_subscriber::fmt().with_env_filter(filter).finish(); tracing::subscriber::set_global_default(subscriber)?; Ok(()) } #[tokio::main] async fn main() -> Result<()> { configure_logging()?; let cfg = config::parse_cmdline_args()?; let aws_config = config::load_aws_config().await; let email_config = cfg.email_config(); let db = database::client::DatabaseClient::new(&aws_config); let blob_client = BlobServiceClient::new(cfg.blob_service_url.clone()); let service = ReportsService::new(db, blob_client, email_config); crate::http::run_http_server(service).await } diff --git a/services/reports/src/report_utils.rs b/services/reports/src/report_utils.rs new file mode 100644 index 000000000..f4aaefbe9 --- /dev/null +++ b/services/reports/src/report_utils.rs @@ -0,0 +1,79 @@ +use serde_json::Value; +use std::collections::{HashMap, HashSet}; + +type ReportContent = HashMap; + +/// Returns a set of keys which differ for both objects +pub fn find_inconsistent_object_keys( + first: &serde_json::Map, + second: &serde_json::Map, +) -> HashSet { + let mut non_matching_ids = HashSet::new(); + for (k, v) in first { + if !second.get(k).is_some_and(|it| v == it) { + non_matching_ids.insert(k.to_string()); + } + } + for k in second.keys() { + if !first.contains_key(k) { + non_matching_ids.insert(k.to_string()); + } + } + non_matching_ids +} + +pub fn inconsistent_thread_ids(content: &ReportContent) -> HashSet { + let Some(push_result) = content + .get("pushResult") + .and_then(Value::as_object) else { return HashSet::new(); }; + let Some(before_action) = content + .get("beforeAction") + .and_then(Value::as_object) else { return HashSet::new(); }; + + find_inconsistent_object_keys(push_result, before_action) +} + +pub fn inconsistent_user_ids(content: &ReportContent) -> HashSet { + let Some(before) = content + .get("beforeStateCheck") + .and_then(Value::as_object) else { return HashSet::new(); }; + let Some(after) = content + .get("afterStateCheck") + .and_then(Value::as_object) else { return HashSet::new(); }; + + find_inconsistent_object_keys(before, after) +} + +pub fn inconsistent_entry_ids(_content: &ReportContent) -> HashSet { + HashSet::from(["--Unimplemented--".to_string()]) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_inconsistent_object_keys() { + let obj_a = json!({ + "foo": "bar", + "a": 2, + "b": "x", + "c": false, + }); + let obj_b = json!({ + "foo": "bar", + "a": 2, + "b": "y", + "D": true, + }); + + let expected_keys = + HashSet::from(["b".to_string(), "c".to_string(), "D".to_string()]); + let inconsistent_keys = find_inconsistent_object_keys( + obj_a.as_object().unwrap(), + obj_b.as_object().unwrap(), + ); + assert_eq!(&expected_keys, &inconsistent_keys); + } +}