stemedb/crates/stemedb-api/src/handlers/feed.rs
jordan 58594bc7b9 feat: add feed endpoint, dashboard feed panel, and FindMyHealth app
- Add /v1/feed API endpoint with handler and tests
- Remove health endpoint rate limiting (behind firewall, caused spurious 429s)
- Add dashboard feed panel with list, row, empty state, and loading skeleton
- Update home page to show feed instead of redirecting to skeptic
- Improve API key auth middleware and DTO create/query params
- Add OpenAPI conceptual guide (api-intro.md) with semaglutide examples
- Add FindMyHealth application scaffolding (vision, architecture, prototypes)
- Add FindMyHealth designer/writer and Aphoria founder-CEO agents
- Update roadmap with current progress

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:16:17 -07:00

91 lines
2.9 KiB
Rust

//! Handler for the `/v1/feed` endpoint (newest-first assertion browsing).
use axum::{extract::State, Json};
use tracing::{instrument, warn};
use crate::{
dto::{FeedParams, QueryResponse},
error::Result,
extractors::QsQuery,
state::AppState,
};
use stemedb_query::Query;
use super::query::assertion_to_dto_with_warning;
/// Browse all assertions in newest-first order with pagination.
///
/// Returns assertions sorted by timestamp descending, useful for
/// "what was just written?" dashboards and dev workflows. No lens
/// resolution is applied — this is a raw chronological feed.
///
/// # Pagination
///
/// - `limit`: Max results per page (default 50, max 500)
/// - `offset`: Number of results to skip (default 0)
#[utoipa::path(
get,
path = "/v1/feed",
params(
("limit" = Option<usize>, Query, description = "Max results (default 50, max 500)"),
("offset" = Option<usize>, Query, description = "Pagination offset (default 0)")
),
responses(
(status = 200, description = "Feed results", body = QueryResponse),
(status = 500, description = "Internal server error", body = crate::dto::ErrorResponse)
),
tag = "query"
)]
#[instrument(skip(state), fields(limit = params.limit, offset = params.offset))]
pub async fn feed(
State(state): State<AppState>,
QsQuery(params): QsQuery<FeedParams>,
) -> Result<Json<QueryResponse>> {
metrics::counter!("stemedb_queries_total", "endpoint" => "feed").increment(1);
let query_start = std::time::Instant::now();
// Fetch all assertions (no subject filter)
let query = Query::builder().limit(usize::MAX).build();
let query_engine = state.query_engine();
let result = query_engine.execute(&query).await?;
let mut assertions = result.assertions;
if assertions.len() > 10_000 {
warn!(
count = assertions.len(),
"Feed scanning large assertion set; consider adding index-backed pagination"
);
}
// Sort by timestamp descending (newest first)
assertions.sort_unstable_by(|a, b| b.timestamp.cmp(&a.timestamp));
let total_count = assertions.len();
let limit = params.clamped_limit();
let offset = params.offset;
let has_more = offset + limit < total_count;
// Apply offset + limit pagination
let page: Vec<_> = assertions.into_iter().skip(offset).take(limit).collect();
// Convert to DTOs (no source enrichment for speed)
let assertion_responses = page
.into_iter()
.map(|a| assertion_to_dto_with_warning(a, None))
.collect::<Result<Vec<_>>>()?;
metrics::histogram!("stemedb_query_latency_seconds", "endpoint" => "feed")
.record(query_start.elapsed().as_secs_f64());
Ok(Json(QueryResponse {
assertions: assertion_responses,
total_count,
has_more,
conflict_score: None,
resolution_confidence: None,
changes_since: None,
}))
}