v2: add settings migration, model expansion, DB queries (provider, models, rate limits)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
master
oabrivard 3 months ago
parent 6b27a0f691
commit ed6b41fe52

@ -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;

@ -18,6 +18,11 @@ struct SettingsRow {
categories: serde_json::Value, categories: serde_json::Value,
max_items_per_category: i32, max_items_per_category: i32,
search_agent_behavior: String, search_agent_behavior: String,
ai_provider: String,
ai_model: String,
ai_model_writing: String,
rate_limit_max_requests: Option<i32>,
rate_limit_time_window_seconds: Option<i32>,
updated_at: chrono::DateTime<chrono::Utc>, updated_at: chrono::DateTime<chrono::Utc>,
} }
@ -36,6 +41,11 @@ impl TryFrom<SettingsRow> for UserSettings {
categories, categories,
max_items_per_category: row.max_items_per_category, max_items_per_category: row.max_items_per_category,
search_agent_behavior: row.search_agent_behavior, 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, updated_at: row.updated_at,
}) })
} }
@ -56,10 +66,10 @@ pub async fn get_or_create_default(
let row = sqlx::query_as::<_, SettingsRow>( let row = sqlx::query_as::<_, SettingsRow>(
r#" r#"
INSERT INTO settings (user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior) 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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (user_id) DO UPDATE SET user_id = settings.user_id 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) .bind(user_id)
@ -68,6 +78,11 @@ pub async fn get_or_create_default(
.bind(&categories_json) .bind(&categories_json)
.bind(defaults.max_items_per_category) .bind(defaults.max_items_per_category)
.bind(&defaults.search_agent_behavior) .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) .fetch_one(pool)
.await?; .await?;
@ -86,16 +101,21 @@ pub async fn upsert(
let row = sqlx::query_as::<_, SettingsRow>( let row = sqlx::query_as::<_, SettingsRow>(
r#" r#"
INSERT INTO settings (user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior) 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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (user_id) DO UPDATE SET ON CONFLICT (user_id) DO UPDATE SET
theme = EXCLUDED.theme, theme = EXCLUDED.theme,
max_age_days = EXCLUDED.max_age_days, max_age_days = EXCLUDED.max_age_days,
categories = EXCLUDED.categories, categories = EXCLUDED.categories,
max_items_per_category = EXCLUDED.max_items_per_category, max_items_per_category = EXCLUDED.max_items_per_category,
search_agent_behavior = EXCLUDED.search_agent_behavior, 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() 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) .bind(user_id)
@ -104,6 +124,11 @@ pub async fn upsert(
.bind(&categories_json) .bind(&categories_json)
.bind(req.max_items_per_category) .bind(req.max_items_per_category)
.bind(&req.search_agent_behavior) .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) .fetch_one(pool)
.await?; .await?;

@ -13,6 +13,11 @@ pub struct UserSettings {
pub categories: Vec<String>, pub categories: Vec<String>,
pub max_items_per_category: i32, pub max_items_per_category: i32,
pub search_agent_behavior: String, pub search_agent_behavior: String,
pub ai_provider: String,
pub ai_model: String,
pub ai_model_writing: String,
pub rate_limit_max_requests: Option<i32>,
pub rate_limit_time_window_seconds: Option<i32>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
@ -24,6 +29,11 @@ pub struct SettingsResponse {
pub categories: Vec<String>, pub categories: Vec<String>,
pub max_items_per_category: i32, pub max_items_per_category: i32,
pub search_agent_behavior: String, pub search_agent_behavior: String,
pub ai_provider: String,
pub ai_model: String,
pub ai_model_writing: String,
pub rate_limit_max_requests: Option<i32>,
pub rate_limit_time_window_seconds: Option<i32>,
} }
impl From<UserSettings> for SettingsResponse { impl From<UserSettings> for SettingsResponse {
@ -34,6 +44,11 @@ impl From<UserSettings> for SettingsResponse {
categories: s.categories, categories: s.categories,
max_items_per_category: s.max_items_per_category, max_items_per_category: s.max_items_per_category,
search_agent_behavior: s.search_agent_behavior, 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<String>, pub categories: Vec<String>,
pub max_items_per_category: i32, pub max_items_per_category: i32,
pub search_agent_behavior: String, pub search_agent_behavior: String,
pub ai_provider: String,
pub ai_model: String,
pub ai_model_writing: String,
pub rate_limit_max_requests: Option<i32>,
pub rate_limit_time_window_seconds: Option<i32>,
} }
impl UpdateSettingsRequest { impl UpdateSettingsRequest {
@ -86,6 +106,25 @@ impl UpdateSettingsRequest {
if self.search_agent_behavior.len() > 2000 { if self.search_agent_behavior.len() > 2000 {
return Err("search_agent_behavior must be at most 2000 characters".into()); 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(()) Ok(())
} }
} }
@ -106,6 +145,11 @@ impl Default for UserSettings {
], ],
max_items_per_category: 4, max_items_per_category: 4,
search_agent_behavior: String::new(), 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(), updated_at: Utc::now(),
} }
} }
@ -115,15 +159,25 @@ impl Default for UserSettings {
mod tests { mod tests {
use super::*; use super::*;
#[test] /// Helper to create a valid request with all new fields populated.
fn test_valid_settings() { fn valid_request() -> UpdateSettingsRequest {
let req = UpdateSettingsRequest { UpdateSettingsRequest {
theme: "Intelligence Artificielle".into(), theme: "Intelligence Artificielle".into(),
max_age_days: 7, max_age_days: 7,
categories: vec!["Category 1".into(), "Category 2".into()], categories: vec!["Category 1".into(), "Category 2".into()],
max_items_per_category: 4, max_items_per_category: 4,
search_agent_behavior: String::new(), 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()); assert!(req.validate().is_ok());
} }
@ -131,10 +185,7 @@ mod tests {
fn test_empty_theme() { fn test_empty_theme() {
let req = UpdateSettingsRequest { let req = UpdateSettingsRequest {
theme: " ".into(), theme: " ".into(),
max_age_days: 7, ..valid_request()
categories: vec!["Cat".into()],
max_items_per_category: 4,
search_agent_behavior: String::new(),
}; };
let err = req.validate().unwrap_err(); let err = req.validate().unwrap_err();
assert!(err.contains("Theme")); assert!(err.contains("Theme"));
@ -144,10 +195,7 @@ mod tests {
fn test_theme_too_long() { fn test_theme_too_long() {
let req = UpdateSettingsRequest { let req = UpdateSettingsRequest {
theme: "a".repeat(201), theme: "a".repeat(201),
max_age_days: 7, ..valid_request()
categories: vec!["Cat".into()],
max_items_per_category: 4,
search_agent_behavior: String::new(),
}; };
assert!(req.validate().is_err()); assert!(req.validate().is_err());
} }
@ -155,11 +203,8 @@ mod tests {
#[test] #[test]
fn test_max_age_days_below_range() { fn test_max_age_days_below_range() {
let req = UpdateSettingsRequest { let req = UpdateSettingsRequest {
theme: "AI".into(),
max_age_days: 0, max_age_days: 0,
categories: vec!["Cat".into()], ..valid_request()
max_items_per_category: 4,
search_agent_behavior: String::new(),
}; };
let err = req.validate().unwrap_err(); let err = req.validate().unwrap_err();
assert!(err.contains("max_age_days")); assert!(err.contains("max_age_days"));
@ -168,11 +213,8 @@ mod tests {
#[test] #[test]
fn test_max_age_days_above_range() { fn test_max_age_days_above_range() {
let req = UpdateSettingsRequest { let req = UpdateSettingsRequest {
theme: "AI".into(),
max_age_days: 366, max_age_days: 366,
categories: vec!["Cat".into()], ..valid_request()
max_items_per_category: 4,
search_agent_behavior: String::new(),
}; };
assert!(req.validate().is_err()); assert!(req.validate().is_err());
} }
@ -180,11 +222,8 @@ mod tests {
#[test] #[test]
fn test_empty_categories() { fn test_empty_categories() {
let req = UpdateSettingsRequest { let req = UpdateSettingsRequest {
theme: "AI".into(),
max_age_days: 7,
categories: vec![], categories: vec![],
max_items_per_category: 4, ..valid_request()
search_agent_behavior: String::new(),
}; };
let err = req.validate().unwrap_err(); let err = req.validate().unwrap_err();
assert!(err.contains("Categories")); assert!(err.contains("Categories"));
@ -194,11 +233,8 @@ mod tests {
fn test_too_many_categories() { fn test_too_many_categories() {
let cats: Vec<String> = (0..21).map(|i| format!("Cat {}", i)).collect(); let cats: Vec<String> = (0..21).map(|i| format!("Cat {}", i)).collect();
let req = UpdateSettingsRequest { let req = UpdateSettingsRequest {
theme: "AI".into(),
max_age_days: 7,
categories: cats, categories: cats,
max_items_per_category: 4, ..valid_request()
search_agent_behavior: String::new(),
}; };
let err = req.validate().unwrap_err(); let err = req.validate().unwrap_err();
assert!(err.contains("20")); assert!(err.contains("20"));
@ -207,11 +243,8 @@ mod tests {
#[test] #[test]
fn test_empty_category_item() { fn test_empty_category_item() {
let req = UpdateSettingsRequest { let req = UpdateSettingsRequest {
theme: "AI".into(),
max_age_days: 7,
categories: vec!["Good".into(), " ".into()], categories: vec!["Good".into(), " ".into()],
max_items_per_category: 4, ..valid_request()
search_agent_behavior: String::new(),
}; };
let err = req.validate().unwrap_err(); let err = req.validate().unwrap_err();
assert!(err.contains("index 1")); assert!(err.contains("index 1"));
@ -220,11 +253,8 @@ mod tests {
#[test] #[test]
fn test_max_items_below_range() { fn test_max_items_below_range() {
let req = UpdateSettingsRequest { let req = UpdateSettingsRequest {
theme: "AI".into(),
max_age_days: 7,
categories: vec!["Cat".into()],
max_items_per_category: 0, max_items_per_category: 0,
search_agent_behavior: String::new(), ..valid_request()
}; };
assert!(req.validate().is_err()); assert!(req.validate().is_err());
} }
@ -232,11 +262,8 @@ mod tests {
#[test] #[test]
fn test_max_items_above_range() { fn test_max_items_above_range() {
let req = UpdateSettingsRequest { let req = UpdateSettingsRequest {
theme: "AI".into(),
max_age_days: 7,
categories: vec!["Cat".into()],
max_items_per_category: 51, max_items_per_category: 51,
search_agent_behavior: String::new(), ..valid_request()
}; };
assert!(req.validate().is_err()); assert!(req.validate().is_err());
} }
@ -244,11 +271,8 @@ mod tests {
#[test] #[test]
fn test_search_agent_behavior_too_long() { fn test_search_agent_behavior_too_long() {
let req = UpdateSettingsRequest { 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), search_agent_behavior: "a".repeat(2001),
..valid_request()
}; };
assert!(req.validate().is_err()); assert!(req.validate().is_err());
} }
@ -261,6 +285,7 @@ mod tests {
categories: vec!["Cat".into()], categories: vec!["Cat".into()],
max_items_per_category: 1, max_items_per_category: 1,
search_agent_behavior: String::new(), search_agent_behavior: String::new(),
..valid_request()
}; };
assert!(req.validate().is_ok()); assert!(req.validate().is_ok());
@ -270,7 +295,79 @@ mod tests {
categories: (0..20).map(|i| format!("Cat {}", i)).collect(), categories: (0..20).map(|i| format!("Cat {}", i)).collect(),
max_items_per_category: 50, max_items_per_category: 50,
search_agent_behavior: "a".repeat(2000), 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()); 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"));
}
} }

@ -139,6 +139,11 @@ mod tests {
], ],
max_items_per_category: 4, max_items_per_category: 4,
search_agent_behavior: String::new(), 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(), updated_at: Utc::now(),
} }
} }

Loading…
Cancel
Save