feat: add rss_url and rss_discovered_at columns to sources

Add nullable rss_url (TEXT) and rss_discovered_at (TIMESTAMPTZ) columns
to the sources table for RSS feed URL caching. Update the Source struct,
all query_as SELECT/RETURNING queries, and add update_source_rss db function.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
oabrivard 2 months ago
parent ef23596c5a
commit cd5f5434b2

@ -0,0 +1,2 @@
ALTER TABLE sources ADD COLUMN rss_url TEXT;
ALTER TABLE sources ADD COLUMN rss_discovered_at TIMESTAMPTZ;

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

@ -16,6 +16,8 @@ pub struct Source {
pub url: String,
pub theme_id: Option<Uuid>,
pub is_preferred: bool,
pub rss_url: Option<String>,
pub rss_discovered_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}

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

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

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

Loading…
Cancel
Save