Refactor insights handling and error encoding in presentation layer
All checks were successful
CI / Check / Test (push) Successful in 1h12m41s
CI / Check / Test (pull_request) Successful in 1h6m20s

- Updated insights analyzer tests to use Utc for date handling.
- Added timezone support for insights retrieval, allowing insights to be bucketed by the user's local time.
- Introduced `encode_domain_error` for better error handling in API responses.
- Modified various handlers to utilize the new error encoding function.
- Enhanced insights API to reject unknown time zones and return appropriate errors.
- Updated frontend components to handle insights data with timezone awareness.
- Improved localization for insights sample counts.
This commit is contained in:
2026-10-12 20:44:54 +02:00
parent ee06f4794c
commit 5cb6fd5fbb
28 changed files with 510 additions and 176 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

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

@@ -1,10 +1,13 @@
use axum::{Json, extract::State};
use axum::{
Json,
extract::{Query, State},
};
use application::insights::get as get_insights_uc;
use domain::models::Insight;
use domain::{errors::DomainError, models::Insight};
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::insights::{InsightDto, InsightsResponse};
use api_types::insights::{InsightDto, InsightsQueryParams, InsightsResponse};
fn to_dto(insight: Insight) -> InsightDto {
match insight {
@@ -64,8 +67,10 @@ fn to_dto(insight: Insight) -> InsightDto {
/// 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" = []))
@@ -73,8 +78,17 @@ fn to_dto(insight: Insight) -> InsightDto {
pub async fn get_insights(
State(state): State<AppState>,
user: AuthenticatedUser,
Query(params): Query<InsightsQueryParams>,
) -> Result<Json<InsightsResponse>, ApiError> {
let insights = get_insights_uc::execute(&state.app_ctx.deps.insights.get, user.0).await?;
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

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

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