federation refinement

This commit is contained in:
2026-05-09 13:53:45 +02:00
parent df71748897
commit 470b29c9e1
56 changed files with 1513 additions and 544 deletions

View File

@@ -11,6 +11,13 @@ pub enum DomainEvent {
rating: Rating,
watched_at: NaiveDateTime,
},
ReviewUpdated {
review_id: ReviewId,
movie_id: MovieId,
user_id: UserId,
rating: Rating,
watched_at: NaiveDateTime,
},
MovieDiscovered {
movie_id: MovieId,
external_metadata_id: ExternalMetadataId,

View File

@@ -5,7 +5,7 @@ use crate::{
models::collections::PageParams,
value_objects::{
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, Rating,
ReleaseYear, ReviewId, UserId,
ReleaseYear, ReviewId, UserId, Username,
},
};
pub mod collections;
@@ -247,8 +247,8 @@ impl ReviewHistory {
pub fn viewings(&self) -> &[Review] {
&self.viewings
}
pub fn viewings_mut(&mut self) -> &mut Vec<Review> {
&mut self.viewings
pub fn sort_by_date(&mut self) {
self.viewings.sort_by_key(|r| *r.watched_at());
}
}
@@ -256,37 +256,32 @@ impl ReviewHistory {
pub struct User {
id: UserId,
email: Email,
username: Username,
password_hash: PasswordHash,
}
impl User {
pub fn new(email: Email, password_hash: PasswordHash) -> Self {
Self {
id: UserId::generate(),
email,
password_hash,
}
pub fn new(email: Email, username: Username, password_hash: PasswordHash) -> Self {
Self { id: UserId::generate(), email, username, password_hash }
}
pub fn from_persistence(id: UserId, email: Email, password_hash: PasswordHash) -> Self {
Self { id, email, password_hash }
pub fn from_persistence(
id: UserId,
email: Email,
username: Username,
password_hash: PasswordHash,
) -> Self {
Self { id, email, username, password_hash }
}
pub fn update_password(&mut self, new_hash: PasswordHash) {
self.password_hash = new_hash;
}
pub fn email(&self) -> &Email {
&self.email
}
pub fn id(&self) -> &UserId {
&self.id
}
pub fn password_hash(&self) -> &PasswordHash {
&self.password_hash
}
pub fn email(&self) -> &Email { &self.email }
pub fn username(&self) -> &Username { &self.username }
pub fn id(&self) -> &UserId { &self.id }
pub fn password_hash(&self) -> &PasswordHash { &self.password_hash }
}
#[derive(Clone, Debug)]
@@ -310,21 +305,16 @@ impl FeedEntry {
#[derive(Clone, Debug)]
pub struct UserSummary {
pub user_id: UserId,
pub email: String,
email: Email,
pub total_movies: i64,
pub avg_rating: Option<f64>,
}
impl UserSummary {
pub fn display_name(&self) -> &str {
self.email.split('@').next().unwrap_or(&self.email)
}
pub fn avg_rating_display(&self) -> String {
self.avg_rating.map(|r| format!("{:.1}", r)).unwrap_or_else(|| "".to_string())
}
pub fn initial(&self) -> char {
self.display_name().chars().next().unwrap_or('?').to_ascii_uppercase()
pub fn new(user_id: UserId, email: Email, total_movies: i64, avg_rating: Option<f64>) -> Self {
Self { user_id, email, total_movies, avg_rating }
}
pub fn email(&self) -> &str { self.email.value() }
}
#[derive(Clone, Debug)]
@@ -335,17 +325,6 @@ pub struct UserStats {
pub most_active_month: Option<String>,
}
impl UserStats {
pub fn avg_rating_display(&self) -> String {
self.avg_rating.map(|r| format!("{:.1}", r)).unwrap_or_else(|| "".to_string())
}
pub fn favorite_director_display(&self) -> &str {
self.favorite_director.as_deref().unwrap_or("")
}
pub fn most_active_month_display(&self) -> &str {
self.most_active_month.as_deref().unwrap_or("")
}
}
#[derive(Clone, Debug)]
pub struct MonthActivity {

View File

@@ -11,7 +11,7 @@ use crate::{
},
value_objects::{
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl,
ReleaseYear, ReviewId, UserId,
ReleaseYear, ReviewId, UserId, Username,
},
};
@@ -57,7 +57,7 @@ pub trait MovieRepository: Send + Sync {
pub enum MetadataSearchCriteria {
ImdbId(ExternalMetadataId),
Title { title: String, year: Option<u16> },
Title { title: MovieTitle, year: Option<ReleaseYear> },
}
#[async_trait]
@@ -102,9 +102,9 @@ pub trait AuthService: Send + Sync {
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError>;
async fn save(&self, user: &User) -> Result<(), DomainError>;
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>;
}

View File

@@ -11,9 +11,7 @@ pub enum Trend {
impl ReviewHistoryAnalyzer {
pub fn sort_chronologically(history: &mut ReviewHistory) {
history
.viewings_mut()
.sort_by(|a, b| a.watched_at().cmp(&b.watched_at()));
history.sort_by_date();
}
pub fn get_latest_rating(history: &ReviewHistory) -> Option<&Rating> {
@@ -29,18 +27,20 @@ impl ReviewHistoryAnalyzer {
return Ok(Trend::Neutral);
}
let mut sorted_history = history.clone();
Self::sort_chronologically(&mut sorted_history);
let latest_review = sorted_history.viewings().last().unwrap();
let latest_rating = latest_review.rating().value() as f32;
let previous_sum: u32 = sorted_history
let latest_review = history
.viewings()
.iter()
.map(|r| r.rating().value() as u32)
.max_by_key(|r| r.watched_at())
.unwrap();
let latest_rating = latest_review.rating().value() as f32;
let count = history.viewings().len() as f32;
let total: f32 = history
.viewings()
.iter()
.map(|r| r.rating().value() as f32)
.sum();
let historical_average = previous_sum as f32 / sorted_history.viewings().len() as f32;
let historical_average = total / count;
if latest_rating > historical_average {
Ok(Trend::Improved)

View File

@@ -1,50 +1,28 @@
use crate::errors::DomainError;
use uuid::Uuid;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MovieId(Uuid);
macro_rules! uuid_id {
($name:ident) => {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct $name(Uuid);
impl MovieId {
pub fn generate() -> Self {
Self(Uuid::new_v4())
}
pub fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
pub fn value(&self) -> Uuid {
self.0
}
impl $name {
pub fn generate() -> Self {
Self(Uuid::new_v4())
}
pub fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
pub fn value(&self) -> Uuid {
self.0
}
}
};
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReviewId(Uuid);
impl ReviewId {
pub fn generate() -> Self {
Self(Uuid::new_v4())
}
pub fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
pub fn value(&self) -> Uuid {
self.0
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UserId(Uuid);
impl UserId {
pub fn generate() -> Self {
Self(Uuid::new_v4())
}
pub fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
pub fn value(&self) -> Uuid {
self.0
}
}
uuid_id!(MovieId);
uuid_id!(ReviewId);
uuid_id!(UserId);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExternalMetadataId(String);
@@ -90,15 +68,17 @@ impl PosterPath {
pub struct MovieTitle(String);
impl MovieTitle {
const MAX_LENGTH: usize = 255;
pub fn new(title: String) -> Result<Self, DomainError> {
let trimmed = title.trim();
if trimmed.is_empty() {
Err(DomainError::ValidationError(
"Movie title cannot be empty".into(),
))
} else if trimmed.len() > 255 {
} else if trimmed.len() > Self::MAX_LENGTH {
Err(DomainError::ValidationError(
"Movie title exceeds 255 characters".into(),
format!("Movie title exceeds {} characters", Self::MAX_LENGTH).into(),
))
} else {
Ok(Self(trimmed.to_string()))
@@ -114,11 +94,13 @@ impl MovieTitle {
pub struct Comment(String);
impl Comment {
const MAX_LENGTH: usize = 10_000;
pub fn new(comment: String) -> Result<Self, DomainError> {
let trimmed = comment.trim();
if trimmed.len() > 10_000 {
if trimmed.len() > Self::MAX_LENGTH {
Err(DomainError::ValidationError(
"Comment exceeds 10,000 characters".into(),
format!("Comment exceeds {} characters", Self::MAX_LENGTH).into(),
))
} else {
Ok(Self(trimmed.to_string()))
@@ -189,6 +171,35 @@ impl Email {
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Username(String);
impl Username {
const MIN_LENGTH: usize = 2;
const MAX_LENGTH: usize = 30;
/// Accepts 230 chars: lowercase letters, digits, underscores, hyphens.
/// Lowercases input automatically.
pub fn new(raw: String) -> Result<Self, DomainError> {
let s = raw.trim().to_lowercase();
if s.len() < Self::MIN_LENGTH || s.len() > Self::MAX_LENGTH {
return Err(DomainError::ValidationError(
format!("Username must be {}{} characters", Self::MIN_LENGTH, Self::MAX_LENGTH).into(),
));
}
if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
return Err(DomainError::ValidationError(
"Username may only contain letters, digits, underscores, and hyphens".into(),
));
}
Ok(Self(s))
}
pub fn value(&self) -> &str {
&self.0
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PasswordHash(String);