feat: scaffold newsfeed — user-controlled news feed
A self-hosted feed where the user owns the ranking: per-source and signed per-interest weights, decayed by recency, via a transparent deterministic scorer. Content is sourced algorithmically (worker RSS/Atom polling) and agentically (per-user API tokens POSTing candidates to the ingest endpoint). Single-user today, multi-user by construction (every row keyed on user_id). Rust cargo workspace + Vite/React/SWC/TS SPA: - newsfeed-entities: DTOs (ts-rs bindings -> web/src/api/bindings) - newsfeed-core: ranking, auth primitives, ingest, data-access ports - newsfeed-data: SQLite adapters (sqlx, runtime queries) - newsfeed-api: axum REST/JSON daemon - newsfeed-worker: RSS polling + rescoring loop - web: responsive, mobile-first SPA (React Query, generated types) Deploy (Gitea Actions, build static musl + SPA, rsync as gitea_ci): api+worker -> slartibartfast, SPA -> oolon (nginx serves + proxies /v1). Deliberate deviations from house conventions (documented in CLAUDE.md/readme): - SQLite instead of Postgres; api+worker co-locate sharing one DB file. - Runtime sqlx queries instead of query! macros (SQLite dynamic typing; keeps CI database-free). Verified end-to-end: auth, token ingest, interest-weighted ranking, signals, pagination (curl + browser); cargo fmt/clippy -D/test and pnpm build/lint pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fKZzDpvjiJ9eYbPGgJvUP
This commit is contained in:
92
crates/newsfeed-entities/src/source.rs
Normal file
92
crates/newsfeed-entities/src/source.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
//! 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<Self> {
|
||||
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<String>,
|
||||
/// 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<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Request to create a source.
|
||||
#[derive(Debug, Clone, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct NewSource {
|
||||
pub kind: SourceKind,
|
||||
pub name: String,
|
||||
pub url: Option<String>,
|
||||
/// Defaults to `1.0` when omitted.
|
||||
#[serde(default)]
|
||||
pub weight: Option<f64>,
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user