diff --git a/crates/adapters/postgres/src/lib.rs b/crates/adapters/postgres/src/lib.rs index 1892607..01dc951 100644 --- a/crates/adapters/postgres/src/lib.rs +++ b/crates/adapters/postgres/src/lib.rs @@ -940,21 +940,21 @@ pub fn create_profile_fields_repo( std::sync::Arc::new(profile_fields::PostgresProfileFieldsRepository::new(pool)) } -pub async fn wire( - database_url: &str, -) -> anyhow::Result<( - sqlx::PgPool, - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, -)> { +pub struct PostgresWireOutput { + pub pool: PgPool, + pub movie: std::sync::Arc, + pub review: std::sync::Arc, + pub diary: std::sync::Arc, + pub stats: std::sync::Arc, + pub user: std::sync::Arc, + pub import_session: std::sync::Arc, + pub import_profile: std::sync::Arc, + pub movie_profile: std::sync::Arc, + pub watchlist: std::sync::Arc, + pub ap_content: std::sync::Arc, +} + +pub async fn wire(database_url: &str) -> anyhow::Result { use anyhow::Context; let pool = sqlx::PgPool::connect(database_url) @@ -967,25 +967,19 @@ pub async fn wire( .map_err(|e| anyhow::anyhow!("{e}")) .context("Database migration failed")?; - let import_session_repo = - std::sync::Arc::new(PostgresImportSessionRepository::new(pool.clone())); - let import_profile_repo = - std::sync::Arc::new(PostgresImportProfileRepository::new(pool.clone())); - let movie_profile_repo = std::sync::Arc::new(PostgresMovieProfileRepository::new(pool.clone())); - let watchlist_repo = std::sync::Arc::new(PostgresWatchlistRepository::new(pool.clone())); - let ap_content = std::sync::Arc::new(PostgresApContentQuery::new(pool.clone())); - - Ok(( - pool.clone(), - std::sync::Arc::clone(&repo) as _, - std::sync::Arc::clone(&repo) as _, - std::sync::Arc::clone(&repo) as _, - std::sync::Arc::clone(&repo) as _, - std::sync::Arc::new(PostgresUserRepository::new(pool)) as _, - import_session_repo as _, - import_profile_repo as _, - movie_profile_repo as _, - watchlist_repo as _, - ap_content as _, - )) + Ok(PostgresWireOutput { + pool: pool.clone(), + movie: std::sync::Arc::clone(&repo) as _, + review: std::sync::Arc::clone(&repo) as _, + diary: std::sync::Arc::clone(&repo) as _, + stats: std::sync::Arc::clone(&repo) as _, + user: std::sync::Arc::new(PostgresUserRepository::new(pool.clone())) as _, + import_session: std::sync::Arc::new(PostgresImportSessionRepository::new(pool.clone())) + as _, + import_profile: std::sync::Arc::new(PostgresImportProfileRepository::new(pool.clone())) + as _, + movie_profile: std::sync::Arc::new(PostgresMovieProfileRepository::new(pool.clone())) as _, + watchlist: std::sync::Arc::new(PostgresWatchlistRepository::new(pool.clone())) as _, + ap_content: std::sync::Arc::new(PostgresApContentQuery::new(pool)) as _, + }) } diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index a5a04c3..de7f104 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -935,21 +935,21 @@ impl StatsRepository for SqliteMovieRepository { } } -pub async fn wire( - database_url: &str, -) -> anyhow::Result<( - sqlx::SqlitePool, - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, -)> { +pub struct SqliteWireOutput { + pub pool: SqlitePool, + pub movie: std::sync::Arc, + pub review: std::sync::Arc, + pub diary: std::sync::Arc, + pub stats: std::sync::Arc, + pub user: std::sync::Arc, + pub import_session: std::sync::Arc, + pub import_profile: std::sync::Arc, + pub movie_profile: std::sync::Arc, + pub watchlist: std::sync::Arc, + pub ap_content: std::sync::Arc, +} + +pub async fn wire(database_url: &str) -> anyhow::Result { use anyhow::Context; use sqlx::sqlite::SqliteConnectOptions; use std::str::FromStr; @@ -969,25 +969,19 @@ pub async fn wire( .map_err(|e| anyhow::anyhow!("{e}")) .context("Database migration failed")?; - let import_session_repo = std::sync::Arc::new(SqliteImportSessionRepository::new(pool.clone())); - let import_profile_repo = std::sync::Arc::new(SqliteImportProfileRepository::new(pool.clone())); - let movie_profile_repo = std::sync::Arc::new(SqliteMovieProfileRepository::new(pool.clone())); - let watchlist_repo = std::sync::Arc::new(SqliteWatchlistRepository::new(pool.clone())); - let ap_content = std::sync::Arc::new(SqliteApContentQuery::new(pool.clone())); - - Ok(( - pool.clone(), - std::sync::Arc::clone(&repo) as _, - std::sync::Arc::clone(&repo) as _, - std::sync::Arc::clone(&repo) as _, - std::sync::Arc::clone(&repo) as _, - std::sync::Arc::new(SqliteUserRepository::new(pool)) as _, - import_session_repo as _, - import_profile_repo as _, - movie_profile_repo as _, - watchlist_repo as _, - ap_content as _, - )) + Ok(SqliteWireOutput { + pool: pool.clone(), + movie: std::sync::Arc::clone(&repo) as _, + review: std::sync::Arc::clone(&repo) as _, + diary: std::sync::Arc::clone(&repo) as _, + stats: std::sync::Arc::clone(&repo) as _, + user: std::sync::Arc::new(SqliteUserRepository::new(pool.clone())) as _, + import_session: std::sync::Arc::new(SqliteImportSessionRepository::new(pool.clone())) as _, + import_profile: std::sync::Arc::new(SqliteImportProfileRepository::new(pool.clone())) as _, + movie_profile: std::sync::Arc::new(SqliteMovieProfileRepository::new(pool.clone())) as _, + watchlist: std::sync::Arc::new(SqliteWatchlistRepository::new(pool.clone())) as _, + ap_content: std::sync::Arc::new(SqliteApContentQuery::new(pool)) as _, + }) } #[cfg(test)] diff --git a/crates/adapters/template-askama/src/lib.rs b/crates/adapters/template-askama/src/lib.rs index 3eafd51..8e223a8 100644 --- a/crates/adapters/template-askama/src/lib.rs +++ b/crates/adapters/template-askama/src/lib.rs @@ -1,12 +1,7 @@ -use application::ports::{ - ActivityFeedPageData, BlockedActorEntry, BlockedActorsPageData, BlockedDomainEntry, - BlockedDomainsPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer, - ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView, - ImportRowStatus, ImportUploadPageData, IntegrationsPageData, LoginPageData, - MovieDetailPageData, NewReviewPageData, ProfilePageData, ProfileSettingsPageData, - RegisterPageData, UsersPageData, WatchQueuePageData, WatchlistPageData, WebhookTokenView, -}; +pub use askama; use askama::Template; + +use application::ports::HtmlPageContext; use chrono::Datelike; use domain::models::{ DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserTrends, @@ -28,13 +23,13 @@ mod filters { } } -struct PageItem { - number: u32, - is_current: bool, - is_ellipsis: bool, +pub struct PageItem { + pub number: u32, + pub is_current: bool, + pub is_ellipsis: bool, } -fn build_page_items(total_pages: u32, current_page: u32) -> Vec { +pub fn build_page_items(total_pages: u32, current_page: u32) -> Vec { if total_pages <= 1 { return vec![]; } @@ -67,45 +62,45 @@ fn build_page_items(total_pages: u32, current_page: u32) -> Vec { #[derive(Template)] #[template(path = "diary.html")] -struct DiaryTemplate<'a> { - entries: &'a [DiaryEntry], - current_offset: u32, - limit: u32, - has_more: bool, - ctx: &'a HtmlPageContext, - page_items: Vec, +pub struct DiaryTemplate<'a> { + pub entries: &'a [DiaryEntry], + pub current_offset: u32, + pub limit: u32, + pub has_more: bool, + pub ctx: &'a HtmlPageContext, + pub page_items: Vec, } #[derive(Template)] #[template(path = "login.html")] -struct LoginTemplate<'a> { - error: Option<&'a str>, - ctx: &'a HtmlPageContext, +pub struct LoginTemplate<'a> { + pub error: Option<&'a str>, + pub ctx: &'a HtmlPageContext, } #[derive(Template)] #[template(path = "register.html")] -struct RegisterTemplate<'a> { - error: Option<&'a str>, - ctx: &'a HtmlPageContext, +pub struct RegisterTemplate<'a> { + pub error: Option<&'a str>, + pub ctx: &'a HtmlPageContext, } #[derive(Template)] #[template(path = "new_review.html")] -struct NewReviewTemplate<'a> { - error: Option<&'a str>, - ctx: &'a HtmlPageContext, +pub struct NewReviewTemplate<'a> { + pub error: Option<&'a str>, + pub ctx: &'a HtmlPageContext, } #[derive(Template)] #[template(path = "activity_feed.html")] -struct ActivityFeedTemplate<'a> { - entries: &'a [FeedEntry], - current_offset: u32, - limit: u32, - has_more: bool, - ctx: &'a HtmlPageContext, - page_items: Vec, +pub struct ActivityFeedTemplate<'a> { + pub entries: &'a [FeedEntry], + pub current_offset: u32, + pub limit: u32, + pub has_more: bool, + pub ctx: &'a HtmlPageContext, + pub page_items: Vec, pub filter: String, pub sort_by: String, pub search: String, @@ -113,30 +108,30 @@ struct ActivityFeedTemplate<'a> { #[derive(Template)] #[template(path = "movie_detail.html")] -struct MovieDetailTemplate<'a> { - ctx: &'a HtmlPageContext, - movie: &'a domain::models::Movie, - stats: &'a domain::models::MovieStats, - profile: Option<&'a domain::models::MovieProfile>, - reviews: &'a [domain::models::FeedEntry], - on_watchlist: bool, - current_offset: u32, - has_more: bool, - limit: u32, - histogram_max: u64, +pub struct MovieDetailTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub movie: &'a domain::models::Movie, + pub stats: &'a domain::models::MovieStats, + pub profile: Option<&'a domain::models::MovieProfile>, + pub reviews: &'a [domain::models::FeedEntry], + pub on_watchlist: bool, + pub current_offset: u32, + pub has_more: bool, + pub limit: u32, + pub histogram_max: u64, } #[derive(Template)] #[template(path = "watchlist.html")] -struct WatchlistTemplate<'a> { - ctx: &'a HtmlPageContext, - owner_id: uuid::Uuid, - display_entries: &'a [application::ports::WatchlistDisplayEntry], - current_offset: u32, - has_more: bool, - limit: u32, - is_owner: bool, - error: Option, +pub struct WatchlistTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub owner_id: uuid::Uuid, + pub display_entries: &'a [application::ports::WatchlistDisplayEntry], + pub current_offset: u32, + pub has_more: bool, + pub limit: u32, + pub is_owner: bool, + pub error: Option, } impl<'a> ActivityFeedTemplate<'a> { @@ -165,53 +160,53 @@ pub struct RemoteActorDisplay { pub url: String, } -struct UserSummaryView { - user_id: uuid::Uuid, - display_name: String, - initial: char, - avg_rating_display: String, - total_movies: i64, - avatar_url: Option, +pub struct UserSummaryView { + pub user_id: uuid::Uuid, + pub display_name: String, + pub initial: char, + pub avg_rating_display: String, + pub total_movies: i64, + pub avatar_url: Option, } #[derive(Template)] #[template(path = "users.html")] -struct UsersTemplate<'a> { - users: Vec, - ctx: &'a HtmlPageContext, - remote_actors: Vec, +pub struct UsersTemplate<'a> { + pub users: Vec, + pub ctx: &'a HtmlPageContext, + pub remote_actors: Vec, } -struct MonthlyRatingRow<'a> { - rating: &'a MonthlyRating, - bar_height_px: i64, +pub struct MonthlyRatingRow<'a> { + pub rating: &'a MonthlyRating, + pub bar_height_px: i64, } #[derive(Template)] #[template(path = "profile.html")] -struct ProfileTemplate<'a> { - ctx: &'a HtmlPageContext, - profile_display_name: String, - profile_user_id: uuid::Uuid, - stats: &'a UserStats, - avg_rating_display: String, - favorite_director_display: String, - most_active_month_display: String, - view: &'a str, - entries: Option<&'a Paginated>, - current_offset: u32, - has_more: bool, - limit: u32, - history: Option<&'a Vec>, - trends: Option<&'a UserTrends>, - monthly_rating_rows: Vec>, - heatmap: Vec, - page_items: Vec, - is_own_profile: bool, - error: Option, - following_count: usize, - followers_count: usize, - pending_followers: Vec, +pub struct ProfileTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub profile_display_name: String, + pub profile_user_id: uuid::Uuid, + pub stats: &'a UserStats, + pub avg_rating_display: String, + pub favorite_director_display: String, + pub most_active_month_display: String, + pub view: &'a str, + pub entries: Option<&'a Paginated>, + pub current_offset: u32, + pub has_more: bool, + pub limit: u32, + pub history: Option<&'a Vec>, + pub trends: Option<&'a UserTrends>, + pub monthly_rating_rows: Vec>, + pub heatmap: Vec, + pub page_items: Vec, + pub is_own_profile: bool, + pub error: Option, + pub following_count: usize, + pub followers_count: usize, + pub pending_followers: Vec, pub sort_by: String, pub search: String, } @@ -235,80 +230,65 @@ impl<'a> ProfileTemplate<'a> { } } -struct RemoteActorData { - handle: String, - display_name: Option, - url: String, - avatar_url: Option, +pub struct RemoteActorData { + pub handle: String, + pub display_name: Option, + pub url: String, + pub avatar_url: Option, } #[derive(Template)] #[template(path = "following.html")] -struct FollowingTemplate { - ctx: HtmlPageContext, - user_id: uuid::Uuid, - actors: Vec, - error: Option, +pub struct FollowingTemplate { + pub ctx: HtmlPageContext, + pub user_id: uuid::Uuid, + pub actors: Vec, + pub error: Option, } #[derive(Template)] #[template(path = "followers.html")] -struct FollowersTemplate { - ctx: HtmlPageContext, - user_id: uuid::Uuid, - actors: Vec, - error: Option, +pub struct FollowersTemplate { + pub ctx: HtmlPageContext, + pub user_id: uuid::Uuid, + pub actors: Vec, + pub error: Option, } #[derive(Template)] #[template(path = "blocked_domains.html")] -struct BlockedDomainsTemplate<'a> { - ctx: &'a HtmlPageContext, - domains: &'a [BlockedDomainEntry], +pub struct BlockedDomainsTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub domains: &'a [BlockedDomainEntry], } #[derive(Template)] #[template(path = "blocked_actors.html")] -struct BlockedActorsTemplate<'a> { - ctx: &'a HtmlPageContext, - actors: &'a [BlockedActorEntry], +pub struct BlockedActorsTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub actors: &'a [BlockedActorEntry], } -struct HeatmapCell { - month_label: String, - count: i64, - alpha: f64, +pub struct BlockedDomainEntry { + pub domain: String, + pub reason: Option, + pub blocked_at: String, } -#[allow(dead_code)] -fn relative_time(dt: chrono::NaiveDateTime) -> String { - let now = chrono::Utc::now().naive_utc(); - let diff = now.signed_duration_since(dt); - if diff.num_seconds() <= 0 { - return "just now".to_string(); - } - let minutes = diff.num_minutes(); - let hours = diff.num_hours(); - let days = diff.num_days(); - if minutes < 1 { - return "just now".to_string(); - } - if minutes < 60 { - return format!("{} min ago", minutes); - } - if hours < 24 { - return format!("{} h ago", hours); - } - if days == 1 { - return "yesterday".to_string(); - } - if days < 30 { - return format!("{} days ago", days); - } - dt.format("%b %-d, %Y").to_string() +pub struct BlockedActorEntry { + pub url: String, + pub handle: String, + pub display_name: Option, + pub avatar_url: Option, } -fn build_heatmap(history: &[MonthActivity]) -> Vec { +pub struct HeatmapCell { + pub month_label: String, + pub count: i64, + pub alpha: f64, +} + +pub fn build_heatmap(history: &[MonthActivity]) -> Vec { let current_year = chrono::Utc::now().year(); let count_for = |m: &str| -> i64 { history @@ -351,442 +331,97 @@ fn build_heatmap(history: &[MonthActivity]) -> Vec { .collect() } -fn bar_height_px(avg_rating: f64) -> i64 { +pub fn bar_height_px(avg_rating: f64) -> i64 { (avg_rating / 5.0 * 60.0) as i64 } #[derive(Template)] #[template(path = "profile_settings.html")] -struct ProfileSettingsTemplate<'a> { - ctx: &'a HtmlPageContext, - bio: Option<&'a str>, - avatar_url: Option<&'a str>, - banner_url: Option<&'a str>, - also_known_as: Option<&'a str>, - profile_fields: &'a [(String, String)], - saved: bool, +pub struct ProfileSettingsTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub bio: Option<&'a str>, + pub avatar_url: Option<&'a str>, + pub banner_url: Option<&'a str>, + pub also_known_as: Option<&'a str>, + pub profile_fields: &'a [(String, String)], + pub saved: bool, } #[derive(Template)] #[template(path = "integrations.html")] -struct IntegrationsTemplate<'a> { - ctx: &'a HtmlPageContext, - tokens: &'a [WebhookTokenView], - webhook_base_url: &'a str, - new_token: Option<&'a str>, +pub struct IntegrationsTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub tokens: &'a [WebhookTokenView], + pub webhook_base_url: &'a str, + pub new_token: Option<&'a str>, +} + +pub struct WebhookTokenView { + pub id: String, + pub provider: String, + pub label: Option, + pub created_at: String, + pub last_used_at: Option, } #[derive(Template)] #[template(path = "watch_queue.html")] -struct WatchQueueTemplate<'a> { - ctx: &'a HtmlPageContext, - entries: &'a [application::ports::WatchQueueDisplayEntry], - error: Option<&'a str>, +pub struct WatchQueueTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub entries: &'a [WatchQueueDisplayEntry], + pub error: Option<&'a str>, +} + +pub struct WatchQueueDisplayEntry { + pub id: String, + pub title: String, + pub year: Option, + pub source: String, + pub watched_at: String, + pub movie_url: Option, } #[derive(Template)] #[template(path = "import_upload.html")] -struct ImportUploadTemplate<'a> { - ctx: &'a HtmlPageContext, - profiles: &'a [ImportProfileView], - error: Option<&'a str>, +pub struct ImportUploadTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub profiles: &'a [ImportProfileView], + pub error: Option<&'a str>, +} + +pub struct ImportProfileView { + pub id: String, + pub name: String, } #[derive(Template)] #[template(path = "import_mapping.html")] -struct ImportMappingTemplate<'a> { - ctx: &'a HtmlPageContext, - session_id: &'a str, - columns: &'a [String], - sample_rows: &'a [Vec], - domain_fields: &'a [(&'static str, &'static str)], - error: Option<&'a str>, +pub struct ImportMappingTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub session_id: &'a str, + pub columns: &'a [String], + pub sample_rows: &'a [Vec], + pub domain_fields: &'a [(&'static str, &'static str)], + pub error: Option<&'a str>, } #[derive(Template)] #[template(path = "import_preview.html")] -struct ImportPreviewTemplate<'a> { - ctx: &'a HtmlPageContext, - session_id: &'a str, - columns: &'a [String], - rows: &'a [ImportPreviewRow], +pub struct ImportPreviewTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub session_id: &'a str, + pub columns: &'a [String], + pub rows: &'a [ImportPreviewRow], } -#[derive(Default)] -pub struct AskamaHtmlRenderer; - -impl AskamaHtmlRenderer { - pub fn new() -> Self { - Self {} - } +pub struct ImportPreviewRow { + pub index: usize, + pub status: ImportRowStatus, + pub cells: Vec, } -impl HtmlRenderer for AskamaHtmlRenderer { - fn render_diary_page( - &self, - data: &Paginated, - ctx: HtmlPageContext, - ) -> Result { - let has_more = (data.offset + data.limit) < data.total_count as u32; - let (total_pages, current_page) = if data.limit > 0 { - let tp = data.total_count.div_ceil(data.limit as u64) as u32; - (tp, data.offset / data.limit) - } else { - (0, 0) - }; - DiaryTemplate { - entries: &data.items, - current_offset: data.offset, - limit: data.limit, - has_more, - ctx: &ctx, - page_items: build_page_items(total_pages, current_page), - } - .render() - .map_err(|e| e.to_string()) - } - - fn render_login_page(&self, data: LoginPageData<'_>) -> Result { - LoginTemplate { - error: data.error, - ctx: &data.ctx, - } - .render() - .map_err(|e| e.to_string()) - } - - fn render_register_page(&self, data: RegisterPageData<'_>) -> Result { - RegisterTemplate { - error: data.error, - ctx: &data.ctx, - } - .render() - .map_err(|e| e.to_string()) - } - - fn render_new_review_page(&self, data: NewReviewPageData<'_>) -> Result { - NewReviewTemplate { - error: data.error, - ctx: &data.ctx, - } - .render() - .map_err(|e| e.to_string()) - } - - fn render_activity_feed_page(&self, data: ActivityFeedPageData) -> Result { - let limit = data.limit; - let total_pages = data.entries.total_count.div_ceil(limit.max(1) as u64) as u32; - let current_page = data.current_offset.checked_div(limit).unwrap_or(0); - ActivityFeedTemplate { - entries: &data.entries.items, - current_offset: data.current_offset, - limit, - has_more: data.has_more, - ctx: &data.ctx, - page_items: build_page_items(total_pages, current_page), - filter: data.filter, - sort_by: data.sort_by, - search: data.search, - } - .render() - .map_err(|e| e.to_string()) - } - - fn render_users_page(&self, data: UsersPageData) -> Result { - let users: Vec = data - .users - .iter() - .map(|u| { - let email = u.email(); - let display_name = email.split('@').next().unwrap_or(email).to_string(); - let initial = display_name - .chars() - .next() - .unwrap_or('?') - .to_ascii_uppercase(); - let avg_rating_display = u - .avg_rating - .map(|r| format!("{:.1}", r)) - .unwrap_or_else(|| "—".to_string()); - UserSummaryView { - user_id: u.user_id.value(), - display_name, - initial, - avg_rating_display, - total_movies: u.total_movies, - avatar_url: u.avatar_path.as_ref().map(|p| format!("/images/{}", p)), - } - }) - .collect(); - let remote_actors = data - .remote_actors - .into_iter() - .map(|a| { - let name = a.display_name.unwrap_or_else(|| a.handle.clone()); - let initial = name.chars().next().unwrap_or('?'); - RemoteActorDisplay { - display_name: name, - initial, - handle: a.handle, - url: a.url, - } - }) - .collect(); - UsersTemplate { - users, - ctx: &data.ctx, - remote_actors, - } - .render() - .map_err(|e| e.to_string()) - } - - fn render_profile_page(&self, data: ProfilePageData) -> Result { - let heatmap = data - .history - .as_deref() - .map(build_heatmap) - .unwrap_or_default(); - let profile_display_name = data - .profile_user_email - .split('@') - .next() - .unwrap_or(&data.profile_user_email) - .to_string(); - let monthly_rating_rows: Vec> = data - .trends - .as_ref() - .map(|t| { - t.monthly_ratings - .iter() - .map(|r| MonthlyRatingRow { - bar_height_px: bar_height_px(r.avg_rating), - rating: r, - }) - .collect() - }) - .unwrap_or_default(); - let total_pages = data - .entries - .as_ref() - .map(|e| e.total_count.div_ceil(e.limit.max(1) as u64) as u32) - .unwrap_or(0); - let current_page = data.current_offset.checked_div(data.limit).unwrap_or(0); - let avg_rating_display = data - .stats - .avg_rating - .map(|r| format!("{:.1}", r)) - .unwrap_or_else(|| "—".to_string()); - let favorite_director_display = data - .stats - .favorite_director - .as_deref() - .unwrap_or("—") - .to_string(); - let most_active_month_display = data - .stats - .most_active_month - .as_deref() - .unwrap_or("—") - .to_string(); - ProfileTemplate { - ctx: &data.ctx, - profile_display_name, - profile_user_id: data.profile_user_id, - stats: &data.stats, - avg_rating_display, - favorite_director_display, - most_active_month_display, - view: &data.view, - entries: data.entries.as_ref(), - current_offset: data.current_offset, - has_more: data.has_more, - limit: data.limit, - history: data.history.as_ref(), - trends: data.trends.as_ref(), - monthly_rating_rows, - heatmap, - page_items: build_page_items(total_pages, current_page), - is_own_profile: data.is_own_profile, - error: data.error, - following_count: data.following_count, - followers_count: data.followers_count, - pending_followers: data - .pending_followers - .into_iter() - .map(|a| RemoteActorData { - handle: a.handle, - url: a.url, - display_name: a.display_name, - avatar_url: a.avatar_url, - }) - .collect(), - sort_by: data.sort_by.clone(), - search: data.search.clone(), - } - .render() - .map_err(|e| e.to_string()) - } - - fn render_movie_detail_page(&self, data: MovieDetailPageData) -> Result { - MovieDetailTemplate { - ctx: &data.ctx, - movie: &data.movie, - stats: &data.stats, - profile: data.profile.as_ref(), - reviews: &data.reviews.items, - on_watchlist: data.on_watchlist, - current_offset: data.current_offset, - has_more: data.has_more, - limit: data.limit, - histogram_max: data.histogram_max, - } - .render() - .map_err(|e| e.to_string()) - } - - fn render_watchlist_page(&self, data: WatchlistPageData) -> Result { - WatchlistTemplate { - ctx: &data.ctx, - owner_id: data.owner_id, - display_entries: &data.display_entries, - current_offset: data.current_offset, - has_more: data.has_more, - limit: data.limit, - is_owner: data.is_owner, - error: data.error, - } - .render() - .map_err(|e| e.to_string()) - } - - fn render_following_page(&self, data: FollowingPageData) -> Result { - FollowingTemplate { - ctx: data.ctx, - user_id: data.user_id, - actors: data - .actors - .into_iter() - .map(|a| RemoteActorData { - handle: a.handle, - display_name: a.display_name, - url: a.url, - avatar_url: a.avatar_url, - }) - .collect(), - error: data.error, - } - .render() - .map_err(|e| e.to_string()) - } - - fn render_followers_page(&self, data: FollowersPageData) -> Result { - FollowersTemplate { - ctx: data.ctx, - user_id: data.user_id, - actors: data - .actors - .into_iter() - .map(|a| RemoteActorData { - handle: a.handle, - display_name: a.display_name, - url: a.url, - avatar_url: a.avatar_url, - }) - .collect(), - error: data.error, - } - .render() - .map_err(|e| e.to_string()) - } - - fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result { - ImportUploadTemplate { - ctx: &data.ctx, - profiles: &data.profiles, - error: data.error.as_deref(), - } - .render() - .map_err(|e| e.to_string()) - } - - fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result { - ImportMappingTemplate { - ctx: &data.ctx, - session_id: &data.session_id, - columns: &data.columns, - sample_rows: &data.sample_rows, - domain_fields: &data.domain_fields, - error: data.error.as_deref(), - } - .render() - .map_err(|e| e.to_string()) - } - - fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result { - ImportPreviewTemplate { - ctx: &data.ctx, - session_id: &data.session_id, - columns: &data.columns, - rows: &data.rows, - } - .render() - .map_err(|e| e.to_string()) - } - - fn render_profile_settings_page( - &self, - data: ProfileSettingsPageData, - ) -> Result { - ProfileSettingsTemplate { - ctx: &data.ctx, - bio: data.bio.as_deref(), - avatar_url: data.avatar_url.as_deref(), - banner_url: data.banner_url.as_deref(), - also_known_as: data.also_known_as.as_deref(), - profile_fields: &data.profile_fields, - saved: data.saved, - } - .render() - .map_err(|e| e.to_string()) - } - - fn render_blocked_domains_page(&self, data: BlockedDomainsPageData) -> Result { - BlockedDomainsTemplate { - ctx: &data.ctx, - domains: &data.domains, - } - .render() - .map_err(|e| e.to_string()) - } - - fn render_blocked_actors_page(&self, data: BlockedActorsPageData) -> Result { - BlockedActorsTemplate { - ctx: &data.ctx, - actors: &data.actors, - } - .render() - .map_err(|e| e.to_string()) - } - - fn render_integrations_page(&self, data: IntegrationsPageData) -> Result { - IntegrationsTemplate { - ctx: &data.ctx, - tokens: &data.tokens, - webhook_base_url: &data.webhook_base_url, - new_token: data.new_token.as_deref(), - } - .render() - .map_err(|e| e.to_string()) - } - - fn render_watch_queue_page(&self, data: WatchQueuePageData) -> Result { - WatchQueueTemplate { - ctx: &data.ctx, - entries: &data.entries, - error: data.error.as_deref(), - } - .render() - .map_err(|e| e.to_string()) - } +pub enum ImportRowStatus { + Valid, + Duplicate, + Invalid(String), } diff --git a/crates/application/src/context.rs b/crates/application/src/context.rs index 4885d6c..7dc4a51 100644 --- a/crates/application/src/context.rs +++ b/crates/application/src/context.rs @@ -13,34 +13,44 @@ use domain::ports::{RemoteWatchlistRepository, SocialQueryPort}; use crate::config::AppConfig; #[derive(Clone)] -pub struct AppContext { - pub movie_repository: Arc, - pub review_repository: Arc, - pub diary_repository: Arc, - pub diary_exporter: Arc, - pub document_parser: Arc, - pub stats_repository: Arc, - pub metadata_client: Arc, - pub poster_fetcher: Arc, - pub image_storage: Arc, - pub event_publisher: Arc, - pub auth_service: Arc, - pub password_hasher: Arc, - pub user_repository: Arc, - pub import_session_repository: Arc, - pub import_profile_repository: Arc, - pub movie_profile_repository: Arc, +pub struct Repositories { + pub movie: Arc, + pub review: Arc, + pub diary: Arc, + pub stats: Arc, + pub user: Arc, + pub import_session: Arc, + pub import_profile: Arc, + pub movie_profile: Arc, + pub watchlist: Arc, + pub watch_event: Arc, + pub webhook_token: Arc, pub person_command: Arc, pub person_query: Arc, pub search_port: Arc, pub search_command: Arc, - pub watchlist_repository: Arc, - pub watch_event_repository: Arc, - pub webhook_token_repository: Arc, - pub profile_fields_repository: Arc, + pub profile_fields: Arc, #[cfg(feature = "federation")] - pub remote_watchlist_repository: Arc, + pub remote_watchlist: Arc, #[cfg(feature = "federation")] pub social_query: Arc, +} + +#[derive(Clone)] +pub struct Services { + pub auth: Arc, + pub password_hasher: Arc, + pub metadata: Arc, + pub poster_fetcher: Arc, + pub image_storage: Arc, + pub event_publisher: Arc, + pub diary_exporter: Arc, + pub document_parser: Arc, +} + +#[derive(Clone)] +pub struct AppContext { + pub repos: Repositories, + pub services: Services, pub config: AppConfig, } diff --git a/crates/application/src/ports.rs b/crates/application/src/ports.rs index e107db9..aed3f40 100644 --- a/crates/application/src/ports.rs +++ b/crates/application/src/ports.rs @@ -1,16 +1,6 @@ use uuid::Uuid; -use domain::models::{ - DiaryEntry, FeedEntry, MonthActivity, Movie, MovieProfile, MovieStats, UserStats, UserSummary, - UserTrends, collections::Paginated, -}; - -pub struct RemoteActorView { - pub handle: String, - pub display_name: Option, - pub url: String, - pub avatar_url: Option, -} +use domain::models::DiaryEntry; pub struct HtmlPageContext { pub user_email: Option, @@ -30,239 +20,16 @@ impl HtmlPageContext { } } -pub struct LoginPageData<'a> { - pub ctx: HtmlPageContext, - pub error: Option<&'a str>, -} - -pub struct RegisterPageData<'a> { - pub ctx: HtmlPageContext, - pub error: Option<&'a str>, -} - -pub struct NewReviewPageData<'a> { - pub ctx: HtmlPageContext, - pub error: Option<&'a str>, -} - -pub struct ActivityFeedPageData { - pub ctx: HtmlPageContext, - pub entries: Paginated, - pub current_offset: u32, - pub has_more: bool, - pub limit: u32, - pub filter: String, - pub sort_by: String, - pub search: String, -} - -pub struct UsersPageData { - pub ctx: HtmlPageContext, - pub users: Vec, - pub remote_actors: Vec, -} - -pub struct ProfilePageData { - pub ctx: HtmlPageContext, - pub profile_user_id: Uuid, - pub profile_user_email: String, - pub stats: UserStats, - pub view: String, - pub entries: Option>, - pub current_offset: u32, - pub has_more: bool, - pub limit: u32, - pub history: Option>, - pub trends: Option, - pub is_own_profile: bool, - pub error: Option, - pub following_count: usize, - pub followers_count: usize, - pub pending_followers: Vec, - pub sort_by: String, - pub search: String, -} - -pub struct FollowingPageData { - pub ctx: HtmlPageContext, - pub user_id: Uuid, - pub actors: Vec, - pub error: Option, -} - -pub struct FollowersPageData { - pub ctx: HtmlPageContext, - pub user_id: Uuid, - pub actors: Vec, - pub error: Option, -} - -pub struct MovieDetailPageData { - pub ctx: HtmlPageContext, - pub movie: Movie, - pub stats: MovieStats, - pub reviews: Paginated, - pub profile: Option, - pub on_watchlist: bool, - pub current_offset: u32, - pub has_more: bool, - pub limit: u32, - pub histogram_max: u64, -} - #[derive(Clone, Debug)] pub struct WatchlistDisplayEntry { - /// Always a full URL: /images/{path} for local, https://... for remote pub poster_url: Option, pub movie_title: String, pub release_year: u16, - /// /movies/{id} for local; None for remote entries without a local movie record pub movie_url: Option, pub added_at: String, - /// /watchlist/{movie_id}/remove for owner; None for remote or non-owner pub remove_url: Option, } -pub struct WatchlistPageData { - pub ctx: HtmlPageContext, - pub owner_id: uuid::Uuid, - pub display_entries: Vec, - pub current_offset: u32, - pub has_more: bool, - pub limit: u32, - pub is_owner: bool, - pub error: Option, -} - -pub struct ImportUploadPageData { - pub ctx: HtmlPageContext, - pub profiles: Vec, - pub error: Option, -} - -pub struct ImportProfileView { - pub id: String, - pub name: String, -} - -pub struct ImportMappingPageData { - pub ctx: HtmlPageContext, - pub session_id: String, - pub columns: Vec, - pub sample_rows: Vec>, - pub domain_fields: Vec<(&'static str, &'static str)>, - pub error: Option, -} - -pub struct ImportPreviewRow { - pub index: usize, - pub status: ImportRowStatus, - pub cells: Vec, -} - -pub enum ImportRowStatus { - Valid, - Duplicate, - Invalid(String), -} - -pub struct ImportPreviewPageData { - pub ctx: HtmlPageContext, - pub session_id: String, - pub columns: Vec, - pub rows: Vec, -} - -pub struct ProfileSettingsPageData { - pub ctx: HtmlPageContext, - pub bio: Option, - pub avatar_url: Option, - pub banner_url: Option, - pub also_known_as: Option, - pub profile_fields: Vec<(String, String)>, - pub saved: bool, -} - -pub struct BlockedDomainEntry { - pub domain: String, - pub reason: Option, - pub blocked_at: String, -} - -pub struct BlockedDomainsPageData { - pub ctx: HtmlPageContext, - pub domains: Vec, -} - -pub struct BlockedActorEntry { - pub url: String, - pub handle: String, - pub display_name: Option, - pub avatar_url: Option, -} - -pub struct BlockedActorsPageData { - pub ctx: HtmlPageContext, - pub actors: Vec, -} - -pub struct WebhookTokenView { - pub id: String, - pub provider: String, - pub label: Option, - pub created_at: String, - pub last_used_at: Option, -} - -pub struct IntegrationsPageData { - pub ctx: HtmlPageContext, - pub tokens: Vec, - pub webhook_base_url: String, - pub new_token: Option, -} - -pub struct WatchQueueDisplayEntry { - pub id: String, - pub title: String, - pub year: Option, - pub source: String, - pub watched_at: String, - pub movie_url: Option, -} - -pub struct WatchQueuePageData { - pub ctx: HtmlPageContext, - pub entries: Vec, - pub error: Option, -} - -pub trait HtmlRenderer: Send + Sync { - fn render_diary_page( - &self, - data: &Paginated, - ctx: HtmlPageContext, - ) -> Result; - fn render_login_page(&self, data: LoginPageData<'_>) -> Result; - fn render_register_page(&self, data: RegisterPageData<'_>) -> Result; - fn render_new_review_page(&self, data: NewReviewPageData<'_>) -> Result; - fn render_activity_feed_page(&self, data: ActivityFeedPageData) -> Result; - fn render_users_page(&self, data: UsersPageData) -> Result; - fn render_profile_page(&self, data: ProfilePageData) -> Result; - fn render_following_page(&self, data: FollowingPageData) -> Result; - fn render_followers_page(&self, data: FollowersPageData) -> Result; - fn render_movie_detail_page(&self, data: MovieDetailPageData) -> Result; - fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result; - fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result; - fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result; - fn render_profile_settings_page(&self, data: ProfileSettingsPageData) - -> Result; - fn render_blocked_domains_page(&self, data: BlockedDomainsPageData) -> Result; - fn render_blocked_actors_page(&self, data: BlockedActorsPageData) -> Result; - fn render_watchlist_page(&self, data: WatchlistPageData) -> Result; - fn render_integrations_page(&self, data: IntegrationsPageData) -> Result; - fn render_watch_queue_page(&self, data: WatchQueuePageData) -> Result; -} - pub trait RssFeedRenderer: Send + Sync { fn render_feed(&self, entries: &[DiaryEntry], title: &str) -> Result; } diff --git a/crates/application/src/test_helpers.rs b/crates/application/src/test_helpers.rs index e8b5025..9ad8a8e 100644 --- a/crates/application/src/test_helpers.rs +++ b/crates/application/src/test_helpers.rs @@ -23,7 +23,10 @@ use domain::{ }, }; -use crate::{config::AppConfig, context::AppContext}; +use crate::{ + config::AppConfig, + context::{AppContext, Repositories, Services}, +}; pub struct TestContextBuilder { pub movie_repo: Arc, @@ -125,35 +128,39 @@ impl TestContextBuilder { pub fn build(self) -> AppContext { AppContext { - movie_repository: self.movie_repo, - review_repository: self.review_repo, - diary_repository: self.diary_repo, - diary_exporter: self.diary_exporter, - document_parser: self.document_parser, - stats_repository: self.stats_repo, - metadata_client: self.metadata_client, - poster_fetcher: self.poster_fetcher, - image_storage: self.image_storage, - event_publisher: self.event_publisher, - auth_service: self.auth_service, - password_hasher: self.password_hasher, - user_repository: self.user_repo, - import_session_repository: self.import_session_repo, - import_profile_repository: self.import_profile_repo, - movie_profile_repository: self.movie_profile_repo, - watchlist_repository: self.watchlist_repo, - watch_event_repository: self.watch_event_repo, - webhook_token_repository: self.webhook_token_repo, - profile_fields_repository: self.profile_fields_repo, - person_command: self.person_command, - person_query: self.person_query, - search_port: self.search_port, - search_command: self.search_command, + repos: Repositories { + movie: self.movie_repo, + review: self.review_repo, + diary: self.diary_repo, + stats: self.stats_repo, + user: self.user_repo, + import_session: self.import_session_repo, + import_profile: self.import_profile_repo, + movie_profile: self.movie_profile_repo, + watchlist: self.watchlist_repo, + watch_event: self.watch_event_repo, + webhook_token: self.webhook_token_repo, + profile_fields: self.profile_fields_repo, + person_command: self.person_command, + person_query: self.person_query, + search_port: self.search_port, + search_command: self.search_command, + #[cfg(feature = "federation")] + remote_watchlist: Arc::new(PanicRemoteWatchlistRepository), + #[cfg(feature = "federation")] + social_query: Arc::new(PanicSocialQueryPort), + }, + services: Services { + auth: self.auth_service, + password_hasher: self.password_hasher, + metadata: self.metadata_client, + poster_fetcher: self.poster_fetcher, + image_storage: self.image_storage, + event_publisher: self.event_publisher, + diary_exporter: self.diary_exporter, + document_parser: self.document_parser, + }, config: self.config, - #[cfg(feature = "federation")] - remote_watchlist_repository: std::sync::Arc::new(PanicRemoteWatchlistRepository), - #[cfg(feature = "federation")] - social_query: std::sync::Arc::new(PanicSocialQueryPort), } } } diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs index a411085..9ba2a4d 100644 --- a/crates/presentation/src/extractors.rs +++ b/crates/presentation/src/extractors.rs @@ -28,7 +28,12 @@ where "Missing or invalid auth token".into(), )) })?; - let user_id = app_state.app_ctx.auth_service.validate_token(token).await?; + let user_id = app_state + .app_ctx + .services + .auth + .validate_token(token) + .await?; Ok(AuthenticatedUser(user_id)) } } @@ -62,7 +67,8 @@ where }; let user_id = app_state .app_ctx - .auth_service + .services + .auth .validate_token(&token) .await .ok(); @@ -83,7 +89,8 @@ where .ok_or_else(|| Redirect::to("/login").into_response())?; let user_id = app_state .app_ctx - .auth_service + .services + .auth .validate_token(&token) .await .map_err(|_| Redirect::to("/login").into_response())?; @@ -106,7 +113,8 @@ where RequiredCookieUser::from_request_parts(parts, state).await?; let user = app_state .app_ctx - .user_repository + .repos + .user .find_by_id(&user_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())? diff --git a/crates/presentation/src/factory.rs b/crates/presentation/src/factory.rs index 856a499..822e340 100644 --- a/crates/presentation/src/factory.rs +++ b/crates/presentation/src/factory.rs @@ -1,36 +1,11 @@ use std::sync::Arc; use anyhow::Context; - use domain::ports::{ - AuthService, DiaryRepository, ImageStorage, ImportProfileRepository, ImportSessionRepository, - LocalApContentQuery, MetadataClient, MovieProfileRepository, MovieRepository, PasswordHasher, - PersonCommand, PersonQuery, PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort, - StatsRepository, UserProfileFieldsRepository, UserRepository, WatchEventRepository, - WatchlistRepository, WebhookTokenRepository, + AuthService, ImageStorage, LocalApContentQuery, MetadataClient, PasswordHasher, + PosterFetcherClient, UserProfileFieldsRepository, WatchEventRepository, WebhookTokenRepository, }; -pub struct DatabaseAdapters { - pub movie_repo: Arc, - pub review_repo: Arc, - pub diary_repo: Arc, - pub stats_repo: Arc, - pub user_repo: Arc, - pub import_session_repo: Arc, - pub import_profile_repo: Arc, - pub movie_profile_repo: Arc, - pub watchlist_repo: Arc, - pub ap_content_repo: Arc, - pub person_command: Arc, - pub person_query: Arc, - pub search_port: Arc, - pub search_command: Arc, - pub profile_fields_repo: Arc, - pub watch_event_repo: Arc, - pub webhook_token_repo: Arc, - pub db_pool: DbPool, -} - pub enum DbPool { #[cfg(feature = "sqlite")] Sqlite(sqlx::SqlitePool), @@ -38,72 +13,94 @@ pub enum DbPool { Postgres(sqlx::PgPool), } -pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result { +pub struct DatabaseOutput { + pub movie: Arc, + pub review: Arc, + pub diary: Arc, + pub stats: Arc, + pub user: Arc, + pub import_session: Arc, + pub import_profile: Arc, + pub movie_profile: Arc, + pub watchlist: Arc, + pub watch_event: Arc, + pub webhook_token: Arc, + pub person_command: Arc, + pub person_query: Arc, + pub search_port: Arc, + pub search_command: Arc, + pub profile_fields: Arc, + pub ap_content: Arc, + pub db_pool: DbPool, +} + +pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result { match backend { #[cfg(feature = "postgres")] "postgres" => { - let (pool, m, r, d, s, u, is, ip, mp, wl, ac) = postgres::wire(url) + let w = postgres::wire(url) .await .context("PostgreSQL connection failed")?; - let (pc, pq) = postgres::create_person_adapter(pool.clone()); - let (sc, sp) = postgres_search::create_search_adapter(pool.clone()); - let pf = postgres::create_profile_fields_repo(pool.clone()); + let (pc, pq) = postgres::create_person_adapter(w.pool.clone()); + let (sc, sp) = postgres_search::create_search_adapter(w.pool.clone()); + let pf = postgres::create_profile_fields_repo(w.pool.clone()); let we: Arc = - Arc::new(postgres::PostgresWatchEventRepository::new(pool.clone())); - let wt: Arc = - Arc::new(postgres::PostgresWebhookTokenRepository::new(pool.clone())); - Ok(DatabaseAdapters { - movie_repo: m, - review_repo: r, - diary_repo: d, - stats_repo: s, - user_repo: u, - import_session_repo: is, - import_profile_repo: ip, - movie_profile_repo: mp, - watchlist_repo: wl, - ap_content_repo: ac, + Arc::new(postgres::PostgresWatchEventRepository::new(w.pool.clone())); + let wt: Arc = Arc::new( + postgres::PostgresWebhookTokenRepository::new(w.pool.clone()), + ); + Ok(DatabaseOutput { + movie: w.movie, + review: w.review, + diary: w.diary, + stats: w.stats, + user: w.user, + import_session: w.import_session, + import_profile: w.import_profile, + movie_profile: w.movie_profile, + watchlist: w.watchlist, + watch_event: we, + webhook_token: wt, person_command: pc, person_query: pq, search_port: sp, search_command: sc, - profile_fields_repo: pf, - watch_event_repo: we, - webhook_token_repo: wt, - db_pool: DbPool::Postgres(pool), + profile_fields: pf, + ap_content: w.ap_content, + db_pool: DbPool::Postgres(w.pool), }) } #[cfg(feature = "sqlite")] _ => { - let (pool, m, r, d, s, u, is, ip, mp, wl, ac) = sqlite::wire(url) + let w = sqlite::wire(url) .await .context("SQLite connection failed")?; - let (pc, pq) = sqlite::create_person_adapter(pool.clone()); - let (sc, sp) = sqlite_search::create_search_adapter(pool.clone()); - let pf = sqlite::create_profile_fields_repo(pool.clone()); + let (pc, pq) = sqlite::create_person_adapter(w.pool.clone()); + let (sc, sp) = sqlite_search::create_search_adapter(w.pool.clone()); + let pf = sqlite::create_profile_fields_repo(w.pool.clone()); let we: Arc = - Arc::new(sqlite::SqliteWatchEventRepository::new(pool.clone())); + Arc::new(sqlite::SqliteWatchEventRepository::new(w.pool.clone())); let wt: Arc = - Arc::new(sqlite::SqliteWebhookTokenRepository::new(pool.clone())); - Ok(DatabaseAdapters { - movie_repo: m, - review_repo: r, - diary_repo: d, - stats_repo: s, - user_repo: u, - import_session_repo: is, - import_profile_repo: ip, - movie_profile_repo: mp, - watchlist_repo: wl, - ap_content_repo: ac, + Arc::new(sqlite::SqliteWebhookTokenRepository::new(w.pool.clone())); + Ok(DatabaseOutput { + movie: w.movie, + review: w.review, + diary: w.diary, + stats: w.stats, + user: w.user, + import_session: w.import_session, + import_profile: w.import_profile, + movie_profile: w.movie_profile, + watchlist: w.watchlist, + watch_event: we, + webhook_token: wt, person_command: pc, person_query: pq, search_port: sp, search_command: sc, - profile_fields_repo: pf, - watch_event_repo: we, - webhook_token_repo: wt, - db_pool: DbPool::Sqlite(pool), + profile_fields: pf, + ap_content: w.ap_content, + db_pool: DbPool::Sqlite(w.pool), }) } #[cfg(not(feature = "sqlite"))] diff --git a/crates/presentation/src/handlers/images.rs b/crates/presentation/src/handlers/images.rs index f04fd82..f5be8e8 100644 --- a/crates/presentation/src/handlers/images.rs +++ b/crates/presentation/src/handlers/images.rs @@ -13,7 +13,7 @@ pub async fn get_image( if key.starts_with("http://") || key.starts_with("https://") { return axum::response::Redirect::temporary(&key).into_response(); } - match state.app_ctx.image_storage.get(&key).await { + match state.app_ctx.services.image_storage.get(&key).await { Ok(bytes) => { let mime = infer::get(&bytes) .map(|t| t.mime_type()) diff --git a/crates/presentation/src/lib.rs b/crates/presentation/src/lib.rs index 3cef903..c230ef4 100644 --- a/crates/presentation/src/lib.rs +++ b/crates/presentation/src/lib.rs @@ -6,6 +6,7 @@ pub mod forms; pub mod handlers; pub mod openapi; pub mod ports; +pub mod render; pub mod routes; pub mod state; diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index 121208a..1f1aca6 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -5,13 +5,14 @@ use anyhow::Context; use tokio::net::TcpListener; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use application::{config::AppConfig, context::AppContext}; +use application::{ + config::AppConfig, + context::{AppContext, Repositories, Services}, +}; use export::ExportAdapter; use importer::ImporterDocumentParser; -use rss::RssAdapter; -use template_askama::AskamaHtmlRenderer; - use presentation::{factory, openapi, routes, state::AppState}; +use rss::RssAdapter; use domain::ports::{DiaryExporter, DocumentParser, EventPublisher}; @@ -59,24 +60,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let image_storage = factory::build_image_storage()?; let db = factory::build_database_adapters(&backend, &database_url).await?; - - let movie_repository = db.movie_repo; - let review_repository = db.review_repo; - let diary_repository = db.diary_repo; - let stats_repository = db.stats_repo; - let user_repository = db.user_repo; - let import_session_repository = db.import_session_repo; - let import_profile_repository = db.import_profile_repo; - let movie_profile_repository = db.movie_profile_repo; - let watchlist_repository = db.watchlist_repo; - let ap_content_repo = db.ap_content_repo; - let person_command = db.person_command; - let person_query = db.person_query; - let search_port = db.search_port; - let search_command = db.search_command; - let profile_fields_repo = db.profile_fields_repo; - let watch_event_repository = db.watch_event_repo; - let webhook_token_repository = db.webhook_token_repo; + let ap_content_repo = db.ap_content; let db_pool = db.db_pool; // Wire up event channel, federation service, and ap_router @@ -135,7 +119,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { review_store, remote_watchlist_repo: remote_watchlist_repo.clone(), local_ap_content: Arc::clone(&ap_content_repo), - user_repo: Arc::clone(&user_repository), + user_repo: Arc::clone(&db.user), base_url: app_config.base_url.clone(), allow_registration: app_config.allow_registration, event_publisher: Arc::clone(&ep), @@ -184,40 +168,43 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let ap_router = axum::Router::new(); let app_ctx = AppContext { - movie_repository, - review_repository, - diary_repository, - diary_exporter: Arc::new(ExportAdapter) as Arc, - document_parser: Arc::new(ImporterDocumentParser) as Arc, - stats_repository, - metadata_client, - poster_fetcher, - image_storage, - event_publisher: event_publisher_arc, - auth_service, - password_hasher, - user_repository, - import_session_repository, - import_profile_repository, - movie_profile_repository, - watchlist_repository, - watch_event_repository, - webhook_token_repository, - profile_fields_repository: profile_fields_repo, - #[cfg(feature = "federation")] - remote_watchlist_repository: remote_watchlist_repo, - #[cfg(feature = "federation")] - social_query: social_query.clone(), - person_command, - person_query, - search_port, - search_command, + repos: Repositories { + movie: db.movie, + review: db.review, + diary: db.diary, + stats: db.stats, + user: db.user, + import_session: db.import_session, + import_profile: db.import_profile, + movie_profile: db.movie_profile, + watchlist: db.watchlist, + watch_event: db.watch_event, + webhook_token: db.webhook_token, + person_command: db.person_command, + person_query: db.person_query, + search_port: db.search_port, + search_command: db.search_command, + profile_fields: db.profile_fields, + #[cfg(feature = "federation")] + remote_watchlist: remote_watchlist_repo, + #[cfg(feature = "federation")] + social_query: social_query.clone(), + }, + services: Services { + auth: auth_service, + password_hasher, + metadata: metadata_client, + poster_fetcher, + image_storage, + event_publisher: event_publisher_arc, + diary_exporter: Arc::new(ExportAdapter) as Arc, + document_parser: Arc::new(ImporterDocumentParser) as Arc, + }, config: app_config, }; let state = AppState { app_ctx, - html_renderer: Arc::new(AskamaHtmlRenderer::new()), rss_renderer: Arc::new(RssAdapter::new( std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()), )), diff --git a/crates/presentation/src/ports.rs b/crates/presentation/src/ports.rs index a91cf69..1e84692 100644 --- a/crates/presentation/src/ports.rs +++ b/crates/presentation/src/ports.rs @@ -1,2 +1 @@ -pub use application::ports::HtmlRenderer; pub use application::ports::RssFeedRenderer; diff --git a/crates/presentation/src/render.rs b/crates/presentation/src/render.rs new file mode 100644 index 0000000..b54e522 --- /dev/null +++ b/crates/presentation/src/render.rs @@ -0,0 +1,14 @@ +use axum::{ + http::StatusCode, + response::{Html, IntoResponse, Response}, +}; + +pub fn render_page(template: impl template_askama::askama::Template) -> Response { + match template.render() { + Ok(html) => Html(html).into_response(), + Err(e) => { + tracing::error!("template error: {e}"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} diff --git a/crates/presentation/src/state.rs b/crates/presentation/src/state.rs index e4c34f0..f31a9fa 100644 --- a/crates/presentation/src/state.rs +++ b/crates/presentation/src/state.rs @@ -2,12 +2,11 @@ use std::sync::Arc; use application::context::AppContext; -use crate::ports::{HtmlRenderer, RssFeedRenderer}; +use crate::ports::RssFeedRenderer; #[derive(Clone)] pub struct AppState { pub app_ctx: AppContext, - pub html_renderer: Arc, pub rss_renderer: Arc, #[cfg(feature = "federation")] pub ap_service: Arc, diff --git a/crates/presentation/src/tests/api_handlers.rs b/crates/presentation/src/tests/api_handlers.rs index 552bc76..72d9bc5 100644 --- a/crates/presentation/src/tests/api_handlers.rs +++ b/crates/presentation/src/tests/api_handlers.rs @@ -68,7 +68,7 @@ impl domain::ports::PersonQuery for PersonQueryStub { async fn search_endpoint_returns_200_with_empty_results() { let mut state = make_test_state(Arc::new(Panic)); // Override the search_port with our stub - state.app_ctx.search_port = Arc::new(SearchPortStub); + state.app_ctx.repos.search_port = Arc::new(SearchPortStub); let app = Router::new() .route("/api/v1/search", get(crate::handlers::api::get_search)) .with_state(state); @@ -90,7 +90,7 @@ async fn search_endpoint_returns_200_with_empty_results() { async fn search_endpoint_with_no_query_returns_200() { let mut state = make_test_state(Arc::new(Panic)); // Override the search_port with our stub - state.app_ctx.search_port = Arc::new(SearchPortStub); + state.app_ctx.repos.search_port = Arc::new(SearchPortStub); let app = Router::new() .route("/api/v1/search", get(crate::handlers::api::get_search)) .with_state(state); @@ -114,7 +114,7 @@ async fn search_endpoint_with_no_query_returns_200() { async fn person_endpoint_returns_404_for_unknown_id() { let mut state = make_test_state(Arc::new(Panic)); // Override the person_query with our stub - state.app_ctx.person_query = Arc::new(PersonQueryStub); + state.app_ctx.repos.person_query = Arc::new(PersonQueryStub); let app = Router::new() .route( "/api/v1/people/{id}", @@ -140,7 +140,7 @@ async fn person_endpoint_returns_404_for_unknown_id() { async fn person_credits_endpoint_returns_404_for_unknown_id() { let mut state = make_test_state(Arc::new(Panic)); // Override the person_query with our stub - state.app_ctx.person_query = Arc::new(PersonQueryStub); + state.app_ctx.repos.person_query = Arc::new(PersonQueryStub); let app = Router::new() .route( "/api/v1/people/{id}/credits", diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index f883904..b123635 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -1,5 +1,8 @@ use super::*; -use application::{config::AppConfig, context::AppContext}; +use application::{ + config::AppConfig, + context::{AppContext, Repositories, Services}, +}; use axum::{ Router, body::Body, @@ -387,120 +390,6 @@ impl domain::ports::DocumentParser for Panic { } } -impl crate::ports::HtmlRenderer for Panic { - fn render_diary_page( - &self, - _: &Paginated, - _: application::ports::HtmlPageContext, - ) -> Result { - panic!() - } - fn render_login_page( - &self, - _: application::ports::LoginPageData<'_>, - ) -> Result { - panic!() - } - fn render_register_page( - &self, - _: application::ports::RegisterPageData<'_>, - ) -> Result { - panic!() - } - fn render_new_review_page( - &self, - _: application::ports::NewReviewPageData<'_>, - ) -> Result { - panic!() - } - fn render_activity_feed_page( - &self, - _: application::ports::ActivityFeedPageData, - ) -> Result { - panic!() - } - fn render_users_page(&self, _: application::ports::UsersPageData) -> Result { - panic!() - } - fn render_profile_page( - &self, - _: application::ports::ProfilePageData, - ) -> Result { - panic!() - } - fn render_following_page( - &self, - _: application::ports::FollowingPageData, - ) -> Result { - panic!() - } - fn render_followers_page( - &self, - _: application::ports::FollowersPageData, - ) -> Result { - panic!() - } - fn render_movie_detail_page( - &self, - _: application::ports::MovieDetailPageData, - ) -> Result { - panic!() - } - fn render_import_upload_page( - &self, - _: application::ports::ImportUploadPageData, - ) -> Result { - panic!() - } - fn render_import_mapping_page( - &self, - _: application::ports::ImportMappingPageData, - ) -> Result { - panic!() - } - fn render_import_preview_page( - &self, - _: application::ports::ImportPreviewPageData, - ) -> Result { - panic!() - } - fn render_profile_settings_page( - &self, - _: application::ports::ProfileSettingsPageData, - ) -> Result { - panic!() - } - fn render_blocked_domains_page( - &self, - _: application::ports::BlockedDomainsPageData, - ) -> Result { - panic!() - } - fn render_blocked_actors_page( - &self, - _: application::ports::BlockedActorsPageData, - ) -> Result { - panic!() - } - fn render_watchlist_page( - &self, - _: application::ports::WatchlistPageData, - ) -> Result { - panic!() - } - fn render_integrations_page( - &self, - _: application::ports::IntegrationsPageData, - ) -> Result { - panic!() - } - fn render_watch_queue_page( - &self, - _: application::ports::WatchQueuePageData, - ) -> Result { - panic!() - } -} impl crate::ports::RssFeedRenderer for Panic { fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result { panic!() @@ -660,41 +549,44 @@ pub fn make_test_state(auth_service: Arc) -> crate::state::AppS let repo = Arc::new(Panic); crate::state::AppState { app_ctx: AppContext { - movie_repository: Arc::clone(&repo) as _, - review_repository: Arc::clone(&repo) as _, - diary_repository: Arc::clone(&repo) as _, - diary_exporter: Arc::clone(&repo) as _, - document_parser: Arc::clone(&repo) as _, - stats_repository: Arc::clone(&repo) as _, - metadata_client: Arc::clone(&repo) as _, - poster_fetcher: Arc::clone(&repo) as _, - image_storage: Arc::clone(&repo) as _, - event_publisher: Arc::clone(&repo) as _, - password_hasher: Arc::clone(&repo) as _, - user_repository: Arc::clone(&repo) as _, - import_session_repository: Arc::clone(&repo) as _, - import_profile_repository: Arc::clone(&repo) as _, - movie_profile_repository: Arc::clone(&repo) as _, - watchlist_repository: Arc::clone(&repo) as _, - watch_event_repository: Arc::clone(&repo) as _, - webhook_token_repository: Arc::clone(&repo) as _, - profile_fields_repository: Arc::clone(&repo) as _, - #[cfg(feature = "federation")] - remote_watchlist_repository: Arc::clone(&repo) as _, - #[cfg(feature = "federation")] - social_query: Arc::clone(&repo) as _, - person_command: Arc::clone(&repo) as _, - person_query: Arc::clone(&repo) as _, - search_port: Arc::clone(&repo) as _, - search_command: Arc::clone(&repo) as _, - auth_service, + repos: Repositories { + movie: Arc::clone(&repo) as _, + review: Arc::clone(&repo) as _, + diary: Arc::clone(&repo) as _, + stats: Arc::clone(&repo) as _, + user: Arc::clone(&repo) as _, + import_session: Arc::clone(&repo) as _, + import_profile: Arc::clone(&repo) as _, + movie_profile: Arc::clone(&repo) as _, + watchlist: Arc::clone(&repo) as _, + watch_event: Arc::clone(&repo) as _, + webhook_token: Arc::clone(&repo) as _, + profile_fields: Arc::clone(&repo) as _, + person_command: Arc::clone(&repo) as _, + person_query: Arc::clone(&repo) as _, + search_port: Arc::clone(&repo) as _, + search_command: Arc::clone(&repo) as _, + #[cfg(feature = "federation")] + remote_watchlist: Arc::clone(&repo) as _, + #[cfg(feature = "federation")] + social_query: Arc::clone(&repo) as _, + }, + services: Services { + auth: auth_service, + password_hasher: Arc::clone(&repo) as _, + metadata: Arc::clone(&repo) as _, + poster_fetcher: Arc::clone(&repo) as _, + image_storage: Arc::clone(&repo) as _, + event_publisher: Arc::clone(&repo) as _, + diary_exporter: Arc::clone(&repo) as _, + document_parser: Arc::clone(&repo) as _, + }, config: AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string(), rate_limit: 20, }, }, - html_renderer: Arc::new(Panic), rss_renderer: Arc::new(Panic), #[cfg(feature = "federation")] ap_service: Arc::new(activitypub::NoopActivityPubService), diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index a186bf5..95ac848 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -1,6 +1,9 @@ use std::sync::Arc; -use application::{config::AppConfig, context::AppContext}; +use application::{ + config::AppConfig, + context::{AppContext, Repositories, Services}, +}; use async_trait::async_trait; use axum::{ Router, @@ -26,7 +29,6 @@ use presentation::{routes, state::AppState}; use rss::RssAdapter; use sqlite::SqliteMovieRepository; use sqlx::SqlitePool; -use template_askama::AskamaHtmlRenderer; use tower::ServiceExt; struct NoopEventPublisher; @@ -394,41 +396,44 @@ async fn test_app() -> Router { let repo = Arc::new(repo); let state = AppState { app_ctx: AppContext { - movie_repository: Arc::clone(&repo) as _, - review_repository: Arc::clone(&repo) as _, - diary_repository: Arc::clone(&repo) as _, - diary_exporter: Arc::new(PanicExporter), - document_parser: Arc::new(PanicDocumentParser), - stats_repository: Arc::clone(&repo) as _, - metadata_client: Arc::new(PanicMeta), - poster_fetcher: Arc::new(PanicFetcher), - image_storage: Arc::new(PanicImageStorage), - event_publisher: Arc::new(NoopEventPublisher), - auth_service: Arc::new(PanicAuth), - password_hasher: Arc::new(PanicHasher), - user_repository: Arc::new(NobodyUserRepo), - import_session_repository: Arc::new(PanicImportSession), - import_profile_repository: Arc::new(PanicImportProfile), - movie_profile_repository: Arc::new(PanicMovieProfile), - watchlist_repository: Arc::new(PanicWatchlist), - watch_event_repository: Arc::new(domain::testing::PanicWatchEventRepository), - webhook_token_repository: Arc::new(domain::testing::PanicWebhookTokenRepository), - profile_fields_repository: Arc::new(PanicProfileFields), - #[cfg(feature = "federation")] - remote_watchlist_repository: Arc::new(PanicRemoteWatchlist), - #[cfg(feature = "federation")] - social_query: Arc::new(PanicSocialQuery), - person_command: Arc::new(PanicPersonCommand), - person_query: Arc::new(PanicPersonQuery), - search_port: Arc::new(PanicSearchPort), - search_command: Arc::new(PanicSearchCommand), + repos: Repositories { + movie: Arc::clone(&repo) as _, + review: Arc::clone(&repo) as _, + diary: Arc::clone(&repo) as _, + stats: Arc::clone(&repo) as _, + user: Arc::new(NobodyUserRepo), + import_session: Arc::new(PanicImportSession), + import_profile: Arc::new(PanicImportProfile), + movie_profile: Arc::new(PanicMovieProfile), + watchlist: Arc::new(PanicWatchlist), + watch_event: Arc::new(domain::testing::PanicWatchEventRepository), + webhook_token: Arc::new(domain::testing::PanicWebhookTokenRepository), + profile_fields: Arc::new(PanicProfileFields), + person_command: Arc::new(PanicPersonCommand), + person_query: Arc::new(PanicPersonQuery), + search_port: Arc::new(PanicSearchPort), + search_command: Arc::new(PanicSearchCommand), + #[cfg(feature = "federation")] + remote_watchlist: Arc::new(PanicRemoteWatchlist), + #[cfg(feature = "federation")] + social_query: Arc::new(PanicSocialQuery), + }, + services: Services { + auth: Arc::new(PanicAuth), + password_hasher: Arc::new(PanicHasher), + metadata: Arc::new(PanicMeta), + poster_fetcher: Arc::new(PanicFetcher), + image_storage: Arc::new(PanicImageStorage), + event_publisher: Arc::new(NoopEventPublisher), + diary_exporter: Arc::new(PanicExporter), + document_parser: Arc::new(PanicDocumentParser), + }, config: AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string(), rate_limit: 20, }, }, - html_renderer: Arc::new(AskamaHtmlRenderer::new()), rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())), #[cfg(feature = "federation")] ap_service: Arc::new(activitypub::NoopActivityPubService), diff --git a/crates/worker/src/db.rs b/crates/worker/src/db.rs index 947c614..6fb5793 100644 --- a/crates/worker/src/db.rs +++ b/crates/worker/src/db.rs @@ -16,7 +16,7 @@ pub enum DbPool { Postgres(sqlx::PgPool), } -pub struct Repos { +pub struct WorkerDbOutput { pub movie: Arc, pub review: Arc, pub diary: Arc, @@ -26,96 +26,95 @@ pub struct Repos { pub import_profile: Arc, pub movie_profile: Arc, pub watchlist: Arc, - pub ap_content: Arc, - pub image_ref_command: Arc, - pub image_ref_query: Arc, + pub watch_event: Arc, + pub webhook_token: Arc, pub person_command: Arc, pub person_query: Arc, pub search_command: Arc, pub search_port: Arc, pub profile_fields: Arc, - pub watch_event: Arc, - pub webhook_token: Arc, + pub ap_content: Arc, + pub image_ref_command: Arc, + pub image_ref_query: Arc, + pub db_pool: DbPool, } -pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<(Repos, DbPool)> { +pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result { match backend { #[cfg(feature = "postgres")] "postgres" => { - let (pool, m, r, d, s, u, is, ip, mp, wl, ac) = postgres::wire(database_url) + let w = postgres::wire(database_url) .await .context("PostgreSQL connection failed")?; - let (image_ref_command, image_ref_query) = postgres::create_image_ref(pool.clone()); - let (person_command, person_query) = postgres::create_person_adapter(pool.clone()); + let (image_ref_command, image_ref_query) = postgres::create_image_ref(w.pool.clone()); + let (person_command, person_query) = postgres::create_person_adapter(w.pool.clone()); let (search_command, search_port) = - postgres_search::create_search_adapter(pool.clone()); - let pf = postgres::create_profile_fields_repo(pool.clone()); + postgres_search::create_search_adapter(w.pool.clone()); + let pf = postgres::create_profile_fields_repo(w.pool.clone()); let we: Arc = - Arc::new(postgres::PostgresWatchEventRepository::new(pool.clone())); - let wt: Arc = - Arc::new(postgres::PostgresWebhookTokenRepository::new(pool.clone())); - Ok(( - Repos { - movie: m, - review: r, - diary: d, - stats: s, - user: u, - import_session: is, - import_profile: ip, - movie_profile: mp, - watchlist: wl, - ap_content: ac, - image_ref_command, - image_ref_query, - person_command, - person_query, - search_command, - search_port, - profile_fields: pf, - watch_event: we, - webhook_token: wt, - }, - DbPool::Postgres(pool), - )) + Arc::new(postgres::PostgresWatchEventRepository::new(w.pool.clone())); + let wt: Arc = Arc::new( + postgres::PostgresWebhookTokenRepository::new(w.pool.clone()), + ); + Ok(WorkerDbOutput { + movie: w.movie, + review: w.review, + diary: w.diary, + stats: w.stats, + user: w.user, + import_session: w.import_session, + import_profile: w.import_profile, + movie_profile: w.movie_profile, + watchlist: w.watchlist, + watch_event: we, + webhook_token: wt, + person_command, + person_query, + search_command, + search_port, + profile_fields: pf, + ap_content: w.ap_content, + image_ref_command, + image_ref_query, + db_pool: DbPool::Postgres(w.pool), + }) } #[cfg(feature = "sqlite")] _ => { - let (pool, m, r, d, s, u, is, ip, mp, wl, ac) = sqlite::wire(database_url) + let w = sqlite::wire(database_url) .await .context("SQLite connection failed")?; - let (image_ref_command, image_ref_query) = sqlite::create_image_ref(pool.clone()); - let (person_command, person_query) = sqlite::create_person_adapter(pool.clone()); - let (search_command, search_port) = sqlite_search::create_search_adapter(pool.clone()); - let pf = sqlite::create_profile_fields_repo(pool.clone()); + let (image_ref_command, image_ref_query) = sqlite::create_image_ref(w.pool.clone()); + let (person_command, person_query) = sqlite::create_person_adapter(w.pool.clone()); + let (search_command, search_port) = + sqlite_search::create_search_adapter(w.pool.clone()); + let pf = sqlite::create_profile_fields_repo(w.pool.clone()); let we: Arc = - Arc::new(sqlite::SqliteWatchEventRepository::new(pool.clone())); + Arc::new(sqlite::SqliteWatchEventRepository::new(w.pool.clone())); let wt: Arc = - Arc::new(sqlite::SqliteWebhookTokenRepository::new(pool.clone())); - Ok(( - Repos { - movie: m, - review: r, - diary: d, - stats: s, - user: u, - import_session: is, - import_profile: ip, - movie_profile: mp, - watchlist: wl, - ap_content: ac, - image_ref_command, - image_ref_query, - person_command, - person_query, - search_command, - search_port, - profile_fields: pf, - watch_event: we, - webhook_token: wt, - }, - DbPool::Sqlite(pool), - )) + Arc::new(sqlite::SqliteWebhookTokenRepository::new(w.pool.clone())); + Ok(WorkerDbOutput { + movie: w.movie, + review: w.review, + diary: w.diary, + stats: w.stats, + user: w.user, + import_session: w.import_session, + import_profile: w.import_profile, + movie_profile: w.movie_profile, + watchlist: w.watchlist, + watch_event: we, + webhook_token: wt, + person_command, + person_query, + search_command, + search_port, + profile_fields: pf, + ap_content: w.ap_content, + image_ref_command, + image_ref_query, + db_pool: DbPool::Sqlite(w.pool), + }) } #[cfg(not(feature = "sqlite"))] _ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build"), diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index 0fcd373..a27f62d 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -6,7 +6,9 @@ use std::sync::Arc; use anyhow::Context; use application::{ - MovieDiscoveryIndexer, SearchCleanupHandler, config::AppConfig, context::AppContext, + MovieDiscoveryIndexer, SearchCleanupHandler, + config::AppConfig, + context::{AppContext, Repositories, Services}, worker::WorkerService, }; use export::ExportAdapter; @@ -34,22 +36,16 @@ async fn main() -> anyhow::Result<()> { let poster_fetcher = poster_fetcher::create()?; let image_storage = image_storage::create()?; - let (repos, db_pool) = db::connect(&database_url, &backend).await?; - let (event_publisher_arc, consumer_arc) = event_bus::create(&db_pool).await?; + let db = db::connect(&database_url, &backend).await?; + let (event_publisher_arc, consumer_arc) = event_bus::create(&db.db_pool).await?; - let image_ref_command = Arc::clone(&repos.image_ref_command); - let image_ref_query = Arc::clone(&repos.image_ref_query); - let person_command = Arc::clone(&repos.person_command); - let person_query = Arc::clone(&repos.person_query); - let search_command = Arc::clone(&repos.search_command); - let search_port = Arc::clone(&repos.search_port); - let profile_fields_repo = Arc::clone(&repos.profile_fields); + let image_ref_command = Arc::clone(&db.image_ref_command); + let image_ref_query = Arc::clone(&db.image_ref_query); - // Clone refs federation handler needs before ctx consumes them. #[cfg(feature = "federation")] let (fed_ap_content, fed_user_repo, base_url, allow_registration) = ( - Arc::clone(&repos.ap_content), - Arc::clone(&repos.user), + Arc::clone(&db.ap_content), + Arc::clone(&db.user), app_config.base_url.clone(), app_config.allow_registration, ); @@ -63,7 +59,7 @@ async fn main() -> anyhow::Result<()> { fed_social_query, fed_review_store, fed_remote_watchlist_repo, - ) = match &db_pool { + ) = match &db.db_pool { #[cfg(feature = "sqlite-federation")] db::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()), #[cfg(feature = "postgres-federation")] @@ -71,34 +67,38 @@ async fn main() -> anyhow::Result<()> { }; let ctx = AppContext { - movie_repository: repos.movie, - review_repository: repos.review, - diary_repository: repos.diary, - diary_exporter: Arc::new(ExportAdapter) as Arc, - document_parser: Arc::new(ImporterDocumentParser) as Arc, - stats_repository: repos.stats, - metadata_client, - poster_fetcher, - image_storage, - event_publisher: event_publisher_arc, - auth_service, - password_hasher, - user_repository: repos.user, - import_session_repository: repos.import_session, - import_profile_repository: repos.import_profile, - movie_profile_repository: repos.movie_profile, - watchlist_repository: repos.watchlist, - watch_event_repository: repos.watch_event, - webhook_token_repository: repos.webhook_token, - profile_fields_repository: Arc::clone(&profile_fields_repo), - #[cfg(feature = "federation")] - remote_watchlist_repository: fed_remote_watchlist_repo.clone(), - #[cfg(feature = "federation")] - social_query: fed_social_query, - person_command: Arc::clone(&person_command), - person_query: Arc::clone(&person_query), - search_port: Arc::clone(&search_port), - search_command: Arc::clone(&search_command), + repos: Repositories { + movie: db.movie, + review: db.review, + diary: db.diary, + stats: db.stats, + user: db.user, + import_session: db.import_session, + import_profile: db.import_profile, + movie_profile: db.movie_profile, + watchlist: db.watchlist, + watch_event: db.watch_event, + webhook_token: db.webhook_token, + profile_fields: db.profile_fields, + person_command: db.person_command, + person_query: db.person_query, + search_port: db.search_port, + search_command: db.search_command, + #[cfg(feature = "federation")] + remote_watchlist: fed_remote_watchlist_repo.clone(), + #[cfg(feature = "federation")] + social_query: fed_social_query, + }, + services: Services { + auth: auth_service, + password_hasher, + metadata: metadata_client, + poster_fetcher, + image_storage, + event_publisher: event_publisher_arc, + diary_exporter: Arc::new(ExportAdapter) as Arc, + document_parser: Arc::new(ImporterDocumentParser) as Arc, + }, config: app_config, }; @@ -113,10 +113,10 @@ async fn main() -> anyhow::Result<()> { tracing::info!("TMDb enrichment enabled"); let handler = Arc::new(tmdb_enrichment::EnrichmentHandler { enrichment_client: Arc::new(client), - movie_repository: Arc::clone(&ctx.movie_repository), - profile_repo: Arc::clone(&ctx.movie_profile_repository), - person_command: Arc::clone(&ctx.person_command), - search_command: Arc::clone(&ctx.search_command), + movie_repository: Arc::clone(&ctx.repos.movie), + profile_repo: Arc::clone(&ctx.repos.movie_profile), + person_command: Arc::clone(&ctx.repos.person_command), + search_command: Arc::clone(&ctx.repos.search_command), }) as Arc; let job = Arc::new(application::jobs::EnrichmentStalenessJob::new(ctx.clone())) as Arc; @@ -131,10 +131,10 @@ async fn main() -> anyhow::Result<()> { // ── Image conversion ────────────────────────────────────────────────────── let conversion = image_converter::build( - Arc::clone(&ctx.image_storage), + Arc::clone(&ctx.services.image_storage), image_ref_command, image_ref_query, - Arc::clone(&ctx.event_publisher), + Arc::clone(&ctx.services.event_publisher), )?; // ── Periodic jobs ───────────────────────────────────────────────────────── @@ -166,27 +166,27 @@ async fn main() -> anyhow::Result<()> { let handlers: Vec> = { let poster = Arc::new(poster_sync::PosterSyncHandler::new( - Arc::clone(&ctx.movie_repository), - Arc::clone(&ctx.metadata_client), - Arc::clone(&ctx.poster_fetcher), - Arc::clone(&ctx.image_storage), - Arc::clone(&ctx.event_publisher), + Arc::clone(&ctx.repos.movie), + Arc::clone(&ctx.services.metadata), + Arc::clone(&ctx.services.poster_fetcher), + Arc::clone(&ctx.services.image_storage), + Arc::clone(&ctx.services.event_publisher), 3, )) as Arc; let cleanup = Arc::new(image_storage::ImageCleanupHandler::new(Arc::clone( - &ctx.image_storage, + &ctx.services.image_storage, ))) as Arc; #[cfg(not(feature = "federation"))] { let search_cleanup = Arc::new(SearchCleanupHandler::new( - Arc::clone(&ctx.search_command), - Arc::clone(&ctx.person_query), + Arc::clone(&ctx.repos.search_command), + Arc::clone(&ctx.repos.person_query), )) as Arc; let discovery_indexer = Arc::new(MovieDiscoveryIndexer::new( - Arc::clone(&ctx.movie_repository), - Arc::clone(&ctx.search_command), + Arc::clone(&ctx.repos.movie), + Arc::clone(&ctx.repos.search_command), )) as Arc; let mut h = vec![poster, cleanup, search_cleanup, discovery_indexer]; if let Some(e) = enrichment_handler { @@ -211,7 +211,7 @@ async fn main() -> anyhow::Result<()> { user_repo: fed_user_repo, base_url, allow_registration, - event_publisher: Arc::clone(&ctx.event_publisher), + event_publisher: Arc::clone(&ctx.services.event_publisher), }) .await?; @@ -221,12 +221,12 @@ async fn main() -> anyhow::Result<()> { }) as Arc; let search_cleanup = Arc::new(SearchCleanupHandler::new( - Arc::clone(&ctx.search_command), - Arc::clone(&ctx.person_query), + Arc::clone(&ctx.repos.search_command), + Arc::clone(&ctx.repos.person_query), )) as Arc; let discovery_indexer = Arc::new(MovieDiscoveryIndexer::new( - Arc::clone(&ctx.movie_repository), - Arc::clone(&ctx.search_command), + Arc::clone(&ctx.repos.movie), + Arc::clone(&ctx.repos.search_command), )) as Arc; tracing::info!("federation event handler registered"); let mut h = vec![