export feature

This commit is contained in:
2026-05-09 20:51:29 +02:00
parent 1eaa3ca8a6
commit dcfc17f542
57 changed files with 2245 additions and 624 deletions

View File

@@ -10,12 +10,16 @@ impl AppConfig {
let allow_registration = std::env::var("ALLOW_REGISTRATION")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let base_url = std::env::var("BASE_URL")
.unwrap_or_else(|_| "http://localhost:3000".to_string());
let base_url =
std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
let rate_limit = std::env::var("RATE_LIMIT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(20);
Self { allow_registration, base_url, rate_limit }
Self {
allow_registration,
base_url,
rate_limit,
}
}
}

View File

@@ -1,7 +1,7 @@
use std::sync::Arc;
use domain::ports::{
AuthService, DiaryRepository, EventPublisher, MetadataClient, MovieRepository,
AuthService, DiaryExporter, DiaryRepository, EventPublisher, MetadataClient, MovieRepository,
PasswordHasher, PosterFetcherClient, PosterStorage, ReviewRepository, StatsRepository,
UserRepository,
};
@@ -13,6 +13,7 @@ pub struct AppContext {
pub movie_repository: Arc<dyn MovieRepository>,
pub review_repository: Arc<dyn ReviewRepository>,
pub diary_repository: Arc<dyn DiaryRepository>,
pub diary_exporter: Arc<dyn DiaryExporter>,
pub stats_repository: Arc<dyn StatsRepository>,
pub metadata_client: Arc<dyn MetadataClient>,
pub poster_fetcher: Arc<dyn PosterFetcherClient>,

View File

@@ -207,29 +207,80 @@ mod tests {
#[async_trait]
impl MovieRepository for RepoWithExternalMovie {
async fn get_movie_by_external_id(&self, _: &ExternalMetadataId) -> Result<Option<Movie>, DomainError> { Ok(Some(self.0.clone())) }
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> { panic!("unexpected") }
async fn get_movies_by_title_and_year(&self, _: &MovieTitle, _: &ReleaseYear) -> Result<Vec<Movie>, DomainError> { panic!("unexpected") }
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
async fn get_movie_by_external_id(
&self,
_: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
Ok(Some(self.0.clone()))
}
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
panic!("unexpected")
}
async fn get_movies_by_title_and_year(
&self,
_: &MovieTitle,
_: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
panic!("unexpected")
}
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!("unexpected")
}
}
#[async_trait]
impl MovieRepository for RepoEmpty {
async fn get_movie_by_external_id(&self, _: &ExternalMetadataId) -> Result<Option<Movie>, DomainError> { Ok(None) }
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> { panic!("unexpected") }
async fn get_movies_by_title_and_year(&self, _: &MovieTitle, _: &ReleaseYear) -> Result<Vec<Movie>, DomainError> { Ok(vec![]) }
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
async fn get_movie_by_external_id(
&self,
_: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
Ok(None)
}
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
panic!("unexpected")
}
async fn get_movies_by_title_and_year(
&self,
_: &MovieTitle,
_: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
Ok(vec![])
}
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!("unexpected")
}
}
#[async_trait]
impl MovieRepository for RepoWithTitleMatch {
async fn get_movie_by_external_id(&self, _: &ExternalMetadataId) -> Result<Option<Movie>, DomainError> { panic!("unexpected") }
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> { panic!("unexpected") }
async fn get_movies_by_title_and_year(&self, _: &MovieTitle, _: &ReleaseYear) -> Result<Vec<Movie>, DomainError> { Ok(vec![self.0.clone()]) }
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
async fn get_movie_by_external_id(
&self,
_: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
panic!("unexpected")
}
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
panic!("unexpected")
}
async fn get_movies_by_title_and_year(
&self,
_: &MovieTitle,
_: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
Ok(vec![self.0.clone()])
}
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!("unexpected")
}
}
struct MetaReturnsMovie(Movie);
@@ -257,7 +308,9 @@ mod tests {
&self,
_: &MetadataSearchCriteria,
) -> Result<Movie, DomainError> {
Err(DomainError::InfrastructureError("metadata unavailable".into()))
Err(DomainError::InfrastructureError(
"metadata unavailable".into(),
))
}
async fn get_poster_url(
&self,

View File

@@ -1,6 +1,9 @@
use uuid::Uuid;
use domain::models::{DiaryEntry, FeedEntry, MonthActivity, UserStats, UserSummary, UserTrends, collections::Paginated};
use domain::models::{
DiaryEntry, FeedEntry, MonthActivity, UserStats, UserSummary, UserTrends,
collections::Paginated,
};
pub struct RemoteActorView {
pub handle: String,
@@ -85,7 +88,11 @@ pub struct FollowersPageData {
}
pub trait HtmlRenderer: Send + Sync {
fn render_diary_page(&self, data: &Paginated<DiaryEntry>, ctx: HtmlPageContext) -> Result<String, String>;
fn render_diary_page(
&self,
data: &Paginated<DiaryEntry>,
ctx: HtmlPageContext,
) -> Result<String, String>;
fn render_login_page(&self, data: LoginPageData<'_>) -> Result<String, String>;
fn render_register_page(&self, data: RegisterPageData<'_>) -> Result<String, String>;
fn render_new_review_page(&self, data: NewReviewPageData<'_>) -> Result<String, String>;

View File

@@ -1,5 +1,8 @@
use domain::{errors::DomainError, value_objects::{ReviewId, UserId}};
use crate::{commands::DeleteReviewCommand, context::AppContext};
use domain::{
errors::DomainError,
value_objects::{ReviewId, UserId},
};
pub async fn execute(ctx: &AppContext, cmd: DeleteReviewCommand) -> Result<(), DomainError> {
let review_id = ReviewId::from_uuid(cmd.review_id);

View File

@@ -1,23 +1,13 @@
use std::sync::Arc;
use domain::{errors::DomainError, value_objects::UserId};
use domain::{
errors::DomainError,
ports::{DiaryExporter, DiaryRepository},
};
use crate::{commands::ExportCommand, context::AppContext};
use crate::commands::ExportCommand;
pub struct ExportDiary {
repository: Arc<dyn DiaryRepository>,
exporter: Arc<dyn DiaryExporter>,
}
impl ExportDiary {
pub async fn execute(&self, req: ExportCommand) -> Result<Vec<u8>, DomainError> {
// 1. fetch all diary entries for the user
// 2. delegate serialization to the port (exporter)
// Return bytes of the exported diary, which can be written to a file or returned in an HTTP response
Ok(vec![])
}
pub async fn execute(ctx: &AppContext, cmd: ExportCommand) -> Result<Vec<u8>, DomainError> {
let entries = ctx
.diary_repository
.get_user_history(&UserId::from_uuid(cmd.user_id))
.await?;
ctx.diary_exporter
.serialize_entries(&entries, cmd.format)
.await
}

View File

@@ -1,8 +1,11 @@
use crate::{context::AppContext, queries::GetActivityFeedQuery};
use domain::{
errors::DomainError,
models::{FeedEntry, collections::{PageParams, Paginated}},
models::{
FeedEntry,
collections::{PageParams, Paginated},
},
};
use crate::{context::AppContext, queries::GetActivityFeedQuery};
pub async fn execute(
ctx: &AppContext,

View File

@@ -1,3 +1,7 @@
use crate::{
context::AppContext,
queries::{GetUserProfileQuery, ProfileView},
};
use chrono::Datelike;
use domain::{
errors::DomainError,
@@ -7,7 +11,6 @@ use domain::{
},
value_objects::UserId,
};
use crate::{context::AppContext, queries::{GetUserProfileQuery, ProfileView}};
pub struct UserProfileData {
pub stats: UserStats,
@@ -27,26 +30,61 @@ pub async fn execute(
ProfileView::History => {
let all_entries = ctx.diary_repository.get_user_history(&user_id).await?;
let history = group_by_month(all_entries);
Ok(UserProfileData { stats, entries: None, history: Some(history), trends: None })
Ok(UserProfileData {
stats,
entries: None,
history: Some(history),
trends: None,
})
}
ProfileView::Trends => {
let trends = ctx.stats_repository.get_user_trends(&user_id).await?;
Ok(UserProfileData { stats, entries: None, history: None, trends: Some(trends) })
Ok(UserProfileData {
stats,
entries: None,
history: None,
trends: Some(trends),
})
}
ProfileView::Ratings => {
let filter = paged_user_filter(user_id, SortDirection::ByRatingDesc, query.limit, query.offset)?;
let filter = paged_user_filter(
user_id,
SortDirection::ByRatingDesc,
query.limit,
query.offset,
)?;
let entries = ctx.diary_repository.query_diary(&filter).await?;
Ok(UserProfileData { stats, entries: Some(entries), history: None, trends: None })
Ok(UserProfileData {
stats,
entries: Some(entries),
history: None,
trends: None,
})
}
ProfileView::Recent => {
let filter = paged_user_filter(user_id, SortDirection::Descending, query.limit, query.offset)?;
let filter = paged_user_filter(
user_id,
SortDirection::Descending,
query.limit,
query.offset,
)?;
let entries = ctx.diary_repository.query_diary(&filter).await?;
Ok(UserProfileData { stats, entries: Some(entries), history: None, trends: None })
Ok(UserProfileData {
stats,
entries: Some(entries),
history: None,
trends: None,
})
}
}
}
fn paged_user_filter(user_id: UserId, sort_by: SortDirection, limit: Option<u32>, offset: Option<u32>) -> Result<DiaryFilter, DomainError> {
fn paged_user_filter(
user_id: UserId,
sort_by: SortDirection,
limit: Option<u32>,
offset: Option<u32>,
) -> Result<DiaryFilter, DomainError> {
let page = PageParams::new(limit, offset)?;
Ok(DiaryFilter {
sort_by,
@@ -81,11 +119,22 @@ fn group_by_month(entries: Vec<DiaryEntry>) -> Vec<MonthActivity> {
fn format_year_month_long(ym: &str) -> String {
let parts: Vec<&str> = ym.splitn(2, '-').collect();
if parts.len() != 2 { return ym.to_string(); }
if parts.len() != 2 {
return ym.to_string();
}
let month = match parts[1] {
"01" => "January", "02" => "February", "03" => "March", "04" => "April",
"05" => "May", "06" => "June", "07" => "July", "08" => "August",
"09" => "September", "10" => "October", "11" => "November", "12" => "December",
"01" => "January",
"02" => "February",
"03" => "March",
"04" => "April",
"05" => "May",
"06" => "June",
"07" => "July",
"08" => "August",
"09" => "September",
"10" => "October",
"11" => "November",
"12" => "December",
_ => parts[1],
};
format!("{} {}", month, parts[0])

View File

@@ -1,5 +1,5 @@
use domain::{errors::DomainError, models::UserSummary};
use crate::{context::AppContext, queries::GetUsersQuery};
use domain::{errors::DomainError, models::UserSummary};
pub async fn execute(
ctx: &AppContext,

View File

@@ -20,7 +20,9 @@ pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), Doma
repository: ctx.movie_repository.as_ref(),
metadata_client: ctx.metadata_client.as_ref(),
};
let (movie, is_new_movie) = MovieResolver::default_pipeline().resolve(&cmd, &deps).await?;
let (movie, is_new_movie) = MovieResolver::default_pipeline()
.resolve(&cmd, &deps)
.await?;
ctx.movie_repository.upsert_movie(&movie).await?;

View File

@@ -1,4 +1,8 @@
use domain::{errors::DomainError, models::User, value_objects::{Email, Username}};
use domain::{
errors::DomainError,
models::User,
value_objects::{Email, Username},
};
use crate::{commands::RegisterCommand, context::AppContext};
@@ -19,13 +23,24 @@ pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> Result<(), Domai
let username = Username::new(cmd.username)?;
if ctx.user_repository.find_by_email(&email).await?.is_some() {
return Err(DomainError::ValidationError("Email already registered".into()));
return Err(DomainError::ValidationError(
"Email already registered".into(),
));
}
if ctx.user_repository.find_by_username(&username).await?.is_some() {
return Err(DomainError::ValidationError("Username already taken".into()));
if ctx
.user_repository
.find_by_username(&username)
.await?
.is_some()
{
return Err(DomainError::ValidationError(
"Username already taken".into(),
));
}
let hash = ctx.password_hasher.hash(&cmd.password).await?;
ctx.user_repository.save(&User::new(email, username, hash)).await
ctx.user_repository
.save(&User::new(email, username, hash))
.await
}