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,
max_items_per_category: i32,
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>,
}
@ -36,6 +41,11 @@ impl TryFrom<SettingsRow> 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?;

@ -13,6 +13,11 @@ pub struct UserSettings {
pub categories: Vec<String>,
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<i32>,
pub rate_limit_time_window_seconds: Option<i32>,
pub updated_at: DateTime<Utc>,
}
@ -24,6 +29,11 @@ pub struct SettingsResponse {
pub categories: Vec<String>,
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<i32>,
pub rate_limit_time_window_seconds: Option<i32>,
}
impl From<UserSettings> for SettingsResponse {
@ -34,6 +44,11 @@ impl From<UserSettings> 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<String>,
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<i32>,
pub rate_limit_time_window_seconds: Option<i32>,
}
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<String> = (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"));
}
}

@ -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(),
}
}

Loading…
Cancel
Save