export feature

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

13
Cargo.lock generated
View File

@@ -1439,6 +1439,18 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "export"
version = "0.1.0"
dependencies = [
"async-trait",
"chrono",
"domain",
"serde_json",
"tokio",
"uuid",
]
[[package]] [[package]]
name = "fancy-regex" name = "fancy-regex"
version = "0.11.0" version = "0.11.0"
@@ -3055,6 +3067,7 @@ dependencies = [
"domain", "domain",
"dotenvy", "dotenvy",
"event-publisher", "event-publisher",
"export",
"http-body-util", "http-body-util",
"infer", "infer",
"metadata", "metadata",

View File

@@ -11,6 +11,7 @@ members = [
"crates/adapters/template-askama", "crates/adapters/template-askama",
"crates/adapters/activitypub", "crates/adapters/activitypub",
"crates/adapters/activitypub-base", "crates/adapters/activitypub-base",
"crates/adapters/export",
"crates/application", "crates/application",
"crates/domain", "crates/domain",
"crates/presentation", "crates/presentation",
@@ -48,6 +49,7 @@ poster-fetcher = { path = "crates/adapters/poster-fetcher" }
poster-storage = { path = "crates/adapters/poster-storage" } poster-storage = { path = "crates/adapters/poster-storage" }
event-publisher = { path = "crates/adapters/event-publisher" } event-publisher = { path = "crates/adapters/event-publisher" }
rss = { path = "crates/adapters/rss" } rss = { path = "crates/adapters/rss" }
export = { path = "crates/adapters/export" }
sqlite = { path = "crates/adapters/sqlite" } sqlite = { path = "crates/adapters/sqlite" }
sqlite-federation = { path = "crates/adapters/sqlite-federation" } sqlite-federation = { path = "crates/adapters/sqlite-federation" }
template-askama = { path = "crates/adapters/template-askama" } template-askama = { path = "crates/adapters/template-askama" }

View File

@@ -1,7 +1,9 @@
use activitypub_federation::{ use activitypub_federation::{
config::Data, config::Data,
fetch::object_id::ObjectId, fetch::object_id::ObjectId,
kinds::activity::{AcceptType, CreateType, DeleteType, FollowType, RejectType, UndoType, UpdateType}, kinds::activity::{
AcceptType, CreateType, DeleteType, FollowType, RejectType, UndoType, UpdateType,
},
traits::Activity, traits::Activity,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -42,10 +44,16 @@ impl Activity for FollowActivity {
let target_domain = match (target_url.host_str(), target_url.port()) { let target_domain = match (target_url.host_str(), target_url.port()) {
(Some(host), Some(port)) => format!("{}:{}", host, port), (Some(host), Some(port)) => format!("{}:{}", host, port),
(Some(host), None) => host.to_string(), (Some(host), None) => host.to_string(),
_ => return Err(Error::bad_request(anyhow::anyhow!("invalid follow target URL"))), _ => {
return Err(Error::bad_request(anyhow::anyhow!(
"invalid follow target URL"
)));
}
}; };
if target_domain != data.domain { if target_domain != data.domain {
return Err(Error::bad_request(anyhow::anyhow!("follow target is not a local actor"))); return Err(Error::bad_request(anyhow::anyhow!(
"follow target is not a local actor"
)));
} }
Ok(()) Ok(())
} }
@@ -105,7 +113,11 @@ impl Activity for AcceptActivity {
let local_user_id = crate::urls::extract_user_id_from_url(self.object.actor.inner()) let local_user_id = crate::urls::extract_user_id_from_url(self.object.actor.inner())
.ok_or_else(|| Error::bad_request(anyhow::anyhow!("invalid actor URL in Follow")))?; .ok_or_else(|| Error::bad_request(anyhow::anyhow!("invalid actor URL in Follow")))?;
data.federation_repo data.federation_repo
.update_following_status(local_user_id, self.actor.inner().as_str(), FollowingStatus::Accepted) .update_following_status(
local_user_id,
self.actor.inner().as_str(),
FollowingStatus::Accepted,
)
.await?; .await?;
tracing::info!(remote_actor = %self.actor.inner(), "follow accepted by remote"); tracing::info!(remote_actor = %self.actor.inner(), "follow accepted by remote");
Ok(()) Ok(())

View File

@@ -3,7 +3,7 @@ use activitypub_federation::{
}; };
use axum::extract::Path; use axum::extract::Path;
use crate::actors::{get_local_actor, Person}; use crate::actors::{Person, get_local_actor};
use crate::data::FederationData; use crate::data::FederationData;
use crate::error::Error; use crate::error::Error;

View File

@@ -63,11 +63,7 @@ pub async fn get_local_actor(
None => { None => {
let kp = generate_actor_keypair()?; let kp = generate_actor_keypair()?;
data.federation_repo data.federation_repo
.save_local_actor_keypair( .save_local_actor_keypair(user_id, kp.public_key.clone(), kp.private_key.clone())
user_id,
kp.public_key.clone(),
kp.private_key.clone(),
)
.await?; .await?;
(kp.public_key, kp.private_key) (kp.public_key, kp.private_key)
} }
@@ -179,10 +175,7 @@ impl Object for DbActor {
Ok(()) Ok(())
} }
async fn from_json( async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
json: Self::Kind,
data: &Data<Self::DataType>,
) -> Result<Self, Self::Error> {
let actor = RemoteActor { let actor = RemoteActor {
url: json.id.inner().to_string(), url: json.id.inner().to_string(),
handle: json.preferred_username.clone(), handle: json.preferred_username.clone(),

View File

@@ -1,5 +1,5 @@
use activitypub_federation::{ use activitypub_federation::{
axum::inbox::{receive_activity, ActivityData}, axum::inbox::{ActivityData, receive_activity},
config::Data, config::Data,
protocol::context::WithContext, protocol::context::WithContext,
}; };
@@ -13,8 +13,6 @@ pub async fn inbox_handler(
data: Data<FederationData>, data: Data<FederationData>,
activity_data: ActivityData, activity_data: ActivityData,
) -> Result<(), Error> { ) -> Result<(), Error> {
receive_activity::<WithContext<InboxActivities>, DbActor, FederationData>( receive_activity::<WithContext<InboxActivities>, DbActor, FederationData>(activity_data, &data)
activity_data, &data,
)
.await .await
} }

View File

@@ -10,14 +10,16 @@ pub mod inbox;
pub mod outbox; pub mod outbox;
pub mod repository; pub mod repository;
pub mod service; pub mod service;
pub(crate) mod urls;
pub mod user; pub mod user;
pub mod webfinger; pub mod webfinger;
pub(crate) mod urls;
pub use content::ApObjectHandler; pub use content::ApObjectHandler;
pub use data::FederationData; pub use data::FederationData;
pub use error::Error; pub use error::Error;
pub use federation::ApFederationConfig; pub use federation::ApFederationConfig;
pub use repository::{FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor}; pub use repository::{
FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
pub use service::ActivityPubService; pub use service::ActivityPubService;
pub use user::{ApUser, ApUserRepository}; pub use user::{ApUser, ApUserRepository};

View File

@@ -31,20 +31,61 @@ pub struct Follower {
#[async_trait] #[async_trait]
pub trait FederationRepository: Send + Sync { pub trait FederationRepository: Send + Sync {
async fn add_follower(&self, local_user_id: uuid::Uuid, remote_actor_url: &str, status: FollowerStatus, follow_activity_id: &str) -> Result<()>; async fn add_follower(
async fn get_follower_follow_activity_id(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<Option<String>>; &self,
async fn remove_follower(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<()>; local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
follow_activity_id: &str,
) -> Result<()>;
async fn get_follower_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>>;
async fn remove_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<()>;
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>>; async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>>;
async fn update_follower_status(&self, local_user_id: uuid::Uuid, remote_actor_url: &str, status: FollowerStatus) -> Result<()>; async fn update_follower_status(
async fn add_following(&self, local_user_id: uuid::Uuid, actor: RemoteActor, follow_activity_id: &str) -> Result<()>; &self,
async fn get_follow_activity_id(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<Option<String>>; local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
) -> Result<()>;
async fn add_following(
&self,
local_user_id: uuid::Uuid,
actor: RemoteActor,
follow_activity_id: &str,
) -> Result<()>;
async fn get_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>>;
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>; async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>; async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize>; async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize>;
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()>; async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()>;
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>>; async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>>;
async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result<Option<(String, String)>>; async fn get_local_actor_keypair(
async fn save_local_actor_keypair(&self, user_id: uuid::Uuid, public_key: String, private_key: String) -> Result<()>; &self,
user_id: uuid::Uuid,
) -> Result<Option<(String, String)>>;
async fn save_local_actor_keypair(
&self,
user_id: uuid::Uuid,
public_key: String,
private_key: String,
) -> Result<()>;
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>; async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
async fn update_following_status(&self, local_user_id: uuid::Uuid, remote_actor_url: &str, status: FollowingStatus) -> Result<()>; async fn update_following_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowingStatus,
) -> Result<()>;
} }

View File

@@ -6,12 +6,12 @@ use activitypub_federation::{
protocol::context::WithContext, protocol::context::WithContext,
traits::Actor, traits::Actor,
}; };
use axum::{routing::get, routing::post, Router}; use axum::{Router, routing::get, routing::post};
use url::Url; use url::Url;
use crate::{ use crate::{
activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity}, activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity},
actors::{get_local_actor, DbActor}, actors::{DbActor, get_local_actor},
content::ApObjectHandler, content::ApObjectHandler,
data::FederationData, data::FederationData,
federation::ApFederationConfig, federation::ApFederationConfig,
@@ -19,8 +19,8 @@ use crate::{
inbox::inbox_handler, inbox::inbox_handler,
outbox::outbox_handler, outbox::outbox_handler,
repository::{FederationRepository, FollowerStatus, FollowingStatus, RemoteActor}, repository::{FederationRepository, FollowerStatus, FollowingStatus, RemoteActor},
user::ApUserRepository,
urls::activity_url, urls::activity_url,
user::ApUserRepository,
webfinger::webfinger_handler, webfinger::webfinger_handler,
}; };
@@ -64,7 +64,10 @@ impl ActivityPubService {
) -> anyhow::Result<Self> { ) -> anyhow::Result<Self> {
let data = FederationData::new(repo, user_repo, object_handler, base_url.clone()); let data = FederationData::new(repo, user_repo, object_handler, base_url.clone());
let federation_config = ApFederationConfig::new(data, debug).await?; let federation_config = ApFederationConfig::new(data, debug).await?;
Ok(Self { federation_config, base_url }) Ok(Self {
federation_config,
base_url,
})
} }
pub fn federation_config(&self) -> &ApFederationConfig { pub fn federation_config(&self) -> &ApFederationConfig {
@@ -82,7 +85,9 @@ impl ActivityPubService {
let actor = get_local_actor(uuid, &data) let actor = get_local_actor(uuid, &data)
.await .await
.map_err(|e| anyhow::anyhow!("{e}"))?; .map_err(|e| anyhow::anyhow!("{e}"))?;
let person = actor.into_json(&data).await let person = actor
.into_json(&data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?; .map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(serde_json::to_string(&WithContext::new_default(person))?) Ok(serde_json::to_string(&WithContext::new_default(person))?)
} }
@@ -133,7 +138,10 @@ impl ActivityPubService {
.await?; .await?;
let failures = send_with_retry(sends, &data).await; let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() { if !failures.is_empty() {
tracing::warn!(count = failures.len(), "some activity deliveries failed permanently"); tracing::warn!(
count = failures.len(),
"some activity deliveries failed permanently"
);
} }
let remote = RemoteActor { let remote = RemoteActor {
@@ -150,11 +158,17 @@ impl ActivityPubService {
Ok(()) Ok(())
} }
pub async fn unfollow(&self, local_user_id: uuid::Uuid, actor_url_str: &str) -> anyhow::Result<()> { pub async fn unfollow(
&self,
local_user_id: uuid::Uuid,
actor_url_str: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
if actor_url_str.starts_with(&self.base_url) { if actor_url_str.starts_with(&self.base_url) {
return self.unfollow_local(local_user_id, actor_url_str, &data).await; return self
.unfollow_local(local_user_id, actor_url_str, &data)
.await;
} }
let remote = data let remote = data
@@ -202,7 +216,10 @@ impl ActivityPubService {
.await?; .await?;
let failures = send_with_retry(sends, &data).await; let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() { if !failures.is_empty() {
tracing::warn!(count = failures.len(), "some activity deliveries failed permanently"); tracing::warn!(
count = failures.len(),
"some activity deliveries failed permanently"
);
} }
data.federation_repo data.federation_repo
@@ -236,7 +253,9 @@ impl ActivityPubService {
.federation_repo .federation_repo
.get_follower_follow_activity_id(local_user_id, remote_actor_url) .get_follower_follow_activity_id(local_user_id, remote_actor_url)
.await? .await?
.ok_or_else(|| anyhow::anyhow!("follow activity id not found for {}", remote_actor_url))?; .ok_or_else(|| {
anyhow::anyhow!("follow activity id not found for {}", remote_actor_url)
})?;
let follow_id = Url::parse(&follow_id_str)?; let follow_id = Url::parse(&follow_id_str)?;
let follow = FollowActivity { let follow = FollowActivity {
id: follow_id, id: follow_id,
@@ -265,7 +284,9 @@ impl ActivityPubService {
.await?; .await?;
let failures = send_with_retry(sends, &data).await; let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() { if !failures.is_empty() {
tracing::warn!("failed to deliver Accept activity, but follower is marked accepted locally"); tracing::warn!(
"failed to deliver Accept activity, but follower is marked accepted locally"
);
} }
self.spawn_backfill(local_user_id, remote_actor.inbox_url.clone()); self.spawn_backfill(local_user_id, remote_actor.inbox_url.clone());
@@ -313,7 +334,10 @@ impl ActivityPubService {
.await?; .await?;
let failures = send_with_retry(sends, &data).await; let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() { if !failures.is_empty() {
tracing::warn!(count = failures.len(), "some activity deliveries failed permanently"); tracing::warn!(
count = failures.len(),
"some activity deliveries failed permanently"
);
} }
data.federation_repo data.federation_repo
@@ -323,12 +347,20 @@ impl ActivityPubService {
Ok(()) Ok(())
} }
pub async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> anyhow::Result<Vec<RemoteActor>> { pub async fn get_pending_followers(
&self,
local_user_id: uuid::Uuid,
) -> anyhow::Result<Vec<RemoteActor>> {
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
data.federation_repo.get_pending_followers(local_user_id).await data.federation_repo
.get_pending_followers(local_user_id)
.await
} }
pub async fn get_accepted_followers(&self, local_user_id: uuid::Uuid) -> anyhow::Result<Vec<RemoteActor>> { pub async fn get_accepted_followers(
&self,
local_user_id: uuid::Uuid,
) -> anyhow::Result<Vec<RemoteActor>> {
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
let followers = data.federation_repo.get_followers(local_user_id).await?; let followers = data.federation_repo.get_followers(local_user_id).await?;
Ok(followers Ok(followers
@@ -338,13 +370,22 @@ impl ActivityPubService {
.collect()) .collect())
} }
pub async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> anyhow::Result<usize> { pub async fn count_accepted_followers(
&self,
local_user_id: uuid::Uuid,
) -> anyhow::Result<usize> {
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
let followers = data.federation_repo.get_followers(local_user_id).await?; let followers = data.federation_repo.get_followers(local_user_id).await?;
Ok(followers.into_iter().filter(|f| f.status == FollowerStatus::Accepted).count()) Ok(followers
.into_iter()
.filter(|f| f.status == FollowerStatus::Accepted)
.count())
} }
pub async fn get_following(&self, local_user_id: uuid::Uuid) -> anyhow::Result<Vec<RemoteActor>> { pub async fn get_following(
&self,
local_user_id: uuid::Uuid,
) -> anyhow::Result<Vec<RemoteActor>> {
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
data.federation_repo.get_following(local_user_id).await data.federation_repo.get_following(local_user_id).await
} }
@@ -354,9 +395,15 @@ impl ActivityPubService {
data.federation_repo.count_following(local_user_id).await data.federation_repo.count_following(local_user_id).await
} }
pub async fn remove_follower(&self, local_user_id: uuid::Uuid, actor_url: &str) -> anyhow::Result<()> { pub async fn remove_follower(
&self,
local_user_id: uuid::Uuid,
actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
data.federation_repo.remove_follower(local_user_id, actor_url).await data.federation_repo
.remove_follower(local_user_id, actor_url)
.await
} }
/// Broadcast a single object to all accepted followers as a Create activity. /// Broadcast a single object to all accepted followers as a Create activity.
@@ -395,10 +442,14 @@ impl ActivityPubService {
.filter_map(|f| Url::parse(&f.actor.inbox_url).ok()) .filter_map(|f| Url::parse(&f.actor.inbox_url).ok())
.collect(); .collect();
let sends = SendActivityTask::prepare(&create_with_ctx, &local_actor, inboxes, &data).await?; let sends =
SendActivityTask::prepare(&create_with_ctx, &local_actor, inboxes, &data).await?;
let failures = send_with_retry(sends, &data).await; let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() { if !failures.is_empty() {
tracing::warn!(count = failures.len(), "some activity deliveries failed permanently"); tracing::warn!(
count = failures.len(),
"some activity deliveries failed permanently"
);
} }
Ok(()) Ok(())
@@ -423,10 +474,17 @@ impl ActivityPubService {
let follower_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string(); let follower_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string();
let target_actor_url = crate::urls::actor_url(&self.base_url, target.id); let target_actor_url = crate::urls::actor_url(&self.base_url, target.id);
let target_inbox_url = format!("{}/inbox", target_actor_url); let target_inbox_url = format!("{}/inbox", target_actor_url);
let follow_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?.to_string(); let follow_id = activity_url(&self.base_url)
.map_err(|e| anyhow::anyhow!("{e}"))?
.to_string();
data.federation_repo data.federation_repo
.add_follower(target.id, &follower_actor_url, FollowerStatus::Accepted, &follow_id) .add_follower(
target.id,
&follower_actor_url,
FollowerStatus::Accepted,
&follow_id,
)
.await?; .await?;
let target_as_remote = RemoteActor { let target_as_remote = RemoteActor {
@@ -441,7 +499,11 @@ impl ActivityPubService {
.await?; .await?;
data.federation_repo data.federation_repo
.update_following_status(local_user_id, &target_actor_url.to_string(), FollowingStatus::Accepted) .update_following_status(
local_user_id,
&target_actor_url.to_string(),
FollowingStatus::Accepted,
)
.await?; .await?;
tracing::info!(follower = %local_user_id, followee = %target.id, "local follow"); tracing::info!(follower = %local_user_id, followee = %target.id, "local follow");
@@ -460,8 +522,12 @@ impl ActivityPubService {
let local_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string(); let local_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string();
data.federation_repo.remove_follower(target_user_id, &local_actor_url).await?; data.federation_repo
data.federation_repo.remove_following(local_user_id, target_actor_url).await?; .remove_follower(target_user_id, &local_actor_url)
.await?;
data.federation_repo
.remove_following(local_user_id, target_actor_url)
.await?;
tracing::info!(follower = %local_user_id, followee = %target_user_id, "local unfollow"); tracing::info!(follower = %local_user_id, followee = %target_user_id, "local unfollow");
Ok(()) Ok(())
@@ -471,7 +537,14 @@ impl ActivityPubService {
let config = self.federation_config.clone(); let config = self.federation_config.clone();
let base_url = self.base_url.clone(); let base_url = self.base_url.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = ActivityPubService::run_backfill(config, base_url, owner_user_id, follower_inbox_url).await { if let Err(e) = ActivityPubService::run_backfill(
config,
base_url,
owner_user_id,
follower_inbox_url,
)
.await
{
tracing::warn!(error = %e, "backfill: task failed"); tracing::warn!(error = %e, "backfill: task failed");
} }
}); });
@@ -491,7 +564,10 @@ impl ActivityPubService {
.map_err(|e| anyhow::anyhow!("{e}"))?; .map_err(|e| anyhow::anyhow!("{e}"))?;
let inbox = Url::parse(&follower_inbox_url)?; let inbox = Url::parse(&follower_inbox_url)?;
let mut objects = data.object_handler.get_local_objects_for_user(owner_user_id).await?; let mut objects = data
.object_handler
.get_local_objects_for_user(owner_user_id)
.await?;
objects.reverse(); // oldest first → chronological feed objects.reverse(); // oldest first → chronological feed
let total = objects.len(); let total = objects.len();
@@ -501,7 +577,9 @@ impl ActivityPubService {
for chunk in objects.chunks(BATCH_SIZE) { for chunk in objects.chunks(BATCH_SIZE) {
for (ap_id, object_json) in chunk { for (ap_id, object_json) in chunk {
// Use a stable Create activity ID derived from the object's ap_id // Use a stable Create activity ID derived from the object's ap_id
let create_id = Url::parse(&format!("{}/activities/create/{}", base_url, let create_id = Url::parse(&format!(
"{}/activities/create/{}",
base_url,
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, ap_id.as_str().as_bytes()) uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, ap_id.as_str().as_bytes())
))?; ))?;
@@ -517,7 +595,8 @@ impl ActivityPubService {
&local_actor, &local_actor,
vec![inbox.clone()], vec![inbox.clone()],
&data, &data,
).await?; )
.await?;
let failures = send_with_retry(sends, &data).await; let failures = send_with_retry(sends, &data).await;
if failures.is_empty() { if failures.is_empty() {
success_count += 1; success_count += 1;

View File

@@ -1,6 +1,6 @@
use activitypub_federation::{ use activitypub_federation::{
config::Data, config::Data,
fetch::webfinger::{build_webfinger_response, extract_webfinger_name, Webfinger}, fetch::webfinger::{Webfinger, build_webfinger_response, extract_webfinger_name},
}; };
use axum::{ use axum::{
extract::Query, extract::Query,
@@ -33,10 +33,6 @@ pub async fn webfinger_handler(
let ap_id = crate::urls::actor_url(&data.base_url, user.id); let ap_id = crate::urls::actor_url(&data.base_url, user.id);
let wf: Webfinger = build_webfinger_response(query.resource, ap_id); let wf: Webfinger = build_webfinger_response(query.resource, ap_id);
let body = serde_json::to_string(&wf) let body = serde_json::to_string(&wf).map_err(|e| Error::from(anyhow::anyhow!(e)))?;
.map_err(|e| Error::from(anyhow::anyhow!(e)))?; Ok(([(header::CONTENT_TYPE, "application/jrd+json")], body).into_response())
Ok((
[(header::CONTENT_TYPE, "application/jrd+json")],
body,
).into_response())
} }

View File

@@ -1,11 +1,11 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::ports::EventHandler;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
ports::{MovieRepository, ReviewRepository}, ports::{MovieRepository, ReviewRepository},
value_objects::{ReviewId, UserId}, value_objects::{ReviewId, UserId},
}; };
use domain::ports::EventHandler;
use std::sync::Arc; use std::sync::Arc;
use activitypub_base::ActivityPubService; use activitypub_base::ActivityPubService;
@@ -27,7 +27,12 @@ impl ActivityPubEventHandler {
review_repository: Arc<dyn ReviewRepository>, review_repository: Arc<dyn ReviewRepository>,
base_url: String, base_url: String,
) -> Self { ) -> Self {
Self { ap_service, movie_repository, review_repository, base_url } Self {
ap_service,
movie_repository,
review_repository,
base_url,
}
} }
} }
@@ -35,7 +40,9 @@ impl ActivityPubEventHandler {
impl EventHandler for ActivityPubEventHandler { impl EventHandler for ActivityPubEventHandler {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
match event { match event {
DomainEvent::ReviewLogged { review_id, user_id, .. } => self DomainEvent::ReviewLogged {
review_id, user_id, ..
} => self
.on_review_logged(user_id, review_id) .on_review_logged(user_id, review_id)
.await .await
.map_err(|e| DomainError::InfrastructureError(e.to_string())), .map_err(|e| DomainError::InfrastructureError(e.to_string())),
@@ -45,11 +52,7 @@ impl EventHandler for ActivityPubEventHandler {
} }
impl ActivityPubEventHandler { impl ActivityPubEventHandler {
async fn on_review_logged( async fn on_review_logged(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> {
&self,
user_id: &UserId,
review_id: &ReviewId,
) -> anyhow::Result<()> {
let review = match self.review_repository.get_review_by_id(review_id).await? { let review = match self.review_repository.get_review_by_id(review_id).await? {
Some(r) => r, Some(r) => r,
None => return Ok(()), None => return Ok(()),
@@ -58,16 +61,33 @@ impl ActivityPubEventHandler {
let ap_id = review_url(&self.base_url, review_id); let ap_id = review_url(&self.base_url, review_id);
let actor = actor_url(&self.base_url, user_id.value()); let actor = actor_url(&self.base_url, user_id.value());
let movie = self.movie_repository.get_movie_by_id(review.movie_id()).await.ok().flatten(); let movie = self
let movie_title = movie.as_ref() .movie_repository
.get_movie_by_id(review.movie_id())
.await
.ok()
.flatten();
let movie_title = movie
.as_ref()
.map(|m| m.title().value().to_string()) .map(|m| m.title().value().to_string())
.unwrap_or_else(|| "Unknown".to_string()); .unwrap_or_else(|| "Unknown".to_string());
let release_year = movie.as_ref().map(|m| m.release_year().value()).unwrap_or(0); let release_year = movie
let poster_url = movie.as_ref() .as_ref()
.map(|m| m.release_year().value())
.unwrap_or(0);
let poster_url = movie
.as_ref()
.and_then(|m| m.poster_path()) .and_then(|m| m.poster_path())
.map(|p| format!("{}/posters/{}", self.base_url, p.value())); .map(|p| format!("{}/posters/{}", self.base_url, p.value()));
let obj = review_to_ap_object(&review, ap_id.clone(), actor, movie_title, release_year, poster_url); let obj = review_to_ap_object(
&review,
ap_id.clone(),
actor,
movie_title,
release_year,
poster_url,
);
let json = serde_json::to_value(obj)?; let json = serde_json::to_value(obj)?;
self.ap_service self.ap_service

View File

@@ -3,8 +3,8 @@ pub mod objects;
pub mod port; pub mod port;
pub mod remote_review_repository; pub mod remote_review_repository;
pub mod review_handler; pub mod review_handler;
pub mod user_adapter;
pub(crate) mod urls; pub(crate) mod urls;
pub mod user_adapter;
// Re-export the generic base types that callers need // Re-export the generic base types that callers need
pub use activitypub_base::{ pub use activitypub_base::{

View File

@@ -36,10 +36,17 @@ pub fn review_to_ap_object(
) -> ReviewObject { ) -> ReviewObject {
let stars: String = "\u{2B50}".repeat(review.rating().value() as usize); let stars: String = "\u{2B50}".repeat(review.rating().value() as usize);
let comment_text = review.comment().map(|c| c.value().to_string()); let comment_text = review.comment().map(|c| c.value().to_string());
let year_str = if release_year > 0 { format!(" ({})", release_year) } else { String::new() }; let year_str = if release_year > 0 {
format!(" ({})", release_year)
} else {
String::new()
};
let watched_str = format!("Watched: {}", review.watched_at().format("%b %-d, %Y")); let watched_str = format!("Watched: {}", review.watched_at().format("%b %-d, %Y"));
let content = match &comment_text { let content = match &comment_text {
Some(c) => format!("{} {}{}\n{}\n{}", stars, movie_title, year_str, c, watched_str), Some(c) => format!(
"{} {}{}\n{}\n{}",
stars, movie_title, year_str, c, watched_str
),
None => format!("{} {}{}\n{}", stars, movie_title, year_str, watched_str), None => format!("{} {}{}\n{}", stars, movie_title, year_str, watched_str),
}; };

View File

@@ -11,10 +11,19 @@ pub trait ActivityPubPort: Send + Sync {
async fn get_pending_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>; async fn get_pending_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()>; async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()>;
async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>; async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn accept_follower(&self, local_user_id: Uuid, remote_actor_url: &str) -> anyhow::Result<()>; async fn accept_follower(
async fn reject_follower(&self, local_user_id: Uuid, remote_actor_url: &str) -> anyhow::Result<()>; &self,
local_user_id: Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()>;
async fn reject_follower(
&self,
local_user_id: Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()>;
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>; async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
async fn get_accepted_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>; async fn get_accepted_followers(&self, local_user_id: Uuid)
-> anyhow::Result<Vec<RemoteActor>>;
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>; async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
} }
@@ -38,16 +47,27 @@ impl ActivityPubPort for ActivityPubService {
async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> { async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
self.unfollow(local_user_id, actor_url).await self.unfollow(local_user_id, actor_url).await
} }
async fn accept_follower(&self, local_user_id: Uuid, remote_actor_url: &str) -> anyhow::Result<()> { async fn accept_follower(
&self,
local_user_id: Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()> {
self.accept_follower(local_user_id, remote_actor_url).await self.accept_follower(local_user_id, remote_actor_url).await
} }
async fn reject_follower(&self, local_user_id: Uuid, remote_actor_url: &str) -> anyhow::Result<()> { async fn reject_follower(
&self,
local_user_id: Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()> {
self.reject_follower(local_user_id, remote_actor_url).await self.reject_follower(local_user_id, remote_actor_url).await
} }
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> { async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
self.get_following(local_user_id).await self.get_following(local_user_id).await
} }
async fn get_accepted_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> { async fn get_accepted_followers(
&self,
local_user_id: Uuid,
) -> anyhow::Result<Vec<RemoteActor>> {
self.get_accepted_followers(local_user_id).await self.get_accepted_followers(local_user_id).await
} }
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> { async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
@@ -59,15 +79,37 @@ pub struct NoopActivityPubService;
#[async_trait] #[async_trait]
impl ActivityPubPort for NoopActivityPubService { impl ActivityPubPort for NoopActivityPubService {
async fn actor_json(&self, _: &str) -> anyhow::Result<String> { Ok(String::new()) } async fn actor_json(&self, _: &str) -> anyhow::Result<String> {
async fn count_following(&self, _: Uuid) -> anyhow::Result<usize> { Ok(0) } Ok(String::new())
async fn count_accepted_followers(&self, _: Uuid) -> anyhow::Result<usize> { Ok(0) } }
async fn get_pending_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> { Ok(vec![]) } async fn count_following(&self, _: Uuid) -> anyhow::Result<usize> {
async fn follow(&self, _: Uuid, _: &str) -> anyhow::Result<()> { Ok(()) } Ok(0)
async fn unfollow(&self, _: Uuid, _: &str) -> anyhow::Result<()> { Ok(()) } }
async fn accept_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> { Ok(()) } async fn count_accepted_followers(&self, _: Uuid) -> anyhow::Result<usize> {
async fn reject_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> { Ok(()) } Ok(0)
async fn get_following(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> { Ok(vec![]) } }
async fn get_accepted_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> { Ok(vec![]) } async fn get_pending_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> { Ok(()) } Ok(vec![])
}
async fn follow(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn unfollow(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn accept_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn reject_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_following(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn get_accepted_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
} }

View File

@@ -9,7 +9,7 @@ use domain::{
}; };
use url::Url; use url::Url;
use crate::objects::{review_to_ap_object, ReviewObject}; use crate::objects::{ReviewObject, review_to_ap_object};
use crate::remote_review_repository::RemoteReviewRepository; use crate::remote_review_repository::RemoteReviewRepository;
use crate::urls::{actor_url, review_url}; use crate::urls::{actor_url, review_url};
@@ -27,7 +27,10 @@ impl ApObjectHandler for ReviewObjectHandler {
user_id: uuid::Uuid, user_id: uuid::Uuid,
) -> anyhow::Result<Vec<(Url, serde_json::Value)>> { ) -> anyhow::Result<Vec<(Url, serde_json::Value)>> {
let domain_user_id = UserId::from_uuid(user_id); let domain_user_id = UserId::from_uuid(user_id);
let history = self.diary_repository.get_user_history(&domain_user_id).await?; let history = self
.diary_repository
.get_user_history(&domain_user_id)
.await?;
let mut results = Vec::new(); let mut results = Vec::new();
for entry in history { for entry in history {
@@ -39,18 +42,33 @@ impl ApObjectHandler for ReviewObjectHandler {
let ap_id = review_url(&self.base_url, review.id()); let ap_id = review_url(&self.base_url, review.id());
let actor_url = actor_url(&self.base_url, user_id); let actor_url = actor_url(&self.base_url, user_id);
let movie = self.movie_repository.get_movie_by_id(review.movie_id()).await.ok().flatten(); let movie = self
let movie_title = movie.as_ref() .movie_repository
.get_movie_by_id(review.movie_id())
.await
.ok()
.flatten();
let movie_title = movie
.as_ref()
.map(|m| m.title().value().to_string()) .map(|m| m.title().value().to_string())
.unwrap_or_else(|| "Unknown".to_string()); .unwrap_or_else(|| "Unknown".to_string());
let release_year = movie.as_ref() let release_year = movie
.as_ref()
.map(|m| m.release_year().value()) .map(|m| m.release_year().value())
.unwrap_or(0); .unwrap_or(0);
let poster_url = movie.as_ref() let poster_url = movie
.as_ref()
.and_then(|m| m.poster_path()) .and_then(|m| m.poster_path())
.map(|p| format!("{}/posters/{}", self.base_url, p.value())); .map(|p| format!("{}/posters/{}", self.base_url, p.value()));
let obj = review_to_ap_object(review, ap_id.clone(), actor_url, movie_title, release_year, poster_url); let obj = review_to_ap_object(
review,
ap_id.clone(),
actor_url,
movie_title,
release_year,
poster_url,
);
let json = serde_json::to_value(obj)?; let json = serde_json::to_value(obj)?;
results.push((ap_id, json)); results.push((ap_id, json));
} }
@@ -73,8 +91,14 @@ impl ApObjectHandler for ReviewObjectHandler {
let actor_url_str = obj.attributed_to.to_string(); let actor_url_str = obj.attributed_to.to_string();
let review_id = ReviewId::generate(); let review_id = ReviewId::generate();
let movie_id = MovieId::from_uuid(uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, obj.movie_title.as_bytes())); let movie_id = MovieId::from_uuid(uuid::Uuid::new_v5(
let user_id = UserId::from_uuid(uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, actor_url_str.as_bytes())); &uuid::Uuid::NAMESPACE_URL,
obj.movie_title.as_bytes(),
));
let user_id = UserId::from_uuid(uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
actor_url_str.as_bytes(),
));
let rating = Rating::new(obj.rating.min(5))?; let rating = Rating::new(obj.rating.min(5))?;
let comment = obj.comment.map(Comment::new).transpose()?; let comment = obj.comment.map(Comment::new).transpose()?;
@@ -86,11 +110,19 @@ impl ApObjectHandler for ReviewObjectHandler {
comment, comment,
obj.watched_at.naive_utc(), obj.watched_at.naive_utc(),
obj.published.naive_utc(), obj.published.naive_utc(),
ReviewSource::Remote { actor_url: actor_url_str }, ReviewSource::Remote {
actor_url: actor_url_str,
},
); );
self.review_store self.review_store
.save_remote_review(&review, obj.id.as_str(), &obj.movie_title, obj.release_year, obj.poster_url.as_deref()) .save_remote_review(
&review,
obj.id.as_str(),
&obj.movie_title,
obj.release_year,
obj.poster_url.as_deref(),
)
.await?; .await?;
Ok(()) Ok(())

View File

@@ -1,5 +1,5 @@
use url::Url;
use domain::value_objects::ReviewId; use domain::value_objects::ReviewId;
use url::Url;
/// Builds the canonical actor URL: `{base_url}/users/{user_id}` /// Builds the canonical actor URL: `{base_url}/users/{user_id}`
pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url { pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url {

View File

@@ -18,8 +18,8 @@ impl ApUserRepository for DomainUserRepoAdapter {
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> { async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> {
use domain::value_objects::Username; use domain::value_objects::Username;
let uname = Username::new(username.to_string()) let uname =
.map_err(|e| anyhow::anyhow!(e.to_string()))?; Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?;
Ok(self.0.find_by_username(&uname).await?.map(|u| ApUser { Ok(self.0.find_by_username(&uname).await?.map(|u| ApUser {
id: u.id().value(), id: u.id().value(),
username: u.username().value().to_string(), username: u.username().value().to_string(),

View File

@@ -1,8 +1,8 @@
use async_trait::async_trait;
use argon2::{ use argon2::{
Argon2, Argon2,
password_hash::{PasswordHasher as _, PasswordVerifier, SaltString}, password_hash::{PasswordHasher as _, PasswordVerifier, SaltString},
}; };
use async_trait::async_trait;
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use rand_core::OsRng; use rand_core::OsRng;
@@ -31,7 +31,10 @@ impl AuthConfig {
.ok() .ok()
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(86400u64); .unwrap_or(86400u64);
Ok(Self { secret, ttl_seconds }) Ok(Self {
secret,
ttl_seconds,
})
} }
} }

View File

@@ -120,12 +120,12 @@ pub fn create_event_channel(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::sync::{Arc, Mutex};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
value_objects::{ExternalMetadataId, MovieId}, value_objects::{ExternalMetadataId, MovieId},
}; };
use std::sync::{Arc, Mutex};
struct RecordingHandler { struct RecordingHandler {
calls: Arc<Mutex<Vec<String>>>, calls: Arc<Mutex<Vec<String>>>,
@@ -147,7 +147,9 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn single_handler_receives_event() { async fn single_handler_receives_event() {
let calls = Arc::new(Mutex::new(vec![])); let calls = Arc::new(Mutex::new(vec![]));
let handler = RecordingHandler { calls: Arc::clone(&calls) }; let handler = RecordingHandler {
calls: Arc::clone(&calls),
};
let config = EventPublisherConfig { channel_buffer: 8 }; let config = EventPublisherConfig { channel_buffer: 8 };
let (publisher, worker) = create_event_channel(config, vec![Box::new(handler)]); let (publisher, worker) = create_event_channel(config, vec![Box::new(handler)]);
@@ -168,13 +170,15 @@ mod tests {
async fn multiple_handlers_all_receive_event() { async fn multiple_handlers_all_receive_event() {
let calls1 = Arc::new(Mutex::new(vec![])); let calls1 = Arc::new(Mutex::new(vec![]));
let calls2 = Arc::new(Mutex::new(vec![])); let calls2 = Arc::new(Mutex::new(vec![]));
let handler1 = RecordingHandler { calls: Arc::clone(&calls1) }; let handler1 = RecordingHandler {
let handler2 = RecordingHandler { calls: Arc::clone(&calls2) }; calls: Arc::clone(&calls1),
};
let handler2 = RecordingHandler {
calls: Arc::clone(&calls2),
};
let config = EventPublisherConfig { channel_buffer: 8 }; let config = EventPublisherConfig { channel_buffer: 8 };
let (publisher, worker) = create_event_channel( let (publisher, worker) =
config, create_event_channel(config, vec![Box::new(handler1), Box::new(handler2)]);
vec![Box::new(handler1), Box::new(handler2)],
);
let handle = tokio::spawn(worker.run()); let handle = tokio::spawn(worker.run());
@@ -201,12 +205,12 @@ mod tests {
} }
let calls = Arc::new(Mutex::new(vec![])); let calls = Arc::new(Mutex::new(vec![]));
let good = RecordingHandler { calls: Arc::clone(&calls) }; let good = RecordingHandler {
calls: Arc::clone(&calls),
};
let config = EventPublisherConfig { channel_buffer: 8 }; let config = EventPublisherConfig { channel_buffer: 8 };
let (publisher, worker) = create_event_channel( let (publisher, worker) =
config, create_event_channel(config, vec![Box::new(FailingHandler), Box::new(good)]);
vec![Box::new(FailingHandler), Box::new(good)],
);
let handle = tokio::spawn(worker.run()); let handle = tokio::spawn(worker.run());

View File

@@ -0,0 +1,14 @@
[package]
name = "export"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
[dev-dependencies]
uuid = { workspace = true }
tokio = { workspace = true }

View File

@@ -0,0 +1,225 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{DiaryEntry, ExportFormat},
ports::DiaryExporter,
};
pub struct ExportAdapter;
#[async_trait]
impl DiaryExporter for ExportAdapter {
async fn serialize_entries(
&self,
entries: &[DiaryEntry],
format: ExportFormat,
) -> Result<Vec<u8>, DomainError> {
match format {
ExportFormat::Csv => serialize_csv(entries),
ExportFormat::Json => serialize_json(entries),
}
}
}
fn serialize_csv(entries: &[DiaryEntry]) -> Result<Vec<u8>, DomainError> {
let mut out =
String::from("title,year,director,rating,comment,watched_at,external_metadata_id\n");
for e in entries {
let title = csv_escape(e.movie().title().value());
let year = e.movie().release_year().value();
let director = e.movie().director().map(csv_escape).unwrap_or_default();
let rating = e.review().rating().value();
let comment = e
.review()
.comment()
.map(|c| csv_escape(c.value()))
.unwrap_or_default();
let watched_at = e.review().watched_at().format("%Y-%m-%d");
let ext_id = e
.movie()
.external_metadata_id()
.map(|id| id.value().to_string())
.unwrap_or_default();
out.push_str(&format!(
"{},{},{},{},{},{},{}\n",
title, year, director, rating, comment, watched_at, ext_id
));
}
Ok(out.into_bytes())
}
fn csv_escape(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
fn serialize_json(entries: &[DiaryEntry]) -> Result<Vec<u8>, DomainError> {
let arr: Vec<serde_json::Value> = entries
.iter()
.map(|e| {
serde_json::json!({
"title": e.movie().title().value(),
"year": e.movie().release_year().value(),
"director": e.movie().director(),
"rating": e.review().rating().value(),
"comment": e.review().comment().map(|c| c.value()),
"watched_at": e.review().watched_at().format("%Y-%m-%d").to_string(),
"external_metadata_id": e.movie().external_metadata_id().map(|id| id.value()),
})
})
.collect();
serde_json::to_vec_pretty(&arr).map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::ExportAdapter;
use domain::{
models::{DiaryEntry, ExportFormat, Movie, Review},
ports::DiaryExporter,
value_objects::{ExternalMetadataId, MovieTitle, Rating, ReleaseYear},
};
fn make_entry(
title: &str,
year: u16,
director: Option<&str>,
rating: u8,
comment: Option<&str>,
) -> DiaryEntry {
make_entry_full(title, year, director, rating, comment, None)
}
fn make_entry_full(
title: &str,
year: u16,
director: Option<&str>,
rating: u8,
comment: Option<&str>,
external_id: Option<&str>,
) -> DiaryEntry {
let movie = Movie::new(
external_id.map(|id| ExternalMetadataId::new(id.to_string()).unwrap()),
MovieTitle::new(title.to_string()).unwrap(),
ReleaseYear::new(year).unwrap(),
director.map(str::to_string),
None,
);
let user_id = domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4());
let review = Review::new(
movie.id().clone(),
user_id,
Rating::new(rating).unwrap(),
comment.map(|c| domain::value_objects::Comment::new(c.to_string()).unwrap()),
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap(),
)
.unwrap();
DiaryEntry::new(movie, review)
}
#[tokio::test]
async fn csv_has_header_and_one_row() {
let adapter = ExportAdapter;
let entry = make_entry(
"Inception",
2010,
Some("Christopher Nolan"),
5,
Some("great"),
);
let bytes = adapter
.serialize_entries(&[entry], ExportFormat::Csv)
.await
.unwrap();
let text = String::from_utf8(bytes).unwrap();
assert!(
text.starts_with(
"title,year,director,rating,comment,watched_at,external_metadata_id\n"
)
);
assert!(text.contains("Inception"));
assert!(text.contains("2010"));
assert!(text.contains("Christopher Nolan"));
assert!(text.contains("5"));
assert!(text.contains("great"));
assert!(text.contains("2024-03-15"));
}
#[tokio::test]
async fn csv_escapes_commas_in_title() {
let adapter = ExportAdapter;
let entry = make_entry("Tár, A Film", 2022, None, 4, None);
let bytes = adapter
.serialize_entries(&[entry], ExportFormat::Csv)
.await
.unwrap();
let text = String::from_utf8(bytes).unwrap();
assert!(text.contains("\"Tár, A Film\""));
}
#[tokio::test]
async fn json_is_valid_array() {
let adapter = ExportAdapter;
let entry = make_entry("Dune", 2021, Some("Denis Villeneuve"), 5, None);
let bytes = adapter
.serialize_entries(&[entry], ExportFormat::Json)
.await
.unwrap();
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["title"], "Dune");
assert_eq!(arr[0]["year"], 2021);
assert_eq!(arr[0]["rating"], 5);
assert_eq!(arr[0]["comment"], serde_json::Value::Null);
assert_eq!(arr[0]["external_metadata_id"], serde_json::Value::Null);
}
#[tokio::test]
async fn external_metadata_id_included_when_present() {
let adapter = ExportAdapter;
let entry = make_entry_full("Alien", 1979, None, 5, None, Some("tt0078748"));
let bytes = adapter
.serialize_entries(&[entry], ExportFormat::Json)
.await
.unwrap();
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
assert_eq!(arr[0]["external_metadata_id"], "tt0078748");
let bytes = adapter
.serialize_entries(
&[make_entry_full(
"Alien",
1979,
None,
5,
None,
Some("tt0078748"),
)],
ExportFormat::Csv,
)
.await
.unwrap();
let text = String::from_utf8(bytes).unwrap();
assert!(text.contains("tt0078748"));
}
#[tokio::test]
async fn empty_entries_returns_csv_header_only() {
let adapter = ExportAdapter;
let bytes = adapter
.serialize_entries(&[], ExportFormat::Csv)
.await
.unwrap();
let text = String::from_utf8(bytes).unwrap();
assert_eq!(
text,
"title,year,director,rating,comment,watched_at,external_metadata_id\n"
);
}
}

View File

@@ -40,7 +40,13 @@ impl MetadataClient for MetadataClientImpl {
criteria: &MetadataSearchCriteria, criteria: &MetadataSearchCriteria,
) -> Result<Movie, DomainError> { ) -> Result<Movie, DomainError> {
let pm = self.provider.fetch(criteria).await?; let pm = self.provider.fetch(criteria).await?;
Ok(Movie::new(Some(pm.imdb_id), pm.title, pm.release_year, pm.director, None)) Ok(Movie::new(
Some(pm.imdb_id),
pm.title,
pm.release_year,
pm.director,
None,
))
} }
async fn get_poster_url( async fn get_poster_url(

View File

@@ -101,8 +101,8 @@ impl MetadataProvider for OmdbProvider {
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let title = MovieTitle::new(resp.title) let title = MovieTitle::new(resp.title)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let release_year = ReleaseYear::new(year) let release_year =
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; ReleaseYear::new(year).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let director = match resp.director.as_str() { let director = match resp.director.as_str() {
"N/A" | "" => None, "N/A" | "" => None,
@@ -114,6 +114,12 @@ impl MetadataProvider for OmdbProvider {
url => PosterUrl::new(url.to_string()).ok(), url => PosterUrl::new(url.to_string()).ok(),
}; };
Ok(ProviderMovie { imdb_id, title, release_year, director, poster_url }) Ok(ProviderMovie {
imdb_id,
title,
release_year,
director,
poster_url,
})
} }
} }

View File

@@ -22,9 +22,9 @@ impl StorageConfig {
&std::env::var("POSTER_STORAGE_PATH") &std::env::var("POSTER_STORAGE_PATH")
.context("POSTER_STORAGE_PATH required when POSTER_STORAGE_BACKEND=local")?, .context("POSTER_STORAGE_PATH required when POSTER_STORAGE_BACKEND=local")?,
)?, )?,
other => anyhow::bail!( other => {
"Unknown POSTER_STORAGE_BACKEND: {other:?}. Valid values: s3, local" anyhow::bail!("Unknown POSTER_STORAGE_BACKEND: {other:?}. Valid values: s3, local")
), }
}; };
Ok(Self(store)) Ok(Self(store))
@@ -55,8 +55,7 @@ fn build_s3_store(
} }
fn build_local_store(path: &str) -> anyhow::Result<Arc<dyn ObjectStore>> { fn build_local_store(path: &str) -> anyhow::Result<Arc<dyn ObjectStore>> {
std::fs::create_dir_all(path) std::fs::create_dir_all(path).context("Failed to create poster storage directory")?;
.context("Failed to create poster storage directory")?;
let store = LocalFileSystem::new_with_prefix(path) let store = LocalFileSystem::new_with_prefix(path)
.context("Failed to initialise local file system store")?; .context("Failed to initialise local file system store")?;
Ok(Arc::new(store)) Ok(Arc::new(store))
@@ -68,8 +67,7 @@ mod tests {
#[test] #[test]
fn local_store_creates_dir_and_succeeds() { fn local_store_creates_dir_and_succeeds() {
let dir = std::env::temp_dir() let dir = std::env::temp_dir().join(format!("poster_test_{}", uuid::Uuid::new_v4()));
.join(format!("poster_test_{}", uuid::Uuid::new_v4()));
let result = build_local_store(dir.to_str().unwrap()); let result = build_local_store(dir.to_str().unwrap());
assert!(result.is_ok(), "expected Ok, got: {:?}", result.err()); assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
assert!(dir.exists(), "directory should have been created"); assert!(dir.exists(), "directory should have been created");
@@ -77,8 +75,7 @@ mod tests {
#[test] #[test]
fn local_store_succeeds_if_dir_already_exists() { fn local_store_succeeds_if_dir_already_exists() {
let dir = std::env::temp_dir() let dir = std::env::temp_dir().join(format!("poster_test_{}", uuid::Uuid::new_v4()));
.join(format!("poster_test_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap(); std::fs::create_dir_all(&dir).unwrap();
let result = build_local_store(dir.to_str().unwrap()); let result = build_local_store(dir.to_str().unwrap());
assert!(result.is_ok()); assert!(result.is_ok());

View File

@@ -7,7 +7,7 @@ use domain::{
ports::PosterStorage, ports::PosterStorage,
value_objects::{MovieId, PosterPath}, value_objects::{MovieId, PosterPath},
}; };
use object_store::{Attribute, Attributes, PutOptions, path::Path, ObjectStore}; use object_store::{Attribute, Attributes, ObjectStore, PutOptions, path::Path};
use std::sync::Arc; use std::sync::Arc;
fn detect_mime(bytes: &[u8]) -> &'static str { fn detect_mime(bytes: &[u8]) -> &'static str {
@@ -41,7 +41,10 @@ impl PosterStorage for PosterStorageAdapter {
let mime = detect_mime(image_bytes); let mime = detect_mime(image_bytes);
let mut attributes = Attributes::new(); let mut attributes = Attributes::new();
attributes.insert(Attribute::ContentType, mime.into()); attributes.insert(Attribute::ContentType, mime.into());
let opts = PutOptions { attributes, ..Default::default() }; let opts = PutOptions {
attributes,
..Default::default()
};
self.store self.store
.put_opts(&path, image_bytes.to_vec().into(), opts) .put_opts(&path, image_bytes.to_vec().into(), opts)
.await .await
@@ -52,7 +55,9 @@ impl PosterStorage for PosterStorageAdapter {
async fn get_poster(&self, poster_path: &PosterPath) -> Result<Vec<u8>, DomainError> { async fn get_poster(&self, poster_path: &PosterPath) -> Result<Vec<u8>, DomainError> {
let path = Path::from(poster_path.value().to_string()); let path = Path::from(poster_path.value().to_string());
let result = self.store.get(&path).await.map_err(|e| match e { let result = self.store.get(&path).await.map_err(|e| match e {
object_store::Error::NotFound { .. } => DomainError::NotFound("Poster not found".into()), object_store::Error::NotFound { .. } => {
DomainError::NotFound("Poster not found".into())
}
_ => DomainError::InfrastructureError(e.to_string()), _ => DomainError::InfrastructureError(e.to_string()),
})?; })?;
result result

View File

@@ -1,10 +1,12 @@
use anyhow::{anyhow, Result}; use anyhow::{Result, anyhow};
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
use sqlx::{Row, SqlitePool}; use sqlx::{Row, SqlitePool};
use activitypub_base::{FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor};
use activitypub::RemoteReviewRepository; use activitypub::RemoteReviewRepository;
use activitypub_base::{
FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
use domain::models::{Review, ReviewSource}; use domain::models::{Review, ReviewSource};
fn datetime_to_str(dt: &NaiveDateTime) -> String { fn datetime_to_str(dt: &NaiveDateTime) -> String {
@@ -85,7 +87,11 @@ impl FederationRepository for SqliteFederationRepository {
Ok(row.flatten()) Ok(row.flatten())
} }
async fn remove_follower(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<()> { async fn remove_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<()> {
let uid = local_user_id.to_string(); let uid = local_user_id.to_string();
sqlx::query("DELETE FROM ap_followers WHERE local_user_id = ? AND remote_actor_url = ?") sqlx::query("DELETE FROM ap_followers WHERE local_user_id = ? AND remote_actor_url = ?")
.bind(&uid) .bind(&uid)
@@ -116,11 +122,18 @@ impl FederationRepository for SqliteFederationRepository {
let status_str: String = row.get("status"); let status_str: String = row.get("status");
let handle: String = row.try_get("handle").unwrap_or_default(); let handle: String = row.try_get("handle").unwrap_or_default();
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default(); let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
let shared_inbox_url: Option<String> = row.try_get("shared_inbox_url").ok().flatten(); let shared_inbox_url: Option<String> =
row.try_get("shared_inbox_url").ok().flatten();
let display_name: Option<String> = row.try_get("display_name").ok().flatten(); let display_name: Option<String> = row.try_get("display_name").ok().flatten();
Follower { Follower {
actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name }, actor: RemoteActor {
url,
handle,
inbox_url,
shared_inbox_url,
display_name,
},
status: str_to_status(&status_str), status: str_to_status(&status_str),
} }
}) })
@@ -154,7 +167,12 @@ impl FederationRepository for SqliteFederationRepository {
Ok(()) Ok(())
} }
async fn add_following(&self, local_user_id: uuid::Uuid, actor: RemoteActor, follow_activity_id: &str) -> Result<()> { async fn add_following(
&self,
local_user_id: uuid::Uuid,
actor: RemoteActor,
follow_activity_id: &str,
) -> Result<()> {
let uid = local_user_id.to_string(); let uid = local_user_id.to_string();
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now); let created_at = datetime_to_str(&now);
@@ -175,7 +193,11 @@ impl FederationRepository for SqliteFederationRepository {
Ok(()) Ok(())
} }
async fn get_follow_activity_id(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<Option<String>> { async fn get_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> {
let uid = local_user_id.to_string(); let uid = local_user_id.to_string();
let row: Option<Option<String>> = sqlx::query_scalar( let row: Option<Option<String>> = sqlx::query_scalar(
"SELECT follow_activity_id FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?", "SELECT follow_activity_id FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?",
@@ -210,13 +232,16 @@ impl FederationRepository for SqliteFederationRepository {
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
Ok(rows.into_iter().map(|row| RemoteActor { Ok(rows
.into_iter()
.map(|row| RemoteActor {
url: row.get("url"), url: row.get("url"),
handle: row.get("handle"), handle: row.get("handle"),
inbox_url: row.get("inbox_url"), inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(), display_name: row.try_get("display_name").ok().flatten(),
}).collect()) })
.collect())
} }
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> { async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
@@ -274,16 +299,25 @@ impl FederationRepository for SqliteFederationRepository {
})) }))
} }
async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result<Option<(String, String)>> { async fn get_local_actor_keypair(
&self,
user_id: uuid::Uuid,
) -> Result<Option<(String, String)>> {
let uid = user_id.to_string(); let uid = user_id.to_string();
let row = sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = ?") let row =
sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = ?")
.bind(&uid) .bind(&uid)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await?; .await?;
Ok(row.map(|r| (r.get("public_key"), r.get("private_key")))) Ok(row.map(|r| (r.get("public_key"), r.get("private_key"))))
} }
async fn save_local_actor_keypair(&self, user_id: uuid::Uuid, public_key: String, private_key: String) -> Result<()> { async fn save_local_actor_keypair(
&self,
user_id: uuid::Uuid,
public_key: String,
private_key: String,
) -> Result<()> {
let uid = user_id.to_string(); let uid = user_id.to_string();
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now); let created_at = datetime_to_str(&now);
@@ -319,13 +353,16 @@ impl FederationRepository for SqliteFederationRepository {
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
Ok(rows.into_iter().map(|row| RemoteActor { Ok(rows
.into_iter()
.map(|row| RemoteActor {
url: row.get("remote_actor_url"), url: row.get("remote_actor_url"),
handle: row.try_get("handle").unwrap_or_default(), handle: row.try_get("handle").unwrap_or_default(),
inbox_url: row.try_get("inbox_url").unwrap_or_default(), inbox_url: row.try_get("inbox_url").unwrap_or_default(),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(), display_name: row.try_get("display_name").ok().flatten(),
}).collect()) })
.collect())
} }
async fn update_following_status( async fn update_following_status(

View File

@@ -3,8 +3,8 @@ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{ models::{
DiaryEntry, DiaryFilter, DirectorStat, FeedEntry, Movie, MonthlyRating, DiaryEntry, DiaryFilter, DirectorStat, FeedEntry, MonthlyRating, Movie, Review,
Review, ReviewHistory, ReviewSource, SortDirection, UserStats, UserTrends, ReviewHistory, ReviewSource, SortDirection, UserStats, UserTrends,
collections::{PageParams, Paginated}, collections::{PageParams, Paginated},
}, },
ports::{DiaryRepository, MovieRepository, ReviewRepository, StatsRepository}, ports::{DiaryRepository, MovieRepository, ReviewRepository, StatsRepository},
@@ -17,20 +17,31 @@ mod models;
mod users; mod users;
use models::{ use models::{
DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, ReviewRow, DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, ReviewRow, UserTotalsRow,
UserTotalsRow, datetime_to_str, datetime_to_str,
}; };
pub use users::SqliteUserRepository; pub use users::SqliteUserRepository;
fn format_year_month(ym: &str) -> String { fn format_year_month(ym: &str) -> String {
let parts: Vec<&str> = ym.splitn(2, '-').collect(); let parts: Vec<&str> = ym.splitn(2, '-').collect();
if parts.len() != 2 { return ym.to_string(); } if parts.len() != 2 {
return ym.to_string();
}
let year = parts[0].get(2..).unwrap_or(parts[0]); let year = parts[0].get(2..).unwrap_or(parts[0]);
let month = match parts[1] { let month = match parts[1] {
"01" => "Jan", "02" => "Feb", "03" => "Mar", "04" => "Apr", "01" => "Jan",
"05" => "May", "06" => "Jun", "07" => "Jul", "08" => "Aug", "02" => "Feb",
"09" => "Sep", "10" => "Oct", "11" => "Nov", "12" => "Dec", "03" => "Mar",
"04" => "Apr",
"05" => "May",
"06" => "Jun",
"07" => "Jul",
"08" => "Aug",
"09" => "Sep",
"10" => "Oct",
"11" => "Nov",
"12" => "Dec",
_ => parts[1], _ => parts[1],
}; };
format!("{} '{}", month, year) format!("{} '{}", month, year)
@@ -60,12 +71,10 @@ impl SqliteMovieRepository {
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(Self::map_err), .map_err(Self::map_err),
Some(id) => { Some(id) => sqlx::query_scalar!("SELECT COUNT(*) FROM reviews WHERE movie_id = ?", id)
sqlx::query_scalar!("SELECT COUNT(*) FROM reviews WHERE movie_id = ?", id)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(Self::map_err) .map_err(Self::map_err),
}
} }
} }
@@ -155,10 +164,7 @@ impl SqliteMovieRepository {
} }
async fn count_user_diary_entries(&self, user_id: &str) -> Result<i64, DomainError> { async fn count_user_diary_entries(&self, user_id: &str) -> Result<i64, DomainError> {
sqlx::query_scalar!( sqlx::query_scalar!("SELECT COUNT(*) FROM reviews WHERE user_id = ?", user_id)
"SELECT COUNT(*) FROM reviews WHERE user_id = ?",
user_id
)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(Self::map_err) .map_err(Self::map_err)
@@ -215,11 +221,7 @@ impl SqliteMovieRepository {
.map_err(Self::map_err) .map_err(Self::map_err)
} }
async fn fetch_feed_rows( async fn fetch_feed_rows(&self, limit: i64, offset: i64) -> Result<Vec<FeedRow>, DomainError> {
&self,
limit: i64,
offset: i64,
) -> Result<Vec<FeedRow>, DomainError> {
sqlx::query_as!( sqlx::query_as!(
FeedRow, FeedRow,
r#"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, r#"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
@@ -451,11 +453,21 @@ impl ReviewRepository for SqliteMovieRepository {
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
Ok(()) Ok(())
} }
async fn get_all_reviews_for_user(
&self,
_user_id: &UserId,
) -> Result<Vec<Review>, DomainError> {
todo!()
}
} }
#[async_trait] #[async_trait]
impl DiaryRepository for SqliteMovieRepository { impl DiaryRepository for SqliteMovieRepository {
async fn query_diary(&self, filter: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> { async fn query_diary(
&self,
filter: &DiaryFilter,
) -> Result<Paginated<DiaryEntry>, DomainError> {
let limit = filter.page.limit as i64; let limit = filter.page.limit as i64;
let offset = filter.page.offset as i64; let offset = filter.page.offset as i64;
@@ -647,9 +659,16 @@ impl StatsRepository for SqliteMovieRepository {
let top_directors = director_rows let top_directors = director_rows
.into_iter() .into_iter()
.map(|d| DirectorStat { director: d.director, count: d.count }) .map(|d| DirectorStat {
director: d.director,
count: d.count,
})
.collect(); .collect();
Ok(UserTrends { monthly_ratings, top_directors, max_director_count }) Ok(UserTrends {
monthly_ratings,
top_directors,
max_director_count,
})
} }
} }

View File

@@ -2,20 +2,22 @@ use async_trait::async_trait;
use chrono::Utc; use chrono::Utc;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use super::models::UserSummaryRow;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::User, models::User,
ports::UserRepository, ports::UserRepository,
value_objects::{Email, PasswordHash, UserId, Username}, value_objects::{Email, PasswordHash, UserId, Username},
}; };
use super::models::UserSummaryRow;
pub struct SqliteUserRepository { pub struct SqliteUserRepository {
pool: SqlitePool, pool: SqlitePool,
} }
impl SqliteUserRepository { impl SqliteUserRepository {
pub fn new(pool: SqlitePool) -> Self { Self { pool } } pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError { fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e); tracing::error!("Database error: {:?}", e);
@@ -30,13 +32,18 @@ impl SqliteUserRepository {
) -> Result<User, DomainError> { ) -> Result<User, DomainError> {
let id = uuid::Uuid::parse_str(&id_str) let id = uuid::Uuid::parse_str(&id_str)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let email = Email::new(email_str) let email =
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; Email::new(email_str).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let username = Username::new(username_str) let username = Username::new(username_str)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let hash = PasswordHash::new(hash_str) let hash = PasswordHash::new(hash_str)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(User::from_persistence(UserId::from_uuid(id), email, username, hash)) Ok(User::from_persistence(
UserId::from_uuid(id),
email,
username,
hash,
))
} }
} }
@@ -52,7 +59,14 @@ impl UserRepository for SqliteUserRepository {
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
row.map(|r| Self::row_to_user(r.id.unwrap_or_default(), r.email, r.username, r.password_hash)) row.map(|r| {
Self::row_to_user(
r.id.unwrap_or_default(),
r.email,
r.username,
r.password_hash,
)
})
.transpose() .transpose()
} }
@@ -66,18 +80,29 @@ impl UserRepository for SqliteUserRepository {
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
row.map(|r| Self::row_to_user(r.id.unwrap_or_default(), r.email, r.username, r.password_hash)) row.map(|r| {
Self::row_to_user(
r.id.unwrap_or_default(),
r.email,
r.username,
r.password_hash,
)
})
.transpose() .transpose()
} }
async fn save(&self, user: &User) -> Result<(), DomainError> { async fn save(&self, user: &User) -> Result<(), DomainError> {
// Check email uniqueness first (clearer error than INSERT OR IGNORE) // Check email uniqueness first (clearer error than INSERT OR IGNORE)
if self.find_by_email(user.email()).await?.is_some() { if self.find_by_email(user.email()).await?.is_some() {
return Err(DomainError::ValidationError("Email already registered".into())); return Err(DomainError::ValidationError(
"Email already registered".into(),
));
} }
// Check username uniqueness // Check username uniqueness
if self.find_by_username(user.username()).await?.is_some() { if self.find_by_username(user.username()).await?.is_some() {
return Err(DomainError::ValidationError("Username already taken".into())); return Err(DomainError::ValidationError(
"Username already taken".into(),
));
} }
let id = user.id().value().to_string(); let id = user.id().value().to_string();
@@ -107,7 +132,14 @@ impl UserRepository for SqliteUserRepository {
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
row.map(|r| Self::row_to_user(r.id.unwrap_or_default(), r.email, r.username, r.password_hash)) row.map(|r| {
Self::row_to_user(
r.id.unwrap_or_default(),
r.email,
r.username,
r.password_hash,
)
})
.transpose() .transpose()
} }
@@ -175,10 +207,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let result = repo let result = repo.find_by_id(&UserId::from_uuid(id)).await.unwrap();
.find_by_id(&UserId::from_uuid(id))
.await
.unwrap();
assert!(result.is_some()); assert!(result.is_some());
assert_eq!(result.unwrap().email().value(), "test@example.com"); assert_eq!(result.unwrap().email().value(), "test@example.com");
} }

View File

@@ -1,12 +1,12 @@
use askama::Template;
use chrono::Datelike;
use application::ports::{ use application::ports::{
ActivityFeedPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer, ActivityFeedPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer,
LoginPageData, NewReviewPageData, ProfilePageData, RegisterPageData, UsersPageData, LoginPageData, NewReviewPageData, ProfilePageData, RegisterPageData, UsersPageData,
}; };
use askama::Template;
use chrono::Datelike;
use domain::models::{ use domain::models::{
DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserTrends,
UserTrends, collections::Paginated, collections::Paginated,
}; };
struct PageItem { struct PageItem {
@@ -31,9 +31,17 @@ fn build_page_items(total_pages: u32, current_page: u32) -> Vec<PageItem> {
let mut items = Vec::new(); let mut items = Vec::new();
for (i, &p) in pages.iter().enumerate() { for (i, &p) in pages.iter().enumerate() {
if i > 0 && p > pages[i - 1] + 1 { if i > 0 && p > pages[i - 1] + 1 {
items.push(PageItem { number: 0, is_current: false, is_ellipsis: true }); items.push(PageItem {
number: 0,
is_current: false,
is_ellipsis: true,
});
} }
items.push(PageItem { number: p, is_current: p == current_page, is_ellipsis: false }); items.push(PageItem {
number: p,
is_current: p == current_page,
is_ellipsis: false,
});
} }
items items
} }
@@ -162,40 +170,71 @@ struct HeatmapCell {
fn relative_time(dt: chrono::NaiveDateTime) -> String { fn relative_time(dt: chrono::NaiveDateTime) -> String {
let now = chrono::Utc::now().naive_utc(); let now = chrono::Utc::now().naive_utc();
let diff = now.signed_duration_since(dt); let diff = now.signed_duration_since(dt);
if diff.num_seconds() <= 0 { return "just now".to_string(); } if diff.num_seconds() <= 0 {
return "just now".to_string();
}
let minutes = diff.num_minutes(); let minutes = diff.num_minutes();
let hours = diff.num_hours(); let hours = diff.num_hours();
let days = diff.num_days(); let days = diff.num_days();
if minutes < 1 { return "just now".to_string(); } if minutes < 1 {
if minutes < 60 { return format!("{} min ago", minutes); } return "just now".to_string();
if hours < 24 { return format!("{} h ago", hours); } }
if days == 1 { return "yesterday".to_string(); } if minutes < 60 {
if days < 30 { return format!("{} days ago", days); } 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() dt.format("%b %-d, %Y").to_string()
} }
fn build_heatmap(history: &[MonthActivity]) -> Vec<HeatmapCell> { fn build_heatmap(history: &[MonthActivity]) -> Vec<HeatmapCell> {
let current_year = chrono::Utc::now().year(); let current_year = chrono::Utc::now().year();
let count_for = |m: &str| -> i64 { let count_for = |m: &str| -> i64 {
history.iter().find(|a| a.year_month == format!("{}-{}", current_year, m)) history
.iter()
.find(|a| a.year_month == format!("{}-{}", current_year, m))
.map(|a| a.count) .map(|a| a.count)
.unwrap_or(0) .unwrap_or(0)
}; };
let months = [ let months = [
("01", "Jan"), ("02", "Feb"), ("03", "Mar"), ("04", "Apr"), ("01", "Jan"),
("05", "May"), ("06", "Jun"), ("07", "Jul"), ("08", "Aug"), ("02", "Feb"),
("09", "Sep"), ("10", "Oct"), ("11", "Nov"), ("12", "Dec"), ("03", "Mar"),
("04", "Apr"),
("05", "May"),
("06", "Jun"),
("07", "Jul"),
("08", "Aug"),
("09", "Sep"),
("10", "Oct"),
("11", "Nov"),
("12", "Dec"),
]; ];
let counts: Vec<i64> = months.iter().map(|(m, _)| count_for(m)).collect(); let counts: Vec<i64> = months.iter().map(|(m, _)| count_for(m)).collect();
let max = counts.iter().copied().max().unwrap_or(0).max(1); let max = counts.iter().copied().max().unwrap_or(0).max(1);
months.iter().zip(counts.iter()).map(|((_, label), &count)| { months
let alpha = if count == 0 { 0.05 } else { 0.15 + 0.75 * (count as f64 / max as f64) }; .iter()
.zip(counts.iter())
.map(|((_, label), &count)| {
let alpha = if count == 0 {
0.05
} else {
0.15 + 0.75 * (count as f64 / max as f64)
};
HeatmapCell { HeatmapCell {
month_label: label.to_string(), month_label: label.to_string(),
count, count,
alpha, alpha,
} }
}).collect() })
.collect()
} }
fn bar_height_px(avg_rating: f64) -> i64 { fn bar_height_px(avg_rating: f64) -> i64 {
@@ -211,7 +250,11 @@ impl AskamaHtmlRenderer {
} }
impl HtmlRenderer for AskamaHtmlRenderer { impl HtmlRenderer for AskamaHtmlRenderer {
fn render_diary_page(&self, data: &Paginated<DiaryEntry>, ctx: HtmlPageContext) -> Result<String, String> { fn render_diary_page(
&self,
data: &Paginated<DiaryEntry>,
ctx: HtmlPageContext,
) -> Result<String, String> {
let has_more = (data.offset + data.limit) < data.total_count as u32; let has_more = (data.offset + data.limit) < data.total_count as u32;
let (total_pages, current_page) = if data.limit > 0 { let (total_pages, current_page) = if data.limit > 0 {
let tp = ((data.total_count + data.limit as u64 - 1) / data.limit as u64) as u32; let tp = ((data.total_count + data.limit as u64 - 1) / data.limit as u64) as u32;
@@ -262,8 +305,14 @@ impl HtmlRenderer for AskamaHtmlRenderer {
let limit = data.limit; let limit = data.limit;
let total_pages = if limit > 0 { let total_pages = if limit > 0 {
((data.entries.total_count + limit as u64 - 1) / limit as u64) as u32 ((data.entries.total_count + limit as u64 - 1) / limit as u64) as u32
} else { 0 }; } else {
let current_page = if limit > 0 { data.current_offset / limit } else { 0 }; 0
};
let current_page = if limit > 0 {
data.current_offset / limit
} else {
0
};
ActivityFeedTemplate { ActivityFeedTemplate {
entries: &data.entries.items, entries: &data.entries.items,
current_offset: data.current_offset, current_offset: data.current_offset,
@@ -277,11 +326,19 @@ impl HtmlRenderer for AskamaHtmlRenderer {
} }
fn render_users_page(&self, data: UsersPageData) -> Result<String, String> { fn render_users_page(&self, data: UsersPageData) -> Result<String, String> {
let users: Vec<UserSummaryView> = data.users.iter().map(|u| { let users: Vec<UserSummaryView> = data
.users
.iter()
.map(|u| {
let email = u.email(); let email = u.email();
let display_name = email.split('@').next().unwrap_or(email).to_string(); let display_name = email.split('@').next().unwrap_or(email).to_string();
let initial = display_name.chars().next().unwrap_or('?').to_ascii_uppercase(); let initial = display_name
let avg_rating_display = u.avg_rating .chars()
.next()
.unwrap_or('?')
.to_ascii_uppercase();
let avg_rating_display = u
.avg_rating
.map(|r| format!("{:.1}", r)) .map(|r| format!("{:.1}", r))
.unwrap_or_else(|| "".to_string()); .unwrap_or_else(|| "".to_string());
UserSummaryView { UserSummaryView {
@@ -291,7 +348,8 @@ impl HtmlRenderer for AskamaHtmlRenderer {
avg_rating_display, avg_rating_display,
total_movies: u.total_movies, total_movies: u.total_movies,
} }
}).collect(); })
.collect();
UsersTemplate { UsersTemplate {
users, users,
ctx: &data.ctx, ctx: &data.ctx,
@@ -301,29 +359,60 @@ impl HtmlRenderer for AskamaHtmlRenderer {
} }
fn render_profile_page(&self, data: ProfilePageData) -> Result<String, String> { fn render_profile_page(&self, data: ProfilePageData) -> Result<String, String> {
let heatmap = data.history.as_deref() let heatmap = data
.history
.as_deref()
.map(|h| build_heatmap(h)) .map(|h| build_heatmap(h))
.unwrap_or_default(); .unwrap_or_default();
let profile_display_name = data.profile_user_email let profile_display_name = data
.split('@').next().unwrap_or(&data.profile_user_email).to_string(); .profile_user_email
let monthly_rating_rows: Vec<MonthlyRatingRow<'_>> = data.trends.as_ref() .split('@')
.map(|t| t.monthly_ratings.iter().map(|r| MonthlyRatingRow { .next()
.unwrap_or(&data.profile_user_email)
.to_string();
let monthly_rating_rows: Vec<MonthlyRatingRow<'_>> = data
.trends
.as_ref()
.map(|t| {
t.monthly_ratings
.iter()
.map(|r| MonthlyRatingRow {
bar_height_px: bar_height_px(r.avg_rating), bar_height_px: bar_height_px(r.avg_rating),
rating: r, rating: r,
}).collect()) })
.collect()
})
.unwrap_or_default(); .unwrap_or_default();
let total_pages = data.entries.as_ref() let total_pages = data
.map(|e| if e.limit > 0 { ((e.total_count + e.limit as u64 - 1) / e.limit as u64) as u32 } else { 0 }) .entries
.as_ref()
.map(|e| {
if e.limit > 0 {
((e.total_count + e.limit as u64 - 1) / e.limit as u64) as u32
} else {
0
}
})
.unwrap_or(0); .unwrap_or(0);
let current_page = if data.limit > 0 { data.current_offset / data.limit } else { 0 }; let current_page = if data.limit > 0 {
let avg_rating_display = data.stats.avg_rating data.current_offset / data.limit
} else {
0
};
let avg_rating_display = data
.stats
.avg_rating
.map(|r| format!("{:.1}", r)) .map(|r| format!("{:.1}", r))
.unwrap_or_else(|| "".to_string()); .unwrap_or_else(|| "".to_string());
let favorite_director_display = data.stats.favorite_director let favorite_director_display = data
.stats
.favorite_director
.as_deref() .as_deref()
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
let most_active_month_display = data.stats.most_active_month let most_active_month_display = data
.stats
.most_active_month
.as_deref() .as_deref()
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
@@ -349,11 +438,15 @@ impl HtmlRenderer for AskamaHtmlRenderer {
error: data.error, error: data.error,
following_count: data.following_count, following_count: data.following_count,
followers_count: data.followers_count, followers_count: data.followers_count,
pending_followers: data.pending_followers.into_iter().map(|a| RemoteActorData { pending_followers: data
.pending_followers
.into_iter()
.map(|a| RemoteActorData {
handle: a.handle, handle: a.handle,
url: a.url, url: a.url,
display_name: a.display_name, display_name: a.display_name,
}).collect(), })
.collect(),
} }
.render() .render()
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
@@ -363,11 +456,15 @@ impl HtmlRenderer for AskamaHtmlRenderer {
FollowingTemplate { FollowingTemplate {
ctx: data.ctx, ctx: data.ctx,
user_id: data.user_id, user_id: data.user_id,
actors: data.actors.into_iter().map(|a| RemoteActorData { actors: data
.actors
.into_iter()
.map(|a| RemoteActorData {
handle: a.handle, handle: a.handle,
display_name: a.display_name, display_name: a.display_name,
url: a.url, url: a.url,
}).collect(), })
.collect(),
error: data.error, error: data.error,
} }
.render() .render()
@@ -378,11 +475,15 @@ impl HtmlRenderer for AskamaHtmlRenderer {
FollowersTemplate { FollowersTemplate {
ctx: data.ctx, ctx: data.ctx,
user_id: data.user_id, user_id: data.user_id,
actors: data.actors.into_iter().map(|a| RemoteActorData { actors: data
.actors
.into_iter()
.map(|a| RemoteActorData {
handle: a.handle, handle: a.handle,
display_name: a.display_name, display_name: a.display_name,
url: a.url, url: a.url,
}).collect(), })
.collect(),
error: data.error, error: data.error,
} }
.render() .render()

View File

@@ -58,6 +58,11 @@
</ul> </ul>
</section> </section>
{% endif %} {% endif %}
<section class="export-section">
<h3>Export diary</h3>
<a href="/diary/export?format=csv">Download CSV</a>
<a href="/diary/export?format=json">Download JSON</a>
</section>
{% endif %} {% endif %}
<div class="view-tabs"> <div class="view-tabs">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,8 @@ use crate::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{ models::{
DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, User, UserStats, DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, Movie, Review, ReviewHistory, User,
UserSummary, UserTrends, UserStats, UserSummary, UserTrends,
collections::{PageParams, Paginated}, collections::{PageParams, Paginated},
}, },
value_objects::{ value_objects::{
@@ -36,12 +36,17 @@ pub trait ReviewRepository: Send + Sync {
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError>; async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError>;
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>; async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>;
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>; async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>;
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError>;
} }
#[async_trait] #[async_trait]
pub trait DiaryRepository: Send + Sync { pub trait DiaryRepository: Send + Sync {
async fn query_diary(&self, filter: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError>; async fn query_diary(&self, filter: &DiaryFilter)
async fn query_activity_feed(&self, page: &PageParams) -> Result<Paginated<FeedEntry>, DomainError>; -> Result<Paginated<DiaryEntry>, DomainError>;
async fn query_activity_feed(
&self,
page: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError>;
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError>; async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError>;
async fn get_user_history(&self, user_id: &UserId) -> Result<Vec<DiaryEntry>, DomainError>; async fn get_user_history(&self, user_id: &UserId) -> Result<Vec<DiaryEntry>, DomainError>;
} }
@@ -122,7 +127,11 @@ pub trait PasswordHasher: Send + Sync {
#[async_trait] #[async_trait]
pub trait DiaryExporter: Send + Sync { pub trait DiaryExporter: Send + Sync {
async fn serialize_reviews(&self, reviews: &[Review]) -> Result<Vec<u8>, DomainError>; async fn serialize_entries(
&self,
entries: &[DiaryEntry],
format: ExportFormat,
) -> Result<Vec<u8>, DomainError>;
} }
#[async_trait] #[async_trait]

View File

@@ -184,10 +184,18 @@ impl Username {
let s = raw.trim().to_lowercase(); let s = raw.trim().to_lowercase();
if s.len() < Self::MIN_LENGTH || s.len() > Self::MAX_LENGTH { if s.len() < Self::MIN_LENGTH || s.len() > Self::MAX_LENGTH {
return Err(DomainError::ValidationError( return Err(DomainError::ValidationError(
format!("Username must be {}{} characters", Self::MIN_LENGTH, Self::MAX_LENGTH).into(), format!(
"Username must be {}{} characters",
Self::MIN_LENGTH,
Self::MAX_LENGTH
)
.into(),
)); ));
} }
if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(DomainError::ValidationError( return Err(DomainError::ValidationError(
"Username may only contain letters, digits, underscores, and hyphens".into(), "Username may only contain letters, digits, underscores, and hyphens".into(),
)); ));

View File

@@ -32,6 +32,7 @@ sqlx = { workspace = true }
template-askama = { workspace = true } template-askama = { workspace = true }
event-publisher = { workspace = true } event-publisher = { workspace = true }
rss = { workspace = true } rss = { workspace = true }
export = { workspace = true }
infer = "0.19.0" infer = "0.19.0"
percent-encoding = "2" percent-encoding = "2"

View File

@@ -259,6 +259,16 @@ pub struct ProfileQueryParams {
pub error: Option<String>, pub error: Option<String>,
} }
#[derive(serde::Deserialize)]
pub struct ExportQueryParams {
#[serde(default = "default_export_format")]
pub format: String,
}
fn default_export_format() -> String {
"csv".to_string()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -345,7 +355,10 @@ mod tests {
movie_id: None, movie_id: None,
}; };
let query = GetDiaryQuery::from(params); let query = GetDiaryQuery::from(params);
assert!(matches!(query.sort_by, Some(domain::models::SortDirection::Ascending))); assert!(matches!(
query.sort_by,
Some(domain::models::SortDirection::Ascending)
));
} }
#[test] #[test]
@@ -357,7 +370,10 @@ mod tests {
movie_id: None, movie_id: None,
}; };
let query = GetDiaryQuery::from(params); let query = GetDiaryQuery::from(params);
assert!(matches!(query.sort_by, Some(domain::models::SortDirection::Descending))); assert!(matches!(
query.sort_by,
Some(domain::models::SortDirection::Descending)
));
} }
#[test] #[test]

View File

@@ -2,8 +2,8 @@ use std::time::Duration;
use application::{commands::SyncPosterCommand, context::AppContext, use_cases::sync_poster}; use application::{commands::SyncPosterCommand, context::AppContext, use_cases::sync_poster};
use async_trait::async_trait; use async_trait::async_trait;
use domain::{errors::DomainError, events::DomainEvent};
use domain::ports::EventHandler; use domain::ports::EventHandler;
use domain::{errors::DomainError, events::DomainEvent};
pub struct PosterSyncHandler { pub struct PosterSyncHandler {
ctx: AppContext, ctx: AppContext,

View File

@@ -28,11 +28,7 @@ where
"Missing or invalid auth token".into(), "Missing or invalid auth token".into(),
)) ))
})?; })?;
let user_id = app_state let user_id = app_state.app_ctx.auth_service.validate_token(token).await?;
.app_ctx
.auth_service
.validate_token(token)
.await?;
Ok(AuthenticatedUser(user_id)) Ok(AuthenticatedUser(user_id))
} }
} }
@@ -98,28 +94,32 @@ where
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::sync::Arc; use application::{config::AppConfig, context::AppContext};
use axum::{ use axum::{
Router,
body::Body, body::Body,
http::{Request, StatusCode}, http::{Request, StatusCode},
routing::get, routing::get,
Router,
}; };
use application::{config::AppConfig, context::AppContext};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, UserStats, UserTrends, collections::{PageParams, Paginated}}, models::{
DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, UserStats,
UserTrends,
collections::{PageParams, Paginated},
},
ports::{ ports::{
AuthService, DiaryRepository, EventPublisher, GeneratedToken, MetadataClient, AuthService, DiaryRepository, EventPublisher, GeneratedToken, MetadataClient,
MovieRepository, PasswordHasher, PosterFetcherClient, PosterStorage, MovieRepository, PasswordHasher, PosterFetcherClient, PosterStorage, ReviewRepository,
ReviewRepository, StatsRepository, UserRepository, StatsRepository, UserRepository,
}, },
value_objects::{ value_objects::{
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl, Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl,
ReleaseYear, ReviewId, UserId, ReleaseYear, ReviewId, UserId,
}, },
}; };
use std::sync::Arc;
use tower::ServiceExt; use tower::ServiceExt;
// --- Panic stubs (defined once) --- // --- Panic stubs (defined once) ---
@@ -128,82 +128,232 @@ mod tests {
#[async_trait::async_trait] #[async_trait::async_trait]
impl MovieRepository for Panic { impl MovieRepository for Panic {
async fn get_movie_by_external_id(&self, _: &ExternalMetadataId) -> Result<Option<Movie>, DomainError> { panic!() } async fn get_movie_by_external_id(
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> { panic!() } &self,
async fn get_movies_by_title_and_year(&self, _: &MovieTitle, _: &ReleaseYear) -> Result<Vec<Movie>, DomainError> { panic!() } _: &ExternalMetadataId,
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!() } ) -> Result<Option<Movie>, DomainError> {
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!() } panic!()
}
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
panic!()
}
async fn get_movies_by_title_and_year(
&self,
_: &MovieTitle,
_: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
panic!()
}
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
panic!()
}
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!()
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl ReviewRepository for Panic { impl ReviewRepository for Panic {
async fn save_review(&self, _: &Review) -> Result<DomainEvent, DomainError> { panic!() } async fn save_review(&self, _: &Review) -> Result<DomainEvent, DomainError> {
async fn get_review_by_id(&self, _: &ReviewId) -> Result<Option<Review>, DomainError> { panic!() } panic!()
async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { panic!() } }
async fn get_review_by_id(&self, _: &ReviewId) -> Result<Option<Review>, DomainError> {
panic!()
}
async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> {
panic!()
}
async fn get_all_reviews_for_user(&self, _: &UserId) -> Result<Vec<Review>, DomainError> {
panic!()
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl DiaryRepository for Panic { impl DiaryRepository for Panic {
async fn query_diary(&self, _: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> { panic!() } async fn query_diary(&self, _: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> {
async fn query_activity_feed(&self, _: &PageParams) -> Result<Paginated<FeedEntry>, DomainError> { panic!() } panic!()
async fn get_review_history(&self, _: &MovieId) -> Result<ReviewHistory, DomainError> { panic!() } }
async fn get_user_history(&self, _: &UserId) -> Result<Vec<DiaryEntry>, DomainError> { panic!() } async fn query_activity_feed(
&self,
_: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
panic!()
}
async fn get_review_history(&self, _: &MovieId) -> Result<ReviewHistory, DomainError> {
panic!()
}
async fn get_user_history(&self, _: &UserId) -> Result<Vec<DiaryEntry>, DomainError> {
panic!()
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl StatsRepository for Panic { impl StatsRepository for Panic {
async fn get_user_stats(&self, _: &UserId) -> Result<UserStats, DomainError> { panic!() } async fn get_user_stats(&self, _: &UserId) -> Result<UserStats, DomainError> {
async fn get_user_trends(&self, _: &UserId) -> Result<UserTrends, DomainError> { panic!() } panic!()
}
async fn get_user_trends(&self, _: &UserId) -> Result<UserTrends, DomainError> {
panic!()
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl MetadataClient for Panic { impl MetadataClient for Panic {
async fn fetch_movie_metadata(&self, _: &domain::ports::MetadataSearchCriteria) -> Result<Movie, DomainError> { panic!() } async fn fetch_movie_metadata(
async fn get_poster_url(&self, _: &ExternalMetadataId) -> Result<Option<PosterUrl>, DomainError> { panic!() } &self,
_: &domain::ports::MetadataSearchCriteria,
) -> Result<Movie, DomainError> {
panic!()
}
async fn get_poster_url(
&self,
_: &ExternalMetadataId,
) -> Result<Option<PosterUrl>, DomainError> {
panic!()
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl PosterFetcherClient for Panic { async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result<Vec<u8>, DomainError> { panic!() } } impl PosterFetcherClient for Panic {
async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result<Vec<u8>, DomainError> {
panic!()
}
}
#[async_trait::async_trait] #[async_trait::async_trait]
impl PosterStorage for Panic { impl PosterStorage for Panic {
async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result<PosterPath, DomainError> { panic!() } async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result<PosterPath, DomainError> {
async fn get_poster(&self, _: &PosterPath) -> Result<Vec<u8>, DomainError> { panic!() } panic!()
}
async fn get_poster(&self, _: &PosterPath) -> Result<Vec<u8>, DomainError> {
panic!()
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl AuthService for Panic { impl AuthService for Panic {
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> { panic!() } async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> {
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> { panic!() } panic!()
}
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
panic!()
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl PasswordHasher for Panic { impl PasswordHasher for Panic {
async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> { panic!() } async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> {
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> { panic!() } panic!()
}
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> {
panic!()
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl UserRepository for Panic { impl UserRepository for Panic {
async fn find_by_email(&self, _: &Email) -> Result<Option<domain::models::User>, DomainError> { panic!() } async fn find_by_email(
async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> { panic!() } &self,
async fn find_by_id(&self, _: &UserId) -> Result<Option<domain::models::User>, DomainError> { panic!() } _: &Email,
async fn find_by_username(&self, _: &domain::value_objects::Username) -> Result<Option<domain::models::User>, DomainError> { panic!() } ) -> Result<Option<domain::models::User>, DomainError> {
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> { panic!() } panic!()
}
async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> {
panic!()
}
async fn find_by_id(
&self,
_: &UserId,
) -> Result<Option<domain::models::User>, DomainError> {
panic!()
}
async fn find_by_username(
&self,
_: &domain::value_objects::Username,
) -> Result<Option<domain::models::User>, DomainError> {
panic!()
}
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
panic!()
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl EventPublisher for Panic { async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> { panic!() } } impl EventPublisher for Panic {
async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl domain::ports::DiaryExporter for Panic {
async fn serialize_entries(
&self,
_: &[domain::models::DiaryEntry],
_: domain::models::ExportFormat,
) -> Result<Vec<u8>, domain::errors::DomainError> {
panic!()
}
}
impl crate::ports::HtmlRenderer for Panic { impl crate::ports::HtmlRenderer for Panic {
fn render_diary_page(&self, _: &Paginated<DiaryEntry>, _: application::ports::HtmlPageContext) -> Result<String, String> { panic!() } fn render_diary_page(
fn render_login_page(&self, _: application::ports::LoginPageData<'_>) -> Result<String, String> { panic!() } &self,
fn render_register_page(&self, _: application::ports::RegisterPageData<'_>) -> Result<String, String> { panic!() } _: &Paginated<DiaryEntry>,
fn render_new_review_page(&self, _: application::ports::NewReviewPageData<'_>) -> Result<String, String> { panic!() } _: application::ports::HtmlPageContext,
fn render_activity_feed_page(&self, _: application::ports::ActivityFeedPageData) -> Result<String, String> { panic!() } ) -> Result<String, String> {
fn render_users_page(&self, _: application::ports::UsersPageData) -> Result<String, String> { panic!() } panic!()
fn render_profile_page(&self, _: application::ports::ProfilePageData) -> Result<String, String> { panic!() } }
fn render_following_page(&self, _: application::ports::FollowingPageData) -> Result<String, String> { panic!() } fn render_login_page(
fn render_followers_page(&self, _: application::ports::FollowersPageData) -> Result<String, String> { panic!() } &self,
_: application::ports::LoginPageData<'_>,
) -> Result<String, String> {
panic!()
}
fn render_register_page(
&self,
_: application::ports::RegisterPageData<'_>,
) -> Result<String, String> {
panic!()
}
fn render_new_review_page(
&self,
_: application::ports::NewReviewPageData<'_>,
) -> Result<String, String> {
panic!()
}
fn render_activity_feed_page(
&self,
_: application::ports::ActivityFeedPageData,
) -> Result<String, String> {
panic!()
}
fn render_users_page(
&self,
_: application::ports::UsersPageData,
) -> Result<String, String> {
panic!()
}
fn render_profile_page(
&self,
_: application::ports::ProfilePageData,
) -> Result<String, String> {
panic!()
}
fn render_following_page(
&self,
_: application::ports::FollowingPageData,
) -> Result<String, String> {
panic!()
}
fn render_followers_page(
&self,
_: application::ports::FollowersPageData,
) -> Result<String, String> {
panic!()
}
} }
impl crate::ports::RssFeedRenderer for Panic { impl crate::ports::RssFeedRenderer for Panic {
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> { panic!() } fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> {
panic!()
}
} }
struct RejectingAuth; struct RejectingAuth;
#[async_trait::async_trait] #[async_trait::async_trait]
impl AuthService for RejectingAuth { impl AuthService for RejectingAuth {
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> { panic!() } async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> {
panic!()
}
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> { async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
Err(DomainError::Unauthorized("bad token".into())) Err(DomainError::Unauthorized("bad token".into()))
} }
@@ -218,6 +368,7 @@ mod tests {
movie_repository: Arc::clone(&repo) as _, movie_repository: Arc::clone(&repo) as _,
review_repository: Arc::clone(&repo) as _, review_repository: Arc::clone(&repo) as _,
diary_repository: Arc::clone(&repo) as _, diary_repository: Arc::clone(&repo) as _,
diary_exporter: Arc::clone(&repo) as _,
stats_repository: Arc::clone(&repo) as _, stats_repository: Arc::clone(&repo) as _,
metadata_client: Arc::clone(&repo) as _, metadata_client: Arc::clone(&repo) as _,
poster_fetcher: Arc::clone(&repo) as _, poster_fetcher: Arc::clone(&repo) as _,
@@ -226,7 +377,11 @@ mod tests {
password_hasher: Arc::clone(&repo) as _, password_hasher: Arc::clone(&repo) as _,
user_repository: Arc::clone(&repo) as _, user_repository: Arc::clone(&repo) as _,
auth_service, auth_service,
config: AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string(), rate_limit: 20 }, config: AppConfig {
allow_registration: false,
base_url: "http://localhost:3000".to_string(),
rate_limit: 20,
},
}, },
html_renderer: Arc::new(Panic), html_renderer: Arc::new(Panic),
rss_renderer: Arc::new(Panic), rss_renderer: Arc::new(Panic),
@@ -236,47 +391,103 @@ mod tests {
// --- Routers --- // --- Routers ---
async fn protected_handler(user: AuthenticatedUser) -> String { user.0.value().to_string() } async fn protected_handler(user: AuthenticatedUser) -> String {
async fn optional_cookie_handler(user: OptionalCookieUser) -> String { user.0.value().to_string()
match user.0 { Some(id) => id.value().to_string(), None => "none".to_string() } }
async fn optional_cookie_handler(user: OptionalCookieUser) -> String {
match user.0 {
Some(id) => id.value().to_string(),
None => "none".to_string(),
}
}
async fn required_cookie_handler(user: RequiredCookieUser) -> String {
user.0.value().to_string()
} }
async fn required_cookie_handler(user: RequiredCookieUser) -> String { user.0.value().to_string() }
fn router_protected(state: crate::state::AppState) -> Router { Router::new().route("/protected", get(protected_handler)).with_state(state) } fn router_protected(state: crate::state::AppState) -> Router {
fn router_optional(state: crate::state::AppState) -> Router { Router::new().route("/optional", get(optional_cookie_handler)).with_state(state) } Router::new()
fn router_required(state: crate::state::AppState) -> Router { Router::new().route("/required", get(required_cookie_handler)).with_state(state) } .route("/protected", get(protected_handler))
.with_state(state)
}
fn router_optional(state: crate::state::AppState) -> Router {
Router::new()
.route("/optional", get(optional_cookie_handler))
.with_state(state)
}
fn router_required(state: crate::state::AppState) -> Router {
Router::new()
.route("/required", get(required_cookie_handler))
.with_state(state)
}
// --- Tests --- // --- Tests ---
#[tokio::test] #[tokio::test]
async fn missing_auth_header_returns_401() { async fn missing_auth_header_returns_401() {
let app = router_protected(make_test_state(Arc::new(Panic))); let app = router_protected(make_test_state(Arc::new(Panic)));
let resp = app.oneshot(Request::builder().uri("/protected").body(Body::empty()).unwrap()).await.unwrap(); let resp = app
.oneshot(
Request::builder()
.uri("/protected")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
} }
#[tokio::test] #[tokio::test]
async fn optional_cookie_user_returns_none_without_cookie() { async fn optional_cookie_user_returns_none_without_cookie() {
let app = router_optional(make_test_state(Arc::new(Panic))); let app = router_optional(make_test_state(Arc::new(Panic)));
let resp = app.oneshot(Request::builder().uri("/optional").body(Body::empty()).unwrap()).await.unwrap(); let resp = app
.oneshot(
Request::builder()
.uri("/optional")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&body[..], b"none"); assert_eq!(&body[..], b"none");
} }
#[tokio::test] #[tokio::test]
async fn optional_cookie_user_returns_none_with_invalid_token() { async fn optional_cookie_user_returns_none_with_invalid_token() {
let app = router_optional(make_test_state(Arc::new(RejectingAuth))); let app = router_optional(make_test_state(Arc::new(RejectingAuth)));
let resp = app.oneshot(Request::builder().uri("/optional").header("cookie", "token=bad.token.here").body(Body::empty()).unwrap()).await.unwrap(); let resp = app
.oneshot(
Request::builder()
.uri("/optional")
.header("cookie", "token=bad.token.here")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&body[..], b"none"); assert_eq!(&body[..], b"none");
} }
#[tokio::test] #[tokio::test]
async fn required_cookie_user_redirects_without_cookie() { async fn required_cookie_user_redirects_without_cookie() {
let app = router_required(make_test_state(Arc::new(Panic))); let app = router_required(make_test_state(Arc::new(Panic)));
let resp = app.oneshot(Request::builder().uri("/required").body(Body::empty()).unwrap()).await.unwrap(); let resp = app
.oneshot(
Request::builder()
.uri("/required")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(resp.headers().get("location").unwrap(), "/login"); assert_eq!(resp.headers().get("location").unwrap(), "/login");
} }
@@ -284,7 +495,16 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn required_cookie_user_redirects_with_invalid_token() { async fn required_cookie_user_redirects_with_invalid_token() {
let app = router_required(make_test_state(Arc::new(RejectingAuth))); let app = router_required(make_test_state(Arc::new(RejectingAuth)));
let resp = app.oneshot(Request::builder().uri("/required").header("cookie", "token=bad.token.here").body(Body::empty()).unwrap()).await.unwrap(); let resp = app
.oneshot(
Request::builder()
.uri("/required")
.header("cookie", "token=bad.token.here")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(resp.headers().get("location").unwrap(), "/login"); assert_eq!(resp.headers().get("location").unwrap(), "/login");
} }

View File

@@ -14,13 +14,17 @@ pub mod html {
use uuid::Uuid; use uuid::Uuid;
use application::{ use application::{
commands::{DeleteReviewCommand, LoginCommand, RegisterCommand}, commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand},
ports::{ ports::{
FollowersPageData, FollowingPageData, HtmlPageContext, LoginPageData, FollowersPageData, FollowingPageData, HtmlPageContext, LoginPageData,
NewReviewPageData, RegisterPageData, RemoteActorView, NewReviewPageData, RegisterPageData, RemoteActorView,
}, },
use_cases::{delete_review, log_review, login as login_uc, register as register_uc}, use_cases::{
delete_review, export_diary as export_diary_uc, log_review, login as login_uc,
register as register_uc,
},
}; };
use domain::models::ExportFormat;
use domain::{errors::DomainError, value_objects::UserId}; use domain::{errors::DomainError, value_objects::UserId};
use crate::{ use crate::{
@@ -265,6 +269,45 @@ pub mod html {
} }
} }
pub async fn get_export(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Query(params): Query<crate::dtos::ExportQueryParams>,
) -> impl IntoResponse {
let format = match params.format.as_str() {
"csv" => ExportFormat::Csv,
"json" => ExportFormat::Json,
_ => return StatusCode::BAD_REQUEST.into_response(),
};
let (content_type, filename) = match &format {
ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"),
ExportFormat::Json => ("application/json", "diary.json"),
};
let cmd = ExportCommand {
user_id: user_id.value(),
format,
};
match export_diary_uc::execute(&state.app_ctx, cmd).await {
Ok(bytes) => (
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, content_type.to_string()),
(
axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", filename),
),
],
bytes,
)
.into_response(),
Err(DomainError::Unauthorized(_)) => StatusCode::FORBIDDEN.into_response(),
Err(e) => {
tracing::error!("export error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
pub async fn get_activity_feed( pub async fn get_activity_feed(
OptionalCookieUser(user_id): OptionalCookieUser, OptionalCookieUser(user_id): OptionalCookieUser,
State(state): State<AppState>, State(state): State<AppState>,

View File

@@ -1,6 +1,6 @@
pub mod event_handlers;
pub mod dtos; pub mod dtos;
pub mod errors; pub mod errors;
pub mod event_handlers;
pub mod extractors; pub mod extractors;
pub mod handlers; pub mod handlers;
pub mod ports; pub mod ports;

View File

@@ -10,15 +10,19 @@ use sqlx::sqlite::SqliteConnectOptions;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use activitypub::{
ActivityPubEventHandler, ActivityPubPort, ActivityPubService, DomainUserRepoAdapter,
ReviewObjectHandler,
};
use application::{config::AppConfig, context::AppContext}; use application::{config::AppConfig, context::AppContext};
use auth::{AuthConfig, Argon2PasswordHasher, JwtAuthService}; use auth::{Argon2PasswordHasher, AuthConfig, JwtAuthService};
use export::ExportAdapter;
use metadata::MetadataClientImpl; use metadata::MetadataClientImpl;
use poster_fetcher::{PosterFetcherConfig, ReqwestPosterFetcher}; use poster_fetcher::{PosterFetcherConfig, ReqwestPosterFetcher};
use poster_storage::{PosterStorageAdapter, StorageConfig}; use poster_storage::{PosterStorageAdapter, StorageConfig};
use activitypub::{ActivityPubEventHandler, ActivityPubPort, ActivityPubService, DomainUserRepoAdapter, ReviewObjectHandler}; use rss::RssAdapter;
use sqlite::{SqliteMovieRepository, SqliteUserRepository}; use sqlite::{SqliteMovieRepository, SqliteUserRepository};
use sqlite_federation::SqliteFederationRepository; use sqlite_federation::SqliteFederationRepository;
use rss::RssAdapter;
use template_askama::AskamaHtmlRenderer; use template_askama::AskamaHtmlRenderer;
use presentation::{routes, state::AppState}; use presentation::{routes, state::AppState};
@@ -68,18 +72,23 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
.context("Database migration failed")?; .context("Database migration failed")?;
use domain::ports::{ use domain::ports::{
AuthService, DiaryRepository, MetadataClient, MovieRepository, PasswordHasher, AuthService, DiaryExporter, DiaryRepository, MetadataClient, MovieRepository,
PosterFetcherClient, PosterStorage, ReviewRepository, StatsRepository, UserRepository, PasswordHasher, PosterFetcherClient, PosterStorage, ReviewRepository, StatsRepository,
UserRepository,
}; };
let movie_repository: Arc<dyn MovieRepository> = Arc::clone(&sqlite_repo) as _; let movie_repository: Arc<dyn MovieRepository> = Arc::clone(&sqlite_repo) as _;
let review_repository: Arc<dyn ReviewRepository> = Arc::clone(&sqlite_repo) as _; let review_repository: Arc<dyn ReviewRepository> = Arc::clone(&sqlite_repo) as _;
let diary_repository: Arc<dyn DiaryRepository> = Arc::clone(&sqlite_repo) as _; let diary_repository: Arc<dyn DiaryRepository> = Arc::clone(&sqlite_repo) as _;
let stats_repository: Arc<dyn StatsRepository> = Arc::clone(&sqlite_repo) as _; let stats_repository: Arc<dyn StatsRepository> = Arc::clone(&sqlite_repo) as _;
let user_repository: Arc<dyn UserRepository> = Arc::new(SqliteUserRepository::new(pool.clone())); let user_repository: Arc<dyn UserRepository> =
let metadata_client: Arc<dyn MetadataClient> = Arc::new(MetadataClientImpl::new_omdb(omdb_api_key)); Arc::new(SqliteUserRepository::new(pool.clone()));
let poster_fetcher: Arc<dyn PosterFetcherClient> = Arc::new(ReqwestPosterFetcher::new(PosterFetcherConfig::from_env())?); let metadata_client: Arc<dyn MetadataClient> =
let poster_storage: Arc<dyn PosterStorage> = Arc::new(PosterStorageAdapter::from_config(storage_config)); Arc::new(MetadataClientImpl::new_omdb(omdb_api_key));
let poster_fetcher: Arc<dyn PosterFetcherClient> =
Arc::new(ReqwestPosterFetcher::new(PosterFetcherConfig::from_env())?);
let poster_storage: Arc<dyn PosterStorage> =
Arc::new(PosterStorageAdapter::from_config(storage_config));
let auth_service: Arc<dyn AuthService> = Arc::new(JwtAuthService::new(auth_config)); let auth_service: Arc<dyn AuthService> = Arc::new(JwtAuthService::new(auth_config));
let password_hasher: Arc<dyn PasswordHasher> = Arc::new(Argon2PasswordHasher); let password_hasher: Arc<dyn PasswordHasher> = Arc::new(Argon2PasswordHasher);
@@ -89,6 +98,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
movie_repository: Arc::clone(&movie_repository), movie_repository: Arc::clone(&movie_repository),
review_repository: Arc::clone(&review_repository), review_repository: Arc::clone(&review_repository),
diary_repository: Arc::clone(&diary_repository), diary_repository: Arc::clone(&diary_repository),
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
stats_repository: Arc::clone(&stats_repository), stats_repository: Arc::clone(&stats_repository),
metadata_client: Arc::clone(&metadata_client), metadata_client: Arc::clone(&metadata_client),
poster_fetcher: Arc::clone(&poster_fetcher), poster_fetcher: Arc::clone(&poster_fetcher),
@@ -139,6 +149,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
movie_repository, movie_repository,
review_repository, review_repository,
diary_repository, diary_repository,
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
stats_repository, stats_repository,
metadata_client, metadata_client,
poster_fetcher, poster_fetcher,

View File

@@ -36,7 +36,11 @@ impl RateLimiter {
let prev = self.window.load(Ordering::Acquire); let prev = self.window.load(Ordering::Acquire);
if now != prev { if now != prev {
// compare_exchange ensures only one thread wins the window reset // compare_exchange ensures only one thread wins the window reset
if self.window.compare_exchange(prev, now, Ordering::AcqRel, Ordering::Relaxed).is_ok() { if self
.window
.compare_exchange(prev, now, Ordering::AcqRel, Ordering::Relaxed)
.is_ok()
{
self.count.store(1, Ordering::Release); self.count.store(1, Ordering::Release);
return true; return true;
} }
@@ -130,6 +134,7 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
"/posters/{*path}", "/posters/{*path}",
routing::get(handlers::posters::get_poster), routing::get(handlers::posters::get_poster),
) )
.route("/diary/export", routing::get(handlers::html::get_export))
.route("/feed.rss", routing::get(handlers::rss::get_feed)) .route("/feed.rss", routing::get(handlers::rss::get_feed))
.route( .route(
"/users/{id}/feed.rss", "/users/{id}/feed.rss",

View File

@@ -21,9 +21,9 @@ use domain::{
}; };
use http_body_util::BodyExt; use http_body_util::BodyExt;
use presentation::{routes, state::AppState}; use presentation::{routes, state::AppState};
use rss::RssAdapter;
use sqlite::SqliteMovieRepository; use sqlite::SqliteMovieRepository;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use rss::RssAdapter;
use template_askama::AskamaHtmlRenderer; use template_askama::AskamaHtmlRenderer;
use tower::ServiceExt; use tower::ServiceExt;
@@ -41,7 +41,10 @@ impl MetadataClient for PanicMeta {
async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result<Movie, DomainError> { async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result<Movie, DomainError> {
panic!("metadata not wired in tests") panic!("metadata not wired in tests")
} }
async fn get_poster_url(&self, _: &ExternalMetadataId) -> Result<Option<PosterUrl>, DomainError> { async fn get_poster_url(
&self,
_: &ExternalMetadataId,
) -> Result<Option<PosterUrl>, DomainError> {
panic!() panic!()
} }
} }
@@ -68,25 +71,58 @@ impl PosterStorage for PanicStorage {
struct PanicHasher; struct PanicHasher;
#[async_trait] #[async_trait]
impl PasswordHasher for PanicHasher { impl PasswordHasher for PanicHasher {
async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> { panic!() } async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> {
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> { panic!() } panic!()
}
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> {
panic!()
}
} }
struct PanicAuth; struct PanicAuth;
#[async_trait] #[async_trait]
impl AuthService for PanicAuth { impl AuthService for PanicAuth {
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> { panic!() } async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> {
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> { panic!() } panic!()
}
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
panic!()
}
} }
struct NobodyUserRepo; struct NobodyUserRepo;
#[async_trait] #[async_trait]
impl UserRepository for NobodyUserRepo { impl UserRepository for NobodyUserRepo {
async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> { Ok(None) } async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> {
async fn find_by_username(&self, _: &domain::value_objects::Username) -> Result<Option<User>, DomainError> { Ok(None) } Ok(None)
async fn save(&self, _: &User) -> Result<(), DomainError> { panic!() } }
async fn find_by_id(&self, _: &UserId) -> Result<Option<User>, DomainError> { panic!() } async fn find_by_username(
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> { panic!() } &self,
_: &domain::value_objects::Username,
) -> Result<Option<User>, DomainError> {
Ok(None)
}
async fn save(&self, _: &User) -> Result<(), DomainError> {
panic!()
}
async fn find_by_id(&self, _: &UserId) -> Result<Option<User>, DomainError> {
panic!()
}
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
panic!()
}
}
struct PanicExporter;
#[async_trait]
impl domain::ports::DiaryExporter for PanicExporter {
async fn serialize_entries(
&self,
_: &[domain::models::DiaryEntry],
_: domain::models::ExportFormat,
) -> Result<Vec<u8>, DomainError> {
panic!()
}
} }
async fn test_app() -> Router { async fn test_app() -> Router {
@@ -102,6 +138,7 @@ async fn test_app() -> Router {
movie_repository: Arc::clone(&repo) as _, movie_repository: Arc::clone(&repo) as _,
review_repository: Arc::clone(&repo) as _, review_repository: Arc::clone(&repo) as _,
diary_repository: Arc::clone(&repo) as _, diary_repository: Arc::clone(&repo) as _,
diary_exporter: Arc::new(PanicExporter),
stats_repository: Arc::clone(&repo) as _, stats_repository: Arc::clone(&repo) as _,
metadata_client: Arc::new(PanicMeta), metadata_client: Arc::new(PanicMeta),
poster_fetcher: Arc::new(PanicFetcher), poster_fetcher: Arc::new(PanicFetcher),
@@ -110,7 +147,11 @@ async fn test_app() -> Router {
auth_service: Arc::new(PanicAuth), auth_service: Arc::new(PanicAuth),
password_hasher: Arc::new(PanicHasher), password_hasher: Arc::new(PanicHasher),
user_repository: Arc::new(NobodyUserRepo), user_repository: Arc::new(NobodyUserRepo),
config: AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string(), rate_limit: 20 }, config: AppConfig {
allow_registration: false,
base_url: "http://localhost:3000".to_string(),
rate_limit: 20,
},
}, },
html_renderer: Arc::new(AskamaHtmlRenderer::new()), html_renderer: Arc::new(AskamaHtmlRenderer::new()),
rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())), rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())),
@@ -124,7 +165,12 @@ async fn test_app() -> Router {
async fn get_api_diary_returns_empty_list() { async fn get_api_diary_returns_empty_list() {
let app = test_app().await; let app = test_app().await;
let response = app let response = app
.oneshot(Request::builder().uri("/api/diary").body(Body::empty()).unwrap()) .oneshot(
Request::builder()
.uri("/api/diary")
.body(Body::empty())
.unwrap(),
)
.await .await
.unwrap(); .unwrap();

View File

@@ -1,6 +1,6 @@
use uuid::Uuid;
use crate::client::{DiaryEntryDto, LogReviewRequest, ReviewHistoryResponse}; use crate::client::{DiaryEntryDto, LogReviewRequest, ReviewHistoryResponse};
use crate::config::Config; use crate::config::Config;
use uuid::Uuid;
// ── Screens ─────────────────────────────────────────────────────────────────── // ── Screens ───────────────────────────────────────────────────────────────────
@@ -25,7 +25,11 @@ pub struct LoginState {
} }
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum LoginField { #[default] Email, Password } pub enum LoginField {
#[default]
Email,
Password,
}
// ── Main (4 tabs) ───────────────────────────────────────────────────────────── // ── Main (4 tabs) ─────────────────────────────────────────────────────────────
@@ -45,13 +49,22 @@ impl MainState {
diary: DiaryState::default(), diary: DiaryState::default(),
add_review: AddReviewState::default(), add_review: AddReviewState::default(),
bulk_import: BulkImportState::default(), bulk_import: BulkImportState::default(),
settings: SettingsState { api_url, focused: SettingsField::default() }, settings: SettingsState {
api_url,
focused: SettingsField::default(),
},
} }
} }
} }
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum Tab { #[default] Diary, AddReview, BulkImport, Settings } pub enum Tab {
#[default]
Diary,
AddReview,
BulkImport,
Settings,
}
// ── Diary ───────────────────────────────────────────────────────────────────── // ── Diary ─────────────────────────────────────────────────────────────────────
@@ -94,7 +107,14 @@ impl Default for AddReviewState {
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum AddReviewField { pub enum AddReviewField {
#[default] ExternalId, Title, Year, Rating, WatchedAt, Comment, Submit, #[default]
ExternalId,
Title,
Year,
Rating,
WatchedAt,
Comment,
Submit,
} }
// ── Bulk Import ─────────────────────────────────────────────────────────────── // ── Bulk Import ───────────────────────────────────────────────────────────────
@@ -111,9 +131,12 @@ pub struct BulkImportState {
#[derive(Debug, Default, Clone, PartialEq, Eq)] #[derive(Debug, Default, Clone, PartialEq, Eq)]
pub enum BulkImportStage { pub enum BulkImportStage {
#[default] EnterPath, #[default]
EnterPath,
Preview, Preview,
Importing { done: usize }, Importing {
done: usize,
},
Done, Done,
} }
@@ -132,7 +155,12 @@ pub struct SettingsState {
} }
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum SettingsField { #[default] ApiUrl, Save, Logout } pub enum SettingsField {
#[default]
ApiUrl,
Save,
Logout,
}
// ── Status bar ──────────────────────────────────────────────────────────────── // ── Status bar ────────────────────────────────────────────────────────────────
@@ -155,13 +183,22 @@ pub struct App {
impl App { impl App {
pub fn new(config: Option<Config>, token: Option<String>) -> Self { pub fn new(config: Option<Config>, token: Option<String>) -> Self {
let api_url = config.as_ref().map(|c| c.api_url.clone()).unwrap_or_default(); let api_url = config
.as_ref()
.map(|c| c.api_url.clone())
.unwrap_or_default();
let screen = match &config { let screen = match &config {
None => Screen::Setup(SetupState::default()), None => Screen::Setup(SetupState::default()),
Some(_) if token.is_none() => Screen::Login(LoginState::default()), Some(_) if token.is_none() => Screen::Login(LoginState::default()),
Some(c) => Screen::Main(MainState::new(c.api_url.clone())), Some(c) => Screen::Main(MainState::new(c.api_url.clone())),
}; };
Self { screen, token, loading: false, status: None, api_url } Self {
screen,
token,
loading: false,
status: None,
api_url,
}
} }
} }
@@ -169,24 +206,51 @@ impl App {
#[derive(Debug)] #[derive(Debug)]
pub enum Action { pub enum Action {
Quit, Escape, TabSelect(Tab), TabNext, TabPrev, Quit,
Escape,
TabSelect(Tab),
TabNext,
TabPrev,
SetupSubmit, SetupSubmit,
InputChar(char), Backspace, FocusNext, FocusPrev, InputChar(char),
Backspace,
FocusNext,
FocusPrev,
LoginSubmit, LoginSubmit,
ScrollDown, ScrollUp, OpenHistory, LoadMore, LoadPrev, ScrollDown,
DeleteInit, DeleteConfirm, DeleteCancel, ScrollUp,
RatingUp, RatingDown, ReviewSubmit, OpenHistory,
BulkParseFile, BulkImportAll, BulkCancel, LoadMore,
SettingsSave, SettingsLogout, LoadPrev,
DeleteInit,
DeleteConfirm,
DeleteCancel,
RatingUp,
RatingDown,
ReviewSubmit,
BulkParseFile,
BulkImportAll,
BulkCancel,
SettingsSave,
SettingsLogout,
// async results // async results
AuthOk(String), AuthFail(String), AuthOk(String),
DiaryLoaded { entries: Vec<DiaryEntryDto>, total: u64 }, AuthFail(String),
DiaryLoaded {
entries: Vec<DiaryEntryDto>,
total: u64,
},
DiaryLoadFailed(String), DiaryLoadFailed(String),
HistoryLoaded(ReviewHistoryResponse), HistoryLoaded(ReviewHistoryResponse),
HistoryLoadFailed(String), HistoryLoadFailed(String),
ReviewCreated, ReviewCreateFailed(String), ReviewCreated,
ReviewDeleted(Uuid), ReviewDeleteFailed(String), ReviewCreateFailed(String),
BulkItemDone { index: usize, error: Option<String> }, ReviewDeleted(Uuid),
ReviewDeleteFailed(String),
BulkItemDone {
index: usize,
error: Option<String>,
},
} }
#[derive(Debug)] #[derive(Debug)]
@@ -211,7 +275,10 @@ pub fn parse_csv(content: &str) -> Vec<ParsedRow> {
let record = match result { let record = match result {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
rows.push(ParsedRow { row: row_num, result: Err(e.to_string()) }); rows.push(ParsedRow {
row: row_num,
result: Err(e.to_string()),
});
continue; continue;
} }
}; };
@@ -224,18 +291,36 @@ pub fn parse_csv(content: &str) -> Vec<ParsedRow> {
let comment = record.get(5).unwrap_or("").trim().to_string(); let comment = record.get(5).unwrap_or("").trim().to_string();
if title.is_empty() && external_id.is_empty() { if title.is_empty() && external_id.is_empty() {
rows.push(ParsedRow { row: row_num, result: Err("title or external_id required".into()) }); rows.push(ParsedRow {
row: row_num,
result: Err("title or external_id required".into()),
});
continue; continue;
} }
let rating: u8 = match rating_str.trim().parse::<u8>() { let rating: u8 = match rating_str.trim().parse::<u8>() {
Ok(r) if r <= 5 => r, Ok(r) if r <= 5 => r,
Ok(_) => { rows.push(ParsedRow { row: row_num, result: Err(format!("rating must be 0-5, got {rating_str}")) }); continue; } Ok(_) => {
Err(_) => { rows.push(ParsedRow { row: row_num, result: Err(format!("invalid rating: {rating_str}")) }); continue; } rows.push(ParsedRow {
row: row_num,
result: Err(format!("rating must be 0-5, got {rating_str}")),
});
continue;
}
Err(_) => {
rows.push(ParsedRow {
row: row_num,
result: Err(format!("invalid rating: {rating_str}")),
});
continue;
}
}; };
if watched_at.is_empty() { if watched_at.is_empty() {
rows.push(ParsedRow { row: row_num, result: Err("watched_at required".into()) }); rows.push(ParsedRow {
row: row_num,
result: Err("watched_at required".into()),
});
continue; continue;
} }
@@ -244,18 +329,32 @@ pub fn parse_csv(content: &str) -> Vec<ParsedRow> {
} else { } else {
match year_str.parse() { match year_str.parse() {
Ok(y) => Some(y), Ok(y) => Some(y),
Err(_) => { rows.push(ParsedRow { row: row_num, result: Err(format!("invalid year: {year_str}")) }); continue; } Err(_) => {
rows.push(ParsedRow {
row: row_num,
result: Err(format!("invalid year: {year_str}")),
});
continue;
}
} }
}; };
rows.push(ParsedRow { rows.push(ParsedRow {
row: row_num, row: row_num,
result: Ok(LogReviewRequest { result: Ok(LogReviewRequest {
external_metadata_id: if external_id.is_empty() { None } else { Some(external_id) }, external_metadata_id: if external_id.is_empty() {
None
} else {
Some(external_id)
},
manual_title: if title.is_empty() { None } else { Some(title) }, manual_title: if title.is_empty() { None } else { Some(title) },
manual_release_year, manual_release_year,
rating, rating,
comment: if comment.is_empty() { None } else { Some(comment) }, comment: if comment.is_empty() {
None
} else {
Some(comment)
},
watched_at, watched_at,
}), }),
}); });
@@ -277,14 +376,24 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
Action::TabNext => { Action::TabNext => {
if let Screen::Main(m) = &mut app.screen { if let Screen::Main(m) = &mut app.screen {
m.tab = match m.tab { Tab::Diary => Tab::AddReview, Tab::AddReview => Tab::BulkImport, Tab::BulkImport => Tab::Settings, Tab::Settings => Tab::Diary }; m.tab = match m.tab {
Tab::Diary => Tab::AddReview,
Tab::AddReview => Tab::BulkImport,
Tab::BulkImport => Tab::Settings,
Tab::Settings => Tab::Diary,
};
} }
vec![] vec![]
} }
Action::TabPrev => { Action::TabPrev => {
if let Screen::Main(m) = &mut app.screen { if let Screen::Main(m) = &mut app.screen {
m.tab = match m.tab { Tab::Diary => Tab::Settings, Tab::AddReview => Tab::Diary, Tab::BulkImport => Tab::AddReview, Tab::Settings => Tab::BulkImport }; m.tab = match m.tab {
Tab::Diary => Tab::Settings,
Tab::AddReview => Tab::Diary,
Tab::BulkImport => Tab::AddReview,
Tab::Settings => Tab::BulkImport,
};
} }
vec![] vec![]
} }
@@ -293,15 +402,23 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
if let Screen::Main(m) = &mut app.screen { if let Screen::Main(m) = &mut app.screen {
match m.tab { match m.tab {
Tab::Diary => { Tab::Diary => {
if m.diary.delete_pending.is_some() { m.diary.delete_pending = None; } if m.diary.delete_pending.is_some() {
else { m.diary.history = None; } m.diary.delete_pending = None;
} else {
m.diary.history = None;
}
} }
Tab::BulkImport => { Tab::BulkImport => {
if matches!(m.bulk_import.stage, BulkImportStage::Preview | BulkImportStage::Done) { if matches!(
m.bulk_import.stage,
BulkImportStage::Preview | BulkImportStage::Done
) {
m.bulk_import.stage = BulkImportStage::EnterPath; m.bulk_import.stage = BulkImportStage::EnterPath;
} }
} }
Tab::AddReview | Tab::Settings => { m.tab = Tab::Diary; } Tab::AddReview | Tab::Settings => {
m.tab = Tab::Diary;
}
} }
} }
vec![] vec![]
@@ -324,7 +441,9 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
AddReviewField::Comment => m.add_review.comment.push(c), AddReviewField::Comment => m.add_review.comment.push(c),
_ => {} _ => {}
}, },
Tab::BulkImport if matches!(m.bulk_import.stage, BulkImportStage::EnterPath) => { Tab::BulkImport
if matches!(m.bulk_import.stage, BulkImportStage::EnterPath) =>
{
m.bulk_import.file_path.push(c); m.bulk_import.file_path.push(c);
} }
Tab::Settings if matches!(m.settings.focused, SettingsField::ApiUrl) => { Tab::Settings if matches!(m.settings.focused, SettingsField::ApiUrl) => {
@@ -338,21 +457,39 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
Action::Backspace => { Action::Backspace => {
match &mut app.screen { match &mut app.screen {
Screen::Setup(s) => { s.api_url.pop(); } Screen::Setup(s) => {
s.api_url.pop();
}
Screen::Login(s) => match s.focused { Screen::Login(s) => match s.focused {
LoginField::Email => { s.email.pop(); } LoginField::Email => {
LoginField::Password => { s.password.pop(); } s.email.pop();
}
LoginField::Password => {
s.password.pop();
}
}, },
Screen::Main(m) => match m.tab { Screen::Main(m) => match m.tab {
Tab::AddReview => match m.add_review.focused { Tab::AddReview => match m.add_review.focused {
AddReviewField::ExternalId => { m.add_review.external_id.pop(); } AddReviewField::ExternalId => {
AddReviewField::Title => { m.add_review.title.pop(); } m.add_review.external_id.pop();
AddReviewField::Year => { m.add_review.year.pop(); } }
AddReviewField::WatchedAt => { m.add_review.watched_at.pop(); } AddReviewField::Title => {
AddReviewField::Comment => { m.add_review.comment.pop(); } m.add_review.title.pop();
}
AddReviewField::Year => {
m.add_review.year.pop();
}
AddReviewField::WatchedAt => {
m.add_review.watched_at.pop();
}
AddReviewField::Comment => {
m.add_review.comment.pop();
}
_ => {} _ => {}
}, },
Tab::BulkImport if matches!(m.bulk_import.stage, BulkImportStage::EnterPath) => { Tab::BulkImport
if matches!(m.bulk_import.stage, BulkImportStage::EnterPath) =>
{
m.bulk_import.file_path.pop(); m.bulk_import.file_path.pop();
} }
Tab::Settings if matches!(m.settings.focused, SettingsField::ApiUrl) => { Tab::Settings if matches!(m.settings.focused, SettingsField::ApiUrl) => {
@@ -367,7 +504,11 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
Action::FocusNext => { Action::FocusNext => {
match &mut app.screen { match &mut app.screen {
Screen::Login(s) => { Screen::Login(s) => {
s.focused = if s.focused == LoginField::Email { LoginField::Password } else { LoginField::Email }; s.focused = if s.focused == LoginField::Email {
LoginField::Password
} else {
LoginField::Email
};
} }
Screen::Main(m) => match m.tab { Screen::Main(m) => match m.tab {
Tab::AddReview => { Tab::AddReview => {
@@ -398,7 +539,11 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
Action::FocusPrev => { Action::FocusPrev => {
match &mut app.screen { match &mut app.screen {
Screen::Login(s) => { Screen::Login(s) => {
s.focused = if s.focused == LoginField::Password { LoginField::Email } else { LoginField::Password }; s.focused = if s.focused == LoginField::Password {
LoginField::Email
} else {
LoginField::Password
};
} }
Screen::Main(m) => match m.tab { Screen::Main(m) => match m.tab {
Tab::AddReview => { Tab::AddReview => {
@@ -451,7 +596,10 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
Action::LoginSubmit => { Action::LoginSubmit => {
if let Screen::Login(s) = &app.screen { if let Screen::Login(s) = &app.screen {
if s.email.is_empty() || s.password.is_empty() { if s.email.is_empty() || s.password.is_empty() {
app.status = Some(StatusMsg { text: "Email and password required".into(), is_error: true }); app.status = Some(StatusMsg {
text: "Email and password required".into(),
is_error: true,
});
return vec![]; return vec![];
} }
let email = s.email.clone(); let email = s.email.clone();
@@ -466,14 +614,20 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
app.loading = false; app.loading = false;
app.status = None; app.status = None;
app.screen = Screen::Main(MainState::new(app.api_url.clone())); app.screen = Screen::Main(MainState::new(app.api_url.clone()));
let cmds = vec![Command::SaveToken(token.clone()), Command::LoadDiary { offset: 0 }]; let cmds = vec![
Command::SaveToken(token.clone()),
Command::LoadDiary { offset: 0 },
];
app.token = Some(token); app.token = Some(token);
cmds cmds
} }
Action::AuthFail(msg) => { Action::AuthFail(msg) => {
app.loading = false; app.loading = false;
app.status = Some(StatusMsg { text: msg, is_error: true }); app.status = Some(StatusMsg {
text: msg,
is_error: true,
});
vec![] vec![]
} }
@@ -547,10 +701,16 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
if msg.contains("unauthorized") || msg.contains("Unauthorized") { if msg.contains("unauthorized") || msg.contains("Unauthorized") {
app.token = None; app.token = None;
app.screen = Screen::Login(LoginState::default()); app.screen = Screen::Login(LoginState::default());
app.status = Some(StatusMsg { text: "Session expired. Please log in again.".into(), is_error: true }); app.status = Some(StatusMsg {
text: "Session expired. Please log in again.".into(),
is_error: true,
});
return vec![Command::ClearToken]; return vec![Command::ClearToken];
} }
app.status = Some(StatusMsg { text: msg, is_error: true }); app.status = Some(StatusMsg {
text: msg,
is_error: true,
});
vec![] vec![]
} }
@@ -564,7 +724,10 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
Action::HistoryLoadFailed(msg) => { Action::HistoryLoadFailed(msg) => {
app.loading = false; app.loading = false;
app.status = Some(StatusMsg { text: msg, is_error: true }); app.status = Some(StatusMsg {
text: msg,
is_error: true,
});
vec![] vec![]
} }
@@ -602,26 +765,36 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
} }
m.diary.history = None; m.diary.history = None;
} }
app.status = Some(StatusMsg { text: "Review deleted".into(), is_error: false }); app.status = Some(StatusMsg {
text: "Review deleted".into(),
is_error: false,
});
vec![] vec![]
} }
Action::ReviewDeleteFailed(msg) => { Action::ReviewDeleteFailed(msg) => {
app.status = Some(StatusMsg { text: msg, is_error: true }); app.status = Some(StatusMsg {
text: msg,
is_error: true,
});
vec![] vec![]
} }
// ── Add Review ──────────────────────────────────────────────────────── // ── Add Review ────────────────────────────────────────────────────────
Action::RatingUp => { Action::RatingUp => {
if let Screen::Main(m) = &mut app.screen { if let Screen::Main(m) = &mut app.screen {
if m.add_review.rating < 5 { m.add_review.rating += 1; } if m.add_review.rating < 5 {
m.add_review.rating += 1;
}
} }
vec![] vec![]
} }
Action::RatingDown => { Action::RatingDown => {
if let Screen::Main(m) = &mut app.screen { if let Screen::Main(m) = &mut app.screen {
if m.add_review.rating > 0 { m.add_review.rating -= 1; } if m.add_review.rating > 0 {
m.add_review.rating -= 1;
}
} }
vec![] vec![]
} }
@@ -633,19 +806,37 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
let has_ext = !f.external_id.is_empty(); let has_ext = !f.external_id.is_empty();
let has_title = !f.title.is_empty(); let has_title = !f.title.is_empty();
let has_watched = !f.watched_at.is_empty(); let has_watched = !f.watched_at.is_empty();
let ext_id = if has_ext { Some(f.external_id.clone()) } else { None }; let ext_id = if has_ext {
let title = if has_title { Some(f.title.clone()) } else { None }; Some(f.external_id.clone())
} else {
None
};
let title = if has_title {
Some(f.title.clone())
} else {
None
};
let year: Option<u16> = f.year.parse().ok(); let year: Option<u16> = f.year.parse().ok();
let rating = f.rating; let rating = f.rating;
let comment = if f.comment.is_empty() { None } else { Some(f.comment.clone()) }; let comment = if f.comment.is_empty() {
None
} else {
Some(f.comment.clone())
};
let watched_at = f.watched_at.clone(); let watched_at = f.watched_at.clone();
if !has_ext && !has_title { if !has_ext && !has_title {
app.status = Some(StatusMsg { text: "Title or external ID required".into(), is_error: true }); app.status = Some(StatusMsg {
text: "Title or external ID required".into(),
is_error: true,
});
return vec![]; return vec![];
} }
if !has_watched { if !has_watched {
app.status = Some(StatusMsg { text: "Watched-at date required".into(), is_error: true }); app.status = Some(StatusMsg {
text: "Watched-at date required".into(),
is_error: true,
});
return vec![]; return vec![];
} }
let req = LogReviewRequest { let req = LogReviewRequest {
@@ -665,7 +856,10 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
Action::ReviewCreated => { Action::ReviewCreated => {
app.loading = false; app.loading = false;
app.status = Some(StatusMsg { text: "Review added!".into(), is_error: false }); app.status = Some(StatusMsg {
text: "Review added!".into(),
is_error: false,
});
if let Screen::Main(m) = &mut app.screen { if let Screen::Main(m) = &mut app.screen {
m.add_review = AddReviewState::default(); m.add_review = AddReviewState::default();
} }
@@ -674,7 +868,10 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
Action::ReviewCreateFailed(msg) => { Action::ReviewCreateFailed(msg) => {
app.loading = false; app.loading = false;
app.status = Some(StatusMsg { text: msg, is_error: true }); app.status = Some(StatusMsg {
text: msg,
is_error: true,
});
vec![] vec![]
} }
@@ -689,7 +886,10 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
m.bulk_import.stage = BulkImportStage::Preview; m.bulk_import.stage = BulkImportStage::Preview;
} }
Err(e) => { Err(e) => {
app.status = Some(StatusMsg { text: format!("Cannot read file: {e}"), is_error: true }); app.status = Some(StatusMsg {
text: format!("Cannot read file: {e}"),
is_error: true,
});
} }
} }
} }
@@ -700,11 +900,17 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
Action::BulkImportAll => { Action::BulkImportAll => {
if let Screen::Main(m) = &mut app.screen { if let Screen::Main(m) = &mut app.screen {
if m.tab == Tab::BulkImport && m.bulk_import.stage == BulkImportStage::Preview { if m.tab == Tab::BulkImport && m.bulk_import.stage == BulkImportStage::Preview {
let valid: Vec<LogReviewRequest> = m.bulk_import.parsed.iter() let valid: Vec<LogReviewRequest> = m
.bulk_import
.parsed
.iter()
.filter_map(|r| r.result.as_ref().ok().cloned()) .filter_map(|r| r.result.as_ref().ok().cloned())
.collect(); .collect();
if valid.is_empty() { if valid.is_empty() {
app.status = Some(StatusMsg { text: "No valid rows to import".into(), is_error: true }); app.status = Some(StatusMsg {
text: "No valid rows to import".into(),
is_error: true,
});
return vec![]; return vec![];
} }
m.bulk_import.results = vec![None; valid.len()]; m.bulk_import.results = vec![None; valid.len()];
@@ -730,7 +936,10 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
Action::BulkItemDone { index, error } => { Action::BulkItemDone { index, error } => {
if let Screen::Main(m) = &mut app.screen { if let Screen::Main(m) = &mut app.screen {
if index >= m.bulk_import.results.len() { if index >= m.bulk_import.results.len() {
app.status = Some(StatusMsg { text: format!("Import error: unexpected index {index}"), is_error: true }); app.status = Some(StatusMsg {
text: format!("Import error: unexpected index {index}"),
is_error: true,
});
m.bulk_import.stage = BulkImportStage::Done; m.bulk_import.stage = BulkImportStage::Done;
return vec![]; return vec![];
} }
@@ -757,10 +966,16 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
if let Screen::Main(m) = &app.screen { if let Screen::Main(m) = &app.screen {
let url = m.settings.api_url.trim().to_string(); let url = m.settings.api_url.trim().to_string();
if url.is_empty() { if url.is_empty() {
app.status = Some(StatusMsg { text: "URL required".into(), is_error: true }); app.status = Some(StatusMsg {
text: "URL required".into(),
is_error: true,
});
return vec![]; return vec![];
} }
app.status = Some(StatusMsg { text: "Settings saved".into(), is_error: false }); app.status = Some(StatusMsg {
text: "Settings saved".into(),
is_error: false,
});
app.api_url = url.clone(); app.api_url = url.clone();
return vec![Command::SaveConfig(url)]; return vec![Command::SaveConfig(url)];
} }
@@ -784,7 +999,10 @@ mod tests {
fn setup_app() -> App { fn setup_app() -> App {
App { App {
screen: Screen::Setup(SetupState { api_url: String::new(), error: None }), screen: Screen::Setup(SetupState {
api_url: String::new(),
error: None,
}),
token: None, token: None,
loading: false, loading: false,
status: None, status: None,
@@ -814,8 +1032,18 @@ mod tests {
fn diary_entry() -> DiaryEntryDto { fn diary_entry() -> DiaryEntryDto {
DiaryEntryDto { DiaryEntryDto {
movie: MovieDto { id: Uuid::new_v4(), title: "The Matrix".into(), release_year: 1999, director: None }, movie: MovieDto {
review: ReviewDto { id: Uuid::new_v4(), rating: 5, comment: None, watched_at: "1999-03-31T00:00:00".into() }, id: Uuid::new_v4(),
title: "The Matrix".into(),
release_year: 1999,
director: None,
},
review: ReviewDto {
id: Uuid::new_v4(),
rating: 5,
comment: None,
watched_at: "1999-03-31T00:00:00".into(),
},
} }
} }
@@ -828,7 +1056,9 @@ mod tests {
update(&mut app, Action::InputChar('i')); update(&mut app, Action::InputChar('i'));
if let Screen::Setup(s) = &app.screen { if let Screen::Setup(s) = &app.screen {
assert_eq!(s.api_url, "hi"); assert_eq!(s.api_url, "hi");
} else { panic!("expected Setup"); } } else {
panic!("expected Setup");
}
} }
#[test] #[test]
@@ -838,7 +1068,9 @@ mod tests {
assert!(cmds.is_empty()); assert!(cmds.is_empty());
if let Screen::Setup(s) = &app.screen { if let Screen::Setup(s) = &app.screen {
assert!(s.error.is_some()); assert!(s.error.is_some());
} else { panic!("expected Setup"); } } else {
panic!("expected Setup");
}
} }
#[test] #[test]
@@ -859,7 +1091,9 @@ mod tests {
if let Screen::Login(s) = &app.screen { if let Screen::Login(s) = &app.screen {
assert_eq!(s.email, "a"); assert_eq!(s.email, "a");
assert_eq!(s.password, ""); assert_eq!(s.password, "");
} else { panic!(); } } else {
panic!();
}
} }
#[test] #[test]
@@ -868,7 +1102,9 @@ mod tests {
update(&mut app, Action::FocusNext); update(&mut app, Action::FocusNext);
if let Screen::Login(s) = &app.screen { if let Screen::Login(s) = &app.screen {
assert_eq!(s.focused, LoginField::Password); assert_eq!(s.focused, LoginField::Password);
} else { panic!(); } } else {
panic!();
}
} }
#[test] #[test]
@@ -878,15 +1114,21 @@ mod tests {
update(&mut app, Action::InputChar('x')); update(&mut app, Action::InputChar('x'));
if let Screen::Login(s) = &app.screen { if let Screen::Login(s) = &app.screen {
assert_eq!(s.password, "x"); assert_eq!(s.password, "x");
} else { panic!(); } } else {
panic!();
}
} }
#[test] #[test]
fn login_submit_returns_login_command_and_sets_loading() { fn login_submit_returns_login_command_and_sets_loading() {
let mut app = login_app(); let mut app = login_app();
for c in "user@example.com".chars() { update(&mut app, Action::InputChar(c)); } for c in "user@example.com".chars() {
update(&mut app, Action::InputChar(c));
}
update(&mut app, Action::FocusNext); update(&mut app, Action::FocusNext);
for c in "pass123".chars() { update(&mut app, Action::InputChar(c)); } for c in "pass123".chars() {
update(&mut app, Action::InputChar(c));
}
let cmds = update(&mut app, Action::LoginSubmit); let cmds = update(&mut app, Action::LoginSubmit);
assert!(cmds.iter().any(|c| matches!(c, Command::Login { .. }))); assert!(cmds.iter().any(|c| matches!(c, Command::Login { .. })));
assert!(app.loading); assert!(app.loading);
@@ -925,34 +1167,55 @@ mod tests {
#[test] #[test]
fn diary_scroll_down_increments_selected() { fn diary_scroll_down_increments_selected() {
let mut app = main_app(); let mut app = main_app();
update(&mut app, Action::DiaryLoaded { update(
&mut app,
Action::DiaryLoaded {
entries: vec![diary_entry(), diary_entry(), diary_entry()], entries: vec![diary_entry(), diary_entry(), diary_entry()],
total: 3, total: 3,
}); },
);
update(&mut app, Action::ScrollDown); update(&mut app, Action::ScrollDown);
if let Screen::Main(m) = &app.screen { if let Screen::Main(m) = &app.screen {
assert_eq!(m.diary.selected, 1); assert_eq!(m.diary.selected, 1);
} else { panic!(); } } else {
panic!();
}
} }
#[test] #[test]
fn diary_scroll_up_clamps_at_zero() { fn diary_scroll_up_clamps_at_zero() {
let mut app = main_app(); let mut app = main_app();
update(&mut app, Action::DiaryLoaded { entries: vec![diary_entry()], total: 1 }); update(
&mut app,
Action::DiaryLoaded {
entries: vec![diary_entry()],
total: 1,
},
);
update(&mut app, Action::ScrollUp); update(&mut app, Action::ScrollUp);
if let Screen::Main(m) = &app.screen { if let Screen::Main(m) = &app.screen {
assert_eq!(m.diary.selected, 0); assert_eq!(m.diary.selected, 0);
} else { panic!(); } } else {
panic!();
}
} }
#[test] #[test]
fn diary_scroll_down_clamps_at_last_entry() { fn diary_scroll_down_clamps_at_last_entry() {
let mut app = main_app(); let mut app = main_app();
update(&mut app, Action::DiaryLoaded { entries: vec![diary_entry()], total: 1 }); update(
&mut app,
Action::DiaryLoaded {
entries: vec![diary_entry()],
total: 1,
},
);
update(&mut app, Action::ScrollDown); update(&mut app, Action::ScrollDown);
if let Screen::Main(m) = &app.screen { if let Screen::Main(m) = &app.screen {
assert_eq!(m.diary.selected, 0); assert_eq!(m.diary.selected, 0);
} else { panic!(); } } else {
panic!();
}
} }
#[test] #[test]
@@ -960,11 +1223,19 @@ mod tests {
let mut app = main_app(); let mut app = main_app();
let entry = diary_entry(); let entry = diary_entry();
let review_id = entry.review.id; let review_id = entry.review.id;
update(&mut app, Action::DiaryLoaded { entries: vec![entry], total: 1 }); update(
&mut app,
Action::DiaryLoaded {
entries: vec![entry],
total: 1,
},
);
update(&mut app, Action::DeleteInit); update(&mut app, Action::DeleteInit);
if let Screen::Main(m) = &app.screen { if let Screen::Main(m) = &app.screen {
assert_eq!(m.diary.delete_pending, Some(review_id)); assert_eq!(m.diary.delete_pending, Some(review_id));
} else { panic!(); } } else {
panic!();
}
} }
#[test] #[test]
@@ -972,22 +1243,39 @@ mod tests {
let mut app = main_app(); let mut app = main_app();
let entry = diary_entry(); let entry = diary_entry();
let review_id = entry.review.id; let review_id = entry.review.id;
update(&mut app, Action::DiaryLoaded { entries: vec![entry], total: 1 }); update(
&mut app,
Action::DiaryLoaded {
entries: vec![entry],
total: 1,
},
);
update(&mut app, Action::DeleteInit); update(&mut app, Action::DeleteInit);
let cmds = update(&mut app, Action::DeleteConfirm); let cmds = update(&mut app, Action::DeleteConfirm);
assert!(cmds.iter().any(|c| matches!(c, Command::DeleteReview(id) if *id == review_id))); assert!(
cmds.iter()
.any(|c| matches!(c, Command::DeleteReview(id) if *id == review_id))
);
} }
#[test] #[test]
fn delete_cancel_clears_pending() { fn delete_cancel_clears_pending() {
let mut app = main_app(); let mut app = main_app();
let entry = diary_entry(); let entry = diary_entry();
update(&mut app, Action::DiaryLoaded { entries: vec![entry], total: 1 }); update(
&mut app,
Action::DiaryLoaded {
entries: vec![entry],
total: 1,
},
);
update(&mut app, Action::DeleteInit); update(&mut app, Action::DeleteInit);
update(&mut app, Action::DeleteCancel); update(&mut app, Action::DeleteCancel);
if let Screen::Main(m) = &app.screen { if let Screen::Main(m) = &app.screen {
assert!(m.diary.delete_pending.is_none()); assert!(m.diary.delete_pending.is_none());
} else { panic!(); } } else {
panic!();
}
} }
#[test] #[test]
@@ -995,12 +1283,20 @@ mod tests {
let mut app = main_app(); let mut app = main_app();
let entry = diary_entry(); let entry = diary_entry();
let review_id = entry.review.id; let review_id = entry.review.id;
update(&mut app, Action::DiaryLoaded { entries: vec![entry], total: 1 }); update(
&mut app,
Action::DiaryLoaded {
entries: vec![entry],
total: 1,
},
);
update(&mut app, Action::ReviewDeleted(review_id)); update(&mut app, Action::ReviewDeleted(review_id));
if let Screen::Main(m) = &app.screen { if let Screen::Main(m) = &app.screen {
assert!(m.diary.entries.is_empty()); assert!(m.diary.entries.is_empty());
assert_eq!(m.diary.total, 0); assert_eq!(m.diary.total, 0);
} else { panic!(); } } else {
panic!();
}
} }
// ── Add Review ──────────────────────────────────────────────────────────── // ── Add Review ────────────────────────────────────────────────────────────
@@ -1008,17 +1304,27 @@ mod tests {
#[test] #[test]
fn rating_up_increments_rating() { fn rating_up_increments_rating() {
let mut app = main_app(); let mut app = main_app();
if let Screen::Main(m) = &mut app.screen { m.tab = Tab::AddReview; m.add_review.rating = 3; } if let Screen::Main(m) = &mut app.screen {
m.tab = Tab::AddReview;
m.add_review.rating = 3;
}
update(&mut app, Action::RatingUp); update(&mut app, Action::RatingUp);
if let Screen::Main(m) = &app.screen { assert_eq!(m.add_review.rating, 4); } if let Screen::Main(m) = &app.screen {
assert_eq!(m.add_review.rating, 4);
}
} }
#[test] #[test]
fn rating_clamps_at_5() { fn rating_clamps_at_5() {
let mut app = main_app(); let mut app = main_app();
if let Screen::Main(m) = &mut app.screen { m.tab = Tab::AddReview; m.add_review.rating = 5; } if let Screen::Main(m) = &mut app.screen {
m.tab = Tab::AddReview;
m.add_review.rating = 5;
}
update(&mut app, Action::RatingUp); update(&mut app, Action::RatingUp);
if let Screen::Main(m) = &app.screen { assert_eq!(m.add_review.rating, 5); } if let Screen::Main(m) = &app.screen {
assert_eq!(m.add_review.rating, 5);
}
} }
#[test] #[test]
@@ -1054,8 +1360,7 @@ mod tests {
if let Screen::Main(m) = &mut app.screen { if let Screen::Main(m) = &mut app.screen {
m.tab = Tab::BulkImport; m.tab = Tab::BulkImport;
m.bulk_import.stage = BulkImportStage::Preview; m.bulk_import.stage = BulkImportStage::Preview;
m.bulk_import.parsed = vec![ m.bulk_import.parsed = vec![ParsedRow {
ParsedRow {
row: 2, row: 2,
result: Ok(LogReviewRequest { result: Ok(LogReviewRequest {
external_metadata_id: None, external_metadata_id: None,
@@ -1065,8 +1370,7 @@ mod tests {
comment: None, comment: None,
watched_at: "1999-03-31T00:00:00".into(), watched_at: "1999-03-31T00:00:00".into(),
}), }),
}, }];
];
} }
let cmds = update(&mut app, Action::BulkImportAll); let cmds = update(&mut app, Action::BulkImportAll);
assert!(cmds.iter().any(|c| matches!(c, Command::ImportNext(0)))); assert!(cmds.iter().any(|c| matches!(c, Command::ImportNext(0))));
@@ -1079,12 +1383,32 @@ mod tests {
m.tab = Tab::BulkImport; m.tab = Tab::BulkImport;
m.bulk_import.stage = BulkImportStage::Importing { done: 0 }; m.bulk_import.stage = BulkImportStage::Importing { done: 0 };
m.bulk_import.valid_requests = vec![ m.bulk_import.valid_requests = vec![
LogReviewRequest { external_metadata_id: None, manual_title: Some("A".into()), manual_release_year: None, rating: 5, comment: None, watched_at: "2024-01-01T00:00:00".into() }, LogReviewRequest {
LogReviewRequest { external_metadata_id: None, manual_title: Some("B".into()), manual_release_year: None, rating: 4, comment: None, watched_at: "2024-01-02T00:00:00".into() }, external_metadata_id: None,
manual_title: Some("A".into()),
manual_release_year: None,
rating: 5,
comment: None,
watched_at: "2024-01-01T00:00:00".into(),
},
LogReviewRequest {
external_metadata_id: None,
manual_title: Some("B".into()),
manual_release_year: None,
rating: 4,
comment: None,
watched_at: "2024-01-02T00:00:00".into(),
},
]; ];
m.bulk_import.results = vec![None, None]; m.bulk_import.results = vec![None, None];
} }
let cmds = update(&mut app, Action::BulkItemDone { index: 0, error: None }); let cmds = update(
&mut app,
Action::BulkItemDone {
index: 0,
error: None,
},
);
assert!(cmds.iter().any(|c| matches!(c, Command::ImportNext(1)))); assert!(cmds.iter().any(|c| matches!(c, Command::ImportNext(1))));
} }
@@ -1094,12 +1418,23 @@ mod tests {
if let Screen::Main(m) = &mut app.screen { if let Screen::Main(m) = &mut app.screen {
m.tab = Tab::BulkImport; m.tab = Tab::BulkImport;
m.bulk_import.stage = BulkImportStage::Importing { done: 0 }; m.bulk_import.stage = BulkImportStage::Importing { done: 0 };
m.bulk_import.valid_requests = vec![ m.bulk_import.valid_requests = vec![LogReviewRequest {
LogReviewRequest { external_metadata_id: None, manual_title: Some("A".into()), manual_release_year: None, rating: 5, comment: None, watched_at: "2024-01-01T00:00:00".into() }, external_metadata_id: None,
]; manual_title: Some("A".into()),
manual_release_year: None,
rating: 5,
comment: None,
watched_at: "2024-01-01T00:00:00".into(),
}];
m.bulk_import.results = vec![None]; m.bulk_import.results = vec![None];
} }
let cmds = update(&mut app, Action::BulkItemDone { index: 0, error: None }); let cmds = update(
&mut app,
Action::BulkItemDone {
index: 0,
error: None,
},
);
assert!(cmds.is_empty()); assert!(cmds.is_empty());
if let Screen::Main(m) = &app.screen { if let Screen::Main(m) = &app.screen {
assert!(matches!(m.bulk_import.stage, BulkImportStage::Done)); assert!(matches!(m.bulk_import.stage, BulkImportStage::Done));
@@ -1117,7 +1452,10 @@ mod tests {
m.settings.api_url = "http://new-server:8080".into(); m.settings.api_url = "http://new-server:8080".into();
} }
let cmds = update(&mut app, Action::SettingsSave); let cmds = update(&mut app, Action::SettingsSave);
assert!(cmds.iter().any(|c| matches!(c, Command::SaveConfig(url) if url.contains("8080")))); assert!(
cmds.iter()
.any(|c| matches!(c, Command::SaveConfig(url) if url.contains("8080")))
);
} }
#[test] #[test]
@@ -1136,7 +1474,9 @@ mod tests {
update(&mut app, Action::AuthOk("tok".into())); update(&mut app, Action::AuthOk("tok".into()));
if let Screen::Main(m) = &app.screen { if let Screen::Main(m) = &app.screen {
assert_eq!(m.settings.api_url, "http://test-server:9000"); assert_eq!(m.settings.api_url, "http://test-server:9000");
} else { panic!("expected Main"); } } else {
panic!("expected Main");
}
} }
// ── parse_csv ───────────────────────────────────────────────────────────── // ── parse_csv ─────────────────────────────────────────────────────────────

View File

@@ -123,7 +123,10 @@ impl ApiClient {
let resp = self let resp = self
.http .http
.post(format!("{}/api/auth/login", self.url())) .post(format!("{}/api/auth/login", self.url()))
.json(&LoginRequest { email: email.into(), password: password.into() }) .json(&LoginRequest {
email: email.into(),
password: password.into(),
})
.send() .send()
.await?; .await?;
Ok(check_status(resp).await?.json().await?) Ok(check_status(resp).await?.json().await?)
@@ -159,11 +162,7 @@ impl ApiClient {
Ok(check_status(resp).await?.json().await?) Ok(check_status(resp).await?.json().await?)
} }
pub async fn create_review( pub async fn create_review(&self, token: &str, req: &LogReviewRequest) -> Result<(), ApiError> {
&self,
token: &str,
req: &LogReviewRequest,
) -> Result<(), ApiError> {
let resp = self let resp = self
.http .http
.post(format!("{}/api/reviews", self.url())) .post(format!("{}/api/reviews", self.url()))

View File

@@ -83,7 +83,9 @@ mod tests {
#[test] #[test]
fn config_roundtrip() { fn config_roundtrip() {
let config = Config { api_url: "http://localhost:3000".into() }; let config = Config {
api_url: "http://localhost:3000".into(),
};
let json = serde_json::to_string(&config).unwrap(); let json = serde_json::to_string(&config).unwrap();
let decoded: Config = serde_json::from_str(&json).unwrap(); let decoded: Config = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.api_url, "http://localhost:3000"); assert_eq!(decoded.api_url, "http://localhost:3000");

View File

@@ -4,9 +4,7 @@ use tokio::sync::mpsc;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyModifiers}; use ratatui::crossterm::event::{self, Event, KeyCode, KeyModifiers};
use tui::app::{ use tui::app::{self, Action, App, BulkImportStage, Command, Screen, SettingsField, Tab};
self, Action, App, BulkImportStage, Command, Screen, SettingsField, Tab,
};
use tui::client::ApiClient; use tui::client::ApiClient;
use tui::config::Config; use tui::config::Config;
@@ -29,9 +27,14 @@ async fn run() -> anyhow::Result<()> {
} }
} }
let initial_url = config.as_ref().map(|c| c.api_url.as_str()).unwrap_or("http://localhost:3000"); let initial_url = config
.as_ref()
.map(|c| c.api_url.as_str())
.unwrap_or("http://localhost:3000");
let client = Arc::new(ApiClient::new(initial_url)); let client = Arc::new(ApiClient::new(initial_url));
let saved_token = tokio::task::spawn_blocking(Config::load_token).await.unwrap_or(None); let saved_token = tokio::task::spawn_blocking(Config::load_token)
.await
.unwrap_or(None);
let mut app = App::new(config, saved_token.clone()); let mut app = App::new(config, saved_token.clone());
let (tx, mut rx) = mpsc::channel::<Action>(64); let (tx, mut rx) = mpsc::channel::<Action>(64);
@@ -45,7 +48,10 @@ async fn run() -> anyhow::Result<()> {
let tx2 = tx.clone(); let tx2 = tx.clone();
tokio::spawn(async move { tokio::spawn(async move {
let action = match c.get_diary(&t, 0, 20).await { let action = match c.get_diary(&t, 0, 20).await {
Ok(r) => Action::DiaryLoaded { entries: r.items, total: r.total_count }, Ok(r) => Action::DiaryLoaded {
entries: r.items,
total: r.total_count,
},
Err(e) => Action::DiaryLoadFailed(e.to_string()), Err(e) => Action::DiaryLoadFailed(e.to_string()),
}; };
let _ = tx2.send(action).await; let _ = tx2.send(action).await;
@@ -84,7 +90,8 @@ async fn run() -> anyhow::Result<()> {
} }
} }
Ok::<(), anyhow::Error>(()) Ok::<(), anyhow::Error>(())
}.await; }
.await;
ratatui::restore(); ratatui::restore();
result result
@@ -95,11 +102,15 @@ async fn run() -> anyhow::Result<()> {
fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::Sender<Action>) { fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::Sender<Action>) {
match cmd { match cmd {
Command::SaveConfig(url) => { Command::SaveConfig(url) => {
let config = Config { api_url: url.clone() }; let config = Config {
api_url: url.clone(),
};
if let Err(e) = config.save() { if let Err(e) = config.save() {
let tx2 = tx.clone(); let tx2 = tx.clone();
let msg = format!("Failed to save config: {e}"); let msg = format!("Failed to save config: {e}");
tokio::spawn(async move { let _ = tx2.send(Action::DiaryLoadFailed(msg)).await; }); tokio::spawn(async move {
let _ = tx2.send(Action::DiaryLoadFailed(msg)).await;
});
} }
client.update_url(&url); client.update_url(&url);
} }
@@ -136,12 +147,17 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
} }
Command::LoadDiary { offset } => { Command::LoadDiary { offset } => {
let Some(token) = app.token.clone() else { return }; let Some(token) = app.token.clone() else {
return;
};
let c = client.clone(); let c = client.clone();
let tx = tx.clone(); let tx = tx.clone();
tokio::spawn(async move { tokio::spawn(async move {
let action = match c.get_diary(&token, offset, 20).await { let action = match c.get_diary(&token, offset, 20).await {
Ok(r) => Action::DiaryLoaded { entries: r.items, total: r.total_count }, Ok(r) => Action::DiaryLoaded {
entries: r.items,
total: r.total_count,
},
Err(e) => Action::DiaryLoadFailed(e.to_string()), Err(e) => Action::DiaryLoadFailed(e.to_string()),
}; };
let _ = tx.send(action).await; let _ = tx.send(action).await;
@@ -149,7 +165,9 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
} }
Command::LoadHistory { movie_id } => { Command::LoadHistory { movie_id } => {
let Some(token) = app.token.clone() else { return }; let Some(token) = app.token.clone() else {
return;
};
let c = client.clone(); let c = client.clone();
let tx = tx.clone(); let tx = tx.clone();
tokio::spawn(async move { tokio::spawn(async move {
@@ -162,7 +180,9 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
} }
Command::CreateReview(req) => { Command::CreateReview(req) => {
let Some(token) = app.token.clone() else { return }; let Some(token) = app.token.clone() else {
return;
};
let c = client.clone(); let c = client.clone();
let tx = tx.clone(); let tx = tx.clone();
tokio::spawn(async move { tokio::spawn(async move {
@@ -175,7 +195,9 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
} }
Command::DeleteReview(id) => { Command::DeleteReview(id) => {
let Some(token) = app.token.clone() else { return }; let Some(token) = app.token.clone() else {
return;
};
let c = client.clone(); let c = client.clone();
let tx = tx.clone(); let tx = tx.clone();
tokio::spawn(async move { tokio::spawn(async move {
@@ -188,7 +210,9 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
} }
Command::ImportNext(index) => { Command::ImportNext(index) => {
let Some(token) = app.token.clone() else { return }; let Some(token) = app.token.clone() else {
return;
};
let req = match &app.screen { let req = match &app.screen {
Screen::Main(m) => match m.bulk_import.valid_requests.get(index) { Screen::Main(m) => match m.bulk_import.valid_requests.get(index) {
Some(r) => r.clone(), Some(r) => r.clone(),
@@ -199,7 +223,11 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
let c = client.clone(); let c = client.clone();
let tx = tx.clone(); let tx = tx.clone();
tokio::spawn(async move { tokio::spawn(async move {
let error = c.create_review(&token, &req).await.err().map(|e| e.to_string()); let error = c
.create_review(&token, &req)
.await
.err()
.map(|e| e.to_string());
let _ = tx.send(Action::BulkItemDone { index, error }).await; let _ = tx.send(Action::BulkItemDone { index, error }).await;
}); });
} }
@@ -248,8 +276,12 @@ fn key_to_action(app: &App, key: ratatui::crossterm::event::KeyEvent) -> Option<
KeyCode::Down | KeyCode::Char('j') => Some(Action::ScrollDown), KeyCode::Down | KeyCode::Char('j') => Some(Action::ScrollDown),
KeyCode::Enter => Some(Action::OpenHistory), KeyCode::Enter => Some(Action::OpenHistory),
KeyCode::Char('d') => Some(Action::DeleteInit), KeyCode::Char('d') => Some(Action::DeleteInit),
KeyCode::Char('y') if m.diary.delete_pending.is_some() => Some(Action::DeleteConfirm), KeyCode::Char('y') if m.diary.delete_pending.is_some() => {
KeyCode::Char('n') if m.diary.delete_pending.is_some() => Some(Action::DeleteCancel), Some(Action::DeleteConfirm)
}
KeyCode::Char('n') if m.diary.delete_pending.is_some() => {
Some(Action::DeleteCancel)
}
KeyCode::Esc => Some(Action::Escape), KeyCode::Esc => Some(Action::Escape),
KeyCode::Char('q') => Some(Action::Quit), KeyCode::Char('q') => Some(Action::Quit),
KeyCode::Tab => Some(Action::TabNext), KeyCode::Tab => Some(Action::TabNext),

View File

@@ -99,8 +99,20 @@ fn draw_login(frame: &mut Frame, area: Rect, state: &LoginState) {
.split(popup); .split(popup);
let pass_masked = "*".repeat(state.password.len()); let pass_masked = "*".repeat(state.password.len());
render_input(frame, rows[1], "Email", &state.email, state.focused == LoginField::Email); render_input(
render_input(frame, rows[3], "Password", &pass_masked, state.focused == LoginField::Password); frame,
rows[1],
"Email",
&state.email,
state.focused == LoginField::Email,
);
render_input(
frame,
rows[3],
"Password",
&pass_masked,
state.focused == LoginField::Password,
);
frame.render_widget( frame.render_widget(
Paragraph::new("Tab: next field Enter: login").alignment(Alignment::Center), Paragraph::new("Tab: next field Enter: login").alignment(Alignment::Center),
rows[4], rows[4],
@@ -175,9 +187,16 @@ fn draw_diary(frame: &mut Frame, area: Rect, state: &DiaryState) {
let can_load_prev = state.offset > 0; let can_load_prev = state.offset > 0;
let page = state.offset / 20 + 1; let page = state.offset / 20 + 1;
let total_pages = state.total.div_ceil(20).max(1); let total_pages = state.total.div_ceil(20).max(1);
let mut title = format!(" Diary ({} entries, page {}/{}) ", state.total, page, total_pages); let mut title = format!(
if can_load_prev { title.push_str("[b: prev] "); } " Diary ({} entries, page {}/{}) ",
if can_load_more { title.push_str("[m: next] "); } state.total, page, total_pages
);
if can_load_prev {
title.push_str("[b: prev] ");
}
if can_load_more {
title.push_str("[m: next] ");
}
let mut list_state = ListState::default(); let mut list_state = ListState::default();
list_state.select(Some(state.selected)); list_state.select(Some(state.selected));
let list = List::new(items).block(Block::default().title(title).borders(Borders::ALL)); let list = List::new(items).block(Block::default().title(title).borders(Borders::ALL));
@@ -273,23 +292,61 @@ fn draw_add_review(frame: &mut Frame, area: Rect, state: &AddReviewState) {
]) ])
.split(inner); .split(inner);
render_input(frame, rows[0], "External ID (TMDB/OMDB)", &state.external_id, state.focused == AddReviewField::ExternalId); render_input(
render_input(frame, rows[1], "Title", &state.title, state.focused == AddReviewField::Title); frame,
render_input(frame, rows[2], "Year", &state.year, state.focused == AddReviewField::Year); rows[0],
"External ID (TMDB/OMDB)",
&state.external_id,
state.focused == AddReviewField::ExternalId,
);
render_input(
frame,
rows[1],
"Title",
&state.title,
state.focused == AddReviewField::Title,
);
render_input(
frame,
rows[2],
"Year",
&state.year,
state.focused == AddReviewField::Year,
);
let rating_active = state.focused == AddReviewField::Rating; let rating_active = state.focused == AddReviewField::Rating;
frame.render_widget( frame.render_widget(
Paragraph::new(format!("{} \u{2190} \u{2192} to adjust", stars(state.rating))).block( Paragraph::new(format!(
"{} \u{2190} \u{2192} to adjust",
stars(state.rating)
))
.block(
Block::default() Block::default()
.title("Rating (0-5)") .title("Rating (0-5)")
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(if rating_active { Style::default().fg(Color::Yellow) } else { Style::default() }), .border_style(if rating_active {
Style::default().fg(Color::Yellow)
} else {
Style::default()
}),
), ),
rows[3], rows[3],
); );
render_input(frame, rows[4], "Watched at (YYYY-MM-DDTHH:MM:SS)", &state.watched_at, state.focused == AddReviewField::WatchedAt); render_input(
render_input(frame, rows[5], "Comment (optional)", &state.comment, state.focused == AddReviewField::Comment); frame,
rows[4],
"Watched at (YYYY-MM-DDTHH:MM:SS)",
&state.watched_at,
state.focused == AddReviewField::WatchedAt,
);
render_input(
frame,
rows[5],
"Comment (optional)",
&state.comment,
state.focused == AddReviewField::Comment,
);
let submit_style = if state.focused == AddReviewField::Submit { let submit_style = if state.focused == AddReviewField::Submit {
Style::default() Style::default()
@@ -507,7 +564,13 @@ fn draw_settings(frame: &mut Frame, area: Rect, state: &SettingsState) {
]) ])
.split(inner); .split(inner);
render_input(frame, rows[0], "API URL", &state.api_url, state.focused == SettingsField::ApiUrl); render_input(
frame,
rows[0],
"API URL",
&state.api_url,
state.focused == SettingsField::ApiUrl,
);
let save_style = if state.focused == SettingsField::Save { let save_style = if state.focused == SettingsField::Save {
Style::default() Style::default()
@@ -555,11 +618,22 @@ fn draw_status_bar(frame: &mut Frame, area: Rect, status: Option<&StatusMsg>, lo
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
fn render_input(frame: &mut Frame, area: Rect, title: &str, value: &str, active: bool) { fn render_input(frame: &mut Frame, area: Rect, title: &str, value: &str, active: bool) {
let text = if active { format!("{value}_") } else { value.to_string() }; let text = if active {
let border_style = if active { Style::default().fg(Color::Yellow) } else { Style::default() }; format!("{value}_")
} else {
value.to_string()
};
let border_style = if active {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
frame.render_widget( frame.render_widget(
Paragraph::new(text).block( Paragraph::new(text).block(
Block::default().title(title).borders(Borders::ALL).border_style(border_style), Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(border_style),
), ),
area, area,
); );