init domain

This commit is contained in:
2026-05-04 00:26:10 +02:00
commit 810bad1126
30 changed files with 3033 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
[package]
name = "auth"
version = "0.1.0"
edition = "2024"
[dependencies]

View File

@@ -0,0 +1,14 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}

View File

@@ -0,0 +1,6 @@
[package]
name = "metadata"
version = "0.1.0"
edition = "2024"
[dependencies]

View File

@@ -0,0 +1,14 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}

View File

@@ -0,0 +1,6 @@
[package]
name = "rss"
version = "0.1.0"
edition = "2024"
[dependencies]

View File

@@ -0,0 +1,14 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}

View File

@@ -0,0 +1,12 @@
[package]
name = "sqlite"
version = "0.1.0"
edition = "2024"
[dependencies]
sqlx = { version = "0.8.6", features = [
"runtime-tokio-rustls",
"sqlite",
"uuid",
"macros",
] }

View File

@@ -0,0 +1,14 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}

View File

@@ -0,0 +1,6 @@
[package]
name = "application"
version = "0.1.0"
edition = "2024"
[dependencies]

View File

@@ -0,0 +1,14 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}

7
crates/common/Cargo.toml Normal file
View File

@@ -0,0 +1,7 @@
[package]
name = "common"
version = "0.1.0"
edition = "2024"
[dependencies]
thiserror = { workspace = true }

View File

1
crates/common/src/lib.rs Normal file
View File

@@ -0,0 +1 @@
pub mod errors;

14
crates/domain/Cargo.toml Normal file
View 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"

View 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),
}

View 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
View File

@@ -0,0 +1,6 @@
pub mod errors;
pub mod events;
pub mod models;
pub mod ports;
pub mod services;
pub mod value_objects;

View 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,
}
}
}

View 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
}
}

View 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>;
}

View File

@@ -0,0 +1 @@
pub mod review_history;

View 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)
}
}
}

View 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
}
}

View File

@@ -0,0 +1,6 @@
[package]
name = "presentation"
version = "0.1.0"
edition = "2024"
[dependencies]

View File

@@ -0,0 +1,3 @@
fn main() {
println!("Hello, world!");
}