//! Content sources. A source is where candidate items come from — an RSS/Atom feed the //! worker polls, or an agentic/external producer that pushes candidates to the ingest //! endpoint under a user's API token. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use ts_rs::TS; use uuid::Uuid; use crate::error::{Error, Result}; /// How a source produces candidates. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case")] #[ts(export)] pub enum SourceKind { /// An RSS/Atom feed the worker polls on a schedule. Rss, /// A producer that pushes candidates to `POST /v1/ingest/candidates` /// (agentic workloads, scrapers, bespoke algorithms). Agentic, } impl SourceKind { /// Stable string form used in the database and on the wire. pub fn as_str(self) -> &'static str { match self { SourceKind::Rss => "rss", SourceKind::Agentic => "agentic", } } /// Parse from the database/wire representation. pub fn parse(s: &str) -> Result { match s { "rss" => Ok(SourceKind::Rss), "agentic" => Ok(SourceKind::Agentic), other => Err(Error::invalid( "kind", format!("unknown source kind: {other}"), )), } } } /// A user's configured content source. #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export)] pub struct Source { pub id: Uuid, pub user_id: Uuid, pub kind: SourceKind, pub name: String, /// Poll URL for [`SourceKind::Rss`]; unused/optional for agentic sources. pub url: Option, /// Baseline weight applied to every item from this source, `0.0..=1.0`. pub weight: f64, pub enabled: bool, /// When the worker last polled this source (rss only). pub last_polled_at: Option>, pub created_at: DateTime, } /// Request to create a source. #[derive(Debug, Clone, Deserialize, TS)] #[ts(export)] pub struct NewSource { pub kind: SourceKind, pub name: String, pub url: Option, /// Defaults to `1.0` when omitted. #[serde(default)] pub weight: Option, } impl NewSource { /// Validate a create-source request. pub fn validate(&self) -> Result<()> { if self.name.trim().is_empty() { return Err(Error::invalid("name", "must not be empty")); } if self.kind == SourceKind::Rss && self.url.as_deref().unwrap_or("").is_empty() { return Err(Error::invalid("url", "rss sources require a url")); } if let Some(w) = self.weight { if !(0.0..=1.0).contains(&w) { return Err(Error::invalid("weight", "must be within 0.0..=1.0")); } } Ok(()) } }