restructure
This commit is contained in:
@@ -138,7 +138,7 @@ impl ActivityPubEventHandler {
|
|||||||
.federation_settings
|
.federation_settings
|
||||||
.get_federation_flags(user_id)
|
.get_federation_flags(user_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(domain::ports::FederationFlags {
|
.unwrap_or(domain::models::FederationFlags {
|
||||||
goals: true,
|
goals: true,
|
||||||
reviews: true,
|
reviews: true,
|
||||||
watchlist: true,
|
watchlist: true,
|
||||||
@@ -204,7 +204,7 @@ impl ActivityPubEventHandler {
|
|||||||
.federation_settings
|
.federation_settings
|
||||||
.get_federation_flags(user_id)
|
.get_federation_flags(user_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(domain::ports::FederationFlags {
|
.unwrap_or(domain::models::FederationFlags {
|
||||||
goals: true,
|
goals: true,
|
||||||
reviews: true,
|
reviews: true,
|
||||||
watchlist: true,
|
watchlist: true,
|
||||||
@@ -283,7 +283,7 @@ impl ActivityPubEventHandler {
|
|||||||
.federation_settings
|
.federation_settings
|
||||||
.get_federation_flags(user_id)
|
.get_federation_flags(user_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(domain::ports::FederationFlags {
|
.unwrap_or(domain::models::FederationFlags {
|
||||||
goals: true,
|
goals: true,
|
||||||
reviews: true,
|
reviews: true,
|
||||||
watchlist: true,
|
watchlist: true,
|
||||||
@@ -363,7 +363,7 @@ impl ActivityPubEventHandler {
|
|||||||
.federation_settings
|
.federation_settings
|
||||||
.get_federation_flags(user_id)
|
.get_federation_flags(user_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(domain::ports::FederationFlags {
|
.unwrap_or(domain::models::FederationFlags {
|
||||||
goals: true,
|
goals: true,
|
||||||
reviews: true,
|
reviews: true,
|
||||||
watchlist: true,
|
watchlist: true,
|
||||||
@@ -403,7 +403,7 @@ impl ActivityPubEventHandler {
|
|||||||
.federation_settings
|
.federation_settings
|
||||||
.get_federation_flags(user_id)
|
.get_federation_flags(user_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(domain::ports::FederationFlags {
|
.unwrap_or(domain::models::FederationFlags {
|
||||||
goals: true,
|
goals: true,
|
||||||
reviews: true,
|
reviews: true,
|
||||||
watchlist: true,
|
watchlist: true,
|
||||||
@@ -448,7 +448,7 @@ impl ActivityPubEventHandler {
|
|||||||
.federation_settings
|
.federation_settings
|
||||||
.get_federation_flags(user_id)
|
.get_federation_flags(user_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(domain::ports::FederationFlags {
|
.unwrap_or(domain::models::FederationFlags {
|
||||||
goals: true,
|
goals: true,
|
||||||
reviews: true,
|
reviews: true,
|
||||||
watchlist: true,
|
watchlist: true,
|
||||||
@@ -486,7 +486,7 @@ impl ActivityPubEventHandler {
|
|||||||
.federation_settings
|
.federation_settings
|
||||||
.get_federation_flags(user_id)
|
.get_federation_flags(user_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(domain::ports::FederationFlags {
|
.unwrap_or(domain::models::FederationFlags {
|
||||||
goals: true,
|
goals: true,
|
||||||
reviews: true,
|
reviews: true,
|
||||||
watchlist: true,
|
watchlist: true,
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::{AuthService, GeneratedToken, PasswordHasher},
|
models::GeneratedToken,
|
||||||
|
ports::{AuthService, PasswordHasher},
|
||||||
value_objects::{PasswordHash, UserId},
|
value_objects::{PasswordHash, UserId},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::Movie,
|
models::{MetadataSearchCriteria, Movie},
|
||||||
ports::{MetadataClient, MetadataSearchCriteria},
|
ports::MetadataClient,
|
||||||
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
|
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::MetadataSearchCriteria,
|
models::MetadataSearchCriteria,
|
||||||
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
|
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::MetadataSearchCriteria,
|
models::MetadataSearchCriteria,
|
||||||
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
|
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|||||||
@@ -740,7 +740,7 @@ impl domain::ports::SocialQueryPort for PostgresFederationRepository {
|
|||||||
|
|
||||||
async fn list_all_followed_remote_actors(
|
async fn list_all_followed_remote_actors(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<Vec<domain::ports::RemoteActorInfo>, domain::errors::DomainError> {
|
) -> Result<Vec<domain::models::RemoteActorInfo>, domain::errors::DomainError> {
|
||||||
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
|
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
|
||||||
"SELECT DISTINCT ar.url, ar.handle, ar.display_name
|
"SELECT DISTINCT ar.url, ar.handle, ar.display_name
|
||||||
FROM ap_remote_actors ar
|
FROM ap_remote_actors ar
|
||||||
@@ -753,7 +753,7 @@ impl domain::ports::SocialQueryPort for PostgresFederationRepository {
|
|||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(
|
.map(
|
||||||
|(url, handle, display_name)| domain::ports::RemoteActorInfo {
|
|(url, handle, display_name)| domain::models::RemoteActorInfo {
|
||||||
url,
|
url,
|
||||||
handle,
|
handle,
|
||||||
display_name,
|
display_name,
|
||||||
@@ -795,7 +795,7 @@ impl domain::ports::SocialQueryPort for PostgresFederationRepository {
|
|||||||
async fn get_pending_followers(
|
async fn get_pending_followers(
|
||||||
&self,
|
&self,
|
||||||
user_id: uuid::Uuid,
|
user_id: uuid::Uuid,
|
||||||
) -> Result<Vec<domain::ports::PendingFollowerInfo>, domain::errors::DomainError> {
|
) -> Result<Vec<domain::models::PendingFollowerInfo>, domain::errors::DomainError> {
|
||||||
let uid = user_id.to_string();
|
let uid = user_id.to_string();
|
||||||
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
|
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
|
||||||
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url
|
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url
|
||||||
@@ -810,7 +810,7 @@ impl domain::ports::SocialQueryPort for PostgresFederationRepository {
|
|||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(
|
.map(
|
||||||
|(url, handle, display_name, avatar_url)| domain::ports::PendingFollowerInfo {
|
|(url, handle, display_name, avatar_url)| domain::models::PendingFollowerInfo {
|
||||||
url,
|
url,
|
||||||
handle,
|
handle,
|
||||||
display_name,
|
display_name,
|
||||||
|
|||||||
@@ -242,18 +242,18 @@ impl DiaryRepository for PostgresDiaryRepository {
|
|||||||
&self,
|
&self,
|
||||||
page: &PageParams,
|
page: &PageParams,
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
self.query_activity_feed_filtered(page, &domain::ports::FeedSortBy::Date, None, None)
|
self.query_activity_feed_filtered(page, &domain::models::FeedSortBy::Date, None, None)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn query_activity_feed_filtered(
|
async fn query_activity_feed_filtered(
|
||||||
&self,
|
&self,
|
||||||
page: &PageParams,
|
page: &PageParams,
|
||||||
sort_by: &domain::ports::FeedSortBy,
|
sort_by: &domain::models::FeedSortBy,
|
||||||
search: Option<&str>,
|
search: Option<&str>,
|
||||||
following: Option<&domain::ports::FollowingFilter>,
|
following: Option<&domain::models::FollowingFilter>,
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
use domain::ports::FeedSortBy;
|
use domain::models::FeedSortBy;
|
||||||
|
|
||||||
let limit = page.limit as i64;
|
let limit = page.limit as i64;
|
||||||
let offset = page.offset as i64;
|
let offset = page.offset as i64;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::UserSettings,
|
models::{FederationFlags, UserSettings},
|
||||||
ports::{FederationFlags, UserFederationSettingsQuery, UserSettingsRepository},
|
ports::{UserFederationSettingsQuery, UserSettingsRepository},
|
||||||
value_objects::UserId,
|
value_objects::UserId,
|
||||||
};
|
};
|
||||||
use sqlx::{PgPool, Row};
|
use sqlx::{PgPool, Row};
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ use async_trait::async_trait;
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::wrapup::{DateRange, WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus},
|
models::wrapup::{
|
||||||
ports::{WrapUpMovieRow, WrapUpRepository, WrapUpStatsQuery},
|
DateRange, WrapUpMovieRow, WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus,
|
||||||
|
},
|
||||||
|
ports::{WrapUpRepository, WrapUpStatsQuery},
|
||||||
value_objects::WrapUpId,
|
value_objects::WrapUpId,
|
||||||
};
|
};
|
||||||
use sqlx::{PgPool, Row};
|
use sqlx::{PgPool, Row};
|
||||||
|
|||||||
@@ -927,7 +927,7 @@ impl domain::ports::SocialQueryPort for SqliteFederationRepository {
|
|||||||
|
|
||||||
async fn list_all_followed_remote_actors(
|
async fn list_all_followed_remote_actors(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<Vec<domain::ports::RemoteActorInfo>, domain::errors::DomainError> {
|
) -> Result<Vec<domain::models::RemoteActorInfo>, domain::errors::DomainError> {
|
||||||
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
|
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
|
||||||
"SELECT DISTINCT ar.url, ar.handle, ar.display_name
|
"SELECT DISTINCT ar.url, ar.handle, ar.display_name
|
||||||
FROM ap_remote_actors ar
|
FROM ap_remote_actors ar
|
||||||
@@ -941,7 +941,7 @@ impl domain::ports::SocialQueryPort for SqliteFederationRepository {
|
|||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(
|
.map(
|
||||||
|(url, handle, display_name)| domain::ports::RemoteActorInfo {
|
|(url, handle, display_name)| domain::models::RemoteActorInfo {
|
||||||
url,
|
url,
|
||||||
handle,
|
handle,
|
||||||
display_name,
|
display_name,
|
||||||
@@ -983,7 +983,7 @@ impl domain::ports::SocialQueryPort for SqliteFederationRepository {
|
|||||||
async fn get_pending_followers(
|
async fn get_pending_followers(
|
||||||
&self,
|
&self,
|
||||||
user_id: uuid::Uuid,
|
user_id: uuid::Uuid,
|
||||||
) -> Result<Vec<domain::ports::PendingFollowerInfo>, domain::errors::DomainError> {
|
) -> Result<Vec<domain::models::PendingFollowerInfo>, domain::errors::DomainError> {
|
||||||
let uid = user_id.to_string();
|
let uid = user_id.to_string();
|
||||||
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
|
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
|
||||||
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url
|
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url
|
||||||
@@ -998,7 +998,7 @@ impl domain::ports::SocialQueryPort for SqliteFederationRepository {
|
|||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(
|
.map(
|
||||||
|(url, handle, display_name, avatar_url)| domain::ports::PendingFollowerInfo {
|
|(url, handle, display_name, avatar_url)| domain::models::PendingFollowerInfo {
|
||||||
url,
|
url,
|
||||||
handle,
|
handle,
|
||||||
display_name,
|
display_name,
|
||||||
|
|||||||
@@ -223,18 +223,18 @@ impl DiaryRepository for SqliteDiaryRepository {
|
|||||||
&self,
|
&self,
|
||||||
page: &PageParams,
|
page: &PageParams,
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
self.query_activity_feed_filtered(page, &domain::ports::FeedSortBy::Date, None, None)
|
self.query_activity_feed_filtered(page, &domain::models::FeedSortBy::Date, None, None)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn query_activity_feed_filtered(
|
async fn query_activity_feed_filtered(
|
||||||
&self,
|
&self,
|
||||||
page: &PageParams,
|
page: &PageParams,
|
||||||
sort_by: &domain::ports::FeedSortBy,
|
sort_by: &domain::models::FeedSortBy,
|
||||||
search: Option<&str>,
|
search: Option<&str>,
|
||||||
following: Option<&domain::ports::FollowingFilter>,
|
following: Option<&domain::models::FollowingFilter>,
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
use domain::ports::FeedSortBy;
|
use domain::models::FeedSortBy;
|
||||||
|
|
||||||
let limit = page.limit as i64;
|
let limit = page.limit as i64;
|
||||||
let offset = page.offset as i64;
|
let offset = page.offset as i64;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use domain::{
|
use domain::{
|
||||||
models::collections::PageParams,
|
models::collections::PageParams,
|
||||||
ports::{DiaryRepository, FeedSortBy, FollowingFilter},
|
models::{FeedSortBy, FollowingFilter},
|
||||||
|
ports::DiaryRepository,
|
||||||
};
|
};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::UserSettings,
|
models::{FederationFlags, UserSettings},
|
||||||
ports::{FederationFlags, UserFederationSettingsQuery, UserSettingsRepository},
|
ports::{UserFederationSettingsQuery, UserSettingsRepository},
|
||||||
value_objects::UserId,
|
value_objects::UserId,
|
||||||
};
|
};
|
||||||
use sqlx::{Row, SqlitePool};
|
use sqlx::{Row, SqlitePool};
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ use async_trait::async_trait;
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::wrapup::{DateRange, WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus},
|
models::wrapup::{
|
||||||
ports::{WrapUpMovieRow, WrapUpRepository, WrapUpStatsQuery},
|
DateRange, WrapUpMovieRow, WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus,
|
||||||
|
},
|
||||||
|
ports::{WrapUpRepository, WrapUpStatsQuery},
|
||||||
value_objects::WrapUpId,
|
value_objects::WrapUpId,
|
||||||
};
|
};
|
||||||
use sqlx::{Row, SqlitePool};
|
use sqlx::{Row, SqlitePool};
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use crate::diary::{deps::GetActivityFeedDeps, queries::GetActivityFeedQuery};
|
use crate::diary::{deps::GetActivityFeedDeps, queries::GetActivityFeedQuery};
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
|
models::FollowingFilter,
|
||||||
models::{
|
models::{
|
||||||
FeedEntry,
|
FeedEntry,
|
||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
},
|
},
|
||||||
ports::FollowingFilter,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::Movie,
|
models::{MetadataSearchCriteria, Movie},
|
||||||
ports::{MetadataClient, MetadataSearchCriteria, MovieRepository},
|
ports::{MetadataClient, MovieRepository},
|
||||||
value_objects::{ExternalMetadataId, MovieTitle, ReleaseYear},
|
value_objects::{ExternalMetadataId, MovieTitle, ReleaseYear},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ pub struct GetReviewHistoryQuery {
|
|||||||
pub struct GetActivityFeedQuery {
|
pub struct GetActivityFeedQuery {
|
||||||
pub limit: u32,
|
pub limit: u32,
|
||||||
pub offset: u32,
|
pub offset: u32,
|
||||||
pub sort_by: domain::ports::FeedSortBy,
|
pub sort_by: domain::models::FeedSortBy,
|
||||||
pub search: Option<String>,
|
pub search: Option<String>,
|
||||||
pub viewer_user_id: Option<Uuid>,
|
pub viewer_user_id: Option<Uuid>,
|
||||||
pub filter_following: bool,
|
pub filter_following: bool,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ async fn returns_empty_feed() {
|
|||||||
GetActivityFeedQuery {
|
GetActivityFeedQuery {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
sort_by: domain::ports::FeedSortBy::Date,
|
sort_by: domain::models::FeedSortBy::Date,
|
||||||
search: None,
|
search: None,
|
||||||
viewer_user_id: None,
|
viewer_user_id: None,
|
||||||
filter_following: false,
|
filter_following: false,
|
||||||
@@ -50,7 +50,7 @@ async fn returns_feed_with_following_filter() {
|
|||||||
GetActivityFeedQuery {
|
GetActivityFeedQuery {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
sort_by: domain::ports::FeedSortBy::Date,
|
sort_by: domain::models::FeedSortBy::Date,
|
||||||
search: None,
|
search: None,
|
||||||
viewer_user_id: Some(viewer),
|
viewer_user_id: Some(viewer),
|
||||||
filter_following: true,
|
filter_following: true,
|
||||||
@@ -80,12 +80,12 @@ impl domain::ports::SocialQueryPort for FakeSocialWithFollowing {
|
|||||||
async fn get_pending_followers(
|
async fn get_pending_followers(
|
||||||
&self,
|
&self,
|
||||||
_: uuid::Uuid,
|
_: uuid::Uuid,
|
||||||
) -> Result<Vec<domain::ports::PendingFollowerInfo>, DomainError> {
|
) -> Result<Vec<domain::models::PendingFollowerInfo>, DomainError> {
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
}
|
}
|
||||||
async fn list_all_followed_remote_actors(
|
async fn list_all_followed_remote_actors(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<Vec<domain::ports::RemoteActorInfo>, DomainError> {
|
) -> Result<Vec<domain::models::RemoteActorInfo>, DomainError> {
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,7 @@ async fn following_filter_parses_local_and_remote_urls() {
|
|||||||
GetActivityFeedQuery {
|
GetActivityFeedQuery {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
sort_by: domain::ports::FeedSortBy::Date,
|
sort_by: domain::models::FeedSortBy::Date,
|
||||||
search: None,
|
search: None,
|
||||||
viewer_user_id: Some(viewer),
|
viewer_user_id: Some(viewer),
|
||||||
filter_following: true,
|
filter_following: true,
|
||||||
@@ -146,7 +146,7 @@ async fn following_filter_without_viewer_returns_none() {
|
|||||||
GetActivityFeedQuery {
|
GetActivityFeedQuery {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
sort_by: domain::ports::FeedSortBy::Date,
|
sort_by: domain::models::FeedSortBy::Date,
|
||||||
search: None,
|
search: None,
|
||||||
viewer_user_id: None,
|
viewer_user_id: None,
|
||||||
filter_following: true,
|
filter_following: true,
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use super::*;
|
|||||||
use crate::diary::commands::MovieInput;
|
use crate::diary::commands::MovieInput;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::Movie,
|
models::{MetadataSearchCriteria, Movie},
|
||||||
ports::{MetadataSearchCriteria, MovieRepository},
|
ports::MovieRepository,
|
||||||
value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear},
|
value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ use async_trait::async_trait;
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::Movie,
|
|
||||||
models::WatchlistEntry,
|
models::WatchlistEntry,
|
||||||
ports::{MetadataClient, MetadataSearchCriteria, MovieRepository, WatchlistRepository},
|
models::{MetadataSearchCriteria, Movie},
|
||||||
|
ports::{MetadataClient, MovieRepository, WatchlistRepository},
|
||||||
testing::{
|
testing::{
|
||||||
FakeMetadataClient, InMemoryMovieRepository, InMemoryReviewRepository,
|
FakeMetadataClient, InMemoryMovieRepository, InMemoryReviewRepository,
|
||||||
InMemoryWatchlistRepository, NoopEventPublisher,
|
InMemoryWatchlistRepository, NoopEventPublisher,
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ struct FakeMetaWithPoster;
|
|||||||
impl MetadataClient for FakeMetaWithPoster {
|
impl MetadataClient for FakeMetaWithPoster {
|
||||||
async fn fetch_movie_metadata(
|
async fn fetch_movie_metadata(
|
||||||
&self,
|
&self,
|
||||||
_: &domain::ports::MetadataSearchCriteria,
|
_: &domain::models::MetadataSearchCriteria,
|
||||||
) -> Result<Movie, DomainError> {
|
) -> Result<Movie, DomainError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ use crate::users::{
|
|||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
|
models::FeedSortBy,
|
||||||
models::{
|
models::{
|
||||||
DiaryEntry, DiaryFilter, SortDirection, UserStats, UserTrends,
|
DiaryEntry, DiaryFilter, SortDirection, UserStats, UserTrends,
|
||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
},
|
},
|
||||||
ports::FeedSortBy,
|
|
||||||
value_objects::UserId,
|
value_objects::UserId,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ mod helper_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn feed_sort_to_direction_all_variants() {
|
fn feed_sort_to_direction_all_variants() {
|
||||||
use domain::ports::FeedSortBy;
|
use domain::models::FeedSortBy;
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
feed_sort_to_direction(FeedSortBy::Date),
|
feed_sort_to_direction(FeedSortBy::Date),
|
||||||
SortDirection::Descending
|
SortDirection::Descending
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use std::sync::Arc;
|
|||||||
use crate::users::queries::GetUsersQuery;
|
use crate::users::queries::GetUsersQuery;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::UserSummary,
|
models::{RemoteActorInfo, UserSummary},
|
||||||
ports::{RemoteActorInfo, SocialQueryPort, UserRepository},
|
ports::{SocialQueryPort, UserRepository},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct UsersListData {
|
pub struct UsersListData {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ pub struct GetUserProfileQuery {
|
|||||||
pub view: ProfileView,
|
pub view: ProfileView,
|
||||||
pub limit: Option<u32>,
|
pub limit: Option<u32>,
|
||||||
pub offset: Option<u32>,
|
pub offset: Option<u32>,
|
||||||
pub sort_by: domain::ports::FeedSortBy,
|
pub sort_by: domain::models::FeedSortBy,
|
||||||
pub search: Option<String>,
|
pub search: Option<String>,
|
||||||
pub is_own_profile: bool,
|
pub is_own_profile: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ async fn returns_profile_with_empty_stats() {
|
|||||||
view: ProfileView::Recent,
|
view: ProfileView::Recent,
|
||||||
limit: None,
|
limit: None,
|
||||||
offset: None,
|
offset: None,
|
||||||
sort_by: domain::ports::FeedSortBy::Date,
|
sort_by: domain::models::FeedSortBy::Date,
|
||||||
search: None,
|
search: None,
|
||||||
is_own_profile: true,
|
is_own_profile: true,
|
||||||
},
|
},
|
||||||
@@ -85,7 +85,7 @@ async fn returns_history_view() {
|
|||||||
view: ProfileView::History,
|
view: ProfileView::History,
|
||||||
limit: None,
|
limit: None,
|
||||||
offset: None,
|
offset: None,
|
||||||
sort_by: domain::ports::FeedSortBy::Date,
|
sort_by: domain::models::FeedSortBy::Date,
|
||||||
search: None,
|
search: None,
|
||||||
is_own_profile: true,
|
is_own_profile: true,
|
||||||
},
|
},
|
||||||
@@ -121,7 +121,7 @@ async fn returns_trends_view() {
|
|||||||
view: ProfileView::Trends,
|
view: ProfileView::Trends,
|
||||||
limit: None,
|
limit: None,
|
||||||
offset: None,
|
offset: None,
|
||||||
sort_by: domain::ports::FeedSortBy::Date,
|
sort_by: domain::models::FeedSortBy::Date,
|
||||||
search: None,
|
search: None,
|
||||||
is_own_profile: true,
|
is_own_profile: true,
|
||||||
},
|
},
|
||||||
@@ -157,7 +157,7 @@ async fn returns_ratings_view() {
|
|||||||
view: ProfileView::Ratings,
|
view: ProfileView::Ratings,
|
||||||
limit: None,
|
limit: None,
|
||||||
offset: None,
|
offset: None,
|
||||||
sort_by: domain::ports::FeedSortBy::Rating,
|
sort_by: domain::models::FeedSortBy::Rating,
|
||||||
search: None,
|
search: None,
|
||||||
is_own_profile: true,
|
is_own_profile: true,
|
||||||
},
|
},
|
||||||
@@ -191,7 +191,7 @@ async fn returns_recent_with_search() {
|
|||||||
view: ProfileView::Recent,
|
view: ProfileView::Recent,
|
||||||
limit: Some(10),
|
limit: Some(10),
|
||||||
offset: Some(0),
|
offset: Some(0),
|
||||||
sort_by: domain::ports::FeedSortBy::Date,
|
sort_by: domain::models::FeedSortBy::Date,
|
||||||
search: Some("blade".into()),
|
search: Some("blade".into()),
|
||||||
is_own_profile: true,
|
is_own_profile: true,
|
||||||
},
|
},
|
||||||
@@ -225,7 +225,7 @@ async fn non_own_profile_skips_pending_followers() {
|
|||||||
view: ProfileView::Recent,
|
view: ProfileView::Recent,
|
||||||
limit: None,
|
limit: None,
|
||||||
offset: None,
|
offset: None,
|
||||||
sort_by: domain::ports::FeedSortBy::Date,
|
sort_by: domain::models::FeedSortBy::Date,
|
||||||
search: None,
|
search: None,
|
||||||
is_own_profile: false,
|
is_own_profile: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
|
use domain::models::WrapUpMovieRow;
|
||||||
use domain::models::wrapup::{DateRange, WrapUpScope};
|
use domain::models::wrapup::{DateRange, WrapUpScope};
|
||||||
use domain::ports::WrapUpMovieRow;
|
|
||||||
use domain::testing::InMemoryWrapUpStatsQuery;
|
use domain::testing::InMemoryWrapUpStatsQuery;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
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),
|
|
||||||
|
|
||||||
#[error("Unauthorized: {0}")]
|
|
||||||
Unauthorized(String),
|
|
||||||
|
|
||||||
#[error("Forbidden: {0}")]
|
|
||||||
Forbidden(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DomainError {
|
|
||||||
pub fn is_transient(&self) -> bool {
|
|
||||||
matches!(self, DomainError::InfrastructureError(_))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn infrastructure_error_is_transient() {
|
|
||||||
assert!(DomainError::InfrastructureError("network timeout".into()).is_transient());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn not_found_is_not_transient() {
|
|
||||||
assert!(!DomainError::NotFound("thing".into()).is_transient());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validation_error_is_not_transient() {
|
|
||||||
assert!(!DomainError::ValidationError("bad input".into()).is_transient());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unauthorized_is_not_transient() {
|
|
||||||
assert!(!DomainError::Unauthorized("token expired".into()).is_transient());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn forbidden_is_not_transient() {
|
|
||||||
assert!(!DomainError::Forbidden("no access".into()).is_transient());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_rating_is_not_transient() {
|
|
||||||
assert!(!DomainError::InvalidRating { max: 5, given: 9 }.is_transient());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
32
crates/domain/src/errors/mod.rs
Normal file
32
crates/domain/src/errors/mod.rs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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),
|
||||||
|
|
||||||
|
#[error("Unauthorized: {0}")]
|
||||||
|
Unauthorized(String),
|
||||||
|
|
||||||
|
#[error("Forbidden: {0}")]
|
||||||
|
Forbidden(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DomainError {
|
||||||
|
pub fn is_transient(&self) -> bool {
|
||||||
|
matches!(self, DomainError::InfrastructureError(_))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests.rs"]
|
||||||
|
mod tests;
|
||||||
31
crates/domain/src/errors/tests.rs
Normal file
31
crates/domain/src/errors/tests.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn infrastructure_error_is_transient() {
|
||||||
|
assert!(DomainError::InfrastructureError("network timeout".into()).is_transient());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_found_is_not_transient() {
|
||||||
|
assert!(!DomainError::NotFound("thing".into()).is_transient());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validation_error_is_not_transient() {
|
||||||
|
assert!(!DomainError::ValidationError("bad input".into()).is_transient());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unauthorized_is_not_transient() {
|
||||||
|
assert!(!DomainError::Unauthorized("token expired".into()).is_transient());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forbidden_is_not_transient() {
|
||||||
|
assert!(!DomainError::Forbidden("no access".into()).is_transient());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_rating_is_not_transient() {
|
||||||
|
assert!(!DomainError::InvalidRating { max: 5, given: 9 }.is_transient());
|
||||||
|
}
|
||||||
20
crates/domain/src/models/federation.rs
Normal file
20
crates/domain/src/models/federation.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RemoteActorInfo {
|
||||||
|
pub url: String,
|
||||||
|
pub handle: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PendingFollowerInfo {
|
||||||
|
pub url: String,
|
||||||
|
pub handle: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FederationFlags {
|
||||||
|
pub goals: bool,
|
||||||
|
pub reviews: bool,
|
||||||
|
pub watchlist: bool,
|
||||||
|
}
|
||||||
@@ -3,6 +3,33 @@ use super::{
|
|||||||
review::{DiaryEntry, Review},
|
review::{DiaryEntry, Review},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
pub enum FeedSortBy {
|
||||||
|
#[default]
|
||||||
|
Date,
|
||||||
|
DateAsc,
|
||||||
|
Rating,
|
||||||
|
RatingAsc,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for FeedSortBy {
|
||||||
|
type Err = std::convert::Infallible;
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
Ok(match s {
|
||||||
|
"date_asc" => Self::DateAsc,
|
||||||
|
"rating" => Self::Rating,
|
||||||
|
"rating_asc" => Self::RatingAsc,
|
||||||
|
_ => Self::Date,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct FollowingFilter {
|
||||||
|
pub local_user_ids: Vec<uuid::Uuid>,
|
||||||
|
pub remote_actor_urls: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct FeedEntry {
|
pub struct FeedEntry {
|
||||||
entry: DiaryEntry,
|
entry: DiaryEntry,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
mod enrichment;
|
mod enrichment;
|
||||||
|
mod federation;
|
||||||
mod feed;
|
mod feed;
|
||||||
mod movie;
|
mod movie;
|
||||||
mod refresh_session;
|
mod refresh_session;
|
||||||
@@ -21,6 +22,7 @@ pub mod watchlist;
|
|||||||
pub mod wrapup;
|
pub mod wrapup;
|
||||||
|
|
||||||
pub use enrichment::*;
|
pub use enrichment::*;
|
||||||
|
pub use federation::*;
|
||||||
pub use feed::*;
|
pub use feed::*;
|
||||||
pub use movie::*;
|
pub use movie::*;
|
||||||
pub use review::*;
|
pub use review::*;
|
||||||
@@ -47,7 +49,7 @@ pub use import_session::ImportSession;
|
|||||||
pub use person::{
|
pub use person::{
|
||||||
CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonEnrichmentData, PersonId,
|
CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonEnrichmentData, PersonId,
|
||||||
};
|
};
|
||||||
pub use refresh_session::RefreshSession;
|
pub use refresh_session::{GeneratedToken, RefreshSession};
|
||||||
pub use search::{
|
pub use search::{
|
||||||
EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, SearchFilters, SearchQuery,
|
EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, SearchFilters, SearchQuery,
|
||||||
SearchResults,
|
SearchResults,
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
use crate::value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterPath, ReleaseYear};
|
use crate::value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterPath, ReleaseYear};
|
||||||
|
|
||||||
|
pub enum MetadataSearchCriteria {
|
||||||
|
ImdbId(ExternalMetadataId),
|
||||||
|
Title {
|
||||||
|
title: MovieTitle,
|
||||||
|
year: Option<ReleaseYear>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub struct MovieFilter {
|
pub struct MovieFilter {
|
||||||
pub search: Option<String>,
|
pub search: Option<String>,
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::value_objects::UserId;
|
use crate::value_objects::UserId;
|
||||||
|
|
||||||
|
pub struct GeneratedToken {
|
||||||
|
pub token: String,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct RefreshSession {
|
pub struct RefreshSession {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
|||||||
@@ -4,6 +4,25 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::value_objects::WrapUpId;
|
use crate::value_objects::WrapUpId;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct WrapUpMovieRow {
|
||||||
|
pub movie_id: Uuid,
|
||||||
|
pub title: String,
|
||||||
|
pub release_year: u16,
|
||||||
|
pub director: Option<String>,
|
||||||
|
pub poster_path: Option<String>,
|
||||||
|
pub rating: u8,
|
||||||
|
pub watched_at: NaiveDateTime,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub runtime_minutes: Option<u32>,
|
||||||
|
pub budget_usd: Option<i64>,
|
||||||
|
pub original_language: Option<String>,
|
||||||
|
pub genres: Vec<String>,
|
||||||
|
pub keywords: Vec<String>,
|
||||||
|
pub cast_names: Vec<(String, u32, i64)>,
|
||||||
|
pub cast_profile_paths: Vec<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct DateRange {
|
pub struct DateRange {
|
||||||
start: NaiveDate,
|
start: NaiveDate,
|
||||||
|
|||||||
@@ -1,621 +0,0 @@
|
|||||||
use async_trait::async_trait;
|
|
||||||
use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
errors::DomainError,
|
|
||||||
events::{DomainEvent, EventEnvelope},
|
|
||||||
models::wrapup::WrapUpReport,
|
|
||||||
models::{
|
|
||||||
AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId,
|
|
||||||
FeedEntry, FieldMapping, FileFormat, Goal, ImportError, ImportProfile, ImportSession,
|
|
||||||
IndexableDocument, Movie, MovieFilter, MovieProfile, MovieStats, MovieSummary, ParsedFile,
|
|
||||||
ParsedPlaybackEvent, Person, PersonCredits, PersonEnrichmentData, PersonId, RefreshSession,
|
|
||||||
RemoteGoalEntry, RemoteWatchlistEntry, Review, ReviewHistory, SearchQuery, SearchResults,
|
|
||||||
User, UserSettings, UserStats, UserSummary, UserTrends, WatchEvent, WatchEventStatus,
|
|
||||||
WatchlistEntry, WatchlistWithMovie, WebhookToken,
|
|
||||||
collections::{self, PageParams, Paginated},
|
|
||||||
wrapup::{DateRange, WrapUpRecord, WrapUpScope, WrapUpStatus},
|
|
||||||
},
|
|
||||||
value_objects::{
|
|
||||||
Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
|
|
||||||
PasswordHash, PosterUrl, ReleaseYear, ReviewId, UserId, Username, WatchEventId,
|
|
||||||
WebhookTokenId, WrapUpId,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub trait DocumentParser: Send + Sync {
|
|
||||||
fn parse(&self, bytes: &[u8], format: FileFormat) -> Result<ParsedFile, ImportError>;
|
|
||||||
fn apply_mapping(&self, file: &ParsedFile, mappings: &[FieldMapping]) -> Vec<AnnotatedRow>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq)]
|
|
||||||
pub enum FeedSortBy {
|
|
||||||
#[default]
|
|
||||||
Date,
|
|
||||||
DateAsc,
|
|
||||||
Rating,
|
|
||||||
RatingAsc,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::str::FromStr for FeedSortBy {
|
|
||||||
type Err = std::convert::Infallible;
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
||||||
Ok(match s {
|
|
||||||
"date_asc" => Self::DateAsc,
|
|
||||||
"rating" => Self::Rating,
|
|
||||||
"rating_asc" => Self::RatingAsc,
|
|
||||||
_ => Self::Date,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct FollowingFilter {
|
|
||||||
pub local_user_ids: Vec<uuid::Uuid>,
|
|
||||||
pub remote_actor_urls: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct RemoteActorInfo {
|
|
||||||
pub url: String,
|
|
||||||
pub handle: String,
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct PendingFollowerInfo {
|
|
||||||
pub url: String,
|
|
||||||
pub handle: String,
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
pub avatar_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait SocialQueryPort: Send + Sync {
|
|
||||||
async fn get_accepted_following_urls(
|
|
||||||
&self,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
) -> Result<Vec<String>, DomainError>;
|
|
||||||
|
|
||||||
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError>;
|
|
||||||
|
|
||||||
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError>;
|
|
||||||
|
|
||||||
async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError>;
|
|
||||||
|
|
||||||
async fn get_pending_followers(
|
|
||||||
&self,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
) -> Result<Vec<PendingFollowerInfo>, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait MovieRepository: Send + Sync {
|
|
||||||
async fn get_movie_by_external_id(
|
|
||||||
&self,
|
|
||||||
external_metadata_id: &ExternalMetadataId,
|
|
||||||
) -> Result<Option<Movie>, DomainError>;
|
|
||||||
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError>;
|
|
||||||
async fn get_movies_by_title_and_year(
|
|
||||||
&self,
|
|
||||||
title: &MovieTitle,
|
|
||||||
year: &ReleaseYear,
|
|
||||||
) -> Result<Vec<Movie>, DomainError>;
|
|
||||||
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>;
|
|
||||||
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError>;
|
|
||||||
async fn existing_external_ids(
|
|
||||||
&self,
|
|
||||||
ids: &[ExternalMetadataId],
|
|
||||||
) -> Result<std::collections::HashSet<String>, DomainError>;
|
|
||||||
async fn existing_title_year_pairs(
|
|
||||||
&self,
|
|
||||||
pairs: &[(MovieTitle, ReleaseYear)],
|
|
||||||
) -> Result<std::collections::HashSet<(String, u16)>, DomainError>;
|
|
||||||
async fn list_movies(
|
|
||||||
&self,
|
|
||||||
page: &collections::PageParams,
|
|
||||||
filter: &MovieFilter,
|
|
||||||
) -> Result<collections::Paginated<MovieSummary>, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait ReviewRepository: Send + Sync {
|
|
||||||
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError>;
|
|
||||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>;
|
|
||||||
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>;
|
|
||||||
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait DiaryRepository: Send + Sync {
|
|
||||||
async fn query_diary(&self, filter: &DiaryFilter)
|
|
||||||
-> Result<Paginated<DiaryEntry>, DomainError>;
|
|
||||||
async fn query_activity_feed(
|
|
||||||
&self,
|
|
||||||
page: &PageParams,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError>;
|
|
||||||
async fn query_activity_feed_filtered(
|
|
||||||
&self,
|
|
||||||
page: &PageParams,
|
|
||||||
sort_by: &FeedSortBy,
|
|
||||||
search: Option<&str>,
|
|
||||||
following: Option<&FollowingFilter>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError>;
|
|
||||||
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError>;
|
|
||||||
async fn get_user_history(&self, user_id: &UserId) -> Result<Vec<DiaryEntry>, DomainError>;
|
|
||||||
fn stream_user_history(
|
|
||||||
&self,
|
|
||||||
user_id: UserId,
|
|
||||||
) -> futures::stream::BoxStream<'static, Result<DiaryEntry, DomainError>>;
|
|
||||||
async fn get_movie_stats(&self, movie_id: &MovieId) -> Result<MovieStats, DomainError>;
|
|
||||||
async fn get_movie_social_feed(
|
|
||||||
&self,
|
|
||||||
movie_id: &MovieId,
|
|
||||||
page: &PageParams,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError>;
|
|
||||||
async fn count_local_posts(&self) -> Result<u64, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait StatsRepository: Send + Sync {
|
|
||||||
async fn get_user_stats(&self, user_id: &UserId) -> Result<UserStats, DomainError>;
|
|
||||||
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum MetadataSearchCriteria {
|
|
||||||
ImdbId(ExternalMetadataId),
|
|
||||||
Title {
|
|
||||||
title: MovieTitle,
|
|
||||||
year: Option<ReleaseYear>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait MetadataClient: Send + Sync {
|
|
||||||
async fn fetch_movie_metadata(
|
|
||||||
&self,
|
|
||||||
criteria: &MetadataSearchCriteria,
|
|
||||||
) -> Result<Movie, DomainError>;
|
|
||||||
async fn get_poster_url(
|
|
||||||
&self,
|
|
||||||
external_metadata_id: &ExternalMetadataId,
|
|
||||||
) -> Result<Option<PosterUrl>, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait PosterFetcherClient: Send + Sync {
|
|
||||||
async fn fetch_poster_bytes(&self, poster_url: &PosterUrl) -> Result<Vec<u8>, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait ObjectStorage: Send + Sync {
|
|
||||||
/// Stores `image_bytes` at `key` and returns the stored key.
|
|
||||||
async fn store(&self, key: &str, image_bytes: &[u8]) -> Result<String, DomainError>;
|
|
||||||
async fn get(&self, key: &str) -> Result<Vec<u8>, DomainError>;
|
|
||||||
async fn get_stream(
|
|
||||||
&self,
|
|
||||||
key: &str,
|
|
||||||
) -> Result<futures::stream::BoxStream<'static, Result<bytes::Bytes, DomainError>>, DomainError>;
|
|
||||||
async fn delete(&self, key: &str) -> Result<(), DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct GeneratedToken {
|
|
||||||
pub token: String,
|
|
||||||
pub expires_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait AuthService: Send + Sync {
|
|
||||||
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError>;
|
|
||||||
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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>;
|
|
||||||
async fn update_profile(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
profile: &crate::models::UserProfile,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait UserProfileFieldsRepository: Send + Sync {
|
|
||||||
async fn get_fields(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
) -> Result<Vec<crate::models::ProfileField>, DomainError>;
|
|
||||||
async fn set_fields(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
fields: Vec<crate::models::ProfileField>,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait EventPublisher: Send + Sync {
|
|
||||||
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait EventConsumer: Send + Sync {
|
|
||||||
/// Returns a stream of event envelopes. Each envelope carries a domain event
|
|
||||||
/// and an ack handle — callers ack after successful dispatch, nack on failure.
|
|
||||||
/// Implementations decide transport (NATS, DB queue, in-memory channel).
|
|
||||||
fn consume(&self) -> futures::stream::BoxStream<'_, Result<EventEnvelope, 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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait DiaryExporter: Send + Sync {
|
|
||||||
fn stream_entries(
|
|
||||||
&self,
|
|
||||||
stream: futures::stream::BoxStream<'static, Result<DiaryEntry, DomainError>>,
|
|
||||||
format: ExportFormat,
|
|
||||||
) -> futures::stream::BoxStream<'static, Result<bytes::Bytes, DomainError>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait EventHandler: Send + Sync {
|
|
||||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait PeriodicJob: Send + Sync {
|
|
||||||
fn interval(&self) -> std::time::Duration;
|
|
||||||
async fn run(&self) -> Result<(), DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait MovieProfileRepository: Send + Sync {
|
|
||||||
async fn upsert(&self, profile: &MovieProfile) -> Result<(), DomainError>;
|
|
||||||
async fn get_by_movie_id(&self, id: &MovieId) -> Result<Option<MovieProfile>, DomainError>;
|
|
||||||
/// Returns (movie_id, external_metadata_id) for movies with no profile or a stale one
|
|
||||||
/// (enriched_at older than 30 days).
|
|
||||||
async fn list_stale(&self) -> Result<Vec<(MovieId, String)>, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait MovieEnrichmentClient: Send + Sync {
|
|
||||||
/// Resolves an external ID (TMDb or IMDb) and fetches the full movie profile.
|
|
||||||
async fn fetch_profile(
|
|
||||||
&self,
|
|
||||||
movie_id: MovieId,
|
|
||||||
external_metadata_id: &str,
|
|
||||||
) -> Result<MovieProfile, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait PersonEnrichmentClient: Send + Sync {
|
|
||||||
async fn fetch_details(&self, external_id: &str) -> Result<PersonEnrichmentData, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait ImportSessionRepository: Send + Sync {
|
|
||||||
async fn create(&self, session: &ImportSession) -> Result<(), DomainError>;
|
|
||||||
async fn get(
|
|
||||||
&self,
|
|
||||||
id: &ImportSessionId,
|
|
||||||
user_id: &UserId,
|
|
||||||
) -> Result<Option<ImportSession>, DomainError>;
|
|
||||||
async fn update(&self, session: &ImportSession) -> Result<(), DomainError>;
|
|
||||||
async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError>;
|
|
||||||
async fn delete_expired(&self) -> Result<u64, DomainError>;
|
|
||||||
async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait RefreshSessionRepository: Send + Sync {
|
|
||||||
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError>;
|
|
||||||
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError>;
|
|
||||||
async fn revoke(&self, token: &str) -> Result<(), DomainError>;
|
|
||||||
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError>;
|
|
||||||
async fn delete_expired(&self) -> Result<u64, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait ImportProfileRepository: Send + Sync {
|
|
||||||
async fn save(&self, profile: &ImportProfile) -> Result<(), DomainError>;
|
|
||||||
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ImportProfile>, DomainError>;
|
|
||||||
async fn get(
|
|
||||||
&self,
|
|
||||||
id: &ImportProfileId,
|
|
||||||
user_id: &UserId,
|
|
||||||
) -> Result<Option<ImportProfile>, DomainError>;
|
|
||||||
async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait ImageRefCommand: Send + Sync {
|
|
||||||
async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait ImageRefQuery: Send + Sync {
|
|
||||||
async fn list_keys(&self) -> Result<Vec<String>, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write port — mutates the persons table. No reads.
|
|
||||||
#[async_trait]
|
|
||||||
pub trait PersonCommand: Send + Sync {
|
|
||||||
/// Upsert a batch of persons. Uses INSERT OR REPLACE (SQLite) / ON CONFLICT DO UPDATE (Postgres).
|
|
||||||
async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError>;
|
|
||||||
/// Insert a batch of missing persons from movie_cast/movie_crew into the persons table.
|
|
||||||
/// Returns (inserted_count, has_more).
|
|
||||||
async fn backfill_from_credits_batch(
|
|
||||||
&self,
|
|
||||||
batch_size: u32,
|
|
||||||
) -> Result<(u64, bool), DomainError>;
|
|
||||||
async fn update_enrichment(
|
|
||||||
&self,
|
|
||||||
id: &PersonId,
|
|
||||||
data: &PersonEnrichmentData,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read port — queries persons and credits. No mutations.
|
|
||||||
#[async_trait]
|
|
||||||
pub trait PersonQuery: Send + Sync {
|
|
||||||
async fn get_by_id(&self, id: &PersonId) -> Result<Option<Person>, DomainError>;
|
|
||||||
async fn get_by_external_id(
|
|
||||||
&self,
|
|
||||||
id: &ExternalPersonId,
|
|
||||||
) -> Result<Option<Person>, DomainError>;
|
|
||||||
/// Returns the person's full cast and crew credit history across all indexed movies.
|
|
||||||
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError>;
|
|
||||||
/// Returns persons who have no remaining entries in movie_cast or movie_crew.
|
|
||||||
/// Called after movie deletion to find index entries that can be pruned.
|
|
||||||
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError>;
|
|
||||||
async fn list_page(&self, limit: u32, offset: u32) -> Result<Vec<Person>, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read port — executes search queries. No mutations.
|
|
||||||
#[async_trait]
|
|
||||||
pub trait SearchPort: Send + Sync {
|
|
||||||
async fn search(&self, query: &SearchQuery) -> Result<SearchResults, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write port — manages the search index. No reads.
|
|
||||||
#[async_trait]
|
|
||||||
pub trait SearchCommand: Send + Sync {
|
|
||||||
/// Add or replace a document in the search index.
|
|
||||||
async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError>;
|
|
||||||
/// Remove a document from the search index by entity type and internal ID string.
|
|
||||||
async fn remove(&self, entity_type: EntityType, id: &str) -> Result<(), DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait WatchlistRepository: Send + Sync {
|
|
||||||
/// Add a new entry. Silently succeeds if the entry already exists.
|
|
||||||
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError>;
|
|
||||||
|
|
||||||
/// Remove an entry. Returns NotFound if the entry does not exist.
|
|
||||||
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError>;
|
|
||||||
|
|
||||||
/// Remove an entry if it exists. Never returns NotFound.
|
|
||||||
async fn remove_if_present(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
movie_id: &MovieId,
|
|
||||||
) -> Result<bool, DomainError>;
|
|
||||||
|
|
||||||
async fn get_for_user(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
page: &collections::PageParams,
|
|
||||||
) -> Result<collections::Paginated<WatchlistWithMovie>, DomainError>;
|
|
||||||
|
|
||||||
async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result<bool, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait RemoteWatchlistRepository: Send + Sync {
|
|
||||||
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError>;
|
|
||||||
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError>;
|
|
||||||
async fn get_by_actor_url(
|
|
||||||
&self,
|
|
||||||
actor_url: &str,
|
|
||||||
) -> Result<Vec<RemoteWatchlistEntry>, DomainError>;
|
|
||||||
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError>;
|
|
||||||
/// Find entries for a remote actor whose URL hashes (v5 UUID) to the given UUID.
|
|
||||||
async fn get_by_derived_uuid(
|
|
||||||
&self,
|
|
||||||
uuid: uuid::Uuid,
|
|
||||||
) -> Result<Vec<RemoteWatchlistEntry>, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Goals ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait GoalRepository: Send + Sync {
|
|
||||||
async fn save(&self, goal: &Goal) -> Result<(), DomainError>;
|
|
||||||
async fn update(&self, goal: &Goal) -> Result<(), DomainError>;
|
|
||||||
async fn delete(&self, id: &GoalId, user_id: &UserId) -> Result<(), DomainError>;
|
|
||||||
async fn find_by_user_and_year(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
year: u16,
|
|
||||||
) -> Result<Option<Goal>, DomainError>;
|
|
||||||
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError>;
|
|
||||||
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait UserSettingsRepository: Send + Sync {
|
|
||||||
async fn get(&self, user_id: &UserId) -> Result<UserSettings, DomainError>;
|
|
||||||
async fn save(&self, settings: &UserSettings) -> Result<(), DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct FederationFlags {
|
|
||||||
pub goals: bool,
|
|
||||||
pub reviews: bool,
|
|
||||||
pub watchlist: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait UserFederationSettingsQuery: Send + Sync {
|
|
||||||
async fn get_federation_flags(&self, user_id: &UserId) -> Result<FederationFlags, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait RemoteGoalRepository: Send + Sync {
|
|
||||||
async fn save(&self, entry: RemoteGoalEntry) -> Result<(), DomainError>;
|
|
||||||
async fn update_by_ap_id(
|
|
||||||
&self,
|
|
||||||
ap_id: &str,
|
|
||||||
target: u32,
|
|
||||||
current: u32,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError>;
|
|
||||||
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError>;
|
|
||||||
async fn get_by_actor_url(&self, actor_url: &str) -> Result<Vec<RemoteGoalEntry>, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read-only query port used exclusively by the ActivityPub adapter.
|
|
||||||
/// Consolidates all reads the AP adapter needs so it never touches write repositories.
|
|
||||||
#[async_trait]
|
|
||||||
pub trait LocalApContentQuery: Send + Sync {
|
|
||||||
async fn get_local_reviews_for_user(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
) -> Result<Vec<DiaryEntry>, DomainError>;
|
|
||||||
|
|
||||||
async fn get_local_watchlist_for_user(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
) -> Result<Vec<WatchlistWithMovie>, DomainError>;
|
|
||||||
|
|
||||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>;
|
|
||||||
|
|
||||||
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError>;
|
|
||||||
|
|
||||||
async fn count_local_posts(&self) -> Result<u64, DomainError>;
|
|
||||||
|
|
||||||
async fn get_local_reviews_for_movie(
|
|
||||||
&self,
|
|
||||||
movie_id: &MovieId,
|
|
||||||
) -> Result<Vec<DiaryEntry>, DomainError>;
|
|
||||||
|
|
||||||
async fn get_local_reviews_page(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
before: Option<chrono::NaiveDateTime>,
|
|
||||||
limit: usize,
|
|
||||||
) -> Result<Vec<DiaryEntry>, DomainError>;
|
|
||||||
|
|
||||||
async fn get_goal_with_progress(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
year: u16,
|
|
||||||
) -> Result<Option<(Goal, u32)>, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Media server integration ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub trait MediaServerParser: Send + Sync {
|
|
||||||
fn parse_playback_event(&self, body: &[u8])
|
|
||||||
-> Result<Option<ParsedPlaybackEvent>, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait WatchEventRepository: Send + Sync {
|
|
||||||
async fn save(&self, event: &WatchEvent) -> Result<(), DomainError>;
|
|
||||||
async fn update_status(
|
|
||||||
&self,
|
|
||||||
id: &WatchEventId,
|
|
||||||
status: WatchEventStatus,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
async fn list_pending(&self, user_id: &UserId) -> Result<Vec<WatchEvent>, DomainError>;
|
|
||||||
async fn get_by_id(&self, id: &WatchEventId) -> Result<Option<WatchEvent>, DomainError>;
|
|
||||||
async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result<Vec<WatchEvent>, DomainError>;
|
|
||||||
async fn update_status_batch(
|
|
||||||
&self,
|
|
||||||
ids: &[WatchEventId],
|
|
||||||
status: WatchEventStatus,
|
|
||||||
) -> Result<u64, DomainError>;
|
|
||||||
async fn find_duplicate(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
external_id: &str,
|
|
||||||
after: chrono::NaiveDateTime,
|
|
||||||
) -> Result<bool, DomainError>;
|
|
||||||
async fn delete_non_pending_older_than(
|
|
||||||
&self,
|
|
||||||
before: chrono::NaiveDateTime,
|
|
||||||
) -> Result<u64, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait WebhookTokenRepository: Send + Sync {
|
|
||||||
async fn save(&self, token: &WebhookToken) -> Result<(), DomainError>;
|
|
||||||
async fn find_by_token_hash(&self, hash: &str) -> Result<Option<WebhookToken>, DomainError>;
|
|
||||||
async fn list_by_user(&self, user_id: &UserId) -> Result<Vec<WebhookToken>, DomainError>;
|
|
||||||
async fn delete(&self, id: &WebhookTokenId, user_id: &UserId) -> Result<(), DomainError>;
|
|
||||||
async fn touch_last_used(&self, id: &WebhookTokenId) -> Result<(), DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait WrapUpRepository: Send + Sync {
|
|
||||||
async fn create(&self, record: &WrapUpRecord) -> Result<(), DomainError>;
|
|
||||||
async fn update_status(
|
|
||||||
&self,
|
|
||||||
id: &WrapUpId,
|
|
||||||
status: &WrapUpStatus,
|
|
||||||
error: Option<&str>,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
async fn set_complete(&self, id: &WrapUpId, report: &WrapUpReport) -> Result<(), DomainError>;
|
|
||||||
async fn get_by_id(&self, id: &WrapUpId) -> Result<Option<WrapUpRecord>, DomainError>;
|
|
||||||
async fn list_for_user(&self, user_id: Uuid) -> Result<Vec<WrapUpRecord>, DomainError>;
|
|
||||||
async fn list_global(&self) -> Result<Vec<WrapUpRecord>, DomainError>;
|
|
||||||
async fn find_existing(
|
|
||||||
&self,
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
start: NaiveDate,
|
|
||||||
end: NaiveDate,
|
|
||||||
) -> Result<Option<WrapUpRecord>, DomainError>;
|
|
||||||
async fn delete(&self, id: &WrapUpId) -> Result<(), DomainError>;
|
|
||||||
async fn delete_failed_older_than(
|
|
||||||
&self,
|
|
||||||
before: chrono::NaiveDateTime,
|
|
||||||
) -> Result<u64, DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Wrap-up / Year-in-Review ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct WrapUpMovieRow {
|
|
||||||
pub movie_id: Uuid,
|
|
||||||
pub title: String,
|
|
||||||
pub release_year: u16,
|
|
||||||
pub director: Option<String>,
|
|
||||||
pub poster_path: Option<String>,
|
|
||||||
pub rating: u8,
|
|
||||||
pub watched_at: NaiveDateTime,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub runtime_minutes: Option<u32>,
|
|
||||||
pub budget_usd: Option<i64>,
|
|
||||||
pub original_language: Option<String>,
|
|
||||||
pub genres: Vec<String>,
|
|
||||||
pub keywords: Vec<String>,
|
|
||||||
pub cast_names: Vec<(String, u32, i64)>,
|
|
||||||
pub cast_profile_paths: Vec<Option<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait WrapUpStatsQuery: Send + Sync {
|
|
||||||
async fn get_reviews_with_profiles(
|
|
||||||
&self,
|
|
||||||
scope: &WrapUpScope,
|
|
||||||
range: &DateRange,
|
|
||||||
) -> Result<Vec<WrapUpMovieRow>, DomainError>;
|
|
||||||
}
|
|
||||||
61
crates/domain/src/ports/auth.rs
Normal file
61
crates/domain/src/ports/auth.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{GeneratedToken, RefreshSession, User, UserSettings, UserSummary},
|
||||||
|
value_objects::{Email, PasswordHash, UserId, Username},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AuthService: Send + Sync {
|
||||||
|
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError>;
|
||||||
|
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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>;
|
||||||
|
async fn update_profile(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
profile: &crate::models::UserProfile,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserProfileFieldsRepository: Send + Sync {
|
||||||
|
async fn get_fields(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<Vec<crate::models::ProfileField>, DomainError>;
|
||||||
|
async fn set_fields(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
fields: Vec<crate::models::ProfileField>,
|
||||||
|
) -> 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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserSettingsRepository: Send + Sync {
|
||||||
|
async fn get(&self, user_id: &UserId) -> Result<UserSettings, DomainError>;
|
||||||
|
async fn save(&self, settings: &UserSettings) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait RefreshSessionRepository: Send + Sync {
|
||||||
|
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError>;
|
||||||
|
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError>;
|
||||||
|
async fn revoke(&self, token: &str) -> Result<(), DomainError>;
|
||||||
|
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError>;
|
||||||
|
async fn delete_expired(&self) -> Result<u64, DomainError>;
|
||||||
|
}
|
||||||
64
crates/domain/src/ports/diary.rs
Normal file
64
crates/domain/src/ports/diary.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
models::{
|
||||||
|
DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FeedSortBy, FollowingFilter, MovieStats,
|
||||||
|
Review, ReviewHistory, UserStats, UserTrends,
|
||||||
|
collections::{PageParams, Paginated},
|
||||||
|
},
|
||||||
|
value_objects::{MovieId, ReviewId, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait DiaryRepository: Send + Sync {
|
||||||
|
async fn query_diary(&self, filter: &DiaryFilter)
|
||||||
|
-> Result<Paginated<DiaryEntry>, DomainError>;
|
||||||
|
async fn query_activity_feed(
|
||||||
|
&self,
|
||||||
|
page: &PageParams,
|
||||||
|
) -> Result<Paginated<FeedEntry>, DomainError>;
|
||||||
|
async fn query_activity_feed_filtered(
|
||||||
|
&self,
|
||||||
|
page: &PageParams,
|
||||||
|
sort_by: &FeedSortBy,
|
||||||
|
search: Option<&str>,
|
||||||
|
following: Option<&FollowingFilter>,
|
||||||
|
) -> Result<Paginated<FeedEntry>, DomainError>;
|
||||||
|
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError>;
|
||||||
|
async fn get_user_history(&self, user_id: &UserId) -> Result<Vec<DiaryEntry>, DomainError>;
|
||||||
|
fn stream_user_history(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
) -> futures::stream::BoxStream<'static, Result<DiaryEntry, DomainError>>;
|
||||||
|
async fn get_movie_stats(&self, movie_id: &MovieId) -> Result<MovieStats, DomainError>;
|
||||||
|
async fn get_movie_social_feed(
|
||||||
|
&self,
|
||||||
|
movie_id: &MovieId,
|
||||||
|
page: &PageParams,
|
||||||
|
) -> Result<Paginated<FeedEntry>, DomainError>;
|
||||||
|
async fn count_local_posts(&self) -> Result<u64, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ReviewRepository: Send + Sync {
|
||||||
|
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError>;
|
||||||
|
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>;
|
||||||
|
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>;
|
||||||
|
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait StatsRepository: Send + Sync {
|
||||||
|
async fn get_user_stats(&self, user_id: &UserId) -> Result<UserStats, DomainError>;
|
||||||
|
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait DiaryExporter: Send + Sync {
|
||||||
|
fn stream_entries(
|
||||||
|
&self,
|
||||||
|
stream: futures::stream::BoxStream<'static, Result<DiaryEntry, DomainError>>,
|
||||||
|
format: ExportFormat,
|
||||||
|
) -> futures::stream::BoxStream<'static, Result<bytes::Bytes, DomainError>>;
|
||||||
|
}
|
||||||
23
crates/domain/src/ports/events.rs
Normal file
23
crates/domain/src/ports/events.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::{DomainEvent, EventEnvelope},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait EventPublisher: Send + Sync {
|
||||||
|
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait EventConsumer: Send + Sync {
|
||||||
|
/// Returns a stream of event envelopes. Each envelope carries a domain event
|
||||||
|
/// and an ack handle — callers ack after successful dispatch, nack on failure.
|
||||||
|
/// Implementations decide transport (NATS, DB queue, in-memory channel).
|
||||||
|
fn consume(&self) -> futures::stream::BoxStream<'_, Result<EventEnvelope, DomainError>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait EventHandler: Send + Sync {
|
||||||
|
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
21
crates/domain/src/ports/goals.rs
Normal file
21
crates/domain/src/ports/goals.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::Goal,
|
||||||
|
value_objects::{GoalId, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait GoalRepository: Send + Sync {
|
||||||
|
async fn save(&self, goal: &Goal) -> Result<(), DomainError>;
|
||||||
|
async fn update(&self, goal: &Goal) -> Result<(), DomainError>;
|
||||||
|
async fn delete(&self, id: &GoalId, user_id: &UserId) -> Result<(), DomainError>;
|
||||||
|
async fn find_by_user_and_year(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
year: u16,
|
||||||
|
) -> Result<Option<Goal>, DomainError>;
|
||||||
|
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError>;
|
||||||
|
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError>;
|
||||||
|
}
|
||||||
30
crates/domain/src/ports/images.rs
Normal file
30
crates/domain/src/ports/images.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::{errors::DomainError, value_objects::PosterUrl};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ObjectStorage: Send + Sync {
|
||||||
|
/// Stores `image_bytes` at `key` and returns the stored key.
|
||||||
|
async fn store(&self, key: &str, image_bytes: &[u8]) -> Result<String, DomainError>;
|
||||||
|
async fn get(&self, key: &str) -> Result<Vec<u8>, DomainError>;
|
||||||
|
async fn get_stream(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<futures::stream::BoxStream<'static, Result<bytes::Bytes, DomainError>>, DomainError>;
|
||||||
|
async fn delete(&self, key: &str) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait PosterFetcherClient: Send + Sync {
|
||||||
|
async fn fetch_poster_bytes(&self, poster_url: &PosterUrl) -> Result<Vec<u8>, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ImageRefCommand: Send + Sync {
|
||||||
|
async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ImageRefQuery: Send + Sync {
|
||||||
|
async fn list_keys(&self) -> Result<Vec<String>, DomainError>;
|
||||||
|
}
|
||||||
41
crates/domain/src/ports/import.rs
Normal file
41
crates/domain/src/ports/import.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{
|
||||||
|
AnnotatedRow, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession,
|
||||||
|
ParsedFile,
|
||||||
|
},
|
||||||
|
value_objects::{ImportProfileId, ImportSessionId, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub trait DocumentParser: Send + Sync {
|
||||||
|
fn parse(&self, bytes: &[u8], format: FileFormat) -> Result<ParsedFile, ImportError>;
|
||||||
|
fn apply_mapping(&self, file: &ParsedFile, mappings: &[FieldMapping]) -> Vec<AnnotatedRow>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ImportSessionRepository: Send + Sync {
|
||||||
|
async fn create(&self, session: &ImportSession) -> Result<(), DomainError>;
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
id: &ImportSessionId,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<Option<ImportSession>, DomainError>;
|
||||||
|
async fn update(&self, session: &ImportSession) -> Result<(), DomainError>;
|
||||||
|
async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError>;
|
||||||
|
async fn delete_expired(&self) -> Result<u64, DomainError>;
|
||||||
|
async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ImportProfileRepository: Send + Sync {
|
||||||
|
async fn save(&self, profile: &ImportProfile) -> Result<(), DomainError>;
|
||||||
|
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ImportProfile>, DomainError>;
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
id: &ImportProfileId,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<Option<ImportProfile>, DomainError>;
|
||||||
|
async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
9
crates/domain/src/ports/jobs.rs
Normal file
9
crates/domain/src/ports/jobs.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::errors::DomainError;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait PeriodicJob: Send + Sync {
|
||||||
|
fn interval(&self) -> std::time::Duration;
|
||||||
|
async fn run(&self) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
50
crates/domain/src/ports/media_server.rs
Normal file
50
crates/domain/src/ports/media_server.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{ParsedPlaybackEvent, WatchEvent, WatchEventStatus, WebhookToken},
|
||||||
|
value_objects::{UserId, WatchEventId, WebhookTokenId},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub trait MediaServerParser: Send + Sync {
|
||||||
|
fn parse_playback_event(&self, body: &[u8])
|
||||||
|
-> Result<Option<ParsedPlaybackEvent>, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait WatchEventRepository: Send + Sync {
|
||||||
|
async fn save(&self, event: &WatchEvent) -> Result<(), DomainError>;
|
||||||
|
async fn update_status(
|
||||||
|
&self,
|
||||||
|
id: &WatchEventId,
|
||||||
|
status: WatchEventStatus,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
async fn list_pending(&self, user_id: &UserId) -> Result<Vec<WatchEvent>, DomainError>;
|
||||||
|
async fn get_by_id(&self, id: &WatchEventId) -> Result<Option<WatchEvent>, DomainError>;
|
||||||
|
async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result<Vec<WatchEvent>, DomainError>;
|
||||||
|
async fn update_status_batch(
|
||||||
|
&self,
|
||||||
|
ids: &[WatchEventId],
|
||||||
|
status: WatchEventStatus,
|
||||||
|
) -> Result<u64, DomainError>;
|
||||||
|
async fn find_duplicate(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
external_id: &str,
|
||||||
|
after: NaiveDateTime,
|
||||||
|
) -> Result<bool, DomainError>;
|
||||||
|
async fn delete_non_pending_older_than(
|
||||||
|
&self,
|
||||||
|
before: NaiveDateTime,
|
||||||
|
) -> Result<u64, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait WebhookTokenRepository: Send + Sync {
|
||||||
|
async fn save(&self, token: &WebhookToken) -> Result<(), DomainError>;
|
||||||
|
async fn find_by_token_hash(&self, hash: &str) -> Result<Option<WebhookToken>, DomainError>;
|
||||||
|
async fn list_by_user(&self, user_id: &UserId) -> Result<Vec<WebhookToken>, DomainError>;
|
||||||
|
async fn delete(&self, id: &WebhookTokenId, user_id: &UserId) -> Result<(), DomainError>;
|
||||||
|
async fn touch_last_used(&self, id: &WebhookTokenId) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
29
crates/domain/src/ports/mod.rs
Normal file
29
crates/domain/src/ports/mod.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod diary;
|
||||||
|
pub mod events;
|
||||||
|
pub mod goals;
|
||||||
|
pub mod images;
|
||||||
|
pub mod import;
|
||||||
|
pub mod jobs;
|
||||||
|
pub mod media_server;
|
||||||
|
pub mod movie;
|
||||||
|
pub mod person;
|
||||||
|
pub mod search;
|
||||||
|
pub mod social;
|
||||||
|
pub mod watchlist;
|
||||||
|
pub mod wrapup;
|
||||||
|
|
||||||
|
pub use auth::*;
|
||||||
|
pub use diary::*;
|
||||||
|
pub use events::*;
|
||||||
|
pub use goals::*;
|
||||||
|
pub use images::*;
|
||||||
|
pub use import::*;
|
||||||
|
pub use jobs::*;
|
||||||
|
pub use media_server::*;
|
||||||
|
pub use movie::*;
|
||||||
|
pub use person::*;
|
||||||
|
pub use search::*;
|
||||||
|
pub use social::*;
|
||||||
|
pub use watchlist::*;
|
||||||
|
pub use wrapup::*;
|
||||||
70
crates/domain/src/ports/movie.rs
Normal file
70
crates/domain/src/ports/movie.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{
|
||||||
|
MetadataSearchCriteria, Movie, MovieFilter, MovieProfile, MovieSummary,
|
||||||
|
collections::{PageParams, Paginated},
|
||||||
|
},
|
||||||
|
value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait MovieRepository: Send + Sync {
|
||||||
|
async fn get_movie_by_external_id(
|
||||||
|
&self,
|
||||||
|
external_metadata_id: &ExternalMetadataId,
|
||||||
|
) -> Result<Option<Movie>, DomainError>;
|
||||||
|
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError>;
|
||||||
|
async fn get_movies_by_title_and_year(
|
||||||
|
&self,
|
||||||
|
title: &MovieTitle,
|
||||||
|
year: &ReleaseYear,
|
||||||
|
) -> Result<Vec<Movie>, DomainError>;
|
||||||
|
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>;
|
||||||
|
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError>;
|
||||||
|
async fn existing_external_ids(
|
||||||
|
&self,
|
||||||
|
ids: &[ExternalMetadataId],
|
||||||
|
) -> Result<std::collections::HashSet<String>, DomainError>;
|
||||||
|
async fn existing_title_year_pairs(
|
||||||
|
&self,
|
||||||
|
pairs: &[(MovieTitle, ReleaseYear)],
|
||||||
|
) -> Result<std::collections::HashSet<(String, u16)>, DomainError>;
|
||||||
|
async fn list_movies(
|
||||||
|
&self,
|
||||||
|
page: &PageParams,
|
||||||
|
filter: &MovieFilter,
|
||||||
|
) -> Result<Paginated<MovieSummary>, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait MetadataClient: Send + Sync {
|
||||||
|
async fn fetch_movie_metadata(
|
||||||
|
&self,
|
||||||
|
criteria: &MetadataSearchCriteria,
|
||||||
|
) -> Result<Movie, DomainError>;
|
||||||
|
async fn get_poster_url(
|
||||||
|
&self,
|
||||||
|
external_metadata_id: &ExternalMetadataId,
|
||||||
|
) -> Result<Option<PosterUrl>, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait MovieProfileRepository: Send + Sync {
|
||||||
|
async fn upsert(&self, profile: &MovieProfile) -> Result<(), DomainError>;
|
||||||
|
async fn get_by_movie_id(&self, id: &MovieId) -> Result<Option<MovieProfile>, DomainError>;
|
||||||
|
/// Returns (movie_id, external_metadata_id) for movies with no profile or a stale one
|
||||||
|
/// (enriched_at older than 30 days).
|
||||||
|
async fn list_stale(&self) -> Result<Vec<(MovieId, String)>, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait MovieEnrichmentClient: Send + Sync {
|
||||||
|
/// Resolves an external ID (TMDb or IMDb) and fetches the full movie profile.
|
||||||
|
async fn fetch_profile(
|
||||||
|
&self,
|
||||||
|
movie_id: MovieId,
|
||||||
|
external_metadata_id: &str,
|
||||||
|
) -> Result<MovieProfile, DomainError>;
|
||||||
|
}
|
||||||
45
crates/domain/src/ports/person.rs
Normal file
45
crates/domain/src/ports/person.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{ExternalPersonId, Person, PersonCredits, PersonEnrichmentData, PersonId},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait PersonEnrichmentClient: Send + Sync {
|
||||||
|
async fn fetch_details(&self, external_id: &str) -> Result<PersonEnrichmentData, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write port — mutates the persons table. No reads.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait PersonCommand: Send + Sync {
|
||||||
|
/// Upsert a batch of persons. Uses INSERT OR REPLACE (SQLite) / ON CONFLICT DO UPDATE (Postgres).
|
||||||
|
async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError>;
|
||||||
|
/// Insert a batch of missing persons from movie_cast/movie_crew into the persons table.
|
||||||
|
/// Returns (inserted_count, has_more).
|
||||||
|
async fn backfill_from_credits_batch(
|
||||||
|
&self,
|
||||||
|
batch_size: u32,
|
||||||
|
) -> Result<(u64, bool), DomainError>;
|
||||||
|
async fn update_enrichment(
|
||||||
|
&self,
|
||||||
|
id: &PersonId,
|
||||||
|
data: &PersonEnrichmentData,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read port — queries persons and credits. No mutations.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait PersonQuery: Send + Sync {
|
||||||
|
async fn get_by_id(&self, id: &PersonId) -> Result<Option<Person>, DomainError>;
|
||||||
|
async fn get_by_external_id(
|
||||||
|
&self,
|
||||||
|
id: &ExternalPersonId,
|
||||||
|
) -> Result<Option<Person>, DomainError>;
|
||||||
|
/// Returns the person's full cast and crew credit history across all indexed movies.
|
||||||
|
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError>;
|
||||||
|
/// Returns persons who have no remaining entries in movie_cast or movie_crew.
|
||||||
|
/// Called after movie deletion to find index entries that can be pruned.
|
||||||
|
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError>;
|
||||||
|
async fn list_page(&self, limit: u32, offset: u32) -> Result<Vec<Person>, DomainError>;
|
||||||
|
}
|
||||||
21
crates/domain/src/ports/search.rs
Normal file
21
crates/domain/src/ports/search.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{EntityType, IndexableDocument, SearchQuery, SearchResults},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Read port — executes search queries. No mutations.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait SearchPort: Send + Sync {
|
||||||
|
async fn search(&self, query: &SearchQuery) -> Result<SearchResults, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write port — manages the search index. No reads.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait SearchCommand: Send + Sync {
|
||||||
|
/// Add or replace a document in the search index.
|
||||||
|
async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError>;
|
||||||
|
/// Remove a document from the search index by entity type and internal ID string.
|
||||||
|
async fn remove(&self, entity_type: EntityType, id: &str) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
93
crates/domain/src/ports/social.rs
Normal file
93
crates/domain/src/ports/social.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{
|
||||||
|
DiaryEntry, FederationFlags, Goal, Movie, PendingFollowerInfo, RemoteActorInfo,
|
||||||
|
RemoteGoalEntry, RemoteWatchlistEntry, Review, WatchlistWithMovie,
|
||||||
|
},
|
||||||
|
value_objects::{MovieId, ReviewId, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait SocialQueryPort: Send + Sync {
|
||||||
|
async fn get_accepted_following_urls(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
) -> Result<Vec<String>, DomainError>;
|
||||||
|
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError>;
|
||||||
|
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError>;
|
||||||
|
async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError>;
|
||||||
|
async fn get_pending_followers(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
) -> Result<Vec<PendingFollowerInfo>, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserFederationSettingsQuery: Send + Sync {
|
||||||
|
async fn get_federation_flags(&self, user_id: &UserId) -> Result<FederationFlags, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait RemoteWatchlistRepository: Send + Sync {
|
||||||
|
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError>;
|
||||||
|
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError>;
|
||||||
|
async fn get_by_actor_url(
|
||||||
|
&self,
|
||||||
|
actor_url: &str,
|
||||||
|
) -> Result<Vec<RemoteWatchlistEntry>, DomainError>;
|
||||||
|
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError>;
|
||||||
|
/// Find entries for a remote actor whose URL hashes (v5 UUID) to the given UUID.
|
||||||
|
async fn get_by_derived_uuid(
|
||||||
|
&self,
|
||||||
|
uuid: uuid::Uuid,
|
||||||
|
) -> Result<Vec<RemoteWatchlistEntry>, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait RemoteGoalRepository: Send + Sync {
|
||||||
|
async fn save(&self, entry: RemoteGoalEntry) -> Result<(), DomainError>;
|
||||||
|
async fn update_by_ap_id(
|
||||||
|
&self,
|
||||||
|
ap_id: &str,
|
||||||
|
target: u32,
|
||||||
|
current: u32,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError>;
|
||||||
|
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError>;
|
||||||
|
async fn get_by_actor_url(&self, actor_url: &str) -> Result<Vec<RemoteGoalEntry>, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only query port used exclusively by the ActivityPub adapter.
|
||||||
|
/// Consolidates all reads the AP adapter needs so it never touches write repositories.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait LocalApContentQuery: Send + Sync {
|
||||||
|
async fn get_local_reviews_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<Vec<DiaryEntry>, DomainError>;
|
||||||
|
async fn get_local_watchlist_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<Vec<WatchlistWithMovie>, DomainError>;
|
||||||
|
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>;
|
||||||
|
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError>;
|
||||||
|
async fn count_local_posts(&self) -> Result<u64, DomainError>;
|
||||||
|
async fn get_local_reviews_for_movie(
|
||||||
|
&self,
|
||||||
|
movie_id: &MovieId,
|
||||||
|
) -> Result<Vec<DiaryEntry>, DomainError>;
|
||||||
|
async fn get_local_reviews_page(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
before: Option<NaiveDateTime>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<DiaryEntry>, DomainError>;
|
||||||
|
async fn get_goal_with_progress(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
year: u16,
|
||||||
|
) -> Result<Option<(Goal, u32)>, DomainError>;
|
||||||
|
}
|
||||||
34
crates/domain/src/ports/watchlist.rs
Normal file
34
crates/domain/src/ports/watchlist.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{
|
||||||
|
WatchlistEntry, WatchlistWithMovie,
|
||||||
|
collections::{PageParams, Paginated},
|
||||||
|
},
|
||||||
|
value_objects::{MovieId, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait WatchlistRepository: Send + Sync {
|
||||||
|
/// Add a new entry. Silently succeeds if the entry already exists.
|
||||||
|
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
/// Remove an entry. Returns NotFound if the entry does not exist.
|
||||||
|
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
/// Remove an entry if it exists. Never returns NotFound.
|
||||||
|
async fn remove_if_present(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
movie_id: &MovieId,
|
||||||
|
) -> Result<bool, DomainError>;
|
||||||
|
|
||||||
|
async fn get_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
page: &PageParams,
|
||||||
|
) -> Result<Paginated<WatchlistWithMovie>, DomainError>;
|
||||||
|
|
||||||
|
async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result<bool, DomainError>;
|
||||||
|
}
|
||||||
43
crates/domain/src/ports/wrapup.rs
Normal file
43
crates/domain/src/ports/wrapup.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{NaiveDate, NaiveDateTime};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::wrapup::{
|
||||||
|
DateRange, WrapUpMovieRow, WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus,
|
||||||
|
},
|
||||||
|
value_objects::WrapUpId,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait WrapUpRepository: Send + Sync {
|
||||||
|
async fn create(&self, record: &WrapUpRecord) -> Result<(), DomainError>;
|
||||||
|
async fn update_status(
|
||||||
|
&self,
|
||||||
|
id: &WrapUpId,
|
||||||
|
status: &WrapUpStatus,
|
||||||
|
error: Option<&str>,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
async fn set_complete(&self, id: &WrapUpId, report: &WrapUpReport) -> Result<(), DomainError>;
|
||||||
|
async fn get_by_id(&self, id: &WrapUpId) -> Result<Option<WrapUpRecord>, DomainError>;
|
||||||
|
async fn list_for_user(&self, user_id: Uuid) -> Result<Vec<WrapUpRecord>, DomainError>;
|
||||||
|
async fn list_global(&self) -> Result<Vec<WrapUpRecord>, DomainError>;
|
||||||
|
async fn find_existing(
|
||||||
|
&self,
|
||||||
|
user_id: Option<Uuid>,
|
||||||
|
start: NaiveDate,
|
||||||
|
end: NaiveDate,
|
||||||
|
) -> Result<Option<WrapUpRecord>, DomainError>;
|
||||||
|
async fn delete(&self, id: &WrapUpId) -> Result<(), DomainError>;
|
||||||
|
async fn delete_failed_older_than(&self, before: NaiveDateTime) -> Result<u64, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait WrapUpStatsQuery: Send + Sync {
|
||||||
|
async fn get_reviews_with_profiles(
|
||||||
|
&self,
|
||||||
|
scope: &WrapUpScope,
|
||||||
|
range: &DateRange,
|
||||||
|
) -> Result<Vec<WrapUpMovieRow>, DomainError>;
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::WrapUpMovieRow;
|
||||||
use crate::models::wrapup::{DateRange, WrapUpScope};
|
use crate::models::wrapup::{DateRange, WrapUpScope};
|
||||||
use crate::ports::WrapUpMovieRow;
|
|
||||||
|
|
||||||
use super::super::wrapup_analyzer::build_report;
|
use super::super::wrapup_analyzer::build_report;
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ use std::collections::HashMap;
|
|||||||
use chrono::Datelike;
|
use chrono::Datelike;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::WrapUpMovieRow;
|
||||||
use crate::models::wrapup::*;
|
use crate::models::wrapup::*;
|
||||||
use crate::models::{ExternalPersonId, PersonId};
|
use crate::models::{ExternalPersonId, PersonId};
|
||||||
use crate::ports::WrapUpMovieRow;
|
|
||||||
|
|
||||||
pub fn build_report(
|
pub fn build_report(
|
||||||
scope: WrapUpScope,
|
scope: WrapUpScope,
|
||||||
|
|||||||
@@ -8,16 +8,17 @@ use uuid::Uuid;
|
|||||||
use crate::{
|
use crate::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{
|
models::{
|
||||||
AnnotatedRow, DiaryEntry, DiaryFilter, ExternalPersonId, FeedEntry, FieldMapping,
|
AnnotatedRow, DiaryEntry, DiaryFilter, ExternalPersonId, FeedEntry, FeedSortBy,
|
||||||
FileFormat, ImportError, ImportRow, Movie, MovieProfile, MovieStats, ParsedFile, Person,
|
FieldMapping, FileFormat, FollowingFilter, GeneratedToken, ImportError, ImportRow,
|
||||||
PersonCredits, PersonId, Review, ReviewHistory, RowResult, SearchQuery, SearchResults,
|
MetadataSearchCriteria, Movie, MovieProfile, MovieStats, ParsedFile, Person, PersonCredits,
|
||||||
UserStats, UserTrends,
|
PersonId, Review, ReviewHistory, RowResult, SearchQuery, SearchResults, UserStats,
|
||||||
|
UserTrends,
|
||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
},
|
},
|
||||||
ports::{
|
ports::{
|
||||||
AuthService, DiaryRepository, DocumentParser, FeedSortBy, FollowingFilter, GeneratedToken,
|
AuthService, DiaryRepository, DocumentParser, MetadataClient, MovieEnrichmentClient,
|
||||||
MetadataClient, MetadataSearchCriteria, MovieEnrichmentClient, PasswordHasher, PersonQuery,
|
PasswordHasher, PersonQuery, PosterFetcherClient, SearchCommand, SearchPort,
|
||||||
PosterFetcherClient, SearchCommand, SearchPort, StatsRepository,
|
StatsRepository,
|
||||||
},
|
},
|
||||||
value_objects::{ExternalMetadataId, MovieId, PasswordHash, PosterUrl, UserId},
|
value_objects::{ExternalMetadataId, MovieId, PasswordHash, PosterUrl, UserId},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,16 +12,16 @@ use crate::{
|
|||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{
|
models::{
|
||||||
Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile, MovieSummary,
|
FederationFlags, Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile,
|
||||||
ProfileField, RefreshSession, Review, User, UserSettings, UserSummary, WatchEvent,
|
MovieSummary, ProfileField, RefreshSession, Review, User, UserSettings, UserSummary,
|
||||||
WatchEventStatus, WatchlistEntry, WatchlistWithMovie, WebhookToken,
|
WatchEvent, WatchEventStatus, WatchlistEntry, WatchlistWithMovie, WebhookToken,
|
||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
},
|
},
|
||||||
ports::{
|
ports::{
|
||||||
FederationFlags, GoalRepository, ImportProfileRepository, ImportSessionRepository,
|
GoalRepository, ImportProfileRepository, ImportSessionRepository, MovieProfileRepository,
|
||||||
MovieProfileRepository, MovieRepository, RefreshSessionRepository, ReviewRepository,
|
MovieRepository, RefreshSessionRepository, ReviewRepository, UserFederationSettingsQuery,
|
||||||
UserFederationSettingsQuery, UserProfileFieldsRepository, UserRepository,
|
UserProfileFieldsRepository, UserRepository, UserSettingsRepository, WatchEventRepository,
|
||||||
UserSettingsRepository, WatchEventRepository, WatchlistRepository, WebhookTokenRepository,
|
WatchlistRepository, WebhookTokenRepository,
|
||||||
},
|
},
|
||||||
value_objects::{
|
value_objects::{
|
||||||
Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
|
Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ impl crate::ports::SocialQueryPort for NoopSocialQueryPort {
|
|||||||
}
|
}
|
||||||
async fn list_all_followed_remote_actors(
|
async fn list_all_followed_remote_actors(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<Vec<crate::ports::RemoteActorInfo>, DomainError> {
|
) -> Result<Vec<crate::models::RemoteActorInfo>, DomainError> {
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
}
|
}
|
||||||
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||||
@@ -114,7 +114,7 @@ impl crate::ports::SocialQueryPort for NoopSocialQueryPort {
|
|||||||
async fn get_pending_followers(
|
async fn get_pending_followers(
|
||||||
&self,
|
&self,
|
||||||
_: uuid::Uuid,
|
_: uuid::Uuid,
|
||||||
) -> Result<Vec<crate::ports::PendingFollowerInfo>, DomainError> {
|
) -> Result<Vec<crate::models::PendingFollowerInfo>, DomainError> {
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,17 @@ use crate::{
|
|||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{
|
models::{
|
||||||
AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId,
|
AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId,
|
||||||
FeedEntry, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession,
|
FeedEntry, FeedSortBy, FieldMapping, FileFormat, FollowingFilter, ImportError,
|
||||||
IndexableDocument, MovieProfile, MovieStats, ParsedFile, Person, PersonCredits,
|
ImportProfile, ImportSession, IndexableDocument, MovieProfile, MovieStats, ParsedFile,
|
||||||
PersonEnrichmentData, PersonId, RefreshSession, ReviewHistory, SearchQuery, SearchResults,
|
PendingFollowerInfo, Person, PersonCredits, PersonEnrichmentData, PersonId, RefreshSession,
|
||||||
UserStats, UserTrends,
|
RemoteActorInfo, ReviewHistory, SearchQuery, SearchResults, UserStats, UserTrends,
|
||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
},
|
},
|
||||||
ports::{
|
ports::{
|
||||||
DiaryExporter, DiaryRepository, DocumentParser, FeedSortBy, FollowingFilter,
|
DiaryExporter, DiaryRepository, DocumentParser, ImportProfileRepository,
|
||||||
ImportProfileRepository, ImportSessionRepository, MovieProfileRepository, PersonCommand,
|
ImportSessionRepository, MovieProfileRepository, PersonCommand, PersonQuery,
|
||||||
PersonQuery, PosterFetcherClient, RefreshSessionRepository, SearchCommand, SearchPort,
|
PosterFetcherClient, RefreshSessionRepository, SearchCommand, SearchPort, StatsRepository,
|
||||||
StatsRepository, UserProfileFieldsRepository,
|
UserProfileFieldsRepository,
|
||||||
},
|
},
|
||||||
value_objects::{ImportProfileId, ImportSessionId, MovieId, PosterUrl, UserId},
|
value_objects::{ImportProfileId, ImportSessionId, MovieId, PosterUrl, UserId},
|
||||||
};
|
};
|
||||||
@@ -330,9 +330,7 @@ impl crate::ports::SocialQueryPort for PanicSocialQueryPort {
|
|||||||
async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result<Vec<String>, DomainError> {
|
async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result<Vec<String>, DomainError> {
|
||||||
panic!("PanicSocialQueryPort called")
|
panic!("PanicSocialQueryPort called")
|
||||||
}
|
}
|
||||||
async fn list_all_followed_remote_actors(
|
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError> {
|
||||||
&self,
|
|
||||||
) -> Result<Vec<crate::ports::RemoteActorInfo>, DomainError> {
|
|
||||||
panic!("PanicSocialQueryPort called")
|
panic!("PanicSocialQueryPort called")
|
||||||
}
|
}
|
||||||
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||||
@@ -344,7 +342,7 @@ impl crate::ports::SocialQueryPort for PanicSocialQueryPort {
|
|||||||
async fn get_pending_followers(
|
async fn get_pending_followers(
|
||||||
&self,
|
&self,
|
||||||
_: uuid::Uuid,
|
_: uuid::Uuid,
|
||||||
) -> Result<Vec<crate::ports::PendingFollowerInfo>, DomainError> {
|
) -> Result<Vec<PendingFollowerInfo>, DomainError> {
|
||||||
panic!("PanicSocialQueryPort called")
|
panic!("PanicSocialQueryPort called")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ impl crate::ports::WrapUpStatsQuery for PanicWrapUpStatsQuery {
|
|||||||
&self,
|
&self,
|
||||||
_: &crate::models::wrapup::WrapUpScope,
|
_: &crate::models::wrapup::WrapUpScope,
|
||||||
_: &crate::models::wrapup::DateRange,
|
_: &crate::models::wrapup::DateRange,
|
||||||
) -> Result<Vec<crate::ports::WrapUpMovieRow>, DomainError> {
|
) -> Result<Vec<crate::models::WrapUpMovieRow>, DomainError> {
|
||||||
unimplemented!("WrapUpStatsQuery not wired")
|
unimplemented!("WrapUpStatsQuery not wired")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@ impl crate::ports::WrapUpStatsQuery for PanicWrapUpStatsQuery {
|
|||||||
// ── InMemoryWrapUpStatsQuery ────────────────────────────────────────────────
|
// ── InMemoryWrapUpStatsQuery ────────────────────────────────────────────────
|
||||||
|
|
||||||
pub struct InMemoryWrapUpStatsQuery {
|
pub struct InMemoryWrapUpStatsQuery {
|
||||||
pub rows: Mutex<Vec<crate::ports::WrapUpMovieRow>>,
|
pub rows: Mutex<Vec<crate::models::WrapUpMovieRow>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InMemoryWrapUpStatsQuery {
|
impl InMemoryWrapUpStatsQuery {
|
||||||
@@ -33,7 +33,7 @@ impl InMemoryWrapUpStatsQuery {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_rows(rows: Vec<crate::ports::WrapUpMovieRow>) -> Arc<Self> {
|
pub fn with_rows(rows: Vec<crate::models::WrapUpMovieRow>) -> Arc<Self> {
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
rows: Mutex::new(rows),
|
rows: Mutex::new(rows),
|
||||||
})
|
})
|
||||||
@@ -46,7 +46,7 @@ impl crate::ports::WrapUpStatsQuery for InMemoryWrapUpStatsQuery {
|
|||||||
&self,
|
&self,
|
||||||
scope: &crate::models::wrapup::WrapUpScope,
|
scope: &crate::models::wrapup::WrapUpScope,
|
||||||
range: &crate::models::wrapup::DateRange,
|
range: &crate::models::wrapup::DateRange,
|
||||||
) -> Result<Vec<crate::ports::WrapUpMovieRow>, DomainError> {
|
) -> Result<Vec<crate::models::WrapUpMovieRow>, DomainError> {
|
||||||
let rows = self.rows.lock().unwrap();
|
let rows = self.rows.lock().unwrap();
|
||||||
let filtered: Vec<_> = rows
|
let filtered: Vec<_> = rows
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -1,284 +0,0 @@
|
|||||||
use crate::errors::DomainError;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
macro_rules! uuid_id {
|
|
||||||
($name:ident) => {
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
||||||
pub struct $name(Uuid);
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
uuid_id!(MovieId);
|
|
||||||
uuid_id!(ReviewId);
|
|
||||||
uuid_id!(UserId);
|
|
||||||
uuid_id!(ImportSessionId);
|
|
||||||
uuid_id!(ImportProfileId);
|
|
||||||
uuid_id!(WatchlistEntryId);
|
|
||||||
uuid_id!(WatchEventId);
|
|
||||||
uuid_id!(WebhookTokenId);
|
|
||||||
uuid_id!(WrapUpId);
|
|
||||||
uuid_id!(GoalId);
|
|
||||||
|
|
||||||
#[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 {
|
|
||||||
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() > Self::MAX_LENGTH {
|
|
||||||
Err(DomainError::ValidationError(format!(
|
|
||||||
"Movie title exceeds {} characters",
|
|
||||||
Self::MAX_LENGTH
|
|
||||||
)))
|
|
||||||
} else {
|
|
||||||
Ok(Self(trimmed.to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn value(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
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() > Self::MAX_LENGTH {
|
|
||||||
Err(DomainError::ValidationError(format!(
|
|
||||||
"Comment exceeds {} characters",
|
|
||||||
Self::MAX_LENGTH
|
|
||||||
)))
|
|
||||||
} 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 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
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct PosterUrl(String);
|
|
||||||
|
|
||||||
impl PosterUrl {
|
|
||||||
pub fn new(url: String) -> Result<Self, DomainError> {
|
|
||||||
let trimmed = url.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
Err(DomainError::ValidationError(
|
|
||||||
"Poster URL cannot be empty".into(),
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Ok(Self(trimmed.to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn value(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq)]
|
|
||||||
pub struct Password(String);
|
|
||||||
|
|
||||||
impl std::fmt::Debug for Password {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.write_str("Password([REDACTED])")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Password {
|
|
||||||
const MIN_LENGTH: usize = 8;
|
|
||||||
|
|
||||||
pub fn new(raw: String) -> Result<Self, DomainError> {
|
|
||||||
if raw.chars().count() < Self::MIN_LENGTH {
|
|
||||||
Err(DomainError::ValidationError(
|
|
||||||
"Password must be at least 8 characters".into(),
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Ok(Self(raw))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn value(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/value_objects.rs"]
|
|
||||||
mod tests;
|
|
||||||
31
crates/domain/src/value_objects/ids.rs
Normal file
31
crates/domain/src/value_objects/ids.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
macro_rules! uuid_id {
|
||||||
|
($name:ident) => {
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct $name(Uuid);
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
uuid_id!(MovieId);
|
||||||
|
uuid_id!(ReviewId);
|
||||||
|
uuid_id!(UserId);
|
||||||
|
uuid_id!(ImportSessionId);
|
||||||
|
uuid_id!(ImportProfileId);
|
||||||
|
uuid_id!(WatchlistEntryId);
|
||||||
|
uuid_id!(WatchEventId);
|
||||||
|
uuid_id!(WebhookTokenId);
|
||||||
|
uuid_id!(WrapUpId);
|
||||||
|
uuid_id!(GoalId);
|
||||||
13
crates/domain/src/value_objects/mod.rs
Normal file
13
crates/domain/src/value_objects/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
mod ids;
|
||||||
|
mod movie;
|
||||||
|
mod review;
|
||||||
|
mod user;
|
||||||
|
|
||||||
|
pub use ids::*;
|
||||||
|
pub use movie::*;
|
||||||
|
pub use review::*;
|
||||||
|
pub use user::*;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../tests/value_objects.rs"]
|
||||||
|
mod tests;
|
||||||
110
crates/domain/src/value_objects/movie.rs
Normal file
110
crates/domain/src/value_objects/movie.rs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
use crate::errors::DomainError;
|
||||||
|
|
||||||
|
#[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 PosterUrl(String);
|
||||||
|
|
||||||
|
impl PosterUrl {
|
||||||
|
pub fn new(url: String) -> Result<Self, DomainError> {
|
||||||
|
let trimmed = url.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
Err(DomainError::ValidationError(
|
||||||
|
"Poster URL 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 {
|
||||||
|
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() > Self::MAX_LENGTH {
|
||||||
|
Err(DomainError::ValidationError(format!(
|
||||||
|
"Movie title exceeds {} characters",
|
||||||
|
Self::MAX_LENGTH
|
||||||
|
)))
|
||||||
|
} else {
|
||||||
|
Ok(Self(trimmed.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn value(&self) -> &str {
|
||||||
|
&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
|
||||||
|
}
|
||||||
|
}
|
||||||
46
crates/domain/src/value_objects/review.rs
Normal file
46
crates/domain/src/value_objects/review.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
use crate::errors::DomainError;
|
||||||
|
|
||||||
|
#[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 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() > Self::MAX_LENGTH {
|
||||||
|
Err(DomainError::ValidationError(format!(
|
||||||
|
"Comment exceeds {} characters",
|
||||||
|
Self::MAX_LENGTH
|
||||||
|
)))
|
||||||
|
} else {
|
||||||
|
Ok(Self(trimmed.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn value(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
99
crates/domain/src/value_objects/user.rs
Normal file
99
crates/domain/src/value_objects/user.rs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
use crate::errors::DomainError;
|
||||||
|
|
||||||
|
#[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 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
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
pub struct Password(String);
|
||||||
|
|
||||||
|
impl std::fmt::Debug for Password {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("Password([REDACTED])")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Password {
|
||||||
|
const MIN_LENGTH: usize = 8;
|
||||||
|
|
||||||
|
pub fn new(raw: String) -> Result<Self, DomainError> {
|
||||||
|
if raw.chars().count() < Self::MIN_LENGTH {
|
||||||
|
Err(DomainError::ValidationError(
|
||||||
|
"Password must be at least 8 characters".into(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(Self(raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn value(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -291,7 +291,7 @@ pub async fn get_user_profile(
|
|||||||
view: profile_view,
|
view: profile_view,
|
||||||
limit: params.limit,
|
limit: params.limit,
|
||||||
offset: params.offset,
|
offset: params.offset,
|
||||||
sort_by: domain::ports::FeedSortBy::Date,
|
sort_by: domain::models::FeedSortBy::Date,
|
||||||
search: params.search,
|
search: params.search,
|
||||||
is_own_profile: viewer_id.value() == user_id,
|
is_own_profile: viewer_id.value() == user_id,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use application::users::get_profile::PendingFollowerView;
|
use application::users::get_profile::PendingFollowerView;
|
||||||
use chrono::Datelike;
|
use chrono::Datelike;
|
||||||
|
use domain::models::RemoteActorInfo;
|
||||||
use domain::models::{DiaryEntry, MonthActivity, UserSummary};
|
use domain::models::{DiaryEntry, MonthActivity, UserSummary};
|
||||||
use domain::ports::RemoteActorInfo;
|
|
||||||
use template_askama::{RemoteActorData, RemoteActorDisplay, UserSummaryView};
|
use template_askama::{RemoteActorData, RemoteActorDisplay, UserSummaryView};
|
||||||
|
|
||||||
pub fn user_summary_view(u: &UserSummary) -> UserSummaryView {
|
pub fn user_summary_view(u: &UserSummary) -> UserSummaryView {
|
||||||
|
|||||||
@@ -11,16 +11,16 @@ use domain::{
|
|||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{
|
models::{
|
||||||
DiaryEntry, DiaryFilter, EntityType, FeedEntry, IndexableDocument, Movie, Person,
|
DiaryEntry, DiaryFilter, EntityType, FeedEntry, GeneratedToken, IndexableDocument, Movie,
|
||||||
PersonCredits, PersonEnrichmentData, PersonId, Review, ReviewHistory, SearchQuery,
|
Person, PersonCredits, PersonEnrichmentData, PersonId, Review, ReviewHistory, SearchQuery,
|
||||||
SearchResults, UserStats, UserTrends,
|
SearchResults, UserStats, UserTrends,
|
||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
},
|
},
|
||||||
ports::{
|
ports::{
|
||||||
AuthService, DiaryRepository, EventPublisher, GeneratedToken, MetadataClient,
|
AuthService, DiaryRepository, EventPublisher, MetadataClient, MovieRepository,
|
||||||
MovieRepository, ObjectStorage, PasswordHasher, PersonCommand, PersonQuery,
|
ObjectStorage, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
|
||||||
PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort, StatsRepository,
|
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserRepository,
|
||||||
UserRepository, WatchlistRepository,
|
WatchlistRepository,
|
||||||
},
|
},
|
||||||
value_objects::{
|
value_objects::{
|
||||||
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl, ReleaseYear,
|
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl, ReleaseYear,
|
||||||
@@ -108,9 +108,9 @@ impl DiaryRepository for Panic {
|
|||||||
async fn query_activity_feed_filtered(
|
async fn query_activity_feed_filtered(
|
||||||
&self,
|
&self,
|
||||||
_: &PageParams,
|
_: &PageParams,
|
||||||
_: &domain::ports::FeedSortBy,
|
_: &domain::models::FeedSortBy,
|
||||||
_: Option<&str>,
|
_: Option<&str>,
|
||||||
_: Option<&domain::ports::FollowingFilter>,
|
_: Option<&domain::models::FollowingFilter>,
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
panic!()
|
panic!()
|
||||||
}
|
}
|
||||||
@@ -151,7 +151,7 @@ impl domain::ports::SocialQueryPort for Panic {
|
|||||||
}
|
}
|
||||||
async fn list_all_followed_remote_actors(
|
async fn list_all_followed_remote_actors(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<Vec<domain::ports::RemoteActorInfo>, DomainError> {
|
) -> Result<Vec<domain::models::RemoteActorInfo>, DomainError> {
|
||||||
panic!()
|
panic!()
|
||||||
}
|
}
|
||||||
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||||
@@ -163,7 +163,7 @@ impl domain::ports::SocialQueryPort for Panic {
|
|||||||
async fn get_pending_followers(
|
async fn get_pending_followers(
|
||||||
&self,
|
&self,
|
||||||
_: uuid::Uuid,
|
_: uuid::Uuid,
|
||||||
) -> Result<Vec<domain::ports::PendingFollowerInfo>, DomainError> {
|
) -> Result<Vec<domain::models::PendingFollowerInfo>, DomainError> {
|
||||||
panic!()
|
panic!()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,7 +180,7 @@ impl StatsRepository for Panic {
|
|||||||
impl MetadataClient for Panic {
|
impl MetadataClient for Panic {
|
||||||
async fn fetch_movie_metadata(
|
async fn fetch_movie_metadata(
|
||||||
&self,
|
&self,
|
||||||
_: &domain::ports::MetadataSearchCriteria,
|
_: &domain::models::MetadataSearchCriteria,
|
||||||
) -> Result<Movie, DomainError> {
|
) -> Result<Movie, DomainError> {
|
||||||
panic!()
|
panic!()
|
||||||
}
|
}
|
||||||
@@ -608,7 +608,7 @@ impl domain::ports::WrapUpStatsQuery for Panic {
|
|||||||
&self,
|
&self,
|
||||||
_: &domain::models::wrapup::WrapUpScope,
|
_: &domain::models::wrapup::WrapUpScope,
|
||||||
_: &domain::models::wrapup::DateRange,
|
_: &domain::models::wrapup::DateRange,
|
||||||
) -> Result<Vec<domain::ports::WrapUpMovieRow>, DomainError> {
|
) -> Result<Vec<domain::models::WrapUpMovieRow>, DomainError> {
|
||||||
panic!()
|
panic!()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ use domain::{
|
|||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{
|
models::{
|
||||||
EntityType, ExternalPersonId, IndexableDocument, Movie, Person, PersonCredits,
|
EntityType, ExternalPersonId, GeneratedToken, IndexableDocument, MetadataSearchCriteria,
|
||||||
PersonEnrichmentData, PersonId, SearchQuery, SearchResults, User,
|
Movie, Person, PersonCredits, PersonEnrichmentData, PersonId, SearchQuery, SearchResults,
|
||||||
|
User,
|
||||||
},
|
},
|
||||||
ports::{
|
ports::{
|
||||||
AuthService, EventPublisher, GeneratedToken, MetadataClient, MetadataSearchCriteria,
|
AuthService, EventPublisher, MetadataClient, ObjectStorage, PasswordHasher, PersonCommand,
|
||||||
ObjectStorage, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
|
PersonQuery, PosterFetcherClient, SearchCommand, SearchPort, UserRepository,
|
||||||
SearchCommand, SearchPort, UserRepository,
|
|
||||||
},
|
},
|
||||||
value_objects::{Email, ExternalMetadataId, PasswordHash, PosterUrl, UserId},
|
value_objects::{Email, ExternalMetadataId, PasswordHash, PosterUrl, UserId},
|
||||||
};
|
};
|
||||||
@@ -410,7 +410,7 @@ impl domain::ports::SocialQueryPort for PanicSocialQuery {
|
|||||||
}
|
}
|
||||||
async fn list_all_followed_remote_actors(
|
async fn list_all_followed_remote_actors(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<Vec<domain::ports::RemoteActorInfo>, DomainError> {
|
) -> Result<Vec<domain::models::RemoteActorInfo>, DomainError> {
|
||||||
panic!()
|
panic!()
|
||||||
}
|
}
|
||||||
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||||
@@ -422,7 +422,7 @@ impl domain::ports::SocialQueryPort for PanicSocialQuery {
|
|||||||
async fn get_pending_followers(
|
async fn get_pending_followers(
|
||||||
&self,
|
&self,
|
||||||
_: uuid::Uuid,
|
_: uuid::Uuid,
|
||||||
) -> Result<Vec<domain::ports::PendingFollowerInfo>, DomainError> {
|
) -> Result<Vec<domain::models::PendingFollowerInfo>, DomainError> {
|
||||||
panic!()
|
panic!()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user