From ed6b41fe525f52902efec2022ff3ddabedbb3779 Mon Sep 17 00:00:00 2001 From: oabrivard Date: Sat, 21 Mar 2026 23:37:03 +0100 Subject: [PATCH] v2: add settings migration, model expansion, DB queries (provider, models, rate limits) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../20260321000010_add_settings_v2_fields.sql | 6 + backend/src/db/settings.rs | 37 +++- backend/src/models/settings.rs | 185 +++++++++++++----- backend/src/services/prompts.rs | 5 + 4 files changed, 183 insertions(+), 50 deletions(-) create mode 100644 backend/migrations/20260321000010_add_settings_v2_fields.sql diff --git a/backend/migrations/20260321000010_add_settings_v2_fields.sql b/backend/migrations/20260321000010_add_settings_v2_fields.sql new file mode 100644 index 0000000..7fac090 --- /dev/null +++ b/backend/migrations/20260321000010_add_settings_v2_fields.sql @@ -0,0 +1,6 @@ +ALTER TABLE settings + ADD COLUMN ai_provider VARCHAR(100) NOT NULL DEFAULT '', + ADD COLUMN ai_model VARCHAR(100) NOT NULL DEFAULT '', + ADD COLUMN ai_model_writing VARCHAR(100) NOT NULL DEFAULT '', + ADD COLUMN rate_limit_max_requests INTEGER, + ADD COLUMN rate_limit_time_window_seconds INTEGER; diff --git a/backend/src/db/settings.rs b/backend/src/db/settings.rs index 6b0c0db..8e9f7b5 100644 --- a/backend/src/db/settings.rs +++ b/backend/src/db/settings.rs @@ -18,6 +18,11 @@ struct SettingsRow { categories: serde_json::Value, max_items_per_category: i32, search_agent_behavior: String, + ai_provider: String, + ai_model: String, + ai_model_writing: String, + rate_limit_max_requests: Option, + rate_limit_time_window_seconds: Option, updated_at: chrono::DateTime, } @@ -36,6 +41,11 @@ impl TryFrom for UserSettings { categories, max_items_per_category: row.max_items_per_category, search_agent_behavior: row.search_agent_behavior, + ai_provider: row.ai_provider, + ai_model: row.ai_model, + ai_model_writing: row.ai_model_writing, + rate_limit_max_requests: row.rate_limit_max_requests, + rate_limit_time_window_seconds: row.rate_limit_time_window_seconds, updated_at: row.updated_at, }) } @@ -56,10 +66,10 @@ pub async fn get_or_create_default( let row = sqlx::query_as::<_, SettingsRow>( r#" - INSERT INTO settings (user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO settings (user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_writing, rate_limit_max_requests, rate_limit_time_window_seconds) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (user_id) DO UPDATE SET user_id = settings.user_id - RETURNING user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, updated_at + RETURNING user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_writing, rate_limit_max_requests, rate_limit_time_window_seconds, updated_at "#, ) .bind(user_id) @@ -68,6 +78,11 @@ pub async fn get_or_create_default( .bind(&categories_json) .bind(defaults.max_items_per_category) .bind(&defaults.search_agent_behavior) + .bind(&defaults.ai_provider) + .bind(&defaults.ai_model) + .bind(&defaults.ai_model_writing) + .bind(defaults.rate_limit_max_requests) + .bind(defaults.rate_limit_time_window_seconds) .fetch_one(pool) .await?; @@ -86,16 +101,21 @@ pub async fn upsert( let row = sqlx::query_as::<_, SettingsRow>( r#" - INSERT INTO settings (user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO settings (user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_writing, rate_limit_max_requests, rate_limit_time_window_seconds) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (user_id) DO UPDATE SET theme = EXCLUDED.theme, max_age_days = EXCLUDED.max_age_days, categories = EXCLUDED.categories, max_items_per_category = EXCLUDED.max_items_per_category, search_agent_behavior = EXCLUDED.search_agent_behavior, + ai_provider = EXCLUDED.ai_provider, + ai_model = EXCLUDED.ai_model, + ai_model_writing = EXCLUDED.ai_model_writing, + rate_limit_max_requests = EXCLUDED.rate_limit_max_requests, + rate_limit_time_window_seconds = EXCLUDED.rate_limit_time_window_seconds, updated_at = now() - RETURNING user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, updated_at + RETURNING user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_writing, rate_limit_max_requests, rate_limit_time_window_seconds, updated_at "#, ) .bind(user_id) @@ -104,6 +124,11 @@ pub async fn upsert( .bind(&categories_json) .bind(req.max_items_per_category) .bind(&req.search_agent_behavior) + .bind(&req.ai_provider) + .bind(&req.ai_model) + .bind(&req.ai_model_writing) + .bind(req.rate_limit_max_requests) + .bind(req.rate_limit_time_window_seconds) .fetch_one(pool) .await?; diff --git a/backend/src/models/settings.rs b/backend/src/models/settings.rs index 6b69408..dc7a5f2 100644 --- a/backend/src/models/settings.rs +++ b/backend/src/models/settings.rs @@ -13,6 +13,11 @@ pub struct UserSettings { pub categories: Vec, pub max_items_per_category: i32, pub search_agent_behavior: String, + pub ai_provider: String, + pub ai_model: String, + pub ai_model_writing: String, + pub rate_limit_max_requests: Option, + pub rate_limit_time_window_seconds: Option, pub updated_at: DateTime, } @@ -24,6 +29,11 @@ pub struct SettingsResponse { pub categories: Vec, pub max_items_per_category: i32, pub search_agent_behavior: String, + pub ai_provider: String, + pub ai_model: String, + pub ai_model_writing: String, + pub rate_limit_max_requests: Option, + pub rate_limit_time_window_seconds: Option, } impl From for SettingsResponse { @@ -34,6 +44,11 @@ impl From for SettingsResponse { categories: s.categories, max_items_per_category: s.max_items_per_category, search_agent_behavior: s.search_agent_behavior, + ai_provider: s.ai_provider, + ai_model: s.ai_model, + ai_model_writing: s.ai_model_writing, + rate_limit_max_requests: s.rate_limit_max_requests, + rate_limit_time_window_seconds: s.rate_limit_time_window_seconds, } } } @@ -46,6 +61,11 @@ pub struct UpdateSettingsRequest { pub categories: Vec, pub max_items_per_category: i32, pub search_agent_behavior: String, + pub ai_provider: String, + pub ai_model: String, + pub ai_model_writing: String, + pub rate_limit_max_requests: Option, + pub rate_limit_time_window_seconds: Option, } impl UpdateSettingsRequest { @@ -86,6 +106,25 @@ impl UpdateSettingsRequest { if self.search_agent_behavior.len() > 2000 { return Err("search_agent_behavior must be at most 2000 characters".into()); } + if self.ai_provider.len() > 100 { + return Err("ai_provider must be at most 100 characters".into()); + } + if self.ai_model.len() > 100 { + return Err("ai_model must be at most 100 characters".into()); + } + if self.ai_model_writing.len() > 100 { + return Err("ai_model_writing must be at most 100 characters".into()); + } + if let Some(max_req) = self.rate_limit_max_requests { + if max_req < 1 { + return Err("rate_limit_max_requests must be >= 1".into()); + } + } + if let Some(window) = self.rate_limit_time_window_seconds { + if window < 1 { + return Err("rate_limit_time_window_seconds must be >= 1".into()); + } + } Ok(()) } } @@ -106,6 +145,11 @@ impl Default for UserSettings { ], max_items_per_category: 4, search_agent_behavior: String::new(), + ai_provider: String::new(), + ai_model: String::new(), + ai_model_writing: String::new(), + rate_limit_max_requests: None, + rate_limit_time_window_seconds: None, updated_at: Utc::now(), } } @@ -115,15 +159,25 @@ impl Default for UserSettings { mod tests { use super::*; - #[test] - fn test_valid_settings() { - let req = UpdateSettingsRequest { + /// Helper to create a valid request with all new fields populated. + fn valid_request() -> UpdateSettingsRequest { + UpdateSettingsRequest { theme: "Intelligence Artificielle".into(), max_age_days: 7, categories: vec!["Category 1".into(), "Category 2".into()], max_items_per_category: 4, search_agent_behavior: String::new(), - }; + ai_provider: String::new(), + ai_model: String::new(), + ai_model_writing: String::new(), + rate_limit_max_requests: None, + rate_limit_time_window_seconds: None, + } + } + + #[test] + fn test_valid_settings() { + let req = valid_request(); assert!(req.validate().is_ok()); } @@ -131,10 +185,7 @@ mod tests { fn test_empty_theme() { let req = UpdateSettingsRequest { theme: " ".into(), - max_age_days: 7, - categories: vec!["Cat".into()], - max_items_per_category: 4, - search_agent_behavior: String::new(), + ..valid_request() }; let err = req.validate().unwrap_err(); assert!(err.contains("Theme")); @@ -144,10 +195,7 @@ mod tests { fn test_theme_too_long() { let req = UpdateSettingsRequest { theme: "a".repeat(201), - max_age_days: 7, - categories: vec!["Cat".into()], - max_items_per_category: 4, - search_agent_behavior: String::new(), + ..valid_request() }; assert!(req.validate().is_err()); } @@ -155,11 +203,8 @@ mod tests { #[test] fn test_max_age_days_below_range() { let req = UpdateSettingsRequest { - theme: "AI".into(), max_age_days: 0, - categories: vec!["Cat".into()], - max_items_per_category: 4, - search_agent_behavior: String::new(), + ..valid_request() }; let err = req.validate().unwrap_err(); assert!(err.contains("max_age_days")); @@ -168,11 +213,8 @@ mod tests { #[test] fn test_max_age_days_above_range() { let req = UpdateSettingsRequest { - theme: "AI".into(), max_age_days: 366, - categories: vec!["Cat".into()], - max_items_per_category: 4, - search_agent_behavior: String::new(), + ..valid_request() }; assert!(req.validate().is_err()); } @@ -180,11 +222,8 @@ mod tests { #[test] fn test_empty_categories() { let req = UpdateSettingsRequest { - theme: "AI".into(), - max_age_days: 7, categories: vec![], - max_items_per_category: 4, - search_agent_behavior: String::new(), + ..valid_request() }; let err = req.validate().unwrap_err(); assert!(err.contains("Categories")); @@ -194,11 +233,8 @@ mod tests { fn test_too_many_categories() { let cats: Vec = (0..21).map(|i| format!("Cat {}", i)).collect(); let req = UpdateSettingsRequest { - theme: "AI".into(), - max_age_days: 7, categories: cats, - max_items_per_category: 4, - search_agent_behavior: String::new(), + ..valid_request() }; let err = req.validate().unwrap_err(); assert!(err.contains("20")); @@ -207,11 +243,8 @@ mod tests { #[test] fn test_empty_category_item() { let req = UpdateSettingsRequest { - theme: "AI".into(), - max_age_days: 7, categories: vec!["Good".into(), " ".into()], - max_items_per_category: 4, - search_agent_behavior: String::new(), + ..valid_request() }; let err = req.validate().unwrap_err(); assert!(err.contains("index 1")); @@ -220,11 +253,8 @@ mod tests { #[test] fn test_max_items_below_range() { let req = UpdateSettingsRequest { - theme: "AI".into(), - max_age_days: 7, - categories: vec!["Cat".into()], max_items_per_category: 0, - search_agent_behavior: String::new(), + ..valid_request() }; assert!(req.validate().is_err()); } @@ -232,11 +262,8 @@ mod tests { #[test] fn test_max_items_above_range() { let req = UpdateSettingsRequest { - theme: "AI".into(), - max_age_days: 7, - categories: vec!["Cat".into()], max_items_per_category: 51, - search_agent_behavior: String::new(), + ..valid_request() }; assert!(req.validate().is_err()); } @@ -244,11 +271,8 @@ mod tests { #[test] fn test_search_agent_behavior_too_long() { let req = UpdateSettingsRequest { - theme: "AI".into(), - max_age_days: 7, - categories: vec!["Cat".into()], - max_items_per_category: 4, search_agent_behavior: "a".repeat(2001), + ..valid_request() }; assert!(req.validate().is_err()); } @@ -261,6 +285,7 @@ mod tests { categories: vec!["Cat".into()], max_items_per_category: 1, search_agent_behavior: String::new(), + ..valid_request() }; assert!(req.validate().is_ok()); @@ -270,7 +295,79 @@ mod tests { categories: (0..20).map(|i| format!("Cat {}", i)).collect(), max_items_per_category: 50, search_agent_behavior: "a".repeat(2000), + ..valid_request() + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn test_validate_with_model_fields() { + let req = UpdateSettingsRequest { + ai_provider: "google".into(), + ai_model: "gemini-2.5-pro".into(), + ai_model_writing: "gemini-2.5-flash".into(), + ..valid_request() }; assert!(req.validate().is_ok()); } + + #[test] + fn test_validate_rate_limit_zero_rejected() { + let req = UpdateSettingsRequest { + rate_limit_max_requests: Some(0), + ..valid_request() + }; + let err = req.validate().unwrap_err(); + assert!(err.contains("rate_limit_max_requests")); + } + + #[test] + fn test_validate_rate_limit_zero_window_rejected() { + let req = UpdateSettingsRequest { + rate_limit_time_window_seconds: Some(0), + ..valid_request() + }; + let err = req.validate().unwrap_err(); + assert!(err.contains("rate_limit_time_window_seconds")); + } + + #[test] + fn test_validate_rate_limit_valid() { + let req = UpdateSettingsRequest { + rate_limit_max_requests: Some(29), + rate_limit_time_window_seconds: Some(60), + ..valid_request() + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn test_validate_ai_provider_too_long_rejected() { + let req = UpdateSettingsRequest { + ai_provider: "a".repeat(101), + ..valid_request() + }; + let err = req.validate().unwrap_err(); + assert!(err.contains("ai_provider")); + } + + #[test] + fn test_validate_ai_model_too_long_rejected() { + let req = UpdateSettingsRequest { + ai_model: "a".repeat(101), + ..valid_request() + }; + let err = req.validate().unwrap_err(); + assert!(err.contains("ai_model")); + } + + #[test] + fn test_validate_ai_model_writing_too_long_rejected() { + let req = UpdateSettingsRequest { + ai_model_writing: "a".repeat(101), + ..valid_request() + }; + let err = req.validate().unwrap_err(); + assert!(err.contains("ai_model_writing")); + } } diff --git a/backend/src/services/prompts.rs b/backend/src/services/prompts.rs index 71f2405..5e90835 100644 --- a/backend/src/services/prompts.rs +++ b/backend/src/services/prompts.rs @@ -139,6 +139,11 @@ mod tests { ], max_items_per_category: 4, search_agent_behavior: String::new(), + ai_provider: String::new(), + ai_model: String::new(), + ai_model_writing: String::new(), + rate_limit_max_requests: None, + rate_limit_time_window_seconds: None, updated_at: Utc::now(), } }