- 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.
191 lines
6.6 KiB
Rust
191 lines
6.6 KiB
Rust
use api_types::HtmlPageContext;
|
|
use domain::value_objects::UserId;
|
|
|
|
use crate::state::AppState;
|
|
|
|
pub(crate) fn encode_error(msg: &str) -> String {
|
|
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
|
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,
|
|
state: &AppState,
|
|
) -> axum::response::Response {
|
|
use axum::{body::Body, http::StatusCode, response::IntoResponse};
|
|
use futures::StreamExt;
|
|
|
|
use application::diary::{export_diary as export_diary_uc, queries::ExportQuery};
|
|
use domain::models::ExportFormat;
|
|
|
|
let format = match format_str {
|
|
"csv" => ExportFormat::Csv,
|
|
"json" => ExportFormat::Json,
|
|
_ => return StatusCode::BAD_REQUEST.into_response(),
|
|
};
|
|
let (content_type, filename) = match &format {
|
|
ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"),
|
|
ExportFormat::Json => ("application/json", "diary.json"),
|
|
};
|
|
let query = ExportQuery { user_id, format };
|
|
let stream = export_diary_uc::execute(&state.app_ctx.deps.diary.export_diary, query);
|
|
let stream = stream.map(|r| {
|
|
if let Err(ref e) = r {
|
|
tracing::error!("diary export stream error: {e}");
|
|
}
|
|
r
|
|
});
|
|
(
|
|
StatusCode::OK,
|
|
[
|
|
(axum::http::header::CONTENT_TYPE, content_type.to_string()),
|
|
(
|
|
axum::http::header::CONTENT_DISPOSITION,
|
|
format!("attachment; filename=\"{}\"", filename),
|
|
),
|
|
],
|
|
Body::from_stream(stream),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
pub(crate) struct ProfileFormData {
|
|
pub display_name: Option<String>,
|
|
pub bio: Option<String>,
|
|
pub avatar_bytes: Option<Vec<u8>>,
|
|
pub avatar_content_type: Option<String>,
|
|
pub banner_bytes: Option<Vec<u8>>,
|
|
pub banner_content_type: Option<String>,
|
|
pub also_known_as: Option<String>,
|
|
pub profile_field_names: std::collections::HashMap<usize, String>,
|
|
pub profile_field_values: std::collections::HashMap<usize, String>,
|
|
}
|
|
|
|
pub(crate) async fn parse_profile_multipart(
|
|
mut multipart: axum::extract::Multipart,
|
|
) -> ProfileFormData {
|
|
let mut data = ProfileFormData {
|
|
display_name: None,
|
|
bio: None,
|
|
avatar_bytes: None,
|
|
avatar_content_type: None,
|
|
banner_bytes: None,
|
|
banner_content_type: None,
|
|
also_known_as: None,
|
|
profile_field_names: std::collections::HashMap::new(),
|
|
profile_field_values: std::collections::HashMap::new(),
|
|
};
|
|
|
|
while let Ok(Some(field)) = multipart.next_field().await {
|
|
let name = field.name().unwrap_or("").to_string();
|
|
match name.as_str() {
|
|
"display_name" => {
|
|
if let Ok(text) = field.text().await {
|
|
data.display_name = Some(text).filter(|s| !s.is_empty());
|
|
}
|
|
}
|
|
"bio" => {
|
|
if let Ok(text) = field.text().await {
|
|
data.bio = Some(text);
|
|
}
|
|
}
|
|
"also_known_as" => {
|
|
if let Ok(text) = field.text().await {
|
|
data.also_known_as = Some(text).filter(|s| !s.is_empty());
|
|
}
|
|
}
|
|
"avatar" => {
|
|
let ct = field.content_type().map(|s| s.to_string());
|
|
if let Ok(bytes) = field.bytes().await
|
|
&& !bytes.is_empty()
|
|
{
|
|
data.avatar_bytes = Some(bytes.to_vec());
|
|
data.avatar_content_type = ct;
|
|
}
|
|
}
|
|
"banner" => {
|
|
let ct = field.content_type().map(|s| s.to_string());
|
|
if let Ok(bytes) = field.bytes().await
|
|
&& !bytes.is_empty()
|
|
{
|
|
data.banner_bytes = Some(bytes.to_vec());
|
|
data.banner_content_type = ct;
|
|
}
|
|
}
|
|
n if n.starts_with("field_name_") => {
|
|
if let Ok(idx) = n["field_name_".len()..].parse::<usize>()
|
|
&& let Ok(text) = field.text().await
|
|
&& !text.is_empty()
|
|
{
|
|
data.profile_field_names.insert(idx, text);
|
|
}
|
|
}
|
|
n if n.starts_with("field_value_") => {
|
|
if let Ok(idx) = n["field_value_".len()..].parse::<usize>()
|
|
&& let Ok(text) = field.text().await
|
|
&& !text.is_empty()
|
|
{
|
|
data.profile_field_values.insert(idx, text);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
data
|
|
}
|
|
|
|
pub(crate) async fn build_page_context(
|
|
state: &AppState,
|
|
user_id: Option<UserId>,
|
|
csrf_token: String,
|
|
) -> HtmlPageContext {
|
|
let uuid = user_id.as_ref().map(|u| u.value());
|
|
// Page chrome (email/admin badge, pending-follow badge) degrades rather than
|
|
// blanking the page: a failed lookup or count logs a warning and falls back
|
|
// to a zeroed `PageViewer`, same as the previous silent `.unwrap_or(0)` — now
|
|
// logged and explicit, per the error policy in `get_page_viewer`'s doc comment.
|
|
let (user_email, is_admin, pending_follow_count) = if let Some(ref id) = user_id {
|
|
match application::users::get_page_viewer::execute(
|
|
&state.app_ctx.deps.users.get_page_viewer,
|
|
id.value(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(viewer) => (viewer.email, viewer.is_admin, viewer.pending_follow_count),
|
|
Err(e) => {
|
|
tracing::warn!("get_page_viewer failed for {}: {e}", id.value());
|
|
(None, false, 0)
|
|
}
|
|
}
|
|
} else {
|
|
(None, false, 0)
|
|
};
|
|
HtmlPageContext {
|
|
user_email,
|
|
user_id: uuid,
|
|
is_admin,
|
|
register_enabled: state.app_ctx.config.allow_registration,
|
|
rss_url: "/feed.rss".to_string(),
|
|
page_title: "Movies Diary".to_string(),
|
|
canonical_url: state.app_ctx.config.base_url.clone(),
|
|
csrf_token,
|
|
page_rss_url: None,
|
|
pending_follow_count,
|
|
}
|
|
}
|