federation refinement
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 2–30 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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user