init domain
This commit is contained in:
14
crates/domain/Cargo.toml
Normal file
14
crates/domain/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "domain"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
common = { workspace = true }
|
||||
email_address = "0.2.9"
|
||||
16
crates/domain/src/errors.rs
Normal file
16
crates/domain/src/errors.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DomainError {
|
||||
#[error("Rating must be between 0 and {max}, but received {given}")]
|
||||
InvalidRating { max: u8, given: u8 },
|
||||
|
||||
#[error("Entity not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Business rule violation: {0}")]
|
||||
ValidationError(String),
|
||||
|
||||
#[error("Infrastructure failure: {0}")]
|
||||
InfrastructureError(String),
|
||||
}
|
||||
14
crates/domain/src/events.rs
Normal file
14
crates/domain/src/events.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
use crate::value_objects::{MovieId, Rating, ReviewId, UserId};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum DomainEvent {
|
||||
ReviewLogged {
|
||||
review_id: ReviewId,
|
||||
movie_id: MovieId,
|
||||
user_id: UserId,
|
||||
rating: Rating,
|
||||
watched_at: NaiveDateTime,
|
||||
},
|
||||
}
|
||||
6
crates/domain/src/lib.rs
Normal file
6
crates/domain/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod errors;
|
||||
pub mod events;
|
||||
pub mod models;
|
||||
pub mod ports;
|
||||
pub mod services;
|
||||
pub mod value_objects;
|
||||
43
crates/domain/src/models/collections.rs
Normal file
43
crates/domain/src/models/collections.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Paginated<T> {
|
||||
pub items: Vec<T>,
|
||||
pub total_count: u64,
|
||||
pub limit: u32,
|
||||
pub offset: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PageParams {
|
||||
pub limit: u32,
|
||||
pub offset: u32,
|
||||
}
|
||||
|
||||
impl PageParams {
|
||||
const MAX_LIMIT: u32 = 100;
|
||||
const DEFAULT_LIMIT: u32 = 20;
|
||||
|
||||
pub fn new(limit: Option<u32>, offset: Option<u32>) -> Result<Self, DomainError> {
|
||||
let l = limit.unwrap_or(Self::DEFAULT_LIMIT);
|
||||
if l == 0 || l > Self::MAX_LIMIT {
|
||||
return Err(DomainError::ValidationError(format!(
|
||||
"Limit must be between 1 and {}",
|
||||
Self::MAX_LIMIT
|
||||
)));
|
||||
}
|
||||
Ok(Self {
|
||||
limit: l,
|
||||
offset: offset.unwrap_or(0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PageParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
limit: Self::DEFAULT_LIMIT,
|
||||
offset: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
179
crates/domain/src/models/mod.rs
Normal file
179
crates/domain/src/models/mod.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
models::collections::PageParams,
|
||||
value_objects::{
|
||||
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, Rating,
|
||||
ReleaseYear, ReviewId, UserId,
|
||||
},
|
||||
};
|
||||
pub mod collections;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub enum SortDirection {
|
||||
#[default]
|
||||
Descending,
|
||||
Ascending,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DiaryFilter {
|
||||
pub sort_by: SortDirection,
|
||||
pub page: PageParams,
|
||||
pub movie_id: Option<MovieId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Movie {
|
||||
id: MovieId,
|
||||
external_metadata_id: ExternalMetadataId,
|
||||
title: MovieTitle,
|
||||
release_year: ReleaseYear,
|
||||
director: Option<String>,
|
||||
poster_path: Option<PosterPath>,
|
||||
}
|
||||
|
||||
impl Movie {
|
||||
pub fn new(
|
||||
external_metadata_id: ExternalMetadataId,
|
||||
title: MovieTitle,
|
||||
release_year: ReleaseYear,
|
||||
director: Option<String>,
|
||||
poster_path: Option<PosterPath>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: MovieId::generate(),
|
||||
external_metadata_id,
|
||||
title,
|
||||
release_year,
|
||||
director,
|
||||
poster_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &MovieId {
|
||||
&self.id
|
||||
}
|
||||
pub fn external_metadata_id(&self) -> &ExternalMetadataId {
|
||||
&self.external_metadata_id
|
||||
}
|
||||
pub fn title(&self) -> &MovieTitle {
|
||||
&self.title
|
||||
}
|
||||
pub fn release_year(&self) -> &ReleaseYear {
|
||||
&self.release_year
|
||||
}
|
||||
pub fn director(&self) -> Option<&str> {
|
||||
self.director.as_deref()
|
||||
}
|
||||
pub fn poster_path(&self) -> Option<&PosterPath> {
|
||||
self.poster_path.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Review {
|
||||
id: ReviewId,
|
||||
movie_id: MovieId,
|
||||
user_id: UserId,
|
||||
rating: Rating,
|
||||
comment: Option<Comment>,
|
||||
watched_at: chrono::NaiveDateTime,
|
||||
created_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
impl Review {
|
||||
pub fn new(
|
||||
movie_id: MovieId,
|
||||
user_id: UserId,
|
||||
rating: Rating,
|
||||
comment: Option<Comment>,
|
||||
watched_at: NaiveDateTime,
|
||||
) -> Result<Self, DomainError> {
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
if watched_at > now {
|
||||
return Err(DomainError::ValidationError(
|
||||
"watched_at cannot be in the future".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
id: ReviewId::generate(),
|
||||
movie_id,
|
||||
user_id,
|
||||
rating,
|
||||
comment,
|
||||
watched_at,
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &ReviewId {
|
||||
&self.id
|
||||
}
|
||||
pub fn movie_id(&self) -> &MovieId {
|
||||
&self.movie_id
|
||||
}
|
||||
pub fn user_id(&self) -> &UserId {
|
||||
&self.user_id
|
||||
}
|
||||
pub fn rating(&self) -> &Rating {
|
||||
&self.rating
|
||||
}
|
||||
pub fn comment(&self) -> Option<&Comment> {
|
||||
self.comment.as_ref()
|
||||
}
|
||||
pub fn watched_at(&self) -> &NaiveDateTime {
|
||||
&self.watched_at
|
||||
}
|
||||
pub fn created_at(&self) -> &NaiveDateTime {
|
||||
&self.created_at
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DiaryEntry {
|
||||
pub movie: Movie,
|
||||
pub review: Review,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ReviewHistory {
|
||||
pub movie: Movie,
|
||||
pub viewings: Vec<Review>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct User {
|
||||
id: UserId,
|
||||
email: Email,
|
||||
password_hash: PasswordHash,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn new(email: Email, password_hash: PasswordHash) -> Self {
|
||||
Self {
|
||||
id: UserId::generate(),
|
||||
email,
|
||||
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
|
||||
}
|
||||
}
|
||||
61
crates/domain/src/ports.rs
Normal file
61
crates/domain/src/ports.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, collections::Paginated},
|
||||
value_objects::{ExternalMetadataId, MovieId, PasswordHash, PosterPath, UserId},
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait MovieRepository: Send + Sync {
|
||||
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>;
|
||||
|
||||
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError>;
|
||||
|
||||
async fn query_diary(&self, filter: &DiaryFilter)
|
||||
-> Result<Paginated<DiaryEntry>, DomainError>;
|
||||
|
||||
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait MetadataClient: Send + Sync {
|
||||
async fn fetch_movie_metadata(
|
||||
&self,
|
||||
external_metadata_id: &ExternalMetadataId,
|
||||
) -> Result<Movie, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait PosterFetcherClient: Send + Sync {
|
||||
async fn fetch_poster_bytes(&self, poster_url: &str) -> Result<Vec<u8>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait PosterStorage: Send + Sync {
|
||||
async fn store_poster(
|
||||
&self,
|
||||
movie_id: &MovieId,
|
||||
image_bytes: &[u8],
|
||||
) -> Result<PosterPath, DomainError>;
|
||||
|
||||
async fn get_poster(&self, poster_path: &PosterPath) -> Result<Vec<u8>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AuthService: Send + Sync {
|
||||
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait EventPublisher: Send + Sync {
|
||||
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait PasswordHasher: Send + Sync {
|
||||
async fn hash(&self, plain_password: &str) -> Result<PasswordHash, DomainError>;
|
||||
|
||||
async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result<bool, DomainError>;
|
||||
}
|
||||
1
crates/domain/src/services/mod.rs
Normal file
1
crates/domain/src/services/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod review_history;
|
||||
53
crates/domain/src/services/review_history.rs
Normal file
53
crates/domain/src/services/review_history.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use crate::{errors::DomainError, models::ReviewHistory, value_objects::Rating};
|
||||
|
||||
pub struct ReviewHistoryAnalyzer;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Trend {
|
||||
Improved,
|
||||
Declined,
|
||||
Neutral,
|
||||
}
|
||||
|
||||
impl ReviewHistoryAnalyzer {
|
||||
pub fn sort_chronologically(history: &mut ReviewHistory) {
|
||||
history
|
||||
.viewings
|
||||
.sort_by(|a, b| a.watched_at().cmp(&b.watched_at()));
|
||||
}
|
||||
|
||||
pub fn get_latest_rating(history: &ReviewHistory) -> Option<&Rating> {
|
||||
history
|
||||
.viewings
|
||||
.iter()
|
||||
.max_by_key(|r| r.watched_at())
|
||||
.map(|r| r.rating())
|
||||
}
|
||||
|
||||
pub fn rating_trend(history: &ReviewHistory) -> Result<Trend, DomainError> {
|
||||
if history.viewings.len() < 2 {
|
||||
return Ok(Trend::Neutral);
|
||||
}
|
||||
|
||||
let mut sorted_history = history.clone();
|
||||
Self::sort_chronologically(&mut sorted_history);
|
||||
|
||||
let latest_review = sorted_history.viewings.pop().unwrap();
|
||||
let latest_rating = latest_review.rating().value() as f32;
|
||||
|
||||
let previous_sum: u32 = sorted_history
|
||||
.viewings
|
||||
.iter()
|
||||
.map(|r| r.rating().value() as u32)
|
||||
.sum();
|
||||
let historical_average = previous_sum as f32 / sorted_history.viewings.len() as f32;
|
||||
|
||||
if latest_rating > historical_average {
|
||||
Ok(Trend::Improved)
|
||||
} else if latest_rating < historical_average {
|
||||
Ok(Trend::Declined)
|
||||
} else {
|
||||
Ok(Trend::Neutral)
|
||||
}
|
||||
}
|
||||
}
|
||||
208
crates/domain/src/value_objects.rs
Normal file
208
crates/domain/src/value_objects.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
use crate::errors::DomainError;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MovieId(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
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExternalMetadataId(String);
|
||||
|
||||
impl ExternalMetadataId {
|
||||
pub fn new(id: String) -> Result<Self, DomainError> {
|
||||
let trimmed = id.trim();
|
||||
if trimmed.is_empty() {
|
||||
Err(DomainError::ValidationError(
|
||||
"External metadata ID cannot be empty".into(),
|
||||
))
|
||||
} else {
|
||||
Ok(Self(trimmed.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PosterPath(String);
|
||||
|
||||
impl PosterPath {
|
||||
pub fn new(path: String) -> Result<Self, DomainError> {
|
||||
let trimmed = path.trim();
|
||||
if trimmed.is_empty() {
|
||||
Err(DomainError::ValidationError(
|
||||
"Poster path cannot be empty".into(),
|
||||
))
|
||||
} else {
|
||||
Ok(Self(trimmed.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MovieTitle(String);
|
||||
|
||||
impl MovieTitle {
|
||||
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 {
|
||||
Err(DomainError::ValidationError(
|
||||
"Movie title exceeds 255 characters".into(),
|
||||
))
|
||||
} else {
|
||||
Ok(Self(trimmed.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Comment(String);
|
||||
|
||||
impl Comment {
|
||||
pub fn new(comment: String) -> Result<Self, DomainError> {
|
||||
let trimmed = comment.trim();
|
||||
if trimmed.len() > 10_000 {
|
||||
Err(DomainError::ValidationError(
|
||||
"Comment exceeds 10,000 characters".into(),
|
||||
))
|
||||
} else {
|
||||
Ok(Self(trimmed.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Rating(u8);
|
||||
|
||||
impl Rating {
|
||||
const MAX: u8 = 5;
|
||||
|
||||
pub fn new(value: u8) -> Result<Self, DomainError> {
|
||||
if value <= Self::MAX {
|
||||
Ok(Self(value))
|
||||
} else {
|
||||
Err(DomainError::InvalidRating {
|
||||
max: Self::MAX,
|
||||
given: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
pub fn value(&self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ReleaseYear(u16);
|
||||
|
||||
impl ReleaseYear {
|
||||
const EARLIEST: u16 = 1888;
|
||||
pub fn new(year: u16) -> Result<Self, DomainError> {
|
||||
if year < Self::EARLIEST {
|
||||
Err(DomainError::ValidationError(format!(
|
||||
"Release year cannot be earlier than {} (first film ever made)",
|
||||
Self::EARLIEST
|
||||
)))
|
||||
} else {
|
||||
Ok(Self(year))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn value(&self) -> u16 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Email(String);
|
||||
|
||||
impl Email {
|
||||
pub fn new(email: String) -> Result<Self, DomainError> {
|
||||
let trimmed = email.trim();
|
||||
if email_address::EmailAddress::is_valid(trimmed) {
|
||||
Ok(Self(trimmed.to_string()))
|
||||
} else {
|
||||
Err(DomainError::ValidationError("Invalid email format".into()))
|
||||
}
|
||||
}
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PasswordHash(String);
|
||||
|
||||
impl PasswordHash {
|
||||
pub fn new(hash: String) -> Result<Self, DomainError> {
|
||||
if hash.is_empty() {
|
||||
Err(DomainError::ValidationError(
|
||||
"Password hash cannot be empty".into(),
|
||||
))
|
||||
} else {
|
||||
Ok(Self(hash))
|
||||
}
|
||||
}
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user