diff --git a/backend/migrations/20260403000031_add_source_rss_fields.sql b/backend/migrations/20260403000031_add_source_rss_fields.sql new file mode 100644 index 0000000..bdc6916 --- /dev/null +++ b/backend/migrations/20260403000031_add_source_rss_fields.sql @@ -0,0 +1,2 @@ +ALTER TABLE sources ADD COLUMN rss_url TEXT; +ALTER TABLE sources ADD COLUMN rss_discovered_at TIMESTAMPTZ; diff --git a/backend/src/db/sources.rs b/backend/src/db/sources.rs index e599d90..aab1fbf 100644 --- a/backend/src/db/sources.rs +++ b/backend/src/db/sources.rs @@ -3,6 +3,7 @@ //! All queries enforce ownership isolation by including `WHERE user_id = $N` //! to ensure users can only access their own sources. +use chrono::{DateTime, Utc}; use sqlx::PgPool; use uuid::Uuid; @@ -16,7 +17,7 @@ pub async fn list_for_user(pool: &PgPool, user_id: Uuid, theme_id: Option) let sources = if let Some(tid) = theme_id { sqlx::query_as::<_, Source>( r#" - SELECT id, user_id, title, url, theme_id, is_preferred, created_at + SELECT id, user_id, title, url, theme_id, is_preferred, rss_url, rss_discovered_at, created_at FROM sources WHERE user_id = $1 AND theme_id = $2 ORDER BY created_at DESC @@ -29,7 +30,7 @@ pub async fn list_for_user(pool: &PgPool, user_id: Uuid, theme_id: Option) } else { sqlx::query_as::<_, Source>( r#" - SELECT id, user_id, title, url, theme_id, is_preferred, created_at + SELECT id, user_id, title, url, theme_id, is_preferred, rss_url, rss_discovered_at, created_at FROM sources WHERE user_id = $1 ORDER BY created_at DESC @@ -58,7 +59,7 @@ pub async fn create( r#" INSERT INTO sources (user_id, title, url, theme_id) VALUES ($1, $2, $3, $4) - RETURNING id, user_id, title, url, theme_id, is_preferred, created_at + RETURNING id, user_id, title, url, theme_id, is_preferred, rss_url, rss_discovered_at, created_at "#, ) .bind(user_id) @@ -108,7 +109,7 @@ pub async fn bulk_create( INSERT INTO sources (user_id, title, url, theme_id) VALUES ($1, $2, $3, $4) ON CONFLICT (user_id, url) DO NOTHING - RETURNING id, user_id, title, url, theme_id, is_preferred, created_at + RETURNING id, user_id, title, url, theme_id, is_preferred, rss_url, rss_discovered_at, created_at "#, ) .bind(user_id) @@ -170,3 +171,25 @@ pub async fn update_preferred( Ok(()) } + +/// Update the cached RSS feed URL and discovery timestamp for a source. +/// +/// Called during synthesis generation when a feed is discovered or re-verified. +/// Pass `rss_url = None` to clear a previously cached feed (e.g., feed no longer exists). +pub async fn update_source_rss( + pool: &PgPool, + source_id: Uuid, + rss_url: Option<&str>, + rss_discovered_at: Option>, +) -> Result<(), AppError> { + sqlx::query( + "UPDATE sources SET rss_url = $1, rss_discovered_at = $2 WHERE id = $3", + ) + .bind(rss_url) + .bind(rss_discovered_at) + .bind(source_id) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/backend/src/models/source.rs b/backend/src/models/source.rs index 83d0bb4..81e74a4 100644 --- a/backend/src/models/source.rs +++ b/backend/src/models/source.rs @@ -16,6 +16,8 @@ pub struct Source { pub url: String, pub theme_id: Option, pub is_preferred: bool, + pub rss_url: Option, + pub rss_discovered_at: Option>, pub created_at: DateTime, } diff --git a/backend/src/services/csv.rs b/backend/src/services/csv.rs index 68ed8d9..c98e8e1 100644 --- a/backend/src/services/csv.rs +++ b/backend/src/services/csv.rs @@ -261,6 +261,8 @@ mod tests { url: "https://blog.example.com".into(), theme_id: None, is_preferred: false, + rss_url: None, + rss_discovered_at: None, created_at: Utc::now(), }, Source { @@ -270,6 +272,8 @@ mod tests { url: "https://news.example.com".into(), theme_id: None, is_preferred: false, + rss_url: None, + rss_discovered_at: None, created_at: Utc::now(), }, ]; @@ -290,6 +294,8 @@ mod tests { url: "https://example.com".into(), theme_id: None, is_preferred: false, + rss_url: None, + rss_discovered_at: None, created_at: Utc::now(), }]; @@ -314,6 +320,8 @@ mod tests { url: "https://blog.example.com".into(), theme_id: None, is_preferred: false, + rss_url: None, + rss_discovered_at: None, created_at: Utc::now(), }, Source { @@ -323,6 +331,8 @@ mod tests { url: "https://news.example.com".into(), theme_id: None, is_preferred: false, + rss_url: None, + rss_discovered_at: None, created_at: Utc::now(), }, ]; diff --git a/backend/src/services/prompts.rs b/backend/src/services/prompts.rs index 45189df..45c030c 100644 --- a/backend/src/services/prompts.rs +++ b/backend/src/services/prompts.rs @@ -244,6 +244,8 @@ mod tests { url: "https://techcrunch.com".into(), theme_id: None, is_preferred: false, + rss_url: None, + rss_discovered_at: None, created_at: Utc::now(), }, Source { @@ -253,6 +255,8 @@ mod tests { url: "https://theverge.com".into(), theme_id: None, is_preferred: false, + rss_url: None, + rss_discovered_at: None, created_at: Utc::now(), }, ]; diff --git a/backend/src/services/synthesis/mod.rs b/backend/src/services/synthesis/mod.rs index 2c018d3..7a8245b 100644 --- a/backend/src/services/synthesis/mod.rs +++ b/backend/src/services/synthesis/mod.rs @@ -1231,8 +1231,8 @@ mod tests { #[test] fn rotate_sources_no_last_url() { let sources = vec![ - crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "A".into(), url: "https://a.com".into(), theme_id: None, is_preferred: false, created_at: chrono::Utc::now() }, - crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "B".into(), url: "https://b.com".into(), theme_id: None, is_preferred: false, created_at: chrono::Utc::now() }, + crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "A".into(), url: "https://a.com".into(), theme_id: None, is_preferred: false, rss_url: None, rss_discovered_at: None, created_at: chrono::Utc::now() }, + crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "B".into(), url: "https://b.com".into(), theme_id: None, is_preferred: false, rss_url: None, rss_discovered_at: None, created_at: chrono::Utc::now() }, ]; let result = rotate_sources(sources.clone(), None); assert_eq!(result.len(), 2); @@ -1242,9 +1242,9 @@ mod tests { #[test] fn rotate_sources_with_last_url() { let sources = vec![ - crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "A".into(), url: "https://a.com".into(), theme_id: None, is_preferred: false, created_at: chrono::Utc::now() }, - crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "B".into(), url: "https://b.com".into(), theme_id: None, is_preferred: false, created_at: chrono::Utc::now() }, - crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "C".into(), url: "https://c.com".into(), theme_id: None, is_preferred: false, created_at: chrono::Utc::now() }, + crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "A".into(), url: "https://a.com".into(), theme_id: None, is_preferred: false, rss_url: None, rss_discovered_at: None, created_at: chrono::Utc::now() }, + crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "B".into(), url: "https://b.com".into(), theme_id: None, is_preferred: false, rss_url: None, rss_discovered_at: None, created_at: chrono::Utc::now() }, + crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "C".into(), url: "https://c.com".into(), theme_id: None, is_preferred: false, rss_url: None, rss_discovered_at: None, created_at: chrono::Utc::now() }, ]; let result = rotate_sources(sources, Some("https://a.com")); assert_eq!(result[0].url, "https://b.com"); @@ -1255,7 +1255,7 @@ mod tests { #[test] fn rotate_sources_last_url_not_found() { let sources = vec![ - crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "A".into(), url: "https://a.com".into(), theme_id: None, is_preferred: false, created_at: chrono::Utc::now() }, + crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "A".into(), url: "https://a.com".into(), theme_id: None, is_preferred: false, rss_url: None, rss_discovered_at: None, created_at: chrono::Utc::now() }, ]; let result = rotate_sources(sources.clone(), Some("https://notfound.com")); assert_eq!(result[0].url, "https://a.com");