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:
61
crates/newsfeed-entities/src/auth.rs
Normal file
61
crates/newsfeed-entities/src/auth.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
//! Session and API-token DTOs.
|
||||
//!
|
||||
//! Humans authenticate with an opaque session cookie; agentic and algorithmic sources
|
||||
//! authenticate with per-user bearer API tokens. In both cases only a hash of the
|
||||
//! secret is ever persisted — see `newsfeed-core::auth`.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// An authenticated browser session, keyed by the hash of an opaque cookie value.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Session {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Public metadata about an API token. The secret itself is shown exactly once, at
|
||||
/// creation, and is never retrievable afterwards.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct ApiTokenInfo {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub name: String,
|
||||
/// Short non-secret prefix (e.g. `nf_a1b2c3`) to help the user identify the token.
|
||||
pub prefix: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Request to mint a new API token.
|
||||
#[derive(Debug, Clone, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct CreateApiToken {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Response returned once when a token is created. `secret` is the full bearer token
|
||||
/// (`nf_<prefix>_<random>`) and is never persisted in the clear.
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct CreatedApiToken {
|
||||
#[serde(flatten)]
|
||||
pub info: ApiTokenInfo,
|
||||
/// The full secret. Present only in this response.
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
/// The current authenticated principal, returned by `GET /v1/auth/me`.
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct Me {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
}
|
||||
30
crates/newsfeed-entities/src/error.rs
Normal file
30
crates/newsfeed-entities/src/error.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
//! Domain-level error type. Validation and invariant failures live here; I/O errors
|
||||
//! belong to the crates that perform the I/O (`newsfeed-data`, the binaries).
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors produced while constructing or validating domain values.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
/// A user-supplied value failed validation (empty, malformed, out of range).
|
||||
#[error("invalid {field}: {reason}")]
|
||||
Invalid {
|
||||
/// The field that failed validation.
|
||||
field: &'static str,
|
||||
/// Why it failed.
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Convenience constructor for [`Error::Invalid`].
|
||||
pub fn invalid(field: &'static str, reason: impl Into<String>) -> Self {
|
||||
Self::Invalid {
|
||||
field,
|
||||
reason: reason.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result alias for domain operations.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
54
crates/newsfeed-entities/src/feed.rs
Normal file
54
crates/newsfeed-entities/src/feed.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
//! Feed query/response DTOs and interaction signals. Signals are the feedback loop:
|
||||
//! what the user does with an item feeds back into future ranking — but only ever
|
||||
//! according to weights the user themselves controls.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::item::ContentItem;
|
||||
|
||||
/// Query parameters for `GET /v1/feed`.
|
||||
#[derive(Debug, Clone, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct FeedQuery {
|
||||
/// Max items to return. Defaults applied server-side.
|
||||
pub limit: Option<u32>,
|
||||
/// Opaque cursor for pagination (score+id of the last item seen).
|
||||
pub cursor: Option<String>,
|
||||
/// When true, include saved items; otherwise only live feed items.
|
||||
#[serde(default)]
|
||||
pub include_saved: bool,
|
||||
}
|
||||
|
||||
/// A page of ranked feed items.
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct FeedPage {
|
||||
pub items: Vec<ContentItem>,
|
||||
/// Cursor to pass back for the next page, or `None` at the end.
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// The kinds of interaction the user can have with an item.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export)]
|
||||
pub enum SignalAction {
|
||||
/// The item scrolled into view.
|
||||
View,
|
||||
/// The user opened/clicked through.
|
||||
Click,
|
||||
/// The user saved it.
|
||||
Save,
|
||||
/// The user dismissed it.
|
||||
Dismiss,
|
||||
}
|
||||
|
||||
/// A recorded interaction, posted to `POST /v1/feed/signals`.
|
||||
#[derive(Debug, Clone, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct Signal {
|
||||
pub item_id: Uuid,
|
||||
pub action: SignalAction,
|
||||
}
|
||||
45
crates/newsfeed-entities/src/interest.rs
Normal file
45
crates/newsfeed-entities/src/interest.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
//! Interests: the user-controlled weightings that drive ranking. A user tells the system
|
||||
//! what they care about and how much; the ranker (see `newsfeed-core::ranking`) uses
|
||||
//! these weights — and nothing else's opinion — to score their feed.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// A weighted topic of interest belonging to a single user.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct Interest {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
/// Free-text label matched (case-insensitively) against item title/summary/tags.
|
||||
pub label: String,
|
||||
/// How strongly this interest pulls matching items up the feed, `-1.0..=1.0`.
|
||||
/// Negative weights actively bury matching content.
|
||||
pub weight: f64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Create or update an interest.
|
||||
#[derive(Debug, Clone, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct UpsertInterest {
|
||||
pub label: String,
|
||||
pub weight: f64,
|
||||
}
|
||||
|
||||
impl UpsertInterest {
|
||||
/// Validate an interest upsert.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.label.trim().is_empty() {
|
||||
return Err(Error::invalid("label", "must not be empty"));
|
||||
}
|
||||
if !(-1.0..=1.0).contains(&self.weight) {
|
||||
return Err(Error::invalid("weight", "must be within -1.0..=1.0"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
89
crates/newsfeed-entities/src/item.rs
Normal file
89
crates/newsfeed-entities/src/item.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
//! Content items: the units that flow through curation into a user's feed. An item
|
||||
//! enters as a *candidate* (either polled by the worker or pushed to the ingest
|
||||
//! endpoint), gets scored, and then lives in the feed until the user acts on it.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Lifecycle state of a content item.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export)]
|
||||
pub enum ItemState {
|
||||
/// Newly ingested, awaiting scoring/curation.
|
||||
Candidate,
|
||||
/// Scored and live in the feed.
|
||||
Feed,
|
||||
/// The user explicitly saved/bookmarked it.
|
||||
Saved,
|
||||
/// The user dismissed it; kept for negative signal, hidden from the feed.
|
||||
Dismissed,
|
||||
}
|
||||
|
||||
impl ItemState {
|
||||
/// Stable string form used in the database and on the wire.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
ItemState::Candidate => "candidate",
|
||||
ItemState::Feed => "feed",
|
||||
ItemState::Saved => "saved",
|
||||
ItemState::Dismissed => "dismissed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A media attachment (image/video/audio) associated with an item.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct Media {
|
||||
pub kind: String,
|
||||
pub url: String,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
}
|
||||
|
||||
/// A content item owned by a user.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct ContentItem {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub source_id: Option<Uuid>,
|
||||
/// De-duplication key from the origin (feed guid, tweet id, URL, …).
|
||||
pub external_id: String,
|
||||
pub url: Option<String>,
|
||||
pub title: String,
|
||||
pub summary: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub media: Vec<Media>,
|
||||
pub published_at: Option<DateTime<Utc>>,
|
||||
pub ingested_at: DateTime<Utc>,
|
||||
pub state: ItemState,
|
||||
/// Cached ranking score; recomputed by the ranker as weights/signals change.
|
||||
pub score: f64,
|
||||
}
|
||||
|
||||
/// A candidate pushed to `POST /v1/ingest/candidates` by an agentic or algorithmic
|
||||
/// source. The authenticated API token determines the owning user; `source` names the
|
||||
/// producer for attribution and per-source weighting.
|
||||
#[derive(Debug, Clone, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct CandidateSubmission {
|
||||
/// Stable de-dup key from the producer. Re-submitting the same key is a no-op.
|
||||
pub external_id: String,
|
||||
pub url: Option<String>,
|
||||
pub title: String,
|
||||
pub summary: Option<String>,
|
||||
pub author: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub media: Vec<Media>,
|
||||
pub published_at: Option<DateTime<Utc>>,
|
||||
/// Optional name of the source that produced this candidate. If it matches an
|
||||
/// existing agentic source for the user it is linked; otherwise ingested unlinked.
|
||||
pub source: Option<String>,
|
||||
}
|
||||
22
crates/newsfeed-entities/src/lib.rs
Normal file
22
crates/newsfeed-entities/src/lib.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
//! Domain types, DTOs and error enums shared across the newsfeed workspace.
|
||||
//!
|
||||
//! This crate is intentionally free of I/O and async-runtime dependencies. Everything
|
||||
//! downstream (`newsfeed-core`, `newsfeed-data`, the binaries and — via generated
|
||||
//! TypeScript bindings — the web frontend) depends on these types.
|
||||
//!
|
||||
//! Wire types derive [`ts_rs::TS`] with `#[ts(export)]` so `cargo test -p
|
||||
//! newsfeed-entities` regenerates the frontend bindings under `web/src/api/bindings/`
|
||||
//! (path set by `TS_RS_EXPORT_DIR` in `.cargo/config.toml`).
|
||||
|
||||
pub mod auth;
|
||||
pub mod error;
|
||||
pub mod feed;
|
||||
pub mod interest;
|
||||
pub mod item;
|
||||
pub mod source;
|
||||
pub mod user;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
|
||||
/// Newtype over a [`uuid::Uuid`] identifier, re-exported for convenience.
|
||||
pub type Id = uuid::Uuid;
|
||||
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(())
|
||||
}
|
||||
}
|
||||
54
crates/newsfeed-entities/src/user.rs
Normal file
54
crates/newsfeed-entities/src/user.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
//! User accounts. Each user is fully in control of their own feed weightings.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// A registered user. The password hash never leaves `newsfeed-data`/`newsfeed-core`;
|
||||
/// it is deliberately absent from this DTO so it can't be serialised to a client.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Registration request from the sign-up form.
|
||||
#[derive(Debug, Clone, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct RegisterRequest {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl RegisterRequest {
|
||||
/// Validate the shape of a registration request before it reaches the store.
|
||||
/// Uniqueness is enforced by the database, not here.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.username.trim().len() < 3 {
|
||||
return Err(Error::invalid("username", "must be at least 3 characters"));
|
||||
}
|
||||
if !self.email.contains('@') {
|
||||
return Err(Error::invalid("email", "must contain '@'"));
|
||||
}
|
||||
if self.password.len() < 8 {
|
||||
return Err(Error::invalid("password", "must be at least 8 characters"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Login request.
|
||||
#[derive(Debug, Clone, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct LoginRequest {
|
||||
/// Username or email.
|
||||
pub identifier: String,
|
||||
pub password: String,
|
||||
}
|
||||
Reference in New Issue
Block a user