export feature
This commit is contained in:
13
Cargo.lock
generated
13
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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<()>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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::{
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
14
crates/adapters/export/Cargo.toml
Normal file
14
crates/adapters/export/Cargo.toml
Normal 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 }
|
||||||
225
crates/adapters/export/src/lib.rs
Normal file
225
crates/adapters/export/src/lib.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
url: row.get("url"),
|
.into_iter()
|
||||||
handle: row.get("handle"),
|
.map(|row| RemoteActor {
|
||||||
inbox_url: row.get("inbox_url"),
|
url: row.get("url"),
|
||||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
handle: row.get("handle"),
|
||||||
display_name: row.try_get("display_name").ok().flatten(),
|
inbox_url: row.get("inbox_url"),
|
||||||
}).collect())
|
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||||
|
display_name: row.try_get("display_name").ok().flatten(),
|
||||||
|
})
|
||||||
|
.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 =
|
||||||
.bind(&uid)
|
sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = ?")
|
||||||
.fetch_optional(&self.pool)
|
.bind(&uid)
|
||||||
.await?;
|
.fetch_optional(&self.pool)
|
||||||
|
.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
|
||||||
url: row.get("remote_actor_url"),
|
.into_iter()
|
||||||
handle: row.try_get("handle").unwrap_or_default(),
|
.map(|row| RemoteActor {
|
||||||
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
|
url: row.get("remote_actor_url"),
|
||||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
handle: row.try_get("handle").unwrap_or_default(),
|
||||||
display_name: row.try_get("display_name").ok().flatten(),
|
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
|
||||||
}).collect())
|
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||||
|
display_name: row.try_get("display_name").ok().flatten(),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_following_status(
|
async fn update_following_status(
|
||||||
|
|||||||
@@ -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,13 +164,10 @@ 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 = ?",
|
.fetch_one(&self.pool)
|
||||||
user_id
|
.await
|
||||||
)
|
.map_err(Self::map_err)
|
||||||
.fetch_one(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(Self::map_err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_user_diary_rows_by_watched(
|
async fn fetch_user_diary_rows_by_watched(
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,8 +59,15 @@ 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| {
|
||||||
.transpose()
|
Self::row_to_user(
|
||||||
|
r.id.unwrap_or_default(),
|
||||||
|
r.email,
|
||||||
|
r.username,
|
||||||
|
r.password_hash,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||||
@@ -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| {
|
||||||
.transpose()
|
Self::row_to_user(
|
||||||
|
r.id.unwrap_or_default(),
|
||||||
|
r.email,
|
||||||
|
r.username,
|
||||||
|
r.password_hash,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.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,8 +132,15 @@ 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| {
|
||||||
.transpose()
|
Self::row_to_user(
|
||||||
|
r.id.unwrap_or_default(),
|
||||||
|
r.email,
|
||||||
|
r.username,
|
||||||
|
r.password_hash,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
HeatmapCell {
|
.zip(counts.iter())
|
||||||
month_label: label.to_string(),
|
.map(|((_, label), &count)| {
|
||||||
count,
|
let alpha = if count == 0 {
|
||||||
alpha,
|
0.05
|
||||||
}
|
} else {
|
||||||
}).collect()
|
0.15 + 0.75 * (count as f64 / max as f64)
|
||||||
|
};
|
||||||
|
HeatmapCell {
|
||||||
|
month_label: label.to_string(),
|
||||||
|
count,
|
||||||
|
alpha,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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,21 +326,30 @@ 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
|
||||||
let email = u.email();
|
.users
|
||||||
let display_name = email.split('@').next().unwrap_or(email).to_string();
|
.iter()
|
||||||
let initial = display_name.chars().next().unwrap_or('?').to_ascii_uppercase();
|
.map(|u| {
|
||||||
let avg_rating_display = u.avg_rating
|
let email = u.email();
|
||||||
.map(|r| format!("{:.1}", r))
|
let display_name = email.split('@').next().unwrap_or(email).to_string();
|
||||||
.unwrap_or_else(|| "—".to_string());
|
let initial = display_name
|
||||||
UserSummaryView {
|
.chars()
|
||||||
user_id: u.user_id.value(),
|
.next()
|
||||||
display_name,
|
.unwrap_or('?')
|
||||||
initial,
|
.to_ascii_uppercase();
|
||||||
avg_rating_display,
|
let avg_rating_display = u
|
||||||
total_movies: u.total_movies,
|
.avg_rating
|
||||||
}
|
.map(|r| format!("{:.1}", r))
|
||||||
}).collect();
|
.unwrap_or_else(|| "—".to_string());
|
||||||
|
UserSummaryView {
|
||||||
|
user_id: u.user_id.value(),
|
||||||
|
display_name,
|
||||||
|
initial,
|
||||||
|
avg_rating_display,
|
||||||
|
total_movies: u.total_movies,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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()
|
||||||
bar_height_px: bar_height_px(r.avg_rating),
|
.unwrap_or(&data.profile_user_email)
|
||||||
rating: r,
|
.to_string();
|
||||||
}).collect())
|
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),
|
||||||
|
rating: r,
|
||||||
|
})
|
||||||
|
.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
|
||||||
handle: a.handle,
|
.pending_followers
|
||||||
url: a.url,
|
.into_iter()
|
||||||
display_name: a.display_name,
|
.map(|a| RemoteActorData {
|
||||||
}).collect(),
|
handle: a.handle,
|
||||||
|
url: a.url,
|
||||||
|
display_name: a.display_name,
|
||||||
|
})
|
||||||
|
.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
|
||||||
handle: a.handle,
|
.actors
|
||||||
display_name: a.display_name,
|
.into_iter()
|
||||||
url: a.url,
|
.map(|a| RemoteActorData {
|
||||||
}).collect(),
|
handle: a.handle,
|
||||||
|
display_name: a.display_name,
|
||||||
|
url: a.url,
|
||||||
|
})
|
||||||
|
.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
|
||||||
handle: a.handle,
|
.actors
|
||||||
display_name: a.display_name,
|
.into_iter()
|
||||||
url: a.url,
|
.map(|a| RemoteActorData {
|
||||||
}).collect(),
|
handle: a.handle,
|
||||||
|
display_name: a.display_name,
|
||||||
|
url: a.url,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
error: data.error,
|
error: data.error,
|
||||||
}
|
}
|
||||||
.render()
|
.render()
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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![])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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(),
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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()))
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user