diff --git a/_migrations/2022-11-02-040723_alter_table_tts_inference_jobs_add_max_duration/down.sql b/_migrations/2022-11-02-040723_alter_table_tts_inference_jobs_add_max_duration/down.sql new file mode 100644 index 0000000000..6e9683dc05 --- /dev/null +++ b/_migrations/2022-11-02-040723_alter_table_tts_inference_jobs_add_max_duration/down.sql @@ -0,0 +1,5 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE tts_inference_jobs DROP COLUMN max_duration_seconds; diff --git a/_migrations/2022-11-02-040723_alter_table_tts_inference_jobs_add_max_duration/up.sql b/_migrations/2022-11-02-040723_alter_table_tts_inference_jobs_add_max_duration/up.sql new file mode 100644 index 0000000000..a5851ce874 --- /dev/null +++ b/_migrations/2022-11-02-040723_alter_table_tts_inference_jobs_add_max_duration/up.sql @@ -0,0 +1,7 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE tts_inference_jobs + ADD COLUMN max_duration_seconds INTEGER NOT NULL DEFAULT 0 + AFTER is_debug_request; diff --git a/_migrations/2022-11-02-040731_alter_table_w2l_inference_jobs_add_max_duration/down.sql b/_migrations/2022-11-02-040731_alter_table_w2l_inference_jobs_add_max_duration/down.sql new file mode 100644 index 0000000000..0f44ed9e0c --- /dev/null +++ b/_migrations/2022-11-02-040731_alter_table_w2l_inference_jobs_add_max_duration/down.sql @@ -0,0 +1,5 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE w2l_inference_jobs DROP COLUMN max_duration_seconds; diff --git a/_migrations/2022-11-02-040731_alter_table_w2l_inference_jobs_add_max_duration/up.sql b/_migrations/2022-11-02-040731_alter_table_w2l_inference_jobs_add_max_duration/up.sql new file mode 100644 index 0000000000..923a90049d --- /dev/null +++ b/_migrations/2022-11-02-040731_alter_table_w2l_inference_jobs_add_max_duration/up.sql @@ -0,0 +1,7 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE w2l_inference_jobs + ADD COLUMN max_duration_seconds INTEGER NOT NULL DEFAULT 0 + AFTER creator_set_visibility; diff --git a/_migrations_squashed/tts_tables/tts_inference_jobs.sql b/_migrations_squashed/tts_tables/tts_inference_jobs.sql index d2c350a9f8..4b0490e3bb 100644 --- a/_migrations_squashed/tts_tables/tts_inference_jobs.sql +++ b/_migrations_squashed/tts_tables/tts_inference_jobs.sql @@ -65,6 +65,13 @@ CREATE TABLE tts_inference_jobs ( -- If true, the request gets routed to a special "debug" worker. is_debug_request BOOLEAN NOT NULL DEFAULT FALSE, + -- ========== PREMIUM FEATURES METADATA ========== + + -- The maximum duration for generated audio in seconds. + -- Zero is implied to be the default value, which is typically 12 seconds. + -- A negative value implies "unlimited". + max_duration_seconds INTEGER NOT NULL DEFAULT 0, + -- ========== JOB SYSTEM DETAILS ========== -- Jobs begin as "pending", then transition to other states. diff --git a/_migrations_squashed/user_tables/user_subscriptions.sql b/_migrations_squashed/user_tables/user_subscriptions.sql index fb92810ff7..5ab1572795 100644 --- a/_migrations_squashed/user_tables/user_subscriptions.sql +++ b/_migrations_squashed/user_tables/user_subscriptions.sql @@ -1,3 +1,6 @@ +-- NB: This is a manually squashed view of all the CREATE and ALTER statements, +-- with comments attached to the fields for centralized documentation. + -- noinspection SqlDialectInspectionForFile -- noinspection SqlNoDataSourceInspectionForFile -- noinspection SqlResolveForFile diff --git a/_migrations_squashed/w2l_tables/w2l_inference_jobs.sql b/_migrations_squashed/w2l_tables/w2l_inference_jobs.sql new file mode 100644 index 0000000000..f9cfa84384 --- /dev/null +++ b/_migrations_squashed/w2l_tables/w2l_inference_jobs.sql @@ -0,0 +1,135 @@ +-- NB: This is a manually squashed view of all the CREATE and ALTER statements, +-- with comments attached to the fields for centralized documentation. + +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +CREATE TABLE w2l_inference_jobs ( + id BIGINT(20) NOT NULL AUTO_INCREMENT, + + -- Effective "primary key" (PUBLIC) + -- This is so the in-progress results can be looked up by the UI. + token VARCHAR(32) NOT NULL, + + -- Idempotency token from client + -- This is so the frontend client doesn't submit duplicate jobs. + uuid_idempotency_token VARCHAR(36) NOT NULL, + + -- ========== SUCCESS CASE ========== + + -- If the job completes successfully, this is the result token. + -- This is only populated on a successful result. + on_success_result_token VARCHAR(32) DEFAULT NULL, + + -- ========== INFERENCE DETAILS : FACE TEMPLATE ========== + + -- The W2L template to use + -- Can be an image or video. + -- This is null if we're using a custom uploaded image. + maybe_w2l_template_token VARCHAR(32) DEFAULT NULL, + + -- If we're using a custom uploaded image, this will be present. + -- NOTE: This may never be supported. + maybe_public_image_bucket_location VARCHAR(255) DEFAULT NULL, + + -- ========== INFERENCE DETAILS : AUDIO SOURCE ========== + + -- If we're using TTS results, this will be present + maybe_tts_inference_result_token VARCHAR(32) DEFAULT NULL, + + -- If we're using custom uploaded audio, this will be present. + maybe_public_audio_bucket_hash VARCHAR(64) DEFAULT NULL, + maybe_public_audio_bucket_location VARCHAR(255) DEFAULT NULL, + + -- The filename that was used at upload time (if available) + maybe_original_audio_filename CHAR(255) DEFAULT NULL, + + -- Where the audio file was originally downloaded (if it was downloaded) + maybe_original_audio_download_url VARCHAR(512) DEFAULT NULL, + + maybe_audio_mime_type VARCHAR(32) DEFAULT NULL, + + -- ========== CREATOR DETAILS AND PREFERENCES ========== + + -- Foreign key to user + -- If no user is logged in, this is null. + maybe_creator_user_token VARCHAR(32) DEFAULT NULL, + + -- Based on a cookie sent by the frontend. + -- We'll save this even if the user is logged in. + maybe_creator_anonymous_visitor_token VARCHAR(32) DEFAULT NULL, + + -- For abuse tracking. + -- Wide enough for IPv4/6 + creator_ip_address VARCHAR(40) NOT NULL, + + -- (THIS MIGHT NOT BE USED) + -- NB: DO NOT SORT! + -- THIS MUST MATCH THE RESPECTIVE JOBS TABLE. + creator_set_visibility ENUM( + 'public', + 'hidden', + 'private' + ) NOT NULL DEFAULT 'public', + + -- ========== PREMIUM FEATURES METADATA ========== + + -- The maximum duration for generated audio in seconds. + -- Zero is implied to be the default value, which is typically 12 seconds. + -- A negative value implies "unlimited". + max_duration_seconds INTEGER NOT NULL DEFAULT 0, + + -- Flags to disable branding + disable_end_bump BOOL NOT NULL DEFAULT false, + disable_watermark BOOL NOT NULL DEFAULT false, + + -- ========== JOB SYSTEM DETAILS ========== + + -- Jobs begin as "pending", then transition to other states. + -- + -- * Pending = job is ready to go + -- * Started = job is running + -- * Complete_Success = job is done (success) + -- * Complete_Failure = job is done (failure) + -- * Attempt_Failed = job failed but may retry. + -- * Dead = job failed permanently. + -- + -- Pending -> Started -> Complete_Success + -- |-> Complete_Failure + -- \-> Attempt_Failed -> Started -> { Complete, Failed, Dead } + status ENUM( + 'pending', + 'started', + 'complete_success', + 'complete_failure', + 'attempt_failed', + 'dead') NOT NULL DEFAULT 'pending', + + -- We can track this against a "max_attempt_count" + attempt_count INT(3) NOT NULL DEFAULT 0, + + -- If there is a failure, tell the user why. + failure_reason VARCHAR(512) DEFAULT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- Failed jobs will set a next attempt time. + -- Subsequent tries can increase the timeout. + -- Failures because of permissions require human intervention => [retry_at=null]. + -- Failures because of invalid files are dead => [status=dead]. + retry_at TIMESTAMP NULL, + + -- INDICES -- + PRIMARY KEY (id), + UNIQUE KEY (token), + UNIQUE KEY (uuid_idempotency_token), + KEY fk_on_success_result_token (on_success_result_token), + KEY fk_maybe_w2l_template_token (maybe_w2l_template_token), + KEY fk_maybe_tts_inference_result_token (maybe_tts_inference_result_token), + KEY fk_maybe_creator_user_token (maybe_creator_user_token), + KEY index_status (status), + KEY index_creator_ip_address (creator_ip_address) + +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; diff --git a/crates/component_subsystem/users/src/utils/user_session_extended.rs b/crates/component_subsystem/users/src/utils/user_session_extended.rs index 93f9157058..821d416d96 100644 --- a/crates/component_subsystem/users/src/utils/user_session_extended.rs +++ b/crates/component_subsystem/users/src/utils/user_session_extended.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use database_queries::column_types::record_visibility::RecordVisibility; -#[derive(Clone)] +#[derive(Clone, Default)] pub struct UserSessionExtended { pub user_token: String, pub user: UserSessionUserDetails, @@ -10,7 +10,7 @@ pub struct UserSessionExtended { pub role: UserSessionRoleAndPermissions, } -#[derive(Clone)] +#[derive(Clone, Default)] pub struct UserSessionUserDetails { pub username: String, pub display_name: String, @@ -19,7 +19,7 @@ pub struct UserSessionUserDetails { pub email_gravatar_hash: String, } -#[derive(Clone)] +#[derive(Clone, Default)] pub struct UserSessionPremiumPlanInfo { pub maybe_stripe_customer_id: Option, pub maybe_loyalty_program_key: Option, @@ -41,7 +41,16 @@ pub struct UserSessionSubscriptionPlan { pub subscription_expires_at: DateTime, } -#[derive(Clone)] +impl Default for UserSessionSubscriptionPlan { + fn default() -> Self { + Self { + subscription_expires_at: chrono::MIN_DATETIME, + ..Default::default() + } + } +} + +#[derive(Clone, Default)] pub struct UserSessionPreferences { pub disable_gravatar: bool, pub auto_play_audio_preference: Option, @@ -50,7 +59,7 @@ pub struct UserSessionPreferences { pub auto_play_video_preference: Option, } -#[derive(Clone)] +#[derive(Clone, Default)] pub struct UserSessionRoleAndPermissions { // ===== ROLE ===== // pub user_role_slug: String, diff --git a/crates/lib/database_queries/src/column_types/record_visibility.rs b/crates/lib/database_queries/src/column_types/record_visibility.rs index 3f1ee15637..2b647db433 100644 --- a/crates/lib/database_queries/src/column_types/record_visibility.rs +++ b/crates/lib/database_queries/src/column_types/record_visibility.rs @@ -15,6 +15,10 @@ pub enum RecordVisibility { Private, } +impl Default for RecordVisibility { + fn default() -> Self { Self::Public } +} + impl RecordVisibility { pub fn to_str(&self) -> &'static str { match self { diff --git a/crates/lib/database_queries/src/queries/tts/tts_inference_jobs/insert_tts_inference_job.rs b/crates/lib/database_queries/src/queries/tts/tts_inference_jobs/insert_tts_inference_job.rs index ff95681774..34213a9f83 100644 --- a/crates/lib/database_queries/src/queries/tts/tts_inference_jobs/insert_tts_inference_job.rs +++ b/crates/lib/database_queries/src/queries/tts/tts_inference_jobs/insert_tts_inference_job.rs @@ -21,7 +21,14 @@ pub struct TtsInferenceJobInsertBuilder { creator_set_visibility: Option, is_from_api: bool, is_for_twitch: bool, + is_debug_request: bool, // Route the request to a special host with this flag priority_level: u8, // Priority is 0 by default and optionally increases + + /// Premium feature controlling TTS max duration. + /// - Negative values imply unlimited duration + /// - 0 is "use default" (typically 12 seconds) + /// - Positive values are that value in seconds up to some max. + max_duration_seconds: i32, } impl TtsInferenceJobInsertBuilder { @@ -38,7 +45,9 @@ impl TtsInferenceJobInsertBuilder { creator_set_visibility: None, is_from_api: false, is_for_twitch: false, + is_debug_request: false, priority_level: 0, + max_duration_seconds: 0, } } @@ -57,7 +66,9 @@ impl TtsInferenceJobInsertBuilder { creator_set_visibility: Some("hidden".to_string()), is_from_api: false, is_for_twitch: false, + is_debug_request: false, priority_level: 0, + max_duration_seconds: 0, } } @@ -106,11 +117,21 @@ impl TtsInferenceJobInsertBuilder { self } + pub fn set_is_debug_request(mut self, value: bool) -> Self { + self.is_debug_request = value; + self + } + pub fn set_priority_level(mut self, value: u8) -> Self { self.priority_level = value; self } + pub fn set_max_duration_seconds(mut self, value: i32) -> Self { + self.max_duration_seconds = value; + self + } + pub async fn insert(&mut self, mysql_pool: &MySqlPool) -> AnyhowResult<()> { // TODO: These should be custom error types with a custom macro to make this easy. let job_token = self.job_token @@ -150,7 +171,9 @@ SET creator_set_visibility = ?, is_from_api = ?, is_for_twitch = ?, + is_debug_request = ?, priority_level = ?, + max_duration_seconds = ?, status = "pending" "#, job_token, @@ -162,7 +185,9 @@ SET creator_set_visibility, self.is_from_api, self.is_for_twitch, + self.is_debug_request, self.priority_level, + self.max_duration_seconds, ); let query_result = query.execute(mysql_pool) @@ -173,7 +198,7 @@ SET res.last_insert_id() }, Err(err) => { - return Err(anyhow!("Twitch pubsub bits insert DB error: {:?}", err)); + return Err(anyhow!("MySQL tts_inference_job record insert error: {:?}", err)); } }; diff --git a/crates/service/storyteller_web/src/configs/plans/get_correct_plan_for_session.rs b/crates/service/storyteller_web/src/configs/plans/get_correct_plan_for_session.rs new file mode 100644 index 0000000000..d7ce545492 --- /dev/null +++ b/crates/service/storyteller_web/src/configs/plans/get_correct_plan_for_session.rs @@ -0,0 +1,254 @@ +use std::ops::Deref; +use chrono::Utc; +use crate::configs::plans::plan::Plan; +use crate::configs::plans::plan_list::{DEVELOPMENT_PREMIUM_PLANS, DEVELOPMENT_PREMIUM_PLANS_BY_SLUG, FREE_LOGGED_IN_PLAN, FREE_LOGGED_OUT_PLAN, LOYALTY_PLANS_BY_SLUG, PRODUCTION_PREMIUM_PLANS, PRODUCTION_PREMIUM_PLANS_BY_SLUG}; +use url_config::server_environment::ServerEnvironment; +use users_component::utils::user_session_extended::{UserSessionExtended, UserSessionSubscriptionPlan}; + +/// Look up the most appropriate plan for the session. +/// This will probably grow to include a lot of factors. +pub fn get_correct_plan_for_session( + server_environment: ServerEnvironment, + maybe_user_session: Option<&UserSessionExtended>, +) -> Plan { + + let user_session = match maybe_user_session { + None => { + return FREE_LOGGED_OUT_PLAN.clone(); + }, + Some(user_session) => user_session, + }; + + premium_plan_if_available(server_environment, user_session) + .or_else(|| loyalty_plan_if_available(user_session)) + .unwrap_or(FREE_LOGGED_IN_PLAN.clone()) +} + +fn premium_plan_if_available( + server_environment: ServerEnvironment, + user_session: &UserSessionExtended, +) -> Option { + + let now = Utc::now(); + + let plan_set = match server_environment { + ServerEnvironment::Development => &DEVELOPMENT_PREMIUM_PLANS_BY_SLUG, + ServerEnvironment::Production => &PRODUCTION_PREMIUM_PLANS_BY_SLUG, + }; + + let applicable_plans = user_session.premium.subscription_plans + .iter() + .filter(|plan| plan.subscription_expires_at.gt(&now)) + .filter_map(|plan| { + plan_set.get(&plan.subscription_product_slug) + }) + .collect::>(); + + // TODO: For now it's only possible for users to have one plan, so we return the first match. + // We may need to revisit this if we add additional plans in the future. + applicable_plans.get(0).map(|plan| (*plan).clone()) +} + +fn loyalty_plan_if_available( + user_session: &UserSessionExtended, +) -> Option { + user_session.premium.maybe_loyalty_program_key + .as_deref() + .and_then(|loyalty_key| LOYALTY_PLANS_BY_SLUG.get(loyalty_key)) + .map(|plan| plan.clone()) +} + +#[cfg(test)] +pub mod tests { + use std::ops::{Add, Sub}; + use chrono::{Duration, Utc}; + use crate::configs::plans::get_correct_plan_for_session::get_correct_plan_for_session; + use crate::configs::plans::plan_list::{ALL_PLANS_BY_SLUG, FREE_LOGGED_IN_PLAN, FREE_LOGGED_OUT_PLAN}; + use url_config::server_environment::ServerEnvironment; + use users_component::utils::user_session_extended::{UserSessionExtended, UserSessionSubscriptionPlan}; + + #[test] + fn test_free_logged_out_plan() { + let plan = get_correct_plan_for_session(ServerEnvironment::Development, None); + assert_eq!(plan, *FREE_LOGGED_OUT_PLAN); + + let plan = get_correct_plan_for_session(ServerEnvironment::Production, None); + assert_eq!(plan, *FREE_LOGGED_OUT_PLAN); + } + + #[test] + fn test_free_logged_in_plan() { + let mut user_session = UserSessionExtended::default(); + + user_session.premium.subscription_plans = vec![]; + user_session.premium.maybe_loyalty_program_key = None; + + let plan = get_correct_plan_for_session( + ServerEnvironment::Development, + Some(&user_session)); + + assert_eq!(plan, *FREE_LOGGED_IN_PLAN); + + let plan = get_correct_plan_for_session( + ServerEnvironment::Production, + Some(&user_session)); + + assert_eq!(plan, *FREE_LOGGED_IN_PLAN); + } + + #[test] + fn test_loyalty_plan() { + let mut user_session = UserSessionExtended::default(); + + user_session.premium.subscription_plans = vec![]; + user_session.premium.maybe_loyalty_program_key = Some("loyalty_plan".to_string()); + + let plan = get_correct_plan_for_session( + ServerEnvironment::Development, + Some(&user_session)); + + assert_eq!(&plan, ALL_PLANS_BY_SLUG.get("loyalty_plan").unwrap()); + + let plan = get_correct_plan_for_session( + ServerEnvironment::Production, + Some(&user_session)); + + assert_eq!(&plan, ALL_PLANS_BY_SLUG.get("loyalty_plan").unwrap()); + } + + #[test] + fn test_premium_plan_valid_for_production() { + let mut user_session = UserSessionExtended::default(); + + let future_expiry = Utc::now().add(Duration::days(30)); + + user_session.premium.maybe_loyalty_program_key = None; + + user_session.premium.subscription_plans = vec![ + UserSessionSubscriptionPlan { + subscription_namespace: "fakeyou".to_string(), + subscription_product_slug: "fakeyou_en_plus".to_string(), + subscription_expires_at: future_expiry, + } + ]; + + let plan = get_correct_plan_for_session( + ServerEnvironment::Development, + Some(&user_session)); + + // NB: The user has a "production" plan, which won't show up in development + assert_eq!(&plan, ALL_PLANS_BY_SLUG.get("free_logged_in").unwrap()); + + let plan = get_correct_plan_for_session( + ServerEnvironment::Production, + Some(&user_session)); + + // In production, however, we see the correct plan + assert_eq!(&plan, ALL_PLANS_BY_SLUG.get("fakeyou_en_plus").unwrap()); + } + + #[test] + fn test_premium_plan_valid_for_development() { + let mut user_session = UserSessionExtended::default(); + + let future_expiry = Utc::now().add(Duration::days(30)); + + user_session.premium.maybe_loyalty_program_key = None; + + user_session.premium.subscription_plans = vec![ + UserSessionSubscriptionPlan { + subscription_namespace: "fakeyou".to_string(), + subscription_product_slug: "development_fakeyou_en_plus".to_string(), + subscription_expires_at: future_expiry, + } + ]; + + let plan = get_correct_plan_for_session( + ServerEnvironment::Development, + Some(&user_session)); + + // NB: In development we see the correct plan + assert_eq!(&plan, ALL_PLANS_BY_SLUG.get("development_fakeyou_en_plus").unwrap()); + + let plan = get_correct_plan_for_session( + ServerEnvironment::Production, + Some(&user_session)); + + // NB: The user has a "development" plan, which won't show up in production + assert_eq!(&plan, ALL_PLANS_BY_SLUG.get("free_logged_in").unwrap()); + } + + #[test] + fn test_expired_plan_no_longer_returned() { + let mut user_session = UserSessionExtended::default(); + + let already_expired = Utc::now().sub(Duration::days(30)); + + user_session.premium.maybe_loyalty_program_key = None; + + user_session.premium.subscription_plans = vec![ + UserSessionSubscriptionPlan { + subscription_namespace: "fakeyou".to_string(), + subscription_product_slug: "fakeyou_en_plus".to_string(), + subscription_expires_at: already_expired, + } + ]; + + let plan = get_correct_plan_for_session( + ServerEnvironment::Development, + Some(&user_session)); + + // Premium plan is already expired + assert_eq!(&plan, ALL_PLANS_BY_SLUG.get("free_logged_in").unwrap()); + + let plan = get_correct_plan_for_session( + ServerEnvironment::Production, + Some(&user_session)); + + // Premium plan is already expired + assert_eq!(&plan, ALL_PLANS_BY_SLUG.get("free_logged_in").unwrap()); + + // NB: Just to show that it goes back to premium with a expiry in the future + let future_expiry = Utc::now().add(Duration::days(30)); + user_session.premium.subscription_plans.get_mut(0).unwrap().subscription_expires_at = future_expiry; + + let plan = get_correct_plan_for_session( + ServerEnvironment::Production, + Some(&user_session)); + + // NB: Now it isn't expired! + assert_eq!(&plan, ALL_PLANS_BY_SLUG.get("fakeyou_en_plus").unwrap()); + } + + #[test] + fn test_premium_plan_preferred_over_loyalty_plan() { + let mut user_session = UserSessionExtended::default(); + + let future_expiry = Utc::now().add(Duration::days(30)); + + user_session.premium.maybe_loyalty_program_key = Some("loyalty_plan".to_string()); + + user_session.premium.subscription_plans = vec![ + UserSessionSubscriptionPlan { + subscription_namespace: "fakeyou".to_string(), + subscription_product_slug: "fakeyou_en_plus".to_string(), + subscription_expires_at: future_expiry, + } + ]; + + let plan = get_correct_plan_for_session( + ServerEnvironment::Production, + Some(&user_session)); + + // In production we see the premium plan + assert_eq!(&plan, ALL_PLANS_BY_SLUG.get("fakeyou_en_plus").unwrap()); + + let plan = get_correct_plan_for_session( + ServerEnvironment::Development, + Some(&user_session)); + + // NB: The user has a "production" plan, and since this is invalid in development, + // we see the loyalty plan instead. + assert_eq!(&plan, ALL_PLANS_BY_SLUG.get("loyalty_plan").unwrap()); + } +} diff --git a/crates/service/storyteller_web/src/configs/plans/mod.rs b/crates/service/storyteller_web/src/configs/plans/mod.rs index 8d1ffe80d1..b5ae37c899 100644 --- a/crates/service/storyteller_web/src/configs/plans/mod.rs +++ b/crates/service/storyteller_web/src/configs/plans/mod.rs @@ -1,3 +1,4 @@ +pub mod get_correct_plan_for_session; pub mod plan; pub mod plan_category; pub mod plan_list; diff --git a/crates/service/storyteller_web/src/configs/plans/plan.rs b/crates/service/storyteller_web/src/configs/plans/plan.rs index 61bd3e689e..2eb80e3d78 100644 --- a/crates/service/storyteller_web/src/configs/plans/plan.rs +++ b/crates/service/storyteller_web/src/configs/plans/plan.rs @@ -14,7 +14,7 @@ const W2L_DEFAULT_TIME_LIMIT_SECONDS : i64 = 20; /// A Plan is either a free or premium plan. /// Each plan corresponds to a certain level of service. -#[derive(Clone, Eq, PartialEq, Hash)] +#[derive(Clone, Eq, PartialEq, Hash, Debug)] pub struct Plan { /// Name of the plan in URL-friendly "slug" form (human readable, no spaces, underscores), /// eg. "plan_1", "en_basic", etc. @@ -37,6 +37,9 @@ pub struct Plan { /// Whether this plan is only active in development is_development_plan: bool, + /// Whether this plan does not exist in Stripe and only exists as defined by this server. + is_synthetic_plan: bool, + // ========== Features for TTS ========== tts_base_priority_level: u8, @@ -70,6 +73,7 @@ impl Plan { stripe_product_id: builder.stripe_product_id.clone(), stripe_price_id: builder.stripe_price_id.clone(), is_development_plan: builder.is_development_plan, + is_synthetic_plan: builder.is_synthetic_plan, tts_base_priority_level: builder.tts_base_priority_level, tts_max_duration: builder.tts_max_duration, tts_can_generate_mp3: builder.tts_can_generate_mp3, @@ -101,10 +105,22 @@ impl Plan { self.stripe_price_id.as_deref() } + /// The plan is only valid in "development" Stripe. + /// These plans should never be used on the production cluster. pub fn is_development_plan(&self) -> bool { self.is_development_plan } + /// The plan is a valid Stripe plan in production (not test/development). + pub fn is_production_plan(&self) -> bool { + !self.is_development_plan + } + + /// The plan is "synthetic", ie. does not exist in Stripe. + pub fn is_synthetic_plan(&self) -> bool { + self.is_synthetic_plan + } + pub fn tts_base_priority_level(&self) -> u8 { self.tts_base_priority_level } @@ -112,6 +128,10 @@ impl Plan { pub fn tts_max_duration(&self) -> Duration { self.tts_max_duration } + + pub fn tts_max_duration_seconds(&self) -> i32 { + self.tts_max_duration.num_seconds() as i32 + } } /// Builder pattern for Plans. @@ -123,6 +143,7 @@ pub struct PlanBuilder { stripe_product_id: Option, stripe_price_id: Option, is_development_plan: bool, + is_synthetic_plan: bool, // ========== Features for TTS ========== @@ -158,6 +179,7 @@ impl PlanBuilder { stripe_product_id: None, stripe_price_id: None, is_development_plan: false, + is_synthetic_plan: false, // TTS tts_base_priority_level : TTS_DEFAULT_PRIORITY_LEVEL, @@ -208,6 +230,11 @@ impl PlanBuilder { self } + pub fn is_synthetic_plan(mut self, value: bool) -> Self { + self.is_synthetic_plan = value; + self + } + pub fn tts_base_priority_level(mut self, value: u8) -> Self { self.tts_base_priority_level = value; self diff --git a/crates/service/storyteller_web/src/configs/plans/plan_list.rs b/crates/service/storyteller_web/src/configs/plans/plan_list.rs index d705e5c406..fb3e80dfdb 100644 --- a/crates/service/storyteller_web/src/configs/plans/plan_list.rs +++ b/crates/service/storyteller_web/src/configs/plans/plan_list.rs @@ -9,6 +9,7 @@ use tts_common::priority::{FAKEYOU_ANONYMOUS_PRIORITY_LEVEL, FAKEYOU_LOGGED_IN_P /// This is declared at the top level because it may need to be referenced directly. pub static FREE_LOGGED_OUT_PLAN : Lazy = Lazy::new(|| { PlanBuilder::new("free_logged_out") + .is_synthetic_plan(true) .plan_category(PlanCategory::Free) .tts_base_priority_level(FAKEYOU_ANONYMOUS_PRIORITY_LEVEL) .build() @@ -18,6 +19,7 @@ pub static FREE_LOGGED_OUT_PLAN : Lazy = Lazy::new(|| { /// This is declared at the top level because it may need to be referenced directly. pub static FREE_LOGGED_OUT_FIRST_TRY_PLAN : Lazy = Lazy::new(|| { PlanBuilder::new("free_logged_out_first_try") + .is_synthetic_plan(true) .plan_category(PlanCategory::Free) .tts_base_priority_level(FAKEYOU_LOGGED_IN_PRIORITY_LEVEL) // NB: Same as logged-in free users. .build() @@ -27,24 +29,28 @@ pub static FREE_LOGGED_OUT_FIRST_TRY_PLAN : Lazy = Lazy::new(|| { /// This is declared at the top level because it may need to be referenced directly. pub static FREE_LOGGED_IN_PLAN : Lazy = Lazy::new(|| { PlanBuilder::new("free_logged_in") + .is_synthetic_plan(true) .plan_category(PlanCategory::Free) .tts_base_priority_level(FAKEYOU_LOGGED_IN_PRIORITY_LEVEL) .build() }); -/// This plan is for users that create models for us but don't pay. -/// This is declared at the top level because it may need to be referenced directly. -pub static LOYALTY_PLAN : Lazy = Lazy::new(|| { - PlanBuilder::new("loyalty_plan") +/// These plans are for users that create models for us but don't pay. +pub static LOYALTY_PLANS : Lazy> = Lazy::new(|| { + let mut plans = HashSet::new(); + + plans.insert(PlanBuilder::new("loyalty_plan") + .is_synthetic_plan(true) .plan_category(PlanCategory::LoyaltyReward) .tts_base_priority_level(2) .tts_max_duration_seconds(30) - .build() + .build()); + + plans }); -// TODO: Add by-[stripe-product] lookup HashSets. - -pub static DEVELOPMENT_PLANS : Lazy> = Lazy::new(|| { +/// These plans do not exist in production! +pub static DEVELOPMENT_PREMIUM_PLANS: Lazy> = Lazy::new(|| { let mut plans = HashSet::new(); // ========== English plans ========== @@ -72,8 +78,8 @@ pub static DEVELOPMENT_PLANS : Lazy> = Lazy::new(|| { plans }); -/// All premium / paid-for plans in the system. -pub static PREMIUM_PLANS : Lazy> = Lazy::new(|| { +/// All paid-for production premium plans in the system. +pub static PRODUCTION_PREMIUM_PLANS: Lazy> = Lazy::new(|| { let mut plans = HashSet::new(); // ========== English plans ========== @@ -111,6 +117,45 @@ pub static PREMIUM_PLANS : Lazy> = Lazy::new(|| { plans }); +/// Only loyalty premium plans (non-Stripe, non-PayPal, etc. - internally defined) +pub static LOYALTY_PLANS_BY_SLUG : Lazy> = Lazy::new(|| { + let mut plans = HashMap::new(); + + fn add_plan(plans: &mut HashMap, plan: &Plan) { + plans.insert(plan.plan_slug().to_string(), plan.clone()); + } + + LOYALTY_PLANS.iter().for_each(|plan| add_plan(&mut plans, plan)); + + plans +}); + +/// Only development premium plans +pub static DEVELOPMENT_PREMIUM_PLANS_BY_SLUG : Lazy> = Lazy::new(|| { + let mut plans = HashMap::new(); + + fn add_plan(plans: &mut HashMap, plan: &Plan) { + plans.insert(plan.plan_slug().to_string(), plan.clone()); + } + + DEVELOPMENT_PREMIUM_PLANS.iter().for_each(|plan| add_plan(&mut plans, plan)); + + plans +}); + +/// Only production premium plans +pub static PRODUCTION_PREMIUM_PLANS_BY_SLUG : Lazy> = Lazy::new(|| { + let mut plans = HashMap::new(); + + fn add_plan(plans: &mut HashMap, plan: &Plan) { + plans.insert(plan.plan_slug().to_string(), plan.clone()); + } + + PRODUCTION_PREMIUM_PLANS.iter().for_each(|plan| add_plan(&mut plans, plan)); + + plans +}); + /// Every single plan in the system: free, paid, loyalty-based, and Stripe development plans. pub static ALL_PLANS_BY_SLUG : Lazy> = Lazy::new(|| { let mut plans = HashMap::new(); @@ -122,10 +167,10 @@ pub static ALL_PLANS_BY_SLUG : Lazy> = Lazy::new(|| { add_plan(&mut plans, &FREE_LOGGED_OUT_PLAN); add_plan(&mut plans, &FREE_LOGGED_OUT_FIRST_TRY_PLAN); add_plan(&mut plans, &FREE_LOGGED_IN_PLAN); - add_plan(&mut plans, &LOYALTY_PLAN); - PREMIUM_PLANS.iter().for_each(|plan| add_plan(&mut plans, plan)); - DEVELOPMENT_PLANS .iter().for_each(|plan| add_plan(&mut plans, plan)); + LOYALTY_PLANS.iter().for_each(|plan| add_plan(&mut plans, plan)); + PRODUCTION_PREMIUM_PLANS.iter().for_each(|plan| add_plan(&mut plans, plan)); + DEVELOPMENT_PREMIUM_PLANS.iter().for_each(|plan| add_plan(&mut plans, plan)); plans }); @@ -158,13 +203,11 @@ pub static PLANS_BY_STRIPE_PRICE_ID : Lazy> = Lazy::new(|| plans }); -// TODO: Add tests to assert our expectations of each plan. - #[cfg(test)] mod test { use crate::configs::plans::plan::Plan; use crate::configs::plans::plan_category::PlanCategory; - use crate::configs::plans::plan_list::{ALL_PLANS_BY_SLUG, DEVELOPMENT_PLANS, PLANS_BY_STRIPE_PRICE_ID, PLANS_BY_STRIPE_PRODUCT_ID, PREMIUM_PLANS}; + use crate::configs::plans::plan_list::{ALL_PLANS_BY_SLUG, DEVELOPMENT_PREMIUM_PLANS, DEVELOPMENT_PREMIUM_PLANS_BY_SLUG, LOYALTY_PLANS, LOYALTY_PLANS_BY_SLUG, PLANS_BY_STRIPE_PRICE_ID, PLANS_BY_STRIPE_PRODUCT_ID, PRODUCTION_PREMIUM_PLANS, PRODUCTION_PREMIUM_PLANS_BY_SLUG}; use speculoos::prelude::*; // NB: We're being extremely careful in this test and all those that follow, essentially @@ -175,23 +218,41 @@ mod test { } #[test] - fn test_number_of_production_plans_is_expected() { + fn test_number_of_stripe_production_plans_is_expected() { let production_plans = ALL_PLANS_BY_SLUG.values() .filter(|plan| !plan.is_development_plan()) + .filter(|plan| !plan.is_synthetic_plan()) // NB: Synthetic plans are production! .collect::>(); - assert_eq!(8, production_plans.len()); + let expected = 4; + + assert_eq!(expected, production_plans.len()); + assert_eq!(expected, PRODUCTION_PREMIUM_PLANS.len()); + assert_eq!(expected, PRODUCTION_PREMIUM_PLANS_BY_SLUG.len()); } #[test] - fn test_number_of_development_plans_is_expected() { + fn test_number_of_stripe_development_plans_is_expected() { let development_plans = ALL_PLANS_BY_SLUG.values() .filter(|plan| plan.is_development_plan()) .collect::>(); - assert_eq!(2, development_plans.len()); + let expected = 2; + + assert_eq!(expected, development_plans.len()); + assert_eq!(expected, DEVELOPMENT_PREMIUM_PLANS.len()); + assert_eq!(expected, DEVELOPMENT_PREMIUM_PLANS_BY_SLUG.len()); } + #[test] + fn test_number_of_loyalty_plans_is_expected() { + let expected = 1; + + assert_eq!(expected, LOYALTY_PLANS.len()); + assert_eq!(expected, LOYALTY_PLANS_BY_SLUG.len()); + } + + #[test] fn test_plans_by_stripe_product_id_are_expected() { let product_ids = PLANS_BY_STRIPE_PRODUCT_ID.keys() @@ -256,65 +317,92 @@ mod test { assert_eq!(120, ALL_PLANS_BY_SLUG.get("fakeyou_en_pro").unwrap().tts_max_duration().num_seconds()); } - // The following tests should not need to change much + // =================== THE FOLLOWING TESTS SHOULD NOT NEED TO CHANGE MUCH =================== // #[test] - fn test_assert_all_premium_plans_are_paid() { - let mut test_count = 0; - - PREMIUM_PLANS.iter().for_each(|plan| { + fn test_assert_all_production_stripe_plans_are_paid() { + PRODUCTION_PREMIUM_PLANS.iter().for_each(|plan| { assert_eq!(plan.plan_category(), PlanCategory::Paid); - test_count += 1; }); + PRODUCTION_PREMIUM_PLANS_BY_SLUG.iter().for_each(|(_, plan)| { + assert_eq!(plan.plan_category(), PlanCategory::Paid); + }); + } - assert!(test_count > 1); + #[test] + fn test_assert_all_production_stripe_plans_are_non_synthetic() { + PRODUCTION_PREMIUM_PLANS.iter().for_each(|plan| { + assert!(!plan.is_synthetic_plan()); + }); + PRODUCTION_PREMIUM_PLANS_BY_SLUG.iter().for_each(|(_, plan)| { + assert!(!plan.is_synthetic_plan()); + }); } #[test] fn test_assert_all_development_stripe_plans_are_marked_development() { - let mut test_count = 0; - - DEVELOPMENT_PLANS.iter().for_each(|plan| { + DEVELOPMENT_PREMIUM_PLANS.iter().for_each(|plan| { + assert!(plan.is_development_plan()); + }); + DEVELOPMENT_PREMIUM_PLANS_BY_SLUG.iter().for_each(|(_, plan)| { assert!(plan.is_development_plan()); - test_count += 1; }); - - assert!(test_count > 1); } #[test] - fn test_assert_correct_key_for_plans_indexed_by_slug() { - let mut test_count = 0; + fn test_assert_all_development_stripe_plans_are_non_synthetic() { + DEVELOPMENT_PREMIUM_PLANS.iter().for_each(|plan| { + assert!(!plan.is_synthetic_plan()); + }); + DEVELOPMENT_PREMIUM_PLANS_BY_SLUG.iter().for_each(|(_, plan)| { + assert!(!plan.is_synthetic_plan()); + }); + } + #[test] + fn test_assert_all_loyalty_plans_are_synthetic() { + LOYALTY_PLANS.iter().for_each(|plan| { + assert!(plan.is_synthetic_plan()); + }); + } + + #[test] + fn test_assert_correct_key_used_for_plans_indexed_by_slug() { ALL_PLANS_BY_SLUG.iter().for_each(|(slug, plan)| { assert_eq!(slug, plan.plan_slug()); - test_count += 1; }); - - assert!(test_count > 1); + PRODUCTION_PREMIUM_PLANS_BY_SLUG.iter().for_each(|(slug, plan)| { + assert_eq!(slug, plan.plan_slug()); + }); + DEVELOPMENT_PREMIUM_PLANS_BY_SLUG.iter().for_each(|(slug, plan)| { + assert_eq!(slug, plan.plan_slug()); + }); + LOYALTY_PLANS_BY_SLUG.iter().for_each(|(slug, plan)| { + assert_eq!(slug, plan.plan_slug()); + }); } #[test] fn test_assert_correct_key_for_plans_indexed_by_product_id() { - let mut test_count = 0; - PLANS_BY_STRIPE_PRODUCT_ID.iter().for_each(|(product_id, plan)| { assert_eq!(product_id, plan.stripe_product_id().unwrap()); - test_count += 1; }); - - assert!(test_count > 1); } #[test] fn test_assert_correct_key_for_plans_indexed_by_price_id() { - let mut test_count = 0; - PLANS_BY_STRIPE_PRICE_ID.iter().for_each(|(price_id, plan)| { assert_eq!(price_id, plan.stripe_price_id().unwrap()); - test_count += 1; }); + } - assert!(test_count > 1); + #[test] + fn test_assert_stripe_plans_are_non_synthetic() { + PLANS_BY_STRIPE_PRICE_ID.iter().for_each(|(price_id, plan)| { + assert!(!plan.is_synthetic_plan()); + }); + PLANS_BY_STRIPE_PRODUCT_ID.iter().for_each(|(price_id, plan)| { + assert!(!plan.is_synthetic_plan()); + }); } } diff --git a/crates/service/storyteller_web/src/http_server/endpoints/tts/enqueue_infer_tts.rs b/crates/service/storyteller_web/src/http_server/endpoints/tts/enqueue_infer_tts.rs index 44c0f2535d..53a4523622 100644 --- a/crates/service/storyteller_web/src/http_server/endpoints/tts/enqueue_infer_tts.rs +++ b/crates/service/storyteller_web/src/http_server/endpoints/tts/enqueue_infer_tts.rs @@ -10,6 +10,7 @@ use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; use crate::server_state::ServerState; use crate::validations::model_uploads::validate_model_title; use database_queries::column_types::record_visibility::RecordVisibility; +use database_queries::queries::tts::tts_inference_jobs::insert_tts_inference_job::TtsInferenceJobInsertBuilder; use database_queries::tokens::Tokens; use http_server_common::request::get_request_api_token::get_request_api_token; use http_server_common::request::get_request_header_optional::get_request_header_optional; @@ -18,13 +19,11 @@ use log::{info, warn, log}; use r2d2_redis::redis::Commands; use redis_common::redis_keys::RedisKeys; use regex::Regex; -use sqlx::error::DatabaseError; -use sqlx::error::Error::Database; -use sqlx::mysql::MySqlDatabaseError; use std::fmt; use std::sync::Arc; use tts_common::priority::{FAKEYOU_LOGGED_IN_PRIORITY_LEVEL, FAKEYOU_ANONYMOUS_PRIORITY_LEVEL, FAKEYOU_INVESTOR_PRIORITY_LEVEL, FAKEYOU_DEFAULT_VALID_API_TOKEN_PRIORITY_LEVEL}; use user_input_common::check_for_slurs::contains_slurs; +use crate::configs::plans::get_correct_plan_for_session::get_correct_plan_for_session; // TODO: Temporary for investor demo const STORYTELLER_DEMO_COOKIE_NAME : &'static str = "storyteller_demo"; @@ -92,15 +91,23 @@ pub async fn infer_tts_handler( { let mut is_from_api = false; let mut maybe_user_token : Option = None; - let mut priority_level = FAKEYOU_ANONYMOUS_PRIORITY_LEVEL; + let mut priority_level ; let mut use_high_priority_rate_limiter = false; // NB: Careful! let mut disable_rate_limiter = false; // NB: Careful! + let mut mysql_connection = server_state.mysql_pool + .acquire() + .await + .map_err(|err| { + warn!("MySql pool error: {:?}", err); + InferTtsError::ServerError + })?; + // ==================== USER SESSION ==================== // let maybe_user_session = server_state .session_checker - .maybe_get_user_session(&http_request, &server_state.mysql_pool) + .maybe_get_user_session_extended_from_connection(&http_request, &mut mysql_connection) .await .map_err(|e| { warn!("Session checker error: {:?}", e); @@ -109,9 +116,15 @@ pub async fn infer_tts_handler( if let Some(user_session) = maybe_user_session.as_ref() { maybe_user_token = Some(user_session.user_token.to_string()); - priority_level = FAKEYOU_LOGGED_IN_PRIORITY_LEVEL; // Give logged in users execution priority. } + // TODO: Plan should handle "first anonymous use" and "investor" cases. + let plan = get_correct_plan_for_session( + server_state.server_environment, + maybe_user_session.as_ref()); + + priority_level = plan.tts_base_priority_level(); + // ==================== API TOKENS ==================== // if let Some(api_token) = get_request_api_token(&http_request) { @@ -171,7 +184,7 @@ pub async fn infer_tts_handler( let mut rate_limiter = match maybe_user_session { None => &server_state.redis_rate_limiters.logged_out, Some(ref user) => { - if user.is_banned { + if user.role.is_banned { return Err(InferTtsError::NotAuthorized); } &server_state.redis_rate_limiters.logged_in @@ -227,7 +240,7 @@ pub async fn infer_tts_handler( let maybe_user_preferred_visibility : Option = maybe_user_session .as_ref() - .map(|user_session| user_session.preferred_tts_result_visibility); + .map(|user_session| user_session.preferences.preferred_tts_result_visibility); let set_visibility = request.creator_set_visibility .or(maybe_user_preferred_visibility) @@ -240,69 +253,30 @@ pub async fn infer_tts_handler( InferTtsError::ServerError })?; - info!("Creating w2l inference job record..."); + info!("Creating tts inference job record..."); - let query_result = sqlx::query!( - r#" -INSERT INTO tts_inference_jobs -SET - token = ?, - uuid_idempotency_token = ?, + let query_result = TtsInferenceJobInsertBuilder::new_for_fakeyou_request() + .set_job_token(&job_token) + .set_uuid_idempotency_token(&request.uuid_idempotency_token) + .set_model_token(&model_token) + .set_raw_inference_text(&inference_text) + .set_maybe_creator_user_token(maybe_user_token.as_deref()) + .set_creator_ip_address(&ip_address) + .set_creator_set_visibility(set_visibility.to_str()) + .set_priority_level(priority_level) + .set_max_duration_seconds(plan.tts_max_duration_seconds()) + .set_is_from_api(is_from_api) + .set_is_debug_request(is_debug_request) + .insert(&server_state.mysql_pool) + .await; - model_token = ?, - raw_inference_text = ?, - maybe_creator_user_token = ?, - creator_ip_address = ?, - creator_set_visibility = ?, - priority_level = ?, - is_from_api = ?, - is_debug_request = ?, - status = "pending" - "#, - &job_token, - request.uuid_idempotency_token.clone(), - model_token, - inference_text, - maybe_user_token, - ip_address, - set_visibility.to_str(), - priority_level, - is_from_api, - is_debug_request, - ) - .execute(&server_state.mysql_pool) - .await; - - let record_id = match query_result { - Ok(res) => { - res.last_insert_id() - }, + match query_result { + Ok(_) => {}, Err(err) => { - warn!("New w2l inference job creation DB error: {:?}", err); - - // NB: SQLSTATE[23000]: Integrity constraint violation - // NB: MySQL Error Code 1062: Duplicate key insertion (this is harder to access) - match err { - Database(err) => { - let maybe_code = err.code().map(|c| c.into_owned()); - /*match maybe_code.as_deref() { - Some("23000") => { - if err.message().contains("username") { - return Err(UsernameTaken); - } else if err.message().contains("email_address") { - return Err(EmailTaken); - } - } - _ => {}, - }*/ - }, - _ => {}, - } + warn!("New tts inference job creation DB error: {:?}", err); return Err(InferTtsError::ServerError); } - }; - - info!("new w2l inference job id: {}", record_id); + } server_state.firehose_publisher.enqueue_tts_inference(maybe_user_token.as_deref(), &job_token, &model_token) .await diff --git a/crates/service/storyteller_web/src/main.rs b/crates/service/storyteller_web/src/main.rs index d7776e90cc..a31b3698c8 100644 --- a/crates/service/storyteller_web/src/main.rs +++ b/crates/service/storyteller_web/src/main.rs @@ -349,6 +349,7 @@ async fn main() -> AnyhowResult<()> { client: stripe_client, }, hostname: server_hostname, + server_environment, third_party_url_redirector, health_check_status, mysql_pool: pool, diff --git a/crates/service/storyteller_web/src/server_state.rs b/crates/service/storyteller_web/src/server_state.rs index e3d23fcb20..cf04bc7e43 100644 --- a/crates/service/storyteller_web/src/server_state.rs +++ b/crates/service/storyteller_web/src/server_state.rs @@ -14,6 +14,7 @@ use memory_caching::single_item_ttl_cache::SingleItemTtlCache; use r2d2_redis::{r2d2, RedisConnectionManager}; use sqlx::MySqlPool; use storage_buckets_common::bucket_client::BucketClient; +use url_config::server_environment::ServerEnvironment; use url_config::third_party_url_redirector::ThirdPartyUrlRedirector; use users_component::utils::session_checker::SessionChecker; use users_component::utils::session_cookie_manager::SessionCookieManager; @@ -27,6 +28,9 @@ pub struct ServerState { pub hostname: String, + /// Knowing if we're in production will allow us to turn off development-only functionalities. + pub server_environment: ServerEnvironment, + pub third_party_url_redirector: ThirdPartyUrlRedirector, pub health_check_status: HealthCheckStatus,