refactor: fix HIGH+MEDIUM architectural violations from code review
HIGH: fix watch_medium data-loss bug, standardize error handling on ApiError, fix dep direction (rss/template-askama no longer dep on application), extract ImageFetcher port (remove reqwest from app layer), move event construction from save_review to use case, extract infra-wiring crate (DbPool/EventBusBackend dedup), deduplicate presentation helpers (encode_error, export streaming, multipart parsing) MEDIUM: split LocalApContentQuery god-trait 10→3 methods, dedup movie resolution orchestration, add RemoteActorDto/PersonDto mappers, move AppConfig to infra-wiring, fix SocialQueryPort Uuid→UserId, replace stringly-typed api-types with domain enums, move count_reviews_in_year to StatsRepository, dedup event publisher cfg blocks, extract should_enrich, move group_by_month to application, dedup count_local_posts, add FederationFlags Default, TUI input helper + ShowError rename + typed auth errors, api-types cleanup (UserSettingsDto/UserProfileBase/PreviewRowData) 102 files changed, -681 lines net
This commit is contained in:
@@ -4,7 +4,10 @@ use domain::ports::EventHandler;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
ports::{LocalApContentQuery, UserFederationSettingsQuery},
|
||||
ports::{
|
||||
GoalRepository, LocalApContentQuery, MovieRepository, ReviewRepository, StatsRepository,
|
||||
UserFederationSettingsQuery,
|
||||
},
|
||||
value_objects::{MovieId, ReviewId, UserId},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
@@ -17,20 +20,33 @@ use crate::urls::{actor_url, goal_url, review_url};
|
||||
pub struct ActivityPubEventHandler {
|
||||
ap_service: Arc<ActivityPubService>,
|
||||
content_query: Arc<dyn LocalApContentQuery>,
|
||||
review_repo: Arc<dyn ReviewRepository>,
|
||||
movie_repo: Arc<dyn MovieRepository>,
|
||||
goal_repo: Arc<dyn GoalRepository>,
|
||||
stats_repo: Arc<dyn StatsRepository>,
|
||||
federation_settings: Arc<dyn UserFederationSettingsQuery>,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl ActivityPubEventHandler {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
ap_service: Arc<ActivityPubService>,
|
||||
content_query: Arc<dyn LocalApContentQuery>,
|
||||
review_repo: Arc<dyn ReviewRepository>,
|
||||
movie_repo: Arc<dyn MovieRepository>,
|
||||
goal_repo: Arc<dyn GoalRepository>,
|
||||
stats_repo: Arc<dyn StatsRepository>,
|
||||
federation_settings: Arc<dyn UserFederationSettingsQuery>,
|
||||
base_url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
ap_service,
|
||||
content_query,
|
||||
review_repo,
|
||||
movie_repo,
|
||||
goal_repo,
|
||||
stats_repo,
|
||||
federation_settings,
|
||||
base_url,
|
||||
}
|
||||
@@ -157,16 +173,12 @@ impl ActivityPubEventHandler {
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or(domain::models::FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
});
|
||||
.unwrap_or_default();
|
||||
if !flags.reviews {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let review = match self.content_query.get_review_by_id(review_id).await? {
|
||||
let review = match self.review_repo.get_review_by_id(review_id).await? {
|
||||
Some(r) => r,
|
||||
None => return Ok(()),
|
||||
};
|
||||
@@ -175,7 +187,7 @@ impl ActivityPubEventHandler {
|
||||
let actor = actor_url(&self.base_url, user_id.value());
|
||||
|
||||
let movie = self
|
||||
.content_query
|
||||
.movie_repo
|
||||
.get_movie_by_id(review.movie_id())
|
||||
.await
|
||||
.ok()
|
||||
@@ -227,16 +239,12 @@ impl ActivityPubEventHandler {
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or(domain::models::FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
});
|
||||
.unwrap_or_default();
|
||||
if !flags.reviews {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let review = match self.content_query.get_review_by_id(review_id).await? {
|
||||
let review = match self.review_repo.get_review_by_id(review_id).await? {
|
||||
Some(r) => r,
|
||||
None => return Ok(()),
|
||||
};
|
||||
@@ -245,7 +253,7 @@ impl ActivityPubEventHandler {
|
||||
let actor = actor_url(&self.base_url, user_id.value());
|
||||
|
||||
let movie = self
|
||||
.content_query
|
||||
.movie_repo
|
||||
.get_movie_by_id(review.movie_id())
|
||||
.await
|
||||
.ok()
|
||||
@@ -310,11 +318,7 @@ impl ActivityPubEventHandler {
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or(domain::models::FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
});
|
||||
.unwrap_or_default();
|
||||
if !flags.watchlist {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -324,7 +328,7 @@ impl ActivityPubEventHandler {
|
||||
let actor = actor_url(&self.base_url, user_id.value());
|
||||
|
||||
let poster_url = self
|
||||
.content_query
|
||||
.movie_repo
|
||||
.get_movie_by_id(movie_id)
|
||||
.await
|
||||
.ok()
|
||||
@@ -373,7 +377,7 @@ impl ActivityPubEventHandler {
|
||||
.get_local_reviews_for_movie(movie_id)
|
||||
.await?;
|
||||
|
||||
let movie = self.content_query.get_movie_by_id(movie_id).await?;
|
||||
let movie = self.movie_repo.get_movie_by_id(movie_id).await?;
|
||||
let movie = match movie {
|
||||
Some(m) => m,
|
||||
None => return Ok(()),
|
||||
@@ -393,11 +397,7 @@ impl ActivityPubEventHandler {
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or(domain::models::FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
});
|
||||
.unwrap_or_default();
|
||||
if !flags.reviews {
|
||||
continue;
|
||||
}
|
||||
@@ -436,23 +436,24 @@ impl ActivityPubEventHandler {
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or(domain::models::FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
});
|
||||
.unwrap_or_default();
|
||||
if !flags.goals {
|
||||
return Ok(());
|
||||
}
|
||||
let Some((goal, current)) = self
|
||||
.content_query
|
||||
.get_goal_with_progress(user_id, year)
|
||||
let Some(goal) = self
|
||||
.goal_repo
|
||||
.find_by_user_and_year(user_id, year)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let current = self
|
||||
.stats_repo
|
||||
.count_reviews_in_year(user_id, year)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let ap_id = goal_url(&self.base_url, user_id.value(), year);
|
||||
let actor = actor_url(&self.base_url, user_id.value());
|
||||
let obj = goal_to_ap_object(
|
||||
@@ -481,21 +482,14 @@ impl ActivityPubEventHandler {
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or(domain::models::FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
});
|
||||
.unwrap_or_default();
|
||||
if !flags.goals {
|
||||
return Ok(());
|
||||
}
|
||||
let current = self
|
||||
.content_query
|
||||
.get_goal_with_progress(user_id, year)
|
||||
.stats_repo
|
||||
.count_reviews_in_year(user_id, year)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|(_, c)| c)
|
||||
.unwrap_or(0);
|
||||
|
||||
let ap_id = goal_url(&self.base_url, user_id.value(), year);
|
||||
@@ -519,11 +513,7 @@ impl ActivityPubEventHandler {
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or(domain::models::FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
});
|
||||
.unwrap_or_default();
|
||||
if !flags.goals {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use async_trait::async_trait;
|
||||
use chrono::DateTime;
|
||||
use domain::{
|
||||
models::RemoteGoalEntry,
|
||||
ports::{LocalApContentQuery, RemoteGoalRepository},
|
||||
ports::{GoalRepository, RemoteGoalRepository},
|
||||
value_objects::UserId,
|
||||
};
|
||||
use k_ap::{ApContentReader, ApObjectHandler};
|
||||
@@ -15,7 +15,7 @@ use crate::urls::{actor_url, goal_url};
|
||||
|
||||
pub struct GoalObjectHandler {
|
||||
pub remote_goal_repo: Arc<dyn RemoteGoalRepository>,
|
||||
pub content_query: Arc<dyn LocalApContentQuery>,
|
||||
pub goal_repo: Arc<dyn GoalRepository>,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ impl ApContentReader for GoalObjectHandler {
|
||||
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<chrono::Utc>)>> {
|
||||
let uid = UserId::from_uuid(user_id);
|
||||
let goals = self
|
||||
.content_query
|
||||
.list_goals_for_user(&uid)
|
||||
.goal_repo
|
||||
.list_for_user(&uid)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
|
||||
|
||||
@@ -51,6 +51,11 @@ pub struct ActivityPubDeps {
|
||||
pub remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
||||
pub remote_goal_repo: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
|
||||
pub local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
|
||||
pub movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
|
||||
pub review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
|
||||
pub diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
|
||||
pub goal_repo: std::sync::Arc<dyn domain::ports::GoalRepository>,
|
||||
pub stats_repo: std::sync::Arc<dyn domain::ports::StatsRepository>,
|
||||
pub user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
|
||||
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
|
||||
pub base_url: String,
|
||||
@@ -68,6 +73,11 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
remote_watchlist_repo,
|
||||
remote_goal_repo,
|
||||
local_ap_content,
|
||||
movie_repo,
|
||||
review_repo,
|
||||
diary_repo,
|
||||
goal_repo,
|
||||
stats_repo,
|
||||
user_repo,
|
||||
federation_settings,
|
||||
base_url,
|
||||
@@ -76,6 +86,8 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
} = deps;
|
||||
let review_handler = std::sync::Arc::new(ReviewObjectHandler {
|
||||
content_query: std::sync::Arc::clone(&local_ap_content),
|
||||
movie_repo: std::sync::Arc::clone(&movie_repo),
|
||||
diary_repo,
|
||||
review_store,
|
||||
event_publisher: std::sync::Arc::clone(&event_publisher),
|
||||
base_url: base_url.clone(),
|
||||
@@ -87,7 +99,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
});
|
||||
let goal_handler = std::sync::Arc::new(goal_handler::GoalObjectHandler {
|
||||
remote_goal_repo,
|
||||
content_query: std::sync::Arc::clone(&local_ap_content),
|
||||
goal_repo: std::sync::Arc::clone(&goal_repo),
|
||||
base_url: base_url.clone(),
|
||||
});
|
||||
let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler {
|
||||
@@ -136,6 +148,10 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new(
|
||||
std::sync::Arc::clone(&concrete),
|
||||
local_ap_content,
|
||||
review_repo,
|
||||
movie_repo,
|
||||
goal_repo,
|
||||
stats_repo,
|
||||
federation_settings,
|
||||
base_url,
|
||||
)) as std::sync::Arc<dyn domain::ports::EventHandler>;
|
||||
|
||||
@@ -4,7 +4,7 @@ use async_trait::async_trait;
|
||||
use domain::{
|
||||
events::DomainEvent,
|
||||
models::ReviewSource,
|
||||
ports::{EventPublisher, LocalApContentQuery},
|
||||
ports::{DiaryRepository, EventPublisher, LocalApContentQuery, MovieRepository},
|
||||
value_objects::{Comment, ExternalMetadataId, MovieId, Rating, ReviewId, UserId},
|
||||
};
|
||||
use k_ap::{ApContentReader, ApObjectHandler};
|
||||
@@ -16,6 +16,8 @@ use crate::urls::{actor_url, review_url};
|
||||
|
||||
pub struct ReviewObjectHandler {
|
||||
pub content_query: Arc<dyn LocalApContentQuery>,
|
||||
pub movie_repo: Arc<dyn MovieRepository>,
|
||||
pub diary_repo: Arc<dyn DiaryRepository>,
|
||||
pub review_store: Arc<dyn RemoteReviewRepository>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
pub base_url: String,
|
||||
@@ -69,7 +71,7 @@ impl ApContentReader for ReviewObjectHandler {
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
||||
self.content_query
|
||||
self.diary_repo
|
||||
.count_local_posts()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))
|
||||
@@ -97,13 +99,18 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
let actor_url_str = obj.attributed_to.to_string();
|
||||
let review_id = ReviewId::generate();
|
||||
let movie_id = if let Some(ref ext_id) = obj.external_metadata_id {
|
||||
match self
|
||||
.content_query
|
||||
.get_movie_by_external_metadata_id(ext_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(movie)) => movie.id().clone(),
|
||||
_ => MovieId::from_uuid(uuid::Uuid::new_v5(
|
||||
let found = if let Ok(ext_meta_id) = ExternalMetadataId::new(ext_id.clone()) {
|
||||
self.movie_repo
|
||||
.get_movie_by_external_id(&ext_meta_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match found {
|
||||
Some(movie) => movie.id().clone(),
|
||||
None => MovieId::from_uuid(uuid::Uuid::new_v5(
|
||||
&uuid::Uuid::NAMESPACE_URL,
|
||||
ext_id.as_bytes(),
|
||||
)),
|
||||
|
||||
@@ -4,7 +4,11 @@ pub use config::PosterFetcherConfig;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, ports::PosterFetcherClient, value_objects::PosterUrl};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{ImageFetcher, PosterFetcherClient},
|
||||
value_objects::PosterUrl,
|
||||
};
|
||||
|
||||
pub struct ReqwestPosterFetcher {
|
||||
client: reqwest::Client,
|
||||
@@ -37,8 +41,32 @@ impl PosterFetcherClient for ReqwestPosterFetcher {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ImageFetcher for ReqwestPosterFetcher {
|
||||
async fn fetch_image(&self, url: &str) -> Result<Vec<u8>, DomainError> {
|
||||
let bytes = self
|
||||
.client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.error_for_status()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create() -> anyhow::Result<std::sync::Arc<dyn domain::ports::PosterFetcherClient>> {
|
||||
Ok(std::sync::Arc::new(ReqwestPosterFetcher::new(
|
||||
PosterFetcherConfig::from_env(),
|
||||
)?))
|
||||
}
|
||||
|
||||
pub fn create_image_fetcher() -> anyhow::Result<std::sync::Arc<dyn domain::ports::ImageFetcher>> {
|
||||
Ok(std::sync::Arc::new(ReqwestPosterFetcher::new(
|
||||
PosterFetcherConfig::from_env(),
|
||||
)?))
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
DiaryEntry, Goal, GoalType, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
|
||||
DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
|
||||
WatchlistWithMovie,
|
||||
},
|
||||
ports::LocalApContentQuery,
|
||||
value_objects::{
|
||||
Comment, ExternalMetadataId, GoalId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
ReviewId, UserId, WatchlistEntryId,
|
||||
},
|
||||
};
|
||||
@@ -82,6 +82,7 @@ struct ReviewRow {
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
@@ -97,6 +98,7 @@ impl ReviewRow {
|
||||
None => ReviewSource::Local,
|
||||
Some(url) => ReviewSource::Remote { actor_url: url },
|
||||
};
|
||||
let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
|
||||
Ok(Review::from_persistence(PersistedReview {
|
||||
id,
|
||||
movie_id,
|
||||
@@ -106,7 +108,7 @@ impl ReviewRow {
|
||||
watched_at,
|
||||
created_at,
|
||||
source,
|
||||
watch_medium: None,
|
||||
watch_medium,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -127,6 +129,7 @@ struct DiaryRow {
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
@@ -149,105 +152,17 @@ impl DiaryRow {
|
||||
watched_at: self.watched_at,
|
||||
created_at: self.created_at,
|
||||
remote_actor_url: self.remote_actor_url,
|
||||
watch_medium: self.watch_medium,
|
||||
}
|
||||
.into_domain()?;
|
||||
Ok(DiaryEntry::new(movie, review))
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_goal(r: &sqlx::postgres::PgRow) -> Result<Goal, DomainError> {
|
||||
let id_str: String = r
|
||||
.try_get("id")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?;
|
||||
let user_id_str: String = r
|
||||
.try_get("user_id")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?;
|
||||
let year: i64 = r
|
||||
.try_get("year")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?;
|
||||
let target: i64 = r.try_get("target_count").map_err(|e| {
|
||||
DomainError::InfrastructureError(format!("Failed to read target_count: {e}"))
|
||||
})?;
|
||||
let goal_type_str: String = r
|
||||
.try_get("goal_type")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?;
|
||||
let created_at_str: String = r
|
||||
.try_get("created_at")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?;
|
||||
|
||||
let id = GoalId::from_uuid(parse_uuid(&id_str)?);
|
||||
let user_id = UserId::from_uuid(parse_uuid(&user_id_str)?);
|
||||
let goal_type: GoalType = goal_type_str.parse()?;
|
||||
let created_at = parse_datetime(&created_at_str)?;
|
||||
|
||||
Ok(Goal::from_persistence(
|
||||
id,
|
||||
user_id,
|
||||
year as u16,
|
||||
target as u32,
|
||||
goal_type,
|
||||
created_at,
|
||||
))
|
||||
}
|
||||
|
||||
async fn count_reviews_in_year(
|
||||
pool: &PgPool,
|
||||
user_id: &UserId,
|
||||
year: u16,
|
||||
) -> Result<u32, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let start = format!("{year}-01-01 00:00:00");
|
||||
let end = format!("{}-01-01 00:00:00", year + 1);
|
||||
|
||||
let count: i64 = sqlx::query(
|
||||
"SELECT COUNT(*) FROM reviews \
|
||||
WHERE user_id = $1 \
|
||||
AND watched_at >= $2::timestamptz \
|
||||
AND watched_at < $3::timestamptz \
|
||||
AND remote_actor_url IS NULL",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&start)
|
||||
.bind(&end)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
})?
|
||||
.try_get(0)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
Ok(count as u32)
|
||||
}
|
||||
|
||||
// ── LocalApContentQuery impl ─────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl LocalApContentQuery for PostgresApContentQuery {
|
||||
async fn get_local_reviews_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL
|
||||
ORDER BY r.created_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_local_watchlist_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -324,7 +239,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.movie_id = $1 AND r.remote_actor_url IS NULL
|
||||
@@ -337,62 +253,6 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||
let id = review_id.value().to_string();
|
||||
sqlx::query_as::<_, ReviewRow>(
|
||||
"SELECT id, movie_id, user_id, rating, comment,
|
||||
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
remote_actor_url
|
||||
FROM reviews WHERE id = $1",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(ReviewRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
let id = movie_id.value().to_string();
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE id = $1",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_movie_by_external_metadata_id(
|
||||
&self,
|
||||
external_id: &str,
|
||||
) -> Result<Option<Movie>, DomainError> {
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE external_metadata_id = $1",
|
||||
)
|
||||
.bind(external_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> Result<u64, DomainError> {
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
Ok(count as u64)
|
||||
}
|
||||
|
||||
async fn get_local_reviews_page(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -409,7 +269,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL AND r.watched_at < $2::timestamptz
|
||||
@@ -428,7 +289,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL
|
||||
@@ -443,45 +305,4 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
};
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_goal_with_progress(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
year: u16,
|
||||
) -> Result<Option<(Goal, u32)>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let y = year as i64;
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT id, user_id, year, target_count, goal_type, \
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \
|
||||
FROM goals WHERE user_id = $1 AND year = $2",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(y)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
let Some(r) = row else { return Ok(None) };
|
||||
|
||||
let goal = row_to_goal(&r)?;
|
||||
let count = count_reviews_in_year(&self.pool, user_id, year).await?;
|
||||
|
||||
Ok(Some((goal, count)))
|
||||
}
|
||||
|
||||
async fn list_goals_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, user_id, year, target_count, goal_type, \
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \
|
||||
FROM goals WHERE user_id = $1 ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
rows.iter().map(row_to_goal).collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use domain::{
|
||||
errors::DomainError,
|
||||
models::{PendingFollowerInfo, RemoteActorInfo},
|
||||
ports::SocialQueryPort,
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
use super::PostgresFederationRepository;
|
||||
@@ -11,9 +12,9 @@ use super::PostgresFederationRepository;
|
||||
impl SocialQueryPort for PostgresFederationRepository {
|
||||
async fn get_accepted_following_urls(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<String>, DomainError> {
|
||||
let user_id_str = user_id.to_string();
|
||||
let user_id_str = user_id.value().to_string();
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT remote_actor_url FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
|
||||
).bind(&user_id_str).fetch_all(&self.pool).await
|
||||
@@ -34,8 +35,8 @@ impl SocialQueryPort for PostgresFederationRepository {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
async fn count_following(&self, user_id: &UserId) -> Result<usize, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
|
||||
)
|
||||
@@ -46,8 +47,8 @@ impl SocialQueryPort for PostgresFederationRepository {
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
async fn count_accepted_followers(&self, user_id: &UserId) -> Result<usize, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'",
|
||||
)
|
||||
@@ -60,9 +61,9 @@ impl SocialQueryPort for PostgresFederationRepository {
|
||||
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<PendingFollowerInfo>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
|
||||
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url FROM ap_followers f JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'pending'",
|
||||
).bind(&uid).fetch_all(&self.pool).await.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
@@ -416,7 +416,8 @@ impl DiaryRepository for PostgresDiaryRepository {
|
||||
"SELECT id, movie_id, user_id, rating, comment,
|
||||
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
remote_actor_url
|
||||
remote_actor_url,
|
||||
watch_medium
|
||||
FROM reviews WHERE movie_id = $1 ORDER BY watched_at ASC",
|
||||
)
|
||||
.bind(&id_str)
|
||||
@@ -464,7 +465,8 @@ impl DiaryRepository for PostgresDiaryRepository {
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1
|
||||
|
||||
@@ -122,10 +122,6 @@ impl GoalRepository for PostgresGoalRepository {
|
||||
|
||||
rows.iter().map(row_to_goal).collect()
|
||||
}
|
||||
|
||||
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
|
||||
count_reviews_in_year(&self.pool, user_id, year).await
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn count_reviews_in_year(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Review, ReviewSource},
|
||||
ports::ReviewRepository,
|
||||
value_objects::{ReviewId, UserId},
|
||||
@@ -27,7 +26,7 @@ impl PostgresReviewRepository {
|
||||
|
||||
#[async_trait]
|
||||
impl ReviewRepository for PostgresReviewRepository {
|
||||
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError> {
|
||||
async fn save_review(&self, review: &Review) -> Result<(), DomainError> {
|
||||
let id = review.id().value().to_string();
|
||||
let movie_id = review.movie_id().value().to_string();
|
||||
let user_id = review.user_id().value().to_string();
|
||||
@@ -57,13 +56,7 @@ impl ReviewRepository for PostgresReviewRepository {
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
Ok(DomainEvent::ReviewLogged {
|
||||
review_id: review.id().clone(),
|
||||
movie_id: review.movie_id().clone(),
|
||||
user_id: review.user_id().clone(),
|
||||
rating: review.rating().clone(),
|
||||
watched_at: *review.watched_at(),
|
||||
})
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||
|
||||
@@ -95,6 +95,10 @@ impl StatsRepository for PostgresStatsRepository {
|
||||
})
|
||||
}
|
||||
|
||||
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
|
||||
crate::goals::count_reviews_in_year(&self.pool, user_id, year).await
|
||||
}
|
||||
|
||||
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
|
||||
@@ -6,4 +6,3 @@ edition = "2024"
|
||||
[dependencies]
|
||||
rss-feed = { package = "rss", version = "2" }
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use application::ports::RssFeedRenderer;
|
||||
use domain::models::DiaryEntry;
|
||||
use domain::ports::RssFeedRenderer;
|
||||
use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder};
|
||||
|
||||
pub struct RssAdapter {
|
||||
|
||||
@@ -2,16 +2,16 @@ use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
DiaryEntry, Goal, GoalType, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
|
||||
DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
|
||||
WatchlistWithMovie,
|
||||
},
|
||||
ports::LocalApContentQuery,
|
||||
value_objects::{
|
||||
Comment, ExternalMetadataId, GoalId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
ReviewId, UserId, WatchlistEntryId,
|
||||
},
|
||||
};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use sqlx::SqlitePool;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct SqliteApContentQuery {
|
||||
@@ -82,6 +82,7 @@ struct ReviewRow {
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
@@ -97,6 +98,7 @@ impl ReviewRow {
|
||||
None => ReviewSource::Local,
|
||||
Some(url) => ReviewSource::Remote { actor_url: url },
|
||||
};
|
||||
let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
|
||||
Ok(Review::from_persistence(PersistedReview {
|
||||
id,
|
||||
movie_id,
|
||||
@@ -106,7 +108,7 @@ impl ReviewRow {
|
||||
watched_at,
|
||||
created_at,
|
||||
source,
|
||||
watch_medium: None,
|
||||
watch_medium,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -127,6 +129,7 @@ struct DiaryRow {
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
@@ -149,6 +152,7 @@ impl DiaryRow {
|
||||
watched_at: self.watched_at,
|
||||
created_at: self.created_at,
|
||||
remote_actor_url: self.remote_actor_url,
|
||||
watch_medium: self.watch_medium,
|
||||
}
|
||||
.into_domain()?;
|
||||
Ok(DiaryEntry::new(movie, review))
|
||||
@@ -190,100 +194,10 @@ impl WatchlistRow {
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_goal(r: &sqlx::sqlite::SqliteRow) -> Result<Goal, DomainError> {
|
||||
let id_str: String = r
|
||||
.try_get("id")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?;
|
||||
let user_id_str: String = r
|
||||
.try_get("user_id")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?;
|
||||
let year: i64 = r
|
||||
.try_get("year")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?;
|
||||
let target: i64 = r.try_get("target_count").map_err(|e| {
|
||||
DomainError::InfrastructureError(format!("Failed to read target_count: {e}"))
|
||||
})?;
|
||||
let goal_type_str: String = r
|
||||
.try_get("goal_type")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?;
|
||||
let created_at_str: String = r
|
||||
.try_get("created_at")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?;
|
||||
|
||||
let id = GoalId::from_uuid(
|
||||
Uuid::parse_str(&id_str)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid goal UUID: {e}")))?,
|
||||
);
|
||||
let user_id = UserId::from_uuid(
|
||||
Uuid::parse_str(&user_id_str)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid user UUID: {e}")))?,
|
||||
);
|
||||
let goal_type: GoalType = goal_type_str.parse()?;
|
||||
let created_at = parse_datetime(&created_at_str)?;
|
||||
|
||||
Ok(Goal::from_persistence(
|
||||
id,
|
||||
user_id,
|
||||
year as u16,
|
||||
target as u32,
|
||||
goal_type,
|
||||
created_at,
|
||||
))
|
||||
}
|
||||
|
||||
async fn count_reviews_in_year(
|
||||
pool: &SqlitePool,
|
||||
user_id: &UserId,
|
||||
year: u16,
|
||||
) -> Result<u32, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let start = format!("{year}-01-01 00:00:00");
|
||||
let end = format!("{}-01-01 00:00:00", year + 1);
|
||||
|
||||
let count: i64 = sqlx::query(
|
||||
"SELECT COUNT(*) FROM reviews \
|
||||
WHERE user_id = ? AND watched_at >= ? AND watched_at < ? \
|
||||
AND remote_actor_url IS NULL",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&start)
|
||||
.bind(&end)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
})?
|
||||
.try_get(0)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
Ok(count as u32)
|
||||
}
|
||||
|
||||
// ── LocalApContentQuery impl ─────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl LocalApContentQuery for SqliteApContentQuery {
|
||||
async fn get_local_reviews_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ? AND r.remote_actor_url IS NULL
|
||||
ORDER BY r.created_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_local_watchlist_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -312,7 +226,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
|
||||
let mid = movie_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.movie_id = ? AND r.remote_actor_url IS NULL
|
||||
@@ -325,59 +239,6 @@ impl LocalApContentQuery for SqliteApContentQuery {
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||
let id = review_id.value().to_string();
|
||||
sqlx::query_as::<_, ReviewRow>(
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url
|
||||
FROM reviews WHERE id = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(ReviewRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
let id = movie_id.value().to_string();
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE id = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_movie_by_external_metadata_id(
|
||||
&self,
|
||||
external_id: &str,
|
||||
) -> Result<Option<Movie>, DomainError> {
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE external_metadata_id = ?",
|
||||
)
|
||||
.bind(external_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> Result<u64, DomainError> {
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
Ok(count as u64)
|
||||
}
|
||||
|
||||
async fn get_local_reviews_page(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -391,7 +252,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
|
||||
let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ? AND r.remote_actor_url IS NULL AND r.watched_at < ?
|
||||
@@ -407,7 +268,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
|
||||
} else {
|
||||
sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ? AND r.remote_actor_url IS NULL
|
||||
@@ -422,43 +283,4 @@ impl LocalApContentQuery for SqliteApContentQuery {
|
||||
};
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_goal_with_progress(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
year: u16,
|
||||
) -> Result<Option<(Goal, u32)>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let y = year as i64;
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT id, user_id, year, target_count, goal_type, created_at \
|
||||
FROM goals WHERE user_id = ? AND year = ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(y)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
let Some(r) = row else { return Ok(None) };
|
||||
|
||||
let goal = row_to_goal(&r)?;
|
||||
let count = count_reviews_in_year(&self.pool, user_id, year).await?;
|
||||
|
||||
Ok(Some((goal, count)))
|
||||
}
|
||||
|
||||
async fn list_goals_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, user_id, year, target_count, goal_type, created_at \
|
||||
FROM goals WHERE user_id = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
rows.iter().map(row_to_goal).collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use domain::{
|
||||
errors::DomainError,
|
||||
models::{PendingFollowerInfo, RemoteActorInfo},
|
||||
ports::SocialQueryPort,
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
use super::SqliteFederationRepository;
|
||||
@@ -11,9 +12,9 @@ use super::SqliteFederationRepository;
|
||||
impl SocialQueryPort for SqliteFederationRepository {
|
||||
async fn get_accepted_following_urls(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<String>, DomainError> {
|
||||
let user_id_str = user_id.to_string();
|
||||
let user_id_str = user_id.value().to_string();
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT remote_actor_url FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
|
||||
).bind(&user_id_str).fetch_all(&self.pool).await
|
||||
@@ -40,8 +41,8 @@ impl SocialQueryPort for SqliteFederationRepository {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
async fn count_following(&self, user_id: &UserId) -> Result<usize, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
|
||||
)
|
||||
@@ -52,8 +53,8 @@ impl SocialQueryPort for SqliteFederationRepository {
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
async fn count_accepted_followers(&self, user_id: &UserId) -> Result<usize, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'",
|
||||
)
|
||||
@@ -66,9 +67,9 @@ impl SocialQueryPort for SqliteFederationRepository {
|
||||
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<PendingFollowerInfo>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
|
||||
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url
|
||||
FROM ap_followers f
|
||||
|
||||
@@ -97,7 +97,8 @@ async fn test_get_accepted_following_urls_returns_only_accepted() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let urls = repo.get_accepted_following_urls(user_id).await.unwrap();
|
||||
let uid = domain::value_objects::UserId::from_uuid(user_id);
|
||||
let urls = repo.get_accepted_following_urls(&uid).await.unwrap();
|
||||
assert_eq!(urls.len(), 1);
|
||||
assert_eq!(urls[0], "https://other.social/users/alice");
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ impl DiaryRepository for SqliteDiaryRepository {
|
||||
.into_domain()?;
|
||||
|
||||
let viewings = sqlx::query_as::<_, ReviewRow>(
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium
|
||||
FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC",
|
||||
)
|
||||
.bind(&id_str)
|
||||
|
||||
@@ -118,10 +118,6 @@ impl GoalRepository for SqliteGoalRepository {
|
||||
|
||||
rows.iter().map(row_to_goal).collect()
|
||||
}
|
||||
|
||||
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
|
||||
count_reviews_in_year(&self.pool, user_id, year).await
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn count_reviews_in_year(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Review, ReviewSource},
|
||||
ports::ReviewRepository,
|
||||
value_objects::{ReviewId, UserId},
|
||||
@@ -27,7 +26,7 @@ impl SqliteReviewRepository {
|
||||
|
||||
#[async_trait]
|
||||
impl ReviewRepository for SqliteReviewRepository {
|
||||
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError> {
|
||||
async fn save_review(&self, review: &Review) -> Result<(), DomainError> {
|
||||
let id = review.id().value().to_string();
|
||||
let movie_id = review.movie_id().value().to_string();
|
||||
let user_id = review.user_id().value().to_string();
|
||||
@@ -57,13 +56,7 @@ impl ReviewRepository for SqliteReviewRepository {
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
Ok(DomainEvent::ReviewLogged {
|
||||
review_id: review.id().clone(),
|
||||
movie_id: review.movie_id().clone(),
|
||||
user_id: review.user_id().clone(),
|
||||
rating: review.rating().clone(),
|
||||
watched_at: *review.watched_at(),
|
||||
})
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||
|
||||
@@ -96,6 +96,10 @@ impl StatsRepository for SqliteStatsRepository {
|
||||
})
|
||||
}
|
||||
|
||||
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
|
||||
crate::goals::count_reviews_in_year(&self.pool, user_id, year).await
|
||||
}
|
||||
|
||||
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
|
||||
@@ -10,4 +10,4 @@ chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
api-types = { workspace = true }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pub use askama;
|
||||
use askama::Template;
|
||||
|
||||
use application::rendering::HtmlPageContext;
|
||||
use api_types::HtmlPageContext;
|
||||
use chrono::Datelike;
|
||||
use domain::models::{
|
||||
DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserTrends,
|
||||
|
||||
Reference in New Issue
Block a user