feat: scaffold newsfeed — user-controlled news feed
Some checks failed
deploy / build (push) Failing after 7s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Has been skipped

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:
2026-07-08 11:36:57 +03:00
commit 5ce52fff4d
104 changed files with 10963 additions and 0 deletions

View 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(())
}
}