@@ -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 }
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
95
crates/presentation/src/handlers/insights.rs
Normal file
95
crates/presentation/src/handlers/insights.rs
Normal 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(),
|
||||
}))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
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