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

@@ -30,7 +30,7 @@ impl InsightsQuery for PostgresInsightsQuery {
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, \
watch_medium \
FROM reviews \
WHERE user_id = $1 \
WHERE user_id = $1 AND remote_actor_url IS NULL \
ORDER BY watched_at ASC",
)
.bind(user_id.value().to_string())

View File

@@ -8,8 +8,6 @@ use sqlx::{Row, SqlitePool};
use adapter_common::{parse_datetime, parse_uuid};
use crate::wrapup::fetch_genres_sqlite;
/// SQLite read side of Insights (ADR-0010). Deliberately thin: one query for
/// review basics, one for genres — every threshold and comparison lives in
/// the pure analyzer, not here.
@@ -29,7 +27,7 @@ impl InsightsQuery for SqliteInsightsQuery {
let rows = sqlx::query(
"SELECT movie_id, rating, watched_at, watch_medium \
FROM reviews \
WHERE user_id = ? \
WHERE user_id = ? AND remote_actor_url IS NULL \
ORDER BY watched_at ASC",
)
.bind(user_id.value().to_string())
@@ -37,20 +35,32 @@ impl InsightsQuery for SqliteInsightsQuery {
.await
.map_err(adapter_common::map_sqlx_error)?;
let mut movie_ids: Vec<String> = Vec::new();
let mut seen = std::collections::HashSet::new();
for row in &rows {
// Subquery rather than an `IN (?, …)` bind list: a whole diary can
// exceed SQLite's bound-parameter limit.
let genre_rows = sqlx::query(
"SELECT movie_id, name FROM movie_genres \
WHERE movie_id IN ( \
SELECT movie_id FROM reviews \
WHERE user_id = ? AND remote_actor_url IS NULL \
) \
ORDER BY name",
)
.bind(user_id.value().to_string())
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
let mut genres_map: HashMap<String, Vec<String>> = HashMap::new();
for row in genre_rows {
let mid: String = row
.try_get("movie_id")
.map_err(adapter_common::map_sqlx_error)?;
if seen.insert(mid.clone()) {
movie_ids.push(mid);
}
let name: String = row
.try_get("name")
.map_err(adapter_common::map_sqlx_error)?;
genres_map.entry(mid).or_default().push(name);
}
let genres_map: HashMap<String, Vec<String>> =
fetch_genres_sqlite(&self.pool, &movie_ids).await?;
let mut result = Vec::with_capacity(rows.len());
for row in &rows {
let movie_id: String = row

View File

@@ -17,7 +17,7 @@ pub enum InsightDto {
sample_count: u32,
},
/// `genre`'s share of watches in `month` (1–12) is `multiplier` times the
/// uniform 1/12 baseline.
/// user's own share of watches in that month.
Seasonality {
genre: String,
month: u32,
@@ -47,3 +47,11 @@ pub enum InsightDto {
pub struct InsightsResponse {
pub insights: Vec<InsightDto>,
}
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct InsightsQueryParams {
/// IANA time zone (e.g. `Europe/Warsaw`) months, weekdays and hours are
/// bucketed in. Defaults to UTC.
pub tz: Option<String>,
}

View File

@@ -3,13 +3,20 @@
//! thresholds. No caching, no snapshots — the same on-the-fly shape as the
//! existing live stats.
use chrono::TimeZone;
use domain::{errors::DomainError, models::Insight, value_objects::UserId};
use crate::insights::deps::GetInsightsDeps;
pub async fn execute(deps: &GetInsightsDeps, user_id: UserId) -> Result<Vec<Insight>, DomainError> {
pub async fn execute<Tz: TimeZone + Sync>(
deps: &GetInsightsDeps,
user_id: UserId,
tz: &Tz,
) -> Result<Vec<Insight>, DomainError> {
let rows = deps.insights.get_watch_rows(&user_id).await?;
Ok(domain::services::insights_analyzer::build_insights(&rows))
Ok(domain::services::insights_analyzer::build_insights(
&rows, tz,
))
}
#[cfg(test)]

View File

@@ -31,7 +31,9 @@ async fn returns_analyzer_output_for_the_user() {
insights: InMemoryInsightsQuery::with_rows(user, rows),
};
let insights = execute(&deps, UserId::from_uuid(user)).await.unwrap();
let insights = execute(&deps, UserId::from_uuid(user), &chrono::Utc)
.await
.unwrap();
assert!(insights.iter().any(
|i| matches!(i, domain::models::Insight::RatingLift { genre, .. } if genre == "Thriller")
));
@@ -49,7 +51,9 @@ async fn other_users_rows_are_not_seen() {
insights: InMemoryInsightsQuery::with_rows(other, rows),
};
let insights = execute(&deps, UserId::from_uuid(user)).await.unwrap();
let insights = execute(&deps, UserId::from_uuid(user), &chrono::Utc)
.await
.unwrap();
assert!(insights.is_empty());
}
@@ -60,7 +64,9 @@ async fn empty_diary_returns_empty_list() {
insights: InMemoryInsightsQuery::new(),
};
let insights = execute(&deps, UserId::from_uuid(user)).await.unwrap();
let insights = execute(&deps, UserId::from_uuid(user), &chrono::Utc)
.await
.unwrap();
assert!(insights.is_empty());
}
@@ -83,7 +89,7 @@ async fn port_errors_propagate() {
insights: Arc::new(Failing),
};
let err = execute(&deps, UserId::from_uuid(Uuid::new_v4()))
let err = execute(&deps, UserId::from_uuid(Uuid::new_v4()), &chrono::Utc)
.await
.unwrap_err();
assert!(matches!(

View File

@@ -35,7 +35,7 @@ pub enum Insight {
sample_count: u32,
},
/// The genre's share of watches in one month of the year is `multiplier`
/// times the uniform 1/12 baseline.
/// times the user's own share of watches in that month.
Seasonality {
genre: String,
/// 1–12

View File

@@ -7,7 +7,7 @@
use std::collections::HashMap;
use chrono::{Datelike, Timelike, Weekday};
use chrono::{Datelike, NaiveDateTime, TimeZone, Timelike, Weekday};
use crate::models::{Insight, WatchRow};
@@ -21,8 +21,11 @@ const MIN_BASELINE_RATINGS: usize = 10;
const MIN_RATING_DELTA: f64 = 0.3;
/// Rows a genre needs before month-of-year distribution is more than noise.
const MIN_GENRE_SEASON_ROWS: usize = 15;
/// Minimum lift over the uniform 1/12 month baseline.
/// Minimum lift over the user's own share of watches in that month.
const MIN_SEASON_MULTIPLIER: f64 = 1.5;
/// Genre watches the peak month needs — a lift built on one or two rows in a
/// quiet month is noise, however large the ratio.
const MIN_SEASON_PEAK_ROWS: u32 = 4;
/// Rows a medium needs before its weekend share means anything.
const MIN_MEDIUM_ROWS: usize = 5;
/// Minimum lift over the user's own weekend share.
@@ -40,15 +43,21 @@ const MAX_PER_CATEGORY: usize = 3;
/// Width of the peak-hour window in the rhythm insight.
const RHYTHM_WINDOW_HOURS: u32 = 3;
pub fn build_insights(rows: &[WatchRow]) -> Vec<Insight> {
/// `rows` carry `watched_at` in UTC as stored; `tz` is the viewer's zone, so
/// months, weekdays and hours are bucketed as the user lived them.
pub fn build_insights<Tz: TimeZone>(rows: &[WatchRow], tz: &Tz) -> Vec<Insight> {
let mut insights = Vec::new();
insights.extend(rating_lifts(rows));
insights.extend(seasonalities(rows));
insights.extend(medium_timings(rows));
insights.extend(rhythm(rows));
insights.extend(seasonalities(rows, tz));
insights.extend(medium_timings(rows, tz));
insights.extend(rhythm(rows, tz));
insights
}
fn local<Tz: TimeZone>(tz: &Tz, utc: &NaiveDateTime) -> NaiveDateTime {
tz.from_utc_datetime(utc).naive_local()
}
// ── Rating lift ──────────────────────────────────────────────────────────────
fn rating_lifts(rows: &[WatchRow]) -> Vec<Insight> {
@@ -75,20 +84,13 @@ fn rating_lifts(rows: &[WatchRow]) -> Vec<Insight> {
if delta.abs() < MIN_RATING_DELTA {
return None;
}
Some((
strength(delta.abs(), ratings.len()),
genre,
genre_avg,
Some(Insight::RatingLift {
genre: genre.to_string(),
genre_avg: round2(genre_avg),
user_avg: round2(user_avg),
delta,
ratings.len(),
))
})
.map(|(_, genre, genre_avg, delta, n)| Insight::RatingLift {
genre: genre.to_string(),
genre_avg: round2(genre_avg),
user_avg: round2(user_avg),
delta,
sample_count: n as u32,
sample_count: ratings.len() as u32,
})
})
.collect();
@@ -128,50 +130,55 @@ fn avg_rating(rows: &[&WatchRow]) -> f64 {
// ── Seasonality ──────────────────────────────────────────────────────────────
fn seasonalities(rows: &[WatchRow]) -> Vec<Insight> {
let mut by_genre: HashMap<&str, Vec<u32>> = HashMap::new();
fn seasonalities<Tz: TimeZone>(rows: &[WatchRow], tz: &Tz) -> Vec<Insight> {
if rows.is_empty() {
return vec![];
}
// The user's own month-of-year distribution is the baseline: a diary
// started in October, or a user who simply watches more in winter, must
// not make every big genre look seasonal.
let mut user_months = [0u32; 12];
let mut by_genre: HashMap<&str, [u32; 12]> = HashMap::new();
for row in rows {
let m = local(tz, &row.watched_at).month0() as usize;
user_months[m] += 1;
for genre in &row.genres {
by_genre
.entry(genre)
.or_default()
.push(row.watched_at.month());
by_genre.entry(genre).or_insert([0; 12])[m] += 1;
}
}
let user_total = rows.len() as f64;
let mut candidates: Vec<Insight> = by_genre
.into_iter()
.filter(|(_, months)| months.len() >= MIN_GENRE_SEASON_ROWS)
.filter_map(|(genre, months)| {
let total = months.len() as f64;
let mut histogram = [0u32; 12];
for &m in &months {
histogram[(m - 1) as usize] += 1;
.filter_map(|(genre, histogram)| {
let genre_total: u32 = histogram.iter().sum();
if (genre_total as usize) < MIN_GENRE_SEASON_ROWS {
return None;
}
// Smallest month number wins ties — deterministic output.
let (peak_month, peak_count) = histogram
.into_iter()
// Month with the largest lift over the user's share for that
// month; smallest month number wins ties — deterministic output.
let (peak_month, multiplier) = histogram
.iter()
.enumerate()
.max_by_key(|(i, c)| (*c, std::cmp::Reverse(*i)))
.map(|(i, c)| (i as u32 + 1, c))?;
// Baseline is a uniform year: each month expects total/12 watches.
let multiplier = round2(f64::from(peak_count) * 12.0 / total);
.filter(|(_, c)| **c >= MIN_SEASON_PEAK_ROWS)
.map(|(i, &c)| {
let genre_share = f64::from(c) / f64::from(genre_total);
let user_share = f64::from(user_months[i]) / user_total;
(i as u32 + 1, round2(genre_share / user_share))
})
.fold(None, |best: Option<(u32, f64)>, cur| match best {
Some(b) if b.1 >= cur.1 => Some(b),
_ => Some(cur),
})?;
if multiplier < MIN_SEASON_MULTIPLIER {
return None;
}
Some((
strength(multiplier - 1.0, months.len()),
genre,
peak_month,
Some(Insight::Seasonality {
genre: genre.to_string(),
month: peak_month,
multiplier,
months.len(),
))
})
.map(|(_, genre, month, multiplier, n)| Insight::Seasonality {
genre: genre.to_string(),
month,
multiplier,
sample_count: n as u32,
sample_count: genre_total,
})
})
.collect();
@@ -182,7 +189,7 @@ fn seasonalities(rows: &[WatchRow]) -> Vec<Insight> {
// ── Medium timing ────────────────────────────────────────────────────────────
fn medium_timings(rows: &[WatchRow]) -> Vec<Insight> {
fn medium_timings<Tz: TimeZone>(rows: &[WatchRow], tz: &Tz) -> Vec<Insight> {
if rows.is_empty() {
return vec![];
}
@@ -196,7 +203,10 @@ fn medium_timings(rows: &[WatchRow]) -> Vec<Insight> {
let mut by_medium: HashMap<&str, (usize, usize)> = HashMap::new();
let mut weekend_total = 0usize;
for row in rows.iter().filter(|r| r.watch_medium.is_some()) {
let weekend = matches!(row.watched_at.weekday(), Weekday::Sat | Weekday::Sun);
let weekend = matches!(
local(tz, &row.watched_at).weekday(),
Weekday::Sat | Weekday::Sun
);
weekend_total += usize::from(weekend);
let entry = by_medium
.entry(row.watch_medium.as_deref().expect("filtered above"))
@@ -220,23 +230,14 @@ fn medium_timings(rows: &[WatchRow]) -> Vec<Insight> {
if multiplier < MIN_MEDIUM_MULTIPLIER {
return None;
}
Some((
strength(multiplier - 1.0, count),
medium,
weekend_share,
multiplier,
count,
))
})
.map(
|(_, medium, weekend_share, multiplier, n)| Insight::MediumTiming {
Some(Insight::MediumTiming {
medium: medium.to_string(),
multiplier,
weekend_share: round2(weekend_share),
baseline_share: round2(baseline_share),
sample_count: n as u32,
},
)
sample_count: count as u32,
})
})
.collect();
candidates.sort_by(strongest_first);
@@ -246,10 +247,14 @@ fn medium_timings(rows: &[WatchRow]) -> Vec<Insight> {
// ── Rhythm ───────────────────────────────────────────────────────────────────
fn rhythm(rows: &[WatchRow]) -> Vec<Insight> {
let real: Vec<&WatchRow> = rows
fn rhythm<Tz: TimeZone>(rows: &[WatchRow], tz: &Tz) -> Vec<Insight> {
// Import noise is detected on the stored value: date-only imports land at
// exactly 00:00 as stored, which no longer reads as midnight once shifted
// into the viewer's zone.
let real: Vec<NaiveDateTime> = rows
.iter()
.filter(|r| !(r.watched_at.hour() == 0 && r.watched_at.minute() == 0))
.map(|r| local(tz, &r.watched_at))
.collect();
let midnight_share = 1.0 - real.len() as f64 / rows.len().max(1) as f64;
if rows.is_empty() || real.len() < MIN_RHYTHM_ROWS || midnight_share > MAX_MIDNIGHT_SHARE {
@@ -258,9 +263,9 @@ fn rhythm(rows: &[WatchRow]) -> Vec<Insight> {
let mut hours = [0u32; 24];
let mut days = [0u32; 7]; // index 0 = Monday (chrono weekday number − 1)
for row in &real {
hours[row.watched_at.hour() as usize] += 1;
days[row.watched_at.weekday().number_from_monday() as usize - 1] += 1;
for at in &real {
hours[at.hour() as usize] += 1;
days[at.weekday().number_from_monday() as usize - 1] += 1;
}
// Circular 3-hour window with the most watches; smallest start hour wins ties.

View File

@@ -1,4 +1,4 @@
use chrono::{Datelike, NaiveDate, Weekday};
use chrono::{Datelike, FixedOffset, NaiveDate, Utc, Weekday};
use uuid::Uuid;
use crate::models::{Insight, WatchRow};
@@ -41,7 +41,7 @@ fn rating_lift_compares_genre_to_personal_average() {
.collect();
rows.extend((0..12).map(|i| watch(&["Comedy"], 2, date(2024, 3, 1 + i), 20)));
let insights = build_insights(&rows);
let insights = build_insights(&rows, &Utc);
let thrill = insights.iter().find_map(|i| match i {
Insight::RatingLift {
genre,
@@ -81,7 +81,7 @@ fn rating_lift_ignores_rewatches() {
rows.push(rewatch);
}
let insights = build_insights(&rows);
let insights = build_insights(&rows, &Utc);
let thrill = insights.iter().find_map(|i| match i {
Insight::RatingLift {
genre,
@@ -107,7 +107,7 @@ fn rating_lift_below_sample_threshold_is_absent() {
// high they are rated, the genre must not yield an insight.
rows.extend((0..4).map(|i| watch(&["Thriller"], 5, date(2024, 2, 1 + i), 20)));
let insights = build_insights(&rows);
let insights = build_insights(&rows, &Utc);
assert!(
!insights
.iter()
@@ -126,14 +126,14 @@ fn rating_lift_below_delta_threshold_is_absent() {
rows.extend((0..3).map(|i| watch(&["Drama"], 3, date(2024, 1, 13 + i), 20)));
rows.extend((0..5).map(|i| watch(&["Thriller"], 4, date(2024, 2, 1 + i), 20)));
let insights = build_insights(&rows);
let insights = build_insights(&rows, &Utc);
assert!(!kinds(&insights).contains(&"rating_lift"));
}
// ── Seasonality ──────────────────────────────────────────────────────────────
#[test]
fn seasonality_detects_month_peak_against_uniform_baseline() {
fn seasonality_detects_month_peak_against_own_monthly_baseline() {
// 16 horrors: 10 in October across two years, 6 scattered.
let mut rows: Vec<WatchRow> = Vec::new();
for i in 0..10u32 {
@@ -146,7 +146,7 @@ fn seasonality_detects_month_peak_against_uniform_baseline() {
// A perfectly spread genre (2 watches per month) must NOT appear.
rows.extend((0..24).map(|i| watch(&["Drama"], 3, date(2024, 1 + i % 12, 5), 21)));
let insights = build_insights(&rows);
let insights = build_insights(&rows, &Utc);
let horror = insights.iter().find_map(|i| match i {
Insight::Seasonality {
genre,
@@ -159,9 +159,10 @@ fn seasonality_detects_month_peak_against_uniform_baseline() {
});
let (month, multiplier, count) = horror.expect("Horror seasonality expected");
assert_eq!(month, 10);
// 10 of 16 watches in one month → (10/16)·12 = 7.5× the uniform baseline.
// Horror: 10 of 16 watches in October (62.5%); the whole diary puts
// 12 of 40 watches there (30%) → 0.625 / 0.3 ≈ 2.08× the user's own share.
assert!(
(multiplier - 7.5).abs() < 1e-9,
(multiplier - 2.08).abs() < 1e-9,
"multiplier was {multiplier}"
);
assert_eq!(count, 16);
@@ -174,12 +175,26 @@ fn seasonality_detects_month_peak_against_uniform_baseline() {
);
}
#[test]
fn seasonality_ignores_when_the_diary_itself_is_concentrated() {
// A diary started in October: every genre peaks in Oct/Nov only because
// the user logged nothing else. Relative to their own months, nothing is
// seasonal.
let mut rows: Vec<WatchRow> = Vec::new();
for i in 0..20u32 {
let month = if i % 2 == 0 { 10 } else { 11 };
rows.push(watch(&["Drama"], 3, date(2024, month, 1 + i), 21));
rows.push(watch(&["Comedy"], 3, date(2024, month, 1 + i), 21));
}
assert!(!kinds(&build_insights(&rows, &Utc)).contains(&"seasonality"));
}
#[test]
fn seasonality_below_sample_threshold_is_absent() {
let rows: Vec<WatchRow> = (0..14)
.map(|i| watch(&["Horror"], 4, date(2024, 10, 1 + i), 21))
.collect();
assert!(!kinds(&build_insights(&rows)).contains(&"seasonality"));
assert!(!kinds(&build_insights(&rows, &Utc)).contains(&"seasonality"));
}
// ── Medium timing ────────────────────────────────────────────────────────────
@@ -216,7 +231,7 @@ fn medium_timing_detects_weekend_lift() {
rows.push(row);
}
let insights = build_insights(&rows);
let insights = build_insights(&rows, &Utc);
let cinema = insights.iter().find_map(|i| match i {
Insight::MediumTiming {
medium,
@@ -255,7 +270,7 @@ fn medium_timing_skipped_when_medium_mostly_missing() {
rows.push(row);
}
assert!(!kinds(&build_insights(&rows)).contains(&"medium_timing"));
assert!(!kinds(&build_insights(&rows, &Utc)).contains(&"medium_timing"));
}
// ── Rhythm ───────────────────────────────────────────────────────────────────
@@ -280,7 +295,7 @@ fn rhythm_finds_peak_window_and_days() {
}
}
let insights = build_insights(&rows);
let insights = build_insights(&rows, &Utc);
let rhythm = insights.iter().find_map(|i| match i {
Insight::Rhythm {
peak_hour_start,
@@ -305,6 +320,61 @@ fn rhythm_finds_peak_window_and_days() {
assert_eq!(count, 12);
}
#[test]
fn rhythm_buckets_in_the_viewers_time_zone() {
// 12 watches stored at 22:30 UTC on Tue/Wed/Thu are 00:30 the next day
// for a viewer at UTC+2.
let first_tuesday = date(2024, 3, 5);
let mut rows: Vec<WatchRow> = Vec::new();
for day in 0..3u32 {
for week in 0..(5 - day) {
rows.push(watch(
&["Drama"],
4,
first_tuesday
+ chrono::Duration::days(i64::from(day))
+ chrono::Duration::weeks(i64::from(week)),
22,
));
}
}
let plus_two = FixedOffset::east_opt(2 * 3600).unwrap();
let insights = build_insights(&rows, &plus_two);
let (start, end, days) = insights
.iter()
.find_map(|i| match i {
Insight::Rhythm {
peak_hour_start,
peak_hour_end,
top_days,
..
} => Some((*peak_hour_start, *peak_hour_end, top_days.clone())),
_ => None,
})
.expect("rhythm expected");
// Local hour 0; the scan starts at 0, so the first window covering it is 0–3.
assert_eq!((start, end), (0, 3));
assert_eq!(days, vec![3, 4, 5], "Wed=3, Thu=4, Fri=5 locally");
}
#[test]
fn rhythm_import_noise_detected_before_zone_shift() {
// Date-only imports are stored at 00:00 UTC; at UTC+2 they read 02:00,
// but they are still noise.
let rows: Vec<WatchRow> = (0..12u32)
.map(|i| WatchRow {
movie_id: Uuid::new_v4(),
rating: 4,
watched_at: date(2024, 1, 1 + i).and_hms_opt(0, 0, 0).unwrap(),
watch_medium: None,
genres: vec!["Drama".to_string()],
})
.collect();
let plus_two = FixedOffset::east_opt(2 * 3600).unwrap();
assert!(!kinds(&build_insights(&rows, &plus_two)).contains(&"rhythm"));
}
#[test]
fn rhythm_skipped_when_timestamps_are_import_noise() {
let mut rows: Vec<WatchRow> = Vec::new();
@@ -320,14 +390,14 @@ fn rhythm_skipped_when_timestamps_are_import_noise() {
rows.push(row);
}
assert!(!kinds(&build_insights(&rows)).contains(&"rhythm"));
assert!(!kinds(&build_insights(&rows, &Utc)).contains(&"rhythm"));
}
// ── Overall ──────────────────────────────────────────────────────────────────
#[test]
fn empty_diary_yields_nothing() {
assert!(build_insights(&[]).is_empty());
assert!(build_insights(&[], &Utc).is_empty());
}
#[test]
@@ -335,7 +405,7 @@ fn small_diary_yields_nothing() {
let rows: Vec<WatchRow> = (0..4)
.map(|i| watch(&["Drama"], 4, date(2024, 1, 1 + i), 20))
.collect();
assert!(build_insights(&rows).is_empty());
assert!(build_insights(&rows, &Utc).is_empty());
}
#[test]
@@ -356,7 +426,7 @@ fn max_three_insights_per_category() {
// Baseline dramas rated 1 make every lift large.
rows.extend((0..10).map(|i| watch(&["Drama"], 1, date(2024, 3, 1 + i), 20)));
let insights = build_insights(&rows);
let insights = build_insights(&rows, &Utc);
let rating_lifts = insights
.iter()
.filter(|i| matches!(i, Insight::RatingLift { .. }))

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

View File

@@ -313,9 +313,23 @@ async fn create_event_publisher(
fn init_tracing() {
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG").unwrap_or_else(|_| "server=debug,tower_http=debug".into()),
))
.with(log_filter("server=debug,tower_http=debug"))
.with(tracing_subscriber::fmt::layer())
.init();
}
/// `RUST_LOG` names the crates to trace in detail; anything it doesn't name
/// still reports warnings and errors. Without a baseline, a filter like
/// `presentation=debug` silently drops adapter errors (e.g. the sqlx detail
/// behind "Database operation failed").
fn log_filter(default: &str) -> tracing_subscriber::EnvFilter {
let directives = std::env::var("RUST_LOG").unwrap_or_else(|_| default.to_string());
let has_baseline = directives
.split(',')
.any(|d| !d.trim().is_empty() && !d.contains('='));
if has_baseline {
tracing_subscriber::EnvFilter::new(directives)
} else {
tracing_subscriber::EnvFilter::new(format!("warn,{directives}"))
}
}

View File

@@ -744,3 +744,103 @@ async fn get_api_insights_returns_rating_lift_for_own_user() {
assert_eq!(rhythm["peak_hour_end"], 14);
assert_eq!(rhythm["top_days"], serde_json::json!([5, 7]));
}
async fn get_insights_json(
app: Router,
user_id: Uuid,
query: &str,
) -> (StatusCode, serde_json::Value) {
let response = app
.oneshot(with_ip(
Request::builder()
.uri(format!("/api/v1/insights{query}"))
.header("authorization", format!("Bearer {user_id}"))
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
let status = response.status();
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
(status, json)
}
#[tokio::test]
async fn get_api_insights_ignores_remote_reviews() {
let (app, pool) = test_app_with(domain::testing::FakeAuthService).await;
let user_id = Uuid::new_v4();
seed_insights_diary(&pool, user_id).await;
// Federated rows stored under the same user_id are someone else's watches.
for i in 0..12u32 {
let movie_id = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)")
.bind(&movie_id)
.bind(format!("Horror {i}"))
.bind(2019)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)")
.bind(&movie_id)
.bind(66600 + i as i64)
.bind("Horror")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at, remote_actor_url) \
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&movie_id)
.bind(user_id.to_string())
.bind(5)
.bind("2024-05-05 09:00:00")
.bind("2024-05-05 09:00:00")
.bind("https://remote.example/users/someone")
.execute(&pool)
.await
.unwrap();
}
let (status, json) = get_insights_json(app, user_id, "").await;
assert_eq!(status, StatusCode::OK);
let insights = json["insights"].as_array().unwrap();
assert!(
!insights.iter().any(|i| i["genre"] == "Horror"),
"remote reviews must not feed insights: {json}"
);
let thriller = insights
.iter()
.find(|i| i["kind"] == "rating_lift" && i["genre"] == "Thriller")
.unwrap();
assert_eq!(thriller["delta"], 1.5);
}
#[tokio::test]
async fn get_api_insights_buckets_rhythm_in_requested_time_zone() {
let (app, pool) = test_app_with(domain::testing::FakeAuthService).await;
let user_id = Uuid::new_v4();
seed_insights_diary(&pool, user_id).await;
// January in Warsaw is UTC+1: 22:30 → 23:30 and 13:00 → 14:00, so the
// earliest tied window moves from 11–14 to 12–15.
let (status, json) = get_insights_json(app, user_id, "?tz=Europe/Warsaw").await;
assert_eq!(status, StatusCode::OK);
let rhythm = json["insights"]
.as_array()
.unwrap()
.iter()
.find(|i| i["kind"] == "rhythm")
.expect("rhythm expected");
assert_eq!(rhythm["peak_hour_start"], 12);
assert_eq!(rhythm["peak_hour_end"], 15);
}
#[tokio::test]
async fn get_api_insights_rejects_unknown_time_zone() {
let (app, _pool) = test_app_with(domain::testing::FakeAuthService).await;
let (status, _) = get_insights_json(app, Uuid::new_v4(), "?tz=Mars/Olympus").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}

View File

@@ -307,10 +307,24 @@ async fn main() -> anyhow::Result<()> {
}
fn init_tracing() {
let filter = std::env::var("RUST_LOG")
.unwrap_or_else(|_| "worker=info,application=info,k_ap=info".to_string());
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(filter))
.with(log_filter("worker=info,application=info,k_ap=info"))
.with(tracing_subscriber::fmt::layer())
.init();
}
/// `RUST_LOG` names the crates to trace in detail; anything it doesn't name
/// still reports warnings and errors. Without a baseline, a filter like
/// `presentation=debug` silently drops adapter errors (e.g. the sqlx detail
/// behind "Database operation failed").
fn log_filter(default: &str) -> tracing_subscriber::EnvFilter {
let directives = std::env::var("RUST_LOG").unwrap_or_else(|_| default.to_string());
let has_baseline = directives
.split(',')
.any(|d| !d.trim().is_empty() && !d.contains('='));
if has_baseline {
tracing_subscriber::EnvFilter::new(directives)
} else {
tracing_subscriber::EnvFilter::new(format!("warn,{directives}"))
}
}