insights-correlations (#22)
Some checks failed
CI / Check / Test (push) Has been cancelled

Reviewed-on: #22
This commit was merged in pull request #22.
This commit is contained in:
2026-10-12 18:56:55 +00:00
parent 4d221e60de
commit e303ae7f08
139 changed files with 2371 additions and 696 deletions

View File

@@ -26,6 +26,7 @@ tokio = { workspace = true }
dotenvy = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
chrono-tz = { workspace = true }
async-trait = { workspace = true }
futures = { workspace = true }

View File

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

View File

@@ -26,7 +26,7 @@ use api_types::{
};
use template_askama::{ActivityFeedTemplate, NewReviewTemplate, build_page_items};
use super::helpers::{build_export_response, build_page_context, encode_error};
use super::helpers::{build_export_response, build_page_context, encode_domain_error};
// ── API ──────────────────────────────────────────────────────────────────────
@@ -238,7 +238,7 @@ pub async fn post_review_html(
{
Ok(_) => Redirect::to("/").into_response(),
Err(e) => {
let msg = encode_error(&e.to_string());
let msg = encode_domain_error(&e);
Redirect::to(&format!("/reviews/new?error={}", msg)).into_response()
}
}

View File

@@ -8,6 +8,19 @@ pub(crate) fn encode_error(msg: &str) -> String {
utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string()
}
/// Error text for a classic-page `?error=` redirect. Infrastructure failures
/// are logged here — the redirect is their only exit, so nothing else would —
/// and shown to the user generically, never with internal detail.
pub(crate) fn encode_domain_error(e: &domain::errors::DomainError) -> String {
match e {
domain::errors::DomainError::InfrastructureError(_) => {
tracing::error!("Internal error: {e}");
encode_error("Something went wrong, please try again")
}
_ => encode_error(&e.to_string()),
}
}
pub(crate) fn build_export_response(
format_str: &str,
user_id: uuid::Uuid,

View File

@@ -40,7 +40,7 @@ use crate::{
state::AppState,
};
use super::helpers::encode_error;
use super::helpers::encode_domain_error;
fn str_to_domain_field(field: &str) -> Option<DomainField> {
match field {
@@ -171,7 +171,7 @@ pub async fn post_upload(
{
Ok(r) => Redirect::to(&format!("/import/{}/mapping", r.session_id.value())).into_response(),
Err(e) => {
Redirect::to(&format!("/import?error={}", encode_error(&e.to_string()))).into_response()
Redirect::to(&format!("/import?error={}", encode_domain_error(&e))).into_response()
}
}
}
@@ -258,7 +258,7 @@ pub async fn post_mapping(
Err(e) => Redirect::to(&format!(
"/import/{}/mapping?error={}",
session_id_str,
encode_error(&e.to_string())
encode_domain_error(&e)
))
.into_response(),
}
@@ -375,7 +375,7 @@ pub async fn post_confirm(
))
.into_response(),
Err(e) => {
Redirect::to(&format!("/import?error={}", encode_error(&e.to_string()))).into_response()
Redirect::to(&format!("/import?error={}", encode_domain_error(&e))).into_response()
}
}
}

View File

@@ -0,0 +1,95 @@
use axum::{
Json,
extract::{Query, State},
};
use application::insights::get as get_insights_uc;
use domain::{errors::DomainError, models::Insight};
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::insights::{InsightDto, InsightsQueryParams, 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",
params(InsightsQueryParams),
responses(
(status = 200, body = InsightsResponse),
(status = 400, description = "Unknown time zone"),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_insights(
State(state): State<AppState>,
user: AuthenticatedUser,
Query(params): Query<InsightsQueryParams>,
) -> Result<Json<InsightsResponse>, ApiError> {
let tz: chrono_tz::Tz = match params.tz.as_deref() {
None => chrono_tz::UTC,
Some(name) => name.parse().map_err(|_| {
ApiError(DomainError::ValidationError(format!(
"unknown time zone: {name}"
)))
})?,
};
let insights = get_insights_uc::execute(&state.app_ctx.deps.insights.get, user.0, &tz).await?;
Ok(Json(InsightsResponse {
insights: insights.into_iter().map(to_dto).collect(),
}))
}

View File

@@ -24,7 +24,7 @@ use crate::{
};
use template_askama::{IntegrationsTemplate, WatchQueueTemplate};
use super::helpers::{build_page_context, encode_error};
use super::helpers::{build_page_context, encode_domain_error};
// ── HTML ─────────────────────────────────────────────────────────────────────
@@ -184,7 +184,7 @@ pub async fn post_confirm_single(
{
Ok(_) => Redirect::to("/watch-queue").into_response(),
Err(e) => {
let msg = encode_error(&e.to_string());
let msg = encode_domain_error(&e);
Redirect::to(&format!("/watch-queue?error={msg}")).into_response()
}
}
@@ -211,7 +211,7 @@ pub async fn post_dismiss_single(
{
Ok(_) => Redirect::to("/watch-queue").into_response(),
Err(e) => {
let msg = encode_error(&e.to_string());
let msg = encode_domain_error(&e);
Redirect::to(&format!("/watch-queue?error={msg}")).into_response()
}
}

View File

@@ -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;

View File

@@ -30,7 +30,7 @@ use domain::value_objects::{FollowTarget, InstanceIdentity, SocialActor, SocialI
use template_askama::{BlockedActorsTemplate, BlockedDomainsTemplate};
use template_askama::{FollowersTemplate, FollowingTemplate, RemoteActorData};
use super::helpers::{build_page_context, encode_error};
use super::helpers::{build_page_context, encode_domain_error};
fn social_actor_to_dto(actor: SocialActor, instance: &InstanceIdentity) -> RemoteActorDto {
RemoteActorDto {
@@ -576,7 +576,7 @@ pub async fn follow_user(
Ok(()) => Redirect::to(&redirect_base).into_response(),
Err(e) => {
tracing::error!("follow error: {:?}", e);
let msg = encode_error(&e.to_string());
let msg = encode_domain_error(&e);
let sep = if redirect_base.contains('?') {
'&'
} else {
@@ -614,7 +614,7 @@ pub async fn unfollow_user(
Redirect::to(&format!("/users/{}/following-list", profile_user_uuid)).into_response()
}
Err(e) => {
let msg = encode_error(&e.to_string());
let msg = encode_domain_error(&e);
Redirect::to(&format!(
"/users/{}/following-list?error={}",
profile_user_uuid, msg
@@ -649,7 +649,7 @@ pub async fn accept_follower_html(
{
Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(),
Err(e) => {
let msg = encode_error(&e.to_string());
let msg = encode_domain_error(&e);
Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response()
}
}
@@ -680,7 +680,7 @@ pub async fn reject_follower_html(
{
Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(),
Err(e) => {
let msg = encode_error(&e.to_string());
let msg = encode_domain_error(&e);
Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response()
}
}
@@ -896,7 +896,7 @@ pub async fn remove_follower_html(
Redirect::to(&format!("/users/{}/followers-list", profile_user_uuid)).into_response()
}
Err(e) => {
let msg = encode_error(&e.to_string());
let msg = encode_domain_error(&e);
Redirect::to(&format!(
"/users/{}/followers-list?error={}",
profile_user_uuid, msg
@@ -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");

View File

@@ -829,7 +829,7 @@ pub async fn post_profile_settings(
tracing::error!("update_profile error: {:?}", e);
return axum::response::Redirect::to(&format!(
"/settings/profile?error={}",
super::helpers::encode_error(&e.to_string())
super::helpers::encode_domain_error(&e)
))
.into_response();
}
@@ -860,7 +860,7 @@ pub async fn post_profile_settings(
tracing::error!("update_profile_fields error: {:?}", e);
return axum::response::Redirect::to(&format!(
"/settings/profile?error={}",
super::helpers::encode_error(&e.to_string())
super::helpers::encode_domain_error(&e)
))
.into_response();
}

View 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;

View File

@@ -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);

View File

@@ -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),

View File

@@ -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();

View File

@@ -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;

View File

@@ -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 _,