From 0d351ca4b9a1d6a2e1087ff8d3d2927e032f2d24 Mon Sep 17 00:00:00 2001 From: Matthew Mauer Date: Fri, 13 Feb 2026 12:29:25 -0500 Subject: [PATCH 1/3] support fetching user insights reports --- src/apis/mod.rs | 5 +- src/apis/report_service_api.rs | 132 +++++++++++++++++ src/models/mod.rs | 1 + src/models/reports.rs | 168 +++++++++++++++++++++ src/propelauth/auth.rs | 12 +- src/propelauth/errors.rs | 17 ++- src/propelauth/mfa.rs | 62 +++----- src/propelauth/mod.rs | 7 +- src/propelauth/reports.rs | 262 +++++++++++++++++++++++++++++++++ 9 files changed, 618 insertions(+), 48 deletions(-) create mode 100644 src/apis/report_service_api.rs create mode 100644 src/models/reports.rs create mode 100644 src/propelauth/reports.rs diff --git a/src/apis/mod.rs b/src/apis/mod.rs index a159d61..e719125 100644 --- a/src/apis/mod.rs +++ b/src/apis/mod.rs @@ -72,9 +72,10 @@ pub fn urlencode>(s: T) -> String { pub mod access_token_service_api; pub mod api_key_service_api; pub mod auth_service_api; -pub mod org_service_api; -pub mod user_service_api; pub mod employee_service_api; pub mod mfa_service_api; +pub mod org_service_api; +pub(crate) mod report_service_api; +pub mod user_service_api; pub mod configuration; diff --git a/src/apis/report_service_api.rs b/src/apis/report_service_api.rs new file mode 100644 index 0000000..251f5ba --- /dev/null +++ b/src/apis/report_service_api.rs @@ -0,0 +1,132 @@ +/* + * propelauth + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 0.1.0 + * + * Generated by: https://openapi-generator.tech + */ + +use reqwest; + +use super::{configuration, Error}; +use crate::apis::ResponseContent; +use crate::models::reports::{ + FetchReportQuery, OrgReport, OrgReportType, UserReportPage, UserReportType, +}; +use crate::propelauth::auth::AUTH_HOSTNAME_HEADER; + +/// struct for typed errors of methods [`fetch_user_report`] or [`fetch_org_report`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum FetchReportRequestError { + Status401(serde_json::Value), + Status400(serde_json::Value), + Status429(serde_json::Value), + UnknownValue(serde_json::Value), +} + +pub(crate) async fn fetch_user_report( + configuration: &configuration::Configuration, + report_key: UserReportType, + params: FetchReportQuery, +) -> Result> { + let local_var_configuration = configuration; + + let local_var_client = &local_var_configuration.client; + + let local_var_uri_str = format!( + "{}/api/backend/v1/user_report/{report_key}", + local_var_configuration.base_path, + report_key = report_key.as_str(), + ); + let mut local_var_req_builder = + local_var_client.request(reqwest::Method::GET, local_var_uri_str.as_str()); + + local_var_req_builder = local_var_req_builder.query(¶ms); + + if let Some(ref local_var_user_agent) = local_var_configuration.user_agent { + local_var_req_builder = + local_var_req_builder.header(reqwest::header::USER_AGENT, local_var_user_agent.clone()); + } + if let Some(ref local_var_token) = local_var_configuration.bearer_access_token { + local_var_req_builder = local_var_req_builder.bearer_auth(local_var_token.to_owned()); + }; + local_var_req_builder = local_var_req_builder.header( + AUTH_HOSTNAME_HEADER, + local_var_configuration.auth_hostname.to_owned(), + ); + + let local_var_req = local_var_req_builder.build()?; + let local_var_resp = local_var_client.execute(local_var_req).await?; + + let local_var_status = local_var_resp.status(); + let local_var_content = local_var_resp.text().await?; + + if !local_var_status.is_client_error() && !local_var_status.is_server_error() { + serde_json::from_str(&local_var_content).map_err(Error::from) + } else { + let local_var_entity: Option = + serde_json::from_str(&local_var_content).ok(); + let local_var_error: crate::apis::ResponseContent = + ResponseContent { + status: local_var_status, + content: local_var_content, + entity: local_var_entity, + }; + Err(Error::ResponseError(local_var_error)) + } +} + +pub(crate) async fn fetch_org_report( + configuration: &configuration::Configuration, + report_key: OrgReportType, + params: FetchReportQuery, +) -> Result> { + let local_var_configuration = configuration; + + let local_var_client = &local_var_configuration.client; + + let local_var_uri_str = format!( + "{}/api/backend/v1/org_report/{report_key}", + local_var_configuration.base_path, + report_key = report_key.as_str(), + ); + let mut local_var_req_builder = + local_var_client.request(reqwest::Method::GET, local_var_uri_str.as_str()); + + local_var_req_builder = local_var_req_builder.query(¶ms); + + if let Some(ref local_var_user_agent) = local_var_configuration.user_agent { + local_var_req_builder = + local_var_req_builder.header(reqwest::header::USER_AGENT, local_var_user_agent.clone()); + } + if let Some(ref local_var_token) = local_var_configuration.bearer_access_token { + local_var_req_builder = local_var_req_builder.bearer_auth(local_var_token.to_owned()); + }; + local_var_req_builder = local_var_req_builder.header( + AUTH_HOSTNAME_HEADER, + local_var_configuration.auth_hostname.to_owned(), + ); + + let local_var_req = local_var_req_builder.build()?; + let local_var_resp = local_var_client.execute(local_var_req).await?; + + let local_var_status = local_var_resp.status(); + let local_var_content = local_var_resp.text().await?; + + if !local_var_status.is_client_error() && !local_var_status.is_server_error() { + serde_json::from_str(&local_var_content).map_err(Error::from) + } else { + let local_var_entity: Option = + serde_json::from_str(&local_var_content).ok(); + let local_var_error: crate::apis::ResponseContent = + ResponseContent { + status: local_var_status, + content: local_var_content, + entity: local_var_entity, + }; + Err(Error::ResponseError(local_var_error)) + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index ac152ac..3438d5f 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -89,6 +89,7 @@ pub mod user_ids_query; pub use self::user_ids_query::UserIdsQuery; pub mod user_in_org; pub use self::user_in_org::UserInOrg; +pub mod reports; pub mod user_metadata; pub use self::user_metadata::UserMetadata; pub mod user_paged_response; diff --git a/src/models/reports.rs b/src/models/reports.rs new file mode 100644 index 0000000..56c2376 --- /dev/null +++ b/src/models/reports.rs @@ -0,0 +1,168 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Default)] +pub struct ReportPagination { + pub page_size: Option, + pub page_number: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub(crate) struct FetchReportQuery { + pub(crate) report_interval: ReportInterval, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) page_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) page_number: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum OrgReportType { + Attrition, + Growth, + Reengagement, + Churn, +} + +impl OrgReportType { + pub fn as_str(&self) -> &'static str { + match self { + OrgReportType::Attrition => "attrition", + OrgReportType::Growth => "growth", + OrgReportType::Reengagement => "reengagement", + OrgReportType::Churn => "churn", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum UserReportType { + TopInviter, + Champion, + Reengagement, + Churn, +} + +impl UserReportType { + pub fn as_str(&self) -> &'static str { + match self { + UserReportType::TopInviter => "top_inviter", + UserReportType::Champion => "champion", + UserReportType::Reengagement => "reengagement", + UserReportType::Churn => "churn", + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(untagged)] +pub(crate) enum ReportInterval { + TopInviter(TopInviterReportInterval), + Champion(ChampionReportInterval), + Reengagement(ReengagementReportInterval), + Churn(ChurnReportInterval), + Attrition(AttritionReportInterval), + Growth(GrowthReportInterval), +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub enum TopInviterReportInterval { + #[serde(rename = "30")] + ThirtyDays, + #[serde(rename = "60")] + SixtyDays, + #[serde(rename = "90")] + NinetyDays, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub enum ChampionReportInterval { + #[serde(rename = "30")] + ThirtyDays, + #[serde(rename = "60")] + SixtyDays, + #[serde(rename = "90")] + NinetyDays, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub enum ReengagementReportInterval { + #[serde(rename = "Weekly")] + Weekly, + #[serde(rename = "Monthly")] + Monthly, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub enum ChurnReportInterval { + #[serde(rename = "7")] + SevenDays, + #[serde(rename = "14")] + FourteenDays, + #[serde(rename = "30")] + ThirtyDays, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub enum AttritionReportInterval { + #[serde(rename = "30")] + ThirtyDays, + #[serde(rename = "60")] + SixtyDays, + #[serde(rename = "90")] + NinetyDays, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub enum GrowthReportInterval { + #[serde(rename = "30")] + ThirtyDays, + #[serde(rename = "60")] + SixtyDays, + #[serde(rename = "90")] + NinetyDays, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct OrgReportRecord { + pub id: String, + pub report_id: String, + pub org_id: String, + pub name: String, + pub num_users: i32, + pub org_created_at: i64, + pub extra_properties: Option, +} + +#[derive(Deserialize)] +pub struct OrgReport { + pub org_reports: Vec, + pub current_page: i64, + pub total_count: i64, + pub page_size: i64, + pub has_more_results: bool, + pub report_time: i64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct UserReportRecord { + pub id: String, + pub report_id: String, + pub user_id: String, + pub user_created_at: i64, + pub username: Option, + pub first_name: Option, + pub last_name: Option, + pub email: String, + pub last_active_at: i64, + pub org_data: serde_json::Value, + pub extra_properties: Option, +} +#[derive(Deserialize)] +pub struct UserReportPage { + pub user_reports: Vec, + pub current_page: i64, + pub total_count: i64, + pub page_size: i64, + pub has_more_results: bool, + pub report_time: i64, +} diff --git a/src/propelauth/auth.rs b/src/propelauth/auth.rs index 71b8b87..f604a8b 100644 --- a/src/propelauth/auth.rs +++ b/src/propelauth/auth.rs @@ -5,14 +5,15 @@ use crate::apis::configuration::Configuration; use crate::models::AuthTokenVerificationMetadata; use crate::propelauth::access_token::AccessTokenService; use crate::propelauth::api_key::ApiKeyService; +use crate::propelauth::employee::EmployeeService; use crate::propelauth::errors::InitializationError; use crate::propelauth::helpers::map_autogenerated_error; use crate::propelauth::mfa::MfaService; use crate::propelauth::options::{AuthOptions, AuthOptionsWithTokenVerification}; use crate::propelauth::org::OrgService; +use crate::propelauth::reports::ReportService; use crate::propelauth::token::TokenService; use crate::propelauth::user::UserService; -use crate::propelauth::employee::EmployeeService; static BACKEND_API_BASE_URL: &str = "https://propelauth-api.com"; pub(crate) static AUTH_HOSTNAME_HEADER: &str = "X-Propelauth-url"; @@ -124,12 +125,19 @@ impl PropelAuth { } } - /// API requests related to employees. + /// API requests related to mfa. pub fn mfa(&self) -> MfaService { MfaService { config: &self.config, } } + + /// API requests related to reports. + pub fn reports(&self) -> ReportService { + ReportService { + config: &self.config, + } + } } fn validate_auth_url_extract_hostname(auth_url: &str) -> Result { diff --git a/src/propelauth/errors.rs b/src/propelauth/errors.rs index 7f79515..ce8bdf4 100644 --- a/src/propelauth/errors.rs +++ b/src/propelauth/errors.rs @@ -1,8 +1,8 @@ use crate::models::{ BadCreateAccessTokenError, BadCreateMagicLinkRequest, BadCreateOrgRequest, BadCreateUserRequest, BadFetchOrgQuery, BadFetchUsersByQuery, BadFetchUsersInOrgQuery, - BadMigrateUserRequest, BadMigrateUserPasswordRequest, BadUpdateOrgRequest, BadUpdatePasswordRequest, - BadUpdateUserEmailRequest, BadUpdateUserMetadataRequest, + BadMigrateUserPasswordRequest, BadMigrateUserRequest, BadUpdateOrgRequest, + BadUpdatePasswordRequest, BadUpdateUserEmailRequest, BadUpdateUserMetadataRequest, }; use thiserror::Error; @@ -462,7 +462,7 @@ pub enum VerifyStepUpGrantError { #[derive(Error, Debug, PartialEq, Clone)] pub enum SendSmsCodeError { - #[error("Invalid API Key")] + #[error("Invalid API Key")] InvalidApiKey, #[error("Rate limited by PropelAuth")] @@ -511,3 +511,14 @@ pub enum VerifySmsChallengeError { UnexpectedException, } +#[derive(Error, Debug, PartialEq, Clone)] +pub enum FetchReportError { + #[error("Invalid API Key")] + InvalidApiKey, + + #[error("Rate limited by PropelAuth")] + PropelAuthRateLimit, + + #[error("Unexpected exception, please try again")] + UnexpectedException, +} diff --git a/src/propelauth/mfa.rs b/src/propelauth/mfa.rs index c83c014..c3e50fc 100644 --- a/src/propelauth/mfa.rs +++ b/src/propelauth/mfa.rs @@ -1,11 +1,13 @@ use crate::apis::configuration::Configuration; -use crate::apis::mfa_service_api::{StepUpMfaError, VerifySmsChallengeParams, VerifyStepUpGrantParams}; +use crate::apis::mfa_service_api::{SendSmsMfaCodeParams, VerifyTotpChallengeParams}; +use crate::apis::mfa_service_api::{VerifySmsChallengeParams, VerifyStepUpGrantParams}; use crate::apis::Error; -use crate::models::{SendSmsCodeResponse, VerifySmsChallengeResponse, VerifyStepUpGrantResponse}; -use crate::propelauth::errors::{VerifyStepUpGrantError, VerifyStepUpTotpChallengeError, SendSmsCodeError, VerifySmsChallengeError}; -use crate::propelauth::helpers::map_autogenerated_error; use crate::models::VerifyTotpChallengeResponse; -use crate::apis::mfa_service_api::{SendSmsMfaCodeParams, VerifyTotpChallengeParams}; +use crate::models::{SendSmsCodeResponse, VerifySmsChallengeResponse, VerifyStepUpGrantResponse}; +use crate::propelauth::errors::{ + SendSmsCodeError, VerifySmsChallengeError, VerifyStepUpGrantError, + VerifyStepUpTotpChallengeError, +}; pub struct MfaService<'a> { pub(crate) config: &'a Configuration, @@ -66,8 +68,7 @@ impl MfaService<'_> { &self, params: VerifyStepUpGrantParams, ) -> Result { - let result = - crate::apis::mfa_service_api::verify_step_up_grant(&self.config, params).await; + let result = crate::apis::mfa_service_api::verify_step_up_grant(&self.config, params).await; match result { Ok(_) => Ok(VerifyStepUpGrantResponse { success: true }), @@ -85,23 +86,24 @@ impl MfaService<'_> { { match error_code { "invalid_request_fields" => { - if let Some(field_to_errors) = - error_json.get("field_to_errors").and_then(|v| v.as_object()) + if let Some(field_to_errors) = error_json + .get("field_to_errors") + .and_then(|v| v.as_object()) { if let Some(grant_error) = field_to_errors.get("grant").and_then(|v| v.as_str()) { if grant_error == "grant_not_found" { - return Ok(VerifyStepUpGrantResponse { success: false }); + return Ok(VerifyStepUpGrantResponse { + success: false, + }); } } } return Err(VerifyStepUpGrantError::BadRequest(response.content)); } - "feature_gated" => { - return Err(VerifyStepUpGrantError::FeatureGated) - } + "feature_gated" => return Err(VerifyStepUpGrantError::FeatureGated), _ => {} } } @@ -117,8 +119,7 @@ impl MfaService<'_> { &self, params: SendSmsMfaCodeParams, ) -> Result { - let result = - crate::apis::mfa_service_api::send_sms_mfa_code(&self.config, params).await; + let result = crate::apis::mfa_service_api::send_sms_mfa_code(&self.config, params).await; match result { Ok(response) => Ok(response), @@ -135,20 +136,12 @@ impl MfaService<'_> { if let Some(error_code) = error_json.get("error_code").and_then(|v| v.as_str()) { match error_code { - "user_not_found" => { - return Err(SendSmsCodeError::UserNotFound) - } - "mfa_not_enabled" => { - return Err(SendSmsCodeError::MfaNotEnabled) - } + "user_not_found" => return Err(SendSmsCodeError::UserNotFound), + "mfa_not_enabled" => return Err(SendSmsCodeError::MfaNotEnabled), "invalid_request_fields" => { - return Err(SendSmsCodeError::BadRequest( - response.content, - )) - } - "feature_gated" => { - return Err(SendSmsCodeError::FeatureGated) + return Err(SendSmsCodeError::BadRequest(response.content)) } + "feature_gated" => return Err(SendSmsCodeError::FeatureGated), _ => {} } } @@ -163,8 +156,7 @@ impl MfaService<'_> { &self, params: VerifySmsChallengeParams, ) -> Result { - let result = - crate::apis::mfa_service_api::verify_sms_challenge(&self.config, params).await; + let result = crate::apis::mfa_service_api::verify_sms_challenge(&self.config, params).await; match result { Ok(response) => Ok(response), @@ -181,20 +173,14 @@ impl MfaService<'_> { if let Some(error_code) = error_json.get("error_code").and_then(|v| v.as_str()) { match error_code { - "user_not_found" => { - return Err(VerifySmsChallengeError::UserNotFound) - } + "user_not_found" => return Err(VerifySmsChallengeError::UserNotFound), "mfa_not_enabled" => { return Err(VerifySmsChallengeError::MfaNotEnabled) } "invalid_request_fields" => { - return Err(VerifySmsChallengeError::BadRequest( - response.content, - )) - } - "feature_gated" => { - return Err(VerifySmsChallengeError::FeatureGated) + return Err(VerifySmsChallengeError::BadRequest(response.content)) } + "feature_gated" => return Err(VerifySmsChallengeError::FeatureGated), _ => {} } } diff --git a/src/propelauth/mod.rs b/src/propelauth/mod.rs index 2d69864..01c9b34 100644 --- a/src/propelauth/mod.rs +++ b/src/propelauth/mod.rs @@ -1,12 +1,13 @@ +pub mod access_token; pub mod api_key; pub mod auth; +pub mod employee; pub mod errors; pub(crate) mod helpers; +pub mod mfa; pub mod options; pub mod org; +pub mod reports; pub mod token; pub mod token_models; pub mod user; -pub mod access_token; -pub mod employee; -pub mod mfa; \ No newline at end of file diff --git a/src/propelauth/reports.rs b/src/propelauth/reports.rs new file mode 100644 index 0000000..3e00887 --- /dev/null +++ b/src/propelauth/reports.rs @@ -0,0 +1,262 @@ +use crate::apis::configuration::Configuration; +use crate::apis::Error; +use crate::models::reports::{ + AttritionReportInterval, ChampionReportInterval, ChurnReportInterval, FetchReportQuery, + GrowthReportInterval, OrgReportType, ReengagementReportInterval, ReportInterval, + ReportPagination, TopInviterReportInterval, UserReportPage, UserReportType, +}; +use crate::propelauth::errors::FetchReportError; + +pub struct ReportService<'a> { + pub(crate) config: &'a Configuration, +} + +impl ReportService<'_> { + pub async fn fetch_user_top_inviter_report( + &self, + report_interval: TopInviterReportInterval, + pagination: ReportPagination, + ) -> Result { + let result = crate::apis::report_service_api::fetch_user_report( + &self.config, + UserReportType::TopInviter, + FetchReportQuery { + report_interval: ReportInterval::TopInviter(report_interval), + page_size: pagination.page_size, + page_number: pagination.page_number, + }, + ) + .await; + + match result { + Ok(response) => Ok(response), + Err(Error::ResponseError(response)) => { + if response.status == 401 { + return Err(FetchReportError::InvalidApiKey); + } else if response.status == 429 { + return Err(FetchReportError::PropelAuthRateLimit); + } else { + Err(FetchReportError::UnexpectedException) + } + } + Err(_) => Err(FetchReportError::UnexpectedException), + } + } + + pub async fn fetch_user_champion_report( + &self, + report_interval: ChampionReportInterval, + pagination: ReportPagination, + ) -> Result { + let result = crate::apis::report_service_api::fetch_user_report( + &self.config, + UserReportType::Champion, + FetchReportQuery { + report_interval: ReportInterval::Champion(report_interval), + page_size: pagination.page_size, + page_number: pagination.page_number, + }, + ) + .await; + + match result { + Ok(response) => Ok(response), + Err(Error::ResponseError(response)) => { + if response.status == 401 { + return Err(FetchReportError::InvalidApiKey); + } else if response.status == 429 { + return Err(FetchReportError::PropelAuthRateLimit); + } else { + Err(FetchReportError::UnexpectedException) + } + } + Err(_) => Err(FetchReportError::UnexpectedException), + } + } + + pub async fn fetch_user_reengagement_report( + &self, + report_interval: ReengagementReportInterval, + pagination: ReportPagination, + ) -> Result { + let result = crate::apis::report_service_api::fetch_user_report( + &self.config, + UserReportType::Reengagement, + FetchReportQuery { + report_interval: ReportInterval::Reengagement(report_interval), + page_size: pagination.page_size, + page_number: pagination.page_number, + }, + ) + .await; + + match result { + Ok(response) => Ok(response), + Err(Error::ResponseError(response)) => { + if response.status == 401 { + return Err(FetchReportError::InvalidApiKey); + } else if response.status == 429 { + return Err(FetchReportError::PropelAuthRateLimit); + } else { + Err(FetchReportError::UnexpectedException) + } + } + Err(_) => Err(FetchReportError::UnexpectedException), + } + } + + pub async fn fetch_user_churn_report( + &self, + report_interval: ChurnReportInterval, + pagination: ReportPagination, + ) -> Result { + let result = crate::apis::report_service_api::fetch_user_report( + &self.config, + UserReportType::Churn, + FetchReportQuery { + report_interval: ReportInterval::Churn(report_interval), + page_size: pagination.page_size, + page_number: pagination.page_number, + }, + ) + .await; + + match result { + Ok(response) => Ok(response), + Err(Error::ResponseError(response)) => { + if response.status == 401 { + return Err(FetchReportError::InvalidApiKey); + } else if response.status == 429 { + return Err(FetchReportError::PropelAuthRateLimit); + } else { + Err(FetchReportError::UnexpectedException) + } + } + Err(_) => Err(FetchReportError::UnexpectedException), + } + } + + pub async fn fetch_org_attrition_report( + &self, + report_interval: AttritionReportInterval, + pagination: ReportPagination, + ) -> Result { + let result = crate::apis::report_service_api::fetch_org_report( + &self.config, + OrgReportType::Attrition, + FetchReportQuery { + report_interval: ReportInterval::Attrition(report_interval), + page_size: pagination.page_size, + page_number: pagination.page_number, + }, + ) + .await; + + match result { + Ok(response) => Ok(response), + Err(Error::ResponseError(response)) => { + if response.status == 401 { + return Err(FetchReportError::InvalidApiKey); + } else if response.status == 429 { + return Err(FetchReportError::PropelAuthRateLimit); + } else { + Err(FetchReportError::UnexpectedException) + } + } + Err(_) => Err(FetchReportError::UnexpectedException), + } + } + + pub async fn fetch_org_growth_report( + &self, + report_interval: GrowthReportInterval, + pagination: ReportPagination, + ) -> Result { + let result = crate::apis::report_service_api::fetch_org_report( + &self.config, + OrgReportType::Growth, + FetchReportQuery { + report_interval: ReportInterval::Growth(report_interval), + page_size: pagination.page_size, + page_number: pagination.page_number, + }, + ) + .await; + + match result { + Ok(response) => Ok(response), + Err(Error::ResponseError(response)) => { + if response.status == 401 { + return Err(FetchReportError::InvalidApiKey); + } else if response.status == 429 { + return Err(FetchReportError::PropelAuthRateLimit); + } else { + Err(FetchReportError::UnexpectedException) + } + } + Err(_) => Err(FetchReportError::UnexpectedException), + } + } + + pub async fn fetch_org_reengagement_report( + &self, + report_interval: ReengagementReportInterval, + pagination: ReportPagination, + ) -> Result { + let result = crate::apis::report_service_api::fetch_org_report( + &self.config, + OrgReportType::Reengagement, + FetchReportQuery { + report_interval: ReportInterval::Reengagement(report_interval), + page_size: pagination.page_size, + page_number: pagination.page_number, + }, + ) + .await; + + match result { + Ok(response) => Ok(response), + Err(Error::ResponseError(response)) => { + if response.status == 401 { + return Err(FetchReportError::InvalidApiKey); + } else if response.status == 429 { + return Err(FetchReportError::PropelAuthRateLimit); + } else { + Err(FetchReportError::UnexpectedException) + } + } + Err(_) => Err(FetchReportError::UnexpectedException), + } + } + + pub async fn fetch_org_churn_report( + &self, + report_interval: ChurnReportInterval, + pagination: ReportPagination, + ) -> Result { + let result = crate::apis::report_service_api::fetch_org_report( + &self.config, + OrgReportType::Churn, + FetchReportQuery { + report_interval: ReportInterval::Churn(report_interval), + page_size: pagination.page_size, + page_number: pagination.page_number, + }, + ) + .await; + + match result { + Ok(response) => Ok(response), + Err(Error::ResponseError(response)) => { + if response.status == 401 { + return Err(FetchReportError::InvalidApiKey); + } else if response.status == 429 { + return Err(FetchReportError::PropelAuthRateLimit); + } else { + Err(FetchReportError::UnexpectedException) + } + } + Err(_) => Err(FetchReportError::UnexpectedException), + } + } +} From feb65b7f17944f78e38cf188b05961f3bd8ab0ed Mon Sep 17 00:00:00 2001 From: Matthew Mauer Date: Thu, 12 Mar 2026 12:14:17 -0400 Subject: [PATCH 2/3] support fetching chart data, rename reports -> user_insights --- src/apis/mod.rs | 2 +- ...ce_api.rs => user_insights_service_api.rs} | 71 +++++++-- src/models/mod.rs | 2 +- src/models/{reports.rs => user_insights.rs} | 54 +++++++ src/propelauth/auth.rs | 8 +- src/propelauth/errors.rs | 2 +- src/propelauth/mod.rs | 2 +- .../{reports.rs => user_insights.rs} | 137 +++++++++++------- 8 files changed, 205 insertions(+), 73 deletions(-) rename src/apis/{report_service_api.rs => user_insights_service_api.rs} (61%) rename src/models/{reports.rs => user_insights.rs} (72%) rename src/propelauth/{reports.rs => user_insights.rs} (55%) diff --git a/src/apis/mod.rs b/src/apis/mod.rs index e719125..f5efcb9 100644 --- a/src/apis/mod.rs +++ b/src/apis/mod.rs @@ -75,7 +75,7 @@ pub mod auth_service_api; pub mod employee_service_api; pub mod mfa_service_api; pub mod org_service_api; -pub(crate) mod report_service_api; +pub(crate) mod user_insights_service_api; pub mod user_service_api; pub mod configuration; diff --git a/src/apis/report_service_api.rs b/src/apis/user_insights_service_api.rs similarity index 61% rename from src/apis/report_service_api.rs rename to src/apis/user_insights_service_api.rs index 251f5ba..ed81833 100644 --- a/src/apis/report_service_api.rs +++ b/src/apis/user_insights_service_api.rs @@ -12,15 +12,16 @@ use reqwest; use super::{configuration, Error}; use crate::apis::ResponseContent; -use crate::models::reports::{ - FetchReportQuery, OrgReport, OrgReportType, UserReportPage, UserReportType, +use crate::models::user_insights::{ + ChartData, ChartMetric, FetchChartDataQuery, FetchReportQuery, OrgReport, OrgReportType, + UserReportPage, UserReportType, }; use crate::propelauth::auth::AUTH_HOSTNAME_HEADER; /// struct for typed errors of methods [`fetch_user_report`] or [`fetch_org_report`] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] -pub enum FetchReportRequestError { +pub enum FetchUserInsightsDataRequestError { Status401(serde_json::Value), Status400(serde_json::Value), Status429(serde_json::Value), @@ -31,7 +32,7 @@ pub(crate) async fn fetch_user_report( configuration: &configuration::Configuration, report_key: UserReportType, params: FetchReportQuery, -) -> Result> { +) -> Result> { let local_var_configuration = configuration; let local_var_client = &local_var_configuration.client; @@ -67,9 +68,9 @@ pub(crate) async fn fetch_user_report( if !local_var_status.is_client_error() && !local_var_status.is_server_error() { serde_json::from_str(&local_var_content).map_err(Error::from) } else { - let local_var_entity: Option = + let local_var_entity: Option = serde_json::from_str(&local_var_content).ok(); - let local_var_error: crate::apis::ResponseContent = + let local_var_error: crate::apis::ResponseContent = ResponseContent { status: local_var_status, content: local_var_content, @@ -83,7 +84,7 @@ pub(crate) async fn fetch_org_report( configuration: &configuration::Configuration, report_key: OrgReportType, params: FetchReportQuery, -) -> Result> { +) -> Result> { let local_var_configuration = configuration; let local_var_client = &local_var_configuration.client; @@ -119,9 +120,61 @@ pub(crate) async fn fetch_org_report( if !local_var_status.is_client_error() && !local_var_status.is_server_error() { serde_json::from_str(&local_var_content).map_err(Error::from) } else { - let local_var_entity: Option = + let local_var_entity: Option = serde_json::from_str(&local_var_content).ok(); - let local_var_error: crate::apis::ResponseContent = + let local_var_error: crate::apis::ResponseContent = + ResponseContent { + status: local_var_status, + content: local_var_content, + entity: local_var_entity, + }; + Err(Error::ResponseError(local_var_error)) + } +} + +pub(crate) async fn fetch_chart_metric_data( + configuration: &configuration::Configuration, + chart_metric: ChartMetric, + params: FetchChartDataQuery, +) -> Result> { + let local_var_configuration = configuration; + + let local_var_client = &local_var_configuration.client; + + let local_var_uri_str = format!( + "{}/api/backend/v1/chart_metrics/{chart_metric}", + local_var_configuration.base_path, + chart_metric = chart_metric.as_str(), + ); + let mut local_var_req_builder = + local_var_client.request(reqwest::Method::GET, local_var_uri_str.as_str()); + + local_var_req_builder = local_var_req_builder.query(¶ms); + + if let Some(ref local_var_user_agent) = local_var_configuration.user_agent { + local_var_req_builder = + local_var_req_builder.header(reqwest::header::USER_AGENT, local_var_user_agent.clone()); + } + if let Some(ref local_var_token) = local_var_configuration.bearer_access_token { + local_var_req_builder = local_var_req_builder.bearer_auth(local_var_token.to_owned()); + }; + local_var_req_builder = local_var_req_builder.header( + AUTH_HOSTNAME_HEADER, + local_var_configuration.auth_hostname.to_owned(), + ); + + let local_var_req = local_var_req_builder.build()?; + let local_var_resp = local_var_client.execute(local_var_req).await?; + + let local_var_status = local_var_resp.status(); + let local_var_content = local_var_resp.text().await?; + + if !local_var_status.is_client_error() && !local_var_status.is_server_error() { + serde_json::from_str(&local_var_content).map_err(Error::from) + } else { + let local_var_entity: Option = + serde_json::from_str(&local_var_content).ok(); + let local_var_error: crate::apis::ResponseContent = ResponseContent { status: local_var_status, content: local_var_content, diff --git a/src/models/mod.rs b/src/models/mod.rs index 3438d5f..14e46cb 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -89,7 +89,7 @@ pub mod user_ids_query; pub use self::user_ids_query::UserIdsQuery; pub mod user_in_org; pub use self::user_in_org::UserInOrg; -pub mod reports; +pub mod user_insights; pub mod user_metadata; pub use self::user_metadata::UserMetadata; pub mod user_paged_response; diff --git a/src/models/reports.rs b/src/models/user_insights.rs similarity index 72% rename from src/models/reports.rs rename to src/models/user_insights.rs index 56c2376..c5076fd 100644 --- a/src/models/reports.rs +++ b/src/models/user_insights.rs @@ -166,3 +166,57 @@ pub struct UserReportPage { pub has_more_results: bool, pub report_time: i64, } +// chart metrics types + +#[derive(Clone, Debug, PartialEq, Deserialize)] +pub enum ChartMetric { + Signups, + OrgsCreated, + ActiveUsers, + ActiveOrgs, +} + +impl ChartMetric { + pub fn as_str(&self) -> &'static str { + match self { + ChartMetric::Signups => "signups", + ChartMetric::OrgsCreated => "orgs_created", + ChartMetric::ActiveUsers => "active_users", + ChartMetric::ActiveOrgs => "active_orgs", + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum ChartMetricCadence { + #[serde(rename = "Daily")] + Daily, + #[serde(rename = "Weekly")] + Weekly, + #[serde(rename = "Monthly")] + Monthly, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ChartDataPoint { + pub result: i64, + pub date: String, // YYYY-MM-DD format date, 24 hours in UTC timezone + pub cadence_completed: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ChartData { + pub metrics: Vec, + pub chart_type: ChartMetric, + pub cadence: ChartMetricCadence, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct FetchChartDataQuery { + #[serde(skip_serializing_if = "Option::is_none", rename = "cadence")] + pub cadence: Option, + #[serde(skip_serializing_if = "Option::is_none", rename = "start_date")] + pub start_date: Option, // YYYY-MM-DD format date + #[serde(skip_serializing_if = "Option::is_none", rename = "end_date")] + pub end_date: Option, // YYYY-MM-DD format date +} diff --git a/src/propelauth/auth.rs b/src/propelauth/auth.rs index f604a8b..55f0ab4 100644 --- a/src/propelauth/auth.rs +++ b/src/propelauth/auth.rs @@ -11,9 +11,9 @@ use crate::propelauth::helpers::map_autogenerated_error; use crate::propelauth::mfa::MfaService; use crate::propelauth::options::{AuthOptions, AuthOptionsWithTokenVerification}; use crate::propelauth::org::OrgService; -use crate::propelauth::reports::ReportService; use crate::propelauth::token::TokenService; use crate::propelauth::user::UserService; +use crate::propelauth::user_insights::UserInsightsService; static BACKEND_API_BASE_URL: &str = "https://propelauth-api.com"; pub(crate) static AUTH_HOSTNAME_HEADER: &str = "X-Propelauth-url"; @@ -132,9 +132,9 @@ impl PropelAuth { } } - /// API requests related to reports. - pub fn reports(&self) -> ReportService { - ReportService { + /// API requests related to user insights data. + pub fn user_insights(&self) -> UserInsightsService { + UserInsightsService { config: &self.config, } } diff --git a/src/propelauth/errors.rs b/src/propelauth/errors.rs index ce8bdf4..c2931e7 100644 --- a/src/propelauth/errors.rs +++ b/src/propelauth/errors.rs @@ -512,7 +512,7 @@ pub enum VerifySmsChallengeError { } #[derive(Error, Debug, PartialEq, Clone)] -pub enum FetchReportError { +pub enum FetchUserInsightsError { #[error("Invalid API Key")] InvalidApiKey, diff --git a/src/propelauth/mod.rs b/src/propelauth/mod.rs index 01c9b34..70459b1 100644 --- a/src/propelauth/mod.rs +++ b/src/propelauth/mod.rs @@ -7,7 +7,7 @@ pub(crate) mod helpers; pub mod mfa; pub mod options; pub mod org; -pub mod reports; pub mod token; pub mod token_models; pub mod user; +pub mod user_insights; diff --git a/src/propelauth/reports.rs b/src/propelauth/user_insights.rs similarity index 55% rename from src/propelauth/reports.rs rename to src/propelauth/user_insights.rs index 3e00887..ea49990 100644 --- a/src/propelauth/reports.rs +++ b/src/propelauth/user_insights.rs @@ -1,23 +1,24 @@ use crate::apis::configuration::Configuration; -use crate::apis::Error; -use crate::models::reports::{ - AttritionReportInterval, ChampionReportInterval, ChurnReportInterval, FetchReportQuery, - GrowthReportInterval, OrgReportType, ReengagementReportInterval, ReportInterval, - ReportPagination, TopInviterReportInterval, UserReportPage, UserReportType, +use crate::apis::{user_insights_service_api, Error}; +use crate::models::user_insights::{ + AttritionReportInterval, ChampionReportInterval, ChartMetric, ChurnReportInterval, + FetchChartDataQuery, FetchReportQuery, GrowthReportInterval, OrgReportType, + ReengagementReportInterval, ReportInterval, ReportPagination, TopInviterReportInterval, + UserReportPage, UserReportType, }; -use crate::propelauth::errors::FetchReportError; +use crate::propelauth::errors::FetchUserInsightsError; -pub struct ReportService<'a> { +pub struct UserInsightsService<'a> { pub(crate) config: &'a Configuration, } -impl ReportService<'_> { +impl UserInsightsService<'_> { pub async fn fetch_user_top_inviter_report( &self, report_interval: TopInviterReportInterval, pagination: ReportPagination, - ) -> Result { - let result = crate::apis::report_service_api::fetch_user_report( + ) -> Result { + let result = user_insights_service_api::fetch_user_report( &self.config, UserReportType::TopInviter, FetchReportQuery { @@ -32,14 +33,14 @@ impl ReportService<'_> { Ok(response) => Ok(response), Err(Error::ResponseError(response)) => { if response.status == 401 { - return Err(FetchReportError::InvalidApiKey); + return Err(FetchUserInsightsError::InvalidApiKey); } else if response.status == 429 { - return Err(FetchReportError::PropelAuthRateLimit); + return Err(FetchUserInsightsError::PropelAuthRateLimit); } else { - Err(FetchReportError::UnexpectedException) + Err(FetchUserInsightsError::UnexpectedException) } } - Err(_) => Err(FetchReportError::UnexpectedException), + Err(_) => Err(FetchUserInsightsError::UnexpectedException), } } @@ -47,8 +48,8 @@ impl ReportService<'_> { &self, report_interval: ChampionReportInterval, pagination: ReportPagination, - ) -> Result { - let result = crate::apis::report_service_api::fetch_user_report( + ) -> Result { + let result = user_insights_service_api::fetch_user_report( &self.config, UserReportType::Champion, FetchReportQuery { @@ -63,14 +64,14 @@ impl ReportService<'_> { Ok(response) => Ok(response), Err(Error::ResponseError(response)) => { if response.status == 401 { - return Err(FetchReportError::InvalidApiKey); + return Err(FetchUserInsightsError::InvalidApiKey); } else if response.status == 429 { - return Err(FetchReportError::PropelAuthRateLimit); + return Err(FetchUserInsightsError::PropelAuthRateLimit); } else { - Err(FetchReportError::UnexpectedException) + Err(FetchUserInsightsError::UnexpectedException) } } - Err(_) => Err(FetchReportError::UnexpectedException), + Err(_) => Err(FetchUserInsightsError::UnexpectedException), } } @@ -78,8 +79,8 @@ impl ReportService<'_> { &self, report_interval: ReengagementReportInterval, pagination: ReportPagination, - ) -> Result { - let result = crate::apis::report_service_api::fetch_user_report( + ) -> Result { + let result = user_insights_service_api::fetch_user_report( &self.config, UserReportType::Reengagement, FetchReportQuery { @@ -94,14 +95,14 @@ impl ReportService<'_> { Ok(response) => Ok(response), Err(Error::ResponseError(response)) => { if response.status == 401 { - return Err(FetchReportError::InvalidApiKey); + return Err(FetchUserInsightsError::InvalidApiKey); } else if response.status == 429 { - return Err(FetchReportError::PropelAuthRateLimit); + return Err(FetchUserInsightsError::PropelAuthRateLimit); } else { - Err(FetchReportError::UnexpectedException) + Err(FetchUserInsightsError::UnexpectedException) } } - Err(_) => Err(FetchReportError::UnexpectedException), + Err(_) => Err(FetchUserInsightsError::UnexpectedException), } } @@ -109,8 +110,8 @@ impl ReportService<'_> { &self, report_interval: ChurnReportInterval, pagination: ReportPagination, - ) -> Result { - let result = crate::apis::report_service_api::fetch_user_report( + ) -> Result { + let result = user_insights_service_api::fetch_user_report( &self.config, UserReportType::Churn, FetchReportQuery { @@ -125,14 +126,14 @@ impl ReportService<'_> { Ok(response) => Ok(response), Err(Error::ResponseError(response)) => { if response.status == 401 { - return Err(FetchReportError::InvalidApiKey); + return Err(FetchUserInsightsError::InvalidApiKey); } else if response.status == 429 { - return Err(FetchReportError::PropelAuthRateLimit); + return Err(FetchUserInsightsError::PropelAuthRateLimit); } else { - Err(FetchReportError::UnexpectedException) + Err(FetchUserInsightsError::UnexpectedException) } } - Err(_) => Err(FetchReportError::UnexpectedException), + Err(_) => Err(FetchUserInsightsError::UnexpectedException), } } @@ -140,8 +141,8 @@ impl ReportService<'_> { &self, report_interval: AttritionReportInterval, pagination: ReportPagination, - ) -> Result { - let result = crate::apis::report_service_api::fetch_org_report( + ) -> Result { + let result = user_insights_service_api::fetch_org_report( &self.config, OrgReportType::Attrition, FetchReportQuery { @@ -156,14 +157,14 @@ impl ReportService<'_> { Ok(response) => Ok(response), Err(Error::ResponseError(response)) => { if response.status == 401 { - return Err(FetchReportError::InvalidApiKey); + return Err(FetchUserInsightsError::InvalidApiKey); } else if response.status == 429 { - return Err(FetchReportError::PropelAuthRateLimit); + return Err(FetchUserInsightsError::PropelAuthRateLimit); } else { - Err(FetchReportError::UnexpectedException) + Err(FetchUserInsightsError::UnexpectedException) } } - Err(_) => Err(FetchReportError::UnexpectedException), + Err(_) => Err(FetchUserInsightsError::UnexpectedException), } } @@ -171,8 +172,8 @@ impl ReportService<'_> { &self, report_interval: GrowthReportInterval, pagination: ReportPagination, - ) -> Result { - let result = crate::apis::report_service_api::fetch_org_report( + ) -> Result { + let result = user_insights_service_api::fetch_org_report( &self.config, OrgReportType::Growth, FetchReportQuery { @@ -187,14 +188,14 @@ impl ReportService<'_> { Ok(response) => Ok(response), Err(Error::ResponseError(response)) => { if response.status == 401 { - return Err(FetchReportError::InvalidApiKey); + return Err(FetchUserInsightsError::InvalidApiKey); } else if response.status == 429 { - return Err(FetchReportError::PropelAuthRateLimit); + return Err(FetchUserInsightsError::PropelAuthRateLimit); } else { - Err(FetchReportError::UnexpectedException) + Err(FetchUserInsightsError::UnexpectedException) } } - Err(_) => Err(FetchReportError::UnexpectedException), + Err(_) => Err(FetchUserInsightsError::UnexpectedException), } } @@ -202,8 +203,8 @@ impl ReportService<'_> { &self, report_interval: ReengagementReportInterval, pagination: ReportPagination, - ) -> Result { - let result = crate::apis::report_service_api::fetch_org_report( + ) -> Result { + let result = user_insights_service_api::fetch_org_report( &self.config, OrgReportType::Reengagement, FetchReportQuery { @@ -218,14 +219,14 @@ impl ReportService<'_> { Ok(response) => Ok(response), Err(Error::ResponseError(response)) => { if response.status == 401 { - return Err(FetchReportError::InvalidApiKey); + return Err(FetchUserInsightsError::InvalidApiKey); } else if response.status == 429 { - return Err(FetchReportError::PropelAuthRateLimit); + return Err(FetchUserInsightsError::PropelAuthRateLimit); } else { - Err(FetchReportError::UnexpectedException) + Err(FetchUserInsightsError::UnexpectedException) } } - Err(_) => Err(FetchReportError::UnexpectedException), + Err(_) => Err(FetchUserInsightsError::UnexpectedException), } } @@ -233,8 +234,8 @@ impl ReportService<'_> { &self, report_interval: ChurnReportInterval, pagination: ReportPagination, - ) -> Result { - let result = crate::apis::report_service_api::fetch_org_report( + ) -> Result { + let result = user_insights_service_api::fetch_org_report( &self.config, OrgReportType::Churn, FetchReportQuery { @@ -249,14 +250,38 @@ impl ReportService<'_> { Ok(response) => Ok(response), Err(Error::ResponseError(response)) => { if response.status == 401 { - return Err(FetchReportError::InvalidApiKey); + return Err(FetchUserInsightsError::InvalidApiKey); } else if response.status == 429 { - return Err(FetchReportError::PropelAuthRateLimit); + return Err(FetchUserInsightsError::PropelAuthRateLimit); } else { - Err(FetchReportError::UnexpectedException) + Err(FetchUserInsightsError::UnexpectedException) } } - Err(_) => Err(FetchReportError::UnexpectedException), + Err(_) => Err(FetchUserInsightsError::UnexpectedException), + } + } + + pub async fn fetch_chart_metric_data( + &self, + chart_metric: ChartMetric, + params: FetchChartDataQuery, + ) -> Result { + let result = + user_insights_service_api::fetch_chart_metric_data(&self.config, chart_metric, params) + .await; + + match result { + Ok(response) => Ok(response), + Err(Error::ResponseError(response)) => { + if response.status == 401 { + return Err(FetchUserInsightsError::InvalidApiKey); + } else if response.status == 429 { + return Err(FetchUserInsightsError::PropelAuthRateLimit); + } else { + Err(FetchUserInsightsError::UnexpectedException) + } + } + Err(_) => Err(FetchUserInsightsError::UnexpectedException), } } } From 02b03ae6cc69cfaa7eb24204ca60398107012a93 Mon Sep 17 00:00:00 2001 From: Matthew Mauer Date: Thu, 12 Mar 2026 12:27:03 -0400 Subject: [PATCH 3/3] validate *_date params pre-fetch --- src/models/user_insights.rs | 2 +- src/propelauth/errors.rs | 3 +++ src/propelauth/user_insights.rs | 22 ++++++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/models/user_insights.rs b/src/models/user_insights.rs index c5076fd..28a15f3 100644 --- a/src/models/user_insights.rs +++ b/src/models/user_insights.rs @@ -211,7 +211,7 @@ pub struct ChartData { pub cadence: ChartMetricCadence, } -#[derive(Clone, Debug, PartialEq, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Default)] pub struct FetchChartDataQuery { #[serde(skip_serializing_if = "Option::is_none", rename = "cadence")] pub cadence: Option, diff --git a/src/propelauth/errors.rs b/src/propelauth/errors.rs index c2931e7..6189581 100644 --- a/src/propelauth/errors.rs +++ b/src/propelauth/errors.rs @@ -516,6 +516,9 @@ pub enum FetchUserInsightsError { #[error("Invalid API Key")] InvalidApiKey, + #[error("Invalid parameters: {0}")] + InvalidParams(&'static str), + #[error("Rate limited by PropelAuth")] PropelAuthRateLimit, diff --git a/src/propelauth/user_insights.rs b/src/propelauth/user_insights.rs index ea49990..4b95d96 100644 --- a/src/propelauth/user_insights.rs +++ b/src/propelauth/user_insights.rs @@ -266,6 +266,28 @@ impl UserInsightsService<'_> { chart_metric: ChartMetric, params: FetchChartDataQuery, ) -> Result { + if let Some(start_date_raw) = ¶ms.start_date { + if let Ok(start_date) = chrono::NaiveDate::parse_from_str(start_date_raw, "%Y-%m-%d") { + if start_date > chrono::Utc::now().naive_utc().date() { + return Err(FetchUserInsightsError::InvalidParams( + "start_date cannot be in the future", + )); + } + } else { + return Err(FetchUserInsightsError::InvalidParams( + "start_date must be in YYYY-MM-DD format", + )); + } + } + + if let Some(end_date_raw) = ¶ms.end_date { + if chrono::NaiveDate::parse_from_str(end_date_raw, "%Y-%m-%d").is_err() { + return Err(FetchUserInsightsError::InvalidParams( + "end_date must be in YYYY-MM-DD format", + )); + } + } + let result = user_insights_service_api::fetch_chart_metric_data(&self.config, chart_metric, params) .await;