feat: always-on Insights — correlation analysis over viewing habits
Some checks failed
CI / Check / Test (push) Failing after 9m16s
Some checks failed
CI / Check / Test (push) Failing after 9m16s
Conservative, live insights (ADR-0010) derived from each user's diary: rating lift by genre (first-time watches only), genre seasonality vs a uniform 1/12 month baseline, medium weekend-lift, and watch-time rhythm. - domain: WatchRow model, InsightsQuery port, pure insights_analyzer with all honesty thresholds (sample minimums, strength cutoffs, suppressions for import noise), unit-tested - adapters: thin Sqlite/Postgres fetch twins, reusing WrapUp's genre fetch - application: get_insights use case, InsightsGroup in Deps - composition/server: wiring through Repositories/DatabaseOutput - presentation: GET /api/v1/insights handler, typed serde-tagged DTOs, openapi doc - spa: /insights route (shadcn cards), insights feature hook, profile entry link, en locale strings - server test: end-to-end seed sqlite → HTTP → rating lift + rhythm Also records ADR-0010, defines "Insight" in the glossary, and includes pre-existing worktree comment-stripping cleanups.
This commit is contained in:
@@ -52,7 +52,6 @@ pub async fn csrf_middleware(mut req: Request, next: Next) -> Response {
|
||||
response
|
||||
}
|
||||
|
||||
/// Returns true if the form token does not match the cookie token.
|
||||
pub fn mismatch(token: &CsrfToken, form_value: &str) -> bool {
|
||||
token.0 != form_value || form_value.is_empty()
|
||||
}
|
||||
|
||||
81
crates/presentation/src/handlers/insights.rs
Normal file
81
crates/presentation/src/handlers/insights.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use axum::{Json, extract::State};
|
||||
|
||||
use application::insights::get as get_insights_uc;
|
||||
use domain::models::Insight;
|
||||
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::insights::{InsightDto, InsightsResponse};
|
||||
|
||||
fn to_dto(insight: Insight) -> InsightDto {
|
||||
match insight {
|
||||
Insight::RatingLift {
|
||||
genre,
|
||||
genre_avg,
|
||||
user_avg,
|
||||
delta,
|
||||
sample_count,
|
||||
} => InsightDto::RatingLift {
|
||||
genre,
|
||||
genre_avg,
|
||||
user_avg,
|
||||
delta,
|
||||
sample_count,
|
||||
},
|
||||
Insight::Seasonality {
|
||||
genre,
|
||||
month,
|
||||
multiplier,
|
||||
sample_count,
|
||||
} => InsightDto::Seasonality {
|
||||
genre,
|
||||
month,
|
||||
multiplier,
|
||||
sample_count,
|
||||
},
|
||||
Insight::MediumTiming {
|
||||
medium,
|
||||
multiplier,
|
||||
weekend_share,
|
||||
baseline_share,
|
||||
sample_count,
|
||||
} => InsightDto::MediumTiming {
|
||||
medium,
|
||||
multiplier,
|
||||
weekend_share,
|
||||
baseline_share,
|
||||
sample_count,
|
||||
},
|
||||
Insight::Rhythm {
|
||||
peak_hour_start,
|
||||
peak_hour_end,
|
||||
top_days,
|
||||
sample_count,
|
||||
} => InsightDto::Rhythm {
|
||||
peak_hour_start,
|
||||
peak_hour_end,
|
||||
top_days,
|
||||
sample_count,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Live insights for the authenticated user's own diary. Private surface:
|
||||
/// habits (late-night watching, mediums) are more sensitive than the taste
|
||||
/// statistics already public on profiles (ADR-0010).
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/insights",
|
||||
responses(
|
||||
(status = 200, body = InsightsResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_insights(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<InsightsResponse>, ApiError> {
|
||||
let insights = get_insights_uc::execute(&state.app_ctx.deps.insights.get, user.0).await?;
|
||||
Ok(Json(InsightsResponse {
|
||||
insights: insights.into_iter().map(to_dto).collect(),
|
||||
}))
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod goals;
|
||||
mod helpers;
|
||||
pub mod images;
|
||||
pub mod import;
|
||||
pub mod insights;
|
||||
pub mod integrations;
|
||||
pub mod movies;
|
||||
pub mod rss;
|
||||
|
||||
@@ -1104,9 +1104,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bug: a local actor used to serialize as `local:{uuid}`, which
|
||||
/// `identify` cannot parse, so every POST echoing this url back resolved
|
||||
/// to Remote and was routed to ActivityPub.
|
||||
#[test]
|
||||
fn local_actor_dto_url_round_trips_back_to_the_same_identity() {
|
||||
let instance = InstanceIdentity::new("https://md.example");
|
||||
|
||||
8
crates/presentation/src/openapi/insights.rs
Normal file
8
crates/presentation/src/openapi/insights.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(crate::handlers::insights::get_insights,),
|
||||
components(schemas(api_types::insights::InsightsResponse, api_types::insights::InsightDto,))
|
||||
)]
|
||||
pub struct InsightsDoc;
|
||||
@@ -2,6 +2,7 @@ mod auth;
|
||||
mod diary;
|
||||
mod goals;
|
||||
mod import;
|
||||
mod insights;
|
||||
mod movies;
|
||||
mod search;
|
||||
mod social;
|
||||
@@ -46,6 +47,7 @@ fn build() -> utoipa::openapi::OpenApi {
|
||||
api.merge(goals::GoalsDoc::openapi());
|
||||
api.merge(webhook::WebhookDoc::openapi());
|
||||
api.merge(wrapup::WrapUpDoc::openapi());
|
||||
api.merge(insights::InsightsDoc::openapi());
|
||||
#[cfg(feature = "federation")]
|
||||
api.merge(social::SocialDoc::openapi());
|
||||
SecurityAddon.modify(&mut api);
|
||||
|
||||
@@ -460,6 +460,7 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
|
||||
"/wrapups/{id}/report",
|
||||
routing::get(handlers::wrapup::get_report),
|
||||
)
|
||||
.route("/insights", routing::get(handlers::insights::get_insights))
|
||||
.route(
|
||||
"/admin/reindex-search",
|
||||
routing::post(handlers::search::post_reindex_search),
|
||||
|
||||
@@ -163,13 +163,6 @@ async fn person_credits_endpoint_returns_404_for_unknown_id() {
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// Proves stub injection reaches the prebuilt deps, not just the `Repositories`
|
||||
/// passed into `build_test_state_from`. Before the fix that added construct-time
|
||||
/// stub injection, this failed on the assertion below — exactly the false-green
|
||||
/// that would have silently broken the person-handler tests in Task 6. `AppContext`
|
||||
/// no longer carries a `repos` field (Plan C2), so the original companion
|
||||
/// assertion on `state.app_ctx.repos.person_query` was dropped along with the
|
||||
/// field; the assertion that matters — that `deps` itself holds the stub — stays.
|
||||
#[test]
|
||||
fn injected_stub_reaches_prebuilt_deps() {
|
||||
let stub: Arc<dyn domain::ports::PersonQuery> = Arc::new(PersonQueryStub);
|
||||
@@ -209,7 +202,6 @@ async fn get_watchlist_requires_auth() {
|
||||
|
||||
// --- Pending-follower-count endpoint tests ---
|
||||
|
||||
/// Stub `FollowGraphQuery` returning a fixed pending-follower count.
|
||||
struct FixedPendingCount(usize);
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::FollowGraphQuery for FixedPendingCount {
|
||||
@@ -264,10 +256,6 @@ impl domain::ports::FollowGraphQuery for FixedPendingCount {
|
||||
}
|
||||
}
|
||||
|
||||
/// The point of Task 4: one use case (`social::count_pending_followers`) now
|
||||
/// serves both the classic UI (via `get_page_viewer`) and this new HTTP
|
||||
/// endpoint. Seed the query port with a fixed count and assert the endpoint
|
||||
/// reflects it verbatim.
|
||||
#[tokio::test]
|
||||
async fn pending_count_endpoint_reports_the_use_case_result() {
|
||||
let uid = domain::value_objects::UserId::from_uuid(Uuid::new_v4());
|
||||
@@ -351,9 +339,6 @@ impl domain::ports::ApDocumentPort for FailingApDocument {
|
||||
}
|
||||
}
|
||||
|
||||
/// Swapping `ap_document` on the returned state is safe — unlike the `repos`
|
||||
/// fields, handlers read `app_ctx.ap_document` directly and no prebuilt deps
|
||||
/// struct carries a copy of it.
|
||||
#[cfg(feature = "federation")]
|
||||
fn state_with_failing_ap_document() -> crate::state::AppState {
|
||||
let mut state = test_app_state();
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
/// The prebuilt deps must point at the SAME port instances as the `Repositories`
|
||||
/// they were built from. If build_deps is wired to a different Arc — or is called
|
||||
/// before a repo is swapped — handlers in Plan B silently talk to the wrong
|
||||
/// dependency, and no other test in Plan A would catch it.
|
||||
///
|
||||
/// `AppContext` no longer carries a `repos` field (Plan C2 deleted it), so this
|
||||
/// test builds its own `Repositories` locally, keeps a clone of the Arcs it
|
||||
/// cares about, and hands the original to `build_test_state_from` — the same
|
||||
/// construction `test_app_state()` uses internally.
|
||||
#[test]
|
||||
fn prebuilt_deps_share_port_instances_with_repos() {
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -16,12 +16,13 @@ use domain::{
|
||||
Person, PersonCredits, PersonEnrichmentData, PersonId, Review, ReviewHistory, SearchQuery,
|
||||
SearchResults, UserStats, UserTrends,
|
||||
collections::{PageParams, Paginated},
|
||||
insights::WatchRow,
|
||||
},
|
||||
ports::{
|
||||
AuthService, DiaryQuery, EventPublisher, FollowGraphQuery, MetadataClient, MovieCommand,
|
||||
MovieQuery, ObjectStorage, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
|
||||
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserRepository,
|
||||
WatchlistRepository,
|
||||
AuthService, DiaryQuery, EventPublisher, FollowGraphQuery, InsightsQuery, MetadataClient,
|
||||
MovieCommand, MovieQuery, ObjectStorage, PasswordHasher, PersonCommand, PersonQuery,
|
||||
PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort, StatsRepository,
|
||||
UserRepository, WatchlistRepository,
|
||||
},
|
||||
testing::InMemoryUserRepository,
|
||||
value_objects::{
|
||||
@@ -169,6 +170,12 @@ impl StatsRepository for Panic {
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl InsightsQuery for Panic {
|
||||
async fn get_watch_rows(&self, _: &UserId) -> Result<Vec<WatchRow>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl MetadataClient for Panic {
|
||||
async fn fetch_movie_metadata(
|
||||
&self,
|
||||
@@ -794,6 +801,7 @@ pub(crate) fn test_repositories() -> Repositories {
|
||||
federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery) as _,
|
||||
wrapup_stats: Arc::clone(&repo) as _,
|
||||
wrapup_repo: Arc::clone(&repo) as _,
|
||||
insights: Arc::clone(&repo) as _,
|
||||
goal_command: Arc::clone(&repo) as _,
|
||||
goal_query: Arc::clone(&repo) as _,
|
||||
user_settings: Arc::clone(&repo) as _,
|
||||
|
||||
Reference in New Issue
Block a user